diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 45e78bbcf..3fc8b3765 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -206,6 +206,9 @@ jobs: - name: Falsify the acceptance gate run: python3 scripts/acceptance/gate.py --self-test + - name: Test isolated acceptance runtime consumers + run: python3 -m unittest discover -s scripts/acceptance -p test_runtime_fixture.py + # A green suite step is not a verdict, and this makes that a rule rather # than a reading: every suite-run step must say the verdict comes from the # gate step, keep its exit-code capture, and report a nonzero result as a @@ -954,14 +957,23 @@ jobs: set -uo pipefail mkdir -p acceptance rc=0 - docker run --rm --memory=2g --workdir /w \ + docker run --rm --network=host --memory=2g --workdir /w \ -v "$PWD:/w" \ -e KIN_ACCEPTANCE_LABEL \ ubuntu:24.04 /bin/bash -c ' set -e export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq --no-install-recommends git python3 + # A runner whose nested containers resolve names intermittently + # lets apt-get update finish with warnings and no index, and the + # install then finds nothing. The suite needs both tools, so the + # pair is retried as one rather than the update alone. + for attempt in 1 2 3 4 5; do + if apt-get update -qq && apt-get install -y -qq --no-install-recommends git python3; then + break + fi + if [ "$attempt" -eq 5 ]; then exit 1; fi + sleep 15 + done git config --global user.name "Kin CI" git config --global user.email "ci@kinlab.ai" exec python3 scripts/acceptance/memory_pressure_refusal.py \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecf75f0b4..fbaf8591b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,8 @@ jobs: ./scripts/advisory-sweep.test.mjs \ ./scripts/verify-capability-proof.test.mjs \ ./scripts/verify-npm-attestation.test.mjs \ + ./scripts/verify-native-release-provenance.test.mjs \ + ./scripts/docker-entrypoint-runtime.test.mjs \ ./scripts/review-ui.test.cjs \ ./scripts/workspace-ui.test.cjs \ ./scripts/workspace-graph.test.cjs @@ -652,7 +654,7 @@ jobs: --locked --target x86_64-pc-windows-msvc \ -p kin-cli --no-default-features --test repository_merge_resolution run_required_target "editor setup guidance" \ - "full_setup_output_recommends_install_only_when_the_editor_extension_is_missing" \ + "development_editor_setup_refuses_with_or_without_an_installed_extension" \ --locked --target x86_64-pc-windows-msvc \ -p kin-cli --no-default-features --test setup_editor_guidance # The daemon's own library tests, here rather than beside the other @@ -872,6 +874,40 @@ jobs: # admission change that moves the refusal turns a pull request red # instead of first turning a release commit red here. Daemon reopen # assertions live in the Linux daemon smoke until the Windows ports land. + - name: Prove Windows precommit rejection restores the original bundle + shell: bash + timeout-minutes: 15 + env: + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_RELEASE_LTO: 'off' + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + run: | + set -euo pipefail + export RUST_BACKTRACE=1 + source ./scripts/windows-authority-legs.sh + test_name="commands::update::tests::windows_before_commit_rejection_restores_original_bundle" + unit="$(resolve_leg_unit --locked --release --target x86_64-pc-windows-msvc -p kin-cli --lib)" + listing="$(_leg_listing "$unit")" + matches="$(printf '%s\n' "$listing" | awk -v expected="$test_name: test" '$0 == expected { count += 1 } END { print count + 0 }')" + if [ "$matches" -ne 1 ]; then + echo "::error::Windows rollback proof requires exactly one listed native test, found $matches" + exit 1 + fi + proof_log="$RUNNER_TEMP/windows-precommit-rollback.log" + _leg_exec "$unit" "$test_name" --exact --nocapture --test-threads=1 --quiet | tee "$proof_log" + tr -d '\r' < "$proof_log" | grep -Fx 'WINDOWS_PRECOMMIT_ROLLBACK_CONTROL_PASS' + tr -d '\r' < "$proof_log" | grep -F '1 passed; 0 failed; 0 ignored' + + - name: Test native worker cleanup ownership (PowerShell 7) + shell: pwsh + timeout-minutes: 10 + run: ./scripts/test-windows-worker-cleanup.ps1 -KinCliPath (Join-Path $pwd 'target/x86_64-pc-windows-msvc/release/kin.exe') -KinDaemonPath (Join-Path $pwd 'target/x86_64-pc-windows-msvc/release/kin-daemon.exe') -OutputRoot (Join-Path $env:RUNNER_TEMP ('kin-worker-cleanup-' + [Guid]::NewGuid().ToString('N'))) + + - name: Test native worker cleanup ownership (Windows PowerShell 5.1) + shell: powershell + timeout-minutes: 10 + run: ./scripts/test-windows-worker-cleanup.ps1 -KinCliPath (Join-Path $pwd 'target/x86_64-pc-windows-msvc/release/kin.exe') -KinDaemonPath (Join-Path $pwd 'target/x86_64-pc-windows-msvc/release/kin-daemon.exe') -OutputRoot (Join-Path $env:RUNNER_TEMP ('kin-worker-cleanup-' + [Guid]::NewGuid().ToString('N'))) + - name: Assert the Windows admission contract shell: bash run: | diff --git a/.github/workflows/install-proof-canary.yml b/.github/workflows/install-proof-canary.yml index 56e343a62..30ba0aaa0 100644 --- a/.github/workflows/install-proof-canary.yml +++ b/.github/workflows/install-proof-canary.yml @@ -100,7 +100,7 @@ jobs: # first capture still sees what a new user sees, and `kin embed` is what # selects the built-in provider and asks for the model. - name: Build kin + kin-daemon from this branch - run: cargo build --bin kin --bin kin-daemon --locked --features kin-cli/embeddings,kin-daemon/embeddings + run: cargo build --release --bin kin --bin kin-daemon --locked --features kin-cli/embeddings,kin-daemon/embeddings # The public installer puts both binaries in ~/.kin/bin, and several health # checks resolve the launcher by that path rather than by whatever happens @@ -110,8 +110,8 @@ jobs: run: | set -euo pipefail mkdir -p "$HOME/.kin/bin" - install -m 0755 target/debug/kin "$HOME/.kin/bin/kin" - install -m 0755 target/debug/kin-daemon "$HOME/.kin/bin/kin-daemon" + install -m 0755 target/release/kin "$HOME/.kin/bin/kin" + install -m 0755 target/release/kin-daemon "$HOME/.kin/bin/kin-daemon" printf '%s\n' "$HOME/.kin/bin" >> "$GITHUB_PATH" - name: Drive a real first run and capture what the proof reads diff --git a/.github/workflows/install-proof.yml b/.github/workflows/install-proof.yml index 7db1788c3..96ae75b0c 100644 --- a/.github/workflows/install-proof.yml +++ b/.github/workflows/install-proof.yml @@ -525,6 +525,7 @@ jobs: # Absolute, because each fixture's Kin home is read from inside the # fixture. scratch="$(cd "$scratch" && pwd)" + trap 'MSYS_NO_PATHCONV=1 taskkill.exe /F /IM kin-daemon.exe >/dev/null 2>&1 || true; rm -rf "$scratch"/*/kin-home/bin 2>/dev/null || true' EXIT # Every assertion reports and the step exits once at the end. One # Windows round is expensive, so stopping at the first failure would @@ -598,6 +599,12 @@ jobs: shift 2 local home home="$(fixture_home "$dir")" + if [ ! -x "$home/bin/kin.exe" ]; then + local installed_bin + installed_bin="$(dirname "$(command -v kin)")" + mkdir -p "$home/bin" + cp "$installed_bin/kin.exe" "$installed_bin/kin-daemon.exe" "$home/bin/" + fi if command -v cygpath > /dev/null 2>&1; then home="$(cygpath -w "$home")" fi diff --git a/.github/workflows/rc-build.yml b/.github/workflows/rc-build.yml index 3bc6b6341..16b2203fd 100644 --- a/.github/workflows/rc-build.yml +++ b/.github/workflows/rc-build.yml @@ -91,7 +91,10 @@ jobs: build: name: RC Build (${{ matrix.artifact }}) if: ${{ github.event_name != 'merge_group' || github.event.merge_group.base_ref == 'refs/heads/main' }} - runs-on: ${{ matrix.os }} + # An explicit outage switch selects only the reviewed native hosted row. + # Unset or false keeps the measured Depot routes. It changes no build or + # proof rule; slower candidates can use this workflow's 120-minute dispatch. + runs-on: ${{ vars.KIN_RC_USE_GITHUB_RUNNERS == 'true' && matrix.github_os || matrix.os }} # A ceiling on a build that stops making progress, not a queue bound. In a # merge group it is 60: the whole run has to finish inside the merge # queue's 90-minute check window. The Windows leg measured 31 to 49 @@ -137,18 +140,22 @@ jobs: target: x86_64-unknown-linux-musl artifact: kin-linux-x86_64 cross: false + github_os: ubuntu-latest - os: ubuntu-24.04-arm target: aarch64-unknown-linux-musl artifact: kin-linux-aarch64 cross: false + github_os: ubuntu-24.04-arm - os: depot-macos-26 target: x86_64-apple-darwin artifact: kin-macos-x86_64 cross: false + github_os: macos-15-intel - os: depot-macos-26 target: aarch64-apple-darwin artifact: kin-macos-aarch64 cross: false + github_os: macos-latest # The Windows archive builds on a 16-core Depot Windows runner, on # the same terms as the macOS rows above. On the standard 4-vCPU # windows-latest runner its build step took 39 to 49 minutes and set @@ -166,6 +173,7 @@ jobs: target: x86_64-pc-windows-msvc artifact: kin-windows-x86_64 cross: false + github_os: windows-latest steps: - name: Preserve tracked bytes on Windows @@ -899,7 +907,7 @@ jobs: # 16 removes the image from the critical path. This pretag producer can # wait for its pool without stranding a release tag; release.yml only # verifies and publishes the archive it uploads. - runs-on: depot-ubuntu-24.04-16 + runs-on: ${{ vars.KIN_RC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-16' }} # A ceiling for a stalled build, with the smokes still inside the merge # queue's 90-minute window. timeout-minutes: 45 diff --git a/.github/workflows/release-rehearsal-windows.yml b/.github/workflows/release-rehearsal-windows.yml index 7bbc81854..54745960d 100644 --- a/.github/workflows/release-rehearsal-windows.yml +++ b/.github/workflows/release-rehearsal-windows.yml @@ -79,20 +79,8 @@ jobs: SOURCE_PATH: ${{ inputs.source_path }} run: | $ErrorActionPreference = "Stop" - & "rehearsal-source/$env:SOURCE_PATH/scripts/test-windows-install-mirror.ps1" -InstallerPath "rehearsal-source/$env:SOURCE_PATH/scripts/install.ps1" -OutputRoot "$env:RUNNER_TEMP/rehearsal/mirror-boundary" - - name: Install the baseline with the candidate Windows installer - if: inputs.proof_mode == 'compatibility-baseline' - shell: pwsh - env: - SOURCE_PATH: ${{ inputs.source_path }} - KIN_VERSION: ${{ inputs.release_tag }} - KIN_NO_SETUP: '1' - run: | - $ErrorActionPreference = "Stop" - $env:KIN_VERSION = $env:KIN_VERSION.TrimStart('v') - & "rehearsal-source/$env:SOURCE_PATH/scripts/install.ps1" - "$HOME\.kin\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Rehearse stable baseline setup and Git author identity + & "rehearsal-source/$env:SOURCE_PATH/scripts/test-windows-install-mirror.ps1" -InstallerPath "rehearsal-source/$env:SOURCE_PATH/scripts/install.ps1" -OutputRoot "$env:RUNNER_TEMP/rehearsal/mirror-boundary" -RetainBaselineArchive + - name: Prepare verified legacy baseline and rehearse its setup if: inputs.proof_mode == 'compatibility-baseline' shell: bash env: @@ -100,6 +88,7 @@ jobs: run: | set -euo pipefail python "rehearsal-source/$SOURCE_PATH/scripts/release-rehearsal-compat.py" \ + --baseline-archive "$RUNNER_TEMP/rehearsal/mirror-boundary/kin-windows-x86_64.zip" \ --output "$RUNNER_TEMP/rehearsal/compatibility" - name: Public install (Unix) if: inputs.proof_mode == 'candidate-full' && (runner.os != 'Windows' && !inputs.local_artifact) @@ -495,6 +484,7 @@ jobs: scratch="kin-windows-admission" mkdir -p "$scratch" scratch="$(cd "$scratch" && pwd)" + trap 'MSYS_NO_PATHCONV=1 taskkill.exe /F /IM kin-daemon.exe >/dev/null 2>&1 || true; rm -rf "$scratch"/*/kin-home/bin 2>/dev/null || true' EXIT failures=0 fail() { @@ -546,6 +536,12 @@ jobs: shift 2 local home home="$(fixture_home "$dir")" + if [ ! -x "$home/bin/kin.exe" ]; then + local installed_bin + installed_bin="$(dirname "$(command -v kin)")" + mkdir -p "$home/bin" + cp "$installed_bin/kin.exe" "$installed_bin/kin-daemon.exe" "$home/bin/" + fi if command -v cygpath > /dev/null 2>&1; then home="$(cygpath -w "$home")" fi diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index 38abe9fa0..b2c7dd8e1 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -425,7 +425,7 @@ jobs: # the train refuses the branch it just generated. The authority # suite cross-checks this against the generator rather than trusting # the two to stay in step by hand. - allowed='^(Cargo\.toml|Cargo\.lock|fuzz/Cargo\.lock|CHANGELOG\.md|crates/kin-cli/Cargo\.toml|packages/(kin-mcp|kin|boundary-contracts)/package\.json|server\.json)$' + allowed='^(Cargo\.toml|Cargo\.lock|fuzz/Cargo\.lock|CHANGELOG\.md|crates/kin-cli/Cargo\.toml|packages/(kin-mcp|kin|boundary-contracts)/package\.json|packages/kin-mcp/package-lock\.json|server\.json)$' branch_paths="$(git diff \ --name-only \ "refs/remotes/origin/main...refs/remotes/origin/${BRANCH}")" @@ -450,7 +450,7 @@ jobs: fi done <<< "$branch_paths" git checkout -B "$BRANCH" "refs/remotes/origin/${BRANCH}" - # Only the seven generated files may differ from main. Resolve any + # Only generated files may differ from main. Resolve any # conflict in those stale generated bytes toward the existing # branch so the merge commit can be formed; the next command then # discards every one of those bytes and regenerates them from the @@ -470,6 +470,7 @@ jobs: CHANGELOG.md \ crates/kin-cli/Cargo.toml \ packages/kin-mcp/package.json \ + packages/kin-mcp/package-lock.json \ packages/kin/package.json \ packages/boundary-contracts/package.json \ server.json @@ -495,6 +496,7 @@ jobs: CHANGELOG.md \ crates/kin-cli/Cargo.toml \ packages/kin-mcp/package.json \ + packages/kin-mcp/package-lock.json \ packages/kin/package.json \ packages/boundary-contracts/package.json \ server.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c4a07911..e442a4e83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1854,13 +1854,14 @@ jobs: publish_npm_compatibility: name: Publish npm Compatibility Wrapper (@kinlab/kin-mcp) - needs: [publish, install_proof, npm_publish_preflight] + needs: [publish, install_proof, npm_publish_preflight, publish_npm_canonical] if: >- ${{ always() && needs.publish.result == 'success' && needs.install_proof.result == 'success' && needs.npm_publish_preflight.result == 'success' && + needs.publish_npm_canonical.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 20fe51984..7a9928770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `kin doctor` names every other copy of Kin this machine can run: each `kin`, `kin-daemon` + and `kin-mcp` on `PATH`, in Cargo's and Homebrew's directories, in npm global prefixes and + in `/usr/local/bin`, with what it is (the managed pair, a link to it, an npm launcher and its + version, a release that hands its commands to the managed pair, a separate older copy, a + development build) and the uninstall that removes it. A second row names every running + `kin-daemon` by the release it runs. +- `kin setup` asks about each copy it can remove, defaulting to keep it, and a run with no + terminal only names them. `kin setup doctor --fix --remove-other-copies` removes them all + with each channel's own uninstall. Neither ever touches a development build or the managed + Kin home, and `--fix` alone removes nothing, because `kin update` runs it unattended. + +### Fixed + +- `kin update` writes the installed release into the npm launcher's version stamp inside the + same transaction as the binaries, and a rollback puts the old stamp back byte for byte. + Every update used to leave the stamp naming the release it replaced, and launchers older + than September 2026 read the stamp as the installed version and could reinstall their own + older release over a newer one. Recovery and `kin doctor --fix` rewrite a stamp an older + updater left behind. An install without a stamp still gets none. +- The npm launcher replaces its version stamp by rename, so a reader never sees it empty or + half-written. + +## [0.8.7] - 2026-10-14 + ### Changed - The local workspace (`kin ui`) is rebuilt so that each page does one job @@ -61,6 +87,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 repository, opens one Kin already holds, or clones one, and shows the conversion's progress. - Coding agents found on this machine are connected to Kin from the app. +- A guided review of a change or a range names, on each entity an agent wrote + through Kin, the agent session that wrote it (`authored_by` on the step, + `authors` in the summary), so a review can go back to the agent that made + the change. Only the entity the agent's own operation named or created is + attributed: an edit that came along in the same change reads as made outside + Kin. The record is kept in the store and survives a daemon restart. +- A guided review also names an agent on the entities it edited with its own + tools, which is how most agents edit. `kin setup` now writes an edit hook + for Claude Code when it connects it: before and after each `Edit`, `Write` or + `MultiEdit` the paired hooks tell the repository's daemon which session made the + edit, and the daemon names that session on the entities whose content the + edit changed (`through: "client"` in `authored_by`). It names nobody unless + the reported edit is exactly what changed, so an edit that arrived mixed + with someone else's is left unattributed. The hook is recorded in the + install ledger and removed by `kin setup uninstall`. + +### Fixed + +- An agent whose MCP server is killed outright, without closing its + connection, no longer shows as connected. A server that has only read now + names its process for the daemon to watch, and the daemon stops listing the + agent as soon as that process is gone and ends its session at the next + sweep. A live connection that only reads still ends on the idle window. +- Add `kin runtime status --json` with paired executable identities, readiness refusals and restart acknowledgement. +- Route installers and npm launchers through one admitted managed CLI and daemon pair, with isolated homes and service namespaces for development runtimes. +- Bootstrap legacy runtimes through the official installer when their executable cannot report the runtime protocol, preserving update transactions and restart acknowledgement. +- Include the reviewed workspace, runtime, installer and release-proof changes since 0.8.6. ## [0.8.6] - 2026-10-13 diff --git a/Cargo.lock b/Cargo.lock index 0efa03298..99c96dc5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3110,7 +3110,7 @@ dependencies = [ [[package]] name = "kin-agent" -version = "0.8.6" +version = "0.8.7" dependencies = [ "anyhow", "chrono", @@ -3140,14 +3140,14 @@ dependencies = [ [[package]] name = "kin-buildinfo" -version = "0.8.6" +version = "0.8.7" dependencies = [ "sha2 0.11.0", ] [[package]] name = "kin-cli" -version = "0.8.6" +version = "0.8.7" dependencies = [ "age", "anyhow", @@ -3231,7 +3231,7 @@ dependencies = [ [[package]] name = "kin-context" -version = "0.8.6" +version = "0.8.7" dependencies = [ "kin-blobs", "kin-db", @@ -3247,7 +3247,7 @@ dependencies = [ [[package]] name = "kin-core" -version = "0.8.6" +version = "0.8.7" dependencies = [ "cap-fs-ext", "cap-std", @@ -3256,6 +3256,7 @@ dependencies = [ "fs2", "gix", "kin-blobs", + "kin-buildinfo", "kin-daemon-spawn", "kin-db", "kin-derivation-inputs", @@ -3286,7 +3287,7 @@ dependencies = [ [[package]] name = "kin-daemon" -version = "0.8.6" +version = "0.8.7" dependencies = [ "anyhow", "async-stream", @@ -3332,6 +3333,7 @@ dependencies = [ "serial_test", "sha2 0.11.0", "signal-hook-registry", + "similar", "socket2", "sysinfo", "tar", @@ -3350,7 +3352,7 @@ dependencies = [ [[package]] name = "kin-daemon-spawn" -version = "0.8.6" +version = "0.8.7" dependencies = [ "fs2", "kin-blobs", @@ -3419,7 +3421,7 @@ dependencies = [ [[package]] name = "kin-derivation-inputs" -version = "0.8.6" +version = "0.8.7" dependencies = [ "sha2 0.11.0", "tempfile", @@ -3428,7 +3430,7 @@ dependencies = [ [[package]] name = "kin-git" -version = "0.8.6" +version = "0.8.7" dependencies = [ "cap-std", "chrono", @@ -3454,7 +3456,7 @@ dependencies = [ [[package]] name = "kin-grammar-typescript" -version = "0.8.6" +version = "0.8.7" dependencies = [ "cc", "tree-sitter", @@ -3463,7 +3465,7 @@ dependencies = [ [[package]] name = "kin-index" -version = "0.8.6" +version = "0.8.7" dependencies = [ "hex", "kin-blobs", @@ -3516,7 +3518,7 @@ dependencies = [ [[package]] name = "kin-integration-tests" -version = "0.8.6" +version = "0.8.7" dependencies = [ "kin-blobs", "kin-context", @@ -3537,7 +3539,7 @@ dependencies = [ [[package]] name = "kin-io" -version = "0.8.6" +version = "0.8.7" dependencies = [ "cap-std", "kin-blobs", @@ -3577,7 +3579,7 @@ dependencies = [ [[package]] name = "kin-mcp" -version = "0.8.6" +version = "0.8.7" dependencies = [ "base64 0.23.0", "hex", @@ -3611,7 +3613,7 @@ dependencies = [ [[package]] name = "kin-migrate" -version = "0.8.6" +version = "0.8.7" dependencies = [ "chrono", "kin-blobs", @@ -3661,7 +3663,7 @@ dependencies = [ [[package]] name = "kin-notifier-macos" -version = "0.8.6" +version = "0.8.7" dependencies = [ "block2", "objc2 0.6.4", @@ -3673,7 +3675,7 @@ dependencies = [ [[package]] name = "kin-notify" -version = "0.8.6" +version = "0.8.7" dependencies = [ "anyhow", "chrono", @@ -3685,7 +3687,7 @@ dependencies = [ [[package]] name = "kin-parser" -version = "0.8.6" +version = "0.8.7" dependencies = [ "kin-grammar-typescript", "kin-io", @@ -3717,7 +3719,7 @@ dependencies = [ [[package]] name = "kin-projection" -version = "0.8.6" +version = "0.8.7" dependencies = [ "kin-blobs", "kin-db", @@ -3733,7 +3735,7 @@ dependencies = [ [[package]] name = "kin-ranking" -version = "0.8.6" +version = "0.8.7" dependencies = [ "kin-io", "kin-model", @@ -3744,7 +3746,7 @@ dependencies = [ [[package]] name = "kin-reconcile" -version = "0.8.6" +version = "0.8.7" dependencies = [ "kin-blobs", "kin-db", @@ -3765,7 +3767,7 @@ dependencies = [ [[package]] name = "kin-registry" -version = "0.8.6" +version = "0.8.7" dependencies = [ "async-stream", "axum", @@ -3798,7 +3800,7 @@ dependencies = [ [[package]] name = "kin-remote" -version = "0.8.6" +version = "0.8.7" dependencies = [ "async-trait", "base64 0.23.0", @@ -3823,7 +3825,7 @@ dependencies = [ [[package]] name = "kin-review" -version = "0.8.6" +version = "0.8.7" dependencies = [ "kin-blobs", "kin-core", @@ -3840,7 +3842,7 @@ dependencies = [ [[package]] name = "kin-runtime" -version = "0.8.6" +version = "0.8.7" dependencies = [ "chrono", "kin-blobs", @@ -3874,7 +3876,7 @@ dependencies = [ [[package]] name = "kin-semantic-contracts" -version = "0.8.6" +version = "0.8.7" dependencies = [ "kin-db", "kin-model", @@ -3889,7 +3891,7 @@ dependencies = [ [[package]] name = "kin-spine" -version = "0.8.6" +version = "0.8.7" dependencies = [ "chrono", "hashbrown 0.15.5", @@ -5347,9 +5349,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", diff --git a/Cargo.toml b/Cargo.toml index 008012312..a302a7d6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ members = [ ] [workspace.package] -version = "0.8.6" +version = "0.8.7" edition = "2021" license = "Apache-2.0" authors = ["Troy Fortin "] diff --git a/Dockerfile b/Dockerfile index d5010378a..8bc936d5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -118,6 +118,10 @@ COPY --from=builder /build/kin/target/release/kin /usr/local/bin/kin # it resolves for the mounted repository, so the daemon binary has to be here # even though nothing in this image starts one and no port is published. COPY --from=builder /build/kin/target/release/kin-daemon /usr/local/bin/kin-daemon +RUN mkdir -p /home/kin/.kin/bin \ + && chown kin:kin /home/kin/.kin /home/kin/.kin/bin \ + && ln /usr/local/bin/kin /home/kin/.kin/bin/kin \ + && ln /usr/local/bin/kin-daemon /home/kin/.kin/bin/kin-daemon USER kin # stdio transport: MCP travels over stdin and stdout, so run this image with # `-i`, mount the repository, and set the working directory to it. @@ -133,6 +137,10 @@ ENV HOME=/home/kin WORKDIR /app COPY --from=builder /build/kin/target/release/kin-daemon /usr/local/bin/kin-daemon COPY --from=builder /build/kin/target/release/kin /usr/local/bin/kin +RUN mkdir -p /home/kin/.kin/bin \ + && chown kin:kin /home/kin/.kin /home/kin/.kin/bin \ + && ln /usr/local/bin/kin /home/kin/.kin/bin/kin \ + && ln /usr/local/bin/kin-daemon /home/kin/.kin/bin/kin-daemon COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Pre-create the default workspace owned by the runtime user. Docker seeds a diff --git a/README.md b/README.md index bbab5d843..6b0c5d2db 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,10 @@ if you said yes. There is no separate linking step. After you install another to `kin setup` again, and `kin setup status` shows what is connected. On Windows, connect AI tools inside WSL2. +`kin runtime status --json` checks the shared CLI and daemon installation, including +their exact source identity. Normal commands refuse mismatched pairs; development +builds use isolated state. See [runtime installation](docs/runtime-installation.md). + Graph and lexical retrieval work without a model. Embeddings and reranking are optional, and their model providers default to `none`. Standard CLI and daemon builds include external HTTP model support. Built-in models require a build with optional local inference enabled. diff --git a/crates/kin-buildinfo/build.rs b/crates/kin-buildinfo/build.rs index 5f206bbda..e5beabbd3 100644 --- a/crates/kin-buildinfo/build.rs +++ b/crates/kin-buildinfo/build.rs @@ -211,6 +211,14 @@ fn main() { println!("cargo:rustc-env=KIN_BUILD_BRANCH={branch}"); println!("cargo:rustc-env=KIN_BUILD_TIME={built_at}"); println!("cargo:rustc-env=KIN_BUILD_VERSION={version}"); + println!( + "cargo:rustc-env=KIN_BUILD_TARGET={}", + std::env::var("TARGET").expect("Cargo target") + ); + println!( + "cargo:rustc-env=KIN_BUILD_PROFILE={}", + std::env::var("PROFILE").expect("Cargo profile") + ); } fn explicit_build_identity() -> Option { diff --git a/crates/kin-buildinfo/src/lib.rs b/crates/kin-buildinfo/src/lib.rs index e72640ba4..e8d7ca0c0 100644 --- a/crates/kin-buildinfo/src/lib.rs +++ b/crates/kin-buildinfo/src/lib.rs @@ -12,6 +12,8 @@ pub struct BuildInfo { pub dependency_provenance: &'static str, pub branch: &'static str, pub built_at: &'static str, + pub target: &'static str, + pub build_profile: &'static str, } /// Versioned, fixed-width build identity embedded exactly once in each @@ -138,6 +140,8 @@ pub fn get() -> BuildInfo { dependency_provenance: env!("KIN_BUILD_DEPENDENCY_PROVENANCE"), branch: env!("KIN_BUILD_BRANCH"), built_at: env!("KIN_BUILD_TIME"), + target: env!("KIN_BUILD_TARGET"), + build_profile: env!("KIN_BUILD_PROFILE"), } } @@ -184,6 +188,8 @@ mod tests { dependency_provenance: "lock-sha", branch: "main", built_at: "2026-06-10T16:00:00Z", + target: "x86_64-unknown-linux-gnu", + build_profile: "debug", }; assert_eq!( @@ -201,6 +207,8 @@ mod tests { dependency_provenance: "lock-sha", branch: "main", built_at: "2026-06-10T16:00:00Z", + target: "x86_64-unknown-linux-gnu", + build_profile: "release", }; assert_eq!(sha_with_dirty(info), "bd7cd12"); diff --git a/crates/kin-cli/Cargo.toml b/crates/kin-cli/Cargo.toml index 3e3cf6d18..3f0ef1eae 100644 --- a/crates/kin-cli/Cargo.toml +++ b/crates/kin-cli/Cargo.toml @@ -102,7 +102,7 @@ toml_edit = "0.25" tempfile = { workspace = true } unicode-normalization = "0.1" unicode-ident = "1" -kin-spine = { version = "0.8.6", path = "../kin-spine" } +kin-spine = { version = "0.8.7", path = "../kin-spine" } [dev-dependencies] kin-mcp = { workspace = true, features = ["test-support"] } diff --git a/crates/kin-cli/assets/workspace_ui/agents.css b/crates/kin-cli/assets/workspace_ui/agents.css index cd4b660d2..dc18e94e3 100644 --- a/crates/kin-cli/assets/workspace_ui/agents.css +++ b/crates/kin-cli/assets/workspace_ui/agents.css @@ -53,6 +53,7 @@ a.chip:hover { color: var(--accent); } .lane-chip { max-width: 260px; height: 24px; padding: 0 10px; border-radius: 12px; background: var(--bg-hover); font-size: 12px; line-height: 24px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lane-chip:hover { color: var(--accent); } .lane-last .agent-entity { font: inherit; font-weight: 500; } +.lane-chips .lane-more { align-self: center; } .lane .agent-open-list { margin-top: 0; } .lane-detail { margin-top: 4px; } .lanes-earlier .lane:not(.is-open) { background: none; box-shadow: none; } @@ -78,4 +79,7 @@ a.chip:hover { color: var(--accent); } @media (max-width: 760px) { .lane-body { padding-left: 20px; } .lane-row { grid-template-columns: minmax(0, 1fr); } + /* When a session was, and how much it did, on a line of its own rather than cut short. */ + .lane-head { flex-wrap: wrap; row-gap: 2px; padding-top: 12px; padding-bottom: 12px; } + .lane-when { order: 3; flex-basis: 100%; padding-left: 18px; white-space: normal; } } diff --git a/crates/kin-cli/assets/workspace_ui/agents.js b/crates/kin-cli/assets/workspace_ui/agents.js index 1d899bfe2..0d3121144 100644 --- a/crates/kin-cli/assets/workspace_ui/agents.js +++ b/crates/kin-cli/assets/workspace_ui/agents.js @@ -18,6 +18,10 @@ const plural = (n, one, many) => `${count(n)} ${n === 1 ? one : many || `${one}s`}`; const NUMBER_WORD = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]; const spoken = (n) => n < NUMBER_WORD.length ? NUMBER_WORD[n] : count(n); + /** A number as it reads inside a sentence: "three", or "12". */ + const spokenInside = (n) => n < NUMBER_WORD.length ? NUMBER_WORD[n].toLowerCase() : count(n); + // The server lists at most this many earlier sessions, so a full list is a lower bound. + const EARLIER_SHOWN = 12; // ── Words ──────────────────────────────────────────────────────────────── @@ -43,54 +47,80 @@ } const READS = new Set(["locate", "refs", "impact", "source", "context", "neighbors", "trace", "history", "other"]); const WRITES = new Set(["stage", "commit", "mutate"]); + // The calls that open, fill, commit or drop a change: when one fails, whether a change is left open matters. + const CHANGING = new Set(["begin", "stage", "commit", "abort", "mutate"]); + // A failed call that ran this long waited on Kin rather than being refused by it. + const WAITED_MS = 10_000; /** * One call in plain words, as parts: strings, and entities the page links to * their own pages. "Looked up Signer.verify_signature and 4 related entities". + * A call that failed reads as what it tried, then why it did not happen: + * "Tried to drop the change it had started; Kin did not answer within 28 s". */ function describe(call, names = {}) { + const known = names && typeof names === "object" ? names : {}; const ids = array(call?.entity_ids).filter((id) => UUID.test(text(id))); - const named = ids.filter((id) => text(names[id]?.label)); - const entity = (id) => ({ id, label: names[id].label }); + const named = ids.filter((id) => text(known[id]?.label)); + const entity = (id) => ({ id, label: known[id].label }); const first = named[0] ? entity(named[0]) : null; const second = named[1] ? entity(named[1]) : null; const found = isCount(call?.result_count) ? call.result_count : ids.length; const some = first || "an entity"; + const failed = Boolean(call?.is_error); let parts; switch (family(call?.tool)) { case "locate": - parts = first - ? ["Looked up ", first, found > 1 ? ` and ${plural(found - 1, "related entity", "related entities")}` : ""] - : [found ? `Looked something up and found ${plural(found, "entity", "entities")}` : "Looked something up and found no entity"]; + parts = failed ? (first ? ["Tried to look up ", first] : ["Tried to look something up"]) + : first ? ["Looked up ", first, found > 1 ? ` and ${plural(found - 1, "related entity", "related entities")}` : ""] + : [found ? `Looked something up and found ${plural(found, "entity", "entities")}` : "Looked something up and found no entity"]; break; - case "refs": parts = ["Checked what uses ", some]; break; - case "impact": parts = ["Checked what a change to ", some, " would touch"]; break; - case "source": parts = ["Read ", some]; break; - case "context": parts = ["Read the context around ", some]; break; - case "neighbors": parts = ["Looked at what ", some, " connects to"]; break; - case "trace": parts = first && second ? ["Traced how ", first, " reaches ", second] : ["Traced where ", some, " leads"]; break; - case "history": parts = ["Read the history of ", some]; break; - case "status": parts = ["Checked the repository's status"]; break; - case "begin": parts = ["Started a change"]; break; - case "stage": parts = first ? ["Staged an edit to ", first] : ["Staged an edit"]; break; + case "refs": parts = [failed ? "Tried to check what uses " : "Checked what uses ", some]; break; + case "impact": parts = [failed ? "Tried to check what a change to " : "Checked what a change to ", some, " would touch"]; break; + case "source": parts = [failed ? "Tried to read " : "Read ", some]; break; + case "context": parts = [failed ? "Tried to read the context around " : "Read the context around ", some]; break; + case "neighbors": parts = [failed ? "Tried to look at what " : "Looked at what ", some, " connects to"]; break; + case "trace": parts = first && second ? [failed ? "Tried to trace how " : "Traced how ", first, " reaches ", second] : [failed ? "Tried to trace where " : "Traced where ", some, " leads"]; break; + case "history": parts = [failed ? "Tried to read the history of " : "Read the history of ", some]; break; + case "status": parts = [failed ? "Tried to check the repository's status" : "Checked the repository's status"]; break; + case "begin": parts = [failed ? "Tried to start a change" : "Started a change"]; break; + case "stage": parts = first ? [failed ? "Tried to stage an edit to " : "Staged an edit to ", first] : [failed ? "Tried to stage an edit" : "Staged an edit"]; break; // A commit's answer also names entities that only moved with the edit, // so a commit is described by the entity it was made to, alone. - case "commit": parts = first ? ["Committed a change to ", first] : ["Committed a change"]; break; - case "abort": parts = ["Dropped a change it had started"]; break; - case "mutate": parts = first ? ["Changed ", first] : ["Changed the code"]; break; - default: parts = first ? ["Asked Kin about ", first] : ["Asked Kin a question"]; + case "commit": parts = first ? [failed ? "Tried to commit a change to " : "Committed a change to ", first] : [failed ? "Tried to commit a change" : "Committed a change"]; break; + case "abort": parts = [failed ? "Tried to drop the change it had started" : "Dropped a change it had started"]; break; + case "mutate": parts = first ? [failed ? "Tried to change " : "Changed ", first] : [failed ? "Tried to change the code" : "Changed the code"]; break; + default: parts = first ? [failed ? "Tried to ask Kin about " : "Asked Kin about ", first] : [failed ? "Tried to ask Kin a question" : "Asked Kin a question"]; } - if (call?.is_error) parts.push(", but Kin could not answer"); + if (failed) parts.push(whyFailed(call)); + // A call that finished after the agent stopped waiting for it still did what it did; the agent was never told. + else if (call?.caller_left === true) parts.push(", after it had stopped waiting for the answer, so it was not told"); return parts.filter((part) => part !== ""); } + /** Why a failed call did not happen, as far as its record says: Kin took too long, or Kin could not do it. */ + function whyFailed(call) { + const ms = call?.duration_ms; + return typeof ms === "number" && Number.isFinite(ms) && ms >= WAITED_MS ? `; Kin did not answer within ${count(Math.round(ms / 1000))} s` : "; Kin could not do it"; + } const plainText = (parts) => parts.map((part) => typeof part === "string" ? part : shortLabel(part.label)).join(""); + /** The change a commit call names, when its record carries one and it is one of the agent's own; null otherwise. */ + function committedChange(call, agent) { + // A mutate commits in one call, as a commit does. + if (!["commit", "mutate"].includes(family(call?.tool)) || call?.is_error) return null; + const id = [call.change_id, call.change].map(text).find((value) => /^[\w-]{1,128}$/.test(value)); + if (!id) return null; + return array(agent?.changes).find((change) => text(change?.id).toLowerCase() === id.toLowerCase()) || null; + } + /** Calls in a row that did the same thing to the same entity, read as one. */ function group(calls) { const groups = []; for (const call of array(calls)) { + if (!call || typeof call !== "object") continue; const last = groups.at(-1); - const same = last && family(last.call.tool) === family(call.tool) && text(last.call.entity_ids?.[0]) === text(call.entity_ids?.[0]) && Boolean(last.call.is_error) === Boolean(call.is_error); + const same = last && family(last.call.tool) === family(call.tool) && text(last.call.entity_ids?.[0]) === text(call.entity_ids?.[0]) && Boolean(last.call.is_error) === Boolean(call.is_error) + && text(last.call.change_id) === text(call.change_id); if (same) { last.times += 1; last.oldest = call; } else groups.push({ call, times: 1, oldest: call }); } @@ -130,19 +160,147 @@ return here ? `Connected for ${here}` : "Connected"; } - /** The Agents lede: who is connected now, in a sentence of real counts. */ + /** The window Kin keeps agents that have gone for, as words: "in the last 30 days". Nothing when Kin did not say. */ + function keptWords(data) { + const days = data?.kept?.days; + return isCount(days) && days > 0 ? `in the last ${plural(days, "day")}` : ""; + } + /** What Kin keeps of an agent once it has gone, in a sentence. Nothing when Kin did not say. */ + function keptSentence(data) { + const days = data?.kept?.days, sessions = data?.kept?.sessions; + if (!isCount(days) || days <= 0) return ""; + return `Kin keeps what each agent did here for ${plural(days, "day")} after it leaves${isCount(sessions) && sessions > 0 ? `, the latest ${count(sessions)} at most` : ""}.`; + } + + /** + * Sessions as the agents that ran them: one agent that connected three + * times is one agent in three sessions. Agents are told apart by name. + */ + function whoRan(sessions) { + const byName = new Map(); + for (const agent of array(sessions)) { + const name = text(agent?.name).trim(); + if (!byName.has(name.toLowerCase())) byName.set(name.toLowerCase(), name); + } + return { agents: byName.size, sessions: array(sessions).length, name: byName.size === 1 ? [...byName.values()][0] : "" }; + } + + /** Who worked here before, as agents and the sessions they ran: "Claude Code worked here in three sessions in the last 30 days." */ + function earlierSentence(data) { + const earlier = array(data?.earlier); + if (!earlier.length) return ""; + const { agents, sessions, name } = whoRan(earlier); + // A full list is the most the server sends, so its counts are at least these. + const atLeast = earlier.length >= EARLIER_SHOWN; + const who = agents === 1 ? name || "One agent" : `${atLeast ? `At least ${spokenInside(agents)}` : spoken(agents)} agents`; + const runs = sessions > agents || atLeast ? ` in ${atLeast ? "at least " : ""}${spokenInside(sessions)} sessions` : ""; + return `${who} worked here${runs} ${keptWords(data) || "earlier"}.`; + } + + /** The Agents lede: who is connected now, in a sentence of real counts of agents, not of the sessions they run. */ function headline(data) { const agents = array(data?.agents); const working = agents.filter((agent) => agent.state === "active").length; - const earlier = array(data?.earlier).length; - if (!agents.length) return earlier ? `No agent is connected right now. ${spoken(earlier)} worked here earlier.` : "No agent is connected right now."; + if (!agents.length) return array(data?.earlier).length ? `No agent is connected right now. ${earlierSentence(data)}` : "No agent is connected right now."; if (agents.length === 1) return working ? "One agent is connected and working." : "One agent is connected. It is idle."; - const connected = `${spoken(agents.length)} agents are connected.`; + const { agents: who, name } = whoRan(agents); + const connected = who === 1 ? `${name || "One agent"} is connected in ${spokenInside(agents.length)} sessions.` + : who < agents.length ? `${spoken(who)} agents are connected in ${spokenInside(agents.length)} sessions.` + : `${spoken(agents.length)} agents are connected.`; if (!working) return `${connected} None is working right now.`; if (working === agents.length) return `${connected} ${agents.length === 2 ? "Both are" : "Each one is"} working.`; return `${connected} ${spoken(working)} ${working === 1 ? "is" : "are"} working now.`; } + // ── What a session came to ─────────────────────────────────────────────── + + /** A change it opened and has not committed, in a few words. */ + const openWords = (edits) => edits ? `Has ${plural(edits, "edit")} staged, not committed yet` : "Started a change, nothing staged yet"; + // How a committed change's review stands, said of it alone, or of the latest of several. + const REVIEW_WORDS = { + pending: [", waiting for your review", ", the latest waiting for your review"], + approved: [", and you marked it as looking right", ", and you marked the latest as looking right"], + needs_work: [", and you flagged it", ", and you flagged the latest"], + }; + + /** + * What a session came to, and where that came from: a change it committed, + * then a change it has open, then the entities it edited, then its last + * call. Null when Kin holds nothing it did. + */ + function settle(agent, names, review) { + if (!agent || typeof agent !== "object") return null; + const known = names && typeof names === "object" ? names : {}; + const changes = array(agent.changes).filter((change) => text(change?.id)); + const committed = Math.max(changes.length, isCount(agent.changes_total) ? agent.changes_total : 0); + if (committed > 0) { + const latest = changes[0] || null; + const title = text(latest?.title).trim(); + const others = committed - 1; + const parts = title ? [`Committed “${title}”`, others ? ` and ${plural(others, "other")}` : ""] : [others ? `Committed ${plural(committed, "change")}` : "Committed a change"]; + let state = null; + if (latest && typeof review === "function") { try { state = review(latest.id); } catch (_) { state = null; } } + const said = REVIEW_WORDS[state === "needswork" ? "needs_work" : state]; + if (said) parts.push(said[others ? 1 : 0]); + return { kind: "committed", change: latest, parts: parts.filter(Boolean) }; + } + const open = array(agent.open_changes).filter((row) => row && typeof row === "object"); + if (open.length) { + const edits = open.reduce((sum, row) => sum + (isCount(row.staged_edits) ? row.staged_edits : 0), 0); + return { kind: "open", parts: [openWords(edits)] }; + } + const edited = array(agent.edited).filter((id) => UUID.test(text(id))); + const editedTotal = Math.max(edited.length, isCount(agent.edited_total) ? agent.edited_total : 0); + if (editedTotal > 0) { + const named = [...new Set(edited.filter((id) => text(known[id]?.label)))]; + const entity = (id) => ({ id, label: known[id].label }); + if (!named.length) return { kind: "edited", parts: [`Edited ${plural(editedTotal, "entity", "entities")}`] }; + if (editedTotal === 2 && named.length === 2) return { kind: "edited", parts: ["Edited ", entity(named[0]), " and ", entity(named[1])] }; + return { kind: "edited", parts: ["Edited ", entity(named[0]), editedTotal > 1 ? ` and ${plural(editedTotal - 1, "other entity", "other entities")}` : ""].filter(Boolean) }; + } + const last = array(agent.recent_calls).find((call) => call && typeof call === "object"); + return last ? { kind: "call", call: last, parts: describe(last, known) } : null; + } + + /** + * What a session came to, in the parts describe() returns: strings, and + * { id, label } entities. `review(changeId)` says how a committed change's + * review stands: "pending", "approved", "needs_work", or null when that is + * not known; without it a commit is said without its review. Null when Kin + * holds nothing the session did. + */ + function outcome(agent, names, review) { + return settle(agent, names, review)?.parts || null; + } + + /** + * Whether a change of an agent's is open now, as far as Kin can say; null + * when its record does not say. A session that has ended no longer reports + * what it holds. + */ + /** + * The change an agent committed after it last started one and before + * `call`, when its record says so: a drop that fails after the change it + * started was committed had nothing left to drop. + */ + function committedBefore(call, calls, agent) { + const at = Date.parse(text(call?.at)); + if (!Number.isFinite(at)) return null; + const begun = Math.max(-Infinity, ...array(calls).filter((other) => family(other?.tool) === "begin" && !other.is_error).map((other) => Date.parse(text(other.at))).filter((when) => Number.isFinite(when) && when <= at)); + if (!Number.isFinite(begun)) return null; + return array(agent?.changes).find((change) => { const when = Date.parse(text(change?.time)); return Number.isFinite(when) && when >= begun && when <= at; }) || null; + } + function openNow(agent) { + if (agent?.state === "ended") return "Kin cannot say now whether that change is still open."; + if (!["active", "idle"].includes(agent?.state) || !Array.isArray(agent.open_changes)) return null; + const open = agent.open_changes.filter((row) => row && typeof row === "object"); + if (!open.length) return "No change of its is open now."; + const counted = open.every((row) => isCount(row.staged_edits)); + const edits = open.reduce((sum, row) => sum + (isCount(row.staged_edits) ? row.staged_edits : 0), 0); + const staged = counted ? `, with ${edits ? `${plural(edits, "edit")} staged` : "nothing staged"}` : ""; + return open.length === 1 ? `Its change is still open${staged}.` : `It has ${spokenInside(open.length)} changes open${staged}.`; + } + // ── Drawing ────────────────────────────────────────────────────────────── /** A small spark that marks an agent, beside its name wherever it appears. */ @@ -166,8 +324,9 @@ for (const open of array(agent.open_changes).slice(0, 3)) { const edits = isCount(open.staged_edits) ? open.staged_edits : 0; const age = isCount(open.age_secs) ? span(Date.now() - open.age_secs * 1000) : null; + // Said in the words a session's outcome uses, so Home and this page agree. rows.push(ctx.h("p", { class: "agent-open" }, ctx.h("span", { class: "agent-open-dot", "aria-hidden": "true" }), - edits ? `Changing the code: ${plural(edits, "edit")} staged, not committed yet` : "Started a change, nothing staged yet", + openWords(edits), age ? ctx.h("span", { class: "agent-quiet" }, ` · open for ${age}`) : null)); } for (const intent of array(agent.intents).slice(0, 2)) { @@ -185,17 +344,53 @@ // ── The Agents page ────────────────────────────────────────────────────── - const timeline = { key: null, data: null, error: null, busy: false, at: 0, timer: null }; + const timeline = { key: null, data: null, error: null, busy: false, at: 0, timer: null, slow: false, tries: 0, retry: null }; + // A busy machine can take minutes to read an agent's calls, so the read waits for them. + const TIMELINE_PATIENCE = 180_000; + // A read that failed only because Kin took too long is asked again on its own, after these waits. + const TIMELINE_RETRY = [3_000, 8_000, 20_000]; + const TOO_SLOW = /did not answer within|took longer than|timed out|timeout/i; + + /** Stop waiting to read an agent's calls again. */ + function stopRetry() { + clearTimeout(timeline.retry); + timeline.retry = null; + } + /** + * Read what an agent did. The last answer stays on the page while it is + * read again. A read that only took too long is asked again on its own a + * few times, further apart each time, before the page says it failed. + */ function loadTimeline(ctx, key, quiet = false) { if (!KEY.test(text(key))) return; - if (timeline.key !== key) { timeline.key = key; timeline.data = null; timeline.error = null; } + if (timeline.key !== key) { stopRetry(); Object.assign(timeline, { key, data: null, error: null, slow: false, tries: 0 }); } + if (!quiet) timeline.tries = 0; if (timeline.busy) { timeline.again = true; return; } + stopRetry(); timeline.busy = true; if (!quiet) ctx.paint("agents-detail"); - ctx.fetchJSON(`/api/workspace/agents/timeline?agent=${encodeURIComponent(key)}&limit=100`) - .then((data) => { if (timeline.key === key) { timeline.data = data; timeline.error = null; timeline.at = Date.now(); } }) - .catch((error) => { if (timeline.key === key) timeline.error = { message: error.message || "unreadable", status: error.status }; }) + ctx.fetchJSON(`/api/workspace/agents/timeline?agent=${encodeURIComponent(key)}&limit=100`, { patience: TIMELINE_PATIENCE }) + .then((data) => { if (timeline.key === key) Object.assign(timeline, { data, error: null, slow: false, tries: 0, at: Date.now() }); }) + .catch((error) => { + if (timeline.key !== key) return; + const message = text(error?.message) || "unreadable"; + if (error?.status !== 404 && TOO_SLOW.test(message) && timeline.tries < TIMELINE_RETRY.length) { + const wait = TIMELINE_RETRY[timeline.tries]; + timeline.tries += 1; + timeline.slow = true; + timeline.error = null; + timeline.retry = setTimeout(() => { + timeline.retry = null; + // Nobody is looking at this agent any more: the read waits until someone is. + if (timeline.key !== key || (ctx.shown && !ctx.shown("agents-detail"))) { timeline.slow = false; return; } + loadTimeline(ctx, key, true); + }, wait); + return; + } + timeline.slow = false; + timeline.error = { message, status: error?.status }; + }) .finally(() => { timeline.busy = false; ctx.paint("agents-detail"); @@ -204,10 +399,12 @@ } function everyAgent(data) { return [...array(data?.agents), ...array(data?.earlier)]; } - /** When a session started, as a time of day, so two sessions of one agent can be told apart. */ + /** When a session started, as a time of day, so two sessions of one agent can be told apart; with its day when that was not today. */ function started(agent) { const date = new Date(agent?.started_at); - return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + if (Number.isNaN(date.getTime())) return ""; + const today = date.toDateString() === new Date().toDateString(); + return date.toLocaleString(undefined, today ? { hour: "numeric", minute: "2-digit" } : { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); } function selectedKey(ctx, data) { @@ -220,7 +417,8 @@ function touching(agent, names) { const seen = new Map(); for (const call of array(agent.recent_calls).slice(0, 4)) { - for (const id of array(call.entity_ids).slice(0, 6)) { + // A write names the entity it was made to; the others its answer lists only moved with it. + for (const id of array(call?.entity_ids).slice(0, WRITES.has(family(call?.tool)) ? 1 : 6)) { const label = shortLabel(names[id]?.label); // Two entities that read the same here are one chip; the first leads to its page. if (label && !seen.has(label.toLowerCase())) seen.set(label.toLowerCase(), { id, label }); @@ -232,6 +430,55 @@ /** A name as a reader would say it: a local inside a function is named for itself. */ function shortLabel(label) { return text(label).replace(/^.*\./, ""); } + /** Entities by name, each once, the first of two that read the same leading to its page; with how many more there are. */ + function namedChips(ids, names, total, shown = 6) { + const seen = new Map(); + for (const id of array(ids)) { + const label = shortLabel(names[id]?.label); + if (label && !seen.has(label.toLowerCase())) seen.set(label.toLowerCase(), { id, label }); + } + const chips = [...seen.values()].slice(0, shown); + const all = Math.max(isCount(total) ? total : 0, array(ids).length); + return { chips, more: Math.max(0, all - chips.length) }; + } + + /** What an agent that has gone did: what it read and what it changed. What it committed is its lane's outcome line. */ + function pastRows(ctx, agent, names) { + const row = (label, ...content) => ctx.h("div", { class: "lane-row" }, ctx.h("span", { class: "lane-label" }, label), ctx.h("span", { class: "lane-chips" }, ...content)); + const chipsOf = ({ chips, more }) => [chips.map((held) => ctx.h("a", { class: "lane-chip", href: ctx.entityHref(held.id), title: names[held.id]?.label }, held.label)), + more ? ctx.h("span", { class: "agent-quiet lane-more" }, chips.length ? `and ${count(more)} more` : plural(more, "entity", "entities")) : null]; + const rows = []; + if (agent.reads_known === false) rows.push(ctx.h("p", { class: "agent-quiet" }, "Kin kept what it wrote, not what it read.")); + else { + const read = Array.isArray(agent.read) ? namedChips(agent.read, names, agent.read_total) : { chips: touching(agent, names), more: 0 }; + if (read.chips.length || read.more) rows.push(row("Read", ...chipsOf(read))); + } + const edited = namedChips(agent.edited, names, agent.edited_total); + if (edited.chips.length || edited.more) rows.push(row("Edited", ...chipsOf(edited))); + return rows; + } + + /** + * Whether a closed lane gives its outcome a line of its own. A committed + * change always does. Otherwise a row the lane already shows says the same: + * a connected agent's open change and its last call, and a gone agent's + * edits and what it read. + */ + function ownsLine(settled, here) { + if (!settled) return false; + if (settled.kind === "committed") return true; + if (settled.kind === "edited") return here; + if (settled.kind === "call") return !here && (Boolean(settled.call.is_error) || !READS.has(family(settled.call.tool))); + return false; + } + + /** A session's outcome as a line: what it came to, and for a commit the way to its review. */ + function outcomeLine(ctx, settled) { + // Drawn as a lane's last-call row is, its entities in the sentence's own type. + return ctx.h("p", { class: "lane-outcome lane-last" }, sentence(ctx, settled.parts), + settled.kind === "committed" && settled.change ? [" · ", ctx.h("a", { class: "more-link", href: ctx.changeURL(settled.change.id) }, "Review")] : null); + } + /** * One lane to an agent. A connected agent's lane shows what it has open, the * entities it is looking at and the last things it did; the open lane holds @@ -239,48 +486,68 @@ */ function lanes(ctx, data, selected) { const rows = everyAgent(data); - if (!rows.length) return ctx.h("div", { class: "agent-empty agent-empty-page" }, - ctx.h("div", {}, ctx.h("p", { class: "agent-empty-title" }, "No agent has worked here since this page started."), connectHint(ctx))); + const unread = data?.earlier_unread ? ctx.h("p", { class: "gap" }, "Kin couldn't read which agents worked here before. It reads again on its own.") : null; + if (!rows.length) { + const within = keptWords(data); + return ctx.h("div", { class: "agent-empty agent-empty-page" }, + ctx.h("div", {}, unread || ctx.h("p", { class: "agent-empty-title" }, within ? `No agent has worked here ${within}.` : "No agent has worked here that Kin has a record of."), + keptSentence(data) ? ctx.h("p", { class: "agent-hint" }, keptSentence(data)) : null, connectHint(ctx))); + } const names = data.names || {}; const lane = (agent) => { const [word, tone] = stateWords(agent); const open = agent.key === selected; const here = agent.state !== "ended"; const calls = array(agent.recent_calls); - const looking = here || open ? touching(agent, names) : []; + const looking = here ? touching(agent, names) : []; const last = calls[0]; + const when = here ? (started(agent) ? `since ${started(agent)}` : "") : started(agent); + const tally = here || agent.reads_known !== false ? plural(agent.calls_total || 0, "call") : ""; + // A closed lane says first what its session came to; an open one says it at the head of what it did. + const settled = open ? null : settle(agent, names, ctx.reviewState); + const lead = ownsLine(settled, here) ? outcomeLine(ctx, settled) : null; return ctx.h("li", { class: `lane${open ? " is-open" : ""}`, "data-state": agent.state === "active" ? "working" : here ? "idle" : "ended" }, ctx.h("a", { class: "lane-head", href: `#/agents/${agent.key}`, "aria-current": open ? "true" : undefined, "aria-expanded": String(open) }, ctx.h("span", { class: "pulse", "aria-hidden": "true" }), ctx.h("span", { class: "lane-name" }, agent.name), - ctx.h("span", { class: "lane-when" }, [started(agent) ? `since ${started(agent)}` : "", plural(agent.calls_total || 0, "call"), agent.changes_total ? `${plural(agent.changes_total, "change")} committed` : "", !here && last ? ctx.ago(last.at) : ""].filter(Boolean).join(" · ")), + ctx.h("span", { class: "lane-when" }, [when, tally, agent.changes_total ? `${plural(agent.changes_total, "change")} committed` : "", !here ? ctx.ago(last?.at || agent.last_activity_at) : ""].filter(Boolean).join(" · ")), here ? ctx.h("span", { class: `state${tone === "live" ? " state-good" : ""}` }, word) : null), - here || open ? ctx.h("div", { class: "lane-body" }, - openWork(ctx, agent, names), - looking.length ? ctx.h("div", { class: "lane-row" }, ctx.h("span", { class: "lane-label" }, here ? "Looking at" : "Looked at"), - ctx.h("span", { class: "lane-chips" }, looking.map((held) => ctx.h("a", { class: "lane-chip", href: ctx.entityHref(held.id), title: names[held.id]?.label }, held.label)))) : null, - !open && last ? ctx.h("div", { class: "lane-row" }, ctx.h("span", { class: "lane-label" }, "Last"), - ctx.h("span", { class: "lane-last" }, sentence(ctx, describe(last, names)), ctx.h("span", { class: "agent-quiet" }, ` · ${ctx.ago(last.at)}`))) : null, - !open && !last ? ctx.h("p", { class: "agent-quiet" }, "Connected. It has not asked Kin for anything yet.") : null, - open ? ctx.section("agents-detail", [], () => detail(ctx, agent.key), { class: "lane-detail" }) : null) : null); + ctx.h("div", { class: "lane-body" }, + lead, + here ? [ + openWork(ctx, agent, names) || (calls.length ? ctx.h("p", { class: "agent-quiet" }, "It has not told Kin what it is working on.") : null), + looking.length ? ctx.h("div", { class: "lane-row" }, ctx.h("span", { class: "lane-label" }, "Looking at"), + ctx.h("span", { class: "lane-chips" }, looking.map((held) => ctx.h("a", { class: "lane-chip", href: ctx.entityHref(held.id), title: names[held.id]?.label }, held.label)))) : null, + !open && last ? ctx.h("div", { class: "lane-row" }, ctx.h("span", { class: "lane-label" }, "Last"), + ctx.h("span", { class: "lane-last" }, sentence(ctx, describe(last, names)), ctx.h("span", { class: "agent-quiet" }, ` · ${ctx.ago(last.at)}`))) : null, + !open && !last && !lead ? ctx.h("p", { class: "agent-quiet" }, "Connected. It has not asked Kin for anything yet.") : null] + : pastRows(ctx, agent, names), + open ? ctx.section("agents-detail", ["standing"], () => detail(ctx, agent.key), { class: "lane-detail" }) : null)); }; const live = array(data.agents); const earlier = array(data.earlier).slice(0, 12); return [ live.length ? ctx.h("ul", { class: "lanes", "aria-label": "Connected now" }, live.map(lane)) : null, - earlier.length ? ctx.h("section", { class: live.length ? "section" : "" }, - live.length ? ctx.h("div", { class: "section-head" }, ctx.h("h2", {}, "Earlier")) : null, - ctx.h("ul", { class: "lanes lanes-earlier", "aria-label": "Earlier sessions" }, earlier.map(lane))) : null, - ctx.h("p", { class: "agent-quiet agent-privacy" }, "Kin records which of its tools an agent called and which entities came back. It does not keep the agent's prompts.")]; + earlier.length || unread ? ctx.h("section", { class: live.length ? "section" : "" }, + live.length ? ctx.h("div", { class: "section-head" }, ctx.h("h2", {}, "Earlier"), keptWords(data) ? ctx.h("span", { class: "agent-quiet" }, keptWords(data).replace(/^in t/, "T")) : null) : null, + unread, + earlier.length ? ctx.h("ul", { class: "lanes lanes-earlier", "aria-label": "Earlier sessions" }, earlier.map(lane)) : null) : null, + ctx.h("p", { class: "agent-quiet agent-privacy" }, [keptSentence(data), "Kin records which of its tools an agent called and which entities came back. It does not keep the agent's prompts."].filter(Boolean).join(" "))]; } - function timelineRows(ctx, calls, names) { + function timelineRows(ctx, calls, names, agent) { const groups = group(calls); const SHOWN = 60; if (!groups.length) return ctx.h("p", { class: "gap" }, "It has not asked Kin anything yet."); + // Under the newest failed attempt to open, fill, commit or drop a change: whether a change is open now, when Kin can say. + const failedChange = groups.find((entry) => entry.call.is_error && CHANGING.has(family(entry.call.tool))); + // A failed drop after the change it started was committed is said as that, which also says nothing is open from it. + const before = failedChange && family(failedChange.call.tool) === "abort" ? committedBefore(failedChange.call, calls, agent) : null; + const openLine = before ? `It had already committed the change it started${text(before.title) ? `, “${text(before.title)}”,` : ""} at ${stamp(before.time)}.` : failedChange ? openNow(agent) : null; return ctx.h("ol", { class: "agent-timeline" }, groups.slice(0, SHOWN).map((entry) => { const call = entry.call; const kind = family(call.tool); + const change = committedChange(call, agent); // A write names the entity it was made to; the others its answer lists only moved with it. const listed = WRITES.has(kind) ? array(call.entity_ids).slice(0, 1) : array(call.entity_ids); const ids = listed.filter((id) => text(names[id]?.label)); @@ -295,7 +562,9 @@ ctx.h("p", { class: "agent-step-text" }, sentence(ctx, describe(call, names))), ids.length > 1 || unnamed > 0 ? ctx.h("div", { class: "agent-step-chips" }, ids.slice(1, 6).map((id) => ctx.h("a", { class: "chip chip-small", href: ctx.entityHref(id) }, names[id].label)), ids.length > 6 || unnamed > 0 ? ctx.h("span", { class: "chip chip-small chip-more" }, `${count(Math.max(0, ids.length - 6) + unnamed)} more`) : null) : null, - meta.length ? ctx.h("p", { class: "agent-quiet agent-step-meta" }, meta.join(" · ")) : null)); + meta.length ? ctx.h("p", { class: "agent-quiet agent-step-meta" }, meta.join(" · ")) : null, + entry === failedChange && openLine ? ctx.h("p", { class: "agent-quiet agent-step-meta agent-step-open" }, openLine) : null, + change ? ctx.h("p", { class: "agent-step-meta" }, ctx.h("a", { class: "more-link", href: ctx.changeURL(change.id) }, text(change.title) ? `Review “${text(change.title)}”` : "Review this change")) : null)); }), groups.length > SHOWN ? ctx.h("li", { class: "agent-step agent-step-more" }, ctx.h("p", { class: "agent-quiet" }, `${count(groups.length - SHOWN)} earlier steps not shown.`)) : null); } @@ -308,44 +577,53 @@ function changeRows(ctx, changes) { if (!changes.length) return ctx.h("p", { class: "gap" }, "It has not committed a change."); return ctx.h("ul", { class: "list list-card" }, changes.slice(0, 16).map((change) => ctx.h("li", {}, ctx.h("a", { class: "change-row", href: ctx.changeURL(change.id) }, - ctx.h("div", {}, ctx.h("span", { class: "change-title" }, text(change.title) || "Untitled change"), - ctx.h("span", { class: "change-meta" }, changedWords(ctx, change), ctx.h("time", { datetime: change.time }, ctx.ago(change.time)))), + // A change older than the history Kin read is shown by its review alone. + ctx.h("div", {}, ctx.h("span", { class: "change-title" }, "title" in change ? text(change.title) || "Untitled change" : "A change it committed"), + ctx.h("span", { class: "change-meta" }, changedWords(ctx, change), change.time ? ctx.h("time", { datetime: change.time }, ctx.ago(change.time)) : null)), ctx.h("span", { class: "change-side" }, ctx.h("span", { class: "more-link" }, "Review")))))); } function detail(ctx, selected) { if (!selected) return null; - if (timeline.key !== selected && !timeline.busy) loadTimeline(ctx, selected, true); + // A read that was left when nobody was looking is made now. + const unread = timeline.key === selected && !timeline.data && !timeline.error && !timeline.slow && !timeline.retry; + if ((timeline.key !== selected || unread) && !timeline.busy) loadTimeline(ctx, selected, true); const data = timeline.key === selected ? timeline.data : null; + const stillReading = timeline.key === selected && timeline.slow ? ctx.h("p", { class: "agent-quiet", role: "status" }, "Kin is still reading what this agent did.") : null; if (!data) { if (timeline.key === selected && timeline.error) { - if (timeline.error.status === 404) return ctx.h("div", { class: "section" }, ctx.h("p", { class: "gap" }, "Kin no longer holds this agent's record. Its session ended before this page saw it.")); + if (timeline.error.status === 404) return ctx.h("div", { class: "section" }, ctx.h("p", { class: "gap" }, "Kin no longer holds this agent's record.")); return ctx.h("div", { class: "section" }, ctx.failed("Kin couldn't load what this agent did.", () => loadTimeline(ctx, selected))); } - return ctx.h("div", { class: "agent-detail", "aria-busy": "true" }, ctx.skeleton("skeleton-lede"), ctx.skeletonLines(6)); + return ctx.h("div", { class: "agent-detail", "aria-busy": "true" }, stillReading, ctx.skeleton("skeleton-lede"), ctx.skeletonLines(6)); } - const agent = data.agent || {}; - const names = data.names || {}; - const [word, tone] = stateWords(agent); - const calls = array(data.calls); + const agent = data.agent && typeof data.agent === "object" ? data.agent : {}; + const names = data.names && typeof data.names === "object" ? data.names : {}; + const calls = array(data.calls).filter((call) => call && typeof call === "object"); + // What the session came to leads, so the summary and the lane's head agree. + const settled = settle(array(agent.recent_calls).length ? agent : { ...agent, recent_calls: calls }, names, ctx.reviewState); const total = isCount(agent.calls_total) ? agent.calls_total : calls.length; const kept = isCount(data.kept) ? data.kept : calls.length; const reads = calls.filter((call) => READS.has(family(call.tool))); const touched = new Set(reads.flatMap((call) => array(call.entity_ids))); + const readsKnown = agent.reads_known !== false; const summary = [ - `${spoken(total)} ${total === 1 ? "call" : "calls"} to Kin`, - touched.size ? `${plural(touched.size, "entity", "entities")} looked at${kept < total ? " in the latest " + count(kept) : ""}` : null, + readsKnown ? `${spoken(total)} ${total === 1 ? "call" : "calls"} to Kin` : null, + readsKnown && touched.size ? `${plural(touched.size, "entity", "entities")} looked at${kept < total ? " in the latest " + count(kept) : ""}` : null, + !readsKnown && isCount(agent.edited_total) && agent.edited_total ? `${plural(agent.edited_total, "entity", "entities")} written` : null, agent.changes_total ? plural(agent.changes_total, "change") + " committed" : "no change committed", ].filter(Boolean).join(", "); return ctx.h("div", { class: "agent-detail" }, - ctx.h("p", { class: "agent-summary" }, `${summary.charAt(0).toUpperCase()}${summary.slice(1)}. `, + ctx.h("p", { class: "agent-summary" }, settled ? [ctx.h("span", { class: "agent-outcome" }, sentence(ctx, settled.parts), "."), " "] : null, + `${summary.charAt(0).toUpperCase()}${summary.slice(1)}. `, ctx.h("span", { class: "agent-quiet" }, [presence(agent), agent.through === "mcp" ? "through Kin’s MCP server" : null].filter(Boolean).join(" · "))), + timeline.slow ? stillReading : null, array(agent.changes).length ? ctx.h("section", { class: "section", "aria-labelledby": "agent-changes-title" }, ctx.h("div", { class: "section-head" }, ctx.h("h2", { id: "agent-changes-title" }, agent.changes_total === 1 ? "The change it committed" : "Changes it committed")), changeRows(ctx, array(agent.changes))) : null, ctx.h("section", { class: "section", "aria-labelledby": "agent-steps-title" }, - ctx.h("div", { class: "section-head" }, ctx.h("h2", { id: "agent-steps-title" }, "What it did"), ctx.h("span", { class: "agent-quiet" }, "Newest first")), - timelineRows(ctx, calls, names), + ctx.h("div", { class: "section-head" }, ctx.h("h2", { id: "agent-steps-title" }, "What it did"), readsKnown ? ctx.h("span", { class: "agent-quiet" }, "Newest first") : null), + readsKnown ? timelineRows(ctx, calls, names, agent) : ctx.h("p", { class: "gap" }, "Kin kept the changes this agent wrote, not the calls it made."), kept < total ? ctx.h("p", { class: "agent-quiet agent-foot" }, `Showing its latest ${count(kept)} of ${count(total)} calls. Kin keeps a bounded record of each agent's calls.`) : null, - timeline.error ? ctx.h("p", { class: "agent-quiet" }, "Showing what Kin last saw. ", ctx.button("Refresh", () => loadTimeline(ctx, selected), { class: "button button-small" })) : null)); + timeline.error && !timeline.slow ? ctx.h("p", { class: "agent-quiet" }, "Showing what Kin last saw. ", ctx.button("Refresh", () => loadTimeline(ctx, selected), { class: "button button-small" })) : null)); } function view(ctx) { @@ -359,7 +637,8 @@ return ctx.h("p", { class: "lede" }, headline(resource.data), " ", liveBadge(ctx)); })), ctx.section("agents-meets", ["agents", "working"], () => ctx.overlaps?.() || null), - ctx.section("agents-list", ["agents"], () => { + // Review decisions are read too: a lane says whether its committed change waits for review. + ctx.section("agents-list", ["agents", "standing"], () => { const resource = ctx.read(); if (resource.missing) return null; if (!resource.data) return resource.error ? ctx.failed("Kin couldn't load which agents are connected.", () => ctx.refresh(["agents"])) : ctx.skeletonLines(3); @@ -426,7 +705,7 @@ live.timer = setTimeout(open, delay); }; open(); - return () => { live.source?.close(); clearTimeout(live.timer); stopPolling(); live.state = "off"; }; + return () => { live.source?.close(); clearTimeout(live.timer); clearTimeout(timeline.timer); stopRetry(); stopPolling(); live.state = "off"; }; } function startPolling(ctx) { if (!live.poll) live.poll = setInterval(() => { if (!document.hidden) ctx.pulse(["agents"]); }, 15_000); } function stopPolling() { if (live.poll) { clearInterval(live.poll); live.poll = null; } } @@ -440,5 +719,5 @@ return name ? ctx.h("span", {}, name) : null; } - globalThis.KinWorkspaceAgents = { describe, plainText, shortLabel, group, family, span, presence, headline, view, follow, author, mark, timeline, live }; + globalThis.KinWorkspaceAgents = { describe, outcome, openNow, plainText, shortLabel, group, family, span, presence, headline, earlierSentence, keptWords, view, follow, author, mark, timeline, live }; })(); diff --git a/crates/kin-cli/assets/workspace_ui/style.css b/crates/kin-cli/assets/workspace_ui/style.css index e7a1d01e7..83b61f0a6 100644 --- a/crates/kin-cli/assets/workspace_ui/style.css +++ b/crates/kin-cli/assets/workspace_ui/style.css @@ -211,6 +211,8 @@ kbd { .repo-dot { width: 7px; height: 7px; flex: none; border-radius: 50%; border: 1.5px solid var(--text-3); } .repo-dot[data-state="work"] { border-color: var(--warn); background: var(--warn); } .repo-dot[data-state="live"] { border-color: var(--good); background: var(--good); } +/* An agent's committed change waits for your review: amber, as what waits on you is, and open, as nothing of yours is in flight. */ +.repo-dot[data-state="review"] { border-color: var(--warn); } .repo-pages { margin: 2px 0 8px 11px; padding-left: 8px; border-left: 1px solid var(--line); } .side-foot { padding: 8px 16px 12px; border-top: 1px solid var(--line); } @@ -326,7 +328,7 @@ a.table-row:hover { background: var(--bg-raised); } .cell { color: var(--text-2); } .cell-main { font-weight: 500; color: var(--text); } .cell-num { font-variant-numeric: tabular-nums; color: var(--text-2); } -.review-grid { grid-template-columns: 24px minmax(160px, 1.3fr) minmax(120px, 1fr) minmax(0, 2fr) 88px; } +.review-grid { grid-template-columns: 24px minmax(160px, 1.3fr) minmax(120px, 1fr) minmax(0, 2fr) minmax(88px, max-content); } .review-grid .state { text-align: right; } .cell.state-good { color: var(--good); } .cell.state-warn { color: var(--warn); } @@ -410,18 +412,21 @@ details.more > summary:hover { color: var(--text); } .review-tick.is-fine { background: var(--good); } .review-tick.is-flagged { background: var(--warn); } .review-tick.is-here { height: 4px; margin-top: -1px; background: var(--text); } -.review-decide { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 32px; padding: 12px 0; border-top: 1px solid var(--line); background: var(--bg); } -.review-note { flex: 1; max-width: 420px; } +.review-decide { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; margin-top: 32px; padding: 16px 0 0; border-top: 1px solid var(--line); background: var(--bg); } +.review-note { flex: 1 1 260px; min-width: 0; max-width: 420px; } +.review-decide .page-actions { flex: 1 1 auto; justify-content: flex-end; max-width: 100%; } .button kbd { min-width: 0; height: auto; padding: 0; border: 0; background: none; font: 400 12px var(--font-sans); color: inherit; opacity: 0.6; } .review-finish textarea { margin-bottom: 12px; max-width: 640px; } .step-name { font-size: 26px; font-weight: 600; line-height: 32px; letter-spacing: -0.02em; } .step-name .ent-name { font-weight: 600; } +/* The entity a step is about is its title, so a long name wraps rather than losing its end. */ +.step-name .ent { display: inline; white-space: normal; overflow: visible; overflow-wrap: anywhere; } .step-place { margin-top: 4px; font-size: 12px; color: var(--text-2); } .step-verdict { margin-top: 12px; max-width: 56ch; font-size: 17px; line-height: 26px; padding-left: 14px; border-left: 2px solid var(--line-strong); text-wrap: balance; } .step-verdict-warn { border-left-color: var(--warn); } .step-verdict-bad { border-left-color: var(--bad); } -.step-part { margin-top: 32px; } +.step-part { margin-top: 32px; container-type: inline-size; } .step-part h2 { margin-bottom: 8px; } .rows { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } .rows-item { display: flex; align-items: baseline; gap: 12px; min-height: 40px; padding: 10px 0; } @@ -429,7 +434,7 @@ details.more > summary:hover { color: var(--text); } .rows-item .state { margin-left: auto; } .rows-more { font-size: 12px; color: var(--text-2); } -.diff-lines { display: grid; grid-template-columns: auto auto 16px minmax(0, 1fr); padding: 8px 0; font: 12px/20px var(--font-mono); } +.diff-lines { display: grid; grid-template-columns: auto auto 16px minmax(max-content, 1fr); padding: 8px 0; font: 12px/20px var(--font-mono); } .diff-no { padding: 0 4px 0 12px; text-align: right; color: var(--text-2); user-select: none; font-variant-numeric: tabular-nums; } .diff-mark { text-align: center; color: var(--text-2); user-select: none; } .diff-text { padding-right: 16px; white-space: pre; } @@ -500,9 +505,9 @@ button.conn-node-more:hover { color: var(--accent); } .conn-focus .ent-name { font-weight: 600; } .conn-empty { font-size: 12px; color: var(--text-2); } -.codeview { border: 1px solid var(--line); border-radius: 12px; background: var(--card); box-shadow: var(--card-shadow); overflow: auto; max-height: 640px; } -.code-lines { display: grid; grid-template-columns: auto minmax(0, 1fr); padding: 8px 0; font: 12px/20px var(--font-mono); } -.code-lines-plain { grid-template-columns: minmax(0, 1fr); } +.codeview { max-width: 100%; border: 1px solid var(--line); border-radius: 12px; background: var(--card); box-shadow: var(--card-shadow); overflow: auto; max-height: 640px; } +.code-lines { display: grid; grid-template-columns: auto minmax(max-content, 1fr); padding: 8px 0; font: 12px/20px var(--font-mono); } +.code-lines-plain { grid-template-columns: minmax(max-content, 1fr); } .code-no { padding: 0 12px; text-align: right; color: var(--text-2); user-select: none; font-variant-numeric: tabular-nums; } .code-text { padding: 0 16px 0 0; white-space: pre; } .code-lines-plain .code-text { padding-left: 12px; } @@ -532,7 +537,14 @@ button.relier, button.rel-row { width: 100%; border: 0; border-top: 1px solid va /* ── Branches ───────────────────────────────────────────────────────────── */ -.branch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; min-height: 56px; padding: 8px 0; } +.branch-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; min-height: 56px; padding: 8px 0; } +/* A branch with work of its own opens from anywhere on its row; its buttons stay its own. */ +.branch-open::after { content: ""; position: absolute; inset: 0; } +.branch-row-opens:hover { background: var(--bg-raised); } +.branch-row-opens:hover .branch-label { color: var(--accent); } +.branch-actions, .branch-detail .read-failed { position: relative; z-index: 1; } +/* A name that is one word to the reader stays on one line. */ +.nobreak { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; } .branch-name { display: flex; align-items: baseline; gap: 12px; font-weight: 500; } .branch-name svg { display: none; } .branch-detail { margin-top: 0; } @@ -569,6 +581,12 @@ button.relier, button.rel-row { width: 100%; border: 0; border-top: 1px solid va /* ── Narrow windows ─────────────────────────────────────────────────────── */ @media (max-width: 1020px) { + .page { padding: 28px 24px 72px; } + .review-note { flex-basis: 100%; max-width: none; } + .diff-lines { grid-template-columns: auto auto 16px minmax(0, 1fr); } + .code-lines { grid-template-columns: auto minmax(0, 1fr); } + .code-lines-plain { grid-template-columns: minmax(0, 1fr); } + .diff-text, .code-text { white-space: pre-wrap; overflow-wrap: anywhere; } .explore-grid { grid-template-columns: minmax(0, 1fr); } .conn { grid-template-columns: minmax(0, 1fr); gap: 16px; } .conn svg.conn-lines { display: none; } @@ -577,7 +595,8 @@ button.relier, button.rel-row { width: 100%; border: 0; border-top: 1px solid va } @media (max-width: 760px) { - .app { grid-template-columns: minmax(0, 1fr); } + /* The bar keeps its own height; a short page never stretches it with the space below. */ + .app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; } .side { position: sticky; height: auto; max-height: 100vh; border-right: 0; border-bottom: 1px solid var(--line); } .icon-button.side-toggle { display: inline-flex; } .side:not([data-menu="open"]) .side-body, @@ -630,6 +649,11 @@ a.impact-node:hover { border-color: var(--line-strong); background: var(--bg-hov .impact-text { display: flex; flex-direction: column; min-width: 0; } .impact-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .impact-place { font-size: 12px; line-height: 16px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* What tells apart two entities a view would otherwise show with one name. */ +.name-qualifier { font-weight: 400; color: var(--text-2); } +.rows-item .name-qualifier { min-width: 0; font-size: 12px; overflow-wrap: anywhere; } +/* A row that holds a long place wraps, so its result stays on the page on a narrow window. */ +.rows-item:has(.name-qualifier) { flex-wrap: wrap; row-gap: 2px; } .impact-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); } .impact-node[data-tone="warn"] .impact-dot { background: var(--warn); box-shadow: 0 0 0 4px var(--warn-soft); } .impact-node[data-tone="bad"] .impact-dot { background: var(--bad); box-shadow: 0 0 0 4px var(--bad-soft); } @@ -637,11 +661,9 @@ a.impact-node:hover { border-color: var(--line-strong); background: var(--bg-hov .impact-more { padding: 4px 14px; font-size: 12px; color: var(--text-2); } a.impact-more:hover { color: var(--accent); } svg.impact-lines { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; fill: none; } -svg.impact-lines path { stroke: var(--text-3); stroke-width: 1.5; opacity: 0.7; stroke-dasharray: 1; stroke-dashoffset: 0; } +svg.impact-lines path { stroke: var(--text-3); stroke-width: 1.5; opacity: 0.7; } svg.impact-lines path[data-tone="warn"] { stroke: var(--warn); } svg.impact-lines path[data-tone="bad"] { stroke: var(--bad); } -svg.impact-lines:not([data-drawn]) path { animation: edge-in 700ms var(--ease) both; } -@keyframes edge-in { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } } /* Agents at work. */ .agent-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; } @@ -683,7 +705,7 @@ a.agent-card:hover { border-color: var(--line-strong); } .lang-key[data-at="0"]::before { background: var(--accent); } .lang-key[data-at="1"]::before { background: var(--info); } .lang-key[data-at="2"]::before { background: var(--good); } -.lang-pending .lang-bar { margin-bottom: 44px; animation: pulse 1.4s var(--ease) infinite; } +.lang-pending { display: none; } .repo-card-foot { display: flex; align-items: baseline; gap: 12px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--line); font-size: 12px; white-space: nowrap; } .repo-card-foot .state { overflow: hidden; text-overflow: ellipsis; } .repo-card-time { flex: none; margin-left: auto; color: var(--text-2); } @@ -714,6 +736,20 @@ svg.impact-lines path[data-tone="good"] { stroke: var(--good); } .impact-focus .impact-name { font-weight: 600; } .step-part .impact-step { padding: 20px 24px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); box-shadow: var(--card-shadow); } .step-part .impact-step svg.impact-lines { inset: 0; } +@container (max-width: 700px) { + .impact-step { grid-template-columns: minmax(0, 1fr); gap: 8px; } + .step-part .impact-step { padding: 0; border: 0; border-radius: 0; background: none; box-shadow: none; } + .impact-step .impact-gap, .impact-step svg.impact-lines { display: none; } + .impact-step .impact-node { flex-wrap: wrap; } + .impact-step .impact-text { flex: 1 1 calc(100% - 32px); } + .impact-step .impact-name { white-space: normal; overflow-wrap: anywhere; } + .impact-step .impact-mark { flex-basis: 100%; margin-left: 20px; padding-left: 0; } +} +@media (max-width: 1020px) { + .impact-step { grid-template-columns: minmax(0, 1fr); gap: 8px; } + .step-part .impact-step { padding: 0; border: 0; border-radius: 0; background: none; box-shadow: none; } + .impact-step .impact-gap, .impact-step svg.impact-lines { display: none; } +} /* One question for every repository. */ .home-ask-box { margin-top: 24px; max-width: 720px; } @@ -721,6 +757,12 @@ svg.impact-lines path[data-tone="good"] { stroke: var(--good); } .home-answers { margin-top: 8px; } .home-answer { margin-top: 32px; } .home-answer > .section-head { margin-bottom: 4px; } +/* A repository that matched only loosely is one line under the stronger answers. */ +.home-answers-loose { margin-top: 32px; border-top: 1px solid var(--line); } +.home-answers-loose > .home-answer:first-child { margin-top: 16px; } +.home-answer-loose { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; min-height: 48px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13px; } +.home-answer-loose-name { font-weight: 600; } +.home-answer-loose-text { flex: 1 1 24ch; min-width: 0; color: var(--text-2); } .agent-card .agent-entity, .agent-card-action .agent-entity { font: inherit; font-weight: 500; color: var(--text); } .agent-card-trail { margin-top: 6px; display: flex; flex-direction: column; gap: 2px; font-size: 12px; line-height: 18px; color: var(--text-2); } .agent-card-trail li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -730,8 +772,10 @@ svg.impact-lines path[data-tone="good"] { stroke: var(--good); } /* Views of one thing: a change at its three stages, or the facets of an entity. */ .page-head-tabs { margin-bottom: 20px; } -.tabs { display: flex; gap: 28px; margin-top: 16px; border-bottom: 1px solid var(--line); } -.tab { display: inline-flex; align-items: center; gap: 8px; height: 40px; margin-bottom: -1px; padding: 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; font-size: 14px; font-weight: 500; color: var(--text-2); } +/* A row of tabs wider than the window scrolls inside its own box; the page never grows sideways. The line under it is drawn inside, so the scroll box keeps it. */ +.tabs { display: flex; gap: 28px; margin-top: 16px; box-shadow: inset 0 -1px 0 var(--line); max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none; } +.tabs::-webkit-scrollbar { display: none; } +.tab { display: inline-flex; flex: none; align-items: center; gap: 8px; height: 40px; padding: 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; font-size: 14px; font-weight: 500; color: var(--text-2); white-space: nowrap; } .tab:hover { color: var(--text); } .tab[aria-current="page"], .tab[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); } .tab-count { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 18px; padding: 0 6px; border-radius: 9px; background: var(--bg-hover); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; color: var(--text-2); } @@ -748,3 +792,58 @@ svg.impact-lines path[data-tone="good"] { stroke: var(--good); } .change-consequence-good .impact-dot { background: var(--good); } .structure-name { display: inline-flex; align-items: baseline; gap: 10px; min-width: 0; } .home-start { margin-top: 20px; } + +/* Engine pieces the review and Work use. */ +.tests-line { margin-top: 8px; font-size: 13px; color: var(--text-2); } +.work-authors { margin-top: 6px; font-size: 13px; color: var(--text-2); } +.step-undo { margin-top: 16px; } +.step-undo-confirm { margin-top: 16px; align-items: center; } +.commit-only { margin-top: 12px; } +.repo-card-live { display: flex; align-items: center; gap: 8px; margin-top: -8px; font-size: 12px; color: var(--good); } +.repo-card-live .pulse { background: var(--good); } +.repo-card-live .pulse::after { content: ""; position: absolute; inset: 0; border-radius: 50%; background: var(--good); animation: ping 1.8s var(--ease) infinite; } +.ent-tag { font-weight: 400; font-size: 12px; color: var(--text-2); } +/* On a narrow window a box keeps its name and puts its state under it. */ +@media (max-width: 760px) { + .tabs { gap: 16px; } + /* A narrow card keeps its Review button the size of its word. */ + .focus-agents .change-row .button { justify-self: start; } + .impact-node { flex-wrap: wrap; } + .impact-text { flex: 1 1 60%; } + .impact-mark { flex-basis: 100%; margin-left: 20px; padding-left: 0; } +} + +/* The lines of a relier that name the changed entity. */ +.sites { margin-top: 20px; } +.sites-title { margin-bottom: 8px; font-size: 12px; font-weight: 400; color: var(--text-2); } +.sites-loading { margin-top: 12px; } +.site + .site { margin-top: 12px; } +.site-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; } +.site-code { max-height: none; box-shadow: none; } + +/* A traced answer: the entities the work passes through, in order. */ +.answer { margin-bottom: 24px; padding: 20px 24px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); box-shadow: var(--card-shadow); } +.answer-sentence { max-width: 64ch; font-size: 15px; line-height: 24px; } +.answer-chain { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; } +.answer-step { display: inline-flex; align-items: center; gap: 8px; min-width: 0; } +.answer-node { min-height: 44px; } +.answer-link { font-size: 12px; color: var(--text-2); white-space: nowrap; } +.answer-link::after { content: " →"; } +.candidates { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--line); } + +/* How far each test is from the entity it reaches. */ +.rows-how { font-size: 12px; color: var(--text-2); white-space: nowrap; } +.rows-in { font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; } +.doc-change { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 0 0 12px; font-size: 13px; } +.doc-change dt { color: var(--text-2); } +.doc-change dd { margin: 0; overflow-wrap: anywhere; } +@media (max-width: 640px) { .doc-change { grid-template-columns: 1fr; } .doc-change dd + dt { margin-top: 6px; } } +.rows-more .button { margin-left: -8px; } + +/* Agent changes nobody has reviewed, beside a repository's uncommitted work on Home. */ +.focus-agents { margin-top: 24px; } +.focus-agents .change-row { min-height: 48px; } +.focus-caption { margin-top: 24px; margin-bottom: 0; } +.focus-caption + .impact { margin-top: 12px; } +/* Where Kin's record of a converted repository begins, said on a line of its own under the oldest change. */ +.change-start { display: block; margin-top: 2px; font-size: 12px; color: var(--text-2); } diff --git a/crates/kin-cli/assets/workspace_ui/workspace.js b/crates/kin-cli/assets/workspace_ui/workspace.js index 7a683adeb..225ddc520 100644 --- a/crates/kin-cli/assets/workspace_ui/workspace.js +++ b/crates/kin-cli/assets/workspace_ui/workspace.js @@ -38,9 +38,11 @@ overview: "/api/workspace/overview", agents: "/api/workspace/agents", home: "root:/api/home", + remotes: "/api/workspace/remotes", + flags: "/api/workspace/flags", }; // A server started for one repository alone has no hub to read. - const OPTIONAL = new Set(MACHINE ? ["clients"] : ["agents", "home"]); + const OPTIONAL = new Set(MACHINE ? ["clients"] : ["agents", "home", "remotes", "flags"]); const readKeys = () => Object.keys(READS); const FAILED_WORDS = { session: MACHINE ? "Kin isn't answering on this machine right now." : "Kin can't reach this repository right now.", @@ -54,9 +56,13 @@ agents: "Kin couldn't load which agents are connected.", home: "Kin couldn't load the repositories on this machine.", clients: "Kin couldn't load which coding agents are connected.", + remotes: "Kin couldn't load this repository's remotes.", + flags: "Kin couldn't load the flags on this work.", }; /** Whether a failure only means Kin took too long, so trying again later may work. */ const isSlow = (detail) => /did not answer within|took longer than|timed out|timeout/i.test(String(detail || "")); + /** Whether Kin declined a read only because its graph was being written while the read ran, so the same read can answer once it holds still. */ + const isUnsettled = (detail) => /no settled graph authority|graph-authority writer|was mutating the graph|picked up working-copy changes|enrichment sweep was installing/i.test(String(detail || "")); const state = { reads: Object.fromEntries(Object.keys(READS).map((key) => [key, { data: null, error: null, at: null, busy: false, missing: false, sig: null }])), @@ -70,12 +76,15 @@ reviewFilter: "all", authorFilter: "all", rangeBase: null, - rangeHead: "HEAD", + rangeHead: "", explore: { query: "", results: null, error: null, busy: false, request: 0, offset: 0, active: -1 }, entity: { id: null, data: null, error: null, busy: false, request: 0, expanded: false, showAll: {}, tab: "connections" }, merge: { source: "", preview: null, result: null, error: null, remedy: null, busy: null, operationId: null, reportedOperationId: null, unknown: false, confirmed: false, receiptRestored: false }, branch: { target: "", preview: null, result: null, error: null, diagnostic: null, busy: null, confirmed: false, operationId: null, unknown: false, receiptRestored: false }, - commit: { open: false, message: "", busy: false, error: null, remedy: null, unknown: false, result: null, operationId: null }, + commit: { open: false, message: "", busy: false, error: null, remedy: null, unknown: false, result: null, operationId: null, onlyFine: true }, + restore: { asking: null, busy: false, error: null, refused: false, unknown: false, operationId: null }, + verify: { busy: false, operation: null, error: null, timer: null, unknown: false }, + remote: { busy: null, result: null, error: null, unknown: false, operationId: null }, add: { open: false, mode: "convert", path: "", source: "", inspect: null, busy: null, error: null, operation: null, timer: null }, connect: { busy: null, error: null, timer: null }, newBranch: { open: false, name: "", busy: false, error: null, created: null }, @@ -83,6 +92,7 @@ flightSelected: null, review: { target: null, guide: null, error: null, busy: false, request: 0, marks: { fine: new Set(), flagged: new Set(), notes: {} }, decision: { busy: false, error: null, done: null, comment: "" } }, diffOpen: new Set(), + testsOpen: new Set(), hubQuery: "", readEpoch: 0, destroyed: false, @@ -152,7 +162,19 @@ const spoken = (n) => n < NUMBER_WORD.length ? NUMBER_WORD[n] : n.toLocaleString(); const count = (n) => Number(n).toLocaleString(); const plural = (n, one, many) => `${count(n)} ${n === 1 ? one : many || `${one}s`}`; - const kindWord = (kind) => text(kind).replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase()); + // Kinds whose word is not their code with its underscores read as spaces. The run-together + // spellings are what an older server still sends for the same kinds. + const ENTITY_KIND_WORDS = { type_alias: "Type alias", typealias: "Type alias", trait_def: "Trait", traitdef: "Trait", enum_def: "Enum", enumdef: "Enum", + enum_variant: "Enum variant", enumvariant: "Enum variant", static_var: "Static", staticvar: "Static", api_endpoint: "API endpoint", apiendpoint: "API endpoint", + event_contract: "Event contract", eventcontract: "Event contract", document_node: "Document section", documentnode: "Document section" }; + const kindWord = (kind) => ENTITY_KIND_WORDS[text(kind).toLowerCase()] || text(kind).replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase()); + /** + * Kin records a member of a type as a method whether it is called or holds + * a value, so a member whose own declaration reads `port?: number` or + * `getter: () => T` is said to be the property it is. + */ + const PROPERTY_DECLARATION = /^\s*(?:(?:readonly|static|public|private|protected|declare|abstract|override)\s+)*[#\w$]+[?!]?\s*:(?!:)/; + const kindOf = (row) => text(row?.kind).toLowerCase() === "method" && PROPERTY_DECLARATION.test(text(row?.signature)) ? "property" : text(row?.kind); const LANGUAGE_WORDS = { typescript: "TypeScript", javascript: "JavaScript", cpp: "C++", csharp: "C#", php: "PHP", sql: "SQL", html: "HTML", css: "CSS", json: "JSON", yaml: "YAML", toml: "TOML", objc: "Objective-C" }; const languageWord = (language) => LANGUAGE_WORDS[text(language).toLowerCase()] || kindWord(language); function clock(value) { @@ -185,6 +207,14 @@ return value.replace(/\s+\d{9,}\s+[+-]\d{4}$/, "").trim(); } const authorOf = (change) => text(change?.author_name) || personName(change?.author); + const limitedGuide = (guide) => { + const completeness = guide?.summary?.completeness; + return Boolean(completeness && (completeness.state !== "settled" || completeness.unresolved_entities > 0)); + }; + const graphCount = (n, limited) => `${limited ? "at least " : ""}${count(n)}`; + const graphBadge = (n, limited) => `${limited && n > 0 ? "≥" : ""}${count(n)}`; + /** How far Kin looked for the tests it counted, finishing that sentence: " within 3 steps". Nothing when the guide does not say. */ + const withinSteps = (depth) => isCount(depth) && depth > 0 ? ` within ${count(depth)} ${depth === 1 ? "step" : "steps"}` : ""; /** * What uncommitted work means, in the guided review's words: what changed, @@ -203,23 +233,27 @@ return kind.endsWith("y") ? `${kind.slice(0, -1)}ies` : kind.endsWith("s") ? `${kind}es` : `${kind}s`; }; const it = changed === 1 ? "it" : "them"; + const quantity = (n) => limitedGuide(guide) ? `At least ${spoken(n).toLowerCase()}` : spoken(n); const parts = [`${spoken(changed)} ${noun(changed)} changed.`]; const outside = summary.recorded_consumers || 0; - if (!outside) parts.push(`Nothing outside the change relies on ${it}.`); + if (!outside) parts.push("Kin found no connections outside this change."); else { - parts.push(`${spoken(outside)} other ${outside === 1 ? "place relies" : "places rely"} on ${it}.`); + parts.push(`${quantity(outside)} other ${outside === 1 ? "place relies" : "places rely"} on ${it}.`); const behind = isCount(summary.consumers_not_updated) ? summary.consumers_not_updated : 0; // The guided review's own verdicts, when it gives them; an older guide reads as before. const check = isCount(summary.consumers_may_need_review) ? summary.consumers_may_need_review : 0; const fine = isCount(summary.consumers_need_no_update) ? summary.consumers_need_no_update : null; - if (behind && behind === outside) parts.push(outside === 1 ? "It was not updated." : outside === 2 ? "Neither was updated." : "None were updated."); + if (behind && behind === outside) parts.push(limitedGuide(guide) ? outside === 1 ? "The one Kin found was not updated." : "The ones Kin found were not updated." : outside === 1 ? "It was not updated." : outside === 2 ? "Neither was updated." : "None were updated."); else if (behind) { parts.push(`${spoken(behind)} ${behind === 1 ? "was" : "were"} not updated.`); if (check) parts.push(`Check ${spoken(check).toLowerCase()} more.`); } - else if (check) parts.push(check === outside ? (outside === 1 ? "Check it." : outside === 2 ? "Check both." : "Check each of them.") : `Check ${spoken(check).toLowerCase()} of them.`); - else if (fine !== null && fine === outside) parts.push(outside === 1 ? "It needs no update." : "None need an update."); - else parts.push(outside === 1 ? "It did not change." : "None of them changed."); + else if (check) parts.push(check === outside ? limitedGuide(guide) ? "Check the ones Kin found." : (outside === 1 ? "Check it." : outside === 2 ? "Check both." : "Check each of them.") : `Check ${spoken(check).toLowerCase()} of them.`); + else if (fine !== null && fine === outside) parts.push(limitedGuide(guide) ? "The ones Kin found need no update." : outside === 1 ? "It needs no update." : "None need an update."); + else parts.push(limitedGuide(guide) ? "The ones Kin found did not change." : outside === 1 ? "It did not change." : "None of them changed."); } const tests = summary.recorded_tests || 0; - parts.push(tests ? `${spoken(tests)} ${tests === 1 ? "test reaches" : "tests reach"} ${it}.` : `No test reaches ${it}.`); + const depths = new Set(steps.map((step) => step.connections?.tests_depth).filter((depth) => isCount(depth) && depth > 0)); + const depth = depths.size === 1 ? [...depths][0] : null; + parts.push(tests ? `${quantity(tests)} ${tests === 1 ? "test reaches" : "tests reach"} ${it}${withinSteps(depth)}.` : depths.size === 1 && steps.length && steps.every((step) => isCount(step.connections?.tests_depth)) + ? `No test found within ${count([...depths][0])} ${[...depths][0] === 1 ? "step" : "steps"} of ${it}.` : "Kin found no tests that reach this work."); return parts.join(" "); } @@ -302,19 +336,27 @@ } /** An entity by name: a way into its own page when Kin holds it. */ + /** An entity's name as a reader says it: a binding imported from another module is named for itself. */ + function shownName(name) { + const imported = /^"[^"]*"\.(.+)$/.exec(text(name)); + return imported ? imported[1] : text(name); + } + const importedName = (name) => /^"[^"]*"\./.test(text(name)); function ent(row, { linked = true } = {}) { const id = text(row?.id) || text(row?.entity_id); - const name = h("span", { class: "ent-name" }, text(row?.name) || "Entity"); + const name = h("span", { class: "ent-name" }, shownName(row?.name) || "Entity", importedName(row?.name) ? h("span", { class: "ent-tag" }, " imported") : null); return linked && UUID.test(id) ? h("a", { class: "ent", href: entityHref(id), title: text(row?.name) || undefined }, name) : h("span", { class: "ent", title: text(row?.name) || undefined }, name); } // ── Reading ────────────────────────────────────────────────────────────── async function fetchJSON(path, options = {}, attempt = 0) { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 60_000); + // A read waits a minute, or longer when its caller says nothing waits on it. + const { patience = 60_000, ...sent } = options; + const timer = setTimeout(() => controller.abort(), patience); const url = path.startsWith("root:") ? path.slice(5) : `${apiBase}${path}`; try { - const response = await fetch(url, { ...options, signal: controller.signal, headers: { accept: "application/json", ...(options.headers || {}) } }); + const response = await fetch(url, { ...sent, signal: controller.signal, headers: { accept: "application/json", ...(sent.headers || {}) } }); // Kin answers "too many at once" when several heavy reads land together. A read // changes nothing, so it waits its turn and asks again; a write is never resent. if (response.status === 429 && attempt < 4 && (!options.method || options.method === "GET")) { @@ -336,12 +378,13 @@ if (typeof body?.operation_id === "string") error.operationId = body.operation_id; if (typeof body?.remedy === "string") error.remedy = body.remedy; if (typeof body?.detail === "string") error.detail = body.detail; + if (body?.operation && typeof body.operation === "object") error.operation = body.operation; throw error; } if (!body || typeof body !== "object") throw new Error("The local server returned nothing readable."); return body; } catch (error) { - if (error.name === "AbortError") throw new Error("This read took longer than a minute."); + if (error.name === "AbortError") throw new Error(`This read took longer than ${patience === 60_000 ? "a minute" : patience % 60_000 ? `${Math.round(patience / 1000)} seconds` : `${patience / 60_000} minutes`}.`); throw error; } finally { clearTimeout(timer); } } @@ -350,6 +393,47 @@ return fetchJSON(path, { method: "POST", headers: { "content-type": "application/json", "x-kin-review-token": session()?.token || "" }, body: JSON.stringify(body) }); } + const workReceiptKey = (kind) => session()?.repository ? `kin.workspace.pending-${kind}:${JSON.stringify([apiBase, session().repository.key || null])}` : null; + function rememberWorkReceipt(kind, operationId) { + try { sessionStorage.setItem(workReceiptKey(kind), JSON.stringify({ operationId })); } catch (_) { /* The in-memory lockout remains. */ } + } + function forgetWorkReceipt(kind) { + try { sessionStorage.removeItem(workReceiptKey(kind)); } catch (_) { /* Preserve the observed result. */ } + } + function restoreWorkReceipts() { + for (const kind of ["commit", "restore", "remote"]) { + const held = state[kind]; + if (held.receiptRestored) continue; + held.receiptRestored = true; + try { + const receipt = JSON.parse(sessionStorage.getItem(workReceiptKey(kind))); + if (!UUID.test(text(receipt?.operationId))) continue; + held.operationId = receipt.operationId; held.unknown = true; + held.error = "An earlier request was sent from this tab. Check its result before starting another."; + } catch (_) { /* No readable receipt to restore. */ } + } + } + + // A committed change's guide, once read in this tab by Home or by a review, + // is handed to the next page that wants it, so the same change is not read + // twice. A recorded change does not move, so a recent reading is the one. + const changeGuideKey = (base, id) => `kin.workspace.change-guide:${base}:${id}`; + const CHANGE_GUIDE_KEPT = 30 * 60_000; + function heldChangeGuide(base, id) { + try { + const held = JSON.parse(sessionStorage.getItem(changeGuideKey(base, id))); + if (held?.data?.summary && Number.isFinite(held.at) && Date.now() - held.at < CHANGE_GUIDE_KEPT && (!held.data.change?.id || held.data.change.id === id)) return held.data; + } catch (_) { /* Nothing kept; the read answers. */ } + return null; + } + function keepChangeGuide(base, id, data) { + if (!data?.summary) return; + try { + const body = JSON.stringify({ at: Date.now(), data }); + if (body.length < 600_000) sessionStorage.setItem(changeGuideKey(base, id), body); + } catch (_) { /* The page still holds it. */ } + } + const workingKey = () => `kin.workspace.working:${apiBase}`; /** The last uncommitted-work answer this tab saw, shown while a new one is read. */ function recallWorking() { @@ -372,6 +456,38 @@ return key === "changes" ? `${READS.changes}?count=${state.requested}` : READS[key]; } + /** + * The list of repositories as this page keeps it: a repository whose daemon + * did not answer this time keeps what the reading before said of its + * branch, its work, its merge and its agents, with when that was known, + * rather than dropping to nothing in motion. A daemon that has stopped + * holds no agent session, so none is kept for it, and a count of work not + * arrived yet for a daemon that answers shows the last count until it does. + */ + function carryHome(next, previous, previousAt) { + const before = new Map(array(previous?.repositories).filter((row) => row && typeof row.key === "string").map((row) => [row.key, row])); + if (!before.size || !Array.isArray(next?.repositories)) return next; + const then = Number.isFinite(previousAt) ? new Date(previousAt).toISOString() : null; + const counted = (working) => Boolean(working) && typeof working === "object" && working.unknown !== true; + return { ...next, repositories: next.repositories.map((row) => { + const held = row && before.get(row.key); + if (!held) return row; + const out = { ...row }; + const reach = text(row.reach); + if (!counted(row.working) && counted(held.working)) out.working = held.working; + if (reach === "answering") return out; + let carried = false; + for (const field of ["branch", "merge_in_progress", ...(reach === "stopped" ? [] : ["agents"])]) { + if (out[field] === undefined && held[field] !== undefined) { out[field] = held[field]; carried = true; } + } + if (out.working !== row.working) carried = true; + if (carried || text(row.known_at)) out.known_at = text(row.known_at) || text(held.known_at) || then; + // A server that does not say whether the daemon answered is taken at its word that it said nothing. + if (carried && !reach) out.reach = "not_answering"; + return out; + }) }; + } + async function load(key) { const resource = read(key); const epoch = state.readEpoch; @@ -379,7 +495,8 @@ if (resource.missing && OPTIONAL.has(key)) return; resource.busy = true; try { - const data = await fetchJSON(pathFor(key)); + const answer = await fetchJSON(pathFor(key)); + const data = key === "home" && Array.isArray(answer?.repositories) ? carryHome(answer, resource.data, resource.at) : answer; if (epoch !== state.readEpoch) return; if (key === "session" && !MACHINE && (!data.repository || typeof data.repository.name !== "string")) throw new Error("The server did not name the repository."); if (key === "changes" && !Array.isArray(data.changes)) throw new Error("Changes were not returned in a readable form."); @@ -396,8 +513,9 @@ resource.data = data; resource.at = Date.now(); resource.error = null; + resource.missing = false; if (key === "working") { if (resource.stale) resource.changed = true; resource.stale = false; keepWorking(data); } - if (key === "session" && !MACHINE) { restoreMergeReceipt(); restoreBranchReceipt(); } + if (key === "session" && !MACHINE) { restoreMergeReceipt(); restoreBranchReceipt(); restoreWorkReceipts(); if (data.capabilities?.remote_pull_checking === true) checkPull(epoch); } } catch (error) { if (epoch !== state.readEpoch) return; if (OPTIONAL.has(key) && [404, 405, 501].includes(error.status)) { resource.missing = true; resource.error = null; resource.changed = true; } @@ -405,9 +523,30 @@ } finally { if (epoch === state.readEpoch) resource.busy = false; } } + /** + * A session that answered before Kin knew whether a pull can work here asks + * for that answer on its own, and the remotes redraw when it lands. Until + * then the pull says it is being checked rather than disappearing. + */ + async function checkPull(epoch) { + if (state.pullChecking) return; + state.pullChecking = true; + try { + const answer = await fetchJSON("/api/workspace/session/pull"); + const data = read("session").data; + if (epoch !== state.readEpoch || !data?.capabilities || answer?.remote_pull_checking === true) return; + data.capabilities = { ...data.capabilities, remote_pull: answer.remote_pull === true, remote_pull_checking: false, + remote_pull_unavailable_reason: typeof answer.remote_pull_unavailable_reason === "string" ? answer.remote_pull_unavailable_reason : null, + ...(typeof answer.remote_fetch === "boolean" ? { remote_fetch: answer.remote_fetch } : {}) }; + paint("branches-remotes"); + } catch { /* The session's own answer stands, and the next refresh asks again. */ } + finally { state.pullChecking = false; } + } + async function refresh(keys = readKeys()) { if (state.refreshing || state.branch.busy || state.merge.busy || state.commit.busy) return; state.refreshing = true; + for (const key of keys) if (OPTIONAL.has(key)) read(key).missing = false; setRefreshDisabled(true); updateChrome(); const hadSession = Boolean(session()); @@ -421,7 +560,7 @@ if (!hadSession || !session()) return; syncAll([key]); drawn.add(key); - if (["branches", "working", "agents", "home"].includes(key)) updateChrome(); + if (["branches", "working", "agents", "home", "changes", "standing"].includes(key)) updateChrome(); if (["branches", "branchState", "conflicts"].includes(key)) repaintPanels(); }; await Promise.all(keys.map(settle)); @@ -489,7 +628,7 @@ const ready = [...pulsing.keys].filter((key) => !read(key).busy); pulsing.keys = new Set([...pulsing.keys].filter((key) => read(key).busy)); await Promise.all(ready.map(async (key) => { await load(key); syncAll([key]); if (key === "home") loadHomeDetails(); })); - if (ready.some((key) => ["changes", "branches", "working", "agents", "home"].includes(key))) updateChrome(); + if (ready.some((key) => ["changes", "standing", "branches", "working", "agents", "home"].includes(key))) updateChrome(); if (pulsing.keys.size) pulse([], 800); }, delay); } @@ -541,11 +680,31 @@ const section_ok = (value) => value && typeof value === "object" && value.ok !== false; + const MODULE_KINDS = new Set(["module", "package", "namespace"]); + // A module that only says which module holds it, such as an index, is named for that one. + const HOLDER_ONLY = new Set(["index", "mod", "__init__", "lib", "main", "init"]); + // A suffix that is part of a module's own name, as `apply.test` is. + const NAME_SUFFIX = new Set(["test", "tests", "spec", "d", "config"]); + /** + * A module as code names it: `schemas`, not the dotted place + * `zod.src.v4.classic.schemas` it is recorded under, and `ws`, not + * `extract.ws`. A suffix that is part of the module's own name stays: + * `apply.test` is the module's name. + */ + function moduleName(name) { + const parts = text(name).split(/::|[./\\]/).filter(Boolean); + if (parts.length < 2) return text(name); + if (HOLDER_ONLY.has(parts.at(-1))) return parts.at(-2); + return NAME_SUFFIX.has(parts.at(-1)) ? parts.slice(-2).join(".") : parts.at(-1); + } function containerOf(row) { if (!row) return ""; + const owner = row.container && typeof row.container === "object" ? row.container : row.semantic_owners?.[0] && typeof row.semantic_owners[0] === "object" ? row.semantic_owners[0] : null; const container = row.container && typeof row.container === "object" ? text(row.container.name) : text(row.container); - const explicit = container || text(row.container_name) || text(row.semantic_owners?.[0]?.name) || text(row.module); - if (explicit) return explicit; + const explicit = container || text(row.container_name) || text(row.semantic_owners?.[0]?.name); + if (explicit) return owner && MODULE_KINDS.has(text(owner.kind).toLowerCase()) ? moduleName(explicit) : explicit; + // The module a row is recorded under is always a module, so it is named as code names it. + if (text(row.module)) return moduleName(row.module); const name = text(row.name); return name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : ""; } @@ -583,6 +742,15 @@ params: () => state.params, // The one count a change is shown with: its own review's, as Changes shows it. changedCount: (id) => changedCount({ id }), + // How a change's review stands: "pending" until someone decides, and null while review decisions are not read. + reviewState: (id) => { + const standing = read("standing").data?.standing; + if (!standing || typeof standing !== "object") return null; + const value = text(standing[id]?.state); + return value === "needswork" ? "needs_work" : value || "pending"; + }, + // Whether a section is on the page now, so work for one nobody can see waits. + shown: (id) => Boolean(state.sections.get(id)?.node?.isConnected), repaintAgents: () => { for (const id of ["agents-lede", "agents-list", "agents-detail"]) paint(id); updateChrome(); }, }; @@ -596,12 +764,66 @@ const ACTIVITY_WORDS = { starting: "Starting", reconciling: "Reading your edits", indexing: "Building the graph", enriching: "Linking references", reading_history: "Reading its history" }; /** What Kin is busy with in a repository, in a few words; nothing when it is idle. */ const repoActivity = (row) => ACTIVITY_WORDS[text(row?.activity?.phase)] || ""; + /** + * What is in motion in a repository, as its dot and its place in the list: + * work of yours waiting first, then an agent's committed change nobody has + * reviewed, then an agent working. + */ function repoTone(row) { + // Home says it from the one reading every part of Home is drawn from, so the dot and the card agree. + if (MACHINE) { const view = homeView(row); if (view) return view.tone; } // Marked only once Kin has counted something changed: its quick reading also differs for Kin's own linking. if ((isCount(row?.working?.changed) && row.working.changed > 0) || row?.merge_in_progress) return "work"; + if (awaitingReview(row).length) return "review"; if (isCount(row?.agents?.active) && row.agents.active > 0) return "live"; return ""; } + const TONE_RANK = { work: 0, review: 1, live: 2, "": 3 }; + + // Which agent changes nobody has reviewed is read from each repository by + // Home, and by a repository's own page for itself. What this tab last read + // is kept so every page's sidebar says the same thing about a repository + // until it is read again. + const reviewKey = (key) => `kin.workspace.awaiting-review:${key}`; + function keepAwaiting(key, ids) { + try { sessionStorage.setItem(reviewKey(key), JSON.stringify(ids)); } catch (_) { /* The page still holds it. */ } + } + function keptAwaiting(key) { + try { const held = JSON.parse(sessionStorage.getItem(reviewKey(key))); return Array.isArray(held) ? held.filter((id) => typeof id === "string") : []; } catch (_) { return []; } + } + const TONES_KEY = "kin.workspace.side-tones"; + function keepTones(tones) { + try { sessionStorage.setItem(TONES_KEY, JSON.stringify(tones)); } catch (_) { /* The page still holds them. */ } + } + function keptTones() { + try { const held = JSON.parse(sessionStorage.getItem(TONES_KEY)); return held && typeof held === "object" ? Object.fromEntries(Object.entries(held).filter(([, tone]) => tone in TONE_RANK)) : {}; } catch (_) { return {}; } + } + /** The ids of a repository's agent changes nobody has reviewed, from the freshest reading this page has. */ + function awaitingReview(row) { + const key = text(row?.key); + if (!key) return []; + if (MACHINE) return awaitingChanges(row).map((change) => change.id); + if (key === text(session()?.repository?.key)) { + const own = ownAwaiting(); + return own ?? keptAwaiting(key); + } + return keptAwaiting(key); + } + /** + * This repository's agent changes nobody has reviewed, among its newest few + * as Home reads them, from its own changes and review standing; null until + * both are read. + */ + function ownAwaiting() { + const standing = read("standing").data?.standing; + const changes = read("changes").data?.changes; + if (!standing || typeof standing !== "object" || !Array.isArray(changes)) return null; + const ids = changes.slice(0, OVERVIEW_CHANGES) + .filter((change) => !isKinUpdate(change) && change.agent && typeof change.id === "string" && (standing[change.id]?.state || "pending") === "pending").map((change) => change.id); + const key = text(session()?.repository?.key); + if (key) keepAwaiting(key, ids); + return ids; + } /** Connect one coding agent to Kin, then read the list again when Kin says it is done. */ async function connectClient(id) { @@ -625,6 +847,16 @@ } catch (error) { finish(error.message || "Kin could not connect it."); } } + /** When a coding agent last worked through Kin, and where; nothing is said when the server does not report it. */ + function lastUsed(row) { + if (!("last_session" in row)) return null; + const last = row.last_session; + if (!last) return h("span", { class: "client-state" }, "Has not worked through Kin yet"); + const where = text(last.repository?.name) || "a repository"; + const place = safeLocalURL(last.repository?.url, null) ? h("a", { class: "link", href: safeLocalURL(last.repository.url, "/") }, where) : where; + return h("span", { class: "client-state" }, ...(last.state === "active" ? ["Working in ", place, " now"] : last.state === "idle" ? ["Connected to ", place] : ["Last worked in ", place, ` ${ago(last.at)}`])); + } + function hubClients() { const resource = read("clients"); if (resource.missing) return h("p", { class: "gap" }, "This version of Kin can't list coding agents here."); @@ -639,8 +871,8 @@ h("ul", { class: "list list-card" }, rows.map((row) => h("li", { class: "client-row" }, h("span", { class: "client-name" }, agentsApi()?.mark?.(14) || null, row.name), !row.detected ? h("span", { class: "client-state" }, "Not installed on this machine") - : row.connected ? h("span", { class: "client-state client-state-on" }, "Connected") - : [h("span", { class: "client-state" }, "Installed, not connected"), + : row.connected ? [lastUsed(row), h("span", { class: "client-state client-state-on" }, "Configured")] + : [h("span", { class: "client-state" }, "Installed, not configured"), button(connect.busy === row.id ? "Connecting…" : "Connect", () => connectClient(row.id), { class: "button button-small", disabled: Boolean(connect.busy) || !session()?.token })])))); } @@ -650,8 +882,20 @@ const KIND_WORDS = { kin: "Kin repository", git: "Git project", plain: "Project without history", missing: "Nothing here yet", not_a_directory: "Not a project" }; const ACTION_WORDS = { convert: ["Convert to Kin", "Converting"], create: ["Create repository", "Creating"], open: ["Open in Kin", "Opening"], clone: ["Clone", "Cloning"] }; + const candidates = { data: null, busy: false, missing: false }; + async function loadCandidates(again = false) { + if (candidates.busy || candidates.missing || (candidates.data && !again)) return; + candidates.busy = true; + try { candidates.data = await fetchJSON("root:/api/home/candidates?limit=30"); } + catch (_) { if (!candidates.data) candidates.missing = true; } + candidates.busy = false; + paint("hub-candidates"); + // Commit counts arrive after the list; ask again while some are still being counted and the form is open. + if (isCount(candidates.data?.counting) && candidates.data.counting > 0 && state.add.open) setTimeout(() => loadCandidates(true), 1500); + } function openAdd(mode) { state.add.open = true; + loadCandidates(); if (mode) state.add.mode = mode; paint("hub-add"); paint("hub-add-toggle"); byId("add-path")?.focus(); @@ -796,7 +1040,23 @@ h("div", { class: "field" }, h("label", { for: "add-path" }, clone ? "Put it here on this machine" : add.mode === "create" ? "Where to create it" : "Where the project is"), path), h("button", { type: "submit", id: "add-submit", class: `button${clone ? " button-primary" : ""}`, disabled: locked || !ready() }, add.busy === "inspect" ? "Checking…" : add.busy === "clone" ? "Starting…" : clone ? "Clone" : "Check")), add.error ? h("div", { class: "message message-bad section-tight", role: "alert" }, h("div", {}, h("strong", {}, "Kin couldn't do that"), h("p", {}, add.error))) : null, - add.operation ? operationBox(add.operation) : add.inspect ? inspectBox(add.inspect) : null); + add.operation ? operationBox(add.operation) : add.inspect ? inspectBox(add.inspect) : null, + !add.operation && !add.inspect && add.mode === "convert" ? section("hub-candidates", [], candidateList) : null); + } + + /** Projects Kin found on this machine that are not in it yet, most recently worked on first. */ + function candidateList() { + if (candidates.missing) return null; + if (!candidates.data) { loadCandidates(); return h("p", { class: "note aside-reading" }, "Looking for projects on this machine"); } + const rows = array(candidates.data.candidates).filter((row) => row && typeof row.name === "string" && typeof row.path === "string" && !row.registered && array(row.actions).some((action) => action === "convert" || action === "create")); + if (!rows.length) return null; + const locked = Boolean(state.add.busy) || state.add.operation?.state === "running"; + return h("div", { class: "candidates" }, + h("h3", { class: "sites-title" }, "Projects on this machine"), + h("ul", { class: "list list-card" }, rows.slice(0, 12).map((row) => h("li", { class: "branch-row" }, + h("div", {}, h("div", { class: "branch-name" }, row.name, text(row.git?.branch) ? h("span", { class: "pill" }, row.git.branch) : null), + h("div", { class: "branch-meta" }, [row.kind === "git" ? (isCount(row.git?.commits) ? `${plural(row.git.commits, "commit")} of history` : "Git project") : "No Git history", row.modified_at ? `worked on ${ago(row.modified_at)}` : ""].filter(Boolean).join(" · "))), + h("div", { class: "branch-actions" }, button("Add", () => { state.add.path = row.path; state.add.inspect = row; runAdd(array(row.actions).includes("convert") ? "convert" : "create"); }, { class: "button button-small", disabled: locked })))))); } // ── Home ───────────────────────────────────────────────────────────────── @@ -807,18 +1067,69 @@ // to them. const HOME_OPEN = 6; - const HOME_PARTS = { overview: "/api/workspace/overview", agents: "/api/workspace/agents", working: "/api/guide?working=1" }; + /** How many of its newest changes a repository's summary carries, which is where Home finds an agent's. */ + const OVERVIEW_CHANGES = 5; + const HOME_PARTS = { overview: "/api/workspace/overview", agents: "/api/workspace/agents", working: "/api/guide?working=1", standing: "/api/standing" }; const homeDetail = new Map(); const heldWorkingKey = (key) => `kin.workspace.working:/repositories/${key}`; + // What Home last read of each repository's summary, agents and review + // standing is kept for this tab, so a reload, or a repository that is slow + // to answer, shows what was last known rather than nothing until it does. + const heldDetailKey = (key) => `kin.workspace.home-detail:${key}`; + function keepDetail(key, detail) { + try { + const body = JSON.stringify({ at: Date.now(), overview: detail.overview, agents: detail.agents, standing: detail.standing }); + if (body.length < 300_000) sessionStorage.setItem(heldDetailKey(key), body); else sessionStorage.removeItem(heldDetailKey(key)); + } catch (_) { /* The page still holds it. */ } + } + /** + * A summary read again keeps any part of the one before that it could not + * read this time, so a part that timed out under load does not take what + * was known with it. + */ + function keepGoodSections(next, previous) { + if (!previous || typeof previous !== "object" || !next || typeof next !== "object") return next; + const out = { ...next }; + for (const [name, part] of Object.entries(previous)) { + const fresh = next[name]; + if (section_ok(part) && part && typeof part === "object" && "ok" in part && fresh && typeof fresh === "object" && fresh.ok === false) out[name] = { ...part, kept: true }; + } + return out; + } + /** The changes agents made among a repository's recent ones, newest first, past Kin's own updates. */ + function agentChanges(overview) { + const recent = overview?.recent_changes; + if (!section_ok(recent)) return []; + return array(recent.items).filter((change) => change && !isKinUpdate(change) && change.agent && typeof change.id === "string"); + } + /** + * The agent changes among a repository's recent ones that nobody has + * reviewed, once Home has read its review standing. Any of them, not only + * the newest: a change of yours on top does not review the agent's. + */ + function unreviewedAgentChanges(row) { + const detail = homeDetail.get(row.key); + const standing = detail?.standing?.standing; + if (!standing || typeof standing !== "object") return []; + return agentChanges(detail?.overview).filter((change) => (standing[change.id]?.state || "pending") === "pending"); + } function detailFor(key) { let detail = homeDetail.get(key); if (detail) return detail; - detail = { overview: null, agents: null, working: null, stale: false, clean: false, busy: new Set(), failed: {}, sigs: {}, chain: Promise.resolve() }; + detail = { overview: null, agents: null, working: null, standing: null, stale: false, clean: false, busy: new Set(), failed: {}, sigs: {}, chain: Promise.resolve() }; // The last reading of its uncommitted work this tab saw, shown while a new one is read. try { const held = JSON.parse(sessionStorage.getItem(heldWorkingKey(key))); if (held?.data?.summary) { detail.working = held.data; detail.stale = true; } } catch (_) { /* Nothing kept; the read answers. */ } + // Its summary and review standing as this tab last read them. Who was connected then is not + // carried over a reload, since it is not known to hold now; who worked here earlier is. + try { + const kept = JSON.parse(sessionStorage.getItem(heldDetailKey(key))); + if (kept?.overview && typeof kept.overview === "object") detail.overview = kept.overview; + if (kept?.standing?.standing && typeof kept.standing.standing === "object") detail.standing = kept.standing; + if (kept?.agents && typeof kept.agents === "object") detail.agents = { ...kept.agents, agents: [], restored: true }; + } catch (_) { /* Nothing kept; the reads answer. */ } homeDetail.set(key, detail); return detail; } @@ -839,14 +1150,24 @@ detail.chain = detail.chain.then(async () => { let moved = false; try { - const data = await fetchJSON(`root:/repositories/${row.key}${HOME_PARTS[part]}`); + const answer = await fetchJSON(`root:/repositories/${row.key}${HOME_PARTS[part]}`); if (epoch !== state.readEpoch) return; - if (part === "working" && !data.summary) throw new Error("Uncommitted work was not returned in a readable form."); + if (part === "working" && !answer.summary) throw new Error("Uncommitted work was not returned in a readable form."); + const data = part === "overview" ? keepGoodSections(answer, detail.overview) : answer; const sig = JSON.stringify({ ...data, read_at: null }); moved = sig !== detail.sigs[part] || Boolean(detail.failed[part]) || (part === "working" && detail.stale); detail.sigs[part] = sig; detail[part] = data; delete detail.failed[part]; + if (part === "standing") detail.standingAt = Date.now(); + if (part === "agents") detail.agentsAt = Date.now(); + if (part === "overview" && agentChanges(data).length && (moved || !detail.standing)) askDetail(row, "standing"); + // A change the agents say they committed waits on how its review stands, as one the summary lists does. + if (part === "agents" && agentsCommitted(row).length && (moved || !detail.standing)) askDetail(row, "standing"); + // What waits for a review is kept for this tab, so a repository's own page says the same of it. + if (part === "overview" && !agentChanges(data).length) keepAwaiting(row.key, []); + else if (detail.standing && ["overview", "standing"].includes(part)) keepAwaiting(row.key, unreviewedAgentChanges(row).map((change) => change.id)); + if (part !== "working") keepDetail(row.key, detail); if (part === "working") { detail.stale = false; detail.clean = !hasWork(data); @@ -874,9 +1195,14 @@ const moved = detail.rowSig !== sig; detail.rowSig = sig; if (first || (moved && row.last_change_at !== detail.lastChange)) askDetail(row, "overview"); + // A review recorded elsewhere moves no change, so what nobody has reviewed is read again now and then. + else if (detail.standing && unreviewedAgentChanges(row).length && Date.now() - (detail.standingAt || 0) > 30_000) askDetail(row, "standing"); detail.lastChange = row.last_change_at; const connected = (row.agents?.connected || 0) + (row.agents?.active || 0) > 0; if (first || moved || connected) askDetail(row, "agents"); + // An agent's change waiting for review is drawn on its card; a read that failed is tried again now and then. + const waiting = awaitingChanges(row)[0]; + if (waiting && repoWork(row).state !== "work" && !row.merge_in_progress) askChange(row, waiting.id); // The list says a repository is clean without anyone reading its work again. const told = row.working && row.working.unknown !== true ? row.working : null; if (told && !told.differs && !(told.changed > 0)) { @@ -887,19 +1213,28 @@ } const homeSigs = {}; - /** Draw again each part of Home whose own facts changed, and leave the rest as it stands. */ + /** + * Draw again each part of Home whose own facts changed, and leave the rest + * as it stands. Every part's facts are read from one snapshot of what Home + * knows, the same one each part is drawn from, so a part is never left + * saying something another part no longer says. + */ function homeFacts() { - const rows = homeListed(); + const snap = homeSnapshot(); const minute = Math.floor(Date.now() / 60_000); - const work = rows.map((row) => { const detail = homeDetail.get(row.key); return [row.key, row.working || null, Boolean(row.merge_in_progress), detail?.sigs.working?.length, detail?.stale, detail?.clean, detail?.busy.has("working"), detail?.failed.working || null]; }); - const agents = rows.map((row) => [row.key, row.agents || null, homeDetail.get(row.key)?.sigs.agents]); - const shape = rows.map((row) => { const detail = homeDetail.get(row.key); return [row.key, row.branch, row.runtime, row.entities, row.last_change_at, detail?.sigs.overview?.length, repoWork(row).state, row.activity?.phase || null]; }); + const facts = (view) => { + const detail = homeDetail.get(view.key); + return [view.key, view.row.branch, view.row.name, view.work.state, view.work.changed ?? null, Boolean(view.work.stale), detail?.sigs.working?.length, detail?.busy.has("working"), detail?.failed.working || null, + Boolean(view.row.merge_in_progress), view.awaiting.map((change) => change.id).join(","), changeFacts(view.row, view.awaiting[0]?.id), view.unreachable ? view.knownAt : null, placesRead]; + }; + const agents = snap.views.map((view) => [view.key, view.agents.active, view.agents.connected, view.agents.named, view.agents.listed, homeDetail.get(view.key)?.sigs.agents, homeDetail.get(view.key)?.sigs.standing]); + const shape = snap.views.map((view) => { const detail = homeDetail.get(view.key); return [view.key, view.row.branch, view.row.runtime, view.row.entities, view.row.last_change_at, detail?.sigs.overview?.length, view.work.state, view.work.changed ?? null, view.row.activity?.phase || null, view.awaiting.length, view.agents.active, view.agents.named, view.unreachable ? view.knownAt : null]; }); return { - "home-headline": JSON.stringify([Boolean(read("home").data), read("home").error, rows.length, work, agents.map((held) => held[1]), homeSessions().live.map((held) => held.agent.state)]), - "home-ask-box": JSON.stringify(rows.length > 0), - "home-attention": JSON.stringify([work, rows.map((row) => [row.branch, row.name])]), + "home-headline": JSON.stringify([Boolean(read("home").data), read("home").error, snap.views.length, snap.needs.map((view) => [view.key, view.row.name]), snap.active, snap.connected, snap.unreachable.map((view) => [view.key, view.knownAt])]), + "home-ask-box": JSON.stringify(snap.views.length > 0), + "home-attention": JSON.stringify(snap.needs.map(facts)), "home-agents": JSON.stringify([agents, minute]), - "home-repos": JSON.stringify([shape, work, agents.map((held) => held[1]), minute, read("home").data?.unavailable, read("home").data?.warning]), + "home-repos": JSON.stringify([shape, minute, read("home").data?.unavailable, read("home").data?.warning]), "home-recent": JSON.stringify([shape.map((held) => [held[0], held[5]]), minute]), }; } @@ -930,21 +1265,184 @@ if (detail?.busy.has("working") || detail?.busy.has("overview")) return { state: row.runtime === "running" ? "reading" : "opening" }; return { state: row.runtime === "running" ? "unknown" : "closed" }; } - const homeNeeds = () => homeListed().filter((row) => repoWork(row).state === "work" || row.merge_in_progress); - /** Every agent session Home knows of, each with the repository it is in. */ - function homeSessions() { + /** How much a repository's waiting work asks of you: left behind first, then what may need review, then untested. */ + function needWeight(row) { + const summary = repoWork(row).guide?.summary || {}; + const num = (value) => isCount(value) ? value : 0; + return [row.merge_in_progress ? 1 : 0, num(summary.consumers_not_updated), num(summary.consumers_may_need_review), num(summary.recorded_tests) ? 0 : 1, num(summary.added) + num(summary.modified) + num(summary.removed)]; + } + /** + * A repository's agent changes nobody has reviewed, among the newest Home + * read: by the review standing Home read, or until that is read, by what + * this tab last knew of it. Every part of Home counts these the same way. + */ + function awaitingChanges(row) { + const detail = homeDetail.get(row.key); + const changes = [...agentChanges(detail?.overview), ...agentsCommitted(row)]; + if (!changes.length) return []; + const standing = detail?.standing?.standing; + if (standing && typeof standing === "object") return changes.filter((change) => (standing[change.id]?.state || "pending") === "pending"); + const kept = new Set(keptAwaiting(row.key)); + return changes.filter((change) => kept.has(change.id)); + } + /** How long ago a change an agent says it committed may be, to be waiting on the reader before the repository's summary lists it. */ + const LISTED_CHANGE_WINDOW = 86_400_000; + /** + * The changes the agents a repository lists say they committed, that are + * newer than every change its summary lists. A busy daemon can answer who + * worked here well before it can read its own recent history, and an + * agent's commit waits on the reader from the moment it lands. + */ + function agentsCommitted(row) { + const detail = homeDetail.get(row.key); + const data = detail?.agents; + if (!data) return []; + const listed = new Set(array(detail.overview?.recent_changes?.items).map((change) => text(change?.id))); + const newest = Math.max(0, ...array(detail.overview?.recent_changes?.items).map((change) => Date.parse(change?.time || change?.timestamp || "") || 0)); + const seen = new Set(); + const out = []; + for (const agent of [...array(data.agents), ...array(data.earlier)]) { + for (const change of array(agent?.changes)) { + const id = text(change?.id); + if (!/^[0-9a-f]{64}$/i.test(id) || listed.has(id) || seen.has(id)) continue; + const at = Date.parse(change.time || "") || Date.parse(agent.last_activity_at || "") || 0; + if (at <= newest || Date.now() - at > LISTED_CHANGE_WINDOW) continue; + seen.add(id); + out.push({ id, title: text(change.title), time: change.time || agent.last_activity_at, agent: { name: text(agent.name) || "An agent", key: agent.key }, listed: true }); + } + } + return out; + } + /** How the reader left an agent's change, as Home last read it; null until Home has read the repository's standing. */ + function reviewStateIn(row, id) { + const standing = homeDetail.get(row.key)?.standing?.standing; + if (!standing || typeof standing !== "object") return null; + return text(standing[id]?.state) || "pending"; + } + /** How long a repository's own list of agents stays the newer word after the machine's list is read again. */ + const AGENTS_LEAD = 15_000; + /** + * Who is connected in a repository, from the newer of two readings: its own + * list of agents, which names each session and what it did, and the + * machine's list, which only counts them. A daemon that is not running + * holds no agent session. + */ + function repoAgents(row) { + const detail = homeDetail.get(row.key); + const earlier = array(detail?.agents?.earlier); + const names = detail?.agents?.names || {}; + if (row.reach === "stopped") return { live: [], earlier, names, active: 0, connected: 0, named: [], listed: false }; + const data = detail?.agents && !detail.agents.restored ? detail.agents : null; + const listAt = Date.parse(text(row.known_at)) || read("home").at || 0; + if (data && Array.isArray(data.agents) && (!row.agents || (detail.agentsAt || 0) >= listAt - AGENTS_LEAD)) { + const live = data.agents.filter((agent) => agent && typeof agent.name === "string"); + return { live, earlier, names, active: live.filter((agent) => agent.state === "active").length, connected: live.length, named: [...new Set(live.map((agent) => agent.name))], listed: true }; + } + const told = row.agents || {}; + return { live: [], earlier, names, active: isCount(told.active) ? told.active : 0, connected: isCount(told.connected) ? told.connected : 0, named: array(told.names).filter((name) => typeof name === "string"), listed: false }; + } + /** One repository as every part of Home says it. */ + function repoView(row) { + const work = repoWork(row); + const awaiting = awaitingChanges(row); + const agents = repoAgents(row); + const knownAt = text(row.known_at); + // What a repository's daemon last said, when it is not answering now or has stopped. + const unreachable = ["not_answering", "stopped"].includes(text(row.reach)) && !Number.isNaN(Date.parse(knownAt)); + const waiting = work.state === "work" || Boolean(row.merge_in_progress); + return { row, key: row.key, work, awaiting, agents, knownAt, unreachable, needs: waiting || awaiting.length > 0, tone: waiting ? "work" : awaiting.length ? "review" : agents.active > 0 ? "live" : "" }; + } + const homeView = (row) => (MACHINE && row ? repoView(row) : null); + /** + * Everything Home says, read at once: each repository's view, what needs + * the reader, who is at work, and which repositories Kin cannot reach. + * Every part of Home and the sidebar is drawn from this. + */ + function homeSnapshot() { + const views = homeListed().map(repoView); + const needs = views.filter((view) => view.needs).sort((a, b) => { const [x, y] = [needWeight(a.row), needWeight(b.row)]; for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return y[i] - x[i]; return 0; }); const live = [], earlier = []; - for (const row of homeListed()) { - const data = homeDetail.get(row.key)?.agents; - for (const agent of array(data?.agents)) live.push({ agent, row, names: data.names || {} }); - for (const agent of array(data?.earlier)) earlier.push({ agent, row, names: data.names || {} }); + for (const view of views) { + const { agents } = view; + for (const agent of agents.live) live.push({ agent, row: view.row, names: agents.names }); + // An agent the machine's list counts before its repository has named it is still said, by the name the list gives. + if (!agents.listed) for (let at = 0; at < agents.connected; at++) live.push({ agent: { name: agents.named[at] || agents.named[0] || "An agent", state: at < agents.active ? "active" : "idle", counted: true }, row: view.row, names: {} }); + for (const agent of agents.earlier) earlier.push({ agent, row: view.row, names: agents.names }); } const when = (held) => Date.parse(held.agent.last_activity_at || "") || 0; - return { live: live.sort((a, b) => when(b) - when(a)), earlier: earlier.sort((a, b) => when(b) - when(a)) }; + live.sort((a, b) => when(b) - when(a)); + earlier.sort((a, b) => when(b) - when(a)); + return { views, needs, live, earlier, active: views.reduce((sum, view) => sum + view.agents.active, 0), connected: views.reduce((sum, view) => sum + view.agents.connected, 0), unreachable: views.filter((view) => view.unreachable) }; } + const homeNeeds = () => homeSnapshot().needs.map((view) => view.row); + /** Every agent session Home knows of, each with the repository it is in. */ + function homeSessions() { + const { live, earlier } = homeSnapshot(); + return { live, earlier }; + } + /** When Home last heard from a repository, as a time of day, with its date when that was not today. */ + function knownWhen(at) { + const date = new Date(at); + if (Number.isNaN(date.getTime())) return ""; + return date.toDateString() === new Date().toDateString() ? clock(date) : date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); + } + /** What Home says of a repository it shows from what Kin last knew. */ + const unreachableWords = (view) => `Kin can't reach ${repositoryLabel(view.row, " / ")} right now; showing what it knew at ${knownWhen(view.knownAt)}.`; // One question, put to every repository Home has open, answered by each from its own graph. - const homeAsk = { query: "", request: 0, timer: null, asked: [], answers: new Map(), busy: false }; + const homeAsk = { query: "", request: 0, timer: null, asked: [], answers: new Map(), busy: false, opened: new Set() }; + /** How many matches Home asks each repository for, so names repeated across places can fold and still leave five. */ + const HOME_ASK_LIMIT = 10; + const HOME_ASK_SHOWN = 5; + // Words a question is phrased with that no entity is named for. + const ASK_FILLER = new Set(["a", "an", "the", "how", "does", "do", "did", "is", "are", "was", "were", "be", "been", "where", "what", "which", "who", "when", "why", + "it", "its", "in", "on", "of", "to", "for", "from", "by", "with", "and", "or", "that", "this", "there", "get", "gets", "got", "can", "we", "i", "my", "our", "any", "all"]); + /** A word cut back to what the forms of it share: `serialized` and `serialization` both begin `serial`. */ + const askStem = (word) => word.length <= 4 ? word : word.endsWith("ies") ? `${word.slice(0, -3)}y` : word.replace(/(?:izations?|ations?|ings?|ized|izes|ize|ed|es|s)$/, "") || word; + /** The words of a question that could name an entity. */ + const askTerms = (query) => [...new Set(text(query).toLowerCase().split(/[^a-z0-9]+/).filter((word) => word.length > 1 && !ASK_FILLER.has(word)).map(askStem).filter((word) => word.length >= 3))]; + /** An entity's name as the words it is made of: `_ZodString.lowercase` is zod, string and lowercase. */ + const nameWords = (name) => shownName(name).replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + /** How many of a question's words an entity's own name holds, whole or begun. */ + function termsNamed(row, terms) { + const words = nameWords(row?.name); + const joined = words.slice(1).map((word, at) => `${words[at]}${word}`); + return terms.filter((term) => [...words, ...joined].some((word) => word.startsWith(term) || (word.length >= 4 && term.startsWith(word)))).length; + } + /** Rows for entities of one name and kind in different places, read as the first with a count of the rest. */ + function foldAlike(rows) { + const at = new Map(); + const out = []; + for (const row of rows) { + const key = `${shownName(row.name)}\u0000${text(row.kind)}`; + const held = at.get(key); + if (held) { held.alike += row.overloads || 1; continue; } + const kept = { ...row, alike: 0 }; + at.set(key, kept); + out.push(kept); + } + return out; + } + /** + * How strongly a repository answered: a chain Kin traced through its graph + * first, then matches whose own names hold the question's words, the more + * of them the stronger. A repository whose matches name none of the + * question's words matched only loosely, by meaning. + */ + /** How many of a question's words a name must hold to answer it: half of them, and at least one. */ + const termsNeeded = (terms) => Math.max(1, Math.ceil(terms.length / 2)); + function answerStrength(answer, terms) { + if (!answer) return { state: "pending" }; + if (answer.error) return { state: "failed" }; + const rows = array(answer.groups).flatMap((group) => group.rows); + if (answer.answer) return { state: "strong", best: Infinity, strong: rows.length, rows }; + // Nothing found by a search that only half answered is a question not asked, never a "nothing matches". + if (!rows.length) return { state: answer.partial ? "failed" : "empty" }; + const named = rows.map((row) => termsNamed(row, terms)); + const best = Math.max(0, ...named); + const needed = termsNeeded(terms); + return { state: !terms.length || best >= needed ? "strong" : "weak", best, strong: named.filter((n) => n >= needed).length, rows }; + } function queueHomeAsk(value) { homeAsk.query = value; clearTimeout(homeAsk.timer); @@ -955,6 +1453,7 @@ const query = homeAsk.query.trim(); const mine = ++homeAsk.request; homeAsk.answers = new Map(); + homeAsk.opened = new Set(); const body = byId("home-body"); if (body) body.hidden = Boolean(query); if (!query) { homeAsk.busy = false; homeAsk.asked = []; paint("home-ask"); return; } @@ -964,7 +1463,7 @@ paint("home-ask"); await Promise.all(rows.map(async (row) => { try { - const data = await fetchJSON(`root:/repositories/${row.key}/api/workspace/ask?q=${encodeURIComponent(query)}&limit=5`); + const data = await fetchJSON(`root:/repositories/${row.key}/api/workspace/ask?q=${encodeURIComponent(query)}&limit=${HOME_ASK_LIMIT}`); if (mine !== homeAsk.request) return; homeAsk.answers.set(row.key, askGroups(data)); } catch (error) { @@ -994,21 +1493,47 @@ const query = homeAsk.query.trim(); if (!query || !homeAsk.asked.length) return null; const rows = homeListed().filter((row) => homeAsk.asked.includes(row.key)); - const count_ = (answer) => array(answer?.groups).reduce((n, group) => n + group.rows.length, 0); - // The repositories with an answer first; the ones still thinking keep their place below. - const rank = (row) => { const answer = homeAsk.answers.get(row.key); return !answer ? 1 : answer.error ? 3 : count_(answer) ? 0 : 2; }; - const ordered = [...rows].sort((a, b) => rank(a) - rank(b)); - const empty = ordered.filter((row) => rank(row) === 2); - const failedRows = ordered.filter((row) => rank(row) === 3); + const terms = askTerms(query); + const strengths = new Map(rows.map((row) => [row.key, answerStrength(homeAsk.answers.get(row.key), terms)])); + const of = (row) => strengths.get(row.key); + // A traced answer first, then the strongest matches; a repository still thinking waits below them, + // and one that matched only loosely is one line under those. + const ORDER = { strong: 0, pending: 1, weak: 2, empty: 3, failed: 4 }; + const ordered = rows.map((row, at) => ({ row, at })).sort((a, b) => ORDER[of(a.row).state] - ORDER[of(b.row).state] + || (of(b.row).best ?? 0) - (of(a.row).best ?? 0) || (of(b.row).strong ?? 0) - (of(a.row).strong ?? 0) || a.at - b.at).map(({ row }) => row); + const inState = (state) => ordered.filter((row) => of(row).state === state); + const empty = inState("empty"); + const failedRows = inState("failed"); const names = (list) => list.map((row) => repositoryLabel(row, " / ")).join(", "); + const more = (row) => h("a", { class: "more-link", href: repoHref(row, `#/explore?q=${encodeURIComponent(query)}`) }, "More here", icon("arrow")); + const full = (row) => { + const answer = homeAsk.answers.get(row.key); + const results = answer ? foldAlike(of(row).rows) : []; + // In a repository that answered by name, the rows whose names hold the question's words, or that Kin + // matched by name, lead, and its looser matches are one line under them until the reader asks for them. + const opened = homeAsk.opened.has(`${row.key}:loose`) || homeAsk.opened.has(row.key); + const named = answer && !answer.answer && terms.length && of(row).state === "strong" && !opened ? results.filter((result) => termsNamed(result, terms) >= termsNeeded(terms) || array(result.matched).includes("name")) : results; + const looser = results.filter((result) => !named.includes(result)); + const shown = named.slice(0, HOME_ASK_SHOWN); + return h("section", { class: "home-answer" }, + h("div", { class: "section-head" }, h("h2", {}, repositoryLabel(row, " / ")), answer ? more(row) : null), + answer ? [answerChain(answer.answer, repoHref(row)), h("ul", { class: "list list-card" }, shown.map((result) => entityRow(result, 0, repoHref(row)))), + looser.length ? h("p", { class: "home-answer-loose home-answer-looser" }, + h("span", { class: "home-answer-loose-text" }, `Also ${plural(looser.length + looser.reduce((sum, result) => sum + result.alike, 0), "loose match", "loose matches")}: ${looser.slice(0, 2).map((result) => shownName(result.name)).join(", ")}${looser.length > 2 ? ` and ${count(looser.length - 2)} more` : ""}.`), + button("Show them", () => { homeAsk.opened.add(`${row.key}:loose`); paint("home-ask"); }, { class: "button button-small button-quiet" })) : null] : skeletonLines(3)); + }; + const loose = (row) => { + const shown = foldAlike(of(row).rows); + const first = shown.slice(0, 2).map((result) => shownName(result.name)); + const rest = shown.slice(2).length + shown.reduce((sum, result) => sum + result.alike, 0); + return h("p", { class: "home-answer-loose" }, + h("span", { class: "home-answer-loose-name" }, repositoryLabel(row, " / ")), + h("span", { class: "home-answer-loose-text" }, `Only loose matches: ${first.join(", ")}${rest ? ` and ${count(rest)} more` : ""}.`), + button("Show them", () => { homeAsk.opened.add(row.key); paint("home-ask"); }, { class: "button button-small button-quiet" })); + }; return h("div", { class: "home-answers", role: "region", "aria-label": "Answers" }, - ordered.filter((row) => rank(row) < 2).map((row) => { - const answer = homeAsk.answers.get(row.key); - return h("section", { class: "home-answer" }, - h("div", { class: "section-head" }, h("h2", {}, repositoryLabel(row, " / ")), - answer ? h("a", { class: "more-link", href: repoHref(row, `#/explore?q=${encodeURIComponent(query)}`) }, "More here", icon("arrow")) : null), - answer ? h("ul", { class: "list list-card" }, answer.groups.flatMap((group) => group.rows).slice(0, 5).map((result) => entityRow(result, 0, repoHref(row)))) : skeletonLines(3)); - }), + [...inState("strong"), ...inState("pending")].map(full), + inState("weak").length ? h("div", { class: "home-answers-loose" }, inState("weak").map((row) => homeAsk.opened.has(row.key) ? full(row) : loose(row))) : null, empty.length && !homeAsk.busy ? h("p", { class: "note quiet-line" }, empty.length === rows.length ? "Nothing in these repositories matches that." : `Nothing in ${names(empty)} matches.`) : null, failedRows.length && !homeAsk.busy ? h("p", { class: "note quiet-line" }, failedRows.length === rows.length ? "Kin couldn't ask these repositories just now. " : `Kin couldn't ask ${names(failedRows)} just now. `, button("Try again", runHomeAsk, { class: "button button-small" })) : null); @@ -1021,16 +1546,17 @@ if (!rows.length) return [h("h1", { class: "home-headline" }, "Bring your first project into Kin."), h("p", { class: "home-sub" }, "Kin converts a project you already have, keeps its history, and maps how its code connects. Your project stays where it is."), state.add.open ? null : h("div", { class: "page-actions home-start" }, button("Add a project", () => openAdd("convert"), { class: "button button-primary" }))]; - const needs = homeNeeds(); + // Every count here is the snapshot's, the one the cards, the agents and the sidebar are drawn from. + const snap = homeSnapshot(); + const needs = snap.needs.map((view) => view.row); const headline = needs.length === 1 ? `${repositoryLabel(needs[0], " / ")} needs you.` : needs.length ? `${spoken(needs.length)} repositories need you.` : "Nothing needs you right now."; - // Each repository's own reading of its agents is fuller than the list's, so the larger count is said. - const sessions = homeSessions().live; - const connected = Math.max(rows.reduce((sum, row) => sum + (row.agents?.connected || 0), 0), sessions.length); - const active = Math.max(rows.reduce((sum, row) => sum + (row.agents?.active || 0), 0), sessions.filter((held) => held.agent.state === "active").length); + const { active, connected } = snap; const agents = active ? `${spoken(active)} ${active === 1 ? "agent is" : "agents are"} working.` : connected ? `${spoken(connected)} ${connected === 1 ? "agent is connected and idle." : "agents are connected and idle."}` : "No agent is connected."; return [h("h1", { class: "home-headline" }, headline), - h("p", { class: "home-sub" }, agents, ` ${spoken(rows.length)} ${rows.length === 1 ? "repository" : "repositories"} on this machine.`)]; + h("p", { class: "home-sub" }, agents, ` ${spoken(rows.length)} ${rows.length === 1 ? "repository" : "repositories"} on this machine.`), + // A repository Kin cannot reach is shown as it was last known, and said so here once. + snap.unreachable.length ? h("p", { class: "note home-known", role: "status" }, snap.unreachable.map(unreachableWords).join(" ")) : null]; } /** @@ -1039,7 +1565,7 @@ * other. Every node is an entity the guide named and every line a * relationship it named. Links open in `base`, the repository's own page. */ - function impactPicture(guide, onCleanup, { base = "", shown = 6, walk = "#/review/w", marks = false, more = null } = {}) { + function impactPicture(guide, onCleanup, { base = "", api = "", shown = 6, walk = "#/review/w", marks = false, more = null } = {}) { const steps = reviewSteps(guide); if (!steps.length) return null; const TONE = ["bad", "warn", "quiet"]; @@ -1052,7 +1578,7 @@ const tag = step.change === "added" ? "New" : step.change === "removed" ? "Removed" : array(step.fields).some((field) => text(field?.field) === "signature") ? "Signature changed" : ""; const node = h("a", { class: "impact-node", "data-tone": tone, href: `${base}${walk}/${at + 1}`, title: step.name }, h("span", { class: "impact-dot", "aria-hidden": "true" }), - h("span", { class: "impact-text" }, h("span", { class: "impact-name" }, step.name), h("span", { class: "impact-place" }, [placeOf(step), tag].filter(Boolean).join(" · "))), + h("span", { class: "impact-text" }, h("span", { class: "impact-name" }, step.name), h("span", { class: "impact-place" }, [placeOf(step), tag, [...new Set(authorsOf(step).map((held) => held.name))].join(" and ")].filter(Boolean).join(" · "))), marks && markState(step) ? h("span", { class: `state impact-mark ${markState(step) === "flagged" ? "state-warn" : "state-good"}` }, MARK_WORDS[markState(step)]) : null); for (const consumer of outsideOf(step).rows) { const id = text(consumer.entity.id) || text(consumer.entity.name); @@ -1063,22 +1589,25 @@ }); const OUTSIDE_SHOWN = 8; const reliers = [...outside.entries()].slice(0, OUTSIDE_SHOWN); + // Two that read the same are told apart by where each sits. + const apart = nameQualifiers(reliers.map(([, held]) => held.entity), api); for (const [, held] of reliers) { const entity = held.entity; const where = [kindWord(entity.kind), withinName(entity.within)].filter(Boolean); held.node = h("a", { class: "impact-node impact-node-out", href: UUID.test(text(entity.id)) ? `${base}${entityHref(entity.id)}` : undefined, title: text(entity.name) }, h("span", { class: "impact-dot", "aria-hidden": "true" }), - h("span", { class: "impact-text" }, h("span", { class: "impact-name" }, text(entity.name) || "Entity"), h("span", { class: "impact-place" }, where.length > 1 ? `${where[0]} in ${where[1]}` : where[0] || ""))); + h("span", { class: "impact-text" }, h("span", { class: "impact-name" }, qualified(text(entity.name) || "Entity", apart.get(text(entity.id)))), h("span", { class: "impact-place" }, where.length > 1 ? `${where[0]} in ${where[1]}` : where[0] || ""))); } const edges = pairs.map((pair) => ({ from: pair.from, to: outside.get(pair.to)?.node, tone: pair.tone })).filter((edge) => edge.to); const known = isCount(guide.summary?.recorded_consumers) ? guide.summary.recorded_consumers : outside.size; const moreOut = Math.max(0, known - reliers.length); const moreIn = steps.length - listed.length; - const host = h("div", { class: `impact${reliers.length ? "" : " impact-alone"}`, role: "group", "aria-label": `${plural(steps.length, "changed entity", "changed entities")}, and ${plural(known, "entity that relies", "entities that rely")} on ${steps.length === 1 ? "it" : "them"} without having changed` }, + const reach = known ? `${limitedGuide(guide) ? "at least " : ""}${plural(known, "entity that relies", "entities that rely")} on ${steps.length === 1 ? "it" : "them"} without having changed` : `nothing Kin found relies on ${steps.length === 1 ? "it" : "them"} from outside`; + const host = h("div", { class: `impact${reliers.length ? "" : " impact-alone"}`, role: "group", "aria-label": `${plural(steps.length, "changed entity", "changed entities")}, and ${reach}` }, h("div", { class: "impact-col" }, h("div", { class: "impact-col-head" }, "Changed", h("span", { class: "count" }, count(steps.length))), left, moreIn > 0 ? (more ? h("a", { class: "impact-more", href: `${base}${more}` }, `and ${count(moreIn)} more`) : h("span", { class: "impact-more" }, `and ${count(moreIn)} more`)) : null), reliers.length ? h("div", { class: "impact-gap", "aria-hidden": "true" }) : null, - reliers.length ? h("div", { class: "impact-col" }, h("div", { class: "impact-col-head" }, steps.length === 1 ? "Rely on it, and did not change" : "Rely on them, and did not change", h("span", { class: "count" }, count(known))), reliers.map(([, held]) => held.node), + reliers.length ? h("div", { class: "impact-col" }, h("div", { class: "impact-col-head" }, steps.length === 1 ? "Rely on it, and did not change" : "Rely on them, and did not change", h("span", { class: "count" }, graphBadge(known, limitedGuide(guide)))), reliers.map(([, held]) => held.node), moreOut > 0 ? h("span", { class: "impact-more" }, `and ${count(moreOut)} more`) : null) : null); const join = graphApi()?.joinEdges; if (join && edges.length && onCleanup) { @@ -1088,6 +1617,61 @@ return host; } + /** One highlighted line as elements: each token in its class, plain text as text. */ + const tokenSpans = (parts) => array(parts).map(([type, value]) => type ? h("span", { class: `t-${type}` }, value) : value); + + // ── How what relies on an entity uses it ── + // The lines of each relier's own source that name the changed entity, read + // from the graph's source for that relier. The ones that did not change with + // it come first, since those are the ones to check. + const callSiteReads = new Map(); + const SITES_SHOWN = 4; + function siteReliers(step) { + const rows = array(step.connections?.consumers).filter((consumer) => UUID.test(text(consumer?.entity?.id))); + return [...rows.filter((consumer) => !consumer.changed_here), ...rows.filter((consumer) => consumer.changed_here)].slice(0, SITES_SHOWN); + } + /** The last part of an entity's name, which is how code that uses it names it. */ + const calledAs = (name) => text(name).split(/::|\.|#/).filter(Boolean).pop() || ""; + function loadCallSites(step) { + const wanted = siteReliers(step).filter((consumer) => !callSiteReads.has(consumer.entity.id)); + if (!wanted.length) return; + const epoch = state.readEpoch; + for (const consumer of wanted) { + const id = consumer.entity.id; + callSiteReads.set(id, { busy: true }); + fetchJSON(`/api/workspace/entity?id=${encodeURIComponent(id)}§ions=source`) + .then((data) => { callSiteReads.set(id, data?.source?.ok && typeof data.source.text === "string" ? { text: data.source.text, start: data.source.start_line, language: data.source.language } : { missing: true }); }) + .catch(() => { callSiteReads.set(id, { missing: true }); }) + .finally(() => { if (epoch === state.readEpoch && state.route === "review") paint("review-sites"); }); + } + } + function callSites(step) { + const reliers = siteReliers(step); + const word = calledAs(step.name); + if (!reliers.length || !word || step.change === "added") return null; + Promise.resolve().then(() => loadCallSites(step)); + const naming = new RegExp(`(^|[^\\w$])${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^\\w$]|$)`); + const apart = nameQualifiers(reliers.map((consumer) => consumer.entity)); + const blocks = reliers.map((consumer) => { + const held = callSiteReads.get(consumer.entity.id); + if (!held || held.busy || held.missing) return null; + const lines = held.text.split("\n"); + const hits = lines.map((line, at) => ({ line, at })).filter(({ line }) => naming.test(line)).slice(0, 3); + if (!hits.length) return null; + let tokens = null; + try { tokens = globalThis.kinReview?.highlightLines?.(hits.map((hit) => hit.line.trim()).join("\n"), held.language) || null; } catch (_) { tokens = null; } + return h("div", { class: "site" }, + h("div", { class: "site-head" }, ent(consumer.entity), apart.get(text(consumer.entity.id)) ? h("span", { class: "name-qualifier" }, `in ${apart.get(text(consumer.entity.id))}`) : null, + h("span", { class: "note" }, consumer.changed_here ? "changed here too" : "did not change")), + h("div", { class: "codeview site-code" }, h("div", { class: "code-lines" }, hits.flatMap((hit, index) => [ + h("span", { class: "code-no" }, Number.isSafeInteger(held.start) ? String(held.start + hit.at) : ""), + h("span", { class: "code-text" }, tokens?.[index] ? tokenSpans(tokens[index]) : hit.line.trim())])))); + }).filter(Boolean); + if (!blocks.length) return reliers.some((consumer) => callSiteReads.get(consumer.entity.id)?.busy !== false && !callSiteReads.get(consumer.entity.id)?.text && !callSiteReads.get(consumer.entity.id)?.missing) + ? h("p", { class: "note aside-reading sites-loading" }, "Reading how they use it") : null; + return h("div", { class: "sites" }, h("h3", { class: "sites-title" }, "How they use it"), blocks); + } + /** One changed entity joined to each thing that relies on it, in the colour of where that thing stands. */ function stepPicture(step, onCleanup) { const connections = step.connections || {}; @@ -1098,12 +1682,13 @@ const toneOf = (consumer) => consumer.changed_here ? "good" : verdict === "needs_no_update" ? "quiet" : verdict === "not_updated" ? "bad" : "warn"; const WORD = { good: "Changed here too", quiet: "Needs no update", bad: "Not updated", warn: "May need review" }; const SHOWN = 8; + const apart = nameQualifiers(consumers.slice(0, SHOWN).map((consumer) => consumer.entity)); const focus = h("div", { class: "impact-node impact-focus" }, h("span", { class: "impact-text" }, h("span", { class: "impact-name" }, step.name), h("span", { class: "impact-place" }, step.change === "added" ? "New here" : step.change === "removed" ? "Removed here" : "Changed here"))); const rows = consumers.slice(0, SHOWN).map((consumer) => { const tone = toneOf(consumer); const node = h("a", { class: "impact-node", "data-tone": tone, href: UUID.test(text(consumer.entity.id)) ? entityHref(consumer.entity.id) : undefined, title: text(consumer.entity.name) }, h("span", { class: "impact-dot", "aria-hidden": "true" }), - h("span", { class: "impact-text" }, h("span", { class: "impact-name" }, text(consumer.entity.name) || "Entity"), h("span", { class: "impact-place" }, RELATION_WORD[text(consumer.relation).toLowerCase()] || "relies on it")), + h("span", { class: "impact-text" }, h("span", { class: "impact-name" }, qualified(text(consumer.entity.name) || "Entity", apart.get(text(consumer.entity.id)))), h("span", { class: "impact-place" }, relationWord(consumer.relation, consumer.entity, step))), h("span", { class: `state impact-mark${tone === "good" ? " state-good" : tone === "bad" ? " state-bad" : tone === "warn" ? " state-warn" : ""}` }, WORD[tone])); return { node, tone }; }); @@ -1123,12 +1708,32 @@ /** One card for each repository with work waiting on you. */ function homeAttention(onCleanup) { if (!read("home").data) return null; - const needs = homeNeeds(); + const needs = homeSnapshot().needs; if (!needs.length) return null; const cleanups = []; onCleanup(() => cleanups.splice(0).forEach((stop) => stop())); - return h("section", { class: "home-section", "aria-label": "Needs you" }, needs.map((row) => { - const work = repoWork(row); + return h("section", { class: "home-section", "aria-label": "Needs you" }, needs.map((view) => { + const { row, work } = view; + const unreviewed = view.awaiting; + const known = view.unreachable ? h("p", { class: "note focus-known" }, unreachableWords(view)) : null; + if (unreviewed.length && work.state !== "work" && !row.merge_in_progress) { + const [first] = unreviewed; + const who = [...new Set(unreviewed.map((change) => text(change.agent?.name) || "An agent"))]; + const sentence = unreviewed.length === 1 + ? `${who[0]} committed ${text(first.title) ? `“${first.title}”` : "a change"} ${ago(first.time || first.timestamp)}, and nobody has reviewed it yet.` + : `${who.length === 1 ? who[0] : "Agents"} committed ${spoken(unreviewed.length).toLowerCase()} changes nobody has reviewed yet.`; + return h("article", { class: "focus-card" }, + h("div", { class: "focus-head" }, + h("a", { class: "focus-repo", href: repoHref(row) }, h("span", { class: "focus-repo-name" }, repositoryTitle(row)), text(row.branch) ? h("span", { class: "focus-repo-branch" }, row.branch) : null, + h("span", { class: "focus-kind" }, unreviewed.length === 1 ? "Agent commit" : "Agent commits")), + unreviewed.length === 1 ? h("div", { class: "page-actions" }, link("Review", repoHref(row, reviewHref(first.id)), true)) : null), + h("p", { class: "focus-sentence" }, sentence), + known, + // The newest one drawn as what it changed and what relies on that, as the review will walk it. + unreviewed.length > 1 ? h("h3", { class: "sites-title focus-caption" }, text(first.title) ? `The newest, “${first.title}”` : "The newest") : null, + agentChangePicture(row, first, (stop) => cleanups.push(stop)), + unreviewed.length > 1 ? agentReviewList(row, unreviewed) : null); + } const detail = homeDetail.get(row.key); const guide = work.guide; const steps = guide ? reviewSteps(guide) : []; @@ -1142,24 +1747,105 @@ link("Open", repoHref(row, work.state === "work" ? UNCOMMITTED : "#/branches"), false, { class: "button button-quiet" }), steps.length ? link(`Review ${count(steps.length)}`, repoHref(row, "#/review/w/1"), true) : row.merge_in_progress ? link("Settle the merge", repoHref(row, "#/branches"), true) : null)), h("p", { class: "focus-sentence" }, sentence), + known, guide && unsettled(guide) ? h("p", { class: "note" }, isCount(pending) && pending > 0 ? `Kin is still mapping ${plural(pending, "entity", "entities")}, so these counts may grow.` : "Kin is still mapping this code, so these counts may grow.") : null, - guide ? impactPicture(guide, (stop) => cleanups.push(stop), { base: repoHref(row), more: UNCOMMITTED }) + guide ? impactPicture(guide, (stop) => cleanups.push(stop), { base: repoHref(row), api: `/repositories/${row.key}`, more: UNCOMMITTED }) : detail?.failed.working ? h("p", { class: "note" }, "Kin couldn't read what changed here yet. Open the repository to see it.") : work.state === "work" ? h("div", { class: "focus-reading", "aria-busy": "true" }, skeletonLines(3)) : null, + // What agents committed here and nobody has reviewed waits beside the uncommitted work, not behind it. + unreviewed.length ? agentReviewList(row, unreviewed) : null, work.stale ? h("p", { class: "note focus-stale" }, "Reading it again") : null); })); } + /** + * Read one committed change for Home: how many entities it changed, from + * its own record, which answers quickly, and its guided review, which draws + * the picture. Neither waits on the other or on the repository's other + * reads, so a slow guide holds back nothing else on Home. + */ + function askChange(row, id) { + const detail = detailFor(row.key); + const changes = detail.changes || (detail.changes = new Map()); + const held = changes.get(id) || { guide: null, counted: undefined, busy: false, failed: null, at: 0 }; + changes.set(id, held); + if (held.busy || held.guide || (held.failed && Date.now() - held.at < 30_000)) return; + // A change a review in this tab already read is drawn from that reading. + const kept = heldChangeGuide(`/repositories/${row.key}`, id); + if (kept) { held.guide = kept; repaintHome(); return; } + held.busy = true; held.failed = null; held.at = Date.now(); + const epoch = state.readEpoch; + const base = `root:/repositories/${row.key}`; + if (held.counted === undefined) { + held.counted = null; + fetchJSON(`${base}/api/counts?changes=${encodeURIComponent(id)}`) + .then((data) => { if (epoch === state.readEpoch && !held.guide) { held.counted = data?.counts?.[id] || null; repaintHome(); } }) + .catch(() => { /* The count was only a first word; the guide still answers. */ }); + } + // Nothing on Home waits on the picture, so a guide that takes a while to read is given the time. + fetchJSON(`${base}/api/guide?change=${encodeURIComponent(id)}`, { patience: CHANGE_PATIENCE }) + // The review this card opens is handed the reading, so the change is not read twice. + .then((guide) => { keepChangeGuide(`/repositories/${row.key}`, id, guide); if (epoch === state.readEpoch) held.guide = guide; }) + .catch((error) => { if (epoch === state.readEpoch) held.failed = error.message || "This change could not be read."; }) + .finally(() => { held.busy = false; held.at = Date.now(); if (epoch === state.readEpoch) repaintHome(); }); + } + const CHANGE_PATIENCE = 180_000; + const changeHeld = (row, id) => homeDetail.get(row.key)?.changes?.get(id) || null; + /** What Home has read of a change so far, in a few words for its facts. */ + const changeFacts = (row, id) => { const held = changeHeld(row, id); return held ? [Boolean(held.guide), held.busy, held.failed, held.counted?.entities ?? null] : null; }; + + /** + * An agent's committed change on its Home card: the entities it changed + * joined to what relies on them and did not change, as its review walks + * them. While the guide is read, the count from the change's own record + * says how much it holds. + */ + function agentChangePicture(row, change, onCleanup) { + Promise.resolve().then(() => askChange(row, change.id)); + const held = changeHeld(row, change.id); + const guide = held?.guide; + const review = reviewHref(change.id); + if (guide) { + const drawn = impactPicture(guide, onCleanup, { base: repoHref(row), api: `/repositories/${row.key}`, walk: review, more: review }); + return drawn || h("p", { class: "note" }, workChanged(guide) ? `${plural(workChanged(guide), "entity", "entities")} changed. Its review shows them.` : "No entity changed in it."); + } + const n = held?.counted?.entities; + const counted = isCount(n) ? (n ? `${spoken(n)} ${n === 1 ? "entity" : "entities"} changed.` : "No entity changed in it.") : ""; + if (held?.failed) return h("p", { class: "note" }, [counted, "Kin couldn't read what relies on it yet. Its review has the rest."].filter(Boolean).join(" ")); + return h("div", { class: "focus-reading", "aria-busy": "true" }, + h("p", { class: "note aside-reading", role: "status" }, counted ? `${counted} Reading what relies on ${n === 1 ? "it" : "them"}` : "Reading what it changed"), + skeletonLines(3)); + } + + /** Agent changes nobody has reviewed, the newest few, each with its own way into its review. */ + function agentReviewList(row, changes) { + const SHOWN = 3; + return h("div", { class: "focus-agents" }, + h("h3", { class: "sites-title" }, changes.length === 1 ? "Committed by an agent, not reviewed yet" : `Committed by agents, not reviewed yet: ${count(changes.length)}`), + h("ul", { class: "list list-card" }, changes.slice(0, SHOWN).map((change) => h("li", {}, h("div", { class: "change-row" }, + h("span", {}, h("span", { class: "change-title" }, text(change.title) || (change.listed ? "A change it committed" : "Untitled change")), + h("span", { class: "change-meta" }, h("span", {}, text(change.agent?.name) || "An agent"), h("span", {}, ago(change.time || change.timestamp)))), + link("Review", repoHref(row, reviewHref(change.id)), false, { class: "button button-small" }))))), + changes.length > SHOWN ? h("p", { class: "note quiet-line" }, h("a", { class: "link", href: repoHref(row, "#/history") }, `${count(changes.length - SHOWN)} more in History`)) : null); + } + /** Who is working through Kin right now, across every repository Home has open. */ function homeAgents() { if (!read("home").data || !homeListed().length) return null; - const { live, earlier } = homeSessions(); + const { live, earlier } = homeSnapshot(); const api = agentsApi(); + const linked = (held, parts) => parts.map((part) => typeof part === "string" ? part : h("a", { class: "agent-entity", href: repoHref(held.row, entityHref(part.id)), title: part.label }, api.shortLabel(part.label))); const action = (held) => { const call = array(held.agent.recent_calls)[0]; if (!call || !api) return null; - return api.describe(call, held.names).map((part) => typeof part === "string" ? part : h("a", { class: "agent-entity", href: repoHref(held.row, entityHref(part.id)), title: part.label }, api.shortLabel(part.label))); + return linked(held, api.describe(call, held.names)); }; + // What a session came to: the change it committed and whether it waits for review, before the last call it made. + const outcome = (held) => { + const parts = api?.outcome?.(held.agent, held.names, (id) => reviewStateIn(held.row, id)); + return Array.isArray(parts) && parts.length ? linked(held, parts) : null; + }; + const plainOf = (parts) => array(parts).map((part) => typeof part === "string" ? part : part.textContent).join(""); const earlierActions = (held) => { const calls = array(held.agent.recent_calls).slice(1, 3); return calls.length && api ? h("ul", { class: "agent-card-trail" }, calls.map((call) => h("li", {}, api.plainText(api.describe(call, held.names))))) : null; @@ -1169,10 +1855,15 @@ if (live.length) { return h("section", { class: "home-section" }, head, h("div", { class: "agent-cards" }, live.slice(0, 6).map((held) => { const working = held.agent.state === "active"; + const did = action(held); + const came = held.agent.counted ? null : outcome(held); + // An agent the machine's list counts before its repository has named what it did. + const counted = held.agent.counted ? (working ? "Working through Kin now." : "Connected through Kin, and idle.") : null; return h("a", { class: "agent-card", href: repoHref(held.row, "#/agents"), "data-state": working ? "working" : "idle" }, h("div", { class: "agent-card-head" }, h("span", { class: "pulse", "aria-hidden": "true" }), h("span", { class: "agent-card-name" }, held.agent.name), h("span", { class: "agent-card-repo" }, `in ${repositoryLabel(held.row, " / ")}`), h("span", { class: `state${working ? " state-good" : ""}` }, working ? "Working" : "Idle")), - h("p", { class: "agent-card-action" }, action(held) || "Connected. It has not asked Kin for anything yet."), + h("p", { class: "agent-card-action" }, counted || did || "Connected. It has not asked Kin for anything yet."), + came && plainOf(came) !== plainOf(did) ? h("p", { class: "agent-card-outcome" }, came) : null, earlierActions(held), h("p", { class: "note" }, [isCount(held.agent.calls_total) ? plural(held.agent.calls_total, "call") : "", api?.presence?.(held.agent) || ""].filter(Boolean).join(" · "))); }))); @@ -1180,8 +1871,9 @@ const last = earlier[0]; return h("section", { class: "home-section" }, head, h("div", { class: "quiet-card" }, h("p", { class: "quiet-card-title" }, "No agent is connected."), + // A session is said by what it came to, never by its last call alone when that call is not what it did. last ? h("p", { class: "quiet-card-text" }, `${last.agent.name} last worked in `, h("a", { class: "link", href: repoHref(last.row, "#/agents") }, repositoryLabel(last.row, " / ")), ` ${ago(last.agent.last_activity_at)}. `, - action(last) ? [action(last), "."] : null) + (outcome(last) || action(last)) ? [outcome(last) || action(last), "."] : null) : h("p", { class: "quiet-card-text" }, "A coding agent works in your repositories through Kin once it is connected."))); } @@ -1189,39 +1881,43 @@ function homeRepos() { const resource = read("home"); if (!resource.data) return resource.error ? null : h("section", { class: "home-section" }, skeletonLines(4)); - const rows = homeListed(); + const views = homeSnapshot().views; + const rows = views.map((view) => view.row); if (!rows.length) return null; - const rank = (row) => repoWork(row).state === "work" || row.merge_in_progress ? 0 : (row.agents?.active || 0) > 0 ? 1 : 2; - const ordered = [...rows].sort((a, b) => rank(a) - rank(b) || (Date.parse(b.last_change_at || "") || 0) - (Date.parse(a.last_change_at || "") || 0) || repositoryLabel(a).localeCompare(repositoryLabel(b))); - const stat = (value, label) => isCount(value) ? h("div", { class: "stat" }, h("span", { class: "stat-value" }, count(value)), h("span", { class: "stat-label" }, label)) : null; + const ordered = [...views].sort((a, b) => TONE_RANK[a.tone] - TONE_RANK[b.tone] || (Date.parse(b.row.last_change_at || "") || 0) - (Date.parse(a.row.last_change_at || "") || 0) || repositoryLabel(a.row).localeCompare(repositoryLabel(b.row))); + const stat = (value, label, atLeast = false) => isCount(value) ? h("div", { class: "stat", title: atLeast ? `At least ${count(value)} ${label}: some call sites are not proven yet` : undefined }, h("span", { class: "stat-value" }, `${atLeast && value > 0 ? "≥" : ""}${count(value)}`), h("span", { class: "stat-label" }, label)) : null; // A share of the whole that never rounds a part to nothing or the rest to everything. const share = (part, whole) => { const percent = part / whole * 100; return percent < 1 ? "under 1%" : `${Math.min(99, Math.round(percent)) === 99 && part < whole ? 99 : Math.round(percent)}%`; }; const unavailable = resource.data.unavailable; return h("section", { class: "home-section" }, h("div", { class: "section-head" }, h("h2", {}, "Repositories", h("span", { class: "count" }, count(rows.length))), section("hub-add-toggle", [], () => state.add.open ? null : h("a", { class: "more-link", href: "#/add" }, "Add repository", icon("arrow")))), - h("div", { class: "repo-cards" }, ordered.map((row) => { + h("div", { class: "repo-cards" }, ordered.map((view) => { + const { row, work } = view; const detail = homeDetail.get(row.key); const overview = detail?.overview; - const work = repoWork(row); const languages = section_ok(overview?.languages) ? array(overview.languages.items).filter((item) => item && typeof item.language === "string" && item.entities > 0) : []; const total = languages.reduce((sum, item) => sum + item.entities, 0); const entities = section_ok(overview?.counts) && isCount(overview.counts.entities) ? overview.counts.entities : row.entities; const connections = section_ok(overview?.counts) ? overview.counts.relationships : null; - const live = row.agents?.active || 0; + const live = view.agents.active; + const waiting = view.awaiting.length; const stateWords = work.state === "work" ? (isCount(work.changed) && work.changed ? `${count(work.changed)} uncommitted` : "Uncommitted work") - : row.merge_in_progress ? "A merge to settle" : repoActivity(row) || (work.state === "clean" ? "All committed" : work.state === "opening" ? "Opening" : work.state === "reading" ? "Looking for changes" : ""); + : row.merge_in_progress ? "A merge to settle" : waiting ? `${count(waiting)} to review` : repoActivity(row) || (work.state === "clean" ? "All committed" : work.state === "opening" ? "Opening" : work.state === "reading" ? "Looking for changes" : ""); return h("a", { class: "repo-card", id: `repo-card-${row.key.slice(0, 16)}`, href: repoHref(row), "data-state": work.state }, h("div", { class: "repo-card-head" }, h("span", { class: "repo-card-name" }, repositoryTitle(row)), text(row.branch) ? h("span", { class: "repo-card-branch" }, row.branch) : null), - h("div", { class: "stats" }, stat(entities, "entities"), stat(connections, "connections")), + live ? h("div", { class: "repo-card-live" }, h("span", { class: "pulse", "aria-hidden": "true" }), + (view.agents.listed ? [...new Set(view.agents.live.filter((agent) => agent.state === "active").map((agent) => agent.name))] : view.agents.named).slice(0, 2).join(" and ") || plural(live, "agent"), live === 1 ? " is working" : " are working") : null, + h("div", { class: "stats" }, stat(entities, "entities"), stat(connections, "connections", connectionsAtLeast(overview))), languages.length ? h("div", { class: "lang" }, h("div", { class: "lang-bar", "aria-hidden": "true" }, languages.slice(0, 4).map((item, at) => h("span", { class: "lang-seg", "data-at": String(at), css: { "flex-grow": String(item.entities) } }))), h("div", { class: "lang-legend" }, languages.slice(0, 2).filter((item) => item.entities / total >= 0.01).map((item, at) => h("span", { class: "lang-key", "data-at": String(at) }, `${languageWord(item.language)} ${share(item.entities, total)}`)))) - : h("div", { class: "lang lang-pending", "aria-hidden": "true" }, h("div", { class: "lang-bar" })), + : h("div", { class: "lang lang-pending", "aria-hidden": "true" }), h("div", { class: "repo-card-foot" }, - stateWords ? h("span", { class: `state${work.state === "work" || row.merge_in_progress ? " state-warn" : ""}` }, stateWords) : null, - live ? h("span", { class: "state state-good" }, `${plural(live, "agent")} working`) : null, - row.last_change_at ? h("span", { class: "repo-card-time", title: "Last change" }, ago(row.last_change_at)) : null)); + stateWords ? h("span", { class: `state${work.state === "work" || row.merge_in_progress || waiting ? " state-warn" : ""}` }, stateWords) : null, + // A repository Kin cannot reach says when what it shows was known, in place of when it last changed. + view.unreachable ? h("span", { class: "repo-card-time repo-card-known", title: unreachableWords(view) }, `As of ${knownWhen(view.knownAt)}`) + : row.last_change_at ? h("span", { class: "repo-card-time", title: "Last change" }, ago(row.last_change_at)) : null)); })), isCount(unavailable) && unavailable > 0 ? h("p", { class: "note quiet-line" }, `${plural(unavailable, "repository", "repositories")} Kin knew about ${unavailable === 1 ? "is" : "are"} no longer on this machine.`) : null, text(resource.data.warning) ? h("p", { class: "note quiet-line" }, resource.data.warning) : null); @@ -1236,11 +1932,13 @@ if (!section_ok(recent)) continue; for (const change of array(recent.items)) if (change && !isKinUpdate(change) && typeof change.id === "string") changes.push({ change, row }); } - if (!changes.length) return null; const when = (held) => Date.parse(held.change.time || held.change.timestamp || "") || 0; + const recent = changes.filter((held) => Date.now() - when(held) < 14 * 86_400_000); + if (!recent.length) return null; + changes.splice(0, changes.length, ...recent); changes.sort((a, b) => when(b) - when(a)); return h("section", { class: "home-section" }, - h("div", { class: "section-head" }, h("h2", {}, "Recently committed")), + h("div", { class: "section-head" }, h("h2", {}, "Committed in the last two weeks")), h("ul", { class: "list list-card" }, changes.slice(0, 6).map(({ change, row }) => h("li", {}, h("a", { class: "change-row", href: repoHref(row, reviewHref(change.id)) }, h("span", {}, h("span", { class: "change-title" }, text(change.title) || "Untitled change"), h("span", { class: "change-meta" }, h("span", {}, repositoryLabel(row, " / ")), @@ -1270,7 +1968,7 @@ return [ h("header", { class: "page-head" }, h("div", { class: "page-title-row" }, h("h1", {}, "Coding agents")), - h("p", { class: "lede" }, "An agent works in your repositories through Kin once it is connected.")), + h("p", { class: "lede" }, "Configured agents are ready to use Kin. Start work in a repository and ask the agent to use Kin to read or change its code. Its session appears in Agents when it uses Kin.")), section("hub-clients", ["clients"], hubClients)]; } @@ -1280,13 +1978,236 @@ // through is the review's job; this page says what there is to read, where // an agent's activity meets it, and commits it. - /** "21 rely on it": the one way every count of what relies on an entity is said. */ - function relyWords(n) { return isCount(n) ? `${count(n)} ${n === 1 ? "relies on it" : "rely on it"}` : ""; } + /** "21 rely on it": the one way every count of what relies on an entity is said. A lower bound says so. */ + function relyWords(n, atLeast = false) { return isCount(n) ? `${atLeast && n > 0 ? "at least " : ""}${count(n)} ${n === 1 && !atLeast ? "relies on it" : "rely on it"}` : ""; } + /** Whether the repository's ranking of what relies on what is a lower bound, as the summary says; unsaid is not a guess. */ + const rankingAtLeast = (overview) => text(overview?.most_relied_on?.bound) === "at_least"; + const connectionsAtLeast = (overview) => text(overview?.counts?.relationships_bound) === "at_least"; /** Whether Kin says its reading of this work is not finished, so every count is a lower bound. */ const unsettled = (guide) => { const state = text(guide?.summary?.completeness?.state); return Boolean(state) && state !== "settled"; }; const workSteps = (guide) => array(guide?.chapters).flatMap((chapter) => array(chapter.steps)).filter((step) => step && typeof step.name === "string"); const hasWork = (guide) => Boolean(guide && (workChanged(guide) > 0 || guide.working_tree?.differs)); const canCommit = () => session()?.capabilities?.commit === true && Boolean(session()?.token); + const can = (name) => session()?.capabilities?.[name] === true && Boolean(session()?.token); + + // ── Who changed an entity ── + // Attribution comes from a confirmed Kin write or a reported client edit. + const authorsOf = (step) => array(step?.authored_by).filter((who) => typeof who?.name === "string"); + const knowsAuthors = (guide) => Array.isArray(guide?.summary?.authors); + /** + * Who changed an uncommitted entity. Kin names an agent when its write or + * its client's report says so. With no such word the edit is a person's or + * a tool's that told Kin nothing, and nothing the page reads tells those + * apart (the sessions it lists are only the ones seen lately), so the gap + * is said as a gap rather than guessed. + */ + function authorWords(step, guide) { + const who = authorsOf(step); + if (who.length) return `changed by ${[...new Set(who.map((held) => held.name))].slice(0, 2).join(" and ")}`; + if (!guide?.working_tree) return knowsAuthors(guide) ? "No agent reported this edit" : ""; + return "No agent reported this edit"; + } + /** The uncommitted work's authorship in one sentence, when no step names an agent or some steps name none. */ + function authorGapWords(guide) { + const steps = workSteps(guide); + if (!guide?.working_tree || !steps.length) return ""; + const unnamed = steps.filter((step) => !authorsOf(step).length).length; + if (!unnamed) return ""; + if (unnamed < steps.length) return `No agent reported the other ${unnamed === 1 ? "one" : spoken(unnamed).toLowerCase()}.`; + return `No agent reported writing ${steps.length === 1 ? "it" : "any of them"}, so Kin cannot say whether ${steps.length === 1 ? "it is" : "they are"} yours or an agent's.`; + } + + // ── Test results ── + const RESULT_WORDS = { passed: ["Run passed", "good"], failed: ["Run failed", "bad"], not_run: ["Not run", ""], stale: ["Out of date", "warn"] }; + function resultWord(result) { + const state = text(result?.state); + const [words, tone] = RESULT_WORDS[state] || []; + // A test that has not run but has a time was tried by a command that could not run. + return words ? h("span", { class: `state${tone ? ` state-${tone}` : ""}`, title: [text(result.summary), result.at ? `${state === "not_run" ? "Tried" : "Run"} ${ago(result.at)}` : ""].filter(Boolean).join(" ") || undefined }, words) : null; + } + /** The tests that reach a piece of work, by their latest result, as one short sentence. */ + function testsSentence(guide) { + const tally = { passed: 0, failed: 0, not_run: 0, stale: 0 }; + let any = false; + for (const step of workSteps(guide)) { + const held = step.tests_result; + if (!held) continue; + any = true; + for (const key of Object.keys(tally)) tally[key] += isCount(held[key]) ? held[key] : 0; + } + if (!any) return ""; + const parts = []; + if (tally.failed) parts.push("A test command failed"); + if (tally.passed) parts.push("A test command passed"); + if (tally.stale) parts.push("Some test runs predate the latest edits"); + if (tally.not_run) parts.push("Some tests have not run yet"); + return parts.length ? `${parts.join(". ")}.` : ""; + } + /** Run the tests that reach the uncommitted work, then read the work again. */ + async function runTests() { + const verify = state.verify; + if (verify.busy || verify.unknown || !globalThis.crypto?.randomUUID) return; + verify.busy = true; verify.error = null; verify.operation = null; + verify.operationId = crypto.randomUUID(); + paint("work-verify"); paint("work-actions"); + try { + const started = await send("/api/workspace/verify", { operation_id: verify.operationId }); + verify.operation = started.state ? started : { ...started, state: "running" }; + followVerify(verify.operation.operation_id); + } catch (error) { + verify.busy = false; + if (error.status === 409 && error.operation?.operation_id) { + verify.operation = error.operation; + verify.busy = verify.operation.state === "running"; + followVerify(verify.operation.operation_id); + } else { + verify.error = error.remedy || error.message || "Kin could not confirm whether the tests started."; + verify.unknown = ![400, 403, 404, 405, 409, 422, 501].includes(error.status); + } + } + paint("work-verify"); paint("work-actions"); + } + function followVerify(id, misses = 0) { + const verify = state.verify; + clearTimeout(verify.timer); + if (!id || verify.operation?.state !== "running") { verify.busy = false; return; } + verify.timer = setTimeout(async () => { + try { + verify.operation = await fetchJSON(`/api/workspace/operations/${encodeURIComponent(id)}`); + if (verify.operation.state === "running") { paint("work-verify"); return followVerify(id); } + verify.busy = false; + announce(verify.operation.state === "done" ? VERIFY_ANNOUNCED[verify.operation.outcome] || "No tests ran." : "The test run did not finish."); + refresh(["working"]); + } catch (error) { + if (misses >= 5) { verify.busy = false; verify.unknown = true; verify.error = "Kin stopped answering about the test run."; } + else return followVerify(id, misses + 1); + } + paint("work-verify"); paint("work-actions"); + }, 1000); + } + const VERIFY_ANNOUNCED = { passed: "The test command passed.", failed: "The test command failed.", could_not_run: "The test command could not run." }; + /** What Kin kept of a finished test command: its exit code, the end of what it printed, and the operation that ran it. */ + function runDetails(operation) { + const run = operation.result?.run; + const tail = text(run?.output_tail); + const exit = Number.isInteger(run?.exit_code) ? `Exit code ${run.exit_code}` : run ? run.outcome === "could_not_run" ? "It never started, so it has no exit code." : "It ended without an exit code." : ""; + if (!exit && !tail && !operation.operation_id) return null; + return h("details", { class: "more" }, h("summary", {}, "Show details"), + exit ? h("p", {}, exit) : null, + tail ? [h("p", {}, "The end of its output"), h("pre", { class: "op-lines", tabindex: "0", "aria-label": "The end of the test command's output" }, tail)] : null, + operation.operation_id ? h("p", {}, "Operation ", h("code", {}, operation.operation_id)) : null); + } + function verifyNote() { + const verify = state.verify; + const guide = read("working").data; + if (verify.unknown) return h("div", { class: "message message-warn section-tight", role: "alert" }, + h("div", {}, h("strong", {}, "The test run's outcome is unknown"), h("p", {}, verify.error), operationDetails(verify.operation?.operation_id || verify.operationId)), + button("Check the run", () => { const id = verify.operation?.operation_id || verify.operationId; verify.unknown = false; verify.error = null; verify.busy = true; verify.operation = { ...verify.operation, operation_id: id, state: "running" }; followVerify(id); paint("work-verify"); paint("work-actions"); }, { class: "button button-small" })); + if (verify.error) return h("div", { class: "message message-bad section-tight", role: "alert" }, h("div", {}, h("strong", {}, "The test run could not start"), h("p", {}, verify.error))); + if (verify.busy) { + const progress = verify.operation?.progress; + const words = text(verify.operation?.phase) || (isCount(progress?.done) && isCount(progress?.total) && progress.total ? `Running the tests that reach this work, ${count(progress.done)} of ${count(progress.total)}` : "Running the tests that reach this work"); + return h("p", { class: "note aside-reading", role: "status" }, words); + } + const operation = verify.operation; + if (operation?.state === "unavailable" || operation?.state === "failed") return h("div", { class: "message message-warn section-tight", role: "alert" }, h("div", {}, + h("strong", {}, operation.state === "unavailable" ? "Tests are unavailable" : "The test run did not finish"), h("p", {}, text(operation.reason) || text(operation.error) || "Kin could not complete the test run."))); + if (operation?.state === "done") { + const outcome = operation.outcome; + const title = outcome === "passed" ? "Test command passed" : outcome === "failed" ? "Test command failed" : outcome === "could_not_run" ? "Test command could not run" : "No tests ran"; + const result = operation.result; + const run = result?.run; + const exited = Number.isInteger(run?.exit_code) ? ` It exited ${run.exit_code}.` : ""; + const said = outcome === "no_tests" ? "Kin found no tests it could run for this work. This work has not passed a test run." + // It never reached a test, so it is said apart from a run that failed, and no test is said to have failed. + : outcome === "could_not_run" ? `${text(run?.reason) || "Kin could not run the test command."}${exited} No test passed or failed, so the tests that reach this work have not run.` + : `${result?.reach === "whole" ? "Kin ran the repository's full test suite." : "Kin ran the test command selected for this work."}${outcome === "failed" ? exited : ""} It did not report individual test outcomes.`; + return h("div", { class: `message message-${outcome === "passed" ? "good" : outcome === "failed" ? "bad" : "warn"} section-tight`, role: "status" }, h("div", {}, + h("strong", {}, title), h("p", {}, said), runDetails(operation)), button("Dismiss", () => { verify.operation = null; paint("work-verify"); }, { class: "button button-small button-quiet" })); + } + const sentence = guide ? testsSentence(guide) : ""; + return sentence ? h("p", { class: "tests-line" }, sentence) : null; + } + + // ── Undoing one entity's change ── + const restoreGuard = (step) => step.change === "removed" ? "absent" : /^[0-9a-f]{64}$/i.test(text(step.after?.body_hash)) ? `source:${step.after.body_hash}` : null; + /** Put one entity back as it was at the last commit, then read the work again. */ + async function restoreEntity(step) { + const restore = state.restore; + const expected = restoreGuard(step); + if (restore.busy || restore.unknown || state.commit.busy || workspaceWriteBusy() || read("working").stale || !expected || !globalThis.crypto?.randomUUID) return; + restore.busy = true; restore.error = null; restore.refused = false; restore.done = null; + restore.operationId = crypto.randomUUID(); + rememberWorkReceipt("restore", restore.operationId); + paint("review-body"); + try { + const result = await send("/api/workspace/entity/restore", { entity: step.entity_id, operation_id: restore.operationId, expected }); + restore.asking = null; + forgetWorkReceipt("restore"); + // The receipt stays where the undo was taken, and on Uncommitted, until it is dismissed. + restore.done = { entity: step.entity_id, name: text(result?.name) || step.name, change: step.change, to: text(result?.to), base: text(read("working").data?.change?.base) }; + state.commit.result = null; + announce(restoreReceiptText(restore.done)); + await refresh(["working"]); + // The undone entity leaves the walk, so the page it stood on now holds the next one, or the walk is over. + if (state.route === "review" && isWorkingReview() && state.params.step) { + const left = reviewSteps(reviewGuide()).length; + if (state.params.step > left) location.hash = left ? "#/review/w/done" : UNCOMMITTED; + } + } catch (error) { + // A refusal is Kin's own sentence about this entity, such as its source + // changing after this reading, so it is said as Kin wrote it. + restore.refused = error.status === 409 && error.outcome === "refused"; + restore.error = (restore.refused ? error.message || error.remedy : error.remedy || error.message) || "It was not undone."; + restore.unknown = !restore.refused && ![400, 403, 404, 405, 422, 501].includes(error.status); + if (restore.unknown) read("working").stale = true; + else forgetWorkReceipt("restore"); + } finally { + restore.busy = false; + paint("review-body"); + } + } + /** What an undo did, in one sentence. */ + function restoreReceiptText(done) { + const name = shownName(done.name); + const at = /^[0-9a-f]{64}$/.test(done.to) ? ` at change ${done.to.slice(0, 8)}` : " at the last commit"; + if (done.change === "added") return `${name} is gone again: it was new in this work, so undoing it removed it.`; + if (done.change === "removed") return `${name} is back, as it was${at}.`; + return `${name} is back as it was${at}. The rest of the work stays as it is.`; + } + function restoreReceipt() { + const done = state.restore.done; + if (!done || state.restore.unknown) return null; + return h("div", { class: "message message-good section-tight", role: "status", id: "restore-receipt" }, + h("div", {}, h("strong", {}, done.change === "added" ? `Removed ${shownName(done.name)}` : `Undid the change to ${shownName(done.name)}`), h("p", {}, restoreReceiptText(done))), + button("Dismiss", () => { state.restore.done = null; paint("work-restore"); paint("review-body"); }, { class: "button button-small button-quiet" })); + } + function restoreRecovery() { + const restore = state.restore; + if (!restore.unknown) return restoreReceipt(); + const working = read("working"); + return h("div", { class: "message message-warn section-tight", role: "alert" }, h("div", {}, h("strong", {}, "The undo's outcome is unknown"), h("p", {}, "The undo may have happened. This page will not send it again. Read the work again to see.")), + h("div", { class: "page-actions" }, button("Read the work again", () => refresh(["working"]), { class: "button button-small" }), + button("I've checked", () => { if (!working.data || working.error || working.stale || working.busy) return; restore.unknown = false; restore.error = null; restore.asking = null; forgetWorkReceipt("restore"); paint("work-restore"); paint("work-actions"); repaintCommit(); }, { class: "button button-small button-quiet", disabled: Boolean(!working.data || working.error || working.stale || working.busy) })), operationDetails(restore.operationId)); + } + function restoreControls(step) { + if (!can("entity_restore") || !isWorkingReview() || !UUID.test(text(step.entity_id))) return null; + const restore = state.restore; + const key = markOf(step); + if (restore.unknown) return null; + const guarded = Boolean(restoreGuard(step)) && !read("working").stale && !read("working").error; + if (restore.asking !== key) return h("div", { class: "step-undo" }, button(step.change === "added" ? "Remove this entity" : "Undo this change", () => { restore.asking = key; restore.error = null; restore.refused = false; paint("review-body"); }, { class: "button button-quiet button-small", id: "review-undo", disabled: !guarded || state.commit.busy || workspaceWriteBusy() }), + !guarded ? h("p", { class: "note" }, "Kin needs a fresh, exact reading of this entity before it can undo it safely. ", button("Refresh", () => refresh(["working"]), { class: "button button-small" })) : null); + return h("div", { class: "message message-warn step-undo-confirm", role: "alertdialog", "aria-label": "Undo this change" }, + h("div", {}, h("strong", {}, step.change === "added" ? `Remove ${step.name}?` : step.change === "removed" ? `Bring ${step.name} back?` : `Put ${step.name} back as it was?`), + h("p", {}, step.change === "added" ? "It is new in this work, so undoing it takes it out. The rest of the work stays as it is." : "It goes back to how it was at the last commit. The rest of the work stays as it is."), + restore.error ? h("p", { class: "state state-bad" }, restore.error) : null), + h("div", { class: "page-actions" }, + button("Keep it", () => { restore.asking = null; restore.error = null; restore.refused = false; paint("review-body"); }, { class: "button button-small button-quiet", disabled: restore.busy }), + // A refused undo is undone from a fresh reading, never by resending the old one. + restore.refused && restore.error ? button("Read the work again", () => { restore.asking = null; restore.error = null; restore.refused = false; paint("review-body"); refresh(["working"]); }, { class: "button button-small button-quiet", id: "review-undo-reread", disabled: restore.busy }) : null, + button(restore.busy ? "Undoing…" : "Undo it", () => restoreEntity(step), { class: "button button-small", id: "review-undo-confirm", disabled: restore.busy || !guarded || state.commit.busy || workspaceWriteBusy() }))); + } function workLede() { const resource = read("working"); @@ -1297,7 +2218,10 @@ const completeness = guide.summary?.completeness; const caveat = sentence && unsettled(guide) ? (isCount(completeness?.pending_entities) && completeness.pending_entities > 0 ? `Kin is still mapping ${plural(completeness.pending_entities, "entity", "entities")}, so these counts may grow.` : "Kin is still mapping this code, so these counts may grow.") : ""; + const authors = array(guide.summary?.authors).filter((who) => typeof who?.name === "string" && isCount(who.entities)); + const byAgents = [authors.length ? `${authors.slice(0, 3).map((who) => `${who.name} changed ${plural(who.entities, "entity", "entities")}`).join(", ")}.` : "", sentence ? authorGapWords(guide) : ""].filter(Boolean).join(" "); return [h("p", { class: `lede${sentence ? " lede-strong" : ""}` }, body), + byAgents ? h("p", { class: "work-authors" }, byAgents) : null, caveat ? h("p", { class: "work-caveat" }, caveat) : null, resource.error ? h("p", { class: "note quiet-line" }, "Showing what Kin last saw. ", button("Refresh", () => refresh(["working"]), { class: "button button-small" })) : null]; } @@ -1310,7 +2234,8 @@ const next = steps.findIndex((step) => !markState(step)); const looked = steps.filter((step) => markState(step)).length; // A commit acts on what is there now, so it waits for the fresh read. - return [canCommit() && !state.commit.open && !read("working").stale ? button("Commit", () => { state.commit.open = true; state.commit.result = null; state.commit.error = null; repaintCommit(); byId("commit-message")?.focus(); }, { disabled: state.commit.unknown }) : null, + return [can("verify_run") && !read("working").stale ? button(state.verify.busy ? "Running tests…" : "Run tests", runTests, { id: "work-run-tests", class: "button button-quiet", disabled: state.verify.busy || state.verify.unknown }) : null, + canCommit() && !state.commit.open && !read("working").stale ? button("Commit", () => { state.commit.open = true; state.commit.result = null; state.commit.error = null; repaintCommit(); byId("commit-message")?.focus(); }, { disabled: state.commit.unknown || state.restore.unknown || state.restore.busy || workspaceWriteBusy() }) : null, steps.length ? link(looked === 0 ? "Start review" : next < 0 ? "Finish review" : "Continue review", next < 0 ? "#/review/w/done" : `#/review/w/${next + 1}`, true) : null]; } @@ -1320,17 +2245,26 @@ async function commitWork() { const commit = state.commit; const message = commit.message.trim(); - if (!message || commit.busy || commit.unknown || state.refreshing) return; + if (!message || commit.busy || commit.unknown || state.restore.busy || state.restore.unknown || workspaceWriteBusy() || state.refreshing || read("working").stale || read("working").error) return; if (!globalThis.crypto?.randomUUID) { commit.error = "This browser could not create an operation ID. Reload the page before committing."; repaintCommit(); return; } commit.operationId = crypto.randomUUID(); + rememberWorkReceipt("commit", commit.operationId); commit.busy = true; commit.error = null; commit.remedy = null; repaintCommit(); try { - const result = await send("/api/workspace/commit", { message, operation_id: commit.operationId }); - commit.result = result; + const only = commitOnly(); + const held = workSteps(read("working").data); + const chosen = only ? held.filter((step) => only.includes(step.entity_id)) : held; + const changedBefore = workChanged(read("working").data); + const result = await send("/api/workspace/commit", { message, operation_id: commit.operationId, ...(only ? { entities: only } : {}) }); + // The receipt names what this commit took and what it left, as the page saw the work when it was sent. + commit.result = { ...result, names: chosen.map((step) => shownName(step.name)), took: only ? only.length : changedBefore, left: only ? Math.max(0, changedBefore - only.length) : 0 }; + state.restore.done = null; + forgetWorkReceipt("commit"); commit.open = false; commit.message = ""; - if (result.outcome === "committed" && state.route === "review" && isWorkingReview()) carryMarks(result.change_id); + commit.drafted = false; + if (result.outcome === "committed" && state.route === "review" && isWorkingReview()) carryMarks(result.change_id, only); announce(result.outcome === "committed" ? "Committed." : "Nothing to commit."); // What the page holds as in flight was read before the commit, so it is marked as such until the new read lands. if (read("working").data) { read("working").stale = true; paint("work-flight"); } @@ -1339,6 +2273,7 @@ commit.remedy = error.remedy || null; // A refusal wrote nothing. Anything else may have landed, so this page does not send it again. commit.unknown = !(error.status === 409 && error.outcome === "refused") && ![400, 403, 404, 405, 422, 501].includes(error.status); + if (!commit.unknown) forgetWorkReceipt("commit"); announce(commit.unknown ? "The commit's outcome is unknown." : "The commit was not made."); } finally { commit.busy = false; @@ -1348,14 +2283,59 @@ } } + /** What a commit took, as one change, and what it left uncommitted. */ + function commitReceiptWords(result) { + const names = array(result.names).filter((name) => typeof name === "string" && name); + const took = isCount(result.took) ? result.took : names.length; + const some = names.length <= 3 ? names.join(names.length === 2 ? " and " : ", ").replace(/, ([^,]*)$/, " and $1") : `${names.slice(0, 2).join(", ")} and ${count(names.length - 2)} more`; + const what = names.length ? some : took ? plural(took, "entity", "entities") : "Your work"; + const id = /^[0-9a-f]{64}$/.test(text(result.change_id)) ? [" as change ", h("code", {}, result.change_id.slice(0, 8))] : [" as one change"]; + const title = text(result.title) ? [`, “${result.title}”`] : []; + const left = isCount(result.left) && result.left > 0 ? ` ${plural(result.left, "entity", "entities")} ${result.left === 1 ? "stays" : "stay"} uncommitted.` : ""; + return [`${what} ${names.length === 1 || (!names.length && took === 1) ? "is" : "are"} committed`, ...id, ...title, `.${left}`]; + } + + /** A first line for the commit, drafted from the entities it holds: what was added, changed and removed, by name. */ + function draftMessage(steps) { + const by = { added: [], modified: [], removed: [] }; + for (const step of steps) (by[step.change] || by.modified).push(shownName(step.name)); + const some = (names) => names.length <= 3 ? names.join(names.length === 2 ? " and " : ", ").replace(/, ([^,]*)$/, " and $1") : `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`; + const parts = []; + if (by.modified.length) parts.push(`Change ${some(by.modified)}`); + if (by.added.length) parts.push(`${parts.length ? "add" : "Add"} ${some(by.added)}`); + if (by.removed.length) parts.push(`${parts.length ? "remove" : "Remove"} ${some(by.removed)}`); + // The clauses join with "and" only between them, never inside a list of names. + return parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}` : parts.join(""); + } + + function prepareCommitDraft(steps) { + const selected = commitOnly(); + const key = JSON.stringify(selected); + // Kin's own draft follows the choice of what to commit until the reader writes their own. + if (state.commit.drafted && state.commit.message.trim() && state.commit.draftKey === key) return; + if (state.commit.message.trim() && !state.commit.drafted) return; + state.commit.message = draftMessage(selected ? steps.filter((step) => selected.includes(step.entity_id)) : steps); + state.commit.drafted = true; + state.commit.draftKey = key; + } + + /** When the reader flagged some entities and the server can split a commit, the entities that looked right. */ + function commitOnly() { + if (!state.commit.onlyFine || !can("entity_commit") || state.route !== "review" || !isWorkingReview()) return null; + const steps = reviewSteps(reviewGuide()); + const fine = steps.filter((step) => markState(step) === "fine" && UUID.test(text(step.entity_id))); + const flagged = steps.filter((step) => markState(step) === "flagged"); + return flagged.length && fine.length ? fine.map((step) => step.entity_id) : null; + } + function commitBox() { const commit = state.commit; const guide = read("working").data; if (commit.result) { const done = commit.result.outcome === "committed"; - return h("div", { class: `message message-${done ? "good" : "warn"} section-tight`, role: "status" }, + return h("div", { class: `message message-${done ? "good" : "warn"} section-tight`, role: "status", id: "commit-receipt" }, h("div", {}, h("strong", {}, done ? "Committed" : "Nothing to commit"), - h("p", {}, done ? text(commit.result.title) || "The work is now a change." : "Your code already matches your last commit.")), + h("p", {}, done ? commitReceiptWords(commit.result) : "Your code already matches your last commit.")), h("div", { class: "page-actions" }, done && /^[0-9a-f]{64}$/.test(text(commit.result.change_id)) ? link("Review it", reviewHref(commit.result.change_id), false, { class: "button button-small" }) : null, button("Dismiss", () => { commit.result = null; repaintCommit(); }, { class: "button button-small button-quiet" }))); @@ -1365,28 +2345,58 @@ h("strong", {}, "The commit's outcome is unknown"), h("p", {}, "Kin stopped answering after the request was sent. This page will not send it again. Check History to see whether it landed."), h("details", { class: "more" }, h("summary", {}, "Show details"), h("p", { class: "detail-text" }, commit.error), commit.operationId ? h("p", {}, "Operation ", h("code", {}, commit.operationId)) : null)), - button("I've checked", () => { commit.unknown = false; commit.error = null; repaintCommit(); }, { class: "button button-small" })); + button("I've checked", () => { commit.unknown = false; commit.error = null; forgetWorkReceipt("commit"); repaintCommit(); }, { class: "button button-small" })); } if (!commit.open) return null; const changed = workChanged(guide); const atFinish = state.route === "review"; - const submit = button(commit.busy ? "Committing…" : "Commit", commitWork, { id: "commit-submit", class: "button button-primary", disabled: commit.busy || !commit.message.trim() }); + const locked = () => commit.busy || state.restore.unknown || state.restore.busy || workspaceWriteBusy() || read("working").stale || Boolean(read("working").error); + const submit = button(commit.busy ? "Committing…" : "Commit", commitWork, { id: "commit-submit", class: "button button-primary", disabled: locked() || !commit.message.trim() }); const field = h("textarea", { id: "commit-message", rows: "3", maxlength: "4000", placeholder: "What changed, and why", "aria-label": "Commit message", disabled: commit.busy, - on: { input: (event) => { commit.message = event.target.value; submit.disabled = commit.busy || !commit.message.trim(); const hint = submit.parentNode?.querySelector?.(".note"); if (hint) hint.hidden = Boolean(commit.message.trim()); }, + on: { input: (event) => { commit.message = event.target.value; commit.drafted = false; submit.disabled = locked() || !commit.message.trim(); needed.hidden = Boolean(commit.message.trim()); const note = byId("commit-drafted"); if (note) note.hidden = true; }, keydown: (event) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); commitWork(); } } } }, commit.message); + const needed = h("span", { class: "note" }, "A message is needed to commit."); + needed.hidden = Boolean(commit.message.trim()); + const flaggedCount = atFinish && isWorkingReview() ? reviewSteps(reviewGuide()).filter((step) => markState(step) === "flagged").length : 0; + const fineCount = atFinish && isWorkingReview() ? reviewSteps(reviewGuide()).filter((step) => markState(step) === "fine").length : 0; + const unmarkedCount = atFinish && isWorkingReview() ? reviewSteps(reviewGuide()).filter((step) => !markState(step)).length : 0; + const splitting = Boolean(commitOnly()); + // Everything not marked as looking right stays: the flagged, to send back, and the unmarked, to look at. + const staying = [`the ${spoken(flaggedCount).toLowerCase()} you flagged`, unmarkedCount ? `the ${spoken(unmarkedCount).toLowerCase()} you did not mark` : ""].filter(Boolean).join(" and "); + const choice = flaggedCount && fineCount && can("entity_commit") ? h("label", { class: "check commit-only" }, + h("input", { type: "checkbox", id: "commit-only-fine", checked: commit.onlyFine, disabled: commit.busy, on: { change: (event) => { commit.onlyFine = event.target.checked; prepareCommitDraft(reviewSteps(reviewGuide())); repaintCommit(); } } }), + h("span", {}, `Commit only the ${spoken(fineCount).toLowerCase()} that ${fineCount === 1 ? "looks" : "look"} right. ${staying.replace(/^\w/, (c) => c.toUpperCase())} ${flaggedCount + unmarkedCount === 1 ? "stays" : "stay"} uncommitted.`)) : null; return h("section", { class: "commit-box", "aria-labelledby": "commit-title" }, - h("h2", { id: "commit-title" }, "Commit this work"), - h("p", {}, changed ? `Everything uncommitted becomes one change: ${plural(changed, "entity", "entities")}.` : "Everything uncommitted becomes one change."), + h("h2", { id: "commit-title" }, splitting ? "Commit what looks right" : "Commit this work"), + h("p", {}, splitting ? `${plural(fineCount, "entity", "entities")} become${fineCount === 1 ? "s" : ""} one change.` : changed ? `Everything uncommitted becomes one change: ${plural(changed, "entity", "entities")}.` : "Everything uncommitted becomes one change."), + choice, field, commit.error ? h("div", { class: "message message-bad section-tight", role: "alert" }, h("div", {}, h("strong", {}, "The commit was not made"), h("p", {}, commit.remedy || commit.error), commit.remedy ? h("details", { class: "more" }, h("summary", {}, "Show details"), h("p", { class: "detail-text" }, commit.error)) : null)) : null, h("div", { class: "page-actions" }, submit, // At the end of a review the form is the page, so there is nothing to cancel back to. atFinish ? null : button("Cancel", () => { commit.open = false; commit.error = null; repaintCommit(); }, { class: "button button-quiet", disabled: commit.busy }), - h("span", { class: "note" }, "A message is needed to commit."))); + needed, + commit.drafted && commit.message.trim() ? h("span", { class: "note", id: "commit-drafted" }, "Kin drafted this from what changed. Say why in your own words.") : null)); } const RELATION_WORD = { calls: "calls it", references: "refers to it", overrides: "overrides it", extends: "extends it", implements: "implements it", imports: "imports it", includes: "includes it", uses_type: "uses its type", instantiates: "constructs it", tests: "tests it", covers: "covers it" }; + // Kinds of entity that are types, which something can use the type of. + const TYPE_KINDS = new Set(["class", "interface", "type_alias", "type", "struct", "enum", "trait", "union", "protocol", "record", "typedef"]); + /** + * How one entity relies on another, from the relation's kind and the two + * entities' kinds. A module relies on what it holds a binding to by + * importing it, and only a type has a type to use: a module that imports a + * function never "uses its type". + */ + function relationWord(relation, from, to) { + const kind = text(relation).toLowerCase(); + const fromKind = text(from?.kind || from).toLowerCase(); + const toKind = text(to?.kind || to).toLowerCase(); + if (fromKind === "module" && ["uses_type", "references", "imports"].includes(kind)) return "imports it"; + if (kind === "uses_type" && toKind && !TYPE_KINDS.has(toKind)) return "refers to it"; + return RELATION_WORD[kind] || "relies on it"; + } /** Lines two versions of an entity share, in order: the longest run both keep. */ function lineDiff(before, after) { @@ -1469,6 +2479,73 @@ } const stepKey = (step) => text(step?.entity_id) || `${text(step?.name)}\u0000${text(step?.within)}`; const withinName = (within) => text(within).replace(/^(module|class|interface|namespace|package|struct|trait|impl|crate) /, ""); + + // ── Telling apart entities that read the same ── + // Two entities of one name and kind in one view are told apart by what + // holds each. The guide says what holds most of them; an entity it places + // in nothing, such as a module, is read once for the place Kin records it + // under, and the part of that place where the two differ is said. + const placeReads = new Map(); + // Bumped as each place read lands, so a part of Home drawn from the places is drawn again. + let placesRead = 0; + /** Where Kin records an entity, read once. Home reads it from the entity's own repository, at `api`. */ + function readPlace(id, api = "") { + if ((MACHINE && !api) || placeReads.has(id) || !UUID.test(id)) return; + placeReads.set(id, { busy: true }); + const epoch = state.readEpoch; + fetchJSON(`${api ? `root:${api}` : ""}/api/workspace/entity?id=${encodeURIComponent(id)}§ions=header`) + .then((data) => { const header = data?.header || {}; placeReads.set(id, { place: text(header.module) || text(header.container?.name) }); }) + .catch(() => { placeReads.set(id, { place: "" }); }) + .finally(() => { + placesRead += 1; + if (epoch !== state.readEpoch) return; + if (MACHINE) repaintHome(); + else for (const section of ["review-body", "work-flight", "review-sites"]) paint(section); + }); + } + + /** + * The shortest end of each place that no other place in the list shares: + * `classic` and `mini` for two `checks` modules, `test_body.test_tutorial001` + * beside another `test_tutorial001`. A place that names the entity itself, + * as a module's does, is read from what holds it. A place that cannot be + * told apart gives nothing. + */ + function differingParts(places, name) { + const parts = places.map((place) => { const list = text(place).split(/::|[./\\]/).filter(Boolean); return list.at(-1) === name ? list.slice(0, -1) : list; }); + return parts.map((list, at) => { + for (let back = 1; back <= list.length; back++) { + const end = list.slice(-back).join("."); + if (parts.every((other, index) => index === at || other.slice(-back).join(".") !== end)) return end; + } + return ""; + }); + } + /** + * For the entities one view shows, what to add after each name that + * another in the view shares: what holds it, or where its place differs. + * Nothing is added for a name that is not shared, or that Kin cannot tell + * apart. + */ + function nameQualifiers(entities, api = "") { + const out = new Map(); + const groups = new Map(); + for (const entity of entities) { + const key = `${shownName(entity?.name)}\u0000${text(entity?.kind).toLowerCase()}`; + if (!groups.has(key)) groups.set(key, Object.assign([], { name: shownName(entity?.name) })); + if (!groups.get(key).some((held) => held.id === text(entity.id))) groups.get(key).push({ id: text(entity.id), within: withinName(entity.within) }); + } + for (const group of groups.values()) { + if (group.length < 2) continue; + if (new Set(group.map((held) => held.within)).size === group.length && group.every((held) => held.within)) { for (const held of group) out.set(held.id, held.within); continue; } + group.forEach((held) => readPlace(held.id, api)); + const places = group.map((held) => placeReads.get(held.id)?.place); + if (places.some((place) => place === undefined)) continue; + differingParts(places, group.name).forEach((part, at) => { if (part) out.set(group[at].id, part); }); + } + return out; + } + const qualified = (name, qualifier) => [name, qualifier ? h("span", { class: "name-qualifier" }, ` in ${qualifier}`) : null]; const placeOf = (step) => { const where = withinName(step.within); return [kindWord(step.kind), where && where !== step.name ? `in ${where}` : ""].filter(Boolean).join(" "); @@ -1482,8 +2559,28 @@ if (fields.has("signature") && fields.has("body")) return "Its signature and its body changed."; if (fields.has("signature")) return "Its signature changed."; if (fields.has("body")) return "Its body changed. Its signature did not."; + // The comment above it is not in its code, so a documentation edit leaves the code reading the same token for token. + if (fields.has("doc_summary") && fields.size === 1) return "Its documentation changed. Its code reads the same token for token."; return "It changed."; } + /** What its documentation said before and after, when that is what changed. */ + function documentationChange(step) { + const field = array(step.fields).find((item) => text(item?.field) === "doc_summary"); + if (!field) return null; + const said = (value) => text(value) ? h("q", {}, text(value)) : h("span", { class: "aside" }, "none"); + return h("dl", { class: "doc-change" }, + h("dt", {}, "Documentation before"), h("dd", {}, said(field.before)), + h("dt", {}, "Documentation after"), h("dd", {}, said(field.after))); + } + /** The entities whose documentation Kin only read again: said once, quietly, never walked. */ + function rereadLine(guide) { + const summary = guide?.summary || {}; + const total = isCount(summary.documentation_rereads) ? summary.documentation_rereads : 0; + if (!total) return null; + const names = array(summary.documentation_reread_names).map(text).filter(Boolean); + const who = total === 1 && names.length === 1 ? names[0] : plural(total, "entity", "entities"); + return h("p", { class: "note quiet-line", id: "review-rereads" }, `Kin re-read the documentation of ${who}; ${total === 1 ? "the code is" : "their code is"} identical.`); + } /** One changed entity in full: what happened, what that asks of the reader, its source before and after, what relies on it, and the tests that reach it. */ function stepDetail(step, guide, onCleanup) { @@ -1492,29 +2589,58 @@ const consumersTotal = isCount(connections.consumers_total) ? connections.consumers_total : consumers.length; const tests = array(connections.tests).filter((test) => test?.entity || test?.name); const testsTotal = isCount(connections.tests_total) ? connections.tests_total : tests.length; - const SHOWN = 8; - const verdict = text(step.consumer_verdict?.verdict); - const consumerState = (consumer) => consumer.changed_here ? h("span", { class: "state state-good" }, "Changed here too") - : verdict === "needs_no_update" ? h("span", { class: "state" }, "Needs no update") - : h("span", { class: `state state-${verdict === "not_updated" ? "bad" : "warn"}` }, verdict === "not_updated" ? "Not updated" : "May need review"); const rank = stepAttention(step); return h("article", { class: "step", "aria-label": step.name }, h("header", { class: "step-head" }, h("h1", { class: "step-name" }, ent({ id: step.entity_id, name: step.name, kind: step.kind }, { linked: step.change !== "removed" })), - h("p", { class: "step-place" }, placeOf(step)), - h("p", { class: `step-verdict${rank === 0 ? " step-verdict-bad" : rank === 1 ? " step-verdict-warn" : ""}` }, `${changeSentence(step)} ${stepReason(step)}.`)), + h("p", { class: "step-place" }, [placeOf(step), authorWords(step, guide)].filter(Boolean).join(" · ")), + h("p", { class: `step-verdict${rank === 0 ? " step-verdict-bad" : rank === 1 ? " step-verdict-warn" : ""}` }, [`${changeSentence(step)} ${stepReason(step, guide)}.`, stepTestsWords(step, guide)].filter(Boolean).join(" "))), h("section", { class: "step-part", "aria-labelledby": "step-source-title" }, h("h2", { id: "step-source-title" }, step.change === "added" ? "New source" : step.change === "removed" ? "Removed source" : "Before and after"), + documentationChange(step), diffView(step) || h("p", { class: "gap" }, "No source was returned for this entity.")), h("section", { class: "step-part", "aria-labelledby": "step-relies-title" }, - h("h2", { id: "step-relies-title" }, "What relies on it", h("span", { class: "count" }, count(consumersTotal))), - consumers.length ? stepPicture(step, onCleanup) - : h("p", { class: "gap" }, step.change === "added" ? "Nothing relies on it yet." : "Kin found nothing else that relies on it.")), + h("h2", { id: "step-relies-title" }, "What relies on it", h("span", { class: "count" }, graphBadge(consumersTotal, limitedGuide(guide)))), + consumers.length ? [stepPicture(step, onCleanup), section("review-sites", [], () => callSites(step))] + : h("p", { class: "gap" }, "Kin found no entities that rely on it.")), h("section", { class: "step-part", "aria-labelledby": "step-tests-title" }, - h("h2", { id: "step-tests-title" }, "Tests that reach it", h("span", { class: "count" }, count(testsTotal))), - tests.length ? h("ul", { class: "rows" }, tests.slice(0, SHOWN).map((test) => h("li", { class: "rows-item" }, ent(test.entity || test))), - testsTotal > Math.min(SHOWN, tests.length) ? h("li", { class: "rows-item rows-more" }, `${count(testsTotal - Math.min(SHOWN, tests.length))} more`) : null) - : h("p", { class: "gap" }, unsettled(guide) ? "Kin has found no test that reaches it yet." : "Kin found no test that reaches it."))); + h("h2", { id: "step-tests-title" }, "Tests that reach it", testsTotal ? h("span", { class: "count" }, graphBadge(testsTotal, limitedGuide(guide))) : null), + stepTests(step, tests, testsTotal, guide), + tests.some((test) => ["passed", "failed"].includes(test.result?.state)) ? h("p", { class: "note" }, "These results describe the test command. Kin did not report individual test outcomes.") : null)); + } + + /** How far a test is from what it reaches: Kin counts the recorded steps between them, and a direct one is one step. */ + const hopsOf = (row) => isCount(row?.hops) && row.hops > 0 ? row.hops : 1; + const stepsWords = (n) => n === 1 ? "1 step away" : `${count(n)} steps away`; + /** How a test reaches what changed: how far, and whether every step on the way is a call. */ + const testRoute = (row) => { + const hops = hopsOf(row); + const byCall = isCount(row?.call_hops) && row.call_hops >= hops; + return byCall ? `${stepsWords(hops)}, ${hops === 1 ? "calls it" : "through calls"}` : stepsWords(hops); + }; + /** How far Kin looked for tests, when the guide says. */ + const testDepth = (step) => isCount(step?.connections?.tests_depth) && step.connections.tests_depth > 0 ? step.connections.tests_depth : null; + /** No test, said with how far Kin looked, so a bounded search never reads as a bare zero. */ + function noTestWords(depth, what = "it") { + return depth ? `No test found within ${count(depth)} ${depth === 1 ? "step" : "steps"} of ${what}.` : `Kin found no tests that reach ${what}.`; + } + + /** The tests that reach a changed entity, each with how far it is and its latest result; the rest open in place. */ + function stepTests(step, tests, total, guide) { + if (!tests.length) return h("p", { class: "gap" }, total ? `Kin counted ${graphCount(total, limitedGuide(guide))} ${total === 1 ? "test" : "tests"} that reach it and listed none here.` : noTestWords(testDepth(step))); + const SHOWN = 8; + const key = stepKey(step); + const open = state.testsOpen.has(key); + const shown = open ? tests : tests.slice(0, SHOWN); + const hidden = tests.length - shown.length; + const unlisted = Math.max(0, total - tests.length); + const ofAll = limitedGuide(guide) ? `of at least ${count(total)}` : `of the ${count(total)}`; + const apart = nameQualifiers(shown.map((test) => test.entity || test)); + return h("ul", { class: "rows", id: "step-tests" }, shown.map((test) => h("li", { class: "rows-item" }, ent(test.entity || test), + apart.get(text((test.entity || test).id)) ? h("span", { class: "name-qualifier" }, `in ${apart.get(text((test.entity || test).id))}`) : null, + h("span", { class: "rows-how" }, testRoute(test)), resultWord(test.result))), + hidden > 0 ? h("li", { class: "rows-item rows-more" }, button(unlisted ? `Show ${count(hidden)} more ${ofAll}` : `Show ${count(hidden)} more`, () => { state.testsOpen.add(key); paint("review-body"); }, { class: "button button-small button-quiet", id: "step-tests-more" })) : null, + !hidden && unlisted ? h("li", { class: "rows-item rows-more" }, `Kin lists ${count(tests.length)} ${ofAll} here.`) : null); } /** @@ -1620,9 +2746,11 @@ function uncommitted() { return [ - workHead(section("work-actions", ["working", "changes"], workActions, { class: "page-actions" })), + workHead(section("work-actions", ["working", "changes", "session"], workActions, { class: "page-actions" })), section("work-lede", ["working"], workLede), - section("work-commit", ["working"], commitBox), + section("work-verify", ["working"], verifyNote), + section("work-restore", ["working"], restoreRecovery), + section("work-commit", ["working", "session"], commitBox), section("work-overlaps", ["working", "agents"], overlapList), section("work-flight", ["working", "changes"], uncommittedList)]; } @@ -1656,25 +2784,44 @@ return workSteps(guide).map((step, index) => ({ step, index })).sort((a, b) => stepAttention(a.step) - stepAttention(b.step) || a.index - b.index).map(({ step }) => step); } /** What a changed entity asks of the reader, in a few words that finish the sentence saying what happened to it. */ - function stepReason(step) { + function stepReason(step, guide) { const { total } = outsideOf(step); const rank = stepAttention(step); - const places = `${count(total)} ${total === 1 ? "entity relies" : "entities rely"} on it`; + // Said at the start of a sentence, so a lower bound reads "At least". + const places = `${graphCount(total, limitedGuide(guide))} ${total === 1 ? "entity relies" : "entities rely"} on it`.replace(/^at/, "At"); if (rank === 0) return `${places} and ${total === 1 ? "was" : "were"} not updated`; if (rank === 1) return `${places} and did not change here`; - return total ? `${places} and ${total === 1 ? "needs" : "need"} no update` : "Nothing outside this change relies on it"; + return total ? `${places} and ${total === 1 ? "needs" : "need"} no update` : "Kin found no connections outside this change"; + } + + /** Whether a test reaches a changed entity, said where the reader starts. */ + function stepTestsWords(step, guide) { + // Something removed has nothing left for a test to reach. + if (step.change === "removed") return ""; + const connections = step.connections || {}; + const total = isCount(connections.tests_total) ? connections.tests_total : array(connections.tests).length; + const failedTests = isCount(step.tests_result?.failed) ? step.tests_result.failed : 0; + // Counts are numerals here, as the sentence before this one says its own. + if (failedTests) return `${count(failedTests)} ${failedTests === 1 ? "test is" : "tests are"} included in a failed run.`; + if (total) return `${graphCount(total, limitedGuide(guide)).replace(/^at/, "At")} ${total === 1 ? "test reaches" : "tests reach"} it${withinSteps(testDepth(step))}.`; + return noTestWords(testDepth(step)); } /** A row's reason: what is unusual about the change, when something is, then what it asks of the reader. */ - function rowReason(step) { + function rowReason(step, guide) { const fields = new Set(array(step.fields).map((field) => text(field?.field))); const lead = step.change === "added" ? "New. " : step.change === "removed" ? "Removed. " : fields.has("signature") ? "Signature changed. " : ""; - return `${lead}${stepReason(step)}.`; + return `${lead}${stepReason(step, guide)}.`; } - // What the reader has already looked at is kept on this machine, by the - // entity and the shape it had, so an entity that changes again is new again. - const markOf = (step) => `${text(step.entity_id) || text(step.name)}:${text(step.evidence?.fingerprint_after?.ast) || text(step.evidence?.fingerprint_before?.ast)}`; + // A source edit makes an entity new to the review again, including a change + // to a literal or a comment that leaves its syntax shape unchanged. + const markOf = (step) => { + const body = text(step.after?.body_hash) || text(step.before?.body_hash); + const behavior = text(step.evidence?.fingerprint_after?.behavior) || text(step.evidence?.fingerprint_before?.behavior); + const shape = text(step.evidence?.fingerprint_after?.ast) || text(step.evidence?.fingerprint_before?.ast); + return `${text(step.entity_id) || text(step.name)}:${body || (/^[0-9a-f]{64}$/i.test(behavior) ? behavior : shape)}`; + }; function marksKey(guide) { const subject = isWorkingReview() ? `w:${text(guide?.change?.base)}` : reviewTargetKey(state.review.target); return `kin.workspace.review:${apiBase}:${subject}`; @@ -1703,17 +2850,120 @@ if (kind === "flagged" && note.trim()) marks.notes[key] = note.trim().slice(0, 400); saveMarks(); } - const noteOf = (step) => text(state.review.marks.notes?.[markOf(step)]); + const noteOf = (step) => text(state.review.marks.notes?.[markOf(step)]) || (state.review.marks.fine.has(markOf(step)) ? "" : text(keptFlag(step)?.note)); + // A flag is also kept in the repository when the server can keep it, so the + // agent that changed the entity sees it the next time it touches it. The + // mark on this machine stays what the walk reads; the repository's copy is + // what reaches the agent. + const HASH = /^[0-9a-f]{64}$/i; + /** + * The repository's open flag on this step's entity. In a review of a + * recorded change, only a flag set on the version that change left counts: + * one set on a later version is about something else. + */ + function flagFor(step) { + const flag = array(read("flags").data?.flags).find((one) => one && one.entity_id === step.entity_id) || null; + if (!flag || isWorkingReview()) return flag; + const reviewed = text(step.after?.body_hash); + return HASH.test(reviewed) && text(flag.fingerprint) === `source:${reviewed}` ? flag : null; + } + // What became of sharing a mark made while reviewing a recorded change, by the mark it is about. + const shareOf = (step) => state.review.shares?.get(markOf(step)) || null; + const SHARE_WORDS = { sending: "Sharing with agents", kept: "Kept in this browser", changed: "Not shared: it changed since", failed: "Not shared" }; + /** + * A mark on a recorded change reaches agents through the same flag as a + * mark on uncommitted work, but only while the entity is still what the + * change left: a flag on a version that is gone would tell an agent about + * code it can no longer meet. Kin's own reading of the entity now decides. + */ + async function shareRecordedMark(step, kind, note) { + const review = state.review; + const key = markOf(step); + const shares = review.shares || (review.shares = new Map()); + const ticket = {}; + const name = shownName(step.name); + const reviewed = text(step.after?.body_hash); + const said = (status, words = "") => { if (shares.get(key)?.ticket === ticket || status === "sending") { shares.set(key, { ticket, state: status, words }); paint("review-body"); } }; + if (kind !== "flagged") { + shares.delete(key); + const open = flagFor(step); + if (open) send("/api/workspace/flags/resolve", { entity: step.entity_id, operation_id: crypto.randomUUID() }).then(() => pulse(["flags"], 0)).catch(() => { /* The open flag stays; the next read shows it. */ }); + return; + } + if (step.change === "removed") { shares.set(key, { ticket, state: "kept", words: `${name} was removed in this change, so no agent can meet it. The flag stays in this browser.` }); paint("review-body"); return; } + if (!HASH.test(reviewed)) { shares.set(key, { ticket, state: "kept", words: `Kin has no exact reading of ${name} as this change left it, so the flag stays in this browser.` }); paint("review-body"); return; } + said("sending"); + try { + const current = await fetchJSON(`/api/workspace/entity?id=${encodeURIComponent(step.entity_id)}§ions=source`); + const now = text(current?.source?.body_hash); + if (!HASH.test(now)) return said("kept", `Kin could not read ${name} exactly as it is now, so it cannot tell whether an agent would meet the version you flagged. The flag stays in this browser.`); + if (now.toLowerCase() !== reviewed.toLowerCase()) return said("changed", `${name} changed after this change, so the flag was not shared: an agent would be told about a version that is gone. Flag it where it changed next.`); + await send("/api/workspace/flags", { entity: step.entity_id, note: note.trim().slice(0, 400) || undefined, fingerprint: `source:${reviewed}`, operation_id: crypto.randomUUID() }); + said("shared"); + pulse(["flags"], 0); + } catch (error) { + said("failed", error.remedy || error.message || "The flag was not kept in the repository."); + } + } + /** Whether a flagged entity's flag reached agents, in a few words; the full reason when it did not. */ + function shareWords(step, full = false) { + const shared = isWorkingReview() ? null : shareOf(step); + if (shared && shared.state !== "shared") return full ? shared.words || SHARE_WORDS[shared.state] : SHARE_WORDS[shared.state]; + const flag = flagFor(step); + if (!flag && !shared && !isWorkingReview() && markState(step) === "flagged" && can("review_flags") && read("flags").data) { + return full ? "This flag is kept in this browser. The repository holds no flag on this version, so no agent has been told." : SHARE_WORDS.kept; + } + return seenWords(flag); + } + function shareMark(step, kind, note) { + if (!can("review_flags") || !UUID.test(text(step.entity_id)) || !globalThis.crypto?.randomUUID) return; + if (!isWorkingReview()) { shareRecordedMark(step, kind, note); return; } + const bodyHash = text(step.after?.body_hash); + if (kind === "flagged" && !/^[0-9a-f]{64}$/i.test(bodyHash)) { state.review.flagError = "Kin needs an exact source reading before it can save this flag for agents."; paint("review-body"); return; } + const fingerprint = `source:${bodyHash}`; + state.review.flagError = null; + const request = kind === "flagged" ? send("/api/workspace/flags", { entity: step.entity_id, note: note.trim().slice(0, 400) || undefined, fingerprint, operation_id: crypto.randomUUID() }) + : flagFor(step) ? send("/api/workspace/flags/resolve", { entity: step.entity_id, operation_id: crypto.randomUUID() }) : null; + request?.then(() => { state.review.flagError = null; pulse(["flags"], 0); }).catch((error) => { state.review.flagError = error.remedy || error.message || "The flag was not kept in the repository."; paint("review-body"); }); + } + /** Whether an agent has seen a flag yet, in a few words. */ + function seenWords(flag) { + if (!flag) return ""; + const seen = array(flag.seen_by).filter((who) => typeof who?.agent === "string"); + if (flag.source_unavailable) return "Kin could not check whether this flag is still current"; + if (flag.changed_since) return "Changed since you flagged it"; + return seen.length ? `Seen by ${seen[0].agent} ${ago(seen[0].at)}` : "No agent has seen it yet"; + } /** What was looked at in uncommitted work stays looked at in the change it was committed as. */ - function carryMarks(changeId) { + function carryMarks(changeId, selected = null) { if (!/^[\w-]{1,128}$/.test(text(changeId))) return; - try { localStorage.setItem(`kin.workspace.review:${apiBase}:${changeId}`, marksBody(state.review.marks)); } catch (_) { /* Nothing to carry to. */ } + const marks = state.review.marks; + const filtered = (chosen) => { + const includes = (key) => selected.some((id) => key.startsWith(`${id}:`)) === chosen; + return { fine: new Set([...marks.fine].filter(includes)), flagged: new Set([...marks.flagged].filter(includes)), notes: Object.fromEntries(Object.entries(marks.notes).filter(([key]) => includes(key))) }; + }; + try { + localStorage.setItem(`kin.workspace.review:${apiBase}:${changeId}`, marksBody(selected ? filtered(true) : marks)); + if (selected) localStorage.setItem(`kin.workspace.review:${apiBase}:w:${changeId}`, marksBody(filtered(false))); + } catch (_) { /* The current page still holds its marks. */ } } - const markState = (step) => state.review.marks.flagged.has(markOf(step)) ? "flagged" : state.review.marks.fine.has(markOf(step)) ? "fine" : ""; + /** + * The repository's open flag on exactly the version this step shows. A + * flag is kept in the repository, so it reads the same in any browser; a + * mark of "looks right" has no record there and stays in this browser. + */ + function keptFlag(step) { + const reviewed = text(step?.after?.body_hash); + if (!HASH.test(reviewed)) return null; + return array(read("flags").data?.flags).find((one) => one && one.entity_id === step.entity_id && text(one.fingerprint).toLowerCase() === `source:${reviewed.toLowerCase()}`) || null; + } + // The mark made in this browser first; otherwise the flag the repository keeps on this version. + const markState = (step) => state.review.marks.flagged.has(markOf(step)) ? "flagged" : state.review.marks.fine.has(markOf(step)) ? "fine" : keptFlag(step) ? "flagged" : ""; /** The guide the open review reads: the page's own working read for uncommitted work, otherwise the change's. Its marks follow it. */ function reviewGuide() { const review = state.review; + if (state.route === "review" && state.params.target && reviewTargetKey(state.params.target) !== reviewTargetKey(review.target)) return null; if (review.target?.kind === "working") review.guide = read("working").data; const guide = review.guide; const key = guide ? marksKey(guide) : null; @@ -1721,31 +2971,139 @@ return guide; } + // A guide Kin could not read yet, because it took too long or the graph was being written as it read, is asked for + // again by itself: soon at first, then less often. Each attempt waits longer than the one before, from a minute + // and a half up to five minutes, since the machine is slowest when agents are busy and that is when their work + // waits for review. Kin keeps reading a committed change while the page waits, so an attempt asked again joins + // the read already under way rather than starting it over. + const REVIEW_ATTEMPTS = 8; + const reviewRetryWait = (attempt) => Math.min(15_000, 2000 * 2 ** Math.max(0, attempt - 1)); + const reviewPatience = (attempt) => Math.min(300_000, 90_000 + 60_000 * Math.max(0, attempt)); + const canReadAgain = (detail) => isSlow(detail) || isUnsettled(detail); + /** + * Whether an answer says Kin is still reading the guide rather than giving + * it: a guide has a summary, and a server that is still working says so + * with its state. Such an answer is not a failed attempt. + */ + const stillReading = (body) => Boolean(body) && !body.summary && (["reading", "pending", "working", "running"].includes(text(body.state)) || body.pending === true); + /** How long to wait before asking about a read Kin says is still running, in milliseconds. */ + const askAgainIn = (body) => { const seconds = Number(body?.retry_after_secs ?? body?.retry_after); return Number.isFinite(seconds) && seconds > 0 ? Math.min(30, seconds) * 1000 : 3000; }; + async function loadReview(target) { const review = state.review; const same = reviewTargetKey(review.target) === reviewTargetKey(target); review.target = target; - if (!same) { review.guide = null; review.error = null; review.marksKey = null; review.decision = { busy: false, error: null, done: null, comment: "" }; } + if (!same) { review.request++; review.busy = false; review.guide = null; review.error = null; review.lastError = null; review.attempt = 0; review.since = null; review.working = false; review.marksKey = null; review.shares = new Map(); review.decision = { busy: false, error: null, done: null, comment: "" }; } + clearTimeout(review.retry); review.retryAt = null; // Uncommitted work is the page's own read; the review follows it as it changes. - if (target.kind === "working") { reviewGuide(); return; } + if (target.kind === "working") { reviewGuide(); paint("review-head"); paint("review-body"); return; } if (same && review.guide) return; + // A change Home or another review in this tab already read opens as it was read. + const held = target.kind === "change" ? heldChangeGuide(apiBase, target.id) : null; + if (held) { review.guide = held; review.error = null; review.lastError = null; reviewGuide(); paint("review-body"); paint("review-head"); return; } const mine = ++review.request; review.busy = true; - paint("review-body"); + // A new attempt shows itself at once; what the last one met stays behind Show details until this one answers. + review.error = null; + review.startedAt = Date.now(); + // How long the reader has waited, across every attempt. + if (!review.since) review.since = review.startedAt; + paint("review-body"); paint("review-head"); + tickReview(); try { - const guide = await fetchJSON(target.kind === "range" ? `/api/guide?base=${encodeURIComponent(target.base)}&change=${encodeURIComponent(target.head)}` : `/api/guide?change=${encodeURIComponent(target.id)}`); + const path = target.kind === "range" ? `/api/guide?base=${encodeURIComponent(target.base)}&change=${encodeURIComponent(target.head)}` : `/api/guide?change=${encodeURIComponent(target.id)}`; + const guide = await fetchJSON(path, { patience: reviewPatience(review.attempt || 0) }); if (mine !== review.request) return; + if (stillReading(guide)) { + // Kin says it is still reading: this attempt goes on, and nothing failed. + review.working = true; + review.retryAt = Date.now() + askAgainIn(guide); + review.retry = setTimeout(() => { if (mine === review.request && state.route === "review" && !review.guide) loadReview(target); }, askAgainIn(guide)); + return; + } review.guide = guide; - review.error = null; + review.error = null; review.lastError = null; review.attempt = 0; review.since = null; review.working = false; + if (target.kind === "change") keepChangeGuide(apiBase, target.id, guide); reviewGuide(); } catch (error) { if (mine !== review.request) return; - review.error = error.message || "This change could not be read."; + const said = error.message || "This change could not be read."; + review.error = said; review.lastError = said; + review.attempt = (review.attempt || 0) + 1; + if (canReadAgain(said) && review.attempt < REVIEW_ATTEMPTS) { + const wait = reviewRetryWait(review.attempt); + review.retryAt = Date.now() + wait; + review.retry = setTimeout(() => { if (mine === review.request && state.route === "review" && review.error) loadReview(target); }, wait); + // What the repository is busy with is read again, so the wait names it as it stands. + pulse(["session"], 0); + } else review.since = null; } finally { - if (mine === review.request) { review.busy = false; paint("review-body"); paint("review-head"); } + if (mine === review.request) { review.busy = false; paint("review-body"); paint("review-head"); tickReview(); } } } + /** Keep the seconds on a waiting review current, without drawing the rest of it again. */ + function tickReview() { + const review = state.review; + clearTimeout(review.tick); + if (review.guide || !(review.busy || review.retryAt)) return; + review.tick = setTimeout(() => { + if (state.route !== "review" || review.guide) return; + const clock = byId("review-wait-clock"); + const said = reviewClock(); + // The first time there is something to say, the waiting state is drawn with it; after that only the seconds change. + if (clock) clock.textContent = said; + else if (said) paint("review-body"); + tickReview(); + }, 1000); + } + + /** A wait in a few words: "45 s", "2 min 10 s". */ + function waitWords(ms) { + const seconds = Math.max(0, Math.floor(ms / 1000)); + return seconds < 120 ? `${seconds} s` : `${Math.floor(seconds / 60)} min${seconds % 60 ? ` ${seconds % 60} s` : ""}`; + } + /** How long the reader has waited for this read across every attempt, or when the next attempt starts. */ + function reviewClock() { + const review = state.review; + const seconds = (ms) => Math.max(1, Math.round(ms / 1000)); + const waited = Date.now() - (review.since || review.startedAt || Date.now()); + // Kin said it is still reading: the attempt goes on, and the clock with it. + if (review.busy || review.working) { + const so = waited >= 1000 ? `, ${waitWords(waited)} so far` : ""; + if (review.lastError) return `Reading it again${review.attempt ? `, attempt ${review.attempt + 1}` : ""}${so}.`; + if (review.working) return `Kin is still reading ${review.target?.kind === "range" ? "them" : "it"}${so}.`; + return waited >= 3000 ? `${waitWords(waited)} so far.` : ""; + } + if (review.retryAt) return `Kin reads ${review.target?.kind === "range" ? "them" : "it"} again in ${seconds(review.retryAt - Date.now())} s, attempt ${review.attempt + 1}.`; + return ""; + } + + /** A guide Kin is still reading, or reading again: what is happening in plain words, how far it has got, and Kin's own words behind Show details. */ + function reviewWaiting() { + const review = state.review; + const range = review.target?.kind === "range"; + const what = range ? "these branches" : "this change"; + const said = text(review.lastError).trim(); + const name = text(session()?.repository?.name) || "This repository"; + const lead = !said ? (range ? "Comparing these branches." : "Reading this change.") + : isUnsettled(said) ? `Kin was taking in new work in ${name} while it read ${what}, so it is reading ${range ? "them" : "it"} again.` + : range ? "Kin is still comparing these branches. This fills in when it is ready." : "Kin is still reading this change. This fills in when it is ready."; + // A repository that is reading its older history says so, since Home does, and says this read does not wait for it. + const history = session()?.history; + const step = isCount(history?.step) && isCount(history?.steps) && history.steps > 0 ? `, step ${count(history.step)} of ${count(history.steps)}` : ""; + const reading = history?.deriving ? h("p", { class: "note" }, `${name} is reading its older history in the background${step}. This review reads ${range ? "the changes as they were" : "the change as it was"} committed, so it does not wait for that.`) : null; + const clock = reviewClock(); + const showClock = Boolean(said) || Boolean(clock); + return [h("div", { class: "section-tight" }, + showClock || reading ? h("p", { class: "note aside-reading", role: "status" }, lead) : null, + reading, + showClock ? h("p", { class: "note", id: "review-wait-clock" }, clock) : null, + said ? h("div", { class: "page-actions" }, review.busy ? null : button("Try now", () => loadReview(review.target), { class: "button button-small" }), + h("details", { class: "more" }, h("summary", {}, "Show details"), h("p", { class: "detail-text" }, said))) : null), + skeletonLines(said ? 4 : 6)]; + } + const MARK_WORDS = { fine: "Looks right", flagged: "Flagged" }; function reviewProgress(steps) { const looked = steps.filter((step) => markState(step)).length; @@ -1761,7 +3119,7 @@ changed ? h("p", {}, `${plural(changed, "entity", "entities")} changed. Kin can still show it whole.`) : null, changed ? h("div", { class: "page-actions" }, link("Read it on one page", target.kind === "working" ? reviewURL("#/w") : target.kind === "range" ? reviewURL(`#/r/${encodeURIComponent(target.base)}..${encodeURIComponent(target.head)}`) : changeURL(target.id), true)) : null); } - const GROUPS = [["Relied on, and not updated", "What relies on these needed a change and did not get one"], ["Relied on by code that did not change", "Check that what relies on these still holds"], ["Nothing to follow up", "Nothing outside the change is left behind"]]; + const GROUPS = [["Relied on, and not updated", "What relies on these needed a change and did not get one"], ["Relied on by code that did not change", "Check that what relies on these still holds"], ["No known follow-up", "No known connections need follow-up"]]; const row = (step) => { const at = steps.indexOf(step); const mark = markState(step); @@ -1769,7 +3127,7 @@ h("span", { class: "cell cell-num" }, String(at + 1)), h("span", { class: "cell-main" }, ent({ name: step.name, kind: step.kind }, { linked: false })), h("span", { class: "cell" }, placeOf(step)), - h("span", { class: "cell" }, rowReason(step)), + h("span", { class: "cell" }, rowReason(step, guide)), h("span", { class: `cell state${mark === "flagged" ? " state-warn" : mark === "fine" ? " state-good" : ""}` }, mark ? MARK_WORDS[mark] : ""))); }; const SHOWN = 200; @@ -1793,7 +3151,9 @@ if (!target) return null; const guide = reviewGuide(); const working = target.kind === "working"; - const title = working ? "Uncommitted" : target.kind === "range" ? `${target.head} against ${target.base}` : text(guide?.change?.title) || "Change"; + // A range named from where you stand reads as the branch you are on, not as Git's word for it. + const rangeSide = (name) => name === "HEAD" ? "the current branch" : name; + const title = working ? "Uncommitted" : target.kind === "range" ? `${rangeSide(target.head).replace(/^the/, "The")} against ${rangeSide(target.base)}` : text(guide?.change?.title) || "Change"; const crumbs = h("nav", { class: "crumbs", "aria-label": "Breadcrumb" }, h("a", { href: working ? UNCOMMITTED : target.kind === "range" ? "#/branches" : "#/history" }, working ? "Uncommitted" : target.kind === "range" ? "Branches" : "History"), !working && (params.step || params.done) ? [h("span", { "aria-hidden": "true" }, "/"), h("a", { href: reviewBase(target) }, title)] : null); @@ -1808,25 +3168,30 @@ h("div", { class: "page-actions" }, steps.length ? link(looked === 0 ? "Start review" : next < 0 ? "Finish review" : "Continue review", next < 0 ? `${reviewBase(target)}/done` : `${reviewBase(target)}/${next + 1}`, true) : null)), sentence ? h("p", { class: "lede lede-strong" }, sentence) : guide ? h("p", { class: "lede" }, "No entity changed in this change.") : null, - author ? h("p", { class: "note" }, author) : null]; + author ? h("p", { class: "note" }, author) : null, + rereadLine(guide)]; } function reviewStep(guide, steps, at, onCleanup) { const target = state.params.target; const step = steps[at]; - if (!step) return h("div", { class: "empty" }, h("h3", {}, "That entity is not in this review"), h("div", { class: "page-actions" }, link("Back to the list", reviewList(target), true))); + if (!step) return h("div", { class: "empty" }, h("h3", {}, "That entity is not in this review"), + steps.length ? h("p", {}, `It holds ${plural(steps.length, "entity", "entities")} now.`) : null, + h("div", { class: "page-actions" }, link("Back to the list", reviewList(target), true), steps.length ? link("Finish review", `${reviewBase(target)}/done`) : null)); const base = reviewBase(target); const previous = at > 0 ? `${base}/${at}` : null; const next = at + 1 < steps.length ? `${base}/${at + 2}` : `${base}/done`; const mark = markState(step); const note = h("input", { id: "review-note", type: "text", maxlength: "400", placeholder: "Why you are flagging it (optional)", "aria-label": "A note for the flag", value: noteOf(step) }); - const decide = (kind) => { setMark(step, kind, note.value); location.hash = next; }; + const decide = (kind) => { setMark(step, kind, note.value); shareMark(step, kind, note.value); location.hash = next; }; return [ h("div", { class: "review-bar" }, h("span", { class: "review-position" }, `${count(at + 1)} of ${count(steps.length)}`), h("span", { class: "review-meter", "aria-hidden": "true" }, steps.length <= 60 ? steps.map((other, index) => h("span", { class: `review-tick${index === at ? " is-here" : ""}${markState(other) ? ` is-${markState(other)}` : ""}` })) : null), mark ? h("span", { class: `state${mark === "flagged" ? " state-warn" : " state-good"}` }, MARK_WORDS[mark]) : null), stepDetail(step, guide, onCleanup), + isWorkingReview() || markState(step) === "flagged" ? (shareWords(step, true) ? h("p", { class: "note", id: "review-shared" }, shareWords(step, true)) : null) : null, + restoreControls(step), h("div", { class: "review-decide" }, h("div", { class: "field review-note" }, note), h("span", { class: "page-actions" }, @@ -1846,6 +3211,11 @@ const about = review.target.kind === "range" ? { change: review.target.head, base: review.target.base, range: true } : { change: review.target.id, title: text(review.guide.change?.title) || undefined }; await send("/api/decide", { ...about, state: value, comment: decision.comment.trim() || undefined }); decision.done = value; + // A view that the change needs work reaches its agent through the flags on its entities, + // so any flag this page has not shared yet, such as one marked before a reload, is shared now. + if (value === "needs_work" && can("review_flags") && globalThis.crypto?.randomUUID) { + for (const step of reviewSteps(review.guide)) if (markState(step) === "flagged" && !shareOf(step) && !flagFor(step)) shareRecordedMark(step, "flagged", noteOf(step)); + } announce("Saved."); refresh(["standing"]); } catch (error) { decision.error = error.message || "Your view was not saved."; } @@ -1856,7 +3226,16 @@ /** What was flagged, as plain text to hand to whoever, or whatever, made the change. */ function flagsText(guide, steps) { const flagged = steps.filter((step) => markState(step) === "flagged"); - return flagged.map((step) => `${step.name} (${placeOf(step)}): ${noteOf(step) ? `${noteOf(step)} ` : ""}${changeSentence(step)} ${stepReason(step)}.`).join("\n"); + const line = (step) => `${step.name} (${placeOf(step)}): ${noteOf(step) ? `${noteOf(step)} ` : ""}${changeSentence(step)} ${stepReason(step, guide)}.`; + // Flags go back to whoever made the change, so they are grouped by the agent Kin saw write each one. + const groups = new Map(); + for (const step of flagged) { + const who = [...new Set(authorsOf(step).map((held) => held.name))].join(" and ") || (knowsAuthors(guide) ? "No agent reported this edit" : ""); + if (!groups.has(who)) groups.set(who, []); + groups.get(who).push(line(step)); + } + if (groups.size === 1 && groups.has("")) return groups.get("").join("\n"); + return [...groups].map(([who, lines]) => `${who || "Unknown"}:\n${lines.join("\n")}`).join("\n\n"); } function reviewDone(guide, steps) { @@ -1867,9 +3246,9 @@ const unlooked = total - looked; const working = target.kind === "working"; const head = h("header", { class: "page-head" }, - h("div", { class: "page-title-row" }, h("h1", {}, !total ? "Nothing to look at here" : unlooked ? `${count(looked)} of ${count(total)} looked at` : flagged ? (fine ? `${spoken(fine)} ${fine === 1 ? "looks" : "look"} right, ${spoken(flagged).toLowerCase()} flagged` : total === 1 ? "Flagged" : flagged === total ? `${total === 2 ? "Both" : `All ${spoken(total).toLowerCase()}`} flagged` : `${spoken(flagged)} flagged`) + h("div", { class: "page-title-row" }, h("h1", {}, !total ? "Nothing to look at here" : unlooked ? `${count(looked)} of ${count(total)} marked` : flagged ? (fine ? `${spoken(fine)} ${fine === 1 ? "looks" : "look"} right, ${spoken(flagged).toLowerCase()} flagged` : total === 1 ? "Flagged" : flagged === total ? `${total === 2 ? "Both" : `All ${spoken(total).toLowerCase()}`} flagged` : `${spoken(flagged)} flagged`) : total === 1 ? "It looks right" : total === 2 ? "Both look right" : `All ${spoken(total).toLowerCase()} look right`)), - unlooked ? h("p", { class: "lede" }, `${plural(unlooked, "entity", "entities")} left. `, h("a", { class: "link", href: `${reviewBase(target)}/${steps.findIndex((step) => !markState(step)) + 1}` }, "Go on")) : null); + unlooked ? h("p", { class: "lede" }, `${plural(unlooked, "entity", "entities")} left unmarked.`) : null); const status = h("span", { class: "note", role: "status" }); const flaggedList = flaggedSteps.length ? h("section", { class: "section", "aria-label": "Flagged" }, h("div", { class: "section-head" }, h("h2", {}, "Flagged", h("span", { class: "count" }, count(flaggedSteps.length))), @@ -1879,13 +3258,28 @@ }, { class: "button button-small" }))), h("ul", { class: "table" }, flaggedSteps.map((step) => h("li", {}, h("a", { class: "table-row review-grid", href: `${reviewBase(target)}/${steps.indexOf(step) + 1}` }, h("span", { class: "cell cell-num" }, String(steps.indexOf(step) + 1)), h("span", { class: "cell-main" }, ent({ name: step.name, kind: step.kind }, { linked: false })), - h("span", { class: "cell" }, placeOf(step)), h("span", { class: "cell" }, noteOf(step) || rowReason(step)), h("span", { class: "cell state state-warn" }, "Flagged")))))) : null; + h("span", { class: "cell" }, placeOf(step)), h("span", { class: "cell" }, noteOf(step) || rowReason(step, guide)), h("span", { class: "cell state state-warn", title: shareWords(step, true) || undefined }, shareWords(step) || "Flagged"))))), + // Why a flag on a recorded change stayed here, said in full, since the row holds only a few words. + working ? null : flaggedSteps.filter((step) => shareOf(step) && !["shared", "sending"].includes(shareOf(step).state)).map((step) => h("p", { class: "note quiet-line" }, shareWords(step, true)))) : null; if (working && !total) { return [state.commit.result ? commitBox() : null, h("div", { class: "empty" }, h("h3", {}, "Nothing is uncommitted"), h("div", { class: "page-actions" }, link("Open History", "#/history")))]; } if (working) { + // A commit made here is said here, above what it left, until it is dismissed. + const receipt = state.commit.result ? [commitBox(), read("working").stale ? h("p", { class: "note aside-reading" }, "Reading what is still uncommitted") : null] : null; + if (receipt && !unlooked) return [receipt, head, flaggedList]; + if (unlooked && !state.commit.open) { + return [receipt, head, flaggedList, h("div", { class: "page-actions section" }, + link("Continue review", `${reviewBase(target)}/${steps.findIndex((step) => !markState(step)) + 1}`, true), + canCommit() ? button("Commit anyway", () => { prepareCommitDraft(steps); state.commit.open = true; paint("review-body"); byId("commit-message")?.focus(); }, { class: "button button-quiet" }) : null)]; + } // Committing is how uncommitted work is accepted, so the form is here at the end of reading it. - if (canCommit()) state.commit.open = true; + if (canCommit()) { + // The message starts as what the commit holds, for the reader to say why in their own words, + // and Kin's draft follows what the commit holds until the reader writes their own. + prepareCommitDraft(steps); + state.commit.open = true; + } return [head, flaggedList, h("section", { class: "section" }, canCommit() ? commitBox() : h("p", { class: "gap" }, "This version of Kin can't commit from this page."))]; } @@ -1897,33 +3291,40 @@ const choose = (value, label, look = "") => button(decision.busy ? "Saving…" : label, () => recordDecision(value), { class: `button ${look}`, disabled: decision.busy || !session()?.token }); return [head, flaggedList, h("section", { class: "section review-finish", "aria-label": "Your view of this change" }, - h("div", { class: "section-head" }, h("h2", {}, "Your view of this change"), h("span", { class: "aside" }, "How it read to you. It stops and starts nothing.")), + h("div", { class: "section-head" }, h("h2", {}, "Your view of this change"), h("span", { class: "aside" }, "Your own record of how it reads. Saving it blocks nothing and changes no code.")), recorded ? h("p", { class: "note" }, `Your view now: ${(DECISION[recorded] || DECISION[recorded.replace("_", "")] || ["Saved"])[0].toLowerCase()}. A new one replaces it.`) : null, + can("review_flags") ? h("p", { class: "note", id: "review-reach" }, flaggedSteps.length + ? "An agent is told about each flag above that was shared, the next time it touches that entity." + : "No entity is flagged, so no agent is told what to change. Flag an entity to tell the agent that made it.") : null, decision.error ? h("div", { class: "message message-bad", role: "alert" }, h("div", {}, h("strong", {}, "Your view was not saved"), h("p", {}, decision.error))) : null, comment, - h("div", { class: "page-actions" }, choose("needs_work", "Flagged"), choose("approved", "Looks right", "button-primary")))]; + h("div", { class: "page-actions" }, choose("needs_work", "Flag it"), choose("approved", "Looks right", "button-primary")))]; } - function reviewBody(onCleanup) { + function reviewBody(onCleanup) { return [state.restore.unknown ? restoreRecovery() : isWorkingReview() ? restoreReceipt() : null, reviewContents(onCleanup)]; } + function reviewContents(onCleanup) { const params = state.params; if (!params.target) return null; const review = state.review; const guide = reviewGuide(); if (!guide) { - if (review.error) return failed(isSlow(review.error) ? "Kin is still reading this change. Try again in a moment." : review.target?.kind === "range" ? "Kin couldn't compare these branches." : "Kin couldn't load this change.", () => { review.guide = null; loadReview(params.target); }, review.error); - if (params.target.kind === "working" && read("working").error) return readFailure("working"); + if (params.target.kind === "working") return read("working").error ? readFailure("working") : skeletonLines(6); + // Still reading, or reading again by itself after Kin could not settle it: the page says which and how far it has got. + if (review.busy || review.retryAt) return reviewWaiting(); + if (review.error) return failed(review.target?.kind === "range" ? "Kin couldn't compare these branches." : "Kin couldn't load this change.", () => { review.guide = null; review.attempt = 0; loadReview(params.target); }, review.error); return skeletonLines(6); } const steps = reviewSteps(guide); - if (params.done) return reviewDone(guide, steps); - if (params.step) return reviewStep(guide, steps, params.step - 1, onCleanup); - return reviewOverview(guide, steps, params.target, onCleanup); + const flagError = review.flagError ? h("div", { class: "message message-warn section-tight", role: "alert" }, h("div", {}, h("strong", {}, "Kin could not save the flag for agents"), h("p", {}, review.flagError), h("p", {}, "Your review mark is still kept in this browser. Read the entity again and flag it to try saving it.")), button("Dismiss", () => { review.flagError = null; paint("review-body"); }, { class: "button button-small" })) : null; + if (params.done) return [flagError, reviewDone(guide, steps)]; + if (params.step) return [flagError, reviewStep(guide, steps, params.step - 1, onCleanup)]; + return [flagError, reviewOverview(guide, steps, params.target, onCleanup)]; } function review() { return [ section("review-head", ["working", "changes", "standing", "branches"], reviewHead, { class: "page-head" }), - section("review-body", ["working", "changes", "standing"], reviewBody)]; + section("review-body", ["working", "changes", "standing", "flags", "session"], reviewBody)]; } // ── Changes ────────────────────────────────────────────────────────────── @@ -1993,11 +3394,12 @@ const check = isCount(summary.consumers_may_need_review) ? summary.consumers_may_need_review : 0; const tests = isCount(summary.recorded_tests) ? summary.recorded_tests : 0; const tone = behind ? "bad" : check ? "warn" : "good"; - const words = !outside ? "Nothing outside it relies on what it changed" + const limited = limitedGuide({ summary }); + const words = !outside ? "Kin found no connections outside this change" : behind ? `${plural(behind, "entity", "entities")} that ${behind === 1 ? "relies" : "rely"} on it ${behind === 1 ? "was" : "were"} not updated` : check ? `${plural(check, "entity", "entities")} ${check === 1 ? "relies" : "rely"} on it and did not change` - : `${plural(outside, "entity", "entities")} ${outside === 1 ? "relies" : "rely"} on it and ${outside === 1 ? "needs" : "need"} no update`; - return h("span", { class: `change-consequence change-consequence-${tone}` }, h("span", { class: "impact-dot", "aria-hidden": "true" }), `${words}. ${tests ? `${spoken(tests)} ${tests === 1 ? "test reaches" : "tests reach"} it.` : "No test reaches it."}`); + : `${limited ? "At least " : ""}${plural(outside, "entity", "entities")} ${outside === 1 ? "relies" : "rely"} on it and ${outside === 1 ? "needs" : "need"} no update`; + return h("span", { class: `change-consequence change-consequence-${tone}` }, h("span", { class: "impact-dot", "aria-hidden": "true" }), `${words}. ${tests ? `${graphCount(tests, limited).replace(/^at/, "At")} ${tests === 1 ? "test reaches" : "tests reach"} it${withinSteps(summary.tests_depth)}.` : noTestWords(isCount(summary.tests_depth) && summary.tests_depth > 0 ? summary.tests_depth : null)}`); } function changeRow(change) { @@ -2005,12 +3407,17 @@ const when = ago(change.timestamp); const kin = isKinUpdate(change); const n = changedCount(change); - const entities = kin ? null : isCount(n) ? (n ? `${plural(n, "entity", "entities")} changed` : "No entity changed") : null; + const listed = array(read("changes").data?.changes); + const partial = text(session()?.history?.state) === "partial"; + // The oldest change of a store that has not read its history yet holds the whole repository as Kin first read it. + const start = partial && !read("changes").data?.truncated && listed.length && listed[listed.length - 1]?.id === change.id; + const entities = kin || start ? null : isCount(n) ? (n ? `${plural(n, "entity", "entities")} changed` : "No entity changed") : null; const quiet = kin || n === 0; return h("li", {}, h("a", { class: `change-row${quiet ? " change-row-quiet" : ""}`, href: reviewHref(change.id) }, h("div", {}, h("span", { class: "change-title" }, change.title || "Untitled change"), consequenceLine(change), - h("span", { class: "change-meta" }, agentsApi() ? agentsApi().author(agentsCtx, change, author) : author ? h("span", {}, author) : null, when ? h("time", { datetime: change.timestamp || undefined }, when) : null, entities ? h("span", {}, entities) : null)), + h("span", { class: "change-meta" }, agentsApi() ? agentsApi().author(agentsCtx, change, author) : author ? h("span", {}, author) : null, when ? h("time", { datetime: change.timestamp || undefined }, when) : null, entities ? h("span", {}, entities) : null), + start ? h("span", { class: "change-start" }, "Kin's record starts here, with the repository as it was") : null), h("span", { class: "change-side" }, kin ? h("span", { class: "state" }, "Kin update") : decisionPill(change)))); } @@ -2049,8 +3456,8 @@ if (!history || text(history.state) !== "partial") return null; const counted = isCount(history.admitted) && isCount(history.total) && history.total > 0; return h("p", { class: "work-caveat" }, counted - ? `Kin is still reading this repository's history: ${count(history.admitted)} of ${plural(history.total, "commit")} so far. Changes fill in as it goes.` - : "Kin is still reading this repository's history. Changes fill in as it goes."); + ? `Kin has read ${count(history.admitted)} of ${plural(history.total, "commit")} from this repository's history. This list includes the history available so far.` + : "This repository's imported history is incomplete. This list includes the history available so far."); } function changesSentence() { @@ -2076,7 +3483,7 @@ const branches = branchRows(); const options = h("datalist", { id: "workspace-branch-options" }, branches.map((branch) => h("option", { value: branch.name }))); const base = h("input", { id: "workspace-range-base", name: "base", list: "workspace-branch-options", placeholder: "main", required: true, value: state.rangeBase ?? defaultBranch()?.name ?? "", autocomplete: "off", spellcheck: "false", on: { input: (event) => { state.rangeBase = event.target.value; } } }); - const head = h("input", { id: "workspace-range-head", name: "head", list: "workspace-branch-options", placeholder: "HEAD", value: state.rangeHead, autocomplete: "off", spellcheck: "false", on: { input: (event) => { state.rangeHead = event.target.value; } } }); + const head = h("input", { id: "workspace-range-head", name: "head", list: "workspace-branch-options", placeholder: "The current branch", value: state.rangeHead, autocomplete: "off", spellcheck: "false", on: { input: (event) => { state.rangeHead = event.target.value; } } }); return h("form", { class: "compare", "aria-label": "Compare branches", on: { submit: (event) => { event.preventDefault(); const from = base.value.trim(); const to = head.value.trim(); if (from) location.hash = rangeHref(from, !to || to === "HEAD" ? (activeBranch()?.name || "HEAD") : to); } } }, h("div", { class: "field" }, h("label", { for: "workspace-range-base" }, "From"), base), h("span", { class: "compare-arrow", "aria-hidden": "true" }, "→"), @@ -2137,7 +3544,7 @@ const overview = read("overview").data; const counts = section_ok(overview?.counts) ? overview.counts : null; return h("p", { class: "lede" }, counts && isCount(counts.entities) - ? `${plural(counts.entities, "entity", "entities")} and ${plural(counts.relationships || 0, "connection")}. Ask by meaning or by name, then open an entity to see what relies on it.` + ? `${plural(counts.entities, "entity", "entities")} and ${connectionsAtLeast(overview) ? "at least " : ""}${plural(counts.relationships || 0, "connection")}. Ask by meaning or by name, then open an entity to see what relies on it.` : "Ask by meaning or by name, then open an entity to see what relies on it."); })), h("form", { class: "ask", role: "search", on: { submit: (event) => { event.preventDefault(); runAsk(input.value, true); } } }, @@ -2146,7 +3553,7 @@ section("explore-results", ["overview"], exploreResults, { class: "results", role: "region", "aria-label": "Results", "aria-live": "polite" })]; } - function entityRow(row, most = 0, base = "") { + function entityRow(row, most = 0, base = "", atLeast = false) { const where = containerOf(row); const n = reliedOn(row); const signature = row.overloads > 1 ? "" : text(row.signature); @@ -2158,9 +3565,10 @@ return h("li", {}, h("a", { class: "entity-row", href: `${base}${entityHref(row.id)}`, "data-entity": row.id, title: text(row.why) || undefined }, h("div", { class: "entity-main" }, h("div", { class: "entity-line" }, ent(row, { linked: false }), row.overloads > 1 ? h("span", { class: "entity-overloads" }, `${count(row.overloads)} overloads`) : null, - h("span", { class: "entity-container" }, [kindWord(row.kind), where && !row.name.startsWith(`${where}.`) ? `in ${where}` : ""].filter(Boolean).join(" "))), + h("span", { class: "entity-container" }, [kindWord(kindOf(row)), where && !row.name.startsWith(`${where}.`) ? `in ${where}` : "", row.outer ? `of ${row.outer}` : ""].filter(Boolean).join(" ")), + row.alike > 0 ? h("span", { class: "entity-overloads" }, `and ${count(row.alike)} more of this name`) : null), detail ? h("div", { class: detail === signature ? "entity-signature" : "entity-why" }, detail) : null), - h("span", { class: "entity-side" }, counted ? relyWords(n) : how))); + h("span", { class: "entity-side" }, counted ? relyWords(n, atLeast) : how))); } /** What the repository holds at its top level, as rows into each container's own page. */ @@ -2168,9 +3576,16 @@ const overview = read("overview").data; const held = section_ok(overview?.containers) ? array(overview.containers.items).filter((row) => row && UUID.test(row.id) && !row.container) : []; const languages = section_ok(overview?.languages) ? array(overview.languages.items).filter((row) => row && typeof row.language === "string") : []; - if (!held.length && !languages.length) return null; + // What many entities rely on and no recorded test reaches, directly or through what it uses: where a change is most likely to break something unseen. + const untested = section_ok(overview?.untested) ? array(overview.untested.items).filter((row) => row && UUID.test(row.id) && typeof row.name === "string") : []; + if (!held.length && !languages.length && !untested.length) return null; return [ - held.length ? h("div", { class: "result-group" }, + untested.length ? h("div", { class: "result-group" }, + h("div", { class: "result-head" }, h("h2", {}, "Relied on, with no tests found that reach them", isCount(overview.untested.total) ? h("span", { class: "count" }, count(overview.untested.total)) : null)), + h("ul", { class: "list list-card" }, untested.slice(0, 10).map((row) => h("li", {}, h("a", { class: "structure-row", href: entityHref(row.id) }, + h("span", { class: "structure-name" }, ent(row, { linked: false }), h("span", { class: "entity-container" }, kindWord(row.kind))), + h("span", { class: "entity-side state-warn" }, isCount(reliedOn(row)) ? relyWords(reliedOn(row), rankingAtLeast(overview)) : "")))))) : null, + held.length && !untested.length ? h("div", { class: "result-group" }, h("div", { class: "result-head" }, h("h2", {}, "Largest containers")), h("ul", { class: "list list-card" }, held.slice(0, 10).map((row, _, shown) => h("li", {}, h("a", { class: "structure-row", href: entityHref(row.id) }, // Two containers of one name are told apart by what they are written in. @@ -2190,23 +3605,23 @@ if (!rows.length) { if (status === "error") return readFailure("overview"); if (status === "reading") return skeletonLines(6); - return [h("p", { class: "gap" }, "Nothing relies on another entity yet. Ask about the code above."), structureList()]; + return [h("p", { class: "gap" }, "Kin found no connections between entities yet. Ask about the code above."), structureList()]; } const most = Math.max(...rows.map((row) => reliedOn(row) || 0)); return h("div", { class: "explore-grid" }, - h("div", { class: "result-group" }, h("div", { class: "result-head" }, h("h2", {}, "Most relied on"), h("span", { class: "aside" }, "A good place to start")), - h("ul", { class: "list list-card", id: "explore-list" }, rows.slice(0, 12).map((row) => entityRow(row, most)))), + h("div", { class: "result-group" }, h("div", { class: "result-head" }, h("h2", {}, "Most relied on")), + h("ul", { class: "list list-card", id: "explore-list" }, rows.slice(0, 12).map((row) => entityRow(row, most, "", rankingAtLeast(read("overview").data))))), h("div", {}, structureList())); } if (ex.error) return failed(isSlow(ex.error) ? "Kin is still working on that search. Try again in a moment." : "Kin couldn't run that search.", () => runAsk(ex.query, true), ex.error); if (!ex.results) return skeletonLines(5); const groups = ex.results.groups.filter((group) => group.rows.length); if (!groups.length) return h("p", { class: "gap" }, `Nothing matched “${ex.query.trim()}”. Try a name, or describe what the code does.`); - return [ex.results.notice ? h("p", { class: "note" }, ex.results.notice) : null, ...groups.map((group) => h("div", { class: "result-group" }, + return [answerChain(ex.results.answer), ex.results.notice ? h("p", { class: "note" }, ex.results.notice) : null, ...groups.map((group) => h("div", { class: "result-group" }, h("div", { class: "result-head" }, h("h2", {}, group.title), h("span", { class: "aside" }, (() => { // A folded row stands for each of its overloads. const shown = group.rows.reduce((sum, row) => sum + (row.overloads || 1), 0); - return group.total > shown ? `${count(shown)} of ${count(group.total)}` : plural(shown, "entity", "entities"); + return group.total > shown ? `${count(shown)} of ${count(group.total)}` : group.top ? `The closest ${count(shown)}` : plural(shown, "entity", "entities"); })())), h("ul", { class: "list list-card" }, group.rows.map((row) => entityRow(row)))))]; } @@ -2234,17 +3649,41 @@ at.set(key, kept); out.push(kept); } + // Two different entities that read the same, name and place alike, are told apart by where their container sits. + const reads = new Map(); + const label = (row) => `${row.name}\u0000${text(row.kind)}\u0000${containerOf(row)}`; + for (const row of out) reads.set(label(row), (reads.get(label(row)) || 0) + 1); + for (const row of out) if (reads.get(label(row)) > 1 && text(row.container?.within?.name)) row.outer = row.container.within.name; return out; } /** An answer as result groups. The ask read is already ranked, names and meaning together. */ - function askGroups(data) { + function askGroups(data, limit = null) { const list = foldOverloads(array(data?.results).filter((row) => row && UUID.test(row.id) && typeof row.name === "string")); - if (!Array.isArray(data?.results)) return { groups: [], notice: "" }; + if (!Array.isArray(data?.results)) return { groups: [], notice: "", answer: null }; const title = data.mode === "name_only" ? "By name" : data.mode === "meaning_only" ? "By meaning" : "Best matches"; - return { groups: [{ title, rows: list, total: isCount(data.total) ? Math.max(data.total, list.length) : list.length }], - notice: data.mode && data.mode !== "meaning_and_name" ? text(data.notice) : "" }; - } + const chain = array(data.answer?.steps).filter((step) => step && UUID.test(text(step.id)) && typeof step.name === "string"); + // How many matched is known only for names; a ranking by meaning has no end, so a full page is its closest few. + const named = data.mode === "name_only" && isCount(data.names?.total) ? data.names.total : null; + const full = isCount(limit) && array(data.results).length >= limit; + return { groups: [{ title, rows: list, total: named !== null ? Math.max(named, list.length) : list.length, top: named === null && full }], + notice: data.mode && data.mode !== "meaning_and_name" ? text(data.notice) : "", + // One of the two searches did not answer, so finding nothing proves nothing. + partial: Boolean(data.mode) && data.mode !== "meaning_and_name", + // An answer is a chain Kin traced through the graph, never a guess: at least two entities joined by how one reaches the next. + answer: chain.length >= 2 ? { sentence: text(data.answer.sentence), chain } : null }; + } + /** A question's answer as Kin traced it: the entities the work passes through, in order, each joined to the next by how it gets there. */ + function answerChain(answer, base = "") { + if (!answer) return null; + return h("div", { class: "answer", role: "group", "aria-label": "How it happens" }, + answer.sentence ? h("p", { class: "answer-sentence" }, answer.sentence) : null, + h("ol", { class: "answer-chain" }, answer.chain.map((step, at) => h("li", { class: "answer-step" }, + h("a", { class: "impact-node answer-node", href: `${base}${entityHref(step.id)}`, title: step.name }, + h("span", { class: "impact-text" }, h("span", { class: "impact-name" }, shownName(step.name)), h("span", { class: "impact-place" }, kindWord(step.kind)))), + at < answer.chain.length - 1 ? h("span", { class: "answer-link" }, RELATION_NEXT[text(step.relation_to_next).toLowerCase()] || "leads to") : null)))); + } + const RELATION_NEXT = { calls: "calls", references: "refers to", instantiates: "constructs", imports: "imports", uses_type: "uses", extends: "extends", implements: "implements", overrides: "overrides", contains: "holds", returns: "returns" }; async function runAsk(value, immediate) { const ex = state.explore; @@ -2258,7 +3697,7 @@ try { const data = await fetchJSON(`/api/workspace/ask?q=${encodeURIComponent(query)}&limit=20`); if (mine !== ex.request) return; - ex.results = askGroups(data); + ex.results = askGroups(data, 20); ex.error = null; ex.active = -1; const total = ex.results.groups.reduce((sum, group) => sum + group.rows.length, 0); @@ -2346,11 +3785,12 @@ bound: text(reliers.bound), notice: text(reliers.notice) } : { ok: false, items: [], total: 0, reason: reasonOf(reliers, "What relies on it could not be read.") }, uses: section_ok(uses) - ? { ok: true, items: dedupe(listOf(uses)).map((row) => ({ ...row, relation: text(row.relationship), through: array(row.through).filter((name) => typeof name === "string") })), total: isCount(uses.total) ? uses.total : listOf(uses).length, + ? { ok: true, items: dedupe(listOf(uses)).map((row) => ({ ...row, relation: text(row.relationship), through: array(row.through).filter((name) => typeof name === "string") })), total: isCount(uses.total) ? uses.total : listOf(uses).length, bound: text(uses.bound), throughMembers: uses.through_members === true, notice: text(uses.notice) } : { ok: false, items: [], total: 0, reason: reasonOf(uses, "What it relies on could not be read.") }, - tests: section_ok(tests) - ? { ok: true, items: dedupe(listOf(tests)).map((row) => ({ ...row, test: true })), total: isCount(tests.total) ? tests.total : listOf(tests).length, bound: text(tests.bound) } + tests: waiting("tests") ? { ok: false, pending: true, items: [], total: 0 } + : section_ok(tests) + ? { ok: true, items: dedupe(listOf(tests)).map((row) => ({ ...row, test: true })), total: isCount(tests.total) ? tests.total : listOf(tests).length, bound: text(tests.bound), depth: isCount(tests.depth) ? tests.depth : null } : { ok: false, items: [], total: 0, reason: reasonOf(tests, "The tests that reach it could not be read.") }, members: section_ok(response.members) ? { ok: true, items: listOf(response.members), total: isCount(response.members.total) ? response.members.total : listOf(response.members).length } : null, impact: impact ? section_ok(impact) @@ -2358,7 +3798,7 @@ top: array(impact.top).filter((row) => row && UUID.test(row.id)).map((row) => ({ ...row, test: isTest(row) })), bound: text(impact.bound), notice: text(impact.notice) } : { ok: false, reason: reasonOf(impact, "What a change would touch could not be read.") } : null, history: waiting("history") ? { ok: false, pending: true, items: [] } : history ? section_ok(history) - ? { ok: true, items: array(history.items).filter((row) => row && (row.change_id || row.id)).map((row) => ({ id: row.change_id || row.id, title: text(row.title), author: authorOf(row), time: row.time || row.timestamp })), total: isCount(history.total) ? history.total : array(history.items).length } + ? { ok: true, items: array(history.items).filter((row) => row && (row.change_id || row.id)).map((row) => ({ id: row.change_id || row.id, title: text(row.title), author: authorOf(row), time: row.time || row.timestamp })), total: isCount(history.total) ? history.total : array(history.items).length, recordStart: text(history.record_start) } : { ok: false, reason: reasonOf(history, "This entity's history could not be read.") } : null, editor: section_ok(editor) ? graphApi()?.editorUri?.(editor.uri, id) || null : null, editorReason: !section_ok(editor) ? text(editor.reason) : "", @@ -2435,16 +3875,22 @@ showEntityPanel(); return h("div", { class: "tabs", role: "tablist", "aria-label": "About this entity" }, tabs.map(([key, label]) => h("button", { type: "button", class: "tab", role: "tab", id: `entity-tab-${key}`, "aria-selected": String(state.entity.tab === key), "aria-controls": `entity-panel-${key}`, tabindex: state.entity.tab === key ? "0" : "-1", - on: { click: () => { state.entity.tab = key; paint("entity-tabs"); byId(`entity-tab-${key}`)?.focus(); }, + on: { click: () => { state.entity.tab = key; paint("entity-tabs"); showTab(key); }, keydown: (event) => { if (!["ArrowRight", "ArrowLeft"].includes(event.key)) return; event.preventDefault(); event.stopPropagation(); const at = tabs.findIndex(([name]) => name === state.entity.tab); state.entity.tab = tabs[(at + (event.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length][0]; - paint("entity-tabs"); byId(`entity-tab-${state.entity.tab}`)?.focus(); + paint("entity-tabs"); showTab(state.entity.tab); } } }, label, key === "members" ? h("span", { class: "tab-count" }, count(members)) : key === "history" && changes ? h("span", { class: "tab-count" }, count(changes)) : null))); } + /** Focus a tab and bring it inside the row's own scroll box, so a narrow window never hides the one chosen. */ + function showTab(key) { + const tab = byId(`entity-tab-${key}`); + tab?.focus({ preventScroll: true }); + tab?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); + } function showEntityPanel() { for (const [key] of ENTITY_TABS) { const panel = byId(`entity-panel-${key}`); if (panel) panel.hidden = key !== state.entity.tab; } } @@ -2512,7 +3958,7 @@ if (e.error) return [crumbs, h("div", { class: "section-tight" }, failed(isSlow(e.error) ? "Kin is still working on this entity. Try again in a moment." : "Kin couldn't load this entity.", retryEntity, e.error))]; return [crumbs, skeleton("skeleton-lede"), skeleton("skeleton-line")]; } - const place = h("p", { class: "entity-place" }, kindWord(data.kind) || "Entity", data.container && !data.name.startsWith(`${data.container}.`) ? ` in ${data.container}` : "", + const place = h("p", { class: "entity-place" }, kindWord(kindOf(data)) || "Entity", data.container && !data.name.startsWith(`${data.container}.`) ? ` in ${data.container}` : "", workSteps(read("working").data).some((step) => step.entity_id === data.id) ? [" · ", h("a", { class: "link", href: UNCOMMITTED }, "changed in uncommitted work")] : null); const feedback = h("span", { class: "note", role: "status" }); const actions = h("div", { class: "entity-actions" }); @@ -2558,12 +4004,12 @@ }; const leftSide = side("connLeft", ordered, relyTotal); const rightSide = side("connRight", data.uses.items, data.uses.total); - const left = h("div", { class: "conn-col" }, h("div", { class: "conn-col-head" }, "What relies on it", isCount(relyTotal) ? h("span", { class: "count" }, count(relyTotal)) : null), - data.reliers.pending ? skeletonLines(3) : !data.reliers.ok ? h("p", { class: "conn-empty" }, data.reliers.reason) : leftSide.nodes.length ? [leftSide.nodes, leftSide.tail] : h("p", { class: "conn-empty" }, "Kin found nothing that relies on it.")); + const left = h("div", { class: "conn-col" }, h("div", { class: "conn-col-head" }, "What relies on it", isCount(relyTotal) ? h("span", { class: "count" }, graphBadge(relyTotal, data.reliers.bound === "at_least")) : null), + data.reliers.pending ? skeletonLines(3) : !data.reliers.ok ? h("p", { class: "conn-empty" }, data.reliers.reason) : leftSide.nodes.length || leftSide.tail.some(Boolean) ? [leftSide.nodes, leftSide.tail] : h("p", { class: "conn-empty" }, "Kin found no entities that rely on it.")); const focus = h("div", { class: "conn-focus" }, h("span", { class: "ent-name" }, data.name)); - const right = h("div", { class: "conn-col" }, h("div", { class: "conn-col-head" }, data.uses.throughMembers ? "What it and its members rely on" : "What it relies on", data.uses.ok ? h("span", { class: "count" }, count(data.uses.total)) : null), - !data.uses.ok ? h("p", { class: "conn-empty" }, data.uses.reason) : rightSide.nodes.length ? [rightSide.nodes, rightSide.tail] : h("p", { class: "conn-empty" }, "It relies on nothing else here.")); - const host = h("div", { class: "conn", role: "group", "aria-label": `${data.name}, with ${relyWords(relyTotal) || "what relies on it still loading"}, and it relies on ${plural(data.uses.total, "entity", "entities")}` }, left, focus, right); + const right = h("div", { class: "conn-col" }, h("div", { class: "conn-col-head" }, data.uses.throughMembers ? "What it and its members rely on" : "What it relies on", data.uses.ok ? h("span", { class: "count" }, graphBadge(data.uses.total, data.uses.bound === "at_least")) : null), + !data.uses.ok ? h("p", { class: "conn-empty" }, data.uses.reason) : rightSide.nodes.length || rightSide.tail.some(Boolean) ? [rightSide.nodes, rightSide.tail] : h("p", { class: "conn-empty" }, "Kin found no entities it relies on.")); + const host = h("div", { class: "conn", role: "group", "aria-label": `${data.name}, with ${relyWords(relyTotal, data.reliers.bound === "at_least") || "what relies on it still loading"}, and ${data.uses.ok ? `it relies on ${data.uses.bound === "at_least" ? "at least " : ""}${plural(data.uses.total, "entity", "entities")}` : "what it relies on could not be read"}` }, left, focus, right); const join = graphApi()?.joinColumns; if (join) { // The lines are measured, so they are drawn once the boxes are on the page. @@ -2632,11 +4078,14 @@ const unlisted = Math.max(0, total - entityCount(rows)); const each = (row) => row.declarations ? `${count(row.declarations)} overloads` : caption(row); const same = rows.length > 1 && new Set(rows.map(each)).size === 1; - const said = (row) => same ? null : each(row); - return h("div", { class: "reach-group" }, h("h3", {}, title, isCount(total) ? h("span", { class: "count" }, count(total)) : null), + const said = (row) => { + const duplicate = rows.some((other) => other.id !== row.id && other.name === row.name); + return [duplicate ? [kindWord(row.kind), containerOf(row)].filter(Boolean).join(" in ") : "", same && !duplicate ? "" : each(row)].filter(Boolean).join(" · ") || null; + }; + return h("div", { class: "reach-group" }, h("h3", {}, title, isCount(total) ? h("span", { class: "count" }, graphBadge(total, key === "tests" && state.entity.data?.tests.bound === "at_least")) : null), h("div", { class: "reach-list" }, visible.map((row) => h("a", { class: "relier", href: entityHref(row.id), title: row.name }, ent(row, { linked: false, test: row.test === true }), h("span", { class: "relier-caption" }, said(row)))), - hidden > 0 ? h("button", { type: "button", class: "relier", on: { click: () => { state.entity.showAll = { ...(state.entity.showAll || {}), [key]: true }; paintReach(); } } }, `Show ${count(hidden)} more`) : null, - !hidden && unlisted > 0 ? h("span", { class: "relier note" }, `${count(unlisted)} more that Kin counted and did not list`) : null)); + hidden > 0 ? h("button", { type: "button", class: "relier", on: { click: () => { state.entity.showAll = { ...(state.entity.showAll || {}), [key]: true }; paintReach(); } } }, unlisted > 0 ? `Show ${count(hidden)} more of the ${count(total)}` : `Show ${count(hidden)} more`) : null, + !hidden && unlisted > 0 ? h("span", { class: "relier note" }, `Kin lists ${count(entityCount(rows))} of the ${count(total)} here`) : null)); } function entityImpact(part) { @@ -2650,16 +4099,21 @@ const relying = relyCount(data); const reach = impact?.transitive ?? null; const tests = impact?.tests ?? data.tests.total; - const testWords = tests ? `${spoken(tests)} ${tests === 1 ? "test reaches" : "tests reach"} it.` : "Kin found no test that reaches it."; + const limitedTests = data.tests.bound === "at_least" || impact?.bound === "at_least"; + // Said once per sentence: after "within 3 steps" on what a change might affect, the tests read in the same steps. + const testWords = (depth) => data.tests.pending ? "Kin is still reading the tests that reach it." : !data.tests.ok ? "Kin could not read the tests that reach it." : tests ? `${limitedTests ? `At least ${spoken(tests).toLowerCase()}` : spoken(tests)} ${tests === 1 ? "test reaches" : "tests reach"} it${withinSteps(depth)}.` : "Kin found no tests that reach it."; + const testDepthHere = data.tests.depth ?? impact?.depth ?? null; let sentence; const atLeast = text(impact?.bound) === "at_least" ? "at least " : ""; - if (isCount(reach) && reach > 0) sentence = `A change here might affect ${atLeast}${plural(reach, "entity", "entities")}${impact.depth ? ` within ${impact.depth} steps` : ""}. ${testWords}`; + if (isCount(reach) && reach > 0) sentence = `A change here might affect ${atLeast}${plural(reach, "entity", "entities")}${impact.depth ? ` within ${impact.depth} steps` : ""}. ${testWords(impact.depth && impact.depth === testDepthHere ? null : testDepthHere)}`; else if (reach === 0 || relying === 0) sentence = `Kin found nothing that relies on ${data.name}.`; - else if (isCount(relying)) sentence = `${relyWords(relying)}. ${testWords}`.replace(/^\w/, (c) => c.toUpperCase()); + else if (isCount(relying)) sentence = `${relyWords(relying, data.reliers.bound === "at_least")}. ${testWords(testDepthHere)}`.replace(/^\w/, (c) => c.toUpperCase()); else sentence = "Kin is still working out what relies on it."; const groups = []; const further = array(impact?.top).filter((row) => isCount(row.hops) ? row.hops > 1 : !data.reliers.items.some((other) => other.id === row.id)); - if (further.length) groups.push(reachGroup("further", "Further out", further, further.length, (row) => row.test ? "Test" : steps(row.hops) || IMPACT_WORD[row.reason] || kindWord(row.kind))); + // A module's reason is its import, and only a type has a type to use, so the word follows both kinds. + const reasonWord = (row) => row.reason === "type_dependent" || row.reason === "dependent" ? relationWord(row.reason === "type_dependent" ? "uses_type" : "", row, data) : IMPACT_WORD[row.reason]; + if (further.length) groups.push(reachGroup("further", "Further out", further, further.length, (row) => row.test ? "Test" : steps(row.hops) || reasonWord(row) || kindWord(row.kind))); if (data.tests.items.length) groups.push(reachGroup("tests", "Tests that reach it", data.tests.items, data.tests.total, (row) => steps(row.hops) || "Test")); const notice = impact?.notice || ""; // The picture above already says when Kin is still mapping; the same line is not said twice. @@ -2699,8 +4153,9 @@ // the source. This column holds what that list does not. const out = []; const builds = data.reliers.ok ? data.reliers.items.filter((row) => row.confirmed && !row.relies) : []; - if (part === "builds" && builds.length) out.push(relGroup("builds", "Builds on it", { ok: true, items: builds, total: entityCount(builds) }, (row) => RELATION_WORD[text(row.relation).toLowerCase()] || "builds on it", "")); - const guessed = data.reliers.ok ? data.reliers.items.filter((row) => !row.confirmed) : []; + if (part === "builds" && builds.length) out.push(relGroup("builds", "Builds on it", { ok: true, items: builds, total: entityCount(builds) }, (row) => relationWord(row.relation, row, data), "")); + const proven = new Set([...array(data.tests.ok ? data.tests.items : []), ...array(data.impact?.ok ? data.impact.top : []), ...array(data.reliers.ok ? data.reliers.items : []).filter((row) => row.confirmed)].map((row) => row.id)); + const guessed = data.reliers.ok ? data.reliers.items.filter((row) => !row.confirmed && !proven.has(row.id)) : []; if (part === "builds" && guessed.length) out.push(relGroup("guessed", "Matched by name only", { ok: true, items: guessed, total: entityCount(guessed), notice: "Kin found these by name and couldn't confirm they use it." }, () => "by name only", "")); if (part === "members" && data.members?.ok && data.members.items.length) out.push(relGroup("members", "Members", data.members, (row) => kindWord(row.kind), "", true)); if (part !== "history") return out; @@ -2711,9 +4166,12 @@ else if (!history) body = h("p", { class: "gap" }, "No history was returned for this entity."); else if (!history.ok) body = failed(history.reason, retryEntity); else if (!history.items.length) body = h("p", { class: "gap" }, "No change has touched it yet."); - else body = h("div", { class: "rel-list" }, history.items.slice(0, 10).map((change) => h("a", { class: "history-row", href: reviewHref(change.id) }, + else body = [h("div", { class: "rel-list" }, history.items.slice(0, 10).map((change) => h("a", { class: "history-row", href: reviewHref(change.id) }, h("div", { class: "history-title" }, change.title || "Untitled change"), - h("div", { class: "history-meta" }, [change.author, ago(change.time)].filter(Boolean).join(" · "))))); + h("div", { class: "history-meta" }, [change.author, ago(change.time)].filter(Boolean).join(" · ")), + history.recordStart && change.id === history.recordStart ? h("div", { class: "change-start" }, "Kin's record starts here, with the repository as it was") : null))), + // Kin has not read further back yet, so this list is where its record begins, not the entity's whole past. + history.recordStart ? h("p", { class: "note" }, "Kin has read this repository's history only from where its record starts, so earlier changes to this entity are not listed yet.") : null]; out.push(h("div", { class: "rel-group", "aria-busy": history?.pending ? "true" : undefined }, head, body)); return out; } @@ -3000,20 +4458,25 @@ announce("Switch preview ready."); } else { if (result.operation_id !== branch.operationId || !result.report || result.report.schema !== "kin.branch-list.v1" || !Array.isArray(result.report.branches) || result.report.repository_id !== payload.expected.repository_id || result.report.workspace_id !== payload.expected.workspace_id || result.report.branches.find((row) => row.active)?.name?.bytes_hex !== payload.expected.target_ref?.bytes_hex) throw new Error("The branch result could not be checked. Look at the repository before trying again."); + // What came along is named as the page last read it, before the reads start again. + branch.carried = branch.preview?.pending_work?.present ? workNames() : []; branch.result = result; branch.preview = null; branch.confirmed = false; clearBranchReceipt(); // Every view reads again after a switch. Old source and old merge - // expectations cannot carry over to the new branch. + // expectations cannot carry over to the new branch. The session is + // the repository's, not the branch's, so it stays while the rest is read. state.readEpoch += 1; - for (const resource of Object.values(state.reads)) { resource.data = null; resource.error = null; resource.at = null; resource.busy = false; resource.sig = null; resource.changed = true; } + for (const [key, resource] of Object.entries(state.reads)) { if (key === "session") continue; resource.data = null; resource.error = null; resource.at = null; resource.busy = false; resource.sig = null; resource.changed = true; } + state.newBranch.created = null; state.entity.data = null; state.explore.results = null; state.merge.preview = null; state.merge.result = null; state.merge.confirmed = false; announce("Branch switched. Loading the new branch."); + syncAll(readKeys().filter((key) => key !== "session")); } } catch (error) { branch.diagnostic = error.message; @@ -3033,53 +4496,99 @@ if (kind === "confirm" && branch.result) await refresh(); } } + /** The uncommitted entities by name, as the latest reading of the work lists them. */ + function workNames() { return workSteps(read("working").data).map((step) => shownName(step.name)).filter(Boolean); } + function nameList(names, most = 4) { + if (names.length <= most) return names.join(names.length === 2 ? " and " : ", ").replace(/, ([^,]*)$/, " and $1"); + return `${names.slice(0, most - 1).join(", ")} and ${count(names.length - most + 1)} more`; + } + /** What comes along when switching, in plain words naming the entities. */ + function carriedWords(present, to) { + if (!present) return "Nothing is uncommitted, so only the branch changes."; + const names = workNames(); + if (names.length) return `Your uncommitted ${names.length === 1 ? "change to" : "changes to"} ${nameList(names)} ${names.length === 1 ? "comes" : "come"} along to ${to}.`; + return read("working").data?.working_tree?.differs ? `Your uncommitted edits come along to ${to}. No entity changed in them.` : `Your uncommitted work comes along to ${to}.`; + } + + /** + * Switching is one thing at a time: choosing a branch, reading what a switch + * does, confirming it, then the switch itself, and its outcome. Each state + * says only what is true in it, so a switch in flight never sits beside a + * sentence about the branch it is leaving. + */ function branchPanel() { const branch = state.branch; if (!switchShown()) return null; const choices = branchChoices(); - // Previewing only reads; confirming waits until the page's own reads settle. - const busy = workspaceWriteBusy() || !session()?.capabilities?.branch_switch; + const allowed = Boolean(session()?.capabilities?.branch_switch); + const busy = workspaceWriteBusy() || !allowed; const confirmLocked = busy || state.refreshing; const current = choices.find((row) => row.active); const others = choices.filter((row) => !row.active); - const select = h("select", { id: "workspace-branch-target", disabled: busy || Boolean(read("branchState").error), on: { change: (event) => { + const currentName = (current ? shortRef(mergeRef(current.name)) : "") || shortRef(activeBranch()?.name); + const close = !branch.busy && !branch.unknown ? closeButton("Close", () => { state.switchOpen = false; branch.preview = null; branch.result = null; branch.error = null; branch.confirmed = false; updateBranchPanel(); }) : null; + const evidence = h("div", { class: "evidence", id: "workspace-branch-evidence", tabindex: "-1" }); + const wrap = (...children) => h("section", { class: "card panel", "aria-labelledby": "switch-title" }, h("div", { class: "panel-head" }, h("h2", { id: "switch-title" }, "Switch branch"), close), ...children, evidence); + + // The switch landed: its receipt, and nothing about the branch it left. + if (branch.result) { + const selected = array(branch.result.report?.branches).find((row) => row.active); + const name = (selected ? shortRef(mergeRef(selected.name)) : "") || "the chosen branch"; + const settled = Boolean(read("branches").data) && shortRef(activeBranch()?.name) === name; + const carried = array(branch.carried); + evidence.append(h("div", { class: "message message-good", role: "status" }, h("div", {}, h("strong", {}, `Switched to ${name}`), + h("p", {}, settled ? `You're on ${name} now.` : `Reading ${name}.`, carried.length ? ` Your uncommitted ${carried.length === 1 ? "change to" : "changes to"} ${nameList(carried)} came along.` : ""), + operationDetails(branch.result.operation_id)))); + return wrap(); + } + // An outcome nobody saw: say so; the chooser below stays locked, so nothing sends it again. + if (branch.unknown) { + evidence.append(h("div", { class: "message message-warn", role: "alert" }, h("div", {}, h("strong", {}, branch.error || "The branch switch outcome is unknown. This page will not send it again."), + h("p", {}, "Read the branches again to see which one you are on."), + branch.diagnostic || branch.operationId ? h("details", { class: "more" }, h("summary", {}, "Show details"), branch.diagnostic ? h("p", {}, branch.diagnostic) : null, branch.operationId ? h("p", {}, "Operation ", h("code", {}, branch.operationId)) : null) : null))); + } + if (!session()) return wrap(skeletonLines(1)); + if (!allowed) return wrap(h("p", { class: "note" }, session().branch_unavailable_reason || "This version of Kin can't switch branches from this page.")); + if (read("branchState").error) return wrap(h("div", { class: "section" }, readFailure("branchState"))); + if (!read("branchState").data) return wrap(skeletonLines(1)); + if (!others.length) return wrap(h("p", {}, `You're on ${currentName || "the only branch"}${currentName ? ", the only branch" : ""}. There is nothing to switch to yet.`)); + + const select = h("select", { id: "workspace-branch-target", disabled: busy, on: { change: (event) => { branch.target = event.target.value; branch.preview = null; branch.confirmed = false; branch.result = null; branch.error = null; updateBranchPanel(); byId("workspace-branch-target")?.focus(); } } }, h("option", { value: "" }, "Choose a branch"), others.map((row) => h("option", { value: row.name?.bytes_hex, selected: row.name?.bytes_hex === branch.target }, shortRef(mergeRef(row.name)) || "Unreadable branch name"))); - const evidence = h("div", { class: "evidence", id: "workspace-branch-evidence", tabindex: "-1" }); - if (branch.error) evidence.append(h("div", { class: "message message-warn" }, h("div", {}, h("strong", {}, branch.error), - !branch.unknown && !branch.operationId ? h("p", {}, workInTheWay(branch.diagnostic) || "Nothing was switched.") : null, - branch.diagnostic || branch.operationId ? h("details", { class: "more" }, h("summary", {}, "Show details"), branch.diagnostic ? h("p", {}, branch.diagnostic) : null, branch.operationId ? h("p", {}, "Operation ", h("code", {}, branch.operationId)) : null) : null))); + const form = h("form", { class: "panel-form workspace-branch-form", on: { submit: (event) => { event.preventDefault(); branchRequest("preview"); } } }, + h("div", { class: "field" }, h("label", { for: "workspace-branch-target" }, `Switch from ${currentName || "this branch"} to`), select), + h("button", { type: "submit", class: "button", disabled: busy || !branchTarget() }, branch.busy === "preview" ? "Reading…" : "Preview switch")); + if (branch.error && !branch.unknown) evidence.append(h("div", { class: "message message-warn", role: "alert" }, h("div", {}, h("strong", {}, branch.error), + h("p", {}, workInTheWay(branch.diagnostic) || "Nothing was switched."), + branch.diagnostic ? h("details", { class: "more" }, h("summary", {}, "Show details"), h("p", {}, branch.diagnostic)) : null))); + if (branch.busy === "preview") evidence.append(h("p", { class: "note aside-reading", role: "status" }, "Reading what switching does")); if (branch.preview) { const preview = branch.preview; const pending = preview.pending_work; + const to = shortRef(preview.target.display); + const from = shortRef(preview.source?.display) || currentName || "this branch"; + const held = state.branchGuides.get(`${from}..${to}`)?.data?.range; + const where = held && isCount(held.changes_total) ? standingWords(held.changes_total, isCount(held.base_changes_total) ? held.base_changes_total : null, from) : ""; + const standing = !where ? "" : where.startsWith("No work") ? `${to} has ${where.replace(/^No/, "no")}.` : `${to} is ${where}.`; const checkbox = h("input", { type: "checkbox", id: "workspace-branch-confirm", checked: branch.confirmed, disabled: confirmLocked, on: { change: (event) => { branch.confirmed = event.target.checked; byId("workspace-branch-submit").disabled = !branch.confirmed || confirmLocked; } } }); - evidence.append(h("div", { class: "preview" }, h("h3", {}, `Switch to ${shortRef(preview.target.display)}`), - h("dl", { class: "heads" }, detail("From", shortRef(preview.source?.display) || "No branch", true), detail("To", shortRef(preview.target.display), true)), - h("details", { class: "more" }, h("summary", {}, "Show details"), h("dl", { class: "heads" }, detail("Its latest change", mergeHash(preview.target.change), true))), - h("div", { class: `message message-${pending.present ? "warn" : "good"}` }, h("div", {}, h("strong", {}, pending.present ? "Uncommitted work comes along" : "No uncommitted work"), h("p", {}, pending.detail))), - preview.limitations.length ? h("ul", { class: "limits" }, preview.limitations.map((line) => h("li", {}, line))) : null, + const own = [text(pending.detail), ...array(preview.limitations).filter((line) => typeof line === "string")].filter(Boolean); + evidence.append(h("div", { class: "preview" }, h("h3", {}, `Switch from ${from} to ${to}`), + h("p", { class: "preview-sentence" }, `Your code moves to ${to}'s latest change.`, standing ? ` ${standing}` : ""), + h("div", { class: `message message-${pending.present ? "warn" : "good"}` }, h("div", {}, h("strong", {}, pending.present ? "Your uncommitted work comes along" : "Nothing is uncommitted"), h("p", {}, carriedWords(pending.present, to)))), + h("p", { class: "note" }, "Kin checks again when you confirm. If anything changed since this preview, nothing is switched and you can preview again."), + h("details", { class: "more" }, h("summary", {}, "Show Kin's own words"), own.length ? h("ul", { class: "limits" }, own.map((line) => h("li", {}, line))) : null, + h("dl", { class: "heads" }, detail(`${to}'s latest change`, mergeHash(preview.target.change), true))), h("div", { class: "confirm" }, - h("label", { class: "check", for: "workspace-branch-confirm" }, checkbox, h("span", {}, `I've checked what switching to ${shortRef(preview.target.display)} does to my uncommitted work.`)), - button(branch.busy === "confirm" ? "Switching…" : "Switch branch", () => branchRequest("confirm"), { id: "workspace-branch-submit", class: "button button-primary", disabled: confirmLocked || !branch.confirmed })))); + h("label", { class: "check", for: "workspace-branch-confirm" }, checkbox, h("span", {}, pending.present ? `I've checked what comes along to ${to}.` : `Switch to ${to}.`)), + button(branch.busy === "confirm" ? "Switching…" : `Switch to ${to}`, () => branchRequest("confirm"), { id: "workspace-branch-submit", class: "button button-primary", disabled: confirmLocked || !branch.confirmed })))); } - if (branch.result) { - const selected = branch.result.report.branches.find((row) => row.active); - evidence.append(h("div", { class: "message message-good" }, h("div", {}, h("strong", {}, `Switched to ${selected ? shortRef(mergeRef(selected.name)) : "the chosen branch"}`), h("p", {}, "This page is loading the new branch."), operationDetails(branch.result.operation_id)))); - } - const close = !branch.busy && !branch.unknown ? closeButton("Close", () => { state.switchOpen = false; branch.preview = null; branch.result = null; branch.error = null; branch.confirmed = false; updateBranchPanel(); }) : null; - return h("section", { class: "card panel", "aria-labelledby": "switch-title" }, h("div", { class: "panel-head" }, h("h2", { id: "switch-title" }, "Switch branch"), close), - h("p", {}, others.length ? "Preview what a switch does to your uncommitted work, then confirm." : `You are on ${current ? shortRef(mergeRef(current.name)) : shortRef(activeBranch()?.name) || "no branch"}, the only branch.`), - read("branchState").error ? h("div", { class: "section" }, readFailure("branchState")) : null, - !session()?.capabilities?.branch_switch ? h("p", { class: "note" }, session()?.branch_unavailable_reason || "Switching is not available in this session.") : null, - !read("branchState").data ? (read("branchState").error ? null : skeletonLines(1)) : !others.length ? null : h("form", { class: "panel-form workspace-branch-form", on: { submit: (event) => { event.preventDefault(); branchRequest("preview"); } } }, - h("div", { class: "field" }, h("label", { for: "workspace-branch-target" }, "Switch to"), select), - h("button", { type: "submit", class: "button", disabled: busy || !branchTarget() || Boolean(read("branchState").error) }, branch.busy === "preview" ? "Loading…" : "Preview switch")), - state.merge.unknown || state.merge.busy ? h("p", { class: "note" }, "Finish checking the pending merge before switching.") : null, - evidence); + return wrap(h("p", {}, "Preview what a switch does to your uncommitted work, then confirm."), form, + state.merge.unknown || state.merge.busy ? h("p", { class: "note" }, "Finish checking the pending merge before switching.") : null); } const conflictHash = (value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value) ? value : mergeHash(value); @@ -3195,21 +4704,39 @@ if (!base || index >= 4) return null; const key = `${base}..${row.name}`; const held = state.branchGuides.get(key); - if (!held) { Promise.resolve().then(() => loadBranchGuide(row.name, base)); return h("div", { class: "branch-detail" }, skeleton("skeleton-line")); } - if (held.busy) return h("div", { class: "branch-detail" }, skeleton("skeleton-line")); - if (held.error) return h("div", { class: "branch-detail" }, failed(isSlow(held.error) ? "Kin is still reading this branch." : "Kin couldn't load what this branch holds.", () => { state.branchGuides.delete(key); paint("branches-list"); }, held.error)); + if (!held) { Promise.resolve().then(() => loadBranchGuide(row.name, base)); return h("div", { class: "branch-detail" }, h("span", { class: "note aside-reading" }, `Reading what ${row.name} changed`)); } + if (held.busy) return h("div", { class: "branch-detail" }, h("span", { class: "note aside-reading" }, `Reading what ${row.name} changed`)); + if (held.error && isSlow(held.error)) { + setTimeout(() => { if (state.branchGuides.get(key) === held) { state.branchGuides.delete(key); paint("branches-list"); } }, 4000); + return h("div", { class: "branch-detail" }, h("span", { class: "note aside-reading" }, `Still reading what ${row.name} changed`)); + } + if (held.error) return h("div", { class: "branch-detail" }, failed("Kin couldn't load what this branch holds.", () => { state.branchGuides.delete(key); paint("branches-list"); }, held.error)); const guide = held.data; const range = guide.range || {}; const changes = array(range.changes); const latest = changes[changes.length - 1] || changes[0]; const ahead = isCount(range.changes_total) ? range.changes_total : changes.length; - const sentence = workSentence(guide) || (ahead ? "No entity changed." : ""); + // With no change of its own, what differs is only what the current branch moved on to, which is not this branch's work. + const sentence = ahead ? workSentence(guide) || "No entity changed." : ""; return h("div", { class: "branch-detail" }, sentence ? h("p", { class: "branch-sentence" }, sentence) : null, - h("p", { class: "branch-meta" }, ahead ? `${plural(ahead, "change")} ahead of ${base}` : `Even with ${base}`, + h("p", { class: "branch-meta" }, standingWords(ahead, isCount(range.base_changes_total) ? range.base_changes_total : null, base), latest ? [" · ", latest.title || "Untitled change", [authorOf(latest), ago(latest.timestamp)].filter(Boolean).length ? ` · ${[authorOf(latest), ago(latest.timestamp)].filter(Boolean).join(", ")}` : ""] : null)); } + /** + * Where a branch stands against another, from both sides of where they + * split: what it holds that the other does not, and what the other holds + * that it does not. A side Kin did not count is not said to be even. + */ + function standingWords(ahead, behind, base) { + if (ahead && behind) return `${plural(ahead, "change")} ahead of ${base}, ${count(behind)} behind`; + if (ahead) return `${plural(ahead, "change")} ahead of ${base}`; + // A branch that holds nothing the other lacks has no work of its own, whatever it is missing. + if (behind) return `No work of its own, ${plural(behind, "change")} behind ${base}`; + return behind === 0 ? `No work of its own, even with ${base}` : `No work of its own beyond ${base}`; + } + /** How many changes a branch holds beyond another, once Kin has read it; null until then. */ function branchAhead(row, base) { const guide = state.branchGuides.get(`${base}..${row.name}`)?.data; @@ -3229,12 +4756,14 @@ return h("ul", { class: "list list-card" }, ordered.map((row) => { const choice = others.find((other) => shortRef(mergeRef(other.name)) === row.name); const detail = row.active ? null : branchSummary(row, current, index++); - return h("li", { class: "branch-row" }, + // A branch with work of its own opens as that work, against the branch you are on. + const opens = !row.active && current && branchAhead(row, current) !== 0; + const name = [icon("branch"), h("span", { class: "branch-label" }, row.name), row.active ? pill("Current", "accent") : null, row.default ? pill("Default") : null]; + return h("li", { class: `branch-row${opens ? " branch-row-opens" : ""}` }, h("div", { class: "branch-main" }, - h("span", { class: "branch-name" }, icon("branch"), row.name, row.active ? pill("Current", "accent") : null, row.default ? pill("Default") : null), + opens ? h("a", { class: "branch-name branch-open", href: rangeHref(current, row.name), title: `What ${row.name} changed, against ${current}` }, name) : h("span", { class: "branch-name" }, name), detail), row.active ? null : h("span", { class: "branch-actions" }, - current && branchAhead(row, current) !== 0 ? h("a", { class: "button button-small", href: rangeHref(current, row.name) }, "Review") : null, // Offered before the branch list's exact names are read; the panel // then shows that read, or its failure, until it can preview. session()?.capabilities?.branch_switch ? button("Switch", () => { @@ -3255,7 +4784,8 @@ paint("branches-new"); try { await send("/api/workspace/branch/create", { name, operation_id: crypto.randomUUID() }); - form.created = name; form.name = ""; form.open = false; + // Where it starts is the branch it was made from, said the same way after a switch. + form.created = name; form.createdFrom = shortRef(activeBranch()?.name) || null; form.name = ""; form.open = false; announce(`Branch ${name} created.`); } catch (error) { form.error = error.remedy || error.message || "The branch was not created."; @@ -3270,9 +4800,15 @@ const form = state.newBranch; const from = shortRef(activeBranch()?.name) || "the current branch"; if (!form.open) { - return form.created ? h("div", { class: "message message-good section-tight", role: "status" }, - h("div", {}, h("strong", {}, `Created ${form.created}`), h("p", {}, `It starts at ${from}'s latest change. Switch to it to work there.`)), - button("Dismiss", () => { form.created = null; paint("branches-new"); }, { class: "button button-small button-quiet" })) : null; + if (!form.created) return null; + const started = form.createdFrom ? `${form.createdFrom}'s latest change` : "the latest change of the branch you were on"; + const here = shortRef(activeBranch()?.name) === form.created; + const choice = branchChoices().find((row) => shortRef(mergeRef(row.name)) === form.created && !row.active); + return h("div", { class: "message message-good section-tight", role: "status", id: "branch-created" }, + h("div", {}, h("strong", {}, `Created ${form.created}`), h("p", {}, here ? `You're on it now. It started at ${started}.` : `It starts at ${started}. You're still on ${shortRef(activeBranch()?.name) || "the branch you were on"}.`)), + h("div", { class: "page-actions" }, + !here && choice && can("branch_switch") ? button(`Switch to ${form.created}`, () => { state.switchOpen = true; state.branch.target = choice.name.bytes_hex; updateBranchPanel(); branchRequest("preview"); byId("workspace-branch-host")?.scrollIntoView?.({ block: "start", behavior: "smooth" }); }, { class: "button button-small", disabled: workspaceWriteBusy() }) : null, + button("Dismiss", () => { form.created = null; paint("branches-new"); }, { class: "button button-small button-quiet" }))); } const submit = h("button", { type: "submit", id: "branch-new-submit", class: "button button-primary", disabled: form.busy || !form.name.trim() }, form.busy ? "Creating…" : "Create branch"); const input = h("input", { id: "branch-new-name", type: "text", value: form.name, placeholder: "feat/short-name", autocomplete: "off", spellcheck: "false", autocapitalize: "off", maxlength: "200", disabled: form.busy, @@ -3295,11 +4831,14 @@ if (!read("branches").data) return read("branches").error ? null : skeleton("skeleton-lede"); const active = activeBranch(); const rows = branchRows(); + // A branch name is one word to the reader, so a line never breaks inside it at a hyphen or a slash. + const named = (name) => h("span", { class: "nobreak", title: name }, name); const others = rows.filter((row) => !row.active).map((row) => row.name); - const which = others.length === 1 ? `One other branch: ${others[0]}.` : others.length <= 3 ? `${spoken(others.length)} other branches: ${others.slice(0, -1).join(", ")} and ${others.at(-1)}.` : `${spoken(others.length)} other branches.`; - return h("p", { class: "lede" }, active ? (others.length ? `You're on ${active.name}. ${which}` : `You're on ${active.name}, the only branch.`) : `${spoken(rows.length)} ${rows.length === 1 ? "branch" : "branches"}.`); + const which = others.length === 1 ? ["One other branch: ", named(others[0]), "."] + : others.length <= 3 ? [`${spoken(others.length)} other branches: `, others.slice(0, -1).flatMap((name, at) => [at ? ", " : "", named(name)]), " and ", named(others.at(-1)), "."] : [`${spoken(others.length)} other branches.`]; + return h("p", { class: "lede" }, active ? (others.length ? ["You're on ", named(active.name), ". ", which] : ["You're on ", named(active.name), ", the only branch."]) : `${spoken(rows.length)} ${rows.length === 1 ? "branch" : "branches"}.`); }, { class: "tab-lede" }), - section("branches-new", ["branches"], newBranchForm), + section("branches-new", ["branches", "branchState"], newBranchForm), section("branches-compare", ["branches"], () => state.compareOpen ? compareForm() : null), h("section", { class: "section-tight", "aria-label": "Branches" }, section("branches-list", ["branches", "branchState"], branchList)), section("branches-review", ["branches"], () => { @@ -3308,7 +4847,8 @@ }), h("div", { id: "workspace-branch-host" }, branchPanel()), h("div", { id: "workspace-merge-host" }, mergePanel()), - section("branches-conflicts", ["conflicts"], conflictPanel)]; + section("branches-conflicts", ["conflicts"], conflictPanel), + section("branches-remotes", ["remotes", "branches", "session"], remotesPanel)]; } // ── Frame ──────────────────────────────────────────────────────────────── @@ -3360,14 +4900,29 @@ page("uncommitted", "Work", "uncommitted", changed === null ? h("span", { class: "nav-count nav-count-work", "aria-label": "uncommitted work" }, "•") : changed ? h("span", { class: "nav-count nav-count-work", "aria-label": `${changed} uncommitted` }, count(changed)) : null), page("agents", "Agents", "agents", working ? h("span", { class: "nav-count nav-count-live", "aria-label": `${working} working now` }, count(working)) : null), page("explore", "Code", "code")); - // The open repository first, then the ones with something in motion, then the rest, as many as fit. + // The ones with something in motion first, then the rest, as many as fit. Opening a + // repository does not move it, so the list holds still under the pointer. const all = sideRepositories(); const SHOWN = 8; - const rank = (row) => row.key === openKey ? 0 : repoTone(row) === "work" ? 1 : repoTone(row) === "live" ? 2 : 3; - const listed = [...all].sort((a, b) => rank(a) - rank(b) || repositoryLabel(a).localeCompare(repositoryLabel(b))).slice(0, SHOWN); + // Until the machine's list is read, each repository is said as this tab last saw it, so the list + // a page opens with is the one the reader just clicked in. + const listRead = homeRows().length > 0; + const kept = keptTones(); + const toneOf = (row) => { + // The open repository is said from its own reads once they land, and as the list says before then. + if (!openKey || row.key !== openKey || !read("working").data) return listRead || !(row.key in kept) ? repoTone(row) : kept[row.key]; + if (changed !== 0 || row.merge_in_progress) return "work"; + if (awaitingReview(row).length) return "review"; + return working ? "live" : ""; + }; + if (listRead) keepTones(Object.fromEntries(all.map((row) => [row.key, toneOf(row)]))); + const sorted = [...all].sort((a, b) => TONE_RANK[toneOf(a)] - TONE_RANK[toneOf(b)] || repositoryLabel(a).localeCompare(repositoryLabel(b))); + const listed = sorted.slice(0, SHOWN); + // The open one is always listed, in its own place among the rest. + if (openKey && !listed.some((row) => row.key === openKey)) { const open = sorted.find((row) => row.key === openKey); if (open) listed.splice(SHOWN - 1, 1, open); } const repository = (row) => { const isOpen = Boolean(openKey) && row.key === openKey; - const tone = isOpen ? (changed !== 0 ? "work" : working ? "live" : "") : repoTone(row); + const tone = toneOf(row); const branch = isOpen ? text(activeBranch()?.name) : text(row.branch); return [h("a", { class: `repo-item${isOpen ? " is-open" : ""}`, href: isOpen ? UNCOMMITTED : safeLocalURL(row.url, "/"), title: repositoryLabel(row, " / ") }, h("span", { class: "repo-dot", "data-state": tone || undefined, "aria-hidden": "true" }), @@ -3403,6 +4958,101 @@ if (state.route !== "entity" || !state.entity.data) document.title = `${TITLES[state.route] || "Uncommitted"} · ${name}`; } + // ── Remotes ────────────────────────────────────────────────────────────── + // Where this repository stands against the places it is shared: what a push + // would send and what a pull would bring, read without moving anything. + + async function moveRemote(kind, remote) { + const move = state.remote; + if (move.busy || move.unknown || workspaceWriteBusy() || state.commit.busy || state.restore.busy || !globalThis.crypto?.randomUUID) return; + move.busy = `${kind}:${remote.name}`; move.error = null; move.result = null; + move.operationId = crypto.randomUUID(); + rememberWorkReceipt("remote", move.operationId); + paint("branches-remotes"); + try { + move.result = { kind, remote: remote.name, ...(await send(`/api/workspace/${kind}`, { remote: remote.name, operation_id: move.operationId })) }; + forgetWorkReceipt("remote"); + announce(move.result.outcome === "pushed" ? "Pushed." : move.result.outcome === "pulled" ? "Pulled." : move.result.outcome === "fetched" ? "Fetched for review." : kind === "push" ? "Nothing to push." : kind === "fetch" ? "Nothing to fetch." : "Nothing to pull."); + } catch (error) { + // Why it did not happen is the server's own sentence; the general advice that follows a refusal adds nothing to it. + move.error = error.remedy || error.message || (kind === "push" ? "Nothing was pushed." : kind === "fetch" ? "Nothing was fetched." : "Nothing was pulled."); + // A refusal moved nothing. Anything else may have landed, so this page does not send it again. + move.unknown = !(error.status === 409 && error.outcome === "refused") && ![400, 403, 404, 405, 422, 501].includes(error.status); + if (!move.unknown) forgetWorkReceipt("remote"); + } finally { + move.busy = null; + paint("branches-remotes"); + state.branchGuides.clear(); + refresh(["remotes", "branches", "branchState", "changes", "working", "flags"]); + } + } + + function previewRemoteMerge(branch) { + if (!text(branch) || workspaceWriteBusy() || state.remote.busy || state.remote.unknown) return; + state.mergeOpen = true; state.merge.source = branch; + updateMergePanel(); mergeRequest("preview"); + byId("workspace-merge-host")?.scrollIntoView?.({ block: "start", behavior: "smooth" }); + } + + /** What moved, in changes and entities, as the answer counts them. */ + function movedWords(result) { + const changes = isCount(result.changes) ? plural(result.changes, "change") : ""; + const entities = isCount(result.entities) && result.entities ? `touching ${plural(result.entities, "entity", "entities")}` : ""; + return [changes, entities].filter(Boolean).join(" "); + } + + function remotesPanel() { + const resource = read("remotes"); + if (resource.missing) return null; + if (!resource.data) return resource.error ? h("section", { class: "section" }, readFailure("remotes")) : null; + const remotes = array(resource.data.remotes).filter((remote) => remote && typeof remote.name === "string"); + const move = state.remote; + const head = h("div", { class: "section-head" }, h("h2", {}, "Remotes", remotes.length ? h("span", { class: "count" }, count(remotes.length)) : null)); + if (!remotes.length) return h("section", { class: "section" }, head, h("p", { class: "gap" }, "This repository is not shared anywhere yet.")); + const done = move.result; + const doneTitle = !done ? "" : done.outcome === "pushed" ? `Pushed to ${done.remote}` : done.outcome === "pulled" ? `Pulled from ${done.remote}` : done.outcome === "fetched" ? `Fetched from ${done.remote}` : done.kind === "push" ? `Nothing to push to ${done.remote}` : done.kind === "fetch" ? `Nothing to fetch from ${done.remote}` : `Nothing to pull from ${done.remote}`; + return h("section", { class: "section" }, head, + resource.error ? readFailure("remotes") : null, + move.unknown ? h("div", { class: "message message-warn section-tight", role: "alert" }, h("div", {}, h("strong", {}, "Kin stopped answering"), h("p", {}, "It may have finished. This page will not send it again. The remotes below show where things stand now.")), + h("div", { class: "page-actions" }, button("Read the remotes again", () => refresh(["remotes", "branches", "changes", "working"]), { class: "button button-small" }), + button("I've checked", () => { move.unknown = false; move.error = null; forgetWorkReceipt("remote"); paint("branches-remotes"); }, { class: "button button-small button-quiet", disabled: Boolean(resource.error || resource.busy) })), operationDetails(move.operationId)) : null, + move.error && !move.unknown ? h("div", { class: "message message-bad section-tight", role: "alert" }, h("div", {}, h("strong", {}, "Nothing moved"), h("p", {}, move.error)), + button("Dismiss", () => { move.error = null; paint("branches-remotes"); }, { class: "button button-small button-quiet" })) : null, + done ? h("div", { class: `message message-${done.outcome === "nothing_to_send" ? "warn" : "good"} section-tight`, role: "status" }, h("div", {}, h("strong", {}, doneTitle), + movedWords(done) && done.outcome !== "nothing_to_send" ? h("p", {}, `${movedWords(done)}.`) : text(done.summary) ? h("p", {}, done.summary) : null, + done.kind === "fetch" && text(done.into_branch) ? h("p", {}, `The remote's work is on ${done.into_branch}, ready to review and merge.`) : null, + operationDetails(done.operation_id || move.operationId)), + h("div", { class: "page-actions" }, done.kind === "fetch" && text(done.into_branch) && (can("merge_preview") || can("merge")) ? button("Preview merge", () => previewRemoteMerge(done.into_branch), { class: "button button-small" }) : null, + button("Dismiss", () => { move.result = null; paint("branches-remotes"); }, { class: "button button-small button-quiet" }))) : null, + h("ul", { class: "list list-card" }, remotes.map((remote) => { + const standing = remote.standing || {}; + const git = remote.kind === "git"; + const since = standing.since_git; + // A Git remote is where the project came from; Kin says what has changed here since, and what publishing there needs. + const said = git ? [isCount(since?.changes) && since.changes ? `${plural(since.changes, "change")}${isCount(since.entities) && since.entities ? ` touching ${plural(since.entities, "entity", "entities")}` : ""} here since Kin last took from Git.` : "", text(remote.unavailable_reason)].filter(Boolean).join(" ") + : text(standing.summary) || text(remote.unavailable_reason) || ""; + // A push or pull that has nothing to move, or that the server says cannot run, is not offered as if it could. + const nothingToSend = ["nothing_to_send", "blocked"].includes(text(standing.push?.state)); + const nothingToBring = ["nothing_to_bring", "blocked"].includes(text(standing.pull?.state)); + const resolution = text(standing.pull?.resolve); + const resolutionBranch = text(standing.pull?.resolve_branch); + const busy = Boolean(resource.error || move.busy || move.unknown || workspaceWriteBusy() || state.commit.busy || state.restore.busy); + return h("li", { class: "branch-row" }, + h("div", {}, h("div", { class: "branch-name" }, remote.name, h("span", { class: "pill" }, git ? "Git" : "Kin"), remote.default ? h("span", { class: "pill" }, "Default") : null, + STANDING_WORD[text(standing.state)] ? h("span", { class: `state${["ahead", "behind"].includes(standing.state) ? " state-warn" : standing.state === "diverged" || standing.state === "blocked" ? " state-bad" : ""}` }, STANDING_WORD[standing.state]) : null), + said ? h("div", { class: "branch-meta" }, said) : null, + text(remote.pull_unavailable_reason) && !said.includes(text(remote.pull_unavailable_reason)) ? h("div", { class: "branch-meta" }, remote.pull_unavailable_reason) : null, + ["fetch_then_merge", "merge"].includes(resolution) ? h("div", { class: "branch-meta" }, resolution === "fetch_then_merge" ? "Both sides have work. Bring the remote's branch in beside this one, then review and merge it." : "The remote's work is ready on a branch here. Preview its merge into this branch.") : null), + h("div", { class: "branch-actions" }, + resolution === "fetch_then_merge" && remote.can_fetch && can("remote_fetch") ? button(move.busy === `fetch:${remote.name}` ? "Fetching…" : "Fetch for review", () => moveRemote("fetch", remote), { class: "button button-small button-primary", disabled: busy }) : null, + resolution === "merge" && resolutionBranch && (can("merge_preview") || can("merge")) ? button("Preview merge", () => previewRemoteMerge(resolutionBranch), { class: "button button-small button-primary", disabled: busy }) : null, + remote.can_pull && can("remote_pull") && !resolution ? button(move.busy === `pull:${remote.name}` ? "Pulling…" : "Pull", () => moveRemote("pull", remote), { class: `button button-small${standing.state === "behind" ? " button-primary" : ""}`, disabled: busy || nothingToBring }) + : remote.can_pull && !resolution && session()?.capabilities?.remote_pull_checking === true ? button("Checking…", () => {}, { class: "button button-small", disabled: true, title: "Kin is checking whether this branch can update from Git." }) : null, + remote.can_push && can("remote_push") ? button(move.busy === `push:${remote.name}` ? "Pushing…" : "Push", () => moveRemote("push", remote), { class: `button button-small${standing.state === "ahead" ? " button-primary" : ""}`, disabled: busy || nothingToSend }) : null)); + }))); + } + const STANDING_WORD = { up_to_date: "Up to date", ahead: "Ahead", behind: "Behind", diverged: "Diverged", blocked: "Blocked", unknown: "Not known" }; + /** On a narrow window the sidebar folds into one menu. */ function setMenu(open) { state.menuOpen = Boolean(open); diff --git a/crates/kin-cli/src/backend.rs b/crates/kin-cli/src/backend.rs index 8c73f1cea..0f556f208 100644 --- a/crates/kin-cli/src/backend.rs +++ b/crates/kin-cli/src/backend.rs @@ -380,6 +380,26 @@ fn with_daemon_auth( } } +/// Authorize and admit one request to the repo-scoped daemon. +/// +/// A managed daemon refuses a request that lacks this process's runtime +/// identity and the repository it means, before any handler runs, so a bare +/// request from this module carries both beside the bearer token. +async fn admitted_daemon_request( + request: reqwest::RequestBuilder, + daemon_url: &str, + layout: &kin_core::KinLayout, + leaf: &str, +) -> anyhow::Result { + crate::daemon_client::admit_daemon_request( + with_daemon_auth(request, layout), + daemon_url, + layout, + leaf, + ) + .await +} + async fn fetch_daemon_graph( daemon_url: &str, layout: &kin_core::KinLayout, @@ -396,13 +416,17 @@ async fn fetch_daemon_graph( .build() .map_err(|error| format!("build daemon bootstrap client: {error}"))?; - let resp = with_daemon_auth( + let resp = admitted_daemon_request( client.get(format!( "{}/graph/bootstrap", daemon_url.trim_end_matches('/') )), + daemon_url, layout, + "graph bootstrap", ) + .await + .map_err(|error| format!("admit graph bootstrap request: {error:#}"))? .send() .await .map_err(|error| format!("send graph bootstrap request: {error}"))?; @@ -445,14 +469,17 @@ pub async fn require_daemon_update_head( "expected_head": expected_head_id, }); - let resp = with_daemon_auth( + let resp = admitted_daemon_request( client.put(format!( "{}/v2/graph/branches/{}/head", daemon_url.trim_end_matches('/'), branch_name )), + &daemon_url, layout, + "branch head update", ) + .await? .json(&payload) .send() .await?; @@ -488,13 +515,16 @@ pub async fn require_daemon_commit( "branch_name": branch_name, }); - let resp = with_daemon_auth( + let resp = admitted_daemon_request( client.post(format!( "{}/v1/graph/commit", daemon_url.trim_end_matches('/') )), + &daemon_url, layout, + "graph commit", ) + .await? .json(&payload) .send() .await?; @@ -565,6 +595,19 @@ async fn send_serialized_release_request( message: format!("build daemon release client: {error}"), })?; let endpoint = format!("{}/v1/graph/commit", daemon_url.trim_end_matches('/')); + // Admitted once: every attempt resends these exact bytes to this endpoint. + // A refused admission dispatched nothing, and the request stays pending. + let admission = + crate::daemon_client::daemon_admission_headers(daemon_url, layout, "release commit") + .await + .map_err(|error| DaemonReleaseCommitError { + kind: DaemonReleaseFailureKind::Uncertain, + safe_to_abandon: false, + message: format!( + "the daemon did not admit the semantic release request for branch \ + {branch_name}; it remains pending: {error:#}" + ), + })?; let attempts = max_attempts.max(1); let mut backoff = initial_backoff; let mut last_uncertain = String::new(); @@ -574,6 +617,7 @@ async fn send_serialized_release_request( let response = with_daemon_auth( client .post(&endpoint) + .headers(admission.clone()) .header(reqwest::header::CONTENT_TYPE, "application/json") // Clone the already-serialized bytes. Retries must never // regenerate the change, timestamp, ID, or JSON request. @@ -754,13 +798,16 @@ pub async fn require_daemon_graph_mutations( let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build()?; - let resp = with_daemon_auth( + let resp = admitted_daemon_request( client.post(format!( "{}/v1/graph/mutations", daemon_url.trim_end_matches('/') )), + &daemon_url, layout, + "graph mutations", ) + .await? .json(&batch) .send() .await?; @@ -796,17 +843,27 @@ pub async fn get_spine_impact( .timeout(Duration::from_secs(10)) .build()?; - let resp = with_daemon_auth( + // A daemon that would not admit the request answered nothing about the + // spine, which is the unavailable case, never an empty answer. + let request = match admitted_daemon_request( client.get(format!("{}/spine/impact", daemon_url.trim_end_matches('/'))), + &daemon_url, layout, + "spine impact", ) - .query(&[ - ("repo", repo_id), - ("entity", &entity_id.to_string()), - ("depth", &depth.to_string()), - ]) - .send() - .await; + .await + { + Ok(request) => request, + Err(error) => return Ok(SpineQuery::Unavailable(format!("{error:#}"))), + }; + let resp = request + .query(&[ + ("repo", repo_id), + ("entity", &entity_id.to_string()), + ("depth", &depth.to_string()), + ]) + .send() + .await; let status = resp.as_ref().ok().map(|r| r.status().as_u16()); match classify_spine_probe(true, status) { @@ -842,13 +899,23 @@ pub async fn get_spine_xref( .timeout(Duration::from_secs(10)) .build()?; - let resp = with_daemon_auth( + // A daemon that would not admit the request answered nothing about the + // spine, which is the unavailable case, never an empty answer. + let request = match admitted_daemon_request( client.get(format!("{}/spine/xref", daemon_url.trim_end_matches('/'))), + &daemon_url, layout, + "spine xref", ) - .query(&[("repo", repo_id), ("entity", &entity_id.to_string())]) - .send() - .await; + .await + { + Ok(request) => request, + Err(error) => return Ok(SpineQuery::Unavailable(format!("{error:#}"))), + }; + let resp = request + .query(&[("repo", repo_id), ("entity", &entity_id.to_string())]) + .send() + .await; let status = resp.as_ref().ok().map(|r| r.status().as_u16()); match classify_spine_probe(true, status) { @@ -893,12 +960,20 @@ mod tests { first_response: Arc>>, attempt_delays: Arc>>, later_response: Arc>>, + /// Whether each received attempt carried both runtime admission + /// headers a managed daemon checks before this handler would run. + admitted: Arc>>, } async fn release_retry_handler( State(state): State, + headers: axum::http::HeaderMap, body: Bytes, ) -> StatusCode { + state.admitted.lock().unwrap().push( + headers.contains_key(kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER) + && headers.contains_key(kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER), + ); state.received.lock().unwrap().push(body.to_vec()); let request: serde_json::Value = serde_json::from_slice(&body).unwrap(); let change_id = request["change"]["id"].as_str().unwrap().to_string(); @@ -924,12 +999,32 @@ mod tests { .unwrap_or(StatusCode::OK) } + /// A stand-in daemon serving `layout`'s repository: it names the caller's + /// own runtime and that repository on `/runtime/identity`, as a real one + /// does, so the caller's admission of each attempt is the real one. async fn release_retry_server( state: ReleaseRetryServerState, + layout: &kin_core::KinLayout, ) -> (String, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); + let mut identity = kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon") + .expect("the fixture uses its actual runtime namespace"); + identity.repository = Some( + kin_core::runtime_identity::RuntimeRepositoryBinding::for_layout(layout) + .expect("the fixture's actual repository binding") + .identity() + .clone(), + ); + let identity = serde_json::to_value(identity).unwrap(); let app = Router::new() + .route( + "/runtime/identity", + axum::routing::get(move || { + let identity = identity.clone(); + async move { axum::Json(identity) } + }), + ) .route("/v1/graph/commit", post(release_retry_handler)) .with_state(state); let task = tokio::spawn(async move { @@ -1012,7 +1107,7 @@ mod tests { let layout = kin_core::init(repo.path()).unwrap().layout; let state = ReleaseRetryServerState::default(); *state.first_response.lock().unwrap() = Some(StatusCode::INTERNAL_SERVER_ERROR); - let (url, server) = release_retry_server(state.clone()).await; + let (url, server) = release_retry_server(state.clone(), &layout).await; let payload = serialized_release_fixture(); send_serialized_release_request( @@ -1031,6 +1126,11 @@ mod tests { let received = state.received.lock().unwrap(); assert_eq!(received.len(), 2); assert!(received.iter().all(|request| request == &payload)); + assert_eq!( + *state.admitted.lock().unwrap(), + vec![true, true], + "every attempt must carry the runtime admission a managed daemon checks first" + ); } #[tokio::test] @@ -1041,7 +1141,7 @@ mod tests { let max_attempts = 4; let state = ReleaseRetryServerState::default(); *state.attempt_delays.lock().unwrap() = vec![attempt_timeout * 120]; - let (url, server) = release_retry_server(state.clone()).await; + let (url, server) = release_retry_server(state.clone(), &layout).await; let payload = serialized_release_fixture(); send_serialized_release_request( @@ -1087,7 +1187,7 @@ mod tests { let max_attempts = 4; let state = ReleaseRetryServerState::default(); *state.attempt_delays.lock().unwrap() = vec![attempt_timeout * 120, attempt_timeout * 120]; - let (url, server) = release_retry_server(state.clone()).await; + let (url, server) = release_retry_server(state.clone(), &layout).await; let payload = serialized_release_fixture(); send_serialized_release_request( @@ -1132,7 +1232,7 @@ mod tests { let state = ReleaseRetryServerState::default(); *state.first_response.lock().unwrap() = Some(StatusCode::REQUEST_TIMEOUT); *state.later_response.lock().unwrap() = Some(StatusCode::CONFLICT); - let (url, server) = release_retry_server(state.clone()).await; + let (url, server) = release_retry_server(state.clone(), &layout).await; let payload = serialized_release_fixture(); let error = send_serialized_release_request( diff --git a/crates/kin-cli/src/commands/agent_edits.rs b/crates/kin-cli/src/commands/agent_edits.rs new file mode 100644 index 000000000..5c2ab1adc --- /dev/null +++ b/crates/kin-cli/src/commands/agent_edits.rs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! `kin agents record-edit`: what an agent client's edit hook runs after the +//! agent edits code with the client's own tools. +//! +//! Most coding agents change code with their own edit tools and never call a +//! Kin write, so Kin sees the edit with no one's name on it. A client that +//! runs paired hooks around each edit hands them the operation id and what +//! the edit will be. This command +//! reads those on stdin and tells the repository's daemon which session made +//! the edit and what it replaced. The daemon attributes the entities the edit +//! changed only once it has confirmed, against the content it admitted, that +//! the edit was all that changed there. +//! +//! It runs after every edit an agent makes, so it does as little as it can. +//! It reaches only a daemon that is already serving the repository, and never +//! starts one. Before execution it waits for one local preparation request; +//! after execution it sends one request and lets attribution finish behind +//! the answer. It prints nothing, and it never fails: a hook that failed would +//! interrupt the agent over something that is not the agent's concern. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::{json, Value}; + +/// The clients whose edit hook this reads, as `kin setup` names them. +pub const EDIT_HOOK_CLIENTS: [&str; 1] = ["claude"]; + +/// The arguments after the launcher that make a hook Kin's edit hook. +pub const EDIT_HOOK_ARGUMENTS: &str = "agents record-edit --client claude"; + +/// How long the request may take before the hook gives up on it. +const SEND_BUDGET: Duration = Duration::from_millis(1500); +/// How long a caller that asked for the outcome waits for it. +const WAIT_BUDGET: Duration = Duration::from_secs(20); + +/// The name a client's sessions give Kin when they connect, so an edit and a +/// connection of the same client read as one agent. +fn client_name(client: &str) -> &'static str { + match client { + "claude" => "claude-code", + _ => "unknown", + } +} + +fn text<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn replacement(edit: &Value) -> Option { + Some(json!({ + "old_text": text(edit, "old_string")?, + "new_text": text(edit, "new_string")?, + "replace_all": edit.get("replace_all").and_then(Value::as_bool).unwrap_or(false), + })) +} + +/// What a hook's input says was edited: where, and the claim to send. `Err` +/// says in words why the input is not an edit this reads. +/// +/// Claude Code runs paired `PreToolUse` and `PostToolUse` hooks with the +/// tool's name, input and operation id. Its +/// `Edit` replaces one text with another, `MultiEdit` makes several such +/// replacements in order, and `Write` writes the whole content. +pub fn claim_from_hook(client: &str, raw: &str) -> Result<(PathBuf, Value), String> { + let input: Value = serde_json::from_str(raw) + .map_err(|error| format!("the hook input is not JSON: {error}"))?; + if let Some(event) = text(&input, "hook_event_name") { + if !matches!(event, "PreToolUse" | "PostToolUse") { + return Err(format!( + "the hook ran on {event}, not before or after a tool" + )); + } + } + let tool = text(&input, "tool_name").ok_or("the hook input names no tool")?; + let tool_input = input + .get("tool_input") + .ok_or("the hook input carries no tool input")?; + let session = text(&input, "session_id") + .map(str::trim) + .filter(|session| !session.is_empty()) + .ok_or("the hook input names no session")?; + let place = text(tool_input, "file_path") + .map(str::trim) + .filter(|place| !place.is_empty()) + .ok_or("the tool input names nothing it edited")?; + let mut unit = PathBuf::from(place); + if unit.is_relative() { + let cwd = + text(&input, "cwd").ok_or("the tool input names a relative place and no folder")?; + unit = Path::new(cwd).join(unit); + } + let mut claim = json!({ + "client": client_name(client), + "session": session, + "phase": if text(&input, "hook_event_name") == Some("PreToolUse") { "before" } else { "after" }, + "tool_use_id": text(&input, "tool_use_id"), + "unit": unit.to_str().ok_or("the edited place is not readable text")?, + }); + match tool { + "Edit" => { + claim["edits"] = + json!([replacement(tool_input).ok_or("the edit carries no old and new text")?]); + } + "MultiEdit" => { + let edits: Option> = tool_input + .get("edits") + .and_then(Value::as_array) + .ok_or("the edit carries no replacements")? + .iter() + .map(replacement) + .collect(); + claim["edits"] = + Value::Array(edits.ok_or("a replacement carries no old and new text")?); + } + "Write" => { + claim["content"] = + json!(text(tool_input, "content").ok_or("the write carries no content")?); + } + other => return Err(format!("{other} is not a tool that edits code")), + } + Ok((unit, claim)) +} + +/// Tell the daemon serving the repository that holds `unit` about `claim`. +/// `Ok(None)` when it was sent and no outcome was asked for. +async fn send(unit: &Path, claim: &Value, wait: bool) -> Result, String> { + let folder = unit.parent().ok_or("the edit names no folder")?; + let layout = kin_core::KinLayout::discover(folder).ok_or("the edit is in no Kin repository")?; + if !kin_core::layout::store_daemon_is_running(layout.root()) { + return Err("no Kin daemon is serving this repository".into()); + } + let port = kin_daemon_spawn::read_reported_port(layout.root()) + .ok_or("the repository's daemon has published no port")?; + let client = crate::daemon_client::DaemonClient::from_base_url_for_layout( + format!("http://127.0.0.1:{port}"), + &layout, + ) + .map_err(|error| format!("{error:#}"))?; + let before = claim["phase"] == "before"; + let budget = if wait && !before { + WAIT_BUDGET + } else { + SEND_BUDGET + }; + match tokio::time::timeout(budget, client.record_agent_edit(claim, wait || before)).await { + Ok(Ok(outcome)) => Ok(wait.then_some(outcome)), + Ok(Err(error)) => Err(format!("{error:#}")), + Err(_) => Err("the daemon did not take the edit in time".into()), + } +} + +/// Run one edit hook: read its input and tell the daemon. Returns what to +/// print, which is nothing unless `wait` asked for the outcome. Why it did +/// nothing goes to stderr, which a client does not show for a hook that +/// succeeded. +pub async fn record_edit(client: &str, raw: &str, wait: bool) -> Option { + let sent = match claim_from_hook(client, raw) { + Ok((unit, claim)) => send(&unit, &claim, wait).await, + Err(why) => Err(why), + }; + match sent { + Ok(outcome) => outcome.map(|outcome| outcome.to_string()), + Err(why) => { + eprintln!("kin agents record-edit: {why}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_edit_a_multi_edit_and_a_write_become_claims() { + let (unit, claim) = claim_from_hook( + "claude", + r#"{"session_id":"s-1","tool_use_id":"tool-1","cwd":"/work/app","hook_event_name":"PostToolUse", + "tool_name":"Edit","tool_input":{"file_path":"/work/app/shapes.py", + "old_string":"return w * h","new_string":"return abs(w * h)"}, + "tool_response":{"filePath":"/work/app/shapes.py"}}"#, + ) + .unwrap(); + assert_eq!(unit, PathBuf::from("/work/app/shapes.py")); + assert_eq!( + claim, + json!({ + "client": "claude-code", + "session": "s-1", + "phase": "after", + "tool_use_id": "tool-1", + "unit": "/work/app/shapes.py", + "edits": [{ + "old_text": "return w * h", + "new_text": "return abs(w * h)", + "replace_all": false, + }], + }) + ); + + let (_, many) = claim_from_hook( + "claude", + r#"{"session_id":"s-1","cwd":"/work/app","tool_name":"MultiEdit", + "tool_input":{"file_path":"shapes.py","edits":[ + {"old_string":"a","new_string":"b"}, + {"old_string":"c","new_string":"d","replace_all":true}]}}"#, + ) + .unwrap(); + assert_eq!(many["unit"], "/work/app/shapes.py", "placed in its folder"); + assert_eq!(many["edits"].as_array().unwrap().len(), 2); + assert_eq!(many["edits"][1]["replace_all"], true); + + let (_, whole) = claim_from_hook( + "claude", + r#"{"session_id":"s-1","tool_name":"Write", + "tool_input":{"file_path":"/work/app/new.py","content":"x = 1\n"}}"#, + ) + .unwrap(); + assert_eq!(whole["content"], "x = 1\n"); + assert!(whole.get("edits").is_none()); + } + + #[test] + fn anything_that_is_not_an_edit_is_refused_in_words() { + for (raw, why) in [ + ("not json", "not JSON"), + ( + r#"{"session_id":"s","tool_name":"Bash","tool_input":{"file_path":"/a","command":"ls"}}"#, + "not a tool that edits code", + ), + ( + r#"{"session_id":"s","hook_event_name":"PostToolUseFailure","tool_name":"Edit","tool_input":{}}"#, + "not before or after a tool", + ), + ( + r#"{"tool_name":"Edit","tool_input":{"file_path":"/a","old_string":"a","new_string":"b"}}"#, + "no session", + ), + ( + r#"{"session_id":"s","tool_name":"Edit","tool_input":{"file_path":"/a"}}"#, + "no old and new text", + ), + ( + r#"{"session_id":"s","tool_name":"Edit","tool_input":{"file_path":"a.py","old_string":"a","new_string":"b"}}"#, + "no folder", + ), + ] { + let refused = claim_from_hook("claude", raw).unwrap_err(); + assert!(refused.contains(why), "{refused}"); + } + } +} diff --git a/crates/kin-cli/src/commands/auth.rs b/crates/kin-cli/src/commands/auth.rs index 79621169e..49bf3e0b5 100644 --- a/crates/kin-cli/src/commands/auth.rs +++ b/crates/kin-cli/src/commands/auth.rs @@ -301,7 +301,15 @@ fn store_credential(base_url: &str, credential: &StoredCredential) -> Result<()> Ok(()) } -fn load_credential(base_url: &str, allow_keyring: bool) -> Result> { +/// The stored credential for `base_url`. `may_prompt` says whether an +/// encrypted credential may ask for its passphrase: a server that answers a +/// page has nobody to answer that prompt, so it reads only what opens without +/// one. +fn load_credential( + base_url: &str, + allow_keyring: bool, + may_prompt: bool, +) -> Result> { let key = account_key(base_url); if allow_keyring && keyring_enabled() { if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &key) { @@ -322,6 +330,9 @@ fn load_credential(base_url: &str, allow_keyring: bool) -> Result Result { Ok(trimmed.to_string()) } +/// The saved bearer token for `base_url` when it opens without asking anyone: +/// a plaintext credential file. The platform keyring and an encrypted file +/// both can stop and wait for a person, so neither is read. +pub(crate) fn saved_bearer_token_without_prompt(base_url: &str) -> Option { + kin_remote::http_transport::validate_credential_url(base_url).ok()?; + load_credential(base_url, false, false) + .ok() + .flatten() + .map(|credential| credential.token) +} + pub(crate) fn load_saved_bearer_token(base_url: &str) -> Option { kin_remote::http_transport::validate_credential_url(base_url).ok()?; - load_credential(base_url, true) + load_credential(base_url, true, true) .ok() .flatten() .map(|credential| credential.token) @@ -639,7 +661,7 @@ pub(crate) fn has_stored_credential(base_url: &str) -> bool { } pub(crate) fn default_cli_actor_id(base_url: &str) -> String { - let identity = load_credential(base_url, false) + let identity = load_credential(base_url, false, true) .ok() .flatten() .map(|credential| credential.user_email) @@ -817,7 +839,7 @@ pub(crate) fn stored_credential_provider(base_url: &str) -> Option { pub async fn status(base_url: Option) -> Result<()> { let base_url = normalized_base_url(base_url); - match load_credential(&base_url, true)? { + match load_credential(&base_url, true, true)? { Some(credential) => { println!("KinLab auth is configured for {}.", base_url); println!(" User: {}", credential.user_email); @@ -846,7 +868,7 @@ pub async fn status(base_url: Option) -> Result<()> { pub async fn whoami(base_url: Option) -> Result<()> { let base_url = normalized_base_url(base_url); kin_remote::http_transport::validate_credential_url(&base_url).map_err(anyhow::Error::msg)?; - let credential = load_credential(&base_url, true)? + let credential = load_credential(&base_url, true, true)? .ok_or_else(|| anyhow::anyhow!("no KinLab auth credential stored for {}", base_url))?; let response = credential_http_client()? .get(format!("{}/api/session", base_url)) @@ -943,7 +965,7 @@ async fn revoke_saved_session(base_url: &str) -> Result { "network revocation skipped: {reason}" ))); } - let revocation = match load_credential(base_url, true)? { + let revocation = match load_credential(base_url, true, true)? { None => None, Some(credential) => Some( match credential_http_client()? @@ -1029,7 +1051,7 @@ mod tests { .expect("local logout must not wait on an unsafe endpoint") .unwrap(); assert!(!legacy.exists()); - assert!(load_credential(other, false).unwrap().is_some()); + assert!(load_credential(other, false, true).unwrap().is_some()); assert_eq!( listener.accept().unwrap_err().kind(), std::io::ErrorKind::WouldBlock @@ -1181,7 +1203,7 @@ mod tests { }); logout(Some(base.clone())).await.unwrap(); server.join().unwrap(); - assert!(load_credential(&base, false).unwrap().is_none()); + assert!(load_credential(&base, false, true).unwrap().is_none()); } #[tokio::test] @@ -1244,7 +1266,7 @@ mod tests { "the fixture has to write the plaintext tier, or the assertion below proves nothing" ); assert!( - load_credential(base_url, false) + load_credential(base_url, false, true) .expect("read back what was stored") .is_some(), "and the product has to be able to read it, or this is not the tier under test" @@ -1253,7 +1275,7 @@ mod tests { delete_credential(base_url).expect("logout removes the local credential"); assert!( - load_credential(base_url, false) + load_credential(base_url, false, true) .expect("read after the logout") .is_none(), "a logout that leaves a credential a later load reads has not logged anybody out" @@ -1282,7 +1304,7 @@ mod tests { delete_credential(mine).expect("log out of mine only"); assert!( - load_credential(theirs, false) + load_credential(theirs, false, true) .expect("read the other account") .is_some(), "logging out of one account must not remove another account's credential" diff --git a/crates/kin-cli/src/commands/bench_meta.rs b/crates/kin-cli/src/commands/bench_meta.rs index 37ec4e267..e955b1325 100644 --- a/crates/kin-cli/src/commands/bench_meta.rs +++ b/crates/kin-cli/src/commands/bench_meta.rs @@ -26,6 +26,8 @@ pub(crate) struct BenchMeta { kin_commit: &'static str, kin_dirty: bool, kin_source_known: bool, + kin_target: &'static str, + kin_build_profile: &'static str, dependency_provenance: &'static str, } @@ -230,6 +232,8 @@ pub(crate) fn build_meta() -> Result { kin_commit: build.sha, kin_dirty: build.dirty, kin_source_known: build.source_known, + kin_target: build.target, + kin_build_profile: build.build_profile, dependency_provenance: build.dependency_provenance, }) } diff --git a/crates/kin-cli/src/commands/branch.rs b/crates/kin-cli/src/commands/branch.rs index dec6e6690..93d148c74 100644 --- a/crates/kin-cli/src/commands/branch.rs +++ b/crates/kin-cli/src/commands/branch.rs @@ -123,6 +123,12 @@ impl BranchRequest { pub struct BranchListEntry { pub name: RefName, pub target: RefTarget, + /// The semantic change `target` resolves to. A branch imported from Git + /// targets its Git commit, which resolves through the commit's alias; a + /// native branch targets its change directly. Absent when the authority + /// cannot resolve the target to an admitted change. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub change_id: Option, pub active: bool, pub default: bool, } diff --git a/crates/kin-cli/src/commands/commit.rs b/crates/kin-cli/src/commands/commit.rs index dae4ee58a..a735d7cf9 100644 --- a/crates/kin-cli/src/commands/commit.rs +++ b/crates/kin-cli/src/commands/commit.rs @@ -7,7 +7,12 @@ use super::commit_progress::{ daemon_death_explanation, PhaseTail, AUTHORITY_NOT_GIT_NOTE, DETACHED_HEAD_NOTE, }; -pub async fn run(message: Option, quiet: bool, amend: bool) -> Result<()> { +pub async fn run( + message: Option, + quiet: bool, + amend: bool, + entities: Vec, +) -> Result<()> { let layout = crate::commands::require_repository_layout()?; // Before attribution, before the daemon is resolved, before anything this // command does for itself. The ambient reconcile tick and this commit both @@ -21,7 +26,7 @@ pub async fn run(message: Option, quiet: bool, amend: bool) -> Result<() // exists. let _announced = CommitAnnouncement::announce(layout.root()); - let result = run_daemon_commit(&layout, message.as_deref(), quiet, amend).await?; + let result = run_daemon_commit(&layout, message.as_deref(), quiet, amend, &entities).await?; if !quiet { println!( "{}", @@ -36,10 +41,24 @@ pub async fn run(message: Option, quiet: bool, amend: bool) -> Result<() retained_parse(&layout).as_deref(), ) ); + if !entities.is_empty() { + println!("{}", kept_entities_line(entities.len())); + } } Ok(()) } +/// What a commit of chosen entities says beneath its summary: that the work it +/// did not choose is still uncommitted, which is the half a reader cannot see +/// in the counts above. +fn kept_entities_line(kept: usize) -> String { + let entities = if kept == 1 { "entity" } else { "entities" }; + format!( + "Committed the changes of {kept} chosen {entities}; every other uncommitted change stays \ + uncommitted, exactly as it was." + ) +} + /// What this store's cross-file sweep still owes, when it owes anything. /// /// `kin commit` printed `(0 entities, 0 relations, 2 artifacts)` for a fully @@ -284,9 +303,20 @@ pub(super) async fn commit_for_app( layout: &kin_core::KinLayout, message: &str, operation_id: kin_model::OperationId, + entities: &[String], ) -> AppCommit { let _announced = CommitAnnouncement::announce(layout.root()); - match dispatch_commit(layout, Some(message), true, false, operation_id, None).await { + match dispatch_commit( + layout, + Some(message), + true, + false, + operation_id, + None, + entities, + ) + .await + { Ok(result) => AppCommit::Committed { change_id: result.change_id, }, @@ -306,6 +336,7 @@ async fn run_daemon_commit( message: Option<&str>, quiet: bool, amend: bool, + entities: &[String], ) -> Result { // Created once per CLI invocation so transport retry logic can reuse the // byte-identical repository transaction. @@ -317,6 +348,7 @@ async fn run_daemon_commit( amend, operation_id, commit_session_header(), + entities, ) .await .map_err(CommitStop::into_error) @@ -369,6 +401,7 @@ async fn dispatch_commit( amend: bool, operation_id: kin_model::OperationId, session: Option, + entities: &[String], ) -> std::result::Result { let timestamp = kin_model::Timestamp::now(); let landed = send_commit( @@ -379,6 +412,7 @@ async fn dispatch_commit( operation_id, session, ×tamp, + entities, ) .await?; note_change_in_registry(layout, ×tamp); @@ -410,6 +444,7 @@ fn note_change_in_registry(layout: &kin_core::KinLayout, at: &kin_model::Timesta }); } +#[allow(clippy::too_many_arguments)] async fn send_commit( layout: &kin_core::KinLayout, message: Option<&str>, @@ -418,6 +453,7 @@ async fn send_commit( operation_id: kin_model::OperationId, session: Option, timestamp: &kin_model::Timestamp, + entities: &[String], ) -> std::result::Result { let PreparedCommit { author, @@ -427,21 +463,44 @@ async fn send_commit( } = prepare_commit(layout, amend) .await .map_err(CommitStop::NotDispatched)?; - let mut payload = serde_json::json!({ - "operation_id": operation_id, - "timestamp": timestamp, - "message": message, - "author": author, - }); - if let Some(expected_head) = expected_head { - payload["amend"] = serde_json::json!(true); - payload["expected_head"] = serde_json::json!(expected_head); - } + let (route, payload) = if entities.is_empty() { + let mut payload = serde_json::json!({ + "operation_id": operation_id, + "timestamp": timestamp, + "message": message, + "author": author, + }); + if let Some(expected_head) = expected_head { + payload["amend"] = serde_json::json!(true); + payload["expected_head"] = serde_json::json!(expected_head); + } + ("commands/commit", payload) + } else { + // Only the chosen entities, through the route that leaves the rest of + // the working copy uncommitted. An amend replaces a whole change and + // never reaches here with entities. + let message = message + .map(str::trim) + .filter(|message| !message.is_empty()) + .ok_or_else(|| { + CommitStop::NotDispatched(anyhow::anyhow!( + "a commit of chosen entities needs a message" + )) + })?; + let request = super::entity_work::EntityCommitRequest { + operation_id, + timestamp: timestamp.clone(), + message: message.to_string(), + author, + entities: entities.to_vec(), + session_id: session.clone(), + }; + let payload = serde_json::to_value(&request) + .map_err(|error| CommitStop::NotDispatched(error.into()))?; + ("commands/commit/entities", payload) + }; let mut request = client - .post(format!( - "{}/commands/commit", - daemon_url.trim_end_matches('/') - )) + .post(format!("{}/{route}", daemon_url.trim_end_matches('/'))) .json(&payload); // Read from the layout this commit names. The local app commits for a // repository that is not its working directory, and for `kin commit` the @@ -452,6 +511,11 @@ async fn send_commit( if let Some(session_id) = session { request = request.header("X-Kin-Session", session_id); } + // Not dispatched yet, so a refused admission is a commit that did not run. + let request = + crate::daemon_client::admit_daemon_request(request, &daemon_url, layout, "commit") + .await + .map_err(CommitStop::NotDispatched)?; let kin_root = layout.root().to_path_buf(); // Opened before the request so the first phase the daemon enters is already @@ -543,7 +607,7 @@ fn commit_reply_deadline() -> Option { /// used a client of its own and sent nothing, so a read-only session could /// commit through the CLI. Only a well-formed id is sent: a stale or malformed /// value left in a shell must not turn every commit into a bad request. -fn commit_session_header() -> Option { +pub(super) fn commit_session_header() -> Option { let value = std::env::var("KIN_SESSION_ID").ok()?; let value = value.trim(); uuid::Uuid::parse_str(value).ok()?; @@ -577,6 +641,9 @@ fn build_commit_client(deadline: Option) -> reqwest::Result /// `HTTP 500 Internal Server Error: Core error: ...` on 0.5.52, and the only /// exit that read found was deleting the store (FIR-2664). fn commit_refusal_message(body: &str) -> Option { + if let Some(sentence) = super::entity_work::refusal_message(body) { + return Some(sentence); + } let parsed: serde_json::Value = serde_json::from_str(body).ok()?; let kind = parsed.get("error")?.as_str()?; if !matches!( @@ -1229,6 +1296,30 @@ mod tests { assert!(commit_refusal_message("daemon is starting").is_none()); } + /// A commit of chosen entities is refused in the daemon's own sentence, and + /// a landed one says the rest of the work is still uncommitted. + #[test] + fn an_entity_commit_refusal_is_its_own_sentence_and_a_landed_one_says_what_stayed() { + let body = serde_json::json!({ + "error": super::super::entity_work::ENTITY_REFUSED, + "message": "left and right were changed in the same place, so Kin cannot commit one \ + without the other.", + }) + .to_string(); + assert_eq!( + commit_refusal_message(&body).as_deref(), + Some( + "left and right were changed in the same place, so Kin cannot commit one without \ + the other." + ) + ); + assert!(!refuses_nothing_to_commit(&body)); + let line = kept_entities_line(2); + assert!(line.contains("2 chosen entities"), "{line}"); + assert!(line.contains("stays uncommitted"), "{line}"); + assert!(kept_entities_line(1).contains("1 chosen entity;")); + } + /// A commit run inside a Kin session names that session to the daemon, which /// is what lets the daemon refuse it for a session that declared itself /// read-only, and the refusal reaches the caller as its own sentence. diff --git a/crates/kin-cli/src/commands/daemon.rs b/crates/kin-cli/src/commands/daemon.rs index 69961321a..56f4f8e3b 100644 --- a/crates/kin-cli/src/commands/daemon.rs +++ b/crates/kin-cli/src/commands/daemon.rs @@ -827,6 +827,574 @@ async fn request_worker_retirement( .map_err(|_| "retirement deadline exhausted before the answer completed".to_string())? } +// ── replacement ───────────────────────────────────────────────────────────── + +/// How often a replacement looks again at the daemon it is waiting on. +const REPLACEMENT_POLL: Duration = Duration::from_millis(100); + +/// How often a replacement asks a daemon it is waiting on what still holds it. +const REPLACEMENT_REASK: Duration = Duration::from_secs(1); + +/// The release that replaces an older daemon, as the one-Kin rule +/// ([`kin_core::runtime_identity::succession`]) judges it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ReplacementSuccessor { + client: kin_core::runtime_identity::SuccessionClient, + /// The managed pair on disk, when it was read. A client that is not it + /// retires nothing. + installed: Option, + /// The managed home whose binaries, and whose update backups, are older + /// generations of the managed install. + managed_home: Option, +} + +impl ReplacementSuccessor { + /// A release this update installs: a clean release build of `version` at + /// `commit` for this target. + pub(crate) fn new(version: &str, commit: &str) -> Result { + let version = version.trim().trim_start_matches('v'); + semver::Version::parse(version).with_context(|| { + format!("replacement successor version {version:?} is not a release version") + })?; + let current = kin_core::runtime_identity::RuntimeIdentity::current(); + Ok(Self { + client: kin_core::runtime_identity::SuccessionClient { + identity: kin_core::runtime_identity::RuntimeIdentity { + version: version.to_string(), + commit_sha: commit.trim().to_ascii_lowercase(), + source_dirty: false, + source_known: true, + target: current.target, + build_profile: "release".to_string(), + }, + mode: kin_core::runtime_identity::RuntimeMode::Managed, + namespace: None, + }, + installed: None, + managed_home: None, + }) + } + + /// This process, replacing an older daemon before it starts its own. + pub(crate) fn current() -> Result { + Ok(Self { + client: crate::daemon_client::succession::current_succession_client() + .map_err(|error| anyhow::anyhow!(error))?, + installed: crate::daemon_client::succession::installed_runtime(), + managed_home: crate::commands::runtime::managed_home().ok(), + }) + } + + pub(crate) fn replacing_install(mut self, install_root: &Path) -> Self { + self.managed_home = Some(install_root.to_path_buf()); + self + } + + /// The name every client of the replaced release is told. + pub(crate) fn label(&self) -> String { + kin_core::runtime_identity::release_label( + &self.client.identity.version, + &self.client.identity.commit_sha, + ) + } +} + +/// What became of a daemon asked to make way for a successor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ReplacementOutcome { + /// No live daemon of that pid serves the repository. + NotRunning, + /// The daemon is gone and its endpoint is retired. `steps` says what it + /// took beyond asking, and is empty when asking was enough. + Replaced { steps: Vec }, + /// The daemon already runs the successor's release, so nothing was asked. + AlreadySuccessor, + /// Still running at the deadline, with what it was waiting on. Nothing was + /// forced; it was left to finish, and nothing should start beside it. + Waiting(Vec), + /// Never asked to go: a newer release, a development build, or a daemon + /// whose release could not be read. The sentence names its pid and + /// executable. + Refused(String), +} + +/// What a daemon answered a `POST /retire`. +#[derive(Debug)] +enum RetireAnswer { + Accepted(crate::daemon_client::replacement::RetirementReply), + /// 400, 415 or 422: a daemon from before the replacement envelope. + Unreadable, + /// 404: a daemon from before `/retire`. + Absent, +} + +/// The endpoint of the daemon a replacement is talking to. +struct ReplacementPeer { + port: u16, + token: Option, + client: reqwest::Client, +} + +impl ReplacementPeer { + fn new(port: u16, token: Option) -> Result { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(2)) + .build() + .context("could not build the replacement request client")?; + Ok(Self { + port, + token, + client, + }) + } + + fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{path}", self.port) + } + + fn authorized(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match &self.token { + Some(token) => request.bearer_auth(token), + None => request, + } + } + + /// Send `request`, read the whole answer, and keep both inside the + /// caller's deadline. + async fn exchange( + &self, + request: reqwest::RequestBuilder, + deadline: Instant, + ) -> std::result::Result<(reqwest::StatusCode, Vec), String> { + let remaining = remaining_budget(deadline); + if remaining.is_zero() { + return Err("the replacement deadline ran out before the request was sent".to_string()); + } + tokio::time::timeout(remaining.min(Duration::from_secs(5)), async { + let response = request + .send() + .await + .map_err(|error| format!("the request did not reach the daemon: {error}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|error| format!("the daemon's answer did not complete: {error}"))?; + Ok((status, body.to_vec())) + }) + .await + .map_err(|_| "the daemon did not answer in time".to_string())? + } + + /// `POST /retire`, as a replacement naming `successor` or, without one, a + /// plain retirement. + async fn retire( + &self, + identity: &ProcessIdentity, + successor: Option<&str>, + deadline: Instant, + ) -> std::result::Result { + use crate::daemon_client::replacement::{ReplacementClaim, RetirementRequest}; + let body = match successor { + Some(successor) => RetirementRequest::Replacement { + identity: identity.clone(), + replacement: ReplacementClaim { + successor: successor.to_string(), + }, + }, + None => RetirementRequest::Plain(identity.clone()), + }; + let request = self.authorized(self.client.post(self.url("/retire")).json(&body)); + let (status, body) = self.exchange(request, deadline).await?; + match status.as_u16() { + 200..=299 => serde_json::from_slice(&body) + .map(RetireAnswer::Accepted) + .map_err(|error| format!("its retirement answer is unreadable: {error}")), + 400 | 415 | 422 => Ok(RetireAnswer::Unreadable), + 404 => Ok(RetireAnswer::Absent), + 409 | 410 => { + Err("its endpoint does not own the recorded process incarnation".to_string()) + } + _ => Err(format!("its retirement endpoint answered HTTP {status}")), + } + } + + /// `POST /shutdown`, bound to the recorded incarnation. + async fn shutdown( + &self, + identity: &ProcessIdentity, + deadline: Instant, + ) -> std::result::Result<(), String> { + let request = self.authorized(self.client.post(self.url("/shutdown")).json(identity)); + let (status, _) = self.exchange(request, deadline).await?; + match status.as_u16() { + 200..=299 => Ok(()), + 409 | 410 => { + Err("its endpoint does not own the recorded process incarnation".to_string()) + } + _ => Err(format!("its stop endpoint answered HTTP {status}")), + } + } +} + +/// A write the daemon at `pid` or a client of it has open right now, in +/// words. Nothing is stopped under one. +fn live_write_marker(kin_root: &Path, pid: u32) -> Option { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_secs()) + .unwrap_or(0); + if let Some(open) = kin_daemon_spawn::read_open_transaction(kin_root) + .filter(|open| open.pid == pid && open.is_beating(now)) + { + return Some(format!("a write is open: {}", open.summary())); + } + kin_daemon_spawn::read_approaching_commit(kin_root) + .filter(|approaching| approaching.is_fresh(now)) + .map(|_| "a commit is on its way to the daemon".to_string()) +} + +/// What in a retirement answer still holds a replacement asked at +/// `asked_at`: codes when the daemon sends them, otherwise its sentences read +/// through the table of what earlier releases said. A sentence this build +/// does not know holds it. +fn holding_replacement( + reply: &crate::daemon_client::replacement::RetirementReply, + asked_at: Instant, + now: Instant, +) -> Vec { + use crate::daemon_client::replacement::{ + legacy_blocker_code, replacement_wait, ReplacementWait, REPLACEMENT_BACKGROUND_GRACE, + }; + let codes: Vec> = match &reply.blockers { + Some(codes) if codes.len() == reply.blocked_by.len() => { + codes.iter().cloned().map(Some).collect() + } + _ => reply + .blocked_by + .iter() + .map(|sentence| legacy_blocker_code(sentence).map(str::to_string)) + .collect(), + }; + reply + .blocked_by + .iter() + .zip(codes) + .filter(|(_, code)| { + match code + .as_deref() + .map_or(ReplacementWait::Always, replacement_wait) + { + ReplacementWait::Always => true, + ReplacementWait::Bounded => { + now.saturating_duration_since(asked_at) < REPLACEMENT_BACKGROUND_GRACE + } + ReplacementWait::Never => false, + } + }) + .map(|(sentence, _)| sentence.clone()) + .collect() +} + +fn incarnation_gone(target: &ProcessIdentity) -> bool { + matches!(process_identity_is_current(target), Ok(false)) +} + +/// How a replacement's wait for one daemon ended. +enum ReplacementWait { + Gone, + Held(Vec), +} + +fn held_or_exiting(waiting_on: Vec) -> ReplacementWait { + if waiting_on.is_empty() { + ReplacementWait::Held(vec!["it has not finished exiting yet".to_string()]) + } else { + ReplacementWait::Held(waiting_on) + } +} + +/// Ask the daemon at `pid` to make way for `successor`, and wait until it has +/// or `deadline` passes. +/// +/// One request per call, in the newest form the daemon reads: +/// +/// - a daemon that takes a replacement finishes its writes, its pending +/// publication and its first scan, gives background passes a short grace, +/// tells its clients to restart, flushes and exits by itself; +/// - a daemon from 0.8.0 to 0.8.6 is asked to retire and asked again what +/// holds it; once only its own clients, or background work past the grace, +/// do, and no write is open, it is stopped through its incarnation-bound +/// `/shutdown`; +/// - an older daemon, with no `/retire`, gets that `/shutdown` once no write +/// is open. +/// +/// A daemon still starting is waited for until it publishes. A newer release, +/// a development build, or a daemon whose release cannot be read is never +/// asked, and the refusal names its pid and executable. Signals go only +/// through the recorded-executable escalation every stop uses, and only with +/// no write open. Nothing here starts a daemon. +pub(crate) async fn retire_for_replacement( + kin_root: &Path, + pid: u32, + successor: &ReplacementSuccessor, + deadline: Instant, +) -> Result { + let Some(mut target) = attributed_worker_identity(kin_root, pid)? else { + return Ok(ReplacementOutcome::NotRunning); + }; + while target.is_starting() && !endpoint_published_by(kin_root, &target) { + if incarnation_gone(&target) { + return Ok(ReplacementOutcome::NotRunning); + } + if Instant::now() >= deadline { + return Ok(ReplacementOutcome::Waiting( + vec![STILL_STARTING.to_string()], + )); + } + tokio::time::sleep(remaining_budget(deadline).min(REPLACEMENT_POLL)).await; + } + if target.is_starting() { + let Some(published) = attributed_worker_identity(kin_root, pid)? else { + return Ok(ReplacementOutcome::NotRunning); + }; + target = published; + } + let (recorded_pid, recorded_port) = repo_daemon_recorded_endpoint(kin_root); + anyhow::ensure!( + recorded_pid == Some(pid), + "worker endpoint changed before the replacement request for pid {pid}" + ); + let port = recorded_port + .with_context(|| format!("worker pid {pid} has no recorded port to ask it to make way"))?; + let peer = ReplacementPeer::new(port, worker_auth_token(kin_root))?; + let release = match crate::daemon_client::succession::read_peer_release( + &peer.url(""), + pid, + successor.managed_home.as_deref(), + remaining_budget(deadline), + ) + .await + { + Ok(release) => release, + Err(error) => { + return Ok(ReplacementOutcome::Refused(format!( + "Kin daemon pid {pid} ({}) was left running: its release could not be read: {error}", + crate::daemon_client::process_executable_path(pid) + .unwrap_or_else(|| "executable unknown".to_string()) + ))) + } + }; + match kin_core::runtime_identity::succession( + &successor.client, + successor.installed.as_ref(), + &release, + ) { + kin_core::runtime_identity::Succession::Replace => {} + kin_core::runtime_identity::Succession::Attach => { + return Ok(ReplacementOutcome::AlreadySuccessor) + } + refused => { + return Ok(ReplacementOutcome::Refused( + refused.refusal().unwrap_or_default().to_string(), + )) + } + } + + let label = successor.label(); + let mut steps = Vec::new(); + let answer = peer + .retire(&target, Some(&label), deadline) + .await + .map_err(|error| { + anyhow::anyhow!("daemon pid {pid} did not take the replacement: {error}") + })?; + let wait = match answer { + RetireAnswer::Accepted(reply) if reply.replacing => { + await_replacement(&peer, &target, &label, reply.blocked_by, deadline).await + } + RetireAnswer::Accepted(_) | RetireAnswer::Unreadable => { + replace_retiring_daemon(kin_root, &peer, &target, deadline, &mut steps).await? + } + RetireAnswer::Absent => { + replace_unretirable_daemon(kin_root, &peer, &target, deadline, &mut steps).await + } + }; + Ok(match wait { + ReplacementWait::Gone => { + if let Some(survivor) = + retire_worker_endpoint(kin_root, &StopOutcome::Stopped, &mut steps) + { + steps.push(format!("its recorded endpoint survived: {survivor}")); + } + ReplacementOutcome::Replaced { steps } + } + ReplacementWait::Held(waiting_on) => ReplacementOutcome::Waiting(waiting_on), + }) +} + +/// Wait for a daemon that took the replacement to go by itself, asking again +/// now and then what still holds it, so a wait that runs out names it. +async fn await_replacement( + peer: &ReplacementPeer, + target: &ProcessIdentity, + label: &str, + mut waiting_on: Vec, + deadline: Instant, +) -> ReplacementWait { + let mut next_ask = Instant::now() + REPLACEMENT_REASK; + loop { + if incarnation_gone(target) { + return ReplacementWait::Gone; + } + let now = Instant::now(); + if now >= deadline { + return held_or_exiting(waiting_on); + } + if now >= next_ask { + next_ask = now + REPLACEMENT_REASK; + // The same request again, which changes nothing it has not + // already changed; only the answer is new. + if let Ok(RetireAnswer::Accepted(reply)) = + peer.retire(target, Some(label), deadline).await + { + waiting_on = reply.blocked_by; + } + } + tokio::time::sleep(remaining_budget(deadline).min(REPLACEMENT_POLL)).await; + } +} + +/// Replace a daemon that takes `/retire` but not a replacement. It is asked to +/// retire, which keeps it from idling on, and asked again what holds it. Once +/// only what a replacement does not wait for is left and no write is open, it +/// is stopped through its incarnation. +async fn replace_retiring_daemon( + kin_root: &Path, + peer: &ReplacementPeer, + target: &AttributedStopTarget, + deadline: Instant, + steps: &mut Vec, +) -> Result { + let pid = target.pid(); + let asked_at = Instant::now(); + let mut next_ask = asked_at; + let mut waiting_on = Vec::new(); + loop { + if incarnation_gone(target) { + return Ok(ReplacementWait::Gone); + } + let now = Instant::now(); + if now >= deadline { + return Ok(held_or_exiting(waiting_on)); + } + if now >= next_ask { + next_ask = now + REPLACEMENT_REASK; + match peer.retire(target, None, deadline).await { + Ok(RetireAnswer::Accepted(reply)) => { + let holding = holding_replacement(&reply, asked_at, now); + if !holding.is_empty() { + waiting_on = holding; + } else if reply.blocked_by.is_empty() { + // Nothing holds it, so it is already on its way out. + waiting_on.clear(); + } else if let Some(write) = live_write_marker(kin_root, pid) { + waiting_on = vec![write]; + } else { + steps.push(format!( + "only what a replacement does not wait for held pid {pid} ({}), so it was stopped through its incarnation", + reply.blocked_by.join("; ") + )); + return Ok( + shutdown_and_await(kin_root, peer, target, deadline, steps).await + ); + } + } + Ok(RetireAnswer::Absent) => { + return Ok( + replace_unretirable_daemon(kin_root, peer, target, deadline, steps).await, + ) + } + Ok(RetireAnswer::Unreadable) => { + anyhow::bail!("daemon pid {pid} cannot read a retirement request") + } + Err(_) if incarnation_gone(target) => return Ok(ReplacementWait::Gone), + Err(error) => { + anyhow::bail!("daemon pid {pid} did not take the retirement: {error}") + } + } + } + tokio::time::sleep(remaining_budget(deadline).min(REPLACEMENT_POLL)).await; + } +} + +/// Replace a daemon from before `/retire`: once no write is open, through its +/// incarnation-bound `/shutdown`. +async fn replace_unretirable_daemon( + kin_root: &Path, + peer: &ReplacementPeer, + target: &AttributedStopTarget, + deadline: Instant, + steps: &mut Vec, +) -> ReplacementWait { + loop { + if incarnation_gone(target) { + return ReplacementWait::Gone; + } + let write = live_write_marker(kin_root, target.pid()); + let Some(write) = write else { + return shutdown_and_await(kin_root, peer, target, deadline, steps).await; + }; + if Instant::now() >= deadline { + return ReplacementWait::Held(vec![write]); + } + tokio::time::sleep(remaining_budget(deadline).min(REPLACEMENT_POLL)).await; + } +} + +/// Send the incarnation-bound `/shutdown` once and wait for the daemon to go. +/// A daemon still there at the deadline is escalated only through the +/// recorded-executable check every stop uses, and only with no write open. +async fn shutdown_and_await( + kin_root: &Path, + peer: &ReplacementPeer, + target: &AttributedStopTarget, + deadline: Instant, + steps: &mut Vec, +) -> ReplacementWait { + let pid = target.pid(); + let outcome = match peer.shutdown(target, deadline).await { + Ok(()) => { + steps.push(format!("sent the stop request to pid {pid}")); + loop { + if incarnation_gone(target) { + return ReplacementWait::Gone; + } + if Instant::now() >= deadline { + break; + } + tokio::time::sleep(remaining_budget(deadline).min(REPLACEMENT_POLL)).await; + } + StopOutcome::Timeout + } + Err(_) if incarnation_gone(target) => return ReplacementWait::Gone, + Err(error) => StopOutcome::SignalFailed(error), + }; + if let Some(write) = live_write_marker(kin_root, pid) { + return ReplacementWait::Held(vec![write]); + } + match escalate_if_unstopped(target, outcome, steps) { + StopOutcome::Stopped | StopOutcome::NotRunning => ReplacementWait::Gone, + _ => ReplacementWait::Held(vec![format!( + "it was asked to stop and did not exit{}", + steps + .last() + .map(|step| format!(" ({step})")) + .unwrap_or_default() + )]), + } +} + #[cfg(not(unix))] fn stop_worker_identity( _kin_root: &Path, @@ -836,9 +1404,12 @@ fn stop_worker_identity( stop_identity_graceful(identity, wait) } +/// Stop the supervisor whose endpoint is recorded in `dir`: the managed +/// services directory for a current supervisor, or where an older one +/// recorded it (see [`legacy_supervisor_dir`]). #[cfg(unix)] -fn stop_supervisor_identity(identity: &ProcessIdentity, wait: Duration) -> StopOutcome { - let (recorded_pid, recorded_port) = supervisor_recorded_endpoint(); +fn stop_supervisor_identity(dir: &Path, identity: &ProcessIdentity, wait: Duration) -> StopOutcome { + let (recorded_pid, recorded_port, _) = crate::daemon_client::supervisor_endpoint_in_dir(dir); if recorded_pid != Some(identity.pid()) { return StopOutcome::SignalFailed(format!( "supervisor endpoint changed before cooperative shutdown for pid {}", @@ -856,8 +1427,7 @@ fn stop_supervisor_identity(identity: &ProcessIdentity, wait: Duration) -> StopO .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .or_else(|| { - let path = supervisor_owner_path().with_file_name("supervisor.token"); - std::fs::read_to_string(path) + std::fs::read_to_string(dir.join("supervisor.token")) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) @@ -868,10 +1438,44 @@ fn stop_supervisor_identity(identity: &ProcessIdentity, wait: Duration) -> StopO } #[cfg(not(unix))] -fn stop_supervisor_identity(identity: &ProcessIdentity, wait: Duration) -> StopOutcome { +fn stop_supervisor_identity( + _dir: &Path, + identity: &ProcessIdentity, + wait: Duration, +) -> StopOutcome { stop_identity_graceful(identity, wait) } +/// Where the current supervisor records its endpoint. +fn current_supervisor_dir() -> PathBuf { + supervisor_pid_path() + .parent() + .map(Path::to_path_buf) + .unwrap_or_default() +} + +/// Where a supervisor from 0.8.6 or older recorded its endpoint: beside an +/// explicit `KIN_REGISTRY_PATH`, or in `.kin` under the real home. Current +/// supervisors record theirs in the managed services directory instead, so a +/// reader of only that one never sees an older supervisor still running. +fn legacy_supervisor_dir() -> Option { + legacy_supervisor_dir_from( + std::env::var_os("KIN_REGISTRY_PATH"), + directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()), + ) +} + +/// [`legacy_supervisor_dir`] with the registry path and the real home given. +fn legacy_supervisor_dir_from( + registry_path: Option, + home: Option, +) -> Option { + if let Some(registry) = registry_path.filter(|path| !path.is_empty()) { + return PathBuf::from(registry).parent().map(Path::to_path_buf); + } + home.map(|home| home.join(".kin")) +} + fn attributed_worker_identity(kin_root: &Path, pid: u32) -> Result> { let (recorded_pid, _) = repo_daemon_recorded_endpoint(kin_root); if recorded_pid != Some(pid) { @@ -1159,7 +1763,7 @@ fn stop_supervisor_attributed( wait: Duration, steps: &mut Vec, ) -> StopOutcome { - let outcome = stop_supervisor_identity(identity, wait); + let outcome = stop_supervisor_identity(¤t_supervisor_dir(), identity, wait); escalate_if_unstopped(identity, outcome, steps) } @@ -3389,60 +3993,298 @@ pub(crate) fn stop_install_owned_daemons_for_update(install_root: &Path) -> Resu Ok(()) } -/// Installation ownership is additional evidence, never a replacement for a -/// present endpoint's published incarnation/image proof. Re-observe around the -/// install scan so PID reuse or exec cannot borrow its predecessor's scope. -fn install_owned_stop_target( - install_root: &Path, - pid: u32, - repo_root: Option<&Path>, - deadline: Instant, -) -> Result> { - let Some(owned) = legacy_managed_identity(install_root, pid, repo_root, deadline)? else { - return Ok(None); - }; - let (published, owner_path) = match repo_root { - Some(root) => { - let kin_root = worker_store_root(root); - ( - attributed_worker_identity(&kin_root, pid), - repo_daemon_owner_path(&kin_root), - ) - } - None => (attributed_supervisor_identity(pid), supervisor_owner_path()), - }; - match published { - Ok(Some(published)) => { - anyhow::ensure!( - *published == *owned, - "daemon incarnation changed during install attribution" - ); - #[cfg(unix)] - anyhow::ensure!( - published.expected_image()? == owned.expected_image()?, - "published daemon executable differs from the install-owned image" - ); - Ok(Some(published)) - } - Ok(None) => Ok(None), - Err(_) - if matches!(std::fs::symlink_metadata(owner_path), - Err(error) if error.kind() == std::io::ErrorKind::NotFound) => - { - Ok(Some(owned)) - } - Err(error) => Err(error), - } +/// How long an update waits for this install's daemons to make way: the +/// stop budget every sweep has, plus the grace a replacement gives background +/// passes. +fn update_replacement_budget() -> Duration { + stop_all_budget() + .saturating_add(crate::daemon_client::replacement::REPLACEMENT_BACKGROUND_GRACE) } -fn stop_install_owned_daemons( +/// Replace every daemon this install runs with `successor`, before the +/// updater commits the successor's bytes. +/// +/// Workers are asked together through [`retire_for_replacement`], so each +/// finishes its own writes while the others do. Supervisors, which hold no +/// store, go after the workers, through the incarnation-bound `/shutdown` +/// recorded wherever they recorded it, a 0.8.6 or older supervisor's `.kin` +/// directory included. Daemons of any other install are never in the scan. +/// +/// Returns what was replaced. Fails, naming every daemon still running and +/// what holds it, when any was refused, is still finishing its work, or did +/// not stop; nothing has been installed then, and the next `kin update` asks +/// again. +pub(crate) async fn replace_install_owned_daemons_for_update( install_root: &Path, - deadline: Instant, - reports: &mut Vec, -) -> Result<()> { - // Takes the sweep DEADLINE, not a remaining-budget snapshot. A snapshot is - // re-spent by every process on every pass, so the real bound here would be - // passes x processes x budget rather than the budget the caller meant. + successor: &ReplacementSuccessor, +) -> Result> { + let deadline = Instant::now() + update_replacement_budget(); + let processes = managed_daemon_processes(install_root); + if let Some(unknown) = processes + .iter() + .find(|process| process.kind == ManagedDaemonKind::Unknown) + { + bail!( + "update found install-owned daemon pid {} without an attributable role", + unknown.pid + ); + } + let workers = processes + .iter() + .filter_map(|process| match &process.kind { + ManagedDaemonKind::Worker { repo_root } => Some((process.pid, repo_root.clone())), + _ => None, + }) + .collect::>(); + let outcomes = + futures_util::future::join_all(workers.iter().map(|(pid, repo_root)| async move { + let kin_root = worker_store_root(repo_root); + let outcome = match retire_for_replacement(&kin_root, *pid, successor, deadline).await { + // A worker from before endpoint owner records can be named only + // by install ownership. It is stopped the way every update has + // stopped one, and only with no write open. + Err(error) + if matches!( + std::fs::symlink_metadata(repo_daemon_owner_path(&kin_root)), + Err(ref io_error) if io_error.kind() == std::io::ErrorKind::NotFound + ) => + { + stop_unattributed_worker_for_update(install_root, &kin_root, *pid, deadline) + .with_context(|| format!("after: {error:#}")) + } + outcome => outcome, + }; + (*pid, repo_root, outcome) + })) + .await; + + let mut actions = Vec::new(); + let mut left = Vec::new(); + for (pid, repo_root, outcome) in outcomes { + let label = repo_label(repo_root); + match outcome { + Ok(ReplacementOutcome::Replaced { steps }) => { + actions.push(format!( + "replaced the daemon for {label} (pid {pid}) with {}", + successor.label() + )); + actions.extend(steps); + } + Ok(ReplacementOutcome::NotRunning) => {} + Ok(ReplacementOutcome::AlreadySuccessor) => left.push(format!( + "daemon pid {pid} for {label} already runs {} from the bytes this update replaces", + successor.label() + )), + Ok(ReplacementOutcome::Waiting(holding)) => left.push(format!( + "daemon pid {pid} for {label} is still finishing: {}", + holding.join("; ") + )), + Ok(ReplacementOutcome::Refused(reason)) => left.push(reason), + Err(error) => left.push(format!( + "daemon pid {pid} for {label} could not be replaced: {error:#}" + )), + } + } + // Workers first: a supervisor routes to them while they drain. + if left.is_empty() { + for process in processes + .iter() + .filter(|process| process.kind == ManagedDaemonKind::Supervisor) + { + let mut steps = Vec::new(); + match replace_supervisor_for_update(install_root, process.pid, deadline, &mut steps) { + Ok(outcome) if outcome.is_success() => { + if outcome == StopOutcome::Stopped { + actions.push(format!("stopped the supervisor (pid {})", process.pid)); + } + actions.extend(steps); + } + Ok(outcome) => left.push(format!( + "supervisor pid {} did not stop ({}){}", + process.pid, + outcome.detail(), + if steps.is_empty() { + String::new() + } else { + format!(": {}", steps.join("; ")) + } + )), + Err(error) => left.push(format!( + "supervisor pid {} could not be stopped: {error:#}", + process.pid + )), + } + } + } + if !left.is_empty() { + bail!( + "the update was not installed because these Kin daemons are still running: {}. \ + Run `kin update` again once they have finished", + left.join("; ") + ); + } + Ok(actions) +} + +/// Stop an install-owned worker that published no owner record, the way every +/// update stopped one, once no write is open. +fn stop_unattributed_worker_for_update( + install_root: &Path, + kin_root: &Path, + pid: u32, + deadline: Instant, +) -> Result { + if let Some(write) = live_write_marker(kin_root, pid) { + return Ok(ReplacementOutcome::Waiting(vec![write])); + } + let mut steps = Vec::new(); + let outcome = stop_worker_at( + kin_root, + pid, + remaining_budget(deadline), + Some(install_root), + &mut steps, + )?; + Ok(match outcome { + StopOutcome::NotRunning => ReplacementOutcome::NotRunning, + StopOutcome::Stopped => { + if let Some(survivor) = retire_worker_endpoint(kin_root, &outcome, &mut steps) { + steps.push(format!("its recorded endpoint survived: {survivor}")); + } + ReplacementOutcome::Replaced { steps } + } + other => ReplacementOutcome::Waiting(vec![format!( + "it was asked to stop and did not exit ({})", + other.detail() + )]), + }) +} + +/// The directory whose recorded endpoint names the supervisor at `pid`, and +/// its owner record: the current services directory, or where a supervisor +/// from 0.8.6 or older recorded it. +fn supervisor_endpoint_naming(pid: u32) -> Option<(PathBuf, EndpointOwnerRecord)> { + supervisor_endpoint_naming_in( + pid, + [Some(current_supervisor_dir()), legacy_supervisor_dir()] + .into_iter() + .flatten(), + ) +} + +/// [`supervisor_endpoint_naming`] over the given directories, in order. +fn supervisor_endpoint_naming_in( + pid: u32, + dirs: impl IntoIterator, +) -> Option<(PathBuf, EndpointOwnerRecord)> { + dirs.into_iter().find_map(|dir| { + let (recorded_pid, _, owner) = crate::daemon_client::supervisor_endpoint_in_dir(&dir); + let owner = + owner.filter(|owner| recorded_pid == Some(pid) && owner.identity().pid() == pid)?; + Some((dir, owner)) + }) +} + +/// Stop an install-owned supervisor for an update through the endpoint it +/// recorded, wherever it recorded it, after proving that endpoint and the +/// install scan name the same incarnation and image. A supervisor no endpoint +/// names is stopped the way every update stopped one. +fn replace_supervisor_for_update( + install_root: &Path, + pid: u32, + deadline: Instant, + steps: &mut Vec, +) -> Result { + let Some((dir, owner)) = supervisor_endpoint_naming(pid) else { + let Some(target) = install_owned_stop_target(install_root, pid, None, deadline)? else { + return Ok(StopOutcome::NotRunning); + }; + return Ok(stop_supervisor_attributed( + &target, + remaining_budget(deadline), + steps, + )); + }; + let Some(owned) = legacy_managed_identity( + install_root, + pid, + None, + deadline.min(Instant::now() + Duration::from_secs(5)), + )? + else { + return Ok(StopOutcome::NotRunning); + }; + let published = AttributedStopTarget::published(owner); + anyhow::ensure!( + *published == *owned, + "supervisor incarnation changed during install attribution" + ); + #[cfg(unix)] + anyhow::ensure!( + published.expected_image()? == owned.expected_image()?, + "published supervisor executable differs from the install-owned image" + ); + if !process_identity_is_current(&published)? { + return Ok(StopOutcome::NotRunning); + } + let outcome = stop_supervisor_identity(&dir, &published, remaining_budget(deadline)); + Ok(escalate_if_unstopped(&published, outcome, steps)) +} + +/// Installation ownership is additional evidence, never a replacement for a +/// present endpoint's published incarnation/image proof. Re-observe around the +/// install scan so PID reuse or exec cannot borrow its predecessor's scope. +fn install_owned_stop_target( + install_root: &Path, + pid: u32, + repo_root: Option<&Path>, + deadline: Instant, +) -> Result> { + let Some(owned) = legacy_managed_identity(install_root, pid, repo_root, deadline)? else { + return Ok(None); + }; + let (published, owner_path) = match repo_root { + Some(root) => { + let kin_root = worker_store_root(root); + ( + attributed_worker_identity(&kin_root, pid), + repo_daemon_owner_path(&kin_root), + ) + } + None => (attributed_supervisor_identity(pid), supervisor_owner_path()), + }; + match published { + Ok(Some(published)) => { + anyhow::ensure!( + *published == *owned, + "daemon incarnation changed during install attribution" + ); + #[cfg(unix)] + anyhow::ensure!( + published.expected_image()? == owned.expected_image()?, + "published daemon executable differs from the install-owned image" + ); + Ok(Some(published)) + } + Ok(None) => Ok(None), + Err(_) + if matches!(std::fs::symlink_metadata(owner_path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound) => + { + Ok(Some(owned)) + } + Err(error) => Err(error), + } +} + +fn stop_install_owned_daemons( + install_root: &Path, + deadline: Instant, + reports: &mut Vec, +) -> Result<()> { + // Takes the sweep DEADLINE, not a remaining-budget snapshot. A snapshot is + // re-spent by every process on every pass, so the real bound here would be + // passes x processes x budget rather than the budget the caller meant. // Recomputing against the deadline keeps the whole sweep inside one bound. // // A second pass catches a worker spawned while the supervisor was exiting; @@ -6250,4 +7092,826 @@ mod tests { ); assert!(steps.is_empty(), "{steps:?}"); } + + // ── replacement ───────────────────────────────────────────────────────── + + #[cfg(unix)] + fn successor(version: &str) -> ReplacementSuccessor { + ReplacementSuccessor::new(version, &"a".repeat(40)).unwrap() + } + + #[test] + fn a_retirement_answer_holds_a_replacement_only_for_writes_the_first_scan_and_unknowns() { + use crate::daemon_client::replacement::{RetirementReply, REPLACEMENT_BACKGROUND_GRACE}; + let asked = Instant::now(); + let legacy = RetirementReply { + retiring: true, + blocked_by: vec![ + "a client session is attached".to_string(), + "embedding is running or queued".to_string(), + "a request is in flight".to_string(), + "something a later release says".to_string(), + ], + ..Default::default() + }; + assert_eq!( + holding_replacement(&legacy, asked, asked), + legacy.blocked_by[1..].to_vec(), + "inside the grace only the client is dropped" + ); + assert_eq!( + holding_replacement(&legacy, asked, asked + REPLACEMENT_BACKGROUND_GRACE), + legacy.blocked_by[2..].to_vec(), + "past the grace the background pass is dropped too" + ); + // Codes win over sentences when the daemon sends them. + let coded = RetirementReply { + retiring: true, + blocked_by: vec!["reworded attached client".to_string()], + blockers: Some(vec!["attached_session".to_string()]), + ..Default::default() + }; + assert!(holding_replacement(&coded, asked, asked).is_empty()); + } + + /// One request a fake daemon was sent. + #[cfg(unix)] + #[derive(Debug, Clone)] + struct FakeRequest { + method: String, + path: String, + authorization: Option, + body: String, + } + + #[cfg(unix)] + type FakeAnswer = dyn Fn(&FakeRequest) -> (u16, String) + Send + Sync; + + /// A scripted HTTP endpoint standing in for a daemon's, recording every + /// request it was sent. + #[cfg(unix)] + struct FakeEndpoint { + port: u16, + requests: std::sync::Arc>>, + server: tokio::task::JoinHandle<()>, + } + + #[cfg(unix)] + impl FakeEndpoint { + /// `answer` gets each request, the pid it answers for, and `end`, which + /// ends that process. + async fn serve( + pid: u32, + end: impl Fn() + Send + Sync + 'static, + answer: impl Fn(&FakeRequest, u32, &dyn Fn()) -> (u16, String) + Send + Sync + 'static, + ) -> Self { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let port = listener.local_addr().unwrap().port(); + let answer: std::sync::Arc = + std::sync::Arc::new(move |request: &FakeRequest| answer(request, pid, &end)); + let recorded = std::sync::Arc::clone(&requests); + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + let (head_end, length) = loop { + let count = socket.read(&mut chunk).await.unwrap_or(0); + if count == 0 { + break (None, 0); + } + buffer.extend_from_slice(&chunk[..count]); + if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&buffer[..end]).to_string(); + let length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + break (Some(end + 4), length); + } + }; + let Some(head_end) = head_end else { + continue; + }; + while buffer.len() < head_end + length { + let count = socket.read(&mut chunk).await.unwrap_or(0); + if count == 0 { + break; + } + buffer.extend_from_slice(&chunk[..count]); + } + let head = String::from_utf8_lossy(&buffer[..head_end]).to_string(); + let mut words = head.split_whitespace(); + let request = FakeRequest { + method: words.next().unwrap_or_default().to_string(), + path: words.next().unwrap_or_default().to_string(), + authorization: head.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("authorization") + .then(|| value.trim().to_string()) + }), + body: String::from_utf8_lossy(&buffer[head_end..]).to_string(), + }; + recorded.lock().unwrap().push(request.clone()); + let (status, body) = answer(&request); + let response = format!( + "HTTP/1.1 {status} Fake\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + } + }); + Self { + port, + requests, + server, + } + } + + fn sent(&self, method: &str, path: &str) -> Vec { + self.requests + .lock() + .unwrap() + .iter() + .filter(|request| request.method == method && request.path == path) + .cloned() + .collect() + } + + fn only_reads(&self) -> bool { + self.requests + .lock() + .unwrap() + .iter() + .all(|request| request.method == "GET") + } + } + + #[cfg(unix)] + impl Drop for FakeEndpoint { + fn drop(&mut self) { + self.server.abort(); + } + } + + /// Record `endpoint` as the published endpoint of `owner` in `kin_root`. + #[cfg(unix)] + fn publish_fake_endpoint( + kin_root: &Path, + owner: &EndpointOwnerRecord, + endpoint: &FakeEndpoint, + ) { + std::fs::write( + repo_daemon_owner_path(kin_root), + serde_json::to_vec(owner).unwrap(), + ) + .unwrap(); + std::fs::write( + repo_daemon_pid_path(kin_root), + owner.identity().pid().to_string(), + ) + .unwrap(); + std::fs::write(repo_daemon_port_path(kin_root), endpoint.port.to_string()).unwrap(); + } + + /// A stand-in daemon: a live process whose recorded incarnation, pid and + /// port name a scripted endpoint, and which exits when its script says so. + #[cfg(unix)] + struct FakeDaemon { + root: tempfile::TempDir, + pid: u32, + identity: ProcessIdentity, + exit: std::sync::Arc>>, + endpoint: FakeEndpoint, + reaper: Option>, + } + + #[cfg(unix)] + impl FakeDaemon { + async fn start( + answer: impl Fn(&FakeRequest, u32, &dyn Fn()) -> (u16, String) + Send + Sync + 'static, + ) -> Self { + use std::io::{Read, Write}; + let mut child = std::process::Command::new("/bin/cat") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn the stand-in daemon"); + child + .stdin + .as_mut() + .expect("stand-in stdin") + .write_all(b"\n") + .expect("signal stand-in exec completion"); + let mut ready = [0_u8; 1]; + child + .stdout + .as_mut() + .expect("stand-in stdout") + .read_exact(&mut ready) + .expect("wait for stand-in exec completion"); + let pid = child.id(); + let identity = process_identity(pid).unwrap().unwrap(); + let exit = std::sync::Arc::new(std::sync::Mutex::new(child.stdin.take())); + let reaper = std::thread::spawn(move || { + let _ = child.wait(); + }); + let end = { + let exit = std::sync::Arc::clone(&exit); + move || drop(exit.lock().unwrap().take()) + }; + let endpoint = FakeEndpoint::serve(pid, end, answer).await; + let root = tempfile::tempdir().unwrap(); + publish_fake_endpoint( + root.path(), + &EndpointOwnerRecord::for_identity(identity.clone()), + &endpoint, + ); + Self { + root, + pid, + identity, + exit, + endpoint, + reaper: Some(reaper), + } + } + + fn kin_root(&self) -> &Path { + self.root.path() + } + + fn sent(&self, method: &str, path: &str) -> Vec { + self.endpoint.sent(method, path) + } + } + + #[cfg(unix)] + impl Drop for FakeDaemon { + fn drop(&mut self) { + drop(self.exit.lock().unwrap().take()); + if let Some(reaper) = self.reaper.take() { + let _ = reaper.join(); + } + } + } + + #[cfg(unix)] + fn legacy_health(pid: u32, version: &str, dirty: bool) -> (u16, String) { + ( + 200, + serde_json::json!({ + "version": version, + "pid": pid, + "build": {"sha": "b".repeat(40), "dirty": dirty, "source_known": true, "built_at": "then"}, + }) + .to_string(), + ) + } + + #[cfg(unix)] + fn runtime_identity(pid: u32, version: &str, commit: &str, mode: &str) -> (u16, String) { + let current = kin_core::runtime_identity::RuntimeIdentity::current(); + ( + 200, + serde_json::json!({ + "schema": "kin.runtime-service.v1", + "identity": { + "version": version, + "commit_sha": commit, + "source_dirty": false, + "source_known": true, + "target": current.target, + "build_profile": "release", + }, + "mode": mode, + "component": "daemon", + "pid": pid, + }) + .to_string(), + ) + } + + #[cfg(unix)] + fn is_envelope(request: &FakeRequest) -> bool { + request.body.contains("\"replacement\"") + } + + /// A 0.8 daemon reads the envelope as a malformed incarnation, takes the + /// plain retirement, and is stopped through its incarnation once only its + /// attached client holds it. + #[cfg(unix)] + #[tokio::test] + async fn a_0_8_daemon_held_only_by_its_client_is_stopped_through_its_incarnation() { + let daemon = FakeDaemon::start(|request, pid, end| { + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/runtime/identity") => (404, String::new()), + ("GET", "/health") => legacy_health(pid, "0.8.6", false), + ("POST", "/retire") if is_envelope(request) => (422, "missing field `pid`".into()), + ("POST", "/retire") => ( + 202, + r#"{"retiring":true,"blocked_by":["a client session is attached"]}"#.into(), + ), + ("POST", "/shutdown") => { + end(); + (202, r#"{"stopping":true}"#.into()) + } + _ => (500, String::new()), + } + }) + .await; + let outcome = retire_for_replacement( + daemon.kin_root(), + daemon.pid, + &successor("0.8.7"), + Instant::now() + Duration::from_secs(10), + ) + .await + .unwrap(); + assert!( + matches!(&outcome, ReplacementOutcome::Replaced { .. }), + "{outcome:?}" + ); + let retires = daemon.sent("POST", "/retire"); + assert!(is_envelope(&retires[0]), "the replacement is asked first"); + assert!(retires[0].body.contains("Kin 0.8.7 (aaaaaaa)")); + assert!(!is_envelope(&retires[1])); + let shutdowns = daemon.sent("POST", "/shutdown"); + assert_eq!(shutdowns.len(), 1, "one stop request, never a second"); + assert_eq!( + serde_json::from_str::(&shutdowns[0].body).unwrap(), + daemon.identity, + "the stop request is bound to the recorded incarnation" + ); + assert_eq!( + repo_daemon_recorded_endpoint(daemon.kin_root()), + (None, None), + "the endpoint of a replaced daemon is retired" + ); + } + + /// A beating write keeps a 0.8 daemon from being stopped even when only + /// its client holds it, and a sentence this build does not know holds it + /// too. Neither is stopped; both are named. + #[cfg(unix)] + #[tokio::test] + async fn a_0_8_daemon_with_an_open_write_or_an_unknown_blocker_is_waited_for() { + for blocker in [ + "a client session is attached", + "something a later release says", + ] { + let blocked_by = + serde_json::json!({"retiring": true, "blocked_by": [blocker]}).to_string(); + let daemon = FakeDaemon::start(move |request, pid, end| { + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/runtime/identity") => (404, String::new()), + ("GET", "/health") => legacy_health(pid, "0.8.2", false), + ("POST", "/retire") if is_envelope(request) => (422, String::new()), + ("POST", "/retire") => (202, blocked_by.clone()), + ("POST", "/shutdown") => { + end(); + (202, String::new()) + } + _ => (500, String::new()), + } + }) + .await; + if blocker == "a client session is attached" { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + kin_daemon_spawn::write_open_transaction( + daemon.kin_root(), + &kin_daemon_spawn::OpenTransaction { + pid: daemon.pid, + operation: "commit".to_string(), + phase: Some("publish".to_string()), + elapsed_secs: 3, + phase_elapsed_secs: 1, + beat_unix: now, + rss_bytes: None, + peak_rss_bytes: None, + }, + ); + } + let outcome = retire_for_replacement( + daemon.kin_root(), + daemon.pid, + &successor("0.8.7"), + Instant::now() + Duration::from_millis(1500), + ) + .await + .unwrap(); + let ReplacementOutcome::Waiting(waiting_on) = &outcome else { + panic!("{blocker}: {outcome:?}"); + }; + let expected = if blocker == "a client session is attached" { + "a write is open: commit in phase publish for 3s" + } else { + blocker + }; + assert_eq!(waiting_on, &vec![expected.to_string()], "{blocker}"); + assert!(daemon.sent("POST", "/shutdown").is_empty(), "{blocker}"); + assert_eq!( + repo_daemon_recorded_endpoint(daemon.kin_root()).0, + Some(daemon.pid), + "a daemon left running keeps its endpoint" + ); + } + } + + /// A daemon from before `/retire` is stopped through its incarnation, and + /// only once no commit is on its way to it. + #[cfg(unix)] + #[tokio::test] + async fn a_0_7_daemon_is_stopped_only_once_no_write_is_open() { + let daemon = FakeDaemon::start(|request, pid, end| { + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/runtime/identity") | ("POST", "/retire") => (404, String::new()), + ("GET", "/health") => legacy_health(pid, "0.7.18", false), + ("POST", "/shutdown") => { + end(); + (202, String::new()) + } + _ => (500, String::new()), + } + }) + .await; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + kin_daemon_spawn::write_approaching_commit( + daemon.kin_root(), + &kin_daemon_spawn::ApproachingCommit { + pid: std::process::id(), + announced_unix: now, + }, + ); + let held = retire_for_replacement( + daemon.kin_root(), + daemon.pid, + &successor("0.8.7"), + Instant::now() + Duration::from_millis(800), + ) + .await + .unwrap(); + assert_eq!( + held, + ReplacementOutcome::Waiting(vec!["a commit is on its way to the daemon".to_string()]) + ); + assert!(daemon.sent("POST", "/shutdown").is_empty()); + + kin_daemon_spawn::clear_approaching_commit(daemon.kin_root()); + let replaced = retire_for_replacement( + daemon.kin_root(), + daemon.pid, + &successor("0.8.7"), + Instant::now() + Duration::from_secs(10), + ) + .await + .unwrap(); + assert!( + matches!(&replaced, ReplacementOutcome::Replaced { .. }), + "{replaced:?}" + ); + assert_eq!(daemon.sent("POST", "/shutdown").len(), 1); + } + + /// A daemon that takes the replacement is left to finish and go by + /// itself, and is never sent a stop. + #[cfg(unix)] + #[tokio::test] + async fn a_daemon_that_takes_the_replacement_goes_by_itself() { + let daemon = FakeDaemon::start(|request, pid, end| { + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/runtime/identity") => { + runtime_identity(pid, "0.8.7", &"c".repeat(40), "managed") + } + ("POST", "/retire") if is_envelope(request) => { + end(); + ( + 202, + serde_json::json!({ + "retiring": true, + "replacing": true, + "successor": "Kin 0.9.0 (aaaaaaa)", + "blocked_by": [], + "blockers": [], + }) + .to_string(), + ) + } + _ => (500, String::new()), + } + }) + .await; + let outcome = retire_for_replacement( + daemon.kin_root(), + daemon.pid, + &successor("0.9.0"), + Instant::now() + Duration::from_secs(10), + ) + .await + .unwrap(); + assert_eq!(outcome, ReplacementOutcome::Replaced { steps: Vec::new() }); + assert_eq!(daemon.sent("POST", "/retire").len(), 1); + assert!(daemon.sent("POST", "/shutdown").is_empty()); + } + + /// A newer daemon, a development build, and a daemon already on the + /// successor's release are never asked anything. + #[cfg(unix)] + #[tokio::test] + async fn newer_development_and_current_daemons_are_never_asked_to_go() { + let cases: [(&str, fn(u32) -> (u16, String), &str); 4] = [ + ( + "newer", + |pid| runtime_identity(pid, "0.9.0", &"c".repeat(40), "managed"), + "Kin 0.9.0", + ), + ( + "development mode", + |pid| runtime_identity(pid, "0.8.6", &"c".repeat(40), "development"), + "development mode", + ), + ( + "dirty legacy build", + |pid| legacy_health(pid, "0.8.6", true), + "uncommitted source changes", + ), + ( + "the successor itself", + |pid| runtime_identity(pid, "0.8.7", &"a".repeat(40), "managed"), + "", + ), + ]; + for (case, report, named) in cases { + let daemon = FakeDaemon::start(move |request, pid, _| { + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/runtime/identity") if case == "dirty legacy build" => { + (404, String::new()) + } + ("GET", "/runtime/identity") | ("GET", "/health") => report(pid), + _ => (500, String::new()), + } + }) + .await; + let outcome = retire_for_replacement( + daemon.kin_root(), + daemon.pid, + &successor("0.8.7"), + Instant::now() + Duration::from_secs(5), + ) + .await + .unwrap(); + if case == "the successor itself" { + assert_eq!(outcome, ReplacementOutcome::AlreadySuccessor); + } else { + let ReplacementOutcome::Refused(reason) = &outcome else { + panic!("{case}: {outcome:?}"); + }; + assert!(reason.contains(&format!("pid {}", daemon.pid)), "{reason}"); + assert!(reason.contains("/bin/cat"), "{case}: {reason}"); + assert!(reason.contains(named), "{case}: {reason}"); + } + assert!( + daemon.endpoint.only_reads(), + "{case}: nothing may be asked of a daemon that is not replaced" + ); + assert!( + process_identity(daemon.pid).unwrap().is_some(), + "{case}: the daemon is left running" + ); + } + } + + // ── update replacement ────────────────────────────────────────────────── + + /// An install-owned stand-in worker behind a scripted endpoint, and the + /// handle that ends it. + #[cfg(unix)] + async fn owned_worker_behind( + install: &Path, + repo: &Path, + answer: impl Fn(&FakeRequest, u32, &dyn Fn()) -> (u16, String) + Send + Sync + 'static, + ) -> (Standin, FakeEndpoint) { + let kin_root = repo.join(".kin"); + std::fs::create_dir_all(&kin_root).unwrap(); + let mut owned = spawn_escalation_standin_at("wait", Some((install, repo))); + let input = std::sync::Arc::new(std::sync::Mutex::new(owned.child.stdin.take())); + let end = move || drop(input.lock().unwrap().take()); + let endpoint = FakeEndpoint::serve(owned.child.id(), end, answer).await; + publish_fake_endpoint(&kin_root, &owned.target.owner, &endpoint); + (owned, endpoint) + } + + /// `kin update` replaces the daemon its install runs, a 0.8 daemon held + /// only by its client here, and leaves a same-named daemon of another + /// install alone. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn update_replaces_the_owned_daemon_and_spares_another_installs() { + let fixture = tempfile::tempdir().unwrap(); + let install = fixture.path().join("install"); + let foreign_install = fixture.path().join("foreign"); + let repo = fixture.path().join("repo"); + let foreign_repo = fixture.path().join("foreign-repo"); + std::fs::create_dir_all(foreign_repo.join(".kin")).unwrap(); + let (owned, endpoint) = owned_worker_behind(&install, &repo, |request, pid, end| { + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/runtime/identity") => (404, String::new()), + ("GET", "/health") => legacy_health(pid, "0.8.6", false), + ("POST", "/retire") if is_envelope(request) => (422, String::new()), + ("POST", "/retire") => ( + 202, + r#"{"retiring":true,"blocked_by":["a client session is attached"]}"#.into(), + ), + ("POST", "/shutdown") => { + end(); + (202, String::new()) + } + _ => (500, String::new()), + } + }) + .await; + let foreign = spawn_escalation_standin_at("wait", Some((&foreign_install, &foreign_repo))); + + let next = successor("0.8.7").replacing_install(&install); + let actions = replace_install_owned_daemons_for_update(&install, &next) + .await + .expect("the owned daemon must be replaced"); + assert!( + actions + .iter() + .any(|action| action.contains("replaced the daemon for repo") + && action.contains("Kin 0.8.7 (aaaaaaa)")), + "{actions:?}" + ); + assert_eq!(endpoint.sent("POST", "/shutdown").len(), 1); + assert!(!process_identity_is_current(&owned.target).unwrap()); + assert!( + process_identity_is_current(&foreign.target).unwrap(), + "a daemon of another install was touched" + ); + } + + /// A newer daemon running from the install fails the update by pid and + /// path and is left running, never asked to go. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn update_refuses_a_newer_owned_daemon_by_name_and_leaves_it_running() { + let fixture = tempfile::tempdir().unwrap(); + let install = fixture.path().join("install"); + let repo = fixture.path().join("repo"); + let (owned, endpoint) = owned_worker_behind(&install, &repo, |request, pid, _| { + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/runtime/identity") => { + runtime_identity(pid, "9.9.9", &"c".repeat(40), "managed") + } + _ => (500, String::new()), + } + }) + .await; + let next = successor("0.8.7").replacing_install(&install); + let error = replace_install_owned_daemons_for_update(&install, &next) + .await + .expect_err("a newer daemon must fail the update"); + let message = format!("{error:#}"); + assert!( + message.contains(&format!("pid {}", owned.child.id())), + "{message}" + ); + assert!( + message.contains("kin-daemon") && message.contains("Kin 9.9.9"), + "{message}" + ); + assert!(endpoint.only_reads()); + assert!(process_identity_is_current(&owned.target).unwrap()); + } + + /// Install ownership never stands in for a malformed publication: the + /// update fails and the daemon is left running. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn update_replacement_cannot_borrow_install_ownership_for_a_malformed_publication() { + let fixture = tempfile::tempdir().unwrap(); + let install = fixture.path().join("install"); + let repo = fixture.path().join("repo"); + let kin_root = repo.join(".kin"); + std::fs::create_dir_all(&kin_root).unwrap(); + let mut owned = spawn_escalation_standin_at("wait", Some((&install, &repo))); + std::fs::write( + repo_daemon_pid_path(&kin_root), + owned.child.id().to_string(), + ) + .unwrap(); + std::fs::write(repo_daemon_owner_path(&kin_root), b"not a valid owner").unwrap(); + let next = successor("0.8.7").replacing_install(&install); + assert!(replace_install_owned_daemons_for_update(&install, &next) + .await + .is_err()); + assert!(owned.child.try_wait().unwrap().is_none()); + assert!(process_identity_is_current(&owned.target).unwrap()); + } + + /// A supervisor from before the managed services directory recorded its + /// endpoint where the registry used to live. The updater finds it there + /// and stops it through that endpoint, with that endpoint's token. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_supervisor_from_before_the_services_directory_is_stopped_where_it_recorded_itself() { + let fixture = tempfile::tempdir().unwrap(); + let legacy = fixture.path().join("legacy"); + let current = fixture.path().join("services"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::create_dir_all(¤t).unwrap(); + assert_eq!( + legacy_supervisor_dir_from( + Some(legacy.join("registry.json").into_os_string()), + Some(fixture.path().join("home")) + ), + Some(legacy.clone()), + "an explicit registry path names where the old supervisor lived" + ); + assert_eq!( + legacy_supervisor_dir_from(None, Some(fixture.path().join("home"))), + Some(fixture.path().join("home").join(".kin")) + ); + + use std::io::{Read, Write}; + let mut child = OwnedTestChild( + std::process::Command::new("/bin/cat") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(), + ); + child.stdin.as_mut().unwrap().write_all(b"\n").unwrap(); + let mut ready = [0_u8; 1]; + child + .stdout + .as_mut() + .unwrap() + .read_exact(&mut ready) + .unwrap(); + let pid = child.id(); + let identity = process_identity(pid).unwrap().unwrap(); + let input = std::sync::Arc::new(std::sync::Mutex::new(child.stdin.take())); + let end = move || drop(input.lock().unwrap().take()); + let expected = identity.clone(); + let endpoint = FakeEndpoint::serve(pid, end, move |request, _, end| { + match (request.method.as_str(), request.path.as_str()) { + ("POST", "/shutdown") + if serde_json::from_str::(&request.body).ok() + == Some(expected.clone()) => + { + end(); + (202, String::new()) + } + _ => (409, String::new()), + } + }) + .await; + let owner = EndpointOwnerRecord::for_identity(identity); + std::fs::write(legacy.join("supervisor.pid"), pid.to_string()).unwrap(); + std::fs::write(legacy.join("supervisor.port"), endpoint.port.to_string()).unwrap(); + std::fs::write( + legacy.join("supervisor.owner"), + serde_json::to_vec(&owner).unwrap(), + ) + .unwrap(); + std::fs::write(legacy.join("supervisor.token"), "legacy-token\n").unwrap(); + + let (dir, named) = supervisor_endpoint_naming_in(pid, [current.clone(), legacy.clone()]) + .expect("the legacy endpoint names it"); + assert_eq!(dir, legacy); + assert_eq!(named, owner); + let target = AttributedStopTarget::published(named); + let outcome = tokio::task::block_in_place(|| { + stop_supervisor_identity(&dir, &target, Duration::from_secs(5)) + }); + assert_eq!(outcome, StopOutcome::Stopped); + let shutdowns = endpoint.sent("POST", "/shutdown"); + assert_eq!(shutdowns.len(), 1); + assert_eq!( + shutdowns[0].authorization.as_deref(), + Some("Bearer legacy-token") + ); + let _ = child.wait(); + } } diff --git a/crates/kin-cli/src/commands/diff.rs b/crates/kin-cli/src/commands/diff.rs index 322853f44..dc6e6446e 100644 --- a/crates/kin-cli/src/commands/diff.rs +++ b/crates/kin-cli/src/commands/diff.rs @@ -307,14 +307,13 @@ pub fn inspect_with_endpoint_entities_at( // Build the immutable history resolver from the exact snapshot carried by // this lease. The authority envelope is excluded from the query graph; it // remains owned by `lease` and cannot be confused with graph content. + // No root hash is computed for it: the constructor that took one ignored + // it, and computing it walked and digested every entity on every diff, + // which the review guide pays again each time the graph moves. let mut history_snapshot = lease.snapshot().clone(); history_snapshot.repository_authority = None; - let expected_root_hash = kin_db::compute_graph_root_hash(&history_snapshot); - let history = kin_db::InMemoryGraph::from_snapshot_without_text_index_with_root_hash( - history_snapshot, - expected_root_hash, - ) - .context("open immutable repository-v6 history for diff")?; + let history = kin_db::InMemoryGraph::from_snapshot_without_text_index(history_snapshot) + .context("open immutable repository-v6 history for diff")?; // Git-compatible default: HEAD is the committed workspace base and the // implicit right side is Kin's exact graph-owned workspace tree. @@ -708,17 +707,26 @@ fn diff_entities( (deltas, withheld) } +/// How the relationships between semantic nodes differ. A link a test run or +/// a work item records (run proof, test coverage) belongs to that record, not +/// to the work being compared, so it is never a relationship change here. fn diff_relations( base: &HashMap, head: &HashMap, ) -> Vec { + fn semantic(relation: Option<&Relation>) -> Option<&Relation> { + relation.filter(|relation| kin_db::is_semantic_relation(relation)) + } base.keys() .copied() .chain(head.keys().copied()) .collect::>() .into_iter() - .filter_map( - |relation_id| match (base.get(&relation_id), head.get(&relation_id)) { + .filter_map(|relation_id| { + match ( + semantic(base.get(&relation_id)), + semantic(head.get(&relation_id)), + ) { (None, Some(new)) => Some(RelationDelta::Added { new: new.clone() }), (Some(old), Some(new)) if old != new => Some(RelationDelta::Modified { old: old.clone(), @@ -726,8 +734,8 @@ fn diff_relations( }), (Some(old), None) => Some(RelationDelta::Removed { old: old.clone() }), (Some(_), Some(_)) | (None, None) => None, - }, - ) + } + }) .collect() } @@ -1981,7 +1989,17 @@ mod tests { evidence: Vec::new(), }; let base_relations = HashMap::new(); - let head_relations = HashMap::from([(relation_id, relation.clone())]); + // A test run's proof link to the entity is the run's record, and a + // diff of the work never lists it. + let proof_id = RelationId::new(); + let proof = Relation { + id: proof_id, + kind: RelationKind::DerivedFrom, + src: GraphNodeId::VerificationRun(kin_model::VerificationRunId::new()), + dst: GraphNodeId::Entity(retained_id), + ..relation.clone() + }; + let head_relations = HashMap::from([(relation_id, relation.clone()), (proof_id, proof)]); let (entity_deltas, _) = diff_entities(&base_entities, &head_entities); let relation_deltas = diff_relations(&base_relations, &head_relations); diff --git a/crates/kin-cli/src/commands/entity_work.rs b/crates/kin-cli/src/commands/entity_work.rs new file mode 100644 index 000000000..58ecee1d9 --- /dev/null +++ b/crates/kin-cli/src/commands/entity_work.rs @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Entity-at-a-time work on the uncommitted working copy: commit only chosen +//! entities, or put one entity back as it was. +//! +//! These are the requests the daemon answers on `/commands/commit/entities` +//! and `/commands/entity/restore`, shared here so the CLI, the local app and +//! the daemon read one contract. Both are writes. Each carries the caller's +//! operation id, which the daemon records as the repository operation, so an +//! answer that never arrives is settled by asking repository authority whether +//! that operation landed, and never by sending it again. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// The error kind every refusal of entity work carries. Its `message` is one +/// sentence about entities, written for the person who asked. +pub const ENTITY_REFUSED: &str = "entity_refused"; + +/// Commit only the named entities' uncommitted changes. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EntityCommitRequest { + pub operation_id: kin_model::OperationId, + pub timestamp: kin_model::Timestamp, + pub message: String, + pub author: kin_model::AuthorId, + /// Entity ids as a review guide gives them, and the ids of unparsed + /// entities. + pub entities: Vec, + #[serde(default)] + pub session_id: Option, +} + +/// The change an entity commit recorded. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityCommitResponse { + pub change_id: String, + /// The branch the change was published onto, or `null` on a detached head. + #[serde(default)] + pub branch: Option, + pub entity_count: usize, + pub relation_count: usize, + pub file_count: usize, + /// The entities whose changes the change carries, as they were named. + pub kept: Vec, + /// Whether this operation had already landed, so this answer restates it. + #[serde(default)] + pub replayed: bool, +} + +/// Put one entity's source back as it was at the head, or at an earlier +/// change, as an uncommitted edit. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EntityRestoreRequest { + pub operation_id: kin_model::OperationId, + pub entity: String, + /// The change to restore from. The workspace's head when absent. + #[serde(default)] + pub to: Option, + /// The entity as the caller last read it in the working copy: + /// `source:` for its exact span as the guide reports it, + /// `absent` when the guide reports it removed, or an unparsed entity's + /// content address. A restore discards the entity's whole source, so only + /// an exact reading guards it. A semantic fingerprint, such as `behavior` + /// or `ast`, stays the same when a comment or spacing changes, so a + /// restore that names one is refused even while it matches. + /// Absent means the caller asks for no check. + #[serde(default)] + pub expected: Option, + pub actor: kin_model::AuthorId, +} + +/// What a restore did. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityRestoreResponse { + pub entity: String, + /// The entity's name, when it has one in either version. + #[serde(default)] + pub name: Option, + /// The change whose version of the entity the working copy now holds. + /// Absent when authority's receipt proves the restore landed without + /// retaining the requested target, including an explicit-target replay. + #[serde(default)] + pub restored_to: Option, + /// Whether this operation had already landed, so this answer restates it. + #[serde(default)] + pub replayed: bool, +} + +/// The sentence a refusal of entity work carries, when the body is one. +pub(crate) fn refusal_message(body: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(body).ok()?; + if parsed.get("error")?.as_str()? != ENTITY_REFUSED { + return None; + } + Some(parsed.get("message")?.as_str()?.to_string()) +} + +/// How a restore the local app asked for ended. +pub(super) enum AppRestore { + Restored(EntityRestoreResponse), + /// Nothing was written. The error is Kin's own sentence. + Refused(anyhow::Error), + /// The request was sent and its outcome is not known. + Unknown(anyhow::Error), +} + +/// Restore one entity for the local app. +/// +/// The actor is resolved against `layout`, never taken from the caller, and +/// the caller's operation id is the one the daemon records. The app is no +/// agent session, so the process's ambient session is not sent. +pub(super) async fn restore_for_app( + layout: &kin_core::KinLayout, + entity: String, + to: Option, + expected: Option, + operation_id: kin_model::OperationId, +) -> AppRestore { + let actor = match super::require_commit_author_for(layout) { + Ok(actor) => actor, + Err(error) => return AppRestore::Refused(error), + }; + let request = EntityRestoreRequest { + operation_id, + entity, + to, + expected, + actor, + }; + send_restore(layout, &request, None).await +} + +/// `kin checkout --entity`: restore one entity and say what was done. +pub async fn run_restore( + entity: String, + to: Option, + expected: Option, +) -> Result<()> { + let layout = crate::commands::require_repository_layout()?; + let actor = crate::commands::require_commit_author_for(&layout)?; + let request = EntityRestoreRequest { + operation_id: kin_model::OperationId::new(), + entity, + to, + expected, + actor, + }; + // The session this command runs in is named, so the daemon can refuse a + // restore asked for from inside one, as it refuses a checkout there. + match send_restore(&layout, &request, super::commit::commit_session_header()).await { + AppRestore::Restored(restored) => { + println!("{}", render_restored(&restored)); + Ok(()) + } + AppRestore::Refused(error) => Err(error), + AppRestore::Unknown(error) => Err(error.context(format!( + "whether operation {} restored the entity is not known; it was not sent again", + request.operation_id + ))), + } +} + +fn render_restored(restored: &EntityRestoreResponse) -> String { + let named = restored.name.as_deref().unwrap_or(&restored.entity); + let lead = if restored.replayed { + "Already restored" + } else { + "Restored" + }; + match &restored.restored_to { + Some(change) => { + format!("{lead} {named} to its version at change {change} as an uncommitted edit") + } + None => format!("{lead} {named} as an uncommitted edit"), + } +} + +async fn send_restore( + layout: &kin_core::KinLayout, + request: &EntityRestoreRequest, + session: Option, +) -> AppRestore { + let daemon_url = match crate::daemon_client::resolve_daemon_url(layout).await { + Ok(Some(url)) => url, + Ok(None) => { + return AppRestore::Refused(crate::daemon_client::daemon_required_error( + "restore", layout, + )) + } + Err(error) => return AppRestore::Refused(error), + }; + // No reply deadline, for the reason `kin commit` has none: the daemon's + // work is bounded by the store, not by anything this side can know. + let client = match reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_millis(500)) + .build() + { + Ok(client) => client, + Err(error) => return AppRestore::Refused(error.into()), + }; + let mut post = client + .post(format!( + "{}/commands/entity/restore", + daemon_url.trim_end_matches('/') + )) + .json(request); + if let Some(token) = crate::daemon_client::resolve_daemon_auth_token_for_layout(layout) { + post = post.bearer_auth(token); + } + if let Some(session) = session { + post = post.header("X-Kin-Session", session); + } + // Not dispatched yet, so a refused admission is a restore that did not run. + let post = match crate::daemon_client::admit_daemon_request( + post, + &daemon_url, + layout, + "restore", + ) + .await + { + Ok(post) => post, + Err(error) => return AppRestore::Refused(error), + }; + let response = match post.send().await { + Ok(response) => response, + Err(error) => { + return match restored_by_operation(layout, request.operation_id) { + Ok(true) => AppRestore::Restored(EntityRestoreResponse { + entity: request.entity.clone(), + name: None, + restored_to: request.to.clone(), + replayed: true, + }), + Ok(false) => AppRestore::Unknown(anyhow::Error::new(error).context( + "the restore request was sent and no answer came back; repository authority \ + holds no record of it yet", + )), + Err(read) => AppRestore::Unknown( + anyhow::Error::new(error) + .context(format!("repository authority could not be read: {read:#}")), + ), + }; + } + }; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if status.is_success() { + return match serde_json::from_str::(&body) { + Ok(restored) => AppRestore::Restored(restored), + Err(error) => AppRestore::Unknown( + anyhow::Error::new(error) + .context("the daemon's answer to a restore did not decode"), + ), + }; + } + if let Some(sentence) = refusal_message(&body) { + return AppRestore::Refused(anyhow::anyhow!(sentence)); + } + let error = anyhow::anyhow!("the daemon answered a restore with HTTP {status}: {body}"); + // A request turned away is settled. A fault inside one is not: it does + // not say how far the restore got. + if status.is_client_error() { + AppRestore::Refused(error) + } else { + match restored_by_operation(layout, request.operation_id) { + Ok(true) => AppRestore::Restored(EntityRestoreResponse { + entity: request.entity.clone(), + name: None, + restored_to: request.to.clone(), + replayed: true, + }), + _ => AppRestore::Unknown(error), + } + } +} + +/// Whether repository authority holds a receipt for `operation_id`. +/// +/// Read from the durable store rather than asked of the daemon, because the +/// state this answers about is the one where the daemon's answer was lost. +pub(crate) fn restored_by_operation( + layout: &kin_core::KinLayout, + operation_id: kin_model::OperationId, +) -> Result { + let binding = kin_core::LocalRepositoryAuthorityBinding::from_layout(layout)?; + let authority = super::repository_authority::ActiveRepositoryAuthority::open(&binding) + .context("open repository authority to settle a lost answer")?; + let lease = authority.manager().read_authority(); + Ok(kin_core::rejoined_receipt(lease.metadata(), operation_id).is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_an_entity_refusal_is_read_as_a_sentence() { + let body = serde_json::json!({ + "error": ENTITY_REFUSED, + "message": "render and helper were changed in the same place, so Kin cannot commit one without the other.", + }) + .to_string(); + assert_eq!( + refusal_message(&body).as_deref(), + Some( + "render and helper were changed in the same place, so Kin cannot commit one \ + without the other." + ) + ); + assert!( + refusal_message(&serde_json::json!({"error":"lease_conflict"}).to_string()).is_none() + ); + assert!(refusal_message("not json").is_none()); + } + + #[test] + fn requests_refuse_fields_they_do_not_name() { + let restore = serde_json::json!({ + "operation_id": kin_model::OperationId::new(), + "entity": "8b0d3c8e-27c2-4b7b-9e0f-0d0c1f4a7a11", + "actor": "Ada ", + "path": "src/lib.rs", + }); + assert!(serde_json::from_value::(restore).is_err()); + let commit = serde_json::json!({ + "operation_id": kin_model::OperationId::new(), + "timestamp": kin_model::Timestamp::now(), + "message": "keep one", + "author": "Ada ", + "entities": ["8b0d3c8e-27c2-4b7b-9e0f-0d0c1f4a7a11"], + }); + let parsed: EntityCommitRequest = serde_json::from_value(commit).unwrap(); + assert_eq!(parsed.entities.len(), 1); + assert!(parsed.session_id.is_none()); + } + + #[test] + fn a_restore_says_what_it_restored_by_name() { + let line = render_restored(&EntityRestoreResponse { + entity: "8b0d3c8e-27c2-4b7b-9e0f-0d0c1f4a7a11".into(), + name: Some("render".into()), + restored_to: Some("5b8ca7b7".into()), + replayed: false, + }); + assert_eq!( + line, + "Restored render to its version at change 5b8ca7b7 as an uncommitted edit" + ); + } +} diff --git a/crates/kin-cli/src/commands/health.rs b/crates/kin-cli/src/commands/health.rs index ce02e1001..34063bfd7 100644 --- a/crates/kin-cli/src/commands/health.rs +++ b/crates/kin-cli/src/commands/health.rs @@ -283,9 +283,14 @@ pub async fn run_health_checks() -> HealthReport { /// surface Kin has on a real store. pub(crate) async fn run_health_checks_sharing(graph_status: &RunGraphStatus) -> HealthReport { let mut checks = vec![ + check_managed_runtime(), check_kin_binary(), check_kin_daemon_binary(), check_supervisor_startup_protocol(), + check_launcher_stamp(), + ]; + checks.extend(check_kin_copies()); + checks.extend([ check_daemon_running().await, check_daemon_idle_window(), check_vfs_projection(), @@ -295,7 +300,7 @@ pub(crate) async fn run_health_checks_sharing(graph_status: &RunGraphStatus) -> check_session_runtime(), check_shell_path(), check_registry_authority(), - ]; + ]); checks.extend(check_mcp_clients()); checks.extend(check_discovery_blocks()); checks.push(check_setup_ledger()); @@ -333,6 +338,40 @@ pub(crate) async fn run_health_checks_sharing(graph_status: &RunGraphStatus) -> assemble_health_report(env::consts::OS.to_string(), checks) } +/// Installation identity is independent from repository and client readiness. +fn check_managed_runtime() -> HealthCheck { + let report = crate::commands::runtime::inspect(&Default::default()); + let ready = report.ready && report.pair_matches; + let detail = if ready { + format!( + "validated CLI and daemon pair at {} ({})", + report.cli.path.display(), + report.cli.commit_sha.as_deref().unwrap_or("unknown") + ) + } else { + report + .detail + .or(report.reason) + .unwrap_or_else(|| "the managed CLI and daemon pair did not pass validation".into()) + }; + HealthCheck { + id: "managed_runtime".into(), + label: "Managed runtime".into(), + status: if ready { + HealthStatus::Healthy + } else { + HealthStatus::Misconfigured + }, + detail, + platform_note: None, + fixable: false, + manual_fix: if ready { + None + } else { + Some("Run `kin runtime status --json`, then repair the managed installation before using clients".into()) + }, + } +} /// Report the active update policy and where it came from. /// @@ -659,6 +698,326 @@ fn supervisor_startup_protocol_check( } } +/// Whether the npm launcher's version stamp names the installed release. +/// +/// Launchers from before September 2026 read the stamp as the installed +/// version, so a stamp naming an older release than the one in `bin/` is how +/// such a launcher reinstalls its own release over a newer one. `kin update` +/// keeps the stamp current and recovery rewrites one it finds wrong; this row +/// is how a person, or `kin doctor --fix`, sees one that is still wrong. +fn check_launcher_stamp() -> HealthCheck { + // Never under test: the unit suite would read whatever this machine has + // installed. The verdict is exercised against constructed inspections. + if cfg!(test) { + return HealthCheck::new( + LAUNCHER_STAMP_ID, + LAUNCHER_STAMP_LABEL, + HealthStatus::Unsupported, + "not inspected in unit tests", + ); + } + match kin_dir() { + Ok(home) => { + launcher_stamp_check_from(crate::commands::update::inspect_launcher_stamp(&home)) + } + Err(error) => HealthCheck::new( + LAUNCHER_STAMP_ID, + LAUNCHER_STAMP_LABEL, + HealthStatus::Unsupported, + format!("the Kin home could not be resolved: {error}"), + ), + } +} + +const LAUNCHER_STAMP_ID: &str = "launcher_version_stamp"; +const LAUNCHER_STAMP_LABEL: &str = "Launcher version stamp"; + +pub(crate) fn launcher_stamp_check_from( + inspection: crate::commands::update::LauncherStampInspection, +) -> HealthCheck { + use crate::commands::update::LauncherStampInspection; + match inspection { + LauncherStampInspection::Absent => HealthCheck::new( + LAUNCHER_STAMP_ID, + LAUNCHER_STAMP_LABEL, + HealthStatus::Unsupported, + "no stamp; the npm launcher did not provision this install", + ), + LauncherStampInspection::Current { path, version } => HealthCheck::new( + LAUNCHER_STAMP_ID, + LAUNCHER_STAMP_LABEL, + HealthStatus::Healthy, + format!( + "{} names {version}, the installed release", + crate::screen::home_relative(&path) + ), + ), + LauncherStampInspection::Disagrees { + path, + recorded, + installed, + } => HealthCheck::new( + LAUNCHER_STAMP_ID, + LAUNCHER_STAMP_LABEL, + HealthStatus::Stale, + format!( + "{} names {recorded}, but the installed Kin is {installed}. An npm launcher older \ + than September 2026 trusts this stamp and can reinstall its own release over the \ + newer one", + crate::screen::home_relative(&path) + ), + ) + .fixable() + .with_manual_fix("kin doctor --fix"), + LauncherStampInspection::Unverifiable { + path, + recorded, + reason, + } => HealthCheck::new( + LAUNCHER_STAMP_ID, + LAUNCHER_STAMP_LABEL, + HealthStatus::Unsupported, + format!( + "{} names {recorded}; the installed Kin's release cannot be read to compare ({reason})", + crate::screen::home_relative(&path) + ), + ), + LauncherStampInspection::Unusable { path, reason } => HealthCheck::new( + LAUNCHER_STAMP_ID, + LAUNCHER_STAMP_LABEL, + HealthStatus::Misconfigured, + format!( + "{} is not a version stamp: {reason}", + crate::screen::home_relative(&path) + ), + ) + .with_manual_fix(format!( + "move {} aside; Kin never writes over it", + crate::screen::home_relative(&path) + )), + } +} + +const OTHER_COPIES_ID: &str = "other_kin_copies"; +const OTHER_COPIES_LABEL: &str = "Other Kin copies"; +const LIVE_DAEMONS_ID: &str = "live_daemons_by_release"; +const LIVE_DAEMONS_LABEL: &str = "Running daemons"; + +/// Every other copy of Kin this machine can run, and every running daemon by +/// the release it runs. One scan serves both rows. +fn check_kin_copies() -> Vec { + // Never under test, for the same reason as the stamp: the unit suite would + // grade whatever this machine has installed and running. + if cfg!(test) { + return vec![ + HealthCheck::new( + OTHER_COPIES_ID, + OTHER_COPIES_LABEL, + HealthStatus::Unsupported, + "not scanned in unit tests", + ), + HealthCheck::new( + LIVE_DAEMONS_ID, + LIVE_DAEMONS_LABEL, + HealthStatus::Unsupported, + "not scanned in unit tests", + ), + ]; + } + let roots = match crate::commands::kin_copies::ScanRoots::from_environment() { + Ok(roots) => roots, + Err(error) => { + let detail = format!("the Kin home could not be resolved: {error}"); + return vec![ + HealthCheck::new( + OTHER_COPIES_ID, + OTHER_COPIES_LABEL, + HealthStatus::Unsupported, + detail.clone(), + ), + HealthCheck::new( + LIVE_DAEMONS_ID, + LIVE_DAEMONS_LABEL, + HealthStatus::Unsupported, + detail, + ), + ]; + } + }; + let scan = crate::commands::kin_copies::scan(&roots); + let groups = crate::commands::kin_copies::group_live_daemons( + &crate::commands::kin_copies::live_daemons(), + &scan, + ); + vec![ + other_copies_check_from(&scan), + live_daemons_check_from(&groups), + ] +} + +fn plural(count: usize, one: &str, many: &str) -> String { + format!("{count} {}", if count == 1 { one } else { many }) +} + +/// The other-copies row for one scan. +pub(crate) fn other_copies_check_from(scan: &crate::commands::kin_copies::CopyScan) -> HealthCheck { + let managed = scan.managed_version.as_deref(); + let one_kin = match managed { + Some(version) => format!( + "the managed Kin {version} at {}", + crate::screen::home_relative(&scan.kin_home.join("bin")) + ), + None => format!( + "the managed Kin at {}", + crate::screen::home_relative(&scan.kin_home.join("bin")) + ), + }; + let concerns = scan.concerns(); + let harmless: Vec = scan + .others() + .filter(|copy| !copy.is_concern(managed)) + .map(|copy| copy.describe(managed)) + .collect(); + let also = if harmless.is_empty() { + String::new() + } else { + format!(". Also found, and harmless: {}", harmless.join("; ")) + }; + if concerns.is_empty() { + return HealthCheck::new( + OTHER_COPIES_ID, + OTHER_COPIES_LABEL, + HealthStatus::Healthy, + format!("no Kin besides {one_kin}{also}"), + ); + } + let named: Vec = concerns.iter().map(|copy| copy.describe(managed)).collect(); + let offers = scan.removal_offers(); + let unremovable: Vec = concerns + .iter() + .filter(|copy| copy.removal.is_none()) + .map(|copy| crate::screen::home_relative(©.path)) + .collect(); + let mut fix = Vec::new(); + if !offers.is_empty() { + fix.push( + "kin setup asks about each one; kin setup doctor --fix --remove-other-copies removes \ + them all with each one's own uninstall:" + .to_string(), + ); + for offer in &offers { + fix.push(offer.removal.command_line()); + } + } + if !unremovable.is_empty() { + fix.push(format!( + "Kin cannot tell what installed {}; remove {} yourself.", + unremovable.join(", "), + if unremovable.len() == 1 { "it" } else { "them" } + )); + } + HealthCheck::new( + OTHER_COPIES_ID, + OTHER_COPIES_LABEL, + HealthStatus::Stale, + format!( + "{} besides {one_kin}: {}{also}", + plural( + concerns.len(), + "other Kin can run here", + "other Kins can run here" + ), + named.join("; ") + ), + ) + .with_manual_fix(fix.join("\n")) +} + +/// The running-daemons row for daemons grouped by release. +pub(crate) fn live_daemons_check_from( + groups: &[crate::commands::kin_copies::DaemonGroup], +) -> HealthCheck { + let total: usize = groups.iter().map(|group| group.daemons.len()).sum(); + if total == 0 { + return HealthCheck::new( + LIVE_DAEMONS_ID, + LIVE_DAEMONS_LABEL, + HealthStatus::Healthy, + "no kin-daemon is running", + ); + } + let describe = |group: &crate::commands::kin_copies::DaemonGroup| { + let pids: Vec = group + .daemons + .iter() + .map(|daemon| match &daemon.repo { + Some(repo) => format!( + "pid {} for {}", + daemon.pid, + crate::screen::home_relative(repo) + ), + None if daemon.supervisor => format!("pid {}, the supervisor", daemon.pid), + None => format!("pid {}", daemon.pid), + }) + .collect(); + format!( + "{} {} ({})", + plural(group.daemons.len(), "runs", "run"), + group.release, + pids.join(", ") + ) + }; + let concerns: Vec = groups + .iter() + .filter(|group| group.concern) + .map(describe) + .collect(); + let rest: Vec = groups + .iter() + .filter(|group| !group.concern) + .map(describe) + .collect(); + if concerns.is_empty() { + return HealthCheck::new( + LIVE_DAEMONS_ID, + LIVE_DAEMONS_LABEL, + HealthStatus::Healthy, + format!( + "{}: {}", + plural(total, "kin-daemon", "kin-daemons"), + rest.join("; ") + ), + ); + } + let stray: usize = groups + .iter() + .filter(|group| group.concern) + .map(|group| group.daemons.len()) + .sum(); + let mut detail = format!( + "{} not the managed Kin: {}", + if stray == 1 { + "1 kin-daemon runs a release that is".to_string() + } else { + format!("{stray} kin-daemons run releases that are") + }, + concerns.join("; ") + ); + if !rest.is_empty() { + detail.push_str(&format!(". The rest: {}", rest.join("; "))); + } + HealthCheck::new( + LIVE_DAEMONS_ID, + LIVE_DAEMONS_LABEL, + HealthStatus::Stale, + detail, + ) + .with_manual_fix( + "Each keeps answering from its own release until it stops. Run kin daemon stop in each \ + repository named above; the managed Kin starts its own daemon on the next command there.", + ) +} + fn check_supervisor_startup_protocol() -> HealthCheck { let sentinel = crate::daemon_client::supervisor_startup_sentinel(); let sentinel_path = crate::daemon_client::supervisor_startup_sentinel_path(); @@ -666,8 +1025,10 @@ fn check_supervisor_startup_protocol() -> HealthCheck { // daemon_client. The probe is requested only in the state whose answer can // change the verdict, but that state is the steady one for every current // user, so this runs on essentially every invocation rather than rarely. - // What keeps it acceptable is its bound, not its frequency: at most two - // deduped candidates, each capped by the daemon probe timeout. + // What keeps it acceptable is its bound, not its frequency: one probe per + // deduped native `kin` the copy enumeration finds, which on a machine with + // one Kin is the managed pair alone, each capped by the daemon probe + // timeout. // // Never under test. This runs inside the unit suite through // `run_health_checks`, where spawning the host's installed kin-daemon would @@ -3032,13 +3393,23 @@ fn evaluate_antigravity_binding_for( let root: Value = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?; let entry = root.get("mcpServers")?.get("kin")?; let expected_command = configured_mcp_launcher().ok()?; + evaluate_antigravity_binding_against(entry, path, workspace, repo_root, &expected_command) +} + +fn evaluate_antigravity_binding_against( + entry: &Value, + path: &Path, + workspace: bool, + repo_root: &Path, + expected_command: &str, +) -> Option<(HealthStatus, String)> { let client_id = if workspace { "antigravity_workspace" } else { "antigravity" }; let expected_repo = repo_root.to_string_lossy(); - let topology = mcp_launcher_topology(entry, &expected_command); + let topology = mcp_launcher_topology(entry, expected_command); let launcher_and_args_match = topology.is_some_and(|topology| { mcp_argument_vector_matches(entry, client_id, topology) && mcp_repo_argument(entry, topology) == Some(expected_repo.as_ref()) @@ -12720,9 +13091,11 @@ mod tests { } let dir = tempfile::tempdir().unwrap(); let kin_home = dir.path().join("kin-home"); - let managed = kin_home.join("bin/kin"); - std::fs::create_dir_all(managed.parent().unwrap()).unwrap(); - std::fs::copy(std::env::current_exe().unwrap(), &managed).unwrap(); + crate::commands::setup::write_managed_runtime_fixture( + &kin_home, + "1111111111111111111111111111111111111111", + ); + let managed = kin_home.join("bin/kin").canonicalize().unwrap(); let _kin_home = EnvVarGuard::set("KIN_HOME", &kin_home); let path = dir.path().join("claude.json"); let config = |command: &Path| { @@ -12901,12 +13274,12 @@ mod tests { let (status, detail) = evaluate_mcp_client_against(&config, "antigravity_workspace", "/managed/kin"); assert!(matches!(status, HealthStatus::Healthy), "got: {detail}"); - assert!(evaluate_antigravity_binding(&config, true).is_none()); + assert!(evaluate_antigravity_fixture_binding(&config, true, &repo).is_none()); let other = dir.path().join("other"); std::fs::create_dir_all(&other).unwrap(); write_config(&other); - assert!(evaluate_antigravity_binding(&config, true).is_some()); + assert!(evaluate_antigravity_fixture_binding(&config, true, &repo).is_some()); } /// Write the global Antigravity binding exactly as `kin setup` does: with @@ -12929,6 +13302,21 @@ mod tests { .unwrap(); } + fn evaluate_antigravity_fixture_binding( + config: &Path, + workspace: bool, + repo_root: &Path, + ) -> Option<(HealthStatus, String)> { + let root: Value = serde_json::from_slice(&std::fs::read(config).unwrap()).unwrap(); + evaluate_antigravity_binding_against( + &root["mcpServers"]["kin"], + config, + workspace, + repo_root, + "/managed/kin", + ) + } + /// The two sides of an MCP binding check name the repository differently: /// the writers canonicalize, discovery inherits the working directory's raw /// form. Normalizing at the source is what lets the exact-string comparison @@ -12990,8 +13378,12 @@ mod tests { assert_ne!(discovered, canonical); assert!( - evaluate_antigravity_binding_for(&config, false, &canonical_health_repo(discovered)) - .is_none(), + evaluate_antigravity_fixture_binding( + &config, + false, + &canonical_health_repo(discovered) + ) + .is_none(), "a binding written with the canonicalized repository root must be accepted when \ discovery names that same repository through an equivalent path" ); @@ -13003,7 +13395,7 @@ mod tests { let other = other.canonicalize().unwrap(); write_global_antigravity_binding(&config, &other); assert!( - evaluate_antigravity_binding_for(&config, false, &canonical_health_repo(canonical)) + evaluate_antigravity_fixture_binding(&config, false, &canonical_health_repo(canonical)) .is_some(), "a binding pointed at a different repository must still be misconfigured" ); @@ -13035,7 +13427,7 @@ mod tests { write_global_antigravity_binding(&config, &canonical); assert!( - evaluate_antigravity_binding_for( + evaluate_antigravity_fixture_binding( &config, false, &canonical_health_repo(PathBuf::from(plain)), @@ -13046,17 +13438,13 @@ mod tests { ); // Without this, the assertion above could pass for the wrong reason. - // `evaluate_antigravity_binding_for` returns None both when the binding - // is correct and when it cannot resolve this installation's launcher at - // all, and this is the first test to reach that code on a Windows - // runner. A binding pointed elsewhere must still be rejected, so a - // launcher that failed to resolve fails the test instead of passing it. + // A binding pointed elsewhere must still be rejected. let other = dir.path().join("other"); std::fs::create_dir_all(&other).unwrap(); let other = other.canonicalize().unwrap(); write_global_antigravity_binding(&config, &other); assert!( - evaluate_antigravity_binding_for( + evaluate_antigravity_fixture_binding( &config, false, &canonical_health_repo(PathBuf::from(plain)), @@ -14573,4 +14961,230 @@ mod tests { ); } } + + #[test] + fn a_launcher_stamp_naming_another_release_is_stale_and_doctor_fixes_it() { + use crate::commands::update::LauncherStampInspection; + + let path = PathBuf::from("/fixture/.kin/bin/.kinlab-kin-version"); + let stale = launcher_stamp_check_from(LauncherStampInspection::Disagrees { + path: path.clone(), + recorded: "0.6.6".to_string(), + installed: "0.8.6".to_string(), + }); + assert_eq!(stale.id, "launcher_version_stamp"); + assert!(matches!(stale.status, HealthStatus::Stale)); + assert!(stale.fixable, "doctor --fix owns this repair"); + assert!( + stale + .detail + .contains("names 0.6.6, but the installed Kin is 0.8.6"), + "{}", + stale.detail + ); + assert_eq!(stale.manual_fix.as_deref(), Some("kin doctor --fix")); + + let current = launcher_stamp_check_from(LauncherStampInspection::Current { + path: path.clone(), + version: "0.8.6".to_string(), + }); + assert!(matches!(current.status, HealthStatus::Healthy)); + assert!(!current.fixable); + + let absent = launcher_stamp_check_from(LauncherStampInspection::Absent); + assert!(matches!(absent.status, HealthStatus::Unsupported)); + + let unusable = launcher_stamp_check_from(LauncherStampInspection::Unusable { + path, + reason: "it is not a regular file".to_string(), + }); + assert!(matches!(unusable.status, HealthStatus::Misconfigured)); + assert!(!unusable.fixable, "Kin never writes over something else"); + } + + fn copy( + path: &str, + program: &'static str, + kind: crate::commands::kin_copies::CopyKind, + version: Option<&str>, + removal: Option, + ) -> crate::commands::kin_copies::KinCopy { + crate::commands::kin_copies::KinCopy { + path: PathBuf::from(path), + program, + target: None, + kind, + version: version.map(str::to_string), + channel: None, + removal, + first_on_path: false, + in_kin_home: false, + } + } + + #[test] + fn other_copies_name_each_one_and_the_uninstall_that_removes_it() { + use crate::commands::kin_copies::{CopyKind, CopyScan, Removal}; + + let mut old_npm = copy( + "/node/bin/kin", + "kin", + CopyKind::NpmLauncher { + package: "@kinlab/kin".to_string(), + }, + Some("0.6.6"), + Some(Removal::Npm { + prefix: PathBuf::from("/node"), + package: "@kinlab/kin".to_string(), + }), + ); + old_npm.first_on_path = true; + let mut cargo = copy( + "/cargo/bin/kin", + "kin", + CopyKind::IndependentRelease, + Some("0.7.18"), + Some(Removal::Cargo { + root: PathBuf::from("/cargo"), + package: "kin-cli".to_string(), + }), + ); + cargo.channel = Some("cargo install kin-cli".to_string()); + let scan = CopyScan { + kin_home: PathBuf::from("/fixture/.kin"), + managed_version: Some("0.8.7".to_string()), + copies: vec![ + copy( + "/fixture/.kin/bin/kin", + "kin", + CopyKind::Managed, + Some("0.8.7"), + None, + ), + copy( + "/usr/local/bin/kin", + "kin", + CopyKind::LinkToManaged, + None, + None, + ), + old_npm, + cargo, + copy( + "/src/target/release/kin", + "kin", + CopyKind::DevelopmentBuild, + None, + None, + ), + copy( + "/scripts/kin", + "kin", + CopyKind::Unknown("a script".to_string()), + None, + None, + ), + ], + }; + + let check = other_copies_check_from(&scan); + assert_eq!(check.id, "other_kin_copies"); + assert!(matches!(check.status, HealthStatus::Stale)); + assert!( + !check.fixable, + "plain --fix never removes a copy; it takes --remove-other-copies" + ); + for expected in [ + "3 other Kins can run here besides the managed Kin 0.8.7 at /fixture/.kin/bin", + "/node/bin/kin: npm launcher @kinlab/kin 0.6.6, older than the managed Kin 0.8.7; a new shell runs this `kin`", + "/cargo/bin/kin: Kin 0.7.18, older than the managed Kin 0.8.7, a separate copy that runs its own daemons (cargo install kin-cli)", + "/scripts/kin: unidentified (a script)", + "Also found, and harmless: /usr/local/bin/kin: a link to the managed Kin; /src/target/release/kin: a development build", + ] { + assert!(check.detail.contains(expected), "missing {expected:?} in {}", check.detail); + } + let fix = check.manual_fix.as_deref().unwrap(); + for expected in [ + "kin setup doctor --fix --remove-other-copies", + "npm uninstall --global --prefix /node @kinlab/kin", + "cargo uninstall --root /cargo kin-cli", + "Kin cannot tell what installed /scripts/kin; remove it yourself.", + ] { + assert!(fix.contains(expected), "missing {expected:?} in {fix}"); + } + + let one_kin = CopyScan { + copies: scan + .copies + .iter() + .filter(|copy| { + matches!( + copy.kind, + CopyKind::Managed | CopyKind::LinkToManaged | CopyKind::DevelopmentBuild + ) + }) + .cloned() + .collect(), + ..scan.clone() + }; + let healthy = other_copies_check_from(&one_kin); + assert!(matches!(healthy.status, HealthStatus::Healthy)); + assert!( + healthy + .detail + .starts_with("no Kin besides the managed Kin 0.8.7 at /fixture/.kin/bin"), + "{}", + healthy.detail + ); + assert!( + healthy.detail.contains("a development build"), + "{}", + healthy.detail + ); + } + + #[test] + fn running_daemons_are_named_by_release_and_only_strays_need_attention() { + use crate::commands::kin_copies::{DaemonGroup, LiveDaemon}; + + let daemon = |pid: u32| LiveDaemon { + pid, + executable: PathBuf::from("/x/kin-daemon"), + repo: Some(PathBuf::from(format!("/repo/{pid}"))), + supervisor: false, + started_at: 0, + }; + let managed = DaemonGroup { + release: "the managed Kin 0.8.7".to_string(), + concern: false, + daemons: vec![daemon(1), daemon(2)], + }; + let replaced = DaemonGroup { + release: "a managed release that has since been replaced".to_string(), + concern: true, + daemons: vec![daemon(3)], + }; + + assert!(matches!( + live_daemons_check_from(&[]).status, + HealthStatus::Healthy + )); + let calm = live_daemons_check_from(std::slice::from_ref(&managed)); + assert!(matches!(calm.status, HealthStatus::Healthy)); + assert_eq!( + calm.detail, + "2 kin-daemons: 2 run the managed Kin 0.8.7 (pid 1 for /repo/1, pid 2 for /repo/2)" + ); + + let check = live_daemons_check_from(&[managed, replaced]); + assert_eq!(check.id, "live_daemons_by_release"); + assert!(matches!(check.status, HealthStatus::Stale)); + assert_eq!( + check.detail, + "1 kin-daemon runs a release that is not the managed Kin: 1 runs a managed release \ + that has since been replaced (pid 3 for /repo/3). The rest: 2 run the managed Kin \ + 0.8.7 (pid 1 for /repo/1, pid 2 for /repo/2)" + ); + assert!(check.manual_fix.unwrap().contains("kin daemon stop")); + } } diff --git a/crates/kin-cli/src/commands/history_derive.rs b/crates/kin-cli/src/commands/history_derive.rs index 36c613ba3..4bce43525 100644 --- a/crates/kin-cli/src/commands/history_derive.rs +++ b/crates/kin-cli/src/commands/history_derive.rs @@ -7,7 +7,9 @@ //! commit and answers at once. Its history is derived afterwards by a worker //! process that runs the complete admission ladder into a stage beside the //! repository, at low priority and with a bounded share of the machine, and -//! then attaches the staged store once no daemon is serving the partial one. +//! then attaches the staged store when no daemon is serving the partial one. +//! A daemon that is serving it finds the stage within seconds and hands the +//! store over at its next quiet point (see `kin_daemon::history_schedule`). //! `kin init --history full` over a partial store does the same work in the //! foreground and attaches at once. //! @@ -179,12 +181,8 @@ fn report(layout: &KinLayout, progress: &HistoryDerivationProgress) { } /// A stage a previous attempt finished, still on disk. -fn finished_stage(progress: Option<&HistoryDerivationProgress>) -> Option { - let progress = progress?; - if progress.state != DerivationState::Staged { - return None; - } - let stage = PathBuf::from(progress.detail.as_deref()?); +pub fn finished_stage(progress: Option<&HistoryDerivationProgress>) -> Option { + let stage = progress?.staged_at()?; history_attach::staged_store(&stage) .join("manifest.json") .is_file() @@ -204,6 +202,18 @@ pub(crate) enum DerivationOutcome { Staged { stage: PathBuf }, } +/// What asking for a staged store to be attached came to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Attachment { + /// The store now holds its complete history. + Attached, + /// A daemon is serving the partial store, and hands it over at its next + /// quiet point. + Served, + /// The partial store holds open work, which the attach waits for. + Waiting(history_attach::OpenWork), +} + /// Derive the store's complete history into a stage, reusing a stage an /// earlier attempt finished. /// @@ -329,7 +339,14 @@ fn first_line(text: &str) -> String { /// Attach a staged store, stopping the daemon first when `stop_daemon`, and /// otherwise only when no daemon serves the store. -async fn attach(layout: &KinLayout, stage: &Path, stop_daemon: bool) -> Result { +/// +/// Open work is looked for before a daemon is stopped for it: stopping the +/// daemon would not finish that work, and the attach would then wait anyway. +async fn attach(layout: &KinLayout, stage: &Path, stop_daemon: bool) -> Result { + if let Some(work) = history_attach::open_work(layout) { + note_wait(layout, Some(work)); + return Ok(Attachment::Waiting(work)); + } if stop_daemon { super::daemon::stop_current_repo_quiet(layout.root()) .await @@ -338,7 +355,7 @@ async fn attach(layout: &KinLayout, stage: &Path, stop_daemon: bool) -> Result Result Ok(true), + AttachOutcome::Attached | AttachOutcome::AlreadyComplete => Ok(Attachment::Attached), + AttachOutcome::Deferred(work) => { + note_wait(layout, Some(work)); + Ok(Attachment::Waiting(work)) + } + } +} + +/// Finish a history attach a crash interrupted, for a command that is about +/// to read the store. +/// +/// Whichever process next holds the repository's runtime authority finishes +/// the swap. A daemon does as it starts. A command that finds the journal +/// does it here, because the half-moved store has no manifest and no layout +/// version to read, and the command would otherwise refuse the store before +/// it ever started the daemon that finishes it. When a daemon holds the +/// authority, that daemon is finishing the swap, and nothing is done here. +pub(crate) fn finish_interrupted_attach(layout: &KinLayout) { + if !history_attach::attach_is_interrupted(layout) { + return; + } + match crate::daemon_client::acquire_repository_runtime_authority_within( + layout.root(), + kin_daemon_spawn::REPOSITORY_RUNTIME_AUTHORITY_RETRY_BUDGET, + ) { + Ok(Some(authority)) => { + if let Err(error) = history_attach::recover_interrupted_attach(layout) { + tracing::warn!(%error, "an interrupted history attach could not be finished"); + } + drop(authority); + } + Ok(None) => tracing::debug!( + "an interrupted history attach is being finished by the daemon on this store" + ), + Err(error) => { + tracing::warn!(%error, "could not take this repository's runtime authority to finish an interrupted history attach") + } + } +} + +/// Record what a staged history's attach waits for, never failing the +/// derivation for it. +fn note_wait(layout: &KinLayout, work: Option) { + if let Err(error) = history_state::note_staged_wait(layout, work.map(|work| work.describe())) { + tracing::warn!(%error, "could not record what the staged history is waiting for"); } } @@ -396,17 +457,27 @@ pub async fn run_worker(path: Option, background: bool) -> Result { .map_err(|error| anyhow!("history derivation stopped: {error}"))??; match outcome { DerivationOutcome::Staged { stage } => { - if attach(&layout, &stage, !background).await? { - let _ = history_state::clear_progress(&layout); - drop(lock); - say("Attached the full Git history; this store's history is complete"); - prepare_attached_store(&layout); - } else { - drop(lock); - say( - "Derived the full Git history; it replaces this store when its daemon is \ - next idle", - ); + match attach(&layout, &stage, !background).await? { + Attachment::Attached => { + let _ = history_state::clear_progress(&layout); + drop(lock); + say("Attached the full Git history; this store's history is complete"); + prepare_attached_store(&layout); + } + Attachment::Served => { + drop(lock); + say( + "Derived the full Git history; this repository switches to it at its \ + daemon's next quiet point", + ); + } + Attachment::Waiting(work) => { + drop(lock); + say(&format!( + "Derived the full Git history; this repository switches to it once it is \ + quiet (waiting for {work})" + )); + } } Ok(0) } @@ -450,8 +521,20 @@ pub(crate) async fn derive_now(layout: &KinLayout, quiet: bool) -> Result { let attached = attach(layout, &stage, true).await?; drop(lock); - if !attached { - bail!("the full history was derived but could not be attached"); + match attached { + Attachment::Attached => {} + Attachment::Served => { + bail!("the full history was derived but could not be attached") + } + Attachment::Waiting(work) => { + return Ok(ForegroundDerivation { + history: "staged", + detail: format!( + "Derived the full Git history. This repository switches to it once \ + it is quiet: Kin is waiting for {work}" + ), + }) + } } let _ = history_state::clear_progress(layout); prepare_attached_store(layout); @@ -486,3 +569,58 @@ fn prepare_attached_store(layout: &KinLayout) { tracing::warn!(%error, "could not prepare the attached store's first graph reopen"); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A command that finds a store half way through an interrupted attach + /// finishes the attach before it reads the store, so the store it reads + /// has its manifest and layout version again. + #[test] + fn a_command_finishes_an_interrupted_attach_before_it_reads_the_store() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("repo/.kin"); + std::fs::create_dir_all(root.join("logs")).unwrap(); + std::fs::write(root.join("daemon.lock"), b"").unwrap(); + let stage = dir.path().join(".kin-history-stage"); + let staged = history_attach::staged_store(&stage); + std::fs::create_dir_all(staged.join("kindb")).unwrap(); + std::fs::write(staged.join("manifest.json"), b"complete").unwrap(); + std::fs::write(staged.join("version"), b"2").unwrap(); + // Everything of the partial store has been moved out and nothing of + // the complete one has been moved in: the store a kill leaves behind. + std::fs::create_dir_all(stage.join("provisional/kindb")).unwrap(); + std::fs::write(stage.join("provisional/manifest.json"), b"partial").unwrap(); + std::fs::write( + root.join("history-attach.json"), + serde_json::to_vec(&serde_json::json!({ + "schema": "kin.history-attach.v1", + "phase": "evacuating", + "stage_parent": stage, + })) + .unwrap(), + ) + .unwrap(); + let layout = KinLayout::new(root.clone()); + assert!(history_attach::attach_is_interrupted(&layout)); + assert!(layout.check_version().is_err()); + + finish_interrupted_attach(&layout); + + assert!(!history_attach::attach_is_interrupted(&layout)); + assert_eq!( + std::fs::read(root.join("manifest.json")).unwrap(), + b"complete" + ); + layout.check_version().unwrap(); + assert!(root.join("daemon.lock").exists()); + assert!(!stage.exists()); + // Nothing left to finish, and asking again changes nothing. + finish_interrupted_attach(&layout); + assert_eq!( + std::fs::read(root.join("manifest.json")).unwrap(), + b"complete" + ); + } +} diff --git a/crates/kin-cli/src/commands/intent.rs b/crates/kin-cli/src/commands/intent.rs index 3c41c5465..5da2611fa 100644 --- a/crates/kin-cli/src/commands/intent.rs +++ b/crates/kin-cli/src/commands/intent.rs @@ -3,20 +3,21 @@ use anyhow::{Context, Result}; -async fn daemon_base_url() -> Result { +/// The daemon serving this repository, with the authority and runtime +/// admission it checks on every request. +async fn daemon() -> Result { let layout = crate::commands::require_repository_layout()?; - crate::daemon_client::resolve_daemon_url(&layout) + let url = crate::daemon_client::resolve_daemon_url(&layout) .await? - .ok_or_else(|| crate::daemon_client::daemon_required_error("intent commands", &layout)) + .ok_or_else(|| crate::daemon_client::daemon_required_error("intent commands", &layout))?; + crate::daemon_client::DaemonClient::from_base_url_for_layout(url, &layout) } /// `kin intent list` — Show all active intents via daemon API. pub async fn list() -> Result<()> { - let client = reqwest::Client::new(); - let daemon_url = daemon_base_url().await?; - let resp = client - .get(format!("{}/intent", daemon_url)) - .send() + let daemon = daemon().await?; + let resp = daemon + .send_untyped(reqwest::Method::GET, "/intent", "intent list", None) .await .context("query daemon intent endpoint")?; if resp.status().is_success() { @@ -64,8 +65,7 @@ pub async fn register( task: String, session: Option, ) -> Result<()> { - let client = reqwest::Client::new(); - let daemon_url = daemon_base_url().await?; + let daemon = daemon().await?; let body = serde_json::json!({ "scope": scope, @@ -74,10 +74,13 @@ pub async fn register( "session_id": session, }); - let resp = client - .post(format!("{}/intent/register", daemon_url)) - .json(&body) - .send() + let resp = daemon + .send_untyped( + reqwest::Method::POST, + "/intent/register", + "intent register", + Some(&body), + ) .await .context("query daemon intent register endpoint")?; if resp.status().is_success() { @@ -131,12 +134,15 @@ pub async fn register( /// `kin intent release ` — Release an intent via daemon API. pub async fn release(intent_id: String) -> Result<()> { - let client = reqwest::Client::new(); - let daemon_url = daemon_base_url().await?; + let daemon = daemon().await?; - let resp = client - .delete(format!("{}/intent/{}", daemon_url, intent_id)) - .send() + let resp = daemon + .send_untyped( + reqwest::Method::DELETE, + &format!("/intent/{intent_id}"), + "intent release", + None, + ) .await .context("query daemon intent release endpoint")?; if resp.status().is_success() { @@ -151,12 +157,15 @@ pub async fn release(intent_id: String) -> Result<()> { /// `kin intent clear ` — Clear all intents for a session via daemon API. pub async fn clear(session_id: String) -> Result<()> { - let client = reqwest::Client::new(); - let daemon_url = daemon_base_url().await?; + let daemon = daemon().await?; - let resp = client - .delete(format!("{}/session/{}/intents", daemon_url, session_id)) - .send() + let resp = daemon + .send_untyped( + reqwest::Method::DELETE, + &format!("/session/{session_id}/intents"), + "intent clear", + None, + ) .await .context("query daemon intent clear endpoint")?; if resp.status().is_success() { diff --git a/crates/kin-cli/src/commands/kin_copies.rs b/crates/kin-cli/src/commands/kin_copies.rs new file mode 100644 index 000000000..79295787b --- /dev/null +++ b/crates/kin-cli/src/commands/kin_copies.rs @@ -0,0 +1,1658 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Every copy of Kin this machine can run, and what each one is. +//! +//! A machine has one Kin: the managed pair in `KIN_HOME/bin`, which the +//! installer and `kin update` replace. Anything else named `kin`, `kin-daemon` +//! or `kin-mcp` that a shell, an agent or a launcher can reach is found here, +//! named plainly, and paired with the uninstall its own channel provides. +//! +//! Finding and naming copies is read-only. Removal runs only a channel's own +//! uninstall (npm, cargo, Homebrew), or deletes a standalone release file a +//! person placed by hand, and only for a copy a person chose or an explicit +//! flag asked for. A development build and anything inside the managed Kin +//! home are never offered for removal, whatever they are. +//! +//! This is install boundary code: it reads directory entries, package +//! manifests and executable headers, and runs package managers when asked. + +use std::collections::{HashMap, HashSet}; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use semver::Version; + +/// The programs a Kin install puts on PATH. +pub(crate) const PROGRAMS: [&str; 3] = ["kin", "kin-daemon", "kin-mcp"]; + +/// The first release whose `kin` hands every command it is given to the +/// managed pair, so a copy of it on PATH cannot run a second Kin. +const FIRST_ROUTING_RELEASE: (u64, u64, u64) = (0, 8, 7); + +/// The npm packages that install Kin launchers. +const NPM_PACKAGES: [&str; 2] = ["@kinlab/kin", "@kinlab/kin-mcp"]; + +/// What a copy is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CopyKind { + /// The managed pair itself, in `KIN_HOME/bin`. + Managed, + /// A link, or the same file under another name, that is the managed pair. + LinkToManaged, + /// An npm launcher, which runs the managed pair rather than a Kin of its + /// own. One older than the managed Kin can refuse to run, and launchers + /// from before September 2026 trusted the version stamp and could + /// reinstall their own release over a newer one. + NpmLauncher { package: String }, + /// A Kin release from 0.8.7 on, which hands every command to the managed + /// pair. + RoutesToManaged, + /// A Kin release that runs by itself, with its own daemons. + IndependentRelease, + /// A build from a source tree. + DevelopmentBuild, + /// A link whose target is gone. It names a Kin that cannot run. + BrokenLink, + /// Named like Kin, but nothing here says what it is. + Unknown(String), +} + +/// The command a copy's own channel removes it with. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum Removal { + /// `npm uninstall --global --prefix `. + Npm { prefix: PathBuf, package: String }, + /// `cargo uninstall --root `. + Cargo { root: PathBuf, package: String }, + /// `brew uninstall `. + Homebrew { formula: String }, + /// A standalone release file placed by hand, removed by deleting it. + DeleteFile { path: PathBuf }, +} + +impl Removal { + /// The command, as a person would type it. + pub(crate) fn command_line(&self) -> String { + match self { + Self::Npm { prefix, package } => format!( + "npm uninstall --global --prefix {} {package}", + shell_word(prefix) + ), + Self::Cargo { root, package } => { + format!("cargo uninstall --root {} {package}", shell_word(root)) + } + Self::Homebrew { formula } => format!("brew uninstall {formula}"), + Self::DeleteFile { path } => format!("rm {}", shell_word(path)), + } + } +} + +fn shell_word(path: &Path) -> String { + let text = path.display().to_string(); + if text + .chars() + .all(|c| c.is_ascii_alphanumeric() || "/._-~+@:".contains(c)) + { + text + } else { + format!("'{}'", text.replace('\'', r"'\''")) + } +} + +/// One file named like Kin, and what it is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct KinCopy { + /// Where it was found, as a shell would name it. + pub(crate) path: PathBuf, + pub(crate) program: &'static str, + /// What it resolves to, when that is another path. + pub(crate) target: Option, + pub(crate) kind: CopyKind, + pub(crate) version: Option, + /// How it got here, when that is known: `cargo install kin-cli`, an npm + /// prefix, a Homebrew formula. + pub(crate) channel: Option, + /// Its channel's uninstall, when removing it is something Kin offers. + pub(crate) removal: Option, + /// Whether a new shell runs this one for its program name. + pub(crate) first_on_path: bool, + /// Whether it sits inside the managed Kin home. + pub(crate) in_kin_home: bool, +} + +impl KinCopy { + /// One plain sentence naming the copy. + pub(crate) fn describe(&self, managed_version: Option<&str>) -> String { + let version = self + .version + .as_deref() + .map(|version| format!(" {version}")) + .unwrap_or_default(); + let what = match &self.kind { + CopyKind::Managed => format!("the managed Kin{version}"), + CopyKind::LinkToManaged => "a link to the managed Kin".to_string(), + CopyKind::NpmLauncher { package } => { + let relation = match (self.version.as_deref(), managed_version) { + (Some(own), Some(managed)) if version_lt(own, managed) => { + format!(", older than the managed Kin {managed}") + } + _ => ", which runs the managed Kin".to_string(), + }; + format!("npm launcher {package}{version}{relation}") + } + CopyKind::RoutesToManaged => { + format!("Kin{version}, which hands its commands to the managed Kin") + } + CopyKind::IndependentRelease => { + let older = match (self.version.as_deref(), managed_version) { + (Some(own), Some(managed)) if version_lt(own, managed) => { + format!(", older than the managed Kin {managed},") + } + _ => ",".to_string(), + }; + format!("Kin{version}{older} a separate copy that runs its own daemons") + } + CopyKind::DevelopmentBuild => format!("a development build{version}"), + CopyKind::BrokenLink => { + return match &self.target { + Some(target) => format!( + "{}: a link to {}, which no longer exists", + display_path(&self.path), + display_path(target) + ), + None => format!( + "{}: a link to something that no longer exists", + display_path(&self.path) + ), + }; + } + CopyKind::Unknown(reason) => format!("unidentified ({reason})"), + }; + let mut sentence = format!("{}: {what}", display_path(&self.path)); + if let Some(channel) = &self.channel { + sentence.push_str(&format!(" ({channel})")); + } + if let Some(target) = &self.target { + if !matches!(self.kind, CopyKind::LinkToManaged) { + sentence.push_str(&format!(", linked to {}", display_path(target))); + } + } + if self.first_on_path { + sentence.push_str(&format!("; a new shell runs this `{}`", self.program)); + } + sentence + } + + /// Whether this copy is a second Kin rather than the one, or a way in to + /// the one. + pub(crate) fn is_concern(&self, managed_version: Option<&str>) -> bool { + match &self.kind { + CopyKind::Managed + | CopyKind::LinkToManaged + | CopyKind::RoutesToManaged + | CopyKind::DevelopmentBuild + | CopyKind::BrokenLink => false, + CopyKind::NpmLauncher { .. } => { + matches!((self.version.as_deref(), managed_version), + (Some(own), Some(managed)) if version_lt(own, managed)) + } + CopyKind::IndependentRelease | CopyKind::Unknown(_) => true, + } + } +} + +/// Everything one scan found. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct CopyScan { + pub(crate) kin_home: PathBuf, + /// The managed `kin`'s release, from its own bytes. + pub(crate) managed_version: Option, + pub(crate) copies: Vec, +} + +/// One removal Kin can offer, which may cover several programs of one package. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RemovalOffer { + pub(crate) removal: Removal, + /// What is removed, in a few words: `npm launcher @kinlab/kin 0.6.6`. + pub(crate) what: String, + /// The paths it removes. + pub(crate) paths: Vec, +} + +impl CopyScan { + /// Every copy that is not the managed pair itself. + pub(crate) fn others(&self) -> impl Iterator { + self.copies + .iter() + .filter(|copy| !matches!(copy.kind, CopyKind::Managed)) + } + + /// The copies that are a second Kin. + pub(crate) fn concerns(&self) -> Vec<&KinCopy> { + self.copies + .iter() + .filter(|copy| copy.is_concern(self.managed_version.as_deref())) + .collect() + } + + /// The removals Kin can offer, one per package or file. + pub(crate) fn removal_offers(&self) -> Vec { + let mut offers: Vec = Vec::new(); + for copy in &self.copies { + let Some(removal) = ©.removal else { + continue; + }; + if let Some(offer) = offers.iter_mut().find(|offer| &offer.removal == removal) { + if !offer.paths.contains(©.path) { + offer.paths.push(copy.path.clone()); + } + continue; + } + let version = copy + .version + .as_deref() + .map(|version| format!(" {version}")) + .unwrap_or_default(); + let what = match (©.kind, removal) { + (CopyKind::NpmLauncher { package }, _) => { + format!("npm launcher {package}{version}") + } + (_, Removal::Cargo { package, .. }) => format!("cargo package {package}{version}"), + (_, Removal::Homebrew { formula }) => { + format!("Homebrew formula {formula}{version}") + } + _ => format!("Kin{version} at {}", display_path(©.path)), + }; + offers.push(RemovalOffer { + removal: removal.clone(), + what, + paths: vec![copy.path.clone()], + }); + } + offers + } +} + +/// Where a scan looks. +#[derive(Clone, Debug, Default)] +pub(crate) struct ScanRoots { + pub(crate) kin_home: PathBuf, + /// The directories of `PATH`, in order. + pub(crate) path_dirs: Vec, + pub(crate) cargo_home: Option, + /// npm global prefixes beyond the ones `PATH` implies. + pub(crate) npm_prefixes: Vec, + /// Further directories installs have used, such as `/usr/local/bin`. + pub(crate) extra_dirs: Vec, + /// Whether an executable with no build identity may be asked for its + /// version. Never in unit tests, where it would run whatever the host + /// has installed. + pub(crate) probe_versions: bool, +} + +impl ScanRoots { + /// The roots of this machine and this process's environment. + pub(crate) fn from_environment() -> Result { + let kin_home = crate::commands::setup::kin_dir()?; + let home = crate::commands::setup::home_dir().ok(); + let path_dirs = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default(); + let cargo_home = std::env::var_os("CARGO_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| home.as_ref().map(|home| home.join(".cargo"))); + let mut npm_prefixes = Vec::new(); + for key in ["NPM_CONFIG_PREFIX", "npm_config_prefix"] { + if let Some(value) = std::env::var_os(key).filter(|value| !value.is_empty()) { + npm_prefixes.push(PathBuf::from(value)); + } + } + if let Some(home) = &home { + npm_prefixes.push(home.join(".npm-global")); + let nvm = std::env::var_os("NVM_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".nvm")); + if let Ok(entries) = fs::read_dir(nvm.join("versions").join("node")) { + let mut versions: Vec = + entries.flatten().map(|entry| entry.path()).collect(); + versions.sort(); + npm_prefixes.extend(versions); + } + } + let mut extra_dirs = Vec::new(); + if cfg!(unix) { + for prefix in ["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"] { + npm_prefixes.push(PathBuf::from(prefix)); + extra_dirs.push(Path::new(prefix).join("bin")); + } + } + if let Some(appdata) = std::env::var_os("APPDATA").filter(|value| !value.is_empty()) { + npm_prefixes.push(PathBuf::from(appdata).join("npm")); + } + Ok(Self { + kin_home, + path_dirs, + cargo_home, + npm_prefixes, + extra_dirs, + probe_versions: true, + }) + } +} + +/// The file names a program can have on this platform. +fn program_file_names(program: &str) -> Vec { + if cfg!(windows) { + ["exe", "cmd", "ps1", "bat"] + .iter() + .map(|extension| format!("{program}.{extension}")) + .chain(std::iter::once(program.to_string())) + .collect() + } else { + vec![program.to_string()] + } +} + +fn managed_component_name(program: &str) -> String { + format!("{program}{}", std::env::consts::EXE_SUFFIX) +} + +/// The program a file name is, if it is one of Kin's. +fn program_for_file_name(name: &OsStr) -> Option<&'static str> { + let name = name.to_str()?; + let stem = if cfg!(windows) { + let lower = name.to_ascii_lowercase(); + ["exe", "cmd", "ps1", "bat"] + .iter() + .find_map(|extension| { + lower + .strip_suffix(&format!(".{extension}")) + .map(str::to_string) + }) + .unwrap_or(lower) + } else { + name.to_string() + }; + PROGRAMS.iter().copied().find(|program| *program == stem) +} + +fn display_path(path: &Path) -> String { + crate::screen::home_relative(path) +} + +fn version_triple(version: &str) -> Option<(u64, u64, u64)> { + let parsed = Version::parse(version.trim().trim_start_matches('v')).ok()?; + Some((parsed.major, parsed.minor, parsed.patch)) +} + +fn version_lt(left: &str, right: &str) -> bool { + match ( + Version::parse(left.trim().trim_start_matches('v')), + Version::parse(right.trim().trim_start_matches('v')), + ) { + (Ok(left), Ok(right)) => left < right, + _ => false, + } +} + +fn routes_to_managed(version: &str) -> bool { + version_triple(version).is_some_and(|triple| triple >= FIRST_ROUTING_RELEASE) +} + +fn canonical(path: &Path) -> Option { + fs::canonicalize(path).ok() +} + +/// Whether two paths name one file. +fn same_file(left: &Path, right: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match (fs::metadata(left), fs::metadata(right)) { + (Ok(left), Ok(right)) => left.dev() == right.dev() && left.ino() == right.ino(), + _ => false, + } + } + #[cfg(not(unix))] + { + matches!((canonical(left), canonical(right)), (Some(left), Some(right)) if left == right) + } +} + +/// Whether `resolved` lives in a Cargo target directory, wherever +/// `CARGO_TARGET_DIR` put it: Cargo marks the root of every target directory +/// with both of these files. +fn is_development_path(resolved: &Path) -> bool { + resolved.ancestors().skip(1).take(8).any(|directory| { + directory.join("CACHEDIR.TAG").is_file() && directory.join(".rustc_info.json").is_file() + }) +} + +/// The npm package a resolved path belongs to: its name, version and global +/// prefix. +fn npm_package_of(resolved: &Path) -> Option<(String, Option, PathBuf)> { + let mut package_dir = None; + for ancestor in resolved.ancestors().skip(1) { + let parent = ancestor.parent()?; + let in_node_modules = parent.file_name() == Some(OsStr::new("node_modules")) + || (parent + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.starts_with('@')) + && parent.parent().and_then(Path::file_name) == Some(OsStr::new("node_modules"))); + if in_node_modules && ancestor.join("package.json").is_file() { + package_dir = Some(ancestor.to_path_buf()); + break; + } + } + let package_dir = package_dir?; + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(package_dir.join("package.json")).ok()?).ok()?; + let name = manifest.get("name")?.as_str()?.to_string(); + let version = manifest + .get("version") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let node_modules = package_dir + .ancestors() + .find(|ancestor| ancestor.file_name() == Some(OsStr::new("node_modules")))?; + let above = node_modules.parent()?; + // A Unix prefix keeps packages in `/lib/node_modules`; Windows + // keeps them in `/node_modules`. + let prefix = if above.file_name() == Some(OsStr::new("lib")) { + above.parent()?.to_path_buf() + } else { + above.to_path_buf() + }; + Some((name, version, prefix)) +} + +/// The npm launcher package beside a Windows command shim, which is a script +/// rather than a link into the package. +fn npm_shim_package(path: &Path) -> Option<(String, Option, PathBuf)> { + let directory = path.parent()?; + for package in NPM_PACKAGES { + let manifest_path = directory + .join("node_modules") + .join(package) + .join("package.json"); + let Ok(bytes) = fs::read(&manifest_path) else { + continue; + }; + let manifest: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + let version = manifest + .get("version") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + return Some((package.to_string(), version, directory.to_path_buf())); + } + None +} + +/// What `cargo install` recorded for each binary in a Cargo home: the package +/// that installed it and that package's version. +fn cargo_install_index(cargo_home: &Path) -> HashMap { + fn record(index: &mut HashMap, key: &str, bins: Vec) { + let mut parts = key.splitn(3, ' '); + let (Some(package), Some(version)) = (parts.next(), parts.next()) else { + return; + }; + for bin in bins { + let bin = bin.strip_suffix(".exe").unwrap_or(&bin).to_string(); + index.insert(bin, (package.to_string(), version.to_string())); + } + } + let mut index = HashMap::new(); + if let Ok(bytes) = fs::read(cargo_home.join(".crates2.json")) { + if let Ok(value) = serde_json::from_slice::(&bytes) { + if let Some(installs) = value.get("installs").and_then(serde_json::Value::as_object) { + for (key, install) in installs { + let bins = install + .get("bins") + .and_then(serde_json::Value::as_array) + .map(|bins| { + bins.iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + record(&mut index, key, bins); + } + } + } + } + if index.is_empty() { + if let Ok(text) = fs::read_to_string(cargo_home.join(".crates.toml")) { + if let Ok(value) = toml::from_str::(&text) { + if let Some(table) = value.get("v1").and_then(toml::Value::as_table) { + for (key, bins) in table { + let bins = bins + .as_array() + .map(|bins| { + bins.iter() + .filter_map(toml::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + record(&mut index, key, bins); + } + } + } + } + } + index +} + +/// What every classification needs to know, computed once per scan. +struct ScanContext<'a> { + roots: &'a ScanRoots, + managed_bin: Option, + /// The managed `kin`'s release, read once per scan. + managed_version: Option, + kin_home: Option, + cargo_bin: Option, + cargo_index: HashMap, +} + +impl ScanContext<'_> { + fn in_kin_home(&self, path: &Path) -> bool { + let resolved_parent = path.parent().and_then(canonical); + self.kin_home + .as_ref() + .is_some_and(|home| resolved_parent.is_some_and(|parent| parent.starts_with(home))) + } +} + +fn is_symlink(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) +} + +/// Classify one file named like a Kin program. +fn classify(path: &Path, program: &'static str, context: &ScanContext<'_>) -> KinCopy { + let in_kin_home = context.in_kin_home(path); + let mut copy = KinCopy { + path: path.to_path_buf(), + program, + target: None, + kind: CopyKind::Unknown(String::new()), + version: None, + channel: None, + removal: None, + first_on_path: false, + in_kin_home, + }; + let Some(resolved) = canonical(path) else { + copy.kind = CopyKind::BrokenLink; + copy.target = fs::read_link(path).ok(); + return copy; + }; + if resolved != path { + copy.target = Some(resolved.clone()); + } + let link = is_symlink(path); + + // The managed pair, or something that is it under another name. + if let Some(managed_bin) = &context.managed_bin { + let managed = managed_bin.join(managed_component_name(program)); + let at_managed_location = path.parent().and_then(canonical).as_deref() + == Some(managed_bin.as_path()) + && path.file_name() == Some(OsStr::new(&managed_component_name(program))); + if at_managed_location && !link { + copy.kind = CopyKind::Managed; + copy.target = None; + return copy; + } + if !at_managed_location && !is_symlink(&managed) && same_file(&resolved, &managed) { + copy.kind = CopyKind::LinkToManaged; + return copy; + } + } + + // An npm launcher, by link into its package or by a Windows shim beside it. + if let Some((package, version, prefix)) = npm_package_of(&resolved) + .or_else(|| npm_shim_package(path).filter(|_| cfg!(windows) && !link)) + { + if NPM_PACKAGES.contains(&package.as_str()) { + copy.version = version; + copy.channel = Some(format!("npm prefix {}", display_path(&prefix))); + copy.kind = CopyKind::NpmLauncher { + package: package.clone(), + }; + if !in_kin_home && copy.is_concern(context.managed_version.as_deref()) { + copy.removal = Some(Removal::Npm { prefix, package }); + } + } else { + copy.kind = CopyKind::Unknown(format!("a file of the npm package {package}")); + } + return copy; + } + + if is_development_path(&resolved) { + copy.kind = CopyKind::DevelopmentBuild; + return copy; + } + + // A Homebrew formula: Cellar///... + let components: Vec<&OsStr> = resolved.iter().collect(); + if let Some(index) = components + .iter() + .position(|component| *component == OsStr::new("Cellar")) + { + if let (Some(formula), Some(version)) = ( + components.get(index + 1).and_then(|c| c.to_str()), + components.get(index + 2).and_then(|c| c.to_str()), + ) { + copy.version = Some(version.to_string()); + copy.channel = Some(format!("Homebrew formula {formula}")); + copy.kind = if routes_to_managed(version) { + CopyKind::RoutesToManaged + } else { + CopyKind::IndependentRelease + }; + if !in_kin_home { + copy.removal = Some(Removal::Homebrew { + formula: formula.to_string(), + }); + } + return copy; + } + } + + // A `cargo install`, which records the package that put it there. + let in_cargo_bin = context + .cargo_bin + .as_deref() + .is_some_and(|cargo_bin| path.parent().and_then(canonical).as_deref() == Some(cargo_bin)); + if in_cargo_bin { + if let Some((package, version)) = context.cargo_index.get(program) { + copy.version = Some(version.clone()); + copy.channel = Some(format!("cargo install {package}")); + copy.kind = if routes_to_managed(version) { + CopyKind::RoutesToManaged + } else { + CopyKind::IndependentRelease + }; + if let Some(cargo_home) = &context.roots.cargo_home { + copy.removal = Some(Removal::Cargo { + root: cargo_home.clone(), + package: package.clone(), + }); + } + return copy; + } + } + + // A standalone executable: its own bytes say what it is. + match crate::commands::update::executable_build_identity(&resolved) { + Ok(identity) => { + copy.version = Some(identity.version.clone()); + copy.kind = if !identity.clean || !identity.source_known { + CopyKind::DevelopmentBuild + } else if routes_to_managed(&identity.version) { + CopyKind::RoutesToManaged + } else { + CopyKind::IndependentRelease + }; + } + Err(_) => match probe_version(&resolved, program, context.roots.probe_versions) { + Some(version) => { + copy.kind = if routes_to_managed(&version) { + CopyKind::RoutesToManaged + } else { + CopyKind::IndependentRelease + }; + copy.version = Some(version); + } + None => { + copy.kind = CopyKind::Unknown(if starts_with_script_marker(&resolved) { + "a script".to_string() + } else { + "it carries no Kin build identity".to_string() + }); + return copy; + } + }, + } + if in_cargo_bin { + copy.channel = Some("in Cargo's bin directory".to_string()); + } + if !link + && !in_kin_home + && matches!( + copy.kind, + CopyKind::IndependentRelease | CopyKind::RoutesToManaged + ) + { + copy.channel + .get_or_insert_with(|| "placed by hand".to_string()); + copy.removal = Some(Removal::DeleteFile { + path: path.to_path_buf(), + }); + } + copy +} + +/// The managed `kin`'s release, from its own bytes. +fn managed_cli_version(managed_bin: &Path) -> Option { + crate::commands::update::executable_build_identity( + &managed_bin.join(managed_component_name("kin")), + ) + .ok() + .map(|identity| identity.version) +} + +fn starts_with_script_marker(path: &Path) -> bool { + use std::io::Read; + let mut head = [0_u8; 2]; + fs::File::open(path) + .and_then(|mut file| file.read_exact(&mut head)) + .is_ok() + && head == *b"#!" +} + +/// Ask an executable with no build identity for its version. Only `kin` +/// answers `--version` without side effects, and only a native executable is +/// run, never a script, which could be a launcher that downloads. +fn probe_version(resolved: &Path, program: &str, allowed: bool) -> Option { + let native_name = resolved + .extension() + .is_none_or(|extension| extension.eq_ignore_ascii_case("exe")); + if !allowed || program != "kin" || !native_name || starts_with_script_marker(resolved) { + return None; + } + crate::commands::runtime::probe_legacy_cli_version(resolved) + .ok() + .map(|version| version.to_string()) +} + +/// Find and classify every copy under `roots`. +pub(crate) fn scan(roots: &ScanRoots) -> CopyScan { + let kin_home = canonical(&roots.kin_home); + let managed_bin = kin_home.as_ref().map(|home| home.join("bin")); + let cargo_bin = roots + .cargo_home + .as_ref() + .and_then(|home| canonical(&home.join("bin"))); + let managed_bin = managed_bin.filter(|bin| bin.is_dir()); + let managed_version = managed_bin.as_deref().and_then(managed_cli_version); + let context = ScanContext { + roots, + managed_bin, + managed_version: managed_version.clone(), + kin_home, + cargo_bin, + cargo_index: roots + .cargo_home + .as_deref() + .map(cargo_install_index) + .unwrap_or_default(), + }; + + let mut copies: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut push = |copy: KinCopy, copies: &mut Vec| { + if seen.insert(copy.path.clone()) { + copies.push(copy); + } + }; + + // The managed home first: the pair itself, and anything else in its `bin` + // that is or links to a Kin program. + if let Some(bin) = &context.managed_bin { + let mut entries: Vec<(OsString, PathBuf)> = fs::read_dir(bin) + .map(|entries| { + entries + .flatten() + .map(|entry| (entry.file_name(), entry.path())) + .collect() + }) + .unwrap_or_default(); + entries.sort(); + for (name, path) in entries { + if name.to_str().is_some_and(|name| name.starts_with('.')) { + continue; + } + let program = program_for_file_name(&name).or_else(|| { + fs::read_link(&path) + .ok() + .and_then(|target| target.file_name().and_then(program_for_file_name)) + }); + if let Some(program) = program { + push(classify(&path, program, &context), &mut copies); + } + } + } + + // Every other directory, PATH first so its order decides what a shell runs. + let mut directories: Vec<(PathBuf, bool)> = Vec::new(); + let mut seen_directories: HashSet = HashSet::new(); + if let Some(bin) = &context.managed_bin { + seen_directories.insert(bin.clone()); + } + let mut add_directory = |directory: PathBuf, on_path: bool| { + if let Some(resolved) = canonical(&directory) { + if resolved.is_dir() && seen_directories.insert(resolved) { + directories.push((directory, on_path)); + } + } + }; + for directory in &roots.path_dirs { + add_directory(directory.clone(), true); + } + if let Some(cargo_home) = &roots.cargo_home { + add_directory(cargo_home.join("bin"), false); + } + for prefix in &roots.npm_prefixes { + add_directory(prefix.join("bin"), false); + if cfg!(windows) { + add_directory(prefix.clone(), false); + } + } + for directory in &roots.extra_dirs { + add_directory(directory.clone(), false); + } + for (directory, _) in &directories { + for program in PROGRAMS { + for name in program_file_names(program) { + let path = directory.join(&name); + if fs::symlink_metadata(&path).is_ok() { + push(classify(&path, program, &context), &mut copies); + } + } + } + } + + // What a new shell runs for each program: the first match in PATH order, + // which may be the managed pair itself. + for program in PROGRAMS { + let first = roots.path_dirs.iter().find_map(|directory| { + program_file_names(program) + .into_iter() + .map(|name| directory.join(name)) + .find(|path| fs::symlink_metadata(path).is_ok()) + }); + let Some(first) = first else { + continue; + }; + let first_resolved = canonical(&first); + if let Some(copy) = copies.iter_mut().find(|copy| { + copy.path == first + || (first.parent().and_then(canonical) == copy.path.parent().and_then(canonical) + && first.file_name() == copy.path.file_name()) + }) { + copy.first_on_path = true; + } else if first_resolved.is_some() { + // Found through a directory spelled differently from the one the + // scan recorded; it names the same file. + if let Some(copy) = copies + .iter_mut() + .find(|copy| canonical(©.path) == first_resolved && copy.program == program) + { + copy.first_on_path = true; + } + } + } + + CopyScan { + kin_home: roots.kin_home.clone(), + managed_version, + copies, + } +} + +/// Run a removal Kin offered, then prove the paths it covers are gone. +/// +/// The safety rules hold here as well as where offers are made: nothing inside +/// the managed Kin home and no development build is removed, whatever asked. +pub(crate) fn remove(offer: &RemovalOffer, roots: &ScanRoots) -> Result<()> { + let kin_home = canonical(&roots.kin_home); + for path in &offer.paths { + let parent = path.parent().and_then(canonical); + if kin_home.as_ref().is_some_and(|home| { + parent + .as_ref() + .is_some_and(|parent| parent.starts_with(home)) + }) { + anyhow::bail!( + "{} is inside the managed Kin home, which removal never touches", + path.display() + ); + } + if canonical(path).is_some_and(|resolved| is_development_path(&resolved)) { + anyhow::bail!( + "{} is a development build, which removal never touches", + path.display() + ); + } + } + match &offer.removal { + Removal::DeleteFile { path } => { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect {}", path.display()))?; + if !metadata.is_file() { + anyhow::bail!("{} is no longer a regular file", path.display()); + } + fs::remove_file(path) + .with_context(|| format!("failed to remove {}", path.display()))?; + } + removal => { + let (program, args) = removal_invocation(removal)?; + let output = std::process::Command::new(&program) + .args(&args) + .stdin(std::process::Stdio::null()) + .output() + .with_context(|| format!("failed to run `{}`", removal.command_line()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let tail = stderr.lines().rev().find(|line| !line.trim().is_empty()); + anyhow::bail!( + "`{}` exited with {}{}", + removal.command_line(), + output.status, + tail.map(|line| format!(": {}", line.trim())) + .unwrap_or_default() + ); + } + } + } + let remaining: Vec = offer + .paths + .iter() + .filter(|path| fs::symlink_metadata(path).is_ok()) + .map(|path| display_path(path)) + .collect(); + if !remaining.is_empty() { + anyhow::bail!( + "`{}` finished, but {} {} still there", + offer.removal.command_line(), + remaining.join(", "), + if remaining.len() == 1 { "is" } else { "are" } + ); + } + Ok(()) +} + +/// The program and arguments a channel removal runs, preferring the tool that +/// belongs to the prefix or home it removes from. +fn removal_invocation(removal: &Removal) -> Result<(PathBuf, Vec)> { + let own_or_path = |own: PathBuf, name: &str| -> Result { + if own.is_file() { + return Ok(own); + } + which::which(name).with_context(|| format!("`{name}` is not installed on this machine")) + }; + match removal { + Removal::Npm { prefix, package } => { + let own = if cfg!(windows) { + prefix.join("npm.cmd") + } else { + prefix.join("bin").join("npm") + }; + Ok(( + own_or_path(own, "npm")?, + vec![ + "uninstall".into(), + "--global".into(), + "--prefix".into(), + prefix.into(), + package.into(), + ], + )) + } + Removal::Cargo { root, package } => Ok(( + own_or_path( + root.join("bin").join(managed_component_name("cargo")), + "cargo", + )?, + vec![ + "uninstall".into(), + "--root".into(), + root.into(), + package.into(), + ], + )), + Removal::Homebrew { formula } => Ok(( + which::which("brew").context("`brew` is not installed on this machine")?, + vec!["uninstall".into(), formula.into()], + )), + Removal::DeleteFile { .. } => anyhow::bail!("a file removal runs no program"), + } +} + +/// One running `kin-daemon`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LiveDaemon { + pub(crate) pid: u32, + pub(crate) executable: PathBuf, + /// The repository it serves, from its `--repo` argument. + pub(crate) repo: Option, + /// Whether it runs as the machine's supervisor rather than for one + /// repository. + pub(crate) supervisor: bool, + /// When it started, in seconds since the Unix epoch. + pub(crate) started_at: u64, +} + +/// Every running `kin-daemon` on this machine, from the process table. +pub(crate) fn live_daemons() -> Vec { + use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; + + let mut system = System::new(); + system.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing() + .with_exe(UpdateKind::OnlyIfNotSet) + .with_cmd(UpdateKind::OnlyIfNotSet), + ); + let mut daemons = Vec::new(); + for (pid, process) in system.processes() { + if process.thread_kind().is_some() { + continue; + } + let Some(executable) = process.exe() else { + continue; + }; + let name = executable + .file_name() + .and_then(OsStr::to_str) + .map(|name| name.trim_end_matches(" (deleted)")); + if name != Some(managed_component_name("kin-daemon").as_str()) { + continue; + } + let args: Vec = process + .cmd() + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + let repo = args + .iter() + .position(|arg| arg == "--repo") + .and_then(|index| args.get(index + 1)) + .map(PathBuf::from) + .or_else(|| { + args.iter() + .find_map(|arg| arg.strip_prefix("--repo=")) + .map(PathBuf::from) + }); + daemons.push(LiveDaemon { + pid: pid.as_u32(), + executable: executable.to_path_buf(), + repo, + supervisor: args.iter().any(|arg| arg == "--supervisor"), + started_at: process.start_time(), + }); + } + daemons.sort_by_key(|daemon| daemon.pid); + daemons +} + +/// The daemons that run one release. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DaemonGroup { + /// The release, in a few words. + pub(crate) release: String, + /// Whether this release is a second Kin. + pub(crate) concern: bool, + pub(crate) daemons: Vec, +} + +/// Group running daemons by the release their executable is. +/// +/// A daemon running from the managed path but started before the file there +/// was last replaced is still running the release that was replaced, and on +/// Linux the kernel says so outright with a ` (deleted)` executable. +pub(crate) fn group_live_daemons(daemons: &[LiveDaemon], scan: &CopyScan) -> Vec { + let managed_daemon = scan + .kin_home + .join("bin") + .join(managed_component_name("kin-daemon")); + let managed_resolved = canonical(&managed_daemon); + let managed_modified = fs::metadata(&managed_daemon) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|elapsed| elapsed.as_secs()); + let kin_home = canonical(&scan.kin_home); + let mut identities: HashMap> = + HashMap::new(); + let mut groups: Vec = Vec::new(); + for daemon in daemons { + let text = daemon.executable.to_string_lossy(); + let deleted = text.ends_with(" (deleted)"); + let executable = PathBuf::from(text.trim_end_matches(" (deleted)")); + let resolved = canonical(&executable).unwrap_or_else(|| executable.clone()); + let in_update_backup = kin_home.as_ref().is_some_and(|home| { + resolved.starts_with(home) + && resolved.iter().any(|component| { + component + .to_str() + .is_some_and(|name| name.starts_with(".update-backup-")) + }) + }); + let at_managed = + managed_resolved.as_ref() == Some(&resolved) || executable == managed_daemon; + let (release, concern) = if in_update_backup + || (at_managed + && (deleted + || managed_modified.is_some_and(|modified| daemon.started_at < modified))) + { + ( + "a managed release that has since been replaced".to_string(), + true, + ) + } else if at_managed { + ( + format!( + "the managed Kin{}", + scan.managed_version + .as_deref() + .map(|version| format!(" {version}")) + .unwrap_or_default() + ), + false, + ) + } else { + match scan.copies.iter().find(|copy| { + canonical(©.path).as_ref() == Some(&resolved) || copy.path == executable + }) { + // A daemon is a second Kin whatever its CLI would do: only + // the managed pair's daemon and a development build's are not. + Some(copy) => ( + copy.describe(scan.managed_version.as_deref()), + !matches!( + copy.kind, + CopyKind::Managed | CopyKind::LinkToManaged | CopyKind::DevelopmentBuild + ), + ), + // Not on any path the scan reads, such as a build copied + // somewhere by hand: its own bytes say what it is. + None => { + let identity = identities + .entry(resolved.clone()) + .or_insert_with(|| { + crate::commands::update::executable_build_identity(&resolved).ok() + }) + .clone(); + let development = is_development_path(&resolved) + || identity + .as_ref() + .is_some_and(|identity| !identity.clean || !identity.source_known); + let release = match (&identity, development) { + (_, true) => format!( + "a development build{} at {}", + identity + .as_ref() + .map(|identity| format!(" {}", identity.version)) + .unwrap_or_default(), + display_path(&executable) + ), + (Some(identity), false) => format!( + "Kin {} outside the managed home at {}", + identity.version, + display_path(&executable) + ), + // Too large to be a release, or older than the + // identity releases carry: say so rather than guess. + (None, false) => format!( + "an unidentified kin-daemon at {}, which carries no build \ + identity Kin can read", + display_path(&executable) + ), + }; + (release, !development) + } + } + }; + match groups.iter_mut().find(|group| group.release == release) { + Some(group) => group.daemons.push(daemon.clone()), + None => groups.push(DaemonGroup { + release, + concern, + daemons: vec![daemon.clone()], + }), + } + } + groups +} + +// The fixtures are symlinks and Unix permission bits. +#[cfg(all(test, unix))] +mod tests { + use super::*; + + fn executable(path: &Path, bytes: &[u8]) { + use std::os::unix::fs::PermissionsExt; + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + } + + fn link(target: &Path, at: &Path) { + fs::create_dir_all(at.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(target, at).unwrap(); + } + + struct Fixture { + _tmp: tempfile::TempDir, + root: PathBuf, + roots: ScanRoots, + } + + /// A machine with one of every kind of copy: the managed pair, a link to + /// it, an old npm launcher and a current one, a cargo install, a Homebrew + /// formula, a standalone old release, a standalone routing release, a + /// development build linked from the managed home, and a script. + fn machine() -> Fixture { + use crate::commands::update::test_binary_with_build_identity as kin_bytes; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let kin_home = root.join("home/.kin"); + executable(&kin_home.join("bin/kin"), &kin_bytes("0.8.7", true)); + executable(&kin_home.join("bin/kin-daemon"), &kin_bytes("0.8.7", true)); + fs::write(kin_home.join("bin/.kinlab-kin-version"), "0.8.7\n").unwrap(); + + // A link to the managed pair, as /usr/local/bin/kin often is. + link(&kin_home.join("bin/kin"), &root.join("usr-local/bin/kin")); + + // An npm prefix with an old launcher, and one with a current launcher. + for (prefix, version) in [("old-node", "0.6.6"), ("new-node", "0.8.7")] { + let package = root.join(prefix).join("lib/node_modules/@kinlab/kin"); + fs::create_dir_all(package.join("bin")).unwrap(); + fs::write( + package.join("package.json"), + format!(r#"{{"name":"@kinlab/kin","version":"{version}"}}"#), + ) + .unwrap(); + executable(&package.join("bin/kin.mjs"), b"#!/usr/bin/env node\n"); + link( + Path::new("../lib/node_modules/@kinlab/kin/bin/kin.mjs"), + &root.join(prefix).join("bin/kin"), + ); + } + + // A cargo install of an old release. + let cargo_home = root.join("home/.cargo"); + executable(&cargo_home.join("bin/kin"), &kin_bytes("0.7.18", true)); + executable( + &cargo_home.join("bin/kin-daemon"), + &kin_bytes("0.7.18", true), + ); + fs::write( + cargo_home.join(".crates2.json"), + r#"{"installs":{"kin-cli 0.7.18 (path+file:///src/kin/crates/kin-cli)":{"bins":["kin","kin-daemon"]}}}"#, + ) + .unwrap(); + + // A Homebrew formula. + let cellar = root.join("brew/Cellar/kin/0.8.1/bin/kin"); + executable(&cellar, &kin_bytes("0.8.1", true)); + link(&cellar, &root.join("brew/bin/kin")); + + // Standalone releases placed by hand: an old one and a routing one. + executable(&root.join("opt-old/kin"), &kin_bytes("0.6.6", true)); + executable(&root.join("opt-new/kin-mcp"), &kin_bytes("0.8.9", true)); + + // A development build, linked from inside the managed home. + let target = root.join("src/kin/target"); + fs::create_dir_all(target.join("release")).unwrap(); + fs::write( + target.join("CACHEDIR.TAG"), + "Signature: 8a477f597d28d172789f06886806bc55\n", + ) + .unwrap(); + fs::write(target.join(".rustc_info.json"), "{}").unwrap(); + executable( + &target.join("release/kin-daemon"), + &kin_bytes("0.8.7", false), + ); + link( + &target.join("release/kin-daemon"), + &kin_home.join("bin/kin-daemon.bak"), + ); + // A link left behind after its target was deleted. + link( + &root.join("gone/release/kin"), + &kin_home.join("bin/kin-real"), + ); + // A development build on PATH too. + executable(&target.join("release/kin"), &kin_bytes("0.8.7", false)); + + // Something named kin that is none of the above. + executable(&root.join("scripts/kin"), b"#!/bin/sh\necho hi\n"); + + let roots = ScanRoots { + kin_home, + path_dirs: vec![ + root.join("old-node/bin"), + root.join("usr-local/bin"), + root.join("home/.kin/bin"), + root.join("opt-old"), + root.join("opt-new"), + root.join("scripts"), + target.join("release"), + ], + cargo_home: Some(cargo_home), + npm_prefixes: vec![root.join("new-node")], + extra_dirs: vec![root.join("brew/bin")], + probe_versions: false, + }; + Fixture { + _tmp: tmp, + root, + roots, + } + } + + fn find<'a>(scan: &'a CopyScan, path: &Path) -> &'a KinCopy { + scan.copies + .iter() + .find(|copy| copy.path == path) + .unwrap_or_else(|| panic!("no copy at {}: {:#?}", path.display(), scan.copies)) + } + + #[test] + fn every_copy_on_a_machine_is_found_and_named_by_what_it_is() { + let fixture = machine(); + let root = &fixture.root; + let scan = scan(&fixture.roots); + assert_eq!(scan.managed_version.as_deref(), Some("0.8.7")); + + let managed = find(&scan, &root.join("home/.kin/bin/kin")); + assert_eq!(managed.kind, CopyKind::Managed); + assert!(managed.removal.is_none()); + assert_eq!( + find(&scan, &root.join("home/.kin/bin/kin-daemon")).kind, + CopyKind::Managed + ); + + let linked = find(&scan, &root.join("usr-local/bin/kin")); + assert_eq!(linked.kind, CopyKind::LinkToManaged); + assert!(linked.removal.is_none()); + + let old_npm = find(&scan, &root.join("old-node/bin/kin")); + assert_eq!( + old_npm.kind, + CopyKind::NpmLauncher { + package: "@kinlab/kin".to_string() + } + ); + assert_eq!(old_npm.version.as_deref(), Some("0.6.6")); + assert_eq!( + old_npm.removal, + Some(Removal::Npm { + prefix: root.join("old-node"), + package: "@kinlab/kin".to_string() + }) + ); + assert!( + old_npm.first_on_path, + "PATH order puts the old launcher first" + ); + + let new_npm = find(&scan, &root.join("new-node/bin/kin")); + assert_eq!(new_npm.version.as_deref(), Some("0.8.7")); + assert!( + new_npm.removal.is_none(), + "a launcher as new as the managed Kin is a way in to it" + ); + + let cargo = find(&scan, &root.join("home/.cargo/bin/kin")); + assert_eq!(cargo.kind, CopyKind::IndependentRelease); + assert_eq!(cargo.version.as_deref(), Some("0.7.18")); + assert_eq!(cargo.channel.as_deref(), Some("cargo install kin-cli")); + assert_eq!( + cargo.removal, + Some(Removal::Cargo { + root: root.join("home/.cargo"), + package: "kin-cli".to_string() + }) + ); + + let brew = find(&scan, &root.join("brew/bin/kin")); + assert_eq!(brew.kind, CopyKind::IndependentRelease); + assert_eq!(brew.version.as_deref(), Some("0.8.1")); + assert_eq!( + brew.removal, + Some(Removal::Homebrew { + formula: "kin".to_string() + }) + ); + + let old_release = find(&scan, &root.join("opt-old/kin")); + assert_eq!(old_release.kind, CopyKind::IndependentRelease); + assert_eq!( + old_release.removal, + Some(Removal::DeleteFile { + path: root.join("opt-old/kin") + }) + ); + + let routing = find(&scan, &root.join("opt-new/kin-mcp")); + assert_eq!(routing.kind, CopyKind::RoutesToManaged); + assert_eq!(routing.version.as_deref(), Some("0.8.9")); + + let bak = find(&scan, &root.join("home/.kin/bin/kin-daemon.bak")); + assert_eq!(bak.kind, CopyKind::DevelopmentBuild); + assert!(bak.in_kin_home); + assert!( + bak.removal.is_none(), + "nothing in the managed home is offered" + ); + + let dev = find(&scan, &root.join("src/kin/target/release/kin")); + assert_eq!(dev.kind, CopyKind::DevelopmentBuild); + assert!( + dev.removal.is_none(), + "a development build is never offered" + ); + + let broken = find(&scan, &root.join("home/.kin/bin/kin-real")); + assert_eq!(broken.kind, CopyKind::BrokenLink); + assert!(broken.removal.is_none()); + assert!(!broken.is_concern(scan.managed_version.as_deref())); + assert!( + broken + .describe(scan.managed_version.as_deref()) + .ends_with("gone/release/kin, which no longer exists"), + "{}", + broken.describe(scan.managed_version.as_deref()) + ); + + let script = find(&scan, &root.join("scripts/kin")); + assert_eq!(script.kind, CopyKind::Unknown("a script".to_string())); + assert!(script.removal.is_none()); + + let concerns: Vec<&Path> = scan + .concerns() + .into_iter() + .map(|copy| copy.path.as_path()) + .collect(); + for expected in [ + root.join("old-node/bin/kin"), + root.join("home/.cargo/bin/kin"), + root.join("home/.cargo/bin/kin-daemon"), + root.join("brew/bin/kin"), + root.join("opt-old/kin"), + root.join("scripts/kin"), + ] { + assert!( + concerns.contains(&expected.as_path()), + "{}", + expected.display() + ); + } + for harmless in [ + root.join("home/.kin/bin/kin"), + root.join("usr-local/bin/kin"), + root.join("new-node/bin/kin"), + root.join("opt-new/kin-mcp"), + root.join("src/kin/target/release/kin"), + ] { + assert!( + !concerns.contains(&harmless.as_path()), + "{}", + harmless.display() + ); + } + } + + #[test] + fn one_package_is_offered_once_with_every_path_it_removes() { + let fixture = machine(); + let scan = scan(&fixture.roots); + let offers = scan.removal_offers(); + let cargo = offers + .iter() + .find(|offer| matches!(offer.removal, Removal::Cargo { .. })) + .unwrap(); + assert_eq!(cargo.what, "cargo package kin-cli 0.7.18"); + assert_eq!(cargo.paths.len(), 2); + assert_eq!( + cargo.removal.command_line(), + format!( + "cargo uninstall --root {} kin-cli", + fixture.root.join("home/.cargo").display() + ) + ); + assert!(offers.iter().all(|offer| offer + .paths + .iter() + .all(|path| !path.starts_with(&fixture.roots.kin_home)))); + } + + #[test] + fn removal_refuses_the_managed_home_and_development_builds() { + let fixture = machine(); + for path in [ + fixture.roots.kin_home.join("bin/kin"), + fixture.root.join("src/kin/target/release/kin"), + ] { + let offer = RemovalOffer { + removal: Removal::DeleteFile { path: path.clone() }, + what: "test".to_string(), + paths: vec![path.clone()], + }; + let error = remove(&offer, &fixture.roots).unwrap_err(); + assert!(format!("{error:#}").contains("never touches"), "{error:#}"); + assert!(path.exists(), "{} was removed", path.display()); + } + } + + #[test] + fn a_standalone_old_release_is_removed_by_deleting_it_and_proven_gone() { + let fixture = machine(); + let scan = scan(&fixture.roots); + let offer = scan + .removal_offers() + .into_iter() + .find(|offer| matches!(offer.removal, Removal::DeleteFile { .. })) + .unwrap(); + remove(&offer, &fixture.roots).unwrap(); + assert!(!fixture.root.join("opt-old/kin").exists()); + assert!(fixture.roots.kin_home.join("bin/kin").exists()); + } + + #[test] + fn live_daemons_are_grouped_by_the_release_they_run() { + let fixture = machine(); + let scan = scan(&fixture.roots); + let managed = fixture.roots.kin_home.join("bin/kin-daemon"); + let modified = fs::metadata(&managed) + .unwrap() + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let daemon = |pid, executable: PathBuf, started_at| LiveDaemon { + pid, + executable, + repo: Some(PathBuf::from(format!("/repo/{pid}"))), + supervisor: false, + started_at, + }; + // Builds copied somewhere no scan reads are named by their own bytes. + let copied_release = fixture.root.join("proofs/before/kin-daemon"); + executable( + &copied_release, + &crate::commands::update::test_binary_with_build_identity("0.8.5", true), + ); + let copied_build = fixture.root.join("proofs/after/kin-daemon"); + executable( + &copied_build, + &crate::commands::update::test_binary_with_build_identity("0.8.7", false), + ); + let groups = group_live_daemons( + &[ + daemon(1, managed.clone(), modified + 60), + daemon(2, managed.clone(), modified + 61), + daemon(3, managed.clone(), modified.saturating_sub(3600)), + daemon(4, fixture.root.join("home/.cargo/bin/kin-daemon"), modified), + daemon( + 5, + fixture.root.join("src/kin/target/release/kin-daemon"), + modified, + ), + daemon(6, copied_release.clone(), modified), + daemon(7, copied_build.clone(), modified), + ], + &scan, + ); + let current = groups + .iter() + .find(|group| group.release == "the managed Kin 0.8.7") + .unwrap(); + assert_eq!(current.daemons.len(), 2); + assert!(!current.concern); + let replaced = groups + .iter() + .find(|group| group.release.contains("since been replaced")) + .unwrap(); + assert_eq!(replaced.daemons[0].pid, 3); + assert!(replaced.concern); + let cargo = groups + .iter() + .find(|group| group.daemons.iter().any(|daemon| daemon.pid == 4)) + .unwrap(); + assert!(cargo.concern); + assert!(cargo.release.contains("0.7.18"), "{}", cargo.release); + let dev = groups + .iter() + .find(|group| group.daemons.iter().any(|daemon| daemon.pid == 5)) + .unwrap(); + assert!(!dev.concern); + assert!(dev.release.contains("development build"), "{}", dev.release); + let release_of = |pid: u32| { + groups + .iter() + .find(|group| group.daemons.iter().any(|daemon| daemon.pid == pid)) + .unwrap() + }; + assert!(release_of(6).concern); + assert!( + release_of(6) + .release + .starts_with("Kin 0.8.5 outside the managed home at "), + "{}", + release_of(6).release + ); + assert!(!release_of(7).concern); + assert!( + release_of(7) + .release + .starts_with("a development build 0.8.7 at "), + "{}", + release_of(7).release + ); + } +} diff --git a/crates/kin-cli/src/commands/mod.rs b/crates/kin-cli/src/commands/mod.rs index dbf228317..07b748bee 100644 --- a/crates/kin-cli/src/commands/mod.rs +++ b/crates/kin-cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod absence_qualifier; pub mod admit; pub mod agent; +pub mod agent_edits; pub mod agents; pub mod allow; pub mod approvals; @@ -39,6 +40,7 @@ pub mod diff; pub mod drift; pub mod eject; pub mod embed; +pub mod entity_work; pub mod external_symbols; pub mod git; pub mod graph; @@ -53,6 +55,7 @@ pub mod hosted_publication; pub mod impact; pub mod init; pub mod intent; +pub(crate) mod kin_copies; pub mod language_server_release; pub mod language_servers; pub mod languages; @@ -92,10 +95,12 @@ pub mod repository_authority; pub mod resolve; pub mod resources; pub mod review; +pub mod review_flags; pub mod review_guide; pub mod review_ui; pub mod rollback; pub mod routed_words; +pub mod runtime; pub mod scope; pub mod search; pub mod secret; @@ -130,10 +135,14 @@ pub mod work; mod workspace_agents; mod workspace_conflicts; mod workspace_explore; +mod workspace_flags; +mod workspace_git_remote; mod workspace_graph; mod workspace_home; +mod workspace_remotes; pub mod workspace_tip; pub mod workspace_ui; +mod workspace_verify; pub mod write_back; pub mod xref; @@ -187,6 +196,9 @@ pub(crate) fn require_repository_layout_at( start: &std::path::Path, ) -> anyhow::Result { if let Some(layout) = kin_core::KinLayout::discover(start) { + // Before anything reads the store: a history attach a crash + // interrupted leaves it without the records every command opens with. + history_derive::finish_interrupted_attach(&layout); return Ok(layout); } use kin_core::layout::{StoreMiss, StoreStanding}; diff --git a/crates/kin-cli/src/commands/note.rs b/crates/kin-cli/src/commands/note.rs index 50f6c5bc6..d23ec94be 100644 --- a/crates/kin-cli/src/commands/note.rs +++ b/crates/kin-cli/src/commands/note.rs @@ -165,6 +165,7 @@ fn build_annotation( authored_by: IdentityRef::human("cli-user"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }; let link = WorkLink::AttachedTo { diff --git a/crates/kin-cli/src/commands/overview.rs b/crates/kin-cli/src/commands/overview.rs index 0fc901827..e9a9b273f 100644 --- a/crates/kin-cli/src/commands/overview.rs +++ b/crates/kin-cli/src/commands/overview.rs @@ -96,6 +96,11 @@ pub fn build_overview_response( relation_ids.insert(rel.id); } } + // Whether every call site the store holds is known and settled, read + // the way the store-wide call-site status reads it. While it is not, + // a count of what calls or relies on an entity can miss callers, so a + // reader shows such counts as lower bounds. + let call_sites_settled = kin_mcp::call_sites::tally_callers(graph, &entities).is_settled(); return Ok(OverviewResponse { lines: vec![serde_json::to_string(&serde_json::json!({ @@ -104,6 +109,7 @@ pub fn build_overview_response( "files": unique_files.len(), "kinds": kinds, "languages": languages, + "call_sites_settled": call_sites_settled, }))?], }); } @@ -378,4 +384,46 @@ mod tests { "{answer}" ); } + + /// The machine answer says whether every call site the store holds is + /// settled, so a reader can tell an exact count of what relies on an + /// entity from one that can miss callers. An entity with no span holds no + /// call site; a spanned one no ledger describes is a caller whose sites + /// are not known yet. + #[test] + fn the_json_answer_says_whether_the_stores_call_sites_are_settled() { + let settled_of = |graph: &InMemoryGraph| { + let response = build_overview_response( + "probe-repo", + graph, + &OverviewRequest { + json: true, + compact: true, + }, + ) + .expect("build overview"); + let answer: serde_json::Value = + serde_json::from_str(&response.lines[0]).expect("one JSON line"); + answer["call_sites_settled"].clone() + }; + + let graph = InMemoryGraph::new(); + graph + .upsert_entity(&overview_entity("spanless", "src/probe.py", None)) + .expect("upsert entity"); + assert_eq!( + settled_of(&graph), + serde_json::json!(true), + "nothing in the store holds a call site Kin has not read" + ); + + graph + .upsert_entity(&overview_entity("unread", "src/probe.py", Some(3))) + .expect("upsert entity"); + assert_eq!( + settled_of(&graph), + serde_json::json!(false), + "a caller with no ledger leaves the store's call sites unsettled" + ); + } } diff --git a/crates/kin-cli/src/commands/recovery_carrier.rs b/crates/kin-cli/src/commands/recovery_carrier.rs index 5af66cd4f..be88f1e05 100644 --- a/crates/kin-cli/src/commands/recovery_carrier.rs +++ b/crates/kin-cli/src/commands/recovery_carrier.rs @@ -1461,6 +1461,7 @@ mod tests { kind: ActorKind::Human, display_name: "Recovery Test".into(), external_refs: vec![], + agent_session: None, }; let audit = AuditEvent { event_id: AuditEventId::new(), diff --git a/crates/kin-cli/src/commands/ref_lookup.rs b/crates/kin-cli/src/commands/ref_lookup.rs index 12139e1a1..7926f66c2 100644 --- a/crates/kin-cli/src/commands/ref_lookup.rs +++ b/crates/kin-cli/src/commands/ref_lookup.rs @@ -260,10 +260,9 @@ where let resolved = super::ref_grammar::resolve(&deferred, graph, reference) .map_err(|error| ref_error(reference, format!("{error:#}")))?; - if graph - .get_change(&resolved.change_id) + if !graph + .holds_change(&resolved.change_id) .map_err(|error| anyhow!(error.to_string()))? - .is_none() { return Err(ref_error( reference, diff --git a/crates/kin-cli/src/commands/remote.rs b/crates/kin-cli/src/commands/remote.rs index e6aacf6e8..6aab33323 100644 --- a/crates/kin-cli/src/commands/remote.rs +++ b/crates/kin-cli/src/commands/remote.rs @@ -136,6 +136,15 @@ pub(crate) fn native_remote_bearer_token(base_url: &str) -> Option { }) } +/// The bearer token the environment names for `base_url`, without reading any +/// saved credential. A server answering a page reads this and nothing that +/// could stop to ask a person. +pub(crate) fn native_remote_bearer_token_from_environment(base_url: &str) -> Option { + credential_for_endpoint(base_url, || { + resolve_native_remote_bearer_token_with(|key| std::env::var(key).ok()) + }) +} + fn credential_for_endpoint( base_url: &str, load: impl FnOnce() -> Option, diff --git a/crates/kin-cli/src/commands/review.rs b/crates/kin-cli/src/commands/review.rs index 3763c40b3..f5af96080 100644 --- a/crates/kin-cli/src/commands/review.rs +++ b/crates/kin-cli/src/commands/review.rs @@ -343,6 +343,152 @@ pub async fn execute_review_request_with_authority( source_scope: kin_mcp::handlers::common::EntitySourceScope, open_authority: Option<&super::repository_authority::ActiveRepositoryAuthority>, ) -> Result { + execute_review_request_with_agent_writes( + refs, + graph, + request, + repository_authority, + source_scope, + open_authority, + &[], + &[], + ) + .await +} + +/// [`execute_review_request_with_authority`] for the daemon that ran the +/// agents' writes through Kin and confirmed the edits their clients reported. +/// A guide marks each step with the agent sessions among `agent_writes` and +/// `client_edits` that wrote its entity in the work it walks; see +/// [`super::review_guide::attribute_agent_writes`]. +pub async fn execute_review_request_with_agent_writes( + refs: &super::repository_authority::RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + request: ReviewRequest, + repository_authority: Option<&kin_mcp::handlers::RequestRepositoryAuthority>, + source_scope: kin_mcp::handlers::common::EntitySourceScope, + open_authority: Option<&super::repository_authority::ActiveRepositoryAuthority>, + agent_writes: &[super::review_guide::AgentWrite], + client_edits: &[super::review_guide::ClientEdit], +) -> Result { + execute_review_read( + refs, + graph, + request, + repository_authority, + source_scope, + open_authority, + agent_writes, + client_edits, + ) +} + +/// The guide of one recorded change, or of the changes between `base` and +/// `change`, as built and before any step is marked with who wrote it. +/// +/// This is all the work a guide of committed history costs, and it depends on +/// nothing but what is asked and the repository it is read from: the change +/// and the history it replays never move, and what it reads of the live +/// graph moves only with a write. So a server can keep it once built and +/// answer the next read of the same change, from any page, by marking it +/// with [`guide_response`]. The daemon does; see its committed guides. +pub fn build_committed_guide( + refs: &super::repository_authority::RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + change: Option<&str>, + base: Option<&str>, + repository_authority: Option<&kin_mcp::handlers::RequestRepositoryAuthority>, + source_scope: kin_mcp::handlers::common::EntitySourceScope, +) -> Result { + let _large_records = graph.hold_large_change_records(); + let source = kin_mcp::handlers::review::AnalysisEscapeEvidence::new( + graph, + repository_authority, + source_scope, + ); + let escape_evidence = |targets: &[kin_model::Entity], at| source.observe(targets, at); + committed_guide_with( + refs, + graph, + change, + base, + repository_authority, + &escape_evidence, + ) +} + +fn committed_guide_with( + refs: &super::repository_authority::RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + change: Option<&str>, + base: Option<&str>, + repository_authority: Option<&kin_mcp::handlers::RequestRepositoryAuthority>, + escape_evidence: &kin_review::enrichment::EscapeEvidence<'_>, +) -> Result { + match base { + Some(base) => super::review_guide::build_range_guide( + refs, + graph, + base, + change, + repository_authority, + Some(escape_evidence), + ), + None => super::review_guide::build_guide( + refs, + graph, + change, + repository_authority, + Some(escape_evidence), + ), + } +} + +/// What a guide read answers from a built guide: each step marked with the +/// agent sessions among `agent_writes` and `client_edits` that wrote its +/// entity, as JSON or as text. +pub fn guide_response( + mut guide: super::review_guide::ReviewGuide, + json: bool, + agent_writes: &[super::review_guide::AgentWrite], + client_edits: &[super::review_guide::ClientEdit], +) -> Result { + super::review_guide::attribute_agent_writes(&mut guide, agent_writes, client_edits); + Ok(if json { + ReviewResponse { + text: String::new(), + json: Some(serde_json::to_string(&guide)?), + } + } else { + ReviewResponse { + text: super::review_guide::guide_text(&guide), + json: None, + } + }) +} + +/// [`execute_review_request_with_agent_writes`] as the synchronous work it is. +/// +/// Every review read is computation over the graph and the store, with +/// nothing to wait for, and a guide of a large change runs for seconds. A +/// server that answers it from an async handler runs this off its runtime +/// worker, so the other requests it is serving keep being answered meanwhile. +pub fn execute_review_read( + refs: &super::repository_authority::RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + request: ReviewRequest, + repository_authority: Option<&kin_mcp::handlers::RequestRepositoryAuthority>, + source_scope: kin_mcp::handlers::common::EntitySourceScope, + open_authority: Option<&super::repository_authority::ActiveRepositoryAuthority>, + agent_writes: &[super::review_guide::AgentWrite], + client_edits: &[super::review_guide::ClientEdit], +) -> Result { + // A guide replays the history to its change several times: to place the + // change, to diff it, to read its connections and to run the census. Each + // crosses the same records, and a record too large to keep between reads + // (a store's bootstrap change, or a commit on it) is decoded once for the + // whole read instead of once per replay. + let _large_records = graph.hold_large_change_records(); let source = kin_mcp::handlers::review::AnalysisEscapeEvidence::new( graph, repository_authority, @@ -412,37 +558,18 @@ pub async fn execute_review_request_with_authority( Some(&escape_evidence), open_authority, )? - } else if let Some(base) = base.as_deref() { - super::review_guide::build_range_guide( - refs, - graph, - base, - change.as_deref(), - repository_authority, - Some(&escape_evidence), - )? } else { - super::review_guide::build_guide( + committed_guide_with( refs, graph, change.as_deref(), + base.as_deref(), repository_authority, - Some(&escape_evidence), + &escape_evidence, )? }; - let response = if json { - ReviewResponse { - text: String::new(), - json: Some(serde_json::to_string(&guide)?), - } - } else { - ReviewResponse { - text: super::review_guide::guide_text(&guide), - json: None, - } - }; Ok(ReviewExecution { - response, + response: guide_response(guide, json, agent_writes, client_edits)?, mutated: false, }) } @@ -671,15 +798,44 @@ fn compact_review_text(review: &kin_review::Review) -> String { .unwrap(); match review.impact.enrichment.as_ref() { Some(observation) if observation.bounds_answer() => { + // Only owed work is pending enrichment. Other unproven sites + // bound the answer without claiming more enrichment is queued. + let owed = observation.owed_entities; + let answered = observation.total_pending_entities.saturating_sub(owed); + let entities = |count: usize| { + if count == 1 { + "1 entity".to_string() + } else { + format!("{count} entities") + } + }; + if owed > 0 { + writeln!( + text, + "not settled: {} pending enrichment; risk may change.", + entities(owed) + ) + .unwrap(); + } + if answered > 0 { + writeln!( + text, + "{}{} {} call sites that remain unproven; no enrichment is currently owed \ + for them.", + if owed > 0 { "" } else { "not settled: " }, + entities(answered), + if answered == 1 { "holds" } else { "hold" }, + ) + .unwrap(); + } writeln!( text, - "not settled: {} entities pending enrichment; risk may change.", - observation.total_pending_entities - ) - .unwrap(); - writeln!( - text, - "Impact counts are lower bounds; pending work may not reach this change." + "Impact counts are lower bounds; {} may not reach this change.", + if owed > 0 { + "pending work" + } else { + "those calls" + } ) .unwrap(); if matches!( diff --git a/crates/kin-cli/src/commands/review_flags.rs b/crates/kin-cli/src/commands/review_flags.rs new file mode 100644 index 000000000..eee262797 --- /dev/null +++ b/crates/kin-cli/src/commands/review_flags.rs @@ -0,0 +1,1160 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Review flags: what a reviewer flagged, held in the repository. +//! +//! A reviewer reading uncommitted work entity by entity flags the ones that +//! are not right, with a note. The flag is an annotation on that entity which +//! carries a [`kin_model::ReviewFlag`]: the note is the annotation's body, the +//! reviewer its author, and the flag records the exact version the reviewer +//! was looking at. Held in the graph, it reaches the agent that made the +//! change the next time an answer from Kin returns the entity, and it records +//! each agent session it reached. +//! +//! A flag stays open until the reviewer closes it. Committing the entity does +//! not close it, and an entity that changes afterwards is told apart by its +//! fingerprint, so the reviewer can look again. A flag is advisory: nothing +//! here refuses a commit. +//! +//! This module is the one place flags are planned and read. A write is +//! planned against the graph ([`plan`], [`plan_sightings`]) as the records it +//! admits ([`FlagWrite`]), so the daemon commits them to repository authority +//! as one transaction before the live graph shows them, and a daemon that +//! reopens the store holds every flag it answered with. The daemon reads +//! [`reached_by`] for every agent call it answers. + +use anyhow::Result; +use kin_model::provenance::{Actor, AuditEvent}; +use kin_model::{ + Annotation, AnnotationFilter, AnnotationId, AnnotationKind, AnnotationTarget, + CollaborationDelta, EntityId, Hash256, Keyed, OperationId, ReviewFlag, ReviewFlagResolution, + ReviewFlagSighting, SemanticAnchor, StalenessState, Timestamp, WorkLink, WorkScope, +}; +use kin_model::{EntityStore as _, ProvenanceStore as _, WorkStore as _}; +use serde::{Deserialize, Serialize}; + +/// The longest note a flag carries, in characters. +pub const NOTE_MAX_CHARS: usize = 400; + +/// The longest name a flag's author or resolver is recorded under, in +/// characters. +pub const AUTHOR_MAX_CHARS: usize = 200; + +/// What setting a flag is recorded as. +pub const SET_ACTION: &str = "review_flag.set"; + +/// What closing a flag is recorded as. +pub const RESOLVE_ACTION: &str = "review_flag.resolve"; + +/// One request about review flags, as the daemon takes it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum ReviewFlagRequest { + /// Flag `entity` at `source:` of its reviewed span, or a legacy + /// `behavior:` or legacy syntax fingerprint without a prefix. + /// An entity already flagged has its flag replaced. + Set { + entity: EntityId, + #[serde(default)] + note: Option, + fingerprint: String, + operation_id: OperationId, + author: String, + }, + /// Close the open flag on `entity`. + Resolve { + entity: EntityId, + operation_id: OperationId, + author: String, + }, + /// Every open flag. + List, +} + +impl ReviewFlagRequest { + /// Whether running this request can write. + pub fn mutates(&self) -> bool { + !matches!(self, Self::List) + } +} + +/// How a request ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewFlagOutcome { + /// The entity carries an open flag as asked. + Flagged, + /// The entity's flag is closed. + Resolved, + /// There was no open flag on the entity to close. + NothingOpen, + /// The open flags are listed. + Listed, + /// Nothing was written, and `error` says why. + Refused, +} + +/// One agent session a flag reached. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewFlagSeen { + /// The name the session's client gave itself. + pub agent: String, + pub session: String, + /// When Kin first handed that session an answer carrying the flag. + pub at: Timestamp, +} + +/// One flag, as a reader is shown it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewFlagView { + /// The flag's own id, stable while it stays open. + pub flag_id: String, + pub entity_id: String, + /// The entity's name now, or the name it was flagged under once the graph + /// no longer holds it. + pub name: String, + /// The entity's kind. Absent once the graph no longer holds it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// The reviewer's note. Empty when the reviewer left none. + pub note: String, + pub author: String, + pub set_at: Timestamp, + /// Exact entity source identity as `source:`. Legacy flags retain + /// their original semantic fingerprint without the prefix. + pub fingerprint: String, + /// The entity is no longer the version that was flagged: it changed, or + /// the graph no longer holds it. + pub changed_since: bool, + /// The graph no longer holds the entity. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub removed: bool, + /// An exact-source flag could not be compared with current recorded source. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub source_unavailable: bool, + /// The agent sessions an answer from Kin handed this flag to, oldest + /// first. + pub seen_by: Vec, +} + +/// The daemon's answer to a [`ReviewFlagRequest`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReviewFlagResponse { + pub outcome: ReviewFlagOutcome, + /// The flag a `set` left open, or the one a `resolve` closed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flag: Option, + /// Every open flag, oldest first. Only a `list` fills it. + #[serde(default)] + pub flags: Vec, + /// Why nothing was written, in words for the reader. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ReviewFlagResponse { + fn refused(reason: impl Into) -> Self { + Self { + outcome: ReviewFlagOutcome::Refused, + flag: None, + flags: Vec::new(), + error: Some(reason.into()), + } + } + + fn of(outcome: ReviewFlagOutcome, flag: Option) -> Self { + Self { + outcome, + flag, + flags: Vec::new(), + error: None, + } + } +} + +/// Every record one flag write admits, as authority holds it afterwards. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct FlagWrite { + /// Each flag the write sets or changes, as its whole new annotation. + pub annotations: Vec, + /// The link from a newly set flag to the entity it is on. + pub work_links: Vec, + /// The actor the audit event below names, when the store does not hold + /// them yet. + pub actors: Vec, + pub audit_events: Vec, +} + +impl FlagWrite { + /// Whether this write records nothing, which is what a request that + /// changes nothing plans. + pub fn is_empty(&self) -> bool { + self.annotations.is_empty() + && self.work_links.is_empty() + && self.actors.is_empty() + && self.audit_events.is_empty() + } + + /// The collaboration delta that admits these records into repository + /// authority, in the one order kin-model accepts. + pub fn to_delta(&self) -> Result { + let mut annotations = self.annotations.clone(); + // A key is ordered by its canonical encoding, which for an id is its + // text. The delta's own validation below is what holds this to it. + annotations.sort_by_key(|annotation| annotation.annotation_id.0.to_string()); + let delta = CollaborationDelta { + annotations: annotations + .into_iter() + .map(|annotation| Keyed::new(annotation.annotation_id, annotation)) + .collect(), + work_links: self.work_links.clone(), + actors: self + .actors + .iter() + .map(|actor| Keyed::new(actor.actor_id, actor.clone())) + .collect(), + audit_events: self.audit_events.clone(), + ..CollaborationDelta::default() + }; + delta.validate()?; + Ok(delta) + } + + /// Make a graph hold these records. Run on the live graph only once + /// authority holds them. + pub fn apply_to(&self, graph: &kin_db::InMemoryGraph) -> Result<()> { + for actor in &self.actors { + if graph.get_actor(&actor.actor_id)?.is_none() { + graph.create_actor(actor)?; + } + } + for annotation in &self.annotations { + graph.create_annotation(annotation)?; + } + for link in &self.work_links { + graph.create_work_link(link)?; + } + for event in &self.audit_events { + graph.record_audit_event(event)?; + } + Ok(()) + } +} + +/// One request, planned: the records it admits, and what its caller answers +/// once they are held. +#[derive(Debug, Clone)] +pub struct PlannedFlagRequest { + pub write: FlagWrite, + pub response: ReviewFlagResponse, + /// What the write is, for the transaction that carries it. + pub action: &'static str, + /// Who the write is by. + pub actor: String, +} + +impl PlannedFlagRequest { + fn read(response: ReviewFlagResponse) -> Self { + Self { + write: FlagWrite::default(), + response, + action: "review_flag.read", + actor: String::new(), + } + } +} + +/// A request's answer, and whether it wrote. +#[derive(Debug, Clone)] +pub struct ReviewFlagExecution { + pub response: ReviewFlagResponse, + pub mutated: bool, +} + +/// A note as it is stored: trimmed, or the reason it cannot be. +fn stored_note(note: Option) -> std::result::Result { + let note = note.unwrap_or_default().trim().to_string(); + if note.chars().count() > NOTE_MAX_CHARS { + return Err(format!("A note is at most {NOTE_MAX_CHARS} characters.")); + } + Ok(note) +} + +/// Who a write is recorded under, or the reason nobody can be named. +fn stored_author(author: &str) -> std::result::Result { + let author = author.trim(); + if author.is_empty() { + return Err("A flag needs to know who set it, and no name is configured.".to_string()); + } + Ok(author.chars().take(AUTHOR_MAX_CHARS).collect()) +} + +/// The name a reader knows an agent by, from the vendor and client name its +/// session registered with: the name the local app shows the same agent under. +pub fn agent_name(vendor: &str, client: &str) -> String { + super::workspace_agents::agent_display_name(vendor, client) +} + +fn kind_label(kind: kin_model::EntityKind) -> Option { + serde_json::to_value(kind) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +/// One flag as a reader is shown it, read against the graph as it is now. +pub fn view(graph: &kin_db::InMemoryGraph, annotation: &Annotation) -> Option { + view_with_source(graph, annotation, |_| None) +} + +/// Read exact-source freshness through the caller's persisted span authority. +/// No source bytes become part of the flag answer. +pub fn view_with_source( + graph: &kin_db::InMemoryGraph, + annotation: &Annotation, + source_digest: impl FnOnce(&kin_model::Entity) -> Option, +) -> Option { + let flag = annotation.review.as_ref()?; + let entity_id = annotation.flagged_entity()?; + let entity = graph.get_entity(&entity_id).ok().flatten(); + let current_source = flag + .flagged_source + .and_then(|_| entity.as_ref().and_then(source_digest)); + let source_unavailable = + flag.flagged_source.is_some() && entity.is_some() && current_source.is_none(); + let changed_since = entity.as_ref().is_none_or(|entity| { + if let Some(flagged) = flag.flagged_source { + current_source != Some(flagged) + } else { + match flag.flagged_behavior { + Some(flagged) => entity.fingerprint.behavior_hash != flagged, + None => entity.fingerprint.ast_hash != flag.flagged_ast, + } + } + }); + Some(ReviewFlagView { + flag_id: annotation.annotation_id.to_string(), + entity_id: entity_id.to_string(), + name: entity + .as_ref() + .map(|entity| entity.name.clone()) + .unwrap_or_else(|| flag.flagged_name.clone()), + kind: entity.as_ref().and_then(|entity| kind_label(entity.kind)), + note: annotation.body.clone(), + author: annotation.authored_by.name.clone(), + set_at: annotation.created_at.clone(), + fingerprint: flag.flagged_source.map_or_else( + || { + flag.flagged_behavior.map_or_else( + || flag.flagged_ast.to_string(), + |digest| format!("behavior:{digest}"), + ) + }, + |digest| format!("source:{digest}"), + ), + changed_since, + removed: entity.is_none(), + source_unavailable, + seen_by: flag + .seen_by + .iter() + .map(|seen| ReviewFlagSeen { + agent: seen.agent.clone(), + session: seen.session.to_string(), + at: seen.at.clone(), + }) + .collect(), + }) +} + +/// Every open flag, oldest first. +pub fn open_flags(graph: &kin_db::InMemoryGraph) -> Vec { + let mut open = graph.open_review_flags(); + open.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.annotation_id.0.cmp(&right.annotation_id.0)) + }); + open +} + +/// The open flags on the entities `named` holds, oldest first: what an answer +/// that returned those entities reaches. +pub fn reached_by( + graph: &kin_db::InMemoryGraph, + named: &std::collections::HashSet, +) -> Vec { + if named.is_empty() { + return Vec::new(); + } + let mut reached: Vec = graph + .open_review_flags() + .into_iter() + .filter(|annotation| { + annotation + .flagged_entity() + .is_some_and(|entity_id| named.contains(&entity_id.0)) + }) + .collect(); + reached.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.annotation_id.0.cmp(&right.annotation_id.0)) + }); + reached +} + +/// The write that records one agent session on each open flag in `flags` +/// that does not record it yet. Empty when there is nothing to record: a +/// session a flag already holds, and a flag closed in the meantime. +pub fn plan_sightings( + graph: &kin_db::InMemoryGraph, + sightings: &[(AnnotationId, ReviewFlagSighting)], +) -> Result { + let mut changed: Vec = Vec::new(); + for (annotation_id, sighting) in sightings { + // A flag two sessions saw is written once, holding both. + let planned = changed + .iter() + .position(|annotation| annotation.annotation_id == *annotation_id); + let mut annotation = match planned { + Some(at) => changed.remove(at), + None => match graph.get_annotation(annotation_id)? { + Some(annotation) => annotation, + None => continue, + }, + }; + let recorded = annotation + .review + .as_mut() + .filter(|flag| flag.resolution.is_none()) + .is_some_and(|flag| flag.record_sighting(sighting.clone())); + if recorded || planned.is_some() { + changed.push(annotation); + } + } + Ok(FlagWrite { + annotations: changed, + ..FlagWrite::default() + }) +} + +/// Add to `write` the record of who did what to a flag, the way `kin note +/// add` records a note. +fn plan_audit( + graph: &kin_db::InMemoryGraph, + write: &mut FlagWrite, + author: &str, + action: &str, + annotation: &Annotation, +) -> Result<()> { + let (actor, event) = crate::provenance::plan_audit_event( + graph, + author, + action, + annotation.scopes.first().cloned(), + Some(format!("annotation_id={}", annotation.annotation_id)), + )?; + write.actors.extend(actor); + write.audit_events.push(event); + Ok(()) +} + +fn plan_set( + graph: &kin_db::InMemoryGraph, + entity_id: EntityId, + note: Option, + fingerprint: &str, + operation_id: OperationId, + author: &str, +) -> Result { + let refused = |reason: String| PlannedFlagRequest::read(ReviewFlagResponse::refused(reason)); + if operation_id.0.is_nil() { + return Ok(refused("A flag needs a nonempty operation ID.".to_string())); + } + let note = match stored_note(note) { + Ok(note) => note, + Err(reason) => return Ok(refused(reason)), + }; + let author = match stored_author(author) { + Ok(author) => author, + Err(reason) => return Ok(refused(reason)), + }; + let fingerprint = fingerprint.trim(); + let exact_source = fingerprint.strip_prefix("source:"); + let behavior = fingerprint.strip_prefix("behavior:"); + let Ok(flagged_fingerprint) = + Hash256::from_hex(exact_source.or(behavior).unwrap_or(fingerprint)) + else { + return Ok(refused( + "A flag names the version it was set on by that version's fingerprint, and this is \ + not one." + .to_string(), + )); + }; + let on_entity = graph.list_annotations(&AnnotationFilter { + kinds: None, + scopes: Some(vec![WorkScope::Entity(entity_id)]), + include_stale: true, + })?; + // Remember every operation, including one superseded or closed since its + // answer was lost. Retrying it must not replace a newer reviewer's flag. + if let Some(recorded) = on_entity.iter().find(|annotation| { + annotation + .review + .as_ref() + .is_some_and(|flag| flag.operation_id == operation_id) + }) { + return Ok(PlannedFlagRequest::read(ReviewFlagResponse::of( + ReviewFlagOutcome::Flagged, + view(graph, recorded), + ))); + } + let Some(entity) = graph.get_entity(&entity_id)? else { + return Ok(refused( + "This entity is not in the repository's graph now, so a flag on it would reach no \ + agent." + .to_string(), + )); + }; + let held = on_entity + .into_iter() + .find(|annotation| annotation.open_review_flag().is_some()); + let annotation = Annotation { + // A replacement is a new record. Keep the old request and its + // sightings so retries and review history remain truthful. + annotation_id: AnnotationId::new(), + kind: AnnotationKind::Warning, + body: note, + scopes: vec![WorkScope::Entity(entity_id)], + // The entity as it is now, which is what recall re-anchors a renamed + // entity's annotations by. The version the reviewer saw is the + // flag's own. Captured here, so authority and the live graph hold + // the same record. + anchored_fingerprint: Some(SemanticAnchor { + ast_hash: entity.fingerprint.ast_hash, + signature_hash: entity.fingerprint.signature_hash, + }), + authored_by: crate::provenance::identity_for_label(&author), + created_at: Timestamp::now(), + staleness: StalenessState::Fresh, + review: Some(ReviewFlag { + flagged_ast: if exact_source.is_none() && behavior.is_none() { + flagged_fingerprint + } else { + entity.fingerprint.ast_hash + }, + flagged_behavior: behavior.map(|_| flagged_fingerprint), + flagged_source: exact_source.map(|_| flagged_fingerprint), + flagged_name: entity.name.clone(), + operation_id, + resolution: None, + // A flag set again says something new, which no agent has seen. + seen_by: Vec::new(), + }), + }; + let mut write = FlagWrite::default(); + write.work_links.push(WorkLink::AttachedTo { + annotation_id: annotation.annotation_id, + target: AnnotationTarget::Scope(WorkScope::Entity(entity_id)), + }); + if let Some(mut held) = held { + if let Some(flag) = held.review.as_mut() { + flag.resolution = Some(ReviewFlagResolution { + by: crate::provenance::identity_for_label(&author), + at: annotation.created_at.clone(), + operation_id, + }); + } + write.work_links.push(WorkLink::Supersedes { + new_id: annotation.annotation_id, + old_id: held.annotation_id, + }); + write.annotations.push(held); + } + plan_audit(graph, &mut write, &author, SET_ACTION, &annotation)?; + let response = ReviewFlagResponse::of(ReviewFlagOutcome::Flagged, view(graph, &annotation)); + write.annotations.push(annotation); + Ok(PlannedFlagRequest { + write, + response, + action: SET_ACTION, + actor: author, + }) +} + +fn plan_resolve( + graph: &kin_db::InMemoryGraph, + entity_id: EntityId, + operation_id: OperationId, + author: &str, +) -> Result { + let refused = |reason: String| PlannedFlagRequest::read(ReviewFlagResponse::refused(reason)); + if operation_id.0.is_nil() { + return Ok(refused( + "Closing a flag needs a nonempty operation ID.".to_string(), + )); + } + let author = match stored_author(author) { + Ok(author) => author, + Err(reason) => return Ok(refused(reason)), + }; + let on_entity: Vec = graph + .list_annotations(&AnnotationFilter { + kinds: None, + scopes: Some(vec![WorkScope::Entity(entity_id)]), + include_stale: true, + })? + .into_iter() + .filter(|annotation| annotation.flagged_entity() == Some(entity_id)) + .collect(); + // A retry of an earlier close names that historical record, even when + // another flag is now open on the same entity. + if let Some(closed) = on_entity.iter().find(|annotation| { + annotation + .review + .as_ref() + .and_then(|flag| flag.resolution.as_ref()) + .is_some_and(|resolution| resolution.operation_id == operation_id) + }) { + return Ok(PlannedFlagRequest::read(ReviewFlagResponse::of( + ReviewFlagOutcome::Resolved, + view(graph, closed), + ))); + } + let Some(mut open) = on_entity + .iter() + .find(|annotation| annotation.open_review_flag().is_some()) + .cloned() + else { + return Ok(PlannedFlagRequest::read(ReviewFlagResponse::of( + ReviewFlagOutcome::NothingOpen, + None, + ))); + }; + if let Some(flag) = open.review.as_mut() { + flag.resolution = Some(ReviewFlagResolution { + by: crate::provenance::identity_for_label(&author), + at: Timestamp::now(), + operation_id, + }); + } + let mut write = FlagWrite::default(); + plan_audit(graph, &mut write, &author, RESOLVE_ACTION, &open)?; + let response = ReviewFlagResponse::of(ReviewFlagOutcome::Resolved, view(graph, &open)); + write.annotations.push(open); + Ok(PlannedFlagRequest { + write, + response, + action: RESOLVE_ACTION, + actor: author, + }) +} + +/// Plan one request against `graph`, writing nothing. +/// +/// A request that cannot be met is answered as refused, in words, and plans +/// no write. An error is a store that could not be read. +pub fn plan( + graph: &kin_db::InMemoryGraph, + request: ReviewFlagRequest, +) -> Result { + match request { + ReviewFlagRequest::Set { + entity, + note, + fingerprint, + operation_id, + author, + } => plan_set(graph, entity, note, &fingerprint, operation_id, &author), + ReviewFlagRequest::Resolve { + entity, + operation_id, + author, + } => plan_resolve(graph, entity, operation_id, &author), + ReviewFlagRequest::List => Ok(PlannedFlagRequest::read(ReviewFlagResponse { + outcome: ReviewFlagOutcome::Listed, + flag: None, + flags: open_flags(graph) + .iter() + .filter_map(|annotation| view(graph, annotation)) + .collect(), + error: None, + })), + } +} + +/// Plan one request and apply its write to `graph` itself. +/// +/// For a graph that is its own authority, as in a test. The daemon commits a +/// plan to repository authority first and applies it after. +pub fn execute( + graph: &kin_db::InMemoryGraph, + request: ReviewFlagRequest, +) -> Result { + let planned = plan(graph, request)?; + let mutated = !planned.write.is_empty(); + planned.write.apply_to(graph)?; + Ok(ReviewFlagExecution { + response: planned.response, + mutated, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Record one session on some flags, as a graph that is its own authority. + fn record_sightings( + graph: &kin_db::InMemoryGraph, + flags: &[AnnotationId], + sighting: &ReviewFlagSighting, + ) -> Result { + let sightings: Vec<_> = flags.iter().map(|flag| (*flag, sighting.clone())).collect(); + let write = plan_sightings(graph, &sightings)?; + write.apply_to(graph)?; + Ok(!write.is_empty()) + } + use kin_model::{ + Entity, EntityKind, EntityRole, FilePathId, FingerprintAlgorithm, LanguageId, + SemanticFingerprint, SessionId, Visibility, + }; + + fn entity(name: &str, ast: u8) -> Entity { + Entity { + id: EntityId::new(), + kind: EntityKind::Method, + name: name.to_string(), + language: LanguageId::TypeScript, + fingerprint: SemanticFingerprint { + algorithm: FingerprintAlgorithm::V1TreeSitter, + ast_hash: Hash256::from_bytes([ast; 32]), + signature_hash: Hash256::from_bytes([0x02; 32]), + behavior_hash: Hash256::from_bytes([0x03; 32]), + equivalence_hash: Hash256::from_bytes([0; 32]), + stability_score: 1.0, + }, + file_origin: Some(FilePathId::new("src/reply.ts")), + span: None, + signature: format!("{name}(payload: Payload): boolean"), + visibility: Visibility::Public, + role: EntityRole::Source, + doc_summary: None, + metadata: Default::default(), + lineage_parent: None, + created_in: None, + superseded_by: None, + } + } + + fn graph_with(entities: &[&Entity]) -> kin_db::InMemoryGraph { + let graph = kin_db::InMemoryGraph::default(); + for entity in entities { + graph.upsert_entity(entity).unwrap(); + } + graph + } + + fn flag(entity: &Entity, note: &str) -> ReviewFlagRequest { + ReviewFlagRequest::Set { + entity: entity.id, + note: Some(note.to_string()), + fingerprint: format!("behavior:{}", entity.fingerprint.behavior_hash), + operation_id: OperationId::new(), + author: "Ada Reviewer".to_string(), + } + } + + fn listed(graph: &kin_db::InMemoryGraph) -> Vec { + execute(graph, ReviewFlagRequest::List) + .unwrap() + .response + .flags + } + + #[test] + fn a_flag_is_stored_as_an_annotation_on_the_entity_and_listed_open() { + let send = entity("ServerReply.send", 0x01); + let graph = graph_with(&[&send]); + assert!(listed(&graph).is_empty()); + + let set = execute(&graph, flag(&send, " a second send must fail loudly ")).unwrap(); + assert!(set.mutated); + assert_eq!(set.response.outcome, ReviewFlagOutcome::Flagged); + let shown = set.response.flag.unwrap(); + assert_eq!(shown.entity_id, send.id.to_string()); + assert_eq!(shown.name, "ServerReply.send"); + assert_eq!(shown.kind.as_deref(), Some("method")); + assert_eq!(shown.note, "a second send must fail loudly"); + assert_eq!(shown.author, "Ada Reviewer"); + assert_eq!( + shown.fingerprint, + format!("behavior:{}", send.fingerprint.behavior_hash) + ); + assert!(!shown.changed_since); + assert!(!shown.removed); + assert!(shown.seen_by.is_empty()); + assert_eq!(listed(&graph), vec![shown]); + + // The flag is an annotation in the store `kin note list` reads, scoped + // to the entity, with an audit record of who set it. + let annotations = graph + .get_annotations_for_scope(&WorkScope::Entity(send.id)) + .unwrap(); + assert_eq!(annotations.len(), 1); + assert_eq!(annotations[0].kind, AnnotationKind::Warning); + assert_eq!(annotations[0].body, "a second send must fail loudly"); + assert!(annotations[0].anchored_fingerprint.is_some()); + assert!(annotations[0].open_review_flag().is_some()); + let audit = graph.query_audit_events(None, 10).unwrap(); + assert_eq!(audit.len(), 1); + assert_eq!(audit[0].action, "review_flag.set"); + } + + #[test] + fn the_same_request_sets_a_flag_once_and_a_new_one_replaces_it() { + let send = entity("ServerReply.send", 0x01); + let graph = graph_with(&[&send]); + let first = flag(&send, "first note"); + assert!(execute(&graph, first.clone()).unwrap().mutated); + let again = execute(&graph, first.clone()).unwrap(); + assert!(!again.mutated); + assert_eq!(again.response.outcome, ReviewFlagOutcome::Flagged); + assert_eq!(again.response.flag.unwrap().note, "first note"); + + // An agent saw the first flag. + let held = open_flags(&graph); + let sighting = ReviewFlagSighting { + agent: "codex".to_string(), + session: SessionId::new(), + at: Timestamp::now(), + }; + assert!(record_sightings(&graph, &[held[0].annotation_id], &sighting).unwrap()); + + // Flagged again with a new note, it is one flag, and nobody has seen + // what it says now. + let replaced = execute(&graph, flag(&send, "second note")).unwrap(); + assert!(replaced.mutated); + let open = listed(&graph); + assert_eq!(open.len(), 1); + assert_eq!(open[0].note, "second note"); + assert!(open[0].seen_by.is_empty()); + assert_ne!(open_flags(&graph)[0].annotation_id, held[0].annotation_id); + let retried = execute(&graph, first).unwrap(); + assert!(!retried.mutated); + assert_eq!(retried.response.flag.unwrap().note, "first note"); + assert_eq!(listed(&graph)[0].note, "second note"); + let previous = graph + .get_annotation(&held[0].annotation_id) + .unwrap() + .unwrap(); + assert!(previous.open_review_flag().is_none()); + assert_eq!(previous.review.unwrap().seen_by.len(), 1); + } + + #[test] + fn a_flag_that_cannot_be_set_is_refused_in_words_and_writes_nothing() { + let send = entity("ServerReply.send", 0x01); + let graph = graph_with(&[&send]); + let refuse = |request: ReviewFlagRequest| { + let done = execute(&graph, request).unwrap(); + assert!(!done.mutated); + assert_eq!(done.response.outcome, ReviewFlagOutcome::Refused); + done.response.error.unwrap() + }; + let ast = send.fingerprint.behavior_hash.to_string(); + let request = |entity: EntityId, + note: &str, + fingerprint: &str, + operation_id: OperationId, + author: &str| ReviewFlagRequest::Set { + entity, + note: Some(note.to_string()), + fingerprint: fingerprint.to_string(), + operation_id, + author: author.to_string(), + }; + let unheld = refuse(request( + EntityId::new(), + "note", + &ast, + OperationId::new(), + "Ada Reviewer", + )); + assert!(unheld.contains("not in the repository's graph"), "{unheld}"); + let long = refuse(request( + send.id, + &"x".repeat(NOTE_MAX_CHARS + 1), + &ast, + OperationId::new(), + "Ada Reviewer", + )); + assert!(long.contains("at most 400 characters"), "{long}"); + let shapeless = refuse(request( + send.id, + "note", + "abc", + OperationId::new(), + "Ada Reviewer", + )); + assert!(shapeless.contains("fingerprint"), "{shapeless}"); + let unkeyed = refuse(request( + send.id, + "note", + &ast, + OperationId::from_uuid(uuid::Uuid::nil()), + "Ada Reviewer", + )); + assert!(unkeyed.contains("operation ID"), "{unkeyed}"); + let unsigned = refuse(request(send.id, "note", &ast, OperationId::new(), " ")); + assert!(unsigned.contains("who set it"), "{unsigned}"); + // No refusal speaks of a file or a command. + for said in [unheld, long, shapeless, unkeyed, unsigned] { + assert!(!said.contains("file") && !said.contains("kin "), "{said}"); + } + assert!(listed(&graph).is_empty()); + assert!(graph + .list_annotations(&AnnotationFilter { + include_stale: true, + ..Default::default() + }) + .unwrap() + .is_empty()); + } + + #[test] + fn a_flag_follows_its_entity_until_the_reviewer_closes_it() { + let send = entity("ServerReply.send", 0x01); + let graph = graph_with(&[&send]); + execute(&graph, flag(&send, "note")).unwrap(); + + // The entity is written again unchanged, as a commit leaves it: the + // flag is still open and says nothing changed. + let mut committed = send.clone(); + committed.created_in = None; + graph.upsert_entity(&committed).unwrap(); + let open = listed(&graph); + assert_eq!(open.len(), 1); + assert!(!open[0].changed_since); + + // The entity changes: the flag is still open, on the version that was + // flagged, and says the entity moved on. + let mut edited = send.clone(); + edited.fingerprint.behavior_hash = Hash256::from_bytes([0x09; 32]); + graph.upsert_entity(&edited).unwrap(); + let open = listed(&graph); + assert_eq!(open.len(), 1); + assert!(open[0].changed_since); + assert!(!open[0].removed); + assert_eq!( + open[0].fingerprint, + format!("behavior:{}", send.fingerprint.behavior_hash) + ); + + // The reviewer closes it. Closing is one write, and the same request + // sent again answers the same without writing. + let resolve = ReviewFlagRequest::Resolve { + entity: send.id, + operation_id: OperationId::new(), + author: "Ada Reviewer".to_string(), + }; + let closed = execute(&graph, resolve.clone()).unwrap(); + assert!(closed.mutated); + assert_eq!(closed.response.outcome, ReviewFlagOutcome::Resolved); + assert!(listed(&graph).is_empty()); + let again = execute(&graph, resolve.clone()).unwrap(); + assert!(!again.mutated); + assert_eq!(again.response.outcome, ReviewFlagOutcome::Resolved); + // A different request finds nothing open. + let nothing = execute( + &graph, + ReviewFlagRequest::Resolve { + entity: send.id, + operation_id: OperationId::new(), + author: "Ada Reviewer".to_string(), + }, + ) + .unwrap(); + assert!(!nothing.mutated); + assert_eq!(nothing.response.outcome, ReviewFlagOutcome::NothingOpen); + // The closed flag is kept as a record of who closed it. + let kept = graph + .get_annotations_for_scope(&WorkScope::Entity(send.id)) + .unwrap(); + assert_eq!(kept.len(), 1); + let resolution = kept[0].review.as_ref().unwrap().resolution.as_ref(); + assert_eq!(resolution.unwrap().by.name, "Ada Reviewer"); + + // Flagged again after that, it is a new flag. + execute(&graph, flag(&edited, "still wrong")).unwrap(); + assert_eq!(listed(&graph).len(), 1); + let retry_close = execute(&graph, resolve).unwrap(); + assert!(!retry_close.mutated); + assert_eq!(retry_close.response.outcome, ReviewFlagOutcome::Resolved); + assert_eq!(listed(&graph)[0].note, "still wrong"); + assert_eq!( + graph + .get_annotations_for_scope(&WorkScope::Entity(send.id)) + .unwrap() + .len(), + 2 + ); + } + + #[test] + fn exact_source_flags_notice_comment_edits_and_report_missing_source() { + let send = entity("ServerReply.send", 0x01); + let graph = graph_with(&[&send]); + let before = Hash256::from_bytes(kin_blobs::digest_bytes(b"send() { return true; }")); + let after = Hash256::from_bytes(kin_blobs::digest_bytes( + b"send() { /* explained */ return true; }", + )); + let mut request = flag(&send, "explain this"); + if let ReviewFlagRequest::Set { fingerprint, .. } = &mut request { + *fingerprint = format!("source:{before}"); + } + execute(&graph, request).unwrap(); + let held = open_flags(&graph).pop().unwrap(); + let original = view_with_source(&graph, &held, |_| Some(before)).unwrap(); + assert!(!original.changed_since); + assert!(!original.source_unavailable); + assert_eq!(original.fingerprint, format!("source:{before}")); + // The semantic hashes are unchanged; source freshness still changes. + let changed = view_with_source(&graph, &held, |_| Some(after)).unwrap(); + assert!(changed.changed_since); + assert!(!changed.source_unavailable); + let missing = view(&graph, &held).unwrap(); + assert!(missing.changed_since); + assert!(missing.source_unavailable); + } + + #[test] + fn a_flag_on_an_entity_the_graph_no_longer_holds_keeps_its_name() { + let send = entity("ServerReply.send", 0x01); + let graph = graph_with(&[&send]); + execute(&graph, flag(&send, "note")).unwrap(); + graph.remove_entity(&send.id).unwrap(); + let open = listed(&graph); + assert_eq!(open.len(), 1); + assert_eq!(open[0].name, "ServerReply.send"); + assert_eq!(open[0].kind, None); + assert!(open[0].changed_since); + assert!(open[0].removed); + } + + #[test] + fn an_answer_reaches_the_flags_on_the_entities_it_returned_and_records_who_saw_them() { + let send = entity("ServerReply.send", 0x01); + let not_found = entity("notFound", 0x04); + let quiet = entity("serialize", 0x05); + let graph = graph_with(&[&send, ¬_found, &quiet]); + execute(&graph, flag(&send, "first")).unwrap(); + execute(&graph, flag(¬_found, "second")).unwrap(); + + let named = |entities: &[&Entity]| -> std::collections::HashSet { + entities.iter().map(|entity| entity.id.0).collect() + }; + assert!(reached_by(&graph, &named(&[])).is_empty()); + assert!(reached_by(&graph, &named(&[&quiet])).is_empty()); + let reached = reached_by(&graph, &named(&[&quiet, &send])); + assert_eq!(reached.len(), 1); + assert_eq!(reached[0].flagged_entity(), Some(send.id)); + let both = reached_by(&graph, &named(&[¬_found, &send])); + assert_eq!( + both.iter() + .map(|annotation| annotation.body.as_str()) + .collect::>(), + vec!["first", "second"], + "oldest first" + ); + + let session = SessionId::new(); + let sighting = ReviewFlagSighting { + agent: "codex".to_string(), + session, + at: Timestamp::now(), + }; + let ids: Vec = reached + .iter() + .map(|annotation| annotation.annotation_id) + .collect(); + assert!(record_sightings(&graph, &ids, &sighting).unwrap()); + assert!( + !record_sightings(&graph, &ids, &sighting).unwrap(), + "a session is recorded once" + ); + let open = listed(&graph); + let seen = &open + .iter() + .find(|flag| flag.name == "ServerReply.send") + .unwrap() + .seen_by; + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].agent, "codex"); + assert_eq!(seen[0].session, session.to_string()); + assert!(open + .iter() + .find(|flag| flag.name == "notFound") + .unwrap() + .seen_by + .is_empty()); + + // A flag closed in the meantime records nothing. + execute( + &graph, + ReviewFlagRequest::Resolve { + entity: send.id, + operation_id: OperationId::new(), + author: "Ada Reviewer".to_string(), + }, + ) + .unwrap(); + let later = ReviewFlagSighting { + agent: "claude".to_string(), + session: SessionId::new(), + at: Timestamp::now(), + }; + assert!(!record_sightings(&graph, &ids, &later).unwrap()); + assert!(reached_by(&graph, &named(&[&send])).is_empty()); + } + + /// The wire shapes the daemon route and the local app's routes carry. + #[test] + fn requests_and_answers_keep_their_wire_shape() { + let entity_id = EntityId::new(); + let operation_id = OperationId::new(); + let request: ReviewFlagRequest = serde_json::from_value(serde_json::json!({ + "op": "set", "entity": entity_id, "fingerprint": "ab".repeat(32), + "operation_id": operation_id, "author": "ada", + })) + .unwrap(); + assert!(request.mutates()); + assert!(matches!(request, ReviewFlagRequest::Set { note: None, .. })); + let list: ReviewFlagRequest = + serde_json::from_value(serde_json::json!({"op": "list"})).unwrap(); + assert!(!list.mutates()); + + let send = entity("ServerReply.send", 0x01); + let graph = graph_with(&[&send]); + let set = execute(&graph, flag(&send, "note")).unwrap().response; + let wire = serde_json::to_value(&set).unwrap(); + assert_eq!(wire["outcome"], "flagged"); + let shown = wire["flag"].as_object().unwrap(); + assert_eq!( + shown.keys().map(String::as_str).collect::>(), + vec![ + "author", + "changed_since", + "entity_id", + "fingerprint", + "flag_id", + "kind", + "name", + "note", + "seen_by", + "set_at", + ] + ); + assert!(wire.get("error").is_none()); + } +} diff --git a/crates/kin-cli/src/commands/review_guide.rs b/crates/kin-cli/src/commands/review_guide.rs index 8fb58375c..583fcb015 100644 --- a/crates/kin-cli/src/commands/review_guide.rs +++ b/crates/kin-cli/src/commands/review_guide.rs @@ -168,6 +168,11 @@ pub struct GuideRange { pub changes_total: usize, /// The newest of them, newest first. pub changes: Vec, + /// How many changes the base holds that the head does not, so a reader + /// can tell a branch that is behind its base from one that is even with + /// it. A branch is even only when this and `changes_total` are both zero. + #[serde(default)] + pub base_changes_total: usize, /// Every change the range holds, by id, so a reader can tell whether a /// change it sees elsewhere belongs to the range. Up to /// `RANGE_MEMBERS_MAX`; `members_complete` says whether that was all. @@ -205,6 +210,12 @@ pub struct GuideHistory { /// range, by id. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub in_range: Vec, + /// The entity's oldest revision, when it is the change the store's + /// history begins with: a change with no parent. In a store that holds + /// only part of the repository's history, that is where Kin's record of + /// the entity starts, with the repository as it was. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub record_start: Option, } /// Where each reviewed change stands, for a list of changes. @@ -330,12 +341,28 @@ pub struct GuideSummary { /// Their names, up to `THROUGH_MEMBERS_LISTED_MAX`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub through_members: Vec, + /// Of `provenance_only_entity_changes`, the entities whose documentation + /// Kin read again in a different form while their code and the comment + /// above them held still. Not modifications, so never walked; said once + /// so a reader who sees the entity re-recorded knows why. + #[serde(default, skip_serializing_if = "is_zero")] + pub documentation_rereads: usize, + /// Their names, up to `THROUGH_MEMBERS_LISTED_MAX`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub documentation_reread_names: Vec, /// Distinct entities outside this change that reach an entity it changed. /// Declarations of one callable, such as `@overload` stubs beside their /// implementation, count once. pub recorded_consumers: usize, - /// Distinct recorded tests that reach an entity it changed. + /// Distinct tests outside the change within the connection test-depth + /// bound. Unsettled graph coverage remains a lower bound. A test is a + /// test entity or a test module; test code inside one, such as a + /// constant, is counted as the test that holds it + /// ([`kin_review::is_test_unit`]). pub recorded_tests: usize, + /// The recorded impact hops `recorded_tests` were counted within. + #[serde(default = "test_reach_depth")] + pub tests_depth: u32, /// Of `recorded_consumers`, the ones a step's `consumer_verdict` says /// were `not_updated`: they reach a changed entity whose signature /// changed, that stopped being public or was removed, or whose recorded @@ -358,18 +385,33 @@ pub struct GuideSummary { pub completeness: GuideCompleteness, /// One plain sentence a reader can take away without opening a step. pub narration: String, + /// The agent sessions whose writes through Kin wrote entities this guide + /// walks, newest write first, each with how many of the steps it wrote. + /// Left out when no step was written through Kin. See + /// [`GuideStep::authored_by`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authors: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GuideCompleteness { - /// `not_settled`, `no_recorded_debt` or `unavailable`. No state here - /// claims the review is complete, because the review never proves that. + /// `not_settled` while enrichment of entities in the review's reach is + /// still owed, `settled` when none is, or `unavailable`. No state here + /// claims the review is complete, because the review never proves that: + /// `settled` says nothing is waiting, not that every relation is known. pub state: String, pub message: String, - /// While `not_settled`: how many entities Kin is still mapping, so a - /// reader can be told the counts may grow by how much work remains. + /// While `not_settled`: how many entities are still owed enrichment, so a + /// reader can be told the counts may grow by how much work remains. Only + /// owed work is counted here, because only owed work changes by waiting. #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_entities: Option, + /// Entities with unproven call sites and no enrichment currently owed. + /// This includes unresolved resolver answers, unavailable resolvers and + /// stale or unverified proof context. Impact counts may miss callers + /// through those sites. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unresolved_entities: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -427,7 +469,273 @@ pub struct GuideStep { #[serde(default, skip_serializing_if = "Option::is_none")] pub consumer_verdict: Option, pub findings: Vec, + /// Every agent session that wrote this entity in the work the guide + /// walks, newest first. A write made through Kin names the entity its own + /// operation named or created, never one that only came along in the same + /// change. An edit the agent made with its own tools names the entities + /// whose content that edit changed, once its client reported the edit and + /// Kin confirmed the edit was all that changed. Left out when neither + /// names this entity: it was then changed by a person, or by a tool that + /// told Kin nothing. A write through Kin is a recorded change the moment + /// it lands, so on uncommitted work only edits made with an agent's own + /// tools appear. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authored_by: Vec, pub evidence: GuideEvidence, + /// How every test that reaches the entity stands for the work as it is + /// now, listed or not. Present in a guide of uncommitted work only, where + /// each listed test carries its own `result`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tests_result: Option, +} + +/// How many of the tests that reach a step's entity passed, failed, have no +/// run, or have only a run recorded before something they ran against +/// changed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GuideTestsResult { + pub passed: usize, + pub failed: usize, + pub not_run: usize, + pub stale: usize, +} + +impl GuideTestsResult { + fn count(&mut self, state: super::verify::TestResultState) { + use super::verify::TestResultState; + match state { + TestResultState::Passed => self.passed += 1, + TestResultState::Failed => self.failed += 1, + TestResultState::NotRun => self.not_run += 1, + TestResultState::Stale => self.stale += 1, + } + } +} + +/// One agent session's writing of a step's entity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GuideAuthorship { + /// The agent as a reader knows it, such as `Codex` or `Claude Code`. + pub name: String, + /// The session the entity was written in: Kin's own session for a write + /// through Kin, the client's session for an edit made with its tools. + pub session: String, + /// When its newest writing of the entity was recorded. + pub at: String, + /// How the writing reached the entity: `kin` for a write made through + /// Kin, `client` for an edit the agent made with its own tools. + pub through: String, + /// For `client`: the Kin session the same agent was connected through, + /// when exactly one session of that client was connected at the time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kin_session: Option, +} + +/// One agent session among a guide's authors. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GuideAuthor { + pub name: String, + pub session: String, + /// How many of the guide's steps it wrote. + pub entities: usize, + /// `kin` or `client`, as on a step. + #[serde(default = "through_kin")] + pub through: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kin_session: Option, +} + +fn through_kin() -> String { + THROUGH_KIN.to_string() +} + +/// One edit an agent made with its own tools, as its client reported it and +/// the daemon confirmed it: the entities whose content that edit changed, the +/// client and its session, and the head the working copy stood on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientEdit { + /// The change the working copy stood on when the edit was admitted. + /// Absent in a repository with no change yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option, + /// The change that recorded the edit, once one did. Until then the edit + /// is uncommitted work on `base`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub change_id: Option, + /// The client as it names itself, such as `claude-code`. + pub client: String, + /// The client's own session. + pub session: String, + /// The Kin session the same agent was connected through, when exactly + /// one session of that client was connected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kin_session: Option, + pub at: String, + pub entities: Vec, +} + +/// What an edit an agent made with its own tools is shown as having reached +/// its entity by. +pub const THROUGH_CLIENT: &str = "client"; + +/// One write an agent made through Kin, as the daemon that ran it recorded it: +/// the change the write was published as, the session that made it, and the +/// entities its own operations wrote. A change can hold more than that, work +/// it carried in and spans that only moved, and those are not among them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentWrite { + pub change_id: String, + pub session: String, + pub vendor: String, + pub client: String, + pub at: String, + pub entities: Vec, +} + +/// What a write through Kin is shown as having reached its entity by. +pub const THROUGH_KIN: &str = "kin"; + +/// Mark each step with the agent sessions that wrote its entity, and the +/// guide's summary with those sessions. +/// +/// A write through Kin belongs to the work a guide walks when it was published +/// as that work: the change a change guide walks, or one of a range's changes. +/// A guide of uncommitted work holds no change, and nothing written through +/// Kin is uncommitted, so it is never marked from `writes`. A step is marked +/// only for the entity a write's own operation wrote, so an edit that came +/// along in the same change, or a span that only moved, is not. +/// +/// An edit an agent made with its own tools belongs to uncommitted work while +/// no change holds it and the working copy still stands on the head it was +/// made on, and to a change or a range once a change of theirs recorded it. +pub fn attribute_agent_writes( + guide: &mut ReviewGuide, + writes: &[AgentWrite], + edits: &[ClientEdit], +) { + struct Mark<'a> { + name: String, + session: &'a str, + at: &'a str, + through: &'static str, + kin_session: Option<&'a str>, + } + + let members: HashSet<&str> = guide + .range + .as_ref() + .map(|range| range.members.iter().map(String::as_str).collect()) + .unwrap_or_default(); + let working = guide.kind == "working"; + let walked = |change_id: &str| match guide.kind.as_str() { + "change" => change_id.eq_ignore_ascii_case(&guide.change.id), + "range" => members.contains(change_id), + _ => false, + }; + type Newest<'a> = HashMap>>; + fn keep<'a>(newest: &mut Newest<'a>, entity: EntityId, mark: Mark<'a>) { + let by_session = newest.entry(entity).or_default(); + match by_session.get_mut(&(mark.through, mark.session)) { + Some(kept) if mark.at > kept.at => *kept = mark, + Some(_) => {} + None => { + by_session.insert((mark.through, mark.session), mark); + } + } + } + // Each session's newest writing of each entity, by how it reached it. + let mut newest: Newest<'_> = HashMap::new(); + for write in writes.iter().filter(|write| walked(&write.change_id)) { + for entity in &write.entities { + keep( + &mut newest, + *entity, + Mark { + name: super::workspace_agents::agent_display_name(&write.vendor, &write.client), + session: &write.session, + at: &write.at, + through: THROUGH_KIN, + kin_session: None, + }, + ); + } + } + for edit in edits.iter().filter(|edit| match &edit.change_id { + Some(change_id) => walked(change_id), + None => working && edit.base == guide.change.base, + }) { + for entity in &edit.entities { + keep( + &mut newest, + *entity, + Mark { + name: super::workspace_agents::agent_display_name(&edit.client, &edit.client), + session: &edit.session, + at: &edit.at, + through: THROUGH_CLIENT, + kin_session: edit.kin_session.as_deref(), + }, + ); + } + } + let mut authors: Vec<(GuideAuthor, String)> = Vec::new(); + for step in guide + .chapters + .iter_mut() + .flat_map(|chapter| chapter.steps.iter_mut()) + { + step.authored_by.clear(); + let Some(by_session) = uuid::Uuid::parse_str(&step.entity_id) + .ok() + .and_then(|id| newest.get(&EntityId(id))) + else { + continue; + }; + let mut marks: Vec<&Mark<'_>> = by_session.values().collect(); + marks.sort_by(|left, right| { + right + .at + .cmp(left.at) + .then_with(|| left.session.cmp(right.session)) + .then_with(|| left.through.cmp(right.through)) + }); + for mark in marks { + step.authored_by.push(GuideAuthorship { + name: mark.name.clone(), + session: mark.session.to_string(), + at: mark.at.to_string(), + through: mark.through.to_string(), + kin_session: mark.kin_session.map(str::to_string), + }); + match authors.iter_mut().find(|(author, _)| { + author.session == mark.session && author.through == mark.through + }) { + Some((author, latest)) => { + author.entities += 1; + if mark.at > latest.as_str() { + *latest = mark.at.to_string(); + } + } + None => authors.push(( + GuideAuthor { + name: mark.name.clone(), + session: mark.session.to_string(), + entities: 1, + through: mark.through.to_string(), + kin_session: mark.kin_session.map(str::to_string), + }, + mark.at.to_string(), + )), + } + } + } + authors.sort_by(|(left, left_at), (right, right_at)| { + right_at + .cmp(left_at) + .then_with(|| left.session.cmp(&right.session)) + .then_with(|| left.through.cmp(&right.through)) + }); + guide.summary.authors = authors.into_iter().map(|(author, _)| author).collect(); } /// What one changed entity's change asks of the recorded consumers that reach @@ -472,6 +780,10 @@ pub struct GuideBody { pub state: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub text: Option, + /// The exact content address of this entity's span, for a source-guarded + /// restore. Unlike semantic fingerprints, it includes comments and spacing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body_hash: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub start_line: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -496,6 +808,10 @@ pub struct GuideConnections { pub consumers_changed_here: usize, pub tests: Vec, pub tests_total: usize, + /// Tests are counted within this many recorded impact hops. Enrichment + /// limits remain qualified by the guide's completeness reading. + #[serde(default = "test_reach_depth")] + pub tests_depth: u32, /// Modules whose only recorded reach is importing this entity. Listed /// apart, because the functions inside them that use it are the consumers. #[serde(default)] @@ -519,12 +835,37 @@ pub struct GuideConnection { pub entity: GuideEntityRef, /// Whether this change also changed the connected entity. pub changed_here: bool, + /// How many recorded impact edges connect this entity to the step. + #[serde(default = "direct_hop", skip_serializing_if = "is_direct_hop")] + pub hops: u32, + /// How many of those edges are calls, or recorded `tests` and `covers` + /// relations. Equal to `hops` when a test reaches the step by calls alone, + /// so running it runs the step. Set on tests only. + #[serde(default, skip_serializing_if = "is_zero_hops")] + pub call_hops: u32, /// Other declarations of the same callable this row stands for: the /// `@overload` stubs, prototypes or ambient declarations its language /// adapter says only declare what `entity` defines. A reader sees one /// callable, so the list shows one row. #[serde(default, skip_serializing_if = "is_zero")] pub overloads: usize, + /// A test's latest result for the work as it is now. Set on the tests of + /// a guide of uncommitted work only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +fn direct_hop() -> u32 { + 1 +} +fn is_direct_hop(hops: &u32) -> bool { + *hops == 1 +} +fn is_zero_hops(hops: &u32) -> bool { + *hops == 0 +} +fn test_reach_depth() -> u32 { + super::verify::WORK_TEST_REACH_DEPTH } fn is_zero(count: &usize) -> bool { @@ -1071,6 +1412,9 @@ struct Reacher { entity: Entity, changed_here: bool, is_test: bool, + hops: u32, + /// How many of `hops` are calls; see [`GuideConnection::call_hops`]. + call_hops: u32, /// The entity a reader knows it by; see [`canonical_callable`]. canonical: EntityId, overloads: usize, @@ -1125,11 +1469,15 @@ fn field_changes(old: &Entity, new: &Entity) -> Vec { ); compare("role", Some(label(old.role)), Some(label(new.role))); compare("file", entity_file(old), entity_file(new)); - compare( - "doc_summary", - old.doc_summary.clone(), - new.doc_summary.clone(), - ); + // Documentation read again in another form says the same thing, so it is + // not a field that moved; see `kin_review::documentation_agrees`. + if !kin_review::documentation_agrees(old.doc_summary.as_deref(), new.doc_summary.as_deref()) { + compare( + "doc_summary", + old.doc_summary.clone(), + new.doc_summary.clone(), + ); + } if old.fingerprint.behavior_hash != new.fingerprint.behavior_hash { changes.push(GuideFieldChange { field: "body".to_string(), @@ -1164,6 +1512,7 @@ fn read_body( match read_entity_source_exact_at(held, entity, BODY_MAX_BYTES, scope) { Ok(Some(source)) => GuideBody { state: "recorded".to_string(), + body_hash: Some(kin_blobs::digest(source.body.as_bytes()).to_string()), text: Some(source.body), start_line: entity_presentation_start_line(entity), change_id, @@ -1177,6 +1526,7 @@ fn read_body( Ok(None) => GuideBody { state: "no_source".to_string(), text: None, + body_hash: None, start_line: None, change_id, working, @@ -1186,6 +1536,7 @@ fn read_body( Err(error) => GuideBody { state: "unavailable".to_string(), text: None, + body_hash: None, start_line: None, change_id, working, @@ -1562,6 +1913,7 @@ pub(crate) fn entity_source_at( body: GuideBody { state: "unavailable".to_string(), text: None, + body_hash: None, start_line: None, change_id: None, working: false, @@ -1623,6 +1975,20 @@ fn is_import(kind: RelationKind) -> bool { matches!(kind, RelationKind::Imports | RelationKind::Includes) } +/// Prefer the specific recorded use when several relations join one caller +/// to the same entity. Generic references and type uses do not hide calls. +pub(super) fn connection_precedence(kind: RelationKind) -> u8 { + match kind { + RelationKind::Tests | RelationKind::Covers => 0, + RelationKind::Calls => 1, + RelationKind::Instantiates | RelationKind::Spawns => 2, + RelationKind::UsesType => 4, + RelationKind::References => 5, + RelationKind::Imports | RelationKind::Includes => 6, + _ => 3, + } +} + /// Whether `relation` ties its two ends into one chapter. /// /// Wider than an impact relation on purpose. A class and the method it @@ -1641,7 +2007,10 @@ fn connection(relation: &Relation, entity: &Entity, changed_here: bool) -> Guide recorded_in: relation.created_in.map(|change| change.to_string()), entity: entity_ref(entity), changed_here, + hops: 1, + call_hops: 0, overloads: 0, + result: None, } } @@ -1656,6 +2025,89 @@ fn sort_connections(connections: &mut Vec) { }); } +/// Tell apart listed rows that share a name and that nothing contains, as +/// test modules of one name in different packages are: each is placed in the +/// fewest enclosing namespace names, read from where it is declared, that no +/// other row of that name shares, such as `classic.tests` beside +/// `mini.tests`. A row a container already names keeps that name. +fn place_repeated_modules(rows: &mut [GuideConnection]) { + let mut named: HashMap = HashMap::new(); + for row in rows.iter() { + *named.entry(row.entity.name.clone()).or_default() += 1; + } + let namespaces = + |row: &GuideConnection| enclosing_namespaces(row.entity.file.as_deref(), false); + let placed: Vec> = rows + .iter() + .map(|row| { + if row.entity.within.is_some() || named[&row.entity.name] < 2 { + return None; + } + let others: Vec> = rows + .iter() + .filter(|other| { + other.entity.id != row.entity.id && other.entity.name == row.entity.name + }) + .map(namespaces) + .collect(); + distinct_place(&namespaces(row), &others).map(|place| format!("namespace {place}")) + }) + .collect(); + for (row, place) in rows.iter_mut().zip(placed) { + if place.is_some() { + row.entity.within = place; + } + } +} + +/// The namespaces enclosing what is declared at `file`, outermost first: its +/// directories, and with `with_module` the module the file itself is, named +/// as its module entity is, without the last extension. +pub(super) fn enclosing_namespaces(file: Option<&str>, with_module: bool) -> Vec { + let file = file.unwrap_or_default(); + let (dir, leaf) = file.rsplit_once('/').unwrap_or(("", file)); + let mut names: Vec = dir + .split('/') + .filter(|segment| !segment.is_empty()) + .map(str::to_string) + .collect(); + if with_module { + let module = leaf.rsplit_once('.').map_or(leaf, |(stem, _)| stem); + if !module.is_empty() { + names.push(module.to_string()); + } + } + names +} + +/// The fewest innermost names of `own` that none of `others` ends with, +/// joined as one dotted name, or `None` when even all of them are shared. +pub(super) fn distinct_place(own: &[String], others: &[Vec]) -> Option { + (1..=own.len()).find_map(|taken| { + let tail = &own[own.len() - taken..]; + others + .iter() + .all(|other| !other.ends_with(tail)) + .then(|| tail.join(".")) + }) +} + +/// Tests in the order a reader weighs them: one this change also changed +/// first, then the nearest, then at one distance the ones that reach by the +/// most calls, as [`kin_review::test_reach_order`] ranks a walk's tests. +fn sort_tests(tests: &mut [GuideConnection]) { + tests.sort_by(|a, b| { + b.changed_here + .cmp(&a.changed_here) + .then_with(|| a.hops.cmp(&b.hops)) + .then_with(|| b.call_hops.cmp(&a.call_hops)) + .then_with(|| a.entity.name.cmp(&b.entity.name)) + .then_with(|| a.entity.parameters.cmp(&b.entity.parameters)) + .then_with(|| a.entity.file.cmp(&b.entity.file)) + .then_with(|| a.entity.id.cmp(&b.entity.id)) + }); +} + /// Order one chapter so what others rely on is read first. /// /// `depends` holds `(dependent, dependency)` pairs among the chapter's own @@ -1724,31 +2176,90 @@ fn find_root(parents: &mut HashMap, id: EntityId) -> EntityI root } +/// What the review's enrichment observation says about the counts, split by +/// whether waiting can change it. +/// +/// The observation counts every entity whose call sites are not settled, and +/// most of those, on a store whose sweep has finished, are sites a resolver +/// already answered without settling: a call into a dependency nothing pins, +/// a call through a value binding. Reporting all of them as pending told a +/// reader Kin was still mapping hundreds of entities on a store that had +/// nothing queued, and the number never moved. Only owed work is pending, and +/// the rest is said for what it is. fn completeness(review: &kin_review::Review) -> GuideCompleteness { - match review.impact.enrichment.as_ref() { - Some(observation) if observation.bounds_answer() => GuideCompleteness { - state: "not_settled".to_string(), - message: format!( - "Not settled: {} entities pending enrichment. Impact counts are lower bounds and \ - risk may change.", - observation.total_pending_entities - ), - pending_entities: Some(observation.total_pending_entities as u64), - }, - Some(observation) if observation.status == "no_recorded_call_site_debt" => { - GuideCompleteness { - state: "no_recorded_debt".to_string(), - message: "No recorded call-site debt. Overall completeness is unverified." - .to_string(), - pending_entities: None, - } - } - _ => GuideCompleteness { + completeness_of(review.impact.enrichment.as_ref()) +} + +fn completeness_of( + observation: Option<&kin_review::enrichment::EnrichmentObservation>, +) -> GuideCompleteness { + let Some(observation) = observation.filter(|observation| { + observation.bounds_answer() || observation.status == "no_recorded_call_site_debt" + }) else { + return GuideCompleteness { state: "unavailable".to_string(), message: "Completeness unavailable. This review does not establish a clear result." .to_string(), pending_entities: None, - }, + unresolved_entities: None, + }; + }; + let owed = if observation.bounds_answer() { + observation.owed_entities + } else { + 0 + }; + let unresolved = if observation.bounds_answer() { + observation.total_pending_entities.saturating_sub(owed) + } else { + 0 + }; + let unresolved_sentence = (unresolved > 0).then(|| { + format!( + "{} {} call sites that remain unproven, so impact counts may miss callers through \ + them. No enrichment is currently owed for {}.", + entity_count(unresolved), + if unresolved == 1 { "holds" } else { "hold" }, + if unresolved == 1 { "it" } else { "them" }, + ) + }); + if owed > 0 { + let mut message = format!( + "Not settled: {} pending enrichment. Impact counts are lower bounds and risk may \ + change.", + entity_count(owed) + ); + if let Some(sentence) = &unresolved_sentence { + message.push(' '); + message.push_str(sentence); + } + return GuideCompleteness { + state: "not_settled".to_string(), + message, + pending_entities: Some(owed as u64), + unresolved_entities: (unresolved > 0).then_some(unresolved as u64), + }; + } + let message = match unresolved_sentence { + Some(sentence) => format!("Settled: no entity is waiting on enrichment. {sentence}"), + None => "Settled: no entity is waiting on enrichment and no call site is left \ + unsettled. Overall completeness is unverified." + .to_string(), + }; + GuideCompleteness { + state: "settled".to_string(), + message, + pending_entities: None, + unresolved_entities: (unresolved > 0).then_some(unresolved as u64), + } +} + +/// `1 entity` or `N entities`. +fn entity_count(count: usize) -> String { + if count == 1 { + "1 entity".to_string() + } else { + format!("{count} entities") } } @@ -1973,18 +2484,26 @@ fn step_narration( } if !draft.is_test() { match wiring.tests.len() { - 0 => text.push_str(if gone { - " No test reached it." - } else { - " No test reaches it." - }), + 0 => write!( + text, + " No test {} it within {} recorded hops.", + if gone { "reached" } else { "reaches" }, + test_reach_depth() + ) + .unwrap(), tests => { let reach = match (gone, tests) { (true, _) => "reached", (false, 1) => "reaches", (false, _) => "reach", }; - write!(text, " {} {reach} it.", plural(tests, "test", "tests")).unwrap(); + write!( + text, + " {} {reach} it within {} recorded hops.", + plural(tests, "test", "tests"), + test_reach_depth() + ) + .unwrap(); } } } @@ -2422,6 +2941,9 @@ fn derive_working_guide( // of this entity, here as in a recorded change. if !kin_review::is_semantic_modification(&old, &new) { semantic.provenance_only_entity_changes += 1; + if kin_review::is_documentation_reread(&old, &new) { + semantic.documentation_rereads.push(new.id); + } continue; } (new.id, EntityChangeKind::Modified { old, new }) @@ -2449,8 +2971,19 @@ fn derive_working_guide( }, }); } - let review = - kin_review::SemanticReview::review_from_diff_with_source(semantic, graph, escape_evidence)?; + // Live work, so a caller with no ledger reads the way impact and the + // store-wide status read it: unproven for want of a resolver on this host, + // not enrichment still owed. Without this, a language no resolver serves + // kept the guide saying its entities were still being mapped, forever. + let no_resolver = kin_mcp::call_sites::host_no_resolver(); + let review = kin_review::SemanticReview::review_from_diff_with_sources( + semantic, + graph, + kin_review::enrichment::ObservationSources { + escape_evidence, + no_resolver: Some(&no_resolver), + }, + )?; let mut message = String::from(if working_tree.differs { "Uncommitted work\n\nChanges you haven't committed yet." @@ -2504,6 +3037,30 @@ fn derive_working_guide( Ok(guide) } +/// How many changes `base` holds that the range's head does not. +/// +/// Walked back from `base`, stopping at any change in `shared`, the merge +/// base's ancestry. With a single merge base that ancestry holds every change +/// the two sides have in common, so the walk reads only the base's own line +/// since the two parted and never the history they share. +fn base_only_changes( + graph: &kin_db::InMemoryGraph, + base: &SemanticChangeId, + shared: &HashSet, +) -> Result { + let mut seen: HashSet = HashSet::new(); + let mut pending = vec![*base]; + while let Some(id) = pending.pop() { + if shared.contains(&id) || !seen.insert(id) { + continue; + } + if let Some(change) = graph.get_change(&id)? { + pending.extend(change.parents.iter().copied()); + } + } + Ok(seen.len()) +} + /// Build the guide for a range: everything `head` reaches that `base` does /// not, read from the change the two share. /// @@ -2558,6 +3115,7 @@ pub(crate) fn build_range_guide( members.sort_unstable(); let members_complete = members.len() <= RANGE_MEMBERS_MAX; members.truncate(RANGE_MEMBERS_MAX); + let base_changes_total = base_only_changes(graph, &base, &base_ancestry)?; let empty = || { let diff = kin_review::SemanticDiff { @@ -2641,6 +3199,7 @@ pub(crate) fn build_range_guide( head_ref: head_label.to_string(), merge_base: merge_base.to_string(), changes_total, + base_changes_total, members, members_complete, changes: listed @@ -2874,6 +3433,13 @@ pub(crate) fn entity_history( } None => Vec::new(), }; + let record_start = match own.first() { + Some(oldest) => graph + .get_change(&oldest.introduced_by)? + .filter(|change| change.parents.is_empty()) + .map(|change| change.id.to_string()), + None => None, + }; Ok(GuideHistory { entity_id: entity_id.to_string(), revisions: listed, @@ -2881,6 +3447,7 @@ pub(crate) fn entity_history( unchanged_revisions: unchanged.len(), removed_by, in_range, + record_start, }) } @@ -3044,6 +3611,17 @@ fn assemble( through_members.truncate(THROUGH_MEMBERS_LISTED_MAX); drafts.retain(|draft| !through.contains(&draft.id)); + // Entities whose documentation was only read again: named, never walked. + let documentation_rereads = review.diff.documentation_rereads.len(); + let mut documentation_reread_names: Vec = Vec::new(); + for id in &review.diff.documentation_rereads { + if let Some(entity) = at_head.get_entity(id)? { + documentation_reread_names.push(entity.name); + } + } + documentation_reread_names.sort(); + documentation_reread_names.truncate(THROUGH_MEMBERS_LISTED_MAX); + let (mut added, mut modified, mut removed) = (0, 0, 0); for draft in &drafts { match draft.change { @@ -3118,12 +3696,46 @@ fn assemble( let entry = wiring.entry(draft.id).or_default(); let mut seen: HashSet = HashSet::new(); let mut reachers: Vec = Vec::new(); - // One row per source, and a source that both imports and calls is - // shown by its call, so the use is read before the import. + // A test may reach this work through unchanged consumers. Read the + // same recorded impact walk as Code and verification, on the exact + // side this guide describes; names and today's graph cannot fill a + // historical gap. The walk decides what is a test, so a direct + // source it lists as one is left to it below. + // A step the side cannot hold, such as one only a merge's other + // parent declares, has no walk; its direct tests are read below. + let held_here = match base_side { + Some(at_base) => at_base.get_entity(&draft.id)?, + None => at_head.get_entity(&draft.id)?, + }; + let ranked = match (held_here, base_side) { + (None, _) => None, + (Some(_), Some(at_base)) => Some(kin_review::rank_impact_at( + at_base, + &draft.id, + test_reach_depth(), + )?), + (Some(_), None) => Some(kin_review::rank_impact_at( + at_head, + &draft.id, + test_reach_depth(), + )?), + }; + // Every piece of test code the walk read, and the tests it counts + // them as: a direct source among them is the walk's to name. + let ranked_tests: HashSet = ranked + .iter() + .flat_map(|ranked| { + ranked + .test_candidates() + .map(|candidate| candidate.entity_id) + .chain(ranked.tests.iter().map(|test| test.entity_id)) + }) + .collect(); + // One row per source, named by its most specific recorded use. let mut inbound = kin_review::inbound_impact_relations(&all, &draft.id); inbound.sort_by(|a, b| { - is_import(a.kind) - .cmp(&is_import(b.kind)) + connection_precedence(a.kind) + .cmp(&connection_precedence(b.kind)) .then_with(|| a.id.cmp(&b.id)) }); for relation in inbound { @@ -3150,14 +3762,95 @@ fn assemble( entry.importing_modules.push(entity_ref(source_entity)); continue; } - let is_test = is_test_entity(source_entity) - || matches!(relation.kind, RelationKind::Tests | RelationKind::Covers); + if ranked_tests.contains(&source) { + continue; + } + // A test the walk cannot see from this side, such as one this + // change removed, is still read as the test it directly is, and + // test code that is not a test as the test that holds it. + let declared = matches!(relation.kind, RelationKind::Tests | RelationKind::Covers); + let mut entity = source_entity.clone(); + let is_test = declared || is_test_entity(source_entity); + if is_test && !declared && !kin_review::is_test_unit(source_entity) { + let holding = match base_side { + Some(at_base) => kin_review::test_unit_holding(at_base, source_entity)?, + None => kin_review::test_unit_holding(at_head, source_entity)?, + }; + let Some(holding) = holding else { + continue; + }; + if ranked_tests.contains(&holding.id) || !seen.insert(holding.id) { + continue; + } + entity = holding; + } reachers.push(Reacher { relation: relation.clone(), - entity: source_entity.clone(), + canonical: entity.id, + entity, changed_here, is_test, - canonical: source, + hops: 1, + call_hops: u32::from(kin_review::is_call_route(relation.kind)), + overloads: 0, + labelled: false, + }); + } + let test_code_walked: HashMap = ranked + .iter() + .flat_map(|ranked| ranked.test_candidates()) + .map(|candidate| (candidate.entity_id, candidate)) + .collect(); + for test in ranked.iter().flat_map(|ranked| ranked.tests.iter()) { + let Some(candidate) = test_code_walked.get(&test.via) else { + continue; + }; + // The test code the walk reached, which names the use below; the + // row is the test that holds it. + let source = candidate.entity_id; + let entity = match base_side { + Some(at_base) => at_base.get_entity(&test.entity_id)?, + None => at_head.get_entity(&test.entity_id)?, + }; + let Some(entity) = entity else { + continue; + }; + let Some(edge) = candidate.path.last() else { + continue; + }; + let relations = match base_side { + Some(at_base) => at_base.get_all_relations_for_entity(&source)?, + None => at_head.get_all_relations_for_entity(&source)?, + }; + // Named like a consumer row and Code's relied-on-by row: by the + // most specific recorded use into the entity it reaches, not by + // whichever edge scored highest in the walk. + let Some(relation) = + kin_review::inbound_impact_relations(&relations, &edge.to_entity_id) + .into_iter() + .filter(|relation| relation.src.as_entity() == Some(source)) + .min_by(|a, b| { + connection_precedence(a.kind) + .cmp(&connection_precedence(b.kind)) + .then_with(|| a.id.cmp(&b.id)) + }) + .or_else(|| { + relations + .iter() + .find(|relation| relation.id == edge.relation_id) + .cloned() + }) + else { + continue; + }; + reachers.push(Reacher { + relation, + changed_here: changed.contains(&entity.id), + canonical: entity.id, + entity, + is_test: true, + hops: test.hop, + call_hops: test.call_hops, overloads: 0, labelled: false, }); @@ -3169,6 +3862,10 @@ fn assemble( for reacher in fold_declarations(reachers, &canonical)? { let mut row = connection(&reacher.relation, &reacher.entity, reacher.changed_here); row.overloads = reacher.overloads; + row.hops = reacher.hops; + if reacher.is_test { + row.call_hops = reacher.call_hops; + } if reacher.labelled { row.entity.parameters = parameters_of(&reacher.entity.signature); } @@ -3409,11 +4106,37 @@ fn assemble( let mut not_updated: BTreeSet = BTreeSet::new(); let mut may_need_review: BTreeSet = BTreeSet::new(); let mut need_no_update: BTreeSet = BTreeSet::new(); + // Each entity's fingerprint as the graph holds it now, read once for + // every test result that is checked against it. + let mut fingerprints_now: HashMap> = HashMap::new(); let mut build_step = |id: &EntityId| -> Result { let draft = by_id[id]; let mut entry = wiring.remove(id).unwrap_or_default(); sort_connections(&mut entry.consumers); - sort_connections(&mut entry.tests); + sort_tests(&mut entry.tests); + // Uncommitted work is the graph as it is now, so each test's latest + // recorded result can be read against it. Every test is read, before + // the list is cut to the ones shown, so the tally covers them all. + let tests_result = if kind == "working" { + let subject = draft.new.map(|entity| entity.id); + let mut tally = GuideTestsResult::default(); + for row in entry.tests.iter_mut() { + let Ok(uuid) = uuid::Uuid::parse_str(&row.entity.id) else { + continue; + }; + let reading = super::verify::test_result( + graph, + &EntityId(uuid), + subject.as_ref(), + &mut fingerprints_now, + )?; + tally.count(reading.state); + row.result = Some(reading); + } + Some(tally) + } else { + None + }; let fields = match (draft.old, draft.new) { (Some(old), Some(new)) => field_changes(old, new), _ => Vec::new(), @@ -3512,6 +4235,7 @@ fn assemble( row.entity.within = within(&EntityId(uuid))?; } } + place_repeated_modules(&mut entry.tests); let step_within = within(id)?; entry .importing_modules @@ -3549,6 +4273,7 @@ fn assemble( consumers_changed_here, tests: entry.tests, tests_total, + tests_depth: test_reach_depth(), importing_modules: entry.importing_modules, importing_modules_total, relation_changes, @@ -3556,12 +4281,14 @@ fn assemble( }, consumer_verdict: Some(verdict), findings, + authored_by: Vec::new(), evidence: GuideEvidence { base_change: base.map(|base| base.to_string()), head_change: head_label.clone(), fingerprint_before: draft.old.map(fingerprint), fingerprint_after: draft.new.map(fingerprint), }, + tests_result, }) }; @@ -3715,14 +4442,18 @@ fn assemble( module_containers, containers_through_members, through_members, + documentation_rereads, + documentation_reread_names, recorded_consumers: outside_consumers.len(), recorded_tests: outside_tests.len(), + tests_depth: test_reach_depth(), consumers_not_updated: consumer_verdicts.not_updated, consumers_may_need_review: consumer_verdicts.may_need_review, consumers_need_no_update: consumer_verdicts.need_no_update, finding_count, completeness: completeness(&review), narration, + authors: Vec::new(), }, chapters, findings: GuideFindings { @@ -3793,6 +4524,29 @@ fn through_members_sentence(summary: &GuideSummary) -> Option { )) } +/// The sentence that names the entities whose documentation was only read +/// again, or nothing when there are none. +fn documentation_rereads_sentence(summary: &GuideSummary) -> Option { + let count = summary.documentation_rereads; + if count == 0 { + return None; + } + let mut names: Vec = summary + .documentation_reread_names + .iter() + .map(|name| format!("`{name}`")) + .collect(); + if count > names.len() { + names.push(plural(count - names.len(), "other", "others")); + } + Some(format!( + "Kin re-read the documentation of {}; {} code is identical, so {} not walked.", + join_and(&names), + if count == 1 { "its" } else { "their" }, + if count == 1 { "it is" } else { "they are" } + )) +} + /// What a guide of this kind calls the thing it walks. fn subject_word(kind: &str) -> &'static str { match kind { @@ -3943,7 +4697,10 @@ fn summary_narration( // them, then the tests, so each sentence names what the one before it did. let mut sentences = Vec::new(); if consumers == 0 && tests == 0 { - sentences.push(format!("Nothing outside the {subject} reaches {them}.")); + sentences.push(format!( + "No direct outside consumer or test within {} recorded hops reaches {them}.", + test_reach_depth() + )); } if consumers > 0 { sentences.push(format!( @@ -3955,11 +4712,15 @@ fn summary_narration( } if tests > 0 { let reach = if tests == 1 { "reaches" } else { "reach" }; + let depth = test_reach_depth(); sentences.push(if consumers > 0 { - format!("{} {reach} {them}.", plural(tests, "test", "tests")) + format!( + "{} {reach} {them} within {depth} recorded hops.", + plural(tests, "test", "tests") + ) } else { format!( - "{} outside the {subject} {reach} {them}.", + "{} outside the {subject} {reach} {them} within {depth} recorded hops.", plural(tests, "test", "tests") ) }); @@ -4112,6 +4873,9 @@ pub(crate) fn guide_text(guide: &ReviewGuide) -> String { if let Some(line) = through_members_sentence(summary) { writeln!(text, "{line}").unwrap(); } + if let Some(line) = documentation_rereads_sentence(summary) { + writeln!(text, "{line}").unwrap(); + } if summary.relation_changes_settling > 0 { writeln!( text, @@ -4183,6 +4947,20 @@ pub(crate) fn guide_text(guide: &ReviewGuide) -> String { ) .unwrap(); writeln!(text, " {}", step.narration).unwrap(); + for (through, said) in [ + (THROUGH_KIN, "Written through Kin by"), + (THROUGH_CLIENT, "Edited with its own tools by"), + ] { + let names: Vec<&str> = step + .authored_by + .iter() + .filter(|author| author.through == through) + .map(|author| author.name.as_str()) + .collect(); + if !names.is_empty() { + writeln!(text, " {said} {}", join_and(&names)).unwrap(); + } + } for field in &step.fields { if let (Some(before), Some(after)) = (&field.before, &field.after) { if field.field != "doc_summary" { @@ -4489,6 +5267,11 @@ fn write_connections(text: &mut String, heading: &str, rows: &[GuideConnection], Some(within) => format!("in {within} "), None => String::new(), }; + let hops = if row.hops > 1 { + format!("{} recorded hops ", row.hops) + } else { + String::new() + }; let overloads = match row.overloads { 0 => String::new(), count => format!( @@ -4498,7 +5281,7 @@ fn write_connections(text: &mut String, heading: &str, rows: &[GuideConnection], }; writeln!( text, - " {:<12} {}{} {place}{overloads}[{}{}]", + " {:<12} {}{} {place}{hops}{overloads}[{}{}]", row.relation.replace('_', " "), row.entity.name, row.entity.parameters.as_deref().unwrap_or_default(), @@ -4763,15 +5546,544 @@ mod tests { .unwrap() } - /// The change the guide exists to show well: one function's body moves, - /// and the three callers and the test that reach it do not. + /// An entity whose oldest revision is the change a store began with says + /// so: in a store that holds only part of the repository's history, that + /// is where Kin's record of it starts, and the entity page says it the way + /// the repository's history does. An entity a later change created starts + /// nowhere special. #[test] - fn one_changed_entity_reads_with_the_callers_that_did_not_change() { + fn an_entity_history_names_the_change_the_store_began_with() { let graph = kin_db::InMemoryGraph::new(); - let list_run = function("listRun", "src/runs/listRun.ts", 1); - let route = function("handleRunsRequest", "src/api/runsRoute.ts", 2); - let panel = function("renderRunsPanel", "src/ui/runsPanel.ts", 3); - let report = function("buildNightlyReport", "src/jobs/nightlyReport.ts", 4); + let kept = function("serialize", "lib/reply.js", 1); + let born = function("serializeLater", "lib/reply.js", 2); + let import = commit(&graph, vec![], "Import", added(&[&kept]), vec![]); + let mut edited = kept.clone(); + edited.fingerprint.behavior_hash = kin_model::Hash256::from_bytes([0xab; 32]); + let mut deltas = added(&[&born]); + deltas.push(EntityDelta::Modified { + old: kept.clone(), + new: edited, + }); + let head = commit(&graph, vec![import], "Edit and add", deltas, vec![]); + let dir = tempfile::tempdir().unwrap(); + let layout = kin_core::KinLayout::new(dir.path().to_path_buf()); + let read = |entity: &Entity| { + entity_history( + &binding(&layout), + &graph, + &entity.id.to_string(), + Some(&head.to_string()), + None, + ) + .unwrap() + }; + let history = read(&kept); + assert_eq!(history.revisions_total, 2); + assert_eq!(history.record_start, Some(import.to_string())); + assert_eq!(read(&born).record_start, None); + } + + #[test] + fn a_recorded_call_names_a_consumer_before_type_uses_or_references() { + for call_has_smallest_id in [false, true] { + let graph = kin_db::InMemoryGraph::new(); + let callee = function("normalizeRequestId", "lib/request-id.js", 1); + let caller = function("buildOptionalHeaderReqId", "lib/request-id.js", 2); + let mut call = linked(RelationOrigin::Parsed, &caller, &callee); + let mut type_use = linked(RelationOrigin::Lsp, &caller, &callee); + let mut reference = linked(RelationOrigin::Lsp, &caller, &callee); + type_use.kind = RelationKind::UsesType; + reference.kind = RelationKind::References; + let id = |value| kin_model::RelationId(uuid::Uuid::from_u128(value)); + call.id = id(if call_has_smallest_id { 1 } else { 3 }); + type_use.id = id(if call_has_smallest_id { 2 } else { 1 }); + reference.id = id(if call_has_smallest_id { 3 } else { 2 }); + let call_id = call.id.to_string(); + let root = commit( + &graph, + vec![], + "Add the request id helper and its caller", + added(&[&callee, &caller]), + vec![reference, type_use, call] + .into_iter() + .map(|new| RelationDelta::Added { new }) + .collect(), + ); + let mut changed = callee.clone(); + changed.fingerprint.behavior_hash = kin_model::Hash256::from_bytes([4; 32]); + changed.fingerprint.ast_hash = kin_model::Hash256::from_bytes([4; 32]); + let head = commit( + &graph, + vec![root], + "Bound the normalized request id", + vec![EntityDelta::Modified { + old: callee.clone(), + new: changed, + }], + vec![], + ); + + let guide = guide_of(&graph, head); + let step = guide + .chapters + .iter() + .flat_map(|chapter| &chapter.steps) + .find(|step| step.entity_id == callee.id.to_string()) + .unwrap(); + assert_eq!(step.connections.consumers_total, 1); + assert_eq!(step.connections.consumers.len(), 1); + let consumer = &step.connections.consumers[0]; + assert_eq!(consumer.entity.id, caller.id.to_string()); + assert_eq!(consumer.relation, "calls"); + assert_eq!(consumer.relation_id, call_id); + } + } + + fn test_code(name: &str, file: &str, kind: EntityKind, body: u8) -> Entity { + let mut entity = function(name, file, body); + entity.kind = kind; + entity.role = EntityRole::Test; + entity + } + + /// Test reach counts tests, never every piece of code inside a test + /// module: a constant and an interface a test module declares are counted + /// as the module that holds them, once. The nearest come first and, at one + /// distance, the ones that reach by calls before those that only name a + /// type; two test modules of one name say where each sits; and the guide + /// and Code read one list. + #[test] + fn test_reach_counts_tests_nearest_and_by_calls_first() { + let graph = kin_db::InMemoryGraph::new(); + let zod = "packages/zod/src/v4"; + let focal = function("_toLowerCase", &format!("{zod}/core/api.ts"), 1); + let string_class = function("_ZodString", &format!("{zod}/classic/schemas.ts"), 2); + let mut checks = function("checks", &format!("{zod}/classic/checks.ts"), 3); + checks.kind = EntityKind::Module; + let module = EntityKind::Module; + let classic_index = test_code( + "index.test", + &format!("{zod}/classic/tests/index.test.ts"), + module, + 4, + ); + let mini_index = test_code( + "index.test", + &format!("{zod}/mini/tests/index.test.ts"), + module, + 5, + ); + let string_test = test_code( + "string.test", + &format!("{zod}/classic/tests/string.test.ts"), + module, + 6, + ); + let utils = format!("{zod}/classic/tests/error-utils.test.ts"); + let error_utils = test_code("error-utils.test", &utils, module, 7); + let fixture = test_code("Test", &utils, EntityKind::Constant, 8); + let shape = test_code("Shape", &utils, EntityKind::Interface, 9); + let root = commit( + &graph, + vec![], + "zod baseline", + added(&[ + &focal, + &string_class, + &checks, + &classic_index, + &mini_index, + &string_test, + &error_utils, + &fixture, + &shape, + ]), + vec![ + calls(&string_class, &focal), + related(RelationKind::References, &checks, &focal), + calls(&string_test, &string_class), + related(RelationKind::UsesType, &classic_index, &checks), + related(RelationKind::UsesType, &mini_index, &checks), + related(RelationKind::UsesType, &fixture, &checks), + related(RelationKind::UsesType, &shape, &checks), + contains(&error_utils, &fixture), + contains(&error_utils, &shape), + ], + ); + let mut new = focal.clone(); + new.fingerprint.behavior_hash = kin_model::Hash256::from_bytes([0xee; 32]); + let head = commit( + &graph, + vec![root], + "Lowercase strings with the locale's rules", + vec![EntityDelta::Modified { + old: focal.clone(), + new, + }], + vec![], + ); + + let guide = guide_of(&graph, head); + let step = guide + .chapters + .iter() + .flat_map(|chapter| &chapter.steps) + .find(|step| step.entity_id == focal.id.to_string()) + .unwrap(); + let read: Vec<(&str, Option<&str>, u32, u32)> = step + .connections + .tests + .iter() + .map(|row| { + ( + row.entity.name.as_str(), + row.entity.within.as_deref(), + row.hops, + row.call_hops, + ) + }) + .collect(); + assert_eq!( + read, + vec![ + ("string.test", None, 2, 2), + ("error-utils.test", None, 2, 0), + ("index.test", Some("namespace classic.tests"), 2, 0), + ("index.test", Some("namespace mini.tests"), 2, 0), + ] + ); + assert_eq!(step.connections.tests_total, 4); + assert_eq!(guide.summary.recorded_tests, 4); + assert_eq!(guide.summary.tests_depth, 3); + assert!( + step.narration + .ends_with("4 tests reach it within 3 recorded hops."), + "{}", + step.narration + ); + + let at_head = GraphAtRef::materialize(&graph, &head).unwrap(); + let ranked = kin_review::rank_impact_at(&at_head, &focal.id, test_reach_depth()).unwrap(); + let response: super::super::impact::ImpactResponse = + serde_json::from_value(serde_json::json!({ + "lines": [], "resolution": "resolved", "ranked": ranked, + })) + .unwrap(); + let (impact, code_tests) = + super::super::workspace_explore::impact_sections(&response).unwrap(); + assert_eq!(impact["tests"], 4); + // A test is a row of the tests section, and only there: the entity + // page listed reply.test both as a test that reaches it and, labelled + // Test, as further out. + let walked_tests = ranked + .candidates + .iter() + .filter(|candidate| matches!(candidate.bucket, kin_review::ImpactBucket::Test)) + .count(); + assert!( + walked_tests > 0, + "the walk reaches tests among its candidates" + ); + let top = impact["top"].as_array().unwrap(); + assert!(!top.is_empty()); + assert!(top.iter().all(|row| row["is_test"] == false), "{top:?}"); + assert_eq!(code_tests["total"], 4); + assert_eq!(code_tests["depth"], 3); + let items = code_tests["items"].as_array().unwrap(); + let named: Vec<(&str, Option<&str>, bool)> = items + .iter() + .map(|item| { + ( + item["name"].as_str().unwrap(), + item["module"].as_str(), + item["by_call"].as_bool().unwrap_or(false), + ) + }) + .collect(); + assert_eq!( + named, + vec![ + ("string.test", None, true), + ("error-utils.test", None, false), + ("index.test", Some("classic.tests"), false), + ("index.test", Some("mini.tests"), false), + ] + ); + } + + /// Editing one declaration re-reads the documentation of the ones beside + /// it. One whose code and comment held still is named once and never + /// walked, and the change counts one entity for the History row too. + #[test] + fn a_documentation_reread_is_named_once_and_never_walked() { + let graph = kin_db::InMemoryGraph::new(); + let focal = function("_toLowerCase", "packages/zod/src/v4/core/api.ts", 1); + let mut documented = function( + "$ZodStringBoolParams.case", + "packages/zod/src/v4/core/api.ts", + 2, + ); + documented.kind = EntityKind::Method; + documented.doc_summary = Some( + "* Options: `\"sensitive\"`, `\"insensitive\"` * * @default `\"insensitive\"` */" + .to_string(), + ); + let root = commit( + &graph, + vec![], + "zod baseline", + added(&[&focal, &documented]), + vec![], + ); + let mut edited = focal.clone(); + edited.fingerprint.behavior_hash = kin_model::Hash256::from_bytes([0xee; 32]); + let mut reread = documented.clone(); + reread.doc_summary = Some("Options: `\"sensitive\"`, `\"insensitive\"`".to_string()); + reread.span.as_mut().unwrap().start_byte += 6; + let head = commit( + &graph, + vec![root], + "Lowercase strings with the locale's rules", + vec![ + EntityDelta::Modified { + old: focal.clone(), + new: edited, + }, + EntityDelta::Modified { + old: documented.clone(), + new: reread, + }, + ], + vec![], + ); + + let guide = guide_of(&graph, head); + let steps: Vec<&str> = guide + .chapters + .iter() + .flat_map(|chapter| &chapter.steps) + .map(|step| step.name.as_str()) + .collect(); + assert_eq!(steps, vec!["_toLowerCase"]); + assert_eq!(guide.summary.modified, 1); + assert_eq!(guide.summary.documentation_rereads, 1); + assert_eq!( + guide.summary.documentation_reread_names, + vec!["$ZodStringBoolParams.case".to_string()] + ); + assert!(guide_text(&guide).contains( + "Kin re-read the documentation of `$ZodStringBoolParams.case`; its code is \ + identical, so it is not walked." + )); + let counts = change_counts(&graph, &[head.to_string()], None).unwrap(); + assert_eq!(counts.counts[&head.to_string()].entities, 1); + } + + /// Code, guided review and work verification use the same recorded + /// three-hop path, and repository coverage records its transitive reach. + #[test] + fn a_three_hop_test_reaches_code_the_guide_and_verification_consistently() { + use kin_model::{EntityStore, VerificationStore}; + let graph = kin_db::InMemoryGraph::new(); + let focal = function("normalizeRequestId", "src/request.ts", 1); + let middle = function("wrapRequestId", "src/wrapper.ts", 2); + let caller = function("requestIdFactory", "src/factory.ts", 3); + let mut test = function("req-id-gen-factory.test", "tests/factory.ts", 4); + test.kind = EntityKind::Test; + let mut farther_test = function("outsideDepth", "tests/outer.ts", 5); + farther_test.role = EntityRole::Test; + let edges = vec![ + calls(&middle, &focal), + calls(&caller, &middle), + calls(&test, &caller), + calls(&farther_test, &test), + ]; + let root = commit( + &graph, + vec![], + "request id baseline", + added(&[&focal, &middle, &caller, &test, &farther_test]), + edges.clone(), + ); + let mut new = focal.clone(); + new.fingerprint.behavior_hash = kin_model::Hash256::from_bytes([0xee; 32]); + let head = commit( + &graph, + vec![root], + "normalize request ids", + vec![EntityDelta::Modified { + old: focal.clone(), + new: new.clone(), + }], + vec![], + ); + let guide = guide_of(&graph, head); + let step = guide + .chapters + .iter() + .flat_map(|chapter| &chapter.steps) + .find(|step| step.entity_id == focal.id.to_string()) + .unwrap(); + assert_eq!(guide.summary.recorded_tests, 1); + assert_eq!( + step.connections.consumers_total, 1, + "consumers remain direct" + ); + assert_eq!(step.connections.tests_total, 1); + assert_eq!(step.connections.tests_depth, 3); + assert_eq!(step.connections.tests[0].entity.id, test.id.to_string()); + assert_eq!(step.connections.tests[0].entity.name, test.name); + assert_eq!(step.connections.tests[0].hops, 3); + assert!(step.connections.tests[0].result.is_none()); + + let at_head = GraphAtRef::materialize(&graph, &head).unwrap(); + let ranked = kin_review::rank_impact_at(&at_head, &focal.id, test_reach_depth()).unwrap(); + let response: super::super::impact::ImpactResponse = + serde_json::from_value(serde_json::json!({ + "lines": [], "resolution": "resolved", "ranked": ranked, + })) + .unwrap(); + let (_, code_tests) = super::super::workspace_explore::impact_sections(&response).unwrap(); + assert_eq!(code_tests["total"], 1); + assert_eq!(code_tests["items"][0]["id"], test.id.to_string()); + assert_eq!(code_tests["items"][0]["hops"], 3); + + for entity in [&new, &middle, &caller, &test, &farther_test] { + graph.upsert_entity(entity).unwrap(); + } + for edge in edges { + let RelationDelta::Added { new } = edge else { + unreachable!() + }; + graph.upsert_relation(&new).unwrap(); + } + let dir = tempfile::tempdir().unwrap(); + kin_core::init(dir.path()).unwrap(); + let layout = kin_core::KinLayout::discover(dir.path()).unwrap(); + let authority = crate::commands::repository_authority::RequestRepositoryAuthority::pinned( + kin_core::LocalRepositoryAuthorityBinding::from_layout(&layout).unwrap(), + ); + struct TestManifest; + impl kin_parser::project_manifest::ProjectFiles for TestManifest { + fn manifest( + &self, + name: &str, + ) -> Result>, kin_parser::project_manifest::UnreadableManifest> + { + Ok((name == "package.json") + .then(|| std::sync::Arc::from(r#"{"scripts":{"test":"node --test"}}"#))) + } + } + let planned = super::super::verify::prepare_work_verify( + &authority, + &graph, + &TestManifest, + &super::super::verify::WorkVerifyRequest::Plan(super::super::verify::WorkVerifyScope { + entities: vec![focal.id.to_string()], + change: None, + depth: 2, + }), + ) + .unwrap(); + let super::super::verify::PreparedWork::Answered(planned) = planned else { + panic!("planning runs nothing") + }; + assert_eq!(planned.tests.len(), 1); + assert_eq!( + planned.tests[0].entity_id.as_deref(), + Some(test.id.to_string().as_str()) + ); + let direct = super::super::verify::coverage_reading(&graph).unwrap(); + assert!( + !direct.missing_proof.contains(&focal.id), + "Code untested excludes a helper with recorded transitive test reach" + ); + + let now = kin_model::Timestamp::now(); + graph + .create_verification_run(&kin_model::VerificationRun { + run_id: kin_model::VerificationRunId::new(), + test_ids: vec![super::super::verify::test_id_for_entity(&test.id)], + status: kin_model::VerificationStatus::Passing, + runner: kin_model::TestRunner::Cargo, + started_at: now.clone(), + finished_at: Some(now), + duration_ms: Some(4), + evidence_blob: None, + exit_code: Some(0), + ran_against: vec![ + kin_model::RunFingerprint { + entity: focal.id, + behavior_hash: new.fingerprint.behavior_hash, + }, + kin_model::RunFingerprint { + entity: test.id, + behavior_hash: test.fingerprint.behavior_hash, + }, + ], + proof: None, + }) + .unwrap(); + let live = kin_review::impact::LiveGraph(&graph); + let mut review = kin_review::SemanticReview::review_from_diff( + kin_review::SemanticDiff { + base: Some(root), + entity_changes: vec![edit_of(&focal)], + ..Default::default() + }, + &graph, + ) + .unwrap(); + review.impact.enrichment = Some(observation(1, 0)); + let working = assemble( + &graph, + None, + Subject { + kind: "working", + review, + connections: &live, + before_at: Some(root), + after: EntitySourceScope::WorkspaceHead, + change: change_header( + &head, + Some(root), + &graph.get_change(&head).unwrap().unwrap(), + ), + head_label: WORKING.to_string(), + detail_command: "kin diff".into(), + reviews: GuideReviews::default(), + range: None, + working_tree: None, + }, + ) + .unwrap(); + let step = &working.chapters[0].steps[0]; + assert_eq!(working.summary.recorded_tests, 1); + assert_eq!(step.connections.tests[0].entity.id, test.id.to_string()); + assert_eq!(step.connections.tests[0].hops, 3); + assert_eq!( + step.connections.tests[0].result.as_ref().unwrap().state, + super::super::verify::TestResultState::Passed + ); + assert_eq!(step.tests_result.as_ref().unwrap().passed, 1); + assert_eq!(step.tests_result.as_ref().unwrap().not_run, 0); + assert_eq!(working.summary.completeness.state, "not_settled"); + assert!(working + .summary + .completeness + .message + .contains("lower bounds")); + } + + /// The change the guide exists to show well: one function's body moves, + /// and the three callers and the test that reach it do not. + #[test] + fn one_changed_entity_reads_with_the_callers_that_did_not_change() { + let graph = kin_db::InMemoryGraph::new(); + let list_run = function("listRun", "src/runs/listRun.ts", 1); + let route = function("handleRunsRequest", "src/api/runsRoute.ts", 2); + let panel = function("renderRunsPanel", "src/ui/runsPanel.ts", 3); + let report = function("buildNightlyReport", "src/jobs/nightlyReport.ts", 4); let mut test = function("testListRun", "test/listRun.test.ts", 5); test.role = EntityRole::Test; let root = commit( @@ -4856,11 +6168,18 @@ mod tests { .all(|row| { row.relation == "calls" && row.origin == "parsed" && !row.changed_here })); assert_eq!(step.connections.tests_total, 1); assert_eq!(step.connections.tests[0].entity.name, "testListRun"); + // A recorded change is not the work as it is now, so its tests carry + // no result and its JSON no result fields. + assert!(step.tests_result.is_none()); + assert!(step.connections.tests[0].result.is_none()); + let json = serde_json::to_value(step).unwrap(); + assert!(json.get("tests_result").is_none()); + assert!(json["connections"]["tests"][0].get("result").is_none()); assert_eq!( step.narration, "The function `listRun` was modified. Its body changed; its signature did not. \ 3 places rely on it, and none of them changed here. They may need review. 1 \ - test reaches it." + test reaches it within 3 recorded hops." ); assert_eq!( step.consumer_verdict, @@ -5843,6 +7162,175 @@ mod tests { .unwrap() } + fn agent_write( + change: SemanticChangeId, + vendor: &str, + at: &str, + entities: &[&Entity], + ) -> AgentWrite { + AgentWrite { + change_id: change.to_string(), + session: uuid::Uuid::new_v4().to_string(), + vendor: vendor.to_string(), + client: vendor.to_string(), + at: at.to_string(), + entities: entities.iter().map(|entity| entity.id).collect(), + } + } + + fn authors_of(guide: &ReviewGuide, name: &str) -> Vec { + let step = guide + .chapters + .iter() + .flat_map(|chapter| chapter.steps.iter()) + .find(|step| step.name == name) + .unwrap_or_else(|| panic!("a step for {name}")); + assert!(step + .authored_by + .iter() + .all(|author| author.through == THROUGH_KIN)); + step.authored_by + .iter() + .map(|author| author.name.clone()) + .collect() + } + + /// Two agents each wrote one entity through Kin. The second agent's change + /// also holds an entity it did not write, an edit that came along in the + /// same change, and that one reads as changed outside Kin. A guide of + /// uncommitted work is never marked: a write through Kin is a recorded + /// change the moment it lands. + #[test] + fn each_step_names_the_agent_that_wrote_it_through_kin_and_no_other() { + let graph = kin_db::InMemoryGraph::new(); + let ranked = function("rankRuns", "src/runs/rank.ts", 1); + let listed = function("listRuns", "src/runs/list.ts", 2); + let carried = function("formatRun", "src/runs/format.ts", 3); + let root = commit( + &graph, + vec![], + "Add runs", + added(&[&ranked, &listed, &carried]), + vec![], + ); + let edited = |entity: &Entity, body: u8| { + let mut new = entity.clone(); + new.fingerprint.ast_hash = kin_model::Hash256::from_bytes([body; 32]); + new.fingerprint.behavior_hash = kin_model::Hash256::from_bytes([body; 32]); + EntityDelta::Modified { + old: entity.clone(), + new, + } + }; + let by_codex = commit( + &graph, + vec![root], + "Rank runs by start", + vec![edited(&ranked, 21)], + vec![], + ); + let by_claude = commit( + &graph, + vec![by_codex], + "List runs newest first", + vec![edited(&listed, 22), edited(&carried, 23)], + vec![], + ); + let writes = vec![ + agent_write(by_codex, "codex", "2026-10-13T21:00:00+00:00", &[&ranked]), + agent_write( + by_claude, + "claude-code", + "2026-10-13T22:00:00+00:00", + &[&listed], + ), + // A write recorded as a change this guide does not walk. + agent_write( + SemanticChangeId::from_hash(kin_model::Hash256::from_bytes([5; 32])), + "gemini-cli", + "2026-10-13T23:00:00+00:00", + &[&carried], + ), + ]; + + let mut range = range_of(&graph, root, by_claude); + attribute_agent_writes(&mut range, &writes, &[]); + assert_eq!(authors_of(&range, "rankRuns"), vec!["Codex"]); + assert_eq!(authors_of(&range, "listRuns"), vec!["Claude Code"]); + assert!( + authors_of(&range, "formatRun").is_empty(), + "changed in an agent's change, written by no agent" + ); + assert_eq!( + range + .summary + .authors + .iter() + .map(|author| (author.name.as_str(), author.entities)) + .collect::>(), + vec![("Claude Code", 1), ("Codex", 1)], + "newest write first" + ); + assert_eq!(range.summary.authors[0].session, writes[1].session); + let text = guide_text(&range); + assert!( + text.contains("Written through Kin by Claude Code"), + "{text}" + ); + + let mut change = guide_of(&graph, by_claude); + attribute_agent_writes(&mut change, &writes, &[]); + assert_eq!(authors_of(&change, "listRuns"), vec!["Claude Code"]); + assert!(authors_of(&change, "formatRun").is_empty()); + let json = serde_json::to_value(&change).unwrap(); + let steps: Vec<&serde_json::Value> = json["chapters"] + .as_array() + .unwrap() + .iter() + .flat_map(|chapter| chapter["steps"].as_array().unwrap()) + .collect(); + let listed_step = steps + .iter() + .find(|step| step["name"] == "listRuns") + .unwrap(); + assert_eq!( + listed_step["authored_by"], + serde_json::json!([{ + "name": "Claude Code", + "session": writes[1].session, + "at": "2026-10-13T22:00:00+00:00", + "through": "kin", + }]) + ); + let carried_step = steps + .iter() + .find(|step| step["name"] == "formatRun") + .unwrap(); + assert!( + carried_step.get("authored_by").is_none(), + "nothing is said on a step no agent wrote through Kin" + ); + assert_eq!( + json["summary"]["authors"], + serde_json::json!([{ + "name": "Claude Code", + "session": writes[1].session, + "entities": 1, + "through": "kin", + }]) + ); + + let mut working = change.clone(); + working.kind = "working".to_string(); + attribute_agent_writes(&mut working, &writes, &[]); + assert!(working + .chapters + .iter() + .flat_map(|chapter| chapter.steps.iter()) + .all(|step| step.authored_by.is_empty())); + assert!(working.summary.authors.is_empty()); + } + /// A range is what a review of a branch reads: everything the head holds /// that the base does not, with "before" read where the two parted. What /// the base did on its own since then is not part of it. @@ -5908,14 +7396,33 @@ mod tests { "{text}" ); + // Main moved on by one change of its own after the branch left it, + // so the branch is two ahead and one behind, not merely ahead. + assert_eq!(range.base_changes_total, 1); + // A range that holds nothing is an answer, not a refusal. let nothing = range_of(&graph, head, head); assert_eq!(nothing.range.as_ref().unwrap().changes_total, 0); + assert_eq!( + nothing.range.as_ref().unwrap().base_changes_total, + 0, + "a branch at its base's own change is even with it" + ); assert!(nothing.chapters.is_empty()); - // A base that is ahead of the head leaves nothing either. + // A base that is ahead of the head leaves nothing either, and says + // how far ahead it is, so a branch behind does not read as even. let behind = range_of(&graph, head, first); assert_eq!(behind.range.as_ref().unwrap().changes_total, 0); + assert_eq!( + behind.range.as_ref().unwrap().base_changes_total, + 1, + "the base holds one change the branch does not" + ); assert!(behind.chapters.is_empty()); + // A branch that only adds to its base is ahead and not behind. + let ahead = range_of(&graph, root, head); + assert_eq!(ahead.range.as_ref().unwrap().changes_total, 2); + assert_eq!(ahead.range.as_ref().unwrap().base_changes_total, 0); } /// A range and its last change share a head. A review of the last change @@ -6456,8 +7963,7 @@ mod tests { ); assert_eq!( summary_narration("change", 2, 0, 0, 0, 0, 1, &counts(0, 0, 0)), - "This change added 2 entities, read here in 1 chapter. Nothing outside the change \ - reaches them." + "This change added 2 entities, read here in 1 chapter. No direct outside consumer or test within 3 recorded hops reaches them." ); assert_eq!( summary_narration("change", 0, 0, 0, 0, 0, 0, &counts(0, 0, 0)), @@ -6466,7 +7972,7 @@ mod tests { assert_eq!( summary_narration("work", 0, 1, 0, 0, 2, 1, &counts(0, 0, 0)), "This work modified 1 entity, read here in 1 chapter. 2 tests outside the work \ - reach it." + reach it within 3 recorded hops." ); assert_eq!( summary_narration("range", 0, 0, 0, 0, 0, 0, &counts(0, 0, 0)), @@ -6478,7 +7984,7 @@ mod tests { summary_narration("change", 0, 1, 0, 16, 2, 1, &counts(0, 0, 16)), "This change modified 1 entity, read here in 1 chapter. 16 places outside the \ change rely on it. None of them needs an update: what they rely on kept its \ - signature and behaves as it did. 2 tests reach it." + signature and behaves as it did. 2 tests reach it within 3 recorded hops." ); assert_eq!( summary_narration("change", 0, 1, 0, 1, 0, 1, &counts(1, 0, 0)), @@ -7086,4 +8592,139 @@ mod tests { ); assert_eq!(counted.entities, 2); } + + /// An enrichment observation as the review records it: `owed` entities + /// whose enrichment a sweep will still reach, and `answered` entities + /// holding call sites a resolver already answered without settling. + fn observation(owed: usize, answered: usize) -> kin_review::enrichment::EnrichmentObservation { + let mut pending_by_code = std::collections::BTreeMap::new(); + if owed > 0 { + pending_by_code.insert( + kin_review::enrichment::ENRICHMENT_INCOMPLETE.to_string(), + owed, + ); + } + if answered > 0 { + pending_by_code.insert("call_sites_unresolved".to_string(), answered); + } + let total = owed + answered; + kin_review::enrichment::EnrichmentObservation { + scope: "selected_graph_impact".to_string(), + selected_change: None, + status: if total > 0 { + "bounded" + } else { + "no_recorded_call_site_debt" + } + .to_string(), + basis: String::new(), + limitation: None, + total_pending_entities: total, + pending_entities: Vec::new(), + entities_withheld: total, + owed_entities: owed, + pending_by_code, + clauses: Vec::new(), + } + } + + /// The guide says entities are pending only while their enrichment is + /// owed. A converted store whose sweep had finished held 552 entities with + /// call sites the resolver had answered without settling and none owed, + /// and the guide told its reader Kin was still mapping 552 entities, a + /// number that never moved. + #[test] + fn completeness_is_settled_when_no_enrichment_is_owed() { + let answered_only = completeness_of(Some(&observation(0, 552))); + assert_eq!(answered_only.state, "settled"); + assert_eq!(answered_only.pending_entities, None); + assert_eq!(answered_only.unresolved_entities, Some(552)); + assert!( + answered_only + .message + .starts_with("Settled: no entity is waiting on enrichment."), + "{}", + answered_only.message + ); + assert!( + answered_only + .message + .contains("552 entities hold call sites") + && answered_only + .message + .contains("No enrichment is currently owed for them"), + "{}", + answered_only.message + ); + assert!( + !answered_only.message.contains("Not settled"), + "a page reading the count out of the message must find none: {}", + answered_only.message + ); + + let one = completeness_of(Some(&observation(0, 1))); + assert!( + one.message.contains("1 entity holds call sites") + && one + .message + .contains("No enrichment is currently owed for it"), + "{}", + one.message + ); + + let nothing = completeness_of(Some(&observation(0, 0))); + assert_eq!(nothing.state, "settled"); + assert_eq!(nothing.pending_entities, None); + assert_eq!(nothing.unresolved_entities, None); + assert!( + nothing + .message + .contains("Overall completeness is unverified"), + "settled never claims the review is complete: {}", + nothing.message + ); + } + + /// Owed enrichment is what waiting changes, so it alone is counted as + /// pending, and what a resolver already answered is counted beside it. + #[test] + fn completeness_counts_only_owed_enrichment_as_pending() { + let both = completeness_of(Some(&observation(3, 7))); + assert_eq!(both.state, "not_settled"); + assert_eq!(both.pending_entities, Some(3)); + assert_eq!(both.unresolved_entities, Some(7)); + assert!( + both.message.starts_with( + "Not settled: 3 entities pending enrichment. Impact counts are lower bounds" + ), + "{}", + both.message + ); + assert!( + both.message.contains("7 entities hold call sites"), + "{}", + both.message + ); + + let owed_only = completeness_of(Some(&observation(1, 0))); + assert_eq!(owed_only.state, "not_settled"); + assert_eq!(owed_only.pending_entities, Some(1)); + assert_eq!(owed_only.unresolved_entities, None); + assert!( + owed_only + .message + .starts_with("Not settled: 1 entity pending enrichment."), + "{}", + owed_only.message + ); + } + + /// With no observation the guide claims nothing either way. + #[test] + fn completeness_without_an_observation_is_unavailable() { + let unavailable = completeness_of(None); + assert_eq!(unavailable.state, "unavailable"); + assert_eq!(unavailable.pending_entities, None); + assert_eq!(unavailable.unresolved_entities, None); + } } diff --git a/crates/kin-cli/src/commands/review_ui.rs b/crates/kin-cli/src/commands/review_ui.rs index 9b46dde9c..df8254e97 100644 --- a/crates/kin-cli/src/commands/review_ui.rs +++ b/crates/kin-cli/src/commands/review_ui.rs @@ -100,6 +100,10 @@ pub(super) struct UiState { pub(super) catalog: Option>, /// The agent sessions this server has seen, for the pages that show them. pub(super) agents: super::workspace_agents::AgentMemory, + /// The pushes and pulls this repository's page has sent. + pub(super) transfers: super::workspace_remotes::Transfers, + /// The test runs this page started, while this server runs. + pub(super) verify_runs: super::workspace_verify::VerifyRuns, } impl UiState { @@ -127,6 +131,8 @@ impl UiState { base_path, catalog: Some(catalog), agents: Default::default(), + transfers: Default::default(), + verify_runs: Default::default(), } } } @@ -594,7 +600,9 @@ async fn serve_guide( /// Admit the working copy before the page reads it. The admission names the /// daemon as its actor, so a host with no Git identity still gets a measured /// working copy; see [`super::admit::AdmitRequest::new`]. -async fn admit_working_for_ui(client: &crate::daemon_client::DaemonClient) -> Option { +pub(super) async fn admit_working_for_ui( + client: &crate::daemon_client::DaemonClient, +) -> Option { working_admission_reason(client.admit(&super::admit::AdmitRequest::new()).await) } @@ -768,6 +776,32 @@ fn polish_guide(guide: &mut serde_json::Value) { *message = plain.into(); } }; + // Each agent named as an author carries the key its agent lane is found + // by, as a change an agent made does. A lane belongs to a Kin session, so + // an edit made with the agent's own tools has one only when Kin could tell + // which connected session the client was. + let keyed = |authors: Option<&mut Vec>| { + for author in authors.into_iter().flatten() { + let through_client = author.get("through").and_then(serde_json::Value::as_str) + == Some(super::review_guide::THROUGH_CLIENT); + let lane = author + .get(if through_client { + "kin_session" + } else { + "session" + }) + .and_then(serde_json::Value::as_str) + .map(super::workspace_agents::agent_key); + if let Some(key) = lane { + author["key"] = key.into(); + } + } + }; + keyed( + guide + .pointer_mut("/summary/authors") + .and_then(serde_json::Value::as_array_mut), + ); if let Some(chapters) = guide["chapters"].as_array_mut() { for step in chapters .iter_mut() @@ -779,6 +813,10 @@ fn polish_guide(guide: &mut serde_json::Value) { rewrite(&mut finding["message"]); } } + keyed( + step.get_mut("authored_by") + .and_then(serde_json::Value::as_array_mut), + ); } } if let Some(lists) = guide["findings"].as_object_mut() { @@ -1388,6 +1426,8 @@ pub async fn run(target: GuideTarget, port: Option, open_browser: bool) -> base_path: String::new(), catalog: None, agents: Default::default(), + transfers: Default::default(), + verify_runs: Default::default(), }); let url = format!("http://127.0.0.1:{port}/"); @@ -1431,6 +1471,8 @@ mod tests { base_path: String::new(), catalog: None, agents: Default::default(), + transfers: Default::default(), + verify_runs: Default::default(), }); tokio::spawn(async move { axum::serve(listener, router(state)).await.unwrap(); diff --git a/crates/kin-cli/src/commands/routed_words.rs b/crates/kin-cli/src/commands/routed_words.rs index 0d82a8dd5..814575dfb 100644 --- a/crates/kin-cli/src/commands/routed_words.rs +++ b/crates/kin-cli/src/commands/routed_words.rs @@ -250,11 +250,12 @@ pub(crate) fn shell_config() -> kin_mcp::McpServerConfig { /// Answer one request through the server path `kin mcp start` answers it on. pub(crate) async fn send(request: &Value, config: &kin_mcp::McpServerConfig) -> Result { let message = request.to_string(); - let response = kin_mcp::process_daemon_message(&message, config) - .await - .context("the routed kin tool gave no answer")?; + // A shell is told what a reviewer flagged the way an agent's client is. + let (response, review_flags) = + kin_mcp::review_flags::collecting(kin_mcp::process_daemon_message(&message, config)).await; + let response = response.context("the routed kin tool gave no answer")?; // Answered in the words that profile's client reads. - let response = kin_mcp::present_for_client(response, &message, config); + let response = kin_mcp::present_flagged_for_client(response, &message, config, &review_flags); if let Some(error) = response.error { anyhow::bail!("the routed kin tool refused the call: {}", error.message); } @@ -530,23 +531,97 @@ mod tests { } } - /// A stand-in daemon that answers `/mcp/tools/call` with `answer` and - /// records each call it was sent. Every other route is a 404, which the - /// server path reads as a daemon with nothing to add. - async fn stand_in_daemon(answer: ToolCallResult) -> (String, Arc>>) { + struct LocalMcpRepositoryBinding { + original_dir: std::path::PathBuf, + } + + impl LocalMcpRepositoryBinding { + fn new(layout: &kin_core::layout::KinLayout) -> Self { + crate::daemon_client::install_spawn_registrar(); + let binding = Self { + original_dir: std::env::current_dir().expect("the original working directory"), + }; + std::env::set_current_dir(layout.working_dir()) + .expect("bind the routed call to its isolated repository"); + let discovered = kin_core::layout::KinLayout::discover( + &std::env::current_dir().expect("the isolated working directory"), + ) + .expect("discover the real isolated repository"); + assert_eq!( + discovered.root().canonicalize().unwrap(), + layout.root().canonicalize().unwrap(), + "the embedded MCP dispatcher must discover this fixture's store" + ); + binding + } + } + + impl Drop for LocalMcpRepositoryBinding { + fn drop(&mut self) { + std::env::set_current_dir(&self.original_dir) + .expect("restore the original working directory"); + } + } + + /// A stand-in daemon admitted for the isolated repository that answers + /// `/mcp/tools/call` with `answer` and records each call it was sent. + /// Other optional routes return 404 without adding to the answer. + async fn stand_in_daemon( + answer: ToolCallResult, + layout: &kin_core::layout::KinLayout, + ) -> (String, Arc>>) { let calls = Arc::new(Mutex::new(Vec::new())); let recorded = Arc::clone(&calls); - let app = axum::Router::new().route( - "/mcp/tools/call", - axum::routing::post(move |axum::Json(body): axum::Json| { - let recorded = Arc::clone(&recorded); - let answer = answer.clone(); - async move { - recorded.lock().unwrap().push(body); - axum::Json(answer) - } - }), + let mut identity = kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon") + .expect("the fixture's actual development runtime identity"); + identity.repository = Some( + kin_core::runtime_identity::RuntimeRepositoryBinding::for_layout(layout) + .expect("the fixture's actual repository binding") + .identity() + .clone(), ); + let health = json!({ + "status": "ok", + "version": identity.identity.version, + "uptime_seconds": 0, + "graph_entity_count": 0, + "graph_loaded": true, + "reconciliation_status": "idle", + "repo_root": layout.working_dir().canonicalize().unwrap(), + "pid": std::process::id(), + }); + let app = axum::Router::new() + .route( + "/runtime/identity", + axum::routing::get(move || { + let identity = identity.clone(); + async move { axum::Json(identity) } + }), + ) + .route( + "/readiness", + axum::routing::get(|| async { + axum::Json(json!({"ready": true, "warming": false})) + }), + ) + .route( + "/health", + axum::routing::get(move || { + let health = health.clone(); + async move { axum::Json(health) } + }), + ) + .route( + "/mcp/tools/call", + axum::routing::post(move |axum::Json(body): axum::Json| { + let recorded = Arc::clone(&recorded); + let answer = answer.clone(); + async move { + recorded.lock().unwrap().push(body); + axum::Json(answer) + } + }), + ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind a loopback port"); @@ -565,10 +640,30 @@ mod tests { #[tokio::test] #[serial] async fn kin_call_runs_the_named_tool_through_the_routed_call() { - let _no_spawn = EnvVarGuard::set("KIN_NO_DAEMON", "1"); - let _session = EnvVarGuard::unset("KIN_SESSION_ID"); + let fixture = tempfile::tempdir().expect("an isolated routed-call fixture"); + let home = fixture.path().join("home"); + let kin_home = fixture.path().join("kin-home"); + let repo = fixture.path().join("repo"); + for directory in [&home, &kin_home, &repo] { + std::fs::create_dir_all(directory).unwrap(); + } + let _environment = EnvVarGuard::set("HOME", &home) + .with("USERPROFILE", &home) + .with("KIN_HOME", &kin_home) + .with("KIN_RUNTIME_MODE", "development") + .with("KIN_REGISTRY_PATH", kin_home.join("registry.toml")) + .with("KIN_NO_DAEMON", "1") + .without("KIN_DIR") + .without("KIN_STORE") + .without("KIN_MCP_REPO") + .without("KIN_SESSION_ID"); + let layout = kin_core::init(&repo) + .expect("initialize the real isolated repository") + .layout; + let _repository = LocalMcpRepositoryBinding::new(&layout); let payload = json!({"entities": [], "note": "read one body with get_entity_source"}); - let (url, calls) = stand_in_daemon(ToolCallResult::text(payload.to_string())).await; + let (url, calls) = + stand_in_daemon(ToolCallResult::text(payload.to_string()), &layout).await; let _daemon = EnvVarGuard::set("KIN_DAEMON_URL", &url); let config = shell_config(); @@ -581,7 +676,7 @@ mod tests { ); let answer = send(&request, &config).await.expect("an answer"); let sent = calls.lock().unwrap().clone(); - assert_eq!(sent.len(), 1, "{sent:?}"); + assert_eq!(sent.len(), 1, "{sent:?}; answer: {}", answer.text); assert_eq!(sent[0]["name"], "graph_neighborhood"); assert_eq!(sent[0]["arguments"]["entity_id"], "e1"); assert_eq!(sent[0]["arguments"]["depth"], 2); @@ -590,8 +685,11 @@ mod tests { assert_eq!(answered["note"], "read one body with kin source"); assert!(answered.get(kin_mcp::ENVELOPE_KEY).is_some(), "{answered}"); - let (url, _) = - stand_in_daemon(ToolCallResult::error("no entity exists with this ID")).await; + let (url, _) = stand_in_daemon( + ToolCallResult::error("no entity exists with this ID"), + &layout, + ) + .await; let _daemon = EnvVarGuard::set("KIN_DAEMON_URL", &url); let answer = send(&request, &config).await.expect("an answer"); assert!(answer.is_error, "{}", answer.text); diff --git a/crates/kin-cli/src/commands/runtime.rs b/crates/kin-cli/src/commands/runtime.rs new file mode 100644 index 000000000..799beaca8 --- /dev/null +++ b/crates/kin-cli/src/commands/runtime.rs @@ -0,0 +1,765 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Executable-pair admission, independent of repository or daemon state. + +use anyhow::{Context, Result}; +use kin_core::runtime_identity::{RuntimeIdentity, RuntimeMode}; +use kin_io::{Grant, HostDir, Spawner}; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +const SCHEMA: &str = "kin.runtime-status.v1"; +const PROBE_TIMEOUT: Duration = Duration::from_secs(10); +const PROBE_OUTPUT_LIMIT: u64 = 65_536; + +#[derive(Clone, Debug, Default)] +pub struct StatusOptions { + pub development: bool, + pub binary: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RuntimeComponent { + pub path: PathBuf, + pub version: Option, + pub commit_sha: Option, + pub source_dirty: Option, + pub source_known: Option, + pub target: Option, + pub build_profile: Option, + pub sha256: Option, +} + +impl RuntimeComponent { + fn empty(path: PathBuf) -> Self { + Self { + path, + version: None, + commit_sha: None, + source_dirty: None, + source_known: None, + target: None, + build_profile: None, + sha256: None, + } + } + + pub fn identity(&self) -> Option { + Some(RuntimeIdentity { + version: self.version.clone()?, + commit_sha: self.commit_sha.clone()?, + source_dirty: self.source_dirty?, + source_known: self.source_known?, + target: self.target.clone()?, + build_profile: self.build_profile.clone()?, + }) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct RuntimeStatus { + pub schema: &'static str, + pub ready: bool, + pub mode: String, + pub kin_home: PathBuf, + pub cli: RuntimeComponent, + pub daemon: RuntimeComponent, + pub pair_matches: bool, + pub channel: Option, + pub release_kind: String, + pub reason: Option, + pub detail: Option, + pub restart_needed: bool, +} + +impl RuntimeStatus { + fn refuse(&mut self, reason: &str, detail: impl Into) { + self.ready = false; + self.reason = Some(reason.to_owned()); + self.detail = Some(detail.into()); + } +} + +pub fn managed_home() -> Result { + let root = match explicit_home() { + Some(root) => PathBuf::from(root), + None => default_home()?, + }; + // Preserve the requested path for the updater's anchored, no-follow root + // validation. Canonicalization here would hide a caller-supplied symlink. + requested_absolute_path(&root) +} + +fn requested_absolute_path(root: &Path) -> Result { + if root.is_absolute() { + Ok(root.to_path_buf()) + } else { + Ok(std::env::current_dir()?.join(root)) + } +} + +fn explicit_home() -> Option { + std::env::var_os("KIN_HOME") + .filter(|value| !value.is_empty()) + .or_else(|| std::env::var_os("KIN_DIR").filter(|value| !value.is_empty())) +} + +fn default_home() -> Result { + let home = super::setup::home_dir().context("cannot resolve the user's home directory")?; + Ok(home.join(".kin")) +} + +fn absolute_path(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let canonical = absolute.canonicalize().unwrap_or(absolute); + Ok(strip_windows_verbatim(canonical)) +} + +fn strip_windows_verbatim(path: PathBuf) -> PathBuf { + #[cfg(windows)] + if let Some(raw) = path.to_str() { + if let Some(rest) = raw.strip_prefix("\\\\?\\UNC\\") { + return PathBuf::from(format!("\\\\{rest}")); + } + if let Some(rest) = raw.strip_prefix("\\\\?\\") { + if rest.as_bytes().get(1) == Some(&b':') { + return PathBuf::from(rest); + } + } + } + path +} + +fn binary_name(name: &str) -> String { + format!("{name}{}", std::env::consts::EXE_SUFFIX) +} + +pub fn development_home(identity: &RuntimeIdentity, binary: &Path) -> Result { + let base = absolute_path(&default_home()?)?; + if let Some(explicit) = explicit_home() { + let explicit = absolute_path(Path::new(&explicit))?; + anyhow::ensure!( + explicit != base, + "development mode cannot use the ordinary managed Kin home; set KIN_HOME to an isolated directory" + ); + return Ok(explicit); + } + let parent = binary + .parent() + .context("runtime executable has no parent directory")?; + Ok(identity.development_home(&base, parent)) +} + +/// Read both executable identities without opening a repository or starting a daemon. +pub fn inspect(options: &StatusOptions) -> RuntimeStatus { + let mode = if options.development { + RuntimeMode::Development + } else { + RuntimeMode::Managed + }; + let home = managed_home() + .and_then(|home| absolute_path(&home)) + .unwrap_or_else(|_| PathBuf::from("/")); + let selected = options.binary.clone().unwrap_or_else(|| { + if options.development { + std::env::current_exe().unwrap_or_else(|_| home.join("bin").join(binary_name("kin"))) + } else { + home.join("bin").join(binary_name("kin")) + } + }); + let selected = absolute_path(&selected).unwrap_or(selected); + let daemon = selected + .parent() + .unwrap_or(&home) + .join(binary_name("kin-daemon")); + let mut report = RuntimeStatus { + schema: SCHEMA, + ready: false, + mode: mode.as_str().to_owned(), + kin_home: home, + cli: RuntimeComponent::empty(selected), + daemon: RuntimeComponent::empty(daemon), + pair_matches: false, + channel: (mode == RuntimeMode::Development).then(|| "development".to_owned()), + release_kind: if options.development { + "development" + } else { + "unknown" + } + .to_owned(), + reason: None, + detail: None, + restart_needed: false, + }; + if options.binary.is_some() && !options.development { + report.refuse( + "development_required", + "--binary is only valid with --development", + ); + return report; + } + if mode == RuntimeMode::Managed { + if let Err(error) = validate_managed_paths() { + report.refuse("managed_path_invalid", format!("{error:#}")); + return report; + } + } + if let Err(error) = probe_component(&mut report.cli, true, mode) { + report.refuse("cli_probe_failed", format!("{error:#}")); + return report; + } + if options.development { + if let Some(identity) = report.cli.identity() { + match development_home(&identity, &report.cli.path) { + Ok(home) => report.kin_home = home, + Err(error) => { + report.refuse("development_home_conflict", error.to_string()); + return report; + } + } + } + } + if let Err(error) = probe_component(&mut report.daemon, false, mode) { + report.refuse("daemon_probe_failed", format!("{error:#}")); + return report; + } + let (Some(cli), Some(daemon)) = (report.cli.identity(), report.daemon.identity()) else { + report.refuse( + "identity_incomplete", + "both runtime components must expose their complete build identity", + ); + return report; + }; + if !cli.source_known + || !daemon.source_known + || !full_commit_sha(&cli.commit_sha) + || !full_commit_sha(&daemon.commit_sha) + { + report.refuse( + "source_identity_unknown", + "both runtime components must identify their exact known source", + ); + return report; + } + if mode == RuntimeMode::Managed { + if let Err(error) = cli.require_managed().and_then(|_| daemon.require_managed()) { + report.refuse("managed_identity_refused", error); + return report; + } + } + if cli != daemon { + report.refuse("pair_source_mismatch", "CLI and daemon version, exact source, dirty state, target and build profile must agree"); + return report; + } + report.pair_matches = true; + if mode == RuntimeMode::Development { + report.ready = true; + return report; + } + let metadata = HostDir::open(&report.kin_home, &installation_grant()); + let metadata = match metadata { + Ok(metadata) => metadata, + Err(error) => { + report.refuse("install_metadata_unreadable", error.to_string()); + return report; + } + }; + match metadata.symlink_metadata("update.toml") { + Ok(entry) if !entry.is_file() => { + report.refuse( + "install_metadata_unreadable", + "recorded update policy must be a regular file", + ); + return report; + } + Err(error) if error.kind() != std::io::ErrorKind::NotFound => { + report.refuse("install_metadata_unreadable", error.to_string()); + return report; + } + _ => {} + } + match metadata.read("update.toml", 65_536) { + Ok(Some(bytes)) => { + let recorded = std::str::from_utf8(&bytes) + .ok() + .and_then(|text| toml::from_str::(text).ok()); + match recorded { + Some(recorded) => { + if let Some(channel) = recorded.get("channel").and_then(toml::Value::as_str) { + if matches!(channel, "stable" | "alpha") { + report.channel = Some(channel.to_owned()); + } else { + report.refuse( + "update_policy_invalid", + "recorded update channel must be stable or alpha", + ); + return report; + } + } + } + None => { + report.refuse( + "update_policy_invalid", + "recorded update policy is not valid UTF-8 TOML", + ); + return report; + } + } + } + Ok(None) => {} + Err(error) => { + report.refuse("install_metadata_unreadable", error.to_string()); + return report; + } + } + report.restart_needed = match metadata.symlink_metadata("update-restart-ack-required.json") { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + report.refuse("install_metadata_unreadable", error.to_string()); + return report; + } + }; + if report.restart_needed { + report.refuse("restart_required", "the native updater's durable restart fence requires acknowledgement with kin update --ack-restart"); + return report; + } + report.ready = true; + report +} + +fn full_commit_sha(sha: &str) -> bool { + sha.len() == 40 && sha.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn validate_managed_paths() -> Result<()> { + let home = managed_home()?; + let entries = [ + home.clone(), + home.join("bin"), + home.join("bin").join(binary_name("kin")), + home.join("bin").join(binary_name("kin-daemon")), + ]; + for (index, entry) in entries.into_iter().enumerate() { + let parent = entry + .parent() + .context("managed runtime path has no parent")?; + let name = entry + .file_name() + .context("managed runtime path has no name")?; + let directory = match HostDir::open(parent, &installation_grant()) { + Ok(directory) => directory, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).context("cannot inspect the managed runtime directory") + } + }; + match directory.symlink_metadata(name) { + Ok(metadata) => { + anyhow::ensure!( + !metadata.file_type().is_symlink(), + "managed runtime path must not be a symbolic link: {}", + entry.display() + ); + anyhow::ensure!( + if index < 2 { + metadata.is_dir() + } else { + metadata.is_file() + }, + "managed runtime path has an invalid entry type: {}", + entry.display() + ); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("cannot inspect the managed runtime entry"), + } + } + Ok(()) +} + +pub fn require_managed_pair() -> Result { + let report = inspect(&StatusOptions::default()); + anyhow::ensure!( + report.ready && report.pair_matches, + "managed runtime admission refused ({}): {}", + report.reason.as_deref().unwrap_or("unknown"), + report.detail.as_deref().unwrap_or("runtime is not ready") + ); + Ok(report) +} + +/// Apply mode and home before repository profiles or clients can observe them. +pub fn prepare_environment() -> Result { + let mode = RuntimeMode::current().map_err(anyhow::Error::msg)?; + let home = if mode == RuntimeMode::Development { + let binary = absolute_path(&std::env::current_exe()?)?; + development_home(&RuntimeIdentity::current(), &binary)? + } else { + managed_home()? + }; + // SAFETY: process entry installs these before creating workers or loading + // profiles. The chosen values persist for this process and its children. + unsafe { + if std::env::var_os("KIN_RUNTIME_MODE").as_deref() + != Some(std::ffi::OsStr::new(mode.as_str())) + { + std::env::set_var("KIN_RUNTIME_MODE", mode.as_str()); + } + if std::env::var_os("KIN_HOME").as_deref() != Some(home.as_os_str()) { + std::env::set_var("KIN_HOME", home); + } + } + Ok(mode) +} + +/// A release found elsewhere on PATH may only enter the admitted managed pair. +/// Diagnostics remain usable when the installation needs repair. +pub fn route_startup(mode: RuntimeMode, exempt: bool) -> Result> { + if exempt { + return Ok(None); + } + let invoked = absolute_path(&std::env::current_exe()?)?; + let options = StatusOptions { + development: mode == RuntimeMode::Development, + binary: (mode == RuntimeMode::Development).then(|| invoked.clone()), + }; + let report = inspect(&options); + anyhow::ensure!( + report.ready, + "runtime admission refused ({}): {}. Run kin runtime status --json; repair the managed installation with kin update --apply, or select an isolated development runtime explicitly", + report.reason.as_deref().unwrap_or("unknown"), + report.detail.as_deref().unwrap_or("runtime is not ready") + ); + // SAFETY: this is still the single-threaded process entry. Every subsequently + // launched client and daemon inherits the admitted pair and the same home. + unsafe { + std::env::set_var("KIN_DAEMON_BIN", &report.daemon.path); + } + if mode == RuntimeMode::Development || invoked == report.cli.path { + return Ok(None); + } + let mut child = Spawner::new(&installation_grant()).command(&report.cli.path); + child.args(std::env::args_os().skip(1)); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let error = child.exec(); + Err(error).context("cannot enter the admitted managed Kin executable") + } + #[cfg(not(unix))] + { + let status = child + .status() + .context("cannot enter the admitted managed Kin executable")?; + Ok(Some(status.code().unwrap_or(1))) + } +} + +fn probe_component(component: &mut RuntimeComponent, cli: bool, mode: RuntimeMode) -> Result<()> { + let before = executable_sha256(&component.path)?; + component.sha256 = Some(before.clone()); + let args: &[&str] = if cli { + &["bench-meta", "--json", "--runtime-identity-only"] + } else { + &["--compat-json"] + }; + let payload = probe_json(&component.path, args, mode)?; + let build = if cli { + &payload + } else { + payload.get("build").unwrap_or(&payload) + }; + let field = |cli_name, daemon_name| { + build + .get(if cli { cli_name } else { daemon_name }) + .and_then(Value::as_str) + .map(str::to_owned) + }; + component.version = if cli { + field("kin_version", "version") + } else { + payload + .get("version") + .and_then(Value::as_str) + .map(str::to_owned) + }; + component.commit_sha = field("kin_commit", "sha"); + component.source_dirty = build + .get(if cli { "kin_dirty" } else { "dirty" }) + .and_then(Value::as_bool); + component.source_known = build + .get(if cli { + "kin_source_known" + } else { + "source_known" + }) + .and_then(Value::as_bool); + component.target = field("kin_target", "target"); + component.build_profile = field("kin_build_profile", "build_profile"); + anyhow::ensure!( + executable_sha256(&component.path)? == before, + "runtime executable changed during its identity probe" + ); + Ok(()) +} + +fn executable_sha256(path: &Path) -> Result { + let parent = path + .parent() + .context("runtime executable has no directory")?; + let name = path.file_name().context("runtime executable has no name")?; + let directory = HostDir::open(parent, &installation_grant()).with_context(|| { + format!( + "cannot open runtime executable directory {}", + parent.display() + ) + })?; + let file = directory + .open_read_file(name) + .with_context(|| format!("cannot read executable {}", path.display()))?; + anyhow::ensure!( + file.metadata()?.is_file(), + "runtime executable is not a regular file" + ); + let size = file.metadata()?.len(); + let mut file = file.take(size.saturating_add(1)); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 65_536]; + let mut hashed = 0_u64; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hashed += count as u64; + hasher.update(&buffer[..count]); + } + anyhow::ensure!( + hashed == size, + "runtime executable changed size while its identity was being hashed" + ); + Ok(hex::encode(hasher.finalize())) +} + +fn probe_json(binary: &Path, args: &[&str], mode: RuntimeMode) -> Result { + let stdout = probe_output(binary, args, mode)?; + serde_json::from_slice(&stdout).context("runtime identity probe did not emit a JSON object") +} + +pub fn probe_legacy_cli_version(binary: &Path) -> Result { + let stdout = probe_output(binary, &["--version"], RuntimeMode::Managed)?; + let text = std::str::from_utf8(&stdout).context("legacy CLI version is not UTF-8")?; + let version = text + .trim() + .strip_prefix("kin ") + .and_then(|text| text.split_whitespace().next()) + .context("legacy CLI did not emit its kin version")?; + version.parse().context("legacy CLI version is not SemVer") +} + +fn probe_output(binary: &Path, args: &[&str], mode: RuntimeMode) -> Result> { + let mut command = Spawner::new(&installation_grant()).command(binary); + command + .args(args) + .env("KIN_RUNTIME_MODE", mode.as_str()) + .current_dir(std::env::temp_dir()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command + .spawn() + .with_context(|| format!("cannot probe {}", binary.display()))?; + let stdout = child.stdout.take().context("probe stdout is missing")?; + let stderr = child.stderr.take().context("probe stderr is missing")?; + let read = |pipe: Box| { + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let result = pipe + .take(PROBE_OUTPUT_LIMIT) + .read_to_end(&mut bytes) + .map(|_| bytes); + let _ = sender.send(result); + }); + receiver + }; + let stdout = read(Box::new(stdout)); + let stderr = read(Box::new(stderr)); + let started = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if started.elapsed() >= PROBE_TIMEOUT { + stop_probe(&mut child); + let _ = child.wait(); + anyhow::bail!( + "runtime identity probe exceeded {} seconds", + PROBE_TIMEOUT.as_secs() + ); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let read_before_deadline = + |receiver: std::sync::mpsc::Receiver>>| -> Result> { + let remaining = PROBE_TIMEOUT.saturating_sub(started.elapsed()); + receiver + .recv_timeout(remaining) + .map_err(|_| { + anyhow::anyhow!( + "runtime identity probe output exceeded {} seconds", + PROBE_TIMEOUT.as_secs() + ) + })? + .map_err(Into::into) + }; + let output = read_before_deadline(stdout) + .and_then(|stdout| read_before_deadline(stderr).map(|stderr| (stdout, stderr))); + let (stdout, stderr) = match output { + Ok(output) => output, + Err(error) => { + stop_probe(&mut child); + return Err(error); + } + }; + anyhow::ensure!( + status.success(), + "runtime identity probe exited {status}: {}", + String::from_utf8_lossy(&stderr).trim() + ); + Ok(stdout) +} + +fn installation_grant() -> Grant { + // Installation metadata and the explicitly selected executable pair are an + // administrative boundary. This grant never reads repository source. + Grant::ambient() +} + +fn stop_probe(child: &mut std::process::Child) { + #[cfg(unix)] + { + // The metadata subprocess owns a fresh group; its descendants can keep + // capture pipes open after the immediate process has exited. + if let Ok(pid) = i32::try_from(child.id()) { + // SAFETY: this group was assigned only to the probe we spawned. + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + } + let _ = child.kill(); +} + +pub fn run(json: bool, options: &StatusOptions) -> Result { + let report = inspect(options); + if json { + crate::println!("{}", serde_json::to_string_pretty(&report)?); + } else { + crate::println!( + "Kin runtime: {} ({})", + if report.ready { "ready" } else { "not ready" }, + report.mode + ); + crate::println!("Home: {}", report.kin_home.display()); + crate::println!("CLI: {}", report.cli.path.display()); + crate::println!("Daemon: {}", report.daemon.path.display()); + if let Some(detail) = &report.detail { + crate::println!("{detail}"); + } + } + Ok(if report.ready { 0 } else { 1 }) +} + +pub fn run_identity_probe() -> Result<()> { + let identity = RuntimeIdentity::current(); + crate::println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "schema": "kin.runtime-build-identity.v1", + "kin_version": identity.version, + "kin_commit": identity.commit_sha, + "kin_dirty": identity.source_dirty, + "kin_source_known": identity.source_known, + "kin_target": identity.target, + "kin_build_profile": identity.build_profile, + }))? + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn managed_home_keeps_symlink_for_native_updater_root_validation() { + let fixture = tempfile::tempdir().unwrap(); + let real = fixture.path().join("real-root"); + let alias = fixture.path().join("root-alias"); + std::fs::create_dir(&real).unwrap(); + std::os::unix::fs::symlink(&real, &alias).unwrap(); + let selected = requested_absolute_path(&alias); + assert_eq!(selected.unwrap(), alias); + assert!(std::fs::symlink_metadata(alias) + .unwrap() + .file_type() + .is_symlink()); + } + + #[test] + fn installation_reader_stays_confined_and_streams_regular_files() { + let fixture = tempfile::tempdir().unwrap(); + let home = fixture.path().join("install"); + std::fs::create_dir(&home).unwrap(); + std::fs::write(home.join("binary"), b"runtime bytes").unwrap(); + std::fs::write(fixture.path().join("outside"), b"outside bytes").unwrap(); + let directory = HostDir::open(&home, &installation_grant()).unwrap(); + let mut file = directory.open_read_file("binary").unwrap(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, b"runtime bytes"); + assert!(directory.open_read_file("../outside").is_err()); + assert!(directory + .open_read_file(fixture.path().join("outside")) + .is_err()); + assert!(directory.open_read_file(".").is_err()); + } + + #[cfg(unix)] + #[test] + fn legacy_version_probe_only_runs_the_static_version_command() { + use std::os::unix::fs::PermissionsExt; + let fixture = tempfile::tempdir().unwrap(); + let binary = fixture.path().join("kin"); + std::fs::write( + &binary, + "#!/bin/sh\n[ \"$1\" = '--version' ] || exit 2\nprintf '%s\\n' 'kin 0.8.1'\n", + ) + .unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + probe_legacy_cli_version(&binary).unwrap(), + semver::Version::new(0, 8, 1) + ); + std::fs::write(&binary, "#!/bin/sh\nprintf '%s\\n' 'unversioned binary'\n").unwrap(); + assert!(probe_legacy_cli_version(&binary).is_err()); + } +} diff --git a/crates/kin-cli/src/commands/setup.rs b/crates/kin-cli/src/commands/setup.rs index 364df8557..866026f28 100644 --- a/crates/kin-cli/src/commands/setup.rs +++ b/crates/kin-cli/src/commands/setup.rs @@ -2520,6 +2520,12 @@ fn configure_claude_code() -> Result { }; merge_mcp_config(&target, "claude")?; + // Connecting Claude Code also has it tell Kin which edits are its own. + // The server entry stands whether or not the hook could be written: a + // person's settings Kin cannot read are left as they are. + if let Err(error) = install_edit_hook_for_claude() { + tracing::debug!("Claude Code's edit hook was not written: {error:#}"); + } Ok(target) } @@ -3604,6 +3610,70 @@ fn install_prompt_hook_for_claude() { } } +/// Write Kin's paired edit hooks into Claude Code's user settings under `home`, +/// beside every hook already there. Returns the settings path, what changed +/// and the groups Kin owns, for the install ledger. Settings that do not parse +/// are left untouched and refused. +fn install_claude_edit_hook( + home: &Path, + launcher: &str, +) -> Result<( + PathBuf, + crate::commands::setup_hooks::PromptHookWrite, + serde_json::Value, +)> { + let path = crate::commands::setup_hooks::claude_settings_path(home); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("could not create {}", parent.display()))?; + } + let lock = ConfigLock::acquire(&path)?; + let original = lock.original_bytes(&path)?; + let root = original + .as_deref() + .filter(|bytes| !bytes.iter().all(u8::is_ascii_whitespace)) + .map(serde_json::from_slice::) + .transpose() + .with_context(|| { + format!( + "{} is not valid JSON, so Kin leaves it untouched", + path.display() + ) + })?; + let command = crate::commands::setup_hooks::edit_hook_command(launcher); + let (root, write) = crate::commands::setup_hooks::with_edit_hook(root, &command) + .with_context(|| format!("Kin leaves {} untouched", path.display()))?; + if write != crate::commands::setup_hooks::PromptHookWrite::Unchanged { + let mut bytes = serde_json::to_vec_pretty(&root).context("serialize Claude settings")?; + bytes.push(b'\n'); + lock.write_guarded(&path, &bytes, original.as_deref())?; + } + let groups = crate::commands::setup_hooks::current_edit_hook(&root) + .context("installed Claude edit hooks are missing")?; + Ok((path, write, groups)) +} + +/// Add Kin's edit hooks to Claude Code's user settings and record them in the +/// install ledger, so the same teardown that removes Kin's server entry +/// removes both. Writing them again changes nothing. +/// +/// Connecting Claude Code is what asks for it. The agent edits code with its +/// own tools far more often than through Kin, and without the hook Kin sees +/// those edits with no one's name on them. +fn install_edit_hook_for_claude() -> Result<(PathBuf, crate::commands::setup_hooks::PromptHookWrite)> +{ + use crate::commands::setup_hooks::CLAUDE_TARGET; + use crate::commands::setup_ledger::{ledger_path, LedgerEntry, SetupLedger}; + let launcher = resolve_mcp_launcher()?; + let (path, write, groups) = install_claude_edit_hook(&home_dir()?, &launcher.command)?; + let ledger = ledger_path()?; + SetupLedger::update(&ledger, |ledger| { + ledger.record(LedgerEntry::edit_hook(CLAUDE_TARGET, path.clone(), &groups)); + Ok(()) + })?; + Ok((path, write)) +} + fn record_discovery_reminders(written: &[WrittenReminder]) { use crate::commands::setup_ledger::{ledger_path, ArtifactKind, LedgerEntry, SetupLedger}; let Ok(path) = ledger_path() else { @@ -5666,56 +5736,24 @@ pub(crate) fn managed_mcp_launcher() -> Result { Ok(launcher_spelling(&path)) } -/// Where the launcher a client config names comes from. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum McpLauncherOrigin { - /// `/bin/kin`, the binary `kin update` replaces in place. - Managed, - /// The running executable, or the `kin` on PATH that is the same file: a - /// source checkout, a scratch build, or a Homebrew or manual install. - /// Nothing manages that path, so an update leaves configs naming it behind. - Unmanaged, -} - -/// The launcher `kin setup` registers, with where it came from. +/// The validated stable launcher `kin setup` registers. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct McpLauncher { pub(crate) command: String, - pub(crate) origin: McpLauncherOrigin, } -/// Resolve the stable launcher that ordinary setup, health, and doctor must -/// agree on for the installation channel currently running Kin. -/// -/// Managed curl/npm installs own `$KIN_HOME/bin/kin`, so that stable path wins -/// when present. Homebrew and manual installs do not create it; for those -/// channels, prefer the `kin` path on PATH only when it resolves to this exact -/// running executable, then fall back to `current_exe`. The updater keeps using -/// [`managed_mcp_launcher`] directly after it has installed managed bytes. +/// Resolve only the validated managed pair. Client registrations are durable +/// pointers, so an arbitrary running executable cannot become their authority. pub(crate) fn resolve_mcp_launcher() -> Result { - if let Ok(command) = managed_mcp_launcher() { - return Ok(McpLauncher { - command, - origin: McpLauncherOrigin::Managed, - }); - } - - let current = env::current_exe().context("could not resolve the running Kin executable")?; - validate_running_mcp_launcher(¤t)?; - if let Ok(path_candidate) = which::which(if cfg!(windows) { "kin.exe" } else { "kin" }) { - let candidate_target = fs::canonicalize(&path_candidate).ok(); - let current_target = fs::canonicalize(¤t).ok(); - if candidate_target.is_some() && candidate_target == current_target { - validate_running_mcp_launcher(&path_candidate)?; - return Ok(McpLauncher { - command: launcher_spelling(&path_candidate), - origin: McpLauncherOrigin::Unmanaged, - }); - } + let report = crate::commands::runtime::inspect(&Default::default()); + if !report.ready || !report.pair_matches { + anyhow::bail!( + "managed Kin runtime is unavailable: {}. Run `kin runtime status --json` and repair the managed installation before registering clients", + report.detail.as_deref().or(report.reason.as_deref()).unwrap_or("the CLI and daemon pair did not pass validation") + ); } Ok(McpLauncher { - command: launcher_spelling(¤t), - origin: McpLauncherOrigin::Unmanaged, + command: launcher_spelling(&report.cli.path), }) } @@ -5724,62 +5762,13 @@ pub(crate) fn configured_mcp_launcher() -> Result { resolve_mcp_launcher().map(|launcher| launcher.command) } -/// The warning setup prints before it registers an unmanaged launcher, or -/// `None` when the launcher is the managed binary. -/// -/// A client config is a standing pointer, and `kin update` repairs only -/// pointers at the managed binary. Registering a source checkout or a scratch -/// build without saying so is how every agent client on one machine came to -/// launch a lane build out of a scratch directory nothing manages. -fn unmanaged_mcp_launcher_warning(launcher: &McpLauncher, kin_home: &Path) -> Option { - match launcher.origin { - McpLauncherOrigin::Managed => None, - McpLauncherOrigin::Unmanaged => Some(format!( - "registering {} as the Kin MCP launcher: no managed binary exists at {}, so this \ - path is a source checkout, a scratch build or a package-manager install that Kin \ - does not manage, and `kin update` will not repair client configs that point at \ - it. If this machine should be on the managed install, install Kin through the \ - installer or npm and re-run `kin setup` to register the managed binary.", - launcher.command, - managed_mcp_launcher_path(kin_home).display() - )), - } -} - -/// Print the unmanaged-launcher warning once per setup run, ahead of the -/// per-client writes that all name the same launcher. A resolution failure is -/// left to those writes, which report it against the client they were writing. +/// Report a refused managed runtime before per-client registration attempts. fn announce_mcp_launcher_origin() { - let Ok(kin_home) = kin_dir() else { - return; - }; - let Ok(launcher) = resolve_mcp_launcher() else { - return; - }; - if let Some(warning) = unmanaged_mcp_launcher_warning(&launcher, &kin_home) { - say!(" {} {warning}", style("!").yellow()); + if let Err(error) = resolve_mcp_launcher() { + say!(" {} {error}", style("!").yellow()); } } -fn validate_running_mcp_launcher(path: &Path) -> Result<()> { - if !path.is_absolute() { - anyhow::bail!("Kin MCP launcher is not absolute: {}", path.display()); - } - let metadata = fs::metadata(path) - .with_context(|| format!("Kin MCP launcher is unavailable at {}", path.display()))?; - if !metadata.is_file() { - anyhow::bail!("Kin MCP launcher is not a file: {}", path.display()); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if metadata.permissions().mode() & 0o111 == 0 { - anyhow::bail!("Kin MCP launcher is not executable: {}", path.display()); - } - } - Ok(()) -} - #[cfg(all(test, unix))] enum InjectedConfigObjectDrift { Bytes(fs::File, Vec), @@ -14783,6 +14772,7 @@ pub async fn run_wizard(opts: WizardOptions) -> Result<()> { // decision some installs never get to make. ask_embedding_model(&opts, interactive, &mut skipped); ask_embedding_provider(&opts, interactive, &mut skipped); + offer_other_copy_removals_in_wizard(interactive, &mut skipped); // The final checklist is the real first-run health engine — not a parallel // set of hardcoded probes. Every line below reflects probed state. @@ -14812,6 +14802,103 @@ pub async fn run_wizard(opts: WizardOptions) -> Result<()> { } } +/// What setup did about the other copies of Kin it found. +#[derive(Debug, Default)] +pub(crate) struct CopyRemovals { + pub(crate) removed: Vec, + /// Offers left in place: declined, or never asked because no one is there. + pub(crate) kept: Vec, + pub(crate) failed: Vec<(crate::commands::kin_copies::RemovalOffer, String)>, +} + +/// Offer each other copy of Kin for removal, one at a time. +/// +/// The default is always to keep it, and a run with no one at the terminal +/// asks nothing and removes nothing: it only names the copies and the command +/// that removes each. `ask` answers for one offer by its position, and +/// `remove` runs it, so the rule is testable without a terminal or a package +/// manager. +pub(crate) fn offer_other_copy_removals( + scan: &crate::commands::kin_copies::CopyScan, + interactive: bool, + mut ask: A, + mut remove: R, +) -> CopyRemovals +where + A: FnMut(usize, &crate::commands::kin_copies::RemovalOffer) -> bool, + R: FnMut(&crate::commands::kin_copies::RemovalOffer) -> Result<()>, +{ + let mut outcome = CopyRemovals::default(); + for (index, offer) in scan.removal_offers().into_iter().enumerate() { + if !interactive || !ask(index, &offer) { + outcome.kept.push(offer); + continue; + } + match remove(&offer) { + Ok(()) => outcome.removed.push(offer), + Err(error) => outcome.failed.push((offer, format!("{error:#}"))), + } + } + outcome +} + +/// The full-record wizard's step for other copies of Kin. +fn offer_other_copy_removals_in_wizard(interactive: bool, skipped: &mut Vec) { + let Ok(roots) = crate::commands::kin_copies::ScanRoots::from_environment() else { + return; + }; + let scan = crate::commands::kin_copies::scan(&roots); + if scan.others().next().is_none() { + return; + } + say!(); + say!("Other copies of Kin on this machine:"); + for line in scan + .others() + .map(|copy| copy.describe(scan.managed_version.as_deref())) + { + say!(" {line}"); + } + let outcome = offer_other_copy_removals( + &scan, + interactive, + |_, offer| { + prompt_yn( + &format!( + " Remove the {} with `{}`?", + offer.what, + offer.removal.command_line() + ), + false, + true, + ) + }, + |offer| crate::commands::kin_copies::remove(offer, &roots), + ); + for offer in &outcome.removed { + say!(" {} removed the {}", style("✓").green(), offer.what); + } + for (offer, reason) in &outcome.failed { + say!( + " {} could not remove the {}: {reason}", + style("✗").red(), + offer.what + ); + say!(" remove it by hand: {}", offer.removal.command_line()); + } + if !interactive && !outcome.kept.is_empty() { + say!(" This run is not interactive, so it removed nothing. Each one's own uninstall:"); + for offer in &outcome.kept { + say!(" {}", offer.removal.command_line()); + } + skipped.push(SkippedDecision { + decision: "Other copies of Kin", + value: format!("kept {}", outcome.kept.len()), + give_back: "kin setup doctor --fix --remove-other-copies".to_string(), + }); + } +} + /// The error setup ends with when the local registry is not safe to use. /// /// Setup cannot finish an install whose registry Kin refuses: every later @@ -15465,6 +15552,18 @@ async fn apply_plan( if let Some(choice) = &choice { say!(" {}", written_profile_line(choice)); } + if *idx == IDX_CLAUDE_CODE { + // Written with the server entry; said here, because + // it is a second place in a person's settings. + match install_edit_hook_for_claude() { + Ok((settings, _)) => say!( + " edit hook in {}: an edit Claude Code makes with its own \ + tools names it on the entities it changed", + settings.display() + ), + Err(error) => say!(" edit hook not written: {error:#}"), + } + } // Which repository a client ends up bound to is decided by // the directory this ran in, and nothing said so. A later // run from a different directory rebound the client @@ -17055,6 +17154,53 @@ struct UnfinishedRepair { requested: bool, } +/// `--remove-other-copies`: run every removal Kin offers for another copy. +/// +/// Each removal is its own channel's uninstall, and each one that fails is +/// reported with that command, because the person asked for all of them. +fn remove_other_kin_copies(applied: &mut Vec, unfinished: &mut Vec) { + let roots = match crate::commands::kin_copies::ScanRoots::from_environment() { + Ok(roots) => roots, + Err(error) => { + unfinished.push(UnfinishedRepair { + what: "find the other copies of Kin".to_string(), + reason: format!("{error:#}"), + remediation: vec!["kin doctor --verbose names each copy it found".to_string()], + requested: true, + }); + return; + } + }; + let offers = crate::commands::kin_copies::scan(&roots).removal_offers(); + if offers.is_empty() { + say!(" No other copy of Kin has an uninstall Kin can run."); + return; + } + for offer in offers { + match crate::commands::kin_copies::remove(&offer, &roots) { + Ok(()) => applied.push(format!( + "removed {} ({})", + offer.what, + offer.removal.command_line() + )), + Err(error) => { + let reason = format!("{error:#}"); + say!( + " {} could not remove {}: {reason}", + style("✗").red(), + offer.what + ); + unfinished.push(UnfinishedRepair { + what: format!("remove {}", offer.what), + reason, + remediation: vec![offer.removal.command_line()], + requested: true, + }); + } + } + } +} + /// Print what could not be repaired, with the commands that close it. fn print_unfinished_repairs(unfinished: &[UnfinishedRepair]) { if unfinished.is_empty() { @@ -17811,15 +17957,21 @@ fn observe_language_server_request( } pub async fn doctor(fix: bool, install_language_servers: bool, json: bool) -> Result<()> { - doctor_with(fix, install_language_servers, json, false).await + doctor_with(fix, install_language_servers, json, false, false).await } /// `kin doctor`, with `verbose` asking for every check on a terminal. +/// +/// `remove_other_copies` is the one repair `--fix` never makes by itself: +/// `kin update` runs `kin setup doctor --fix` unattended at the end of its +/// chain, and removing a package someone installed is not something an +/// unattended run decides. It takes this flag, typed by a person. pub async fn doctor_with( fix: bool, install_language_servers: bool, json: bool, verbose: bool, + remove_other_copies: bool, ) -> Result<()> { // The run's one graph status, kept so the language census below reads the // answer the report already fetched rather than asking the daemon again. @@ -17912,6 +18064,41 @@ pub async fn doctor_with( } } + let stamp_needs_fix = report.checks.iter().any(|c| { + c.id == "launcher_version_stamp" + && c.fixable + && !matches!(c.status, crate::commands::health::HealthStatus::Healthy) + }); + if stamp_needs_fix { + use crate::commands::update::LauncherStampRepair; + match crate::commands::update::repair_launcher_stamp() { + Ok(LauncherStampRepair::Rewritten { from, to }) => applied.push(format!( + "rewrote the launcher version stamp from {from} to {to}, the installed release" + )), + Ok(LauncherStampRepair::Left(reason)) => say!( + " {} left the launcher version stamp as it is: {reason}", + style("!").yellow() + ), + Ok(LauncherStampRepair::Absent | LauncherStampRepair::Current) => {} + Err(e) => { + let reason = format!("{e:#}"); + say!( + " {} launcher version stamp repair failed: {reason}", + style("✗").red() + ); + unfinished.push(UnfinishedRepair { + what: "rewrite the launcher version stamp".to_string(), + reason, + remediation: vec![ + "run `kin update`, which rewrites the stamp under its install lock" + .to_string(), + ], + requested: false, + }); + } + } + } + let shell_needs_fix = report.checks.iter().any(|c| { c.id == "shell_path" && c.fixable @@ -18114,6 +18301,10 @@ pub async fn doctor_with( } } + if remove_other_copies { + remove_other_kin_copies(&mut applied, &mut unfinished); + } + // Clean orphaned daemon PID/port files across registered repos. match cleanup_stale_daemons() { Ok(cleaned) if cleaned > 0 => { @@ -19726,6 +19917,9 @@ pub async fn uninstall(all: bool, dry_run: bool, force: bool, json: bool) -> Res Ok(()) } +#[cfg(all(test, unix))] +pub(crate) use tests::write_managed_runtime_fixture; + #[cfg(test)] mod tests { use super::projection_mode_to_record; @@ -20106,8 +20300,38 @@ mod tests { #[tokio::test] async fn post_install_refresh_requires_an_available_enrichment_channel() { + if !kin_core::test_env::alone_in_process() { + return; + } use super::{request_post_install_sweep, PostInstallDaemonRefresh}; - use axum::{http::StatusCode, routing::post, Router}; + use axum::{ + http::StatusCode, + routing::{get, post}, + Router, + }; + + let fixture = tempfile::tempdir().expect("isolated post-install sweep fixture"); + let home = fixture.path().join("home"); + let kin_home = fixture.path().join("kin-home"); + let repo = fixture.path().join("repo"); + for directory in [&home, &kin_home, &repo] { + std::fs::create_dir_all(directory).unwrap(); + } + let _environment = kin_core::test_env::EnvVarGuard::set("HOME", &home) + .with("USERPROFILE", &home) + .with("KIN_HOME", &kin_home) + .with("KIN_RUNTIME_MODE", "development") + .with("KIN_REGISTRY_PATH", kin_home.join("registry.toml")) + .without("KIN_DIR") + .without("KIN_STORE") + .without("KIN_STORE_LAYOUT") + .without("KIN_SESSION_ID") + .without("KIN_DAEMON_URL"); + let layout = kin_core::init(&repo) + .expect("initialize the sweep fixture's real repository") + .layout; + let binding = kin_core::runtime_identity::RuntimeRepositoryBinding::for_layout(&layout) + .expect("capture the sweep fixture's actual repository"); let cases = [ ( @@ -20141,21 +20365,38 @@ mod tests { .await .expect("bind isolated response fixture"); let address = listener.local_addr().unwrap(); - let app = Router::new().route( - "/lsp/sweep", - post(move || async move { (status, axum::Json(body)) }), - ); + let mut identity = + kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon") + .expect("the test runner provides an existing isolated Kin home"); + identity.repository = Some(binding.identity().clone()); + let sweeps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed_sweeps = sweeps.clone(); + let app = Router::new() + .route( + "/runtime/identity", + get(move || { + let identity = identity.clone(); + async move { axum::Json(identity) } + }), + ) + .route( + "/lsp/sweep", + post(move || async move { + observed_sweeps.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + (status, axum::Json(body)) + }), + ); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - let client = crate::daemon_client::DaemonClient::from_base_url_with_explicit_authority( + let client = crate::daemon_client::DaemonClient::from_base_url_for_layout( format!("http://{address}"), - None, - None, + &layout, ) .unwrap(); let observed = request_post_install_sweep(&client).await; server.abort(); + assert_eq!(sweeps.load(std::sync::atomic::Ordering::SeqCst), 1); assert_eq!(observed, expected); let message = observed.message().expect("a running daemon has a notice"); if expected == PostInstallDaemonRefresh::SweepRequested { @@ -25844,7 +26085,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el std::fs::write(&path, b"this is not json {{{").unwrap(); let original = std::fs::read(&path).unwrap(); - let err = merge_mcp_config(&path, "cursor").unwrap_err(); + let err = merge_configuration_fixture_json(&path, "cursor").unwrap_err(); let msg = err.to_string(); assert!( @@ -25873,7 +26114,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let path = dir.path().join("config.json"); std::fs::write(&path, r#"{"existingKey": true}"#).unwrap(); - merge_mcp_config(&path, "cursor").unwrap(); + merge_configuration_fixture_json(&path, "cursor").unwrap(); let content = std::fs::read_to_string(&path).unwrap(); let val: serde_json::Value = serde_json::from_str(&content).unwrap(); @@ -25983,7 +26224,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el }; for (id, profile) in [("cursor", "agent-routed"), ("claude", "agent-default")] { let path = dir.path().join(format!("{id}.json")); - merge_mcp_config(&path, id).unwrap(); + merge_configuration_fixture_json(&path, id).unwrap(); assert_eq!(profile_in(&path).as_deref(), Some(profile), "{id}"); } @@ -25994,6 +26235,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el } } + #[cfg(unix)] #[test] #[serial] fn claude_writer_uses_fallback_only_while_primary_is_absent() { @@ -26003,6 +26245,8 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("home"); let kin_home = dir.path().join("kin-home"); + write_managed_runtime_fixture(&kin_home, "1111111111111111111111111111111111111111"); + let managed = kin_home.join("bin/kin").canonicalize().unwrap(); let fallback = home.join(".claude/config.json"); let primary = home.join(".claude.json"); fs::create_dir_all(fallback.parent().unwrap()).unwrap(); @@ -26015,7 +26259,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let fallback_entry = read_kin_mcp_entry(&fallback).unwrap(); assert_eq!( fallback_entry["command"].as_str(), - env::current_exe().unwrap().to_str() + Some(launcher_spelling(&managed).as_str()) ); assert_eq!(fallback_entry["args"], serde_json::json!(["mcp", "start"])); assert_eq!( @@ -26028,6 +26272,88 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el assert!(read_kin_mcp_entry(&primary).is_some()); } + /// Connecting Claude Code writes the paired edit hooks beside whatever + /// hooks a person keeps, records them in the ledger, changes nothing when + /// it runs again, and takes both back out through the server's teardown. + #[cfg(unix)] + #[test] + #[serial] + fn connecting_claude_code_writes_the_edit_hook_and_teardown_removes_it() { + use crate::commands::setup_ledger::{ + ledger_path, run_uninstall, verify_ledger, ArtifactKind, EntryState, RemovalAction, + }; + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("home"); + let kin_home = dir.path().join("kin-home"); + write_managed_runtime_fixture(&kin_home, "1111111111111111111111111111111111111111"); + let managed = kin_home.join("bin/kin").canonicalize().unwrap(); + let settings = home.join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + let theirs = serde_json::json!({ + "model": "opus", + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "echo ready"}], + }], + "PostToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "echo ran"}], + }], + }, + }); + fs::write(&settings, serde_json::to_vec_pretty(&theirs).unwrap()).unwrap(); + let _home = EnvVarGuard::set("HOME", &home); + let _kin_home = EnvVarGuard::set("KIN_HOME", &kin_home); + + configure_claude_code().unwrap(); + let written: serde_json::Value = + serde_json::from_slice(&fs::read(&settings).unwrap()).unwrap(); + let command = format!( + "{} agents record-edit --client claude", + launcher_spelling(&managed) + ); + for event in ["PreToolUse", "PostToolUse"] { + assert_eq!( + written["hooks"][event], + serde_json::json!([ + theirs["hooks"][event][0], + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [{"type": "command", "command": command, "timeout": 10}], + }, + ]) + ); + } + assert_eq!(written["model"], "opus"); + + let once = fs::read(&settings).unwrap(); + configure_claude_code().unwrap(); + assert_eq!( + fs::read(&settings).unwrap(), + once, + "a second run changes nothing" + ); + + let ledger = ledger_path().unwrap(); + let hooks: Vec<_> = verify_ledger(&ledger) + .unwrap() + .into_iter() + .filter(|verified| verified.entry.kind == ArtifactKind::EditHook) + .collect(); + assert_eq!(hooks.len(), 1, "recorded once"); + assert_eq!(hooks[0].state, EntryState::Verified); + assert_eq!(hooks[0].entry.path, settings); + + let removed = run_uninstall(&ledger, false, false).unwrap(); + assert!(removed.iter().any(|outcome| { + outcome.entry.kind == ArtifactKind::EditHook && outcome.action == RemovalAction::Removed + })); + let after: serde_json::Value = + serde_json::from_slice(&fs::read(&settings).unwrap()).unwrap(); + assert_eq!(after, theirs, "only Kin's groups went"); + } + /// A temp home plus the ledger path a test drives directly. fn reminder_fixture() -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().unwrap(); @@ -26461,11 +26787,11 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el }; let cursor = dir.path().join("cursor.json"); - merge_mcp_config(&cursor, "cursor").unwrap(); + merge_configuration_fixture_json(&cursor, "cursor").unwrap(); assert_eq!(profile_of(&cursor), ("agent-routed".to_string(), false)); // An older Kin wrote agent-default here, and the ledger says so. edit_entry(&cursor, "cursor", true, &set_profile("agent-default")); - merge_mcp_config(&cursor, "cursor").unwrap(); + merge_configuration_fixture_json(&cursor, "cursor").unwrap(); assert_eq!( profile_of(&cursor), ("agent-routed".to_string(), false), @@ -26473,14 +26799,14 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el ); // A person set agent-query after that. edit_entry(&cursor, "cursor", false, &set_profile("agent-query")); - merge_mcp_config(&cursor, "cursor").unwrap(); + merge_configuration_fixture_json(&cursor, "cursor").unwrap(); assert_eq!( profile_of(&cursor), ("agent-query".to_string(), true), "a profile set by hand is kept" ); repair(&cursor, "cursor"); - merge_mcp_config(&cursor, "cursor").unwrap(); + merge_configuration_fixture_json(&cursor, "cursor").unwrap(); assert_eq!( profile_of(&cursor), ("agent-query".to_string(), true), @@ -26489,7 +26815,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el // A person who puts a routed client back on the named belt is heard too. let gemini = dir.path().join("gemini.json"); - merge_mcp_config(&gemini, "gemini").unwrap(); + merge_configuration_fixture_json(&gemini, "gemini").unwrap(); edit_entry(&gemini, "gemini", false, &set_profile("agent-default")); repair(&gemini, "gemini"); assert_eq!(profile_of(&gemini), ("agent-default".to_string(), true)); @@ -26498,7 +26824,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el // has no ledger record to say who chose it, so it moves forward and the // rest of what was changed stays. let windsurf = dir.path().join("windsurf.json"); - merge_mcp_config(&windsurf, "windsurf").unwrap(); + merge_configuration_fixture_json(&windsurf, "windsurf").unwrap(); edit_entry( &windsurf, "windsurf", @@ -26519,7 +26845,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let lmstudio = dir.path().join("lmstudio.json"); { let scope = RequestedProfileScope::enter(Some("agent-routed-query")); - merge_mcp_config(&lmstudio, "lmstudio").unwrap(); + merge_configuration_fixture_json(&lmstudio, "lmstudio").unwrap(); let choice = scope .choice_for(&lmstudio) .expect("the writer noted its choice"); @@ -26534,7 +26860,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el profile_of(&lmstudio), ("agent-routed-query".to_string(), true) ); - merge_mcp_config(&lmstudio, "lmstudio").unwrap(); + merge_configuration_fixture_json(&lmstudio, "lmstudio").unwrap(); repair(&lmstudio, "lmstudio"); assert_eq!( profile_of(&lmstudio), @@ -26564,9 +26890,8 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let kin_home = dir.path().join("kin-home"); let repo = dir.path().join("repo"); fs::create_dir_all(&home).unwrap(); - fs::create_dir_all(kin_home.join("bin")).unwrap(); + write_managed_runtime_fixture(&kin_home, "1111111111111111111111111111111111111111"); fs::create_dir_all(repo.join(".kin")).unwrap(); - fs::copy(env::current_exe().unwrap(), kin_home.join("bin/kin")).unwrap(); let _home = EnvVarGuard::set("HOME", &home); let _kin_home = EnvVarGuard::set("KIN_HOME", &kin_home); let _scan_root = @@ -26806,7 +27131,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el #[cfg(unix)] #[test] #[serial] - fn non_managed_install_setup_health_and_doctor_repair_converge() { + fn setup_health_and_doctor_repair_converge_after_managed_installation() { if !kin_core::test_env::alone_in_process() { return; } @@ -26823,6 +27148,12 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el !kin_home.join("bin/kin").exists(), "fixture must model Homebrew/manual install without a managed launcher" ); + let error = + configure_cursor().expect_err("unmanaged setup must refuse client registration"); + assert!(format!("{error:#}").contains("managed Kin runtime is unavailable")); + assert!(!home.join(".cursor/mcp.json").exists()); + + write_managed_runtime_fixture(&kin_home, "1111111111111111111111111111111111111111"); let config = configure_cursor().unwrap(); let expected = configured_mcp_launcher().unwrap(); let entry = read_kin_mcp_entry(&config).unwrap(); @@ -26859,10 +27190,8 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el ); } - /// `kin update` replaces `/bin/kin` in place, so a client config - /// naming that path is repaired by every update. When it exists, setup - /// registers it rather than whichever binary happens to be running, and - /// has nothing to warn about. + /// Client registrations always point at the admitted pair, regardless of the + /// executable running setup. #[cfg(unix)] #[test] #[serial] @@ -26874,22 +27203,12 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let home = dir.path().join("home"); let kin_home = dir.path().join("kin-home"); fs::create_dir_all(&home).unwrap(); - fs::create_dir_all(kin_home.join("bin")).unwrap(); - let managed = kin_home.join("bin").join("kin"); - fs::copy(env::current_exe().unwrap(), &managed).unwrap(); + write_managed_runtime_fixture(&kin_home, "1111111111111111111111111111111111111111"); let _home = EnvVarGuard::set("HOME", &home); let _kin_home = EnvVarGuard::set("KIN_HOME", &kin_home); - let launcher = resolve_mcp_launcher().unwrap(); - assert_eq!(launcher.origin, McpLauncherOrigin::Managed); + let managed = kin_home.join("bin/kin").canonicalize().unwrap(); assert_eq!(launcher.command, launcher_spelling(&managed)); - assert_ne!( - fs::canonicalize(&launcher.command).unwrap(), - fs::canonicalize(env::current_exe().unwrap()).unwrap(), - "the fixture must be able to tell the managed copy from the running binary" - ); - assert_eq!(unmanaged_mcp_launcher_warning(&launcher, &kin_home), None); - let config = configure_cursor().unwrap(); assert_eq!( read_kin_mcp_entry(&config).unwrap()["command"].as_str(), @@ -26902,7 +27221,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el /// at the index it dispatches on. #[test] #[serial] - fn setup_registers_kin_with_lm_studio_beside_its_other_servers() { + fn lm_studio_config_writer_preserves_other_servers_and_detects_installation() { if !kin_core::test_env::alone_in_process() { return; } @@ -26910,12 +27229,6 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let home = dir.path().join("home"); let kin_home = dir.path().join("kin-home"); fs::create_dir_all(home.join(".lmstudio")).unwrap(); - fs::create_dir_all(kin_home.join("bin")).unwrap(); - fs::copy( - env::current_exe().unwrap(), - kin_home.join("bin").join("kin"), - ) - .unwrap(); fs::write( home.join(".lmstudio").join("mcp.json"), r#"{"mcpServers":{"other":{"command":"other-server"}}}"#, @@ -26924,7 +27237,8 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let _home = EnvVarGuard::set("HOME", &home); let _kin_home = EnvVarGuard::set("KIN_HOME", &kin_home); - let config = configure_lmstudio().unwrap(); + let config = mcp_config_path_for_index(IDX_LMSTUDIO).unwrap(); + merge_configuration_fixture_json(&config, "lmstudio").unwrap(); assert_eq!(config, home.join(".lmstudio").join("mcp.json")); let root: serde_json::Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap(); let kin = &root["mcpServers"]["kin"]; @@ -26945,13 +27259,12 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el assert_eq!(assistants[IDX_GROK].name, "Grok CLI"); } - /// Without a managed binary, setup falls back to the running executable - /// and says so: nothing manages that path, and `kin update` will not - /// repair a client config that points at it. + /// A missing or mismatched pair cannot create a client registration through + /// an executable or PATH fallback. #[cfg(unix)] #[test] #[serial] - fn setup_warns_when_the_registered_launcher_is_unmanaged() { + fn setup_refuses_unmanaged_or_mismatched_runtime_without_client_write() { if !kin_core::test_env::alone_in_process() { return; } @@ -26961,26 +27274,69 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el fs::create_dir_all(&home).unwrap(); let _home = EnvVarGuard::set("HOME", &home); let _kin_home = EnvVarGuard::set("KIN_HOME", &kin_home); - assert!(!kin_home.join("bin").join("kin").exists()); - - let launcher = resolve_mcp_launcher().unwrap(); - assert_eq!(launcher.origin, McpLauncherOrigin::Unmanaged); - assert_eq!( - fs::canonicalize(&launcher.command).unwrap(), - fs::canonicalize(env::current_exe().unwrap()).unwrap() - ); - let warning = unmanaged_mcp_launcher_warning(&launcher, &kin_home) - .expect("an unmanaged launcher must be announced"); - assert!(warning.contains(&launcher.command), "{warning}"); - assert!( - warning.contains(&kin_home.join("bin").join("kin").display().to_string()), - "{warning}" - ); + assert!(resolve_mcp_launcher().is_err()); + assert!(configure_cursor().is_err()); + assert!(!home.join(".cursor/mcp.json").exists()); + write_managed_runtime_fixture(&kin_home, "2222222222222222222222222222222222222222"); + let error = resolve_mcp_launcher().unwrap_err().to_string(); assert!( - warning.contains("`kin update` will not repair"), - "{warning}" + error.contains("managed Kin runtime is unavailable"), + "{error}" ); - assert!(!warning.contains('\u{2014}'), "{warning}"); + assert!(configure_cursor().is_err()); + assert!(!home.join(".cursor/mcp.json").exists()); + } + + #[cfg(unix)] + pub(crate) fn write_managed_runtime_fixture(kin_home: &Path, daemon_sha: &str) { + use std::os::unix::fs::PermissionsExt; + fs::create_dir_all(kin_home.join("bin")).unwrap(); + let cli = serde_json::json!({"schema":"kin.runtime-build-identity.v1","kin_version":"0.8.6","kin_commit":"1111111111111111111111111111111111111111","kin_dirty":false,"kin_source_known":true,"kin_target":"aarch64-apple-darwin","kin_build_profile":"release"}); + let daemon = serde_json::json!({"schema":"kin.daemon.compat.v2","version":"0.8.6","build":{"sha":daemon_sha,"dirty":false,"source_known":true,"target":"aarch64-apple-darwin","build_profile":"release"}}); + for (name, identity) in [("kin", cli), ("kin-daemon", daemon)] { + let path = kin_home.join("bin").join(name); + fs::write(&path, format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", identity)).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + } + + // Writer unit tests supply their launcher explicitly. Managed admission is + // exercised separately by the normal setup entrypoint tests. + fn merge_configuration_fixture_json(path: &PathBuf, target_id: &str) -> Result<()> { + let target = McpRepairTarget { + id: target_id.to_string(), + path: path.clone(), + repo_root: None, + captured_config_sha256: "0".repeat(64), + }; + let command = launcher_spelling(&managed_mcp_launcher_path(&kin_dir()?)); + let lock = ConfigLock::acquire(path)?; + merge_json_mcp_target_locked(&target, &command, &lock) + } + + fn merge_configuration_fixture_toml(path: &PathBuf, repo_root: &Path) -> Result<()> { + merge_configuration_fixture_toml_for(path, repo_root, "codex") + } + + fn merge_configuration_fixture_toml_for( + path: &PathBuf, + repo_root: &Path, + target_id: &str, + ) -> Result<()> { + let command = launcher_spelling(&managed_mcp_launcher_path(&kin_dir()?)); + let lock = ConfigLock::acquire(path)?; + merge_mcp_config_toml_locked(path, repo_root, &lock, target_id, &command) + } + + fn write_configuration_writer_launcher_fixture(kin_home: &Path) { + let path = managed_mcp_launcher_path(kin_home); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"configuration writer fixture, never executed").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } } /// Setup writes these bytes into files a person reads: the discovery @@ -27019,7 +27375,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el ) .unwrap(); - merge_mcp_config_toml_for(&path, &repo, "grok").unwrap(); + merge_configuration_fixture_toml_for(&path, &repo, "grok").unwrap(); let content = fs::read_to_string(&path).unwrap(); assert!( @@ -27060,7 +27416,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el std::fs::write(&path, b"this is not toml [[[").unwrap(); let original = std::fs::read(&path).unwrap(); - let err = merge_mcp_config_toml(&path, &repo).unwrap_err(); + let err = merge_configuration_fixture_toml(&path, &repo).unwrap_err(); let msg = err.to_string(); assert!( @@ -27096,7 +27452,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el ) .unwrap(); - merge_mcp_config_toml(&path, &repo).unwrap(); + merge_configuration_fixture_toml(&path, &repo).unwrap(); let content = std::fs::read_to_string(&path).unwrap(); let root: toml::Value = toml::from_str(&content).unwrap(); @@ -27142,8 +27498,8 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let repo = repo.canonicalize().unwrap(); let path = dir.path().join(".codex").join("config.toml"); - merge_mcp_config_toml(&path, &repo).unwrap(); - merge_mcp_config_toml(&path, &repo).unwrap(); + merge_configuration_fixture_toml(&path, &repo).unwrap(); + merge_configuration_fixture_toml(&path, &repo).unwrap(); let content = std::fs::read_to_string(&path).unwrap(); let root: toml::Value = toml::from_str(&content).unwrap(); @@ -27188,7 +27544,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el ) .unwrap(); - merge_mcp_config_toml(&path, &repo).unwrap(); + merge_configuration_fixture_toml(&path, &repo).unwrap(); let root: toml::Value = toml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); let kin = &root["mcp_servers"]["kin"]; assert_eq!(kin["cwd"].as_str(), repo.to_str()); @@ -27212,7 +27568,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let json = dir.path().join("config.json"); let json_bytes = br#"{"mcpServers":{"kin":{"env":"user-owned"}}}"#; fs::write(&json, json_bytes).unwrap(); - assert!(merge_mcp_config(&json, "cursor").is_err()); + assert!(merge_configuration_fixture_json(&json, "cursor").is_err()); assert_eq!(fs::read(json).unwrap(), json_bytes); let repo = dir.path().join("repo"); @@ -27220,7 +27576,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let toml = dir.path().join("config.toml"); let toml_bytes = b"mcp_servers = 7\n"; fs::write(&toml, toml_bytes).unwrap(); - assert!(merge_mcp_config_toml(&toml, &repo).is_err()); + assert!(merge_configuration_fixture_toml(&toml, &repo).is_err()); assert_eq!(fs::read(toml).unwrap(), toml_bytes); } @@ -29489,8 +29845,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("home"); let kin_home = dir.path().join("kin-home"); - fs::create_dir_all(kin_home.join("bin")).unwrap(); - fs::copy(std::env::current_exe().unwrap(), kin_home.join("bin/kin")).unwrap(); + write_configuration_writer_launcher_fixture(&kin_home); let repo = dir.path().join("repo"); fs::create_dir_all(repo.join(".kin")).unwrap(); let repo = repo.canonicalize().unwrap(); @@ -29544,8 +29899,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("home"); let kin_home = dir.path().join("kin-home"); - fs::create_dir_all(kin_home.join("bin")).unwrap(); - fs::copy(std::env::current_exe().unwrap(), kin_home.join("bin/kin")).unwrap(); + write_configuration_writer_launcher_fixture(&kin_home); let repo_a = dir.path().join("repo-a"); let repo_b = dir.path().join("repo-b"); fs::create_dir_all(repo_a.join(".kin")).unwrap(); @@ -29570,7 +29924,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el .into_iter() .find(|target| target.id == "codex") .expect("Codex target must be captured"); - merge_mcp_config_toml(&config, &repo_b).unwrap(); + merge_configuration_fixture_toml(&config, &repo_b).unwrap(); let rebound_bytes = fs::read(&config).unwrap(); let finalized = std::cell::Cell::new(false); @@ -29596,8 +29950,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("home"); let kin_home = dir.path().join("kin-home"); - fs::create_dir_all(kin_home.join("bin")).unwrap(); - fs::copy(std::env::current_exe().unwrap(), kin_home.join("bin/kin")).unwrap(); + write_configuration_writer_launcher_fixture(&kin_home); let cursor = home.join(".cursor/mcp.json"); let windsurf = home.join(".codeium/windsurf/mcp_config.json"); fs::create_dir_all(cursor.parent().unwrap()).unwrap(); @@ -29614,7 +29967,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el // This is a normal setup writer that wins after the updater's durable // target capture. Finalization must include it, not clear an incomplete // obligation. - merge_mcp_config(&windsurf, "windsurf").unwrap(); + merge_configuration_fixture_json(&windsurf, "windsurf").unwrap(); let finalized = std::cell::Cell::new(false); let repaired = remerge_mcp_targets_exact_with_finalizer(&captured, || { finalized.set(true); @@ -29707,9 +30060,8 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let home = dir.path().join("home"); let kin_home = dir.path().join("kin-home"); let repo = dir.path().join("repo"); - fs::create_dir_all(kin_home.join("bin")).unwrap(); + write_managed_runtime_fixture(&kin_home, "1111111111111111111111111111111111111111"); fs::create_dir_all(repo.join(".kin")).unwrap(); - fs::copy(env::current_exe().unwrap(), kin_home.join("bin/kin")).unwrap(); assert!( !repo.join(".git").exists(), "the fixture is Git-free by construction" @@ -29758,9 +30110,9 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let home = dir.path().join("home"); let kin_home = dir.path().join("kin-home"); let repo = dir.path().join("repo"); - fs::create_dir_all(kin_home.join("bin")).unwrap(); + write_managed_runtime_fixture(&kin_home, "1111111111111111111111111111111111111111"); + let managed = kin_home.join("bin/kin").canonicalize().unwrap(); fs::create_dir_all(repo.join(".kin")).unwrap(); - fs::copy(env::current_exe().unwrap(), kin_home.join("bin/kin")).unwrap(); let legacy = home.join(".gemini/antigravity-ide/mcp_config.json"); fs::create_dir_all(legacy.parent().unwrap()).unwrap(); let legacy_without_kin = @@ -29792,7 +30144,7 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let entry = read_kin_mcp_entry(path).unwrap(); assert_eq!( entry["command"].as_str(), - Some(kin_home.join("bin/kin").to_string_lossy().as_ref()) + Some(launcher_spelling(&managed).as_str()) ); assert_eq!( entry["args"], @@ -29854,11 +30206,10 @@ $value = if ($env:KIN_TEST_PATH_PRESENT -eq '1') { $env:KIN_TEST_PATH_VALUE } el let bound = dir.path().join("bound-repo"); let moved = dir.path().join("bound-repo-moved"); let elsewhere = dir.path().join("another-repo"); - fs::create_dir_all(kin_home.join("bin")).unwrap(); + write_managed_runtime_fixture(&kin_home, "1111111111111111111111111111111111111111"); fs::create_dir_all(bound.join(".kin")).unwrap(); fs::create_dir_all(elsewhere.join(".kin")).unwrap(); fs::create_dir_all(&home).unwrap(); - fs::copy(env::current_exe().unwrap(), kin_home.join("bin/kin")).unwrap(); // The workspace binding refuses a repository without trusted Git // authority, so the fixture is a real one. for repo in [&bound, &elsewhere] { diff --git a/crates/kin-cli/src/commands/setup_hooks.rs b/crates/kin-cli/src/commands/setup_hooks.rs index 75d19dc2f..d7fb96f7b 100644 --- a/crates/kin-cli/src/commands/setup_hooks.rs +++ b/crates/kin-cli/src/commands/setup_hooks.rs @@ -16,6 +16,14 @@ //! first, recommending it, and a non-interactive run writes it only when //! `--prompt-hook` says so. This module only shapes the settings; the //! installer in `setup.rs` reads and writes them. +//! +//! The same settings hold the edit hook, which `kin setup` writes whenever it +//! connects Claude Code. Synchronous `PreToolUse` and `PostToolUse` hooks run +//! `kin agents record-edit` around `Edit`, `Write` and `MultiEdit`: they tell +//! the repository's daemon which session made the edit and capture its before +//! and after state, so the entities the edit changed can name the agent in a +//! review. Kin owns one group in each event list, the one holding that command, +//! and touches no other. use std::path::{Path, PathBuf}; @@ -174,6 +182,179 @@ pub(crate) fn current_prompt_hook(root: &Value) -> Option { .cloned() } +// --------------------------------------------------------------------------- +// The edit hook +// --------------------------------------------------------------------------- + +/// The Claude Code event the edit hook runs after. +pub(crate) const EDIT_HOOK_EVENT: &str = "PostToolUse"; + +/// The Claude Code event the edit hook runs before. +pub(crate) const PRE_EDIT_HOOK_EVENT: &str = "PreToolUse"; + +const EDIT_HOOK_EVENTS: [&str; 2] = [PRE_EDIT_HOOK_EVENT, EDIT_HOOK_EVENT]; + +/// The Claude Code tools the edit hooks run around: the ones that edit code. +pub(crate) const EDIT_HOOK_MATCHER: &str = "Edit|Write|MultiEdit"; + +/// The seconds Claude Code gives the edit hook. The hook sends one local +/// request and gives up on it well inside this. +pub(crate) const EDIT_HOOK_TIMEOUT_SECONDS: u64 = 10; + +/// The command the edit hook runs: the launcher every Kin client entry +/// names, quoted when it carries a space, then `agents record-edit`. +pub(crate) fn edit_hook_command(launcher: &str) -> String { + let arguments = super::agent_edits::EDIT_HOOK_ARGUMENTS; + if launcher.contains(char::is_whitespace) && !launcher.starts_with('"') { + format!("\"{launcher}\" {arguments}") + } else { + format!("{launcher} {arguments}") + } +} + +/// The synchronous group Kin owns in each edit-event list, as Claude Code +/// reads it. Claude Code's default is synchronous when `async` is absent. +pub(crate) fn edit_hook_group(command: &str) -> Value { + json!({ + "matcher": EDIT_HOOK_MATCHER, + "hooks": [{ + "type": "command", + "command": command, + "timeout": EDIT_HOOK_TIMEOUT_SECONDS, + }], + }) +} + +/// Whether a hook entry is Kin's edit hook: a command that ends by running +/// `agents record-edit` for Claude Code. +fn is_kin_edit_hook(hook: &Value) -> bool { + hook.get("command") + .and_then(Value::as_str) + .is_some_and(|command| { + command + .trim_end() + .ends_with(super::agent_edits::EDIT_HOOK_ARGUMENTS) + }) +} + +fn holds_kin_edit_hook(group: &Value) -> bool { + group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|hooks| hooks.iter().any(is_kin_edit_hook)) +} + +/// `root` with Kin's paired edit hooks running `command`, and what that changed. +/// Each group is written whole: its matcher and its one hook. A Kin entry found +/// among a person's own hooks in another group is taken out of that group and +/// their hooks are left as they were. Settings not shaped as Claude Code +/// writes them are refused rather than rewritten. +pub(crate) fn with_edit_hook( + root: Option, + command: &str, +) -> Result<(Value, PromptHookWrite)> { + let mut root = root.unwrap_or_else(|| Value::Object(Map::new())); + let group = edit_hook_group(command); + let Some(object) = root.as_object_mut() else { + bail!("it is not a JSON object"); + }; + let hooks = object + .entry("hooks") + .or_insert_with(|| Value::Object(Map::new())); + let Some(hooks) = hooks.as_object_mut() else { + bail!("its `hooks` value is not an object"); + }; + let mut existing = false; + let mut changed = false; + for event in EDIT_HOOK_EVENTS { + let groups = hooks + .entry(event) + .or_insert_with(|| Value::Array(Vec::new())); + let Some(groups) = groups.as_array_mut() else { + bail!("its `hooks.{event}` value is not a list"); + }; + let held = groups + .iter() + .filter(|held| holds_kin_edit_hook(held)) + .count(); + existing |= held != 0; + if held == 1 && groups.iter().any(|held| *held == group) { + continue; + } + changed = true; + for held in groups.iter_mut() { + if let Some(entries) = held.get_mut("hooks").and_then(Value::as_array_mut) { + entries.retain(|hook| !is_kin_edit_hook(hook)); + } + } + groups.retain(|held| { + held.get("hooks") + .and_then(Value::as_array) + .is_none_or(|entries| !entries.is_empty()) + }); + groups.push(group.clone()); + } + let write = if !changed { + PromptHookWrite::Unchanged + } else if !existing { + PromptHookWrite::Added + } else { + PromptHookWrite::Replaced + }; + Ok((root, write)) +} + +/// `root` without Kin's edit hooks: every Kin entry removed, and a group left +/// with no hooks removed with it. Whether anything was removed. +pub(crate) fn without_edit_hook(root: &mut Value) -> bool { + let mut removed = false; + for event in EDIT_HOOK_EVENTS { + let Some(groups) = root + .get_mut("hooks") + .and_then(|hooks| hooks.get_mut(event)) + .and_then(Value::as_array_mut) + else { + continue; + }; + for group in groups.iter_mut() { + if let Some(hooks) = group.get_mut("hooks").and_then(Value::as_array_mut) { + let before = hooks.len(); + hooks.retain(|hook| !is_kin_edit_hook(hook)); + removed |= hooks.len() != before; + } + } + groups.retain(|group| { + group + .get("hooks") + .and_then(Value::as_array) + .is_none_or(|hooks| !hooks.is_empty()) + }); + } + removed +} + +/// Every group holding Kin's edit hook, keyed by event, when any remain. Both +/// event lists are fingerprinted together, including unexpected duplicates, +/// so a partial pair or changed matcher cannot verify as an intact install. +pub(crate) fn current_edit_hook(root: &Value) -> Option { + let hooks = root.get("hooks")?; + let mut owned = Map::new(); + for event in EDIT_HOOK_EVENTS { + let Some(groups) = hooks.get(event).and_then(Value::as_array) else { + continue; + }; + let groups: Vec<_> = groups + .iter() + .filter(|group| holds_kin_edit_hook(group)) + .cloned() + .collect(); + if !groups.is_empty() { + owned.insert(event.to_owned(), Value::Array(groups)); + } + } + (!owned.is_empty()).then_some(Value::Object(owned)) +} + #[cfg(test)] mod tests { use super::*; @@ -259,4 +440,154 @@ mod tests { "\"/Users/A B/.kin/bin/kin\" ask --hook claude" ); } + + #[test] + fn the_edit_hook_is_its_own_group_beside_every_other_hook() { + let settings = json!({ + "hooks": { + "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo ready"}]}], + "PostToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo ran"}]}], + "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo hi"}]}] + } + }); + let command = edit_hook_command(LAUNCHER); + assert_eq!( + command, + format!("{LAUNCHER} agents record-edit --client claude") + ); + let (root, write) = with_edit_hook(Some(settings), &command).unwrap(); + assert_eq!(write, PromptHookWrite::Added); + for event in EDIT_HOOK_EVENTS { + let groups = root["hooks"][event].as_array().unwrap(); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0]["matcher"], "Bash"); + assert_eq!(groups[1]["matcher"], "Edit|Write|MultiEdit"); + assert_eq!(groups[1]["hooks"][0]["command"], command); + assert_eq!(groups[1]["hooks"][0]["timeout"], 10); + assert!(groups[1]["hooks"][0].get("async").is_none()); + } + assert_eq!( + root["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + "echo ready" + ); + assert_eq!( + root["hooks"]["PostToolUse"][0]["hooks"][0]["command"], + "echo ran" + ); + assert_eq!( + root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], + "echo hi" + ); + assert_eq!( + current_edit_hook(&root), + Some(json!({ + "PreToolUse": [edit_hook_group(&command)], + "PostToolUse": [edit_hook_group(&command)], + })) + ); + + let (again, write) = with_edit_hook(Some(root.clone()), &command).unwrap(); + assert_eq!(write, PromptHookWrite::Unchanged); + assert_eq!(again, root); + + let moved = edit_hook_command("/opt/kin/bin/kin"); + let (mut moved_root, write) = with_edit_hook(Some(root), &moved).unwrap(); + assert_eq!(write, PromptHookWrite::Replaced); + for event in EDIT_HOOK_EVENTS { + let groups = moved_root["hooks"][event].as_array().unwrap(); + assert_eq!(groups.len(), 2, "one Kin group per event, never two"); + assert_eq!(groups[1]["hooks"][0]["command"], moved); + } + + assert!(without_edit_hook(&mut moved_root)); + assert_eq!( + moved_root["hooks"]["PreToolUse"], + json!([{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo ready"}]}]) + ); + assert_eq!( + moved_root["hooks"]["PostToolUse"], + json!([{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo ran"}]}]) + ); + assert!(!without_edit_hook(&mut moved_root)); + assert!(current_edit_hook(&moved_root).is_none()); + } + + #[test] + fn a_kin_edit_hook_a_person_moved_among_their_own_is_taken_back_out() { + let command = edit_hook_command(LAUNCHER); + let theirs = json!({"type": "command", "command": "echo edited"}); + let mixed = json!({ + "matcher": "Edit", + "hooks": [theirs.clone(), {"type": "command", "command": command}], + }); + let settings = json!({"hooks": { + "PreToolUse": [mixed.clone()], + "PostToolUse": [mixed], + }}); + let (mut root, write) = with_edit_hook(Some(settings), &command).unwrap(); + assert_eq!(write, PromptHookWrite::Replaced); + for event in EDIT_HOOK_EVENTS { + assert_eq!( + root["hooks"][event], + json!([{"matcher": "Edit", "hooks": [theirs]}, edit_hook_group(&command)]) + ); + } + assert!(without_edit_hook(&mut root)); + for event in EDIT_HOOK_EVENTS { + assert_eq!( + root["hooks"][event], + json!([{"matcher": "Edit", "hooks": [theirs]}]) + ); + } + for settings in [ + json!([]), + json!({"hooks": []}), + json!({"hooks": {"PreToolUse": {}}}), + json!({"hooks": {"PostToolUse": {}}}), + ] { + assert!( + with_edit_hook(Some(settings.clone()), &command).is_err(), + "{settings}" + ); + } + } + + #[test] + fn a_post_only_install_is_upgraded_without_duplicating_its_group() { + let command = edit_hook_command(LAUNCHER); + let post = edit_hook_group(&command); + let settings = json!({"hooks": {"PostToolUse": [post.clone()]}}); + let (root, write) = with_edit_hook(Some(settings), &command).unwrap(); + assert_eq!(write, PromptHookWrite::Replaced); + assert_eq!(root["hooks"]["PostToolUse"], json!([post])); + assert_eq!( + root["hooks"]["PreToolUse"], + json!([edit_hook_group(&command)]) + ); + let (again, write) = with_edit_hook(Some(root.clone()), &command).unwrap(); + assert_eq!(write, PromptHookWrite::Unchanged); + assert_eq!(again, root); + } + + #[test] + fn duplicate_edit_groups_are_collapsed_and_all_owned_groups_are_observed() { + let command = edit_hook_command(LAUNCHER); + let group = edit_hook_group(&command); + let settings = json!({"hooks": { + "PreToolUse": [group.clone(), group.clone()], + "PostToolUse": [group.clone()], + }}); + assert_eq!( + current_edit_hook(&settings).unwrap()["PreToolUse"] + .as_array() + .unwrap() + .len(), + 2 + ); + let (root, write) = with_edit_hook(Some(settings), &command).unwrap(); + assert_eq!(write, PromptHookWrite::Replaced); + for event in EDIT_HOOK_EVENTS { + assert_eq!(root["hooks"][event], json!([group])); + } + } } diff --git a/crates/kin-cli/src/commands/setup_ledger.rs b/crates/kin-cli/src/commands/setup_ledger.rs index 915856350..a2d60e396 100644 --- a/crates/kin-cli/src/commands/setup_ledger.rs +++ b/crates/kin-cli/src/commands/setup_ledger.rs @@ -64,6 +64,11 @@ pub enum ArtifactKind { /// owned slice is that one hook entry; every other hook and key is the /// person's. PromptHook, + /// The paired edit hook groups Kin added to an agent client's settings: + /// Claude Code's `PreToolUse` and `PostToolUse` groups that run + /// `kin agents record-edit` around its edit tools. The owned slice is those + /// groups; every other hook and key is the person's. + EditHook, } impl ArtifactKind { @@ -86,6 +91,7 @@ impl ArtifactKind { Self::DiscoveryReminder => "discovery reminder", Self::DaemonConfig => "daemon config", Self::PromptHook => "prompt hook", + Self::EditHook => "edit hook", } } } @@ -171,6 +177,22 @@ impl LedgerEntry { } } + /// Build an entry for the edit hook groups Kin added to a client's + /// settings, fingerprinted together by event. + pub fn edit_hook(target: impl Into, path: PathBuf, groups: &serde_json::Value) -> Self { + let now = now_rfc3339(); + Self { + kind: ArtifactKind::EditHook, + target: target.into(), + path, + fingerprint: sha256_hex(groups.to_string().as_bytes()), + snippet: None, + kin_version: kin_version(), + written_at: now.clone(), + updated_at: now, + } + } + /// Build an entry for an appended-marker artifact (rc line, discovery /// reminder), capturing the exact appended text. pub fn appended( @@ -344,6 +366,29 @@ fn current_owned_fingerprint_from_bytes(entry: &LedgerEntry, content: &[u8]) -> let hook = super::setup_hooks::current_prompt_hook(&root)?; Some(sha256_hex(hook.to_string().as_bytes())) } + ArtifactKind::EditHook => { + let root: serde_json::Value = serde_json::from_slice(content).ok()?; + let groups = super::setup_hooks::current_edit_hook(&root)?; + // A legacy install recorded only its post group. Preserve safe + // teardown before setup upgrades it, but never validate a partial + // pair against a newer paired fingerprint. + if groups + .get(super::setup_hooks::PRE_EDIT_HOOK_EVENT) + .is_none() + { + if let Some([group]) = groups + .get(super::setup_hooks::EDIT_HOOK_EVENT) + .and_then(serde_json::Value::as_array) + .map(Vec::as_slice) + { + let legacy = sha256_hex(group.to_string().as_bytes()); + if legacy == entry.fingerprint { + return Some(legacy); + } + } + } + Some(sha256_hex(groups.to_string().as_bytes())) + } ArtifactKind::ShellHook | ArtifactKind::VfsShim | ArtifactKind::DaemonConfig => { Some(sha256_hex(content)) } @@ -633,6 +678,20 @@ fn remove_owned_slice_locked( entry.path.display() )); } + if entry.kind == ArtifactKind::EditHook { + let mut root: serde_json::Value = serde_json::from_slice(&original) + .with_context(|| format!("{} is not valid JSON", entry.path.display()))?; + super::setup_hooks::without_edit_hook(&mut root); + // The settings file stays: Kin removes only its own hook groups. + let mut formatted = + serde_json::to_vec_pretty(&root).context("failed to serialize client settings")?; + formatted.push(b'\n'); + lock.write_guarded(&entry.path, &formatted, Some(&original))?; + return Ok(format!( + "removed Kin's edit hook from {}", + entry.path.display() + )); + } if entry.kind.is_appended_marker() { let snippet = entry .snippet @@ -1007,6 +1066,111 @@ mod tests { assert_eq!(verify_entry(&entry).state, EntryState::Removed); } + #[test] + fn edit_hook_verification_covers_both_phases_and_preserves_modified_settings() { + use super::super::setup_hooks::{current_edit_hook, with_edit_hook, without_edit_hook}; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let command = "/managed/kin agents record-edit --client claude"; + let (root, _) = with_edit_hook(None, command).unwrap(); + write(&path, &root.to_string()); + let entry = + LedgerEntry::edit_hook("claude", path.clone(), ¤t_edit_hook(&root).unwrap()); + assert_eq!(verify_entry(&entry).state, EntryState::Verified); + + let mut unrelated = root.clone(); + unrelated["model"] = serde_json::json!("opus"); + unrelated["hooks"]["PreToolUse"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "matcher": "Bash", "hooks": [{"type": "command", "command": "echo ready"}], + })); + write(&path, &unrelated.to_string()); + assert_eq!(verify_entry(&entry).state, EntryState::Verified); + + let mut modified = unrelated.clone(); + modified["hooks"]["PreToolUse"][0]["hooks"][0]["timeout"] = serde_json::json!(20); + let modified_bytes = modified.to_string(); + write(&path, &modified_bytes); + assert_eq!(verify_entry(&entry).state, EntryState::Modified); + assert_eq!( + uninstall_entry(&entry, false, false).action, + RemovalAction::SkippedModified + ); + assert_eq!(fs::read_to_string(&path).unwrap(), modified_bytes); + + let mut partial = unrelated.clone(); + partial["hooks"] + .as_object_mut() + .unwrap() + .remove("PreToolUse"); + write(&path, &partial.to_string()); + assert_eq!(verify_entry(&entry).state, EntryState::Modified); + + let mut duplicate = unrelated.clone(); + duplicate["hooks"]["PreToolUse"] + .as_array_mut() + .unwrap() + .push(root["hooks"]["PreToolUse"][0].clone()); + write(&path, &duplicate.to_string()); + assert_eq!(verify_entry(&entry).state, EntryState::Modified); + + write(&path, &unrelated.to_string()); + assert_eq!( + uninstall_entry(&entry, false, false).action, + RemovalAction::Removed + ); + without_edit_hook(&mut unrelated); + let after: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!( + after, unrelated, + "both Kin groups removed and the person's sibling retained" + ); + assert_eq!(verify_entry(&entry).state, EntryState::Removed); + } + + #[test] + fn a_legacy_post_only_ledger_can_uninstall_or_upgrade_under_the_same_identity() { + use super::super::setup_hooks::{ + current_edit_hook, edit_hook_group, with_edit_hook, PromptHookWrite, + }; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let command = "/managed/kin agents record-edit --client claude"; + let post = edit_hook_group(command); + let legacy_root = serde_json::json!({"hooks": {"PostToolUse": [post.clone()]}}); + let legacy_entry = LedgerEntry::edit_hook("claude", path.clone(), &post); + write(&path, &legacy_root.to_string()); + assert_eq!(verify_entry(&legacy_entry).state, EntryState::Verified); + assert_eq!( + uninstall_entry(&legacy_entry, false, false).action, + RemovalAction::Removed + ); + + let mut ledger = SetupLedger::default(); + ledger.record(legacy_entry.clone()); + let (root, write_kind) = with_edit_hook(Some(legacy_root), command).unwrap(); + assert_eq!(write_kind, PromptHookWrite::Replaced); + write(&path, &root.to_string()); + assert_eq!(verify_entry(&legacy_entry).state, EntryState::Modified); + ledger.record(LedgerEntry::edit_hook( + "claude", + path, + ¤t_edit_hook(&root).unwrap(), + )); + assert_eq!(ledger.entries.len(), 1); + assert_eq!(ledger.entries[0].written_at, legacy_entry.written_at); + assert_ne!(ledger.entries[0].fingerprint, legacy_entry.fingerprint); + assert_eq!(verify_entry(&ledger.entries[0]).state, EntryState::Verified); + assert_eq!( + uninstall_entry(&ledger.entries[0], false, false).action, + RemovalAction::Removed + ); + } + #[test] fn verify_mcp_entry_states() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/kin-cli/src/commands/setup_short.rs b/crates/kin-cli/src/commands/setup_short.rs index 83aca4fbb..3cd0e07a2 100644 --- a/crates/kin-cli/src/commands/setup_short.rs +++ b/crates/kin-cli/src/commands/setup_short.rs @@ -49,6 +49,8 @@ enum Asked { Path, Servers, Model, + /// Whether to remove the other copy of Kin at this offer's position. + RemoveCopy(usize), } /// One question and, when a flag already answered it, that answer. @@ -71,8 +73,20 @@ pub(super) async fn run(opts: WizardOptions, interactive: bool) -> Result<()> { let in_repo = current_initialized_setup_repo("setup").is_ok(); let model = crate::embed_model::EmbedModelFetch::probe(false); let machine = screen::this_machine(); + // Every other copy of Kin, each asked about with the default to keep it. + let copies = crate::commands::kin_copies::ScanRoots::from_environment() + .ok() + .map(|roots| { + let scan = crate::commands::kin_copies::scan(&roots); + (roots, scan) + }); + let copy_offers = copies + .as_ref() + .map(|(_, scan)| scan.removal_offers()) + .unwrap_or_default(); - let steps = steps(&opts, &assistants, shell_name, &model, kin_home.as_deref()); + let mut steps = steps(&opts, &assistants, shell_name, &model, kin_home.as_deref()); + steps.extend(copy_removal_steps(©_offers)); let to_ask: Vec<&Step> = steps.iter().filter(|step| step.preset.is_none()).collect(); // The logo, the welcome and the first question have to fit on screen // together, so a short terminal gets the compact logo. @@ -260,6 +274,17 @@ pub(super) async fn run(opts: WizardOptions, interactive: bool) -> Result<()> { } } record_language_tool_dirs_in_wizard(); + let copy_removals = copies.as_ref().map(|(roots, scan)| { + super::offer_other_copy_removals( + scan, + interactive, + |index, _| answer(Asked::RemoveCopy(index)) == Some(true), + |offer| { + note("removing another copy of Kin"); + crate::commands::kin_copies::remove(offer, roots) + }, + ) + }); // macOS's request waits for an answer, so the live line says what setup // is waiting on for as long as it waits. let notifications = report_notification_identity(interactive, || match &live { @@ -294,6 +319,7 @@ pub(super) async fn run(opts: WizardOptions, interactive: bool) -> Result<()> { report: &report, in_repo, defaults_taken: if interactive { 0 } else { skipped.len() }, + copy_removals: copy_removals.as_ref(), }); println!(); for line in completion_lines(style, &summary, screen::terminal_width()) { @@ -311,6 +337,42 @@ pub(super) async fn run(opts: WizardOptions, interactive: bool) -> Result<()> { } } +/// One question per other copy of Kin that its own channel can remove. +/// +/// Never answered by a flag and always defaulting to keep, so a run with no +/// one at the terminal names the copies and removes none of them. +fn copy_removal_steps(offers: &[crate::commands::kin_copies::RemovalOffer]) -> Vec { + offers + .iter() + .enumerate() + .map(|(index, offer)| { + let paths: Vec = offer + .paths + .iter() + .map(|path| screen::home_relative(path)) + .collect(); + Step { + asked: Asked::RemoveCopy(index), + question: Question::yes_no( + "Other Kin", + format!("Remove the {}?", offer.what), + ("Yes, remove it", &format!("remove {}", offer.what)), + ("No, keep it", &format!("keep {}", offer.what)), + false, + ) + .consent(format!( + "Runs `{}`. The managed Kin in ~/.kin/bin stays as it is.", + offer.removal.command_line() + )) + .note(join_names( + &paths.iter().map(String::as_str).collect::>(), + )), + preset: None, + } + }) + .collect() +} + /// The questions this machine needs asked, each answered already when a flag /// gave the answer. fn steps( @@ -851,6 +913,8 @@ struct Observed<'a> { /// Decisions a run without a terminal took without asking, which the /// completion counts and `--verbose` lists. defaults_taken: usize, + /// What became of the other copies of Kin this run asked about. + copy_removals: Option<&'a super::CopyRemovals>, } /// Something that needs the person, and what fixes it. @@ -975,6 +1039,15 @@ fn summarize(observed: Observed<'_>) -> Summary { if let Some(item) = observed.notifications.and_then(notification_attention) { attention.push(item); } + if let Some(removals) = observed.copy_removals { + for (offer, reason) in &removals.failed { + attention.push(Attention { + status: Status::Fail, + what: format!("The {} was not removed: {reason}", offer.what), + fix: Some(offer.removal.command_line()), + }); + } + } let needs: Vec<&crate::commands::health::HealthCheck> = observed .report .checks @@ -1037,6 +1110,17 @@ fn summarize(observed: Observed<'_>) -> Summary { if !observed.installed_servers.is_empty() { facts.push(("Installed", observed.installed_servers.join(", "))); } + if let Some(removals) = observed + .copy_removals + .filter(|removals| !removals.removed.is_empty()) + { + let removed: Vec<&str> = removals + .removed + .iter() + .map(|offer| offer.what.as_str()) + .collect(); + facts.push(("Removed", join_names(&removed))); + } if observed.defaults_taken > 0 { facts.push(( "Defaults", @@ -1438,6 +1522,7 @@ mod tests { report: &report, in_repo: false, defaults_taken: 0, + copy_removals: None, }); assert_eq!(summary.attention.len(), usize::from(missing)); let lines = completion_lines(Style::plain(), &summary, 60); @@ -1661,6 +1746,7 @@ mod tests { report: &report, in_repo: false, defaults_taken: 0, + copy_removals: None, }); let text = completion_lines(Style::plain(), &summary, 80).join("\n"); assert_eq!( @@ -1707,6 +1793,7 @@ mod tests { report: &report, in_repo: false, defaults_taken: 0, + copy_removals: None, }); let text = completion_lines(Style::plain(), &summary, 80).join("\n"); assert!( @@ -1750,6 +1837,7 @@ mod tests { report: &report, in_repo: false, defaults_taken: 0, + copy_removals: None, }); completion_lines(Style::plain(), &summary, 80).join("\n") }; @@ -1879,4 +1967,113 @@ mod tests { assert!(console::measure_text_width(line) < 60, "{line:?}"); } } + + fn other_copies() -> crate::commands::kin_copies::CopyScan { + use crate::commands::kin_copies::{CopyKind, CopyScan, KinCopy, Removal}; + + let copy = |path: &str, kind: CopyKind, version: &str, removal: Removal| KinCopy { + path: PathBuf::from(path), + program: "kin", + target: None, + kind, + version: Some(version.to_string()), + channel: None, + removal: Some(removal), + first_on_path: false, + in_kin_home: false, + }; + CopyScan { + kin_home: PathBuf::from("/fixture/.kin"), + managed_version: Some("0.8.7".to_string()), + copies: vec![ + copy( + "/node/bin/kin", + CopyKind::NpmLauncher { + package: "@kinlab/kin".to_string(), + }, + "0.6.6", + Removal::Npm { + prefix: PathBuf::from("/node"), + package: "@kinlab/kin".to_string(), + }, + ), + copy( + "/cargo/bin/kin", + CopyKind::IndependentRelease, + "0.7.18", + Removal::Cargo { + root: PathBuf::from("/cargo"), + package: "kin-cli".to_string(), + }, + ), + ], + } + } + + #[test] + fn a_run_with_no_one_at_the_terminal_names_other_copies_and_removes_none() { + let scan = other_copies(); + let outcome = super::super::offer_other_copy_removals( + &scan, + false, + |_, _| panic!("a run with no terminal asked about a copy"), + |_| panic!("a run with no terminal removed a copy"), + ); + assert!(outcome.removed.is_empty()); + assert!(outcome.failed.is_empty()); + assert_eq!( + outcome + .kept + .iter() + .map(|offer| offer.what.as_str()) + .collect::>(), + [ + "npm launcher @kinlab/kin 0.6.6", + "cargo package kin-cli 0.7.18" + ] + ); + + // The short form asks one question per copy. No flag answers them and + // each defaults to keeping the copy, which is what a run with no + // terminal takes. + let steps = copy_removal_steps(&scan.removal_offers()); + assert_eq!(steps.len(), 2); + for step in &steps { + assert!(step.preset.is_none()); + assert!(!step.question.choices[step.question.default].value); + } + assert_eq!(steps[0].asked, Asked::RemoveCopy(0)); + assert_eq!( + steps[1].question.title, + "Remove the cargo package kin-cli 0.7.18?" + ); + assert!(steps[1].question.consent[0].contains("cargo uninstall --root /cargo kin-cli")); + } + + #[test] + fn a_person_removes_only_the_copies_they_say_yes_to() { + let scan = other_copies(); + let mut ran = Vec::new(); + let outcome = super::super::offer_other_copy_removals( + &scan, + true, + |index, _| index == 1, + |offer| { + ran.push(offer.removal.command_line()); + Ok(()) + }, + ); + assert_eq!(ran, ["cargo uninstall --root /cargo kin-cli"]); + assert_eq!(outcome.removed.len(), 1); + assert_eq!(outcome.kept.len(), 1); + + let outcome = super::super::offer_other_copy_removals( + &scan, + true, + |_, _| true, + |_| anyhow::bail!("npm is not installed on this machine"), + ); + assert_eq!(outcome.failed.len(), 2); + assert!(outcome.failed[0].1.contains("npm is not installed")); + } } diff --git a/crates/kin-cli/src/commands/status.rs b/crates/kin-cli/src/commands/status.rs index f64786df7..3eece3ace 100644 --- a/crates/kin-cli/src/commands/status.rs +++ b/crates/kin-cli/src/commands/status.rs @@ -3029,7 +3029,25 @@ mod tests { .to_string(); let status_reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counted = std::sync::Arc::clone(&status_reads); + let supervisor_home = tempfile::tempdir().unwrap(); + let mut env = kin_core::test_env::EnvVarGuard::new(); + without_a_supervisor(&mut env, supervisor_home.path()); + let mut identity = + kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon").unwrap(); + identity.repository = Some( + kin_core::runtime_identity::RuntimeRepositoryBinding::for_layout(&layout) + .expect("capture the fixture daemon's actual selected repository") + .identity() + .clone(), + ); let app = axum::Router::new() + .route( + "/runtime/identity", + axum::routing::get(move || { + let identity = identity.clone(); + async move { axum::Json(identity) } + }), + ) .route( "/readiness", axum::routing::get(|| async { @@ -3071,10 +3089,6 @@ mod tests { ) .unwrap(); std::fs::write(layout.root().join("daemon.port"), port.to_string()).unwrap(); - let supervisor_home = tempfile::tempdir().unwrap(); - let mut env = kin_core::test_env::EnvVarGuard::new(); - without_a_supervisor(&mut env, supervisor_home.path()); - let daemon = crate::daemon_client::running_daemon_reading(&layout).await; assert!( matches!( diff --git a/crates/kin-cli/src/commands/traffic.rs b/crates/kin-cli/src/commands/traffic.rs index 6b115fde3..0cd150c0a 100644 --- a/crates/kin-cli/src/commands/traffic.rs +++ b/crates/kin-cli/src/commands/traffic.rs @@ -3,21 +3,27 @@ use anyhow::{Context, Result}; -async fn daemon_base_url() -> Result { +/// The daemon serving this repository, with the authority and runtime +/// admission it checks on every request. +async fn daemon() -> Result { let layout = crate::commands::require_repository_layout()?; - crate::daemon_client::resolve_daemon_url(&layout) + let url = crate::daemon_client::resolve_daemon_url(&layout) .await? - .ok_or_else(|| crate::daemon_client::daemon_required_error("traffic commands", &layout)) + .ok_or_else(|| crate::daemon_client::daemon_required_error("traffic commands", &layout))?; + crate::daemon_client::DaemonClient::from_base_url_for_layout(url, &layout) } /// `kin traffic show ` — Show active traffic via daemon API. pub async fn run(scope: String) -> Result<()> { - let client = reqwest::Client::new(); - let daemon_url = daemon_base_url().await?; - - let resp = client - .get(format!("{}/traffic/{}", daemon_url, urlencoded(&scope))) - .send() + let daemon = daemon().await?; + + let resp = daemon + .send_untyped( + reqwest::Method::GET, + &format!("/traffic/{}", urlencoded(&scope)), + "traffic", + None, + ) .await .context("query daemon traffic endpoint")?; if resp.status().is_success() { @@ -106,12 +112,10 @@ pub async fn run(scope: String) -> Result<()> { /// `kin traffic sessions` — List active sessions via daemon API. pub async fn sessions() -> Result<()> { - let client = reqwest::Client::new(); - let daemon_url = daemon_base_url().await?; + let daemon = daemon().await?; - let resp = client - .get(format!("{}/session", daemon_url)) - .send() + let resp = daemon + .send_untyped(reqwest::Method::GET, "/session", "sessions", None) .await .context("query daemon sessions endpoint")?; if resp.status().is_success() { diff --git a/crates/kin-cli/src/commands/update.rs b/crates/kin-cli/src/commands/update.rs index 7e8654d6a..39139c956 100644 --- a/crates/kin-cli/src/commands/update.rs +++ b/crates/kin-cli/src/commands/update.rs @@ -23,6 +23,10 @@ use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System, U #[path = "update_windows.rs"] pub(crate) mod windows_update; +#[path = "update_bootstrap.rs"] +mod bootstrap_update; +pub use bootstrap_update::run_bootstrap_published; + const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const GITHUB_RELEASES_LATEST_URL: &str = "https://api.github.com/repos/kinlab-ai/kin/releases/latest"; @@ -752,6 +756,12 @@ struct RestartPending { /// and a fresh process scan proved every managed server was quiescent. #[serde(default, skip_serializing_if = "Option::is_none")] commit_runtime_fence: Option>, + /// Exact process incarnations of MCP transports deliberately preserved by + /// the update. Their continued life keeps runtime readiness pending; this + /// field never authorizes a signal or treats a pathname as an image proof. + /// None identifies an older schema-3 marker that needs quiescent recovery. + #[serde(default, skip_serializing_if = "Option::is_none")] + retained_mcp_sessions: Option>, reason: String, runtime_obligations: Vec, } @@ -1011,6 +1021,452 @@ fn detect_install_method(kin_home: &Path) -> InstallMethod { ) } +/// A launcher stamp larger than this is not one any launcher wrote. +const MAX_LAUNCHER_STAMP_BYTES: usize = 256; +/// The permission bits of a stamp the updater writes. +const LAUNCHER_STAMP_MODE: u32 = 0o644; + +/// The stamp's bytes for `version`, in the form the npm launcher writes. +fn launcher_stamp_bytes(version: &str) -> Vec { + format!("{version}\n").into_bytes() +} + +/// The release a stamp records, when it records a readable one. +fn launcher_stamp_version(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + parse_release_version(text.trim()).ok() +} + +/// What sits where the npm launcher keeps its version stamp. +#[derive(Clone, Debug, PartialEq, Eq)] +enum LauncherStampFile { + Absent, + Recorded { + bytes: Vec, + /// Permission bits, where the platform has them. + mode: Option, + }, + /// Something no launcher writes there: a link, a directory, or a file + /// larger than any version. It is reported and never rewritten. + Unusable(String), +} + +fn launcher_stamp_path(kin_home: &Path) -> PathBuf { + kin_home.join("bin").join(NPM_LAUNCHER_STAMP) +} + +/// Read the stamp by path. The transaction uses its anchored twin on Unix; +/// this one serves the read-only health report and the non-Unix updater. +fn read_launcher_stamp_path(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(LauncherStampFile::Absent) + } + Err(error) => { + return Err(error).with_context(|| { + format!("failed to inspect the launcher stamp {}", path.display()) + }) + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Ok(LauncherStampFile::Unusable( + "it is not a regular file".to_string(), + )); + } + if metadata.len() > MAX_LAUNCHER_STAMP_BYTES as u64 { + return Ok(LauncherStampFile::Unusable(format!( + "it holds {} bytes, more than any version", + metadata.len() + ))); + } + let mut file = File::open(path) + .with_context(|| format!("failed to open the launcher stamp {}", path.display()))?; + let bytes = read_bounded_archive_entry( + &mut file, + metadata.len(), + MAX_LAUNCHER_STAMP_BYTES as u64, + "launcher stamp", + )?; + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + Some(metadata.permissions().mode() & 0o777) + }; + #[cfg(not(unix))] + let mode = None; + Ok(LauncherStampFile::Recorded { bytes, mode }) +} + +#[cfg(unix)] +fn read_launcher_stamp_at(bin: &AnchoredDir) -> Result { + let Some(stat) = bin.stat_entry(NPM_LAUNCHER_STAMP)? else { + return Ok(LauncherStampFile::Absent); + }; + if rustix::fs::FileType::from_raw_mode(stat.st_mode) != rustix::fs::FileType::RegularFile { + return Ok(LauncherStampFile::Unusable( + "it is not a regular file".to_string(), + )); + } + if stat.st_size as u64 > MAX_LAUNCHER_STAMP_BYTES as u64 { + return Ok(LauncherStampFile::Unusable(format!( + "it holds {} bytes, more than any version", + stat.st_size + ))); + } + let Some((mut file, opened)) = bin.open_regular(NPM_LAUNCHER_STAMP, "launcher stamp")? else { + return Ok(LauncherStampFile::Absent); + }; + let bytes = read_bounded_archive_entry( + &mut file, + opened.st_size as u64, + MAX_LAUNCHER_STAMP_BYTES as u64, + "launcher stamp", + )?; + bin.ensure_regular_binding( + NPM_LAUNCHER_STAMP, + opened.st_dev as u64, + opened.st_ino as u64, + )?; + Ok(LauncherStampFile::Recorded { + bytes, + mode: Some(opened.st_mode as u32 & 0o777), + }) +} + +/// Remove scratch files an interrupted stamp write left in `bin/`. +/// +/// Only the updater's own scratch names are touched, and only under the +/// install lock, which every updater stamp write holds. A launcher's scratch +/// file is named differently and may belong to a write in progress. +#[cfg(unix)] +fn remove_stale_launcher_stamp_scratch_at(install: &InstallLayout) -> Result<()> { + let prefix = format!(".{NPM_LAUNCHER_STAMP}.tmp-"); + for name in install.bin.entry_names()? { + if !is_canonical_random_uuid_suffix(&name, &prefix) { + continue; + } + let Some(stat) = install.bin.stat_entry(&name)? else { + continue; + }; + if rustix::fs::FileType::from_raw_mode(stat.st_mode) != rustix::fs::FileType::RegularFile { + continue; + } + install.ensure_bound()?; + install + .bin + .ensure_regular_binding(&name, stat.st_dev as u64, stat.st_ino as u64)?; + install.bin.unlink_file(&name)?; + } + Ok(()) +} + +/// The build identity a Kin executable carries in its bytes. +/// +/// Read from the static identity the release build embeds, so the executable +/// is never run. Builds older than that identity, and anything that is not a +/// Kin executable, answer an error. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExecutableBuildIdentity { + pub(crate) version: String, + pub(crate) commit: String, + pub(crate) clean: bool, + pub(crate) source_known: bool, +} + +impl From for ExecutableBuildIdentity { + fn from(identity: StaticBuildIdentity) -> Self { + Self { + version: identity.version, + commit: identity.commit, + clean: identity.clean, + source_known: identity.source_known, + } + } +} + +/// Read the static build identity of the executable at `path`. +/// +/// A release executable is tens of megabytes, and one `kin doctor` asks about +/// the managed CLI from several rows, so answers are kept for the life of the +/// process under the file's size, modification time and, on Unix, its inode. +/// A file replaced by an update is a different key and is read again. +pub(crate) fn executable_build_identity(path: &Path) -> Result { + type Key = (PathBuf, u64, Option, u64, u64); + static ANSWERS: std::sync::OnceLock< + std::sync::Mutex>>, + > = std::sync::OnceLock::new(); + + let metadata = fs::metadata(path) + .with_context(|| format!("failed to inspect executable {}", path.display()))?; + if !metadata.is_file() { + anyhow::bail!("{} is not a regular file", path.display()); + } + #[cfg(unix)] + let (device, inode) = { + use std::os::unix::fs::MetadataExt; + (metadata.dev(), metadata.ino()) + }; + #[cfg(not(unix))] + let (device, inode) = (0, 0); + let key: Key = ( + path.to_path_buf(), + metadata.len(), + metadata.modified().ok(), + device, + inode, + ); + let answers = ANSWERS.get_or_init(Default::default); + if let Some(answer) = answers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + { + return answer.clone().map_err(anyhow::Error::msg); + } + let answer = (|| -> Result { + let mut file = + File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let bytes = read_bounded_archive_entry( + &mut file, + metadata.len(), + MAX_ARCHIVE_ENTRY_BYTES, + &format!("executable {}", path.display()), + )?; + Ok(parse_static_build_identity(&bytes)?.into()) + })() + .map_err(|error| format!("{error:#}")); + answers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key, answer.clone()); + answer.map_err(anyhow::Error::msg) +} + +fn installed_cli_component(spec: &[ComponentSpec]) -> Result { + let (cli_name, _) = static_identity_component_names(spec)?; + spec.iter() + .copied() + .find(|component| component.name == cli_name) + .context("platform bundle contract has no CLI component") +} + +/// The release of the CLI installed in the locked root, from its own bytes. +#[cfg(unix)] +fn installed_cli_version_at(install: &InstallLayout, spec: &[ComponentSpec]) -> Result { + let component = installed_cli_component(spec)?; + let directory = install.component_dir(component); + let Some((mut file, stat)) = directory.open_regular(component.name, "installed CLI")? else { + anyhow::bail!("no CLI is installed"); + }; + let bytes = read_bounded_archive_entry( + &mut file, + stat.st_size as u64, + MAX_ARCHIVE_ENTRY_BYTES, + "installed CLI", + )?; + directory.ensure_regular_binding(component.name, stat.st_dev as u64, stat.st_ino as u64)?; + parse_release_version(&parse_static_build_identity(&bytes)?.version) +} + +/// What the stamp says beside what is installed, read without any lock. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum LauncherStampInspection { + /// No stamp: the platform installer put this Kin here, and nothing reads + /// a version from a file. + Absent, + /// The stamp names the installed release. + Current { path: PathBuf, version: String }, + /// The stamp names another release, or none it can parse. + Disagrees { + path: PathBuf, + recorded: String, + installed: String, + }, + /// The installed CLI carries no build identity this Kin can read, so the + /// stamp cannot be checked against it. + Unverifiable { + path: PathBuf, + recorded: String, + reason: String, + }, + /// Something other than a stamp sits at the stamp's path. + Unusable { path: PathBuf, reason: String }, +} + +fn recorded_stamp_text(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes); + let trimmed = text.trim(); + if trimmed.is_empty() { + "nothing".to_string() + } else { + trimmed.to_string() + } +} + +/// Compare the launcher stamp with the installed CLI, reading both from disk. +pub(crate) fn inspect_launcher_stamp(kin_home: &Path) -> LauncherStampInspection { + let path = launcher_stamp_path(kin_home); + let bytes = match read_launcher_stamp_path(&path) { + Ok(LauncherStampFile::Absent) => return LauncherStampInspection::Absent, + Ok(LauncherStampFile::Unusable(reason)) => { + return LauncherStampInspection::Unusable { path, reason } + } + Err(error) => { + return LauncherStampInspection::Unusable { + path, + reason: format!("{error:#}"), + } + } + Ok(LauncherStampFile::Recorded { bytes, .. }) => bytes, + }; + let recorded = recorded_stamp_text(&bytes); + let installed = platform_bundle_spec(std::env::consts::OS) + .and_then(installed_cli_component) + .and_then(|component| executable_build_identity(&component_path(kin_home, component))) + .and_then(|identity| parse_release_version(&identity.version)); + match installed { + Err(error) => LauncherStampInspection::Unverifiable { + path, + recorded, + reason: format!("{error:#}"), + }, + Ok(installed) if launcher_stamp_version(&bytes).as_ref() == Some(&installed) => { + LauncherStampInspection::Current { + path, + version: installed.to_string(), + } + } + Ok(installed) => LauncherStampInspection::Disagrees { + path, + recorded, + installed: installed.to_string(), + }, + } +} + +/// What a locked stamp repair did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum LauncherStampRepair { + /// There is no stamp, and none is created. + Absent, + /// The stamp already names the installed release. + Current, + /// The stamp named `from` and now names the installed release `to`. + Rewritten { from: String, to: String }, + /// Left as it is, and why. + Left(String), +} + +/// Make an existing stamp name the release installed in the locked root. +/// +/// Runs under the install lock after every recovery, so a stamp an older +/// updater left behind, or one a recovery could not reach, is corrected the +/// next time anything takes the lock. A stamp is never created, and anything +/// at its path that is not a stamp is left for a person. +fn repair_launcher_stamp_locked( + lock: &InstallRootLock, + spec: &[ComponentSpec], +) -> Result { + #[cfg(unix)] + { + let install = lock.install()?; + remove_stale_launcher_stamp_scratch_at(install)?; + let bytes = match read_launcher_stamp_at(&install.bin)? { + LauncherStampFile::Absent => return Ok(LauncherStampRepair::Absent), + LauncherStampFile::Unusable(reason) => return Ok(LauncherStampRepair::Left(reason)), + LauncherStampFile::Recorded { bytes, .. } => bytes, + }; + let installed = match installed_cli_version_at(install, spec) { + Ok(installed) => installed, + Err(error) => { + return Ok(LauncherStampRepair::Left(format!( + "the installed CLI's release could not be read: {error:#}" + ))) + } + }; + if launcher_stamp_version(&bytes).as_ref() == Some(&installed) { + return Ok(LauncherStampRepair::Current); + } + install.ensure_bound()?; + install + .bin + .atomic_write_checked( + NPM_LAUNCHER_STAMP, + &launcher_stamp_bytes(&installed.to_string()), + LAUNCHER_STAMP_MODE, + || install.ensure_bound(), + ) + .context("failed to rewrite the launcher version stamp")?; + Ok(LauncherStampRepair::Rewritten { + from: recorded_stamp_text(&bytes), + to: installed.to_string(), + }) + } + #[cfg(not(unix))] + { + let path = launcher_stamp_path(lock.root()); + let bytes = match read_launcher_stamp_path(&path)? { + LauncherStampFile::Absent => return Ok(LauncherStampRepair::Absent), + LauncherStampFile::Unusable(reason) => return Ok(LauncherStampRepair::Left(reason)), + LauncherStampFile::Recorded { bytes, .. } => bytes, + }; + let installed = match installed_cli_component(spec) + .and_then(|component| { + executable_build_identity(&component_path(lock.root(), component)) + }) + .and_then(|identity| parse_release_version(&identity.version)) + { + Ok(installed) => installed, + Err(error) => { + return Ok(LauncherStampRepair::Left(format!( + "the installed CLI's release could not be read: {error:#}" + ))) + } + }; + if launcher_stamp_version(&bytes).as_ref() == Some(&installed) { + return Ok(LauncherStampRepair::Current); + } + write_file_atomically( + &path, + &launcher_stamp_bytes(&installed.to_string()), + LAUNCHER_STAMP_MODE, + ) + .context("failed to rewrite the launcher version stamp")?; + Ok(LauncherStampRepair::Rewritten { + from: recorded_stamp_text(&bytes), + to: installed.to_string(), + }) + } +} + +/// `kin doctor --fix`'s stamp repair: take the install lock and correct it. +/// +/// An interrupted update owns the stamp until it is recovered, and a running +/// install mutation writes the stamp itself, so both leave it alone and say +/// so rather than racing them. +pub(crate) fn repair_launcher_stamp() -> Result { + let requested_home = crate::commands::setup::kin_dir()?; + let spec = platform_bundle_spec(std::env::consts::OS)?; + let lock = match InstallRootLock::acquire_existing(&requested_home) { + Ok(lock) => lock, + Err(error) if error.downcast_ref::().is_some() => { + return Ok(LauncherStampRepair::Left( + "another Kin install is running, and it writes the stamp itself".to_string(), + )) + } + Err(error) => return Err(error), + }; + if !transaction_dirs(lock.root())?.is_empty() { + return Ok(LauncherStampRepair::Left( + "an interrupted update needs recovery first; `kin update` recovers it and then the \ + stamp" + .to_string(), + )); + } + repair_launcher_stamp_locked(&lock, spec) +} + /// Why a check-only report has no verdict, as a stable code and a sentence a /// person can act on. The codes are the contract; the sentences may change. #[derive(Debug, Clone, PartialEq, Eq)] @@ -1209,6 +1665,7 @@ async fn run_check_only_json( Vec::new(), false, false, + None, ) .await { @@ -1242,7 +1699,24 @@ pub async fn run( dry_run: bool, unattended: bool, force_window: bool, + prepare_windows_worker: bool, + windows_worker: Option, ) -> Result<()> { + if prepare_windows_worker { + return prepare_windows_update_worker(); + } + if windows_worker.is_some() + && (skip_verify + || check_only + || json + || ack_restart + || set_policy.is_some() + || dry_run + || unattended + || force_window) + { + anyhow::bail!("a Windows update worker only accepts a mutating update and release pins"); + } // Setting the policy is a preference write and nothing else. It runs before // any expectation parsing, network client, or install-root inspection so // that changing how updates arrive never depends on one being available. @@ -1277,6 +1751,7 @@ pub async fn run( runtime_sessions, apply, dry_run, + windows_worker.as_deref(), ) .await?; @@ -1299,6 +1774,7 @@ async fn run_update_flow( runtime_sessions: Vec, apply: bool, dry_run: bool, + windows_worker: Option<&Path>, ) -> Result<()> { // A dry run of the chain is a read. Forcing the check-only path is what // makes that a property of the code rather than a promise in the help text: @@ -1316,7 +1792,9 @@ async fn run_update_flow( "release pins require checksum and provenance verification; remove --skip-verify" ); } - ensure_mutating_update_supported(std::env::consts::OS, check_only)?; + if !ack_restart && windows_worker.is_none() { + ensure_mutating_update_supported(std::env::consts::OS, check_only)?; + } registry_authority_preflight()?; if ack_restart { return acknowledge_runtime_restart(&parse_runtime_session_evidence(&runtime_sessions)?); @@ -1352,9 +1830,14 @@ async fn run_update_flow( // after authenticated preflight closes the race before mutation. refuse_restart_marker_before_remote_preflight(&inspected_home)?; } - if !check_only { + if !check_only && !stale.is_empty() { + // Recovery moves managed bytes, so it never runs under a daemon. ensure_no_active_managed_runtimes(&inspected_home, spec).context( - "initial update preflight requires every managed serving executable to be stopped; no recovery, cleanup, repair, preference mutation, or release download was attempted", + "recovering an interrupted update requires every managed serving executable to be stopped; no recovery, cleanup, repair, preference mutation, or release download was attempted", + )?; + } else if !check_only { + ensure_no_active_runtimes_but_daemons(&inspected_home, spec).context( + "initial update preflight requires every managed VFS server to be stopped; no recovery, cleanup, repair, preference mutation, or release download was attempted", )?; } @@ -1362,7 +1845,16 @@ async fn run_update_flow( // process' mapped executable before constructing an HTTP client or making // any network request. A stale interactive install is recovered under its // lock first, then captured before that flow reaches the network. - let mut start_authority = if stale.is_empty() { + if windows_worker.is_some() && !stale.is_empty() { + anyhow::bail!("a Windows worker cannot recover a different interrupted generation; recover the native transaction before preparing a new worker"); + } + let mut start_authority = if let Some(record) = windows_worker { + Some(capture_windows_worker_authority( + &inspected_home, + spec, + record, + )?) + } else if stale.is_empty() { Some(UpdaterStartAuthority::capture(&inspected_home, spec)?) } else { None @@ -1432,7 +1924,12 @@ async fn run_update_flow( let latest = prepared.version.to_string(); let current_version = parse_release_version(CURRENT_VERSION)?; - let update_available = prepared.version > current_version; + let update_available = managed_runtime_update_available( + ¤t_version, + &kin_core::runtime_identity::RuntimeIdentity::current().commit_sha, + &prepared.version, + &prepared.commit_sha, + ); let restart_ack_required = restart_pending_path(&kin_home).exists(); if !update_available { println!("Already up to date (v{CURRENT_VERSION})."); @@ -1446,7 +1943,7 @@ async fn run_update_flow( return Ok(()); } - println!("New version available: v{latest}"); + print_available_runtime_update(&latest, &prepared.commit_sha); println!( "Using preflight-verified {} after matching its external byte-authority digest (release commit {}).", prepared.archive_name, prepared.commit_sha @@ -1458,7 +1955,8 @@ async fn run_update_flow( .as_ref() .context("pinned updater lost its startup authority")?, spec, - ); + ) + .await; } let release = resolve_release(&client, channel).await?; @@ -1489,7 +1987,12 @@ async fn run_update_flow( }; let latest = latest_version.to_string(); let current_version = parse_release_version(CURRENT_VERSION)?; - let update_available = latest_version > current_version; + let update_available = managed_runtime_update_available( + ¤t_version, + &kin_core::runtime_identity::RuntimeIdentity::current().commit_sha, + &latest_version, + &release_commit_sha, + ); let restart_ack_required = restart_pending_path(&kin_home).exists(); let mcp_repair_pending = mcp_repair_pending_path(&kin_home).exists(); @@ -1525,7 +2028,7 @@ async fn run_update_flow( if json { println!("{}", serde_json::to_string_pretty(&check)?); } else if update_available { - println!("Update available: v{CURRENT_VERSION} -> v{latest}"); + print_available_runtime_update(&latest, &release_commit_sha); } else { println!("Already up to date (v{CURRENT_VERSION})."); } @@ -1556,10 +2059,10 @@ async fn run_update_flow( return Ok(()); } - println!("New version available: v{latest}"); + print_available_runtime_update(&latest, &release_commit_sha); - ensure_no_active_managed_runtimes(&kin_home, spec).context( - "update preflight requires every managed serving executable to be stopped; no release archive was downloaded or staged", + ensure_no_active_runtimes_but_daemons(&kin_home, spec).context( + "update preflight requires every managed VFS server to be stopped; no release archive was downloaded or staged", )?; println!("Downloading {}...", asset.name); @@ -1609,6 +2112,7 @@ async fn run_update_flow( !skip_verify, )?; validate_staged_static_build_identity(staging.path(), spec, &latest, &provenance)?; + replace_daemons_for_release(&kin_home, &latest, &release_commit_sha).await?; start_authority .as_ref() .context("mutating updater lost its startup authority")? @@ -1631,7 +2135,7 @@ async fn run_update_flow( report_successful_install(lock, &kin_home, &latest, outcome) } -fn install_prepared_release( +async fn install_prepared_release( lock: &InstallRootLock, prepared: &PreparedPinnedRelease, start_authority: &UpdaterStartAuthority, @@ -1649,6 +2153,7 @@ fn install_prepared_release( true, )?; validate_staged_static_build_identity(staging.path(), spec, &latest, &prepared.provenance)?; + replace_daemons_for_release(kin_home, &latest, &prepared.commit_sha).await?; start_authority.verify_locked(lock, spec)?; let pending_record = restart_pending_record( kin_home, @@ -1668,6 +2173,30 @@ fn install_prepared_release( report_successful_install(lock, kin_home, &latest, outcome) } +/// Replace every daemon this install runs with the release about to be +/// committed. +/// +/// Called once the release is staged and verified, so a release that fails +/// either never costs a running daemon, and before the commit's own final check +/// that nothing managed is serving, which still refuses anything that appeared +/// since. A daemon still finishing its work, a newer one, or a development +/// build fails the update by name, and nothing is installed. +pub(crate) async fn replace_daemons_for_release( + kin_home: &Path, + version: &str, + commit: &str, +) -> Result<()> { + let successor = crate::commands::daemon::ReplacementSuccessor::new(version, commit)? + .replacing_install(kin_home); + for action in + crate::commands::daemon::replace_install_owned_daemons_for_update(kin_home, &successor) + .await? + { + println!(" {action}"); + } + Ok(()) +} + /// The managed `kin` the chain re-invokes. Deliberately the installed path /// rather than `current_exe`: after an install those are different bytes, and /// the whole reason the tail exists is to run as the new ones. @@ -2197,9 +2726,15 @@ fn probe_machine_activity(kin_home: &Path, spec: &[ComponentSpec]) -> MachineAct /// supervisor may register unrelated candidate binaries, so its topology is /// not installation ownership. Daemon stops retain exact incarnation/image /// checks; an unverified or surviving daemon fails the update. +/// +/// With a `successor`, the release this update installs, daemons are replaced +/// rather than stopped: each finishes its writes and is told what replaced it +/// (see `replace_install_owned_daemons_for_update`). Without one, a repair of +/// the installed release, they are stopped as before. async fn stop_managed_runtimes_for_update( kin_home: &Path, spec: &[ComponentSpec], + successor: Option<&crate::commands::daemon::ReplacementSuccessor>, ) -> Result> { let mut actions = Vec::new(); // Say what is being kept, before anything is signalled. These hold no @@ -2221,8 +2756,16 @@ async fn stop_managed_runtimes_for_update( )); } } - crate::commands::daemon::stop_install_owned_daemons_for_update(kin_home)?; - actions.push("stopped only this installation's daemon runtimes".to_string()); + match successor { + Some(successor) => actions.extend( + crate::commands::daemon::replace_install_owned_daemons_for_update(kin_home, successor) + .await?, + ), + None => { + crate::commands::daemon::stop_install_owned_daemons_for_update(kin_home)?; + actions.push("stopped only this installation's daemon runtimes".to_string()); + } + } let deadline = std::time::Instant::now() + Duration::from_secs(35); let mut signaled: HashSet = HashSet::new(); @@ -2317,10 +2860,11 @@ pub async fn run_unattended(force_window: bool) -> Result<()> { let client = build_update_http_client()?; let release = resolve_release(&client, channel).await?; let version = parse_release_version(&release.tag_name)?; - Ok::<_, anyhow::Error>((client, release, version)) + let release_commit_sha = resolve_release_commit(&client, &release.tag_name).await?; + Ok::<_, anyhow::Error>((client, release, version, release_commit_sha)) } .await; - let (client, release, latest_version) = match availability { + let (client, release, latest_version, release_commit_sha) = match availability { Ok(selected) => selected, Err(error) => { // No gate ran, so there is no decision to record; the outcome is @@ -2334,7 +2878,12 @@ pub async fn run_unattended(force_window: bool) -> Result<()> { }; let latest = latest_version.to_string(); let current_version = parse_release_version(CURRENT_VERSION)?; - let update_available = latest_version > current_version; + let update_available = managed_runtime_update_available( + ¤t_version, + &kin_core::runtime_identity::RuntimeIdentity::current().commit_sha, + &latest_version, + &release_commit_sha, + ); entry.update_available = update_available; let chain_needed = update_available || restart_pending_path(&kin_home).exists() @@ -2417,13 +2966,21 @@ pub async fn run_unattended(force_window: bool) -> Result<()> { Ok((authority, prepared)) } .await; + let successor = preflight.as_ref().ok().and_then(|(_, prepared)| { + crate::commands::daemon::ReplacementSuccessor::new( + &prepared.version.to_string(), + &prepared.commit_sha, + ) + .ok() + .map(|successor| successor.replacing_install(&kin_home)) + }); apply_prepared_unattended_release( &requested_home, spec, channel, preflight, &mut entry.steps, - || stop_managed_runtimes_for_update(&kin_home, spec), + || stop_managed_runtimes_for_update(&kin_home, spec, successor.as_ref()), ) .await .and_then(|()| run_chain_tail()) @@ -2433,7 +2990,7 @@ pub async fn run_unattended(force_window: bool) -> Result<()> { spec, UpdaterStartAuthority::capture(&kin_home, spec), &mut entry.steps, - || stop_managed_runtimes_for_update(&kin_home, spec), + || stop_managed_runtimes_for_update(&kin_home, spec, None), ) .await // Availability already selected no new release. Repair only the @@ -2576,7 +3133,7 @@ where authority.verify_locked(&lock, spec)?; cleanup_stale_staging_dirs(&lock)?; attempt_pending_mcp_repair(&lock)?; - install_prepared_release(&lock, &prepared, &authority, spec) + install_prepared_release(&lock, &prepared, &authority, spec).await } fn report_successful_install( @@ -2861,12 +3418,18 @@ fn enter_pinned_install_phase( // preflight interval behind a newer concurrent install cannot use its old // embedded CURRENT_VERSION to overwrite the new full bundle generation. start_authority.verify_locked(&lock, spec)?; + if !transaction_dirs(lock.root())?.is_empty() { + // Recovery moves managed bytes, so it never runs under a daemon. + ensure_no_active_managed_runtimes(lock.root(), spec).context( + "recovering an interrupted update requires every managed serving executable to be stopped", + )?; + } recover_stale_transactions(&lock, spec)?; refuse_new_update_while_restart_marker_exists(&lock)?; cleanup_stale_staging_dirs(&lock)?; start_authority.verify_locked(&lock, spec)?; - ensure_no_active_managed_runtimes(lock.root(), spec).context( - "pinned update install phase requires every managed serving executable to remain stopped", + ensure_no_active_runtimes_but_daemons(lock.root(), spec).context( + "pinned update install phase requires every managed VFS server to remain stopped", )?; attempt_pending_mcp_repair(&lock)?; Ok((lock, prepared)) @@ -3246,50 +3809,414 @@ pub fn retry_pending_mcp_repair_from_managed_process() -> Result { retry_pending_mcp_repair_with_start_authority(&requested_home, spec, &start_authority) } -fn ensure_mutating_update_supported(os: &str, check_only: bool) -> Result<()> { - if os == "windows" && !check_only { - anyhow::bail!( - "in-process self-update is disabled on Windows because the running kin.exe cannot be \ - replaced safely. No update state was changed; use the signed Windows installer" - ); - } - Ok(()) -} +#[cfg(any(windows, test))] +const WINDOWS_WORKER_SCHEMA: &str = "kin.windows-update-worker.v1"; +#[cfg(windows)] +const WINDOWS_WORKER_PREFIX: &str = ".update-worker-"; +#[cfg(windows)] +const WINDOWS_WORKER_EXECUTABLE: &str = "kin-update-worker.exe"; +#[cfg(windows)] +const WINDOWS_WORKER_RECORD: &str = "handoff.json"; -/// OS-level lock held by every writer of a managed Kin install component. -/// -/// Unix keeps a persistent in-root flock file; deleting it while held would -/// create a second inode that another process could lock concurrently. Windows -/// uses a persistent current-user-only sibling lock because an open descendant -/// handle prevents atomic install-root retirement there. -pub(crate) struct InstallRootLock { - file: File, - root: PathBuf, - #[cfg(unix)] - install: InstallLayout, +#[cfg(any(windows, test))] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct WindowsWorkerHandoff { + schema: String, + bundle: ManagedBundleGeneration, + build: EmbeddedBuildIdentity, + worker: ManagedComponentGeneration, + preparing_process: crate::daemon_client::ProcessIdentity, } -/// Shared admission lease held from immediately before a managed supervisor or -/// worker spawn until that child has published readiness. Full uninstall takes -/// the same install authority exclusively, so a spawn is either wholly before -/// the uninstall stop sweep or wholly after retirement (where path -/// revalidation fails because the managed binary no longer exists). Unix uses -/// `update.lock`; Windows uses the current-user-only sibling authority file -/// because Windows cannot rename a directory while an open lock remains inside -/// it. -pub(crate) struct InstallSpawnFence { - _file: File, +#[cfg(windows)] +struct WindowsWorkerAuthority { + executing: ExecutingProcessAuthority, + files: windows_update::WindowsWorkerFiles, } -impl InstallSpawnFence { - pub(crate) fn acquire_for_daemon_binary(binary: &Path) -> Result> { - let configured_root = crate::commands::setup::kin_dir()?; - Self::acquire_for_daemon_binary_at(binary, &configured_root) - } +#[cfg(any(windows, test))] +fn validate_windows_worker_handoff( + handoff: &WindowsWorkerHandoff, + bundle: &ManagedBundleGeneration, + worker: &ManagedComponentGeneration, + build: &EmbeddedBuildIdentity, +) -> Result<()> { + anyhow::ensure!( + handoff.schema == WINDOWS_WORKER_SCHEMA, + "unsupported Windows worker handoff" + ); + anyhow::ensure!( + &handoff.bundle == bundle, + "managed Kin generation changed after Windows worker preparation" + ); + anyhow::ensure!( + &handoff.worker == worker, + "Windows worker executable object or bytes changed after preparation" + ); + anyhow::ensure!( + &handoff.build == build, + "Windows worker source identity changed after preparation" + ); + build.require_published_release_identity()?; + let cli = bundle + .components + .get("kin.exe") + .and_then(Option::as_ref) + .context("Windows worker handoff has no managed kin.exe")?; + anyhow::ensure!( + worker.identity == cli.identity, + "Windows worker is not an exact copy of the prepared managed CLI" + ); + anyhow::ensure!( + worker.binding != cli.binding, + "Windows worker must be a distinct copied object, not a hard link to the installed CLI" + ); + Ok(()) +} - fn acquire_for_daemon_binary_at(binary: &Path, configured_root: &Path) -> Result> { - let Some(bin_dir) = binary.parent() else { - return Ok(None); +fn prepare_windows_update_worker() -> Result<()> { + #[cfg(not(windows))] + anyhow::bail!("Windows update worker preparation is only supported on Windows"); + #[cfg(windows)] + { + registry_authority_preflight()?; + let requested_home = crate::commands::setup::kin_dir()?; + let home = validate_existing_install_root(&requested_home)?; + let spec = platform_bundle_spec("windows")?; + let authority = UpdaterStartAuthority::capture(&home, spec)?; + authority + .executing + .build + .require_published_release_identity()?; + let lock = InstallRootLock::acquire_existing(&home)?; + authority.verify_locked(&lock, spec)?; + refuse_new_update_while_restart_marker_exists(&lock)?; + anyhow::ensure!( + transaction_dirs(&home)?.is_empty(), + "recover the interrupted native transaction before preparing a Windows worker" + ); + ensure_no_active_managed_runtimes(&home, spec)?; + + let mut directory = + windows_update::WindowsPrivateTempDir::create(&home, WINDOWS_WORKER_PREFIX)?; + let executable = directory.path().join(WINDOWS_WORKER_EXECUTABLE); + let record = directory.path().join(WINDOWS_WORKER_RECORD); + { + let mut source = authority.executing.file.file().try_clone()?; + source.rewind()?; + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&executable)?; + std::io::copy(&mut source, &mut output)?; + kin_blobs::durable::sync_all(&output) + .context("failed to durably prepare the Windows worker executable")?; + } + directory.seal_file(&executable)?; + let copied = File::open(&executable)?; + let worker = ManagedComponentGeneration { + identity: file_identity_from_open_file(&copied, "prepared Windows update worker")?, + binding: windows_update::file_object_identity(&copied)?, + }; + drop(copied); + let handoff = WindowsWorkerHandoff { + schema: WINDOWS_WORKER_SCHEMA.to_string(), + bundle: authority.bundle.clone(), + build: authority.executing.build.clone(), + worker, + preparing_process: crate::daemon_client::process_identity(std::process::id())? + .context("could not capture the Windows worker preparing process")?, + }; + validate_windows_worker_handoff( + &handoff, + &authority.bundle, + &handoff.worker, + &authority.executing.build, + )?; + { + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&record)?; + output.write_all(&serde_json::to_vec(&handoff)?)?; + kin_blobs::durable::sync_all(&output) + .context("failed to durably prepare the Windows worker handoff")?; + } + directory.seal_file(&record)?; + directory.validate()?; + authority.verify_locked(&lock, spec)?; + let ready = serde_json::json!({ + "schema": WINDOWS_WORKER_SCHEMA, + "worker_executable": executable, + "handoff_record": record, + "source_commit_sha": handoff.build.commit, + "version": handoff.build.version, + "worker_sha256": handoff.worker.identity.sha256, + "authority_lock_file": windows_install_authority_path(lock.root())?, + }); + directory.persist_for_worker()?; + println!("{}", serde_json::to_string(&ready)?); + Ok(()) + } +} + +/// Remove a consumed copied worker using the canonical managed updater's +/// install authority. Recovery evidence is never removed by this action. +pub fn cleanup_windows_update_worker(record: &Path) -> Result<()> { + #[cfg(not(windows))] + { + let _ = record; + anyhow::bail!("Windows update worker cleanup is only supported on Windows"); + } + #[cfg(windows)] + { + let requested_home = crate::commands::setup::kin_dir()?; + let home = validate_existing_install_root(&requested_home)?; + let spec = platform_bundle_spec("windows")?; + let _directories = windows_update::guard_managed_directories(&home)?; + let authority = UpdaterStartAuthority::capture(&home, spec)?; + authority + .executing + .build + .require_published_release_identity()?; + let lock = InstallRootLock::acquire_existing(&home)?; + authority.verify_locked(&lock, spec)?; + refuse_windows_worker_cleanup_with_recovery_fence(&home)?; + + anyhow::ensure!( + record.is_absolute(), + "Windows worker handoff must be absolute" + ); + let directory = record + .parent() + .context("Windows worker handoff has no directory")?; + let name = directory + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix(WINDOWS_WORKER_PREFIX)) + .context("Windows worker handoff is outside a prepared worker directory")?; + let id = + uuid::Uuid::parse_str(name).context("invalid Windows worker directory identity")?; + anyhow::ensure!( + id.get_version_num() == 4 + && id.to_string() == name + && directory.parent() == Some(home.as_path()) + && record.file_name() == Some(std::ffi::OsStr::new(WINDOWS_WORKER_RECORD)), + "Windows worker handoff does not belong to this installation" + ); + let files = windows_update::WindowsWorkerCleanup::open(directory, record)?; + let handoff_bytes = files.read_handoff()?; + let handoff: WindowsWorkerHandoff = serde_json::from_slice(&handoff_bytes)?; + let consumption = files.read_consumption()?; + files.validate_consumption(&consumption, &handoff_bytes)?; + anyhow::ensure!( + handoff.bundle.root == authority.bundle.root + && handoff.bundle.root_binding == authority.bundle.root_binding, + "Windows worker belongs to a different managed installation object" + ); + let worker = ManagedComponentGeneration { + identity: file_identity_from_open_file(files.worker_file(), "consumed Windows worker")?, + binding: windows_update::file_object_identity(files.worker_file())?, + }; + // The copied worker describes the old generation. The current updater + // is independently authenticated above and may have a newer source. + validate_windows_worker_handoff(&handoff, &handoff.bundle, &worker, &handoff.build)?; + anyhow::ensure!( + !crate::daemon_client::process_identity_is_current(&handoff.preparing_process)? + && !crate::daemon_client::process_identity_is_current(&consumption.worker_process)?, + "Windows worker cleanup requires the preparing and consuming processes to have exited" + ); + files.dispose(|| { + authority.verify_locked(&lock, spec)?; + refuse_windows_worker_cleanup_with_recovery_fence(&home) + })?; + println!("Windows update worker cleanup completed."); + Ok(()) + } +} + +#[cfg(windows)] +fn refuse_windows_worker_cleanup_with_recovery_fence(home: &Path) -> Result<()> { + for entry in fs::read_dir(home)? { + anyhow::ensure!( + !entry? + .file_name() + .to_string_lossy() + .starts_with(TRANSACTION_PREFIX), + "Windows worker retained while native transaction recovery evidence exists" + ); + } + for path in [restart_pending_path(home), mcp_repair_pending_path(home)] { + match fs::symlink_metadata(&path) { + Ok(_) => anyhow::bail!( + "Windows worker retained while recovery evidence exists at {}", + path.display() + ), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).context("could not inspect Windows worker recovery fence") + } + } + } + Ok(()) +} + +fn capture_windows_worker_authority( + home: &Path, + spec: &[ComponentSpec], + record: &Path, +) -> Result { + #[cfg(not(windows))] + { + let _ = (home, spec, record); + anyhow::bail!("Windows update worker handoffs are only supported on Windows"); + } + #[cfg(windows)] + { + anyhow::ensure!( + record.is_absolute(), + "Windows worker handoff must be absolute" + ); + let directory = record + .parent() + .context("Windows worker handoff has no directory")?; + let dirname = directory + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix(WINDOWS_WORKER_PREFIX)) + .context("Windows worker handoff is outside a prepared worker directory")?; + uuid::Uuid::parse_str(dirname).context("invalid Windows worker directory identity")?; + anyhow::ensure!( + directory.parent() == Some(home) + && record.file_name() == Some(std::ffi::OsStr::new(WINDOWS_WORKER_RECORD)), + "Windows worker handoff does not belong to this installation" + ); + let worker_path = directory.join(WINDOWS_WORKER_EXECUTABLE); + let mut files = windows_update::WindowsWorkerFiles::open(directory, record)?; + let handoff: WindowsWorkerHandoff = serde_json::from_slice(&files.read_record(record)?)?; + anyhow::ensure!( + !crate::daemon_client::process_identity_is_current(&handoff.preparing_process)?, + "wait for the Windows worker preparing process to exit before replacement" + ); + let worker = ExecutingProcessAuthority::capture()?; + // Object identity tolerates Windows' equivalent case and long-path + // spellings while still rejecting a different executable or a link. + let prepared_worker = windows_update::ExecutingFileAuthority::capture_handoff_target( + &worker_path, + &worker.generation.binding, + )?; + anyhow::ensure!( + file_identity_from_open_file(prepared_worker.file(), "prepared Windows worker")? + == worker.generation.identity, + "invoke the exact prepared Windows worker executable" + ); + drop(prepared_worker); + let bundle = snapshot_managed_bundle_generation(home, spec)?; + validate_windows_worker_handoff(&handoff, &bundle, &worker.generation, &worker.build)?; + let cli = bundle + .components + .get("kin.exe") + .and_then(Option::as_ref) + .context("prepared Windows bundle lost its CLI")?; + let file = windows_update::ExecutingFileAuthority::capture_handoff_target( + &home.join("bin/kin.exe"), + &cli.binding, + )?; + let executing = ExecutingProcessAuthority { + path: file.path().to_path_buf(), + generation: ManagedComponentGeneration { + identity: file_identity_from_open_file(file.file(), "handoff managed CLI")?, + binding: file.binding().clone(), + }, + build: worker.build.clone(), + file, + }; + anyhow::ensure!( + snapshot_managed_bundle_generation(home, spec)? == bundle, + "managed Kin generation changed during Windows handoff capture" + ); + files.claim()?; + let authority = UpdaterStartAuthority { + bundle, + executing, + worker: Some(WindowsWorkerAuthority { + executing: worker, + files, + }), + }; + authority.validate_managed_cli_binding()?; + Ok(authority) + } +} + +fn ensure_mutating_update_supported(os: &str, check_only: bool) -> Result<()> { + if os == "windows" && !check_only { + anyhow::bail!( + "in-process self-update is disabled on Windows because the running kin.exe cannot be \ + replaced safely. No update state was changed; use the signed Windows installer" + ); + } + Ok(()) +} + +/// OS-level lock held by every writer of a managed Kin install component. +/// +/// Unix keeps a persistent in-root flock file; deleting it while held would +/// create a second inode that another process could lock concurrently. Windows +/// uses a persistent current-user-only sibling lock because an open descendant +/// handle prevents atomic install-root retirement there. +/// Another process holds the install lock right now. +/// +/// Typed so a caller that can step aside, such as `kin doctor --fix`'s stamp +/// repair, tells contention apart from a lock it cannot take at all. +#[derive(Debug)] +pub(crate) struct InstallLockContended { + root: PathBuf, + lock: PathBuf, +} + +impl std::fmt::Display for InstallLockContended { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "another Kin install mutation is already active for {} (lock: {})", + self.root.display(), + self.lock.display() + ) + } +} + +impl std::error::Error for InstallLockContended {} + +pub(crate) struct InstallRootLock { + file: File, + root: PathBuf, + #[cfg(unix)] + install: InstallLayout, +} + +/// Shared admission lease held from immediately before a managed supervisor or +/// worker spawn until that child has published readiness. Full uninstall takes +/// the same install authority exclusively, so a spawn is either wholly before +/// the uninstall stop sweep or wholly after retirement (where path +/// revalidation fails because the managed binary no longer exists). Unix uses +/// `update.lock`; Windows uses the current-user-only sibling authority file +/// because Windows cannot rename a directory while an open lock remains inside +/// it. +pub(crate) struct InstallSpawnFence { + _file: File, +} + +impl InstallSpawnFence { + pub(crate) fn acquire_for_daemon_binary(binary: &Path) -> Result> { + let configured_root = crate::commands::setup::kin_dir()?; + Self::acquire_for_daemon_binary_at(binary, &configured_root) + } + + fn acquire_for_daemon_binary_at(binary: &Path, configured_root: &Path) -> Result> { + let Some(bin_dir) = binary.parent() else { + return Ok(None); }; if bin_dir.file_name().and_then(|name| name.to_str()) != Some("bin") { return Ok(None); @@ -3490,11 +4417,11 @@ impl InstallRootLock { match FileExt::try_lock_exclusive(&file) { Ok(()) => {} Err(error) if error.kind() == fs2::lock_contended_error().kind() => { - anyhow::bail!( - "another Kin install mutation is already active for {} (lock: {})", - kin_home.display(), - authority_path.display() - ); + return Err(InstallLockContended { + root: kin_home.to_path_buf(), + lock: authority_path.clone(), + } + .into()); } Err(error) => { return Err(error).with_context(|| { @@ -3555,11 +4482,11 @@ impl InstallRootLock { match FileExt::try_lock_exclusive(&file) { Ok(()) => {} Err(err) if err.kind() == fs2::lock_contended_error().kind() => { - anyhow::bail!( - "another Kin install mutation is already active for {} (lock: {})", - root.display(), - path.display() - ); + return Err(InstallLockContended { + root: root.clone(), + lock: path.clone(), + } + .into()); } Err(err) => { return Err(err).with_context(|| { @@ -5850,20 +6777,23 @@ struct PlatformObjectIdentity { file: WindowsFileId, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] struct ManagedComponentGeneration { identity: FileIdentity, binding: PlatformObjectIdentity, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] struct ManagedBundleGeneration { root: PathBuf, root_binding: PlatformObjectIdentity, components: HashMap>, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] struct EmbeddedBuildIdentity { version: String, commit: String, @@ -6138,13 +7068,20 @@ impl ExecutingProcessAuthority { struct UpdaterStartAuthority { bundle: ManagedBundleGeneration, executing: ExecutingProcessAuthority, + #[cfg(windows)] + worker: Option, } impl UpdaterStartAuthority { fn capture(requested_home: &Path, spec: &[ComponentSpec]) -> Result { let executing = ExecutingProcessAuthority::capture()?; let bundle = snapshot_managed_bundle_generation(requested_home, spec)?; - let authority = Self { bundle, executing }; + let authority = Self { + bundle, + executing, + #[cfg(windows)] + worker: None, + }; authority.validate_managed_cli_binding()?; // An unlocked snapshot spans several component opens. A second exact @@ -6157,6 +7094,8 @@ impl UpdaterStartAuthority { Ok(Self { bundle: confirmed, executing: authority.executing, + #[cfg(windows)] + worker: None, }) } @@ -6207,6 +7146,11 @@ impl UpdaterStartAuthority { fn verify_locked(&self, lock: &InstallRootLock, spec: &[ComponentSpec]) -> Result<()> { self.executing.verify_durable_identity()?; + #[cfg(windows)] + if let Some(worker) = &self.worker { + worker.executing.verify_durable_identity()?; + worker.files.validate()?; + } verify_managed_bundle_generation_locked(lock, spec, &self.bundle)?; self.validate_managed_cli_binding() } @@ -8898,6 +9842,57 @@ struct TransactionJournal { notifier_bundle: JournalBundle, restart_pending: RestartPending, mcp_repair_pending: McpRepairPending, + /// The launcher version stamp this transaction replaces, when the install + /// had one. + /// + /// Absent from every journal written before the stamp joined the + /// transaction, and from every transaction on an install that has no + /// stamp. It is additive rather than a schema change: an older CLI that + /// recovers this journal ignores the field and leaves the stamp alone, + /// and the next recovery by a CLI that knows it rewrites a stamp that + /// disagrees with the installed release. + #[serde(default, skip_serializing_if = "Option::is_none")] + launcher_stamp: Option, +} + +/// The npm launcher's version stamp, as one transaction found and replaces it. +/// +/// The stamp says which release sits in `bin/`. Launchers older than the +/// probe-first rule trusted it as the installed version and reinstalled their +/// own release over anything that read older, so a stamp left behind by an +/// update is how a newer install gets replaced by an older one. The +/// transaction therefore writes the installed version with the bundle and puts +/// the exact old bytes back when it rolls back. It never creates a stamp: an +/// install the launcher did not provision has none, and gains none here. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct JournalLauncherStamp { + /// The stamp's bytes before the transaction, hex encoded so any bytes + /// round-trip exactly. + original_hex: String, + /// Its permission bits before the transaction, restored with the bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + original_mode: Option, + /// The release the transaction installs, which is what it writes. + installed_version: String, +} + +impl JournalLauncherStamp { + fn original_bytes(&self) -> Result> { + let bytes = hex::decode(&self.original_hex) + .context("update journal launcher stamp bytes are not hex")?; + if bytes.len() > MAX_LAUNCHER_STAMP_BYTES { + anyhow::bail!("update journal launcher stamp is larger than any launcher writes"); + } + Ok(bytes) + } + + fn installed_bytes(&self) -> Vec { + launcher_stamp_bytes(&self.installed_version) + } + + fn restore_mode(&self) -> u32 { + self.original_mode.unwrap_or(LAUNCHER_STAMP_MODE) & 0o777 + } } #[cfg(test)] @@ -9013,9 +10008,39 @@ where } #[cfg(not(unix))] + install_staged_bundle_locked_with_hooks( + lock, + stage_root, + spec, + verified_staged_identities, + target_version, + restart_pending, + before_install, + || Ok(()), + ) +} + +#[cfg(not(unix))] +#[allow(clippy::too_many_arguments)] +fn install_staged_bundle_locked_with_hooks( + lock: &InstallRootLock, + stage_root: &Path, + spec: &[ComponentSpec], + verified_staged_identities: &VerifiedStagedIdentities, + target_version: &str, + restart_pending: &RestartPending, + before_install: F, + before_commit: P, +) -> Result +where + F: FnMut(usize, &Path) -> Result<()>, + P: FnMut() -> Result<()>, +{ + refuse_new_update_while_restart_marker_exists(lock)?; { let kin_home = lock.root(); let mut before_install = before_install; + let mut before_commit = before_commit; validate_staged_bundle(stage_root, spec)?; ensure_managed_dirs(kin_home, true)?; @@ -9100,6 +10125,10 @@ where // transaction directory. A malformed/unsupported marker or an // unbound managed client therefore fails without journalless residue. let mcp_repair_pending = mcp_repair_pending_record(lock, target_version)?; + let launcher_stamp = journal_launcher_stamp( + read_launcher_stamp_path(&launcher_stamp_path(kin_home))?, + target_version, + ); let transaction_root = create_transaction_root(kin_home)?; let backup_root = transaction_root.join("old"); // The schema is what this build writes, not what this platform happens @@ -9115,6 +10144,7 @@ where notifier_bundle: JournalBundle::default(), restart_pending: restart_pending.clone(), mcp_repair_pending, + launcher_stamp, }; if let Err(err) = persist_journal(&transaction_root, &journal) { let _ = durable_remove_dir_all(&transaction_root); @@ -9213,6 +10243,26 @@ where install_index += 1; } + maybe_crash_at("before-launcher-stamp"); + if let Some(stamp) = journal.launcher_stamp.clone() { + if let Err(err) = write_file_atomically( + &launcher_stamp_path(kin_home), + &stamp.installed_bytes(), + LAUNCHER_STAMP_MODE, + ) + .context("failed to write the installed release to the launcher stamp") + { + return rollback_after_failure( + err, + &mut journal, + &transaction_root, + kin_home, + spec, + ); + } + maybe_crash_at("after-install-launcher-stamp"); + } + if let Err(err) = validate_installed_bundle(kin_home, &journal, spec) { return rollback_after_failure(err, &mut journal, &transaction_root, kin_home, spec); } @@ -9229,6 +10279,16 @@ where ); } + if let Err(error) = before_commit() { + return rollback_after_failure( + error.context("update precommit authority revalidation failed"), + &mut journal, + &transaction_root, + kin_home, + spec, + ); + } + // This durable transition is the transaction's commit point. Recovery // rolls back every earlier phase and finishes cleanup for this phase. journal.phase = TransactionPhase::Committed; @@ -9613,6 +10673,10 @@ where // Resolve the durable repair manifest before transaction creation so a // fallible config/ledger read cannot strand an unjournaled directory. let mcp_repair_pending = mcp_repair_pending_record(lock, target_version)?; + // The stamp is read here for the same reason, and only a stamp that is + // already there joins the transaction. + let launcher_stamp = + journal_launcher_stamp(read_launcher_stamp_at(&install.bin)?, target_version); let transaction = TransactionLayout::create(install)?; let transaction_root = kin_home.join(&transaction.name); let mut journal = TransactionJournal { @@ -9623,6 +10687,7 @@ where notifier_bundle, restart_pending: restart_pending.clone(), mcp_repair_pending, + launcher_stamp, }; if let Err(error) = persist_journal_at(install, &transaction, &journal) { let _ = cleanup_transaction_at(install, &transaction, &journal, spec); @@ -9819,6 +10884,28 @@ where install_index += 1; } + // The stamp says what is installed, so it changes with the bundle and + // inside the same rollback boundary. + maybe_crash_at("before-launcher-stamp"); + if let Some(stamp) = &journal.launcher_stamp { + install.ensure_bound()?; + transaction.ensure_bound(install)?; + install + .bin + .atomic_write_checked( + NPM_LAUNCHER_STAMP, + &stamp.installed_bytes(), + LAUNCHER_STAMP_MODE, + || { + install.ensure_bound()?; + transaction.ensure_bound(install) + }, + ) + .context("failed to write the installed release to the launcher stamp")?; + after_precommit_mutation("after-install-mutation-launcher-stamp")?; + maybe_crash_at("after-install-launcher-stamp"); + } + validate_installed_bundle_at(install, &journal, spec)?; validate_backup_tree_at(install, &transaction, &journal, spec)?; install.ensure_bound()?; @@ -10073,22 +11160,81 @@ fn rollback_transaction_at( } } rollback_notifier_bundle_at(install, transaction, journal)?; + rollback_launcher_stamp_at(install, transaction, journal)?; journal.phase = TransactionPhase::RolledBack; persist_journal_at(install, transaction, journal)?; cleanup_transaction_at(install, transaction, journal, spec) } -/// Reverse the bundle swap, last, because it moved first. +/// The stamp's part of a transaction, from what was found before it began. /// -/// The decision is made from what is actually on disk rather than from how far -/// the transaction believed it had got, so a crash between any two steps -/// resolves the same way a clean failure does. +/// Only a stamp that is already a stamp joins: an absent one stays absent, and +/// anything else at its path is left for a person. +fn journal_launcher_stamp( + found: LauncherStampFile, + target_version: &str, +) -> Option { + match found { + LauncherStampFile::Recorded { bytes, mode } => Some(JournalLauncherStamp { + original_hex: hex::encode(bytes), + original_mode: mode, + installed_version: target_version.to_string(), + }), + LauncherStampFile::Absent | LauncherStampFile::Unusable(_) => None, + } +} + +/// Put the stamp's journaled bytes back, last, because it was written last. /// -/// A component is one file swapped by one atomic rename, so it is only ever the -/// old bytes or the new bytes, and anything else is refused as ambiguous. A -/// bundle is a tree removed file by file, so an interrupted rollback genuinely -/// can leave a partial tree that is neither. Refusing that outright would wedge -/// every later update behind a state only manual deletion could clear. So the +/// Like the bundle, the decision reads what is on disk, so a rollback that was +/// itself interrupted finishes the same way. Bytes and mode already restored +/// are left alone. Something that is no longer a stamp is left alone too: +/// replacing it would destroy what a person put there, and the stamp is a +/// receipt rather than an executable the install depends on. +#[cfg(unix)] +fn rollback_launcher_stamp_at( + install: &InstallLayout, + transaction: &TransactionLayout, + journal: &TransactionJournal, +) -> Result<()> { + let Some(stamp) = &journal.launcher_stamp else { + return Ok(()); + }; + let original = stamp.original_bytes()?; + let mode = stamp.restore_mode(); + match read_launcher_stamp_at(&install.bin)? { + LauncherStampFile::Recorded { + bytes, + mode: current_mode, + } if bytes == original && current_mode.is_none_or(|current| current == mode) => { + return Ok(()) + } + LauncherStampFile::Unusable(_) => return Ok(()), + LauncherStampFile::Recorded { .. } | LauncherStampFile::Absent => {} + } + transaction.ensure_bound(install)?; + install + .bin + .atomic_write_checked(NPM_LAUNCHER_STAMP, &original, mode, || { + install.ensure_bound()?; + transaction.ensure_bound(install) + }) + .context("failed to restore the launcher stamp")?; + maybe_crash_at("after-rollback-restore-launcher-stamp"); + Ok(()) +} + +/// Reverse the bundle swap, last, because it moved first. +/// +/// The decision is made from what is actually on disk rather than from how far +/// the transaction believed it had got, so a crash between any two steps +/// resolves the same way a clean failure does. +/// +/// A component is one file swapped by one atomic rename, so it is only ever the +/// old bytes or the new bytes, and anything else is refused as ambiguous. A +/// bundle is a tree removed file by file, so an interrupted rollback genuinely +/// can leave a partial tree that is neither. Refusing that outright would wedge +/// every later update behind a state only manual deletion could clear. So the /// rule is narrower than the components': a live tree that matches neither is /// discarded only while the recorded original is proven present in the backup, /// which is precisely when discarding it loses nothing. With no backup to @@ -10388,6 +11534,17 @@ fn validate_journal(journal: &TransactionJournal, spec: &[ComponentSpec]) -> Res } validate_restart_record_schema(&journal.restart_pending)?; validate_mcp_repair_record(&journal.mcp_repair_pending)?; + if let Some(stamp) = &journal.launcher_stamp { + stamp.original_bytes()?; + if parse_release_version(&stamp.installed_version)? + != parse_release_version(&journal.target_version)? + { + anyhow::bail!("update journal launcher stamp does not name its target version"); + } + if stamp.original_mode.is_some_and(|mode| mode & !0o777 != 0) { + anyhow::bail!("update journal launcher stamp mode is not a permission mode"); + } + } let runtime_kinds = journal .restart_pending .runtime_obligations @@ -10696,11 +11853,31 @@ fn rollback_transaction( } } } + rollback_launcher_stamp(kin_home, journal)?; journal.phase = TransactionPhase::RolledBack; persist_journal(transaction_root, journal)?; cleanup_transaction_root(transaction_root) } +/// [`rollback_launcher_stamp_at`] by path, for the non-Unix transaction. +#[cfg(not(unix))] +fn rollback_launcher_stamp(kin_home: &Path, journal: &TransactionJournal) -> Result<()> { + let Some(stamp) = &journal.launcher_stamp else { + return Ok(()); + }; + let original = stamp.original_bytes()?; + let path = launcher_stamp_path(kin_home); + match read_launcher_stamp_path(&path)? { + LauncherStampFile::Recorded { bytes, .. } if bytes == original => return Ok(()), + LauncherStampFile::Unusable(_) => return Ok(()), + LauncherStampFile::Recorded { .. } | LauncherStampFile::Absent => {} + } + write_file_atomically(&path, &original, stamp.restore_mode()) + .context("failed to restore the launcher stamp")?; + maybe_crash_at("after-rollback-restore-launcher-stamp"); + Ok(()) +} + #[cfg(not(unix))] fn rollback_after_failure( primary: anyhow::Error, @@ -10758,7 +11935,20 @@ fn transaction_dirs(kin_home: &Path) -> Result> { Ok(transactions) } +/// Finish or reverse every interrupted transaction, then make the launcher +/// stamp name what is installed. +/// +/// The stamp repair runs last and on every recovery, interrupted transaction +/// or not: an updater from before the stamp joined the transaction left it +/// naming the release it replaced, and this is the first locked moment a +/// newer CLI gets to correct that. fn recover_stale_transactions(lock: &InstallRootLock, spec: &[ComponentSpec]) -> Result<()> { + recover_stale_transaction_dirs(lock, spec)?; + repair_launcher_stamp_locked(lock, spec)?; + Ok(()) +} + +fn recover_stale_transaction_dirs(lock: &InstallRootLock, spec: &[ComponentSpec]) -> Result<()> { #[cfg(unix)] { return recover_stale_transactions_unix(lock, spec); @@ -12429,11 +13619,31 @@ fn reject_active_managed_runtime( /// gate reads the same predicate, so this can never reject a machine the gate /// called idle. fn ensure_no_active_managed_runtimes(kin_home: &Path, spec: &[ComponentSpec]) -> Result<()> { + ensure_no_active_runtimes_of(kin_home, spec, true) +} + +/// [`ensure_no_active_managed_runtimes`] for the checks an update makes before +/// its release is staged and verified. A running daemon passes them: the update +/// replaces it once the release is verified (see +/// [`replace_daemons_for_release`]), and the full check still runs before the +/// commit. Anything else that would refuse the commit refuses here, before any +/// download. +fn ensure_no_active_runtimes_but_daemons(kin_home: &Path, spec: &[ComponentSpec]) -> Result<()> { + ensure_no_active_runtimes_of(kin_home, spec, false) +} + +fn ensure_no_active_runtimes_of( + kin_home: &Path, + spec: &[ComponentSpec], + including_daemons: bool, +) -> Result<()> { for pass in 0..3 { let system = managed_runtime_process_table(); let paths = ProcessPathCache::default(); for kind in [RuntimeKind::Daemon, RuntimeKind::Mcp, RuntimeKind::Vfs] { - if !kind.blocks_managed_byte_movement() { + if !kind.blocks_managed_byte_movement() + || (kind == RuntimeKind::Daemon && !including_daemons) + { continue; } for component in spec @@ -12509,6 +13719,7 @@ fn restart_pending_record( recorded_at: now.to_rfc3339(), recorded_at_unix_seconds: now.timestamp().max(0) as u64, commit_runtime_fence: None, + retained_mcp_sessions: Some(capture_retained_mcp_sessions(kin_home, spec)?), reason: RESTART_FENCE_REASON.to_string(), runtime_obligations, }) @@ -12571,6 +13782,153 @@ fn capture_runtime_commit_fence( Ok(identities) } +/// Classify conservatively, then retain only a native process-birth identity. +/// A classification can add a restart obligation; it never proves which image +/// is mapped and never authorizes adoption, convergence, or termination. +fn capture_retained_mcp_sessions( + kin_home: &Path, + spec: &[ComponentSpec], +) -> Result> { + let system = managed_runtime_process_table(); + let mut retained = Vec::new(); + for (kind, _, pid) in scan_active_managed_runtimes(&system, kin_home, spec) { + if kind != RuntimeKind::Mcp { + continue; + } + let Some(identity) = crate::daemon_client::process_identity(pid) + .with_context(|| format!("cannot capture retained MCP process birth for PID {pid}"))? + else { + continue; + }; + if !crate::daemon_client::process_identity_is_current(&identity).with_context(|| { + format!("cannot revalidate retained MCP process birth for PID {pid}") + })? { + anyhow::bail!( + "MCP process PID {pid} changed during restart-fence capture; retry the update" + ); + } + if !retained.contains(&identity) { + retained.push(identity); + } + } + retained.sort_by_key(crate::daemon_client::ProcessIdentity::pid); + Ok(retained) +} + +fn validate_retained_mcp_sessions( + record: &RestartPending, + kin_home: &Path, + spec: &[ComponentSpec], +) -> Result<()> { + let Some(retained) = record.retained_mcp_sessions.as_ref() else { + // Old schema-3 markers fenced installed objects but did not record the + // MCP transports they deliberately kept. No image or birth identity + // can be reconstructed after the fact. Quiescence is the safe proof. + if !capture_retained_mcp_sessions(kin_home, spec)?.is_empty() { + anyhow::bail!("this older restart marker did not capture MCP process births; close managed MCP clients before acknowledgement; no session was stopped"); + } + return Ok(()); + }; + validate_retained_mcp_births(retained, crate::daemon_client::process_identity_is_current) +} + +fn validate_retained_mcp_births( + retained: &[crate::daemon_client::ProcessIdentity], + mut is_current: F, +) -> Result<()> +where + F: FnMut(&crate::daemon_client::ProcessIdentity) -> std::io::Result, +{ + for identity in retained { + if identity.pid() == 0 { + anyhow::bail!("restart marker contains an invalid retained MCP process birth"); + } + if is_current(identity).with_context(|| { + format!( + "cannot establish whether retained MCP PID {} exited", + identity.pid() + ) + })? { + anyhow::bail!("pre-update MCP transport PID {} is still live; restart its client to use the installed runtime; no session was stopped", identity.pid()); + } + } + Ok(()) +} + +/// Legacy markers cannot prove a replacement image off Linux. They can still +/// be acknowledged under the exact installed-bundle authority after every +/// recorded PID is authoritatively absent and all managed roles are quiescent. +/// A reused PID also refuses this path because legacy markers lack an exact +/// native birth token. No pathname or second-resolution time substitutes one. +fn validate_quiescent_legacy_restart( + record: &RestartPending, + kin_home: &Path, + spec: &[ComponentSpec], + evidence: &[RuntimeSessionEvidence], +) -> Result { + #[cfg(target_os = "linux")] + { + let _ = (record, kin_home, spec, evidence); + Ok(false) + } + #[cfg(not(target_os = "linux"))] + { + if record.schema_version != 2 || !evidence.is_empty() { + return Ok(false); + } + validate_restart_record_schema(record)?; + let mut prior_processes = HashSet::new(); + for obligation in &record.runtime_obligations { + for prior in &obligation.prior_sessions { + if prior.pid == 0 + || prior.start_time == 0 + || !prior.executable.is_absolute() + || prior + .binding + .as_ref() + .is_some_and(|path| !path.is_absolute()) + || !prior_processes.insert((prior.pid, prior.start_time)) + { + anyhow::bail!("legacy restart marker has an invalid prior runtime identity"); + } + validate_hex( + &prior.executable_identity.sha256, + 64, + "legacy prior runtime SHA-256", + )?; + if crate::daemon_client::process_identity(prior.pid) + .with_context(|| { + format!( + "cannot establish authoritative absence of legacy runtime PID {}", + prior.pid + ) + })? + .is_some() + { + anyhow::bail!("legacy runtime PID {} is occupied; exact prior birth is unavailable, so restart acknowledgement remains pending", prior.pid); + } + } + } + ensure_no_active_managed_runtimes(kin_home, spec)?; + if !capture_retained_mcp_sessions(kin_home, spec)?.is_empty() { + anyhow::bail!("legacy restart acknowledgement requires quiescent managed MCP clients; no session was stopped"); + } + // This ephemeral fence validates the current installed objects only. + // It never rewrites or promotes the legacy persisted marker. + let mut quiescent = record.clone(); + quiescent.schema_version = RESTART_MARKER_SCHEMA_VERSION; + quiescent.reason = RESTART_FENCE_REASON.to_string(); + quiescent.retained_mcp_sessions = Some(Vec::new()); + for obligation in &mut quiescent.runtime_obligations { + obligation.prior_sessions.clear(); + } + quiescent.commit_runtime_fence = + Some(capture_runtime_commit_fence(&quiescent, kin_home, spec)?); + validate_runtime_convergence(&quiescent, kin_home, spec, &[], &HashMap::new())?; + Ok(true) + } +} + fn mark_restart_record_committed( record: &mut RestartPending, kin_home: &Path, @@ -12578,7 +13936,19 @@ fn mark_restart_record_committed( ) -> Result<()> { validate_restart_record_schema(record)?; if record.schema_version == RESTART_MARKER_SCHEMA_VERSION { + let first_commit = record.commit_runtime_fence.is_none(); record.commit_runtime_fence = Some(capture_runtime_commit_fence(record, kin_home, spec)?); + // Never append a later process to an already committed journal. Restart + // persistence is create-only-or-identical, and recovery must preserve + // the exact births captured by that commit rather than inventing proof. + if first_commit { + let retained = record.retained_mcp_sessions.get_or_insert_with(Vec::new); + for process in capture_retained_mcp_sessions(kin_home, spec)? { + if !retained.contains(&process) { + retained.push(process); + } + } + } } let now = chrono::Utc::now(); record.recorded_at = now.to_rfc3339(); @@ -12877,6 +14247,9 @@ fn validate_restart_record_schema(record: &RestartPending) -> Result<()> { if record.commit_runtime_fence.is_some() { anyhow::bail!("legacy restart acknowledgement marker has an unknown commit fence"); } + if record.retained_mcp_sessions.is_some() { + anyhow::bail!("legacy restart acknowledgement marker has an unknown retained MCP fence"); + } Ok(()) } RESTART_MARKER_SCHEMA_VERSION => { @@ -13220,6 +14593,9 @@ fn validate_runtime_convergence( ); } validate_runtime_commit_fence(record, kin_home, spec)?; + if record.schema_version == RESTART_MARKER_SCHEMA_VERSION { + validate_retained_mcp_sessions(record, kin_home, spec)?; + } if record.schema_version == RESTART_MARKER_SCHEMA_VERSION && (!evidence.is_empty() || !observed.is_empty()) { @@ -13469,8 +14845,17 @@ fn acknowledge_runtime_restart(evidence: &[RuntimeSessionEvidence]) -> Result<() build.sha, build.dependency_provenance, )?; - let observed = observe_runtime_sessions(&record, evidence)?; - validate_runtime_convergence(&record, lock.root(), spec, evidence, &observed)?; + let quiescent_legacy = validate_quiescent_legacy_restart(&record, lock.root(), spec, evidence)?; + if !quiescent_legacy { + let observed = observe_runtime_sessions(&record, evidence)?; + validate_runtime_convergence(&record, lock.root(), spec, evidence, &observed)?; + } + start_authority.verify_locked(&lock, spec)?; + if quiescent_legacy { + validate_quiescent_legacy_restart(&record, lock.root(), spec, evidence)?; + } else if record.schema_version == RESTART_MARKER_SCHEMA_VERSION { + validate_retained_mcp_sessions(&record, lock.root(), spec)?; + } #[cfg(unix)] { install.ensure_bound()?; @@ -13750,6 +15135,24 @@ fn parse_release_version(value: &str) -> Result { Version::parse(value).with_context(|| format!("'{value}' is not valid semantic version syntax")) } +/// Bytes that read as a Kin executable of `version`, for tests elsewhere in +/// the crate that classify installed copies. +#[cfg(test)] +pub(crate) fn test_binary_with_build_identity(version: &str, clean: bool) -> Vec { + tests::bytes_with_static_build_identity( + b"fixture-kin-executable", + &StaticBuildIdentity { + schema: STATIC_BUILD_IDENTITY_SCHEMA.to_string(), + version: version.to_string(), + commit: "a".repeat(40), + clean, + source_known: true, + dependency_provenance: "b".repeat(64), + graph_snapshot_version: 1, + }, + ) +} + #[cfg(test)] fn is_newer(latest: &str, current: &str) -> Result { Ok(parse_release_version(latest)? > parse_release_version(current)?) @@ -14033,7 +15436,10 @@ mod tests { } } - fn bytes_with_static_build_identity(prefix: &[u8], identity: &StaticBuildIdentity) -> Vec { + pub(super) fn bytes_with_static_build_identity( + prefix: &[u8], + identity: &StaticBuildIdentity, + ) -> Vec { fn put_ascii(target: &mut [u8], value: &str) { assert!(value.len() <= target.len()); target[..value.len()].copy_from_slice(value.as_bytes()); @@ -14466,6 +15872,7 @@ mod tests { recorded_at: "2026-07-13T00:00:00Z".to_string(), recorded_at_unix_seconds: 1_752_364_800, commit_runtime_fence: None, + retained_mcp_sessions: None, reason: "test restart pending".to_string(), runtime_obligations: [ (RuntimeKind::Daemon, "kin-daemon", b"new-daemon".as_slice()), @@ -15694,6 +17101,7 @@ cwd = {:?} repair_required: false, targets: Vec::new(), }, + launcher_stamp: None, }; // Fix the staged identities the restart obligations pin. for (name, bytes) in [ @@ -15711,6 +17119,30 @@ cwd = {:?} validate_journal(&journal, LINUX_COMPONENTS) .expect("a schema 2 journal predates the bundle and must still recover"); + // The launcher stamp record is additive. A journal without it carries + // no key at all, so the bytes an older CLI reads are unchanged, and + // one with it must name the release the transaction installs. + let serialized = serde_json::to_value(&journal).unwrap(); + assert!(serialized.get("launcher_stamp").is_none(), "{serialized}"); + let mut stamped = journal.clone(); + stamped.launcher_stamp = journal_launcher_stamp( + LauncherStampFile::Recorded { + bytes: b"0.6.6\n".to_vec(), + mode: Some(0o664), + }, + "0.2.22", + ); + validate_journal(&stamped, LINUX_COMPONENTS).expect("a stamp naming the target is valid"); + let reread: TransactionJournal = + serde_json::from_value(serde_json::to_value(&stamped).unwrap()).unwrap(); + assert_eq!(reread.launcher_stamp, stamped.launcher_stamp); + stamped.launcher_stamp.as_mut().unwrap().installed_version = "0.2.21".to_string(); + let error = validate_journal(&stamped, LINUX_COMPONENTS).unwrap_err(); + assert!( + format!("{error:#}").contains("does not name its target version"), + "{error:#}" + ); + // The same schema claiming a bundle record it cannot describe is not a // journal this build wrote, so it is refused rather than guessed at. journal.notifier_bundle.staged_identity = Some(BundleIdentity { @@ -15922,6 +17354,7 @@ cwd = {:?} repair_required: false, targets: Vec::new(), }, + launcher_stamp: None, } }; @@ -16375,80 +17808,571 @@ cwd = {:?} "kin", limits, ) - .unwrap_err(); - assert!(format!("{error:#}").contains("nonzero padding")); + .unwrap_err(); + assert!(format!("{error:#}").contains("nonzero padding")); + } + + #[test] + fn raw_tar_preflight_globally_bounds_a_small_gzip_bomb() { + let raw = vec![0_u8; MAX_TAR_FORMAT_OVERHEAD_BYTES as usize + 1025]; + let archive = gzip_test_tar(&raw); + assert!( + archive.len() < 16 * 1024, + "fixture must remain a small compressed bomb, got {} bytes", + archive.len() + ); + let error = extract_named_file_from_tar_gz_with_limits( + &archive, + "kin", + ArchiveSizeLimits { + compressed_bytes: archive.len(), + entry_bytes: 0, + expanded_bytes: 0, + }, + ) + .expect_err("every decompressed trailer byte must count toward the global bound"); + assert!(format!("{error:#}").contains("global limit")); + } + + #[test] + #[serial] + fn full_bundle_install_includes_vfs_and_shim_under_custom_root() { + if !kin_core::test_env::alone_in_process() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let custom_home = tmp.path().join("custom-kin-home"); + let stage = tmp.path().join("stage"); + let _environment = isolated_update_environment(tmp.path(), &custom_home); + let _cwd = CwdGuard::set(tmp.path()); + write_bundle(&custom_home, LINUX_COMPONENTS, b"old-"); + + let archive = full_linux_archive("kin-linux-x86_64"); + stage_archive( + &archive, + "kin-linux-x86_64.tar.gz", + &stage, + LINUX_COMPONENTS, + ) + .unwrap(); + let outcome = install_staged_bundle( + &custom_home, + &stage, + LINUX_COMPONENTS, + "0.2.22", + &test_restart_pending("0.2.22"), + ) + .unwrap(); + + assert!(outcome.retained_backup.is_none()); + assert_eq!(fs::read(custom_home.join("bin/kin")).unwrap(), b"new-kin"); + assert_eq!( + fs::read(custom_home.join("bin/kin-daemon")).unwrap(), + b"new-daemon" + ); + assert_eq!( + fs::read(custom_home.join("bin/kin-vfs")).unwrap(), + b"new-vfs" + ); + assert_eq!( + fs::read(custom_home.join("lib/libkin_vfs_shim.so")).unwrap(), + b"new-shim" + ); + assert!( + !custom_home.join("bin/kin-mcp").exists(), + "obsolete standalone MCP binary must not survive the bundle update" + ); + } + + fn write_launcher_stamp_for_test(kin_home: &Path, bytes: &[u8], mode: u32) { + let path = launcher_stamp_path(kin_home); + fs::write(&path, bytes).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + } + #[cfg(not(unix))] + let _ = mode; + } + + #[cfg(unix)] + fn launcher_stamp_mode(kin_home: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + fs::metadata(launcher_stamp_path(kin_home)) + .unwrap() + .permissions() + .mode() + & 0o777 + } + + fn scratch_entries(kin_home: &Path) -> Vec { + fs::read_dir(kin_home.join("bin")) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".tmp-")) + .collect() + } + + /// The marker beside a 0.8.6 binary read 0.6.6 because only the npm + /// launcher wrote it and every `kin update` left it behind. An install + /// now writes the release it installs into a stamp that is already there. + #[test] + #[serial] + fn an_install_writes_the_installed_release_into_an_existing_launcher_stamp() { + if !kin_core::test_env::alone_in_process() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let kin_home = tmp.path().join("kin-home"); + let stage = tmp.path().join("stage"); + let _environment = isolated_update_environment(tmp.path(), &kin_home); + let _cwd = CwdGuard::set(tmp.path()); + write_bundle(&kin_home, LINUX_COMPONENTS, b"old-"); + write_launcher_stamp_for_test(&kin_home, b"0.6.6\n", 0o664); + stage_archive( + &full_linux_archive("kin-linux-x86_64"), + "kin-linux-x86_64.tar.gz", + &stage, + LINUX_COMPONENTS, + ) + .unwrap(); + + install_staged_bundle( + &kin_home, + &stage, + LINUX_COMPONENTS, + "0.2.22", + &test_restart_pending("0.2.22"), + ) + .unwrap(); + + assert_eq!(fs::read(kin_home.join("bin/kin")).unwrap(), b"new-kin"); + assert_eq!( + fs::read(launcher_stamp_path(&kin_home)).unwrap(), + b"0.2.22\n" + ); + #[cfg(unix)] + assert_eq!(launcher_stamp_mode(&kin_home), LAUNCHER_STAMP_MODE); + assert!(scratch_entries(&kin_home).is_empty()); + assert!(transaction_dirs(&kin_home).unwrap().is_empty()); + } + + /// An install the npm launcher never provisioned has no stamp, and neither + /// the transaction nor recovery gives it one. + #[test] + #[serial] + fn an_install_without_a_launcher_stamp_never_gains_one() { + if !kin_core::test_env::alone_in_process() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let kin_home = tmp.path().join("kin-home"); + let stage = tmp.path().join("stage"); + let _environment = isolated_update_environment(tmp.path(), &kin_home); + let _cwd = CwdGuard::set(tmp.path()); + write_bundle(&kin_home, LINUX_COMPONENTS, b"old-"); + fs::write( + kin_home.join("bin/kin"), + test_binary_with_build_identity("0.2.21", true), + ) + .unwrap(); + stage_archive( + &full_linux_archive("kin-linux-x86_64"), + "kin-linux-x86_64.tar.gz", + &stage, + LINUX_COMPONENTS, + ) + .unwrap(); + + install_staged_bundle( + &kin_home, + &stage, + LINUX_COMPONENTS, + "0.2.22", + &test_restart_pending("0.2.22"), + ) + .unwrap(); + assert!(!launcher_stamp_path(&kin_home).exists()); + + fs::write( + kin_home.join("bin/kin"), + test_binary_with_build_identity("0.2.22", true), + ) + .unwrap(); + let lock = InstallRootLock::acquire_existing(&kin_home).unwrap(); + recover_stale_transactions(&lock, LINUX_COMPONENTS).unwrap(); + assert_eq!( + repair_launcher_stamp_locked(&lock, LINUX_COMPONENTS).unwrap(), + LauncherStampRepair::Absent + ); + assert!(!launcher_stamp_path(&kin_home).exists()); + } + + /// A transaction that fails after the stamp was written puts back the + /// exact bytes and permission bits it journaled. + #[cfg(unix)] + #[test] + #[serial] + fn a_failed_install_restores_the_exact_launcher_stamp() { + if !kin_core::test_env::alone_in_process() { + return; + } + for failure_point in [ + "after-install-mutation-0", + "after-install-mutation-launcher-stamp", + "precommit-validated", + ] { + let tmp = tempfile::tempdir().unwrap(); + let kin_home = tmp.path().join("kin-home"); + let stage = tmp.path().join("stage"); + let _environment = isolated_update_environment(tmp.path(), &kin_home); + write_bundle(&kin_home, LINUX_COMPONENTS, b"old-"); + let old = bundle_snapshot(&kin_home, LINUX_COMPONENTS); + write_launcher_stamp_for_test(&kin_home, b"v0.6.6 from a launcher\n", 0o664); + stage_archive( + &full_linux_archive("kin-linux-x86_64"), + "kin-linux-x86_64.tar.gz", + &stage, + LINUX_COMPONENTS, + ) + .unwrap(); + let lock = InstallRootLock::acquire_existing(&kin_home).unwrap(); + let staged_identities = staged_identities_for_test(&stage, LINUX_COMPONENTS).unwrap(); + let mut stamp_seen_during_install = None; + + let error = install_staged_bundle_unix_with_hooks( + &lock, + &StagingLayout::open(&stage).unwrap(), + LINUX_COMPONENTS, + &staged_identities, + "0.2.22", + &test_restart_pending("0.2.22"), + |_, _| Ok(()), + |_, _| Ok(()), + |point| { + if point == failure_point { + stamp_seen_during_install = + Some(fs::read(launcher_stamp_path(&kin_home)).unwrap()); + anyhow::bail!("injected failure at {point}"); + } + Ok(()) + }, + ) + .expect_err("the injected failure must abort the transaction"); + assert!( + format!("{error:#}").contains("previous bundle was restored"), + "{error:#}" + ); + let expected_during = if failure_point == "after-install-mutation-0" { + b"v0.6.6 from a launcher\n".to_vec() + } else { + b"0.2.22\n".to_vec() + }; + assert_eq!( + stamp_seen_during_install, + Some(expected_during), + "{failure_point}" + ); + assert_bundle_matches(&kin_home, LINUX_COMPONENTS, &old); + assert_eq!( + fs::read(launcher_stamp_path(&kin_home)).unwrap(), + b"v0.6.6 from a launcher\n", + "{failure_point}" + ); + assert_eq!(launcher_stamp_mode(&kin_home), 0o664, "{failure_point}"); + assert!(scratch_entries(&kin_home).is_empty()); + assert!(transaction_dirs(&kin_home).unwrap().is_empty()); + } + } + + /// Kill a real update between the bundle and the stamp, after the stamp, + /// and after commit. However it stopped, recovery ends with the stamp + /// naming the release that is actually installed. + #[cfg(unix)] + #[test] + fn a_crash_around_the_launcher_stamp_recovers_to_the_installed_release() { + for (point, committed, stamp_at_crash) in [ + ("before-launcher-stamp", false, b"0.6.6\n".as_slice()), + ( + "after-install-launcher-stamp", + false, + b"0.2.22\n".as_slice(), + ), + ("after-commit", true, b"0.2.22\n".as_slice()), + ] { + let tmp = tempfile::tempdir().unwrap(); + let kin_home = tmp.path().join("kin-home"); + let stage = tmp.path().join("stage"); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).unwrap(); + write_bundle(&kin_home, LINUX_COMPONENTS, b"old-"); + let old_cli = test_binary_with_build_identity("0.2.21", true); + fs::write(kin_home.join("bin/kin"), &old_cli).unwrap(); + write_launcher_stamp_for_test(&kin_home, b"0.6.6\n", 0o644); + stage_archive( + &full_linux_archive("kin-linux-x86_64"), + "kin-linux-x86_64.tar.gz", + &stage, + LINUX_COMPONENTS, + ) + .unwrap(); + + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "commands::update::tests::crash_recovery_worker", + "--nocapture", + ]) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("KIN_HOME", &kin_home) + .env_remove("KIN_DIR") + .env_remove("KIN_MCP_REPO") + .env("KIN_UPDATE_TEST_WORKER_HOME", &kin_home) + .env("KIN_UPDATE_TEST_WORKER_STAGE", &stage) + .env("KIN_UPDATE_TEST_CRASH_POINT", point); + let output = + test_subprocess_output(command, &format!("stamp crash worker at {point}")).unwrap(); + assert_eq!( + output.status.code(), + Some(86), + "worker output: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!transaction_dirs(&kin_home).unwrap().is_empty()); + assert_eq!( + fs::read(launcher_stamp_path(&kin_home)).unwrap(), + stamp_at_crash, + "{point}" + ); + + let lock = InstallRootLock::acquire_existing(&kin_home).unwrap(); + recover_stale_transactions(&lock, LINUX_COMPONENTS).unwrap(); + if committed { + assert_eq!(fs::read(kin_home.join("bin/kin")).unwrap(), b"new-kin"); + // The new fixture CLI carries no build identity, so recovery + // keeps the stamp the committed transaction wrote. + assert_eq!( + fs::read(launcher_stamp_path(&kin_home)).unwrap(), + b"0.2.22\n" + ); + } else { + assert_eq!(fs::read(kin_home.join("bin/kin")).unwrap(), old_cli); + // Rollback put 0.6.6 back; the stamp repair then named the + // release that rollback left installed. + assert_eq!( + fs::read(launcher_stamp_path(&kin_home)).unwrap(), + b"0.2.21\n", + "{point}" + ); + } + assert!(scratch_entries(&kin_home).is_empty()); + assert!(transaction_dirs(lock.root()).unwrap().is_empty()); + } + } + + /// Updaters from before the stamp joined the transaction left it naming + /// the release they replaced. The first locked recovery by this build + /// corrects it, clears an interrupted write's scratch file, and leaves + /// alone a stamp it cannot check. + #[cfg(unix)] + #[test] + fn recovery_rewrites_a_stamp_an_older_updater_left_behind() { + let tmp = tempfile::tempdir().unwrap(); + let kin_home = tmp.path().join("kin-home"); + write_bundle(&kin_home, LINUX_COMPONENTS, b"old-"); + fs::write( + kin_home.join("bin/kin"), + test_binary_with_build_identity("0.8.6", true), + ) + .unwrap(); + write_launcher_stamp_for_test(&kin_home, b"0.6.6\n", 0o644); + let scratch = format!(".{NPM_LAUNCHER_STAMP}.tmp-{}", uuid::Uuid::new_v4()); + fs::write(kin_home.join("bin").join(&scratch), b"0.8.").unwrap(); + // A launcher's own scratch name may belong to a write in progress. + let launcher_scratch = format!(".{NPM_LAUNCHER_STAMP}.tmp-4242-{}", uuid::Uuid::new_v4()); + fs::write(kin_home.join("bin").join(&launcher_scratch), b"0.8.").unwrap(); + + let lock = InstallRootLock::acquire_existing(&kin_home).unwrap(); + recover_stale_transactions(&lock, LINUX_COMPONENTS).unwrap(); + assert_eq!( + fs::read(launcher_stamp_path(&kin_home)).unwrap(), + b"0.8.6\n" + ); + assert!(!kin_home.join("bin").join(&scratch).exists()); + assert!(kin_home.join("bin").join(&launcher_scratch).exists()); + assert_eq!( + repair_launcher_stamp_locked(&lock, LINUX_COMPONENTS).unwrap(), + LauncherStampRepair::Current + ); + + fs::write(kin_home.join("bin/kin"), b"a CLI with no build identity").unwrap(); + write_launcher_stamp_for_test(&kin_home, b"0.6.6\n", 0o644); + let repair = repair_launcher_stamp_locked(&lock, LINUX_COMPONENTS).unwrap(); + assert!(matches!(repair, LauncherStampRepair::Left(_)), "{repair:?}"); + assert_eq!( + fs::read(launcher_stamp_path(&kin_home)).unwrap(), + b"0.6.6\n" + ); } #[test] - fn raw_tar_preflight_globally_bounds_a_small_gzip_bomb() { - let raw = vec![0_u8; MAX_TAR_FORMAT_OVERHEAD_BYTES as usize + 1025]; - let archive = gzip_test_tar(&raw); - assert!( - archive.len() < 16 * 1024, - "fixture must remain a small compressed bomb, got {} bytes", - archive.len() - ); - let error = extract_named_file_from_tar_gz_with_limits( - &archive, - "kin", - ArchiveSizeLimits { - compressed_bytes: archive.len(), - entry_bytes: 0, - expanded_bytes: 0, - }, + fn the_health_inspection_compares_the_stamp_with_the_installed_cli() { + let tmp = tempfile::tempdir().unwrap(); + let kin_home = tmp.path().join("kin-home"); + let spec = platform_bundle_spec(std::env::consts::OS).unwrap(); + let cli = installed_cli_component(spec).unwrap(); + fs::create_dir_all(kin_home.join("bin")).unwrap(); + fs::write( + component_path(&kin_home, cli), + test_binary_with_build_identity("0.8.6", true), ) - .expect_err("every decompressed trailer byte must count toward the global bound"); - assert!(format!("{error:#}").contains("global limit")); + .unwrap(); + + assert_eq!( + inspect_launcher_stamp(&kin_home), + LauncherStampInspection::Absent + ); + + write_launcher_stamp_for_test(&kin_home, b"0.6.6\n", 0o644); + assert_eq!( + inspect_launcher_stamp(&kin_home), + LauncherStampInspection::Disagrees { + path: launcher_stamp_path(&kin_home), + recorded: "0.6.6".to_string(), + installed: "0.8.6".to_string(), + } + ); + + write_launcher_stamp_for_test(&kin_home, b"v0.8.6\n", 0o644); + assert_eq!( + inspect_launcher_stamp(&kin_home), + LauncherStampInspection::Current { + path: launcher_stamp_path(&kin_home), + version: "0.8.6".to_string(), + } + ); + + fs::write(component_path(&kin_home, cli), b"no identity here").unwrap(); + assert!(matches!( + inspect_launcher_stamp(&kin_home), + LauncherStampInspection::Unverifiable { .. } + )); + + fs::remove_file(launcher_stamp_path(&kin_home)).unwrap(); + fs::create_dir(launcher_stamp_path(&kin_home)).unwrap(); + assert!(matches!( + inspect_launcher_stamp(&kin_home), + LauncherStampInspection::Unusable { .. } + )); } + #[cfg(not(unix))] #[test] #[serial] - fn full_bundle_install_includes_vfs_and_shim_under_custom_root() { + fn windows_before_commit_rejection_restores_original_bundle() { if !kin_core::test_env::alone_in_process() { + println!("WINDOWS_PRECOMMIT_ROLLBACK_CONTROL_PASS"); return; } let tmp = tempfile::tempdir().unwrap(); - let custom_home = tmp.path().join("custom-kin-home"); + let kin_home = tmp.path().join("kin-home"); let stage = tmp.path().join("stage"); - let _environment = isolated_update_environment(tmp.path(), &custom_home); - let _cwd = CwdGuard::set(tmp.path()); - write_bundle(&custom_home, LINUX_COMPONENTS, b"old-"); - - let archive = full_linux_archive("kin-linux-x86_64"); - stage_archive( - &archive, - "kin-linux-x86_64.tar.gz", - &stage, - LINUX_COMPONENTS, - ) - .unwrap(); - let outcome = install_staged_bundle( - &custom_home, + let _environment = isolated_update_environment(tmp.path(), &kin_home); + write_bundle(&kin_home, WINDOWS_COMPONENTS, b"old-"); + let original_generation = + snapshot_managed_bundle_generation(&kin_home, WINDOWS_COMPONENTS).unwrap(); + let original: Vec<_> = WINDOWS_COMPONENTS + .iter() + .map(|component| { + let path = component_path(&kin_home, *component); + let bytes = fs::read(&path).unwrap(); + (path, bytes) + }) + .collect(); + fs::create_dir_all(stage.join("bin")).unwrap(); + fs::create_dir_all(stage.join("lib")).unwrap(); + for component in WINDOWS_COMPONENTS + .iter() + .filter(|component| matches!(component.name, "kin.exe" | "kin-daemon.exe")) + { + fs::write( + component_path(&stage, *component), + format!("new-{}", component.name), + ) + .unwrap(); + } + let verified = staged_identities_for_test(&stage, WINDOWS_COMPONENTS).unwrap(); + let mut pending = test_restart_pending("0.8.7"); + pending.runtime_obligations = [ + (RuntimeKind::Daemon, "kin-daemon.exe"), + (RuntimeKind::Mcp, "kin.exe"), + ] + .into_iter() + .map(|(kind, component)| RuntimeRestartObligation { + kind, + component: component.to_string(), + expected_identity: verified.get(component).unwrap().clone(), + prior_sessions: Vec::new(), + }) + .collect(); + let lock = InstallRootLock::acquire_existing(&kin_home).unwrap(); + let mut reached_commit = false; + let error = install_staged_bundle_locked_with_hooks( + &lock, &stage, - LINUX_COMPONENTS, - "0.2.22", - &test_restart_pending("0.2.22"), + WINDOWS_COMPONENTS, + &verified, + "0.8.7", + &pending, + |_, _| Ok(()), + || { + reached_commit = true; + for component in WINDOWS_COMPONENTS { + let path = component_path(&kin_home, *component); + match verified.get(component.name) { + Some(identity) => { + verify_file_identity(&path, identity, "new pair before rejected commit") + .unwrap() + } + None => assert!(!path.exists(), "unstaged component must be backed up"), + } + } + let transactions = transaction_dirs(&kin_home).unwrap(); + assert_eq!(transactions.len(), 1); + assert!(matches!( + read_journal(&transactions[0]).unwrap().phase, + TransactionPhase::Installing + )); + assert!(!restart_pending_path(&kin_home).exists()); + assert!(!mcp_repair_pending_path(&kin_home).exists()); + anyhow::bail!("injected Windows before-commit rejection") + }, ) - .unwrap(); - - assert!(outcome.retained_backup.is_none()); - assert_eq!(fs::read(custom_home.join("bin/kin")).unwrap(), b"new-kin"); - assert_eq!( - fs::read(custom_home.join("bin/kin-daemon")).unwrap(), - b"new-daemon" + .expect_err("precommit rejection must roll back the activated pair"); + assert!( + reached_commit, + "control must reach the final callback after pair activation" ); - assert_eq!( - fs::read(custom_home.join("bin/kin-vfs")).unwrap(), - b"new-vfs" + assert!( + format!("{error:#}").contains("previous bundle was restored"), + "{error:#}" ); + for (path, bytes) in original { + assert_eq!(fs::read(&path).unwrap(), bytes, "{}", path.display()); + } + assert!(transaction_dirs(&kin_home).unwrap().is_empty()); assert_eq!( - fs::read(custom_home.join("lib/libkin_vfs_shim.so")).unwrap(), - b"new-shim" - ); - assert!( - !custom_home.join("bin/kin-mcp").exists(), - "obsolete standalone MCP binary must not survive the bundle update" + snapshot_managed_bundle_generation(&kin_home, WINDOWS_COMPONENTS).unwrap(), + original_generation ); + assert!(!restart_pending_path(&kin_home).exists()); + assert!(!mcp_repair_pending_path(&kin_home).exists()); + println!("WINDOWS_PRECOMMIT_ROLLBACK_CONTROL_PASS"); } #[test] @@ -17873,6 +19797,99 @@ cwd = {:?} assert!(ensure_mutating_update_supported("macos", false).is_ok()); } + #[cfg(unix)] + #[test] + fn windows_worker_handoff_requires_the_full_generation_and_distinct_exact_copy() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("kin-home"); + fs::create_dir_all(root.join("bin")).unwrap(); + fs::create_dir(root.join("lib")).unwrap(); + let cli_path = root.join("bin/kin"); + let worker_path = tmp.path().join("worker"); + fs::write(&cli_path, b"the installed CLI generation").unwrap(); + fs::write(root.join("bin/kin-daemon"), b"the paired daemon generation").unwrap(); + fs::copy(&cli_path, &worker_path).unwrap(); + let mut bundle = snapshot_managed_bundle_generation(&root, LINUX_COMPONENTS).unwrap(); + let cli = bundle.components.remove("kin").unwrap().unwrap(); + bundle + .components + .insert("kin.exe".to_string(), Some(cli.clone())); + let worker = ExecutingProcessAuthority::capture_test_file(&worker_path) + .unwrap() + .generation; + let build = EmbeddedBuildIdentity { + version: "0.8.6".to_string(), + commit: "a".repeat(40), + dirty: false, + source_known: true, + dependency_provenance: "locked".to_string(), + }; + let handoff = WindowsWorkerHandoff { + schema: WINDOWS_WORKER_SCHEMA.to_string(), + bundle: bundle.clone(), + build: build.clone(), + worker: worker.clone(), + preparing_process: crate::daemon_client::process_identity(std::process::id()) + .unwrap() + .unwrap(), + }; + validate_windows_worker_handoff(&handoff, &bundle, &worker, &build).unwrap(); + let roundtrip: WindowsWorkerHandoff = + serde_json::from_slice(&serde_json::to_vec(&handoff).unwrap()).unwrap(); + validate_windows_worker_handoff(&roundtrip, &bundle, &worker, &build).unwrap(); + + let mut replaced_worker = worker.clone(); + replaced_worker.binding.file += 1; + assert!( + validate_windows_worker_handoff(&handoff, &bundle, &replaced_worker, &build).is_err() + ); + let mut replaced_bundle = bundle.clone(); + replaced_bundle + .components + .get_mut("kin.exe") + .unwrap() + .as_mut() + .unwrap() + .binding + .file += 1; + assert!( + validate_windows_worker_handoff(&handoff, &replaced_bundle, &worker, &build).is_err() + ); + let mut changed_pair = bundle.clone(); + changed_pair + .components + .get_mut("kin-daemon") + .unwrap() + .as_mut() + .unwrap() + .identity + .sha256 = "b".repeat(64); + assert!(validate_windows_worker_handoff(&handoff, &changed_pair, &worker, &build).is_err()); + let mut same_version_other_source = build.clone(); + same_version_other_source.commit = "b".repeat(40); + assert!(validate_windows_worker_handoff( + &handoff, + &bundle, + &worker, + &same_version_other_source + ) + .is_err()); + let mut hardlink = handoff.clone(); + hardlink.worker.binding = cli.binding; + assert!( + validate_windows_worker_handoff(&hardlink, &bundle, &hardlink.worker, &build).is_err() + ); + let mut different_bytes = handoff.clone(); + different_bytes.worker.identity.sha256 = "b".repeat(64); + assert!(validate_windows_worker_handoff( + &different_bytes, + &bundle, + &different_bytes.worker, + &build + ) + .is_err()); + } + #[cfg(unix)] #[test] fn install_root_and_lock_symlinks_are_rejected_without_touching_targets() { @@ -18396,6 +20413,8 @@ cwd = {:?} false, false, false, + false, + None, ) .await .expect_err("check-only must report, not recover, a stale transaction"); @@ -18420,6 +20439,8 @@ cwd = {:?} false, false, false, + false, + None, ) .await .expect_err("check-only without --json must report, not recover, a stale transaction"); @@ -18488,6 +20509,7 @@ cwd = {:?} let executable_name = if cfg!(windows) { "kin.exe" } else { "kin" }; fs::create_dir_all(home.join(".codex")).unwrap(); fs::create_dir_all(kin_home.join("bin")).unwrap(); + fs::create_dir_all(kin_home.join("lib")).unwrap(); fs::copy( std::env::current_exe().unwrap(), kin_home.join("bin").join(executable_name), @@ -18510,21 +20532,44 @@ cwd = {:?} ) .unwrap(); let _home = EnvVarGuard::set("HOME", &home); + #[cfg(windows)] + let _userprofile = EnvVarGuard::set("USERPROFILE", &home); + #[cfg(windows)] + let _appdata = EnvVarGuard::set("APPDATA", home.join("AppData/Roaming")); + #[cfg(windows)] + let _localappdata = EnvVarGuard::set("LOCALAPPDATA", home.join("AppData/Local")); let _kin_home = EnvVarGuard::set("KIN_HOME", &kin_home); let _kin_dir = EnvVarGuard::set("KIN_DIR", tmp.path().join("wrong-install")); - let repaired = crate::commands::setup::remerge_existing_mcp_configs(); + let target = crate::commands::setup::McpRepairTarget { + id: "codex".to_owned(), + path: config.clone(), + repo_root: Some(repo.clone()), + captured_config_sha256: crate::commands::setup_ledger::sha256_hex( + &fs::read(&config).unwrap(), + ), + }; + let _install = InstallRootLock::acquire_existing(&kin_home).unwrap(); + let topology = crate::commands::setup::McpTopologyLock::acquire().unwrap(); + let repaired = + crate::commands::setup::remerge_mcp_targets_exact_with_topology_and_finalizer( + &[target], + &topology, + || Ok(()), + ) + .unwrap(); let normalized_config = crate::commands::setup::ConfigLock::normalized_path(&config) .expect("test MCP config path must normalize"); assert!(repaired.contains(&normalized_config)); let root: toml::Value = toml::from_str(&fs::read_to_string(config).unwrap()).unwrap(); let entry = &root["mcp_servers"]["kin"]; - let expected_launcher = kin_home - .join("bin") - .join(executable_name) - .to_string_lossy() - .into_owned(); - assert_eq!(entry["command"].as_str(), Some(expected_launcher.as_str())); + let expected_launcher = kin_home.join("bin").join(executable_name); + assert_eq!( + Path::new(entry["command"].as_str().unwrap()) + .canonicalize() + .unwrap(), + expected_launcher.canonicalize().unwrap() + ); assert_eq!(entry["cwd"].as_str(), repo.to_str()); assert_eq!(entry["args"][2].as_str(), Some("--repo")); assert_eq!(entry["args"][3].as_str(), repo.to_str()); @@ -19344,13 +21389,17 @@ cwd = {:?} outcome .errors .iter() - .all(|error| error.contains("directory sync failure")), + .all(|error| error.contains("configured installation launcher is unavailable")), "{:?}", outcome.errors ); let lock = InstallRootLock::acquire_existing(&state.kin_home).unwrap(); - assert!(attempt_pending_mcp_repair(&lock).is_err()); + let error = attempt_pending_mcp_repair(&lock).unwrap_err(); + assert!( + format!("{error:#}").contains("directory sync failure"), + "{error:#}" + ); assert_eq!(fs::read(&marker).unwrap(), marker_before); } @@ -19655,6 +21704,116 @@ cwd = {:?} )); } + #[test] + fn retained_mcp_restart_fence_requires_original_birth_to_exit() { + let identity = crate::daemon_client::current_process_identity().unwrap(); + let retained = vec![identity]; + let live = validate_retained_mcp_births( + &retained, + crate::daemon_client::process_identity_is_current, + ) + .expect_err("a retained process keeps readiness pending"); + assert!(format!("{live:#}").contains("is still live")); + validate_retained_mcp_births(&retained, |_| Ok(false)).unwrap(); + let unknown = validate_retained_mcp_births(&retained, |_| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "birth denied", + )) + }) + .expect_err("an unobservable process does not prove exit"); + assert!(format!("{unknown:#}").contains("cannot establish")); + } + + #[test] + fn retained_mcp_restart_fence_does_not_confuse_a_reused_pid() { + let current = crate::daemon_client::current_process_identity().unwrap(); + let mut prior = serde_json::to_value(¤t).unwrap(); + prior["birth_token"] = serde_json::Value::String("a-different-process-birth".into()); + let prior: crate::daemon_client::ProcessIdentity = serde_json::from_value(prior).unwrap(); + assert_eq!(prior.pid(), current.pid()); + validate_retained_mcp_births(&[prior], crate::daemon_client::process_identity_is_current) + .unwrap(); + } + + #[cfg(unix)] + #[test] + fn stop_before_update_marker_retains_mcp_birth_readiness() { + let (_tmp, kin_home, mut record) = fenced_runtime_convergence_fixture(); + let current = crate::daemon_client::current_process_identity().unwrap(); + record.retained_mcp_sessions = Some(vec![current]); + let bytes = serde_json::to_vec(&record).unwrap(); + let decoded: RestartPending = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(decoded.retained_mcp_sessions, record.retained_mcp_sessions); + let error = validate_runtime_convergence( + &decoded, + &kin_home, + LINUX_COMPONENTS, + &[], + &HashMap::new(), + ) + .expect_err("installed bytes do not prove retained MCP processes restarted"); + assert!(format!("{error:#}").contains("pre-update MCP transport")); + } + + #[cfg(target_os = "macos")] + #[test] + fn legacy_quiescent_restart_requires_authoritative_prior_process_absence() { + let tmp = tempfile::tempdir().unwrap(); + let kin_home = tmp.path().join("kin-home"); + write_bundle(&kin_home, LINUX_COMPONENTS, b"new-"); + let mut record = test_restart_pending("0.2.22"); + for obligation in &mut record.runtime_obligations { + obligation.expected_identity = + file_identity(&kin_home.join("bin").join(&obligation.component)).unwrap(); + } + assert!( + validate_quiescent_legacy_restart(&record, &kin_home, LINUX_COMPONENTS, &[]).unwrap() + ); + let child = std::process::Command::new("/bin/sleep") + .arg("1") + .spawn() + .unwrap(); + let pid = child.id(); + let identity = crate::daemon_client::process_identity(pid) + .unwrap() + .unwrap(); + let prior_executable_identity = record.runtime_obligations[0].expected_identity.clone(); + record.runtime_obligations[0] + .prior_sessions + .push(RuntimeSessionAtUpdate { + pid, + start_time: 1, + executable: kin_home.join("bin/kin-daemon"), + executable_identity: prior_executable_identity, + binding: None, + }); + let occupied = validate_quiescent_legacy_restart(&record, &kin_home, LINUX_COMPONENTS, &[]) + .expect_err("a present PID never substitutes for exact legacy birth proof"); + assert!(format!("{occupied:#}").contains("is occupied")); + let mut child = child; + child.wait().unwrap(); + assert!(!crate::daemon_client::process_identity_is_current(&identity).unwrap()); + assert!( + validate_quiescent_legacy_restart(&record, &kin_home, LINUX_COMPONENTS, &[]).unwrap() + ); + let evidence = [RuntimeSessionEvidence { + kind: RuntimeKind::Daemon, + pid, + }]; + assert!(!validate_quiescent_legacy_restart( + &record, + &kin_home, + LINUX_COMPONENTS, + &evidence + ) + .unwrap()); + record.runtime_obligations[0].expected_identity.sha256 = "e".repeat(64); + assert!( + validate_quiescent_legacy_restart(&record, &kin_home, LINUX_COMPONENTS, &[]).is_err() + ); + } + #[cfg(not(target_os = "linux"))] #[test] fn legacy_restart_ack_fails_without_pid_mapped_identity_authority() { @@ -22249,3 +24408,55 @@ cwd = {:?} ); } } + +fn print_available_runtime_update(latest: &str, selected_commit: &str) { + let current_commit = kin_core::runtime_identity::RuntimeIdentity::current().commit_sha; + println!( + "Update available: v{CURRENT_VERSION} (source {current_commit}) -> v{latest} (source {selected_commit})" + ); +} + +/// Equal version labels do not establish equal installed source. The selected +/// published channel can replace a different build at the same version, while +/// an older release never causes an implicit downgrade. Unpublished candidates +/// use development mode, which cannot mutate a managed installation. +fn managed_runtime_update_available( + current_version: &Version, + current_commit: &str, + release_version: &Version, + release_commit: &str, +) -> bool { + release_version > current_version + || (release_version == current_version + && !release_commit.eq_ignore_ascii_case(current_commit)) +} + +#[cfg(test)] +mod managed_runtime_release_tests { + use super::*; + + #[test] + fn managed_runtime_update_distinguishes_source_at_the_same_version() { + let current = Version::parse("0.8.6").unwrap(); + let original = "522ede0d0dfbdd33ff8ba3a5798bb3bed6902c08"; + let different = "25ba64660ab5e4bf155bc07e20997e9f262ceaac"; + assert!(!managed_runtime_update_available( + ¤t, original, ¤t, original + )); + assert!(managed_runtime_update_available( + ¤t, original, ¤t, different + )); + assert!(!managed_runtime_update_available( + ¤t, + original, + &Version::parse("0.8.5").unwrap(), + different + )); + assert!(managed_runtime_update_available( + ¤t, + original, + &Version::parse("0.8.7").unwrap(), + original + )); + } +} diff --git a/crates/kin-cli/src/commands/update_bootstrap.rs b/crates/kin-cli/src/commands/update_bootstrap.rs new file mode 100644 index 000000000..d0ebbcd49 --- /dev/null +++ b/crates/kin-cli/src/commands/update_bootstrap.rs @@ -0,0 +1,648 @@ +//! Published-release migration for an older managed installation. +//! +//! The incoming process is deliberately not authorized as the original managed +//! executable. Its independently authenticated release bytes and the original +//! full install generation form a separate, bounded bootstrap authority. + +use super::*; +use kin_core::runtime_identity::RuntimeIdentity; + +struct BootstrapReleasePins { + release: ReleaseExpectation, + target: String, + archive_name: String, +} + +impl BootstrapReleasePins { + fn new( + version: Version, + commit_sha: String, + archive_sha256: String, + target: String, + archive_name: String, + ) -> Result { + let release = ReleaseExpectation::from_options( + Some(version), + Some(commit_sha), + Some(archive_sha256), + )? + .context("published bootstrap requires every release pin")?; + if target.is_empty() || archive_name != current_platform_asset_name()? { + anyhow::bail!( + "published bootstrap target and archive must name this platform's release" + ); + } + Ok(Self { + release, + target, + archive_name, + }) + } + + fn validate_incoming(&self, identity: &RuntimeIdentity) -> Result<()> { + identity.require_managed().map_err(anyhow::Error::msg)?; + if identity.version != self.release.version.to_string() + || identity.commit_sha != self.release.commit_sha + || identity.target != self.target + { + anyhow::bail!( + "incoming bootstrap executable does not match the pinned published version, source commit, and target" + ); + } + Ok(()) + } + + fn validate_prepared(&self, prepared: &PreparedPinnedRelease) -> Result<()> { + self.release + .validate_selected_release(&prepared.version, &prepared.commit_sha)?; + self.release + .validate_archive_bytes(&prepared.archive_bytes)?; + if prepared.archive_name != self.archive_name + || prepared.asset()?.name != self.archive_name + || prepared.provenance.target != self.target + { + anyhow::bail!("verified release does not match the bootstrap target and archive pins"); + } + Ok(()) + } +} + +/// This type cannot authorize an ordinary update: it never implements or +/// constructs `UpdaterStartAuthority`, whose executing-object binding remains +/// the exact original managed CLI. +struct BootstrapInstallAuthority { + original: ManagedBundleGeneration, + #[cfg(unix)] + original_notifier: Option, + incoming: ExecutingProcessAuthority, + runtime: RuntimeIdentity, +} + +#[cfg(unix)] +#[derive(Clone, Debug, PartialEq, Eq)] +struct BootstrapNotifierGeneration { + identity: BundleIdentity, + binding: PlatformObjectIdentity, +} + +#[cfg(unix)] +fn bootstrap_notifier_generation( + lib: &AnchoredDir, + spec: &[ComponentSpec], +) -> Result> { + if !spec_carries_notifier_bundle(spec) { + return Ok(None); + } + let Some(identity) = bundle_identity_at(lib, NOTIFIER_BUNDLE_DIR)? else { + return Ok(None); + }; + let bundle = lib.open_child(NOTIFIER_BUNDLE_DIR)?; + lib.ensure_child_binding(NOTIFIER_BUNDLE_DIR, &bundle)?; + if bundle_identity_at(lib, NOTIFIER_BUNDLE_DIR)?.as_ref() != Some(&identity) { + anyhow::bail!("managed notification bundle changed while bootstrap identity was read"); + } + lib.ensure_child_binding(NOTIFIER_BUNDLE_DIR, &bundle)?; + Ok(Some(BootstrapNotifierGeneration { + identity, + binding: PlatformObjectIdentity { + namespace: bundle.dev, + file: bundle.ino, + }, + })) +} + +#[cfg(unix)] +fn snapshot_bootstrap_notifier_generation( + root: &Path, + spec: &[ComponentSpec], +) -> Result> { + let root_dir = AnchoredDir::open_ambient(root)?; + let lib = root_dir.open_child("lib")?; + root_dir.ensure_child_binding("lib", &lib)?; + let generation = bootstrap_notifier_generation(&lib, spec)?; + root_dir.ensure_child_binding("lib", &lib)?; + Ok(generation) +} + +impl BootstrapInstallAuthority { + fn capture(requested_home: &Path, spec: &[ComponentSpec]) -> Result { + Self::capture_with_incoming( + requested_home, + spec, + ExecutingProcessAuthority::capture()?, + RuntimeIdentity::current(), + ) + } + + fn capture_with_incoming( + requested_home: &Path, + spec: &[ComponentSpec], + incoming: ExecutingProcessAuthority, + runtime: RuntimeIdentity, + ) -> Result { + runtime.require_managed().map_err(anyhow::Error::msg)?; + let original = snapshot_managed_bundle_generation(requested_home, spec)?; + #[cfg(unix)] + let original_notifier = snapshot_bootstrap_notifier_generation(&original.root, spec)?; + let cli_name = if cfg!(windows) { "kin.exe" } else { "kin" }; + let original_cli = original + .components + .get(cli_name) + .and_then(Option::as_ref) + .context("published bootstrap requires an existing managed CLI")?; + if incoming.generation.binding == original_cli.binding + || incoming.path.starts_with(&original.root) + { + anyhow::bail!( + "published bootstrap must run a separate staged release executable outside the managed installation" + ); + } + incoming.verify_durable_identity()?; + let confirmed = snapshot_managed_bundle_generation(requested_home, spec)?; + if original != confirmed { + anyhow::bail!("managed Kin bundle changed while bootstrap authority was captured"); + } + #[cfg(unix)] + if original_notifier != snapshot_bootstrap_notifier_generation(&confirmed.root, spec)? { + anyhow::bail!( + "managed notification bundle changed while bootstrap authority was captured" + ); + } + Ok(Self { + original: confirmed, + #[cfg(unix)] + original_notifier, + incoming, + runtime, + }) + } + + fn verify_locked(&self, lock: &InstallRootLock, spec: &[ComponentSpec]) -> Result<()> { + self.incoming.verify_durable_identity()?; + verify_managed_bundle_generation_locked(lock, spec, &self.original)?; + #[cfg(unix)] + { + let install = lock.install()?; + if bootstrap_notifier_generation(&install.lib, spec)? != self.original_notifier { + anyhow::bail!("managed notification bundle changed during bootstrap preflight"); + } + install.ensure_bound()?; + } + Ok(()) + } + + fn validate_legacy_target( + &self, + spec: &[ComponentSpec], + incoming_version: &Version, + ) -> Result<()> { + let cli_name = if cfg!(windows) { "kin.exe" } else { "kin" }; + let installed_version = crate::commands::runtime::probe_legacy_cli_version( + &self.original.root.join("bin").join(cli_name), + )?; + ensure_bootstrap_is_not_a_downgrade(&installed_version, incoming_version)?; + if crate::commands::runtime::inspect(&crate::commands::runtime::StatusOptions::default()) + .ready + { + anyhow::bail!( + "managed Kin already supports the current runtime contract; invoke its exact installed updater" + ); + } + self.incoming.verify_durable_identity()?; + let confirmed = snapshot_managed_bundle_generation(&self.original.root, spec)?; + if confirmed != self.original { + anyhow::bail!("managed Kin generation changed during the legacy version probe"); + } + #[cfg(unix)] + if snapshot_bootstrap_notifier_generation(&self.original.root, spec)? + != self.original_notifier + { + anyhow::bail!("managed notification bundle changed during the legacy version probe"); + } + Ok(()) + } + + fn validate_published_binding(&self, prepared: &PreparedPinnedRelease) -> Result<()> { + let cli_name = if cfg!(windows) { "kin.exe" } else { "kin" }; + let published_cli = prepared + .provenance_identities + .get(cli_name) + .context("verified published archive has no CLI identity")?; + validate_bootstrap_release_binding( + &self.incoming.build, + &self.runtime, + &self.incoming.generation.identity, + &prepared.version, + &prepared.commit_sha, + &prepared.provenance.target, + &prepared.provenance.kin.embedded_dependency_provenance, + published_cli, + ) + } +} + +fn ensure_bootstrap_is_not_a_downgrade( + installed_version: &Version, + incoming_version: &Version, +) -> Result<()> { + if installed_version > incoming_version { + anyhow::bail!( + "published bootstrap cannot downgrade installed Kin {installed_version} to {incoming_version}" + ); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn validate_bootstrap_release_binding( + embedded: &EmbeddedBuildIdentity, + runtime: &RuntimeIdentity, + mapped: &FileIdentity, + version: &Version, + commit_sha: &str, + target: &str, + dependency_provenance: &str, + published_cli: &FileIdentity, +) -> Result<()> { + runtime.require_managed().map_err(anyhow::Error::msg)?; + embedded.require_published_release_identity()?; + if embedded.version != version.to_string() + || embedded.commit != commit_sha + || embedded.dependency_provenance != dependency_provenance + || runtime.version != embedded.version + || runtime.commit_sha != embedded.commit + || runtime.target != target + || mapped != published_cli + { + anyhow::bail!( + "incoming mapped bootstrap executable is not the exact CLI authenticated by the pinned published archive" + ); + } + Ok(()) +} + +/// Migrate an existing installation without relying on its older command API. +/// No managed byte, lock, preference, marker, or registration is written before +/// the incoming executable and every pinned remote input have been verified. +#[allow(clippy::too_many_arguments)] +pub async fn run_bootstrap_published( + channel: Channel, + version: Version, + commit_sha: String, + archive_sha256: String, + target: String, + archive_name: String, + apply: bool, +) -> Result<()> { + let pins = + BootstrapReleasePins::new(version, commit_sha, archive_sha256, target, archive_name)?; + pins.validate_incoming(&RuntimeIdentity::current())?; + registry_authority_preflight()?; + let requested_home = crate::commands::setup::kin_dir()?; + let inspected_home = validate_existing_install_root(&requested_home)?; + let spec = platform_bundle_spec(std::env::consts::OS)?; + if !transaction_dirs(&inspected_home)?.is_empty() { + anyhow::bail!( + "an interrupted native update requires recovery before published bootstrap; the existing installation was not modified" + ); + } + refuse_restart_marker_before_remote_preflight(&inspected_home)?; + ensure_no_active_runtimes_but_daemons(&inspected_home, spec).context( + "published bootstrap requires managed VFS servers to be stopped before release preflight", + )?; + let authority = BootstrapInstallAuthority::capture(&inspected_home, spec)?; + authority.validate_legacy_target(spec, &pins.release.version)?; + + let client = build_update_http_client()?; + let prepared = + prepare_pinned_release(&client, channel, &pins.release, &requested_home, spec).await?; + pins.validate_prepared(&prepared)?; + authority.validate_published_binding(&prepared)?; + + let lock = InstallRootLock::acquire_existing(&requested_home)?; + refuse_new_update_while_restart_marker_exists(&lock)?; + authority.verify_locked(&lock, spec)?; + if !transaction_dirs(lock.root())?.is_empty() { + anyhow::bail!("another native update interrupted the captured bootstrap generation"); + } + ensure_no_active_runtimes_but_daemons(lock.root(), spec).context( + "published bootstrap install phase requires managed VFS servers to remain stopped", + )?; + cleanup_stale_staging_dirs(&lock)?; + authority.verify_locked(&lock, spec)?; + let locked_channel = + resolve_channel_locked(lock.root(), Some(channel), false, true, Some(&lock)); + ensure_pinned_channel_unchanged(channel, locked_channel)?; + install_bootstrap_prepared_release(&lock, &prepared, &authority, spec).await?; + drop(lock); + // Only a fresh invocation of the exact installed CLI can clear the native + // restart fence; the incoming bootstrap process never acknowledges itself. + if apply { + return run_chain_tail(); + } + Ok(()) +} + +async fn install_bootstrap_prepared_release( + lock: &InstallRootLock, + prepared: &PreparedPinnedRelease, + authority: &BootstrapInstallAuthority, + spec: &[ComponentSpec], +) -> Result<()> { + authority.verify_locked(lock, spec)?; + authority.validate_published_binding(prepared)?; + let latest = prepared.version.to_string(); + let staging = StagingDir::create(lock)?; + stage_archive_locked( + lock, + &staging, + &prepared.archive_bytes, + &prepared.asset()?.name, + spec, + )?; + validate_staged_artifact_provenance( + staging.path(), + spec, + &prepared.provenance_identities, + true, + )?; + validate_staged_static_build_identity(staging.path(), spec, &latest, &prepared.provenance)?; + replace_daemons_for_release(lock.root(), &latest, &prepared.commit_sha).await?; + authority.verify_locked(lock, spec)?; + let pending = restart_pending_record( + lock.root(), + &latest, + &prepared.provenance, + &prepared.provenance_identities, + spec, + )?; + let outcome = install_bootstrap_bundle_locked( + lock, + &staging, + spec, + &prepared.provenance_identities, + &latest, + &pending, + authority, + )?; + report_successful_install(lock, lock.root(), &latest, outcome) +} + +fn install_bootstrap_bundle_locked( + lock: &InstallRootLock, + staging: &StagingDir<'_>, + spec: &[ComponentSpec], + verified: &VerifiedStagedIdentities, + version: &str, + pending: &RestartPending, + authority: &BootstrapInstallAuthority, +) -> Result { + if !std::ptr::eq(lock, staging.lock) { + anyhow::bail!("bootstrap staging authority does not match the held install lock"); + } + refuse_new_update_while_restart_marker_exists(lock)?; + authority.verify_locked(lock, spec)?; + #[cfg(unix)] + { + install_staged_bundle_unix_with_hooks( + lock, + staging.layout(), + spec, + verified, + version, + pending, + |_, _| authority.incoming.verify_durable_identity(), + |_, _| authority.incoming.verify_durable_identity(), + |_| authority.incoming.verify_durable_identity(), + ) + } + #[cfg(not(unix))] + { + install_staged_bundle_locked_with_hooks( + lock, + staging.path(), + spec, + verified, + version, + pending, + |_, _| authority.incoming.verify_durable_identity(), + || authority.incoming.verify_durable_identity(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release_identity() -> (EmbeddedBuildIdentity, RuntimeIdentity, FileIdentity) { + let sha = "a".repeat(40); + ( + EmbeddedBuildIdentity { + version: "0.8.7".into(), + commit: sha.clone(), + dirty: false, + source_known: true, + dependency_provenance: "provenance".into(), + }, + RuntimeIdentity { + version: "0.8.7".into(), + commit_sha: sha, + source_dirty: false, + source_known: true, + target: "test-target".into(), + build_profile: "release".into(), + }, + FileIdentity { + size_bytes: 100, + sha256: "b".repeat(64), + }, + ) + } + + fn binding( + embedded: &EmbeddedBuildIdentity, + runtime: &RuntimeIdentity, + mapped: &FileIdentity, + published: &FileIdentity, + ) -> Result<()> { + validate_bootstrap_release_binding( + embedded, + runtime, + mapped, + &Version::parse("0.8.7").unwrap(), + &"a".repeat(40), + "test-target", + "provenance", + published, + ) + } + + #[test] + fn published_bootstrap_requires_exact_release_bytes_and_embedded_identity() { + let (mut embedded, mut runtime, mapped) = release_identity(); + assert!(binding(&embedded, &runtime, &mapped, &mapped).is_ok()); + let mut other_bytes = mapped.clone(); + other_bytes.sha256 = "c".repeat(64); + assert!(binding(&embedded, &runtime, &mapped, &other_bytes).is_err()); + other_bytes = mapped.clone(); + other_bytes.size_bytes += 1; + assert!(binding(&embedded, &runtime, &mapped, &other_bytes).is_err()); + embedded.dependency_provenance = "another-source".into(); + assert!(binding(&embedded, &runtime, &mapped, &mapped).is_err()); + embedded.dependency_provenance = "provenance".into(); + runtime.target = "another-target".into(); + assert!(binding(&embedded, &runtime, &mapped, &mapped).is_err()); + } + + #[test] + fn published_bootstrap_refuses_dirty_unknown_and_development_builds() { + let (mut embedded, mut runtime, mapped) = release_identity(); + embedded.dirty = true; + assert!(binding(&embedded, &runtime, &mapped, &mapped).is_err()); + embedded.dirty = false; + embedded.source_known = false; + assert!(binding(&embedded, &runtime, &mapped, &mapped).is_err()); + embedded.source_known = true; + runtime.build_profile = "iteration".into(); + assert!(binding(&embedded, &runtime, &mapped, &mapped).is_err()); + runtime.build_profile = "release".into(); + runtime.source_dirty = true; + assert!(binding(&embedded, &runtime, &mapped, &mapped).is_err()); + runtime.source_dirty = false; + runtime.commit_sha = "a".repeat(12); + assert!(binding(&embedded, &runtime, &mapped, &mapped).is_err()); + } + + #[test] + fn published_bootstrap_refuses_an_already_newer_installation() { + let incoming = Version::parse("0.8.7").unwrap(); + assert!( + ensure_bootstrap_is_not_a_downgrade(&Version::parse("0.8.1").unwrap(), &incoming,) + .is_ok() + ); + assert!(ensure_bootstrap_is_not_a_downgrade(&incoming, &incoming).is_ok()); + assert!( + ensure_bootstrap_is_not_a_downgrade(&Version::parse("0.8.8").unwrap(), &incoming,) + .is_err() + ); + } + + #[cfg(unix)] + fn authority_fixture() -> (tempfile::TempDir, BootstrapInstallAuthority) { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("managed"); + fs::create_dir_all(home.join("bin")).unwrap(); + fs::create_dir_all(home.join("lib")).unwrap(); + fs::write(home.join("bin/kin"), b"old-cli").unwrap(); + fs::write(home.join("bin/kin-daemon"), b"old-daemon").unwrap(); + let incoming_path = temp.path().join("incoming-kin"); + fs::write(&incoming_path, b"verified-incoming-cli").unwrap(); + let incoming = ExecutingProcessAuthority::capture_test_file(&incoming_path).unwrap(); + let (_, runtime, _) = release_identity(); + let authority = BootstrapInstallAuthority::capture_with_incoming( + &home, + platform_bundle_spec(std::env::consts::OS).unwrap(), + incoming, + runtime, + ) + .unwrap(); + (temp, authority) + } + + #[cfg(unix)] + #[test] + fn published_bootstrap_fences_the_full_original_generation() { + let (_temp, authority) = authority_fixture(); + let spec = platform_bundle_spec(std::env::consts::OS).unwrap(); + let lock = InstallRootLock::acquire_existing(&authority.original.root).unwrap(); + assert!(authority.verify_locked(&lock, spec).is_ok()); + fs::write( + authority.original.root.join("bin/kin-daemon"), + b"new-daemon", + ) + .unwrap(); + assert!(authority.verify_locked(&lock, spec).is_err()); + } + + #[cfg(unix)] + #[test] + fn published_bootstrap_refuses_identical_bytes_on_replaced_original_object() { + let (_temp, authority) = authority_fixture(); + let spec = platform_bundle_spec(std::env::consts::OS).unwrap(); + let destination = authority.original.root.join("bin/kin"); + let replacement = authority.original.root.join("bin/replacement"); + fs::write(&replacement, b"old-cli").unwrap(); + fs::rename(replacement, destination).unwrap(); + let lock = InstallRootLock::acquire_existing(&authority.original.root).unwrap(); + assert!(authority.verify_locked(&lock, spec).is_err()); + } + + #[cfg(unix)] + #[test] + fn published_bootstrap_rechecks_its_retained_incoming_image() { + let (_temp, authority) = authority_fixture(); + let spec = platform_bundle_spec(std::env::consts::OS).unwrap(); + let lock = InstallRootLock::acquire_existing(&authority.original.root).unwrap(); + fs::write(&authority.incoming.path, b"changed-incoming-cli").unwrap(); + assert!(authority.verify_locked(&lock, spec).is_err()); + } + + #[cfg(unix)] + #[test] + fn published_bootstrap_cannot_authorize_the_mapped_installed_cli() { + let (_temp, authority) = authority_fixture(); + let incoming = + ExecutingProcessAuthority::capture_test_file(&authority.original.root.join("bin/kin")) + .unwrap(); + let (_, runtime, _) = release_identity(); + assert!(BootstrapInstallAuthority::capture_with_incoming( + &authority.original.root, + platform_bundle_spec(std::env::consts::OS).unwrap(), + incoming, + runtime, + ) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn published_bootstrap_fences_notification_bundle_presence_bytes_and_object() { + for control in ["added", "changed", "replaced"] { + let (_temp, prior) = authority_fixture(); + let bundle = prior.original.root.join("lib").join(NOTIFIER_BUNDLE_DIR); + if control != "added" { + fs::create_dir(&bundle).unwrap(); + fs::write(bundle.join("member"), b"original").unwrap(); + } + let incoming = + ExecutingProcessAuthority::capture_test_file(&prior.incoming.path).unwrap(); + let (_, runtime, _) = release_identity(); + let authority = BootstrapInstallAuthority::capture_with_incoming( + &prior.original.root, + MACOS_COMPONENTS, + incoming, + runtime, + ) + .unwrap(); + match control { + "added" => { + fs::create_dir(&bundle).unwrap(); + fs::write(bundle.join("member"), b"added").unwrap(); + } + "changed" => { + fs::write(bundle.join("member"), b"changed").unwrap(); + } + "replaced" => { + let old = bundle.with_extension("old"); + fs::rename(&bundle, old).unwrap(); + fs::create_dir(&bundle).unwrap(); + fs::write(bundle.join("member"), b"original").unwrap(); + } + _ => unreachable!(), + } + let lock = InstallRootLock::acquire_existing(&authority.original.root).unwrap(); + assert!(authority.verify_locked(&lock, MACOS_COMPONENTS).is_err()); + } + } +} diff --git a/crates/kin-cli/src/commands/update_windows.rs b/crates/kin-cli/src/commands/update_windows.rs index 3a3bc5cf4..b2d016889 100644 --- a/crates/kin-cli/src/commands/update_windows.rs +++ b/crates/kin-cli/src/commands/update_windows.rs @@ -2593,6 +2593,9 @@ fn open_path_handle( ) -> Result { let path_wide = wide_null(path.as_os_str())?; let mut access = FILE_READ_ATTRIBUTES | READ_CONTROL; + if block_writes && !expect_directory { + access |= GENERIC_READ; + } if write_security { access |= WRITE_DAC | WRITE_OWNER; } @@ -2765,6 +2768,7 @@ pub(super) struct WindowsPrivateTempDir { path: PathBuf, parent: SecurePathGuard, tree: Option, + persisted: bool, } pub(crate) fn ensure_private_temp_container(parent: &Path, name: &str) -> Result { @@ -2793,8 +2797,9 @@ pub(crate) fn ensure_private_temp_container(parent: &Path, name: &str) -> Result impl WindowsPrivateTempDir { pub(super) fn create(parent: &Path, prefix: &str) -> Result { let user = CurrentUserSid::load()?; - let parent_guard = SecurePathGuard::open(parent, true, Some(&user), false, false)?; - parent_guard.validate(Some(&user))?; + let parent_user = (prefix != ".update-worker-").then_some(&user); + let parent_guard = SecurePathGuard::open(parent, true, parent_user, false, false)?; + parent_guard.validate(parent_user)?; for _ in 0..128 { let path = parent.join(format!("{prefix}{}", uuid::Uuid::new_v4())); if !create_private_directory(&path, &user)? { @@ -2808,7 +2813,7 @@ impl WindowsPrivateTempDir { } }; if let Err(error) = parent_guard - .validate(Some(&user)) + .validate(parent_user) .and_then(|()| tree.validate()) { drop(tree); @@ -2819,6 +2824,7 @@ impl WindowsPrivateTempDir { path, parent: parent_guard, tree: Some(tree), + persisted: false, }); } anyhow::bail!( @@ -2859,7 +2865,8 @@ impl WindowsPrivateTempDir { pub(super) fn validate(&self) -> Result<()> { let user = CurrentUserSid::load()?; - self.parent.validate(Some(&user))?; + self.parent + .validate(self.parent.require_private.then_some(&user))?; self.tree .as_ref() .context("private updater directory security was already released")? @@ -2877,7 +2884,431 @@ pub(super) fn private_directory_identity(path: &Path) -> Result Result { + self.validate()?; + self.persisted = true; + Ok(self.path.clone()) + } +} + +pub(super) fn file_object_identity(file: &File) -> Result { + object_identity(file.as_raw_handle().cast(), false) +} + +const WINDOWS_WORKER_CONSUMED_SCHEMA: &str = "kin.windows-update-worker-consumed.v1"; + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct WindowsWorkerConsumption { + schema: String, + directory_binding: PlatformObjectIdentity, + handoff_binding: PlatformObjectIdentity, + handoff_sha256: String, + pub(super) worker_process: crate::daemon_client::ProcessIdentity, +} + +/// A cleanup handle authenticates and disposes the same object. Withholding +/// write and delete sharing prevents a path replacement between those steps. +struct WindowsWorkerCleanupObject { + path: PathBuf, + directory: bool, + identity: PlatformObjectIdentity, + file: File, +} + +impl WindowsWorkerCleanupObject { + fn open(path: &Path, directory: bool, user: &CurrentUserSid) -> Result { + let path_wide = wide_null(path.as_os_str())?; + let handle = unsafe { + CreateFileW( + path_wide.as_ptr(), + GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL | DELETE, + FILE_SHARE_READ | if directory { FILE_SHARE_WRITE } else { 0 }, + null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT + | if directory { + FILE_FLAG_BACKUP_SEMANTICS + } else { + 0 + }, + null_mut(), + ) + }; + let handle = OwnedHandle::new( + handle, + &format!( + "failed to retain Windows worker cleanup object {}", + path.display() + ), + )?; + let identity = object_identity(handle.raw(), directory)?; + validate_private_security(handle.raw(), user, directory)?; + let object = Self { + path: path.to_path_buf(), + directory, + identity, + file: handle.into_file(), + }; + object.revalidate_worker_cleanup_object(user)?; + Ok(object) + } + + fn revalidate_worker_cleanup_object(&self, user: &CurrentUserSid) -> Result<()> { + let held = self.file.as_raw_handle().cast(); + anyhow::ensure!( + object_identity(held, self.directory)? == self.identity, + "retained Windows worker cleanup object changed" + ); + validate_private_security(held, user, self.directory)?; + let path_wide = wide_null(self.path.as_os_str())?; + let visible = unsafe { + CreateFileW( + path_wide.as_ptr(), + FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT + | if self.directory { + FILE_FLAG_BACKUP_SEMANTICS + } else { + 0 + }, + null_mut(), + ) + }; + let visible = OwnedHandle::new( + visible, + "could not validate visible Windows worker cleanup object", + )?; + anyhow::ensure!( + object_identity(visible.raw(), self.directory)? == self.identity, + "Windows worker cleanup path changed: {}", + self.path.display() + ); + validate_private_security(visible.raw(), user, self.directory) + } + + fn read_bounded(&self, user: &CurrentUserSid) -> Result> { + use std::io::{Read, Seek}; + self.revalidate_worker_cleanup_object(user)?; + let mut reader = &self.file; + reader.rewind()?; + let mut bytes = Vec::new(); + reader.take(64 * 1024 + 1).read_to_end(&mut bytes)?; + anyhow::ensure!( + bytes.len() <= 64 * 1024, + "Windows worker cleanup record exceeds its bound" + ); + self.revalidate_worker_cleanup_object(user)?; + Ok(bytes) + } + + fn require_names(&self, expected: &[&str], user: &CurrentUserSid) -> Result<()> { + self.revalidate_worker_cleanup_object(user)?; + let mut names = Vec::new(); + for entry in std::fs::read_dir(&self.path)? { + names.push(entry?.file_name()); + } + names.sort(); + let mut expected: Vec<_> = expected + .iter() + .map(|name| std::ffi::OsString::from(*name)) + .collect(); + expected.sort(); + anyhow::ensure!( + names == expected, + "Windows worker directory has unexpected entries; retained" + ); + self.revalidate_worker_cleanup_object(user) + } + + fn dispose_file(self, user: &CurrentUserSid, label: &str) -> Result<()> { + self.revalidate_worker_cleanup_object(user)?; + dispose_private_file_handle_exact(&self.file, &self.path, label)?; + let path = self.path.clone(); + drop(self); + require_worker_cleanup_absent(&path) + } + + fn dispose_directory(self, user: &CurrentUserSid) -> Result<()> { + use windows_sys::Win32::Storage::FileSystem::{FileDispositionInfo, FILE_DISPOSITION_INFO}; + self.revalidate_worker_cleanup_object(user)?; + self.require_names(&[], user)?; + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + let removed = unsafe { + SetFileInformationByHandle( + self.file.as_raw_handle().cast(), + FileDispositionInfo, + (&disposition as *const FILE_DISPOSITION_INFO).cast(), + size_of::() as u32, + ) + }; + if removed == 0 { + return Err(std::io::Error::last_os_error()) + .context("failed to dispose exact empty Windows worker directory; retained"); + } + let path = self.path.clone(); + drop(self); + require_worker_cleanup_absent(&path) + } +} + +fn require_worker_cleanup_absent(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(_) => anyhow::bail!( + "disposed Windows worker object remains visible at {}; cleanup is incomplete", + path.display() + ), + Err(error) => { + Err(error).context("could not confirm exact Windows worker object disposition") + } + } +} + +pub(super) struct WindowsWorkerCleanup { + directory: WindowsWorkerCleanupObject, + worker: WindowsWorkerCleanupObject, + handoff: WindowsWorkerCleanupObject, + claim: WindowsWorkerCleanupObject, + user: CurrentUserSid, +} + +impl WindowsWorkerCleanup { + pub(super) fn open(directory: &Path, handoff: &Path) -> Result { + let user = CurrentUserSid::load()?; + let directory_object = WindowsWorkerCleanupObject::open(directory, true, &user)?; + directory_object.require_names( + &[ + super::WINDOWS_WORKER_EXECUTABLE, + super::WINDOWS_WORKER_RECORD, + "claimed", + ], + &user, + )?; + let worker = WindowsWorkerCleanupObject::open( + &directory.join(super::WINDOWS_WORKER_EXECUTABLE), + false, + &user, + )?; + let handoff = WindowsWorkerCleanupObject::open(handoff, false, &user)?; + let claim = WindowsWorkerCleanupObject::open(&directory.join("claimed"), false, &user)?; + Ok(Self { + directory: directory_object, + worker, + handoff, + claim, + user, + }) + } + + pub(super) fn worker_file(&self) -> &File { + &self.worker.file + } + + pub(super) fn read_handoff(&self) -> Result> { + self.handoff.read_bounded(&self.user) + } + + pub(super) fn read_consumption(&self) -> Result { + serde_json::from_slice(&self.claim.read_bounded(&self.user)?) + .context("invalid Windows worker consumption evidence") + } + + pub(super) fn validate_consumption( + &self, + consumed: &WindowsWorkerConsumption, + handoff: &[u8], + ) -> Result<()> { + use sha2::{Digest, Sha256}; + anyhow::ensure!( + consumed.schema == WINDOWS_WORKER_CONSUMED_SCHEMA + && consumed.directory_binding == self.directory.identity + && consumed.handoff_binding == self.handoff.identity + && consumed.handoff_sha256 == hex::encode(Sha256::digest(handoff)) + && consumed.worker_process.pid() != 0, + "Windows worker consumption evidence or retained object binding changed" + ); + self.directory.require_names( + &[ + super::WINDOWS_WORKER_EXECUTABLE, + super::WINDOWS_WORKER_RECORD, + "claimed", + ], + &self.user, + )?; + self.worker.revalidate_worker_cleanup_object(&self.user)?; + self.handoff.revalidate_worker_cleanup_object(&self.user)?; + self.claim.revalidate_worker_cleanup_object(&self.user) + } + + pub(super) fn dispose(self, mut before_remove: F) -> Result<()> + where + F: FnMut() -> Result<()>, + { + let Self { + directory, + worker, + handoff, + claim, + user, + } = self; + directory.require_names( + &[ + super::WINDOWS_WORKER_EXECUTABLE, + super::WINDOWS_WORKER_RECORD, + "claimed", + ], + &user, + )?; + before_remove()?; + // Keep both records until the helper has actually disappeared. A live + // mapped image or sharing refusal therefore preserves its evidence. + worker.dispose_file(&user, "Windows copied update worker")?; + directory.require_names(&[super::WINDOWS_WORKER_RECORD, "claimed"], &user)?; + before_remove()?; + handoff.dispose_file(&user, "Windows worker handoff")?; + directory.require_names(&["claimed"], &user)?; + before_remove()?; + claim.dispose_file(&user, "Windows worker consumption evidence")?; + before_remove()?; + directory.dispose_directory(&user) + } +} + +/// Holds a prepared record and its private directory across the complete +/// worker transaction, without changing their permissions on consumption. +pub(super) struct WindowsWorkerFiles { + directory: SecurePathGuard, + record: SecurePathGuard, + claim: Option, + user: CurrentUserSid, +} + +impl WindowsWorkerFiles { + pub(super) fn open(directory: &Path, record: &Path) -> Result { + let user = CurrentUserSid::load()?; + let directory = SecurePathGuard::open(directory, true, Some(&user), false, false)?; + let record = SecurePathGuard::open(record, false, Some(&user), false, true)?; + let files = Self { + directory, + record, + claim: None, + user, + }; + files.validate()?; + Ok(files) + } + + pub(super) fn read_record(&self, path: &Path) -> Result> { + self.validate()?; + anyhow::ensure!( + path == self.record.path, + "Windows worker record path changed" + ); + anyhow::ensure!( + std::fs::metadata(path)?.len() <= 64 * 1024, + "Windows worker record exceeds its bound" + ); + let bytes = std::fs::read(path)?; + self.validate()?; + Ok(bytes) + } + + pub(super) fn claim(&mut self) -> Result<()> { + use sha2::{Digest, Sha256}; + use std::io::Write; + + self.validate()?; + let handoff = self.read_record(&self.record.path)?; + let consumption = WindowsWorkerConsumption { + schema: WINDOWS_WORKER_CONSUMED_SCHEMA.to_string(), + directory_binding: self.directory.identity.clone(), + handoff_binding: self.record.identity.clone(), + handoff_sha256: hex::encode(Sha256::digest(&handoff)), + worker_process: crate::daemon_client::process_identity(std::process::id())? + .context("could not capture the consuming Windows worker process")?, + }; + let path = self.directory.path.join("claimed"); + let created_binding; + { + let mut file = create_current_user_private_file_for_exact_commit(&path) + .context("Windows worker handoff was already consumed")?; + created_binding = file_object_identity(&file)?; + file.write_all(&serde_json::to_vec(&consumption)?)?; + kin_blobs::durable::sync_all(&file) + .context("failed to durably claim the Windows worker handoff")?; + } + self.claim = Some(SecurePathGuard::open( + &path, + false, + Some(&self.user), + false, + true, + )?); + anyhow::ensure!( + self.claim + .as_ref() + .context("Windows worker lost its claim")? + .identity + == created_binding, + "Windows worker consumption object changed during capture" + ); + self.validate() + } + + pub(super) fn validate(&self) -> Result<()> { + self.directory.validate(Some(&self.user))?; + self.record.validate(Some(&self.user))?; + if let Some(claim) = &self.claim { + claim.validate(Some(&self.user))?; + } + Ok(()) + } +} + +impl ExecutingFileAuthority { + /// The worker retains the original exact object without keeping its name + /// replacement-protected. Ordinary executing-image capture remains strict. + pub(super) fn capture_handoff_target( + path: &Path, + expected: &PlatformObjectIdentity, + ) -> Result { + let path_wide = wide_null(path.as_os_str())?; + // Deny in-place writes while permitting the existing transaction to + // rename the retained original object into its backup directory. + // SAFETY: all pointers reference live, NUL-terminated storage. + let handle = unsafe { + CreateFileW( + path_wide.as_ptr(), + windows_sys::Win32::Foundation::GENERIC_READ | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE, + null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, + null_mut(), + ) + }; + let handle = OwnedHandle::new(handle, "failed to retain the prepared managed CLI object")?; + let binding = object_identity(handle.raw(), false)?; + anyhow::ensure!( + &binding == expected, + "prepared managed CLI object changed before worker capture" + ); + Ok(Self { + path: path.to_path_buf(), + file: handle.into_file(), + binding, + }) } } @@ -3373,6 +3804,57 @@ mod tests { std::fs::remove_dir(container).unwrap(); } + #[test] + fn worker_handoff_target_guard_denies_writes_but_allows_transaction_rename() { + let container = private_test_container(); + let root = WindowsPrivateTempDir::create(&container, ".kin-worker-target-test-").unwrap(); + let target = root.path().join("kin.exe"); + std::fs::write(&target, b"original CLI").unwrap(); + let original = std::fs::File::open(&target).unwrap(); + let binding = file_object_identity(&original).unwrap(); + drop(original); + let authority = ExecutingFileAuthority::capture_handoff_target(&target, &binding).unwrap(); + assert!(std::fs::OpenOptions::new() + .write(true) + .open(&target) + .is_err()); + let backup = root.path().join("backup.exe"); + std::fs::rename(&target, &backup).unwrap(); + authority.validate().unwrap(); + std::fs::write(&target, b"replacement CLI").unwrap(); + assert!(ExecutingFileAuthority::capture_handoff_target(&target, &binding).is_err()); + drop(authority); + drop(root); + std::fs::remove_dir(container).unwrap(); + } + + #[test] + fn prepared_worker_record_is_private_immutable_and_consumed_only_once() { + let container = private_test_container(); + let mut root = + WindowsPrivateTempDir::create(&container, ".kin-worker-record-test-").unwrap(); + let record = root.path().join("handoff.json"); + std::fs::write(&record, b"{}\n").unwrap(); + root.seal_file(&record).unwrap(); + let path = root.persist_for_worker().unwrap(); + assert!(path.exists()); + let mut files = WindowsWorkerFiles::open(&path, &record).unwrap(); + assert_eq!(files.read_record(&record).unwrap(), b"{}\n"); + assert!(std::fs::OpenOptions::new() + .write(true) + .open(&record) + .is_err()); + files.claim().unwrap(); + assert!(files.claim().is_err()); + let mut competing = WindowsWorkerFiles::open(&path, &record).unwrap(); + assert!(competing.claim().is_err()); + files.validate().unwrap(); + drop(competing); + drop(files); + std::fs::remove_dir_all(&path).unwrap(); + std::fs::remove_dir(container).unwrap(); + } + #[test] fn hardlinked_candidates_and_guarded_path_replacements_fail_closed() { let container = private_test_container(); diff --git a/crates/kin-cli/src/commands/verify.rs b/crates/kin-cli/src/commands/verify.rs index 5160bc1b4..55df193a7 100644 --- a/crates/kin-cli/src/commands/verify.rs +++ b/crates/kin-cli/src/commands/verify.rs @@ -11,6 +11,8 @@ use kin_runtime::workspace::record_verification_evidence; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; +use crate::commands::repository_authority::RequestRepositoryAuthority; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VerifyRunRequest { pub entity: String, @@ -217,7 +219,13 @@ fn build_entity_verify_response( let mut uncovered_count = 0usize; for ent in &entities { - let tests = graph.get_tests_for_entity(&ent.id)?; + // Recorded test cases, and the test entities that reach it. + let mut tests = graph.get_tests_for_entity(&ent.id)?; + tests.extend( + tests_reaching(graph, &ent.id)? + .iter() + .map(|test| test_case_for_entity(test, &ent.id)), + ); if tests.is_empty() { uncovered_count += 1; lines.push(format!(" MISSING {} ({:?})", ent.name, ent.kind)); @@ -258,7 +266,7 @@ fn build_verify_summary_response(graph: &kin_db::InMemoryGraph) -> Result Result { - let summary = graph.get_coverage_summary()?; + let summary = coverage_reading(graph)?; if summary.missing_proof.is_empty() { return Ok(VerifyCommandResponse { lines: vec![format!( @@ -294,7 +302,7 @@ fn build_verify_missing_response(graph: &kin_db::InMemoryGraph) -> Result Result> { - let summary = graph.get_coverage_summary()?; + let summary = coverage_reading(graph)?; let mut lines = vec![ "Repository Coverage:".to_string(), format!( @@ -317,12 +325,29 @@ fn verify_summary_lines(graph: &kin_db::InMemoryGraph) -> Result> { Ok(lines) } -pub fn execute_verify_run( - layout: &kin_core::KinLayout, - binding: &kin_core::LocalRepositoryAuthorityBinding, +/// A `kin verify run` planned and ready to run: its proof set, the runner it +/// was asked for, and the command line that runs it. +pub struct PreparedVerifyRun { + entity: Entity, + work: WorkPlan, + runner: TestRunner, + runner_label: String, + line: String, + lines: Vec, +} + +impl PreparedVerifyRun { + pub fn command_line(&self) -> &str { + &self.line + } +} + +/// Plan `kin verify run`: resolve the entity, plan its proof set and build +/// the runner's command. Reads the graph only. +pub fn prepare_verify_run( graph: &kin_db::InMemoryGraph, request: &VerifyRunRequest, -) -> Result { +) -> Result { let plan = build_verification_plan(graph, &request.entity, request.depth)?; let test_runner = parse_runner(&request.runner)?; let cmd_str = build_runner_command(&test_runner, &plan.entity.name, &plan.tests); @@ -339,93 +364,1507 @@ pub fn execute_verify_run( lines.push(format!("Running: {}", cmd_str)); + let entity = plan.entity.clone(); + let mut work = WorkPlan::from_plans(vec![plan]); + work.capture_dependencies(graph)?; + Ok(PreparedVerifyRun { + entity, + work, + runner: test_runner, + runner_label: request.runner.clone(), + line: cmd_str, + lines, + }) +} + +/// The records of a finished `kin verify run`, and what it answers once they +/// are durable. +pub fn record_verify_run( + authority: &RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + prepared: &PreparedVerifyRun, + executed: &ExecutedRun, +) -> Result<(VerificationRecord, VerifyRunResponse)> { + let proved_entities = prepared.work.proved_entities.len(); + let proved_work = prepared.work.proved_work_items.len(); + let record = plan_verification_record( + authority, + graph, + &prepared.work, + prepared.runner.clone(), + executed, + "verify.run", + Some(WorkScope::Entity(prepared.entity.id)), + |run| { + format!( + "runner={}; status={}; tests={}; impacted_entities={}", + prepared.runner_label, + audit_status(run), + run.test_ids.len(), + proved_entities + ) + }, + )?; + let run = &record.run; + let mut lines = prepared.lines.clone(); + lines.push(String::new()); + lines.push("VerificationRun recorded:".to_string()); + lines.push(format!(" Run ID: {}", run.run_id)); + lines.push(format!( + " Entity: {} ({:?})", + prepared.entity.name, prepared.entity.kind + )); + lines.push(format!(" Status: {}", run_word(run))); + if let Some(reason) = &executed.could_not_run { + lines.push(format!(" Reason: {reason}")); + } + lines.push(format!(" Duration: {}ms", executed.duration_ms)); + lines.push(format!( + " Exit: {}", + executed + .exit_code + .map_or_else(|| "none".to_string(), |code| code.to_string()) + )); + lines.push(format!(" Tests: {}", run.test_ids.len())); + lines.push(format!(" Entities: {}", proved_entities)); + lines.push(format!(" Work: {}", proved_work)); + if let Some(ref blob) = run.evidence_blob { + lines.push(format!(" Evidence: {}", blob)); + } + if run.status != VerificationStatus::Passing && !executed.output_tail.is_empty() { + lines.push(" Last output:".to_string()); + lines.extend( + executed + .output_tail + .lines() + .map(|line| format!(" {line}")), + ); + } + Ok((record, VerifyRunResponse { lines })) +} + +/// One execution of a test command in the repository's working tree, before +/// it is recorded. +#[derive(Debug, Clone)] +pub struct ExecutedRun { + started_at: Timestamp, + finished_at: Timestamp, + duration_ms: u64, + /// `Passing` or `Failing` when the command ran, and `Pending` when it + /// could not run, which attests nothing about any test. + status: VerificationStatus, + /// The command's exit code. `None` when it has none: it never started, + /// or a signal ended it. + exit_code: Option, + /// Why the command could not run, when it could not. + could_not_run: Option, + /// The last lines it printed, as a reader is shown them. + output_tail: String, + evidence_text: String, +} + +impl ExecutedRun { + /// Read a command that finished: its exit code when it has one, and what + /// it printed to each stream. + fn finished(exit_code: Option, stdout: &str, stderr: &str) -> Self { + let could_not_run = could_not_run_reason(exit_code, stdout, stderr); + let status = match (&could_not_run, exit_code) { + (Some(_), _) => VerificationStatus::Pending, + (None, Some(0)) => VerificationStatus::Passing, + (None, _) => VerificationStatus::Failing, + }; + let streams = format!("=== STDOUT ===\n{stdout}\n=== STDERR ===\n{stderr}"); + let evidence_text = match &could_not_run { + Some(reason) => format!("=== COULD NOT RUN ===\n{reason}\n{streams}"), + None => streams, + }; + Self { + started_at: Timestamp::now(), + finished_at: Timestamp::now(), + duration_ms: 0, + status, + exit_code, + could_not_run, + output_tail: output_tail(stdout, stderr), + evidence_text, + } + } + + /// A command Kin could not start at all. + fn never_started(error: &std::io::Error) -> Self { + let reason = format!("Kin could not start the test command: {error}."); + Self { + started_at: Timestamp::now(), + finished_at: Timestamp::now(), + duration_ms: 0, + status: VerificationStatus::Pending, + exit_code: None, + output_tail: String::new(), + evidence_text: format!("=== COULD NOT RUN ===\n{reason}\n"), + could_not_run: Some(reason), + } + } + + /// Whether the command passed, failed, or could not run, as the page and + /// `kin verify` say it. + fn outcome(&self) -> &'static str { + match self.status { + VerificationStatus::Passing => "passed", + VerificationStatus::Pending => "could_not_run", + _ => "failed", + } + } +} + +/// Run `command` in `working_dir`, the repository's own working tree, and +/// keep what it printed as the run's evidence. +pub fn execute_verification(working_dir: &std::path::Path, command: &str) -> ExecutedRun { let started_at = Timestamp::now(); let start_instant = std::time::Instant::now(); - let output = verification_command(layout.working_dir(), &cmd_str).output(); + let output = verification_command(working_dir, command).output(); let duration = start_instant.elapsed(); let finished_at = Timestamp::now(); - let (status, exit_code, evidence_text) = match output { - Ok(out) => { - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - let evidence = format!("=== STDOUT ===\n{}\n=== STDERR ===\n{}", stdout, stderr); - let exit = out.status.code().unwrap_or(-1); - let status = if out.status.success() { - VerificationStatus::Passing - } else { - VerificationStatus::Failing - }; - (status, exit, evidence) + let executed = match output { + Ok(out) => ExecutedRun::finished( + out.status.code(), + &String::from_utf8_lossy(&out.stdout), + &String::from_utf8_lossy(&out.stderr), + ), + Err(error) => ExecutedRun::never_started(&error), + }; + ExecutedRun { + started_at, + finished_at, + duration_ms: duration.as_millis() as u64, + ..executed + } +} + +/// The lines a reader is shown from a command's output, at most this many. +const OUTPUT_TAIL_LINES: usize = 20; +/// The most characters of one shown line. +const OUTPUT_TAIL_LINE_CHARS: usize = 240; + +/// The last lines a command printed, standard error last, without blank +/// lines or terminal color codes. +fn output_tail(stdout: &str, stderr: &str) -> String { + let lines: Vec = stdout + .lines() + .chain(stderr.lines()) + .map(strip_terminal_codes) + .map(|line| line.trim_end().to_string()) + .filter(|line| !line.trim().is_empty()) + .collect(); + let start = lines.len().saturating_sub(OUTPUT_TAIL_LINES); + lines[start..] + .iter() + .map( + |line| match line.char_indices().nth(OUTPUT_TAIL_LINE_CHARS) { + Some((cut, _)) => format!("{}…", &line[..cut]), + None => line.clone(), + }, + ) + .collect::>() + .join("\n") +} + +/// `line` without ANSI escape sequences, which a runner prints for color. +fn strip_terminal_codes(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + while let Some(character) = chars.next() { + if character != '\u{1b}' { + out.push(character); + continue; } - Err(err) => { - let evidence = format!("Failed to execute test runner: {}", err); - (VerificationStatus::Failing, -1, evidence) + if chars.peek() == Some(&'[') { + chars.next(); + for code in chars.by_ref() { + if ('@'..='~').contains(&code) { + break; + } + } } + } + out +} + +/// What a shell said about a command it was asked to run and could not. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ShellComplaint { + /// It found no such command. + NotFound(String), + /// It found the command and could not execute it. + NotExecutable(String), +} + +/// The shells and launchers whose own complaint names a missing runner. +const COMPLAINING_SHELLS: [&str; 7] = ["sh", "bash", "zsh", "dash", "ash", "ksh", "env"]; + +/// Read one output line as a shell's complaint about a command, in the forms +/// the common shells print: `sh: eslint: command not found`, `/bin/sh: line 1: +/// eslint: command not found`, `sh: 1: eslint: not found`, `zsh: command not +/// found: eslint`, `env: node: No such file or directory` and `sh: ./run: +/// Permission denied`. Any other line is `None`, so a test's own output that +/// mentions "not found" is never read as one. +fn shell_complaint(line: &str) -> Option { + let mut parts = line.trim().split(':').map(str::trim); + let shell = parts.next()?; + let shell = shell.rsplit('/').next().unwrap_or(shell); + if !COMPLAINING_SHELLS.contains(&shell) { + return None; + } + // Where the shell was reading: `line 1`, or dash's and zsh's bare number. + let rest: Vec<&str> = parts + .skip_while(|part| { + part.strip_prefix("line ") + .unwrap_or(part) + .bytes() + .all(|byte| byte.is_ascii_digit()) + }) + .collect(); + let name = |raw: &str| { + let name = raw.trim_matches(|c: char| matches!(c, '\'' | '"' | '‘' | '’' | '`')); + (!name.is_empty()).then(|| name.to_string()) }; + match rest.as_slice() { + ["command not found", command] => name(command).map(ShellComplaint::NotFound), + [command, "command not found" | "not found" | "No such file or directory"] => { + name(command).map(ShellComplaint::NotFound) + } + [command, complaint, ..] + if *complaint == "Permission denied" || complaint.starts_with("cannot execute") => + { + name(command).map(ShellComplaint::NotExecutable) + } + _ => None, + } +} - let evidence_blob = store_evidence_blob(binding, &evidence_text); - let run_id = VerificationRunId::new(); - let verification_run = VerificationRun { - run_id, - test_ids: plan.tests.iter().map(|test| test.test_id).collect(), - status, - runner: test_runner, - started_at, - finished_at: Some(finished_at), - duration_ms: Some(duration.as_millis() as u64), +/// Whether a line reports that a command it ran exited 126 or 127, as a +/// package manager or build tool relays it: `error Command failed with exit +/// code 127.`, `npm error code 127`, `make: *** [test] Error 127`. +fn relays_could_not_run(line: &str) -> bool { + let line = line.to_ascii_lowercase(); + ["code ", "error ", "errno ", "exit status "] + .iter() + .any(|lead| { + line.match_indices(lead).any(|(at, _)| { + let after = &line[at + lead.len()..]; + (after.starts_with("126") || after.starts_with("127")) + && !after[3..].starts_with(|c: char| c.is_ascii_digit()) + }) + }) +} + +/// Why a finished command could not run, or `None` when it ran and its exit +/// code is its outcome. +/// +/// A command could not run when the shell exited 126 or 127, which POSIX +/// reserves for a command found but not executable and a command not found, +/// or when it exited otherwise and the shell's own complaint about a missing +/// runner is what ended it: the last thing it printed, or relayed by a +/// wrapper as an exit of 126 or 127. Any other exit is the outcome of tests +/// that ran, so a real failure stays a failure. +fn could_not_run_reason(exit_code: Option, stdout: &str, stderr: &str) -> Option { + let exit = exit_code?; + if exit == 0 { + return None; + } + let lines = || stdout.lines().chain(stderr.lines()); + let complaint = lines().rev().find_map(shell_complaint); + let shell_exit = matches!(exit, 126 | 127); + if !shell_exit { + let last_said = stderr + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .or_else(|| stdout.lines().rev().find(|line| !line.trim().is_empty())); + let ended_it = + last_said.and_then(shell_complaint).is_some() || lines().any(relays_could_not_run); + if complaint.is_none() || !ended_it { + return None; + } + } + Some(match (complaint, exit) { + (Some(ShellComplaint::NotFound(command)), _) => { + format!("The shell could not find `{command}`, so no test ran.") + } + (Some(ShellComplaint::NotExecutable(command)), _) => { + format!("The shell found `{command}` but could not execute it, so no test ran.") + } + (None, 126) => "The shell found a command the test command runs but could not execute \ + it, so no test ran." + .to_string(), + (None, _) => { + "The shell could not find a command the test command runs, so no test ran.".to_string() + } + }) +} + +/// A finished run's records, planned and written nowhere yet: the run tied +/// to the fingerprints its proof set was planned against, what it proves, and +/// the audit event that says who ran it. +/// +/// A run is repository authority, as a review is. Its writer commits +/// [`Self::to_delta`] to authority first and only then shows the run to a +/// graph with [`Self::apply_to`], so no graph holds a run, or a relation from +/// one, that authority does not hold. A working-copy admission checks every +/// relation's ends against authority's records, and refuses a graph that got +/// ahead of them. +#[derive(Debug, Clone)] +pub struct VerificationRecord { + run: VerificationRun, + proved_entities: Vec, + proved_work_items: Vec, + /// The acting identity, when the store does not hold it yet. + actor: Option, + audit_event: kin_model::AuditEvent, + actor_label: String, + action: &'static str, +} + +impl VerificationRecord { + pub fn run(&self) -> &VerificationRun { + &self.run + } + + /// Who the run is recorded as made by. + pub fn actor_label(&self) -> &str { + &self.actor_label + } + + /// What the transaction that admits the run says it is. + pub fn reason(&self) -> String { + format!("{} {}", self.action, self.run.run_id) + } + + /// The collaboration delta that admits these records into repository + /// authority. + pub fn to_delta(&self) -> Result { + let delta = kin_model::CollaborationDelta { + verification_runs: vec![kin_model::Keyed::new(self.run.run_id, self.run.clone())], + actors: self + .actor + .iter() + .map(|actor| kin_model::Keyed::new(actor.actor_id, actor.clone())) + .collect(), + audit_events: vec![self.audit_event.clone()], + ..kin_model::CollaborationDelta::default() + }; + delta + .validate() + .map_err(|error| anyhow!("the verification run is not a valid record: {error}"))?; + Ok(delta) + } + + /// Show `graph` the run, its proof links and its audit event. + pub fn apply_to(&self, graph: &G) -> Result<()> + where + G: GraphStore, + ::Error: std::fmt::Display + Send + Sync + 'static, + { + if let Some(actor) = &self.actor { + if graph + .get_actor(&actor.actor_id) + .map_err(|err| anyhow!(err.to_string()))? + .is_none() + { + graph + .create_actor(actor) + .map_err(|err| anyhow!(err.to_string()))?; + } + } + record_verification_evidence( + graph, + &self.run, + &self.proved_entities, + &self.proved_work_items, + ) + .map_err(|err: Box| anyhow!(err.to_string()))?; + graph + .record_audit_event(&self.audit_event) + .map_err(|err| anyhow!(err.to_string()))?; + Ok(()) + } +} + +/// Plan the records of `executed` as a run of `work`'s proof set. Keeps the +/// run's output as its evidence, and writes nothing else. +#[allow(clippy::too_many_arguments)] +fn plan_verification_record( + authority: &RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + work: &WorkPlan, + runner: TestRunner, + executed: &ExecutedRun, + action: &'static str, + scope: Option, + details: impl FnOnce(&VerificationRun) -> String, +) -> Result { + let evidence_blob = store_evidence_blob(authority, &executed.evidence_text); + let run = VerificationRun { + run_id: VerificationRunId::new(), + test_ids: work.tests.iter().map(|test| test.test_id).collect(), + status: executed.status, + runner, + started_at: executed.started_at.clone(), + finished_at: Some(executed.finished_at.clone()), + duration_ms: Some(executed.duration_ms), evidence_blob, - exit_code: Some(exit_code), + exit_code: executed.exit_code, + ran_against: work.ran_against.clone(), + proof: Some(kin_model::VerificationProof { + entities: work + .proved_entities + .iter() + .map(|entity| entity.id) + .collect(), + work_items: work + .proved_work_items + .iter() + .map(|item| item.work_id) + .collect(), + }), }; - let proved_work_ids = plan - .proved_work_items - .iter() - .map(|work| work.work_id) - .collect::>(); - let proved_entity_ids = plan - .proved_entities + let actor_label = crate::provenance::current_actor_label(); + let (actor, audit_event) = crate::provenance::plan_audit_event( + graph, + &actor_label, + action, + scope, + Some(details(&run)), + )?; + Ok(VerificationRecord { + proved_entities: work + .proved_entities + .iter() + .map(|entity| entity.id) + .collect(), + proved_work_items: work + .proved_work_items + .iter() + .map(|work| work.work_id) + .collect(), + run, + actor, + audit_event, + actor_label, + action, + }) +} + +// --------------------------------------------------------------------------- +// The tests that reach a piece of work, run with the repository's own command +// --------------------------------------------------------------------------- + +/// What the reach rule reads of an entity that reaches another. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReachClass { + /// A test by its role or kind. + pub is_test: bool, + /// A module, which holds the functions that use what it imports. + pub is_module_container: bool, +} + +impl ReachClass { + pub fn of(entity: &Entity) -> Self { + Self { + is_test: is_test_entity(entity), + is_module_container: kin_model::is_module_container(entity), + } + } +} + +/// Whether an entity is a test, by its role or its kind. +pub fn is_test_entity(entity: &Entity) -> bool { + entity.role == kin_model::EntityRole::Test || entity.kind == kin_model::EntityKind::Test +} + +/// The test entities that reach `target`, by id, over `all`, every relation +/// read for it. +/// +/// A test entity reaches it when it is the source of an impact relation into +/// it and is a test by its role or kind, or the relation is itself `tests` or +/// `covers`. A module that only imports it is not one, since the functions +/// inside the module that use it are. `class` answers for a source, or +/// `None` for one the graph does not hold. +/// +/// This is the direct-coverage rule shared by coverage readings and the +/// list of what no test reaches directly. Code, guided review and a proof +/// plan also read test candidates in the bounded ranked impact traversal. +pub fn reaching_test_ids( + all: &[kin_model::Relation], + target: &kin_model::EntityId, + class: &mut dyn FnMut(&kin_model::EntityId) -> Option, +) -> Vec { + use kin_model::RelationKind; + let mut seen = HashSet::new(); + let mut tests = Vec::new(); + for relation in kin_review::inbound_impact_relation_refs(all, target) { + let Some(source) = relation.src.as_entity() else { + continue; + }; + if source == *target || seen.contains(&source) { + continue; + } + let Some(class) = class(&source) else { + continue; + }; + let imports = matches!( + relation.kind, + RelationKind::Imports | RelationKind::Includes + ); + if imports && class.is_module_container { + continue; + } + if class.is_test || matches!(relation.kind, RelationKind::Tests | RelationKind::Covers) { + seen.insert(source); + tests.push(source); + } + } + tests +} + +/// The entities no recorded test case covers, by the graph's own summary. An +/// entity is missing proof when it is one of these and no test entity +/// reaches it either ([`reaching_test_ids`]): the two halves of one coverage +/// reading. A test case's cover is a relation from the test case, which a +/// read of an entity's own relations does not carry, so it is read here. +pub fn uncovered_by_test_cases(graph: &G) -> Result> +where + G: GraphStore, + ::Error: std::fmt::Display + Send + Sync + 'static, +{ + Ok(graph + .get_coverage_summary() + .map_err(|err| anyhow!(err.to_string()))? + .missing_proof + .into_iter() + .collect()) +} + +/// The test entities that directly reach `entity` ([`reaching_test_ids`]). +/// Bounded transitive test reach is read by the ranked impact traversal. +pub fn tests_reaching(graph: &G, entity: &kin_model::EntityId) -> Result> +where + G: GraphStore, + ::Error: std::fmt::Display + Send + Sync + 'static, +{ + let all = graph + .get_all_relations_for_entity(entity) + .map_err(|err| anyhow!(err.to_string()))?; + let mut found: HashMap = HashMap::new(); + let mut failed = None; + let ids = reaching_test_ids(&all, entity, &mut |source| { + if let Some(held) = found.get(source) { + return Some(ReachClass::of(held)); + } + match graph.get_entity(source) { + Ok(Some(held)) => { + let class = ReachClass::of(&held); + found.insert(*source, held); + Some(class) + } + Ok(None) => None, + Err(err) => { + failed.get_or_insert_with(|| anyhow!(err.to_string())); + None + } + } + }); + if let Some(error) = failed { + return Err(error); + } + // Test code that is not a test of its own, such as a constant in a test + // module, reaches as the test that holds it, counted once. + let mut tests: Vec = Vec::new(); + let mut counted = HashSet::new(); + for id in ids { + let Some(test) = found.remove(&id) else { + continue; + }; + let test = if kin_review::is_test_unit(&test) || !is_test_entity(&test) { + Some(test) + } else { + kin_review::test_unit_holding(&kin_review::impact::LiveGraph(graph), &test) + .map_err(|err| anyhow!(err.to_string()))? + }; + if let Some(test) = test.filter(|test| counted.insert(test.id)) { + tests.push(test); + } + } + tests.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id))); + Ok(tests) +} + +/// Repository-wide structural test reach over the recorded semantic graph. +/// Each projected entity's relations are supplied once, then one multi-source +/// traversal visits each reachable entity once. Verification/work metadata +/// cannot form a path because both endpoints must be semantic entities. +pub struct RecordedTestReach { + known: HashSet, + test_sources: HashSet, + frontier: Vec, + covered: HashSet, + outgoing: HashMap>, +} + +impl RecordedTestReach { + pub fn new(entities: &[Entity], uncovered_by_test_cases: HashSet) -> Self { + let known: HashSet<_> = entities.iter().map(|entity| entity.id).collect(); + let covered: HashSet<_> = known + .difference(&uncovered_by_test_cases) + .copied() + .collect(); + let mut frontier: Vec<_> = entities + .iter() + .filter(|entity| kin_review::ranked_impact::is_test_impact_source(entity)) + .map(|entity| entity.id) + .collect(); + // Native test-case scopes are recorded test reach too. Their covered + // entity's dependencies can be followed without fabricating a test entity. + frontier.extend(covered.iter().copied()); + let test_sources = entities + .iter() + .filter(|entity| { + !kin_model::is_derived_member(entity) + && !matches!( + entity.role, + kin_model::EntityRole::Generated | kin_model::EntityRole::Vendored + ) + }) + .map(|entity| entity.id) + .collect(); + Self { + known, + test_sources, + frontier, + covered, + outgoing: HashMap::new(), + } + } + + /// Use the same edge policy as ranked impact, including declaring-parent + /// exclusions. `all` must be the projected relations of `target`. + pub fn observe_inbound(&mut self, all: &[kin_model::Relation], target: &kin_model::EntityId) { + for relation in kin_review::inbound_impact_relation_refs(all, target) { + let (Some(source), Some(destination)) = + (relation.src.as_entity(), relation.dst.as_entity()) + else { + continue; + }; + if source == destination + || !self.known.contains(&source) + || !self.known.contains(&destination) + { + continue; + } + if matches!( + relation.kind, + kin_model::RelationKind::Tests | kin_model::RelationKind::Covers + ) && self.test_sources.contains(&source) + { + self.frontier.push(source); + } + self.outgoing.entry(source).or_default().insert(destination); + } + } + + pub fn uncovered(mut self) -> HashSet { + let mut visited: HashSet<_> = self.frontier.iter().copied().collect(); + let mut pending: std::collections::VecDeque<_> = visited.iter().copied().collect(); + while let Some(source) = pending.pop_front() { + for destination in self.outgoing.get(&source).into_iter().flatten() { + self.covered.insert(*destination); + if visited.insert(*destination) { + pending.push_back(*destination); + } + } + } + self.known.difference(&self.covered).copied().collect() + } +} + +/// A repository's recorded structural test reach. Native test-case scopes and +/// test entities cover everything reachable through supported semantic edges. +/// The rest have no recorded test reach, which does not establish complete +/// runtime coverage or an executed, passing test. +pub fn coverage_reading(graph: &G) -> Result +where + G: GraphStore, + ::Error: std::fmt::Display + Send + Sync + 'static, +{ + let mut entities = graph + .list_all_entities() + .map_err(|err| anyhow!(err.to_string()))?; + entities.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id))); + let mut reach = RecordedTestReach::new(&entities, uncovered_by_test_cases(graph)?); + for entity in &entities { + let all = kin_index::relation_read::relations_for_read(graph, &entity.id) + .map_err(|err| anyhow!(err.to_string()))?; + reach.observe_inbound(&all, &entity.id); + } + let uncovered = reach.uncovered(); + let missing_proof: Vec<_> = entities .iter() + .filter(|entity| uncovered.contains(&entity.id)) .map(|entity| entity.id) - .collect::>(); + .collect(); + let total_entities = entities.len(); + let covered_entities = total_entities - missing_proof.len(); + Ok(kin_model::CoverageSummary { + total_entities, + covered_entities, + coverage_ratio: if total_entities == 0 { + 0.0 + } else { + covered_entities as f64 / total_entities as f64 + }, + missing_proof, + }) +} - record_verification_evidence( - graph, - &verification_run, - &proved_entity_ids, - &proved_work_ids, - ) - .map_err(|err: Box| anyhow!(err.to_string()))?; - crate::provenance::record_cli_audit_event( +/// The test a test entity is recorded as in a verification run. Derived from +/// the entity's identity alone, so every run of it names the same test and +/// its history survives edits to it. +pub fn test_id_for_entity(entity: &kin_model::EntityId) -> kin_model::TestId { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"kin.verify.test-entity.v1\0"); + hasher.update(entity.0.as_bytes()); + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&hasher.finalize()); + kin_model::TestId::from_hash(Hash256::from_bytes(bytes)) +} + +/// What a test entity's derived [`TestCase`] names as its runner: the +/// repository's own test command, whichever it declares. +const REPOSITORY_RUNNER: &str = "repository"; + +fn test_case_for_entity(test: &Entity, covers: &kin_model::EntityId) -> TestCase { + TestCase { + test_id: test_id_for_entity(&test.id), + name: test.name.clone(), + language: test.language.to_string(), + kind: kin_model::TestKind::Unit, + scopes: vec![WorkScope::Entity(*covers)], + runner: TestRunner::Custom(REPOSITORY_RUNNER.to_string()), + file_origin: test.file_origin.clone(), + } +} + +/// How this repository runs its tests, read from what its own manifests +/// declare ([`kin_parser::project_manifest::DeclaredTests`]). The manifests +/// are read in their root precedence, and the first that declares a way +/// wins. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RepositoryTestCommand { + /// A runner Kin narrows to the planned tests by name. + Runner(TestRunner), + /// The repository's own test command, which Kin runs whole: its program + /// and arguments. + Whole(Vec), +} + +impl RepositoryTestCommand { + /// The runner a run of it is recorded under. + fn runner(&self) -> TestRunner { + match self { + Self::Runner(runner) => runner.clone(), + Self::Whole(words) => TestRunner::Custom(words.join(" ")), + } + } + + /// `targeted` when the command runs only the planned tests, `whole` when + /// it runs every test the repository has. + fn reach(&self) -> &'static str { + match self { + Self::Runner(_) => "targeted", + Self::Whole(_) => "whole", + } + } + + /// The shell line that runs `tests`. + fn command_line(&self, tests: &[TestCase]) -> String { + match self { + Self::Runner(runner) => { + let fallback = tests.first().map_or("", |test| test.name.as_str()); + build_runner_command(runner, fallback, tests) + } + Self::Whole(words) => words + .iter() + .map(|word| shell_quote(word)) + .collect::>() + .join(" "), + } + } +} + +/// The sentence a repository with no test command Kin can run is answered +/// with. +pub const NO_TEST_COMMAND: &str = "This repository declares no test command Kin can run."; + +/// This repository's test command, from the manifests at its root, or `None` +/// when none declares one Kin can run. +pub fn repository_test_command( + project: &dyn kin_parser::project_manifest::ProjectFiles, +) -> Option { + use kin_parser::project_manifest::DeclaredTests; + kin_parser::project_manifest::all() + .iter() + .find_map(|manifest| { + let text = project.manifest(manifest.name).ok().flatten()?; + (manifest.declared_tests)(&text) + }) + .and_then(|declared| match declared { + DeclaredTests::Runner(name) => { + parse_runner(name).ok().map(RepositoryTestCommand::Runner) + } + DeclaredTests::Command(words) => Some(RepositoryTestCommand::Whole( + words.iter().map(|word| word.to_string()).collect(), + )), + }) +} + +/// What `POST /verify/work` is asked: how this repository runs its tests, +/// the proof set for a piece of work, or to run that proof set and record it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum WorkVerifyRequest { + TestCommand, + Plan(WorkVerifyScope), + Run(WorkVerifyScope), +} + +/// The work whose tests are planned: the entities named, or a recorded +/// change's entities, or, when neither is named, every entity the working +/// copy adds or changes. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkVerifyScope { + #[serde(default)] + pub entities: Vec, + #[serde(default)] + pub change: Option, + #[serde(default = "default_work_depth")] + pub depth: u32, +} + +/// The default test reach shared by Code, guided review and work verification. +/// Verification's depth counts intervening consumers, plus the test's final hop. +pub(crate) const WORK_TEST_REACH_DEPTH: u32 = 3; + +fn default_work_depth() -> u32 { + WORK_TEST_REACH_DEPTH - 1 +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkVerifyResponse { + /// Whether this repository declares a test command Kin can run. + pub runnable: bool, + /// Why not, in one sentence, when it does not. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unavailable: Option, + /// `targeted` when the command runs only the planned tests, `whole` when + /// it runs every test the repository has. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reach: Option, + /// The entities the proof set was planned for. + #[serde(default)] + pub entities: Vec, + /// The tests planned. + #[serde(default)] + pub tests: Vec, + /// The run, once one was made and recorded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkEntity { + pub id: String, + pub name: String, + pub kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkTest { + /// A planned graph test, whose individual execution is not attested by + /// a successful test-command exit alone. + pub test_id: String, + pub name: String, + /// The test entity, when the test is one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entity_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkRun { + pub run_id: String, + /// `passed` or `failed` for the repository's test command, or + /// `could_not_run` when it never ran its tests: Kin could not start it, + /// or the shell could not find or execute the runner it names. + pub outcome: String, + pub duration_ms: u64, + /// The command's exit code, `null` when it has none: it never started, + /// or a signal ended it. + #[serde(default)] + pub exit_code: Option, + pub finished_at: String, + /// Why the command could not run, in one sentence, when it could not. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// The last lines the command printed, when it did not pass. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_tail: Option, +} + +/// A piece of work's proof set: one [`VerificationPlan`] per entity, their +/// tests together, and the fingerprints a run of them is tied to. +#[derive(Debug, Clone, Default)] +struct WorkPlan { + subjects: Vec, + tests: Vec, + test_entities: HashMap, + proved_entities: Vec, + proved_work_items: Vec, + ran_against: Vec, +} + +impl WorkPlan { + fn from_plans(plans: Vec) -> Self { + let mut work = Self::default(); + let mut seen_tests = HashSet::new(); + let mut seen_proved = HashSet::new(); + let mut seen_work = HashSet::new(); + let mut fingerprinted: Vec<&Entity> = Vec::new(); + for plan in &plans { + work.subjects.push(plan.entity.clone()); + for test in &plan.tests { + if seen_tests.insert(test.test_id) { + work.tests.push(test.clone()); + } + } + for (test_id, entity) in &plan.test_entities { + work.test_entities + .entry(*test_id) + .or_insert_with(|| entity.clone()); + } + for entity in &plan.proved_entities { + if seen_proved.insert(entity.id) { + work.proved_entities.push(entity.clone()); + } + } + for item in &plan.proved_work_items { + if seen_work.insert(item.work_id) { + work.proved_work_items.push(item.clone()); + } + } + } + for plan in &plans { + fingerprinted.push(&plan.entity); + fingerprinted.extend(plan.impacted.iter().map(|slice| &slice.entity)); + } + fingerprinted.extend(work.test_entities.values()); + let mut seen_fingerprints = HashSet::new(); + for entity in fingerprinted { + if seen_fingerprints.insert(entity.id) { + work.ran_against.push(kin_model::RunFingerprint { + entity: entity.id, + behavior_hash: entity.fingerprint.behavior_hash, + }); + } + } + work.ran_against.sort_by(|a, b| a.entity.cmp(&b.entity)); + work + } + + /// A selected test can call helpers outside the inbound impact slice. + /// Include their transitive outgoing dependencies so changing one makes + /// the result stale while an unrelated entity leaves it current. + fn capture_dependencies(&mut self, graph: &G) -> Result<()> + where + G: GraphStore, + ::Error: std::fmt::Display + Send + Sync + 'static, + { + let mut seen: HashSet<_> = self.ran_against.iter().map(|tied| tied.entity).collect(); + let mut queue: std::collections::VecDeque<_> = seen.iter().copied().collect(); + while let Some(id) = queue.pop_front() { + let relations = graph + .get_relations(&id, &[]) + .map_err(|error| anyhow!(error.to_string()))?; + for relation in relations { + if !kin_review::is_impact_relation(relation.kind) { + continue; + } + let Some(destination) = relation.dst.as_entity() else { + continue; + }; + if !seen.insert(destination) { + continue; + } + if let Some(entity) = graph + .get_entity(&destination) + .map_err(|error| anyhow!(error.to_string()))? + { + self.ran_against.push(kin_model::RunFingerprint { + entity: destination, + behavior_hash: entity.fingerprint.behavior_hash, + }); + queue.push_back(destination); + } + } + } + self.ran_against.sort_by_key(|tied| tied.entity); + Ok(()) + } + + fn response(&self, command: &RepositoryTestCommand) -> WorkVerifyResponse { + let mut tests: Vec = self + .tests + .iter() + .map(|test| WorkTest { + test_id: test.test_id.to_string(), + name: test.name.clone(), + entity_id: self + .test_entities + .get(&test.test_id) + .map(|entity| entity.id.to_string()), + }) + .collect(); + tests.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.test_id.cmp(&b.test_id))); + WorkVerifyResponse { + runnable: true, + unavailable: None, + reach: Some(command.reach().to_string()), + entities: self + .subjects + .iter() + .map(|entity| WorkEntity { + id: entity.id.to_string(), + name: entity.name.clone(), + kind: kind_word(entity.kind), + }) + .collect(), + tests, + run: None, + } + } +} + +/// `TypeAlias` as `type_alias`. +fn kind_word(kind: impl std::fmt::Debug) -> String { + let debug = format!("{kind:?}"); + let mut word = String::with_capacity(debug.len() + 4); + for (index, character) in debug.chars().enumerate() { + if character.is_ascii_uppercase() { + if index > 0 { + word.push('_'); + } + word.push(character.to_ascii_lowercase()); + } else { + word.push(character); + } + } + word +} + +/// The entities a piece of work names, as the graph holds them now. +fn work_subjects( + authority: &RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + scope: &WorkVerifyScope, +) -> Result> { + let mut ids: Vec = Vec::new(); + if !scope.entities.is_empty() { + for raw in &scope.entities { + let uuid = uuid::Uuid::parse_str(raw.trim()) + .map_err(|_| anyhow!("'{raw}' is not an entity id"))?; + ids.push(kin_model::EntityId(uuid)); + } + } else if let Some(change) = scope.change.as_deref() { + let change = resolve_change(graph, authority.binding(), Some(change))?; + for delta in &change.entity_deltas { + match delta { + EntityDelta::Added { new: entity } | EntityDelta::Modified { new: entity, .. } => { + ids.push(entity.id) + } + EntityDelta::Removed { .. } => { + bail!("Kin cannot plan tests for removed entities yet.") + } + } + } + } else { + // What `kin diff` reads as uncommitted: the entities the working copy + // adds or edits. A span that only moved is not an edit, and a module + // is read through its members, as a guide reads them. + let (report, _) = super::diff::inspect_with_endpoint_entities_at( + authority.open()?.as_ref(), + None, + None, + Some(graph), + )?; + for delta in report.entity_deltas { + match delta { + EntityDelta::Added { new } => ids.push(new.id), + EntityDelta::Modified { old, new } => { + if kin_review::is_semantic_modification(&old, &new) { + ids.push(new.id); + } + } + EntityDelta::Removed { .. } => { + bail!("Kin cannot plan tests for removed entities yet.") + } + } + } + } + let mut seen = HashSet::new(); + let mut subjects = Vec::new(); + for id in ids { + if !seen.insert(id) { + continue; + } + match graph + .get_entity(&id) + .map_err(|err| anyhow!(err.to_string()))? + { + Some(entity) + if kin_model::is_module_container(&entity) && scope.entities.is_empty() => {} + Some(entity) => subjects.push(entity), + // A named entity the repository does not hold is refused. One a + // change or the working copy names that is gone now has nothing + // left to run against. + None if !scope.entities.is_empty() => { + bail!("No entity with the id {id} is in this repository.") + } + None => {} + } + } + Ok(subjects) +} + +fn plan_work( + authority: &RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + scope: &WorkVerifyScope, +) -> Result { + let plans = work_subjects(authority, graph, scope)? + .into_iter() + .map(|entity| build_verification_plan_for_entity(graph, entity, scope.depth)) + .collect::>>()?; + let mut work = WorkPlan::from_plans(plans); + work.capture_dependencies(graph)?; + Ok(work) +} + +/// A piece of work's proof set, planned and ready to run, or the answer that +/// says why nothing will run. +pub enum PreparedWork { + /// Nothing runs: no command, or no test reaches the work. The answer is + /// final. + Answered(WorkVerifyResponse), + Ready(ReadyWork), +} + +/// A planned proof set and the command line that runs it. +pub struct ReadyWork { + work: WorkPlan, + command: RepositoryTestCommand, + line: String, +} + +impl ReadyWork { + pub fn command_line(&self) -> &str { + &self.line + } + + pub fn tests_planned(&self) -> usize { + self.work.tests.len() + } +} + +/// Answer `POST /verify/work` up to the point a command would run: the test +/// command, the plan, or a plan ready to run. Reads the graph only. +pub fn prepare_work_verify( + authority: &RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + project: &dyn kin_parser::project_manifest::ProjectFiles, + request: &WorkVerifyRequest, +) -> Result { + let Some(command) = repository_test_command(project) else { + return Ok(PreparedWork::Answered(WorkVerifyResponse { + runnable: false, + unavailable: Some(NO_TEST_COMMAND.to_string()), + ..WorkVerifyResponse::default() + })); + }; + let (scope, run) = match request { + WorkVerifyRequest::TestCommand => { + return Ok(PreparedWork::Answered(WorkVerifyResponse { + runnable: true, + reach: Some(command.reach().to_string()), + ..WorkVerifyResponse::default() + })) + } + WorkVerifyRequest::Plan(scope) => (scope, false), + WorkVerifyRequest::Run(scope) => (scope, true), + }; + let work = plan_work(authority, graph, scope)?; + if !run || work.tests.is_empty() { + return Ok(PreparedWork::Answered(work.response(&command))); + } + let line = command.command_line(&work.tests); + Ok(PreparedWork::Ready(ReadyWork { + work, + command, + line, + })) +} + +/// The records of a finished run of `ready`, and what it answers once they +/// are durable. +pub fn record_work_verify( + authority: &RequestRepositoryAuthority, + graph: &kin_db::InMemoryGraph, + ready: &ReadyWork, + executed: &ExecutedRun, +) -> Result<(VerificationRecord, WorkVerifyResponse)> { + let record = plan_verification_record( + authority, graph, - "verify.run", - Some(WorkScope::Entity(plan.entity.id)), - Some(format!( - "runner={}; status={}; tests={}; impacted_entities={}", - request.runner, - verification_run.status, - verification_run.test_ids.len(), - proved_entity_ids.len() - )), + &ready.work, + ready.command.runner(), + executed, + "verify.work", + ready + .work + .subjects + .first() + .map(|entity| WorkScope::Entity(entity.id)), + |run| { + format!( + "reach={}; status={}; tests={}; entities={}", + ready.command.reach(), + audit_status(run), + run.test_ids.len(), + ready.work.subjects.len() + ) + }, )?; + let mut response = ready.work.response(&ready.command); + response.run = Some(WorkRun { + run_id: record.run.run_id.to_string(), + outcome: executed.outcome().to_string(), + duration_ms: executed.duration_ms, + exit_code: executed.exit_code, + finished_at: rfc3339(&executed.finished_at), + reason: executed.could_not_run.clone(), + output_tail: (executed.status != VerificationStatus::Passing + && !executed.output_tail.is_empty()) + .then(|| executed.output_tail.clone()), + }); + Ok((record, response)) +} - lines.push(String::new()); - lines.push("VerificationRun recorded:".to_string()); - lines.push(format!(" Run ID: {}", run_id)); - lines.push(format!( - " Entity: {} ({:?})", - plan.entity.name, plan.entity.kind - )); - lines.push(format!(" Status: {}", verification_run.status)); - lines.push(format!(" Duration: {}ms", duration.as_millis())); - lines.push(format!(" Exit: {}", exit_code)); - lines.push(format!(" Tests: {}", verification_run.test_ids.len())); - lines.push(format!(" Entities: {}", proved_entity_ids.len())); - lines.push(format!(" Work: {}", proved_work_ids.len())); - if let Some(ref blob) = verification_run.evidence_blob { - lines.push(format!(" Evidence: {}", blob)); +/// A recorded run's status in words, as `kin verify` prints it. +fn run_word(run: &VerificationRun) -> String { + if run.could_not_run() { + "could not run".to_string() + } else { + run.status.to_string() + } +} + +/// A recorded run's status as its audit event names it. +fn audit_status(run: &VerificationRun) -> String { + if run.could_not_run() { + "could_not_run".to_string() + } else { + run.status.to_string() + } +} + +fn rfc3339(at: &Timestamp) -> String { + at.0.to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +// --------------------------------------------------------------------------- +// A test's latest result, for the work as it is now +// --------------------------------------------------------------------------- + +/// `passed`, `failed`, `not_run` or `stale`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TestResultState { + Passed, + Failed, + NotRun, + Stale, +} + +/// A test's latest result for the work as it is now. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TestResultReading { + pub state: TestResultState, + /// When the run this result comes from finished. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +/// Whether `run` is a result for the graph as it is now: it was tied to +/// `subject` when there is one, and every entity it was tied to still has +/// the fingerprint it ran against. A run tied to nothing is never current. +fn run_is_current( + graph: &G, + run: &VerificationRun, + subject: Option<&kin_model::EntityId>, + now: &mut HashMap>, +) -> Result +where + G: GraphStore, + ::Error: std::fmt::Display + Send + Sync + 'static, +{ + if run.ran_against.is_empty() { + return Ok(false); + } + if let Some(subject) = subject { + if !run.ran_against.iter().any(|tied| tied.entity == *subject) { + return Ok(false); + } } + for tied in &run.ran_against { + let current = match now.get(&tied.entity) { + Some(current) => *current, + None => { + let current = graph + .get_entity(&tied.entity) + .map_err(|err| anyhow!(err.to_string()))? + .map(|entity| entity.fingerprint.behavior_hash); + now.insert(tied.entity, current); + current + } + }; + if current != Some(tied.behavior_hash) { + return Ok(false); + } + } + Ok(true) +} + +fn finished_at(run: &VerificationRun) -> &Timestamp { + run.finished_at.as_ref().unwrap_or(&run.started_at) +} - Ok(VerifyRunResponse { lines }) +/// The latest result of `test` for `subject` as the graph holds both now. +/// +/// The newest run of the test that is current ([`run_is_current`]) and ran +/// gives `passed` or `failed`. A run whose command could not run attests no +/// outcome, so it never gives one: when no current run ran, the newest +/// current attempt that could not run makes the test `not_run`, with why. +/// When runs of it exist and none is current, the result is `stale`: it was +/// recorded against an older fingerprint of an entity it was tied to, and is +/// never shown as current. A test no run reached is `not_run`. +pub fn test_result( + graph: &G, + test: &kin_model::EntityId, + subject: Option<&kin_model::EntityId>, + now: &mut HashMap>, +) -> Result +where + G: GraphStore, + ::Error: std::fmt::Display + Send + Sync + 'static, +{ + let mut runs = graph + .list_runs_for_test(&test_id_for_entity(test)) + .map_err(|err| anyhow!(err.to_string()))?; + let ran = |run: &VerificationRun| { + matches!( + run.status, + VerificationStatus::Passing | VerificationStatus::Failing + ) + }; + runs.retain(|run| ran(run) || run.could_not_run()); + runs.sort_by(|a, b| { + finished_at(b) + .cmp(finished_at(a)) + .then_with(|| b.run_id.0.cmp(&a.run_id.0)) + }); + let mut attempt: Option<&VerificationRun> = None; + for run in &runs { + if !run_is_current(graph, run, subject, now)? { + continue; + } + if run.could_not_run() { + attempt.get_or_insert(run); + continue; + } + let passed = run.status == VerificationStatus::Passing; + // A run that passed passed every test in it. One that failed says + // which test failed only when it ran this test alone through a runner + // Kin narrowed to it. + let alone = run.test_ids.len() == 1 && !matches!(run.runner, TestRunner::Custom(_)); + let summary = if passed { + if alone { + "Passed." + } else { + "The test command passed; Kin does not report individual test outcomes." + } + } else if alone { + "Failed." + } else { + "The run that included it failed, and Kin cannot tell which test in that run failed." + }; + return Ok(TestResultReading { + state: if passed { + TestResultState::Passed + } else { + TestResultState::Failed + }, + at: Some(rfc3339(finished_at(run))), + duration_ms: run.duration_ms, + summary: Some(summary.to_string()), + }); + } + if let Some(attempt) = attempt { + let exit = attempt + .exit_code + .map(|code| format!(" It exited {code}.")) + .unwrap_or_default(); + return Ok(TestResultReading { + state: TestResultState::NotRun, + at: Some(rfc3339(finished_at(attempt))), + duration_ms: attempt.duration_ms, + summary: Some(format!( + "The test command could not run, so this test did not run.{exit}" + )), + }); + } + let Some(newest) = runs.iter().find(|run| ran(run)) else { + return Ok(TestResultReading { + state: TestResultState::NotRun, + at: None, + duration_ms: None, + summary: None, + }); + }; + let was = if newest.status == VerificationStatus::Passing { + "passed" + } else { + "failed" + }; + Ok(TestResultReading { + state: TestResultState::Stale, + at: Some(rfc3339(finished_at(newest))), + duration_ms: newest.duration_ms, + summary: Some(format!( + "Its last run {was}, and an entity it ran against has changed since." + )), + }) } #[cfg(unix)] @@ -453,6 +1892,8 @@ struct VerificationPlan { direct: EntityProofSlice, impacted: Vec, tests: Vec, + /// The test entity each planned test is, for the tests that are one. + test_entities: HashMap, latest_test_runs: HashMap, proved_entities: Vec, proved_work_items: Vec, @@ -462,6 +1903,8 @@ struct VerificationPlan { struct EntityProofSlice { entity: Entity, tests: Vec, + /// The test entities among `tests`, each with the test it is recorded as. + test_entities: Vec<(kin_model::TestId, Entity)>, work_items: Vec, } @@ -493,28 +1936,69 @@ where G: GraphStore, ::Error: std::fmt::Display + Send + Sync + 'static, { - let direct = build_entity_proof_slice(graph, entity.clone())?; - let impacted_entities = graph - .get_downstream_impact(&entity.id, depth) - .map_err(|err| anyhow!(err.to_string()))?; + let mut direct = build_entity_proof_slice(graph, entity.clone())?; + // A test beyond an impacted consumer adds one final hop. This is the + // same recorded impact traversal Code and guided review read, including + // its relation policy and maximum depth. + let ranked = kin_review::rank_impact(graph, &entity.id, depth.saturating_add(1))?; let mut impacted = Vec::new(); - for impacted_entity in impacted_entities { - impacted.push(build_entity_proof_slice(graph, impacted_entity)?); + for candidate in ranked + .candidates + .iter() + .filter(|candidate| candidate.hop <= depth) + { + if let Some(impacted_entity) = graph + .get_entity(&candidate.entity_id) + .map_err(|error| anyhow!(error.to_string()))? + { + impacted.push(build_entity_proof_slice(graph, impacted_entity)?); + } + } + + // The tests the walk counts, one per test: test code inside a test + // module is planned as the module that holds it, never as a test itself. + let ranked_test_entities: HashSet<_> = ranked.tests.iter().map(|test| test.entity_id).collect(); + for slice in std::iter::once(&mut direct).chain(impacted.iter_mut()) { + let rejected: HashSet<_> = slice + .test_entities + .iter() + .filter(|(_, entity)| !ranked_test_entities.contains(&entity.id)) + .map(|(test, _)| *test) + .collect(); + slice.tests.retain(|test| !rejected.contains(&test.test_id)); + slice + .test_entities + .retain(|(_, entity)| ranked_test_entities.contains(&entity.id)); } let mut seen_test_ids = HashSet::new(); let mut tests = Vec::new(); - for test in &direct.tests { - if seen_test_ids.insert(test.test_id) { - tests.push(test.clone()); - } - } - for slice in &impacted { + let mut test_entities = HashMap::new(); + for slice in std::iter::once(&direct).chain(impacted.iter()) { for test in &slice.tests { if seen_test_ids.insert(test.test_id) { tests.push(test.clone()); } } + for (test_id, entity) in &slice.test_entities { + test_entities + .entry(*test_id) + .or_insert_with(|| entity.clone()); + } + } + + for reach in &ranked.tests { + let Some(test_entity) = graph + .get_entity(&reach.entity_id) + .map_err(|error| anyhow!(error.to_string()))? + else { + continue; + }; + let test = test_case_for_entity(&test_entity, &entity.id); + if seen_test_ids.insert(test.test_id) { + tests.push(test.clone()); + } + test_entities.entry(test.test_id).or_insert(test_entity); } let mut proved_entities = Vec::new(); @@ -559,6 +2043,7 @@ where direct, impacted, tests, + test_entities, latest_test_runs, proved_entities, proved_work_items, @@ -677,10 +2162,21 @@ where } } } + // The test entities that reach it, which a guide lists as its tests, are + // part of its proof as much as a recorded test case is. + let mut test_entities = Vec::new(); + for test in tests_reaching(graph, &entity.id)? { + let case = test_case_for_entity(&test, &entity.id); + if seen_test_ids.insert(case.test_id) { + test_entities.push((case.test_id, test)); + tests.push(case); + } + } Ok(EntityProofSlice { entity, tests, + test_entities, work_items, }) } @@ -692,6 +2188,12 @@ fn verification_plan_lines(plan: &VerificationPlan) -> Vec { plan.entity.name, plan.entity.kind )); lines.push(format!(" Impact depth: {}", plan.depth)); + lines.push(format!( + " Test reach: up to {} recorded hops", + plan.depth + .saturating_add(1) + .min(kin_review::IMPACT_MAX_DEPTH) + )); lines.push(format!(" Planned entities: {}", 1 + plan.impacted.len())); lines.push(format!( " Direct scope: {} test(s), {} work item(s)", @@ -720,7 +2222,7 @@ fn verification_plan_lines(plan: &VerificationPlan) -> Vec { let latest = plan .latest_test_runs .get(&test.test_id) - .map(|run| run.status.to_string()) + .map(run_word) .unwrap_or_else(|| "missing".to_string()); lines.push(format!( " - {} [{}] runner={} latest={}", @@ -772,7 +2274,7 @@ fn change_verification_plan_lines(plan: &ChangeVerificationPlan) -> Vec let latest = plan .latest_test_runs .get(&test.test_id) - .map(|run| run.status.to_string()) + .map(run_word) .unwrap_or_else(|| "missing".to_string()); lines.push(format!( " - {} [{}] runner={} latest={}", @@ -842,7 +2344,7 @@ impl std::fmt::Display for VerifyEntityUnresolved { impl std::error::Error for VerifyEntityUnresolved {} /// Whether a plan or run for `entity_query` names exactly one entity in -/// `graph`, by the rule [`execute_verify_run`] resolves it with. +/// `graph`, by the rule [`prepare_verify_run`] resolves it with. /// /// Public for the daemon, which asks before a run takes the coordination gate. /// A run holds its own writer guard while it resolves, so it cannot tell from @@ -860,6 +2362,16 @@ where G: GraphStore, ::Error: std::fmt::Display + Send + Sync + 'static, { + // An entity id names exactly one entity, so it is read as one before the + // query is read as a name. + if let Ok(id) = uuid::Uuid::parse_str(entity_query.trim()).map(kin_model::EntityId) { + if let Some(entity) = graph + .get_entity(&id) + .map_err(|err| anyhow!(err.to_string()))? + { + return Ok(vec![entity]); + } + } let filter = EntityFilter { name_pattern: Some(entity_query.to_string()), ..Default::default() @@ -964,14 +2476,22 @@ fn custom_runner_refusal(runner: &str) -> Option { fn build_runner_command(test_runner: &TestRunner, entity_name: &str, tests: &[TestCase]) -> String { match test_runner { - TestRunner::Cargo => { - let filter = if tests.len() == 1 { - tests[0].name.clone() - } else { - entity_name.to_string() - }; - format!("cargo test {}", shell_quote(&filter)) - } + TestRunner::Cargo => match tests { + [] => format!("cargo test {}", shell_quote(entity_name)), + [test] => format!("cargo test {}", shell_quote(&test.name)), + // The test harness takes any number of name filters after `--`. + many => { + let mut names: Vec<&str> = many.iter().map(|test| test.name.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + let filters = names + .iter() + .map(|name| shell_quote(name)) + .collect::>() + .join(" "); + format!("cargo test -- {filters}") + } + }, TestRunner::Jest => { let pattern = if tests.is_empty() { entity_name.to_string() @@ -1006,7 +2526,9 @@ fn build_runner_command(test_runner: &TestRunner, entity_name: &str, tests: &[Te .collect::>() .join("|") }; - format!("go test -run {}", shell_quote(&pattern)) + // Every package of the module, as the module's own `go test ./...` + // would run them, narrowed to the planned tests. + format!("go test -run {} ./...", shell_quote(&pattern)) } TestRunner::JUnit => { let pattern = if tests.is_empty() { @@ -1041,8 +2563,12 @@ fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } +/// Keep `evidence_text` as an immutable blob, through the authority the +/// request already holds. A server hands every request one open per +/// publication, so recording a run no longer re-verifies the whole store for +/// one small blob. fn store_evidence_blob( - binding: &kin_core::LocalRepositoryAuthorityBinding, + authority: &RequestRepositoryAuthority, evidence_text: &str, ) -> Option { use sha2::{Digest, Sha256}; @@ -1054,9 +2580,9 @@ fn store_evidence_blob( bytes.copy_from_slice(&result); let hash = Hash256::from_bytes(bytes); - let authority = - crate::commands::repository_authority::ActiveRepositoryAuthority::open(binding).ok()?; authority + .open() + .ok()? .save_source_blob(hash, evidence_text.as_bytes()) .ok()?; Some(hash) @@ -1066,10 +2592,10 @@ fn store_evidence_blob( mod tests { use super::*; use kin_model::{ - AuthorId, ChangeOrigin, EntityId, EntityKind, EntityMetadata, EntityRole, FilePathId, - FingerprintAlgorithm, GraphNodeId, IdentityRef, LanguageId, Priority, ProvenanceStore, - Relation, RelationId, RelationKind, RelationOrigin, SemanticFingerprint, TestKind, - Visibility, WorkStatus, WorkStore, + AuthorId, ChangeOrigin, ChangeStore, EntityId, EntityKind, EntityMetadata, EntityRole, + FilePathId, FingerprintAlgorithm, GraphNodeId, IdentityRef, LanguageId, Priority, + ProvenanceStore, Relation, RelationId, RelationKind, RelationOrigin, SemanticFingerprint, + TestKind, Visibility, WorkStatus, WorkStore, }; fn make_entity(name: &str, file: &str) -> Entity { Entity { @@ -1195,7 +2721,9 @@ mod tests { let dir = tempfile::tempdir().unwrap(); kin_core::init(dir.path()).unwrap(); let layout = kin_core::KinLayout::discover(dir.path()).unwrap(); - let binding = kin_core::LocalRepositoryAuthorityBinding::from_layout(&layout).unwrap(); + let binding = RequestRepositoryAuthority::pinned( + kin_core::LocalRepositoryAuthorityBinding::from_layout(&layout).unwrap(), + ); let snap = crate::backend::open_kindb_snapshot(&layout).unwrap(); let graph = snap.graph(); @@ -1232,9 +2760,7 @@ mod tests { .unwrap(); snap.save().unwrap(); - let response = execute_verify_run( - &layout, - &binding, + let prepared = prepare_verify_run( graph.as_ref(), &VerifyRunRequest { entity: "checkout".into(), @@ -1243,10 +2769,25 @@ mod tests { }, ) .unwrap(); + let executed = execute_verification(layout.working_dir(), prepared.command_line()); + let (record, response) = + record_verify_run(&binding, graph.as_ref(), &prepared, &executed).unwrap(); assert!(response .lines .iter() .any(|line| line.contains("VerificationRun recorded"))); + // Planning the records writes none of them: the run reaches a graph + // only when its writer applies it, after authority holds the delta. + assert!(graph.list_runs_for_test(&test.test_id).unwrap().is_empty()); + assert!(graph.query_audit_events(None, 10).unwrap().is_empty()); + let delta = record.to_delta().unwrap(); + assert_eq!(delta.verification_runs.len(), 1); + assert_eq!(delta.audit_events.len(), 1); + assert_eq!( + record.reason(), + format!("verify.run {}", record.run().run_id) + ); + record.apply_to(graph.as_ref()).unwrap(); snap.save().unwrap(); drop(graph); drop(snap); @@ -1407,6 +2948,742 @@ mod tests { assert!(plan.removed_entities.is_empty()); } + fn calls(source: &Entity, target: &Entity) -> Relation { + Relation { + id: RelationId::new(), + kind: RelationKind::Calls, + src: GraphNodeId::Entity(source.id), + dst: GraphNodeId::Entity(target.id), + confidence: 1.0, + origin: RelationOrigin::Parsed, + created_in: None, + import_source: None, + evidence: vec![], + } + } + + fn test_entity(name: &str) -> Entity { + Entity { + role: EntityRole::Test, + ..make_entity(name, "tests/math_test.rs") + } + } + + /// The manifests of a project that holds only what it is given. + struct Manifests(Vec<(&'static str, &'static str)>); + + impl kin_parser::project_manifest::ProjectFiles for Manifests { + fn manifest( + &self, + path: &str, + ) -> std::result::Result< + Option>, + kin_parser::project_manifest::UnreadableManifest, + > { + Ok(self + .0 + .iter() + .find(|(name, _)| *name == path) + .map(|(_, text)| std::sync::Arc::from(*text))) + } + } + + const NODE_PACKAGE: &str = r#"{"name":"tally","scripts":{"test":"node --test"}}"#; + + #[test] + fn a_removed_entity_is_an_explicit_verification_gap() { + let dir = tempfile::tempdir().unwrap(); + kin_core::init(dir.path()).unwrap(); + let layout = kin_core::KinLayout::discover(dir.path()).unwrap(); + let binding = RequestRepositoryAuthority::pinned( + kin_core::LocalRepositoryAuthorityBinding::from_layout(&layout).unwrap(), + ); + let snap = crate::backend::open_kindb_snapshot(&layout).unwrap(); + let graph = snap.graph(); + let removed = make_entity("removed", "src/lib.rs"); + // A persistent store refuses a change whose declared identity does not + // recompute from its contents, so the id is derived, not invented. + let mut change = SemanticChange { + id: SemanticChangeId::from_hash(Hash256::from_bytes([17; 32])), + parents: vec![], + origin: ChangeOrigin::Native, + timestamp: Timestamp::now(), + author: AuthorId::new("test"), + message: "remove callable".into(), + entity_deltas: vec![EntityDelta::Removed { old: removed }], + relation_deltas: vec![], + tree_deltas: vec![], + projected_files: vec![], + spec_link: None, + evidence: vec![], + risk_summary: None, + admission_policy_delta: None, + external_reference_deltas: vec![], + resolution_record_deltas: vec![], + }; + change.id = kin_model::compute_semantic_change_id(&change).unwrap(); + graph.create_change(&change).unwrap(); + let scope = WorkVerifyScope { + entities: vec![], + change: Some(change.id.to_string()), + depth: 2, + }; + let result = prepare_work_verify( + &binding, + graph.as_ref(), + &Manifests(vec![("package.json", NODE_PACKAGE)]), + &WorkVerifyRequest::Run(scope), + ); + match result { + Err(error) => assert_eq!( + error.to_string(), + "Kin cannot plan tests for removed entities yet." + ), + Ok(_) => panic!("deletion must not be reported as no changed entities"), + } + } + + /// The tests a guide lists for an entity are the tests its plan runs: a + /// test entity that calls it directly, and one that reaches it through a + /// dependent, each recorded under the one test id its identity gives it. + /// A consumer that is not a test is no test. + #[test] + fn the_plan_runs_the_test_entities_that_reach_an_entity_and_its_dependents() { + let dir = tempfile::tempdir().unwrap(); + kin_core::init(dir.path()).unwrap(); + let layout = kin_core::KinLayout::discover(dir.path()).unwrap(); + let snap = crate::backend::open_kindb_snapshot(&layout).unwrap(); + let graph = snap.graph(); + + let add = make_entity("add", "src/math.rs"); + let multiply = make_entity("multiply", "src/math.rs"); + let check_add = test_entity("check_add"); + let check_multiply = test_entity("check_multiply"); + for entity in [&add, &multiply, &check_add, &check_multiply] { + graph.upsert_entity(entity).unwrap(); + } + graph.upsert_relation(&calls(&multiply, &add)).unwrap(); + graph.upsert_relation(&calls(&check_add, &add)).unwrap(); + graph + .upsert_relation(&calls(&check_multiply, &multiply)) + .unwrap(); + + let reaching = tests_reaching(graph.as_ref(), &add.id).unwrap(); + assert_eq!( + reaching.iter().map(|test| test.id).collect::>(), + [check_add.id], + "multiply reaches add, but it is not a test" + ); + + let plan = build_verification_plan_for_entity(graph.as_ref(), add.clone(), 2).unwrap(); + let mut planned: Vec<_> = plan.tests.iter().map(|test| test.test_id).collect(); + planned.sort_by_key(|id| id.0); + let mut expected = vec![ + test_id_for_entity(&check_add.id), + test_id_for_entity(&check_multiply.id), + ]; + expected.sort_by_key(|id| id.0); + assert_eq!(planned, expected); + assert_eq!( + plan.test_entities + .get(&test_id_for_entity(&check_multiply.id)) + .map(|entity| entity.id), + Some(check_multiply.id) + ); + assert_eq!( + test_id_for_entity(&check_add.id), + test_id_for_entity(&check_add.id), + "a test entity is one test in every run" + ); + + let work = WorkPlan::from_plans(vec![plan]); + let tied: HashSet<_> = work.ran_against.iter().map(|tied| tied.entity).collect(); + for entity in [&add, &multiply, &check_add, &check_multiply] { + assert!( + tied.contains(&entity.id), + "{} is tied to the run", + entity.name + ); + } + } + + /// Recorded structural coverage includes transitive dependencies and + /// imports from test entities. Native test-case scopes remain included, + /// while direct reach is separately readable for targeted proof slices. + #[test] + fn coverage_counts_an_entity_a_test_entity_reaches_as_covered() { + let dir = tempfile::tempdir().unwrap(); + kin_core::init(dir.path()).unwrap(); + let layout = kin_core::KinLayout::discover(dir.path()).unwrap(); + let snap = crate::backend::open_kindb_snapshot(&layout).unwrap(); + let graph = snap.graph(); + + let add = make_entity("add", "src/math.rs"); + let multiply = make_entity("multiply", "src/math.rs"); + let negate = make_entity("negate", "src/math.rs"); + let uncovered = make_entity("uncovered", "src/math.rs"); + let check_multiply = test_entity("check_multiply"); + let mut test_module = test_entity("math_test"); + test_module.kind = EntityKind::Module; + for entity in [ + &add, + &multiply, + &negate, + &uncovered, + &check_multiply, + &test_module, + ] { + graph.upsert_entity(entity).unwrap(); + } + graph.upsert_relation(&calls(&multiply, &add)).unwrap(); + graph + .upsert_relation(&calls(&check_multiply, &multiply)) + .unwrap(); + graph + .upsert_relation(&Relation { + kind: RelationKind::Imports, + ..calls(&test_module, &negate) + }) + .unwrap(); + + assert_eq!( + graph.get_coverage_summary().unwrap().covered_entities, + 0, + "no test case was recorded, so the graph's own summary sees none" + ); + let reading = coverage_reading(graph.as_ref()).unwrap(); + assert_eq!(reading.total_entities, 6); + assert_eq!(reading.covered_entities, 3); + let missing: HashSet<_> = reading.missing_proof.iter().copied().collect(); + assert!(!missing.contains(&multiply.id), "a test calls multiply"); + assert!( + !missing.contains(&add.id), + "the test reaches add through multiply" + ); + assert!( + !missing.contains(&negate.id), + "the test module has recorded import reach" + ); + assert!(missing.contains(&uncovered.id)); + assert!( + tests_reaching(graph.as_ref(), &add.id).unwrap().is_empty(), + "direct reach remains separately readable" + ); + + // A recorded test case still covers what it names. + graph + .create_test_case(&TestCase { + test_id: kin_model::TestId::new(), + name: "covers_negate".into(), + language: "rust".into(), + kind: TestKind::Unit, + scopes: vec![WorkScope::Entity(uncovered.id)], + runner: TestRunner::Cargo, + file_origin: None, + }) + .unwrap(); + let reading = coverage_reading(graph.as_ref()).unwrap(); + assert_eq!(reading.covered_entities, 4); + assert!(!reading.missing_proof.contains(&uncovered.id)); + } + + #[test] + fn a_repository_runs_the_test_command_its_own_manifest_declares() { + assert_eq!( + repository_test_command(&Manifests(vec![("package.json", NODE_PACKAGE)])), + Some(RepositoryTestCommand::Whole(vec![ + "npm".to_string(), + "test".to_string() + ])) + ); + assert_eq!( + repository_test_command(&Manifests(vec![( + "Cargo.toml", + "[package]\nname = \"tally\"\n" + )])), + Some(RepositoryTestCommand::Runner(TestRunner::Cargo)) + ); + assert_eq!(repository_test_command(&Manifests(vec![])), None); + assert_eq!( + repository_test_command(&Manifests(vec![( + "package.json", + r#"{"name":"tally","scripts":{"build":"tsc"}}"# + )])), + None + ); + + let whole = RepositoryTestCommand::Whole(vec!["npm".into(), "test".into()]); + assert_eq!(whole.command_line(&[]), "'npm' 'test'"); + let tests = ["b_test", "a_test"].map(|name| TestCase { + test_id: kin_model::TestId::new(), + name: name.into(), + language: "rust".into(), + kind: TestKind::Unit, + scopes: vec![], + runner: TestRunner::Cargo, + file_origin: None, + }); + assert_eq!( + RepositoryTestCommand::Runner(TestRunner::Cargo).command_line(&tests), + "cargo test -- 'a_test' 'b_test'" + ); + } + + /// A repository with no test command is answered in one sentence that + /// asks nothing of its reader, whatever was asked. + #[test] + fn a_repository_with_no_test_command_is_told_so_in_one_sentence() { + let dir = tempfile::tempdir().unwrap(); + kin_core::init(dir.path()).unwrap(); + let layout = kin_core::KinLayout::discover(dir.path()).unwrap(); + let binding = RequestRepositoryAuthority::pinned( + kin_core::LocalRepositoryAuthorityBinding::from_layout(&layout).unwrap(), + ); + let snap = crate::backend::open_kindb_snapshot(&layout).unwrap(); + for request in [ + WorkVerifyRequest::TestCommand, + WorkVerifyRequest::Run(WorkVerifyScope::default()), + ] { + let PreparedWork::Answered(answer) = prepare_work_verify( + &binding, + snap.graph().as_ref(), + &Manifests(vec![]), + &request, + ) + .unwrap() else { + panic!("nothing runs without a test command"); + }; + assert!(!answer.runnable); + assert_eq!(answer.unavailable.as_deref(), Some(NO_TEST_COMMAND)); + assert!(answer.run.is_none()); + } + assert_eq!(NO_TEST_COMMAND.matches('.').count(), 1); + assert!(!NO_TEST_COMMAND.to_lowercase().contains("run `")); + } + + /// A run of a piece of work is recorded against the fingerprints it + /// planned from. Its result is current while they hold, the newest + /// current run wins, and once an entity it ran against changes the + /// result is stale, never current. A test no run reached is `not_run`. + #[cfg(unix)] + #[test] + fn a_work_run_is_current_until_an_entity_it_ran_against_changes() { + let dir = tempfile::tempdir().unwrap(); + kin_core::init(dir.path()).unwrap(); + let layout = kin_core::KinLayout::discover(dir.path()).unwrap(); + let binding = RequestRepositoryAuthority::pinned( + kin_core::LocalRepositoryAuthorityBinding::from_layout(&layout).unwrap(), + ); + let snap = crate::backend::open_kindb_snapshot(&layout).unwrap(); + let graph = snap.graph(); + + let add = make_entity("add", "src/math.rs"); + let negate = make_entity("negate", "src/math.rs"); + let check_add = test_entity("check_add"); + let helper = make_entity("helper", "src/helpers.rs"); + let helper2 = make_entity("helper2", "src/helpers.rs"); + for entity in [&add, &negate, &check_add, &helper, &helper2] { + graph.upsert_entity(entity).unwrap(); + } + graph.upsert_relation(&calls(&check_add, &add)).unwrap(); + graph.upsert_relation(&calls(&check_add, &helper)).unwrap(); + graph.upsert_relation(&calls(&helper, &helper2)).unwrap(); + graph.upsert_relation(&calls(&helper2, &helper)).unwrap(); + let project = Manifests(vec![("package.json", NODE_PACKAGE)]); + let result = |subject: &Entity| { + test_result( + graph.as_ref(), + &check_add.id, + Some(&subject.id), + &mut HashMap::new(), + ) + .unwrap() + }; + assert_eq!(result(&add).state, TestResultState::NotRun); + + let scope = WorkVerifyScope { + entities: vec![add.id.to_string()], + change: None, + depth: 2, + }; + let run_with = |shell: &str| { + let PreparedWork::Ready(ready) = prepare_work_verify( + &binding, + graph.as_ref(), + &project, + &WorkVerifyRequest::Run(scope.clone()), + ) + .unwrap() else { + panic!("a test reaches add, so the work runs"); + }; + assert_eq!(ready.command_line(), "'npm' 'test'"); + assert_eq!(ready.tests_planned(), 1); + let executed = execute_verification(dir.path(), shell); + let (record, answer) = + record_work_verify(&binding, graph.as_ref(), &ready, &executed).unwrap(); + record.to_delta().unwrap(); + record.apply_to(graph.as_ref()).unwrap(); + answer + }; + + let failed = run_with("exit 3"); + let run = failed.run.as_ref().unwrap(); + assert_eq!(run.outcome, "failed"); + assert_eq!(run.exit_code, Some(3)); + assert_eq!(failed.reach.as_deref(), Some("whole")); + assert_eq!( + failed + .tests + .iter() + .map(|test| test.name.as_str()) + .collect::>(), + ["check_add"] + ); + let reading = result(&add); + assert_eq!(reading.state, TestResultState::Failed); + assert!(reading.at.is_some() && reading.duration_ms.is_some()); + assert!( + reading.summary.as_deref().unwrap().contains("cannot tell"), + "a whole-suite failure is not pinned on this test: {reading:?}" + ); + + std::thread::sleep(std::time::Duration::from_millis(5)); + let passed = run_with("true"); + assert_eq!(passed.run.as_ref().unwrap().outcome, "passed"); + assert_eq!(result(&add).state, TestResultState::Passed); + // A step the run was not tied to has no current result from it. + assert_eq!(result(&negate).state, TestResultState::Stale); + + // An outgoing helper dependency is outside the inbound proof slice, + // but its transitive body still affects what check_add executes. + let mut changed_helper = helper2.clone(); + changed_helper.fingerprint.behavior_hash = Hash256::from_bytes([7; 32]); + graph.upsert_entity(&changed_helper).unwrap(); + assert_eq!(result(&add).state, TestResultState::Stale); + graph.upsert_entity(&helper2).unwrap(); + assert_eq!(result(&add).state, TestResultState::Passed); + let mut unrelated = negate.clone(); + unrelated.fingerprint.behavior_hash = Hash256::from_bytes([6; 32]); + graph.upsert_entity(&unrelated).unwrap(); + assert_eq!(result(&add).state, TestResultState::Passed); + + // Edit add: every run of its test is now against an older add. + let mut edited = add.clone(); + edited.fingerprint.behavior_hash = Hash256::from_bytes([9; 32]); + graph.upsert_entity(&edited).unwrap(); + let stale = result(&edited); + assert_eq!(stale.state, TestResultState::Stale); + assert!(stale + .summary + .as_deref() + .unwrap() + .contains("last run passed")); + + // Put add back as it was, and the passing run is current again. + graph.upsert_entity(&add).unwrap(); + assert_eq!(result(&add).state, TestResultState::Passed); + + // An edit to the test itself makes its result stale too. + let mut test_edited = check_add.clone(); + test_edited.fingerprint.behavior_hash = Hash256::from_bytes([8; 32]); + graph.upsert_entity(&test_edited).unwrap(); + assert_eq!(result(&add).state, TestResultState::Stale); + } + + /// The runner a test command names is missing, as in a JavaScript + /// repository with no `node_modules`: the shell exits 127. That is a + /// command that could not run, said with the shell's own reason, its exit + /// code and the end of its output, and recorded as attesting nothing. + #[cfg(unix)] + #[test] + fn a_missing_runner_exiting_127_could_not_run() { + let dir = tempfile::tempdir().unwrap(); + let executed = execute_verification(dir.path(), "kin-missing-runner --test"); + assert_eq!(executed.exit_code, Some(127)); + assert_eq!(executed.status, VerificationStatus::Pending); + assert_eq!(executed.outcome(), "could_not_run"); + assert_eq!( + executed.could_not_run.as_deref(), + Some("The shell could not find `kin-missing-runner`, so no test ran.") + ); + assert!( + executed + .output_tail + .ends_with("kin-missing-runner: command not found") + || executed + .output_tail + .ends_with("kin-missing-runner: not found"), + "{:?}", + executed.output_tail + ); + assert!(executed + .evidence_text + .starts_with("=== COULD NOT RUN ===\nThe shell could not find `kin-missing-runner`")); + } + + /// Kin could not start the command at all: no exit code, and the reason + /// is the error that stopped it. + #[test] + fn a_command_that_never_started_could_not_run() { + let dir = tempfile::tempdir().unwrap(); + let gone = dir.path().join("removed-working-tree"); + let executed = execute_verification(&gone, "true"); + assert_eq!(executed.exit_code, None); + assert_eq!(executed.status, VerificationStatus::Pending); + assert_eq!(executed.outcome(), "could_not_run"); + let reason = executed.could_not_run.as_deref().unwrap(); + assert!( + reason.starts_with("Kin could not start the test command: "), + "{reason}" + ); + assert!(executed.evidence_text.contains(reason)); + } + + /// A test command that ran and failed stays a failure, even when what its + /// tests printed mentions something not found, and its output is kept. + #[cfg(unix)] + #[test] + fn a_real_failure_stays_a_failure() { + let dir = tempfile::tempdir().unwrap(); + let executed = execute_verification( + dir.path(), + "echo 'not ok 1 - reply carries the status'; \ + echo 'expected 200, got 404 Not Found' >&2; \ + echo 'sh: helper-tool: command not found' >&2; \ + echo '# tests 3, pass 2, fail 1' >&2; exit 1", + ); + assert_eq!(executed.exit_code, Some(1)); + assert_eq!(executed.status, VerificationStatus::Failing); + assert_eq!(executed.outcome(), "failed"); + assert_eq!(executed.could_not_run, None); + assert!(executed + .output_tail + .starts_with("not ok 1 - reply carries the status")); + assert!(executed.output_tail.ends_with("# tests 3, pass 2, fail 1")); + assert!(!executed.evidence_text.contains("COULD NOT RUN")); + + let passed = execute_verification(dir.path(), "echo ok 1; exit 0"); + assert_eq!(passed.status, VerificationStatus::Passing); + assert_eq!(passed.outcome(), "passed"); + assert_eq!(passed.could_not_run, None); + } + + /// A wrapper that turns the shell's 127 into its own exit still relays + /// that the runner was missing, and a script found without permission to + /// run exits 126: neither ran a test. + #[cfg(unix)] + #[test] + fn a_relayed_or_unexecutable_runner_could_not_run() { + let dir = tempfile::tempdir().unwrap(); + let relayed = execute_verification( + dir.path(), + "echo 'sh: borp: command not found' >&2; \ + echo 'error Command failed with exit code 127.' >&2; exit 1", + ); + assert_eq!(relayed.exit_code, Some(1)); + assert_eq!(relayed.status, VerificationStatus::Pending); + assert_eq!( + relayed.could_not_run.as_deref(), + Some("The shell could not find `borp`, so no test ran.") + ); + + std::fs::write(dir.path().join("run-tests"), "#!/bin/sh\nexit 0\n").unwrap(); + let unexecutable = execute_verification(dir.path(), "./run-tests"); + assert_eq!(unexecutable.exit_code, Some(126)); + assert_eq!(unexecutable.status, VerificationStatus::Pending); + assert_eq!( + unexecutable.could_not_run.as_deref(), + Some("The shell found `./run-tests` but could not execute it, so no test ran.") + ); + } + + #[test] + fn shell_complaints_are_read_in_each_shell_s_own_form() { + let not_found = |name: &str| Some(ShellComplaint::NotFound(name.to_string())); + for (line, expected) in [ + ("sh: eslint: command not found", not_found("eslint")), + ("/bin/sh: eslint: command not found", not_found("eslint")), + ("/bin/sh: line 1: tap: command not found", not_found("tap")), + ("sh: 1: jest: not found", not_found("jest")), + ("zsh:1: command not found: borp", not_found("borp")), + ("env: node: No such file or directory", not_found("node")), + ( + "/usr/bin/env: \u{2018}node\u{2019}: No such file or directory", + not_found("node"), + ), + ( + "bash: ./run: cannot execute binary file: Exec format error", + Some(ShellComplaint::NotExecutable("./run".to_string())), + ), + ("expected 200, got 404 Not Found", None), + ("Error: cannot find module 'tap'", None), + ("node: command not found in fixture", None), + ] { + assert_eq!(shell_complaint(line), expected, "{line}"); + } + assert!(relays_could_not_run("npm error code 127")); + assert!(relays_could_not_run("make: *** [test] Error 127")); + assert!(!relays_could_not_run("exit code 1270")); + assert!(!relays_could_not_run("expected code 1")); + assert_eq!( + strip_terminal_codes("\u{1b}[31mnot ok\u{1b}[0m 1"), + "not ok 1" + ); + } + + /// A recorded run whose command could not run attests no outcome: the + /// tests it planned read as not run, with why, and never as failed. A real + /// result for the same code still stands beside a later attempt that could + /// not run, and its evidence keeps the reason. + #[cfg(unix)] + #[test] + fn tests_a_command_could_not_run_read_as_not_run() { + let dir = tempfile::tempdir().unwrap(); + kin_core::init(dir.path()).unwrap(); + let layout = kin_core::KinLayout::discover(dir.path()).unwrap(); + let authority = RequestRepositoryAuthority::pinned( + kin_core::LocalRepositoryAuthorityBinding::from_layout(&layout).unwrap(), + ); + let snap = crate::backend::open_kindb_snapshot(&layout).unwrap(); + let graph = snap.graph(); + let add = make_entity("add", "src/math.rs"); + let check_add = test_entity("check_add"); + for entity in [&add, &check_add] { + graph.upsert_entity(entity).unwrap(); + } + graph.upsert_relation(&calls(&check_add, &add)).unwrap(); + let project = Manifests(vec![("package.json", NODE_PACKAGE)]); + let scope = WorkVerifyScope { + entities: vec![add.id.to_string()], + change: None, + depth: 2, + }; + let result = || { + test_result( + graph.as_ref(), + &check_add.id, + Some(&add.id), + &mut HashMap::new(), + ) + .unwrap() + }; + let run_with = |shell: &str| { + let PreparedWork::Ready(ready) = prepare_work_verify( + &authority, + graph.as_ref(), + &project, + &WorkVerifyRequest::Run(scope.clone()), + ) + .unwrap() else { + panic!("a test reaches add, so the work runs"); + }; + let executed = execute_verification(dir.path(), shell); + let (record, answer) = + record_work_verify(&authority, graph.as_ref(), &ready, &executed).unwrap(); + record.apply_to(graph.as_ref()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + (record, answer) + }; + + let (record, answer) = run_with("kin-missing-runner --test"); + let run = answer.run.as_ref().unwrap(); + assert_eq!(run.outcome, "could_not_run"); + assert_eq!(run.exit_code, Some(127)); + assert_eq!( + run.reason.as_deref(), + Some("The shell could not find `kin-missing-runner`, so no test ran.") + ); + let tail = run.output_tail.as_deref().unwrap(); + assert!( + tail.ends_with("kin-missing-runner: command not found") + || tail.ends_with("kin-missing-runner: not found"), + "{tail:?}" + ); + assert_eq!(answer.tests.len(), 1, "the plan is still reported"); + assert!(record.run().could_not_run()); + assert_eq!(record.run().status, VerificationStatus::Pending); + assert_eq!(record.run().exit_code, Some(127)); + let evidence = authority + .open() + .unwrap() + .load_source_blob(record.run().evidence_blob.unwrap()) + .unwrap(); + assert!(String::from_utf8(evidence) + .unwrap() + .starts_with("=== COULD NOT RUN ===\nThe shell could not find `kin-missing-runner`")); + + let reading = result(); + assert_eq!(reading.state, TestResultState::NotRun); + assert!(reading.at.is_some()); + assert_eq!( + reading.summary.as_deref(), + Some("The test command could not run, so this test did not run. It exited 127.") + ); + let mut lines = Vec::new(); + let plan = build_verification_plan(graph.as_ref(), &add.id.to_string(), 2).unwrap(); + lines.extend(verification_plan_lines(&plan)); + assert!( + lines + .iter() + .any(|line| line.ends_with("latest=could not run")), + "{lines:?}" + ); + + // A command that ran and failed is a failure. + run_with("exit 3"); + assert_eq!(result().state, TestResultState::Failed); + // A later attempt that could not run does not erase that result, + // because it attests nothing about the same code. + run_with("kin-missing-runner --test"); + assert_eq!(result().state, TestResultState::Failed); + run_with("true"); + assert_eq!(result().state, TestResultState::Passed); + + // An attempt against code since edited is not a current result. + let mut edited = add.clone(); + edited.fingerprint.behavior_hash = Hash256::from_bytes([9; 32]); + graph.upsert_entity(&edited).unwrap(); + assert_eq!(result().state, TestResultState::Stale); + } + + /// A run recorded before runs were tied to fingerprints is never shown + /// as current. + #[test] + fn a_run_tied_to_no_fingerprint_is_stale() { + let dir = tempfile::tempdir().unwrap(); + kin_core::init(dir.path()).unwrap(); + let layout = kin_core::KinLayout::discover(dir.path()).unwrap(); + let snap = crate::backend::open_kindb_snapshot(&layout).unwrap(); + let graph = snap.graph(); + let add = make_entity("add", "src/math.rs"); + let check_add = test_entity("check_add"); + graph.upsert_entity(&add).unwrap(); + graph.upsert_entity(&check_add).unwrap(); + graph + .create_verification_run(&VerificationRun { + run_id: VerificationRunId::new(), + test_ids: vec![test_id_for_entity(&check_add.id)], + status: VerificationStatus::Passing, + runner: TestRunner::Cargo, + started_at: Timestamp::now(), + finished_at: Some(Timestamp::now()), + duration_ms: Some(4), + evidence_blob: None, + exit_code: Some(0), + ran_against: Vec::new(), + proof: None, + }) + .unwrap(); + let reading = test_result( + graph.as_ref(), + &check_add.id, + Some(&add.id), + &mut HashMap::new(), + ) + .unwrap(); + assert_eq!(reading.state, TestResultState::Stale); + } + /// `kin verify` reports the tests linked to repository entities, and a /// symbol outside the repository has none here. It is refused by what it /// is, never with the absence line the daemon waits out, and a plan or a diff --git a/crates/kin-cli/src/commands/work.rs b/crates/kin-cli/src/commands/work.rs index 04102e15e..a434b2240 100644 --- a/crates/kin-cli/src/commands/work.rs +++ b/crates/kin-cli/src/commands/work.rs @@ -1633,6 +1633,8 @@ mod tests { duration_ms: Some(25), evidence_blob: None, exit_code: Some(0), + ran_against: Vec::new(), + proof: None, }; store.create_verification_run(&run).unwrap(); store diff --git a/crates/kin-cli/src/commands/workspace_agents.rs b/crates/kin-cli/src/commands/workspace_agents.rs index 697cce6f4..edfb8ea6c 100644 --- a/crates/kin-cli/src/commands/workspace_agents.rs +++ b/crates/kin-cli/src/commands/workspace_agents.rs @@ -10,9 +10,12 @@ //! on disk. A call is the tool the agent used and the entities it was about, //! never the words it asked with, which the daemon does not keep. //! -//! The daemon forgets a session when it ends. This server remembers the -//! sessions it has seen while it runs, with their last call log, so the page -//! can still show what an agent did after it disconnected. +//! An agent that has gone is read from what the daemon keeps past a session's +//! end, `/agents/ended`: the sessions that ended inside the window it keeps +//! them for, with their calls, and the ones it knows only by what they wrote. +//! That record is repository authority, so it is there after this server or +//! the daemon restarts. This server also remembers the sessions it has seen while it +//! runs, which covers a daemon that keeps nothing past a session's end. use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::Infallible; @@ -40,11 +43,19 @@ const READ_TIMEOUT: Duration = Duration::from_secs(8); const NAME_TIMEOUT: Duration = Duration::from_secs(4); const NAME_CONCURRENCY: usize = 6; /// The most entities one answer looks up names for. The rest are counted. -const NAMES_PER_READ: usize = 48; +const NAMES_PER_READ: usize = 96; const NAMES_KEPT: usize = 4096; -/// The most agent sessions this server remembers, live and ended. -const AGENTS_KEPT: usize = 24; -const EARLIER_SHOWN: usize = 8; +/// The most agent sessions this server remembers, live and ended. Above the +/// ended sessions the daemon keeps, with room for the live ones. +const AGENTS_KEPT: usize = 64; +const EARLIER_SHOWN: usize = 12; +/// The newest calls read for each agent that has gone. +const ENDED_CALLS_READ: usize = 5; +/// The entities an agent's lane names for what it read and what it wrote. +const LANE_ENTITIES: usize = 6; +/// How long a change no recent history held is left untitled before it is +/// looked for again. +const UNTITLED_RETRY: Duration = Duration::from_secs(300); /// The most calls remembered for one session, and the most a timeline shows. const CALLS_KEPT: usize = 200; const TIMELINE_DEFAULT: usize = 100; @@ -111,6 +122,37 @@ fn known_agent(raw: &str) -> Option<&'static str> { }) } +/// The id `kin setup clients` lists a coding agent by, from the vendor and +/// client name a session's connection gave, when the session came from one +/// of the coding agents Kin connects. A client names itself in its own +/// words, so the id is matched whole or as what the name begins with: +/// `codex-mcp-client` is Codex. Claude's desktop app is not Claude Code. +pub(super) fn agent_client_id(vendor: &str, client: &str) -> Option<&'static str> { + const BEGINNINGS: [(&str, &str); 11] = [ + ("claude-code", "claude-code"), + ("cursor", "cursor"), + ("codex", "codex"), + ("openai-codex", "codex"), + ("gemini", "gemini-cli"), + ("windsurf", "windsurf"), + ("antigravity", "antigravity"), + ("google-antigravity", "antigravity"), + ("lm-studio", "lm-studio"), + ("lmstudio", "lm-studio"), + ("grok", "grok"), + ]; + [vendor, client].into_iter().find_map(|raw| { + let name = raw.trim().to_ascii_lowercase().replace([' ', '_'], "-"); + BEGINNINGS + .iter() + .find(|(beginning, _)| { + name.strip_prefix(beginning) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('-')) + }) + .map(|(_, id)| *id) + }) +} + /// A client name a reader can be shown, in title case. Nothing that is a /// placeholder, an id or a place on disk. fn titled(raw: &str) -> Option { @@ -154,6 +196,9 @@ pub(super) fn agent_key(session: &str) -> String { pub(super) struct AgentAuthor { pub(super) name: String, pub(super) session: String, + /// The coding agent Kin connects that the change came from, by the id + /// `kin setup clients` lists it by, when it is one of them. + pub(super) client: Option<&'static str>, } pub(super) fn agent_author(raw: &str) -> Option { @@ -165,15 +210,16 @@ pub(super) fn agent_author(raw: &str) -> Option { return None; } let label = text[..open].trim(); + let (vendor, client) = label.split_once('/').unwrap_or((label, "")); let name = if label.is_empty() || label.eq_ignore_ascii_case(session) { "Unnamed agent".to_string() } else { - let (vendor, client) = label.split_once('/').unwrap_or((label, "")); agent_display_name(vendor, client) }; Some(AgentAuthor { name, session: session.to_ascii_lowercase(), + client: agent_client_id(vendor, client), }) } @@ -297,6 +343,9 @@ struct Memory { /// The name an author line gives a session the daemon no longer lists. author_names: HashMap, change_rows: HashMap, + /// Changes the newest recorded history did not hold when last read, and + /// when that was. + untitled: HashMap, } #[derive(Debug, Clone)] @@ -306,6 +355,9 @@ struct Seen { transport: String, started_at: String, last_activity_at: String, + /// When it ended, as Kin recorded it. Empty while it is live, and for one + /// only this server saw go. + ended_at: String, live: bool, last_listed: Instant, calls_total: u64, @@ -315,6 +367,44 @@ struct Seen { changes: Vec, /// The daemon's last answer for this session while it was live. snapshot: Value, + /// Whether the daemon keeps this ended session's calls, so they can be + /// read from it again. + kept_by_kin: bool, + /// Whether what it read is known. Not for an agent Kin knows only by what + /// it wrote. + reads_known: bool, + /// What its kept reads were about, newest first, as the daemon counted + /// them over every call it kept. Empty when it is counted here instead. + read: Vec, + read_total: usize, + /// What it wrote, newest first. + edited: Vec, + edited_total: usize, +} + +impl Seen { + fn new(session: &str, name: &str, now: Instant) -> Self { + Self { + session: session.to_string(), + name: name.to_string(), + transport: String::new(), + started_at: String::new(), + last_activity_at: String::new(), + ended_at: String::new(), + live: true, + last_listed: now, + calls_total: 0, + calls: BTreeMap::new(), + changes: Vec::new(), + snapshot: Value::Null, + kept_by_kin: false, + reads_known: true, + read: Vec::new(), + read_total: 0, + edited: Vec::new(), + edited_total: 0, + } + } } impl AgentMemory { @@ -334,8 +424,9 @@ fn seq(call: &Value) -> u64 { } /// One call as the page may see it: what tool, about which entities, how many -/// entities the answer named, and whether it was refused. Nothing else of the -/// daemon's entry is passed on. +/// entities the answer named, whether it was refused, and, for a write, the +/// change it committed and whether it finished after the agent stopped +/// waiting for it. Nothing else of the daemon's entry is passed on. fn call_json(call: &Value) -> Option { let seq = call["seq"].as_u64()?; let tool = tool_name(text(&call["tool"])); @@ -347,7 +438,7 @@ fn call_json(call: &Value) -> Option { .filter(|id| uuid::Uuid::parse_str(id).is_ok()) .take(IDS_PER_CALL) .collect(); - Some(json!({ + let mut shown = json!({ "seq": seq, "at": text(&call["at"]), "tool": tool, @@ -355,7 +446,17 @@ fn call_json(call: &Value) -> Option { "result_count": call["result_count"].as_u64().unwrap_or(0), "duration_ms": call["duration_ms"].as_u64().unwrap_or(0), "is_error": call["is_error"].as_bool().unwrap_or(false), - })) + }); + if let Some(change) = call["change_id"] + .as_str() + .filter(|id| id.len() == 64 && id.bytes().all(|byte| byte.is_ascii_hexdigit())) + { + shown["change_id"] = json!(change.to_ascii_lowercase()); + } + if call["caller_left"].as_bool() == Some(true) { + shown["caller_left"] = json!(true); + } + Some(shown) } /// A registered tool's name, which is an identifier; anything else is not @@ -437,19 +538,7 @@ fn observe(memory: &AgentMemory, payload: &Value) -> Vec { let seen = memory .agents .entry(session.clone()) - .or_insert_with(|| Seen { - session: session.clone(), - name: name.clone(), - transport: String::new(), - started_at: String::new(), - last_activity_at: String::new(), - live: true, - last_listed: now, - calls_total: 0, - calls: BTreeMap::new(), - changes: Vec::new(), - snapshot: Value::Null, - }); + .or_insert_with(|| Seen::new(&session, &name, now)); seen.name = name; seen.transport = text(&agent["transport"]).to_string(); seen.started_at = text(&agent["started_at"]).to_string(); @@ -485,6 +574,13 @@ fn observe(memory: &AgentMemory, payload: &Value) -> Vec { seen.live = false; } } + bound_agents(&mut memory); + live +} + +/// Forget the ended sessions heard of least recently past what is kept, and +/// the attribution only they held. +fn bound_agents(memory: &mut Memory) { while memory.agents.len() > AGENTS_KEPT { let Some(oldest) = memory .agents @@ -497,8 +593,84 @@ fn observe(memory: &AgentMemory, payload: &Value) -> Vec { }; memory.agents.remove(&oldest); } - prune_attribution(&mut memory); - live + prune_attribution(memory); +} + +fn ids_of(value: &Value) -> Vec { + value + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|id| uuid::Uuid::parse_str(id).is_ok()) + .map(str::to_string) + .collect() +} + +fn count_of(value: &Value, floor: usize) -> usize { + value + .as_u64() + .and_then(|count| usize::try_from(count).ok()) + .unwrap_or(0) + .max(floor) +} + +/// Take in one `/agents/ended` answer: the agents that worked here and are +/// gone, from what Kin keeps past a session's end. A session listed live +/// is left as it is. +fn observe_ended(memory: &AgentMemory, payload: &Value) { + let mut memory = memory.lock(); + let now = Instant::now(); + for agent in payload["agents"].as_array().into_iter().flatten() { + let Some(session) = agent["session_id"].as_str().map(str::to_ascii_lowercase) else { + continue; + }; + if memory.agents.get(&session).is_some_and(|seen| seen.live) { + continue; + } + let name = agent_display_name(text(&agent["vendor"]), text(&agent["client"])); + let seen = memory + .agents + .entry(session.clone()) + .or_insert_with(|| Seen::new(&session, &name, now)); + seen.name = name; + let transport = text(&agent["transport"]); + if !transport.is_empty() { + seen.transport = transport.to_string(); + } + seen.started_at = text(&agent["started_at"]).to_string(); + seen.last_activity_at = text(&agent["last_activity_at"]).to_string(); + seen.ended_at = text(&agent["ended_at"]).to_string(); + seen.live = false; + seen.last_listed = now; + let kept = agent["calls_kept"].as_bool().unwrap_or(false); + seen.kept_by_kin = kept; + seen.reads_known = kept || !seen.calls.is_empty(); + seen.calls_total = agent["calls_total"] + .as_u64() + .unwrap_or(0) + .max(seen.calls_total); + remember_calls( + seen, + agent["recent_calls"] + .as_array() + .map_or(&[][..], Vec::as_slice), + ); + seen.read = ids_of(&agent["read_entity_ids"]); + seen.read_total = count_of(&agent["read_entities"], seen.read.len()); + seen.edited = ids_of(&agent["edited_entity_ids"]); + seen.edited_total = count_of(&agent["edited_entities"], seen.edited.len()); + let changes: Vec = agent["change_ids"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|id| is_change_id(id)) + .map(str::to_ascii_lowercase) + .collect(); + remember_changes(&mut memory, &session, &changes); + } + bound_agents(&mut memory); } fn is_change_id(text: &str) -> bool { @@ -569,10 +741,20 @@ async fn names_for(memory: &AgentMemory, client: &DaemonClient, wanted: &[String /// Rows for changes an agent made: title, time and how many entities moved. /// Read from the newest recorded changes, once; a change's row never changes. +/// +/// A change older than the history read has no row, and is shown by its id +/// alone, so its review can still be opened. It is looked for again only +/// after [`UNTITLED_RETRY`]. async fn change_rows(memory: &AgentMemory, client: &DaemonClient, ids: &[String]) -> Vec { let missing = { let memory = memory.lock(); - ids.iter().any(|id| !memory.change_rows.contains_key(id)) + ids.iter().any(|id| { + !memory.change_rows.contains_key(id) + && memory + .untitled + .get(id) + .is_none_or(|at| at.elapsed() >= UNTITLED_RETRY) + }) }; if missing { let request = super::log::LogRequest { @@ -606,13 +788,28 @@ async fn change_rows(memory: &AgentMemory, client: &DaemonClient, ids: &[String] }); memory.change_rows.insert(id, row); } + let now = Instant::now(); + for id in ids { + if !memory.change_rows.contains_key(id) { + memory.untitled.insert(id.clone(), now); + } + } + memory + .untitled + .retain(|_, at| at.elapsed() < UNTITLED_RETRY * 2); prune_attribution(&mut memory); } } } let memory = memory.lock(); ids.iter() - .filter_map(|id| memory.change_rows.get(id).cloned()) + .map(|id| { + memory + .change_rows + .get(id) + .cloned() + .unwrap_or_else(|| json!({ "id": id })) + }) .collect() } @@ -681,6 +878,42 @@ fn intents(snapshot: &Value, scope_names: &mut Map) -> Vec .collect() } +/// What an agent's kept calls read: what each read asked about first, +/// newest first, then what the answers named, each once. +fn reads_of(seen: &Seen) -> (Vec, usize) { + if !seen.read.is_empty() { + return (seen.read.clone(), seen.read_total.max(seen.read.len())); + } + let reads: Vec<&Value> = seen + .calls + .values() + .rev() + .filter(|call| is_read(text(&call["tool"])) && call["is_error"] != true) + .collect(); + let mut kept = HashSet::new(); + let mut ids = Vec::new(); + let all = |call: &&Value| -> Vec { + call["entity_ids"] + .as_array() + .into_iter() + .flatten() + .map(|id| text(id).to_string()) + .collect() + }; + for id in reads.iter().filter_map(|call| all(call).into_iter().next()) { + if kept.insert(id.clone()) { + ids.push(id); + } + } + for id in reads.iter().flat_map(all) { + if kept.insert(id.clone()) { + ids.push(id); + } + } + let total = ids.len(); + (ids, total) +} + fn agent_json(seen: &Seen, calls_shown: usize, changes: Vec) -> Value { let live = seen.live; let mut scope_names = Map::new(); @@ -689,6 +922,7 @@ fn agent_json(seen: &Seen, calls_shown: usize, changes: Vec) -> Value { } else { Vec::new() }; + let (read, read_total) = reads_of(seen); json!({ "key": agent_key(&seen.session), "name": seen.name, @@ -701,7 +935,13 @@ fn agent_json(seen: &Seen, calls_shown: usize, changes: Vec) -> Value { }, "started_at": seen.started_at, "last_activity_at": seen.last_activity_at, + "ended_at": (!seen.ended_at.is_empty()).then_some(&seen.ended_at), "calls_total": seen.calls_total, + "reads_known": seen.reads_known, + "read": read.into_iter().take(LANE_ENTITIES * 2).collect::>(), + "read_total": read_total, + "edited": seen.edited.iter().take(LANE_ENTITIES * 2).collect::>(), + "edited_total": seen.edited_total.max(seen.edited.len()), "recent_calls": seen.calls.values().rev().take(calls_shown).cloned().collect::>(), "open_changes": if live { open_changes(&seen.snapshot) } else { Vec::new() }, "intents": intents, @@ -729,6 +969,16 @@ fn wanted_ids(agent: &Value) -> Vec { ids.push(text(id).to_string()); } } + for field in ["read", "edited"] { + for id in agent[field] + .as_array() + .into_iter() + .flatten() + .take(LANE_ENTITIES) + { + ids.push(text(id).to_string()); + } + } ids } @@ -756,65 +1006,131 @@ fn read_at() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) } -async fn read_agents(state: &UiState, client: &DaemonClient) -> Result<(Value, Vec)> { - let payload = tokio::time::timeout(READ_TIMEOUT, client.agents()) - .await - .context("Kin took too long to say which agents are connected")??; +/// One read of who is here and who has gone. +struct AgentsRead { + payload: Value, + live: Vec, + /// The window Kin keeps agents that have gone for, when it keeps them. + kept: Option, + /// Kin keeps them, but could not be read this time. + earlier_unread: bool, +} + +async fn read_agents(memory: &AgentMemory, client: &DaemonClient) -> Result { + let (here, gone) = tokio::join!( + tokio::time::timeout(READ_TIMEOUT, client.agents()), + tokio::time::timeout(READ_TIMEOUT, client.ended_agents(ENDED_CALLS_READ)), + ); + let payload = here.context("Kin took too long to say which agents are connected")??; anyhow::ensure!( payload["agents"].is_array(), "Kin's answer about connected agents was not readable" ); - let live = observe(&state.agents, &payload); - Ok((payload, live)) + let live = observe(memory, &payload); + let (kept, earlier_unread) = match gone { + Ok(Ok(Some(ended))) if ended["agents"].is_array() => { + observe_ended(memory, &ended); + let kept = json!({ "days": ended["kept_days"], "sessions": ended["kept_sessions"] }); + (Some(kept), false) + } + // A daemon that keeps nothing past a session's end. + Ok(Ok(None)) => (None, false), + _ => (None, true), + }; + Ok(AgentsRead { + payload, + live, + kept, + earlier_unread, + }) +} + +/// When an agent was last active, for ordering the ones that have gone. +fn last_active(seen: &Seen) -> Option> { + chrono::DateTime::parse_from_rfc3339(seen.last_activity_at.trim()) + .ok() + .map(|at| at.with_timezone(&chrono::Utc)) +} + +/// The agents here now and the ones that have gone, as the page shows them. +async fn agents_answer(memory: &AgentMemory, client: &DaemonClient) -> Result { + let read = read_agents(memory, client).await?; + let (live_seen, earlier_seen): (Vec, Vec) = { + let memory = memory.lock(); + let live_seen = read + .live + .iter() + .filter_map(|session| memory.agents.get(session).cloned()) + .collect(); + // An agent that connected and asked nothing has nothing to show once + // it has gone. + let mut earlier: Vec = memory + .agents + .values() + .filter(|seen| { + !seen.live + && (seen.calls_total > 0 || !seen.changes.is_empty() || !seen.edited.is_empty()) + }) + .cloned() + .collect(); + earlier.sort_by(|a, b| { + last_active(b) + .cmp(&last_active(a)) + .then_with(|| b.last_listed.cmp(&a.last_listed)) + }); + earlier.truncate(EARLIER_SHOWN); + (live_seen, earlier) + }; + // Every change shown is titled from one read of the history. + let shown = + |seen: &Seen| -> Vec { seen.changes.iter().take(CHANGES_SHOWN).cloned().collect() }; + let wanted: Vec = live_seen + .iter() + .chain(earlier_seen.iter()) + .flat_map(shown) + .collect(); + let rows: HashMap = change_rows(memory, client, &wanted) + .await + .into_iter() + .filter_map(|row| Some((row["id"].as_str()?.to_string(), row))) + .collect(); + let rows_of = |seen: &Seen| -> Vec { + shown(seen) + .iter() + .filter_map(|id| rows.get(id).cloned()) + .collect() + }; + let agents: Vec = live_seen + .iter() + .map(|seen| agent_json(seen, 5, rows_of(seen))) + .collect(); + let earlier: Vec = earlier_seen + .iter() + .map(|seen| agent_json(seen, 3, rows_of(seen))) + .collect(); + let wanted: Vec = agents + .iter() + .chain(earlier.iter()) + .flat_map(wanted_ids) + .collect(); + let mut names = names_for(memory, client, &wanted).await; + merge_names(&mut names, &agents); + Ok(json!({ + "schema": "kin.workspace.agents.v1", + "read_at": read_at(), + "active_window_secs": read.payload["active_window_secs"], + "agents": agents, + "earlier": earlier, + "kept": read.kept, + "earlier_unread": read.earlier_unread, + "names": names, + })) } async fn serve_agents(State(state): State>) -> Response { let answer = async { let client = super::review_ui::daemon(&state).await?; - let (payload, live) = read_agents(&state, &client).await?; - let (live_seen, earlier_seen): (Vec, Vec) = { - let memory = state.agents.lock(); - let live_seen = live - .iter() - .filter_map(|session| memory.agents.get(session).cloned()) - .collect(); - let mut earlier: Vec = memory - .agents - .values() - .filter(|seen| !seen.live) - .cloned() - .collect(); - earlier.sort_by(|a, b| b.last_listed.cmp(&a.last_listed)); - earlier.truncate(EARLIER_SHOWN); - (live_seen, earlier) - }; - let mut agents = Vec::new(); - for seen in &live_seen { - let shown: Vec = seen.changes.iter().take(CHANGES_SHOWN).cloned().collect(); - let rows = change_rows(&state.agents, &client, &shown).await; - agents.push(agent_json(seen, 5, rows)); - } - let mut earlier = Vec::new(); - for seen in &earlier_seen { - let shown: Vec = seen.changes.iter().take(CHANGES_SHOWN).cloned().collect(); - let rows = change_rows(&state.agents, &client, &shown).await; - earlier.push(agent_json(seen, 3, rows)); - } - let wanted: Vec = agents - .iter() - .chain(earlier.iter()) - .flat_map(wanted_ids) - .collect(); - let mut names = names_for(&state.agents, &client, &wanted).await; - merge_names(&mut names, &agents); - Ok::<_, anyhow::Error>(json!({ - "schema": "kin.workspace.agents.v1", - "read_at": read_at(), - "active_window_secs": payload["active_window_secs"], - "agents": agents, - "earlier": earlier, - "names": names, - })) + agents_answer(&state.agents, &client).await } .await; match answer { @@ -870,14 +1186,14 @@ async fn serve_timeline( }; // The agents read refreshes what this server remembers; when it fails the // timeline is still drawn from what was remembered. - let listed = read_agents(&state, &client).await.is_ok(); + let listed = read_agents(&state.agents, &client).await.is_ok(); let Some(seen) = session_for_key(&state.agents, &query.agent) else { return unavailable( StatusCode::NOT_FOUND, - "Kin has no record of this agent any more. Its session ended before this page saw it.", + "Kin has no record of this agent any more.", ); }; - if seen.live { + if seen.live || seen.kept_by_kin { if let Ok(Ok(calls)) = tokio::time::timeout( READ_TIMEOUT, client.session_calls(&seen.session, Some(limit)), @@ -1018,7 +1334,7 @@ pub(super) async fn serve_change_agent( ) } }; - let _ = read_agents(&state, &client).await; + let _ = read_agents(&state.agents, &client).await; let mut owner = state.agents.lock().change_owner.get(&change).cloned(); if owner.is_none() { let _ = change_rows(&state.agents, &client, std::slice::from_ref(&change)).await; @@ -1044,7 +1360,7 @@ pub(super) async fn serve_change_agent( })) .into_response(); }; - if seen.live { + if seen.live || seen.kept_by_kin { if let Ok(Ok(calls)) = tokio::time::timeout( READ_TIMEOUT, client.session_calls(&session, Some(CALLS_KEPT)), @@ -1332,6 +1648,30 @@ async fn serve_events(State(state): State>) -> Response { mod tests { use super::*; + /// The commit an agent's client gave up on reaches the page as what the + /// agent did: its change, and that it finished after the agent stopped + /// waiting. A read passes on neither. + #[test] + fn a_write_says_the_change_it_committed_and_whether_its_agent_waited() { + let change = "C".repeat(64); + let commit = call_json(&json!({ + "seq": 3, "at": "2026-10-14T13:08:30Z", "tool": "kin_transaction_commit", + "entity_ids": [], "result_count": 1, "duration_ms": 461_000, "is_error": false, + "change_id": change, "caller_left": true, "repo_id": "elsewhere", + })) + .unwrap(); + assert_eq!(commit["change_id"], "c".repeat(64)); + assert_eq!(commit["caller_left"], true); + assert!(commit.get("repo_id").is_none()); + let read = call_json(&json!({ + "seq": 1, "at": "2026-10-14T13:00:48Z", "tool": "semantic_locate", + "entity_ids": [], "change_id": "not a change", "caller_left": false, + })) + .unwrap(); + assert!(read.get("change_id").is_none()); + assert!(read.get("caller_left").is_none()); + } + #[test] fn agents_are_named_as_a_reader_knows_them() { assert_eq!( @@ -1356,6 +1696,37 @@ mod tests { assert_eq!(agent_display_name("", "src/agent.py"), "Unnamed agent"); } + #[test] + fn a_session_is_matched_to_the_coding_agent_kin_connects() { + for (vendor, client, id) in [ + ("claude-code", "claude-code", Some("claude-code")), + ("codex-mcp-client", "codex-mcp-client", Some("codex")), + ("cursor-vscode", "Cursor VSCode", Some("cursor")), + ( + "gemini-cli-mcp-client", + "gemini-cli-mcp-client", + Some("gemini-cli"), + ), + ("windsurf-client", "windsurf-client", Some("windsurf")), + ("lm-studio", "LM Studio", Some("lm-studio")), + ("", "Grok CLI", Some("grok")), + ("antigravity", "", Some("antigravity")), + // Claude's desktop app is another product, and a name that only + // begins like an id is another name. + ("claude-ai", "Claude", None), + ("cursory-helper", "cursory helper", None), + ("my-helper", "my-helper", None), + ("", "", None), + ] { + assert_eq!(agent_client_id(vendor, client), id, "{vendor} / {client}"); + } + let author = + agent_author("codex-mcp-client/Codex ") + .unwrap(); + assert_eq!(author.client, Some("codex")); + assert_eq!(agent_author("ada "), None); + } + #[test] fn a_change_authored_by_an_agent_names_the_agent_and_hides_the_session() { let session = "0B6F8C55-3F0E-4A8E-9A31-5D1F2F0C9E11"; @@ -1427,19 +1798,11 @@ mod tests { const C: &str = "0c3d7a4e-8d55-4d0b-a7a4-0b1f1b8f6d10"; fn seen_with(calls: &[Value], changes: &[&str]) -> Seen { - let mut seen = Seen { - session: "s".into(), - name: "Claude Code".into(), - transport: "mcp".into(), - started_at: String::new(), - last_activity_at: String::new(), - live: true, - last_listed: Instant::now(), - calls_total: calls.len() as u64, - calls: BTreeMap::new(), - changes: changes.iter().map(|c| c.to_string()).collect(), - snapshot: json!({ "state": "active" }), - }; + let mut seen = Seen::new("s", "Claude Code", Instant::now()); + seen.transport = "mcp".into(); + seen.calls_total = calls.len() as u64; + seen.changes = changes.iter().map(|c| c.to_string()).collect(); + seen.snapshot = json!({ "state": "active" }); remember_calls(&mut seen, calls); seen } @@ -1578,6 +1941,165 @@ mod tests { assert_eq!(shown["recent_calls"].as_array().unwrap().len(), 2); } + /// A daemon on a free port that answers `/agents` with nobody here and + /// `/agents/ended` with `ended`, or with `status` when it is not 200. + async fn daemon_answering( + ended: Value, + status: StatusCode, + ) -> (DaemonClient, tokio::task::JoinHandle<()>) { + let mut identity = + kin_core::runtime_identity::RuntimeServiceIdentity::current("hosted-daemon").unwrap(); + identity.namespace = None; + let app = Router::new() + .route( + "/runtime/identity", + get(move || { + let identity = identity.clone(); + async move { Json(identity) } + }), + ) + .route( + "/agents", + get(|| async { Json(json!({ "schema": "kin.agents.v1", "active_window_secs": 60, "agents": [] })) }), + ) + .route( + "/agents/ended", + get(move || { + let ended = ended.clone(); + async move { + if status == StatusCode::OK { + Json(ended).into_response() + } else { + status.into_response() + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = DaemonClient::from_base_url_with_explicit_authority( + format!("http://{address}"), + None, + None, + ) + .unwrap(); + (client, server) + } + + fn ended_answer(session: &str, change: &str) -> Value { + json!({ "schema": "kin.agents-ended.v1", "kept_days": 30, "kept_sessions": 32, "agents": [ + { "session_id": session, "client": "claude-code", "vendor": "claude-code", "transport": "mcp", + "started_at": "2026-10-14T07:43:01Z", "last_activity_at": "2026-10-14T07:44:07Z", + "ended_at": "2026-10-14T07:44:30Z", "calls_kept": true, "calls_total": 4, + "calls_retained": 4, + "recent_calls": [call(4, "kin_transaction_commit", &[A]), call(3, "get_entity_source", &[A]), + call(2, "semantic_locate", &[A, B])], + "read_entity_ids": [A, B], "read_entities": 2, + "edited_entity_ids": [A], "edited_entities": 1, + "change_ids": [change] }, + { "session_id": "6f1d2c3b-0a9e-4c8d-b7a6-5e4f3a2b1c0d", "client": "codex", "vendor": "codex", + "transport": "", "started_at": "2026-10-13T09:00:00Z", "last_activity_at": "2026-10-13T09:05:00Z", + "ended_at": "2026-10-13T09:05:00Z", "calls_kept": false, "calls_total": 0, "calls_retained": 0, + "recent_calls": [], "read_entity_ids": [], "read_entities": 0, + "edited_entity_ids": [C], "edited_entities": 1, "change_ids": ["d".repeat(64)] }, + ]}) + } + + /// The Agents page is drawn by a server that saw none of it: the agent had + /// ended before it started, as after a restart. Who it was, when, what it + /// read, what it wrote and the change it committed still come from what + /// Kin kept, and the window Kin keeps is said. + #[tokio::test] + async fn an_agent_that_ended_before_this_server_started_is_an_earlier_lane() { + let session = "dbecbbcd-c382-4559-93ba-9f08bbf28ab6"; + let change = "7a484793eb222d07dc925f52d65f98e0cea1f4d4d376315588e90bc20082be07"; + let (client, server) = + daemon_answering(ended_answer(session, change), StatusCode::OK).await; + let memory = AgentMemory::default(); + let answer = agents_answer(&memory, &client).await.unwrap(); + server.abort(); + assert_eq!(answer["agents"], json!([])); + assert_eq!(answer["kept"], json!({ "days": 30, "sessions": 32 })); + assert_eq!(answer["earlier_unread"], false); + let earlier = answer["earlier"].as_array().unwrap(); + assert_eq!(earlier.len(), 2, "{answer}"); + let lane = &earlier[0]; + assert_eq!(lane["name"], "Claude Code"); + assert_eq!(lane["state"], "ended"); + assert_eq!(lane["key"], agent_key(session)); + assert_eq!(lane["ended_at"], "2026-10-14T07:44:30Z"); + assert_eq!(lane["calls_total"], 4); + assert_eq!(lane["reads_known"], true); + assert_eq!(lane["read"], json!([A, B])); + assert_eq!(lane["edited"], json!([A])); + assert_eq!( + lane["changes"], + json!([{ "id": change }]), + "its review opens by id even untitled" + ); + assert_eq!(lane["changes_total"], 1); + assert_eq!(lane["recent_calls"][0]["tool"], "kin_transaction_commit"); + let wrote_only = &earlier[1]; + assert_eq!(wrote_only["name"], "Codex"); + assert_eq!( + wrote_only["reads_known"], false, + "what it read was not kept" + ); + assert_eq!(wrote_only["edited"], json!([C])); + let text = answer.to_string(); + assert!( + !text.contains(session), + "no session id reaches the page: {text}" + ); + // The review of that change names the agent from the same record. + assert_eq!( + memory.lock().change_owner.get(change).map(String::as_str), + Some(session) + ); + } + + #[tokio::test] + async fn the_page_says_when_kin_keeps_no_earlier_agents_and_when_it_could_not_read_them() { + let (client, server) = daemon_answering(json!({}), StatusCode::NOT_FOUND).await; + let answer = agents_answer(&AgentMemory::default(), &client) + .await + .unwrap(); + server.abort(); + assert_eq!(answer["kept"], Value::Null); + assert_eq!(answer["earlier_unread"], false); + let (client, server) = daemon_answering(json!({}), StatusCode::INTERNAL_SERVER_ERROR).await; + let answer = agents_answer(&AgentMemory::default(), &client) + .await + .unwrap(); + server.abort(); + assert_eq!(answer["kept"], Value::Null); + assert_eq!(answer["earlier_unread"], true); + assert_eq!(answer["earlier"], json!([])); + } + + #[test] + fn a_live_session_is_not_taken_for_one_that_ended() { + let memory = AgentMemory::default(); + let session = "dbecbbcd-c382-4559-93ba-9f08bbf28ab6"; + observe( + &memory, + &json!({ "agents": [{ "session_id": session, "client": "claude-code", "vendor": "claude-code", + "recent_calls": [call(1, "semantic_locate", &[A])], "calls_total": 1 }] }), + ); + observe_ended(&memory, &ended_answer(session, &"a".repeat(64))); + let seen = memory.lock().agents.get(session).cloned().unwrap(); + assert!(seen.live); + assert!(seen.ended_at.is_empty()); + // Once it is gone, what Kin kept of it is taken in. + observe(&memory, &json!({ "agents": [] })); + observe_ended(&memory, &ended_answer(session, &"a".repeat(64))); + let seen = memory.lock().agents.get(session).cloned().unwrap(); + assert!(!seen.live && seen.kept_by_kin); + assert_eq!(state_of(&seen), "ended"); + assert_eq!(seen.calls_total, 4); + } + #[test] fn attribution_history_stays_bounded_through_agent_and_commit_churn() { let memory = AgentMemory::default(); diff --git a/crates/kin-cli/src/commands/workspace_explore.rs b/crates/kin-cli/src/commands/workspace_explore.rs index a4f8ac98c..63f70ee4d 100644 --- a/crates/kin-cli/src/commands/workspace_explore.rs +++ b/crates/kin-cli/src/commands/workspace_explore.rs @@ -2,8 +2,9 @@ // Copyright 2026 Firelock, LLC //! Read-only answers the workspace page explores a repository with: an -//! overview, one question answered by meaning and by name, one entity in -//! independently fallible sections, and a map of the whole repository. +//! overview, one question answered by meaning and by name, with the chain of +//! calls behind it when it asks how or where something happens, one entity +//! in independently fallible sections, and a map of the whole repository. //! //! Every answer speaks of entities. No field carries a place in the checkout, //! even where the daemon's own answer holds one, and a graph miss is reported @@ -51,11 +52,13 @@ const OVERVIEW_RELIED_ON: usize = 10; /// How many of the most relied-on rows are read, so the ones with no /// definition here can be left out and the list still fills. const OVERVIEW_RELIED_ON_READ: usize = 40; +/// How many of the entities others rely on that no test reaches are shown. +const OVERVIEW_UNTESTED: usize = 12; const OVERVIEW_CHANGES: usize = 5; const ENTITY_LIST_CAP: usize = 50; const NEIGHBORHOOD_LIMIT: usize = 120; -const IMPACT_DEPTH: u32 = 3; +const IMPACT_DEPTH: u32 = super::verify::WORK_TEST_REACH_DEPTH; const IMPACT_TOP: usize = 8; const HISTORY_CAP: usize = 10; /// Inventory rows read to find one entity's doc summary by its name. @@ -157,7 +160,8 @@ fn daemon_unreachable(error: anyhow::Error) -> Response { } /// The graph's spelling of a kind or relation, as the page reads it: -/// `TraitDef` and `trait_def` are both `trait_def`, `Calls` is `calls`. +/// `TraitDef`, `trait_def` and `traitdef` are all `trait_def`, `Calls` is +/// `calls`. pub(super) fn kind_label(raw: &str) -> String { let mut label = String::with_capacity(raw.len() + 4); for character in raw.trim().chars() { @@ -172,9 +176,41 @@ pub(super) fn kind_label(raw: &str) -> String { label.push(character); } } - label + // Locate publishes a kind as its variant name lowercased, which runs a + // compound kind together: `typealias` is the `type_alias` every other + // answer says. + match RUN_TOGETHER.iter().find(|(joined, _)| *joined == label) { + Some((_, spelled)) => (*spelled).to_string(), + None => label, + } } +/// Each compound kind and relation as a lowercased variant name spells it, +/// beside the one spelling the page reads. +const RUN_TOGETHER: [(&str, &str); 21] = [ + ("traitdef", "trait_def"), + ("typealias", "type_alias"), + ("apiendpoint", "api_endpoint"), + ("eventcontract", "event_contract"), + ("documentnode", "document_node"), + ("enumdef", "enum_def"), + ("enumvariant", "enum_variant"), + ("staticvar", "static_var"), + ("usesmacro", "uses_macro"), + ("usestype", "uses_type"), + ("dependson", "depends_on"), + ("emitsevent", "emits_event"), + ("subscribesto", "subscribes_to"), + ("definescontract", "defines_contract"), + ("consumescontract", "consumes_contract"), + ("sendsmessage", "sends_message"), + ("cochanges", "co_changes"), + ("derivedfrom", "derived_from"), + ("documentedby", "documented_by"), + ("ownedby", "owned_by"), + ("ownedbyfile", "owned_by_file"), +]; + /// A signature a reader is shown. A module, package or file entity's /// recorded signature is the place its text is held, not a declaration, so /// none is shown for one. @@ -403,9 +439,20 @@ impl<'a> Census<'a> { self.node(id).is_some_and(|node| node.file.is_some()) } + /// What contains an entity, and under `within` what contains that. Two + /// containers can share a name and a kind, and the one a container sits + /// in is what tells them apart. fn container_of(&self, id: &str) -> Option { let container = self.node(self.parent.get(id)?)?; - Some(entity_ref(&container.id, &container.name, &container.kind)) + let mut named = entity_ref(&container.id, &container.name, &container.kind); + let outer = self + .parent + .get(container.id.as_str()) + .and_then(|outer| self.node(outer)); + if let Some(outer) = outer { + named["within"] = entity_ref(&outer.id, &outer.name, &outer.kind); + } + Some(named) } fn top(&self, id: &'a str) -> &'a str { @@ -537,6 +584,30 @@ fn overview_languages(summary: &Value) -> Result> { }))) } +/// Whether counts of what relies on an entity are exact or lower bounds, +/// from the daemon's store-wide call-site reading in its summary: `exact` +/// when every call site the store holds is settled, `at_least` while some +/// are not, since a call Kin cannot prove may be one more caller. +/// +/// `None` when the summary was not read or its daemon does not report the +/// reading, so the page is told no bound at all rather than a guessed one. +fn relied_on_bound(summary: Option<&Value>) -> Option<&'static str> { + let settled = summary?.get("call_sites_settled")?.as_bool()?; + Some(if settled { "exact" } else { "at_least" }) +} + +/// `section` with `bound` under `key`, or as it was when there is none. +fn with_bound( + mut section: Map, + key: &str, + bound: Option<&'static str>, +) -> Map { + if let Some(bound) = bound { + section.insert(key.into(), Value::String(bound.into())); + } + section +} + pub(super) fn overview_containers(census: &Census<'_>, limit: usize) -> Map { let containers = census.containers(); let total = containers.len(); @@ -610,6 +681,81 @@ pub(super) fn overview_relied_on( }))) } +/// The entities many others rely on that no test reaches, most relied-on +/// first, from the daemon's reading of the whole repository. +/// +/// The daemon reads coverage in the pass that ranks, with the rule `kin +/// verify --missing` reads it with, and counts dependents as the ranking +/// does, so this list cannot disagree with either. It leaves out names with +/// no definition here, tests and modules. A daemon that does not report the +/// reading is a gap, said as one: nothing here guesses at coverage. +pub(super) fn overview_untested( + inventory: &Value, + census: Option<&Census<'_>>, +) -> Result> { + let untested = inventory + .get("untested") + .filter(|value| value.is_object()) + .context( + "this repository's daemon does not report it and predates this version of kin ui", + )?; + let rows = untested["entities"] + .as_array() + .context("the reading omitted its rows")?; + let total = untested["total"] + .as_u64() + .context("the reading omitted its total")?; + let items: Vec = rows + .iter() + .filter_map(|row| { + let id = row["id"].as_str()?; + let mut item = fields(entity_ref( + id, + row["name"].as_str().unwrap_or_default(), + row["kind"].as_str().unwrap_or_default(), + )); + item.insert( + "container".into(), + census + .and_then(|census| census.container_of(id)) + .unwrap_or(Value::Null), + ); + item.insert("dependents".into(), row["dependents"].clone()); + Some(Value::Object(item)) + }) + .collect(); + Ok(fields(json!({ + "items": items, + "total": total, + "scope": "recorded_graph", + "bound": "observed", + "basis": "Entities defined here that others rely on and that no recorded test reaches through the supported semantic relationships. Tests and modules are left out. Read across the whole repository, most relied-on first. These are recorded graph gaps, not evidence that complete runtime test coverage is known.", + }))) +} + +/// The untested section, or its gap in one sentence: what was not read and +/// why, with no list guessed in its place. +fn untested_section(answer: Result>) -> Value { + match answer { + Ok(mut fields) => { + fields.insert("ok".into(), Value::Bool(true)); + Value::Object(fields) + } + Err(error) => { + let mut why = readable(&format!("{error:#}")); + while why.ends_with(['.', '!', '?', ' ']) { + why.pop(); + } + json!({ + "ok": false, + "reason": plain(&format!( + "Kin could not read which entities no test reaches: {why}." + )), + }) + } + } +} + fn recent_changes(report: crate::commands::log::LogReport) -> Map { let items: Vec = report .entries @@ -657,8 +803,16 @@ async fn serve_overview(State(state): State>) -> Response { !repo_id.is_empty(), "the daemon did not identify its repository" ); + // One ranking pass in the daemon answers both the most relied-on + // list and what no test reaches, so the overview asks once. client - .workspace_entities(&repo_id, "", OVERVIEW_RELIED_ON_READ, 0) + .workspace_entities_and_untested( + &repo_id, + "", + OVERVIEW_RELIED_ON_READ, + 0, + Some(OVERVIEW_UNTESTED), + ) .await }), bounded(READ_TIMEOUT, async { @@ -680,19 +834,30 @@ async fn serve_overview(State(state): State>) -> Response { }), ); let census = export.as_ref().ok().map(Census::new); + let bound = relied_on_bound(summary.as_ref().ok()); let counts = match (&export, &summary) { (Ok(payload), _) => Ok(overview_counts(payload)), (Err(_), Ok(summary)) => overview_counts_from_summary(summary), (Err(error), Err(_)) => Err(anyhow::anyhow!("{error:#}")), - }; + } + .map(|counts| with_bound(counts, "relationships_bound", bound)); let containers = match (&census, &export) { (Some(census), _) => Ok(overview_containers(census, OVERVIEW_CONTAINERS)), (None, Err(error)) => Err(anyhow::anyhow!("{error:#}")), (None, Ok(_)) => Err(anyhow::anyhow!("the graph read was empty")), }; let languages = summary.and_then(|summary| overview_languages(&summary)); - let relied_on = ranking - .and_then(|inventory| overview_relied_on(&inventory, census.as_ref(), OVERVIEW_RELIED_ON)); + let (relied_on, untested) = match ranking { + Ok(inventory) => ( + overview_relied_on(&inventory, census.as_ref(), OVERVIEW_RELIED_ON) + .map(|relied_on| with_bound(relied_on, "bound", bound)), + overview_untested(&inventory, census.as_ref()), + ), + Err(error) => { + let reason = format!("{error:#}"); + (Err(error), Err(anyhow::anyhow!(reason))) + } + }; let branch = branches.ok().and_then(|report| { report .branches @@ -715,6 +880,7 @@ async fn serve_overview(State(state): State>) -> Response { "languages": section("Kin could not read this repository's languages.", languages), "containers": section("Kin could not read what contains what.", containers), "most_relied_on": section("Kin could not rank the most relied-on entities.", relied_on), + "untested": untested_section(untested), "recent_changes": section("Kin could not read recent changes.", log.map(recent_changes)), "read_at": read_at(), })) @@ -947,7 +1113,42 @@ async fn enrich(client: &DaemonClient, ids: Vec) -> HashMap) -> Value { +/// What contains each result's container, by the container's id, read from +/// the container's own answer. Two results can sit in containers of one name +/// and kind, and this is what tells those apart. A container that sits in +/// nothing, or whose read fails or is slow, has no entry. +async fn outer_containers( + client: &DaemonClient, + sources: &HashMap, +) -> HashMap { + let containers: HashSet = sources + .values() + .filter_map(|source| owner_ref(&source["semantic_owners"])) + .filter_map(|container| parse_entity_id(container["id"].as_str()?)) + .collect(); + let gate = Arc::new(tokio::sync::Semaphore::new(ENRICH_CONCURRENCY)); + let mut reads = tokio::task::JoinSet::new(); + for id in containers { + let client = client.clone(); + let gate = gate.clone(); + reads.spawn(async move { + let _permit = gate.acquire_owned().await.ok()?; + let answer = bounded(ENRICH_TIMEOUT, client.workspace_entity_owners(&id)) + .await + .ok()?; + Some((id, owner_ref(&answer["semantic_owners"])?)) + }); + } + let mut found = HashMap::new(); + while let Some(read) = reads.join_next().await { + if let Ok(Some((id, outer))) = read { + found.insert(id, outer); + } + } + found +} + +fn hit_json(hit: &Hit, source: Option<&Value>, outer: &HashMap) -> Value { let mut row = fields(json!({ "id": hit.id, "name": hit.name, "kind": hit.kind, "matched": hit.matched(), "why": hit.why(), "defined_here": hit.defined_here, @@ -965,7 +1166,11 @@ fn hit_json(hit: &Hit, source: Option<&Value>) -> Value { row.insert("dependents".into(), json!(dependents)); } if let Some(source) = source { - if let Some(container) = owner_ref(&source["semantic_owners"]) { + if let Some(mut container) = owner_ref(&source["semantic_owners"]) { + let within = container["id"].as_str().and_then(|id| outer.get(id)); + if let Some(within) = within { + container["within"] = within.clone(); + } row.insert("container".into(), container); } if let Some(module) = text_field(source, "module") { @@ -975,6 +1180,687 @@ fn hit_json(hit: &Hit, source: Option<&Value>) -> Value { Value::Object(row) } +// --------------------------------------------------------------------------- +// Ask: the chain behind a how or where question +// --------------------------------------------------------------------------- +// +// A question that asks how or where something happens is answered with a +// short chain of entities when the graph holds one Kin can stand behind, and +// with the ranked list alone when it does not. Every hop of a chain is a call +// the graph resolved, read from the graph's own record of what calls each +// entity, and the chain as a whole is confirmed by the route query behind +// `kin path`. Which chain is the question's is decided by the graph's own +// names, under one of two rules: +// +// - named ends: the chain ends at a ranked result whose name carries some of +// the question's words and begins at the nearest caller whose name carries +// the rest, so both ends are named by the question; +// - sole callers: the chain ends at a ranked result whose name carries every +// word of the question, and each entity on it is called by the one above +// it and by nothing else, so no choice between callers was made. +// +// Anything else would be a guess, and a guess is not returned. + +/// The ranked results one answer climbs from, best first. +const ANSWER_SEEDS: usize = 4; +/// How long the reads behind an answer may take together. Past it the +/// question is answered with its list alone. +const ANSWER_TIMEOUT: Duration = Duration::from_secs(3); +const ANSWER_MIN_STEPS: usize = 3; +const ANSWER_MAX_STEPS: usize = 8; +/// How many calls above a ranked result its callers are read. +const ANSWER_HOPS: usize = 6; +/// The entities whose callers are read above one ranked result. +const ANSWER_READS: usize = 40; +/// The fewest words of a question that must name something before a chain is +/// tied to it. One word names a topic, not a question. +const ANSWER_MIN_TERMS: usize = 2; + +/// Words that ask or connect and name nothing in a repository. +const ANSWER_STOP_WORDS: &[&str] = &[ + "a", + "about", + "actually", + "all", + "an", + "and", + "any", + "are", + "as", + "at", + "be", + "been", + "being", + "by", + "can", + "code", + "codebase", + "come", + "comes", + "could", + "did", + "do", + "does", + "done", + "each", + "end", + "ends", + "every", + "exactly", + "explain", + "flow", + "flows", + "for", + "from", + "get", + "gets", + "getting", + "go", + "goes", + "got", + "had", + "happen", + "happening", + "happens", + "has", + "have", + "here", + "how", + "i", + "if", + "in", + "into", + "is", + "it", + "its", + "kin", + "me", + "my", + "not", + "now", + "of", + "on", + "one", + "or", + "our", + "out", + "over", + "pass", + "passes", + "place", + "please", + "reach", + "reaches", + "repo", + "repository", + "should", + "show", + "so", + "something", + "tell", + "that", + "the", + "their", + "them", + "then", + "there", + "these", + "they", + "thing", + "things", + "this", + "those", + "to", + "up", + "us", + "was", + "way", + "we", + "were", + "what", + "when", + "where", + "which", + "who", + "why", + "will", + "with", + "work", + "working", + "works", + "would", + "you", + "your", +]; + +/// What a question asks for, when it asks how or where something happens. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Asks { + How, + Where, +} + +impl Asks { + fn word(self) -> &'static str { + match self { + Self::How => "how", + Self::Where => "where", + } + } +} + +/// The words of a question or of one of the graph's names, lowercased: +/// split wherever a letter or digit run ends and wherever a capital begins a +/// new word, so `Reply.serializeInput` is `reply`, `serialize` and `input`. +fn words_of(text: &str) -> Vec { + let mut words = Vec::new(); + for part in text.split(|character: char| !character.is_alphanumeric()) { + let letters: Vec = part.chars().collect(); + let mut start = 0; + for index in 1..=letters.len() { + let ends = index == letters.len() || { + let (before, here) = (letters[index - 1], letters[index]); + (before.is_lowercase() && here.is_uppercase()) + || before.is_alphabetic() != here.is_alphabetic() + || (before.is_uppercase() + && here.is_uppercase() + && letters + .get(index + 1) + .is_some_and(|next| next.is_lowercase())) + }; + if ends { + words.push( + letters[start..index] + .iter() + .collect::() + .to_lowercase(), + ); + start = index; + } + } + } + words +} + +/// Whether a question asks how or where something happens. The asking word +/// leads, after at most two words such as "show me". "How many" and its kind +/// ask for an amount, which no chain answers. +fn asks(question: &str) -> Option { + let words = words_of(question); + for (index, word) in words.iter().enumerate().take(3) { + match word.as_str() { + "where" => return Some(Asks::Where), + "how" => { + let amount = matches!( + words.get(index + 1).map(String::as_str), + Some( + "many" + | "much" + | "long" + | "often" + | "big" + | "large" + | "small" + | "old" + | "far" + | "fast" + | "slow" + ) + ); + return (!amount).then_some(Asks::How); + } + _ => {} + } + } + None +} + +/// One word reduced so its forms meet: `serialized`, `serializer` and +/// `serialization` all become `serializ`, `routes` and `routing` become +/// `rout`. It only has to agree with itself, since both the question and the +/// graph's names pass through it. +fn stem(word: &str) -> String { + let mut text = match word { + "sent" => "send", + "built" => "build", + "ran" => "run", + "made" => "make", + "wrote" | "written" => "write", + "threw" | "thrown" => "throw", + "caught" => "catch", + "found" => "find", + "kept" => "keep", + "held" => "hold", + "chose" | "chosen" => "choose", + other => other, + } + .to_string(); + let replaced = [ + ("ization", "ize"), + ("isation", "ize"), + ("izing", "ize"), + ("ized", "ize"), + ("izer", "ize"), + ("izes", "ize"), + ("ies", "y"), + ("sses", "ss"), + ] + .into_iter() + .find(|(suffix, _)| text.ends_with(suffix) && text.len() > suffix.len() + 2); + if let Some((suffix, with)) = replaced { + text.truncate(text.len() - suffix.len()); + text.push_str(with); + } else if text.ends_with('s') && !text.ends_with("ss") && text.len() > 3 { + text.pop(); + } + for (suffix, longer_than) in [("ing", 6), ("ed", 4), ("er", 5), ("or", 5)] { + if text.ends_with(suffix) && text.len() > longer_than { + text.truncate(text.len() - suffix.len()); + // `formatted` and `running` double a consonant before the suffix. + let mut letters = text.chars().rev(); + if let (Some(last), Some(before)) = (letters.next(), letters.next()) { + if text.len() > 3 && last == before && !"aeiouls".contains(last) { + text.pop(); + } + } + break; + } + } + if text.ends_with('e') && text.len() > 4 { + text.pop(); + } + if text.ends_with('y') && text.len() > 3 { + text.pop(); + text.push('i'); + } + text +} + +/// The words of a question that could name something in a repository, +/// reduced, in the order asked and once each. +fn question_terms(question: &str) -> Vec { + let mut terms: Vec = Vec::new(); + for word in words_of(question) { + if word.chars().count() < 2 || ANSWER_STOP_WORDS.contains(&word.as_str()) { + continue; + } + let term = stem(&word); + if !terms.contains(&term) { + terms.push(term); + } + } + terms +} + +/// Whether a word of a name is a word of the question: the same once +/// reduced, or one beginning with the other when that is at least four +/// letters, which is how `regist` meets `register`. +fn term_matches(term: &str, word: &str) -> bool { + let (shorter, longer) = if term.len() <= word.len() { + (term, word) + } else { + (word, term) + }; + shorter == longer || (shorter.chars().count() >= 4 && longer.starts_with(shorter)) +} + +/// Which of the question's words a name carries, by their place in `terms`. +fn matched_terms(name: &str, terms: &[String]) -> HashSet { + let words: Vec = words_of(name).iter().map(|word| stem(word)).collect(); + terms + .iter() + .enumerate() + .filter(|(_, term)| words.iter().any(|word| term_matches(term, word))) + .map(|(index, _)| index) + .collect() +} + +fn is_callable(kind: &str) -> bool { + matches!(kind, "function" | "method") +} + +/// One entity of an answer's chain. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Link { + id: String, + name: String, + kind: String, +} + +/// What the graph records as calling one entity. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct Callers { + /// The functions and methods that call it by a call the graph resolved, + /// once each and by name, so one question always reads one order. + proven: Vec, + /// How many distinct entities call it at all, resolved or not. + all: usize, + /// Whether the read was cut short, so there may be more. + cut: bool, +} + +impl Callers { + /// The one entity that calls it, when exactly one does and that call is + /// resolved. + fn only(&self) -> Option<&Link> { + (!self.cut && self.all == 1 && self.proven.len() == 1).then(|| &self.proven[0]) + } +} + +/// What calls `id`, from the graph's own incoming relations for it. +fn callers_of(incoming: &Value, id: &str) -> Callers { + let entities: HashMap<&str, &Value> = incoming["entities"] + .as_array() + .into_iter() + .flatten() + .filter_map(|row| Some((row["id"].as_str()?, row))) + .collect(); + let mut all: HashSet<&str> = HashSet::new(); + let mut proven: Vec = Vec::new(); + for relation in incoming["relations"].as_array().into_iter().flatten() { + let calls = relation["direction"] == "incoming" + && kind_label(relation["kind"].as_str().unwrap_or_default()) == "calls"; + let Some(caller) = relation["src"]["Entity"].as_str().filter(|_| calls) else { + continue; + }; + if caller == id { + continue; + } + all.insert(caller); + let Some(row) = entities.get(caller) else { + continue; + }; + let kind = kind_label(row["kind"].as_str().unwrap_or_default()); + let named = ( + parse_entity_id(caller), + text_field(row, "name"), + relation["resolution"] == "type_resolved" && is_callable(&kind), + ); + if let (Some(id), Some(name), true) = named { + if proven.iter().all(|seen| seen.id != id) { + proven.push(Link { id, name, kind }); + } + } + } + proven.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); + Callers { + proven, + all: all.len(), + cut: incoming["truncated"] != false, + } +} + +/// What was read above one ranked result: who calls it, who calls those, and +/// so on, by resolved calls only. +#[derive(Debug, Default)] +struct Climb { + /// Each caller found, nearest first, with the entity it calls on its + /// shortest way down to the result. + above: Vec<(Link, String)>, + /// What calls each entity whose callers were read, by its id. + callers: HashMap, +} + +/// Read who calls `target`, and who calls those, level by level. Each read +/// is one entity's incoming relations. It stops [`ANSWER_HOPS`] calls up or +/// after [`ANSWER_READS`] entities, and a read that fails or is slow only +/// leaves that entity's callers unknown. +async fn climb(client: &DaemonClient, target: &Link) -> Climb { + let mut climb = Climb::default(); + let mut seen: HashSet = HashSet::from([target.id.clone()]); + let mut level = vec![target.id.clone()]; + let mut reads = 0; + for _ in 0..ANSWER_HOPS { + level.truncate(ANSWER_READS - reads); + if level.is_empty() { + break; + } + reads += level.len(); + let answers = futures_util::future::join_all(level.iter().map(|id| async move { + bounded( + ENRICH_TIMEOUT, + client.workspace_entity_neighborhood(id, "in", NEIGHBORHOOD_LIMIT), + ) + .await + .ok() + })) + .await; + let mut next = Vec::new(); + for (id, answer) in level.iter().zip(answers) { + let Some(answer) = answer else { + continue; + }; + let callers = callers_of(&answer, id); + for caller in &callers.proven { + if seen.insert(caller.id.clone()) { + climb.above.push((caller.clone(), id.clone())); + next.push(caller.id.clone()); + } + } + climb.callers.insert(id.clone(), callers); + } + level = next; + } + climb +} + +/// Whether a name reads as test code. The graph's fast reads do not say +/// which entities are tests, and a test calls into the code it tests, so it +/// would otherwise stand as the place a chain begins. +fn names_a_test(name: &str) -> bool { + words_of(name).iter().any(|word| { + matches!( + word.as_str(), + "test" | "tests" | "spec" | "specs" | "fixture" | "fixtures" | "mock" | "mocks" + ) + }) +} + +/// The shortest chain of resolved calls that ends at `target` and begins at +/// a caller whose name carries a word of the question `target`'s own name +/// does not, with every word of the question carried by some name on it. +fn named_ends_chain( + target: &Link, + climb: &Climb, + terms: &[String], + tests_asked: bool, +) -> Option> { + let down: HashMap<&str, (&Link, &str)> = climb + .above + .iter() + .map(|(link, next)| (link.id.as_str(), (link, next.as_str()))) + .collect(); + let target_terms = matched_terms(&target.name, terms); + // Nearest first, so the first chain that fits is the shortest. + climb.above.iter().find_map(|(entry, _)| { + let mut chain = Vec::new(); + let mut at = entry.id.as_str(); + while at != target.id { + let &(link, next) = down.get(at)?; + if chain.len() >= ANSWER_MAX_STEPS { + return None; + } + chain.push(link.clone()); + at = next; + } + chain.push(target.clone()); + let named: HashSet = chain + .iter() + .flat_map(|link| matched_terms(&link.name, terms)) + .collect(); + let entry_adds = matched_terms(&entry.name, terms) + .iter() + .any(|term| !target_terms.contains(term)); + let fits = (ANSWER_MIN_STEPS..=ANSWER_MAX_STEPS).contains(&chain.len()) + && named.len() == terms.len() + && entry_adds + && (tests_asked || !chain.iter().any(|link| names_a_test(&link.name))); + fits.then_some(chain) + }) +} + +/// The chain above `target` while each entity has exactly one caller and +/// that call is resolved. It stops where an entity has several callers, +/// none, or callers that were not read whole. +fn sole_caller_chain(target: &Link, climb: &Climb, tests_asked: bool) -> Option> { + let mut upward = vec![target.clone()]; + while upward.len() < ANSWER_MAX_STEPS { + let below = &upward[upward.len() - 1]; + let Some(only) = climb.callers.get(&below.id).and_then(Callers::only) else { + break; + }; + if upward.iter().any(|seen| seen.id == only.id) + || (!tests_asked && names_a_test(&only.name)) + { + break; + } + upward.push(only.clone()); + } + upward.reverse(); + (upward.len() >= ANSWER_MIN_STEPS).then_some(upward) +} + +/// `a`, `a and b`, `a, b and c`. +fn listed(names: &[&str]) -> String { + match names { + [] => String::new(), + [only] => (*only).to_string(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} + +/// One sentence that says a chain, in the graph's own names and nothing else. +fn answer_sentence(asks: Asks, chain: &[Link]) -> String { + let names: Vec<&str> = chain.iter().map(|link| link.name.as_str()).collect(); + let (Some(entry), Some(target)) = (names.first(), names.last()) else { + return String::new(); + }; + let between = listed(&names[1..names.len() - 1]); + match asks { + Asks::Where => { + format!("This happens in {target}, which {entry} reaches through {between}.") + } + Asks::How if names.len() <= 4 => { + let mut sentence = format!("{entry} calls {}", names[1]); + for name in &names[2..] { + sentence.push_str(&format!(", which calls {name}")); + } + sentence.push('.'); + sentence + } + Asks::How => { + format!("{entry} reaches {target} through {between}, each calling the next.") + } + } +} + +fn answer_json(asks: Asks, basis: &str, chain: &[Link]) -> Value { + let steps: Vec = chain + .iter() + .enumerate() + .map(|(index, link)| { + json!({ + "id": link.id, + "name": link.name, + "kind": link.kind, + "relation_to_next": (index + 1 < chain.len()).then_some("calls"), + }) + }) + .collect(); + json!({ + "asks": asks.word(), + "sentence": plain(&answer_sentence(asks, chain)), + "basis": basis, + "steps": steps, + }) +} + +/// The chain the calls above one ranked result give the question, and the +/// rule it stands on. +fn chain_from( + target: &Link, + climb: &Climb, + terms: &[String], + tests_asked: bool, +) -> Option<(&'static str, Vec)> { + if matched_terms(&target.name, terms).len() < terms.len() { + let chain = named_ends_chain(target, climb, terms, tests_asked)?; + Some(("named_ends", chain)) + } else { + Some(( + "sole_callers", + sole_caller_chain(target, climb, tests_asked)?, + )) + } +} + +/// The ranked results an answer may end at: functions and methods defined +/// here whose names carry a word of the question, best first. +fn answer_seeds(hits: &[Hit], terms: &[String]) -> Vec { + hits.iter() + .filter(|hit| { + hit.defined_here + && is_callable(&hit.kind) + && !matched_terms(&hit.name, terms).is_empty() + }) + .take(ANSWER_SEEDS) + .map(|hit| Link { + id: hit.id.clone(), + name: hit.name.clone(), + kind: hit.kind.clone(), + }) + .collect() +} + +/// Whether the route query behind `kin path` finds `chain`'s first entity +/// reaching its last within the chain's own length. +async fn route_confirmed(client: &DaemonClient, chain: &[Link]) -> bool { + let (Some(entry), Some(target)) = (chain.first(), chain.last()) else { + return false; + }; + let request = kin_mcp::handlers::path::PathRequest { + from: entry.id.clone(), + to: target.id.clone(), + max_depth: Some(chain.len() - 1), + limit: Some(1), + direction: Some(kin_mcp::handlers::path::PathDirection::Forward), + ..Default::default() + }; + client + .path(&request) + .await + .is_ok_and(|route| route["found"] == true) +} + +/// The answer to a how or where question, or nothing when the graph holds +/// no chain Kin can stand behind. The best-ranked result with a chain wins. +async fn answer(client: &DaemonClient, question: &str, hits: &[Hit]) -> Option { + let asks = asks(question)?; + let terms = question_terms(question); + if terms.len() < ANSWER_MIN_TERMS { + return None; + } + let tests_asked = names_a_test(question); + let read = async { + let seeds = answer_seeds(hits, &terms); + let climbs = + futures_util::future::join_all(seeds.iter().map(|seed| climb(client, seed))).await; + for (target, climb) in seeds.iter().zip(&climbs) { + let Some((basis, chain)) = chain_from(target, climb, &terms, tests_asked) else { + continue; + }; + if route_confirmed(client, &chain).await { + return Some(answer_json(asks, basis, &chain)); + } + } + None + }; + tokio::time::timeout(ANSWER_TIMEOUT, read) + .await + .ok() + .flatten() +} + async fn serve_ask(State(state): State>, Query(query): Query) -> Response { let (text, limit) = match ask_request(&query) { Ok(request) => request, @@ -1039,10 +1925,19 @@ async fn serve_ask(State(state): State>, Query(query): Query = hits .iter() - .map(|hit| hit_json(hit, sources.get(&hit.id))) + .map(|hit| hit_json(hit, sources.get(&hit.id), &outer)) .collect(); let meaning = located.map(|located| { fields(json!({ @@ -1058,6 +1953,7 @@ async fn serve_ask(State(state): State>, Query(query): Query, source: Option<&Value>, @@ -1233,13 +2135,20 @@ pub(super) fn source_section( if let Some(text) = body["text"].as_str() { let start = body["start_line"].as_u64(); let lines = text.lines().count().max(1) as u64; - return Ok(fields(json!({ + let mut section = fields(json!({ "text": text, "start_line": start, "end_line": start.map(|start| start + lines - 1), "language": language, "working": body["working"].as_bool().unwrap_or(false), - }))); + })); + if let Some(digest) = body["body_hash"] + .as_str() + .filter(|digest| is_digest(digest)) + { + section.insert("body_hash".into(), Value::String(digest.to_string())); + } + return Ok(section); } } if source.and_then(|source| source["body"].as_str()).is_none() { @@ -1258,6 +2167,11 @@ pub(super) fn source_section( }))) } +/// Whether `text` is a whole content digest: 64 hex digits. +fn is_digest(text: &str) -> bool { + text.len() == 64 && text.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + fn coverage_bound(answer: &Value) -> (&'static str, Option<&'static str>) { let exact = answer["_kin"]["completeness"]["bound"].as_str() == Some("exact"); if exact { @@ -1310,6 +2224,14 @@ impl Relier { } impl Relier { + fn primary_relationship(&self) -> Option<&String> { + self.relationships.iter().min_by(|left, right| { + relationship_precedence(left) + .cmp(&relationship_precedence(right)) + .then_with(|| left.cmp(right)) + }) + } + fn relate(&mut self, kind: String) { if !kind.is_empty() && !self.relationships.contains(&kind) { self.relationships.push(kind); @@ -1341,6 +2263,20 @@ impl Relier { } } +fn relationship_precedence(kind: &str) -> u8 { + let mut variant = String::new(); + for part in kind.split('_') { + let mut chars = part.chars(); + if let Some(first) = chars.next() { + variant.extend(first.to_uppercase()); + variant.extend(chars); + } + } + serde_json::from_value::(Value::String(variant)) + .map(super::review_guide::connection_precedence) + .unwrap_or(u8::MAX) +} + /// What reaches the entity, one row per entity: every relationship the graph /// records into it (`contains` excluded), the references Kin proved, with /// their sites, and the matches by name alone it could not prove, marked @@ -1407,7 +2343,7 @@ pub(super) fn relied_on_by_section( let relies = relier.relies(); let mut item = fields(json!({ "id": other, "name": relier.name, "kind": relier.kind, - "relationship": relier.relationships.first(), + "relationship": relier.primary_relationship(), "relationships": relier.relationships, "is_test": is_test, "confirmed": relier.confirmed, "sites": relier.sites, "relies": relies, @@ -1459,12 +2395,14 @@ pub(super) fn relied_on_by_section( ); } if references.is_none() { + // Its rows still came from its relationships, matches by name alone + // among them, so the sentence says only what the reference read adds. said.push( - "Kin could not read its references, so sites and matches by name are missing." + "Kin could not read its references, so where each one uses it is not shown, and \ + there could be more." .to_string(), ); - } - if let Some(coverage) = coverage { + } else if let Some(coverage) = coverage { said.push(coverage.to_string()); } else if truncated { said.push(INCOMPLETE_COVERAGE.to_string()); @@ -1803,10 +2741,9 @@ pub(super) fn impact_sections( .iter() .filter(|candidate| candidate.hop == 1) .count(); - let tests: Vec<&kin_review::RankedImpactCandidate> = candidates - .iter() - .filter(|candidate| bucket(candidate) == "test") - .collect(); + // Tests as the walk counts them, one per test, nearest and most direct + // first: the same reading a guided review's step and verification use. + let tests = &ranked.tests; let mut ordered: Vec<&kin_review::RankedImpactCandidate> = candidates.iter().collect(); ordered.sort_by(|a, b| { b.priority_score @@ -1814,8 +2751,11 @@ pub(super) fn impact_sections( .then_with(|| a.hop.cmp(&b.hop)) .then_with(|| a.identity.name.cmp(&b.identity.name)) }); + // Tests are the tests section's rows, nearest first; listed here too, the + // same test read twice on one page, once as further out. let top: Vec = ordered .into_iter() + .filter(|candidate| bucket(candidate) != "test") .take(IMPACT_TOP) .map(|candidate| { let reason = bucket(candidate); @@ -1834,23 +2774,42 @@ pub(super) fn impact_sections( })); let mut test_items: Vec = tests .iter() - .map(|candidate| { - json!({ - "id": candidate.entity_id.to_string(), "name": candidate.identity.name, - "kind": kind_label(&candidate.identity.kind), "hops": candidate.hop, - }) + .map(|test| { + let mut item = json!({ + "id": test.entity_id.to_string(), "name": test.identity.name, + "kind": kind_label(&test.identity.kind), "hops": test.hop, + }); + if test.by_call() { + item["by_call"] = Value::Bool(true); + } + // Tests of one name are told apart by the module each sits in, + // or, for test modules of one name, the namespace each sits in. + let places = |of: &kin_review::TestReach| { + super::review_guide::enclosing_namespaces( + Some(&of.identity.file), + of.identity.kind != "module", + ) + }; + let others: Vec> = tests + .iter() + .filter(|other| { + other.entity_id != test.entity_id && other.identity.name == test.identity.name + }) + .map(places) + .collect(); + if !others.is_empty() { + if let Some(place) = super::review_guide::distinct_place(&places(test), &others) { + item["module"] = Value::String(place); + } + } + item }) .collect(); - test_items.sort_by(|a, b| { - a["hops"] - .as_u64() - .cmp(&b["hops"].as_u64()) - .then_with(|| a["name"].as_str().cmp(&b["name"].as_str())) - }); let total = test_items.len(); test_items.truncate(ENTITY_LIST_CAP); let mut tests_section = fields(json!({ "shown": test_items.len(), "items": test_items, "total": total, "bound": bound, + "depth": ranked.max_depth, })); if let Some(notice) = notice { impact.insert("notice".into(), Value::String(notice.into())); @@ -1860,7 +2819,16 @@ pub(super) fn impact_sections( } /// Recent recorded changes that changed the entity, newest first. -pub(super) fn history_section(history: &Value) -> Result> { +/// +/// While the store holds only part of the repository's history +/// (`history_partial`), the change Kin's record of it starts at, when that is +/// the one change the store began with, is named as `record_start`: the page +/// says the record starts there, with the repository as it was, as the +/// repository's own history does. +pub(super) fn history_section( + history: &Value, + history_partial: bool, +) -> Result> { let revisions = history["revisions"] .as_array() .context("the history answer omitted its revisions")?; @@ -1879,10 +2847,18 @@ pub(super) fn history_section(history: &Value) -> Result> { let total = history["revisions_total"] .as_u64() .unwrap_or(revisions.len() as u64); - Ok(fields(json!({ + let mut section = fields(json!({ "shown": items.len(), "items": items, "total": total, "unchanged": history["unchanged_revisions"].as_u64().unwrap_or(0), - }))) + })); + let start = history["record_start"].as_str(); + let listed_last = items.last().and_then(|item| item["change_id"].as_str()); + if let (true, Some(start)) = (history_partial, start) { + if listed_last == Some(start) && items.len() as u64 == total { + section.insert("record_start".into(), Value::String(start.to_string())); + } + } + Ok(section) } fn review_json(response: Result) -> Result { @@ -2116,6 +3092,17 @@ async fn serve_entity( }; let references = not_read(references); let incoming = not_read(incoming); + let history_partial = asked.contains(&"history") && { + let layout = state.layout.clone(); + tokio::task::spawn_blocking(move || { + matches!( + kin_core::history_state::standing(&layout), + kin_core::history_state::HistoryStanding::Partial { .. } + ) + }) + .await + .unwrap_or(false) + }; let mut history = Some(not_read(history)); let mut relies_on = Some(relies_on); let mut members = Some(members); @@ -2191,7 +3178,7 @@ async fn serve_entity( history .take() .unwrap_or_else(|| Err(anyhow::anyhow!("it was not read"))) - .and_then(|answer| history_section(&answer)), + .and_then(|answer| history_section(&answer, history_partial)), ), "editor" => section( "This entity cannot be opened in an editor.", @@ -2508,6 +3495,58 @@ mod tests { assert_eq!(kind_label("ApiEndpoint"), "api_endpoint"); } + /// Every entity kind reads in the one spelling the graph serializes, + /// whether it arrives as that spelling, as its variant name, or as the + /// variant name lowercased the way locate publishes it. + #[test] + fn every_entity_kind_reads_as_its_serialized_word_however_it_arrives() { + use kin_model::EntityKind::{self, *}; + let every = [ + Function, + Class, + Interface, + TraitDef, + TypeAlias, + Module, + Package, + Test, + Schema, + ApiEndpoint, + EventContract, + File, + DocumentNode, + Method, + EnumDef, + EnumVariant, + Constant, + StaticVar, + Macro, + Field, + ]; + // A kind added later fails to compile here until it is listed above. + for kind in every { + match kind { + Function | Class | Interface | TraitDef | TypeAlias | Module | Package | Test + | Schema | ApiEndpoint | EventContract | File | DocumentNode | Method | EnumDef + | EnumVariant | Constant | StaticVar | Macro | Field => {} + } + } + let serialized = |kind: EntityKind| { + serde_json::to_value(kind) + .unwrap() + .as_str() + .unwrap() + .to_string() + }; + for kind in every { + let word = serialized(kind); + let variant = format!("{kind:?}"); + assert_eq!(kind_label(&word), word); + assert_eq!(kind_label(&variant), word, "{variant}"); + assert_eq!(kind_label(&variant.to_lowercase()), word, "{variant}"); + } + } + #[test] fn shown_text_never_carries_an_em_dash() { assert_eq!( @@ -2602,6 +3641,135 @@ mod tests { assert_eq!(containers["items"][1]["container"]["name"], "signer"); } + /// The most relied-on list and the relationship count say whether they + /// are exact or lower bounds, from the daemon's store-wide call-site + /// reading. A daemon that does not report the reading, or a summary that + /// was not read, leaves both with no bound at all, never a guessed one. + #[test] + fn overview_counts_say_whether_they_can_miss_callers_only_when_the_daemon_says() { + let payload = fixture(); + let census = Census::new(&payload); + let inventory = json!({"total": 1, "entities": [ + {"id": "f-function", "name": "want_bytes", "kind": "Function", + "file_path": "held/for/grouping/only.py", "dependents": 2}, + ]}); + let summary = |settled: Value| { + let mut summary = json!({"entities": 7, "edges": 9, "kinds": {}, "languages": {}}); + if !settled.is_null() { + summary["call_sites_settled"] = settled; + } + summary + }; + let read = |summary: Option<&Value>| { + let bound = relied_on_bound(summary); + let counts = section( + "counts", + Ok(with_bound( + overview_counts(&payload), + "relationships_bound", + bound, + )), + ); + let relied_on = section( + "relied on", + overview_relied_on(&inventory, Some(&census), 10) + .map(|relied_on| with_bound(relied_on, "bound", bound)), + ); + (counts, relied_on) + }; + + let unsettled = summary(json!(false)); + let (counts, relied_on) = read(Some(&unsettled)); + assert_eq!(counts["relationships_bound"], "at_least"); + assert_eq!(relied_on["bound"], "at_least"); + assert_eq!(counts["relationships"], payload.relation_count); + assert_eq!(relied_on["items"][0]["dependents"], 2); + + let settled = summary(json!(true)); + let (counts, relied_on) = read(Some(&settled)); + assert_eq!(counts["relationships_bound"], "exact"); + assert_eq!(relied_on["bound"], "exact"); + + // An older daemon, a reading that is not a yes or no, and a summary + // that failed all say nothing about the bound. + let older = summary(Value::Null); + let malformed = summary(json!("settled")); + for summary in [Some(&older), Some(&malformed), None] { + let (counts, relied_on) = read(summary); + assert_eq!(counts["ok"], true); + assert!(counts.get("relationships_bound").is_none(), "{counts}"); + assert_eq!(relied_on["ok"], true); + assert!(relied_on.get("bound").is_none(), "{relied_on}"); + } + } + + /// Two entities can share a name, a kind and the name of what contains + /// them. What their container sits in tells them apart, so each + /// container says it, one level up and by name alone. + #[test] + fn a_container_says_what_it_sits_in() { + let payload = fixture(); + let census = Census::new(&payload); + // A method sits in a class, and the class in a module. + assert_eq!( + census.container_of("c-method"), + Some(json!({ + "id": "b-class", "name": "Signer", "kind": "class", + "within": {"id": "a-module", "name": "signer", "kind": "module"}, + })) + ); + // A module here sits in nothing, so its members' container says + // nothing more. + assert_eq!( + census.container_of("b-class"), + Some(json!({"id": "a-module", "name": "signer", "kind": "module"})) + ); + + // A ranked result carries the same, read from its container's own + // answer and never from where anything is kept. + let owners = |id: &str, name: &str, kind: &str| json!([{"entity_id": id, "name": name, "kind": kind, "relationship": "Contains"}]); + let sources: HashMap = [(SEND, HOOK), (REDIRECT, HOOK_END)] + .into_iter() + .map(|(id, class)| { + let source = json!({ + "name": "MultipartError.status", "kind": "Method", + "module": "extract.multipart", + "semantic_owners": owners(class, "MultipartError", "class"), + "file_path": "held/for/grouping/only.rs", + }); + (id.to_string(), source) + }) + .collect(); + let outer: HashMap = [(HOOK, "multipart"), (HOOK_END, "multipart_extra")] + .into_iter() + .map(|(class, module)| { + let within = owner_ref(&owners(OTHER, module, "Module")).unwrap(); + (class.to_string(), within) + }) + .collect(); + let rows: Vec = [SEND, REDIRECT] + .into_iter() + .map(|id| { + let hit = hit(id, "MultipartError.status", true, None); + hit_json(&hit, sources.get(id), &outer) + }) + .collect(); + assert_eq!(rows[0]["container"]["name"], rows[1]["container"]["name"]); + assert_eq!( + rows[0]["container"]["within"], + json!({"id": OTHER, "name": "multipart", "kind": "module"}) + ); + assert_eq!(rows[1]["container"]["within"]["name"], "multipart_extra"); + // A container whose own container is not known says nothing of it. + let alone = hit_json( + &hit(SEND, "MultipartError.status", true, None), + sources.get(SEND), + &HashMap::new(), + ); + assert!(alone["container"].get("within").is_none()); + assert!(!json!(rows).to_string().contains("held/for/grouping")); + } + /// Two containers can share a name and a kind, as a JavaScript module and /// the TypeScript one beside it do, and no answer says where either is. /// Each row carries its language so a page can tell them apart, and null @@ -2660,10 +3828,17 @@ mod tests { assert_eq!(relied_on["excluded_without_definition"], 1); assert_eq!(relied_on["items"][0]["name"], "want_bytes"); assert_eq!(relied_on["items"][0]["container"]["name"], "encoding"); + let mut with_untested = inventory.clone(); + with_untested["untested"] = json!({"total": 1, "entities": [ + {"id": "f-function", "name": "want_bytes", "kind": "Function", + "file_path": "held/for/grouping/only.py", "degree": 3, "dependents": 2}, + ]}); + let untested = overview_untested(&with_untested, Some(&census)).unwrap(); for answer in [ Value::Object(overview_counts(&payload)), Value::Object(overview_containers(&census, 10)), Value::Object(relied_on), + Value::Object(untested), build_map(&payload, MAP_NODE_CAP, MAP_EDGE_CAP), ] { let text = answer.to_string(); @@ -2675,6 +3850,55 @@ mod tests { } } + /// What no test reaches is the daemon's own reading, shaped for the page: + /// each entity by id, name, kind, container and how many depend on it, + /// with the whole count and what the list means. A daemon that does not + /// report the reading is a gap said in one sentence, never a guessed + /// list. + #[test] + fn the_untested_section_is_the_daemons_reading_or_its_gap_in_one_sentence() { + let payload = fixture(); + let census = Census::new(&payload); + let inventory = json!({"total": 2, "entities": [], "untested": {"total": 7, "entities": [ + {"id": "f-function", "name": "want_bytes", "kind": "Function", + "file_path": "held/for/grouping/only.py", "degree": 3, "dependents": 2}, + ]}}); + let read = untested_section(overview_untested(&inventory, Some(&census))); + assert_eq!(read["ok"], true); + assert_eq!(read["total"], 7); + assert_eq!( + read["items"], + json!([{ + "id": "f-function", "name": "want_bytes", "kind": "function", + "container": census.container_of("f-function"), "dependents": 2, + }]) + ); + assert_eq!(read["items"][0]["container"]["name"], "encoding"); + let basis = read["basis"].as_str().unwrap(); + assert!( + basis.contains("no recorded test") && basis.contains("whole repository"), + "{basis}" + ); + + // An older daemon answers the ranking without the reading. + let older = json!({"total": 2, "entities": []}); + let gap = untested_section(overview_untested(&older, Some(&census))); + assert_eq!(gap["ok"], false); + assert!(gap.get("items").is_none() && gap.get("total").is_none()); + let reason = gap["reason"].as_str().unwrap(); + assert!(reason.starts_with("Kin could not read which entities no test reaches: ")); + assert_eq!(reason.matches(". ").count(), 0, "one sentence: {reason}"); + assert!(reason.ends_with('.'), "{reason}"); + assert!(!reason.contains('`'), "no command to run: {reason}"); + + // A refusal that ends its own sentence still reads as one. + let refused = untested_section(Err(anyhow::anyhow!("the daemon did not answer in time."))); + assert_eq!( + refused["reason"], + "Kin could not read which entities no test reaches: the daemon did not answer in time." + ); + } + #[test] fn the_map_adds_up_relationships_between_containers_and_reports_true_totals() { let payload = fixture(); @@ -2772,6 +3996,41 @@ mod tests { assert_eq!(ask("verify", Some(0)).unwrap().1, 1); } + #[test] + fn a_primary_call_preserves_every_recorded_relationship_in_any_order() { + for kinds in [ + ["UsesType", "Calls", "References"], + ["UsesType", "References", "Calls"], + ["References", "UsesType", "Calls"], + ["References", "Calls", "UsesType"], + ["Calls", "References", "UsesType"], + ["Calls", "UsesType", "References"], + ] { + let incoming = json!({ + "truncated": false, + "entities": [ + {"id": "focal", "name": "normalizeRequestId", "kind": "Function"}, + {"id": "caller", "name": "buildOptionalHeaderReqId", "kind": "Function"}, + ], + "relations": kinds.iter().map(|kind| json!({ + "direction": "incoming", "kind": kind, "resolution": "type_resolved", + "src": {"Entity": "caller"}, "dst": {"Entity": "focal"}, + })).collect::>(), + }); + let section = + relied_on_by_section("focal", Some(&incoming), None, &HashMap::new()).unwrap(); + assert_eq!(section["total"], 1); + assert_eq!(section["confirmed"], 1); + let caller = §ion["items"][0]; + assert_eq!(caller["relationship"], "calls"); + assert_eq!( + caller["relationships"], + json!(kinds.map(kind_label)), + "the primary label must not discard or reorder the graph's facts" + ); + } + } + #[test] fn who_relies_on_an_entity_reads_one_row_per_entity_with_its_sites() { let answer = json!({ @@ -2840,7 +4099,7 @@ mod tests { "2 of them match by name alone, and Kin could not prove they use this one." )); let serializer = §ion["items"][0]; - assert_eq!(serializer["relationship"], "references"); + assert_eq!(serializer["relationship"], "calls"); assert_eq!(serializer["relationships"], json!(["references", "calls"])); assert_eq!(serializer["sites"], 4); assert_eq!(serializer["container"]["name"], "serializer"); @@ -2866,6 +4125,66 @@ mod tests { assert!(relied_on_by_section("focal", None, None, &HashMap::new()).is_err()); } + /// fastify's `serialize`, as the entity page read it while Kin could not + /// read its references: three callers proven and one test matched by + /// name alone. The notice said "sites and matches by name are missing" + /// and then "1 of them matches by name alone". It now says only what the + /// reference read would have added, and the match by name alone is still + /// said to be one. + #[test] + fn without_its_references_the_notice_says_only_what_the_references_add() { + let incoming = json!({ + "truncated": false, + "entities": [ + {"id": "focal", "name": "serialize", "kind": "Function"}, + {"id": "r1", "name": "Reply.serialize", "kind": "Method"}, + {"id": "t1", "name": "reply-serialize.test", "kind": "Module", "role": "test"}, + ], + "relations": [ + {"direction": "incoming", "kind": "Calls", "resolution": "type_resolved", + "src": {"Entity": "r1"}, "dst": {"Entity": "focal"}}, + {"direction": "incoming", "kind": "Calls", "resolution": "name_only", + "src": {"Entity": "t1"}, "dst": {"Entity": "focal"}}, + ] + }); + let section = + relied_on_by_section("focal", Some(&incoming), None, &HashMap::new()).unwrap(); + assert_eq!(section["total"], 2); + assert_eq!(section["confirmed"], 1); + assert_eq!(section["relies_total"], 1); + assert_eq!( + section["notice"], + "Kin could not read its references, so where each one uses it is not shown, and \ + there could be more. 1 of them matches by name alone, and Kin could not prove it \ + uses this one." + ); + } + + #[test] + fn an_entity_history_names_where_the_record_starts_while_history_is_partial() { + let import = "9dda05f2fa165dc5274f2b047c582df107fd0caf118989243c0c10db08bb4eb6"; + let answer = json!({ + "revisions_total": 1, "unchanged_revisions": 0, "record_start": import, + "revisions": [{"id": import, "title": "docs: clarify the threat model (#6537)", + "author": "Matteo Collina ", + "timestamp": "2026-03-11T10:57:14+00:00"}] + }); + let partial = history_section(&answer, true).unwrap(); + assert_eq!(partial["record_start"], import); + // A store that holds the whole history began where the repository did. + assert!(history_section(&answer, false) + .unwrap() + .get("record_start") + .is_none()); + // Nor is it named when the oldest revision listed is not the start. + let mut later = answer.clone(); + later["revisions_total"] = json!(2); + assert!(history_section(&later, true) + .unwrap() + .get("record_start") + .is_none()); + } + /// The overload declarations of one callable read as one row, kept under /// the declaration with the most sites, never under the running sum. #[test] @@ -2953,6 +4272,40 @@ mod tests { assert!(source_section(Some(&gap), None).is_err()); } + /// The exact read carries the digest of the span it cut, the one a guide + /// step reports, so the page can tell whether a flag set on a recorded + /// change is still about the entity as it is now. A read that is not an + /// exact span carries none, and nothing malformed is passed on. + #[test] + fn the_source_section_carries_the_exact_reads_digest_and_no_other() { + let text = "class Signer:\n pass"; + let digest = kin_blobs::digest(text.as_bytes()).to_string(); + let source = json!({"id": "e", "name": "Signer", "kind": "Class", "language": "python", "body": text}); + let recorded = json!({"body": { + "state": "recorded", "text": text, "start_line": 76, "working": true, + "body_hash": digest, + }}); + let exact = source_section(Some(&recorded), Some(&source)).unwrap(); + assert_eq!(exact["body_hash"], json!(digest)); + + // The plain source read is not an exact span, so it has no digest to + // give, whether the guide's read was absent or refused. + let fallback = source_section(None, Some(&source)).unwrap(); + assert!(fallback.get("body_hash").is_none(), "{fallback:?}"); + let refused = json!({"body": {"state": "no_source", "body_hash": digest}}); + let fallback = source_section(Some(&refused), Some(&source)).unwrap(); + assert!(fallback.get("body_hash").is_none(), "{fallback:?}"); + + // A recorded read with no digest, or one that is not a whole digest, + // passes none on. + for body_hash in [Value::Null, json!("abc123"), json!(42)] { + let mut read = recorded.clone(); + read["body"]["body_hash"] = body_hash; + let section = source_section(Some(&read), Some(&source)).unwrap(); + assert!(section.get("body_hash").is_none(), "{section:?}"); + } + } + #[test] fn a_graph_tool_refusal_reads_as_its_sentence() { let refusal = anyhow::anyhow!( @@ -3016,7 +4369,7 @@ mod tests { "author": "Grace Hopper 1713298162 -0700", "timestamp": "2024-04-16T20:09:22+00:00"}] }); - let section = history_section(&answer).unwrap(); + let section = history_section(&answer, false).unwrap(); assert_eq!(section["items"][0]["author"], "Grace Hopper"); assert_eq!(section["items"][0]["time"], "2024-04-16T20:09:22+00:00"); assert_eq!(section["total"], 3); @@ -3040,6 +4393,346 @@ mod tests { assert_eq!(answered["total"], 0); } + const SEND: &str = "7fe6951b-4071-4e59-a4d8-83ebecc63e4b"; + const HOOK: &str = "4086b425-a85e-4470-9e4f-7207dfdadbe6"; + const HOOK_END: &str = "15ca387d-91f0-4258-8053-ab6e664a38e1"; + const SERIALIZE: &str = "30677f6b-2a16-459e-a1da-ec3a66235429"; + const REDIRECT: &str = "c9620963-28ef-4698-9c11-5982e0e971f2"; + const OTHER: &str = "47460d30-4af2-4faa-888b-191f7804efd7"; + + fn terms(question: &str) -> Vec { + question_terms(question) + } + + fn entity(id: &str, name: &str) -> Link { + Link { + id: id.into(), + name: name.into(), + kind: if name.contains('.') { + "method" + } else { + "function" + } + .into(), + } + } + + /// One entity's incoming relations, as the daemon answers them: each + /// caller with how its call was matched. + fn incoming(callers: &[(&str, &str, &str, &str)], truncated: bool) -> Value { + let entities: Vec = callers + .iter() + .map(|(id, name, kind, _)| { + json!({"id": id, "name": name, "kind": kind, + "signature": "held/for/grouping/only.js"}) + }) + .collect(); + let mut relations: Vec = callers + .iter() + .map(|(id, _, _, resolution)| { + json!({"direction": "incoming", "kind": "Calls", "resolution": resolution, + "src": {"Entity": id}, "dst": {"Entity": SERIALIZE}}) + }) + .collect(); + // What holds the entity, and its own recursion, call nothing into it. + relations.push(json!({"direction": "incoming", "kind": "Contains", + "resolution": "type_resolved", "src": {"Entity": OTHER}, "dst": {"Entity": SERIALIZE}})); + relations.push(json!({"direction": "incoming", "kind": "Calls", + "resolution": "type_resolved", "src": {"Entity": SERIALIZE}, + "dst": {"Entity": SERIALIZE}})); + json!({"truncated": truncated, "entities": entities, "relations": relations}) + } + + /// The calls above `serialize` in a web framework: a method that calls + /// it directly, and the hook chain a reply's send runs through. + fn serialize_climb() -> (Link, Climb) { + let climb = Climb { + above: vec![ + (entity(OTHER, "Reply.serialize"), SERIALIZE.into()), + ( + entity(HOOK_END, "preSerializationHookEnd"), + SERIALIZE.into(), + ), + (entity(HOOK, "preSerializationHook"), HOOK_END.into()), + (entity(SEND, "Reply.send"), HOOK.into()), + (entity(REDIRECT, "Reply.redirect"), SEND.into()), + ], + callers: HashMap::new(), + }; + (entity(SERIALIZE, "serialize"), climb) + } + + #[test] + fn only_a_question_that_asks_how_or_where_is_given_an_answer() { + assert_eq!(asks("how does a reply get serialized"), Some(Asks::How)); + assert_eq!(asks("Where are errors formatted?"), Some(Asks::Where)); + assert_eq!(asks("show me how a route is registered"), Some(Asks::How)); + assert_eq!(asks("how many routes are registered"), None); + assert_eq!(asks("Reply.send"), None); + assert_eq!(asks("what calls serialize"), None); + assert_eq!(asks("list every entity that says where it is used"), None); + } + + #[test] + fn a_question_keeps_the_words_that_could_name_something() { + assert_eq!( + terms("how does a reply get serialized"), + ["repli", "serializ"] + ); + assert_eq!(terms("Where are errors formatted?"), ["error", "format"]); + assert_eq!(terms("how is a 404 handled"), ["404", "handl"]); + assert_eq!(terms("how is a reply sent"), ["repli", "send"]); + assert_eq!( + terms("how does contentTypeParser work"), + ["content", "type", "pars"] + ); + assert!(terms("how does it work").is_empty()); + } + + #[test] + fn the_forms_of_a_word_meet_in_a_name() { + let all = |question: &str, name: &str| { + let terms = terms(question); + matched_terms(name, &terms).len() == terms.len() + }; + assert!(all("serialized replies", "Reply.serializeInput")); + assert!(all("serialization", "preSerializationHookEnd")); + assert!(all("registered", "registerPluginName")); + assert!(all("routing", "fastify..fastify.route")); + assert!(all("formatted errors", "validateSchemaErrorFormatter")); + assert!(all("cookies", "parseCookie")); + assert!(all("HTTP2 server", "createHTTP2Server")); + assert!(!all("serialized", "Reply.send")); + // A short word is matched whole, never as the start of a longer one. + assert!(!all("id", "identity")); + assert!(!all("run", "runtime")); + } + + #[test] + fn what_calls_an_entity_is_read_from_its_incoming_relations() { + let read = callers_of( + &incoming( + &[ + ( + HOOK_END, + "preSerializationHookEnd", + "Function", + "type_resolved", + ), + (OTHER, "Reply.serialize", "Method", "type_resolved"), + // Two call sites in one caller are one caller. + (OTHER, "Reply.serialize", "Method", "type_resolved"), + // A call matched by name alone, and a caller that is no + // function, call it but are no hop of a chain. + (SEND, "handleRequest", "Function", "name_only"), + (REDIRECT, "reply.test", "Module", "type_resolved"), + ], + false, + ), + SERIALIZE, + ); + let names: Vec<&str> = read.proven.iter().map(|link| link.name.as_str()).collect(); + assert_eq!(names, ["Reply.serialize", "preSerializationHookEnd"]); + assert_eq!(read.proven[0].kind, "method"); + assert_eq!((read.all, read.cut), (4, false)); + assert_eq!(read.only(), None); + + let one = [(HOOK, "buildRouting", "Function", "type_resolved")]; + assert_eq!( + callers_of(&incoming(&one, false), SERIALIZE).only(), + Some(&entity(HOOK, "buildRouting")) + ); + // One caller of a read that was cut short may not be the only one, + // and one caller matched by name alone is not known to call it. + assert_eq!(callers_of(&incoming(&one, true), SERIALIZE).only(), None); + let guessed = [(HOOK, "buildRouting", "Function", "name_only")]; + assert_eq!( + callers_of(&incoming(&guessed, false), SERIALIZE).only(), + None + ); + assert_eq!( + callers_of(&incoming(&[], false), SERIALIZE), + Callers::default() + ); + } + + #[test] + fn a_chain_begins_and_ends_at_entities_the_question_names() { + let (target, climb) = serialize_climb(); + let asked = terms("how does a reply get serialized"); + let (basis, chain) = chain_from(&target, &climb, &asked, false).expect("a chain"); + assert_eq!(basis, "named_ends"); + let names: Vec<&str> = chain.iter().map(|link| link.name.as_str()).collect(); + // The nearest caller that adds a word the last entity lacks: + // `Reply.serialize` is one call away, which is not a chain, and + // `Reply.redirect` reaches the same place by a longer way. + assert_eq!( + names, + [ + "Reply.send", + "preSerializationHook", + "preSerializationHookEnd", + "serialize" + ] + ); + let answer = answer_json(Asks::How, basis, &chain); + assert_eq!( + answer["sentence"], + "Reply.send calls preSerializationHook, which calls preSerializationHookEnd, which \ + calls serialize." + ); + assert_eq!(answer["asks"], "how"); + assert_eq!( + answer["steps"][0], + json!({ + "id": SEND, "name": "Reply.send", "kind": "method", "relation_to_next": "calls", + }) + ); + assert_eq!(answer["steps"][3]["relation_to_next"], Value::Null); + assert_eq!(answer["steps"][3]["kind"], "function"); + let text = answer.to_string(); + assert!( + !text.contains("held/for/grouping") && !text.contains("file"), + "{text}" + ); + assert_eq!( + answer_json(Asks::Where, basis, &chain)["sentence"], + "This happens in serialize, which Reply.send reaches through preSerializationHook \ + and preSerializationHookEnd." + ); + } + + #[test] + fn a_question_the_names_do_not_carry_is_given_no_chain() { + let (target, climb) = serialize_climb(); + // Nothing on any chain is named for a route. + assert!(chain_from(&target, &climb, &terms("how is a route serialized"), false).is_none()); + // One word names a topic. It ties no chain to a question. + assert!(question_terms("how does serialization work").len() < ANSWER_MIN_TERMS); + // Nothing is known to call the result. + let asked = terms("how does a reply get serialized"); + assert!(chain_from(&target, &Climb::default(), &asked, false).is_none()); + } + + #[test] + fn a_test_is_never_where_a_chain_begins_unless_tests_are_asked_about() { + let (target, mut climb) = serialize_climb(); + // The only caller named for a reply is a test of it. + climb.above[3].0.name = "test_reply_send".into(); + climb.above[4].0.name = "ReplySpec.redirects".into(); + let asked = terms("how does a reply get serialized"); + assert!(chain_from(&target, &climb, &asked, false).is_none()); + assert!(!names_a_test("how does a reply get serialized")); + let about_tests = "how does a reply test get serialized"; + assert!(names_a_test(about_tests)); + assert!(chain_from(&target, &climb, &terms(about_tests), true).is_some()); + assert!(names_a_test("TestTimed.test_max_age") && !names_a_test("attestation")); + } + + /// `compileSchemas` is called only by `buildRouting`, which is called + /// only by `fastify`, which several entities call. + fn sole_climb() -> (Link, Climb) { + let only = |caller: Link| Callers { + proven: vec![caller], + all: 1, + cut: false, + }; + let callers = HashMap::from([ + (SERIALIZE.to_string(), only(entity(HOOK, "buildRouting"))), + (HOOK.to_string(), only(entity(SEND, "fastify"))), + ( + SEND.to_string(), + Callers { + proven: Vec::new(), + all: 3, + cut: false, + }, + ), + ]); + let climb = Climb { + above: vec![ + (entity(HOOK, "buildRouting"), SERIALIZE.into()), + (entity(SEND, "fastify"), HOOK.into()), + ], + callers, + }; + (entity(SERIALIZE, "compileSchemas"), climb) + } + + #[test] + fn an_entity_named_by_the_whole_question_is_reached_through_its_only_callers() { + let asked = terms("where are schemas compiled"); + let (target, climb) = sole_climb(); + let (basis, chain) = chain_from(&target, &climb, &asked, false).expect("a chain"); + assert_eq!(basis, "sole_callers"); + let names: Vec<&str> = chain.iter().map(|link| link.name.as_str()).collect(); + assert_eq!(names, ["fastify", "buildRouting", "compileSchemas"]); + assert_eq!( + answer_json(Asks::Where, basis, &chain)["sentence"], + "This happens in compileSchemas, which fastify reaches through buildRouting." + ); + + // A second caller anywhere on the way leaves a choice, so no chain. + let (_, mut two) = sole_climb(); + two.callers.get_mut(HOOK).unwrap().all = 2; + assert!(chain_from(&target, &two, &asked, false).is_none()); + // So does a caller list that was not read whole. + let (_, mut cut) = sole_climb(); + cut.callers.get_mut(SERIALIZE).unwrap().cut = true; + assert!(chain_from(&target, &cut, &asked, false).is_none()); + // And one call is not a chain. + let (_, mut short) = sole_climb(); + short.callers.remove(HOOK); + assert!(chain_from(&target, &short, &asked, false).is_none()); + } + + #[test] + fn a_long_chain_is_said_in_one_sentence() { + let chain: Vec = [ + "handleRequest", + "handler", + "preValidationCallback", + "validate", + "wrap", + ] + .iter() + .map(|name| Link { + id: SEND.into(), + name: (*name).into(), + kind: "function".into(), + }) + .collect(); + assert_eq!( + answer_sentence(Asks::How, &chain), + "handleRequest reaches wrap through handler, preValidationCallback and validate, \ + each calling the next." + ); + assert_eq!( + answer_sentence(Asks::How, &chain[..3]), + "handleRequest calls handler, which calls preValidationCallback." + ); + } + + #[test] + fn an_answer_ends_only_at_a_ranked_function_or_method_defined_here() { + let mut function = hit(SERIALIZE, "serialize", false, Some(Meaning::Words)); + function.kind = "function".into(); + function.defined_here = true; + let mut outside = function.clone(); + outside.id = OTHER.into(); + outside.defined_here = false; + let mut class = function.clone(); + class.id = SEND.into(); + class.kind = "class".into(); + let mut unrelated = function.clone(); + unrelated.id = HOOK.into(); + unrelated.name = "fallbackErrorHandler".into(); + let hits = [outside, class, unrelated, function]; + let seeds = answer_seeds(&hits, &terms("how does a reply get serialized")); + assert_eq!(seeds.len(), 1); + assert_eq!(seeds[0].id, SERIALIZE); + } + /// The explore routes refuse a malformed request before resolving a /// daemon, so these never start or reach one. #[tokio::test] diff --git a/crates/kin-cli/src/commands/workspace_flags.rs b/crates/kin-cli/src/commands/workspace_flags.rs new file mode 100644 index 000000000..a979c35f8 --- /dev/null +++ b/crates/kin-cli/src/commands/workspace_flags.rs @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Review flags, as the workspace page sets and reads them. +//! +//! A reader walking uncommitted work flags an entity with a note. These routes +//! record that in the repository, through the daemon that holds it, so the +//! agent that made the change is told the next time an answer from Kin +//! returns the entity, and so the page can show which agents have been told. +//! See [`super::review_flags`]. +//! +//! A flag is signed as the page's own reader, never as a name the request +//! carries. It is advisory: no route here stops a commit. + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::review_flags::{ + ReviewFlagOutcome, ReviewFlagRequest, ReviewFlagResponse, ReviewFlagView, +}; +use super::review_ui::{self, UiState}; + +/// The schema of the answer that lists a repository's open flags. +const FLAGS_SCHEMA: &str = "kin.workspace.flags.v1"; + +pub(super) fn routes() -> Router> { + Router::new() + .route("/api/workspace/flags", get(serve_flags).post(set_flag)) + .route("/api/workspace/flags/resolve", post(resolve_flag)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FlagBody { + entity: String, + #[serde(default)] + note: Option, + fingerprint: String, + operation_id: kin_model::OperationId, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ResolveBody { + entity: String, + operation_id: kin_model::OperationId, +} + +/// One flag as the page reads it: the daemon's view, with each agent that saw +/// it under the key the page knows that agent's lane by. +fn flag_json(flag: &ReviewFlagView) -> Value { + let mut shown = serde_json::to_value(flag).unwrap_or_default(); + if let Some(seen_by) = shown.get_mut("seen_by").and_then(Value::as_array_mut) { + for seen in seen_by { + let key = seen + .get("session") + .and_then(Value::as_str) + .map(super::workspace_agents::agent_key); + if let Some(key) = key { + seen["key"] = json!(key); + } + } + } + shown +} + +/// Nothing was recorded: the request stopped before the daemon was asked, or +/// the daemon said why it would not record it. +fn refused(operation_id: kin_model::OperationId, reason: &str) -> Response { + ( + StatusCode::CONFLICT, + Json(json!({ + "error": reason, "outcome": "refused", "operation_id": operation_id, + "remedy": "Nothing was recorded. Correct what is reported, then send it again.", + })), + ) + .into_response() +} + +/// The daemon's answer was lost, so this request cannot say how it ended. The +/// same request sent again records at most one flag. +fn unknown(operation_id: kin_model::OperationId, error: &anyhow::Error) -> Response { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ + "error": format!("{error:#}"), "outcome": "unknown", "operation_id": operation_id, + "remedy": "This may have been recorded. Read the flags again, or send the same \ + request again: one operation is recorded once.", + })), + ) + .into_response() +} + +fn entity_id(text: &str) -> Option { + uuid::Uuid::parse_str(text.trim()) + .ok() + .map(kin_model::EntityId) +} + +/// Send one write to the daemon and answer the page with how it ended. +async fn write( + state: &UiState, + operation_id: kin_model::OperationId, + request: ReviewFlagRequest, +) -> Response { + let answered = async { review_ui::daemon(state).await?.review_flags(&request).await }.await; + let response: ReviewFlagResponse = match answered { + Ok(response) => response, + Err(error) => return unknown(operation_id, &error), + }; + let outcome = match response.outcome { + ReviewFlagOutcome::Refused => { + return refused( + operation_id, + response + .error + .as_deref() + .unwrap_or("The repository did not record this."), + ) + } + ReviewFlagOutcome::Flagged => "flagged", + ReviewFlagOutcome::Resolved => "resolved", + ReviewFlagOutcome::NothingOpen => "nothing_open", + ReviewFlagOutcome::Listed => "listed", + }; + let mut answer = json!({ "operation_id": operation_id, "outcome": outcome }); + if let Some(flag) = &response.flag { + answer["flag"] = flag_json(flag); + } + Json(answer).into_response() +} + +/// Flag an entity at the version the reader saw, with an optional note. +async fn set_flag( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !review_ui::may_write(&headers, &state) { + return review_ui::write_refused(); + } + let operation_id = body.operation_id; + let Some(entity) = entity_id(&body.entity) else { + return refused(operation_id, "A flag is set on an entity, named by its id."); + }; + write( + &state, + operation_id, + ReviewFlagRequest::Set { + entity, + note: body.note, + fingerprint: body.fingerprint, + operation_id, + // Signed as this page's own reader, resolved for this URL's + // repository, never as a caller-supplied name. + author: state.actor.clone(), + }, + ) + .await +} + +/// Close the open flag on an entity. +async fn resolve_flag( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !review_ui::may_write(&headers, &state) { + return review_ui::write_refused(); + } + let operation_id = body.operation_id; + let Some(entity) = entity_id(&body.entity) else { + return refused( + operation_id, + "A flag is closed on an entity, named by its id.", + ); + }; + write( + &state, + operation_id, + ReviewFlagRequest::Resolve { + entity, + operation_id, + author: state.actor.clone(), + }, + ) + .await +} + +/// Every open flag in this repository, oldest first. +async fn serve_flags(State(state): State>) -> Response { + let answered = async { + review_ui::daemon(&state) + .await? + .review_flags(&ReviewFlagRequest::List) + .await + } + .await; + match answered { + Ok(response) => Json(json!({ + "schema": FLAGS_SCHEMA, + "flags": response.flags.iter().map(flag_json).collect::>(), + })) + .into_response(), + Err(error) => review_ui::refusal(StatusCode::BAD_GATEWAY, &error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kin_model::Timestamp; + + #[test] + fn a_flag_names_each_agent_that_saw_it_by_the_key_of_its_lane() { + let session = uuid::Uuid::new_v4().to_string(); + let flag = ReviewFlagView { + flag_id: uuid::Uuid::new_v4().to_string(), + entity_id: uuid::Uuid::new_v4().to_string(), + name: "ServerReply.send".into(), + kind: Some("method".into()), + note: "a second send must fail loudly".into(), + author: "Ada Reviewer".into(), + set_at: Timestamp::now(), + fingerprint: "ab".repeat(32), + changed_since: false, + removed: false, + source_unavailable: false, + seen_by: vec![super::super::review_flags::ReviewFlagSeen { + agent: "Codex".into(), + session: session.clone(), + at: Timestamp::now(), + }], + }; + let shown = flag_json(&flag); + assert_eq!(shown["name"], "ServerReply.send"); + assert_eq!(shown["changed_since"], false); + assert!(shown.get("removed").is_none()); + let seen = &shown["seen_by"][0]; + assert_eq!(seen["agent"], "Codex"); + assert_eq!(seen["session"], json!(session)); + assert_eq!( + seen["key"], + json!(super::super::workspace_agents::agent_key(&session)) + ); + assert!(seen["at"].is_string()); + } + + #[test] + fn an_entity_is_named_by_its_id_and_nothing_else() { + let id = uuid::Uuid::new_v4(); + assert_eq!(entity_id(&format!(" {id} ")), Some(kin_model::EntityId(id))); + assert_eq!(entity_id("ServerReply.send"), None); + assert_eq!(entity_id("src/reply.ts"), None); + } +} diff --git a/crates/kin-cli/src/commands/workspace_git_remote.rs b/crates/kin-cli/src/commands/workspace_git_remote.rs new file mode 100644 index 000000000..2c4461bf9 --- /dev/null +++ b/crates/kin-cli/src/commands/workspace_git_remote.rs @@ -0,0 +1,1400 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Publishing a branch to a Git remote from the local app, and reading where +//! the branch stands against it. +//! +//! Git is an interoperability projection, never an authority. A publish runs +//! the same exact projection `kin git export` writes, into a staged +//! repository outside the project, takes the branch's commits from there into +//! the project's own Git repository, and lets Git push them with the person's +//! own credentials. A commit Kin took from Git is its original object, and a +//! change made in Kin is the one commit the projection always writes for it, +//! so a second publish extends the first. Nothing is ever forced: a remote +//! branch that moved is refused. +//! +//! Updates admit a fetched branch only when its current Kin head is still +//! an exact imported Git commit. Descendants of published native changes need +//! an additional verified export binding and are refused until it exists. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::http::StatusCode; +use kin_model::{GitObjectId, RefName, SemanticChangeId}; +use serde_json::{json, Value}; + +use super::review_ui::UiState; +use super::workspace_remotes::{ + entity_changes, local_range, local_range_reading, not_read, not_sent, overall, plural, refused, + short_ref, since_git, unknown, Act, Ended, LocalBranch, Remote, Side, +}; +use crate::daemon_client::DaemonClient; + +/// The unsupported lineage, kept explicit at the local app boundary. +pub(super) const GIT_PULL_UNAVAILABLE: &str = "This branch contains Kin-native changes. Kin can \ + update Git-origin histories, but cannot yet admit Git commits descended from a published \ + native change."; + +/// How long the repository's daemon is given to say whether this branch can +/// update from Git. Nothing a person waits on waits for it: a session answers +/// from what is known ([`update_ready_known`]), and the page asks for the +/// answer on its own route. +const UPDATE_STATUS_BUDGET: Duration = Duration::from_secs(30); + +/// Whether persisted local authority can accept an exact Git update, asked of +/// the repository's daemon now. A caller that finds an ask running joins it +/// rather than asking again. No remote is asked and no daemon is started for +/// this observation. +pub(super) async fn update_ready(state: &UiState) -> Result<(), String> { + ready(state).await?; + match state.transfers.ask_update() { + super::workspace_remotes::UpdateAsk::Join(mut asking) => asking + .wait_for(Option::is_some) + .await + .ok() + .and_then(|answer| answer.clone()) + .unwrap_or_else(|| Err(UPDATE_UNANSWERED.to_string())), + super::workspace_remotes::UpdateAsk::Lead(sender) => { + let answer = ask_update_status(state).await; + state.transfers.answer_update(sender, answer.clone()); + answer + } + } +} + +/// What a session says about updating this branch from Git without waiting +/// for the repository's daemon, given what Git itself said (`git`): the +/// daemon's last answer while it is current ([`super::workspace_remotes::UPDATE_READY_KEPT`]), +/// or `None` while no current answer is known, with an ask for one running on +/// its own. An old answer is not repeated as current: the branch or the +/// working copy may have moved since. +/// +/// Measured on a busy axum and zod, the daemon took more than the five +/// seconds a session gave it to say, so every page began with ten blank +/// seconds and then said it did not answer. A pull itself still asks first +/// ([`update`]). +pub(super) fn update_ready_known( + state: &Arc, + git: &Result<(), String>, +) -> Option> { + if let Err(reason) = git { + return Some(Err(reason.clone())); + } + let current = state + .transfers + .update_ready() + .filter(|(age, _)| *age < super::workspace_remotes::UPDATE_READY_KEPT) + .map(|(_, answer)| answer); + if current.is_none() && !state.transfers.update_asking() { + let asking = Arc::clone(state); + tokio::spawn(async move { + let _ = update_ready(&asking).await; + }); + } + current +} + +/// Whether this branch can update from Git, from a fresh answer, or from the +/// ask that is running or this starts, waited for within `limit`. An ask +/// that outlasts the limit runs on, and its answer is kept for the next read. +pub(super) async fn update_ready_within( + state: &Arc, + limit: Duration, +) -> Result<(), String> { + if let Some((age, answer)) = state.transfers.update_ready() { + if age < super::workspace_remotes::UPDATE_READY_KEPT { + return answer; + } + } + let asking = Arc::clone(state); + let ask = tokio::spawn(async move { update_ready(&asking).await }); + tokio::time::timeout(limit, ask) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_else(|| Err(UPDATE_UNANSWERED.to_string())) +} + +const UPDATE_UNANSWERED: &str = + "This repository's daemon did not answer its Git update eligibility."; + +/// Ask the repository's daemon, within [`UPDATE_STATUS_BUDGET`], whether +/// this branch can update from Git. +async fn ask_update_status(state: &UiState) -> Result<(), String> { + #[cfg(test)] + pause_update_status_for_test(state).await; + let (_, port) = crate::daemon_client::repo_daemon_recorded_endpoint(state.layout.root()); + let Some(port) = port.filter(|port| *port != 0) else { + return Err( + "Open repository content to read whether this branch can update from Git.".to_string(), + ); + }; + let reading = tokio::time::timeout(UPDATE_STATUS_BUDGET, async { + let client = + DaemonClient::from_running_workspace(format!("http://127.0.0.1:{port}"), &state.layout) + .await?; + client.git_update_status().await + }) + .await; + match reading { + Ok(Ok(status)) if status.can_update => Ok(()), + Ok(Ok(status)) => Err(update_reason( + status.reason.as_deref().unwrap_or(GIT_PULL_UNAVAILABLE), + )), + _ => Err(UPDATE_UNANSWERED.to_string()), + } +} + +/// How long an ask of the daemon about updating from Git takes, for the one +/// server state a test names, so a test can make the daemon slow to say. +/// Keyed by the state's address, so no other test's ask waits on it. +#[cfg(test)] +pub(super) static UPDATE_STATUS_PAUSE_FOR_TEST: std::sync::Mutex> = + std::sync::Mutex::new(None); + +#[cfg(test)] +async fn pause_update_status_for_test(state: &UiState) { + let owner = state as *const UiState as usize; + let named = *UPDATE_STATUS_PAUSE_FOR_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((named_state, pause)) = named { + if named_state == owner { + tokio::time::sleep(pause).await; + } + } +} + +/// Protocol refusals are diagnostics. The page states the precondition a +/// person can change without printing local locations or object identities. +fn update_reason(reason: &str) -> String { + if reason.contains("native Kin changes") { + return GIT_PULL_UNAVAILABLE.to_string(); + } + if reason.contains("does not descend") || reason.contains("fast-forward") { + return "This branch and the fetched Git branch have diverged. Updating would replace recorded work, so nothing was admitted.".to_string(); + } + if reason.contains("graph-owned changes") + || reason.contains("workspace projection") + || reason.contains("working projection") + || reason.contains("working copy") + { + return "This workspace holds uncommitted changes. Commit or preserve them before updating from Git.".to_string(); + } + if reason.contains("moved") || reason.contains("stale") { + return "This branch changed after the Git update was selected. Read its standing again before updating.".to_string(); + } + if reason.contains("born branch") || reason.contains("current branch") { + return "A Git update needs a current branch with a recorded imported Git commit." + .to_string(); + } + "Kin could not verify the fetched Git history against this branch's recorded source. Nothing was admitted.".to_string() +} + +/// How long a Git remote is given to say what it holds. +const REMOTE_BUDGET: Duration = Duration::from_secs(15); +/// How long Git is given for a question about the project's own repository. +const LOCAL_BUDGET: Duration = Duration::from_secs(60); +/// How long the projection and its hand-over to the project's repository are +/// given. Both walk the whole history. +const PROJECTION_BUDGET: Duration = Duration::from_secs(30 * 60); +/// How long Git is given to carry a branch to its remote. +const PUSH_BUDGET: Duration = Duration::from_secs(10 * 60); +/// Set on every Git child: never stop to ask at a terminal nobody is at, and +/// answer in the words the refusals below are read by. +const GIT_ENVIRONMENT: &[(&str, &str)] = &[("GIT_TERMINAL_PROMPT", "0"), ("LC_ALL", "C")]; + +/// What one child printed and how it ended. +#[derive(Debug, Default)] +struct Ran { + code: Option, + out: Vec, + err: Vec, +} + +impl Ran { + fn succeeded(&self) -> bool { + self.code == Some(0) + } + + fn first(&self) -> Option<&str> { + self.out.first().map(|line| line.trim()) + } + + fn said(&self, words: &str) -> bool { + self.out + .iter() + .chain(&self.err) + .any(|line| line.contains(words)) + } +} + +/// Why a child gave no answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Stopped { + /// The program is not on this machine. + Missing, + TimedOut, + Failed, +} + +/// Run `program` in `directory` to its end, keeping what it printed. Its +/// arguments are handed over one by one, never as a line a shell would read. +async fn run( + program: &OsStr, + directory: &Path, + arguments: &[&str], + budget: Duration, +) -> Result { + let out = Mutex::new(Vec::new()); + let err = Mutex::new(Vec::new()); + let ended = { + let keep = |errors: bool, line: &str| { + let lines = if errors { &err } else { &out }; + let mut lines = lines + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if lines.len() < 2000 { + lines.push(line.to_string()); + } + }; + let arguments: Vec = arguments.iter().map(OsString::from).collect(); + tokio::time::timeout( + budget, + super::workspace_home::run_child( + program, + &arguments, + Some(directory), + false, + GIT_ENVIRONMENT, + &keep, + ), + ) + .await + }; + let code = match ended { + Err(_) => return Err(Stopped::TimedOut), + Ok(Err(error)) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(Stopped::Missing) + } + Ok(Err(_)) => return Err(Stopped::Failed), + Ok(Ok(code)) => code, + }; + let lines = |kept: Mutex>| { + kept.into_inner() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }; + Ok(Ran { + code, + out: lines(out), + err: lines(err), + }) +} + +async fn git(directory: &Path, arguments: &[&str], budget: Duration) -> Result { + run(OsStr::new("git"), directory, arguments, budget).await +} + +/// Whether Git answered `arguments` with success. +async fn git_says_yes(directory: &Path, arguments: &[&str]) -> bool { + git(directory, arguments, LOCAL_BUDGET) + .await + .is_ok_and(|ran| ran.succeeded()) +} + +fn is_object_id(text: &str) -> bool { + matches!(text.len(), 40 | 64) && text.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// Whether Git can carry a branch for the project at `directory`: Git is on +/// this machine, and the project has a Git repository of its own. +async fn probe(directory: &Path) -> Result<(), String> { + match git(directory, &["rev-parse", "--show-prefix"], LOCAL_BUDGET).await { + Err(Stopped::Missing) => Err( + "Git is not on this machine, so Kin cannot publish to a Git remote from here." + .to_string(), + ), + Err(_) => Err("Git did not answer, so Kin cannot publish to a Git remote.".to_string()), + // At the top of its own repository Git names no prefix. + Ok(ran) if ran.succeeded() && ran.first().is_none_or(str::is_empty) => Ok(()), + Ok(_) => Err( + "This project has no Git repository of its own, so there is nothing to publish \ + through." + .to_string(), + ), + } +} + +/// [`probe`], asked at most once in a while for one repository. +pub(super) async fn ready(state: &UiState) -> Result<(), String> { + if let Some(known) = state.transfers.git_ready() { + return known; + } + let answer = probe(state.layout.working_dir()).await; + state.transfers.remember_git_ready(answer.clone()); + answer +} + +/// Why Git could not ask a remote what it holds, in words. +fn reach_reason(ran: &Ran, remote: &str) -> String { + if ran.said("Authentication failed") + || ran.said("Permission denied") + || ran.said("could not read Username") + || ran.said("terminal prompts disabled") + { + return format!("{remote} did not accept the Git credentials on this machine."); + } + if ran.said("does not appear to be a git repository") || ran.said("No such remote") { + return format!("The project's own Git repository cannot reach a remote named {remote}."); + } + format!("Git could not reach {remote}.") +} + +/// The single endpoint Git would publish to for this remote. A separate +/// fetch URL is not evidence about the destination of a push, and several +/// push URLs cannot produce one unambiguous receipt. +async fn publish_target(directory: &Path, remote: &str) -> Result { + match git(directory, &["remote", "get-url", "--push", "--all", "--", remote], LOCAL_BUDGET).await { + Ok(ran) if ran.succeeded() => match ran.out.as_slice() { + [only] if !only.trim().is_empty() => Ok(only.trim().to_string()), + _ => Err(format!("{remote} names several Git publish destinations, so Kin cannot publish it as one operation.")), + }, + _ => Err(format!("The project's own Git repository has no publish destination named {remote}.")), + } +} + +/// The endpoint Git fetches from for a named remote, independent of any +/// configured publish destination. +async fn fetch_target(directory: &Path, remote: &str) -> Result { + match git( + directory, + &["remote", "get-url", "--", remote], + LOCAL_BUDGET, + ) + .await + { + Ok(ran) if ran.succeeded() => ran + .first() + .map(str::to_string) + .filter(|url| !url.is_empty()) + .ok_or_else(|| format!("{remote} names no Git fetch destination.")), + _ => Err(format!( + "The project's own Git repository has no fetch destination named {remote}." + )), + } +} + +/// The commit `remote` holds for the branch `name` now, asked of the remote +/// through the project's own Git repository. `Ok(None)` is a remote without +/// the branch. +async fn remote_head( + directory: &Path, + remote: &str, + target: &str, + name: &str, +) -> Result, String> { + let wanted = format!("refs/heads/{name}"); + match git( + directory, + &["ls-remote", "--heads", "--", target, &wanted], + REMOTE_BUDGET, + ) + .await + { + Ok(ran) if ran.succeeded() => Ok(ran.out.iter().find_map(|line| { + let (object, named) = line.split_once('\t')?; + (named.trim() == wanted && is_object_id(object)).then(|| object.to_string()) + })), + Ok(ran) => Err(reach_reason(&ran, remote)), + Err(Stopped::TimedOut) => Err(format!("{remote} did not answer Git in time.")), + Err(Stopped::Missing) => Err("Git is not on this machine.".to_string()), + Err(Stopped::Failed) => Err("Git could not be started.".to_string()), + } +} + +/// The change a commit Kin wrote names in its own header, read from the +/// commit's printed form. The header is Kin's marker on a commit it projected; +/// it is read to count. Before sending, the staged export's marked head must +/// match the selected semantic head. +fn marked_change(commit: &[String]) -> Option { + let committer = commit + .iter() + .position(|line| line.starts_with("committer "))?; + let marked = commit.get(committer + 1)?.strip_prefix("kin-change-id ")?; + super::ref_grammar::parse_change_id(marked.trim()).ok() +} + +/// What Kin holds for a commit a remote names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Held { + /// The change this commit is. + Change(SemanticChangeId), + /// The project's own Git repository does not have the commit. + NotHere, + /// The commit is here and Kin holds no change for it. + NoChange, +} + +/// The change behind the commit `object`: the one Kin took it in as, or the +/// one Kin wrote it out for. +async fn change_for(directory: &Path, client: &DaemonClient, object: &str) -> Held { + if !is_object_id(object) { + return Held::NotHere; + } + let commit = match git(directory, &["cat-file", "commit", object], LOCAL_BUDGET).await { + Ok(ran) if ran.succeeded() => ran, + _ => return Held::NotHere, + }; + // Resolve the exact imported-object alias in repository authority. A + // bounded history scan cannot prove an older object was never admitted. + let selector = format!("git:{object}"); + if let Some(change) = client + .diff(&super::diff::DiffRequest { + base: Some(selector.clone()), + head: Some(selector), + json: true, + full_bodies: false, + }) + .await + .ok() + .and_then(|answer| answer.report) + .and_then(|report| report.head.change_id) + { + return Held::Change(change); + } + if let Some(change) = marked_change(&commit.out) { + return Held::Change(change); + } + Held::NoChange +} + +fn unknown_commits(remote: &str) -> String { + format!("{remote} holds Git commits this repository has not admitted. Kin cannot publish over them.") +} + +/// Where the current branch stands against one Git remote: what a publish +/// would send, in changes and entities. Moves nothing. +pub(super) async fn standing( + state: &UiState, + client: &DaemonClient, + local: &LocalBranch, + branch: &RefName, + remote: &Remote, + ready: &Result<(), String>, + update: &Result<(), String>, +) -> Value { + let since = since_git(client, local, branch) + .await + .unwrap_or(Value::Null); + let unread = + |reason: String| { + let mut standing = not_read(reason); + standing["pull"] = + Side::new("blocked") + .because(update.as_ref().err().cloned().unwrap_or_else(|| { + "Kin could not read this remote's Git history.".to_string() + })) + .json(); + standing["since_git"] = since.clone(); + standing + }; + if let Err(reason) = ready { + return unread(reason.clone()); + } + let directory = state.layout.working_dir(); + let name = short_ref(branch); + let Some(head) = local.head_of(branch) else { + return unread("This repository holds nothing on this branch yet.".to_string()); + }; + let target = match publish_target(directory, &remote.name).await { + Ok(target) => target, + Err(reason) => return unread(reason), + }; + let there = match remote_head(directory, &remote.name, &target, &name).await { + Ok(there) => there, + Err(reason) => return unread(reason), + }; + let push = match &there { + None => Side::new("would_send").because(format!( + "{} has no branch named {name} yet, so a publish creates it.", + remote.name + )), + Some(object) => match change_for(directory, client, object).await { + Held::Change(on_remote) if on_remote == head => Side::new("nothing_to_send"), + Held::Change(on_remote) => { + match local_range_reading(client, &local.repository_id, on_remote, head).await { + Ok(count) if count.behind == 0 => { + let mut side = Side::new("would_send"); + side.changes = Some(count.ahead); + side.entities = Some(count); + side + } + Ok(_) | Err(None) => Side::new("blocked").because(format!( + "{} and this repository each hold changes the other does not, and \ + Git never overwrites a branch that moved.", + remote.name + )), + Err(Some(reason)) => Side::new("unknown").because(reason), + } + } + Held::NotHere | Held::NoChange => { + Side::new("blocked").because(unknown_commits(&remote.name)) + } + }, + }; + // A remote may fetch from one repository and publish to another. Its + // publish reading never proves where an update from its fetch endpoint + // stands. + let fetch_head = match fetch_target(directory, &remote.name).await { + Ok(fetch_target) => remote_head(directory, &remote.name, &fetch_target, &name).await, + Err(reason) => Err(reason), + }; + let pull = match &fetch_head { + Ok(None) => Side::new("nothing_to_bring"), + Ok(Some(object)) => match change_for(directory, client, object).await { + Held::Change(remote_change) if remote_change == head => Side::new("nothing_to_bring"), + Held::Change(remote_change) => match local_range_reading(client, &local.repository_id, remote_change, head).await { + Ok(count) if count.behind == 0 => Side::new("nothing_to_bring"), + Ok(count) if count.ahead == 0 && update.is_ok() => { + let mut side = Side::new("would_bring"); + side.changes = Some(count.behind); + side.entities = local_range(client, &local.repository_id, head, remote_change).await; + side + }, + Ok(_) => Side::new("blocked").because(update.as_ref().err().cloned().unwrap_or_else(|| "This branch and the fetched Git branch have diverged. An update moves only a fast-forward.".to_string())), + Err(Some(reason)) => Side::new("unknown").because(reason), + Err(None) => Side::new("unknown").because("Kin could not resolve the fetched branch's recorded history."), + }, + Held::NotHere | Held::NoChange => match update { + Ok(()) => Side::new("unknown").because("The fetch destination holds Git commits this repository has not admitted. An update verifies their ancestry before moving this branch."), + Err(reason) => Side::new("blocked").because(reason.clone()), + }, + }, + Err(reason) => Side::new("unknown").because(reason.clone()), + }; + let state = overall(&push, &pull); + let summary = match (state, push.changes, push.entities) { + ("up_to_date", _, _) => format!("This branch matches {}.", remote.name), + ("ahead", Some(changes), Some(count)) => format!( + "{changes} {} touching {} {} {} not on {} yet.", + plural(changes, "change", "changes"), + count.entities, + plural(count.entities, "entity", "entities"), + plural(changes, "is", "are"), + remote.name + ), + _ => push + .reason + .clone() + .or_else(|| pull.reason.clone()) + .unwrap_or_else(|| { + format!( + "Kin could not read where this branch stands on {}.", + remote.name + ) + }), + }; + json!({ + "read":"complete", + "state":state, + "summary":summary, + "remote_head":there, + "fetch_head":fetch_head.ok().flatten(), + "push":push.json(), + "pull":pull.json(), + "since_git":since, + }) +} + +/// A Git projection of the whole repository, staged outside the project. The +/// directory is removed when this is dropped. +struct Staged { + _directory: tempfile::TempDir, + repository: PathBuf, +} + +/// Write the repository out as Git, exactly as `kin git export` does, by +/// running that command. The projection is one writer, and this is it. +async fn stage_projection(directory: &Path) -> Result { + let failed = || "Kin could not write this repository's changes out as Git commits.".to_string(); + let staging = tempfile::TempDir::new().map_err(|_| failed())?; + let repository = staging.path().join("projection.git"); + let kin = std::env::current_exe().map_err(|_| failed())?; + let output = repository.to_string_lossy().into_owned(); + let ran = run( + kin.as_os_str(), + directory, + &["git", "export", "--output", &output], + PROJECTION_BUDGET, + ) + .await; + match ran { + Ok(ran) if ran.succeeded() => Ok(Staged { + _directory: staging, + repository, + }), + Ok(ran) => { + tracing::debug!(lines = ?ran.err, "the Git projection for a publish failed"); + Err(failed()) + } + Err(_) => Err(failed()), + } +} + +/// The project's own Git branch of the published name, against the commit +/// being published. +#[derive(Debug, Clone, PartialEq, Eq)] +enum OwnBranch { + Absent, + Current, + /// An ancestor of the published commit, at this commit. + Behind(String), + /// It holds commits the published commit does not build on. + Apart, +} + +async fn own_branch(directory: &Path, name: &str, published: &str) -> OwnBranch { + let own = format!("refs/heads/{name}^{{commit}}"); + let at = match git( + directory, + &["rev-parse", "--verify", "--quiet", &own], + LOCAL_BUDGET, + ) + .await + { + Ok(ran) if ran.succeeded() => ran.first().map(str::to_string), + _ => None, + }; + let Some(at) = at else { + return OwnBranch::Absent; + }; + if at == published { + OwnBranch::Current + } else if git_says_yes(directory, &["merge-base", "--is-ancestor", &at, published]).await { + OwnBranch::Behind(at) + } else { + OwnBranch::Apart + } +} + +/// Bring the project's own Git branch to the published commit, and say what +/// became of it: `created`, `moved`, `current` or `left`. A branch that is +/// checked out moves only when Git's own index holds nothing staged, and the +/// index then follows it, so Git reports the working copy against what was +/// published. The working copy itself is never touched. +async fn settle_own_branch( + directory: &Path, + name: &str, + published: &str, + own: &OwnBranch, +) -> &'static str { + let branch = format!("refs/heads/{name}"); + let checked_out = git( + directory, + &["symbolic-ref", "--quiet", "HEAD"], + LOCAL_BUDGET, + ) + .await + .is_ok_and(|ran| ran.succeeded() && ran.first() == Some(branch.as_str())); + match own { + OwnBranch::Current => "current", + OwnBranch::Apart => "left", + OwnBranch::Absent => { + if checked_out && !git_says_yes(directory, &["diff", "--cached", "--quiet"]).await { + return "left"; + } + if !git_says_yes( + directory, + &[ + "update-ref", + &branch, + published, + &"0".repeat(published.len()), + ], + ) + .await + { + return "left"; + } + if checked_out && !git_says_yes(directory, &["read-tree", published]).await { + return "created_index_behind"; + } + "created" + } + OwnBranch::Behind(at) => { + if checked_out + && !git_says_yes(directory, &["diff-index", "--cached", "--quiet", "HEAD"]).await + { + return "left"; + } + if !git_says_yes(directory, &["update-ref", &branch, published, at]).await { + return "left"; + } + if checked_out && !git_says_yes(directory, &["read-tree", published]).await { + return "moved_index_behind"; + } + "moved" + } + } +} + +/// Why Git did not publish, read from what it printed. +fn push_refusal(ran: &Ran, remote: &str) -> String { + if ran.said("non-fast-forward") || ran.said("fetch first") || ran.said("stale info") { + return format!( + "{remote}'s branch moved while this was being published, and Git never overwrites \ + it. Nothing was published." + ); + } + if ran.said("Authentication failed") + || ran.said("Permission denied") + || ran.said("could not read Username") + || ran.said("terminal prompts disabled") + { + return format!("{remote} did not accept the Git credentials on this machine."); + } + if ran.said("remote rejected") || ran.said("hook declined") || ran.said("protected branch") { + return format!("{remote} refused the branch under its own rules."); + } + if ran.said("Could not resolve host") + || ran.said("unable to access") + || ran.said("Could not read from remote repository") + { + return format!("Git could not reach {remote}."); + } + format!("Git did not publish to {remote}.") +} + +fn nothing_to_send(head: Option, summary: String) -> Ended { + Ended { + outcome: "nothing_to_send", + status: StatusCode::OK, + body: json!({"head":head.map(|head| head.to_string()),"summary":summary}), + } +} + +/// Publish `branch` to the Git remote `remote`, once. The second value says +/// whether Git was asked to push. +pub(super) async fn publish( + state: &Arc, + operation: kin_model::OperationId, + client: &DaemonClient, + local: &LocalBranch, + branch: &RefName, + remote: &Remote, +) -> (Ended, bool) { + let push = Act::Push; + let directory = state.layout.working_dir(); + let name = short_ref(branch); + let Some(head) = local.head_of(branch) else { + let ended = if local.branch.as_ref() == Some(branch) { + nothing_to_send( + None, + "This repository holds nothing on this branch yet.".to_string(), + ) + } else { + not_sent(push, format!("This repository has no branch named {name}.")) + }; + return (ended, false); + }; + if !git_says_yes(directory, &["check-ref-format", "--branch", &name]).await { + return ( + not_sent(push, format!("Git does not take {name} as a branch name.")), + false, + ); + } + let target = match publish_target(directory, &remote.name).await { + Ok(target) => target, + Err(reason) => return (not_sent(push, reason), false), + }; + let before = match remote_head(directory, &remote.name, &target, &name).await { + Ok(before) => before, + Err(reason) => return (not_sent(push, reason), false), + }; + + // The exact projection, staged outside the project, and from there the + // branch's commits into the project's own Git repository under a ref of + // Kin's own. Git copies the objects, so they arrive as they were written. + let staged = match stage_projection(directory).await { + Ok(staged) => staged, + Err(reason) => return (not_sent(push, reason), false), + }; + let holding = format!("refs/kin/export/{name}"); + let source = staged.repository.to_string_lossy().into_owned(); + let taking = format!("+refs/heads/{name}:{holding}"); + let taken = git( + directory, + &["fetch", "--no-tags", "--quiet", "--", &source, &taking], + PROJECTION_BUDGET, + ) + .await + .is_ok_and(|ran| ran.succeeded()); + drop(staged); + let held = format!("{holding}^{{commit}}"); + let published = match git( + directory, + &["rev-parse", "--verify", "--quiet", &held], + LOCAL_BUDGET, + ) + .await + { + Ok(ran) if taken && ran.succeeded() => ran.first().unwrap_or_default().to_string(), + _ => { + return ( + not_sent( + push, + "Git could not take the written commits into the project's own repository.", + ), + false, + ) + } + }; + if !is_object_id(&published) { + return ( + not_sent(push, "Git named no commit for the written branch."), + false, + ); + } + + if change_for(directory, client, &published).await != Held::Change(head) { + return ( + not_sent(push, "This branch changed while its Git projection was being written. Read its standing again before publishing."), + false, + ); + } + let own = own_branch(directory, &name, &published).await; + if own == OwnBranch::Apart { + return ( + not_sent( + push, + format!( + "The project's own Git branch {name} holds commits Kin does not hold. \ + Publishing would leave them behind, so nothing was published." + ), + ), + false, + ); + } + if before.as_deref() == Some(published.as_str()) { + let own = settle_own_branch(directory, &name, &published, &own).await; + let mut ended = nothing_to_send( + Some(head), + format!("{} already holds everything on this branch.", remote.name), + ); + ended.body["git_commit"] = json!(published); + ended.body["git_branch"] = json!(own); + return (ended, false); + } + let mut on_remote = None; + let mut changes = None; + if let Some(there) = &before { + // The remote's commit has to be one this branch builds on. Git checks + // that again when it pushes, and never overwrites. + if !git_says_yes( + directory, + &["cat-file", "-e", &format!("{there}^{{commit}}")], + ) + .await + { + return (not_sent(push, unknown_commits(&remote.name)), false); + } + if !git_says_yes( + directory, + &["merge-base", "--is-ancestor", there, &published], + ) + .await + { + return ( + not_sent( + push, + format!( + "{} and this repository each hold changes the other does not, and Git \ + never overwrites a branch that moved. Nothing was published.", + remote.name + ), + ), + false, + ); + } + changes = git( + directory, + &["rev-list", "--count", &format!("{there}..{published}")], + LOCAL_BUDGET, + ) + .await + .ok() + .filter(Ran::succeeded) + .and_then(|ran| ran.first()?.parse::().ok()); + if let Held::Change(change) = change_for(directory, client, there).await { + on_remote = Some(change); + } + } + + // Sent once. From here the remote may have moved, so nothing below is a + // plain refusal until a fresh reading says the branch is where it was. + state.transfers.mark_sent(operation); + let pushed_ref = format!("{holding}:refs/heads/{name}"); + let pushed = git( + directory, + &[ + "push", + "--porcelain", + "--no-follow-tags", + "--", + &target, + &pushed_ref, + ], + PUSH_BUDGET, + ) + .await; + match pushed { + Ok(ran) if ran.succeeded() => {} + Ok(ran) => match remote_head(directory, &remote.name, &target, &name).await { + Ok(after) if after.as_deref() == Some(published.as_str()) => {} + Ok(after) if after == before => { + return (refused(push, push_refusal(&ran, &remote.name)), true) + } + _ => { + return ( + unknown( + push, + format!( + "Git stopped while publishing to {}, and what it holds now could \ + not be told apart from before.", + remote.name + ), + ), + true, + ) + } + }, + Err(_) => { + return ( + unknown( + push, + format!( + "Git did not finish publishing to {} before the app stopped waiting.", + remote.name + ), + ), + true, + ) + } + } + let own = settle_own_branch(directory, &name, &published, &own).await; + let counted = match on_remote { + Some(on_remote) => local_range(client, &local.repository_id, on_remote, head).await, + None => None, + }; + let ended = Ended { + outcome: "pushed", + status: StatusCode::OK, + body: json!({ + "head":head.to_string(), + "git_commit":published, + "git_branch":own, + "changes":changes.or(counted.map(|count| count.ahead)), + "entities":counted.map(|count| count.entities), + "entity_changes":counted.as_ref().map(entity_changes), + }), + }; + (ended, true) +} + +/// Bring the named Git branch in through the daemon's exact admission +/// transaction. Fetching Git objects is preparation; only the daemon can +/// advance semantic history or the workspace. +pub(super) async fn update( + state: &Arc, + operation: kin_model::OperationId, + client: &DaemonClient, + local: &LocalBranch, + branch: &RefName, + remote: &Remote, +) -> (Ended, bool) { + let pull = Act::Pull; + if let Err(reason) = update_ready(state).await { + return (not_sent(pull, reason), false); + } + if local.branch.as_ref() != Some(branch) { + return (not_sent(pull, "A Git update advances the current workspace branch. Open that branch before updating it."), false); + } + let Some(before) = local.head_of(branch) else { + return ( + not_sent(pull, "This branch holds no imported Git head to update."), + false, + ); + }; + let directory = state.layout.working_dir(); + let name = short_ref(branch); + if !git_says_yes(directory, &["check-ref-format", "--branch", &name]).await { + return ( + not_sent(pull, "Git does not accept this branch name."), + false, + ); + } + let target = match fetch_target(directory, &remote.name).await { + Ok(target) => target, + Err(reason) => return (not_sent(pull, reason), false), + }; + let there = match remote_head(directory, &remote.name, &target, &name).await { + Ok(Some(there)) => there, + Ok(None) => { + return ( + nothing_to_send( + Some(before), + "The remote holds nothing on this branch.".to_string(), + ), + false, + ) + } + Err(reason) => return (not_sent(pull, reason), false), + }; + let holding = format!("refs/kin/update/{name}"); + let taking = format!("+refs/heads/{name}:{holding}"); + let fetched = git( + directory, + &["fetch", "--no-tags", "--quiet", "--", &target, &taking], + PROJECTION_BUDGET, + ) + .await; + match fetched { + Ok(ran) if ran.succeeded() => {} + Ok(ran) => return (not_sent(pull, reach_reason(&ran, &remote.name)), false), + Err(_) => { + return ( + not_sent( + pull, + "Git did not finish fetching this branch. No semantic update was sent.", + ), + false, + ) + } + } + let fetched = match git( + directory, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("{holding}^{{commit}}"), + ], + LOCAL_BUDGET, + ) + .await + { + Ok(ran) if ran.succeeded() => ran.first().unwrap_or_default().to_string(), + _ => { + return ( + not_sent(pull, "Git did not identify the fetched branch's commit."), + false, + ) + } + }; + if fetched != there { + return (not_sent(pull, "The remote's branch changed while it was being fetched. Read its standing again before updating."), false); + } + let bytes = match hex::decode(&fetched) { + Ok(bytes) => bytes, + Err(_) => { + return ( + not_sent(pull, "Git returned an invalid fetched object identity."), + false, + ) + } + }; + let source_oid = match bytes.as_slice() { + bytes if bytes.len() == 20 => { + GitObjectId::sha1(bytes.try_into().expect("checked SHA-1 width")) + } + bytes if bytes.len() == 32 => { + GitObjectId::sha256(bytes.try_into().expect("checked SHA-256 width")) + } + _ => { + return ( + not_sent(pull, "Git returned an unsupported object identity."), + false, + ) + } + }; + let request = crate::git_update::GitUpdateRequest { + operation_id: operation, + source_dir: directory.to_path_buf(), + source_ref: RefName::from_bytes(holding.into_bytes()).expect("valid internal Git ref"), + source_oid, + destination_ref: branch.clone(), + expected_head: before, + }; + state.transfers.mark_sent(operation); + match client.git_update_once(&request).await { + crate::git_update::GitUpdateDispatch::Answered(response) => { + let counted = local_range(client, &local.repository_id, before, response.head).await; + let own = own_branch(directory, &name, &fetched).await; + let git_branch = settle_own_branch(directory, &name, &fetched, &own).await; + (Ended { + outcome: if response.head == before { "nothing_to_send" } else { "pulled" }, + status: StatusCode::OK, + body: json!({ + "head":response.head.to_string(), + "previous_head":response.previous_head.to_string(), + "git_commit":fetched, + "git_branch":git_branch, + "changes":response.admitted_changes, + "entities":counted.map(|count| count.entities), + "entity_changes":counted.as_ref().map(entity_changes), + "workspace":super::workspace_remotes::workspace_json(&response.workspace_follow), + }), + }, true) + }, + crate::git_update::GitUpdateDispatch::Refused { status, .. } if status >= 500 => ( + unknown(pull, "The daemon could not confirm how this Git update ended. It may have admitted the fetched history.".to_string()), + true, + ), + crate::git_update::GitUpdateDispatch::Refused { body, .. } => { + let after = super::workspace_remotes::local_branch(client).await.ok().and_then(|now| now.head_of(branch)); + if after == Some(before) { + (refused(pull, update_reason(&body)), true) + } else { + (unknown(pull, "The Git update was refused after this branch's reading changed. Read its standing and recent changes before another update.".to_string()), true) + } + }, + crate::git_update::GitUpdateDispatch::Lost(_) => (unknown(pull, "The daemon's answer to this Git update was lost. It may have admitted the fetched history.".to_string()), true), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ran(code: i32, out: &[&str], err: &[&str]) -> Ran { + Ran { + code: Some(code), + out: out.iter().map(ToString::to_string).collect(), + err: err.iter().map(ToString::to_string).collect(), + } + } + + #[test] + fn a_commit_kin_wrote_names_its_change_right_after_the_committer() { + let change = "ab".repeat(32); + let lines = |extra: &[String]| { + let mut lines = vec![ + "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904".to_string(), + "author Ada 1700000000 +0000".to_string(), + "committer Ada 1700000000 +0000".to_string(), + ]; + lines.extend_from_slice(extra); + lines + }; + let marked = marked_change(&lines(&[format!("kin-change-id {change}"), "work".into()])); + assert_eq!(marked.map(|id| id.to_string()), Some(change.clone())); + // A message that merely quotes the header names nothing. + assert_eq!( + marked_change(&lines(&["work".into(), format!("kin-change-id {change}")])), + None + ); + assert_eq!( + marked_change(&lines(&["kin-change-id nonsense".into()])), + None + ); + assert_eq!(marked_change(&[]), None); + } + + #[test] + fn an_object_id_is_sha1_or_sha256_hex_and_nothing_else() { + assert!(is_object_id(&"0a".repeat(20))); + assert!(is_object_id(&"0a".repeat(32))); + assert!(!is_object_id("--upload-pack=sh")); + assert!(!is_object_id(&"0a".repeat(19))); + assert!(!is_object_id(&"zz".repeat(20))); + } + + #[test] + fn a_git_update_refusal_names_the_actionable_precondition() { + assert!( + update_reason("fetched Git commit does not descend from current head") + .contains("diverged") + ); + assert!( + update_reason("commit or preserve current graph-owned changes") + .contains("uncommitted changes") + ); + // What the daemon says when tracked bytes changed and were not yet + // admitted, so the edit is still only in the working copy. + assert!(update_reason( + "the tracked working projection changed; admit or preserve those changes before a \ + Git update" + ) + .contains("uncommitted changes")); + assert!(update_reason( + "Git updates after native Kin changes require verified native export bindings" + ) + .contains("Kin-native changes")); + let reason = + update_reason("source closure at /private/project/object/abcdef did not verify"); + assert!(!reason.contains("/private") && !reason.contains("abcdef")); + } + + #[test] + fn a_push_git_refused_is_said_in_words_without_a_command() { + let moved = ran( + 1, + &["!\trefs/kin/export/main:refs/heads/main\t[rejected] (fetch first)"], + &["hint: Updates were rejected because the remote contains work that you do not have"], + ); + assert!(push_refusal(&moved, "origin").contains("origin's branch moved")); + let credentials = ran( + 128, + &[], + &["fatal: Authentication failed for 'https://example.invalid/'"], + ); + assert!(push_refusal(&credentials, "origin").contains("did not accept the Git credentials")); + let rules = ran( + 1, + &["!\ta:b\t[remote rejected] (pre-receive hook declined)"], + &[], + ); + assert!(push_refusal(&rules, "origin").contains("under its own rules")); + let other = ran(1, &[], &["error: something else"]); + for reason in [ + push_refusal(&moved, "origin"), + push_refusal(&credentials, "origin"), + push_refusal(&rules, "origin"), + push_refusal(&other, "origin"), + reach_reason(&other, "origin"), + unknown_commits("origin"), + GIT_PULL_UNAVAILABLE.to_string(), + ] { + assert!(!reason.contains('`'), "{reason}"); + assert!(!reason.contains("git push"), "{reason}"); + } + assert_eq!( + push_refusal(&other, "origin"), + "Git did not publish to origin." + ); + } + + #[tokio::test] + async fn publication_reads_one_push_destination_and_refuses_several() { + let fixture = tempfile::TempDir::new().unwrap(); + let here = fixture.path(); + assert!(git_says_yes(here, &["init", "--quiet", "--initial-branch=main"]).await); + assert!(git_says_yes(here, &["remote", "add", "origin", "fetch-only"]).await); + assert_eq!(publish_target(here, "origin").await.unwrap(), "fetch-only"); + assert!( + git_says_yes( + here, + &["remote", "set-url", "--push", "origin", "publish-here"] + ) + .await + ); + assert_eq!( + publish_target(here, "origin").await.unwrap(), + "publish-here" + ); + assert!( + git_says_yes( + here, + &[ + "remote", + "set-url", + "--add", + "--push", + "origin", + "another-place" + ] + ) + .await + ); + assert!(publish_target(here, "origin") + .await + .unwrap_err() + .contains("several Git publish destinations")); + } + + #[tokio::test] + async fn an_unborn_checked_out_branch_follows_its_index_without_discarding_staging() { + let fixture = tempfile::TempDir::new().unwrap(); + let here = fixture.path(); + assert!(git_says_yes(here, &["init", "--quiet", "--initial-branch=main"]).await); + // An empty tree and an exact commit are enough to witness a ref and + // index transition without depending on a person's author settings. + let tree = git(here, &["mktree"], LOCAL_BUDGET).await.unwrap(); + assert!(tree.succeeded()); + let tree = tree.first().unwrap(); + let commit = git( + here, + &[ + "-c", + "user.name=Test Author", + "-c", + "user.email=test@example.invalid", + "commit-tree", + tree, + "-m", + "initial", + ], + LOCAL_BUDGET, + ) + .await + .unwrap(); + assert!(commit.succeeded()); + let commit = commit.first().unwrap(); + assert_eq!( + settle_own_branch(here, "main", commit, &OwnBranch::Absent).await, + "created" + ); + assert!(git_says_yes(here, &["diff", "--cached", "--quiet", "HEAD"]).await); + assert!(git_says_yes(here, &["update-ref", "-d", "refs/heads/main"]).await); + std::fs::write(here.join("staged.txt"), "staged work").unwrap(); + assert!(git_says_yes(here, &["add", "staged.txt"]).await); + assert_eq!( + settle_own_branch(here, "main", commit, &OwnBranch::Absent).await, + "left" + ); + assert!(!git_says_yes(here, &["rev-parse", "--verify", "HEAD"]).await); + let staged = git(here, &["show", ":staged.txt"], LOCAL_BUDGET) + .await + .unwrap(); + assert_eq!(staged.first(), Some("staged work")); + } + + /// Git is run with its arguments handed over one by one, and a program + /// that is not there is told apart from one that refused. + #[cfg(unix)] + #[tokio::test] + async fn a_child_keeps_what_it_printed_and_a_missing_program_is_said() { + let here = std::env::temp_dir(); + let answered = run( + OsStr::new("sh"), + &here, + &["-c", "printf 'one\\ntwo\\n'; printf 'no\\n' >&2; exit 3"], + Duration::from_secs(20), + ) + .await + .unwrap(); + assert_eq!(answered.code, Some(3)); + assert_eq!(answered.out, ["one", "two"]); + assert_eq!(answered.err, ["no"]); + assert!(answered.said("two") && !answered.succeeded()); + let missing = run( + OsStr::new("kin-no-such-program-for-this-test"), + &here, + &[], + Duration::from_secs(20), + ) + .await; + assert_eq!(missing.unwrap_err(), Stopped::Missing); + let slow = run( + OsStr::new("sh"), + &here, + &["-c", "sleep 30"], + Duration::from_millis(200), + ) + .await; + assert_eq!(slow.unwrap_err(), Stopped::TimedOut); + } +} diff --git a/crates/kin-cli/src/commands/workspace_home.rs b/crates/kin-cli/src/commands/workspace_home.rs index ba3c9d04c..509e026a9 100644 --- a/crates/kin-cli/src/commands/workspace_home.rs +++ b/crates/kin-cli/src/commands/workspace_home.rs @@ -25,9 +25,9 @@ use std::collections::{HashMap, VecDeque}; use std::ffi::{OsStr, OsString}; use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; +use std::time::{Duration, SystemTime}; -use axum::extract::{DefaultBodyLimit, Path as UrlPath, State}; +use axum::extract::{DefaultBodyLimit, Path as UrlPath, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; @@ -56,6 +56,34 @@ const OPERATIONS_KEPT: usize = 50; const LOCATION_MAX_BYTES: usize = 4096; const SOURCE_MAX_BYTES: usize = 2048; +/// How long one look for projects around a home directory may take. +const LOOK_BUDGET: Duration = Duration::from_millis(900); +/// How long what a look found is kept before a request looks again. +const LOOK_KEPT: Duration = Duration::from_secs(30); +/// The directories one look lists, over every place it looks through. +const LOOK_LISTINGS: usize = 400; +/// The directories examined in any one place, most recently changed first. +const LOOK_PER_PLACE: usize = 200; +/// The projects one look keeps, most recently changed first. +const LOOK_FOUND: usize = 200; +const CANDIDATES_DEFAULT: usize = 30; +const CANDIDATES_MAX: usize = 100; +/// How long a request waits for commit counts Git has not given yet. A count +/// that is later than this is in the next answer. +const COUNT_WAIT: Duration = Duration::from_millis(600); +/// How long Git is given to count one project's commits once no request is +/// waiting on it. +const GIT_PATIENCE: Duration = Duration::from_secs(20); +/// The projects Git counts commits for at once. +const GIT_AT_ONCE: usize = 4; +/// The projects whose commit counts are remembered. +const COUNTS_KEPT: usize = 512; +/// The coding agent sessions remembered. The oldest ended one goes first. +const SESSIONS_KEPT: usize = 256; +/// The newest changes read from a running repository for the coding agents +/// that made them. +const SESSION_CHANGES_READ: usize = 50; + /// What the listing page's server remembers while it runs. #[derive(Default)] pub(super) struct Home { @@ -66,6 +94,16 @@ pub(super) struct Home { /// has moved, so a page that polls costs each daemon one comparison and /// not one diff. readings: Mutex>>, + /// The projects the last look around the home directory found, and when + /// it looked. + look: Mutex)>>, + /// The directory looked around in place of the home directory. Only a + /// test names one. + looks_in: Mutex>, + counts: Counts, + /// The coding agent sessions the repositories on this machine have + /// shown this server, by session. + sessions: Mutex>, } pub(super) fn routes() -> Router> { @@ -73,6 +111,7 @@ pub(super) fn routes() -> Router> { .route("/api/home/session", get(session)) .route("/api/home", get(home)) .route("/api/home/inspect", post(inspect)) + .route("/api/home/candidates", get(candidates)) .route("/api/home/repositories", post(add)) .route("/api/home/clients", get(clients).post(connect)) .route("/api/home/operations", get(operations)) @@ -353,21 +392,10 @@ struct InspectBody { path: String, } -async fn inspect(State(hub): State>, Json(body): Json) -> Response { - let reading = { - let hub = hub.clone(); - tokio::task::spawn_blocking(move || { - let place = look_at(&body.path)?; - let registered = hub.catalog.registered_at(&place.path); - Ok::<_, String>((place, registered)) - }) - .await - }; - let (place, registered) = match reading { - Ok(Ok(reading)) => reading, - Ok(Err(sentence)) => return refused(StatusCode::BAD_REQUEST, sentence), - Err(error) => return refused(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), - }; +/// What the add flow says of one location: what is there, what adding it +/// would do, and what this page may be asked to do with it. Everything but +/// what Git has to be asked for. +fn described(hub: &Hub, place: &Place, registered: bool) -> Value { // A repository answers at its URL once the registry names it or this // server already serves it. Before that the URL would lead nowhere. let key = place @@ -384,7 +412,7 @@ async fn inspect(State(hub): State>, Json(body): Json) -> "kind": place.kind.word(), "name": place.path.file_name().map(|name| name.to_string_lossy().into_owned()), "registered": registered, - "note": note(&place, reachable.is_some()), + "note": note(place, reachable.is_some()), }); if let Some(key) = reachable { answer["repository"] = json!({ @@ -400,22 +428,10 @@ async fn inspect(State(hub): State>, Json(body): Json) -> answer["actions"] = json!( [Action::Convert, Action::Create, Action::Open, Action::Clone] .into_iter() - .filter(|action| precondition(*action, &place).is_none()) + .filter(|action| precondition(*action, place).is_none()) .map(Action::word) .collect::>() ); - if place.kind == Kind::Git { - let mut git = serde_json::Map::new(); - if let Some(commits) = git_commits(&place.path).await { - git.insert("commits".into(), json!(commits)); - } - if let Some(branch) = &place.branch { - git.insert("branch".into(), json!(branch)); - } - if !git.is_empty() { - answer["git"] = Value::Object(git); - } - } if let Some(outer) = &place.inside { let entry = super::workspace_ui::entry(outer); answer["inside"] = json!({ @@ -425,9 +441,503 @@ async fn inspect(State(hub): State>, Json(body): Json) -> "location": location(outer.working_dir()), }); } + answer +} + +/// What is known of a Git project before it is added: the branch it has +/// checked out and how many commits it holds, each when known. +fn git_block(branch: Option<&str>, commits: Option) -> Option { + let mut git = serde_json::Map::new(); + if let Some(commits) = commits { + git.insert("commits".into(), json!(commits)); + } + if let Some(branch) = branch { + git.insert("branch".into(), json!(branch)); + } + (!git.is_empty()).then_some(Value::Object(git)) +} + +async fn inspect(State(hub): State>, Json(body): Json) -> Response { + let reading = { + let hub = hub.clone(); + tokio::task::spawn_blocking(move || { + let place = look_at(&body.path)?; + let registered = hub.catalog.registered_at(&place.path); + Ok::<_, String>((place, registered)) + }) + .await + }; + let (place, registered) = match reading { + Ok(Ok(reading)) => reading, + Ok(Err(sentence)) => return refused(StatusCode::BAD_REQUEST, sentence), + Err(error) => return refused(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), + }; + let mut answer = described(&hub, &place, registered); + if place.kind == Kind::Git { + let commits = git_commits(&place.path, GIT_BUDGET).await; + if let Some(git) = git_block(place.branch.as_deref(), commits) { + answer["git"] = git; + } + } Json(answer).into_response() } +// --------------------------------------------------------------------------- +// Projects a person is likely to add +// --------------------------------------------------------------------------- +// +// So that adding a repository starts from a list and not from a typed +// location, this page looks for projects where people keep them. It is the +// same boundary `inspect` sits on, reached without a location being typed, +// and it is kept as narrow: directory names and change times are read, +// nothing a project holds is, and what is found is only ever offered to the +// reader to add. + +/// Directories directly inside a home directory that people keep projects +/// in, in lowercase. Each is looked through two levels deep, so a project +/// kept under its owner's name is found as well. +const CODE_ROOTS: [&str; 15] = [ + "code", + "dev", + "developer", + "development", + "git", + "github", + "gitlab", + "projects", + "repos", + "repositories", + "source", + "src", + "work", + "workspace", + "workspaces", +]; + +/// Directories never looked into, in lowercase: what tools install or build +/// into, and the folders an operating system keeps a person's documents, +/// media and synced storage in. Those are not where projects are looked for, +/// and macOS asks a person before any program may read some of them. +const NEVER_LOOKED_IN: [&str; 38] = [ + "__pycache__", + "_build", + "applications", + "bin", + "bower_components", + "build", + "coverage", + "creative cloud files", + "deps", + "deriveddata", + "desktop", + "dist", + "documents", + "downloads", + "dropbox", + "env", + "google drive", + "icloud drive", + "library", + "movies", + "music", + "node_modules", + "obj", + "onedrive", + "out", + "parallels", + "pictures", + "pods", + "public", + "site-packages", + "snap", + "target", + "temp", + "templates", + "tmp", + "vendor", + "venv", + "videos", +]; + +/// Names at the top of a directory that say it is a project. Only the name +/// is looked at. +const PROJECT_MARKERS: [&str; 26] = [ + "build.bazel", + "build.gradle", + "build.gradle.kts", + "build.sbt", + "cargo.toml", + "cmakelists.txt", + "composer.json", + "deno.json", + "dune-project", + "flake.nix", + "gemfile", + "go.mod", + "makefile", + "meson.build", + "mix.exs", + "package.json", + "package.swift", + "pipfile", + "pom.xml", + "pubspec.yaml", + "pyproject.toml", + "requirements.txt", + "settings.gradle", + "setup.py", + "stack.yaml", + "tsconfig.json", +]; +const PROJECT_MARKER_ENDINGS: [&str; 5] = [".sln", ".xcodeproj", ".csproj", ".cabal", ".gemspec"]; + +/// A project a look found: where it is and when it last changed. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Spotted { + path: PathBuf, + /// The later of when its own directory and its Git directory changed. + changed: Option, +} + +/// What one look around a home directory found. +#[derive(Debug, Default)] +struct Look { + /// Most recently changed first. + spotted: Vec, + /// Whether every place was looked through inside the look's bounds. + complete: bool, +} + +fn lowercase_name(name: &OsStr) -> String { + name.to_string_lossy().to_lowercase() +} + +/// Whether a directory of this name is looked at at all. +fn looked_into(name: &OsStr) -> bool { + let name = lowercase_name(name); + !name.starts_with('.') && !NEVER_LOOKED_IN.contains(&name.as_str()) +} + +fn marks_a_project(name: &OsStr) -> bool { + let name = lowercase_name(name); + PROJECT_MARKERS.contains(&name.as_str()) + || PROJECT_MARKER_ENDINGS + .iter() + .any(|ending| name.ends_with(ending)) +} + +/// Look for projects where people keep them: the directories directly +/// inside `home`, and one and two levels inside those named like a code +/// root. Blocking, and read-only. +/// +/// It reads directory names and change times and nothing a project holds. A +/// project is known by the `.git` or `.kin` directory at its top or by the +/// name of a build manifest there, and a directory that is one is never +/// looked inside. A second working copy of a project is not a second project. +/// It follows no link and enters no directory a tool installs or builds +/// into. It stops at `until`, or once it has listed [`LOOK_LISTINGS`] +/// directories, and in each place it examines the most recently changed +/// directories first, so that what a bound cuts off is the oldest. +fn look_around(home: &Path, until: std::time::Instant) -> Look { + let mut look = Look { + spotted: Vec::new(), + complete: true, + }; + let Ok(tree) = kin_io::WorktreeSource::open(home, &kin_io::Grant::ambient()) else { + return look; + }; + let mut listings = 0; + // Each place still to look through: where it is under `home`, the names + // it holds when they were already read, whether a directory in it that + // is no project is itself looked through, and how many levels further. + let mut places: VecDeque<(PathBuf, Option>, usize)> = + VecDeque::from([(PathBuf::new(), None, 0)]); + 'places: while let Some((place, names, deeper)) = places.pop_front() { + let at_home = place.as_os_str().is_empty(); + let names = match names { + Some(names) => names, + None => { + if listings >= LOOK_LISTINGS || std::time::Instant::now() >= until { + look.complete = false; + break; + } + listings += 1; + tree.names(&place).unwrap_or_default() + } + }; + // The directories here, most recently changed first. + let mut directories: Vec<(Option, PathBuf, OsString)> = Vec::new(); + for name in names { + if !looked_into(&name) { + continue; + } + let inner = place.join(&name); + if tree.kind(&inner).ok().flatten() != Some(kin_io::EntryKind::Directory) { + continue; + } + directories.push((tree.modified(&inner).ok().flatten(), inner, name)); + } + directories.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + if directories.len() > LOOK_PER_PLACE { + directories.truncate(LOOK_PER_PLACE); + look.complete = false; + } + for (changed, inner, name) in directories { + if std::time::Instant::now() >= until { + look.complete = false; + break 'places; + } + let is_directory = |name: &str| { + tree.kind(inner.join(name)).ok().flatten() == Some(kin_io::EntryKind::Directory) + }; + if is_directory(".git") || is_directory(".kin") { + let git = tree.modified(inner.join(".git")).ok().flatten(); + look.spotted.push(Spotted { + path: home.join(&inner), + changed: changed.max(git), + }); + continue; + } + // A `.git` that is not a directory marks one more working copy + // of a project kept somewhere else, or a project held inside + // another. That project is found where it is kept, so this is + // neither offered beside it nor looked inside. + if tree.kind(inner.join(".git")).ok().flatten().is_some() { + continue; + } + // Neither Git nor Kin holds it, so its top names say whether it + // is a project, and they are what is looked through if it is not. + if listings >= LOOK_LISTINGS { + look.complete = false; + break 'places; + } + listings += 1; + let inside = tree.names(&inner).unwrap_or_default(); + if inside.iter().any(|name| marks_a_project(name)) { + look.spotted.push(Spotted { + path: home.join(&inner), + changed, + }); + } else if at_home && CODE_ROOTS.contains(&lowercase_name(&name).as_str()) { + places.push_back((inner, Some(inside), 1)); + } else if deeper > 0 { + places.push_back((inner, Some(inside), deeper - 1)); + } + } + } + look.spotted.sort_by(|left, right| { + right + .changed + .cmp(&left.changed) + .then_with(|| left.path.cmp(&right.path)) + }); + if look.spotted.len() > LOOK_FOUND { + look.spotted.truncate(LOOK_FOUND); + look.complete = false; + } + look +} + +/// What Git said of one project's commits. +#[derive(Default)] +struct Counted { + /// The project's change time the count was taken at. A project that has + /// changed since is counted again. + taken_at: Option>, + commits: Option, + in_flight: bool, +} + +/// Commit counts for the projects this page offers, kept between requests so +/// a list that is read again starts no process for a project that has not +/// changed. +struct Counts { + known: Mutex>, + gate: tokio::sync::Semaphore, + /// Bumped whenever a count arrives, for a request waiting on one. + moved: tokio::sync::watch::Sender, +} + +impl Default for Counts { + fn default() -> Self { + Self { + known: Mutex::default(), + gate: tokio::sync::Semaphore::new(GIT_AT_ONCE), + moved: tokio::sync::watch::channel(0).0, + } + } +} + +impl Counts { + /// The count held for a project, and whether a newer one is on its way. + fn held(&self, path: &Path) -> (Option, bool) { + locked(&self.known) + .get(path) + .map_or((None, false), |counted| { + (counted.commits, counted.in_flight) + }) + } +} + +/// Have Git count the commits of the project at `path`, unless a count is +/// held for it as it was at `changed` or one is already being taken. The +/// count is taken by a task of its own, so it outlasts the request that +/// asked and is there for the next one. +fn count_commits(hub: &Arc, path: &Path, changed: Option) { + { + let mut known = locked(&hub.home.counts.known); + if known.len() >= COUNTS_KEPT && !known.contains_key(path) { + known.retain(|_, counted| counted.in_flight); + } + let counted = known.entry(path.to_path_buf()).or_default(); + if counted.in_flight || counted.taken_at == Some(changed) { + return; + } + counted.in_flight = true; + } + let (hub, path) = (hub.clone(), path.to_path_buf()); + tokio::spawn(async move { + let counts = &hub.home.counts; + let commits = match counts.gate.acquire().await { + Ok(_one_at_a_time) => git_commits(&path, GIT_PATIENCE).await, + Err(_) => None, + }; + if let Some(counted) = locked(&counts.known).get_mut(&path) { + *counted = Counted { + taken_at: Some(changed), + commits, + in_flight: false, + }; + } + counts.moved.send_modify(|version| *version += 1); + }); +} + +impl Home { + /// What a look around the home directory finds, from the last look when + /// that was within [`LOOK_KEPT`]. Blocking. + fn looked_around(&self) -> Arc { + if let Some((looked, look)) = locked(&self.look).as_ref() { + if looked.elapsed() < LOOK_KEPT { + return look.clone(); + } + } + // The resolved form of the home directory, so that what is found is + // named the way the registry and `inspect` name it. + let home = locked(&self.looks_in) + .clone() + .or_else(|| homes().last().cloned()); + let look = Arc::new(match home { + Some(home) => look_around(&home, std::time::Instant::now() + LOOK_BUDGET), + None => Look::default(), + }); + *locked(&self.look) = Some((std::time::Instant::now(), look.clone())); + look + } + + /// Look around `home` in place of the home directory. + #[cfg(test)] + fn look_in(&self, home: &Path) { + *locked(&self.looks_in) = Some(home.to_path_buf()); + *locked(&self.look) = None; + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CandidatesQuery { + #[serde(default)] + limit: Option, +} + +/// The projects on this machine a person is likely to want in Kin, most +/// recently changed first, each described as `inspect` describes it. +/// +/// Looking is bounded and its result is kept for a while, so a large disk +/// costs a request no more than a small one. What each project is, and what +/// may be done with it, is read again for every answer, so a project added +/// a moment ago says so. Commit counts come from Git at its own pace: the +/// ones not known yet are left out, and `counting` says how many are coming. +async fn candidates(State(hub): State>, Query(query): Query) -> Response { + let limit = query + .limit + .unwrap_or(CANDIDATES_DEFAULT) + .clamp(1, CANDIDATES_MAX); + let reading = { + let hub = hub.clone(); + tokio::task::spawn_blocking(move || { + let look = hub.home.looked_around(); + let registered = hub.catalog.registered_keys(); + let shown: Vec<(Spotted, Place, bool)> = look + .spotted + .iter() + .take(limit) + .filter_map(|spotted| { + // Read as `inspect` reads a location a person typed. A + // project that has gone since the look is left out. + let place = look_at(&spotted.path.to_string_lossy()).ok()?; + let known = registered.contains(&repository_key(&place.path)); + matches!(place.kind, Kind::Kin | Kind::Git | Kind::Plain) + .then(|| (spotted.clone(), place, known)) + }) + .collect(); + (look.spotted.len(), look.complete, shown) + }) + .await + }; + let (found, complete, shown) = match reading { + Ok(reading) => reading, + Err(error) => return refused(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), + }; + let counted: Vec<&Path> = shown + .iter() + .filter(|(_, place, _)| place.kind == Kind::Git) + .map(|(spotted, place, _)| { + count_commits(&hub, &place.path, spotted.changed); + place.path.as_path() + }) + .collect(); + let counts = &hub.home.counts; + let mut moved = counts.moved.subscribe(); + let deadline = tokio::time::Instant::now() + COUNT_WAIT; + let counting = loop { + let counting = counted.iter().filter(|path| counts.held(path).1).count(); + if counting == 0 + || tokio::time::timeout_at(deadline, moved.changed()) + .await + .is_err() + { + break counting; + } + }; + let rows: Vec = shown + .iter() + .map(|(spotted, place, registered)| { + let mut row = described(&hub, place, *registered); + if let Some(changed) = spotted.changed { + row["modified_at"] = json!(chrono::DateTime::::from(changed) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); + } + if place.kind == Kind::Git { + let commits = counts.held(&place.path).0; + if let Some(git) = git_block(place.branch.as_deref(), commits) { + row["git"] = git; + } + } + row + }) + .collect(); + Json(json!({ + "schema": "kin.workspace.home-candidates.v1", + "read_at": now(), + "candidates": rows, + "found": found, + "complete": complete, + "counting": counting, + })) + .into_response() +} + // --------------------------------------------------------------------------- // The processes this page starts // --------------------------------------------------------------------------- @@ -510,15 +1020,18 @@ async fn relay( /// Run `program` to its end and answer its exit status, handing `said` each /// line it prints and whether the line came on its error stream. /// -/// This is the one place this page starts a process, and it starts two: this -/// same Kin binary, to add a repository exactly as a terminal would, and Git, -/// to count a project's commits before it is added. Neither answers a -/// question about a repository's code. -async fn run_child( +/// This is the one place the local app starts a process, and it starts two: +/// this same Kin binary, to add a repository or write a Git projection +/// exactly as a terminal would, and Git, to count a project's commits before +/// it is added and to carry a published branch to a Git remote. Neither +/// answers a question about a repository's code. `environment` is set on the +/// child over what it inherits. +pub(super) async fn run_child( program: &OsStr, arguments: &[OsString], directory: Option<&Path>, outlives_request: bool, + environment: &[(&str, &str)], said: &(dyn Fn(bool, &str) + Send + Sync), ) -> std::io::Result> { let spawner = kin_io::Spawner::new(&kin_io::Grant::ambient()); @@ -526,6 +1039,7 @@ async fn run_child( command .args(arguments) .env("NO_COLOR", "1") + .envs(environment.iter().copied()) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); @@ -547,9 +1061,9 @@ async fn run_child( } /// How many commits Git reaches from every ref of the project at -/// `directory`, when it can say within [`GIT_BUDGET`]. The same count `kin -/// init` opens with, asked only to word the add flow. -async fn git_commits(directory: &Path) -> Option { +/// `directory`, when it can say within `budget`. The same count `kin init` +/// opens with, asked only to word the add flow. +async fn git_commits(directory: &Path, budget: Duration) -> Option { let printed = Mutex::new(String::new()); let keep = |errors: bool, line: &str| { if !errors { @@ -558,8 +1072,15 @@ async fn git_commits(directory: &Path) -> Option { }; let arguments = ["rev-list", "--count", "--all"].map(OsString::from); let ended = tokio::time::timeout( - GIT_BUDGET, - run_child(OsStr::new("git"), &arguments, Some(directory), false, &keep), + budget, + run_child( + OsStr::new("git"), + &arguments, + Some(directory), + false, + &[], + &keep, + ), ) .await .ok()? @@ -953,7 +1474,7 @@ fn serve(hub: &Hub, layout: kin_core::KinLayout) -> RepositoryEntry { async fn run_kin(hub: Arc, operation: Arc, arguments: Vec) { let said = |errors: bool, line: &str| operation.say(errors, line); let ended = match std::env::current_exe() { - Ok(kin) => run_child(kin.as_os_str(), &arguments, None, true, &said).await, + Ok(kin) => run_child(kin.as_os_str(), &arguments, None, true, &[], &said).await, Err(error) => Err(error), }; let failed = |detail: String| Outcome::Failed { @@ -1183,13 +1704,236 @@ async fn add( /// The most coding agents one request may name. Kin knows fewer. const CLIENTS_PER_REQUEST: usize = 16; +/// One coding agent session a repository showed this server: its daemon +/// listed it as connected, or its history records a change the session made. +#[derive(Clone, Debug, PartialEq, Eq)] +struct SessionSeen { + /// The coding agent, by the id `kin setup clients` lists it by. + client: &'static str, + /// The repository it worked in, by key and by name. + key: String, + name: String, + /// When it was last known to do anything. + at: chrono::DateTime, + /// `active` or `idle` while the repository's daemon lists it, and + /// nothing once it does not. + listed: Option<&'static str>, +} + +impl SessionSeen { + fn state(&self) -> &'static str { + self.listed.unwrap_or("ended") + } +} + +/// Keep what is remembered inside its bound, dropping the session that +/// ended longest ago. +fn bound_sessions(sessions: &mut HashMap) { + while sessions.len() > SESSIONS_KEPT { + let Some(oldest) = sessions + .iter() + .filter(|(_, seen)| seen.listed.is_none()) + .min_by_key(|(_, seen)| seen.at) + .map(|(session, _)| session.clone()) + else { + break; + }; + sessions.remove(&oldest); + } +} + +/// Take in what one repository's daemon lists as connected. `None` says no +/// daemon is running for the repository, so no session of it is still +/// connected. An answer that is not a list says nothing either way. +fn sessions_listed( + sessions: &mut HashMap, + key: &str, + name: &str, + answer: Option<&Value>, +) { + let agents: &[Value] = match answer { + Some(answer) => match answer["agents"].as_array() { + Some(agents) => agents, + None => return, + }, + None => &[], + }; + let mut listed = Vec::new(); + for agent in agents { + let Some(session) = agent["session_id"].as_str().map(str::to_ascii_lowercase) else { + continue; + }; + listed.push(session.clone()); + let Some(client) = super::workspace_agents::agent_client_id( + agent["vendor"].as_str().unwrap_or_default(), + agent["client"].as_str().unwrap_or_default(), + ) else { + continue; + }; + let at = ["last_activity_at", "started_at"] + .iter() + .find_map(|field| recorded_time(agent[*field].as_str()?)) + .unwrap_or_else(chrono::Utc::now); + let state = if agent["state"] == "active" { + "active" + } else { + "idle" + }; + let seen = sessions.entry(session).or_insert_with(|| SessionSeen { + client, + key: key.to_string(), + name: name.to_string(), + at, + listed: None, + }); + seen.client = client; + seen.key = key.to_string(); + seen.name = name.to_string(); + seen.at = seen.at.max(at); + seen.listed = Some(state); + } + for (session, seen) in sessions.iter_mut() { + if seen.key == key && !listed.contains(session) { + seen.listed = None; + } + } + bound_sessions(sessions); +} + +/// Take in one recorded change of a repository: when a coding agent made +/// it, its session was at work in that repository then. +fn session_committed( + sessions: &mut HashMap, + key: &str, + name: &str, + author: &str, + at: chrono::DateTime, +) { + let Some(author) = super::workspace_agents::agent_author(author) else { + return; + }; + let Some(client) = author.client else { + return; + }; + let seen = sessions + .entry(author.session) + .or_insert_with(|| SessionSeen { + client, + key: key.to_string(), + name: name.to_string(), + at, + listed: None, + }); + seen.at = seen.at.max(at); + bound_sessions(sessions); +} + +/// The newest session of each coding agent, as the page is told of it. A +/// session still connected leads one that ended at the same moment. +fn last_sessions(sessions: &HashMap) -> HashMap<&'static str, Value> { + let mut newest: HashMap<&'static str, &SessionSeen> = HashMap::new(); + for seen in sessions.values() { + let rank = |seen: &SessionSeen| (seen.at, seen.listed.is_some()); + let kept = newest.entry(seen.client).or_insert(seen); + if rank(seen) > rank(kept) { + *kept = seen; + } + } + newest + .into_iter() + .map(|(client, seen)| { + let last = json!({ + "repository": { + "key": seen.key, + "name": seen.name, + "url": format!("/repositories/{}/workspace/", seen.key), + }, + "at": seen.at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "state": seen.state(), + }); + (client, last) + }) + .collect() +} + +/// Read which coding agents one repository has seen, when its daemon is +/// already running: the sessions it lists now, and the agents behind its +/// newest changes. A repository that is not running is not started. It is +/// only noted that nothing is connected to it. +async fn read_sessions(hub: &Hub, listed: &Listed) { + let deadline = tokio::time::Instant::now() + OBSERVE_BUDGET; + let (key, name) = (&listed.entry.key, &listed.entry.name); + let client = match tokio::time::timeout_at(deadline, running_daemon(&listed.layout)).await { + Ok(Some(client)) => client, + Ok(None) => { + sessions_listed(&mut locked(&hub.home.sessions), key, name, None); + return; + } + // Not known in time, which says nothing about what is connected. + Err(_) => return, + }; + let newest = super::log::LogRequest { + count: SESSION_CHANGES_READ, + skip: 0, + }; + let (agents, changes) = tokio::join!( + tokio::time::timeout_at(deadline, client.agents()), + tokio::time::timeout_at(deadline, client.log(&newest)), + ); + let mut sessions = locked(&hub.home.sessions); + if let Ok(Ok(agents)) = &agents { + sessions_listed(&mut sessions, key, name, Some(agents)); + } + for entry in changes + .ok() + .and_then(Result::ok) + .and_then(|answer| answer.report) + .into_iter() + .flat_map(|report| report.entries) + { + session_committed( + &mut sessions, + key, + name, + &entry.author.to_string(), + entry.timestamp.0, + ); + } +} + /// Every coding agent Kin can connect, as `kin setup clients --json` prints -/// it, from the same function. Reads only. -async fn clients() -> Response { - match tokio::task::spawn_blocking(super::setup::clients_report).await { - Ok(report) => Json(report).into_response(), - Err(error) => refused(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), +/// it, from the same function, and for each the last session Kin has a +/// record of on this machine: which repository it worked in, when, and +/// whether it is still connected. `null` when Kin has no record of one. +/// +/// The record is what the repositories that are running say now, the agents +/// behind their newest changes, and what this server has seen since it +/// started. Reads only, and no repository is started to answer. +async fn clients(State(hub): State>) -> Response { + let catalog = hub.catalog.clone(); + let reading = + tokio::task::spawn_blocking(move || (super::setup::clients_report(), catalog.read())).await; + let (mut report, listing) = match reading { + Ok(reading) => reading, + Err(error) => return refused(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), + }; + futures_util::future::join_all( + listing + .repositories + .iter() + .map(|listed| read_sessions(&hub, listed)), + ) + .await; + let last = last_sessions(&locked(&hub.home.sessions)); + for client in report["clients"].as_array_mut().into_iter().flatten() { + let session = client["id"] + .as_str() + .and_then(|id| last.get(id)) + .cloned() + .unwrap_or(Value::Null); + client["last_session"] = session; } + Json(report).into_response() } #[derive(Deserialize)] @@ -1411,14 +2155,48 @@ struct Reading { head_change: Option<(chrono::DateTime, chrono::DateTime)>, /// The generation `head_change` was read at. head_generation: Option, + /// The last count this server read, at any generation: whether the + /// working copy held work, how many entities it touched, and when. Kept + /// so a row whose daemon stops answering says what was last known. + counted: Option<(bool, usize, chrono::DateTime)>, in_flight: bool, /// A read that failed is not started again before this. retry_after: Option, } +/// What a repository's daemon last told this server about its repository, +/// and when. A daemon that is busy, wedged or stopped leaves this as it +/// was, so the row says what Kin last knew instead of dropping to saying +/// that nothing is in motion. +#[derive(Clone, Debug, Default, PartialEq)] +struct Known { + /// When the daemon last answered this server. + at: Option>, + /// The generations its branch listing was last read at. + generation: Option, + branch: Option, + agents: Option, + merge_in_progress: Option, + activity: Option, +} + +/// The reading of a daemon that runs in the background of the requests that +/// want it. +#[derive(Default)] +struct Asking { + in_flight: bool, + /// When the running reading started. + started: Option, + /// When the last reading finished, and whether the daemon answered it. + finished: Option<(tokio::time::Instant, bool)>, +} + struct Slot { reading: Mutex, - /// Bumped whenever `reading` gains something, for a request waiting on it. + known: Mutex, + asking: Mutex, + /// Bumped whenever `reading`, `known` or `asking` gains something, for a + /// request waiting on it. moved: tokio::sync::watch::Sender, } @@ -1426,6 +2204,8 @@ impl Default for Slot { fn default() -> Self { Self { reading: Mutex::default(), + known: Mutex::default(), + asking: Mutex::default(), moved: tokio::sync::watch::channel(0).0, } } @@ -1523,6 +2303,9 @@ async fn read_repository(client: DaemonClient, slot: Arc) { reading.differs = reading .differs .map(|dirty| differs_after_count(dirty, changed)); + reading.counted = reading + .differs + .map(|differs| (differs, changed, chrono::Utc::now())); } if let Ok(Some(made)) = head { reading.head_generation = Some(generation); @@ -1606,10 +2389,37 @@ fn last_change( } } -/// What a running daemon said about its repository. A reading that did not -/// arrive in time is left out or said to be unknown, never guessed. +/// Whether a repository's daemon answered for one reading of the list. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Reach { + /// It answered, for this request or a moment before it. + Answering, + /// It is running and has not answered for a while. + NotAnswering, + /// No daemon is running for the repository. + Stopped, +} + +impl Reach { + fn word(self) -> &'static str { + match self { + Self::Answering => "answering", + Self::NotAnswering => "not_answering", + Self::Stopped => "stopped", + } + } +} + +/// What a row says about a repository's daemon. A reading that did not +/// arrive in time is what the daemon last told this server, with when it +/// told it, or is left out; it is never guessed and never dropped to +/// "nothing in motion". struct Observed { running: bool, + reach: Reach, + /// When the branch, working copy, agents and merge the row carries were + /// last read, when that was not for this request. + known_at: Option>, branch: Option, working: Option, agents: Option, @@ -1619,14 +2429,21 @@ struct Observed { } /// The daemon already serving `layout`, when its recorded endpoint answers -/// and proves it is that repository's. Reads recorded endpoint metadata and -/// verifies health without discovery, cleanup, startup or process control. +/// and proves it is that repository's and this release's. Reads recorded +/// endpoint metadata and verifies health without discovery, cleanup, startup +/// or process control, except to replace a daemon of an older release, which +/// the page never reads through. async fn running_daemon(layout: &kin_core::KinLayout) -> Option { let (pid, port) = crate::daemon_client::repo_daemon_recorded_endpoint(layout.root()); let port = pid.and(port).filter(|port| *port != 0)?; - DaemonClient::from_running_workspace(format!("http://127.0.0.1:{port}"), layout) - .await - .ok() + DaemonClient::for_workspace_page( + "local UI", + format!("http://127.0.0.1:{port}"), + layout, + Duration::from_secs(10), + ) + .await + .ok() } /// Who the daemon lists as connected, by the names a reader knows them by. @@ -1658,12 +2475,217 @@ impl Home { } } +/// How long a reading of a daemon, once started, waits on it after no +/// request is waiting on the answer any more. +const ASK_PATIENCE: Duration = Duration::from_secs(20); +/// A reading that finished this shortly before a request began is current +/// enough for it, so several pages polling at once ask a daemon once. +const ASKED_RECENTLY: Duration = Duration::from_secs(2); +/// How long what a daemon said still counts as current when it is slow to +/// answer again. Past this the row says what it shows was last known. +const STILL_CURRENT: Duration = Duration::from_secs(60); + +/// What a row carries for a repository whose daemon did not answer this +/// request: what the daemon last told this server, and when. +#[derive(Debug, PartialEq)] +struct Carried { + reach: Reach, + known_at: Option>, + branch: Option, + working: Option, + agents: Option, + merge_in_progress: Option, +} + +/// What a row says for a repository whose daemon did not answer this +/// request. `running` is whether a daemon process is alive for it. What the +/// daemon told this server before is said with when it was told: a busy or +/// wedged daemon is still working in a repository that holds the same work, +/// branch and agents, and a stopped one still leaves its work, branch and +/// merge as they were. A stopped daemon holds no agent session, so no agent +/// is carried for it. An answer within [`STILL_CURRENT`] is current, so the +/// daemon still counts as answering. +fn unanswered( + known: &Known, + counted: Option<(bool, usize, chrono::DateTime)>, + running: bool, + now: chrono::DateTime, +) -> Carried { + let recent = known.at.is_some_and(|at| { + (now - at) + .to_std() + .ok() + .is_none_or(|age| age <= STILL_CURRENT) + }); + let reach = if !running { + Reach::Stopped + } else if recent { + Reach::Answering + } else { + Reach::NotAnswering + }; + let Some(at) = known.at else { + return Carried { + reach, + known_at: None, + branch: None, + working: None, + agents: None, + merge_in_progress: None, + }; + }; + let working = counted.map(|(differs, changed, _)| Working::Read { + differs, + changed: Some(changed), + }); + // The oldest of what is carried says when the row was last known. + let known_at = counted.map_or(at, |(_, _, read)| read.min(at)); + Carried { + reach, + known_at: Some(known_at), + branch: known.branch.clone(), + working, + agents: if reach == Reach::Stopped { + None + } else { + known.agents.clone() + }, + merge_in_progress: known.merge_in_progress, + } +} + +/// The active branch a listing names, without its `refs/heads/` prefix. +fn active_branch(report: &super::branch::BranchListReport) -> Option { + report + .branches + .iter() + .find(|branch| branch.active) + .map(|branch| { + let full = branch.name.to_string(); + full.strip_prefix("refs/heads/") + .unwrap_or(&full) + .to_string() + }) +} + +/// Start counting a repository's working copy unless what it would read is +/// already held for `wanted`, or a count is running or waiting to retry. +fn start_reading( + client: &DaemonClient, + slot: &Arc, + wanted: Option, + since: tokio::time::Instant, +) { + let mut reading = locked(&slot.reading); + let settled = matches!( + working_from(&reading, wanted, since), + Working::Read { + changed: Some(_), + .. + } + ) && wanted.is_some_and(|wanted| reading.head_generation == Some(wanted)); + let may_start = reading + .retry_after + .is_none_or(|after| tokio::time::Instant::now() >= after); + if !settled && !reading.in_flight && may_start { + reading.in_flight = true; + tokio::spawn(read_repository(client.clone(), slot.clone())); + } +} + +/// Ask a repository's running daemon what it says of its branch, its merge, +/// its agents and what it is doing, keep each answer in `slot` as it is +/// given, and start counting its working copy. Runs on its own, so an answer +/// that arrives after every request stopped waiting is kept for the next. +async fn ask_daemon( + hub: Arc, + key: String, + name: String, + layout: kin_core::KinLayout, + slot: Arc, +) { + use super::daemon_activity::{read_activity, Reachability}; + + let started = tokio::time::Instant::now(); + let answered = match tokio::time::timeout(ASK_PATIENCE, running_daemon(&layout)).await { + Ok(Some(client)) => { + locked(&slot.known).at = Some(chrono::Utc::now()); + slot.announce(); + let list = super::branch::BranchRequest::List; + let listing = super::conflicts::ConflictsRequest { bodies: false }; + let root = layout.working_dir().display().to_string(); + let (branches, conflicts, agents, activity) = tokio::join!( + tokio::time::timeout(ASK_PATIENCE, client.branch(&list)), + tokio::time::timeout(ASK_PATIENCE, client.conflicts(&listing)), + tokio::time::timeout(ASK_PATIENCE, client.agents()), + tokio::time::timeout( + ASK_PATIENCE, + read_activity(client.base_url().to_string(), root, Reachability::Answering), + ), + ); + // What the list reads anyway is also what says which coding + // agents work here, so it is remembered for the page that asks + // about them. + if let Ok(Ok(answer)) = &agents { + sessions_listed(&mut locked(&hub.home.sessions), &key, &name, Some(answer)); + } + let branches = branches + .ok() + .and_then(Result::ok) + .and_then(|answer| answer.report); + let generation = branches + .as_ref() + .map(|report| (report.authority_generation, report.workspace_generation)); + { + let mut known = locked(&slot.known); + known.at = Some(chrono::Utc::now()); + if let Some(report) = &branches { + known.generation = generation; + known.branch = active_branch(report); + } + if let Some(seen) = agents + .ok() + .and_then(Result::ok) + .as_ref() + .and_then(agents_seen) + { + known.agents = Some(seen); + } + if let Some(merging) = conflicts + .ok() + .and_then(Result::ok) + .and_then(|answer| answer.report) + .map(|report| { + report.record.is_some_and(|record| { + matches!(record.state, kin_model::MergeTransactionState::InProgress) + }) + }) + { + known.merge_in_progress = Some(merging); + } + if let Ok(activity) = activity { + known.activity = Some(activity); + } + } + start_reading(&client, &slot, generation, started); + true + } + _ => false, + }; + { + let mut asking = locked(&slot.asking); + asking.in_flight = false; + asking.finished = Some((tokio::time::Instant::now(), answered)); + } + slot.announce(); +} + async fn observe( - hub: &Hub, + hub: &Arc, listed: &Listed, standing: &kin_core::history_state::HistoryStanding, ) -> Observed { - use super::daemon_activity::{activity_without_health, read_activity, Reachability}; + use super::daemon_activity::{activity_without_health, Reachability}; let since = tokio::time::Instant::now(); let deadline = since + OBSERVE_BUDGET; @@ -1673,65 +2695,101 @@ async fn observe( .and_then(|record| recorded_time(&record.at)); let slot = hub.home.slot(&listed.entry.key); let history = super::daemon_activity::running_history(standing); - let Ok(Some(client)) = tokio::time::timeout_at(deadline, running_daemon(&listed.layout)).await - else { - // No daemon answers. A history derivation runs in a worker of its - // own, so it can still be what this repository is doing. - let head = locked(&slot.reading).head_change; + let (pid, _) = crate::daemon_client::repo_daemon_recorded_endpoint(listed.layout.root()); + let alive = pid.is_some_and(crate::daemon_client::is_process_alive); + if !alive { + // No daemon is running. A history derivation runs in a worker of + // its own, so it can still be what this repository is doing. + let known = locked(&slot.known).clone(); + let (counted, head) = { + let reading = locked(&slot.reading); + (reading.counted, reading.head_change) + }; + let carried = unanswered(&known, counted, false, chrono::Utc::now()); return Observed { running: false, - branch: None, - working: None, - agents: None, - merge_in_progress: None, + reach: carried.reach, + known_at: carried.known_at, + branch: carried.branch, + working: carried.working, + agents: carried.agents, + merge_in_progress: carried.merge_in_progress, activity: activity_without_health(Reachability::Gone, history.as_ref()), changed_at: last_change(recorded, head, false), }; - }; - let list = super::branch::BranchRequest::List; - let listing = super::conflicts::ConflictsRequest { bodies: false }; - let root = listed.layout.working_dir().display().to_string(); - let (branches, conflicts, agents, activity) = tokio::join!( - tokio::time::timeout_at(deadline, client.branch(&list)), - tokio::time::timeout_at(deadline, client.conflicts(&listing)), - tokio::time::timeout_at(deadline, client.agents()), - tokio::time::timeout_at( - deadline, - read_activity(client.base_url().to_string(), root, Reachability::Answering), - ), - ); - let branches = branches - .ok() - .and_then(Result::ok) - .and_then(|answer| answer.report); - let wanted = branches - .as_ref() - .map(|report| (report.authority_generation, report.workspace_generation)); + } - // Start the reader unless what it would read is already held for this - // generation, then wait for it only as long as this request has. + // Ask the daemon in the background unless a reading is under way or one + // just finished, then wait for it only as long as this request has. let mut moved = slot.moved.subscribe(); { - let mut reading = locked(&slot.reading); - let settled = matches!( - working_from(&reading, wanted, since), - Working::Read { - changed: Some(_), - .. + let mut asking = locked(&slot.asking); + let recent = asking.finished.is_some_and(|(at, answered)| { + answered && since.saturating_duration_since(at) <= ASKED_RECENTLY + }); + if !asking.in_flight && !recent { + asking.in_flight = true; + asking.started = Some(since); + tokio::spawn(ask_daemon( + hub.clone(), + listed.entry.key.clone(), + listed.entry.name.clone(), + listed.layout.clone(), + slot.clone(), + )); + } + } + let fresh = loop { + { + let asking = locked(&slot.asking); + match asking.finished { + Some((at, answered)) if since.saturating_duration_since(at) <= ASKED_RECENTLY => { + break answered; + } + _ if !asking.in_flight => break false, + _ => {} } - ) && wanted.is_some_and(|wanted| reading.head_generation == Some(wanted)); - let may_start = reading - .retry_after - .is_none_or(|after| tokio::time::Instant::now() >= after); - if !settled && !reading.in_flight && may_start { - reading.in_flight = true; - tokio::spawn(read_repository(client.clone(), slot.clone())); } + if tokio::time::timeout_at(deadline, moved.changed()) + .await + .is_err() + { + break false; + } + }; + if !fresh { + let known = locked(&slot.known).clone(); + let (counted, head) = { + let reading = locked(&slot.reading); + (reading.counted, reading.head_change) + }; + let carried = unanswered(&known, counted, true, chrono::Utc::now()); + let activity = match (&carried.reach, &known.activity) { + (Reach::Answering, Some(activity)) => activity.clone(), + _ => activity_without_health(Reachability::NotAnswering, history.as_ref()), + }; + return Observed { + running: true, + reach: carried.reach, + known_at: carried.known_at, + branch: carried.branch, + working: Some(carried.working.unwrap_or(Working::Unknown)), + agents: carried.agents, + merge_in_progress: carried.merge_in_progress, + activity, + changed_at: last_change(recorded, head, false), + }; } + + // The daemon answered. Wait for the count of its working copy at the + // generation it answered at, as long as this request has. + let known = locked(&slot.known).clone(); + let wanted = known.generation; + let counting_since = locked(&slot.asking).started.unwrap_or(since); let (working, head, head_is_current) = loop { { let reading = locked(&slot.reading); - let working = working_from(&reading, wanted, since); + let working = working_from(&reading, wanted, counting_since); let counted = matches!( working, Working::Read { @@ -1751,7 +2809,7 @@ async fn observe( let reading = locked(&slot.reading); let current = wanted.is_some_and(|wanted| reading.head_generation == Some(wanted)); break ( - working_from(&reading, wanted, since), + working_from(&reading, wanted, counting_since), reading.head_change, current, ); @@ -1759,34 +2817,13 @@ async fn observe( }; Observed { running: true, - branch: branches.as_ref().and_then(|report| { - report - .branches - .iter() - .find(|branch| branch.active) - .map(|branch| { - let full = branch.name.to_string(); - full.strip_prefix("refs/heads/") - .unwrap_or(&full) - .to_string() - }) - }), + reach: Reach::Answering, + known_at: None, + branch: known.branch, working: Some(working), - agents: agents - .ok() - .and_then(Result::ok) - .as_ref() - .and_then(agents_seen), - merge_in_progress: conflicts - .ok() - .and_then(Result::ok) - .and_then(|answer| answer.report) - .map(|report| { - report.record.is_some_and(|record| { - matches!(record.state, kin_model::MergeTransactionState::InProgress) - }) - }), - activity: activity.unwrap_or_else(|_| { + agents: known.agents, + merge_in_progress: known.merge_in_progress, + activity: known.activity.unwrap_or_else(|| { activity_without_health(Reachability::NotAnswering, history.as_ref()) }), changed_at: last_change(recorded, head, head_is_current), @@ -1844,6 +2881,7 @@ async fn home(State(hub): State>) -> Response { .changed_at .map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)), "runtime": if observed.running { "running" } else { "idle" }, + "reach": observed.reach.word(), "activity": observed.activity, "history": super::workspace_ui::history_json(standing), }); @@ -1853,6 +2891,9 @@ async fn home(State(hub): State>) -> Response { if let Some(host) = &entry.host { row["host"] = json!(host); } + if let Some(at) = observed.known_at { + row["known_at"] = json!(at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); + } if let Some(branch) = observed.branch { row["branch"] = json!(branch); } @@ -2228,6 +3269,80 @@ mod tests { assert!(recorded_time("yesterday").is_none()); } + #[test] + fn a_daemon_that_does_not_answer_leaves_what_it_last_said_with_when_it_said_it() { + let at = |text: &str| recorded_time(text).unwrap(); + let answered = at("2026-10-14T09:12:00Z"); + let counted = at("2026-10-14T09:11:30Z"); + let agents = json!({ "active": 1, "connected": 1, "names": ["Claude Code"] }); + let known = Known { + at: Some(answered), + generation: Some((7, 9)), + branch: Some("main".to_string()), + agents: Some(agents.clone()), + merge_in_progress: Some(false), + activity: None, + }; + let work = Some((true, 4, counted)); + let later = at("2026-10-14T09:40:00Z"); + + // Busy or wedged: still running, so everything it said is carried, + // said to be as of the oldest reading carried, and never dropped to + // "nothing in motion". + let busy = unanswered(&known, work, true, later); + assert_eq!( + busy, + Carried { + reach: Reach::NotAnswering, + known_at: Some(counted), + branch: Some("main".to_string()), + working: Some(Working::Read { + differs: true, + changed: Some(4) + }), + agents: Some(agents.clone()), + merge_in_progress: Some(false), + } + ); + assert_eq!( + busy.working.unwrap().json(), + json!({ "differs": true, "changed": 4 }) + ); + + // Stopped: the work, branch and merge stay as they were, but no + // agent session can outlive the daemon that held it. + let stopped = unanswered(&known, work, false, later); + assert_eq!(stopped.reach, Reach::Stopped); + assert_eq!(stopped.agents, None); + assert_eq!(stopped.branch.as_deref(), Some("main")); + assert_eq!(stopped.known_at, Some(counted)); + + // Slow to answer again a moment after it last did: still current. + let moment = at("2026-10-14T09:12:40Z"); + let slow = unanswered(&known, None, true, moment); + assert_eq!(slow.reach, Reach::Answering); + assert_eq!(slow.known_at, Some(answered)); + assert_eq!( + slow.working, None, + "a working copy never counted is not guessed" + ); + + // Never heard from: nothing is carried and nothing is claimed. + let never = unanswered(&Known::default(), None, true, later); + assert_eq!(never.reach, Reach::NotAnswering); + assert_eq!(never.known_at, None); + assert_eq!(never.branch, None); + assert_eq!(never.agents, None); + assert_eq!( + unanswered(&Known::default(), None, false, later).reach, + Reach::Stopped + ); + assert_eq!( + [Reach::Answering, Reach::NotAnswering, Reach::Stopped].map(Reach::word), + ["answering", "not_answering", "stopped"] + ); + } + #[test] fn a_stage_is_read_from_what_a_conversion_reports_and_never_from_its_result() { // Every step Kin's conversion announces has a stage here, so a new @@ -2431,7 +3546,7 @@ mod tests { let keep = |errors: bool, line: &str| locked(&heard).push((errors, line.to_string())); let script = "printf 'one\\rtwo\\n'; printf 'refused\\n' >&2; exit 7"; let arguments = ["-c", script].map(OsString::from); - let ended = run_child(OsStr::new("sh"), &arguments, None, false, &keep) + let ended = run_child(OsStr::new("sh"), &arguments, None, false, &[], &keep) .await .unwrap(); assert_eq!(ended, Some(7)); @@ -2445,4 +3560,372 @@ mod tests { assert!(heard.contains(&(true, "refused".to_string()))); assert!(exit_note(7).is_some() && exit_note(8).is_some() && exit_note(1).is_none()); } + + /// A home directory with projects kept the ways people keep them, and + /// with the places a look must leave alone. + fn a_home() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + let make = |path: &str| std::fs::create_dir_all(home.path().join(path)).unwrap(); + let write = |path: &str, text: &str| std::fs::write(home.path().join(path), text).unwrap(); + // A Git project in a code root, with a dependency tree of its own. + make("GitHub/alpha/.git"); + write("GitHub/alpha/.git/HEAD", "ref: refs/heads/trunk\n"); + make("GitHub/alpha/node_modules/dep/.git"); + // A project Git does not hold, kept under its owner's name. + make("GitHub/owner/beta"); + write("GitHub/owner/beta/Cargo.toml", ""); + // Three levels down is further than a look goes. + make("GitHub/owner/group/deep/.git"); + // What tools install, and a directory that is no project. + make("GitHub/node_modules/pkg/.git"); + make("GitHub/notes"); + write("GitHub/notes/todo.txt", ""); + write("GitHub/readme.txt", ""); + // One more working copy of a project that is kept elsewhere. + make("GitHub/alpha-second"); + write( + "GitHub/alpha-second/.git", + "gitdir: ../alpha/.git/worktrees/second\n", + ); + write("GitHub/alpha-second/Cargo.toml", ""); + // A project directly in the home directory, and one inside a + // directory there that is not named like a code root. + make("loose/.git"); + make("stuff/inner/.git"); + // Where a person's documents are, and a hidden directory. + make("Documents/report/.git"); + make(".cache/tool/.git"); + home + } + + fn found_in(home: &Path, look: &Look) -> Vec { + look.spotted + .iter() + .map(|spotted| { + spotted + .path + .strip_prefix(home) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) + .collect() + } + + #[test] + fn a_look_finds_projects_where_people_keep_them_and_nowhere_else() { + let home = a_home(); + #[cfg(unix)] + std::os::unix::fs::symlink( + home.path().join("GitHub/alpha"), + home.path().join("GitHub/linked"), + ) + .unwrap(); + let far = std::time::Instant::now() + Duration::from_secs(60); + let look = look_around(home.path(), far); + let mut found = found_in(home.path(), &look); + found.sort(); + assert_eq!(found, ["GitHub/alpha", "GitHub/owner/beta", "loose"]); + assert!(look.complete); + + // The most recently changed project leads, whichever place holds it. + let day = Duration::from_secs(24 * 60 * 60); + let age = |path: &str, days: u32| { + let time = filetime::FileTime::from_system_time(SystemTime::now() - day * days); + filetime::set_file_mtime(home.path().join(path), time).unwrap(); + }; + for (path, days) in [ + ("GitHub/alpha", 9), + ("GitHub/alpha/.git", 2), + ("GitHub/owner/beta", 5), + ("loose", 7), + ("loose/.git", 8), + ] { + age(path, days); + } + let look = look_around(home.path(), far); + assert_eq!( + found_in(home.path(), &look), + ["GitHub/alpha", "GitHub/owner/beta", "loose"] + ); + + // A look that is out of time says it did not finish, and guesses + // nothing. + let late = look_around(home.path(), std::time::Instant::now()); + assert!(late.spotted.is_empty() && !late.complete); + // A home directory that cannot be read holds no project. + assert!(look_around(&home.path().join("absent"), far) + .spotted + .is_empty()); + } + + #[test] + fn a_directory_is_a_project_by_the_name_of_what_is_at_its_top() { + for name in [ + "package.json", + "Cargo.toml", + "go.mod", + "Makefile", + "App.xcodeproj", + ] { + assert!(marks_a_project(OsStr::new(name)), "{name}"); + } + for name in ["notes.txt", "src", "README.md", "cargo.toml.bak"] { + assert!(!marks_a_project(OsStr::new(name)), "{name}"); + } + for name in [ + "node_modules", + "target", + "Library", + "Documents", + ".git", + ".venv", + ] { + assert!(!looked_into(OsStr::new(name)), "{name}"); + } + assert!(looked_into(OsStr::new("GitHub")) && looked_into(OsStr::new("kin"))); + } + + /// The projects a person is likely to add are listed without a location + /// being typed, each described as `inspect` describes it. + #[tokio::test] + async fn candidates_are_listed_newest_first_with_what_may_be_done_with_each() { + let home = a_home(); + let day = Duration::from_secs(24 * 60 * 60); + for (path, days) in [ + ("GitHub/alpha", 1), + ("GitHub/alpha/.git", 1), + ("GitHub/owner/beta", 2), + ("loose", 3), + ("loose/.git", 3), + ] { + let time = filetime::FileTime::from_system_time(SystemTime::now() - day * days); + filetime::set_file_mtime(home.path().join(path), time).unwrap(); + } + let listener = review_ui::bind(Some(0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let hub = Hub::new(port, super::super::workspace_ui::empty_catalog(), None); + hub.home.look_in(home.path()); + let router = super::super::workspace_ui::hub_router(hub.clone()); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let client = reqwest::Client::new(); + let origin = format!("http://127.0.0.1:{port}"); + + let listed: Value = client + .get(format!("{origin}/api/home/candidates")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(listed["schema"], "kin.workspace.home-candidates.v1"); + assert_eq!(listed["found"], 3); + assert_eq!(listed["complete"], true); + let rows = listed["candidates"].as_array().unwrap(); + let names: Vec<&str> = rows.iter().filter_map(|row| row["name"].as_str()).collect(); + assert_eq!(names, ["alpha", "beta", "loose"]); + let alpha = &rows[0]; + assert_eq!(alpha["kind"], "git"); + assert_eq!(alpha["git"]["branch"], "trunk"); + assert_eq!(alpha["actions"], json!(["convert"])); + assert_eq!(alpha["registered"], false); + assert!(alpha["modified_at"] + .as_str() + .is_some_and(|at| at.ends_with('Z'))); + assert!(alpha["note"] + .as_str() + .unwrap() + .starts_with("Kin reads this project")); + // A project is added by the location this answer gives for it. + let resolved = home.path().canonicalize().unwrap(); + assert_eq!( + alpha["path"], + resolved.join("GitHub/alpha").display().to_string() + ); + let beta = &rows[1]; + assert_eq!(beta["kind"], "plain"); + assert_eq!(beta["actions"], json!(["convert"])); + assert!(beta.get("git").is_none()); + // A caller may ask for fewer, and is still told how many were found. + let settled: Value = client + .get(format!("{origin}/api/home/candidates?limit=1")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(settled["candidates"].as_array().unwrap().len(), 1); + assert_eq!(settled["found"], 3); + assert!(settled["counting"].is_u64()); + + let refused = client + .get(format!("{origin}/api/home/candidates?path=/")) + .send() + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + server.abort(); + } + + fn listed_agent(session: &str, vendor: &str, state: &str, at: &str) -> Value { + json!({ + "session_id": session, "vendor": vendor, "client": vendor, "state": state, + "started_at": "2026-10-13T08:00:00Z", "last_activity_at": at, + }) + } + + #[test] + fn the_last_session_of_a_coding_agent_is_the_newest_any_repository_showed() { + let mut sessions = HashMap::new(); + // Two repositories are running. Claude Code is at work in one, and + // sits idle in the other beside Codex. + let atlas = json!({"agents": [ + listed_agent("S-ONE", "claude-code", "active", "2026-10-13T10:00:00Z"), + listed_agent("s-two", "my-helper", "active", "2026-10-13T11:00:00Z"), + ]}); + let harbor = json!({"agents": [ + listed_agent("s-three", "codex-mcp-client", "idle", "2026-10-13T09:00:00Z"), + listed_agent("s-four", "claude-code", "idle", "2026-10-13T09:30:00Z"), + ]}); + sessions_listed(&mut sessions, "k1", "atlas", Some(&atlas)); + sessions_listed(&mut sessions, "k2", "harbor", Some(&harbor)); + let last = last_sessions(&sessions); + assert_eq!( + last["claude-code"], + json!({ + "repository": {"key": "k1", "name": "atlas", "url": "/repositories/k1/workspace/"}, + "at": "2026-10-13T10:00:00Z", + "state": "active", + }) + ); + assert_eq!(last["codex"]["state"], "idle"); + assert_eq!(last["codex"]["repository"]["name"], "harbor"); + // An agent Kin does not connect is no coding agent's session, and + // no session is named by what identifies it. + assert_eq!(last.len(), 2); + assert!(!json!(last).to_string().to_lowercase().contains("s-one")); + + // A session its repository no longer lists has ended, and is still + // the last one known when nothing newer is. + sessions_listed(&mut sessions, "k1", "atlas", Some(&json!({"agents": []}))); + let last = last_sessions(&sessions); + assert_eq!(last["claude-code"]["state"], "ended"); + assert_eq!(last["claude-code"]["repository"]["name"], "atlas"); + assert_eq!(last["claude-code"]["at"], "2026-10-13T10:00:00Z"); + + // An answer that is not a list says nothing either way. A repository + // that is no longer running has nothing connected. + sessions_listed( + &mut sessions, + "k2", + "harbor", + Some(&json!({"error": "starting"})), + ); + assert_eq!(last_sessions(&sessions)["codex"]["state"], "idle"); + sessions_listed(&mut sessions, "k2", "harbor", None); + assert_eq!(last_sessions(&sessions)["codex"]["state"], "ended"); + assert_eq!(last_sessions(&sessions)["claude-code"]["state"], "ended"); + } + + #[test] + fn a_change_a_coding_agent_made_is_a_record_of_its_session() { + let mut sessions = HashMap::new(); + let noon = recorded_time("2026-10-13T12:00:00Z").unwrap(); + let author = "gemini-cli-mcp-client/Gemini "; + session_committed(&mut sessions, "k2", "harbor", author, noon); + // A person's change, and an agent's that Kin does not connect. + session_committed( + &mut sessions, + "k2", + "harbor", + "Ada ", + noon, + ); + session_committed( + &mut sessions, + "k2", + "harbor", + "my-helper/x ", + noon, + ); + let last = last_sessions(&sessions); + assert_eq!(last.len(), 1); + assert_eq!(last["gemini-cli"]["state"], "ended"); + assert_eq!(last["gemini-cli"]["at"], "2026-10-13T12:00:00Z"); + + // The session is still connected: its change moves when it was last + // at work and ends nothing. + let listed = json!({"agents": [ + listed_agent("s-five", "gemini-cli-mcp-client", "idle", "2026-10-13T11:00:00Z"), + ]}); + sessions_listed(&mut sessions, "k2", "harbor", Some(&listed)); + let last = last_sessions(&sessions); + assert_eq!(last["gemini-cli"]["state"], "idle"); + assert_eq!(last["gemini-cli"]["at"], "2026-10-13T12:00:00Z"); + } + + #[test] + fn remembered_sessions_stay_bounded_and_keep_the_connected_ones() { + let mut sessions = HashMap::new(); + let connected = json!({"agents": [ + listed_agent("still-here", "codex", "idle", "2020-01-01T00:00:00Z"), + ]}); + sessions_listed(&mut sessions, "k1", "atlas", Some(&connected)); + let start = recorded_time("2026-10-13T00:00:00Z").unwrap(); + for index in 0..SESSIONS_KEPT + 20 { + let author = format!("claude-code/Claude Code "); + let at = start + chrono::Duration::seconds(index as i64); + session_committed(&mut sessions, "k2", "harbor", &author, at); + } + assert_eq!(sessions.len(), SESSIONS_KEPT); + assert!(sessions.contains_key("still-here")); + assert!(!sessions.contains_key("ended-0")); + assert!(sessions.contains_key(&format!("ended-{}", SESSIONS_KEPT + 19))); + } + + /// The coding agents page is told, for each agent, the last session Kin + /// has a record of, or that it has none. + #[tokio::test] + async fn every_coding_agent_says_its_last_session_or_that_kin_has_none() { + let listener = review_ui::bind(Some(0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let hub = Hub::new(port, super::super::workspace_ui::empty_catalog(), None); + let working = json!({"agents": [ + listed_agent("s-one", "claude-code", "active", "2026-10-13T10:00:00Z"), + ]}); + sessions_listed( + &mut locked(&hub.home.sessions), + "k1", + "atlas", + Some(&working), + ); + let router = super::super::workspace_ui::hub_router(hub.clone()); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let answer: Value = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/api/home/clients")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(answer["schema"], "kin.setup-clients.v1"); + let clients = answer["clients"].as_array().unwrap(); + assert!(clients.len() >= 8); + for client in clients { + let last = client.get("last_session").expect("every agent says"); + if client["id"] == "claude-code" { + assert_eq!(last["state"], "active"); + assert_eq!(last["repository"]["name"], "atlas"); + assert_eq!(last["at"], "2026-10-13T10:00:00Z"); + } else { + assert!(last.is_null(), "{client}"); + } + // What the list already said of each agent is still said. + assert!(client["detected"].is_boolean() && client["connected"].is_boolean()); + } + server.abort(); + } } diff --git a/crates/kin-cli/src/commands/workspace_remotes.rs b/crates/kin-cli/src/commands/workspace_remotes.rs new file mode 100644 index 000000000..0f6cd3dfd --- /dev/null +++ b/crates/kin-cli/src/commands/workspace_remotes.rs @@ -0,0 +1,2535 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Where a repository stands against its remotes, and push and pull from the +//! local app. +//! +//! Reading moves nothing on either side. A push's standing comes from the +//! same negotiation `kin remote plan-push` runs in the daemon; a pull's from +//! the remote's own advertisement of its refs, and, for a peer daemon, from +//! the remote's own reading of the changes between the two heads. Counts are +//! changes and entities, read from the graph on whichever side holds them. +//! What Kin cannot ask, for want of a credential or an answer, is said as +//! that, never filled in. +//! +//! A push or a pull is sent once. Its answer names an outcome, and an answer +//! that is lost is reported as unknown and never sent again behind the page. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use kin_core::{KinConfig, KinLayout, RemoteHostKind, RemoteTransportKind}; +use kin_model::{OperationId, RefName, RepositoryId, SemanticChangeId}; +use kin_remote::repository_transfer::{RepositoryRefAdvertisement, RepositoryTransferError}; +use kin_remote::repository_transfer_http::{ + HttpRepositoryTransferTransport, RepositoryTransferEndpoint, +}; +use kin_remote::repository_transfer_negotiation::{ + RepositoryTransferDirection, RepositoryTransferPlan, RepositoryTransferTransport, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::review_ui::{self, UiState}; +use super::transfer::{CommandTransferRequest, CommandTransferResponse, WorkspaceFollow}; +use crate::daemon_client::{DaemonClient, TransferDispatch}; + +/// The schema every remotes answer carries. +pub(super) const REMOTES_SCHEMA: &str = "kin.workspace.remotes.v1"; + +/// How long one remote is given to answer a reading. +const REMOTE_READ_TIMEOUT: Duration = Duration::from_secs(15); + +/// The most changes whose entities a reading counts. Past it the change count +/// is still given, and the entity count is said to be left uncounted. +const ENTITY_COUNT_MAX_CHANGES: usize = 1000; + +/// How many sent operations a server remembers, so a second request with the +/// same operation is refused rather than sent again. +const SENT_KEPT: usize = 256; + +/// How long an answer about Git on this machine is kept. +const GIT_READY_KEPT: Duration = Duration::from_secs(30); + +pub(super) fn routes() -> Router> { + Router::new() + .route("/api/workspace/remotes", get(serve_remotes)) + .route("/api/workspace/push", post(push)) + .route("/api/workspace/pull", post(pull)) + .route("/api/workspace/fetch", post(fetch)) +} + +/// The pushes and pulls one repository's page has sent. +pub(super) struct Transfers { + /// Held while a push or a pull runs, so two from this app never race on + /// one repository. + running: Arc>, + /// Each operation sent, oldest first, with its outcome once it is known. + sent: Mutex)>>, + /// Whether Git can carry a branch for this project, and when that was + /// last asked, so a session that is read often starts Git seldom. + git: Mutex)>>, + /// Whether this branch can update from Git, as the daemon last said and + /// when, and the ask running now, if one is. A session answers from what + /// is known and never waits for an ask; see + /// [`super::workspace_git_remote::update_ready_known`]. + update: Mutex, +} + +/// What is known of whether this branch can update from Git. +#[derive(Default)] +struct UpdateReadiness { + known: Option<(Instant, Result<(), String>)>, + asking: Option>>>, +} + +/// What a caller finds when it needs to know whether this branch can update +/// from Git. +pub(super) enum UpdateAsk { + /// An ask is running: wait for its answer. + Join(tokio::sync::watch::Receiver>>), + /// None is: this caller asks, and hands its answer to whoever joined. + Lead(tokio::sync::watch::Sender>>), +} + +/// How long the daemon's answer on updating from Git is taken as current. +pub(super) const UPDATE_READY_KEPT: Duration = Duration::from_secs(30); + +impl Default for Transfers { + fn default() -> Self { + Self { + running: Arc::new(tokio::sync::Mutex::new(())), + sent: Mutex::new(VecDeque::new()), + git: Mutex::new(None), + update: Mutex::new(UpdateReadiness::default()), + } + } +} + +impl Transfers { + fn sent(&self) -> std::sync::MutexGuard<'_, VecDeque<(OperationId, Option<&'static str>)>> { + self.sent + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Whether `operation` was sent before, and how it ended when known. + fn earlier(&self, operation: OperationId) -> Option> { + self.sent() + .iter() + .find(|(sent, _)| *sent == operation) + .map(|(_, outcome)| *outcome) + } + + /// What Git last said about carrying a branch here, while it is fresh. + pub(super) fn git_ready(&self) -> Option> { + let known = self + .git + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + known + .as_ref() + .filter(|(asked, _)| asked.elapsed() < GIT_READY_KEPT) + .map(|(_, answer)| answer.clone()) + } + + pub(super) fn remember_git_ready(&self, answer: Result<(), String>) { + *self + .git + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((Instant::now(), answer)); + } + + fn update(&self) -> std::sync::MutexGuard<'_, UpdateReadiness> { + self.update + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// What the daemon last said about updating this branch from Git, and + /// how long ago, however old. + pub(super) fn update_ready(&self) -> Option<(Duration, Result<(), String>)> { + self.update() + .known + .as_ref() + .map(|(asked, answer)| (asked.elapsed(), answer.clone())) + } + + /// Whether an ask about updating from Git is running now. + pub(super) fn update_asking(&self) -> bool { + self.update() + .asking + .as_ref() + .is_some_and(|asking| asking.has_changed().is_ok()) + } + + /// Join the ask about updating from Git that is running, or lead one. + pub(super) fn ask_update(&self) -> UpdateAsk { + let mut update = self.update(); + if let Some(asking) = &update.asking { + // An ask whose asker went away without answering answers nobody. + if asking.has_changed().is_ok() { + return UpdateAsk::Join(asking.clone()); + } + } + let (sender, receiver) = tokio::sync::watch::channel(None); + update.asking = Some(receiver); + UpdateAsk::Lead(sender) + } + + /// Keep the answer an ask led with [`Self::ask_update`] brought, and hand + /// it to every caller that joined. + pub(super) fn answer_update( + &self, + sender: tokio::sync::watch::Sender>>, + answer: Result<(), String>, + ) { + { + let mut update = self.update(); + update.known = Some((Instant::now(), answer.clone())); + update.asking = None; + } + sender.send_replace(Some(answer)); + } + + /// Record that `operation` is about to be sent. + pub(super) fn mark_sent(&self, operation: OperationId) { + let mut sent = self.sent(); + sent.push_back((operation, None)); + while sent.len() > SENT_KEPT { + sent.pop_front(); + } + } + + /// Record how `operation` ended. + fn settle(&self, operation: OperationId, outcome: &'static str) { + if let Some(entry) = self.sent().iter_mut().find(|(sent, _)| *sent == operation) { + entry.1 = Some(outcome); + } + } +} + +/// Where a credential for a remote came from. Never the credential itself. +#[derive(Clone)] +enum Credential { + /// Another Kin repository on this machine answers at the remote's + /// address, and its own local credential reaches it. + ThisMachine { + token: String, + layout: KinLayout, + }, + /// Named by the environment this app was started in. + Environment(String), + /// A saved credential that opens without a prompt. + Saved(String), + None, +} + +impl Credential { + fn label(&self) -> &'static str { + match self { + Self::ThisMachine { .. } => "this_machine", + Self::Environment(_) => "environment", + Self::Saved(_) => "saved", + Self::None => "none", + } + } + + fn token(&self) -> Option<&str> { + match self { + Self::ThisMachine { token, .. } | Self::Environment(token) | Self::Saved(token) => { + Some(token) + } + Self::None => None, + } + } +} + +/// How Kin addresses one native remote's transfer seam. +#[derive(Clone)] +struct Peer { + base_url: String, + /// A hosted remote's organization. `None` is a peer daemon. + organization_id: Option, + credential: Credential, +} + +/// One remote this repository names, as its configuration records it. +#[derive(Clone)] +pub(super) struct Remote { + pub(super) name: String, + host: RemoteHostKind, + native: bool, + default: bool, + /// The remote's address without any credential it carried. + address: Option, + /// A native remote's transfer peer, or why Kin cannot address one. A Git + /// remote has none: Git reaches it. + peer: Result, +} + +/// The remotes of one repository, and the one a push or pull uses when the +/// page names none. +struct Remotes { + remotes: Vec, + default: Result, +} + +/// An address fit to show: a URL without its credentials, or nothing. +fn shown_address(url: Option<&str>) -> Option { + let url = url.map(str::trim).filter(|url| !url.is_empty())?; + kin_remote::repository_transfer::redacted_remote_address(url) +} + +/// The credential Kin can use for `base_url` without asking anyone. Another +/// repository on this machine whose daemon records this loopback port comes +/// first: it is the most specific, and the daemon is checked to be that +/// repository's before its credential is sent. +fn credential_for(base_url: &str, this_machine: &[KinLayout]) -> Credential { + if crate::daemon_client::endpoint_is_loopback(base_url) { + let port = url::Url::parse(base_url) + .ok() + .and_then(|url| url.port_or_known_default()); + let found = this_machine.iter().find_map(|layout| { + let (_, recorded) = crate::daemon_client::repo_daemon_recorded_endpoint(layout.root()); + if recorded.is_none() || recorded != port { + return None; + } + let token = crate::daemon_client::resolve_daemon_auth_token_for_layout(layout)?; + Some(Credential::ThisMachine { + token, + layout: layout.clone(), + }) + }); + if let Some(found) = found { + return found; + } + } + if let Some(token) = super::remote::native_remote_bearer_token_from_environment(base_url) { + return Credential::Environment(token); + } + match super::auth::saved_bearer_token_without_prompt(base_url) { + Some(token) => Credential::Saved(token), + None => Credential::None, + } +} + +/// Where a native remote's transfer seam is, as `kin push` resolves it. +fn native_peer( + host: RemoteHostKind, + url: Option<&str>, + this_machine: &[KinLayout], +) -> Result { + let url = url + .map(str::trim) + .filter(|url| !url.is_empty()) + .ok_or_else(|| "This remote has no transfer address.".to_string())?; + let (base_url, located_organization) = super::transfer::peer_base_and_organization(url); + let organization_id = match host { + RemoteHostKind::KinLab => Some( + located_organization + .or_else(|| { + std::env::var("KIN_ORG_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) + .ok_or_else(|| { + "This KinLab remote's address names no organization, so Kin cannot address \ + its repository." + .to_string() + })?, + ), + _ => None, + }; + if kin_remote::http_transport::validate_credential_url(&base_url).is_err() { + return Err( + "This remote's address is not one Kin sends a credential to: it must be HTTPS, or \ + HTTP on this machine's own loopback address." + .to_string(), + ); + } + Ok(Peer { + credential: credential_for(&base_url, this_machine), + base_url, + organization_id, + }) +} + +/// Every remote the repository's configuration names. Blocking: it reads the +/// configuration and, for a loopback address, the daemon records of this +/// machine's other repositories. +fn read_remotes(layout: &KinLayout, this_machine: &[KinLayout]) -> Result { + let config = KinConfig::load_or_default(&layout.config_path()) + .map_err(|error| format!("Kin could not read this repository's remotes: {error:#}"))?; + let mut remotes = Vec::new(); + for remote in &config.remote.refs { + let native = remote.transport == RemoteTransportKind::NativeKin; + remotes.push(Remote { + name: remote.name.clone(), + host: remote.host, + native, + default: config.remote.default.as_deref() == Some(remote.name.as_str()), + address: shown_address(remote.url.as_deref()), + peer: if native { + native_peer(remote.host, remote.url.as_deref(), this_machine) + } else { + Err(GIT_REACHES_IT.to_string()) + }, + }); + } + for sealed in &config.git.remotes { + if remotes.iter().any(|known| known.name == sealed.name) { + continue; + } + let host = sealed.host_kind(); + remotes.push(Remote { + name: sealed.name.clone(), + host, + native: false, + default: false, + address: shown_address(sealed.publish_url()), + peer: Err(GIT_REACHES_IT.to_string()), + }); + } + let default = default_remote(config.remote.default.as_deref(), &remotes); + Ok(Remotes { remotes, default }) +} + +/// The remote a push or pull uses when the page names none: the configured +/// default, else the only Kin remote, else the only Git remote. +fn default_remote(configured: Option<&str>, remotes: &[Remote]) -> Result { + if let Some(name) = configured { + if remotes.iter().any(|remote| remote.name == name) { + return Ok(name.to_string()); + } + } + let of_kind = |native: bool| -> Vec<&Remote> { + remotes + .iter() + .filter(|remote| remote.native == native) + .collect() + }; + match of_kind(true).as_slice() { + [] => {} + [only] => return Ok(only.name.clone()), + many => { + return Err(format!( + "{} Kin remotes are set up and none is the default, so name the one to use.", + many.len() + )) + } + } + match of_kind(false).as_slice() { + [] => Err("No remote is set up for this repository.".to_string()), + [only] => Ok(only.name.clone()), + many => Err(format!( + "{} Git remotes are set up and none is the default, so name the one to use.", + many.len() + )), + } +} + +/// Why a Git remote has no transfer peer of Kin's. +const GIT_REACHES_IT: &str = "This is a Git remote, which Git reaches."; + +/// Why a push or pull cannot run for this repository at all, or nothing. +/// +/// A store that holds part of its history cannot send or take it as if it +/// were whole. What it holds is said as a count when the record has one, and +/// the rest is said to be on its way only while a derivation is actually +/// reading it or has read it, so a store whose reading has stopped, or never +/// started, is not described as filling in. +fn history_reason(standing: &kin_core::history_state::HistoryStanding) -> Option { + use kin_core::history_state::{DerivationState, HistoryStanding}; + standing.sentence()?; + let (held, staged) = match standing { + HistoryStanding::Partial { record, progress } => ( + match record.reachable_commits { + Some(total) => format!( + "Kin holds {} of this repository's {} commits", + grouped(record.admitted_commits), + grouped(total) + ), + None => "Kin holds only part of this repository's history".to_string(), + }, + progress + .as_ref() + .is_some_and(|progress| progress.state == DerivationState::Staged), + ), + _ => ( + "Kin could not read how much of this repository's history it holds".to_string(), + false, + ), + }; + let rest = if super::daemon_activity::running_history(standing).is_some() { + " and is reading the rest now" + } else if staged { + " and has read the rest, which it takes in at its next quiet point" + } else { + "" + }; + Some(format!( + "{held}{rest}, so it cannot be pushed or pulled until it holds all of it." + )) +} + +/// A count as a reader writes it: `4,673`. +fn grouped(count: u64) -> String { + let digits = count.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index) % 3 == 0 { + out.push(','); + } + out.push(digit); + } + out +} + +/// Whether a remote can be pushed to and pulled from as far as this machine +/// can tell without asking it, or why not. +fn usable(remote: &Remote, history: Option<&str>) -> Result<(), String> { + let peer = remote.peer.as_ref().map_err(Clone::clone)?; + if let Some(reason) = history { + return Err(reason.to_string()); + } + if matches!(peer.credential, Credential::None) { + return Err(format!( + "Kin holds no credential for {} that it can read without asking, so it cannot reach \ + it.", + remote.name + )); + } + Ok(()) +} + +/// Whether a push and a pull can run against `remote` as far as this machine +/// can tell without asking it, each with why not. A Git remote is published +/// to when Git can carry the branch, and updated only when exact local +/// Git-origin admission can advance it. +fn availability( + remote: &Remote, + history: Option<&str>, + git: &Result<(), String>, + git_pull: &Result<(), String>, +) -> (Result<(), String>, Result<(), String>) { + if remote.native { + let usable = usable(remote, history); + return (usable.clone(), usable); + } + let push = match history { + Some(reason) => Err(reason.to_string()), + None => git.clone(), + }; + let pull = match history { + Some(reason) => Err(reason.to_string()), + None => git + .as_ref() + .map_err(Clone::clone) + .and_then(|_| git_pull.clone()), + }; + (push, pull) +} + +/// The session's `remote_push` and `remote_pull`: whether a push and a pull +/// can work for this repository, from its configuration, its credentials and +/// what Git said, each with why not. No remote is asked. Blocking. +pub(super) fn transfer_capability( + layout: &KinLayout, + history: &kin_core::history_state::HistoryStanding, + this_machine: &[KinLayout], + git: &Result<(), String>, + git_pull: &Result<(), String>, +) -> (Result<(), String>, Result<(), String>, bool) { + let remotes = match read_remotes(layout, this_machine) { + Ok(remotes) => remotes, + Err(reason) => return (Err(reason.clone()), Err(reason), false), + }; + let history = history_reason(history); + let none = || Err("No remote is set up for this repository.".to_string()); + let (mut push, mut pull) = (none(), none()); + let mut fetch = false; + for (index, remote) in remotes.remotes.iter().enumerate() { + let (can_push, can_pull) = availability(remote, history.as_deref(), git, git_pull); + fetch |= remote.native && can_pull.is_ok(); + // The first remote's reason stands until some remote can. + if can_push.is_ok() || (index == 0 && push.is_err()) { + push = can_push; + } + if can_pull.is_ok() || (index == 0 && pull.is_err()) { + pull = can_pull; + } + } + (push, pull, fetch) +} + +/// The repositories this machine can open, for a credential of their own. +async fn machine_repositories(state: &Arc) -> Vec { + let Some(catalog) = state.catalog.clone() else { + return Vec::new(); + }; + tokio::task::spawn_blocking(move || { + catalog + .read() + .repositories + .into_iter() + .map(|listed| listed.layout) + .collect() + }) + .await + .unwrap_or_default() +} + +/// The branch this workspace is on, its head, and the repository's identity. +pub(super) struct LocalBranch { + pub(super) repository_id: RepositoryId, + /// `None` for a detached workspace. + pub(super) branch: Option, + branches: Vec<(RefName, Option)>, +} + +impl LocalBranch { + pub(super) fn head_of(&self, name: &RefName) -> Option { + self.branches + .iter() + .find(|(branch, _)| branch == name) + .and_then(|(_, head)| *head) + } +} + +pub(super) async fn local_branch(client: &DaemonClient) -> anyhow::Result { + use anyhow::Context as _; + let report = client + .branch(&super::branch::BranchRequest::List) + .await? + .report + .context("the daemon answered the branch list without its report")?; + let branch = match &report.workspace_head { + kin_model::WorkspaceHead::Symbolic { target } if target.is_branch() => Some(target.clone()), + _ => None, + }; + Ok(LocalBranch { + repository_id: report.repository_id, + branch, + branches: report + .branches + .into_iter() + // A Git-origin branch targets its Git commit and a native one its + // change; the daemon resolves either to the change it stands on. + .map(|entry| (entry.name, entry.change_id)) + .collect(), + }) +} + +pub(super) fn short_ref(name: &RefName) -> String { + let full = name.to_string(); + full.strip_prefix("refs/heads/") + .unwrap_or(&full) + .to_string() +} + +fn transfer_endpoint(peer: &Peer, timeout: Option) -> RepositoryTransferEndpoint { + let mut endpoint = RepositoryTransferEndpoint::new(peer.base_url.clone()); + if let Some(token) = peer.credential.token() { + endpoint = endpoint.with_auth(token); + } + if let Some(organization) = &peer.organization_id { + endpoint = endpoint.with_organization(organization.clone()); + } + if let Some(timeout) = timeout { + endpoint = endpoint.with_timeout(timeout.as_secs()); + } + endpoint +} + +/// The remote's own advertisement of its refs. Reads, moves nothing. +async fn advertisement( + peer: &Peer, + repository_id: &RepositoryId, +) -> Result { + let endpoint = transfer_endpoint(peer, Some(REMOTE_READ_TIMEOUT)); + let repository_id = repository_id.clone(); + tokio::task::spawn_blocking(move || { + HttpRepositoryTransferTransport::new(endpoint).advertise_refs(&repository_id) + }) + .await + .unwrap_or_else(|error| { + Err(RepositoryTransferError::Storage(format!( + "the reading did not finish: {error}" + ))) + }) +} + +fn advertised_head( + advertisement: &RepositoryRefAdvertisement, + name: &RefName, +) -> Option { + advertisement + .refs + .iter() + .find(|entry| &entry.name == name) + .map(|entry| entry.head) +} + +/// A remote's refusal or silence, in words. +fn remote_reason(error: &RepositoryTransferError) -> String { + match error { + RepositoryTransferError::Unauthenticated(_) => { + "The remote refused the credential Kin sent.".to_string() + } + RepositoryTransferError::Storage(message) if message.contains("transport failed") => { + "The remote could not be reached.".to_string() + } + RepositoryTransferError::Invalid(message) if message.contains("HTTP 404") => { + "The remote does not hold this repository.".to_string() + } + other => format!("The remote did not answer the reading: {other}"), + } +} + +/// Why a pull cannot run when each side holds changes the other lacks. +const DIVERGED_PULL: &str = "This repository holds changes the remote does not, and the remote \ + holds changes this repository does not. Kin moves only fast-forwards."; + +/// The local daemon's refusal of a negotiation, in words. +fn daemon_reason(status: u16, body: &str) -> String { + if body.contains("HTTP 401") { + return "The remote refused the credential Kin sent.".to_string(); + } + if body.contains("transport failed") { + return "The remote could not be reached.".to_string(); + } + if body.contains("(HTTP 404)") { + return "The remote does not hold this repository.".to_string(); + } + if body.contains("identical imported-Git authority") { + return "This repository's history was converted from Git, and the remote's history did \ + not start from the same conversion. Kin moves history only between replicas that \ + share it." + .to_string(); + } + if body.contains("is not an ancestor of local head") { + return "The remote holds changes this repository does not. Kin moves only \ + fast-forwards, so those changes have to come here first." + .to_string(); + } + if body.contains("is not an ancestor of source head") { + return DIVERGED_PULL.to_string(); + } + let body = body.trim(); + if body.is_empty() { + format!("Kin refused with HTTP {status}.") + } else { + format!("Kin refused: {body}") + } +} + +/// Changes, entities and how the entities changed, from a semantic diff. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) struct RangeCount { + pub(super) ahead: usize, + pub(super) behind: usize, + pub(super) entities: usize, + added: usize, + modified: usize, + removed: usize, +} + +fn range_count(diff: &Value) -> Option { + let entities = diff.get("entities")?.as_array()?; + let kind = |wanted: &str| { + entities + .iter() + .filter(|row| row.get("change_type").and_then(Value::as_str) == Some(wanted)) + .count() + }; + Some(RangeCount { + ahead: usize::try_from(diff.get("ahead")?.as_u64()?).ok()?, + behind: usize::try_from(diff.get("behind")?.as_u64()?).ok()?, + entities: entities.len(), + added: kind("added"), + modified: kind("modified"), + removed: kind("removed"), + }) +} + +pub(super) fn entity_changes(count: &RangeCount) -> Value { + json!({"added":count.added,"modified":count.modified,"removed":count.removed}) +} + +/// One graph's reading of the range between two changes. `Err(None)` is a +/// graph that does not hold one of them, `Err(Some(reason))` one that did not +/// answer. +pub(super) type RangeReading = Result>; + +/// The local graph's reading of what lies between two changes. +pub(super) async fn local_range_reading( + client: &DaemonClient, + repository_id: &RepositoryId, + base: SemanticChangeId, + head: SemanticChangeId, +) -> RangeReading { + match client + .repository_semantic_diff(repository_id.as_str(), &base.to_string(), &head.to_string()) + .await + { + Ok(Ok(diff)) => range_count(&diff).ok_or_else(|| { + Some("This repository's daemon answered a reading Kin could not read.".to_string()) + }), + // This graph could not resolve a read point: it does not hold it. + Ok(Err((404 | 422, _))) => Err(None), + Ok(Err((status, _))) => Err(Some(format!( + "This repository's daemon answered HTTP {status}." + ))), + Err(_) => Err(Some( + "This repository's daemon did not answer the reading.".to_string(), + )), + } +} + +/// [`local_range_reading`] for a caller that only counts when it can. +pub(super) async fn local_range( + client: &DaemonClient, + repository_id: &RepositoryId, + base: SemanticChangeId, + head: SemanticChangeId, +) -> Option { + local_range_reading(client, repository_id, base, head) + .await + .ok() +} + +/// What a pull would bring, as far as this repository's own graph settles +/// it, from its reading of the range from the remote's head to its own. A +/// remote head this graph holds is never something a pull brings. `None` +/// means this graph does not hold the remote's head, so the remote is asked. +fn pull_settled_here(here: &RangeReading) -> Option { + match here { + // The remote's head is an ancestor of this repository's. + Ok(count) if count.behind == 0 => Some(Side::new("nothing_to_bring")), + // Held here, and each head has changes the other lacks. + Ok(_) => Some(Side::new("blocked").because(DIVERGED_PULL)), + Err(Some(reason)) => Some(Side::new("unknown").because(reason.clone())), + Err(None) => None, + } +} + +/// What a pull would bring, from the remote's reading of the range from this +/// repository's head to its own, once this repository is known not to hold +/// the remote's head. +fn pull_settled_there(there: RangeReading) -> Side { + match there { + Ok(count) if count.behind == 0 => { + let mut side = Side::new("would_bring"); + side.changes = Some(count.ahead); + side.entities = Some(count); + side + } + // The remote does not hold this repository's head either, or holds + // it off its own line. + Ok(_) | Err(None) => Side::new("blocked").because(DIVERGED_PULL), + Err(Some(reason)) => Side::new("unknown").because(reason), + } +} + +/// What a peer daemon's graph reads between two read points. +async fn remote_range( + peer: &Peer, + repository_id: &RepositoryId, + base: SemanticChangeId, + head: SemanticChangeId, +) -> RangeReading { + let client = reqwest::Client::builder() + .timeout(REMOTE_READ_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| { + Some(format!( + "Kin could not build a reader for the remote: {error}" + )) + })?; + let mut request = client + .get(format!( + "{}/repos/{}/semantic-diff", + peer.base_url, + crate::daemon_client::urlencoding::encode(repository_id.as_str()) + )) + .query(&[("base", base.to_string()), ("head", head.to_string())]); + if let Some(token) = peer.credential.token() { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .map_err(|_| Some("The remote could not be reached.".to_string()))?; + match response.status().as_u16() { + 200..=299 => {} + 401 => { + return Err(Some( + "The remote refused the credential Kin sent.".to_string(), + )) + } + // The remote could not resolve a read point: it does not hold it. + 404 | 422 => return Err(None), + status => return Err(Some(format!("The remote answered HTTP {status}."))), + } + let diff: Value = response + .json() + .await + .map_err(|_| Some("The remote's answer could not be read.".to_string()))?; + range_count(&diff).ok_or_else(|| Some("The remote's answer could not be read.".to_string())) +} + +/// One side of a reading. +pub(super) struct Side { + pub(super) state: &'static str, + pub(super) changes: Option, + pub(super) entities: Option, + pub(super) reason: Option, + /// What gets a blocked side moving again, and the branch it goes + /// through: `fetch_then_merge` or `merge`. + resolve: Option<(&'static str, String)>, +} + +impl Side { + pub(super) fn new(state: &'static str) -> Self { + Self { + state, + changes: None, + entities: None, + reason: None, + resolve: None, + } + } + + pub(super) fn because(mut self, reason: impl Into) -> Self { + self.reason = Some(reason.into()); + self + } + + pub(super) fn json(&self) -> Value { + json!({ + "state": self.state, + "changes": self.changes, + "entities": self.entities.map(|count| count.entities), + "entity_changes": self.entities.as_ref().map(entity_changes), + "reason": self.reason, + "resolve": self.resolve.as_ref().map(|(how, _)| *how), + "resolve_branch": self.resolve.as_ref().map(|(_, branch)| branch.as_str()), + }) + } +} + +/// How the two sides sit, from what each would move. +pub(super) fn overall(push: &Side, pull: &Side) -> &'static str { + match (push.state, pull.state) { + ("would_send", "would_bring") | ("blocked", "blocked") => "diverged", + ("would_send", "nothing_to_bring") => "ahead", + (_, "would_bring") => "behind", + ("nothing_to_send", "nothing_to_bring") => "up_to_date", + ("blocked", "nothing_to_bring") => "blocked", + _ => "unknown", + } +} + +fn summary(state: &str, push: &Side, pull: &Side) -> String { + let counted = |side: &Side, verb: &str| match (side.changes, side.entities) { + (Some(changes), Some(entities)) => format!( + "{verb} {changes} {} touching {} {}", + plural(changes, "change", "changes"), + entities.entities, + plural(entities.entities, "entity", "entities") + ), + (Some(changes), None) => { + format!("{verb} {changes} {}", plural(changes, "change", "changes")) + } + _ => format!("{verb} changes"), + }; + match state { + "up_to_date" => "This branch matches the remote.".to_string(), + "ahead" => format!("A push would {}.", counted(push, "send")), + "behind" => format!("A pull would {}.", counted(pull, "bring")), + "diverged" => "This repository and the remote each hold changes the other does not. Kin \ + moves only fast-forwards, so neither a push nor a pull can run until they \ + are brought together." + .to_string(), + _ => push + .reason + .clone() + .or_else(|| pull.reason.clone()) + .unwrap_or_else(|| "Kin could not read where this branch stands.".to_string()), + } +} + +pub(super) fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str { + if count == 1 { + one + } else { + many + } +} + +/// A standing nobody read, in the shape a read one has, with why. +pub(super) fn not_read(reason: impl Into) -> Value { + let reason = reason.into(); + json!({ + "read":"not_read", + "state":"unknown", + "summary":reason.clone(), + "remote_head":null, + "push":Side::new("unknown").because(reason.clone()).json(), + "pull":Side::new("unknown").because(reason).json(), + }) +} + +/// Where the current branch stands against one native remote. Moves nothing. +async fn native_standing( + client: &DaemonClient, + local: &LocalBranch, + branch: &RefName, + peer: &Peer, + remote_name: &str, +) -> Value { + if matches!(peer.credential, Credential::None) { + return not_read( + "Kin holds no credential for this remote that it can read without asking, so it was \ + not asked.", + ); + } + if let Credential::ThisMachine { layout, .. } = &peer.credential { + if DaemonClient::from_running_workspace(peer.base_url.clone(), layout) + .await + .is_err() + { + return not_read( + "The Kin repository on this machine that this address named does not answer \ + there now, so it was not asked.", + ); + } + } + let advertised = match advertisement(peer, &local.repository_id).await { + Ok(advertised) => advertised, + Err(error) => return not_read(remote_reason(&error)), + }; + let remote_head = advertised_head(&advertised, branch); + let local_head = local.head_of(branch); + + let mut ancestor_of_local = remote_head.is_some() && remote_head == local_head; + // A branch with no change here has nothing to push, and the negotiation + // would only refuse to plan one. + let plan = match local_head { + Some(_) => Some( + client + .command_transfer_plan_reading(&transfer_request(peer, branch)) + .await, + ), + None => None, + }; + let push = match plan { + None => Side::new("nothing_to_send") + .because("This repository holds nothing on this branch yet."), + Some(plan) => match plan { + Ok(Ok(plan)) => match plan.plan.plan { + RepositoryTransferPlan::UpToDate { .. } => Side::new("nothing_to_send"), + RepositoryTransferPlan::FastForward { + destination_head, + source_head, + change_count, + } => { + ancestor_of_local = + destination_head.is_some() && destination_head == remote_head; + let mut side = Side::new("would_send"); + side.changes = change_count; + match destination_head { + Some(base) if change_count.unwrap_or(0) <= ENTITY_COUNT_MAX_CHANGES => { + side.entities = + local_range(client, &local.repository_id, base, source_head).await; + } + Some(_) => { + side.reason = Some( + "These changes are too many to count their entities here." + .to_string(), + ); + } + None => { + side.reason = Some( + "The remote holds nothing on this branch yet, so a push sends the \ + whole branch." + .to_string(), + ); + } + } + side + } + }, + Ok(Err((409, body))) => Side::new("blocked").because(daemon_reason(409, &body)), + Ok(Err((status, body))) => Side::new("unknown").because(daemon_reason(status, &body)), + Err(_) => Side::new("unknown") + .because("This repository's daemon did not answer the push reading."), + }, + }; + + let pull = match (remote_head, local_head) { + (None, _) => { + Side::new("nothing_to_bring").because("The remote holds nothing on this branch.") + } + (Some(remote), Some(local_head)) if remote == local_head => Side::new("nothing_to_bring"), + _ if ancestor_of_local => Side::new("nothing_to_bring"), + (Some(_), None) => Side::new("would_bring").because( + "This repository holds nothing on this branch yet, so a pull brings the remote's \ + whole branch.", + ), + (Some(remote), Some(local_head)) => { + // This repository's own graph first: a remote head it holds is + // nothing a pull brings, whatever the push reading came to. + let here = local_range_reading(client, &local.repository_id, remote, local_head).await; + match pull_settled_here(&here) { + Some(side) => side, + None if peer.organization_id.is_none() => pull_settled_there( + remote_range(peer, &local.repository_id, local_head, remote).await, + ), + None => Side::new("unknown").because( + "The remote holds changes this repository does not. It does not count them \ + before a pull, so how many is known once they arrive.", + ), + } + } + }; + // A branch that has diverged moves again by bringing the remote's branch + // in beside it and merging the two. Once it is in, only the merge is left. + let mut pull = pull; + if pull.state == "blocked" && pull.reason.as_deref() == Some(DIVERGED_PULL) { + let beside = tracking_name(remote_name, branch); + let held = RefName::branch(beside.as_bytes()) + .ok() + .and_then(|beside| local.head_of(&beside)); + let how = if held.is_some() && held == remote_head { + "merge" + } else { + "fetch_then_merge" + }; + pull.resolve = Some((how, beside)); + } + let push = if push.state == "blocked" && pull.state == "would_bring" { + Side::new("blocked").because( + "The remote holds changes this repository does not. Kin moves only fast-forwards, so \ + those changes have to come here first.", + ) + } else { + push + }; + let state = overall(&push, &pull); + json!({ + "read":"complete", + "state":state, + "summary":summary(state, &push, &pull), + "remote_head":remote_head.map(|head| head.to_string()), + "push":push.json(), + "pull":pull.json(), + }) +} + +/// Changes this branch holds that Kin recorded since the newest one it took +/// from Git, counted in changes and entities. `None` when none came from Git +/// within the newest page of history, or the reading failed. +pub(super) async fn since_git( + client: &DaemonClient, + local: &LocalBranch, + branch: &RefName, +) -> Option { + let head = local.head_of(branch)?; + let log = client + .log(&super::log::LogRequest { + count: 200, + skip: 0, + }) + .await + .ok()? + .report?; + let from_git = log + .entries + .iter() + .find(|entry| matches!(entry.origin, kin_model::ChangeOrigin::GitCommit { .. }))? + .change_id; + if from_git == head { + return Some( + json!({"changes":0,"entities":0,"entity_changes":entity_changes(&RangeCount::default())}), + ); + } + let count = local_range(client, &local.repository_id, from_git, head).await?; + Some(json!({ + "changes":count.ahead, + "entities":count.entities, + "entity_changes":entity_changes(&count), + })) +} + +/// The local branch a remote's branch is brought in as when the page names +/// none: the remote's name, then the branch's. +fn tracking_name(remote: &str, branch: &RefName) -> String { + format!("{remote}/{}", short_ref(branch)) +} + +fn transfer_request(peer: &Peer, branch: &RefName) -> CommandTransferRequest { + CommandTransferRequest { + remote_base_url: peer.base_url.clone(), + remote_token: peer.credential.token().map(str::to_string), + remote_organization_id: peer.organization_id.clone(), + repository_id: None, + source_ref: Some(branch.clone()), + destination_ref: Some(branch.clone()), + } +} + +/// `GET /api/workspace/remotes`: each remote, which one is the default, and +/// for the current branch what a push would send and what a pull would bring. +async fn serve_remotes(State(state): State>) -> Response { + let machine = machine_repositories(&state).await; + let read = { + let layout = state.layout.clone(); + tokio::task::spawn_blocking(move || { + let history = kin_core::history_state::standing(&layout); + (read_remotes(&layout, &machine), history_reason(&history)) + }) + .await + }; + let (remotes, history) = match read { + Ok((Ok(remotes), history)) => (remotes, history), + Ok((Err(reason), _)) => { + return review_ui::refusal(StatusCode::BAD_GATEWAY, &anyhow::anyhow!(reason)) + } + Err(error) => { + return review_ui::refusal( + StatusCode::BAD_GATEWAY, + &anyhow::anyhow!("the remotes reading did not finish: {error}"), + ) + } + }; + let any_native = remotes.remotes.iter().any(|remote| remote.native); + // The daemon is asked only when there is something to compare against. + let local = if remotes.remotes.is_empty() { + Err("No remote is set up for this repository.".to_string()) + } else { + match review_ui::daemon(&state).await { + Ok(client) => match local_branch(&client).await { + Ok(local) => Ok((client, local)), + Err(_) => Err( + "This repository's daemon did not answer, so nothing was compared.".to_string(), + ), + }, + Err(_) => { + Err("This repository's daemon did not answer, so nothing was compared.".to_string()) + } + } + }; + let branch = local + .as_ref() + .ok() + .and_then(|(_, local)| local.branch.clone()); + // Git is asked about itself once, and only when a Git remote is shown. + let git = if remotes.remotes.iter().any(|remote| !remote.native) { + super::workspace_git_remote::ready(&state).await + } else { + Ok(()) + }; + let git_pull = if remotes.remotes.iter().any(|remote| !remote.native) { + super::workspace_git_remote::update_ready_within(&state, Duration::from_secs(5)).await + } else { + Err("No Git remote is set up for this repository.".to_string()) + }; + // Every remote is read at once, so one that is slow to answer holds up + // no other. + let rows = futures_util::future::join_all(remotes.remotes.iter().map(|remote| { + let (local, branch, history, git, git_pull, state) = + (&local, &branch, &history, &git, &git_pull, &state); + async move { + let (can_push, can_pull) = availability(remote, history.as_deref(), git, git_pull); + let late = || not_read("The remote did not answer in time, so nothing was compared."); + let standing = match (local, branch, &remote.peer) { + (Err(reason), _, _) => not_read(reason.as_str()), + (Ok(_), None, _) => { + not_read("This workspace is on no branch, so there is nothing to compare.") + } + (Ok((client, local)), Some(branch), _) if !remote.native => tokio::time::timeout( + REMOTE_READ_TIMEOUT * 2, + super::workspace_git_remote::standing( + state, client, local, branch, remote, git, git_pull, + ), + ) + .await + .unwrap_or_else(|_| late()), + (Ok((client, local)), Some(branch), Ok(peer)) => tokio::time::timeout( + REMOTE_READ_TIMEOUT * 2, + native_standing(client, local, branch, peer, &remote.name), + ) + .await + .unwrap_or_else(|_| late()), + (Ok(_), Some(_), Err(reason)) => not_read(reason.as_str()), + }; + json!({ + "name":remote.name, + "kind":if remote.native { "native" } else { "git" }, + "host":remote.host.as_str(), + "default":remote.default, + "address":remote.address, + "credential":match &remote.peer { + Ok(peer) => peer.credential.label(), + // Git reaches a Git remote, with the person's own credentials. + Err(_) if !remote.native => "git", + Err(_) => "not_applicable", + }, + "can_push":can_push.is_ok(), + "can_pull":can_pull.is_ok(), + "can_fetch":remote.native && can_pull.is_ok(), + "unavailable_reason":can_push.err(), + "pull_unavailable_reason":can_pull.err(), + "standing":standing, + }) + } + })) + .await; + let (default_remote, default_reason) = match &remotes.default { + Ok(name) => (Some(name.clone()), None), + Err(reason) => (None, Some(reason.clone())), + }; + let head = match (&local, &branch) { + (Ok((_, local)), Some(branch)) => local.head_of(branch).map(|head| head.to_string()), + _ => None, + }; + Json(json!({ + "schema":REMOTES_SCHEMA, + "branch":branch.as_ref().map(short_ref), + "ref":branch.as_ref().map(ToString::to_string), + "head":head, + "default_remote":default_remote, + "default_remote_reason":default_reason, + "history_complete":history.is_none(), + "any_native":any_native, + "remotes":rows, + "basis":"Read for this answer from this repository's daemon and from each remote Kin could ask. Nothing moved on either side.", + })) + .into_response() +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TransferBody { + #[serde(default)] + remote: Option, + #[serde(default, rename = "ref")] + reference: Option, + /// For a fetch, the local branch the remote's branch is brought in as. + #[serde(default, rename = "as")] + into: Option, + operation_id: OperationId, +} + +async fn push( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + transfer(state, headers, body, Act::Push).await +} + +async fn pull( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + transfer(state, headers, body, Act::Pull).await +} + +/// `POST /api/workspace/fetch`: bring the remote's branch in as a local +/// branch of its own, moving nothing that is here. What a diverged branch +/// needs before the two can be merged. +async fn fetch( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + transfer(state, headers, body, Act::Fetch).await +} + +/// What a transfer route was asked to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Act { + Push, + Pull, + /// Bring the remote's branch in beside the local one, as a branch of its + /// own. A pull onto another branch, and nothing here moves. + Fetch, +} + +impl Act { + /// Which way history moves at the daemon. + fn direction(self) -> RepositoryTransferDirection { + match self { + Self::Push => RepositoryTransferDirection::Push, + Self::Pull | Self::Fetch => RepositoryTransferDirection::Pull, + } + } +} + +fn verb(direction: Act) -> &'static str { + match direction { + Act::Push => "push", + Act::Pull => "pull", + Act::Fetch => "fetch", + } +} + +/// How one push or pull ended, as the page reads it. +#[derive(Debug)] +pub(super) struct Ended { + pub(super) outcome: &'static str, + pub(super) status: StatusCode, + pub(super) body: Value, +} + +/// Nothing was sent: the request stopped before the daemon was asked. +pub(super) fn not_sent(direction: Act, reason: impl Into) -> Ended { + Ended { + outcome: "refused", + status: StatusCode::CONFLICT, + body: json!({ + "error":reason.into(), + "remedy":format!("Nothing was sent. Correct what is reported, then {} again.", verb(direction)), + }), + } +} + +/// The daemon answered with a refusal and a fresh reading shows nothing moved. +pub(super) fn refused(direction: Act, reason: String) -> Ended { + Ended { + outcome: "refused", + status: StatusCode::CONFLICT, + body: json!({ + "error":reason, + "remedy":format!("The {} was refused and nothing moved. Correct what is reported, then try again.", verb(direction)), + }), + } +} + +/// Nothing says how the request ended, or a reading shows that something +/// moved before a refusal. +pub(super) fn unknown(direction: Act, reason: String) -> Ended { + let moved = match direction { + Act::Push => { + "The push may have published some or all of its changes. Read the remote's standing before pushing again." + } + Act::Pull => { + "The pull may have admitted some or all of the remote's changes. Read the remote's standing and recent changes before pulling again." + } + Act::Fetch => { + "The fetch may have brought in some or all of the remote's changes. Read the branches before fetching again." + } + }; + Ended { + outcome: "unknown", + status: StatusCode::BAD_GATEWAY, + body: json!({ + "error":reason, + "remedy":format!("{moved} This request was not retried."), + }), + } +} + +/// What the page needs to know about how the working copy followed a pull. +pub(super) fn workspace_json(workspace: &WorkspaceFollow) -> Value { + match workspace { + WorkspaceFollow::Unreported => json!({"state":"unreported"}), + WorkspaceFollow::NotApplicable { .. } => json!({"state":"not_applicable"}), + WorkspaceFollow::AlreadyCurrent { .. } => json!({"state":"already_current"}), + WorkspaceFollow::Advanced { .. } => json!({"state":"followed"}), + WorkspaceFollow::Behind { detail } => json!({ + "state":"behind", + "summary":"The pulled changes are recorded, and this working copy still shows what it showed before.", + "detail":detail, + }), + } +} + +fn reviews_json(response: &CommandTransferResponse) -> Value { + use kin_remote::collaboration_transfer::CollaborationTransfer; + match &response.outcome.collaboration { + None => Value::Null, + Some(CollaborationTransfer::InSync) => json!({"state":"in_sync"}), + Some(CollaborationTransfer::PeerUnsupported { .. }) => json!({"state":"not_exchanged", + "summary":"The remote does not exchange review records."}), + Some(CollaborationTransfer::Skipped { .. }) => json!({"state":"not_exchanged"}), + Some(CollaborationTransfer::Exchanged { carried, gaps, .. }) => json!({ + "state":"exchanged","carried":carried,"gaps":gaps.len(), + }), + Some(CollaborationTransfer::Refused { .. }) => json!({"state":"not_exchanged", + "summary":"Review records were not exchanged. Any history this moved is durable."}), + } +} + +/// Read a settled answer into the outcome the page sees. +async fn answered( + direction: Act, + response: &CommandTransferResponse, + client: &DaemonClient, + repository_id: &RepositoryId, + before: Option, +) -> Ended { + let head = match (&response.outcome.plan, response.outcome.final_receipt()) { + (_, Some(receipt)) => Some(receipt.destination_head), + (RepositoryTransferPlan::UpToDate { head }, None) => *head, + (RepositoryTransferPlan::FastForward { .. }, None) => None, + }; + let moved = response.outcome.moved_history(); + let mut body = json!({ + "head":head.map(|head| head.to_string()), + "packs":response.outcome.receipts.len(), + "reviews":reviews_json(response), + }); + if direction == Act::Pull { + body["workspace"] = workspace_json(&response.workspace); + } + if !moved { + return Ended { + outcome: "nothing_to_send", + status: StatusCode::OK, + body, + }; + } + let counted = match (before, head) { + (Some(before), Some(head)) => local_range(client, repository_id, before, head).await, + _ => None, + }; + let changes = match &response.outcome.plan { + RepositoryTransferPlan::FastForward { + change_count: Some(count), + .. + } => Some(*count), + _ => counted.map(|count| count.ahead), + }; + // Entities are counted along one line of history. Between two branches + // that each moved, the difference is not what arrived. + let counted = counted.filter(|count| count.behind == 0); + body["changes"] = json!(changes); + body["entities"] = json!(counted.map(|count| count.entities)); + body["entity_changes"] = counted.as_ref().map(entity_changes).unwrap_or(Value::Null); + Ended { + outcome: match direction { + Act::Push => "pushed", + Act::Pull => "pulled", + Act::Fetch => "fetched", + }, + status: StatusCode::OK, + body, + } +} + +/// Why a sent transfer has no answer, in words. The transport's own error +/// is for a log: it names the daemon's records and the commands that inspect +/// them, and a page reader acts on neither. +fn lost_reason(direction: Act, error: &anyhow::Error) -> String { + let timed_out = format!("{error:#}").contains("timed out"); + if timed_out { + format!( + "This repository's daemon did not answer the {} before the app stopped waiting.", + verb(direction) + ) + } else { + format!( + "The connection to this repository's daemon ended before it answered the {}.", + verb(direction) + ) + } +} + +/// Settle a dispatch the daemon refused or whose answer was lost, from a +/// reading taken after it. `before` and `after` are the head the transfer +/// would move: the remote's for a push, this repository's for a pull or a +/// fetch. A lost +/// answer is never settled as refused, because the daemon may still be +/// running it. +fn settle_unanswered( + direction: Act, + dispatch: &TransferDispatch, + before: Option, + after: Option>, +) -> Ended { + match dispatch { + TransferDispatch::Answered(_) => { + unreachable!("an answered transfer is settled from its answer") + } + TransferDispatch::Lost(error) => unknown(direction, lost_reason(direction, error)), + TransferDispatch::Refused { status, body } => match after { + Some(after) if after == before => refused(direction, daemon_reason(*status, body)), + Some(_) => unknown( + direction, + format!( + "The {} was refused after the branch had already moved: {}", + verb(direction), + daemon_reason(*status, body) + ), + ), + None => unknown( + direction, + format!( + "The {} was refused, and whether anything moved first could not be read: {}", + verb(direction), + daemon_reason(*status, body) + ), + ), + }, + } +} + +/// The branch a transfer moves: the one the page names, or the current one. +fn transfer_ref(reference: Option<&str>, local: &LocalBranch) -> Result { + match reference.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => { + if value.len() > 512 { + return Err("This branch name is too long.".to_string()); + } + let name = super::repository_authority::parse_ref_name(value) + .map_err(|error| format!("This is not a branch name: {error}"))?; + if !name.is_branch() { + return Err("Choose a branch below refs/heads/.".to_string()); + } + Ok(name) + } + None => local.branch.clone().ok_or_else(|| { + "This workspace is on no branch, so name the branch to move.".to_string() + }), + } +} + +/// The branch a fetch lands on: the one the page names, or the remote's name +/// then the branch's. Never the branch it is fetched for, nor the one this +/// workspace is on: a pull is what moves those. +fn fetch_destination( + named: Option<&str>, + remote: &str, + branch: &RefName, + local: &LocalBranch, +) -> Result { + let name = match named.map(str::trim).filter(|name| !name.is_empty()) { + Some(name) => name.to_string(), + None => tracking_name(remote, branch), + }; + if name.len() > 512 { + return Err("This branch name is too long.".to_string()); + } + let destination = super::repository_authority::parse_ref_name(&name) + .map_err(|error| format!("This is not a branch name: {error}"))?; + if !destination.is_branch() { + return Err("Choose a branch below refs/heads/.".to_string()); + } + if &destination == branch || local.branch.as_ref() == Some(&destination) { + return Err( + "A fetch brings the remote's branch in beside this one, so it needs a branch of \ + another name. A pull is what moves this branch." + .to_string(), + ); + } + Ok(destination) +} + +/// A push to a Git remote, or the refusal of anything else against one. +async fn git_transfer( + state: &Arc, + body: &TransferBody, + direction: Act, + remote: &Remote, + history: Option<&str>, +) -> Ended { + if direction == Act::Fetch { + return not_sent(direction, "Git updates advance the current branch. Fetching a Git branch beside it is not supported here."); + } + if let Some(reason) = history { + return not_sent(direction, reason); + } + if let Err(reason) = super::workspace_git_remote::ready(state).await { + return not_sent(direction, reason); + } + let client = match review_ui::daemon(state).await { + Ok(client) => client, + Err(_) => return not_sent(direction, "This repository's daemon did not answer."), + }; + let local = match local_branch(&client).await { + Ok(local) => local, + Err(_) => { + return not_sent( + direction, + "This repository's daemon did not answer the branch reading.", + ) + } + }; + let branch = match transfer_ref(body.reference.as_deref(), &local) { + Ok(branch) => branch, + Err(reason) => return not_sent(direction, reason), + }; + let (mut ended, sent) = match direction { + Act::Push => { + super::workspace_git_remote::publish( + state, + body.operation_id, + &client, + &local, + &branch, + remote, + ) + .await + } + Act::Pull => { + super::workspace_git_remote::update( + state, + body.operation_id, + &client, + &local, + &branch, + remote, + ) + .await + } + Act::Fetch => unreachable!("Git fetch refused before dispatch"), + }; + ended.body["remote"] = json!(remote.name); + ended.body["ref"] = json!(branch.to_string()); + ended.body["branch"] = json!(short_ref(&branch)); + ended.body["sent"] = json!(sent); + ended +} + +/// Run one push, pull or fetch to its end and say how it ended. +async fn run_transfer(state: &Arc, body: &TransferBody, direction: Act) -> Ended { + let machine = machine_repositories(state).await; + let read = { + let layout = state.layout.clone(); + tokio::task::spawn_blocking(move || { + let history = kin_core::history_state::standing(&layout); + (read_remotes(&layout, &machine), history_reason(&history)) + }) + .await + }; + let (remotes, history) = match read { + Ok((Ok(remotes), history)) => (remotes, history), + Ok((Err(reason), _)) => return not_sent(direction, reason), + Err(error) => { + return not_sent( + direction, + format!("The remotes reading did not finish: {error}"), + ) + } + }; + let name = match body + .remote + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + { + Some(name) => name.to_string(), + None => match &remotes.default { + Ok(name) => name.clone(), + Err(reason) => return not_sent(direction, reason.clone()), + }, + }; + let Some(remote) = remotes.remotes.iter().find(|remote| remote.name == name) else { + return not_sent( + direction, + format!("No remote named {name} is set up for this repository."), + ); + }; + if !remote.native { + return git_transfer(state, body, direction, remote, history.as_deref()).await; + } + if let Err(reason) = usable(remote, history.as_deref()) { + return not_sent(direction, reason); + } + let Ok(peer) = remote.peer.clone() else { + return not_sent(direction, "This remote cannot be addressed."); + }; + let client = match review_ui::daemon(state).await { + Ok(client) => client, + Err(_) => return not_sent(direction, "This repository's daemon did not answer."), + }; + let local = match local_branch(&client).await { + Ok(local) => local, + Err(_) => { + return not_sent( + direction, + "This repository's daemon did not answer the branch reading.", + ) + } + }; + let branch = match transfer_ref(body.reference.as_deref(), &local) { + Ok(branch) => branch, + Err(reason) => return not_sent(direction, reason), + }; + // Where the remote's history lands here: on the branch itself, or for a + // fetch on a branch beside it. + let destination = match direction { + Act::Fetch => { + match fetch_destination(body.into.as_deref(), &remote.name, &branch, &local) { + Ok(destination) => destination, + Err(reason) => return not_sent(direction, reason), + } + } + Act::Push | Act::Pull => branch.clone(), + }; + if let Credential::ThisMachine { layout, .. } = &peer.credential { + if DaemonClient::from_running_workspace(peer.base_url.clone(), layout) + .await + .is_err() + { + return not_sent( + direction, + "The Kin repository on this machine that this address named does not answer there now.", + ); + } + } + // What the transfer would move, read before it is sent so a refusal can + // be told apart from a transfer that moved something and then stopped. + let remote_before = match advertisement(&peer, &local.repository_id).await { + Ok(advertised) => advertised_head(&advertised, &branch), + Err(error) => return not_sent(direction, remote_reason(&error)), + }; + let local_before = local.head_of(&destination); + let before = match direction { + Act::Push => remote_before, + Act::Pull | Act::Fetch => local_before, + }; + let named = |mut ended: Ended, sent: bool| { + ended.body["remote"] = json!(remote.name); + ended.body["ref"] = json!(branch.to_string()); + ended.body["branch"] = json!(short_ref(&branch)); + if direction == Act::Fetch { + ended.body["into"] = json!(destination.to_string()); + ended.body["into_branch"] = json!(short_ref(&destination)); + } + ended.body["sent"] = json!(sent); + ended + }; + // A side that holds nothing on the branch has nothing to move, and the + // negotiation would only refuse to plan it. Nothing is sent for either. + let nothing_here = |summary: &str| Ended { + outcome: "nothing_to_send", + status: StatusCode::OK, + body: json!({"head":local_before.map(|head| head.to_string()),"packs":0,"summary":summary}), + }; + match direction { + Act::Pull | Act::Fetch if remote_before.is_none() => { + return named( + nothing_here("The remote holds nothing on this branch."), + false, + ); + } + Act::Push if local_before.is_none() => { + let ended = if local.branch.as_ref() == Some(&branch) { + nothing_here("This repository holds nothing on this branch yet.") + } else { + not_sent( + direction, + format!( + "This repository has no branch named {}.", + short_ref(&branch) + ), + ) + }; + return named(ended, false); + } + _ => {} + } + + state.transfers.mark_sent(body.operation_id); + let mut request = transfer_request(&peer, &branch); + request.destination_ref = Some(destination.clone()); + let dispatch = client.transfer_once(direction.direction(), &request).await; + let ended = match &dispatch { + TransferDispatch::Answered(response) => { + // A branch brought in for the first time is counted against the + // branch it was fetched for: what it holds that this one does not. + let counted_from = match direction { + Act::Fetch => before.or_else(|| local.head_of(&branch)), + Act::Push | Act::Pull => before, + }; + answered( + direction, + response, + &client, + &local.repository_id, + counted_from, + ) + .await + } + TransferDispatch::Lost(_) => settle_unanswered(direction, &dispatch, before, None), + TransferDispatch::Refused { .. } => { + let after = match direction { + Act::Push => advertisement(&peer, &local.repository_id) + .await + .ok() + .map(|advertised| advertised_head(&advertised, &branch)), + Act::Pull | Act::Fetch => local_branch(&client) + .await + .ok() + .map(|local| local.head_of(&destination)), + }; + let settled = settle_unanswered(direction, &dispatch, before, after); + // A pull refused because this repository already holds what the + // remote has moved nothing and had nothing to move. + let holds_remote = match (direction, settled.outcome, remote_before, before) { + (Act::Pull | Act::Fetch, "refused", Some(remote), Some(here)) => { + local_range(&client, &local.repository_id, remote, here) + .await + .is_some_and(|count| count.behind == 0) + } + _ => false, + }; + if holds_remote { + Ended { + outcome: "nothing_to_send", + status: StatusCode::OK, + body: json!({"head":before.map(|head| head.to_string()),"packs":0, + "summary":"This repository already holds everything the remote has on this branch."}), + } + } else { + settled + } + } + }; + named(ended, true) +} + +/// `POST /api/workspace/push`, `/pull` and `/fetch`. +async fn transfer( + state: Arc, + headers: HeaderMap, + body: TransferBody, + direction: Act, +) -> Response { + if !review_ui::may_write(&headers, &state) { + return review_ui::write_refused(); + } + let operation_id = body.operation_id; + let answer = |ended: Ended| { + let mut body = ended.body; + body["operation_id"] = json!(operation_id); + body["outcome"] = json!(ended.outcome); + // Whether this request reached the daemon, said on every answer. + if body.get("sent").is_none() { + body["sent"] = json!(false); + } + (ended.status, Json(body)).into_response() + }; + if operation_id.0.is_nil() { + return answer(not_sent(direction, "A nonempty operation ID is required.")); + } + if direction != Act::Fetch && body.into.is_some() { + return answer(not_sent( + direction, + "Only a fetch names a branch to bring the remote's branch in as.", + )); + } + let Ok(running) = state.transfers.running.clone().try_lock_owned() else { + return answer(not_sent( + direction, + "Another push, pull or fetch is running for this repository.", + )); + }; + if let Some(earlier) = state.transfers.earlier(operation_id) { + let mut ended = Ended { + outcome: "refused", + status: StatusCode::CONFLICT, + body: json!({ + "error":"This operation was already sent once, and an operation is never sent twice.", + "remedy":"Read the remote's standing to see how it ended, then start a new operation if there is more to move.", + "already_sent":true, + }), + }; + ended.body["earlier_outcome"] = json!(earlier); + return answer(ended); + } + // Run to its end whatever becomes of this request, holding the one-at-a- + // time guard until then. A page that closes mid-transfer must not leave + // the daemon's answer unread. + let running_state = state.clone(); + let ended = tokio::spawn(async move { + let _running = running; + let ended = run_transfer(&running_state, &body, direction).await; + if ended.body.get("sent").and_then(Value::as_bool) == Some(true) { + running_state + .transfers + .settle(body.operation_id, ended.outcome); + } + ended + }) + .await + .unwrap_or_else(|error| { + unknown( + direction, + format!("The {} did not finish: {error}", verb(direction)), + ) + }); + answer(ended) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn head(byte: u8) -> SemanticChangeId { + SemanticChangeId::from_hash(kin_model::Hash256::from_bytes([byte; 32])) + } + + /// A partial history is said as what Kin holds, and the rest is said to + /// be on its way only while a derivation reads it or has read it. + #[test] + fn a_partial_history_says_what_is_held_and_claims_no_reading_that_is_not_happening() { + use kin_core::history_state::{ + DerivationState, HistoryDerivationProgress, HistoryMode, HistoryStanding, + HistoryStateRecord, + }; + let record = HistoryStateRecord::new( + HistoryMode::Background, + "a".repeat(40), + Some("refs/heads/main".into()), + "b".repeat(64), + Vec::new(), + Some(4_673), + None, + ); + let standing = |progress| HistoryStanding::Partial { + record: record.clone(), + progress, + }; + let held = "Kin holds 1 of this repository's 4,673 commits"; + let idle = history_reason(&standing(None)).unwrap(); + assert_eq!( + idle, + format!("{held}, so it cannot be pushed or pulled until it holds all of it.") + ); + assert!(!idle.contains("filled in"), "{idle}"); + // A derivation whose worker is gone is not reading anything. + let mut stopped = HistoryDerivationProgress::new(DerivationState::Running); + stopped.pid = Some(i32::MAX.unsigned_abs()); + assert_eq!(history_reason(&standing(Some(stopped))).unwrap(), idle); + let mut running = HistoryDerivationProgress::new(DerivationState::Running); + running.pid = Some(std::process::id()); + assert_eq!( + history_reason(&standing(Some(running))).unwrap(), + format!( + "{held} and is reading the rest now, so it cannot be pushed or pulled until it \ + holds all of it." + ) + ); + let staged = history_reason(&standing(Some(HistoryDerivationProgress::new( + DerivationState::Staged, + )))) + .unwrap(); + assert!(staged.contains("has read the rest"), "{staged}"); + assert_eq!(history_reason(&HistoryStanding::Complete), None); + let uncounted = HistoryStanding::Partial { + record: HistoryStateRecord { + reachable_commits: None, + ..record.clone() + }, + progress: None, + }; + assert!(history_reason(&uncounted) + .unwrap() + .starts_with("Kin holds only part of this repository's history,")); + assert_eq!( + [0, 999, 1_000, 1_234_567].map(grouped), + ["0", "999", "1,000", "1,234,567"] + ); + } + + #[test] + fn an_operation_is_remembered_once_sent_and_its_outcome_once_settled() { + let transfers = Transfers::default(); + let operation = OperationId::new(); + assert_eq!(transfers.earlier(operation), None); + transfers.mark_sent(operation); + assert_eq!(transfers.earlier(operation), Some(None)); + transfers.settle(operation, "pushed"); + assert_eq!(transfers.earlier(operation), Some(Some("pushed"))); + assert_eq!(transfers.earlier(OperationId::new()), None); + // The memory is bounded, oldest first out. + for _ in 0..SENT_KEPT { + transfers.mark_sent(OperationId::new()); + } + assert_eq!(transfers.earlier(operation), None); + } + + /// A lost answer is unknown whatever a later reading says, because the + /// daemon may still be running the transfer. + #[test] + fn a_lost_answer_is_unknown_and_never_refused() { + for direction in [Act::Push, Act::Pull] { + let lost = TransferDispatch::Lost(anyhow::anyhow!("connection reset")); + for after in [None, Some(Some(head(1))), Some(None)] { + let ended = settle_unanswered(direction, &lost, Some(head(1)), after); + assert_eq!(ended.outcome, "unknown"); + assert_eq!(ended.status, StatusCode::BAD_GATEWAY); + assert!(ended.body["remedy"] + .as_str() + .unwrap() + .contains("This request was not retried.")); + let error = ended.body["error"].as_str().unwrap(); + assert!(error.contains("ended before it answered"), "{error}"); + assert!(!error.contains("connection reset"), "{error}"); + } + } + } + + /// A refusal is refused only when a reading taken after it shows the head + /// where it was. A moved head, or one that cannot be read, is unknown. + #[test] + fn a_refusal_is_refused_only_when_nothing_moved() { + let refusal = TransferDispatch::Refused { + status: 409, + body: "remote head x on refs/heads/main is not an ancestor of local head y".into(), + }; + let direction = Act::Push; + let same = settle_unanswered(direction, &refusal, Some(head(1)), Some(Some(head(1)))); + assert_eq!(same.outcome, "refused"); + assert_eq!(same.status, StatusCode::CONFLICT); + assert!(same.body["error"] + .as_str() + .unwrap() + .contains("holds changes this repository does not")); + let unborn = settle_unanswered(direction, &refusal, None, Some(None)); + assert_eq!(unborn.outcome, "refused"); + let moved = settle_unanswered(direction, &refusal, Some(head(1)), Some(Some(head(2)))); + assert_eq!(moved.outcome, "unknown"); + let unread = settle_unanswered(direction, &refusal, Some(head(1)), None); + assert_eq!(unread.outcome, "unknown"); + } + + #[test] + fn a_daemon_refusal_is_said_in_words_without_a_command() { + let credential = daemon_reason( + 424, + "repository transfer storage failed: remote refused ref advertisement (HTTP 401): \ + {\"error\":\"Authentication required\"}; send a bearer token ... run `kin auth login`", + ); + assert_eq!(credential, "The remote refused the credential Kin sent."); + assert!(!credential.contains('`')); + assert_eq!( + daemon_reason( + 424, + "remote transfer status transport failed: connection refused" + ), + "The remote could not be reached." + ); + assert_eq!(daemon_reason(500, " "), "Kin refused with HTTP 500."); + assert_eq!( + daemon_reason( + 409, + "remote refused transfer export with a repository-v6 conflict (HTTP 409): \ + destination head a is not an ancestor of source head b" + ), + DIVERGED_PULL + ); + } + + #[test] + fn a_semantic_diff_counts_changes_and_entities() { + let diff = json!({ + "ahead":2,"behind":0, + "entities":[ + {"change_type":"added"},{"change_type":"modified"}, + {"change_type":"modified"},{"change_type":"removed"}, + ], + }); + assert_eq!( + range_count(&diff), + Some(RangeCount { + ahead: 2, + behind: 0, + entities: 4, + added: 1, + modified: 2, + removed: 1, + }) + ); + assert_eq!(range_count(&json!({"ahead":1})), None); + } + + /// A remote head this repository holds is never something a pull + /// brings, and only a head it does not hold is the remote's to count. + #[test] + fn a_pull_is_settled_here_before_the_remote_is_asked() { + let count = |ahead, behind| RangeCount { + ahead, + behind, + entities: 2, + ..RangeCount::default() + }; + // The remote is behind: its head is an ancestor of this one. + assert_eq!( + pull_settled_here(&Ok(count(3, 0))).unwrap().state, + "nothing_to_bring" + ); + assert_eq!( + pull_settled_here(&Ok(count(1, 1))).unwrap().state, + "blocked" + ); + assert_eq!( + pull_settled_here(&Err(Some("no answer".into()))) + .unwrap() + .state, + "unknown" + ); + assert!(pull_settled_here(&Err(None)).is_none()); + + let brings = pull_settled_there(Ok(count(2, 0))); + assert_eq!(brings.state, "would_bring"); + assert_eq!(brings.changes, Some(2)); + assert_eq!(brings.entities.unwrap().entities, 2); + assert_eq!(pull_settled_there(Ok(count(2, 1))).state, "blocked"); + assert_eq!(pull_settled_there(Err(None)).state, "blocked"); + assert_eq!( + pull_settled_there(Err(Some("no answer".into()))).state, + "unknown" + ); + } + + #[test] + fn the_two_sides_make_one_standing() { + let side = |state| Side::new(state); + assert_eq!( + overall(&side("nothing_to_send"), &side("nothing_to_bring")), + "up_to_date" + ); + assert_eq!( + overall(&side("would_send"), &side("nothing_to_bring")), + "ahead" + ); + assert_eq!(overall(&side("blocked"), &side("would_bring")), "behind"); + assert_eq!(overall(&side("blocked"), &side("blocked")), "diverged"); + assert_eq!( + overall(&side("blocked"), &side("nothing_to_bring")), + "blocked" + ); + assert_eq!( + overall(&side("unknown"), &side("nothing_to_bring")), + "unknown" + ); + let mut push = side("would_send"); + push.changes = Some(1); + push.entities = Some(RangeCount { + ahead: 1, + entities: 3, + ..RangeCount::default() + }); + assert_eq!( + summary("ahead", &push, &side("nothing_to_bring")), + "A push would send 1 change touching 3 entities." + ); + } + + #[test] + fn the_default_is_the_configured_remote_then_the_only_kin_one_then_the_only_git_one() { + let remote = |name: &str, native: bool| Remote { + name: name.into(), + host: RemoteHostKind::Peer, + native, + default: false, + address: None, + peer: Err(String::new()), + }; + let both = [remote("origin", false), remote("peer", true)]; + assert_eq!(default_remote(Some("origin"), &both), Ok("origin".into())); + assert_eq!(default_remote(Some("gone"), &both), Ok("peer".into())); + assert_eq!(default_remote(None, &both), Ok("peer".into())); + let two = [remote("a", true), remote("b", true)]; + assert_eq!(default_remote(Some("b"), &two), Ok("b".into())); + assert!(default_remote(None, &two) + .unwrap_err() + .contains("none is the default")); + assert_eq!( + default_remote(None, &[remote("origin", false)]), + Ok("origin".into()) + ); + assert!( + default_remote(None, &[remote("origin", false), remote("fork", false)]) + .unwrap_err() + .contains("2 Git remotes") + ); + assert_eq!( + default_remote(None, &[]), + Err("No remote is set up for this repository.".into()) + ); + } + + /// A fetch lands beside the branch it is for, never on it or on the + /// branch this workspace is on. + #[test] + fn a_fetch_lands_on_a_branch_of_another_name() { + let main = RefName::branch(b"main").unwrap(); + let local = LocalBranch { + repository_id: RepositoryId::new("fetch-destination").unwrap(), + branch: Some(main.clone()), + branches: vec![(main.clone(), Some(head(1)))], + }; + assert_eq!( + fetch_destination(None, "peer", &main, &local).unwrap(), + RefName::branch(b"peer/main").unwrap() + ); + assert_eq!( + fetch_destination(Some(" theirs "), "peer", &main, &local).unwrap(), + RefName::branch(b"theirs").unwrap() + ); + for refused in ["main", "refs/heads/main"] { + assert!(fetch_destination(Some(refused), "peer", &main, &local) + .unwrap_err() + .contains("a branch of another name")); + } + assert!( + fetch_destination(Some("refs/tags/v1"), "peer", &main, &local) + .unwrap_err() + .contains("refs/heads/") + ); + assert_eq!(Act::Fetch.direction(), RepositoryTransferDirection::Pull); + assert_eq!(verb(Act::Fetch), "fetch"); + } + + /// What a blocked side can do next travels with it. + #[test] + fn a_side_says_how_it_moves_again() { + let mut pull = Side::new("blocked").because(DIVERGED_PULL); + assert_eq!(pull.json()["resolve"], Value::Null); + pull.resolve = Some(("fetch_then_merge", "peer/main".into())); + assert_eq!(pull.json()["resolve"], "fetch_then_merge"); + assert_eq!(pull.json()["resolve_branch"], "peer/main"); + assert_eq!( + tracking_name("peer", &RefName::branch(b"topic/a").unwrap()), + "peer/topic/a" + ); + } + + #[test] + fn an_address_is_shown_without_its_credentials() { + assert_eq!( + shown_address(Some("https://alice:s3cr3t@example.com/team")).as_deref(), + Some("https://example.com/team") + ); + assert_eq!(shown_address(Some(" ")), None); + assert_eq!(shown_address(None), None); + } + + #[test] + fn a_peer_needs_an_address_kin_sends_a_credential_to() { + assert!(native_peer(RemoteHostKind::Peer, None, &[]) + .err() + .unwrap() + .contains("no transfer address")); + assert!( + native_peer(RemoteHostKind::Peer, Some("http://peer.example:4219"), &[]) + .err() + .unwrap() + .contains("not one Kin sends a credential to") + ); + let peer = native_peer(RemoteHostKind::Peer, Some("http://127.0.0.1:4010/"), &[]).unwrap(); + assert_eq!(peer.base_url, "http://127.0.0.1:4010"); + assert_eq!(peer.organization_id, None); + } + + /// A fake daemon that counts the connections it is sent and answers each + /// with `reply`, or with nothing when `reply` is `None`. + async fn fake_daemon( + reply: Option<&'static str>, + ) -> (String, Arc) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = format!("http://{}", listener.local_addr().unwrap()); + let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = seen.clone(); + let mut identity = + kin_core::runtime_identity::RuntimeServiceIdentity::current("hosted-daemon").unwrap(); + identity.namespace = None; + let identity_body = serde_json::to_string(&identity).unwrap(); + let identity_reply = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + identity_body.len(), + identity_body + ); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let mut buffer = vec![0_u8; 64 * 1024]; + let n = socket.read(&mut buffer).await.unwrap_or(0); + if buffer[..n].starts_with(b"GET /runtime/identity ") { + let _ = socket.write_all(identity_reply.as_bytes()).await; + continue; + } + counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Some(reply) = reply { + let _ = socket.write_all(reply.as_bytes()).await; + } + // Dropping the socket with no reply is an answer lost after + // the request was sent. + } + }); + (address, seen) + } + + fn sample_request() -> CommandTransferRequest { + CommandTransferRequest { + remote_base_url: "http://127.0.0.1:9".into(), + remote_token: None, + remote_organization_id: None, + repository_id: None, + source_ref: Some(RefName::branch(b"main").unwrap()), + destination_ref: Some(RefName::branch(b"main").unwrap()), + } + } + + /// The no-retry rule at the transport: a push whose answer is lost is + /// sent exactly once and reported lost, never as a refusal, and a refusal + /// is kept apart from a lost answer. + #[tokio::test] + async fn a_transfer_is_sent_once_and_a_lost_answer_is_not_sent_again() { + let (address, seen) = fake_daemon(None).await; + let client = + DaemonClient::from_base_url_with_explicit_authority(address, None, None).unwrap(); + for direction in [Act::Push, Act::Pull] { + let before = seen.load(std::sync::atomic::Ordering::SeqCst); + let dispatch = client + .transfer_once(direction.direction(), &sample_request()) + .await; + assert!( + matches!(dispatch, TransferDispatch::Lost(_)), + "{dispatch:?}" + ); + // Give a retry, were there one, the time to arrive. + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), before + 1); + } + + let (address, seen) = fake_daemon(Some( + "HTTP/1.1 409 Conflict\r\ncontent-type: text/plain\r\ncontent-length: 8\r\nconnection: close\r\n\r\nconflict", + )) + .await; + let client = + DaemonClient::from_base_url_with_explicit_authority(address, None, None).unwrap(); + let dispatch = client + .transfer_once(Act::Push.direction(), &sample_request()) + .await; + match dispatch { + TransferDispatch::Refused { status, body } => { + assert_eq!(status, 409); + assert_eq!(body, "conflict"); + } + other => panic!("a refusal is the daemon's answer, not a lost one: {other:?}"), + } + assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1); + + // A success whose body cannot be read is lost, not answered. + let (address, _) = fake_daemon(Some( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 2\r\nconnection: close\r\n\r\n{}", + )) + .await; + let client = + DaemonClient::from_base_url_with_explicit_authority(address, None, None).unwrap(); + let dispatch = client + .transfer_once(Act::Pull.direction(), &sample_request()) + .await; + assert!( + matches!(dispatch, TransferDispatch::Lost(_)), + "{dispatch:?}" + ); + } + + /// The session's capability is read from configuration, credentials and + /// what Git said: a repository with no remote can do neither, and one + /// with only a Git remote can publish when Git can carry the branch, and + /// update only while its current lineage supports exact Git admission. + #[test] + fn a_git_remote_can_update_only_when_exact_admission_is_available() { + let complete = kin_core::history_state::HistoryStanding::Complete; + let git_ready = Ok(()); + let temporary = tempfile::TempDir::new().unwrap(); + let layout = KinLayout::new(temporary.path().join(".kin")); + let git_pull = Err(super::super::workspace_git_remote::GIT_PULL_UNAVAILABLE.to_string()); + let (push, pull, _) = transfer_capability(&layout, &complete, &[], &git_ready, &git_pull); + for none in [push, pull] { + assert_eq!( + none, + Err("No remote is set up for this repository.".to_string()) + ); + } + + std::fs::create_dir_all(layout.root()).unwrap(); + let mut config = KinConfig::default(); + config.git.remotes.push(kin_core::GitRemoteTransportConfig { + name: "origin".into(), + fetch_urls: vec!["https://github.invalid/example/demo.git".into()], + push_urls: Vec::new(), + fetch_refspecs: Vec::new(), + push_refspecs: Vec::new(), + }); + config.save(&layout.config_path()).unwrap(); + let remotes = read_remotes(&layout, &[]).unwrap(); + assert_eq!(remotes.remotes.len(), 1); + assert!(!remotes.remotes[0].native); + assert_eq!( + remotes.remotes[0].address.as_deref(), + Some("https://github.invalid/example/demo.git") + ); + assert_eq!(remotes.default, Ok("origin".to_string())); + + let (push, pull, _) = transfer_capability(&layout, &complete, &[], &git_ready, &git_pull); + assert_eq!(push, Ok(())); + let never = pull.unwrap_err(); + assert!(never.contains("Kin-native changes"), "{never}"); + assert!(!never.contains('`'), "{never}"); + + // Without Git, or without a Git repository at the project, nothing + // can be published either, and the reason is Git's. + let no_git = Err("Git is not on this machine.".to_string()); + let (push, _, _) = transfer_capability(&layout, &complete, &[], &no_git, &git_pull); + assert_eq!(push, no_git); + let (_, pull, fetch) = transfer_capability(&layout, &complete, &[], &git_ready, &Ok(())); + assert_eq!(pull, Ok(())); + assert!(!fetch); + } + + /// A native remote is usable only with an address, a complete history + /// and a credential Kin can read without asking. + #[test] + fn a_native_remote_needs_a_credential_and_a_complete_history() { + let remote = |credential| Remote { + name: "peer".into(), + host: RemoteHostKind::Peer, + native: true, + default: true, + address: Some("http://127.0.0.1:4010".into()), + peer: Ok(Peer { + base_url: "http://127.0.0.1:4010".into(), + organization_id: None, + credential, + }), + }; + assert!(usable(&remote(Credential::Environment("token".into())), None).is_ok()); + let missing = usable(&remote(Credential::None), None).unwrap_err(); + assert!( + missing.contains("holds no credential for peer"), + "{missing}" + ); + let partial = usable( + &remote(Credential::Environment("token".into())), + Some("Kin holds 1 of this repository's 4,673 commits."), + ) + .unwrap_err(); + assert!(partial.contains("Kin holds 1 of")); + // A credential's label never carries the credential. + assert_eq!(Credential::Saved("s3cr3t".into()).label(), "saved"); + } +} diff --git a/crates/kin-cli/src/commands/workspace_ui.rs b/crates/kin-cli/src/commands/workspace_ui.rs index 8b5751da6..c76fd0abe 100644 --- a/crates/kin-cli/src/commands/workspace_ui.rs +++ b/crates/kin-cli/src/commands/workspace_ui.rs @@ -148,6 +148,23 @@ impl Catalog { }) } + /// The key of every location the registry names, for a caller that asks + /// about many locations at once. Blocking. + pub(super) fn registered_keys(&self) -> std::collections::HashSet { + if !self.reads_registry { + return Default::default(); + } + kin_core::registry::KinRegistry::load() + .map(|registry| { + registry + .repos + .iter() + .map(|row| repository_key(&row.path)) + .collect() + }) + .unwrap_or_default() + } + /// Read the registry and name what it holds. Blocking: it reads the /// registry and each repository's own records. pub(super) fn read(&self) -> Listing { @@ -330,6 +347,7 @@ pub(super) fn routes() -> Router> { .route("/workspace/", get(index)) .merge(page_assets()) .route("/api/workspace/session", get(session)) + .route("/api/workspace/session/pull", get(session_pull)) .route("/api/workspace/branches", get(branches)) .route("/api/workspace/branch/preview", post(branch_preview)) .route("/api/workspace/branch/confirm", post(branch_confirm)) @@ -345,8 +363,12 @@ pub(super) fn routes() -> Router> { .route("/api/workspace/branch/create", post(branch_create)) .route("/api/workspace/merge/confirm", post(merge_confirm)) .route("/api/workspace/commit", post(commit)) + .route("/api/workspace/entity/restore", post(entity_restore)) .merge(super::workspace_explore::routes()) .merge(super::workspace_agents::routes()) + .merge(super::workspace_remotes::routes()) + .merge(super::workspace_verify::routes()) + .merge(super::workspace_flags::routes()) .layer(DefaultBodyLimit::max(64 * 1024)) } @@ -404,7 +426,7 @@ async fn branch_preview( let (_, port) = crate::daemon_client::repo_daemon_recorded_endpoint(state.layout.root()); let port = port.filter(|port| *port != 0) .context("No daemon endpoint is recorded. Open repository content before requesting a branch preview.")?; - DaemonClient::from_running_workspace(format!("http://127.0.0.1:{port}"), &state.layout) + DaemonClient::for_workspace_page("branch preview", format!("http://127.0.0.1:{port}"), &state.layout, Duration::from_secs(30)) .await?.branch_switch_preview(&request).await }.await; match answer { @@ -611,10 +633,12 @@ async fn merge_preview( }; let answer = async { // Preview observes a running, verified runtime. It must not start a - // daemon or open repository recovery as a side effect of previewing. + // daemon or open repository recovery as a side effect of previewing, + // except to replace a daemon of an older release, which this release + // never previews through. let (_, port) = crate::daemon_client::repo_daemon_recorded_endpoint(state.layout.root()); let port = port.filter(|port| *port != 0).context("No daemon endpoint is recorded. Open repository content before requesting a merge preview.")?; - DaemonClient::from_running_workspace(format!("http://127.0.0.1:{port}"), &state.layout) + DaemonClient::for_workspace_page("merge preview", format!("http://127.0.0.1:{port}"), &state.layout, Duration::from_secs(30)) .await? .merge_preview(&super::merge::MergePreviewRequest { source }) .await @@ -697,8 +721,15 @@ async fn merge_confirm( struct CommitBody { message: String, operation_id: kin_model::OperationId, + /// Commit only these entities' changes, as the uncommitted-work guide + /// names them, and leave every other change uncommitted. + #[serde(default)] + entities: Option>, } +/// The most entities one commit from the page may choose. +const COMMIT_ENTITIES_MAX: usize = 10_000; + /// This attempt recorded nothing: it stopped before the daemon was asked, or /// the daemon refused before publication. fn commit_refused(operation_id: kin_model::OperationId, error: &anyhow::Error) -> Response { @@ -713,7 +744,8 @@ fn commit_refused(operation_id: kin_model::OperationId, error: &anyhow::Error) - .into_response() } -/// Commit the whole working copy, as `kin commit -m` does. +/// Commit the whole working copy, as `kin commit -m` does, or only the +/// entities the body names, as `kin commit -m --entity` does. async fn commit( State(state): State>, headers: HeaderMap, @@ -724,6 +756,8 @@ async fn commit( } let operation_id = body.operation_id; let message = body.message.trim().to_string(); + let entities = body.entities.unwrap_or_default(); + let chose = !entities.is_empty(); let checked = (|| { anyhow::ensure!( !operation_id.0.is_nil(), @@ -734,6 +768,14 @@ async fn commit( message.chars().count() <= COMMIT_MESSAGE_MAX_CHARS, "a commit message is at most {COMMIT_MESSAGE_MAX_CHARS} characters" ); + anyhow::ensure!( + entities.len() <= COMMIT_ENTITIES_MAX, + "one commit chooses at most {COMMIT_ENTITIES_MAX} entities" + ); + anyhow::ensure!( + entities.iter().all(|entity| !entity.trim().is_empty()), + "every chosen entity needs its id" + ); Ok(()) })(); if let Err(error) = checked { @@ -748,18 +790,24 @@ async fn commit( // Run to its end whatever becomes of this request. A page that closes // mid-commit must not leave the daemon's answer unread, and attribution // resolves against this URL's repository, never a caller-supplied actor. + let chosen = entities.clone(); let committing = tokio::spawn(async move { - super::commit::commit_for_app(&state.layout, &message, operation_id).await + super::commit::commit_for_app(&state.layout, &message, operation_id, &chosen).await }); let outcome = committing.await.unwrap_or_else(|error| { super::commit::AppCommit::Unknown(anyhow::anyhow!("the commit did not finish: {error}")) }); match outcome { - super::commit::AppCommit::Committed { change_id } => Json(serde_json::json!({ - "operation_id":operation_id, "outcome":"committed", - "change_id":change_id, "title":title, - })) - .into_response(), + super::commit::AppCommit::Committed { change_id } => { + let mut answer = serde_json::json!({ + "operation_id":operation_id, "outcome":"committed", + "change_id":change_id, "title":title, + }); + if chose { + answer["entities"] = serde_json::json!(entities); + } + Json(answer).into_response() + } super::commit::AppCommit::NothingToCommit => Json(serde_json::json!({ "operation_id":operation_id, "outcome":"nothing_to_commit", })) @@ -775,31 +823,137 @@ async fn commit( } } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EntityRestoreBody { + entity: String, + #[serde(default)] + to: Option, + operation_id: kin_model::OperationId, + #[serde(default)] + expected: Option, +} + +/// This restore wrote nothing: it stopped before the daemon was asked, or the +/// daemon refused before writing. +fn restore_refused(operation_id: kin_model::OperationId, error: &anyhow::Error) -> Response { + ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error":format!("{error:#}"), "outcome":"refused", + "operation_id":operation_id, + "remedy":"Nothing was changed. Resolve what is reported, then restore it again.", + })), + ) + .into_response() +} + +/// Put one entity back as it was at the head, or at an earlier change, as an +/// uncommitted edit, as `kin checkout --entity` does. +async fn entity_restore( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !review_ui::may_write(&headers, &state) { + return review_ui::write_refused(); + } + let operation_id = body.operation_id; + let entity = body.entity.trim().to_string(); + let checked = (|| { + anyhow::ensure!( + !operation_id.0.is_nil(), + "a nonempty operation ID is required" + ); + anyhow::ensure!(!entity.is_empty(), "a restore names the entity it restores"); + Ok(()) + })(); + if let Err(error) = checked { + return restore_refused(operation_id, &error); + } + // Run to its end whatever becomes of this request, like a commit: a page + // that closes mid-restore must not leave the daemon's answer unread. + let (to, expected) = (body.to, body.expected); + let restoring = tokio::spawn(async move { + super::entity_work::restore_for_app(&state.layout, entity, to, expected, operation_id).await + }); + let outcome = restoring.await.unwrap_or_else(|error| { + super::entity_work::AppRestore::Unknown(anyhow::anyhow!( + "the restore did not finish: {error}" + )) + }); + match outcome { + super::entity_work::AppRestore::Restored(restored) => Json(serde_json::json!({ + "operation_id":operation_id, "outcome":"restored", + "entity":restored.entity, "name":restored.name, + "to":restored.restored_to, "replayed":restored.replayed, + })) + .into_response(), + super::entity_work::AppRestore::Refused(error) => restore_refused(operation_id, &error), + // A lost answer can follow a restore that landed. Return its identity + // for reconciliation and never replay it behind the page. + super::entity_work::AppRestore::Unknown(error) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ + "error":format!("{error:#}"), "outcome":"unknown", + "operation_id":operation_id, + "remedy":"The entity may have been restored. Refresh the uncommitted work before restoring it again. This request was not retried.", + }))).into_response(), + } +} + /// How much of a repository's history its store holds, from the store's own /// record of it. `complete` is a store with its whole history, which a /// repository that began in Kin always is. `partial` is a converted project /// whose one admitted commit is being followed by the rest in the background. /// `none` is a store that holds that one commit and derives no more: it was /// asked not to, or the project it came from has no more to give. +/// +/// A partial store also says where the rest of its history stands. `deriving` +/// is true while it is being read, and `step` and `steps` then say how far +/// the reading has come, when the worker has said. `staged` is true once all +/// of it has been read and the repository has only to switch to it, which it +/// does at its next quiet point; `admitted` stays at what the repository shows +/// until then. `waiting_for` is there while that switch is held back, and +/// says what for, as a clause that follows "waiting for". pub(super) fn history_json( standing: &kin_core::history_state::HistoryStanding, ) -> serde_json::Value { - use kin_core::history_state::{HistoryMode, HistoryStanding}; + use kin_core::history_state::{DerivationState, HistoryMode, HistoryStanding}; match standing { HistoryStanding::Complete => serde_json::json!({"state":"complete"}), // A record that cannot be read is treated as incomplete, as every // other surface treats it, and no count is claimed for it. HistoryStanding::Unreadable { .. } => serde_json::json!({"state":"partial"}), - HistoryStanding::Partial { record, .. } => { - let filling = record.mode == HistoryMode::Background && record.underivable.is_none(); + HistoryStanding::Partial { record, progress } => { + let staged = progress + .as_ref() + .is_some_and(|progress| progress.state == DerivationState::Staged); + // A history that has been read is on its way in whatever mode the + // store records, so it is never shown as one that will not come. + let filling = + staged || (record.mode == HistoryMode::Background && record.underivable.is_none()); + let running = super::daemon_activity::running_history(standing); let mut history = serde_json::json!({ "state": if filling { "partial" } else { "none" }, "admitted": record.admitted_commits, - "deriving": super::daemon_activity::running_history(standing).is_some(), + "deriving": running.is_some(), + "staged": staged, }); + if let Some((step, steps)) = running + .as_ref() + .and_then(|running| running.step.zip(running.steps)) + { + history["step"] = serde_json::json!(step); + history["steps"] = serde_json::json!(steps); + } if let Some(total) = record.reachable_commits { history["total"] = serde_json::json!(total); } + if let Some(waiting_for) = progress + .as_ref() + .and_then(|progress| progress.staged_waiting_for()) + { + history["waiting_for"] = serde_json::json!(waiting_for); + } history } } @@ -827,56 +981,103 @@ async fn session(State(state): State>) -> Response { // Read recorded endpoint metadata and verify health without discovery, // endpoint cleanup, startup or process control. Other resource // reads use the ordinary command resolver when the user opens that resource. - let observed = tokio::time::timeout(Duration::from_secs(5), async { + let observe = tokio::time::timeout(Duration::from_secs(5), async { let (pid, port) = crate::daemon_client::repo_daemon_recorded_endpoint(state.layout.root()); match (pid, port) { (Some(_), Some(port)) if port != 0 => { - let client = DaemonClient::from_running_workspace( - format!("http://127.0.0.1:{port}"), &state.layout, - ).await?; + let client = DaemonClient::for_workspace_page( + "local UI", + format!("http://127.0.0.1:{port}"), + &state.layout, + Duration::from_secs(5), + ) + .await?; let health = client.health().await?; + // Whether the repository declares a test command Kin can run, + // read from its manifests by the same daemon a run would use, + // as it last said: a daemon that is writing can take most of + // a minute to say, and nothing on the page waits for it. One + // that cannot say leaves it unknown. + let tests = super::workspace_verify::test_command_known(&state, client).flatten(); Ok::<_, anyhow::Error>(( serde_json::json!({"state":"running","detail":health.status, "graph_loaded":health.graph_loaded, "reconciliation":health.reconciliation_status}), - health.repo_id, health.workspace_id, + health.repo_id, + health.workspace_id, + tests, )) } _ => Ok(( serde_json::json!({"state":"unknown", "detail":"No complete local daemon endpoint is recorded. Opening repository content uses Kin's normal startup path."}), - None, None, + None, + None, + None, )), } - }).await; - let (runtime, repository_id, workspace_id) = match observed { + }); + // Whether Git can carry a branch for this project, asked of Git at most + // once in a while, beside the runtime reading rather than after it. A + // session never asks a remote anything, and never waits for the daemon + // to say whether this branch can update from Git: it says what is known, + // and that it is checking when nothing is yet. + let (observed, git) = tokio::join!(observe, super::workspace_git_remote::ready(&state)); + let git_pull = super::workspace_git_remote::update_ready_known(&state, &git); + let (runtime, repository_id, workspace_id, verify_unavailable) = match observed { Ok(Ok(reading)) => reading, Ok(Err(error)) => ( serde_json::json!({"state":"unknown", "detail":format!("{error:#}")}), None, None, + None, ), Err(_) => ( serde_json::json!({"state":"unknown", "detail":"Runtime observation did not finish within five seconds. Retry to check again."}), None, None, + None, ), }; let key = repository_key(state.layout.working_dir()); // Read for this answer, so a repository added since the server started // is in the list the page switches between. - let (listing, history) = { + let (listing, history, transfer) = { let state = state.clone(); tokio::task::spawn_blocking(move || { + let listing = state.catalog.as_ref().map(|catalog| catalog.read()); + // The store's own record of its history, so the page can say + // when the record of changes is still filling in. + let standing = kin_core::history_state::standing(&state.layout); + let machine: Vec<_> = listing + .iter() + .flat_map(|listing| &listing.repositories) + .map(|listed| listed.layout.clone()) + .collect(); + let transfer = + session_transfer(&state.layout, &standing, &machine, &git, git_pull.as_ref()); + (listing, history_json(&standing), transfer) + }) + .await + .unwrap_or_else(|_| { + let unread = || Err("Kin could not read this repository's remotes.".to_string()); ( - state.catalog.as_ref().map(|catalog| catalog.read()), - // The store's own record of its history, so the page can say - // when the record of changes is still filling in. - history_json(&kin_core::history_state::standing(&state.layout)), + None, + serde_json::Value::Null, + SessionTransfer { + push: unread(), + pull: unread(), + fetch: false, + pull_checking: false, + }, ) }) - .await - .unwrap_or((None, serde_json::Value::Null)) }; + let SessionTransfer { + push: remote_push, + pull: remote_pull, + fetch: remote_fetch, + pull_checking, + } = transfer; let repositories: Vec<_> = listing.iter().flat_map(|listing| &listing.repositories).map(|listed| { let item = &listed.entry; serde_json::json!({"key":item.key,"name":item.name,"owner":item.owner,"host":item.host,"url":item.url,"selected":item.key == key}) @@ -886,7 +1087,13 @@ async fn session(State(state): State>) -> Response { "repositories":repositories, "registry_warning":listing.as_ref().and_then(|listing| listing.warning.as_deref()), "catalog_basis":"Repositories registered on this machine, read for this answer.", - "capabilities":{"graph":true,"review":true,"merge":true,"commit":true,"editor":true,"branch_switch":true,"branch_switch_unavailable_reason":null,"branch_create":true,"conflict_details":true,"conflict_settle":true}, + "capabilities":{"graph":true,"review":true,"merge":true,"commit":true,"entity_commit":true,"entity_restore":true,"editor":true,"branch_switch":true,"branch_switch_unavailable_reason":null,"branch_create":true,"conflict_details":true,"conflict_settle":true, + "remotes":true, + "remote_push":remote_push.is_ok(),"remote_pull":remote_pull.is_ok(),"remote_fetch":remote_fetch, + "remote_unavailable_reason":remote_push.as_ref().err(), + "remote_pull_unavailable_reason":remote_pull.as_ref().err(), + "remote_pull_checking":pull_checking, + "review_flags":true,"verify_run":verify_unavailable.is_none(),"verify_run_unavailable_reason":verify_unavailable}, "history":history, "runtime":runtime,"version":env!("CARGO_PKG_VERSION"),"actor":state.actor,"token":state.token, "review_url":format!("{}/review/",state.base_path), @@ -894,6 +1101,100 @@ async fn session(State(state): State>) -> Response { })).into_response() } +/// Whether a push, a pull and a fetch can work here, as a session says it. +struct SessionTransfer { + push: Result<(), String>, + pull: Result<(), String>, + fetch: bool, + /// The pull depends on whether this branch can update from Git, which + /// is being asked and is not known yet. `pull` then says nothing either + /// way, and the page asks `/api/workspace/session/pull` for it. + pull_checking: bool, +} + +/// What a push, a pull and a fetch can do here, from configuration, +/// credentials, what Git said, and, when it is known, whether this branch +/// can update from Git. Blocking. +fn session_transfer( + layout: &kin_core::KinLayout, + standing: &kin_core::history_state::HistoryStanding, + machine: &[kin_core::KinLayout], + git: &Result<(), String>, + git_pull: Option<&Result<(), String>>, +) -> SessionTransfer { + let capability = |git_pull: &Result<(), String>| { + super::workspace_remotes::transfer_capability(layout, standing, machine, git, git_pull) + }; + if let Some(git_pull) = git_pull { + let (push, pull, fetch) = capability(git_pull); + return SessionTransfer { + push, + pull, + fetch, + pull_checking: false, + }; + } + // Not known yet. When the answer would not change what a pull can do, + // as with no Git remote, or one that cannot pull for another reason, + // the session says so; otherwise it says the pull is being checked. + let (push, pull_if_ready, fetch) = capability(&Ok(())); + let (_, pull_if_not, _) = capability(&Err(String::new())); + if pull_if_ready == pull_if_not { + return SessionTransfer { + push, + pull: pull_if_ready, + fetch, + pull_checking: false, + }; + } + SessionTransfer { + push, + pull: Err(PULL_CHECKING.to_string()), + fetch, + pull_checking: true, + } +} + +/// What a session says of a pull while it checks whether this branch can +/// update from Git. +const PULL_CHECKING: &str = "Kin is checking whether this branch can update from Git."; + +/// GET /api/workspace/session/pull: whether a pull can work here, once Kin +/// has asked the repository's daemon whether this branch can update from +/// Git. A session answers without waiting for that ask and says +/// `remote_pull_checking`; the page reads the pull from here. +async fn session_pull(State(state): State>) -> Response { + let git = super::workspace_git_remote::ready(&state).await; + let git_pull = super::workspace_git_remote::update_ready(&state).await; + let transfer = { + let state = state.clone(); + tokio::task::spawn_blocking(move || { + let listing = state.catalog.as_ref().map(|catalog| catalog.read()); + let standing = kin_core::history_state::standing(&state.layout); + let machine: Vec<_> = listing + .iter() + .flat_map(|listing| &listing.repositories) + .map(|listed| listed.layout.clone()) + .collect(); + session_transfer(&state.layout, &standing, &machine, &git, Some(&git_pull)) + }) + .await + }; + match transfer { + Ok(transfer) => Json(serde_json::json!({ + "remote_pull": transfer.pull.is_ok(), + "remote_pull_unavailable_reason": transfer.pull.as_ref().err(), + "remote_pull_checking": false, + "remote_fetch": transfer.fetch, + })) + .into_response(), + Err(_) => review_ui::refusal( + StatusCode::INTERNAL_SERVER_ERROR, + &anyhow::anyhow!("Kin could not read this repository's remotes."), + ), + } +} + /// Canonicalize the explicitly selected repository at the opening boundary. /// This locates its Kin authority; no semantic answer comes from host bytes. fn workspace_layout(path: &Path) -> Result { @@ -1140,6 +1441,87 @@ mod tests { assert_ne!(first, second); } + /// The page is told when the whole history has been read and the + /// repository has only to switch to it, and what that switch waits for, + /// so it never says a history is still being read when it is not. + #[test] + fn a_staged_history_is_reported_as_read_and_waiting_to_switch() { + use kin_core::history_state::{ + self, DerivationState, HistoryDerivationProgress, HistoryMode, HistoryStateRecord, + }; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join(".kin"); + std::fs::create_dir_all(&root).unwrap(); + let layout = kin_core::KinLayout::new(root); + let stamp = |mode| { + history_state::stamp_staged( + &layout, + &HistoryStateRecord::new( + mode, + "0123456789abcdef0123456789abcdef01234567".into(), + None, + "snapshot".into(), + Vec::new(), + Some(4_673), + None, + ), + ) + .unwrap(); + }; + stamp(HistoryMode::Background); + let read = || history_json(&history_state::standing(&layout)); + + // Nothing derived yet: the fields the page already reads, and staged + // answered rather than left out. + assert_eq!( + read(), + serde_json::json!({ + "state": "partial", "admitted": 1, "total": 4_673, + "deriving": false, "staged": false, + }) + ); + + // Being read: how far the reading has come, when the worker said. + let mut running = HistoryDerivationProgress::new(DerivationState::Running); + running.step = Some(11); + running.steps = Some(16); + running.pid = None; + history_state::write_progress(&layout, &running).unwrap(); + assert_eq!( + read(), + serde_json::json!({ + "state": "partial", "admitted": 1, "total": 4_673, + "deriving": true, "staged": false, "step": 11, "steps": 16, + }) + ); + + let mut staged = HistoryDerivationProgress::new(DerivationState::Staged); + staged.detail = Some("/stages/one".into()); + history_state::write_progress(&layout, &staged).unwrap(); + assert_eq!( + read(), + serde_json::json!({ + "state": "partial", "admitted": 1, "total": 4_673, + "deriving": false, "staged": true, + }) + ); + + history_state::note_staged_wait(&layout, Some("an open merge to be finished")).unwrap(); + let waiting = read(); + assert_eq!(waiting["staged"], true); + assert_eq!(waiting["waiting_for"], "an open merge to be finished"); + // Where the staged store is kept is never part of the answer. + assert!(!waiting.to_string().contains("stages"), "{waiting}"); + + // A store asked to derive nothing whose history was read anyway is on + // its way to having it, not a store that will never have it. + stamp(HistoryMode::None); + assert_eq!(read()["state"], "partial"); + history_state::clear_progress(&layout).unwrap(); + assert_eq!(read()["state"], "none"); + assert_eq!(read()["staged"], false); + } + /// One server with two repositories mounted, the way `run` composes it, /// and no registry behind it. Neither layout holds a repository, so every /// route under test answers before any daemon is looked for. @@ -1452,6 +1834,409 @@ mod tests { server.abort(); } + /// Push and pull from the page need its authority and an operation, and + /// a repository with no Kin remote refuses them with nothing sent. The + /// remotes reading and the session say the same without asking anyone. + #[tokio::test] + async fn push_and_pull_refuse_with_nothing_sent_when_they_cannot_run() { + let (origin, tokens, _hub, server) = serve_two().await; + let client = reqwest::Client::new(); + for verb in ["push", "pull", "fetch"] { + let url = format!("{origin}/repositories/second/api/workspace/{verb}"); + let operation_id = kin_model::OperationId::new(); + for (token, request_origin) in [ + (tokens[0].as_str(), origin.as_str()), + (tokens[1].as_str(), "https://foreign.example"), + ("", origin.as_str()), + ] { + let refused = client + .post(&url) + .header("x-kin-review-token", token) + .header("origin", request_origin) + .json(&serde_json::json!({"operation_id":operation_id})) + .send() + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{verb}"); + } + let send = |body: serde_json::Value| { + client + .post(&url) + .header("x-kin-review-token", &tokens[1]) + .header("origin", &origin) + .json(&body) + .send() + }; + let nil = send(serde_json::json!({"operation_id":uuid::Uuid::nil()})) + .await + .unwrap(); + assert_eq!(nil.status(), StatusCode::CONFLICT); + let nil: serde_json::Value = nil.json().await.unwrap(); + assert_eq!(nil["outcome"], "refused"); + assert!(nil["error"].as_str().unwrap().contains("operation ID")); + // A caller never names the credential or the address. + let injected = send(serde_json::json!({ + "operation_id":operation_id,"url":"http://127.0.0.1:1", + })) + .await + .unwrap(); + assert_eq!(injected.status(), StatusCode::UNPROCESSABLE_ENTITY); + // Refused before anything is sent, so the same operation may be + // sent again once what is reported is corrected. + for _ in 0..2 { + let refused = send(serde_json::json!({ + "operation_id":operation_id,"remote":null,"ref":"main", + })) + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::CONFLICT, "{verb}"); + let refused: serde_json::Value = refused.json().await.unwrap(); + assert_eq!(refused["outcome"], "refused"); + assert_eq!(refused["operation_id"], serde_json::json!(operation_id)); + assert_eq!(refused["error"], "No remote is set up for this repository."); + assert!(refused["remedy"] + .as_str() + .unwrap() + .starts_with("Nothing was sent.")); + assert!(refused.get("already_sent").is_none()); + } + let named = send(serde_json::json!({"operation_id":operation_id,"remote":"origin"})) + .await + .unwrap(); + let named: serde_json::Value = named.json().await.unwrap(); + assert_eq!( + named["error"], + "No remote named origin is set up for this repository." + ); + } + + let remotes: serde_json::Value = client + .get(format!( + "{origin}/repositories/second/api/workspace/remotes" + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + remotes["schema"], + super::super::workspace_remotes::REMOTES_SCHEMA + ); + assert_eq!(remotes["remotes"], serde_json::json!([])); + assert_eq!(remotes["default_remote"], serde_json::Value::Null); + assert_eq!( + remotes["default_remote_reason"], + "No remote is set up for this repository." + ); + + let session: serde_json::Value = client + .get(format!( + "{origin}/repositories/second/api/workspace/session" + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let capabilities = &session["capabilities"]; + assert_eq!(capabilities["remotes"], true); + assert_eq!(capabilities["remote_push"], false); + assert_eq!(capabilities["remote_pull"], false); + assert_eq!(capabilities["remote_fetch"], false); + assert_eq!( + capabilities["remote_pull_unavailable_reason"], + "No remote is set up for this repository." + ); + assert_eq!( + capabilities["remote_unavailable_reason"], + "No remote is set up for this repository." + ); + server.abort(); + } + + /// A session answers while the repository's daemon is slow to say + /// whether this branch can update from Git. Measured on a busy axum, the + /// session waited ten seconds on that question before every page could + /// draw, and then said the daemon did not answer. It now says the pull is + /// being checked, the page reads the pull on its own route, which joins + /// the ask already running, and the next session has the answer. + /// + /// Breaking it: have the session wait for the ask again, and its answer + /// takes the whole held ask. + #[tokio::test] + async fn a_session_answers_while_the_git_update_check_is_slow() { + let temporary = tempfile::TempDir::new().unwrap(); + let layout = kin_core::KinLayout::new(temporary.path().join(".kin")); + std::fs::create_dir_all(layout.root()).unwrap(); + let mut config = kin_core::KinConfig::default(); + config.git.remotes.push(kin_core::GitRemoteTransportConfig { + name: "origin".into(), + fetch_urls: vec!["https://github.invalid/example/demo.git".into()], + push_urls: Vec::new(), + fetch_refspecs: Vec::new(), + push_refspecs: Vec::new(), + }); + config.save(&layout.config_path()).unwrap(); + + let listener = review_ui::bind(Some(0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let catalog = empty_catalog(); + let hub = Hub::new(port, catalog.clone(), None); + let state = Arc::new(UiState::workspace( + layout, + port, + "/repositories/slow".to_string(), + catalog, + )); + // Git itself can carry the branch; only the daemon's answer is slow. + state.transfers.remember_git_ready(Ok(())); + hub.mount("slow".to_string(), state.clone()); + let router = hub_router(hub.clone()); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let held = Duration::from_secs(3); + *super::super::workspace_git_remote::UPDATE_STATUS_PAUSE_FOR_TEST + .lock() + .unwrap() = Some((Arc::as_ptr(&state) as usize, held)); + + let client = reqwest::Client::new(); + let base = format!("http://127.0.0.1:{port}/repositories/slow"); + let read = |path: &'static str| { + let client = client.clone(); + let url = format!("{base}{path}"); + async move { + let asked = std::time::Instant::now(); + let answer: serde_json::Value = + client.get(url).send().await.unwrap().json().await.unwrap(); + (answer, asked.elapsed()) + } + }; + let (session, took) = read("/api/workspace/session").await; + assert!( + took < Duration::from_millis(1500), + "the session waited {took:?} for the Git update check" + ); + let capabilities = &session["capabilities"]; + assert_eq!(capabilities["remote_push"], true, "{capabilities}"); + assert_eq!(capabilities["remote_pull"], false); + assert_eq!(capabilities["remote_pull_checking"], true); + + let (pull, took) = read("/api/workspace/session/pull").await; + assert!( + took < held + Duration::from_millis(1500), + "the pull read joins the ask the session started: {took:?}" + ); + assert_eq!(pull["remote_pull_checking"], false); + assert_eq!(pull["remote_pull"], false); + assert_eq!( + pull["remote_pull_unavailable_reason"], + "Open repository content to read whether this branch can update from Git." + ); + + let (session, took) = read("/api/workspace/session").await; + assert!(took < Duration::from_millis(1500), "{took:?}"); + let capabilities = &session["capabilities"]; + assert_eq!(capabilities["remote_pull_checking"], false); + assert_eq!( + capabilities["remote_pull_unavailable_reason"], + pull["remote_pull_unavailable_reason"] + ); + *super::super::workspace_git_remote::UPDATE_STATUS_PAUSE_FOR_TEST + .lock() + .unwrap() = None; + server.abort(); + } + + /// Entity work answers the page's checks before any daemon is asked, and + /// the session tells the page this server can do it. + #[tokio::test] + async fn entity_commit_and_restore_check_authority_and_shape_before_anything_is_asked() { + let (origin, tokens, _hub, server) = serve_two().await; + let client = reqwest::Client::new(); + let base = format!("{origin}/repositories/second/api/workspace"); + let operation_id = kin_model::OperationId::new(); + let restore = format!("{base}/entity/restore"); + let foreign = client + .post(&restore) + .header("x-kin-review-token", &tokens[0]) + .header("origin", &origin) + .json(&serde_json::json!({"entity":"e","operation_id":operation_id})) + .send() + .await + .unwrap(); + assert_eq!(foreign.status(), StatusCode::FORBIDDEN); + for (body, reason) in [ + ( + serde_json::json!({"entity":" ","operation_id":operation_id}), + "names the entity", + ), + ( + serde_json::json!({"entity":"e","operation_id":uuid::Uuid::nil()}), + "operation ID is required", + ), + ] { + let refused = client + .post(&restore) + .header("x-kin-review-token", &tokens[1]) + .header("origin", &origin) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::CONFLICT); + let answer: serde_json::Value = refused.json().await.unwrap(); + assert_eq!(answer["outcome"], "refused"); + assert!( + answer["error"].as_str().unwrap().contains(reason), + "{answer}" + ); + assert!(answer["remedy"] + .as_str() + .unwrap() + .contains("Nothing was changed")); + } + // A restore names an entity, never where its content is projected. + let projected = client + .post(&restore) + .header("x-kin-review-token", &tokens[1]) + .header("origin", &origin) + .json(&serde_json::json!({ + "entity":"e","operation_id":operation_id,"path":"src/lib.rs", + })) + .send() + .await + .unwrap(); + assert_eq!(projected.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let refused = client + .post(format!("{base}/commit")) + .header("x-kin-review-token", &tokens[1]) + .header("origin", &origin) + .json(&serde_json::json!({ + "message":"keep one","operation_id":operation_id,"entities":["a",""], + })) + .send() + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::CONFLICT); + let answer: serde_json::Value = refused.json().await.unwrap(); + assert!( + answer["error"].as_str().unwrap().contains("needs its id"), + "{answer}" + ); + + let session: serde_json::Value = client + .get(format!("{base}/session")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(session["capabilities"]["entity_commit"], true); + assert_eq!(session["capabilities"]["entity_restore"], true); + server.abort(); + } + + /// A flag from the page is refused in words, with nothing asked of the + /// repository, when it lacks the page's authority or names no entity, and + /// a caller never names who set it. + #[tokio::test] + async fn a_flag_needs_authority_and_an_entity_before_anything_is_asked() { + let (origin, tokens, _hub, server) = serve_two().await; + let client = reqwest::Client::new(); + let session: serde_json::Value = client + .get(format!( + "{origin}/repositories/second/api/workspace/session" + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(session["capabilities"]["review_flags"], true); + + let operation_id = kin_model::OperationId::new(); + let entity = uuid::Uuid::new_v4(); + for (path, body) in [ + ( + "flags", + serde_json::json!({ + "entity":entity,"note":"look again","fingerprint":"ab".repeat(32), + "operation_id":operation_id, + }), + ), + ( + "flags/resolve", + serde_json::json!({"entity":entity,"operation_id":operation_id}), + ), + ] { + let url = format!("{origin}/repositories/second/api/workspace/{path}"); + for (token, request_origin) in [ + (tokens[0].as_str(), origin.as_str()), + (tokens[1].as_str(), "https://foreign.example"), + ("", origin.as_str()), + ] { + let refused = client + .post(&url) + .header("x-kin-review-token", token) + .header("origin", request_origin) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{path}"); + } + // An entity is named by its id, never by a name or a place. + for unnamed in ["ServerReply.send", "src/reply.ts", ""] { + let mut named = body.clone(); + named["entity"] = serde_json::json!(unnamed); + let refused = client + .post(&url) + .header("x-kin-review-token", &tokens[1]) + .header("origin", &origin) + .json(&named) + .send() + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::CONFLICT, "{path}"); + let said: serde_json::Value = refused.json().await.unwrap(); + assert_eq!(said["outcome"], "refused"); + assert_eq!(said["operation_id"], serde_json::json!(operation_id)); + assert!( + said["error"] + .as_str() + .unwrap() + .contains("on an entity, named by its id"), + "{said}" + ); + assert!(said["remedy"] + .as_str() + .unwrap() + .contains("Nothing was recorded")); + } + let mut signed = body.clone(); + signed["author"] = serde_json::json!("caller"); + let injected = client + .post(&url) + .header("x-kin-review-token", &tokens[1]) + .header("origin", &origin) + .json(&signed) + .send() + .await + .unwrap(); + assert_eq!( + injected.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{path}" + ); + } + server.abort(); + } + /// The server's boundaries can be tested without starting a repository /// daemon or admitting any content. Tokens belong to URL scopes. #[tokio::test] diff --git a/crates/kin-cli/src/commands/workspace_verify.rs b/crates/kin-cli/src/commands/workspace_verify.rs new file mode 100644 index 000000000..09c33643c --- /dev/null +++ b/crates/kin-cli/src/commands/workspace_verify.rs @@ -0,0 +1,977 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Running the tests that reach a piece of work, from a repository's page. +//! +//! The page asks, and this server answers at once with an operation it can +//! follow. The repository's daemon does the work, as it does for `kin verify +//! run`: it plans the tests that reach the work, runs them with the test +//! command the repository's own manifests declare, in its working tree, and +//! records the run against the fingerprints of the entities it ran against. +//! A guide of uncommitted work then reads each test's latest result from that +//! record, so the page and the terminal cannot disagree about a test. +//! +//! This server keeps only the operations it started, while it runs. The +//! results themselves are the repository's. + +use std::sync::{Arc, Mutex}; + +use axum::extract::{Path as UrlPath, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::review_ui::{self, UiState}; +use super::verify::{WorkVerifyRequest, WorkVerifyResponse, WorkVerifyScope}; +use crate::daemon_client::DaemonClient; + +/// The operations remembered. The oldest finished one is dropped first. +const RUNS_KEPT: usize = 20; +/// The most entities one request may name. +const ENTITIES_PER_REQUEST: usize = 500; + +pub(super) fn routes() -> Router> { + Router::new() + .route("/api/workspace/verify", post(start)) + .route("/api/workspace/operations/{id}", get(operation)) +} + +fn now() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +fn locked(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn refused(status: StatusCode, sentence: impl Into) -> Response { + (status, Json(json!({ "error": sentence.into() }))).into_response() +} + +/// The test runs this page started, oldest first, and what the repository's +/// daemon last said about how this repository runs its tests. +#[derive(Default)] +pub(super) struct VerifyRuns { + runs: Mutex>>, + test_command: Mutex, +} + +/// What the daemon last said about how this repository runs its tests: why it +/// cannot, or nothing when it can, and when; and whether a read is running. +#[derive(Default)] +struct TestCommandReading { + known: Option<(std::time::Instant, Option)>, + asking: bool, +} + +/// How long the daemon's answer on how this repository runs its tests is +/// taken as current before a session reads it again. +const TEST_COMMAND_KEPT: std::time::Duration = std::time::Duration::from_secs(60); +/// How long that read is given. Nothing waits for it. +const TEST_COMMAND_BUDGET: std::time::Duration = std::time::Duration::from_secs(60); + +/// Why this repository's tests cannot be run from here, as the daemon last +/// said: `Some(None)` when they can, and `None` when it has not said yet. A +/// read for a newer answer starts on its own when this one is old, and the +/// caller never waits for it. +/// +/// Measured on a busy zod, the daemon took more than 30 s to read its test +/// command from its manifests while it wrote, and a session that waited for +/// it, within the five seconds it gives the daemon, reported the whole runtime +/// as unobserved and drew nothing for five seconds. +pub(super) fn test_command_known( + state: &Arc, + client: DaemonClient, +) -> Option> { + let mut reading = locked(&state.verify_runs.test_command); + let known = reading.known.clone(); + let fresh = known + .as_ref() + .is_some_and(|(read_at, _)| read_at.elapsed() < TEST_COMMAND_KEPT); + if !fresh && !reading.asking { + reading.asking = true; + let state = Arc::clone(state); + tokio::spawn(async move { + let answer = tokio::time::timeout( + TEST_COMMAND_BUDGET, + client.verify_work(&WorkVerifyRequest::TestCommand), + ) + .await; + let mut reading = locked(&state.verify_runs.test_command); + reading.asking = false; + if let Ok(Ok(answer)) = answer { + let unavailable = if answer.runnable { + None + } else { + answer.unavailable + }; + reading.known = Some((std::time::Instant::now(), unavailable)); + } + }); + } + known.map(|(_, unavailable)| unavailable) +} + +/// How far a run has come, as a code. The page words each stage itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Stage { + /// Reading the latest edits into the repository, so the plan sees them. + Reading, + /// Planning the tests that reach the work. + Planning, + /// The repository's test command is running. + Running, + /// The run is recorded, or nothing was run, and the answer is final. + Finishing, +} + +impl Stage { + fn word(self) -> &'static str { + match self { + Self::Reading => "reading", + Self::Planning => "planning", + Self::Running => "running", + Self::Finishing => "finishing", + } + } +} + +enum Outcome { + /// The daemon answered: a recorded run, or a plan with no test in it. + Answered(WorkVerifyResponse), + Failed(String), +} + +struct Progress { + stage: Stage, + phase: String, + /// The plan, once it is read. + planned: Option, + finished_at: Option, + outcome: Option, +} + +/// One request to run the tests that reach a piece of work, and how far it +/// has come. +struct VerifyRun { + id: kin_model::OperationId, + scope: WorkVerifyScope, + started_at: String, + status_url: String, + progress: Mutex, +} + +impl VerifyRun { + fn running(&self) -> bool { + locked(&self.progress).outcome.is_none() + } + + fn enter(&self, stage: Stage, phase: impl Into) { + let mut progress = locked(&self.progress); + progress.stage = stage; + progress.phase = phase.into(); + } + + fn finish(&self, outcome: Outcome) { + let mut progress = locked(&self.progress); + progress.stage = Stage::Finishing; + progress.finished_at = Some(now()); + progress.phase = match &outcome { + Outcome::Answered(answer) => match &answer.run { + Some(run) if run.outcome == "passed" => "The test command passed.".to_string(), + // It never reached a test, so it is not said as a failure. + Some(run) if run.outcome == "could_not_run" => { + "The test command could not run.".to_string() + } + Some(_) => "The test command failed.".to_string(), + None => no_tests_sentence(answer), + }, + Outcome::Failed(error) => error.clone(), + }; + progress.outcome = Some(outcome); + } + + fn json(&self) -> Value { + let progress = locked(&self.progress); + let mut body = json!({ + "operation_id": self.id, + "action": "verify", + "state": match progress.outcome { + None => "running", + Some(Outcome::Answered(_)) => "done", + Some(Outcome::Failed(_)) => "failed", + }, + "stage": progress.stage.word(), + "phase": progress.phase, + "scope": { + "entities": self.scope.entities, + "change": self.scope.change, + }, + "started_at": self.started_at, + "finished_at": progress.finished_at, + "status_url": self.status_url, + }); + if let Some(planned) = &progress.planned { + body["progress"] = json!({ + "tests": planned.tests.len(), + "entities": planned.entities.len(), + "reach": planned.reach, + }); + } + match &progress.outcome { + Some(Outcome::Answered(answer)) => { + body["outcome"] = json!(match &answer.run { + Some(run) => run.outcome.as_str(), + None => "no_tests", + }); + body["result"] = json!({ + "run": answer.run, + "reach": answer.reach, + "entities": answer.entities, + "tests": answer.tests, + }); + } + Some(Outcome::Failed(error)) => body["error"] = json!(error), + None => {} + } + body + } +} + +enum Reservation { + Started, + Known(Arc), + Busy(Arc), +} + +/// What a finished request that ran nothing says. +fn no_tests_sentence(answer: &WorkVerifyResponse) -> String { + if answer.entities.is_empty() { + "No entity is changed, so there are no tests to run.".to_string() + } else { + format!( + "No test reaches this work within {} recorded hops.", + super::verify::WORK_TEST_REACH_DEPTH + ) + } +} + +impl VerifyRuns { + fn find(&self, id: kin_model::OperationId) -> Option> { + locked(&self.runs).iter().find(|run| run.id == id).cloned() + } + + fn running(&self) -> Option> { + locked(&self.runs).iter().find(|run| run.running()).cloned() + } + + /// Reserve the working tree and operation id together, after any async + /// preflight. A simultaneous request cannot launch a second suite. + fn reserve(&self, run: Arc) -> Reservation { + let mut runs = locked(&self.runs); + if let Some(known) = runs.iter().find(|known| known.id == run.id) { + return Reservation::Known(known.clone()); + } + if let Some(running) = runs.iter().find(|known| known.running()) { + return Reservation::Busy(running.clone()); + } + runs.push(run); + Self::trim(&mut runs); + Reservation::Started + } + + fn trim(runs: &mut Vec>) { + while runs.len() > RUNS_KEPT { + let Some(oldest) = runs.iter().position(|run| !run.running()) else { + break; + }; + runs.remove(oldest); + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct VerifyBody { + #[serde(default)] + entities: Option>, + #[serde(default)] + change: Option, + operation_id: kin_model::OperationId, +} + +/// The work a request names, checked before anything is asked of the daemon. +fn scope_of(body: &VerifyBody) -> Result { + if body.operation_id.0.is_nil() { + return Err("A test run needs a nonempty operation id.".to_string()); + } + let entities = body.entities.clone().unwrap_or_default(); + let change = body + .change + .as_deref() + .map(str::trim) + .filter(|change| !change.is_empty()) + .map(str::to_string); + if !entities.is_empty() && change.is_some() { + return Err("Name entities or a change, not both.".to_string()); + } + if entities.len() > ENTITIES_PER_REQUEST { + return Err(format!( + "One test run names at most {ENTITIES_PER_REQUEST} entities." + )); + } + let mut ids = Vec::with_capacity(entities.len()); + for raw in entities { + let id = raw.trim(); + if uuid::Uuid::parse_str(id).is_err() { + return Err(format!("'{id}' is not an entity id.")); + } + ids.push(id.to_string()); + } + if let Some(change) = &change { + if change.len() != 64 || !change.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("'{change}' is not a full change id.")); + } + } + Ok(WorkVerifyScope { + entities: ids, + change, + depth: super::verify::WORK_TEST_REACH_DEPTH - 1, + }) +} + +/// `POST /api/workspace/verify`: start running the tests that reach a piece +/// of work, or say why none will run. +async fn start( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !review_ui::may_write(&headers, &state) { + return review_ui::write_refused(); + } + // A request sent again is answered with the run it started. + if let Some(known) = state.verify_runs.find(body.operation_id) { + return Json(known.json()).into_response(); + } + let scope = match scope_of(&body) { + Ok(scope) => scope, + Err(sentence) => return refused(StatusCode::BAD_REQUEST, sentence), + }; + // One run at a time in one working tree: two suites at once would read + // and write each other's files. + if let Some(running) = state.verify_runs.running() { + return ( + StatusCode::CONFLICT, + Json(json!({ + "error": "Kin is already running tests for this repository.", + "operation": running.json(), + })), + ) + .into_response(); + } + let client = match review_ui::daemon(&state).await { + Ok(client) => client, + Err(error) => return review_ui::refusal(StatusCode::BAD_GATEWAY, &error), + }; + // Whether the repository has a command to run is known before anything + // starts, so the answer can say so at once. + match client.verify_work(&WorkVerifyRequest::TestCommand).await { + Ok(answer) if !answer.runnable => { + return Json(json!({ + "operation_id": body.operation_id, + "action": "verify", + "state": "unavailable", + "reason": answer.unavailable.unwrap_or_else(|| { + super::verify::NO_TEST_COMMAND.to_string() + }), + })) + .into_response() + } + Ok(_) => {} + Err(error) => return review_ui::refusal(StatusCode::BAD_GATEWAY, &error), + } + let run = Arc::new(VerifyRun { + id: body.operation_id, + scope, + started_at: now(), + status_url: format!( + "{}/api/workspace/operations/{}", + state.base_path, body.operation_id + ), + progress: Mutex::new(Progress { + stage: Stage::Reading, + phase: "Reading the latest edits.".to_string(), + planned: None, + finished_at: None, + outcome: None, + }), + }); + match state.verify_runs.reserve(run.clone()) { + Reservation::Known(known) => return Json(known.json()).into_response(), + Reservation::Busy(running) => { + return ( + StatusCode::CONFLICT, + Json(json!({ + "error": "Kin is already running tests for this repository.", + "operation": running.json(), + })), + ) + .into_response(); + } + Reservation::Started => {} + } + // Run to its end whatever becomes of this request: the page follows it + // by its operation, and a closed page must not stop a recorded run. + tokio::spawn(drive(client, run.clone())); + (StatusCode::ACCEPTED, Json(run.json())).into_response() +} + +/// Take a run through its stages: read the latest edits, plan, run and +/// record, each by the repository's daemon. +async fn drive(client: DaemonClient, run: Arc) { + // The runner reads the working tree, so its graph fingerprints establish + // what ran only after a complete admission of those bytes. + if let Some(cause) = review_ui::admit_working_for_ui(&client).await { + let sentence = unread_edits(&client, &cause).await; + return run.finish(Outcome::Failed(sentence)); + } + run.enter(Stage::Planning, "Planning the tests that reach this work."); + let planned = match client + .verify_work(&WorkVerifyRequest::Plan(run.scope.clone())) + .await + { + Ok(planned) => planned, + Err(error) => return run.finish(Outcome::Failed(failure(&error))), + }; + if !planned.runnable { + let sentence = planned + .unavailable + .clone() + .unwrap_or_else(|| super::verify::NO_TEST_COMMAND.to_string()); + return run.finish(Outcome::Failed(sentence)); + } + let tests = planned.tests.len(); + { + let mut progress = locked(&run.progress); + progress.planned = Some(planned.clone()); + } + if tests == 0 { + return run.finish(Outcome::Answered(planned)); + } + run.enter( + Stage::Running, + match tests { + 1 => "Running the test command for 1 planned test.".to_string(), + tests => format!("Running the test command for {tests} planned tests."), + }, + ); + match client + .verify_work(&WorkVerifyRequest::Run(run.scope.clone())) + .await + { + Ok(answer) => run.finish(Outcome::Answered(answer)), + Err(error) => run.finish(Outcome::Failed(failure(&error))), + } +} + +/// What a run says when the latest edits could not be read: that it ran +/// nothing, then why. An edit that removed an entity something still relates +/// to is said with the entity's name, read from the graph that still holds it. +async fn unread_edits(client: &DaemonClient, cause: &str) -> String { + let removed = match stranded_entity(cause) { + Some(id) => removed_entity_words(client, &id).await, + None => None, + }; + unread_edits_sentence(cause, removed.as_deref()) +} + +/// The lead every such answer starts with, so a reader and the page can tell +/// at once that nothing ran. +const UNREAD_EDITS: &str = "Kin could not read the latest edits, so it did not run the tests."; + +/// The admission layers a cause is wrapped in, which say where it failed +/// inside Kin rather than why. +const CAUSE_LAYERS: [&str; 7] = [ + "complete exact-tree admission failed: ", + "graph error: ", + "storage error: ", + "reconcile error: ", + "index error: ", + "core error: ", + "io error: ", +]; + +fn unread_edits_sentence(cause: &str, removed: Option<&str>) -> String { + let reason = if stranded_entity(cause).is_some() { + match removed { + Some(entity) => format!( + "The edits removed {entity}, and the graph still holds a relationship that \ + names it." + ), + None => "The edits removed an entity, and the graph still holds a relationship \ + that names it." + .to_string(), + } + } else { + let mut reason = cause.trim(); + while let Some(layer) = CAUSE_LAYERS.iter().find(|layer| { + reason + .get(..layer.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(layer)) + }) { + reason = reason[layer.len()..].trim_start(); + } + let mut reason = reason.to_string(); + if let Some(first) = reason.get(..1) { + let upper = first.to_uppercase(); + reason.replace_range(..1, &upper); + } + if !reason.is_empty() && !reason.ends_with(['.', '!', '?']) { + reason.push('.'); + } + reason + }; + if reason.is_empty() { + UNREAD_EDITS.to_string() + } else { + format!("{UNREAD_EDITS} {reason}") + } +} + +/// The entity a refused transition would have left a relationship naming, +/// from the graph's refusal: `... unadmitted destination endpoint entity:`. +fn stranded_entity(cause: &str) -> Option { + let (_, rest) = cause.split_once("endpoint entity:")?; + let id = rest.get(..36)?; + uuid::Uuid::parse_str(id).ok()?; + Some(id.to_string()) +} + +/// The removed entity as a reader knows it, such as the function `negate`, +/// read while the graph that refused the edit still holds it. `None` when it +/// cannot be read in time. +async fn removed_entity_words(client: &DaemonClient, id: &str) -> Option { + let answer = tokio::time::timeout( + std::time::Duration::from_secs(5), + client.workspace_entity_owners(id), + ) + .await + .ok()? + .ok()?; + let name = answer["name"].as_str()?.trim(); + if name.is_empty() { + return None; + } + let kind = super::workspace_explore::kind_label(answer["kind"].as_str().unwrap_or_default()) + .replace('_', " "); + Some(if kind.is_empty() { + format!("`{name}`") + } else { + format!("the {kind} `{name}`") + }) +} + +/// A failure in Kin's own words. A refusal from the daemon is its sentence, +/// without the name of the route that carried it. +fn failure(error: &anyhow::Error) -> String { + let text = format!("{error:#}"); + let sentence = match text.split_once("kin verify work refused (HTTP ") { + Some((_, rest)) => rest + .split_once("): ") + .map_or(rest, |(_, sentence)| sentence), + None => &text, + }; + format!("Kin could not run the tests: {}", sentence.trim()) +} + +/// `GET /api/workspace/operations/{id}`: how far a test run has come. +async fn operation(State(state): State>, UrlPath(id): UrlPath) -> Response { + let found = uuid::Uuid::parse_str(&id) + .ok() + .and_then(|id| state.verify_runs.find(kin_model::OperationId(id))); + match found { + Some(run) => Json(run.json()).into_response(), + None => refused( + StatusCode::NOT_FOUND, + "This page has no such operation. It keeps them only while it runs.", + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(entities: Option>, change: Option<&str>) -> VerifyBody { + VerifyBody { + entities: entities.map(|ids| ids.into_iter().map(str::to_string).collect()), + change: change.map(str::to_string), + operation_id: kin_model::OperationId(uuid::Uuid::new_v4()), + } + } + + #[test] + fn a_request_names_entities_or_a_change_or_the_whole_work() { + let entity = "196d8edb-c20c-4d44-8f59-d0e96e6f74c1"; + let scope = scope_of(&body(Some(vec![entity]), None)).unwrap(); + assert_eq!(scope.entities, [entity]); + assert_eq!(scope.change, None); + + let whole = scope_of(&body(None, None)).unwrap(); + assert!(whole.entities.is_empty() && whole.change.is_none()); + + let change = "a".repeat(64); + assert_eq!( + scope_of(&body(None, Some(&change))).unwrap().change, + Some(change.clone()) + ); + + assert_eq!( + scope_of(&body(Some(vec![entity]), Some(&change))).unwrap_err(), + "Name entities or a change, not both." + ); + assert_eq!( + scope_of(&body(Some(vec!["src/math.js"]), None)).unwrap_err(), + "'src/math.js' is not an entity id." + ); + assert!(scope_of(&body(None, Some("abc123"))).is_err()); + let mut nil = body(None, None); + nil.operation_id = kin_model::OperationId(uuid::Uuid::nil()); + assert!(scope_of(&nil).is_err()); + } + + fn a_run(stage: Stage) -> Arc { + Arc::new(VerifyRun { + id: kin_model::OperationId(uuid::Uuid::new_v4()), + scope: WorkVerifyScope::default(), + started_at: now(), + status_url: "/api/workspace/operations/x".to_string(), + progress: Mutex::new(Progress { + stage, + phase: String::new(), + planned: None, + finished_at: None, + outcome: None, + }), + }) + } + + #[test] + fn a_run_reports_its_stage_until_it_is_done_and_then_its_outcome() { + let run = a_run(Stage::Reading); + assert_eq!(run.json()["state"], "running"); + assert_eq!(run.json()["stage"], "reading"); + + let planned = WorkVerifyResponse { + runnable: true, + reach: Some("whole".to_string()), + entities: vec![super::super::verify::WorkEntity { + id: "e".to_string(), + name: "add".to_string(), + kind: "function".to_string(), + }], + tests: vec![super::super::verify::WorkTest { + test_id: "t".to_string(), + name: "checkAdd".to_string(), + entity_id: Some("c".to_string()), + }], + ..WorkVerifyResponse::default() + }; + locked(&run.progress).planned = Some(planned.clone()); + run.enter(Stage::Running, "Running 1 test."); + let reading = run.json(); + assert_eq!(reading["stage"], "running"); + assert_eq!(reading["progress"]["tests"], 1); + assert_eq!(reading["progress"]["reach"], "whole"); + + let mut answered = planned; + answered.run = Some(super::super::verify::WorkRun { + run_id: "r".to_string(), + outcome: "failed".to_string(), + duration_ms: 12, + exit_code: Some(1), + finished_at: now(), + reason: None, + output_tail: Some("not ok 1 - checkAdd".to_string()), + }); + run.finish(Outcome::Answered(answered)); + let done = run.json(); + assert_eq!(done["state"], "done"); + assert_eq!(done["stage"], "finishing"); + assert_eq!(done["outcome"], "failed"); + assert_eq!(done["phase"], "The test command failed."); + assert_eq!(done["result"]["run"]["exit_code"], 1); + assert_eq!(done["result"]["tests"][0]["name"], "checkAdd"); + assert!(done["finished_at"].is_string()); + } + + /// A command whose runner is missing is said as one that could not run, + /// with why, its exit code and the end of its output, never as a failed + /// run of the tests it planned. + #[test] + fn a_run_whose_command_could_not_run_is_not_said_as_a_failure() { + let run = a_run(Stage::Running); + run.finish(Outcome::Answered(WorkVerifyResponse { + runnable: true, + reach: Some("whole".to_string()), + tests: vec![super::super::verify::WorkTest { + test_id: "t".to_string(), + name: "404s.test".to_string(), + entity_id: Some("c".to_string()), + }], + run: Some(super::super::verify::WorkRun { + run_id: "r".to_string(), + outcome: "could_not_run".to_string(), + duration_ms: 395, + exit_code: Some(127), + finished_at: now(), + reason: Some("The shell could not find `eslint`, so no test ran.".to_string()), + output_tail: Some("sh: eslint: command not found".to_string()), + }), + ..WorkVerifyResponse::default() + })); + let done = run.json(); + assert_eq!(done["state"], "done"); + assert_eq!(done["outcome"], "could_not_run"); + assert_eq!(done["phase"], "The test command could not run."); + assert!(!done["phase"].as_str().unwrap().contains("failed")); + assert_eq!(done["result"]["run"]["exit_code"], 127); + assert_eq!( + done["result"]["run"]["reason"], + "The shell could not find `eslint`, so no test ran." + ); + assert_eq!( + done["result"]["run"]["output_tail"], + "sh: eslint: command not found" + ); + } + + #[test] + fn work_no_test_reaches_finishes_without_a_run() { + let run = a_run(Stage::Planning); + run.finish(Outcome::Answered(WorkVerifyResponse { + runnable: true, + entities: vec![super::super::verify::WorkEntity { + id: "e".to_string(), + name: "negate".to_string(), + kind: "function".to_string(), + }], + ..WorkVerifyResponse::default() + })); + let done = run.json(); + assert_eq!(done["outcome"], "no_tests"); + assert_eq!( + done["phase"], + "No test reaches this work within 3 recorded hops." + ); + } + + #[test] + fn a_daemon_refusal_is_reported_as_its_own_sentence() { + let refused = anyhow::anyhow!( + "kin verify work refused (HTTP 400): No entity with the id 7 is in this repository." + ); + assert_eq!( + failure(&refused), + "Kin could not run the tests: No entity with the id 7 is in this repository." + ); + assert_eq!( + failure(&anyhow::anyhow!("the daemon stopped answering")), + "Kin could not run the tests: the daemon stopped answering" + ); + } + + #[test] + fn simultaneous_requests_reserve_only_one_run_and_duplicate_ids_reuse_it() { + let runs = Arc::new(VerifyRuns::default()); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let handles: Vec<_> = (0..8) + .map(|_| { + let runs = runs.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + let run = a_run(Stage::Reading); + barrier.wait(); + matches!(runs.reserve(run), Reservation::Started) + }) + }) + .collect(); + let started = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .filter(|started| *started) + .count(); + assert_eq!(started, 1); + assert_eq!(locked(&runs.runs).len(), 1); + let running = runs.running().unwrap(); + match runs.reserve(running.clone()) { + Reservation::Known(known) => assert!(Arc::ptr_eq(&known, &running)), + _ => panic!("a repeated operation must reuse its original run"), + } + } + + /// Drive one run against a daemon whose admission answers `admitted`, + /// whose graph names entities as `entity` says, and whose test routes + /// count every request. Returns how many test requests arrived and the + /// run as the page reads it. + async fn drive_after_admission(admitted: Value, entity: Value) -> (usize, Value) { + use std::sync::atomic::{AtomicUsize, Ordering}; + let calls = Arc::new(AtomicUsize::new(0)); + let count = calls.clone(); + let mut identity = + kin_core::runtime_identity::RuntimeServiceIdentity::current("hosted-daemon").unwrap(); + identity.namespace = None; + let app = Router::new() + .route( + "/runtime/identity", + axum::routing::get(move || { + let identity = identity.clone(); + async move { Json(identity) } + }), + ) + .route( + "/commands/admit", + post(move || { + let admitted = admitted.clone(); + async move { Json(admitted) } + }), + ) + .route( + "/mcp/tools/call", + post(move || { + let entity = entity.clone(); + async move { + Json(json!({"content": [{"type": "text", "text": entity.to_string()}]})) + } + }), + ) + .route( + "/verify/work", + post(move || { + let count = count.clone(); + async move { + count.fetch_add(1, Ordering::SeqCst); + Json(json!({"runnable": true, "entities": [], "tests": []})) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = DaemonClient::from_base_url_with_explicit_authority( + format!("http://{address}"), + None, + None, + ) + .unwrap(); + let run = a_run(Stage::Reading); + drive(client, run.clone()).await; + server.abort(); + (calls.load(Ordering::SeqCst), run.json()) + } + + #[tokio::test] + async fn tests_are_not_planned_or_run_after_an_incomplete_admission() { + let (calls, run) = drive_after_admission( + json!({"lines": [], "mutated": false, "report": null}), + json!({}), + ) + .await; + assert_eq!(calls, 0); + assert_eq!(run["state"], "failed"); + assert_eq!( + run["phase"], + "Kin could not read the latest edits, so it did not run the tests. The daemon \ + returned no admission report, so whether the working copy was admitted is unknown; \ + read `kin graph status` for the state it left behind." + ); + assert_eq!(run["error"], run["phase"]); + } + + /// An edit removed an entity a recorded relationship still named, and the + /// run used to say only that the edits could not be read. It names the + /// entity, read from the graph that still holds it, and never the relation + /// or entity ids. + #[tokio::test] + async fn an_unread_edit_that_removed_an_entity_names_it() { + let negate = "14dd277c-657c-4b98-832d-dad86ae678a9"; + let refusal = format!( + "Complete exact-tree admission failed: Graph error: storage error: transaction \ + relation d5442745-dfb2-5501-ab92-d8e315c6bda8 has unadmitted destination endpoint \ + entity:{negate}" + ); + let (calls, run) = drive_after_admission( + json!({"lines": [refusal], "mutated": false, "report": null}), + json!({"entity_id": negate, "name": "negate", "kind": "Function"}), + ) + .await; + assert_eq!( + calls, 0, + "nothing is planned or run after a failed admission" + ); + assert_eq!(run["state"], "failed"); + let phase = run["phase"].as_str().unwrap(); + assert_eq!( + phase, + "Kin could not read the latest edits, so it did not run the tests. The edits removed \ + the function `negate`, and the graph still holds a relationship that names it." + ); + assert!(!phase.contains(negate) && !phase.contains("d5442745")); + } + + #[test] + fn an_unread_edit_keeps_its_cause_without_the_layers_it_failed_through() { + assert_eq!( + unread_edits_sentence( + "complete exact-tree admission failed: Graph error: storage error: the store \ + refused the transition", + None + ), + "Kin could not read the latest edits, so it did not run the tests. The store \ + refused the transition." + ); + assert_eq!( + unread_edits_sentence( + "Graph error: storage error: transaction relation r1 has unadmitted source \ + endpoint entity:14dd277c-657c-4b98-832d-dad86ae678a9", + None + ), + "Kin could not read the latest edits, so it did not run the tests. The edits removed \ + an entity, and the graph still holds a relationship that names it." + ); + assert_eq!( + unread_edits_sentence(" ", None), + "Kin could not read the latest edits, so it did not run the tests." + ); + assert_eq!( + stranded_entity("has unadmitted destination endpoint entity:not-an-id"), + None + ); + } + + #[test] + fn finished_runs_are_forgotten_before_running_ones() { + let runs = VerifyRuns::default(); + let running = a_run(Stage::Running); + assert!(matches!( + runs.reserve(running.clone()), + Reservation::Started + )); + for _ in 0..RUNS_KEPT + 3 { + let finished = a_run(Stage::Planning); + finished.finish(Outcome::Failed("x".to_string())); + let mut kept = locked(&runs.runs); + kept.push(finished); + VerifyRuns::trim(&mut kept); + } + assert_eq!(locked(&runs.runs).len(), RUNS_KEPT); + assert!(runs.find(running.id).is_some()); + assert_eq!(runs.running().map(|run| run.id), Some(running.id)); + } +} diff --git a/crates/kin-cli/src/daemon_client.rs b/crates/kin-cli/src/daemon_client.rs index 17768b465..8e9506783 100644 --- a/crates/kin-cli/src/daemon_client.rs +++ b/crates/kin-cli/src/daemon_client.rs @@ -33,7 +33,9 @@ use uuid::Uuid; mod attach_env; pub(crate) mod probe_process; pub(crate) mod process_executable; +pub mod replacement; mod starting_owner; +pub mod succession; #[cfg(all(test, unix))] pub(crate) use starting_owner::stand_in_as_starting_daemon_for_test; pub use starting_owner::{ @@ -78,6 +80,11 @@ pub enum AutoStartError { StartupTimeout(String), #[error("invalid .kin layout: {0}")] InvalidLayout(String), + /// An older release's daemon serves this repository. The caller replaces + /// it under the startup lock and starts this release's; it reaches a + /// caller of the crate only if that replacement could not be made. + #[error("an older Kin daemon (pid {pid}) serves this repository and must be replaced first")] + Superseded { pid: u32 }, } impl AutoStartError { @@ -281,6 +288,10 @@ struct SupervisorRouteResponse { #[derive(Debug, Deserialize)] struct DaemonCompatResponse { + #[serde(default)] + runtime_identity: Option, + #[serde(default)] + runtime_mode: Option, #[serde(default)] schema: String, graph_snapshot_version: u32, @@ -449,13 +460,12 @@ pub struct DaemonClient { /// Carries the same endpoint authority as `client`, but never follows an /// HTTP redirect that could redispatch a non-idempotent mutation. one_dispatch_client: reqwest::Client, - /// The store this client's daemon serves, when one was resolvable. - /// - /// Held so that a request which goes unanswered can ask the store what - /// happened to its daemon instead of asserting the ordinary cause. A - /// transport error describes a socket; the killer leaves its evidence in - /// the repository, and without a root there is nowhere to read it. - /// `None` behaves exactly as this client behaved before it had this field. + runtime_namespace: kin_core::runtime_identity::RuntimeNamespace, + runtime_repository: + Option>, + /// The intended store supplies repository admission and daemon failure + /// evidence. Health diagnostics can work without it; semantic requests + /// require a captured repository binding. kin_root: Option, } @@ -541,6 +551,17 @@ impl fmt::Display for RefusedBeforeWriteError { impl std::error::Error for RefusedBeforeWriteError {} +/// What came of one push or pull sent once by [`DaemonClient::transfer_once`]. +#[derive(Debug)] +pub enum TransferDispatch { + /// The daemon finished the command and said how it went. + Answered(Box), + /// The daemon answered with a refusal. The command has ended there. + Refused { status: u16, body: String }, + /// Nothing says how the request ended. The daemon may still be running it. + Lost(anyhow::Error), +} + /// Proof carried by a successful acknowledgement of a non-idempotent command. /// /// Requiring this trait at the one-dispatch transport boundary makes it @@ -834,7 +855,7 @@ impl DaemonClient { ) -> Result { let mut client = Self::from_base_url_with_token(base_url, resolve_daemon_auth_token_for_layout(layout))?; - client.kin_root = Some(layout.root().to_path_buf()); + client.bind_repository(layout.root()); Ok(client) } @@ -864,7 +885,18 @@ impl DaemonClient { if let Some(token) = auth_token.as_deref() { refuse_local_token_leaving_loopback(&base_url, token)?; } - let headers = daemon_client_headers(auth_token, session_id)?; + let mut headers = daemon_client_headers(auth_token, session_id)?; + let mode = + kin_core::runtime_identity::RuntimeMode::current().map_err(anyhow::Error::msg)?; + let runtime_namespace = + kin_core::runtime_identity::RuntimeNamespace::current().map_err(anyhow::Error::msg)?; + headers.insert( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + kin_core::runtime_identity::RuntimeIdentity::current() + .fingerprint(mode, runtime_namespace.token()) + .parse() + .expect("runtime fingerprint is ASCII hex"), + ); let request_timeout = std::env::var("KIN_DAEMON_HTTP_TIMEOUT_SECS") .ok() .and_then(|v| v.trim().parse::().ok()) @@ -884,28 +916,160 @@ impl DaemonClient { .default_headers(headers) .build() .context("build one-dispatch daemon client")?; + let kin_root = crate::daemon_death::kin_root_from_cwd(); + let runtime_repository = kin_root + .as_deref() + .map(kin_core::runtime_identity::RuntimeRepositoryBinding::for_store); Ok(Self { base_url, client, one_dispatch_client, - // Discovery from the working directory is the fallback every - // constructor but the layout one gets. A caller standing outside a - // repository resolves nothing, which is the same reading as a store - // that never lost a daemon and leaves every message unchanged. - kin_root: crate::daemon_death::kin_root_from_cwd(), + runtime_namespace, + runtime_repository, + // Layout constructors replace both the discovered root and its + // retained binding with the explicitly selected workspace. + kin_root, }) } + fn bind_repository(&mut self, root: &Path) { + self.kin_root = Some(root.to_path_buf()); + self.runtime_repository = + Some(kin_core::runtime_identity::RuntimeRepositoryBinding::for_store(root)); + } + + async fn request_repository_fingerprint(&self) -> Result> { + let identity = read_runtime_endpoint(&self.base_url).await?; + let mode = kin_core::runtime_identity::RuntimeMode::current() + .map_err(|error| anyhow::Error::new(RuntimeEndpointRefusal(error)))?; + let fingerprint = identity + .admit_daemon_for( + &kin_core::runtime_identity::RuntimeIdentity::current(), + mode, + Some(&self.runtime_namespace), + ) + .map_err(|error| anyhow::Error::new(RuntimeEndpointRefusal(error)))?; + if fingerprint.is_none() { + return Ok(None); + } + let expected = self.runtime_repository.as_ref().ok_or_else(|| { + anyhow::Error::new(RuntimeEndpointRefusal( + "repository admission refused: no intended repository is bound".into(), + )) + })?; + let expected = expected.as_ref().map_err(|error| { + anyhow::Error::new(RuntimeEndpointRefusal(format!( + "repository admission refused: {error}" + ))) + })?; + let repository = identity.repository.as_ref().ok_or_else(|| { + anyhow::Error::new(RuntimeEndpointRefusal( + "repository admission refused: ready service supplied no repository identity" + .into(), + )) + })?; + repository + .validate_for(expected) + .map(Some) + .map_err(|error| { + anyhow::Error::new(RuntimeEndpointRefusal(format!( + "repository admission refused: {error}" + ))) + }) + } + /// The store whose records explain a daemon of this endpoint that died. fn kin_root(&self) -> Option<&Path> { self.kin_root.as_deref() } + fn request_runtime_fingerprint(&self) -> Result { + self.runtime_namespace + .revalidate() + .map_err(anyhow::Error::msg)?; + let selected = + kin_core::runtime_identity::RuntimeNamespace::current().map_err(anyhow::Error::msg)?; + anyhow::ensure!( + selected.token() == self.runtime_namespace.token(), + "selected Kin home changed after this daemon client was created; reconnect using the current runtime profile" + ); + let mode = + kin_core::runtime_identity::RuntimeMode::current().map_err(anyhow::Error::msg)?; + Ok(kin_core::runtime_identity::RuntimeIdentity::current() + .fingerprint(mode, selected.token())) + } + + /// Attach the admission a local daemon checks before any handler runs. + /// + /// A managed daemon refuses a request that does not carry this process's + /// runtime identity and, for anything but a health probe, the repository + /// it means. Everything this client sends passes through here. A command + /// that builds its own request, for a deadline or a redirect policy this + /// client does not carry, passes it through [`admit_daemon_request`] + /// rather than sending it bare. + /// + /// Reading the repository the endpoint serves is itself a request to it. + /// When that read never reaches the daemon, the command has lost its + /// request exactly as if the request itself had been sent, so it reads the + /// same way, with the store's account of why the daemon is gone. + pub(crate) async fn admitted_request( + &self, + request: reqwest::RequestBuilder, + leaf: &str, + ) -> Result { + Ok(request.headers(self.admission_headers(leaf).await?)) + } + + /// The headers [`Self::admitted_request`] attaches, for a caller that + /// sends the same request more than once. + pub(crate) async fn admission_headers(&self, leaf: &str) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + reqwest::header::HeaderValue::from_str(&self.request_runtime_fingerprint()?) + .context("runtime fingerprint is not a header value")?, + ); + let repository = if matches!(leaf, "health" | "readiness") { + validate_runtime_endpoint(&self.base_url, "daemon") + .await + .map(|()| None) + } else { + self.request_repository_fingerprint().await + } + .map_err(|error| self.lost_admission_probe(error, leaf))?; + if let Some(repository) = repository { + headers.insert( + kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER, + reqwest::header::HeaderValue::from_str(&repository) + .context("repository fingerprint is not a header value")?, + ); + } + Ok(headers) + } + + /// Explain an admission probe the endpoint never answered as the lost + /// request it is. A refusal the endpoint did answer stays its own error. + fn lost_admission_probe(&self, error: anyhow::Error, leaf: &str) -> anyhow::Error { + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + error.context(daemon_send_failure_message( + &self.base_url, + leaf, + self.kin_root(), + )) + } else { + error + } + } + async fn send( &self, request: reqwest::RequestBuilder, leaf: &str, ) -> Result { + let request = self.admitted_request(request, leaf).await?; let resp = request.send().await; // The daemon answered, or the request failed. Either way the line that // said it was ready has done its job, and what prints next is the @@ -917,6 +1081,24 @@ impl DaemonClient { Ok(resp) } + /// Send a request this client has no typed method for, with the bearer + /// token and runtime admission every request of this client carries. + pub async fn send_untyped( + &self, + method: reqwest::Method, + path: &str, + leaf: &str, + body: Option<&serde_json::Value>, + ) -> Result { + let url = format!("{}{}", self.base_url.trim_end_matches('/'), path); + let request = self.client.request(method, url); + let request = match body { + Some(body) => request.json(body), + None => request, + }; + self.send(request, leaf).await + } + /// Turn a non-success daemon response into the error the user reads. async fn http_refusal(&self, leaf: &str, response: reqwest::Response) -> anyhow::Error { let status = response.status().as_u16(); @@ -973,6 +1155,63 @@ impl DaemonClient { Self::from_running_workspace(discovered.base_url, layout).await } + /// A known workspace endpoint for a page of `kin ui`, which never works + /// beside a daemon of another release. + /// + /// Like [`Self::from_running_workspace`], except that the endpoint must + /// admit this client's release first. A daemon of an older release this + /// client supersedes is not used: the page goes through + /// [`Self::connect_for_workspace`], which replaces it and starts this + /// release's, within `budget`. A newer release, a development build, and a + /// client an update has replaced are refused in their own words. + pub async fn for_workspace_page( + command: &str, + base_url: impl Into, + layout: &KinLayout, + budget: Duration, + ) -> Result { + let base_url = base_url.into(); + if let Err(error) = validate_runtime_endpoint(&base_url, "daemon").await { + if error.downcast_ref::().is_none() { + return Err(error); + } + let (pid, port) = repo_daemon_recorded_endpoint(layout.root()); + let recorded = pid.filter(|_| { + port.is_some_and(|port| { + base_url.trim_end_matches('/') == format!("http://127.0.0.1:{port}") + }) + }); + let judged = match recorded { + Some(pid) => { + judge_daemon_of_another_release( + &base_url, + pid, + budget.min(Duration::from_secs(5)), + ) + .await + } + None => None, + }; + return match judged { + Some(kin_core::runtime_identity::Succession::Replace) => { + let replacing = "Kin is replacing an older daemon for this repository; \ + reload in a moment"; + tokio::time::timeout(budget, Self::connect_for_workspace(command, layout)) + .await + .unwrap_or_else(|_| Err(anyhow::anyhow!(replacing))) + } + Some(refused) => Err(anyhow::anyhow!( + "{}", + refused + .refusal() + .unwrap_or("the workspace daemon was refused") + )), + None => Err(error), + }; + } + Self::from_running_workspace(base_url, layout).await + } + /// Verify a known endpoint without starting a daemon or adopting ambient scope. pub async fn from_running_workspace( base_url: impl Into, @@ -983,7 +1222,7 @@ impl DaemonClient { resolve_daemon_auth_token_for_layout(layout), None, )?; - client.kin_root = Some(layout.root().to_path_buf()); + client.bind_repository(layout.root()); let health = client.health().await?; match classify_health_repo(&health, layout.root(), layout.working_dir()) { RepoIdentity::Matches => Ok(client), @@ -1000,11 +1239,37 @@ impl DaemonClient { query: &str, limit: usize, offset: usize, + ) -> Result { + self.workspace_entities_and_untested(repo_id, query, limit, offset, None) + .await + } + + /// [`Self::workspace_entities`], and with `untested` the most relied-on + /// entities no test reaches, that many of them, under the answer's + /// `untested`. Both come from the daemon's one ranking pass. A daemon that + /// predates the list answers without the field. + pub async fn workspace_entities_and_untested( + &self, + repo_id: &str, + query: &str, + limit: usize, + offset: usize, + untested: Option, ) -> Result { anyhow::ensure!( (1..=100).contains(&limit), "entity page limit must be 1..100" ); + let mut asked = vec![ + ("query", query.to_string()), + ("order", "importance".to_string()), + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ("include", "ranking,signature,summary".to_string()), + ]; + if let Some(rows) = untested { + asked.push(("untested", rows.to_string())); + } let response = self .send( self.client @@ -1013,13 +1278,7 @@ impl DaemonClient { self.base_url, urlencoding::encode(repo_id) )) - .query(&[ - ("query", query.to_string()), - ("order", "importance".to_string()), - ("limit", limit.to_string()), - ("offset", offset.to_string()), - ("include", "ranking,signature,summary".to_string()), - ]), + .query(&asked), "workspace entity inventory", ) .await?; @@ -1078,7 +1337,19 @@ impl DaemonClient { .await } - // Only the two named, read-only wrappers above reach this transport. + /// What contains one entity, read without its text. + pub async fn workspace_entity_owners(&self, entity_id: &str) -> Result { + uuid::Uuid::parse_str(entity_id).context("invalid entity id")?; + self.workspace_graph_read( + "get_entity_source", + serde_json::json!({ + "entity_id": entity_id, "full_body": false, + }), + ) + .await + } + + // Only the three named, read-only wrappers above reach this transport. async fn workspace_graph_read( &self, name: &str, @@ -1664,6 +1935,34 @@ impl DaemonClient { .context("parse daemon verify run response") } + /// Ask about the tests that reach a piece of work, or run them. + /// + /// Sent once and never retried: a run executes the repository's tests + /// and records what they did, so a replay would run them again. A run + /// takes as long as the repository's tests do, so it is given far longer + /// than an ordinary request. + pub async fn verify_work( + &self, + request: &crate::commands::verify::WorkVerifyRequest, + ) -> Result { + const RUN_BUDGET: Duration = Duration::from_secs(2 * 60 * 60); + let resp = self + .send( + self.one_dispatch_client + .post(format!("{}/verify/work", self.base_url)) + .timeout(RUN_BUDGET) + .json(request), + "verify work", + ) + .await?; + if !resp.status().is_success() { + return Err(self.http_refusal("verify work", resp).await); + } + resp.json() + .await + .context("parse daemon verify work response") + } + pub async fn verify_command( &self, request: &crate::commands::verify::VerifyCommandRequest, @@ -1807,6 +2106,73 @@ impl DaemonClient { .context("parse daemon command status response") } + pub async fn git_update_status(&self) -> Result { + let response = self + .send( + self.client + .get(format!("{}/commands/git-update/status", self.base_url)), + "Git update status", + ) + .await?; + if !response.status().is_success() { + return Err(self.http_refusal("Git update status", response).await); + } + response + .json() + .await + .context("parse daemon Git update status") + } + + /// Dispatch an update once. A lost acknowledgement never triggers another + /// request, since the original command may already have admitted history. + pub async fn git_update_once( + &self, + request: &crate::git_update::GitUpdateRequest, + ) -> crate::git_update::GitUpdateDispatch { + use crate::git_update::GitUpdateDispatch; + let response = match self + .send( + self.one_dispatch_client + .post(format!( + "{}/commands/git-update", + self.base_url.trim_end_matches('/') + )) + .json(request), + "Git update", + ) + .await + { + Ok(response) => response, + Err(error) => return GitUpdateDispatch::Lost(error), + }; + let status = response.status(); + let body = match response.text().await { + Ok(body) => body, + Err(error) => { + return GitUpdateDispatch::Lost( + anyhow!(error).context("read the daemon's Git update answer"), + ) + } + }; + if !status.is_success() { + return GitUpdateDispatch::Refused { + status: status.as_u16(), + body, + }; + } + match serde_json::from_str::(&body) { + Ok(response) if response.operation_id == request.operation_id => { + GitUpdateDispatch::Answered(Box::new(response)) + } + Ok(_) => GitUpdateDispatch::Lost(anyhow!( + "Git update acknowledgement names another operation" + )), + Err(error) => GitUpdateDispatch::Lost( + anyhow!(error).context("parse the daemon's Git update answer"), + ), + } + } + pub async fn command_push( &self, request: &crate::commands::transfer::CommandTransferRequest, @@ -1828,6 +2194,121 @@ impl DaemonClient { self.transfer_command("transfer-plan", request).await } + /// Send one push or pull exactly once, and say what came of it. + /// + /// The local app must never repeat a transfer behind the page when its + /// answer is lost, so this goes out on the client that never retries and + /// keeps a refusal apart from a lost answer instead of folding both into + /// one error. A refusal is the daemon's own answer: the command has ended + /// on the daemon, although a transfer that needed several packs may have + /// published some before it refused, which only a fresh reading can tell. + pub async fn transfer_once( + &self, + direction: kin_remote::repository_transfer_negotiation::RepositoryTransferDirection, + request: &crate::commands::transfer::CommandTransferRequest, + ) -> TransferDispatch { + use kin_remote::repository_transfer_negotiation::RepositoryTransferDirection; + let leaf = match direction { + RepositoryTransferDirection::Push => "push", + RepositoryTransferDirection::Pull => "pull", + }; + let sent = self + .send( + self.one_dispatch_client + .post(format!( + "{}/commands/{leaf}", + self.base_url.trim_end_matches('/') + )) + .json(request), + leaf, + ) + .await; + let response = match sent { + Ok(response) => response, + Err(error) => return TransferDispatch::Lost(error), + }; + let status = response.status(); + let body = match response.text().await { + Ok(body) => body, + Err(error) => { + return TransferDispatch::Lost( + anyhow!(error).context(format!("read the daemon's {leaf} answer")), + ) + } + }; + if !status.is_success() { + return TransferDispatch::Refused { + status: status.as_u16(), + body, + }; + } + match serde_json::from_str(&body) { + Ok(response) => TransferDispatch::Answered(Box::new(response)), + Err(error) => TransferDispatch::Lost( + anyhow!(error).context(format!("parse the daemon's {leaf} answer")), + ), + } + } + + /// Negotiate a push plan, keeping the daemon's status apart from a + /// transport failure. The outer error is a daemon that did not answer; the + /// inner one is the daemon's refusal with its status and its words. + pub async fn command_transfer_plan_reading( + &self, + request: &crate::commands::transfer::CommandTransferRequest, + ) -> Result< + std::result::Result, + > { + let response = self + .send( + self.client + .post(format!("{}/commands/transfer-plan", self.base_url)) + .json(request), + "transfer-plan", + ) + .await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Ok(Err((status.as_u16(), body))); + } + Ok(Ok(response + .json() + .await + .context("parse daemon transfer plan response")?)) + } + + /// What changed between two read points of this daemon's repository, + /// entity by entity, from `GET /repos/{repo_id}/semantic-diff`. + pub async fn repository_semantic_diff( + &self, + repo_id: &str, + base: &str, + head: &str, + ) -> Result> { + let response = self + .send( + self.client + .get(format!( + "{}/repos/{}/semantic-diff", + self.base_url.trim_end_matches('/'), + urlencoding::encode(repo_id) + )) + .query(&[("base", base), ("head", head)]), + "semantic diff", + ) + .await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Ok(Err((status.as_u16(), body))); + } + Ok(Ok(response + .json() + .await + .context("parse daemon semantic diff response")?)) + } + /// Drive one repository-v6 transfer command in the daemon. /// /// A refusal body is surfaced verbatim. The daemon has already mapped the @@ -2398,22 +2879,26 @@ impl DaemonClient { Err(error) => { return AdmitDispatch::Refused( anyhow::Error::new(error).context("encode the admission request"), - ) + ); } }; - let response = match self + let request = self .one_dispatch_client .post(&url) .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(payload) - .send() - .await - { + .body(payload); + // Nothing was dispatched when admission itself fails, so that is a + // refusal rather than an unanswered request. + let request = match self.admitted_request(request, "admit").await { + Ok(request) => request, + Err(error) => return AdmitDispatch::Refused(error), + }; + let response = match request.send().await { Ok(response) => response, Err(error) => { return AdmitDispatch::Unanswered(anyhow::Error::new(error).context( daemon_send_failure_message(&self.base_url, "admit", self.kin_root()), - )) + )); } }; if let Err(error) = check_response_build_match(response.headers()) { @@ -2434,7 +2919,7 @@ impl DaemonClient { Err(error) => { return AdmitDispatch::Unanswered( anyhow::Error::new(error).context("read the admission response body"), - ) + ); } }; match serde_json::from_slice(&body) { @@ -2583,8 +3068,51 @@ impl DaemonClient { resp.json().await.context("parse daemon agents response") } - /// `GET /session/{session_id}/calls`: one live session's call log, newest - /// first, at most `limit` entries. + /// `POST /agents/edits`: report an edit an agent made with its client's + /// own tools. With `wait` the answer says what became of the report; + /// without it the daemon answers at once and resolves it behind the answer. + pub async fn record_agent_edit( + &self, + claim: &serde_json::Value, + wait: bool, + ) -> Result { + let url = if wait { + format!("{}/agents/edits?wait=true", self.base_url) + } else { + format!("{}/agents/edits", self.base_url) + }; + let resp = self + .send(self.client.post(url).json(claim), "agents/edits") + .await?; + if !resp.status().is_success() { + return Err(self.http_refusal("agents/edits", resp).await); + } + resp.json() + .await + .context("parse daemon edit report response") + } + + /// `GET /agents/ended`: the agents that worked in this repository and are + /// gone, with at most `calls` of each one's newest calls. `None` from a + /// daemon that does not keep sessions past their end. + pub async fn ended_agents(&self, calls: usize) -> Result> { + let url = format!("{}/agents/ended?calls={calls}", self.base_url); + let resp = self.send(self.client.get(url), "agents/ended").await?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + return Err(self.http_refusal("agents/ended", resp).await); + } + resp.json() + .await + .map(Some) + .context("parse daemon ended agents response") + } + + /// `GET /session/{session_id}/calls`: one session's call log, newest + /// first, at most `limit` entries. A session that ended answers from what + /// the daemon kept of it. pub async fn session_calls( &self, session_id: &str, @@ -2654,6 +3182,27 @@ impl DaemonClient { resp.json().await.context("parse daemon note response") } + /// Set, close or list review flags in the repository daemon. + pub async fn review_flags( + &self, + request: &crate::commands::review_flags::ReviewFlagRequest, + ) -> Result { + let resp = self + .send( + self.client + .post(format!("{}/review/flags", self.base_url)) + .json(request), + "review flags", + ) + .await?; + if !resp.status().is_success() { + return Err(self.http_refusal("review flags", resp).await); + } + resp.json() + .await + .context("parse daemon review flags response") + } + pub async fn set_scope(&self, session_id: &str, ref_string: &str) -> Result { let resp = self .send( @@ -3858,7 +4407,7 @@ pub fn probe_daemon_port(port: u16) -> DaemonPortProbe { let mut stream = match std::net::TcpStream::connect_timeout(&addr, PORT_PROBE_CONNECT_TIMEOUT) { Ok(stream) => stream, Err(error) if error.kind() == std::io::ErrorKind::TimedOut => { - return DaemonPortProbe::OpenNotAccepting + return DaemonPortProbe::OpenNotAccepting; } Err(_) => return DaemonPortProbe::Closed, }; @@ -4893,7 +5442,20 @@ pub fn read_supervisor_owner_record() -> Option { read_supervisor_owner_record_in_dir(&supervisor_dir()) } -fn read_supervisor_owner_record_in_dir(dir: &Path) -> Option { +/// The supervisor endpoint recorded in `dir`: its pid, port and owner record, +/// with no liveness probe. +/// +/// A supervisor from 0.8.6 or older recorded its endpoint in `.kin` under the +/// real home, or beside an explicit `KIN_REGISTRY_PATH`, rather than in the +/// managed services directory, and the updater reads it there to retire it. +pub fn supervisor_endpoint_in_dir( + dir: &Path, +) -> (Option, Option, Option) { + let recorded = supervisor_endpoint_snapshot(dir); + (recorded.pid, recorded.port, recorded.owner) +} + +fn read_supervisor_owner_record_in_dir(dir: &Path) -> Option { let raw = std::fs::read_to_string(dir.join(SUPERVISOR_OWNER_FILE)).ok()?; let record: EndpointOwnerRecord = serde_json::from_str(&raw).ok()?; (record.schema == ENDPOINT_OWNER_SCHEMA).then_some(record) @@ -5637,6 +6199,15 @@ fn validate_daemon_compat_response(compat: &DaemonCompatResponse) -> Result<(), SUPERVISOR_BOUNDED_ROLLBACK_CAPABILITY )); } + let identity = compat.runtime_identity.as_ref().ok_or_else(|| "daemon does not report exact runtime identity; install CLI and daemon from the same current bundle".to_string())?; + let mode = compat.runtime_mode.ok_or_else(|| { + "daemon does not acknowledge managed or development runtime mode".to_string() + })?; + kin_core::runtime_identity::RuntimeIdentity::current().validate_service( + identity, + kin_core::runtime_identity::RuntimeMode::current()?, + mode, + )?; Ok(()) } @@ -5819,9 +6390,57 @@ async fn attach_to_existing_daemon( kin_root: &Path, base_url: &str, ) -> std::result::Result<(), AutoStartError> { + if let Err(error) = validate_runtime_endpoint(base_url, "daemon").await { + if error.downcast_ref::().is_some() { + // Only the repository's own recorded daemon can be replaced from + // here: its incarnation is what the replacement is bound to. + let recorded = daemon_endpoint_snapshot(kin_root); + if let (Some(pid), Some(port)) = (recorded.pid, recorded.port) { + if base_url.trim_end_matches('/') == format!("http://127.0.0.1:{port}") { + match judge_daemon_of_another_release(base_url, pid, Duration::from_secs(5)) + .await + { + Some(kin_core::runtime_identity::Succession::Replace) => { + return Err(AutoStartError::Superseded { pid }) + } + Some(refused) => { + if let Some(message) = refused.refusal() { + return Err(AutoStartError::SpawnFailed(message.to_string())); + } + } + None => {} + } + } + } + } + return Err(AutoStartError::spawn(error)); + } report_behavior_env_ignored_by_existing_daemon(kin_root, base_url).await } +/// What a managed client does about the daemon at `base_url`, which refused +/// it as another release, or `None` for a development client, which keeps the +/// refusal it always had. +/// +/// Only the refusals that say something new are worth their words here: a +/// newer release, a development build, and a client an update has replaced. +/// The rest keep the admission's own refusal. +async fn judge_daemon_of_another_release( + base_url: &str, + pid: u32, + budget: Duration, +) -> Option { + use kin_core::runtime_identity::Succession; + let client = succession::current_succession_client().ok()?; + if client.mode != kin_core::runtime_identity::RuntimeMode::Managed { + return None; + } + match succession::judge_running_daemon(base_url, pid, budget).await { + Succession::Attach | Succession::Refuse(_) => None, + judged => Some(judged), + } +} + /// Say so when this command sets behavior levers the attached daemon cannot /// honor, instead of letting them be dropped in silence. /// @@ -7337,28 +7956,39 @@ pub enum InstalledStartupProtocol { /// Installed kin binaries on this host that are not the running one. /// -/// Candidates are whatever `PATH` resolves for `kin`, plus the Kin home's -/// `bin`. That is deliberately the set an operator actually invokes rather than -/// a filtered notion of an install: `PATH` is what decides which binary runs, so -/// a build tree reachable through it is probed like any other install and -/// diagnosed on the same evidence. Paths are canonicalized so one binary -/// reachable by two names is probed once, and so the running binary is excluded -/// even when it was invoked through a symlink. +/// The candidates are every native `kin` the full copy enumeration finds: +/// each one on `PATH`, Cargo's and Homebrew's, `/usr/local/bin`, the managed +/// pair, and anything the managed home links to. npm launchers are left out, +/// because they run the managed pair and carry no daemon of their own, and so +/// are copies that cannot be identified. Paths are canonicalized so one binary +/// reachable by two names is probed once, and so the running binary is +/// excluded even when it was invoked through a symlink. fn other_installed_kin_binaries() -> Vec { + use crate::commands::kin_copies::{scan, CopyKind, ScanRoots}; + let running = std::env::current_exe() .ok() .and_then(|path| path.canonicalize().ok()); - let mut candidates = Vec::new(); - if let Some(path) = crate::commands::setup::check_binary_in_path("kin") { - candidates.push(path); - } - if let Ok(home) = crate::commands::setup::kin_dir() { - candidates.push(home.join("bin").join("kin")); - } - + let Ok(roots) = ScanRoots::from_environment() else { + return Vec::new(); + }; + // Only the startup protocol probe below runs anything; identifying a copy + // never does here. + let roots = ScanRoots { + probe_versions: false, + ..roots + }; let mut found: Vec = Vec::new(); - for candidate in candidates { - let Ok(resolved) = candidate.canonicalize() else { + for copy in scan(&roots).copies { + if copy.program != "kin" + || matches!( + copy.kind, + CopyKind::NpmLauncher { .. } | CopyKind::Unknown(_) + ) + { + continue; + } + let Ok(resolved) = copy.path.canonicalize() else { continue; }; if running.as_ref() == Some(&resolved) || found.contains(&resolved) { @@ -7421,7 +8051,7 @@ pub fn probe_installed_startup_protocol(kin_binary: &Path) -> InstalledStartupPr Err(error) => { return InstalledStartupProtocol::Undetermined(format!( "compat probe failed to execute: {error}" - )) + )); } }; let compat: DaemonCompatResponse = match serde_json::from_slice(&output.stdout) { @@ -7429,7 +8059,7 @@ pub fn probe_installed_startup_protocol(kin_binary: &Path) -> InstalledStartupPr Err(error) => { return InstalledStartupProtocol::Undetermined(format!( "compat probe returned invalid JSON: {error}" - )) + )); } }; match compat.supervisor_startup_protocol { @@ -8260,6 +8890,20 @@ enum ExistingDaemon { /// one would lose the singleton flock, so the caller must report this /// instead. LiveNotReady(String), + /// A live daemon of an older release owns this repo. The caller replaces + /// it under the startup lock, then starts this release's. + Superseded(u32), +} + +/// How the administrative identity handshake with a recorded daemon ended. +#[derive(Debug, PartialEq, Eq)] +enum IdentityHandshake { + /// It runs this client's release. + Admitted, + /// The recorded endpoint changed or its owner exited while waiting. + EndpointChanged, + /// It runs an older release this client replaces. + Superseded, } /// Resolve the endpoint recorded for this repo, escalating patience — never @@ -8305,6 +8949,87 @@ async fn wait_for_existing_daemon_within( } } +/// Wait only for the administrative identity handshake. Health and repository +/// requests cannot begin until the endpoint proves the selected runtime. +async fn wait_for_existing_daemon_identity( + kin_root: &Path, + endpoint: LiveDaemonEndpoint, + deadline: Instant, + patience: Duration, + silence_bound: Option, +) -> std::result::Result { + let started = Instant::now(); + let base_url = format!("http://127.0.0.1:{}", endpoint.port); + let mut detail = "the runtime identity endpoint has not answered".to_string(); + loop { + let recorded = daemon_endpoint_snapshot(kin_root); + if recorded.pid != Some(endpoint.pid) + || recorded.port != Some(endpoint.port) + || process_liveness(endpoint.pid).authorizes_cleanup() + { + return Ok(IdentityHandshake::EndpointChanged); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + let silence_remaining = silence_bound.map(|bound| bound.saturating_sub(started.elapsed())); + if silence_remaining.is_some_and(|remaining| remaining.is_zero()) { + return Err(live_daemon_wedged_message( + endpoint.pid, + endpoint.port, + &detail, + started.elapsed(), + )); + } + if remaining.is_zero() { + return Err(live_daemon_not_ready_message( + endpoint.pid, + endpoint.port, + &detail, + false, + patience.as_secs(), + )); + } + let budget = silence_remaining.map_or(remaining, |silence| remaining.min(silence)); + let handshake = + tokio::time::timeout(budget, validate_runtime_endpoint(&base_url, "daemon")).await; + let recorded = daemon_endpoint_snapshot(kin_root); + if recorded.pid != Some(endpoint.pid) + || recorded.port != Some(endpoint.port) + || process_liveness(endpoint.pid).authorizes_cleanup() + { + return Ok(IdentityHandshake::EndpointChanged); + } + match handshake { + Ok(Ok(())) => return Ok(IdentityHandshake::Admitted), + Ok(Err(error)) if error.downcast_ref::().is_some() => { + let budget = deadline.saturating_duration_since(Instant::now()); + return match judge_daemon_of_another_release(&base_url, endpoint.pid, budget) + .await + { + Some(kin_core::runtime_identity::Succession::Replace) => { + Ok(IdentityHandshake::Superseded) + } + Some(refused) if refused.refusal().is_some() => { + Err(refused.refusal().unwrap_or_default().to_string()) + } + _ => Err(format!("{error:#}; existing daemon pid {} was left running and its endpoint files were preserved", endpoint.pid)), + }; + } + Ok(Err(error)) => detail = format!("{error:#}"), + Err(_) => { + detail = + "the runtime identity probe did not answer within the caller's readiness budget" + .to_string() + } + } + let mut retry_delay = + Duration::from_millis(50).min(deadline.saturating_duration_since(Instant::now())); + if let Some(bound) = silence_bound { + retry_delay = retry_delay.min(bound.saturating_sub(started.elapsed())); + } + tokio::time::sleep(retry_delay).await; + } +} + async fn inspect_existing_daemon_once( kin_root: &Path, short: Duration, @@ -8391,6 +9116,18 @@ async fn inspect_existing_daemon_once( }; let existing = LiveDaemonEndpoint { pid, port }; + match wait_for_existing_daemon_identity(kin_root, existing, deadline, patience, silence_bound) + .await + { + Ok(IdentityHandshake::Admitted) => {} + Ok(IdentityHandshake::EndpointChanged) => { + return ExistingDaemon::Starting( + "the daemon endpoint changed while its runtime identity was pending".to_string(), + ) + } + Ok(IdentityHandshake::Superseded) => return ExistingDaemon::Superseded(pid), + Err(message) => return ExistingDaemon::LiveNotReady(message), + } let short_deadline = (Instant::now() + short).min(deadline); // No silence bound on the short probe. It is the fast path for a healthy // daemon and is shorter than the bound anyway, so applying it there would @@ -8616,6 +9353,7 @@ fn live_daemon_not_ready_message( async fn validate_supervisor_endpoint(endpoint: LiveDaemonEndpoint) -> Result { let base_url = format!("http://127.0.0.1:{}", endpoint.port); + validate_runtime_endpoint(&base_url, "supervisor").await?; let client = daemon_health_client(); let health: SupervisorHealthResponse = client .get(format!("{base_url}/health")) @@ -8638,6 +9376,93 @@ async fn validate_supervisor_endpoint(endpoint: LiveDaemonEndpoint) -> Result) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for RuntimeEndpointRefusal {} + +/// Attach the admission a local daemon requires to a request built outside +/// [`DaemonClient`]. +/// +/// Some commands send to the daemon on an HTTP client of their own, for a +/// deadline or a retry policy the shared client does not carry. A managed +/// daemon refuses every such request that lacks this process's runtime +/// identity and the repository it means, before the handler runs, so each one +/// passes through here with the layout it addresses. +pub async fn admit_daemon_request( + request: reqwest::RequestBuilder, + base_url: &str, + layout: &KinLayout, + leaf: &str, +) -> Result { + Ok(request.headers(daemon_admission_headers(base_url, layout, leaf).await?)) +} + +/// The headers [`admit_daemon_request`] attaches, for a caller that sends the +/// same request more than once. +pub async fn daemon_admission_headers( + base_url: &str, + layout: &KinLayout, + leaf: &str, +) -> Result { + DaemonClient::from_base_url_for_layout(base_url.to_string(), layout)? + .admission_headers(leaf) + .await +} + +pub async fn validate_runtime_endpoint(base_url: &str, component: &str) -> Result<()> { + let identity = read_runtime_endpoint(base_url).await?; + if component == "daemon" { + let mode = kin_core::runtime_identity::RuntimeMode::current() + .map_err(|error| anyhow::Error::new(RuntimeEndpointRefusal(error)))?; + let namespace = kin_core::runtime_identity::RuntimeNamespace::current(); + return identity + .admit_daemon_for( + &kin_core::runtime_identity::RuntimeIdentity::current(), + mode, + namespace.as_ref().ok(), + ) + .map(|_| ()) + .map_err(|error| anyhow::Error::new(RuntimeEndpointRefusal(error))); + } + identity + .validate_for_current(component) + .map_err(|error| anyhow::Error::new(RuntimeEndpointRefusal(error))) +} + +pub async fn read_runtime_endpoint( + base_url: &str, +) -> Result { + let response = daemon_health_client() + .get(format!( + "{}/runtime/identity", + base_url.trim_end_matches('/') + )) + .send() + .await + .context("probe service runtime identity")?; + if !response.status().is_success() { + return Err(anyhow::Error::new(RuntimeEndpointRefusal(format!( + "service does not acknowledge exact runtime identity (HTTP {})", + response.status() + )))); + } + let identity: kin_core::runtime_identity::RuntimeServiceIdentity = + response.json().await.map_err(|error| { + anyhow::Error::new(RuntimeEndpointRefusal(format!( + "invalid runtime identity: {error}" + ))) + })?; + Ok(identity) +} + #[derive(Debug)] enum ExistingSupervisor { Connected(String), @@ -8806,6 +9631,11 @@ where let existing = LiveDaemonEndpoint { pid, port }; match validate_supervisor_endpoint(existing).await { Ok(base_url) => ExistingSupervisor::Connected(base_url), + Err(err) if err.downcast_ref::().is_some() => { + ExistingSupervisor::LiveNotReady(format!( + "{err}; existing supervisor pid {pid} and its endpoint were preserved" + )) + } Err(err) => { warn!( pid = existing.pid, @@ -9501,6 +10331,74 @@ impl kin_daemon_spawn::DaemonSpawnRegistrar for CliSpawnRegistrar { .await }) } + + fn reading_at( + &self, + kin_root: PathBuf, + endpoint: String, + budget: Duration, + ) -> std::pin::Pin< + Box + Send>, + > { + Box::pin(async move { probe_running_route_within(&kin_root, &endpoint, budget).await }) + } + + fn replace_superseded( + &self, + kin_root: PathBuf, + endpoint: String, + budget: Duration, + ) -> std::pin::Pin< + Box + Send>, + > { + Box::pin(async move { replace_superseded_endpoint(&kin_root, &endpoint, budget).await }) + } +} + +/// Replace the daemon recorded for `kin_root` at `endpoint`, which refused +/// this client as another release, when it is an older one this client +/// supersedes. Holds the repository's startup lock while it does, and starts +/// nothing. +async fn replace_superseded_endpoint( + kin_root: &Path, + endpoint: &str, + budget: Duration, +) -> kin_daemon_spawn::SupersededReplacement { + use kin_daemon_spawn::SupersededReplacement; + let deadline = Instant::now() + budget; + let recorded = daemon_endpoint_snapshot(kin_root); + let Some(pid) = recorded.pid.filter(|_| { + recorded.port.is_some_and(|port| { + endpoint.trim_end_matches('/') == format!("http://127.0.0.1:{port}") + }) + }) else { + return SupersededReplacement::Refused(format!( + "{endpoint} is not the daemon recorded for this repository, so it was left running" + )); + }; + match judge_daemon_of_another_release(endpoint, pid, budget.min(Duration::from_secs(5))).await { + Some(kin_core::runtime_identity::Succession::Replace) => {} + Some(refused) => { + return SupersededReplacement::Refused( + refused.refusal().unwrap_or_default().to_string(), + ) + } + None => { + return SupersededReplacement::Refused(format!( + "Kin daemon pid {pid} runs another Kin, and this client does not replace it; it \ + was left running" + )) + } + } + let _startup_lock = match acquire_startup_lock(kin_root).await { + Ok(lock) => lock, + Err(error) => return SupersededReplacement::Refused(format!("{error:#}")), + }; + let mut replaced = None; + match replace_superseded_daemon_once_until(kin_root, pid, &mut replaced, deadline).await { + Ok(()) => SupersededReplacement::Replaced, + Err(error) => SupersededReplacement::Refused(error.to_string()), + } } /// Publish this crate's supervisor access to every daemon spawn in this @@ -9540,8 +10438,12 @@ async fn ensure_daemon_running_inner( .await .map_err(map_supervisor_auto_start_error)?; if let Some(base_url) = supervisor_route_for_repo(kin_root, &supervisor_url).await { - attach_to_existing_daemon(kin_root, &base_url).await?; - return Ok(base_url); + match attach_to_existing_daemon(kin_root, &base_url).await { + Ok(()) => return Ok(base_url), + // Replaced under the startup lock below. + Err(AutoStartError::Superseded { .. }) => {} + Err(error) => return Err(error), + } } match wait_for_existing_daemon(kin_root).await { @@ -9549,34 +10451,59 @@ async fn ensure_daemon_running_inner( register_repo_daemon_with_supervisor(kin_root, &base_url, &supervisor_url, false) .await .map_err(AutoStartError::spawn)?; - attach_to_existing_daemon(kin_root, &base_url).await?; - return Ok(base_url); + match attach_to_existing_daemon(kin_root, &base_url).await { + Ok(()) => return Ok(base_url), + Err(AutoStartError::Superseded { .. }) => {} + Err(error) => return Err(error), + } } ExistingDaemon::Starting(message) | ExistingDaemon::LiveNotReady(message) => { return Err(AutoStartError::SpawnFailed(message)); } - ExistingDaemon::None => {} + // Replaced under the startup lock below, where no other command of + // this release can start a daemon in the meantime. + ExistingDaemon::Superseded(_) | ExistingDaemon::None => {} } let _startup_lock = acquire_startup_lock(kin_root) .await .map_err(AutoStartError::spawn)?; - if let Some(base_url) = supervisor_route_for_repo(kin_root, &supervisor_url).await { - attach_to_existing_daemon(kin_root, &base_url).await?; - return Ok(base_url); - } - match wait_for_existing_daemon(kin_root).await { - ExistingDaemon::Connected(base_url) => { - register_repo_daemon_with_supervisor(kin_root, &base_url, &supervisor_url, false) - .await - .map_err(AutoStartError::spawn)?; - attach_to_existing_daemon(kin_root, &base_url).await?; - return Ok(base_url); + // One replacement per call. A second older daemon after it means + // something keeps starting one, and replacing it again would only race + // that. + let mut replaced: Option = None; + loop { + if let Some(base_url) = supervisor_route_for_repo(kin_root, &supervisor_url).await { + match attach_to_existing_daemon(kin_root, &base_url).await { + Ok(()) => return Ok(base_url), + Err(AutoStartError::Superseded { pid }) => { + replace_superseded_daemon_once(kin_root, pid, &mut replaced).await?; + continue; + } + Err(error) => return Err(error), + } } - ExistingDaemon::Starting(message) | ExistingDaemon::LiveNotReady(message) => { - return Err(AutoStartError::SpawnFailed(message)); + match wait_for_existing_daemon(kin_root).await { + ExistingDaemon::Connected(base_url) => { + register_repo_daemon_with_supervisor(kin_root, &base_url, &supervisor_url, false) + .await + .map_err(AutoStartError::spawn)?; + match attach_to_existing_daemon(kin_root, &base_url).await { + Ok(()) => return Ok(base_url), + Err(AutoStartError::Superseded { pid }) => { + replace_superseded_daemon_once(kin_root, pid, &mut replaced).await?; + } + Err(error) => return Err(error), + } + } + ExistingDaemon::Starting(message) | ExistingDaemon::LiveNotReady(message) => { + return Err(AutoStartError::SpawnFailed(message)); + } + ExistingDaemon::Superseded(pid) => { + replace_superseded_daemon_once(kin_root, pid, &mut replaced).await?; + } + ExistingDaemon::None => break, } - ExistingDaemon::None => {} } // Everything above this line either attached to a live daemon or proved @@ -9659,10 +10586,10 @@ async fn ensure_daemon_running_inner( base_url } Err(DaemonReadinessError::Failed(error)) => { - return Err(AutoStartError::spawn(format!("{error:#}"))) + return Err(AutoStartError::spawn(format!("{error:#}"))); } Err(DaemonReadinessError::Timeout(detail)) => { - return Err(AutoStartError::StartupTimeout(detail)) + return Err(AutoStartError::StartupTimeout(detail)); } }; register_repo_daemon_with_supervisor(kin_root, &base_url, &supervisor_url, true) @@ -9680,6 +10607,76 @@ async fn ensure_daemon_running_inner( Ok(base_url) } +/// Replace the older daemon at `pid` that serves `kin_root`, once per call. +/// +/// The caller holds the repository's startup lock. Waits as long as a daemon +/// start may take for it to finish its writes and go. A daemon still working +/// at the end, or one that is not this client's to replace, is refused in its +/// own words, and nothing is started or attached. A second older daemon after +/// the first is refused by the process that started it. +async fn replace_superseded_daemon_once( + kin_root: &Path, + pid: u32, + replaced: &mut Option, +) -> std::result::Result<(), AutoStartError> { + let deadline = Instant::now() + Duration::from_secs(daemon_ready_timeout_secs()); + replace_superseded_daemon_once_until(kin_root, pid, replaced, deadline).await +} + +/// [`replace_superseded_daemon_once`] within the caller's `deadline`. +async fn replace_superseded_daemon_once_until( + kin_root: &Path, + pid: u32, + replaced: &mut Option, + deadline: Instant, +) -> std::result::Result<(), AutoStartError> { + use crate::commands::daemon::ReplacementOutcome; + if let Some(previous) = *replaced { + return Err(AutoStartError::SpawnFailed(format!( + "Kin replaced the older daemon pid {previous} for this repository, and another older daemon, pid {pid}, took its place, started by {}. Kin will not replace it again in one command; stop whatever keeps starting it", + describe_parent_process(pid) + ))); + } + let successor = + crate::commands::daemon::ReplacementSuccessor::current().map_err(AutoStartError::spawn)?; + let outcome = + crate::commands::daemon::retire_for_replacement(kin_root, pid, &successor, deadline) + .await + .map_err(AutoStartError::spawn)?; + *replaced = Some(pid); + match outcome { + ReplacementOutcome::Replaced { .. } + | ReplacementOutcome::NotRunning + | ReplacementOutcome::AlreadySuccessor => { + info!(pid, successor = %successor.label(), "replaced an older Kin daemon"); + Ok(()) + } + ReplacementOutcome::Waiting(holding) => Err(AutoStartError::SpawnFailed(format!( + "Kin is replacing the older daemon pid {pid} with {}, and it is still finishing: {}. Nothing was started or attached; run the command again once it has finished", + successor.label(), + holding.join("; ") + ))), + ReplacementOutcome::Refused(reason) => Err(AutoStartError::SpawnFailed(reason)), + } +} + +/// The process that started `pid`, for a refusal to name: its pid and +/// executable, or that it could not be read. +fn describe_parent_process(pid: u32) -> String { + let mut system = sysinfo::System::new(); + let target = sysinfo::Pid::from_u32(pid); + system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[target]), true); + match system.process(target).and_then(|process| process.parent()) { + Some(parent) => format!( + "pid {} ({})", + parent.as_u32(), + process_executable_path(parent.as_u32()) + .unwrap_or_else(|| "executable unknown".to_string()) + ), + None => "a process that could not be read".to_string(), + } +} + /// Refuse a `.kin/` store this build cannot serve before anything is spawned. /// /// The daemon holds the same gate, but reaching it costs a supervisor, a binary @@ -9691,7 +10688,15 @@ async fn ensure_daemon_running_inner( /// Only the version gap is answered here. Any other failure to read the marker /// is recorded and left to the daemon, whose gate sees the same file. fn refuse_incompatible_store(kin_root: &Path) -> std::result::Result<(), AutoStartError> { - match kin_core::KinLayout::new(kin_root.to_path_buf()).check_version() { + let layout = kin_core::KinLayout::new(kin_root.to_path_buf()); + // A store whose history attach a crash interrupted may hold no version + // marker at all until the attach is finished, and a missing marker reads + // as the oldest layout. The daemon this start is about to spawn finishes + // the attach before it reads the store, so the refusal is left to it. + if kin_core::history_attach::attach_is_interrupted(&layout) { + return Ok(()); + } + match layout.check_version() { Ok(()) => Ok(()), Err(error @ kin_core::KinError::IncompatibleVersion { .. }) => { Err(AutoStartError::IncompatibleStore( @@ -9811,7 +10816,7 @@ async fn running_daemon_route_reading_within( && !recorded.port_exists && !repo_daemon_owner_path(layout.root()).exists() => { - return Reading::Absent + return Reading::Absent; } _ => return Reading::Unresolved { detail: @@ -9839,14 +10844,23 @@ async fn running_daemon_route_reading_within( ) .await; match verdict { - Ok(EndpointVerdict::Serving(url)) => probe_running_route_within( - layout.root(), &url, - deadline.saturating_duration_since(tokio::time::Instant::now()), - ).await, + Ok(EndpointVerdict::Serving(url)) => { + probe_running_route_within( + layout.root(), + &url, + deadline.saturating_duration_since(tokio::time::Instant::now()), + ) + .await + } Ok(EndpointVerdict::Invalid(detail)) => Reading::Unresolved { detail }, - Ok(EndpointVerdict::LiveNotReady { detail, warming, .. }) => Reading::NotReady { detail, warming }, + Ok(EndpointVerdict::LiveNotReady { + detail, warming, .. + }) => Reading::NotReady { detail, warming }, Err(_) => Reading::NotReady { - detail: format!("recorded daemon pid {} at loopback port {} has not established readiness for this repository within the probe budget", endpoint.pid, endpoint.port), + detail: format!( + "recorded daemon pid {} at loopback port {} has not established readiness for this repository within the probe budget", + endpoint.pid, endpoint.port + ), warming: warming.load(Ordering::Relaxed), }, } @@ -10485,60 +11499,921 @@ mod tests { ); } - async fn spawn_fixed_response_server_at( - route: &'static str, - status: axum::http::StatusCode, - body: impl Into, - ) -> ( - String, - std::sync::Arc, - tokio::task::JoinHandle<()>, - ) { - use axum::{routing::post, Router}; + fn runtime_identity_body(component: &str) -> String { + serde_json::to_string( + &kin_core::runtime_identity::RuntimeServiceIdentity::current(component) + .expect("the fixture uses the selected isolated runtime home"), + ) + .unwrap() + } - let body = body.into(); - let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let handler_requests = requests.clone(); - let app = Router::new().route( - route, - post(move || { - let requests = handler_requests.clone(); + struct RequestRepositoryFixture { + layout: kin_core::layout::KinLayout, + _environment: kin_core::test_env::EnvVarGuard, + _root: tempfile::TempDir, + } + + impl RequestRepositoryFixture { + fn new() -> Self { + let root = tempfile::tempdir().expect("isolated protected-request repository"); + let home = root.path().join("home"); + let kin_home = root.path().join("kin-home"); + let working_dir = root.path().join("repo"); + for directory in [&home, &kin_home, &working_dir] { + std::fs::create_dir_all(directory).unwrap(); + } + let environment = kin_core::test_env::EnvVarGuard::set("HOME", &home) + .with("USERPROFILE", &home) + .with("KIN_HOME", &kin_home) + .with("KIN_RUNTIME_MODE", "development") + .with("KIN_REGISTRY_PATH", kin_home.join("registry.toml")) + .with("KIN_NO_DAEMON", "1") + .without("KIN_DIR") + .without("KIN_STORE") + .without("KIN_STORE_LAYOUT") + .without("KIN_DAEMON_URL") + .without("KIN_MCP_REPO") + .without("KIN_SESSION_ID"); + let layout = kin_core::init(&working_dir) + .expect("initialize the actual protected-request repository") + .layout; + kin_core::runtime_identity::RuntimeNamespace::initialize_local_service() + .expect("initialize the fixture's actual runtime namespace"); + Self { + layout, + _environment: environment, + _root: root, + } + } + + fn client( + &self, + base_url: String, + checkout_token: Option, + session_id: Option<&str>, + ) -> DaemonClient { + let mut client = DaemonClient::from_base_url_with_explicit_authority( + base_url, + checkout_token, + session_id, + ) + .unwrap(); + client.bind_repository(self.layout.root()); + client + } + } + + fn repository_runtime_identity( + component: &str, + layout: &kin_core::layout::KinLayout, + ) -> kin_core::runtime_identity::RuntimeServiceIdentity { + let mut identity = kin_core::runtime_identity::RuntimeServiceIdentity::current(component) + .expect("the fixture uses its actual isolated runtime namespace"); + identity.repository = Some( + kin_core::runtime_identity::RuntimeRepositoryBinding::for_layout(layout) + .expect("the fixture's actual repository binding") + .identity() + .clone(), + ); + identity + } + + fn repository_runtime_identity_body( + component: &str, + layout: &kin_core::layout::KinLayout, + ) -> String { + serde_json::to_string(&repository_runtime_identity(component, layout)).unwrap() + } + + fn with_repository_runtime_identity( + app: axum::Router, + component: &str, + layout: &kin_core::layout::KinLayout, + ) -> axum::Router { + let body = repository_runtime_identity_body(component, layout); + app.route( + "/runtime/identity", + axum::routing::get(move || { let body = body.clone(); async move { - requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - (status, body) + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + ) } }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - (format!("http://{address}"), requests, server) + ) } - async fn spawn_fixed_response_server( - status: axum::http::StatusCode, - body: &'static str, + async fn spawn_repository_admission_server( + identity: kin_core::runtime_identity::RuntimeServiceIdentity, ) -> ( String, std::sync::Arc, tokio::task::JoinHandle<()>, ) { - spawn_fixed_response_server_at("/commands/test", status, body).await - } - - async fn read_complete_http_request(stream: &mut tokio::net::TcpStream) -> std::io::Result<()> { - use tokio::io::AsyncReadExt; + use axum::{ + routing::{get, post}, + Router, + }; - let mut request = Vec::new(); - let mut chunk = [0_u8; 4096]; - loop { - let read = stream.read(&mut chunk).await?; - if read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, + identity + .validate_for_current("daemon") + .expect("the peer must share the caller's real source and runtime namespace"); + let identity_body = serde_json::to_string(&identity).unwrap(); + let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let handler_requests = requests.clone(); + let app = Router::new() + .route( + "/runtime/identity", + get(move || { + let body = identity_body.clone(); + async move { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + ) + } + }), + ) + .route( + "/commands/test", + post(move || { + let requests = handler_requests.clone(); + async move { + requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + axum::Json(serde_json::json!({"accepted": true})) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{address}"), requests, server) + } + + type HostedControlRequests = + std::sync::Arc>>; + + fn independent_hosted_identity() -> kin_core::runtime_identity::RuntimeServiceIdentity { + let mut peer = kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon") + .expect("the caller's real isolated runtime identity"); + peer.component = "hosted-daemon".into(); + peer.namespace = None; + peer.repository = None; + peer.identity.commit_sha = if peer.identity.commit_sha == "1".repeat(40) { + "2".repeat(40) + } else { + "1".repeat(40) + }; + peer + } + + async fn spawn_hosted_control_server( + peer: kin_core::runtime_identity::RuntimeServiceIdentity, + ) -> (String, HostedControlRequests, tokio::task::JoinHandle<()>) { + let identity = serde_json::to_string(&peer).unwrap(); + let requests: HostedControlRequests = Default::default(); + let captured = requests.clone(); + let operation = + move |uri: axum::http::Uri, + headers: axum::http::HeaderMap, + axum::Json(body): axum::Json| { + let captured = captured.clone(); + async move { + captured.lock().unwrap().push(( + uri.path().to_string(), + headers.contains_key(kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER), + body, + )); + axum::Json(if uri.path() == "/commands/admit" { + serde_json::json!({"lines": ["hosted admission control"], "mutated": false}) + } else { + serde_json::json!({"accepted": true}) + }) + } + }; + let app = axum::Router::new() + .route( + "/runtime/identity", + axum::routing::get(move || { + let identity = identity.clone(); + async move { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + identity, + ) + } + }), + ) + .route("/commands/test", axum::routing::post(operation.clone())) + .route("/commands/admit", axum::routing::post(operation)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{address}"), requests, server) + } + + fn unbound_hosted_client( + repository: &RequestRepositoryFixture, + base_url: String, + ) -> DaemonClient { + let mut client = repository.client(base_url, None, None); + client.kin_root = None; + client.runtime_repository = None; + client + } + + #[tokio::test] + async fn runtime_repository_client_hosted_send_uses_independent_authority() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let peer = independent_hosted_identity(); + assert_ne!( + peer.identity.commit_sha, + kin_core::runtime_identity::RuntimeIdentity::current().commit_sha + ); + let (base_url, requests, server) = spawn_hosted_control_server(peer).await; + let client = unbound_hosted_client(&repository, base_url); + let body = serde_json::json!({"control": "independent hosted send"}); + let response = client + .send( + client + .client + .post(format!("{}/commands/test", client.base_url)) + .json(&body), + "hosted send control", + ) + .await + .unwrap(); + assert!(response.status().is_success()); + assert_eq!( + response.json::().await.unwrap(), + serde_json::json!({"accepted": true}) + ); + assert_eq!( + *requests.lock().unwrap(), + vec![("/commands/test".into(), false, body)] + ); + server.abort(); + } + + #[tokio::test] + async fn runtime_repository_client_hosted_admit_uses_independent_authority() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let (base_url, requests, server) = + spawn_hosted_control_server(independent_hosted_identity()).await; + let client = unbound_hosted_client(&repository, base_url); + let request = crate::commands::admit::AdmitRequest::default(); + let AdmitDispatch::Answered(answer) = client.admit(&request).await else { + panic!("eligible independent Hosted authority must answer deliberate admission"); + }; + assert_eq!(answer.lines, vec!["hosted admission control"]); + assert!(!answer.mutated); + assert_eq!( + *requests.lock().unwrap(), + vec![( + "/commands/admit".into(), + false, + serde_json::to_value(&request).unwrap() + )] + ); + server.abort(); + } + + async fn assert_hosted_client_eligibility_refusal(wrong_mode: bool) { + let repository = RequestRepositoryFixture::new(); + let mut peer = independent_hosted_identity(); + let reason = if wrong_mode { + peer.mode = kin_core::runtime_identity::RuntimeMode::Managed; + "mode" + } else { + peer.identity.source_known = false; + "source" + }; + let (base_url, requests, server) = spawn_hosted_control_server(peer).await; + let client = unbound_hosted_client(&repository, base_url); + let error = client + .send( + client + .client + .post(format!("{}/commands/test", client.base_url)) + .json(&serde_json::json!({"control": "hosted refusal"})), + "hosted refusal control", + ) + .await + .expect_err("ineligible Hosted authority must refuse ordinary dispatch"); + assert!( + error.downcast_ref::().is_some(), + "{error:#}" + ); + assert!(format!("{error:#}").contains(reason), "{error:#}"); + let AdmitDispatch::Refused(error) = client + .admit(&crate::commands::admit::AdmitRequest::default()) + .await + else { + panic!("ineligible Hosted authority must refuse deliberate admission"); + }; + assert!( + error.downcast_ref::().is_some(), + "{error:#}" + ); + assert!(format!("{error:#}").contains(reason), "{error:#}"); + assert!( + requests.lock().unwrap().is_empty(), + "ineligible Hosted authority must receive no semantic request" + ); + server.abort(); + } + + #[tokio::test] + async fn runtime_repository_client_hosted_wrong_mode_refuses_both_dispatches() { + if !kin_core::test_env::alone_in_process() { + return; + } + assert_hosted_client_eligibility_refusal(true).await; + } + + #[tokio::test] + async fn runtime_repository_client_hosted_unknown_source_refuses_both_dispatches() { + if !kin_core::test_env::alone_in_process() { + return; + } + assert_hosted_client_eligibility_refusal(false).await; + } + + async fn assert_repository_client_refusal( + client: &DaemonClient, + requests: &std::sync::atomic::AtomicUsize, + reason: &str, + ) { + let error = client + .send( + client + .client + .post(format!("{}/commands/test", client.base_url)) + .json(&serde_json::json!({"control": "repository admission"})), + "repository admission control", + ) + .await + .expect_err("repository admission must refuse before semantic dispatch"); + assert!( + error.downcast_ref::().is_some(), + "{error:#}" + ); + assert!(format!("{error:#}").contains(reason), "{error:#}"); + assert_eq!( + requests.load(std::sync::atomic::Ordering::SeqCst), + 0, + "a refused repository must receive no semantic request" + ); + } + + #[tokio::test] + async fn runtime_repository_client_refuses_unbound_caller_without_dispatch() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let peer = repository_runtime_identity("daemon", &repository.layout); + let (base_url, requests, server) = spawn_repository_admission_server(peer).await; + let mut client = + DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + client.kin_root = None; + client.runtime_repository = None; + assert_repository_client_refusal(&client, &requests, "no intended repository is bound") + .await; + server.abort(); + } + + #[tokio::test] + async fn runtime_repository_client_refuses_missing_peer_repository_without_dispatch() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let mut peer = repository_runtime_identity("daemon", &repository.layout); + peer.repository = None; + let (base_url, requests, server) = spawn_repository_admission_server(peer).await; + let client = repository.client(base_url, None, None); + assert_repository_client_refusal( + &client, + &requests, + "ready service supplied no repository identity", + ) + .await; + server.abort(); + } + + #[tokio::test] + async fn runtime_repository_client_refuses_wrong_repository_id_without_dispatch() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let other_dir = repository._root.path().join("other-repo"); + std::fs::create_dir_all(&other_dir).unwrap(); + let other = kin_core::init(&other_dir) + .expect("initialize the other real repository") + .layout; + let other_binding = + kin_core::runtime_identity::RuntimeRepositoryBinding::for_layout(&other) + .expect("capture the other real repository binding"); + let mut peer = repository_runtime_identity("daemon", &repository.layout); + let claim = peer.repository.as_mut().unwrap(); + assert_ne!(claim.repository_id, other_binding.identity().repository_id); + claim.repository_id = other_binding.identity().repository_id.clone(); + let (base_url, requests, server) = spawn_repository_admission_server(peer).await; + let client = repository.client(base_url, None, None); + assert_repository_client_refusal( + &client, + &requests, + "repository identity differs from the selected repository", + ) + .await; + server.abort(); + } + + #[tokio::test] + async fn runtime_repository_client_refuses_wrong_store_namespace_without_dispatch() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let other_dir = repository._root.path().join("other-repo"); + std::fs::create_dir_all(&other_dir).unwrap(); + let other = kin_core::init(&other_dir) + .expect("initialize the other real repository") + .layout; + let other_binding = + kin_core::runtime_identity::RuntimeRepositoryBinding::for_layout(&other) + .expect("capture the other real repository binding"); + let mut peer = repository_runtime_identity("daemon", &repository.layout); + let claim = peer.repository.as_mut().unwrap(); + assert_ne!( + claim.store_namespace, + other_binding.identity().store_namespace + ); + claim.store_namespace = other_binding.identity().store_namespace.clone(); + let (base_url, requests, server) = spawn_repository_admission_server(peer).await; + let client = repository.client(base_url, None, None); + assert_repository_client_refusal( + &client, + &requests, + "repository identity differs from the selected repository", + ) + .await; + server.abort(); + } + + // Unix permits renaming a retained directory; Windows directory handles deny delete sharing. + #[cfg(unix)] + #[tokio::test] + async fn runtime_repository_client_refuses_replaced_retained_store_without_dispatch() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let peer = repository_runtime_identity("daemon", &repository.layout); + let (base_url, requests, server) = spawn_repository_admission_server(peer).await; + let client = repository.client(base_url, None, None); + let store = repository.layout.root(); + let retained = store.with_extension("retained-store"); + std::fs::rename(store, &retained) + .expect("rename the fixture's own store while its admitted capability is retained"); + std::fs::create_dir(store).expect("create a distinct store object at the original path"); + assert!( + client + .runtime_repository + .as_ref() + .unwrap() + .as_ref() + .unwrap() + .revalidate() + .is_err(), + "the retained capability must detect the replaced object" + ); + assert_repository_client_refusal(&client, &requests, "repository admission refused").await; + server.abort(); + } + + #[tokio::test] + async fn runtime_identity_client_refuses_same_source_other_home_before_health_dispatch() { + let other_home = tempfile::tempdir().unwrap(); + let other = + kin_core::runtime_identity::RuntimeNamespace::capture(other_home.path()).unwrap(); + let mut service = + kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon").unwrap(); + service.namespace = Some(other.token().to_owned()); + let reached = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let marker = reached.clone(); + let app = axum::Router::new() + .route( + "/runtime/identity", + axum::routing::get(move || { + let service = service.clone(); + async move { axum::Json(service) } + }), + ) + .route( + "/health", + axum::routing::get(move || { + let marker = marker.clone(); + async move { + marker.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + "repository health accessed" + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + struct FixtureServer(tokio::task::JoinHandle<()>); + impl Drop for FixtureServer { + fn drop(&mut self) { + self.0.abort(); + } + } + let _server = FixtureServer(tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + })); + let client = DaemonClient::from_base_url(base_url).unwrap(); + let error = client.health().await.unwrap_err(); + assert!(error.to_string().contains("namespace differs"), "{error:#}"); + assert_eq!(reached.load(std::sync::atomic::Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn runtime_identity_waiter_refuses_different_source_without_readiness_dispatch_or_retirement( + ) { + if !kin_core::test_env::alone_in_process() { + return; + } + let home = tempfile::tempdir().unwrap(); + let _env = kin_core::test_env::EnvVarGuard::set("KIN_HOME", home.path()) + .with("KIN_RUNTIME_MODE", "development"); + kin_core::runtime_identity::RuntimeNamespace::initialize_local_service().unwrap(); + let fixture = tempfile::tempdir().unwrap(); + let mut service = + kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon").unwrap(); + service.identity.commit_sha = if service.identity.commit_sha == "a".repeat(40) { + "b".repeat(40) + } else { + "a".repeat(40) + }; + let reached = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let marker = reached.clone(); + let app = axum::Router::new() + .route( + "/runtime/identity", + axum::routing::get(move || { + let service = service.clone(); + async move { axum::Json(service) } + }), + ) + .route( + "/health", + axum::routing::get(move || { + let marker = marker.clone(); + async move { + marker.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + "repository health accessed" + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + struct FixtureServer(tokio::task::JoinHandle<()>); + impl Drop for FixtureServer { + fn drop(&mut self) { + self.0.abort(); + } + } + let _server = FixtureServer(tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + })); + let pid_text = std::process::id().to_string(); + let port_text = port.to_string(); + std::fs::write(fixture.path().join("daemon.pid"), &pid_text).unwrap(); + std::fs::write(fixture.path().join("daemon.port"), &port_text).unwrap(); + let reading = tokio::time::timeout( + Duration::from_secs(5), + wait_for_existing_daemon_within( + fixture.path(), + Duration::from_millis(20), + Duration::from_secs(60), + None, + ), + ) + .await + .expect("an explicit identity refusal must not wait out startup patience"); + match reading { + ExistingDaemon::LiveNotReady(message) => { + assert!(message.contains("runtime identity mismatch"), "{message}") + } + other => panic!("a different source generation must remain refused: {other:?}"), + } + assert_eq!(reached.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + std::fs::read_to_string(fixture.path().join("daemon.pid")).unwrap(), + pid_text + ); + assert_eq!( + std::fs::read_to_string(fixture.path().join("daemon.port")).unwrap(), + port_text + ); + } + + /// A managed Kin 0.8.7 client, for the replacement tests. Test builds are + /// development clients, which never replace anything. + #[cfg(unix)] + fn managed_client_0_8_7() -> impl Drop { + succession::stand_in_client_for_test( + kin_core::runtime_identity::SuccessionClient { + identity: kin_core::runtime_identity::RuntimeIdentity { + version: "0.8.7".into(), + commit_sha: "a".repeat(40), + source_dirty: false, + source_known: true, + target: "aarch64-apple-darwin".into(), + build_profile: "release".into(), + }, + mode: kin_core::runtime_identity::RuntimeMode::Managed, + namespace: None, + }, + None, + ) + } + + /// A live stand-in daemon from before runtime identity, published for a + /// repository, whose endpoint reports `health` and ends the stand-in when + /// it is asked to stop. Counts every request that is not a read. + #[cfg(unix)] + struct LegacyDaemonFixture { + root: tempfile::TempDir, + pid: u32, + identity: ProcessIdentity, + writes: std::sync::Arc, + _server: tokio::task::JoinHandle<()>, + child: std::process::Child, + } + + #[cfg(unix)] + impl LegacyDaemonFixture { + async fn start(version: &str, dirty: bool) -> Self { + let mut child = std::process::Command::new("/bin/cat") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + let pid = child.id(); + let identity = process_identity(pid).unwrap().unwrap(); + let input = std::sync::Arc::new(std::sync::Mutex::new(child.stdin.take())); + let writes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let health = serde_json::json!({ + "version": version, + "pid": pid, + "build": {"sha": "b".repeat(40), "dirty": dirty, "source_known": true, "built_at": "then"}, + }); + let retire_writes = writes.clone(); + let shutdown_writes = writes.clone(); + let app = axum::Router::new() + .route( + "/health", + axum::routing::get(move || { + let health = health.clone(); + async move { axum::Json(health) } + }), + ) + .route( + "/retire", + axum::routing::post(move |body: String| { + let writes = retire_writes.clone(); + async move { + writes.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if body.contains("\"replacement\"") { + (axum::http::StatusCode::UNPROCESSABLE_ENTITY, String::new()) + } else { + ( + axum::http::StatusCode::ACCEPTED, + r#"{"retiring":true,"blocked_by":["a client session is attached"]}"# + .to_string(), + ) + } + } + }), + ) + .route( + "/shutdown", + axum::routing::post(move || { + let writes = shutdown_writes.clone(); + let input = input.clone(); + async move { + writes.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + drop(input.lock().unwrap().take()); + axum::http::StatusCode::ACCEPTED + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let root = tempfile::tempdir().unwrap(); + std::fs::write( + repo_daemon_owner_path(root.path()), + serde_json::to_vec(&EndpointOwnerRecord::for_identity(identity.clone())).unwrap(), + ) + .unwrap(); + std::fs::write(root.path().join("daemon.pid"), pid.to_string()).unwrap(); + std::fs::write(root.path().join("daemon.port"), port.to_string()).unwrap(); + Self { + root, + pid, + identity, + writes, + _server: server, + child, + } + } + + async fn existing(&self) -> ExistingDaemon { + tokio::time::timeout( + Duration::from_secs(10), + wait_for_existing_daemon_within( + self.root.path(), + Duration::from_millis(20), + Duration::from_secs(60), + None, + ), + ) + .await + .expect("a release decision must not wait out startup patience") + } + } + + #[cfg(unix)] + impl Drop for LegacyDaemonFixture { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } + + /// An older release's daemon is named superseded rather than refused, + /// replaced through its incarnation, and then the repository has no + /// daemon, which is where the spawn path starts this release's. A second + /// older daemon in the same command is refused by who started it. + #[cfg(unix)] + #[tokio::test] + async fn an_older_daemon_is_replaced_and_then_the_spawn_path_runs() { + let _client = managed_client_0_8_7(); + let daemon = LegacyDaemonFixture::start("0.8.6", false).await; + let ExistingDaemon::Superseded(pid) = daemon.existing().await else { + panic!("an older release must be named superseded"); + }; + assert_eq!(pid, daemon.pid); + + let mut replaced = None; + replace_superseded_daemon_once(daemon.root.path(), pid, &mut replaced) + .await + .expect("the older daemon must be replaced"); + assert_eq!(replaced, Some(pid)); + assert!( + !process_identity_is_current(&daemon.identity).unwrap(), + "the older daemon must be gone" + ); + assert!( + matches!(daemon.existing().await, ExistingDaemon::None), + "with the older daemon replaced, nothing serves the repository and the spawn path runs" + ); + + let AutoStartError::SpawnFailed(message) = + replace_superseded_daemon_once(daemon.root.path(), 424_242, &mut replaced) + .await + .unwrap_err() + else { + panic!("a second older daemon must be refused"); + }; + assert!( + message.contains(&format!("pid {pid}")) && message.contains("started by"), + "{message}" + ); + } + + /// A newer release and a development build are refused by pid and path, + /// and never asked to retire or stop. + #[cfg(unix)] + #[tokio::test] + async fn a_newer_or_development_daemon_is_refused_and_never_asked_to_go() { + let _client = managed_client_0_8_7(); + for (version, dirty, named) in [ + ("0.9.0", false, "Kin 0.9.0"), + ("0.8.6", true, "uncommitted source changes"), + ] { + let daemon = LegacyDaemonFixture::start(version, dirty).await; + let ExistingDaemon::LiveNotReady(message) = daemon.existing().await else { + panic!("{version}: the daemon must be refused"); + }; + assert!( + message.contains(&format!("pid {}", daemon.pid)) && message.contains("/bin/cat"), + "{message}" + ); + assert!(message.contains(named), "{message}"); + assert_eq!( + daemon.writes.load(std::sync::atomic::Ordering::SeqCst), + 0, + "{version}: nothing may be asked of a daemon that is not replaced" + ); + assert!(process_identity_is_current(&daemon.identity).unwrap()); + } + } + + async fn write_fixture_json_response( + stream: &mut tokio::net::TcpStream, + body: &str, + ) -> std::io::Result<()> { + use tokio::io::AsyncWriteExt; + + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + } + + async fn spawn_fixed_response_server_at( + layout: &kin_core::layout::KinLayout, + route: &'static str, + status: axum::http::StatusCode, + body: impl Into, + ) -> ( + String, + std::sync::Arc, + tokio::task::JoinHandle<()>, + ) { + use axum::{routing::post, Router}; + + let body = body.into(); + let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let handler_requests = requests.clone(); + let app = Router::new().route( + route, + post(move || { + let requests = handler_requests.clone(); + let body = body.clone(); + async move { + requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + (status, body) + } + }), + ); + let app = with_repository_runtime_identity(app, "daemon", layout); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{address}"), requests, server) + } + + async fn spawn_fixed_response_server( + layout: &kin_core::layout::KinLayout, + status: axum::http::StatusCode, + body: &'static str, + ) -> ( + String, + std::sync::Arc, + tokio::task::JoinHandle<()>, + ) { + spawn_fixed_response_server_at(layout, "/commands/test", status, body).await + } + + async fn read_complete_http_request( + stream: &mut tokio::net::TcpStream, + ) -> std::io::Result> { + use tokio::io::AsyncReadExt; + + let mut request = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, "connection closed before a complete HTTP request arrived", )); } @@ -10561,16 +12436,19 @@ mod tests { }) .unwrap_or(0); if request.len() >= header_end + content_length { - return Ok(()); + return Ok(request); } } } - async fn spawn_dropped_ack_server() -> ( + async fn spawn_dropped_ack_server( + layout: &kin_core::layout::KinLayout, + ) -> ( String, std::sync::Arc, tokio::task::JoinHandle<()>, ) { + let runtime_identity = repository_runtime_identity_body("daemon", layout); let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let server_requests = requests.clone(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -10580,8 +12458,12 @@ mod tests { let Ok((mut stream, _)) = listener.accept().await else { return; }; - if read_complete_http_request(&mut stream).await.is_ok() { - server_requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Ok(request) = read_complete_http_request(&mut stream).await { + if request.starts_with(b"GET /runtime/identity ") { + let _ = write_fixture_json_response(&mut stream, &runtime_identity).await; + } else { + server_requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } } drop(stream); } @@ -10589,13 +12471,16 @@ mod tests { (format!("http://{address}"), requests, server) } - async fn spawn_truncated_ack_server() -> ( + async fn spawn_truncated_ack_server( + layout: &kin_core::layout::KinLayout, + ) -> ( String, std::sync::Arc, tokio::task::JoinHandle<()>, ) { use tokio::io::AsyncWriteExt; + let runtime_identity = repository_runtime_identity_body("daemon", layout); let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let server_requests = requests.clone(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -10605,7 +12490,11 @@ mod tests { let Ok((mut stream, _)) = listener.accept().await else { return; }; - if read_complete_http_request(&mut stream).await.is_ok() { + if let Ok(request) = read_complete_http_request(&mut stream).await { + if request.starts_with(b"GET /runtime/identity ") { + let _ = write_fixture_json_response(&mut stream, &runtime_identity).await; + continue; + } server_requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let _ = stream .write_all( @@ -10620,7 +12509,9 @@ mod tests { (format!("http://{address}"), requests, server) } - async fn spawn_redirect_server() -> ( + async fn spawn_redirect_server( + layout: &kin_core::layout::KinLayout, + ) -> ( String, std::sync::Arc, tokio::task::JoinHandle<()>, @@ -10654,6 +12545,7 @@ mod tests { } }), ); + let app = with_repository_runtime_identity(app, "daemon", layout); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -10692,6 +12584,10 @@ mod tests { #[tokio::test] async fn merge_requires_a_corroborated_acknowledgement_and_dispatches_once() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); let operation_id = stable_test_operation_id(); let other_operation_id = OperationId::from_uuid(Uuid::from_u128(0xfedcba09_8765_4321_fedc_ba0987654321)); @@ -10714,11 +12610,14 @@ mod tests { ]; for (case, body, detail) in cases { - let (base_url, requests, server) = - spawn_fixed_response_server_at("/commands/merge", axum::http::StatusCode::OK, body) - .await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + let (base_url, requests, server) = spawn_fixed_response_server_at( + &repository.layout, + "/commands/merge", + axum::http::StatusCode::OK, + body, + ) + .await; + let client = repository.client(base_url, None, None); let request = crate::commands::merge::MergeRequest { source: kin_model::RefName::branch(b"source").unwrap(), operation_id, @@ -10746,6 +12645,10 @@ mod tests { #[tokio::test] async fn resolve_requires_a_corroborated_acknowledgement_and_dispatches_once() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); let operation_id = stable_test_operation_id(); let other_operation_id = OperationId::from_uuid(Uuid::from_u128(0xfedcba09_8765_4321_fedc_ba0987654321)); @@ -10769,13 +12672,13 @@ mod tests { for (case, body, detail) in cases { let (base_url, requests, server) = spawn_fixed_response_server_at( + &repository.layout, "/commands/resolve", axum::http::StatusCode::OK, body, ) .await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + let client = repository.client(base_url, None, None); let request = crate::commands::resolve::ResolveRequest { operation_id, actor: kin_model::AuthorId::new("ack-test"), @@ -10804,10 +12707,13 @@ mod tests { #[tokio::test] async fn non_idempotent_post_serialization_failure_is_direct_and_never_dispatched() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); let (base_url, requests, server) = - spawn_fixed_response_server(axum::http::StatusCode::OK, "{}").await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + spawn_fixed_response_server(&repository.layout, axum::http::StatusCode::OK, "{}").await; + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -10833,9 +12739,12 @@ mod tests { #[tokio::test] async fn non_idempotent_post_dropped_ack_is_indeterminate_and_dispatched_once() { - let (base_url, requests, server) = spawn_dropped_ack_server().await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let (base_url, requests, server) = spawn_dropped_ack_server(&repository.layout).await; + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -10855,9 +12764,12 @@ mod tests { #[tokio::test] async fn non_idempotent_post_truncated_body_is_indeterminate_and_dispatched_once() { - let (base_url, requests, server) = spawn_truncated_ack_server().await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let (base_url, requests, server) = spawn_truncated_ack_server(&repository.layout).await; + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -10878,11 +12790,17 @@ mod tests { #[tokio::test] async fn non_idempotent_post_server_error_is_indeterminate_and_dispatched_once() { - let (base_url, requests, server) = - spawn_fixed_response_server(axum::http::StatusCode::SERVICE_UNAVAILABLE, "try later") - .await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let (base_url, requests, server) = spawn_fixed_response_server( + &repository.layout, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "try later", + ) + .await; + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -10903,10 +12821,14 @@ mod tests { #[tokio::test] async fn non_idempotent_post_malformed_success_is_indeterminate_and_dispatched_once() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); let (base_url, requests, server) = - spawn_fixed_response_server(axum::http::StatusCode::OK, "not-json").await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + spawn_fixed_response_server(&repository.layout, axum::http::StatusCode::OK, "not-json") + .await; + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -10927,11 +12849,17 @@ mod tests { #[tokio::test] async fn non_idempotent_post_client_error_is_indeterminate_and_dispatched_once() { - let (base_url, requests, server) = - spawn_fixed_response_server(axum::http::StatusCode::CONFLICT, "stale merge record") - .await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let (base_url, requests, server) = spawn_fixed_response_server( + &repository.layout, + axum::http::StatusCode::CONFLICT, + "stale merge record", + ) + .await; + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -10977,18 +12905,22 @@ mod tests { /// it is, once, with the daemon's reason carried through. #[tokio::test] async fn a_marked_client_error_is_a_refusal_that_wrote_nothing_and_dispatched_once() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); let body = crate::daemon_error::DaemonErrorBody::before_write( "merging refs/heads/pretty into refs/heads/main still has 27 unresolved conflict(s)", ) .to_wire(); let (base_url, requests, server) = spawn_fixed_response_server_at( + &repository.layout, "/commands/test", axum::http::StatusCode::CONFLICT, body, ) .await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -11018,16 +12950,20 @@ mod tests { /// one real safety property. #[tokio::test] async fn a_marked_server_error_is_still_indeterminate() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); let body = crate::daemon_error::DaemonErrorBody::before_write("nothing was committed").to_wire(); let (base_url, requests, server) = spawn_fixed_response_server_at( + &repository.layout, "/commands/test", axum::http::StatusCode::SERVICE_UNAVAILABLE, body, ) .await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -11051,13 +12987,17 @@ mod tests { /// a new CLI must not read silence as a promise. #[tokio::test] async fn an_unmarked_client_error_body_is_still_indeterminate() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); let (base_url, requests, server) = spawn_fixed_response_server( + &repository.layout, axum::http::StatusCode::CONFLICT, "repository projection conflict: tracked working-copy path differs", ) .await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -11082,13 +13022,16 @@ mod tests { /// green while breaking the other. #[tokio::test] async fn a_client_error_marked_false_is_still_indeterminate() { - let (base_url, _requests, server) = spawn_fixed_response_server( + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let (base_url, _requests, server) = spawn_fixed_response_server(&repository.layout, axum::http::StatusCode::BAD_REQUEST, r#"{"message":"no recorded merge conflict matches summarize","refused_before_write":false}"#, ) .await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -11107,11 +13050,17 @@ mod tests { #[tokio::test] async fn non_idempotent_post_bad_request_is_indeterminate_and_dispatched_once() { - let (base_url, requests, server) = - spawn_fixed_response_server(axum::http::StatusCode::BAD_REQUEST, "finalization failed") - .await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let (base_url, requests, server) = spawn_fixed_response_server( + &repository.layout, + axum::http::StatusCode::BAD_REQUEST, + "finalization failed", + ) + .await; + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -11132,9 +13081,12 @@ mod tests { #[tokio::test] async fn non_idempotent_post_does_not_redispatch_through_redirects() { - let (base_url, requests, server) = spawn_redirect_server().await; - let client = - DaemonClient::from_base_url_with_explicit_authority(base_url, None, None).unwrap(); + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); + let (base_url, requests, server) = spawn_redirect_server(&repository.layout).await; + let client = repository.client(base_url, None, None); let operation_id = stable_test_operation_id(); let error = client @@ -11173,6 +13125,10 @@ mod tests { #[tokio::test] async fn idempotent_post_retries_exact_body_with_same_session_authority() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repository = RequestRepositoryFixture::new(); use axum::{body::Bytes, http::StatusCode, routing::post, Router}; #[derive(Default)] @@ -11205,17 +13161,17 @@ mod tests { } }), ); + let app = with_repository_runtime_identity(app, "daemon", &repository.layout); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - let client = DaemonClient::from_base_url_with_explicit_authority( + let client = repository.client( format!("http://{address}"), Some("checkout-token".to_string()), Some("session-checkout"), - ) - .unwrap(); + ); let request = serde_json::json!({ "operation_id": "stable-operation", "path_hex": "7372632f6c69622e7273" @@ -11826,6 +13782,7 @@ mod tests { ) -> (u16, tokio::task::JoinHandle<()>) { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let runtime_identity = runtime_identity_body("daemon"); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let server = tokio::spawn(async move { @@ -11838,7 +13795,9 @@ mod tests { continue; }; let request = String::from_utf8_lossy(&buf[..read]).to_string(); - if request.contains("/readiness") { + if request.starts_with("GET /runtime/identity ") { + let _ = write_fixture_json_response(&mut socket, &runtime_identity).await; + } else if request.contains("/readiness") { let body = format!(r#"{{"ready":true,"warming":{warming}}}"#); let _ = socket .write_all( @@ -12266,6 +14225,58 @@ mod tests { assert!(matches!(no_budget, Reading::Unresolved { .. })); } + #[tokio::test] + async fn endpoint_route_observer_uses_the_chosen_route_and_preserves_repository_identity() { + use kin_daemon_spawn::RunningDaemonRouteReading as Reading; + if !kin_core::test_env::alone_in_process() { + return; + } + let home = tempfile::tempdir().unwrap(); + let mine = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let root = mine.path().join(".kin"); + std::fs::create_dir(&root).unwrap(); + let own_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let own_url = format!("http://{}", own_listener.local_addr().unwrap()); + let other_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let other_url = format!("http://{}", other_listener.local_addr().unwrap()); + let _env = kin_core::test_env::EnvVarGuard::set("KIN_HOME", home.path()) + .with("KIN_RUNTIME_MODE", "development") + .with("KIN_DAEMON_URL", &other_url); + kin_core::runtime_identity::RuntimeNamespace::initialize_local_service().unwrap(); + let own_server = serve_repo_daemon_health(own_listener, mine.path()); + let other_server = serve_repo_daemon_health(other_listener, other.path()); + + let own_reading = kin_daemon_spawn::DaemonSpawnRegistrar::reading_at( + &CliSpawnRegistrar, + root.clone(), + own_url.clone(), + Duration::from_millis(500), + ) + .await; + let other_reading = kin_daemon_spawn::DaemonSpawnRegistrar::reading_at( + &CliSpawnRegistrar, + root, + other_url, + Duration::from_millis(500), + ) + .await; + own_server.abort(); + other_server.abort(); + let _ = own_server.await; + let _ = other_server.await; + + assert_eq!( + own_reading, + Reading::Serving(own_url), + "the chosen endpoint must win over a stale configured route" + ); + assert!( + matches!(other_reading, Reading::Unresolved { .. }), + "an endpoint serving another repository must stay refused: {other_reading:?}" + ); + } + #[tokio::test] async fn typed_route_reading_does_not_certify_untrusted_or_wrong_repository_urls() { use kin_daemon_spawn::RunningDaemonRouteReading as Reading; @@ -12317,6 +14328,7 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let repo_root = strict_canonical_path(repo_root).unwrap(); + let runtime_identity = runtime_identity_body("daemon"); tokio::spawn(async move { loop { let Ok((mut socket, _)) = listener.accept().await else { @@ -12327,7 +14339,9 @@ mod tests { continue; }; let request = String::from_utf8_lossy(&buffer[..read]); - let body = if request.contains("/readiness") { + let body = if request.starts_with("GET /runtime/identity ") { + runtime_identity.clone() + } else if request.contains("/readiness") { r#"{"ready":true,"warming":false}"#.to_string() } else if request.contains("/health") { serde_json::json!({ @@ -13082,6 +15096,8 @@ mod tests { ); let current: DaemonCompatResponse = serde_json::from_value(serde_json::json!({ + "runtime_identity": kin_core::runtime_identity::RuntimeIdentity::current(), + "runtime_mode": kin_core::runtime_identity::RuntimeMode::current().unwrap(), "schema": "kin.daemon.compat.v2", "graph_snapshot_version": kin_db::GraphSnapshot::CURRENT_VERSION, "supervisor_startup_protocol": SUPERVISOR_STARTUP_PROTOCOL, @@ -13224,6 +15240,7 @@ mod tests { fn serve_supervisor_health(listener: tokio::net::TcpListener) -> tokio::task::JoinHandle<()> { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let runtime_identity = runtime_identity_body("supervisor"); tokio::spawn(async move { loop { let Ok((mut socket, _)) = listener.accept().await else { @@ -13233,10 +15250,14 @@ mod tests { let Ok(read) = socket.read(&mut buffer).await else { continue; }; - if !String::from_utf8_lossy(&buffer[..read]).contains("/health") { + let request = String::from_utf8_lossy(&buffer[..read]); + let body = if request.starts_with("GET /runtime/identity ") { + runtime_identity.as_str() + } else if request.contains("/health") { + r#"{"status":"ok"}"# + } else { continue; - } - let body = r#"{"status":"ok"}"#; + }; let response = format!( "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ connection: close\r\ncontent-length: {}\r\n\r\n{body}", diff --git a/crates/kin-cli/src/daemon_client/replacement.rs b/crates/kin-cli/src/daemon_client/replacement.rs new file mode 100644 index 000000000..05b77acfd --- /dev/null +++ b/crates/kin-cli/src/daemon_client/replacement.rs @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! What one Kin release says to a daemon of another release when it replaces +//! it. +//! +//! One Kin serves a repository at a time. A client of a newer release does not +//! work beside an older daemon: it asks that daemon to make way, waits for it +//! to go, and starts its own. This module is the wire contract both sides read: +//! the `/retire` body that carries a replacement, the stable codes a daemon +//! names what it is waiting on by, which of those a replacement waits for, and +//! the sentence a client of the replaced release is given. + +use super::ProcessIdentity; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// The release that replaces a daemon, as a person reads it, for example +/// `Kin 0.8.7 (0123abc)`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReplacementClaim { + pub successor: String, +} + +/// The body of `POST /retire`. +/// +/// A bare process incarnation is the request every daemon with `/retire` takes. +/// The envelope adds the replacement. A daemon that predates the envelope +/// cannot read it as an incarnation and answers 422, and the client then asks +/// the plain way. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RetirementRequest { + Replacement { + identity: ProcessIdentity, + replacement: ReplacementClaim, + }, + Plain(ProcessIdentity), +} + +impl RetirementRequest { + /// The incarnation the caller attributed, and the replacement when there + /// is one. + pub fn into_parts(self) -> (ProcessIdentity, Option) { + match self { + Self::Replacement { + identity, + replacement, + } => (identity, Some(replacement)), + Self::Plain(identity) => (identity, None), + } + } +} + +/// The answer to `POST /retire`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetirementReply { + #[serde(default)] + pub retiring: bool, + /// What the daemon is still waiting on, as sentences. Every daemon with + /// `/retire` sends these. + #[serde(default)] + pub blocked_by: Vec, + /// The same, as codes from [`blocker`]. Absent from a daemon that predates + /// replacement, whose sentences [`legacy_blocker_code`] reads instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blockers: Option>, + /// Whether the daemon took the request as a replacement. + #[serde(default)] + pub replacing: bool, + /// The release it is making way for, when it is replacing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub successor: Option, +} + +/// The stable codes a daemon names what keeps it running by. +pub mod blocker { + pub const PREPARED_PUBLICATION: &str = "prepared_publication"; + pub const REQUEST_IN_FLIGHT: &str = "request_in_flight"; + pub const FIRST_SCAN: &str = "first_scan"; + pub const RECONCILIATION: &str = "reconciliation"; + pub const EMBEDDING: &str = "embedding"; + pub const EMBEDDING_HELD_FOR_MEMORY: &str = "embedding_held_for_memory"; + pub const ENRICHMENT: &str = "enrichment"; + pub const EVENT_SUBSCRIBER: &str = "event_subscriber"; + pub const ATTACHED_SESSION: &str = "attached_session"; + pub const SWEEP_WAITER: &str = "sweep_waiter"; +} + +/// How a replacement treats one thing a daemon is waiting on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplacementWait { + /// Work an interruption would lose rather than delay: a write or a request + /// in flight, a publication being prepared, and the first scan, which does + /// not resume. Always waited for. + Always, + /// A background pass a successor redoes: reconciliation, embedding and + /// language-server enrichment. Waited for up to + /// [`REPLACEMENT_BACKGROUND_GRACE`] from the replacement request. + Bounded, + /// A client of the release being replaced. It is told to restart and never + /// holds a replacement up. + Never, +} + +/// How a replacement treats the blocker named `code`. A code this build does +/// not know is something it cannot judge, so it is always waited for. +pub fn replacement_wait(code: &str) -> ReplacementWait { + match code { + blocker::RECONCILIATION + | blocker::EMBEDDING + | blocker::EMBEDDING_HELD_FOR_MEMORY + | blocker::ENRICHMENT => ReplacementWait::Bounded, + blocker::EVENT_SUBSCRIBER | blocker::ATTACHED_SESSION | blocker::SWEEP_WAITER => { + ReplacementWait::Never + } + _ => ReplacementWait::Always, + } +} + +/// How long a replacement waits for background passes before it proceeds. +/// +/// Short on purpose. The successor runs every one of these passes again from +/// what is on disk, and a replacement held for a whole embedding backlog is +/// two releases serving one repository for that long. +pub const REPLACEMENT_BACKGROUND_GRACE: Duration = Duration::from_secs(15); + +/// The code a replacing daemon answers new requests with, under HTTP 503. +pub const DAEMON_REPLACING: &str = "daemon_replacing"; + +/// What a client of the replaced release is told. +pub fn replaced_message(successor: &str) -> String { + format!("Kin was replaced by {successor}; restart this client") +} + +/// The body of a replacing daemon's 503. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReplacingRefusal { + pub error: String, + pub code: String, + pub successor: String, +} + +impl ReplacingRefusal { + pub fn new(successor: &str) -> Self { + Self { + error: replaced_message(successor), + code: DAEMON_REPLACING.to_string(), + successor: successor.to_string(), + } + } +} + +/// Longest successor name a daemon keeps, in characters. +pub const SUCCESSOR_LABEL_MAX_CHARS: usize = 120; + +/// A successor name fit to repeat to every client: control characters +/// dropped, bounded in length, and never empty. +pub fn bounded_successor_label(raw: &str) -> String { + let label: String = raw + .chars() + .filter(|character| !character.is_control()) + .take(SUCCESSOR_LABEL_MAX_CHARS) + .collect(); + let label = label.trim(); + if label.is_empty() { + "a newer Kin".to_string() + } else { + label.to_string() + } +} + +/// The code for a sentence a daemon from 0.8.0 to 0.8.6 sent in `blocked_by`, +/// before daemons sent codes, or `None` for a sentence this build does not +/// know, which a replacement treats as something to wait for. +/// +/// Frozen copies of what those releases said, byte for byte, and never the +/// current daemon's own sentences: those may change, and these must keep +/// reading the releases already installed. +pub fn legacy_blocker_code(sentence: &str) -> Option<&'static str> { + Some(match sentence { + "a publication is being prepared" => blocker::PREPARED_PUBLICATION, + "a request is in flight" => blocker::REQUEST_IN_FLIGHT, + "the first scan of the repository is still running" => blocker::FIRST_SCAN, + "reconciliation is running" => blocker::RECONCILIATION, + "embedding is running or queued" => blocker::EMBEDDING, + "embedding is owed and waiting for memory to free, for a bounded window" => { + blocker::EMBEDDING_HELD_FOR_MEMORY + } + "language-server enrichment is running" => blocker::ENRICHMENT, + "a client is subscribed to its events" => blocker::EVENT_SUBSCRIBER, + "a client session is attached" => blocker::ATTACHED_SESSION, + "a command is waiting to read a language-server sweep's result" => blocker::SWEEP_WAITER, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_incarnation_and_the_envelope_both_read_and_the_envelope_is_no_incarnation() { + let identity = super::super::current_process_identity().unwrap(); + let plain: RetirementRequest = + serde_json::from_value(serde_json::to_value(&identity).unwrap()).unwrap(); + assert_eq!(plain.into_parts(), (identity.clone(), None)); + + let envelope = serde_json::to_value(RetirementRequest::Replacement { + identity: identity.clone(), + replacement: ReplacementClaim { + successor: "Kin 9.9.9 (0123abc)".to_string(), + }, + }) + .unwrap(); + let read: RetirementRequest = serde_json::from_value(envelope.clone()).unwrap(); + assert_eq!( + read.into_parts(), + ( + identity, + Some(ReplacementClaim { + successor: "Kin 9.9.9 (0123abc)".to_string() + }) + ) + ); + // A daemon that predates the envelope reads its body as a bare + // incarnation. It must refuse it rather than read a default one. + assert!(serde_json::from_value::(envelope).is_err()); + } + + #[test] + fn writes_and_the_first_scan_hold_a_replacement_and_clients_never_do() { + for code in [ + blocker::PREPARED_PUBLICATION, + blocker::REQUEST_IN_FLIGHT, + blocker::FIRST_SCAN, + "a_code_from_a_later_release", + ] { + assert_eq!(replacement_wait(code), ReplacementWait::Always, "{code}"); + } + for code in [ + blocker::RECONCILIATION, + blocker::EMBEDDING, + blocker::EMBEDDING_HELD_FOR_MEMORY, + blocker::ENRICHMENT, + ] { + assert_eq!(replacement_wait(code), ReplacementWait::Bounded, "{code}"); + } + for code in [ + blocker::EVENT_SUBSCRIBER, + blocker::ATTACHED_SESSION, + blocker::SWEEP_WAITER, + ] { + assert_eq!(replacement_wait(code), ReplacementWait::Never, "{code}"); + } + } + + #[test] + fn a_legacy_sentence_reads_as_its_code_and_an_unknown_one_reads_as_none() { + assert_eq!( + legacy_blocker_code("a client session is attached"), + Some(blocker::ATTACHED_SESSION) + ); + assert_eq!( + legacy_blocker_code("a command is waiting to read a language-server sweep's result"), + Some(blocker::SWEEP_WAITER) + ); + assert_eq!(legacy_blocker_code("a client session is attached."), None); + assert_eq!(legacy_blocker_code(""), None); + } + + #[test] + fn a_successor_label_is_bounded_and_never_empty() { + assert_eq!(bounded_successor_label("Kin 0.8.7\n\u{7}"), "Kin 0.8.7"); + assert_eq!(bounded_successor_label(" \r\n"), "a newer Kin"); + assert_eq!( + bounded_successor_label(&"x".repeat(1000)).chars().count(), + SUCCESSOR_LABEL_MAX_CHARS + ); + assert_eq!( + ReplacingRefusal::new("Kin 0.8.7").error, + "Kin was replaced by Kin 0.8.7; restart this client" + ); + } +} diff --git a/crates/kin-cli/src/daemon_client/succession.rs b/crates/kin-cli/src/daemon_client/succession.rs new file mode 100644 index 000000000..bb10e1223 --- /dev/null +++ b/crates/kin-cli/src/daemon_client/succession.rs @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Which release a running daemon runs, read for the one-Kin-per-machine rule. +//! +//! The rule itself is [`kin_core::runtime_identity::succession`], a pure +//! function of the client, the installed pair and the daemon. This module +//! reads the daemon: `/runtime/identity` when it serves one, otherwise what +//! `/health` says, which every daemon since embedded build provenance sends, +//! and where its executable lives. + +use super::*; +use kin_core::runtime_identity::{ + PeerLocation, PeerRelease, PeerReport, RuntimeIdentity, RuntimeMode, RuntimeNamespace, + RuntimeServiceIdentity, Succession, SuccessionClient, +}; + +/// The parts of a `/health` answer that name a release. +#[derive(Debug, Deserialize)] +struct HealthRelease { + #[serde(default)] + version: Option, + #[serde(default)] + pid: Option, + #[serde(default)] + build: Option, +} + +#[derive(Debug, Deserialize)] +struct HealthBuild { + #[serde(default)] + sha: Option, + #[serde(default)] + dirty: bool, + #[serde(default)] + source_known: Option, +} + +/// Why an executable's location makes it a development build, if it does. +/// +/// Every installed release, managed, npm, Homebrew or cargo install, keeps +/// `kin-daemon` in a `bin` directory, and cargo never builds into one. +pub fn build_directory(executable: &Path) -> Option<&'static str> { + if let Some(root) = std::env::var_os("KIN_DEV_TARGET_ROOT").filter(|root| !root.is_empty()) { + if executable.starts_with(Path::new(&root)) { + return Some("it runs from the development build root"); + } + } + if executable + .components() + .any(|component| component.as_os_str() == "target") + { + return Some("it runs from a cargo target directory"); + } + let installed = executable + .parent() + .and_then(Path::file_name) + .is_some_and(|directory| directory == "bin"); + (!installed).then_some("it runs from a build directory, not an installed release") +} + +/// Whether `executable` is the managed install's own `bin`, or an update's +/// backup of it, under `managed_home`: an older generation of the managed +/// install, whatever version it reports. +pub fn runs_from_managed_generation(executable: &Path, managed_home: &Path) -> bool { + // A replaced daemon's executable may no longer exist, so a path that does + // not resolve is resolved through its directory. + let canonical = |path: &Path| { + path.canonicalize() + .or_else(|error| match (path.parent(), path.file_name()) { + (Some(parent), Some(name)) => parent.canonicalize().map(|parent| parent.join(name)), + _ => Err(error), + }) + .unwrap_or_else(|_| path.to_path_buf()) + }; + let home = canonical(managed_home); + let executable = canonical(executable); + let Ok(within) = executable.strip_prefix(&home) else { + return false; + }; + let mut components = within.components(); + match components + .next() + .map(|first| first.as_os_str().to_string_lossy()) + { + Some(first) if first == "bin" => components.clone().count() == 1, + Some(first) => first.starts_with(".update-backup-"), + None => false, + } +} + +/// Where the daemon at `executable` lives, as far as replacing it goes. +pub fn classify_peer_location( + executable: Option<&Path>, + managed_home: Option<&Path>, +) -> PeerLocation { + let Some(executable) = executable else { + return PeerLocation::Unknown; + }; + if managed_home.is_some_and(|home| runs_from_managed_generation(executable, home)) { + return PeerLocation::ManagedGeneration; + } + match build_directory(executable) { + Some(why) => PeerLocation::BuildDirectory(why.to_string()), + None => PeerLocation::Installed, + } +} + +/// Read the release of the daemon serving `base_url`, which must answer as +/// `pid`, within `budget`. +pub async fn read_peer_release( + base_url: &str, + pid: u32, + managed_home: Option<&Path>, + budget: Duration, +) -> std::result::Result { + let executable = process_executable_path(pid).map(PathBuf::from); + let location = classify_peer_location(executable.as_deref(), managed_home); + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(budget.clamp(Duration::from_millis(100), Duration::from_secs(5))) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("could not build the release probe: {error}"))?; + let base_url = base_url.trim_end_matches('/'); + let get = |path: &'static str| { + let client = client.clone(); + let url = format!("{base_url}{path}"); + async move { + let response = client + .get(url) + .send() + .await + .map_err(|error| format!("its {path} did not answer: {error}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|error| format!("its {path} answer did not complete: {error}"))?; + Ok::<_, String>((status, body)) + } + }; + let (status, body) = get("/runtime/identity").await?; + let report = if status.is_success() { + let identity: RuntimeServiceIdentity = serde_json::from_slice(&body) + .map_err(|error| format!("its runtime identity is unreadable: {error}"))?; + if identity.pid != pid { + return Err(format!( + "its endpoint answers for pid {}, not {pid}", + identity.pid + )); + } + PeerReport::Identified { + identity: identity.identity, + mode: identity.mode, + namespace: identity.namespace, + } + } else if status == reqwest::StatusCode::NOT_FOUND { + let (status, body) = get("/health").await?; + if !status.is_success() { + return Err(format!("its /health answered HTTP {status}")); + } + let health: HealthRelease = serde_json::from_slice(&body) + .map_err(|error| format!("its /health answer is unreadable: {error}"))?; + if let Some(other) = health.pid.filter(|other| *other != pid) { + return Err(format!("its endpoint answers for pid {other}, not {pid}")); + } + let (commit, dirty, source_known) = match health.build { + Some(build) => (build.sha, build.dirty, build.source_known), + None => (None, false, None), + }; + PeerReport::Legacy { + version: health.version, + commit, + dirty, + source_known, + } + } else { + return Err(format!("its /runtime/identity answered HTTP {status}")); + }; + Ok(PeerRelease { + pid, + executable, + location, + report, + }) +} + +/// The managed pair installed on disk, read once per process and only when a +/// client first meets a daemon of another release. `None` when this process +/// is not a managed client or the pair cannot be read. +pub fn installed_runtime() -> Option { + #[cfg(test)] + if let Some((_, installed)) = CLIENT_FOR_TEST.with(|cell| cell.borrow().clone()) { + return installed; + } + static INSTALLED: std::sync::OnceLock> = std::sync::OnceLock::new(); + INSTALLED + .get_or_init(|| { + if RuntimeMode::current().ok() != Some(RuntimeMode::Managed) { + return None; + } + let status = crate::commands::runtime::inspect( + &crate::commands::runtime::StatusOptions::default(), + ); + status.pair_matches.then(|| status.cli.identity()).flatten() + }) + .clone() +} + +/// This process as a client deciding about a daemon of another release. +pub fn current_succession_client() -> std::result::Result { + #[cfg(test)] + if let Some((client, _)) = CLIENT_FOR_TEST.with(|cell| cell.borrow().clone()) { + return Ok(client); + } + Ok(SuccessionClient { + identity: RuntimeIdentity::current(), + mode: RuntimeMode::current()?, + namespace: RuntimeNamespace::current() + .ok() + .map(|namespace| namespace.token().to_owned()), + }) +} + +/// What this process does about the daemon serving `base_url` as `pid`, which +/// refused it as another release. +pub async fn judge_running_daemon(base_url: &str, pid: u32, budget: Duration) -> Succession { + let client = match current_succession_client() { + Ok(client) => client, + Err(error) => return Succession::Refuse(error), + }; + let managed_home = crate::commands::runtime::managed_home().ok(); + match read_peer_release(base_url, pid, managed_home.as_deref(), budget).await { + Ok(peer) => { + kin_core::runtime_identity::succession(&client, installed_runtime().as_ref(), &peer) + } + Err(error) => Succession::Refuse(format!( + "Kin daemon pid {pid} ({}) was left running: its release could not be read: {error}", + process_executable_path(pid).unwrap_or_else(|| "executable unknown".to_string()) + )), + } +} + +#[cfg(test)] +type ClientForTest = Option<(SuccessionClient, Option)>; + +#[cfg(test)] +thread_local! { + /// The client a test stands in for this process. Test builds are always + /// development clients, and only a managed client replaces anything. + static CLIENT_FOR_TEST: std::cell::RefCell = + const { std::cell::RefCell::new(None) }; +} + +/// Stand in a managed client, and the installed pair, for this test's thread +/// until the guard drops. +#[cfg(all(test, unix))] +pub(crate) fn stand_in_client_for_test( + client: SuccessionClient, + installed: Option, +) -> impl Drop { + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + CLIENT_FOR_TEST.with(|cell| cell.borrow_mut().take()); + } + } + CLIENT_FOR_TEST.with(|cell| *cell.borrow_mut() = Some((client, installed))); + Restore +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_build_directory_is_told_apart_from_every_installed_release() { + for executable in [ + "/Volumes/KinBuild/targets/some-branch/debug/kin-daemon", + "/work/kin/target/release/kin-daemon", + "/work/kin/target/aarch64-apple-darwin/release/kin-daemon", + ] { + assert!( + build_directory(Path::new(executable)).is_some(), + "{executable}" + ); + } + for executable in [ + "/home/someone/.kin/bin/kin-daemon", + "/opt/homebrew/Cellar/kin/0.8.6/bin/kin-daemon", + "/home/someone/.cargo/bin/kin-daemon", + ] { + assert_eq!(build_directory(Path::new(executable)), None, "{executable}"); + } + } + + #[test] + fn the_managed_install_and_its_update_backup_are_its_own_generations() { + let home = tempfile::tempdir().unwrap(); + let bin = home.path().join("bin"); + let backup = home.path().join(".update-backup-1234").join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir_all(&backup).unwrap(); + for executable in [bin.join("kin-daemon"), backup.join("kin-daemon")] { + assert_eq!( + classify_peer_location(Some(&executable), Some(home.path())), + PeerLocation::ManagedGeneration, + "{}", + executable.display() + ); + } + let elsewhere = Path::new("/opt/homebrew/bin/kin-daemon"); + assert_eq!( + classify_peer_location(Some(elsewhere), Some(home.path())), + PeerLocation::Installed + ); + assert_eq!( + classify_peer_location(None, Some(home.path())), + PeerLocation::Unknown + ); + assert!(!runs_from_managed_generation( + &home.path().join("bin").join("nested").join("kin-daemon"), + home.path() + )); + } +} diff --git a/crates/kin-cli/src/git_update.rs b/crates/kin-cli/src/git_update.rs new file mode 100644 index 000000000..f831df66a --- /dev/null +++ b/crates/kin-cli/src/git_update.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +pub use kin_core::git_update::{GitUpdateRequest, GitUpdateStatus}; +use kin_model::{OperationId, SemanticChangeId}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GitUpdateResponse { + pub operation_id: OperationId, + pub previous_head: SemanticChangeId, + pub head: SemanticChangeId, + pub admitted_changes: usize, + pub authority_generation: u64, + pub workspace_follow: crate::commands::transfer::WorkspaceFollow, + pub derived_views: crate::commands::transfer::DerivedViewRefresh, +} + +#[derive(Debug)] +pub enum GitUpdateDispatch { + Answered(Box), + Refused { status: u16, body: String }, + Lost(anyhow::Error), +} diff --git a/crates/kin-cli/src/lib.rs b/crates/kin-cli/src/lib.rs index 16baf7a52..4e1a4df57 100644 --- a/crates/kin-cli/src/lib.rs +++ b/crates/kin-cli/src/lib.rs @@ -18,6 +18,7 @@ pub mod embed_model; pub mod entity_identity; pub mod entity_ref; pub mod first_run; +pub mod git_update; pub mod mark; pub mod model_residency; pub mod model_slots; diff --git a/crates/kin-cli/src/main.rs b/crates/kin-cli/src/main.rs index 2703ed4b4..729ef42d9 100644 --- a/crates/kin-cli/src/main.rs +++ b/crates/kin-cli/src/main.rs @@ -4,7 +4,7 @@ #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{self, Shell}; use kin_cli::commands; @@ -171,6 +171,7 @@ const COMMAND_GROUPS: &[(&str, &[&str])] = &[ "telemetry", "resources", "daemon", + "runtime", "registry", "update", "upgrade", @@ -323,6 +324,11 @@ struct Cli { #[derive(Subcommand)] enum Command { + /// Inspect the one admitted CLI and daemon runtime pair + Runtime { + #[command(subcommand)] + action: RuntimeAction, + }, /// Show which Git commands Kin replaces, and how ready each one is Capabilities { /// Output the versioned capability inventory as JSON @@ -448,6 +454,10 @@ enum Command { /// Suppress progress output (only print final summary) #[arg(short, long)] quiet: bool, + /// Commit only this entity's uncommitted change, leaving every other + /// change uncommitted (repeat for more entities) + #[arg(long = "entity", value_name = "ENTITY_ID", conflicts_with = "amend")] + entities: Vec, }, /// Show the immutable repository-v6 change log Log { @@ -1359,6 +1369,12 @@ enum Command { /// Change ID (defaults to current branch head) #[arg(long)] change: Option, + /// Restore one entity's source, as an uncommitted edit, instead of a path + #[arg(long, value_name = "ENTITY_ID", conflicts_with_all = ["path", "path_hex"])] + entity: Option, + /// Refuse unless the entity is exactly as last read: source: from a review, or absent + #[arg(long, value_name = "FINGERPRINT", requires = "entity")] + expected: Option, }, /// Verify test coverage for entities Verify { @@ -1409,6 +1425,14 @@ enum Command { /// Include repo-specific prepared-state cache manifest fields #[arg(long, default_value_t = false, hide = true)] prepared_state: bool, + /// Static executable identity, without host or repository configuration + #[arg( + long, + hide = true, + requires = "json", + conflicts_with = "prepared_state" + )] + runtime_identity_only: bool, }, /// Hidden prepared-state publish/materialize surfaces for benchmark/runtime orchestration #[command(hide = true)] @@ -1555,6 +1579,8 @@ enum Command { /// Print the daemon's agents payload as JSON #[arg(long)] json: bool, + #[command(subcommand)] + action: Option, }, /// Manage assistant adapters Assistant { @@ -1818,6 +1844,51 @@ enum Command { /// own activity gates. #[arg(long, requires = "unattended")] force_window: bool, + /// Prepare a verified out-of-process Windows update worker + #[arg(long, hide = true, conflicts_with_all = [ + "skip_verify", "channel", "expect_version", "expect_sha", "expect_archive_sha256", + "check_only", "ack_restart", "runtime_sessions", "set_policy", "apply", "dry_run", + "unattended", "force_window", "windows_worker" + ])] + prepare_windows_worker: bool, + /// Verify the native updater's exact Windows handoff before replacement + #[arg(long, hide = true, value_name = "RECORD", conflicts_with_all = [ + "prepare_windows_worker", "skip_verify", "check_only", "json", "ack_restart", "runtime_sessions", + "set_policy", "unattended", "force_window", "dry_run" + ])] + windows_worker: Option, + /// Remove an authenticated, completed Windows update worker handoff + #[arg(long, hide = true, value_name = "RECORD", conflicts_with_all = [ + "skip_verify", "channel", "expect_version", "expect_sha", "expect_archive_sha256", + "check_only", "json", "ack_restart", "runtime_sessions", "set_policy", "apply", "dry_run", + "unattended", "force_window", "prepare_windows_worker", "windows_worker", + "bootstrap_published", "expect_target", "expect_archive_name" + ])] + cleanup_windows_worker: Option, + /// Admit an independently verified published archive into the managed install + #[arg(long, hide = true, requires_all = [ + "channel", "expect_version", "expect_sha", "expect_archive_sha256", + "expect_target", "expect_archive_name" + ], conflicts_with_all = [ + "skip_verify", "check_only", "json", "ack_restart", "runtime_sessions", + "set_policy", "dry_run", "unattended", "force_window", + "prepare_windows_worker", "windows_worker" + ])] + bootstrap_published: bool, + #[arg( + long, + hide = true, + requires = "bootstrap_published", + value_name = "TARGET" + )] + expect_target: Option, + #[arg( + long, + hide = true, + requires = "bootstrap_published", + value_name = "ARCHIVE" + )] + expect_archive_name: Option, }, /// Show or manage the global Kin repository registry Registry { @@ -1834,6 +1905,19 @@ enum Command { /// Apply safe automatic repairs (shell hook, MCP configs, config dirs) #[arg(long, default_value_t = false)] fix: bool, + /// With --fix: also remove every other copy of Kin that has its own + /// uninstall + /// + /// Runs each copy's own channel (npm, cargo, Homebrew), or deletes a + /// standalone release file. Never removes a development build or + /// anything in the managed Kin home. `--fix` alone never removes a + /// copy, because `kin update` runs it unattended. + #[arg( + long = "remove-other-copies", + requires = "fix", + default_value_t = false + )] + remove_other_copies: bool, /// Install the missing language servers this repository's languages need /// /// Only for the languages the repository uses. An install can write @@ -2073,6 +2157,21 @@ enum Command { }, } +#[derive(Subcommand)] +enum RuntimeAction { + /// Report exact executable identities without opening a repository + Status { + #[arg(long)] + json: bool, + /// Inspect an isolated development pair instead of the managed install + #[arg(long)] + development: bool, + /// Development CLI whose adjacent daemon should be inspected + #[arg(long, requires = "development", value_name = "PATH")] + binary: Option, + }, +} + #[derive(Subcommand)] enum HostedPublicationAction { /// Build the reserved repository's durable authority and report what landed @@ -2809,6 +2908,21 @@ enum ReviewAction { }, } +#[derive(Subcommand)] +enum AgentsAction { + /// Answer an agent client's edit hook: read the hook's JSON on stdin and + /// tell this repository's daemon which session made the edit + #[command(name = "record-edit", hide = true)] + RecordEdit { + /// The client whose hook this is + #[arg(long, value_parser = commands::agent_edits::EDIT_HOOK_CLIENTS)] + client: String, + /// Wait for the daemon and print what became of the report + #[arg(long)] + wait: bool, + }, +} + #[derive(Subcommand)] enum TrafficAction { /// Show active traffic on a scope @@ -3272,6 +3386,19 @@ enum SetupAction { /// Apply safe automatic repairs (shell hook, MCP configs, config dirs) #[arg(long, default_value_t = false)] fix: bool, + /// With --fix: also remove every other copy of Kin that has its own + /// uninstall + /// + /// Runs each copy's own channel (npm, cargo, Homebrew), or deletes a + /// standalone release file. Never removes a development build or + /// anything in the managed Kin home. `--fix` alone never removes a + /// copy, because `kin update` runs it unattended. + #[arg( + long = "remove-other-copies", + requires = "fix", + default_value_t = false + )] + remove_other_copies: bool, /// Emit the machine-readable health report as JSON #[arg(long, default_value_t = false)] json: bool, @@ -3847,6 +3974,70 @@ fn main() { } fn run() -> Result<()> { + if is_top_level_display_flag() { + let _ = parse_cli_or_report_retired_command(); + } + let startup_args: Vec = std::env::args_os() + .skip(1) + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + if typed_command_path(&startup_args).first() == Some(&"runtime") { + let cli = parse_cli_or_report_retired_command(); + if let Command::Runtime { + action: + RuntimeAction::Status { + json, + development, + binary, + }, + } = cli.command + { + let code = commands::runtime::run( + json, + &commands::runtime::StatusOptions { + development, + binary, + }, + )?; + if code != 0 { + std::process::exit(code); + } + return Ok(()); + } + } + if typed_command_path(&startup_args).first() == Some(&"bench-meta") + && startup_args + .iter() + .any(|arg| arg == "--runtime-identity-only") + { + let cli = parse_cli_or_report_retired_command(); + if let Command::BenchMeta { + json: true, + prepared_state: false, + runtime_identity_only: true, + } = cli.command + { + return commands::runtime::run_identity_probe(); + } + } + if typed_command_path(&startup_args).as_slice() == ["registry", "authority"] { + let cli = parse_cli_or_report_retired_command(); + if let Command::Registry { + action: + Some(RegistryAction::Authority { + json, + fix: false, + initialize: false, + }), + } = cli.command + { + return tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(commands::registry::authority(json, false, false)); + } + } + let runtime_mode = commands::runtime::prepare_environment()?; // Raise this process's open-file soft limit before anything opens a file. // macOS ships that limit at 256, which is below what one admission needs, // so on a stock Mac `kin init` died on the first real repository a person @@ -3871,10 +4062,6 @@ fn run() -> Result<()> { // retrieval or resource settings into every repository daemon it opens. // Consult the same parser metadata used for leading command paths, so a // global option value named `ui` is not mistaken for the command. - let startup_args: Vec = std::env::args_os() - .skip(1) - .map(|arg| arg.to_string_lossy().into_owned()) - .collect(); let workspace_ui_requested = startup_args.iter().any(|arg| arg == "ui") && typed_command_path(&startup_args).first() == Some(&"ui"); // ...then let the repository's recorded profile take over from that default, @@ -3899,6 +4086,91 @@ fn run() -> Result<()> { } kin_buildinfo::retain_update_build_identity(&KIN_UPDATE_BUILD_IDENTITY); let cli = parse_cli_or_report_retired_command(); + let setup_read_only = matches!( + &cli.command, + Command::Setup { + action: None, + check: true, + .. + } | Command::Setup { + action: Some(SetupAction::Status { .. }), + .. + } | Command::Setup { + action: Some(SetupAction::Clients { .. }), + .. + } | Command::Setup { + action: Some(SetupAction::Ledger { .. }), + .. + } | Command::Setup { + action: Some(SetupAction::Doctor { fix: false, .. }), + install_language_servers: false, + .. + } | Command::Setup { + action: Some(SetupAction::Uninstall { dry_run: true, .. }), + .. + } + ); + let update_read_only = matches!( + &cli.command, + Command::Update { + check_only: true, + cleanup_windows_worker: None, + .. + } | Command::Update { + apply: true, + dry_run: true, + cleanup_windows_worker: None, + .. + } + ); + if runtime_mode == kin_core::runtime_identity::RuntimeMode::Development + && matches!(&cli.command, Command::Update { .. }) + && !update_read_only + { + anyhow::bail!( + "development mode cannot mutate the managed installation; run kin update from the admitted managed runtime" + ); + } + if runtime_mode == kin_core::runtime_identity::RuntimeMode::Development + && ((matches!(&cli.command, Command::Setup { .. }) && !setup_read_only) + || matches!( + &cli.command, + Command::Doctor { fix: true, .. } + | Command::Doctor { + reclaim_staging: true, + .. + } + )) + { + anyhow::bail!("development mode cannot run setup or doctor repairs against global clients and shell configuration; use the admitted managed runtime"); + } + let runtime_exempt = setup_read_only + || matches!( + &cli.command, + Command::Runtime { .. } + | Command::BenchMeta { .. } + | Command::Registry { + action: Some(RegistryAction::Authority { .. }), + } + | Command::Daemon { + action: DaemonAction::Status { .. } | DaemonAction::Stop { .. }, + } + | Command::Update { .. } + | Command::Capabilities { .. } + | Command::Describe { .. } + | Command::Languages { .. } + | Command::Completions { .. } + | Command::SemanticOnlyGuard + | Command::Doctor { + fix: false, + heal: false, + reclaim_staging: false, + .. + } + ); + if let Some(code) = commands::runtime::route_startup(runtime_mode, runtime_exempt)? { + std::process::exit(code); + } if !is_top_level_display_flag() && !matches!(&cli.command, Command::Ui { .. }) { let model_repository = match &cli.command { Command::Mcp { @@ -3994,7 +4266,7 @@ fn run() -> Result<()> { // A durable MCP marker is an active repair obligation. Ordinary commands // retry it; the update command retries while holding the install lock. - if !matches!(&cli.command, Command::Update { .. }) { + if !runtime_exempt && !matches!(&cli.command, Command::Update { .. }) { match commands::update::retry_pending_mcp_repair_from_managed_process() { Ok(_) => {} Err(error) => eprintln!("kin: MCP repair remains pending: {error:#}"), @@ -4014,6 +4286,9 @@ fn run() -> Result<()> { let result = runtime.block_on( (async move { match cli.command { + Command::Runtime { .. } => { + unreachable!("runtime status returned before repository startup") + } Command::Capabilities { json, verbose } => { commands::capabilities::run(json, verbose) } @@ -4082,9 +4357,10 @@ fn run() -> Result<()> { message, quiet, amend, + entities, } => { commands::capabilities::require_ready("commit")?; - commands::commit::run(message, quiet, amend).await + commands::commit::run(message, quiet, amend, entities).await } Command::Log { count, json } => commands::log::run(count, json).await, Command::Branch { action } => match action { @@ -5110,9 +5386,16 @@ fn run() -> Result<()> { path, path_hex, change, + entity, + expected, } => { commands::capabilities::require_ready("checkout")?; - commands::checkout::run(path, path_hex, change).await + match entity { + Some(entity) => { + commands::entity_work::run_restore(entity, change, expected).await + } + None => commands::checkout::run(path, path_hex, change).await, + } } Command::Verify { action } => match action { VerifyAction::Entity { entity } => commands::verify::run(entity).await, @@ -5151,6 +5434,7 @@ fn run() -> Result<()> { Command::BenchMeta { json, prepared_state, + runtime_identity_only: _, } => commands::bench_meta::run(json, prepared_state).await, Command::PreparedState { action } => match action { PreparedStateAction::Publish { target, json } => { @@ -5359,7 +5643,20 @@ fn run() -> Result<()> { TrafficAction::Show { scope } => commands::traffic::run(scope).await, TrafficAction::Sessions => commands::traffic::sessions().await, }, - Command::Agents { json } => commands::agents::run(json).await, + Command::Agents { + action: Some(AgentsAction::RecordEdit { client, wait }), + .. + } => { + let mut raw = String::new(); + let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut raw); + if let Some(printed) = + commands::agent_edits::record_edit(&client, &raw, wait).await + { + println!("{printed}"); + } + Ok(()) + } + Command::Agents { json, action: None } => commands::agents::run(json).await, Command::Assistant { action } => match action { AssistantAction::Install { assistant } => { commands::assistant::install(assistant).await @@ -5507,7 +5804,32 @@ fn run() -> Result<()> { dry_run, unattended, force_window, + prepare_windows_worker, + windows_worker, + cleanup_windows_worker, + bootstrap_published, + expect_target, + expect_archive_name, } => { + if let Some(record) = cleanup_windows_worker { + return commands::update::cleanup_windows_update_worker(&record); + } + if bootstrap_published { + return commands::update::run_bootstrap_published( + channel.context("published bootstrap requires an explicit channel")?, + expect_version.context("published bootstrap requires a version")?, + expect_sha + .context("published bootstrap requires an exact source commit")?, + expect_archive_sha256 + .context("published bootstrap requires an archive digest")?, + expect_target + .context("published bootstrap requires an exact build target")?, + expect_archive_name + .context("published bootstrap requires an exact archive name")?, + apply, + ) + .await; + } commands::update::run( skip_verify, channel, @@ -5523,6 +5845,8 @@ fn run() -> Result<()> { dry_run, unattended, force_window, + prepare_windows_worker, + windows_worker, ) .await } @@ -5557,6 +5881,7 @@ fn run() -> Result<()> { }, Command::Doctor { fix, + remove_other_copies, install_language_servers, json, verbose, @@ -5588,8 +5913,14 @@ fn run() -> Result<()> { // for. commands::health::reclaim_stranded_stages(json) } else { - commands::setup::doctor_with(fix, install_language_servers, json, verbose) - .await + commands::setup::doctor_with( + fix, + install_language_servers, + json, + verbose, + remove_other_copies, + ) + .await } } Command::Vfs { action } => match action { @@ -5679,8 +6010,19 @@ fn run() -> Result<()> { Some(SetupAction::Status { json, verbose }) => { commands::setup::status(json, verbose).await } - Some(SetupAction::Doctor { fix, json }) => { - commands::setup::doctor(fix, install_language_servers, json).await + Some(SetupAction::Doctor { + fix, + remove_other_copies, + json, + }) => { + commands::setup::doctor_with( + fix, + install_language_servers, + json, + false, + remove_other_copies, + ) + .await } Some(SetupAction::Ledger { json }) => commands::setup::ledger_status(json), Some(SetupAction::Uninstall { @@ -8278,6 +8620,42 @@ mod tests { }); } + /// `--entity` repeats, never rides with an amend, and makes a checkout + /// restore one entity instead of a path. + #[test] + fn commit_and_checkout_take_entities() { + on_cli_test_stack(|| { + let cli = Cli::try_parse_from([ + "kin", "commit", "-m", "keep two", "--entity", "a", "--entity", "b", + ]) + .unwrap(); + assert!( + matches!(&cli.command, Command::Commit { entities, amend: false, .. } if entities == &["a", "b"]) + ); + assert!( + Cli::try_parse_from(["kin", "commit", "--amend", "--entity", "a"]).is_err(), + "an amend replaces a whole change and chooses no entities" + ); + let cli = Cli::try_parse_from([ + "kin", + "checkout", + "--entity", + "a", + "--change", + "c", + "--expected", + "f", + ]) + .unwrap(); + assert!(matches!( + &cli.command, + Command::Checkout { entity: Some(entity), expected: Some(_), path: None, .. } if entity == "a" + )); + assert!(Cli::try_parse_from(["kin", "checkout", "src", "--entity", "a"]).is_err()); + assert!(Cli::try_parse_from(["kin", "checkout", "--expected", "f"]).is_err()); + }); + } + /// A stranger forms the write-through assumption at help, not at commit. /// /// The npm0549 green stranger read `kin commit --help`, took `kin commit` diff --git a/crates/kin-cli/src/provenance.rs b/crates/kin-cli/src/provenance.rs index 1159055a7..c2c25b089 100644 --- a/crates/kin-cli/src/provenance.rs +++ b/crates/kin-cli/src/provenance.rs @@ -27,6 +27,7 @@ where identifier: label, url: None, }], + agent_session: None, }; graph .create_actor(&actor) @@ -90,6 +91,7 @@ where identifier: label.to_string(), url: None, }], + agent_session: None, }) } else { None diff --git a/crates/kin-cli/tests/app_cli_surfaces.rs b/crates/kin-cli/tests/app_cli_surfaces.rs index 177f9d508..477eab160 100644 --- a/crates/kin-cli/tests/app_cli_surfaces.rs +++ b/crates/kin-cli/tests/app_cli_surfaces.rs @@ -3,8 +3,8 @@ //! The machine surfaces a program drives Kin through, run as the real binary //! in an isolated home: `kin update --check-only --json` answers with one JSON -//! object even when there is nothing to check, and `kin setup --shell-only` -//! adds kin to the shell and connects no AI client. +//! object even when there is nothing to check. Development shell-only setup +//! refuses all global writes; release acceptance grades its Managed behavior. use std::path::{Path, PathBuf}; use std::process::Output; @@ -30,7 +30,8 @@ fn kin(home: &Path, cwd: &Path, args: &[&str]) -> Output { .current_dir(cwd) .env("HOME", home) .env("USERPROFILE", home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", home.join("kin-home")) + .env("KIN_RUNTIME_MODE", "development") .env("KIN_VFS_DISABLE", "1") .env("KIN_NO_DAEMON", "1") .env("KIN_REGISTRY_PATH", home.join("registry.toml")) @@ -60,7 +61,7 @@ fn one_json_object(output: &Output) -> serde_json::Value { #[test] fn update_check_only_json_reports_a_missing_install_root_as_json() { let (_root, home, cwd) = fixture(); - let kin_home = home.join(".kin"); + let kin_home = home.join("kin-home"); assert!(!kin_home.exists()); let output = kin(&home, &cwd, &["update", "--check-only", "--json"]); @@ -87,96 +88,63 @@ fn update_check_only_json_reports_a_missing_install_root_as_json() { ); } -#[test] -fn setup_shell_only_adds_kin_to_the_shell_and_connects_no_client() { - let (_root, home, cwd) = fixture(); - let kin_home = home.join(".kin"); - // The layout that puts kin's bin directory on PATH. - std::fs::create_dir_all(kin_home.join("bin")).expect("create the managed bin directory"); - // Claude Code's own install evidence, so setup detects a client it would - // otherwise connect. - let claude_config = b"{\"mcpServers\": {}}\n"; - std::fs::write(home.join(".claude.json"), claude_config).expect("seed Claude Code config"); - - let output = kin( - &home, - &cwd, - &["setup", "--shell-only", "--shell", "zsh", "--json"], - ); - let report = one_json_object(&output); - assert!(output.status.success(), "{report}"); - assert_eq!(report["schema"], "kin.setup-shell.v1"); - assert_eq!(report["shell"], "zsh"); - assert_eq!(report["status"], "installed"); - assert_eq!(report["clients_connected"], serde_json::json!([])); - assert!(report["reason"].is_null(), "{report}"); - assert_eq!(report["add_by_hand"], serde_json::json!([])); - - let zshrc = std::fs::read_to_string(home.join(".zshrc")).expect("read .zshrc"); - assert!(zshrc.contains("kin-vfs"), "the hook is sourced:\n{zshrc}"); - let zshenv = std::fs::read_to_string(home.join(".zshenv")).expect("read .zshenv"); +fn assert_development_shell_refusal(output: &Output, home: &Path) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - zshenv.contains(&kin_home.join("bin").display().to_string()), - "kin's bin directory is on PATH:\n{zshenv}" + !output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" ); - - // No AI client was touched. - assert_eq!( - std::fs::read(home.join(".claude.json")).expect("read Claude Code config"), - claude_config, - "the client's config must be left exactly as it was" + assert!(stderr.contains("development mode cannot"), "{stderr}"); + assert!( + stdout.trim().is_empty(), + "refused setup must not report an install: {stdout}" ); - for client_config in [ - ".cursor/mcp.json", - ".codex/config.toml", - ".gemini/settings.json", - ".codeium/windsurf/mcp_config.json", - ".lmstudio/mcp.json", - ".grok/config.toml", + for untouched in [ + "kin-home", + ".kin", + ".zshenv", + ".cursor", + ".codex", + ".gemini", + ".codeium", + ".lmstudio", + ".grok", + "registry.toml", ] { - assert!( - !home.join(client_config).exists(), - "--shell-only wrote {client_config}" - ); + assert!(!home.join(untouched).exists(), "setup wrote {untouched}"); } +} - // Recorded like any setup's shell step, so doctor and uninstall find it. - let ledger = std::fs::read_to_string(kin_home.join("config").join("setup-ledger.json")) - .expect("read the install ledger"); - assert!(ledger.contains("zsh"), "{ledger}"); - - // Again, for a person: the same step, already in place, said in words. - let again = kin(&home, &cwd, &["setup", "--shell-only", "--shell", "zsh"]); - let stdout = String::from_utf8_lossy(&again.stdout); - assert!(again.status.success(), "{stdout}"); - assert!(stdout.contains("No AI client was connected"), "{stdout}"); - assert!(stdout.contains("Open a new terminal"), "{stdout}"); - assert_eq!( - std::fs::read_to_string(home.join(".zshrc")).expect("reread .zshrc"), - zshrc, - "a second run must not add the hook twice" - ); +#[test] +fn development_shell_only_setup_writes_neither_profiles_nor_client_entries() { + let (_root, home, cwd) = fixture(); + let original = b"{\"mcpServers\": {}}\n"; + std::fs::write(home.join(".claude.json"), original).unwrap(); + for args in [ + vec!["setup", "--shell-only", "--shell", "zsh", "--json"], + vec!["setup", "--shell-only", "--shell", "zsh"], + ] { + let output = kin(&home, &cwd, &args); + assert_development_shell_refusal(&output, &home); + assert!(!home.join(".zshrc").exists()); + assert_eq!(std::fs::read(home.join(".claude.json")).unwrap(), original); + } } #[cfg(unix)] #[test] -fn setup_shell_only_json_names_what_to_add_by_hand_when_the_profile_refuses() { +fn development_shell_only_setup_does_not_attempt_to_repair_a_blocked_profile() { let (_root, home, cwd) = fixture(); - // A directory where the profile should be, so no write can land. - std::fs::create_dir_all(home.join(".zshrc")).expect("block .zshrc"); - + let profile = home.join(".zshrc"); + std::fs::create_dir(&profile).unwrap(); let output = kin( &home, &cwd, &["setup", "--shell-only", "--shell", "zsh", "--json"], ); - let report = one_json_object(&output); - assert_eq!(output.status.code(), Some(1), "{report}"); - assert_eq!(report["schema"], "kin.setup-shell.v1"); - assert_eq!(report["status"], "not_written"); - assert!(report["reason"] - .as_str() - .is_some_and(|reason| !reason.is_empty())); - assert_eq!(report["clients_connected"], serde_json::json!([])); - assert!(report["next"].is_null(), "{report}"); + assert_development_shell_refusal(&output, &home); + assert!(profile.is_dir()); + assert_eq!(std::fs::read_dir(&profile).unwrap().count(), 0); + assert!(!home.join(".claude.json").exists()); } diff --git a/crates/kin-cli/tests/common/support.rs b/crates/kin-cli/tests/common/support.rs index 15f857bcd..a546f5707 100644 --- a/crates/kin-cli/tests/common/support.rs +++ b/crates/kin-cli/tests/common/support.rs @@ -937,6 +937,7 @@ pub struct IsolatedDaemonRuntime { repository: PathBuf, registry_path: PathBuf, home_path: PathBuf, + kin_home_path: PathBuf, owner_token: String, owned_children: Mutex>>>, containment: RuntimeContainment, @@ -952,6 +953,9 @@ impl IsolatedDaemonRuntime { .tempdir() .unwrap_or_else(|error| panic!("create isolated runtime root: {error}")); let home_path = runtime_root.path().join("home"); + let kin_home_path = runtime_root.path().join("kin-home"); + std::fs::create_dir_all(&kin_home_path) + .unwrap_or_else(|error| panic!("create isolated Kin home: {error}")); let owner_token = uuid::Uuid::new_v4().to_string(); let containment = RuntimeContainment::new(runtime_root.path(), &owner_token) .unwrap_or_else(|error| panic!("create isolated runtime containment: {error}")); @@ -959,6 +963,7 @@ impl IsolatedDaemonRuntime { repository: repository.to_path_buf(), registry_path: runtime_root.path().join("registry.toml"), home_path, + kin_home_path, owner_token, owned_children: Mutex::new(Vec::new()), containment, @@ -986,6 +991,7 @@ impl IsolatedDaemonRuntime { } pub fn kin_command(&self) -> Command<'_> { + let _ = self.daemon_bin(); self.command(env!("CARGO_BIN_EXE_kin")) } @@ -1055,11 +1061,10 @@ impl IsolatedDaemonRuntime { scrub_inherited_kin_authority(command); command .env("KIN_REGISTRY_PATH", &self.registry_path) - // Bind the managed install and store root explicitly. HOME and - // USERPROFILE remain a fail-closed fallback for any child boundary - // that intentionally rebuilds its environment instead of - // inheriting the override. - .env("KIN_HOME", self.home_path.join(".kin")) + // Development binaries use an explicitly isolated state root. + // HOME and USERPROFILE still describe the user configuration home; + // their ordinary `.kin` directory is not this runtime's namespace. + .env("KIN_HOME", &self.kin_home_path) .env("HOME", &self.home_path) .env("USERPROFILE", &self.home_path) .env("XDG_CONFIG_HOME", self.home_path.join(".config")) @@ -1227,7 +1232,7 @@ impl IsolatedDaemonRuntime { .envs(cleanup.env.iter().map(|(key, value)| (key, value))); command } else { - let mut command = self.kin_command(); + let mut command = self.command(env!("CARGO_BIN_EXE_kin")); command.args(["daemon", "stop", "--all"]); command } @@ -1439,6 +1444,9 @@ pub struct Command<'runtime> { impl Command<'static> { pub fn new>(program: S) -> Self { let program = program.as_ref(); + if program == OsStr::new(env!("CARGO_BIN_EXE_kin")) { + let _ = ensure_sibling_daemon_built(); + } let mut inner = std::process::Command::new(program); if is_git_program(program) { inner @@ -1842,6 +1850,10 @@ fn is_allowed_runtime_override(key: &OsStr) -> bool { // per-command configuration rather than repository or session // authority, so carrying it costs the isolation boundary nothing. "KIN_DAEMON_AUTO_EMBED", + // Holds a development daemon to the managed admission contract, so a + // test can prove a command sends the runtime claims a release daemon + // refuses requests without. It can only make admission stricter. + "KIN_DAEMON_REQUIRE_RUNTIME_IDENTITY", // The runtime's three seconds are a default for daemons its Drop has to // stop. A test about what a daemon finishes inside its shutdown budget // needs to set that budget, and it waits for its daemon to end before @@ -3191,11 +3203,11 @@ pub fn daemon_compat_mismatch( } pub fn daemon_compat(runtime: &IsolatedDaemonRuntime, path: &Path) -> Result<(), String> { - daemon_compat_within(runtime, path, COMMAND_TIMEOUT) + daemon_compat_within(Some(runtime), path, COMMAND_TIMEOUT) } fn daemon_compat_within( - runtime: &IsolatedDaemonRuntime, + runtime: Option<&IsolatedDaemonRuntime>, path: &Path, timeout: Duration, ) -> Result<(), String> { @@ -3217,7 +3229,10 @@ fn daemon_compat_within( if !path.exists() { return Err(format!("{} does not exist", path.display())); } - let mut command = runtime.command(path); + let mut command = match runtime { + Some(runtime) => runtime.command(path), + None => Command::new(path), + }; let output = command .arg("--compat-json") .output_within(timeout) @@ -3273,6 +3288,15 @@ fn daemon_rebuild_target<'a>(kin_bin: &Path, triple: &'a str) -> Option<&'a str> (profile_parent.file_name()?.to_str() == Some(triple)).then_some(triple) } +fn daemon_rebuild_target_dir<'a>(kin_bin: &'a Path, triple: &str) -> Option<&'a Path> { + let profile_parent = kin_bin.parent()?.parent()?; + if daemon_rebuild_target(kin_bin, triple).is_some() { + profile_parent.parent() + } else { + Some(profile_parent) + } +} + /// The profile arguments a `kin-daemon` rebuild has to carry to land beside /// `kin`. /// @@ -3295,12 +3319,16 @@ fn daemon_rebuild_profile(kin_bin: &Path) -> Vec { } } +pub fn ensure_sibling_daemon_built() -> PathBuf { + fresh_daemon_bin_with_deadline(None, None) +} + fn fresh_daemon_bin(runtime: &IsolatedDaemonRuntime) -> PathBuf { - fresh_daemon_bin_with_deadline(runtime, None) + fresh_daemon_bin_with_deadline(Some(runtime), None) } fn fresh_daemon_bin_before(runtime: &IsolatedDaemonRuntime, deadline: Instant) -> PathBuf { - fresh_daemon_bin_with_deadline(runtime, Some(deadline)) + fresh_daemon_bin_with_deadline(Some(runtime), Some(deadline)) } fn daemon_preparation_budget( @@ -3319,7 +3347,7 @@ fn daemon_preparation_budget( } fn fresh_daemon_bin_with_deadline( - runtime: &IsolatedDaemonRuntime, + runtime: Option<&IsolatedDaemonRuntime>, deadline: Option, ) -> PathBuf { let kin_bin = PathBuf::from(env!("CARGO_BIN_EXE_kin")); @@ -3349,8 +3377,13 @@ fn fresh_daemon_bin_with_deadline( // the stderr this call captures, so the wait is bounded here. let mut build = Command::new(env!("CARGO")); scrub_inherited_kin_authority(build.inner_mut()); + build.args(&args); + if let Some(target_dir) = + daemon_rebuild_target_dir(&kin_bin, env!("KIN_CLI_TARGET_TRIPLE")) + { + build.arg("--target-dir").arg(target_dir); + } let output = build - .args(&args) .current_dir(workspace_root) .output_within(daemon_preparation_budget( deadline, diff --git a/crates/kin-cli/tests/daemon_runtime_hermeticity.rs b/crates/kin-cli/tests/daemon_runtime_hermeticity.rs index f516182c8..6573efeed 100644 --- a/crates/kin-cli/tests/daemon_runtime_hermeticity.rs +++ b/crates/kin-cli/tests/daemon_runtime_hermeticity.rs @@ -636,6 +636,16 @@ fn isolated_runtime_scrubs_ambient_authority_and_reaps_every_process() { "constructing the isolated runtime created product control state before kin init" ); let isolated_command = runtime.kin_command(); + let supervisor_pid_path = { + let child_kin_home = isolated_command + .configured_env_for_test(std::ffi::OsStr::new("KIN_HOME")) + .flatten() + .expect("the fixture declares its existing Kin home"); + let _kin_home = EnvVarGuard::set("KIN_HOME", child_kin_home.as_os_str()); + let _registry = EnvVarGuard::set("KIN_REGISTRY_PATH", runtime.registry_path().as_os_str()); + let _mode = EnvVarGuard::set("KIN_RUNTIME_MODE", std::ffi::OsStr::new("development")); + kin_core::registry::supervisor_root().join("supervisor.pid") + }; for removed in [ "KIN_STORAGE", "KIN_GCS_BUCKET", @@ -762,11 +772,7 @@ fn isolated_runtime_scrubs_ambient_authority_and_reaps_every_process() { ); let worker_pid_path = kin_root.join("daemon.pid"); - let supervisor_pid_path = runtime - .registry_path() - .parent() - .expect("runtime registry parent") - .join("supervisor.pid"); + let worker_pid = recorded_pid(&worker_pid_path); let supervisor_pid = recorded_pid(&supervisor_pid_path); assert!( diff --git a/crates/kin-cli/tests/daemon_start_failure_remedy.rs b/crates/kin-cli/tests/daemon_start_failure_remedy.rs index bf2107696..a65f18620 100644 --- a/crates/kin-cli/tests/daemon_start_failure_remedy.rs +++ b/crates/kin-cli/tests/daemon_start_failure_remedy.rs @@ -115,15 +115,26 @@ fn a_daemon_that_cannot_start_still_names_a_remedy() { let kin_root = runtime.layout().root().to_path_buf(); - // Stop whatever init started, so the next command pays for a start rather - // than attaching to a live daemon and never reaching the code under test. - runtime + // Retire both the worker and supervisor so the next start receives this + // command's fault injection instead of a supervisor's earlier environment. + let stopped = runtime .kin_command() - .env("KIN_DAEMON_BIN", runtime.daemon_bin()) - .args(["daemon", "stop"]) + .args(["daemon", "stop", "--all"]) .current_dir(repo.path()) .output() - .expect("stop the daemon init started"); + .expect("stop the runtime init started"); + assert!( + stopped.status.success(), + "fixture runtime did not stop: status={} stdout={} stderr={}", + stopped.status, + String::from_utf8_lossy(&stopped.stdout), + String::from_utf8_lossy(&stopped.stderr) + ); + + // The admitted native pair remains authoritative. The supported gate holds + // its real daemon before readiness, so a bounded query cannot be served. + let startup_gate = repo.path().join("daemon-startup.gate"); + fs::write(&startup_gate, b"held").expect("hold the native daemon in startup"); // A serving record naming a dead pid is what `peek_unwatched_daemon_death` // grades, and it is the same shape the init-budget acceptance suite seeds @@ -137,11 +148,13 @@ fn a_daemon_that_cannot_start_still_names_a_remedy() { let output = runtime .kin_command() - .env("KIN_DAEMON_BIN", repo.path().join("no-such-kin-daemon")) + .env("KIN_DAEMON_TEST_STARTUP_GATE", &startup_gate) + .env("KIN_DAEMON_READY_TIMEOUT_SECS", "2") + .env("KIN_DAEMON_STARTUP_LOCK_TIMEOUT_SECS", "2") .args(["locate", "lexer"]) .current_dir(repo.path()) .output() - .expect("run a daemon-backed command with no daemon binary"); + .expect("run a command while its native daemon cannot become ready"); let stderr = String::from_utf8_lossy(&output.stderr); assert!( diff --git a/crates/kin-cli/tests/doctor_language_servers.rs b/crates/kin-cli/tests/doctor_language_servers.rs index d098f4ff6..9c88da6ff 100644 --- a/crates/kin-cli/tests/doctor_language_servers.rs +++ b/crates/kin-cli/tests/doctor_language_servers.rs @@ -12,6 +12,9 @@ //! //! Nothing below needs a daemon, a network or a repository. +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + use serde_json::Value; use tempfile::tempdir; @@ -19,15 +22,46 @@ mod common; use common::Command; -/// A `kin` invocation with its own `HOME`, so a `--fix` run repairs a temporary -/// directory rather than the machine running the suite. +/// A development invocation with isolated home and configuration roots. fn kin_command(home: &std::path::Path, cwd: &std::path::Path) -> Command<'static> { let mut command = Command::new(env!("CARGO_BIN_EXE_kin")); - command.env("HOME", home); + command + .env("HOME", home) + .env("USERPROFILE", home) + .env("APPDATA", home.join("AppData/Roaming")) + .env("LOCALAPPDATA", home.join("AppData/Local")) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("XDG_CACHE_HOME", home.join(".cache")) + .env("KIN_HOME", home.join("development-runtime")) + .env("KIN_RUNTIME_MODE", "development"); command.current_dir(cwd); command } +fn home_contents(home: &Path) -> BTreeMap>> { + let mut contents = BTreeMap::new(); + let mut directories = vec![home.to_path_buf()]; + while let Some(directory) = directories.pop() { + for entry in std::fs::read_dir(&directory).expect("read isolated home") { + let entry = entry.expect("read isolated home entry"); + let path = entry.path(); + let relative = path + .strip_prefix(home) + .expect("owned home path") + .to_path_buf(); + let kind = entry.file_type().expect("read isolated home entry type"); + if kind.is_dir() { + contents.insert(relative, None); + directories.push(path); + } else { + assert!(kind.is_file(), "unexpected isolated home entry: {path:?}"); + contents.insert(relative, Some(std::fs::read(path).expect("read fixture"))); + } + } + } + contents +} + /// A `HOME` and a working directory that is definitely not a Kin repository. fn scratch() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { let root = tempdir().expect("temp root"); @@ -52,6 +86,11 @@ fn the_flag_without_fix_says_what_it_needs_instead_of_exiting_quietly() { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + assert!( + output.status.success(), + "the read-only notice must run. status={} stderr={stderr} stdout={stdout}", + output.status + ); assert!( stderr.contains("Nothing installed."), "the no-op has to be stated. stderr={stderr} stdout={stdout}" @@ -103,8 +142,14 @@ fn the_notice_leaves_json_stdout_parseable() { let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let report: Value = serde_json::from_slice(&output.stdout) - .unwrap_or_else(|e| panic!("doctor --json stdout must parse: {e}. stdout={stdout}")); + assert!( + output.status.success(), + "the read-only JSON report must run. status={} stderr={stderr} stdout={stdout}", + output.status + ); + let report: Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { + panic!("doctor --json stdout must parse: {e}. stderr={stderr} stdout={stdout}") + }); assert!( report["checks"].is_array(), "the report is still the report. stdout={stdout}" @@ -115,32 +160,44 @@ fn the_notice_leaves_json_stdout_parseable() { ); } -/// Hole two, the half a stranger can reach without building a scratch repo. -/// Outside a repository the coverage row reads `Unsupported`, the gate stays -/// shut, and this command used to download nothing and print not one word. +/// Development builds must refuse global repairs before they write any +/// configuration. The language-server decision unit tests cover the managed +/// repair's outside-repository notice independently. #[test] -fn outside_a_repository_the_fix_run_says_where_to_run_it_instead() { +fn development_doctor_repairs_refuse_global_writes() { let (_root, home, cwd) = scratch(); - - let output = kin_command(&home, &cwd) - .args(["doctor", "--fix", "--install-language-servers"]) - .output() - .expect("run kin doctor --fix --install-language-servers"); - - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - assert!( - stderr.contains("not inside a Kin repository"), - "the reader has to be told which state they are in. stderr={stderr} stdout={stdout}" - ); - // The scoping nuance the stranger had to reverse-engineer with a scratch - // repository: the servers install per host, the gap is measured per repo. - assert!( - stderr.contains("per repository") && stderr.contains("per host"), - "both scopes have to be named or the reader guesses. stderr={stderr}" - ); - assert!( - stderr.contains("rust, python, typescript, javascript, go"), - "it has to name what it would have checked. stderr={stderr}" - ); + std::fs::write(home.join(".zshrc"), "# retain shell configuration\n") + .expect("write shell fixture"); + let client = home.join(".config/kin-client"); + std::fs::create_dir_all(&client).expect("create client fixture"); + std::fs::write(client.join("config.json"), "{\"retain\":true}\n") + .expect("write client fixture"); + let before = home_contents(&home); + + for args in [ + vec!["doctor", "--fix"], + vec!["doctor", "--fix", "--install-language-servers"], + ] { + let output = kin_command(&home, &cwd) + .args(&args) + .output() + .expect("run development doctor repair"); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + assert!( + !output.status.success(), + "development repair must refuse. args={args:?} stderr={stderr} stdout={stdout}" + ); + assert!( + stderr.contains("development mode cannot run setup or doctor repairs") + && stderr.contains("use the admitted managed runtime"), + "the refusal must identify the required runtime. args={args:?} stderr={stderr}" + ); + assert!(stdout.is_empty(), "repair must not start: {stdout}"); + assert_eq!( + home_contents(&home), + before, + "repair changed the owned home" + ); + } } diff --git a/crates/kin-cli/tests/fixtures/git-replacement-capabilities-v1.json b/crates/kin-cli/tests/fixtures/git-replacement-capabilities-v1.json index 30f0e0a15..ff03c1916 100644 --- a/crates/kin-cli/tests/fixtures/git-replacement-capabilities-v1.json +++ b/crates/kin-cli/tests/fixtures/git-replacement-capabilities-v1.json @@ -60,7 +60,8 @@ "preserve all unsupported-language and non-code artifacts, modes, symlinks, gitlinks, and byte-exact paths", "publish the target ref with generation and root-bundle compare-and-swap and no raw-filesystem rebuild", "explicit --amend replaces the exact selected head with full working state, retaining the original immutable change, author, message unless overridden, and every ordered parent", - "refuse stale amend selection before admission, unborn workspaces, open merges, and conflicting entity leases; detached amend advances only its workspace" + "refuse stale amend selection before admission, unborn workspaces, open merges, and conflicting entity leases; detached amend advances only its workspace", + "--entity records only the chosen entities' uncommitted changes in a change planned from the head, moves the workspace base without moving its tree or writing the working copy, proves every derived entity before publication, and refuses work it cannot separate exactly" ], "evidence_tests": [ "kin_daemon::repository_commit::tests::native_commit_atomically_projects_exact_non_code_tree_and_ref", @@ -79,9 +80,12 @@ "kin_daemon::repository_commit::tests::amend_refuses_unborn_stale_selection_and_stale_publication", "kin_daemon::api::tests::commit_amend_rejects_stale_head_before_admitting_pending_files", "kin_daemon::api::tests::commit_amend_cannot_drop_a_leased_entity_by_reverting_its_old_change", - "kin_daemon::api::tests::commit_amend_during_open_merge_refuses_before_admission" + "kin_daemon::api::tests::commit_amend_during_open_merge_refuses_before_admission", + "crates/kin-daemon/src/api/tests/entity_keep.rs::entity_keep_commits_only_the_chosen_entities_and_leaves_the_rest_exactly", + "crates/kin-daemon/src/api/tests/entity_keep.rs::entity_keep_keeps_one_member_of_a_container_and_new_content_whole", + "crates/kin-daemon/src/api/tests/entity_keep.rs::entity_keep_refuses_what_it_cannot_commit_and_records_nothing" ], - "note": "Commit consumes one complete host observation that carries the scanner's completion proof into publication, binds the observed manifest to the authority roots and prior workspace tree it was planned against, and refuses rather than replans when authority moved. An unconfirmed mass deletion refuses the whole observation, so no addition, modification, or derived admission-policy state published beside it. Explicit amend includes the full working state; path and hunk selection remain unavailable." + "note": "Commit consumes one complete host observation that carries the scanner's completion proof into publication, binds the observed manifest to the authority roots and prior workspace tree it was planned against, and refuses rather than replans when authority moved. An unconfirmed mass deletion refuses the whole observation, so no addition, modification, or derived admission-policy state published beside it. Explicit amend includes the full working state. Entity selection records only the chosen entities and leaves every other change uncommitted in an unwritten working copy; path and hunk selection remain unavailable." }, { "command": "log", @@ -281,7 +285,8 @@ "verify complete selected tree, semantic graph, and source CAS closure before one projection and authority transaction", "preserve every unsupported-language and non-code artifact, executable bit, symlink, gitlink, opaque binary, and host-unrepresentable byte path in graph authority while materializing only host-representable entries", "reject every session-scoped request before mutation and preserve independently dirty unselected paths", - "replay the exact caller-stable operation across same-daemon and restart crash gaps without advancing authority twice" + "replay the exact caller-stable operation across same-daemon and restart crash gaps without advancing authority twice", + "--entity restores one entity's source from the head or a selected change as an uncommitted edit through the exact workspace transition, keeps its identity, refuses a stale expected fingerprint, and replays the caller-stable operation without writing twice" ], "evidence_tests": [ "kin_daemon::api::tests::command_checkout_projects_universal_selected_tree_and_replays_exactly", @@ -292,7 +297,8 @@ "kin_daemon::api::tests::command_checkout_refuses_replaced_repository_namespace_without_mutation", "kin_daemon::api::tests::command_checkout_refuses_detached_repository_namespace_without_mutation", "kin_core::repository_authority::tests::retained_binding_rejects_identical_repository_namespace_replacement", - "kin_core::repository_authority::tests::retained_binding_rejects_detached_repository_namespace" + "kin_core::repository_authority::tests::retained_binding_rejects_detached_repository_namespace", + "crates/kin-daemon/src/api/tests/entity_keep.rs::entity_restore_undoes_one_entity_and_round_trips" ], "note": "Checkout binds only the repository and workspace identity this daemon pinned at startup, revalidates the retained per-repository storage namespace before planning, and answers a namespace replaced at the same ambient path, detached, or absent from the pinned store with a typed conflict instead of mismatched truth. Exact projection, universal artifact preservation, and caller-stable replay are proven across same-daemon and restart gaps. The retained-identity refusals are proven on Unix; the Windows file-identity path is implemented but not covered by CI." }, diff --git a/crates/kin-cli/tests/init_json.rs b/crates/kin-cli/tests/init_json.rs index 9eaf2189c..e8d0c06a3 100644 --- a/crates/kin-cli/tests/init_json.rs +++ b/crates/kin-cli/tests/init_json.rs @@ -44,9 +44,17 @@ fn seed_git_repo(path: &Path) { run_git(path, &["commit", "-m", "second"]); } +fn fixture_kin_home(home: &Path) -> std::path::PathBuf { + let kin_home = home.join("kin-home"); + fs::create_dir_all(&kin_home).expect("create isolated Kin home"); + kin_home +} + fn fixture_layout(repo: &Path, home: &Path) -> kin_core::KinLayout { let mut command = Command::new(env!("CARGO_BIN_EXE_kin")); - command.env("HOME", home).env("KIN_HOME", home.join(".kin")); + command + .env("HOME", home) + .env("KIN_HOME", fixture_kin_home(home)); common::repository_layout(command, repo) } @@ -56,7 +64,7 @@ fn kin_init(repo: &Path, home: &Path, extra: &[&str]) -> std::process::Output { .arg(repo) .args(extra) .env("HOME", home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", fixture_kin_home(home)) .output() .expect("run kin init") } @@ -65,7 +73,7 @@ fn kin_graph_materialize(repo: &Path, home: &Path) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_kin")) .args(["graph", "materialize", "--json"]) .env("HOME", home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", fixture_kin_home(home)) .env("KIN_EMBED_BACKEND", "cpu") .env("KIN_NO_DAEMON", "1") .env("KIN_SESSION_ID", uuid::Uuid::new_v4().to_string()) @@ -114,7 +122,7 @@ fn explicit_in_tree_init_keeps_the_store_out_of_git_status() { .args(["init"]) .arg(&repo) .env("HOME", &home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", fixture_kin_home(&home)) .env("KIN_STORE_LAYOUT", "in-tree") .output() .expect("initialize explicit in-tree store"); @@ -311,7 +319,7 @@ fn kin_in(repo: &Path, home: &Path, args: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_kin")) .args(args) .env("HOME", home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", fixture_kin_home(home)) .env("KIN_EMBED_BACKEND", "cpu") .env("KIN_NO_DAEMON", "1") .current_dir(repo) @@ -567,7 +575,13 @@ fn a_worked_in_git_repository_names_the_delta_in_human_output() { fs::write(repo.join("scratch.log"), "log\n").expect("untracked file"); let output = kin_init(&repo, &home, &[]); - assert!(output.status.success(), "init must succeed"); + assert!( + output.status.success(), + "init must succeed: status={} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); let printed = String::from_utf8_lossy(&output.stdout); assert!( printed.contains("Uncommitted changes: 1 path(s) differ"), @@ -619,7 +633,7 @@ fn init_and_status_report_the_same_admission_enrichment() { let status = Command::new(env!("CARGO_BIN_EXE_kin")) .args(["status", "--json"]) .env("HOME", &home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", fixture_kin_home(&home)) .env_remove("KIN_DAEMON_URL") .current_dir(&repo) .output() @@ -689,7 +703,13 @@ fn existing_repository_is_not_rebuilt_from_the_working_tree() { fs::create_dir_all(&home).expect("create home"); let first = kin_init(&repo, &home, &[]); - assert!(first.status.success()); + assert!( + first.status.success(), + "first init failed: status={} stdout={} stderr={}", + first.status, + String::from_utf8_lossy(&first.stdout), + String::from_utf8_lossy(&first.stderr) + ); let manifest_before = fs::read(fixture_layout(&repo, &home).manifest_path()).expect("read initial manifest"); @@ -699,7 +719,8 @@ fn existing_repository_is_not_rebuilt_from_the_working_tree() { let second = kin_init(&repo, &home, &[]); assert!( second.status.success(), - "stdout={} stderr={}", + "second init failed: status={} stdout={} stderr={}", + second.status, String::from_utf8_lossy(&second.stdout), String::from_utf8_lossy(&second.stderr) ); @@ -722,7 +743,13 @@ fn existing_repository_is_not_rebuilt_from_the_working_tree() { // `--json` callers parse one result shape, so they keep the refusal. let json = kin_init(&repo, &home, &["--json"]); - assert!(!json.status.success()); + assert!( + !json.status.success(), + "JSON rebuild unexpectedly succeeded: status={} stdout={} stderr={}", + json.status, + String::from_utf8_lossy(&json.stdout), + String::from_utf8_lossy(&json.stderr) + ); assert!(String::from_utf8_lossy(&json.stderr) .contains("never rebuilds graph authority from the working tree")); assert_eq!( @@ -740,7 +767,12 @@ fn pre_release_init_flags_are_not_accepted() { for flag in ["--git-history", "--no-lsp", "--force"] { let repo = root.path().join(flag.trim_start_matches('-')); let mut command = Command::new(env!("CARGO_BIN_EXE_kin")); - command.arg("init").arg(&repo).arg(flag).env("HOME", &home); + command + .arg("init") + .arg(&repo) + .arg(flag) + .env("HOME", &home) + .env("KIN_HOME", fixture_kin_home(&home)); if flag == "--git-history" { command.arg("full"); } @@ -775,7 +807,7 @@ fn non_git_existing_content_starts_unborn_and_is_reported_as_uncommitted_work() .arg(&repo) .arg("--json") .env("HOME", &home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", fixture_kin_home(&home)) .env("KIN_NO_DAEMON", "1") .output() .expect("run kin init"); @@ -863,7 +895,13 @@ fn git_init_states_the_store_size_and_its_ratio_to_the_git_object_store() { seed_git_repo(&repo); let output = kin_init(&repo, &home, &[]); - assert!(output.status.success(), "init failed"); + assert!( + output.status.success(), + "init failed: status={} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); let stdout = String::from_utf8_lossy(&output.stdout); assert!( @@ -979,7 +1017,13 @@ fn init_json_carries_the_measured_store_and_git_object_bytes() { seed_git_repo(&repo); let output = kin_init(&repo, &home, &["--json"]); - assert!(output.status.success(), "init --json failed"); + assert!( + output.status.success(), + "init --json failed: status={} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); let payload: Value = serde_json::from_slice(&output.stdout).expect("init stdout should be JSON"); @@ -1015,7 +1059,13 @@ fn native_init_reports_a_store_size_with_no_comparison() { fs::create_dir_all(&home).expect("create home"); let output = kin_init(&repo, &home, &[]); - assert!(output.status.success(), "native init failed"); + assert!( + output.status.success(), + "native init failed: status={} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("Store size:") && stdout.contains("no Git object store"), diff --git a/crates/kin-cli/tests/live_graph_durability_disclosure.rs b/crates/kin-cli/tests/live_graph_durability_disclosure.rs index 3f16b58dd..c7646887b 100644 --- a/crates/kin-cli/tests/live_graph_durability_disclosure.rs +++ b/crates/kin-cli/tests/live_graph_durability_disclosure.rs @@ -394,6 +394,11 @@ fn run_mcp_session( port: u16, ) -> Vec<(u64, serde_json::Value)> { let started = Instant::now(); + let kin_home = runtime + .kin_command() + .configured_env_for_test(std::ffi::OsStr::new("KIN_HOME")) + .flatten() + .expect("the owned daemon runtime supplies its existing isolated Kin home"); // The stdio client uses a private home and the daemon runtime's registry. // Its central store must resolve exactly as the initialization did. let home = tempdir().expect("private MCP client home"); @@ -409,6 +414,13 @@ fn run_mcp_session( .env("HOME", home.path()) .env("KIN_REGISTRY_PATH", runtime.registry_path()) .env("USERPROFILE", home.path()) + // Keep the client's configuration home separate while sharing the + // admitted namespace of the daemon that serves this repository. + .env("KIN_HOME", kin_home) + .env( + "KIN_RUNTIME_MODE", + kin_core::runtime_identity::RuntimeMode::Development.as_str(), + ) .env("TMPDIR", std::env::temp_dir()) .env("KIN_VFS_DISABLE", "1") .env("KIN_DAEMON_URL", format!("http://127.0.0.1:{port}")) diff --git a/crates/kin-cli/tests/managed_runtime_admission.rs b/crates/kin-cli/tests/managed_runtime_admission.rs new file mode 100644 index 000000000..d70cb0aa4 --- /dev/null +++ b/crates/kin-cli/tests/managed_runtime_admission.rs @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! CLI commands that build their own daemon requests still carry the runtime +//! admission a release daemon requires. +//! +//! A managed daemon refuses a local request that lacks the caller's runtime +//! identity and the repository it means, before any handler runs. A +//! development daemon checks those claims when a request carries them but does +//! not demand them, so a command that sends a request on an HTTP client of its +//! own, without the claims, passed every development run and was refused on the +//! first release build: `kin commit` answered `HTTP 409 Conflict: managed +//! semantic requests require the exact runtime identity header`. +//! +//! `KIN_DAEMON_REQUIRE_RUNTIME_IDENTITY` holds a development daemon to the +//! managed contract. Under it, a bare request is the positive control that the +//! daemon really refuses, and the commands that build their own requests are +//! what must still be answered. + +use std::fs; +use std::io::{Read, Write}; +use std::path::Path; +use tempfile::tempdir; + +mod common; + +use common::Command; + +const STRICT_ADMISSION: &str = "KIN_DAEMON_REQUIRE_RUNTIME_IDENTITY"; + +fn run_git(repo: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", kin_git::empty_global_git_config()) + .current_dir(repo) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn run_kin( + runtime: &common::IsolatedDaemonRuntime, + repo: &Path, + args: &[&str], +) -> std::process::Output { + runtime + .kin_command() + .args(args) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", kin_git::empty_global_git_config()) + .env("KIN_DAEMON_BIN", runtime.daemon_bin()) + .env("KIN_DAEMON_DISABLE_LSP", "1") + .env(STRICT_ADMISSION, "1") + .current_dir(repo) + .output() + .expect("run kin") +} + +fn require_kin( + runtime: &common::IsolatedDaemonRuntime, + repo: &Path, + args: &[&str], +) -> std::process::Output { + let output = run_kin(runtime, repo, args); + assert!( + output.status.success(), + "kin {args:?} failed under strict runtime admission: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +/// One HTTP/1.1 request to the daemon with its bearer token and nothing else: +/// no runtime identity and no repository claim. Answers the status line. +fn bare_request(port: u16, token: &str, path: &str) -> String { + let body = br#"{"json":true}"#; + let mut stream = + std::net::TcpStream::connect(("127.0.0.1", port)).expect("connect to the daemon"); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(30))) + .expect("bound the read"); + let head = format!( + "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {token}\r\n\ + Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(head.as_bytes()).expect("send the head"); + stream.write_all(body).expect("send the body"); + let mut response = String::new(); + let _ = stream.read_to_string(&mut response); + response +} + +#[test] +fn commands_with_their_own_requests_are_admitted_by_a_daemon_that_requires_runtime_identity() { + let root = tempdir().expect("temp root"); + let repo = root.path().join("repo"); + fs::create_dir_all(repo.join("src")).expect("create source directory"); + run_git(&repo, &["init", "--initial-branch=main"]); + run_git(&repo, &["config", "commit.gpgsign", "false"]); + run_git(&repo, &["config", "user.name", "Ada Lovelace"]); + run_git(&repo, &["config", "user.email", "ada@example.com"]); + fs::write(repo.join("src/lib.rs"), b"pub fn shipped() -> u8 { 1 }\n").expect("write source"); + run_git(&repo, &["add", "--all"]); + run_git(&repo, &["commit", "-m", "first commit"]); + + let runtime = common::IsolatedDaemonRuntime::new(&repo); + require_kin(&runtime, &repo, &["init", ".", "--json"]); + // `kin doctor --drift` reaches the daemon through the shared client and + // starts one under strict admission when none is serving. + require_kin(&runtime, &repo, &["doctor", "--drift", "--json"]); + + // The positive control: this daemon really refuses a request that carries + // its token and none of the runtime claims. Without it, every assertion + // below would also pass against a daemon that checks nothing. + let layout = runtime.layout_for(&repo); + let port: u16 = fs::read_to_string(layout.root().join("daemon.port")) + .expect("a daemon serves the repository") + .trim() + .parse() + .expect("the daemon records its port"); + let token = fs::read_to_string(layout.root().join("daemon.token")) + .expect("the daemon records its token") + .trim() + .to_string(); + let refused = bare_request(port, &token, "/commands/drift"); + assert!( + refused.starts_with("HTTP/1.1 409"), + "a request without runtime claims must be refused before its handler: {refused}" + ); + assert!( + refused.contains("runtime identity header"), + "the refusal must be the runtime admission one: {refused}" + ); + + // `kin commit` sends its request on a client of its own, for a reply with + // no deadline. It was the command a release build refused. + fs::write(repo.join("src/lib.rs"), b"pub fn shipped() -> u8 { 2 }\n").expect("edit source"); + let committed = require_kin( + &runtime, + &repo, + &["commit", "-m", "an edit under strict runtime admission"], + ); + let said = String::from_utf8_lossy(&committed.stdout); + assert!( + !said.trim().is_empty(), + "the commit must report the change it landed" + ); + let log = require_kin(&runtime, &repo, &["log", "--json", "--count", "5"]); + let log: serde_json::Value = serde_json::from_slice(&log.stdout).expect("kin log emits JSON"); + assert!( + log["entries"] + .as_array() + .expect("log reports its entries") + .iter() + .any(|entry| entry + .to_string() + .contains("an edit under strict runtime admission")), + "the commit must have landed: {log}" + ); + + // The intent commands built bare requests too. + require_kin(&runtime, &repo, &["intent", "list"]); + + // Single-entity restore also builds its own daemon request without a reply + // deadline. + let located = require_kin(&runtime, &repo, &["locate", "shipped", "--json"]); + let located: serde_json::Value = + serde_json::from_slice(&located.stdout).expect("kin locate emits JSON"); + let shipped_id = located["entities"][0]["entity_id"] + .as_str() + .expect("locate returns the entity id of shipped") + .to_string(); + fs::write(repo.join("src/lib.rs"), b"pub fn shipped() -> u8 { 3 }\n").expect("edit source"); + require_kin(&runtime, &repo, &["checkout", "--entity", &shipped_id]); + assert_eq!( + fs::read_to_string(repo.join("src/lib.rs")).expect("read restored source"), + "pub fn shipped() -> u8 { 2 }\n" + ); +} diff --git a/crates/kin-cli/tests/mcp_default_profile_writes.rs b/crates/kin-cli/tests/mcp_default_profile_writes.rs index 790f9076e..09f06b415 100644 --- a/crates/kin-cli/tests/mcp_default_profile_writes.rs +++ b/crates/kin-cli/tests/mcp_default_profile_writes.rs @@ -73,7 +73,8 @@ struct McpSession { } impl McpSession { - fn start(repo: &Path, home: &Path, registry: &Path, port: u16) -> Self { + fn start(repo: &Path, home: &Path, kin_home: &Path, registry: &Path, port: u16) -> Self { + let _ = common::ensure_sibling_daemon_built(); let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_kin")) .args(["mcp", "start"]) .current_dir(repo) @@ -84,6 +85,8 @@ impl McpSession { .env("PATH", std::env::var_os("PATH").unwrap_or_default()) .env("HOME", home) .env("USERPROFILE", home) + .env("KIN_HOME", kin_home) + .env("KIN_RUNTIME_MODE", "development") .env("TMPDIR", std::env::temp_dir()) .env("KIN_VFS_DISABLE", "1") .env("KIN_REGISTRY_PATH", registry) @@ -210,6 +213,13 @@ fn the_default_profile_lists_no_session_and_a_write_needs_none() { seed_repository(&repo); let runtime = common::IsolatedDaemonRuntime::new(&repo); + let kin_home = std::path::PathBuf::from( + runtime + .kin_command() + .configured_env_for_test(std::ffi::OsStr::new("KIN_HOME")) + .flatten() + .expect("owned Kin home"), + ); let init = runtime .kin_command() .args(["init", ".", "--json"]) @@ -238,7 +248,7 @@ fn the_default_profile_lists_no_session_and_a_write_needs_none() { .expect("spawn the daemon"); let port = wait_until_serving(runtime.layout().root(), &mut daemon); - let mut mcp = McpSession::start(&repo, &home, runtime.registry_path(), port); + let mut mcp = McpSession::start(&repo, &home, &kin_home, runtime.registry_path(), port); let initialized = mcp.request( 1, "initialize", diff --git a/crates/kin-cli/tests/mcp_stdio_contract.rs b/crates/kin-cli/tests/mcp_stdio_contract.rs index bbdf664d6..6f000c6c3 100644 --- a/crates/kin-cli/tests/mcp_stdio_contract.rs +++ b/crates/kin-cli/tests/mcp_stdio_contract.rs @@ -71,6 +71,7 @@ fn run_configured_probe_session( extra_env: &[(&str, std::ffi::OsString)], ) -> McpRun { let started = Instant::now(); + let _ = common::ensure_sibling_daemon_built(); let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_kin")); command .args(["mcp", "start"]) @@ -177,7 +178,7 @@ fn response_with_id(run: &McpRun, id: u64) -> &serde_json::Value { }) } -fn assert_four_frame_contract(run: &McpRun) { +fn assert_four_frame_protocol(run: &McpRun) { assert!( run.elapsed < FOUR_FRAME_BOUND, "the probe session took {:?} against a {FOUR_FRAME_BOUND:?} bound", @@ -196,8 +197,12 @@ fn assert_four_frame_contract(run: &McpRun) { .unwrap_or(0); assert!( tool_count > 0, - "tools/list must serve the tool surface with no daemon: {tools:#?}" + "tools/list must serve the tool surface: {tools:#?}" ); +} + +fn assert_four_frame_contract(run: &McpRun) { + assert_four_frame_protocol(run); // The graph-backed call is answered with a structured error naming the // situation, never with silence or a dead process. @@ -209,7 +214,8 @@ fn assert_four_frame_contract(run: &McpRun) { assert_eq!( call.pointer("/result/isError").and_then(|v| v.as_bool()), Some(true), - "with no daemon anywhere the graph call is an error result: {call:#?}" + "with no daemon anywhere the graph call is an error result: {call:#?}; stderr:\n{}", + run.stderr ); assert!( content.contains("daemon") || content.contains("repository"), @@ -267,29 +273,175 @@ fn the_four_frame_probe_is_answered_inside_a_repository_whose_daemon_is_absent() /// session started a fresh daemon from inside the probe, the mechanism behind /// the 2026-08-15 boot spawn storm. /// -/// The spawn detector is a stub `kin-daemon` injected through `KIN_DAEMON_BIN` -/// that records every invocation to a marker file. The positive control runs -/// the identical session without `--no-spawn` and requires the marker to -/// appear, which is what proves the detector can see a spawn at all; only then -/// does the absent marker under `--no-spawn` mean zero daemon processes rather -/// than a check that cannot fail. Unix-only because the stub is a shell script. +/// The negative control preserves the dead endpoint and leaves actors absent. +/// The unrestricted control must revive the admitted real daemon and answer +/// for the repository UUID emitted by initialization. Both sessions run inside +/// the existing owned runtime containment, including cleanup on assertion failure. #[cfg(unix)] #[test] fn a_no_spawn_probe_over_a_dead_daemon_record_spawns_nothing() { + use std::os::fd::{AsRawFd, FromRawFd}; use std::os::unix::fs::PermissionsExt as _; + struct DeadPortReservation { + _socket: std::fs::File, + port: u16, + } + + fn reserve_dead_port() -> DeadPortReservation { + // SAFETY: socket has no pointer arguments; the returned descriptor is + // immediately transferred to the RAII owner below. + let descriptor = unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0) }; + assert!( + descriptor >= 0, + "create reserved socket: {}", + std::io::Error::last_os_error() + ); + // SAFETY: a successful socket call returned this owned descriptor. + let socket = unsafe { std::fs::File::from_raw_fd(descriptor) }; + // SAFETY: the retained file owns a live descriptor for both fcntl calls. + let flags = unsafe { libc::fcntl(socket.as_raw_fd(), libc::F_GETFD) }; + assert!(flags >= 0, "inspect reserved socket flags"); + // SAFETY: this sets descriptor flags without transferring ownership. + assert_eq!( + unsafe { libc::fcntl(socket.as_raw_fd(), libc::F_SETFD, flags | libc::FD_CLOEXEC) }, + 0 + ); + // SAFETY: sockaddr_in is a plain C data structure with valid zero fields. + let mut address: libc::sockaddr_in = unsafe { std::mem::zeroed() }; + address.sin_family = libc::AF_INET as libc::sa_family_t; + address.sin_addr.s_addr = u32::from_ne_bytes([127, 0, 0, 1]); + #[cfg(any( + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + ))] + { + address.sin_len = std::mem::size_of::() as u8; + } + // SAFETY: address is aligned and remains live for the supplied length. + assert_eq!( + unsafe { + libc::bind( + socket.as_raw_fd(), + (&address as *const libc::sockaddr_in).cast(), + std::mem::size_of::() as libc::socklen_t, + ) + }, + 0, + "reserve dead loopback port: {}", + std::io::Error::last_os_error() + ); + let mut length = std::mem::size_of::() as libc::socklen_t; + // SAFETY: address and length are writable buffers of the declared sizes. + assert_eq!( + unsafe { + libc::getsockname( + socket.as_raw_fd(), + (&mut address as *mut libc::sockaddr_in).cast(), + &mut length, + ) + }, + 0, + "read reserved port" + ); + DeadPortReservation { + _socket: socket, + port: u16::from_be(address.sin_port), + } + } + + fn run_owned_probe_session( + runtime: &common::IsolatedDaemonRuntime, + repository: &Path, + captures: &Path, + label: &str, + extra_args: &[&str], + dead_port: u16, + ) -> McpRun { + let input_path = captures.join(format!("{label}.stdin")); + let stdout_path = captures.join(format!("{label}.stdout")); + let stderr_path = captures.join(format!("{label}.stderr")); + std::fs::write(&input_path, probe_frames()).expect("write owned probe frames"); + let started = Instant::now(); + let mut child = runtime + .kin_command() + .args(["mcp", "start"]) + .args(extra_args) + .current_dir(repository) + .env("KIN_MCP_MACHINE_REPLIES", "1") + .env("KIN_RUNTIME_MODE", "development") + .env_remove("KIN_NO_DAEMON") + .env("KIN_DAEMON_URL", format!("http://127.0.0.1:{dead_port}")) + .stdin(Stdio::from( + std::fs::File::open(&input_path).expect("open probe frames"), + )) + .stdout(Stdio::from( + std::fs::File::create(&stdout_path).expect("capture probe stdout"), + )) + .stderr(Stdio::from( + std::fs::File::create(&stderr_path).expect("capture probe stderr"), + )) + .spawn_owned() + .expect("spawn contained MCP probe"); + let status = loop { + if let Some(status) = child.try_wait().expect("poll owned MCP probe") { + break status; + } + assert!( + started.elapsed() < FOUR_FRAME_BOUND, + "contained MCP probe {label} exceeded {FOUR_FRAME_BOUND:?}" + ); + std::thread::sleep(Duration::from_millis(20)); + }; + let stderr = std::fs::read_to_string(&stderr_path).expect("read probe stderr"); + assert!( + status.success(), + "contained MCP probe {label} exited with {status}; stderr:\n{stderr}" + ); + let responses = std::fs::read_to_string(&stdout_path) + .expect("read probe stdout") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("stdout lines are JSON-RPC")) + .collect(); + McpRun { + responses, + stderr, + elapsed: started.elapsed(), + } + } + let root = tempfile::tempdir().expect("temp root"); - let home = root.path().join("home"); - std::fs::create_dir_all(&home).expect("create home"); let repo = root.path().join("repo"); std::fs::create_dir_all(&repo).expect("create repo dir"); + let runtime = common::IsolatedDaemonRuntime::new(&repo); + let environment = runtime.kin_command(); + let home = std::path::PathBuf::from( + environment + .configured_env_for_test(std::ffi::OsStr::new("HOME")) + .flatten() + .expect("owned HOME"), + ); + let kin_home = std::path::PathBuf::from( + environment + .configured_env_for_test(std::ffi::OsStr::new("KIN_HOME")) + .flatten() + .expect("owned Kin home"), + ); + drop(environment); + std::fs::create_dir_all(&home).expect("create owned configuration home"); + std::fs::set_permissions(&kin_home, std::fs::Permissions::from_mode(0o700)) + .expect("secure isolated Kin home"); - let init = common::Command::new(env!("CARGO_BIN_EXE_kin")) - .arg("init") + let init = runtime + .kin_command() + .args(["init", "--no-enrich", "--json"]) .arg(&repo) - .env("HOME", &home) + .env("KIN_RUNTIME_MODE", "development") .env("KIN_NO_DAEMON", "1") - .env("KIN_REGISTRY_PATH", home.join("registry.toml")) .output() .expect("run kin init"); assert!( @@ -298,98 +450,194 @@ fn a_no_spawn_probe_over_a_dead_daemon_record_spawns_nothing() { String::from_utf8_lossy(&init.stdout), String::from_utf8_lossy(&init.stderr) ); + let initialized: serde_json::Value = + serde_json::from_slice(&init.stdout).unwrap_or_else(|error| { + panic!( + "init JSON failed: {error}; status={} stdout={} stderr={}", + init.status, + String::from_utf8_lossy(&init.stdout), + String::from_utf8_lossy(&init.stderr) + ) + }); + assert_eq!(initialized["schema"], "kin.init-result.v6"); + let intended_repository = initialized["repository_id"] + .as_str() + .expect("init repository UUID") + .to_string(); + uuid::Uuid::parse_str(&intended_repository).expect("init names a repository UUID"); - // A pid that provably belonged to a real process and provably no longer - // does: reap a child and record its pid. let dead_pid = { - let mut child = std::process::Command::new("/usr/bin/true") - .spawn() - .or_else(|_| std::process::Command::new("/bin/true").spawn()) - .expect("spawn short-lived child"); + let mut child = runtime + .process_command_for_test("/usr/bin/true") + .spawn_owned() + .or_else(|_| runtime.process_command_for_test("/bin/true").spawn_owned()) + .expect("spawn short-lived owned child"); let pid = child.id(); - child.wait().expect("reap short-lived child"); + child.wait().expect("reap short-lived owned child"); pid }; - // A loopback port that was free at selection time: bind, read, release. - let dead_port = { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve a loopback port"); - listener.local_addr().expect("read reserved port").port() - }; - let mut store_command = common::Command::new(env!("CARGO_BIN_EXE_kin")); - store_command - .env("HOME", &home) - .env("KIN_REGISTRY_PATH", home.join("registry.toml")); - let kin_dir = common::repository_layout(store_command, &repo) - .root() - .to_path_buf(); - std::fs::write( - kin_dir.join(kin_daemon_spawn::PID_FILE_NAME), - dead_pid.to_string(), - ) - .expect("plant stale pid record"); - std::fs::write( - kin_dir.join(kin_daemon_spawn::PORT_FILE_NAME), - dead_port.to_string(), - ) - .expect("plant stale port record"); - - // The stub daemon: records its invocation and exits, so a run that spawns - // leaves evidence and leaves no process behind. - let marker = root.path().join("daemon-spawned.marker"); - let stub = root.path().join("kin-daemon-stub"); - std::fs::write( - &stub, - format!("#!/bin/sh\necho \"$@\" >> '{}'\nexit 7\n", marker.display()), - ) - .expect("write stub daemon"); - std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)) - .expect("mark stub executable"); - - let fixture_env = |ready_timeout: Option<&str>| { - let mut env: Vec<(&str, std::ffi::OsString)> = vec![ - ( - "KIN_DAEMON_URL", - format!("http://127.0.0.1:{dead_port}").into(), - ), - ("KIN_DAEMON_BIN", stub.clone().into_os_string()), - ]; - if let Some(secs) = ready_timeout { - env.push(("KIN_DAEMON_READY_TIMEOUT_SECS", secs.into())); - } - env - }; + let dead_endpoint = reserve_dead_port(); + let dead_port = dead_endpoint.port; + let kin_dir = runtime.layout().root().to_path_buf(); + let supervisor_services = runtime + .registry_path() + .parent() + .expect("owned registry parent") + .join("services"); + assert!( + !kin_dir.join(kin_daemon_spawn::PID_FILE_NAME).exists(), + "preparation must not leave a daemon PID before injecting the dead record" + ); + assert!( + !kin_dir.join(kin_daemon_spawn::PORT_FILE_NAME).exists(), + "preparation must not leave a daemon port before injecting the dead record" + ); + assert!( + !kin_home.join("services").exists(), + "preparation must not start services in the owned Kin home" + ); + assert!( + !supervisor_services.exists(), + "preparation must not start supervisor services beside the owned registry" + ); + assert!( + std::net::TcpStream::connect(("127.0.0.1", dead_port)).is_err(), + "the retained non-listening socket must not accept connections" + ); + let stale_pid = dead_pid.to_string(); + let stale_port = dead_port.to_string(); + std::fs::write(kin_dir.join(kin_daemon_spawn::PID_FILE_NAME), &stale_pid) + .expect("plant stale PID record"); + std::fs::write(kin_dir.join(kin_daemon_spawn::PORT_FILE_NAME), &stale_port) + .expect("plant stale port record"); - // The claim: under --no-spawn the whole session answers and nothing is - // started. The tools/call error must name KIN_NO_DAEMON, which proves the - // binding settled, the dead daemon was observed, and revival was consulted - // and refused. Without that assertion, an early failure anywhere on the - // path would also leave the marker absent and the test would prove nothing. - let no_spawn = run_configured_probe_session(&repo, &home, &["--no-spawn"], &fixture_env(None)); + let no_spawn = run_owned_probe_session( + &runtime, + &repo, + root.path(), + "no-spawn", + &["--no-spawn"], + dead_port, + ); assert_four_frame_contract(&no_spawn); let refusal = response_with_id(&no_spawn, 3) .pointer("/result/content/0/text") .and_then(|text| text.as_str()) - .expect("tools/call carries a content block") - .to_string(); + .expect("tools/call carries a content block"); assert!( refusal.contains("KIN_NO_DAEMON"), "the no-spawn refusal must reach the tool answer: {refusal}" ); + assert_eq!( + std::fs::read_to_string(kin_dir.join(kin_daemon_spawn::PID_FILE_NAME)).unwrap(), + stale_pid, + "no-spawn must preserve the dead PID record" + ); + assert_eq!( + std::fs::read_to_string(kin_dir.join(kin_daemon_spawn::PORT_FILE_NAME)).unwrap(), + stale_port, + "no-spawn must preserve the dead port record" + ); + assert!(!kin_home.join("services").exists()); assert!( - !marker.exists(), - "--no-spawn probe invoked the daemon binary: {}", - std::fs::read_to_string(&marker).unwrap_or_default() + !supervisor_services.exists(), + "no-spawn must leave supervisor actors absent" ); + assert!(std::net::TcpStream::connect(("127.0.0.1", dead_port)).is_err()); - // Positive control: the identical session without --no-spawn must reach - // the stub, proving the marker detects spawns. The short ready timeout - // bounds the supervisor-spawn arm should binary validation ever start - // accepting the stub. - let unrestricted = run_configured_probe_session(&repo, &home, &[], &fixture_env(Some("2"))); - assert_four_frame_contract(&unrestricted); - assert!( - marker.exists(), - "the control run must spawn the stub daemon, or the marker check cannot fail; stderr:\n{}", + // The unrestricted control starts from absence after the no-spawn control + // proved these deliberately incomplete records were preserved. + let fabricated_pid = kin_dir.join(kin_daemon_spawn::PID_FILE_NAME); + let fabricated_port = kin_dir.join(kin_daemon_spawn::PORT_FILE_NAME); + assert_eq!( + std::fs::read_to_string(&fabricated_pid).unwrap(), + stale_pid, + "discard only the test's unchanged fabricated PID record" + ); + assert_eq!( + std::fs::read_to_string(&fabricated_port).unwrap(), + stale_port, + "discard only the test's unchanged fabricated port record" + ); + std::fs::remove_file(&fabricated_pid).expect("discard fabricated PID record"); + std::fs::remove_file(&fabricated_port).expect("discard fabricated port record"); + assert!(!fabricated_pid.exists()); + assert!(!fabricated_port.exists()); + + let unrestricted = + run_owned_probe_session(&runtime, &repo, root.path(), "unrestricted", &[], dead_port); + assert_four_frame_protocol(&unrestricted); + let call = response_with_id(&unrestricted, 3); + assert_ne!( + call.pointer("/result/isError") + .and_then(|value| value.as_bool()), + Some(true), + "unrestricted graph call must succeed: {call}; stderr:\n{}", unrestricted.stderr ); + let graph_text = call + .pointer("/result/content/0/text") + .and_then(|text| text.as_str()) + .expect("successful graph reply carries JSON text"); + let graph: serde_json::Value = serde_json::from_str(graph_text).expect("graph reply is JSON"); + assert_eq!( + graph + .pointer("/repository_base/context/repository_id") + .and_then(|value| value.as_str()), + Some(intended_repository.as_str()), + "unrestricted graph reply must identify the initialized repository: {graph}" + ); + + let pid: u32 = std::fs::read_to_string(kin_dir.join(kin_daemon_spawn::PID_FILE_NAME)) + .expect("real daemon PID published") + .trim() + .parse() + .expect("real daemon PID"); + let port: u16 = std::fs::read_to_string(kin_dir.join(kin_daemon_spawn::PORT_FILE_NAME)) + .expect("real daemon port published") + .trim() + .parse() + .expect("real daemon port"); + assert_ne!( + port, dead_port, + "a real daemon cannot listen on the retained non-listening reservation" + ); + assert!( + std::net::TcpStream::connect(("127.0.0.1", port)).is_ok(), + "the admitted real daemon must listen on its published port" + ); + let async_runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("peer metadata runtime"); + let peer = async_runtime + .block_on(kin_cli::daemon_client::read_runtime_endpoint(&format!( + "http://127.0.0.1:{port}" + ))) + .expect("real daemon runtime identity"); + assert_eq!(peer.schema, "kin.runtime-service.v1"); + assert_eq!(peer.component, "daemon"); + assert_eq!(peer.pid, pid); + assert_eq!( + peer.mode, + kin_core::runtime_identity::RuntimeMode::Development + ); + assert_eq!( + peer.identity, + kin_core::runtime_identity::RuntimeIdentity::current() + ); + let expected_namespace = kin_core::runtime_identity::RuntimeNamespace::capture(&kin_home) + .expect("owned runtime namespace"); + assert_eq!(peer.namespace.as_deref(), Some(expected_namespace.token())); + let incarnation = kin_cli::daemon_client::process_identity(pid) + .expect("capture real daemon incarnation") + .expect("real daemon is alive"); + drop(async_runtime); + // The fixture's existing Drop uses product stop and owned containment on + // both success and unwinding; it never signals an arbitrary recorded PID. + drop(runtime); + assert!( + !kin_cli::daemon_client::process_identity_is_current(&incarnation) + .expect("confirm owned daemon cleanup") + ); } diff --git a/crates/kin-cli/tests/offline_tracked_changes.rs b/crates/kin-cli/tests/offline_tracked_changes.rs index a2c5e6384..7d79cb31c 100644 --- a/crates/kin-cli/tests/offline_tracked_changes.rs +++ b/crates/kin-cli/tests/offline_tracked_changes.rs @@ -129,6 +129,11 @@ fn mcp_answers( home: &Path, calls: &[(&str, Value)], ) -> Vec { + let kin_home = runtime + .kin_command() + .configured_env_for_test(std::ffi::OsStr::new("KIN_HOME")) + .flatten() + .expect("the owned daemon runtime supplies its existing isolated Kin home"); let port = fs::read_to_string(runtime.layout_for(repo).root().join("daemon.port")) .expect("a daemon serves the repository") .trim() @@ -162,6 +167,13 @@ fn mcp_answers( .env("PATH", std::env::var_os("PATH").unwrap_or_default()) .env("HOME", home) .env("USERPROFILE", home) + // The client has its own configuration home and shares the daemon's + // admitted runtime namespace after clearing inherited authority. + .env("KIN_HOME", kin_home) + .env( + "KIN_RUNTIME_MODE", + kin_core::runtime_identity::RuntimeMode::Development.as_str(), + ) .env("TMPDIR", std::env::temp_dir()) .env("KIN_VFS_DISABLE", "1") .env("KIN_REGISTRY_PATH", runtime.registry_path()) diff --git a/crates/kin-cli/tests/permissive_umask_state.rs b/crates/kin-cli/tests/permissive_umask_state.rs index dc1990992..8c72d5f49 100644 --- a/crates/kin-cli/tests/permissive_umask_state.rs +++ b/crates/kin-cli/tests/permissive_umask_state.rs @@ -1,24 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Firelock, LLC -//! Kin's state stays private to its user under a permissive umask. -//! -//! Ubuntu, Debian and every distribution that sets `USERGROUPS_ENAB` give a -//! login the umask 0002. Under it `kin setup` made `~/.kin`, its config and -//! shell files and the supervisor's records group writable, and the registry -//! then refused the directory it lives in, so setup reported a misconfigured -//! registry and still exited 0. These drive the real binary under that umask. +//! A permissive login umask cannot authorize Development to write or repair +//! global setup state. Release acceptance grades Managed-created state modes. #![cfg(unix)] use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::Output; use std::time::Duration; mod common; -/// Wall-clock cap for one scripted `kin` run, which takes seconds. const RUN_TIMEOUT: Duration = Duration::from_secs(120); struct Fixture { @@ -29,25 +23,20 @@ struct Fixture { impl Fixture { fn kin_home(&self) -> PathBuf { - self.home.join(".kin") + self.home.join("kin-home") } } -/// An isolated home with no `~/.kin` yet, so every directory under it is one -/// Kin creates. With `claude_code`, Claude Code is detected and configured. -fn fixture(claude_code: bool) -> Fixture { +fn fixture() -> Fixture { let root = tempfile::tempdir().expect("fixture root"); let home = root.path().join("home"); let cwd = root.path().join("outside-repository"); - std::fs::create_dir_all(&home).expect("create home"); - std::fs::create_dir_all(&cwd).expect("create cwd"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); for directory in [root.path(), home.as_path()] { - std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o755)) - .expect("fix fixture mode"); - } - if claude_code { - std::fs::write(home.join(".claude.json"), b"{}").expect("seed Claude Code config"); + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o755)).unwrap(); } + std::fs::write(home.join(".claude.json"), b"{}").unwrap(); Fixture { _root: root, home, @@ -55,7 +44,6 @@ fn fixture(claude_code: bool) -> Fixture { } } -/// Run `kin ` under umask 0002, the way a login on Ubuntu starts it. fn kin_under_usergroups_umask(fixture: &Fixture, args: &[&str]) -> Output { let mut argv = vec![ "-c".to_string(), @@ -69,6 +57,7 @@ fn kin_under_usergroups_umask(fixture: &Fixture, args: &[&str]) -> Output { .env("HOME", &fixture.home) .env("USERPROFILE", &fixture.home) .env("KIN_HOME", fixture.kin_home()) + .env("KIN_RUNTIME_MODE", "development") .env("KIN_VFS_DISABLE", "1") .env("KIN_NO_DAEMON", "1") .env_remove("KIN_DIR") @@ -76,7 +65,7 @@ fn kin_under_usergroups_umask(fixture: &Fixture, args: &[&str]) -> Output { .env_remove("KIN_DAEMON_URL") .env_remove("KIN_MCP_REPO") .output_within(RUN_TIMEOUT) - .expect("run kin") + .expect("run development kin under umask 0002") } fn setup(fixture: &Fixture, extra: &[&str]) -> Output { @@ -93,120 +82,55 @@ fn setup(fixture: &Fixture, extra: &[&str]) -> Output { kin_under_usergroups_umask(fixture, &args) } -/// Every directory and file below `root` that its group or others can write, -/// with its mode. Symlinks are skipped: their own mode means nothing. -fn shared_writable(root: &Path) -> Vec<(PathBuf, u32)> { - let mut found = Vec::new(); - let mut pending = vec![root.to_path_buf()]; - while let Some(path) = pending.pop() { - let metadata = std::fs::symlink_metadata(&path).expect("inspect created state"); - if metadata.file_type().is_symlink() { - continue; - } - let mode = metadata.permissions().mode() & 0o7777; - if mode & 0o022 != 0 { - found.push((path.clone(), mode)); - } - if metadata.is_dir() { - for entry in std::fs::read_dir(&path).expect("list created state") { - pending.push(entry.expect("directory entry").path()); - } - } +fn assert_development_refusal(fixture: &Fixture, output: &Output) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stderr.contains("development mode cannot"), "{stderr}"); + assert_eq!( + std::fs::read(fixture.home.join(".claude.json")).unwrap(), + b"{}" + ); + for untouched in [".kin", ".zshrc", ".zshenv", ".codex", ".cursor"] { + assert!( + !fixture.home.join(untouched).exists(), + "setup wrote {untouched}" + ); } - found -} - -fn transcript(output: &Output) -> String { - format!( - "status: {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ) } #[test] -fn setup_under_umask_0002_creates_nothing_group_writable() { - let fixture = fixture(true); - - let output = setup(&fixture, &[]); - assert!(output.status.success(), "{}", transcript(&output)); - let kin_home = fixture.kin_home(); - assert!( - kin_home.join("config").join("setup.toml").is_file(), - "setup wrote nothing under the Kin home, so this proves nothing:\n{}", - transcript(&output) - ); - - let initialize = - kin_under_usergroups_umask(&fixture, &["registry", "authority", "--initialize"]); - assert!(initialize.status.success(), "{}", transcript(&initialize)); - assert!(kin_home.join("registry.toml").is_file()); - - let mut exposed = shared_writable(&kin_home); - for dotfile in [".zshenv", ".zshrc", ".claude.json"] { - let path = fixture.home.join(dotfile); - if path.exists() && path.metadata().unwrap().permissions().mode() & 0o022 != 0 { - exposed.push((path.clone(), path.metadata().unwrap().permissions().mode())); - } - } - assert!( - exposed.is_empty(), - "state Kin created under umask 0002 is group or world writable: {exposed:#?}" - ); +fn development_setup_under_umask_0002_creates_no_global_state() { + let fixture = fixture(); + assert_development_refusal(&fixture, &setup(&fixture, &[])); + assert!(!fixture.kin_home().exists()); } -/// A Kin home an earlier install left group writable: setup fails and names -/// the directory and the exact repair, `kin doctor --fix` makes that repair -/// with consent, and setup then succeeds. #[test] -fn setup_refuses_a_group_writable_kin_home_until_doctor_fix_repairs_it() { - let fixture = fixture(false); +fn development_setup_and_doctor_fix_leave_a_group_writable_home_unchanged() { + let fixture = fixture(); let kin_home = fixture.kin_home(); - std::fs::create_dir_all(kin_home.join("bin")).expect("create the earlier install"); - std::fs::set_permissions(&kin_home, std::fs::Permissions::from_mode(0o775)) - .expect("leave it group writable"); - - // The short summary and the full record each end the run themselves. + let bin = kin_home.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::set_permissions(&kin_home, std::fs::Permissions::from_mode(0o775)).unwrap(); for extra in [&[][..], &["--verbose"][..]] { - let refused = setup(&fixture, extra); - let text = transcript(&refused); - assert!( - !refused.status.success(), - "setup {extra:?} must not exit 0 with a registry Kin refuses:\n{text}" - ); - // The registry decides the exit itself. A client that fails on the same - // directory also fails setup, and which clients a host detects is not - // something this fixture controls, so the status alone proves too little. - assert!( - String::from_utf8_lossy(&refused.stderr) - .contains("kin setup cannot use the local registry"), - "setup {extra:?} must end on the registry refusal:\n{text}" - ); - let remedy = format!("chmod go-w {}", kin_home.display()); - assert!( - text.contains(&remedy), - "the refusal must name the exact repair `{remedy}`:\n{text}" - ); - assert!( - !text.contains("move the unsafe object aside"), - "a mode problem is not an unsafe object:\n{text}" - ); + assert_development_refusal(&fixture, &setup(&fixture, extra)); assert_eq!( std::fs::metadata(&kin_home).unwrap().permissions().mode() & 0o777, - 0o775, - "setup never repairs without consent" + 0o775 ); } - - let repaired = kin_under_usergroups_umask(&fixture, &["doctor", "--fix"]); + assert_development_refusal( + &fixture, + &kin_under_usergroups_umask(&fixture, &["doctor", "--fix"]), + ); assert_eq!( std::fs::metadata(&kin_home).unwrap().permissions().mode() & 0o777, - 0o755, - "doctor --fix must remove group and world write:\n{}", - transcript(&repaired) + 0o775 ); - - let after = setup(&fixture, &[]); - assert!(after.status.success(), "{}", transcript(&after)); + assert_eq!(std::fs::read_dir(&bin).unwrap().count(), 0); + assert_eq!(std::fs::read_dir(&kin_home).unwrap().count(), 1); } diff --git a/crates/kin-cli/tests/registry_authority.rs b/crates/kin-cli/tests/registry_authority.rs index 704ac2e90..e601cee95 100644 --- a/crates/kin-cli/tests/registry_authority.rs +++ b/crates/kin-cli/tests/registry_authority.rs @@ -27,17 +27,68 @@ fn unsafe_authority() -> (tempfile::TempDir, std::path::PathBuf, std::path::Path fn kin(root: &Path, registry: &Path) -> Command<'static> { let home = root.join("home"); + let kin_home = root.join("isolated-kin-home"); std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&kin_home).unwrap(); + std::fs::set_permissions(&kin_home, std::fs::Permissions::from_mode(0o700)).unwrap(); let mut command = Command::new(env!("CARGO_BIN_EXE_kin")); command .current_dir(root) .env("HOME", &home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", &kin_home) .env("KIN_REGISTRY_PATH", registry) .env("PATH", "/usr/bin:/bin"); command } +fn json_report(output: &std::process::Output) -> serde_json::Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "expected a JSON diagnostic: {error}; status: {}; stdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }) +} + +fn broken_managed_kin(root: &Path, registry: &Path) -> (Command<'static>, std::path::PathBuf) { + let managed_home = root.join("home").join(".kin"); + let bin = managed_home.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::set_permissions(&managed_home, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o700)).unwrap(); + for (name, body) in [ + ("kin", b"invalid managed CLI image\n".as_slice()), + ("kin-daemon", b"invalid managed daemon image\n".as_slice()), + ] { + let path = bin.join(name); + std::fs::write(&path, body).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let mut command = kin(root, registry); + command + .env("KIN_HOME", &managed_home) + .env("KIN_RUNTIME_MODE", "managed"); + (command, managed_home) +} + +fn assert_broken_managed_pair_unchanged(managed_home: &Path) { + assert_eq!( + std::fs::read(managed_home.join("bin/kin")).unwrap(), + b"invalid managed CLI image\n" + ); + assert_eq!( + std::fs::read(managed_home.join("bin/kin-daemon")).unwrap(), + b"invalid managed daemon image\n" + ); + assert_eq!(std::fs::read_dir(managed_home).unwrap().count(), 1); + assert_eq!( + std::fs::read_dir(managed_home.join("bin")).unwrap().count(), + 2 + ); +} + #[test] fn registry_authority_json_fails_closed_without_reading_or_repairing() { let (root, registry, lock) = unsafe_authority(); @@ -55,15 +106,115 @@ fn registry_authority_json_fails_closed_without_reading_or_repairing() { assert!(!output.status.success()); assert!(!String::from_utf8_lossy(&output.stdout).contains(private_sentinel)); assert!(!String::from_utf8_lossy(&output.stderr).contains(private_sentinel)); - let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let report = json_report(&output); + assert!(report["checks"].as_array().unwrap().iter().any(|check| { + check["state"] == "repairable_permissions" + && check["detail"] + .as_str() + .is_some_and(|detail| detail.contains("expected 0600")) + })); + assert_eq!(mode(®istry), 0o644); + assert_eq!(mode(&lock), 0o644); +} + +#[test] +fn registry_authority_json_remains_read_only_with_a_broken_managed_pair() { + let (root, registry, lock) = unsafe_authority(); + let registry_before = std::fs::read(®istry).unwrap(); + let lock_before = std::fs::read(&lock).unwrap(); + let (mut command, managed_home) = broken_managed_kin(root.path(), ®istry); + let output = command + .args(["registry", "authority", "--json"]) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "unsafe authority must still be refused" + ); + let report = json_report(&output); assert!(report["checks"].as_array().unwrap().iter().any(|check| { check["state"] == "repairable_permissions" && check["detail"] .as_str() .is_some_and(|detail| detail.contains("expected 0600")) })); + assert_eq!(std::fs::read(®istry).unwrap(), registry_before); + assert_eq!(std::fs::read(&lock).unwrap(), lock_before); + assert_eq!(mode(®istry), 0o644); + assert_eq!(mode(&lock), 0o644); + assert_broken_managed_pair_unchanged(&managed_home); +} + +#[test] +fn registry_authority_fix_requires_admission_and_preserves_unsafe_permissions() { + let (root, registry, lock) = unsafe_authority(); + let registry_before = std::fs::read(®istry).unwrap(); + let lock_before = std::fs::read(&lock).unwrap(); + let (mut command, managed_home) = broken_managed_kin(root.path(), ®istry); + let output = command + .args(["registry", "authority", "--fix", "--json"]) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "a broken managed pair cannot authorize repair" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("runtime admission refused") + || stderr.contains( + "managed runtime requires a clean release build with a full known source commit and target" + ), + "mutation must be refused by runtime qualification or admission; status: {}; stdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + stderr + ); + assert_eq!(std::fs::read(®istry).unwrap(), registry_before); + assert_eq!(std::fs::read(&lock).unwrap(), lock_before); assert_eq!(mode(®istry), 0o644); assert_eq!(mode(&lock), 0o644); + assert_broken_managed_pair_unchanged(&managed_home); +} + +#[test] +fn registry_authority_initialize_requires_admission_and_creates_nothing() { + let root = tempfile::tempdir().unwrap(); + let registry = root.path().join("registry.toml"); + let lock = root.path().join("registry.lock"); + let (mut command, managed_home) = broken_managed_kin(root.path(), ®istry); + let output = command + .args(["registry", "authority", "--initialize", "--json"]) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "a broken managed pair cannot authorize initialization" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("runtime admission refused") + || stderr.contains( + "managed runtime requires a clean release build with a full known source commit and target" + ), + "mutation must be refused by runtime qualification or admission; status: {}; stdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + stderr + ); + assert!( + !registry.exists(), + "refused initialization must not create the registry" + ); + assert!( + !lock.exists(), + "refused initialization must not create the registry lock" + ); + assert!(!registry.with_extension("tmp").exists()); + assert_broken_managed_pair_unchanged(&managed_home); } #[test] @@ -78,7 +229,7 @@ fn doctor_json_and_human_report_the_same_read_only_contract() { "{}", String::from_utf8_lossy(&json.stderr) ); - let report: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap(); + let report = json_report(&json); let check = report["checks"] .as_array() .unwrap() @@ -99,21 +250,31 @@ fn doctor_json_and_human_report_the_same_read_only_contract() { } #[test] -fn doctor_fix_is_the_explicit_permission_only_repair() { +fn development_doctor_fix_refuses_registry_permission_repair() { let (root, registry, lock) = unsafe_authority(); + let registry_before = std::fs::read(®istry).unwrap(); + let lock_before = std::fs::read(&lock).unwrap(); let output = kin(root.path(), ®istry) + .env("KIN_RUNTIME_MODE", "development") .args(["doctor", "--fix"]) .output() .unwrap(); assert!( - output.status.success(), + !output.status.success(), "stdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert_eq!(mode(®istry), 0o600); - assert_eq!(mode(&lock), 0o600); - assert!(String::from_utf8_lossy(&output.stdout) - .contains("repaired registry authority permissions to 0600")); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("development mode cannot run setup or doctor repairs") + && stderr.contains("use the admitted managed runtime"), + "doctor repair must require managed admission: {stderr}" + ); + assert!(output.stdout.is_empty(), "doctor repair must not start"); + assert_eq!(std::fs::read(®istry).unwrap(), registry_before); + assert_eq!(std::fs::read(&lock).unwrap(), lock_before); + assert_eq!(mode(®istry), 0o644); + assert_eq!(mode(&lock), 0o644); } diff --git a/crates/kin-cli/tests/repository_status.rs b/crates/kin-cli/tests/repository_status.rs index 255a1c345..342995f28 100644 --- a/crates/kin-cli/tests/repository_status.rs +++ b/crates/kin-cli/tests/repository_status.rs @@ -31,11 +31,14 @@ fn run_git(path: &Path, args: &[&str]) { } fn status_command(home: &Path) -> Command<'static> { + let kin_home = home.join("kin-home"); + std::fs::create_dir_all(&kin_home).expect("create isolated development Kin home"); let mut command = Command::new(env!("CARGO_BIN_EXE_kin")); command .env("HOME", home) - .env("KIN_HOME", home.join(".kin")) - .env("KIN_REGISTRY_PATH", home.join(".kin/registry.toml")) + .env("KIN_HOME", &kin_home) + .env("KIN_RUNTIME_MODE", "development") + .env("KIN_REGISTRY_PATH", kin_home.join("registry.toml")) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", kin_git::empty_global_git_config()) .env_remove("XDG_CONFIG_HOME") @@ -247,6 +250,10 @@ fn inspect_canonical_status(repo: &Path, home: &Path) -> kin_cli::commands::stat /// copy or the durable marker moves. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn default_status_admits_before_it_reads_and_names_a_refused_pass() { + use kin_core::runtime_identity::{ + RuntimeIdentity, RuntimeMode, RuntimeNamespace, RuntimeRepositoryBinding, + RuntimeServiceIdentity, + }; use std::sync::{Arc, Mutex}; let root = tempdir().unwrap(); let (repo, home) = seeded_status_repository(root.path()); @@ -272,19 +279,35 @@ async fn default_status_admits_before_it_reads_and_names_a_refused_pass() { "graph_loaded": true, "reconciliation_status": "idle", "repo_id": before.repository.repository_id, "repo_root": before.repo_root, }); + let namespace = RuntimeNamespace::capture(&home.join("kin-home")) + .expect("capture the fixture command's selected namespace"); + let repository = RuntimeRepositoryBinding::for_layout(&status_layout(&repo, &home)) + .expect("capture the fixture command's actual selected repository"); + let identity = serde_json::to_value(RuntimeServiceIdentity { + schema: "kin.runtime-service.v1".into(), + identity: RuntimeIdentity::current(), + mode: RuntimeMode::Development, + component: "daemon".into(), + pid: std::process::id(), + namespace: Some(namespace.token().to_owned()), + repository: Some(repository.identity().clone()), + }) + .unwrap(); let requests = Arc::new(Mutex::new(Vec::new())); let captured = Arc::clone(&requests); let app = axum::Router::new().fallback(axum::routing::any(move |method: axum::http::Method, uri: axum::http::Uri| { captured.lock().unwrap().push(format!("{method} {}", uri.path())); let response = response.clone(); let health = health.clone(); + let identity = identity.clone(); async move { match (method.as_str(), uri.path()) { + ("GET", "/runtime/identity") => (axum::http::StatusCode::OK, axum::Json(identity)), ("GET", "/readiness") => (axum::http::StatusCode::OK, axum::Json(serde_json::json!({"ready": true, "warming": false}))), ("GET", "/health") => (axum::http::StatusCode::OK, axum::Json(health)), ("POST", "/commands/status") => (axum::http::StatusCode::OK, axum::Json(response)), ("POST", "/commands/admit") => (axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({"error": "this fixture daemon refuses the pass on purpose"}))), - _ => (axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({"error": "this fixture daemon answers only readiness, health, admit and status"}))), + _ => (axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({"error": "this fixture daemon answers only runtime identity, readiness, health, admit and status"}))), } } })); @@ -334,6 +357,10 @@ async fn default_status_admits_before_it_reads_and_names_a_refused_pass() { } server.abort(); let seen = requests.lock().unwrap().clone(); + let identity_at = seen + .iter() + .position(|path| path == "GET /runtime/identity") + .unwrap_or_else(|| panic!("status never admitted the fixture runtime: {seen:?}")); let admitted_at = seen .iter() .position(|path| path == "POST /commands/admit") @@ -342,6 +369,10 @@ async fn default_status_admits_before_it_reads_and_names_a_refused_pass() { .iter() .position(|path| path == "POST /commands/status") .unwrap_or_else(|| panic!("status never read the report: {seen:?}")); + assert!( + identity_at < admitted_at, + "status requested admission before validating the fixture runtime: {seen:?}" + ); assert!( admitted_at < read_at, "status read the report before it admitted the working copy: {seen:?}" @@ -349,7 +380,11 @@ async fn default_status_admits_before_it_reads_and_names_a_refused_pass() { assert!( seen.iter().all(|path| matches!( path.as_str(), - "GET /health" | "GET /readiness" | "POST /commands/admit" | "POST /commands/status" + "GET /runtime/identity" + | "GET /health" + | "GET /readiness" + | "POST /commands/admit" + | "POST /commands/status" )), "{seen:?}" ); diff --git a/crates/kin-cli/tests/runtime_status.rs b/crates/kin-cli/tests/runtime_status.rs new file mode 100644 index 000000000..0c54691ff --- /dev/null +++ b/crates/kin-cli/tests/runtime_status.rs @@ -0,0 +1,440 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! The status contract is exercised across the real CLI process boundary. +#![cfg(unix)] + +use serde_json::{json, Value}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const SHA: &str = "1111111111111111111111111111111111111111"; + +struct Fixture { + root: tempfile::TempDir, + home: PathBuf, + working: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("managed"); + let working = root.path().join("repository"); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::create_dir_all(&working).unwrap(); + Self { + root, + home, + working, + } + } + + fn pair(&self, daemon_sha: &str, dirty: bool, known: bool) { + self.cli(dirty, known); + self.daemon(daemon_sha, dirty, known, "aarch64-apple-darwin", "release"); + } + + fn daemon(&self, sha: &str, dirty: bool, known: bool, target: &str, profile: &str) { + executable( + &self.home.join("bin/kin-daemon"), + &json!({ + "schema": "kin.daemon.compat.v2", "version": "0.8.6", + "build": { + "sha": sha, "dirty": dirty, "source_known": known, + "target": target, "build_profile": profile + } + }), + ); + } + + fn cli(&self, dirty: bool, known: bool) { + executable( + &self.home.join("bin/kin"), + &json!({ + "kin_version": "0.8.6", "kin_commit": SHA, + "kin_dirty": dirty, "kin_source_known": known, + "kin_target": "aarch64-apple-darwin", "kin_build_profile": "release" + }), + ); + } + + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_kin")); + command + .args(["runtime", "status", "--json"]) + .current_dir(&self.working) + .env("HOME", self.root.path()) + .env("KIN_HOME", &self.home) + .env_remove("KIN_DIR") + .env_remove("KIN_DAEMON_URL") + .env_remove("KIN_DAEMON_BIN") + .env_remove("KIN_RUNTIME_MODE"); + command + } + + fn status(&self) -> (bool, Value) { + let output = self.command().output().unwrap(); + let report = serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "status did not return JSON: {error}; stderr={}", + String::from_utf8_lossy(&output.stderr) + ) + }); + (output.status.success(), report) + } +} + +fn executable(path: &Path, payload: &Value) { + let payload = serde_json::to_string(payload).unwrap(); + std::fs::write(path, format!("#!/bin/sh\nprintf '%s\\n' '{payload}'\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[test] +fn runtime_status_admits_exact_pair_without_opening_repository() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, true); + std::fs::create_dir(fixture.working.join(".kin")).unwrap(); + std::fs::write(fixture.working.join(".kin/not-a-store"), "sentinel").unwrap(); + let (success, report) = fixture.status(); + assert!(success, "{report}"); + assert_eq!(report["schema"], "kin.runtime-status.v1"); + assert_eq!(report["mode"], "managed"); + assert_eq!(report["ready"], true); + assert_eq!(report["pair_matches"], true); + assert_eq!(report["release_kind"], "unknown"); + assert_eq!(report["channel"], Value::Null); + assert_eq!(report["cli"]["commit_sha"], SHA); + assert_eq!(report["cli"]["sha256"].as_str().unwrap().len(), 64); + assert_eq!(report["daemon"]["sha256"].as_str().unwrap().len(), 64); + assert_eq!( + std::fs::read_dir(fixture.working.join(".kin")) + .unwrap() + .count(), + 1 + ); +} + +#[test] +fn runtime_status_refuses_same_version_from_different_source() { + let fixture = Fixture::new(); + fixture.pair("2222222222222222222222222222222222222222", false, true); + let (success, report) = fixture.status(); + assert!(!success); + assert_eq!(report["ready"], false); + assert_eq!(report["pair_matches"], false); + assert_eq!(report["reason"], "pair_source_mismatch"); + assert_eq!(report["cli"]["version"], report["daemon"]["version"]); + assert_ne!(report["cli"]["commit_sha"], report["daemon"]["commit_sha"]); +} + +#[test] +fn runtime_status_refuses_target_and_profile_mismatch() { + let fixture = Fixture::new(); + fixture.cli(false, true); + fixture.daemon(SHA, false, true, "x86_64-apple-darwin", "release"); + let (success, report) = fixture.status(); + assert!(!success); + assert_eq!(report["reason"], "pair_source_mismatch"); + fixture.daemon(SHA, false, true, "aarch64-apple-darwin", "debug"); + let (success, report) = fixture.status(); + assert!(!success); + assert_eq!(report["pair_matches"], false); +} + +#[test] +fn runtime_status_development_home_is_deterministic_and_not_managed_home() { + let fixture = Fixture::new(); + fixture.pair(SHA, true, true); + let read = || { + let output = fixture + .command() + .arg("--development") + .arg("--binary") + .arg(fixture.home.join("bin/kin")) + .env_remove("KIN_HOME") + .output() + .unwrap(); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(output.status.success(), "{report}"); + PathBuf::from(report["kin_home"].as_str().unwrap()) + }; + let first = read(); + assert_eq!(first, read()); + assert_ne!(first, fixture.home); + assert!(first.to_string_lossy().contains("/.kin/development/")); + assert_eq!(first.file_name().unwrap().len(), 64); + assert!(!first.exists()); +} + +#[test] +fn runtime_status_development_cannot_reuse_default_managed_home() { + let fixture = Fixture::new(); + fixture.pair(SHA, true, true); + let output = fixture + .command() + .arg("--development") + .arg("--binary") + .arg(fixture.home.join("bin/kin")) + .env("KIN_HOME", fixture.root.path().join(".kin")) + .output() + .unwrap(); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(!output.status.success()); + assert_eq!(report["reason"], "development_home_conflict"); +} + +#[test] +fn runtime_status_refuses_unknown_source_and_dirty_managed_pair() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, false); + let (success, report) = fixture.status(); + assert!(!success); + assert_eq!(report["reason"], "source_identity_unknown"); + fixture.pair(SHA, true, true); + let (success, report) = fixture.status(); + assert!(!success); + assert_eq!(report["reason"], "managed_identity_refused"); +} + +#[test] +fn runtime_status_admits_explicit_isolated_dirty_development_pair() { + let fixture = Fixture::new(); + fixture.pair(SHA, true, true); + let output = fixture + .command() + .arg("--development") + .arg("--binary") + .arg(fixture.home.join("bin/kin")) + .env("KIN_HOME", fixture.root.path().join("isolated-development")) + .output() + .unwrap(); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(output.status.success(), "{report}"); + assert_eq!(report["mode"], "development"); + assert_eq!(report["release_kind"], "development"); + assert_eq!(report["pair_matches"], true); + assert_eq!(report["cli"]["source_dirty"], true); + assert!(!fixture.root.path().join("isolated-development").exists()); +} + +#[test] +fn runtime_status_reports_missing_daemon_as_complete_refusal_json() { + let fixture = Fixture::new(); + fixture.cli(false, true); + let (success, report) = fixture.status(); + assert!(!success); + assert_eq!(report["reason"], "daemon_probe_failed"); + assert_eq!(report["cli"]["commit_sha"], SHA); + assert!(report["daemon"]["path"] + .as_str() + .unwrap() + .ends_with("/bin/kin-daemon")); + assert_eq!(report["daemon"]["sha256"], Value::Null); +} + +#[test] +fn runtime_status_preserves_native_updater_restart_fence() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, true); + std::fs::write(fixture.home.join("update-restart-ack-required.json"), "{}").unwrap(); + let (success, report) = fixture.status(); + assert!(!success); + assert_eq!(report["pair_matches"], true); + assert_eq!(report["restart_needed"], true); + assert_eq!(report["reason"], "restart_required"); +} + +#[test] +fn runtime_status_honors_kin_home_before_kin_dir() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, true); + let output = fixture + .command() + .env("KIN_DIR", fixture.root.path().join("wrong-root")) + .output() + .unwrap(); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(output.status.success(), "{report}"); + assert_eq!( + PathBuf::from(report["kin_home"].as_str().unwrap()), + fixture.home.canonicalize().unwrap() + ); +} + +#[test] +fn runtime_status_ignores_empty_home_before_kin_dir() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, true); + let output = fixture + .command() + .env("KIN_HOME", "") + .env("KIN_DIR", &fixture.home) + .output() + .unwrap(); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(output.status.success(), "{report}"); + assert_eq!( + PathBuf::from(report["kin_home"].as_str().unwrap()), + fixture.home.canonicalize().unwrap() + ); +} + +#[test] +fn development_cli_refuses_global_setup_doctor_repair_and_update_mutations() { + let fixture = Fixture::new(); + for args in [ + vec!["setup"], + vec!["doctor", "--fix"], + vec!["doctor", "--reclaim-staging"], + vec!["setup", "doctor", "--install-language-servers"], + vec!["update", "--apply"], + vec!["update", "--cleanup-windows-worker", "unused-handoff.json"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_kin")) + .args(&args) + .env("HOME", fixture.root.path()) + .env("KIN_HOME", fixture.root.path().join("isolated-development")) + .env("KIN_RUNTIME_MODE", "development") + .env_remove("KIN_DIR") + .env_remove("KIN_DAEMON_URL") + .current_dir(&fixture.working) + .output() + .unwrap(); + assert!(!output.status.success(), "{args:?} unexpectedly succeeded"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("development mode cannot"), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + assert!(!fixture.root.path().join("isolated-development").exists()); + assert!(!fixture.root.path().join(".zshrc").exists()); + assert!(!fixture.root.path().join(".codex").exists()); +} + +#[test] +fn windows_worker_cleanup_refuses_combined_update_actions_before_dispatch() { + let fixture = Fixture::new(); + for extra in [ + vec!["--apply"], + vec!["--check-only"], + vec!["--ack-restart"], + vec!["--prepare-windows-worker"], + vec!["--windows-worker", "other-handoff.json"], + vec!["--set-policy", "auto"], + vec!["--channel", "stable"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_kin")) + .args(["update", "--cleanup-windows-worker", "unused-handoff.json"]) + .args(&extra) + .env("HOME", fixture.root.path()) + .env("KIN_HOME", fixture.root.path().join("isolated-development")) + .env("KIN_RUNTIME_MODE", "development") + .env_remove("KIN_DIR") + .env_remove("KIN_DAEMON_URL") + .current_dir(&fixture.working) + .output() + .unwrap(); + assert!(!output.status.success(), "{extra:?} unexpectedly succeeded"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("cannot be used with"), + "{extra:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + assert!(!fixture.root.path().join("isolated-development").exists()); +} + +#[test] +fn runtime_status_deadline_includes_descendant_inherited_output_pipes() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, true); + std::fs::write( + fixture.home.join("bin/kin"), + "#!/bin/sh\nsleep 60 &\nprintf '%s\\n' '{\"kin_version\":\"0.8.6\"}'\nexit 0\n", + ) + .unwrap(); + let started = std::time::Instant::now(); + let (success, report) = fixture.status(); + assert!(!success); + assert_eq!(report["reason"], "cli_probe_failed"); + assert!(report["detail"] + .as_str() + .unwrap() + .contains("output exceeded")); + assert!( + started.elapsed() < std::time::Duration::from_secs(20), + "capture waited for descendant instead of respecting its deadline" + ); +} + +#[test] +fn runtime_status_binary_override_requires_development() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, true); + let output = fixture + .command() + .arg("--binary") + .arg(fixture.home.join("bin/kin")) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--development")); +} + +#[test] +fn runtime_status_records_channel_without_claiming_published_release() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, true); + std::fs::write(fixture.home.join("update.toml"), "channel = \"alpha\"\n").unwrap(); + let (success, report) = fixture.status(); + assert!(success, "{report}"); + assert_eq!(report["channel"], "alpha"); + assert_eq!(report["release_kind"], "unknown"); +} + +#[test] +fn runtime_status_refuses_symbolic_link_managed_root() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, true); + let alias = fixture.root.path().join("managed-alias"); + std::os::unix::fs::symlink(&fixture.home, &alias).unwrap(); + let output = fixture.command().env("KIN_HOME", &alias).output().unwrap(); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(!output.status.success()); + assert_eq!(report["reason"], "managed_path_invalid"); + assert!(std::fs::symlink_metadata(alias) + .unwrap() + .file_type() + .is_symlink()); +} + +#[test] +fn runtime_status_refuses_managed_component_links_but_allows_explicit_development_alias() { + let fixture = Fixture::new(); + fixture.pair(SHA, false, true); + let real = fixture.home.join("bin/real-kin"); + let alias = fixture.home.join("bin/kin"); + std::fs::rename(&alias, &real).unwrap(); + std::os::unix::fs::symlink(&real, &alias).unwrap(); + let (success, report) = fixture.status(); + assert!(!success); + assert_eq!(report["reason"], "managed_path_invalid"); + let output = fixture + .command() + .arg("--development") + .arg("--binary") + .arg(&alias) + .env("KIN_HOME", fixture.root.path().join("isolated-development")) + .output() + .unwrap(); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(output.status.success(), "{report}"); + assert_eq!( + PathBuf::from(report["cli"]["path"].as_str().unwrap()), + real.canonicalize().unwrap() + ); +} diff --git a/crates/kin-cli/tests/setup_client_write_failure.rs b/crates/kin-cli/tests/setup_client_write_failure.rs index 266161d7c..16a840946 100644 --- a/crates/kin-cli/tests/setup_client_write_failure.rs +++ b/crates/kin-cli/tests/setup_client_write_failure.rs @@ -1,14 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Firelock, LLC -//! `kin setup` fails when a client it set out to configure could not be -//! configured. -//! -//! A failed client write used to be one red line in the middle of a long run -//! while setup still exited 0, so an install that lost its Claude Code entry -//! read as a success to the person and to every script that ran it. These drive -//! the real wizard, because the defect was in what the whole run reported rather -//! than in any one config writer. +//! Development setup refuses global client writes before inspecting or changing +//! client configuration. Release acceptance covers admitted Managed setup. use std::path::{Path, PathBuf}; use std::process::Output; @@ -16,15 +10,8 @@ use std::time::Duration; mod common; -/// Wall-clock cap for one scripted `kin setup` run, which takes seconds. const SETUP_TIMEOUT: Duration = Duration::from_secs(120); -/// An isolated home whose `~/.claude.json` holds `claude_config`, and a -/// directory outside any repository to run setup from. -/// -/// The file is Claude Code's own install evidence, so the client is detected -/// and setup tries to configure it on every host, whether or not Claude Code is -/// installed there. fn fixture(claude_config: &[u8]) -> (tempfile::TempDir, PathBuf, PathBuf) { let root = tempfile::tempdir().expect("setup root"); let home = root.path().join("home"); @@ -36,14 +23,7 @@ fn fixture(claude_config: &[u8]) -> (tempfile::TempDir, PathBuf, PathBuf) { } fn run_agent_setup(home: &Path, cwd: &Path) -> Output { - agent_setup_command(home, cwd) - .output_within(SETUP_TIMEOUT) - .expect("run kin setup --intent agent") -} - -fn agent_setup_command(home: &Path, cwd: &Path) -> common::Command<'static> { - let mut command = common::Command::new(env!("CARGO_BIN_EXE_kin")); - command + common::Command::new(env!("CARGO_BIN_EXE_kin")) .args([ "setup", "--no-interactive", @@ -55,133 +35,49 @@ fn agent_setup_command(home: &Path, cwd: &Path) -> common::Command<'static> { .current_dir(cwd) .env("HOME", home) .env("USERPROFILE", home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", home.join("kin-home")) + .env("KIN_RUNTIME_MODE", "development") .env("KIN_VFS_DISABLE", "1") .env("KIN_NO_DAEMON", "1") - .env("KIN_REGISTRY_PATH", home.join("registry.toml")) + .env("KIN_REGISTRY_PATH", home.join("kin-home/registry.toml")) .env_remove("KIN_DIR") .env_remove("KIN_DAEMON_URL") - .env_remove("KIN_MCP_REPO"); - command -} - -/// Put an inert `codex` first on the PATH `command` launches with, so Codex CLI -/// is detected on every host whether or not Codex is installed there. -/// -/// Two things have to be right, and the first version of this fixture got both -/// wrong without noticing, because it passed on a host with Codex installed and -/// failed on every runner without it. -/// -/// - Where the file lives. Detection asks whether the file is executable, and -/// the system temporary directory can sit on a filesystem mounted `noexec`, -/// where no file is. So it lives under Cargo's per-target scratch directory, -/// in the same tree the test binary is already executing from. -/// - How the directory reaches the child. Launch rebinds `PATH` to the host's, -/// so an explicit `PATH` never arrives, and the host's own `codex`, if it has -/// one, answered instead. [`common::Command::fixture_path_prefix`] is the -/// surface that survives the rebinding. -/// -/// The returned directory has to outlive the run. -#[cfg(unix)] -fn put_inert_codex_first_on_path( - command: &mut common::Command<'static>, - cwd: &Path, -) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt; - let bin = tempfile::Builder::new() - .prefix("codex-shim-") - .tempdir_in(env!("CARGO_TARGET_TMPDIR")) - .expect("create the shim directory under the target tree"); - let codex = bin.path().join("codex"); - std::fs::write(&codex, b"#!/bin/sh\nexit 0\n").expect("write inert codex"); - std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)) - .expect("make inert codex executable"); - command.fixture_path_prefix(bin.path()); - - // Resolve `codex` against the exact PATH the child will launch with, the - // way detection does, so a host's own Codex cannot pass this run for it. - command.prepare_for_launch_for_test(); - let launched_path = command - .configured_env_for_test(std::ffi::OsStr::new("PATH")) - .flatten() - .expect("launch preparation sets PATH"); - assert_eq!( - which::which_in("codex", Some(&launched_path), cwd).ok(), - Some(codex), - "the launched PATH must resolve codex to the fixture's shim: {launched_path:?}" - ); - bin + .env_remove("KIN_MCP_REPO") + .output_within(SETUP_TIMEOUT) + .expect("run development setup") } -#[test] -fn setup_fails_and_names_a_detected_client_whose_config_it_could_not_write() { - // Present, so Claude Code is detected, and not JSON, so Kin refuses to merge - // its entry in rather than overwrite a file it cannot read. - let unmergeable: &[u8] = b"{ this is not json\n"; - let (_root, home, cwd) = fixture(unmergeable); - - let output = run_agent_setup(&home, &cwd); +fn assert_refused_without_writes(output: &Output, home: &Path, original: &[u8]) { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( !output.status.success(), - "setup reported success though Claude Code was not configured:\n\ - stdout:\n{stdout}\nstderr:\n{stderr}" - ); - assert!( - stderr.contains("kin setup could not configure Claude Code") - && stderr.contains("not valid JSON"), - "the run must end naming the client and the reason:\nstderr:\n{stderr}" - ); - assert_eq!( - std::fs::read(home.join(".claude.json")).expect("read Claude Code config"), - unmergeable, - "a config Kin refused to merge into must be left exactly as it was" - ); - // The failure is the last word, not the only one: the rest of the run - // still happened before it. - assert!( - stdout.contains("=== Health checklist ==="), - "setup stopped before finishing the rest of the run:\n{stdout}" + "stdout:\n{stdout}\nstderr:\n{stderr}" ); + assert!(stderr.contains("development mode cannot"), "{stderr}"); + assert!(!stdout.contains("=== Health checklist ==="), "{stdout}"); + assert_eq!(std::fs::read(home.join(".claude.json")).unwrap(), original); + for untouched in ["kin-home", ".kin", ".zshrc", ".zshenv", ".codex", ".cursor"] { + assert!(!home.join(untouched).exists(), "setup wrote {untouched}"); + } } -/// The control for the case above. The same run with a config Kin can merge -/// into succeeds, so the failure there is the unwritable config and nothing -/// else about the fixture. -/// -/// On Unix the run also carries a deferral: an inert `codex` on `PATH` makes -/// Codex CLI detected, and its entry has to name a repository that does not -/// exist yet. That is the ordinary order of a first install, so it must not -/// fail setup, and the install scripts depend on exactly that. #[test] -fn setup_succeeds_when_the_same_client_config_can_be_written() { - let (_root, home, cwd) = fixture(b"{}"); - let mut command = agent_setup_command(&home, &cwd); - #[cfg(unix)] - let _codex_shim = put_inert_codex_first_on_path(&mut command, &cwd); - - let output = command - .output_within(SETUP_TIMEOUT) - .expect("run kin setup --intent agent"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - output.status.success(), - "setup failed with a writable Claude Code config:\nstdout:\n{stdout}\nstderr:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - if cfg!(unix) { - assert!( - stdout.contains("Codex CLI is not configured yet"), - "the run carried no deferral, so it proves nothing about one:\n{stdout}" - ); - } - let written: serde_json::Value = serde_json::from_slice( - &std::fs::read(home.join(".claude.json")).expect("read Claude Code config"), - ) - .expect("Claude Code config is JSON"); +fn development_setup_leaves_an_invalid_client_config_unchanged() { + let original = b"{ this is not json\n"; + let (_root, home, cwd) = fixture(original); + let output = run_agent_setup(&home, &cwd); + assert_refused_without_writes(&output, &home, original); assert!( - written["mcpServers"]["kin"].is_object(), - "setup succeeded without writing the Kin entry: {written}" + !String::from_utf8_lossy(&output.stderr).contains("not valid JSON"), + "Development admission must refuse before a client write is attempted" ); } + +#[test] +fn development_setup_also_refuses_a_writable_client_config() { + let original = b"{\"mcpServers\": {}, \"keep\": true}\n"; + let (_root, home, cwd) = fixture(original); + let output = run_agent_setup(&home, &cwd); + assert_refused_without_writes(&output, &home, original); +} diff --git a/crates/kin-cli/tests/setup_editor_guidance.rs b/crates/kin-cli/tests/setup_editor_guidance.rs index 38ebe5d67..7ef2a71f5 100644 --- a/crates/kin-cli/tests/setup_editor_guidance.rs +++ b/crates/kin-cli/tests/setup_editor_guidance.rs @@ -1,12 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Firelock, LLC -//! `kin setup --intent editor` describes the extension state consistently. -//! -//! The public installer can enter setup with kin-editor already installed. -//! This drives the whole captured command because the contradictory guidance -//! used to span two independently correct sections: the plan recommended an -//! install before the follow-up and health checklist reported it installed. +//! Editor intent cannot bypass Development's refusal of global setup writes. +//! Managed release acceptance checks installed and missing extension guidance. use std::path::Path; use std::time::Duration; @@ -15,7 +11,31 @@ mod common; const SETUP_TIMEOUT: Duration = Duration::from_secs(120); -fn run_editor_setup(home: &Path, cwd: &Path) -> String { +#[test] +fn development_editor_setup_refuses_with_or_without_an_installed_extension() { + for installed in [false, true] { + let root = tempfile::tempdir().expect("editor setup root"); + let home = root.path().join("home"); + let cwd = root.path().join("outside-repository"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + let manifest = home.join(".vscode/extensions/firelock.kin-editor-test/package.json"); + let original = + b"{\"publisher\":\"firelock\",\"name\":\"kin-editor\",\"version\":\"0.0.0-test\"}"; + if installed { + std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); + std::fs::write(&manifest, original).unwrap(); + } + run_editor_setup(&home, &cwd); + if installed { + assert_eq!(std::fs::read(&manifest).unwrap(), original); + } else { + assert!(!home.join(".vscode").exists()); + } + } +} + +fn run_editor_setup(home: &Path, cwd: &Path) { let output = common::Command::new(env!("CARGO_BIN_EXE_kin")) .args([ "setup", @@ -27,76 +47,33 @@ fn run_editor_setup(home: &Path, cwd: &Path) -> String { .current_dir(cwd) .env("HOME", home) .env("USERPROFILE", home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", home.join("kin-home")) + .env("KIN_RUNTIME_MODE", "development") .env("KIN_VFS_DISABLE", "1") .env("KIN_NO_DAEMON", "1") - .env("KIN_REGISTRY_PATH", home.join("registry.toml")) + .env("KIN_REGISTRY_PATH", home.join("kin-home/registry.toml")) .env_remove("KIN_DIR") .env_remove("KIN_DAEMON_URL") + .env_remove("KIN_MCP_REPO") .output_within(SETUP_TIMEOUT) - .expect("run kin setup --intent editor"); - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - assert!( - output.status.success(), - "editor setup failed: stdout:\n{stdout}\nstderr:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - stdout -} - -#[test] -fn full_setup_output_recommends_install_only_when_the_editor_extension_is_missing() { - let installed_root = tempfile::tempdir().expect("installed setup root"); - let installed_home = installed_root.path().join("home"); - let installed_cwd = installed_root.path().join("outside-repository"); - let installed_extension = installed_home - .join(".vscode") - .join("extensions") - .join("firelock.kin-editor-test"); - std::fs::create_dir_all(&installed_extension) - .expect("seed version-neutral extension directory"); - std::fs::write( - installed_extension.join("package.json"), - r#"{"publisher":"firelock","name":"kin-editor","version":"0.0.0-test"}"#, - ) - .expect("seed official extension manifest"); - std::fs::create_dir_all(&installed_cwd).expect("create installed cwd"); - - let installed = run_editor_setup(&installed_home, &installed_cwd); - let installed_lower = installed.to_lowercase(); - assert!( - installed_lower.contains("kin-editor extension already installed") - && installed_lower.contains("kin-editor is already installed"), - "the full setup output did not acknowledge the detected extension:\n{installed}" - ); - assert!( - !installed_lower.contains("install the kin-editor"), - "the full setup output recommended an extension that is already installed:\n{installed}" - ); - - let missing_root = tempfile::tempdir().expect("missing setup root"); - let missing_home = missing_root.path().join("home"); - let missing_cwd = missing_root.path().join("outside-repository"); - std::fs::create_dir_all(&missing_home).expect("create missing home"); - std::fs::create_dir_all(&missing_cwd).expect("create missing cwd"); - - let missing = run_editor_setup(&missing_home, &missing_cwd); - let missing_lower = missing.to_lowercase(); - assert!( - missing_lower.contains("plan: editor (vs code + kin-editor)") - && missing_lower.contains("plus how to install the kin-editor extension"), - "the missing-extension plan stopped describing its install path:\n{missing}" - ); - let editor_followup = missing - .split_once("Editor extension:") - .map(|(_, after_heading)| after_heading) - .and_then(|after_heading| after_heading.split_once("=== Health checklist ===")) - .map(|(section, _)| section) - .unwrap_or_else(|| panic!("setup printed no bounded editor follow-up section:\n{missing}")); + .expect("run development editor setup"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - editor_followup.contains("Install the kin-editor VS Code extension for the entity explorer,") - && editor_followup - .contains("semantic search, and trace surfaces. See the kin-editor README."), - "the missing-extension editor follow-up lost its actionable install guidance:\n{editor_followup}" + !output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" ); + assert!(stderr.contains("development mode cannot"), "{stderr}"); + assert!(!stdout.contains("Kin is set up on"), "{stdout}"); + for untouched in [ + "kin-home", + ".kin", + ".zshrc", + ".zshenv", + ".claude.json", + ".codex", + ".cursor", + ] { + assert!(!home.join(untouched).exists(), "setup wrote {untouched}"); + } } diff --git a/crates/kin-cli/tests/setup_mcp_round_trip.rs b/crates/kin-cli/tests/setup_mcp_round_trip.rs index 362c2137e..4ade84592 100644 --- a/crates/kin-cli/tests/setup_mcp_round_trip.rs +++ b/crates/kin-cli/tests/setup_mcp_round_trip.rs @@ -1,45 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Firelock, LLC -//! `kin setup` proves each client's MCP entry before claiming it works -//! (FIR-1882). -//! -//! Setup used to finish at the file level: the right JSON landed in the right -//! config and setup reported the client configured. These tests drive the real -//! binary through the real wizard and read the section it prints, because the -//! defect this closes is invisible at the file level by construction. A -//! recorded launcher that cannot run leaves a config file that parses, matches -//! its ledger fingerprint, and points at nothing. +//! Development setup cannot write client registrations or claim an MCP round +//! trip. Admitted Managed transports are exercised by release acceptance. use std::path::{Path, PathBuf}; +use std::process::Output; use std::time::Duration; mod common; -/// Wall-clock cap for one scripted `kin setup` run. -/// -/// Setup itself is a couple of seconds; the round trip adds one launch per -/// configured client, each bounded by the check's own budget. This is well -/// above both and far below a run that is not going to finish. -const SETUP_TIMEOUT: Duration = Duration::from_secs(240); - -fn kin_command(home: &Path) -> common::Command<'static> { - let mut command = common::Command::new(env!("CARGO_BIN_EXE_kin")); - command - .env("HOME", home) - .env("USERPROFILE", home) - .env("XDG_CONFIG_HOME", home.join(".config")) - .env("KIN_HOME", home.join(".kin")) - .env("KIN_REGISTRY_PATH", home.join("registry.toml")) - .env("KIN_VFS_DISABLE", "1") - .env("KIN_NO_DAEMON", "1") - .env_remove("KIN_STORE") - .env_remove("KIN_STORE_LAYOUT") - .env_remove("KIN_DIR") - .env_remove("KIN_DAEMON_URL") - .env_remove("KIN_MCP_REPO"); - command -} +const SETUP_TIMEOUT: Duration = Duration::from_secs(120); struct Fixture { _root: tempfile::TempDir, @@ -49,37 +20,16 @@ struct Fixture { } impl Fixture { - /// An isolated home with one initialized repository and one directory that - /// is not a repository. - /// - /// `~/.claude.json` is seeded because that is Claude Code's own install - /// evidence: without it, a runner with no AI client installed configures - /// nothing, and a test that asserts on a client section would pass by - /// asserting on an empty one. fn new() -> Self { - let root = tempfile::tempdir().expect("temp root"); + let root = tempfile::tempdir().expect("setup root"); let home = root.path().join("home"); - std::fs::create_dir_all(&home).expect("create home"); - std::fs::write(home.join(".claude.json"), "{}").expect("seed Claude Code evidence"); - let repo = root.path().join("repo"); - std::fs::create_dir_all(&repo).expect("create repo dir"); - let init = kin_command(&home) - .arg("init") - .arg(&repo) - .output_within(SETUP_TIMEOUT) - .expect("kin init"); - assert!( - init.status.success(), - "kin init failed: {}", - String::from_utf8_lossy(&init.stderr) - ); - let layout = common::repository_layout(kin_command(&home), &repo); - assert!(layout.is_external(), "init uses the private central store"); - - let plain = root.path().join("not-a-repo"); - std::fs::create_dir_all(&plain).expect("create plain dir"); - + let plain = root.path().join("outside-repository"); + for path in [&home, &repo, &plain] { + std::fs::create_dir_all(path).unwrap(); + } + std::fs::write(home.join(".claude.json"), b"{}").unwrap(); + std::fs::write(repo.join("sentinel.txt"), b"repository stays unchanged\n").unwrap(); Self { _root: root, home, @@ -88,14 +38,8 @@ impl Fixture { } } - /// Run the scripted agent-intent wizard from `dir`. - /// - /// `KIN_NO_DAEMON` is set for the same reason the MCP stdio contract tests - /// set it: it guarantees that nothing this test starts can spawn or find a - /// repo daemon, so the tool call is answered by the transport under test - /// rather than by whatever daemon happens to be running on the machine. - fn setup(&self, dir: &Path, extra: &[&str]) -> String { - let output = kin_command(&self.home) + fn setup(&self, dir: &Path, extra: &[&str]) -> Output { + common::Command::new(env!("CARGO_BIN_EXE_kin")) .args([ "setup", "--no-interactive", @@ -105,139 +49,95 @@ impl Fixture { ]) .args(extra) .current_dir(dir) + .env("HOME", &self.home) + .env("USERPROFILE", &self.home) + .env("XDG_CONFIG_HOME", self.home.join(".config")) + .env("KIN_HOME", self.home.join("kin-home")) + .env("KIN_RUNTIME_MODE", "development") + .env( + "KIN_REGISTRY_PATH", + self.home.join("kin-home/registry.toml"), + ) + .env("KIN_VFS_DISABLE", "1") + .env("KIN_NO_DAEMON", "1") .env("KIN_MCP_SCAN_ROOT", dir) + .env_remove("KIN_STORE") + .env_remove("KIN_STORE_LAYOUT") + .env_remove("KIN_DIR") + .env_remove("KIN_DAEMON_URL") + .env_remove("KIN_MCP_REPO") .output_within(SETUP_TIMEOUT) - .expect("kin setup"); - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - // The verification is reported, never fatal: the config it exercised - // may be exactly right and the machine simply not ready. + .expect("run development setup") + } + + fn assert_refused_without_writes(&self, output: &Output) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - output.status.success(), - "kin setup exited with {}; stdout:\n{stdout}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stderr) + !output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stderr.contains("development mode cannot"), "{stderr}"); + assert!(!stdout.contains("the entry answered"), "{stdout}"); + assert!(!stdout.contains("the round trip failed at"), "{stdout}"); + assert!(!stdout.contains("Kin is set up on"), "{stdout}"); + assert_eq!( + std::fs::read(self.home.join(".claude.json")).unwrap(), + b"{}" ); - stdout + assert_eq!( + std::fs::read(self.repo.join("sentinel.txt")).unwrap(), + b"repository stays unchanged\n" + ); + for untouched in [ + ".kin", + ".zshrc", + ".zshenv", + ".codex", + ".cursor", + "kin-home/config", + "kin-home/registry.toml", + "kin-home/services", + ] { + assert!( + !self.home.join(untouched).exists(), + "setup wrote {untouched}" + ); + } + assert!(!self.repo.join(".kin").exists()); } } -/// The line the round-trip section printed for one client. -fn round_trip_line(stdout: &str, client: &str) -> String { - assert!( - stdout.contains("MCP round trip"), - "setup printed no round-trip section:\n{stdout}" - ); - let needle = format!("{client}: "); - stdout - .lines() - .skip_while(|line| !line.contains("MCP round trip")) - .find(|line| line.contains(&needle)) - .unwrap_or_else(|| panic!("no round-trip line for {client}:\n{stdout}")) - .trim() - .to_string() -} - -/// The whole point: the entry setup wrote is launched, handshakes, serves its -/// tool list, and answers one real call, and what it answered is reported. -/// -/// With no daemon reachable the answer is that none is serving, and that answer -/// IS the proof. It is read off the `_kin` degraded flag on a real tool result, -/// which nothing but a completed spawn, `initialize`, `tools/list` and -/// `tools/call` could have produced. #[test] -fn setup_reports_what_the_configured_client_s_own_server_answered() { +fn development_setup_cannot_write_a_client_entry_or_claim_a_round_trip() { let fixture = Fixture::new(); - let stdout = fixture.setup(&fixture.repo, &[]); - let line = round_trip_line(&stdout, "Claude Code"); - assert!( - line.contains("the entry answered"), - "the recorded entry did not complete a round trip: {line}\n\n{stdout}" - ); - assert!( - line.contains("status reports"), - "the round trip must name the tool it called: {line}" - ); - assert!( - line.contains("no repo daemon is serving"), - "the tool's own answer must reach the report: {line}" - ); - assert!( - !stdout.contains("were not shown to work"), - "a working entry with no daemon behind it is not an unproven entry:\n{stdout}" - ); + fixture.assert_refused_without_writes(&fixture.setup(&fixture.repo, &[])); + assert!(!fixture.home.join("kin-home").exists()); } -/// The failure mode the ticket was filed for, end to end: a recorded launcher -/// that a version upgrade moved out from under the config. -/// -/// `$KIN_HOME/bin/kin` is what a managed install owns and what setup records -/// when it exists, so a file that is present, regular and executable but is not -/// a Kin binary reproduces the brew-upgrade shape exactly. The config file it -/// produces is perfectly well formed, which is why nothing short of launching -/// it can tell. #[test] -fn setup_refuses_to_claim_a_client_whose_recorded_launcher_cannot_run() { +fn development_setup_leaves_a_broken_selected_launcher_unchanged() { let fixture = Fixture::new(); - let bin = fixture.home.join(".kin").join("bin"); - std::fs::create_dir_all(&bin).expect("create the managed bin directory"); + let bin = fixture.home.join("kin-home/bin"); + std::fs::create_dir_all(&bin).unwrap(); let launcher = bin.join(if cfg!(windows) { "kin.exe" } else { "kin" }); - std::fs::write(&launcher, b"this is not a Kin binary\n").expect("write the broken launcher"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)) - .expect("make the broken launcher executable"); - } - - let stdout = fixture.setup(&fixture.repo, &[]); - let line = round_trip_line(&stdout, "Claude Code"); - assert!( - line.contains("the round trip failed at"), - "a launcher that cannot run must be reported as a failure: {line}\n\n{stdout}" - ); - assert!( - !line.contains("declined to answer") && !line.contains("status answered"), - "nothing may be claimed for a client whose entry never answered: {line}" - ); - assert!( - line.contains(&launcher.to_string_lossy().into_owned()), - "the failure must name the launcher it could not run: {line}" - ); - assert!( - stdout.contains("were not shown to work"), - "setup must say the entries it wrote were not proven:\n{stdout}" - ); + let original = b"this is not a Kin binary\n"; + std::fs::write(&launcher, original).unwrap(); + fixture.assert_refused_without_writes(&fixture.setup(&fixture.repo, &[])); + assert_eq!(std::fs::read(&launcher).unwrap(), original); + assert_eq!(std::fs::read_dir(&bin).unwrap().count(), 1); } -/// A scripted install may genuinely have no repository yet. The skip is -/// printed per client, because a section that simply disappeared would read -/// exactly like a run that proved something. #[test] -fn the_check_is_skippable_and_the_skip_is_printed() { +fn skipping_the_mcp_check_cannot_bypass_development_setup_refusal() { let fixture = Fixture::new(); - let stdout = fixture.setup(&fixture.repo, &["--skip-mcp-check"]); - let line = round_trip_line(&stdout, "Claude Code"); - assert!( - line.contains("not exercised") && line.contains("--skip-mcp-check"), - "a skipped check must name itself: {line}\n\n{stdout}" - ); + fixture.assert_refused_without_writes(&fixture.setup(&fixture.repo, &["--skip-mcp-check"])); + assert!(!fixture.home.join("kin-home").exists()); } -/// `kin mcp start` resolves its repository from the working directory, so a -/// setup run outside one has nothing for a tool call to answer about. That is -/// not a broken config, and reporting it as one would teach a stranger to -/// distrust a check that was right. #[test] -fn a_setup_run_outside_a_repository_degrades_instead_of_reporting_a_failure() { +fn running_outside_a_repository_cannot_bypass_development_setup_refusal() { let fixture = Fixture::new(); - let stdout = fixture.setup(&fixture.plain, &[]); - let line = round_trip_line(&stdout, "Claude Code"); - assert!( - line.contains("not exercised") && line.contains("no initialized Kin repository"), - "a run with no repository must degrade honestly: {line}\n\n{stdout}" - ); - assert!( - !line.contains("failed at"), - "a missing repository is not a broken config: {line}" - ); + fixture.assert_refused_without_writes(&fixture.setup(&fixture.plain, &[])); + assert!(!fixture.home.join("kin-home").exists()); } diff --git a/crates/kin-cli/tests/setup_plain_summary.rs b/crates/kin-cli/tests/setup_plain_summary.rs index 7e3d72f69..b3d9b6ccb 100644 --- a/crates/kin-cli/tests/setup_plain_summary.rs +++ b/crates/kin-cli/tests/setup_plain_summary.rs @@ -1,14 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Firelock, LLC -//! `kin setup` without a terminal prints the short plain summary, and -//! `--verbose` the full record. -//! -//! An installer with no terminal, a CI log and a pipe used to get the full -//! record by default: over a hundred and fifty lines of every step and every -//! health check, in Kin's internal terms. They now get the summary a person at -//! a terminal gets, without the live line, and the full record stays one flag -//! away for the scripts and checks that read it. +//! Development setup never prints a successful wizard summary or writes global +//! setup state. Release acceptance grades the Managed short and verbose output. use std::path::Path; use std::process::Output; @@ -16,12 +10,10 @@ use std::time::Duration; mod common; -/// Wall-clock cap for one scripted `kin setup` run, which takes seconds. const SETUP_TIMEOUT: Duration = Duration::from_secs(120); fn run_setup(home: &Path, cwd: &Path, extra: &[&str]) -> Output { - let mut command = common::Command::new(env!("CARGO_BIN_EXE_kin")); - command + common::Command::new(env!("CARGO_BIN_EXE_kin")) .args([ "setup", "--no-interactive", @@ -35,97 +27,77 @@ fn run_setup(home: &Path, cwd: &Path, extra: &[&str]) -> Output { .current_dir(cwd) .env("HOME", home) .env("USERPROFILE", home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", home.join("kin-home")) + .env("KIN_RUNTIME_MODE", "development") .env("KIN_VFS_DISABLE", "1") .env("KIN_NO_DAEMON", "1") - .env("KIN_REGISTRY_PATH", home.join("registry.toml")) + .env("KIN_REGISTRY_PATH", home.join("kin-home/registry.toml")) .env_remove("KIN_DIR") .env_remove("KIN_DAEMON_URL") - .env_remove("KIN_MCP_REPO"); - command.output_within(SETUP_TIMEOUT).expect("run kin setup") + .env_remove("KIN_MCP_REPO") + .output_within(SETUP_TIMEOUT) + .expect("run development setup") +} + +fn assert_development_refusal(output: &Output, home: &Path) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stderr.contains("development mode cannot"), "{stderr}"); + assert!(!stdout.contains("Kin is set up on"), "{stdout}"); + assert!(!stdout.contains("=== Health checklist ==="), "{stdout}"); + for untouched in [ + "kin-home", + ".kin", + ".zshrc", + ".zshenv", + ".codex", + ".claude.json", + ] { + assert!(!home.join(untouched).exists(), "setup wrote {untouched}"); + } } #[test] -fn setup_without_a_terminal_prints_the_short_summary_and_verbose_the_full_record() { +fn development_setup_refuses_both_short_and_verbose_wizard_runs() { let root = tempfile::tempdir().expect("setup root"); let home = root.path().join("home"); let cwd = root.path().join("outside-repository"); - std::fs::create_dir_all(&home).expect("create home"); - std::fs::create_dir_all(&cwd).expect("create cwd"); - - let short = run_setup(&home, &cwd, &[]); - let stdout = String::from_utf8_lossy(&short.stdout); - let stderr = String::from_utf8_lossy(&short.stderr); - assert!( - short.status.success(), - "setup failed:\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - assert!( - stdout.contains("This run asks nothing and takes the defaults."), - "the summary says up front that nothing is asked:\n{stdout}" - ); - assert!( - stdout.contains("Kin is set up on"), - "the summary ends on its verdict:\n{stdout}" - ); - assert!( - stdout.contains("taken without asking; kin setup --verbose lists each one"), - "the summary counts the defaults it took and says where they are listed:\n{stdout}" - ); - assert!( - !stdout.contains("=== Health checklist ===") && !stdout.contains("Hardware check"), - "the full record stays behind --verbose:\n{stdout}" - ); - assert!( - !stdout.contains('\u{1b}'), - "a pipe gets no escape codes:\n{stdout}" - ); - assert!( - stdout.lines().count() <= 30, - "the summary is short: {} lines\n{stdout}", - stdout.lines().count() - ); - - let full = run_setup(&home, &cwd, &["--verbose"]); - let stdout = String::from_utf8_lossy(&full.stdout); - assert!(full.status.success(), "verbose setup failed:\n{stdout}"); - assert!( - stdout.contains("=== Health checklist ===") - && stdout.contains("This run was not interactive, so it answered these for you:"), - "--verbose prints the full record with every decision it took:\n{stdout}" - ); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + for extra in [&[][..], &["--verbose"][..]] { + assert_development_refusal(&run_setup(&home, &cwd, extra), &home); + } } -/// `--mode` changed nothing for a long time. It stays accepted so a script -/// that passes it keeps running, and says it is ignored rather than staying -/// silent. #[test] -fn setup_says_the_mode_flag_is_ignored() { +fn a_legacy_mode_flag_cannot_enable_development_setup_and_help_remains_available() { let root = tempfile::tempdir().expect("setup root"); let home = root.path().join("home"); let cwd = root.path().join("outside-repository"); - std::fs::create_dir_all(&home).expect("create home"); - std::fs::create_dir_all(&cwd).expect("create cwd"); - - let output = run_setup(&home, &cwd, &["--mode", "native"]); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "--mode must not fail a run:\n{stderr}" - ); - assert!( - stderr.contains("--mode is ignored"), - "the flag says it is ignored:\n{stderr}" - ); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + assert_development_refusal(&run_setup(&home, &cwd, &["--mode", "native"]), &home); - let help = common::Command::new(env!("CARGO_BIN_EXE_kin")) + let output = common::Command::new(env!("CARGO_BIN_EXE_kin")) .args(["setup", "--help"]) + .current_dir(&cwd) .env("HOME", &home) + .env("USERPROFILE", &home) + .env("KIN_HOME", home.join("kin-home")) + .env("KIN_RUNTIME_MODE", "development") + .env_remove("KIN_DIR") .output_within(SETUP_TIMEOUT) - .expect("run kin setup --help"); - let help = String::from_utf8_lossy(&help.stdout); + .expect("run setup help"); + let help = String::from_utf8_lossy(&output.stdout); assert!( - !help.contains("--mode"), - "the help no longer offers it:\n{help}" + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) ); + assert!(!help.contains("--mode"), "{help}"); + assert!(!home.join("kin-home").exists()); } diff --git a/crates/kin-cli/tests/setup_shell_profile_failure.rs b/crates/kin-cli/tests/setup_shell_profile_failure.rs index be6016111..844befd19 100644 --- a/crates/kin-cli/tests/setup_shell_profile_failure.rs +++ b/crates/kin-cli/tests/setup_shell_profile_failure.rs @@ -1,15 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Firelock, LLC -//! `kin setup` finishes configuring clients when it cannot write a shell -//! profile. -//! -//! The shell-profile step used to run first and return its first refused -//! write, so on a machine whose dotfiles are read-only, which is what Nix -//! home-manager does by linking them out of a read-only store, setup ended -//! before any AI client was configured. These drive the real wizard in an -//! isolated home, because the defect was in how the whole run was ordered and -//! what it reported, not in any one writer. +//! Development setup leaves shell profiles and client configurations untouched, +//! including profiles that Managed release acceptance exercises as write failures. use std::path::{Path, PathBuf}; use std::process::Output; @@ -17,12 +10,8 @@ use std::time::Duration; mod common; -/// Wall-clock cap for one scripted `kin setup` run, which takes seconds. const SETUP_TIMEOUT: Duration = Duration::from_secs(120); -/// An isolated home that Claude Code is detected in, with a managed bin -/// directory so the PATH line is part of the plan, and a directory outside any -/// repository to run setup from. struct Fixture { _root: tempfile::TempDir, home: PathBuf, @@ -33,9 +22,9 @@ fn fixture() -> Fixture { let root = tempfile::tempdir().expect("setup root"); let home = root.path().join("home"); let cwd = root.path().join("outside-repository"); - std::fs::create_dir_all(home.join(".kin").join("bin")).expect("create managed bin"); - std::fs::create_dir_all(&cwd).expect("create cwd"); - std::fs::write(home.join(".claude.json"), b"{}").expect("seed Claude Code config"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write(home.join(".claude.json"), b"{}").unwrap(); Fixture { _root: root, home, @@ -58,150 +47,62 @@ fn run_agent_setup(home: &Path, cwd: &Path) -> Output { .current_dir(cwd) .env("HOME", home) .env("USERPROFILE", home) - .env("KIN_HOME", home.join(".kin")) + .env("KIN_HOME", home.join("kin-home")) + .env("KIN_RUNTIME_MODE", "development") .env("KIN_VFS_DISABLE", "1") .env("KIN_NO_DAEMON", "1") - .env("KIN_REGISTRY_PATH", home.join("registry.toml")) + .env("KIN_REGISTRY_PATH", home.join("kin-home/registry.toml")) .env_remove("KIN_DIR") .env_remove("KIN_DAEMON_URL") .env_remove("KIN_MCP_REPO") .output_within(SETUP_TIMEOUT) - .expect("run kin setup --intent agent") + .expect("run development setup") } -fn claude_has_kin_entry(home: &Path) -> bool { - let written: serde_json::Value = serde_json::from_slice( - &std::fs::read(home.join(".claude.json")).expect("read Claude Code config"), - ) - .expect("Claude Code config is JSON"); - written["mcpServers"]["kin"].is_object() +fn assert_development_refusal(output: &Output, home: &Path) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stderr.contains("development mode cannot"), "{stderr}"); + assert!(!stdout.contains("AI client MCP configuration:"), "{stdout}"); + assert_eq!(std::fs::read(home.join(".claude.json")).unwrap(), b"{}"); + for untouched in ["kin-home", ".kin", ".zshenv", ".codex", ".cursor"] { + assert!(!home.join(untouched).exists(), "setup wrote {untouched}"); + } } -/// A read-only `.zshrc`, linked the way home-manager links one out of its -/// store. The client is still configured, the refusal is named with the file -/// and the reason, the PATH line still lands in the writable `.zshenv`, and the -/// block that could not be written is printed for the person to add. #[cfg(unix)] #[test] -fn a_read_only_shell_profile_still_leaves_the_clients_configured() { - use std::os::unix::fs::PermissionsExt as _; - +fn development_setup_preserves_a_read_only_symlinked_shell_profile() { + use std::os::unix::fs::PermissionsExt; let fixture = fixture(); let store = fixture.home.join("store"); - std::fs::create_dir_all(&store).expect("create store"); + std::fs::create_dir(&store).unwrap(); let managed = store.join("zshrc"); - let managed_text = "# managed by home-manager\n"; - std::fs::write(&managed, managed_text).expect("write managed zshrc"); - std::fs::set_permissions(&managed, std::fs::Permissions::from_mode(0o444)) - .expect("make managed zshrc read-only"); - std::os::unix::fs::symlink(&managed, fixture.home.join(".zshrc")).expect("link .zshrc"); - if std::fs::OpenOptions::new() - .append(true) - .open(&managed) - .is_ok() - { - eprintln!("skipped: this user can write a mode 0444 file, so no refusal exists"); - return; - } + let original = b"# managed shell profile\n"; + std::fs::write(&managed, original).unwrap(); + std::fs::set_permissions(&managed, std::fs::Permissions::from_mode(0o444)).unwrap(); + let profile = fixture.home.join(".zshrc"); + std::os::unix::fs::symlink(&managed, &profile).unwrap(); - let output = run_agent_setup(&fixture.home, &fixture.cwd); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - output.status.success(), - "a refused shell profile must not fail a run whose client was configured:\n\ - stdout:\n{stdout}\nstderr:\n{stderr}" - ); - assert!( - claude_has_kin_entry(&fixture.home), - "Claude Code was not configured:\nstdout:\n{stdout}" - ); - let zshrc = fixture.home.join(".zshrc"); - assert!( - stdout.contains(&format!( - "Shell integration was not written: failed to update {}", - zshrc.display() - )), - "the refusal must name the file:\n{stdout}" - ); - assert!( - stdout.contains("Permission denied"), - "the refusal must carry the reason:\n{stdout}" - ); - let by_hand = stdout - .find("To finish it by hand") - .expect("the closing summary must say what to add by hand"); - let tail = &stdout[by_hand..]; - assert!( - tail.contains(&format!("to {}:", zshrc.display())) && tail.contains("source "), - "the missing hook line must be printed against .zshrc:\n{tail}" - ); - assert!( - !tail.contains(".zshenv"), - ".zshenv was written, so it owes nothing by hand:\n{tail}" - ); + assert_development_refusal(&run_agent_setup(&fixture.home, &fixture.cwd), &fixture.home); + assert_eq!(std::fs::read_link(&profile).unwrap(), managed); + assert_eq!(std::fs::read(&managed).unwrap(), original); assert_eq!( - std::fs::read_to_string(&managed).expect("read managed zshrc"), - managed_text, - "a read-only profile must be left exactly as it was" - ); - let zshenv = std::fs::read_to_string(fixture.home.join(".zshenv")) - .expect("the writable .zshenv must still be written"); - assert!( - zshenv.contains(".kin/bin"), - "the PATH line must land in the file that accepted it:\n{zshenv}" - ); - let clients = stdout - .find("AI client MCP configuration:") - .expect("the client section must run"); - let shell = stdout - .find("Shell integration:") - .expect("the shell section must run"); - assert!( - clients < shell, - "clients are configured before the step that can be refused:\n{stdout}" - ); - assert!( - stdout.contains("=== Health checklist ==="), - "setup stopped before finishing the rest of the run:\n{stdout}" + std::fs::metadata(&managed).unwrap().permissions().mode() & 0o777, + 0o444 ); } -/// The same refusal without relying on file modes, so it runs for every user -/// including root: a directory where `.zshrc` should be cannot be read or -/// written by anyone. #[test] -fn an_unusable_shell_profile_path_is_reported_and_setup_finishes() { +fn development_setup_preserves_an_unusable_shell_profile_directory() { let fixture = fixture(); - let zshrc = fixture.home.join(".zshrc"); - std::fs::create_dir_all(&zshrc).expect("put a directory at .zshrc"); - - let output = run_agent_setup(&fixture.home, &fixture.cwd); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - output.status.success(), - "setup failed on an unusable shell profile:\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - assert!( - claude_has_kin_entry(&fixture.home), - "Claude Code was not configured:\nstdout:\n{stdout}" - ); - assert!( - stdout.contains(&format!( - "Shell integration was not written: failed to read {}", - zshrc.display() - )), - "the refusal must name the file it could not use:\n{stdout}" - ); - assert!( - stdout.contains("To finish it by hand"), - "the closing summary must say what to add by hand:\n{stdout}" - ); - assert!( - !stdout.contains("Open a new shell session to load the shell hook"), - "a hook that was not written must not be announced as loadable:\n{stdout}" - ); + let profile = fixture.home.join(".zshrc"); + std::fs::create_dir(&profile).unwrap(); + assert_development_refusal(&run_agent_setup(&fixture.home, &fixture.cwd), &fixture.home); + assert!(profile.is_dir()); + assert_eq!(std::fs::read_dir(&profile).unwrap().count(), 0); } diff --git a/crates/kin-cli/tests/sibling_cross_repo_refresh.rs b/crates/kin-cli/tests/sibling_cross_repo_refresh.rs index 381f2068a..aa62ce022 100644 --- a/crates/kin-cli/tests/sibling_cross_repo_refresh.rs +++ b/crates/kin-cli/tests/sibling_cross_repo_refresh.rs @@ -260,9 +260,10 @@ fn certified_references( return answer } (_, Some(factor)) - if codes_past_unswept_call_sites(&answer, &factor) - == ["cross_repo_authority_incomplete"] - && Instant::now() < deadline => + if matches!( + codes_past_unswept_call_sites(&answer, &factor).as_slice(), + ["cross_repo_authority_incomplete" | "spine_initialization_deferred"] + ) && Instant::now() < deadline => { std::thread::sleep(Duration::from_secs(1)); } diff --git a/crates/kin-context/src/builder.rs b/crates/kin-context/src/builder.rs index 453affb4e..a5a6e92ab 100644 --- a/crates/kin-context/src/builder.rs +++ b/crates/kin-context/src/builder.rs @@ -2575,6 +2575,7 @@ mod tests { authored_by: IdentityRef::human("test"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, } } diff --git a/crates/kin-core/Cargo.toml b/crates/kin-core/Cargo.toml index d30a27c5e..431ff605d 100644 --- a/crates/kin-core/Cargo.toml +++ b/crates/kin-core/Cargo.toml @@ -25,6 +25,7 @@ kin-model = { workspace = true } # consults it rather than opening `/sys/fs/cgroup` a second time, so the two # can never describe the same container differently. kin-daemon-spawn = { workspace = true } +kin-buildinfo = { workspace = true } kin-db = { workspace = true } kin-blobs = { workspace = true } kin-git = { workspace = true } diff --git a/crates/kin-core/dependency_env_allowlist.txt b/crates/kin-core/dependency_env_allowlist.txt index 4cd037536..e3dc4f995 100644 --- a/crates/kin-core/dependency_env_allowlist.txt +++ b/crates/kin-core/dependency_env_allowlist.txt @@ -119,3 +119,17 @@ KIN_FIR3064_REPO # Read only by that cfg(test) helper; no product runtime reads it as an # operator control. KIN_TEST_BUILT_IN_EMBEDDINGS_CHILD + +# ---- kin-db freezing-ref kill test: child-process handshake ---------------- +# `a_killed_freezing_ref_commit_leaves_the_old_ref_or_the_new_one` in kin-db's +# storage/repository.rs re-runs its own test binary as a child and passes the +# store root and the kill point through these two names. Only that `#[test]` +# reads them, so no kin process an operator runs ever does. +KIN_DB_FREEZING_FRAME_KILL_ROOT +KIN_DB_FREEZING_FRAME_KILL_POINT + +# kin-db storage/repository.rs: the ignored collaboration_commit_measurement +# test times one collaboration-only and one change-admitting commit on a scratch +# copy of a real store that these two name. Only that test reads them. +KIN_DB_MEASURE_STORE +KIN_DB_MEASURE_REPO diff --git a/crates/kin-core/derivation-inputs.toml b/crates/kin-core/derivation-inputs.toml index 7910c0389..a90335bdc 100644 --- a/crates/kin-core/derivation-inputs.toml +++ b/crates/kin-core/derivation-inputs.toml @@ -11,6 +11,8 @@ products = ["graph"] files = [ "src/diff.rs", "src/git_init.rs", + # Selects exact fetched history and builds the changes, aliases and Git authority stored on update. + "src/git_update.rs", "src/init.rs", "src/lib.rs", "src/ref_view.rs", @@ -26,6 +28,7 @@ files = [ [[excluded]] reason = "tests and test support, compiled only under cfg(test) or the test-support feature" files = [ + "src/git_update/tests/mod.rs", "src/session_publication_observer_test.rs", "src/session_publication_test_support.rs", "src/test_env.rs", @@ -187,6 +190,14 @@ files = [ +[[excluded_items]] +file = "src/git_update.rs" +reason = "local update eligibility and its read-only status shape report persisted authority; the admission adapter does not call them and they produce no stored record" +items = [ + "fn git_update_status", + "struct GitUpdateStatus", +] + [[excluded_items]] file = "src/ref_view.rs" reason = "reads, searches and maintains what is stored (the text index, vectors and embeddings, recall, counts, impact and neighbourhood queries, entity history pages and warnings); answering calls them, deriving never does" @@ -198,7 +209,17 @@ items = [ reason = "capability-scoped IO for store sidecars and configuration; reads and writes the caller's record bytes without deriving an entity or relation" files = ["src/store_records.rs"] +[[excluded]] +reason = "runtime build identity and managed/development process routing; no semantic derivation" +files = ["src/runtime_identity.rs"] + +[[excluded_items]] +file = "src/lib.rs" +reason = "exports process identity metadata without changing graph derivation" +items = ["mod runtime_identity"] + [dependencies] +kin-buildinfo = { reason = "compiled runtime provenance for exact CLI and service identity; derives no graph data" } kin-io = { reason = "worktree authority tokens and confined store/config record IO; graph derivation uses the supplied source bytes and never derives a value through this crate" } semver = { reason = "compares recorded Kin releases before an upgrade; never derives graph data" } kin-model = { decides = ["graph"] } diff --git a/crates/kin-core/env_mutation_allowlist.txt b/crates/kin-core/env_mutation_allowlist.txt index 8e3fb2451..701b17c6a 100644 --- a/crates/kin-core/env_mutation_allowlist.txt +++ b/crates/kin-core/env_mutation_allowlist.txt @@ -29,6 +29,15 @@ crates/kin-cli/src/commands/mcp.rs # write is the feature. crates/kin-cli/src/commands/routed_words.rs +# Runtime admission publishes KIN_RUNTIME_MODE and the selected KIN_HOME in +# the single-threaded CLI/daemon entry prologue, before profiles or workers can +# observe them. Both writes are idempotent. Managed-entry routing then pins +# KIN_DAEMON_BIN only after the selected pair is admitted, before any child or +# worker starts. These three values are the process-lifetime interface spawned +# clients and daemons inherit, so the writes are product behavior rather than +# a test fixture. Tests continue to use EnvVarGuard for scoped overrides. +crates/kin-cli/src/commands/runtime.rs + # `kin` and `kin-daemon` select their resource profile at startup when the # operator set none, and `kin-daemon` additionally adopts the profile this # repository recorded in its `[resources]` config so a knob set to survive an OOM diff --git a/crates/kin-core/env_var_allowlist.txt b/crates/kin-core/env_var_allowlist.txt index 3dd9a1b74..98192571a 100644 --- a/crates/kin-core/env_var_allowlist.txt +++ b/crates/kin-core/env_var_allowlist.txt @@ -232,3 +232,9 @@ KIN_ESCAPE_NAMES # marker; no product runtime reads either variable. KIN_RUNTIME_LSP_FIXTURE KIN_RUNTIME_LSP_MARKER + +# kin-db storage/repository.rs: the ignored collaboration_commit_measurement +# test times one collaboration-only and one change-admitting commit on a scratch +# copy of a real store that these two name. Only that test reads them. +KIN_DB_MEASURE_STORE +KIN_DB_MEASURE_REPO diff --git a/crates/kin-core/src/assistant.rs b/crates/kin-core/src/assistant.rs index f37fdab5e..58cf39fb6 100644 --- a/crates/kin-core/src/assistant.rs +++ b/crates/kin-core/src/assistant.rs @@ -1221,6 +1221,7 @@ pub fn resolve_actor_from_session(vendor: &str, client_name: &str, _kind: &Assis kind: ActorKind::Assistant, display_name: key, external_refs: Vec::new(), + agent_session: None, } } @@ -1236,6 +1237,7 @@ pub fn resolve_human_actor(name: &str) -> Actor { kind: ActorKind::Human, display_name: name.to_string(), external_refs: Vec::new(), + agent_session: None, } } diff --git a/crates/kin-core/src/env_registry.rs b/crates/kin-core/src/env_registry.rs index adf0f7f51..7b4bdf2ea 100644 --- a/crates/kin-core/src/env_registry.rs +++ b/crates/kin-core/src/env_registry.rs @@ -143,8 +143,9 @@ pub const OPERATIONAL: &[EnvVarSpec] = &[ // ---- validation control --------------------------------------------------- EnvVarSpec { name: "KIN_ENV_VALIDATION", kind: Kind::OneOf(&["off", "warn", "strict"]), default: "warn", sensitivity: Sensitivity::Operational, summary: "startup env validation mode: off, warn (default), or strict" }, // ---- install / setup root ------------------------------------------------- - EnvVarSpec { name: "KIN_HOME", kind: Kind::Path, default: "~/.kin", sensitivity: Sensitivity::Operational, summary: "preferred root for the managed Kin install used by the launcher, setup, and shell hooks; bounds install and store state, NOT the machine-wide daemon supervisor" }, + EnvVarSpec { name: "KIN_HOME", kind: Kind::Path, default: "~/.kin", sensitivity: Sensitivity::Operational, summary: "preferred root for the admitted Kin runtime, install, stores and supervisor service namespace; development builds use an isolated home; KIN_HOME wins over KIN_DIR" }, EnvVarSpec { name: "KIN_DIR", kind: Kind::Path, default: "", sensitivity: Sensitivity::Operational, summary: "compatibility alias for the managed Kin install root; KIN_HOME wins when both are set" }, + EnvVarSpec { name: "KIN_RUNTIME_MODE", kind: Kind::OneOf(&["managed", "development"]), default: "", sensitivity: Sensitivity::Correctness, summary: "runtime admission mode: clean release builds default to managed; debug or dirty builds default to development with isolated home and services; explicit managed requires known clean release identity" }, EnvVarSpec { name: "KIN_VERSION", kind: Kind::Str, default: "latest", sensitivity: Sensitivity::Operational, summary: "release version selected by the shell and PowerShell installers" }, EnvVarSpec { name: "KIN_BASE_URL", kind: Kind::Url, default: "GitHub Releases", sensitivity: Sensitivity::Operational, summary: "release mirror base URL used by the shell and PowerShell installers" }, EnvVarSpec { name: "KIN_NO_SETUP", kind: Kind::Bool, default: "false", sensitivity: Sensitivity::Operational, summary: "skip the installer's post-install setup wizard when set truthy" }, @@ -243,6 +244,7 @@ pub const OPERATIONAL: &[EnvVarSpec] = &[ EnvVarSpec { name: "KIN_SPINE_MAX_EAGER_SIBLINGS", kind: Kind::Usize, default: "16", sensitivity: Sensitivity::Operational, summary: "how many registered sibling repositories the daemon loads eagerly into the cross-repo spine at startup, each of which opens that sibling's whole workspace graph serially. Unset keeps 16; zero disables eager sibling loading entirely and the spine answers over the primary alone. A capture stopped here is BOUNDED, not incomplete: the spine says so with captured-of-registered counts and edges between captured repos stay valid, which is what separates a deliberate cap from a sibling that failed to load" }, EnvVarSpec { name: "KIN_TRANSFER_MAX_DECODED_BODY_BYTES", kind: Kind::Usize, default: "67108864", sensitivity: Sensitivity::Operational, summary: "kin-daemon receive ceiling for one transfer pack's decoded body closure, in bytes; unset, zero or unparseable keeps the compiled 64 MiB, and the effective bound is always the smaller of this and what the sending peer advertised, so raising it never overrides a peer. It is a transport bound, not the product's answer to repository size, and it is held in memory per concurrent receive" }, EnvVarSpec { name: "KIN_DAEMON_STOP_TIMEOUT_SECS", kind: Kind::Secs, default: "30", sensitivity: Sensitivity::Operational, summary: "ceiling in seconds kin daemon stop waits for a signaled daemon to exit" }, + EnvVarSpec { name: "KIN_DAEMON_REQUIRE_RUNTIME_IDENTITY", kind: Kind::Bool, default: "false", sensitivity: Sensitivity::Operational, summary: "make a development daemon refuse a local request that lacks the caller's runtime identity and repository claims, as a managed daemon always does; it can only make admission stricter, and exists so a debug build can prove a client sends what a release daemon requires. Read by the daemon at process start" }, EnvVarSpec { name: "KIN_DAEMON_REQUIRE_TOKEN", kind: Kind::Bool, default: "true", sensitivity: Sensitivity::Operational, summary: "require a bearer token for all daemon requests; set falsy to opt out" }, EnvVarSpec { name: "KIN_DAEMON_WATCH_PID", kind: Kind::Usize, default: "", sensitivity: Sensitivity::Operational, summary: "pid the daemon watches; it exits when that process dies" }, EnvVarSpec { name: "KIN_DAEMON_EMBED_BATCH_SIZE", kind: Kind::Usize, default: "", sensitivity: Sensitivity::Operational, summary: "embedding batch size for daemon-side embed passes" }, @@ -391,7 +393,10 @@ pub const OPERATIONAL: &[EnvVarSpec] = &[ EnvVarSpec { name: "KIN_TEST_RUNTIME_GUARDIAN_EXECUTABLE", kind: Kind::Str, default: "", sensitivity: Sensitivity::Operational, summary: "dev/test tooling: exact executable implementing the existing process-group guardian protocol for a verified runtime; ordinary product execution leaves this unset" }, EnvVarSpec { name: "KIN_TEST_RUNTIME_OWNER_TOKEN", kind: Kind::Str, default: "", sensitivity: Sensitivity::Operational, summary: "dev/test tooling: names the isolated test runtime that owns a daemon; with KIN_TEST_RUNTIME_CONTAINMENT_PROCESS_GROUP matching the daemon's process group it keeps the daemon in the runtime's group rather than detaching it. The test harness sets both; an operator never does (read by the kin-daemon-spawn crate)" }, EnvVarSpec { name: "KIN_TEST_SHARED_TMPDIR", kind: Kind::OneOf(&["0", "1"]), default: "0", sensitivity: Sensitivity::Diagnostic, summary: "dev/test tooling: `1` makes the nextest setup script keep the inherited TMPDIR instead of a fresh one per run; Kin itself does not read it" }, + EnvVarSpec { name: "KIN_ACCEPTANCE_LABEL", kind: Kind::Str, default: "", sensitivity: Sensitivity::Diagnostic, summary: "dev/test tooling: the label the product acceptance scripts stamp on their reports, set once for a whole acceptance job; Kin itself does not read it" }, + EnvVarSpec { name: "KIN_EPHEMERAL_RUNNER", kind: Kind::Str, default: "", sensitivity: Sensitivity::Diagnostic, summary: "dev/test tooling: `true` when a CI job runs on a runner that lasts one job, so the CI target scripts restore a cached build target instead of keeping one; Kin itself does not read it" }, EnvVarSpec { name: "KIN_CI_LANGUAGE_SERVERS_INSTALLED", kind: Kind::OneOf(&["1"]), default: "", sensitivity: Sensitivity::Diagnostic, summary: "dev/test tooling: `1` once scripts/ci-install-language-servers.sh has installed and proved the language servers, so the kin-daemon reference-enrichment tests require them instead of skipping; Kin itself does not read it" }, + EnvVarSpec { name: "KIN_DEV_TARGET_ROOT", kind: Kind::Path, default: "", sensitivity: Sensitivity::Diagnostic, summary: "dev/test tooling: root directory holding cargo target trees outside the checkout; daemon succession treats an executable under it as a development build and leaves it running" }, ]; /// Behavior levers **read by sibling first-party crates** that are linked into, or @@ -975,6 +980,7 @@ fn doc_group(name: &str) -> &'static str { ("KIN_ENV_VALIDATION", "Validation control"), ("KIN_HOME", "Install & launch"), ("KIN_DIR", "Install & launch"), + ("KIN_RUNTIME_MODE", "Install & launch"), ("KIN_VERSION", "Install & launch"), ("KIN_BASE_URL", "Install & launch"), ("KIN_NO_SETUP", "Install & launch"), diff --git a/crates/kin-core/src/git_init.rs b/crates/kin-core/src/git_init.rs index 2b82ac5d5..02723f10b 100644 --- a/crates/kin-core/src/git_init.rs +++ b/crates/kin-core/src/git_init.rs @@ -2386,7 +2386,7 @@ fn git_boundary_error(context: impl std::fmt::Display, error: impl std::fmt::Dis /// and the fold keeps a commit's semantic state only while a later commit /// still folds against it. What the phase accumulates is the deltas /// themselves, which are what the bootstrap transaction carries. -fn bind_historical_semantics( +pub(crate) fn bind_historical_semantics( plan: kin_git::SemanticGitImportPlan, capture_store: &BlobStore, ) -> Result { @@ -4790,16 +4790,149 @@ pub(crate) mod tests { ); assert!(history_state::standing(&complete.layout).is_complete()); - std::fs::write(partial.layout.stashes_dir().join("held"), b"stash").unwrap(); - let refused = - crate::history_attach::attach_staged_history(&partial.layout, &stage).unwrap_err(); - assert!(refused.to_string().contains("stashes"), "{refused}"); - assert!(!history_state::standing(&partial.layout).is_complete()); - std::fs::remove_file(partial.layout.stashes_dir().join("held")).unwrap(); + // Work the partial store holds open is never dropped: the attach + // waits for each kind, moves nothing, and leaves the stage whole. + { + use crate::history_attach::{AttachOutcome, AttachReadiness, OpenWork}; + let root = partial.layout.root().to_path_buf(); + let entries = || { + let mut names: Vec<_> = std::fs::read_dir(&root) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + names.sort(); + names + }; + let drafts = root.join(crate::history_attach::ENTITY_DRAFTS_DIR); + let transactions = root.join(crate::history_attach::AGENT_TRANSACTIONS_RECORD); + let recovery = root.join(crate::history_attach::AGENT_TRANSACTIONS_RECOVERY_RECORD); + type Step<'a> = Box; + let held: Vec<(OpenWork, Step<'_>, Step<'_>)> = vec![ + ( + OpenWork::Stash, + Box::new(|| { + std::fs::write(partial.layout.stashes_dir().join("held"), b"stash").unwrap() + }), + Box::new(|| { + std::fs::remove_file(partial.layout.stashes_dir().join("held")).unwrap() + }), + ), + ( + OpenWork::Merge, + Box::new(|| std::fs::write(partial.layout.merge_state_path(), b"{}").unwrap()), + Box::new(|| std::fs::remove_file(partial.layout.merge_state_path()).unwrap()), + ), + ( + OpenWork::Transaction, + Box::new(|| { + std::fs::write( + &transactions, + br#"{"t1":{"state":"committed"},"t2":{"state":"active"}}"#, + ) + .unwrap() + }), + // What a daemon leaves once every transaction is settled. + Box::new(|| { + std::fs::write( + &transactions, + br#"{"t1":{"state":"committed"},"t2":{"state":"aborted"}}"#, + ) + .unwrap() + }), + ), + ( + OpenWork::Transaction, + Box::new(|| std::fs::write(&transactions, b"not a transaction map").unwrap()), + Box::new(|| std::fs::write(&transactions, b"{}").unwrap()), + ), + ( + OpenWork::Transaction, + Box::new(|| std::fs::write(&recovery, br#"{"kind":"missing"}"#).unwrap()), + Box::new(|| std::fs::remove_file(&recovery).unwrap()), + ), + ( + OpenWork::Draft, + Box::new(|| { + std::fs::create_dir_all(&drafts).unwrap(); + std::fs::write(drafts.join("store.lock"), b"").unwrap(); + std::fs::write(drafts.join("draft-1"), b"draft").unwrap(); + }), + // The lock a draft store keeps is not a draft. + Box::new(|| std::fs::remove_file(drafts.join("draft-1")).unwrap()), + ), + ( + OpenWork::Release, + Box::new(|| { + std::fs::write(partial.layout.pending_release_path(), b"{}").unwrap() + }), + Box::new(|| { + std::fs::remove_file(partial.layout.pending_release_path()).unwrap() + }), + ), + ]; + for (work, open, finish) in &held { + open(); + let before = entries(); + assert_eq!( + crate::history_attach::open_work(&partial.layout), + Some(*work) + ); + assert_eq!( + crate::history_attach::attach_readiness(&partial.layout, &stage).unwrap(), + AttachReadiness::Waiting(*work) + ); + assert_eq!( + crate::history_attach::attach_staged_history(&partial.layout, &stage).unwrap(), + AttachOutcome::Deferred(*work), + ); + assert_eq!(entries(), before, "waiting for {work} moved something"); + assert!(!history_state::standing(&partial.layout).is_complete()); + assert!(crate::history_attach::staged_store(&stage) + .join("manifest.json") + .is_file()); + finish(); + assert_eq!(crate::history_attach::open_work(&partial.layout), None); + } + assert_eq!( + crate::history_attach::attach_readiness(&partial.layout, &stage).unwrap(), + AttachReadiness::Ready + ); + } + // A swap stopped after any one of its steps, as a crash stops it, is + // finished by recovery, however many times recovery is stopped too. + // Every stop leaves the journal that makes the next start finish it. + { + let journal = partial.layout.root().join("history-attach.json"); + crate::history_attach::interruption::after(Some(0)); + let stopped = + crate::history_attach::attach_staged_history(&partial.layout, &stage).unwrap_err(); + assert!(stopped.to_string().contains("interrupted"), "{stopped}"); + let mut stops = 1; + loop { + assert!(journal.is_file(), "stop {stops} left no journal"); + assert!(stops < 500, "recovery never finished the swap"); + crate::history_attach::interruption::after(Some(1)); + match crate::history_attach::recover_interrupted_attach(&partial.layout) { + Ok(finished) => { + assert!(finished, "recovery found no swap to finish"); + break; + } + Err(stopped) => { + assert!(stopped.to_string().contains("interrupted"), "{stopped}"); + stops += 1; + } + } + } + crate::history_attach::interruption::after(None); + // One stop for each entry moved out and in, at the least. + assert!(stops > 8, "the swap was stopped only {stops} times"); + assert!(!journal.exists()); + assert!(!crate::history_attach::recover_interrupted_attach(&partial.layout).unwrap()); + } assert_eq!( crate::history_attach::attach_staged_history(&partial.layout, &stage).unwrap(), - crate::history_attach::AttachOutcome::Attached + crate::history_attach::AttachOutcome::AlreadyComplete ); assert!(history_state::standing(&partial.layout).is_complete()); assert!(!stage.exists()); diff --git a/crates/kin-core/src/git_update.rs b/crates/kin-core/src/git_update.rs new file mode 100644 index 000000000..f7f645076 --- /dev/null +++ b/crates/kin-core/src/git_update.rs @@ -0,0 +1,558 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Exact incremental Git ingestion for an already bound Git-origin branch. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use kin_blobs::BlobStore; +use kin_db::{ + LocalFileBackend, PersistedRepositoryAuthority, RepositoryAuthorityManager, + RepositoryAuthorityState, +}; +use kin_git::{AdmittedSemanticGitImportPlan, SealedContentObservation}; +use kin_model::{ + AuthorId, ChangeOrigin, ExternalObjectId, ExternalObjectKind, GitExternalAuthority, + GitExternalAuthorityDelta, GitObjectBodyLoader, GitObjectId, GitRawRef, GitRawTarget, Hash256, + OperationId, RefExpectation, RefMutation, RefName, RefTarget, RefUpdatePolicy, + RepositoryTransaction, SealedObservationBinding, SemanticChangeId, WorkspaceHead, WorkspaceId, + WorkspaceState, REPOSITORY_TRANSACTION_SCHEMA_VERSION, +}; +use serde::{Deserialize, Serialize}; + +use crate::{KinError, Result}; + +#[cfg(all(test, unix))] +mod tests; + +/// A fetched, immutable commit observation and the semantic head it updates. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GitUpdateRequest { + pub operation_id: OperationId, + pub source_dir: PathBuf, + pub source_ref: RefName, + pub source_oid: GitObjectId, + pub destination_ref: RefName, + pub expected_head: SemanticChangeId, +} + +/// Eligibility from persisted local authority. Source projection is verified +/// separately at the update's publication boundary. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GitUpdateStatus { + pub can_update: bool, + pub reason: Option, + pub branch: Option, + pub head: Option, + pub git_oid: Option, +} + +pub struct PreparedGitUpdate { + pub transaction: Option, + pub previous_head: SemanticChangeId, + pub head: SemanticChangeId, + pub admitted_changes: usize, + pub workspace: WorkspaceState, + admitted: AdmittedSemanticGitImportPlan, + sealed: Option, +} + +pub fn git_update_status( + authority: &RepositoryAuthorityState, + workspace_id: WorkspaceId, +) -> GitUpdateStatus { + let mut status = GitUpdateStatus { + can_update: false, + reason: None, + branch: None, + head: None, + git_oid: None, + }; + let checked = (|| -> Result<()> { + let workspace = find_workspace(authority, workspace_id)?; + let WorkspaceHead::Symbolic { target: branch } = &workspace.head else { + return Err(refusal("Git updates require a current branch")); + }; + status.branch = Some(branch.clone()); + let target = workspace + .base_target + .as_ref() + .ok_or_else(|| refusal("Git updates require a born branch"))?; + let head = authority + .resolve_target_change_id(target) + .map_err(graph_error)?; + status.head = Some(head); + let oid = require_git_update_base(authority, workspace, branch, head)?; + status.git_oid = Some(oid); + Ok(()) + })(); + match checked { + Ok(()) => status.can_update = true, + Err(error) => status.reason = Some(error.to_string()), + } + status +} + +/// Derive the selected fetched commit through the same semantic admission path +/// as complete Git import, and compare all held ancestor records byte for byte. +/// No source working-copy state or ambient Git branch enters the derivation. +pub fn prepare_git_update( + manager: &RepositoryAuthorityManager, + workspace_id: WorkspaceId, + request: &GitUpdateRequest, + capture_store: &BlobStore, +) -> Result { + let authority = manager.read_authority(); + let workspace = find_workspace(&authority, workspace_id)?.clone(); + let current_oid = require_git_update_base( + &authority, + &workspace, + &request.destination_ref, + request.expected_head, + )?; + let held = repository_metadata(&authority); + let held_aliases = held + .aliases + .iter() + .map(|alias| (alias.oid, alias)) + .collect::>(); + let repository_id = held.repository_id.clone(); + let snapshot = kin_git::capture_lossless_git_ref( + &kin_io::WorktreeRoot::new(&request.source_dir, &kin_io::Grant::ambient()), + repository_id.clone(), + &request.source_ref, + request.source_oid, + capture_store, + ) + .map_err(git_error)?; + for record in &snapshot.objects { + if record.object.kind != ExternalObjectKind::Commit + || held_aliases.contains_key(&record.object.oid) + { + continue; + } + let body = capture_store + .read(&kin_blobs::Hash256::from_bytes( + *record.body_hash.as_bytes(), + )) + .map_err(|error| refusal(error.to_string()))?; + if body + .split(|byte| *byte == b'\n') + .take_while(|line| !line.is_empty()) + .any(|line| line.starts_with(b"kin-change-id ")) + { + return Err(refusal( + "fetched history contains a native Kin export without verified native export bindings", + )); + } + } + let plan = kin_git::plan_semantic_git_import(&snapshot, capture_store).map_err(git_error)?; + let plan = crate::git_init::bind_historical_semantics(plan, capture_store)?; + let admitted = kin_git::admit_semantic_git_import(&plan, capture_store).map_err(git_error)?; + let mut aliases = Vec::new(); + let mut changes = Vec::new(); + let mut contains_current = false; + for alias in &admitted.aliases { + let change = admitted + .changes + .read_by_oid(&alias.oid) + .map_err(git_error)? + .ok_or_else(|| refusal("fetched Git alias has no admitted semantic change"))?; + if alias.oid == current_oid { + contains_current = true; + } + if let Some(existing_alias) = held_aliases.get(&alias.oid) { + if *existing_alias != alias { + return Err(refusal(format!( + "Git commit {} derives a different semantic identity; its existing alias cannot be changed", + alias.oid + ))); + } + let existing = authority + .snapshot() + .changes + .read_change(&existing_alias.change_id) + .map_err(graph_error)? + .ok_or_else(|| refusal("held Git alias has no immutable semantic change"))?; + if existing != change { + return Err(refusal(format!( + "Git commit {} no longer derives byte-identical held semantic records", + alias.oid + ))); + } + } else { + if authority.snapshot().changes.contains_change(&change.id) { + return Err(refusal( + "a fetched Git change already exists without its exact alias", + )); + } + aliases.push(alias.clone()); + changes.push(change); + } + } + let mut fresh_changes = changes + .into_iter() + .map(|change| (change.id, change)) + .collect::>(); + let changes = admitted + .changes + .ids() + .filter_map(|id| fresh_changes.remove(&id)) + .collect::>(); + if !contains_current { + return Err(refusal( + "the fetched Git commit does not descend from the current Git-origin head", + )); + } + let head = admitted + .workspace_base_change_id() + .ok_or_else(|| refusal("fetched Git commit has no admitted semantic head"))?; + if request.source_oid == current_oid { + if head != request.expected_head || !changes.is_empty() || !aliases.is_empty() { + return Err(refusal( + "the current Git commit no longer derives its exact held head", + )); + } + return Ok(PreparedGitUpdate { + transaction: None, + previous_head: request.expected_head, + head, + admitted_changes: 0, + workspace, + admitted, + sealed: None, + }); + } + let mut external_objects = Vec::new(); + for record in &admitted.external_objects { + match held + .external_objects + .binary_search_by_key(&record.object, |held| held.object) + { + Ok(index) if held.external_objects[index] == *record => {} + Ok(_) => { + return Err(refusal( + "fetched Git object disagrees with its held descriptor", + )) + } + Err(_) => external_objects.push(record.clone()), + } + } + let old_git_authority = held + .git_external_authority + .as_ref() + .ok_or_else(|| refusal("Git updates require existing exact Git external authority"))?; + let new_git_authority = union_git_authority( + old_git_authority, + &snapshot, + &request.destination_ref, + request.source_oid, + capture_store, + manager, + )?; + let target = authority + .resolve_ref_target(&request.destination_ref) + .map_err(graph_error)? + .ok_or_else(|| refusal("the selected current branch no longer exists"))?; + let transaction = RepositoryTransaction { + schema_version: REPOSITORY_TRANSACTION_SCHEMA_VERSION, + operation_id: request.operation_id, + repository_id, + expected_generation: authority.roots().generation, + expected_roots: authority.roots().clone(), + actor: AuthorId::new("kin-git-update"), + reason: "admit exact fetched Git descendants".to_string(), + external_objects, + git_authority_delta: Some(GitExternalAuthorityDelta::update( + old_git_authority.clone(), + new_git_authority, + )), + changes, + aliases, + ref_mutations: vec![RefMutation { + name: request.destination_ref.clone(), + expected: RefExpectation::MustEqual { target }, + new_target: Some(RefTarget::external_object(ExternalObjectId::new( + ExternalObjectKind::Commit, + request.source_oid, + ))), + policy: RefUpdatePolicy::FastForwardOnly, + }], + default_ref_mutation: None, + // The workspace stays on its old base until the daemon follows the + // admitted ref. A workspace mutation that changes nothing is not one + // the model admits, so none rides here: the expected roots stop a + // concurrent workspace change, and the publication seam proves the + // tracked bytes unchanged before it commits. + workspace_mutation: None, + local_overlay_delta: None, + merge_transaction_delta: None, + sealed_observation: None, + collaboration_delta: None, + git_snapshot_proofs: Vec::new(), + }; + transaction.validate().map_err(model_error)?; + Ok(PreparedGitUpdate { + admitted_changes: transaction.changes.len(), + transaction: Some(transaction), + previous_head: request.expected_head, + head, + workspace, + admitted, + sealed: None, + }) +} + +impl PreparedGitUpdate { + /// Copy capture bodies into the retained repository source CAS and seal + /// their exact tree/content closure before it can become authority. + pub fn seal_in_repository( + &mut self, + manager: &RepositoryAuthorityManager, + capture_store: &BlobStore, + ) -> Result<()> { + if self.transaction.is_none() { + return Ok(()); + } + manager + .with_source_blob_write_batch(&mut |batch| { + for record in &self.admitted.external_objects { + let body = capture_store + .read(&kin_blobs::Hash256::from_bytes( + *record.body_hash.as_bytes(), + )) + .map_err(|error| kin_db::KinDbError::StorageError(error.to_string()))?; + batch.save(*record.body_hash.as_bytes(), &body)?; + } + for hash in self.admitted.content.identities.keys() { + let body = capture_store + .read(&kin_blobs::Hash256::from_bytes(*hash.as_bytes())) + .map_err(|error| kin_db::KinDbError::StorageError(error.to_string()))?; + batch.save(*hash.as_bytes(), &body)?; + } + Ok(()) + }) + .map_err(graph_error)?; + let sealed = kin_git::seal_all_content_observation( + &self.admitted, + &crate::init::GraphOwnedContent::new(manager), + ) + .map_err(git_error)?; + let binding = SealedObservationBinding::new( + sealed.fingerprint, + sealed.observed_trees as u64, + sealed.observed_entries as u64, + sealed.coverage.sealed_bodies as u64, + sealed.coverage.sealed_body_bytes, + sealed.coverage.opaque_bodies as u64, + sealed.exclusions.len() as u64, + ) + .map_err(model_error)?; + self.transaction + .as_mut() + .expect("a mutation was checked above") + .sealed_observation = Some(binding); + self.sealed = Some(sealed); + Ok(()) + } + + pub fn verify_published_seal( + &self, + manager: &RepositoryAuthorityManager, + ) -> Result<()> { + let Some(before) = &self.sealed else { + return Ok(()); + }; + let after = kin_git::seal_all_content_observation( + &self.admitted, + &crate::init::GraphOwnedContent::new(manager), + ) + .map_err(git_error)?; + if before != &after { + return Err(refusal( + "published Git update content does not match its admission seal", + )); + } + Ok(()) + } +} + +fn find_workspace( + authority: &RepositoryAuthorityState, + workspace_id: WorkspaceId, +) -> Result<&WorkspaceState> { + repository_metadata(authority) + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .ok_or_else(|| refusal("the current repository workspace is absent")) +} + +fn require_git_update_base( + authority: &RepositoryAuthorityState, + workspace: &WorkspaceState, + branch: &RefName, + expected_head: SemanticChangeId, +) -> Result { + let metadata = repository_metadata(authority); + if metadata.merge_transactions.iter().any(|record| { + record.workspace_id == workspace.workspace_id && record.state.is_in_progress() + }) { + return Err(refusal( + "complete or abandon the current merge before a Git update", + )); + } + if workspace.holds_uncommitted_work() { + return Err(refusal( + "commit or preserve current graph-owned changes before a Git update", + )); + } + if workspace.head + != (WorkspaceHead::Symbolic { + target: branch.clone(), + }) + { + return Err(refusal( + "the workspace no longer tracks the selected branch", + )); + } + let base = workspace + .base_target + .as_ref() + .ok_or_else(|| refusal("Git updates require a born branch"))?; + let base_id = authority + .resolve_target_change_id(base) + .map_err(graph_error)?; + let target = authority + .resolve_ref_target(branch) + .map_err(graph_error)? + .ok_or_else(|| refusal("the current branch is absent"))?; + if base_id != expected_head + || authority + .resolve_target_change_id(&target) + .map_err(graph_error)? + != expected_head + { + return Err(refusal( + "the current branch or workspace moved after the Git update was selected", + )); + } + let change = authority + .snapshot() + .changes + .read_change(&expected_head) + .map_err(graph_error)? + .ok_or_else(|| refusal("the current semantic head is absent"))?; + let ChangeOrigin::GitCommit { oid } = change.origin else { + return Err(refusal( + "Git updates after native Kin changes require verified native export bindings", + )); + }; + if !metadata.aliases.iter().any(|alias| { + alias.oid == oid + && alias.change_id == expected_head + && alias.repository_id == metadata.repository_id + }) { + return Err(refusal( + "the current Git-origin head has no exact repository alias", + )); + } + if metadata.git_external_authority.is_none() { + return Err(refusal( + "the current head has no exact Git external authority", + )); + } + Ok(oid) +} + +fn union_git_authority( + old: &GitExternalAuthority, + fetched: &kin_git::LosslessGitRepository, + destination: &RefName, + oid: GitObjectId, + store: &BlobStore, + manager: &RepositoryAuthorityManager, +) -> Result { + let mut records = old + .closure + .objects + .iter() + .map(|entry| (entry.record.object, entry.record.clone())) + .collect::>(); + for record in &fetched.objects { + if let Some(existing) = records.insert(record.object, record.clone()) { + if existing != *record { + return Err(refusal( + "fetched closure differs from the retained Git authority", + )); + } + } + } + let mut refs = old + .raw_refs + .iter() + .map(|raw| (raw.name.clone(), raw.clone())) + .collect::>(); + refs.insert( + destination.clone(), + GitRawRef { + name: destination.clone(), + target: GitRawTarget::Direct { + object: ExternalObjectId::new(ExternalObjectKind::Commit, oid), + }, + }, + ); + // Every old root remains reachable: the sole replaced ref advances to a + // descendant, and all other refs and the raw HEAD remain exact. + GitExternalAuthority::from_raw_parts( + old.repository_id.clone(), + old.object_format, + refs.into_values().collect(), + old.raw_head.clone(), + records.into_values().collect(), + &mut StoreBodyLoader { store, manager }, + ) + .map_err(|error| refusal(format!("verify the exact updated Git authority: {error}"))) +} + +struct StoreBodyLoader<'a> { + store: &'a BlobStore, + manager: &'a RepositoryAuthorityManager, +} + +impl GitObjectBodyLoader for StoreBodyLoader<'_> { + type Error = String; + fn load_body(&mut self, hash: &Hash256) -> std::result::Result>, String> { + match self + .store + .read(&kin_blobs::Hash256::from_bytes(*hash.as_bytes())) + { + Ok(body) => Ok(Some(body)), + Err(kin_blobs::BlobError::NotFound { .. }) => self + .manager + .load_source_blob(*hash) + .map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + } + } +} + +fn refusal(message: impl Into) -> KinError { + KinError::Other(message.into()) +} + +fn repository_metadata(authority: &RepositoryAuthorityState) -> &PersistedRepositoryAuthority { + authority.metadata() +} +fn graph_error(error: kin_db::KinDbError) -> KinError { + KinError::Graph(error.to_string()) +} +fn model_error(error: kin_model::ModelError) -> KinError { + refusal(error.to_string()) +} +fn git_error(error: kin_git::GitError) -> KinError { + refusal(format!("exact Git update refused: {error}")) +} diff --git a/crates/kin-core/src/git_update/tests/mod.rs b/crates/kin-core/src/git_update/tests/mod.rs new file mode 100644 index 000000000..2c3bc60e8 --- /dev/null +++ b/crates/kin-core/src/git_update/tests/mod.rs @@ -0,0 +1,583 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +use super::*; +use kin_model::{WorkspaceExpectation, WorkspaceMutation}; +use std::path::Path; +use std::sync::Arc; + +struct Fixture { + _root: tempfile::TempDir, + source: PathBuf, + initialized: crate::InitResult, + manager: RepositoryAuthorityManager, + store: BlobStore, + initial_oid: GitObjectId, + initial_head: SemanticChangeId, +} + +impl Fixture { + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let hooks = source.join(".git/hooks"); + let excludes = source.join(".git/fixture-excludes"); + kin_git::test_support::FixtureGitBatch::new() + .git(["init", "--initial-branch=main"]) + .git(["config", "user.email", "kin@example.invalid"]) + .git(["config", "user.name", "Kin Test"]) + .git(["config", "core.hooksPath", hooks.to_str().unwrap()]) + .git(["config", "core.excludesFile", excludes.to_str().unwrap()]) + .run_in(&source); + std::fs::create_dir_all(hooks).unwrap(); + std::fs::write(excludes, b"").unwrap(); + std::fs::write(source.join("README.md"), b"initial body\n").unwrap(); + commit(&source, "initial"); + git(&source, &["branch", "retained"]); + git(&source, &["tag", "retained-tag"]); + let initial_oid = oid(&git(&source, &["rev-parse", "HEAD"])); + let initialized = crate::git_init::init_from_git(&source).unwrap(); + let initial_head = initialized.authority.initial_change_id.unwrap(); + let manager = RepositoryAuthorityManager::open( + initialized.repository_id.clone(), + Arc::new(LocalFileBackend::new(initialized.layout.kindb_dir())), + ) + .unwrap(); + let store = BlobStore::new(root.path().join("capture")).unwrap(); + Self { + _root: root, + source, + initialized, + manager, + store, + initial_oid, + initial_head, + } + } + + fn request(&self, source_oid: GitObjectId) -> GitUpdateRequest { + GitUpdateRequest { + operation_id: OperationId::new(), + source_dir: self.source.clone(), + source_ref: ref_name("refs/kin/update/main"), + source_oid, + destination_ref: ref_name("refs/heads/main"), + expected_head: self.initial_head, + } + } + + fn fetch_descendant(&self) -> GitObjectId { + std::fs::write(self.source.join("README.md"), b"descendant body\n").unwrap(); + commit(&self.source, "descendant"); + let fetched = oid(&git(&self.source, &["rev-parse", "HEAD"])); + self.retain_fetched(fetched); + fetched + } + + fn retain_fetched(&self, fetched: GitObjectId) { + git( + &self.source, + &["update-ref", "refs/kin/update/main", &fetched.to_string()], + ); + git( + &self.source, + &["reset", "--hard", &self.initial_oid.to_string()], + ); + } +} + +#[test] +fn exact_fast_forward_retains_authority_and_survives_reopen() { + if isolated("exact_fast_forward_retains_authority_and_survives_reopen") { + return; + } + let fixture = Fixture::new(); + let before = fixture.manager.read_authority(); + let old_metadata = before.metadata().clone(); + let old_roots = before.roots().clone(); + let retained = before + .resolve_ref_target(&ref_name("refs/heads/retained")) + .unwrap(); + let fetched = fixture.fetch_descendant(); + git( + &fixture.source, + &["update-ref", "refs/heads/unadmitted", &fetched.to_string()], + ); + let request = fixture.request(fetched); + let mut prepared = prepare_git_update( + &fixture.manager, + fixture.initialized.workspace_id, + &request, + &fixture.store, + ) + .unwrap(); + assert_eq!(prepared.admitted_changes, 1); + assert_eq!(prepared.previous_head, fixture.initial_head); + assert_ne!(prepared.head, fixture.initial_head); + let new_head = prepared.head; + let transaction = prepared.transaction.as_ref().unwrap(); + assert_eq!(transaction.changes.len(), 1); + assert_eq!(transaction.changes[0].parents, vec![fixture.initial_head]); + assert_eq!(transaction.aliases.len(), 1); + assert_eq!(transaction.aliases[0].oid, fetched); + assert_eq!(transaction.ref_mutations.len(), 1); + assert_eq!(transaction.ref_mutations[0].name, request.destination_ref); + // The workspace stays on its old base until the daemon follows the ref, + // and a mutation that would leave it unchanged is not one to carry. + assert!(transaction.workspace_mutation.is_none()); + prepared + .seal_in_repository(&fixture.manager, &fixture.store) + .unwrap(); + let transaction = prepared.transaction.take().unwrap(); + let mut stale = transaction.clone(); + stale.operation_id = OperationId::new(); + let (materialized, receipt, freeze) = + crate::tree::verify_repository_workspace_projection_and_commit_repository_transaction( + &fixture.source, + &prepared.workspace.tree, + &fixture.manager, + transaction, + ) + .unwrap(); + assert_eq!(materialized, prepared.workspace.tree.artifacts().len()); + assert_eq!(receipt.roots_before, old_roots); + assert_eq!(freeze.roots(), &receipt.roots_after); + // The seal reads graph-owned content under the store's shared lock, which + // the freeze holds exclusively, so it is checked once the freeze is gone. + drop(freeze); + prepared.verify_published_seal(&fixture.manager).unwrap(); + assert!(fixture + .manager + .commit_repository_transaction(stale) + .is_err()); + let reopened = RepositoryAuthorityManager::open( + fixture.initialized.repository_id.clone(), + Arc::new(LocalFileBackend::new( + fixture.initialized.layout.kindb_dir(), + )), + ) + .unwrap(); + let after = reopened.read_authority(); + assert_eq!(after.roots(), &receipt.roots_after); + assert_eq!( + after + .resolve_target_change_id( + &after + .resolve_ref_target(&request.destination_ref) + .unwrap() + .unwrap() + ) + .unwrap(), + new_head + ); + assert_eq!( + after + .resolve_ref_target(&ref_name("refs/heads/retained")) + .unwrap(), + retained + ); + assert!(after + .resolve_ref_target(&ref_name("refs/heads/unadmitted")) + .unwrap() + .is_none()); + for alias in &old_metadata.aliases { + assert!(after.metadata().aliases.contains(alias)); + } + let old_git = old_metadata.git_external_authority.as_ref().unwrap(); + let new_git = after.metadata().git_external_authority.as_ref().unwrap(); + assert_eq!(new_git.raw_head, old_git.raw_head); + for raw_ref in &old_git.raw_refs { + if raw_ref.name != request.destination_ref { + assert!(new_git.raw_refs.contains(raw_ref)); + } + } + assert_eq!( + find_workspace(&after, fixture.initialized.workspace_id).unwrap(), + &prepared.workspace, + "admission must leave the workspace for the follow to move" + ); + assert_eq!( + std::fs::read(fixture.source.join("README.md")).unwrap(), + b"initial body\n" + ); +} + +#[test] +fn admission_refuses_over_changed_tracked_bytes() { + if isolated("admission_refuses_over_changed_tracked_bytes") { + return; + } + let fixture = Fixture::new(); + let request = fixture.request(fixture.fetch_descendant()); + let mut prepared = prepare_git_update( + &fixture.manager, + fixture.initialized.workspace_id, + &request, + &fixture.store, + ) + .unwrap(); + prepared + .seal_in_repository(&fixture.manager, &fixture.store) + .unwrap(); + let roots = fixture.manager.read_authority().roots().clone(); + std::fs::write(fixture.source.join("README.md"), b"edited, not admitted\n").unwrap(); + let error = + crate::tree::verify_repository_workspace_projection_and_commit_repository_transaction( + &fixture.source, + &prepared.workspace.tree, + &fixture.manager, + prepared.transaction.take().unwrap(), + ) + .expect_err("an edited tracked member must stop the admission"); + assert!(!error.to_string().is_empty()); + assert_eq!(fixture.manager.read_authority().roots(), &roots); + assert_eq!( + std::fs::read(fixture.source.join("README.md")).unwrap(), + b"edited, not admitted\n" + ); +} + +#[test] +fn selected_head_must_still_match_persisted_authority() { + if isolated("selected_head_must_still_match_persisted_authority") { + return; + } + let fixture = Fixture::new(); + let mut request = fixture.request(fixture.fetch_descendant()); + request.expected_head = SemanticChangeId::from_hash(Hash256::from_bytes([7; 32])); + let roots = fixture.manager.read_authority().roots().clone(); + let error = prepare_git_update( + &fixture.manager, + fixture.initialized.workspace_id, + &request, + &fixture.store, + ) + .err() + .expect("a stale selected head must refuse"); + assert!(error.to_string().contains("moved after")); + assert_eq!(fixture.manager.read_authority().roots(), &roots); +} + +#[test] +fn fetched_head_must_descend_from_current_git_head() { + if isolated("fetched_head_must_descend_from_current_git_head") { + return; + } + let fixture = Fixture::new(); + git(&fixture.source, &["checkout", "--orphan", "unrelated"]); + std::fs::write(fixture.source.join("README.md"), b"unrelated body\n").unwrap(); + commit(&fixture.source, "unrelated root"); + let fetched = oid(&git(&fixture.source, &["rev-parse", "HEAD"])); + git(&fixture.source, &["checkout", "main"]); + fixture.retain_fetched(fetched); + let roots = fixture.manager.read_authority().roots().clone(); + let error = prepare_git_update( + &fixture.manager, + fixture.initialized.workspace_id, + &fixture.request(fetched), + &fixture.store, + ) + .err() + .expect("an unrelated root must refuse"); + assert!(error.to_string().contains("does not descend")); + assert_eq!(fixture.manager.read_authority().roots(), &roots); +} + +#[test] +fn fetched_ref_must_still_name_selected_exact_oid() { + if isolated("fetched_ref_must_still_name_selected_exact_oid") { + return; + } + let fixture = Fixture::new(); + let fetched = fixture.fetch_descendant(); + let request = fixture.request(fetched); + git( + &fixture.source, + &[ + "update-ref", + "refs/kin/update/main", + &fixture.initial_oid.to_string(), + ], + ); + let roots = fixture.manager.read_authority().roots().clone(); + let error = prepare_git_update( + &fixture.manager, + fixture.initialized.workspace_id, + &request, + &fixture.store, + ) + .err() + .expect("a moved fetched ref must refuse"); + assert!(error.to_string().contains("exact Git update refused")); + assert_eq!(fixture.manager.read_authority().roots(), &roots); +} + +#[test] +fn fetching_same_exact_oid_is_a_no_op() { + if isolated("fetching_same_exact_oid_is_a_no_op") { + return; + } + let fixture = Fixture::new(); + fixture.retain_fetched(fixture.initial_oid); + let roots = fixture.manager.read_authority().roots().clone(); + let mut prepared = prepare_git_update( + &fixture.manager, + fixture.initialized.workspace_id, + &fixture.request(fixture.initial_oid), + &fixture.store, + ) + .unwrap(); + assert!(prepared.transaction.is_none()); + assert_eq!(prepared.admitted_changes, 0); + assert_eq!(prepared.head, fixture.initial_head); + prepared + .seal_in_repository(&fixture.manager, &fixture.store) + .unwrap(); + assert_eq!(fixture.manager.read_authority().roots(), &roots); +} + +#[test] +fn native_export_marker_in_fetched_ancestry_requires_native_binding() { + if isolated("native_export_marker_in_fetched_ancestry_requires_native_binding") { + return; + } + let fixture = Fixture::new(); + let tree = git(&fixture.source, &["rev-parse", "HEAD^{tree}"]); + let commit_body = format!( + "tree {}\nparent {}\nauthor Kin Test 1700000000 +0000\ncommitter Kin Test 1700000000 +0000\nkin-change-id {}\n\nexported native ancestor\n", + tree.trim(), fixture.initial_oid, fixture.initial_head + ); + let output = kin_git::test_support::fixture_git_in(&fixture.source) + .args(["hash-object", "-t", "commit", "-w", "--stdin"]) + .output_with_input(commit_body.as_bytes()) + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let native_oid = oid(&String::from_utf8(output.stdout).unwrap()); + git( + &fixture.source, + &["reset", "--hard", &native_oid.to_string()], + ); + std::fs::write( + fixture.source.join("README.md"), + b"child of native export\n", + ) + .unwrap(); + commit(&fixture.source, "ordinary child"); + let fetched = oid(&git(&fixture.source, &["rev-parse", "HEAD"])); + fixture.retain_fetched(fetched); + let roots = fixture.manager.read_authority().roots().clone(); + let error = prepare_git_update( + &fixture.manager, + fixture.initialized.workspace_id, + &fixture.request(fetched), + &fixture.store, + ) + .err() + .expect("native export ancestry must not become fresh Git provenance"); + assert!(error.to_string().contains("native")); + assert_eq!(fixture.manager.read_authority().roots(), &roots); +} + +#[test] +fn native_current_head_reports_explicit_update_gap() { + if isolated("native_current_head_reports_explicit_update_gap") { + return; + } + let fixture = Fixture::new(); + let authority = fixture.manager.read_authority(); + assert!(git_update_status(&authority, fixture.initialized.workspace_id).can_update); + let workspace = find_workspace(&authority, fixture.initialized.workspace_id).unwrap(); + let branch = ref_name("refs/heads/main"); + let target = authority.resolve_ref_target(&branch).unwrap().unwrap(); + let mut native = authority + .snapshot() + .changes + .read_change(&fixture.initial_head) + .unwrap() + .unwrap(); + native.origin = ChangeOrigin::Native; + native.parents = vec![fixture.initial_head]; + native.message = "native metadata change".to_string(); + native.entity_deltas.clear(); + native.relation_deltas.clear(); + native.tree_deltas.clear(); + native.admission_policy_delta = None; + native.external_reference_deltas.clear(); + native.resolution_record_deltas.clear(); + native.projected_files.clear(); + native.id = crate::compute_semantic_change_id(&native).unwrap(); + let mut workspace_mutation = unchanged_workspace_mutation(workspace).unwrap(); + workspace_mutation.new_base_target = Some(RefTarget::change(native.id)); + fixture + .manager + .commit_repository_transaction(RepositoryTransaction { + schema_version: REPOSITORY_TRANSACTION_SCHEMA_VERSION, + operation_id: OperationId::new(), + repository_id: fixture.initialized.repository_id.clone(), + expected_generation: authority.roots().generation, + expected_roots: authority.roots().clone(), + actor: AuthorId::new("native-author"), + reason: "advance native head".to_string(), + changes: vec![native], + aliases: Vec::new(), + external_objects: Vec::new(), + git_authority_delta: None, + ref_mutations: vec![RefMutation { + name: branch, + expected: RefExpectation::MustEqual { target }, + new_target: workspace_mutation.new_base_target.clone(), + policy: RefUpdatePolicy::FastForwardOnly, + }], + default_ref_mutation: None, + workspace_mutation: Some(workspace_mutation), + local_overlay_delta: None, + merge_transaction_delta: None, + sealed_observation: None, + collaboration_delta: None, + git_snapshot_proofs: Vec::new(), + }) + .unwrap(); + let status = git_update_status( + &fixture.manager.read_authority(), + fixture.initialized.workspace_id, + ); + assert!(!status.can_update); + assert!(status.reason.unwrap().contains("native Kin changes")); + assert!(status.git_oid.is_none()); +} + +#[test] +fn dirty_graph_owned_workspace_refuses_before_admission() { + if isolated("dirty_graph_owned_workspace_refuses_before_admission") { + return; + } + let fixture = Fixture::new(); + let request = fixture.request(fixture.fetch_descendant()); + let authority = fixture.manager.read_authority(); + let workspace = find_workspace(&authority, fixture.initialized.workspace_id).unwrap(); + let dirty_body = b"uncommitted graph-owned body\n"; + let dirty_hash = Hash256::from_bytes(kin_blobs::digest_bytes(dirty_body)); + fixture + .manager + .save_source_blob(dirty_hash, dirty_body) + .unwrap(); + let mut artifacts = workspace.tree.artifacts().cloned().collect::>(); + assert_eq!(artifacts.len(), 1); + artifacts[0].entry = kin_model::TreeEntry::Blob { + hash: dirty_hash, + executable: false, + }; + let dirty_tree = kin_model::ResolvedTree::from_artifacts(artifacts).unwrap(); + let mut mutation = unchanged_workspace_mutation(workspace).unwrap(); + mutation.tree_deltas = crate::exact_tree_correction(&workspace.tree, &dirty_tree).unwrap(); + mutation.new_tree_hash = kin_model::compute_resolved_tree_hash(&dirty_tree).unwrap(); + fixture + .manager + .commit_repository_transaction(RepositoryTransaction { + schema_version: REPOSITORY_TRANSACTION_SCHEMA_VERSION, + operation_id: OperationId::new(), + repository_id: fixture.initialized.repository_id.clone(), + expected_generation: authority.roots().generation, + expected_roots: authority.roots().clone(), + actor: AuthorId::new("workspace-author"), + reason: "retain uncommitted workspace body".to_string(), + changes: Vec::new(), + aliases: Vec::new(), + external_objects: Vec::new(), + git_authority_delta: None, + ref_mutations: Vec::new(), + default_ref_mutation: None, + workspace_mutation: Some(mutation), + local_overlay_delta: None, + merge_transaction_delta: None, + sealed_observation: None, + collaboration_delta: None, + git_snapshot_proofs: Vec::new(), + }) + .unwrap(); + let dirty_authority = fixture.manager.read_authority(); + let roots = dirty_authority.roots().clone(); + let status = git_update_status(&dirty_authority, fixture.initialized.workspace_id); + assert!(!status.can_update); + assert!(status.reason.unwrap().contains("graph-owned changes")); + let error = prepare_git_update( + &fixture.manager, + fixture.initialized.workspace_id, + &request, + &fixture.store, + ) + .err() + .expect("uncommitted graph-owned work must refuse"); + assert!(error.to_string().contains("graph-owned changes")); + assert_eq!(fixture.manager.read_authority().roots(), &roots); +} + +/// A mutation of `workspace` that changes nothing yet, for a test to edit +/// into the change it commits. +fn unchanged_workspace_mutation(workspace: &WorkspaceState) -> Result { + Ok(WorkspaceMutation { + workspace_id: workspace.workspace_id, + expected: WorkspaceExpectation::MustEqual { + generation: workspace.generation, + head: workspace.head.clone(), + base_target: workspace.base_target.clone(), + base_tree_hash: workspace.base_tree_hash, + tree_hash: workspace.tree_hash, + semantic_overlay_hash: workspace.semantic_overlay_hash, + admission_policy: workspace.admission_policy, + }, + new_generation: workspace.generation + 1, + new_head: workspace.head.clone(), + new_base_target: workspace.base_target.clone(), + new_base_tree_hash: workspace.base_tree_hash, + tree_deltas: Vec::new(), + new_tree_hash: workspace.tree_hash, + semantic_delta: Default::default(), + new_shared_admission_policy: workspace.shared_admission_policy.clone(), + new_admission_policy: workspace.admission_policy, + }) +} + +fn isolated(name: &str) -> bool { + crate::git_init::tests::run_in_isolated_store( + &format!("git_update::tests::{name}"), + &["central"], + ) +} + +fn ref_name(name: &str) -> RefName { + RefName::from_utf8(name).unwrap() +} + +fn oid(value: &str) -> GitObjectId { + match value.trim().parse::().unwrap() { + gix::ObjectId::Sha1(bytes) => GitObjectId::sha1(bytes), + gix::ObjectId::Sha256(bytes) => GitObjectId::sha256(bytes), + _ => panic!("fixture Git returned an unsupported object ID algorithm"), + } +} + +fn commit(source: &Path, message: &str) { + kin_git::test_support::FixtureGitBatch::new() + .git(["add", "--all"]) + .git(["commit", "-m", message]) + .run_in(source); +} + +fn git(source: &Path, args: &[&str]) -> String { + let output = kin_git::test_support::fixture_git_in(source) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} diff --git a/crates/kin-core/src/history_attach.rs b/crates/kin-core/src/history_attach.rs index e9b5440a2..f795f8598 100644 --- a/crates/kin-core/src/history_attach.rs +++ b/crates/kin-core/src/history_attach.rs @@ -20,11 +20,16 @@ //! //! **Nothing the partial store holds is lost.** The swap refuses a partial //! store that has anything the staged store cannot stand for: a change other -//! than its snapshot, a ref that moved off the snapshot, a stash or an open -//! merge. What the partial store accumulated on top of the snapshot without -//! committing, its workspace state and the cross-file enrichment a sweep -//! published into it, is re-derived by the next daemon from the working tree -//! and the language servers, exactly as for a newly converted store. +//! than its snapshot, or a ref that moved off the snapshot. It waits, moving +//! nothing, while the partial store holds work that is still open +//! ([`OpenWork`]): a merge, a stash, an agent's transaction, an unapplied +//! entity draft or a release being recorded. That check is made here, under +//! the runtime authority and with no daemon serving, so it is exact whoever +//! asks for the attach and whatever a daemon believed a moment earlier. What +//! the partial store accumulated on top of the snapshot without committing, +//! its workspace state and the cross-file enrichment a sweep published into +//! it, is re-derived by the next daemon from the working tree and the +//! language servers, exactly as for a newly converted store. //! //! **A crash never strands the store.** A journal written before the first move //! and removed after the last one names the stage, and every attach, including @@ -98,6 +103,132 @@ pub enum AttachOutcome { Attached, /// The store is already complete; nothing was done. AlreadyComplete, + /// Nothing was moved, because the partial store holds work that is still + /// open and the swap would drop it. The stage stays staged, and the attach + /// goes ahead once that work is finished. + Deferred(OpenWork), +} + +/// Work a partial store holds open, which an attach waits for. +/// +/// Each of these lives in the store's own records and nowhere else, so the +/// swap would drop it, and each is finished by whoever opened it. A refusal is +/// different: a change committed after the snapshot never goes away, so an +/// attach that meets one fails rather than waits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpenWork { + /// A merge is open. + Merge, + /// The store holds stashed work. + Stash, + /// An agent's transaction is open, or one is being recovered. + Transaction, + /// An entity draft has not been applied. + Draft, + /// A release request is recorded and not settled yet. + Release, +} + +impl OpenWork { + /// What is open, as a clause every surface can put after "waiting for". + pub const fn describe(self) -> &'static str { + match self { + Self::Merge => "an open merge to be finished", + Self::Stash => "stashed work to be restored or dropped", + Self::Transaction => "an agent's open transaction to be committed or abandoned", + Self::Draft => "an unapplied entity draft to be applied or discarded", + Self::Release => "a release being recorded to finish", + } + } +} + +impl std::fmt::Display for OpenWork { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.describe()) + } +} + +/// Whether an attach of the store staged at `stage_parent` would go ahead. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttachReadiness { + /// Nothing stands in the way. + Ready, + /// The store is already complete. + AlreadyComplete, + /// The partial store holds open work the attach waits for. + Waiting(OpenWork), +} + +/// The daemon's durable mirror of its agents' transactions, and the record it +/// leaves while one of their lifecycle writes is being recovered. The daemon +/// writes both and takes their names from here, because the attach has to +/// find them from every process that can run it. +pub const AGENT_TRANSACTIONS_RECORD: &str = "mcp_transactions.json"; +pub const AGENT_TRANSACTIONS_RECOVERY_RECORD: &str = "mcp_transactions.lifecycle.json"; +/// The directory the daemon keeps entity drafts in, and the lock inside it, +/// which is not a draft. Named here for the same reason. +pub const ENTITY_DRAFTS_DIR: &str = "entity-drafts-v1"; +pub const ENTITY_DRAFTS_LOCK: &str = "store.lock"; + +/// The open work the store at `layout` holds, if it holds any. +/// +/// Reads the store's own records, so it answers the same from a serving +/// daemon deciding whether now is a quiet point and from the attach itself. +/// A record that cannot be read counts as open work: the only safe answer to +/// "would the swap drop anything" when the answer is unknown is yes. +pub fn open_work(layout: &KinLayout) -> Option { + let root = layout.root(); + let present = |path: &Path| !matches!(crate::store_records::read(root, path), Ok(None)); + if present(&layout.merge_state_path()) { + return Some(OpenWork::Merge); + } + if holds_an_entry(&layout.stashes_dir(), None) { + return Some(OpenWork::Stash); + } + if present(&root.join(AGENT_TRANSACTIONS_RECOVERY_RECORD)) + || holds_an_open_transaction(root, &root.join(AGENT_TRANSACTIONS_RECORD)) + { + return Some(OpenWork::Transaction); + } + if holds_an_entry(&root.join(ENTITY_DRAFTS_DIR), Some(ENTITY_DRAFTS_LOCK)) { + return Some(OpenWork::Draft); + } + if present(&layout.pending_release_path()) { + return Some(OpenWork::Release); + } + None +} + +/// Whether `dir` holds any entry other than `ignored`. A directory that is +/// not there holds none; one that cannot be listed is taken to hold one. +fn holds_an_entry(dir: &Path, ignored: Option<&str>) -> bool { + match std::fs::read_dir(dir) { + Ok(mut entries) => entries.any(|entry| match entry { + Ok(entry) => ignored.is_none_or(|ignored| entry.file_name() != ignored), + Err(_) => true, + }), + Err(error) => error.kind() != std::io::ErrorKind::NotFound, + } +} + +/// Whether the transaction mirror at `path` holds a transaction that is +/// neither committed nor abandoned. A mirror that cannot be read as the map it +/// is counts as holding one. +fn holds_an_open_transaction(root: &Path, path: &Path) -> bool { + let raw = match crate::store_records::read(root, path) { + Ok(None) => return false, + Ok(Some(raw)) => raw, + Err(_) => return true, + }; + let Ok(serde_json::Value::Object(transactions)) = serde_json::from_slice(&raw) else { + return true; + }; + transactions.values().any(|transaction| { + !matches!( + transaction.get("state").and_then(serde_json::Value::as_str), + Some("committed" | "aborted") + ) + }) } /// Where a stage's complete store lives. @@ -187,8 +318,32 @@ pub fn attach_staged_history(layout: &KinLayout, stage_parent: &Path) -> Result< if recover_interrupted_attach(layout)? { return Ok(AttachOutcome::Attached); } + match attach_readiness(layout, stage_parent)? { + AttachReadiness::Ready => {} + AttachReadiness::AlreadyComplete => return Ok(AttachOutcome::AlreadyComplete), + AttachReadiness::Waiting(work) => return Ok(AttachOutcome::Deferred(work)), + } + + let journal = AttachJournal { + schema: ATTACH_JOURNAL_SCHEMA.to_string(), + phase: AttachPhase::Evacuating, + stage_parent: stage_parent.to_path_buf(), + }; + write_journal(layout, &journal)?; + roll_forward(layout, journal)?; + Ok(AttachOutcome::Attached) +} + +/// Whether attaching the store staged at `stage_parent` would go ahead now, +/// without moving anything. +/// +/// The attach asks this itself, with the runtime authority held and no daemon +/// serving, and that answer is the one that decides. A serving daemon asks it +/// earlier, only to avoid leaving its clients for an attach that would wait or +/// be refused anyway. An error is a refusal that waiting does not cure. +pub fn attach_readiness(layout: &KinLayout, stage_parent: &Path) -> Result { let record = match history_state::standing(layout) { - HistoryStanding::Complete => return Ok(AttachOutcome::AlreadyComplete), + HistoryStanding::Complete => return Ok(AttachReadiness::AlreadyComplete), HistoryStanding::Unreadable { reason } => { return Err(KinError::Other(format!( "the store's history record cannot be read ({reason}), so its history cannot be \ @@ -199,16 +354,25 @@ pub fn attach_staged_history(layout: &KinLayout, stage_parent: &Path) -> Result< }; let staged = KinLayout::new(staged_store(stage_parent)); verify_staged_store(layout, &staged)?; + if let Some(work) = open_work(layout) { + return Ok(AttachReadiness::Waiting(work)); + } verify_nothing_would_be_lost(layout, &record)?; + Ok(AttachReadiness::Ready) +} - let journal = AttachJournal { - schema: ATTACH_JOURNAL_SCHEMA.to_string(), - phase: AttachPhase::Evacuating, - stage_parent: stage_parent.to_path_buf(), - }; - write_journal(layout, &journal)?; - roll_forward(layout, journal)?; - Ok(AttachOutcome::Attached) +/// Whether a swap of the store at `layout` was interrupted and still has to +/// be finished. A journal that cannot be read counts: something is there. +/// +/// Until it is finished the store may hold neither its old records nor its +/// new ones, its manifest included, so a process that would read them has to +/// ask this first and finish the swap under the runtime authority. +pub fn attach_is_interrupted(layout: &KinLayout) -> bool { + let root = layout.root(); + !matches!( + crate::store_records::read(root, &root.join(ATTACH_JOURNAL_FILE)), + Ok(None) + ) } /// Finish a swap a crash interrupted. Answers whether there was one. @@ -240,6 +404,46 @@ pub fn recover_interrupted_attach(layout: &KinLayout) -> Result { Ok(true) } +/// Stop a swap after a number of its steps, as a crash would, so a test can +/// prove every step is one recovery resumes from. Compiled for tests only: +/// this crate's own, and those of a crate that takes its `test-support` +/// feature from a dev-dependency. +#[cfg(any(test, feature = "test-support"))] +pub mod interruption { + use std::cell::Cell; + + thread_local! { + static STEPS_LEFT: Cell> = const { Cell::new(None) }; + } + + /// Let this thread's swaps take `steps` more steps and then stop, or run + /// to their end with `None`. + pub fn after(steps: Option) { + STEPS_LEFT.with(|left| left.set(steps)); + } + + pub(super) fn step() -> crate::error::Result<()> { + STEPS_LEFT.with(|left| match left.get() { + None => Ok(()), + Some(0) => Err(crate::error::KinError::Other( + "history attach interrupted by its test".to_string(), + )), + Some(steps) => { + left.set(Some(steps - 1)); + Ok(()) + } + }) + } +} + +/// One step of a swap is about to change the store. Stops the swap here when a +/// test asked for an interruption, and is nothing otherwise. +fn before_step() -> Result<()> { + #[cfg(any(test, feature = "test-support"))] + interruption::step()?; + Ok(()) +} + fn roll_forward(layout: &KinLayout, mut journal: AttachJournal) -> Result<()> { let root = layout.root(); let stage_store = staged_store(&journal.stage_parent); @@ -252,6 +456,7 @@ fn roll_forward(layout: &KinLayout, mut journal: AttachJournal) -> Result<()> { } let from = root.join(&entry); let to = provisional.join(&entry); + before_step()?; if to.exists() { // Moved by an earlier attempt that died before the journal // advanced, and something recreated the name since. The @@ -264,6 +469,7 @@ fn roll_forward(layout: &KinLayout, mut journal: AttachJournal) -> Result<()> { } sync_directory(root)?; sync_directory(&provisional)?; + before_step()?; journal.phase = AttachPhase::Installing; write_journal(layout, &journal)?; } @@ -274,10 +480,14 @@ fn roll_forward(layout: &KinLayout, mut journal: AttachJournal) -> Result<()> { continue; } let from = stage_store.join(&entry); + before_step()?; std::fs::rename(&from, &to).map_err(|error| KinError::io(&from, error))?; } + // Not a step of its own: it is repeated by every recovery that reaches + // it, and writes the same setting each time. carry_enrichment_setting(&provisional, root)?; sync_directory(root)?; + before_step()?; let journal_path = root.join(ATTACH_JOURNAL_FILE); match std::fs::remove_file(&journal_path) { Ok(()) => {} @@ -340,25 +550,25 @@ fn verify_staged_store(layout: &KinLayout, staged: &KinLayout) -> Result<()> { staged.root().display() )) })?; + // Neither refusal names where the stage is kept: both are recorded in the + // store's history progress, which people read. if ours.repo_id != theirs.repo_id { return Err(KinError::Other(format!( - "the store staged at {} belongs to repository {}, not {}", - staged.root().display(), - theirs.repo_id, - ours.repo_id + "the staged history belongs to repository {}, not {}", + theirs.repo_id, ours.repo_id ))); } if !history_state::standing(staged).is_complete() { - return Err(KinError::Other(format!( - "the store staged at {} does not hold complete history", - staged.root().display() - ))); + return Err(KinError::Other( + "the staged store does not hold complete history".to_string(), + )); } Ok(()) } -/// Refuse a swap that would drop anything the partial store holds beyond its -/// snapshot. +/// Refuse a swap that would drop anything the partial store committed beyond +/// its snapshot. Work that is still open is [`open_work`]'s to find, and the +/// attach waits for it instead. fn verify_nothing_would_be_lost( layout: &KinLayout, record: &history_state::HistoryStateRecord, @@ -369,12 +579,6 @@ fn verify_nothing_would_be_lost( it, so this store stays partial; its history keeps answering as partial" ))) }; - if std::fs::symlink_metadata(layout.merge_state_path()).is_ok() { - return refuse("a merge is open in this store".to_string()); - } - if std::fs::read_dir(layout.stashes_dir()).is_ok_and(|mut entries| entries.next().is_some()) { - return refuse("this store holds stashes".to_string()); - } let manifest = KinManifest::load(&layout.manifest_path())?; let repository_id = kin_model::RepositoryId::new(manifest.repo_id.clone()) .map_err(|error| KinError::Other(format!("invalid repository identity: {error}")))?; diff --git a/crates/kin-core/src/history_state.rs b/crates/kin-core/src/history_state.rs index a877676b2..64c9f0b9e 100644 --- a/crates/kin-core/src/history_state.rs +++ b/crates/kin-core/src/history_state.rs @@ -218,8 +218,10 @@ impl DerivationState { pub struct HistoryDerivationProgress { pub schema: String, pub state: DerivationState, - /// The admission step the derivation is on, as the conversion ladder - /// names it. + /// The admission step a running derivation is on, as the conversion ladder + /// names it. For a staged derivation, what the switch to the full history + /// is waiting for, when a daemon has found it has to wait; see + /// [`Self::staged_waiting_for`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub phase: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -263,6 +265,22 @@ impl HistoryDerivationProgress { } } + /// The stage a staged derivation left its complete store in. + pub fn staged_at(&self) -> Option { + (self.state == DerivationState::Staged) + .then(|| self.detail.as_deref().map(PathBuf::from)) + .flatten() + } + + /// What the switch to a staged history is waiting for, as a clause that + /// follows "waiting for". `None` when the history is not staged, or when + /// nothing is known to hold the switch back. + pub fn staged_waiting_for(&self) -> Option<&str> { + (self.state == DerivationState::Staged) + .then_some(self.phase.as_deref()) + .flatten() + } + pub fn new(state: DerivationState) -> Self { Self { schema: HISTORY_PROGRESS_SCHEMA.to_string(), @@ -436,9 +454,15 @@ fn derivation_clause(mode: HistoryMode, progress: Option<&HistoryDerivationProgr _ => "the full history is deriving in the background".to_string(), } } - Some((DerivationState::Staged, _)) => "the full history is derived and replaces this \ - store when the daemon is next idle" - .to_string(), + Some((DerivationState::Staged, progress)) => match progress.staged_waiting_for() { + Some(waiting_for) => format!( + "the full history is derived, and this repository switches to it at its next \ + quiet point (waiting for {waiting_for})" + ), + None => "the full history is derived, and this repository switches to it at its \ + next quiet point" + .to_string(), + }, Some((DerivationState::Failed, progress)) => format!( "the last background derivation failed{} and is retried later", detail(progress) @@ -559,6 +583,31 @@ pub fn write_progress( write_json_atomically(layout.root(), &progress_path(layout), progress) } +/// Record what the switch to a staged history is waiting for, or with `None` +/// that nothing is known to hold it back. +/// +/// Changes nothing but that note and the time, and only on a progress that is +/// staged, so it can never turn a running or failed derivation into a staged +/// one. Answers whether it wrote: a note that already says this is left alone, +/// so a daemon that looks every few seconds does not rewrite the record every +/// few seconds. +pub fn note_staged_wait(layout: &KinLayout, waiting_for: Option<&str>) -> std::io::Result { + let HistoryStanding::Partial { + progress: Some(mut progress), + .. + } = standing(layout) + else { + return Ok(false); + }; + if progress.state != DerivationState::Staged || progress.phase.as_deref() == waiting_for { + return Ok(false); + } + progress.phase = waiting_for.map(str::to_string); + progress.updated_at = Utc::now(); + write_progress(layout, &progress)?; + Ok(true) +} + /// Remove the derivation progress, for a store whose derivation is over. pub fn clear_progress(layout: &KinLayout) -> std::io::Result<()> { crate::store_records::remove(layout.root(), &progress_path(layout)).map(|_| ()) @@ -641,11 +690,75 @@ mod tests { write_progress(&layout, &progress).unwrap(); let read = standing(&layout); assert!(!read.is_complete()); - assert!(read.sentence().unwrap().contains("replaces this store")); + let sentence = read.sentence().unwrap(); + assert!( + sentence.ends_with("switches to it at its next quiet point."), + "{sentence}" + ); clear_progress(&layout).unwrap(); clear_progress(&layout).unwrap(); } + /// A staged history that has to wait says what it waits for, in the + /// sentence and to a program, and names its stage only while it is staged. + #[test] + fn a_staged_history_that_waits_says_what_for() { + let dir = tempfile::tempdir().unwrap(); + let layout = layout_in(dir.path()); + stamp_staged(&layout, &record(HistoryMode::Background)).unwrap(); + let mut progress = HistoryDerivationProgress::new(DerivationState::Staged); + progress.detail = Some("/stages/one".to_string()); + assert_eq!(progress.staged_at(), Some(PathBuf::from("/stages/one"))); + assert_eq!(progress.staged_waiting_for(), None); + progress.phase = Some("an agent's open transaction to be committed".to_string()); + write_progress(&layout, &progress).unwrap(); + let HistoryStanding::Partial { + progress: Some(read), + .. + } = standing(&layout) + else { + panic!("the store reads back partial with its progress"); + }; + assert_eq!( + read.staged_waiting_for(), + Some("an agent's open transaction to be committed") + ); + let sentence = standing(&layout).sentence().unwrap(); + assert!( + sentence.contains("(waiting for an agent's open transaction to be committed)"), + "{sentence}" + ); + + // The note is written once, cleared once, and never onto a + // derivation that is not staged. + assert!(!note_staged_wait(&layout, read.staged_waiting_for()).unwrap()); + assert!(note_staged_wait(&layout, Some("an open merge to be finished")).unwrap()); + assert!(note_staged_wait(&layout, None).unwrap()); + assert!(!note_staged_wait(&layout, None).unwrap()); + let HistoryStanding::Partial { + progress: Some(cleared), + .. + } = standing(&layout) + else { + panic!("the store reads back partial with its progress"); + }; + assert_eq!(cleared.staged_waiting_for(), None); + assert_eq!(cleared.staged_at(), Some(PathBuf::from("/stages/one"))); + write_progress( + &layout, + &HistoryDerivationProgress::new(DerivationState::Failed), + ) + .unwrap(); + assert!(!note_staged_wait(&layout, Some("anything")).unwrap()); + + // A running derivation's phase is its admission step, never a wait. + let mut running = HistoryDerivationProgress::new(DerivationState::Running); + running.phase = Some("admit history".to_string()); + running.detail = Some("/stages/one".to_string()); + assert_eq!(running.staged_waiting_for(), None); + assert_eq!(running.staged_at(), None); + } + #[test] fn the_mode_can_change_but_never_to_full() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/kin-core/src/lib.rs b/crates/kin-core/src/lib.rs index 37615a603..f95dabf00 100644 --- a/crates/kin-core/src/lib.rs +++ b/crates/kin-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod error; pub mod exact_tree; pub mod file_limit; pub mod git_init; +pub mod git_update; pub mod graph_section; pub mod history_attach; pub mod history_state; @@ -57,6 +58,7 @@ pub mod repository_authority; pub mod repository_origin; pub mod resolver; pub mod retained_parse; +pub mod runtime_identity; #[cfg(any(test, feature = "test-support"))] pub mod session_publication_test_support; pub mod shims; @@ -127,6 +129,7 @@ pub use tree::{ validate_portable_source_symlink, validate_source_entry, validate_source_paths, validate_source_tree, verify_observed_target_tree_and_commit_repository_transaction, verify_repository_workspace_projection, + verify_repository_workspace_projection_and_commit_repository_transaction, verify_unchanged_source_tree_and_commit_repository_transaction, ExactProjectionDetachTarget, ExactProjectionEjectOutcome, ExactProjectionFreeze, ExactProjectionGitStage, ExactProjectionVerification, ExactSessionProjection, SourceProjectionDisposition, diff --git a/crates/kin-core/src/registry.rs b/crates/kin-core/src/registry.rs index 96bec8ec2..1ce3887dc 100644 --- a/crates/kin-core/src/registry.rs +++ b/crates/kin-core/src/registry.rs @@ -1960,19 +1960,19 @@ pub(crate) fn resolve_registry_path( managed_home().join("registry.toml") } -/// Directory of the machine-level daemon supervisor. -/// -/// Keyed on the real home, or on the parent of an explicit `KIN_REGISTRY_PATH`, -/// deliberately *not* on `KIN_HOME`. One supervisor holds daemons launched under -/// several managed homes, so every daemon records [`managed_kin_home`] and the -/// census partitions on it. Moving the supervisor with `KIN_HOME` would hide -/// from a pinned session the daemons it shares the box with, which is the -/// opposite of what that variable is for. +/// Supervisor authority is scoped to the selected home and runtime mode. +/// Old global or development supervisors remain outside normal discovery. pub fn supervisor_root() -> PathBuf { - resolve_supervisor_root( - |key| std::env::var_os(key), - || directories::BaseDirs::new().map(|b| b.home_dir().to_path_buf()), - ) + let home = managed_kin_home(); + match crate::runtime_identity::RuntimeMode::current() { + Ok(crate::runtime_identity::RuntimeMode::Managed) => home.join("services").join("managed"), + _ => { + let isolated_home = std::env::var_os("KIN_REGISTRY_PATH") + .and_then(|path| PathBuf::from(path).parent().map(Path::to_path_buf)) + .unwrap_or(home); + isolated_home.join("services").join("development") + } + } } /// The policy behind [`supervisor_root`], with the environment and the OS home @@ -1981,6 +1981,7 @@ pub fn supervisor_root() -> PathBuf { /// This reproduces, case for case, what the supervisor directory was before the /// registry moved into the managed home: the parent of an explicit /// `KIN_REGISTRY_PATH`, or `/.kin`. +#[cfg(test)] pub(crate) fn resolve_supervisor_root( var_os: impl Fn(&str) -> Option, base_dirs_home: impl FnOnce() -> Option, diff --git a/crates/kin-core/src/runtime_identity.rs b/crates/kin-core/src/runtime_identity.rs new file mode 100644 index 000000000..d38db91fa --- /dev/null +++ b/crates/kin-core/src/runtime_identity.rs @@ -0,0 +1,1094 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Exact process identity and the separation between managed and development services. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +pub const RUNTIME_MODE_ENV: &str = "KIN_RUNTIME_MODE"; +pub const RUNTIME_IDENTITY_HEADER: &str = "x-kin-runtime-identity"; +pub const RUNTIME_REPOSITORY_HEADER: &str = "x-kin-repository-identity"; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RuntimeRepositoryIdentity { + pub repository_id: String, + pub store_namespace: String, +} + +impl RuntimeRepositoryIdentity { + pub fn fingerprint(&self) -> String { + let bytes = serde_json::to_vec(&( + "kin.runtime-repository.v1", + &self.repository_id, + &self.store_namespace, + )) + .expect("repository identity is serializable"); + let mut digest = sha2::Sha256::new(); + digest.update(bytes); + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + + pub fn validate_for(&self, expected: &RuntimeRepositoryBinding) -> Result { + expected.revalidate()?; + if self != expected.identity() { + return Err("repository identity differs from the selected repository; reconnect without changing the intended repository".into()); + } + Ok(expected.fingerprint()) + } +} + +#[derive(Debug, Clone)] +pub struct RuntimeRepositoryBinding { + identity: RuntimeRepositoryIdentity, + store: RuntimeNamespace, +} + +impl RuntimeRepositoryBinding { + pub fn capture(repository_id: &str, root: &Path) -> Result { + Self::from_store_namespace(repository_id, RuntimeNamespace::capture(root)?) + } + + fn from_store_namespace(repository_id: &str, store: RuntimeNamespace) -> Result { + if repository_id.trim().is_empty() { + return Err("selected repository has no logical identity".into()); + } + Ok(Self { + identity: RuntimeRepositoryIdentity { + repository_id: repository_id.to_owned(), + store_namespace: store.token().to_owned(), + }, + store, + }) + } + + pub fn for_store(root: &Path) -> Result { + let store = RuntimeNamespace::capture(root)?; + let repository_id = store.read_repository_id()?; + store.revalidate()?; + Self::from_store_namespace(&repository_id, store) + } + + pub fn for_layout(layout: &crate::layout::KinLayout) -> Result { + Self::for_store(layout.root()) + } + + pub fn identity(&self) -> &RuntimeRepositoryIdentity { + &self.identity + } + + pub fn fingerprint(&self) -> String { + self.identity.fingerprint() + } + + pub fn revalidate(&self) -> Result<(), String> { + self.store.revalidate() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeMode { + Managed, + Development, +} + +impl RuntimeMode { + pub fn current() -> Result { + Self::resolve( + std::env::var(RUNTIME_MODE_ENV).ok().as_deref(), + &RuntimeIdentity::current(), + ) + } + + pub fn resolve(value: Option<&str>, identity: &RuntimeIdentity) -> Result { + match value { + Some("managed") => { + identity.require_managed()?; + Ok(Self::Managed) + } + Some("development") => Ok(Self::Development), + Some(value) => Err(format!( + "{RUNTIME_MODE_ENV} must be managed or development, got {value:?}" + )), + None if identity.require_managed().is_ok() => Ok(Self::Managed), + None => Ok(Self::Development), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Managed => "managed", + Self::Development => "development", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeIdentity { + pub version: String, + pub commit_sha: String, + pub source_dirty: bool, + pub source_known: bool, + pub target: String, + pub build_profile: String, +} + +impl RuntimeIdentity { + pub fn current() -> Self { + let build = kin_buildinfo::get(); + Self { + version: env!("CARGO_PKG_VERSION").to_string(), + commit_sha: build.sha.to_string(), + source_dirty: build.dirty, + source_known: build.source_known, + target: build.target.to_string(), + build_profile: build.build_profile.to_string(), + } + } + + pub fn require_managed(&self) -> Result<(), String> { + if !self.source_known + || self.source_dirty + || self.build_profile != "release" + || self.commit_sha.len() != 40 + || !self + .commit_sha + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || self.target.is_empty() + { + return Err("managed runtime requires a clean release build with a full known source commit and target; select development mode for custom builds".into()); + } + Ok(()) + } + + /// A version label alone cannot establish that these processes were built together. + pub fn validate_service( + &self, + actual: &Self, + expected_mode: RuntimeMode, + actual_mode: RuntimeMode, + ) -> Result<(), String> { + if !self.source_known + || !actual.source_known + || self.commit_sha.len() != 40 + || actual.commit_sha.len() != 40 + { + return Err("runtime pair has unknown source provenance; rebuild both components from known source".into()); + } + if expected_mode != actual_mode { + return Err(format!( + "runtime mode mismatch: expected {}, service reports {}; the other service was left running", + expected_mode.as_str(), + actual_mode.as_str() + )); + } + if expected_mode == RuntimeMode::Managed { + self.require_managed()?; + actual.require_managed()?; + } + if self != actual { + return Err(format!( + "runtime identity mismatch: CLI {} {} ({}, {}) and service {} {} ({}, {}); update the managed runtime together or select an isolated development runtime", + self.version, + self.commit_sha, + self.target, + self.build_profile, + actual.version, + actual.commit_sha, + actual.target, + actual.build_profile + )); + } + Ok(()) + } + + /// CLI and daemon siblings share a development namespace; other build directories do not. + pub fn development_key(&self, binary_directory: &Path) -> String { + let mut digest = Sha256::new(); + digest.update(serde_json::to_vec(self).expect("runtime identity is serializable")); + digest.update([0]); + digest.update(binary_directory.as_os_str().to_string_lossy().as_bytes()); + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + + pub fn fingerprint(&self, mode: RuntimeMode, namespace: &str) -> String { + let mut digest = Sha256::new(); + digest.update(serde_json::to_vec(self).expect("runtime identity is serializable")); + digest.update([0]); + digest.update(mode.as_str().as_bytes()); + digest.update([0]); + digest.update(namespace.as_bytes()); + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + + pub fn development_home( + &self, + managed_home: &Path, + binary_directory: &Path, + ) -> std::path::PathBuf { + managed_home + .join("development") + .join(self.development_key(binary_directory)) + } +} + +/// A retained local home directory, independent of repository/store authority. +/// The token binds its canonical name and native object identity. Keeping the +/// directory open prevents an unlinked object's identity from being recycled +/// while this runtime is still using the old profile. +#[derive(Clone, Debug)] +pub struct RuntimeNamespace { + selected_home: PathBuf, + token: String, + #[cfg(any(unix, windows))] + _directory: Arc, +} + +static LOCAL_SERVICE_NAMESPACE: OnceLock = OnceLock::new(); + +impl RuntimeNamespace { + fn read_repository_id(&self) -> Result { + #[cfg(any(unix, windows))] + { + let directory = self._directory.try_clone().map_err(|error| { + format!("cannot retain the selected repository directory: {error}") + })?; + let bytes = crate::store_records::read_retained( + &self.selected_home, + directory, + Path::new("manifest.json"), + ) + .map_err(|error| format!("selected repository identity is unavailable: {error}"))? + .ok_or_else(|| "selected repository has no manifest record".to_string())?; + let manifest: crate::manifest::KinManifest = serde_json::from_slice(&bytes) + .map_err(|error| format!("selected repository manifest is invalid: {error}"))?; + Ok(manifest.repo_id) + } + #[cfg(not(any(unix, windows)))] + { + Err("native repository identity is unsupported on this platform".into()) + } + } + + pub fn current() -> Result { + Self::capture(&crate::registry::managed_kin_home()) + } + + pub fn capture(home: &Path) -> Result { + #[cfg(any(unix, windows))] + { + let refusal = |error: std::io::Error| { + format!("cannot bind the selected Kin home runtime namespace: {error}") + }; + let canonical = home.canonicalize().map_err(refusal)?; + let directory = cap_std::fs::Dir::open_ambient_dir(home, cap_std::ambient_authority()) + .map_err(refusal)?; + let object = runtime_directory_identity(&directory).map_err(refusal)?; + let observed = cap_std::fs::Dir::open_ambient_dir(home, cap_std::ambient_authority()) + .map_err(refusal)?; + if home.canonicalize().map_err(refusal)? != canonical + || runtime_directory_identity(&observed).map_err(refusal)? != object + { + return Err( + "selected Kin home changed while its runtime namespace was captured".into(), + ); + } + let mut digest = Sha256::new(); + digest.update(canonical.as_os_str().as_encoded_bytes()); + digest.update([0]); + digest.update(object); + let token = digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok(Self { + selected_home: home.to_path_buf(), + token, + _directory: Arc::new(directory), + }) + } + #[cfg(not(any(unix, windows)))] + { + let _ = home; + Err("native runtime namespace identity is unsupported on this platform".into()) + } + } + + pub fn token(&self) -> &str { + &self.token + } + + pub fn revalidate(&self) -> Result<(), String> { + let actual = Self::capture(&self.selected_home)?; + if actual.token != self.token { + return Err("selected Kin home runtime namespace was replaced; restart the runtime using its current profile".into()); + } + Ok(()) + } + + /// Real local services call this before profile/model/store loading. Test + /// routers can capture their own namespace without mutating global state. + pub fn initialize_local_service() -> Result<(), String> { + let home = crate::registry::managed_kin_home(); + crate::registry::create_kin_dir_all(&home) + .map_err(|error| format!("cannot prepare selected Kin home: {error}"))?; + let namespace = Self::capture(&home)?; + if let Some(existing) = LOCAL_SERVICE_NAMESPACE.get() { + existing.revalidate()?; + if existing.token != namespace.token { + return Err( + "local service runtime namespace was already captured for another home".into(), + ); + } + return Ok(()); + } + LOCAL_SERVICE_NAMESPACE + .set(namespace) + .map_err(|_| "local service runtime namespace capture raced".to_string()) + } + + pub fn for_service() -> Result { + match LOCAL_SERVICE_NAMESPACE.get() { + Some(namespace) => { + namespace.revalidate()?; + Ok(namespace.clone()) + } + None => Self::current(), + } + } +} + +#[cfg(unix)] +fn runtime_directory_identity(directory: &cap_std::fs::Dir) -> std::io::Result> { + use cap_std::fs::MetadataExt as _; + let metadata = directory.dir_metadata()?; + let mut identity = metadata.dev().to_le_bytes().to_vec(); + identity.extend_from_slice(&metadata.ino().to_le_bytes()); + Ok(identity) +} + +#[cfg(windows)] +fn runtime_directory_identity(directory: &cap_std::fs::Dir) -> std::io::Result> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileIdInfo, GetFileInformationByHandleEx, FILE_ID_INFO, + }; + let mut info: FILE_ID_INFO = unsafe { std::mem::zeroed() }; + // SAFETY: the retained Dir handle is live; the output has the exact API layout. + let inspected = unsafe { + GetFileInformationByHandleEx( + directory.as_raw_handle().cast(), + FileIdInfo, + (&raw mut info).cast(), + std::mem::size_of::() as u32, + ) + }; + if inspected == 0 { + return Err(std::io::Error::last_os_error()); + } + if info.VolumeSerialNumber == 0 || info.FileId.Identifier.iter().all(|byte| *byte == 0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Windows runtime namespace returned a zero volume or FILE_ID_128 identity", + )); + } + let mut identity = info.VolumeSerialNumber.to_le_bytes().to_vec(); + identity.extend_from_slice(&info.FileId.Identifier); + Ok(identity) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeServiceIdentity { + pub schema: String, + pub identity: RuntimeIdentity, + pub mode: RuntimeMode, + pub component: String, + pub pid: u32, + #[serde(default)] + pub namespace: Option, + #[serde(default)] + pub repository: Option, +} + +impl RuntimeServiceIdentity { + pub fn current(component: &str) -> Result { + Ok(Self { + schema: "kin.runtime-service.v1".into(), + identity: RuntimeIdentity::current(), + mode: RuntimeMode::current()?, + component: component.into(), + pid: std::process::id(), + namespace: if component == "hosted-daemon" { + None + } else { + Some(RuntimeNamespace::for_service()?.token().to_owned()) + }, + repository: None, + }) + } + + pub fn validate_for_current(&self, component: &str) -> Result<(), String> { + self.validate_for( + component, + &RuntimeIdentity::current(), + RuntimeMode::current()?, + &RuntimeNamespace::current()?, + ) + } + + /// Admit a daemon request under its declared local or hosted authority. + /// Hosted release authority is independent of this machine's installation. + pub fn admit_daemon_for( + &self, + identity: &RuntimeIdentity, + mode: RuntimeMode, + namespace: Option<&RuntimeNamespace>, + ) -> Result, String> { + if self.schema == "kin.runtime-service.v1" + && self.component == "hosted-daemon" + && self.namespace.is_none() + { + // Each release must be eligible under the same mode, while the + // hosted service's source identity is independent of the client. + identity.validate_service(identity, mode, mode)?; + self.identity + .validate_service(&self.identity, mode, self.mode)?; + return Ok(None); + } + let namespace = namespace.ok_or_else(|| { + "local runtime request has no admitted Kin home namespace".to_string() + })?; + self.validate_for("daemon", identity, mode, namespace)?; + Ok(Some(identity.fingerprint(mode, namespace.token()))) + } + + fn validate_for( + &self, + component: &str, + identity: &RuntimeIdentity, + mode: RuntimeMode, + namespace: &RuntimeNamespace, + ) -> Result<(), String> { + if self.schema != "kin.runtime-service.v1" || self.component != component { + return Err(format!( + "endpoint does not acknowledge the {component} runtime identity contract" + )); + } + identity.validate_service(&self.identity, mode, self.mode)?; + namespace.revalidate()?; + if self.namespace.as_deref() != Some(namespace.token()) { + return Err("local runtime namespace differs from the selected Kin home; the other service was left running".into()); + } + Ok(()) + } +} + +/// Where a running daemon's executable lives, as far as replacing it goes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PeerLocation { + /// The executable could not be read. + Unknown, + /// An installed release outside the managed home, such as an npm, + /// Homebrew or cargo install. + Installed, + /// The managed home's own binaries, or an update's backup of them: an + /// older generation of the managed install. + ManagedGeneration, + /// A build directory rather than an installed release, and why. + BuildDirectory(String), +} + +/// What a running daemon says about the release it runs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PeerReport { + /// It serves `/runtime/identity`. + Identified { + identity: RuntimeIdentity, + mode: RuntimeMode, + namespace: Option, + }, + /// A release from before runtime identity, as its `/health` reads. + Legacy { + version: Option, + commit: Option, + dirty: bool, + source_known: Option, + }, +} + +/// A running daemon of some release, read before a client of another release +/// decides whether to replace it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeerRelease { + pub pid: u32, + pub executable: Option, + pub location: PeerLocation, + pub report: PeerReport, +} + +impl PeerRelease { + fn named(&self) -> String { + format!( + "Kin daemon pid {} ({})", + self.pid, + self.executable.as_deref().map_or_else( + || "executable unknown".to_string(), + |path| path.display().to_string() + ) + ) + } + + fn version(&self) -> Option<&str> { + match &self.report { + PeerReport::Identified { identity, .. } => Some(&identity.version), + PeerReport::Legacy { version, .. } => version.as_deref(), + } + } + + fn commit(&self) -> Option<&str> { + match &self.report { + PeerReport::Identified { identity, .. } => Some(&identity.commit_sha), + PeerReport::Legacy { commit, .. } => commit.as_deref(), + } + } + + /// Why this daemon is a development build, when it is one. + fn development(&self) -> Option { + let reported = match &self.report { + PeerReport::Identified { + mode: RuntimeMode::Development, + .. + } => Some("it runs in development mode".to_string()), + PeerReport::Identified { identity, .. } => identity + .require_managed() + .err() + .map(|_| "it is not a clean release build".to_string()), + PeerReport::Legacy { dirty: true, .. } => { + Some("its build has uncommitted source changes".to_string()) + } + PeerReport::Legacy { + source_known: Some(false), + .. + } => Some("its build does not know its source commit".to_string()), + PeerReport::Legacy { .. } => None, + }; + reported.or_else(|| match &self.location { + PeerLocation::BuildDirectory(why) => Some(why.clone()), + _ => None, + }) + } +} + +/// The client deciding whether a running daemon may stay, be attached to or +/// be replaced: its own release, mode and Kin home. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SuccessionClient { + pub identity: RuntimeIdentity, + pub mode: RuntimeMode, + /// The Kin home namespace it serves, when it has one to compare. + pub namespace: Option, +} + +/// What a client does about a running daemon of a release that is not +/// exactly its own. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Succession { + /// The daemon runs this client's release: use it. + Attach, + /// The daemon runs an older release this client supersedes: retire it and + /// start this release's. + Replace, + /// A newer release. An older one never retires it. + Newer(String), + /// A development build. It is never replaced, only stopped by whoever + /// runs it. + Development(String), + /// This client is not the installed release, a long-running client of a + /// release an update replaced. It retires nothing and is told to restart. + ClientSuperseded(String), + /// Anything else that refuses, as before: a development client meeting a + /// managed daemon, another Kin home, an unreadable release. + Refuse(String), +} + +impl Succession { + /// The sentence a refusal is told in, or `None` for attach and replace. + pub fn refusal(&self) -> Option<&str> { + match self { + Self::Attach | Self::Replace => None, + Self::Newer(message) + | Self::Development(message) + | Self::ClientSuperseded(message) + | Self::Refuse(message) => Some(message), + } + } +} + +/// `Kin 0.8.7 (0123abc)`. +pub fn release_label(version: &str, commit: &str) -> String { + let commit: String = commit.chars().take(7).collect(); + if commit.is_empty() { + format!("Kin {version}") + } else { + format!("Kin {version} ({commit})") + } +} + +fn same_commit(left: &str, right: &str) -> bool { + let (left, right) = (left.to_ascii_lowercase(), right.to_ascii_lowercase()); + left.len().min(right.len()) >= 7 && (left.starts_with(&right) || right.starts_with(&left)) +} + +/// Whether `client` attaches to, replaces or leaves the daemon `peer`. +/// `installed` is the managed pair on disk, when it was read: one Kin per +/// machine, and the client that is not it retires nothing. +/// +/// Only an older release is ever replaced. A newer release, a development +/// build, a daemon whose release cannot be read, another Kin home, and every +/// daemon a development client meets are left running, and each refusal names +/// the daemon's pid and executable. +pub fn succession( + client: &SuccessionClient, + installed: Option<&RuntimeIdentity>, + peer: &PeerRelease, +) -> Succession { + if let PeerReport::Identified { + identity, + mode, + namespace, + } = &peer.report + { + if identity == &client.identity + && *mode == client.mode + && (client.namespace.is_none() || namespace == &client.namespace) + { + return Succession::Attach; + } + } + let named = peer.named(); + if let Some(why) = peer.development() { + return Succession::Development(format!( + "{named} was left running: {why}, and a development build is never replaced. \ + Stop it with `kin daemon stop` in its repository" + )); + } + if client.mode != RuntimeMode::Managed { + return Succession::Refuse(format!( + "{named} runs another Kin, and a development build never replaces a release; \ + it was left running" + )); + } + if let PeerReport::Identified { + namespace: Some(namespace), + .. + } = &peer.report + { + if client + .namespace + .as_ref() + .is_some_and(|own| own != namespace) + { + return Succession::Refuse(format!( + "{named} serves another Kin home and was left running" + )); + } + } + if let Some(installed) = installed.filter(|installed| **installed != client.identity) { + return Succession::ClientSuperseded(format!( + "Kin was updated to {}; restart this client, which is {}", + release_label(&installed.version, &installed.commit_sha), + release_label(&client.identity.version, &client.identity.commit_sha) + )); + } + let Some(version) = peer.version() else { + return Succession::Refuse(format!( + "{named} does not report which release it runs and was left running" + )); + }; + let parse = |version: &str| semver::Version::parse(version.trim().trim_start_matches('v')); + let (Ok(peer_version), Ok(client_version)) = (parse(version), parse(&client.identity.version)) + else { + return Succession::Refuse(format!( + "{named} reports release {version:?}, which cannot be ordered against this Kin; \ + it was left running" + )); + }; + let client_label = release_label(&client.identity.version, &client.identity.commit_sha); + match peer_version.cmp(&client_version) { + std::cmp::Ordering::Less => Succession::Replace, + std::cmp::Ordering::Greater => Succession::Newer(format!( + "{named} runs Kin {peer_version}, newer than {client_label}. An older release never \ + retires a newer one; run `kin update`" + )), + std::cmp::Ordering::Equal + if peer + .commit() + .is_some_and(|commit| same_commit(commit, &client.identity.commit_sha)) => + { + Succession::Attach + } + std::cmp::Ordering::Equal if peer.location == PeerLocation::ManagedGeneration => { + Succession::Replace + } + std::cmp::Ordering::Equal => Succession::Refuse(format!( + "{named} runs another build of Kin {peer_version}, and two builds of one version \ + cannot be ordered; it was left running" + )), + } +} + +#[cfg(test)] +mod tests { + #[test] + fn repository_binding_retained_manifest_keeps_identity_coherent_across_aba_alias() { + let parent = tempfile::tempdir().unwrap(); + let selected = parent.path().join("selected"); + let replacement = parent.path().join("replacement"); + let retired = parent.path().join("retired"); + std::fs::create_dir(&selected).unwrap(); + std::fs::create_dir(&replacement).unwrap(); + let mut first = crate::manifest::KinManifest::new(); + first.repo_id = "repository-a".into(); + first.save(&selected.join("manifest.json")).unwrap(); + let mut second = crate::manifest::KinManifest::new(); + second.repo_id = "repository-b".into(); + second.save(&replacement.join("manifest.json")).unwrap(); + let retained = super::RuntimeNamespace::capture(&selected).unwrap(); + let original_namespace = retained.token().to_owned(); + + std::fs::rename(&selected, &retired).unwrap(); + std::fs::rename(&replacement, &selected).unwrap(); + assert_eq!( + crate::manifest::KinManifest::load(&selected.join("manifest.json")) + .unwrap() + .repo_id, + "repository-b", + "the mutable path must actually name B during this read" + ); + let retained_id = retained.read_repository_id().unwrap(); + std::fs::rename(&selected, &replacement).unwrap(); + std::fs::rename(&retired, &selected).unwrap(); + retained.revalidate().unwrap(); + + let binding = + super::RuntimeRepositoryBinding::from_store_namespace(&retained_id, retained).unwrap(); + assert_eq!(binding.identity().repository_id, "repository-a"); + assert_eq!(binding.identity().store_namespace, original_namespace); + assert_eq!( + binding.identity(), + super::RuntimeRepositoryBinding::for_store(&selected) + .unwrap() + .identity() + ); + } + + use super::*; + + fn clean_identity() -> RuntimeIdentity { + RuntimeIdentity { + version: "0.8.6".into(), + commit_sha: "a".repeat(40), + source_dirty: false, + source_known: true, + target: "aarch64-apple-darwin".into(), + build_profile: "release".into(), + } + } + + #[test] + fn equal_versions_with_different_source_commits_are_refused() { + let cli = clean_identity(); + let mut daemon = cli.clone(); + daemon.commit_sha = "b".repeat(40); + assert!(cli + .validate_service(&daemon, RuntimeMode::Managed, RuntimeMode::Managed) + .unwrap_err() + .contains("identity mismatch")); + } + + #[test] + fn development_service_cannot_answer_a_managed_caller() { + let cli = clean_identity(); + let mut daemon = cli.clone(); + daemon.source_dirty = true; + assert!(cli + .validate_service(&daemon, RuntimeMode::Managed, RuntimeMode::Development) + .unwrap_err() + .contains("mode mismatch")); + assert!(RuntimeMode::resolve(Some("managed"), &daemon).is_err()); + assert_eq!( + RuntimeMode::resolve(None, &daemon).unwrap(), + RuntimeMode::Development + ); + } + + #[test] + fn explicit_development_is_isolated_by_build_directory() { + let mut identity = clean_identity(); + identity.build_profile = "debug".into(); + assert_eq!( + RuntimeMode::resolve(Some("development"), &identity).unwrap(), + RuntimeMode::Development + ); + assert_ne!( + identity.development_key(Path::new("/build/one")), + identity.development_key(Path::new("/build/two")) + ); + assert!(identity + .validate_service( + &identity, + RuntimeMode::Development, + RuntimeMode::Development + ) + .is_ok()); + } + + #[test] + fn same_build_different_home_metadata_is_refused() { + let service_home = tempfile::tempdir().unwrap(); + let caller_home = tempfile::tempdir().unwrap(); + let service_namespace = RuntimeNamespace::capture(service_home.path()).unwrap(); + let caller_namespace = RuntimeNamespace::capture(caller_home.path()).unwrap(); + let identity = clean_identity(); + let service = RuntimeServiceIdentity { + schema: "kin.runtime-service.v1".into(), + identity: identity.clone(), + mode: RuntimeMode::Managed, + component: "daemon".into(), + pid: std::process::id(), + namespace: Some(service_namespace.token().to_owned()), + repository: None, + }; + service + .validate_for( + "daemon", + &identity, + RuntimeMode::Managed, + &service_namespace, + ) + .unwrap(); + assert!(service + .validate_for("daemon", &identity, RuntimeMode::Managed, &caller_namespace) + .unwrap_err() + .contains("namespace differs")); + } + + fn managed_client(version: &str) -> SuccessionClient { + SuccessionClient { + identity: RuntimeIdentity { + version: version.into(), + ..clean_identity() + }, + mode: RuntimeMode::Managed, + namespace: Some("home-a".into()), + } + } + + fn identified(version: &str, commit: &str, mode: RuntimeMode, home: &str) -> PeerRelease { + PeerRelease { + pid: 4242, + executable: Some(PathBuf::from("/home/someone/.kin/bin/kin-daemon")), + location: PeerLocation::ManagedGeneration, + report: PeerReport::Identified { + identity: RuntimeIdentity { + version: version.into(), + commit_sha: commit.into(), + ..clean_identity() + }, + mode, + namespace: Some(home.into()), + }, + } + } + + fn legacy(version: &str, location: PeerLocation) -> PeerRelease { + PeerRelease { + pid: 4242, + executable: Some(PathBuf::from("/opt/homebrew/bin/kin-daemon")), + location, + report: PeerReport::Legacy { + version: Some(version.into()), + commit: Some("b".repeat(40)), + dirty: false, + source_known: Some(true), + }, + } + } + + /// Every row of the replacement rule, as the outcome each must reach. + #[test] + fn succession_follows_the_one_kin_per_machine_rule_row_by_row() { + let client = managed_client("0.8.7"); + let installed = client.identity.clone(); + let a = "a".repeat(40); + let c = "c".repeat(40); + let rows: Vec<( + &str, + SuccessionClient, + Option, + PeerRelease, + &str, + )> = vec![ + ( + "the daemon is this client's release", + client.clone(), + Some(installed.clone()), + identified("0.8.7", &a, RuntimeMode::Managed, "home-a"), + "attach", + ), + ( + "an older managed daemon of this home", + client.clone(), + Some(installed.clone()), + identified("0.8.6", &c, RuntimeMode::Managed, "home-a"), + "replace", + ), + ( + "an older clean release from before runtime identity", + client.clone(), + Some(installed.clone()), + legacy("0.8.6", PeerLocation::Installed), + "replace", + ), + ( + "another build of this version from the managed install", + client.clone(), + Some(installed.clone()), + legacy("0.8.7", PeerLocation::ManagedGeneration), + "replace", + ), + ( + "another build of this version from somewhere else", + client.clone(), + Some(installed.clone()), + legacy("0.8.7", PeerLocation::Installed), + "refuse", + ), + ( + "a newer release", + client.clone(), + Some(installed.clone()), + legacy("0.9.0", PeerLocation::Installed), + "newer", + ), + ( + "a daemon in development mode", + client.clone(), + Some(installed.clone()), + identified("0.8.6", &c, RuntimeMode::Development, "home-a"), + "development", + ), + ( + "a legacy daemon from a build directory", + client.clone(), + Some(installed.clone()), + legacy( + "0.8.6", + PeerLocation::BuildDirectory("it runs from a cargo target directory".into()), + ), + "development", + ), + ( + "a client of a release an update replaced", + managed_client("0.8.6"), + Some(installed.clone()), + legacy("0.8.5", PeerLocation::Installed), + "client superseded", + ), + ( + "a development client meeting a managed daemon", + SuccessionClient { + mode: RuntimeMode::Development, + ..client.clone() + }, + None, + legacy("0.8.6", PeerLocation::Installed), + "refuse", + ), + ( + "a daemon of another Kin home", + client.clone(), + Some(installed.clone()), + identified("0.8.6", &c, RuntimeMode::Managed, "home-b"), + "refuse", + ), + ( + "a daemon that does not say its release", + client.clone(), + None, + PeerRelease { + report: PeerReport::Legacy { + version: None, + commit: None, + dirty: false, + source_known: None, + }, + ..legacy("0.0.0", PeerLocation::Installed) + }, + "refuse", + ), + ]; + for (row, client, installed, peer, expected) in rows { + let outcome = succession(&client, installed.as_ref(), &peer); + let reached = match &outcome { + Succession::Attach => "attach", + Succession::Replace => "replace", + Succession::Newer(_) => "newer", + Succession::Development(_) => "development", + Succession::ClientSuperseded(_) => "client superseded", + Succession::Refuse(_) => "refuse", + }; + assert_eq!(reached, expected, "{row}: {outcome:?}"); + if let Some(refusal) = outcome.refusal() { + if !matches!(outcome, Succession::ClientSuperseded(_)) { + assert!( + refusal.contains("pid 4242") && refusal.contains("kin-daemon"), + "{row}: a refusal names the daemon's pid and executable: {refusal}" + ); + } + } + } + } + + #[test] + fn a_superseded_client_is_told_which_release_replaced_it() { + let client = managed_client("0.8.6"); + let installed = managed_client("0.8.7").identity; + let Succession::ClientSuperseded(message) = succession( + &client, + Some(&installed), + &legacy("0.8.5", PeerLocation::Installed), + ) else { + panic!("a client that is not the installed release retires nothing"); + }; + assert_eq!( + message, + "Kin was updated to Kin 0.8.7 (aaaaaaa); restart this client, which is Kin 0.8.6 (aaaaaaa)" + ); + let Succession::Newer(newer) = succession( + &managed_client("0.8.7"), + None, + &legacy("0.9.0", PeerLocation::Installed), + ) else { + panic!("a newer daemon is never retired"); + }; + assert!(newer.contains("run `kin update`"), "{newer}"); + } +} diff --git a/crates/kin-core/src/store_records.rs b/crates/kin-core/src/store_records.rs index 628ef012b..2c0c5a38d 100644 --- a/crates/kin-core/src/store_records.rs +++ b/crates/kin-core/src/store_records.rs @@ -56,6 +56,15 @@ pub(crate) fn read(root: &Path, path: &Path) -> io::Result>> { store.read_record(relative(root, path)?, MAX_RECORD_BYTES) } +#[cfg(any(unix, windows))] +pub(crate) fn read_retained( + root: &Path, + directory: cap_std::fs::Dir, + name: &Path, +) -> io::Result>> { + StoreDir::from_retained_directory(root, directory).read_record(name, MAX_RECORD_BYTES) +} + /// Replace the record at `path` in the store at `root` atomically and /// durably, creating the store and the record's directory when they are /// missing. A reader sees the previous record or this one, never a torn one. diff --git a/crates/kin-core/src/test_env.rs b/crates/kin-core/src/test_env.rs index 6b036c5b0..9b328c4c6 100644 --- a/crates/kin-core/src/test_env.rs +++ b/crates/kin-core/src/test_env.rs @@ -342,6 +342,7 @@ pub fn install_test_registry() -> &'static std::path::Path { install_process_wide("KIN_REGISTRY_PATH", &path); path }); + let _ = crate::registry::create_kin_dir_all(&crate::registry::managed_kin_home()); path } diff --git a/crates/kin-core/src/tree.rs b/crates/kin-core/src/tree.rs index abd5f324e..223888530 100644 --- a/crates/kin-core/src/tree.rs +++ b/crates/kin-core/src/tree.rs @@ -721,32 +721,7 @@ pub fn verify_repository_workspace_projection( ) })?; - let entry_refs = entries.iter().collect::>(); - let materialized_identities = projection - .projection - .validate_frozen_entries_unchanged(&entry_refs)?; - let mut graph_only_proofs = Vec::new(); - for artifact in tree.artifacts_by_path() { - let disposition = source_projection_disposition(&artifact.path, artifact.entry)?; - if disposition != SourceProjectionDisposition::Materialized { - graph_only_proofs.push(( - artifact.path.clone(), - disposition, - projection - .projection - .verify_frozen_graph_only(&artifact.path, disposition)?, - )); - } - } - projection - .projection - .revalidate_frozen_entries_unchanged(&entry_refs, &materialized_identities)?; - for (path, disposition, proof) in &graph_only_proofs { - projection - .projection - .revalidate_frozen_graph_only(path, *disposition, proof)?; - } - projection.revalidate_namespace()?; + verify_frozen_workspace_projection(&projection, tree, &entries)?; Ok((entries.len(), authority_freeze)) } #[cfg(not(any(unix, windows)))] @@ -756,6 +731,95 @@ pub fn verify_repository_workspace_projection( } } +/// Verify that the complete current repository workspace still matches its +/// exact derived projection, then publish one repository transaction that +/// leaves every workspace where it is, while the projection stays frozen. +/// +/// History admitted onto the branch a workspace tracks is such a transaction: +/// it moves the ref and leaves the workspace on its previous base until a +/// separate transition follows the ref. The model refuses a workspace mutation +/// that changes nothing, so such a transaction carries none, and this is the +/// seam that still proves the tracked bytes before it publishes. The +/// transaction's own root compare-and-swap is what stops a concurrent change +/// to the workspace; the projection lock is held from the proof through the +/// commit, in the global projection-then-authority order. +pub fn verify_repository_workspace_projection_and_commit_repository_transaction( + root: &Path, + tree: &ResolvedTree, + authority: &RepositoryAuthorityManager, + transaction: RepositoryTransaction, +) -> Result<( + usize, + RepositoryCommitReceipt, + LocalRepositoryAuthorityFreeze, +)> { + if transaction.workspace_mutation.is_some() { + return Err(KinError::Other( + "a transaction that moves a workspace publishes through a workspace transition" + .to_string(), + )); + } + #[cfg(any(unix, windows))] + { + let projection = + ExactProjectionFreeze::acquire_existing_for_repository_transition(root, authority)?; + let owned = load_repository_projection_entries(authority, tree, "current workspace")?; + let entries = validated_source_entries( + owned + .iter() + .map(|entry| (&entry.path, entry.kind, entry.content.as_slice())), + )?; + validate_repository_projection_entries_match_tree("current workspace", tree, &entries)?; + validate_projection_proof_paths(tree.artifacts_by_path().map(|artifact| &artifact.path))?; + verify_frozen_workspace_projection(&projection, tree, &entries)?; + let (receipt, freeze) = + commit_repository_transaction_exact_and_freeze(authority, transaction).into_result()?; + Ok((entries.len(), receipt, freeze)) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (root, tree, authority, transaction); + Err(unsupported_safe_projection_error()) + } +} + +/// Prove every member of `tree` unchanged in the frozen projection, the +/// materialized ones by their exact bytes and the graph-only ones by their +/// retained representation, twice, and the projection root still in place. +#[cfg(any(unix, windows))] +fn verify_frozen_workspace_projection( + projection: &ExactProjectionFreeze, + tree: &ResolvedTree, + entries: &[ValidatedSourceEntry<'_>], +) -> Result<()> { + let entry_refs = entries.iter().collect::>(); + let materialized_identities = projection + .projection + .validate_frozen_entries_unchanged(&entry_refs)?; + let mut graph_only_proofs = Vec::new(); + for artifact in tree.artifacts_by_path() { + let disposition = source_projection_disposition(&artifact.path, artifact.entry)?; + if disposition != SourceProjectionDisposition::Materialized { + graph_only_proofs.push(( + artifact.path.clone(), + disposition, + projection + .projection + .verify_frozen_graph_only(&artifact.path, disposition)?, + )); + } + } + projection + .projection + .revalidate_frozen_entries_unchanged(&entry_refs, &materialized_identities)?; + for (path, disposition, proof) in &graph_only_proofs { + projection + .projection + .revalidate_frozen_graph_only(path, *disposition, proof)?; + } + projection.revalidate_namespace() +} + /// One bounded read-only observation of the derived projection against the /// exact workspace tree graph authority owns. /// diff --git a/crates/kin-daemon-spawn/src/lib.rs b/crates/kin-daemon-spawn/src/lib.rs index a42bd00f8..472cfa447 100644 --- a/crates/kin-daemon-spawn/src/lib.rs +++ b/crates/kin-daemon-spawn/src/lib.rs @@ -44,7 +44,8 @@ mod runtime_authority; pub use runtime_authority::{ acquire_repository_runtime_authority, acquire_repository_runtime_authority_within, - RepositoryRuntimeAuthority, REPOSITORY_RUNTIME_AUTHORITY_RETRY_BUDGET, + repository_runtime_authority_is_held, RepositoryRuntimeAuthority, + REPOSITORY_RUNTIME_AUTHORITY_RETRY_BUDGET, }; /// The port argument every daemon spawn passes. @@ -5820,6 +5821,19 @@ pub enum RunningDaemonRouteReading { Absent, } +/// What became of asking this process's client to replace the daemon at an +/// endpoint that refused it as another release. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SupersededReplacement { + /// The older daemon is gone. The caller starts, or reaches, this + /// release's. + Replaced, + /// It was left running, and the sentence says why: a newer release, a + /// development build, a client an update replaced, a daemon still + /// finishing its work, or one that is not this repository's to replace. + Refused(String), +} + pub trait DaemonSpawnRegistrar: Send + Sync { /// The supervisor endpoint a daemon about to be spawned should be told /// about, starting the supervisor if it is not already up. @@ -5861,6 +5875,39 @@ pub trait DaemonSpawnRegistrar: Send + Sync { } }) } + + /// Probe this endpoint for this repository without discovering another + /// route or starting anything. A legacy registrar cannot certify it. + fn reading_at( + &self, + _kin_root: PathBuf, + _endpoint: String, + _budget: std::time::Duration, + ) -> std::pin::Pin + Send>> + { + Box::pin(async { + RunningDaemonRouteReading::Unresolved { + detail: "the installed registrar has no endpoint-specific readiness probe" + .to_string(), + } + }) + } + + /// Replace the daemon at `endpoint`, serving `kin_root`, which refused this + /// client as another release: only when it is an older release this client + /// supersedes, within `budget`. Starts nothing; the caller does. + fn replace_superseded( + &self, + _kin_root: PathBuf, + _endpoint: String, + _budget: std::time::Duration, + ) -> std::pin::Pin + Send>> { + Box::pin(async { + SupersededReplacement::Refused( + "the installed registrar cannot replace a daemon of another release".to_string(), + ) + }) + } } /// Resolve the supervisor endpoint through the installed seam, if there is one. @@ -5895,6 +5942,41 @@ pub async fn running_daemon_route_reading( .await } +/// Certify the caller's chosen endpoint for this repository within `budget`. +/// This observer never substitutes a configured or discovered route. +pub async fn running_daemon_route_reading_at( + kin_root: &Path, + endpoint: &str, + budget: std::time::Duration, +) -> RunningDaemonRouteReading { + let Some(registrar) = registrar() else { + return RunningDaemonRouteReading::Unresolved { + detail: "no daemon registrar is installed in this process".to_string(), + }; + }; + registrar + .reading_at(kin_root.to_path_buf(), endpoint.to_string(), budget) + .await +} + +/// Replace the daemon at `endpoint`, which refused this client as another +/// release, through the installed seam. A missing seam replaces nothing. +pub async fn replace_superseded_daemon( + kin_root: &Path, + endpoint: &str, + budget: std::time::Duration, +) -> SupersededReplacement { + let Some(registrar) = registrar() else { + return SupersededReplacement::Refused( + "no daemon registrar is installed in this process to replace a daemon of another release" + .to_string(), + ); + }; + registrar + .replace_superseded(kin_root.to_path_buf(), endpoint.to_string(), budget) + .await +} + static REGISTRAR: OnceLock> = OnceLock::new(); /// Install the process-wide registrar. Returns whether this call installed it; diff --git a/crates/kin-daemon-spawn/src/runtime_authority.rs b/crates/kin-daemon-spawn/src/runtime_authority.rs index 3edc124ca..d49bca0a9 100644 --- a/crates/kin-daemon-spawn/src/runtime_authority.rs +++ b/crates/kin-daemon-spawn/src/runtime_authority.rs @@ -106,6 +106,33 @@ pub fn acquire_repository_runtime_authority_within( }) } +/// Whether some process holds the repository's runtime authority right now, +/// without taking it. +/// +/// A store with no `daemon.lock` has never had a holder. The probe takes the +/// lock and gives it back at once when it is free, and writes nothing, so the +/// owner stamp of a holder that comes next is the holder's own; a starter that +/// meets the probe in that instant retries, as it retries any contention. +pub fn repository_runtime_authority_is_held(kin_root: &Path) -> std::io::Result { + let file = match OpenOptions::new() + .read(true) + .write(true) + .open(kin_root.join("daemon.lock")) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + match file.try_lock_exclusive() { + Ok(()) => { + FileExt::unlock(&file)?; + Ok(false) + } + Err(error) if error.kind() == fs2::lock_contended_error().kind() => Ok(true), + Err(error) => Err(error), + } +} + fn try_acquire_repository_runtime_authority( kin_root: &Path, deadline: Instant, @@ -201,6 +228,34 @@ mod tests { /// hold for. Several retry intervals is the width that answers both. const REACQUIRE_BUDGET: Duration = Duration::from_millis(1_000); + #[test] + fn a_probe_sees_a_holder_and_never_takes_the_authority() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join(".kin"); + std::fs::create_dir(&root).unwrap(); + assert!(!repository_runtime_authority_is_held(&root).unwrap()); + assert!( + !root.join("daemon.lock").exists(), + "a probe created the lock" + ); + + let held = acquire_repository_runtime_authority(&root, "owner-a") + .unwrap() + .expect("the first holder acquires"); + assert!(repository_runtime_authority_is_held(&root).unwrap()); + drop(held); + assert!(!repository_runtime_authority_is_held(&root).unwrap()); + // The probe left the lock free and unstamped for the next holder. + let next = acquire_repository_runtime_authority_within(&root, REACQUIRE_BUDGET, "owner-b") + .unwrap() + .expect("the authority is free after a probe"); + let mut owner_stamp = String::new(); + let mut reader = &next.file; + reader.seek(SeekFrom::Start(0)).unwrap(); + reader.read_to_string(&mut owner_stamp).unwrap(); + assert_eq!(owner_stamp, "owner-b"); + } + #[test] fn authority_is_canonical_exclusive_and_never_unlinks_its_inode() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/kin-daemon/Cargo.toml b/crates/kin-daemon/Cargo.toml index a2c53479b..5d9bb93bc 100644 --- a/crates/kin-daemon/Cargo.toml +++ b/crates/kin-daemon/Cargo.toml @@ -67,6 +67,7 @@ chrono = { workspace = true } uuid = { workspace = true } hex = { workspace = true } sha2 = { workspace = true } +similar = { workspace = true } socket2 = { workspace = true } async-stream = "0.3" base64 = "0.23" diff --git a/crates/kin-daemon/src/agent_activity.rs b/crates/kin-daemon/src/agent_activity.rs index 2f2c5c472..7a3b91e3d 100644 --- a/crates/kin-daemon/src/agent_activity.rs +++ b/crates/kin-daemon/src/agent_activity.rs @@ -18,6 +18,11 @@ //! //! The log lives and dies with the session: ending or reaping a session drops //! its log, so `/agents` describes agents that are here now. +//! +//! It also holds, briefly, which sessions were handed a reviewer's open flag +//! ([`AgentActivity::note_flag_sightings`]). Those outlive the session, because +//! they are on their way into the repository: `crate::review_flags` writes +//! them into the flag once the agents that saw it have gone quiet. use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, OnceLock}; @@ -54,6 +59,9 @@ pub const MAX_TOOL_NAME_BYTES: usize = 64; pub const MAX_ENTITY_ID_CANDIDATES: usize = 2048; /// Committed change ids one session keeps. pub const MAX_CHANGES_PER_SESSION: usize = 16; +/// Flag sightings held before they are written into the repository. Past it, +/// the oldest goes. +pub const MAX_PENDING_FLAG_SIGHTINGS: usize = 1024; /// At most one `ToolCall` event per session in this window. pub const TOOL_CALL_EVENT_MIN_INTERVAL: Duration = Duration::from_millis(250); @@ -98,6 +106,13 @@ pub struct ToolCallEntry { /// named. Absent for a call on the daemon's own repository. #[serde(default, skip_serializing_if = "Option::is_none")] pub repo_id: Option, + /// The change a write committed, as its answer named it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub change_id: Option, + /// The call finished after the agent that made it stopped waiting for its + /// answer, so what it did is known here and the agent was never told. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub caller_left: bool, } /// What a call contributes to its entry before the log numbers and stamps it. @@ -110,6 +125,10 @@ pub struct ToolCallDraft { pub duration_ms: u64, pub is_error: bool, pub repo_id: Option, + /// The change a write committed. + pub change_id: Option, + /// The caller stopped waiting before the call finished. + pub caller_left: bool, } /// One entry as the event bus carries it. @@ -208,6 +227,8 @@ impl CallLog { duration_ms: draft.duration_ms, is_error: draft.is_error, repo_id: draft.repo_id, + change_id: draft.change_id, + caller_left: draft.caller_left, }; log.calls.push_back((order, entry.clone())); let mut added = 1; @@ -342,11 +363,34 @@ impl EventGate { } } +/// One session that was handed one open review flag, not yet written into +/// the flag. +pub type FlagSighting = (kin_model::AnnotationId, kin_model::ReviewFlagSighting); + +#[derive(Debug, Default)] +struct FlagSightings { + pending: VecDeque, + /// A task is running that will write what is pending. + settling: bool, +} + +/// What one session's log held when the session ended. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct EndedLog { + pub calls_total: u64, + /// Its newest calls, oldest first. + pub calls: Vec, + /// The changes it committed, newest first. + pub changes: Vec, + pub last_call_at: Option, +} + /// The call logs and the event gate, owned by the session coordinator. #[derive(Debug, Default)] pub struct AgentActivity { log: Mutex, events: Mutex, + flag_sightings: Mutex, } fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { @@ -433,15 +477,104 @@ impl AgentActivity { .unwrap_or_default() } - /// Drop everything held for a session that ended. - pub fn forget(&self, session_id: &SessionId) { - { + /// Drop everything held for a session that ended, and hand back what its + /// log held so the session can be kept past its end + /// ([`crate::ended_sessions`]). + pub fn forget(&self, session_id: &SessionId) -> EndedLog { + let ended = { let mut log = lock(&self.log); - if let Some(evicted) = log.sessions.remove(session_id) { - log.entries -= evicted.calls.len(); + match log.sessions.remove(session_id) { + Some(evicted) => { + log.entries -= evicted.calls.len(); + EndedLog { + calls_total: evicted.calls_total, + calls: evicted.calls.into_iter().map(|(_, entry)| entry).collect(), + changes: evicted.changes.into_iter().collect(), + last_call_at: evicted.last_call_at, + } + } + None => EndedLog::default(), } - } + }; lock(&self.events).forget(session_id); + ended + } + + /// Hold that `sighting`'s session was handed each flag in `flags`, until + /// it is written into the repository. A flag the session is already held + /// for is not held twice. Returns whether the caller must start the task + /// that writes them: true for exactly the call that found none running. + pub fn note_flag_sightings( + &self, + flags: &[kin_model::AnnotationId], + sighting: &kin_model::ReviewFlagSighting, + ) -> bool { + let mut held = lock(&self.flag_sightings); + let mut added = false; + for flag in flags { + let known = held + .pending + .iter() + .any(|(pending, seen)| pending == flag && seen.session == sighting.session); + if !known { + held.pending.push_back((*flag, sighting.clone())); + added = true; + } + } + while held.pending.len() > MAX_PENDING_FLAG_SIGHTINGS { + held.pending.pop_front(); + } + let start = added && !held.settling; + if start { + held.settling = true; + } + start + } + + /// The sightings held, oldest first. + pub fn pending_flag_sightings(&self) -> Vec { + lock(&self.flag_sightings).pending.iter().cloned().collect() + } + + /// Take the held sightings `ready` accepts, oldest first, leaving the + /// rest held. + pub fn take_flag_sightings(&self, ready: impl Fn(&FlagSighting) -> bool) -> Vec { + let mut held = lock(&self.flag_sightings); + let (taken, kept): (VecDeque<_>, VecDeque<_>) = std::mem::take(&mut held.pending) + .into_iter() + .partition(ready); + held.pending = kept; + taken.into() + } + + /// Hold sightings again that could not be written, ahead of any noted + /// since they were taken. Returns whether the caller must start the task + /// that writes them, as [`Self::note_flag_sightings`] does. + pub fn return_flag_sightings(&self, unwritten: Vec) -> bool { + let mut held = lock(&self.flag_sightings); + if unwritten.is_empty() { + return false; + } + for sighting in unwritten.into_iter().rev() { + held.pending.push_front(sighting); + } + while held.pending.len() > MAX_PENDING_FLAG_SIGHTINGS { + held.pending.pop_front(); + } + let start = !held.settling; + held.settling = true; + start + } + + /// Whether the task that writes sightings may stop: nothing is held. It + /// is marked stopped under the lock a new sighting takes, so one noted + /// after this starts a new task. + pub fn flag_sightings_settled(&self) -> bool { + let mut held = lock(&self.flag_sightings); + if held.pending.is_empty() { + held.settling = false; + } + held.pending.is_empty() } /// Entries held across every log. @@ -561,6 +694,11 @@ pub fn argument_uuids(arguments: &HashMap) -> Vec String { static REGISTERED_NAMES: OnceLock> = OnceLock::new(); + // A keyed mutate reaches the daemon under its versioned internal name, + // which is no tool an agent sees: it called `kin_mutate`. + if tool == crate::mcp_mutate::TOOL { + return "kin_mutate".to_string(); + } if tool.len() <= MAX_TOOL_NAME_BYTES && REGISTERED_NAMES .get_or_init(|| { @@ -625,21 +763,47 @@ pub fn draft_for_call( duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX), is_error: result.is_error == Some(true), repo_id: None, + change_id: None, + caller_left: false, } } +/// Whether a call writes: it opens, stages, commits or drops a change, or +/// mutates in one call. A write lands whether or not its caller is still +/// waiting when it ends, so its record must too; see [`record_tool_call`]. +pub fn is_write_tool(tool: &str) -> bool { + tool.starts_with("kin_transaction") + || matches!(tool, "kin_mutate" | "mutate" | "transaction") + || tool == crate::mcp_mutate::TOOL +} + /// Tools whose calls are session lifecycle rather than work. -fn is_lifecycle_tool(tool: &str) -> bool { +pub(crate) fn is_lifecycle_tool(tool: &str) -> bool { matches!( tool, "kin_session_start" | "kin_session_heartbeat" | "kin_session_end" | "register_session" ) } +/// How one finished call met the session that made it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CallMeeting { + /// The session was live when the call began. + pub began_live: bool, + /// The caller stopped waiting before the call finished. + pub caller_left: bool, +} + /// Record one finished `/mcp/tools/call` for the session that made it, and /// put it on the event bus within the gate's bound. Returns whether the daemon -/// holds that session at all; a call naming one it does not hold is not -/// recorded. +/// still holds that session; a call naming one it never held is not recorded. +/// +/// A call whose session ended while it ran, as one does when its agent gives +/// up on a slow commit and closes its connection, is kept on what was kept of +/// the session ([`crate::session_registry::SessionCoordinator::record_finished_call`]), +/// so what the agent did is never lost to how long Kin took to do it. A write +/// says the change it committed, and every call says whether its caller had +/// stopped waiting. pub(crate) async fn record_tool_call( state: &Arc, session_id: SessionId, @@ -647,6 +811,7 @@ pub(crate) async fn record_tool_call( targets: &[Uuid], result: &kin_mcp::ToolCallResult, duration: Duration, + meeting: CallMeeting, ) -> bool { let coordinator = &state.coordinator; let is_live = || { @@ -660,18 +825,22 @@ pub(crate) async fn record_tool_call( return is_live(); } let graph = state.graph_for_request(Some(&session_id)).await; - let draft = draft_for_call(tool, targets, result, duration, |id| { + let mut draft = draft_for_call(tool, targets, result, duration, |id| { graph.get_entity(id).ok().flatten().is_some() }); - let Some(entry) = - coordinator - .activity() - .record_call_if(&session_id, draft, Timestamp::now(), is_live) - else { - return false; - }; - publish_tool_call(state, session_id, entry); - true + if is_write_tool(tool) { + draft.change_id = committed_change_id(result); + } + draft.caller_left = meeting.caller_left; + match coordinator.record_finished_call(&session_id, draft, Timestamp::now(), meeting.began_live) + { + crate::session_registry::FinishedCall::Live(entry) => { + publish_tool_call(state, session_id, entry); + true + } + crate::session_registry::FinishedCall::AfterEnd + | crate::session_registry::FinishedCall::Unknown => false, + } } /// Record one finished `/repos/{repo_id}/mcp/tools/call` for the session its @@ -820,8 +989,7 @@ pub(crate) fn attribute_commit(state: &DaemonState, owner: Option<&str>, change_ let session_id = SessionId(owner); state .coordinator - .activity() - .record_change(&session_id, change_id.to_string()); + .record_committed_change(&session_id, change_id.to_string()); let Ok(hash) = kin_model::Hash256::from_hex(change_id) else { return; }; @@ -946,7 +1114,7 @@ pub fn scrub_task(text: &str, limit: usize) -> String { cut } -fn transport_name(transport: SessionTransport) -> &'static str { +pub(crate) fn transport_name(transport: SessionTransport) -> &'static str { match transport { SessionTransport::Mcp => "mcp", SessionTransport::Cli => "cli", @@ -1015,8 +1183,60 @@ pub struct SessionCallsResponse { pub calls_total: u64, pub retained: usize, pub calls: Vec, + /// When the session ended, for one the daemon keeps past its end. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ended_at: Option, +} + +/// The agents that worked in this repository and are gone, as +/// `/agents/ended` shows them. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EndedAgentsResponse { + pub schema: String, + pub generated_at: Timestamp, + /// How long Kin keeps a session after it ends. + pub kept_days: u64, + /// How many ended sessions Kin keeps at most, the newest. + pub kept_sessions: usize, + /// Newest activity first. + pub agents: Vec, +} + +/// One agent session that is gone: who it was, when it worked, what it read, +/// what it wrote and the changes it committed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EndedAgentView { + pub session_id: String, + pub client: String, + pub vendor: String, + /// How it connected. Empty when Kin kept only what it wrote. + pub transport: String, + pub started_at: Timestamp, + pub last_activity_at: Timestamp, + pub ended_at: Timestamp, + /// Whether Kin kept the session's calls. When it did not, what Kin kept + /// is what the session wrote, and what it read is not known. + pub calls_kept: bool, + pub calls_total: u64, + pub calls_retained: usize, + /// Its newest calls, newest first. + pub recent_calls: Vec, + /// The entities its kept reads were about, newest first, each once: what + /// each read asked about first, then what its answers named. + pub read_entity_ids: Vec, + /// How many distinct entities its kept reads were about. + pub read_entities: usize, + /// The entities it wrote, through Kin or with its own tools, newest + /// first, each once. + pub edited_entity_ids: Vec, + pub edited_entities: usize, + /// The changes it committed, newest first. + pub change_ids: Vec, } +/// Entities an ended agent's read or write list names at most. +pub const ENDED_ENTITIES_SHOWN: usize = 24; + fn seconds_between(earlier: &Timestamp, later: &Timestamp) -> u64 { later .0 @@ -1162,9 +1382,11 @@ pub(crate) fn agents_response(state: &DaemonState) -> Result = transactions @@ -1216,7 +1438,8 @@ pub(crate) struct CallsParams { limit: Option, } -/// GET /session/{session_id}/calls — one live session's call log, newest first. +/// GET /session/{session_id}/calls — one session's call log, newest first: +/// a live session's own, or what was kept of one that ended. pub(crate) async fn session_calls_route( Path(session_id): Path, Query(params): Query, @@ -1229,21 +1452,36 @@ pub(crate) async fn session_calls_route( ) })?; let session_id = SessionId(uuid); + let limit = params + .limit + .unwrap_or(DEFAULT_CALLS_LIMIT) + .clamp(1, MAX_CALLS_PER_SESSION); if state .coordinator .get_session(&session_id) .map_err(internal)? .is_none() { - return Err(( - StatusCode::NOT_FOUND, - format!("session not found: {session_id}"), - )); + // A session that ended answers from what was kept of it. + let Some(ended) = state + .agent_authorship + .ended_session(&session_id.to_string(), &Timestamp::now()) + else { + return Err(( + StatusCode::NOT_FOUND, + format!("session not found: {session_id}"), + )); + }; + let retained = ended.calls.len(); + return Ok(Json(SessionCallsResponse { + schema: "kin.agent-calls.v1".to_string(), + session_id: session_id.to_string(), + calls_total: ended.calls_total, + retained, + calls: ended.calls.into_iter().rev().take(limit).collect(), + ended_at: Some(ended.ended_at), + })); } - let limit = params - .limit - .unwrap_or(DEFAULT_CALLS_LIMIT) - .clamp(1, MAX_CALLS_PER_SESSION); let (calls_total, retained, calls) = state .coordinator .activity() @@ -1254,9 +1492,308 @@ pub(crate) async fn session_calls_route( calls_total, retained, calls, + ended_at: None, })) } +/// Whether a logged call read the graph, rather than opening, staging or +/// committing a change or managing its session. +pub fn is_read_tool(tool: &str) -> bool { + !(is_lifecycle_tool(tool) + || tool.starts_with("kin_transaction") + || tool.starts_with("kin_session") + || matches!( + tool, + "kin_mutate" | "mutate" | "transaction" | "unrecognized_tool" + )) +} + +/// What a session's kept calls read: the entity each read asked about first, +/// newest first, then the others their answers named. At most `shown`, with +/// the count of every distinct one. +pub fn read_entities(calls_newest_first: &[ToolCallEntry], shown: usize) -> (Vec, usize) { + let reads: Vec<&ToolCallEntry> = calls_newest_first + .iter() + .filter(|call| is_read_tool(&call.tool) && !call.is_error) + .collect(); + let mut seen = std::collections::HashSet::new(); + let mut ids = Vec::new(); + for id in reads.iter().filter_map(|call| call.entity_ids.first()) { + if seen.insert(*id) { + ids.push(*id); + } + } + for id in reads.iter().flat_map(|call| call.entity_ids.iter()) { + if seen.insert(*id) { + ids.push(*id); + } + } + let total = ids.len(); + ids.truncate(shown); + (ids, total) +} + +fn parse_time(at: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(at.trim()) + .ok() + .map(|at| Timestamp(at.with_timezone(&chrono::Utc))) +} + +/// What a session wrote, newest first, each once, and the changes its writes +/// were published as, newest first, from the record of what agents wrote. +fn written_by( + session: &str, + writes: &[kin_cli::commands::review_guide::AgentWrite], + edits: &[kin_cli::commands::review_guide::ClientEdit], +) -> (Vec, Vec) { + let mut entities = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut changes = Vec::new(); + let mut dated: Vec<(&str, &[EntityId], Option<&str>)> = writes + .iter() + .filter(|write| write.session.eq_ignore_ascii_case(session)) + .map(|write| { + ( + write.at.as_str(), + write.entities.as_slice(), + Some(write.change_id.as_str()), + ) + }) + .chain( + edits + .iter() + .filter(|edit| { + edit.kin_session + .as_deref() + .is_some_and(|kin| kin.eq_ignore_ascii_case(session)) + }) + .map(|edit| (edit.at.as_str(), edit.entities.as_slice(), None)), + ) + .collect(); + dated.sort_by(|left, right| { + let at = |text: &str| parse_time(text); + at(right.0).cmp(&at(left.0)) + }); + for (_, written, change) in dated { + for id in written { + if seen.insert(*id) { + entities.push(*id); + } + } + if let Some(change) = change { + let change = change.to_ascii_lowercase(); + if !changes.contains(&change) { + changes.push(change); + } + } + } + (entities, changes) +} + +/// The agents that worked here and are gone, newest activity first: each +/// session the daemon kept past its end, each one still listed whose process +/// is gone, and each session the record of what agents wrote names that +/// neither holds. That last kind ended before its calls were kept, so only +/// what it wrote is known. All of them inside the window ended sessions are +/// kept for, and no more of them than are kept. +pub(crate) fn ended_agents_response( + state: &DaemonState, + calls_shown: usize, +) -> Result { + use crate::ended_sessions::{within_window, ENDED_KEPT_DAYS, MAX_ENDED_SESSIONS}; + + let now = Timestamp::now(); + let coordinator = &state.coordinator; + let writes = state.agent_authorship.writes(); + let edits = state.agent_authorship.edits(); + let sessions = coordinator.list_sessions().map_err(internal)?; + let mut agents: Vec = Vec::new(); + let mut known: std::collections::HashSet = std::collections::HashSet::new(); + + let view = |session_id: &str, + vendor: &str, + client: &str, + transport: &str, + started_at: Timestamp, + last_activity_at: Timestamp, + ended_at: Timestamp, + calls: Option<(u64, Vec)>, + changes: Vec| + -> EndedAgentView { + let (written, written_changes) = written_by(session_id, &writes, &edits); + let mut change_ids = changes; + for change in written_changes { + if !change_ids.contains(&change) { + change_ids.push(change); + } + } + change_ids.truncate(crate::ended_sessions::MAX_ENDED_CHANGES); + let calls_kept = calls.is_some(); + let (calls_total, newest_first) = calls.unwrap_or_default(); + let (read_entity_ids, read_entities) = read_entities(&newest_first, ENDED_ENTITIES_SHOWN); + let edited_entities = written.len(); + EndedAgentView { + session_id: session_id.to_ascii_lowercase(), + client: client.to_string(), + vendor: vendor.to_string(), + transport: transport.to_string(), + started_at, + last_activity_at, + ended_at, + calls_kept, + calls_total, + calls_retained: newest_first.len(), + recent_calls: newest_first.into_iter().take(calls_shown).collect(), + read_entity_ids, + read_entities, + edited_entity_ids: written.into_iter().take(ENDED_ENTITIES_SHOWN).collect(), + edited_entities, + change_ids, + } + }; + + // Still listed, but its process is gone: it left, and the next sweep + // reaps it. Its log is still the live one. + for session in sessions + .iter() + .filter(|session| session.vendor != DAEMON_OWN_VENDOR && coordinator.process_gone(session)) + { + let (calls_total, _, newest_first) = coordinator + .activity() + .recent_calls(&session.session_id, MAX_CALLS_PER_SESSION); + let changes = coordinator.activity().recent_changes(&session.session_id); + if calls_total == 0 && changes.is_empty() { + continue; + } + let mut last = session.started_at.clone(); + if session.last_heartbeat > last { + last = session.last_heartbeat.clone(); + } + let last = coordinator + .activity() + .last_call_at(&session.session_id) + .unwrap_or(last); + let id = session.session_id.to_string(); + known.insert(id.to_ascii_lowercase()); + agents.push(view( + &id, + &display_name(&session.vendor, "unknown"), + &display_name(&session.client_name, "unnamed client"), + transport_name(session.transport), + session.started_at.clone(), + last.clone(), + last, + Some((calls_total, newest_first)), + changes, + )); + } + // Every session still listed is here now, or was just shown as gone. + for session in &sessions { + known.insert(session.session_id.to_string().to_ascii_lowercase()); + } + + for ended in state.agent_authorship.ended_sessions(&now) { + let id = ended.session_id.to_ascii_lowercase(); + if !known.insert(id.clone()) { + continue; + } + let mut newest_first = ended.calls; + newest_first.reverse(); + agents.push(view( + &id, + &ended.vendor, + &ended.client, + &ended.transport, + ended.started_at, + ended.last_activity_at, + ended.ended_at, + Some((ended.calls_total, newest_first)), + ended.change_ids, + )); + } + + // Sessions known only by what they wrote: the newest write names them. + let mut by_session: Vec<(String, Timestamp, Timestamp, String, String)> = Vec::new(); + for write in writes.iter().rev() { + let id = write.session.trim().to_ascii_lowercase(); + if known.contains(&id) || !within_window(&write.at, &now) { + continue; + } + let Some(at) = parse_time(&write.at) else { + continue; + }; + match by_session.iter_mut().find(|(session, ..)| *session == id) { + Some((_, first, last, ..)) => { + if at < *first { + *first = at; + } else if at > *last { + *last = at; + } + } + None => by_session.push(( + id, + at.clone(), + at, + write.vendor.clone(), + write.client.clone(), + )), + } + } + for (id, first, last, vendor, client) in by_session { + if vendor == DAEMON_OWN_VENDOR { + continue; + } + agents.push(view( + &id, + &display_name(&vendor, "unknown"), + &display_name(&client, "unnamed client"), + "", + first, + last.clone(), + last, + None, + Vec::new(), + )); + } + + agents.sort_by(|left, right| { + right + .last_activity_at + .cmp(&left.last_activity_at) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + agents.truncate(MAX_ENDED_SESSIONS); + Ok(EndedAgentsResponse { + schema: "kin.agents-ended.v1".to_string(), + generated_at: now, + kept_days: ENDED_KEPT_DAYS, + kept_sessions: MAX_ENDED_SESSIONS, + agents, + }) +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EndedParams { + /// The newest calls shown for each agent. + #[serde(default)] + calls: Option, +} + +/// GET /agents/ended — the agents that worked here and are gone, path-free. +pub(crate) async fn ended_agents_route( + Query(params): Query, + State(state): State>, +) -> Result, (StatusCode, String)> { + let shown = params + .calls + .unwrap_or(AGENT_RECENT_CALLS) + .min(crate::ended_sessions::MAX_ENDED_CALLS); + tokio::task::spawn_blocking(move || ended_agents_response(&state, shown)) + .await + .map_err(internal)? + .map(Json) +} + #[cfg(test)] mod tests { use super::*; @@ -1271,6 +1808,8 @@ mod tests { duration_ms: 3, is_error: false, repo_id: None, + change_id: None, + caller_left: false, } } @@ -1285,6 +1824,8 @@ mod tests { duration_ms: 0, is_error: false, repo_id: None, + change_id: None, + caller_left: false, } } diff --git a/crates/kin-daemon/src/agent_authorship.rs b/crates/kin-daemon/src/agent_authorship.rs new file mode 100644 index 000000000..dc61287d0 --- /dev/null +++ b/crates/kin-daemon/src/agent_authorship.rs @@ -0,0 +1,1444 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! What agents did in this repository, kept in repository authority. +//! +//! A write through Kin lands as a recorded change, and the change names the +//! session that made it as its author. The change can hold more than that +//! session wrote: work it carried in from the working copy, an edit beside the +//! written entity in the same source unit, and entities whose span only moved. +//! Attributing every entity a change holds to its author would name the agent +//! for edits it never made, and the review that is sent back to that agent +//! would ask it about them. +//! +//! So this keeps, for each change an agent published, the entities its own +//! operations wrote: the entity a patch, an update, a removal or a content +//! replacement named, and the entity a create declared, each only when the +//! change holds a delta for it. Read from the operations at the moment the +//! commit lands, which is the only time anybody knows which of the change's +//! entities they named. Beside those writes it keeps the edits agents made +//! with their own tools, which [`crate::client_edits`] confirms and hands +//! over, and what each agent session did once it ended: the tools it called, +//! the entities each call was about and the changes it committed. +//! +//! All of it is collaboration authority. Each agent session is an actor, and +//! the actor carries the session's [`AgentSessionRecord`]; an agent that +//! edited with its own tools is an actor for the session its client named. A +//! record is held in memory the moment it is known, so the next read shows +//! it, and committed to repository authority as one transaction once the +//! session it belongs to has ended or gone quiet ([`settle`]), the way review +//! flag sightings are: a write is a repository transaction that moves the +//! authority generation, and an agent's own work must not see one start in +//! the middle of it. Only once authority holds a record does the live graph +//! show it, so a daemon that reopens the store reads back exactly what this +//! one wrote. Records hold change and session ids, the agents' names, tool +//! names and entity ids, never a path. +//! +//! Authority has no removal, so the bounds are kept by rewriting: past +//! [`AGENT_WRITES_MAX`] writes or [`AGENT_EDITS_MAX`] edits the oldest are +//! dropped from the records that hold them, and a session that ended outside +//! the window [`crate::ended_sessions`] keeps keeps no calls. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use kin_cli::commands::review_guide::{AgentWrite, ClientEdit}; +use kin_model::change::EntityDelta; +use kin_model::timestamp::Timestamp; +use kin_model::ProvenanceStore as _; +use kin_model::{ + Actor, ActorId, ActorKind, AgentCall, AgentCallOutcome, AgentEditRecord, AgentSessionRecord, + AgentSessionSource, AgentWriteRecord, CollaborationDelta, Entity, EntityId, FilePathId, + Hash256, Keyed, SemanticChange, AGENT_EDITS_MAX, AGENT_ENTITIES_PER_WRITE_MAX, + AGENT_WRITES_MAX, +}; +use sha2::{Digest, Sha256}; +use tracing::debug; + +use crate::agent_activity::ToolCallEntry; +use crate::ended_sessions::EndedSession; +use crate::DaemonState; + +/// The writes through Kin kept. Past it the oldest go. +pub const MAX_AUTHORED_WRITES: usize = AGENT_WRITES_MAX; +/// The entities one write keeps. +pub const MAX_ENTITIES_PER_WRITE: usize = AGENT_ENTITIES_PER_WRITE_MAX; +/// The edits made with an agent's own tools kept. Past it the oldest go. +pub const MAX_CLIENT_EDITS: usize = AGENT_EDITS_MAX; + +/// How long a session must have been quiet before what is held for it is +/// written. A session that has ended is written at the next look. +#[cfg(not(test))] +const QUIET_BEFORE_WRITE: Duration = Duration::from_secs(20); +#[cfg(test)] +const QUIET_BEFORE_WRITE: Duration = Duration::from_millis(150); + +/// How often the daemon looks at held records ([`crate::daemon`]). +#[cfg(not(test))] +pub(crate) const SETTLE_TICK: Duration = Duration::from_secs(5); +#[cfg(test)] +pub(crate) const SETTLE_TICK: Duration = Duration::from_millis(40); + +/// What a transaction that keeps agent records is, and who it is by. +const RECORD_ACTION: &str = "agent_session.kept"; +const RECORD_ACTOR: &str = "kin-daemon:agent-sessions"; + +/// The actor that stands for a session connected through Kin. +pub(crate) fn session_actor_id(session: &str) -> ActorId { + let mut hasher = Sha256::new(); + hasher.update(b"kin-agent-session-record-v1\0"); + hasher.update(session.trim().to_ascii_lowercase().as_bytes()); + ActorId::from_hash(Hash256::from_bytes(hasher.finalize().into())) +} + +/// The actor that stands for a session a client named when it reported edits +/// made with its own tools. +pub(crate) fn client_actor_id(client: &str, session: &str) -> ActorId { + let mut hasher = Sha256::new(); + hasher.update(b"kin-agent-client-session-record-v1\0"); + hasher.update(client.as_bytes()); + hasher.update(b"\0"); + hasher.update(session.as_bytes()); + ActorId::from_hash(Hash256::from_bytes(hasher.finalize().into())) +} + +fn parse_time(at: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(at.trim()) + .ok() + .map(|at| Timestamp(at.with_timezone(&chrono::Utc))) +} + +/// The actor that carries `record`. +fn actor_for(actor_id: ActorId, record: AgentSessionRecord) -> Actor { + let display_name = [record.vendor.as_str(), record.client.as_str()] + .into_iter() + .find(|name| !name.is_empty()) + .unwrap_or("agent session") + .to_string(); + Actor { + actor_id, + kind: ActorKind::Assistant, + display_name, + external_refs: Vec::new(), + agent_session: Some(record), + } +} + +/// One record held in memory, not yet in authority. +#[derive(Debug, Clone)] +struct HeldRecord { + record: AgentSessionRecord, + /// Moves on every change, so a write that raced a change keeps it held. + version: u64, + touched: Instant, +} + +#[derive(Debug, Default)] +struct Held { + records: HashMap, + next_version: u64, + /// The store takes no local write, as a hosted daemon's does not: what is + /// known is kept in memory only. + memory_only: bool, +} + +/// What agents did here, as authority holds it and as it is held to be +/// written. +#[derive(Debug)] +pub struct AgentAuthorship { + /// The live graph, which holds what authority holds. + graph: Arc, + held: Mutex, +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Every record, `held` over what the graph holds, in actor order. +fn records_in( + graph: &kin_db::InMemoryGraph, + held: &Held, +) -> BTreeMap { + let mut all = BTreeMap::new(); + for actor in graph.list_actors().unwrap_or_default() { + if let Some(record) = actor.agent_session { + all.insert(actor.actor_id.0.to_string(), (actor.actor_id, record)); + } + } + for (actor_id, held) in &held.records { + all.insert(actor_id.0.to_string(), (*actor_id, held.record.clone())); + } + all +} + +fn call_record(entry: &ToolCallEntry) -> AgentCall { + AgentCall { + seq: entry.seq, + at: entry.at.clone(), + tool: entry.tool.clone(), + entity_ids: entry.entity_ids.clone(), + result_count: u64::try_from(entry.result_count).unwrap_or(u64::MAX), + bytes: u64::try_from(entry.bytes).unwrap_or(u64::MAX), + duration_ms: entry.duration_ms, + is_error: entry.is_error, + outcome: AgentCallOutcome::of(entry.change_id.clone(), entry.caller_left), + } +} + +fn call_entry(call: &AgentCall) -> ToolCallEntry { + ToolCallEntry { + seq: call.seq, + at: call.at.clone(), + tool: call.tool.clone(), + entity_ids: call.entity_ids.clone(), + result_count: usize::try_from(call.result_count).unwrap_or(usize::MAX), + bytes: usize::try_from(call.bytes).unwrap_or(usize::MAX), + duration_ms: call.duration_ms, + is_error: call.is_error, + repo_id: None, + change_id: call + .outcome + .as_ref() + .and_then(|outcome| outcome.change_id.clone()), + caller_left: call + .outcome + .as_ref() + .is_some_and(|outcome| outcome.caller_left), + } +} + +impl AgentAuthorship { + /// What agents did, read from what `graph` holds, which is what authority + /// holds once a daemon has opened the store. + pub fn new(graph: Arc) -> Self { + Self { + graph, + held: Mutex::new(Held::default()), + } + } + + /// Change the record under `actor_id`, starting it with `start` when + /// there is none. `change` says whether it changed anything; a record that + /// changed is held until it is written. + fn update( + &self, + actor_id: ActorId, + start: impl FnOnce() -> AgentSessionRecord, + change: impl FnOnce(&mut AgentSessionRecord) -> bool, + ) { + let mut held = lock(&self.held); + let mut record = match held.records.get(&actor_id) { + Some(kept) => kept.record.clone(), + None => self + .graph + .get_actor(&actor_id) + .ok() + .flatten() + .and_then(|actor| actor.agent_session) + .unwrap_or_else(start), + }; + if !change(&mut record) { + return; + } + record.bound(); + held.next_version += 1; + let version = held.next_version; + held.records.insert( + actor_id, + HeldRecord { + record, + version, + touched: Instant::now(), + }, + ); + } + + /// Every record, as authority holds it with what is held over it. + fn records(&self) -> Vec<(ActorId, AgentSessionRecord)> { + let held = lock(&self.held); + records_in(&self.graph, &held).into_values().collect() + } + + /// Keep one write. A change already recorded is not recorded twice, as a + /// resumed commit finalizes the change it already published. A write that + /// wrote no entity is not kept: there is nothing it could be shown on. + pub fn record(&self, write: AgentWrite) { + if write.entities.is_empty() { + return; + } + let known = self.records().iter().any(|(_, record)| { + record + .writes + .iter() + .any(|kept| kept.change_id.eq_ignore_ascii_case(&write.change_id)) + }); + if known { + return; + } + let session = write.session.trim().to_ascii_lowercase(); + self.update( + session_actor_id(&session), + || AgentSessionRecord::new(&session, AgentSessionSource::Kin), + |record| { + if record.vendor.is_empty() { + record.vendor = write.vendor.clone(); + } + if record.client.is_empty() { + record.client = write.client.clone(); + } + let mut entities = write.entities.clone(); + entities.truncate(MAX_ENTITIES_PER_WRITE); + record.writes.push(AgentWriteRecord { + change_id: write.change_id.to_ascii_lowercase(), + at: parse_time(&write.at).unwrap_or_else(Timestamp::now), + entities, + }); + true + }, + ); + } + + /// Every write kept, oldest first, the newest [`MAX_AUTHORED_WRITES`]. + pub fn writes(&self) -> Vec { + let mut writes: Vec<(Timestamp, AgentWrite)> = Vec::new(); + for (_, record) in self.records() { + for write in &record.writes { + writes.push(( + write.at.clone(), + AgentWrite { + change_id: write.change_id.clone(), + session: record.session.clone(), + vendor: record.vendor.clone(), + client: record.client.clone(), + at: write.at.to_string(), + entities: write.entities.clone(), + }, + )); + } + } + writes.sort_by(|left, right| left.0.cmp(&right.0)); + let skip = writes.len().saturating_sub(MAX_AUTHORED_WRITES); + writes + .into_iter() + .skip(skip) + .map(|(_, write)| write) + .collect() + } + + /// Keep one edit an agent made with its own tools. One that changed no + /// entity is not kept. + pub fn record_edit(&self, edit: ClientEdit) { + if edit.entities.is_empty() { + return; + } + self.update( + client_actor_id(&edit.client, &edit.session), + || { + let mut record = AgentSessionRecord::new(&edit.session, AgentSessionSource::Client); + record.client = edit.client.clone(); + record + }, + |record| { + record.edits.push(edit_record(&edit)); + true + }, + ); + } + + /// Every edit kept, oldest first, the newest [`MAX_CLIENT_EDITS`]. + pub fn edits(&self) -> Vec { + edits_of(&self.records()) + } + + /// Rewrite the edits kept with `seal`, which returns whether it changed + /// any, and keep the result. An edit left with no entity is dropped. + pub fn reseal_edits(&self, seal: impl FnOnce(&mut Vec) -> bool) { + let mut held = lock(&self.held); + let records: Vec<(ActorId, AgentSessionRecord)> = + records_in(&self.graph, &held).into_values().collect(); + let mut edits = edits_of(&records); + if !seal(&mut edits) { + return; + } + edits.retain(|edit| !edit.entities.is_empty()); + let mut regrouped: HashMap> = HashMap::new(); + for edit in &edits { + regrouped + .entry(client_actor_id(&edit.client, &edit.session)) + .or_default() + .push(edit_record(edit)); + } + for (actor_id, record) in records { + if record.source != AgentSessionSource::Client { + continue; + } + let now_held = regrouped.remove(&actor_id).unwrap_or_default(); + if now_held == record.edits { + continue; + } + let mut record = record; + record.edits = now_held; + record.bound(); + held.next_version += 1; + let version = held.next_version; + held.records.insert( + actor_id, + HeldRecord { + record, + version, + touched: Instant::now(), + }, + ); + } + } + + /// Keep what one session's log held when it ended. + pub fn hold_ended(&self, ended: EndedSession) { + let session = ended.session_id.trim().to_ascii_lowercase(); + self.update( + session_actor_id(&session), + || AgentSessionRecord::new(&session, AgentSessionSource::Kin), + |record| { + record.vendor = ended.vendor.clone(); + record.client = ended.client.clone(); + record.transport = ended.transport.clone(); + record.started_at = Some(ended.started_at.clone()); + record.last_activity_at = Some(ended.last_activity_at.clone()); + record.ended_at = Some(ended.ended_at.clone()); + record.calls_total = ended.calls_total; + record.calls = ended.calls.iter().map(call_record).collect(); + let mut changes = ended.change_ids.clone(); + for change in &record.change_ids { + if !changes.contains(change) { + changes.push(change.clone()); + } + } + record.change_ids = changes; + true + }, + ); + } + + /// Keep one call a session made that finished after the session ended: + /// a commit that ran on after its agent stopped waiting and closed its + /// connection. It goes after the calls the session's log held when it + /// ended, numbered after the last of them, and the change it committed + /// joins the session's changes. Returns whether a session ended here to + /// keep it on; nothing is started for one that was never kept. + pub fn record_after_end( + &self, + session: &str, + draft: &crate::agent_activity::ToolCallDraft, + at: &Timestamp, + ) -> bool { + let session = session.trim().to_ascii_lowercase(); + let mut kept = false; + self.update( + session_actor_id(&session), + || AgentSessionRecord::new(&session, AgentSessionSource::Kin), + |record| { + if record.source != AgentSessionSource::Kin || record.ended_at.is_none() { + return false; + } + record.calls_total += 1; + record.calls.push(AgentCall { + seq: record.calls_total, + at: at.clone(), + tool: draft.tool.clone(), + entity_ids: draft.entity_ids.clone(), + result_count: u64::try_from(draft.result_count).unwrap_or(u64::MAX), + bytes: u64::try_from(draft.bytes).unwrap_or(u64::MAX), + duration_ms: draft.duration_ms, + is_error: draft.is_error, + outcome: AgentCallOutcome::of(draft.change_id.clone(), draft.caller_left), + }); + if record + .last_activity_at + .as_ref() + .is_none_or(|last| last < at) + { + record.last_activity_at = Some(at.clone()); + } + if let Some(change) = &draft.change_id { + keep_change(record, change); + } + kept = true; + true + }, + ); + kept + } + + /// Keep a change a session committed after it ended on what was kept of + /// the session. Nothing is started for a session that was never kept. + pub fn record_change_after_end(&self, session: &str, change_id: &str) { + let session = session.trim().to_ascii_lowercase(); + self.update( + session_actor_id(&session), + || AgentSessionRecord::new(&session, AgentSessionSource::Kin), + |record| { + if record.source != AgentSessionSource::Kin || record.ended_at.is_none() { + return false; + } + keep_change(record, change_id) + }, + ); + } + + /// Every session kept that ended inside the window, newest end first, + /// the newest [`crate::ended_sessions::MAX_ENDED_SESSIONS`]. + pub fn ended_sessions(&self, now: &Timestamp) -> Vec { + let mut ended: Vec = self + .records() + .into_iter() + .filter(|(_, record)| record.source == AgentSessionSource::Kin) + .filter_map(|(_, record)| { + let ended_at = record.ended_at.clone()?; + let last = record + .last_activity_at + .clone() + .unwrap_or_else(|| ended_at.clone()); + Some(EndedSession { + session_id: record.session.clone(), + vendor: record.vendor.clone(), + client: record.client.clone(), + transport: record.transport.clone(), + started_at: record.started_at.clone().unwrap_or_else(|| last.clone()), + last_activity_at: last, + ended_at, + calls_total: record.calls_total, + calls: record.calls.iter().map(call_entry).collect(), + change_ids: record.change_ids.clone(), + }) + }) + .filter(|session| !crate::ended_sessions::expired(&session.ended_at, now)) + .collect(); + ended.sort_by(|left, right| { + right + .ended_at + .cmp(&left.ended_at) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + ended.truncate(crate::ended_sessions::MAX_ENDED_SESSIONS); + ended + } + + /// One session kept that ended inside the window. + pub fn ended_session(&self, session_id: &str, now: &Timestamp) -> Option { + self.ended_sessions(now) + .into_iter() + .find(|session| session.session_id.eq_ignore_ascii_case(session_id.trim())) + } + + /// Whether anything is held that authority does not hold yet. + pub fn holds_unwritten(&self) -> bool { + let held = lock(&self.held); + !held.memory_only && !held.records.is_empty() + } +} + +/// Put `change_id` first among a session's changes, which are newest first, +/// unless it is already there. Returns whether the record changed. +fn keep_change(record: &mut AgentSessionRecord, change_id: &str) -> bool { + let change_id = change_id.to_ascii_lowercase(); + if record.change_ids.contains(&change_id) { + return false; + } + record.change_ids.insert(0, change_id); + true +} + +fn edit_record(edit: &ClientEdit) -> AgentEditRecord { + let mut entities = edit.entities.clone(); + entities.truncate(MAX_ENTITIES_PER_WRITE); + AgentEditRecord { + base: edit.base.clone(), + change_id: edit.change_id.clone(), + kin_session: edit.kin_session.clone(), + at: parse_time(&edit.at).unwrap_or_else(Timestamp::now), + entities, + } +} + +/// The edits `records` hold, oldest first, the newest [`MAX_CLIENT_EDITS`]. +fn edits_of(records: &[(ActorId, AgentSessionRecord)]) -> Vec { + let mut edits: Vec<(Timestamp, ClientEdit)> = Vec::new(); + for (_, record) in records + .iter() + .filter(|(_, record)| record.source == AgentSessionSource::Client) + { + for edit in &record.edits { + edits.push(( + edit.at.clone(), + ClientEdit { + base: edit.base.clone(), + change_id: edit.change_id.clone(), + client: record.client.clone(), + session: record.session.clone(), + kin_session: edit.kin_session.clone(), + at: edit.at.to_string(), + entities: edit.entities.clone(), + }, + )); + } + } + // Stable, so edits made at one moment keep the order they were kept in. + edits.sort_by(|left, right| left.0.cmp(&right.0)); + let skip = edits.len().saturating_sub(MAX_CLIENT_EDITS); + edits.into_iter().skip(skip).map(|(_, edit)| edit).collect() +} + +/// The records past a bound, rewritten inside it: the oldest writes and +/// edits past what is kept, and the calls of every session that ended outside +/// the window ended sessions are kept for. +fn bounded( + records: &[(ActorId, AgentSessionRecord)], + now: &Timestamp, +) -> HashMap { + fn take<'a>( + rewritten: &'a mut HashMap, + records: &[(ActorId, AgentSessionRecord)], + actor_id: ActorId, + ) -> &'a mut AgentSessionRecord { + rewritten.entry(actor_id).or_insert_with(|| { + records + .iter() + .find(|(id, _)| *id == actor_id) + .map(|(_, record)| record.clone()) + .expect("a rewritten record is one of the records") + }) + } + let mut rewritten: HashMap = HashMap::new(); + + let mut ended: Vec<(&Timestamp, ActorId)> = records + .iter() + .filter_map(|(id, record)| record.ended_at.as_ref().map(|at| (at, *id))) + .collect(); + ended.sort_by(|left, right| right.0.cmp(left.0)); + for (rank, (at, actor_id)) in ended.iter().enumerate() { + let kept = rank < crate::ended_sessions::MAX_ENDED_SESSIONS + && !crate::ended_sessions::expired(at, now); + let holds_calls = records + .iter() + .any(|(id, record)| id == actor_id && !record.calls.is_empty()); + if !kept && holds_calls { + take(&mut rewritten, records, *actor_id).calls.clear(); + } + } + + let mut writes: Vec<(Timestamp, String, ActorId)> = records + .iter() + .flat_map(|(id, record)| { + record + .writes + .iter() + .map(move |write| (write.at.clone(), write.change_id.clone(), *id)) + }) + .collect(); + writes.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1))); + let past = writes.len().saturating_sub(MAX_AUTHORED_WRITES); + for (_, change_id, actor_id) in writes.into_iter().take(past) { + take(&mut rewritten, records, actor_id) + .writes + .retain(|write| write.change_id != change_id); + } + + // In record order, so of edits made at one moment the first kept goes + // first, as `edits_of` reads them. + let mut edits: Vec<(Timestamp, ActorId, usize)> = records + .iter() + .flat_map(|(id, record)| { + record + .edits + .iter() + .enumerate() + .map(move |(index, edit)| (edit.at.clone(), *id, index)) + }) + .collect(); + edits.sort_by(|left, right| left.0.cmp(&right.0)); + let past = edits.len().saturating_sub(MAX_CLIENT_EDITS); + let mut dropped: HashMap> = HashMap::new(); + for (_, actor_id, index) in edits.into_iter().take(past) { + dropped.entry(actor_id).or_default().push(index); + } + for (actor_id, indexes) in dropped { + let record = take(&mut rewritten, records, actor_id); + let mut index = 0; + record.edits.retain(|_| { + let keep = !indexes.contains(&index); + index += 1; + keep + }); + } + rewritten +} + +/// Whether any agent session on `state` is currently at work or has been +/// heard from inside [`QUIET_BEFORE_WRITE`]. +pub(crate) fn any_session_at_work(state: &DaemonState) -> bool { + let coordinator = &state.coordinator; + let now = Timestamp::now(); + for session in coordinator.list_sessions().unwrap_or_default() { + if session.vendor == crate::agent_activity::DAEMON_OWN_VENDOR { + continue; + } + if coordinator.active_call_count(&session.session_id) > 0 { + return true; + } + let mut last = session.started_at.clone(); + if session.last_heartbeat > last { + last = session.last_heartbeat; + } + if let Some(at) = coordinator.activity().last_call_at(&session.session_id) { + if at > last { + last = at; + } + } + if now + .0 + .signed_duration_since(last.0) + .to_std() + .map_or(true, |idle| idle < QUIET_BEFORE_WRITE) + { + return true; + } + } + false +} + +/// Whether what is held for `record` may be written now: it has been quiet +/// for [`QUIET_BEFORE_WRITE`], and its session has ended or been quiet for +/// [`QUIET_BEFORE_WRITE`]. +fn is_quiet(state: &DaemonState, record: &HeldRecord) -> bool { + if record.touched.elapsed() < QUIET_BEFORE_WRITE { + return false; + } + let Ok(session) = uuid::Uuid::parse_str(record.record.session.trim()) else { + return true; + }; + let session = kin_model::SessionId(session); + let coordinator = &state.coordinator; + if record.record.source != AgentSessionSource::Kin + || coordinator.get_session(&session).ok().flatten().is_none() + { + return true; + } + if coordinator.active_call_count(&session) > 0 { + return false; + } + coordinator + .activity() + .last_call_at(&session) + .is_none_or(|at| { + Timestamp::now() + .0 + .signed_duration_since(at.0) + .to_std() + .is_ok_and(|idle| idle >= QUIET_BEFORE_WRITE) + }) +} + +/// Commit to repository authority, as one transaction, every held record +/// whose session has ended or gone quiet, or every held record with `all`, +/// together with the records the bounds rewrite. Returns how many records +/// it wrote. +pub(crate) async fn settle(state: &Arc, all: bool) -> usize { + let ledger = &state.agent_authorship; + if !ledger.holds_unwritten() { + return 0; + } + if !all && any_session_at_work(state) { + return 0; + } + let ready_now = { + let held = lock(&ledger.held); + if !all + && held + .records + .values() + .any(|record| record.touched.elapsed() < QUIET_BEFORE_WRITE) + { + false + } else { + held.records + .values() + .any(|record| all || is_quiet(state, record)) + } + }; + if !ready_now { + return 0; + } + let _coordinated = state.coordination_gate.lock().await; + if state.prepared_publication.ensure_serving().is_err() { + // Not serving writes now. What is held stays held for the next look. + return 0; + } + if !all && any_session_at_work(state) { + return 0; + } + let _mutation = state.begin_graph_authority_mutation(); + let (batch, records, held_ids) = { + let held = lock(&ledger.held); + if !all + && held + .records + .values() + .any(|record| record.touched.elapsed() < QUIET_BEFORE_WRITE) + { + return 0; + } + let batch: Vec<(ActorId, u64)> = held + .records + .iter() + .filter(|(_, record)| all || is_quiet(state, record)) + .map(|(actor_id, record)| (*actor_id, record.version)) + .collect(); + let held_ids: Vec = held.records.keys().copied().collect(); + let records: Vec<(ActorId, AgentSessionRecord)> = + records_in(&ledger.graph, &held).into_values().collect(); + (batch, records, held_ids) + }; + if batch.is_empty() { + return 0; + } + let in_batch = |actor_id: &ActorId| batch.iter().any(|(id, _)| id == actor_id); + let mut writing: BTreeMap = BTreeMap::new(); + for (actor_id, record) in records.iter().filter(|(id, _)| in_batch(id)) { + writing.insert(actor_id.0.to_string(), actor_for(*actor_id, record.clone())); + } + // A record still held and not written now is not rewritten either: it is + // written whole, inside its bounds, once it is ready. + for (actor_id, record) in bounded(&records, &Timestamp::now()) { + if in_batch(&actor_id) || !held_ids.contains(&actor_id) { + writing.insert(actor_id.0.to_string(), actor_for(actor_id, record)); + } + } + let actors: Vec = writing.into_values().collect(); + let written = kin_model::in_canonical_key_order( + actors + .iter() + .map(|actor| Keyed::new(actor.actor_id, actor.clone())) + .collect(), + ) + .map_err(|error| error.to_string()) + .and_then(|keyed| { + let delta = CollaborationDelta { + actors: keyed, + ..CollaborationDelta::default() + }; + delta.validate().map_err(|error| error.to_string())?; + crate::review_write::commit_collaboration_records( + state, + delta, + RECORD_ACTION, + RECORD_ACTOR, + |graph| { + for actor in &actors { + graph + .create_actor(actor) + .map_err(|error| error.to_string())?; + } + Ok(()) + }, + ) + .map_err(|refusal| match refusal { + crate::review_write::ReviewWriteRefusal::Hosted => { + lock(&ledger.held).memory_only = true; + refusal.to_string() + } + other => other.to_string(), + }) + }); + match written { + Ok(generation) => { + tracing::info!( + records = actors.len(), + generation, + "kept what agents did in repository authority" + ); + let mut held = lock(&ledger.held); + for (actor_id, version) in &batch { + if held + .records + .get(actor_id) + .is_some_and(|record| record.version == *version) + { + held.records.remove(actor_id); + } + } + actors.len() + } + Err(error) => { + // Held again for the next look: a record of what an agent did is + // a record of something that happened, and one failed write does + // not unmake it. + debug!(%error, "agent records could not be written to repository authority"); + 0 + } + } +} + +/// Whether `entity` is the declaration a create named `name`. A member may be +/// recorded under its container's name, as `Signer.sign`. +fn declares(entity: &Entity, name: &str) -> bool { + let name = name.trim(); + !name.is_empty() + && (entity.name == name + || entity + .name + .strip_suffix(name) + .is_some_and(|head| head.ends_with('.') || head.ends_with("::"))) +} + +/// The entities `operations` wrote in `change`: for each operation, the entity +/// it names or the one it created, kept only where the change holds a delta +/// for it. An edit the change carried in, an entity whose span only moved +/// because a line above it changed, and a source unit's own container are +/// never among them, because no operation named them. +/// +/// A create names its entity by the name it declares. It is the change's one +/// added entity of that name, or, where the change added more than one, the +/// one beside the entity it was anchored to; one it cannot tell apart is left +/// out rather than guessed. `origin_of` says where an entity the change does +/// not hold lives, for an anchor the change left as it was. +pub(crate) fn written_entities( + operations: &[kin_mcp::McpMutationOperation], + change: &SemanticChange, + origin_of: impl Fn(&EntityId) -> Option, +) -> Vec { + use kin_mcp::McpMutationPayload as Payload; + + let mut held: HashMap = HashMap::new(); + let mut added: Vec<&Entity> = Vec::new(); + for delta in &change.entity_deltas { + match delta { + EntityDelta::Added { new } => { + held.insert(new.id, new); + added.push(new); + } + EntityDelta::Modified { new, .. } => { + held.insert(new.id, new); + } + EntityDelta::Removed { old } => { + held.insert(old.id, old); + } + } + } + let origin = |id: &EntityId| { + held.get(id) + .and_then(|entity| entity.file_origin.clone()) + .or_else(|| origin_of(id)) + }; + let created = |name: &str, anchor: Option<&EntityId>| -> Option { + let named: Vec<&&Entity> = added + .iter() + .filter(|entity| declares(entity, name)) + .collect(); + if let [only] = named.as_slice() { + return Some(only.id); + } + let place = origin(anchor?)?; + let beside: Vec<&&&Entity> = named + .iter() + .filter(|entity| entity.file_origin.as_ref() == Some(&place)) + .collect(); + match beside.as_slice() { + [only] => Some(only.id), + _ => None, + } + }; + + let mut written: Vec = Vec::new(); + for operation in operations { + if written.len() >= MAX_ENTITIES_PER_WRITE { + break; + } + let mut push = |id: Option| { + if let Some(id) = id { + if held.contains_key(&id) && !written.contains(&id) { + written.push(id); + } + } + }; + let verb = operation.verb.trim().to_ascii_lowercase(); + match operation.payload.as_ref() { + Some(Payload::EntitySourceBase(base)) => push(Some(base.entity_id)), + Some(Payload::EntitySourcePatch(patch)) => push(Some(patch.source_base.entity_id)), + Some(Payload::EntityRemove(remove)) => push(Some(remove.source_base.entity_id)), + Some(Payload::EntityContent(content)) if verb == "create" => push( + content + .name + .as_deref() + .and_then(|name| created(name, Some(&content.source_base.entity_id))), + ), + Some(Payload::EntityContent(content)) => push(Some(content.source_base.entity_id)), + Some(Payload::EntityCreate(create)) => push(created( + &create.name, + create.source_base.as_ref().map(|base| &base.entity_id), + )), + Some(Payload::Entity(entity)) => push(Some(entity.id)), + // An import list and a relation write no declaration of their own, + // and bytes name no entity. + Some(Payload::UnitImports(_) | Payload::Relation { .. } | Payload::Blob(_)) => {} + None => push( + uuid::Uuid::parse_str(operation.target.trim()) + .ok() + .map(EntityId), + ), + } + } + written +} + +/// Keep what one committed agent transaction wrote. `vendor` and `client` are +/// the names its session gave, already made safe to record. +pub(crate) fn record_commit( + authorship: &AgentAuthorship, + session_id: &str, + vendor: &str, + client: &str, + operations: &[kin_mcp::McpMutationOperation], + change: &SemanticChange, + graph: &kin_db::InMemoryGraph, +) { + use kin_model::EntityStore as _; + + let Ok(session) = uuid::Uuid::parse_str(session_id.trim()) else { + return; + }; + let entities = written_entities(operations, change, |id| { + graph + .get_entity(id) + .ok() + .flatten() + .and_then(|entity| entity.file_origin) + }); + authorship.record(AgentWrite { + change_id: change.id.to_string(), + session: session.to_string(), + vendor: crate::agent_activity::display_name(vendor, ""), + client: crate::agent_activity::display_name(client, ""), + at: change.timestamp.to_string(), + entities, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use kin_mcp::source_base::{ + EntitySourceBase, EntitySourcePatch, SourceBaseContext, SourceBaseSchema, + }; + + fn entity(name: &str, file: &str) -> Entity { + let place = FilePathId::new(file); + Entity { + id: EntityId::new(), + kind: kin_model::EntityKind::Function, + name: name.to_string(), + language: kin_model::LanguageId::Python, + fingerprint: kin_model::SemanticFingerprint { + algorithm: kin_model::FingerprintAlgorithm::V1TreeSitter, + ast_hash: kin_model::Hash256::from_bytes([1; 32]), + signature_hash: kin_model::Hash256::from_bytes([2; 32]), + behavior_hash: kin_model::Hash256::from_bytes([3; 32]), + equivalence_hash: kin_model::Hash256::from_bytes([0; 32]), + stability_score: 1.0, + }, + file_origin: Some(place.clone()), + span: Some(kin_model::SourceSpan { + file: place, + start_byte: 0, + end_byte: 1, + start_line: 1, + start_col: 0, + end_line: 2, + end_col: 0, + }), + signature: format!("def {name}()"), + visibility: kin_model::Visibility::Public, + role: kin_model::EntityRole::Source, + doc_summary: None, + metadata: kin_model::EntityMetadata::default(), + lineage_parent: None, + created_in: None, + superseded_by: None, + } + } + + fn base_of(entity: &Entity) -> EntitySourceBase { + EntitySourceBase { + schema: SourceBaseSchema::V1, + context: SourceBaseContext { + repository_id: "authorship-test".into(), + workspace_id: uuid::Uuid::new_v4().to_string(), + workspace_generation: 1, + workspace_head_hash: "a".repeat(64), + workspace_tree_hash: "b".repeat(64), + }, + entity_id: entity.id, + artifact_id: kin_model::ArtifactId::new(), + source_blob_hash: "c".repeat(64), + start_byte: 0, + end_byte: 1, + body_hash: "d".repeat(64), + } + } + + fn operation( + verb: &str, + payload: kin_mcp::McpMutationPayload, + ) -> kin_mcp::McpMutationOperation { + kin_mcp::McpMutationOperation { + verb: verb.to_string(), + target: String::new(), + payload: Some(payload), + body: None, + destination: None, + description: "edit".to_string(), + } + } + + fn patch(target: &Entity) -> kin_mcp::McpMutationOperation { + operation( + "patch", + kin_mcp::McpMutationPayload::EntitySourcePatch(EntitySourcePatch { + source_base: base_of(target), + edits: Vec::new(), + }), + ) + } + + fn create_beside(anchor: &Entity, name: &str) -> kin_mcp::McpMutationOperation { + let create: kin_mcp::entity_lifecycle::EntityCreate = + serde_json::from_value(serde_json::json!({ + "source_base": base_of(anchor), + "placement": "sibling_after", + "name": name, + "kind": "function", + "body": format!("def {name}():\n pass"), + })) + .expect("an anchored create"); + operation("create", kin_mcp::McpMutationPayload::EntityCreate(create)) + } + + fn change_of(entity_deltas: Vec) -> SemanticChange { + SemanticChange { + id: kin_model::SemanticChangeId::from_hash(kin_model::Hash256::from_bytes([9; 32])), + parents: Vec::new(), + origin: kin_model::ChangeOrigin::Native, + timestamp: kin_model::Timestamp::now(), + author: kin_model::AuthorId::new("codex/codex "), + message: "MCP transaction".to_string(), + entity_deltas, + relation_deltas: Vec::new(), + tree_deltas: Vec::new(), + projected_files: Vec::new(), + spec_link: None, + evidence: Vec::new(), + risk_summary: None, + admission_policy_delta: None, + external_reference_deltas: Vec::new(), + resolution_record_deltas: Vec::new(), + } + } + + fn modified(entity: &Entity) -> EntityDelta { + EntityDelta::Modified { + old: entity.clone(), + new: entity.clone(), + } + } + + /// The agent patched one function. The same change also holds the user's + /// edit to its neighbour in the same source unit, and a third function + /// whose span only moved. Only the patched one is the agent's. + #[test] + fn only_the_entity_an_operation_named_is_written() { + let written = entity("double", "util.py"); + let neighbour = entity("clamp", "util.py"); + let moved = entity("area", "util.py"); + let change = change_of(vec![ + modified(&neighbour), + modified(&written), + modified(&moved), + ]); + assert_eq!( + written_entities(&[patch(&written)], &change, |_| None), + vec![written.id] + ); + } + + /// An operation whose entity the change holds no delta for wrote nothing + /// there. + #[test] + fn an_operation_is_kept_only_where_the_change_holds_its_entity() { + let named = entity("double", "util.py"); + let other = entity("clamp", "util.py"); + let change = change_of(vec![modified(&other)]); + assert!(written_entities(&[patch(&named)], &change, |_| None).is_empty()); + } + + /// A create is the added entity of the name it declared, told apart by + /// the unit it was anchored in when another unit added the same name, and + /// left out when nothing tells them apart. + #[test] + fn a_create_is_the_entity_it_declared_beside_its_anchor() { + let anchor = entity("area", "shapes.py"); + let made = entity("scale", "shapes.py"); + let elsewhere = entity("scale", "other.py"); + let alone = change_of(vec![EntityDelta::Added { new: made.clone() }]); + assert_eq!( + written_entities(&[create_beside(&anchor, "scale")], &alone, |_| None), + vec![made.id] + ); + let twice = change_of(vec![ + EntityDelta::Added { + new: elsewhere.clone(), + }, + EntityDelta::Added { new: made.clone() }, + ]); + let origin = anchor.file_origin.clone(); + assert_eq!( + written_entities(&[create_beside(&anchor, "scale")], &twice, |id| { + (*id == anchor.id).then(|| origin.clone()).flatten() + }), + vec![made.id], + "beside the anchor, which the change left as it was" + ); + assert!( + written_entities(&[create_beside(&anchor, "scale")], &twice, |_| None).is_empty(), + "two of the name and no anchor to place them: no guess" + ); + } + + fn write(change: u8, entities: Vec) -> AgentWrite { + AgentWrite { + change_id: format!("{change:02x}").repeat(32), + session: uuid::Uuid::new_v4().to_string(), + vendor: "codex".to_string(), + client: "codex".to_string(), + at: kin_model::Timestamp::now().to_string(), + entities, + } + } + + /// A daemon over a real store, as one that opened it. + fn daemon(dir: &std::path::Path) -> Arc { + kin_core::test_env::install_test_registry(); + let layout = kin_core::init(dir).unwrap().layout; + reopen(layout) + } + + fn reopen(layout: kin_core::KinLayout) -> Arc { + let state = Arc::new(DaemonState::open(layout).unwrap()); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + state + } + + fn edit(session: &str, entities: Vec) -> ClientEdit { + ClientEdit { + base: None, + change_id: None, + client: "claude-code".to_string(), + session: session.to_string(), + kin_session: None, + at: Timestamp::now().to_string(), + entities, + } + } + + fn ended(session: &str, calls: u64, minutes_ago: i64) -> EndedSession { + let at = |minutes: i64| Timestamp(chrono::Utc::now() - chrono::Duration::minutes(minutes)); + EndedSession { + session_id: session.to_string(), + vendor: "claude-code".to_string(), + client: "claude-code".to_string(), + transport: "mcp".to_string(), + started_at: at(minutes_ago + 10), + last_activity_at: at(minutes_ago + 1), + ended_at: at(minutes_ago), + calls_total: calls, + calls: (1..=calls) + .map(|seq| ToolCallEntry { + seq, + at: at(minutes_ago + 2), + tool: "semantic_locate".to_string(), + entity_ids: vec![EntityId::new()], + result_count: 1, + bytes: 10, + duration_ms: 3, + is_error: false, + repo_id: None, + change_id: None, + caller_left: false, + }) + .collect(), + change_ids: vec!["a".repeat(64)], + } + } + + /// What agents did is shown the moment it is known, is repository + /// authority once written, and a daemon that reopens the store reads it + /// back from there: writes, edits made with an agent's own tools, and the + /// sessions that ended with their calls. Nothing is kept beside the store + /// as a record of its own. + #[tokio::test] + async fn what_agents_did_reopens_from_repository_authority() { + let dir = tempfile::tempdir().unwrap(); + let state = daemon(dir.path()); + let first = write(1, vec![EntityId::new()]); + state.agent_authorship.record(first.clone()); + state.agent_authorship.record(first.clone()); + state.agent_authorship.record(write(2, Vec::new())); + let edited = edit("client-session-1", vec![EntityId::new()]); + state.agent_authorship.record_edit(edited.clone()); + let session = uuid::Uuid::new_v4().to_string(); + state.agent_authorship.hold_ended(ended(&session, 4, 3)); + assert_eq!(state.agent_authorship.writes(), vec![first.clone()]); + assert_eq!(state.agent_authorship.edits(), vec![edited.clone()]); + let now = Timestamp::now(); + assert_eq!(state.agent_authorship.ended_sessions(&now).len(), 1); + + let generation = state + .snapshot_generation + .load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(settle(&state, true).await, 3); + assert!(!state.agent_authorship.holds_unwritten()); + assert!( + state + .snapshot_generation + .load(std::sync::atomic::Ordering::SeqCst) + > generation, + "the records were committed to repository authority" + ); + assert!( + !state.layout.root().join("agent_authorship.json").exists() + && !state.layout.root().join("agent_sessions.json").exists(), + "no record is kept beside the store" + ); + let reopened = reopen(state.layout.clone()); + drop(state); + assert_eq!(reopened.agent_authorship.writes(), vec![first]); + assert_eq!(reopened.agent_authorship.edits(), vec![edited]); + let kept = reopened.agent_authorship.ended_sessions(&Timestamp::now()); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].session_id, session); + assert_eq!(kept[0].calls_total, 4); + assert_eq!(kept[0].calls.len(), 4); + assert_eq!(kept[0].change_ids, vec!["a".repeat(64)]); + } + + /// The bounds hold in authority, which has no removal: past the writes + /// kept the oldest go from the record that held them, and a session that + /// ended outside the window keeps no calls. + #[tokio::test] + async fn the_records_stay_inside_their_bounds_in_authority() { + let dir = tempfile::tempdir().unwrap(); + let state = daemon(dir.path()); + let first = write(1, vec![EntityId::new()]); + state.agent_authorship.record(first.clone()); + for index in 0..MAX_AUTHORED_WRITES { + state.agent_authorship.record(AgentWrite { + change_id: format!("{index:064x}"), + ..write(3, vec![EntityId::new()]) + }); + } + let days = i64::try_from(crate::ended_sessions::ENDED_KEPT_DAYS).unwrap(); + let old = uuid::Uuid::new_v4().to_string(); + state + .agent_authorship + .hold_ended(ended(&old, 2, days * 24 * 60 + 5)); + settle(&state, true).await; + let reopened = reopen(state.layout.clone()); + drop(state); + let kept = reopened.agent_authorship.writes(); + assert_eq!(kept.len(), MAX_AUTHORED_WRITES); + assert!( + !kept.contains(&first), + "the oldest write went past the bound" + ); + let held: Vec = reopened + .graph + .list_actors() + .unwrap() + .into_iter() + .filter(|actor| actor.agent_session.is_some()) + .collect(); + let writes: usize = held + .iter() + .map(|actor| actor.agent_session.as_ref().unwrap().writes.len()) + .sum(); + assert_eq!( + writes, MAX_AUTHORED_WRITES, + "authority holds no more than is kept" + ); + let outside = held + .iter() + .find(|actor| actor.actor_id == session_actor_id(&old)) + .expect("the session outside the window is still an actor"); + assert!( + outside.agent_session.as_ref().unwrap().calls.is_empty(), + "outside the window a session keeps no calls" + ); + assert!(reopened + .agent_authorship + .ended_sessions(&Timestamp::now()) + .is_empty()); + } + + /// A record is not written while its session is at work, and is once the + /// session has gone quiet. + #[tokio::test] + async fn a_record_waits_for_its_session_to_go_quiet() { + let dir = tempfile::tempdir().unwrap(); + let state = daemon(dir.path()); + state + .agent_authorship + .record(write(1, vec![EntityId::new()])); + assert_eq!(settle(&state, false).await, 0, "written just now: held"); + assert!(state.agent_authorship.holds_unwritten()); + tokio::time::sleep(QUIET_BEFORE_WRITE + Duration::from_millis(50)).await; + assert_eq!(settle(&state, false).await, 1); + assert!(!state.agent_authorship.holds_unwritten()); + } + + /// An ended session is held in memory while another agent session is active + /// or inside the quiet window, so background settlement does not move the + /// repository generation under a concurrent reader. + #[tokio::test] + async fn an_ended_session_waits_while_another_session_is_at_work() { + let dir = tempfile::tempdir().unwrap(); + let state = daemon(dir.path()); + let first = uuid::Uuid::new_v4().to_string(); + state.agent_authorship.hold_ended(ended(&first, 2, 1)); + assert_eq!(settle(&state, false).await, 0, "ended just now: held"); + + tokio::time::sleep(QUIET_BEFORE_WRITE + Duration::from_millis(50)).await; + let active = state + .coordinator + .register_session( + "claude-code", + "claude-code", + kin_model::session::SessionTransport::Mcp, + None, + dir.path().to_path_buf(), + kin_model::session::SessionCapabilities::default(), + ) + .unwrap(); + assert_eq!( + settle(&state, false).await, + 0, + "held while another session is active" + ); + state.coordinator.deregister_session(&active).unwrap(); + assert_eq!(settle(&state, false).await, 1); + assert!(!state.agent_authorship.holds_unwritten()); + } +} diff --git a/crates/kin-daemon/src/api.rs b/crates/kin-daemon/src/api.rs index 1bf37bbe5..75ef3ecb5 100644 --- a/crates/kin-daemon/src/api.rs +++ b/crates/kin-daemon/src/api.rs @@ -162,7 +162,7 @@ const MAX_AUTHORITY_PUBLICATION_RECORD_BYTES: u64 = 1024 * 1024; /// This is storage-commit metadata, never a semantic answer: it decides only /// whether an already-loaded authority may be reused, and every answer served /// still comes from repository-v6 graph truth. -#[derive(Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) enum LocalPublicationIdentity { /// The repository has persisted no authority yet, so its authority is the /// unpublished generation-zero state. @@ -1450,6 +1450,12 @@ struct StartSessionRequest { transport: String, #[serde(default)] pid: Option, + /// A process whose exit ends a session that names no `pid`, without + /// keeping it alive while it runs. A connection that opens its session + /// only to read names its process here; see + /// [`crate::session_registry::SessionCoordinator::watch_process_exit`]. + #[serde(default)] + watch_pid: Option, cwd: String, /// What the client restricts itself to, when it says. Absent means the /// client declared nothing, which is different from declaring read-only @@ -1808,6 +1814,28 @@ pub struct RepoEntitiesResponse { /// page from a full one. #[serde(default, skip_serializing_if = "Option::is_none")] pub total: Option, + /// What others rely on that no test reaches, present only when the caller + /// asked for it with `untested`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub untested: Option, +} + +/// The entities others rely on that no test reaches, most relied-on first. +/// +/// An entity is listed when at least one other depends on it, it is defined +/// in this repository, it is neither a test nor a module, and no test reaches +/// it through the recorded dependency graph, including transitive reach +/// from a test entity or a recorded test case's covered scope. This is +/// structural reach, not measured runtime coverage. It is the coverage +/// `kin verify --missing` reads, and +/// `dependents` is the number importance orders by. +#[derive(Debug, Serialize, Deserialize)] +pub struct RepoUntested { + /// The most relied-on of them, as many as were asked for, each with its + /// ranking numbers. + pub entities: Vec, + /// How many there are among the entities the request matched. + pub total: usize, } /// Repo file listing response. @@ -1968,6 +1996,10 @@ struct RepoEntitiesQuery { /// Comma-separated optional row fields: `ranking`, `signature`, `summary`. #[serde(default)] include: Option, + /// How many of the entities others rely on that no test reaches to list, + /// most relied-on first. Absent leaves the list out and reads no coverage. + #[serde(default)] + untested: Option, } /// The current API version number, returned in the `X-Kin-API-Version` header. @@ -2015,6 +2047,825 @@ fn current_build_response() -> BuildResponse { } } +async fn runtime_identity() -> std::result::Result< + Json, + (StatusCode, String), +> { + runtime_identity_for("daemon").await +} + +async fn runtime_identity_for( + component: &'static str, +) -> std::result::Result< + Json, + (StatusCode, String), +> { + runtime_identity_for_repository(component, None).await +} + +async fn runtime_identity_for_repository( + component: &'static str, + repository: Option, +) -> std::result::Result< + Json, + (StatusCode, String), +> { + let mut identity = kin_core::runtime_identity::RuntimeServiceIdentity::current(component) + .map_err(|error| (StatusCode::SERVICE_UNAVAILABLE, error))?; + if let Some(repository) = repository { + repository + .revalidate() + .map_err(|error| (StatusCode::SERVICE_UNAVAILABLE, error))?; + identity.repository = Some(repository.identity().clone()); + } + Ok(Json(identity)) +} + +fn local_runtime_repository_binding( + state: &DaemonState, +) -> std::result::Result { + let backend = state + .local_repository_backend() + .ok_or_else(|| "local daemon has no retained startup storage authority".to_string())?; + let repository_id = RepositoryId::new(state.cached_repo_id.clone()) + .map_err(|error| format!("local daemon startup repository identity is invalid: {error}"))?; + kin_core::revalidate_pinned_local_namespace(&backend, &repository_id) + .map_err(|refusal| refusal.into_error().to_string())?; + let binding = kin_core::runtime_identity::RuntimeRepositoryBinding::for_layout(&state.layout)?; + if binding.identity().repository_id != state.cached_repo_id { + return Err( + "selected store identity differs from the daemon's retained startup repository".into(), + ); + } + kin_core::revalidate_pinned_local_namespace(&backend, &repository_id) + .map_err(|refusal| refusal.into_error().to_string())?; + binding.revalidate()?; + Ok(binding) +} + +/// Whether a development daemon holds its local requests to the managed +/// admission contract. +/// +/// A managed daemon always refuses a local request that lacks the caller's +/// runtime identity and the repository it means. A development one checks the +/// claims a request carries but does not demand them, so a client path that +/// sends none passes every development run and is refused on the first release +/// build. Setting `KIN_DAEMON_REQUIRE_RUNTIME_IDENTITY` truthy makes a debug +/// build demand them too. It can only make the daemon stricter. +fn development_requires_runtime_identity() -> bool { + std::env::var("KIN_DAEMON_REQUIRE_RUNTIME_IDENTITY").is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +/// Whether a request is a remote peer's on a repository transfer or range-read +/// route. +/// +/// Native push, pull, fetch, clone and remote range readings between Kin peers +/// call these routes on the other repository's daemon with that daemon's +/// bearer credential and none of the caller's local runtime claims. An +/// unauthenticated request, or one that carries local runtime claims, stays on +/// the local admission path and keeps every local runtime check. +fn is_remote_peer_request( + method: &axum::http::Method, + path: &str, + headers: &axum::http::HeaderMap, +) -> bool { + let Some(rest) = path.strip_prefix("/repos/") else { + return false; + }; + let is_peer_route = if let Some((repository, leaf)) = rest.split_once("/transfer/") { + !repository.is_empty() + && !repository.contains('/') + && matches!(leaf, "advertise" | "status" | "export" | "receive") + } else if let Some(repository) = rest.strip_suffix("/semantic-diff") { + *method == axum::http::Method::GET && !repository.is_empty() && !repository.contains('/') + } else { + false + }; + is_peer_route + && !headers.contains_key(kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER) + && !headers.contains_key(kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER) + && headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("Bearer ")) +} + +#[derive(Clone)] +struct RuntimeRequestPolicy { + require_identity: bool, + local: bool, + namespace: Option, + repository: Option, +} + +/// Whether `path` asks this daemon process to stop or retire. +/// +/// These routes are bound to the daemon's own process incarnation, which their +/// handlers check against the identity the caller sends. They touch no +/// semantic store, so the caller's runtime generation, home and repository +/// claims are not theirs to check. A client of another release, older or +/// newer, is exactly who has to retire a daemon, and `kin daemon stop` sends no +/// runtime claim at all. +fn is_process_lifecycle_route(path: &str) -> bool { + matches!( + path.strip_prefix("/v2").unwrap_or(path), + "/shutdown" | "/retire" + ) +} + +async fn validate_runtime_request( + State(policy): State, + request: axum::http::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + // Ahead of every runtime check, including the home revalidation: a daemon + // whose home was replaced is superseded, and it must still take the request + // that retires it. The bearer token and the incarnation still apply. + if is_process_lifecycle_route(request.uri().path()) { + return next.run(request).await; + } + let namespace = if policy.local { + let Some(namespace) = policy.namespace.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "selected local runtime namespace could not be captured", + ) + .into_response(); + }; + if let Err(error) = namespace.revalidate() { + return (StatusCode::SERVICE_UNAVAILABLE, error).into_response(); + } + Some(namespace) + } else { + None + }; + let path = request + .uri() + .path() + .strip_prefix("/v2") + .unwrap_or(request.uri().path()); + let diagnostic = matches!(path, "/health" | "/ready" | "/readiness" | "/auth/rotation"); + // A peer Kin reaching this repository's transfer or range-read routes is a + // remote boundary, as hosted authority is. It authenticates with this + // daemon's bearer credential, which the auth layer inside still checks, + // and the runtime generation, home and repository a local CLI or MCP + // request is bound to are the peer's own: a peer serving another + // repository cannot present this one's fingerprint. Every other request + // stays bound. + if policy.local && is_remote_peer_request(request.method(), path, request.headers()) { + return next.run(request).await; + } + if policy.require_identity + && !diagnostic + && !request + .headers() + .contains_key(kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER) + { + return (StatusCode::CONFLICT, "managed semantic requests require the exact runtime identity header; restart the CLI or MCP client using the selected managed runtime").into_response(); + } + let has_runtime_claim = request + .headers() + .contains_key(kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER); + if let Some(claim) = request + .headers() + .get(kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER) + .filter(|_| policy.local) + { + let mode = match kin_core::runtime_identity::RuntimeMode::current() { + Ok(mode) => mode, + Err(error) => return (StatusCode::SERVICE_UNAVAILABLE, error).into_response(), + }; + let expected = kin_core::runtime_identity::RuntimeIdentity::current().fingerprint( + mode, + namespace + .expect("local policy captured a namespace") + .token(), + ); + if claim.to_str().ok() != Some(expected.as_str()) { + return (StatusCode::CONFLICT, "runtime generation changed or differs from the caller; reconnect using the same managed CLI and daemon").into_response(); + } + } + if policy.local && !diagnostic && (policy.require_identity || has_runtime_claim) { + let Some(repository) = policy.repository.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "selected local repository identity could not be captured", + ) + .into_response(); + }; + if let Err(error) = repository.revalidate() { + return (StatusCode::SERVICE_UNAVAILABLE, error).into_response(); + } + let expected = repository.fingerprint(); + let claim = request + .headers() + .get(kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER) + .and_then(|value| value.to_str().ok()); + if claim != Some(expected.as_str()) { + return (StatusCode::CONFLICT, "repository identity changed or differs from the caller; reconnect using the intended repository").into_response(); + } + } + next.run(request).await +} + +#[cfg(test)] +mod runtime_identity_tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tower::ServiceExt; + + /// A peer Kin's transfer or range-read request is a remote boundary on a + /// managed local daemon, and nothing else is. + /// + /// Native push, pull, fetch, clone and remote range readings call the + /// other repository's daemon on its transfer or `semantic-diff` routes + /// with that daemon's bearer credential and none of the local runtime + /// claims, which a peer serving another repository could not present + /// anyway. Such a request reaches the handler. The credential still + /// applies: a wrong token is refused by the auth layer, and a request with + /// no credential is not a peer at all and keeps every local check. A local + /// request without the runtime headers is still refused before any handler + /// runs, on a semantic route and on a repository route that is not a peer + /// boundary. + #[tokio::test] + async fn a_peer_transfer_request_is_a_remote_boundary_and_a_local_request_stays_bound() { + const TOKEN: &str = "peer-transfer-test-token"; + let home = tempfile::tempdir().unwrap(); + let namespace = kin_core::runtime_identity::RuntimeNamespace::capture(home.path()).unwrap(); + let repository = kin_core::runtime_identity::RuntimeRepositoryBinding::capture( + &uuid::Uuid::new_v4().to_string(), + home.path(), + ) + .unwrap(); + let reached = Arc::new(AtomicUsize::new(0)); + let counted = |reached: &Arc| { + let reached = Arc::clone(reached); + move || { + let reached = Arc::clone(&reached); + async move { + reached.fetch_add(1, Ordering::SeqCst); + "handler reached" + } + } + }; + let routes = Router::new() + .route( + "/repos/{repo_id}/transfer/advertise", + get(counted(&reached)), + ) + .route("/repos/{repo_id}/transfer/receive", post(counted(&reached))) + .route( + "/repos/{repo_id}/semantic-diff", + get(counted(&reached)).post(counted(&reached)), + ) + .route("/repos/{repo_id}/refs", get(counted(&reached))) + .route("/semantic", get(counted(&reached))); + let app = Router::new() + .merge(routes.clone()) + .nest("/v2", routes) + .layer(middleware::from_fn_with_state( + DaemonAuthState { + tokens: crate::auth_rotation::RotationTokens::primary_only(Some( + TOKEN.to_string(), + )), + publication_control_auth_token: None, + }, + daemon_auth, + )) + .layer(middleware::from_fn_with_state( + RuntimeRequestPolicy { + require_identity: true, + local: true, + namespace: Some(namespace), + repository: Some(repository), + }, + validate_runtime_request, + )); + let send = |method: &str, uri: &str, token: Option<&str>| { + let mut builder = axum::http::Request::builder().method(method).uri(uri); + if let Some(token) = token { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + app.clone() + .oneshot(builder.body(axum::body::Body::empty()).unwrap()) + }; + + for (method, uri) in [ + ("GET", "/repos/peer-repository/transfer/advertise"), + ("POST", "/repos/peer-repository/transfer/receive"), + ("GET", "/v2/repos/peer-repository/transfer/advertise"), + ("GET", "/repos/peer-repository/semantic-diff?base=a&head=b"), + ] { + let before = reached.load(Ordering::SeqCst); + let response = send(method, uri, Some(TOKEN)).await.unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "an authenticated peer {method} {uri} must reach the handler" + ); + assert_eq!(reached.load(Ordering::SeqCst), before + 1); + } + + let before = reached.load(Ordering::SeqCst); + let wrong = send( + "GET", + "/repos/peer-repository/transfer/advertise", + Some("not-this-daemons-token"), + ) + .await + .unwrap(); + assert_eq!( + wrong.status(), + StatusCode::UNAUTHORIZED, + "the peer boundary still requires this daemon's credential" + ); + let anonymous = send("GET", "/repos/peer-repository/transfer/advertise", None) + .await + .unwrap(); + assert_eq!( + anonymous.status(), + StatusCode::CONFLICT, + "a transfer request with no credential is not a peer and keeps the local checks" + ); + let anonymous_diff = send("GET", "/repos/peer-repository/semantic-diff", None) + .await + .unwrap(); + assert_eq!( + anonymous_diff.status(), + StatusCode::CONFLICT, + "a range read with no credential is not a peer and keeps the local checks" + ); + for (method, uri) in [ + ("GET", "/semantic"), + ("GET", "/repos/peer-repository/refs"), + ("POST", "/repos/peer-repository/semantic-diff"), + ] { + let response = send(method, uri, Some(TOKEN)).await.unwrap(); + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "a local request to {method} {uri} without the runtime headers must stay refused" + ); + } + assert_eq!( + reached.load(Ordering::SeqCst), + before, + "no refused request may reach a handler" + ); + } + + #[tokio::test] + async fn legacy_and_wrong_source_requests_never_reach_the_semantic_handler() { + let home = tempfile::tempdir().unwrap(); + let namespace = kin_core::runtime_identity::RuntimeNamespace::capture(home.path()).unwrap(); + let repository = kin_core::runtime_identity::RuntimeRepositoryBinding::capture( + &uuid::Uuid::new_v4().to_string(), + home.path(), + ) + .unwrap(); + let reached = Arc::new(AtomicUsize::new(0)); + let marker = Arc::clone(&reached); + let app = Router::new() + .route( + "/semantic", + get(move || { + let marker = Arc::clone(&marker); + async move { + marker.fetch_add(1, Ordering::SeqCst); + "semantic store accessed" + } + }), + ) + .layer(middleware::from_fn_with_state( + RuntimeRequestPolicy { + require_identity: true, + local: true, + namespace: Some(namespace.clone()), + repository: Some(repository.clone()), + }, + validate_runtime_request, + )); + let response = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/semantic") + .header( + kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER, + repository.fingerprint(), + ) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(reached.load(Ordering::SeqCst), 0); + let mode = kin_core::runtime_identity::RuntimeMode::current().unwrap(); + let mut wrong_source = kin_core::runtime_identity::RuntimeIdentity::current(); + wrong_source.commit_sha = if wrong_source.commit_sha == "a".repeat(40) { + "b".repeat(40) + } else { + "a".repeat(40) + }; + let response = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/semantic") + .header( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + wrong_source.fingerprint(mode, namespace.token()), + ) + .header( + kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER, + repository.fingerprint(), + ) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(reached.load(Ordering::SeqCst), 0); + let response = app + .oneshot( + axum::http::Request::builder() + .uri("/semantic") + .header( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + kin_core::runtime_identity::RuntimeIdentity::current() + .fingerprint(mode, namespace.token()), + ) + .header( + kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER, + repository.fingerprint(), + ) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(reached.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn same_source_other_home_and_replaced_home_never_reach_the_semantic_handler() { + let root = tempfile::tempdir().unwrap(); + let service_home = root.path().join("service"); + let other_home = root.path().join("other"); + std::fs::create_dir(&service_home).unwrap(); + std::fs::create_dir(&other_home).unwrap(); + let namespace = + kin_core::runtime_identity::RuntimeNamespace::capture(&service_home).unwrap(); + let other_namespace = + kin_core::runtime_identity::RuntimeNamespace::capture(&other_home).unwrap(); + let identity = kin_core::runtime_identity::RuntimeIdentity::current(); + let mode = kin_core::runtime_identity::RuntimeMode::current().unwrap(); + let repository = kin_core::runtime_identity::RuntimeRepositoryBinding::capture( + &uuid::Uuid::new_v4().to_string(), + root.path(), + ) + .unwrap(); + let reached = Arc::new(AtomicUsize::new(0)); + let marker = Arc::clone(&reached); + let app = Router::new() + .route( + "/semantic", + get(move || { + let marker = Arc::clone(&marker); + async move { + marker.fetch_add(1, Ordering::SeqCst); + "semantic store accessed" + } + }), + ) + .layer(middleware::from_fn_with_state( + RuntimeRequestPolicy { + require_identity: true, + local: true, + namespace: Some(namespace.clone()), + repository: Some(repository.clone()), + }, + validate_runtime_request, + )); + let response = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/semantic") + .header( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + identity.fingerprint(mode, other_namespace.token()), + ) + .header( + kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER, + repository.fingerprint(), + ) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(reached.load(Ordering::SeqCst), 0); + let response = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/semantic") + .header( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + identity.fingerprint(mode, namespace.token()), + ) + .header( + kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER, + repository.fingerprint(), + ) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(reached.load(Ordering::SeqCst), 1); + std::fs::rename(&service_home, root.path().join("retired")).unwrap(); + std::fs::create_dir(&service_home).unwrap(); + let response = app + .oneshot( + axum::http::Request::builder() + .uri("/semantic") + .header( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + identity.fingerprint(mode, namespace.token()), + ) + .header( + kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER, + repository.fingerprint(), + ) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(reached.load(Ordering::SeqCst), 1); + } +} + +#[cfg(test)] +mod runtime_repository_binding_tests { + use super::*; + use kin_core::runtime_identity::{ + RuntimeIdentity, RuntimeMode, RuntimeNamespace, RuntimeRepositoryBinding, + RUNTIME_IDENTITY_HEADER, RUNTIME_REPOSITORY_HEADER, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tower::ServiceExt; + + struct RepositoryFixture { + namespace: RuntimeNamespace, + repository_a: RuntimeRepositoryBinding, + repository_b: RuntimeRepositoryBinding, + store_a: PathBuf, + store_b: PathBuf, + _root: tempfile::TempDir, + _environment: kin_core::test_env::EnvVarGuard, + } + + impl RepositoryFixture { + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("kin-home"); + let store_a = root.path().join("store-a"); + let store_b = root.path().join("store-b"); + for directory in [&home, &store_a, &store_b] { + std::fs::create_dir(directory).unwrap(); + } + let mut environment = kin_core::test_env::EnvVarGuard::set("KIN_HOME", &home); + environment.apply("KIN_RUNTIME_MODE", Some("development")); + let namespace = RuntimeNamespace::current().unwrap(); + let repository_a = + RuntimeRepositoryBinding::capture(&uuid::Uuid::new_v4().to_string(), &store_a) + .unwrap(); + let repository_b = + RuntimeRepositoryBinding::capture(&uuid::Uuid::new_v4().to_string(), &store_b) + .unwrap(); + Self { + namespace, + repository_a, + repository_b, + store_a, + store_b, + _root: root, + _environment: environment, + } + } + + fn runtime_claim(&self) -> String { + RuntimeIdentity::current() + .fingerprint(RuntimeMode::current().unwrap(), self.namespace.token()) + } + + fn app(&self, repository: RuntimeRepositoryBinding) -> (Router, Arc) { + let reached = Arc::new(AtomicUsize::new(0)); + let marker = Arc::clone(&reached); + let app = Router::new() + .route( + "/mcp/tools/call", + post(move || { + let marker = Arc::clone(&marker); + async move { + marker.fetch_add(1, Ordering::SeqCst); + "selected repository semantic handler" + } + }), + ) + .layer(middleware::from_fn_with_state( + RuntimeRequestPolicy { + require_identity: true, + local: true, + namespace: Some(self.namespace.clone()), + repository: Some(repository), + }, + validate_runtime_request, + )); + (app, reached) + } + } + + async fn semantic_request( + app: Router, + runtime_claim: &str, + repository_claim: Option<&str>, + ) -> StatusCode { + let mut request = axum::http::Request::builder() + .method("POST") + .uri("/mcp/tools/call") + .header(RUNTIME_IDENTITY_HEADER, runtime_claim) + .header(axum::http::header::CONTENT_TYPE, "application/json"); + if let Some(repository_claim) = repository_claim { + request = request.header(RUNTIME_REPOSITORY_HEADER, repository_claim); + } + app.oneshot( + request + .body(axum::body::Body::from( + r#"{"name":"semantic_locate","arguments":{"query":"captured-target"}}"#, + )) + .unwrap(), + ) + .await + .unwrap() + .status() + } + + #[tokio::test] + async fn runtime_repository_binding_matching_request_reaches_handler() { + let fixture = RepositoryFixture::new(); + let (app, reached) = fixture.app(fixture.repository_a.clone()); + let status = semantic_request( + app, + &fixture.runtime_claim(), + Some(&fixture.repository_a.fingerprint()), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(reached.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn runtime_repository_binding_replayed_peer_headers_refuse_replacement_repo() { + let fixture = RepositoryFixture::new(); + let captured_runtime = fixture.runtime_claim(); + let captured_repository = fixture.repository_a.fingerprint(); + assert_ne!( + fixture.repository_a.identity().repository_id, + fixture.repository_b.identity().repository_id + ); + let (app_a, reached_a) = fixture.app(fixture.repository_a.clone()); + let (app_b, reached_b) = fixture.app(fixture.repository_b.clone()); + let accepted = semantic_request(app_a, &captured_runtime, Some(&captured_repository)).await; + assert_eq!(accepted, StatusCode::OK); + assert_eq!(reached_a.load(Ordering::SeqCst), 1); + let refused = semantic_request(app_b, &captured_runtime, Some(&captured_repository)).await; + assert_eq!(refused, StatusCode::CONFLICT); + assert_eq!(reached_b.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn runtime_repository_binding_missing_repository_header_refuses_before_dispatch() { + let fixture = RepositoryFixture::new(); + let (app, reached) = fixture.app(fixture.repository_a.clone()); + let status = semantic_request(app, &fixture.runtime_claim(), None).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(reached.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn runtime_repository_binding_wrong_repository_uuid_refuses_before_dispatch() { + let fixture = RepositoryFixture::new(); + let mut wrong_repository = fixture.repository_a.identity().clone(); + wrong_repository.repository_id = fixture.repository_b.identity().repository_id.clone(); + assert_eq!( + wrong_repository.store_namespace, + fixture.repository_a.identity().store_namespace + ); + let (app, reached) = fixture.app(fixture.repository_a.clone()); + let status = semantic_request( + app, + &fixture.runtime_claim(), + Some(&wrong_repository.fingerprint()), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(reached.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn runtime_repository_binding_same_uuid_other_store_refuses_before_dispatch() { + let fixture = RepositoryFixture::new(); + let other_store = RuntimeRepositoryBinding::capture( + &fixture.repository_a.identity().repository_id, + &fixture.store_b, + ) + .unwrap(); + assert_eq!( + other_store.identity().repository_id, + fixture.repository_a.identity().repository_id + ); + assert_ne!( + other_store.identity().store_namespace, + fixture.repository_a.identity().store_namespace + ); + let (app, reached) = fixture.app(other_store); + let status = semantic_request( + app, + &fixture.runtime_claim(), + Some(&fixture.repository_a.fingerprint()), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(reached.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn runtime_repository_binding_replaced_store_refuses_before_dispatch() { + let fixture = RepositoryFixture::new(); + let (app, reached) = fixture.app(fixture.repository_a.clone()); + let captured_runtime = fixture.runtime_claim(); + let captured_repository = fixture.repository_a.fingerprint(); + let retired = fixture.store_a.with_file_name("retired-store-a"); + std::fs::rename(&fixture.store_a, retired).unwrap(); + std::fs::create_dir(&fixture.store_a).unwrap(); + fixture.namespace.revalidate().unwrap(); + assert!(fixture.repository_a.revalidate().is_err()); + let status = semantic_request(app, &captured_runtime, Some(&captured_repository)).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(reached.load(Ordering::SeqCst), 0); + } +} + +#[cfg(test)] +mod hosted_runtime_boundary_tests { + use super::*; + use tower::ServiceExt; + + #[tokio::test] + async fn hosted_api_authority_is_independent_of_the_local_runtime_fingerprint() { + let app = Router::new() + .route("/semantic", get(|| async { "hosted API handler" })) + .layer(middleware::from_fn_with_state( + RuntimeRequestPolicy { + require_identity: false, + local: false, + namespace: None, + repository: None, + }, + validate_runtime_request, + )); + let response = app + .oneshot( + axum::http::Request::builder() + .uri("/semantic") + .header( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + "different-local-release", + ) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } +} + #[derive(Clone)] struct DaemonAuthState { tokens: crate::auth_rotation::RotationTokens, @@ -2035,7 +2886,7 @@ async fn request_daemon_shutdown( StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": format!("current process identity unavailable: {error}")})), ) - .into_response() + .into_response(); } }; if current != expected { @@ -2081,10 +2932,16 @@ async fn request_daemon_shutdown( /// Bound to the process incarnation the caller attributed, exactly as /// `/shutdown` is, so a retirement meant for a daemon that has since been /// replaced never reaches its successor. +/// +/// A client of another release sends the incarnation inside a replacement +/// envelope naming itself. The daemon then makes way rather than waiting to be +/// unused: see `DaemonState::request_replacement`. A bare incarnation is the +/// plain retirement every daemon with this route has taken. async fn request_daemon_retirement( State(state): State>, - Json(expected): Json, + Json(request): Json, ) -> Response { + let (expected, replacement) = request.into_parts(); let current = match kin_cli::daemon_client::current_process_identity() { Ok(current) => current, Err(error) => { @@ -2092,7 +2949,7 @@ async fn request_daemon_retirement( StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": format!("current process identity unavailable: {error}")})), ) - .into_response() + .into_response(); } }; if current != expected { @@ -2102,15 +2959,71 @@ async fn request_daemon_retirement( ) .into_response(); } - state.request_retirement(); + match replacement { + Some(claim) => { + if state.request_replacement(&claim.successor) { + info!( + successor = %claim.successor, + "another release asked this daemon to make way for it" + ); + } + } + None => { + state.request_retirement(); + } + } // This request is itself in flight, so it leaves itself out of the count. - let blocked_by: Vec<&'static str> = crate::daemon::retirement_blockers(&state, 1) - .into_iter() - .map(crate::daemon::ShutdownBlocker::describe) - .collect(); + let blockers = crate::daemon::retirement_blockers(&state, 1); + let replacing = state.replacement(); ( StatusCode::ACCEPTED, - Json(json!({"retiring": true, "blocked_by": blocked_by})), + Json(kin_cli::daemon_client::replacement::RetirementReply { + retiring: true, + blocked_by: blockers + .iter() + .map(|blocker| blocker.describe().to_string()) + .collect(), + blockers: Some( + blockers + .iter() + .map(|blocker| blocker.code().to_string()) + .collect(), + ), + replacing: replacing.is_some(), + successor: replacing.map(|replacement| replacement.successor), + }), + ) + .into_response() +} + +/// Answer every new request with 503 `daemon_replacing` once this daemon is +/// making way for another release, so the requests already in flight can +/// finish and nothing new starts behind them. +/// +/// The client is told which release replaced this one and to restart, which is +/// the whole answer for a client of the replaced release: the successor will +/// not serve it either. Probes and the lifecycle routes still pass, so a +/// supervisor can watch the drain and the replacing client can ask again. +async fn refuse_new_requests_while_replacing( + State(state): State>, + request: axum::http::Request, + next: Next, +) -> Response { + let Some(replacement) = state.replacement() else { + return next.run(request).await; + }; + let path = request.uri().path(); + if is_process_lifecycle_route(path) + || is_process_liveness_route(path) + || is_readiness_route(path) + { + return next.run(request).await; + } + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(kin_cli::daemon_client::replacement::ReplacingRefusal::new( + &replacement.successor, + )), ) .into_response() } @@ -2550,6 +3463,14 @@ fn api_routes() -> Router> { get(crate::agent_activity::session_calls_route), ) .route("/agents", get(crate::agent_activity::agents_route)) + .route( + "/agents/ended", + get(crate::agent_activity::ended_agents_route), + ) + .route( + "/agents/edits", + post(crate::client_edits::record_edit_route), + ) .route( "/session/{session_id}/scope", post(set_scope).delete(clear_scope).get(get_scope), @@ -2570,10 +3491,12 @@ fn api_routes() -> Router> { .route("/review", post(review)) .route("/work", post(work)) .route("/note", post(note)) + .route("/review/flags", post(crate::review_flags::route)) .route("/embed", post(embed)) .route("/blame", post(blame)) .route("/history", post(history)) .route("/verify/run", post(verify_run)) + .route("/verify/work", post(verify_work)) .route("/commands/verify", post(command_verify)) .route("/support", get(support)) .route("/graph/bootstrap", get(graph_bootstrap)) @@ -2599,6 +3522,11 @@ fn api_routes() -> Router> { .route("/commands/approvals", post(command_approvals)) .route("/commands/security", post(command_security)) .route("/commands/branch", post(command_branch)) + .route("/commands/git-update", post(crate::git_update::update)) + .route( + "/commands/git-update/status", + get(crate::git_update::status), + ) .route("/commands/branch/preview", post(command_branch_preview)) .route("/commands/branch/confirm", post(command_branch_confirm)) .route("/commands/merge", post(command_merge)) @@ -2625,6 +3553,8 @@ fn api_routes() -> Router> { ) .route("/reconcile", post(reconcile_session_workspace)) .route("/commands/commit", post(command_commit)) + .route("/commands/commit/entities", post(command_commit_entities)) + .route("/commands/entity/restore", post(command_entity_restore)) .route("/commands/push", post(command_push)) .route("/commands/pull", post(command_pull)) .route("/commands/transfer-plan", post(command_transfer_plan)) @@ -3073,9 +4003,9 @@ fn router_with_rotation_tokens( "cargo registry startup repair: repaired {:?}; left unserveable {:?}; snapshot {:?}", report.repaired, report.unrepairable, report.snapshot ), - Err(error) => tracing::error!( - "cargo registry startup repair failed and rolled back: {error}" - ), + Err(error) => { + tracing::error!("cargo registry startup repair failed and rolled back: {error}") + } } } else { info!("cargo registry startup repair disabled by KIN_REGISTRY_STARTUP_REPAIR"); @@ -3128,10 +4058,38 @@ fn router_with_rotation_tokens( // `validate_host_and_origin`) still apply to EVERYTHING, registry included. let admission_state = Arc::clone(&state); let prepared_state = Arc::clone(&state); + let replacing_state = Arc::clone(&state); + let runtime_component = if state.publication_control.is_some() { + "hosted-daemon" + } else { + "daemon" + }; + let runtime_repository = if state.publication_control.is_none() { + local_runtime_repository_binding(&state).map(Some) + } else { + Ok(None) + }; + let runtime_policy = RuntimeRequestPolicy { + local: state.publication_control.is_none(), + require_identity: state.publication_control.is_none() + && (kin_core::runtime_identity::RuntimeMode::current().ok() + != Some(kin_core::runtime_identity::RuntimeMode::Development) + || development_requires_runtime_identity()), + namespace: if state.publication_control.is_none() { + kin_core::runtime_identity::RuntimeNamespace::for_service().ok() + } else { + None + }, + repository: runtime_repository.as_ref().ok().cloned().flatten(), + }; let daemon_routes = Router::new() .merge(routes.clone()) .nest("/v2", routes) .layer(Extension(DaemonShutdownControl(shutdown))) + .layer(middleware::from_fn_with_state( + replacing_state, + refuse_new_requests_while_replacing, + )) .layer(middleware::from_fn_with_state( prepared_state, crate::prepared_publication::serving, @@ -3150,10 +4108,37 @@ fn router_with_rotation_tokens( }, daemon_auth, )) - .with_state(state); + .with_state(state) + .layer(middleware::from_fn_with_state( + runtime_policy, + validate_runtime_request, + )); + let versioned_runtime_repository = runtime_repository.clone(); let app = Router::new() .merge(daemon_routes) + .route( + "/runtime/identity", + get(move || { + let repository = runtime_repository.clone(); + async move { + let repository = + repository.map_err(|error| (StatusCode::SERVICE_UNAVAILABLE, error))?; + runtime_identity_for_repository(runtime_component, repository).await + } + }), + ) + .route( + "/v2/runtime/identity", + get(move || { + let repository = versioned_runtime_repository.clone(); + async move { + let repository = + repository.map_err(|error| (StatusCode::SERVICE_UNAVAILABLE, error))?; + runtime_identity_for_repository(runtime_component, repository).await + } + }), + ) .merge(cargo_routes) .merge(npm_routes) .merge(oci_routes) @@ -4895,6 +5880,56 @@ impl NameReadWait { } } + /// For a read of committed history whose attempt a writer moved the + /// authority epoch across: whether the graph's truth held still all the + /// same, so the answer read is the one the last settled graph gives. + /// + /// A writer that held graph authority while the attempt ran and changed + /// nothing, such as a pass over the working copy that found it as it was, + /// leaves the graph's truth epoch where it was. Each write to truth + /// advances that epoch before its writer lets authority go, so once no + /// writer holds authority and the reconcile loop holds no changes it + /// picked up, an epoch that never moved since the attempt began means + /// every row the attempt read was the graph's row throughout. + /// + /// Waits, within the read's limit and holding nothing, for writers still + /// running. A truth that moved, or a writer that outlasts the limit, is + /// left to the ordinary wait, which reads again or refuses. + /// + /// Before this a guide of one commit that took longer to build than the + /// gap between two such passes was served only when one attempt happened + /// to fall between them: on zod, while its watcher dropped events and a + /// pass ran every six to eight seconds, a 20 s guide build was spanned on + /// most attempts, and the page's guide did not load in four minutes. + async fn truth_held_still( + &mut self, + state: &DaemonState, + graph: &kin_db::InMemoryGraph, + epoch: &AnalysisReadEpoch, + ) -> bool { + if graph.truth_epoch() != epoch.truth { + return false; + } + self.pass = None; + let started = tokio::time::Instant::now(); + let drain = state + .wait_for_graph_authority_drain( + self.deadline, + crate::state::SettleScope::WritersAndPendingAdmissions, + ) + .await; + if drain != crate::state::GraphAuthorityDrain::NoWriter { + self.wait.waited += started.elapsed(); + } + if matches!(drain, crate::state::GraphAuthorityDrain::DeadlinePassed(_)) { + return false; + } + // Every writer the read could have met has finished, so its writes to + // truth are counted; one that begins from here on began after the + // read ended. + graph.truth_epoch() == epoch.truth + } + /// Whether an answer read since `epoch` was sampled may claim an absence: /// the read is of a historical scope no admission writes, or nothing /// blocked it as it began, no writer moved authority since, and the @@ -5537,7 +6572,7 @@ fn stamp_settled_replay( } async fn command_xref_with_stable_authority( - state: &DaemonState, + state: &Arc, session_id: Option<&SessionId>, selected_graph: Arc, authority: RequestGraphAuthority, @@ -5555,7 +6590,7 @@ where // Initialize once per round, outside the stamped interval: lazy spine // hydration and registration can be expensive, but it is not part of // the graph read. - let spine_authority = state.acquire_spine_read_authority().await; + let spine_authority = state.acquire_spine_read_authority_for_read().await; if state.hosted_spine_readiness_required() && spine_authority.is_none() { return Err(( StatusCode::SERVICE_UNAVAILABLE, @@ -5729,7 +6764,7 @@ where let deadline = tokio::time::Instant::now() + wait.ceiling; let mut attempts_made = 0; loop { - let spine_authority = state.acquire_spine_read_authority().await; + let spine_authority = state.acquire_spine_read_authority_for_read().await; if state.hosted_spine_readiness_required() && spine_authority.is_none() { return Err(kin_mcp::McpError::Other(state.spine_unavailable_reason())); } @@ -6099,7 +7134,7 @@ fn require_mcp_local_repository_binding( } async fn mcp_bulk_check_references_with_stable_authority( - state: &DaemonState, + state: &Arc, session_id: Option<&SessionId>, selected_graph: Arc, authority: RequestGraphAuthority, @@ -6114,7 +7149,7 @@ where let deadline = tokio::time::Instant::now() + wait.ceiling; let mut attempts_made = 0; loop { - let spine_authority = state.acquire_spine_read_authority().await; + let spine_authority = state.acquire_spine_read_authority_for_read().await; if state.hosted_spine_readiness_required() && spine_authority.is_none() { return Err(kin_mcp::McpError::Other(state.spine_unavailable_reason())); } @@ -6904,6 +7939,12 @@ async fn start_session( capabilities, ) .map_err(internal_error)?; + if let (None, Some(watch_pid)) = (request.pid, request.watch_pid) { + state + .coordinator + .watch_process_exit(&session_id, watch_pid) + .map_err(internal_error)?; + } let session = state .coordinator .get_session(&session_id) @@ -8403,7 +9444,7 @@ async fn command_refs( // The spine read the reference tools take, so an empty answer is graded // on the cross-repo authority `find_references` weighs. Held for this // attempt only, and let go before any wait below. - let spine_authority = state.acquire_spine_read_authority().await; + let spine_authority = state.acquire_spine_read_authority_for_read().await; let spine_deferral = state.spine_initialization_deferral(); // The text at each site is cut from its caller's own body through the // held authority and at the source scope `find_references` reads at, @@ -9924,7 +10965,7 @@ fn command_commit_after_admission( .into_iter() .filter_map(|path| RepoPath::from_utf8(path).ok()) .collect(), - )) + )); } Err(error) => return Err(repository_commit_error(error).into()), }; @@ -9945,59 +10986,10 @@ fn command_commit_after_admission( .collect::>(); // --- Lease enforcement gate for native repository publication --- + if let Some(refusal) = + foreign_hard_lease_refusal(state, &plan.entity_scope_ids, request.session_id.as_deref())? { - use kin_model::session::IntentScope; - - let scopes: Vec = plan - .entity_scope_ids - .iter() - .copied() - .map(IntentScope::Entity) - .collect(); - - if !scopes.is_empty() { - let caller_session = request - .session_id - .as_ref() - .and_then(|s| uuid::Uuid::parse_str(s).ok().map(kin_model::SessionId)); - let traffic = state - .coordinator - .check_traffic(&scopes) - .map_err(internal_error)?; - if traffic.has_hard_blocks { - let foreign_blocks: Vec<_> = traffic - .reports - .iter() - .flat_map(|r| r.active_intents.iter()) - .filter(|s| { - s.lock_type == kin_model::session::LockType::Hard - && caller_session.as_ref().is_none_or(|cs| &s.session_id != cs) - }) - .collect(); - if !foreign_blocks.is_empty() { - let body = serde_json::json!({ - "error": "lease_conflict", - "conflict_type": "HardCollision", - "blocking_intents": foreign_blocks.iter().map(|b| { - serde_json::json!({ - "intent_id": b.intent_id.to_string(), - "session_id": b.session_id.to_string(), - "lock_type": format!("{:?}", b.lock_type), - "task_description": b.task_description, - }) - }).collect::>(), - "message": format!( - "commit blocked: {} entity scope(s) held by active hard lease(s)", - foreign_blocks.len() - ), - }); - return Err(CommitAttempt::Refused(( - StatusCode::CONFLICT, - body.to_string(), - ))); - } - } - } + return Err(CommitAttempt::Refused(refusal)); } let committed = if let Some(observed) = observed { @@ -10035,6 +11027,11 @@ fn command_commit_after_admission( }) .map_err(internal_error)?; crate::binding_history::restore_exact_authority(state); + crate::client_edits::seal_commit( + state, + &committed.change, + committed.resolved_publication_base, + ); // The plan above was derived from this live graph under the coordination // gate, and the transaction published it, so the two are level here and // this is the count durable authority now carries (FIR-2421). Read from the @@ -10071,6 +11068,210 @@ fn command_commit_after_admission( })) } +/// The refusal a native publication meets when another session holds a hard +/// lease on an entity it changes, or `None` when nothing blocks it. +/// +/// `caller` is the session the write names, whose own leases never block it. +pub(crate) fn foreign_hard_lease_refusal( + state: &DaemonState, + entity_scope_ids: &[kin_model::EntityId], + caller: Option<&str>, +) -> Result, (StatusCode, String)> { + use kin_model::session::IntentScope; + + let scopes: Vec = entity_scope_ids + .iter() + .copied() + .map(IntentScope::Entity) + .collect(); + if scopes.is_empty() { + return Ok(None); + } + let caller_session = + caller.and_then(|s| uuid::Uuid::parse_str(s).ok().map(kin_model::SessionId)); + let traffic = state + .coordinator + .check_traffic(&scopes) + .map_err(internal_error)?; + if !traffic.has_hard_blocks { + return Ok(None); + } + let foreign_blocks: Vec<_> = traffic + .reports + .iter() + .flat_map(|r| r.active_intents.iter()) + .filter(|s| { + s.lock_type == kin_model::session::LockType::Hard + && caller_session.as_ref().is_none_or(|cs| &s.session_id != cs) + }) + .collect(); + if foreign_blocks.is_empty() { + return Ok(None); + } + let body = serde_json::json!({ + "error": "lease_conflict", + "conflict_type": "HardCollision", + "blocking_intents": foreign_blocks.iter().map(|b| { + serde_json::json!({ + "intent_id": b.intent_id.to_string(), + "session_id": b.session_id.to_string(), + "lock_type": format!("{:?}", b.lock_type), + "task_description": b.task_description, + }) + }).collect::>(), + "message": format!( + "commit blocked: {} entity scope(s) held by active hard lease(s)", + foreign_blocks.len() + ), + }); + Ok(Some((StatusCode::CONFLICT, body.to_string()))) +} + +/// The answer an entity write gives when it stopped before it began, worded +/// for the person who asked. +fn entity_refusal(message: &str) -> (StatusCode, String) { + ( + StatusCode::CONFLICT, + json!({ + "error": kin_cli::commands::entity_work::ENTITY_REFUSED, + "message": message, + }) + .to_string(), + ) +} + +/// POST /commands/commit/entities — commit only the chosen entities' +/// uncommitted changes and leave every other change uncommitted. +/// +/// The same admission and the same gate as `/commands/commit`, and the same +/// operation id contract: the caller's id is the repository operation, so a +/// request whose answer was lost is settled by authority's receipt, and a +/// request sent again after it landed answers with the change it recorded. +async fn command_commit_entities( + headers: axum::http::HeaderMap, + State(state): State>, + Json(request): Json, +) -> Result { + if state.filesystem_reconcile_disabled() { + return Err(filesystem_ingest_disabled_response( + "/commands/commit/entities", + &state.layout.working_dir().display().to_string(), + )); + } + if state.storage_backend.is_some() { + return Err(entity_refusal( + "Entity work is not available on a hosted store, so nothing was committed.", + )); + } + if request.operation_id.0.is_nil() { + return Err(entity_refusal("A commit needs an operation id.")); + } + if request.entities.is_empty() { + return Err(entity_refusal("Choose at least one entity to commit.")); + } + if request.message.trim().is_empty() { + return Err(entity_refusal("A commit needs a message.")); + } + if let Some(refusal) = read_only_session_write_refusal( + &state, + &headers, + request.session_id.as_deref(), + kin_mcp::session::WriteDoor::CliCommit, + )? { + return Err(refusal); + } + let _pending_commit = state.pending_commits.announce(); + let _transaction = + crate::commit_liveness::TransactionGuard::open(state.layout.root(), "commit"); + let _coordination = crate::mcp_commit::timed_commit_phase_async( + "coordination_gate_wait", + state.coordination_gate.lock(), + ) + .await; + if cached_authority_has_open_merge(&state)? { + return Err(entity_refusal( + "A merge is still open, so nothing was committed; finish or abandon the merge first.", + )); + } + // The working copy is admitted first, so the entities chosen from what the + // reader saw are the entities the plan reads, and the plan's own check + // that the daemon holds the admitted tree has something current to hold. + crate::loop_runner::sync_filesystem_with_graph_under_coordination(&state) + .await + .map_err(|error| { + tracing::warn!(%error, "the working copy could not be admitted for an entity commit"); + entity_refusal("Kin could not read the working copy, so nothing was committed.") + })?; + state + .background_work + .reconcile() + .record_admission_success(std::time::Instant::now()); + crate::background_work::record_durable_admission( + &state.layout, + state.graph.resolved_tree().len() as u64, + ); + let response = crate::entity_keep::commit_entities(&state, &request) + .map_err(|refusal| refusal.into_http())?; + Ok(Json(response)) +} + +/// POST /commands/entity/restore — put one entity's source back as it was at +/// the head, or at an earlier change, as an uncommitted edit. +async fn command_entity_restore( + headers: axum::http::HeaderMap, + State(state): State>, + Json(request): Json, +) -> Result { + if state.filesystem_reconcile_disabled() { + return Err(filesystem_ingest_disabled_response( + "/commands/entity/restore", + &state.layout.working_dir().display().to_string(), + )); + } + if state.storage_backend.is_some() { + return Err(entity_refusal( + "Entity work is not available on a hosted store, so nothing was restored.", + )); + } + if request.operation_id.0.is_nil() { + return Err(entity_refusal("A restore needs an operation id.")); + } + // Like a checkout, a restore writes the primary working copy, which no + // session owns, so it is refused for any session rather than held to one + // session's capabilities. + if extract_session_id_from_headers(&headers)?.is_some() { + return Err(entity_refusal( + "A restore writes the primary working copy and does not run inside a session, so \ + nothing was restored.", + )); + } + let _transaction = + crate::commit_liveness::TransactionGuard::open(state.layout.root(), "restore"); + let _coordination = state.coordination_gate.lock().await; + if cached_authority_has_open_merge(&state)? { + return Err(entity_refusal( + "A merge is still open, so nothing was restored; finish or abandon the merge first.", + )); + } + crate::loop_runner::sync_filesystem_with_graph_under_coordination(&state) + .await + .map_err(|error| { + tracing::warn!(%error, "the working copy could not be admitted for an entity restore"); + entity_refusal("Kin could not read the working copy, so nothing was restored.") + })?; + state + .background_work + .reconcile() + .record_admission_success(std::time::Instant::now()); + crate::background_work::record_durable_admission( + &state.layout, + state.graph.resolved_tree().len() as u64, + ); + let response = crate::entity_keep::restore_entity(&state, &request) + .map_err(|refusal| refusal.into_http())?; + Ok(Json(response)) +} + /// Refuse a CLI write made for a read-only session. /// /// A session's capabilities used to be enforced nowhere a write actually went, @@ -10826,6 +12027,38 @@ struct GraphEventsParams { types: Option, } +/// The successor's name, once this daemon is making way for another release. +/// Never resolves otherwise, so an event stream can wait on it beside its +/// events. +async fn replaced_successor( + watch: &mut tokio::sync::watch::Receiver>, +) -> String { + let successor = match watch.wait_for(Option::is_some).await { + Ok(current) => current + .as_ref() + .map(|replacement| replacement.successor.clone()), + Err(_) => None, + }; + match successor { + Some(successor) => successor, + None => std::future::pending().await, + } +} + +/// The last frame an event stream sends when its daemon is replaced: the same +/// out-of-band shape as `connected` and `lagged`, naming the successor and +/// telling the subscriber to restart, since the successor will not serve a +/// client of this release either. +fn replaced_frame(successor: &str) -> String { + let frame = json!({ + "type": "replaced", + "code": kin_cli::daemon_client::replacement::DAEMON_REPLACING, + "successor": successor, + "message": kin_cli::daemon_client::replacement::replaced_message(successor), + }); + format!("data: {frame}\n\n") +} + /// GET /graph/events: the graph-facing view of the daemon event bus. /// /// The same broadcast channel `/vfs/subscribe` serves, which is left exactly as @@ -10851,6 +12084,7 @@ async fn graph_events( .filter(|names| !names.is_empty()); let mut rx = state.event_tx.subscribe(); + let mut replaced = state.replacement_watch(); let root_hash = hex::encode(state.graph.compute_root_hash()); let entity_count = state.graph.entity_count(); let seq = state.current_event_seq(); @@ -10911,6 +12145,12 @@ async fn graph_events( _ = heartbeat.tick() => { yield Ok(": heartbeat\n\n".to_string()); } + successor = replaced_successor(&mut replaced) => { + // Never filtered, like a gap: it is the last thing this + // stream says. + yield Ok(replaced_frame(&successor)); + break; + } } } }; @@ -12590,36 +13830,93 @@ async fn review( // binding, which opened and re-verified the whole store per request, and // that open was nearly all of what an entity's history read cost. let repository_authority = entity_history_authority(&state)?; + let mut stages = ReviewReadStages::begin(&req); + // A guide names, on each entity an agent wrote through Kin, the session + // that wrote it; see `crate::agent_authorship`. + let (agent_writes, client_edits) = + if matches!(req, kin_cli::commands::review::ReviewRequest::Guide { .. }) { + ( + state.agent_authorship.writes(), + state.agent_authorship.edits(), + ) + } else { + (Vec::new(), Vec::new()) + }; + // A guide of committed history is kept once built and joined while it + // builds; see `crate::committed_guides`. A session's temporal scope reads + // another graph, which is read the ordinary way. + if let kin_cli::commands::review::ReviewRequest::Guide { + change, + base, + json, + working: false, + } = &req + { + if matches!(graph_authority, RequestGraphAuthority::Head) { + let request = + crate::committed_guides::GuideRequest::new(change.as_deref(), base.as_deref()); + if let Some(answer) = committed_guide( + &state, + session_id, + request, + *json, + &graph, + &agent_writes, + &client_edits, + ) + .await + { + return answer.map(Json); + } + } + } // A review and its persisted enrichment limits must describe one graph // epoch. Non-analysis reads retain their existing behavior. let mut settle = NameReadWait::begin(&state, &graph); let execution = loop { + let attempt_started = std::time::Instant::now(); let epoch = settle .sample_analysis(&state, &graph, graph_authority) .await; + stages.sampled(attempt_started, epoch.writer.is_some()); let source_authority = mcp_repository_authority_source(&state) .map_err(|error| (StatusCode::SERVICE_UNAVAILABLE, error.to_string()))?; // A guide of uncommitted work reads the diff `/commands/diff` reads, // and takes the authority the way that route does: one open per // publication rather than one per request. The page that shows it // asks again as the working tree changes. - let open_authority = if matches!( - req, - kin_cli::commands::review::ReviewRequest::Guide { working: true, .. } - ) { - Some(command_repository_authority(&state)?) - } else { - None - }; - let outcome = kin_cli::commands::review::execute_review_request_with_authority( - &repository_authority, - graph.as_ref(), - req.clone(), - source_authority.as_ref(), - source_scope, - open_authority.as_deref(), - ) - .await; + // + // The authority open and the build are synchronous and can run for + // seconds, a guide of a large committed change for most of a minute. + // Inline on the runtime worker they held whatever the scheduler had + // queued there, the listener among it: measured on zod, every request + // to this daemon, its health included, waited out a 50 s guide build. + // So they run off the worker, and reads arriving meanwhile are + // answered. + let build_started = std::time::Instant::now(); + let outcome = crate::lifecycle::without_blocking_runtime_worker(|| { + #[cfg(test)] + pause_review_read_for_test(&state); + let open_authority = if matches!( + req, + kin_cli::commands::review::ReviewRequest::Guide { working: true, .. } + ) { + Some(command_repository_authority(&state)?) + } else { + None + }; + Ok::<_, (StatusCode, String)>(kin_cli::commands::review::execute_review_read( + &repository_authority, + graph.as_ref(), + req.clone(), + source_authority.as_ref(), + source_scope, + open_authority.as_deref(), + &agent_writes, + &client_edits, + )) + })?; + stages.built(build_started); if state .source_scope_for_selected_graph(session_id.as_ref(), &graph, graph_authority) .await @@ -12637,22 +13934,14 @@ async fn review( ); let settled = if analysis { epoch.is_current(&state, &graph) + || (reads_committed_history(&req) + && settle.truth_held_still(&state, &graph, &epoch).await) } else { settle.settled(&state, epoch.writer) }; if !(analysis || review_run_claims_absence(&req, &outcome)) || settled { - break outcome.map_err(|error| { - if kin_cli::commands::ref_lookup::is_ref_resolution_error(&error) { - (StatusCode::BAD_REQUEST, crate::error::cause_first(&error)) - } else if error - .downcast_ref::() - .is_some() - { - (StatusCode::NOT_FOUND, error.to_string()) - } else { - internal_error(error) - } - })?; + stages.finish(settle.attempts, settle.wait.waited); + break outcome.map_err(review_read_refusal)?; } settle .wait_out(&state, "review") @@ -12662,6 +13951,301 @@ async fn review( Ok(Json(execution.response)) } +/// The status a failed review read is answered with: a ref that resolves to +/// nothing is the caller's, a review that does not exist is not found, and +/// anything else is this daemon's. +fn review_read_refusal(error: anyhow::Error) -> (StatusCode, String) { + if kin_cli::commands::ref_lookup::is_ref_resolution_error(&error) { + (StatusCode::BAD_REQUEST, crate::error::cause_first(&error)) + } else if error + .downcast_ref::() + .is_some() + { + (StatusCode::NOT_FOUND, error.to_string()) + } else { + internal_error(error) + } +} + +/// The basis a guide of committed history read from `graph` stands at now: +/// the graph's identity and writes to truth, and the repository's +/// publication. `None` on a daemon with no local publication to read, which +/// builds every guide as it is asked. +fn committed_guide_basis( + state: &DaemonState, + graph: &kin_db::InMemoryGraph, +) -> Option { + let backend = state.local_repository_backend()?; + let binding = state.local_repository_authority_binding().ok()?; + let publication = read_local_publication_identity(&backend, binding.repository_id()).ok()?; + Some(crate::committed_guides::GuideBasis::of(graph, publication)) +} + +/// Answer a guide of committed history from the guide kept for its basis, +/// from the build of it already running, or from a build this request +/// starts. `None` when the daemon cannot read the basis to keep a guide by. +/// +/// Each answer marks the guide's steps with the agent sessions that wrote +/// them as they are recorded now, so a session recorded after the guide was +/// built is named on it. +async fn committed_guide( + state: &Arc, + session_id: Option, + request: crate::committed_guides::GuideRequest, + json: bool, + graph: &Arc, + agent_writes: &[kin_cli::commands::review_guide::AgentWrite], + client_edits: &[kin_cli::commands::review_guide::ClientEdit], +) -> Option> { + use crate::committed_guides::{built, Found}; + let arrived = committed_guide_basis(state, graph)?; + let started = std::time::Instant::now(); + let (guide, how) = loop { + match state.committed_guides.find(&request, &arrived) { + Found::Kept(guide) => break (Ok(guide), "kept"), + Found::Lead(sender) => { + let receiver = sender.subscribe(); + // On a task of its own, so a build outlives a request that + // stops waiting for it, and the next read of the change + // takes it up where it is. + let building = Arc::clone(state); + let read_from = Arc::clone(graph); + let building_request = request.clone(); + tokio::spawn(async move { + let built = + build_committed_guide(&building, session_id, &building_request, read_from) + .await; + building + .committed_guides + .finish(&building_request, sender, built); + }); + break match built(receiver).await { + Some(Ok((_, guide))) => (Ok(guide), "built"), + Some(Err(refusal)) => (Err(refusal), "built"), + None => ( + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "the guide's build ended without an answer".to_string(), + )), + "built", + ), + }; + } + Found::Join(receiver) => match built(receiver).await { + // A build that began before this request settled where the + // repository stood as it ended. Its guide answers this request + // only when it describes the repository as the request found + // it, or as it stands now; otherwise the request looks again. + Some(Ok((basis, guide))) + if basis == arrived + || committed_guide_basis(state, graph).as_ref() == Some(&basis) => + { + break (Ok(guide), "joined"); + } + Some(Ok(_)) | None => continue, + Some(Err(refusal)) => break (Err(refusal), "joined"), + }, + } + }; + let answered = match guide { + Ok(guide) => crate::lifecycle::without_blocking_runtime_worker(|| { + kin_cli::commands::review::guide_response( + guide.as_ref().clone(), + json, + agent_writes, + client_edits, + ) + }) + .map_err(internal_error), + Err(refusal) => Err(refusal), + }; + let elapsed = started.elapsed(); + if how != "kept" || elapsed >= Duration::from_secs(1) { + tracing::info!( + how, + elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX), + builds = state.committed_guides.builds(), + kept_served = state.committed_guides.kept_served(), + "committed guide answered" + ); + } + Some(answered) +} + +/// Build the guide of committed history `request` names, settled the way +/// every review read is: an attempt whose graph a writer moved is read again, +/// and one that never holds still is refused at the read's limit. +async fn build_committed_guide( + state: &Arc, + session_id: Option, + request: &crate::committed_guides::GuideRequest, + graph: Arc, +) -> crate::committed_guides::Built { + let graph_authority = RequestGraphAuthority::Head; + let source_scope = state + .source_scope_for_selected_graph(session_id.as_ref(), &graph, graph_authority) + .await + .ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + "selected source scope expired or was replaced".to_string(), + ) + })?; + let repository_authority = entity_history_authority(state)?; + let mut stages = ReviewReadStages::begin(&kin_cli::commands::review::ReviewRequest::Guide { + change: request.change.clone(), + json: true, + working: false, + base: request.base.clone(), + }); + let mut settle = NameReadWait::begin(state, &graph); + loop { + let attempt_started = std::time::Instant::now(); + let epoch = settle.sample_analysis(state, &graph, graph_authority).await; + stages.sampled(attempt_started, epoch.writer.is_some()); + let basis = committed_guide_basis(state, &graph).ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + "this daemon could not read its repository's publication".to_string(), + ) + })?; + let source_authority = mcp_repository_authority_source(state) + .map_err(|error| (StatusCode::SERVICE_UNAVAILABLE, error.to_string()))?; + let build_started = std::time::Instant::now(); + let outcome = crate::lifecycle::without_blocking_runtime_worker(|| { + #[cfg(test)] + pause_review_read_for_test(state); + kin_cli::commands::review::build_committed_guide( + &repository_authority, + graph.as_ref(), + request.change.as_deref(), + request.base.as_deref(), + source_authority.as_ref(), + source_scope, + ) + }); + stages.built(build_started); + if state + .source_scope_for_selected_graph(session_id.as_ref(), &graph, graph_authority) + .await + != Some(source_scope) + { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "selected source scope changed while reading review; retry the request".into(), + )); + } + if epoch.is_current(state, &graph) || settle.truth_held_still(state, &graph, &epoch).await { + stages.finish(settle.attempts, settle.wait.waited); + return outcome + .map(|guide| (basis, Arc::new(guide))) + .map_err(review_read_refusal); + } + settle + .wait_out(state, "review") + .await + .map_err(|refusal| (StatusCode::SERVICE_UNAVAILABLE, refusal))?; + } +} + +/// A pause a review read takes as it builds, for the one daemon state a test +/// names, so a test can hold a read open as long as a guide of a large change +/// runs without a repository large enough to make it slow. Keyed by the +/// state's address, so no other test's read waits on it. +#[cfg(test)] +static REVIEW_READ_PAUSE_FOR_TEST: std::sync::Mutex> = + std::sync::Mutex::new(None); + +#[cfg(test)] +fn pause_review_read_for_test(state: &DaemonState) { + let owner = state as *const DaemonState as usize; + let named = *REVIEW_READ_PAUSE_FOR_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((named_state, pause)) = named { + if named_state == owner { + std::thread::sleep(pause); + } + } +} + +/// Whether a review read is a guide of committed history: one change, or the +/// changes between two refs, rather than the working copy or a run over files. +/// +/// Its change, the history it replays and the graph at that change never +/// move. What it reads of the live graph is truth, which moves only by a +/// write, so it can be served once the graph's truth held still across the +/// read; see [`NameReadWait::truth_held_still`]. +fn reads_committed_history(request: &kin_cli::commands::review::ReviewRequest) -> bool { + matches!( + request, + kin_cli::commands::review::ReviewRequest::Guide { working: false, .. } + ) +} + +/// Where one review read spent its time, logged when the read was slow. +/// +/// A guide read after an edit answers only once the graph holds still, so its +/// wall time is the build plus however long it waited for that. The two are +/// separate costs with separate fixes, and the request's own latency cannot +/// tell them apart. +struct ReviewReadStages { + op: &'static str, + started: std::time::Instant, + sample_wait: Duration, + build: Duration, + last_build: Duration, + unsettled_samples: usize, +} + +impl ReviewReadStages { + fn begin(request: &kin_cli::commands::review::ReviewRequest) -> Self { + use kin_cli::commands::review::ReviewRequest; + Self { + op: match request { + ReviewRequest::Guide { working: true, .. } => "guide_working", + ReviewRequest::Guide { .. } => "guide", + ReviewRequest::Run { .. } => "run", + _ => "other", + }, + started: std::time::Instant::now(), + sample_wait: Duration::ZERO, + build: Duration::ZERO, + last_build: Duration::ZERO, + unsettled_samples: 0, + } + } + + fn sampled(&mut self, attempt_started: std::time::Instant, settled: bool) { + self.sample_wait += attempt_started.elapsed(); + self.unsettled_samples += usize::from(!settled); + } + + fn built(&mut self, build_started: std::time::Instant) { + self.last_build = build_started.elapsed(); + self.build += self.last_build; + } + + fn finish(&self, attempts: usize, writer_wait: Duration) { + let elapsed = self.started.elapsed(); + if elapsed < Duration::from_secs(1) { + return; + } + let ms = |duration: Duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX); + tracing::info!( + op = self.op, + elapsed_ms = ms(elapsed), + attempts, + unsettled_samples = self.unsettled_samples, + build_ms = ms(self.build), + last_build_ms = ms(self.last_build), + sample_wait_ms = ms(self.sample_wait), + writer_wait_ms = ms(writer_wait), + "slow review read" + ); + } +} + /// Whether a review read claims an absence: a files review whose files /// resolved to no entity in the live graph, which the review reports as no /// changes. A file an edit admission is adding has no entity until it admits. @@ -13229,6 +14813,34 @@ fn with_graph_authority_mutation(state: &DaemonState, operation: impl FnOnce( result } +/// The authority a test run plans and records through: the one this daemon +/// holds open for the current publication, resolved when a step first reads +/// it, as [`command_repository_authority`] serves every request. +/// +/// A run used to open the persisted authority itself three times, once for +/// each of its two plans and once to keep its output as one evidence blob, +/// and each open re-verifies every persisted body. On a converted fastify +/// store that was about 6 seconds apiece, so a test command that exited in +/// 0.4 seconds took 25 to report. +fn verification_authority( + state: &Arc, +) -> Result +{ + let binding = state + .local_repository_authority_binding() + .map_err(repository_authority_error)?; + let resolver_state = Arc::clone(state); + Ok( + kin_cli::commands::repository_authority::RequestRepositoryAuthority::shared( + binding, + Arc::new(move || { + command_repository_authority(&resolver_state) + .map_err(|(_, message)| anyhow::anyhow!(message)) + }), + ), + ) +} + /// POST /verify/run — execute and persist a verification run in daemon state. async fn verify_run( State(state): State>, @@ -13272,27 +14884,31 @@ async fn verify_run( // enrichment writes back for the whole run. drop(settle); let state_for_verify = Arc::clone(&state); - let repository_authority = state - .local_repository_authority_binding() - .map_err(repository_authority_error)?; + let repository_authority = verification_authority(&state)?; let response = tokio::task::spawn_blocking(move || { // A cancelled request detaches this worker; the worker must own its - // exclusion fence until proof relations and their snapshot are installed. + // exclusion fence until the run and its proof relations are committed. // Release authorization reads coverage under this same gate. let _coordination = state_for_verify.coordination_gate.blocking_lock(); with_graph_authority_mutation(state_for_verify.as_ref(), || { - let response = kin_cli::commands::verify::execute_verify_run( - &state_for_verify.layout, + let graph = state_for_verify.graph.as_ref(); + let prepared = kin_cli::commands::verify::prepare_verify_run(graph, &req) + .map_err(|error| error.to_string())?; + let executed = kin_cli::commands::verify::execute_verification( + state_for_verify.layout.working_dir(), + prepared.command_line(), + ); + let (record, response) = kin_cli::commands::verify::record_verify_run( &repository_authority, - state_for_verify.graph.as_ref(), - &req, + graph, + &prepared, + &executed, ) .map_err(|error| error.to_string())?; - state_for_verify.bump_version(); - state_for_verify - .save_snapshot() - .map_err(|error| error.to_string())?; - state_for_verify.mark_clean(); + // The run is repository authority: committed there first, then + // shown to the live graph, so no admission meets a relation from + // a run authority does not hold. + crate::verification_write::commit_verification_record(&state_for_verify, &record)?; Ok::<_, String>(response) }) }) @@ -13302,6 +14918,88 @@ async fn verify_run( Ok(Json(response)) } +/// POST /verify/work — the tests that reach a piece of work: how this +/// repository runs them, their plan, or a run of them recorded in daemon +/// state. +/// +/// A run plans from the graph, runs the repository's own test command in its +/// working tree with no gate held, and commits the run under the coordination +/// gate. `/verify/run` holds the gate for the whole run; this route does not, +/// because the page that asks keeps reading the working copy while a suite +/// runs, and every such read admits it first. The run is tied to the +/// fingerprints the plan read, so an edit made while it ran leaves its result +/// stale rather than current. +async fn verify_work( + State(state): State>, + Json(req): Json, +) -> Result { + use kin_cli::commands::verify::PreparedWork; + if !state + .is_initialized + .load(std::sync::atomic::Ordering::Relaxed) + { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "daemon not fully initialized".to_string(), + )); + } + let repository_authority = verification_authority(&state)?; + let state_for_verify = Arc::clone(&state); + let response = tokio::task::spawn_blocking(move || { + let state = state_for_verify; + let authority_context = + crate::local_repository_authority::LocalRepositoryAuthorityContext::from_state(&state) + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("open startup-pinned repository authority: {error}"), + ) + })?; + let manifests = kin_index::GraphProjectFiles::with_reader( + state.graph.as_ref(), + crate::source_cas::source_reader_opening(state.blobs.as_ref(), &authority_context), + ); + let prepared = { + // A plan's entities, relations and fingerprints must all describe + // one graph version, rather than span a concurrent admission. + let _coordination = state.coordination_gate.blocking_lock(); + kin_cli::commands::verify::prepare_work_verify( + &repository_authority, + state.graph.as_ref(), + &manifests, + &req, + ) + .map_err(|error| (StatusCode::BAD_REQUEST, format!("{error:#}")))? + }; + let ready = match prepared { + PreparedWork::Answered(response) => return Ok::<_, (StatusCode, String)>(response), + PreparedWork::Ready(ready) => ready, + }; + let executed = kin_cli::commands::verify::execute_verification( + state.layout.working_dir(), + ready.command_line(), + ); + // A cancelled request detaches this worker; it keeps the fence until + // the run and its proof relations are committed. + let _coordination = state.coordination_gate.blocking_lock(); + with_graph_authority_mutation(state.as_ref(), || { + let (record, response) = kin_cli::commands::verify::record_work_verify( + &repository_authority, + state.graph.as_ref(), + &ready, + &executed, + ) + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, format!("{error:#}")))?; + crate::verification_write::commit_verification_record(&state, &record) + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?; + Ok(response) + }) + }) + .await + .map_err(internal_error)??; + Ok(Json(response)) +} + /// POST /commands/verify — render verify read commands from daemon-owned graph state. async fn command_verify( State(state): State>, @@ -14745,7 +16443,9 @@ fn build_semantic_locate_result( .get("granularity") .is_some_and(|value| value.as_str() != Some("entity")) { - return kin_mcp::ToolCallResult::error("semantic_locate accepts only entity granularity; select semantic entities, not whole files"); + return kin_mcp::ToolCallResult::error( + "semantic_locate accepts only entity granularity; select semantic entities, not whole files", + ); } let file_granularity = false; let include_tests = arguments @@ -14802,7 +16502,9 @@ fn build_semantic_locate_result( )) = cached { if cached_file_granularity { - return kin_mcp::ToolCallResult::error("semantic_locate cannot continue a file-granularity cursor; start an entity query"); + return kin_mcp::ToolCallResult::error( + "semantic_locate cannot continue a file-granularity cursor; start an entity query", + ); } if let Err(error) = validate_cursor_projection( arguments, @@ -15927,7 +17629,9 @@ async fn build_fused_semantic_locate_result( .get("granularity") .is_some_and(|value| value.as_str() != Some("entity")) { - return Ok(kin_mcp::ToolCallResult::error("semantic_locate accepts only entity granularity; select semantic entities, not whole files")); + return Ok(kin_mcp::ToolCallResult::error( + "semantic_locate accepts only entity granularity; select semantic entities, not whole files", + )); } let file_granularity = false; // Rank test-role entities alongside source when the caller says so, or @@ -16059,7 +17763,9 @@ async fn build_fused_semantic_locate_result( )) = cached { if cached_file_granularity { - return Ok(kin_mcp::ToolCallResult::error("semantic_locate cannot continue a file-granularity cursor; start an entity query")); + return Ok(kin_mcp::ToolCallResult::error( + "semantic_locate cannot continue a file-granularity cursor; start an entity query", + )); } if let Err(error) = validate_cursor_projection( arguments, @@ -16893,13 +18599,13 @@ async fn mcp_entity_sources_attempt( kin_mcp::handlers::entities::ResolvedEntitySource::Inventory { value } } else { kin_mcp::handlers::entities::ResolvedEntitySource::Derived { value } - } + }; } Err(error) => { return kin_mcp::handlers::entities::ResolvedEntitySource::NoSource { id: entity_id.clone(), message: error.to_string(), - } + }; } Ok(None) => {} } @@ -18175,7 +19881,8 @@ async fn hosted_view_prewarm_pass( if let HostedViewPrewarmOutcome::Quarantined(reason) = outcome { if warmed.quarantined.insert(repo_id.clone(), reason).is_none() { warn!( - repo_id, reason, + repo_id, + reason, "hosted view automatic pre-warm disabled for this repository until daemon restart: timed-out storage work may still be running; on-demand reads remain available" ); } @@ -19325,7 +21032,7 @@ async fn repo_mcp_tools_call_answer( message, false, ), - ) + ); } }; if let Err(failure) = @@ -20090,41 +21797,106 @@ async fn mcp_tools_call( // and how much it answered, never the request or the answer. The only part // of the request it may keep is an argument that is a whole entity id, so // those are read here, before the call consumes its arguments. - let activity = extract_session_id_from_headers(&headers) + // The same ids say which of a reviewer's open flags the call reaches, + // whether or not it names a session. + let session_id = extract_session_id_from_headers(&headers).ok().flatten(); + let targets = request + .as_ref() + .map(|Json(request)| crate::agent_activity::argument_uuids(&request.arguments)) + .unwrap_or_default(); + let flags_cursor = request + .as_ref() .ok() - .flatten() - .map(|session_id| { - let targets = request - .as_ref() - .map(|Json(request)| crate::agent_activity::argument_uuids(&request.arguments)) - .unwrap_or_default(); - (session_id, Arc::clone(&state.0), targets) - }); + .and_then(|Json(request)| request.arguments.get("flags_cursor")) + .cloned(); + let held_state = Arc::clone(&state.0); + let began_live = session_id.is_some_and(|session_id| { + held_state + .coordinator + .get_session(&session_id) + .ok() + .flatten() + .is_some() + }); let started = Instant::now(); - let answered = mcp_tools_call_internal(headers, tokens, state, request).await; - let mut session_held = true; - if let Some((session_id, state, targets)) = activity { - let refused; - let result = match &answered { - Ok(Json(result)) => result, - Err((_, message)) => { - refused = kin_mcp::ToolCallResult::error(message.clone()); - &refused - } + // A write lands whether or not its caller is still waiting when it ends: + // an agent's MCP client gives up on a commit after its own patience, and + // the commit runs on to publish its change. Answered on this request's + // task, its record went with the request: the client closed its + // connection, the request was dropped, and the session's calls never + // showed the commit its change came from. So a write runs and is recorded + // on its own task, and this request only waits for it; the guard below + // says, once the request is gone, that nobody heard the answer. + let waiting = CallerWaiting::default(); + let caller_left = waiting.left(); + let tool_for_call = tool.clone(); + let call = async move { + let tool = tool_for_call; + let answered = mcp_tools_call_internal(headers, tokens, state, request).await; + #[cfg(test)] + pause_call_record_for_test(&held_state).await; + let (answered, reached) = match answered { + Ok(Json(result)) => match crate::review_flags::reached_by_call( + &held_state, + session_id, + &tool, + &targets, + &result, + flags_cursor.as_ref(), + ) { + Ok(reached) => (Ok(Json(result)), reached), + Err(message) => ( + Ok(Json(kin_mcp::ToolCallResult::error(message))), + kin_mcp::review_flags::Delivered::default(), + ), + }, + Err(refusal) => (Err(refusal), kin_mcp::review_flags::Delivered::default()), }; - session_held = crate::agent_activity::record_tool_call( - &state, - session_id, - &tool, - &targets, - result, - started.elapsed(), - ) - .await; - } + let mut session_held = true; + if let Some(session_id) = session_id { + let refused; + let result = match &answered { + Ok(Json(result)) => result, + Err((_, message)) => { + refused = kin_mcp::ToolCallResult::error(message.clone()); + &refused + } + }; + session_held = crate::agent_activity::record_tool_call( + &held_state, + session_id, + &tool, + &targets, + result, + started.elapsed(), + crate::agent_activity::CallMeeting { + began_live, + caller_left: caller_left(), + }, + ) + .await; + } + (answered, reached, session_held) + }; + let (answered, reached, session_held) = + if crate::agent_activity::is_write_tool(&tool) || mcp_tool_mutates_graph(&tool) { + tokio::spawn(call).await.map_err(internal_error)? + } else { + call.await + }; + waiting.answered(); let Json(result) = answered .map_err(|(status, message)| (status, kin_mcp::semantic_surface::diagnostic(&message)))?; - let mut response = Json(present_mcp_http_tool_result(result, &tool)).into_response(); + // Read from the answer as the daemon wrote it, before presentation, and + // carried beside it: the answer's own payload is served exactly as it was. + let presented = present_mcp_http_tool_result(result, &tool); + let mut response = if reached.is_silent() { + Json(presented).into_response() + } else { + let mut carried = serde_json::to_value(&presented).map_err(internal_error)?; + kin_mcp::review_flags::attach(&mut carried, &reached); + Json(carried).into_response() + }; if !session_held { // The session this call named is not one the daemon holds, so the // call was not recorded against it. A connection that opened that @@ -20137,6 +21909,65 @@ async fn mcp_tools_call( Ok(response) } +/// A pause a tool call takes between its answer and its record, for the one +/// daemon state a test names, so a test can end the call's session and drop +/// its request while a write is still finishing. Keyed by the state's address, +/// so no other test's call waits on it. +#[cfg(test)] +static CALL_RECORD_PAUSE_FOR_TEST: std::sync::Mutex> = + std::sync::Mutex::new(None); + +#[cfg(test)] +async fn pause_call_record_for_test(state: &DaemonState) { + let owner = state as *const DaemonState as usize; + let named = *CALL_RECORD_PAUSE_FOR_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((named_state, pause)) = named { + if named_state == owner { + tokio::time::sleep(pause).await; + } + } +} + +/// Held by a request while the call it made runs elsewhere. Dropped with the +/// request, it records that the caller stopped waiting; see +/// [`mcp_tools_call`]. +struct CallerWaiting { + left: Arc, + answered: bool, +} + +impl Default for CallerWaiting { + fn default() -> Self { + Self { + left: Arc::new(std::sync::atomic::AtomicBool::new(false)), + answered: false, + } + } +} + +impl CallerWaiting { + /// Whether the caller has stopped waiting, read when the call ends. + fn left(&self) -> impl Fn() -> bool + Send + Sync + 'static { + let left = Arc::clone(&self.left); + move || left.load(std::sync::atomic::Ordering::SeqCst) + } + + /// The call's answer reached the request: whoever reads it was waiting. + fn answered(mut self) { + self.answered = true; + } +} + +impl Drop for CallerWaiting { + fn drop(&mut self) { + if !self.answered { + self.left.store(true, std::sync::atomic::Ordering::SeqCst); + } + } +} + async fn mcp_tools_call_internal( headers: axum::http::HeaderMap, Extension(tokens): Extension, @@ -20540,7 +22371,7 @@ async fn reference_tools_page_with_hooks( Some(_) => { return Ok(kin_mcp::ToolCallResult::error( "reference cursor must be a string", - )) + )); } }; let session_id = extract_session_id_from_headers(&headers)?; @@ -20610,7 +22441,9 @@ async fn reference_tools_page_with_hooks( .await || !trace_read_epoch_current(&state, authority, epoch) { - return Ok(kin_mcp::ToolCallResult::error("reference graph, authority or standing changed while resuming; restart without cursor")); + return Ok(kin_mcp::ToolCallResult::error( + "reference graph, authority or standing changed while resuming; restart without cursor", + )); } return Ok(page .map(|page| kin_mcp::ToolCallResult::text(page.to_string())) @@ -20728,7 +22561,7 @@ async fn reference_tools_page_with_hooks( None => { return Ok(kin_mcp::ToolCallResult::error( "reference authority changed while reading; restart without cursor", - )) + )); } } }; @@ -21009,7 +22842,7 @@ async fn frozen_tools_page_once( Some(_) => { return Ok(FrozenRead::Served(kin_mcp::ToolCallResult::error(format!( "{label} cursor must be a string" - )))) + )))); } }; let result = if let Some(cursor) = cursor { @@ -21922,7 +23755,7 @@ async fn mcp_tools_call_selected( let repository_authority = match require_mcp_command_repository_authority(&state) { Ok(authority) => authority, Err(error) => { - return Ok(Json(kin_mcp::ToolCallResult::error(error.to_string()))) + return Ok(Json(kin_mcp::ToolCallResult::error(error.to_string()))); } }; // Same off-runtime seam as the CLI route. This is the arm the @@ -21937,7 +23770,7 @@ async fn mcp_tools_call_selected( None => { return Ok(Json(kin_mcp::ToolCallResult::error( "selected trace source scope expired or was replaced", - ))) + ))); } }, ) @@ -24483,6 +26316,7 @@ async fn repo_entities( let order = RepoEntityOrder::parse(params.order.as_deref())?; let fields = RepoEntityFields::parse(params.include.as_deref())?; let window = repo_entity_window(params.limit, params.offset)?; + let untested = repo_untested_rows(params.untested)?; // On a local daemon this repository's graph is the live one an edit // admission writes, so a name query that matched nothing is served only // from a read no writer spanned, for the reason `NameReadWait` gives. A @@ -24500,6 +26334,7 @@ async fn repo_entities( order, fields, window, + untested, params.query.clone(), repo_id.clone(), ) @@ -24522,6 +26357,7 @@ async fn repo_entities_attempt( order: RepoEntityOrder, fields: RepoEntityFields, window: Option<(usize, usize)>, + untested: Option, query: Option, repo: String, ) -> std::result::Result<(RepoEntitiesResponse, bool), (StatusCode, String)> { @@ -24536,57 +26372,98 @@ async fn repo_entities_attempt( }; let mut matched = graph.query_entities(&filter)?; let matched_nothing = matched.is_empty(); - let ranks = if order == RepoEntityOrder::Importance || fields.ranking { - Some(repo_entity_ranks(graph.as_ref())?) - } else { - None + // Coverage is read in the pass that ranks, over the relations it has + // already read, so asking for it adds no second walk of the graph. + let reading = + if order == RepoEntityOrder::Importance || fields.ranking || untested.is_some() { + Some(repo_entity_ranks(graph.as_ref(), untested.is_some())?) + } else { + None + }; + let ranks = reading.as_ref().map(|reading| &reading.ranks); + let by_importance = |ranks: &HashMap, + left: &kin_model::Entity, + right: &kin_model::Entity| { + let (left_rank, right_rank) = (entity_rank(ranks, left), entity_rank(ranks, right)); + right_rank + .dependents + .cmp(&left_rank.dependents) + .then_with(|| right_rank.degree.cmp(&left_rank.degree)) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) + }; + if let (RepoEntityOrder::Importance, Some(ranks)) = (order, ranks) { + matched.sort_by(|left, right| by_importance(ranks, left, right)); + } + let entry = |entity: &kin_model::Entity, ranked: bool| { + let rank = ranks + .filter(|_| ranked) + .map(|ranks| entity_rank(ranks, entity)); + RepoEntityEntry { + id: entity.id.to_string(), + name: entity.name.clone(), + kind: format!("{:?}", entity.kind), + file_path: entity.file_origin.as_ref().map(|f| f.0.clone()), + degree: rank.map(|rank| rank.degree), + dependents: rank.map(|rank| rank.dependents), + signature: fields + .signature + .then(|| one_line(&entity.signature)) + .flatten(), + summary: fields + .summary + .then(|| entity.doc_summary.as_deref().and_then(one_line)) + .flatten(), + } + }; + let untested = match (untested, reading.as_ref()) { + (Some(rows), Some(reading)) => { + let unreached = reading.unreached.as_ref(); + let mut relied_on: Vec<&kin_model::Entity> = matched + .iter() + .filter(|entity| { + entity.file_origin.is_some() + && !kin_cli::commands::verify::is_test_entity(entity) + && !kin_model::is_module_container(entity) + && entity_rank(&reading.ranks, entity).dependents > 0 + && unreached.is_some_and(|unreached| unreached.contains(&entity.id)) + }) + .collect(); + relied_on.sort_by(|left, right| by_importance(&reading.ranks, left, right)); + Some(RepoUntested { + total: relied_on.len(), + entities: relied_on + .into_iter() + .take(rows) + .map(|entity| entry(entity, true)) + .collect(), + }) + } + _ => None, }; - if let (RepoEntityOrder::Importance, Some(ranks)) = (order, ranks.as_ref()) { - matched.sort_by(|left, right| { - let (left_rank, right_rank) = (entity_rank(ranks, left), entity_rank(ranks, right)); - right_rank - .dependents - .cmp(&left_rank.dependents) - .then_with(|| right_rank.degree.cmp(&left_rank.degree)) - .then_with(|| left.name.cmp(&right.name)) - .then_with(|| left.id.cmp(&right.id)) - }); - } // The population this window was cut from, so a reader of one page can // tell a last page from a full one. Absent when no window was asked for, // where the rows are the population. let total = window.map(|_| matched.len()); - let rows: Vec = match window { - Some((offset, limit)) => matched.into_iter().skip(offset).take(limit).collect(), - None => matched, + let ranked_rows = order == RepoEntityOrder::Importance || fields.ranking; + let entries = match window { + Some((offset, limit)) => matched + .iter() + .skip(offset) + .take(limit) + .map(|entity| entry(entity, ranked_rows)) + .collect(), + None => matched + .iter() + .map(|entity| entry(entity, ranked_rows)) + .collect(), }; - let entries = rows - .into_iter() - .map(|entity| { - let rank = ranks.as_ref().map(|ranks| entity_rank(ranks, &entity)); - RepoEntityEntry { - id: entity.id.to_string(), - name: entity.name.clone(), - kind: format!("{:?}", entity.kind), - file_path: entity.file_origin.as_ref().map(|f| f.0.clone()), - degree: rank.map(|rank| rank.degree), - dependents: rank.map(|rank| rank.dependents), - signature: fields - .signature - .then(|| one_line(&entity.signature)) - .flatten(), - summary: fields - .summary - .then(|| entity.doc_summary.as_deref().and_then(one_line)) - .flatten(), - } - }) - .collect(); Ok::<_, anyhow::Error>(( RepoEntitiesResponse { repo_id: repo, entities: entries, total, + untested, }, matched_nothing, )) @@ -24648,7 +26525,7 @@ impl RepoEntityFields { format!( "include accepts 'ranking', 'signature' and 'summary'; got '{other}'" ), - )) + )); } } } @@ -24675,6 +26552,22 @@ fn repo_entity_window( } } +/// How many untested rows a request asked for. The list is a short one for a +/// reader, so a request for none or for more than a page is refused rather +/// than clamped. +fn repo_untested_rows( + asked: Option, +) -> std::result::Result, (StatusCode, String)> { + match asked { + None => Ok(None), + Some(rows) if (1..=100).contains(&rows) => Ok(Some(rows)), + Some(rows) => Err(( + StatusCode::BAD_REQUEST, + format!("untested must be 1..100 when given; got {rows}"), + )), + } +} + /// One entity's place in its graph. #[derive(Debug, Clone, Copy, Default)] struct RepoEntityRank { @@ -24682,6 +26575,15 @@ struct RepoEntityRank { dependents: usize, } +/// One pass over a graph's entities and their relations: each entity's rank, +/// and, when asked, the entities no test reaches. +struct RepoEntityReading { + ranks: HashMap, + /// The entities no test reaches, by the rule `kin verify` reads coverage + /// with. `None` when the pass was not asked to read coverage. + unreached: Option>, +} + fn entity_rank( ranks: &HashMap, entity: &kin_model::Entity, @@ -24698,20 +26600,56 @@ fn entity_rank( /// `dependents` is the other half, and the one importance orders by, because /// degree alone ranks a long test function that calls fifty things above the /// function fifty things call. It counts distinct entities that reach this one -/// over a call, an import or a reference, excluding the entity itself and -/// excluding receiver-name guesses, which is exactly the set the reference -/// surface certifies: those guesses bind `x.clone()` to every method named -/// `clone` in the repository, and counting them would rank the guess. +/// over a call, an import or a reference, excluding the entity itself, under +/// the floor the reference surface counts `find_references` rows by: a +/// receiver-name guess never, since it binds `x.clone()` to every method named +/// `clone` in the repository; a match by name alone only where nothing proves +/// more, that is when no edge into the entity is proven and its language has +/// no proven reference across files anywhere in the store. +/// +/// It used to count every match by name alone that was not a receiver guess. +/// The entity page lists what relies on an entity from its reference read, +/// which holds those matches as candidates, so on fastify's `serialize` the +/// page said four relied on it, listed three that do and one candidate, and +/// said "1 more that Kin counted and did not list". +/// +/// With `read_coverage`, the same walk also says which entities no test +/// reaches through the dependency graph. Each entity's projected relations +/// are already in hand here, so collecting adjacency costs no second graph +/// read. One multi-source traversal reads transitive reach by the same rule +/// as `kin verify --missing`. fn repo_entity_ranks( graph: &kin_db::InMemoryGraph, -) -> anyhow::Result> { + read_coverage: bool, +) -> anyhow::Result { + use kin_cli::commands::verify::{uncovered_by_test_cases, RecordedTestReach}; let entities = graph.list_all_entities()?; let known: HashSet = entities.iter().map(|entity| entity.id).collect(); + let mut test_reach = if read_coverage { + Some(RecordedTestReach::new( + &entities, + uncovered_by_test_cases(graph)?, + )) + } else { + None + }; let reference_kinds = kin_mcp::handlers::common::default_reference_kinds(); let mut edges: HashSet<(EntityId, EntityId, kin_model::RelationKind)> = HashSet::new(); let mut dependents: HashMap> = HashMap::new(); + // Matches by name alone, counted only where the floor drops to them. + let mut named_only: HashMap> = HashMap::new(); + let places: HashMap, kin_model::LanguageId)> = + entities + .iter() + .map(|entity| (entity.id, (entity.file_origin.as_ref(), entity.language))) + .collect(); + let mut languages_proven: HashSet = HashSet::new(); for entity in &entities { - for relation in kin_index::relation_read::relations_for_read(graph, &entity.id)? { + let relations = kin_index::relation_read::relations_for_read(graph, &entity.id)?; + if let Some(test_reach) = test_reach.as_mut() { + test_reach.observe_inbound(&relations, &entity.id); + } + for relation in relations { let (Some(source), Some(destination)) = (relation.src.as_entity(), relation.dst.as_entity()) else { @@ -24733,10 +26671,33 @@ fn repo_entity_ranks( && reference_kinds.contains(&relation.kind) && !kin_index::resolution::is_receiver_name_guess(&relation) { - dependents.entry(destination).or_default().insert(source); + if kin_index::resolution::RelationResolution::of(&relation).is_proven() { + dependents.entry(destination).or_default().insert(source); + let (from, to) = (places.get(&source), places.get(&destination)); + if let ( + Some((Some(from_file), from_language)), + Some((Some(to_file), to_language)), + ) = (from, to) + { + if from_file != to_file { + languages_proven.insert(*from_language); + languages_proven.insert(*to_language); + } + } + } else { + named_only.entry(destination).or_default().insert(source); + } } } } + for (destination, sources) in named_only { + let language_proves_more = places + .get(&destination) + .is_some_and(|(_, language)| languages_proven.contains(language)); + if !dependents.contains_key(&destination) && !language_proves_more { + dependents.insert(destination, sources); + } + } let mut ranks: HashMap = HashMap::new(); for (source, destination, _kind) in &edges { ranks.entry(*source).or_default().degree += 1; @@ -24745,7 +26706,10 @@ fn repo_entity_ranks( for (entity_id, reaching) in dependents { ranks.entry(entity_id).or_default().dependents = reaching.len(); } - Ok(ranks) + Ok(RepoEntityReading { + ranks, + unreached: test_reach.map(RecordedTestReach::uncovered), + }) } /// One line of text, whitespace collapsed, or `None` when there is none. @@ -25654,6 +27618,7 @@ async fn vfs_readdir( /// to keep the connection alive through proxies/load balancers. async fn vfs_subscribe(State(state): State>) -> impl IntoResponse { let mut rx = state.event_tx.subscribe(); + let mut replaced = state.replacement_watch(); let stream = async_stream::stream! { // Send initial connected event @@ -25695,6 +27660,10 @@ async fn vfs_subscribe(State(state): State>) -> impl IntoRespon _ = heartbeat.tick() => { yield Ok(": heartbeat\n\n".to_string()); } + successor = replaced_successor(&mut replaced) => { + yield Ok(replaced_frame(&successor)); + break; + } } } }; @@ -26986,7 +28955,7 @@ impl ExactSourceArchiveExportQueue { Ok(_) => { return Some(ExactSourceExportQueueSeat { waiting: &self.waiting, - }) + }); } Err(current) => observed = current, } @@ -27902,6 +29871,8 @@ pub async fn serve_warming_with_progress_until( ) { let app = Router::new() .route("/health", get(warming_health)) + .route("/runtime/identity", get(runtime_identity)) + .route("/v2/runtime/identity", get(runtime_identity)) .route("/v2/health", get(warming_health)) .route("/ready", get(warming_readiness)) .route("/readiness", get(warming_readiness)) @@ -29826,18 +31797,12 @@ pub(crate) mod tests { /// verifiable, which is what C4 asks for and what an `is_err()` cannot give. const CURSOR_REFUSAL_QUERY: &str = "semantic_locate cursor belongs to a different query; re-run the requested query"; - const CURSOR_REFUSAL_GRANULARITY: &str = - "semantic_locate cursor preserves its original granularity; re-run the original query to change it"; - const CURSOR_REFUSAL_SNIPPET: &str = - "semantic_locate cursor preserves its original snippet projection; re-run the original query to change it"; - const CURSOR_REFUSAL_ALIAS: &str = - "semantic_locate cursor preserves its original snippet alias setting; re-run the original query to change it"; - const CURSOR_REFUSAL_VARIANTS: &str = - "semantic_locate cursor preserves its original query variants; re-run the original query to change them"; - const CURSOR_REFUSAL_TEST_SCOPE: &str = - "semantic_locate cursor preserves its original test-role scope; re-run the original query to change it"; - const CURSOR_REFUSAL_EXPLAIN: &str = - "semantic_locate cursor cannot expand a ranking whose explanation was not built; re-run the original query with explain: true"; + const CURSOR_REFUSAL_GRANULARITY: &str = "semantic_locate cursor preserves its original granularity; re-run the original query to change it"; + const CURSOR_REFUSAL_SNIPPET: &str = "semantic_locate cursor preserves its original snippet projection; re-run the original query to change it"; + const CURSOR_REFUSAL_ALIAS: &str = "semantic_locate cursor preserves its original snippet alias setting; re-run the original query to change it"; + const CURSOR_REFUSAL_VARIANTS: &str = "semantic_locate cursor preserves its original query variants; re-run the original query to change them"; + const CURSOR_REFUSAL_TEST_SCOPE: &str = "semantic_locate cursor preserves its original test-role scope; re-run the original query to change it"; + const CURSOR_REFUSAL_EXPLAIN: &str = "semantic_locate cursor cannot expand a ranking whose explanation was not built; re-run the original query with explain: true"; /// Every refusal message a cursor validator can produce, so a test can /// require that the message it expected is the one that answered AND that no @@ -32193,6 +34158,34 @@ pub(crate) mod tests { Arc::new(DaemonState::open(layout).unwrap()) } + struct LocalMcpRepositoryBinding { + previous_directory: PathBuf, + } + + impl LocalMcpRepositoryBinding { + fn new(layout: &kin_core::KinLayout) -> Self { + kin_cli::daemon_client::install_spawn_registrar(); + let previous_directory = std::env::current_dir().unwrap(); + std::env::set_current_dir(layout.working_dir()).unwrap(); + let binding = Self { previous_directory }; + let selected = kin_core::KinLayout::discover(layout.working_dir()) + .expect("the MCP fixture must bind its actual repository"); + assert_eq!( + std::fs::canonicalize(selected.root()).unwrap(), + std::fs::canonicalize(layout.root()).unwrap(), + "the MCP delegate and the served fixture must select the same store" + ); + binding + } + } + + impl Drop for LocalMcpRepositoryBinding { + fn drop(&mut self) { + std::env::set_current_dir(&self.previous_directory) + .expect("restore the MCP fixture working directory"); + } + } + /// The state a hand-built runtime literal cannot reach: a real store with /// no vector-index sidecar on disk and a coverage-completion marker from an /// earlier life. @@ -43935,6 +45928,52 @@ pub(crate) mod tests { .collect() } + /// A drift observation survives a publication that lands right after it + /// bound the held authority. + /// + /// The daemon's own workspace admission publishes through an authority it + /// opens for itself, not the held one, so a drift request bound just + /// before such a publication holds a manager that no longer names the + /// persisted head. Every retry against that one bind refused with "persisted + /// authority moved", and `kin doctor --heal` failed over an ordinary + /// watcher admission. Each attempt now binds again, so the second attempt + /// observes the publication the first one missed and reports it. + #[tokio::test] + async fn drift_rebinds_after_another_writer_publishes_under_its_first_bind() { + let state = committed_test_state(); + let before = crate::repository_drift::execute(&state) + .expect("the fixture's projection is observable") + .report + .expect("drift reports a body"); + assert!(before.clean, "the fixture must start clean: {before:?}"); + + let writer = Arc::clone(&state); + let published = std::rc::Rc::new(std::cell::Cell::new(None)); + let recorded = std::rc::Rc::clone(&published); + crate::repository_drift::after_bind_hook::arm_once(move || { + recorded.set(Some(publish_ref_as_another_writer( + &writer, + b"published-under-a-drift-bind", + ))); + }); + let after = crate::repository_drift::execute(&state) + .expect("a publication under the first bind must be re-observed, not refused") + .report + .expect("drift reports a body"); + let external_generation = published + .get() + .expect("the hook must have published inside the first attempt"); + assert_eq!(external_generation, before.authority_generation + 1); + assert_eq!( + after.authority_generation, external_generation, + "the report must describe the generation the other writer published" + ); + assert!( + after.clean, + "a ref publication moves no tracked bytes: {after:?}" + ); + } + /// Branch commands commit through the authority the daemon holds, and the /// daemon keeps holding what each one wrote. /// @@ -55567,12 +57606,217 @@ pub(crate) mod tests { repo_id: repo_id.to_string(), entities: Vec::new(), total: None, + untested: None, }, ); } (status, serde_json::from_slice(&body).unwrap()) } + /// What others rely on that no test reaches is read in the ranking pass, + /// by the rule a verification plan reads tests with: `hub` has three + /// dependents and no test, so it leads the list with that count, ahead of + /// the five entities one other calls. Once a test entity calls it, or a + /// recorded test case covers it, an entity is not listed. A test, a + /// module and a name with no definition here are never listed, however + /// many depend on them, and an entity nothing depends on is not relied on + /// at all. A request that does not ask carries no such field. + #[tokio::test] + async fn a_repo_entity_list_names_what_others_rely_on_that_no_test_reaches() { + let (state, repo_id) = ranked_entities_state().await; + let user = test_entity("user", "src/user.py"); + state.graph.upsert_entity(&user).unwrap(); + let mut a_test = test_entity("check_helper", "tests/test_helper.py"); + a_test.role = EntityRole::Test; + let mut external = test_entity("requests", "src/none.py"); + external.file_origin = None; + let mut module = test_entity("helpers", "src/helpers.py"); + module.kind = EntityKind::Module; + let tested = test_entity("tested", "src/tested.py"); + for entity in [&a_test, &external, &module, &tested] { + state.graph.upsert_entity(entity).unwrap(); + link(&state, &user, entity, kin_model::RelationKind::Calls); + } + let mut reaching = test_entity("check_tested", "tests/test_tested.py"); + reaching.role = EntityRole::Test; + state.graph.upsert_entity(&reaching).unwrap(); + link(&state, &reaching, &tested, kin_model::RelationKind::Calls); + + let (status, payload) = ranked_entities( + Arc::clone(&state), + &repo_id, + "order=importance&limit=5&untested=12", + ) + .await; + assert_eq!(status, StatusCode::OK); + let untested = payload.untested.expect("the list was asked for"); + assert_eq!( + untested + .entities + .iter() + .map(|entity| (entity.name.as_str(), entity.dependents)) + .collect::>(), + [ + ("hub", Some(3)), + ("callee_0", Some(1)), + ("callee_1", Some(1)), + ("callee_2", Some(1)), + ("callee_3", Some(1)), + ("callee_4", Some(1)), + ], + "relied on, defined here, no test, no module, and unreached, most relied-on first" + ); + assert_eq!(untested.total, 6); + + // A test that calls hub reaches it, and a recorded test case covers + // what it names. + let entity_named = |name: &str| { + let row = payload + .entities + .iter() + .chain(untested.entities.iter()) + .find(|entity| entity.name == name) + .unwrap_or_else(|| panic!("{name} is in the answer")); + let id = EntityId(uuid::Uuid::parse_str(&row.id).unwrap()); + state.graph.get_entity(&id).unwrap().unwrap() + }; + link( + &state, + &reaching, + &entity_named("hub"), + kin_model::RelationKind::Calls, + ); + state + .graph + .create_test_case(&TestCase { + test_id: kin_model::TestId::new(), + name: "covers_callee_0".to_string(), + language: "python".to_string(), + kind: TestKind::Unit, + scopes: vec![WorkScope::Entity(entity_named("callee_0").id)], + runner: TestRunner::Pytest, + file_origin: None, + }) + .unwrap(); + let (_, payload) = + ranked_entities(Arc::clone(&state), &repo_id, "order=importance&untested=1").await; + let untested = payload.untested.expect("the list was asked for"); + assert_eq!( + untested + .entities + .iter() + .map(|entity| entity.name.as_str()) + .collect::>(), + ["callee_1"], + "one row was asked for" + ); + assert_eq!(untested.total, 4, "hub and callee_0 are reached now"); + + let (_, unasked) = + ranked_entities(Arc::clone(&state), &repo_id, "order=importance&limit=5").await; + assert!(unasked.untested.is_none()); + for refused in ["untested=0", "untested=101"] { + let (status, _) = ranked_entities(Arc::clone(&state), &repo_id, refused).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{refused}"); + } + } + + #[tokio::test] + async fn a_repo_entity_list_follows_test_dependencies_and_ignores_run_metadata() { + let (state, repo_id) = ranked_entities_state().await; + let mut reaching = test_entity("check_outer", "tests/test_outer.py"); + reaching.role = EntityRole::Test; + let outer = test_entity("outer", "src/outer.py"); + let middle = test_entity("middle", "src/middle.py"); + let leaf = test_entity("leaf", "src/leaf.py"); + for entity in [&reaching, &outer, &middle, &leaf] { + state.graph.upsert_entity(entity).unwrap(); + } + link(&state, &outer, &middle, RelationKind::Calls); + link(&state, &middle, &leaf, RelationKind::Calls); + + let hub = state + .graph + .list_all_entities() + .unwrap() + .into_iter() + .find(|entity| entity.name == "hub") + .unwrap(); + let now = Timestamp::now(); + let run = kin_model::VerificationRun { + run_id: kin_model::VerificationRunId::from_hash(Hash256::from_bytes([0x71; 32])), + test_ids: Vec::new(), + status: kin_model::VerificationStatus::Passing, + runner: TestRunner::Pytest, + started_at: now.clone(), + finished_at: Some(now), + duration_ms: Some(1), + evidence_blob: None, + exit_code: Some(0), + ran_against: vec![kin_model::RunFingerprint { + entity: hub.id, + behavior_hash: hub.fingerprint.behavior_hash, + }], + proof: Some(kin_model::VerificationProof { + entities: vec![hub.id], + work_items: Vec::new(), + }), + }; + state.graph.create_verification_run(&run).unwrap(); + state + .graph + .link_run_proves_entity(&run.run_id, &hub.id) + .unwrap(); + assert_eq!( + state.graph.list_runs_proving_entity(&hub.id).unwrap().len(), + 1 + ); + + let (_, before) = ranked_entities( + Arc::clone(&state), + &repo_id, + "order=importance&limit=1&untested=12", + ) + .await; + let before = before.untested.expect("the list was asked for"); + assert_eq!(before.total, 8); + for entity in [&middle, &leaf] { + assert!( + before + .entities + .iter() + .any(|row| row.id == entity.id.to_string()), + "{} has no recorded test reach before the test calls outer", + entity.name + ); + } + + link(&state, &reaching, &outer, RelationKind::Calls); + let (status, after) = + ranked_entities(state, &repo_id, "order=importance&limit=1&untested=12").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(after.entities.len(), 1, "the entity window stays bounded"); + let after = after.untested.expect("the list was asked for"); + assert_eq!(after.total, 6); + assert_eq!(after.entities[0].name, "hub"); + assert_eq!(after.entities[0].dependents, Some(3)); + for entity in [&outer, &middle, &leaf] { + assert!( + after + .entities + .iter() + .all(|row| row.id != entity.id.to_string()), + "{} is reached through the test's dependency graph", + entity.name + ); + } + assert_eq!( + after.entities[0].id, + hub.id.to_string(), + "a run's DerivedFrom proof metadata does not supply test reach" + ); + } + /// P1: a request that asks for nothing new gets the response this route has /// always served, field for field. Everything ranking added is opt-in, and a /// consumer reading today's four fields must keep reading them. @@ -55661,6 +57905,77 @@ pub(crate) mod tests { ); } + /// A match by name alone is counted as relying on an entity only where the + /// reference read would count it: where nothing proves more. `hub` has + /// three proven callers across files, so this store proves Python + /// references across files, and a fourth caller matched by name alone is a + /// candidate, as the entity page lists it. A Go entity in a store that + /// proves no Go reference across files counts its match by name alone, as + /// the reference read does there. + /// + /// Breaking it: count every match by name alone that is no receiver guess, + /// and `hub` reads four, beside a page that lists three that rely on it + /// and one candidate. + #[tokio::test] + async fn a_repo_entity_list_counts_a_match_by_name_alone_only_where_nothing_proves_more() { + let (state, repo_id) = ranked_entities_state().await; + let named_only = |source: &Entity, destination: &Entity| kin_model::Relation { + id: kin_model::RelationId::new(), + kind: kin_model::RelationKind::Calls, + src: GraphNodeId::Entity(source.id), + dst: GraphNodeId::Entity(destination.id), + confidence: 0.5, + origin: kin_model::RelationOrigin::Inferred, + created_in: None, + import_source: None, + evidence: Vec::new(), + }; + let hub = state + .graph + .list_all_entities() + .unwrap() + .into_iter() + .find(|entity| entity.name == "hub") + .unwrap(); + let by_name = test_entity("by_name", "src/by_name.py"); + state.graph.upsert_entity(&by_name).unwrap(); + state + .graph + .upsert_relation(&named_only(&by_name, &hub)) + .unwrap(); + let mut lone = test_entity("lone", "src/lone.go"); + lone.language = LanguageId::Go; + let mut lone_caller = test_entity("lone_caller", "src/lone_caller.go"); + lone_caller.language = LanguageId::Go; + state.graph.upsert_entity(&lone).unwrap(); + state.graph.upsert_entity(&lone_caller).unwrap(); + state + .graph + .upsert_relation(&named_only(&lone_caller, &lone)) + .unwrap(); + + let (status, payload) = ranked_entities(state, &repo_id, "include=ranking&limit=50").await; + assert_eq!(status, StatusCode::OK); + let dependents = |name: &str| { + payload + .entities + .iter() + .find(|entity| entity.name == name) + .unwrap_or_else(|| panic!("{name} is listed")) + .dependents + }; + assert_eq!( + dependents("hub"), + Some(3), + "the match by name alone is a candidate" + ); + assert_eq!( + dependents("lone"), + Some(1), + "where nothing proves more, the match by name alone counts" + ); + } + /// The list and the picture of one repository must not disagree about how /// connected a thing is, so the list's degree is the export's degree for /// every entity of a fixture, not just for one. @@ -57321,6 +59636,9 @@ pub(crate) mod tests { include!("api/tests/entity_draft_platform.rs"); include!("api/tests/artifact_entities.rs"); include!("api/tests/rust_crate_names.rs"); + include!("api/tests/entity_keep.rs"); + include!("api/tests/review_read_runtime.rs"); + include!("api/tests/write_outcomes.rs"); #[tokio::test] async fn mcp_transaction_stage_unknown_id_still_fails() { @@ -63981,6 +66299,496 @@ pub(crate) mod tests { assert_eq!(runs.len(), 1); } + #[tokio::test] + async fn verification_proof_links_survive_metadata_writes_admission_and_commit() { + async fn post( + state: &Arc, + endpoint: &str, + request: serde_json::Value, + ) -> serde_json::Value { + let response = router(Arc::clone(state)) + .oneshot( + Request::post(endpoint) + .header("content-type", "application/json") + .body(Body::from(request.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{endpoint}: {}", + String::from_utf8_lossy(&body) + ); + serde_json::from_slice(&body).unwrap() + } + + let state = test_state(); + install_admitted_query_sources(&state, &[("src/lib.py", "def handler():\n return 1\n")]) + .await; + let layout = state.layout.clone(); + let entity = state + .graph + .list_all_entities() + .unwrap() + .into_iter() + .find(|entity| entity.name == "handler" && entity.kind == EntityKind::Function) + .unwrap(); + assert!(state + .graph + .semantic_observation() + .verified_binding_history + .is_some()); + let authority = ActiveApiRepositoryAuthority::open_layout_for_test(&layout).unwrap(); + let before = authority + .manager + .read_authority() + .metadata() + .workspaces + .clone(); + drop(authority); + + for request in [ + ( + "/verify/run", + json!({"entity": "handler", "runner": "printf", "depth": 1}), + ), + ( + "/review/flags", + json!({ + "op": "set", "entity": entity.id, "note": "Keep the recorded proof", + "fingerprint": format!("behavior:{}", entity.fingerprint.behavior_hash), + "operation_id": kin_model::OperationId::new(), "author": "Proof Reviewer", + }), + ), + ( + "/review", + json!({ + "op": "create", "title": "Review after verification", "base": "main", "head": "HEAD", + }), + ), + ( + "/verify/run", + json!({"entity": "handler", "runner": "printf", "depth": 1}), + ), + ] { + post(&state, request.0, request.1).await; + let authority = ActiveApiRepositoryAuthority::open_layout_for_test(&layout).unwrap(); + let lease = authority.manager.read_authority(); + let workspace = lease + .metadata() + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == authority.workspace_id) + .unwrap(); + let parent = lease + .resolve_target_change_id(workspace.base_target.as_ref().unwrap()) + .unwrap(); + let pending = crate::commit_deltas::compute_deltas_vs_repository_authority( + &state.graph, + lease.snapshot(), + Some(&parent), + ) + .unwrap(); + assert!( + pending.entity_deltas.is_empty() + && pending.relation_deltas.is_empty() + && pending.tree_deltas.is_empty() + && pending.external_reference_deltas.is_empty() + && pending.resolution_record_deltas.is_empty(), + "{} must leave the source commit diff clean: {pending:?}", + request.0 + ); + assert_eq!( + lease.metadata().workspaces, + before, + "{} must not manufacture a semantic workspace transition from proof links", + request.0 + ); + let selected = lease + .workspace_graph_snapshot(&authority.workspace_id) + .unwrap() + .unwrap(); + let projected = + kin_db::InMemoryGraph::from_snapshot_without_text_index(selected).unwrap(); + assert_eq!( + projected + .list_runs_proving_entity(&entity.id) + .unwrap() + .len(), + state + .graph + .list_runs_proving_entity(&entity.id) + .unwrap() + .len(), + "{} must retain the durable proof links", + request.0 + ); + assert!(state + .graph + .semantic_observation() + .verified_binding_history + .is_some()); + } + assert_eq!( + state + .graph + .list_runs_proving_entity(&entity.id) + .unwrap() + .len(), + 2 + ); + + install_working_copy_file( + &state, + "src/lib.py", + b"def handler():\n return 2\n", + false, + ); + let (_, admitted) = admit_through_api(&router(Arc::clone(&state))).await; + assert_eq!(admitted["report"]["admitted"], json!(true)); + assert_eq!(admitted["mutated"], json!(true)); + assert_eq!( + state + .graph + .list_runs_proving_entity(&entity.id) + .unwrap() + .len(), + 2 + ); + let authority = ActiveApiRepositoryAuthority::open_layout_for_test(&layout).unwrap(); + let lease = authority.manager.read_authority(); + assert!(lease.metadata().workspaces.iter().all(|workspace| { + workspace + .semantic_overlay + .relation_deltas() + .iter() + .all(|delta| { + let relation = match delta { + kin_model::RelationDelta::Added { new } + | kin_model::RelationDelta::Modified { new, .. } => new, + kin_model::RelationDelta::Removed { old } => old, + }; + !matches!(relation.src, GraphNodeId::VerificationRun(_)) + }) + })); + drop(lease); + drop(authority); + + command_commit( + axum::http::HeaderMap::new(), + State(Arc::clone(&state)), + Json(CommandCommitRequest { + operation_id: kin_model::OperationId::new(), + timestamp: Timestamp::now(), + message: Some("Commit source after verification".into()), + amend: false, + expected_head: None, + author: AuthorId::new("proof-test"), + session_id: None, + }), + ) + .await + .unwrap(); + assert_eq!( + state + .graph + .list_runs_proving_entity(&entity.id) + .unwrap() + .len(), + 2 + ); + drop(state); + let reopened = DaemonState::open(layout).unwrap(); + assert_eq!( + reopened + .graph + .list_runs_proving_entity(&entity.id) + .unwrap() + .len(), + 2 + ); + assert_eq!(reopened.graph.open_review_flags().len(), 1); + } + + /// Deleting an entity a recorded test run proved, and a test still calls, + /// is admitted through the same complete admission the local app runs + /// before it plans tests. The admission used to be refused with + /// `transaction relation has unadmitted destination endpoint + /// entity:`: the proof link is a record's, so the semantic delta + /// that removed the entity could not carry it, and the run reported only + /// that Kin could not read the latest edits. Now the link leaves with the + /// entity, the run keeps its recorded proof, the planner reaches the + /// removal and refuses it in its own words, and the store reopens. + #[tokio::test] + async fn deleting_an_entity_a_recorded_run_proved_is_admitted_and_planned_as_removed() { + const PACKAGE: &str = + r#"{"name":"tally","type":"module","scripts":{"test":"node --test"}}"#; + const MATH: &str = "export function add(a, b) {\n return a + b;\n}\n\nexport function negate(value) {\n return 0 - value;\n}\n"; + const MATH_WITHOUT_NEGATE: &str = "export function add(a, b) {\n return a + b;\n}\n"; + const TESTS: &str = "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { add, negate } from '../src/math.js';\n\nexport function checkAdd() {\n assert.equal(add(2, 3), 5);\n}\n\nexport function checkSign() {\n assert.equal(negate(2), -2);\n}\n\ntest('add', checkAdd);\ntest('sign', checkSign);\n"; + + let state = test_state(); + install_admitted_query_sources( + &state, + &[ + ("package.json", PACKAGE), + ("src/math.js", MATH), + ("test/math.test.js", TESTS), + ], + ) + .await; + let layout = state.layout.clone(); + let named = |name: &str| { + state + .graph + .list_all_entities() + .unwrap() + .into_iter() + .find(|entity| entity.name == name && entity.kind == EntityKind::Function) + .unwrap_or_else(|| panic!("the fixture defines {name}")) + }; + let negate = named("negate"); + let add = named("add"); + + let response = router(Arc::clone(&state)) + .oneshot( + Request::post("/verify/run") + .header("content-type", "application/json") + .body(Body::from( + json!({"entity": "negate", "runner": "printf", "depth": 1}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 16 * 1024) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let proved = state.graph.list_runs_proving_entity(&negate.id).unwrap(); + assert_eq!(proved.len(), 1, "the recorded run proves negate"); + let run = proved[0].clone(); + assert!(run + .proof + .as_ref() + .is_some_and(|proof| proof.entities.contains(&negate.id))); + + install_working_copy_file(&state, "src/math.js", MATH_WITHOUT_NEGATE.as_bytes(), false); + let (_, admitted) = admit_through_api(&router(Arc::clone(&state))).await; + assert_eq!( + admitted["report"]["admitted"], + json!(true), + "deleting an entity a run proved must not block admission: {admitted}" + ); + assert!(state.graph.get_entity(&negate.id).unwrap().is_none()); + assert!(state.graph.get_entity(&add.id).unwrap().is_some()); + assert!( + state + .graph + .traverse(&GraphNodeId::Entity(negate.id), &[], 1) + .unwrap() + .relations + .is_empty(), + "no relation of any kind is left naming the removed entity" + ); + assert_eq!( + state.graph.get_verification_run(&run.run_id).unwrap(), + Some(run.clone()), + "the run keeps the proof it recorded" + ); + + // The planner now reads the removal, as the local app asks it to. + let response = router(Arc::clone(&state)) + .oneshot( + Request::post("/verify/work") + .header("content-type", "application/json") + .body(Body::from(json!({"mode": "plan"}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 16 * 1024) + .await + .unwrap(); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + String::from_utf8_lossy(&body), + "Kin cannot plan tests for removed entities yet." + ); + + drop(state); + let reopened = DaemonState::open(layout).unwrap(); + assert!(reopened.graph.get_entity(&negate.id).unwrap().is_none()); + assert!(reopened + .graph + .list_runs_proving_entity(&negate.id) + .unwrap() + .is_empty()); + assert_eq!( + reopened.graph.get_verification_run(&run.run_id).unwrap(), + Some(run) + ); + } + + /// A repository whose manifests declare no test command is told so in + /// one sentence, whether it asks what its command is or asks for a run, + /// and nothing is run or recorded. + #[tokio::test] + async fn verify_work_says_in_one_sentence_when_there_is_no_test_command() { + let state = test_state(); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let entity = test_entity("handler", "src/lib.py"); + state.graph.upsert_entity(&entity).unwrap(); + for request in [ + serde_json::json!({"mode": "test_command"}), + serde_json::json!({"mode": "run", "entities": [entity.id.to_string()]}), + ] { + let response = router(Arc::clone(&state)) + .oneshot( + Request::post("/verify/work") + .header("content-type", "application/json") + .body(Body::from(request.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 16 * 1024) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let answer: kin_cli::commands::verify::WorkVerifyResponse = + serde_json::from_slice(&body).unwrap(); + assert!(!answer.runnable); + assert_eq!( + answer.unavailable.as_deref(), + Some(kin_cli::commands::verify::NO_TEST_COMMAND) + ); + assert!(answer.run.is_none()); + } + assert!(state + .graph + .list_runs_proving_entity(&entity.id) + .unwrap() + .is_empty()); + } + + /// A repository whose test command names a runner that is not installed, + /// as a JavaScript repository with no `node_modules`, records a run that + /// could not run. It attests nothing, keeps the shell's reason, its exit + /// code and the end of its output, and the tests it planned read as not + /// run rather than failed, in this daemon and after the store reopens. + #[tokio::test] + async fn verify_work_records_a_missing_runner_as_a_run_that_could_not_run() { + const PACKAGE: &str = + r#"{"name":"tally","type":"module","scripts":{"test":"kin-missing-runner --test"}}"#; + const MATH: &str = "export function add(a, b) {\n return a + b;\n}\n"; + const TESTS: &str = "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { add } from '../src/math.js';\n\nexport function checkAdd() {\n assert.equal(add(2, 3), 5);\n}\n\ntest('add', checkAdd);\n"; + + let state = test_state(); + install_admitted_query_sources( + &state, + &[ + ("package.json", PACKAGE), + ("src/math.js", MATH), + ("test/math.test.js", TESTS), + ], + ) + .await; + let layout = state.layout.clone(); + let named = |name: &str| { + state + .graph + .list_all_entities() + .unwrap() + .into_iter() + .find(|entity| entity.name == name && entity.kind == EntityKind::Function) + .unwrap_or_else(|| panic!("the fixture defines {name}")) + }; + let add = named("add"); + let check_add = named("checkAdd"); + + let response = router(Arc::clone(&state)) + .oneshot( + Request::post("/verify/work") + .header("content-type", "application/json") + .body(Body::from( + json!({"mode": "run", "entities": [add.id.to_string()]}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let answer: kin_cli::commands::verify::WorkVerifyResponse = + serde_json::from_slice(&body).unwrap(); + assert!(answer.tests.iter().any(|test| test.name == "checkAdd")); + let run = answer.run.expect("the attempt is recorded"); + assert_eq!(run.outcome, "could_not_run"); + // The shell exits 127 whether `npm` or the runner it starts is missing. + assert_eq!(run.exit_code, Some(127)); + let reason = run.reason.as_deref().unwrap(); + assert!( + reason.starts_with("The shell could not find `") + && reason.ends_with("`, so no test ran."), + "{reason}" + ); + assert!(run + .output_tail + .as_deref() + .is_some_and(|tail| tail.contains("command not found") || tail.contains("not found"))); + + let recorded = state.graph.list_runs_proving_entity(&add.id).unwrap(); + assert_eq!(recorded.len(), 1); + let stored = recorded[0].clone(); + assert!(stored.could_not_run()); + assert_eq!(stored.exit_code, Some(127)); + assert!( + stored.evidence_blob.is_some(), + "its output is kept as evidence" + ); + let reading = |graph: &kin_db::InMemoryGraph| { + kin_cli::commands::verify::test_result( + graph, + &check_add.id, + Some(&add.id), + &mut std::collections::HashMap::new(), + ) + .unwrap() + }; + let not_run = reading(state.graph.as_ref()); + assert_eq!( + not_run.state, + kin_cli::commands::verify::TestResultState::NotRun + ); + assert_eq!( + not_run.summary.as_deref(), + Some("The test command could not run, so this test did not run. It exited 127.") + ); + + drop(state); + let reopened = DaemonState::open(layout).unwrap(); + assert_eq!( + reopened.graph.get_verification_run(&stored.run_id).unwrap(), + Some(stored) + ); + assert_eq!(reading(reopened.graph.as_ref()).state, not_run.state); + } + #[tokio::test] async fn mcp_bootstrap_route_is_not_available() { let state = test_state(); @@ -69544,6 +72352,8 @@ pub(crate) mod tests { let base = format!("http://{addr}"); let mut daemon_url = kin_core::test_env::EnvVarGuard::set("KIN_DAEMON_URL", &base); + let _repository_binding = LocalMcpRepositoryBinding::new(&layout); + let run_task_str = run_task_id.to_string(); let do_work_str = do_work_id.to_string(); @@ -69723,6 +72533,7 @@ pub(crate) mod tests { ); server.abort(); + let _ = server.await; } #[tokio::test] @@ -69787,8 +72598,10 @@ pub(crate) mod tests { body["cross_repo"]["authority_roots"][provider_repo_id.as_str()], provider_root ); - assert_eq!(body["negative"]["safe_to_conclude_absent"], false, - "complete federation does not supply the synthetic local graph's missing source proof: {body}"); + assert_eq!( + body["negative"]["safe_to_conclude_absent"], false, + "complete federation does not supply the synthetic local graph's missing source proof: {body}" + ); } #[tokio::test] @@ -69992,9 +72805,12 @@ pub(crate) mod tests { state.graph.invalidate_binding_history(); let revoked = capture(); assert!(!Arc::ptr_eq(&history.graph, &revoked.graph)); - state.graph.restore_binding_history_from(&revoked.graph); + // Restoring from a graph that holds no proof onto one that holds none + // writes nothing, so the capture taken before it is still current. A + // restore that does change the proof is covered below. + assert!(!state.graph.restore_binding_history_from(&revoked.graph)); let restored = capture(); - assert!(!Arc::ptr_eq(&revoked.graph, &restored.graph)); + assert!(Arc::ptr_eq(&revoked.graph, &restored.graph)); assert_eq!(state.graph.compute_root_hash(), root); assert_eq!(state.stable_graph_authority_epoch(), Some(daemon_epoch)); assert_eq!( @@ -70958,7 +73774,13 @@ pub(crate) mod tests { Some(EntitySourceScope::At(revision)) ); assert_eq!( - state.source_scope_for_selected_graph(Some(&session), &state.graph, RequestGraphAuthority::Head).await, + state + .source_scope_for_selected_graph( + Some(&session), + &state.graph, + RequestGraphAuthority::Head + ) + .await, None, "a request selected before the session was scoped must not retain a HEAD source binding", ); @@ -73705,7 +76527,10 @@ pub(crate) mod tests { /// its own process, the defect is structurally invisible. #[tokio::test] async fn mcp_find_references_reports_legacy_xref_payload_as_unavailable() { - async fn legacy_xref() -> Json { + async fn legacy_xref( + Extension(requests): Extension>, + ) -> Json { + requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst); Json(serde_json::json!({ "version": kin_spine::SPINE_PAYLOAD_VERSION, "edges": [{ @@ -73717,10 +76542,22 @@ pub(crate) mod tests { })) } + let state = test_state(); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let environment = kin_core::test_env::EnvVarGuard::new(); + let _repository_binding = LocalMcpRepositoryBinding::new(&state.layout); + let requests = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let recorded_requests = Arc::clone(&requests); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { - let _ = axum::serve(listener, Router::new().fallback(legacy_xref)).await; + let routes = Router::new() + .route("/spine/xref", get(legacy_xref)) + .layer(Extension(recorded_requests)) + .fallback_service(router(state)); + let _ = axum::serve(listener, routes).await; }); let binding = kin_mcp::handlers::entities::AmbientCrossRepoBinding::new( Some("provider"), @@ -73749,25 +76586,44 @@ pub(crate) mod tests { .as_str() .is_some_and(|reason| reason.contains("malformed spine xref response"))); assert_eq!(body["total_upstream"], 0); + assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1); server.abort(); + let _ = server.await; + drop(_repository_binding); + drop(environment); } /// Names its binding by argument for the reason given on /// `mcp_find_references_reports_legacy_xref_payload_as_unavailable`. #[tokio::test] async fn mcp_find_references_rejects_unanchored_xref_as_inconclusive() { - async fn legacy_xref() -> Json { + async fn legacy_xref( + Extension(requests): Extension>, + ) -> Json { + requests.fetch_add(1, std::sync::atomic::Ordering::SeqCst); Json(serde_json::json!({ "version": kin_spine::SPINE_PAYLOAD_VERSION, "edges": [], })) } + let state = test_state(); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let environment = kin_core::test_env::EnvVarGuard::new(); + let _repository_binding = LocalMcpRepositoryBinding::new(&state.layout); + let requests = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let recorded_requests = Arc::clone(&requests); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { - let _ = axum::serve(listener, Router::new().fallback(legacy_xref)).await; + let routes = Router::new() + .route("/spine/xref", get(legacy_xref)) + .layer(Extension(recorded_requests)) + .fallback_service(router(state)); + let _ = axum::serve(listener, routes).await; }); let binding = kin_mcp::handlers::entities::AmbientCrossRepoBinding::new( Some("provider"), @@ -73808,8 +76664,12 @@ pub(crate) mod tests { assert!(body["negative"]["trust_reason"] .as_str() .is_some_and(|reason| reason.contains("cross_repo_unavailable"))); + assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1); server.abort(); + let _ = server.await; + drop(_repository_binding); + drop(environment); } /// Names its binding by argument for the reason given on @@ -75036,6 +77896,362 @@ pub(crate) mod tests { ); } + /// A managed daemon still takes `/shutdown` and `/retire` from a client + /// that sends no runtime identity, or another release's. Those routes are + /// bound to the daemon's own process incarnation, which the handler checks, + /// and a client of an older or newer release is exactly who has to retire + /// it. Semantic routes keep refusing a missing identity. + #[tokio::test] + async fn managed_lifecycle_routes_are_bound_to_the_incarnation_not_the_runtime_header() { + let home = tempfile::tempdir().unwrap(); + let namespace = kin_core::runtime_identity::RuntimeNamespace::capture(home.path()).unwrap(); + let repository = kin_core::runtime_identity::RuntimeRepositoryBinding::capture( + &uuid::Uuid::new_v4().to_string(), + home.path(), + ) + .unwrap(); + let identity = kin_cli::daemon_client::current_process_identity().unwrap(); + let mut stale = serde_json::to_value(&identity).unwrap(); + stale["birth_token"] = serde_json::Value::String("reused-pid-successor".to_string()); + let state = test_state(); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + // The production route table, under the production runtime policy of a + // managed local daemon. + let app = Router::new() + .merge(api_routes()) + .nest("/v2", api_routes()) + .layer(Extension(DaemonShutdownControl(Some(shutdown_tx)))) + .with_state(Arc::clone(&state)) + .layer(middleware::from_fn_with_state( + RuntimeRequestPolicy { + require_identity: true, + local: true, + namespace: Some(namespace), + repository: Some(repository), + }, + validate_runtime_request, + )); + let post = |path: &str, body: String| { + Request::post(path) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap() + }; + let json_body = |response: Response| async move { + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + serde_json::from_slice::(&bytes).unwrap_or_else(|_| { + serde_json::Value::String(String::from_utf8_lossy(&bytes).into_owned()) + }) + }; + + let rejected = app + .clone() + .oneshot(post("/retire", stale.to_string())) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::CONFLICT); + let body = json_body(rejected).await; + assert_eq!( + body["error"], "daemon process incarnation changed", + "the handler, not the runtime header check, must answer a stale incarnation: {body}" + ); + assert!(!state.retirement_requested()); + + let accepted = app + .clone() + .oneshot(post("/retire", serde_json::to_string(&identity).unwrap())) + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::ACCEPTED); + let body = json_body(accepted).await; + assert_eq!(body["retiring"], true, "{body}"); + assert!(body["blocked_by"].is_array(), "{body}"); + assert!(state.retirement_requested()); + + // A client of another release sends its own runtime identity, which + // this daemon does not share, and no repository claim. The incarnation + // still decides. + let foreign = app + .clone() + .oneshot( + Request::post("/v2/shutdown") + .header("content-type", "application/json") + .header( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + "another-release", + ) + .body(Body::from(serde_json::to_vec(&identity).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(foreign.status(), StatusCode::ACCEPTED); + assert!(*shutdown_rx.borrow(), "the matching incarnation must stop"); + + let semantic = app + .oneshot(Request::get("/session").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + semantic.status(), + StatusCode::CONFLICT, + "a semantic route still requires the runtime identity header" + ); + } + + /// A replacement is bound to the incarnation like any retirement, drops the + /// attached client from what it waits on, answers in stable codes, and from + /// then on refuses new requests with the successor's name while probes and + /// the lifecycle routes still pass. The first successor named stays. + #[tokio::test] + async fn a_replacement_answers_in_codes_and_refuses_new_requests_with_the_successor() { + use kin_cli::daemon_client::replacement::{ + blocker, ReplacementClaim, RetirementReply, RetirementRequest, DAEMON_REPLACING, + }; + let identity = kin_cli::daemon_client::current_process_identity().unwrap(); + let mut stale = serde_json::to_value(&identity).unwrap(); + stale["birth_token"] = serde_json::Value::String("reused-pid-successor".to_string()); + let state = test_state(); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + state + .coordinator + .register_session( + "claude-code", + "attached-editor", + kin_model::SessionTransport::Mcp, + Some(std::process::id()), + state.layout.working_dir().to_path_buf(), + kin_model::SessionCapabilities::default(), + ) + .unwrap(); + let app = router_with_auth(Arc::clone(&state), Some("retire-token".to_string())); + let retire = |body: serde_json::Value| { + Request::post("/retire") + .header("authorization", "Bearer retire-token") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap() + }; + let replacement = |identity: serde_json::Value, successor: &str| json!({"identity": identity, "replacement": {"successor": successor}}); + + let rejected = app + .clone() + .oneshot(retire(replacement(stale, "Kin 9.9.9 (0123abc)"))) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::CONFLICT); + assert!(state.replacement().is_none() && !state.retirement_requested()); + + let request = RetirementRequest::Replacement { + identity: identity.clone(), + replacement: ReplacementClaim { + successor: "Kin 9.9.9 (0123abc)".to_string(), + }, + }; + let accepted = app + .clone() + .oneshot(retire(serde_json::to_value(&request).unwrap())) + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::ACCEPTED); + let reply: RetirementReply = serde_json::from_slice( + &axum::body::to_bytes(accepted.into_body(), 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + assert!(reply.retiring && reply.replacing, "{reply:?}"); + assert_eq!(reply.successor.as_deref(), Some("Kin 9.9.9 (0123abc)")); + let codes = reply + .blockers + .clone() + .expect("a replacing daemon sends codes"); + assert!( + !codes.iter().any(|code| code == blocker::ATTACHED_SESSION), + "an attached client must not hold a replacement: {reply:?}" + ); + assert!( + !codes.iter().any(|code| code == blocker::REQUEST_IN_FLIGHT), + "the replacement request itself must not be named as in flight: {reply:?}" + ); + assert_eq!(reply.blocked_by.len(), codes.len()); + assert!(state.retirement_requested()); + + let refused = app + .clone() + .oneshot( + Request::get("/session") + .header("authorization", "Bearer retire-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(refused.into_body(), 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(body["code"], DAEMON_REPLACING); + assert_eq!( + body["error"], + "Kin was replaced by Kin 9.9.9 (0123abc); restart this client" + ); + + let probe = app + .clone() + .oneshot(Request::get("/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(probe.status(), StatusCode::OK, "probes see the drain"); + + // A later plain retirement, or another successor, changes nothing. + let again = app + .clone() + .oneshot(retire(serde_json::to_value(&identity).unwrap())) + .await + .unwrap(); + assert_eq!(again.status(), StatusCode::ACCEPTED); + let other = app + .oneshot(retire(replacement( + serde_json::to_value(&identity).unwrap(), + "Kin 10.0.0 (fedcba9)", + ))) + .await + .unwrap(); + let reply: RetirementReply = serde_json::from_slice( + &axum::body::to_bytes(other.into_body(), 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + assert!(reply.replacing); + assert_eq!(reply.successor.as_deref(), Some("Kin 9.9.9 (0123abc)")); + } + + /// A plain retirement still answers in codes beside its sentences, so a + /// client of this release reads it without the sentence table. + #[tokio::test] + async fn a_plain_retirement_names_its_blockers_by_code_and_keeps_serving() { + use kin_cli::daemon_client::replacement::{blocker, RetirementReply}; + let identity = kin_cli::daemon_client::current_process_identity().unwrap(); + let state = test_state(); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + state + .coordinator + .register_session( + "claude-code", + "attached-editor", + kin_model::SessionTransport::Mcp, + Some(std::process::id()), + state.layout.working_dir().to_path_buf(), + kin_model::SessionCapabilities::default(), + ) + .unwrap(); + let app = router(Arc::clone(&state)); + let accepted = app + .clone() + .oneshot( + Request::post("/retire") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&identity).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::ACCEPTED); + let reply: RetirementReply = serde_json::from_slice( + &axum::body::to_bytes(accepted.into_body(), 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + assert!(reply.retiring && !reply.replacing && reply.successor.is_none()); + assert_eq!( + reply.blockers.as_deref(), + Some(&[blocker::ATTACHED_SESSION.to_string()][..]) + ); + assert_eq!( + reply.blocked_by, + vec![crate::daemon::ShutdownBlocker::AttachedSession + .describe() + .to_string()] + ); + let served = app + .oneshot(Request::get("/session").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + served.status(), + StatusCode::OK, + "a retiring daemon keeps serving its clients" + ); + } + + /// Both event streams end with one frame naming the successor once their + /// daemon is replaced, so a subscriber of the replaced release is told to + /// restart instead of finding its stream cut. + #[tokio::test] + async fn event_streams_end_with_the_successor_when_their_daemon_is_replaced() { + async fn next_frame( + body: &mut axum::body::BodyDataStream, + ) -> Option> { + tokio::time::timeout(Duration::from_secs(10), futures_util::StreamExt::next(body)) + .await + .expect("the stream must answer, not hang") + } + for path in ["/vfs/subscribe", "/graph/events"] { + let state = test_state(); + let response = router(Arc::clone(&state)) + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let mut body = response.into_body().into_data_stream(); + let connected = next_frame(&mut body) + .await + .expect("connected frame") + .unwrap(); + assert!(String::from_utf8_lossy(&connected).contains("\"connected\"")); + assert_eq!(state.event_tx.receiver_count(), 1); + + assert!(state.request_replacement("Kin 9.9.9 (0123abc)")); + let last = next_frame(&mut body) + .await + .expect("the replaced frame") + .unwrap(); + let text = String::from_utf8(last.to_vec()).unwrap(); + let frame: serde_json::Value = + serde_json::from_str(text.trim_start_matches("data: ").trim()).unwrap(); + assert_eq!(frame["type"], "replaced", "{path}: {frame}"); + assert_eq!(frame["successor"], "Kin 9.9.9 (0123abc)"); + assert_eq!( + frame["message"], + "Kin was replaced by Kin 9.9.9 (0123abc); restart this client" + ); + assert!( + next_frame(&mut body).await.is_none(), + "{path} must end after the replaced frame" + ); + assert_eq!( + state.event_tx.receiver_count(), + 0, + "{path} must let go of its subscription" + ); + } + } + #[tokio::test] async fn spine_edges_snapshot_is_authenticated_and_read_only() { let state = test_state(); @@ -76895,9 +80111,12 @@ pub(crate) mod tests { body_bytes > 5_000, "the fixture must carry substantial source" ); - assert!(with_bodies.len().saturating_sub(compact.len()) >= body_bytes, + assert!( + with_bodies.len().saturating_sub(compact.len()) >= body_bytes, "shape response must save every omitted body byte: {} vs {} bytes, {body_bytes} source bytes", - compact.len(), with_bodies.len()); + compact.len(), + with_bodies.len() + ); } /// `compact: true` is the same request as `include_body: false`. diff --git a/crates/kin-daemon/src/api/tests/agent_activity_routes.rs b/crates/kin-daemon/src/api/tests/agent_activity_routes.rs index 5c7022f0d..11bc1d4f2 100644 --- a/crates/kin-daemon/src/api/tests/agent_activity_routes.rs +++ b/crates/kin-daemon/src/api/tests/agent_activity_routes.rs @@ -184,7 +184,9 @@ async fn an_agents_reads_are_shown_by_entity_and_never_by_what_it_asked_or_where ); assert_path_free(&agents, &forbidden); - // An ended session is gone from both, and so is its log. + // An ended session is no longer listed as here, and its live log is + // gone. What it did is kept past its end, as path-free as the live log, + // and it outlives the daemon. let ended = router(Arc::clone(&state)) .oneshot( Request::delete(format!("/session/{session}")) @@ -194,11 +196,174 @@ async fn an_agents_reads_are_shown_by_entity_and_never_by_what_it_asked_or_where .await .unwrap(); assert_eq!(ended.status(), StatusCode::OK); - let (status, _) = activity_get(&state, &format!("/session/{session}/calls")).await; - assert_eq!(status, StatusCode::NOT_FOUND); let (_, agents) = activity_get(&state, "/agents").await; assert!(!agents.contains(&session)); assert_eq!(state.coordinator.activity().retained_entries(), 0); + let (status, kept) = activity_get(&state, &format!("/session/{session}/calls")).await; + assert_eq!(status, StatusCode::OK, "{kept}"); + let kept_calls: serde_json::Value = serde_json::from_str(&kept).unwrap(); + assert_eq!(kept_calls["calls_total"], 1); + assert_eq!(kept_calls["calls"][0]["tool"], "find_references"); + assert!(kept_calls["ended_at"].is_string(), "{kept}"); + assert_path_free(&kept, &forbidden); + let gone = activity_ended(&state, &session) + .await + .expect("the agent that ended is kept"); + assert_eq!(gone["client"], "claude-code"); + assert_eq!(gone["transport"], "mcp"); + assert_eq!(gone["calls_kept"], true); + assert_eq!(gone["calls_total"], 1); + assert_eq!(gone["recent_calls"][0]["tool"], "find_references"); + assert_eq!(gone["read_entity_ids"][0], entity.id.to_string()); + let (_, listed) = activity_get(&state, "/agents/ended").await; + let listed_json: serde_json::Value = serde_json::from_str(&listed).unwrap(); + assert_eq!(listed_json["schema"], "kin.agents-ended.v1"); + assert_eq!( + listed_json["kept_days"], + crate::ended_sessions::ENDED_KEPT_DAYS + ); + assert_eq!( + listed_json["kept_sessions"], + crate::ended_sessions::MAX_ENDED_SESSIONS + ); + assert_path_free(&listed, &forbidden); + + // Written to repository authority once the session has ended and the + // quiet window has elapsed, and read back from there by a daemon that + // reopens the store. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert!(crate::agent_authorship::settle(&state, false).await >= 1); + let layout = state.layout.clone(); + drop(state); + let reopened = Arc::new(DaemonState::open(layout).unwrap()); + let again = activity_ended(&reopened, &session) + .await + .expect("the agent that ended outlives the daemon"); + assert_eq!(again["calls_total"], 1); + assert_eq!(again["read_entity_ids"][0], entity.id.to_string()); +} + +/// The entry `/agents/ended` serves for `session`, if it serves one. +async fn activity_ended(state: &Arc, session: &str) -> Option { + let (status, body) = activity_get(state, "/agents/ended").await; + assert_eq!(status, StatusCode::OK, "{body}"); + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap(); + parsed["agents"] + .as_array() + .unwrap() + .iter() + .find(|agent| agent["session_id"] == session.to_ascii_lowercase()) + .cloned() +} + +/// An agent that worked here and left is still shown with what it did: one +/// whose process was killed, before the sweep reaps it and after, and one +/// that ended before its calls were kept, by what it wrote. A write older +/// than the window ended sessions are kept for is not shown. +#[tokio::test] +async fn agents_that_worked_here_and_left_are_kept_with_what_they_read_and_wrote() { + let state = test_state(); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let entity = test_entity("handler", "src/secret_module.py"); + state.graph.upsert_entity(&entity).unwrap(); + let working_dir = state.layout.working_dir().display().to_string(); + let started = router(Arc::clone(&state)) + .oneshot( + Request::post("/session") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "vendor": "codex", + "client_name": "codex", + "transport": "mcp", + "cwd": state.layout.working_dir(), + // A process id no process holds, as a killed server's is. + "watch_pid": 999_999_999u32, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(started.status(), StatusCode::OK); + let body = axum::body::to_bytes(started.into_body(), 64 * 1024) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let killed = body["session_id"].as_str().unwrap().to_string(); + let answered = activity_call( + &state, + Some(&killed), + "find_references", + serde_json::json!({ "entity_id": entity.id.to_string() }), + ) + .await; + assert_eq!(answered.status(), StatusCode::OK); + + let (_, here) = activity_get(&state, "/agents").await; + assert!(!here.contains(&killed), "a killed agent is not shown as here"); + let before_sweep = activity_ended(&state, &killed) + .await + .expect("a killed agent is shown as gone before the sweep reaps it"); + assert_eq!(before_sweep["calls_total"], 1); + assert_eq!(before_sweep["read_entity_ids"][0], entity.id.to_string()); + let reaped = state.coordinator.sweep_stale_sessions_detailed().unwrap(); + assert_eq!(reaped.len(), 1); + let after_sweep = activity_ended(&state, &killed) + .await + .expect("and after it"); + assert_eq!(after_sweep["calls_kept"], true); + assert_eq!(after_sweep["calls_total"], 1); + assert_eq!(after_sweep["client"], "codex"); + + let writer = SessionId::new().to_string(); + let change = "c".repeat(64); + state + .agent_authorship + .record(kin_cli::commands::review_guide::AgentWrite { + change_id: change.clone(), + session: writer.clone(), + vendor: "claude-code".into(), + client: "claude-code".into(), + at: kin_model::timestamp::Timestamp::now().to_string(), + entities: vec![entity.id], + }); + let old_writer = SessionId::new().to_string(); + state + .agent_authorship + .record(kin_cli::commands::review_guide::AgentWrite { + change_id: "d".repeat(64), + session: old_writer.clone(), + vendor: "claude-code".into(), + client: "claude-code".into(), + at: kin_model::timestamp::Timestamp( + chrono::Utc::now() + - chrono::Duration::days( + i64::try_from(crate::ended_sessions::ENDED_KEPT_DAYS).unwrap() + 1, + ), + ) + .to_string(), + entities: vec![entity.id], + }); + let wrote = activity_ended(&state, &writer) + .await + .expect("an agent known only by what it wrote is shown"); + assert_eq!(wrote["calls_kept"], false); + assert_eq!(wrote["client"], "claude-code"); + assert_eq!(wrote["edited_entity_ids"][0], entity.id.to_string()); + assert_eq!(wrote["change_ids"][0], change); + assert!( + activity_ended(&state, &old_writer).await.is_none(), + "a write past the window is not shown" + ); + let (_, listed) = activity_get(&state, "/agents/ended").await; + assert_path_free( + &listed, + &[working_dir.as_str(), "secret_module", "\"cwd\"", "\"pid\""], + ); } #[tokio::test] @@ -300,6 +465,358 @@ async fn a_session_opened_without_a_process_takes_one_when_it_writes() { assert_eq!(missing.status(), StatusCode::NOT_FOUND); } +/// A connection that opens its session to read names its server's process to +/// watch. The record still names no process, so a live idle connection ages +/// out on the idle rule; once the process is gone the agent is no longer +/// listed, and the next sweep reaps the session. +#[tokio::test] +async fn a_read_session_whose_server_was_killed_is_not_listed_and_is_reaped() { + let state = test_state(); + let open = |watch_pid: u32, client: &'static str| { + let state = Arc::clone(&state); + async move { + let started = router(Arc::clone(&state)) + .oneshot( + Request::post("/session") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "vendor": client, + "client_name": client, + "transport": "mcp", + "cwd": state.layout.working_dir(), + "watch_pid": watch_pid, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(started.status(), StatusCode::OK); + let body = axum::body::to_bytes(started.into_body(), 64 * 1024) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + parse_session_id(body["session_id"].as_str().unwrap()).unwrap() + } + }; + // A process id no process holds, as a killed server's is. + let killed = open(999_999_999, "codex").await; + let running = open(std::process::id(), "claude-code").await; + for session in [killed, running] { + assert_eq!( + state.coordinator.get_session(&session).unwrap().unwrap().pid, + None, + "a watched process is not one the session lives by" + ); + } + let (status, body) = activity_get(&state, "/agents").await; + assert_eq!(status, StatusCode::OK); + let listed: serde_json::Value = serde_json::from_str(&body).unwrap(); + let sessions: Vec<&str> = listed["agents"] + .as_array() + .unwrap() + .iter() + .filter_map(|agent| agent["session_id"].as_str()) + .collect(); + assert_eq!(sessions, vec![running.to_string().as_str()]); + let reaped = state.coordinator.sweep_stale_sessions_detailed().unwrap(); + assert_eq!( + reaped + .iter() + .map(|(session, _)| session.session_id) + .collect::>(), + vec![killed] + ); + assert!(state.coordinator.get_session(&running).unwrap().is_some()); +} + +async fn activity_report_edit( + state: &Arc, + unit: &std::path::Path, + old_text: &str, + new_text: &str, +) -> serde_json::Value { + activity_edit_hook(state, unit, old_text, new_text, "after").await +} + +async fn activity_edit_hook( + state: &Arc, + unit: &std::path::Path, + old_text: &str, + new_text: &str, + phase: &str, +) -> serde_json::Value { + let response = router(Arc::clone(state)) + .oneshot( + Request::post("/agents/edits?wait=true") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "client": "claude-code", + "session": "client-session-1", + "tool_use_id": "tool-1", + "phase": phase, + "unit": unit, + "edits": [{"old_text": old_text, "new_text": new_text}], + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .unwrap(); + serde_json::from_slice(&body).unwrap() +} + +/// An agent edits one function with its own tool and its client reports the +/// edit. A person then edits the function beside it and nobody reports that. +/// The agent is named on the one entity its edit changed, a report that is not +/// what happened confirms nothing, and the guide of uncommitted work says so +/// step by step, with no place on disk in anything kept or answered. +#[tokio::test] +#[serial_test::serial] +async fn an_edit_a_client_reports_names_the_agent_on_the_entity_it_changed_and_no_other() { + let (dir, state, source) = source_base_fixture().await; + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + // The agent is connected, so admission steps are noted from the start. + let connected = activity_open_session(&state).await; + let unit = dir.path().join("src/value.rs"); + let value = source["id"].as_str().unwrap().to_string(); + + let edited = SOURCE_BASE_ORIGINAL.replacen("{ 1 }", "{ 2 }", 1); + let prepared = activity_edit_hook(&state, &unit, "{ 1 }", "{ 2 }", "before").await; + assert_eq!(prepared["outcome"], "prepared", "{prepared}"); + std::fs::write(&unit, &edited).unwrap(); + crate::loop_runner::sync_filesystem_with_graph(&state) + .await + .unwrap(); + let outcome = activity_report_edit(&state, &unit, "{ 1 }", "{ 2 }").await; + assert_eq!(outcome["outcome"], "attributed", "{outcome}"); + assert_eq!(outcome["entities"], 1, "{outcome}"); + let edits = state.agent_authorship.edits(); + assert_eq!(edits.len(), 1); + assert_eq!( + edits[0] + .entities + .iter() + .map(ToString::to_string) + .collect::>(), + vec![value.clone()], + "the function it edited, and not the one beside it or their module" + ); + assert_eq!(edits[0].client, "claude-code"); + assert_eq!(edits[0].session, "client-session-1"); + assert_eq!(edits[0].kin_session.as_deref(), Some(connected.as_str())); + assert!(edits[0].base.is_some() && edits[0].change_id.is_none()); + + // The same report again finds its step already claimed. + let again = activity_report_edit(&state, &unit, "{ 1 }", "{ 2 }").await; + assert_eq!(again["outcome"], "unconfirmed", "{again}"); + + // A person edits the neighbour. A report that is not that edit confirms + // nothing, and nobody is named on it. + let prepared = activity_edit_hook(&state, &unit, "{ 8 }", "{ 9 }", "before").await; + assert_eq!(prepared["outcome"], "prepared", "{prepared}"); + std::fs::write(&unit, edited.replacen("{ 8 }", "{ 9 }", 1)).unwrap(); + crate::loop_runner::sync_filesystem_with_graph(&state) + .await + .unwrap(); + let wrong = activity_report_edit(&state, &unit, "{ 8 }", "{ 7 }").await; + assert_eq!(wrong["outcome"], "unconfirmed", "{wrong}"); + assert_eq!(state.agent_authorship.edits().len(), 1); + + let elsewhere = activity_report_edit( + &state, + std::path::Path::new("/nowhere/kin/holds/value.rs"), + "a", + "b", + ) + .await; + assert_eq!(elsewhere["outcome"], "not_this_repository", "{elsewhere}"); + + let response = router(Arc::clone(&state)) + .oneshot( + Request::post("/review") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({"op": "guide", "json": true, "working": true}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + let answered: kin_cli::commands::review::ReviewResponse = + serde_json::from_slice(&body).unwrap(); + let guide: serde_json::Value = serde_json::from_str(&answered.json.unwrap()).unwrap(); + let steps: Vec<&serde_json::Value> = guide["chapters"] + .as_array() + .unwrap() + .iter() + .flat_map(|chapter| chapter["steps"].as_array().unwrap()) + .collect(); + let step = |name: &str| { + *steps + .iter() + .find(|step| step["name"] == name) + .unwrap_or_else(|| panic!("a step for {name} in {guide}")) + }; + assert_eq!( + step("value")["authored_by"], + serde_json::json!([{ + "name": "Claude Code", + "session": "client-session-1", + "at": edits[0].at, + "through": "client", + "kin_session": connected, + }]) + ); + assert!( + step("sibling").get("authored_by").is_none(), + "changed by a person: nothing is said" + ); + assert_eq!( + guide["summary"]["authors"], + serde_json::json!([{ + "name": "Claude Code", + "session": "client-session-1", + "entities": 1, + "through": "client", + "kin_session": connected, + }]) + ); + // What authority keeps of the edit, once it is written there. + use kin_model::ProvenanceStore as _; + assert!(crate::agent_authorship::settle(&state, true).await >= 1); + let kept: Vec = state + .graph + .list_actors() + .unwrap() + .into_iter() + .filter_map(|actor| actor.agent_session) + .collect(); + assert_eq!(kept.len(), 1, "one record, for the client's session"); + let kept = serde_json::to_string(&kept).unwrap(); + assert!( + !kept.contains("value.rs") && !kept.contains("{ 2 }"), + "neither the place nor the text of the edit is kept: {kept}" + ); +} + +/// A paired Write that keeps the current content cannot take ownership of +/// an earlier person's identical resulting content, even while that human +/// admission step is still retained. +#[tokio::test] +#[serial_test::serial] +async fn a_no_op_client_write_never_claims_an_earlier_human_edit() { + let (dir, state, _) = source_base_fixture().await; + state.is_initialized.store(true, std::sync::atomic::Ordering::Relaxed); + let unit = dir.path().join("src/value.rs"); + let content = SOURCE_BASE_ORIGINAL.replacen("{ 1 }", "{ 3 }", 1); + std::fs::write(&unit, &content).unwrap(); + crate::loop_runner::sync_filesystem_with_graph(&state).await.unwrap(); + let mut claim = crate::client_edits::ClientEditClaim { + client: "claude-code".into(), session: "client-session-write".into(), + tool_use_id: Some("write-1".into()), phase: "before".into(), + unit: unit.to_str().unwrap().into(), edits: Vec::new(), content: Some(content.clone()), + }; + let prepared = crate::client_edits::resolve(&state, claim.clone()).await; + assert_eq!(prepared.outcome, "prepared"); + std::fs::write(&unit, &content).unwrap(); + crate::loop_runner::sync_filesystem_with_graph(&state).await.unwrap(); + claim.phase = "after".into(); + let outcome = crate::client_edits::resolve(&state, claim).await; + assert_eq!(outcome.outcome, "unconfirmed"); + assert!(state.agent_authorship.edits().is_empty()); +} + +/// A post hook delivered after a commit keeps the admission's original +/// base and points at the actual published change, rather than attributing +/// already committed work to the successor's uncommitted overlay. +#[tokio::test] +#[serial_test::serial] +async fn a_delayed_client_post_hook_attributes_the_change_that_recorded_it() { + let (dir, state, _) = source_base_fixture().await; + state.is_initialized.store(true, std::sync::atomic::Ordering::Relaxed); + let unit = dir.path().join("src/value.rs"); + let original_base = crate::unit_lifecycle::workspace_base_change(&state).unwrap(); + let prepared = activity_edit_hook(&state, &unit, "{ 1 }", "{ 4 }", "before").await; + assert_eq!(prepared["outcome"], "prepared", "{prepared}"); + std::fs::write(&unit, SOURCE_BASE_ORIGINAL.replacen("{ 1 }", "{ 4 }", 1)).unwrap(); + crate::loop_runner::sync_filesystem_with_graph(&state).await.unwrap(); + let change = commit_through_api(&router(Arc::clone(&state)), kin_model::OperationId::new(), "Record the paired edit").await; + let outcome = activity_report_edit(&state, &unit, "{ 1 }", "{ 4 }").await; + assert_eq!(outcome["outcome"], "attributed", "{outcome}"); + let edits = state.agent_authorship.edits(); + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].base, Some(original_base.to_string())); + assert_eq!(edits[0].change_id, Some(change.to_string())); +} + +/// An amend preserves the replaced change's parents, so attribution seals +/// against the publication receipt's previous workspace base instead. +#[tokio::test] +#[serial_test::serial] +async fn a_client_edit_seals_against_the_replaced_head_when_amended() { + let (dir, state, _) = source_base_fixture().await; + state.is_initialized.store(true, std::sync::atomic::Ordering::Relaxed); + let unit = dir.path().join("src/value.rs"); + let replaced = crate::unit_lifecycle::workspace_base_change(&state).unwrap(); + let prepared = activity_edit_hook(&state, &unit, "{ 1 }", "{ 6 }", "before").await; + assert_eq!(prepared["outcome"], "prepared", "{prepared}"); + std::fs::write(&unit, SOURCE_BASE_ORIGINAL.replacen("{ 1 }", "{ 6 }", 1)).unwrap(); + crate::loop_runner::sync_filesystem_with_graph(&state).await.unwrap(); + let outcome = activity_report_edit(&state, &unit, "{ 1 }", "{ 6 }").await; + assert_eq!(outcome["outcome"], "attributed", "{outcome}"); + let (status, body) = amend_request_through_api(&router(Arc::clone(&state)), replaced, Some("Amend the paired edit")).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let amended = crate::unit_lifecycle::workspace_base_change(&state).unwrap(); + assert_ne!(amended, replaced); + assert_eq!(state.agent_authorship.edits()[0].base, Some(replaced.to_string())); + assert_eq!(state.agent_authorship.edits()[0].change_id, Some(amended.to_string())); +} + +/// The absent head of a new native repository is a real base. Its first +/// commit seals paired edits exactly like a commit whose parent exists. +#[tokio::test] +#[serial_test::serial] +async fn a_client_edit_before_the_root_commit_keeps_its_authorship() { + let dir = tempfile::tempdir().unwrap(); + let initialized = kin_core::init(dir.path()).unwrap(); + let state = Arc::new(DaemonState::open(initialized.layout).unwrap()); + state.is_initialized.store(true, std::sync::atomic::Ordering::Relaxed); + let unit = dir.path().join("value.rs"); + let mut claim = crate::client_edits::ClientEditClaim { + client: "claude-code".into(), session: "client-session-root".into(), + tool_use_id: Some("write-root".into()), phase: "before".into(), + unit: unit.to_str().unwrap().into(), edits: Vec::new(), + content: Some("pub fn root_value() -> u32 { 5 }\n".into()), + }; + let prepared = crate::client_edits::resolve(&state, claim.clone()).await; + assert_eq!(prepared.outcome, "prepared"); + std::fs::write(&unit, claim.content.as_ref().unwrap()).unwrap(); + crate::loop_runner::sync_filesystem_with_graph(&state).await.unwrap(); + claim.phase = "after".into(); + let outcome = crate::client_edits::resolve(&state, claim).await; + assert_eq!(outcome.outcome, "attributed"); + // The Write made the whole unit: its module and the function in it. + assert_eq!(outcome.entities, 2); + assert!(state.agent_authorship.edits()[0].base.is_none()); + let change = commit_through_api(&router(Arc::clone(&state)), kin_model::OperationId::new(), "Record the first client edit").await; + assert_eq!(state.agent_authorship.edits()[0].change_id, Some(change.to_string())); +} + /// Read the next `data:` frame off an SSE body, skipping heartbeats. async fn activity_next_frame(body: &mut S) -> serde_json::Value where diff --git a/crates/kin-daemon/src/api/tests/detached_switch.rs b/crates/kin-daemon/src/api/tests/detached_switch.rs index 96267917a..d5d810653 100644 --- a/crates/kin-daemon/src/api/tests/detached_switch.rs +++ b/crates/kin-daemon/src/api/tests/detached_switch.rs @@ -607,3 +607,30 @@ async fn a_detached_switch_retires_the_previous_trees_enrichment_markers() { ); assert!(workspace_of(&history.state).enrichment_marks.is_empty()); } + +/// A branch imported from Git targets its Git commit, and the branch list +/// reports the change that commit resolves to through its alias. The local +/// app's remotes and Git update read a branch's head from exactly this, so a +/// reading of the raw target alone found no head on a freshly imported branch. +#[tokio::test] +async fn the_branch_list_reports_the_change_a_git_branch_stands_on() { + let history = history("branch-list-git-head"); + let head = history.commits[2].clone(); + let request = kin_cli::commands::branch::BranchRequest::List; + let (status, body) = post_branch_request(Arc::clone(&history.state), &request, None).await; + let body = String::from_utf8_lossy(&body).to_string(); + assert_eq!(status, StatusCode::OK, "{body}"); + let response: kin_cli::commands::branch::BranchResponse = serde_json::from_str(&body).unwrap(); + let report = response.report.expect("the list carries its report"); + let main = report + .branches + .iter() + .find(|entry| entry.name == kin_model::RefName::branch(b"main").unwrap()) + .expect("main is listed"); + assert!( + matches!(main.target, kin_model::RefTarget::ExternalObject { .. }), + "an imported branch targets its Git commit: {:?}", + main.target + ); + assert_eq!(main.change_id, Some(change_for(&history.state, &head))); +} diff --git a/crates/kin-daemon/src/api/tests/entity_keep.rs b/crates/kin-daemon/src/api/tests/entity_keep.rs new file mode 100644 index 000000000..3ea8cef5f --- /dev/null +++ b/crates/kin-daemon/src/api/tests/entity_keep.rs @@ -0,0 +1,1281 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +// Keeping chosen entities of the uncommitted working copy, and undoing one +// entity, end to end through the daemon's routes: the working copy is never +// written by a keep, the change carries exactly the chosen entities, and the +// work left behind is exactly the work that was not chosen. + +const ENTITY_KEEP_FILE: &str = "src/work.py"; +const ENTITY_KEEP_HEAD: &str = "import os\n\n\ndef alpha():\n return 1\n\n\ndef beta():\n return 2\n\n\ndef gamma():\n return 3\n"; +const ENTITY_KEEP_NOTES: &str = "notes.txt"; + +/// The working copy every keep test starts from: alpha and beta edited, gamma +/// removed, delta added, a new import, and the unparsed notes rewritten. +const ENTITY_KEEP_WORK: &str = "import os\nimport sys\n\n\ndef alpha():\n return 10\n\n\ndef beta():\n return 20\n\n\ndef delta():\n return 4\n"; + +async fn entity_keep_post( + state: &Arc, + route: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let response = router(Arc::clone(state)) + .oneshot( + Request::post(route) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 256 * 1024) + .await + .unwrap(); + let parsed = serde_json::from_slice(&body) + .unwrap_or_else(|_| serde_json::json!({"raw": String::from_utf8_lossy(&body)})); + (status, parsed) +} + +fn entity_keep_commit_body( + entities: &[String], + operation_id: kin_model::OperationId, +) -> serde_json::Value { + serde_json::json!({ + "operation_id": operation_id, + "timestamp": kin_model::Timestamp::now(), + "message": "keep the chosen entities", + "author": "Test Author ", + "entities": entities, + }) +} + +fn entity_keep_restore_body( + entity: &str, + to: Option<&str>, + expected: Option<&str>, +) -> serde_json::Value { + serde_json::json!({ + "operation_id": kin_model::OperationId::new(), + "entity": entity, + "to": to, + "expected": expected, + "actor": "Test Author ", + }) +} + +/// The live entity named `name` in the work content. +fn entity_keep_live(state: &Arc, name: &str) -> Option { + state + .graph + .query_entities(&kin_model::EntityFilter { + file_path: Some(kin_model::FilePathId::new(ENTITY_KEEP_FILE)), + ..Default::default() + }) + .unwrap() + .into_iter() + .find(|entity| entity.name == name && entity.kind != kin_model::EntityKind::Module) +} + +fn entity_keep_id(state: &Arc, name: &str) -> String { + entity_keep_live(state, name) + .unwrap_or_else(|| panic!("{name} is held in the working copy")) + .id + .to_string() +} + +/// What the head change holds at `path`. +fn entity_keep_head_bytes(state: &Arc, path: &str) -> Option> { + let authority = ActiveApiRepositoryAuthority::open(state).unwrap(); + let lease = authority.manager.read_authority(); + let workspace = lease + .metadata() + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == authority.workspace_id) + .unwrap() + .clone(); + let head = lease + .resolve_target_change_id(workspace.base_target.as_ref().unwrap()) + .unwrap(); + let resolved = crate::commit_deltas::resolve_authority_baseline(lease.snapshot(), &head) + .unwrap() + .into_owned(); + let artifact = resolved + .tree + .artifact_at_path(&RepoPath::from_utf8(path).unwrap())?; + let hash = artifact.entry.blob_identity().unwrap(); + Some(authority.manager.load_source_blob(hash).unwrap().unwrap()) +} + +fn entity_keep_head_change(state: &Arc) -> SemanticChangeId { + let authority = ActiveApiRepositoryAuthority::open(state).unwrap(); + let lease = authority.manager.read_authority(); + let workspace = lease + .metadata() + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == authority.workspace_id) + .unwrap() + .clone(); + lease + .resolve_target_change_id(workspace.base_target.as_ref().unwrap()) + .unwrap() +} + +/// The entities and unparsed contents the working copy holds uncommitted, by +/// name, as `kin diff` derives them. +fn entity_keep_uncommitted( + state: &Arc, +) -> ( + std::collections::BTreeSet, + std::collections::BTreeSet, +) { + let binding = kin_core::LocalRepositoryAuthorityBinding::from_layout(&state.layout).unwrap(); + let report = + kin_cli::commands::diff::inspect(&binding, None, None, Some(state.graph.as_ref())).unwrap(); + let entities = report + .entity_deltas + .iter() + .filter_map(|delta| match delta { + EntityDelta::Added { new } => Some(new), + EntityDelta::Removed { old } => Some(old), + EntityDelta::Modified { old, new } => { + kin_review::is_semantic_modification(old, new).then_some(new) + } + }) + .filter(|entity| entity.kind != kin_model::EntityKind::Module) + .map(|entity| entity.name.clone()) + .collect(); + let contents = report + .artifact_deltas + .iter() + .filter_map(|delta| delta.new_state().or(delta.old_state())) + .map(|located| located.path.to_string()) + .collect(); + (entities, contents) +} + +/// A store whose head holds the original work content and notes, with the +/// working copy then edited into [`ENTITY_KEEP_WORK`] and admitted. +async fn entity_keep_fixture() -> (Arc, SemanticChangeId, String) { + let state = test_state(); + let root = state.layout.working_dir().to_path_buf(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join(ENTITY_KEEP_FILE), ENTITY_KEEP_HEAD).unwrap(); + std::fs::write(root.join(ENTITY_KEEP_NOTES), "first draft\n").unwrap(); + post_command_commit(&state, "the original work").await; + let base = entity_keep_head_change(&state); + let gamma = entity_keep_id(&state, "gamma"); + std::fs::write(root.join(ENTITY_KEEP_FILE), ENTITY_KEEP_WORK).unwrap(); + std::fs::write(root.join(ENTITY_KEEP_NOTES), "second draft\n").unwrap(); + crate::loop_runner::sync_filesystem_with_graph_under_coordination(&state) + .await + .unwrap(); + (state, base, gamma) +} + +fn entity_keep_notes_id(state: &Arc) -> String { + state + .graph + .resolved_tree() + .artifact_at_path(&RepoPath::from_utf8(ENTITY_KEEP_NOTES).unwrap()) + .unwrap() + .artifact_id + .0 + .to_string() +} + +fn entity_keep_disk(state: &Arc, path: &str) -> String { + std::fs::read_to_string(state.layout.working_dir().join(path)).unwrap() +} + +/// The keep: alpha's edit, delta's addition and gamma's removal are committed, +/// and beta's edit, the new import and the notes stay uncommitted, the working +/// copy not touched at all. +#[tokio::test] +async fn entity_keep_commits_only_the_chosen_entities_and_leaves_the_rest_exactly() { + let (state, _base, gamma) = entity_keep_fixture().await; + let (before, _) = entity_keep_uncommitted(&state); + assert_eq!( + before, + ["alpha", "beta", "delta", "gamma"] + .into_iter() + .map(String::from) + .collect::>(), + "the fixture's uncommitted work" + ); + let chosen = vec![ + entity_keep_id(&state, "alpha"), + entity_keep_id(&state, "delta"), + gamma, + ]; + let operation_id = kin_model::OperationId::new(); + let (status, answer) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body(&chosen, operation_id), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!(answer["replayed"], false, "{answer}"); + + { + let authority = ActiveApiRepositoryAuthority::open(&state).unwrap(); + let lease = authority.manager.read_authority(); + let persisted = lease + .workspace_graph_snapshot(&authority.workspace_id) + .unwrap() + .unwrap(); + let live = state.graph.to_snapshot(); + assert_eq!( + persisted.resolved_tree, live.resolved_tree, + "a reopened authority keeps the whole working copy's tree" + ); + assert_eq!( + persisted.entities, live.entities, + "a reopened authority keeps the uncommitted entities after its base advances" + ); + assert_eq!( + persisted.relations, live.relations, + "a reopened authority keeps the working copy's relations" + ); + } + + assert_eq!( + entity_keep_disk(&state, ENTITY_KEEP_FILE), + ENTITY_KEEP_WORK, + "a keep never writes the working copy" + ); + assert_eq!( + entity_keep_disk(&state, ENTITY_KEEP_NOTES), + "second draft\n" + ); + assert_eq!( + String::from_utf8(entity_keep_head_bytes(&state, ENTITY_KEEP_FILE).unwrap()).unwrap(), + "import os\n\n\ndef alpha():\n return 10\n\n\ndef beta():\n return 2\n\n\ndef delta():\n return 4\n", + "the change holds alpha's edit, delta and gamma's removal, and nothing else" + ); + assert_eq!( + entity_keep_head_bytes(&state, ENTITY_KEEP_NOTES).unwrap(), + b"first draft\n", + "an unparsed entity that was not chosen stays at the head" + ); + let (entities, contents) = entity_keep_uncommitted(&state); + assert_eq!( + entities, + ["beta"] + .into_iter() + .map(String::from) + .collect::>(), + "only beta's change is left uncommitted, and delta keeps the identity it had" + ); + assert!(contents.contains(ENTITY_KEEP_NOTES), "{contents:?}"); + + // Sent again, the same operation answers with the change it recorded and + // records nothing new. + let landed = entity_keep_head_change(&state); + let (status, replay) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body(&chosen, operation_id), + ) + .await; + assert_eq!(status, StatusCode::OK, "{replay}"); + assert_eq!(replay["replayed"], true, "{replay}"); + assert_eq!(replay["change_id"], answer["change_id"]); + assert_eq!(entity_keep_head_change(&state), landed); + + // The unparsed entity is kept whole. + let notes = entity_keep_notes_id(&state); + let (status, answer) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body(&[notes], kin_model::OperationId::new()), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!( + entity_keep_head_bytes(&state, ENTITY_KEEP_NOTES).unwrap(), + b"second draft\n" + ); + let (entities, contents) = entity_keep_uncommitted(&state); + assert_eq!( + entities, + ["beta"] + .into_iter() + .map(String::from) + .collect::>() + ); + assert!(!contents.contains(ENTITY_KEEP_NOTES), "{contents:?}"); + + // Keeping the last entity of the content keeps the content whole, + // the import no entity holds included. + let beta = entity_keep_id(&state, "beta"); + let (status, answer) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body(&[beta], kin_model::OperationId::new()), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!( + String::from_utf8(entity_keep_head_bytes(&state, ENTITY_KEEP_FILE).unwrap()).unwrap(), + ENTITY_KEEP_WORK + ); + let (entities, contents) = entity_keep_uncommitted(&state); + assert!(entities.is_empty(), "{entities:?}"); + assert!(contents.is_empty(), "{contents:?}"); +} + +#[tokio::test] +async fn entity_keep_refuses_what_it_cannot_commit_and_records_nothing() { + let (state, _base, _gamma) = entity_keep_fixture().await; + let refuse = |answer: &serde_json::Value| { + assert_eq!(answer["error"], "entity_refused", "{answer}"); + answer["message"].as_str().unwrap().to_string() + }; + + let (status, answer) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body(&["not-an-entity".into()], kin_model::OperationId::new()), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(refuse(&answer), "not-an-entity does not name an entity."); + + let (status, answer) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body( + &[kin_model::EntityId::new().to_string()], + kin_model::OperationId::new(), + ), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert!(refuse(&answer).starts_with("No uncommitted work names entity")); + + let root = state.layout.working_dir().to_path_buf(); + std::fs::write( + root.join("src/pair.js"), + "function left() { return 1 } function right() { return 2 }\n", + ) + .unwrap(); + post_command_commit(&state, "a pair on one line").await; + std::fs::write( + root.join("src/pair.js"), + "function left() { return 10 } function right() { return 20 }\n", + ) + .unwrap(); + crate::loop_runner::sync_filesystem_with_graph_under_coordination(&state) + .await + .unwrap(); + let left = state + .graph + .query_entities(&kin_model::EntityFilter { + file_path: Some(kin_model::FilePathId::new("src/pair.js")), + ..Default::default() + }) + .unwrap() + .into_iter() + .find(|entity| entity.name == "left") + .unwrap(); + let head = entity_keep_head_change(&state); + let (status, answer) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body(&[left.id.to_string()], kin_model::OperationId::new()), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{answer}"); + assert_eq!( + refuse(&answer), + "left and right were changed in the same place, so Kin cannot commit one without the \ + other." + ); + assert_eq!( + entity_keep_head_change(&state), + head, + "a refusal records nothing" + ); + assert_eq!( + entity_keep_disk(&state, "src/pair.js"), + "function left() { return 10 } function right() { return 20 }\n" + ); +} + +/// The sentence a restore guarded by a semantic fingerprint is refused with. +fn entity_keep_inexact(name: &str) -> String { + format!( + "Kin cannot tell whether {name} changed since it was read, because that fingerprint \ + stays the same when a comment or spacing changes, so nothing was restored. Guard the \ + restore with the exact source a review reports for {name}, source:." + ) +} + +/// The exact source guard for `entity`, cut from the working copy the way a +/// review reports its `after.body_hash`. +fn entity_keep_exact_source(state: &Arc, entity: &kin_model::Entity) -> String { + let span = entity.span.as_ref().unwrap(); + let work = entity_keep_disk(state, &entity.file_origin.as_ref().unwrap().0); + format!( + "source:{}", + kin_blobs::digest(&work.as_bytes()[span.start_byte..span.end_byte]) + ) +} + +/// The exact source guard for the live entity `name` in the work content. +fn entity_keep_exact(state: &Arc, name: &str) -> String { + entity_keep_exact_source(state, &entity_keep_live(state, name).unwrap()) +} + +/// No semantic fingerprint is a stale guard: an AST shape stays put when a +/// literal changes, and a behavior fingerprint stays put when a comment does. +#[tokio::test] +async fn entity_restore_refuses_a_later_token_edit_and_any_semantic_fingerprint_guard() { + let (state, _, _) = entity_keep_fixture().await; + let read = entity_keep_live(&state, "beta").unwrap(); + let later = ENTITY_KEEP_WORK.replace("return 20", "return 30"); + std::fs::write(state.layout.working_dir().join(ENTITY_KEEP_FILE), &later).unwrap(); + crate::loop_runner::sync_filesystem_with_graph_under_coordination(&state) + .await + .unwrap(); + let current = entity_keep_live(&state, "beta").unwrap(); + assert_eq!(read.id, current.id); + assert_eq!(read.fingerprint.ast_hash, current.fingerprint.ast_hash); + assert_ne!( + read.fingerprint.behavior_hash, + current.fingerprint.behavior_hash + ); + let head = entity_keep_head_change(&state); + for (expected, message) in [ + ( + read.fingerprint.behavior_hash, + "beta changed since it was read, so nothing was restored.".to_string(), + ), + (read.fingerprint.ast_hash, entity_keep_inexact("beta")), + (current.fingerprint.behavior_hash, entity_keep_inexact("beta")), + ] { + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(&read.id.to_string(), None, Some(&expected.to_string())), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{answer}"); + assert_eq!(answer["message"], message); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), later); + assert_eq!(entity_keep_head_change(&state), head); + } + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body( + ¤t.id.to_string(), + None, + Some(&entity_keep_exact(&state, "beta")), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!( + entity_keep_disk(&state, ENTITY_KEEP_FILE), + ENTITY_KEEP_WORK.replace("return 20", "return 2") + ); +} + +/// The step a working review reports for `name`, as the local app reads it. +async fn entity_keep_read_step(state: &Arc, name: &str) -> serde_json::Value { + let guide = entity_keep_guide(state, None).await; + guide["chapters"] + .as_array() + .unwrap() + .iter() + .flat_map(|chapter| chapter["steps"].as_array().unwrap()) + .find(|step| step["name"] == name) + .unwrap_or_else(|| panic!("the working review reports {name}: {guide}")) + .clone() +} + +/// A reviewer reads an entity, then its source changes in a way no semantic +/// fingerprint shows: a comment, or spacing inside it. Restoring from that +/// reading would discard what was never read, so it is refused and nothing +/// is written, whether the reading is named by its exact source or by its +/// behavior fingerprint. A literal edit is refused the same way. Read again, +/// the restore lands, and the same operation sent again replays. +#[tokio::test] +async fn entity_restore_refuses_comment_spacing_and_literal_edits_after_the_read_then_replays() { + let (state, _, _) = entity_keep_fixture().await; + let head = entity_keep_head_change(&state); + let step = entity_keep_read_step(&state, "beta").await; + let entity = step["entity_id"].as_str().unwrap().to_string(); + let body_hash = step["after"]["body_hash"].as_str().unwrap(); + assert_eq!( + format!("source:{body_hash}"), + entity_keep_exact(&state, "beta"), + "the review reports the exact span the restore checks" + ); + let exact = format!("source:{body_hash}"); + let behavior = step["evidence"]["fingerprint_after"]["behavior"] + .as_str() + .unwrap() + .to_string(); + + for (later, what) in [ + ( + ENTITY_KEEP_WORK.replace("def beta():\n", "def beta():\n # explained later\n"), + "a comment", + ), + ( + ENTITY_KEEP_WORK.replace("return 20", "return 20"), + "spacing inside the entity", + ), + ( + ENTITY_KEEP_WORK.replace("return 20", "return 21"), + "a literal", + ), + ] { + std::fs::write(state.layout.working_dir().join(ENTITY_KEEP_FILE), &later).unwrap(); + crate::loop_runner::sync_filesystem_with_graph_under_coordination(&state) + .await + .unwrap(); + let semantic_only = what != "a literal"; + assert_eq!( + entity_keep_live(&state, "beta") + .unwrap() + .fingerprint + .behavior_hash + .to_string() + == behavior, + semantic_only, + "{what} leaves the behavior fingerprint as it was: {semantic_only}" + ); + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(&entity, None, Some(&exact)), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{what}: {answer}"); + assert_eq!( + answer["message"], "beta changed since it was read, so nothing was restored.", + "{what}" + ); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), later, "{what}"); + assert_eq!(entity_keep_head_change(&state), head, "{what}"); + + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(&entity, None, Some(&behavior)), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{what}: {answer}"); + let expected_sentence = if semantic_only { + entity_keep_inexact("beta") + } else { + "beta changed since it was read, so nothing was restored.".to_string() + }; + assert_eq!(answer["message"], expected_sentence, "{what}"); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), later, "{what}"); + assert_eq!(entity_keep_head_change(&state), head, "{what}"); + } + + // Read again, the restore lands, and the same operation replays without + // writing again, even though the reading it names is no longer current. + let commented = + ENTITY_KEEP_WORK.replace("def beta():\n", "def beta():\n # explained later\n"); + std::fs::write(state.layout.working_dir().join(ENTITY_KEEP_FILE), &commented).unwrap(); + crate::loop_runner::sync_filesystem_with_graph_under_coordination(&state) + .await + .unwrap(); + let fresh = entity_keep_read_step(&state, "beta").await; + let fresh = format!("source:{}", fresh["after"]["body_hash"].as_str().unwrap()); + assert_ne!(fresh, exact); + let restore = entity_keep_restore_body(&entity, None, Some(&fresh)); + let (status, answer) = + entity_keep_post(&state, "/commands/entity/restore", restore.clone()).await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!(answer["replayed"], false, "{answer}"); + let restored = ENTITY_KEEP_WORK.replace("return 20", "return 2"); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), restored); + let (status, replay) = entity_keep_post(&state, "/commands/entity/restore", restore).await; + assert_eq!(status, StatusCode::OK, "{replay}"); + assert_eq!(replay["replayed"], true, "{replay}"); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), restored); + assert_eq!(entity_keep_head_change(&state), head, "a restore records no change"); +} + +#[tokio::test] +async fn entity_restore_checks_exact_source_and_preserves_an_unrelated_later_edit() { + let (state, _, _) = entity_keep_fixture().await; + let read = entity_keep_live(&state, "beta").unwrap(); + let span = read.span.as_ref().unwrap(); + let expected = format!( + "source:{}", + kin_blobs::digest(&ENTITY_KEEP_WORK.as_bytes()[span.start_byte..span.end_byte]) + ); + let later = ENTITY_KEEP_WORK.replace("def beta():\n", "def beta():\n # newly explained\n"); + std::fs::write(state.layout.working_dir().join(ENTITY_KEEP_FILE), &later).unwrap(); + crate::loop_runner::sync_filesystem_with_graph_under_coordination(&state) + .await + .unwrap(); + let current = entity_keep_live(&state, "beta").unwrap(); + assert_eq!( + read.fingerprint.behavior_hash, + current.fingerprint.behavior_hash + ); + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(&read.id.to_string(), None, Some(&expected)), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{answer}"); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), later); + + // Only a sibling moves after the read. Its edit must not stale this read, + // and the restore must preserve it exactly. + let later = ENTITY_KEEP_WORK.replace("return 10", "return 99"); + std::fs::write(state.layout.working_dir().join(ENTITY_KEEP_FILE), &later).unwrap(); + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(&read.id.to_string(), None, Some(&expected)), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!( + entity_keep_disk(&state, ENTITY_KEEP_FILE), + later.replace("return 20", "return 2") + ); +} + +/// Undo one entity: its uncommitted change goes, as an uncommitted edit, and +/// nothing else in the working copy moves. A removed entity comes back, an +/// added one goes, and a stale read is refused. +#[tokio::test] +async fn entity_restore_undoes_one_entity_and_round_trips() { + let (state, base, gamma) = entity_keep_fixture().await; + let head = entity_keep_head_change(&state); + let beta = entity_keep_live(&state, "beta").unwrap(); + + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(&beta.id.to_string(), None, Some("stale-fingerprint")), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{answer}"); + assert_eq!( + answer["message"], + "beta changed since it was read, so nothing was restored." + ); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), ENTITY_KEEP_WORK); + + let restore_beta = entity_keep_restore_body( + &beta.id.to_string(), + None, + Some(&entity_keep_exact(&state, "beta")), + ); + let (status, answer) = + entity_keep_post(&state, "/commands/entity/restore", restore_beta.clone()).await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!(answer["restored_to"], head.to_string()); + assert_eq!(answer["replayed"], false); + // Sent again, the same operation answers that it landed and writes nothing. + let (status, replay) = entity_keep_post(&state, "/commands/entity/restore", restore_beta).await; + assert_eq!(status, StatusCode::OK, "{replay}"); + assert_eq!(replay["replayed"], true, "{replay}"); + let restored = ENTITY_KEEP_WORK.replace("return 20", "return 2"); + assert_eq!( + entity_keep_disk(&state, ENTITY_KEEP_FILE), + restored, + "beta is back at the head's version and nothing else moved" + ); + assert_eq!( + entity_keep_head_change(&state), + head, + "a restore records no change" + ); + let (entities, _) = entity_keep_uncommitted(&state); + assert_eq!( + entities, + ["alpha", "delta", "gamma"] + .into_iter() + .map(String::from) + .collect::>() + ); + assert_eq!( + entity_keep_live(&state, "beta").unwrap().id, + beta.id, + "the restored entity keeps its identity" + ); + + // The removed entity comes back, after the entity written in its place. + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(&gamma, Some(&base.to_string()), Some("absent")), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + let returned = format!("{restored}\n\ndef gamma():\n return 3\n"); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), returned); + assert_eq!( + entity_keep_live(&state, "gamma").unwrap().id.to_string(), + gamma + ); + + // The added entity goes. + let delta = entity_keep_id(&state, "delta"); + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(&delta, None, None), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert!(entity_keep_live(&state, "delta").is_none()); + let (entities, _) = entity_keep_uncommitted(&state); + assert_eq!( + entities, + ["alpha"] + .into_iter() + .map(String::from) + .collect::>(), + "after three restores only alpha's change is left" + ); + + // Restoring what already matches is refused, and writes nothing. + let unchanged = entity_keep_disk(&state, ENTITY_KEEP_FILE); + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(&beta.id.to_string(), None, None), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{answer}"); + assert_eq!( + answer["message"], + "beta already matches that change, so there is nothing to restore." + ); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), unchanged); + + // The unparsed entity is restored whole. + let notes = entity_keep_notes_id(&state); + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body(¬es, None, None), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_NOTES), "first draft\n"); +} + +#[tokio::test] +async fn entity_restore_replay_uses_its_recorded_base_after_the_head_advances() { + let (state, original_head, _) = entity_keep_fixture().await; + let beta = entity_keep_id(&state, "beta"); + let restore_beta = entity_keep_restore_body(&beta, None, None); + let (status, answer) = + entity_keep_post(&state, "/commands/entity/restore", restore_beta.clone()).await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!(answer["restored_to"], original_head.to_string()); + + post_command_commit(&state, "record the remaining work").await; + let advanced_head = entity_keep_head_change(&state); + assert_ne!(advanced_head, original_head); + let later_work = entity_keep_disk(&state, ENTITY_KEEP_FILE) + .replace("def beta():\n return 2", "def beta():\n return 200"); + std::fs::write( + state.layout.working_dir().join(ENTITY_KEEP_FILE), + &later_work, + ) + .unwrap(); + let (status, replay) = entity_keep_post(&state, "/commands/entity/restore", restore_beta).await; + assert_eq!(status, StatusCode::OK, "{replay}"); + assert_eq!(replay["replayed"], true, "{replay}"); + assert_eq!( + replay["restored_to"], + original_head.to_string(), + "a replay reports the original implicit target rather than today's head" + ); + assert_eq!(entity_keep_head_change(&state), advanced_head); + assert_eq!( + entity_keep_disk(&state, ENTITY_KEEP_FILE), + later_work, + "replaying an old restore leaves a later edit intact" + ); + + let alpha = entity_keep_id(&state, "alpha"); + let explicit = entity_keep_restore_body(&alpha, Some(&original_head.to_string()), None); + let (status, answer) = + entity_keep_post(&state, "/commands/entity/restore", explicit.clone()).await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!(answer["restored_to"], original_head.to_string()); + let restored_work = entity_keep_disk(&state, ENTITY_KEEP_FILE); + let (status, replay) = entity_keep_post(&state, "/commands/entity/restore", explicit).await; + assert_eq!(status, StatusCode::OK, "{replay}"); + assert_eq!(replay["replayed"], true, "{replay}"); + assert!( + replay["restored_to"].is_null(), + "a receipt does not prove an explicit restore's target" + ); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), restored_work); +} + +async fn entity_keep_guide(state: &Arc, change: Option) -> serde_json::Value { + let response = router(Arc::clone(state)) + .oneshot( + Request::post("/review") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({"op":"guide", "json":true, "working":change.is_none(), "change":change}) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + let answer: kin_cli::commands::review::ReviewResponse = serde_json::from_slice(&body).unwrap(); + serde_json::from_str(&answer.json.unwrap()).unwrap() +} + +fn entity_keep_authored_steps(guide: &serde_json::Value) -> std::collections::BTreeSet { + guide["chapters"] + .as_array() + .unwrap() + .iter() + .flat_map(|chapter| chapter["steps"].as_array().unwrap()) + .filter_map(|step| { + let authors = step["authored_by"].as_array()?; + assert_eq!(authors.len(), 1, "{step}"); + assert_eq!(authors[0]["name"], "Claude Code", "{step}"); + assert_eq!(authors[0]["session"], "client-session-1", "{step}"); + assert_eq!(authors[0]["through"], "client", "{step}"); + Some(step["name"].as_str().unwrap().to_string()) + }) + .collect() +} + +async fn entity_keep_client_authorship_round_trip(delayed: bool) { + let state = test_state(); + let root = state.layout.working_dir().to_path_buf(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join(ENTITY_KEEP_FILE), ENTITY_KEEP_HEAD).unwrap(); + post_command_commit(&state, "the original entities").await; + let original_base = entity_keep_head_change(&state); + activity_open_session(&state).await; + let unit = root.join(ENTITY_KEEP_FILE); + let prepared = + activity_edit_hook(&state, &unit, ENTITY_KEEP_HEAD, ENTITY_KEEP_WORK, "before").await; + assert_eq!(prepared["outcome"], "prepared", "{prepared}"); + std::fs::write(&unit, ENTITY_KEEP_WORK).unwrap(); + crate::loop_runner::sync_filesystem_with_graph(&state) + .await + .unwrap(); + if !delayed { + let claimed = activity_report_edit(&state, &unit, ENTITY_KEEP_HEAD, ENTITY_KEEP_WORK).await; + assert_eq!(claimed["outcome"], "attributed", "{claimed}"); + assert_eq!(claimed["entities"], 4, "{claimed}"); + } + let alpha = entity_keep_id(&state, "alpha"); + let request = entity_keep_commit_body(&[alpha], kin_model::OperationId::new()); + let (status, kept) = + entity_keep_post(&state, "/commands/commit/entities", request.clone()).await; + assert_eq!(status, StatusCode::OK, "{kept}"); + let first_change = kept["change_id"].as_str().unwrap().to_string(); + if delayed { + let claimed = activity_report_edit(&state, &unit, ENTITY_KEEP_HEAD, ENTITY_KEEP_WORK).await; + assert_eq!(claimed["outcome"], "attributed", "{claimed}"); + assert_eq!(claimed["entities"], 4, "{claimed}"); + } + let edits = state.agent_authorship.edits(); + assert_eq!(edits.len(), 2, "{edits:?}"); + let sealed = edits + .iter() + .find(|edit| edit.change_id.as_ref() == Some(&first_change)) + .unwrap(); + assert_eq!(sealed.base, Some(original_base.to_string())); + assert_eq!(sealed.entities.len(), 1); + let leftover = edits.iter().find(|edit| edit.change_id.is_none()).unwrap(); + assert_eq!(leftover.base.as_ref(), Some(&first_change)); + assert_eq!(leftover.entities.len(), 3); + let (status, replayed) = entity_keep_post(&state, "/commands/commit/entities", request).await; + assert_eq!(status, StatusCode::OK, "{replayed}"); + assert_eq!( + state.agent_authorship.edits(), + edits, + "a replay duplicates no attribution" + ); + let names = |items: &[&str]| { + items + .iter() + .map(|name| name.to_string()) + .collect::>() + }; + assert_eq!( + entity_keep_authored_steps(&entity_keep_guide(&state, Some(first_change.clone())).await), + names(&["alpha"]) + ); + assert_eq!( + entity_keep_authored_steps(&entity_keep_guide(&state, None).await), + names(&["beta", "delta", "gamma"]) + ); + + // Another chosen commit takes only beta and leaves the remaining two + // claims uncommitted on the new base without changing their attribution. + let beta = entity_keep_id(&state, "beta"); + let (status, kept) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body(&[beta], kin_model::OperationId::new()), + ) + .await; + assert_eq!(status, StatusCode::OK, "{kept}"); + let second_change = kept["change_id"].as_str().unwrap().to_string(); + assert_eq!( + entity_keep_authored_steps(&entity_keep_guide(&state, Some(second_change.clone())).await), + names(&["beta"]) + ); + assert_eq!( + entity_keep_authored_steps(&entity_keep_guide(&state, None).await), + names(&["delta", "gamma"]) + ); + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), ENTITY_KEEP_WORK); + let recorded = state.agent_authorship.edits(); + // Attribution is repository authority once written, which a daemon does + // as its agents go quiet and before it stops; a daemon that reopens the + // store reads it back from there. + crate::agent_authorship::settle(&state, true).await; + let reopened = Arc::new(DaemonState::open(state.layout.clone()).unwrap()); + reopened + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + assert_eq!(reopened.agent_authorship.edits(), recorded); + assert_eq!( + entity_keep_authored_steps(&entity_keep_guide(&reopened, Some(first_change)).await), + names(&["alpha"]) + ); + assert_eq!( + entity_keep_authored_steps(&entity_keep_guide(&reopened, Some(second_change)).await), + names(&["beta"]) + ); + assert_eq!( + entity_keep_authored_steps(&entity_keep_guide(&reopened, None).await), + names(&["delta", "gamma"]) + ); +} + +#[tokio::test] +#[serial_test::serial] +async fn entity_keep_preserves_client_authorship_for_committed_and_leftover_entities_after_reopen() +{ + entity_keep_client_authorship_round_trip(false).await; +} + +#[tokio::test] +#[serial_test::serial] +async fn entity_keep_attributes_a_delayed_client_hook_to_both_chosen_and_leftover_work() { + entity_keep_client_authorship_round_trip(true).await; +} + +const ENTITY_KEEP_CLASS_FILE: &str = "src/cart.py"; +const ENTITY_KEEP_CLASS_HEAD: &str = "class Cart:\n def first(self):\n return 1\n\n def second(self):\n return 2\n"; +const ENTITY_KEEP_CLASS_WORK: &str = "class Cart:\n def first(self):\n return 10\n\n def second(self):\n return 20\n\n def third(self):\n return 3\n"; +const ENTITY_KEEP_FRESH_FILE: &str = "src/fresh.py"; +const ENTITY_KEEP_FRESH_WORK: &str = + "import os\n\n\ndef one():\n return 1\n\n\ndef two():\n return 2\n"; + +/// The live entity whose name is `name`, or ends in `.name`, in `path`. +fn entity_keep_member(state: &Arc, path: &str, name: &str) -> kin_model::Entity { + state + .graph + .query_entities(&kin_model::EntityFilter { + file_path: Some(kin_model::FilePathId::new(path)), + ..Default::default() + }) + .unwrap() + .into_iter() + .find(|entity| entity.name == name || entity.name.ends_with(&format!(".{name}"))) + .unwrap_or_else(|| panic!("{name} is held in the working copy")) +} + +/// One member of a container is kept while its sibling's edit stays. Content +/// no commit holds yet is kept whole or not at all. +#[tokio::test] +async fn entity_keep_keeps_one_member_of_a_container_and_new_content_whole() { + let state = test_state(); + let root = state.layout.working_dir().to_path_buf(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join(ENTITY_KEEP_CLASS_FILE), ENTITY_KEEP_CLASS_HEAD).unwrap(); + post_command_commit(&state, "a container with two members").await; + std::fs::write(root.join(ENTITY_KEEP_CLASS_FILE), ENTITY_KEEP_CLASS_WORK).unwrap(); + std::fs::write(root.join(ENTITY_KEEP_FRESH_FILE), ENTITY_KEEP_FRESH_WORK).unwrap(); + crate::loop_runner::sync_filesystem_with_graph_under_coordination(&state) + .await + .unwrap(); + let first = entity_keep_member(&state, ENTITY_KEEP_CLASS_FILE, "first"); + let third = entity_keep_member(&state, ENTITY_KEEP_CLASS_FILE, "third"); + let one = entity_keep_member(&state, ENTITY_KEEP_FRESH_FILE, "one"); + let two = entity_keep_member(&state, ENTITY_KEEP_FRESH_FILE, "two"); + let (status, answer) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body( + &[first.id.to_string(), third.id.to_string()], + kin_model::OperationId::new(), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!( + String::from_utf8(entity_keep_head_bytes(&state, ENTITY_KEEP_CLASS_FILE).unwrap()).unwrap(), + "class Cart:\n def first(self):\n return 10\n\n def second(self):\n return 2\n\n def third(self):\n return 3\n", + "the container holds the kept members' changes and the other member as it was" + ); + assert_eq!( + entity_keep_disk(&state, ENTITY_KEEP_CLASS_FILE), + ENTITY_KEEP_CLASS_WORK + ); + assert!(entity_keep_head_bytes(&state, ENTITY_KEEP_FRESH_FILE).is_none()); + let leftover = |state: &Arc| { + entity_keep_uncommitted(state) + .0 + .iter() + .map(|name| name.rsplit('.').next().unwrap().to_string()) + .collect::>() + }; + assert_eq!( + leftover(&state), + ["Cart", "one", "second", "two"] + .into_iter() + .map(String::from) + .collect::>(), + "what was not chosen is what is left, with the container that still holds it" + ); + + // One entity of content the head never held cannot be kept alone. + let head = entity_keep_head_change(&state); + let (status, answer) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body(&[two.id.to_string()], kin_model::OperationId::new()), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{answer}"); + assert_eq!( + answer["message"], + "two and one are in content no commit holds yet, which Kin commits whole, so it cannot \ + commit one without the other." + ); + assert_eq!(entity_keep_head_change(&state), head); + + // Both of its entities keep it whole, the import no entity holds included. + let (status, answer) = entity_keep_post( + &state, + "/commands/commit/entities", + entity_keep_commit_body( + &[one.id.to_string(), two.id.to_string()], + kin_model::OperationId::new(), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!( + String::from_utf8(entity_keep_head_bytes(&state, ENTITY_KEEP_FRESH_FILE).unwrap()).unwrap(), + ENTITY_KEEP_FRESH_WORK + ); + assert_eq!( + leftover(&state), + ["Cart", "second"] + .into_iter() + .map(String::from) + .collect::>() + ); + assert_eq!( + entity_keep_member(&state, ENTITY_KEEP_FRESH_FILE, "two").id, + two.id, + "a kept entity keeps the identity it had in the working copy" + ); + + // The sibling's edit is undone alone, and the kept members stay kept. + let second = entity_keep_member(&state, ENTITY_KEEP_CLASS_FILE, "second"); + let (status, answer) = entity_keep_post( + &state, + "/commands/entity/restore", + entity_keep_restore_body( + &second.id.to_string(), + None, + Some(&entity_keep_exact_source(&state, &second)), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!( + entity_keep_disk(&state, ENTITY_KEEP_CLASS_FILE), + ENTITY_KEEP_CLASS_WORK.replace("return 20", "return 2") + ); + assert!(leftover(&state).is_empty()); +} + +const ENTITY_KEEP_AGENT_FILE: &str = "src/agent.py"; +const ENTITY_KEEP_AGENT_HEAD: &str = "def tally():\n return 1\n"; + +/// Each step of a guide, by entity name, with the names of whoever it says +/// wrote that entity. +fn entity_keep_step_authors( + guide: &serde_json::Value, +) -> std::collections::BTreeMap> { + guide["chapters"] + .as_array() + .unwrap() + .iter() + .flat_map(|chapter| chapter["steps"].as_array().unwrap()) + .map(|step| { + let authors = step["authored_by"] + .as_array() + .map(|authors| { + authors + .iter() + .map(|author| author["name"].as_str().unwrap_or_default().to_string()) + .collect() + }) + .unwrap_or_default(); + (step["name"].as_str().unwrap().to_string(), authors) + }) + .collect() +} + +/// An agent's commit over MCP records what its operation wrote and nothing +/// else. A person's uncommitted entities in another unit, attributed to the +/// client session that made them, stay uncommitted against the agent's new +/// change, keep that attribution, and are still what the guide of uncommitted +/// work walks; the agent's change names only the entity the agent wrote. +#[tokio::test] +#[serial_test::serial] +async fn entity_keep_an_agent_commit_leaves_uncommitted_work_and_its_authorship_in_place() { + let state = test_state(); + let root = state.layout.working_dir().to_path_buf(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join(ENTITY_KEEP_FILE), ENTITY_KEEP_HEAD).unwrap(); + std::fs::write(root.join(ENTITY_KEEP_AGENT_FILE), ENTITY_KEEP_AGENT_HEAD).unwrap(); + post_command_commit(&state, "the original work").await; + let original_base = entity_keep_head_change(&state); + + // The person's edits, reported by the client that made them. + activity_open_session(&state).await; + let unit = root.join(ENTITY_KEEP_FILE); + let prepared = + activity_edit_hook(&state, &unit, ENTITY_KEEP_HEAD, ENTITY_KEEP_WORK, "before").await; + assert_eq!(prepared["outcome"], "prepared", "{prepared}"); + std::fs::write(&unit, ENTITY_KEEP_WORK).unwrap(); + crate::loop_runner::sync_filesystem_with_graph(&state) + .await + .unwrap(); + let claimed = activity_report_edit(&state, &unit, ENTITY_KEEP_HEAD, ENTITY_KEEP_WORK).await; + assert_eq!(claimed["outcome"], "attributed", "{claimed}"); + let (uncommitted, _) = entity_keep_uncommitted(&state); + let persons = ["alpha", "beta", "delta", "gamma"] + .into_iter() + .map(String::from) + .collect::>(); + assert_eq!(uncommitted, persons, "the person's uncommitted work"); + assert_eq!( + entity_keep_authored_steps(&entity_keep_guide(&state, None).await), + persons + ); + + // An agent changes tally through MCP, in a unit nobody else touched. + let tally = entity_keep_member(&state, ENTITY_KEEP_AGENT_FILE, "tally"); + let session = mcp_test_session(&state); + let read = entity_patch_read(&state, &serde_json::json!(tally.id)).await; + let result = mutate_http( + &state, + "kin_mutate", + serde_json::json!({ + "session_id": session, + "request_id": "entity-keep-agent-commit", + "operations": [entity_patch_operation_public(&read, &[("return 1", "return 2")])], + "summary": "Return two from tally", + }), + &session, + ) + .await; + assert_ne!(result.is_error, Some(true), "{}", mcp_result_text(&result)); + + // The change is the base change plus the agent's unit. + let agent_change = entity_keep_head_change(&state); + assert_ne!(agent_change, original_base); + assert_eq!( + String::from_utf8(entity_keep_head_bytes(&state, ENTITY_KEEP_AGENT_FILE).unwrap()).unwrap(), + "def tally():\n return 2\n" + ); + assert_eq!( + String::from_utf8(entity_keep_head_bytes(&state, ENTITY_KEEP_FILE).unwrap()).unwrap(), + ENTITY_KEEP_HEAD, + "the agent's change holds none of the person's uncommitted work" + ); + let change = kin_model::ChangeStore::get_change(state.graph.as_ref(), &agent_change) + .unwrap() + .unwrap(); + assert!( + change.entity_deltas.iter().all(|delta| { + delta + .new_state() + .or(delta.old_state()) + .and_then(|entity| entity.file_origin.as_ref()) + .is_some_and(|origin| origin.0 == ENTITY_KEEP_AGENT_FILE) + }), + "the agent's change holds only entities of the unit its operation wrote: {:?}", + change.entity_deltas + ); + let guide = entity_keep_step_authors(&entity_keep_guide(&state, Some(agent_change.to_string())).await); + assert_eq!( + guide.keys().cloned().collect::>(), + vec!["tally".to_string()], + "the agent's change walks only the entity the agent wrote" + ); + assert!( + guide.values().flatten().all(|author| author != "Claude Code"), + "nothing in the agent's change is attributed to the person's client: {guide:?}" + ); + + // The person's work is untouched, uncommitted and still theirs. + assert_eq!(entity_keep_disk(&state, ENTITY_KEEP_FILE), ENTITY_KEEP_WORK); + assert_eq!( + entity_keep_disk(&state, ENTITY_KEEP_AGENT_FILE), + "def tally():\n return 2\n" + ); + assert_eq!(entity_keep_uncommitted(&state).0, persons); + let edits = state.agent_authorship.edits(); + assert_eq!(edits.len(), 1, "{edits:?}"); + assert_eq!(edits[0].change_id, None, "{edits:?}"); + assert_eq!(edits[0].base, Some(agent_change.to_string()), "{edits:?}"); + assert_eq!(edits[0].entities.len(), 4, "{edits:?}"); + assert_eq!( + entity_keep_authored_steps(&entity_keep_guide(&state, None).await), + persons, + "the guide of uncommitted work still walks the person's entities under their author" + ); + + // Repository authority keeps the whole working copy, as a reopen reads it. + let authority = ActiveApiRepositoryAuthority::open(&state).unwrap(); + let lease = authority.manager.read_authority(); + let persisted = lease + .workspace_graph_snapshot(&authority.workspace_id) + .unwrap() + .unwrap(); + let live = state.graph.to_snapshot(); + assert_eq!(persisted.resolved_tree, live.resolved_tree); + assert_eq!(persisted.entities, live.entities); + assert_eq!(persisted.relations, live.relations); +} diff --git a/crates/kin-daemon/src/api/tests/mcp_mutate_durability.rs b/crates/kin-daemon/src/api/tests/mcp_mutate_durability.rs index 13626ca6c..223ffb124 100644 --- a/crates/kin-daemon/src/api/tests/mcp_mutate_durability.rs +++ b/crates/kin-daemon/src/api/tests/mcp_mutate_durability.rs @@ -70,6 +70,7 @@ async fn mcp_mutate_keyed_retry_recovers_original_receipt_through_real_delegate( let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let mut env = kin_core::test_env::EnvVarGuard::set("KIN_DAEMON_URL", format!("http://{addr}")); env.apply("KIN_DAEMON_AUTH_TOKEN", Some("mutate-test-token")); + let _repository_binding = LocalMcpRepositoryBinding::new(&state.layout); let args: HashMap = serde_json::from_value(request).unwrap(); let first = kin_mcp::handlers::sessions::mutate_through_daemon(&args) .await @@ -898,6 +899,7 @@ async fn mcp_mutate_pending_restart_resumes_original_owner_through_real_mcp_dele let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let mut env = kin_core::test_env::EnvVarGuard::set("KIN_DAEMON_URL", format!("http://{addr}")); env.apply("KIN_DAEMON_AUTH_TOKEN", Some("mutate-test-token")); + let _repository_binding = LocalMcpRepositoryBinding::new(&reopened.layout); let args: HashMap = serde_json::from_value(args).unwrap(); let expired = kin_mcp::handlers::sessions::mutate_through_daemon(&args) .await diff --git a/crates/kin-daemon/src/api/tests/merge_parse_coverage.rs b/crates/kin-daemon/src/api/tests/merge_parse_coverage.rs index 4a744653f..7e097a0cb 100644 --- a/crates/kin-daemon/src/api/tests/merge_parse_coverage.rs +++ b/crates/kin-daemon/src/api/tests/merge_parse_coverage.rs @@ -218,7 +218,13 @@ async fn body_bound_parse_certificates_do_not_become_authored_merge_conflicts() )), "the verified body-bound certificate is derived from the settled body" ); - assert!(entries.iter().all(|entry| !matches!(entry.subject, kin_model::MergeConflictSubject::Relation { .. })), "derived source certificates must not become independently authored conflicts: {entries:#?}"); + assert!( + entries.iter().all(|entry| !matches!( + entry.subject, + kin_model::MergeConflictSubject::Relation { .. } + )), + "derived source certificates must not become independently authored conflicts: {entries:#?}" + ); for action in [ kin_cli::commands::resolve::ResolveAction::Settle { directives: vec![], diff --git a/crates/kin-daemon/src/api/tests/review_read_runtime.rs b/crates/kin-daemon/src/api/tests/review_read_runtime.rs new file mode 100644 index 000000000..37f52aa18 --- /dev/null +++ b/crates/kin-daemon/src/api/tests/review_read_runtime.rs @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +// A review read and the runtime worker it runs on. Included into +// `api::tests`. + +/// Clears the review read pause however the case ends, so a failing assertion +/// cannot leave a pause armed for an address a later allocation might reuse. +struct ReviewReadPauseGuard; + +impl Drop for ReviewReadPauseGuard { + fn drop(&mut self) { + *REVIEW_READ_PAUSE_FOR_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } +} + +/// A review read does not hold the runtime worker it runs on, so the daemon +/// keeps answering while a long guide builds. +/// +/// A multi-thread runtime with one worker, a review read held open for two +/// seconds through the test pause, and a health request sent while it is +/// held. The request can only be answered on a worker: if the read kept its +/// worker, the request waits the pause out; handed off, it is answered at +/// once. Measured before this on zod, whose guide of one committed change +/// built for 50 s: every request to its daemon, health included, waited the +/// build out, and the entity page's read of references ran out its 20 s and +/// said Kin could not read them, while the repository was reading its older +/// history and drew the blame. +/// +/// Breaking it: run the review build inline on its worker again, and the +/// health request waits for the whole pause. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn a_review_read_does_not_hold_the_runtime_worker_it_runs_on() { + let state = test_state(); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let _guard = ReviewReadPauseGuard; + *REVIEW_READ_PAUSE_FOR_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some((Arc::as_ptr(&state) as usize, Duration::from_secs(2))); + let reviewing = { + let state = Arc::clone(&state); + tokio::spawn(async move { + review_call(&state, serde_json::json!({ "op": "list", "state": null })).await + }) + }; + + // The test's own thread is blocked on purpose: it is not a runtime worker, + // and a runtime timer would need the very worker this case is about. + std::thread::sleep(Duration::from_millis(400)); + let asked = std::time::Instant::now(); + let health = { + let app = router(Arc::clone(&state)); + tokio::spawn(async move { + let response = app + .oneshot(Request::get("/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + (response.status(), std::time::Instant::now()) + }) + }; + let (status, answered) = health.await.unwrap(); + let waited = answered.saturating_duration_since(asked); + let listed = reviewing.await.unwrap(); + + assert!( + listed.text.contains("0 review(s)") || listed.text.contains("No reviews"), + "the held read must still answer, or this case measures a failure: {}", + listed.text + ); + assert_eq!(status, StatusCode::OK); + assert!( + waited < Duration::from_millis(1000), + "a health request sent while a review read was built waited {waited:?} for an \ + answer, so the review read is holding the runtime worker it runs on" + ); +} + +/// Graph-authority writers coming and going until stopped, each held for a +/// moment, the way passes over the working copy come while a repository's +/// watcher drops events: each takes authority and, unless `writes_truth`, +/// finds nothing to change. +struct WriterChurn { + stop: Arc, + handle: tokio::task::JoinHandle, +} + +impl WriterChurn { + fn start(state: &Arc, writes_truth: bool) -> Self { + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let handle = { + let state = Arc::clone(state); + let stop = Arc::clone(&stop); + tokio::spawn(async move { + let mut writers = 0_u64; + while !stop.load(std::sync::atomic::Ordering::SeqCst) { + { + let _writer = state.begin_graph_authority_mutation(); + if writes_truth { + state + .graph + .upsert_entity(&test_entity( + &format!("churned_{writers}"), + "churn.py", + )) + .unwrap(); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + writers += 1; + tokio::time::sleep(Duration::from_millis(20)).await; + } + writers + }) + }; + Self { stop, handle } + } + + async fn stop(self) -> u64 { + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + self.handle.await.unwrap() + } +} + +/// POST a guide of the committed head and return the status and body. +async fn committed_head_guide(state: &Arc) -> (StatusCode, String) { + let response = router(Arc::clone(state)) + .oneshot( + Request::post("/review") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({"op": "guide", "json": true, "working": false}) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 16 * 1024 * 1024) + .await + .unwrap(); + (status, String::from_utf8_lossy(&body).into_owned()) +} + +/// A guide of a committed change is served while writers that change nothing +/// come and go throughout its build. +/// +/// The change, its history and the graph at that change never move, and a +/// writer that changes nothing leaves the graph's truth where it was. Measured +/// on zod while its watcher dropped events: a pass over the working copy took +/// graph authority every six to eight seconds and found nothing, a guide of +/// one commit took 20 s to build and was spanned by a pass on most attempts, +/// and the review page's guide did not load in four minutes. Here every +/// attempt is held open for 300 ms through the test pause while writers come +/// and go every 25 ms, under a 1.5 s limit. +/// +/// Breaking it: serve a guide only from an attempt no writer moved the +/// authority epoch across, as before, and it refuses with 503 and "a +/// graph-authority writer was mutating the graph", the refusal the page met. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_committed_change_guide_answers_while_writers_that_change_nothing_come_and_go() { + let (_repo, state) = refs_source_observation_fixture().await; + state.set_xref_writer_drain_ceiling_for_test(Duration::from_millis(1_500)); + let _guard = ReviewReadPauseGuard; + *REVIEW_READ_PAUSE_FOR_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some((Arc::as_ptr(&state) as usize, Duration::from_millis(300))); + let truth = state.graph.truth_epoch(); + + let churn = WriterChurn::start(&state, false); + let (status, body) = committed_head_guide(&state).await; + let writers = churn.stop().await; + + assert!(writers >= 5, "writers must span the read: {writers}"); + assert_eq!(state.graph.truth_epoch(), truth, "no writer changed truth"); + assert_eq!(status, StatusCode::OK, "{body}"); + let answer: kin_cli::commands::review::ReviewResponse = serde_json::from_str(&body).unwrap(); + let guide: serde_json::Value = serde_json::from_str(&answer.json.unwrap()).unwrap(); + assert_eq!(guide["kind"], "change", "{guide}"); +} + +/// The guard still holds for a guide whose graph truth keeps moving: no +/// attempt reads a graph that held still, and the read refuses at its limit +/// with the in-flight write window named, never an answer read across writes. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_committed_change_guide_is_refused_while_writes_to_truth_keep_landing() { + let (_repo, state) = refs_source_observation_fixture().await; + state.set_xref_writer_drain_ceiling_for_test(Duration::from_millis(1_500)); + let _guard = ReviewReadPauseGuard; + *REVIEW_READ_PAUSE_FOR_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some((Arc::as_ptr(&state) as usize, Duration::from_millis(300))); + + let churn = WriterChurn::start(&state, true); + let (status, body) = committed_head_guide(&state).await; + churn.stop().await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}"); + assert!( + body.contains(kin_mcp::negative::UNANSWERED_READ_PREFIX), + "the refusal names the write window: {body}" + ); +} + +/// A guide of a committed change is built once and kept while what it read +/// holds still. Measured under load, axum's guide of one fresh commit took 56 +/// to 66 s and zod's 30 s; the page gives a read a minute, so every retry of +/// axum's started again from nothing, and Home and the review page each built +/// zod's. The second read here is answered from the guide the first built. +/// +/// Breaking it: build every guide as it is asked, and the second read builds +/// again. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_committed_change_guide_is_built_once_and_read_again_from_what_was_kept() { + let (_repo, state) = refs_source_observation_fixture().await; + let (status, first) = committed_head_guide(&state).await; + assert_eq!(status, StatusCode::OK, "{first}"); + assert_eq!(state.committed_guides.builds(), 1); + + let asked = std::time::Instant::now(); + let (status, second) = committed_head_guide(&state).await; + assert_eq!(status, StatusCode::OK, "{second}"); + assert_eq!( + state.committed_guides.builds(), + 1, + "the second read built the guide again" + ); + assert_eq!(state.committed_guides.kept_served(), 1); + assert_eq!(second, first, "the kept guide is the guide that was built"); + assert!(asked.elapsed() < Duration::from_secs(5)); +} + +/// A read of a change whose guide is being built joins that build, and a read +/// whose caller stops waiting leaves the build running for the next one: the +/// page's retry after its minute is answered by the build its first attempt +/// started, and Home and the review page asking together cost one build. +/// +/// Breaking it: build on the request's own task, or start a build for every +/// read, and the count of builds is two or three. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_committed_change_guide_read_while_it_builds_joins_the_build() { + let (_repo, state) = refs_source_observation_fixture().await; + let _guard = ReviewReadPauseGuard; + *REVIEW_READ_PAUSE_FOR_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some((Arc::as_ptr(&state) as usize, Duration::from_millis(800))); + + // The first caller gives up long before the build ends. + let abandoned = tokio::time::timeout( + Duration::from_millis(150), + committed_head_guide(&state), + ) + .await; + assert!(abandoned.is_err(), "the held build answered at once"); + let together = { + let one = Arc::clone(&state); + let two = Arc::clone(&state); + tokio::join!( + tokio::spawn(async move { committed_head_guide(&one).await }), + tokio::spawn(async move { committed_head_guide(&two).await }), + ) + }; + let (one, two) = (together.0.unwrap(), together.1.unwrap()); + assert_eq!(one.0, StatusCode::OK, "{}", one.1); + assert_eq!(two.0, StatusCode::OK, "{}", two.1); + assert_eq!(one.1, two.1); + assert_eq!( + state.committed_guides.builds(), + 1, + "the abandoned read's build answered both reads after it" + ); +} + +/// A guide is never served for a repository that has moved: a write to the +/// graph's truth since it was built builds it again, and the guide built then +/// is the one kept. +/// +/// Breaking it: keep a guide by its change alone, and the read after the +/// write is answered from before it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_committed_change_guide_is_built_again_once_the_graph_moves() { + let (_repo, state) = refs_source_observation_fixture().await; + let (status, body) = committed_head_guide(&state).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(state.committed_guides.builds(), 1); + + let truth = state.graph.truth_epoch(); + { + let _writer = state.begin_graph_authority_mutation(); + state + .graph + .upsert_entity(&test_entity("written_since", "since.py")) + .unwrap(); + } + assert!(state.graph.truth_epoch() > truth); + let (status, body) = committed_head_guide(&state).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(state.committed_guides.builds(), 2); + let (status, _) = committed_head_guide(&state).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + state.committed_guides.builds(), + 2, + "the guide built after the write is kept" + ); +} diff --git a/crates/kin-daemon/src/api/tests/session_crash_prefix_test.rs b/crates/kin-daemon/src/api/tests/session_crash_prefix_test.rs index 3fd116117..20e9709fd 100644 --- a/crates/kin-daemon/src/api/tests/session_crash_prefix_test.rs +++ b/crates/kin-daemon/src/api/tests/session_crash_prefix_test.rs @@ -510,6 +510,16 @@ fn session_crash_prefix_child() { let session = PathBuf::from(configured(SESSION_ENV).expect("controlled session directory")); let control = configured_control().expect("controlled stop marker"); let root = PathBuf::from(repo); + let expected_home = root.join(".controlled-runtime/managed"); + assert_eq!( + kin_core::registry::managed_kin_home(), + expected_home, + "the controlled child must initialize only its own selected runtime home" + ); + // Match real daemon startup before opening any repository authority. The + // subprocess spawner selects this home but deliberately does not bind it. + kin_core::runtime_identity::RuntimeNamespace::initialize_local_service() + .expect("initialize the controlled child's selected runtime namespace"); let layout = kin_core::KinLayout::new(root.join(".kin")); let singleton = crate::lifecycle::acquire_singleton_lock(layout.root()) .unwrap() diff --git a/crates/kin-daemon/src/api/tests/worker_publication_fences.rs b/crates/kin-daemon/src/api/tests/worker_publication_fences.rs index e362a79bd..ac9634738 100644 --- a/crates/kin-daemon/src/api/tests/worker_publication_fences.rs +++ b/crates/kin-daemon/src/api/tests/worker_publication_fences.rs @@ -98,6 +98,103 @@ async fn worker_publication_verify_cancel_keeps_gate_until_proof_publication() { drop(released); } +/// A recorded run is repository authority before the live graph sees it, so +/// the working-copy admission after it still succeeds and a daemon that +/// reopens the store still holds the run. +/// +/// Written to the graph alone, the run left a relation from a run authority +/// had never seen. Admission checks every relation's ends against authority's +/// records, so each one after the run was refused with `unadmitted source +/// endpoint verification_run`, and the repository stopped following its own +/// working copy. Falsify by applying the record without committing it in +/// `verification_write::commit_verification_record`. +#[tokio::test] +async fn a_recorded_run_does_not_refuse_the_next_admission_and_survives_a_reopen() { + let (repo, state) = mcp_lifecycle_fixture(); + std::fs::write( + repo.path().join("source.py"), + "def verified():\n return 1\n", + ) + .unwrap(); + waiting_admit(&state, "verification baseline").await; + waiting_commit(&state, "verification baseline").await; + let entity = waiting_entity(&state, "source.py", "verified"); + + let response = router(Arc::clone(&state)) + .oneshot( + Request::post("/verify/run") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({"entity":"verified","runner":"true","depth":0}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let answer: kin_cli::commands::verify::VerifyRunResponse = + serde_json::from_slice(&body).unwrap(); + let run_id = answer + .lines + .iter() + .find_map(|line| line.trim().strip_prefix("Run ID:")) + .map(|id| { + kin_model::VerificationRunId::from_hash( + kin_model::Hash256::from_hex(id.trim()).expect("the run id is a hash"), + ) + }) + .expect("the answer names the run"); + let runs = state.graph.list_runs_proving_entity(&entity.id).unwrap(); + assert_eq!(runs.len(), 1, "the run proves the entity it ran for"); + assert_eq!(runs[0].run_id, run_id); + assert_eq!( + runs[0].ran_against, + [kin_model::RunFingerprint { + entity: entity.id, + behavior_hash: entity.fingerprint.behavior_hash, + }], + "the run is tied to the fingerprint it ran against" + ); + + // The working copy moves on, and the repository still follows it. + std::fs::write( + repo.path().join("source.py"), + "def verified():\n return 2\n", + ) + .unwrap(); + waiting_admit(&state, "after the recorded run").await; + let edited = waiting_entity(&state, "source.py", "verified"); + assert_ne!( + edited.fingerprint.behavior_hash, entity.fingerprint.behavior_hash, + "the admission after the run read the edit" + ); + + assert_eq!( + state.graph.list_runs_proving_entity(&entity.id).unwrap()[0].run_id, + run_id, + "a later admission retains the durable proof link" + ); + let layout = state.layout.clone(); + drop(state); + let reopened = DaemonState::open(layout).unwrap(); + let held = reopened + .graph + .get_verification_run(&run_id) + .unwrap() + .expect("a reopened daemon serves the run it answered with"); + assert_eq!(held.status, kin_model::VerificationStatus::Passing); + assert_eq!(held.ran_against.len(), 1); + assert_eq!( + reopened.graph.list_runs_proving_entity(&entity.id).unwrap(), + [held], + "a reopened daemon restores the proof link, not just the run record" + ); +} + #[tokio::test] async fn worker_publication_pull_cancel_keeps_gate_until_workspace_publication() { let baseline = b"services:\n api: { image: baseline }\n"; @@ -143,7 +240,10 @@ async fn worker_publication_pull_cancel_keeps_gate_until_workspace_publication() workspace.base_target, Some(kin_model::RefTarget::change(fixture.peer_head)) ); - assert!(gate_was_retained, "cancelling the request released coordination before the blocking workspace writer finished"); + assert!( + gate_was_retained, + "cancelling the request released coordination before the blocking workspace writer finished" + ); let released = tokio::time::timeout( Duration::from_secs(20), fixture.state.coordination_gate.lock(), diff --git a/crates/kin-daemon/src/api/tests/write_outcomes.rs b/crates/kin-daemon/src/api/tests/write_outcomes.rs new file mode 100644 index 000000000..cd14dda23 --- /dev/null +++ b/crates/kin-daemon/src/api/tests/write_outcomes.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +// What a write leaves in its session's calls when its caller stops waiting +// before it ends. Included into `api::tests`. + +/// The commit an agent's client gave up on. The client's patience ran out, +/// it closed its connection and its session ended, and the commit ran on +/// and landed. The session's calls name the begin, the stage and the +/// commit, and the commit says the change it published and that nobody +/// was waiting for it. +#[tokio::test] +async fn a_commit_that_lands_after_its_caller_left_is_kept_on_its_session() { + let state = test_state(); + let mut caller = test_entity("caller", "src/lib.rs"); + caller.file_origin = None; + caller.span = None; + let mut callee = test_entity("callee", "src/lib.rs"); + callee.file_origin = None; + callee.span = None; + install_repository_entities(&state, vec![caller.clone(), callee.clone()]); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let session_id = mcp_test_session(&state); + let session = SessionId(Uuid::parse_str(&session_id).unwrap()); + + let begin = mcp_call_as( + router(Arc::clone(&state)), + "kin_transaction_begin", + serde_json::json!({ "session_id": session_id, "scope": "file:src/lib.rs" }), + session, + ) + .await; + let begin_json: serde_json::Value = serde_json::from_str(&mcp_result_text(&begin)).unwrap(); + let tx_id = begin_json["transaction_id"].as_str().unwrap().to_string(); + let stage = mcp_call_as( + router(Arc::clone(&state)), + "kin_transaction_stage", + serde_json::json!({ + "transaction_id": tx_id, + "operations": [{ + "verb": "add", + "target": "", + "payload": { "Relation": { + "from": caller.id, "to": callee.id, "kind": RelationKind::Calls }}, + "description": "" + }] + }), + session, + ) + .await; + assert_ne!(stage.is_error, Some(true), "{}", mcp_result_text(&stage)); + + // The commit's answer is held back from its record long enough for + // the caller to give up and the session to end first. + let owner = Arc::as_ptr(&state) as usize; + *CALL_RECORD_PAUSE_FOR_TEST.lock().unwrap() = Some((owner, Duration::from_millis(800))); + let commit = router(Arc::clone(&state)).oneshot( + Request::post("/mcp/tools/call") + .header("content-type", "application/json") + .header("X-Kin-Session", session.to_string()) + .body(Body::from( + serde_json::json!({ + "name": "kin_transaction_commit", + "arguments": { "transaction_id": tx_id, "message": "Call the callee" } + }) + .to_string(), + )) + .unwrap(), + ); + assert!( + tokio::time::timeout(Duration::from_millis(150), commit) + .await + .is_err(), + "the commit answered before its caller gave up" + ); + state.coordinator.deregister_session(&session).unwrap(); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + let kept = loop { + let kept = state + .agent_authorship + .ended_session(&session_id, &Timestamp::now()) + .expect("the session that did work is kept once it ends"); + if kept + .calls + .iter() + .any(|call| call.tool == "kin_transaction_commit") + { + break kept; + } + assert!( + tokio::time::Instant::now() < deadline, + "the commit never reached the session's calls: {kept:?}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + }; + *CALL_RECORD_PAUSE_FOR_TEST.lock().unwrap() = None; + + let tools: Vec<&str> = kept.calls.iter().map(|call| call.tool.as_str()).collect(); + assert_eq!( + tools, + [ + "kin_transaction_begin", + "kin_transaction_stage", + "kin_transaction_commit" + ] + ); + assert_eq!(kept.calls_total, 3); + let committed = kept.calls.last().unwrap(); + assert_eq!(committed.seq, 3); + assert!(!committed.is_error); + assert!(committed.caller_left, "{committed:?}"); + let change = committed + .change_id + .clone() + .expect("the commit names the change it published"); + assert!(kept.change_ids.contains(&change), "{kept:?}"); + // What the begin and the stage were told reached their caller. + assert!(kept.calls[..2].iter().all(|call| !call.caller_left)); +} + +/// A commit whose caller stopped waiting while its session lives is on +/// the live log, saying so, and a write its caller waited for says the +/// change it made and nothing about anybody leaving. +#[tokio::test] +async fn a_live_session_shows_a_commit_its_caller_stopped_waiting_for() { + let state = test_state(); + let mut caller = test_entity("caller", "src/lib.rs"); + caller.file_origin = None; + caller.span = None; + let mut callee = test_entity("callee", "src/lib.rs"); + callee.file_origin = None; + callee.span = None; + install_repository_entities(&state, vec![caller.clone(), callee.clone()]); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let session_id = mcp_test_session(&state); + let session = SessionId(Uuid::parse_str(&session_id).unwrap()); + let operations = serde_json::json!([{ + "verb": "add", + "target": "", + "payload": { "Relation": { + "from": caller.id, "to": callee.id, "kind": RelationKind::Calls }}, + "description": "" + }]); + let begin = mcp_call_as( + router(Arc::clone(&state)), + "kin_transaction_begin", + serde_json::json!({ "session_id": session_id, "scope": "file:src/lib.rs" }), + session, + ) + .await; + let begin_json: serde_json::Value = serde_json::from_str(&mcp_result_text(&begin)).unwrap(); + let tx_id = begin_json["transaction_id"].as_str().unwrap().to_string(); + + let owner = Arc::as_ptr(&state) as usize; + *CALL_RECORD_PAUSE_FOR_TEST.lock().unwrap() = Some((owner, Duration::from_millis(500))); + let commit = router(Arc::clone(&state)).oneshot( + Request::post("/mcp/tools/call") + .header("content-type", "application/json") + .header("X-Kin-Session", session.to_string()) + .body(Body::from( + serde_json::json!({ + "name": "kin_transaction_commit", + "arguments": { "transaction_id": tx_id, "operations": operations } + }) + .to_string(), + )) + .unwrap(), + ); + assert!(tokio::time::timeout(Duration::from_millis(100), commit) + .await + .is_err()); + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + let committed = loop { + let (_, _, calls) = state.coordinator.activity().recent_calls(&session, 10); + if let Some(call) = calls + .iter() + .find(|call| call.tool == "kin_transaction_commit") + { + break call.clone(); + } + assert!(tokio::time::Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(25)).await; + }; + *CALL_RECORD_PAUSE_FOR_TEST.lock().unwrap() = None; + assert!(!committed.is_error, "{committed:?}"); + assert!(committed.caller_left); + let change = committed.change_id.clone().unwrap(); + assert_eq!( + state.coordinator.activity().recent_changes(&session), + vec![change] + ); + let (_, _, calls) = state.coordinator.activity().recent_calls(&session, 10); + let begun = calls + .iter() + .find(|call| call.tool == "kin_transaction_begin") + .unwrap(); + assert!(!begun.caller_left && begun.change_id.is_none()); +} diff --git a/crates/kin-daemon/src/bin/kin-daemon.rs b/crates/kin-daemon/src/bin/kin-daemon.rs index dfe17e1a3..998948359 100644 --- a/crates/kin-daemon/src/bin/kin-daemon.rs +++ b/crates/kin-daemon/src/bin/kin-daemon.rs @@ -555,7 +555,48 @@ fn main() { } } + // Still single-threaded, and before anything below writes what it + // resolves into this process's environment: a daemon that later hands its + // store over starts its successor with what it was itself started with. + kin_daemon::history_schedule::remember_start_environment(); kin_buildinfo::retain_update_build_identity(&KIN_UPDATE_BUILD_IDENTITY); + let program = env::args() + .next() + .unwrap_or_else(|| "kin-daemon".to_string()); + let args = match parse_args() { + Ok(args) => args, + Err(error) => { + eprintln!("kin-daemon: {error}"); + usage(&program); + process::exit(1); + } + }; + if args.compat_json { + println!("{}", compatibility_json()); + return; + } + // Hosted GCS serving is admitted by release digest and publication control, + // independently of the local per-user runtime installation. + if args.supervisor || matches!(args.storage, StorageMode::Local) { + let mode = match kin_cli::commands::runtime::prepare_environment() { + Ok(mode) => mode, + Err(error) => { + eprintln!("kin-daemon: {error:#}"); + process::exit(2); + } + }; + if mode == kin_core::runtime_identity::RuntimeMode::Managed { + if let Err(error) = require_managed_daemon_authority() { + eprintln!("kin-daemon: {error:#}"); + process::exit(2); + } + } + if let Err(error) = kin_core::runtime_identity::RuntimeNamespace::initialize_local_service() + { + eprintln!("kin-daemon: {error}"); + process::exit(2); + } + } // Before the embedder can be built: a machine whose `kin setup` declined // the embedding model download never fetches it, whether the background // pass, a query or an explicit embed request reaches the embedder. @@ -606,13 +647,28 @@ fn main() { } }; - let exit_code = runtime.block_on(async_main()); + let exit_code = runtime.block_on(async_main(args)); // `block_on` has returned (graceful shutdown released the lock + endpoint // files), but a blocking embed batch may still be running. `shutdown_timeout` // waits a bounded grace period for the blocking pool to drain, then abandons // anything still running so we don't hang here… runtime.shutdown_timeout(kin_daemon::daemon::runtime_shutdown_grace()); + // A daemon that stopped serving to hand its store over starts the daemon + // that attaches the staged history, as late as it can: this process still + // holds the repository's runtime authority, and the successor takes it + // when the exit below ends the process. + if kin_daemon::history_schedule::handed_off() { + match kin_daemon::history_schedule::start_successor() { + Ok(pid) => eprintln!( + "kin-daemon: started daemon {pid} to attach this store's complete history" + ), + Err(error) => eprintln!( + "kin-daemon: could not start the daemon that attaches this store's complete \ + history ({error}); the next start attaches it" + ), + } + } // …and we exit explicitly to terminate the whole process — including any // blocking thread that was abandoned above. SIGTERM always ends in real // termination; no zombie survives. @@ -664,59 +720,78 @@ fn init_tracing() { .init(); } -async fn async_main() -> i32 { - let program = env::args() - .next() - .unwrap_or_else(|| "kin-daemon".to_string()); - let args = match parse_args() { - Ok(args) => args, - Err(error) => { - eprintln!("kin-daemon: {error}"); - usage(&program); - process::exit(1); +fn require_managed_daemon_authority() -> Result<(), String> { + let expected = kin_core::registry::managed_kin_home() + .join("bin") + .join(if cfg!(windows) { + "kin-daemon.exe" + } else { + "kin-daemon" + }); + let metadata = std::fs::symlink_metadata(&expected).map_err(|error| { + format!( + "managed daemon is unavailable at {}: {error}", + expected.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { + return Err("managed daemon must be a regular selected binary, not a symlink; select development mode for custom builds".into()); + } + let selected = std::fs::canonicalize(&expected).map_err(|error| error.to_string())?; + let executable = std::env::current_exe() + .and_then(std::fs::canonicalize) + .map_err(|error| error.to_string())?; + if executable != selected { + return Err(format!( + "this daemon is outside the selected managed runtime at {}; select explicit development mode with an isolated home", + expected.display() + )); + } + if let Some(pin) = std::env::var_os("KIN_DAEMON_BIN") { + let pin = std::path::PathBuf::from(pin); + if pin != expected { + return Err("KIN_DAEMON_BIN cannot select another managed daemon; use explicit development mode for a custom pair".into()); } - }; - - // The CLI's daemon-compat probe runs `kin-daemon --compat-json` and parses - // this process's stdout as JSON. Emit that payload before installing the - // tracing subscriber (whose default writer is stdout) or running startup env - // validation, so no log line — e.g. a KIN_* override warning — can precede - // the JSON and be misread as a stale/incompatible daemon. A compat probe is - // a pure metadata query needing neither logging nor env enforcement. - if args.compat_json { - let build = kin_buildinfo::get(); - println!( - "{}", - serde_json::json!({ - "schema": "kin.daemon.compat.v2", - "version": env!("CARGO_PKG_VERSION"), - "graph_snapshot_version": kin_db::GraphSnapshot::CURRENT_VERSION, - "graph_snapshot_min_supported_version": kin_db::GraphSnapshot::MIN_SUPPORTED_VERSION, - "graph_snapshot_max_supported_version": kin_db::GraphSnapshot::CURRENT_VERSION, - "supervisor_startup_protocol": 2, - "supervisor_startup_capabilities": [ - "generation-adoption-ack-v2", - "legacy-directory-sentinel-v1", - "bounded-legacy-rollback-v1", - ], - "build": { - "sha": build.sha, - "dirty": build.dirty, - "source_known": build.source_known, - "dependency_provenance": build.dependency_provenance, - "branch": build.branch, - "built_at": build.built_at, - }, - // What this image needs before it can serve hosted, declared by - // the same table its start path refuses from. A deployment can - // grade its config against the image it pins instead of against - // a list typed after the last rollback. - "hosted_start_requirements": kin_daemon::hosted_start::declaration(), - }) - ); - return 0; } + kin_cli::commands::runtime::require_managed_pair().map_err(|error| error.to_string())?; + Ok(()) +} +fn compatibility_json() -> serde_json::Value { + let build = kin_buildinfo::get(); + serde_json::json!({ + "schema": "kin.daemon.compat.v2", + "version": env!("CARGO_PKG_VERSION"), + "graph_snapshot_version": kin_db::GraphSnapshot::CURRENT_VERSION, + "graph_snapshot_min_supported_version": kin_db::GraphSnapshot::MIN_SUPPORTED_VERSION, + "graph_snapshot_max_supported_version": kin_db::GraphSnapshot::CURRENT_VERSION, + "supervisor_startup_protocol": 2, + "supervisor_startup_capabilities": [ + "generation-adoption-ack-v2", + "legacy-directory-sentinel-v1", + "bounded-legacy-rollback-v1", + ], + "build": { + "sha": build.sha, + "dirty": build.dirty, + "source_known": build.source_known, + "dependency_provenance": build.dependency_provenance, + "branch": build.branch, + "built_at": build.built_at, + "target": build.target, + "build_profile": build.build_profile, + }, + "runtime_identity": kin_core::runtime_identity::RuntimeIdentity::current(), + "runtime_mode": kin_core::runtime_identity::RuntimeMode::current().ok(), + // What this image needs before it can serve hosted, declared by + // the same table its start path refuses from. A deployment can + // grade its config against the image it pins instead of against + // a list typed after the last rollback. + "hosted_start_requirements": kin_daemon::hosted_start::declaration(), + }) +} + +async fn async_main(args: Args) -> i32 { init_tracing(); // Validate the KIN_* environment surface at startup: unknown names and @@ -757,6 +832,17 @@ async fn async_main() -> i32 { } }; + // Ahead of the first read of the store's own records: a history attach + // a crash interrupted can have left it without them, its manifest + // included, and a start is what finishes it. + if matches!(args.storage, StorageMode::Local) { + let interrupted = layout.clone(); + let _ = tokio::task::spawn_blocking(move || { + kin_daemon::history_schedule::recover_before_start(&interrupted) + }) + .await; + } + let explicit_repo_id = args.repo_id.or_else(|| env::var("KIN_REPO_ID").ok()); let repo_id = match kin_core::manifest::resolve_repo_id(&layout, explicit_repo_id.as_deref()) { Ok(repo_id) => repo_id, @@ -990,6 +1076,12 @@ async fn async_main() -> i32 { }, embed_batch_size, embed_pipeline_overlap, + // A local store's history is attached by the daemon that starts next, + // and this entrypoint is what starts it. Not when this process is a + // container's init: everything in the container ends with it, the + // successor included, so it leaves a staged history for its next + // start, which attaches it. + history_handoff: matches!(args.storage, StorageMode::Local) && process::id() != 1, ..DaemonConfig::default() }; diff --git a/crates/kin-daemon/src/binding_history.rs b/crates/kin-daemon/src/binding_history.rs index 0a1ec8843..55e4d241a 100644 --- a/crates/kin-daemon/src/binding_history.rs +++ b/crates/kin-daemon/src/binding_history.rs @@ -26,6 +26,16 @@ pub(crate) fn restore_exact_authority(state: &DaemonState) { crate::error::DaemonError::Graph(kin_db::KinDbError::StorageError(message)) })?; let lease = authority.read_authority(); + // The proof a materialized workspace graph would carry is the witness + // the authority records for this workspace, and nothing else. With + // none recorded and none held, there is nothing to restore, and that is + // known without materializing the workspace graph: after an edit's + // publication the graph is not yet kept for the new generation, so + // asking for it rebuilt the whole workspace from authority on every + // admission only to discard it. + if !lease.records_binding_history_witness(&context.workspace_id()) && unproven(state) { + return Ok(()); + } if let Some(snapshot) = lease.workspace_graph_snapshot(&context.workspace_id())? { // An authority that holds no proof has none to restore, and a // graph that holds none has none to lose. Comparing the two would @@ -179,9 +189,18 @@ fn capture_same_tree_predecessor( .iter() .find(|workspace| workspace.workspace_id == authority.workspace_id) .expect("selected workspace is admitted"); + // The observation carries semantic relations only. Proof links restored + // from collaboration records remain in the selected graph, but their + // absence from an observation cannot be a workspace removal. + let selected_relations = selected + .relations + .iter() + .filter(|(_, relation)| kin_db::is_semantic_relation(relation)) + .map(|(id, relation)| (*id, relation.clone())) + .collect(); let semantic = kin_core::diff_workspace_semantics( &selected.entities, - &selected.relations, + &selected_relations, &observed.entities, &observed.relations, ) diff --git a/crates/kin-daemon/src/client_edits.rs b/crates/kin-daemon/src/client_edits.rs new file mode 100644 index 000000000..815240754 --- /dev/null +++ b/crates/kin-daemon/src/client_edits.rs @@ -0,0 +1,1219 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Edits an agent made with its own tools, attributed to the entities they +//! changed. +//! +//! Most coding agents change code with their own edit tools and never call a +//! Kin write. Kin sees such an edit only as the working copy changing, with no +//! one's name on it. A client that runs a hook after each edit can say whose +//! it was, and this module is where that statement is checked and kept. +//! +//! Two records are joined here. +//! +//! - A *step* is one unit moving from one content to the next in an admission: +//! the content before, the content after, and the entities whose content +//! changed in between. It is read from the admission itself. An admission +//! that carries a unit's complete parse applies the entity changes in the +//! same transaction as the content, and those changes are the step's. A +//! unit whose parse comes later is remembered as it stood, and becomes a +//! step once the graph holds a complete parse of the new content. An entity +//! changed when it was added, removed, or modified in more than where it +//! sits. A container that changed only because a member inside it did is +//! left to that member. +//! - A *claim* is a client saying "this session just made this edit", with +//! the edit itself: the text it replaced and what it put there, or the whole +//! content it wrote. +//! +//! A pre hook first records the current admitted content identity and base +//! for the specific tool operation. Its matching post hook can claim only a +//! later step with that predecessor. A step is attributed only when applying the claimed edit to the +//! step's content before yields exactly its content after. So a step that also +//! holds someone else's change, or an edit a formatter rewrote before Kin read +//! it, is attributed to nobody: nothing here guesses. A claim that arrives +//! before its edit was admitted waits for the admission, and one that arrives +//! after finds the step already noted. +//! +//! What is kept is entity ids, the client's name and session, a time and the +//! head the working copy stood on. The place a claim names and the text it +//! carries are used to confirm it and then dropped. + +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::Json; +use kin_cli::commands::review_guide::ClientEdit; +use kin_model::change::EntityDelta; +use kin_model::{ + Entity, EntityFilter, EntityId, EntityStore as _, FilePathId, Hash256, RepoPath, + SemanticChange, SemanticChangeId, Timestamp, TreeDelta, TreeEntry, +}; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use crate::DaemonState; + +/// Units one admission may move and still have its steps noted. A larger +/// admission is a checkout or a bulk rewrite, which no single edit made. +pub const MAX_UNITS_PER_PASS: usize = 32; +/// Units whose step is waiting for the graph to hold their new content. +pub const MAX_PENDING_UNITS: usize = 64; +/// Steps kept for a claim to find. Past it the oldest go. +pub const MAX_STEPS: usize = 256; +/// The largest unit a step is confirmed against. +pub const MAX_UNIT_BYTES: usize = 4 * 1024 * 1024; +/// Replacements one claim may carry. +pub const MAX_CLAIM_EDITS: usize = 256; +/// How long a unit may wait for its new content to be parsed. +const PENDING_UNIT_TTL: Duration = Duration::from_secs(30); +/// How long a claim waits for its edit to be admitted and parsed. +const RESOLVE_BUDGET: Duration = Duration::from_secs(8); +/// How long a claim waits on the watcher before it asks for an admission. +const WATCHER_GRACE: Duration = Duration::from_millis(1500); +const RESOLVE_TICK: Duration = Duration::from_millis(100); +const MAX_PREPARED_EDITS: usize = 256; +const PREPARED_EDIT_TTL: Duration = Duration::from_secs(300); + +/// A unit whose tree entry moved, with what it held before. +#[derive(Debug)] +struct PendingUnit { + /// The base its exact admission predecessor was captured on. + base: Option, + attribution_base: Option, + old: Option, + entities: Vec, + since: Instant, +} + +/// One unit moving from one content to the next, and what that changed. +#[derive(Debug)] +struct Step { + path: RepoPath, + old: Option, + new: Hash256, + at: Timestamp, + noted: Instant, + /// Kept unchanged so a delayed post hook matches its prepared operation. + base: Option, + attribution: Vec, + claimed: bool, +} + +/// Where the entities of one proven edit now belong. A selected commit can +/// record only some of them while the same edit's other entities stay pending. +#[derive(Debug, Clone)] +struct StepAttribution { + base: Option, + change_id: Option, + entities: Vec, +} + +#[derive(Debug)] +struct PreparedEdit { + claim: ClientEditClaim, + path: RepoPath, + old: Option, + base: Option, + since: Instant, +} + +#[derive(Debug, Default)] +struct Inner { + pending: HashMap, + steps: VecDeque, + prepared: HashMap<(String, String, String), PreparedEdit>, +} + +/// The steps noted and the units still waiting to become one. +#[derive(Debug, Default)] +pub struct ClientEdits { + inner: Mutex, +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn blob_of(entry: &TreeEntry) -> Option { + match entry { + TreeEntry::Blob { hash, .. } => Some(*hash), + _ => None, + } +} + +fn entities_at(state: &DaemonState, path: &RepoPath) -> Option> { + let file = FilePathId::new(path.as_utf8()?); + state + .graph + .query_entities(&EntityFilter { + file_path: Some(file), + ..Default::default() + }) + .ok() +} + +fn span_of(entity: &Entity) -> Option<(usize, usize)> { + entity + .span + .as_ref() + .map(|span| (span.start_byte, span.end_byte)) +} + +/// Whether `inner` lies inside `outer` and is not the whole of it. +fn inside(inner: Option<(usize, usize)>, outer: Option<(usize, usize)>) -> bool { + match (inner, outer) { + (Some(inner), Some(outer)) => outer.0 <= inner.0 && inner.1 <= outer.1 && inner != outer, + _ => false, + } +} + +/// The entities whose content changed between `before` and `after`, the two +/// sets one unit held on either side of a step. +/// +/// Added and removed entities changed. An entity in both changed when it was +/// modified in more than where it sits, the test a review uses. A modified +/// container is left out when another changed entity lies inside it, and a +/// module container when anything else in its unit changed: its record moved +/// because a member's did, and the member is what was edited. `moved` names +/// an entity the graph still holds somewhere else, which was not removed. +pub(crate) fn changed_entities( + before: &[Entity], + after: &[Entity], + moved: impl Fn(&EntityId) -> bool, +) -> Vec { + #[derive(Clone, Copy, PartialEq)] + enum How { + Added, + Modified, + Removed, + } + let before_by: HashMap = before.iter().map(|e| (e.id, e)).collect(); + let after_by: HashMap = after.iter().map(|e| (e.id, e)).collect(); + let mut changed: Vec<(EntityId, How)> = Vec::new(); + for entity in after { + match before_by.get(&entity.id) { + None => changed.push((entity.id, How::Added)), + Some(old) if kin_review::is_semantic_modification(old, entity) => { + changed.push((entity.id, How::Modified)); + } + Some(_) => {} + } + } + for entity in before { + if !after_by.contains_key(&entity.id) && !moved(&entity.id) { + changed.push((entity.id, How::Removed)); + } + } + let explained = |id: &EntityId| { + let (Some(old), Some(new)) = (before_by.get(id), after_by.get(id)) else { + return false; + }; + if kin_model::is_module_container(new) { + return changed.len() > 1; + } + changed.iter().any(|(other, how)| { + other != id + && match how { + How::Removed => { + inside(before_by.get(other).and_then(|e| span_of(e)), span_of(old)) + } + How::Added | How::Modified => { + inside(after_by.get(other).and_then(|e| span_of(e)), span_of(new)) + } + } + }) + }; + let mut ids: Vec = changed + .iter() + .filter(|(id, how)| *how != How::Modified || !explained(id)) + .map(|(id, _)| *id) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids +} + +impl ClientEdits { + /// Note one step for each unit an admission moved. + /// + /// Called by every admission of the working copy, once its transaction + /// has been applied. `parsed` names the units whose complete parse that + /// transaction carried, and `applied` is the entity delta it applied: for + /// those units the step is whole, and its entities are read from the + /// delta. Any other unit moved in the tree only, with its parse still to + /// come, so what it holds now is what it held before, and it waits for + /// [`ClientEdits::settle`]. A unit already waiting keeps what it held + /// first, so two moves the graph has not parsed between become one step + /// from the first content to the last. + pub(crate) fn tree_moved( + &self, + state: &DaemonState, + deltas: &[TreeDelta], + parsed: Option<&BTreeSet>, + applied: &[EntityDelta], + ) { + // Pending units were captured before their parse. Their next parse + // can span several tree admissions, so do not manufacture a second + // step from a successor delta with that earlier semantic predecessor. + let already_pending: HashSet = + lock(&self.inner).pending.keys().cloned().collect(); + self.settle(state); + if deltas.is_empty() { + return; + } + let mut inner = lock(&self.inner); + if deltas.len() > MAX_UNITS_PER_PASS { + for delta in deltas { + for located in [delta.old_state(), delta.new_state()].into_iter().flatten() { + inner.pending.remove(&located.path); + } + } + return; + } + for delta in deltas { + let (path, old, new) = match delta { + TreeDelta::Added { new, .. } => match blob_of(&new.entry) { + Some(new_hash) => (&new.path, None, new_hash), + None => continue, + }, + TreeDelta::Updated { old, new, .. } if old.path == new.path => { + match (blob_of(&old.entry), blob_of(&new.entry)) { + (Some(old_hash), Some(new_hash)) => (&new.path, Some(old_hash), new_hash), + _ => continue, + } + } + // A unit that moved place, went away, or is not content of + // its own is no edit anyone will claim. + _ => { + for located in [delta.old_state(), delta.new_state()].into_iter().flatten() { + inner.pending.remove(&located.path); + } + continue; + } + }; + if already_pending.contains(path) || inner.pending.contains_key(path) { + continue; + } + let Some(file) = path.as_utf8().map(FilePathId::new) else { + continue; + }; + if parsed.is_some_and(|parsed| parsed.contains(path)) { + let mut before = Vec::new(); + let mut after = Vec::new(); + let here = |entity: &Entity| entity.file_origin.as_ref() == Some(&file); + for change in applied { + match change { + EntityDelta::Added { new } if here(new) => after.push(new.clone()), + EntityDelta::Modified { old, new } if here(old) && here(new) => { + before.push(old.clone()); + after.push(new.clone()); + } + EntityDelta::Removed { old } if here(old) => before.push(old.clone()), + _ => {} + } + } + let changed = changed_entities(&before, &after, |id| { + state.graph.get_entity(id).ok().flatten().is_some() + }); + debug!(entities = changed.len(), "noted one unit's admission step"); + let base = workspace_base(state); + inner.steps.push_back(Step { + path: path.clone(), + old, + new, + at: Timestamp::now(), + noted: Instant::now(), + base, + attribution: vec![StepAttribution { + base, + change_id: None, + entities: changed, + }], + claimed: false, + }); + while inner.steps.len() > MAX_STEPS { + inner.steps.pop_front(); + } + continue; + } + // Only source Kin parses can become a step: anything else holds + // no entities whose content an edit could change. + if inner.pending.len() >= MAX_PENDING_UNITS + || !crate::loop_runner::is_entity_source_path(path) + { + continue; + } + let Some(entities) = entities_at(state, path) else { + continue; + }; + let base = workspace_base(state); + inner.pending.insert( + path.clone(), + PendingUnit { + base, + attribution_base: base, + old, + entities, + since: Instant::now(), + }, + ); + } + } + + /// Turn every waiting unit whose new content the graph now holds into a + /// step. A unit back at the content it started from changed nothing, and + /// one the graph has not parsed yet keeps waiting. + pub(crate) fn settle(&self, state: &DaemonState) { + let mut inner = lock(&self.inner); + if inner.pending.is_empty() { + return; + } + let tree = state.graph.resolved_tree(); + let paths: Vec = inner.pending.keys().cloned().collect(); + for path in paths { + let Some(unit) = inner.pending.get(&path) else { + continue; + }; + let Some(new) = tree + .artifact_at_path(&path) + .and_then(|artifact| blob_of(&artifact.entry)) + else { + inner.pending.remove(&path); + continue; + }; + if Some(new) == unit.old { + inner.pending.remove(&path); + continue; + } + if !crate::loop_runner::unit_semantics_are_held(state, &tree, &path) { + if unit.since.elapsed() > PENDING_UNIT_TTL { + inner.pending.remove(&path); + } + continue; + } + let Some(unit) = inner.pending.remove(&path) else { + continue; + }; + let Some(after) = entities_at(state, &path) else { + continue; + }; + let changed = changed_entities(&unit.entities, &after, |id| { + state.graph.get_entity(id).ok().flatten().is_some() + }); + debug!(entities = changed.len(), "noted one unit's admission step"); + inner.steps.push_back(Step { + path, + old: unit.old, + new, + at: Timestamp::now(), + noted: Instant::now(), + base: unit.base, + attribution: vec![StepAttribution { + base: unit.attribution_base, + change_id: None, + entities: changed, + }], + claimed: false, + }); + while inner.steps.len() > MAX_STEPS { + inner.steps.pop_front(); + } + } + } + + fn is_pending(&self, path: &RepoPath) -> bool { + lock(&self.inner).pending.contains_key(path) + } + + fn forget_prepared(&self, claim: &ClientEditClaim) { + if let Some(key) = claim_key(claim) { + lock(&self.inner).prepared.remove(&key); + } + } + + /// The newest unclaimed step of `path` that is exactly `claim`'s edit, + /// claimed: the entities it changed and when. + fn claim_step( + &self, + state: &DaemonState, + path: &RepoPath, + claim: &ClientEditClaim, + ) -> Option<(Vec, Timestamp)> { + let mut inner = lock(&self.inner); + let read = |hash: &Hash256| { + state + .blobs + .read(hash) + .ok() + .filter(|bytes| bytes.len() <= MAX_UNIT_BYTES) + }; + let key = claim_key(claim)?; + let prepared = inner.prepared.remove(&key)?; + if prepared.since.elapsed() > PREPARED_EDIT_TTL + || prepared.path != *path + || !same_operation(&prepared.claim, claim) + { + return None; + } + let step = inner + .steps + .iter_mut() + .rev() + .filter(|step| { + !step.claimed + && step.path == *path + && step.old == prepared.old + && step.base == prepared.base + && step.noted >= prepared.since + }) + .find(|step| { + let before = match &step.old { + Some(hash) => read(hash), + None => Some(Vec::new()), + }; + match (before, read(&step.new)) { + (Some(before), Some(after)) => confirms(claim, &before, &after), + _ => false, + } + }); + let Some(step) = step else { + inner.prepared.insert(key, prepared); + return None; + }; + step.claimed = true; + Some((step.attribution.clone(), step.at.clone())) + } +} + +// --------------------------------------------------------------------------- +// Claims +// --------------------------------------------------------------------------- + +/// One replacement a client's edit tool made: `old_text` became `new_text`, +/// once, or everywhere with `replace_all`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClaimedReplacement { + pub old_text: String, + pub new_text: String, + #[serde(default)] + pub replace_all: bool, +} + +/// A client saying one of its sessions just made an edit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientEditClaim { + /// The client as it names itself, such as `claude-code`. + pub client: String, + /// The client's own session. + pub session: String, + /// The same operation id carried by the client's pre and post hooks. + #[serde(default)] + pub tool_use_id: Option, + /// `before` prepares evidence before execution; `after` confirms success. + #[serde(default = "after_phase")] + pub phase: String, + /// Where the client says it edited, as the client names it. Used to find + /// the unit and then dropped. + pub unit: String, + /// The replacements it made, in the order it made them. + #[serde(default)] + pub edits: Vec, + /// The whole content it wrote, for a tool that writes a unit whole. + #[serde(default)] + pub content: Option, +} + +/// What became of a claim. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClaimOutcome { + pub schema: String, + /// `attributed` when a step was exactly this edit; `accepted` when the + /// claim was taken and is being resolved; `unconfirmed` when Kin holds the + /// edited content and no step is exactly this edit; `not_settled` when Kin + /// had not read the edit in time; `not_this_repository` when the claim + /// names nothing this repository holds; `refused` when it cannot be read. + pub outcome: String, + /// How many entities the edit was attributed on. + pub entities: usize, +} + +impl ClaimOutcome { + fn of(outcome: &str, entities: usize) -> Self { + Self { + schema: "kin.agents.edit.v1".to_string(), + outcome: outcome.to_string(), + entities, + } + } +} + +/// `before` with `claim`'s edit applied, or `None` when the edit does not +/// apply to it as the client's tool would have applied it: a replacement +/// whose text is absent, or present more than once without `replace_all`. +fn applied(claim: &ClientEditClaim, before: &str) -> Option { + if let Some(content) = &claim.content { + return Some(content.clone()); + } + if claim.edits.is_empty() { + return None; + } + let mut text = before.to_string(); + for edit in &claim.edits { + if edit.old_text.is_empty() { + // Nothing to replace: the tool wrote a unit that held nothing. + if !text.is_empty() { + return None; + } + text.clone_from(&edit.new_text); + continue; + } + let found = text.matches(edit.old_text.as_str()).count(); + if found == 0 || (found > 1 && !edit.replace_all) { + return None; + } + text = if edit.replace_all { + text.replace(edit.old_text.as_str(), &edit.new_text) + } else { + text.replacen(edit.old_text.as_str(), &edit.new_text, 1) + }; + } + Some(text) +} + +/// Whether `claim`'s edit, applied to `before`, yields exactly `after`. A unit +/// that keeps carriage returns is also compared without them, because an edit +/// tool reads and writes such a unit through line-feed text. +pub(crate) fn confirms(claim: &ClientEditClaim, before: &[u8], after: &[u8]) -> bool { + let (Ok(before), Ok(after)) = (std::str::from_utf8(before), std::str::from_utf8(after)) else { + return false; + }; + let exact = |before: &str, after: &str| applied(claim, before).is_some_and(|out| out == after); + if exact(before, after) { + return true; + } + if !before.contains("\r\n") && !after.contains("\r\n") { + return false; + } + exact(&before.replace("\r\n", "\n"), &after.replace("\r\n", "\n")) +} + +/// The change this daemon's working copy stands on. +fn workspace_base(state: &DaemonState) -> Option { + crate::unit_lifecycle::workspace_base_change(state) +} + +fn client_label(raw: &str) -> String { + raw.trim().to_ascii_lowercase().replace([' ', '_'], "-") +} + +/// The one connected Kin session of `client`, when there is exactly one. +fn lone_session_of(state: &DaemonState, client: &str) -> Option { + let sessions = state.coordinator.list_sessions().ok()?; + let mut of_client = sessions.iter().filter(|session| { + client_label(&session.vendor) == client && !state.coordinator.process_gone(session) + }); + let only = of_client.next()?; + of_client + .next() + .is_none() + .then(|| only.session_id.to_string()) +} + +fn after_phase() -> String { + "after".into() +} + +fn claim_key(claim: &ClientEditClaim) -> Option<(String, String, String)> { + let operation = claim.tool_use_id.as_deref()?.trim(); + (!operation.is_empty()).then(|| { + ( + claim.client.clone(), + claim.session.clone(), + operation.to_string(), + ) + }) +} + +fn same_operation(before: &ClientEditClaim, after: &ClientEditClaim) -> bool { + before.client == after.client + && before.session == after.session + && before.tool_use_id == after.tool_use_id + && before.unit == after.unit + && before.edits == after.edits + && before.content == after.content +} + +/// Seal attribution only when this daemon actually publishes a commit. A +/// branch switch or remote advance does not establish that an edit was part +/// of that change. +pub(crate) fn seal_commit( + state: &DaemonState, + change: &SemanticChange, + base: Option, +) { + // Commit may have drained a pending parse without another tree move. + // Finish its captured admission step before marking this publication. + state.client_edits.settle(state); + let held: HashSet = change + .entity_deltas + .iter() + .map(EntityDelta::target_id) + .collect(); + { + let mut inner = lock(&state.client_edits.inner); + for attribution in inner + .steps + .iter_mut() + .flat_map(|step| &mut step.attribution) + .filter(|attribution| attribution.change_id.is_none() && attribution.base == base) + { + attribution.entities.retain(|id| held.contains(id)); + attribution.change_id = Some(change.id); + } + } + state.agent_authorship.reseal_edits(|edits| { + let mut changed = false; + for edit in edits.iter_mut().filter(|edit| { + edit.change_id.is_none() && edit.base == base.map(|base| base.to_string()) + }) { + edit.entities.retain(|id| held.contains(id)); + edit.change_id = Some(change.id.to_string()); + changed = true; + } + changed + }); +} + +fn take_committed_entities( + entities: &mut Vec, + committed: &HashSet, +) -> Vec { + let mut recorded = Vec::new(); + entities.retain(|id| { + if committed.contains(id) { + recorded.push(*id); + false + } else { + true + } + }); + recorded +} + +fn seal_kept_step( + step: &mut Step, + change_id: SemanticChangeId, + base: Option, + committed: &HashSet, +) { + let mut recorded = Vec::new(); + for attribution in step + .attribution + .iter_mut() + .filter(|attribution| attribution.change_id.is_none() && attribution.base == base) + { + let entities = take_committed_entities(&mut attribution.entities, committed); + if !entities.is_empty() { + recorded.push(StepAttribution { + base: attribution.base, + change_id: Some(change_id), + entities, + }); + } + attribution.base = Some(change_id); + } + step.attribution + .retain(|attribution| !attribution.entities.is_empty()); + step.attribution.extend(recorded); +} + +fn seal_kept_edits( + edits: &mut Vec, + change_id: SemanticChangeId, + base: Option, + committed: &HashSet, +) -> bool { + let base = base.map(|base| base.to_string()); + let change_id = change_id.to_string(); + let mut recorded = Vec::new(); + let mut changed = false; + for edit in edits + .iter_mut() + .filter(|edit| edit.change_id.is_none() && edit.base == base) + { + let entities = take_committed_entities(&mut edit.entities, committed); + if !entities.is_empty() { + let mut sealed = edit.clone(); + sealed.entities = entities; + sealed.change_id = Some(change_id.clone()); + recorded.push(sealed); + } + edit.base = Some(change_id.clone()); + changed = true; + } + edits.retain(|edit| !edit.entities.is_empty()); + edits.extend(recorded); + changed +} + +/// Record attribution for the chosen work and keep the rest on the new base. +/// +/// The admission step keeps its original base and exact before/after content +/// for a post hook that arrives later. Its attribution can belong to several +/// changes by then, so confirming the edit returns every surviving partition. +pub(crate) fn seal_kept_commit( + state: &DaemonState, + change: &SemanticChange, + base: Option, + chosen: &HashSet, +) { + state.client_edits.settle(state); + let committed = change + .entity_deltas + .iter() + .filter(|delta| chosen.contains(&delta.target_id())) + .filter(|delta| match delta { + EntityDelta::Modified { old, new } => kin_review::is_semantic_modification(old, new), + EntityDelta::Added { .. } | EntityDelta::Removed { .. } => true, + }) + .map(EntityDelta::target_id) + .collect::>(); + { + let mut inner = lock(&state.client_edits.inner); + for step in &mut inner.steps { + seal_kept_step(step, change.id, base, &committed); + } + for unit in inner + .pending + .values_mut() + .filter(|unit| unit.attribution_base == base) + { + unit.attribution_base = Some(change.id); + } + } + state + .agent_authorship + .reseal_edits(|edits| seal_kept_edits(edits, change.id, base, &committed)); +} + +/// Prepare one client operation before it executes. The host and the parsed +/// graph must agree first; a pending or unattributed earlier edit is never +/// swept into this operation's authorship. +async fn prepare( + state: &Arc, + claim: ClientEditClaim, + path: RepoPath, + host: PathBuf, +) -> ClaimOutcome { + let Some(key) = claim_key(&claim) else { + return ClaimOutcome::of("refused", 0); + }; + let _coordination = state.coordination_gate.lock().await; + crate::loop_runner::off_the_runtime_worker(|| { + let tree = state.graph.resolved_tree(); + let old = tree.artifact_at_path(&path).and_then(|a| blob_of(&a.entry)); + if old.is_some() && !crate::loop_runner::unit_semantics_are_held(state, &tree, &path) { + return ClaimOutcome::of("not_settled", 0); + } + if !crate::loop_runner::host_unit_is_admitted(state, &host, &path) { + return ClaimOutcome::of("not_settled", 0); + } + let mut inner = lock(&state.client_edits.inner); + inner + .prepared + .retain(|_, prepared| prepared.since.elapsed() <= PREPARED_EDIT_TTL); + if inner.prepared.len() >= MAX_PREPARED_EDITS || inner.prepared.contains_key(&key) { + return ClaimOutcome::of("refused", 0); + } + inner.prepared.insert( + key, + PreparedEdit { + claim, + path, + old, + base: workspace_base(state), + since: Instant::now(), + }, + ); + ClaimOutcome::of("prepared", 0) + }) +} + +/// Check one claim against the steps noted and keep what it confirms. +/// +/// Waits for the edit to be admitted and parsed when it has not been: the +/// watcher usually has it within a moment, and an admission is asked for when +/// it does not. +pub(crate) async fn resolve(state: &Arc, claim: ClientEditClaim) -> ClaimOutcome { + let client = client_label(&crate::agent_activity::display_name(&claim.client, "")); + let session = crate::agent_activity::display_name(&claim.session, ""); + let readable = !client.is_empty() + && !session.is_empty() + && claim.edits.len() <= MAX_CLAIM_EDITS + && (claim.content.is_some() || !claim.edits.is_empty()); + let host = PathBuf::from(claim.unit.trim()); + if !readable || !host.is_absolute() { + return ClaimOutcome::of("refused", 0); + } + let Some(path) = crate::loop_runner::off_the_runtime_worker(|| { + crate::loop_runner::host_repo_path(state, &host) + }) else { + return ClaimOutcome::of("not_this_repository", 0); + }; + if claim.phase == "before" { + return prepare(state, claim, path, host).await; + } + if claim.phase != "after" || claim_key(&claim).is_none() { + return ClaimOutcome::of("refused", 0); + } + let started = Instant::now(); + let mut asked = false; + loop { + let found = { + let _coordination = state.coordination_gate.lock().await; + crate::loop_runner::off_the_runtime_worker(|| { + state.client_edits.settle(state); + let (attribution, at) = state.client_edits.claim_step(state, &path, &claim)?; + let attributed = attribution.iter().map(|part| part.entities.len()).sum(); + for part in attribution { + state.agent_authorship.record_edit(ClientEdit { + base: part.base.map(|base| base.to_string()), + change_id: part.change_id.map(|change| change.to_string()), + client: client.clone(), + session: session.clone(), + kin_session: lone_session_of(state, &client), + at: at.to_string(), + entities: part.entities, + }); + } + Some(attributed) + }) + }; + if let Some(attributed) = found { + return ClaimOutcome::of("attributed", attributed); + } + let admitted = crate::loop_runner::off_the_runtime_worker(|| { + crate::loop_runner::host_unit_is_admitted(state, &host, &path) + }); + if admitted && !state.client_edits.is_pending(&path) { + state.client_edits.forget_prepared(&claim); + return ClaimOutcome::of("unconfirmed", 0); + } + if started.elapsed() >= RESOLVE_BUDGET { + state.client_edits.forget_prepared(&claim); + return ClaimOutcome::of("not_settled", 0); + } + if !admitted && !asked && started.elapsed() >= WATCHER_GRACE { + asked = true; + if let Err(error) = crate::loop_runner::sync_filesystem_with_graph(state).await { + debug!(%error, "the admission a reported edit asked for did not finish"); + } + continue; + } + tokio::time::sleep(RESOLVE_TICK).await; + } +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ClaimParams { + /// Answer once the claim is resolved, with what became of it. + #[serde(default)] + wait: bool, +} + +/// POST /agents/edits: a client reports an edit one of its sessions made. +/// +/// Answers at once, and resolves the claim behind the answer, so a hook that +/// runs after every edit costs its client one local request. `?wait=true` +/// answers with what became of the claim instead. +pub(crate) async fn record_edit_route( + State(state): State>, + Query(params): Query, + Json(claim): Json, +) -> (StatusCode, Json) { + if params.wait || claim.phase == "before" { + return (StatusCode::OK, Json(resolve(&state, claim).await)); + } + tokio::spawn(async move { + let outcome = resolve(&state, claim).await; + debug!(outcome = %outcome.outcome, entities = outcome.entities, "resolved a reported edit"); + }); + (StatusCode::ACCEPTED, Json(ClaimOutcome::of("accepted", 0))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entity(name: &str, span: (usize, usize), body: u8) -> Entity { + let place = FilePathId::new("shapes.py"); + Entity { + id: EntityId::new(), + kind: kin_model::EntityKind::Function, + name: name.to_string(), + language: kin_model::LanguageId::Python, + fingerprint: kin_model::SemanticFingerprint { + algorithm: kin_model::FingerprintAlgorithm::V1TreeSitter, + ast_hash: Hash256::from_bytes([body; 32]), + signature_hash: Hash256::from_bytes([2; 32]), + behavior_hash: Hash256::from_bytes([body; 32]), + equivalence_hash: Hash256::from_bytes([0; 32]), + stability_score: 1.0, + }, + file_origin: Some(place.clone()), + span: Some(kin_model::SourceSpan { + file: place, + start_byte: span.0, + end_byte: span.1, + start_line: 1, + start_col: 0, + end_line: 2, + end_col: 0, + }), + signature: format!("def {name}()"), + visibility: kin_model::Visibility::Public, + role: kin_model::EntityRole::Source, + doc_summary: None, + metadata: kin_model::EntityMetadata::default(), + lineage_parent: None, + created_in: None, + superseded_by: None, + } + } + + fn edited(entity: &Entity, body: u8, span: (usize, usize)) -> Entity { + let mut new = entity.clone(); + new.fingerprint.ast_hash = Hash256::from_bytes([body; 32]); + new.fingerprint.behavior_hash = Hash256::from_bytes([body; 32]); + if let Some(held) = new.span.as_mut() { + held.start_byte = span.0; + held.end_byte = span.1; + } + new + } + + fn moved_to(entity: &Entity, span: (usize, usize)) -> Entity { + let mut new = entity.clone(); + if let Some(held) = new.span.as_mut() { + held.start_byte = span.0; + held.end_byte = span.1; + } + new + } + + fn replace(old: &str, new: &str) -> ClientEditClaim { + ClientEditClaim { + client: "claude-code".into(), + session: "s".into(), + tool_use_id: Some("tool-1".into()), + phase: "after".into(), + unit: "/repo/shapes.py".into(), + edits: vec![ClaimedReplacement { + old_text: old.into(), + new_text: new.into(), + replace_all: false, + }], + content: None, + } + } + + #[test] + fn selected_commits_keep_one_late_claims_proof_and_all_its_attribution() { + let first = EntityId::new(); + let second = EntityId::new(); + let remaining = EntityId::new(); + let base = SemanticChangeId::from_hash(Hash256::from_bytes([1; 32])); + let first_commit = SemanticChangeId::from_hash(Hash256::from_bytes([2; 32])); + let second_commit = SemanticChangeId::from_hash(Hash256::from_bytes([3; 32])); + let old = Hash256::from_bytes([4; 32]); + let new = Hash256::from_bytes([5; 32]); + let mut step = Step { + path: RepoPath::from_utf8("shapes.py").unwrap(), + old: Some(old), + new, + at: Timestamp::now(), + noted: Instant::now(), + base: Some(base), + attribution: vec![StepAttribution { + base: Some(base), + change_id: None, + entities: vec![first, second, remaining], + }], + claimed: false, + }; + seal_kept_step(&mut step, first_commit, Some(base), &HashSet::from([first])); + seal_kept_step( + &mut step, + second_commit, + Some(first_commit), + &HashSet::from([second]), + ); + + assert_eq!(step.base, Some(base)); + assert_eq!(step.old, Some(old)); + assert_eq!(step.new, new); + assert!(!step.claimed); + assert_eq!(step.attribution.len(), 3); + let held = |id: EntityId| { + step.attribution + .iter() + .find(|part| part.entities.contains(&id)) + .unwrap() + }; + assert_eq!(held(first).change_id, Some(first_commit)); + assert_eq!(held(first).base, Some(base)); + assert_eq!(held(second).change_id, Some(second_commit)); + assert_eq!(held(second).base, Some(first_commit)); + assert_eq!(held(remaining).change_id, None); + assert_eq!(held(remaining).base, Some(second_commit)); + } + + #[test] + fn selected_commits_preserve_recorded_edits_metadata_and_pending_siblings() { + let first = EntityId::new(); + let second = EntityId::new(); + let remaining = EntityId::new(); + let base = SemanticChangeId::from_hash(Hash256::from_bytes([1; 32])); + let first_commit = SemanticChangeId::from_hash(Hash256::from_bytes([2; 32])); + let second_commit = SemanticChangeId::from_hash(Hash256::from_bytes([3; 32])); + let original = ClientEdit { + base: Some(base.to_string()), + change_id: None, + client: "claude-code".into(), + session: "client-session".into(), + kin_session: Some("kin-session".into()), + at: Timestamp::now().to_string(), + entities: vec![first, second, remaining], + }; + let mut edits = vec![original.clone()]; + assert!(seal_kept_edits( + &mut edits, + first_commit, + Some(base), + &HashSet::from([first]), + )); + assert!(seal_kept_edits( + &mut edits, + second_commit, + Some(first_commit), + &HashSet::from([second]), + )); + assert!(!seal_kept_edits( + &mut edits, + first_commit, + Some(base), + &HashSet::from([first]), + )); + assert_eq!(edits.len(), 3); + for edit in &edits { + assert_eq!(edit.client, original.client); + assert_eq!(edit.session, original.session); + assert_eq!(edit.kin_session, original.kin_session); + assert_eq!(edit.at, original.at); + assert_eq!(edit.entities.len(), 1); + } + let held = |id: EntityId| edits.iter().find(|edit| edit.entities == vec![id]).unwrap(); + assert_eq!(held(first).change_id, Some(first_commit.to_string())); + assert_eq!(held(first).base, original.base); + assert_eq!(held(second).change_id, Some(second_commit.to_string())); + assert_eq!(held(second).base, Some(first_commit.to_string())); + assert_eq!(held(remaining).change_id, None); + assert_eq!(held(remaining).base, Some(second_commit.to_string())); + } + + /// One function's body changed. The function after it only moved down, + /// and the class around it changed only because its member did. + #[test] + fn only_entities_whose_content_changed_are_the_edits() { + let class = entity("Shape", (0, 100), 1); + let area = entity("area", (10, 40), 2); + let perimeter = entity("perimeter", (50, 90), 3); + let before = vec![class.clone(), area.clone(), perimeter.clone()]; + let after = vec![ + edited(&class, 9, (0, 104)), + edited(&area, 8, (10, 44)), + moved_to(&perimeter, (54, 94)), + ]; + assert_eq!(changed_entities(&before, &after, |_| false), vec![area.id]); + } + + /// An added entity and a removed one both changed, and one the graph + /// still holds elsewhere was not removed. + #[test] + fn entities_that_came_or_went_changed() { + let kept = entity("area", (0, 10), 1); + let gone = entity("old", (20, 30), 2); + let relocated = entity("moved", (40, 50), 3); + let came = entity("scale", (20, 35), 4); + let before = vec![kept.clone(), gone.clone(), relocated.clone()]; + let after = vec![kept.clone(), came.clone()]; + let mut expected = vec![gone.id, came.id]; + expected.sort_unstable(); + assert_eq!( + changed_entities(&before, &after, |id| *id == relocated.id), + expected + ); + } + + /// A step is a claim's only when the claimed edit takes its content + /// before to exactly its content after. + #[test] + fn a_step_is_confirmed_only_by_exactly_its_edit() { + let before = b"def area(w, h):\n return w * h\n"; + let after = b"def area(w, h):\n return abs(w * h)\n"; + assert!(confirms( + &replace("return w * h", "return abs(w * h)"), + before, + after + )); + // Someone else's change rode along in the same step. + let mixed = b"def area(w, h):\n return abs(w * h)\n\nX = 1\n"; + assert!(!confirms( + &replace("return w * h", "return abs(w * h)"), + before, + mixed + )); + // The text is not there, or is there twice. + assert!(!confirms( + &replace("return w + h", "return 0"), + before, + after + )); + let twice = b"a = 1\nb = 1\n"; + assert!(!confirms(&replace("1", "2"), twice, b"a = 2\nb = 1\n")); + let mut everywhere = replace("1", "2"); + everywhere.edits[0].replace_all = true; + assert!(confirms(&everywhere, twice, b"a = 2\nb = 2\n")); + } + + /// A whole-content write is confirmed by the content it wrote, an edit + /// that creates a unit by an empty start, and a unit that keeps carriage + /// returns by its line-feed text. + #[test] + fn whole_writes_new_units_and_carriage_returns_are_confirmed() { + let mut write = replace("", ""); + write.edits.clear(); + write.content = Some("x = 1\n".into()); + assert!(confirms(&write, b"x = 0\n", b"x = 1\n")); + assert!(!confirms(&write, b"x = 0\n", b"x = 2\n")); + assert!(confirms(&replace("", "x = 1\n"), b"", b"x = 1\n")); + assert!(!confirms(&replace("", "x = 1\n"), b"y = 0\n", b"x = 1\n")); + assert!(confirms( + &replace("a = 1\nb = 1", "a = 2\nb = 1"), + b"a = 1\r\nb = 1\r\n", + b"a = 2\r\nb = 1\r\n" + )); + let mut empty = replace("", ""); + empty.edits.clear(); + assert!( + !confirms(&empty, b"", b""), + "a claim of nothing confirms nothing" + ); + } +} diff --git a/crates/kin-daemon/src/committed_guides.rs b/crates/kin-daemon/src/committed_guides.rs new file mode 100644 index 000000000..a67804739 --- /dev/null +++ b/crates/kin-daemon/src/committed_guides.rs @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! The guides of committed changes this daemon built, kept for the next read. +//! +//! A guide of a recorded change is the most expensive read a daemon serves: +//! measured under load, one of zod's took 30 s and one of axum's 56 to 66 s. +//! It was built again for every request. The page gives a read a minute and +//! asks again, so a guide that took longer than that never reached it, each +//! attempt starting over; and Home and the review page each built the same +//! guide, one after the other. +//! +//! What a guide of committed history reads cannot move while three things +//! hold still: the change it names (and the base of a range), which never +//! moves once recorded; the graph it is read from, whose identity and count +//! of completed writes to truth say whether anything it could read moved; +//! and the repository's publication, which carries the refs a name resolves +//! through and the reviews the guide lists. So a guide is kept with the +//! [`GuideBasis`] it was read at, and served again only while that is still +//! the repository's basis. The guide's format is this build's own: what is +//! kept lives in this process and goes with it. +//! +//! A read of a change whose guide is being built joins that build rather than +//! starting another, and the build runs on a task of its own, so a request +//! that stops waiting does not take the work with it. A guide built while the +//! repository moved on is only handed to a request that asked after it +//! settled when it still describes the repository as that request finds it; +//! otherwise the request builds again. What a guide is answered with marks +//! each step with the agent session that wrote it, read fresh for every +//! request, so it is never kept. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use axum::http::StatusCode; +use kin_cli::commands::review_guide::ReviewGuide; + +use crate::api::LocalPublicationIdentity; + +/// Guides kept at once, for the changes read most recently. +pub const KEPT_GUIDES_MAX: usize = 16; + +/// What a guide of committed history was read against. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GuideBasis { + /// The graph the guide was read from: its identity, never reused, and how + /// many writes to its truth it had completed. + pub graph: (u64, u64), + /// The repository publication it was read against, which holds its refs, + /// its history and its reviews. + pub publication: LocalPublicationIdentity, +} + +impl GuideBasis { + /// The basis `graph` stands at now, against `publication`. + pub fn of(graph: &kin_db::InMemoryGraph, publication: LocalPublicationIdentity) -> Self { + Self { + graph: (graph.instance_id(), graph.truth_epoch()), + publication, + } + } + + /// Whether a guide kept at this basis can never be served again from + /// `now`: it was read from another graph, or from this one before writes + /// that have since landed. Neither moves backwards. + fn left_behind_by(&self, now: &GuideBasis) -> bool { + self.graph.0 != now.graph.0 || self.graph.1 < now.graph.1 + } +} + +/// One committed guide a request names: the change, and the base of a range, +/// as written, trimmed and in lower case. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GuideRequest { + pub change: Option, + pub base: Option, +} + +impl GuideRequest { + pub fn new(change: Option<&str>, base: Option<&str>) -> Self { + let spelled = |name: Option<&str>| { + name.map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_ascii_lowercase) + }; + Self { + change: spelled(change), + base: spelled(base), + } + } +} + +/// How one build ended: the guide and the basis it settled at, or the refusal +/// every request that waited for it is answered with. +pub type Built = Result<(GuideBasis, Arc), (StatusCode, String)>; + +/// What a request finds for the guide it asks for. +pub enum Found { + /// A guide kept for the basis the request found. + Kept(Arc), + /// A build already running: wait for its end. + Join(tokio::sync::watch::Receiver>>), + /// Nothing: this request starts the build, and waits for it like any + /// other. + Lead(tokio::sync::watch::Sender>>), +} + +struct Entry { + building: Option>>>, + kept: Option<(GuideBasis, Arc)>, + used: Option, +} + +impl Default for Entry { + fn default() -> Self { + Self { + building: None, + kept: None, + used: None, + } + } +} + +/// The committed guides this daemon is building and has kept. +pub struct CommittedGuides { + entries: Mutex>>, + builds: AtomicU64, + kept_served: AtomicU64, +} + +impl Default for CommittedGuides { + fn default() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + builds: AtomicU64::new(0), + kept_served: AtomicU64::new(0), + } + } +} + +impl std::fmt::Debug for CommittedGuides { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CommittedGuides") + .field("builds", &self.builds()) + .field("kept_served", &self.kept_served()) + .finish_non_exhaustive() + } +} + +impl CommittedGuides { + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap>> { + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// What there is for `request` while the repository stands at `basis`. + pub fn find(&self, request: &GuideRequest, basis: &GuideBasis) -> Found { + let mut entries = self.lock(); + for entry in entries.values_mut() { + if entry + .kept + .as_ref() + .is_some_and(|(kept, _)| kept.left_behind_by(basis)) + { + entry.kept = None; + } + // A build whose task ended without finishing, as a panic ends + // one, answers nobody; the next read builds again. + if entry.building.as_ref().is_some_and(|building| { + building.has_changed().is_err() && building.borrow().is_none() + }) { + entry.building = None; + } + } + entries.retain(|_, entry| entry.building.is_some() || entry.kept.is_some()); + let entry = entries.entry(request.clone()).or_default(); + entry.used = Some(Instant::now()); + if let Some((kept, guide)) = &entry.kept { + if kept == basis { + self.kept_served.fetch_add(1, Ordering::Relaxed); + return Found::Kept(Arc::clone(guide)); + } + } + if let Some(building) = &entry.building { + return Found::Join(building.clone()); + } + let (sender, receiver) = tokio::sync::watch::channel(None); + entry.building = Some(receiver); + self.builds.fetch_add(1, Ordering::Relaxed); + Found::Lead(sender) + } + + /// End the build of `request`: keep what it built under the basis it + /// settled at, and answer every request waiting for it. + pub fn finish( + &self, + request: &GuideRequest, + sender: tokio::sync::watch::Sender>>, + built: Built, + ) { + { + let mut entries = self.lock(); + let entry = entries.entry(request.clone()).or_default(); + entry.building = None; + entry.used = Some(Instant::now()); + if let Ok((basis, guide)) = &built { + entry.kept = Some((basis.clone(), Arc::clone(guide))); + } + if entries.len() > KEPT_GUIDES_MAX { + let mut idle: Vec<(Option, GuideRequest)> = entries + .iter() + .filter(|(_, entry)| entry.building.is_none()) + .map(|(request, entry)| (entry.used, request.clone())) + .collect(); + idle.sort_by_key(|(used, _)| *used); + let extra = entries.len() - KEPT_GUIDES_MAX; + for (_, request) in idle.into_iter().take(extra) { + entries.remove(&request); + } + } + } + sender.send_replace(Some(built)); + } + + /// How many builds have started, for a test or a log line. + pub fn builds(&self) -> u64 { + self.builds.load(Ordering::Relaxed) + } + + /// How many reads a kept guide answered. + pub fn kept_served(&self) -> u64 { + self.kept_served.load(Ordering::Relaxed) + } +} + +/// Wait for the build `receiver` follows to end. +pub async fn built( + mut receiver: tokio::sync::watch::Receiver>>, +) -> Option> { + receiver + .wait_for(Option::is_some) + .await + .ok() + .and_then(|built| built.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn basis(truth: u64, publication: u8) -> GuideBasis { + GuideBasis { + graph: (7, truth), + publication: LocalPublicationIdentity::Published([publication; 32]), + } + } + + fn guide() -> Arc { + Arc::new("a guide".to_string()) + } + + #[tokio::test] + async fn a_guide_is_built_once_kept_for_its_basis_and_built_again_once_that_moves() { + let guides = CommittedGuides::::default(); + let request = GuideRequest::new(Some(" ABC "), None); + assert_eq!(request, GuideRequest::new(Some("abc"), None)); + let Found::Lead(sender) = guides.find(&request, &basis(1, 1)) else { + panic!("the first read builds"); + }; + let Found::Join(waiting) = guides.find(&request, &basis(1, 1)) else { + panic!("a read during the build joins it"); + }; + guides.finish(&request, sender, Ok((basis(1, 1), guide()))); + assert!(built(waiting).await.unwrap().is_ok()); + assert!(matches!( + guides.find(&request, &basis(1, 1)), + Found::Kept(_) + )); + assert!(matches!( + guides.find(&request, &basis(1, 2)), + Found::Lead(_) + )); + assert_eq!(guides.builds(), 2); + assert_eq!(guides.kept_served(), 1); + } + + #[tokio::test] + async fn a_build_whose_task_ended_without_an_answer_is_started_again() { + let guides = CommittedGuides::::default(); + let request = GuideRequest::new(Some("abc"), None); + let Found::Lead(sender) = guides.find(&request, &basis(1, 1)) else { + panic!() + }; + let Found::Join(waiting) = guides.find(&request, &basis(1, 1)) else { + panic!() + }; + drop(sender); + assert!(built(waiting).await.is_none()); + assert!(matches!( + guides.find(&request, &basis(1, 1)), + Found::Lead(_) + )); + } + + #[tokio::test] + async fn a_refused_build_is_not_kept_and_a_moved_graph_drops_what_was() { + let guides = CommittedGuides::::default(); + let request = GuideRequest::new(Some("abc"), Some("def")); + let Found::Lead(sender) = guides.find(&request, &basis(1, 1)) else { + panic!() + }; + guides.finish( + &request, + sender, + Err((StatusCode::SERVICE_UNAVAILABLE, "busy".into())), + ); + let Found::Lead(sender) = guides.find(&request, &basis(1, 1)) else { + panic!("a refusal is not kept") + }; + guides.finish(&request, sender, Ok((basis(1, 1), guide()))); + let other = GuideRequest::new(Some("xyz"), None); + let Found::Lead(_) = guides.find(&other, &basis(2, 1)) else { + panic!() + }; + assert!( + !matches!(guides.find(&request, &basis(1, 1)), Found::Kept(_)), + "a guide of a graph that has since been written is never served" + ); + } +} diff --git a/crates/kin-daemon/src/daemon.rs b/crates/kin-daemon/src/daemon.rs index 7ad56aee7..b41a56225 100644 --- a/crates/kin-daemon/src/daemon.rs +++ b/crates/kin-daemon/src/daemon.rs @@ -46,6 +46,12 @@ pub struct DaemonConfig { /// flush. Off by default: the serial path is deterministic and is what the /// proof profile relies on. Enabled only under the throughput profile. pub embed_pipeline_overlap: bool, + /// Whether this daemon hands its store over, at a quiet point, when a + /// worker has staged the store's complete history: it stops serving and a + /// successor attaches the history as it starts. Off by default, because a + /// daemon that leaves needs someone to start the next one; the process + /// entrypoint turns it on for a local store and starts that successor. + pub history_handoff: bool, } impl Default for DaemonConfig { @@ -59,10 +65,14 @@ impl Default for DaemonConfig { lsp_enabled: true, idle_timeout: None, embed_pipeline_overlap: false, + history_handoff: false, } } } +/// How long a stop waits to write what agents did that is still held. +const AGENT_RECORDS_STOP_BUDGET: Duration = Duration::from_secs(10); + /// How often the session sweeper looks for sessions whose client is gone. /// /// A session whose registered process has exited is reaped by the first sweep @@ -2752,6 +2762,39 @@ impl ShutdownBlocker { Self::SweepWaiter => "a command is waiting to read a language-server sweep's result", } } + + /// The stable name a client reads this blocker by. Sentences may be + /// reworded; these never change meaning. + pub(crate) fn code(self) -> &'static str { + use kin_cli::daemon_client::replacement::blocker; + match self { + Self::PreparedPublication => blocker::PREPARED_PUBLICATION, + Self::RequestInFlight => blocker::REQUEST_IN_FLIGHT, + Self::FirstScan => blocker::FIRST_SCAN, + Self::Reconciliation => blocker::RECONCILIATION, + Self::Embedding => blocker::EMBEDDING, + Self::EmbeddingHeldForMemory => blocker::EMBEDDING_HELD_FOR_MEMORY, + Self::Enrichment => blocker::ENRICHMENT, + Self::EventSubscriber => blocker::EVENT_SUBSCRIBER, + Self::AttachedSession => blocker::ATTACHED_SESSION, + Self::SweepWaiter => blocker::SWEEP_WAITER, + } + } + + /// Whether this blocker still holds a replacement asked at `requested_at`, + /// judged at `now`. + fn holds_replacement(self, requested_at: Instant, now: Instant) -> bool { + use kin_cli::daemon_client::replacement::{ + replacement_wait, ReplacementWait, REPLACEMENT_BACKGROUND_GRACE, + }; + match replacement_wait(self.code()) { + ReplacementWait::Always => true, + ReplacementWait::Bounded => { + now.saturating_duration_since(requested_at) < REPLACEMENT_BACKGROUND_GRACE + } + ReplacementWait::Never => false, + } + } } /// Work a shutdown would destroy rather than merely interrupt, independent of @@ -2824,20 +2867,51 @@ fn client_blockers(state: &DaemonState) -> Vec { /// Everything keeping this daemon from retiring right now, for the caller /// that asked it to. See [`work_blockers`] for `requests_to_ignore`. +/// +/// A daemon making way for another release keeps fewer: see +/// [`replacement_blockers`]. pub(crate) fn retirement_blockers( state: &DaemonState, requests_to_ignore: u64, ) -> Vec { + if let Some(replacement) = state.replacement() { + return replacement_blockers(state, requests_to_ignore, &replacement, Instant::now()); + } let mut blockers = work_blockers(state, requests_to_ignore); blockers.extend(client_blockers(state)); blockers } +/// What still holds a daemon that a client of another release asked to make +/// way for it, judged at `now`. +/// +/// Writes, a publication being prepared and the first scan always finish, +/// because an interruption loses them. Background passes the successor redoes +/// hold it only for the grace after the request. Attached clients and event +/// subscribers never do: they belong to the release being replaced, which the +/// successor would refuse anyway, and they are told to restart. +fn replacement_blockers( + state: &DaemonState, + requests_to_ignore: u64, + replacement: &crate::state::Replacement, + now: Instant, +) -> Vec { + work_blockers(state, requests_to_ignore) + .into_iter() + .filter(|blocker| blocker.holds_replacement(replacement.requested_at, now)) + .collect() +} + fn ready_for_idle_shutdown( state: &DaemonState, idle_timeout: Duration, control_plane: ControlPlane, ) -> bool { + if let Some(replacement) = state.replacement() { + // The window is zero and the client gates are gone; only what a + // replacement waits for is left to decide. + return replacement_blockers(state, 0, &replacement, Instant::now()).is_empty(); + } if work_would_be_lost_by_shutdown(state) { return false; } @@ -3678,7 +3752,12 @@ async fn run_idle_monitor( } } } - if retiring { + if let Some(replacement) = state.replacement() { + info!( + successor = %replacement.successor, + "daemon replaced by another release and its work is finished, shutting down" + ); + } else if retiring { info!("daemon asked to retire and nothing needs it now, shutting down"); } else { info!( @@ -10676,7 +10755,7 @@ pub async fn run_with_authority_on( // This registration borrows singleton authority until normal run teardown. // Any unresolved publication stops synchronously before that lock can drop. - let _prepared_runtime = state + let prepared_runtime = state .prepared_publication .register(&daemon_lock, state.layout.root())?; @@ -10803,6 +10882,9 @@ pub async fn run_with_authority_on( // A path that must stop this daemon serving sends on the same channel a // cooperative stop request does. let _ = state.stop_trigger.set(cancel_tx.clone()); + if config.history_handoff { + crate::history_schedule::spawn_handoff_watch(Arc::clone(&state), cancel_rx.clone()); + } // A planned sweep supplies readiness from the actual servers it starts. // A warm no-op sweep probes skipped languages once at its tail. A sweep // refused for memory leaves readiness to the sweep that does run, which is @@ -11191,6 +11273,24 @@ pub async fn run_with_authority_on( } }); + // Write what agents did into repository authority as their sessions end + // or go quiet. Not drained: the stop writes what is still held itself, + // before the daemon stops taking writes. + let records_state = Arc::clone(&state); + let mut records_cancel = cancel_rx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = tokio::time::sleep(crate::agent_authorship::SETTLE_TICK) => {} + _ = records_cancel.changed() => break, + } + if *records_cancel.borrow() { + break; + } + crate::agent_authorship::settle(&records_state, false).await; + } + }); + // Spawn the background persistence task. // Instead of blocking the reconcile loop with synchronous save_graph(), // this task periodically flushes dirty state to disk: @@ -12721,6 +12821,15 @@ pub async fn run_with_authority_on( // daemon cannot start while this process is still draining persistent state. crate::lifecycle::remove_daemon_files_if_current_process(state.layout.root()); + drop(prepared_runtime); + if state.handing_off_for_history() { + // The successor replaces this store's contents as it starts. A thread + // of this process that outlives the runtime's teardown must not be + // able to write into what it installs, so the authority is not + // released here: it ends with the process. + crate::history_schedule::keep_authority_until_exit(daemon_lock); + } + result } @@ -12826,6 +12935,13 @@ async fn select_with_signals( } }; + // What agents did that is still held is written while the daemon still + // takes writes, so a session that ended moments ago is not lost with it. + let _ = tokio::time::timeout( + AGENT_RECORDS_STOP_BUDGET, + crate::agent_authorship::settle(state, true), + ) + .await; state.prepared_publication.shutdown(); // The escalation watchdog's grace began when the stop was signalled, so a @@ -13194,6 +13310,13 @@ async fn select_with_signals( } }; + // What agents did that is still held is written while the daemon still + // takes writes, so a session that ended moments ago is not lost with it. + let _ = tokio::time::timeout( + AGENT_RECORDS_STOP_BUDGET, + crate::agent_authorship::settle(state, true), + ) + .await; state.prepared_publication.shutdown(); // The escalation watchdog's grace began when the stop was signalled, so a @@ -20084,6 +20207,118 @@ while True: assert!(*cancel_tx.borrow()); } + /// What holds a daemon making way for another release: the first scan + /// always, background passes only for the grace after the request, and an + /// attached client never, though that client still holds a plain + /// retirement. + #[cfg(unix)] + #[tokio::test] + async fn a_replacement_waits_for_the_first_scan_bounds_background_work_and_skips_clients() { + use super::ShutdownBlocker; + use kin_cli::daemon_client::replacement::REPLACEMENT_BACKGROUND_GRACE; + let (_repo, state) = open_idle_daemon(Some(Duration::from_secs(3600))).await; + let client = StandInClient::spawn(); + client.attach(&state); + state.is_initialized.store(false, Ordering::Relaxed); + state + .reconciliation_status + .store(crate::state::RECON_PROCESSING, Ordering::Relaxed); + assert!( + super::retirement_blockers(&state, 0).contains(&ShutdownBlocker::AttachedSession), + "an attached client holds a plain retirement" + ); + + assert!(state.request_replacement("Kin 9.9.9 (0123abc)")); + let replacement = state.replacement().expect("the replacement is recorded"); + let asked = replacement.requested_at; + assert_eq!( + super::replacement_blockers(&state, 0, &replacement, asked), + vec![ShutdownBlocker::FirstScan, ShutdownBlocker::Reconciliation], + "inside the grace the background pass still holds it; the client never does" + ); + assert_eq!( + super::retirement_blockers(&state, 0), + vec![ShutdownBlocker::FirstScan, ShutdownBlocker::Reconciliation], + "the /retire answer reads the same gates" + ); + let after_grace = asked + REPLACEMENT_BACKGROUND_GRACE; + assert_eq!( + super::replacement_blockers(&state, 0, &replacement, after_grace), + vec![ShutdownBlocker::FirstScan], + "the first scan does not resume, so it outlasts the grace" + ); + state.is_initialized.store(true, Ordering::Relaxed); + assert!(super::replacement_blockers(&state, 0, &replacement, after_grace).is_empty()); + assert!( + !super::ready_for_idle_shutdown(&state, Duration::ZERO, ControlPlane::Ours), + "on the real clock the grace has not run out yet" + ); + state + .reconciliation_status + .store(RECON_IDLE, Ordering::Relaxed); + assert!(super::ready_for_idle_shutdown( + &state, + Duration::ZERO, + ControlPlane::Ours + )); + drop(client); + } + + /// Asked to make way for another release, a daemon goes with a client + /// still attached, but only once the request already in flight has + /// finished, and it flushes its graph on the way out. + #[cfg(unix)] + #[tokio::test] + async fn a_replacing_daemon_finishes_its_write_flushes_and_goes_under_an_attached_client() { + use super::ShutdownBlocker; + let window = Duration::from_secs(3600); + let (_repo, state) = open_idle_daemon(Some(window)).await; + let client = StandInClient::spawn(); + client.attach(&state); + state.mark_dirty(); + // A write that started before the replacement was asked. + state.begin_request(); + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let monitor = tokio::spawn(super::run_idle_monitor( + Arc::clone(&state), + Some(window), + 4219, + cancel_tx.clone(), + cancel_rx, + )); + + assert!(state.request_replacement("Kin 9.9.9 (0123abc)")); + assert!( + !state.request_replacement("Kin 10.0.0 (fedcba9)"), + "a repeat changes nothing" + ); + assert_eq!( + state.replacement().map(|replacement| replacement.successor), + Some("Kin 9.9.9 (0123abc)".to_string()) + ); + tokio::time::sleep(Duration::from_millis(1500)).await; + assert!( + !*cancel_tx.borrow(), + "a replacing daemon went with a write still in flight" + ); + assert_eq!( + super::retirement_blockers(&state, 0), + vec![ShutdownBlocker::RequestInFlight] + ); + + state.end_request(); + tokio::time::timeout(Duration::from_secs(10), monitor) + .await + .expect("a replacing daemon must go once its write has finished") + .expect("the idle monitor must not panic"); + assert!(*cancel_tx.borrow()); + assert!( + !state.is_dirty(), + "a replacing daemon flushes its graph before it goes" + ); + drop(client); + } + #[tokio::test] async fn an_endpoint_blip_cannot_shut_down_a_daemon_mid_scan() { // The EndpointLost branch used to return on the idle clock alone, diff --git a/crates/kin-daemon/src/daemon/incremental_enrichment.rs b/crates/kin-daemon/src/daemon/incremental_enrichment.rs index 59ef08f33..6de44be9f 100644 --- a/crates/kin-daemon/src/daemon/incremental_enrichment.rs +++ b/crates/kin-daemon/src/daemon/incremental_enrichment.rs @@ -1268,8 +1268,26 @@ mod tests { /// sweep of that file leaves, and so may record the file's mark: in the /// same store, where a sweep changes only the peer's proof context, and /// in a store that was only ever swept at the edited text. - #[tokio::test] - async fn an_incremental_pass_leaves_the_ledgers_a_fresh_sweep_does() { + // The pass, the sweep and both ledger reads are one future; an unoptimized + // build lays its frames out wider than a test thread's default stack, so it + // runs on a thread sized like the CLI's. + #[test] + fn an_incremental_pass_leaves_the_ledgers_a_fresh_sweep_does() { + std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(an_incremental_pass_leaves_the_ledgers_a_fresh_sweep_does_body()) + }) + .unwrap() + .join() + .unwrap(); + } + + async fn an_incremental_pass_leaves_the_ledgers_a_fresh_sweep_does_body() { async fn validated_ledgers( fixture: &Fixture, ) -> ( diff --git a/crates/kin-daemon/src/ended_sessions.rs b/crates/kin-daemon/src/ended_sessions.rs new file mode 100644 index 000000000..32aee6770 --- /dev/null +++ b/crates/kin-daemon/src/ended_sessions.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! The agent sessions that ended, and the window they are kept for. +//! +//! A live session's call log lives in memory and goes with the session (see +//! [`crate::agent_activity`]). Someone who looks at a repository after its +//! agent finished still needs to see who worked there, what it read and what +//! it changed, so when a session ends or is reaped the daemon keeps what its +//! log held: the tools it called, the entities each call was about, and the +//! changes it committed. Never the question, the arguments or the answer, and +//! never a directory, a process id or a path, the same as the live log. +//! +//! What is kept is repository authority, on the actor that stands for the +//! session ([`kin_model::AgentSessionRecord`]), written by +//! [`crate::agent_authorship`]. It is bounded twice: a session's calls are +//! kept for [`ENDED_KEPT_DAYS`] days after it ends, and only for the newest +//! [`MAX_ENDED_SESSIONS`]. Each keeps its newest [`MAX_ENDED_CALLS`] calls. + +use kin_model::timestamp::Timestamp; + +use crate::agent_activity::ToolCallEntry; + +/// How long a session is kept after it ends. +pub const ENDED_KEPT_DAYS: u64 = kin_model::AGENT_SESSION_KEPT_DAYS; +/// The ended sessions kept, the newest. +pub const MAX_ENDED_SESSIONS: usize = kin_model::AGENT_SESSIONS_KEPT; +/// The calls kept for one ended session, its newest. +pub const MAX_ENDED_CALLS: usize = kin_model::AGENT_SESSION_CALLS_MAX; +/// The changes kept for one ended session, its newest. +pub const MAX_ENDED_CHANGES: usize = kin_model::AGENT_SESSION_CHANGES_MAX; + +/// One session that ended, as its log stood when it ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndedSession { + pub session_id: String, + /// The names its connection gave, already made safe to show. + pub vendor: String, + pub client: String, + pub transport: String, + pub started_at: Timestamp, + /// When it last called Kin, or was last heard from when it never did. + pub last_activity_at: Timestamp, + pub ended_at: Timestamp, + /// Every call it made, counted, including the ones not kept. + pub calls_total: u64, + /// Its newest calls, oldest first. + pub calls: Vec, + /// The changes it committed, newest first. + pub change_ids: Vec, +} + +/// Whether `at` is older than the window a session is kept for, seen from +/// `now`. +pub fn expired(at: &Timestamp, now: &Timestamp) -> bool { + let window = chrono::Duration::days(i64::try_from(ENDED_KEPT_DAYS).unwrap_or(30)); + now.0.signed_duration_since(at.0) > window +} + +/// Whether a write made at `at`, as its record spells the time, falls inside +/// the window a session is kept for, seen from `now`. A time that does not +/// read is outside it. +pub fn within_window(at: &str, now: &Timestamp) -> bool { + chrono::DateTime::parse_from_rfc3339(at.trim()) + .map(|at| !expired(&Timestamp(at.with_timezone(&chrono::Utc)), now)) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_time_is_inside_the_window_only_when_it_reads_and_is_recent() { + let now = Timestamp::now(); + let recent = Timestamp(chrono::Utc::now() - chrono::Duration::minutes(10)); + assert!(within_window(&recent.to_string(), &now)); + assert!(!within_window("not a time", &now)); + let days = i64::try_from(ENDED_KEPT_DAYS).unwrap(); + let old = Timestamp(chrono::Utc::now() - chrono::Duration::days(days + 1)); + assert!(!within_window(&old.to_string(), &now)); + assert!(expired(&old, &now)); + assert!(!expired(&recent, &now)); + } +} diff --git a/crates/kin-daemon/src/entity_drafts.rs b/crates/kin-daemon/src/entity_drafts.rs index 716aa708d..6480423ae 100644 --- a/crates/kin-daemon/src/entity_drafts.rs +++ b/crates/kin-daemon/src/entity_drafts.rs @@ -15,8 +15,10 @@ mod application; pub(crate) use application::call as apply; use serde::{Deserialize, Serialize}; -const STORE_DIR: &str = "entity-drafts-v1"; -const LOCK_FILE: &str = "store.lock"; +// Both names are the history attach's to know as well: it waits while this +// store holds a draft, and reads the store without this crate. +const STORE_DIR: &str = kin_core::history_attach::ENTITY_DRAFTS_DIR; +const LOCK_FILE: &str = kin_core::history_attach::ENTITY_DRAFTS_LOCK; const MAX_RECORD_BYTES: u64 = 128 * 1024 * 1024; #[derive(Debug, Clone, Copy, Serialize)] diff --git a/crates/kin-daemon/src/entity_keep.rs b/crates/kin-daemon/src/entity_keep.rs new file mode 100644 index 000000000..cdafa536b --- /dev/null +++ b/crates/kin-daemon/src/entity_keep.rs @@ -0,0 +1,1903 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Keep chosen entities of the uncommitted working copy in a change, and put +//! one entity back as it was. +//! +//! Both are entity work on one working copy, and both start from the same +//! question: which bytes carry exactly the chosen entities' changes and none +//! of the others. [`crate::entity_split`] builds them. This module derives +//! their entities the way every admitted source is derived, proves the result +//! entity by entity, and publishes it through the repository paths the rest of +//! the daemon publishes through. +//! +//! A commit of chosen entities publishes a change whose tree is the head's +//! with only the chosen entities' changes in it, and moves the workspace's base +//! onto that change without moving its tree. Nothing in the working copy is +//! written, so every change that was not chosen is still uncommitted work +//! against the new base, byte for byte as it was. +//! +//! An agent's transaction commits through the same shape when the working +//! copy holds uncommitted work in source units its operations did not write: +//! [`with_units_from`] puts those units back as the head holds them in the +//! transaction's plan, so the change records the head and what the transaction +//! wrote, and gives the working copy the daemon's own entities for them, so +//! that work stays uncommitted with the identities, and so the authorship, it +//! had. +//! +//! A restore splices one entity's version at a change into the working copy as +//! an uncommitted edit, through the same exact workspace transition a branch +//! switch or a checkout writes with, and refuses when the entity changed since +//! the caller read it. + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::http::StatusCode; +use kin_cli::commands::entity_work::{ + EntityCommitRequest, EntityCommitResponse, EntityRestoreRequest, EntityRestoreResponse, + ENTITY_REFUSED, +}; +use kin_db::{LocalFileBackend, RepositoryAuthorityManager}; +use kin_model::graph::ResolvedGraphState; +use kin_model::{ + ArtifactId, ChangeStore, Entity, EntityDelta, EntityId, EntityKind, EntityStore, + ExternalReference, ExternalReferenceId, FilePathId, GraphNodeId, LocatedEntry, OperationId, + Relation, RelationDelta, RelationId, RepoPath, RepositoryCommitReceipt, RepositoryTransaction, + ResolutionRecord, ResolutionRecordId, ResolvedArtifact, ResolvedTree, SemanticChange, + SemanticChangeId, TransactionDelta, TreeDelta, TreeEntry, WorkspaceExpectation, + WorkspaceMutation, WorkspaceState, REPOSITORY_TRANSACTION_SCHEMA_VERSION, +}; + +use crate::entity_split::{Outside, Spanned, Version}; +use crate::local_repository_authority::{ + ActiveLocalRepositoryAuthority, LocalRepositoryAuthorityContext, TargetGraph, +}; +use crate::state::{DaemonEvent, DaemonState}; + +type Authority = RepositoryAuthorityManager; + +/// The error kind of an answer that does not say whether the work landed. +const UNSETTLED: &str = "entity_unsettled"; + +/// Why entity work wrote nothing, or why its outcome is not known. +#[derive(Debug)] +pub(crate) struct Refusal { + status: StatusCode, + kind: &'static str, + message: String, +} + +impl Refusal { + /// Nothing was written, for a reason one sentence about entities states. + fn worded(message: impl Into) -> Self { + Self { + status: StatusCode::CONFLICT, + kind: ENTITY_REFUSED, + message: message.into(), + } + } + + /// Nothing was written, for a reason that is about storage rather than + /// about entities. The reason is logged and the caller is told what did + /// not happen. + fn failed(what: &str, work: Work, error: impl std::fmt::Display) -> Self { + tracing::warn!(%error, what, "entity work refused before it published anything"); + Self::worded(format!("Kin could not {what}, so {}.", work.nothing())) + } + + /// The work may have been published, and nothing here can say. + fn unsettled(error: impl std::fmt::Display) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + kind: UNSETTLED, + message: format!("the entity work did not finish: {error}"), + } + } + + pub(crate) fn into_http(self) -> (StatusCode, String) { + ( + self.status, + serde_json::json!({"error": self.kind, "message": self.message}).to_string(), + ) + } +} + +/// Which entity work is being done, for the words its refusals use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Work { + Commit, + Restore, +} + +impl Work { + fn verb(self) -> &'static str { + match self { + Self::Commit => "commit", + Self::Restore => "restore", + } + } + + fn nothing(self) -> &'static str { + match self { + Self::Commit => "nothing was committed", + Self::Restore => "nothing was restored", + } + } +} + +/// One version of the repository as entity work reads it: its tree and its +/// entities. +struct Held<'a> { + tree: &'a ResolvedTree, + entities: Entities<'a>, +} + +enum Entities<'a> { + Recorded(&'a HashMap), + Live(&'a kin_db::InMemoryGraph), +} + +impl Entities<'_> { + fn get(&self, id: &EntityId) -> Option { + match self { + Self::Recorded(entities) => entities.get(id).cloned(), + Self::Live(graph) => graph.get_entity(id).ok().flatten(), + } + } + + /// The entities with a span in the content at `path`. + fn in_path(&self, path: &str) -> Vec { + let mut found = match self { + Self::Recorded(entities) => entities + .values() + .filter(|entity| origin_is(entity, path)) + .cloned() + .collect::>(), + Self::Live(graph) => graph + .query_entities(&kin_model::EntityFilter { + file_path: Some(FilePathId::new(path)), + ..Default::default() + }) + .unwrap_or_default(), + }; + found.retain(|entity| entity.span.is_some() && origin_is(entity, path)); + found + } +} + +fn origin_is(entity: &Entity, path: &str) -> bool { + entity + .file_origin + .as_ref() + .is_some_and(|origin| origin.0 == path) +} + +fn is_whole_unit(kind: EntityKind) -> bool { + matches!( + kind, + EntityKind::Module | EntityKind::File | EntityKind::Package + ) +} + +/// One content as one version holds it. +#[derive(Debug, Clone, Default)] +struct Content { + artifact: Option, + bytes: Vec, + entities: Vec, +} + +impl Content { + fn spanned(&self) -> Vec { + self.entities + .iter() + .filter_map(|entity| { + let span = entity.span.as_ref()?; + Some(Spanned { + id: entity.id, + name: entity.name.clone(), + kind: entity.kind, + span: span.start_byte..span.end_byte, + }) + }) + .collect() + } +} + +/// What the chosen ids name. +#[derive(Debug, Default)] +struct Chosen { + /// Entities Kin parses, by id. + code: HashSet, + /// The contents those entities live in, in either version. + paths: BTreeSet, + /// Unparsed entities, which are kept or restored whole. + unparsed: Vec, + /// A name for each chosen id, for the sentences refusals say. + names: HashMap, +} + +impl Chosen { + fn name(&self, id: &EntityId) -> String { + self.names + .get(id) + .cloned() + .unwrap_or_else(|| id.to_string()) + } +} + +/// What one content becomes. +#[derive(Debug)] +struct Transition { + path: RepoPath, + from: Content, + to: Content, + /// The artifact, entry and bytes the content takes, or `None` when it + /// leaves the tree. + after: Option<(ArtifactId, TreeEntry, Vec)>, + /// Parsed declarations that take an identity the version they are read + /// from does not hold yet, by kind and name. + retain: HashMap<(EntityKind, String), EntityId>, +} + +impl Transition { + fn changes_anything(&self) -> bool { + match (&self.from.artifact, &self.after) { + (None, None) => false, + (Some(before), Some((artifact, entry, _))) => { + before.artifact_id != *artifact || before.entry != *entry + } + _ => true, + } + } + + fn after_bytes(&self) -> &[u8] { + self.after + .as_ref() + .map(|(_, _, bytes)| bytes.as_slice()) + .unwrap_or_default() + } +} + +fn parse_entity(raw: &str) -> Result { + uuid::Uuid::parse_str(raw.trim()) + .map(EntityId) + .map_err(|_| Refusal::worded(format!("{} does not name an entity.", raw.trim()))) +} + +/// The name an unparsed entity goes by: its own name, without where it is +/// projected. +fn unparsed_name(artifact: &ResolvedArtifact) -> String { + artifact + .path + .as_utf8() + .and_then(|path| path.rsplit('/').next()) + .unwrap_or("this unparsed entity") + .to_string() +} + +/// Read what the chosen ids name in the two versions. +fn classify(work: Work, raw: &[String], from: &Held<'_>, to: &Held<'_>) -> Result { + let mut chosen = Chosen::default(); + for raw in raw { + let id = parse_entity(raw)?; + if kin_mcp::artifact_entity::parts::is_part_id(&id.0) { + return Err(Refusal::worded(format!( + "Kin cannot {} one section of a text on its own; choose the whole text.", + work.verb() + ))); + } + let held = [from.entities.get(&id), to.entities.get(&id)]; + let origins = held + .iter() + .flatten() + .filter(|entity| entity.span.is_some()) + .filter_map(|entity| entity.file_origin.as_ref()) + .filter_map(|origin| RepoPath::from_utf8(origin.0.clone()).ok()) + .collect::>(); + if let Some(entity) = held.iter().flatten().next() { + if is_whole_unit(entity.kind) { + return Err(Refusal::worded(format!( + "{} stands for its whole content, so Kin {}s the entities in it one at a time.", + entity.name, + work.verb() + ))); + } + if !origins.is_empty() { + chosen.names.insert(id, entity.name.clone()); + chosen.code.insert(id); + chosen.paths.extend(origins); + continue; + } + } + let artifact = ArtifactId(id.0); + let (before, after) = (from.tree.get(&artifact), to.tree.get(&artifact)); + let Some(named) = before.or(after) else { + return Err(Refusal::worded(match work { + Work::Commit => format!("No uncommitted work names entity {}.", raw.trim()), + Work::Restore => format!( + "Neither the working copy nor that change holds entity {}.", + raw.trim() + ), + })); + }; + let name = unparsed_name(named); + if before == after { + return Err(Refusal::worded(match work { + Work::Commit => format!( + "{name} has no uncommitted change, so there is nothing of it to commit." + ), + Work::Restore => { + format!("{name} already matches that change, so there is nothing to restore.") + } + })); + } + let parsed = [before, after].into_iter().flatten().any(|artifact| { + artifact.path.as_utf8().is_some_and(|path| { + !from.entities.in_path(path).is_empty() || !to.entities.in_path(path).is_empty() + }) + }); + if parsed { + return Err(Refusal::worded(format!( + "{name} is held as code, so Kin {}s the entities in it one at a time.", + work.verb() + ))); + } + chosen.names.insert(id, name); + chosen.unparsed.push(artifact); + } + if chosen.code.is_empty() && chosen.unparsed.is_empty() { + return Err(Refusal::worded(format!( + "Choose at least one entity to {}.", + work.verb() + ))); + } + Ok(chosen) +} + +/// One content as `held` has it at `path`. +fn content( + state: &DaemonState, + authority: &Authority, + work: Work, + held: &Held<'_>, + path: &RepoPath, + names: &str, +) -> Result { + let Some(artifact) = held.tree.artifact_at_path(path).cloned() else { + return Ok(Content { + entities: path + .as_utf8() + .map(|path| held.entities.in_path(path)) + .unwrap_or_default(), + ..Content::default() + }); + }; + let TreeEntry::Blob { hash, .. } = artifact.entry else { + return Err(Refusal::worded(format!( + "{names} is not held as text Kin can separate, so {}.", + work.nothing() + ))); + }; + let bytes = crate::source_cas::read_publishable_source(state.blobs.as_ref(), authority, hash) + .map_err(|error| Refusal::failed("read the recorded content", work, error))? + .body() + .to_vec(); + let entities = path + .as_utf8() + .map(|path| held.entities.in_path(path)) + .unwrap_or_default(); + Ok(Content { + artifact: Some(artifact), + bytes, + entities, + }) +} + +/// Separate the chosen entities' changes in every content they live in. +fn separate( + state: &DaemonState, + authority: &Authority, + work: Work, + chosen: &Chosen, + from: &Held<'_>, + to: &Held<'_>, + outside: Outside, +) -> Result<(Vec, BTreeSet), Refusal> { + let names = chosen + .code + .iter() + .map(|id| chosen.name(id)) + .collect::>() + .into_iter() + .collect::>() + .join(", "); + let mut transitions = Vec::new(); + let mut changed = BTreeSet::new(); + for path in &chosen.paths { + let from_content = content(state, authority, work, from, path, &names)?; + let to_content = content(state, authority, work, to, path, &names)?; + // A content whose identity moved, or that was replaced by another, + // changed as a whole, and its entities have no place to be kept apart + // from that move. + let moved = |artifact: &Option, other: &Held<'_>| { + artifact.as_ref().is_some_and(|artifact| { + other + .tree + .get(&artifact.artifact_id) + .is_some_and(|there| there.path != *path) + }) + }; + let replaced = matches!( + (&from_content.artifact, &to_content.artifact), + (Some(before), Some(after)) if before.artifact_id != after.artifact_id + ); + if replaced || moved(&from_content.artifact, to) || moved(&to_content.artifact, from) { + return Err(Refusal::worded(format!( + "{names} sits in content that was moved or replaced as a whole, so Kin cannot {} it \ + apart from that content.", + work.verb() + ))); + } + let (from_spans, to_spans) = (from_content.spanned(), to_content.spanned()); + let selected = crate::entity_split::select( + Version { + bytes: &from_content.bytes, + entities: &from_spans, + }, + Version { + bytes: &to_content.bytes, + entities: &to_spans, + }, + &chosen.code, + outside, + ) + .map_err(|entangled| { + Refusal::worded(format!( + "{} and {} were changed in the same place, so Kin cannot {} one without the other.", + entangled.chosen, + entangled.other, + work.verb() + )) + })?; + changed.extend(selected.changed.iter().copied()); + // Repository authority admits content no commit holds yet only as the + // workspace holds it, so a change cannot introduce part of it. Text + // being restored into a working copy is no change and has no such + // bound. + if work == Work::Commit + && from_content.artifact.is_none() + && selected.bytes != to_content.bytes + && selected.bytes != from_content.bytes + { + let named = |picked: bool| { + selected + .changed + .iter() + .find(|id| chosen.code.contains(id) == picked) + .and_then(|id| to_content.entities.iter().find(|entity| entity.id == *id)) + .map(|entity| entity.name.clone()) + }; + return Err(Refusal::worded(match (named(true), named(false)) { + (Some(kept), Some(left)) => format!( + "{kept} and {left} are in content no commit holds yet, which Kin commits \ + whole, so it cannot commit one without the other." + ), + _ => format!( + "{names} is in content no commit holds yet, which Kin commits whole, so {}.", + work.nothing() + ), + })); + } + let after = if selected.bytes == to_content.bytes { + to_content + .artifact + .as_ref() + .map(|artifact| (artifact.artifact_id, artifact.entry, selected.bytes.clone())) + } else if selected.bytes == from_content.bytes { + from_content + .artifact + .as_ref() + .map(|artifact| (artifact.artifact_id, artifact.entry, selected.bytes.clone())) + } else { + let carrier = from_content + .artifact + .as_ref() + .or(to_content.artifact.as_ref()) + .expect("a content a chosen entity lives in is held by one version"); + let executable = match carrier.entry { + TreeEntry::Blob { executable, .. } => executable, + _ => false, + }; + let hash = kin_blobs::digest(&selected.bytes); + Some(( + carrier.artifact_id, + TreeEntry::blob(hash, executable), + selected.bytes, + )) + }; + // A chosen declaration the starting version does not hold yet keeps + // the identity the other version gave it, so the work left behind + // reads it as the same entity and not as one removed and one added. + let mut retain = HashMap::new(); + let mut seen: HashMap<(EntityKind, String), usize> = HashMap::new(); + for entity in &to_content.entities { + *seen.entry((entity.kind, entity.name.clone())).or_default() += 1; + } + for entity in &to_content.entities { + let key = (entity.kind, entity.name.clone()); + if chosen.code.contains(&entity.id) + && from.entities.get(&entity.id).is_none() + && seen.get(&key) == Some(&1) + { + retain.insert(key, entity.id); + } + } + transitions.push(Transition { + path: path.clone(), + from: from_content, + to: to_content, + after, + retain, + }); + } + Ok((transitions, changed)) +} + +/// The graph a version of the repository holds, built from the daemon's live +/// graph so it carries every index and record a derivation reads. +fn graph_at( + state: &DaemonState, + target: &ResolvedGraphState, +) -> Result { + graph_toward(state.graph.to_snapshot(), target) +} + +/// `current` moved to hold exactly `target`'s entities, relations and tree, +/// with the external symbols and resolution records those relations name, and +/// every other index and record `current` carries. +pub(crate) fn graph_toward( + current: kin_db::GraphSnapshot, + target: &ResolvedGraphState, +) -> Result { + let semantic = kin_core::diff_workspace_semantics( + ¤t.entities, + ¤t.relations, + &target.entities, + &target.relations, + ) + .map_err(|error| error.to_string())?; + let (references, records) = kin_core::desired_resolution_nodes( + &target.external_references, + &target.resolution_records, + ¤t.external_references, + ¤t.resolution_records, + target.relations.values(), + ); + let semantic = kin_core::with_resolution_node_transition( + semantic, + ¤t.external_references, + ¤t.resolution_records, + &references, + &records, + ) + .map_err(|error| error.to_string())?; + let mut delta = semantic.transaction_delta(); + delta.tree_deltas = kin_core::exact_tree_correction(¤t.resolved_tree, &target.tree) + .map_err(|error| error.to_string())?; + let graph = kin_db::InMemoryGraph::from_snapshot_without_text_index(current) + .map_err(|error| error.to_string())?; + graph + .apply_transaction_delta(&delta) + .map_err(|error| error.to_string())?; + Ok(graph) +} + +/// One version of the repository's semantics, read as plain maps: a change's +/// resolved state or a graph's snapshot. +#[derive(Clone, Copy)] +pub(crate) struct Semantics<'a> { + entities: &'a HashMap, + relations: &'a HashMap, + tree: &'a ResolvedTree, + external_references: &'a HashMap, + resolution_records: &'a HashMap, +} + +impl<'a> Semantics<'a> { + pub(crate) fn of_state(state: &'a ResolvedGraphState) -> Self { + Self { + entities: &state.entities, + relations: &state.relations, + tree: &state.tree, + external_references: &state.external_references, + resolution_records: &state.resolution_records, + } + } + + pub(crate) fn of_snapshot(snapshot: &'a kin_db::GraphSnapshot) -> Self { + Self { + entities: &snapshot.entities, + relations: &snapshot.relations, + tree: &snapshot.resolved_tree, + external_references: &snapshot.external_references, + resolution_records: &snapshot.resolution_records, + } + } +} + +/// `current` with the source units at `units` exactly as `source` holds them. +/// +/// This is how a change leaves uncommitted work out of itself without a second +/// planner: the transaction's own plan is `current`, and the units it did not +/// write are put back as the base change holds them, so the change records +/// only what the transaction wrote. The same move with the daemon's live graph +/// as `source` gives the working copy the transaction leaves behind, whose +/// uncommitted units keep the entities, and so the identities and authorship, +/// the daemon already serves for them. +/// +/// A unit moves whole: its tree entry, every entity whose origin is in it, and +/// every relationship that starts or ends at one of those. A relationship the +/// transaction itself changed (`changed` answers that, from the value +/// `current` holds) keeps `current`'s value, since its own operations are what +/// set it; any other takes `source`'s. A relationship left naming an entity or +/// content the result does not hold is dropped, unless it is one the +/// transaction asked for by name in `required`, which refuses instead, because +/// that relationship would name uncommitted work. +/// +/// An entity that moved between a unit in `units` and one outside them has a +/// place on both sides of the line, so nothing can put one side back without +/// the other, and the move is refused rather than split. +pub(crate) fn with_units_from( + current: kin_db::GraphSnapshot, + source: Semantics<'_>, + units: &BTreeSet, + changed: &dyn Fn(&RelationId, Option<&Relation>) -> bool, + required: &BTreeSet, +) -> Result { + let unit_paths = units + .iter() + .filter_map(|path| path.as_utf8()) + .collect::>(); + let in_units = |entity: &Entity| { + entity + .file_origin + .as_ref() + .is_some_and(|origin| unit_paths.contains(origin.0.as_str())) + }; + + // The tree, with the units' entries as `source` holds them. + let taken = units + .iter() + .filter_map(|path| current.resolved_tree.artifact_id_at_path(path)) + .collect::>(); + let given = units + .iter() + .filter_map(|path| source.tree.artifact_at_path(path)) + .collect::>(); + let mut artifacts = current + .resolved_tree + .artifacts() + .filter(|artifact| !taken.contains(&artifact.artifact_id)) + .cloned() + .collect::>(); + let standing = artifacts + .iter() + .map(|artifact| artifact.artifact_id) + .collect::>(); + for artifact in &given { + if standing.contains(&artifact.artifact_id) { + return Err(format!( + "{} moved between work this change records and uncommitted work it leaves in \ + place", + unparsed_name(artifact) + )); + } + artifacts.push((*artifact).clone()); + } + let tree = ResolvedTree::from_artifacts(artifacts).map_err(|error| error.to_string())?; + let unit_artifacts = taken + .iter() + .copied() + .chain(given.iter().map(|artifact| artifact.artifact_id)) + .collect::>(); + + // Every entity in the units, as `source` holds it. + let scoped = current + .entities + .values() + .chain(source.entities.values()) + .filter(|entity| in_units(entity)) + .map(|entity| entity.id) + .collect::>(); + let mut entities = current.entities.clone(); + for id in &scoped { + let (here, there) = (current.entities.get(id), source.entities.get(id)); + if let Some(outside) = here + .into_iter() + .chain(there) + .find(|entity| entity.file_origin.is_some() && !in_units(entity)) + { + return Err(format!( + "{} moved between work this change records and uncommitted work it leaves in \ + place", + outside.name + )); + } + match there { + Some(entity) => entities.insert(*id, entity.clone()), + None => entities.remove(id), + }; + } + + // Every relationship that starts or ends in the units. + let touches = |node: &GraphNodeId| match node { + GraphNodeId::Entity(id) => scoped.contains(id), + GraphNodeId::Artifact(id) => unit_artifacts.contains(id), + _ => false, + }; + let candidates = current + .relations + .values() + .chain(source.relations.values()) + .filter(|relation| touches(&relation.src) || touches(&relation.dst)) + .map(|relation| relation.id) + .collect::>(); + let mut relations = current.relations.clone(); + let mut external_references = current.external_references.clone(); + let mut resolution_records = current.resolution_records.clone(); + for id in candidates { + let here = current.relations.get(&id); + let value = if changed(&id, here) { + here + } else { + source.relations.get(&id) + }; + let Some(relation) = value else { + relations.remove(&id); + continue; + }; + let held = |node: &GraphNodeId| match node { + GraphNodeId::Entity(entity) => entities.contains_key(entity), + GraphNodeId::Artifact(artifact) => tree.get(artifact).is_some(), + GraphNodeId::ExternalReference(reference) => { + external_references.contains_key(reference) + || source.external_references.contains_key(reference) + } + _ => true, + }; + if !(held(&relation.src) && held(&relation.dst)) { + if required.contains(&id) { + let name = |node: &GraphNodeId| match node { + GraphNodeId::Entity(entity) => current + .entities + .get(entity) + .or_else(|| source.entities.get(entity)) + .map(|entity| entity.name.clone()) + .unwrap_or_else(|| entity.to_string()), + other => format!("{other:?}"), + }; + return Err(format!( + "the relationship from {} to {} names an entity that exists only in \ + uncommitted work this change leaves in place", + name(&relation.src), + name(&relation.dst) + )); + } + relations.remove(&id); + continue; + } + // The external symbols and resolution records it names come with it. + for node in [relation.src, relation.dst] { + if let GraphNodeId::ExternalReference(reference) = node { + if !external_references.contains_key(&reference) { + if let Some(found) = source.external_references.get(&reference) { + external_references.insert(reference, found.clone()); + } + } + } + } + for record in relation + .evidence + .iter() + .filter_map(|evidence| evidence.token.as_deref()) + .filter_map(ResolutionRecordId::from_context_token) + { + if !resolution_records.contains_key(&record) { + if let Some(found) = source.resolution_records.get(&record) { + resolution_records.insert(record, found.clone()); + } + } + } + relations.insert(id, relation.clone()); + } + + let target = ResolvedGraphState { + entities, + relations, + tree, + external_references, + resolution_records, + ..ResolvedGraphState::default() + }; + graph_toward(current, &target) +} + +/// Why installing the new contents failed. +enum InstallError { + /// A content no longer parses as its language. + Unparseable, + Other(String), +} + +impl From for InstallError { + fn from(message: String) -> Self { + Self::Other(message) + } +} + +/// Install every transition into `prospective` and derive its entities again +/// from the exact new bytes, the way every admitted source is derived. +fn install( + state: &DaemonState, + authority: &Authority, + prospective: &kin_db::InMemoryGraph, + transitions: &[Transition], + unparsed: &[(Option, Option)], +) -> Result<(), InstallError> { + for transition in transitions { + if let (Some(before), None) = (&transition.from.artifact, &transition.after) { + retire(prospective, before)?; + } + } + let unparsed_deltas = unparsed + .iter() + .filter_map(|(before, after)| match (before, after) { + (Some(before), Some(after)) if before != after => Some(TreeDelta::Updated { + artifact_id: before.artifact_id, + old: before.located_entry(), + new: after.located_entry(), + }), + (Some(before), None) => Some(TreeDelta::Removed { + artifact_id: before.artifact_id, + old: before.located_entry(), + }), + (None, Some(after)) => Some(TreeDelta::Added { + artifact_id: after.artifact_id, + new: after.located_entry(), + }), + _ => None, + }) + .collect::>(); + if !unparsed_deltas.is_empty() { + prospective + .apply_transaction_delta(&TransactionDelta { + tree_deltas: unparsed_deltas, + ..TransactionDelta::default() + }) + .map_err(|error| format!("install unparsed content: {error}"))?; + } + + let mut reconciler = kin_reconcile::Reconciler::new(PathBuf::new()); + reconciler.seed_cross_file_linker_from_graph(prospective); + for transition in transitions { + let Some((artifact, entry, bytes)) = &transition.after else { + continue; + }; + if !transition.changes_anything() { + continue; + } + let file_id = FilePathId::new(transition.path.as_utf8().unwrap_or_default()); + let digest = state + .blobs + .write(bytes) + .map_err(|error| format!("stage new content: {error}"))?; + if entry.blob_identity() != Some(digest) { + return Err(InstallError::Other( + "staged content does not match the entry it was planned for".into(), + )); + } + let new = LocatedEntry::new(transition.path.clone(), *entry); + let tree_delta = match &transition.from.artifact { + Some(before) => TreeDelta::Updated { + artifact_id: before.artifact_id, + old: before.located_entry(), + new, + }, + None => TreeDelta::Added { + artifact_id: *artifact, + new, + }, + }; + prospective + .apply_transaction_delta(&TransactionDelta { + tree_deltas: vec![tree_delta], + ..TransactionDelta::default() + }) + .map_err(|error| format!("install new content: {error}"))?; + let manifests = + crate::source_cas::graph_manifests(prospective, state.blobs.as_ref(), Some(authority)); + let indexed = kin_index::shared_index::index_any(&file_id, bytes, digest, &manifests) + .map_err(|error| format!("parse new content: {error}"))?; + let kin_index::IndexedAny::EntitySource(mut indexed) = indexed else { + let held = prospective + .query_entities(&kin_model::EntityFilter { + file_path: Some(file_id.clone()), + ..Default::default() + }) + .map_err(|error| format!("read entities: {error}"))?; + if !held.is_empty() { + crate::loop_runner::clear_incompatible_facets_in( + prospective, + &file_id, + crate::loop_runner::EnrichmentFacet::None, + ) + .map_err(|error| format!("retire entities: {error}"))?; + } + continue; + }; + if !matches!(indexed.parse_state, kin_model::ParseState::Valid) { + return Err(InstallError::Unparseable); + } + let parsed = indexed + .entities + .iter() + .map(|entity| (entity.id, entity.kind, entity.name.clone())) + .collect::>(); + for (parsed, kind, name) in parsed { + if let Some(retained) = transition.retain.get(&(kind, name)) { + if *retained != parsed { + indexed + .retain_entity_identity(parsed, *retained) + .map_err(|error| format!("keep an entity's identity: {error}"))?; + } + } + } + let reconcile = reconciler + .reconcile_indexed_content(&indexed, state.blobs.as_ref(), prospective) + .map_err(|error| match error { + kin_reconcile::ReconcileError::IncompleteParseWouldDelete { .. } + | kin_reconcile::ReconcileError::BrokenAstRejected { .. } => { + InstallError::Unparseable + } + other => InstallError::Other(format!("derive entities: {other}")), + })?; + prospective + .apply_transaction_delta(&reconcile.delta) + .map_err(|error| format!("apply derived entities: {error}"))?; + if let Some(layout) = reconciler.projection().get_layout(&file_id).cloned() { + prospective + .upsert_file_layout(&layout) + .map_err(|error| format!("install layout: {error}"))?; + } + } + Ok(()) +} + +/// Take one content out of `prospective` with its entities and the edges that +/// address it. +fn retire(prospective: &kin_db::InMemoryGraph, before: &ResolvedArtifact) -> Result<(), String> { + let file_id = FilePathId::new(before.path.as_utf8().unwrap_or_default()); + crate::loop_runner::clear_incompatible_facets_in( + prospective, + &file_id, + crate::loop_runner::EnrichmentFacet::None, + ) + .map_err(|error| format!("retire entities: {error}"))?; + let node = GraphNodeId::Artifact(before.artifact_id); + let relation_deltas = prospective + .get_all_relations_for_node(&node) + .map_err(|error| format!("read edges: {error}"))? + .into_iter() + .map(|old| RelationDelta::Removed { old }) + .collect(); + prospective + .apply_transaction_delta(&TransactionDelta { + relation_deltas, + tree_deltas: vec![TreeDelta::Removed { + artifact_id: before.artifact_id, + old: before.located_entry(), + }], + ..TransactionDelta::default() + }) + .map_err(|error| format!("retire content: {error}")) +} + +/// Prove one content's derived entities: every chosen entity reads exactly as +/// the version it was taken from, and every other entity exactly as the +/// version it was left at. +/// +/// An entity that holds others whose choice differs from its own, such as a +/// class one of whose methods is chosen, is held to its identity, kind and +/// name rather than its whole text, because its text is by construction part +/// one version's and part the other's. Its own changes are proved by the +/// separation, which never splits an entity's own text. +fn prove( + prospective: &kin_db::InMemoryGraph, + transition: &Transition, + chosen: &HashSet, +) -> Result<(), String> { + let path = transition.path.as_utf8().unwrap_or_default(); + let after = Entities::Live(prospective) + .in_path(path) + .into_iter() + .map(|entity| (entity.id, entity)) + .collect::>(); + let from = transition + .from + .entities + .iter() + .map(|entity| (entity.id, entity)) + .collect::>(); + let to = transition + .to + .entities + .iter() + .map(|entity| (entity.id, entity)) + .collect::>(); + let body = |bytes: &[u8], entity: &Entity| -> Option> { + let span = entity.span.as_ref()?; + bytes + .get(span.start_byte..span.end_byte) + .map(<[u8]>::to_vec) + }; + let versions: [Vec<&Entity>; 3] = [ + from.values().copied().collect(), + to.values().copied().collect(), + after.values().collect(), + ]; + let holds_differently = |id: &EntityId| { + let picked = chosen.contains(id); + versions.iter().any(|version| { + let Some(outer) = version + .iter() + .find(|entity| entity.id == *id) + .and_then(|entity| entity.span.as_ref()) + else { + return false; + }; + version.iter().any(|inner| { + inner.id != *id + && !is_whole_unit(inner.kind) + && chosen.contains(&inner.id) != picked + && inner.span.as_ref().is_some_and(|span| { + outer.start_byte <= span.start_byte && span.end_byte <= outer.end_byte + }) + }) + }) + }; + let ids = from + .keys() + .chain(to.keys()) + .chain(after.keys()) + .copied() + .collect::>(); + for id in ids { + let kind = from + .get(&id) + .map(|entity| entity.kind) + .or_else(|| to.get(&id).map(|entity| entity.kind)) + .or_else(|| after.get(&id).map(|entity| entity.kind)); + if kind.is_some_and(is_whole_unit) { + continue; + } + let (expected, bytes) = if chosen.contains(&id) { + (to.get(&id), transition.to.bytes.as_slice()) + } else { + (from.get(&id), transition.from.bytes.as_slice()) + }; + match (expected, after.get(&id)) { + (None, None) => {} + (Some(expected), None) => { + return Err(format!("{} was lost", expected.name)); + } + (None, Some(got)) => { + return Err(format!("{} appeared", got.name)); + } + (Some(expected), Some(got)) => { + if expected.kind != got.kind || expected.name != got.name { + return Err(format!("{} changed identity", expected.name)); + } + if holds_differently(&id) { + continue; + } + if body(bytes, expected) != body(transition.after_bytes(), got) { + return Err(format!("{} does not read as it should", expected.name)); + } + } + } + } + Ok(()) +} + +/// Refuse a planned change that carries anything outside the chosen work. +fn confined( + change: &SemanticChange, + transitions: &[Transition], + unparsed: &[ArtifactId], + prospective: &kin_db::InMemoryGraph, +) -> Result<(), String> { + let paths = transitions + .iter() + .filter_map(|transition| transition.path.as_utf8().map(str::to_string)) + .collect::>(); + let mut artifacts = unparsed.iter().copied().collect::>(); + let mut entities = HashSet::new(); + for transition in transitions { + for content in [&transition.from, &transition.to] { + artifacts.extend( + content + .artifact + .as_ref() + .map(|artifact| artifact.artifact_id), + ); + entities.extend(content.entities.iter().map(|entity| entity.id)); + } + if let Some((artifact, _, _)) = &transition.after { + artifacts.insert(*artifact); + } + entities.extend( + Entities::Live(prospective) + .in_path(transition.path.as_utf8().unwrap_or_default()) + .into_iter() + .map(|entity| entity.id), + ); + } + let local = |entity: &Entity| { + entities.contains(&entity.id) + || entity + .file_origin + .as_ref() + .is_some_and(|origin| paths.contains(&origin.0)) + }; + for delta in &change.entity_deltas { + let ok = match delta { + EntityDelta::Added { new } => local(new), + EntityDelta::Modified { old, new } => local(old) && local(new), + EntityDelta::Removed { old } => local(old), + }; + if !ok { + return Err(format!( + "the change would also carry entity {}", + delta.target_id() + )); + } + } + for delta in &change.tree_deltas { + if !artifacts.contains(&delta.artifact_id()) { + return Err("the change would also carry other content".into()); + } + } + let node_local = |node: &GraphNodeId| match node { + GraphNodeId::Entity(id) => entities.contains(id), + GraphNodeId::Artifact(id) => artifacts.contains(id), + _ => false, + }; + for delta in &change.relation_deltas { + let ok = match delta { + RelationDelta::Added { new } => node_local(&new.src) || node_local(&new.dst), + RelationDelta::Modified { old, new } => { + node_local(&old.src) + || node_local(&old.dst) + || node_local(&new.src) + || node_local(&new.dst) + } + RelationDelta::Removed { old } => node_local(&old.src) || node_local(&old.dst), + }; + if !ok { + return Err("the change would also carry a relation outside the chosen work".into()); + } + } + Ok(()) +} + +type Lease = kin_db::AuthorityReadLease; + +/// This working copy's workspace as one read of repository authority holds it. +fn workspace_in(lease: &Lease, workspace_id: kin_model::WorkspaceId) -> Option { + lease + .metadata() + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .cloned() +} + +/// The receipt one read of repository authority holds for an operation, when +/// that operation landed. +fn receipt_in(lease: &Lease, operation_id: OperationId) -> Option { + kin_core::rejoined_receipt(lease.metadata(), operation_id) +} + +/// The change an operation already published, read from authority. +fn landed( + authority: &Authority, + operation_id: OperationId, +) -> Option<(SemanticChange, Option)> { + let lease = authority.read_authority(); + let receipt = receipt_in(&lease, operation_id)?; + let published = kin_core::published_change(&receipt.operation)?; + let change = lease.snapshot().changes.get(&published.change_id)?.clone(); + Some((change, published.branch.map(|branch| branch.to_string()))) +} + +fn commit_response( + change: &SemanticChange, + branch: Option, + request: &EntityCommitRequest, + replayed: bool, +) -> EntityCommitResponse { + EntityCommitResponse { + change_id: change.id.to_string(), + branch, + entity_count: change.entity_deltas.len(), + relation_count: change.relation_deltas.len(), + file_count: change.tree_deltas.len(), + kept: request + .entities + .iter() + .map(|id| id.trim().to_string()) + .collect(), + replayed, + } +} + +/// Commit the chosen entities' uncommitted changes and leave every other +/// change uncommitted. +/// +/// The caller holds the coordination gate and has just admitted the working +/// copy, so the daemon's graph is the working copy this plans against. +pub(crate) fn commit_entities( + state: &Arc, + request: &EntityCommitRequest, +) -> Result { + let work = Work::Commit; + let message = request.message.trim(); + if message.is_empty() { + return Err(Refusal::worded("A commit needs a message.")); + } + let context = LocalRepositoryAuthorityContext::from_state(state) + .map_err(|error| Refusal::failed("open this repository", work, error))?; + let held = crate::api::held_repository_authority(state) + .map_err(|(_, error)| Refusal::failed("open this repository", work, error))?; + if let Some((change, branch)) = landed(&held, request.operation_id) { + return Ok(commit_response(&change, branch, request, true)); + } + + let lease = held.read_authority(); + let roots = lease.roots().clone(); + let workspace = workspace_in(&lease, context.workspace_id()) + .ok_or_else(|| Refusal::failed("find this working copy", work, "no local workspace"))?; + let live_tree = state.graph.resolved_tree(); + if live_tree != workspace.tree { + return Err(Refusal::worded( + "Kin is still reading the latest edits, so nothing was committed; try again in a \ + moment.", + )); + } + let head = workspace + .base_target + .as_ref() + .map(|target| lease.resolve_target_change_id(target)) + .transpose() + .map_err(|error| Refusal::failed("read the last commit", work, error))?; + let head_state = match head { + Some(head) => crate::commit_deltas::resolve_authority_baseline(lease.snapshot(), &head) + .map_err(|error| Refusal::failed("read the last commit", work, error))? + .into_owned(), + None => ResolvedGraphState::default(), + }; + drop(lease); + + let from = Held { + tree: &head_state.tree, + entities: Entities::Recorded(&head_state.entities), + }; + let to = Held { + tree: &live_tree, + entities: Entities::Live(state.graph.as_ref()), + }; + let chosen = classify(work, &request.entities, &from, &to)?; + let (transitions, changed) = separate( + state, + &held, + work, + &chosen, + &from, + &to, + Outside::WhenEveryEntityIsChosen, + )?; + if let Some(unchanged) = chosen.code.iter().find(|id| !changed.contains(id)) { + return Err(Refusal::worded(format!( + "{} has no uncommitted change of its own, so there is nothing of it to commit.", + chosen.name(unchanged) + ))); + } + let unparsed = chosen + .unparsed + .iter() + .map(|artifact| { + ( + head_state.tree.get(artifact).cloned(), + live_tree.get(artifact).cloned(), + ) + }) + .collect::>(); + let names = chosen + .names + .values() + .cloned() + .collect::>() + .into_iter() + .collect::>() + .join(", "); + + let prospective = graph_at(state, &head_state) + .map_err(|error| Refusal::failed("read the last commit", work, error))?; + match install(state, &held, &prospective, &transitions, &unparsed) { + Ok(()) => {} + Err(InstallError::Unparseable) => { + return Err(Refusal::worded(format!( + "Committing {names} without the rest of the uncommitted work would leave code that \ + does not parse, so nothing was committed." + ))) + } + Err(InstallError::Other(error)) => { + return Err(Refusal::failed( + &format!("separate {names} from the rest of the uncommitted work"), + work, + error, + )) + } + } + for transition in &transitions { + prove(&prospective, transition, &chosen.code).map_err(|error| { + Refusal::failed( + &format!("separate {names} from the rest of the uncommitted work exactly"), + work, + error, + ) + })?; + } + + let plan = crate::repository_commit::plan_kept_workspace_commit( + &prospective, + state.graph.as_ref(), + state.blobs.as_ref(), + &context, + Arc::clone(&held), + &roots, + request.operation_id, + request.timestamp.clone(), + request.author.clone(), + message.to_string(), + ) + .map_err(|error| Refusal::failed(&format!("record {names}"), work, error))?; + confined(&plan.change, &transitions, &chosen.unparsed, &prospective).map_err(|error| { + Refusal::failed( + &format!("separate {names} from the rest of the uncommitted work exactly"), + work, + error, + ) + })?; + drop(prospective); + if let Some(blocked) = crate::api::foreign_hard_lease_refusal( + state, + &plan.entity_scope_ids, + request.session_id.as_deref(), + ) + .map_err(|(_, error)| Refusal::failed("check who else is working", work, error))? + { + tracing::info!(blocked = %blocked.1, "an entity commit met a hard lease"); + return Err(Refusal::worded( + "Another session holds a lease on one of these entities, so nothing was committed.", + )); + } + + let committed = match crate::repository_commit::commit_native_plan_with_projection( + &state.layout, + state.blobs.as_ref(), + &context, + plan, + ) { + Ok(committed) => committed, + Err(error) => { + match crate::repository_commit::recover_native_commit(&context, request.operation_id) { + Ok(Some(recovered)) => recovered, + Ok(None) => { + return Err(Refusal::failed( + &format!("record {names} over the working copy as it now is"), + work, + error, + )) + } + Err(recovery) => { + return Err(Refusal::unsettled(format!( + "{error}; reading repository authority afterwards also failed: {recovery}" + ))) + } + } + } + }; + after_commit( + state, + &committed.change, + committed.receipt.generation, + committed.resolved_publication_base, + &chosen.code, + ) + .map_err(Refusal::unsettled)?; + Ok(commit_response( + &committed.change, + committed.target.branch().map(|name| name.to_string()), + request, + false, + )) +} + +/// What every native commit does once authority holds its change. +fn after_commit( + state: &Arc, + change: &SemanticChange, + generation: u64, + base: Option, + chosen: &HashSet, +) -> Result<(), String> { + crate::client_edits::seal_kept_commit(state, change, base, chosen); + crate::api::relabel_held_authority_after_own_commit(state); + state + .record_repository_authority_commit(generation) + .map_err(|error| error.to_string())?; + // The working copy did not move, so the live graph keeps every entity it + // holds and only learns the new change. + crate::mcp_commit::timed_commit_phase("install_live_graph", || { + state.graph.create_change(change) + }) + .map_err(|error| error.to_string())?; + crate::binding_history::restore_exact_authority(state); + state.record_durable_entity_count(state.graph.entity_count() as u64); + state.record_durable_relation_count(state.graph.relation_count() as u64); + crate::background_work::record_relation_census( + state, + state.graph.as_ref(), + kin_core::relation_census::CensusSource::Commit, + ); + state.bump_version(); + state.emit_event(DaemonEvent::GraphRootChanged { + old_root_hash: None, + new_root_hash: change.id.to_string(), + }); + Ok(()) +} + +/// Parse a change id the caller named. +fn parse_change(raw: &str) -> Result { + let raw = raw.trim(); + kin_model::Hash256::from_hex(raw) + .ok() + .filter(|hash| hash.to_string() == raw) + .map(SemanticChangeId::from_hash) + .ok_or_else(|| Refusal::worded(format!("{raw} does not name a recorded change."))) +} + +/// How an entity the working copy holds now stands against the reading a +/// restore names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Reading { + /// The entity is exactly as it was read. + Current, + /// The entity changed after it was read. + Changed, + /// The reading names the entity by a semantic fingerprint, which a + /// comment or spacing edit does not move, so it cannot show that the + /// source a restore would discard is the source that was read. + Inexact, +} + +impl Reading { + /// The answer of an exact check: the entity is as read, or it changed. + fn exact(same: bool) -> Self { + if same { + Self::Current + } else { + Self::Changed + } + } +} + +/// How `expected` names the entity as the working copy holds it now. +/// +/// Only an exact span check can guard a restore, because a restore discards +/// the entity's whole source, comments and spacing included. The check is on +/// the entity's own span, so an edit to a sibling does not make a read stale, +/// while a later comment or formatting edit to this entity does. A semantic +/// fingerprint that still matches is reported as [`Reading::Inexact`] rather +/// than accepted, since a comment written after the read would match it too. +fn matches_expected( + state: &DaemonState, + authority: &Authority, + held: &Held<'_>, + id: &EntityId, + expected: &str, +) -> Result { + let entity = held.entities.get(id); + if expected == "absent" { + return Ok(Reading::exact(entity.is_none())); + } + let Some(entity) = entity else { + return Ok(Reading::Changed); + }; + let Some(expected_source) = expected.strip_prefix("source:") else { + let fingerprint = &entity.fingerprint; + let semantic = [ + fingerprint.behavior_hash, + fingerprint.ast_hash, + fingerprint.signature_hash, + ]; + return Ok( + if semantic.iter().any(|hash| hash.to_string() == expected) { + Reading::Inexact + } else { + Reading::Changed + }, + ); + }; + let Some(span) = entity.span.as_ref() else { + return Ok(Reading::Changed); + }; + let Some(path) = entity + .file_origin + .as_ref() + .and_then(|origin| RepoPath::from_utf8(origin.0.clone()).ok()) + else { + return Ok(Reading::Changed); + }; + let source = content(state, authority, Work::Restore, held, &path, &entity.name)?; + Ok(Reading::exact( + source + .bytes + .get(span.start_byte..span.end_byte) + .is_some_and(|body| kin_blobs::digest(body).to_string() == expected_source), + )) +} + +/// Put one entity back as it was at the head, or at an earlier change, as an +/// uncommitted edit. +/// +/// The caller holds the coordination gate and has just admitted the working +/// copy. +pub(crate) fn restore_entity( + state: &DaemonState, + request: &EntityRestoreRequest, +) -> Result { + let work = Work::Restore; + let graph_mutation = state.begin_graph_authority_mutation(); + let persistence = state + .persist_lock + .lock() + .map_err(|_| Refusal::unsettled("the daemon's persistence lock is poisoned"))?; + let previous_graph_root = hex::encode(state.graph.compute_root_hash()); + let authority = ActiveLocalRepositoryAuthority::open_bound(state) + .map_err(|refusal| Refusal::failed("open this repository", work, refusal.into_error()))?; + let lease = authority.manager.read_authority(); + let roots = lease.roots().clone(); + let workspace = workspace_in(&lease, authority.workspace_id) + .ok_or_else(|| Refusal::failed("find this working copy", work, "no local workspace"))?; + if let Some(receipt) = receipt_in(&lease, request.operation_id) { + // An implicit restore used the base the receipt's workspace mutation + // held then, even when another commit has advanced the base since. + // Explicit restore targets are not retained in the operation record, + // so a replay must not claim its new request's target as recorded fact. + let restored_to = if request.to.is_none() { + receipt + .operation + .workspace_mutation + .as_ref() + .filter(|mutation| mutation.workspace_id == authority.workspace_id) + .and_then(|mutation| mutation.new_base_target.as_ref()) + .and_then(|target| lease.resolve_target_change_id(target).ok()) + .map(|target| target.to_string()) + } else { + None + }; + return Ok(EntityRestoreResponse { + entity: request.entity.trim().to_string(), + name: None, + restored_to, + replayed: true, + }); + } + let target = match request.to.as_deref() { + Some(raw) => parse_change(raw)?, + None => workspace + .base_target + .as_ref() + .map(|target| lease.resolve_target_change_id(target)) + .transpose() + .map_err(|error| Refusal::failed("read the last commit", work, error))? + .ok_or_else(|| { + Refusal::worded("There is no commit to restore it from, so nothing was restored.") + })?, + }; + let workspace_graph = lease + .workspace_graph_snapshot(&workspace.workspace_id) + .map_err(|error| Refusal::failed("read the working copy", work, error))? + .ok_or_else(|| Refusal::failed("read the working copy", work, "no workspace graph"))?; + crate::local_repository_authority::require_fresh_daemon_workspace( + state, + &roots, + &workspace_graph, + "restoring an entity", + ) + .map_err(|error| { + tracing::info!(%error, "an entity restore met a daemon behind its authority"); + Refusal::worded( + "Kin is still reading the latest edits, so nothing was restored; try again in a moment.", + ) + })?; + if lease.snapshot().changes.get(&target).is_none() { + return Err(Refusal::worded(format!( + "{target} does not name a recorded change." + ))); + } + let target_state = crate::commit_deltas::resolve_authority_baseline(lease.snapshot(), &target) + .map_err(|error| Refusal::failed("read that change", work, error))? + .into_owned(); + drop(lease); + + let live_tree = state.graph.resolved_tree(); + let from = Held { + tree: &live_tree, + entities: Entities::Live(state.graph.as_ref()), + }; + let to = Held { + tree: &target_state.tree, + entities: Entities::Recorded(&target_state.entities), + }; + let chosen = classify(work, std::slice::from_ref(&request.entity), &from, &to)?; + let id = parse_entity(&request.entity)?; + let name = chosen.name(&id); + if let Some(expected) = request.expected.as_deref() { + // An unparsed entity is read whole, so its content address is exact. + let reading = match chosen.unparsed.first() { + Some(artifact) if expected == "absent" => { + Reading::exact(live_tree.get(artifact).is_none()) + } + Some(artifact) => Reading::exact( + live_tree + .get(artifact) + .and_then(|artifact| artifact.entry.blob_identity()) + .is_some_and(|hash| { + hash.to_string() == expected.strip_prefix("source:").unwrap_or(expected) + }), + ), + None => matches_expected(state, &authority.manager, &from, &id, expected)?, + }; + match reading { + Reading::Current => {} + Reading::Changed => { + return Err(Refusal::worded(format!( + "{name} changed since it was read, so nothing was restored." + ))) + } + Reading::Inexact => { + return Err(Refusal::worded(format!( + "Kin cannot tell whether {name} changed since it was read, because that \ + fingerprint stays the same when a comment or spacing changes, so nothing \ + was restored. Guard the restore with the exact source a review reports for \ + {name}, source:." + ))) + } + } + } + let (transitions, changed) = separate( + state, + &authority.manager, + work, + &chosen, + &from, + &to, + Outside::Never, + )?; + if chosen.unparsed.is_empty() && !changed.contains(&id) { + return Err(Refusal::worded(format!( + "{name} already matches that change, so there is nothing to restore." + ))); + } + let unparsed = chosen + .unparsed + .iter() + .map(|artifact| { + ( + live_tree.get(artifact).cloned(), + target_state.tree.get(artifact).cloned(), + ) + }) + .collect::>(); + + let prospective = + kin_db::InMemoryGraph::from_snapshot_without_text_index(state.graph.to_snapshot()) + .map_err(|error| Refusal::failed("read the working copy", work, error))?; + match install( + state, + &authority.manager, + &prospective, + &transitions, + &unparsed, + ) { + Ok(()) => {} + Err(InstallError::Unparseable) => { + return Err(Refusal::worded(format!( + "Restoring {name} would leave code that does not parse, so nothing was restored." + ))) + } + Err(InstallError::Other(error)) => { + return Err(Refusal::failed(&format!("restore {name}"), work, error)) + } + } + for transition in &transitions { + prove(&prospective, transition, &chosen.code) + .map_err(|error| Refusal::failed(&format!("restore {name} exactly"), work, error))?; + } + + let next_tree = prospective.resolved_tree(); + let tree_deltas = kin_core::exact_tree_correction(&workspace.tree, &next_tree) + .map_err(|error| Refusal::failed(&format!("restore {name}"), work, error))?; + let target_graph = prospective.to_snapshot(); + drop(prospective); + let semantic_delta = crate::local_repository_authority::plan_workspace_graph_transition( + TargetGraph::of_snapshot(&workspace_graph), + TargetGraph::of_snapshot(&target_graph), + ) + .map_err(|error| Refusal::failed(&format!("restore {name}"), work, error))?; + let new_tree_hash = kin_model::compute_resolved_tree_hash(&next_tree) + .map_err(|error| Refusal::failed(&format!("restore {name}"), work, error))?; + let transaction = RepositoryTransaction { + schema_version: REPOSITORY_TRANSACTION_SCHEMA_VERSION, + operation_id: request.operation_id, + repository_id: authority.repository_id.clone(), + expected_generation: roots.generation, + expected_roots: roots, + actor: request.actor.clone(), + reason: format!("restore entity {id} to its version at change {target}"), + external_objects: Vec::new(), + git_authority_delta: None, + changes: Vec::new(), + aliases: Vec::new(), + ref_mutations: Vec::new(), + default_ref_mutation: None, + workspace_mutation: Some(WorkspaceMutation { + workspace_id: workspace.workspace_id, + expected: WorkspaceExpectation::MustEqual { + generation: workspace.generation, + head: workspace.head.clone(), + base_target: workspace.base_target.clone(), + base_tree_hash: workspace.base_tree_hash, + tree_hash: workspace.tree_hash, + semantic_overlay_hash: workspace.semantic_overlay_hash, + admission_policy: workspace.admission_policy, + }, + new_generation: workspace.generation.checked_add(1).ok_or_else(|| { + Refusal::failed( + &format!("restore {name}"), + work, + "workspace generation exhausted", + ) + })?, + new_head: workspace.head.clone(), + new_base_target: workspace.base_target.clone(), + new_base_tree_hash: workspace.base_tree_hash, + tree_deltas, + new_tree_hash, + semantic_delta, + new_shared_admission_policy: workspace.shared_admission_policy.clone(), + new_admission_policy: workspace.admission_policy, + }), + local_overlay_delta: None, + merge_transaction_delta: None, + sealed_observation: None, + collaboration_delta: None, + git_snapshot_proofs: Vec::new(), + }; + transaction + .validate() + .map_err(|error| Refusal::failed(&format!("restore {name}"), work, error))?; + // The transition below projects the target tree from repository CAS, so the + // body this restore spliced has to be there first. A body is immutable and + // addressed by its content, so one a refused transition leaves behind is + // named by nothing, exactly as a commit's staged bodies are. + for transition in &transitions { + let Some(hash) = transition + .after + .as_ref() + .and_then(|(_, entry, _)| entry.blob_identity()) + else { + continue; + }; + let source = crate::source_cas::read_publishable_source( + state.blobs.as_ref(), + &authority.manager, + hash, + ) + .map_err(|error| Refusal::failed(&format!("restore {name}"), work, error))?; + if let Some(body) = source.body_to_publish() { + authority + .manager + .save_source_blob(hash, body) + .map_err(|error| Refusal::failed(&format!("restore {name}"), work, error))?; + } + } + if request.to.is_some() { + crate::hydration_requalify::before_restoring( + state, + state.graph.as_ref(), + target, + "entity restore", + ) + .map_err(|error| Refusal::failed(&format!("restore {name}"), work, error))?; + } + let published = + kin_core::tree::transition_repository_workspace_tree_and_commit_repository_transaction( + state.layout.working_dir(), + &workspace.tree, + &next_tree, + &authority.manager, + transaction, + ); + let (_, receipt, authority_freeze) = match published { + Ok(published) => published, + Err(error) => { + let landed = + receipt_in(&authority.manager.read_authority(), request.operation_id).is_some(); + return Err(if landed { + Refusal::unsettled(error) + } else { + Refusal::failed( + &format!("write {name} into the working copy as it now is"), + work, + error, + ) + }); + } + }; + let finalization = state + .finalize_local_repository_commit( + &receipt, + &authority_freeze, + &TransactionDelta::default(), + &workspace.tree, + &next_tree, + ) + .map_err(Refusal::unsettled)?; + if finalization.graph_changed { + state.bump_version(); + state.emit_event(DaemonEvent::GraphRootChanged { + old_root_hash: Some(previous_graph_root), + new_root_hash: hex::encode(state.graph.compute_root_hash()), + }); + } else if finalization.generation_advanced { + state.mark_dirty(); + } + if finalization.generation_advanced { + state.emit_event(DaemonEvent::RepositoryAuthorityChanged { + repository_id: receipt.repository_id.to_string(), + operation_id: receipt.operation_id, + previous_generation: receipt.roots_before.generation, + new_generation: receipt.generation, + }); + } + if !finalization.graph_changed { + state.invalidate_projection(); + } + crate::api::relabel_held_authority_after_own_commit(state); + drop(persistence); + drop(graph_mutation); + Ok(EntityRestoreResponse { + entity: request.entity.trim().to_string(), + name: Some(name), + restored_to: Some(target.to_string()), + replayed: false, + }) +} diff --git a/crates/kin-daemon/src/entity_lifecycle.rs b/crates/kin-daemon/src/entity_lifecycle.rs index ba993fba5..5ef9c04c0 100644 --- a/crates/kin-daemon/src/entity_lifecycle.rs +++ b/crates/kin-daemon/src/entity_lifecycle.rs @@ -33,7 +33,7 @@ use kin_parser::declaration_lifecycle::{ use kin_parser::member_placement::{member_placement_for, member_placement_languages}; use crate::local_repository_authority::LocalRepositoryAuthorityContext; -use crate::repository_commit::{load_native_source_blob, NativeCommitBase}; +use crate::repository_commit::load_native_source_blob; use crate::state::DaemonState; pub(crate) struct LifecyclePlan { @@ -529,16 +529,45 @@ fn ensure_source_cache( Ok(()) } +/// A removal takes every line of the declaration with it, so an edit to +/// anything written inside it in the same transaction would be planned and +/// then silently dropped. Refuse that pairing instead of reporting a commit +/// that lost work. +fn refuse_dropping_an_edit( + graph: &kin_db::GraphSnapshot, + edited: &std::collections::HashSet, + removed: &Entity, + file: &FilePathId, + removal: &Range, +) -> Result<(), String> { + let inside = graph.entities.values().find(|entity| { + entity.id != removed.id + && edited.contains(&entity.id) + && entity.file_origin.as_ref() == Some(file) + && entity.span.as_ref().is_some_and(|held| { + removal.start <= held.start_byte && held.end_byte <= removal.end + }) + }); + match inside { + Some(entity) => Err(format!( + "removing {} would drop the edit to {} written inside it; remove it and edit {} in separate transactions", + removed.name, entity.name, entity.name + )), + None => Ok(()), + } +} + pub(crate) fn prepare( state: &DaemonState, context: &LocalRepositoryAuthorityContext, - base: &NativeCommitBase, + prospective_graph: &kin_db::InMemoryGraph, + edited_entities: &std::collections::HashSet, operations: &[McpMutationOperation], ) -> Result, String> { if !operations.iter().any(is_lifecycle) { return Ok(None); } - let before = base.graph.to_snapshot(); + let before = prospective_graph.to_snapshot(); ensure_source_cache(state, context, &before)?; let prospective = kin_db::InMemoryGraph::from_snapshot(before.clone()).map_err(|error| error.to_string())?; @@ -557,13 +586,20 @@ pub(crate) fn prepare( create.anchor().ok_or("anchored creation lost its anchor")?; (source_base, Some((create, placement))) } - McpMutationPayload::EntityRemove(remove) => (&remove.source_base, None), + McpMutationPayload::EntityRemove(remove) => { + if edited_entities.contains(&remove.source_base.entity_id) { + return Err( + "an entity cannot be both edited and removed in one transaction".into(), + ); + } + (&remove.source_base, None) + } _ => unreachable!(), }; - let anchor = base - .graph - .get_entity(&source_base.entity_id) - .map_err(|error| error.to_string())? + let anchor = before + .entities + .get(&source_base.entity_id) + .cloned() .ok_or("lifecycle anchor no longer exists")?; kin_model::require_targeted_source(&anchor)?; let member_declaration = is_member_declaration(&anchor, create.map(|(create, _)| create)); @@ -579,19 +615,36 @@ pub(crate) fn prepare( .as_ref() .ok_or("lifecycle anchor has no source owner")?; let path = RepoPath::from_utf8(file.0.clone()).map_err(|error| error.to_string())?; - let artifact = base - .tree + let artifact = before + .resolved_tree .artifact_at_path(&path) .ok_or("lifecycle source artifact is absent")?; let TreeEntry::Blob { hash, executable } = artifact.entry else { return Err("lifecycle source is not a regular blob".into()); }; - let original = load_native_source_blob(context, hash).map_err(|error| error.to_string())?; + let digest = kin_blobs::Hash256::from_bytes(*hash.as_bytes()); + let original = if state + .blobs + .exists(&digest) + .map_err(|error| error.to_string())? + { + state + .blobs + .read(&digest) + .map_err(|error| error.to_string())? + } else { + load_native_source_blob(context, hash).map_err(|error| error.to_string())? + }; let mut removed_span = None; let mut enclosing = BTreeSet::new(); let mut nested = false; let (target, projected, created, removed) = if member_declaration { let planned = plan_member_declaration(&before, &anchor, file, &original, create)?; + if planned.removed.is_some() { + if let Some(removal) = planned.removed_span.as_ref() { + refuse_dropping_an_edit(&before, edited_entities, &anchor, file, removal)?; + } + } removed_span = planned.removed_span; enclosing = planned.enclosing; nested = true; @@ -727,13 +780,12 @@ pub(crate) fn prepare( // The declaration goes with its own lines, its whole statement and // its doc comment, and the space that set it apart, so what stood // around it is as far apart as before. + let removal = kin_parser::code_placement::removal_of(&original, proven.extent.clone()); + refuse_dropping_an_edit(&before, edited_entities, &anchor, file, &removal)?; let projected = kin_projection::apply_splices( &original, vec![kin_projection::Splice { - byte_range: kin_parser::code_placement::removal_of( - &original, - proven.extent.clone(), - ), + byte_range: removal, new_content: Vec::new(), }], ) diff --git a/crates/kin-daemon/src/entity_lifecycle_commit_test.rs b/crates/kin-daemon/src/entity_lifecycle_commit_test.rs index 19609581c..0b918e6a2 100644 --- a/crates/kin-daemon/src/entity_lifecycle_commit_test.rs +++ b/crates/kin-daemon/src/entity_lifecycle_commit_test.rs @@ -320,7 +320,7 @@ fn entity_lifecycle_new_unit_receipt_recovers_after_publication_crash() { #[test] fn entity_lifecycle_occupied_new_unit_and_mixed_owner_edits_refuse_atomically() { - use kin_mcp::entity_lifecycle::EntityPlacement::{NewSourceUnit, SiblingAfter}; + use kin_mcp::entity_lifecycle::EntityPlacement::NewSourceUnit; for occupied in [true, false] { let (_dir, state) = test_state(); let original = b"def anchor():\n return 1\n"; @@ -334,16 +334,27 @@ fn entity_lifecycle_occupied_new_unit_and_mixed_owner_edits_refuse_atomically() ); } let sessions = test_sessions(); - let mut operations = vec![lifecycle_create( - &state, - &anchor, - if occupied { - NewSourceUnit - } else { - SiblingAfter - }, - "def added():\n return 2", - )]; + let mut operations = if occupied { + vec![lifecycle_create( + &state, + &anchor, + NewSourceUnit, + "def added():\n return 2", + )] + } else { + vec![kin_mcp::McpMutationOperation { + verb: "remove".into(), + target: anchor.id.to_string(), + body: None, + destination: None, + description: "remove anchor".into(), + payload: Some(kin_mcp::McpMutationPayload::EntityRemove( + kin_mcp::entity_lifecycle::EntityRemove { + source_base: lifecycle_base(&state, &anchor), + }, + )), + }] + }; if !occupied { operations.push(kin_mcp::McpMutationOperation { verb: "update".into(), @@ -377,6 +388,82 @@ fn entity_lifecycle_occupied_new_unit_and_mixed_owner_edits_refuse_atomically() } } +#[test] +fn entity_lifecycle_create_and_patch_in_same_source_unit_commit_together() { + use kin_mcp::entity_lifecycle::EntityPlacement::SiblingAfter; + let (_dir, state) = test_state(); + let original = b"def anchor():\n return 1\n"; + let (anchor, _) = install_exact_source(&state, "pkg/source.py", original, "anchor"); + let sessions = test_sessions(); + let operations = vec![ + lifecycle_create(&state, &anchor, SiblingAfter, "def added():\n return 2"), + kin_mcp::McpMutationOperation { + verb: "update".into(), + target: anchor.id.to_string(), + payload: Some(kin_mcp::McpMutationPayload::EntitySourceBase( + lifecycle_base(&state, &anchor), + )), + body: Some("def anchor():\n return added()".into()), + destination: None, + description: "call added from anchor".into(), + }, + ]; + let arguments = lifecycle_stage(&sessions, operations); + let result = commit_exact_transaction(&state, &sessions, &arguments, None); + assert_ne!(result.is_error, Some(true), "{}", result_text(&result)); + assert_eq!( + std::fs::read(state.layout.working_dir().join("pkg/source.py")).unwrap(), + b"def anchor():\n return added()\n\n\ndef added():\n return 2\n" + ); + // The graph holds both changes, not only the bytes: the created entity + // and the anchor's new body. + assert!(lifecycle_entity(&state, "added", kin_model::EntityKind::Function).is_some()); + let patched = state.graph.get_entity(&anchor.id).unwrap().unwrap(); + assert_ne!(patched.fingerprint, anchor.fingerprint); +} + +/// Removing a declaration while editing one written inside it must never +/// report a commit that lost the edit. Here the class removal is refused +/// before the enclosing check is reached; the planner's own guard covers any +/// removable declaration that holds an editable one. Either way the pair is +/// refused whole and nothing is written. +#[test] +fn entity_lifecycle_refuses_removing_a_declaration_that_holds_an_edit() { + use kin_model::EntityKind; + let file = "pkg/app.py"; + let original = + "class App(Base):\n def first(self):\n return 1\n\n\ndef helper():\n return 1\n"; + let (_dir, state) = test_state(); + let (method, _) = install_exact_source(&state, file, original.as_bytes(), "App.first"); + let class = lifecycle_entity(&state, "App", EntityKind::Class).unwrap(); + let sessions = test_sessions(); + let operations = vec![ + lifecycle_remove(&state, &class), + kin_mcp::McpMutationOperation { + verb: "update".into(), + target: method.id.to_string(), + payload: Some(kin_mcp::McpMutationPayload::EntitySourceBase( + lifecycle_base(&state, &method), + )), + body: Some("def first(self):\n return 2".into()), + destination: None, + description: "change first".into(), + }, + ]; + let arguments = lifecycle_stage(&sessions, operations); + let result = commit_exact_transaction(&state, &sessions, &arguments, None); + assert_eq!(result.is_error, Some(true), "{}", result_text(&result)); + assert!( + result_text(&result).contains("repository authority did not move"), + "{}", + result_text(&result) + ); + assert_eq!(lifecycle_disk(&state, file), original); + let kept = state.graph.get_entity(&method.id).unwrap().unwrap(); + assert_eq!(kept.fingerprint, method.fingerprint); + assert!(lifecycle_entity(&state, "App", EntityKind::Class).is_some()); +} + #[test] fn entity_lifecycle_abort_survives_restart_without_creating_an_artifact() { let (_dir, state) = test_state(); diff --git a/crates/kin-daemon/src/entity_split.rs b/crates/kin-daemon/src/entity_split.rs new file mode 100644 index 000000000..37edbdb87 --- /dev/null +++ b/crates/kin-daemon/src/entity_split.rs @@ -0,0 +1,1184 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! One content's changes, separated by the entity each one belongs to. +//! +//! Keeping some of the working copy's entities and leaving the rest +//! uncommitted needs, for every content a chosen entity lives in, the text +//! that carries exactly the chosen entities' changes and none of the others. +//! Undoing one entity needs the same thing in the other direction. This module +//! takes two versions of one content, the entities each version holds with +//! their exact spans, and the chosen set, and builds that text. It reads +//! nothing and writes nothing: the caller derives the result's entities again +//! and proves them before anything is published. +//! +//! A change is attributed by where it lands. The two versions are compared +//! line by line, and every changed region belongs to the entities whose own +//! text it touches, an entity's own text being its span less the spans of the +//! entities inside it. A region that belongs only to chosen entities is +//! carried, one that belongs to none of them is left, and one that belongs to +//! both is separated at entity boundaries when its lines fall cleanly on one +//! side of them, and refused when they do not. Text no entity holds, such as +//! an import line between declarations, is carried only when the caller's +//! [`Outside`] rule says so. + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::ops::Range; + +use kin_model::{EntityId, EntityKind}; + +/// One entity as one version of a content holds it. +#[derive(Debug, Clone)] +pub(crate) struct Spanned { + pub(crate) id: EntityId, + pub(crate) name: String, + pub(crate) kind: EntityKind, + pub(crate) span: Range, +} + +impl Spanned { + /// An entity that stands for its whole content, whose own text is the + /// text between every other entity in it. Changes there belong to no + /// entity a reader reviews one at a time. + fn is_whole_unit(&self) -> bool { + matches!( + self.kind, + EntityKind::Module | EntityKind::File | EntityKind::Package + ) + } +} + +/// One version of one content: its bytes and the entities it holds. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Version<'a> { + pub(crate) bytes: &'a [u8], + pub(crate) entities: &'a [Spanned], +} + +/// What happens to changed text that no entity holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Outside { + /// Carried when every entity the content's changes touch is chosen, so a + /// content whose every changed entity is chosen is carried whole. + WhenEveryEntityIsChosen, + /// Never carried. + Never, +} + +/// The content that carries the chosen entities' changes. +#[derive(Debug)] +pub(crate) struct Selected { + pub(crate) bytes: Vec, + /// Every entity whose own text the changes between the two versions touch, + /// chosen or not. + pub(crate) changed: BTreeSet, +} + +/// A chosen entity whose change shares lines with an entity that was not +/// chosen, so neither can be carried without the other. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Entangled { + pub(crate) chosen: String, + pub(crate) other: String, +} + +/// Build the content `from` becomes when exactly the chosen entities take the +/// text they have in `to`. +pub(crate) fn select( + from: Version<'_>, + to: Version<'_>, + chosen: &HashSet, + outside: Outside, +) -> Result { + let mut from = Side::new(from); + let mut to = Side::new(to); + let mut hunks = Vec::new(); + let (from_lines, to_lines) = (from.lines.len(), to.lines.len()); + align( + &mut from, + &mut to, + 0..from_lines, + 0..to_lines, + None, + &mut hunks, + ); + + let mut changed = BTreeSet::new(); + for hunk in &hunks { + changed.extend(from.own_in(hunk.from.clone())); + changed.extend(to.own_in(hunk.to.clone())); + } + let carry_outside = + outside == Outside::WhenEveryEntityIsChosen && changed.iter().all(|id| chosen.contains(id)); + let names = from.names().chain(to.names()).collect::>(); + let decide = Decide { + chosen, + carry_outside, + names: &names, + }; + + let mut bytes = Vec::with_capacity(to.version.bytes.len().max(from.version.bytes.len())); + let mut cursor = 0; + for hunk in &hunks { + from.push_lines(&mut bytes, cursor..hunk.from.start); + decide.hunk(hunk, &mut from, &mut to, &mut bytes)?; + cursor = hunk.from.end; + } + from.push_lines(&mut bytes, cursor..from.lines.len()); + Ok(Selected { bytes, changed }) +} + +/// One changed region, in lines of each version. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Hunk { + from: Range, + to: Range, +} + +/// One line's reading: which entities' own text it holds, and which entity it +/// sits in when read from outside. +#[derive(Debug, Clone, Default)] +struct Reading { + /// The outermost entity holding any of the line's non-blank text. + owner: Option, + /// The innermost entity of each non-blank byte. + own: BTreeSet, + /// The innermost entity of the line's first non-blank byte: the entity + /// the line is a line of. + of: Option, + blank: bool, +} + +struct Side<'a> { + version: Version<'a>, + lines: Vec>, + readings: Vec>, +} + +impl<'a> Side<'a> { + fn new(version: Version<'a>) -> Self { + let mut lines = Vec::new(); + let mut start = 0; + for (index, byte) in version.bytes.iter().enumerate() { + if *byte == b'\n' { + lines.push(start..index + 1); + start = index + 1; + } + } + if start < version.bytes.len() { + lines.push(start..version.bytes.len()); + } + let readings = vec![None; lines.len()]; + Self { + version, + lines, + readings, + } + } + + fn text(&self, line: usize) -> &'a [u8] { + &self.version.bytes[self.lines[line].clone()] + } + + fn names(&self) -> impl Iterator + '_ { + self.version + .entities + .iter() + .map(|entity| (entity.id, entity.name.clone())) + } + + fn reading(&mut self, line: usize) -> &Reading { + if self.readings[line].is_none() { + let reading = self.read(line); + self.readings[line] = Some(reading); + } + self.readings[line].as_ref().expect("read above") + } + + fn read(&self, line: usize) -> Reading { + let range = self.lines[line].clone(); + let mut candidates = self + .version + .entities + .iter() + .filter(|entity| { + !entity.is_whole_unit() + && entity.span.start < range.end + && range.start < entity.span.end + }) + .collect::>(); + candidates.sort_by_key(|entity| entity.span.len()); + let mut reading = Reading { + blank: true, + ..Reading::default() + }; + let mut outermost: Option<&Spanned> = None; + for at in range { + if self.version.bytes[at].is_ascii_whitespace() { + continue; + } + let first = reading.blank; + reading.blank = false; + let mut holding = candidates.iter().filter(|entity| entity.span.contains(&at)); + if let Some(innermost) = holding.next() { + reading.own.insert(innermost.id); + if first { + reading.of = Some(innermost.id); + } + } + if let Some(widest) = candidates + .iter() + .rev() + .find(|entity| entity.span.contains(&at)) + { + if outermost.is_none_or(|held| widest.span.len() > held.span.len()) { + outermost = Some(widest); + } + } + } + reading.owner = outermost.map(|entity| entity.id); + reading + } + + fn own_in(&mut self, lines: Range) -> BTreeSet { + let mut own = BTreeSet::new(); + for line in lines { + own.extend(self.reading(line).own.iter().copied()); + } + own + } + + fn push_lines(&self, out: &mut Vec, lines: Range) { + if lines.is_empty() { + return; + } + let start = self.lines[lines.start].start; + let end = self.lines[lines.end - 1].end; + out.extend_from_slice(&self.version.bytes[start..end]); + } + + /// The lines of `range` gathered into runs that each belong to one + /// outermost entity, or to none. + /// + /// Unowned lines directly above an entity with no blank line between + /// travel with the entity below them: they are its leading comments. + /// Blank lines are spacing, and travel with the entity they separate in + /// the direction the run is written: a run that opens on an entity and + /// closes on spacing carries each entity's spacing after it, any other run + /// before it. Unowned text separated from the next entity by a blank line, + /// or after the last one, is a group of its own. + fn groups(&mut self, range: Range) -> Vec { + let spacing_follows = !range.is_empty() + && self.reading(range.start).owner.is_some() + && self.reading(range.end - 1).blank; + let mut groups: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + for line in range.clone() { + let reading = self.reading(line).clone(); + match reading.owner { + Some(owner) => { + if spacing_follows { + if let Some(last) = groups.last_mut() { + if last.owner.is_some() && pending.first() == Some(&last.lines.end) { + let spacing = pending + .iter() + .take_while(|line| { + self.readings[**line].as_ref().is_some_and(|r| r.blank) + }) + .count(); + last.lines.end += spacing; + pending.drain(..spacing); + } + } + } + let split = pending + .iter() + .enumerate() + .rev() + .find(|(position, line)| { + !self.reading_cached(**line).blank + && pending + .get(position + 1) + .is_some_and(|next| self.reading_cached(*next).blank) + }) + .map_or(0, |(position, _)| position + 1); + if split > 0 { + groups.push(Group { + lines: pending[0]..pending[split - 1] + 1, + owner: None, + own: BTreeSet::new(), + }); + } + let start = pending.get(split).copied().unwrap_or(line); + pending.clear(); + match groups.last_mut() { + Some(last) if last.owner == Some(owner) && last.lines.end == start => { + last.lines.end = line + 1; + last.own.extend(reading.own.iter().copied()); + } + _ => groups.push(Group { + lines: start..line + 1, + owner: Some(owner), + own: reading.own.clone(), + }), + } + } + None => pending.push(line), + } + } + if !pending.is_empty() { + // Blank lines straight after an entity are its trailing spacing. + let trailing = pending + .iter() + .take_while(|line| self.reading_cached(**line).blank) + .count(); + let attach = match groups.last_mut() { + Some(last) if last.lines.end == pending[0] => { + last.lines.end += trailing; + trailing + } + _ => 0, + }; + if attach < pending.len() { + groups.push(Group { + lines: pending[attach]..range.end, + owner: None, + own: BTreeSet::new(), + }); + } + } + groups + } + + /// Whether two lines of this version read the same and are lines of the + /// same entity. + fn same_line(&mut self, left: usize, right: usize) -> bool { + let of = self.reading(left).of; + self.text(left) == self.text(right) && of == self.reading(right).of + } + + /// The blank lines directly above `line`, as bytes. + fn spacing_above(&mut self, line: usize) -> Vec { + let mut first = line; + while first > 0 && self.reading(first - 1).blank { + first -= 1; + } + let mut spacing = Vec::new(); + self.push_lines(&mut spacing, first..line); + spacing + } + + fn reading_cached(&self, line: usize) -> &Reading { + self.readings[line] + .as_ref() + .expect("every grouped line is read before it is grouped") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Origin { + From, + To, +} + +/// One run of lines a separated region is rebuilt from. +#[derive(Debug, Clone)] +struct Piece { + origin: Origin, + lines: Range, + /// Whether it is an entity only its own version holds. + alone: bool, +} + +/// A run of lines that belongs to one outermost entity, or to none. +#[derive(Debug, Clone)] +struct Group { + lines: Range, + owner: Option, + own: BTreeSet, +} + +/// One entity's whole lines in one version: the line its span starts on +/// through the line it ends on. +#[derive(Debug, Clone)] +struct Block { + id: EntityId, + lines: Range, +} + +impl Side<'_> { + /// The line holding `byte`. + fn line_of(&self, byte: usize) -> usize { + self.lines + .partition_point(|line| line.end <= byte) + .min(self.lines.len().saturating_sub(1)) + } + + /// The outermost entities whose whole lines lie inside `range`, in order, + /// leaving out `parent` itself. An entity that shares a line with a + /// sibling has no lines of its own to anchor on and is left out too. + fn blocks(&self, range: Range, parent: Option) -> Vec { + let mut candidates = self + .version + .entities + .iter() + .filter(|entity| { + !entity.is_whole_unit() && !entity.span.is_empty() && Some(entity.id) != parent + }) + .filter(|entity| entity.span.end <= self.version.bytes.len()) + .map(|entity| { + let lines = self.line_of(entity.span.start)..self.line_of(entity.span.end - 1) + 1; + (entity, lines) + }) + .filter(|(_, lines)| range.start <= lines.start && lines.end <= range.end) + .collect::>(); + candidates.sort_by(|(left, left_lines), (right, right_lines)| { + left_lines + .start + .cmp(&right_lines.start) + .then(right_lines.end.cmp(&left_lines.end)) + .then(right.span.len().cmp(&left.span.len())) + }); + let mut tops: Vec<(&Spanned, Range, bool)> = Vec::new(); + for (entity, lines) in candidates { + match tops.last_mut() { + Some((top, top_lines, shared)) if lines.start < top_lines.end => { + let nested = + top.span.start <= entity.span.start && entity.span.end <= top.span.end; + if !nested { + *shared = true; + top_lines.end = top_lines.end.max(lines.end); + } + } + _ => tops.push((entity, lines, false)), + } + } + tops.into_iter() + .filter(|(_, _, shared)| !shared) + .map(|(entity, lines, _)| Block { + id: entity.id, + lines, + }) + .collect() + } +} + +/// The changed regions between two versions, anchored on the entities both +/// versions hold. +/// +/// Comparing the two contents as plain lines lets an inserted entity's lines +/// match a neighbour's identical lines: a new function whose last two lines +/// read like the function above it is read as an edit of that function plus a +/// stray insertion. So the entities present in both versions are aligned +/// first, by identity and in order, each aligned pair is compared inside +/// itself the same way, and only the text between aligned entities is +/// compared as lines. +fn align( + from: &mut Side<'_>, + to: &mut Side<'_>, + from_range: Range, + to_range: Range, + parent: Option, + out: &mut Vec, +) { + let from_blocks = from.blocks(from_range.clone(), parent); + let to_blocks = to.blocks(to_range.clone(), parent); + let from_ids = from_blocks.iter().map(|block| block.id).collect::>(); + let to_ids = to_blocks.iter().map(|block| block.id).collect::>(); + let mut anchors = Vec::new(); + for op in similar::capture_diff_slices(similar::Algorithm::Myers, &from_ids, &to_ids) { + let (tag, old, new) = op.as_tag_tuple(); + if tag == similar::DiffTag::Equal { + anchors.extend(old.zip(new)); + } + } + let (mut from_at, mut to_at) = (from_range.start, to_range.start); + for (old, new) in anchors { + let (old, new) = (&from_blocks[old], &to_blocks[new]); + compare_lines( + from, + to, + from_at..old.lines.start, + to_at..new.lines.start, + out, + ); + let same = old.lines.len() == new.lines.len() + && old + .lines + .clone() + .zip(new.lines.clone()) + .all(|(left, right)| from.text(left) == to.text(right)); + if !same { + align( + from, + to, + old.lines.clone(), + new.lines.clone(), + Some(old.id), + out, + ); + } + from_at = old.lines.end; + to_at = new.lines.end; + } + compare_lines(from, to, from_at..from_range.end, to_at..to_range.end, out); +} + +/// The changed regions of one stretch of text no aligned entity holds, +/// compared line by line. +fn compare_lines( + from: &mut Side<'_>, + to: &mut Side<'_>, + from_range: Range, + to_range: Range, + out: &mut Vec, +) { + if from_range.is_empty() && to_range.is_empty() { + return; + } + // A line matches a line of the same entity, or of no entity, and never a + // line of a different one: the closing brace of one function is not the + // closing brace of the function written in its place. + let from_text = from_range + .clone() + .map(|line| (from.reading(line).of, from.text(line))) + .collect::>(); + let to_text = to_range + .clone() + .map(|line| (to.reading(line).of, to.text(line))) + .collect::>(); + let mut hunks: Vec = Vec::new(); + for op in similar::capture_diff_slices(similar::Algorithm::Histogram, &from_text, &to_text) { + let (tag, old, new) = op.as_tag_tuple(); + if tag == similar::DiffTag::Equal { + continue; + } + let old = old.start + from_range.start..old.end + from_range.start; + let new = new.start + to_range.start..new.end + to_range.start; + match hunks.last_mut() { + Some(last) if last.from.end == old.start && last.to.end == new.start => { + last.from.end = old.end; + last.to.end = new.end; + } + _ => hunks.push(Hunk { from: old, to: new }), + } + } + settle(&mut hunks, from, to, &from_range, &to_range); + out.extend(hunks); +} + +/// Slide every pure insertion and pure deletion to the place where it touches +/// the fewest entities. +/// +/// A block of lines inserted between two equal lines can often be read at more +/// than one position: adding a function after another whose last line is the +/// same `}` matches either brace. The comparison picks one; the entity +/// boundaries say which reading is the change. Only positions the comparison +/// itself would accept are tried, so the content produced is the same either +/// way and only the attribution moves. +fn settle( + hunks: &mut [Hunk], + from: &mut Side<'_>, + to: &mut Side<'_>, + from_range: &Range, + to_range: &Range, +) { + for index in 0..hunks.len() { + let (lower_from, lower_to) = match index { + 0 => (from_range.start, to_range.start), + _ => (hunks[index - 1].from.end, hunks[index - 1].to.end), + }; + let (upper_from, upper_to) = match hunks.get(index + 1) { + Some(next) => (next.from.start, next.to.start), + None => (from_range.end, to_range.end), + }; + let hunk = hunks[index].clone(); + let settled = if hunk.from.is_empty() && !hunk.to.is_empty() { + slide(&hunk.to, lower_to, upper_to, to).map(|(lines, shift)| Hunk { + from: shifted(&hunk.from, shift), + to: lines, + }) + } else if hunk.to.is_empty() && !hunk.from.is_empty() { + slide(&hunk.from, lower_from, upper_from, from).map(|(lines, shift)| Hunk { + from: lines, + to: shifted(&hunk.to, shift), + }) + } else { + None + }; + if let Some(settled) = settled { + hunks[index] = settled; + } + } +} + +fn shifted(range: &Range, shift: isize) -> Range { + let start = range.start.saturating_add_signed(shift); + start..start + range.len() +} + +/// The best position of one block of lines that may slide between `lower` +/// and `upper` on its own side, and how far it moved. +fn slide( + block: &Range, + lower: usize, + upper: usize, + side: &mut Side<'_>, +) -> Option<(Range, isize)> { + let len = block.len(); + let mut start = block.start; + while start > lower && side.same_line(start - 1, start + len - 1) { + start -= 1; + } + let mut best: Option<(usize, usize)> = None; + let mut candidate = start; + loop { + let touched = side.own_in(candidate..candidate + len).len(); + let distance = candidate.abs_diff(block.start); + let better = match best { + None => true, + Some((score, at)) => { + touched < score || (touched == score && distance < at.abs_diff(block.start)) + } + }; + if better { + best = Some((touched, candidate)); + } + if candidate + len >= upper || !side.same_line(candidate, candidate + len) { + break; + } + candidate += 1; + } + let (_, at) = best?; + (at != block.start).then(|| { + let shift = at as isize - block.start as isize; + (at..at + len, shift) + }) +} + +struct Decide<'a> { + chosen: &'a HashSet, + carry_outside: bool, + names: &'a HashMap, +} + +impl Decide<'_> { + fn hunk( + &self, + hunk: &Hunk, + from: &mut Side<'_>, + to: &mut Side<'_>, + out: &mut Vec, + ) -> Result<(), Entangled> { + let mut parts = from.own_in(hunk.from.clone()); + parts.extend(to.own_in(hunk.to.clone())); + if parts.is_empty() { + if self.carry_outside { + to.push_lines(out, hunk.to.clone()); + } else { + from.push_lines(out, hunk.from.clone()); + } + return Ok(()); + } + let chosen = parts.iter().filter(|id| self.chosen.contains(id)).count(); + if chosen == parts.len() { + to.push_lines(out, hunk.to.clone()); + return Ok(()); + } + if chosen == 0 { + from.push_lines(out, hunk.from.clone()); + return Ok(()); + } + self.separate(hunk, from, to, out, &parts) + } + + /// Carry part of one region whose lines belong to chosen and unchosen + /// entities alike, one entity's lines at a time. + fn separate( + &self, + hunk: &Hunk, + from: &mut Side<'_>, + to: &mut Side<'_>, + out: &mut Vec, + parts: &BTreeSet, + ) -> Result<(), Entangled> { + let from_groups = from.groups(hunk.from.clone()); + let to_groups = to.groups(hunk.to.clone()); + // An entity present on both sides of the region is one change, decided + // once over everything it touches on either side. + let mut owned: HashMap> = HashMap::new(); + for group in from_groups.iter().chain(&to_groups) { + if let Some(owner) = group.owner { + owned + .entry(owner) + .or_default() + .extend(group.own.iter().copied()); + } + } + let mut take: HashMap = HashMap::new(); + for (owner, own) in &owned { + take.insert(*owner, self.decide_owned(own)?); + } + let paired = |owner: EntityId| from_groups.iter().position(|g| g.owner == Some(owner)); + let in_to = |owner: EntityId| to_groups.iter().any(|g| g.owner == Some(owner)); + + let mut pieces: Vec = Vec::new(); + let mut next_from = 0; + let keep_from = |group: &Group, pieces: &mut Vec| { + let keep = match group.owner { + Some(owner) => !take[&owner], + None => !self.carry_outside, + }; + if keep { + pieces.push(Piece { + origin: Origin::From, + lines: group.lines.clone(), + alone: group.owner.is_some_and(|owner| !in_to(owner)), + }); + } + }; + // What this version already holds stays where it stands, and what is + // carried from the other version follows it, between the entities both + // versions hold. + let mut carried: Vec = Vec::new(); + for group in &to_groups { + match group.owner { + Some(owner) => { + if let Some(at) = paired(owner) { + if at < next_from { + return Err(self.entangled(parts)); + } + for skipped in &from_groups[next_from..at] { + keep_from(skipped, &mut pieces); + } + pieces.append(&mut carried); + next_from = at + 1; + pieces.push(if take[&owner] { + Piece { + origin: Origin::To, + lines: group.lines.clone(), + alone: false, + } + } else { + Piece { + origin: Origin::From, + lines: from_groups[at].lines.clone(), + alone: false, + } + }); + } else if take[&owner] { + carried.push(Piece { + origin: Origin::To, + lines: group.lines.clone(), + alone: true, + }); + } + } + None => { + if self.carry_outside { + carried.push(Piece { + origin: Origin::To, + lines: group.lines.clone(), + alone: false, + }); + } + } + } + } + for group in &from_groups[next_from..] { + keep_from(group, &mut pieces); + } + pieces.append(&mut carried); + + // An entity one version holds alone, written where the other version + // holds a different one, never stood beside it in either version, so + // no spacing between the two exists to carry. They take the spacing + // that separates this region from what stands above it. + let spacing = { + let above = from.spacing_above(hunk.from.start); + if above.is_empty() { + to.spacing_above(hunk.to.start) + } else { + above + } + }; + let mut previous: Option<&Piece> = None; + for piece in &pieces { + let side: &Side<'_> = match piece.origin { + Origin::From => from, + Origin::To => to, + }; + if let Some(before) = previous { + let before_side: &Side<'_> = match before.origin { + Origin::From => from, + Origin::To => to, + }; + if out.last().is_some_and(|byte| *byte != b'\n') { + out.push(b'\n'); + } + if before.origin != piece.origin + && (before.alone || piece.alone) + && !before_side.reading_cached(before.lines.end - 1).blank + && !side.reading_cached(piece.lines.start).blank + { + out.extend_from_slice(&spacing); + } + } + // Spacing that separated an entity from one above it has nothing + // to separate when the entity opens the content. + let mut lines = piece.lines.clone(); + if out.is_empty() && piece.alone { + while lines.len() > 1 && side.reading_cached(lines.start).blank { + lines.start += 1; + } + } + side.push_lines(out, lines); + previous = Some(piece); + } + Ok(()) + } + + /// Whether one entity's lines are carried: all of what they touch chosen, + /// or none of it. + fn decide_owned(&self, own: &BTreeSet) -> Result { + let chosen = own.iter().find(|id| self.chosen.contains(id)); + let other = own.iter().find(|id| !self.chosen.contains(id)); + match (chosen, other) { + (Some(chosen), Some(other)) => Err(Entangled { + chosen: self.name(chosen), + other: self.name(other), + }), + (Some(_), None) => Ok(true), + _ => Ok(false), + } + } + + fn entangled(&self, parts: &BTreeSet) -> Entangled { + let chosen = parts.iter().find(|id| self.chosen.contains(id)); + let other = parts.iter().find(|id| !self.chosen.contains(id)); + Entangled { + chosen: chosen.map(|id| self.name(id)).unwrap_or_default(), + other: other.map(|id| self.name(id)).unwrap_or_default(), + } + } + + fn name(&self, id: &EntityId) -> String { + self.names + .get(id) + .cloned() + .unwrap_or_else(|| id.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An entity of `text` running from the first `start` to the end of the + /// first `end` after it. + fn spanned(text: &str, name: &str, start: &str, end: &str) -> Spanned { + let at = text + .find(start) + .unwrap_or_else(|| panic!("{start} in {text}")); + let close = text[at..] + .find(end) + .unwrap_or_else(|| panic!("{end} in {text}")) + + at; + Spanned { + id: id(name), + name: name.to_string(), + kind: EntityKind::Function, + span: at..close + end.len(), + } + } + + fn function(text: &str, name: &str) -> Spanned { + spanned(text, name, &format!("fn {name}("), "\n}") + } + + fn id(name: &str) -> EntityId { + EntityId::from_content("t.rs", name, "Function", 0) + } + + fn module(text: &str) -> Spanned { + Spanned { + id: id("module"), + name: "module".into(), + kind: EntityKind::Module, + span: 0..text.len(), + } + } + + fn versioned(text: &str, names: &[&str]) -> Vec { + let mut entities = vec![module(text)]; + entities.extend(names.iter().map(|name| function(text, name))); + entities + } + + fn keep( + from: &str, + from_names: &[&str], + to: &str, + to_names: &[&str], + chosen: &[&str], + ) -> String { + keep_with( + from, + from_names, + to, + to_names, + chosen, + Outside::WhenEveryEntityIsChosen, + ) + .expect("the chosen entities separate") + } + + fn keep_with( + from: &str, + from_names: &[&str], + to: &str, + to_names: &[&str], + chosen: &[&str], + outside: Outside, + ) -> Result { + let from_entities = versioned(from, from_names); + let to_entities = versioned(to, to_names); + let chosen = chosen.iter().map(|name| id(name)).collect::>(); + select( + Version { + bytes: from.as_bytes(), + entities: &from_entities, + }, + Version { + bytes: to.as_bytes(), + entities: &to_entities, + }, + &chosen, + outside, + ) + .map(|selected| String::from_utf8(selected.bytes).unwrap()) + } + + const HEAD: &str = + "use a;\n\nfn one() {\n 1\n}\n\nfn two() {\n 2\n}\n\nfn three() {\n 3\n}\n"; + + #[test] + fn a_chosen_edit_is_carried_and_an_unchosen_one_is_left() { + let work = HEAD + .replace(" 1\n", " 10\n") + .replace(" 3\n", " 30\n"); + let names = ["one", "two", "three"]; + assert_eq!( + keep(HEAD, &names, &work, &names, &["one"]), + HEAD.replace(" 1\n", " 10\n") + ); + assert_eq!( + keep(HEAD, &names, &work, &names, &["three"]), + HEAD.replace(" 3\n", " 30\n") + ); + assert_eq!(keep(HEAD, &names, &work, &names, &["one", "three"]), work); + } + + #[test] + fn an_added_entity_is_inserted_where_it_was_written_and_left_out_when_unchosen() { + let work = HEAD.replace( + "fn two() {\n 2\n}\n", + "fn two() {\n 2\n}\n\nfn added() {\n 4\n}\n", + ); + let to_names = ["one", "two", "added", "three"]; + assert_eq!( + keep(HEAD, &["one", "two", "three"], &work, &to_names, &["added"]), + work + ); + let edited = work.replace(" 1\n", " 10\n"); + assert_eq!( + keep(HEAD, &["one", "two", "three"], &edited, &to_names, &["one"]), + HEAD.replace(" 1\n", " 10\n"), + "the new entity stays uncommitted when it is not chosen" + ); + } + + #[test] + fn two_entities_added_side_by_side_are_kept_one_at_a_time() { + let work = HEAD.replace( + "fn three() {", + "fn left() {\n 5\n}\n\nfn right() {\n 6\n}\n\nfn three() {", + ); + let from = ["one", "two", "three"]; + let to = ["one", "two", "left", "right", "three"]; + assert_eq!( + keep(HEAD, &from, &work, &to, &["right"]), + HEAD.replace("fn three() {", "fn right() {\n 6\n}\n\nfn three() {") + ); + assert_eq!( + keep(HEAD, &from, &work, &to, &["left"]), + HEAD.replace("fn three() {", "fn left() {\n 5\n}\n\nfn three() {") + ); + } + + #[test] + fn a_removed_entity_is_removed_only_when_chosen() { + let work = HEAD.replace("fn two() {\n 2\n}\n\n", ""); + let from = ["one", "two", "three"]; + let to = ["one", "three"]; + assert_eq!(keep(HEAD, &from, &work, &to, &["two"]), work); + let edited = work.replace(" 1\n", " 10\n"); + assert_eq!( + keep(HEAD, &from, &edited, &to, &["one"]), + HEAD.replace(" 1\n", " 10\n"), + "the removal stays uncommitted when it is not chosen" + ); + } + + fn one_liners(text: &str) -> Vec { + vec![ + module(text), + spanned(text, "one", "fn one(", "}"), + spanned(text, "two", "fn two(", "}"), + ] + } + + fn select_one_liners(head: &str, work: &str, chosen: &[&str]) -> Result { + let (from, to) = (one_liners(head), one_liners(work)); + let chosen = chosen.iter().map(|name| id(name)).collect::>(); + select( + Version { + bytes: head.as_bytes(), + entities: &from, + }, + Version { + bytes: work.as_bytes(), + entities: &to, + }, + &chosen, + Outside::WhenEveryEntityIsChosen, + ) + .map(|selected| String::from_utf8(selected.bytes).unwrap()) + } + + #[test] + fn edits_on_one_line_with_an_unchosen_entity_are_refused() { + let refused = select_one_liners( + "fn one() { 1 } fn two() { 2 }\n", + "fn one() { 10 } fn two() { 20 }\n", + &["one"], + ) + .expect_err("one line holds both entities' changes"); + assert_eq!(refused.chosen, "one"); + assert_eq!(refused.other, "two"); + } + + #[test] + fn edits_on_adjacent_lines_of_two_entities_separate_by_line() { + assert_eq!( + select_one_liners( + "fn one() { 1 }\nfn two() { 2 }\n", + "fn one() { 10 }\nfn two() { 20 }\n", + &["two"], + ) + .unwrap(), + "fn one() { 1 }\nfn two() { 20 }\n" + ); + } + + #[test] + fn text_outside_every_entity_follows_only_a_content_kept_whole() { + let work = HEAD + .replace("use a;\n", "use a;\nuse b;\n") + .replace(" 1\n", " 10\n") + .replace(" 2\n", " 20\n"); + let names = ["one", "two", "three"]; + assert_eq!( + keep(HEAD, &names, &work, &names, &["one"]), + HEAD.replace(" 1\n", " 10\n"), + "the import stays with the work that is left" + ); + assert_eq!( + keep(HEAD, &names, &work, &names, &["one", "two"]), + work, + "a content whose every changed entity is kept is kept whole" + ); + assert_eq!( + keep_with(HEAD, &names, &work, &names, &["one", "two"], Outside::Never).unwrap(), + HEAD.replace(" 1\n", " 10\n") + .replace(" 2\n", " 20\n"), + "and never under the rule that never carries it" + ); + } + + #[test] + fn an_insertion_that_matches_a_neighbours_closing_line_is_read_as_the_new_entity() { + let head = "fn one() {\n 1\n}\n"; + let work = "fn one() {\n 1\n}\n\nfn two() {\n 1\n}\n"; + let kept = keep(head, &["one"], work, &["one", "two"], &["two"]); + assert_eq!(kept, work); + let left = keep_with( + head, + &["one"], + &work.replace("fn one() {\n 1", "fn one() {\n 9"), + &["one", "two"], + &["one"], + Outside::WhenEveryEntityIsChosen, + ) + .unwrap(); + assert_eq!(left, "fn one() {\n 9\n}\n"); + } + + #[test] + fn a_new_content_keeps_only_the_chosen_entity() { + let work = "use z;\n\nfn left() {\n 1\n}\n\nfn right() {\n 2\n}\n"; + let kept = keep("", &[], work, &["left", "right"], &["right"]); + assert_eq!(kept, "fn right() {\n 2\n}\n"); + assert_eq!( + keep("", &[], work, &["left", "right"], &["left"]), + "fn left() {\n 1\n}\n" + ); + assert_eq!( + keep("", &[], work, &["left", "right"], &["left", "right"]), + work + ); + } + + #[test] + fn an_entity_written_in_place_of_another_takes_the_spacing_above_them() { + // The working copy replaced `three` with `four` in the same place. + let work = HEAD.replace("fn three() {\n 3\n}\n", "fn four() {\n 4\n}\n"); + let from = ["one", "two", "three"]; + let to = ["one", "two", "four"]; + assert_eq!( + keep(HEAD, &from, &work, &to, &["four"]), + format!("{HEAD}\nfn four() {{\n 4\n}}\n"), + "kept alone, the new entity stands beside the one whose removal was not chosen" + ); + assert_eq!( + keep(HEAD, &from, &work, &to, &["three"]), + HEAD.replace("fn three() {\n 3\n}\n", ""), + "and the removal alone leaves neither" + ); + assert_eq!(keep(HEAD, &from, &work, &to, &["three", "four"]), work); + } + + #[test] + fn undoing_one_entity_reverses_its_change_and_leaves_the_others() { + // Undo is a selection in the other direction: from the working copy + // toward the recorded version, with only the undone entity chosen. + let work = HEAD + .replace(" 1\n", " 10\n") + .replace("fn two() {\n 2\n}\n\n", "") + .replace("use a;\n", "use a;\nuse b;\n"); + let undone = keep_with( + &work, + &["one", "three"], + HEAD, + &["one", "two", "three"], + &["two"], + Outside::Never, + ) + .unwrap(); + assert_eq!( + undone, + work.replace("fn three() {", "fn two() {\n 2\n}\n\nfn three() {"), + "the removed entity comes back where it stood and nothing else moves" + ); + } +} diff --git a/crates/kin-daemon/src/git_update.rs b/crates/kin-daemon/src/git_update.rs new file mode 100644 index 000000000..30223320d --- /dev/null +++ b/crates/kin-daemon/src/git_update.rs @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::Json; +use kin_cli::commands::transfer::{DerivedViewRefresh, WorkspaceFollow}; +use kin_cli::git_update::{GitUpdateRequest, GitUpdateResponse, GitUpdateStatus}; +use kin_model::{AuthorId, ChangeStore}; + +use crate::local_repository_authority::{ + require_fresh_daemon_workspace, ActiveLocalRepositoryAuthority, +}; +use crate::state::{DaemonEvent, DaemonState}; + +type Refusal = (StatusCode, String); + +pub(crate) async fn status( + State(state): State>, +) -> Result, Refusal> { + require_local_daemon(&state)?; + let read = tokio::task::spawn_blocking(move || { + let _persistence = state + .persist_lock + .lock() + .map_err(|_| internal("daemon persistence lock poisoned"))?; + let authority = ActiveLocalRepositoryAuthority::open_bound(&state) + .map_err(|error| conflict(error.into_error()))?; + let lease = authority.manager.read_authority(); + let mut status = kin_core::git_update::git_update_status(&lease, authority.workspace_id); + if let Err(reason) = + kin_core::history_state::require_complete_for(&state.layout, "Git update") + { + status.can_update = false; + status.reason = Some(reason); + } + Ok(Json(status)) + }) + .await + .map_err(internal)?; + read +} + +pub(crate) async fn update( + headers: HeaderMap, + State(state): State>, + Json(request): Json, +) -> Result, Refusal> { + require_local_daemon(&state)?; + if headers.contains_key("x-kin-session") { + return Err(conflict( + "Git updates require primary workspace authority without session scope", + )); + } + tokio::task::spawn_blocking(move || { + // Keep this gate inside the worker so cancellation of the HTTP request + // cannot release it while the blocking publication is still running. + let _coordination = state.coordination_gate.blocking_lock(); + run_update(&state, &request).map(Json) + }) + .await + .map_err(internal)? +} + +fn run_update( + state: &DaemonState, + request: &GitUpdateRequest, +) -> Result { + kin_core::history_state::require_complete_for(&state.layout, "Git update").map_err(conflict)?; + let (mut response, destination) = { + let _graph_mutation = state.begin_graph_authority_mutation(); + let _persistence = state + .persist_lock + .lock() + .map_err(|_| internal("daemon persistence lock poisoned"))?; + let authority = ActiveLocalRepositoryAuthority::open_bound(state) + .map_err(|error| conflict(error.into_error()))?; + let workspace_graph = authority + .manager + .workspace_graph_snapshot(&authority.repository_id, &authority.workspace_id) + .map_err(internal)? + .ok_or_else(|| conflict("current workspace graph is absent"))?; + let roots = authority.manager.read_authority().roots().clone(); + require_fresh_daemon_workspace(state, &roots, &workspace_graph, "Git update") + .map_err(conflict)?; + let drift = kin_core::report_repository_workspace_projection_drift( + state.layout.working_dir(), + &workspace_graph.resolved_tree, + &authority.manager, + ) + .map_err(internal)?; + if !drift.is_clean() { + return Err(conflict("the tracked working projection changed; admit or preserve those changes before a Git update")); + } + let mut plan = kin_core::git_update::prepare_git_update( + &authority.manager, + authority.workspace_id, + request, + &state.blobs, + ) + .map_err(conflict)?; + plan.seal_in_repository(&authority.manager, &state.blobs) + .map_err(internal)?; + let mut derived_views = DerivedViewRefresh::Current; + let generation = if let Some(transaction) = plan.transaction.take() { + let changes = transaction.changes.clone(); + // The workspace remains on its previous base during admission. + // The projection seam proves its exact tracked bytes and keeps + // the projection frozen through the root/ref compare-and-swap. + let (_, receipt, freeze) = + kin_core::verify_repository_workspace_projection_and_commit_repository_transaction( + state.layout.working_dir(), + &plan.workspace.tree, + &authority.manager, + transaction, + ) + .map_err(publication_refusal)?; + crate::api::relabel_held_authority_after_own_commit(state); + let refresh = (|| -> Result<(), String> { + state + .record_repository_authority_commit(receipt.generation) + .map_err(|error| error.to_string())?; + for change in &changes { + state + .graph + .create_change(change) + .map_err(|error| error.to_string())?; + } + Ok(()) + })(); + // The seal reads graph-owned content, which takes the store's + // shared lock, so it is checked once the freeze has released the + // exclusive one. Read under the freeze, it would wait forever. + drop(freeze); + let refresh = refresh.and_then(|()| { + plan.verify_published_seal(&authority.manager) + .map_err(|error| error.to_string()) + }); + if let Err(detail) = refresh { + derived_views = DerivedViewRefresh::Stale { detail }; + } + state.bump_version(); + state.emit_event(DaemonEvent::GraphRootChanged { + old_root_hash: None, + new_root_hash: plan.head.to_string(), + }); + receipt.generation + } else { + let (_, freeze) = kin_core::verify_repository_workspace_projection( + state.layout.working_dir(), + &plan.workspace.tree, + &authority.manager, + ) + .map_err(publication_refusal)?; + let generation = freeze.roots().generation; + drop(freeze); + generation + }; + ( + GitUpdateResponse { + operation_id: request.operation_id, + previous_head: plan.previous_head, + head: plan.head, + admitted_changes: plan.admitted_changes, + authority_generation: generation, + workspace_follow: WorkspaceFollow::AlreadyCurrent { + authority_generation: generation, + }, + derived_views, + }, + request.destination_ref.clone(), + ) + }; + // Admission has released the persistence/graph gates. Following takes the + // same gates independently and reports a failed projection honestly after + // history has already become durable. + response.workspace_follow = match &response.derived_views { + DerivedViewRefresh::Current => crate::repository_branch::follow_moved_ref( + state, + &destination, + &AuthorId::new("kin-git-update"), + ), + DerivedViewRefresh::Stale { detail } => WorkspaceFollow::Behind { + detail: format!("Git history was admitted, but derived views are behind: {detail}"), + }, + }; + Ok(response) +} + +fn require_local_daemon(state: &DaemonState) -> Result<(), Refusal> { + if !state.is_initialized.load(Ordering::Relaxed) { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "daemon not fully initialized".to_string(), + )); + } + if state.storage_backend.is_some() { + return Err(conflict("Git updates require local repository authority")); + } + Ok(()) +} + +fn conflict(error: impl std::fmt::Display) -> Refusal { + (StatusCode::CONFLICT, error.to_string()) +} +fn internal(error: impl std::fmt::Display) -> Refusal { + (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) +} + +fn publication_refusal(error: kin_core::KinError) -> Refusal { + match error { + kin_core::KinError::RepositoryCommitIndeterminate(_) + | kin_core::KinError::Graph(_) + | kin_core::KinError::Io { .. } => internal(error), + _ => conflict(error), + } +} diff --git a/crates/kin-daemon/src/history_schedule.rs b/crates/kin-daemon/src/history_schedule.rs index 5735c6583..9bbfc3a35 100644 --- a/crates/kin-daemon/src/history_schedule.rs +++ b/crates/kin-daemon/src/history_schedule.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Firelock, LLC -//! Schedule a partial store's history derivation, and attach it at a start. +//! Schedule a partial store's history derivation, and attach it when it is +//! ready. //! //! A store `kin init --history background` created holds its checked-out //! commit and derives its history in a worker process that `kin init` starts. @@ -15,12 +16,48 @@ //! bounded share of threads and memory (see //! `kin_cli::commands::history_derive`), so a derivation never runs on the //! threads that answer queries and never counts against this daemon's memory. +//! +//! ## A daemon that is serving when the history arrives +//! +//! A daemon the local app reads every few seconds, or an agent keeps calling, +//! never idles out, so "attached at the next start" used to mean never: the +//! repository showed one change of thousands for as long as anyone used it. +//! A serving daemon now looks for a staged history every few seconds and, at +//! its first quiet point, hands the store over: it stops serving the way a +//! cooperative stop does, keeps the repository's runtime authority until its +//! process is gone, and its process starts a successor on its way out. The +//! successor attaches the history in [`before_open`], as every start does. +//! +//! The attach is not done in place. Everything a serving daemon holds, its +//! graph, its indexes, its open records and every task that reads them, is of +//! the partial store, and the attach replaces that store's contents wholesale. +//! Swapping them under a live daemon would leave each of those holding what +//! was just moved away. A start is the one place that opens all of it from +//! nothing, and the attach it runs is the journaled one a crash is already +//! recovered from. +//! +//! A quiet point is a look that finds no request being answered, no change +//! being published, no pass over the working copy running, no agent's claim on +//! an entity, and no open work in the store +//! ([`kin_core::history_attach::OpenWork`]). It is an estimate, made +//! to avoid leaving clients for nothing. The attach decides again for itself, +//! under the authority and with nothing serving, so work that opens between +//! the look and the stop is never dropped: the successor finds it, leaves the +//! store partial, and serves on until the next quiet point. Whatever the +//! handover is waiting for is written to the store's history progress, where +//! every surface reads it. A crash at any point of the attach is finished by +//! the next process to take the authority ([`recover_before_start`]). +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::Duration; +use kin_core::history_attach::{self, AttachOutcome, AttachReadiness, OpenWork}; use kin_core::history_state::{self, DerivationState, HistoryMode, HistoryStanding}; use kin_core::KinLayout; +use crate::state::{DaemonState, RECON_PROCESSING}; + /// How long a serving daemon waits before its first look, so its own startup /// work goes first. const FIRST_LOOK: Duration = Duration::from_secs(60); @@ -31,6 +68,51 @@ const LOOK_EVERY: Duration = Duration::from_secs(5 * 60); const RETRY_BASE: Duration = Duration::from_secs(5 * 60); /// The longest wait after a failed attempt. const RETRY_CEILING: Duration = Duration::from_secs(6 * 60 * 60); +/// How often a serving daemon looks for a staged history while a derivation +/// is running or staged. A look reads two small records. +const HANDOFF_LOOK: Duration = Duration::from_secs(2); +/// How often it looks while no derivation is running or staged. +const HANDOFF_LOOK_WHEN_NOTHING_DERIVES: Duration = Duration::from_secs(30); + +/// Finish a history attach a crash interrupted, before this process reads +/// anything else of the store. +/// +/// A swap stopped between moving the partial store out and the complete one +/// in leaves a store with no manifest, no settings and no graph, and a start +/// reads the manifest long before it takes the runtime authority it keeps. So +/// a start that finds the journal takes the authority for this alone, finishes +/// the swap and lets go, and everything after it reads a whole store. Without +/// this, a daemon killed at that point left a store no start could open. +/// +/// Never fails the start: when another daemon holds the authority, that +/// daemon is the one finishing the swap, and this start ends on its own once +/// it finds the repository taken. +pub fn recover_before_start(layout: &KinLayout) { + if !history_attach::attach_is_interrupted(layout) { + return; + } + let authority = match crate::daemon::acquire_daemon_authority(layout.root()) { + Ok(authority) => authority, + Err(error) => { + tracing::warn!( + %error, + "an interrupted history attach was found, and the repository's runtime \ + authority is held elsewhere, so it is left to its holder" + ); + return; + } + }; + match history_attach::recover_interrupted_attach(layout) { + Ok(true) => { + tracing::info!("finished attaching this store's complete history after an interruption") + } + Ok(false) => {} + Err(error) => { + tracing::warn!(%error, "an interrupted history attach could not be finished") + } + } + drop(authority); +} /// Attach a staged history, or finish an attach a crash interrupted, before /// this daemon opens the store. The caller holds the runtime authority. @@ -43,6 +125,7 @@ pub fn before_open(layout: &KinLayout) { tracing::info!( "finished attaching this store's complete history after an interruption" ); + reap_stages_of_a_complete_store(layout); return; } Ok(false) => {} @@ -51,38 +134,79 @@ pub fn before_open(layout: &KinLayout) { return; } } - let HistoryStanding::Partial { - progress: Some(progress), - .. - } = history_state::standing(layout) - else { - return; + let progress = match history_state::standing(layout) { + HistoryStanding::Partial { + progress: Some(progress), + .. + } => progress, + HistoryStanding::Complete => { + reap_stages_of_a_complete_store(layout); + return; + } + _ => return, }; - if progress.state != DerivationState::Staged { - return; - } - let Some(stage) = progress.detail.as_deref().map(std::path::PathBuf::from) else { + // A stage that is gone is left for the scheduler, which starts the + // derivation again; nothing here could attach it. + let Some(stage) = kin_cli::commands::history_derive::finished_stage(Some(&progress)) else { return; }; - match kin_core::history_attach::attach_staged_history(layout, &stage) { - Ok(_) => { + match history_attach::attach_staged_history(layout, &stage) { + Ok(AttachOutcome::Attached | AttachOutcome::AlreadyComplete) => { let _ = history_state::clear_progress(layout); tracing::info!( stage = %stage.display(), "attached this store's complete history before opening it" ); } - Err(error) => { - tracing::warn!(%error, "the staged history was not attached; the store stays partial"); - let mut waiting = - kin_core::history_state::HistoryDerivationProgress::new(DerivationState::Waiting); - waiting.attempts = progress.attempts; - waiting.detail = Some(error.to_string()); - let _ = history_state::write_progress(layout, &waiting); + Ok(AttachOutcome::Deferred(work)) => { + // Nothing was moved and the stage is whole. This daemon serves the + // partial store, and hands it over once the work is finished. + tracing::info!( + waiting_for = work.describe(), + "the staged history was not attached yet; the store holds open work" + ); + let _ = history_state::note_staged_wait(layout, Some(work.describe())); } + Err(error) => record_refusal(layout, progress.attempts, &error.to_string()), + } +} + +/// Remove what is left of stages derived for a store that is now complete. +/// +/// An attach removes its stage last, after the journal that would finish it is +/// gone, so a crash during that removal leaves the stage, the partial store it +/// replaced, behind with nothing to say so. A complete store has no use for a +/// stage, and no derivation starts for one, so whatever is left is reclaimed +/// here. Only stages named for this repository and holding no other +/// repository's store are touched. +fn reap_stages_of_a_complete_store(layout: &KinLayout) { + let Ok(manifest) = kin_core::KinManifest::load(&layout.manifest_path()) else { + return; + }; + let reaped = + history_attach::reap_abandoned_stages(layout.working_dir(), &manifest.repo_id, None); + if reaped > 0 { + tracing::info!( + reaped, + "removed what was left of history stages this complete store no longer needs" + ); } } +/// Record that the staged history cannot be attached, for a reason waiting +/// does not cure. The store stays partial and says why. +fn record_refusal(layout: &KinLayout, attempts: u32, reason: &str) { + tracing::warn!( + error = reason, + "the staged history was not attached; the store stays partial" + ); + let mut waiting = + kin_core::history_state::HistoryDerivationProgress::new(DerivationState::Waiting); + waiting.attempts = attempts; + waiting.detail = Some(reason.to_string()); + let _ = history_state::write_progress(layout, &waiting); +} + /// Whether the store at `layout` owes a derivation a worker should start now. fn owes_a_worker(layout: &KinLayout, now: chrono::DateTime) -> bool { let HistoryStanding::Partial { record, progress } = history_state::standing(layout) else { @@ -99,8 +223,12 @@ fn owes_a_worker(layout: &KinLayout, now: chrono::DateTime) -> bool // once; one that died left this state behind, and a new one // resumes it. DerivationState::Running => true, - // Attached at the next start. - DerivationState::Staged | DerivationState::Waiting => false, + // Handed over at the next quiet point, unless the stage it + // names is gone, which only a new derivation repairs. + DerivationState::Staged => { + kin_cli::commands::history_derive::finished_stage(Some(&progress)).is_none() + } + DerivationState::Waiting => false, DerivationState::Failed => { let backoff = RETRY_BASE .saturating_mul(2_u32.saturating_pow(progress.attempts.saturating_sub(1))) @@ -160,6 +288,326 @@ pub fn spawn_scheduler(layout: KinLayout) { }); } +/// What keeps a serving daemon from handing its store over right now. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum QuietBlocker { + /// A request is being answered. + Request, + /// A change is being published. + Publication, + /// The first pass over the working copy has not finished. + FirstScan, + /// Changes to the working copy are being taken in. + Reconciliation, + /// A command is waiting to read a language-server pass's result. + SweepWaiter, + /// An agent holds a claim on an entity it is about to change. + Claim, + /// The store holds open work the attach would drop. + Open(OpenWork), +} + +impl QuietBlocker { + /// What the handover waits for, as a clause that follows "waiting for". + pub(crate) const fn describe(self) -> &'static str { + match self { + Self::Request => "the requests being answered to finish", + Self::Publication => "a change being published to land", + Self::FirstScan => "the first read of the working copy to finish", + Self::Reconciliation => "changes to the working copy to be taken in", + Self::SweepWaiter => "a command reading language-server results to finish", + Self::Claim => "an agent's claim on an entity to be released", + Self::Open(work) => work.describe(), + } + } +} + +/// The first thing that keeps `state` from being at a quiet point, if anything +/// does. +/// +/// Embedding and language-server enrichment do not: both are derived from the +/// partial store, the attach replaces it, and the successor derives them again +/// for the complete one. An attached session or an event subscriber does not +/// either: neither holds work, and both find the successor the way they find +/// any daemon that restarted. A session that holds a claim does, because the +/// claim is what keeps another agent off the entity it names, and the store +/// that replaces this one knows nothing of it. +pub(crate) fn quiet_blocker(state: &DaemonState) -> Option { + if state.active_request_count() > 0 { + return Some(QuietBlocker::Request); + } + if state.prepared_publication.pending() { + return Some(QuietBlocker::Publication); + } + if !state.is_initialized.load(Ordering::Relaxed) { + return Some(QuietBlocker::FirstScan); + } + if state.reconciliation_status.load(Ordering::Relaxed) == RECON_PROCESSING { + return Some(QuietBlocker::Reconciliation); + } + if state + .sweep_waiters + .any_waiting(crate::sweep_waiters::process_alive) + { + return Some(QuietBlocker::SweepWaiter); + } + // Unknown counts as held: a claim that cannot be read may still stand. + let holds_a_claim = state.coordinator.list_sessions().map_or(true, |sessions| { + sessions + .iter() + .filter(|session| session.vendor != crate::agent_activity::DAEMON_OWN_VENDOR) + .any(|session| { + state + .coordinator + .list_intents(&session.session_id) + .map_or(true, |claims| !claims.is_empty()) + }) + }); + if holds_a_claim { + return Some(QuietBlocker::Claim); + } + // The transactions this daemon holds, ahead of their durable mirror, which + // the store's own records answer for below. + let holds_a_transaction = state + .mcp_transactions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .any(|transaction| !matches!(transaction.state.as_str(), "committed" | "aborted")); + if holds_a_transaction { + return Some(QuietBlocker::Open(OpenWork::Transaction)); + } + history_attach::open_work(&state.layout).map(QuietBlocker::Open) +} + +/// What one look at a serving daemon's store found. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Look { + /// No history is staged. `deriving` says whether one may be soon. + Nothing { deriving: bool }, + /// A history is staged and the handover waits for this. + Wait(QuietBlocker), + /// A history is staged and this is a quiet point. + HandOff, + /// The history is complete, or can never be attached: nothing to watch. + Over, +} + +/// Look at the store `state` serves and decide what a staged history calls +/// for. Blocking: the last check before a handover reads the store's +/// persisted authority. +pub(crate) fn look(state: &DaemonState) -> Look { + let layout = &state.layout; + let progress = match history_state::standing(layout) { + HistoryStanding::Complete => return Look::Over, + HistoryStanding::Partial { + progress: Some(progress), + .. + } => progress, + // A background store whose worker has reported nothing yet is owed a + // derivation that is about to start, and a small repository's is over + // in seconds. + HistoryStanding::Partial { + record, + progress: None, + } => { + return Look::Nothing { + deriving: record.mode == HistoryMode::Background && record.underivable.is_none(), + } + } + HistoryStanding::Unreadable { .. } => return Look::Nothing { deriving: false }, + }; + let Some(stage) = kin_cli::commands::history_derive::finished_stage(Some(&progress)) else { + return Look::Nothing { + deriving: matches!( + progress.state, + DerivationState::Queued | DerivationState::Running + ), + }; + }; + if let Some(blocker) = quiet_blocker(state) { + return Look::Wait(blocker); + } + // Asked here so this daemon does not leave its clients for an attach that + // would wait or be refused. The attach asks again for itself. + match history_attach::attach_readiness(layout, &stage) { + Ok(AttachReadiness::Ready) => {} + Ok(AttachReadiness::AlreadyComplete) => return Look::Over, + Ok(AttachReadiness::Waiting(work)) => return Look::Wait(QuietBlocker::Open(work)), + Err(error) => { + record_refusal(layout, progress.attempts, &error.to_string()); + return Look::Over; + } + } + // That read takes as long as the partial store is large, and a request + // may have arrived during it. + match quiet_blocker(state) { + Some(blocker) => Look::Wait(blocker), + None => Look::HandOff, + } +} + +/// Watch for a staged history for as long as this daemon serves a partial +/// store, and hand the store over at the first quiet point after one arrives. +pub(crate) fn spawn_handoff_watch( + state: Arc, + cancel: tokio::sync::watch::Receiver, +) { + spawn_handoff_watch_every( + state, + cancel, + HANDOFF_LOOK, + HANDOFF_LOOK_WHEN_NOTHING_DERIVES, + ); +} + +/// [`spawn_handoff_watch`] at a cadence its caller names: `look_every` while +/// a derivation is running or staged, `idle_every` otherwise. +fn spawn_handoff_watch_every( + state: Arc, + mut cancel: tokio::sync::watch::Receiver, + look_every: Duration, + idle_every: Duration, +) -> Option> { + if history_state::standing(&state.layout).is_complete() { + return None; + } + Some(tokio::spawn(async move { + let mut wait = look_every; + // The blocker the looks keep meeting, and since when. One that has + // stood for a whole look interval is worth telling people about; one + // met once is usually a request that has been answered before anyone + // could read the note. + let mut met: Option<(QuietBlocker, tokio::time::Instant)> = None; + loop { + tokio::select! { + () = tokio::time::sleep(wait) => {} + _ = cancel.changed() => return, + } + if *cancel.borrow() { + return; + } + let looking = Arc::clone(&state); + let Ok(found) = tokio::task::spawn_blocking(move || look(&looking)).await else { + continue; + }; + wait = look_every; + match found { + Look::Over => return, + Look::Nothing { deriving } => { + met = None; + if !deriving { + wait = idle_every; + } + } + Look::Wait(blocker) => { + let since = match met { + Some((standing, since)) if standing == blocker => since, + _ => tokio::time::Instant::now(), + }; + met = Some((blocker, since)); + if since.elapsed() >= look_every { + note_wait(&state.layout, Some(blocker)); + } + // Requests come and go in milliseconds, so the gap + // between two of them is looked for more often than + // anything else is waited on. + if blocker == QuietBlocker::Request { + wait = look_every / 10; + } + } + Look::HandOff => { + note_wait(&state.layout, None); + if state.hand_off_for_history() { + tracing::info!( + "this store's complete history is staged and nothing is open; \ + stopping so the next start attaches it" + ); + return; + } + } + } + } + })) +} + +fn note_wait(layout: &KinLayout, blocker: Option) { + match history_state::note_staged_wait(layout, blocker.map(QuietBlocker::describe)) { + Ok(noted) => { + if let (true, Some(blocker)) = (noted, blocker) { + tracing::info!( + waiting_for = blocker.describe(), + "this store's complete history is staged; waiting for a quiet point" + ); + } + } + Err(error) => { + tracing::warn!(%error, "could not record what the staged history is waiting for") + } + } +} + +/// Set once this process has stopped serving to hand its store over, so its +/// entrypoint starts the successor as the process ends. +static HANDED_OFF: AtomicBool = AtomicBool::new(false); + +/// Hold the repository's runtime authority until this process is gone, and +/// mark the process as one that handed its store over. +/// +/// A daemon's process outlives its run: the entrypoint gives the runtime's +/// blocking threads a grace period afterwards, and one of them may still be +/// writing a record by name. Released here, the authority would let the +/// successor attach the history under that thread, and the write would land in +/// the store the successor installed. Never released, it ends with the +/// process, which is the first moment nothing of this daemon can write. +pub(crate) fn keep_authority_until_exit(authority: crate::lifecycle::DaemonLock) { + std::mem::forget(authority); + HANDED_OFF.store(true, Ordering::SeqCst); +} + +/// Whether this process stopped serving to hand its store over. +pub fn handed_off() -> bool { + HANDED_OFF.load(Ordering::SeqCst) +} + +/// The environment this process was started with, before it adopted anything +/// of its own. +static STARTED_WITH: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +/// Remember the environment this process was started with. Called first +/// thing, while the process is single-threaded and before it writes the +/// resource profile, retrieval arm and tool directories it resolves into its +/// own environment. +/// +/// A successor that inherited those would read them as an operator's explicit +/// settings, which outrank a store's own, and the store it opens is not the +/// one they were resolved from. +pub fn remember_start_environment() { + let _ = STARTED_WITH.set(std::env::vars_os().collect()); +} + +/// Start the daemon that takes this one's place, exactly as this one was +/// started: the same program, arguments, environment and output. +/// +/// Called as the process ends, after the runtime is gone. The successor waits +/// on the runtime authority this process still holds, takes it the moment the +/// process is gone, and attaches the staged history before it opens the +/// store. A successor that cannot be started costs nothing but time: the next +/// client to call starts a daemon as it always has, and that start attaches. +pub fn start_successor() -> std::io::Result { + let program = std::env::current_exe()?; + let mut command = std::process::Command::new(program); + command + .args(std::env::args_os().skip(1)) + .stdin(std::process::Stdio::null()); + if let Some(environment) = STARTED_WITH.get() { + command.env_clear().envs(environment.iter().cloned()); + } + kin_daemon_spawn::detach_from_caller(&mut command); + command.spawn().map(|child| child.id()) +} + #[cfg(test)] mod tests { use super::*; @@ -211,15 +659,515 @@ mod tests { assert!(owes_a_worker(&layout, now + chrono::Duration::minutes(11))); } + /// A Git repository of two commits, converted with only its checked-out + /// commit, with its complete history derived and staged beside it: what a + /// background worker leaves for a daemon to find. + struct StagedHistory { + _repository: tempfile::TempDir, + layout: KinLayout, + repository_id: kin_model::RepositoryId, + stage: std::path::PathBuf, + } + + impl StagedHistory { + fn progress(&self) -> Option { + match history_state::standing(&self.layout) { + HistoryStanding::Partial { progress, .. } => progress, + _ => None, + } + } + + fn is_complete(&self) -> bool { + history_state::standing(&self.layout).is_complete() + } + + fn stage_is_whole(&self) -> bool { + history_attach::staged_store(&self.stage) + .join("manifest.json") + .is_file() + } + + /// How many changes the store's persisted authority holds. + fn changes(&self) -> usize { + let (authority, _) = + kin_core::repository_authority::open_persisted_local_repository_authority( + self.repository_id.clone(), + Arc::new(kin_db::LocalFileBackend::new(self.layout.kindb_dir())), + ) + .unwrap(); + let lease = authority.read_authority(); + lease.snapshot().changes.len() + } + + /// The store as a start finds it after the attach: complete, with + /// both commits, no journal, no stage, and a daemon state that opens. + fn assert_attached(&self) { + assert!( + self.is_complete(), + "the store still records partial history" + ); + assert!(!self.layout.root().join("history-attach.json").exists()); + assert!(!self.stage.exists(), "the stage was left behind"); + assert_eq!(self.changes(), 2, "both commits are in the attached store"); + DaemonState::open(self.layout.clone()).expect("the attached store opens"); + } + } + + fn staged_history() -> StagedHistory { + let repository = tempfile::tempdir().unwrap(); + let root = repository.path(); + let git = |args: &[&str]| { + let output = kin_git::test_support::fixture_git_in(root) + .args([ + "-c", + "user.name=History Test", + "-c", + "user.email=history@example.test", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .current_dir(root) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "-q", "--initial-branch=main"]); + // The conversion resolves these in this process too. Pin both to the + // temporary repository rather than the developer's global scope. + let hooks = root.join(".git/hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + git(&["config", "core.hooksPath", hooks.to_str().unwrap()]); + let excludes = root.join(".git/fixture-excludes"); + std::fs::write(&excludes, b"").unwrap(); + git(&["config", "core.excludesFile", excludes.to_str().unwrap()]); + std::fs::write(root.join("a.py"), b"def a():\n return 1\n").unwrap(); + git(&["add", "a.py"]); + git(&["commit", "-q", "-s", "-m", "Add a"]); + std::fs::write( + root.join("b.py"), + b"from a import a\n\ndef b():\n return a()\n", + ) + .unwrap(); + git(&["add", "b.py"]); + git(&["commit", "-q", "-s", "-m", "Add b"]); + + let partial = + kin_core::init_current_commit_from_git(root, None, HistoryMode::Background).unwrap(); + let stage = history_attach::new_stage_parent(root, partial.repository_id.as_str()).unwrap(); + kin_core::derive_complete_history_into_stage( + root, + partial.layout.root(), + &partial.repository_id, + &stage, + ) + .unwrap(); + let mut staged = HistoryDerivationProgress::new(DerivationState::Staged); + staged.attempts = 1; + staged.detail = Some(stage.display().to_string()); + history_state::write_progress(&partial.layout, &staged).unwrap(); + StagedHistory { + _repository: repository, + layout: partial.layout, + repository_id: partial.repository_id, + stage, + } + } + + fn transaction(state: &str) -> kin_mcp::McpTransaction { + kin_mcp::McpTransaction { + transaction_id: "transaction".into(), + session_id: "session".into(), + scope: "entity".into(), + state: state.into(), + staged_operations: Vec::new(), + created_at: None, + commit_payload_hash: None, + last_activity_at: kin_model::Timestamp::now(), + } + } + + /// A serving daemon that finds its history staged waits while anything is + /// in flight or open, names what it waits for, moves nothing, and reaches + /// the handover only once all of it is finished. #[test] - fn a_staged_history_waits_for_the_next_start() { - let dir = tempfile::tempdir().unwrap(); - let layout = partial(dir.path(), HistoryMode::Background); + fn a_staged_history_waits_for_every_kind_of_work_before_the_handover() { + let staged = staged_history(); + let state = DaemonState::open(staged.layout.clone()).unwrap(); + + // Until a history is staged there is nothing to hand over, and the + // daemon keeps looking often only while one is on its way. + let worker_left = staged.progress().unwrap(); + history_state::clear_progress(&staged.layout).unwrap(); + assert_eq!(look(&state), Look::Nothing { deriving: true }); + let mut running = HistoryDerivationProgress::new(DerivationState::Running); + running.pid = Some(std::process::id()); + history_state::write_progress(&staged.layout, &running).unwrap(); + assert_eq!(look(&state), Look::Nothing { deriving: true }); history_state::write_progress( - &layout, - &HistoryDerivationProgress::new(DerivationState::Staged), + &staged.layout, + &HistoryDerivationProgress::new(DerivationState::Failed), ) .unwrap(); + assert_eq!(look(&state), Look::Nothing { deriving: false }); + history_state::write_progress(&staged.layout, &worker_left).unwrap(); + + state.is_initialized.store(false, Ordering::Relaxed); + assert_eq!(look(&state), Look::Wait(QuietBlocker::FirstScan)); + state.is_initialized.store(true, Ordering::Relaxed); + + state.begin_request(); + assert_eq!(look(&state), Look::Wait(QuietBlocker::Request)); + state.end_request(); + + state + .reconciliation_status + .store(RECON_PROCESSING, Ordering::Relaxed); + assert_eq!(look(&state), Look::Wait(QuietBlocker::Reconciliation)); + state + .reconciliation_status + .store(crate::state::RECON_IDLE, Ordering::Relaxed); + + // An attached session holds nothing by being attached. One that + // claims an entity does, until it lets go. + let session = state + .coordinator + .register_session( + "claude-code", + "test", + kin_model::session::SessionTransport::Mcp, + None, + std::path::PathBuf::from("/"), + kin_model::session::SessionCapabilities::default(), + ) + .unwrap(); + assert_eq!(look(&state), Look::HandOff); + let claim = match state + .coordinator + .register_intent( + &session, + vec![kin_model::session::IntentScope::Entity( + kin_model::ids::EntityId::new(), + )], + kin_model::session::LockType::Soft, + "rename an entity", + None, + ) + .unwrap() + { + crate::session_registry::IntentRegistrationResult::Registered { intent_id, .. } => { + intent_id + } + other => panic!("the claim was not registered: {other:?}"), + }; + assert_eq!(look(&state), Look::Wait(QuietBlocker::Claim)); + state.coordinator.release_intent(&session, &claim).unwrap(); + assert_eq!(look(&state), Look::HandOff); + + // A transaction this daemon holds, before and after it is settled. + state + .mcp_transactions + .lock() + .unwrap() + .insert("transaction".into(), transaction("active")); + assert_eq!( + look(&state), + Look::Wait(QuietBlocker::Open(OpenWork::Transaction)) + ); + state + .mcp_transactions + .lock() + .unwrap() + .insert("transaction".into(), transaction("committed")); + assert_eq!(look(&state), Look::HandOff); + state.mcp_transactions.lock().unwrap().clear(); + + // Work the store's own records hold, which outlives this daemon. + std::fs::write(staged.layout.merge_state_path(), b"{}").unwrap(); + assert_eq!( + look(&state), + Look::Wait(QuietBlocker::Open(OpenWork::Merge)) + ); + std::fs::remove_file(staged.layout.merge_state_path()).unwrap(); + + assert_eq!(look(&state), Look::HandOff); + // Looking changes nothing: the store is still partial, its history + // still staged where the worker left it. + assert!(!staged.is_complete()); + assert!(staged.stage_is_whole()); + assert_eq!( + staged.progress().unwrap().staged_at(), + Some(staged.stage.clone()) + ); + assert_eq!(staged.changes(), 1); + + // A state no daemon loop runs has nothing to stop, and is not marked + // as handing off for it. + assert!(!state.hand_off_for_history()); + assert!(!state.handing_off_for_history()); + } + + /// A staged history that can never be attached is recorded as such once, + /// and the daemon stops watching rather than leaving its clients for it. + #[test] + fn a_stage_that_cannot_be_attached_is_recorded_and_never_handed_off() { + let staged = staged_history(); + let state = DaemonState::open(staged.layout.clone()).unwrap(); + state.is_initialized.store(true, Ordering::Relaxed); + // A staged store that itself records partial history is not complete. + let HistoryStanding::Partial { record, .. } = history_state::standing(&staged.layout) + else { + panic!("the fixture store is partial"); + }; + history_state::stamp_staged( + &KinLayout::new(history_attach::staged_store(&staged.stage)), + &record, + ) + .unwrap(); + + assert_eq!(look(&state), Look::Over); + let progress = staged.progress().unwrap(); + assert_eq!(progress.state, DerivationState::Waiting); + assert!( + progress + .detail + .as_deref() + .is_some_and(|detail| detail.contains("does not hold complete history")), + "{progress:?}" + ); + assert!(!staged.is_complete()); + assert_eq!(staged.changes(), 1); + } + + /// The watch a serving daemon runs: while a request is held it records + /// what it waits for and stops nothing; once the request ends it stops the + /// daemon for the handover, and the next start attaches the history. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_watch_waits_while_busy_then_hands_off_and_the_next_start_attaches() { + let staged = staged_history(); + let state = Arc::new(DaemonState::open(staged.layout.clone()).unwrap()); + state.is_initialized.store(true, Ordering::Relaxed); + let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false); + state.stop_trigger.set(cancel_tx).unwrap(); + + state.begin_request(); + let watch = spawn_handoff_watch_every( + Arc::clone(&state), + cancel_rx.clone(), + Duration::from_millis(20), + Duration::from_millis(20), + ) + .expect("a partial store is watched"); + let waiting_for = QuietBlocker::Request.describe(); + let deadline = std::time::Instant::now() + Duration::from_secs(60); + while staged + .progress() + .and_then(|progress| progress.staged_waiting_for().map(str::to_string)) + .as_deref() + != Some(waiting_for) + { + assert!( + std::time::Instant::now() < deadline, + "the watch never recorded what it waits for: {:?}", + staged.progress() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + // Several looks later it is still serving and has moved nothing. + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + !*cancel_rx.borrow(), + "the daemon was stopped under a request" + ); + assert!(!state.handing_off_for_history()); + assert!(!staged.is_complete()); + assert!(staged.stage_is_whole()); + let sentence = history_state::standing(&staged.layout).sentence().unwrap(); + assert!( + sentence.contains(&format!("(waiting for {waiting_for})")), + "{sentence}" + ); + + state.end_request(); + tokio::time::timeout(Duration::from_secs(60), cancel_rx.changed()) + .await + .expect("with nothing in flight the daemon is stopped for the handover") + .unwrap(); + assert!(*cancel_rx.borrow()); + assert!(state.handing_off_for_history()); + tokio::time::timeout(Duration::from_secs(60), watch) + .await + .expect("the watch ends with the handover") + .unwrap(); + // The handover itself attaches nothing: that is the next start's. + let progress = staged.progress().unwrap(); + assert_eq!(progress.staged_at(), Some(staged.stage.clone())); + assert_eq!(progress.staged_waiting_for(), None); + assert!(!staged.is_complete()); + drop(state); + + before_open(&staged.layout); + staged.assert_attached(); + assert!(staged.progress().is_none()); + } + + /// A start that finds open work attaches nothing and keeps the history + /// staged, saying what it waits for. A start after the work is finished + /// attaches it. + #[test] + fn a_start_waits_for_open_work_and_attaches_once_it_is_finished() { + let staged = staged_history(); + let mirror = staged + .layout + .root() + .join(history_attach::AGENT_TRANSACTIONS_RECORD); + let open: std::collections::HashMap<_, _> = + [("transaction".to_string(), transaction("active"))].into(); + std::fs::write(&mirror, serde_json::to_vec(&open).unwrap()).unwrap(); + + before_open(&staged.layout); + assert!(!staged.is_complete()); + assert!(staged.stage_is_whole()); + let progress = staged.progress().unwrap(); + assert_eq!(progress.state, DerivationState::Staged); + assert_eq!(progress.staged_at(), Some(staged.stage.clone())); + assert_eq!( + progress.staged_waiting_for(), + Some(OpenWork::Transaction.describe()) + ); + // The transaction is exactly where its agent left it. + assert_eq!( + crate::state::load_persisted_mcp_transactions_checked(&staged.layout) + .unwrap() + .get("transaction") + .map(|transaction| transaction.state.clone()), + Some("active".to_string()) + ); + assert_eq!(staged.changes(), 1); + + let settled: std::collections::HashMap<_, _> = + [("transaction".to_string(), transaction("aborted"))].into(); + std::fs::write(&mirror, serde_json::to_vec(&settled).unwrap()).unwrap(); + before_open(&staged.layout); + staged.assert_attached(); + } + + /// An attach stopped part way, as a crash stops it, is finished by the + /// next start before anything opens the store, whichever half of the swap + /// it was stopped in. + #[test] + fn a_start_finishes_an_attach_a_crash_interrupted() { + let journal_phase = |layout: &KinLayout| { + let raw = std::fs::read(layout.root().join("history-attach.json")).unwrap(); + let journal: serde_json::Value = serde_json::from_slice(&raw).unwrap(); + journal["phase"].as_str().unwrap().to_string() + }; + // Stopped before the first move, part way through moving the partial + // store out, and part way through moving the complete one in. + for stop in ["before the first move", "moving out", "moving in"] { + let staged = staged_history(); + history_attach::interruption::after(Some(match stop { + "before the first move" => 0, + _ => 2, + })); + let stopped = + history_attach::attach_staged_history(&staged.layout, &staged.stage).unwrap_err(); + assert!(stopped.to_string().contains("interrupted"), "{stopped}"); + assert_eq!(journal_phase(&staged.layout), "evacuating", "{stop}"); + if stop == "moving in" { + while journal_phase(&staged.layout) != "installing" { + history_attach::interruption::after(Some(1)); + history_attach::recover_interrupted_attach(&staged.layout).unwrap_err(); + } + // Two entries of the complete store are in place, the rest + // are still staged. + history_attach::interruption::after(Some(2)); + history_attach::recover_interrupted_attach(&staged.layout).unwrap_err(); + assert!(staged.stage_is_whole() || staged.layout.manifest_path().is_file()); + } + history_attach::interruption::after(None); + + before_open(&staged.layout); + staged.assert_attached(); + } + } + + /// A daemon killed after the partial store was moved out and before the + /// complete one was moved in leaves a store with no manifest. A start + /// finishes the attach before it reads the manifest, so that store opens. + #[test] + fn a_start_recovers_a_store_whose_manifest_was_already_moved_out() { + let staged = staged_history(); + // Stop the swap one step at a time until the store's manifest has + // been moved out, which is how a kill at that point leaves it. + history_attach::interruption::after(Some(0)); + history_attach::attach_staged_history(&staged.layout, &staged.stage).unwrap_err(); + while staged.layout.manifest_path().exists() { + history_attach::interruption::after(Some(1)); + history_attach::recover_interrupted_attach(&staged.layout).unwrap_err(); + } + history_attach::interruption::after(None); + assert!(history_attach::attach_is_interrupted(&staged.layout)); + assert!( + kin_core::manifest::resolve_repo_id(&staged.layout, None).is_err(), + "the half-moved store still answers for its identity" + ); + + recover_before_start(&staged.layout); + assert!(!history_attach::attach_is_interrupted(&staged.layout)); + assert_eq!( + kin_core::manifest::resolve_repo_id(&staged.layout, None).unwrap(), + staged.repository_id.as_str() + ); + staged.assert_attached(); + // Nothing is left to recover, and asking again changes nothing. + recover_before_start(&staged.layout); + staged.assert_attached(); + } + + /// A crash while an attach removes its stage, after the journal is gone, + /// leaves the stage behind. The next start of the complete store removes + /// it, and never a stage of another repository. + #[test] + fn a_start_removes_a_stage_an_attach_left_behind() { + let staged = staged_history(); + before_open(&staged.layout); + staged.assert_attached(); + let working_dir = staged.layout.working_dir().to_path_buf(); + // What a crash part way through removing the stage leaves: some of + // the replaced partial store, and no staged store. + let left = + history_attach::new_stage_parent(&working_dir, staged.repository_id.as_str()).unwrap(); + std::fs::create_dir_all(left.join("provisional/kindb")).unwrap(); + std::fs::write(left.join("provisional/kindb/left"), b"replaced").unwrap(); + let theirs = history_attach::new_stage_parent(&working_dir, "another-repository").unwrap(); + + before_open(&staged.layout); + assert!(!left.exists(), "the leftover stage was kept"); + assert!(theirs.exists(), "another repository's stage was removed"); + staged.assert_attached(); + std::fs::remove_dir_all(theirs).unwrap(); + } + + /// A staged history is handed over, not derived again, for as long as + /// its stage is there. A stage that is gone is derived again. + #[test] + fn a_staged_history_owes_a_worker_only_when_its_stage_is_gone() { + let dir = tempfile::tempdir().unwrap(); + let layout = partial(dir.path(), HistoryMode::Background); + let stage = dir.path().join("stage"); + let staged_store = history_attach::staged_store(&stage); + std::fs::create_dir_all(&staged_store).unwrap(); + std::fs::write(staged_store.join("manifest.json"), b"{}").unwrap(); + let mut staged = HistoryDerivationProgress::new(DerivationState::Staged); + staged.detail = Some(stage.display().to_string()); + history_state::write_progress(&layout, &staged).unwrap(); assert!(!owes_a_worker(&layout, chrono::Utc::now())); + std::fs::remove_dir_all(&stage).unwrap(); + assert!(owes_a_worker(&layout, chrono::Utc::now())); } } diff --git a/crates/kin-daemon/src/lib.rs b/crates/kin-daemon/src/lib.rs index 86c3b5255..1bb30d05b 100644 --- a/crates/kin-daemon/src/lib.rs +++ b/crates/kin-daemon/src/lib.rs @@ -116,6 +116,7 @@ pub(crate) mod accepted_enrichment; pub mod agent_activity; +pub mod agent_authorship; pub mod api; mod artifact_commit; pub mod auth_rotation; @@ -123,15 +124,21 @@ pub mod background_work; mod binding_history; pub(crate) mod call_site_ledger; pub(crate) mod call_site_settlement; +pub mod client_edits; mod commit_changes; pub mod commit_deltas; pub mod commit_liveness; +mod committed_guides; pub mod daemon; +pub mod ended_sessions; mod entity_drafts; +mod entity_keep; mod entity_lifecycle; +mod entity_split; mod entity_words; pub mod error; pub mod gcs_endpoint; +mod git_update; pub mod graph_only_members; /// First contact with `kin_graph_status` while an embedding pass holds /// embedding-work serialization. Test-only, and its own module so the fixture @@ -174,6 +181,7 @@ mod repository_rename; mod repository_rollback; mod repository_stash; mod repository_tag; +mod review_flags; mod review_transfer; mod review_write; mod semantic_debt; @@ -194,6 +202,7 @@ mod sweep_waiters; mod test_fixture_cache; pub mod traffic_adapter; mod unit_lifecycle; +mod verification_write; pub mod watcher_loss; pub mod write_veto; diff --git a/crates/kin-daemon/src/loop_runner.rs b/crates/kin-daemon/src/loop_runner.rs index f15b49984..55ed9b5c8 100644 --- a/crates/kin-daemon/src/loop_runner.rs +++ b/crates/kin-daemon/src/loop_runner.rs @@ -657,6 +657,52 @@ fn layout_is_held(state: &DaemonState, file_id: &FilePathId) -> bool { }) } +/// Where a host path sits in this repository, for a caller outside the +/// reconcile loop that was handed one at a boundary. `None` for a path that is +/// not this repository's to hold. +pub(crate) fn host_repo_path(state: &DaemonState, host: &Path) -> Option { + repo_path(host, state.layout.working_dir()).ok().flatten() +} + +/// Whether `path` is source Kin parses into entities, by its name alone. +pub(crate) fn is_entity_source_path(path: &RepoPath) -> bool { + path.as_utf8().is_some_and(|file| { + matches!( + FileClassifier::classify(Path::new(file)), + FileClassification::EntitySource + ) + }) +} + +/// Whether the daemon's tree holds exactly what the host holds at `path`. +pub(crate) fn host_unit_is_admitted(state: &DaemonState, host: &Path, path: &RepoPath) -> bool { + matches!(host_entry_matches_graph(state, host, path), Ok(true)) +} + +/// Whether the entities the graph holds for `path` were derived from the +/// bytes its tree holds there: a complete parse bound to those bytes, whose +/// declaration regions are the spans the graph holds, with no earlier parse +/// retained in its place. +pub(crate) fn unit_semantics_are_held( + state: &DaemonState, + tree: &kin_model::ResolvedTree, + path: &RepoPath, +) -> bool { + let Some(file) = path.as_utf8() else { + return false; + }; + let retained = kin_core::retained_parse::read(&state.layout); + if matches!( + retained, + kin_core::retained_parse::RetainedParseRead::Unreadable(_) + ) || retained.errors_for(file).is_some() + { + return false; + } + crate::semantic_debt::answering_graph_parsed(state, tree, path) + && layout_is_held(state, &FilePathId::new(file)) +} + /// Whether `file_id` carries no shallow, structured or opaque record, which an /// entity-source round would clear. fn no_facet_of_another_kind(state: &DaemonState, file_id: &FilePathId) -> bool { @@ -747,12 +793,26 @@ pub(crate) fn publish_exact_workspace_tree( // still owe rides the same transaction. let owed = crate::semantic_debt::owed_by(&admitted.exact_deltas()?); let legacy = crate::semantic_debt::legacy_carry(state); - let authority_context = - crate::local_repository_authority::LocalRepositoryAuthorityContext::from_state(state)?; let started = Instant::now(); - let Some(admission) = crate::repository_commit::publish_workspace_tree( + // The authority the daemon already holds for the current publication, as + // every other daemon publisher binds it, rather than an open of this + // publication's own. See `publish_workspace_tree_through`. + let authority = + crate::local_repository_authority::ActiveLocalRepositoryAuthority::open_bound(state) + .map_err(|refusal| match refusal { + crate::local_repository_authority::RepositoryAuthorityBindRefusal::Unbound( + error, + ) => error, + refusal => DaemonError::Graph(kin_db::KinDbError::StorageError(format!( + "{:#}", + refusal.into_error() + ))), + })?; + let published = crate::repository_commit::publish_workspace_tree_through( state.blobs.as_ref(), - &authority_context, + &authority.manager, + &authority.repository_id, + authority.workspace_id, admitted, kin_model::OperationId::new(), // The daemon's own loop is the actor here, and naming it is a statement @@ -765,10 +825,14 @@ pub(crate) fn publish_exact_workspace_tree( kin_model::AuthorId::new(DAEMON_ADMISSION_ACTOR), &owed, &legacy, - )? - else { + )?; + let Some(admission) = published else { return Ok(None); }; + // The held authority now holds the successor it just wrote. While the + // durable record is still the one that commit installed, every reader + // after this one keeps it rather than reopening the store to load it. + crate::api::relabel_held_authority_after_own_commit(state); if !legacy.is_empty() { crate::semantic_debt::legacy_carried(state); } @@ -1521,21 +1585,32 @@ fn exact_tree_admission_once( // captured successor then carries both in the same publication, so // a pass carrying a single source does not publish its bytes and // leave its parse to the live graph alone. - let before = state.graph.semantic_observation(); - let staged = kin_db::InMemoryGraph::from_snapshot_without_text_index(before.clone())?; - staged.apply_transaction_delta(&live_delta)?; + let (before, staged) = + crate::mcp_commit::timed_commit_phase("stage_live_graph", || { + let before = state.graph.semantic_observation(); + let staged = + kin_db::InMemoryGraph::from_snapshot_without_text_index(before.clone())?; + staged.apply_transaction_delta(&live_delta)?; + Ok::<_, DaemonError>((before, staged)) + })?; let paths = source_batch::with_owed_paths(state, changed_paths.clone()); - prepared_sources = source_batch::prepare( - state, - reconciler, - &staged, - &paths, - source_batch::BatchPredecessor::Observed(Some(&before)), - &changed_paths, - source_batch::BatchScope::ExactAdmission, - )?; + prepared_sources = + crate::mcp_commit::timed_commit_phase("prepare_changed_sources", || { + source_batch::prepare( + state, + reconciler, + &staged, + &paths, + source_batch::BatchPredecessor::Observed(Some(&before)), + &changed_paths, + source_batch::BatchScope::ExactAdmission, + ) + })?; if let Some(prepared) = &prepared_sources { - let mut joined = source_batch::semantic_delta(&before, prepared.snapshot())?; + let mut joined = + crate::mcp_commit::timed_commit_phase("join_semantic_delta", || { + source_batch::semantic_delta(&before, prepared.snapshot()) + })?; joined.tree_deltas = live_delta.tree_deltas; live_delta = joined; } @@ -1547,13 +1622,15 @@ fn exact_tree_admission_once( // has not crossed the repository-v6 compare-and-swap. The scanner's // completion proof and the planning base travel with the tree so // publication can verify both rather than trust the caller. - let admitted = crate::repository_commit::AdmittedWorkspaceTree::from_complete_observation( - observed.completion(), - expected_roots, - previous.clone(), - desired_tree, - ) - .with_live_observation(state.graph.as_ref(), &live_delta)?; + let admitted = crate::mcp_commit::timed_commit_phase("capture_live_observation", || { + crate::repository_commit::AdmittedWorkspaceTree::from_complete_observation( + observed.completion(), + expected_roots, + previous.clone(), + desired_tree, + ) + .with_live_observation(state.graph.as_ref(), &live_delta) + })?; #[cfg(test)] after_admission_capture_for_test(state); if proposes_root_rule { @@ -1633,17 +1710,34 @@ fn exact_tree_admission_once( // earlier live observation; it must not overwrite the newer witness. let batch_derivation = (defer && prepared_sources.is_some()) .then(|| crate::binding_history::Derivation::begin(state)); - let applied = state - .graph - .apply_transaction_delta(&live_delta) - .map_err(|error| name_stranded_endpoint_refusal(DaemonError::Graph(error), &deltas)); + let applied = crate::mcp_commit::timed_commit_phase("apply_live_delta", || { + state + .graph + .apply_transaction_delta(&live_delta) + .map_err(|error| name_stranded_endpoint_refusal(DaemonError::Graph(error), &deltas)) + }); match applied { Ok(()) => { finish_removed_path_enrichment(state, &vacated, &live_delta.entity_deltas)?; relocate_file_records_for(state, &relocations)?; if !defer { - crate::binding_history::restore_exact_authority(state); + crate::mcp_commit::timed_commit_phase("restore_binding_authority", || { + crate::binding_history::restore_exact_authority(state) + }); } + // One step per unit this transition moved, for an agent + // client that says the edit was its own: the entities the + // transaction changed where it carried the unit's parse, and + // what the unit still holds where its parse is yet to come. + // See `crate::client_edits`. + state.client_edits.tree_moved( + state, + &deltas, + prepared_sources + .as_ref() + .map(source_batch::PreparedLiveBatch::handled), + &live_delta.entity_deltas, + ); } // A deferred tree has not crossed authority, so a refusal here // splits nothing: the caller's transaction never runs and authority @@ -15626,24 +15720,31 @@ async fn sync_filesystem_with_graph_publishing_inner( // had paid. let batch_paths = source_batch::with_owed_paths(state, exact_admission.changed_paths.clone()); let mut batch_delta = crate::state::PassDelta::default(); - let batch_result = if let Some(prepared) = exact_admission.prepared_sources.take() { - prepared.finish(state, &mut reconciler, &mut batch_delta) - } else { - source_batch::try_readmit( - state, - &mut reconciler, - &batch_paths, - exact_admission.semantic_predecessor.as_ref(), - &exact_admission.changed_paths, - &mut batch_delta, - ) - }; + let batch_result = crate::mcp_commit::timed_commit_phase("finish_changed_sources", || { + if let Some(prepared) = exact_admission.prepared_sources.take() { + prepared.finish(state, &mut reconciler, &mut batch_delta) + } else { + source_batch::try_readmit( + state, + &mut reconciler, + &batch_paths, + exact_admission.semantic_predecessor.as_ref(), + &exact_admission.changed_paths, + &mut batch_delta, + ) + } + }); drop(reconciler); if let Some(event) = batch_delta.into_event() { state.emit_event(event); } let handled = batch_result?; - if let Err(error) = drain_semantic_debt_except(state, &handled).await { + if let Err(error) = crate::mcp_commit::timed_commit_phase_async( + "drain_semantic_debt", + drain_semantic_debt_except(state, &handled), + ) + .await + { drop(binding_derivation); drop(graph_mutation); return Err(error); diff --git a/crates/kin-daemon/src/loop_runner/source_batch.rs b/crates/kin-daemon/src/loop_runner/source_batch.rs index e91228fef..79fd21bd6 100644 --- a/crates/kin-daemon/src/loop_runner/source_batch.rs +++ b/crates/kin-daemon/src/loop_runner/source_batch.rs @@ -421,6 +421,11 @@ impl PreparedLiveBatch { self.prepared.snapshot() } + /// The units whose complete parse this batch carries. + pub(super) fn handled(&self) -> &BTreeSet { + &self.handled + } + pub(super) fn finish( self, state: &DaemonState, diff --git a/crates/kin-daemon/src/mcp_commit.rs b/crates/kin-daemon/src/mcp_commit.rs index d5d153708..5ba9abf3f 100644 --- a/crates/kin-daemon/src/mcp_commit.rs +++ b/crates/kin-daemon/src/mcp_commit.rs @@ -46,6 +46,11 @@ struct ExactMcpPlan { /// build of Kin derived. See /// [`crate::repository_commit::NativeCommitPlan::carried_semantic_files`]. rederived_files: Vec, + /// Entities this change records in units the operations wrote exactly as + /// the working copy already held them: uncommitted work published with + /// the unit it sits in, which no operation here wrote. See + /// [`held_before_in_written_units`]. + held_before: BTreeSet, /// The paths this transaction's own operations write. Publication accepts /// one of them when the working copy already holds its exact target bytes, /// because a writer that edited the file and then staged its new text is @@ -209,6 +214,7 @@ struct PlannedCommitFacts { layouts: Vec, carried_pending_files: Vec, rederived_files: Vec, + held_before: BTreeSet, } /// How far back a commit looks in the audit log for attribution it may already @@ -862,6 +868,7 @@ fn commit_exact_transaction_inner( layouts: plan.layouts, carried_pending_files: plan.carried_pending_files, rederived_files: plan.rederived_files, + held_before: plan.held_before, }), coordination, CommitApplication::Applied, @@ -970,6 +977,10 @@ struct CommitActor { author: kin_model::AuthorId, actor: Actor, session_id: String, + /// The vendor and client names the session gave, made safe to record. + /// Empty when the session had already ended. + vendor: String, + client: String, } fn resolve_commit_actor(sessions: &kin_mcp::SessionRegistry, session_id: &str) -> CommitActor { @@ -1002,8 +1013,15 @@ fn commit_actor_for( kind: ActorKind::Assistant, display_name, external_refs: Vec::new(), + agent_session: None, }, session_id: session_id.to_string(), + vendor: agent + .map(|agent| provenance_label(&agent.vendor)) + .unwrap_or_default(), + client: agent + .map(|agent| provenance_label(&agent.client_name)) + .unwrap_or_default(), } } @@ -1091,6 +1109,7 @@ fn record_commit_provenance( committed: &NativeCommitResult, carried_pending_files: &[RepoPath], rederived_files: &[RepoPath], + held_before: &BTreeSet, ) -> Result<(), String> { graph .create_actor(&actor.actor) @@ -1135,7 +1154,13 @@ fn record_commit_provenance( origin_of.insert(entity.id, origin); } } + // Uncommitted work published inside a unit an operation wrote is carried + // the same way, entity by entity: the change records it as the working + // copy already held it, and no operation here wrote it. let is_carried = |entity_id: &kin_model::EntityId| -> bool { + if held_before.contains(entity_id) { + return true; + } if let Some(origin) = origin_of.get(entity_id) { return carried_origins.contains(origin); } @@ -1653,9 +1678,9 @@ fn plan_exact_transaction( // Unit-addressed work lands on top of entity edits, so an edit and an // import change to one unit publish together. It may not share a unit with - // a whole-file operation or with anchored lifecycle work, whose bytes are - // planned from a different base. - let mut whole_file_paths = creations + // a whole-file operation. Anchored lifecycle work is planned after the + // edits, on the source they produced, so it may share their unit. + let whole_file_paths = creations .values() .map(|(file_id, _)| file_id) .chain(replacements.values().map(|(file_id, _)| file_id)) @@ -1663,23 +1688,8 @@ fn plan_exact_transaction( .chain(relocations.values().flat_map(|(from, to)| [from, to])) .filter_map(|file_id| RepoPath::from_utf8(file_id.0.clone()).ok()) .collect::>(); - let lifecycle = crate::entity_lifecycle::prepare( - state, - authority_context, - base, - &transaction.staged_operations, - )?; - let (snapshot, mut layouts) = if let Some(lifecycle) = lifecycle { - if !authored_files.is_disjoint(&lifecycle.authored_files) { - return Err("lifecycle and other source operations cannot write the same source unit in one transaction".into()); - } - whole_file_paths.extend(lifecycle.authored_files.iter().cloned()); - authored_files.extend(lifecycle.authored_files); - (lifecycle.snapshot, lifecycle.layouts) - } else { - (base.graph.to_snapshot(), Vec::new()) - }; - let prospective = kin_db::InMemoryGraph::from_snapshot(snapshot) + let mut layouts = Vec::new(); + let mut prospective = kin_db::InMemoryGraph::from_snapshot(base.graph.to_snapshot()) .map_err(|error| format!("create prospective exact graph: {error}"))?; // Before any staged operation is planned, because an edit is re-derived // against its dependents and the strict reader refuses one still holding @@ -2005,6 +2015,33 @@ fn plan_exact_transaction( layouts.push(layout); } + if let Some(lifecycle) = crate::entity_lifecycle::prepare( + state, + authority_context, + &prospective, + &edited_entities, + &transaction.staged_operations, + )? { + if !whole_file_paths.is_disjoint(&lifecycle.authored_files) { + return Err( + "lifecycle and other source operations cannot write the same source unit in one transaction" + .into(), + ); + } + prospective = kin_db::InMemoryGraph::from_snapshot(lifecycle.snapshot) + .map_err(|error| format!("create prospective exact graph: {error}"))?; + for layout in lifecycle.layouts { + layouts.retain(|held: &FileLayout| held.file_id != layout.file_id); + layouts.push(layout); + } + for layout in &layouts { + prospective.upsert_file_layout(layout).map_err(|error| { + format!("install prospective layout for {}: {error}", layout.file_id) + })?; + } + authored_files.extend(lifecycle.authored_files); + } + authored_files.extend(crate::module_code_commit::plan_module_patches( state, held_authority, @@ -2058,7 +2095,7 @@ fn plan_exact_transaction( &mut layouts, )?; - apply_relation_operations(&prospective, relation_operations)?; + let added_relations = apply_relation_operations(&prospective, relation_operations)?; if semantic_workspace_matches( &prospective, carried_baseline.as_ref().unwrap_or(&base.graph), @@ -2080,6 +2117,133 @@ fn plan_exact_transaction( ); } + // Uncommitted work in source units no operation here wrote is somebody + // else's: a person's edits, or another session's. It is not this + // transaction's to publish, so the change records the base change and what + // this transaction's operations wrote, and that work stays in the working + // copy, uncommitted against the new change, with the entities and the + // authorship it already had. A unit an operation writes is one set of + // bytes, and the operation was planned against them as the working copy + // holds them, so uncommitted work inside that unit is published with the + // operation's own, as it always was. + let pending = timed_commit_phase("plan_pending_units", || { + pending_units(held_authority, base, &authored_files) + })?; + // Read before anything is published, while the daemon's graph is still the + // working copy this transaction was planned beside. + let held_before = |change: &kin_model::SemanticChange| { + held_before_in_written_units(state.graph.as_ref(), change, &pending.written) + }; + if let Some(left) = pending.left.as_ref() { + // A relationship this transaction's own operations or derivation + // changed keeps the value they gave it; every other one in a unit left + // uncommitted is that unit's, on either side. + let baseline = carried_baseline.as_ref().unwrap_or(&base.graph); + let changed = |id: &kin_model::RelationId, here: Option<&Relation>| { + baseline.get_relation_by_id(id).as_ref() != here + }; + let change = timed_commit_phase("plan_change_without_pending_units", || { + crate::entity_keep::with_units_from( + prospective.to_snapshot(), + crate::entity_keep::Semantics::of_state(&left.head), + &left.units, + &changed, + &added_relations, + ) + }) + .map_err(|error| { + format!( + "{error}, so this change cannot record what this transaction wrote without that \ + work. Commit or restore that uncommitted work first, then send this commit again" + ) + })?; + // The working copy keeps the units it leaves uncommitted as the daemon + // serves them, which is what keeps their entities' identities, and so + // whatever authorship is recorded against them, exactly as they were. + let live = state.graph.to_snapshot(); + if left.units.iter().any(|path| { + live.resolved_tree.artifact_at_path(path) != base.tree.artifact_at_path(path) + }) { + return Err( + "Kin is still reading the latest edits to uncommitted work this change leaves in \ + place, so nothing was published; send this commit again in a moment" + .to_string(), + ); + } + let working_copy = timed_commit_phase("plan_working_copy_after_commit", || { + crate::entity_keep::with_units_from( + prospective.to_snapshot(), + crate::entity_keep::Semantics::of_snapshot(&live), + &left.units, + &changed, + &BTreeSet::new(), + ) + }) + .map_err(|error| { + format!( + "{error}, so Kin cannot keep that work uncommitted beside this change. Commit or \ + restore that uncommitted work first, then send this commit again" + ) + })?; + drop(live); + drop(prospective); + + let plan_kept = |rederived: &[RepoPath]| { + crate::repository_commit::plan_native_commit_from_base_keeping_pending( + &change, + &working_copy, + state.blobs.as_ref(), + authority_context, + operation_id, + kin_model::Timestamp::now(), + actor.author.clone(), + &authored_files, + &|carried| { + commit_message( + &transaction.transaction_id, + requested_message, + carried, + rederived, + ) + }, + base, + Some(Arc::clone(held_authority)), + ) + .map_err(|error| format!("plan exact MCP repository commit: {error}")) + }; + let native = plan_kept(&[])?; + // Named in the message as on every other commit; see below. + let native = if native.carried_semantic_files.is_empty() { + native + } else { + let rederived = native.carried_semantic_files.clone(); + drop(native); + plan_kept(&rederived)? + }; + refuse_left_units_in_change(&native.change, &left.units)?; + if native.change.entity_deltas.is_empty() + && native.change.relation_deltas.is_empty() + && native.change.tree_deltas.is_empty() + { + return Err( + "exact MCP transaction produced no semantic, relation, or tree change outside \ + the uncommitted work it leaves in place" + .to_string(), + ); + } + let carried_pending_files = native.carried_pending_files.clone(); + let rederived_files = native.carried_semantic_files.clone(); + let held_before = held_before(&native.change); + return Ok(ExactMcpPlan { + native, + layouts, + carried_pending_files, + rederived_files, + held_before, + authored_files, + }); + } + let plan_against = |graph: &kin_db::InMemoryGraph, rederived: &[RepoPath]| { plan_native_commit_from_base_declaring_carry( graph, @@ -2143,15 +2307,179 @@ fn plan_exact_transaction( }; let carried_pending_files = native.carried_pending_files.clone(); let rederived_files = native.carried_semantic_files.clone(); + let held_before = held_before(&native.change); Ok(ExactMcpPlan { native, layouts, carried_pending_files, rederived_files, + held_before, authored_files, }) } +/// How the workspace's uncommitted work stands against one transaction's +/// operations. +struct PendingUnits { + /// The units the operations wrote that already held uncommitted work. Each + /// is one set of bytes, so that work is published with the operation's. + written: BTreeSet, + /// The units no operation wrote, which stay uncommitted, when there are + /// any, with the base change they are left against. + left: Option, +} + +/// The source units one transaction leaves uncommitted, and the base change +/// it leaves them against. +struct LeftUncommitted { + /// Every path those units hold in the base change or the working copy. + units: BTreeSet, + /// The base change the workspace stands on, as the change it records + /// holds the units it does not write. + head: kin_model::graph::ResolvedGraphState, +} + +/// Which source units the workspace holds ahead of its base change, read +/// against what this transaction's operations wrote. +/// +/// Read one unit transition at a time, and a transition that touches a path +/// the operations wrote belongs to them whole. A unit the working copy moved +/// and an operation then wrote is one unit: its move is published with the +/// write, declared as carried, rather than split from it. +fn pending_units( + held_authority: &kin_db::RepositoryAuthorityManager, + base: &NativeCommitBase, + authored: &BTreeSet, +) -> Result { + let lease = held_authority.read_authority(); + let head = match base.head { + Some(head) => crate::commit_deltas::resolve_authority_baseline(lease.snapshot(), &head) + .map_err(|error| format!("read the change the workspace stands on: {error}"))?, + None => std::borrow::Cow::Owned(kin_model::graph::ResolvedGraphState::default()), + }; + let pending = kin_core::exact_tree_correction(&head.tree, &base.tree) + .map_err(|error| format!("read the workspace's uncommitted work: {error}"))?; + let mut written = BTreeSet::new(); + let mut units = BTreeSet::new(); + for delta in &pending { + let paths = [delta.old_state(), delta.new_state()] + .into_iter() + .flatten() + .map(|located| located.path.clone()) + .collect::>(); + if paths.iter().any(|path| authored.contains(path)) { + written.extend(paths); + } else { + units.extend(paths); + } + } + let left = (!units.is_empty()).then(|| LeftUncommitted { + units, + head: head.into_owned(), + }); + Ok(PendingUnits { written, left }) +} + +/// The entities a change records, in units the transaction wrote that already +/// held uncommitted work, exactly as the working copy held them before the +/// transaction ran. +/// +/// That is uncommitted work published with the unit it sits in, which no +/// operation in this transaction wrote, so it is not attributed to the +/// committing session. `held` is the working copy before this commit, which +/// the daemon serves, and an entity is matched by identity or, where the +/// change derived its own, by its kind and name in the same unit. A position +/// is not content ([`kin_core::entity_content_agrees`]), so an entity that +/// only moved because the operation's edit sat above it is matched too. +fn held_before_in_written_units( + held: &kin_db::InMemoryGraph, + change: &kin_model::SemanticChange, + written: &BTreeSet, +) -> BTreeSet { + let written = written + .iter() + .filter_map(|path| path.as_utf8()) + .collect::>(); + let mut carried = BTreeSet::new(); + for delta in &change.entity_deltas { + let recorded = delta.new_state().or(delta.old_state()); + let Some(path) = recorded + .and_then(|entity| entity.file_origin.as_ref()) + .filter(|origin| written.contains(origin.0.as_str())) + else { + continue; + }; + let target = delta.target_id(); + let held_entity = held.get_entity(&target).ok().flatten().or_else(|| { + let recorded = recorded?; + let mut named = held + .query_entities(&kin_model::EntityFilter { + file_path: Some(path.clone()), + ..Default::default() + }) + .ok()? + .into_iter() + .filter(|entity| entity.kind == recorded.kind && entity.name == recorded.name); + let only = named.next()?; + named.next().is_none().then_some(only) + }); + let as_held = match (delta.new_state(), held_entity.as_ref()) { + (Some(new), Some(held)) => { + kin_core::workspace_semantics::entity_content_agrees(new, held) + } + (None, None) => true, + _ => false, + }; + if as_held { + carried.insert(target); + } + } + carried +} + +/// Refuse a planned change that records anything inside a source unit it +/// leaves uncommitted. +/// +/// [`crate::entity_keep::with_units_from`] builds the change so that it does +/// not; this checks the change itself before anything is published, because +/// publishing somebody's uncommitted work under this transaction's name is the +/// outcome this path exists to prevent. +fn refuse_left_units_in_change( + change: &kin_model::SemanticChange, + units: &BTreeSet, +) -> Result<(), String> { + let unit_paths = units + .iter() + .filter_map(|path| path.as_utf8()) + .collect::>(); + let tree = change.tree_deltas.iter().any(|delta| { + [delta.old_state(), delta.new_state()] + .into_iter() + .flatten() + .any(|located| units.contains(&located.path)) + }); + let entity = change + .entity_deltas + .iter() + .flat_map(|delta| [delta.old_state(), delta.new_state()]) + .flatten() + .filter_map(|entity| entity.file_origin.as_ref()) + .any(|origin| unit_paths.contains(origin.0.as_str())); + if tree || entity { + tracing::warn!( + change = %change.id, + units = units.len(), + "a planned MCP change recorded uncommitted work its operations did not write" + ); + return Err( + "this change would also record uncommitted work no operation in it wrote, so \ + nothing was published" + .to_string(), + ); + } + Ok(()) +} + /// How many carried paths one commit message names before it stops listing. /// /// A sample rather than the whole set, because a workspace can hold hundreds of @@ -2172,11 +2500,14 @@ const CARRIED_SAMPLE: usize = 10; /// Trimmed, and an all-whitespace message is read as none, so a caller cannot /// publish a blank subject line by accident. fn requested_commit_message(arguments: &HashMap) -> Option { - let message = arguments - .get("message") - .and_then(serde_json::Value::as_str)? - .trim(); - (!message.is_empty()).then(|| message.to_string()) + // `transaction commit` names it `message` and `mutate` names it + // `summary`; the first one with words in it is the subject. + ["message", "summary"] + .iter() + .filter_map(|key| arguments.get(*key).and_then(serde_json::Value::as_str)) + .map(str::trim) + .find(|message| !message.is_empty()) + .map(str::to_string) } /// The message one MCP commit publishes, stating what it folded in. @@ -2186,21 +2517,23 @@ fn requested_commit_message(arguments: &HashMap) -> O /// declaration on a commit that folded nothing would teach every reader to skim /// past the ones that did. /// -/// When something was carried, the first line says so on its own, because a -/// subject-only view of history is where a reader is most likely to meet this -/// change and least able to ask a follow-up question. The body then says what -/// the fold does and does not do: the bytes move and the semantics move with -/// them, because a change that published one without the other would describe -/// two different sources, and the entities inside those files keep the -/// authorship they already had rather than silently becoming this agent's work, -/// because no operation here wrote them. +/// When something was carried, the transaction line declares the fold and the +/// body says what the fold does and does not do: the bytes move and the +/// semantics move with them, because a change that published one without the +/// other would describe two different sources, and the entities inside those +/// files keep the authorship they already had rather than silently becoming +/// this agent's work, because no operation here wrote them. Uncommitted work +/// in a unit no operation writes is never carried; it stays in the working +/// copy (see [`pending_units`]). What can be carried is +/// uncommitted content of a unit an operation does write, such as the place a +/// moved unit left. /// /// A caller that named its own message gets that message as the subject, with /// the transaction line kept underneath rather than dropped: the id is how an /// operator ties a change back to the call that made it, and an agent's subject -/// is what a human reads first. A caller that named none sees exactly what it -/// saw before this argument existed, which is why the whole shape hangs off -/// `Option` instead of a default string. +/// is what a human reads first. A caller that named none sees the transaction +/// line as the subject, which is why the whole shape hangs off `Option` instead +/// of a default string. fn commit_message( transaction_id: &str, requested: Option<&str>, @@ -2226,11 +2559,6 @@ fn commit_message( } sample }; - // The subject still declares the fold even when the caller named a message, - // because the fold is the thing a reader of a subject-only history most - // needs to be warned about and least able to ask a follow-up question - // about. The caller's own words open the body instead, where they are the - // first thing read once the subject has been. let mut folds = Vec::new(); let mut paragraphs = Vec::new(); if !carried.is_empty() { @@ -2241,11 +2569,11 @@ fn commit_message( )); paragraphs.push(format!( "The workspace already held admitted working-tree content its base change did not \ - carry, so this change publishes that content beside the staged operations rather \ - than reverting it, and re-derives its semantics from the exact bytes published \ - here, because a change that moved one without the other would describe two \ - different sources. No operation in this transaction authored that content, so the \ - entities inside it keep the authorship they already had.\n\ + carry, as part of a source unit the staged operations write, so this change \ + publishes that content with them, and re-derives its semantics from the exact \ + bytes published here, because a change that moved one without the other would \ + describe two different sources. No operation in this transaction authored that \ + content, so the entities inside it keep the authorship they already had.\n\ Carried: {}", sample_of(carried) )); @@ -2268,12 +2596,11 @@ fn commit_message( sample_of(rederived) )); } - let subject = format!("MCP transaction {transaction_id} ({})", folds.join("; ")); - let preamble = match requested { - Some(message) => format!("{message}\n\n"), - None => String::new(), - }; - format!("{subject}\n\n{preamble}{}", paragraphs.join("\n\n")) + let fold_line = format!("MCP transaction {transaction_id} ({})", folds.join("; ")); + match requested { + Some(message) => format!("{message}\n\n{fold_line}\n\n{}", paragraphs.join("\n\n")), + None => format!("{fold_line}\n\n{}", paragraphs.join("\n\n")), + } } /// Land what this daemon's startup repair re-derived on the values the live @@ -3318,7 +3645,9 @@ enum CarriedDerivation { /// `WorkspaceSemanticDelta`. [`plan_exact_transaction`] reparses only the files /// its staged operations name, so a file the workspace admitted and no /// operation authored arrives at publication as new bytes standing over old -/// entity spans, and the change seals both. +/// entity spans, and the change seals both. Such a file reaches a plan only as +/// part of a unit an operation writes, such as the place a moved unit left: a +/// unit no operation writes is left uncommitted and never carried. /// /// Declaring the carry does not settle that. The declaration says who authored /// the file, and this is about whether one change's tree and entities agree @@ -3559,10 +3888,13 @@ fn refuse_carry_standing_over_unparsed_content( )) } +/// Apply the transaction's relationship operations to `prospective`, and name +/// the relationships they added. fn apply_relation_operations( prospective: &kin_db::InMemoryGraph, operations: Vec<(String, kin_mcp::McpMutationPayload)>, -) -> Result<(), String> { +) -> Result, String> { + let mut added = BTreeSet::new(); for (verb, payload) in operations { let kin_mcp::McpMutationPayload::Relation { from, to, kind } = payload else { return Err("internal exact relation planner received a non-relation payload".into()); @@ -3673,11 +4005,20 @@ fn apply_relation_operations( )) } }; + added.extend( + delta + .relation_deltas + .iter() + .filter_map(|delta| match delta { + RelationDelta::Added { new } => Some(new.id), + _ => None, + }), + ); prospective .apply_transaction_delta(&delta) .map_err(|error| format!("apply prospective relation operation: {error}"))?; } - Ok(()) + Ok(added) } /// Answer a commit whose transaction record is gone by asking repository @@ -3832,13 +4173,18 @@ fn finalize_committed_transaction( coordination: Option<&kin_mcp::CoordinationWritePreflight>, application: CommitApplication, ) -> Result { - let (planned_layouts, planned_carry, rederived_files) = match planned { + // A commit recovered by operation id has no plan, so it cannot tell + // uncommitted work published with a written unit from the operations' own + // and attributes the whole unit, as every commit did before that line was + // drawn. + let (planned_layouts, planned_carry, rederived_files, held_before) = match planned { Some(planned) => ( planned.layouts, Some(planned.carried_pending_files), planned.rederived_files, + planned.held_before, ), - None => (Vec::new(), None, Vec::new()), + None => (Vec::new(), None, Vec::new(), BTreeSet::new()), }; let authority_context = authority_context(state)?; // Through the held resolver: the commit moved the record, so this is the @@ -3919,8 +4265,55 @@ fn finalize_committed_transaction( &committed, &carried_pending_files, &rederived_files, + &held_before, ) })?; + // Which of the change's entities this transaction's own operations wrote, + // for a review that names the agent on each entity it wrote and on none + // it did not. The provenance above attributes every entity outside a + // carried file, which includes an edit beside the written one and spans + // that only moved. + timed_finalize_step("record_agent_authorship", || { + crate::agent_authorship::record_commit( + &state.agent_authorship, + &actor.session_id, + &actor.vendor, + &actor.client, + &transaction.staged_operations, + &committed.change, + &authority.graph, + ) + }); + // A change that left uncommitted work in the working copy seals only what + // it recorded, and the attribution of everything it left moves onto the new + // base, still uncommitted and still its author's. Read from the receipt, so + // a commit recovered after an interruption seals the same way. + let kept_working_copy = committed + .receipt + .operation + .workspace_mutation + .as_ref() + .is_some_and(|mutation| mutation.new_base_tree_hash != Some(mutation.new_tree_hash)); + if kept_working_copy { + let recorded = committed + .change + .entity_deltas + .iter() + .map(EntityDelta::target_id) + .collect::>(); + crate::client_edits::seal_kept_commit( + state, + &committed.change, + committed.resolved_publication_base, + &recorded, + ); + } else { + crate::client_edits::seal_commit( + state, + &committed.change, + committed.resolved_publication_base, + ); + } // The second commit path, recorded for the same reason the first one is. // An agent that only ever writes through MCP would otherwise leave the // store's census frozen at whatever the last CLI commit or sweep left, and @@ -5158,35 +5551,6 @@ pub(crate) mod tests { Ok(()) } - /// Leave one brand new working file admitted the way the watcher leaves it. - /// - /// The workspace tree gains a path its base change never carried and no - /// semantic change is published for it, so the change that follows carries - /// this path as a `TreeDelta::Added` with no entities behind it. - fn admit_pending_working_tree_file(state: &Arc, file: &str, content: &[u8]) { - let path = RepoPath::from_utf8(file).unwrap(); - let target = state.layout.working_dir().join(file); - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(&target, content).unwrap(); - let digest = state.blobs.write(content).unwrap(); - state - .graph - .apply_transaction_delta(&TransactionDelta { - tree_deltas: vec![TreeDelta::Added { - artifact_id: kin_model::ArtifactId::new(), - new: LocatedEntry::new( - path, - TreeEntry::blob(Hash256::from_bytes(digest.0), false), - ), - }], - ..TransactionDelta::default() - }) - .unwrap(); - publish_pending_workspace_tree(state); - } - /// Leave one working file's removal admitted the way the watcher leaves it. /// /// The entities go with the artifact in the LIVE graph, because kin-db @@ -5228,6 +5592,108 @@ pub(crate) mod tests { try_publish_pending_workspace_tree(state) } + /// Leave one working-file edit admitted the way the watcher leaves it: the + /// working copy, the daemon's graph and workspace authority all hold the + /// new bytes and the entities they derive to, and no change records them. + fn admit_watched_edit(state: &Arc, file: &str, content: &[u8]) { + let written = state.layout.working_dir().join(file); + if let Some(parent) = written.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&written, content).unwrap(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(crate::loop_runner::sync_filesystem_with_graph_under_coordination(state)) + .unwrap(); + } + + /// What the change the workspace stands on holds at `file`. + fn head_source(state: &Arc, file: &str) -> Option> { + let context = authority_context(state).unwrap(); + let authority = context.open().unwrap(); + let lease = authority.read_authority(); + let workspace = lease + .metadata() + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == context.workspace_id()) + .unwrap() + .clone(); + let head = lease + .resolve_target_change_id(workspace.base_target.as_ref().unwrap()) + .unwrap(); + let hash = crate::commit_deltas::resolve_authority_baseline(lease.snapshot(), &head) + .unwrap() + .tree + .artifact_at_path(&test_path(file)) + .and_then(|artifact| artifact.entry.blob_identity()); + drop(lease); + hash.map(|hash| authority.load_source_blob(hash).unwrap().unwrap()) + } + + /// The entities the working copy holds uncommitted, by name, as `kin diff` + /// derives them. + fn uncommitted_entities(state: &Arc) -> BTreeSet { + let binding = + kin_core::LocalRepositoryAuthorityBinding::from_layout(&state.layout).unwrap(); + kin_cli::commands::diff::inspect(&binding, None, None, Some(state.graph.as_ref())) + .unwrap() + .entity_deltas + .iter() + .filter_map(|delta| match delta { + EntityDelta::Added { new } => Some(new), + EntityDelta::Removed { old } => Some(old), + EntityDelta::Modified { old, new } => { + kin_review::is_semantic_modification(old, new).then_some(new) + } + }) + .filter(|entity| !kin_model::is_file_module_surface(entity)) + .map(|entity| entity.name.clone()) + .collect() + } + + fn names(names: [&str; N]) -> BTreeSet { + names.into_iter().map(String::from).collect() + } + + /// The change a commit reply names, read through an entity it changed. + fn replied_change( + state: &Arc, + reply: &serde_json::Value, + entity: &Entity, + ) -> kin_model::SemanticChange { + let change_id = reply["change_id"].as_str().unwrap(); + state + .graph + .get_entity_history(&entity.id) + .unwrap() + .into_iter() + .find(|change| change.id.to_string() == change_id) + .expect("the published change is reachable from the entity the operation wrote") + } + + /// Every path one change's tree and entities name. + fn change_paths(change: &kin_model::SemanticChange) -> BTreeSet { + change + .tree_deltas + .iter() + .flat_map(|delta| [delta.old_state(), delta.new_state()]) + .flatten() + .map(|located| located.path.to_string()) + .chain( + change + .entity_deltas + .iter() + .flat_map(|delta| [delta.old_state(), delta.new_state()]) + .flatten() + .filter_map(|entity| entity.file_origin.as_ref()) + .map(|origin| origin.0.clone()), + ) + .collect() + } + /// Whether workspace authority still holds a tree its base change does not. fn workspace_is_dirty(state: &Arc) -> bool { let context = authority_context(state).unwrap(); @@ -11467,18 +11933,20 @@ pub(crate) mod tests { ); } - /// A store somebody has been working in commits in one pass, and says what - /// it took with it. + /// A store somebody has been working in commits the agent's work and only + /// the agent's work. /// - /// The workspace here holds an admitted edit to a file no staged operation - /// names, which is the state the commit path used to refuse with "requires a - /// clean exact workspace". The pending content is inside the prospective - /// graph either way, so the only honest options were publishing it or - /// reverting the working file. It is published, and both the reply and the - /// change record name it as carried rather than presenting it as the agent's - /// work. + /// The workspace here holds an admitted edit to a unit no staged operation + /// names, which is the state every store somebody works in sits in. That + /// edit is somebody else's, and publishing it under the agent's change put + /// a person's uncommitted work in the agent's commit and emptied their + /// view of what they had left to review. The change records the base + /// change and the unit the operation wrote. The other unit's edit stays in + /// the working copy, uncommitted against the new change, with the entities + /// it had, and the reply and the record declare no fold because there is + /// none. #[test] - fn a_commit_carrying_pending_working_tree_content_declares_it_in_the_reply_and_the_record() { + fn a_commit_leaves_pending_work_in_a_unit_it_did_not_write_uncommitted() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -11486,107 +11954,121 @@ pub(crate) mod tests { b"pub fn value() -> u8 { 1 }\n", "value", ); - install_exact_source( + let (other, installed) = install_exact_source( &state, "src/other.rs", b"pub fn other() -> u8 { 1 }\n", "other", ); - admit_pending_working_tree_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 7 }\n"); + admit_watched_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 7 }\n"); + assert_eq!( + uncommitted_entities(&state), + names(["other"]), + "the fixture's uncommitted work" + ); let sessions = test_sessions(); - let (_, arguments) = + let (transaction_id, arguments) = stage_entity_edit(&state, &sessions, &entity, "pub fn value() -> u8 { 2 }"); let result = commit_exact_transaction(&state, &sessions, &arguments, None); assert_ne!( result.is_error, Some(true), - "a workspace holding pending content must still commit: {}", + "a workspace holding uncommitted work must still commit: {}", result_text(&result) ); let reply = commit_reply(&result); + assert!( + reply.get("staged_operation_files").is_none() + && reply.get("carried_pending_files").is_none(), + "a change that carried nothing in declares no fold: {reply:#}" + ); + assert_eq!(reply["modified_files"], serde_json::json!(["src/lib.rs"])); let accounting = &reply["publication_accounting"]; - assert_eq!(accounting["status"], "exact"); - assert_eq!(accounting["source_units"]["published_total"], 2); + assert_eq!(accounting["status"], "exact", "{reply:#}"); + assert_eq!(accounting["source_units"]["published_total"], 1); assert_eq!(accounting["source_units"]["publication_only"]["count"], 1); - assert_eq!(accounting["source_units"]["carried_unchanged"]["count"], 1); - // Pending bytes may be reparsed only during publication, independently - // of whether their semantic overlay was already admitted. + assert_eq!(accounting["source_units"]["carried_unchanged"]["count"], 0); + + let change = replied_change(&state, &reply, &entity); assert_eq!( - accounting["entities"]["published_total"], - reply["entity_deltas"] + change.message, + format!("MCP transaction {transaction_id}"), + "the record declares no fold, because it carried none" ); assert_eq!( - accounting["relationships"]["published_total"], - reply["relation_deltas"] + change_paths(&change), + names(["src/lib.rs"]), + "the agent's change holds only what its operation wrote" + ); + assert!(change + .entity_deltas + .iter() + .any(|delta| delta.target_id() == entity.id)); + assert!( + !change + .entity_deltas + .iter() + .any(|delta| delta.target_id() == other.id), + "the other unit's entity is not in the agent's change" ); + assert_eq!( - reply["staged_operation_files"], - serde_json::json!(["src/lib.rs"]), - "the reply must separate the files the operations wrote: {reply:#}" + head_source(&state, "src/lib.rs").unwrap(), + b"pub fn value() -> u8 { 2 }\n" + ); + assert_eq!( + head_source(&state, "src/other.rs").unwrap(), + b"pub fn other() -> u8 { 1 }\n", + "the change keeps the other unit as its base change holds it" ); assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/other.rs"]), - "the reply must name the files it carried in: {reply:#}" + std::fs::read(state.layout.working_dir().join("src/lib.rs")).unwrap(), + b"pub fn value() -> u8 { 2 }\n", + "the staged edit reaches the working file" ); assert_eq!( - reply["modified_files"], - serde_json::json!(["src/lib.rs", "src/other.rs"]), - "the split covers modified_files rather than replacing it: {reply:#}" + std::fs::read(state.layout.working_dir().join("src/other.rs")).unwrap(), + b"pub fn other() -> u8 { 7 }\n", + "the other unit keeps the bytes its author left in it" ); - - let change_id = reply["change_id"].as_str().unwrap().to_string(); - let change = state - .graph - .get_entity_history(&entity.id) - .unwrap() - .into_iter() - .find(|change| change.id.to_string() == change_id) - .expect("the published change is reachable from the entity the operation wrote"); assert!( - change - .message - .contains("also admitted 1 pending working-tree file"), - "the change record must state the fold and its count: {}", - change.message - ); - assert!( - change.message.contains("src/other.rs"), - "the change record must sample what it carried: {}", - change.message + workspace_is_dirty(&state), + "the other unit's edit is still uncommitted against the new change" ); - assert_eq!( - std::fs::read(state.layout.working_dir().join("src/lib.rs")).unwrap(), - b"pub fn value() -> u8 { 2 }\n", - "the staged edit reaches the working file" - ); - assert_eq!( - std::fs::read(state.layout.working_dir().join("src/other.rs")).unwrap(), - b"pub fn other() -> u8 { 7 }\n", - "the carried file keeps the bytes the human left in it" + uncommitted_entities(&state), + names(["other"]), + "the uncommitted work is exactly what it was, and the agent's is not in it" ); + let history = state.graph.get_entity_history(&other.id).unwrap(); assert!( - !workspace_is_dirty(&state), - "the commit must leave the workspace level with its base" + !history.iter().any(|recorded| recorded.id == change.id), + "the agent's change is not in the history of an entity it did not write" + ); + assert_eq!(history.first().map(|recorded| recorded.id), Some(installed)); + assert_eq!(semantic_disagreement(&state, "src/lib.rs"), None); + assert_eq!( + semantic_disagreement(&state, "src/other.rs"), + None, + "the working copy's entities still describe the bytes it holds" ); } - /// The closing arm: a file nothing did but write is carried by the next - /// commit and named as carried. + /// The closing arm: a unit nothing did but write stays uncommitted beside + /// the next agent commit. /// /// The pending content here comes from the real watcher path rather than /// from a delta assembled to look like one, because the composition is the - /// claim. Writing the file is the whole of the user's action: ambient + /// claim. Writing the unit is the whole of the user's action: ambient /// admission puts it in the workspace tree without moving the base, and the - /// commit that follows publishes it while stating plainly that its author - /// did not write it. Either half alone is defensible and useless; a file - /// that becomes queryable and then gets published as somebody's work is - /// worse than one that never appeared. + /// agent commit that follows records only what its operation wrote. A unit + /// that becomes queryable and then gets published as somebody else's work + /// is worse than one that never appeared, so it stays queryable and stays + /// its author's. #[test] - fn a_commit_declares_a_watcher_admitted_file_as_carried() { + fn a_watcher_admitted_unit_stays_uncommitted_beside_an_agent_commit() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -11615,38 +12097,40 @@ pub(crate) mod tests { assert_ne!( result.is_error, Some(true), - "a workspace carrying a watcher-admitted file must still commit: {}", + "a workspace holding a watcher-admitted unit must still commit: {}", result_text(&result) ); let reply = commit_reply(&result); - assert_eq!( - reply["staged_operation_files"], - serde_json::json!(["src/lib.rs"]), - "the reply must keep the operations' own file separate: {reply:#}" - ); - assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/brand_new.rs"]), - "a file the watcher admitted is carried, not authored: {reply:#}" + assert!( + reply.get("carried_pending_files").is_none(), + "a unit the watcher admitted is neither carried nor authored: {reply:#}" ); - - let change_id = reply["change_id"].as_str().unwrap().to_string(); - let change = state - .graph - .get_entity_history(&entity.id) - .unwrap() - .into_iter() - .find(|change| change.id.to_string() == change_id) - .expect("the published change is reachable from the entity the operation wrote"); + assert_eq!(reply["modified_files"], serde_json::json!(["src/lib.rs"])); + let change = replied_change(&state, &reply, &entity); + assert_eq!(change_paths(&change), names(["src/lib.rs"])); assert!( - change.message.contains("src/brand_new.rs"), - "the permanent record must name what it carried: {}", + !change.message.contains("brand_new"), + "the permanent record names nothing it did not record: {}", change.message ); + assert_eq!( + head_source(&state, "src/brand_new.rs"), + None, + "the change the agent made does not hold the unit somebody else wrote" + ); + assert!(workspace_is_dirty(&state)); assert!( - !workspace_is_dirty(&state), - "the commit must leave the workspace level with its base" + state + .graph + .resolved_tree() + .artifact_at_path(&test_path("src/brand_new.rs")) + .is_some(), + "the unit stays in the working copy, uncommitted" + ); + assert_eq!( + std::fs::read(state.layout.working_dir().join("src/brand_new.rs")).unwrap(), + b"pub fn brand_new() -> u8 { 1 }\n" ); } @@ -11740,6 +12224,18 @@ pub(crate) mod tests { ); } + /// Uncommitted work inside the very unit an operation writes is published + /// with the operation, and declared. + /// + /// A unit's bytes are one, and the operation was planned against them as + /// the working copy holds them: its source base names that version, so the + /// agent read and patched the unit with the sibling's edit in it. Leaving + /// the sibling out would publish a version of the unit nobody read, and + /// refusing would leave an agent unable to write any unit somebody else had + /// touched. So the unit is published whole, the accounting names the unit + /// as one that held uncommitted work before this publication, and a unit + /// the operation did not write is still left alone (see + /// `a_commit_leaves_pending_work_in_a_unit_it_did_not_write_uncommitted`). #[test] fn publication_accounting_reports_pending_overlap_within_one_source_unit() { let (_dir, state) = test_state(); @@ -11749,11 +12245,12 @@ pub(crate) mod tests { b"pub fn value() -> u8 { 1 }\npub fn sibling() -> u8 { 1 }\n", "value", ); - admit_pending_working_tree_edit( + admit_watched_edit( &state, "src/lib.rs", b"pub fn value() -> u8 { 1 }\npub fn sibling() -> u8 { 7 }\n", ); + let sibling = live_entity(&state, "src/lib.rs", "sibling"); let context = authority_context(&state).unwrap(); let authority = context.open().unwrap(); let lease = authority.read_authority(); @@ -11824,6 +12321,121 @@ pub(crate) mod tests { std::fs::read(state.layout.working_dir().join("src/lib.rs")).unwrap(), b"pub fn value() -> u8 { 2 }\npub fn sibling() -> u8 { 7 }\n" ); + assert_eq!( + head_source(&state, "src/lib.rs").unwrap(), + b"pub fn value() -> u8 { 2 }\npub fn sibling() -> u8 { 7 }\n", + "the unit the operation wrote is published as one set of bytes" + ); + assert!( + !workspace_is_dirty(&state), + "nothing is left uncommitted when the only uncommitted work was in the unit written" + ); + let attributed = commit_attributed(&state, &reply); + assert!( + attributed.contains(&entity.id), + "the entity the operation wrote is attributed to the committing session" + ); + assert!( + !attributed.contains(&sibling.id), + "the uncommitted sibling published as the working copy held it is not: {attributed:?}" + ); + } + + /// The live entity named `name` declared at `file`. + fn live_entity(state: &Arc, file: &str, name: &str) -> Entity { + state + .graph + .query_entities(&EntityFilter { + file_path: Some(FilePathId::new(file)), + ..EntityFilter::default() + }) + .unwrap() + .into_iter() + .find(|entity| entity.name == name && !kin_model::is_file_module_surface(entity)) + .unwrap_or_else(|| panic!("{file} declares no {name}")) + } + + /// The entities one commit's attribution events name. + fn commit_attributed( + state: &Arc, + reply: &serde_json::Value, + ) -> BTreeSet { + let change_id = reply["change_id"].as_str().unwrap(); + state + .graph + .query_audit_events(None, 64) + .unwrap() + .into_iter() + .filter(|event| { + event.action == "kin_transaction_commit" + && event + .details + .as_deref() + .is_some_and(|details| details.contains(change_id)) + }) + .filter_map(|event| match event.target_scope { + Some(WorkScope::Entity(id)) => Some(id), + _ => None, + }) + .collect() + } + + /// Both rules in one commit: the unit an operation writes is published + /// whole, its uncommitted sibling with it, and a unit it does not write + /// stays uncommitted. + #[test] + fn a_commit_publishes_its_own_unit_whole_and_leaves_other_units_uncommitted() { + let (_dir, state) = test_state(); + let (entity, _) = install_exact_source( + &state, + "src/lib.rs", + b"pub fn value() -> u8 { 1 }\npub fn sibling() -> u8 { 1 }\n", + "value", + ); + install_exact_source( + &state, + "src/other.rs", + b"pub fn other() -> u8 { 1 }\n", + "other", + ); + admit_watched_edit( + &state, + "src/lib.rs", + b"pub fn value() -> u8 { 1 }\npub fn sibling() -> u8 { 7 }\n", + ); + admit_watched_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 7 }\n"); + assert_eq!(uncommitted_entities(&state), names(["other", "sibling"])); + let sibling = live_entity(&state, "src/lib.rs", "sibling"); + + let sessions = test_sessions(); + let (_, arguments) = + stage_entity_edit(&state, &sessions, &entity, "pub fn value() -> u8 { 2 }"); + let result = commit_exact_transaction(&state, &sessions, &arguments, None); + assert_ne!(result.is_error, Some(true), "{}", result_text(&result)); + let reply = commit_reply(&result); + assert_eq!(reply["modified_files"], serde_json::json!(["src/lib.rs"])); + assert_eq!( + reply["publication_accounting"]["source_units"]["pending_and_publication"]["count"], 1, + "the written unit is declared as one that held uncommitted work: {reply:#}" + ); + assert_eq!( + head_source(&state, "src/lib.rs").unwrap(), + b"pub fn value() -> u8 { 2 }\npub fn sibling() -> u8 { 7 }\n" + ); + assert_eq!( + head_source(&state, "src/other.rs").unwrap(), + b"pub fn other() -> u8 { 1 }\n" + ); + assert!(workspace_is_dirty(&state)); + assert_eq!(uncommitted_entities(&state), names(["other"])); + assert_eq!(semantic_disagreement(&state, "src/lib.rs"), None); + assert_eq!(semantic_disagreement(&state, "src/other.rs"), None); + let attributed = commit_attributed(&state, &reply); + assert!(attributed.contains(&entity.id)); + assert!( + !attributed.contains(&sibling.id), + "the uncommitted sibling published with the unit is not attributed: {attributed:?}" + ); } #[test] @@ -11916,6 +12528,54 @@ pub(crate) mod tests { ); } + /// The caller's subject stands as it would on a clean workspace, with + /// uncommitted work elsewhere left out of the change and out of its record. + #[test] + fn a_commit_beside_uncommitted_work_keeps_the_caller_message_as_subject() { + let (_dir, state) = test_state(); + let (entity, _) = install_exact_source( + &state, + "src/lib.rs", + b"pub fn value() -> u8 { 1 }\n", + "value", + ); + install_exact_source( + &state, + "src/other.rs", + b"pub fn other() -> u8 { 1 }\n", + "other", + ); + admit_pending_working_tree_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 7 }\n"); + + let sessions = test_sessions(); + let (transaction_id, mut arguments) = + stage_entity_edit(&state, &sessions, &entity, "pub fn value() -> u8 { 2 }"); + arguments.insert( + "summary".to_string(), + serde_json::json!("Return two from value"), + ); + let result = commit_exact_transaction(&state, &sessions, &arguments, None); + assert_ne!(result.is_error, Some(true), "{}", result_text(&result)); + + let change_id = commit_reply(&result)["change_id"] + .as_str() + .unwrap() + .to_string(); + let change = state + .graph + .get_entity_history(&entity.id) + .unwrap() + .into_iter() + .find(|change| change.id.to_string() == change_id) + .expect("the published change is reachable from the entity it wrote"); + assert_eq!( + change.message, + format!("Return two from value\n\nMCP transaction {transaction_id}"), + "the record is the caller's subject and the transaction line, and names no work it \ + did not record" + ); + } + /// A blank message is no message, not a blank subject line. /// /// A client that always sends the field and sometimes has nothing to put in @@ -11957,17 +12617,19 @@ pub(crate) mod tests { ); } - /// The retry that a slow commit guarantees is told about the fold too. + /// The retry that a slow commit guarantees is answered as the first + /// attempt was, uncommitted work beside it included. /// /// This is the reply a real caller reads. The MCP client budgets 60 seconds /// per attempt, a commit on a store of any size takes longer, and the /// duplicate request that follows is answered from the repository receipt - /// after the first attempt has evicted its own transaction record. A split - /// that only the planning process could produce would therefore never reach - /// anyone, so the fold is recorded in the commit's own attribution and read - /// back here. + /// after the first attempt has evicted its own transaction record. That + /// answer has only the receipt and the commit's own attribution to go on, + /// so it has to say what the first one said from those alone: the same + /// change, the same units, no fold, and the same accounting, including when + /// the attribution window has moved past the commit. #[test] - fn a_retry_of_an_evicted_commit_still_declares_what_that_commit_carried() { + fn a_retry_of_an_evicted_commit_answers_as_the_commit_did() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -12005,22 +12667,24 @@ pub(crate) mod tests { let retry_reply = commit_reply(&retry); assert_eq!(retry_reply["already_applied"], true); assert_eq!(retry_reply["change_id"], first_reply["change_id"]); + assert_eq!(retry_reply["modified_files"], first_reply["modified_files"]); assert_eq!( - retry_reply["carried_pending_files"], first_reply["carried_pending_files"], - "the retry must declare the same fold the first answer did: {retry_reply:#}" - ); - assert_eq!( - retry_reply["staged_operation_files"], - first_reply["staged_operation_files"] + retry_reply["modified_files"], + serde_json::json!(["src/lib.rs"]) ); - assert_eq!( - retry_reply["carried_pending_files"], - serde_json::json!(["src/other.rs"]) + assert!( + retry_reply.get("carried_pending_files").is_none() + && first_reply.get("carried_pending_files").is_none(), + "neither answer declares a fold, because the commit made none: {retry_reply:#}" ); assert_eq!( retry_reply["publication_accounting"], first_reply["publication_accounting"] ); + assert!( + workspace_is_dirty(&state), + "the retry moved nothing, and the uncommitted work beside the commit is still there" + ); let mut unrelated = state .graph .query_audit_events(None, 1) @@ -12045,34 +12709,26 @@ pub(crate) mod tests { first_reply["publication_accounting"] ); assert_eq!( - old_reply["publication_accounting"]["source_units"]["carried_unchanged"]["count"], + old_reply["publication_accounting"]["source_units"]["publication_only"]["count"], 1 ); + assert_eq!( + old_reply["publication_accounting"]["source_units"]["carried_unchanged"]["count"], + 0 + ); } - /// Carrying a file in never rewrites who authored what is inside it. + /// A unit left uncommitted keeps the authorship its entities already had. /// - /// A carried file reaches the change as a tree delta AND the semantics those - /// bytes derive to, because a change that published one without the other - /// would seal a tree and an entity set describing different source. So its - /// entities do gain a revision, and that revision is a statement about - /// content, not about authorship: the entity keeps its id and the change it - /// was created in, and no attribution event names it, which is what keeps a - /// provenance reader from being told the agent wrote a human's uncommitted - /// work. The fold is also reachable from provenance one level up, because - /// the change every attributed entity names carries the declaration in its - /// message. - /// - /// The assertion that the carried entity's history did NOT contain this - /// change was here until the incoherence it described was fixed. It could - /// not survive the fix and be true at the same time: `get_entity_history` - /// selects changes by scanning entity deltas, so the only change that - /// satisfies it is one that publishes bytes for a file and no semantics for - /// them. What that assertion was protecting is below, in the origin change - /// and the attribution, and it is asserted there rather than through the - /// absence of a revision. + /// The other unit's edit here is attributed, the way a client that reports + /// its own edits attributes one, to a client session that is not the agent + /// committing. The agent's commit does not record that edit, so nothing may + /// seal its attribution into the agent's change or drop it: it stays + /// uncommitted, now on the new base, under the session that made it. No + /// attribution event of the commit names the entity, the agent's change is + /// not in its history, and it keeps the change it was created in. #[test] - fn a_carried_file_keeps_the_authorship_its_entities_already_had() { + fn a_unit_left_uncommitted_keeps_the_authorship_its_entities_already_had() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -12080,13 +12736,23 @@ pub(crate) mod tests { b"pub fn value() -> u8 { 1 }\n", "value", ); - let (carried_entity, installed_change) = install_exact_source( + let (pending, installed_change) = install_exact_source( &state, "src/other.rs", b"pub fn other() -> u8 { 1 }\n", "other", ); - admit_pending_working_tree_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 7 }\n"); + admit_watched_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 7 }\n"); + let authored = kin_cli::commands::review_guide::ClientEdit { + base: Some(installed_change.to_string()), + change_id: None, + client: "claude-code".to_string(), + session: "client-session-1".to_string(), + kin_session: None, + at: Timestamp::now().to_string(), + entities: vec![pending.id], + }; + state.agent_authorship.record_edit(authored.clone()); let sessions = test_sessions(); let (_, arguments) = @@ -12096,27 +12762,36 @@ pub(crate) mod tests { let reply = commit_reply(&result); let change_id = reply["change_id"].as_str().unwrap().to_string(); - let carried_history = state.graph.get_entity_history(&carried_entity.id).unwrap(); + let edits = state.agent_authorship.edits(); + assert_eq!( + edits, + vec![kin_cli::commands::review_guide::ClientEdit { + base: Some(change_id.clone()), + ..authored + }], + "the edit is still uncommitted, under the session that made it, on the new base" + ); + + let history = state.graph.get_entity_history(&pending.id).unwrap(); assert!( - carried_history + !history .iter() .any(|change| change.id.to_string() == change_id), - "the change that republished the carried file's bytes must carry its semantics too, \ - or it seals a tree and an entity set describing different source" + "the agent's change is not a revision of an entity it did not record" ); assert_eq!( - carried_history.first().map(|change| change.id), + history.first().map(|change| change.id), Some(installed_change), - "the carried file's entity keeps the change it was created in" + "the entity keeps the change it was created in" ); assert_eq!( state .graph - .get_entity(&carried_entity.id) + .get_entity(&pending.id) .unwrap() .map(|entity| (entity.name, entity.kind)), - Some((carried_entity.name.clone(), carried_entity.kind)), - "the carried file's entity keeps the identity it already had" + Some((pending.name.clone(), pending.kind)), + "the entity keeps the identity it already had" ); let attributed = state @@ -12141,24 +12816,16 @@ pub(crate) mod tests { "the entity the operation wrote is attributed to the committing session" ); assert!( - !attributed.contains(&carried_entity.id), - "no attribution event may name an entity inside a carried file" + !attributed.contains(&pending.id), + "no attribution event may name an entity the change left uncommitted" ); - - // Reachability, from provenance rather than from the plan: the events - // above name a change id, and that change states what it folded in. - let declared = state - .graph - .get_entity_history(&entity.id) - .unwrap() - .into_iter() - .find(|change| change.id.to_string() == change_id) - .expect("the attributed change is loadable by the id its audit event names"); + let declared = replied_change(&state, &reply, &entity); assert!( - declared.message.contains("src/other.rs"), - "the change an audit event names must declare the fold: {}", + !declared.message.contains("src/other.rs"), + "the change names nothing it did not record: {}", declared.message ); + assert_eq!(uncommitted_entities(&state), names(["other"])); } /// How the store's own entities disagree with the exact bytes it publishes @@ -12173,10 +12840,10 @@ pub(crate) mod tests { /// bytes over older semantic spans leaves behind. /// /// Read against the live graph on purpose. `install_authority_graph` levels - /// it onto repository authority inside every commit reply and + /// it onto repository authority's workspace inside every commit reply and /// `verify_workspace_matches_authority` refuses if it did not, so after a - /// commit the live graph, authority and the change just sealed are the same - /// answer, and asking the cheapest of the three asks all of them. + /// commit the live graph and the working copy authority keeps are the same + /// answer, and for a unit the change recorded, so is the change. fn semantic_disagreement(state: &Arc, file: &str) -> Option { let file_id = FilePathId::new(file); let path = RepoPath::from_utf8(file).unwrap(); @@ -12240,26 +12907,20 @@ pub(crate) mod tests { None } - /// A commit over MCP must publish entities that describe the bytes it - /// published, for a carried file as much as for an authored one. - /// - /// The two commit surfaces plan from different graphs. The CLI route is - /// handed the daemon's live derived graph, which the reconcile loop keeps - /// current, so a pending file's entities there came from the pending bytes. - /// The MCP route is handed repository authority's own workspace graph - /// snapshot, whose tree is the workspace tree and whose entities are - /// whatever the last published semantic delta left, because - /// `publish_workspace_tree` advances the tree with an empty - /// `WorkspaceSemanticDelta`. `plan_exact_transaction` reparses only the - /// files its staged operations name, so a carried file reaches the sealed - /// change as new tree bytes over old entity spans and nothing on that path - /// re-derives them. + /// A commit beside an uncommitted edit leaves the change and the working + /// copy each describing its own bytes. /// - /// Declaring the carry does not make that acceptable. The declaration is - /// about authorship, and this is about whether canonical truth agrees with - /// itself. + /// The two commit surfaces plan from different graphs. The MCP route is + /// handed repository authority's own workspace graph snapshot, whose + /// entities for a unit with uncommitted bytes are whatever the last + /// published semantic delta left. A change that published that unit's new + /// bytes over those entities sealed a tree and an entity set describing + /// different source. The change no longer records that unit at all: it + /// keeps the bytes and entities its base change holds for it, which agree, + /// and the working copy keeps the entities the daemon derived from the + /// uncommitted bytes, which agree too. #[test] - fn a_carried_immediate_edit_commits_entities_that_describe_the_bytes_it_published() { + fn an_uncommitted_edit_beside_a_commit_stays_coherent_in_the_change_and_the_working_copy() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -12273,7 +12934,7 @@ pub(crate) mod tests { b"pub fn other() -> u8 { 1 }\n", "other", ); - admit_pending_working_tree_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 100 }\n"); + admit_watched_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 100 }\n"); let sessions = test_sessions(); let (_, arguments) = @@ -12282,35 +12943,39 @@ pub(crate) mod tests { assert_ne!(result.is_error, Some(true), "{}", result_text(&result)); let reply = commit_reply(&result); assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/other.rs"]), - "the fixture must actually exercise the carry: {reply:#}" + reply["modified_files"], + serde_json::json!(["src/lib.rs"]), + "the fixture must leave the other unit out of the change: {reply:#}" + ); + assert_eq!( + head_source(&state, "src/other.rs").unwrap(), + b"pub fn other() -> u8 { 1 }\n" ); - // The control for the check itself. The authored file is re-derived by - // the edit path, so a checker that could not tell a coherent file from + // The control for the check itself. The authored unit is re-derived by + // the edit path, so a checker that could not tell a coherent unit from // an incoherent one would report this one too. assert_eq!( semantic_disagreement(&state, "src/lib.rs"), None, - "the file the operation authored must be coherent, or the check below proves nothing" + "the unit the operation authored must be coherent, or the check below proves nothing" ); assert_eq!( semantic_disagreement(&state, "src/other.rs"), None, - "the carried file's entities must describe the bytes this commit published for it" + "the working copy's entities must describe the uncommitted bytes it holds" ); } - /// The same defect where no span moves, so only a fingerprint can catch it. + /// The same, where no span moves, so only a fingerprint can tell. /// - /// `return 1` to `return 7` leaves every byte offset in the file identical, + /// `return 1` to `return 7` leaves every byte offset in the unit identical, /// so a coherence check written as a span or a length comparison passes over /// it while the entity's body is still wrong. `behavior_hash` is the hash of /// an entity's full source text, so it is the field that moves, and this arm /// exists to keep the check honest about which one it reads. #[test] - fn a_carried_same_length_body_edit_commits_entities_that_describe_the_bytes_it_published() { + fn an_uncommitted_same_length_edit_beside_a_commit_stays_coherent_in_the_working_copy() { const BEFORE: &[u8] = b"pub fn other() -> u8 { 1 }\n"; const AFTER: &[u8] = b"pub fn other() -> u8 { 7 }\n"; assert_eq!( @@ -12326,13 +12991,13 @@ pub(crate) mod tests { b"pub fn value() -> u8 { 1 }\n", "value", ); - let (carried_entity, _) = install_exact_source(&state, "src/other.rs", BEFORE, "other"); - let span_before = carried_entity + let (pending, _) = install_exact_source(&state, "src/other.rs", BEFORE, "other"); + let span_before = pending .span .as_ref() .map(|span| (span.start_byte, span.end_byte)) .expect("an installed source entity has a span"); - admit_pending_working_tree_edit(&state, "src/other.rs", AFTER); + admit_watched_edit(&state, "src/other.rs", AFTER); let sessions = test_sessions(); let (_, arguments) = @@ -12341,30 +13006,31 @@ pub(crate) mod tests { assert_ne!(result.is_error, Some(true), "{}", result_text(&result)); let reply = commit_reply(&result); assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/other.rs"]), - "the fixture must actually exercise the carry: {reply:#}" + reply["modified_files"], + serde_json::json!(["src/lib.rs"]), + "the fixture must leave the other unit out of the change: {reply:#}" ); + assert_eq!(head_source(&state, "src/other.rs").unwrap(), BEFORE); assert_eq!( semantic_disagreement(&state, "src/lib.rs"), None, - "the file the operation authored must be coherent, or the check below proves nothing" + "the unit the operation authored must be coherent, or the check below proves nothing" ); assert_eq!( semantic_disagreement(&state, "src/other.rs"), None, - "the carried file's entities must describe the bytes this commit published for it, \ - including when the edit moved no byte offset" + "the working copy's entities must describe the uncommitted bytes it holds, including \ + when the edit moved no byte offset" ); - let carried_after = state + let after = state .graph - .get_entity(&carried_entity.id) + .get_entity(&pending.id) .unwrap() - .expect("the carried entity keeps its identity across the commit"); + .expect("the uncommitted entity keeps its identity across the commit"); assert_eq!( - carried_after + after .span .as_ref() .map(|span| (span.start_byte, span.end_byte)), @@ -12374,16 +13040,15 @@ pub(crate) mod tests { ); } - /// The same defect on a store nothing in this process derived. + /// The same on a store nothing in this process derived. /// /// Reopening drops every graph this process built and rebuilds the daemon's /// view from what the store persisted, so the commit that follows plans - /// against cold repository authority and cold source. If the incoherence - /// were an artifact of live in-memory state carried across the admission it - /// would not survive here; it does, because the snapshot the MCP route plans - /// from is the persisted one. + /// against cold repository authority and cold source. The change still + /// records only the unit its operation wrote, and the working copy still + /// answers for the uncommitted unit with entities that describe its bytes. #[test] - fn a_carried_edit_committed_after_a_reopen_publishes_entities_that_describe_its_tree() { + fn an_uncommitted_edit_beside_a_commit_after_a_reopen_stays_out_of_the_change() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -12397,7 +13062,7 @@ pub(crate) mod tests { b"pub fn other() -> u8 { 1 }\n", "other", ); - admit_pending_working_tree_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 7 }\n"); + admit_watched_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 7 }\n"); let layout = state.layout.clone(); drop(state); @@ -12410,35 +13075,39 @@ pub(crate) mod tests { assert_ne!(result.is_error, Some(true), "{}", result_text(&result)); let reply = commit_reply(&result); assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/other.rs"]), - "the fixture must actually exercise the carry: {reply:#}" + reply["modified_files"], + serde_json::json!(["src/lib.rs"]), + "the fixture must leave the other unit out of the change: {reply:#}" ); + assert_eq!( + head_source(&state, "src/other.rs").unwrap(), + b"pub fn other() -> u8 { 1 }\n" + ); + assert!(workspace_is_dirty(&state)); assert_eq!( semantic_disagreement(&state, "src/lib.rs"), None, - "the file the operation authored must be coherent, or the check below proves nothing" + "the unit the operation authored must be coherent, or the check below proves nothing" ); assert_eq!( semantic_disagreement(&state, "src/other.rs"), None, - "a cold graph and cold source must still publish entities that describe the bytes the \ - commit published" + "a cold graph and cold source must still leave the working copy describing its bytes" ); } - /// The success contract a carried commit already has, held to while the - /// semantics are repaired. + /// The success contract held where repository authority's semantics for + /// the uncommitted unit lag its bytes. /// - /// Deriving a carried file's semantics into the commit is the fix, and the - /// thing it could plausibly break is everything this asserts: the commit - /// still lands, the reply and the change record still declare the fold, the - /// carried entity keeps its identity and the change it was created in, and - /// no attribution event names it. Republishing an entity's bytes is not the - /// same as claiming its authorship, and this is where the two stay separate. + /// The pending edit here is admitted the way a tree-only admission leaves + /// it: the workspace tree holds the new bytes and nothing derived them. The + /// commit still lands, records only the unit its operation wrote, declares + /// no fold, leaves the edit uncommitted on disk and in the workspace, and + /// the uncommitted entity keeps its identity, the change it was created in, + /// and its freedom from the agent's attribution. #[test] - fn a_coherent_carried_pending_commit_still_succeeds_and_declares_the_carry() { + fn a_commit_beside_an_underived_uncommitted_edit_lands_and_leaves_that_edit_alone() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -12446,7 +13115,7 @@ pub(crate) mod tests { b"pub fn value() -> u8 { 1 }\n", "value", ); - let (carried_entity, installed_change) = install_exact_source( + let (pending, installed_change) = install_exact_source( &state, "src/other.rs", b"pub fn other() -> u8 { 1 }\n", @@ -12455,62 +13124,43 @@ pub(crate) mod tests { admit_pending_working_tree_edit(&state, "src/other.rs", b"pub fn other() -> u8 { 7 }\n"); let sessions = test_sessions(); - let (_, arguments) = + let (transaction_id, arguments) = stage_entity_edit(&state, &sessions, &entity, "pub fn value() -> u8 { 2 }"); let result = commit_exact_transaction(&state, &sessions, &arguments, None); assert_ne!( result.is_error, Some(true), - "a workspace holding pending content must still commit: {}", + "a workspace holding uncommitted work must still commit: {}", result_text(&result) ); let reply = commit_reply(&result); let change_id = reply["change_id"].as_str().unwrap().to_string(); - assert_eq!( - reply["staged_operation_files"], - serde_json::json!(["src/lib.rs"]) - ); - assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/other.rs"]) - ); - assert_eq!( - reply["modified_files"], - serde_json::json!(["src/lib.rs", "src/other.rs"]) - ); + assert!(reply.get("carried_pending_files").is_none(), "{reply:#}"); + assert_eq!(reply["modified_files"], serde_json::json!(["src/lib.rs"])); - let declared = state - .graph - .get_entity_history(&entity.id) - .unwrap() - .into_iter() - .find(|change| change.id.to_string() == change_id) - .expect("the published change is reachable from the entity the operation wrote"); - assert!( - declared - .message - .contains("also admitted 1 pending working-tree file") - && declared.message.contains("src/other.rs"), - "the change record must still state the fold and sample it: {}", - declared.message + let declared = replied_change(&state, &reply, &entity); + assert_eq!( + declared.message, + format!("MCP transaction {transaction_id}") ); + assert_eq!(change_paths(&declared), names(["src/lib.rs"])); - let carried_after = state + let after = state .graph - .get_entity(&carried_entity.id) + .get_entity(&pending.id) .unwrap() - .expect("the carried entity keeps the identity it already had"); - assert_eq!(carried_after.name, carried_entity.name); - assert_eq!(carried_after.kind, carried_entity.kind); + .expect("the uncommitted entity keeps the identity it already had"); + assert_eq!(after.name, pending.name); + assert_eq!(after.kind, pending.kind); assert_eq!( state .graph - .get_entity_history(&carried_entity.id) + .get_entity_history(&pending.id) .unwrap() .first() .map(|change| change.id), Some(installed_change), - "the carried entity keeps the change it was created in" + "the uncommitted entity keeps the change it was created in" ); let attributed = state @@ -12530,33 +13180,37 @@ pub(crate) mod tests { _ => None, }) .collect::>(); + assert!(attributed.contains(&entity.id)); assert!( - attributed.contains(&entity.id), - "the entity the operation wrote is attributed to the committing session" - ); - assert!( - !attributed.contains(&carried_entity.id), - "republishing a carried entity's bytes must not attribute it to the committing session" + !attributed.contains(&pending.id), + "an entity the change left uncommitted is not attributed to the committing session" ); + assert_eq!( + std::fs::read(state.layout.working_dir().join("src/other.rs")).unwrap(), + b"pub fn other() -> u8 { 7 }\n" + ); + assert_eq!( + head_source(&state, "src/other.rs").unwrap(), + b"pub fn other() -> u8 { 1 }\n" + ); assert!( - !workspace_is_dirty(&state), - "the commit must leave the workspace level with its base" + workspace_is_dirty(&state), + "the commit must leave the uncommitted edit uncommitted" ); } - /// A carried path the workspace ADDED reaches the change with the entities - /// its bytes derive to. + /// A unit the workspace ADDED stays uncommitted, and the working copy keeps + /// answering about it. /// - /// The other carried arms exercise a `TreeDelta::Updated`, where the entity - /// deltas are modifications. This one is the admission of untracked content: - /// the workspace tree gains a path its base change never carried, and - /// because ambient admission publishes no semantic delta, the change would - /// otherwise publish a source file with no entities inside it at all. That - /// is not a subtler version of the same defect; it is a file the graph - /// cannot answer a single question about. + /// The other arms exercise an edit of a unit the base change holds. This + /// one is the admission of untracked content: the workspace tree gains a + /// path its base change never carried. The agent's change does not take it, + /// so the base change it records is still the one without that unit, and + /// the working copy keeps the entities the daemon derived from it, so the + /// graph can still answer every question about it. #[test] - fn a_carried_added_path_commits_the_entities_its_bytes_derive_to() { + fn an_added_unit_stays_uncommitted_and_answerable_beside_a_commit() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -12564,7 +13218,7 @@ pub(crate) mod tests { b"pub fn value() -> u8 { 1 }\n", "value", ); - admit_pending_working_tree_file(&state, "src/added.rs", b"pub fn added() -> u8 { 5 }\n"); + admit_watched_edit(&state, "src/added.rs", b"pub fn added() -> u8 { 5 }\n"); let sessions = test_sessions(); let (_, arguments) = @@ -12573,20 +13227,21 @@ pub(crate) mod tests { assert_ne!(result.is_error, Some(true), "{}", result_text(&result)); let reply = commit_reply(&result); assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/added.rs"]), - "the fixture must actually exercise the carry: {reply:#}" + reply["modified_files"], + serde_json::json!(["src/lib.rs"]), + "the fixture must leave the added unit out of the change: {reply:#}" ); + assert_eq!(head_source(&state, "src/added.rs"), None); assert_eq!( semantic_disagreement(&state, "src/lib.rs"), None, - "the file the operation authored must be coherent, or the check below proves nothing" + "the unit the operation authored must be coherent, or the check below proves nothing" ); assert_eq!( semantic_disagreement(&state, "src/added.rs"), None, - "a carried file the workspace admitted must reach the change with its entities" + "the added unit's entities still describe its bytes" ); assert!( state @@ -12598,8 +13253,9 @@ pub(crate) mod tests { .unwrap() .iter() .any(|held| held.name == "added"), - "the graph must be able to answer about the file the commit published" + "the graph must still be able to answer about the uncommitted unit" ); + assert_eq!(uncommitted_entities(&state), names(["added"])); } /// A vacated path takes its semantics with it, and this is the mechanism @@ -12682,18 +13338,19 @@ pub(crate) mod tests { ); } - /// A carried path whose new bytes stop being source is refused by name, not - /// published with the old entities still standing over them. + /// A unit whose uncommitted bytes stop being source neither blocks the + /// commit nor reaches it. /// - /// Deriving cannot answer this one: there is nothing to parse, so there are - /// no entities to replace the ones the graph holds. Publishing anyway is the - /// defect in its worst form, entities describing source the repository is - /// about to stop holding. Retiring them silently is not this commit's call - /// either, because no operation in it named that file and a retirement is a - /// decision about somebody else's work. So it refuses, names the path, says - /// what the graph still holds, and gives the three ways out. + /// Deriving cannot answer for those bytes: there is nothing to parse, so + /// there are no entities to replace the ones the graph holds. Publishing + /// them would be the worst form of the incoherence, entities describing + /// source the repository no longer holds, and retiring them is a decision + /// about somebody else's work. Neither is this commit's to make, because no + /// operation in it named that unit: the change keeps the unit as its base + /// change holds it, bytes and entities both, and the working copy keeps + /// the uncommitted bytes for whoever resolves them. #[test] - fn a_carried_path_that_stops_being_source_is_refused_by_name() { + fn a_unit_whose_uncommitted_bytes_stop_being_source_is_left_out_of_the_change() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -12701,7 +13358,7 @@ pub(crate) mod tests { b"pub fn value() -> u8 { 1 }\n", "value", ); - install_exact_source( + let (pending, _) = install_exact_source( &state, "src/other.rs", b"pub fn other() -> u8 { 1 }\n", @@ -12710,43 +13367,40 @@ pub(crate) mod tests { // Not valid UTF-8, so the classifier cannot call it entity source no // matter what the extension says. admit_pending_working_tree_edit(&state, "src/other.rs", &[0xff, 0xfe, 0x00, 0x01]); - let before = load_native_commit_base(&state.layout).unwrap(); let sessions = test_sessions(); - let (transaction_id, arguments) = + let (_, arguments) = stage_entity_edit(&state, &sessions, &entity, "pub fn value() -> u8 { 2 }"); let result = commit_exact_transaction(&state, &sessions, &arguments, None); - - assert_eq!( + assert_ne!( result.is_error, Some(true), - "publishing stale entities under bytes they never came from is not an option: {}", + "uncommitted work the operations did not write is not this commit's to resolve: {}", result_text(&result) ); - let message = result_text(&result); - assert!( - message.contains("src/other.rs"), - "the refusal must name the path that cannot be derived: {message}" - ); - assert!( - message.contains("no longer classifies as supported entity source"), - "the refusal must say what is wrong with it: {message}" + let reply = commit_reply(&result); + let change = replied_change(&state, &reply, &entity); + assert_eq!(change_paths(&change), names(["src/lib.rs"])); + assert_eq!( + head_source(&state, "src/other.rs").unwrap(), + b"pub fn other() -> u8 { 1 }\n", + "the change keeps the unit's source and the entities that describe it" ); assert!( - message.contains("conversion/reconciliation boundary") - && message.contains("restore the prior working content"), - "the refusal must say what to do about it: {message}" - ); - assert_eq!( - sessions.get_transaction(&transaction_id).unwrap().state, - "active", - "a refused commit leaves the transaction where the caller can retry it" + !state + .graph + .get_entity_history(&pending.id) + .unwrap() + .iter() + .any(|recorded| recorded.id == change.id), + "no entity of the unit is revised by a change that did not write it" ); assert_eq!( - load_native_commit_base(&state.layout).unwrap().roots, - before.roots, - "no repository authority may move behind a refusal" + std::fs::read(state.layout.working_dir().join("src/other.rs")).unwrap(), + [0xff, 0xfe, 0x00, 0x01], + "the working copy keeps the uncommitted bytes" ); + assert!(workspace_is_dirty(&state)); } /// Leave one source path converted to a symlink the way a working session @@ -12784,21 +13438,18 @@ pub(crate) mod tests { try_publish_pending_workspace_tree(state) } - /// A carried path that became a symlink is refused by name, not skipped. + /// A unit the working copy turned into a symlink is left out of the change + /// the same way. /// - /// The first cut of the derivation skipped every non-blob tree entry on the - /// reasoning that a symlink carries no source body and therefore no entities - /// to disagree with it. The first half is true and the second does not - /// follow: the tree and the semantics are independent, and kin-db - /// revalidates an invalidated path by requiring an artifact to remain at it - /// without requiring that artifact to be a blob (0.7.104 - /// `src/engine/graph.rs:8705`). So a same-path source-to-symlink conversion - /// satisfies every check while the entities the old source derived keep - /// standing, and the skip published them over a link. It is the same defect - /// as an unsupported blob, so it gets the same refusal. + /// The tree and the semantics are independent, and kin-db revalidates an + /// invalidated path by requiring an artifact to remain at it without + /// requiring that artifact to be a blob, so publishing the conversion would + /// leave the entities the old source derived standing over a link. The + /// change does not publish it: it keeps the unit as its base change holds + /// it, and the symlink stays uncommitted in the working copy. #[cfg(unix)] #[test] - fn a_carried_source_to_symlink_conversion_is_refused_by_name() { + fn a_unit_the_working_copy_turned_into_a_symlink_is_left_out_of_the_change() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -12816,55 +13467,44 @@ pub(crate) mod tests { "if some earlier layer refuses this conversion, that mechanism is the finding and \ this test must be rewritten around it rather than deleted", ); - let before = load_native_commit_base(&state.layout).unwrap(); let sessions = test_sessions(); - let (transaction_id, arguments) = + let (_, arguments) = stage_entity_edit(&state, &sessions, &entity, "pub fn value() -> u8 { 2 }"); let result = commit_exact_transaction(&state, &sessions, &arguments, None); - - assert_eq!( + assert_ne!( result.is_error, Some(true), - "publishing source entities over a symlink is not an option: {}", + "uncommitted work the operations did not write is not this commit's to resolve: {}", result_text(&result) ); - let message = result_text(&result); - assert!( - message.contains("src/other.rs"), - "the refusal must name the path it cannot derive: {message}" - ); - assert!( - message.contains("a symlink"), - "the refusal must say what the path became: {message}" - ); - assert!( - message.contains("conversion/reconciliation boundary") - && message.contains("restore the prior working content"), - "the refusal must say what to do about it: {message}" - ); + let reply = commit_reply(&result); + let change = replied_change(&state, &reply, &entity); + assert_eq!(change_paths(&change), names(["src/lib.rs"])); assert_eq!( - sessions.get_transaction(&transaction_id).unwrap().state, - "active", - "a refused commit leaves the transaction where the caller can retry it" + head_source(&state, "src/other.rs").unwrap(), + b"pub fn other() -> u8 { 1 }\n", + "the change keeps the unit as source, with the entities that describe it" ); - assert_eq!( - load_native_commit_base(&state.layout).unwrap().roots, - before.roots, - "no repository authority may move behind a refusal" + assert!( + std::fs::symlink_metadata(state.layout.working_dir().join("src/other.rs")) + .unwrap() + .file_type() + .is_symlink(), + "the working copy keeps the uncommitted symlink" ); + assert!(workspace_is_dirty(&state)); } - /// A newly admitted symlink with nothing standing under it stays a legal - /// tree-only carry, and its target is never resolved through the parser. + /// A newly admitted symlink stays uncommitted beside a commit, and its + /// target is never resolved through the parser. /// - /// The control for the refusal above. A symlink is a perfectly ordinary - /// thing for a workspace to carry, and refusing every one of them would - /// trade one broken commit for another. The refusal is about the entities - /// standing at the path, not about the entry kind, and this is what says so. + /// A symlink is a perfectly ordinary thing for a working copy to hold, and + /// it is somebody's work: the change does not take it, and nothing parses + /// its target on its behalf. #[cfg(unix)] #[test] - fn a_carried_symlink_with_no_standing_entities_is_a_legal_tree_only_carry() { + fn a_new_symlink_stays_uncommitted_beside_a_commit() { let (_dir, state) = test_state(); let (entity, _) = install_exact_source( &state, @@ -12896,14 +13536,23 @@ pub(crate) mod tests { assert_ne!( result.is_error, Some(true), - "a symlink nothing derives entities from must still commit: {}", + "a symlink nothing derives entities from must not block a commit: {}", result_text(&result) ); let reply = commit_reply(&result); + assert!(reply.get("carried_pending_files").is_none(), "{reply:#}"); assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/link.rs"]), - "the symlink is carried and declared like any other pending path: {reply:#}" + change_paths(&replied_change(&state, &reply, &entity)), + names(["src/lib.rs"]) + ); + assert_eq!(head_source(&state, "src/link.rs"), None); + assert!( + state + .graph + .resolved_tree() + .artifact_at_path(&test_path("src/link.rs")) + .is_some(), + "the working copy keeps the symlink" ); assert!( state @@ -12919,23 +13568,23 @@ pub(crate) mod tests { assert_eq!( semantic_disagreement(&state, "src/lib.rs"), None, - "the file the operation authored stays coherent beside a carried symlink" + "the unit the operation authored stays coherent beside an uncommitted symlink" ); + assert!(workspace_is_dirty(&state)); } - /// A relation-only commit beside a carried edit attributes the endpoint the - /// agent joined and not the one it carried. + /// A relation-only commit beside an uncommitted edit of its endpoint + /// records the relationship and none of the edit. /// - /// The carried-path filter that keeps a folded-in file out of attribution - /// applies while entity-delta scopes are collected. A relation-only commit - /// has no entity deltas of its own, so it falls back to the relation's - /// endpoints, and once a carried file contributes entity deltas the two - /// interact: the carried entity is filtered out of the first list, the list - /// is empty, the fallback opens, and an unfiltered fallback hands the carried - /// endpoint straight back to the committing session. Both sources are - /// filtered for that reason. + /// The relationship joins two entities the base change holds, so the + /// change records it exactly as it would on a clean workspace, and its + /// attribution falls back to the endpoints it joined as a relation-only + /// commit's always does. The uncommitted edit of the callee's unit is not + /// in the change and stays uncommitted, and the same relationship, under + /// the same identity, stands in the working copy, so it is not reported as + /// uncommitted work too. #[test] - fn a_relation_only_commit_beside_a_carry_attributes_only_the_endpoint_it_joined() { + fn a_relation_only_commit_beside_an_uncommitted_endpoint_records_only_the_relationship() { let (_dir, state) = test_state(); let (caller, _) = install_exact_source( &state, @@ -12949,9 +13598,7 @@ pub(crate) mod tests { b"pub fn callee() -> u8 { 2 }\n", "callee", ); - // The carried half, on the callee's own path, so the commit re-derives - // the entity the staged relation points at. - admit_pending_working_tree_edit(&state, "src/callee.rs", b"pub fn callee() -> u8 { 7 }\n"); + admit_watched_edit(&state, "src/callee.rs", b"pub fn callee() -> u8 { 7 }\n"); let sessions = kin_mcp::SessionRegistry::new(); let session = start_agent_session(&sessions, "gemini-cli", "relation-writer"); @@ -12989,14 +13636,35 @@ pub(crate) mod tests { assert_ne!( result.is_error, Some(true), - "a relation-only commit beside a carry must still land: {}", + "a relation-only commit beside uncommitted work must still land: {}", result_text(&result) ); let reply = commit_reply(&result); + assert!(reply.get("carried_pending_files").is_none(), "{reply:#}"); + let change_id = reply["change_id"].as_str().unwrap().to_string(); + let change = state + .graph + .get_change(&SemanticChangeId::from_hash( + Hash256::from_hex(&change_id).unwrap(), + )) + .unwrap() + .expect("the live graph holds the change it just published"); + assert!(change.tree_deltas.is_empty(), "{change:?}"); + assert!(change.entity_deltas.is_empty(), "{change:?}"); + let [RelationDelta::Added { new }] = change.relation_deltas.as_slice() else { + panic!("the change records exactly the relationship: {change:?}"); + }; + assert_eq!( + (new.src, new.dst), + ( + GraphNodeId::Entity(caller.id), + GraphNodeId::Entity(callee.id) + ) + ); assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/callee.rs"]), - "the fixture must actually exercise the carry: {reply:#}" + state.graph.get_relation_by_id(&new.id).as_ref(), + Some(new), + "the working copy holds the same relationship the change recorded" ); let scoped = state @@ -13010,18 +13678,18 @@ pub(crate) mod tests { }) .collect::>(); assert!( - scoped.contains(&caller.id), - "the endpoint the agent joined and did not carry is still attributed to it" + scoped.contains(&caller.id) && scoped.contains(&callee.id), + "a relation-only commit is attributed to the endpoints it joined" ); - assert!( - !scoped.contains(&callee.id), - "an endpoint inside a carried file must not reach the committing session's \ - attribution through the relation fallback" + assert_eq!( + head_source(&state, "src/callee.rs").unwrap(), + b"pub fn callee() -> u8 { 2 }\n" ); + assert_eq!(uncommitted_entities(&state), names(["callee"])); assert_eq!( semantic_disagreement(&state, "src/callee.rs"), None, - "the carried endpoint's own entities still describe the bytes this commit published" + "the uncommitted endpoint's own entities still describe its bytes" ); } @@ -13066,7 +13734,7 @@ pub(crate) mod tests { .graph .get_entity(&original.id) .unwrap() - .expect("a carried move must preserve entity identity"); + .expect("an uncommitted move must preserve entity identity"); assert_eq!(moved.file_origin, Some(FilePathId::new("src/new.rs"))); assert_eq!( moved.span.as_ref().unwrap().file, @@ -13202,10 +13870,17 @@ pub(crate) mod tests { let result = commit_exact_transaction(&state, &sessions, &arguments, None); assert_ne!(result.is_error, Some(true), "{}", result_text(&result)); let reply = commit_reply(&result); - assert_eq!( - reply["carried_pending_files"], - serde_json::json!(["src/new.rs"]) + assert!( + reply.get("carried_pending_files").is_none(), + "the uncommitted move is not the agent's to publish: {reply:#}" + ); + assert_eq!(reply["modified_files"], serde_json::json!(["src/lib.rs"])); + assert!( + head_source(&state, "src/old.rs").is_some() + && head_source(&state, "src/new.rs").is_none(), + "the change keeps the moved unit where its base change holds it" ); + assert!(workspace_is_dirty(&state)); assert_carried_move_identity(&state, &moved, artifact, &incoming); assert_eq!(semantic_disagreement(&state, "src/new.rs"), None); assert_eq!(semantic_disagreement(&state, "src/lib.rs"), None); diff --git a/crates/kin-daemon/src/repository_admit.rs b/crates/kin-daemon/src/repository_admit.rs index 8c66d9677..8c3b8cd41 100644 --- a/crates/kin-daemon/src/repository_admit.rs +++ b/crates/kin-daemon/src/repository_admit.rs @@ -449,6 +449,60 @@ mod tests { ); } + /// An edit's admission publishes through the authority the daemon already + /// holds. It opens none of its own, and the reader after it finds the held + /// authority already at the successor the admission wrote, instead of + /// loading the store again to read it. Each of those opens decodes the + /// whole persisted authority and re-verifies every body, and together they + /// were 17 of the 41 seconds the first guide read after a one-function edit + /// took on a converted fastify store in a debug build. + #[tokio::test] + async fn an_edit_admission_publishes_through_the_authority_the_daemon_holds() { + let repo = tempfile::tempdir().unwrap(); + let state = open_test_state(&repo); + let source = state.layout.working_dir().join("edited.rs"); + std::fs::write(&source, "pub fn edited() -> u8 {\n 1\n}\n").unwrap(); + let first = execute(&state).await.expect("the pass reported an outcome"); + assert!(first.report.expect("a report").admitted); + crate::api::held_repository_authority(&state).unwrap(); + let loads = state.projection_authority.loads(); + + std::fs::write(&source, "pub fn edited() -> u8 {\n 2\n}\n").unwrap(); + crate::local_repository_authority::reset_authority_open_count(); + let response = execute(&state).await.expect("the pass reported an outcome"); + let report = response.report.expect("a reported pass carries its report"); + assert!(report.admitted, "{:?}", report.failure); + assert_eq!(report.tree_moved, Some(true), "the edit moved the tree"); + assert_eq!( + crate::local_repository_authority::authority_open_count(), + 0, + "the publication borrows the held authority rather than opening one" + ); + + let held = crate::api::held_repository_authority(&state).unwrap(); + assert_eq!( + state.projection_authority.loads(), + loads, + "the reader after the admission keeps the authority it published through" + ); + let workspace_id = state + .local_repository_authority_binding() + .unwrap() + .workspace_id(); + let lease = held.read_authority(); + let workspace = lease + .metadata() + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .expect("the bound workspace"); + assert_eq!( + workspace.tree, + state.graph.resolved_tree(), + "the held authority carries the edit the admission published" + ); + } + #[tokio::test] async fn automatic_recovery_keeps_a_newer_loss_and_then_admits_its_source() { use kin_model::EntityStore as _; diff --git a/crates/kin-daemon/src/repository_branch.rs b/crates/kin-daemon/src/repository_branch.rs index bd5b9d982..62bb1a46c 100644 --- a/crates/kin-daemon/src/repository_branch.rs +++ b/crates/kin-daemon/src/repository_branch.rs @@ -843,6 +843,11 @@ fn list(authority: &ActiveLocalRepositoryAuthority) -> Result { .map(|repository_ref| BranchListEntry { name: repository_ref.name.clone(), target: repository_ref.target.clone(), + change_id: lease + .resolve_ref_target(&repository_ref.name) + .ok() + .flatten() + .and_then(|target| lease.resolve_target_change_id(&target).ok()), active: active_ref == Some(&repository_ref.name), default: default_ref.as_ref() == Some(&repository_ref.name), }) diff --git a/crates/kin-daemon/src/repository_commit.rs b/crates/kin-daemon/src/repository_commit.rs index f776e64e0..fd0c57f31 100644 --- a/crates/kin-daemon/src/repository_commit.rs +++ b/crates/kin-daemon/src/repository_commit.rs @@ -1367,6 +1367,11 @@ pub(crate) fn is_stale_plan_refusal(error: &DaemonError) -> bool { /// `legacy` what an earlier build's records still owe. The same /// compare-and-swap that publishes the tree records both in the workspace's /// owed derivation ledger, so a refused publication records nothing. +/// +/// This entry point opens an authority of its own, so it publishes as another +/// writer would. The daemon publishes through the authority it already holds, +/// with [`publish_workspace_tree_through`]. +#[cfg(test)] pub(crate) fn publish_workspace_tree( blobs: &kin_blobs::BlobStore, authority_context: &LocalRepositoryAuthorityContext, @@ -1376,10 +1381,51 @@ pub(crate) fn publish_workspace_tree( owed: &[(RepoPath, Hash256)], legacy: &[(RepoPath, Hash256)], ) -> Result> { - let desired_tree = &admitted.desired_tree; - let repository_id = authority_context.repository_id().clone(); - let workspace_id = authority_context.workspace_id(); let authority = authority_context.open().map_err(DaemonError::Graph)?; + publish_workspace_tree_through( + blobs, + &authority, + authority_context.repository_id(), + authority_context.workspace_id(), + admitted, + operation_id, + actor, + owed, + legacy, + ) +} + +/// [`publish_workspace_tree`] through an authority the caller already holds. +/// +/// An open decodes the whole persisted authority and re-verifies every body in +/// repository CAS, and the daemon already holds one for the current +/// publication. Opening a second one to publish an edit's tree paid that cost +/// on every admission, and because the commit then landed in an authority the +/// daemon did not hold, the next reader paid it again to load the successor +/// this publication had just written. Measured on a converted fastify store in +/// a debug build on a loaded machine, admitting a one-function edit took 36 +/// seconds, 7 of them in that open and another 10 after the commit, reloading +/// and re-materializing what it had just written. +/// +/// The compare-and-swap is unchanged: the roots checked below are the held +/// authority's, which the daemon hands out only while the durable publication +/// record still reads as it did before that authority was loaded, and the +/// commit's own compare-and-swap against the durable head still refuses a +/// successor built on a head another writer has moved. +#[allow(clippy::too_many_arguments)] +pub(crate) fn publish_workspace_tree_through( + blobs: &kin_blobs::BlobStore, + authority: &RepositoryAuthorityManager, + repository_id: &kin_model::RepositoryId, + workspace_id: WorkspaceId, + admitted: &AdmittedWorkspaceTree, + operation_id: OperationId, + actor: AuthorId, + owed: &[(RepoPath, Hash256)], + legacy: &[(RepoPath, Hash256)], +) -> Result> { + let desired_tree = &admitted.desired_tree; + let repository_id = repository_id.clone(); let lease = authority.read_authority(); if lease.roots() != &admitted.expected_roots { return Err(invalid(format!( @@ -1436,86 +1482,81 @@ pub(crate) fn publish_workspace_tree( let vacated = VacatedPaths::from_deltas(&tree_deltas); let moves = session_artifact_moves(&tree_deltas); let module_relocations = - plan_session_module_relocations(blobs, &authority, &workspace.tree, &tree_deltas)?; - let semantic_delta = - if let Some((observed, after)) = &admitted.observed_semantics { - if observed.resolved_tree != workspace.tree || after.resolved_tree != *desired_tree { - return Err(invalid( - "captured live semantics do not bind the admitted tree transition", - )); - } - let snapshot = lease - .workspace_graph_snapshot(&workspace_id)? - .ok_or_else(|| invalid("observed workspace has no authority predecessor"))?; - let semantic = kin_core::diff_workspace_semantics( - &snapshot.entities, - &snapshot.relations, - &after.entities, - &after.relations, - )?; - let mut references = Vec::new(); - for (id, old) in &snapshot.external_references { - match after.external_references.get(id) { - None => references - .push(kin_model::ExternalReferenceDelta::Removed { old: old.clone() }), - Some(new) if old != new => { - return Err(invalid( - "live observation rewrote an immutable external reference identity", - )) - } - _ => {} + plan_session_module_relocations(blobs, authority, &workspace.tree, &tree_deltas)?; + let semantic_delta = if let Some((observed, after)) = &admitted.observed_semantics { + if observed.resolved_tree != workspace.tree || after.resolved_tree != *desired_tree { + return Err(invalid( + "captured live semantics do not bind the admitted tree transition", + )); + } + let snapshot = lease + .workspace_graph_snapshot(&workspace_id)? + .ok_or_else(|| invalid("observed workspace has no authority predecessor"))?; + let semantic = kin_core::diff_workspace_semantics( + &snapshot.entities, + &snapshot.relations, + &after.entities, + &after.relations, + )?; + let mut references = Vec::new(); + for (id, old) in &snapshot.external_references { + match after.external_references.get(id) { + None => { + references.push(kin_model::ExternalReferenceDelta::Removed { old: old.clone() }) } - } - for (id, new) in &after.external_references { - if !snapshot.external_references.contains_key(id) { - references.push(kin_model::ExternalReferenceDelta::Added { new: new.clone() }); + Some(new) if old != new => { + return Err(invalid( + "live observation rewrote an immutable external reference identity", + )) } + _ => {} } - WorkspaceSemanticDelta::new_with_external_references( - semantic.entity_deltas().to_vec(), - semantic.relation_deltas().to_vec(), - references, - )? - } else if vacated.is_empty() && moves.is_empty() { - WorkspaceSemanticDelta::default() - } else { - match lease.workspace_graph_snapshot(&workspace_id)? { - Some(snapshot) => { - let retirement = retire_semantics_on_vacated(&snapshot, &vacated)?; - let mut entities = retirement.entity_deltas().to_vec(); - let mut relations = retirement.relation_deltas().to_vec(); - if !moves.is_empty() { - let graph = - kin_db::InMemoryGraph::from_snapshot_without_text_index(snapshot)?; - relations.extend(plan_moved_source_coverage_withdrawals(&graph, &moves)?); - plan_move_binding_transition( - &graph, - blobs, - &authority, - &moves, - &mut relations, - )?; - kin_reconcile::relocate_binding_obligations(&graph, &moves, &mut relations) - .map_err(|error| invalid(error.to_string()))?; - for (from, to) in &moves { - entities.extend(plan_session_entity_relocations(&graph, from, to)?); - } - bind_session_module_relocations(&mut entities, &module_relocations)?; + } + for (id, new) in &after.external_references { + if !snapshot.external_references.contains_key(id) { + references.push(kin_model::ExternalReferenceDelta::Added { new: new.clone() }); + } + } + WorkspaceSemanticDelta::new_with_external_references( + semantic.entity_deltas().to_vec(), + semantic.relation_deltas().to_vec(), + references, + )? + } else if vacated.is_empty() && moves.is_empty() { + WorkspaceSemanticDelta::default() + } else { + match lease.workspace_graph_snapshot(&workspace_id)? { + Some(snapshot) => { + let retirement = retire_semantics_on_vacated(&snapshot, &vacated)?; + let mut entities = retirement.entity_deltas().to_vec(); + let mut relations = retirement.relation_deltas().to_vec(); + if !moves.is_empty() { + let graph = kin_db::InMemoryGraph::from_snapshot_without_text_index(snapshot)?; + relations.extend(plan_moved_source_coverage_withdrawals(&graph, &moves)?); + plan_move_binding_transition(&graph, blobs, authority, &moves, &mut relations)?; + kin_reconcile::relocate_binding_obligations(&graph, &moves, &mut relations) + .map_err(|error| invalid(error.to_string()))?; + for (from, to) in &moves { + entities.extend(plan_session_entity_relocations(&graph, from, to)?); } - WorkspaceSemanticDelta::new(entities, relations)? + bind_session_module_relocations(&mut entities, &module_relocations)?; } - // A vacated set with no snapshot to retire from is nothing to carry. - // A MOVE with no snapshot is different: the identity is real, this - // publication cannot prove it survives, and publishing the tree - // anyway would strand it exactly as the missing retirement stranded - // a removal. Refusing leaves authority untouched. - None if !moves.is_empty() => return Err(invalid( + WorkspaceSemanticDelta::new(entities, relations)? + } + // A vacated set with no snapshot to retire from is nothing to carry. + // A MOVE with no snapshot is different: the identity is real, this + // publication cannot prove it survives, and publishing the tree + // anyway would strand it exactly as the missing retirement stranded + // a removal. Refusing leaves authority untouched. + None if !moves.is_empty() => { + return Err(invalid( "repository authority holds no workspace graph snapshot to relocate a moved \ identity into, so publishing this tree would strand it", - )), - None => WorkspaceSemanticDelta::default(), + )) } - }; + None => WorkspaceSemanticDelta::default(), + } + }; let mut source_lengths = std::collections::BTreeMap::new(); let (shared_policy, _) = SharedAdmissionPolicy::derive_from_tree_with_allowances( Some(&workspace.shared_admission_policy), @@ -1527,7 +1568,7 @@ pub(crate) fn publish_workspace_tree( // Every rule file in the desired tree is measured here, changed or // not, because the policy is derived from the whole tree rather // than from what moved. - let source = read_publishable_source(blobs, &authority, hash).map_err(|error| { + let source = read_publishable_source(blobs, authority, hash).map_err(|error| { ModelError::InvalidOperation(format!( "{error}, while deriving the admitted workspace policy" )) @@ -1541,7 +1582,7 @@ pub(crate) fn publish_workspace_tree( Ok(length) }, |hash| { - read_publishable_source(blobs, &authority, hash) + read_publishable_source(blobs, authority, hash) .map(|source| source.body().to_vec()) .map_err(|error| { ModelError::InvalidOperation(format!( @@ -1619,7 +1660,7 @@ pub(crate) fn publish_workspace_tree( // source becomes durable. An unchanged rule source is already there and // needs no copy. for hash in source_hashes { - if let Some(body) = read_publishable_source(blobs, &authority, hash)?.body_to_publish() { + if let Some(body) = read_publishable_source(blobs, authority, hash)?.body_to_publish() { authority.save_source_blob(hash, body)?; } } @@ -1709,6 +1750,7 @@ pub(crate) fn plan_native_amend( Some(amend), SemanticCurrency::DaemonMaintained, None, + None, ) } @@ -1754,6 +1796,77 @@ pub(crate) fn plan_command_commit( amend, SemanticCurrency::DaemonMaintained, Some(held), + None, + ) +} + +/// What a workspace holds after a change that leaves some of its working copy +/// uncommitted. +/// +/// A change of this kind records less than the working copy holds, so the +/// workspace does not take the change's tree. It takes this graph instead, +/// whose semantics describe the whole working copy, and its base advances to +/// the change, so everything the change does not record is uncommitted work +/// against the new base. +#[derive(Clone, Copy)] +pub(crate) enum KeptWorkingCopy<'a> { + /// The working copy keeps its tree exactly, and nothing in it is written. + /// A commit of chosen entities records part of what is already there. + AsItIs(&'a kin_db::InMemoryGraph), + /// The working copy takes this graph's tree, which differs from the + /// workspace's only at `written`, the source units the caller's own + /// operations wrote. An agent's transaction writes its units into the + /// working copy and records only them. + Written { + graph: &'a kin_db::InMemoryGraph, + written: &'a BTreeSet, + }, +} + +impl<'a> KeptWorkingCopy<'a> { + fn graph(self) -> &'a kin_db::InMemoryGraph { + match self { + Self::AsItIs(graph) | Self::Written { graph, .. } => graph, + } + } +} + +/// Plan a change that records part of the working copy and leaves the rest of +/// it uncommitted. +/// +/// `graph` is the prospective graph the change records: the workspace's base +/// with only the chosen work in it. `live` is the daemon's graph of the whole +/// working copy. The workspace keeps its tree and takes `live`'s semantics, so +/// nothing in the working copy moves, and only its base advances to the new +/// change. Everything the change does not carry is uncommitted work against +/// that new base, exactly as it stood before. +#[allow(clippy::too_many_arguments)] +pub(crate) fn plan_kept_workspace_commit( + graph: &kin_db::InMemoryGraph, + live: &kin_db::InMemoryGraph, + blobs: &kin_blobs::BlobStore, + authority_context: &LocalRepositoryAuthorityContext, + held: Arc>, + expected_roots: &RootBundle, + operation_id: OperationId, + timestamp: Timestamp, + author: AuthorId, + message: String, +) -> Result { + plan_native_commit_inner( + graph, + blobs, + authority_context, + operation_id, + timestamp, + author, + None, + &|_| message.clone(), + Some(expected_roots), + None, + SemanticCurrency::AuthoritySnapshot, + Some(held), + Some(KeptWorkingCopy::AsItIs(live)), ) } @@ -1807,6 +1920,7 @@ pub(crate) fn plan_native_commit( None, SemanticCurrency::DaemonMaintained, None, + None, ) } @@ -1841,6 +1955,7 @@ pub(crate) fn plan_native_commit_from_base( None, SemanticCurrency::AuthoritySnapshot, None, + None, ) } @@ -1852,9 +1967,12 @@ pub(crate) fn plan_native_commit_from_base( /// and it publishes no change, so that content sits ahead of history with no /// authorship attached to it. It is already inside the prospective graph a /// caller plans against, because the workspace graph takes its resolved tree -/// from the workspace, so a commit either publishes it or reverts the working -/// files that hold it. It gets published, and this is what makes that -/// publication say so. +/// from the workspace, so a commit planned from that graph alone publishes it. +/// This is what makes that publication say so. A caller that can tell its own +/// units from the rest leaves the rest uncommitted instead, through +/// [`plan_native_commit_from_base_keeping_pending`]; an MCP transaction reaches +/// this planner only when every unit ahead of the base is one it wrote, so +/// what it declares here is uncommitted content inside those units. /// /// `authored_files` names the files the caller's own operations produced. Every /// other file this change publishes came from the pending tree, and the rendered @@ -1887,6 +2005,55 @@ pub(crate) fn plan_native_commit_from_base_declaring_carry( None, SemanticCurrency::AuthoritySnapshot, held, + None, + ) +} + +/// Plan one exact native transaction that records only what the caller's own +/// operations wrote and leaves the rest of the working copy uncommitted. +/// +/// `graph` is the change: the workspace's base change with the caller's +/// written units in it, and none of the uncommitted work in units the caller +/// did not write. `workspace` is the whole working copy after the caller's +/// writes, which the workspace keeps; its tree differs from the workspace's +/// only at `authored_files`, and only those are written to the working copy. +/// The base advances to the new change, so the work the change leaves out is +/// still uncommitted against it, exactly as it stood. +/// +/// The message is told what the change carried in that its operations did not +/// write, as [`plan_native_commit_from_base_declaring_carry`] does, which for a +/// change built this way is nothing. +#[allow(clippy::too_many_arguments)] +pub(crate) fn plan_native_commit_from_base_keeping_pending( + graph: &kin_db::InMemoryGraph, + workspace: &kin_db::InMemoryGraph, + blobs: &kin_blobs::BlobStore, + authority_context: &LocalRepositoryAuthorityContext, + operation_id: OperationId, + timestamp: Timestamp, + author: AuthorId, + authored_files: &BTreeSet, + message: &dyn Fn(&[RepoPath]) -> String, + base: &NativeCommitBase, + held: Option>>, +) -> Result { + plan_native_commit_inner( + graph, + blobs, + authority_context, + operation_id, + timestamp, + author, + Some(authored_files), + message, + Some(&base.roots), + None, + SemanticCurrency::AuthoritySnapshot, + held, + Some(KeptWorkingCopy::Written { + graph: workspace, + written: authored_files, + }), ) } @@ -2424,6 +2591,7 @@ fn plan_native_commit_inner( amend: Option<&NativeAmend>, currency: SemanticCurrency, held: Option>>, + kept: Option>, ) -> Result { let repository_id = authority_context.repository_id().clone(); let workspace_id = authority_context.workspace_id(); @@ -2516,6 +2684,11 @@ fn plan_native_commit_inner( None => None, }; + // The tree a kept working copy holds after this change, and the exact + // transition from the workspace's tree to it. An agent's transaction moves + // only the units it wrote; a commit of chosen entities moves nothing. + let mut kept_tree: Option<(kin_model::ResolvedTree, Vec)> = None; + // Both sides of the semantic diff are whole copies of the graph and neither // is read again once the delta exists, so both are scoped to the diff and // freed before the phase that follows. @@ -2543,8 +2716,44 @@ fn plan_native_commit_inner( "repository authority has no graph snapshot for workspace {workspace_id}" )) })?; + // A change that keeps the rest of the working copy uncommitted leaves + // the workspace holding the whole working copy's semantics; any other + // commit leaves it holding exactly what the change records. let desired_workspace_graph = - crate::mcp_commit::timed_commit_phase("plan_snapshot_clone", || graph.to_snapshot()); + crate::mcp_commit::timed_commit_phase("plan_snapshot_clone", || { + kept.map(KeptWorkingCopy::graph) + .unwrap_or(graph) + .to_snapshot() + }); + let moved = || invalid("the working copy moved while this change was being planned"); + match kept { + Some(KeptWorkingCopy::AsItIs(_)) => { + if desired_workspace_graph.resolved_tree != workspace.tree { + return Err(moved()); + } + kept_tree = Some((workspace.tree.clone(), Vec::new())); + } + Some(KeptWorkingCopy::Written { written, .. }) => { + let transition = kin_core::exact_tree_correction( + &workspace.tree, + &desired_workspace_graph.resolved_tree, + )?; + // Every unit this moves is one the caller's operations wrote. + // Anything else moving means the working copy changed under + // the plan, and writing it would overwrite somebody's work. + let only_written = transition.iter().all(|delta| { + [delta.old_state(), delta.new_state()] + .into_iter() + .flatten() + .all(|located| written.contains(&located.path)) + }); + if !only_written { + return Err(moved()); + } + kept_tree = Some((desired_workspace_graph.resolved_tree.clone(), transition)); + } + None => {} + } crate::mcp_commit::timed_commit_phase("plan_diff_semantics", || { let semantic = kin_core::diff_workspace_semantics( &authority_workspace_graph.entities, @@ -2708,8 +2917,66 @@ fn plan_native_commit_inner( .collect(); let tree_hash = compute_resolved_tree_hash(&deltas.expected_tree)?; - let workspace_tree_deltas = - kin_core::exact_tree_correction(&workspace.tree, &deltas.expected_tree)?; + // What the workspace holds after the change: the change's own tree and + // policy, or, for a kept working copy, its own tree and the policy that + // tree is admitted under. + let (workspace_tree, workspace_tree_deltas, workspace_tree_hash, workspace_policy) = + match (kept, kept_tree) { + (Some(KeptWorkingCopy::AsItIs(_)), Some((tree, transition))) => ( + tree, + transition, + workspace.tree_hash, + workspace.shared_admission_policy.clone(), + ), + (Some(KeptWorkingCopy::Written { .. }), Some((tree, transition))) => { + // A working copy the caller wrote into is admitted again, the + // way every admitted tree is: its policy is derived from the + // whole tree it now holds, so a written rule source governs it + // exactly as it would after an ordinary admission. + let (policy, _) = crate::mcp_commit::timed_commit_phase( + "plan_derive_workspace_admission_policy", + || { + SharedAdmissionPolicy::derive_from_tree_with_allowances( + Some(&workspace.shared_admission_policy), + &tree, + |hash| { + read_publishable_source(blobs, &authority, hash) + .map_err(|error| { + ModelError::InvalidOperation(format!( + "{error}, while deriving the kept working copy's policy" + )) + }) + .and_then(|source| { + u64::try_from(source.body().len()).map_err(|_| { + ModelError::InvalidOperation(format!( + "kept working copy policy source {hash} exceeds u64" + )) + }) + }) + }, + |hash| { + read_publishable_source(blobs, &authority, hash) + .map(|source| source.body().to_vec()) + .map_err(|error| { + ModelError::InvalidOperation(format!( + "{error}, while reading the approvals the kept working \ + copy's policy derives" + )) + }) + }, + ) + }, + )?; + let hash = compute_resolved_tree_hash(&tree)?; + (tree, transition, hash, policy) + } + _ => ( + deltas.expected_tree.clone(), + kin_core::exact_tree_correction(&workspace.tree, &deltas.expected_tree)?, + tree_hash, + shared_policy.clone(), + ), + }; let workspace_generation = workspace.generation.checked_add(1).ok_or_else(|| { invalid(format!( "workspace {} generation exhausted", @@ -2759,13 +3026,18 @@ fn plan_native_commit_inner( new_base_target: Some(new_target.clone()), new_base_tree_hash: Some(tree_hash), tree_deltas: workspace_tree_deltas, - new_tree_hash: tree_hash, + // A kept working copy keeps its tree, and with it the admission policy + // that tree was admitted under; the change carries its own policy. + new_tree_hash: workspace_tree_hash, semantic_delta: workspace_semantic_delta, - new_shared_admission_policy: shared_policy.clone(), - new_admission_policy: EffectiveAdmissionPolicyStamp { - shared: shared_policy.stamp(), - local: workspace.admission_policy.local, + new_admission_policy: match kept { + Some(KeptWorkingCopy::AsItIs(_)) => workspace.admission_policy, + _ => EffectiveAdmissionPolicyStamp { + shared: workspace_policy.stamp(), + local: workspace.admission_policy.local, + }, }, + new_shared_admission_policy: workspace_policy, }; let transaction = RepositoryTransaction { schema_version: REPOSITORY_TRANSACTION_SCHEMA_VERSION, @@ -2774,9 +3046,19 @@ fn plan_native_commit_inner( expected_generation: lease.roots().generation, expected_roots: lease.roots().clone(), actor: author, - reason: match amend { - Some(amend) => format!("amend native semantic change {}", amend.expected_head), - None => "publish admitted native semantic change".to_string(), + reason: match (amend, kept) { + (Some(amend), _) => format!("amend native semantic change {}", amend.expected_head), + (None, Some(KeptWorkingCopy::AsItIs(_))) => { + "publish chosen entities as a native semantic change, keeping the rest of the \ + working copy uncommitted" + .to_string() + } + (None, Some(KeptWorkingCopy::Written { .. })) => { + "publish the source units a transaction wrote as a native semantic change, \ + keeping the rest of the working copy uncommitted" + .to_string() + } + (None, None) => "publish admitted native semantic change".to_string(), }, external_objects: Vec::new(), git_authority_delta: None, @@ -2803,6 +3085,27 @@ fn plan_native_commit_inner( } } source_hashes.extend(shared_policy.sources.iter().map(|source| source.body_hash)); + // A working copy the caller wrote into beside the change takes bodies the + // change may not carry, and the projection reads every body it writes back + // out of repository CAS. + if let (Some(KeptWorkingCopy::Written { .. }), Some(mutation)) = + (kept, &transaction.workspace_mutation) + { + source_hashes.extend( + mutation + .tree_deltas + .iter() + .filter_map(|delta| delta.new_state()) + .filter_map(|located| located.entry.blob_identity()), + ); + source_hashes.extend( + mutation + .new_shared_admission_policy + .sources + .iter() + .map(|source| source.body_hash), + ); + } for entity in change .entity_deltas .iter() @@ -2835,8 +3138,11 @@ fn plan_native_commit_inner( carried_pending_files, carried_semantic_files, entity_scope_ids, + // A kept working copy is published over itself: the working copy must + // still hold the tree it held, and only the units the caller wrote, if + // any, are written. + target_tree: workspace_tree, previous_tree: workspace.tree, - target_tree: deltas.expected_tree, source_hashes: source_hashes.into_iter().collect(), authority, owed_derivations: (currency == SemanticCurrency::DaemonMaintained) @@ -2857,12 +3163,13 @@ fn plan_native_commit_inner( /// /// Refusing it never kept that content out of a commit either. The workspace /// graph takes its resolved tree from the workspace, so the pending content is -/// inside the prospective graph every caller plans against, and a commit that -/// excluded it would have to revert the working files that hold it. What the -/// refusal actually bought was silence about a fold that was going to happen -/// anyway once the guard was lifted, which is why the caller that lifts it -/// declares the fold instead: see -/// [`plan_native_commit_from_base_declaring_carry`]. +/// inside the prospective graph every caller plans against. What keeps it out +/// of a change is planning the change apart from the working copy, which keeps +/// its tree and moves only its base: see +/// [`plan_native_commit_from_base_keeping_pending`] for an MCP transaction and +/// [`plan_kept_workspace_commit`] for a commit of chosen entities. A caller +/// that plans from that graph alone publishes the content and declares it: +/// see [`plan_native_commit_from_base_declaring_carry`]. pub(crate) fn load_native_commit_base( authority_context: &LocalRepositoryAuthorityContext, ) -> Result { diff --git a/crates/kin-daemon/src/repository_drift.rs b/crates/kin-daemon/src/repository_drift.rs index e4fd8bd6e..8773c5201 100644 --- a/crates/kin-daemon/src/repository_drift.rs +++ b/crates/kin-daemon/src/repository_drift.rs @@ -21,11 +21,11 @@ use crate::state::DaemonState; /// Attempts allowed to take one generation-bound observation. /// -/// Each attempt is independently pinned to the generation it was taken -/// against; a retry re-observes from scratch rather than stitching a report -/// across generations. Retrying only absorbs a benign race with concurrent -/// authority movement, and a report is still refused rather than published if -/// authority never holds still. +/// Each attempt binds the authority afresh and is pinned to the generation it +/// was taken against; a retry re-observes from scratch rather than stitching a +/// report across generations. Retrying only absorbs a benign race with +/// concurrent authority movement, and a report is still refused rather than +/// published if authority never holds still. const OBSERVATION_ATTEMPTS: usize = 4; /// Linear backoff between observation attempts, so a repository still settling @@ -35,10 +35,21 @@ const OBSERVATION_BACKOFF: std::time::Duration = std::time::Duration::from_milli pub(crate) fn execute( state: &DaemonState, ) -> std::result::Result { - let authority = - ActiveLocalRepositoryAuthority::open_bound(state).map_err(drift_bind_refusal)?; let mut last_conflict = None; for attempt in 0..OBSERVATION_ATTEMPTS { + // Bind on every attempt, not once ahead of the loop. Authority can + // move through a manager this bind does not hold: the daemon's own + // workspace admission publishes through an open of its own, and so do + // the CLI and a second daemon. The bound manager then still reads the + // generation it was loaded at, every retry against it freezes a head + // that no longer matches, and one admission landing inside the first + // observation used up all the attempts. A fresh bind reuses the held + // authority while the publication record is unchanged and loads the + // new publication otherwise. + let authority = + ActiveLocalRepositoryAuthority::open_bound(state).map_err(drift_bind_refusal)?; + #[cfg(test)] + after_bind_hook::run(); match report(state, &authority) { Ok(response) => return Ok(response), Err(error) if is_authority_movement(&error) => { @@ -184,6 +195,28 @@ fn classify_drift_error(error: anyhow::Error) -> (StatusCode, String) { ) } +/// A test seam that runs once, right after an observation attempt binds its +/// authority, so a test can publish as another writer inside the window the +/// bind does not guard. +#[cfg(test)] +pub(crate) mod after_bind_hook { + use std::cell::RefCell; + + thread_local! { + static HOOK: RefCell>> = const { RefCell::new(None) }; + } + + pub(crate) fn arm_once(hook: impl FnOnce() + 'static) { + HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook))); + } + + pub(super) fn run() { + if let Some(hook) = HOOK.with(|slot| slot.borrow_mut().take()) { + hook(); + } + } +} + fn drift_bind_refusal(refusal: RepositoryAuthorityBindRefusal) -> (StatusCode, String) { let identity = refusal.is_identity_refusal(); let error = refusal.into_error(); diff --git a/crates/kin-daemon/src/repository_rename.rs b/crates/kin-daemon/src/repository_rename.rs index d75ded2f5..9ca70586d 100644 --- a/crates/kin-daemon/src/repository_rename.rs +++ b/crates/kin-daemon/src/repository_rename.rs @@ -1442,6 +1442,7 @@ mod tests { authored_by: IdentityRef::assistant("rename-annotation-test"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }; fixture.state.graph.create_annotation(&annotation).unwrap(); diff --git a/crates/kin-daemon/src/review_flags.rs b/crates/kin-daemon/src/review_flags.rs new file mode 100644 index 000000000..7b98b2bab --- /dev/null +++ b/crates/kin-daemon/src/review_flags.rs @@ -0,0 +1,1046 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! Review flags in the daemon: where they are written, and how they reach the +//! agent that made the change. +//! +//! Three parts: +//! +//! - `POST /review/flags` plans a [`ReviewFlagRequest`] against the live graph +//! and commits what it writes to repository authority as one transaction +//! before the graph shows it, through the writer review state uses +//! ([`crate::review_write`]). This is how the local app sets, closes and +//! lists flags, and why a daemon that reopens the store still holds them. +//! - [`reached_by_call`] reads one finished agent call the way the call log +//! does, by the entity ids its request and its answer named, and says which +//! open flags that answer reaches. `status` reaches every open flag. What it +//! returns travels beside the answer to the server the agent talks to. +//! - A session handed a flag is recorded on the flag. The record is held in +//! memory at once, so the app's next read shows it, and written into the +//! repository when the sessions it names have gone quiet or ended +//! ([`settle_sightings`]). A write is a repository transaction that moves +//! the authority generation, and an agent's own read must not start one in +//! the middle of its work: a page it was handed continues only while the +//! graph it was read from is still the one served. +//! +//! A flag is advisory. Nothing here refuses a call or a commit. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use uuid::Uuid; + +use kin_cli::commands::review_flags::{ + self as flags, FlagWrite, ReviewFlagRequest, ReviewFlagResponse, ReviewFlagSeen, ReviewFlagView, +}; +use kin_mcp::review_flags::{Delivered, FlagNotice, ANSWER_FLAGS_MAX, LISTING_FLAGS_MAX}; +use kin_model::ids::SessionId; +use kin_model::timestamp::Timestamp; +use kin_model::{Annotation, AnnotationId, ReviewFlagSighting, WorkStore as _}; + +use crate::agent_activity::FlagSighting; +use crate::DaemonState; + +/// The tool that answers with every open flag in the repository. +const LISTING_TOOL: &str = "kin_graph_status"; + +/// What a transaction that only records which agents saw a flag is. +const SIGHTING_ACTION: &str = "review_flag.seen"; + +/// Id-shaped strings read out of one answer before the scan stops. +const ANSWER_ID_CANDIDATES: usize = 8192; + +/// How long a session must have made no call before what it saw is written +/// into the repository. A session that has ended is written at once. +#[cfg(not(test))] +const QUIET_BEFORE_WRITE: Duration = Duration::from_secs(20); +#[cfg(test)] +const QUIET_BEFORE_WRITE: Duration = Duration::from_millis(150); + +/// How long a sighting that could not be written is held to try again. +const UNWRITTEN_SIGHTING_KEPT: Duration = Duration::from_secs(600); + +/// How often the task that writes sightings looks again. +#[cfg(not(test))] +const SETTLE_TICK: Duration = Duration::from_secs(5); +#[cfg(test)] +const SETTLE_TICK: Duration = Duration::from_millis(40); + +/// Who a transaction that only records which agents saw a flag is by. +const SIGHTING_ACTOR: &str = "kin-daemon:review-flag-sightings"; + +/// Commit one planned write to repository authority, then apply it to the +/// live graph. The caller holds the coordination gate and a graph-authority +/// mutation guard. +fn commit( + state: &DaemonState, + write: &FlagWrite, + action: &str, + actor: &str, +) -> Result<(), (StatusCode, String)> { + use crate::review_write::ReviewWriteRefusal; + let failed = |detail: String| (StatusCode::INTERNAL_SERVER_ERROR, detail); + let delta = write + .to_delta() + .map_err(|error| failed(format!("{error:#}")))?; + crate::review_write::commit_collaboration_records(state, delta, action, actor, |graph| { + write.apply_to(graph).map_err(|error| format!("{error:#}")) + }) + .map(drop) + .map_err(|refusal| { + let status = match &refusal { + ReviewWriteRefusal::Hosted | ReviewWriteRefusal::Commit { conflict: true, .. } => { + StatusCode::CONFLICT + } + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, refusal.to_string()) + }) +} + +/// Add the sightings still held in memory to the flags a reader is shown, so +/// an agent that was handed a flag a moment ago is already named. +fn show_pending(shown: &mut [ReviewFlagView], pending: &[FlagSighting]) { + for (flag_id, sighting) in pending { + let flag_id = flag_id.to_string(); + let session = sighting.session.to_string(); + let Some(flag) = shown.iter_mut().find(|flag| flag.flag_id == flag_id) else { + continue; + }; + if flag.seen_by.iter().all(|seen| seen.session != session) { + flag.seen_by.push(ReviewFlagSeen { + agent: sighting.agent.clone(), + session, + at: sighting.at.clone(), + }); + } + } +} + +/// POST /review/flags: set, close or list review flags in the repo daemon. +pub(crate) async fn route( + State(state): State>, + Json(request): Json, +) -> Result, (StatusCode, String)> { + if !state + .is_initialized + .load(std::sync::atomic::Ordering::Relaxed) + { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "daemon not fully initialized".to_string(), + )); + } + let mutates = request.mutates(); + let _coordinated = if mutates { + Some(state.coordination_gate.lock().await) + } else { + None + }; + state + .prepared_publication + .ensure_serving() + .map_err(crate::prepared_publication::write_refusal)?; + let _mutation = mutates.then(|| state.begin_graph_authority_mutation()); + let graph = state.graph.as_ref(); + let activity = state.coordinator.activity(); + let mut planned = flags::plan(graph, request) + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, format!("{error:#}")))?; + if !planned.write.is_empty() { + // A flag being closed, explicitly or by replacement, first takes + // its pending sightings. The replacement has a new identity and + // starts unseen while the old record keeps everyone it reached. + let written: Vec = planned + .write + .annotations + .iter() + .map(|annotation| annotation.annotation_id) + .collect(); + let held = activity.take_flag_sightings(|(flag, _)| written.contains(flag)); + for annotation in &mut planned.write.annotations { + let Some(flag) = annotation + .review + .as_mut() + .filter(|flag| flag.resolution.is_some()) + else { + continue; + }; + for (_, sighting) in held + .iter() + .filter(|(id, _)| *id == annotation.annotation_id) + { + flag.record_sighting(sighting.clone()); + } + } + if planned.action == flags::RESOLVE_ACTION { + if let Some(annotation) = planned.write.annotations.first() { + planned.response.flag = flags::view(graph, annotation); + } + } + if let Err(refusal) = commit(&state, &planned.write, planned.action, &planned.actor) { + if activity.return_flag_sightings(held) { + start_settling(Arc::clone(&state)); + } + return Err(refusal); + } + } + let mut response = planned.response; + response.flags = response + .flags + .into_iter() + .filter_map(|shown| { + let id = Uuid::parse_str(&shown.flag_id).ok().map(AnnotationId)?; + let annotation = graph.get_annotation(&id).ok().flatten()?; + view(&state, &annotation) + }) + .collect(); + response.flag = response.flag.and_then(|shown| { + let id = Uuid::parse_str(&shown.flag_id).ok().map(AnnotationId)?; + let annotation = graph.get_annotation(&id).ok().flatten()?; + view(&state, &annotation) + }); + let pending = activity.pending_flag_sightings(); + show_pending(&mut response.flags, &pending); + if let Some(flag) = response.flag.as_mut() { + show_pending(std::slice::from_mut(flag), &pending); + } + Ok(Json(response)) +} + +/// Whether what `sighting`'s session saw may be written now: the session has +/// ended, or it has made no call for [`QUIET_BEFORE_WRITE`]. +fn is_quiet(state: &DaemonState, sighting: &ReviewFlagSighting, now: &Timestamp) -> bool { + let coordinator = &state.coordinator; + let live = coordinator + .get_session(&sighting.session) + .ok() + .flatten() + .is_some(); + if !live { + return true; + } + if coordinator.active_call_count(&sighting.session) > 0 { + return false; + } + let last_heard = coordinator + .activity() + .last_call_at(&sighting.session) + .unwrap_or_else(|| sighting.at.clone()); + now.0 + .signed_duration_since(last_heard.0) + .to_std() + .is_ok_and(|idle| idle >= QUIET_BEFORE_WRITE) +} + +/// Write into the repository every held sighting whose session has gone quiet +/// or ended, as one transaction. Returns how many sightings it took. +pub(crate) async fn settle_sightings(state: &Arc) -> usize { + if crate::agent_authorship::any_session_at_work(state) { + return 0; + } + let now = Timestamp::now(); + let activity = state.coordinator.activity(); + if !activity + .pending_flag_sightings() + .iter() + .any(|(_, sighting)| is_quiet(state, sighting, &now)) + { + return 0; + } + let _coordinated = state.coordination_gate.lock().await; + if state.prepared_publication.ensure_serving().is_err() { + // Not serving writes now. What is held stays held for the next look. + return 0; + } + if crate::agent_authorship::any_session_at_work(state) { + return 0; + } + let ready = activity.take_flag_sightings(|(_, sighting)| is_quiet(state, sighting, &now)); + if ready.is_empty() { + return 0; + } + let _mutation = state.begin_graph_authority_mutation(); + let written = flags::plan_sightings(state.graph.as_ref(), &ready) + .map_err(|error| format!("{error:#}")) + .and_then(|write| { + if write.is_empty() { + return Ok(()); + } + commit(state, &write, SIGHTING_ACTION, SIGHTING_ACTOR).map_err(|(_, detail)| detail) + }); + let taken = ready.len(); + if let Err(error) = written { + // Held again for the next look: a sighting is a record of something + // that happened, and one failed write does not unmake it. One that + // has failed for this long is let go, so a store that cannot take the + // write is not asked forever. + tracing::debug!(%error, "review flag sightings could not be written"); + let recent: Vec = ready + .into_iter() + .filter(|(_, sighting)| { + now.0 + .signed_duration_since(sighting.at.0) + .to_std() + .is_ok_and(|age| age < UNWRITTEN_SIGHTING_KEPT) + }) + .collect(); + activity.return_flag_sightings(recent); + } + taken +} + +/// Run until nothing is held, writing sightings as their sessions go quiet. +fn start_settling(state: Arc) { + tokio::spawn(async move { + loop { + tokio::time::sleep(SETTLE_TICK).await; + settle_sightings(&state).await; + if state.coordinator.activity().flag_sightings_settled() { + break; + } + } + }); +} + +/// Compare exact-source flags with the current entity span admitted in the +/// graph. Bytes are read only from its recorded content digest, never from a +/// filesystem projection, and only the resulting span digest leaves here. +fn view(state: &DaemonState, annotation: &Annotation) -> Option { + flags::view_with_source(state.graph.as_ref(), annotation, |entity| { + let span = entity.span.as_ref()?; + let blob = entity.metadata.extra.get("blob_hash")?.as_str()?; + let digest = kin_blobs::Hash256::from_hex(blob).ok()?; + let bytes = state.blobs.read(&digest).ok()?; + let body = bytes.get(span.start_byte..span.end_byte)?; + Some(kin_model::Hash256::from_bytes(kin_blobs::digest_bytes( + body, + ))) + }) +} + +fn notice(view: ReviewFlagView) -> FlagNotice { + FlagNotice { + entity: view.entity_id, + name: view.name, + note: view.note, + by: view.author, + at: view + .set_at + .0 + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + changed_since: view.changed_since, + source_unavailable: view.source_unavailable, + } +} + +/// The entity ids a finished call named: its request's whole-id arguments and +/// every id that stands alone in its answer. +fn named_by_call(targets: &[Uuid], result: &kin_mcp::ToolCallResult) -> HashSet { + let mut found = Vec::new(); + for block in &result.content { + let kin_mcp::ContentBlock::Text { text } = block; + crate::agent_activity::uuid_candidates(text, ANSWER_ID_CANDIDATES, &mut found); + } + targets.iter().copied().chain(found).collect() +} + +/// The open flags one finished agent call reaches, for the reply the agent +/// reads, and the record that its session was handed them. +/// +/// A call reaches the flags on the entities its request or its answer named, +/// and `status` reaches every open flag. The live graph is read whatever +/// graph the session's own scope selects: a flag is about the work in +/// progress, not about the commit a session is looking back at. +pub(crate) fn reached_by_call( + state: &Arc, + session_id: Option, + tool: &str, + targets: &[Uuid], + result: &kin_mcp::ToolCallResult, + flags_cursor: Option<&serde_json::Value>, +) -> Result { + if tool.is_empty() || crate::agent_activity::is_lifecycle_tool(tool) { + return Ok(Delivered::default()); + } + let listing = tool == LISTING_TOOL && result.is_error != Some(true); + let graph = state.graph.as_ref(); + let mut total = None; + let reached: Vec = if listing { + let open = flags::open_flags(graph); + total = Some(open.len()); + let offset = match flags_cursor { + None | Some(serde_json::Value::Null) => 0, + Some(serde_json::Value::String(cursor)) => { + let id = cursor + .strip_prefix("review-flags:") + .and_then(|id| Uuid::parse_str(id).ok()) + .ok_or_else(|| { + "Invalid flags_cursor; restart status without flags_cursor.".to_string() + })?; + open.iter() + .position(|annotation| annotation.annotation_id.0 == id) + .map(|index| index + 1) + .ok_or_else(|| { + "Stale flags_cursor: its flag was closed or replaced; restart status \ + without flags_cursor." + .to_string() + })? + } + Some(_) => return Err("flags_cursor must be a string.".to_string()), + }; + open.into_iter().skip(offset).collect() + } else { + // Nothing is read out of the answer unless a flag is open. + if graph.open_review_flags().is_empty() { + return Ok(Delivered::default()); + } + flags::reached_by(graph, &named_by_call(targets, result)) + }; + let limit = if listing { + LISTING_FLAGS_MAX + } else { + ANSWER_FLAGS_MAX + }; + let more = reached.len().saturating_sub(limit); + let handed: Vec<&Annotation> = reached.iter().take(limit).collect(); + let delivered = Delivered { + flags: handed + .iter() + .filter_map(|annotation| view(state, annotation)) + .map(notice) + .collect(), + more, + total, + next_cursor: (listing && more > 0) + .then(|| format!("review-flags:{}", handed.last().unwrap().annotation_id)), + listing, + }; + if let Some(session_id) = session_id { + note_seen(state, session_id, &handed); + } + Ok(delivered) +} + +/// Hold that `session_id` was handed `handed`, for the flags that do not +/// record it yet, and make sure the task that writes them is running. +fn note_seen(state: &Arc, session_id: SessionId, handed: &[&Annotation]) { + let unseen: Vec = handed + .iter() + .filter(|annotation| { + annotation + .review + .as_ref() + .is_some_and(|flag| flag.seen_by.iter().all(|seen| seen.session != session_id)) + }) + .map(|annotation| annotation.annotation_id) + .collect(); + if unseen.is_empty() { + return; + } + // Only a session the daemon holds is an agent it can name. + let Ok(Some(session)) = state.coordinator.get_session(&session_id) else { + return; + }; + let sighting = ReviewFlagSighting { + agent: flags::agent_name(&session.vendor, &session.client_name), + session: session_id, + at: Timestamp::now(), + }; + if state + .coordinator + .activity() + .note_flag_sightings(&unseen, &sighting) + { + start_settling(Arc::clone(state)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use kin_db::EntityStore; + use kin_model::session::{SessionCapabilities, SessionTransport}; + use tower::ServiceExt; + + /// A daemon over a real store holding two entities. + fn daemon() -> ( + tempfile::TempDir, + Arc, + kin_model::Entity, + kin_model::Entity, + ) { + kin_core::test_env::install_test_registry(); + let dir = tempfile::tempdir().unwrap(); + let layout = kin_core::init(dir.path()).unwrap().layout; + let state = Arc::new(DaemonState::open(layout).unwrap()); + state + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let send = entity("ServerReply.send", 0x01); + let serialize = entity("serialize", 0x04); + state.graph.upsert_entity(&send).unwrap(); + state.graph.upsert_entity(&serialize).unwrap(); + (dir, state, send, serialize) + } + + fn entity(name: &str, ast: u8) -> kin_model::Entity { + kin_model::Entity { + id: kin_model::EntityId::new(), + kind: kin_model::EntityKind::Method, + name: name.to_string(), + language: kin_model::LanguageId::TypeScript, + fingerprint: kin_model::SemanticFingerprint { + algorithm: kin_model::FingerprintAlgorithm::V1TreeSitter, + ast_hash: kin_model::Hash256::from_bytes([ast; 32]), + signature_hash: kin_model::Hash256::from_bytes([0x02; 32]), + behavior_hash: kin_model::Hash256::from_bytes([0x03; 32]), + equivalence_hash: kin_model::Hash256::from_bytes([0; 32]), + stability_score: 1.0, + }, + file_origin: Some(kin_model::FilePathId::new("src/reply.ts")), + span: Some(kin_model::SourceSpan { + file: kin_model::FilePathId::new("src/reply.ts"), + start_byte: 0, + end_byte: 0, + start_line: 1, + start_col: 1, + end_line: 1, + end_col: 20, + }), + signature: format!("{name}(payload: Payload): boolean"), + visibility: kin_model::Visibility::Public, + role: kin_model::EntityRole::Source, + doc_summary: None, + metadata: Default::default(), + lineage_parent: None, + created_in: None, + superseded_by: None, + } + } + + async fn post( + state: &Arc, + path: &str, + session: Option, + body: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + let mut request = Request::post(path).header("content-type", "application/json"); + if let Some(session) = session { + request = request.header("X-Kin-Session", session.to_string()); + } + let response = crate::api::router(Arc::clone(state)) + .oneshot(request.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .unwrap(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null), + ) + } + + async fn flag(state: &Arc, entity: &kin_model::Entity, note: &str) { + let (status, answer) = post( + state, + "/review/flags", + None, + serde_json::json!({ + "op": "set", "entity": entity.id, "note": note, + "fingerprint": format!("behavior:{}", entity.fingerprint.behavior_hash), + "operation_id": kin_model::OperationId::new(), "author": "Ada Reviewer", + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!(answer["outcome"], "flagged", "{answer}"); + } + + async fn listed(state: &Arc) -> Vec { + let (status, answer) = post( + state, + "/review/flags", + None, + serde_json::json!({"op": "list"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!(answer["outcome"], "listed"); + answer["flags"].as_array().unwrap().clone() + } + + /// Call a tool the way the server an agent talks to does. + async fn call( + state: &Arc, + session: Option, + tool: &str, + arguments: serde_json::Value, + ) -> serde_json::Value { + let (status, answer) = post( + state, + "/mcp/tools/call", + session, + serde_json::json!({"name": tool, "arguments": arguments}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + answer + } + + fn agent(state: &Arc, vendor: &str, client: &str) -> SessionId { + state + .coordinator + .register_session_with_id( + SessionId::new(), + vendor, + client, + SessionTransport::Mcp, + None, + std::env::temp_dir(), + SessionCapabilities::default(), + ) + .unwrap() + } + + #[tokio::test] + async fn source_flags_compare_only_the_current_recorded_entity_span() { + let (_dir, state, mut send, _) = daemon(); + let body = "send() { return true; }"; + let stamp = |entity: &mut kin_model::Entity, prefix: &str, body: &str| { + let bytes = format!("{prefix}{body}"); + let blob = state.blobs.write(bytes.as_bytes()).unwrap(); + entity + .metadata + .extra + .insert("blob_hash".into(), serde_json::json!(blob.to_string())); + let span = entity.span.as_mut().unwrap(); + span.start_byte = prefix.len(); + span.end_byte = bytes.len(); + state.graph.upsert_entity(entity).unwrap(); + }; + stamp(&mut send, "// sibling\n", body); + let fingerprint = format!("source:{}", kin_blobs::digest(body.as_bytes())); + let (status, answer) = post( + &state, + "/review/flags", + None, + serde_json::json!({ + "op":"set", "entity":send.id, "note":"explain this", "fingerprint":fingerprint, + "operation_id":kin_model::OperationId::new(), "author":"Ada Reviewer", + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert_eq!(answer["flag"]["fingerprint"], fingerprint); + assert_eq!(answer["flag"]["changed_since"], false); + assert!(answer["flag"].get("source_unavailable").is_none()); + // Other source changes alter the blob and span without changing the body. + stamp(&mut send, "// a different sibling comment\n", body); + assert_eq!(listed(&state).await[0]["changed_since"], false); + // Comments inside the entity change its reviewed bytes even though + // its AST and behavior fingerprints in this fixture stay identical. + stamp( + &mut send, + "// sibling\n", + "send() { /* explained */ return true; }", + ); + assert_eq!(listed(&state).await[0]["changed_since"], true); + send.metadata.extra.remove("blob_hash"); + state.graph.upsert_entity(&send).unwrap(); + assert_eq!(listed(&state).await[0]["source_unavailable"], true); + } + + #[tokio::test] + async fn flags_and_agent_sightings_reopen_from_repository_authority() { + let (_dir, state, send, _) = daemon(); + flag(&state, &send, "a second send must fail loudly").await; + let session = agent(&state, "codex-mcp-client", "codex-mcp-client"); + call( + &state, + Some(session), + "get_entity", + serde_json::json!({"entity_id": send.id}), + ) + .await; + let before = listed(&state).await; + state.coordinator.deregister_session(&session).unwrap(); + assert_eq!(settle_sightings(&state).await, 1); + let reopened = Arc::new(DaemonState::open(state.layout.clone()).unwrap()); + reopened + .is_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + let after = listed(&reopened).await; + assert_eq!(after.len(), 1); + assert_eq!(after[0]["flag_id"], before[0]["flag_id"]); + assert_eq!(after[0]["note"], before[0]["note"]); + assert_eq!(after[0]["seen_by"], before[0]["seen_by"]); + assert_eq!(after[0]["seen_by"][0]["agent"], "Codex"); + assert_eq!(after[0]["seen_by"][0]["session"], session.to_string()); + } + + #[tokio::test] + async fn the_route_sets_lists_and_closes_a_flag_in_the_live_graph() { + let (_dir, state, send, _) = daemon(); + assert!(listed(&state).await.is_empty()); + flag(&state, &send, "a second send must fail loudly").await; + assert!(state.is_dirty(), "a flag is a graph mutation that is saved"); + + let open = listed(&state).await; + assert_eq!(open.len(), 1); + assert_eq!(open[0]["entity_id"], serde_json::json!(send.id)); + assert_eq!(open[0]["name"], "ServerReply.send"); + assert_eq!(open[0]["note"], "a second send must fail loudly"); + assert_eq!(open[0]["author"], "Ada Reviewer"); + assert_eq!(open[0]["changed_since"], false); + assert_eq!(open[0]["seen_by"], serde_json::json!([])); + + // A request that cannot be met is answered in words and writes nothing. + let (status, refused) = post( + &state, + "/review/flags", + None, + serde_json::json!({ + "op": "set", "entity": kin_model::EntityId::new(), "fingerprint": "ab".repeat(32), + "operation_id": kin_model::OperationId::new(), "author": "Ada Reviewer", + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(refused["outcome"], "refused"); + assert!(refused["error"] + .as_str() + .unwrap() + .contains("not in the repository's graph")); + assert_eq!(listed(&state).await.len(), 1); + + let (status, closed) = post( + &state, + "/review/flags", + None, + serde_json::json!({ + "op": "resolve", "entity": send.id, + "operation_id": kin_model::OperationId::new(), "author": "Ada Reviewer", + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(closed["outcome"], "resolved"); + assert!(listed(&state).await.is_empty()); + } + + #[tokio::test] + async fn an_answer_that_returns_a_flagged_entity_carries_the_flag_beside_it() { + let (_dir, state, send, serialize) = daemon(); + // With no flag open, an answer is exactly what it was. + let plain = call( + &state, + None, + "get_entity", + serde_json::json!({"entity_id": send.id}), + ) + .await; + assert!(plain.get("_meta").is_none(), "{plain}"); + + flag(&state, &send, "a second send must fail loudly").await; + let mut answered = call( + &state, + None, + "get_entity", + serde_json::json!({"entity_id": send.id}), + ) + .await; + let reached = kin_mcp::review_flags::take(&mut answered).expect("the flag travels"); + assert!(!reached.listing); + assert_eq!(reached.more, 0); + assert_eq!(reached.flags.len(), 1); + assert_eq!(reached.flags[0].entity, send.id.to_string()); + assert_eq!(reached.flags[0].name, "ServerReply.send"); + assert_eq!(reached.flags[0].note, "a second send must fail loudly"); + assert_eq!(reached.flags[0].by, "Ada Reviewer"); + assert!( + reached.flags[0].at.ends_with('Z'), + "{}", + reached.flags[0].at + ); + assert!(!reached.flags[0].changed_since); + // The answer itself is untouched: the flag is beside it. + assert_eq!(answered["content"], plain["content"]); + assert_eq!( + kin_mcp::review_flags::advice(&reached).unwrap(), + "A reviewer flagged ServerReply.send: a second send must fail loudly. Address it \ + before committing." + ); + + // An answer about another entity reaches no flag. + let other = call( + &state, + None, + "get_entity", + serde_json::json!({"entity_id": serialize.id}), + ) + .await; + assert!(other.get("_meta").is_none(), "{other}"); + + // Once the entity changes, the flag says so. + let mut edited = send.clone(); + edited.fingerprint.behavior_hash = kin_model::Hash256::from_bytes([0x09; 32]); + state.graph.upsert_entity(&edited).unwrap(); + let mut answered = call( + &state, + None, + "get_entity", + serde_json::json!({"entity_id": send.id}), + ) + .await; + let reached = kin_mcp::review_flags::take(&mut answered).unwrap(); + assert!(reached.flags[0].changed_since); + } + + #[tokio::test] + async fn status_answers_with_every_open_flag_and_nothing_when_there_are_none() { + let (_dir, state, send, serialize) = daemon(); + let mut answered = call(&state, None, "kin_graph_status", serde_json::json!({})).await; + assert_eq!(kin_mcp::review_flags::take(&mut answered), None); + + flag(&state, &send, "first").await; + flag(&state, &serialize, "second").await; + let mut answered = call(&state, None, "kin_graph_status", serde_json::json!({})).await; + let all = kin_mcp::review_flags::take(&mut answered).unwrap(); + assert!(all.listing); + assert_eq!( + all.flags + .iter() + .map(|flag| flag.name.as_str()) + .collect::>(), + vec!["ServerReply.send", "serialize"], + "oldest first" + ); + } + + #[tokio::test] + async fn a_session_handed_a_flag_is_named_at_once_and_written_when_it_goes_quiet() { + let (_dir, state, send, _) = daemon(); + flag(&state, &send, "a second send must fail loudly").await; + let session = agent(&state, "codex-mcp-client", "codex-mcp-client"); + + // A call with no session names nobody. + call( + &state, + None, + "get_entity", + serde_json::json!({"entity_id": send.id}), + ) + .await; + assert_eq!(listed(&state).await[0]["seen_by"], serde_json::json!([])); + + call( + &state, + Some(session), + "get_entity", + serde_json::json!({"entity_id": send.id}), + ) + .await; + // Named at once, from what the daemon holds. + let seen = listed(&state).await[0]["seen_by"].clone(); + assert_eq!(seen.as_array().unwrap().len(), 1, "{seen}"); + assert_eq!(seen[0]["agent"], "Codex"); + assert_eq!(seen[0]["session"], serde_json::json!(session.to_string())); + assert!(seen[0]["at"].is_string()); + // And named once, however often the session reads the entity. + call( + &state, + Some(session), + "get_entity", + serde_json::json!({"entity_id": send.id}), + ) + .await; + assert_eq!( + listed(&state).await[0]["seen_by"].as_array().unwrap().len(), + 1 + ); + + // Written into the flag itself once the session has gone quiet, so it + // outlives the session and the daemon. + let stored = || { + flags::open_flags(state.graph.as_ref())[0] + .review + .clone() + .unwrap() + .seen_by + }; + let mut waited = Duration::ZERO; + while stored().is_empty() && waited < Duration::from_secs(5) { + tokio::time::sleep(Duration::from_millis(25)).await; + waited += Duration::from_millis(25); + } + let written = stored(); + assert_eq!(written.len(), 1, "the sighting was never written"); + assert_eq!(written[0].session, session); + assert_eq!(written[0].agent, "Codex"); + assert!(state + .coordinator + .activity() + .pending_flag_sightings() + .is_empty()); + state.coordinator.deregister_session(&session).unwrap(); + let seen = listed(&state).await[0]["seen_by"].clone(); + assert_eq!(seen.as_array().unwrap().len(), 1, "{seen}"); + assert_eq!(seen[0]["session"], serde_json::json!(session.to_string())); + } + + #[tokio::test] + async fn closing_a_flag_keeps_who_saw_it_and_a_new_flag_starts_unseen() { + let (_dir, state, send, _) = daemon(); + flag(&state, &send, "first").await; + let session = agent(&state, "claude-code", "claude-code"); + call( + &state, + Some(session), + "kin_graph_status", + serde_json::json!({}), + ) + .await; + assert_eq!( + listed(&state).await[0]["seen_by"][0]["agent"], + "Claude Code" + ); + + // Flagged again with something new to say, nobody has seen it. + flag(&state, &send, "second").await; + let open = listed(&state).await; + assert_eq!(open.len(), 1); + assert_eq!(open[0]["note"], "second"); + assert_eq!(open[0]["seen_by"], serde_json::json!([])); + + call( + &state, + Some(session), + "get_entity", + serde_json::json!({"entity_id": send.id}), + ) + .await; + let (_, closed) = post( + &state, + "/review/flags", + None, + serde_json::json!({ + "op": "resolve", "entity": send.id, + "operation_id": kin_model::OperationId::new(), "author": "Ada Reviewer", + }), + ) + .await; + assert_eq!(closed["outcome"], "resolved"); + assert_eq!(closed["flag"]["seen_by"][0]["agent"], "Claude Code"); + assert!(state + .coordinator + .activity() + .pending_flag_sightings() + .is_empty()); + } + + #[tokio::test] + async fn status_flag_pages_reach_every_note_and_stale_pages_name_no_new_sightings() { + let (_dir, state, send, serialize) = daemon(); + let mut entities = vec![send, serialize]; + for index in 2..(LISTING_FLAGS_MAX + 2) { + let entity = entity(&format!("flagged_{index}"), index as u8); + state.graph.upsert_entity(&entity).unwrap(); + entities.push(entity); + } + for (index, entity) in entities.iter().enumerate() { + flag(&state, entity, &format!("review note {index}")).await; + } + let session = agent(&state, "codex-mcp-client", "codex-mcp-client"); + let mut first = call( + &state, + Some(session), + "kin_graph_status", + serde_json::json!({}), + ) + .await; + let first = kin_mcp::review_flags::take(&mut first).unwrap(); + assert_eq!(first.flags.len(), LISTING_FLAGS_MAX); + assert_eq!(first.more, 2); + assert_eq!(first.total, Some(entities.len())); + let cursor = first.next_cursor.unwrap(); + let first_seen = listed(&state) + .await + .into_iter() + .filter(|flag| !flag["seen_by"].as_array().unwrap().is_empty()) + .count(); + assert_eq!(first_seen, LISTING_FLAGS_MAX); + + let mut next = call( + &state, + Some(session), + "kin_graph_status", + serde_json::json!({ + "flags_cursor": cursor, + }), + ) + .await; + let next = kin_mcp::review_flags::take(&mut next).unwrap(); + assert_eq!(next.flags.len(), 2); + assert_eq!(next.more, 0); + assert_eq!(next.total, Some(entities.len())); + assert!(next.next_cursor.is_none()); + let reached: std::collections::HashSet<_> = first + .flags + .iter() + .chain(&next.flags) + .map(|flag| flag.entity.clone()) + .collect(); + assert_eq!(reached.len(), entities.len()); + + let last = Uuid::parse_str(&first.flags.last().unwrap().entity).unwrap(); + let last = entities.iter().find(|entity| entity.id.0 == last).unwrap(); + flag(&state, last, "a replacement nobody has seen").await; + let mut stale = call( + &state, + Some(session), + "kin_graph_status", + serde_json::json!({ + "flags_cursor": cursor, + }), + ) + .await; + assert_eq!(stale["isError"], true, "{stale}"); + assert!( + stale["content"][0]["text"] + .as_str() + .unwrap() + .contains("Stale flags_cursor"), + "{stale}" + ); + assert!(kin_mcp::review_flags::take(&mut stale).is_none()); + let listed = listed(&state).await; + let replacement = listed + .iter() + .find(|flag| flag["entity_id"] == last.id.to_string()) + .unwrap(); + assert_eq!(replacement["seen_by"], serde_json::json!([])); + + let invalid = call( + &state, + Some(session), + "kin_graph_status", + serde_json::json!({ + "flags_cursor": 7, + }), + ) + .await; + assert_eq!(invalid["isError"], true, "{invalid}"); + assert!(invalid["content"][0]["text"] + .as_str() + .unwrap() + .contains("flags_cursor must be a string")); + } +} diff --git a/crates/kin-daemon/src/review_write.rs b/crates/kin-daemon/src/review_write.rs index f0e1a0fdb..0fd81bb99 100644 --- a/crates/kin-daemon/src/review_write.rs +++ b/crates/kin-daemon/src/review_write.rs @@ -158,6 +158,66 @@ pub(crate) fn commit_review_event( Ok(planned.answer) } +/// Commit collaboration records that are not a review event, a reviewer's flag +/// on an entity and the record of who saw it, as one repository transaction, +/// then make the live graph hold them with `apply`. +/// +/// The same order as [`commit_review_event`], for the same reason: authority +/// holds the records before the graph shows them, so a daemon that reopens the +/// store serves every flag this one answered with. The caller holds +/// `coordination_gate` and a graph-authority mutation guard for the whole call. +/// Returns the authority generation the records were committed at. +pub(crate) fn commit_collaboration_records( + state: &DaemonState, + delta: CollaborationDelta, + reason: &str, + actor_label: &str, + apply: impl FnOnce(&kin_db::InMemoryGraph) -> Result<(), String>, +) -> Result> { + if state.storage_backend.is_some() { + return Err(ReviewWriteRefusal::Hosted); + } + let _persistence = state + .persist_lock + .lock() + .map_err(|_| ReviewWriteRefusal::Authority("daemon persistence lock poisoned".into()))?; + let authority = ActiveLocalRepositoryAuthority::open_bound(state) + .map_err(|refusal| ReviewWriteRefusal::Authority(format!("{:#}", refusal.into_error())))?; + let receipt = commit_collaboration( + state, + &authority, + delta, + reason, + &AuthorId::new(actor_label.to_string()), + )?; + + // Durable from here, as in `commit_review_event`. + let diverged = |detail: String| ReviewWriteRefusal::Diverged { + generation: receipt.generation, + detail, + }; + state + .record_repository_authority_commit(receipt.generation) + .map_err(|error| diverged(error.to_string()))?; + apply(state.graph.as_ref()).map_err(diverged)?; + crate::binding_history::restore_review_publication(state, &authority) + .map_err(|error| diverged(error.to_string()))?; + + state.bump_version(); + state.mark_dirty(); + state.emit_event(DaemonEvent::GraphRootChanged { + old_root_hash: None, + new_root_hash: "review-flag-state".to_string(), + }); + state.emit_event(DaemonEvent::RepositoryAuthorityChanged { + repository_id: receipt.repository_id.to_string(), + operation_id: receipt.operation_id, + previous_generation: receipt.roots_before.generation, + new_generation: receipt.generation, + }); + Ok(receipt.generation) +} + /// Commit one MCP review write tool through [`commit_review_event`]. /// /// The dispatch that calls this already holds the coordination gate and a @@ -298,7 +358,9 @@ impl ReviewPublication { /// Unknown collaboration records can retry once against fresh roots as before. /// A checked observation cannot: an intervening writer changed its authority, /// so the whole operation must be retried from an actually observed state. -fn commit_collaboration( +/// +/// Shared with the verification writer, which commits its runs the same way. +pub(crate) fn commit_collaboration( state: &DaemonState, authority: &ActiveLocalRepositoryAuthority, delta: CollaborationDelta, diff --git a/crates/kin-daemon/src/session_registry.rs b/crates/kin-daemon/src/session_registry.rs index bfe93c183..c13502e59 100644 --- a/crates/kin-daemon/src/session_registry.rs +++ b/crates/kin-daemon/src/session_registry.rs @@ -82,6 +82,15 @@ pub struct TrafficCheck { /// restart. Deployments that run many PID-less sessions against one daemon and /// would rather take the mid-task reaping risk can lower the window with /// [`SESSION_IDLE_TTL_ENV`]. +/// +/// A session opened only to read, as an MCP connection's is when its client +/// initializes, names no PID on purpose: a live PID would keep a connection +/// that never does anything for as long as its process runs. It may instead +/// name a process to watch ([`SessionCoordinator::watch_process_exit`]). That +/// process proves nothing while it runs, so the session still ages out on this +/// window, but once it is gone the session is reaped at the next sweep, the +/// way a session whose PID died is. A server killed outright therefore stops +/// holding its session within one sweep rather than for this whole window. pub const DEFAULT_SESSION_IDLE_TTL: Duration = Duration::from_secs(1800); /// Environment override for [`DEFAULT_SESSION_IDLE_TTL`], in whole seconds. @@ -147,6 +156,17 @@ impl Drop for ActiveCall { } } +/// Where one finished call was recorded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FinishedCall { + /// On the live session's log, as this entry. + Live(crate::agent_activity::ToolCallEntry), + /// On what was kept of the session, which ended while the call ran. + AfterEnd, + /// Nowhere: the daemon holds no such session and kept nothing of one. + Unknown, +} + pub struct SessionCoordinator { graph: Arc, /// Linearization point for session/intent lifecycle mutations. `kin-db`'s @@ -165,6 +185,10 @@ pub struct SessionCoordinator { /// log, the changes it committed, and the throttle on the events that /// announce them. Dropped with the session. activity: crate::agent_activity::AgentActivity, + /// Where what a session did is kept once it ends: the record of what + /// agents did, which repository authority holds once + /// [`SessionCoordinator::keep_ended_sessions_in`] names the daemon's own. + ended: Arc, /// When each live session was last heard from, kept here rather than in /// its record. /// @@ -179,6 +203,13 @@ pub struct SessionCoordinator { /// what the record holds, and a client whose session it lost opens another /// on its next call. liveness: Mutex>, + /// The process each PID-less session watches: once it is gone the session + /// is reaped, and while it runs it keeps nothing alive. See + /// [`SessionCoordinator::watch_process_exit`]. + /// + /// In memory, like liveness. A daemon that restarts forgets the watch and + /// leaves the session on its idle window, which is what it had before. + exit_watch: Mutex>, } impl SessionCoordinator { @@ -197,6 +228,9 @@ impl SessionCoordinator { /// Create a coordinator with an explicit session idle TTL. pub fn with_session_idle_ttl(graph: Arc, ttl: Duration) -> Self { + let ended = Arc::new(crate::agent_authorship::AgentAuthorship::new(Arc::clone( + &graph, + ))); Self { graph, arbitration: Mutex::new(()), @@ -204,7 +238,9 @@ impl SessionCoordinator { active_calls: Arc::new(Mutex::new(HashMap::new())), next_call_id: AtomicU64::new(0), activity: crate::agent_activity::AgentActivity::default(), + ended, liveness: Mutex::new(HashMap::new()), + exit_watch: Mutex::new(HashMap::new()), } } @@ -223,12 +259,46 @@ impl SessionCoordinator { session } - /// Drop the liveness kept for a session that ended. + /// Drop the liveness and the process watch kept for a session that ended. fn forget_liveness(&self, session_id: &SessionId) { self.liveness .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .remove(session_id); + self.exit_watch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(session_id); + } + + /// The process a PID-less session watches, if it watches one. + fn watched_process(&self, session_id: &SessionId) -> Option { + self.exit_watch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(session_id) + .copied() + } + + /// Whether the process a session lives by, or the one it watches, is gone, + /// so the next sweep reaps it. A session with a call running here is not + /// judged, as the sweep does not judge it. + pub fn process_gone(&self, session: &AgentSession) -> bool { + if self.active_call_count(&session.session_id) > 0 { + return false; + } + session + .pid + .or_else(|| self.watched_process(&session.session_id)) + .is_some_and(|pid| !is_process_alive(pid)) + } + + /// How many sessions watch a process. + pub fn watched_processes(&self) -> usize { + self.exit_watch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() } /// Make a session look last heard from at `at`, in its record and in the @@ -257,6 +327,124 @@ impl SessionCoordinator { &self.activity } + /// Keep what the sessions that end did in `record`, the daemon's record + /// of what agents did. + pub fn keep_ended_sessions_in( + &mut self, + record: Arc, + ) { + self.ended = record; + } + + /// The record that keeps what ended sessions did. + pub fn ended_sessions(&self) -> &crate::agent_authorship::AgentAuthorship { + &self.ended + } + + /// Whether the daemon holds `session_id` as a live session. + fn holds(&self, session_id: &SessionId) -> bool { + self.get_session(session_id).ok().flatten().is_some() + } + + /// Record one finished call for `session_id`: on its live log while the + /// session lives, and otherwise, when the session was live as the call + /// began, on what was kept of the session when it ended. + /// + /// An agent that gives up on a slow commit closes its connection, and its + /// session ends while the commit runs on to land. Recorded only on the + /// live log, that commit was nowhere in the session's calls although its + /// change was. Ending a session deletes it and hands its log over in one + /// step under `arbitration`, so a call that finds the session gone waits + /// for that step and is then kept after the calls the log handed over. + pub fn record_finished_call( + &self, + session_id: &SessionId, + draft: crate::agent_activity::ToolCallDraft, + at: Timestamp, + began_live: bool, + ) -> FinishedCall { + let live = || self.holds(session_id); + if let Some(entry) = + self.activity + .record_call_if(session_id, draft.clone(), at.clone(), live) + { + return FinishedCall::Live(entry); + } + if !began_live { + return FinishedCall::Unknown; + } + let _arbitration = self + .arbitration + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(entry) = + self.activity + .record_call_if(session_id, draft.clone(), at.clone(), live) + { + return FinishedCall::Live(entry); + } + if self + .ended + .record_after_end(&session_id.to_string(), &draft, &at) + { + FinishedCall::AfterEnd + } else { + FinishedCall::Unknown + } + } + + /// Remember a change `session_id` committed: on its live log, or on what + /// was kept of the session when the commit landed after it ended. A + /// session the daemon never held is given no log for it. + pub fn record_committed_change(&self, session_id: &SessionId, change_id: String) { + if self.holds(session_id) { + self.activity.record_change(session_id, change_id); + return; + } + let _arbitration = self + .arbitration + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.holds(session_id) { + self.activity.record_change(session_id, change_id); + } else { + self.ended + .record_change_after_end(&session_id.to_string(), &change_id); + } + } + + /// Drop the log of a session that ends, and keep what it held when the + /// session did any work: an agent that connected and asked nothing has + /// nothing to show. The daemon's own session is not an agent. + fn end_activity(&self, session: Option<&AgentSession>, session_id: &SessionId) { + let log = self.activity.forget(session_id); + let Some(session) = session else { + return; + }; + if session.vendor == crate::agent_activity::DAEMON_OWN_VENDOR + || (log.calls_total == 0 && log.changes.is_empty()) + { + return; + } + let mut heard = session.started_at.clone(); + if session.last_heartbeat > heard { + heard = session.last_heartbeat.clone(); + } + let now = Timestamp::now(); + self.ended.hold_ended(crate::ended_sessions::EndedSession { + session_id: session.session_id.to_string(), + vendor: crate::agent_activity::display_name(&session.vendor, "unknown"), + client: crate::agent_activity::display_name(&session.client_name, "unnamed client"), + transport: crate::agent_activity::transport_name(session.transport).to_string(), + started_at: session.started_at.clone(), + last_activity_at: log.last_call_at.unwrap_or(heard), + ended_at: now, + calls_total: log.calls_total, + calls: log.calls, + change_ids: log.changes, + }); + } + /// How many calls the daemon is running for this session right now. pub fn active_call_count(&self, session_id: &SessionId) -> usize { self.active_calls @@ -480,6 +668,41 @@ impl SessionCoordinator { } } + /// Watch a process whose exit ends a session that names no PID. `None` + /// when the session does not exist; otherwise whether the watch is held. + /// + /// A watched process is evidence in one direction only. Once it is gone + /// the sweep reaps the session, as it reaps one whose PID died; while it + /// runs it keeps nothing alive, so the session still ages out on the idle + /// window. That is the difference from [`SessionCoordinator::attach_process`], + /// and the reason a session opened only to read uses this one: it ends + /// promptly when its server is killed outright, and a connection that + /// never does anything is still reaped by the idle rule while its server + /// runs. A session that already names a PID keeps judging by it and holds + /// no watch. + pub fn watch_process_exit(&self, session_id: &SessionId, pid: u32) -> Result> { + let _arbitration = self + .arbitration + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(session) = self + .graph + .get_session(session_id) + .map_err(DaemonError::from)? + else { + return Ok(None); + }; + if session.pid.is_some() { + return Ok(Some(false)); + } + self.exit_watch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(*session_id, pid); + debug!(session_id = %session_id, pid, "session watches its process for exit"); + Ok(Some(true)) + } + /// Record a heartbeat for a session. Returns an error if the session /// doesn't exist. pub fn heartbeat(&self, session_id: &SessionId) -> Result<()> { @@ -499,6 +722,7 @@ impl SessionCoordinator { .arbitration .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let ending = self.get_session(session_id)?; for intent in self.list_intents(session_id)? { self.graph .delete_intent(&intent.intent_id) @@ -507,7 +731,7 @@ impl SessionCoordinator { self.graph .delete_session(session_id) .map_err(DaemonError::from)?; - self.activity.forget(session_id); + self.end_activity(ending.as_ref(), session_id); self.forget_liveness(session_id); info!(session_id = %session_id, "deregistered agent session"); Ok(()) @@ -1240,6 +1464,21 @@ impl SessionCoordinator { is_stale = true; } + // A PID-less session's watched process can only end it. While it + // runs, the idle rule below still decides. + if session.pid.is_none() { + if let Some(pid) = self.watched_process(&session.session_id) { + if !is_process_alive(pid) { + debug!( + session_id = %session.session_id, + pid = pid, + "the process this session watches is gone" + ); + is_stale = true; + } + } + } + // Check heartbeat staleness. if let Some(age) = timestamp_age(&session.last_heartbeat, &now) { if age > stale_threshold { @@ -1282,7 +1521,7 @@ impl SessionCoordinator { self.graph .delete_session(&session.session_id) .map_err(DaemonError::from)?; - self.activity.forget(&session.session_id); + self.end_activity(Some(session), &session.session_id); self.forget_liveness(&session.session_id); warn!( session_id = %session.session_id, @@ -1740,6 +1979,8 @@ mod tests { duration_ms: 1, is_error: false, repo_id: None, + change_id: None, + caller_left: false, } } @@ -1802,6 +2043,102 @@ mod tests { assert!(coord.get_session(&sid).unwrap().is_some()); } + fn read_session(coord: &SessionCoordinator) -> SessionId { + coord + .register_session( + "claude-code", + "claude-code", + SessionTransport::Mcp, + None, + PathBuf::from("/"), + write_capabilities(), + ) + .unwrap() + } + + /// A session opened for reads watches its server's process. When that + /// process is killed outright the session is reaped at the next sweep, + /// long before its idle window would end it. + #[test] + fn a_read_session_whose_process_is_gone_is_reaped_at_the_next_sweep() { + let graph = Arc::new(kin_db::InMemoryGraph::new()); + let coord = SessionCoordinator::with_session_idle_ttl(graph, DEFAULT_SESSION_IDLE_TTL); + let sid = read_session(&coord); + // A process id no process holds, as a killed server's is. + assert_eq!( + coord.watch_process_exit(&sid, 999_999_999).unwrap(), + Some(true) + ); + assert_eq!(coord.watched_processes(), 1); + let reaped = coord.sweep_stale_sessions_detailed().unwrap(); + assert_eq!(reaped.len(), 1, "a fresh session whose process is gone"); + assert_eq!(reaped[0].0.session_id, sid); + assert!(coord.get_session(&sid).unwrap().is_none()); + assert_eq!(coord.watched_processes(), 0, "the watch goes with it"); + } + + /// A watched process that runs keeps nothing alive: a read session that + /// never does anything still ages out on the idle rule, which is why such + /// a session names no PID. + #[test] + fn a_live_idle_read_session_still_ages_out_on_the_idle_rule() { + let graph = Arc::new(kin_db::InMemoryGraph::new()); + let coord = SessionCoordinator::with_session_idle_ttl(graph, Duration::from_millis(40)); + let sid = read_session(&coord); + assert_eq!( + coord.watch_process_exit(&sid, std::process::id()).unwrap(), + Some(true) + ); + assert_eq!(coord.sweep_stale_sessions().unwrap(), 0, "fresh and alive"); + assert!(coord.get_session(&sid).unwrap().is_some()); + std::thread::sleep(Duration::from_millis(80)); + assert_eq!( + coord.sweep_stale_sessions().unwrap(), + 1, + "idle past the window although its process runs" + ); + assert!(coord.get_session(&sid).unwrap().is_none()); + assert_eq!(coord.watched_processes(), 0); + } + + /// Only a PID-less session holds a watch. One that names its process is + /// judged by that process, and a session the daemon does not hold is + /// answered as missing. + #[test] + fn only_a_session_without_a_process_watches_one() { + let graph = Arc::new(kin_db::InMemoryGraph::new()); + let coord = SessionCoordinator::with_session_idle_ttl(graph, Duration::from_millis(30)); + let sid = read_session(&coord); + coord.attach_process(&sid, std::process::id()).unwrap(); + assert_eq!( + coord.watch_process_exit(&sid, 999_999_999).unwrap(), + Some(false) + ); + assert_eq!(coord.watched_processes(), 0); + assert_eq!( + coord + .watch_process_exit(&SessionId::new(), 999_999_999) + .unwrap(), + None + ); + std::thread::sleep(Duration::from_millis(60)); + assert_eq!( + coord.sweep_stale_sessions().unwrap(), + 0, + "its live PID keeps it, as before" + ); + let ended = read_session(&coord); + coord + .watch_process_exit(&ended, std::process::id()) + .unwrap(); + coord.deregister_session(&ended).unwrap(); + assert_eq!( + coord.watched_processes(), + 0, + "an ended session's watch goes" + ); + } + /// Liveness is kept in memory, so refreshing it writes nothing into the /// graph and gives its next save no reason to be a full snapshot, while the /// idle reap still judges the session by it. diff --git a/crates/kin-daemon/src/state.rs b/crates/kin-daemon/src/state.rs index 201c63042..4764677e8 100644 --- a/crates/kin-daemon/src/state.rs +++ b/crates/kin-daemon/src/state.rs @@ -936,7 +936,9 @@ pub(crate) fn sync_directory_metadata(_path: &std::path::Path) -> std::io::Resul /// silently drop staged-but-uncommitted work — stdio-MCP and HTTP-MCP then /// behave identically across a restart, not just across HTTP calls. pub(crate) fn mcp_transactions_disk_path(layout: &KinLayout) -> std::path::PathBuf { - layout.root().join("mcp_transactions.json") + layout + .root() + .join(kin_core::history_attach::AGENT_TRANSACTIONS_RECORD) } /// Wall-clock cost of each blocking phase of a daemon open. @@ -1060,7 +1062,9 @@ fn mcp_transaction_recovery_error( } fn mcp_lifecycle_recovery_path(layout: &KinLayout) -> std::path::PathBuf { - layout.root().join("mcp_transactions.lifecycle.json") + layout + .root() + .join(kin_core::history_attach::AGENT_TRANSACTIONS_RECOVERY_RECORD) } #[derive(serde::Serialize, serde::Deserialize)] @@ -1970,6 +1974,31 @@ pub(crate) enum RequestGraphAuthority { SessionScope, } +/// The spine build a read started on a task of its own. +#[derive(Debug, Default)] +struct SpineBackgroundBuild { + active: Option>, + /// When the last one ended without a spine, as one does when a + /// repository moved while the others were read. + ended_without_spine_at: Option, +} + +/// How long a spine build that ended without a spine keeps a read from +/// starting another. Each opens every sibling repository's authority. +const SPINE_BACKGROUND_RETRY: Duration = Duration::from_secs(60); + +/// How long a read waits for the background spine build to land before +/// answering without cross-repository references. Short enough that the +/// workspace entity page's 20 s timeout still receives a response when +/// large siblings take minutes to open, while ordinary sibling sets finish +/// in place so the first reference read certifies. +const SPINE_BACKGROUND_READ_WAIT: Duration = Duration::from_secs(10); + +/// Why a read while the spine builds has no references from the other +/// repositories on this machine. +const SPINE_BUILDING_REASON: &str = "Kin is still reading the other repositories on this machine, \ + so references from them are not in this answer yet"; + /// Type of entity change for SSE events. #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -4406,6 +4435,16 @@ pub struct SweepLanguageSkip { pub reason: String, } +/// A client of another release asked this daemon to make way for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Replacement { + /// The release replacing this daemon, as every client is told it. + pub successor: String, + /// When the first request arrived. The background grace runs from here, + /// so a repeated request never extends it. + pub requested_at: Instant, +} + pub struct DaemonState { pub layout: KinLayout, pub graph: Arc, @@ -4482,6 +4521,17 @@ pub struct DaemonState { pub coordination_mode: std::sync::RwLock, /// Durable, release-attributed coordination event collector. pub coordination_events: CoordinationEventLog, + /// What agents did here: which entities each wrote through Kin, change + /// by change, the edits they made with their own tools, and what each + /// session did once it ended. Kept past the session, in repository + /// authority; shared with the session coordinator. + pub agent_authorship: Arc, + /// The guides of committed changes this daemon built, kept while what + /// they read holds still, and the builds running now. + pub(crate) committed_guides: crate::committed_guides::CommittedGuides, + /// Each unit's admission steps, for an agent client that reports an edit + /// it made with its own tools. + pub client_edits: crate::client_edits::ClientEdits, /// Runtime completeness signal for the durable collector. Citable runs /// must require zero; an append failure never masquerades as a recorded /// event merely because the product action itself completed. @@ -4754,6 +4804,9 @@ pub struct DaemonState { /// and its own repo is served, but a cross-repo surface is materializing. /// Clients must treat it as alive-and-waiting, never as a dead endpoint. spine_warming: AtomicBool, + /// The spine build a read started on a task of its own, when one did; see + /// [`DaemonState::acquire_spine_read_authority_for_read`]. + spine_background_build: Mutex, /// Deterministic blocking seam for concurrency and runtime-starvation /// regression tests. Production initialization has no injected hook. #[cfg(test)] @@ -4943,6 +4996,14 @@ pub struct DaemonState { /// needs it. The idle monitor then treats the window as zero, so the exit /// still waits on every gate an idle exit waits on. retirement_requested: AtomicBool, + /// Set once a client of another release has asked this daemon to make + /// way for it. Implies `retirement_requested`, and changes which gates + /// hold the exit: see `crate::daemon::retirement_blockers`. A watch rather + /// than a flag, so an event stream can end itself with the reason. + replacement: tokio::sync::watch::Sender>, + /// Set when this daemon stops serving so that its successor can attach the + /// complete history a worker staged. See [`crate::history_schedule`]. + history_handoff: AtomicBool, /// Number of API requests currently being handled. pub active_requests: AtomicU64, /// Channel for LSP enrichment messages (incremental or sweep). @@ -7688,7 +7749,13 @@ impl DaemonState { crate::traffic_adapter::CoordinatorTrafficChecker::new(Arc::clone(&graph)); reconciler.set_traffic_checker(Box::new(traffic_checker)); - let coordinator = SessionCoordinator::new(Arc::clone(&graph)); + // What agents did here, read from and kept in repository authority: + // the sessions that end are kept in it too. + let agent_authorship = Arc::new(crate::agent_authorship::AgentAuthorship::new(Arc::clone( + &graph, + ))); + let mut coordinator = SessionCoordinator::new(Arc::clone(&graph)); + coordinator.keep_ended_sessions_in(Arc::clone(&agent_authorship)); // Resume from the last persisted VFS version so kin-vfs clients // don't see a reset after daemon restart. @@ -7725,6 +7792,9 @@ impl DaemonState { kin_mcp::CoordinationEnforcementMode::from_env(), ), coordination_events, + agent_authorship, + committed_guides: Default::default(), + client_edits: crate::client_edits::ClientEdits::default(), coordination_event_persist_failures: AtomicU64::new( coordination_event_persist_failures, ), @@ -7808,6 +7878,7 @@ impl DaemonState { hosted_durable_spine_for_test: Mutex::new(None), spine_initialization: Mutex::new(()), spine_warming: AtomicBool::new(false), + spine_background_build: Mutex::new(SpineBackgroundBuild::default()), background_work: Arc::new(crate::background_work::BackgroundWorkSupervisor::default()), admission_runs: crate::repository_admit::AdmissionRuns::default(), #[cfg(test)] @@ -7845,6 +7916,8 @@ impl DaemonState { idle_timeout_ms: AtomicU64::new(0), idle_floor_leases: crate::idle_floor::IdleFloorLeases::default(), retirement_requested: AtomicBool::new(false), + replacement: tokio::sync::watch::channel(None).0, + history_handoff: AtomicBool::new(false), active_requests: AtomicU64::new(0), lsp_enrichment_tx: None, lsp_enrichment_enabled: false, @@ -8209,7 +8282,13 @@ impl DaemonState { crate::traffic_adapter::CoordinatorTrafficChecker::new(Arc::clone(&graph)); reconciler.set_traffic_checker(Box::new(traffic_checker)); - let coordinator = SessionCoordinator::new(Arc::clone(&graph)); + // What agents did here, read from and kept in repository authority: + // the sessions that end are kept in it too. + let agent_authorship = Arc::new(crate::agent_authorship::AgentAuthorship::new(Arc::clone( + &graph, + ))); + let mut coordinator = SessionCoordinator::new(Arc::clone(&graph)); + coordinator.keep_ended_sessions_in(Arc::clone(&agent_authorship)); let persisted_vfs_version = Self::load_persisted_vfs_version(&layout); @@ -8238,6 +8317,9 @@ impl DaemonState { kin_mcp::CoordinationEnforcementMode::from_env(), ), coordination_events, + agent_authorship, + committed_guides: Default::default(), + client_edits: crate::client_edits::ClientEdits::default(), coordination_event_persist_failures: AtomicU64::new( coordination_event_persist_failures, ), @@ -8321,6 +8403,7 @@ impl DaemonState { hosted_durable_spine_for_test: Mutex::new(None), spine_initialization: Mutex::new(()), spine_warming: AtomicBool::new(false), + spine_background_build: Mutex::new(SpineBackgroundBuild::default()), background_work: Arc::new(crate::background_work::BackgroundWorkSupervisor::default()), admission_runs: crate::repository_admit::AdmissionRuns::default(), #[cfg(test)] @@ -8358,6 +8441,8 @@ impl DaemonState { idle_timeout_ms: AtomicU64::new(0), idle_floor_leases: crate::idle_floor::IdleFloorLeases::default(), retirement_requested: AtomicBool::new(false), + replacement: tokio::sync::watch::channel(None).0, + history_handoff: AtomicBool::new(false), active_requests: AtomicU64::new(0), lsp_enrichment_tx: None, lsp_enrichment_enabled: false, @@ -9781,6 +9866,98 @@ impl DaemonState { } } + /// [`Self::acquire_spine_read_authority`] for a read that can answer + /// without the spine, saying what it lacks, as a reference read can. + /// + /// A local daemon builds its spine the first time a read needs it, from + /// this repository and every sibling registered on the machine, and each + /// sibling's whole persisted authority is opened and verified for it. The + /// read that asked used to wait for all of it. Measured on fastify beside + /// axum, fastapi and zod on a busy host, that took more than ten minutes, + /// and the entity page's references read, which gives up at 20 s, never + /// saw an answer: it said Kin could not read the references. So while no + /// spine is built and there are siblings to load, the build runs on a + /// blocking task of its own, one at a time, and the read is answered + /// without references from the other repositories, the way a deferred + /// spine is ([`kin_spine::DaemonSpine::Deferred`]). A read after the build + /// lands is answered with them. With no sibling the build reads this + /// repository alone and is as quick as before, so the read builds it. + pub(crate) async fn acquire_spine_read_authority_for_read( + self: &Arc, + ) -> Option> { + if self.storage_backend.is_none() + && !self.spine_disabled() + && self.spine.get().is_none() + && self.spine_peer_repositories() > 0 + { + let mut rx = self.build_spine_in_background()?; + let wait = self.spine_background_read_wait(); + if !wait.is_zero() { + let _ = tokio::time::timeout(wait, rx.wait_for(|done| *done)).await; + } + self.spine.get()?; + } + self.acquire_spine_read_authority().await + } + + fn spine_background_read_wait(&self) -> Duration { + #[cfg(test)] + if self + .spine_initialization_test_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + { + return Duration::ZERO; + } + SPINE_BACKGROUND_READ_WAIT + } + + /// Start the spine's first build on a blocking task, unless one is + /// running or the last ended without a spine less than + /// [`SPINE_BACKGROUND_RETRY`] ago. + fn build_spine_in_background(self: &Arc) -> Option> { + let mut build = self + .spine_background_build + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(active) = build.active.as_ref() { + return Some(active.clone()); + } + if build + .ended_without_spine_at + .is_some_and(|ended| ended.elapsed() < SPINE_BACKGROUND_RETRY) + { + return None; + } + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return None; + }; + let (tx, rx) = tokio::sync::watch::channel(false); + build.active = Some(rx.clone()); + self.record_spine_unavailable(SPINE_BUILDING_REASON.to_string()); + let state = Arc::clone(self); + drop(runtime.spawn_blocking(move || { + let started = Instant::now(); + let built = state.ensure_spine().is_some(); + info!( + built, + elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + siblings = state.spine_peer_repositories(), + "spine build a read started has ended" + ); + let mut build = state + .spine_background_build + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + build.active = None; + build.ended_without_spine_at = (!built).then(Instant::now); + drop(build); + let _ = tx.send(true); + })); + Some(rx) + } + /// Acquire one query authority that remains stable through the caller's /// read. Hosted and local writers use the same gate, so the proof cannot be /// invalidated in the gap between readiness and response construction. @@ -13728,8 +13905,12 @@ impl DaemonState { "frozen repository authority has no workspace {workspace_id}" ))) })?; + // Never searched: it is read for its tree, compared domain by domain + // with the live graph, and asked for its binding-history capability. + // A text index over it would index every entity to answer no query. let authority_graph = - kin_db::InMemoryGraph::from_snapshot(authority_snapshot).map_err(DaemonError::from)?; + kin_db::InMemoryGraph::from_snapshot_without_text_index(authority_snapshot) + .map_err(DaemonError::from)?; if authority_graph.resolved_tree() != *desired_tree { return Err(DaemonError::Graph(kin_db::KinDbError::StorageError( format!( @@ -14613,7 +14794,7 @@ impl DaemonState { .vector_checkpoint_authority_match .holds(generation, &live_tree) { - let authority_graph = self.load_committed_authority_graph(generation)?; + let (authority_graph, _) = self.load_committed_authority_graph(generation)?; // The reopen is linear in store size, and this path holds only // `persist_lock` while commit and reconcile exclude on the // coordination gate, so the live graph can move while it runs. @@ -14824,8 +15005,13 @@ impl DaemonState { ))); } - let authority_graph = self.load_committed_authority_graph(generation)?; - self.finalize_generation_from_graph(generation, authority_graph.as_ref())?; + let (authority_graph, workspace_matches_committed_base) = + self.load_committed_authority_graph(generation)?; + self.finalize_generation_from_graph( + generation, + authority_graph.as_ref(), + workspace_matches_committed_base, + )?; Ok(()) } @@ -14842,7 +15028,7 @@ impl DaemonState { /// Reopening the same authority here doubles graph memory and repeats /// remote snapshot I/O on every healthy restart. fn finalize_loaded_generation(&self, generation: u64) -> Result<()> { - self.finalize_generation_from_graph(generation, self.graph.as_ref()) + self.finalize_generation_from_graph(generation, self.graph.as_ref(), false) } /// A locate-only graph is intentionally incomplete for general-purpose @@ -14859,6 +15045,7 @@ impl DaemonState { &self, generation: u64, authority_graph: &kin_db::InMemoryGraph, + workspace_matches_committed_base: bool, ) -> Result<()> { // A live graph ahead of this immutable authority deliberately remains // unproven. Only exact semantic equality rebinds its admitted witness. @@ -14875,10 +15062,11 @@ impl DaemonState { } self.persisted_entity_count .store(persisted_entity_count, Ordering::SeqCst); - if live_matches_authority { + if live_matches_authority && workspace_matches_committed_base { // The exact witness restoration established this levelling. Read // both counts from its held authority, never from a live graph - // that an independent writer may have advanced during finalization. + // that an independent writer may have advanced during finalization, + // and never when the workspace holds uncommitted working-copy changes. self.record_durable_entity_count(persisted_entity_count); self.record_durable_relation_count(authority_graph.relation_count() as u64); } @@ -14893,7 +15081,7 @@ impl DaemonState { generation: u64, authority_graph: &kin_db::InMemoryGraph, ) -> Result<()> { - self.finalize_generation_from_graph(generation, authority_graph) + self.finalize_generation_from_graph(generation, authority_graph, true) } /// Retire the read index a committed generation left stale, and leave its @@ -16008,40 +16196,52 @@ impl DaemonState { Ok((combined, debts, retired)) } + /// The committed workspace graph a finalization or a vector checkpoint + /// compares against. Its readers take its tree, its counts, its entities + /// for the read index and its proof, and none of them searches it, so it is + /// built without a text index. Indexing every entity's text, and digesting + /// the graph to decide whether that index was current, was paid by the + /// flush that follows every edit for a graph dropped a moment later. fn load_committed_authority_graph( &self, generation: u64, - ) -> Result> { - let authority_graph = if let Some(backend) = &self.storage_backend { - match kin_db::load_recovered_snapshot(backend.as_ref(), &self.cached_repo_id) + ) -> Result<(Arc, bool)> { + let (authority_graph, workspace_matches_committed_base) = if let Some(backend) = + &self.storage_backend + { + let graph = match kin_db::load_recovered_snapshot( + backend.as_ref(), + &self.cached_repo_id, + ) .map_err(DaemonError::from)? - { - Some(recovered) => { - if recovered.generation != generation { + { + Some(recovered) => { + if recovered.generation != generation { + return Err(DaemonError::Graph(kin_db::KinDbError::StorageError( + format!( + "post-commit authority moved for repo {}: expected generation {generation}, recovered {}; reopen before finalizing derived indexes", + self.cached_repo_id, recovered.generation + ), + ))); + } + let (query_snapshot, _) = + materialize_hosted_repository_snapshot(recovered.snapshot)?; + Arc::new( + kin_db::InMemoryGraph::from_snapshot_without_text_index(query_snapshot) + .map_err(DaemonError::from)?, + ) + } + None if generation == kin_db::GENERATION_INIT => Arc::clone(&self.graph), + None => { return Err(DaemonError::Graph(kin_db::KinDbError::StorageError( format!( - "post-commit authority moved for repo {}: expected generation {generation}, recovered {}; reopen before finalizing derived indexes", - self.cached_repo_id, recovered.generation + "committed generation {generation} for repo {} is missing during read-index finalization", + self.cached_repo_id ), - ))); + ))) } - let (query_snapshot, _) = - materialize_hosted_repository_snapshot(recovered.snapshot)?; - Arc::new( - kin_db::InMemoryGraph::from_snapshot(query_snapshot) - .map_err(DaemonError::from)?, - ) - } - None if generation == kin_db::GENERATION_INIT => Arc::clone(&self.graph), - None => { - return Err(DaemonError::Graph(kin_db::KinDbError::StorageError( - format!( - "committed generation {generation} for repo {} is missing during read-index finalization", - self.cached_repo_id - ), - ))) - } - } + }; + (graph, true) } else { let binding = self.local_repository_authority_binding()?; let authority = self.held_local_repository_authority()?; @@ -16049,25 +16249,35 @@ impl DaemonState { let observed_generation = lease.roots().generation; if observed_generation != generation { return Err(DaemonError::Graph(kin_db::KinDbError::StorageError( - format!( - "post-commit authority moved for repo {}: expected generation {generation}, recovered {observed_generation}; reopen before finalizing derived indexes", - self.cached_repo_id - ), - ))); + format!( + "post-commit authority moved for repo {}: expected generation {generation}, recovered {observed_generation}; reopen before finalizing derived indexes", + self.cached_repo_id + ), + ))); } + let workspace_id = binding.workspace_id(); + let matches_committed_base = lease.workspace_matches_committed_base(&workspace_id); let snapshot = lease - .workspace_graph_snapshot(&binding.workspace_id()) - .map_err(DaemonError::from)? - .ok_or_else(|| { - DaemonError::Graph(kin_db::KinDbError::StorageError(format!( - "repository {} authority has no manifest workspace {} during read-index finalization", - self.cached_repo_id, - binding.workspace_id() - ))) - })?; - Arc::new(kin_db::InMemoryGraph::from_snapshot(snapshot).map_err(DaemonError::from)?) + .workspace_graph_snapshot(&workspace_id) + .map_err(DaemonError::from)? + .ok_or_else(|| { + DaemonError::Graph(kin_db::KinDbError::StorageError(format!( + "repository {} authority has no manifest workspace {} during read-index finalization", + self.cached_repo_id, + workspace_id + ))) + })?; + // Its readers take a tree, entity and relation counts, a read + // index built from its entities, and its binding-history + // capability. None of them searches it, so it carries no text + // index. + let graph = Arc::new( + kin_db::InMemoryGraph::from_snapshot_without_text_index(snapshot) + .map_err(DaemonError::from)?, + ); + (graph, matches_committed_base) }; - Ok(authority_graph) + Ok((authority_graph, workspace_matches_committed_base)) } fn stage_read_index_from_graph( @@ -16474,13 +16684,6 @@ impl DaemonState { self.active_requests.load(Ordering::SeqCst) } - /// Ask this daemon to exit as soon as nothing needs it. Returns whether - /// this call was the one that asked. - /// - /// Not a stop. The idle monitor keeps every gate an idle exit has: an - /// attached client, a request in flight, a pending publication, running - /// enrichment or embedding, and an unflushed graph all keep the daemon up, - /// and it exits on the first check after the last of them clears. /// Stop this daemon serving now, through the same cancel channel a /// cooperative stop request sends on. Answers whether a channel was there to /// send on; a state no daemon loop runs (a test's) has none. @@ -16490,15 +16693,78 @@ impl DaemonState { .is_some_and(|cancel| cancel.send(true).is_ok()) } + /// Ask this daemon to exit as soon as nothing needs it. Returns whether + /// this call was the one that asked. + /// + /// Not a stop. The idle monitor keeps every gate an idle exit has: an + /// attached client, a request in flight, a pending publication, running + /// enrichment or embedding, and an unflushed graph all keep the daemon up, + /// and it exits on the first check after the last of them clears. pub fn request_retirement(&self) -> bool { !self.retirement_requested.swap(true, Ordering::SeqCst) } + /// Ask this daemon to make way for `successor`, a client of another + /// release. Returns whether this call was the one that asked; the first + /// successor named is the one every client is told about. + /// + /// A retirement with different gates. Writes, a publication being + /// prepared and the first scan still finish. Background passes get + /// [`kin_cli::daemon_client::replacement::REPLACEMENT_BACKGROUND_GRACE`]. + /// Attached clients and event subscribers no longer hold it: new requests + /// are refused with the successor's name, so they learn to restart. + pub fn request_replacement(&self, successor: &str) -> bool { + self.retirement_requested.store(true, Ordering::SeqCst); + let successor = kin_cli::daemon_client::replacement::bounded_successor_label(successor); + self.replacement.send_if_modified(|current| { + if current.is_some() { + return false; + } + *current = Some(Replacement { + successor, + requested_at: Instant::now(), + }); + true + }) + } + + /// The replacement this daemon is making way for, if one was asked. + pub fn replacement(&self) -> Option { + self.replacement.borrow().clone() + } + + /// A receiver that sees the replacement the moment one is asked. + pub fn replacement_watch(&self) -> tokio::sync::watch::Receiver> { + self.replacement.subscribe() + } + /// Whether a caller has asked this daemon to exit once nothing needs it. pub fn retirement_requested(&self) -> bool { self.retirement_requested.load(Ordering::SeqCst) } + /// Stop serving so the next daemon on this store attaches its staged + /// history as it starts. Answers whether this daemon is now stopping for + /// that: a state no daemon loop runs has nothing to stop. + /// + /// The stop is the cooperative one, so a request already being answered is + /// answered and the graph is flushed before the process ends. The mark is + /// set first, so the teardown that follows knows to keep the repository's + /// runtime authority until the process is gone. + pub(crate) fn hand_off_for_history(&self) -> bool { + self.history_handoff.store(true, Ordering::SeqCst); + if self.stop_serving() { + return true; + } + self.history_handoff.store(false, Ordering::SeqCst); + false + } + + /// Whether this daemon is stopping to hand its store over. + pub(crate) fn handing_off_for_history(&self) -> bool { + self.history_handoff.load(Ordering::SeqCst) + } + /// Duration since the last recorded external activity. pub fn idle_duration(&self) -> Duration { let now_ms = self @@ -21601,6 +21867,92 @@ mod tests { } } + /// A reference read is not held while the spine's first build opens the + /// repositories registered beside this one. Measured on fastify beside + /// axum, fastapi and zod on a busy host, that build ran for more than ten + /// minutes, and the entity page's read of references, which gives up at + /// 20 s, said Kin could not read them every time. The read is answered at + /// once without the spine, saying why; one build runs however many reads + /// arrive; and once it lands, reads have the spine. + /// + /// Breaking it: have the read build the spine itself again, and the first + /// read waits on the held build. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial_test::serial] + async fn a_read_is_answered_while_the_spine_loads_its_siblings() { + if !kin_core::test_env::alone_in_process() { + return; + } + let _fixture = registered_sibling_fixture(); + let primary_dir = tempfile::tempdir().unwrap(); + let primary_init = kin_core::init(primary_dir.path()).unwrap(); + let state = Arc::new(test_state(primary_init.layout, primary_dir.path())); + assert_eq!(state.spine_peer_repositories(), 1, "the sibling is pinned"); + + let builds = Arc::new(AtomicUsize::new(0)); + let release = Arc::new((Mutex::new(false), std::sync::Condvar::new())); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let builds_in_hook = Arc::clone(&builds); + let release_in_hook = Arc::clone(&release); + state.set_spine_initialization_test_hook(Some(Arc::new(move || { + builds_in_hook.fetch_add(1, Ordering::SeqCst); + let _ = entered_tx.send(()); + let (released, wake) = &*release_in_hook; + let released = released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _ = wake + .wait_timeout_while(released, Duration::from_secs(120), |released| !*released) + .unwrap_or_else(std::sync::PoisonError::into_inner); + }))); + + let first = tokio::time::timeout( + Duration::from_secs(5), + state.acquire_spine_read_authority_for_read(), + ) + .await + .expect("a read does not wait for the spine's first build") + .is_some(); + assert!(!first, "no spine is ready while its build is held"); + assert_eq!( + state.spine_initialization_deferral().as_deref(), + Some(SPINE_BUILDING_REASON) + ); + tokio::task::spawn_blocking(move || entered_rx.recv_timeout(Duration::from_secs(30))) + .await + .unwrap() + .expect("the build runs on a task of its own"); + assert!(state.spine_warming()); + assert!(state + .acquire_spine_read_authority_for_read() + .await + .is_none()); + assert_eq!( + builds.load(Ordering::SeqCst), + 1, + "a read while a build runs starts no second one" + ); + + { + let (released, wake) = &*release; + *released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = true; + wake.notify_all(); + } + let deadline = Instant::now() + Duration::from_secs(60); + while state + .acquire_spine_read_authority_for_read() + .await + .is_none() + { + assert!(Instant::now() < deadline, "the spine never landed"); + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert_eq!(builds.load(Ordering::SeqCst), 1); + assert!(!state.spine_warming()); + } + #[test] #[serial_test::serial] fn a_clean_sibling_pin_emits_no_failure_diagnostics() { diff --git a/crates/kin-daemon/src/supervisor.rs b/crates/kin-daemon/src/supervisor.rs index 18bb3135c..240f1176d 100644 --- a/crates/kin-daemon/src/supervisor.rs +++ b/crates/kin-daemon/src/supervisor.rs @@ -1008,7 +1008,7 @@ async fn request_supervisor_shutdown( StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": format!("current process identity unavailable: {error}")})), ) - .into_response() + .into_response(); } }; if current != expected { @@ -1318,6 +1318,7 @@ fn router_with_rotation_tokens( SupervisorAuthState { tokens }, supervisor_auth, )) + .route("/runtime/identity", get(runtime_identity)) .layer(middleware::from_fn(validate_host_and_origin)); // Synthetic in-process tower test requests (`Request::get("/…")`) omit the @@ -1365,6 +1366,15 @@ async fn health(State(state): State>) -> impl IntoResponse }) } +async fn runtime_identity() -> std::result::Result< + Json, + (axum::http::StatusCode, String), +> { + kin_core::runtime_identity::RuntimeServiceIdentity::current("supervisor") + .map(Json) + .map_err(|error| (axum::http::StatusCode::SERVICE_UNAVAILABLE, error)) +} + async fn readiness() -> impl IntoResponse { StatusCode::OK } @@ -1414,7 +1424,18 @@ async fn route_repo( async fn register_daemon( State(state): State>, Json(payload): Json, -) -> impl IntoResponse { +) -> axum::response::Response { + let runtime = kin_cli::daemon_client::read_runtime_endpoint(&format!( + "http://127.0.0.1:{}", + payload.port + )) + .await; + let proven = runtime.as_ref().is_ok_and(|identity| { + identity.pid == payload.pid && identity.validate_for_current("daemon").is_ok() + }); + if !proven { + return (StatusCode::CONFLICT, Json(serde_json::json!({"error":"daemon runtime identity is missing or differs from the supervisor; daemon left running"}))).into_response(); + } state.touch(); state.prune_unhealthy_daemons().await; let now = chrono::Utc::now().to_rfc3339(); @@ -1425,7 +1446,7 @@ async fn register_daemon( let mut repos = state.repo_daemons.write().await; if let Some(existing) = repos.get(&payload.repo_id) { if existing.instance_id != instance_id { - return (StatusCode::CONFLICT, Json(existing.clone())); + return (StatusCode::CONFLICT, Json(existing.clone())).into_response(); } } let record = RegisteredRepoDaemon { @@ -1443,7 +1464,7 @@ async fn register_daemon( last_heartbeat_elapsed_ms: heartbeat_ms, }; repos.insert(payload.repo_id, record.clone()); - (StatusCode::OK, Json(record)) + (StatusCode::OK, Json(record)).into_response() } async fn heartbeat_daemon( @@ -2145,6 +2166,148 @@ struct DiscoveredDaemon { exe_path: Option, } +#[cfg(unix)] +struct VerifiedDaemonOwner { + process: kin_cli::daemon_client::ProcessIdentity, + port: u16, +} + +#[cfg(unix)] +fn process_owner_is_current(process: &kin_cli::daemon_client::ProcessIdentity) -> bool { + kin_cli::daemon_client::process_identity_is_current(process).unwrap_or(false) +} + +#[cfg(unix)] +async fn verify_discovered_runtime( + client: &reqwest::Client, + daemon: &DiscoveredDaemon, +) -> Option { + let port = daemon.port?; + let process = kin_cli::daemon_client::process_identity(daemon.pid).ok()??; + let identity = client + .get(format!("http://127.0.0.1:{port}/runtime/identity")) + .send() + .await + .ok()? + .error_for_status() + .ok()? + .json::() + .await + .ok()?; + if identity.pid != daemon.pid + || identity.validate_for_current("daemon").is_err() + || !process_owner_is_current(&process) + { + return None; + } + Some(VerifiedDaemonOwner { process, port }) +} + +#[cfg(unix)] +async fn verified_owner_is_current(owner: &VerifiedDaemonOwner) -> bool { + if !process_owner_is_current(&owner.process) { + return false; + } + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + { + Ok(client) => client, + Err(_) => return false, + }; + let identity = match client + .get(format!("http://127.0.0.1:{}/runtime/identity", owner.port)) + .send() + .await + { + Ok(response) => match response.error_for_status() { + Ok(response) => match response + .json::() + .await + { + Ok(identity) => identity, + Err(_) => return false, + }, + Err(_) => return false, + }, + Err(_) => return false, + }; + identity.pid == owner.process.pid() + && identity.validate_for_current("daemon").is_ok() + && process_owner_is_current(&owner.process) +} + +#[cfg(all(test, unix))] +mod runtime_identity_tests { + use super::*; + + struct OwnedChild(std::process::Child); + impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + #[tokio::test] + async fn other_runtime_generation_is_neither_signaled_nor_written() { + let mut child = OwnedChild( + std::process::Command::new("sleep") + .arg("30") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(), + ); + let process = kin_cli::daemon_client::process_identity(child.0.id()) + .unwrap() + .unwrap(); + let mut unrelated = + kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon").unwrap(); + unrelated.pid = child.0.id(); + unrelated.identity.commit_sha = if unrelated.identity.commit_sha == "a".repeat(40) { + "b".repeat(40) + } else { + "a".repeat(40) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let app = Router::new().route( + "/runtime/identity", + get(move || { + let unrelated = unrelated.clone(); + async move { Json(unrelated) } + }), + ); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let owner = VerifiedDaemonOwner { process, port }; + assert!(!verified_owner_is_current(&owner).await); + let store = tempfile::tempdir().unwrap(); + graceful_terminate( + child.0.id(), + store.path().to_str().unwrap(), + store.path().to_str().unwrap(), + "reaping", + "fixture negative control", + &owner, + ) + .await; + assert!( + child.0.try_wait().unwrap().is_none(), + "the unrelated live actor received a signal" + ); + assert_eq!( + store.path().read_dir().unwrap().count(), + 0, + "the unrelated store was written" + ); + server.abort(); + } +} + #[cfg(unix)] async fn reaper_sweep( state: &SupervisorState, @@ -2155,7 +2318,14 @@ async fn reaper_sweep( self_pid: u32, policy: ReapPolicy, ) { - let discovered = enumerate_repo_daemons(sys, self_pid); + let mut discovered = Vec::new(); + let mut verified_owners = HashMap::new(); + for daemon in enumerate_repo_daemons(sys, self_pid) { + if let Some(owner) = verify_discovered_runtime(client, &daemon).await { + verified_owners.insert(daemon.pid, owner); + discovered.push(daemon); + } + } // Supervisor self-identity, read from the just-refreshed process table: the // binary it is executing, that binary's mtime, and its own start_time. These @@ -2276,17 +2446,17 @@ async fn reaper_sweep( }; match classify_daemon(&observation, &policy) { DaemonDecision::Reap(reason) => { - reap_daemon(&observation, reason).await; + reap_daemon(&observation, reason, &verified_owners[&daemon.pid]).await; pinned_sweeps.remove(&daemon.pid); stalled_sweeps.remove(&daemon.pid); } DaemonDecision::Redeploy(reason) => { - redeploy_daemon(&observation, reason).await; + redeploy_daemon(&observation, reason, &verified_owners[&daemon.pid]).await; pinned_sweeps.remove(&daemon.pid); stalled_sweeps.remove(&daemon.pid); } DaemonDecision::Adopt(reason) => { - adopt_daemon(state, daemon, reason).await; + adopt_daemon(state, daemon, reason, &verified_owners[&daemon.pid]).await; } DaemonDecision::Keep => { // Surface a persistent split-brain: two live daemons claim one @@ -2317,7 +2487,15 @@ async fn reaper_sweep( /// produces (`pid--port-`), so the daemon's next heartbeat reconciles /// with the adopted entry instead of conflicting with it. #[cfg(unix)] -async fn adopt_daemon(state: &SupervisorState, daemon: &DiscoveredDaemon, reason: AdoptReason) { +async fn adopt_daemon( + state: &SupervisorState, + daemon: &DiscoveredDaemon, + reason: AdoptReason, + owner: &VerifiedDaemonOwner, +) { + if !verified_owner_is_current(owner).await { + return; + } // A routable entry needs a port. Health is only `Healthy` when a port was // probed, so an Adopt decision implies `Some` here; guard defensively anyway. let Some(port) = daemon.port else { @@ -2337,6 +2515,9 @@ async fn adopt_daemon(state: &SupervisorState, daemon: &DiscoveredDaemon, reason return; } } + if !process_owner_is_current(&owner.process) { + return; + } let record = RegisteredRepoDaemon { repo_id: repo_id.clone(), display_name: repo_display_name_for_path(Path::new(&repo_root)), @@ -2524,7 +2705,11 @@ async fn graceful_terminate( store_root: &str, action: &str, reason: &str, + owner: &VerifiedDaemonOwner, ) { + if owner.process.pid() != pid || !verified_owner_is_current(owner).await { + return; + } warn!( pid, repo = %repo_root, @@ -2549,12 +2734,18 @@ async fn graceful_terminate( in_flight, at: chrono::Utc::now().to_rfc3339(), }; + if !process_owner_is_current(&owner.process) { + return; + } kin_daemon_spawn::append_to_daemon_log( &kin_root, &format!("kin-daemon TERMINATED BY SUPERVISOR: {}", note.summary()), ); kin_daemon_spawn::write_daemon_death_note(&kin_root, ¬e); + if !process_owner_is_current(&owner.process) { + return; + } unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); } @@ -2572,7 +2763,7 @@ async fn graceful_terminate( } tokio::time::sleep(Duration::from_millis(200)).await; } - if is_process_alive(pid) { + if is_process_alive(pid) && verified_owner_is_current(owner).await { warn!( pid, repo = %repo_root, @@ -2627,7 +2818,11 @@ fn retire_endpoint_of_terminated_daemon(kin_root: &Path, pid: u32) { /// outcome plus the CPU-pinned and persisted-progress-stall streak counts — so an /// operator can see exactly why a daemon was judged rogue. #[cfg(unix)] -async fn reap_daemon(observation: &DaemonObservation, reason: ReapReason) { +async fn reap_daemon( + observation: &DaemonObservation, + reason: ReapReason, + owner: &VerifiedDaemonOwner, +) { let probe = match observation.health { DaemonHealth::Healthy(_) => "healthy", DaemonHealth::Unhealthy => "answered-unhealthy", @@ -2660,6 +2855,7 @@ async fn reap_daemon(observation: &DaemonObservation, reason: ReapReason) { &observation.store_root, "reaping misbehaving repo daemon", &context, + owner, ) .await; } @@ -2667,13 +2863,18 @@ async fn reap_daemon(observation: &DaemonObservation, reason: ReapReason) { /// Redeploy a stale idle daemon: terminate it so it respawns on demand into the /// fresh build. Uses the same graceful ladder as reaping. #[cfg(unix)] -async fn redeploy_daemon(observation: &DaemonObservation, reason: RedeployReason) { +async fn redeploy_daemon( + observation: &DaemonObservation, + reason: RedeployReason, + owner: &VerifiedDaemonOwner, +) { graceful_terminate( observation.pid, &observation.repo_root, &observation.store_root, "redeploying stale repo daemon", &format!("{reason:?}"), + owner, ) .await; } @@ -2911,8 +3112,30 @@ mod tests { async fn register_repo( app: Router, - payload: &RepoDaemonRegistration, + payload: &mut RepoDaemonRegistration, ) -> axum::response::Response { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + payload.port = listener.local_addr().unwrap().port(); + payload.endpoint = format!("http://127.0.0.1:{}", payload.port); + let identity = + kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon").unwrap(); + assert_eq!(identity.pid, payload.pid); + let metadata = Router::new().route( + "/runtime/identity", + get(move || { + let identity = identity.clone(); + async move { Json(identity) } + }), + ); + struct FixtureServer(tokio::task::JoinHandle<()>); + impl Drop for FixtureServer { + fn drop(&mut self) { + self.0.abort(); + } + } + let _server = FixtureServer(tokio::spawn(async move { + axum::serve(listener, metadata).await.unwrap(); + })); tower::ServiceExt::oneshot( app, axum::http::Request::post("/daemons/register") @@ -2932,10 +3155,10 @@ mod tests { #[tokio::test] async fn supervisor_register_route_and_deregister() { let state = Arc::new(SupervisorState::new()); - let payload = repo_payload("instance-a", 49152); + let mut payload = repo_payload("instance-a", 49152); let app = router(Arc::clone(&state)); - let response = register_repo(app.clone(), &payload).await; + let response = register_repo(app.clone(), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); let response = tower::ServiceExt::oneshot( @@ -2963,8 +3186,8 @@ mod tests { #[tokio::test] async fn supervisor_register_rejects_conflicting_live_instance() { let state = Arc::new(SupervisorState::new()); - let payload = repo_payload("instance-a", 49152); - let conflicting = RepoDaemonRegistration { + let mut payload = repo_payload("instance-a", 49152); + let mut conflicting = RepoDaemonRegistration { instance_id: "instance-b".to_string(), port: 49153, endpoint: "http://127.0.0.1:49153".to_string(), @@ -2972,23 +3195,23 @@ mod tests { }; let app = router(Arc::clone(&state)); - let response = register_repo(app.clone(), &payload).await; + let response = register_repo(app.clone(), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); - let response = register_repo(app, &conflicting).await; + let response = register_repo(app, &mut conflicting).await; assert_eq!(response.status(), StatusCode::CONFLICT); let repos = state.repo_daemons.read().await; let registered = repos.get("demo").unwrap(); assert_eq!(registered.instance_id, "instance-a"); - assert_eq!(registered.endpoint, "http://127.0.0.1:49152"); + assert_eq!(registered.endpoint, payload.endpoint); } #[tokio::test] async fn supervisor_register_replaces_stale_conflicting_instance() { let state = old_supervisor_state(); - let payload = repo_payload("instance-a", 49152); - let replacement = RepoDaemonRegistration { + let mut payload = repo_payload("instance-a", 49152); + let mut replacement = RepoDaemonRegistration { instance_id: "instance-b".to_string(), port: 49153, endpoint: "http://127.0.0.1:49153".to_string(), @@ -2997,27 +3220,27 @@ mod tests { }; let app = router(Arc::clone(&state)); - let response = register_repo(app.clone(), &payload).await; + let response = register_repo(app.clone(), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); force_stale_route(&state, "demo").await; - let response = register_repo(app, &replacement).await; + let response = register_repo(app, &mut replacement).await; assert_eq!(response.status(), StatusCode::OK); let repos = state.repo_daemons.read().await; let registered = repos.get("demo").unwrap(); assert_eq!(registered.instance_id, "instance-b"); - assert_eq!(registered.endpoint, "http://127.0.0.1:49153"); + assert_eq!(registered.endpoint, replacement.endpoint); assert_eq!(registered.graph_entity_count, Some(24)); } #[tokio::test] async fn supervisor_deregister_ignores_wrong_or_missing_instance() { let state = Arc::new(SupervisorState::new()); - let payload = repo_payload("instance-a", 49152); + let mut payload = repo_payload("instance-a", 49152); let app = router(Arc::clone(&state)); - let response = register_repo(app.clone(), &payload).await; + let response = register_repo(app.clone(), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); let response = tower::ServiceExt::oneshot( @@ -3049,10 +3272,10 @@ mod tests { #[tokio::test] async fn supervisor_route_list_and_health_prune_stale_routes() { let state = old_supervisor_state(); - let payload = repo_payload("instance-a", 49152); + let mut payload = repo_payload("instance-a", 49152); let app = router(Arc::clone(&state)); - let response = register_repo(app.clone(), &payload).await; + let response = register_repo(app.clone(), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); force_stale_route(&state, "demo").await; let response = tower::ServiceExt::oneshot( @@ -3066,7 +3289,7 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); assert!(state.repo_daemons.read().await.is_empty()); - let response = register_repo(app.clone(), &payload).await; + let response = register_repo(app.clone(), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); force_stale_route(&state, "demo").await; let response = tower::ServiceExt::oneshot( @@ -3080,7 +3303,7 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); assert!(state.repo_daemons.read().await.is_empty()); - let response = register_repo(app.clone(), &payload).await; + let response = register_repo(app.clone(), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); force_stale_route(&state, "demo").await; let response = tower::ServiceExt::oneshot( @@ -3098,15 +3321,17 @@ mod tests { #[tokio::test] async fn supervisor_heartbeat_refreshes_route_before_ttl_prune() { let state = old_supervisor_state(); - let payload = repo_payload("instance-a", 49152); - let refreshed = RepoDaemonRegistration { + let mut payload = repo_payload("instance-a", 49152); + let mut refreshed = RepoDaemonRegistration { graph_entity_count: Some(99), ..payload.clone() }; let app = router(Arc::clone(&state)); - let response = register_repo(app.clone(), &payload).await; + let response = register_repo(app.clone(), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); + refreshed.port = payload.port; + refreshed.endpoint = payload.endpoint.clone(); let previous_heartbeat = { let repos = state.repo_daemons.read().await; repos.get("demo").unwrap().last_heartbeat_elapsed_ms @@ -4223,21 +4448,13 @@ mod tests { assert_eq!(current.status(), StatusCode::OK); } - /// Building a supervisor router must not disturb an open window's durable - /// record, which lives in one directory shared by the whole machine. - /// - /// `router_with_auth_and_shutdown` once built its token set through - /// `RotationTokens::new`, which reads a primary-only configuration as a - /// finished rotation and removes the record. The supervisor's record sits - /// under `supervisor_root()`, keyed on the real home rather than on - /// `KIN_HOME`, so that was not confined to a test's own temp directory: - /// running any supervisor unit test on a machine with a live rotation - /// window deleted that window's state. + /// Building a supervisor router must preserve the selected runtime's open + /// rotation-window record. Construction must never complete a primary-only + /// rotation or delete its durable state. /// - /// Redirecting the environment is the only way to observe it, so this holds - /// the binary's env lock and the sanctioned guard, and it asserts the - /// redirect took effect BEFORE it asserts anything else. A seed written - /// where the code never looks would survive on the broken code too. + /// This holds the environment lock, declares an isolated development home, + /// and checks its service path before creating any record. A seed outside + /// the selected namespace would survive broken router construction too. #[tokio::test] async fn building_a_supervisor_router_leaves_the_rotation_window_record_intact() { if !kin_core::test_env::alone_in_process() { @@ -4245,6 +4462,8 @@ mod tests { } let _env = env_test_lock(); let home = tempfile::tempdir().expect("tempdir"); + let _kin_home = kin_core::test_env::EnvVarGuard::set("KIN_HOME", home.path()); + let _mode = kin_core::test_env::EnvVarGuard::set("KIN_RUNTIME_MODE", "development"); let _registry = kin_core::test_env::EnvVarGuard::set( "KIN_REGISTRY_PATH", home.path().join("registry.toml"), @@ -4257,6 +4476,12 @@ mod tests { home.path().display(), path.display() ); + assert!( + path.starts_with(home.path().join("services/development")), + "the rotation record must belong to the selected development service namespace" + ); + std::fs::create_dir_all(path.parent().expect("rotation record parent")) + .expect("prepare the isolated service namespace"); let seeded = br#"{"opened_unix":1787900000,"previous_accepted":7}"#; std::fs::write(&path, seeded).expect("seed a window record"); @@ -4660,18 +4885,18 @@ mod tests { #[tokio::test] async fn registry_records_the_managed_home_a_daemon_reports() { let state = Arc::new(SupervisorState::new()); - let payload = repo_payload_in_home("instance-a", 49152, "/scratch/home/.kin"); + let mut payload = repo_payload_in_home("instance-a", 49152, "/scratch/home/.kin"); - let response = register_repo(router(Arc::clone(&state)), &payload).await; + let response = register_repo(router(Arc::clone(&state)), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); let repos = state.repo_daemons.read().await; assert_eq!(repos.get("demo").unwrap().kin_home, "/scratch/home/.kin"); } - /// One machine-wide supervisor legitimately holds daemons from several - /// managed homes. That is the whole premise of the scoping contract, so it - /// is asserted rather than assumed. + /// Legacy reported-home labels remain diagnostic metadata. The actual + /// registering services must independently prove this supervisor's local + /// runtime namespace through their metadata endpoint. #[tokio::test] async fn one_supervisor_holds_daemons_from_two_homes() { let state = Arc::new(SupervisorState::new()); @@ -4683,10 +4908,10 @@ mod tests { b.repo_id = "repo-b".to_string(); assert_eq!( - register_repo(app.clone(), &a).await.status(), + register_repo(app.clone(), &mut a).await.status(), StatusCode::OK ); - assert_eq!(register_repo(app, &b).await.status(), StatusCode::OK); + assert_eq!(register_repo(app, &mut b).await.status(), StatusCode::OK); let repos = state.repo_daemons.read().await; assert_eq!(repos.get("repo-a").unwrap().kin_home, "/homes/a/.kin"); @@ -4699,9 +4924,9 @@ mod tests { #[tokio::test] async fn an_unreported_home_is_never_filled_in_from_the_supervisor() { let state = Arc::new(SupervisorState::new()); - let payload = repo_payload_in_home("instance-a", 49152, " "); + let mut payload = repo_payload_in_home("instance-a", 49152, " "); - let response = register_repo(router(Arc::clone(&state)), &payload).await; + let response = register_repo(router(Arc::clone(&state)), &mut payload).await; assert_eq!(response.status(), StatusCode::OK); let repos = state.repo_daemons.read().await; @@ -4727,9 +4952,9 @@ mod tests { async fn a_heartbeat_keeps_the_recorded_home_current() { let state = Arc::new(SupervisorState::new()); let app = router(Arc::clone(&state)); - let payload = repo_payload_in_home("instance-a", 49152, "/homes/a/.kin"); + let mut payload = repo_payload_in_home("instance-a", 49152, "/homes/a/.kin"); assert_eq!( - register_repo(app.clone(), &payload).await.status(), + register_repo(app.clone(), &mut payload).await.status(), StatusCode::OK ); diff --git a/crates/kin-daemon/src/unit_lifecycle.rs b/crates/kin-daemon/src/unit_lifecycle.rs index 16b3197a9..2f4fda1a7 100644 --- a/crates/kin-daemon/src/unit_lifecycle.rs +++ b/crates/kin-daemon/src/unit_lifecycle.rs @@ -72,18 +72,42 @@ pub(crate) fn current_repository_base(state: &DaemonState) -> Option Option { + let context = LocalRepositoryAuthorityContext::from_state(state).ok()?; + let authority = crate::api::held_repository_authority(state).ok()?; + workspace_state_from(&authority, context.workspace_id(), |lease, workspace| { + lease + .resolve_target_change_id(workspace.base_target.as_ref()?) + .ok() + }) +} + +/// Read one workspace's state from an already-held authority. +fn workspace_state_from( authority: &kin_db::RepositoryAuthorityManager, workspace_id: kin_model::WorkspaceId, -) -> Option { + read: impl FnOnce(&kin_db::RepositoryAuthorityState, &kin_model::WorkspaceState) -> Option, +) -> Option { let lease = authority.read_authority(); let workspace = lease .metadata() .workspaces .iter() .find(|workspace| workspace.workspace_id == workspace_id)?; - RepositoryBase::from_workspace(workspace).ok() + read(&lease, workspace) +} + +/// The repository base an already-held authority names for one workspace. +pub(crate) fn repository_base_from( + authority: &kin_db::RepositoryAuthorityManager, + workspace_id: kin_model::WorkspaceId, +) -> Option { + workspace_state_from(authority, workspace_id, |_, workspace| { + RepositoryBase::from_workspace(workspace).ok() + }) } /// One member of a unit's namespace the graph already holds. diff --git a/crates/kin-daemon/src/verification_write.rs b/crates/kin-daemon/src/verification_write.rs new file mode 100644 index 000000000..0a0e40265 --- /dev/null +++ b/crates/kin-daemon/src/verification_write.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! The one writer for verification runs. +//! +//! A run is repository authority, as a review is: it says what was proved, +//! against which fingerprints, and by whom. Every run this daemon records, +//! for `kin verify run` or for the tests that reach a piece of work, is +//! committed here as one repository transaction, the way the review writer +//! commits a review event, and shown to the live graph only once authority +//! holds it. +//! +//! The order is what keeps the repository whole. A run reaches the graph with +//! relations from it to the entities it proves, and a working-copy admission +//! checks every relation's ends against the records authority holds. A run +//! written to the graph alone left those relations pointing at a run +//! authority had never seen, and every admission after it was refused, so the +//! repository stopped following its own working copy. A daemon that reopens +//! the store also serves exactly the runs it answered with. +//! +//! The writer takes `persist_lock` itself. Its caller holds the coordination +//! gate and a graph-authority mutation guard, the pair every authority writer +//! in this daemon holds. + +use kin_cli::commands::verify::VerificationRecord; +use kin_model::AuthorId; + +use crate::local_repository_authority::ActiveLocalRepositoryAuthority; +use crate::review_write::ReviewWriteRefusal; +use crate::state::{DaemonEvent, DaemonState}; + +/// Commit `record` to repository authority, then show it to the live graph. +/// +/// Nothing is written when the commit is refused. Once it commits, a failure +/// to follow it in this process is reported as exactly that, with the +/// generation authority now holds. +pub(crate) fn commit_verification_record( + state: &DaemonState, + record: &VerificationRecord, +) -> Result<(), String> { + if state.storage_backend.is_some() { + return Err( + "a hosted daemon takes verification runs through the repository transfer seam, not \ + through local runs" + .to_string(), + ); + } + let _persistence = state + .persist_lock + .lock() + .map_err(|_| "daemon persistence lock poisoned".to_string())?; + let authority = ActiveLocalRepositoryAuthority::open_bound(state).map_err(|refusal| { + format!( + "a verification run is repository authority: {:#}", + refusal.into_error() + ) + })?; + let delta = record.to_delta().map_err(|error| format!("{error:#}"))?; + let receipt = crate::review_write::commit_collaboration::( + state, + &authority, + delta, + &record.reason(), + &AuthorId::new(record.actor_label().to_string()), + ) + .map_err(|refusal| match refusal { + ReviewWriteRefusal::Commit { detail, .. } => { + format!("the verification run was not committed to repository authority: {detail}") + } + other => other.to_string(), + })?; + + // Durable from here. The steps below make this process agree with what + // authority now holds. + let diverged = |detail: String| { + format!( + "the verification run committed to repository authority at generation {}, but the \ + daemon's live graph could not follow it: {detail}; restart the daemon to serve it \ + from authority again", + receipt.generation + ) + }; + state + .record_repository_authority_commit(receipt.generation) + .map_err(|error| diverged(error.to_string()))?; + record + .apply_to(state.graph.as_ref()) + .map_err(|error| diverged(format!("{error:#}")))?; + crate::binding_history::restore_review_publication(state, &authority) + .map_err(|error| diverged(error.to_string()))?; + + state.bump_version(); + state.mark_dirty(); + state.emit_event(DaemonEvent::GraphRootChanged { + old_root_hash: None, + new_root_hash: "verification-state".to_string(), + }); + state.emit_event(DaemonEvent::RepositoryAuthorityChanged { + repository_id: receipt.repository_id.to_string(), + operation_id: receipt.operation_id, + previous_generation: receipt.roots_before.generation, + new_generation: receipt.generation, + }); + Ok(()) +} diff --git a/crates/kin-daemon/tests/compat_json_stdout.rs b/crates/kin-daemon/tests/compat_json_stdout.rs index f108f1b6a..8673a56af 100644 --- a/crates/kin-daemon/tests/compat_json_stdout.rs +++ b/crates/kin-daemon/tests/compat_json_stdout.rs @@ -23,8 +23,12 @@ async fn compat_json_stdout_is_pure_json_under_env_overrides() { // probe. The compat payload must now be emitted before any logging. let mut command = Command::new(env!("CARGO_BIN_EXE_kin-daemon")); isolate_daemon_test_command(&mut command); + let fixture = tempfile::tempdir().unwrap(); + let untouched_home = fixture.path().join("uninitialized-kin-home"); command .arg("--compat-json") + .env("KIN_HOME", &untouched_home) + .env("KIN_RUNTIME_MODE", "development") .env("KIN_BYPASS_EMBEDDING_COVERAGE_CHECK", "1") .env("KIN_DAEMON_DISABLE_LSP", "1"); let output = daemon_test_output(command, "kin-daemon --compat-json", Duration::from_secs(30)) @@ -81,6 +85,41 @@ async fn compat_json_stdout_is_pure_json_under_env_overrides() { assert!(parsed["build"]["dependency_provenance"].is_string()); assert!(parsed["build"]["branch"].is_string()); assert!(parsed["build"]["built_at"].is_string()); + assert!(parsed["build"]["target"].is_string()); + assert!(parsed["build"]["build_profile"].is_string()); + let identity: kin_core::runtime_identity::RuntimeIdentity = + serde_json::from_value(parsed["runtime_identity"].clone()) + .expect("compat payload must carry typed static runtime identity"); + assert_eq!( + identity, + kin_core::runtime_identity::RuntimeIdentity::current() + ); + assert_eq!(identity.version, parsed["version"].as_str().unwrap()); + assert_eq!( + identity.commit_sha, + parsed["build"]["sha"].as_str().unwrap() + ); + assert_eq!( + identity.source_dirty, + parsed["build"]["dirty"].as_bool().unwrap() + ); + assert_eq!( + identity.source_known, + parsed["build"]["source_known"].as_bool().unwrap() + ); + assert_eq!(identity.target, parsed["build"]["target"].as_str().unwrap()); + assert_eq!( + identity.build_profile, + parsed["build"]["build_profile"].as_str().unwrap() + ); + let mode: kin_core::runtime_identity::RuntimeMode = + serde_json::from_value(parsed["runtime_mode"].clone()) + .expect("compat payload must carry typed runtime mode"); + assert_eq!(mode, kin_core::runtime_identity::RuntimeMode::Development); + assert!( + !untouched_home.exists(), + "the static compatibility probe must not initialize or capture its selected Kin home" + ); // The exact top-level key set. kin-infra reads this payload by name and // records it verbatim as the evidence a hosted pin is graded against, so a @@ -101,6 +140,8 @@ async fn compat_json_stdout_is_pure_json_under_env_overrides() { "graph_snapshot_min_supported_version", "graph_snapshot_version", "hosted_start_requirements", + "runtime_identity", + "runtime_mode", "schema", "supervisor_startup_capabilities", "supervisor_startup_protocol", diff --git a/crates/kin-daemon/tests/history_handoff.rs b/crates/kin-daemon/tests/history_handoff.rs new file mode 100644 index 000000000..9a1ca64e8 --- /dev/null +++ b/crates/kin-daemon/tests/history_handoff.rs @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! End-to-end proof that a daemon which is being read attaches its store's +//! complete history once that history is staged. +//! +//! A store converted with only its checked-out commit derives the rest in a +//! worker and stages it beside the store. A daemon that never idles used to +//! leave it there for good. This test runs the real daemon over such a store +//! while a reader calls it every few milliseconds, and requires: +//! +//! - while the store holds open work, the daemon keeps serving the partial +//! store, moves nothing, and records what it is waiting for; +//! - once that work is gone, the daemon hands the store over without being +//! asked: its process ends cleanly, a successor it started itself attaches +//! the staged history, and that successor serves the complete store. +//! +//! Unix only: the successor stays inside this test's containment because a +//! contained daemon does not start a new session, and that rule is the Unix +//! one. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use kin_core::history_attach; +use kin_core::history_state::{ + self, DerivationState, HistoryDerivationProgress, HistoryMode, HistoryStanding, +}; +use kin_core::KinLayout; +use tokio::process::Command; + +mod common; + +use common::{ + daemon_test_output, isolate_daemon_test_command, spawn_daemon_test_command, terminate_daemon, + DaemonChild, +}; + +const TIMEOUT: Duration = Duration::from_secs(180); +const FIXTURE_ROOT: &str = "KIN_TEST_HISTORY_HANDOFF_FIXTURE_ROOT"; +const FIXTURE_REPORT: &str = "KIN_TEST_HISTORY_HANDOFF_FIXTURE_REPORT"; + +/// Builds the fixture in a process of its own, so the conversion chooses its +/// store under this test's private home and never under the parent's. +#[test] +fn staged_history_fixture_worker() { + let Some(root) = std::env::var_os(FIXTURE_ROOT) else { + return; + }; + let root = PathBuf::from(root); + let report = PathBuf::from(std::env::var_os(FIXTURE_REPORT).expect("fixture report path")); + let git = |args: &[&str]| { + // A plain Git: this worker's own environment is already scrubbed of + // inherited Git and Kin authority by the test that started it. + let output = std::process::Command::new("git") + .args([ + "-c", + "user.name=History Test", + "-c", + "user.email=history@example.test", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .current_dir(&root) + .output() + .expect("run git for the fixture"); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "-q", "--initial-branch=main"]); + let hooks = root.join(".git/hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + git(&["config", "core.hooksPath", hooks.to_str().unwrap()]); + let excludes = root.join(".git/fixture-excludes"); + std::fs::write(&excludes, b"").unwrap(); + git(&["config", "core.excludesFile", excludes.to_str().unwrap()]); + std::fs::write(root.join("a.py"), b"def a():\n return 1\n").unwrap(); + git(&["add", "a.py"]); + git(&["commit", "-q", "-s", "-m", "Add a"]); + std::fs::write( + root.join("b.py"), + b"from a import a\n\ndef b():\n return a()\n", + ) + .unwrap(); + git(&["add", "b.py"]); + git(&["commit", "-q", "-s", "-m", "Add b"]); + + let partial = kin_core::init_current_commit_from_git(&root, None, HistoryMode::Background) + .expect("convert the checked-out commit"); + let stage = history_attach::new_stage_parent(&root, partial.repository_id.as_str()) + .expect("a stage beside the store"); + kin_core::derive_complete_history_into_stage( + &root, + partial.layout.root(), + &partial.repository_id, + &stage, + ) + .expect("derive the complete history into the stage"); + // What a background worker records when it has staged the history. + let mut staged = HistoryDerivationProgress::new(DerivationState::Staged); + staged.attempts = 1; + staged.detail = Some(stage.display().to_string()); + history_state::write_progress(&partial.layout, &staged).expect("record the staged history"); + + let output = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(report) + .expect("create the fixture report"); + serde_json::to_writer( + output, + &serde_json::json!({ + "root": partial.layout.root(), + "repository_id": partial.repository_id.as_str(), + "stage": stage, + }), + ) + .expect("write the fixture report"); +} + +struct StagedStore { + layout: KinLayout, + repository_id: kin_model::RepositoryId, + stage: PathBuf, + home: tempfile::TempDir, +} + +impl StagedStore { + fn progress(&self) -> Option { + match history_state::standing(&self.layout) { + HistoryStanding::Partial { progress, .. } => progress, + _ => None, + } + } + + fn changes(&self) -> usize { + let (authority, _) = + kin_core::repository_authority::open_persisted_local_repository_authority( + self.repository_id.clone(), + Arc::new(kin_db::LocalFileBackend::new(self.layout.kindb_dir())), + ) + .expect("open the store's persisted authority"); + let lease = authority.read_authority(); + lease.snapshot().changes.len() + } +} + +async fn staged_store(repo: &Path) -> StagedStore { + let repo = repo.canonicalize().expect("canonical repository path"); + let home = tempfile::tempdir().expect("private home"); + let report = home.path().join("fixture.json"); + let mut command = Command::new(std::env::current_exe().expect("this test binary")); + isolate_daemon_test_command(&mut command); + command + .args(["--exact", "staged_history_fixture_worker", "--nocapture"]) + .env("KIN_HOME", home.path()) + .env("KIN_REGISTRY_PATH", home.path().join("registry.toml")) + .env(FIXTURE_ROOT, &repo) + .env(FIXTURE_REPORT, &report); + let output = daemon_test_output(command, "staged history fixture", TIMEOUT) + .await + .expect("contained fixture worker"); + assert!( + output.status.success(), + "fixture worker failed: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let built: serde_json::Value = + serde_json::from_slice(&std::fs::read(&report).expect("fixture report")).unwrap(); + let root = PathBuf::from(built["root"].as_str().expect("reported store root")); + assert!( + root.canonicalize() + .unwrap() + .starts_with(home.path().canonicalize().unwrap()), + "the fixture store escaped its private home: {}", + root.display() + ); + StagedStore { + layout: KinLayout::external(root, repo), + repository_id: kin_model::RepositoryId::new( + built["repository_id"].as_str().unwrap().to_string(), + ) + .unwrap(), + stage: PathBuf::from(built["stage"].as_str().expect("reported stage")), + home, + } +} + +fn spawn_daemon(store: &StagedStore) -> DaemonChild { + let mut command = Command::new(env!("CARGO_BIN_EXE_kin-daemon")); + isolate_daemon_test_command(&mut command); + command + .arg("--repo") + .arg(store.layout.working_dir()) + .arg("--store") + .arg(store.layout.root()) + .args(["--port", "0"]) + .env("KIN_HOME", store.home.path()) + .env("KIN_REGISTRY_PATH", store.home.path().join("registry.toml")) + .env("KIN_DAEMON_DISABLE_LSP", "1") + .env("KIN_DAEMON_AUTO_EMBED", "0") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + spawn_daemon_test_command(command, "history handoff daemon") + .expect("spawn the contained daemon") +} + +/// The daemon a store's endpoint records, when it answers `/health`. +async fn serving(client: &reqwest::Client, root: &Path) -> Option<(u32, u16)> { + let read = |name: &str| std::fs::read_to_string(root.join(name)).ok(); + let pid = read("daemon.pid")?.trim().parse::().ok()?; + let port = read("daemon.port")?.trim().parse::().ok()?; + let health = client + .get(format!("http://127.0.0.1:{port}/health")) + .send() + .await + .ok()?; + (port != 0 && health.status().is_success()).then_some((pid, port)) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_daemon_that_is_being_read_attaches_its_staged_history() { + let repo = tempfile::tempdir().expect("repository directory"); + let store = staged_store(repo.path()).await; + let root = store.layout.root().to_path_buf(); + assert_eq!(store.changes(), 1, "the fixture store holds its snapshot"); + + // Stashed work is open work: the attach waits for it, and so does a + // daemon that would otherwise leave to attach. + let stash = store.layout.stashes_dir().join("held"); + std::fs::create_dir_all(store.layout.stashes_dir()).unwrap(); + std::fs::write(&stash, b"stashed work").unwrap(); + + let mut daemon = spawn_daemon(&store); + let first_pid = daemon.id().expect("daemon pid"); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let deadline = Instant::now() + TIMEOUT; + while serving(&client, &root).await.is_none() { + assert!( + daemon.try_wait().expect("poll the daemon").is_none(), + "the daemon exited before it served" + ); + assert!(Instant::now() < deadline, "the daemon never served"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Something reads the repository the whole time, as the local app does. + let reading = Arc::new(AtomicBool::new(true)); + let answered = Arc::new(AtomicU64::new(0)); + let reader = { + let (reading, answered, client, root) = ( + Arc::clone(&reading), + Arc::clone(&answered), + client.clone(), + root.clone(), + ); + tokio::spawn(async move { + while reading.load(Ordering::SeqCst) { + if serving(&client, &root).await.is_some() { + answered.fetch_add(1, Ordering::SeqCst); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + }; + + // The start found the stash and attached nothing. The daemon serves the + // partial store and says what the history is waiting for. + let waiting_for = history_attach::OpenWork::Stash.describe(); + let deadline = Instant::now() + TIMEOUT; + loop { + let progress = store.progress().expect("the store stays partial"); + if progress.staged_waiting_for() == Some(waiting_for) { + assert_eq!(progress.staged_at(), Some(store.stage.clone())); + break; + } + assert!( + Instant::now() < deadline, + "the wait was never recorded: {progress:?}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + // Well past several looks, it is the same daemon, still serving, and the + // stash and the stage are untouched. + let answered_before = answered.load(Ordering::SeqCst); + tokio::time::sleep(Duration::from_secs(6)).await; + assert!( + daemon.try_wait().expect("poll the daemon").is_none(), + "the daemon left while the store held open work" + ); + assert_eq!( + serving(&client, &root).await.map(|(pid, _)| pid), + Some(first_pid) + ); + assert!(answered.load(Ordering::SeqCst) > answered_before); + assert!(!history_state::standing(&store.layout).is_complete()); + assert_eq!(std::fs::read(&stash).unwrap(), b"stashed work"); + assert!(history_attach::staged_store(&store.stage) + .join("manifest.json") + .is_file()); + + // The work is finished. Nobody stops the daemon or asks it for anything + // but its health, and the complete history arrives. + std::fs::remove_file(&stash).unwrap(); + let handed_off = Instant::now(); + let deadline = handed_off + TIMEOUT; + let successor = loop { + if history_state::standing(&store.layout).is_complete() { + if let Some((pid, port)) = serving(&client, &root).await { + if pid != first_pid { + break (pid, port); + } + } + } + assert!( + Instant::now() < deadline, + "the staged history was never attached; progress: {:?}", + store.progress() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + eprintln!( + "complete history served by daemon {} after {:?}", + successor.0, + handed_off.elapsed() + ); + reading.store(false, Ordering::SeqCst); + reader.await.unwrap(); + + // The first daemon ended by itself, cleanly. + let status = tokio::time::timeout(Duration::from_secs(60), daemon.wait()) + .await + .expect("the first daemon ends after handing off") + .expect("reap the first daemon"); + let stage_gone = !store.stage.exists(); + let journal_gone = !root.join("history-attach.json").exists(); + let progress = store.progress(); + let changes = store.changes(); + // End the successor before asserting, so a failure leaks no process. The + // successor is inside this test's containment, which is proven empty. + let cleanup = terminate_daemon(&mut daemon, "history handoff daemon").await; + let deadline = Instant::now() + Duration::from_secs(30); + while serving(&client, &root).await.is_some() && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!( + serving(&client, &root).await.is_none(), + "the successor daemon {} outlived the test's containment; cleanup={cleanup:?}", + successor.0 + ); + assert!(cleanup.is_ok(), "{cleanup:?}"); + + assert!(status.success(), "the first daemon ended with {status}"); + assert_eq!(changes, 2, "the attached store holds both commits"); + assert!(stage_gone, "the stage was left behind"); + assert!(journal_gone, "an attach journal was left behind"); + assert!(progress.is_none(), "a complete store reports no derivation"); +} diff --git a/crates/kin-daemon/tests/mcp_session_idle_floor.rs b/crates/kin-daemon/tests/mcp_session_idle_floor.rs index dc4c88fdd..15820313e 100644 --- a/crates/kin-daemon/tests/mcp_session_idle_floor.rs +++ b/crates/kin-daemon/tests/mcp_session_idle_floor.rs @@ -16,7 +16,7 @@ //! the same `kin_mcp` delegate a `kin mcp start` session uses, which holds and //! renews the floor itself. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -53,6 +53,7 @@ fn spawn_daemon(repository: &common::NativeDaemonRepository) -> DaemonChild { .arg(repository.layout.working_dir()) .arg("--port") .arg("0") + .env("KIN_RUNTIME_MODE", "development") .env("KIN_DAEMON_DISABLE_LSP", "1") .env("KIN_DAEMON_AUTO_EMBED", "0") .env( @@ -127,6 +128,34 @@ async fn graph_status_call() -> Result<(), String> { Ok(()) } +struct McpRepositoryBinding { + previous_directory: PathBuf, +} + +impl McpRepositoryBinding { + fn new(repository: &common::NativeDaemonRepository) -> Self { + kin_cli::daemon_client::install_spawn_registrar(); + let previous_directory = std::env::current_dir().unwrap(); + std::env::set_current_dir(repository.working_dir()).unwrap(); + let binding = Self { previous_directory }; + let selected = kin_core::KinLayout::discover(repository.working_dir()) + .expect("the MCP fixture must bind its actual repository"); + assert_eq!( + selected.root().canonicalize().unwrap(), + repository.root().canonicalize().unwrap(), + "the MCP peer and native daemon must select the same store" + ); + binding + } +} + +impl Drop for McpRepositoryBinding { + fn drop(&mut self) { + std::env::set_current_dir(&self.previous_directory) + .expect("restore the MCP fixture working directory"); + } +} + #[tokio::test(flavor = "multi_thread")] async fn an_attached_mcp_session_keeps_a_short_window_daemon_alive_until_it_ends() { let repo = tempfile::tempdir().unwrap(); @@ -138,16 +167,25 @@ async fn an_attached_mcp_session_keeps_a_short_window_daemon_alive_until_it_ends // This test binary is the MCP session. The environment is what `kin mcp // start` leaves behind once it binds a daemon: the delegate URL and the - // bearer token. `KIN_NO_DAEMON` keeps a failed call from reviving a - // replacement, so a daemon that idled out stays visibly dead rather than - // being papered over by a fresh one. + // bearer token, selected home and actual repository. `KIN_NO_DAEMON` keeps + // a failed call from reviving a replacement, so a daemon that idled out + // stays visibly dead rather than being papered over by a fresh one. // Held for the whole test, so the writes are serialized with every other // environment-mutating test and restored when it ends. let _environment = kin_core::test_env::EnvVarGuard::new() + .with("KIN_RUNTIME_MODE", "development") + .with("KIN_HOME", repository.home.path()) + .with( + "KIN_REGISTRY_PATH", + repository.home.path().join("registry.toml"), + ) + .with("KIN_STORE", repository.root()) + .with("KIN_MCP_REPO", repository.working_dir()) .with("KIN_DAEMON_URL", &base) .with("KIN_DAEMON_AUTH_TOKEN", TOKEN) .with("KIN_NO_DAEMON", "1") .without("KIN_DAEMON_IDLE_TIMEOUT_SECS"); + let _repository_binding = McpRepositoryBinding::new(&repository); kin_mcp::session_idle_floor::enable(); let outcome = diff --git a/crates/kin-db/src/call_site_escape.rs b/crates/kin-db/src/call_site_escape.rs index 07dcc1844..c0440c02e 100644 --- a/crates/kin-db/src/call_site_escape.rs +++ b/crates/kin-db/src/call_site_escape.rs @@ -173,11 +173,15 @@ pub struct OutsideMemberRead { pub owner: String, } -/// The member reads a graph proves outside, read once for each file a -/// followed name is read in. -struct ProvenOutside<'a> { - graph: &'a dyn EscapeCensusGraph, - files: HashMap<&'a str, &'a CensusFile>, +/// What a graph holds for each file of a census's domain about the member +/// reads and import statements written there, read once for the whole census. +/// +/// Each answer is the file's alone: it does not depend on the focal whose +/// names led the census there. A batch census used to read it again for every +/// focal, and a guide of a change asks about every entity the change reaches, +/// so each file's coverage certificate was read and parsed once per entity. +#[derive(Default)] +struct FileReads { by_file: std::cell::RefCell>>, /// The member reads a graph types the receiver of, read the same way. typed_by_file: std::cell::RefCell>>, @@ -186,17 +190,27 @@ struct ProvenOutside<'a> { imports_by_file: std::cell::RefCell>>, } +/// The member reads a graph proves outside, read once for each file a +/// followed name is read in. +struct ProvenOutside<'a> { + graph: &'a dyn EscapeCensusGraph, + files: HashMap<&'a str, &'a CensusFile>, + reads: &'a FileReads, +} + impl<'a> ProvenOutside<'a> { - fn new(graph: &'a dyn EscapeCensusGraph, domain: &[&'a DomainFile]) -> Self { + fn new( + graph: &'a dyn EscapeCensusGraph, + domain: &[&'a DomainFile], + reads: &'a FileReads, + ) -> Self { Self { graph, files: domain .iter() .map(|file| (file.file.path.0.as_str(), &file.file)) .collect(), - by_file: Default::default(), - typed_by_file: Default::default(), - imports_by_file: Default::default(), + reads, } } @@ -214,7 +228,7 @@ impl<'a> ProvenOutside<'a> { let Some(file) = self.files.get(path) else { return false; }; - let mut by_file = self.imports_by_file.borrow_mut(); + let mut by_file = self.reads.imports_by_file.borrow_mut(); let imports = by_file .entry(path.to_string()) .or_insert_with(|| self.graph.imports_written_in(file)); @@ -249,7 +263,7 @@ impl<'a> ProvenOutside<'a> { } let receiver = occurrence.receiver.as_deref()?; let file = self.files.get(path)?; - let mut by_file = self.typed_by_file.borrow_mut(); + let mut by_file = self.reads.typed_by_file.borrow_mut(); let reads = by_file .entry(path.to_string()) .or_insert_with(|| self.graph.typed_member_reads(file)); @@ -274,7 +288,7 @@ impl<'a> ProvenOutside<'a> { } let receiver = occurrence.receiver.as_deref()?; let file = self.files.get(path)?; - let mut by_file = self.by_file.borrow_mut(); + let mut by_file = self.reads.by_file.borrow_mut(); let reads = by_file .entry(path.to_string()) .or_insert_with(|| self.graph.outside_member_reads(file)); @@ -830,6 +844,7 @@ pub fn focal_escape_evidence_batch_scoped( .collect(); } let domain = read_domain(graph, &languages, include_tests); + let reads = FileReads::default(); let roots = ImportRoots::of( domain .iter() @@ -848,7 +863,7 @@ pub fn focal_escape_evidence_batch_scoped( reason: "the selected tree could not be read", }; }; - census_one(graph, domain, &roots, focal, names) + census_one(graph, domain, &roots, &reads, focal, names) }) .collect() } @@ -1272,6 +1287,7 @@ fn census_one( graph: &dyn EscapeCensusGraph, domain: &[DomainFile], roots: &ImportRoots, + reads: &FileReads, focal: &Entity, names: &[String], ) -> FocalEscape { @@ -1403,7 +1419,7 @@ fn census_one( let mut outside: BTreeMap<(String, String, String), usize> = BTreeMap::new(); // Reads of a followed name on a receiver whose value the graph proves to // be of a type outside the repository, by receiver, type and name. - let proven = ProvenOutside::new(graph, &in_domain); + let proven = ProvenOutside::new(graph, &in_domain, reads); let mut outside_values: BTreeMap<(String, String, String), usize> = BTreeMap::new(); // Reads of a followed member name off the instance receiver of a class // that shares no instance with the focal's owner, by receiver, class and diff --git a/crates/kin-db/src/call_site_escape/tests.rs b/crates/kin-db/src/call_site_escape/tests.rs index a76820c2b..3df561a78 100644 --- a/crates/kin-db/src/call_site_escape/tests.rs +++ b/crates/kin-db/src/call_site_escape/tests.rs @@ -2232,3 +2232,93 @@ fn a_files_imported_names_are_read_from_its_record() { assert_eq!(imports.bound_in(&FilePathId::new("web/app.ts")), None); assert_eq!(imports.bound_in(&FilePathId::new("tests/missing.py")), None); } + +/// A census graph that counts how often the census asks it for each file's +/// member reads, and answers as the fixture it wraps does. +struct CountingReads<'f> { + inner: &'f Fixture, + asked: std::cell::RefCell>, +} + +impl EscapeCensusGraph for CountingReads<'_> { + fn census_files(&self, languages: &[LanguageId]) -> Option> { + self.inner.census_files(languages) + } + + fn entities_in(&self, path: &FilePathId) -> Option> { + self.inner.entities_in(path) + } + + fn import_evidence_into(&self, target: EntityId) -> Option> { + self.inner.import_evidence_into(target) + } + + fn uses_into(&self, target: EntityId) -> Option> { + self.inner.uses_into(target) + } + + fn outside_member_reads(&self, file: &CensusFile) -> Vec { + *self + .asked + .borrow_mut() + .entry(file.path.0.clone()) + .or_default() += 1; + Vec::new() + } +} + +/// A batch census reads each file's member reads once, however many of its +/// focals have names read there. +/// +/// What a graph holds for a file does not depend on the focal whose names led +/// the census to it. A guide of a change asks about every entity the change +/// reaches in one batch, and each focal used to read every such file again. +#[test] +fn a_batch_reads_each_files_member_reads_once_for_all_its_focals() { + let mut user = record(ModuleScope::Open); + for (name, line) in [("first", 3), ("second", 7)] { + let mut read = occurrence(line, OccurrenceContext::Argument, OccurrenceBinding::Member); + read.receiver = Some("obj".into()); + read.receiver_kind = Some(ReceiverKind::Other); + user.values.insert(name.into(), vec![read]); + } + let mut owner = record(ModuleScope::Open); + owner.classes.push(class("Owner", 1, &[], false)); + let fixture = Fixture::default() + .recorded("app.py", owner) + .declares( + "app.py", + "Owner.first", + EntityKind::Method, + 2, + Visibility::Public, + ) + .declares( + "app.py", + "Owner.second", + EntityKind::Method, + 4, + Visibility::Public, + ) + .recorded("user.py", user); + let first = fixture.named("Owner.first"); + let second = fixture.named("Owner.second"); + let counting = CountingReads { + inner: &fixture, + asked: Default::default(), + }; + let answers = focal_escape_evidence_batch( + &counting, + &[ + (first, kin_model::focal_call_names(first)), + (second, kin_model::focal_call_names(second)), + ], + ); + assert_eq!(answers.len(), 2); + assert_eq!( + counting.asked.borrow().get("user.py").copied(), + Some(1), + "the file both focals' names are read in is read once: {:?}", + counting.asked.borrow() + ); +} diff --git a/crates/kin-db/src/engine/graph.rs b/crates/kin-db/src/engine/graph.rs index c7e52eea1..892c53b7d 100644 --- a/crates/kin-db/src/engine/graph.rs +++ b/crates/kin-db/src/engine/graph.rs @@ -1211,6 +1211,116 @@ fn verification_relation_id(kind: RelationKind, src: GraphNodeId, dst: GraphNode )) } +/// Whether `relation` is part of the semantic workspace: both of its ends are +/// nodes a semantic observation carries. The remaining links belong to +/// verification and work records. +pub fn is_semantic_relation(relation: &Relation) -> bool { + fn semantic(node: GraphNodeId) -> bool { + match node { + GraphNodeId::Entity(_) + | GraphNodeId::Artifact(_) + | GraphNodeId::ExternalReference(_) => true, + GraphNodeId::Test(_) + | GraphNodeId::Contract(_) + | GraphNodeId::Work(_) + | GraphNodeId::VerificationRun(_) => false, + } + } + semantic(relation.src) && semantic(relation.dst) +} + +/// The record links a transaction retires with the entities it removes. +/// +/// A link from a verification or work record, such as a run's proof of an +/// entity or a test case's coverage of one, is that record projected onto the +/// entities the graph holds. The record owns it, not the source: a semantic +/// transition never carries it ([`is_semantic_relation`]), and a workspace +/// materialization projects it only onto targets that are present +/// ([`InMemoryGraph::restore_verification_proof_relations`]). So when a +/// transaction removes an entity, every record link naming that entity leaves +/// with it, and the record itself keeps its history. Without this, an edit that +/// deleted an entity a recorded test run had proved was refused as a stranded +/// endpoint, because the delta that removed the entity could not name a link it +/// is not allowed to carry. +/// +/// Only record links are retired here. A semantic relation left naming a +/// removed entity is still the planner's to collect, and the gate still refuses +/// it. A link the delta already names is left to the delta. +fn record_links_retired_with( + ent: &EntityData, + delta: &TransactionDelta, + prospective_entity_ids: &HashSet, +) -> Vec { + let departing: Vec = delta + .entity_deltas + .iter() + .filter_map(|entity_delta| match entity_delta { + EntityDelta::Removed { old } if !prospective_entity_ids.contains(&old.id) => { + Some(GraphNodeId::Entity(old.id)) + } + _ => None, + }) + .collect(); + if departing.is_empty() { + return Vec::new(); + } + let named: HashSet = delta + .relation_deltas + .iter() + .map(|relation_delta| match relation_delta { + RelationDelta::Added { new } => new.id, + RelationDelta::Modified { old, .. } | RelationDelta::Removed { old } => old.id, + }) + .collect(); + let mut retired: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for node in &departing { + let held = ent + .node_incoming + .get(node) + .into_iter() + .chain(ent.node_outgoing.get(node)) + .flatten(); + for relation_id in held { + let Some(relation) = ent.relations.get(relation_id) else { + continue; + }; + if is_semantic_relation(relation) + || named.contains(&relation.id) + || !seen.insert(relation.id) + { + continue; + } + retired.push(relation.clone()); + } + } + retired.sort_unstable_by_key(|relation| relation.id.0); + retired + .into_iter() + .map(|old| RelationDelta::Removed { old }) + .collect() +} + +/// Whether two graphs hold the same semantic relations, by value. +fn semantic_relations_match(left: &EntityData, right: &EntityData) -> bool { + let mut compared = 0usize; + for (id, relation) in left.relations.iter() { + if !is_semantic_relation(relation) { + continue; + } + compared += 1; + if right.relations.get(id) != Some(relation) { + return false; + } + } + compared + == right + .relations + .values() + .filter(|relation| is_semantic_relation(relation)) + .count() +} + fn verification_relation(kind: RelationKind, src: GraphNodeId, dst: GraphNodeId) -> Relation { Relation { id: verification_relation_id(kind, src, dst), @@ -2304,6 +2414,23 @@ fn embed_sort_key_for(ent: &EntityData, key: RetrievalKey, recency: EmbedRecency struct PendingMerkle { dirty: bool, seeds: HashSet, + /// The cache holds nothing yet and the next flush builds it whole. + /// + /// A graph restored from a snapshot starts here rather than hashing every + /// entity as it is built. Most restored graphs are scratch graphs that an + /// admission, a publication or a review builds, applies one delta to, + /// compares and drops, and none of them ever reads its root. + rebuild: bool, +} + +impl PendingMerkle { + fn rebuild_on_first_read() -> Self { + Self { + dirty: true, + seeds: HashSet::new(), + rebuild: true, + } + } } /// Write access to [`EntityData`] that records the mutation in the graph's @@ -2950,13 +3077,7 @@ impl InMemoryGraph { snapshot: &GraphSnapshot, admitted: Option<&crate::storage::change_validation::AdmittedChangeMap<'_>>, ) -> Result<(), KinDbError> { - match admitted { - Some(admitted) => snapshot.validate_authority_free_storage_admission_carrying( - crate::storage::repository::GitProjectionTreeReplay::Required, - admitted, - )?, - None => snapshot.validate_authority_free_storage_admission()?, - } + Self::prove_authority_free_snapshot_admission(snapshot, admitted)?; if snapshot.entity_revisions.is_empty() && !snapshot.changes.is_empty() { let _span = tracing::info_span!( "kindb.graph.validate_history", @@ -2974,6 +3095,27 @@ impl InMemoryGraph { Ok(()) } + /// The first of the two steps [`prove_authority_free_snapshot_decode`] + /// runs: storage admission over the authority-free payload, without the + /// revision derivation across the whole history. + /// + /// For a caller that replays the part of the history it has not already + /// proven on its own, and only that part. + /// + /// [`prove_authority_free_snapshot_decode`]: Self::prove_authority_free_snapshot_decode + pub(crate) fn prove_authority_free_snapshot_admission( + snapshot: &GraphSnapshot, + admitted: Option<&crate::storage::change_validation::AdmittedChangeMap<'_>>, + ) -> Result<(), KinDbError> { + match admitted { + Some(admitted) => snapshot.validate_authority_free_storage_admission_carrying( + crate::storage::repository::GitProjectionTreeReplay::Required, + admitted, + ), + None => snapshot.validate_authority_free_storage_admission(), + } + } + /// Restore a graph from a snapshot without constructing any text index. /// /// The graph root hash argument is accepted and ignored: text index @@ -3059,6 +3201,44 @@ impl InMemoryGraph { Self::from_locate_snapshot_inner(snapshot, text_index_path, expected_root_hash, true) } + /// Restore the links of durable verification targets in a workspace + /// projection. A target absent from this workspace keeps its historical + /// record without introducing a relation to a missing node. + pub(crate) fn restore_verification_proof_relations(snapshot: &mut GraphSnapshot) { + for run in snapshot.verification_runs.values() { + let Some(proof) = &run.proof else { + continue; + }; + for target in &proof.entities { + if !snapshot.entities.contains_key(target) { + continue; + } + let relation = verification_relation( + RelationKind::DerivedFrom, + GraphNodeId::VerificationRun(run.run_id), + GraphNodeId::Entity(*target), + ); + let incoming = snapshot.incoming.entry(*target).or_default(); + if !incoming.contains(&relation.id) { + incoming.push(relation.id); + incoming.sort_unstable_by_key(|id| id.0); + } + snapshot.relations.insert(relation.id, relation); + } + for target in &proof.work_items { + if !snapshot.work_items.contains_key(target) { + continue; + } + let relation = verification_relation( + RelationKind::DerivedFrom, + GraphNodeId::VerificationRun(run.run_id), + GraphNodeId::Work(*target), + ); + snapshot.relations.insert(relation.id, relation); + } + } + } + /// Restore a graph from a snapshot. /// /// This takes no expected graph root hash. It used to, and the value was @@ -3088,7 +3268,7 @@ impl InMemoryGraph { /// caller has already completed. Private: the two callers above are the /// only entry points, and each validates before reaching here. fn from_snapshot_inner_admitted( - snapshot: GraphSnapshot, + mut snapshot: GraphSnapshot, text_index_path: Option, read_only: bool, skip_text_index: bool, @@ -3096,7 +3276,16 @@ impl InMemoryGraph { if let Some(proof) = &snapshot.verified_binding_history { proof.validate_selected(&snapshot)?; } - let retrieval_authority_hash = compute_retrieval_authority_hash(&snapshot); + Self::restore_verification_proof_relations(&mut snapshot); + // The retrieval authority hash decides one thing here, whether a text + // index is current, and it costs a whole-graph Merkle root pass plus a + // digest of every retrieval domain. A graph built without a text index + // has nothing for it to decide. Those are the scratch graphs an edit's + // admission and publication build and drop, several per edit, and + // computing it for each was the largest single cost of admitting a + // one-function edit. + let retrieval_authority_hash = + (!skip_text_index).then(|| compute_retrieval_authority_hash(&snapshot)); let GraphSnapshot { verified_binding_history, version: _, @@ -3219,8 +3408,8 @@ impl InMemoryGraph { let text_index_current = text_index .as_ref() .and_then(TextIndex::graph_root_hash) - .map(|hash| hash == retrieval_authority_hash) - .unwrap_or(false); + .zip(retrieval_authority_hash) + .is_some_and(|(indexed, current)| indexed == current); let text_index_entity_coverage_current = if text_index_current { text_index .as_ref() @@ -3315,7 +3504,9 @@ impl InMemoryGraph { structured_artifacts, opaque_artifacts, }; - let merkle = MerkleCache::from_source(&entity_data); + // Built by the first read of the root, not here; see + // `PendingMerkle::rebuild`. + let merkle = MerkleCache::new(); let graph = Self { entities: RwLock::new(entity_data), @@ -3358,7 +3549,7 @@ impl InMemoryGraph { }), text_index, merkle: parking_lot::RwLock::new(merkle), - merkle_pending: parking_lot::Mutex::new(PendingMerkle::default()), + merkle_pending: parking_lot::Mutex::new(PendingMerkle::rebuild_on_first_read()), pending_delta: parking_lot::Mutex::new(PendingGraphDelta::default()), full_snapshot_required: AtomicBool::new(false), text_dirty: AtomicBool::new(false), @@ -3393,8 +3584,10 @@ impl InMemoryGraph { fail_next_text_rebuild: AtomicBool::new(false), }; - if !skip_text_index && (!text_index_current || !text_index_entity_coverage_current) { - graph.try_rebuild_text_index_from_graph(&graph, retrieval_authority_hash)?; + if let Some(retrieval_authority_hash) = retrieval_authority_hash { + if !text_index_current || !text_index_entity_coverage_current { + graph.try_rebuild_text_index_from_graph(&graph, retrieval_authority_hash)?; + } } Ok(graph) @@ -3750,15 +3943,22 @@ impl InMemoryGraph { /// `self.entities` (read or write) so the graph cannot mutate mid-refresh. /// `ent` is that already-held guard, passed in to avoid re-locking. fn flush_merkle(&self, ent: &EntityData) { - let seeds = { - let mut pending = self.merkle_pending.lock(); - if !pending.dirty { - return; - } - pending.dirty = false; - std::mem::take(&mut pending.seeds) - }; - self.merkle.write().refresh_affected(ent, seeds); + // The pending record is held until the cache is reconciled, so a + // concurrent reader that finds nothing pending reads a root this flush + // has already brought current. A graph whose cache was never built + // would otherwise hand that reader the empty graph's root. + let mut pending = self.merkle_pending.lock(); + if !pending.dirty { + return; + } + let seeds = std::mem::take(&mut pending.seeds); + if std::mem::take(&mut pending.rebuild) { + self.merkle.write().rebuild_from_source(ent); + } else { + self.merkle.write().refresh_affected(ent, seeds); + } + pending.dirty = false; + drop(pending); #[cfg(test)] self.merkle_flush_count .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -4760,6 +4960,11 @@ impl InMemoryGraph { /// Comparing a graph with itself short-circuits rather than taking the same /// lock twice, since a second read acquisition behind a waiting writer is a /// deadlock rather than a re-entrant read. + /// + /// The relations compared are the semantic ones, the ones + /// [`Self::semantic_observation`] carries. A relation from a test case, a + /// verification run, a work item or a contract is that record's, and two + /// graphs that differ only in those agree on the workspace. pub fn semantic_workspace_matches(&self, other: &InMemoryGraph) -> bool { if std::ptr::eq(self, other) { return true; @@ -4767,12 +4972,21 @@ impl InMemoryGraph { let left = self.entities.read(); let right = other.entities.read(); left.entities == right.entities - && left.relations == right.relations + && semantic_relations_match(&left, &right) && left.resolved_tree == right.resolved_tree } /// Coherent current semantic observation only. No history, facets, work, /// sessions, or vector/text indexes are copied at an admission boundary. + /// + /// Its relations are the ones between the nodes it carries: entities, + /// artifacts and symbols outside the repository. The relation store also + /// holds the links of verification and work records, from a test case to + /// what it covers and from a verification run to what it proves. Those + /// records are not copied here, so their relations are not either. Copied + /// without them, each one named an end the observation did not hold, and + /// a graph staged from the observation refused it, which stopped every + /// working-copy admission after the first recorded run. pub fn semantic_observation(&self) -> GraphSnapshot { let ent = self.entities.read(); Self::semantic_observation_from_entities(&ent) @@ -4788,6 +5002,7 @@ impl InMemoryGraph { snapshot.relations = ent .relations .iter() + .filter(|(_, relation)| is_semantic_relation(relation)) .map(|(id, value)| (*id, value.clone())) .collect(); snapshot.external_references = ent @@ -4963,6 +5178,7 @@ impl InMemoryGraph { relations: ent .relations .iter() + .filter(|(_, relation)| is_semantic_relation(relation)) .map(|(id, relation)| (*id, relation.clone())) .collect(), external_references: ent @@ -4978,8 +5194,9 @@ impl InMemoryGraph { /// Export exactly the domains a workspace comparison reads, by consuming /// this graph. /// - /// Identical in value to calling [`to_snapshot`] and keeping four of its - /// fields, and very different in cost. `to_snapshot` clones every store + /// Identical in value to [`crate::WorkspaceGraphFacts::from_snapshot`] on + /// [`to_snapshot`], including its semantic relation filter, and very + /// different in cost. `to_snapshot` clones every store /// under its own lock, so a caller that wanted entity truth also paid for /// a copy of the whole change map, the work, review, verification, /// provenance and session domains, and every adjacency and secondary index @@ -4993,7 +5210,11 @@ impl InMemoryGraph { let ent = self.entities.into_inner(); crate::storage::format::WorkspaceGraphFacts { entities: ent.entities.into_iter().collect(), - relations: ent.relations.into_iter().collect(), + relations: ent + .relations + .into_iter() + .filter(|(_, relation)| is_semantic_relation(relation)) + .collect(), external_references: ent.external_references.into_iter().collect(), resolved_tree: ent.resolved_tree, resolution_records: ent.resolution_records.into_records(), @@ -5041,6 +5262,15 @@ impl InMemoryGraph { self.truth_epoch.load(Ordering::Acquire) } + /// Keep this graph's history records that are too large to keep between + /// reads decoded for as long as the returned hold lives. A read that + /// replays across the same change several times, as a guide of one commit + /// does, holds them for its own life. See + /// [`crate::storage::ChangeMap::hold_large_records`]. + pub fn hold_large_change_records(&self) -> Option { + self.changes.read().changes.hold_large_records() + } + /// A process-unique identity for this graph instance, never reused. /// /// A cache of something derived from a graph keys on this together with @@ -5071,15 +5301,44 @@ impl InMemoryGraph { if std::ptr::eq(self, authority) { return self.entities.read().verified_binding_history.is_some(); } + let semantics_match = |target: &EntityData, source: &EntityData| { + target.entities == source.entities + && semantic_relations_match(target, source) + && target.external_references == source.external_references + && target.resolution_records == source.resolution_records + && target.resolved_tree == source.resolved_tree + }; + // What a restore leaves is the authority's proof when the semantics + // match and no proof when they do not. A graph already holding exactly + // that is not written: the write would change nothing, and it would + // still move the truth epoch, which tells every cache keyed on it that + // truth moved. The daemon restores after every publication it + // finalizes, so on a repository that never holds a proof each edit + // threw away every answer computed since the edit landed, including a + // review guide read already in flight. + let already = |target: &EntityData, source: &EntityData| { + let restored = semantics_match(target, source) + .then_some(source.verified_binding_history.as_ref()) + .flatten(); + (target.verified_binding_history.as_ref() == restored) + .then(|| target.verified_binding_history.is_some()) + }; // A stable address order avoids inverse-call deadlocks. Authority is // ordinarily immutable, but the public method need not assume that. + let held = if std::ptr::from_ref(self) < std::ptr::from_ref(authority) { + let target = self.entities.read(); + let source = authority.entities.read(); + already(&target, &source) + } else { + let source = authority.entities.read(); + let target = self.entities.read(); + already(&target, &source) + }; + if let Some(holds_proof) = held { + return holds_proof; + } let install = |mut target: TruthWriteGuard<'_>, source: &EntityData| { - if target.entities != source.entities - || target.relations != source.relations - || target.external_references != source.external_references - || target.resolution_records != source.resolution_records - || target.resolved_tree != source.resolved_tree - { + if !semantics_match(&target, source) { return false; } target.verified_binding_history = source.verified_binding_history.clone(); @@ -10300,8 +10559,19 @@ impl EntityStore for InMemoryGraph { KinDbError::StorageError(format!("transaction resolution records: {error}")) })?; } + // Record links naming an entity this transaction removes go with + // it, through the same checks, persistence and indexes as the + // delta's own relation removals. + let retired_record_links = + record_links_retired_with(&ent, delta, &prospective_entity_ids); + let relation_deltas = || { + delta + .relation_deltas + .iter() + .chain(retired_record_links.iter()) + }; let mut prospective_relations = ent.relations.clone(); - for relation_delta in &delta.relation_deltas { + for relation_delta in relation_deltas() { match relation_delta { RelationDelta::Added { new } => { if prospective_relations.contains_key(&new.id) { @@ -10551,12 +10821,12 @@ impl EntityStore for InMemoryGraph { // 5. Process relation deltas, recorded through indexed recorders so // a transaction carrying a whole workspace overlay records it in // linear work. - if !delta.relation_deltas.is_empty() { + if !delta.relation_deltas.is_empty() || !retired_record_links.is_empty() { let pending_delta = &mut pending.delta; let mut relation_record = IndexedDeltaRecorder::new(&mut pending_delta.relations); let mut outgoing_record = IndexedDeltaRecorder::new(&mut pending_delta.outgoing); let mut incoming_record = IndexedDeltaRecorder::new(&mut pending_delta.incoming); - for rel_delta in &delta.relation_deltas { + for rel_delta in relation_deltas() { match rel_delta { RelationDelta::Added { new: relation } => { insert_relation_indexes(&mut ent, relation); @@ -10615,7 +10885,7 @@ impl EntityStore for InMemoryGraph { // touched embeds differently even when its own payload formats the // same. Take those ids back out of the skip set rather than // reasoning about them inside each relation arm. - for rel_delta in &delta.relation_deltas { + for rel_delta in relation_deltas() { let endpoints = match rel_delta { RelationDelta::Added { new } => entity_ids_for_relation(new), RelationDelta::Removed { old } => entity_ids_for_relation(old), @@ -11199,6 +11469,11 @@ impl ChangeStore for InMemoryGraph { self.changes.read().changes.read_change_for_serving(id) } + /// Answered from the history's index, without reading the change. + fn holds_change(&self, id: &SemanticChangeId) -> Result { + Ok(self.changes.read().changes.contains_change(id)) + } + fn get_changes_since( &self, base: &SemanticChangeId, @@ -11476,6 +11751,21 @@ impl InMemoryGraph { }) } + /// The annotations that carry a review flag still open, in no order. + /// + /// Read on every agent call, to tell whether an answer returned a flagged + /// entity, so it clones only the flags and nothing else the work graph + /// holds. + pub fn open_review_flags(&self) -> Vec { + self.work + .read() + .annotations + .values() + .filter(|annotation| annotation.open_review_flag().is_some()) + .cloned() + .collect() + } + /// Recall annotations ("memory deposits") for an entity, re-anchoring across /// renames. Uses default [`RecallOptions`] (exclude `Stale` and fingerprint /// collisions). @@ -13019,6 +13309,123 @@ mod tests { ); } + /// A graph restored without a text index takes no whole-graph root pass. + /// + /// The retrieval authority hash decides only whether a text index is + /// current, so a graph with none has nothing for it to decide. An edit's + /// admission and publication build several of these graphs and drop them, + /// and the pass each one paid was the largest single cost of admitting a + /// one-function edit. The graph itself is unchanged: its live root and its + /// retrieval authority, asked for afterwards, are the ones an indexed + /// restore of the same snapshot reports. + #[test] + fn a_restore_without_a_text_index_takes_no_graph_root_pass() { + let mut snapshot = GraphSnapshot::empty(); + for name in ["parseRegistry", "renderRegistry", "flushRegistry"] { + let entity = test_entity(name, "src/registry.rs"); + snapshot.entities.insert(entity.id, entity); + } + + root_hash_passes::reset(); + let bare = InMemoryGraph::from_snapshot_without_text_index(snapshot.clone()).unwrap(); + assert_eq!( + root_hash_passes::count(), + 0, + "a graph with no text index has no use for the retrieval authority hash" + ); + + let indexed = InMemoryGraph::from_snapshot(snapshot).unwrap(); + assert_eq!(bare.compute_root_hash(), indexed.compute_root_hash()); + assert_eq!( + bare.retrieval_authority_hash(), + indexed.retrieval_authority_hash() + ); + } + + /// A restored graph hashes its entities when its root is first read, not + /// while it is being built, and the root it then reports is the one an + /// eager build gives, mutations made before that read included. + /// + /// Most restored graphs are scratch graphs an admission, a publication or + /// a review builds and drops without reading a root, and the eager build + /// was what each of them paid beyond its indexes. + #[test] + fn a_restored_graph_hashes_its_entities_when_its_root_is_first_read() { + let mut snapshot = GraphSnapshot::empty(); + for name in ["parseRegistry", "renderRegistry"] { + let entity = test_entity(name, "src/registry.rs"); + snapshot.entities.insert(entity.id, entity); + } + + let graph = InMemoryGraph::from_snapshot_without_text_index(snapshot.clone()).unwrap(); + assert!( + graph.merkle.read().is_empty(), + "nothing is hashed at restore" + ); + assert_eq!( + graph.compute_root_hash(), + compute_graph_root_hash(&snapshot) + ); + assert_eq!( + graph + .merkle_flush_count + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "the first read builds the cache in one flush" + ); + assert_eq!( + graph.compute_root_hash(), + compute_graph_root_hash(&snapshot) + ); + assert_eq!( + graph + .merkle_flush_count + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "a second read finds it current" + ); + + let edited = InMemoryGraph::from_snapshot_without_text_index(snapshot).unwrap(); + edited + .upsert_entity(&test_entity("flushRegistry", "src/registry.rs")) + .unwrap(); + assert_eq!( + edited.compute_root_hash(), + compute_graph_root_hash(&edited.to_snapshot()) + ); + } + + /// A restore that would leave the graph's proof exactly as it is does not + /// write, so it does not move the truth epoch. + /// + /// The daemon restores after every publication it finalizes. On a store + /// that holds no proof, each of those restores used to move the epoch + /// without changing anything, which discarded every cache keyed on it and + /// sent a review read already in flight back to build again. + #[test] + fn a_restore_that_changes_no_proof_leaves_truth_where_it_was() { + let mut snapshot = GraphSnapshot::empty(); + for name in ["parseRegistry", "renderRegistry"] { + let entity = test_entity(name, "src/registry.rs"); + snapshot.entities.insert(entity.id, entity); + } + let live = InMemoryGraph::from_snapshot_without_text_index(snapshot.clone()).unwrap(); + let authority = InMemoryGraph::from_snapshot_without_text_index(snapshot).unwrap(); + + // The same semantics and no proof on either side. + let epoch = live.truth_epoch(); + assert!(!live.restore_binding_history_from(&authority)); + assert_eq!(live.truth_epoch(), epoch); + + // Semantics that differ from authority, and no proof to revoke. + let edit = test_entity("flushRegistry", "src/registry.rs"); + live.upsert_entity(&edit).unwrap(); + let epoch = live.truth_epoch(); + assert!(!live.restore_binding_history_from(&authority)); + assert_eq!(live.truth_epoch(), epoch); + assert!(live.get_entity(&edit.id).unwrap().is_some()); + } + /// The in-run vector-sidecar flush policy: the first checkpoint of a run is /// always due; afterwards a checkpoint is due once EITHER the time interval /// OR the batch backstop is crossed, whichever comes first. Pure, so both @@ -15481,6 +15888,297 @@ mod tests { ); } + /// A semantic observation carries no test case and no verification run, + /// so it carries no relation from one either. Copied with those relations, + /// the observation named ends it did not hold, a graph staged from it was + /// refused with `unadmitted source endpoint`, and every working-copy + /// admission after the first recorded run failed. The live graph keeps + /// the relations, and two graphs that differ only in them still agree on + /// the workspace. Falsify by dropping the filter in + /// `semantic_observation_from_entities`. + #[test] + fn a_semantic_observation_leaves_out_the_relations_of_verification_records() { + let graph = InMemoryGraph::new(); + let covered = test_entity("covered", "src/a.rs"); + let caller = test_entity("caller", "src/b.rs"); + graph.upsert_entity(&covered).unwrap(); + graph.upsert_entity(&caller).unwrap(); + let call = Relation { + id: RelationId::new(), + kind: RelationKind::Calls, + src: GraphNodeId::Entity(caller.id), + dst: GraphNodeId::Entity(covered.id), + confidence: 1.0, + origin: RelationOrigin::Parsed, + created_in: None, + import_source: None, + evidence: Vec::new(), + }; + graph.upsert_relation(&call).unwrap(); + let test_case = TestCase { + test_id: TestId::new(), + name: "covers_a".into(), + language: "rust".into(), + kind: TestKind::Unit, + scopes: vec![WorkScope::Entity(covered.id)], + runner: TestRunner::Cargo, + file_origin: Some(FilePathId::new("tests/a.rs")), + }; + graph.create_test_case(&test_case).unwrap(); + let run = VerificationRun { + run_id: VerificationRunId::new(), + test_ids: vec![test_case.test_id], + status: VerificationStatus::Passing, + runner: TestRunner::Cargo, + started_at: Timestamp::now(), + finished_at: None, + duration_ms: None, + evidence_blob: None, + exit_code: Some(0), + ran_against: Vec::new(), + proof: None, + }; + graph.create_verification_run(&run).unwrap(); + graph + .link_run_proves_entity(&run.run_id, &covered.id) + .unwrap(); + assert_eq!( + graph.relation_count(), + 3, + "the call, the cover and the proof" + ); + + let observation = graph.semantic_observation(); + assert_eq!( + observation.relations.keys().copied().collect::>(), + [call.id], + "only the relation between two entities is semantic" + ); + let expected = observation.relations.clone(); + assert_eq!(graph.workspace_graph_facts().relations, expected); + assert_eq!( + crate::WorkspaceGraphFacts::from_snapshot(graph.to_snapshot()).relations, + expected + ); + let consumed = InMemoryGraph::from_snapshot_without_text_index(graph.to_snapshot()) + .unwrap() + .into_workspace_graph_facts(); + assert_eq!(consumed.relations, expected); + let staged = InMemoryGraph::from_snapshot_without_text_index(observation) + .expect("a graph staged from the observation holds every end it names"); + assert!(graph.semantic_workspace_matches(&staged)); + assert!(staged.semantic_workspace_matches(&graph)); + assert_eq!( + graph.list_runs_proving_entity(&covered.id).unwrap().len(), + 1, + "the live graph keeps the proof" + ); + + // A semantic relation only one side holds is still a difference. + staged.remove_relation(&call.id).unwrap(); + assert!(!graph.semantic_workspace_matches(&staged)); + assert!(!staged.semantic_workspace_matches(&graph)); + } + + #[test] + fn durable_proof_targets_restore_links_without_inventing_missing_endpoints() { + let graph = InMemoryGraph::new(); + let covered = test_entity("covered", "src/a.rs"); + let dependency = test_entity("dependency", "src/b.rs"); + graph.upsert_entity(&covered).unwrap(); + graph.upsert_entity(&dependency).unwrap(); + let call = test_relation(dependency.id, covered.id, RelationKind::Calls); + graph.upsert_relation(&call).unwrap(); + let work = WorkItem { + work_id: WorkId::new(), + kind: WorkKind::Feature, + title: "Covered work".into(), + description: String::new(), + status: WorkStatus::Proposed, + priority: Priority::High, + scopes: vec![WorkScope::Entity(covered.id)], + acceptance_criteria: Vec::new(), + external_refs: Vec::new(), + created_by: IdentityRef::human("alice"), + created_at: Timestamp::now(), + }; + graph.create_work_item(&work).unwrap(); + let run = VerificationRun { + run_id: VerificationRunId::new(), + test_ids: Vec::new(), + status: VerificationStatus::Passing, + runner: TestRunner::Cargo, + started_at: Timestamp::now(), + finished_at: None, + duration_ms: None, + evidence_blob: None, + exit_code: Some(0), + ran_against: vec![kin_model::RunFingerprint { + entity: dependency.id, + behavior_hash: dependency.fingerprint.behavior_hash, + }], + proof: Some(kin_model::VerificationProof { + entities: vec![covered.id, EntityId::new()], + work_items: vec![work.work_id, WorkId::new()], + }), + }; + graph.create_verification_run(&run).unwrap(); + assert_eq!(graph.relation_count(), 1, "no proof links were recorded"); + let recorded = graph.into_snapshot(); + let semantic_digest = crate::storage::binding_history::graph_digest(&recorded).unwrap(); + let restored = InMemoryGraph::from_snapshot_without_text_index(recorded) + .expect("missing targets cannot create invalid relation endpoints"); + assert_eq!( + restored.list_runs_proving_entity(&covered.id).unwrap(), + std::slice::from_ref(&run) + ); + assert_eq!( + restored.list_runs_proving_work(&work.work_id).unwrap(), + std::slice::from_ref(&run) + ); + assert!(restored + .list_runs_proving_entity(&dependency.id) + .unwrap() + .is_empty()); + assert_eq!(restored.relation_count(), 3); + let projected = restored.into_snapshot(); + assert_eq!( + crate::storage::binding_history::graph_digest(&projected).unwrap(), + semantic_digest, + "proof links do not change semantic binding history" + ); + let restored = InMemoryGraph::from_snapshot_without_text_index(projected).unwrap(); + assert_eq!(restored.relation_count(), 3, "restoration is idempotent"); + assert!(matches!( + restored.lookup_relation_by_id(&call.id).unwrap(), + kin_model::RelationLookup::Present(_) + )); + assert_eq!( + restored.list_runs_proving_entity(&covered.id).unwrap(), + [run] + ); + } + + /// A transaction that removes an entity a test case covers and a recorded + /// run proved is taken when it carries only the entity's semantic + /// relations. A semantic delta may not carry record links, so the store + /// retires the ones naming the removed entity and the records keep their + /// history. A semantic relation the delta leaves behind is still refused. + #[test] + fn removing_an_entity_retires_the_record_links_that_name_it() { + let graph = InMemoryGraph::new(); + let removed = test_entity("negate", "src/math.rs"); + let kept = test_entity("add", "src/math.rs"); + let check = test_entity("check_math", "tests/math.rs"); + for entity in [&removed, &kept, &check] { + graph.upsert_entity(entity).unwrap(); + } + let calls_removed = test_relation(check.id, removed.id, RelationKind::Calls); + let calls_kept = test_relation(check.id, kept.id, RelationKind::Calls); + graph.upsert_relation(&calls_removed).unwrap(); + graph.upsert_relation(&calls_kept).unwrap(); + let test_case = TestCase { + test_id: TestId::new(), + name: "check_math".into(), + language: "rust".into(), + kind: TestKind::Unit, + scopes: vec![WorkScope::Entity(removed.id), WorkScope::Entity(kept.id)], + runner: TestRunner::Cargo, + file_origin: Some(FilePathId::new("tests/math.rs")), + }; + graph.create_test_case(&test_case).unwrap(); + let run = VerificationRun { + run_id: VerificationRunId::new(), + test_ids: vec![test_case.test_id], + status: VerificationStatus::Passing, + runner: TestRunner::Cargo, + started_at: Timestamp::now(), + finished_at: None, + duration_ms: None, + evidence_blob: None, + exit_code: Some(0), + ran_against: Vec::new(), + proof: Some(kin_model::VerificationProof { + entities: vec![removed.id, kept.id], + work_items: Vec::new(), + }), + }; + graph.create_verification_run(&run).unwrap(); + graph + .link_run_proves_entity(&run.run_id, &removed.id) + .unwrap(); + graph.link_run_proves_entity(&run.run_id, &kept.id).unwrap(); + assert_eq!( + graph.relation_count(), + 6, + "two calls, two covers and two proofs" + ); + + let leaves_the_call = TransactionDelta { + entity_deltas: vec![EntityDelta::Removed { + old: removed.clone(), + }], + ..TransactionDelta::default() + }; + let refused = graph + .apply_transaction_delta(&leaves_the_call) + .expect_err("a semantic relation naming the removed entity is the planner's to carry"); + assert!( + refused.to_string().contains(&format!( + "transaction relation {} has unadmitted destination endpoint", + calls_removed.id + )), + "{refused}" + ); + assert_eq!(graph.relation_count(), 6, "a refusal changes nothing"); + + graph + .apply_transaction_delta(&TransactionDelta { + entity_deltas: vec![EntityDelta::Removed { + old: removed.clone(), + }], + relation_deltas: vec![RelationDelta::Removed { + old: calls_removed.clone(), + }], + ..TransactionDelta::default() + }) + .expect("record links leave with the entity they name"); + assert!(graph.get_entity(&removed.id).unwrap().is_none()); + assert!(graph + .list_runs_proving_entity(&removed.id) + .unwrap() + .is_empty()); + assert!(graph.get_tests_for_entity(&removed.id).unwrap().is_empty()); + assert_eq!( + graph.list_runs_proving_entity(&kept.id).unwrap(), + std::slice::from_ref(&run) + ); + assert_eq!( + graph.get_tests_for_entity(&kept.id).unwrap(), + std::slice::from_ref(&test_case) + ); + assert_eq!(graph.relation_count(), 3, "the kept call, cover and proof"); + assert_eq!( + graph.get_verification_run(&run.run_id).unwrap().as_ref(), + Some(&run), + "the run keeps the proof it recorded" + ); + assert_eq!( + graph.get_test_case(&test_case.test_id).unwrap().as_ref(), + Some(&test_case), + "the test case keeps the scopes it recorded" + ); + + let reopened = InMemoryGraph::from_snapshot_without_text_index(graph.into_snapshot()) + .expect("the retired links leave no stranded endpoint behind"); + assert!(reopened + .list_runs_proving_entity(&removed.id) + .unwrap() + .is_empty()); + assert_eq!(reopened.list_runs_proving_entity(&kept.id).unwrap(), [run]); + assert_eq!(reopened.relation_count(), 3); + } + /// The control the fix is most likely to break. `create_test_case` inserts /// the test into the verification map and then batches `Test -> Entity` /// edges, so a check that consults only `ent.entities` passes every test @@ -19711,6 +20409,7 @@ mod tests { authored_by: IdentityRef::human("bob"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }; let id = ann.annotation_id; @@ -19879,6 +20578,7 @@ mod tests { kind: ActorKind::Human, display_name: "Alice".into(), external_refs: vec![], + agent_session: None, }; let aid = actor.actor_id; @@ -25154,6 +25854,7 @@ mod tests { authored_by: IdentityRef::human("alice"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }; graph.create_annotation(&ann).unwrap(); let anchor = graph @@ -25214,6 +25915,7 @@ mod tests { authored_by: IdentityRef::human("t"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }; graph.create_annotation(&ann).unwrap(); id diff --git a/crates/kin-db/src/engine/mod.rs b/crates/kin-db/src/engine/mod.rs index 685eef1fa..a6cd2bbdc 100644 --- a/crates/kin-db/src/engine/mod.rs +++ b/crates/kin-db/src/engine/mod.rs @@ -6,6 +6,8 @@ pub mod incremental; mod index; mod traverse; +pub use graph::is_semantic_relation; + #[cfg(feature = "vector")] pub use graph::VectorSalvageStats; pub use graph::{ diff --git a/crates/kin-db/src/lib.rs b/crates/kin-db/src/lib.rs index 22a4042fa..c075be3f0 100644 --- a/crates/kin-db/src/lib.rs +++ b/crates/kin-db/src/lib.rs @@ -27,10 +27,10 @@ pub use embed::{ #[cfg(feature = "vector")] pub use engine::VectorSalvageStats; pub use engine::{ - EmbeddingStatus, EnrichmentContextStatus, EnrichmentStatusError, EnrichmentStatusFacts, - FileEnrichmentStatus, InMemoryGraph, PersistenceEpoch, ProducedSemanticSearch, - ProducedSemanticSearchBatch, ResolvedRetrievalItem, SourceDerivationFacts, - SourceDerivationItem, SourceDerivationLimit, SourceDerivationLimits, + is_semantic_relation, EmbeddingStatus, EnrichmentContextStatus, EnrichmentStatusError, + EnrichmentStatusFacts, FileEnrichmentStatus, InMemoryGraph, PersistenceEpoch, + ProducedSemanticSearch, ProducedSemanticSearchBatch, ResolvedRetrievalItem, + SourceDerivationFacts, SourceDerivationItem, SourceDerivationLimit, SourceDerivationLimits, SourceDerivationUnavailable, SourceEntityBinding, SourceLayoutFact, SourceOpaqueFact, SourceReservedRelation, }; diff --git a/crates/kin-db/src/storage/authority_frame.rs b/crates/kin-db/src/storage/authority_frame.rs index cd878c499..2e373fa6f 100644 --- a/crates/kin-db/src/storage/authority_frame.rs +++ b/crates/kin-db/src/storage/authority_frame.rs @@ -1512,6 +1512,14 @@ pub(crate) fn first_difference( } fn same_changes(left: &super::ChangeMap, right: &super::ChangeMap) -> Result { + // Two maps over the same base and the same overlay are one history, not + // two equal ones: a clone shares both, and admitting a change detaches the + // overlay. A frame that carries no change reconstructs exactly such a + // clone, so the proof for it reads no record. Decoding every change of + // both maps to conclude that was a whole-history read per ref write. + if left.shares_storage(right) { + return Ok(true); + } if left.len() != right.len() { return Ok(false); } diff --git a/crates/kin-db/src/storage/backend.rs b/crates/kin-db/src/storage/backend.rs index 6ffeb3517..8a82b0247 100644 --- a/crates/kin-db/src/storage/backend.rs +++ b/crates/kin-db/src/storage/backend.rs @@ -4468,10 +4468,13 @@ struct LocalRepositoryLock { #[derive(Debug)] pub(crate) struct LocalAuthorityFreezeLock { repo_id: String, - authority: SnapshotAuthority, - /// Acknowledged authority frames between the snapshot base and the head, - /// read and digest-verified under the same lock hold as `authority`. - frames: Vec, + /// The head's bytes, when this lock was taken to read an existing head. + /// + /// `None` for the lock a commit retained. The committing writer holds the + /// exact state it serialized, so copying the base snapshot into the lock, + /// or reading it back after a frame append, would only cost time for bytes + /// no caller of a committed freeze reads. + captured: Option, /// The head these bytes are, named by digests that were checked against /// the bytes under this lock hold. identity: DurableAuthorityIdentity, @@ -4481,6 +4484,15 @@ pub(crate) struct LocalAuthorityFreezeLock { lock: LocalRepositoryLock, } +/// One existing head read under a freeze lock. +#[derive(Debug)] +struct CapturedAuthority { + authority: SnapshotAuthority, + /// Acknowledged authority frames between the snapshot base and the head, + /// read and digest-verified under the same lock hold as `authority`. + frames: Vec, +} + impl LocalAuthorityFreezeLock { pub(crate) fn ensure_no_active_session_publication(&self) -> Result<(), KinDbError> { if self.active_session_publication { @@ -4491,20 +4503,37 @@ impl LocalAuthorityFreezeLock { Ok(()) } - pub(crate) fn authority(&self) -> &SnapshotAuthority { - &self.authority + fn captured(&self) -> Result<&CapturedAuthority, KinDbError> { + self.captured + .as_ref() + .ok_or_else(|| Self::captured_nothing(&self.repo_id)) + } + + fn captured_nothing(repo_id: &str) -> KinDbError { + KinDbError::StorageError(format!( + "repo {repo_id}: a freeze retained by a commit captured no authority bytes" + )) + } + + pub(crate) fn authority(&self) -> Result<&SnapshotAuthority, KinDbError> { + Ok(&self.captured()?.authority) } - pub(crate) fn frames(&self) -> &[PersistedDelta] { - &self.frames + pub(crate) fn frames(&self) -> Result<&[PersistedDelta], KinDbError> { + Ok(&self.captured()?.frames) } /// The captured head and acknowledged frames, with the lock released. /// /// Both were digest-checked against the authority record under this hold, /// so a reader that only decodes them needs the lock no longer. - pub(crate) fn into_captured(self) -> (SnapshotAuthority, Vec) { - (self.authority, self.frames) + pub(crate) fn into_captured( + self, + ) -> Result<(SnapshotAuthority, Vec), KinDbError> { + match self.captured { + Some(captured) => Ok((captured.authority, captured.frames)), + None => Err(Self::captured_nothing(&self.repo_id)), + } } pub(crate) fn identity(&self) -> &DurableAuthorityIdentity { @@ -4614,6 +4643,22 @@ struct LocalAuthorityRecord { session_publications: Vec, } +impl LocalAuthorityRecord { + /// The head this record acknowledges: its base snapshot and every + /// acknowledged frame, by the digests the record binds them to. + fn durable_identity(&self) -> DurableAuthorityIdentity { + DurableAuthorityIdentity { + snapshot_generation: self.snapshot_generation, + snapshot_sha256: self.snapshot_sha256.clone(), + frames: self + .acknowledged_deltas + .iter() + .map(|identity| (identity.generation, identity.sha256.clone())) + .collect(), + } + } +} + impl LocalFileBackend { /// Create a new local backend rooted at `base_path`. pub fn new(base_path: impl Into) -> Self { @@ -6676,20 +6721,11 @@ impl LocalFileBackend { // frame's by the capture beside it. A journal-free record acknowledges // no frame, which the record decoder already enforces. let active_session_publication = record.active_session().is_some(); - let identity = DurableAuthorityIdentity { - snapshot_generation: record.snapshot_generation, - snapshot_sha256: record.snapshot_sha256, - frames: record - .acknowledged_deltas - .into_iter() - .map(|identity| (identity.generation, identity.sha256)) - .collect(), - }; + let identity = record.durable_identity(); self.confirm_existing_lock_visible(&lock.namespace)?; Ok(Some(LocalAuthorityFreezeLock { repo_id: repo_id.to_string(), - authority, - frames, + captured: Some(CapturedAuthority { authority, frames }), identity, active_session_publication, lock, @@ -8201,19 +8237,43 @@ impl LocalFileBackend { /// snapshot promotion follows. The base snapshot bytes are not re-read /// here: installed files are immutable, the record binds the base by /// digest, and every open verifies that binding before it trusts the base. + /// + /// Returns the committed head generation and the head the record now + /// acknowledges, named from the record this call wrote or, for an exact + /// retry, the record that already acknowledged these bytes. fn save_authority_frame_unlocked( &self, namespace: &LocalRepositoryCapability, frame_data: &[u8], expected_gen: Generation, history_validator_version: Option, - ) -> Result { + ) -> Result<(Generation, DurableAuthorityIdentity), KinDbError> { let repo_id = &namespace.repo_id; - let Some(mut record) = self.read_authority_record_unlocked(namespace)? else { + let Some(record) = self.read_authority_record_unlocked(namespace)? else { return Err(KinDbError::StorageError(format!( "repo {repo_id} has no atomic local snapshot authority; persist a full snapshot before authority frames" ))); }; + self.save_authority_frame_from_record_unlocked( + namespace, + record, + frame_data, + expected_gen, + history_validator_version, + ) + } + + /// Append against a record already read and validated under the same + /// repository lock, without reading and hashing its journal twice. + fn save_authority_frame_from_record_unlocked( + &self, + namespace: &LocalRepositoryCapability, + mut record: LocalAuthorityRecord, + frame_data: &[u8], + expected_gen: Generation, + history_validator_version: Option, + ) -> Result<(Generation, DurableAuthorityIdentity), KinDbError> { + let repo_id = &namespace.repo_id; record.refuse_active_session()?; if !record.supports_frames() && !record.acknowledged_deltas.is_empty() { return Err(KinDbError::StorageError(format!( @@ -8265,7 +8325,7 @@ impl LocalFileBackend { "repo {repo_id} exact authority frame retry refers to committed generation {current_gen}, but final namespace confirmation failed: {error}" ))); } - return Ok(current_gen); + return Ok((current_gen, record.durable_identity())); } } if current_gen != expected_gen { @@ -8323,7 +8383,60 @@ impl LocalFileBackend { "repo {repo_id} authority frame committed generation {new_gen}, but post-commit namespace confirmation failed: {error}" ))); } - Ok(new_gen) + Ok((new_gen, record.durable_identity())) + } + + /// Append one authority frame and return the same still-held repository + /// lock that protected its commit point. + /// + /// The frame counterpart of [`Self::save_snapshot_and_freeze`], for a + /// freezing publication whose successor extends the acknowledged head by + /// one frame: a branch, tag, stash or merge-state move. The append is + /// exactly the one [`StorageBackend::save_authority_frame`] makes, under + /// the same exclusive lock and with the same commit point, the rename of + /// the record. The lock is then handed back instead of released, so the + /// caller keeps the exclusion it committed under with no release and + /// reacquire window, as it does after a full snapshot. + /// + /// Any failure after the record rename is reported as + /// [`KinDbError::SnapshotPersistenceIndeterminate`] by the append itself, + /// and nothing here can fail after it, so an error never hides a commit. + pub(crate) fn save_authority_frame_and_freeze( + &self, + repo_id: &str, + frame: &[u8], + expected_cursor: SnapshotCursor, + history_validator_version: Option, + ) -> Result<(SnapshotCursor, LocalAuthorityFreezeLock), KinDbError> { + let lock = self.acquire_existing_lock(repo_id)?; + // A returned freeze vouches for a durable head, including its base. + // The append checks acknowledged frames while reading the record, but + // an ordinary append assumes the base remains immutable after open. + // Check its mapped bytes here before writing anything, so corruption + // cannot turn a successful freezing commit into an unreopenable head. + let record = self + .read_authority_record_unlocked(&lock.namespace)? + .ok_or_else(|| no_existing_authority_to_freeze(repo_id))?; + self.read_authoritative_snapshot_bytes_unlocked(&lock.namespace, &record)?; + let (generation, identity) = self.save_authority_frame_from_record_unlocked( + &lock.namespace, + record, + frame, + expected_cursor.backend_generation(), + history_validator_version, + )?; + Ok(( + SnapshotCursor::from_backend_generation(generation), + LocalAuthorityFreezeLock { + repo_id: repo_id.to_string(), + captured: None, + identity, + // The append refuses a record with an active preparation, so + // the record it acknowledged carries none. + active_session_publication: false, + lock, + }, + )) } /// Persist a local full-snapshot CAS and return the same still-held @@ -8367,27 +8480,11 @@ impl LocalFileBackend { )?; let cursor = SnapshotCursor::from_backend_generation(generation); let snapshot_sha256 = hex::encode(Sha256::digest(data)); - let authority = SnapshotAuthority { - snapshot_bytes: data.to_vec().into(), - snapshot_source: None, - snapshot_generation: generation, - head_generation: generation, - history_validation: history_validator_version.map(|validator_version| { - HistoryValidationProof { - validator_version, - repository_id: repo_id.to_string(), - generation, - snapshot_sha256: snapshot_sha256.clone(), - journal_sha256: None, - } - }), - }; Ok(( cursor, LocalAuthorityFreezeLock { repo_id: repo_id.to_string(), - authority, - frames: Vec::new(), + captured: None, identity: DurableAuthorityIdentity::full_snapshot(generation, snapshot_sha256), // Ordinary save refuses active preparation; a prepared save // atomically completes its exact acknowledged operation. @@ -9176,7 +9273,7 @@ impl StorageBackend for LocalFileBackend { expected.backend_generation(), history_validator_version, ) { - Ok(generation) => SnapshotSaveOutcome::Committed { + Ok((generation, _)) => SnapshotSaveOutcome::Committed { cursor: SnapshotCursor::from_backend_generation(generation), }, Err(error @ KinDbError::SnapshotPersistenceIndeterminate(_)) => { diff --git a/crates/kin-db/src/storage/binding_history.rs b/crates/kin-db/src/storage/binding_history.rs index c7ccae61c..90c7b66af 100644 --- a/crates/kin-db/src/storage/binding_history.rs +++ b/crates/kin-db/src/storage/binding_history.rs @@ -248,9 +248,12 @@ pub(super) fn checked_witnesses( } } let metadata = transition.successor.metadata(); - let selected = transition + // A successor that inherited this workspace's graph from its + // predecessor inherited the digest the predecessor checked it against, + // and hashing the same graph again would only reach it a second time. + let graph_digest = transition .successor - .workspace_graph_snapshot(&workspace)? + .workspace_graph_digest(&workspace)? .ok_or_else(|| invalid("qualified workspace is absent"))?; let scope = metadata .workspaces @@ -258,7 +261,6 @@ pub(super) fn checked_witnesses( .find(|item| item.workspace_id == workspace) .ok_or_else(|| invalid("qualified workspace is absent"))? .snapshot_binding(metadata.roots.clone())?; - let graph_digest = graph_digest(&selected)?; // A re-derivation starts its own lineage: the selected graph was derived // again from exact bytes, so nothing an earlier proof covered, and // nothing an earlier unproven operation did, is carried into it. @@ -286,9 +288,13 @@ pub(super) fn checked_witnesses( "qualified transition differs from the DB-held operation", )); } + let identities = transition.successor.operation_identities()?; + let operation_identity = *identities + .last() + .ok_or_else(|| invalid("qualified transition has no committed operation"))?; let step = BindingHistoryStep { operation_id: operation.operation_id, - operation_identity: operation.identity_hash()?, + operation_identity, qualification: match eligibility { BindingHistoryEligibility::NewNativeGenesis => { BindingHistoryQualification::NewNativeGenesis @@ -327,7 +333,7 @@ pub(super) fn checked_witnesses( proof, lineage_digest, }; - witness.validate_lineage(metadata)?; + witness.validate_lineage(metadata, Some(&identities))?; result.push(witness); } Ok(result) @@ -395,10 +401,19 @@ impl BindingHistoryWitness { self.scope.workspace_id } - fn validate_lineage(&self, metadata: &PersistedRepositoryAuthority) -> Result<(), KinDbError> { + /// `identities`, when given, is the identity hash of every record in + /// `metadata.operation_log`, by position, held by the state that owns this + /// exact log. A list of any other length describes some other log and is + /// not consulted; the identities are then hashed here. + fn validate_lineage( + &self, + metadata: &PersistedRepositoryAuthority, + identities: Option<&[Hash256]>, + ) -> Result<(), KinDbError> { if self.proof.is_empty() || self.proof.len() > metadata.operation_log.len() { return Err(invalid("unqualified or incomplete operation lineage")); } + let identities = identities.filter(|held| held.len() == metadata.operation_log.len()); // A proof names every operation from its own start to the head. It // starts at genesis, or at a checked re-derivation part way through the // log; the operations before a re-derivation are not part of it. @@ -412,8 +427,12 @@ impl BindingHistoryWitness { .enumerate() { let index = offset + position; + let identity = match identities { + Some(held) => held[index], + None => operation.identity_hash()?, + }; if step.operation_id != operation.operation_id - || step.operation_identity != operation.identity_hash()? + || step.operation_identity != identity || operation.repository_id != metadata.repository_id || operation.roots_before.generation != index as u64 || operation.roots_after.generation != index as u64 + 1 @@ -461,6 +480,12 @@ pub struct VerifiedBindingHistory { } impl VerifiedBindingHistory { + /// Digest of the selected graph this capability was bound to, which + /// [`bind_selected_graph`] compared with that graph's own digest. + pub(crate) fn selected_graph_digest(&self) -> Hash256 { + self.selected_graph_digest + } + pub(crate) fn validate_selected(&self, snapshot: &GraphSnapshot) -> Result<(), KinDbError> { if graph_digest(snapshot)? != self.selected_graph_digest { return Err(invalid("runtime capability no longer names this graph")); @@ -481,6 +506,21 @@ fn invalid(reason: impl std::fmt::Display) -> KinDbError { } pub(crate) fn validate_metadata(metadata: &PersistedRepositoryAuthority) -> Result<(), KinDbError> { + validate_metadata_with(metadata, None) +} + +/// [`validate_metadata`] for a caller that holds the identity hash of every +/// record in `metadata.operation_log`, by position. +/// +/// Every lineage step is checked against its operation's identity, and an +/// identity is a canonical encoding of the whole record, workspace mutation +/// included. The log only grows, so the state that owns it hashes each record +/// once and hands the list in rather than paying for the whole log again on +/// every validation. +pub(crate) fn validate_metadata_with( + metadata: &PersistedRepositoryAuthority, + identities: Option<&[Hash256]>, +) -> Result<(), KinDbError> { if metadata.schema_version < BINDING_HISTORY_AUTHORITY_SCHEMA && !metadata.binding_history.is_empty() { @@ -506,7 +546,7 @@ pub(crate) fn validate_metadata(metadata: &PersistedRepositoryAuthority) -> Resu if witness.scope != expected || witness.scope.repository_id != metadata.repository_id { return Err(invalid("authority or workspace binding differs")); } - witness.validate_lineage(metadata)?; + witness.validate_lineage(metadata, identities)?; } Ok(()) } @@ -551,9 +591,14 @@ pub(in crate::storage) fn restore_prepared_successor( pub(crate) fn graph_digest(snapshot: &GraphSnapshot) -> Result { let mut hasher = Sha256::new(); hasher.update(b"kin.binding-history.selected-graph.v1\0"); + let semantic_relations: std::collections::HashMap<_, _> = snapshot + .relations + .iter() + .filter(|(_, relation)| crate::engine::is_semantic_relation(relation)) + .collect(); for result in [ super::canonical_hash::canonical_hash_into(&mut hasher, &snapshot.entities), - super::canonical_hash::canonical_hash_into(&mut hasher, &snapshot.relations), + super::canonical_hash::canonical_hash_into(&mut hasher, &semantic_relations), super::canonical_hash::canonical_hash_into(&mut hasher, &snapshot.external_references), super::canonical_hash::canonical_hash_into(&mut hasher, &snapshot.resolved_tree), ] { @@ -562,21 +607,37 @@ pub(crate) fn graph_digest(snapshot: &GraphSnapshot) -> Result Result<(), KinDbError> { + known_digest: Option, + identities: Option<&[Hash256]>, +) -> Result, KinDbError> { snapshot.verified_binding_history = None; - validate_metadata(metadata)?; + validate_metadata_with(metadata, identities)?; let Some(witness) = metadata .binding_history .iter() .find(|w| w.scope.workspace_id == workspace) else { - return Ok(()); + return Ok(known_digest); }; - if graph_digest(snapshot)? != witness.graph_digest { + let digest = match known_digest { + Some(digest) => digest, + None => graph_digest(snapshot)?, + }; + if digest != witness.graph_digest { return Err(invalid("selected graph differs")); } snapshot.verified_binding_history = Some(VerifiedBindingHistory { @@ -584,7 +645,7 @@ pub(crate) fn bind_selected_graph( selected_graph_digest: witness.graph_digest, runtime_lineage: witness.lineage_digest, }); - Ok(()) + Ok(Some(digest)) } /// Runtime derivations carry the exact durable anchor plus a checked selected diff --git a/crates/kin-db/src/storage/binding_history_tests.rs b/crates/kin-db/src/storage/binding_history_tests.rs index fff8d59fa..087ef151b 100644 --- a/crates/kin-db/src/storage/binding_history_tests.rs +++ b/crates/kin-db/src/storage/binding_history_tests.rs @@ -299,7 +299,54 @@ fn binding_history_binds_whole_scope_graph_and_invalidates_before_mutation() { .unwrap() .unwrap(); let metadata = lease.metadata().clone(); - binding_history::bind_selected_graph(&metadata, workspace.workspace_id, &mut selected).unwrap(); + binding_history::bind_selected_graph( + &metadata, + workspace.workspace_id, + &mut selected, + None, + None, + ) + .unwrap(); + // A digest the caller holds is compared with the witness, never trusted + // past it. + assert!(binding_history::bind_selected_graph( + &metadata, + workspace.workspace_id, + &mut selected.clone(), + Some(Hash256::from_bytes([7; 32])), + None + ) + .is_err()); + // Identities the caller holds stand in for hashing the log: the log's own + // bind, a wrong one is refused exactly as a wrong record is, and a list of + // another length describes another log and is not consulted. + let mut identities = lease.operation_identities().unwrap().as_ref().clone(); + binding_history::bind_selected_graph( + &metadata, + workspace.workspace_id, + &mut selected.clone(), + None, + Some(&identities), + ) + .unwrap(); + *identities.last_mut().unwrap() = Hash256::from_bytes([7; 32]); + assert!(binding_history::bind_selected_graph( + &metadata, + workspace.workspace_id, + &mut selected.clone(), + None, + Some(&identities) + ) + .is_err()); + identities.pop(); + binding_history::bind_selected_graph( + &metadata, + workspace.workspace_id, + &mut selected.clone(), + None, + Some(&identities), + ) + .unwrap(); let graph = InMemoryGraph::from_snapshot_without_text_index(selected.clone()).unwrap(); assert!(matches!( graph.binding_history_observation(), @@ -314,7 +361,9 @@ fn binding_history_binds_whole_scope_graph_and_invalidates_before_mutation() { assert!(binding_history::bind_selected_graph( &wrong_root, workspace.workspace_id, - &mut selected + &mut selected, + None, + None ) .is_err()); let mut wrong_graph = selected.clone(); @@ -322,7 +371,9 @@ fn binding_history_binds_whole_scope_graph_and_invalidates_before_mutation() { assert!(binding_history::bind_selected_graph( &metadata, workspace.workspace_id, - &mut wrong_graph + &mut wrong_graph, + None, + None ) .is_err()); graph diff --git a/crates/kin-db/src/storage/change_map.rs b/crates/kin-db/src/storage/change_map.rs index 5c2db97df..48dd9c64d 100644 --- a/crates/kin-db/src/storage/change_map.rs +++ b/crates/kin-db/src/storage/change_map.rs @@ -377,6 +377,9 @@ pub(crate) struct EncodedChanges { /// Records this history already read, checked against the digest its open /// recorded, and decoded, so a later read of the same record serves them. verified: Mutex, + /// Records too large for `verified`, kept only while a read holds them. + /// See [`ChangeMap::hold_large_records`]. + large: Arc>, } /// Encoded bytes of verified records one history keeps decoded. @@ -450,6 +453,51 @@ impl VerifiedRecords { } } +/// Records over [`VERIFIED_RECORD_KEPT_MAX`] that reads in progress keep +/// decoded, for as long as any of them runs. +/// +/// A store whose older history is still being derived holds the repository as +/// one bootstrap change, and every commit on it after that names thousands of +/// relations. Neither fits the bound, so each was read, hashed and decoded +/// again by every replay that crossed it, and a guide of one commit crosses +/// them in five or six replays: on zod, close to half of a guide's time. A read +/// that will cross them several times holds them for its own life; nothing is +/// kept once the last such read ends, so the daemon carries no more between +/// reads than it did. Sound for the reasons [`VerifiedRecords`] is: a record is +/// held only after it was verified and decoded from this generation's bytes. +#[derive(Default)] +struct HeldLargeRecords { + /// Reads that hold large records now. + holders: usize, + records: HashMap>, +} + +/// One read's hold on the large records of a history; see +/// [`ChangeMap::hold_large_records`]. The records are let go when the last +/// hold on them is dropped. +#[must_use = "large records are kept only while the hold lives"] +pub struct LargeRecordHold { + held: Arc>, +} + +impl Drop for LargeRecordHold { + fn drop(&mut self) { + let mut held = self.held.lock(); + held.holders = held.holders.saturating_sub(1); + if held.holders == 0 { + held.records = HashMap::new(); + } + } +} + +impl fmt::Debug for LargeRecordHold { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LargeRecordHold") + .finish_non_exhaustive() + } +} + impl fmt::Debug for EncodedChanges { /// Never the index or the checksum bytes: an admitted history's index has /// one entry per change, and a converted repository's is hundreds of @@ -610,6 +658,7 @@ impl EncodedChanges { carries_call_site_ledgers: false, carries_context_validations: false, verified: Mutex::new(VerifiedRecords::default()), + large: Arc::default(), } } @@ -707,9 +756,26 @@ impl EncodedChanges { if let Some(kept) = kept { return Ok(Some(SemanticChange::clone(&kept))); } + let large = record_len > VERIFIED_RECORD_KEPT_MAX; + if large { + let held = self.large.lock().records.get(id).map(Arc::clone); + if let Some(held) = held { + return Ok(Some(SemanticChange::clone(&held))); + } + } let change = self.read_change(id)?; if let Some(change) = &change { - self.verified.lock().keep(*id, change, record_len); + if !large { + self.verified.lock().keep(*id, change, record_len); + } else if self.large.lock().holders > 0 { + // Copied outside the lock; a second read that decoded it at + // the same time keeps the first copy. + let copy = Arc::new(change.clone()); + let mut held = self.large.lock(); + if held.holders > 0 { + held.records.entry(*id).or_insert(copy); + } + } } Ok(change) } @@ -1064,6 +1130,20 @@ impl ChangeMap { } } + /// Keep the records too large for the verified-record bound decoded for + /// as long as the returned hold lives, so a read that replays across them + /// several times decodes each once. `None` when this map is not read from + /// encoded bytes, where there is nothing to keep. See `HeldLargeRecords`. + pub fn hold_large_records(&self) -> Option { + if self.body.decoded.get().is_some() { + return None; + } + let encoded = self.body.encoded.as_ref()?; + let held = Arc::clone(&encoded.large); + held.lock().holders += 1; + Some(LargeRecordHold { held }) + } + /// Project one selected record, borrowing an already decoded body and /// reading only this identity from indexed retained or spooled history. /// Legacy unindexed encoded maps retain read_change's whole-map fallback. diff --git a/crates/kin-db/src/storage/format.rs b/crates/kin-db/src/storage/format.rs index dbbb44984..cd661c611 100644 --- a/crates/kin-db/src/storage/format.rs +++ b/crates/kin-db/src/storage/format.rs @@ -15,7 +15,8 @@ use crate::storage::change_map::{ }; use crate::storage::change_validation::{validate_semantic_change_entries, AdmittedChangeMap}; use crate::storage::repository::{ - GitProjectionTreeReplay, HistoryProof, PersistedRepositoryAuthority, RootRecomputation, + GitProjectionTreeReplay, HistoryProof, PayloadProof, PersistedRepositoryAuthority, + RootRecomputation, }; use crate::types::*; @@ -894,21 +895,23 @@ where Option::deserialize(deserializer) } -/// The four graph domains a workspace mutation ever compares. +/// The semantic graph domains a workspace mutation compares. /// /// Deriving a workspace's cumulative semantic overlay and proving that overlay -/// reproduces the desired graph read entities, relations, external references -/// and the resolved tree, and read nothing else. Carrying those two steps on +/// reproduces the desired graph read entities, semantic relations, external +/// references, resolution records and the resolved tree. Carrying those two steps on /// whole `GraphSnapshot`s meant each of them also held a copy of every change /// in the repository with its full entity, relation and tree delta payload, /// plus change children, work items, annotations, reviews, verification runs, /// provenance and sessions, none of which any comparison consults. On a /// full-history conversion the change map IS the repository, so a snapshot -/// kept for four fields was a whole extra history. +/// kept for those fields was a whole extra history. /// /// This is a projection of a `GraphSnapshot`, never a substitute for one: /// nothing is validated through it, and every value in it is the value the -/// snapshot's own field carried. +/// snapshot's own field carried. Relations retain only semantic endpoints; +/// verification and work links belong to their records and cannot become +/// workspace overlay edits when those records are projected here. #[derive(Debug)] pub struct WorkspaceGraphFacts { pub entities: HashMap, @@ -923,11 +926,15 @@ impl WorkspaceGraphFacts { /// /// Consuming rather than borrowing is the point: the domains move out and /// everything else is freed at the call, instead of staying alive beside - /// the four fields a caller went on to read. + /// the compared fields a caller went on to read. pub fn from_snapshot(snapshot: GraphSnapshot) -> Self { Self { entities: snapshot.entities, - relations: snapshot.relations, + relations: snapshot + .relations + .into_iter() + .filter(|(_, relation)| crate::engine::is_semantic_relation(relation)) + .collect(), external_references: snapshot.external_references, resolved_tree: snapshot.resolved_tree, resolution_records: snapshot.resolution_records, @@ -2075,6 +2082,7 @@ impl GraphSnapshot { RootRecomputation::Required, AuthorityEnvelope::Validated, HistoryProof::Required, + PayloadProof::Required, ) } @@ -2092,16 +2100,20 @@ impl GraphSnapshot { /// `history` is the successor's [`HistoryProof`], stated by the same caller /// for the same reason: it built the successor and knows which of its /// inputs the transaction left exactly as the predecessor held them. + /// `payload` is the same caller's [`PayloadProof`]: its own replay view + /// proved this exact authority-free payload a few statements earlier. pub(crate) fn validate_storage_admission_with_proven_roots( &self, replay: GitProjectionTreeReplay, history: HistoryProof, + payload: PayloadProof, ) -> Result<(), crate::error::KinDbError> { self.validate_admission_with_envelope( replay, RootRecomputation::Proven, AuthorityEnvelope::Validated, history, + payload, ) } @@ -2122,6 +2134,7 @@ impl GraphSnapshot { RootRecomputation::Required, AuthorityEnvelope::Ignored, HistoryProof::Required, + PayloadProof::Required, ) } @@ -2131,6 +2144,7 @@ impl GraphSnapshot { roots: RootRecomputation, envelope: AuthorityEnvelope, history: HistoryProof, + payload: PayloadProof, ) -> Result<(), crate::error::KinDbError> { let mut timer = crate::storage::repository::PublicationPhaseTimer::start(); // Indexed recovery either verified an exact durable proof or admitted @@ -2145,7 +2159,7 @@ impl GraphSnapshot { }; let changes_ms = timer.lap_ms(); self.validate_storage_admission_after_changes( - replay, roots, &admitted, envelope, changes_ms, timer, history, + replay, roots, &admitted, envelope, changes_ms, timer, history, payload, ) } @@ -2210,6 +2224,7 @@ impl GraphSnapshot { 0, timer, HistoryProof::Required, + PayloadProof::Required, ) } @@ -2222,6 +2237,7 @@ impl GraphSnapshot { changes_ms: u128, mut timer: crate::storage::repository::PublicationPhaseTimer, history: HistoryProof, + payload: PayloadProof, ) -> Result<(), crate::error::KinDbError> { for (id, reference) in &self.external_references { validate_external_reference_entry(id, reference, "snapshot")?; @@ -2271,7 +2287,8 @@ impl GraphSnapshot { AuthorityEnvelope::Ignored => None, }; if let Some(authority) = envelope_to_validate { - authority.validate_against_snapshot_with(self, replay, roots, admitted, history)?; + authority + .validate_against_snapshot_with(self, replay, roots, admitted, history, payload)?; } let repository_authority_ms = timer.lap_ms(); tracing::debug!( @@ -4048,6 +4065,7 @@ mod tests { kind: ActorKind::Human, display_name: "tester".into(), external_refs: vec![], + agent_session: None, }; snap.actors.insert(actor.actor_id, actor.clone()); @@ -4078,6 +4096,7 @@ mod tests { kind: ActorKind::Human, display_name: "live".into(), external_refs: vec![], + agent_session: None, }, ); @@ -6903,6 +6922,115 @@ mod tests { ); } + /// A record too large to keep between reads is decoded once for as long + /// as a read holds the large records, and kept no longer than that. + /// + /// A store whose older history is still being derived holds the whole + /// repository as one bootstrap change, over the bound every other record + /// is kept under, and a guide of one commit replayed across it five or six + /// times, decoding it each time. Without a hold, each serving read of the + /// record still decodes it. With one, the first read does and every other + /// read, from the map or a clone that shares its storage, and under a + /// second hold, serves what it decoded. Once the last hold is let go + /// nothing is kept, and the next read decodes again. + #[test] + fn a_large_record_is_decoded_once_while_a_read_holds_large_records() { + use std::io::Write; + let decoded_records = crate::storage::change_map::records_decoded_on_this_thread; + let mut original = a_snapshot_with_history(2); + let mut bootstrap = a_history_change(9, None); + bootstrap.message = "x".repeat(9 * 1024 * 1024); + let bootstrap = seal_change(bootstrap); + let large = bootstrap.id; + original.changes.insert(large, bootstrap); + let frame = encode_snapshot_without_admission_validation(&original); + let mut file = tempfile::tempfile().unwrap(); + file.write_all(&frame).unwrap(); + let (opened, _) = decode_lazily( + &frame, + HistorySource::File { + file: Arc::new(file.try_clone().unwrap()), + display: "large record fixture".into(), + frame_len: frame.len() as u64, + }, + ); + let changes = opened.changes; + let serve = |reader: &crate::storage::ChangeMap| { + let change = reader.read_change_for_serving(&large).unwrap(); + assert_eq!(change.as_ref(), original.changes.get(&large)); + }; + + let before = decoded_records(); + serve(&changes); + serve(&changes); + assert_eq!( + decoded_records() - before, + 2, + "with no read holding them, a large record is decoded on every read" + ); + + let shared = changes.clone(); + let hold = changes.hold_large_records().expect("an encoded history"); + let held_from = decoded_records(); + serve(&changes); + serve(&changes); + serve(&shared); + let second = shared.hold_large_records().expect("an encoded history"); + serve(&shared); + assert_eq!( + decoded_records() - held_from, + 1, + "while a read holds the large records, each is decoded once" + ); + drop(hold); + serve(&changes); + assert_eq!( + decoded_records() - held_from, + 1, + "a second hold keeps them after the first is let go" + ); + drop(second); + let released = decoded_records(); + serve(&changes); + assert_eq!( + decoded_records() - released, + 1, + "once the last hold is let go nothing is kept" + ); + + // A graph over the same history says it holds the change from its + // index, the check every read that names a ref makes, without reading + // the change; and it holds its large records for a read the same way. + use kin_model::graph::ChangeStore as _; + let (reopened, _) = decode_lazily(&frame, memory_source(&frame)); + let graph = crate::InMemoryGraph::from_snapshot_without_text_index(reopened).unwrap(); + let checked = decoded_records(); + assert!(graph.holds_change(&large).unwrap()); + assert!(!graph + .holds_change(&SemanticChangeId::from_hash(Hash256::from_bytes([7; 32]))) + .unwrap()); + assert_eq!( + decoded_records(), + checked, + "whether a graph holds a change is answered without reading it" + ); + let hold = graph + .hold_large_change_records() + .expect("an encoded history"); + for _ in 0..3 { + assert_eq!( + graph.get_change(&large).unwrap().as_ref(), + original.changes.get(&large) + ); + } + drop(hold); + assert_eq!( + decoded_records() - checked, + 1, + "a graph read that holds its large records decodes each once" + ); + } + #[test] fn change_ids_preserves_order_seals_and_identity_across_storage_shapes() { let original = a_snapshot_with_history(4); diff --git a/crates/kin-db/src/storage/history_replay.rs b/crates/kin-db/src/storage/history_replay.rs index e9513dd73..8082eaadb 100644 --- a/crates/kin-db/src/storage/history_replay.rs +++ b/crates/kin-db/src/storage/history_replay.rs @@ -366,6 +366,9 @@ pub(crate) fn validate_first_parent_history( } } + #[cfg(test)] + VALIDATED_HISTORY_CHANGES + .with(|counter| counter.set(counter.get().saturating_add(lineage.len()))); let mut progress = ReplayProgress::new("history_replay", "changes", lineage.len()); match walk_first_parent_forest(changes, &lineage, &children, &roots, &mut progress) { Ok(()) => { @@ -671,6 +674,23 @@ pub(crate) fn take_lineage_steps() -> usize { LINEAGE_STEPS.with(|counter| counter.replace(0)) } +// Changes entered by every first-parent history validation on this thread, +// whichever caller asked for it. A test asserts on this rather than on a lap's +// milliseconds, because a commit that is meant to carry its predecessor's +// validated history must enter none of it, and one that adds a change must +// enter that change's lineage once rather than the whole store several times. +#[cfg(test)] +thread_local! { + static VALIDATED_HISTORY_CHANGES: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + +/// Zero the validated-history counter and report what it held. +#[cfg(test)] +pub(crate) fn take_validated_history_changes() -> usize { + VALIDATED_HISTORY_CHANGES.with(|counter| counter.replace(0)) +} + /// Apply or unwind one change's complete transition. /// /// Forward order is entities, external references, relations, then the diff --git a/crates/kin-db/src/storage/mod.rs b/crates/kin-db/src/storage/mod.rs index 810b29f98..336904bfd 100644 --- a/crates/kin-db/src/storage/mod.rs +++ b/crates/kin-db/src/storage/mod.rs @@ -52,7 +52,7 @@ pub use backend::{ VectorRepositoryIdentity, VerifiedSourceBlob, VerifiedSourceBlobBatch, GENERATION_INIT, MAX_SOURCE_BLOB_BYTES, MAX_VECTOR_ARTIFACT_BYTES, MAX_VECTOR_ARTIFACT_METADATA_BYTES, }; -pub use change_map::{ChangeMap, ChangeMapInner}; +pub use change_map::{ChangeMap, ChangeMapInner, LargeRecordHold}; pub use delta::{ apply_graph_delta, compute_graph_delta, CollectionDelta, GraphSnapshotDelta, VecDelta, }; diff --git a/crates/kin-db/src/storage/repository.rs b/crates/kin-db/src/storage/repository.rs index 160491ef2..d27a6bc55 100644 --- a/crates/kin-db/src/storage/repository.rs +++ b/crates/kin-db/src/storage/repository.rs @@ -951,6 +951,7 @@ impl PersistedRepositoryAuthority { RootRecomputation::Required, &admitted, HistoryProof::Required, + PayloadProof::Required, ) } @@ -961,6 +962,7 @@ impl PersistedRepositoryAuthority { roots: RootRecomputation, admitted: &AdmittedChangeMap<'_>, history: HistoryProof, + payload: PayloadProof, ) -> Result<(), KinDbError> { let mut timer = PublicationPhaseTimer::start(); if !(MIN_REPOSITORY_AUTHORITY_SCHEMA_VERSION..=REPOSITORY_AUTHORITY_SCHEMA_VERSION) @@ -977,7 +979,14 @@ impl PersistedRepositoryAuthority { } self.roots.validate()?; super::binding_history::validate_metadata(self)?; - git_snapshot_proof::validate_metadata(snapshot, self)?; + // Carried on the same obligation as the Git authority checks below. It + // reads the snapshot proofs, the external objects they name and the + // root changes they prove. A transaction that leaves the Git authority + // carried admits no proof and no external object, and history only + // grows, so all three are the values the predecessor passed this with. + if history.git_authority_required() { + git_snapshot_proof::validate_metadata(snapshot, self)?; + } self.ref_state.validate()?; require_sorted_unique( @@ -1146,7 +1155,7 @@ impl PersistedRepositoryAuthority { let history_caches_ms = timer.lap_ms(); validate_ref_targets(snapshot, self)?; let ref_targets_ms = timer.lap_ms(); - validate_workspace_authority(snapshot, self, Some(admitted), history)?; + validate_workspace_authority(snapshot, self, Some(admitted), history, payload)?; let workspace_authority_ms = timer.lap_ms(); // Skipped only on an exact obligation the caller discharged over this @@ -1257,9 +1266,14 @@ pub struct RepositoryAuthorityState { published: std::sync::atomic::AtomicBool, /// The workspace graph this state last materialized, kept so the next /// reader of the same workspace is handed a copy. See - /// [`KeptWorkspaceGraph`]. Never serialized, and no clone or successor - /// carries it: it is derived from this exact state and from nothing else. + /// [`KeptWorkspaceGraph`]. Never serialized, and no clone carries it. A + /// successor inherits it only for a workspace its transaction provably + /// could not move: see [`Self::inherit_workspace_graph`]. workspace_graph: std::sync::Mutex>, + /// Identity hash of every record in this state's operation log, by + /// position. See [`Self::operation_identities`]. Never serialized, and no + /// clone carries it. + operation_identities: std::sync::OnceLock>>, } /// One workspace's graph as an immutable [`RepositoryAuthorityState`] @@ -1286,6 +1300,16 @@ pub struct RepositoryAuthorityState { struct KeptWorkspaceGraph { workspace_id: WorkspaceId, graph: Arc, + /// [`graph_digest`](super::binding_history::graph_digest) of `graph`, when + /// it has been computed over this exact graph or checked against the + /// witness that binds it. + digest: Option, + /// Whether `graph` is the predecessor's materialization rather than this + /// state's own. An inherited graph holds the right entities, relations, + /// external references and tree, and the predecessor's binding-history + /// capability, so it is bound again under this state's envelope each time + /// it is served until this state keeps its own bound copy. + inherited: bool, } /// Most entities and relations a workspace graph may hold and still be kept @@ -1303,6 +1327,7 @@ impl Clone for RepositoryAuthorityState { installed_record: std::sync::OnceLock::new(), published: std::sync::atomic::AtomicBool::new(false), workspace_graph: std::sync::Mutex::new(None), + operation_identities: std::sync::OnceLock::new(), } } } @@ -1338,6 +1363,7 @@ impl RepositoryAuthorityState { installed_record: std::sync::OnceLock::new(), published: std::sync::atomic::AtomicBool::new(false), workspace_graph: std::sync::Mutex::new(None), + operation_identities: std::sync::OnceLock::new(), } } @@ -1376,6 +1402,7 @@ impl RepositoryAuthorityState { installed_record: std::sync::OnceLock::new(), published: std::sync::atomic::AtomicBool::new(false), workspace_graph: std::sync::Mutex::new(None), + operation_identities: std::sync::OnceLock::new(), } } @@ -1400,6 +1427,9 @@ impl RepositoryAuthorityState { installed_record: std::sync::OnceLock::new(), published: std::sync::atomic::AtomicBool::new(false), workspace_graph: std::sync::Mutex::new(None), + // An equivalent rewrite changes the representation and nothing the + // envelope says, so its operation log is `current`'s own. + operation_identities: current.operation_identities.clone(), } } @@ -1418,6 +1448,25 @@ impl RepositoryAuthorityState { &self.metadata().roots } + /// Whether this authority records a binding-history witness for + /// `workspace_id`. + /// + /// The witness is the only source of the proof a materialized workspace + /// graph carries, so this answers whether [`Self::workspace_graph_snapshot`] + /// would return a graph holding one, from the authority's own record and + /// without materializing that graph. + pub fn records_binding_history_witness(&self, workspace_id: &WorkspaceId) -> bool { + self.snapshot + .repository_authority + .as_ref() + .is_some_and(|authority| { + authority + .binding_history + .iter() + .any(|witness| witness.workspace_id() == *workspace_id) + }) + } + /// The language-server enrichment marks one workspace carries, or none /// when the workspace is not registered. pub fn workspace_enrichment_marks( @@ -1436,6 +1485,20 @@ impl RepositoryAuthorityState { .map_or(&[], |workspace| workspace.enrichment_marks.as_slice()) } + /// Whether the workspace's working tree matches its committed base tree. + pub fn workspace_matches_committed_base(&self, workspace_id: &WorkspaceId) -> bool { + self.snapshot + .repository_authority + .as_ref() + .and_then(|authority| { + authority + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == *workspace_id) + }) + .is_some_and(|workspace| workspace.base_tree_hash == Some(workspace.tree_hash)) + } + /// The durable bytes this exact state was loaded from or written as, once /// the persistence that read or wrote them has bound them. fn durable_identity(&self) -> Option<&DurableAuthorityIdentity> { @@ -1537,17 +1600,26 @@ impl RepositoryAuthorityState { if let Some(kept) = self.kept_workspace_graph(workspace_id) { return Ok(Some(kept)); } + // Or its predecessor did, and the transaction between them could not + // have moved it. + if let Some(inherited) = self.inherited_workspace_graph(workspace_id)? { + return Ok(Some(inherited)); + } let prepared = self .prepared .as_ref() .filter(|_| prepared_workspace_state_pays_for_itself(workspace)); + let identities = self.lineage_identities()?; + let identities = identities.as_ref().map(|held| held.as_slice()); if let Some(prepared) = prepared { if let Some(mut snapshot) = prepared.serve(self.generation(), workspace) { super::binding_history::bind_selected_graph( self.metadata(), *workspace_id, &mut snapshot, + None, + identities, )?; self.keep_workspace_graph(workspace_id, &snapshot); return Ok(Some(snapshot)); @@ -1558,6 +1630,7 @@ impl RepositoryAuthorityState { self.metadata(), workspace, None, + identities, )?; if let Some(prepared) = prepared { prepared.record(self.generation(), workspace, &materialized); @@ -1566,6 +1639,43 @@ impl RepositoryAuthorityState { Ok(Some(materialized)) } + /// Digest of the graph [`Self::workspace_graph_snapshot`] hands out for + /// `workspace_id`, or `None` when this state holds no such workspace. + /// + /// The digest this state already holds beside that graph whenever it holds + /// one, without copying the graph out to hash it. Otherwise the graph is + /// materialized and hashed here, and the digest is remembered beside the + /// graph when the state keeps it. + pub(crate) fn workspace_graph_digest( + &self, + workspace_id: &WorkspaceId, + ) -> Result, KinDbError> { + let held = self + .workspace_graph + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .filter(|kept| &kept.workspace_id == workspace_id) + .and_then(|kept| kept.digest); + if held.is_some() { + return Ok(held); + } + let Some(graph) = self.workspace_graph_snapshot(workspace_id)? else { + return Ok(None); + }; + let digest = super::binding_history::graph_digest(&graph)?; + if let Some(kept) = self + .workspace_graph + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_mut() + .filter(|kept| &kept.workspace_id == workspace_id) + { + kept.digest = Some(digest); + } + Ok(Some(digest)) + } + /// A copy of the graph this state last materialized for `workspace_id`. fn kept_workspace_graph(&self, workspace_id: &WorkspaceId) -> Option { if !self.is_published() { @@ -1577,7 +1687,7 @@ impl RepositoryAuthorityState { .unwrap_or_else(|poisoned| poisoned.into_inner()); let graph = kept .as_ref() - .filter(|kept| &kept.workspace_id == workspace_id) + .filter(|kept| !kept.inherited && &kept.workspace_id == workspace_id) .map(|kept| Arc::clone(&kept.graph))?; drop(kept); #[cfg(test)] @@ -1600,8 +1710,166 @@ impl RepositoryAuthorityState { .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(KeptWorkspaceGraph { workspace_id: *workspace_id, graph: Arc::new(graph.clone()), + // A bound graph was compared with its witness's digest when it was + // bound, so the capability names the digest of these exact domains. + digest: graph + .verified_binding_history + .as_ref() + .map(super::binding_history::VerifiedBindingHistory::selected_graph_digest), + inherited: false, + }); + } + + /// Inherit `current`'s materialized workspace graph for a workspace the + /// transaction that produced this state could not have moved. + /// + /// Called only for a successor prepared under + /// [`HistoryProof::CarriedFromPredecessor`] that admits no collaboration + /// record, which states the obligation this rests on: the transaction + /// admits no change, alias, external object, Git authority delta, + /// collaboration record or local overlay, so the + /// authority-free payload, the materialized graph section and everything a + /// base target resolves through are the predecessor's own values. A + /// workspace's selected graph is a function of exactly those and of the + /// workspace's own state. So for every workspace the transaction does not + /// mutate, and whose state in this successor equals its state in the + /// predecessor, materializing it here would resolve the same base, apply + /// the same overlay and reach the graph the predecessor already holds. + /// + /// What does differ is the binding-history capability bound on that graph, + /// which names the envelope it was bound under. An inherited graph is + /// therefore never handed out as it was kept: every serve binds it again + /// under this state's envelope as it then stands, with the digest the + /// graph was kept under compared against this state's witness. + fn inherit_workspace_graph(&self, current: &Self, mutated_workspace: Option) { + let held = current + .workspace_graph + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .map(|kept| (kept.workspace_id, Arc::clone(&kept.graph), kept.digest)); + let Some((workspace_id, graph, digest)) = held else { + return; + }; + if mutated_workspace == Some(workspace_id) { + return; + } + let state_in = |state: &Self| { + state + .metadata() + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == workspace_id) + .cloned() + }; + // Compared rather than assumed from the transaction's shape, so a + // transaction that reaches a workspace's state by any other route + // inherits nothing for it. + match (state_in(current), state_in(self)) { + (Some(before), Some(after)) if before == after => {} + _ => return, + } + *self + .workspace_graph + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(KeptWorkspaceGraph { + workspace_id, + graph, + digest, + inherited: true, }); } + + /// A copy of the graph this state inherited for `workspace_id`, bound + /// under this state's envelope. + /// + /// Served before publication too, which a graph this state materialized + /// itself never is. The commit still preparing this state writes only its + /// binding history, and the inherited domains do not read it; the binding + /// is applied here, from the envelope as it stands at this call. Once the + /// state is published the envelope is final, and the bound copy replaces + /// the inherited one as this state's own. + fn inherited_workspace_graph( + &self, + workspace_id: &WorkspaceId, + ) -> Result, KinDbError> { + let inherited = self + .workspace_graph + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .filter(|kept| kept.inherited && &kept.workspace_id == workspace_id) + .map(|kept| (Arc::clone(&kept.graph), kept.digest)); + let Some((graph, digest)) = inherited else { + return Ok(None); + }; + let mut snapshot = GraphSnapshot::clone(&graph); + let identities = self.lineage_identities()?; + let digest = super::binding_history::bind_selected_graph( + self.metadata(), + *workspace_id, + &mut snapshot, + digest, + identities.as_ref().map(|held| held.as_slice()), + )?; + #[cfg(test)] + WORKSPACE_GRAPHS_INHERITED.with(|count| count.set(count.get() + 1)); + let mut kept = self + .workspace_graph + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(kept) = kept + .as_mut() + .filter(|kept| kept.inherited && &kept.workspace_id == workspace_id) + { + kept.digest = digest; + if self.is_published() { + kept.graph = Arc::new(snapshot.clone()); + kept.inherited = false; + } + } + Ok(Some(snapshot)) + } + + /// Identity hash of every record in this state's operation log, by + /// position. + /// + /// An identity is a canonical encoding of the whole record, including the + /// workspace mutation it carried, and the local root and every + /// binding-history lineage read all of them. A state's log never changes + /// once the state exists, so the list is computed at most once per state, + /// and a successor is handed its predecessor's list plus the one record it + /// appended rather than hashing the log again. + pub(crate) fn operation_identities(&self) -> Result>, KinDbError> { + if let Some(held) = self.operation_identities.get() { + return Ok(Arc::clone(held)); + } + let computed = Arc::new(operation_identities_of(self.metadata())?); + Ok(Arc::clone( + self.operation_identities.get_or_init(|| computed), + )) + } + + /// [`Self::operation_identities`] for a caller about to validate this + /// state's binding-history lineages, or `None` when it holds no lineage and + /// nothing would read them. + fn lineage_identities(&self) -> Result>>, KinDbError> { + if self.metadata().binding_history.is_empty() { + return Ok(None); + } + self.operation_identities().map(Some) + } +} + +/// Identity hash of every record in `metadata`'s operation log, in log order. +fn operation_identities_of( + metadata: &PersistedRepositoryAuthority, +) -> Result, KinDbError> { + Ok(metadata + .operation_log + .iter() + .map(RepositoryOperationRecord::identity_hash) + .collect::>>()?) } /// Whether a durable prepared artifact can pay for itself for this workspace. @@ -2057,6 +2325,42 @@ impl RepositorySnapshotPersistence { } } + /// The proven frame this publication may take, or `None` when it must be + /// a full snapshot whatever its size. + /// + /// A successor that raises the envelope schema, which only a re-derivation + /// commit does, changes the representation a frame is encoded against, so + /// it is persisted whole rather than offered to the frame encoder, which + /// refuses it. The byte bound is judged by the caller, on the frame this + /// returns. + fn candidate_frame( + &self, + current: &RepositoryAuthorityState, + next: &RepositoryAuthorityState, + state: &PersistenceState, + ) -> Option { + let same_schema = current.metadata().schema_version == next.metadata().schema_version; + if !same_schema || !self.frame_is_candidate(state) { + return None; + } + match self.encode_frame(current, next) { + Ok(frame) => Some(frame), + Err(error) => { + // The writer refusing its own frame is a kin-db defect, never a + // durability question: the successor is still fully validated, + // so it is persisted whole and the defect is reported where an + // operator will see it. + tracing::error!( + repository = %self.repository_id, + generation = next.generation(), + error = %error, + "authority frame encoding refused; persisting this publication as a full snapshot" + ); + None + } + } + } + /// Encode the frame that carries `current` to `next`, proven from its own /// wire bytes, or say why the publication must be a full snapshot instead. fn encode_frame( @@ -2116,31 +2420,7 @@ impl RepositorySnapshotPersistence { // an earlier one was either reconciled as committed or dropped, and a // frame retained for it must not outlive that decision. state.pending_frame = None; - // A successor that raises the envelope schema, which only a - // re-derivation commit does, changes the representation a frame is - // encoded against, so it is persisted whole rather than offered to the - // frame encoder, which refuses it. - let same_schema = current.metadata().schema_version == next.metadata().schema_version; - let frame = if same_schema && self.frame_is_candidate(&state) { - match self.encode_frame(current, next) { - Ok(frame) => Some(frame), - Err(error) => { - // The writer refusing its own frame is a kin-db defect, - // never a durability question: the successor is still - // fully validated, so it is persisted whole and the defect - // is reported where an operator will see it. - tracing::error!( - repository = %self.repository_id, - generation = next.generation(), - error = %error, - "authority frame encoding refused; persisting this publication as a full snapshot" - ); - None - } - } - } else { - None - }; + let frame = self.candidate_frame(current, next, &state); let shape = match &frame { Some(frame) => self.publication_shape(&state, frame.bytes().len() as u64), None => PublicationShape::FullSnapshot, @@ -2437,18 +2717,121 @@ fn record_frame_persistence_phases( } impl RepositorySnapshotPersistence { - /// Persist a full snapshot and keep the backend lock that committed it. + /// Persist a freezing publication and keep the backend lock that committed + /// it. /// /// The freezing publication is the branch, tag, stash, merge-state, and - /// replay-recovery path; it stays a full snapshot in this revision, which - /// also compacts whatever journal the commit path built up. + /// replay-recovery path. It takes the shape any other publication takes + /// under the same bounds: one authority frame while the journal has room, + /// otherwise a full snapshot, which also compacts the journal. A branch + /// create or delete moves the ref state and appends one operation, so its + /// frame is a few kilobytes where the full snapshot is the whole store. + /// + /// Either shape commits at the record rename under the repository's + /// exclusive lock and returns that same lock, so the caller's freeze has + /// no release and reacquire window whichever was written. fn persist_and_freeze( &self, + current: &RepositoryAuthorityState, next: &RepositoryAuthorityState, ) -> RetainedPersistOutcome { + let _persist_span = tracing::info_span!("kindb.commit.persist_and_freeze").entered(); + let timer = PublicationPhaseTimer::start(); + let mut state = self.state.lock(); + // As in `persist_successor`: a persist call means no candidate is + // retained, so a frame kept for an earlier one must not outlive it. + state.pending_frame = None; + if let Some(frame) = self.candidate_frame(current, next, &state) { + if self.publication_shape(&state, frame.bytes().len() as u64) == PublicationShape::Frame + { + return self.persist_frame_and_freeze(&mut state, next, &frame, timer); + } + } + // The publication's writer permit is held across this hand-off, so + // nothing else persists between releasing the journal state here and + // the full-snapshot path taking it again. + drop(state); self.persist_and_freeze_inner(next, None) } + /// Append `frame` as the successor and keep the backend lock that + /// committed it. + /// + /// The writer-side bookkeeping is exactly an ordinary frame append's: the + /// cursor and the journal counts move only on a committed append, and an + /// indeterminate one retains the exact frame bytes for the successor they + /// were encoded for, so the next commit reconciles them against the record + /// rather than guessing. + fn persist_frame_and_freeze( + &self, + state: &mut PersistenceState, + next: &RepositoryAuthorityState, + frame: &crate::storage::authority_frame::ProvenFrame, + mut timer: PublicationPhaseTimer, + ) -> RetainedPersistOutcome { + timer.lap_ms(); + let bytes = frame.bytes(); + let cursor = state.cursor; + let (persisted, record) = self.installing_record(|| { + self.backend.save_authority_frame_and_freeze( + self.repository_id.as_str(), + bytes, + cursor, + Some(HISTORY_VALIDATION_VERSION), + ) + }); + let outcome = match persisted { + Ok((committed_cursor, retained)) if committed_cursor != state.cursor => { + let generation = committed_cursor.backend_generation(); + state.cursor = committed_cursor; + // Named the way an ordinary append names its head: the head + // this writer could already name, extended by the frame it just + // wrote. The record the retained lock committed names the same + // head, and when it does not this writer's view was stale, so + // it stops naming one and a later freeze reloads in full. + state.head = state + .head + .take() + .and_then(|head| head.with_frame(generation, hex::encode(Sha256::digest(bytes)))) + .filter(|head| head == retained.identity()); + state.record = record.filter(|_| state.head.is_some()); + state.journal_frames = state.journal_frames.saturating_add(1); + state.journal_bytes = state.journal_bytes.saturating_add(bytes.len() as u64); + next.mark_published(); + if let Some(head) = state.head.clone() { + next.bind_durable_identity(head); + } + if let Some(record) = state.record { + next.bind_installed_record(record); + } + RetainedPersistOutcome::Committed { retained } + } + Ok((committed_cursor, _)) => RetainedPersistOutcome::Indeterminate(storage(format!( + "authority frame backend acknowledged a freezing publication without advancing its CAS cursor from {}", + committed_cursor.backend_generation() + ))), + Err(error @ KinDbError::SnapshotPersistenceIndeterminate(_)) => { + RetainedPersistOutcome::Indeterminate(error) + } + Err(error) => RetainedPersistOutcome::NotCommitted(error), + }; + if let RetainedPersistOutcome::Indeterminate(_) = &outcome { + state.pending_frame = Some(PendingFrame { + roots: next.roots().clone(), + bytes: bytes.to_vec(), + }); + } + let write_ms = timer.lap_ms(); + record_frame_persistence_phases( + frame.drain_ms(), + frame.self_check_ms(), + frame.serialize_ms(), + write_ms, + bytes.len(), + ); + outcome + } + fn persist_and_freeze_inner( &self, next: &RepositoryAuthorityState, @@ -2751,8 +3134,8 @@ impl LocalRepositoryAuthorityFreeze { // cached history proofs, while the retained lock excludes writers. let snapshot = crate::storage::backend::recover_snapshot_from_state( repository_id.as_str(), - locked.authority(), - locked.frames(), + locked.authority()?, + locked.frames()?, None, crate::storage::backend::HistoryDecode::Streamed(&mut |change| { crate::storage::change_validation::validate_semantic_change(change) @@ -3256,7 +3639,7 @@ impl RepositoryAuthorityMetadata { else { return Ok(None); }; - let (authority, journal) = locked.into_captured(); + let (authority, journal) = locked.into_captured()?; let read_at = started.elapsed(); let envelope = crate::storage::format::AuthorityEnvelopeSnapshot::from_bytes( &authority.snapshot_bytes, @@ -4479,7 +4862,11 @@ impl RepositoryAuthorityManager { .as_mut() .expect("validated authority") .binding_history = witnesses; - super::binding_history::validate_metadata(next.metadata())?; + let identities = next.lineage_identities()?; + super::binding_history::validate_metadata_with( + next.metadata(), + identities.as_ref().map(|held| held.as_slice()), + )?; Ok(AuthorityCommitDecision::Publish { next, output }) } @@ -4613,7 +5000,7 @@ impl RepositoryAuthorityManager { } }, |_, current| self.freeze_exact_state(current), - |persistence, _, next| match persistence.persist_and_freeze(next) { + |persistence, current, next| match persistence.persist_and_freeze(current, next) { RetainedPersistOutcome::Committed { retained } => { RetainedPersistOutcome::Committed { retained: LocalRepositoryAuthorityFreeze { @@ -5091,7 +5478,12 @@ fn prepare_successor_with_history( external_history, history, )?; - git_snapshot_proof::admit(current, &snapshot, &mut metadata, transaction, source)?; + // A transaction that leaves the Git authority carried brings no snapshot + // proof, so there is none to admit, and the proofs it holds were validated + // with the predecessor: see the same carry in `validate_against_snapshot_with`. + if history.git_authority_required() || !transaction.git_snapshot_proofs.is_empty() { + git_snapshot_proof::admit(current, &snapshot, &mut metadata, transaction, source)?; + } // A carried successor keeps the predecessor's policies, which were derived // from this same history. if history.history_required() { @@ -5125,7 +5517,7 @@ fn prepare_successor_with_history( // Every remaining validation reads the same authority-free payload: // `changes` is final once admission above returns, and the steps below // mutate only the detached metadata. One shared decode serves them all. - let replay = history.replay_graph(&snapshot, None); + let replay = history.preparation_replay_graph(&snapshot, &transaction.changes); apply_workspace(backend, &replay, &snapshot, &mut metadata, transaction)?; let workspace_ms = timer.lap_ms(); drop(lap); @@ -5200,6 +5592,14 @@ fn prepare_successor_with_history( // Release any retained workspace base tree once its admission readers // finish, before constructing the successor's roots and envelope. let replay_decode_ms = replay.build_ms(); + // Read off the view itself rather than inferred from the arm: every arm + // has proven or carried the payload by here, and storage admission below + // reads this same payload with only the envelope and version put back. + let payload = if replay.proven.get().is_some() { + PayloadProof::Proven + } else { + PayloadProof::Required + }; drop(replay); let lap = tracing::info_span!("kindb.prepare.roots").entered(); @@ -5230,12 +5630,20 @@ fn prepare_successor_with_history( }; operation.validate()?; metadata.operation_log.push(operation.clone()); + // The successor's log is the predecessor's, cloned above with the rest of + // its snapshot, and this one record: nothing between that clone and here + // touches a record already in it. So its identities are the predecessor's, + // which that state hashes at most once, and this record's. The identity + // excludes both root bundles, so it is final before `roots_after` is. + let mut operation_identities = current.operation_identities()?.as_ref().clone(); + operation_identities.push(operation.identity_hash()?); metadata.roots = compute_successor_roots( &snapshot, &metadata, next_generation, history, current.roots(), + &operation_identities, )?; let roots_ms = timer.lap_ms(); drop(lap); @@ -5246,6 +5654,14 @@ fn prepare_successor_with_history( .operation_log .last_mut() .expect("operation was just appended") = operation.clone(); + // Checked against the final log, `roots_after` included, so a test build + // refuses the carry if an identity ever starts reading the roots. + #[cfg(test)] + assert_eq!( + operation_identities, + operation_identities_of(&metadata)?, + "a successor's carried operation identities are its log's own" + ); let receipt = RepositoryCommitReceipt { operation_id: transaction.operation_id, @@ -5306,8 +5722,16 @@ fn prepare_successor_with_history( // points restores this caller's obligation to // `RootRecomputation::Required`. This is the ONE caller entitled to state // it, because it is the only one holding a bundle it folded itself. - snapshot - .validate_storage_admission_with_proven_roots(GitProjectionTreeReplay::Proven, history)?; + // + // The payload proof is stated on the same terms through `payload`: the + // replay view above proved or carried exactly this authority-free payload, + // and the only writes since are `repository_authority` and `version`, which + // the proof does not read. + snapshot.validate_storage_admission_with_proven_roots( + GitProjectionTreeReplay::Proven, + history, + payload, + )?; let storage_admission_ms = timer.lap_ms(); drop(lap); @@ -5317,6 +5741,15 @@ fn prepare_successor_with_history( } else { RepositoryAuthorityState::from_validated_successor(current, snapshot, &transaction.changes) }; + // Both are derived from this exact successor and nothing else. The + // identities were folded into its local root above, and the comparison + // there was against this same log. + let _ = state + .operation_identities + .set(Arc::new(operation_identities)); + if let Some(mutated_workspace) = history.inherits_workspace_graphs_except() { + state.inherit_workspace_graph(current, mutated_workspace); + } let successor_index_ms = timer.lap_ms(); drop(lap); let elapsed = prepare_started.elapsed(); @@ -5735,6 +6168,29 @@ pub(crate) enum RootRecomputation { Proven, } +/// Whether storage admission proves the authority-free payload decodes, or the +/// caller has already proven it over this exact payload. +/// +/// The proof is [`SharedReplayGraph::prove`]: storage admission over the +/// payload with its envelope ignored, and the history replay behind it. +/// Storage admission needs it before a workspace's base resolves, and it used +/// to build a replay view of its own and run the proof again, after successor +/// preparation had run it a few statements earlier over the same payload. On +/// every commit that replayed history, that was one more replay of the whole +/// history: 27 seconds of a 244-second collaboration commit on a converted +/// axum store in a debug build. +/// +/// [`PayloadProof::Proven`] states an exact obligation, as +/// [`RootRecomputation::Proven`] does, and only `prepare_successor` states +/// it: its own replay view proved this payload, and the only writes since are +/// the authority envelope and the snapshot version, which the proof does not +/// read. Every other caller passes [`PayloadProof::Required`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PayloadProof { + Required, + Proven, +} + /// Whether a successor still owes the proofs that read immutable history alone. /// /// Five checks in successor preparation and storage admission read nothing a @@ -5749,11 +6205,13 @@ pub(crate) enum RootRecomputation { /// most of the minutes `kin branch create` spent preparing a one-ref successor. /// /// [`HistoryProof::CarriedFromPredecessor`] states an exact obligation. The -/// transaction admits no change, alias, external object, Git authority delta, -/// collaboration record or local overlay, so the successor's history, Git -/// authority, admission policies, child index and authority-free payload are -/// the predecessor's own values, and the one operation it appends records no -/// Git delta. The predecessor passed every one of these checks, because every +/// transaction admits no change, alias, external object, Git authority delta +/// or local overlay, so the successor's history, Git authority, admission +/// policies and child index are the predecessor's own values, and the one +/// operation it appends records no Git delta. Its authority-free payload is +/// the predecessor's too, except for any collaboration records it admits, +/// which no history proof reads; the paragraph on collaboration records below +/// says why. The predecessor passed every one of these checks, because every /// [`RepositoryAuthorityState`] did: an open runs them or trusts a /// history-validation record bound to the exact bytes that passed them, a /// frozen lock and an equivalent rewrite run them, and a successor runs them @@ -5766,11 +6224,43 @@ pub(crate) enum RootRecomputation { /// Everything a ref or workspace mutation can change still runs on a carried /// successor: the ref leases, targets and fast-forward checks, the /// operation-log and receipt chain, the mutated workspace, and the ref-state, -/// ref-log and local roots. The other three roots carry, for the same reason, -/// through [`compute_successor_roots`]. +/// ref-log and local roots. The history and replication roots carry, for the +/// same reason, through [`compute_successor_roots`], and so does the +/// collaboration root unless the transaction admits collaboration records. +/// +/// Two more derivations rest on this same obligation. A workspace's selected +/// graph is a function of the authority-free payload, of what its base target +/// resolves through, and of its own state. A base target is always resolved, +/// never symbolic, so no ref mutation moves it. Every workspace such a +/// transaction leaves as it was therefore selects the graph its predecessor +/// materialized, and the successor inherits that graph instead of resolving +/// the base and applying the overlay again: +/// [`RepositoryAuthorityState::inherit_workspace_graph`]. The local root still +/// folds on every successor, but over operation identities the predecessor +/// already hashed plus the one record appended: +/// [`RepositoryAuthorityState::operation_identities`]. /// A persisted full snapshot still re-verifies every history record as it /// serializes, so history bytes edited after the open are refused at the write. /// +/// Collaboration records (reviews and their flags and sightings, test cases +/// and verification runs, agent authorship actors and audit events) are +/// snapshot collections beside the history, never part of it, and a +/// collaboration delta only adds or replaces records in them. No history +/// proof reads them: the replay, the admission policies and the child index +/// read `changes` alone, and the one part of the payload proof that does read +/// a collaboration collection, the graph-node sets relation endpoints are +/// checked against, runs again on every successor in its own storage +/// admission over the final snapshot. So a transaction that admits +/// collaboration records and no history carries the history proofs as well, +/// under two obligations a carried successor otherwise drops. Its collaboration +/// root folds again. And it inherits no workspace graph, because a +/// materialized workspace graph holds a copy of every collaboration +/// collection and the relations derived from verification runs, so the +/// predecessor's graph is not the one this successor selects. A daemon makes +/// such a commit for every review flag, stored test run and ended agent +/// session, and on a converted axum store with 1,901 changes each one used to +/// replay the whole history three times over: 244 seconds in a debug build. +/// /// [`HistoryProof::GitAuthorityCarried`] is the narrower obligation of an /// ordinary native commit. It adds history, so every other history proof runs /// over it, but it admits no alias, external object or Git authority delta, @@ -5778,7 +6268,21 @@ pub(crate) enum RootRecomputation { /// the external objects, the aliases, the Git deltas the operation log /// records, and, among the changes, only those an alias names or whose origin /// is a Git commit; the projection replay walks aliased changes alone. None of -/// that differs from the predecessor, so their verdict carries too. +/// that differs from the predecessor, so their verdict carries too. The Git +/// snapshot proofs carry with them in both arms: validating them reads the +/// proofs, the external objects they name and the root changes they prove, +/// and such a transaction brings no proof and no object while history only +/// grows. +/// +/// History only growing is also what lets this arm replay just what it adds. +/// `admit_changes` appends, and refuses a held identity whose bytes differ, so +/// every change the predecessor held is byte-identical here and its +/// first-parent lineage is held changes alone. A replay verdict depends on a +/// change and its lineage and on nothing else, so the predecessor's verdict on +/// every held change stands, and the payload proof replays the lineage of the +/// changes this transaction adds rather than the whole forest: +/// [`SharedReplayGraph::new_over_additions`]. The Kahn pass over every change +/// still runs, so a merge parent nobody holds is refused as before. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum HistoryProof { Required, @@ -5788,6 +6292,9 @@ pub(crate) enum HistoryProof { // Authenticated by the predecessor's workspace admission, never by // the proposed mutation. Valid only while its history is unchanged. workspace_base: Option<(SemanticChangeId, Hash256)>, + // The transaction admits collaboration records, so the collaboration + // root folds and no workspace graph is inherited. + admits_collaboration: bool, }, } @@ -5806,10 +6313,10 @@ impl HistoryProof { if !git_authority_untouched { return Self::Required; } - if !transaction.changes.is_empty() - || transaction.collaboration_delta.is_some() - || transaction.local_overlay_delta.is_some() - { + // A local overlay is carried by no arm below: every unmutated + // workspace's validation reads the overlay its local stamp names, and a + // carried successor validates only the workspace it mutates. + if !transaction.changes.is_empty() || transaction.local_overlay_delta.is_some() { return Self::GitAuthorityCarried; } Self::CarriedFromPredecessor { @@ -5818,6 +6325,7 @@ impl HistoryProof { .as_ref() .map(|mutation| mutation.workspace_id), workspace_base: None, + admits_collaboration: transaction.collaboration_delta.is_some(), } } @@ -5831,7 +6339,9 @@ impl HistoryProof { current: &RepositoryAuthorityState, ) -> Result { let Self::CarriedFromPredecessor { - mutated_workspace, .. + mutated_workspace, + admits_collaboration, + .. } = self else { return Ok(self); @@ -5848,6 +6358,7 @@ impl HistoryProof { Ok(Self::CarriedFromPredecessor { mutated_workspace, workspace_base, + admits_collaboration, }) } @@ -5856,6 +6367,34 @@ impl HistoryProof { !matches!(self, Self::CarriedFromPredecessor { .. }) } + /// Whether the collaboration root folds again rather than carrying. + fn collaboration_root_required(self) -> bool { + match self { + Self::Required | Self::GitAuthorityCarried => true, + Self::CarriedFromPredecessor { + admits_collaboration, + .. + } => admits_collaboration, + } + } + + /// The workspace a successor under this obligation may NOT inherit a + /// materialized graph for, or `None` when it inherits none at all. + /// + /// Only a carried successor inherits, and only one that admits no + /// collaboration record: a workspace graph copies every collaboration + /// collection, so a new record changes the graph every workspace selects. + fn inherits_workspace_graphs_except(self) -> Option> { + match self { + Self::CarriedFromPredecessor { + mutated_workspace, + admits_collaboration: false, + .. + } => Some(mutated_workspace), + _ => None, + } + } + /// Whether the Git authority shape, projection and alias checks still run. fn git_authority_required(self) -> bool { matches!(self, Self::Required) @@ -5885,6 +6424,27 @@ impl HistoryProof { } replay } + + /// The replay view successor preparation reads, for a transaction adding + /// `additions` to its predecessor's history. + /// + /// Under [`Self::GitAuthorityCarried`] history only grows, so the payload + /// proof replays the lineage of `additions` alone; the paragraph on that + /// arm says why the predecessor's verdict stands for every held change. + /// Every other arm reads [`Self::replay_graph`]. + fn preparation_replay_graph<'a>( + self, + snapshot: &'a GraphSnapshot, + additions: &[kin_model::SemanticChange], + ) -> SharedReplayGraph<'a> { + match self { + Self::GitAuthorityCarried => SharedReplayGraph::new_over_additions( + snapshot, + additions.iter().map(|change| change.id), + ), + _ => self.replay_graph(snapshot, None), + } + } } fn validate_git_authority_shape_and_projection( @@ -7974,6 +8534,10 @@ fn verify_artifact_admission( struct SharedReplayGraph<'a> { snapshot: &'a GraphSnapshot, admitted: Option>, + // The changes a transaction adds to a history its predecessor already + // validated, sorted and deduplicated, when the proof replays their lineage + // alone. `None` replays the whole history. + additions: Option>, workspace_base: Option<(SemanticChangeId, Hash256)>, workspace_base_tree: std::cell::OnceCell, proven: std::cell::OnceCell<()>, @@ -7998,6 +8562,7 @@ impl<'a> SharedReplayGraph<'a> { Self { snapshot, admitted: admitted.copied(), + additions: None, workspace_base: None, workspace_base_tree: std::cell::OnceCell::new(), proven: std::cell::OnceCell::new(), @@ -8006,6 +8571,32 @@ impl<'a> SharedReplayGraph<'a> { } } + /// Build a replay view over a successor whose predecessor validated every + /// change it held, and which adds `additions` and nothing else. + /// + /// Its proof runs the same storage admission over the authority-free + /// payload, then replays the first-parent lineage of `additions` instead + /// of the whole forest. Only [`HistoryProof::GitAuthorityCarried`] is + /// entitled to ask for it; its documentation states the obligation. + fn new_over_additions( + snapshot: &'a GraphSnapshot, + additions: impl IntoIterator, + ) -> Self { + let mut additions: Vec<_> = additions.into_iter().collect(); + additions.sort_unstable(); + additions.dedup(); + Self { + additions: Some(additions), + ..Self::new(snapshot) + } + } + + /// Whether this view's own proof replayed exactly the lineage of + /// `targets`, so a caller asking for that same replay already has it. + fn proof_replayed_lineage_of(&self, targets: &[SemanticChangeId]) -> bool { + self.proven.get().is_some() && self.additions.as_deref() == Some(targets) + } + /// Prove the payload decodes, once, and remember that it did. /// /// Every query below runs this first, so a payload that fails the proof @@ -8015,13 +8606,30 @@ impl<'a> SharedReplayGraph<'a> { if self.proven.get().is_none() { let started = std::time::Instant::now(); self.proof_runs.set(self.proof_runs.get() + 1); + #[cfg(test)] + PAYLOAD_PROOFS_ON_THIS_THREAD.with(|count| count.set(count.get() + 1)); // The witness, when there is one, was minted for this very map: // nothing is cloned here, so the pointer identity `describes` // checks holds outright instead of being carried onto a copy. - InMemoryGraph::prove_authority_free_snapshot_decode( - self.snapshot, - self.admitted.as_ref(), - )?; + match &self.additions { + None => InMemoryGraph::prove_authority_free_snapshot_decode( + self.snapshot, + self.admitted.as_ref(), + )?, + Some(additions) => { + InMemoryGraph::prove_authority_free_snapshot_admission( + self.snapshot, + self.admitted.as_ref(), + )?; + let _span = tracing::info_span!( + "kindb.graph.validate_history", + changes = self.snapshot.changes.len(), + additions = additions.len() + ) + .entered(); + validate_first_parent_history(&self.snapshot.changes, additions)?; + } + } self.build_ms.set(started.elapsed().as_millis()); let _ = self.proven.set(()); } @@ -8170,6 +8778,20 @@ fn history_replays_on_this_thread() -> usize { HISTORY_REPLAYS_ON_THIS_THREAD.with(std::cell::Cell::get) } +#[cfg(test)] +thread_local! { + static PAYLOAD_PROOFS_ON_THIS_THREAD: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + +/// How many authority-free payload proofs this thread ran, across every replay +/// view. Counted in [`SharedReplayGraph::prove`] itself, so a proof storage +/// admission runs on a view of its own is counted too. +#[cfg(test)] +fn payload_proofs_on_this_thread() -> usize { + PAYLOAD_PROOFS_ON_THIS_THREAD.with(std::cell::Cell::get) +} + fn validate_history_replay_with<'a>( replay: &SharedReplayGraph<'_>, snapshot: &GraphSnapshot, @@ -8190,7 +8812,9 @@ fn validate_history_replay_with<'a>( // and it proves graph truth rather than serving queries, so it needs // neither a text index nor a decoded copy of the payload to keep. The // shared proof is that exact pass: an earlier preparation step may already - // have paid for it, and asking for it here proves it. + // have paid for it, and asking for it here proves it. A view built over a + // validated predecessor derives only the timelines its additions reach; + // see `SharedReplayGraph::new_over_additions`. replay.prove()?; // New transactions validate every admitted tip directly. Reopen has no @@ -8201,6 +8825,16 @@ fn validate_history_replay_with<'a>( // Reopen and freeze borrow the complete map; transaction preparation // borrows only incoming changes. Replay needs identities, not owned payloads. let mut validation_targets: Vec<_> = new_changes.into_iter().map(|change| change.id).collect(); + validation_targets.sort_unstable(); + validation_targets.dedup(); + // A proof over a predecessor's validated history replays exactly what the + // transaction adds, which is this same set, so it has already been + // replayed once. An empty set is a transaction that adds no history to a + // validated predecessor, and it has nothing to replay, rather than every + // leaf the way a reopen with no predecessor does. + if replay.proof_replayed_lineage_of(&validation_targets) { + return Ok(()); + } if validation_targets.is_empty() { validation_targets = snapshot .changes @@ -8394,11 +9028,16 @@ fn is_ancestor( /// all three as one `TransactionDelta` preserves the graph engine's atomic /// validation: an entity can never be restored onto an absent artifact and a /// relation can never point at a missing entity. +/// `operation_identities` is as +/// [`validate_metadata_with`](super::binding_history::validate_metadata_with) +/// takes it: the identity of every record in `metadata.operation_log`, from +/// the state that owns that log, or `None` to hash them here. fn materialize_workspace_graph_snapshot( authority_snapshot: &GraphSnapshot, metadata: &PersistedRepositoryAuthority, workspace: &WorkspaceState, admitted: Option<&AdmittedChangeMap<'_>>, + operation_identities: Option<&[Hash256]>, ) -> Result { let base = resolve_workspace_base_graph_snapshot( authority_snapshot, @@ -8407,7 +9046,13 @@ fn materialize_workspace_graph_snapshot( WorkspaceBaseHistory::Carried(admitted), )?; let mut selected = materialize_workspace_graph_snapshot_from_base(base, workspace, admitted)?; - super::binding_history::bind_selected_graph(metadata, workspace.workspace_id, &mut selected)?; + super::binding_history::bind_selected_graph( + metadata, + workspace.workspace_id, + &mut selected, + None, + operation_identities, + )?; Ok(selected) } @@ -8419,7 +9064,7 @@ fn materialize_workspace_graph_snapshot( /// check below is the one the resolving entry point runs; nothing is skipped /// because the base arrived by argument. fn materialize_workspace_graph_snapshot_from_base( - base: GraphSnapshot, + mut base: GraphSnapshot, workspace: &WorkspaceState, admitted: Option<&AdmittedChangeMap<'_>>, ) -> Result { @@ -8447,6 +9092,7 @@ fn materialize_workspace_graph_snapshot_from_base( workspace.workspace_id ))); } + InMemoryGraph::restore_verification_proof_relations(&mut base); return Ok(base); } // Materialization consumes this graph as a snapshot and drops it. Building a @@ -8475,7 +9121,8 @@ fn materialize_workspace_graph_snapshot_from_base( // it out instead held a whole second copy of everything `materialized` // carries across the admission pass below, which at repository scale is // one more whole history. - let materialized = graph.into_snapshot(); + let mut materialized = graph.into_snapshot(); + InMemoryGraph::restore_verification_proof_relations(&mut materialized); let to_snapshot_ms = timer.lap_ms(); if materialized.resolved_tree != workspace.tree { return Err(storage(format!( @@ -8602,6 +9249,9 @@ thread_local! { /// Workspace graphs a state handed out from the one it had kept. static WORKSPACE_GRAPHS_SERVED_FROM_STATE: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Workspace graphs a state handed out from the one its predecessor kept. + static WORKSPACE_GRAPHS_INHERITED: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Base-graph resolutions performed on this thread, ever. /// /// Read only as the endpoints of a span; the running total is meaningless @@ -9104,9 +9754,10 @@ fn derive_workspace_semantic_overlay( .collect(); let relation_deltas = base .relations - .keys() - .chain(desired.relations.keys()) - .copied() + .iter() + .filter(|(_, relation)| crate::engine::is_semantic_relation(relation)) + .map(|(id, _)| *id) + .chain(desired.relations.keys().copied()) .collect::>() .into_iter() .filter_map(|relation_id| { @@ -9284,12 +9935,18 @@ fn validate_workspace_authority( metadata: &PersistedRepositoryAuthority, admitted: Option<&AdmittedChangeMap<'_>>, history: HistoryProof, + payload: PayloadProof, ) -> Result<(), KinDbError> { // One decode serves every workspace's base-tree check instead of one // rebuild per workspace. A workspace validation reads no ref, and the only // operations it reads are those that mutated it, so a carried successor // re-validates the one workspace its transaction moved and carries the rest. let replay = history.replay_graph(snapshot, admitted); + // The caller proved this exact payload in the same preparation; see + // `PayloadProof::Proven`. Each base still resolves its own lineage below. + if payload == PayloadProof::Proven { + let _ = replay.proven.set(()); + } for workspace in metadata .workspaces .iter() @@ -9586,12 +10243,10 @@ fn peel_authority_external_target( )) .into()); }; - let entries = authority - .closure - .objects - .iter() - .map(|entry| (entry.record.object, entry)) - .collect::>(); + // Indexed only for an annotated tag: a commit, which is what nearly every + // branch targets, peels to itself, and indexing the whole closure for it + // would make every branch read cost the size of the history. + let mut entries = None; let mut current = object; let mut seen = BTreeSet::new(); while current.kind == ExternalObjectKind::Tag { @@ -9603,6 +10258,14 @@ fn peel_authority_external_target( )) .into()); } + let entries = entries.get_or_insert_with(|| { + authority + .closure + .objects + .iter() + .map(|entry| (entry.record.object, entry)) + .collect::>() + }); let entry = entries.get(¤t).ok_or_else(|| { ModelError::InvalidOperation(format!( "external tag {} is absent from the persisted Git authority closure", @@ -10604,7 +11267,7 @@ fn compute_roots( }; let local_state = { let _s = tracing::info_span!("kindb.roots.local_state").entered(); - local_state_root(snapshot, authority)? + local_state_root(snapshot, authority, None)? }; Ok(RootBundle { version: REPOSITORY_ROOT_SCHEMA_VERSION, @@ -10628,9 +11291,9 @@ fn compute_roots( /// ref-only preparation on a converted Flask store once the history proofs /// carried, and a large part of every native commit's. Under /// [`HistoryProof::CarriedFromPredecessor`] the history root, which reads the -/// changes and admission policies, and the collaboration root, which reads the -/// collaboration records, carry for the same reason. Every other root is -/// folded here as always. +/// changes and admission policies, carries for the same reason, and so does +/// the collaboration root, which reads the collaboration records, unless the +/// transaction admits some. Every other root is folded here as always. /// /// A bundle of another root schema is never carried from; the whole bundle is /// folded instead, so a carried root is always one this binary would compute. @@ -10640,6 +11303,7 @@ fn compute_successor_roots( generation: u64, history: HistoryProof, predecessor: &RootBundle, + operation_identities: &[Hash256], ) -> Result { let carried_schema = [ predecessor.version, @@ -10654,24 +11318,23 @@ fn compute_successor_roots( } #[cfg(test)] ROOT_BUNDLES_FOLDED_ON_THIS_THREAD.with(|count| count.set(count.get() + 1)); - let (history_hash, collaboration_hash) = if history.history_required() { - let history_hash = { - let _s = tracing::info_span!("kindb.roots.history").entered(); - history_root(snapshot, authority)? - }; - let collaboration_hash = { - let _s = tracing::info_span!("kindb.roots.collaboration").entered(); - collaboration_root(snapshot)? - }; - ( - AuthorityRoot::new(REPOSITORY_ROOT_SCHEMA_VERSION, history_hash), - AuthorityRoot::new(REPOSITORY_ROOT_SCHEMA_VERSION, collaboration_hash), + let history_hash = if history.history_required() { + let _s = tracing::info_span!("kindb.roots.history").entered(); + AuthorityRoot::new( + REPOSITORY_ROOT_SCHEMA_VERSION, + history_root(snapshot, authority)?, ) } else { - ( - predecessor.history.clone(), - predecessor.collaboration.clone(), + predecessor.history.clone() + }; + let collaboration_hash = if history.collaboration_root_required() { + let _s = tracing::info_span!("kindb.roots.collaboration").entered(); + AuthorityRoot::new( + REPOSITORY_ROOT_SCHEMA_VERSION, + collaboration_root(snapshot)?, ) + } else { + predecessor.collaboration.clone() }; let ref_state = { let _s = tracing::info_span!("kindb.roots.ref_state").entered(); @@ -10683,7 +11346,7 @@ fn compute_successor_roots( }; let local_state = { let _s = tracing::info_span!("kindb.roots.local_state").entered(); - local_state_root(snapshot, authority)? + local_state_root(snapshot, authority, Some(operation_identities))? }; Ok(RootBundle { version: REPOSITORY_ROOT_SCHEMA_VERSION, @@ -10838,15 +11501,25 @@ fn replication_root(authority: &PersistedRepositoryAuthority) -> Result, ) -> Result { - let operation_identities = authority - .operation_log - .iter() - .map(RepositoryOperationRecord::identity_hash) - .collect::>>()?; + let hashed; + let operation_identities = + match operation_identities.filter(|held| held.len() == authority.operation_log.len()) { + Some(held) => held, + None => { + hashed = operation_identities_of(authority)?; + &hashed + } + }; let mut root = DomainRoot::new(b"kin-repository-local-root-v2\0"); root.unordered("sessions", &snapshot.sessions.iter().collect::>())?; root.unordered("intents", &snapshot.intents.iter().collect::>())?; @@ -10861,7 +11534,7 @@ fn local_state_root( if !authority.merge_transactions.is_empty() { root.ordered("merge_transactions", &authority.merge_transactions)?; } - root.ordered("operation_identities", &operation_identities)?; + root.ordered("operation_identities", operation_identities)?; Ok(root.finish()) } @@ -10961,8 +11634,8 @@ mod tests { MergeTransactionDelta, MergeTransactionState, MergeWorkspaceRestorePoint, RefMutation, Relation, RelationDelta, RelationId, RelationKind, RelationOrigin, RepoPath, ResolvedTree, SemanticChange, SemanticFingerprint, SensitiveArtifactAllowance, SensitiveArtifactKind, - TreeDelta, Visibility, WorkspaceExpectation, WorkspaceMutation, WorkspaceSemanticDelta, - REPOSITORY_TRANSACTION_SCHEMA_VERSION, + TreeDelta, VerificationStore, Visibility, WorkspaceExpectation, WorkspaceMutation, + WorkspaceSemanticDelta, REPOSITORY_TRANSACTION_SCHEMA_VERSION, }; use parking_lot::Mutex; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -15324,6 +15997,97 @@ mod tests { assert_eq!(reopened_prepared.resolved_tree, workspace.tree); } + #[test] + fn verification_proof_targets_survive_authority_publication_and_workspace_reopen() { + let backend = Arc::new(MemoryBackend::default()); + let manager = initial_manager(Arc::clone(&backend)); + let entity = semantic_test_entity("src/lib.rs", "kin", LanguageId::Rust, 0x30); + let mut initial = arbitrary_repository_transaction(&manager); + let change = initial.changes.first_mut().unwrap(); + change.entity_deltas.push(EntityDelta::Added { + new: entity.clone(), + }); + change.id = SemanticChangeId::from_hash(Hash256::from_bytes([0; 32])); + change.id = compute_semantic_change_id(change).unwrap(); + let base_target = RefTarget::change(change.id); + initial.ref_mutations[0].new_target = Some(base_target.clone()); + let workspace = initial.workspace_mutation.as_mut().unwrap(); + workspace.new_base_target = Some(base_target); + workspace.semantic_delta = WorkspaceSemanticDelta::new( + vec![EntityDelta::Added { + new: entity.clone(), + }], + Vec::new(), + ) + .unwrap(); + let work = kin_model::WorkItem { + work_id: kin_model::WorkId::new(), + kind: kin_model::WorkKind::Feature, + title: "Verified work".into(), + description: String::new(), + status: kin_model::WorkStatus::Proposed, + priority: kin_model::Priority::High, + scopes: vec![kin_model::WorkScope::Entity(entity.id)], + acceptance_criteria: Vec::new(), + external_refs: Vec::new(), + created_by: kin_model::IdentityRef::human("alice"), + created_at: Timestamp::now(), + }; + let run = kin_model::VerificationRun { + run_id: kin_model::VerificationRunId::new(), + test_ids: Vec::new(), + status: kin_model::VerificationStatus::Passing, + runner: kin_model::TestRunner::Cargo, + started_at: Timestamp::now(), + finished_at: None, + duration_ms: None, + evidence_blob: None, + exit_code: Some(0), + ran_against: vec![kin_model::RunFingerprint { + entity: entity.id, + behavior_hash: entity.fingerprint.behavior_hash, + }], + proof: Some(kin_model::VerificationProof { + entities: vec![entity.id], + work_items: vec![work.work_id], + }), + }; + initial.collaboration_delta = Some(kin_model::CollaborationDelta { + work_items: vec![kin_model::Keyed::new(work.work_id, work.clone())], + verification_runs: vec![kin_model::Keyed::new(run.run_id, run.clone())], + ..kin_model::CollaborationDelta::default() + }); + manager.commit_repository_transaction(initial).unwrap(); + let workspace_id = manager.read_authority().metadata().workspaces[0].workspace_id; + let check = |manager: &RepositoryAuthorityManager| { + let authority = manager.read_authority(); + assert!(authority.snapshot().entities.is_empty()); + assert!(authority.snapshot().relations.is_empty()); + let projection = authority + .workspace_graph_snapshot(&workspace_id) + .unwrap() + .unwrap(); + assert_eq!( + projection.relations.len(), + 2, + "the authority projection restores both proof links" + ); + let graph = InMemoryGraph::from_snapshot_without_text_index(projection).unwrap(); + assert_eq!( + graph.list_runs_proving_entity(&entity.id).unwrap(), + std::slice::from_ref(&run) + ); + assert_eq!( + graph.list_runs_proving_work(&work.work_id).unwrap(), + std::slice::from_ref(&run) + ); + }; + check(&manager); + drop(manager); + let reopened = RepositoryAuthorityManager::open(repository_id(), backend).unwrap(); + check(&reopened); + } + #[test] fn same_tree_polyglot_semantics_and_cross_language_relation_survive_reopen() { let backend = Arc::new(MemoryBackend::default()); @@ -19263,6 +20027,18 @@ mod tests { #[test] fn local_commit_returns_successor_freeze_without_releasing_writer_lock() { + freezing_commit_retains_its_commit_point_lock(PublicationShape::FullSnapshot); + } + + #[test] + fn local_frame_commit_returns_successor_freeze_without_releasing_writer_lock() { + freezing_commit_retains_its_commit_point_lock(PublicationShape::Frame); + } + + /// A freezing commit of either shape returns the lock its commit point + /// held, so a competing writer that queued on it during the commit waits + /// until the freeze is dropped and then loses its compare-and-swap. + fn freezing_commit_retains_its_commit_point_lock(shape: PublicationShape) { let directory = TempDir::new().unwrap(); let backend = Arc::new(LocalFileBackend::new(directory.path())); let manager = @@ -19270,6 +20046,14 @@ mod tests { manager .commit_repository_transaction(arbitrary_repository_transaction(&manager)) .unwrap(); + match shape { + PublicationShape::FullSnapshot => { + manager.persistence().set_max_journal_frames_for_test(0) + } + PublicationShape::Frame => manager + .persistence() + .set_journal_byte_bound_for_test(Some(u64::MAX)), + } let competing = RepositoryAuthorityManager::open( repository_id(), @@ -19292,7 +20076,7 @@ mod tests { .unwrap(); }); let hook_finished_rx = Arc::clone(&finished_rx); - backend.set_snapshot_before_authority_commit_hook(move || { + let hook = move || { release_tx.send(()).unwrap(); started_rx .recv_timeout(std::time::Duration::from_secs(2)) @@ -19305,12 +20089,23 @@ mod tests { .is_err(), "the competing writer must wait behind the commit-point lock" ); - }); + }; + match shape { + PublicationShape::FullSnapshot => { + backend.set_snapshot_before_authority_commit_hook(hook) + } + PublicationShape::Frame => backend.set_delta_before_authority_commit_hook(hook), + } let (receipt, freeze) = manager .commit_repository_transaction_and_freeze(successor) .expect("local commit must return its still-held successor lock"); assert_eq!(receipt.generation, 2); + assert_eq!( + acknowledged_frame_count(&directory), + usize::from(shape == PublicationShape::Frame), + "the freezing commit took the {shape:?} shape" + ); assert_eq!(freeze.roots(), &receipt.roots_after); assert_eq!(freeze.authority().roots(), &receipt.roots_after); assert!( @@ -22476,123 +23271,537 @@ mod tests { assert_eq!(store.manager.read_authority().roots(), &before); } - /// A workspace mutation is three materialization steps over a base graph - /// that costs a whole history replay to resolve, and the mutation's own - /// contract fixes which base each step wants. Resolving a base per step - /// replayed the same history two extra times on every forced tree - /// admission, which is the shape both publications of one `kin commit` - /// take. This pins the resolution count so it cannot drift back. - #[test] - fn one_workspace_mutation_resolves_each_distinct_base_exactly_once() { - let store = build_synthetic_history_store(2, 8, 1, 3); + /// The change a synthetic store's workspace is based at. + fn synthetic_base_change(store: &SyntheticHistoryStore) -> SemanticChangeId { + let lease = store.manager.read_authority(); + let base = lease.metadata().workspaces[0] + .base_target + .as_ref() + .expect("the synthetic workspace bases at head"); + target_change_id(lease.metadata(), base).unwrap() + } - let unchanged_base = overlay_transaction_at_unchanged_base(&store, 0xf2_2347_0001, 0); - let (receipt, resolutions) = mutation_base_resolutions_during(|| { - store - .manager - .commit_repository_transaction(unchanged_base) - .unwrap() - }); - receipt.validate().unwrap(); - assert_eq!( - resolutions, 1, - "a mutation that leaves its base where it was names one base graph, \ - so it must resolve one, not one per materialization" - ); + /// A native change on `parent` carrying `entity_deltas` and nothing else. + fn native_change_on( + parent: SemanticChangeId, + message: &str, + entity_deltas: Vec, + ) -> SemanticChange { + let mut change = SemanticChange { + id: SemanticChangeId::from_hash(Hash256::from_bytes([0; 32])), + origin: ChangeOrigin::Native, + parents: vec![parent], + timestamp: Timestamp( + chrono::DateTime::parse_from_rfc3339("2026-10-14T12:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + ), + author: AuthorId::new("authority-test"), + message: message.to_string(), + entity_deltas, + relation_deltas: Vec::new(), + tree_deltas: Vec::new(), + admission_policy_delta: None, + external_reference_deltas: Vec::new(), + projected_files: Vec::new(), + spec_link: None, + evidence: Vec::new(), + risk_summary: None, + resolution_record_deltas: Vec::new(), + }; + change.id = compute_semantic_change_id(&change).unwrap(); + change + } - let advanced = advance_synthetic_base(&store); - let (advanced_receipt, advanced_resolutions) = mutation_base_resolutions_during(|| { - store.manager.commit_repository_transaction(advanced) + /// One transaction publishing `change` on a new branch named `branch`. + fn change_on_new_branch( + manager: &RepositoryAuthorityManager, + operation: u128, + change: SemanticChange, + branch: &[u8], + ) -> RepositoryTransaction { + let mut transaction = transaction_shell(manager, operation); + transaction.ref_mutations.push(RefMutation { + name: RefName::branch(branch).unwrap(), + expected: RefExpectation::MustNotExist, + new_target: Some(RefTarget::change(change.id)), + policy: RefUpdatePolicy::FastForwardOnly, }); - advanced_receipt.unwrap().validate().unwrap(); - assert_eq!( - advanced_resolutions, 2, - "a mutation that advances its base names two distinct base graphs and must \ - resolve both, never fewer" - ); + transaction.changes.push(change); + transaction } - /// A workspace mutation compares four domains and reads no change through - /// the bases it compares over, so the bases it resolves carry no change - /// map. On a full-history conversion that map IS the repository, and each - /// base carrying one was a whole extra copy of it live at the point where - /// the preparation reaches its peak. - /// - /// This counts the changes the resolutions actually built rather than the - /// mode they were asked for, so it goes red three separate ways: a call - /// site asking for the history back, either one of them independently, and - /// a resolver that starts carrying the map whatever it was asked. The - /// peak-ratio guard in `tests/workspace_prepare_memory.rs` cannot do that - /// job: on its fixture one of the two copies fits entirely under a mark - /// another term has already set, so restoring that copy alone moves the - /// ratio by nothing at all. + /// What preparing `transaction`'s successor replayed: the changes every + /// history validation entered, the payload proofs run, and the calls into + /// the history replay. Read at the production seam rather than around a + /// whole commit, because this test backend decodes and validates every + /// snapshot it saves in full again, which a local store does not. + fn replay_work_preparing( + store: &SyntheticHistoryStore, + transaction: &RepositoryTransaction, + ) -> (RepositoryAuthorityState, usize, usize, usize) { + use crate::storage::history_replay::take_validated_history_changes; + let held = store.manager.read_authority(); + let _ = take_validated_history_changes(); + let proofs = payload_proofs_on_this_thread(); + let replays = history_replays_on_this_thread(); + let (after, receipt) = prepare_successor( + &held, + transaction, + transaction.transaction_hash().unwrap(), + store.backend.as_ref(), + NativeAdmissionSource::LocalWorkspace, + None, + ) + .unwrap(); + receipt.validate().unwrap(); + ( + after, + take_validated_history_changes(), + payload_proofs_on_this_thread() - proofs, + history_replays_on_this_thread() - replays, + ) + } + + /// A transaction that admits collaboration records and no history, the + /// shape a daemon commits for every review flag, stored test run and ended + /// agent session, carries the history proofs. Its preparation enters no + /// change in any history validation and runs no payload proof, in its own + /// laps or in storage admission, where a native commit on the same store + /// does both. /// - /// The resolution counts are asserted beside the change counts because zero - /// changes across zero resolutions is what a mutation that stopped resolving - /// anything would also report, and that is a different bug wearing this - /// one's passing costume. Those two lines are defensive rather than proven, - /// and that is worth saying rather than leaving someone to assume otherwise. - /// Every mutation tried against them, forcing one base to serve for two and - /// returning from `apply_workspace` before it resolves anything, is refused - /// by the product at the fixture's own bootstrap commit, so the test fails - /// on that `unwrap` and never reaches its own assertions. The change counts - /// below are the part of this test that has been shown to fail on demand. + /// What it carries is sound and what it owes still runs. The successor + /// passes a from-scratch validation under `HistoryProof::Required`, which + /// replays the whole history and folds every root to compare. Its history + /// and replication roots are the predecessor's and its collaboration root + /// is not. The workspace graph it serves holds the new records, although + /// the predecessor held a materialized graph that a carried successor + /// otherwise inherits. And a reopen, which on this backend trusts no record + /// and replays the whole history, loads exactly what it published. #[test] - fn a_workspace_mutation_resolves_no_base_carrying_the_history() { - let store = build_synthetic_history_store(2, 8, 1, 3); - let history_changes = { - let lease = store.manager.read_authority(); - let count = lease.snapshot().changes.len(); - drop(lease); - count - }; + fn a_collaboration_only_commit_replays_no_history() { + use crate::storage::history_replay::take_validated_history_changes; + let store = build_synthetic_history_store(6, 8, 6, 3); + + let control = advance_synthetic_base(&store); + let (_, entered, proofs, replays) = replay_work_preparing(&store, &control); assert!( - history_changes > 1, - "the fixture must carry a history worth not copying, got {history_changes} changes" + entered > 0 && proofs > 0 && replays > 0, + "control: a native commit must replay history, or the zeros below prove nothing" ); + store + .manager + .commit_repository_transaction(control) + .unwrap(); - let unchanged_base = overlay_transaction_at_unchanged_base(&store, 0xf2_6510_0001, 0); - let (receipt, resolutions, base_changes) = - mutation_base_resolutions_and_changes_during(|| { - store - .manager - .commit_repository_transaction(unchanged_base) - .unwrap() - }); - receipt.validate().unwrap(); - assert_eq!( - resolutions, 1, - "a mutation that leaves its base where it was resolves one base" + let before_graph = materialize_synthetic(&store); + let roots_before = store.manager.read_authority().roots().clone(); + let delta = every_collaboration_collection(0x31); + let review_id = delta.reviews[0].key; + assert!(!before_graph.reviews.contains_key(&review_id)); + let mut transaction = transaction_shell(&store.manager, 0xc011_0001); + transaction.collaboration_delta = Some(delta); + assert!( + matches!( + HistoryProof::owed_by(&transaction, None), + HistoryProof::CarriedFromPredecessor { + admits_collaboration: true, + .. + } + ), + "a collaboration-only transaction must carry the history proofs" ); + + let (after, entered, proofs, replays) = replay_work_preparing(&store, &transaction); assert_eq!( - base_changes, 0, - "the base this mutation resolved carried {base_changes} changes out of the repository's {history_changes}, which a workspace comparison never reads" + entered, 0, + "a collaboration-only commit entered history in a validation" ); - - let advanced = advance_synthetic_base(&store); - let (advanced_receipt, advanced_resolutions, advanced_base_changes) = - mutation_base_resolutions_and_changes_during(|| { - store.manager.commit_repository_transaction(advanced) - }); - advanced_receipt.unwrap().validate().unwrap(); assert_eq!( - advanced_resolutions, 2, - "a mutation that advances its base resolves two" + proofs, 0, + "a collaboration-only commit proved a payload whose history it carries" ); assert_eq!( - advanced_base_changes, 0, - "the two bases this mutation resolved carried {advanced_base_changes} changes between them out of the repository's {history_changes}" + replays, 0, + "a collaboration-only commit replayed history it cannot change" ); - } + after + .metadata() + .validate_against_snapshot(after.snapshot()) + .expect("the carried successor must pass a from-scratch validation"); + assert_eq!(after.roots().history, roots_before.history); + assert_eq!(after.roots().replication, roots_before.replication); + assert_ne!( + after.roots().collaboration, + roots_before.collaboration, + "the collaboration root must fold the records the transaction admitted" + ); + drop(after); - /// A store written before the receipts were trimmed still opens. - /// - /// This is the migration this change must not require, and an equality - /// check on the envelope schema silently created one: every store on disk - /// declares schema 3, and a binary that accepted only 4 refused all of - /// them with `unsupported repository authority schema 3; expected 4`. The - /// PR body said no migration was needed while the code required one, and - /// nothing in the suite disagreed, because every fixture writes the schema + let inherited = inherited_graphs(); + store + .manager + .commit_repository_transaction(transaction) + .unwrap() + .validate() + .unwrap(); + { + let lease = store.manager.read_authority(); + assert!(lease.snapshot().reviews.contains_key(&review_id)); + assert_ne!(lease.roots().collaboration, roots_before.collaboration); + } + + let after_graph = materialize_synthetic(&store); + assert_eq!( + inherited_graphs(), + inherited, + "a successor that admits collaboration records must not inherit a workspace graph" + ); + assert!( + after_graph.reviews.contains_key(&review_id), + "the workspace graph must hold the records the commit admitted" + ); + assert_eq!(after_graph.entities, before_graph.entities); + assert_eq!(after_graph.resolved_tree, before_graph.resolved_tree); + + let _ = take_validated_history_changes(); + let reopened = + RepositoryAuthorityManager::open(repository_id(), Arc::clone(&store.backend)).unwrap(); + assert!(!reopened.opened_by_history_validation()); + assert!( + take_validated_history_changes() > 0, + "a reopen that trusts no record must replay the history" + ); + assert_eq!( + reopened.read_authority().roots(), + store.manager.read_authority().roots() + ); + assert!(reopened + .read_authority() + .snapshot() + .reviews + .contains_key(&review_id)); + } + + /// An ordinary native commit replays the lineage of the change it adds, + /// once. Its predecessor validated every change it held, and a held + /// change's replay verdict reads only that change and its first-parent + /// lineage, which history only growing leaves exactly as they were. Storage + /// admission, which reads the same payload, runs no proof of its own. + /// + /// A side branch makes the difference countable: its change is held and is + /// outside the new change's lineage, so a whole-forest replay enters it and + /// this one must not. The successor still passes a from-scratch validation. + #[test] + fn a_native_commit_replays_only_the_lineage_it_adds() { + let store = build_synthetic_history_store(4, 8, 4, 3); + let side = native_change_on(synthetic_base_change(&store), "a side branch", Vec::new()); + store + .manager + .commit_repository_transaction(change_on_new_branch( + &store.manager, + 0xc011_1001, + side.clone(), + b"side", + )) + .unwrap(); + + let transaction = advance_synthetic_base(&store); + assert_eq!( + HistoryProof::owed_by(&transaction, None), + HistoryProof::GitAuthorityCarried + ); + let added = transaction.changes[0].id; + let (after, entered, proofs, _) = replay_work_preparing(&store, &transaction); + assert_eq!( + proofs, 1, + "storage admission must reuse the payload proof preparation ran" + ); + + let snapshot = after.snapshot(); + let mut lineage = BTreeSet::new(); + let mut next = Some(added); + while let Some(id) = next { + lineage.insert(id); + next = snapshot + .changes + .read_change(&id) + .unwrap() + .unwrap() + .parents + .first() + .copied(); + } + assert!(!lineage.contains(&side.id)); + assert_eq!( + entered, + lineage.len(), + "a native commit must replay its addition's lineage once and nothing else" + ); + assert!(entered < snapshot.changes.len()); + after + .metadata() + .validate_against_snapshot(snapshot) + .expect("a successor proven over its additions must pass a from-scratch validation"); + drop(after); + store + .manager + .commit_repository_transaction(transaction) + .unwrap(); + let reopened = + RepositoryAuthorityManager::open(repository_id(), Arc::clone(&store.backend)).unwrap(); + assert_eq!( + reopened.read_authority().roots(), + store.manager.read_authority().roots() + ); + } + + /// Replaying only what a commit adds still refuses a forged addition. A + /// change whose transition contradicts the state its lineage published is + /// refused by successor preparation itself, with the error a whole-history + /// replay gives, whether or not the transaction also admits collaboration + /// records, and the commit publishes nothing. + /// + /// Asserted at the preparation seam first, because this test backend + /// decodes and validates every snapshot it saves in full again, which + /// would refuse the forgery on its own and hide a preparation that let it + /// through. A local store validates nothing at the save. + #[test] + fn a_forged_addition_is_still_refused_when_only_its_lineage_replays() { + let store = build_synthetic_history_store(2, 8, 1, 3); + let published = materialize_synthetic(&store) + .entities + .get(&store.modified_entity) + .expect("the modified entity resolves in the workspace graph") + .clone(); + let mut stale = published.clone(); + stale.signature = format!("{} /* never published */", published.signature); + let mut new = published.clone(); + new.signature = format!("{} /* forged */", published.signature); + let forged = native_change_on( + synthetic_base_change(&store), + "a transition from a state its lineage never published", + vec![EntityDelta::Modified { old: stale, new }], + ); + let roots = store.manager.read_authority().roots().clone(); + for (operation, label, collaboration) in [ + (0xc011_2001, "native", None), + ( + 0xc011_2002, + "native with collaboration records", + Some(every_collaboration_collection(0x32)), + ), + ] { + let mut transaction = + change_on_new_branch(&store.manager, operation, forged.clone(), b"forged"); + transaction.collaboration_delta = collaboration; + assert_eq!( + HistoryProof::owed_by(&transaction, None), + HistoryProof::GitAuthorityCarried, + "{label}: the fixture must take the lineage-only proof" + ); + let held = store.manager.read_authority(); + let prepared = prepare_successor( + &held, + &transaction, + transaction.transaction_hash().unwrap(), + store.backend.as_ref(), + NativeAdmissionSource::LocalWorkspace, + None, + ); + drop(held); + let error = match prepared { + Ok(_) => panic!("{label}: preparation admitted a forged addition"), + Err(error) => error, + }; + assert!( + error.to_string().contains("stale old payload for entity"), + "{label}: unexpected refusal: {error}" + ); + store + .manager + .commit_repository_transaction(transaction) + .expect_err("a forged addition must be refused"); + assert_eq!( + store.manager.read_authority().roots(), + &roots, + "{label}: a refused commit must publish nothing" + ); + } + } + + /// A carried collaboration commit leaves a reopen nothing to trust that it + /// did not check before. With the durable validation record removed, the + /// reopen replays the whole history and loads exactly what was published; + /// and the frame the commit wrote is refused once its bytes are tampered. + #[test] + fn a_reopen_after_a_carried_collaboration_commit_still_verifies() { + use crate::storage::history_replay::take_validated_history_changes; + let directory = TempDir::new().unwrap(); + let (_backend, manager) = framed_local_repository(&directory); + let mut transaction = transaction_shell(&manager, 0xc011_3001); + transaction.collaboration_delta = Some(every_collaboration_collection(0x33)); + assert!(matches!( + HistoryProof::owed_by(&transaction, None), + HistoryProof::CarriedFromPredecessor { + admits_collaboration: true, + .. + } + )); + let receipt = manager.commit_repository_transaction(transaction).unwrap(); + let roots = manager.read_authority().roots().clone(); + drop(manager); + assert_eq!(acknowledged_frame_count(&directory), 1); + + let mut record = read_authority_json(directory.path()); + record + .as_object_mut() + .unwrap() + .remove("history_validation") + .expect("the commit bound a validation record to remove"); + write_authority_json(directory.path(), &record); + let _ = take_validated_history_changes(); + let reopened = reopen(&directory); + assert!(!reopened.opened_by_history_validation()); + assert!( + take_validated_history_changes() > 0, + "a reopen without a record must replay the history" + ); + assert_eq!(reopened.read_authority().roots(), &roots); + drop(reopened); + + let path = frame_path(&directory, receipt.generation); + let mut bytes = std::fs::read(&path).unwrap(); + let middle = bytes.len() / 2; + bytes[middle] ^= 0x01; + std::fs::write(&path, &bytes).unwrap(); + assert!( + RepositoryAuthorityManager::open( + repository_id(), + Arc::new(LocalFileBackend::new(directory.path())), + ) + .is_err(), + "a tampered collaboration frame must refuse the reopen" + ); + } + + /// A workspace mutation is three materialization steps over a base graph + /// that costs a whole history replay to resolve, and the mutation's own + /// contract fixes which base each step wants. Resolving a base per step + /// replayed the same history two extra times on every forced tree + /// admission, which is the shape both publications of one `kin commit` + /// take. This pins the resolution count so it cannot drift back. + #[test] + fn one_workspace_mutation_resolves_each_distinct_base_exactly_once() { + let store = build_synthetic_history_store(2, 8, 1, 3); + + let unchanged_base = overlay_transaction_at_unchanged_base(&store, 0xf2_2347_0001, 0); + let (receipt, resolutions) = mutation_base_resolutions_during(|| { + store + .manager + .commit_repository_transaction(unchanged_base) + .unwrap() + }); + receipt.validate().unwrap(); + assert_eq!( + resolutions, 1, + "a mutation that leaves its base where it was names one base graph, \ + so it must resolve one, not one per materialization" + ); + + let advanced = advance_synthetic_base(&store); + let (advanced_receipt, advanced_resolutions) = mutation_base_resolutions_during(|| { + store.manager.commit_repository_transaction(advanced) + }); + advanced_receipt.unwrap().validate().unwrap(); + assert_eq!( + advanced_resolutions, 2, + "a mutation that advances its base names two distinct base graphs and must \ + resolve both, never fewer" + ); + } + + /// A workspace mutation compares four domains and reads no change through + /// the bases it compares over, so the bases it resolves carry no change + /// map. On a full-history conversion that map IS the repository, and each + /// base carrying one was a whole extra copy of it live at the point where + /// the preparation reaches its peak. + /// + /// This counts the changes the resolutions actually built rather than the + /// mode they were asked for, so it goes red three separate ways: a call + /// site asking for the history back, either one of them independently, and + /// a resolver that starts carrying the map whatever it was asked. The + /// peak-ratio guard in `tests/workspace_prepare_memory.rs` cannot do that + /// job: on its fixture one of the two copies fits entirely under a mark + /// another term has already set, so restoring that copy alone moves the + /// ratio by nothing at all. + /// + /// The resolution counts are asserted beside the change counts because zero + /// changes across zero resolutions is what a mutation that stopped resolving + /// anything would also report, and that is a different bug wearing this + /// one's passing costume. Those two lines are defensive rather than proven, + /// and that is worth saying rather than leaving someone to assume otherwise. + /// Every mutation tried against them, forcing one base to serve for two and + /// returning from `apply_workspace` before it resolves anything, is refused + /// by the product at the fixture's own bootstrap commit, so the test fails + /// on that `unwrap` and never reaches its own assertions. The change counts + /// below are the part of this test that has been shown to fail on demand. + #[test] + fn a_workspace_mutation_resolves_no_base_carrying_the_history() { + let store = build_synthetic_history_store(2, 8, 1, 3); + let history_changes = { + let lease = store.manager.read_authority(); + let count = lease.snapshot().changes.len(); + drop(lease); + count + }; + assert!( + history_changes > 1, + "the fixture must carry a history worth not copying, got {history_changes} changes" + ); + + let unchanged_base = overlay_transaction_at_unchanged_base(&store, 0xf2_6510_0001, 0); + let (receipt, resolutions, base_changes) = + mutation_base_resolutions_and_changes_during(|| { + store + .manager + .commit_repository_transaction(unchanged_base) + .unwrap() + }); + receipt.validate().unwrap(); + assert_eq!( + resolutions, 1, + "a mutation that leaves its base where it was resolves one base" + ); + assert_eq!( + base_changes, 0, + "the base this mutation resolved carried {base_changes} changes out of the repository's {history_changes}, which a workspace comparison never reads" + ); + + let advanced = advance_synthetic_base(&store); + let (advanced_receipt, advanced_resolutions, advanced_base_changes) = + mutation_base_resolutions_and_changes_during(|| { + store.manager.commit_repository_transaction(advanced) + }); + advanced_receipt.unwrap().validate().unwrap(); + assert_eq!( + advanced_resolutions, 2, + "a mutation that advances its base resolves two" + ); + assert_eq!( + advanced_base_changes, 0, + "the two bases this mutation resolved carried {advanced_base_changes} changes between them out of the repository's {history_changes}" + ); + } + + /// A store written before the receipts were trimmed still opens. + /// + /// This is the migration this change must not require, and an equality + /// check on the envelope schema silently created one: every store on disk + /// declares schema 3, and a binary that accepted only 4 refused all of + /// them with `unsupported repository authority schema 3; expected 4`. The + /// PR body said no migration was needed while the code required one, and + /// nothing in the suite disagreed, because every fixture writes the schema /// the binary writes. /// /// So the fixture is aged deliberately: a real committed store, put back to @@ -24453,6 +25662,7 @@ mod tests { authored_by: who.clone(), created_at: at.clone(), staleness: kin_model::StalenessState::Fresh, + review: None, }, )], work_links: vec![kin_model::WorkLink::Affects { @@ -24530,6 +25740,8 @@ mod tests { duration_ms: None, evidence_blob: None, exit_code: None, + ran_against: Vec::new(), + proof: None, }, )], mock_hints: vec![kin_model::MockHint { @@ -24557,6 +25769,7 @@ mod tests { kind: kin_model::ActorKind::Human, display_name: format!("actor {seed}"), external_refs: Vec::new(), + agent_session: None, }, )], delegations: vec![kin_model::Delegation { @@ -25552,56 +26765,796 @@ mod tests { 3, "the candidate landed whole, not through the foreign frame" ); - assert_eq!(acknowledged_frame_count(&directory), 0); + assert_eq!(acknowledged_frame_count(&directory), 0); + assert!( + !frame_path(&directory, 2).exists(), + "the foreign frame was never installed" + ); + assert!(manager.persistence().state.lock().pending_frame.is_none()); + assert_same_authority( + &manager.read_authority(), + &reopen(&directory).read_authority(), + "after the foreign frame was dropped", + ); + } + + #[test] + fn freezes_reconstruct_a_journal_head_and_a_freezing_commit_extends_it() { + let directory = TempDir::new().unwrap(); + let (backend, manager) = framed_local_repository(&directory); + manager + .commit_repository_transaction(overlay_publication(&manager, 0xf2_2347_3001, 0x60)) + .unwrap(); + assert_eq!(acknowledged_frame_count(&directory), 1); + + let expected_roots = manager.read_authority().roots().clone(); + let frozen = manager + .freeze_current_authority(&expected_roots) + .expect("a journal head freezes"); + assert_eq!(frozen.roots(), &expected_roots); + assert_same_authority(&manager.read_authority(), frozen.authority(), "frozen"); + drop(frozen); + + let snapshots = snapshot_files(&directory); + let (receipt, freeze) = manager + .commit_repository_transaction_and_freeze(overlay_publication( + &manager, + 0xf2_2347_3002, + 0x61, + )) + .unwrap(); + assert_eq!(receipt.generation, 3); + assert_eq!( + record_version(&directory), + 4, + "a freezing commit appends a frame while the journal has room" + ); + assert_eq!(acknowledged_frame_count(&directory), 2); + assert_eq!( + snapshot_files(&directory), + snapshots, + "a freezing frame commit rewrites no snapshot" + ); + assert!( + backend + .repository_writer_would_block(repository_id().as_str()) + .unwrap(), + "the freeze keeps the lock its frame committed under" + ); + drop(freeze); + assert!(!backend + .repository_writer_would_block(repository_id().as_str()) + .unwrap()); + assert_same_authority( + &manager.read_authority(), + &reopen(&directory).read_authority(), + "after freezing commit", + ); + } + + /// Name and length of every file under the repository's snapshot surface. + fn snapshot_files(directory: &TempDir) -> Vec<(std::ffi::OsString, u64)> { + let surface = directory + .path() + .join(repository_id().as_str()) + .join("snapshots"); + let mut files: Vec<_> = std::fs::read_dir(surface) + .unwrap() + .map(|entry| { + let entry = entry.unwrap(); + (entry.file_name(), entry.metadata().unwrap().len()) + }) + .collect(); + files.sort(); + files + } + + /// A branch create at `main`'s target: the ref-only transition a + /// `kin branch create` commits. + fn branch_create_transaction( + manager: &RepositoryAuthorityManager, + operation: u128, + name: &[u8], + ) -> RepositoryTransaction { + let target = + branch_target(&manager.read_authority(), b"main").expect("the fixture publishes main"); + let mut transaction = transaction_shell(manager, operation); + transaction.reason = "create branch".to_string(); + transaction.ref_mutations.push(RefMutation { + name: RefName::branch(name).unwrap(), + expected: RefExpectation::MustNotExist, + new_target: Some(target), + policy: RefUpdatePolicy::FastForwardOnly, + }); + transaction + } + + /// The matching branch delete, leased on the branch's current target. + fn branch_delete_transaction( + manager: &RepositoryAuthorityManager, + operation: u128, + name: &[u8], + ) -> RepositoryTransaction { + let target = + branch_target(&manager.read_authority(), name).expect("the branch to delete exists"); + let mut transaction = transaction_shell(manager, operation); + transaction.reason = "delete branch".to_string(); + transaction.ref_mutations.push(RefMutation { + name: RefName::branch(name).unwrap(), + expected: RefExpectation::MustEqual { target }, + new_target: None, + policy: RefUpdatePolicy::ForceWithLease, + }); + transaction + } + + fn branch_target(state: &RepositoryAuthorityState, name: &[u8]) -> Option { + state + .resolve_ref_target(&RefName::branch(name).unwrap()) + .unwrap() + } + + /// A branch create and a branch delete move only refs. Each freezing + /// commit appends one frame, rewrites no snapshot, keeps the lock it + /// committed under, and leaves a history proof the next open trusts; and a + /// freeze of the head it wrote re-derives nothing. + #[test] + fn a_ref_only_freezing_commit_appends_one_frame_and_rewrites_no_snapshot() { + let directory = TempDir::new().unwrap(); + let (backend, manager) = framed_local_repository(&directory); + let snapshots = snapshot_files(&directory); + let [(_, base_bytes)] = snapshots.as_slice() else { + panic!("the fixture holds exactly one full snapshot: {snapshots:?}"); + }; + let main = branch_target(&manager.read_authority(), b"main").unwrap(); + + let (receipt, freeze) = manager + .commit_repository_transaction_and_freeze(branch_create_transaction( + &manager, + 0x0e3f_0001, + b"feature", + )) + .unwrap(); + assert!(matches!( + receipt.outcome, + RepositoryCommitOutcome::Committed + )); + assert_eq!(receipt.generation, 2); + assert_eq!(freeze.roots(), &receipt.roots_after); + let written = last_persistence_phases() + .expect("the freezing commit recorded its phases") + .payload_bytes as u64; + let frame_bytes = std::fs::metadata(frame_path(&directory, 2)).unwrap().len(); + assert_eq!(written, frame_bytes, "the frame is all the commit wrote"); + assert!( + frame_bytes < *base_bytes, + "a ref frame ({frame_bytes} bytes) is smaller than the store ({base_bytes} bytes)" + ); + assert_eq!(record_version(&directory), 4); + assert_eq!(acknowledged_frame_count(&directory), 1); + assert_eq!(snapshot_files(&directory), snapshots); + assert!(backend + .repository_writer_would_block(repository_id().as_str()) + .unwrap()); + drop(freeze); + + let reopened = reopen(&directory); + assert!( + reopened.opened_by_history_validation(), + "the frame's record carries a history proof for its head" + ); + assert_eq!( + branch_target(&reopened.read_authority(), b"feature"), + Some(main) + ); + assert_same_authority( + &manager.read_authority(), + &reopened.read_authority(), + "after the branch create", + ); + drop(reopened); + + let before = FreezeRevalidation::now(); + let frozen = manager + .freeze_current_authority(manager.read_authority().roots()) + .unwrap(); + assert_eq!( + FreezeRevalidation::since(before), + FreezeRevalidation::NONE, + "the manager names the head its frame wrote" + ); + drop(frozen); + + let (receipt, freeze) = manager + .commit_repository_transaction_and_freeze(branch_delete_transaction( + &manager, + 0x0e3f_0002, + b"feature", + )) + .unwrap(); + assert_eq!(receipt.generation, 3); + drop(freeze); + assert_eq!(acknowledged_frame_count(&directory), 2); + assert_eq!(snapshot_files(&directory), snapshots); + let reopened = reopen(&directory); + assert_eq!(branch_target(&reopened.read_authority(), b"feature"), None); + assert_same_authority( + &manager.read_authority(), + &reopened.read_authority(), + "after the branch delete", + ); + } + + fn base_resolutions() -> usize { + WORKSPACE_BASE_RESOLUTIONS.with(std::cell::Cell::get) + } + + fn inherited_graphs() -> usize { + WORKSPACE_GRAPHS_INHERITED.with(std::cell::Cell::get) + } + + fn fixture_workspace() -> WorkspaceId { + WorkspaceId::from_uuid(Uuid::from_u128(20)) + } + + /// Every domain a workspace graph selects, and the capability bound on it. + fn assert_same_workspace_graph(left: &GraphSnapshot, right: &GraphSnapshot, context: &str) { + assert_eq!(left.entities, right.entities, "{context}: entities"); + assert_eq!(left.relations, right.relations, "{context}: relations"); + assert_eq!( + left.external_references, right.external_references, + "{context}: external references" + ); + assert_eq!(left.resolved_tree, right.resolved_tree, "{context}: tree"); + assert_eq!( + left.resolution_records, right.resolution_records, + "{context}: resolution records" + ); + assert_eq!(left.changes, right.changes, "{context}: changes"); + assert_eq!( + left.verified_binding_history, right.verified_binding_history, + "{context}: binding-history capability" + ); + } + + /// The graph a state materializes for the fixture workspace when it holds + /// nothing to serve it from: base resolved, overlay applied, bound. + fn freshly_materialized(state: &RepositoryAuthorityState) -> GraphSnapshot { + let workspace = state + .metadata() + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == fixture_workspace()) + .expect("the fixture workspace exists"); + materialize_workspace_graph_snapshot( + state.snapshot(), + state.metadata(), + workspace, + None, + None, + ) + .unwrap() + } + + /// A ref-only transaction cannot move a workspace's selected graph, so + /// its successor serves the graph its predecessor materialized rather than + /// resolving the base and applying the overlay again, bound under the + /// successor's own envelope. What it serves is what a from-scratch + /// materialization of the successor reaches, and what a fresh open of the + /// persisted bytes reaches. + #[test] + fn a_ref_only_successor_inherits_the_workspace_graph_it_cannot_move() { + let directory = TempDir::new().unwrap(); + let (_backend, manager) = binding_history_framed_repository(&directory); + // An overlay first, so the workspace graph is more than its base. + manager + .commit_repository_transaction_with_binding_history( + overlay_publication(&manager, 0x0e40_0001, 0x90), + &StorageTestVerifier, + ) + .unwrap(); + let before = manager + .read_authority() + .workspace_graph_snapshot(&fixture_workspace()) + .unwrap() + .unwrap(); + assert!( + before.verified_binding_history.is_some(), + "the fixture's lineage is checked" + ); + + let resolutions = base_resolutions(); + let inherited = inherited_graphs(); + manager + .commit_repository_transaction_with_binding_history( + branch_create_transaction(&manager, 0x0e40_0002, b"feature"), + &StorageTestVerifier, + ) + .unwrap(); + let lease = manager.read_authority(); + let served = lease + .workspace_graph_snapshot(&fixture_workspace()) + .unwrap() + .unwrap(); + let digest = lease + .workspace_graph_digest(&fixture_workspace()) + .unwrap() + .unwrap(); + assert_eq!( + base_resolutions(), + resolutions, + "a ref-only commit and the read after it resolve no workspace base" + ); + assert!( + inherited_graphs() > inherited, + "the successor served its predecessor's graph" + ); + + let fresh = freshly_materialized(&lease); + assert_same_workspace_graph(&served, &fresh, "inherited against fresh"); + assert_ne!( + served.verified_binding_history, before.verified_binding_history, + "the capability is bound under the successor's envelope, not carried" + ); + assert_eq!( + digest, + crate::storage::binding_history::graph_digest(&fresh).unwrap(), + "the digest held with the inherited graph is the graph's own" + ); + assert_eq!( + *lease.operation_identities().unwrap(), + operation_identities_of(lease.metadata()).unwrap(), + "the identities carried through three commits are the log's own" + ); + + let reopened = reopen(&directory); + let reopened_graph = reopened + .read_authority() + .workspace_graph_snapshot(&fixture_workspace()) + .unwrap() + .unwrap(); + assert_same_workspace_graph(&served, &reopened_graph, "inherited against reopened"); + } + + #[test] + fn an_unqualified_successor_clears_an_inherited_binding_capability() { + let directory = TempDir::new().unwrap(); + let (_backend, manager) = binding_history_framed_repository(&directory); + let before = manager + .read_authority() + .workspace_graph_snapshot(&fixture_workspace()) + .unwrap() + .unwrap(); + assert!(before.verified_binding_history.is_some()); + + let inherited = inherited_graphs(); + manager + .commit_repository_transaction(branch_create_transaction( + &manager, + 0x0e42_0001, + b"unqualified", + )) + .unwrap(); + let lease = manager.read_authority(); + let served = lease + .workspace_graph_snapshot(&fixture_workspace()) + .unwrap() + .unwrap(); + assert!(inherited_graphs() > inherited); + assert!(served.verified_binding_history.is_none()); + assert_same_workspace_graph(&served, &freshly_materialized(&lease), "unqualified"); + assert_eq!(served.entities, before.entities); + assert_eq!(served.relations, before.relations); + assert_eq!(served.resolved_tree, before.resolved_tree); + } + + /// The carry is per workspace and per transaction: a transaction that + /// mutates the workspace inherits nothing for it and materializes its new + /// graph in full. + #[test] + fn a_workspace_mutation_inherits_no_workspace_graph() { + let directory = TempDir::new().unwrap(); + let (_backend, manager) = binding_history_framed_repository(&directory); + manager + .read_authority() + .workspace_graph_snapshot(&fixture_workspace()) + .unwrap() + .unwrap(); + + let resolutions = base_resolutions(); + let inherited = inherited_graphs(); + manager + .commit_repository_transaction_with_binding_history( + overlay_publication(&manager, 0x0e41_0001, 0x91), + &StorageTestVerifier, + ) + .unwrap(); + let lease = manager.read_authority(); + let served = lease + .workspace_graph_snapshot(&fixture_workspace()) + .unwrap() + .unwrap(); + assert_eq!( + inherited_graphs(), + inherited, + "a mutated workspace is never served from its predecessor" + ); + assert!( + base_resolutions() > resolutions, + "a mutated workspace is materialized from its base" + ); + assert!( + served + .entities + .values() + .any(|entity| entity.name == "kin_145"), + "the served graph holds the entity the mutation added" + ); + assert_same_workspace_graph(&served, &freshly_materialized(&lease), "mutated"); + } + + /// The journal bound holds for freezing commits: at the bound the freezing + /// commit promotes a full snapshot, retires the journal, and still returns + /// the lock it committed under. + #[test] + fn a_freezing_commit_at_the_journal_bound_compacts_it() { + let directory = TempDir::new().unwrap(); + let (backend, manager) = framed_local_repository(&directory); + manager.persistence().set_max_journal_frames_for_test(1); + let (_, freeze) = manager + .commit_repository_transaction_and_freeze(branch_create_transaction( + &manager, + 0x0e3e_0001, + b"feature", + )) + .unwrap(); + drop(freeze); + assert_eq!(acknowledged_frame_count(&directory), 1); + + let (receipt, freeze) = manager + .commit_repository_transaction_and_freeze(branch_delete_transaction( + &manager, + 0x0e3e_0002, + b"feature", + )) + .unwrap(); + assert_eq!(receipt.generation, 3); + assert_eq!( + record_version(&directory), + 3, + "the bound forced a promotion" + ); + assert_eq!(acknowledged_frame_count(&directory), 0); + assert!(backend + .repository_writer_would_block(repository_id().as_str()) + .unwrap()); + drop(freeze); + let reopened = reopen(&directory); + assert_eq!(branch_target(&reopened.read_authority(), b"feature"), None); + assert_same_authority( + &manager.read_authority(), + &reopened.read_authority(), + "after the promotion", + ); + } + + /// A freezing frame whose record was installed but whose durability was + /// not confirmed fails the commit, holds no lock, and keeps its exact bytes; + /// the retry reconciles them as committed rather than appending again. + #[test] + fn an_indeterminate_freezing_frame_is_reconciled_by_its_retry() { + let directory = TempDir::new().unwrap(); + let (backend, manager) = framed_local_repository(&directory); + let transaction = branch_create_transaction(&manager, 0x0e3d_0001, b"feature"); + + backend.set_delta_before_authority_commit_hook(|| { + // As in the ordinary frame test: the record write claims and + // promotes its candidate through two parent syncs, and failing the + // third leaves it installed with its durability unconfirmed. + crate::storage::mmap::fail_parent_sync_after(2); + }); + let error = manager + .commit_repository_transaction_and_freeze(transaction.clone()) + .expect_err("an installed frame with a lost durability acknowledgement fails"); + assert!( + matches!(error, KinDbError::SnapshotPersistenceIndeterminate(_)), + "{error}" + ); + assert_eq!(manager.read_authority().generation(), 1); + assert_eq!(acknowledged_frame_count(&directory), 1, "the frame landed"); + assert!(manager + .persistence() + .state + .lock() + .pending_frame + .as_ref() + .is_some_and(|pending| pending.roots.generation == 2)); assert!( - !frame_path(&directory, 2).exists(), - "the foreign frame was never installed" + !backend + .repository_writer_would_block(repository_id().as_str()) + .unwrap(), + "a failed freezing commit holds no lock" ); + + let (receipt, freeze) = manager + .commit_repository_transaction_and_freeze(transaction) + .expect("the retained frame reconciles as committed"); + assert_eq!(receipt.generation, 2); + assert!(matches!( + receipt.outcome, + RepositoryCommitOutcome::IdempotentReplay + )); + assert!(backend + .repository_writer_would_block(repository_id().as_str()) + .unwrap()); + drop(freeze); + assert_eq!(acknowledged_frame_count(&directory), 1, "no second frame"); assert!(manager.persistence().state.lock().pending_frame.is_none()); + let reopened = reopen(&directory); + assert!(branch_target(&reopened.read_authority(), b"feature").is_some()); assert_same_authority( &manager.read_authority(), - &reopen(&directory).read_authority(), - "after the foreign frame was dropped", + &reopened.read_authority(), + "reconciled", ); } + /// A freezing frame over a head another writer moved is refused at the + /// backend's compare-and-swap, before anything is written: the other + /// writer's head stands, and the refused ref is nowhere. #[test] - fn freezes_reconstruct_a_journal_head_and_a_freezing_commit_compacts_it() { + fn a_freezing_frame_over_a_moved_head_commits_nothing() { let directory = TempDir::new().unwrap(); - let (_backend, manager) = framed_local_repository(&directory); - manager - .commit_repository_transaction(overlay_publication(&manager, 0xf2_2347_3001, 0x60)) + let (backend, manager) = framed_local_repository(&directory); + let other = + RepositoryAuthorityManager::open(repository_id(), Arc::clone(&backend)).unwrap(); + other + .persistence() + .set_journal_byte_bound_for_test(Some(u64::MAX)); + other + .commit_repository_transaction(overlay_publication(&other, 0x0e3c_1001, 0x62)) .unwrap(); - assert_eq!(acknowledged_frame_count(&directory), 1); - - let expected_roots = manager.read_authority().roots().clone(); - let frozen = manager - .freeze_current_authority(&expected_roots) - .expect("a journal head freezes"); - assert_eq!(frozen.roots(), &expected_roots); - assert_same_authority(&manager.read_authority(), frozen.authority(), "frozen"); - drop(frozen); + let moved = authority_record_bytes(&directory); - let (receipt, freeze) = manager - .commit_repository_transaction_and_freeze(overlay_publication( + let error = manager + .commit_repository_transaction_and_freeze(branch_create_transaction( &manager, - 0xf2_2347_3002, - 0x61, + 0x0e3c_1002, + b"feature", )) - .unwrap(); - assert_eq!(receipt.generation, 3); + .expect_err("the head moved under this writer"); + assert!( + !matches!(error, KinDbError::SnapshotPersistenceIndeterminate(_)), + "a compare-and-swap refusal is a clean failure: {error}" + ); + assert_eq!(authority_record_bytes(&directory), moved); + assert_eq!(manager.read_authority().generation(), 1); + assert!(manager.persistence().state.lock().pending_frame.is_none()); + assert!(!backend + .repository_writer_would_block(repository_id().as_str()) + .unwrap()); + let reopened = reopen(&directory); assert_eq!( - record_version(&directory), - 3, - "a freezing commit writes a full snapshot" + reopened.read_authority().roots(), + other.read_authority().roots() ); - assert_eq!(acknowledged_frame_count(&directory), 0); - drop(freeze); - assert_same_authority( - &manager.read_authority(), - &reopen(&directory).read_authority(), - "after freezing commit", + assert_eq!(branch_target(&reopened.read_authority(), b"feature"), None); + } + + /// A freezing append must verify the retained base and every earlier + /// acknowledged frame before acknowledging a new ref over them. + #[test] + fn a_freezing_ref_commit_refuses_corrupted_retained_authority() { + for corrupt_frame in [false, true] { + let directory = TempDir::new().unwrap(); + let (_backend, manager) = framed_local_repository(&directory); + let (_, freeze) = manager + .commit_repository_transaction_and_freeze(branch_create_transaction( + &manager, + 0x0e3a_0001, + b"retained", + )) + .unwrap(); + drop(freeze); + let before = manager.read_authority().roots().clone(); + let record = authority_record_bytes(&directory); + let path = if corrupt_frame { + frame_path(&directory, 2) + } else { + let snapshot_file = read_authority_json(directory.path())["snapshot_file"] + .as_str() + .unwrap() + .to_string(); + directory + .path() + .join(repository_id().as_str()) + .join("snapshots") + .join(snapshot_file) + }; + let mut bytes = std::fs::read(&path).unwrap(); + let corrupted = if corrupt_frame { bytes.len() / 2 } else { 0 }; + bytes[corrupted] ^= 0x01; + std::fs::write(&path, bytes).unwrap(); + + let error = manager + .commit_repository_transaction_and_freeze(branch_create_transaction( + &manager, + 0x0e3a_0002, + b"refused", + )) + .expect_err("corrupted retained authority cannot support a freezing commit"); + assert!(error.to_string().contains("digest mismatch"), "{error}"); + assert!(!matches!( + error, + KinDbError::SnapshotPersistenceIndeterminate(_) + )); + assert_eq!(manager.read_authority().roots(), &before); + assert_eq!(authority_record_bytes(&directory), record); + assert_eq!(acknowledged_frame_count(&directory), 1); + assert_eq!(branch_target(&manager.read_authority(), b"refused"), None); + } + } + + /// A process killed during a freezing ref commit leaves the old ref or the + /// new one, never neither, and a commit that returned survives the kill. + /// + /// A child process runs the commit and is SIGKILLed at one of two points: + /// after the frame is durable but before the record rename that commits + /// it, and after the commit returned while the child still holds the + /// freeze. No destructor or unwinding runs in either case, so what the + /// parent reopens is exactly what the kernel holds. + #[cfg(unix)] + #[test] + fn a_killed_freezing_ref_commit_leaves_the_old_ref_or_the_new_one() { + const CHILD_ROOT: &str = "KIN_DB_FREEZING_FRAME_KILL_ROOT"; + const CHILD_POINT: &str = "KIN_DB_FREEZING_FRAME_KILL_POINT"; + const OPERATION: u128 = 0x0e3b_1001; + fn kill_this_process() -> ! { + // SAFETY: signalling this process is always sound; SIGKILL cannot + // be caught, so nothing after it runs. + unsafe { + libc::kill(libc::getpid(), libc::SIGKILL); + } + loop { + std::thread::park(); + } + } + + if let (Some(root), Some(point)) = + (std::env::var_os(CHILD_ROOT), std::env::var_os(CHILD_POINT)) + { + let root = std::path::PathBuf::from(root); + let marker = root.join("reached"); + let backend = Arc::new(LocalFileBackend::new(root.join("store"))); + let manager = + RepositoryAuthorityManager::open(repository_id(), Arc::clone(&backend)).unwrap(); + manager + .persistence() + .set_journal_byte_bound_for_test(Some(u64::MAX)); + let transaction = branch_create_transaction(&manager, OPERATION, b"feature"); + match point.to_str() { + Some("before-record") => { + backend.set_delta_before_authority_commit_hook(move || { + std::fs::write(&marker, b"frame installed").unwrap(); + kill_this_process(); + }); + let _ = manager.commit_repository_transaction_and_freeze(transaction); + unreachable!("the child is killed before its record rename"); + } + Some("after-commit") => { + let (receipt, _freeze) = manager + .commit_repository_transaction_and_freeze(transaction) + .unwrap(); + std::fs::write(&marker, receipt.generation.to_string()).unwrap(); + kill_this_process(); + } + other => panic!("unknown kill point {other:?}"), + } + } + + let name = format!( + "{}::a_killed_freezing_ref_commit_leaves_the_old_ref_or_the_new_one", + module_path!().split_once("::").unwrap().1 ); + for point in ["before-record", "after-commit"] { + let root = TempDir::new().unwrap(); + let store = root.path().join("store"); + std::fs::create_dir(&store).unwrap(); + let (main, before) = { + let backend = Arc::new(LocalFileBackend::new(&store)); + let manager = + RepositoryAuthorityManager::open(repository_id(), Arc::clone(&backend)) + .unwrap(); + manager + .commit_repository_transaction(arbitrary_repository_transaction(&manager)) + .unwrap(); + let lease = manager.read_authority(); + ( + branch_target(&lease, b"main").unwrap(), + lease.roots().clone(), + ) + }; + + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &name, "--nocapture"]) + .env(CHILD_ROOT, root.path()) + .env(CHILD_POINT, point) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("{point}: the child neither finished nor was killed in time"); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + use std::os::unix::process::ExitStatusExt; + assert_eq!( + status.signal(), + Some(libc::SIGKILL), + "{point}: the child must die at the kill point, not finish: {status}" + ); + assert!( + root.path().join("reached").exists(), + "{point}: the child reached its kill point" + ); + + let backend = Arc::new(LocalFileBackend::new(&store)); + let reopened = RepositoryAuthorityManager::open(repository_id(), Arc::clone(&backend)) + .expect("a store killed mid-commit reopens"); + let lease = reopened.read_authority(); + match point { + "before-record" => { + assert_eq!(lease.roots(), &before, "the old head stands"); + assert_eq!(branch_target(&lease, b"feature"), None); + drop(lease); + // The frame the kill left staged past head is not authority, + // and the same write lands over it. + reopened + .persistence() + .set_journal_byte_bound_for_test(Some(u64::MAX)); + let (receipt, freeze) = reopened + .commit_repository_transaction_and_freeze(branch_create_transaction( + &reopened, OPERATION, b"feature", + )) + .expect("the store takes the write after the kill"); + assert!(matches!( + receipt.outcome, + RepositoryCommitOutcome::Committed + )); + drop(freeze); + let again = + RepositoryAuthorityManager::open(repository_id(), Arc::clone(&backend)) + .unwrap(); + assert_eq!( + branch_target(&again.read_authority(), b"feature"), + Some(main.clone()) + ); + } + "after-commit" => { + let reached: u64 = std::fs::read_to_string(root.path().join("reached")) + .unwrap() + .parse() + .unwrap(); + assert_eq!(lease.generation(), reached, "the returned commit survived"); + assert_eq!(lease.generation(), before.generation + 1); + assert_eq!(branch_target(&lease, b"feature"), Some(main.clone())); + assert_eq!( + read_authority_json(&store)["acknowledged_deltas"] + .as_array() + .map(Vec::len), + Some(1), + "the killed child's commit was a frame append" + ); + } + _ => unreachable!(), + } + } } /// What one freeze re-derived on this thread, read as a delta across it. @@ -30141,4 +32094,201 @@ mod committed_artifact_under_an_ignored_directory { } } +/// What one collaboration-only commit and one change-admitting commit cost on +/// a real store with a long history, phase by phase. +/// +/// Run it against a scratch copy, never a live store: it commits twice and +/// reopens. Each arm prints the preparation laps the slow-preparation log line +/// carries, the storage-admission laps, and the counters the unit tests assert +/// on, so a reading names the work done rather than only its milliseconds. +/// +/// ```text +/// KIN_DB_MEASURE_STORE=/kindb KIN_DB_MEASURE_REPO= \ +/// cargo test -p kin-db --lib collaboration_commit_measurement -- --ignored --nocapture +/// ``` +#[cfg(test)] +mod collaboration_commit_measurement { + use super::*; + use crate::storage::history_replay::take_validated_history_changes; + use kin_model::{ + compute_semantic_change_id, ChangeOrigin, REPOSITORY_TRANSACTION_SCHEMA_VERSION, + }; + + fn print_phase_log(label: &str) { + PREPARATION_PHASE_LOG.with(|log| { + for (name, laps) in log.borrow().iter() { + let total: u128 = laps.iter().map(|(_, ms)| *ms).sum(); + let detail = laps + .iter() + .map(|(field, ms)| format!("{field}={ms}")) + .collect::>() + .join(" "); + println!("[{label}] {name} total_ms={total} {detail}"); + } + log.borrow_mut().clear(); + }); + } + + fn shell( + manager: &RepositoryAuthorityManager, + repository_id: &RepositoryId, + reason: &str, + ) -> RepositoryTransaction { + let lease = manager.read_authority(); + RepositoryTransaction { + schema_version: REPOSITORY_TRANSACTION_SCHEMA_VERSION, + operation_id: OperationId::new(), + repository_id: repository_id.clone(), + expected_generation: lease.generation(), + expected_roots: lease.roots().clone(), + actor: AuthorId::new("replay-fix-measurement"), + reason: reason.to_string(), + external_objects: Vec::new(), + git_authority_delta: None, + changes: Vec::new(), + aliases: Vec::new(), + ref_mutations: Vec::new(), + default_ref_mutation: None, + workspace_mutation: None, + local_overlay_delta: None, + merge_transaction_delta: None, + sealed_observation: None, + collaboration_delta: None, + git_snapshot_proofs: Vec::new(), + } + } + + fn commit_and_report( + label: &str, + manager: &RepositoryAuthorityManager, + transaction: RepositoryTransaction, + ) { + println!( + "[{label}] proof={:?}", + HistoryProof::owed_by(&transaction, None) + ); + PREPARATION_PHASE_LOG.with(|log| log.borrow_mut().clear()); + let _ = take_validated_history_changes(); + let replays = history_replays_on_this_thread(); + let proofs = payload_proofs_on_this_thread(); + let started = std::time::Instant::now(); + let receipt = manager + .commit_repository_transaction(transaction) + .unwrap_or_else(|error| panic!("{label} commit: {error}")); + println!( + "[TERM] {label}_commit_ms={} generation={}", + started.elapsed().as_millis(), + receipt.generation + ); + println!( + "[{label}] history_replays={} payload_proofs={} validated_history_changes={}", + history_replays_on_this_thread() - replays, + payload_proofs_on_this_thread() - proofs, + take_validated_history_changes() + ); + print_phase_log(label); + } + + #[test] + #[ignore = "needs KIN_DB_MEASURE_STORE naming a scratch copy of a real kindb directory"] + fn measure_one_collaboration_and_one_change_commit() { + let store = std::env::var("KIN_DB_MEASURE_STORE") + .expect("set KIN_DB_MEASURE_STORE to a scratch copy of a /kindb directory"); + let repo = std::env::var("KIN_DB_MEASURE_REPO") + .expect("set KIN_DB_MEASURE_REPO to the repository id under that directory"); + let repository_id = RepositoryId::new(&repo).expect("repository id"); + let backend = Arc::new(crate::storage::backend::LocalFileBackend::new(&store)); + + let started = std::time::Instant::now(); + let manager = RepositoryAuthorityManager::open(repository_id.clone(), Arc::clone(&backend)) + .expect("open repository authority"); + println!( + "[TERM] authority_open_ms={} by_history_validation={}", + started.elapsed().as_millis(), + manager.opened_by_history_validation() + ); + let base_change = { + let lease = manager.read_authority(); + let metadata = lease.metadata(); + println!( + "[store] generation={} changes={} workspaces={} operations={}", + lease.generation(), + lease.snapshot().changes.len(), + metadata.workspaces.len(), + metadata.operation_log.len(), + ); + let base = metadata + .workspaces + .first() + .and_then(|workspace| workspace.base_target.as_ref()) + .expect("the store's first workspace bases at a change"); + target_change_id(metadata, base).expect("the base target names a change") + }; + + let annotation_id = kin_model::AnnotationId(uuid::Uuid::new_v4()); + let mut collaboration = shell(&manager, &repository_id, "measure a collaboration commit"); + collaboration.collaboration_delta = Some(kin_model::CollaborationDelta { + annotations: vec![kin_model::Keyed::new( + annotation_id, + kin_model::Annotation { + annotation_id, + kind: kin_model::AnnotationKind::Comment, + body: "replay-fix measurement".to_string(), + scopes: Vec::new(), + anchored_fingerprint: None, + authored_by: kin_model::IdentityRef::human("replay-fix-measurement"), + created_at: Timestamp::now(), + staleness: kin_model::StalenessState::Fresh, + review: None, + }, + )], + ..Default::default() + }); + commit_and_report("collaboration", &manager, collaboration); + + let mut change = kin_model::SemanticChange { + id: SemanticChangeId::from_hash(Hash256::from_bytes([0; 32])), + origin: ChangeOrigin::Native, + parents: vec![base_change], + timestamp: Timestamp::now(), + author: AuthorId::new("replay-fix-measurement"), + message: "measure a change-admitting commit".to_string(), + entity_deltas: Vec::new(), + relation_deltas: Vec::new(), + tree_deltas: Vec::new(), + admission_policy_delta: None, + external_reference_deltas: Vec::new(), + projected_files: Vec::new(), + spec_link: None, + evidence: Vec::new(), + risk_summary: None, + resolution_record_deltas: Vec::new(), + }; + change.id = compute_semantic_change_id(&change).expect("change id computes"); + let mut admitting = shell(&manager, &repository_id, "measure a change commit"); + admitting.ref_mutations.push(RefMutation { + name: RefName::branch(b"replay-fix-measurement").expect("branch name"), + expected: RefExpectation::MustNotExist, + new_target: Some(RefTarget::change(change.id)), + policy: RefUpdatePolicy::FastForwardOnly, + }); + admitting.changes.push(change); + commit_and_report("change", &manager, admitting); + + let roots = manager.read_authority().roots().clone(); + drop(manager); + let _ = take_validated_history_changes(); + let started = std::time::Instant::now(); + let reopened = RepositoryAuthorityManager::open(repository_id, backend) + .expect("reopen what the two commits published"); + println!( + "[TERM] reopen_ms={} by_history_validation={} validated_history_changes={}", + started.elapsed().as_millis(), + reopened.opened_by_history_validation(), + take_validated_history_changes() + ); + assert_eq!(reopened.read_authority().roots(), &roots); + } +} + mod git_snapshot_proof; diff --git a/crates/kin-db/src/storage/repository/git_snapshot_proof.rs b/crates/kin-db/src/storage/repository/git_snapshot_proof.rs index 228c59409..4705319dc 100644 --- a/crates/kin-db/src/storage/repository/git_snapshot_proof.rs +++ b/crates/kin-db/src/storage/repository/git_snapshot_proof.rs @@ -39,12 +39,26 @@ fn refuse(reason: impl std::fmt::Display) -> KinDbError { ModelError::InvalidOperation(format!("Git snapshot proof: {reason}")).into() } +#[cfg(test)] +thread_local! { + /// Calls to [`validate_metadata`] on this thread, read as the endpoints of + /// a span by the tests that pin which transactions re-validate a proof. + static METADATA_VALIDATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn metadata_validations() -> usize { + METADATA_VALIDATIONS.with(std::cell::Cell::get) +} + /// Validate the persisted shape even when raw-body verification can be carried /// from a durable validation record for these exact snapshot bytes. pub(super) fn validate_metadata( snapshot: &GraphSnapshot, metadata: &PersistedRepositoryAuthority, ) -> Result<(), KinDbError> { + #[cfg(test)] + METADATA_VALIDATIONS.with(|count| count.set(count.get() + 1)); super::require_sorted_unique( &metadata.git_snapshot_proofs, |proof| proof.snapshot_change_id, diff --git a/crates/kin-db/src/storage/repository/test_git_snapshot_proof_integration.rs b/crates/kin-db/src/storage/repository/test_git_snapshot_proof_integration.rs index 566001b08..51d6a68f8 100644 --- a/crates/kin-db/src/storage/repository/test_git_snapshot_proof_integration.rs +++ b/crates/kin-db/src/storage/repository/test_git_snapshot_proof_integration.rs @@ -783,3 +783,56 @@ fn repeating_a_held_proof_does_not_readmit_an_ignored_path_under_a_new_artifact_ .is_none()); } } + +/// A proof, the objects it names and the root it proves cannot move in a +/// transaction that brings no proof, object, alias or Git delta. A ref-only +/// commit therefore re-validates none of them, a transaction that names a +/// proof validates every proof it leaves behind, and the store reopens with the +/// proof it held. +#[test] +fn a_transaction_that_cannot_touch_a_proof_does_not_revalidate_it() { + let directory = tempfile::tempdir().unwrap(); + let manager = open_manager(&directory); + let fixture = Fixture::ignored(); + fixture.save(&manager); + manager + .commit_repository_transaction(fixture.transaction(&manager, 140)) + .unwrap(); + + let mut branch = transaction_shell(&manager, 141); + branch.ref_mutations.push(RefMutation { + name: RefName::branch(b"feature").unwrap(), + expected: RefExpectation::MustNotExist, + new_target: Some(RefTarget::change(fixture.change.id)), + policy: RefUpdatePolicy::FastForwardOnly, + }); + let validations = super::metadata_validations(); + let receipt = manager.commit_repository_transaction(branch).unwrap(); + assert_eq!(receipt.outcome, RepositoryCommitOutcome::Committed); + assert_eq!( + super::metadata_validations(), + validations, + "a ref-only commit re-proves no snapshot proof" + ); + assert_eq!( + manager.read_authority().metadata().git_snapshot_proofs, + vec![fixture.proof.clone()] + ); + + let mut identical = transaction_shell(&manager, 142); + identical.git_snapshot_proofs.push(fixture.proof.clone()); + manager.commit_repository_transaction(identical).unwrap(); + assert!( + super::metadata_validations() > validations, + "a transaction that names a proof validates the proofs it leaves" + ); + + let roots = manager.read_authority().roots().clone(); + drop(manager); + let reopened = open_manager(&directory); + assert_eq!(reopened.read_authority().roots(), &roots); + assert_eq!( + reopened.read_authority().metadata().git_snapshot_proofs, + vec![fixture.proof] + ); +} diff --git a/crates/kin-db/src/storage/snapshot.rs b/crates/kin-db/src/storage/snapshot.rs index 24639dba2..02b93ea92 100644 --- a/crates/kin-db/src/storage/snapshot.rs +++ b/crates/kin-db/src/storage/snapshot.rs @@ -4271,6 +4271,7 @@ mod tests { kind: ActorKind::Assistant, display_name: "cleanup-test".into(), external_refs: Vec::new(), + agent_session: None, }; graph.create_actor(&actor).unwrap(); mgr.save().unwrap(); @@ -5804,6 +5805,7 @@ mod tests { authored_by: IdentityRef::assistant("codex"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }; graph.create_annotation(&annotation).unwrap(); graph @@ -5837,6 +5839,8 @@ mod tests { duration_ms: Some(12), evidence_blob: None, exit_code: Some(0), + ran_against: Vec::new(), + proof: None, }; graph.create_verification_run(&run).unwrap(); graph @@ -5853,6 +5857,7 @@ mod tests { kind: ActorKind::Assistant, display_name: "Codex".into(), external_refs: Vec::new(), + agent_session: None, }; graph.create_actor(&actor).unwrap(); graph diff --git a/crates/kin-db/tests/retrieval_authority_memory.rs b/crates/kin-db/tests/retrieval_authority_memory.rs index 676269147..8eee23e35 100644 --- a/crates/kin-db/tests/retrieval_authority_memory.rs +++ b/crates/kin-db/tests/retrieval_authority_memory.rs @@ -88,6 +88,7 @@ fn served_digest_does_not_copy_history() { authored_by: kin_model::IdentityRef::human("fixture"), created_at: Timestamp(chrono::Utc::now()), staleness: kin_model::StalenessState::default(), + review: None, }) .unwrap(); let mut parent = None; diff --git a/crates/kin-db/tests/workspace_facts_export_memory.rs b/crates/kin-db/tests/workspace_facts_export_memory.rs index 9f766ef56..b8aa33ebe 100644 --- a/crates/kin-db/tests/workspace_facts_export_memory.rs +++ b/crates/kin-db/tests/workspace_facts_export_memory.rs @@ -218,6 +218,7 @@ fn fixture() -> GraphSnapshot { authored_by: IdentityRef::human("fixture-author"), created_at: fixed_timestamp(), staleness: StalenessState::default(), + review: None, }, ); } diff --git a/crates/kin-git/derivation-inputs.toml b/crates/kin-git/derivation-inputs.toml index e5dd2974d..a4265aa0f 100644 --- a/crates/kin-git/derivation-inputs.toml +++ b/crates/kin-git/derivation-inputs.toml @@ -13,6 +13,8 @@ files = [ "src/authority.rs", "src/lib.rs", "src/lossless.rs", + # Chooses the fetched ref and complete ancestor closure fed into exact semantic admission. + "src/lossless/capture_ref.rs", "src/preflight.rs", "src/semantic_import.rs", ] diff --git a/crates/kin-git/src/lib.rs b/crates/kin-git/src/lib.rs index b2c81c147..2f7bb64ef 100644 --- a/crates/kin-git/src/lib.rs +++ b/crates/kin-git/src/lib.rs @@ -71,10 +71,10 @@ pub use error::{ pub use gc_lock::{wait_for_running_git_gc, RunningGitGc, GIT_GC_WAIT}; pub use global_config::empty_global_git_config; pub use lossless::{ - capture_lossless_git_repository, capture_lossless_git_repository_scoped, - closure_reconstruction_count, rehydrate_lossless_git_repository, - sync_git_repository_for_authority_handoff, CaptureScope, GitObjectFormat, GitRehydrationResult, - LosslessGitRepository, + capture_lossless_git_ref, capture_lossless_git_repository, + capture_lossless_git_repository_scoped, closure_reconstruction_count, + rehydrate_lossless_git_repository, sync_git_repository_for_authority_handoff, CaptureScope, + GitObjectFormat, GitRehydrationResult, LosslessGitRepository, }; pub use preflight::{ git_remote_mapping_facts, kin_store_is_git_ignored, preflight_git_migration, diff --git a/crates/kin-git/src/lossless.rs b/crates/kin-git/src/lossless.rs index e602d1007..3b1e53fd2 100644 --- a/crates/kin-git/src/lossless.rs +++ b/crates/kin-git/src/lossless.rs @@ -31,6 +31,9 @@ use kin_model::{ use crate::error::{GitError, Result}; +mod capture_ref; +pub use capture_ref::capture_lossless_git_ref; + #[cfg(test)] fn worktree(path: &std::path::Path) -> kin_io::WorktreeRoot { kin_io::WorktreeRoot::new(path, &kin_io::Grant::ambient()) diff --git a/crates/kin-git/src/lossless/capture_ref.rs b/crates/kin-git/src/lossless/capture_ref.rs new file mode 100644 index 000000000..ad45dbd45 --- /dev/null +++ b/crates/kin-git/src/lossless/capture_ref.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +use super::*; + +/// Capture one fetched direct commit ref and its complete ancestor closure. +/// +/// HEAD and other refs are outside this ingestion boundary. The snapshot's +/// detached head names the selected commit, without moving the source's HEAD, +/// index, refs, or working copy. The selected ref must retain its supplied OID +/// throughout capture, and shallow or incomplete ancestors fail closed. +pub fn capture_lossless_git_ref( + repo_path: &WorktreeRoot, + repository_id: RepositoryId, + name: &RefName, + expected_oid: GitObjectId, + blob_store: &BlobStore, +) -> Result { + let repo = open_repo(repo_path.path())?; + reject_shallow_repository(&repo)?; + let object_format = object_format(&repo)?; + let selected = selected_ref(&repo, name, expected_oid)?; + let RawTarget::Direct(oid) = &selected.target else { + unreachable!("selected_ref requires a direct target") + }; + let pending = VecDeque::from([PendingObject { + oid: *oid, + expected_kind: Some(ExternalObjectKind::Commit), + context: format!("selected fetched ref {name}"), + }]); + let (mut objects, _, history_boundary) = capture_objects(&repo, blob_store, pending, true)?; + objects.sort_by_key(|record| record.object); + reject_shallow_repository(&repo)?; + if selected_ref(&repo, name, expected_oid)? != selected { + return Err(GitError::InvalidSnapshot(format!( + "selected fetched ref {name} changed during capture; fetch again" + ))); + } + let target = RefTarget::external_object(ExternalObjectId::new( + ExternalObjectKind::Commit, + expected_oid, + )); + let snapshot = LosslessGitRepository { + repository_id: repository_id.clone(), + object_format, + objects, + refs: RepositoryRefState { + refs: vec![RepositoryRef { + repository_id, + name: name.clone(), + target: target.clone(), + }], + default_ref: None, + }, + head: WorkspaceHead::Detached { target }, + history_boundary, + scope: CaptureScope::Reachable, + }; + validate_snapshot(&snapshot, blob_store)?; + Ok(snapshot) +} + +fn selected_ref( + repo: &gix::Repository, + name: &RefName, + expected_oid: GitObjectId, +) -> Result { + let selected = collect_refs(repo)? + .into_iter() + .find(|raw| raw.name == name.as_bytes()) + .ok_or_else(|| { + GitError::InvalidSnapshot(format!("selected fetched ref {name} is absent")) + })?; + match &selected.target { + RawTarget::Direct(oid) if git_object_id(*oid)? == expected_oid => Ok(selected), + _ => Err(GitError::InvalidSnapshot(format!( + "selected fetched ref {name} no longer directly names {expected_oid}; fetch again" + ))), + } +} diff --git a/crates/kin-git/src/preflight.rs b/crates/kin-git/src/preflight.rs index 9ae135add..295e0eec5 100644 --- a/crates/kin-git/src/preflight.rs +++ b/crates/kin-git/src/preflight.rs @@ -2498,13 +2498,9 @@ pub(crate) fn scan_remote_mapping(repo: &gix::Repository) -> Result collect_transfer_core_keys(section, &mut refusals), // Split out of the arm below because it is the one section here a // user reaches without ever configuring transport: `git submodule - // add` writes it for them. - "submodule" => refusals.push(unsafe_submodule_reason( - section - .header() - .subsection_name() - .and_then(|name| name.to_str().ok()), - )), + // add` writes it for them, and GUI clients write `[submodule]` + // defaults on clone. + "submodule" => collect_submodule_keys(section, &mut refusals), "lfs" => collect_lfs_keys(section, &mut refusals), "credential" | "http" | "https" | "url" | "protocol" | "transport" | "transfer" | "fetch" | "receive" | "uploadpack" | "ssh" => { @@ -2629,6 +2625,36 @@ fn collect_unknown_config_keys( refusals.len() != before } +/// Classify one `[submodule]` section rather than refusing an un-subsectioned +/// client preference wholesale. +/// +/// A subsectioned `[submodule ""]` entry binds a specific submodule, so it +/// always refuses with the submodule's path. An un-subsectioned `[submodule]` +/// section is where Git clients and `git clone --recurse-submodules` record +/// repository-wide defaults (`submodule.active = .`, `submodule.recurse`, and +/// `submodule.fetchJobs`) even when the repository has no `.gitmodules` and zero +/// gitlinks. Those three keys do not define a submodule or alter remote ref +/// transport on a repository without gitlinks, while any actual gitlink or +/// `.gitmodules` entry is still refused by the tree, index, and `.gitmodules` +/// checks. Any other key in an un-subsectioned `[submodule]` section still +/// fails closed. +fn collect_submodule_keys(section: &gix::config::file::Section<'_>, refusals: &mut Vec) { + if let Some(subsection) = section.header().subsection_name() { + refusals.push(unsafe_submodule_reason(subsection.to_str().ok())); + return; + } + for name in section.value_names() { + let name = name.to_string(); + let admissible = name.eq_ignore_ascii_case("active") + || name.eq_ignore_ascii_case("recurse") + || name.eq_ignore_ascii_case("fetchjobs"); + if !admissible { + refusals.push(unsafe_submodule_reason(None)); + return; + } + } +} + /// Classify one `[lfs]` section key by key rather than refusing it wholesale. /// /// Git LFS moves bytes only through its `filter.lfs` clean and smudge commands, @@ -5743,6 +5769,41 @@ mod tests { ); } + #[test] + fn unsubsectioned_submodule_client_preferences_are_admitted_while_other_keys_refuse() { + let (temp, repo) = config_only_repository(); + FixtureGitBatch::new() + .git(["config", "submodule.active", "."]) + .git(["config", "submodule.recurse", "true"]) + .git(["config", "submodule.fetchJobs", "4"]) + .run_in(&repo); + remote_mapping_facts(&open_repo(&repo).expect("open client preference fixture")) + .expect("un-subsectioned [submodule] client preferences must be admitted"); + let store = BlobStore::new(temp.path().join("cas")).expect("blob store"); + let (snapshot, plan) = snapshot_plan(&repo, &store); + preflight_git_migration(&worktree(&repo), &snapshot, &plan, &store) + .expect("preflight must admit repository with [submodule] active/recurse/fetchJobs"); + drop(temp); + + let (_temp, repo) = config_only_repository(); + git( + &repo, + &[ + "config", + "submodule.url", + "https://example.invalid/unexpected.git", + ], + ); + let error = remote_mapping_facts(&open_repo(&repo).expect("open unknown key fixture")) + .expect_err("unknown un-subsectioned [submodule] key must reject"); + let message = error.to_string(); + assert!(message.contains("safe exact subset"), "{message}"); + assert!( + message.contains("unsupported submodule section"), + "{message}" + ); + } + /// Every transfer-affecting section shares one message, so naming the cause /// means naming the section that actually matched rather than guessing. #[test] diff --git a/crates/kin-io/src/confined.rs b/crates/kin-io/src/confined.rs index be29299e0..113626f6d 100644 --- a/crates/kin-io/src/confined.rs +++ b/crates/kin-io/src/confined.rs @@ -34,6 +34,13 @@ fn too_long(relative: &Path, root: &Path, max_bytes: u64) -> io::Error { } impl Confined { + pub(crate) fn from_retained_directory(root: &Path, dir: Dir) -> Self { + Self { + dir, + root: root.to_path_buf(), + } + } + pub(crate) fn open(root: &Path, _grant: &Grant) -> io::Result { Self::open_held(root) } @@ -71,6 +78,27 @@ impl Confined { }) } + pub(crate) fn open_read_file(&self, relative: &Path) -> io::Result { + if !self.dir.symlink_metadata(relative)?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "entry is not a regular file", + )); + } + let file = self.dir.open(relative)?; + if !file.metadata()?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "entry is not a regular file", + )); + } + Ok(file) + } + + pub(crate) fn symlink_metadata(&self, relative: &Path) -> io::Result { + self.dir.symlink_metadata(relative) + } + /// Read `relative` whole, refusing anything longer than `max_bytes`. /// `Ok(None)` when nothing exists there. pub(crate) fn read_bounded( @@ -208,6 +236,22 @@ impl Confined { } } + /// When the entry at `relative` last changed, without following a link. + /// An empty path asks about the directory itself. `Ok(None)` when nothing + /// exists there. + pub(crate) fn modified(&self, relative: &Path) -> io::Result> { + let metadata = if relative.as_os_str().is_empty() { + self.dir.dir_metadata() + } else { + self.dir.symlink_metadata(relative) + }; + match metadata { + Ok(metadata) => Ok(Some(metadata.modified()?.into_std())), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } + /// The names directly inside `relative`, sorted. pub(crate) fn names(&self, relative: &Path) -> io::Result> { let entries = if relative.as_os_str().is_empty() { diff --git a/crates/kin-io/src/store.rs b/crates/kin-io/src/store.rs index 472e60cc9..d06368c3c 100644 --- a/crates/kin-io/src/store.rs +++ b/crates/kin-io/src/store.rs @@ -21,6 +21,14 @@ pub struct StoreDir { } impl StoreDir { + /// Adopt a directory capability the caller already retains, without + /// reopening the mutable path naming it. + pub fn from_retained_directory(root: &Path, directory: cap_std::fs::Dir) -> Self { + Self { + inner: Confined::from_retained_directory(root, directory), + } + } + /// Open the store directory at `root`. pub fn open(root: &Path, grant: &Grant) -> io::Result { Ok(Self { @@ -129,6 +137,16 @@ impl HostDir { self.inner.read_bounded(name.as_ref(), max_bytes) } + /// Stream a regular file within this host directory without materializing it. + pub fn open_read_file(&self, name: impl AsRef) -> io::Result { + self.inner.open_read_file(name.as_ref()) + } + + /// Inspect an entry without following its final symbolic link. + pub fn symlink_metadata(&self, name: impl AsRef) -> io::Result { + self.inner.symlink_metadata(name.as_ref()) + } + /// Replace `name` atomically and durably. pub fn write(&self, name: impl AsRef, bytes: &[u8]) -> io::Result<()> { self.inner.replace(name.as_ref(), bytes) diff --git a/crates/kin-io/src/worktree.rs b/crates/kin-io/src/worktree.rs index 2746cb97b..792509588 100644 --- a/crates/kin-io/src/worktree.rs +++ b/crates/kin-io/src/worktree.rs @@ -69,6 +69,16 @@ impl WorktreeSource { self.inner.names(relative.as_ref()) } + /// When the entry at `relative` last changed, without following a link + /// and without reading it. An empty path asks about the root. `Ok(None)` + /// when nothing exists there. + pub fn modified( + &self, + relative: impl AsRef, + ) -> io::Result> { + self.inner.modified(relative.as_ref()) + } + /// The target the link at `relative` names, exactly as stored and without /// following it. `Ok(None)` when nothing exists there. pub fn link_target(&self, relative: impl AsRef) -> io::Result> { @@ -144,6 +154,16 @@ mod tests { assert_eq!(source.names("").unwrap(), vec![OsString::from("pkg")]); assert_eq!(source.kind("pkg").unwrap(), Some(EntryKind::Directory)); assert_eq!(source.kind("pkg/mod.py").unwrap(), Some(EntryKind::Regular)); + // A change time is read for the root and for an entry, and an entry + // that is not there has none. + let written = std::fs::metadata(tree.join("pkg/mod.py")) + .unwrap() + .modified() + .unwrap(); + assert_eq!(source.modified("pkg/mod.py").unwrap(), Some(written)); + assert!(source.modified("").unwrap().is_some()); + assert_eq!(source.modified("pkg/absent.py").unwrap(), None); + assert!(source.modified("../outside").is_err()); assert_eq!(source.kind("pkg/absent.py").unwrap(), None); assert_eq!( source.read("pkg/mod.py", 64).unwrap().as_deref(), diff --git a/crates/kin-mcp/src/agent_belt.rs b/crates/kin-mcp/src/agent_belt.rs index f5e0a262f..183644bb9 100644 --- a/crates/kin-mcp/src/agent_belt.rs +++ b/crates/kin-mcp/src/agent_belt.rs @@ -413,7 +413,7 @@ fn short_descriptions() -> BTreeMap<&'static str, &'static str> { ), ( crate::verbs::STATUS, - "Graph health; not needed to trust a reply. last_settled_selected_graph is older.", + "Health/review flags; not needed to trust a reply. last_settled_selected_graph is older.", ), // The one fact an agent cannot discover for itself: that the list it was // handed is partial on purpose, and how to reach the rest. Every other @@ -1226,6 +1226,7 @@ mod tests { (crate::impact_pages::TOOL, "next_cursor", "cursor"), // The status and enrichment pages: `crate::status_pages`. ("kin_graph_status", "next_cursor", "cursor"), + ("kin_graph_status", "_kin.flags_next_cursor", "flags_cursor"), // Older changes: `crate::handlers::provenance`. ("kin_provenance_query", "next_offset", "offset"), // Draft pages: `crate::entity_drafts`, whose `next_cursor` is passed @@ -1407,6 +1408,12 @@ mod tests { "kin_graph_status", "cursor", ), + ( + crate::verbs::STATUS, + serde_json::json!({"flags_cursor": "c"}), + "kin_graph_status", + "flags_cursor", + ), ( crate::verbs::HISTORY, serde_json::json!({"entity_id": "e", "offset": 20}), diff --git a/crates/kin-mcp/src/answer_account.rs b/crates/kin-mcp/src/answer_account.rs index 997433b60..2cf5e5545 100644 --- a/crates/kin-mcp/src/answer_account.rs +++ b/crates/kin-mcp/src/answer_account.rs @@ -2129,10 +2129,40 @@ pub(crate) struct NameSearchAccount { pub(crate) folded_attributes: std::collections::BTreeMap, pub(crate) folded_total: u64, pub(crate) not_held_total: u64, + /// How many declarations `not_held` lists. The reply may later say rows + /// that read alike once, with a count, but they are still these many. + pub(crate) not_held_listed: u64, pub(crate) closure: NameClosure, pub(crate) artifacts: u64, } +/// Declarations that read alike once projected, the same name, kind, module, +/// enclosing entity and reason on different lines, are said once with how +/// many there are. Sixteen tests that each declare `validate` inside a +/// `constraint` object are one row with a count of sixteen, not sixteen +/// copies of one sentence. The order of first appearance is kept. +pub(crate) fn said_once(rows: Vec) -> Vec { + let mut said: Vec = Vec::with_capacity(rows.len()); + let mut at: std::collections::HashMap = std::collections::HashMap::new(); + for row in rows { + let key = row.to_string(); + match at.get(&key) { + Some(&index) => { + let count = said[index] + .get("count") + .and_then(Value::as_u64) + .unwrap_or(1); + said[index]["count"] = serde_json::json!(count + 1); + } + None => { + at.insert(key, said.len()); + said.push(row); + } + } + } + said +} + impl NameSearchAccount { pub(crate) fn read( machine: &Map, @@ -2178,11 +2208,12 @@ impl NameSearchAccount { .collect() }) .unwrap_or_default(); + let not_held_listed = not_held.len() as u64; let folded_total: u64 = folded_attributes.values().sum(); let not_held_total = census .get("matching_unadmitted_total") .and_then(Value::as_u64) - .unwrap_or(not_held.len() as u64 + folded_total) + .unwrap_or(not_held_listed + folded_total) .saturating_sub(folded_total); let closure = NameClosure::read(machine, verdict, &census, rows, total); @@ -2199,6 +2230,7 @@ impl NameSearchAccount { folded_attributes, folded_total, not_held_total, + not_held_listed, closure, artifacts, } diff --git a/crates/kin-mcp/src/call_sites.rs b/crates/kin-mcp/src/call_sites.rs index 9402e990c..c4a1d1284 100644 --- a/crates/kin-mcp/src/call_sites.rs +++ b/crates/kin-mcp/src/call_sites.rs @@ -186,8 +186,9 @@ fn enrichment_switched_off() -> Option { /// by the repository's own setting, then its probe of the language's server. /// A server the probe found missing, or that could not start, proves nothing /// until the host changes, and a language no build of Kin enriches never has -/// one. Nothing published, or a language the probe did not report on, is -/// unknown, and reads as owed. +/// one, whatever was published. For a language Kin enriches, nothing +/// published, or a probe that did not report on it, is unknown, and reads as +/// owed. pub fn no_resolver_for( switch: Option, readiness: Option<&kin_core::reference_coverage::LanguageServerReadinessMap>, @@ -201,6 +202,13 @@ pub fn no_resolver_for( } Some(EnrichmentSwitch::On) | None => {} } + // A fact of this build, not of the host, so it holds whether or not a + // probe was published. A daemon serving only such languages starts no + // server and publishes nothing, and reading that silence as unknown left + // every caller in a small Ruby project owed enrichment that never comes. + if !ENRICHABLE_LANGUAGES.contains(&language) { + return Some(NoResolver::NoLanguageServer); + } match readiness?.get(&language) { Some(LanguageServerReadiness::Absent) => Some(NoResolver::NoLanguageServer), Some(LanguageServerReadiness::Unusable { reason }) => Some(NoResolver::ServerCannotStart { @@ -208,7 +216,6 @@ pub fn no_resolver_for( }), Some(LanguageServerReadiness::Usable) => None, Some(LanguageServerReadiness::Disabled) => Some(NoResolver::EnrichmentOff), - None if !ENRICHABLE_LANGUAGES.contains(&language) => Some(NoResolver::NoLanguageServer), None => None, } } @@ -3355,11 +3362,20 @@ mod tests { ); // Enrichable, and the probe said nothing about it: unknown. assert_eq!(read(on, Some(&probed), LanguageId::TypeScript), None); - // A language no build of Kin enriches. + // A language no build of Kin enriches, whether or not anything was + // published. assert_eq!( read(on, Some(&probed), LanguageId::Java), Some(NoResolver::NoLanguageServer) ); + assert_eq!( + read(None, None, LanguageId::Ruby), + Some(NoResolver::NoLanguageServer) + ); + assert_eq!( + read(on, None, LanguageId::Java), + Some(NoResolver::NoLanguageServer) + ); } const BODY: &str = "def run(data):\n helper(data)\n json.dumps(data)\n handler(data)\n mystery(data)\n"; diff --git a/crates/kin-mcp/src/connection_session.rs b/crates/kin-mcp/src/connection_session.rs index 5a1b618f0..009a127a6 100644 --- a/crates/kin-mcp/src/connection_session.rs +++ b/crates/kin-mcp/src/connection_session.rs @@ -22,7 +22,8 @@ //! which the daemon takes as proof the session is alive for as long as the //! process runs, and every call made in it refreshes its idle window as any //! session's call does. It is ended when the client closes the connection, and -//! a server that dies first leaves it to the daemon's idle rule. A session the +//! a server that dies first leaves it to the daemon's next sweep, which finds +//! the process gone. A session the //! daemon reaped anyway is opened again once, for a write that started //! nothing, by [`ConnectionSession::forget`]. //! @@ -34,10 +35,13 @@ //! client initializes and a daemon is already serving, in the background, so //! the handshake waits on nothing and no daemon is started for it. Every call //! the connection forwards then names it, which is how the daemon can show an -//! agent that only reads. Opened that way it names no process, so a connection -//! that never does anything is reaped by the daemon's ordinary idle rule; its -//! first write attaches this process, and from then on it lives as long as the -//! process does, as a session opened by a write always has. +//! agent that only reads. Opened that way it names no process to live by, so a +//! connection that never does anything is reaped by the daemon's ordinary idle +//! rule; its first write attaches this process, and from then on it lives as +//! long as the process does, as a session opened by a write always has. It +//! does name this process to watch: that keeps nothing alive, and it lets the +//! daemon end the session at its next sweep when this server is killed +//! outright and never gets to close it. use std::collections::HashMap; use std::future::Future; @@ -187,11 +191,17 @@ impl ConnectionSession { } /// The arguments that open this connection's session for reads: the same - /// as [`ConnectionSession::start_arguments`] without this process, so the - /// daemon's idle rule reaps it if the connection never does anything. + /// as [`ConnectionSession::start_arguments`] with this process named only + /// as one to watch. A `pid` would keep the session for as long as the + /// process runs; a `watch_pid` keeps nothing, so the daemon's idle rule + /// still reaps a connection that never does anything, and it ends the + /// session at its next sweep once this process is gone, so a server killed + /// outright does not leave its agent showing as connected. pub fn read_start_arguments(&self, cwd: &str) -> HashMap { let mut start = self.start_arguments(cwd); - start.remove("pid"); + if let Some(pid) = start.remove("pid") { + start.insert("watch_pid".to_string(), pid); + } start } @@ -405,6 +415,11 @@ mod tests { !start.contains_key("pid"), "a session opened for reads is reaped by the idle rule" ); + assert_eq!( + start["watch_pid"], + std::process::id(), + "and is reaped once this process is gone" + ); assert_eq!(start["vendor"], "claude-code"); let opened = session .id_for_reads(|| async { Ok("read".to_string()) }) diff --git a/crates/kin-mcp/src/daemon_delegate.rs b/crates/kin-mcp/src/daemon_delegate.rs index 5a367d3d8..45dc3ffdd 100644 --- a/crates/kin-mcp/src/daemon_delegate.rs +++ b/crates/kin-mcp/src/daemon_delegate.rs @@ -20,6 +20,268 @@ use crate::types::{ContentBlock, ToolCallResult}; /// Cached daemon HTTP client. We only cache positive connectivity so that a /// daemon that comes online after MCP startup can still take authority. static DAEMON_CLIENT: OnceLock = OnceLock::new(); +static REQUEST_RUNTIME: OnceLock> = OnceLock::new(); + +struct DaemonRequestRuntime { + identity: kin_core::runtime_identity::RuntimeIdentity, + mode: kin_core::runtime_identity::RuntimeMode, + namespace: Result, +} + +impl DaemonRequestRuntime { + fn capture() -> Result { + Ok(Self { + identity: kin_core::runtime_identity::RuntimeIdentity::current(), + mode: kin_core::runtime_identity::RuntimeMode::current()?, + namespace: kin_core::runtime_identity::RuntimeNamespace::for_service(), + }) + } +} + +pub(crate) fn capture_request_runtime() { + let _ = REQUEST_RUNTIME.get_or_init(DaemonRequestRuntime::capture); +} + +/// Every local HTTP forward admits the peer before sending semantic data. +pub(crate) async fn send_admitted_daemon_request( + operation: &str, + base: &str, + request: reqwest::RequestBuilder, + patience: Duration, +) -> Result { + let runtime = REQUEST_RUNTIME + .get_or_init(DaemonRequestRuntime::capture) + .as_ref() + .map_err(|error| { + DaemonCallError::DaemonError(format!("runtime admission refused: {error}")) + })?; + send_admitted_daemon_request_with( + operation, + base, + request, + patience, + runtime, + |remaining| async move { + let root = discover_kin_dir().ok_or_else(|| { + DaemonCallError::DaemonError( + "runtime admission refused: no intended repository is bound".into(), + ) + })?; + let repository = kin_core::runtime_identity::RuntimeRepositoryBinding::for_store(&root) + .map_err(|error| { + DaemonCallError::DaemonError(format!( + "runtime admission refused: cannot bind the intended repository ({error})" + )) + })?; + Ok(( + kin_daemon_spawn::running_daemon_route_reading_at(&root, base, remaining).await, + repository, + )) + }, + ) + .await +} + +async fn read_daemon_runtime_claim( + client: &reqwest::Client, + base: &str, + authorization: Option<&reqwest::header::HeaderValue>, + deadline: tokio::time::Instant, +) -> Result { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(DaemonCallError::Timeout( + "runtime identity probe exceeded this request's deadline".into(), + )); + } + let metadata_request = client + .get(format!("{}/runtime/identity", base.trim_end_matches('/'))) + .timeout(remaining); + let metadata_request = match authorization { + Some(value) => metadata_request.header(reqwest::header::AUTHORIZATION, value.clone()), + None => metadata_request, + }; + let metadata = metadata_request + .send() + .await + .map_err(|error| classify_send_error("runtime identity probe", error))?; + if metadata.status() == reqwest::StatusCode::NOT_FOUND { + // Every release before runtime identity answers this way. + return Err(DaemonCallError::Superseded( + "runtime admission refused: the daemon predates runtime identity".into(), + )); + } + if !metadata.status().is_success() { + return Err(DaemonCallError::DaemonError(format!( + "runtime admission refused: endpoint returned HTTP {} for its identity claim", + metadata.status() + ))); + } + metadata + .json::() + .await + .map_err(|error| { + DaemonCallError::DaemonError(format!( + "runtime admission refused: endpoint supplied no valid identity claim ({error})" + )) + }) +} + +async fn send_admitted_daemon_request_with( + operation: &str, + base: &str, + request: reqwest::RequestBuilder, + patience: Duration, + runtime: &DaemonRequestRuntime, + repository_probe: F, +) -> Result +where + F: FnOnce(Duration) -> R, + R: std::future::Future< + Output = Result< + ( + kin_daemon_spawn::RunningDaemonRouteReading, + kin_core::runtime_identity::RuntimeRepositoryBinding, + ), + DaemonCallError, + >, + >, +{ + let deadline = tokio::time::Instant::now() + patience; + let mut request = request.build().map_err(|error| { + DaemonCallError::DaemonError(format!("cannot build daemon request: {error}")) + })?; + let selected = reqwest::Url::parse(base).map_err(|error| { + DaemonCallError::DaemonError(format!( + "runtime admission refused: invalid endpoint ({error})" + )) + })?; + if request.url().origin() != selected.origin() { + return Err(DaemonCallError::DaemonError( + "runtime admission refused: request addresses a different endpoint".into(), + )); + } + let client = daemon_client().await.ok_or_else(|| { + DaemonCallError::DaemonError("cannot construct the admitted daemon HTTP client".into()) + })?; + let mut peer = read_daemon_runtime_claim( + &client, + base, + request.headers().get(reqwest::header::AUTHORIZATION), + deadline, + ) + .await?; + let mut fingerprint = peer + .admit_daemon_for( + &runtime.identity, + runtime.mode, + runtime.namespace.as_ref().ok(), + ) + .map_err(|error| { + DaemonCallError::Superseded(format!("runtime admission refused: {error}")) + })?; + let mut repository_fingerprint = None; + if fingerprint.is_some() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let (route, repository) = tokio::time::timeout(remaining, repository_probe(remaining)) + .await + .map_err(|_| { + DaemonCallError::Timeout( + "repository binding probe exceeded this request's deadline".into(), + ) + })??; + match route { + kin_daemon_spawn::RunningDaemonRouteReading::Serving(_) => {} + kin_daemon_spawn::RunningDaemonRouteReading::NotReady { + detail, + warming: true, + } => { + return Err(DaemonCallError::Warming(detail)); + } + other => { + return Err(DaemonCallError::DaemonError(format!( + "runtime admission refused: endpoint does not serve the intended repository ({other:?})" + ))); + } + } + if peer.repository.is_none() { + // Warming metadata has no bound store. A readiness transition + // needs a fresh full identity claim before it can serve this call. + peer = read_daemon_runtime_claim( + &client, + base, + request.headers().get(reqwest::header::AUTHORIZATION), + deadline, + ) + .await?; + fingerprint = peer + .admit_daemon_for( + &runtime.identity, + runtime.mode, + runtime.namespace.as_ref().ok(), + ) + .map_err(|error| { + DaemonCallError::DaemonError(format!("runtime admission refused: {error}")) + })?; + if fingerprint.is_none() { + return Err(DaemonCallError::DaemonError( + "runtime admission refused: local service authority changed during repository binding" + .into(), + )); + } + } + repository_fingerprint = Some( + peer.repository + .as_ref() + .ok_or_else(|| { + DaemonCallError::DaemonError( + "runtime admission refused: ready service supplied no repository identity" + .into(), + ) + })? + .validate_for(&repository) + .map_err(|error| { + DaemonCallError::DaemonError(format!( + "runtime admission refused: repository identity mismatch ({error})" + )) + })?, + ); + } + if let Some(fingerprint) = fingerprint { + let value = reqwest::header::HeaderValue::from_str(&fingerprint).map_err(|error| { + DaemonCallError::DaemonError(format!("cannot encode runtime fingerprint: {error}")) + })?; + request.headers_mut().insert( + reqwest::header::HeaderName::from_static( + kin_core::runtime_identity::RUNTIME_IDENTITY_HEADER, + ), + value, + ); + } + if let Some(fingerprint) = repository_fingerprint { + let value = reqwest::header::HeaderValue::from_str(&fingerprint).map_err(|error| { + DaemonCallError::DaemonError(format!("cannot encode repository fingerprint: {error}")) + })?; + request.headers_mut().insert( + reqwest::header::HeaderName::from_static( + kin_core::runtime_identity::RUNTIME_REPOSITORY_HEADER, + ), + value, + ); + } + // Execute through the same no-redirect client that admitted the endpoint. + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(DaemonCallError::Timeout( + "runtime admission exceeded this request's deadline".into(), + )); + } + *request.timeout_mut() = Some(remaining); + client + .execute(request) + .await + .map_err(|error| classify_send_error(operation, error)) +} /// Runtime override for the daemon base URL. /// @@ -965,7 +1227,10 @@ async fn fetch_embedding_coverage_from_resources_at( ) .timeout(Duration::from_secs(3)) .json(&serde_json::json!({ "json": false })); - let response = request.send().await.ok()?; + let response = + send_admitted_daemon_request("embedding coverage", base, request, Duration::from_secs(3)) + .await + .ok()?; if !response.status().is_success() { return None; } @@ -1586,6 +1851,10 @@ pub(crate) enum DaemonCallError { /// HTTP-level or protocol failure — the daemon responded but signalled an /// error. Revival is not attempted; the error is surfaced immediately. DaemonError(String), + /// The daemon runs another release and refused this client's. One Kin + /// serves a repository: an older release is replaced once and the call + /// retried on this release's daemon, and any other is refused by name. + Superseded(String), } /// Classify a `reqwest` send failure. @@ -1635,6 +1904,19 @@ pub(crate) trait DaemonReviver: Sync { async fn is_provably_alive(&self, base: &str) -> bool { daemon_is_provably_alive(base).await } + + /// Replace the daemon at `base`, which refused this client as another + /// release, when it is an older one this client supersedes. Starts + /// nothing: [`Self::revive`] does, once it is gone. + async fn replace_superseded(&self, base: &str, budget: Duration) -> Result<(), String> { + let Some(kin_root) = discover_kin_dir() else { + return Err("no repository is bound, so no daemon was replaced".to_string()); + }; + match kin_daemon_spawn::replace_superseded_daemon(&kin_root, base, budget).await { + kin_daemon_spawn::SupersededReplacement::Replaced => Ok(()), + kin_daemon_spawn::SupersededReplacement::Refused(reason) => Err(reason), + } + } } /// Abstraction over daemon communication and revival used by @@ -1699,17 +1981,30 @@ impl DaemonCallSeam for RealDaemonSeam { .timeout(patience) .json(&serde_json::json!({ "name": name, "arguments": args })); let request = with_session_header(with_auth(request), args); - let resp = request - .send() - .await - .map_err(|e| classify_send_error("MCP tool call", e))?; + let resp = send_admitted_daemon_request("MCP tool call", base, request, patience).await?; note_session_status(&resp, args); if !resp.status().is_success() { - return Err(daemon_http_error("MCP tool call", resp).await); + return Err(daemon_http_error_waiting_out( + "MCP tool call", + resp, + crate::routed::is_read_only(name), + ) + .await); } - let result = resp.json::().await.map_err(|e| { + let parse_failed = |e: &dyn std::fmt::Display| { DaemonCallError::DaemonError(format!("daemon MCP tool call response parse failed: {e}")) - })?; + }; + let mut answered = resp + .json::() + .await + .map_err(|e| parse_failed(&e))?; + // The open review flags this answer reaches travel beside it, and are + // written into the reply the agent reads once every budget has run. + if let Some(reached) = crate::review_flags::take(&mut answered) { + crate::review_flags::note_reached(reached); + } + let result = + serde_json::from_value::(answered).map_err(|e| parse_failed(&e))?; Ok(Some(result)) } } @@ -1842,6 +2137,15 @@ async fn revive_mcp_daemon() -> Result { let kin_dir = discover_kin_dir().ok_or_else(|| "MCP revival: cannot find .kin directory".to_string())?; + // Another process may hold the repository already: a daemon starting in + // place of one that stopped, such as one taking over a store whose full + // history it attaches as it starts, or one a sibling client started. A + // daemon spawned now could only lose to it and exit, and the caller would + // be told to restart a session that was about to work. Wait for that + // daemon instead, and spawn only if it goes away without serving. + if let Some(started) = await_daemon_started_elsewhere(&kin_dir).await? { + return Ok(started); + } let working_dir = kin_core::layout::working_dir_of_store(&kin_dir) .ok_or_else(|| "MCP revival: invalid .kin layout (no parent directory)".to_string())?; let daemon_bin = find_mcp_daemon_binary().ok_or_else(|| { @@ -1890,6 +2194,91 @@ async fn revive_mcp_daemon() -> Result { revived } +/// Wait for a daemon another process holds the repository for, and answer its +/// base URL once it can serve, or `None` when no other process holds the +/// repository, or it let go without serving, so a revival should start one. +/// +/// Patience is the same a revival grants the daemon it starts, priced off what +/// this store's last open cost, because the daemon being waited for is doing +/// exactly that work. Running out of it is an error that says a daemon is +/// still starting, never that one exited. +async fn await_daemon_started_elsewhere( + kin_dir: &std::path::Path, +) -> Result, String> { + if !kin_daemon_spawn::repository_runtime_authority_is_held(kin_dir) + .map_err(|error| format!("MCP revival: inspect repository runtime authority: {error}"))? + { + return Ok(None); + } + let last_open_ms = kin_daemon_spawn::read_boot_cost(kin_dir).map(|cost| cost.total_ms); + let patience = kin_daemon_spawn::daemon_startup_patience( + last_open_ms, + kin_daemon_spawn::daemon_startup_patience_override(), + ); + await_daemon_started_elsewhere_within( + kin_dir, + patience, + last_open_ms.map(Duration::from_millis), + ) + .await +} + +async fn await_daemon_started_elsewhere_within( + kin_dir: &std::path::Path, + patience: Duration, + last_open: Option, +) -> Result, String> { + let probe = reqwest::Client::builder() + .connect_timeout(Duration::from_millis(300)) + .timeout(Duration::from_secs(3)) + .build() + .map_err(|e| format!("MCP revival: build probe client: {e}"))?; + let started = tokio::time::Instant::now(); + let deadline = started + patience; + let mut pacer = ProgressPacer::default(); + tracing::info!("MCP revival: another process holds this repository; waiting for its daemon"); + loop { + if pacer.due() { + report_call_progress(|| { + format!( + "waiting for the repository daemon that is starting: {}", + crate::startup_binding::how_far(started.elapsed(), last_open) + ) + }); + } + if let Some(port) = kin_daemon_spawn::read_reported_port(kin_dir) { + if kin_daemon_spawn::recorded_owner_is_alive(kin_dir) { + let base = format!("http://127.0.0.1:{port}"); + if poll_revival_readiness(&probe, &base).await == ReadinessPoll::Ready { + await_revived_catch_up(&probe, &base, deadline).await; + if let Ok(mut guard) = DAEMON_URL_OVERRIDE.lock() { + *guard = Some(base.clone()); + } + tracing::info!(url = %base, "MCP revival: the daemon that was starting is ready"); + return Ok(Some(base)); + } + } + } + // A daemon killed after publishing can leave its port behind. Its + // authority, rather than the presence of that record, decides whether + // there is still a holder to wait for. + if !kin_daemon_spawn::repository_runtime_authority_is_held(kin_dir).map_err(|error| { + format!("MCP revival: inspect repository runtime authority: {error}") + })? { + return Ok(None); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "MCP revival: the repository daemon another process started is still starting \ + after {}s; it holds the repository, so no second daemon was started. Retry the \ + call", + patience.as_secs() + )); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + /// The route a revival polls to decide the daemon it started can serve a call. /// /// Named rather than inlined so a test can stand a server in front of it and @@ -2452,6 +2841,12 @@ where let first_err = match attempt_through_warmup(&attempt, daemon_url, fast, deadline).await { Ok(result) => return Ok(result), Err(DaemonCallError::DaemonError(e)) => return Err(e), + Err(DaemonCallError::Superseded(refusal)) => { + return replace_then_retry( + operation, daemon_url, reviver, &attempt, fast, deadline, refusal, + ) + .await; + } // `attempt_through_warmup` resolves every warming answer, so this arm // exists to make the class impossible to lose rather than because it is // reachable today: a warming report is evidence of life, and grouping @@ -2467,6 +2862,12 @@ where let retry_err = match attempt_through_warmup(&attempt, daemon_url, fast, deadline).await { Ok(result) => return Ok(result), Err(DaemonCallError::DaemonError(e)) => return Err(e), + Err(DaemonCallError::Superseded(refusal)) => { + return replace_then_retry( + operation, daemon_url, reviver, &attempt, fast, deadline, refusal, + ) + .await; + } // The endpoint went from refusing connections to accepting one and // taking its time: it is coming up, not going down. Err(DaemonCallError::Timeout(e)) | Err(DaemonCallError::Warming(e)) => { @@ -2527,12 +2928,7 @@ where match attempt_through_warmup(&attempt, &new_url, fast, deadline).await { Ok(result) => Ok(result), Err(e) => { - let detail = match e { - DaemonCallError::ConnectionLost(s) - | DaemonCallError::Timeout(s) - | DaemonCallError::Warming(s) - | DaemonCallError::DaemonError(s) => s, - }; + let detail = call_error_detail(e); Err(revived_retry_failed_message( operation, &new_url, @@ -2545,6 +2941,65 @@ where } } +fn call_error_detail(error: DaemonCallError) -> String { + match error { + DaemonCallError::ConnectionLost(detail) + | DaemonCallError::Timeout(detail) + | DaemonCallError::Warming(detail) + | DaemonCallError::DaemonError(detail) + | DaemonCallError::Superseded(detail) => detail, + } +} + +/// Replace a daemon of another release once, then retry on this release's. +/// +/// One Kin serves a repository. A daemon of an older release is asked to make +/// way, this release's is started through the ordinary revival, and the call +/// is retried there exactly once. Any other release, a development build, or +/// an update that replaced this client is refused in the replacement's own +/// words, with nothing retried. +async fn replace_then_retry( + operation: &str, + daemon_url: &str, + reviver: &impl DaemonReviver, + attempt: &A, + fast: Duration, + deadline: tokio::time::Instant, + refusal: String, +) -> Result +where + A: Fn(String, Duration) -> Fut, + Fut: std::future::Future>, +{ + if let Err(reason) = reviver + .replace_superseded(daemon_url, remaining_until(deadline)) + .await + { + tracing::warn!( + operation, + daemon_url, + refusal = refusal.as_str(), + "daemon of another release left running" + ); + return Err(format!("{operation}: {reason}")); + } + let new_url = reviver.revive().await.map_err(|error| { + format!( + "{operation}: Kin replaced the older daemon for this repository, and starting this \ + release's failed: {error}" + ) + })?; + attempt_through_warmup(attempt, &new_url, fast, deadline) + .await + .map_err(|error| { + format!( + "{operation}: Kin replaced the older daemon for this repository, and this \ + release's did not answer: {}", + call_error_detail(error) + ) + }) +} + /// Spend the rest of this call's patience on a daemon that answered late. /// /// Exactly one further attempt, on whatever budget remains, and no revival on @@ -2638,7 +3093,10 @@ where "daemon {operation} failed: could not build an HTTP client" ))); }; - send_daemon_json(operation, build(&client, &url).timeout(patience)).await + let response = + send_admitted_daemon_request(operation, &url, build(&client, &url), patience) + .await?; + read_daemon_json_response(operation, response).await } }) .await @@ -2655,7 +3113,25 @@ const MAX_DAEMON_ERROR_BODY_BYTES: usize = 8 * 1024; /// /// Reading by chunks enforces the bound while the response is in flight rather /// than collecting an arbitrarily large body and truncating only afterward. -async fn daemon_http_error(operation: &str, mut response: reqwest::Response) -> DaemonCallError { +async fn daemon_http_error(operation: &str, response: reqwest::Response) -> DaemonCallError { + daemon_http_error_waiting_out(operation, response, false).await +} + +/// [`daemon_http_error`] for a call that is safe to send again, which may +/// also wait out the daemon publishing a prepared repository state. +/// +/// While the daemon swaps in a prepared publication, such as history it +/// staged while it served, it refuses a request that overlaps the swap with +/// `prepared_publication_unavailable` and asks for the same operation again. +/// A read answers the same question against the published state, so it is +/// waited out like a daemon that is still opening. A write is not: the +/// refusal can arrive after it ran, so its caller must read the outcome +/// rather than send it twice. +async fn daemon_http_error_waiting_out( + operation: &str, + mut response: reqwest::Response, + publication_retryable: bool, +) -> DaemonCallError { let status = response.status(); let mut body = Vec::with_capacity(MAX_DAEMON_ERROR_BODY_BYTES); let mut truncated = false; @@ -2697,7 +3173,9 @@ async fn daemon_http_error(operation: &str, mut response: reqwest::Response) -> let detail = String::from_utf8_lossy(&body); let detail = detail.trim(); let truncation = if truncated { " … [truncated]" } else { "" }; - if is_warming_refusal(status, detail) { + if is_warming_refusal(status, detail) + || (publication_retryable && is_publication_refusal(status, detail)) + { return DaemonCallError::Warming(format!("HTTP {status}: {detail}{truncation}")); } if detail.is_empty() { @@ -2709,6 +3187,16 @@ async fn daemon_http_error(operation: &str, mut response: reqwest::Response) -> } } +/// Whether a refusal is the daemon's serving fence asking for the same +/// operation again once a prepared publication has landed. +fn is_publication_refusal(status: reqwest::StatusCode, body: &str) -> bool { + status == reqwest::StatusCode::SERVICE_UNAVAILABLE + && serde_json::from_str::(body).is_ok_and(|value| { + value.get("error").and_then(serde_json::Value::as_str) + == Some("prepared_publication_unavailable") + }) +} + /// Whether a refusal is the daemon reporting that it is still opening its /// state, rather than a failure. /// @@ -2737,6 +3225,7 @@ fn is_warming_refusal(status: reqwest::StatusCode, body: &str) -> bool { /// An empty body is `Value::Null`, not an error. Some daemon mutations answer /// `204 No Content`, and the callers that expect it (intent release) synthesize /// their own result. +#[cfg(test)] async fn send_daemon_json( operation: &str, request: reqwest::RequestBuilder, @@ -2745,6 +3234,13 @@ async fn send_daemon_json( .send() .await .map_err(|e| classify_send_error(operation, e))?; + read_daemon_json_response(operation, resp).await +} + +async fn read_daemon_json_response( + operation: &str, + resp: reqwest::Response, +) -> Result { if !resp.status().is_success() { return Err(daemon_http_error(operation, resp).await); } @@ -2785,6 +3281,7 @@ pub async fn forward_tool_call( let cwd = required_string(arguments, "cwd")?; let transport = optional_string(arguments, "transport").unwrap_or("mcp"); let pid = optional_u32(arguments, "pid"); + let watch_pid = optional_u32(arguments, "watch_pid"); let capabilities = parse_capabilities(arguments); forward_session_start_with_id( optional_string(arguments, "session_id"), @@ -2792,6 +3289,7 @@ pub async fn forward_tool_call( &client_name, transport, pid, + watch_pid, &cwd, capabilities.as_ref(), ) @@ -3440,12 +3938,14 @@ pub async fn fetch_health_snapshot() -> Option { // Its own deadline, not the client backstop: this is an envelope-decorating // probe whose answer is optional, so it must never inherit the patience a // forwarded tool call is entitled to. - let resp = client - .get(format!("{}/health", base)) - .timeout(Duration::from_secs(3)) - .send() - .await - .ok()?; + let resp = send_admitted_daemon_request( + "daemon health snapshot", + &base, + with_auth(client.get(format!("{}/health", base))), + Duration::from_secs(3), + ) + .await + .ok()?; if !resp.status().is_success() { return None; } @@ -3470,6 +3970,7 @@ pub async fn daemon_client() -> Option { } let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) // Slow is not dead. A daemon fresh off a clone spends its first minute // on enrichment and spine work, and a legitimate tool call inside that // window can take well over five seconds; a 5 s total-request timeout @@ -3514,18 +4015,31 @@ pub async fn forward_session_start( cwd: &str, capabilities: Option<&SessionCapabilities>, ) -> Result, String> { - forward_session_start_with_id(None, vendor, client_name, transport, pid, cwd, capabilities) - .await + forward_session_start_with_id( + None, + vendor, + client_name, + transport, + pid, + None, + cwd, + capabilities, + ) + .await } /// Register the caller's original UUID through the daemon's authenticated /// session API. This grants no ownership beyond the daemon's bearer identity. +/// +/// `watch_pid` names a process whose exit ends a session that names no `pid`, +/// without keeping it alive while it runs. pub async fn forward_session_start_with_id( session_id: Option<&str>, vendor: &str, client_name: &str, transport: &str, pid: Option, + watch_pid: Option, cwd: &str, capabilities: Option<&SessionCapabilities>, ) -> Result, String> { @@ -3548,6 +4062,8 @@ pub async fn forward_session_start_with_id( } if let Some(p) = pid { body["pid"] = serde_json::json!(p); + } else if let Some(watched) = watch_pid { + body["watch_pid"] = serde_json::json!(watched); } let value = daemon_json_request("session start", &base, |client, base| { with_auth(client.post(format!("{base}/session")).json(&body)) @@ -3684,10 +4200,200 @@ pub async fn forward_check_traffic( }))) } +#[cfg(test)] +pub(crate) use tests::RuntimeDaemonFixture; + #[cfg(test)] mod tests { use super::*; + #[derive(Clone)] + pub(crate) struct RuntimeDaemonFixture { + pub(crate) store_root: std::path::PathBuf, + _repository: kin_core::runtime_identity::RuntimeRepositoryBinding, + identity: String, + health: String, + } + + impl RuntimeDaemonFixture { + pub(crate) fn new(repo: &Path) -> Self { + let layout = kin_core::init(repo) + .expect("initialize the fixture repository") + .layout; + let repository = + kin_core::runtime_identity::RuntimeRepositoryBinding::for_layout(&layout) + .expect("capture the fixture repository binding"); + let mut identity = + kin_core::runtime_identity::RuntimeServiceIdentity::current("daemon") + .expect("capture the fixture runtime identity"); + identity.repository = Some(repository.identity().clone()); + let health = serde_json::json!({ + "status": "ok", + "version": identity.identity.version, + "uptime_seconds": 0, + "graph_entity_count": 0, + "graph_loaded": true, + "reconciliation_status": "idle", + "repo_root": repo.canonicalize().unwrap(), + "pid": std::process::id(), + }) + .to_string(); + Self { + store_root: layout.root().to_path_buf(), + _repository: repository, + identity: serde_json::to_string(&identity).unwrap(), + health, + } + } + + pub(crate) fn route_response(&self, target: &str) -> Option { + match target { + "/runtime/identity" => Some(self.identity.clone()), + "/readiness" => Some(r#"{"ready":true,"warming":false}"#.to_string()), + "/health" => Some(self.health.clone()), + _ => None, + } + } + + pub(crate) fn install_registrar() { + kin_daemon_spawn::install_registrar(std::sync::Arc::new(FixtureSpawnRegistrar)); + } + } + + pub(super) fn fixture_home(repo: &Path) -> kin_core::test_env::EnvVarGuard { + let home = repo.join("private-home"); + std::fs::create_dir_all(&home).unwrap(); + kin_core::test_env::EnvVarGuard::set("HOME", repo) + .with("USERPROFILE", repo) + .with("KIN_HOME", &home) + .with("KIN_REGISTRY_PATH", home.join("registry.toml")) + .with("KIN_RUNTIME_MODE", "development") + .with("KIN_STORE_LAYOUT", "in-tree") + } + + struct FixtureSpawnRegistrar; + + impl kin_daemon_spawn::DaemonSpawnRegistrar for FixtureSpawnRegistrar { + fn supervisor_url( + &self, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { None }) + } + + fn register( + &self, + _: std::path::PathBuf, + _: String, + ) -> std::pin::Pin> + Send>> + { + Box::pin(async { Err("the fixture registrar cannot start or register daemons".into()) }) + } + + fn route_if_running( + &self, + _: std::path::PathBuf, + ) -> std::pin::Pin> + Send>> { + Box::pin(async { None }) + } + + fn reading_at( + &self, + root: std::path::PathBuf, + endpoint: String, + budget: Duration, + ) -> std::pin::Pin< + Box< + dyn std::future::Future + + Send, + >, + > { + Box::pin(async move { + use kin_daemon_spawn::RunningDaemonRouteReading; + + #[derive(serde::Deserialize)] + struct Readiness { + ready: bool, + warming: bool, + } + #[derive(serde::Deserialize)] + struct Health { + status: String, + version: String, + uptime_seconds: u64, + graph_entity_count: u64, + graph_loaded: bool, + reconciliation_status: String, + repo_root: std::path::PathBuf, + pid: u32, + } + + let probe = async { + let binding = + kin_core::runtime_identity::RuntimeRepositoryBinding::for_store(&root)?; + let working_dir = kin_core::KinLayout::new(root) + .working_dir() + .canonicalize() + .map_err(|error| error.to_string())?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| error.to_string())?; + let readiness: Readiness = + with_auth(client.get(format!("{endpoint}/readiness"))) + .send() + .await + .map_err(|error| error.to_string())? + .error_for_status() + .map_err(|error| error.to_string())? + .json() + .await + .map_err(|error| error.to_string())?; + if !readiness.ready { + return Ok(RunningDaemonRouteReading::NotReady { + detail: "fixture endpoint is not ready".into(), + warming: readiness.warming, + }); + } + let health: Health = with_auth(client.get(format!("{endpoint}/health"))) + .send() + .await + .map_err(|error| error.to_string())? + .error_for_status() + .map_err(|error| error.to_string())? + .json() + .await + .map_err(|error| error.to_string())?; + let _ = (health.uptime_seconds, health.graph_entity_count); + if health.status != "ok" + || health.version + != kin_core::runtime_identity::RuntimeIdentity::current().version + || !health.graph_loaded + || health.reconciliation_status != "idle" + || health.pid == 0 + || health + .repo_root + .canonicalize() + .map_err(|error| error.to_string())? + != working_dir + { + return Err( + "fixture health does not certify the intended repository".to_string() + ); + } + binding.revalidate()?; + Ok::<_, String>(RunningDaemonRouteReading::Serving(endpoint)) + }; + match tokio::time::timeout(budget, probe).await { + Ok(Ok(reading)) => reading, + Ok(Err(detail)) => RunningDaemonRouteReading::Unresolved { detail }, + Err(_) => RunningDaemonRouteReading::Unresolved { + detail: "fixture endpoint readiness probe timed out".into(), + }, + } + }) + } + } + /// A store an older Kin build wrote names `kin upgrade` when no ready route /// serves it, because a daemon cannot serve it until it is re-derived. The /// same repository with a current record names `kin graph status`. @@ -4494,12 +5200,117 @@ mod tests { } } - #[tokio::test] - async fn a_refusing_endpoint_keeps_its_port_reserved() { - let endpoint = RefusingEndpoint::new(); - let addr: std::net::SocketAddr = endpoint - .url - .strip_prefix("http://") + /// A revival that finds the repository held by a daemon another process + /// is starting waits for that daemon and uses it, instead of starting one + /// that could only lose the repository to it and exit. A holder that lets + /// go without serving leaves the revival to start one. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_revival_waits_for_the_daemon_another_process_is_starting() { + let store = tempfile::tempdir().unwrap(); + let kin_dir = store.path().join(".kin"); + std::fs::create_dir(&kin_dir).unwrap(); + + // Nobody holds the repository: start one. + assert_eq!( + await_daemon_started_elsewhere_within(&kin_dir, Duration::from_secs(5), None) + .await + .unwrap(), + None + ); + + // Held, and still opening: nothing published yet. + let held = kin_daemon_spawn::acquire_repository_runtime_authority(&kin_dir, "starting") + .unwrap() + .expect("the test holds the repository"); + let catching_up = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let checked_catch_up = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (base, server) = stub_daemon_raw_with_body(200, "OK", { + let catching_up = std::sync::Arc::clone(&catching_up); + let checked_catch_up = std::sync::Arc::clone(&checked_catch_up); + move |request| { + if request.starts_with(b"GET /health ") { + let owes = catching_up.load(std::sync::atomic::Ordering::SeqCst); + checked_catch_up.store(true, std::sync::atomic::Ordering::SeqCst); + if owes { + return r#"{"reconcile":{"changed_path_count":1,"admission_failure_streak":0}}"#; + } + } + "{}" + } + }) + .await; + let port = base.rsplit(':').next().unwrap().to_string(); + let waiting = tokio::spawn({ + let kin_dir = kin_dir.clone(); + async move { + await_daemon_started_elsewhere_within(&kin_dir, Duration::from_secs(30), None).await + } + }); + tokio::time::sleep(Duration::from_millis(400)).await; + assert!( + !waiting.is_finished(), + "the revival gave up on a starting daemon" + ); + // It publishes and serves. + std::fs::write(kin_dir.join("daemon.pid"), std::process::id().to_string()).unwrap(); + std::fs::write(kin_dir.join("daemon.port"), &port).unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while !checked_catch_up.load(std::sync::atomic::Ordering::SeqCst) { + assert!( + !waiting.is_finished(), + "the revival skipped the catch-up check" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the revival checks what the ready daemon still owes"); + assert!( + !waiting.is_finished(), + "the revival skipped admission of changes made during the handover" + ); + catching_up.store(false, std::sync::atomic::Ordering::SeqCst); + let found = tokio::time::timeout(Duration::from_secs(20), waiting) + .await + .expect("the revival waits only until the daemon serves") + .unwrap() + .unwrap(); + assert_eq!(found.as_deref(), Some(base.as_str())); + server.abort(); + drop(held); + + // Held, then let go after publishing an endpoint that no longer + // answers: start one even though the stale records remain. + let held = kin_daemon_spawn::acquire_repository_runtime_authority(&kin_dir, "dying") + .unwrap() + .expect("the test holds the repository"); + let waiting = tokio::spawn({ + let kin_dir = kin_dir.clone(); + async move { + await_daemon_started_elsewhere_within(&kin_dir, Duration::from_secs(30), None).await + } + }); + tokio::time::sleep(Duration::from_millis(300)).await; + drop(held); + assert_eq!( + tokio::time::timeout(Duration::from_secs(20), waiting) + .await + .expect("a holder that lets go ends the wait") + .unwrap() + .unwrap(), + None + ); + if let Ok(mut guard) = DAEMON_URL_OVERRIDE.lock() { + *guard = None; + } + } + + #[tokio::test] + async fn a_refusing_endpoint_keeps_its_port_reserved() { + let endpoint = RefusingEndpoint::new(); + let addr: std::net::SocketAddr = endpoint + .url + .strip_prefix("http://") .unwrap() .parse() .unwrap(); @@ -4533,15 +5344,26 @@ mod tests { status: u16, reason: &'static str, body: &'static str, + ) -> (String, tokio::task::JoinHandle<()>) { + stub_daemon_raw_with_body(status, reason, move |_| body).await + } + + async fn stub_daemon_raw_with_body( + status: u16, + reason: &'static str, + body: impl Fn(&[u8]) -> &'static str + Send + Sync + 'static, ) -> (String, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); + let body = std::sync::Arc::new(body); let handle = tokio::spawn(async move { while let Ok((mut socket, _)) = listener.accept().await { + let body = std::sync::Arc::clone(&body); tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut buf = [0u8; 8192]; - let _ = socket.read(&mut buf).await; + let read = socket.read(&mut buf).await.unwrap_or(0); + let body = body(&buf[..read]); let response = format!( "HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\n\ content-length: {}\r\nconnection: close\r\n\r\n{body}", @@ -4555,8 +5377,46 @@ mod tests { (format!("http://127.0.0.1:{port}"), handle) } - /// One-shot responder that also returns the exact HTTP request it received. + async fn admitted_stub_daemon_raw( + runtime: RuntimeDaemonFixture, + status: u16, + reason: &'static str, + body: &'static str, + ) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = [0u8; 8192]; + let read = socket.read(&mut buf).await.unwrap(); + let request = String::from_utf8_lossy(&buf[..read]); + let target = request + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(); + let admitted = runtime.route_response(target); + let (status, reason, body) = match admitted.as_deref() { + Some(body) => (200, "OK", body), + None => (status, reason, body), + }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + let _ = socket.shutdown().await; + } + }); + (format!("http://127.0.0.1:{port}"), handle) + } + + /// Respond to admission probes, then capture the semantic resources request. async fn capturing_resources_daemon( + runtime: RuntimeDaemonFixture, body: &'static str, ) -> ( String, @@ -4568,51 +5428,78 @@ mod tests { let (request_tx, request_rx) = tokio::sync::oneshot::channel(); let handle = tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = Vec::new(); - loop { - let mut chunk = [0u8; 2048]; - let read = socket.read(&mut chunk).await.unwrap(); - if read == 0 { - break; + let mut request_tx = Some(request_tx); + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = Vec::new(); + loop { + let mut chunk = [0u8; 2048]; + let read = socket.read(&mut chunk).await.unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + let Some(header_end) = + request.windows(4).position(|bytes| bytes == b"\r\n\r\n") + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } } - request.extend_from_slice(&chunk[..read]); - let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") - else { - continue; - }; - let headers = String::from_utf8_lossy(&request[..header_end]); - let content_length = headers + let request = String::from_utf8_lossy(&request).into_owned(); + let target = request .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().ok()) - .flatten() - }) - .unwrap_or(0); - if request.len() >= header_end + 4 + content_length { + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(); + let admitted = runtime.route_response(target); + if admitted.is_none() { + let _ = request_tx.take().unwrap().send(request); + } + let response_body = admitted.as_deref().unwrap_or(body); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\ + connection: close\r\n\r\n{response_body}", + response_body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + let _ = socket.shutdown().await; + if admitted.is_none() { break; } } - let _ = request_tx.send(String::from_utf8_lossy(&request).into_owned()); - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\ - connection: close\r\n\r\n{body}", - body.len() - ); - socket.write_all(response.as_bytes()).await.unwrap(); - let _ = socket.shutdown().await; }); (format!("http://127.0.0.1:{port}"), request_rx, handle) } #[tokio::test] async fn resources_observation_preserves_refusal_on_legacy_and_error_shapes() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repo = tempfile::tempdir().unwrap(); + let _home = fixture_home(repo.path()); + let _working_dir = TestWorkingDir::enter(repo.path()); + let runtime = RuntimeDaemonFixture::new(repo.path()); + RuntimeDaemonFixture::install_registrar(); let client = probe_client(); let arguments = HashMap::new(); - let (exact, exact_handle) = stub_daemon( + let (exact, exact_handle) = admitted_stub_daemon_raw( + runtime.clone(), 200, "OK", r#"{"embed_runtime":{"embeddings_pending":0,"embeddings_indexed":4,"embeddings_total":4}}"#, ) .await; @@ -4622,8 +5509,13 @@ mod tests { ); exact_handle.abort(); - let (legacy, legacy_handle) = - stub_daemon(r#"{"embed_runtime":{"embeddings_indexed":4}}"#).await; + let (legacy, legacy_handle) = admitted_stub_daemon_raw( + runtime.clone(), + 200, + "OK", + r#"{"embed_runtime":{"embeddings_indexed":4}}"#, + ) + .await; assert_eq!( fetch_embedding_coverage_from_resources_at(&client, &legacy, &arguments).await, None, @@ -4631,15 +5523,21 @@ mod tests { ); legacy_handle.abort(); - let (malformed, malformed_handle) = stub_daemon("not json").await; + let (malformed, malformed_handle) = + admitted_stub_daemon_raw(runtime.clone(), 200, "OK", "not json").await; assert_eq!( fetch_embedding_coverage_from_resources_at(&client, &malformed, &arguments).await, None ); malformed_handle.abort(); - let (failed, failed_handle) = - stub_daemon_raw(500, "Internal Server Error", r#"{"error":"old daemon"}"#).await; + let (failed, failed_handle) = admitted_stub_daemon_raw( + runtime, + 500, + "Internal Server Error", + r#"{"error":"old daemon"}"#, + ) + .await; assert_eq!( fetch_embedding_coverage_from_resources_at(&client, &failed, &arguments).await, None @@ -4649,6 +5547,14 @@ mod tests { #[tokio::test] async fn resources_observation_posts_the_default_request_for_the_callers_session() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repo = tempfile::tempdir().unwrap(); + let _home = fixture_home(repo.path()); + let _working_dir = TestWorkingDir::enter(repo.path()); + let runtime = RuntimeDaemonFixture::new(repo.path()); + RuntimeDaemonFixture::install_registrar(); let client = probe_client(); let mut arguments = HashMap::new(); arguments.insert( @@ -4656,6 +5562,7 @@ mod tests { serde_json::json!("session-selected-graph"), ); let (base, request_rx, handle) = capturing_resources_daemon( + runtime, r#"{"embed_runtime":{"embeddings_pending":0,"embeddings_indexed":4,"embeddings_total":4}}"#, ) .await; @@ -5189,9 +6096,17 @@ mod tests { /// the direct Axum route test cannot cover. #[tokio::test] async fn expired_session_404_reaches_the_final_mcp_tool_result() { + if !kin_core::test_env::alone_in_process() { + return; + } + let repo = tempfile::tempdir().unwrap(); + let _home = fixture_home(repo.path()); + let _working_dir = TestWorkingDir::enter(repo.path()); + let runtime = RuntimeDaemonFixture::new(repo.path()); + RuntimeDaemonFixture::install_registrar(); let body = "session not found: dead-session. It expired after its idle timeout. \ Call kin_session_start for a new session id."; - let (base, handle) = stub_daemon_raw(404, "Not Found", body).await; + let (base, handle) = admitted_stub_daemon_raw(runtime, 404, "Not Found", body).await; let forwarded = session_heartbeat_request(&base, "dead-session") .await @@ -5831,8 +6746,15 @@ mod tests { /// the listener until the child has finished and aborts it even on failure. #[tokio::test] async fn traffic_scope_forwarding_preserves_one_decoded_path_segment() { + if !kin_core::test_env::alone_in_process() { + return; + } use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let repo = tempfile::tempdir().unwrap(); + let _home = fixture_home(repo.path()); + let runtime = RuntimeDaemonFixture::new(repo.path()); + struct ServerGuard(tokio::task::JoinHandle<()>); impl Drop for ServerGuard { fn drop(&mut self) { @@ -5869,6 +6791,15 @@ mod tests { .split_whitespace() .nth(1) .unwrap(); + if let Some(body) = runtime.route_response(target) { + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + let _ = socket.shutdown().await; + continue; + } let scope = target .strip_prefix("/traffic/") .filter(|segment| !segment.is_empty() && !segment.contains(['/', '?'])) @@ -5896,6 +6827,7 @@ mod tests { "daemon_delegate::tests::traffic_scope_forwarding_child", "--nocapture", ]) + .current_dir(repo.path()) .env("TRAFFIC_SCOPE_FORWARDING_TEST_CHILD", "1") .env("KIN_DAEMON_URL", base) .kill_on_drop(true) @@ -5924,6 +6856,7 @@ mod tests { if std::env::var("TRAFFIC_SCOPE_FORWARDING_TEST_CHILD").as_deref() != Ok("1") { return; } + RuntimeDaemonFixture::install_registrar(); for scope in TRAFFIC_SCOPE_CASES { let result = forward_check_traffic(&[scope.to_string()]) .await @@ -6173,16 +7106,22 @@ mod tests { // auth and process-global state cannot contaminate concurrently running tests. #[test] fn precommit_source_failures_are_rechecked_without_a_semantic_commit() { + if !kin_core::test_env::alone_in_process() { + return; + } use std::io::{Read, Write}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; for marker in [Some("4\n"), None, Some("unreadable-generation\n")] { let repo = tempfile::tempdir().unwrap(); - let kin_dir = repo.path().join(".kin"); - std::fs::create_dir_all(kin_dir.join("kindb")).unwrap(); + let _home = fixture_home(repo.path()); + let runtime = RuntimeDaemonFixture::new(repo.path()); + let kin_dir = runtime.store_root.clone(); if let Some(marker) = marker { std::fs::write(kin_dir.join("kindb/head-generation"), marker).unwrap(); + } else if kin_dir.join("kindb/head-generation").exists() { + std::fs::remove_file(kin_dir.join("kindb/head-generation")).unwrap(); } let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); @@ -6207,7 +7146,7 @@ mod tests { .set_read_timeout(Some(Duration::from_secs(3))) .unwrap(); let mut request = Vec::new(); - loop { + let admitted = loop { let mut chunk = [0; 1024]; let received = socket.read(&mut chunk).unwrap(); assert!(received > 0, "fixture request ended before its body"); @@ -6216,7 +7155,17 @@ mod tests { if let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") { let headers = std::str::from_utf8(&request[..end]).unwrap(); - assert!(headers.starts_with("POST /mcp/tools/call ")); + let target = headers + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(); + let admitted = runtime.route_response(target); + if admitted.is_none() { + assert!(headers.starts_with("POST /mcp/tools/call ")); + } let length: usize = headers .lines() .find_map(|line| { @@ -6224,15 +7173,21 @@ mod tests { key.eq_ignore_ascii_case("content-length") .then(|| value.trim().parse().unwrap()) }) - .unwrap(); + .unwrap_or(0); if request.len() >= end + 4 + length { - let body: serde_json::Value = - serde_json::from_slice(&request[end + 4..end + 4 + length]) - .unwrap(); - assert!(body["arguments"]["entity_id"].as_str().is_some()); - break; + if admitted.is_none() { + let body: serde_json::Value = + serde_json::from_slice(&request[end + 4..end + 4 + length]) + .unwrap(); + assert!(body["arguments"]["entity_id"].as_str().is_some()); + } + break admitted; } } + }; + if let Some(body) = admitted { + write!(socket, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).unwrap(); + continue; } let call = server_calls.fetch_add(1, Ordering::SeqCst); let result = if call % 2 == 0 { @@ -6296,6 +7251,7 @@ mod tests { std::env::var("SOURCE_FORWARDING_TEST_CHILD").as_deref(), Ok("1") ); + RuntimeDaemonFixture::install_registrar(); for (index, name) in ["get_entity_source", "get_entity_source", "get_entity_body"] .into_iter() .enumerate() @@ -7221,6 +8177,105 @@ mod tests { DaemonCallError::Timeout("operation timed out".to_string()) } + fn superseded() -> DaemonCallError { + DaemonCallError::Superseded( + "runtime admission refused: the daemon predates runtime identity".to_string(), + ) + } + + /// A reviver that counts replacement requests and revivals, and answers a + /// replacement with `replace`. + struct ReplacingReviver { + replace: Result<(), String>, + replaces: std::sync::atomic::AtomicUsize, + revives: std::sync::atomic::AtomicUsize, + } + + impl ReplacingReviver { + fn new(replace: Result<(), String>) -> Self { + Self { + replace, + replaces: std::sync::atomic::AtomicUsize::new(0), + revives: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn counts(&self) -> (usize, usize) { + ( + self.replaces.load(std::sync::atomic::Ordering::SeqCst), + self.revives.load(std::sync::atomic::Ordering::SeqCst), + ) + } + } + + impl DaemonReviver for ReplacingReviver { + async fn revive(&self) -> Result { + self.revives + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok("http://127.0.0.1:2".to_string()) + } + + async fn is_provably_alive(&self, _base: &str) -> bool { + false + } + + async fn replace_superseded(&self, _base: &str, _budget: Duration) -> Result<(), String> { + self.replaces + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.replace.clone() + } + } + + /// A daemon of another release is replaced once and the call retried once + /// on this release's. A refused replacement is the answer, with nothing + /// started or retried, and a second refusal after a replacement is never + /// a second replacement. + #[tokio::test(start_paused = true)] + async fn a_daemon_of_another_release_is_replaced_once_and_the_call_retried_once() { + async fn call( + attempts: &ScriptedAttempts, + reviver: &ReplacingReviver, + ) -> Result { + attempt_with_revival_within( + "tool semantic_locate", + "http://127.0.0.1:1", + reviver, + |url, budget| attempts.attempt(url, budget), + Duration::from_secs(60), + Duration::from_secs(300), + ) + .await + } + + let attempts = ScriptedAttempts::new(vec![superseded()]); + let reviver = ReplacingReviver::new(Ok(())); + assert_eq!(call(&attempts, &reviver).await, Ok(7)); + assert_eq!(reviver.counts(), (1, 1), "one replacement, one start"); + assert_eq!(attempts.budgets().len(), 2, "one retry"); + + let attempts = ScriptedAttempts::new(vec![superseded()]); + let newer = "Kin daemon pid 9 (/opt/homebrew/bin/kin-daemon) runs Kin 0.9.0, newer than \ + Kin 0.8.7 (aaaaaaa). An older release never retires a newer one; run `kin update`"; + let reviver = ReplacingReviver::new(Err(newer.to_string())); + let error = call(&attempts, &reviver).await.unwrap_err(); + assert!(error.contains(newer), "{error}"); + assert_eq!( + reviver.counts(), + (1, 0), + "a refused replacement starts nothing" + ); + assert_eq!(attempts.budgets().len(), 1, "and retries nothing"); + + let attempts = ScriptedAttempts::new(vec![superseded(), superseded()]); + let reviver = ReplacingReviver::new(Ok(())); + assert!(call(&attempts, &reviver).await.is_err()); + assert_eq!( + reviver.counts(), + (1, 1), + "never a second replacement in one call" + ); + } + /// A call waiting on a daemon that is open and still loading tells a /// client that asked for progress how far it has come, paced so a long /// wait is a handful of notifications rather than one per poll. @@ -7583,6 +8638,29 @@ mod tests { } } + /// The serving fence's refusal during a publication is recognised by its + /// own marker and status, and nothing else reads as it. + #[test] + fn the_publication_guard_recognises_only_the_serving_fence() { + let fence = r#"{"error":"prepared_publication_unavailable","reason":"prepared publication unavailable: ObservationChanged","live":true,"semantic_ready":false}"#; + assert!(is_publication_refusal( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + fence + )); + assert!(!is_publication_refusal( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + fence + )); + assert!(!is_publication_refusal( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + r#"{"error":"embedder_unavailable"}"# + )); + assert!(!is_warming_refusal( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + fence + )); + } + /// A request that is sent and not answered must not classify as the same /// thing as a port that refuses connections. Driven over real sockets so /// reqwest's own classification is exercised rather than assumed. @@ -7919,46 +8997,78 @@ mod connection_session_tests { /// marks the tag, which is how the server knows to open a new one. #[tokio::test] async fn a_daemon_that_does_not_hold_the_session_is_heard() { + if !kin_core::test_env::alone_in_process() { + return; + } if launcher_session_set() { return; } + let repo = tempfile::tempdir().unwrap(); + let _home = tests::fixture_home(repo.path()); + let _working_dir = TestWorkingDir::enter(repo.path()); + let runtime = RuntimeDaemonFixture::new(repo.path()); + RuntimeDaemonFixture::install_registrar(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let (request_tx, request_rx) = tokio::sync::oneshot::channel::(); let server = tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = Vec::new(); - loop { - let mut chunk = [0u8; 4096]; - let read = socket.read(&mut chunk).await.unwrap(); - if read == 0 { - break; + let mut request_tx = Some(request_tx); + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = Vec::new(); + loop { + let mut chunk = [0u8; 4096]; + let read = socket.read(&mut chunk).await.unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") + else { + continue; + }; + let head = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase(); + let length = head + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + if request.len() >= end + 4 + length { + break; + } } - request.extend_from_slice(&chunk[..read]); - let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else { - continue; - }; - let head = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase(); - let length = head + let request = String::from_utf8_lossy(&request).into_owned(); + let target = request .lines() - .find_map(|line| line.strip_prefix("content-length:")) - .and_then(|value| value.trim().parse::().ok()) - .unwrap_or(0); - if request.len() >= end + 4 + length { + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(); + let admitted = runtime.route_response(target); + if admitted.is_none() { + let _ = request_tx.take().unwrap().send(request); + } + let body = admitted + .as_deref() + .unwrap_or(r#"{"content":[{"type":"text","text":"{}"}]}"#); + let session_status = if admitted.is_none() { + "x-kin-session-status: unknown\r\n" + } else { + "" + }; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + {session_status}content-length: {}\r\n\ + connection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + let _ = socket.shutdown().await; + if admitted.is_none() { break; } } - let _ = request_tx.send(String::from_utf8_lossy(&request).into_owned()); - let body = r#"{"content":[{"type":"text","text":"{}"}]}"#; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ - x-kin-session-status: unknown\r\ncontent-length: {}\r\n\ - connection: close\r\n\r\n{body}", - body.len() - ); - socket.write_all(response.as_bytes()).await.unwrap(); - let _ = socket.shutdown().await; }); let tag = ConnectionSessionTag::new("stale-session".to_string()); let answered = with_connection_session(Some(tag.clone()), async { @@ -7982,3 +9092,731 @@ mod connection_session_tests { server.abort(); } } + +#[cfg(test)] +mod runtime_request_admission_tests { + use super::{send_admitted_daemon_request_with, DaemonCallError, DaemonRequestRuntime}; + use kin_core::runtime_identity::{ + RuntimeIdentity, RuntimeMode, RuntimeNamespace, RuntimeRepositoryBinding, + RuntimeServiceIdentity, RUNTIME_IDENTITY_HEADER, RUNTIME_REPOSITORY_HEADER, + }; + use kin_daemon_spawn::RunningDaemonRouteReading; + use std::collections::BTreeMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + const AUTHORIZATION: &str = "Bearer admission-fixture-token"; + const PATIENCE: Duration = Duration::from_secs(3); + + #[derive(Debug)] + struct RecordedRequest { + method: String, + path: String, + headers: BTreeMap, + body: Vec, + } + + struct HttpFixture { + base: String, + requests: Arc>>, + events: Arc>>, + server: Option>, + } + + impl HttpFixture { + async fn start(metadata: Vec) -> Self { + assert!(!metadata.is_empty()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind admission fixture"); + let base = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let events = Arc::new(Mutex::new(Vec::new())); + let seen_requests = requests.clone(); + let seen_events = events.clone(); + let metadata: Vec = metadata + .iter() + .map(|value| serde_json::to_string(value).unwrap()) + .collect(); + let server = tokio::spawn(async move { + let mut metadata_readings = 0_usize; + while let Ok((mut socket, _)) = listener.accept().await { + let request = + match tokio::time::timeout(PATIENCE, read_request(&mut socket)).await { + Ok(Some(request)) => request, + _ => continue, + }; + let (status, body, event) = + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/runtime/identity") => { + let body = &metadata[metadata_readings.min(metadata.len() - 1)]; + metadata_readings += 1; + ("200 OK", body.as_str(), "metadata") + } + ("POST", "/mcp/tools/call") => { + ("200 OK", r#"{"result":"fixture-answer"}"#, "operation") + } + _ => ("404 Not Found", "{}", "unexpected"), + }; + seen_requests.lock().unwrap().push(request); + seen_events.lock().unwrap().push(event); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + } + }); + Self { + base, + requests, + events, + server: Some(server), + } + } + + async fn finish(mut self) -> (Vec, Vec<&'static str>) { + if let Some(server) = self.server.take() { + server.abort(); + let _ = server.await; + } + let requests = std::mem::take(&mut *self.requests.lock().unwrap()); + let events = std::mem::take(&mut *self.events.lock().unwrap()); + (requests, events) + } + } + + impl Drop for HttpFixture { + fn drop(&mut self) { + if let Some(server) = self.server.take() { + server.abort(); + } + } + } + + async fn read_request(socket: &mut tokio::net::TcpStream) -> Option { + let mut bytes = Vec::new(); + let header_end = loop { + if let Some(index) = bytes.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + break index + 4; + } + let mut chunk = [0_u8; 4096]; + let count = socket.read(&mut chunk).await.ok()?; + if count == 0 || bytes.len() + count > 65536 { + return None; + } + bytes.extend_from_slice(&chunk[..count]); + }; + let head = std::str::from_utf8(&bytes[..header_end]).ok()?; + let mut lines = head.split("\r\n"); + let mut request_line = lines.next()?.split_whitespace(); + let method = request_line.next()?.to_string(); + let path = request_line.next()?.to_string(); + let headers: BTreeMap = lines + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string())) + .collect(); + let content_length = headers + .get("content-length") + .map(|value| value.parse::()) + .transpose() + .ok()? + .unwrap_or(0); + if content_length > 65536 { + return None; + } + while bytes.len() < header_end + content_length { + let mut chunk = [0_u8; 4096]; + let count = socket.read(&mut chunk).await.ok()?; + if count == 0 { + return None; + } + bytes.extend_from_slice(&chunk[..count]); + } + Some(RecordedRequest { + method, + path, + headers, + body: bytes[header_end..header_end + content_length].to_vec(), + }) + } + + fn local_runtime() -> (tempfile::TempDir, DaemonRequestRuntime) { + let home = tempfile::tempdir().expect("create isolated admission namespace"); + let namespace = RuntimeNamespace::capture(home.path()).unwrap(); + let identity = RuntimeIdentity { + version: "0.8.6".into(), + commit_sha: "a".repeat(40), + source_dirty: false, + source_known: true, + target: "aarch64-apple-darwin".into(), + build_profile: "release".into(), + }; + identity.require_managed().unwrap(); + let runtime = DaemonRequestRuntime { + identity, + mode: RuntimeMode::Managed, + namespace: Ok(namespace), + }; + (home, runtime) + } + + fn local_repository() -> (tempfile::TempDir, RuntimeRepositoryBinding) { + let fixture = tempfile::tempdir().expect("create isolated repository fixture"); + let home = fixture.path().join("home"); + let kin_home = fixture.path().join("kin-home"); + let repo = fixture.path().join("repository"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&kin_home).unwrap(); + std::fs::create_dir_all(&repo).unwrap(); + let layout = { + let _env = kin_core::test_env::EnvVarGuard::set("HOME", &home) + .with("USERPROFILE", &home) + .with("KIN_HOME", &kin_home) + .with("KIN_REGISTRY_PATH", kin_home.join("registry.toml")) + .with("KIN_RUNTIME_MODE", "development"); + kin_core::init(&repo) + .expect("initialize the fixture repository") + .layout + }; + let binding = RuntimeRepositoryBinding::for_store(layout.root()) + .expect("capture the initialized repository binding"); + (fixture, binding) + } + + fn local_peer( + runtime: &DaemonRequestRuntime, + binding: &RuntimeRepositoryBinding, + ) -> RuntimeServiceIdentity { + RuntimeServiceIdentity { + schema: "kin.runtime-service.v1".into(), + identity: runtime.identity.clone(), + mode: runtime.mode, + component: "daemon".into(), + pid: std::process::id(), + namespace: Some(runtime.namespace.as_ref().unwrap().token().into()), + repository: Some(binding.identity().clone()), + } + } + + #[derive(Clone, Copy)] + enum RepositoryRoute { + Serving, + Unresolved, + Warming, + } + + struct Observation { + answer: Result, + requests: Vec, + events: Vec<&'static str>, + repository_probes: usize, + } + + async fn observe_request( + runtime: &DaemonRequestRuntime, + metadata: serde_json::Value, + serves_repository: bool, + binding: &RuntimeRepositoryBinding, + ) -> Observation { + observe_metadata_sequence( + runtime, + vec![metadata], + if serves_repository { + RepositoryRoute::Serving + } else { + RepositoryRoute::Unresolved + }, + binding, + ) + .await + } + + async fn observe_metadata_sequence( + runtime: &DaemonRequestRuntime, + metadata: Vec, + route: RepositoryRoute, + binding: &RuntimeRepositoryBinding, + ) -> Observation { + let fixture = HttpFixture::start(metadata).await; + let events = fixture.events.clone(); + let repository_probes = Arc::new(AtomicUsize::new(0)); + let probes = repository_probes.clone(); + let selected = fixture.base.clone(); + let binding = binding.clone(); + let request = reqwest::Client::new() + .post(format!("{}/mcp/tools/call", fixture.base)) + .header(reqwest::header::AUTHORIZATION, AUTHORIZATION) + .json(&serde_json::json!({ + "name": "semantic_locate", + "arguments": { "query": "fixture-target" }, + })); + let answer = send_admitted_daemon_request_with( + "admission fixture", + &fixture.base, + request, + PATIENCE, + runtime, + move |remaining| async move { + assert!(remaining > Duration::ZERO && remaining <= PATIENCE); + probes.fetch_add(1, Ordering::SeqCst); + events.lock().unwrap().push("repository"); + let route = match route { + RepositoryRoute::Serving => RunningDaemonRouteReading::Serving(selected), + RepositoryRoute::Unresolved => RunningDaemonRouteReading::Unresolved { + detail: "selected endpoint does not bind the intended repository".into(), + }, + RepositoryRoute::Warming => RunningDaemonRouteReading::NotReady { + detail: "the intended repository is still opening".into(), + warming: true, + }, + }; + Ok((route, binding)) + }, + ) + .await; + let answer = match answer { + Ok(response) => { + assert!(response.status().is_success()); + Ok(response.json::().await.unwrap()) + } + Err(error) => Err(error), + }; + let (requests, events) = fixture.finish().await; + Observation { + answer, + requests, + events, + repository_probes: repository_probes.load(Ordering::SeqCst), + } + } + + fn assert_refused(observation: &Observation, repository_probes: usize) { + assert_refused_after_metadata(observation, repository_probes, 1); + } + + fn assert_refused_after_metadata( + observation: &Observation, + repository_probes: usize, + metadata_readings: usize, + ) { + assert!( + matches!( + &observation.answer, + Err(DaemonCallError::DaemonError(detail) | DaemonCallError::Superseded(detail)) + if detail.contains("runtime admission refused") + ), + "{:?}", + observation.answer + ); + assert_eq!(observation.repository_probes, repository_probes); + assert_eq!( + observation.requests.len(), + metadata_readings, + "{:?}", + observation.requests + ); + for request in &observation.requests { + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/runtime/identity"); + assert!(request.body.is_empty()); + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some(AUTHORIZATION) + ); + assert!(!request.headers.contains_key(RUNTIME_IDENTITY_HEADER)); + assert!(!request.headers.contains_key(RUNTIME_REPOSITORY_HEADER)); + } + } + + #[tokio::test] + async fn runtime_request_admission_matching_managed_peer_dispatches_after_proof() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let peer = local_peer(&runtime, &binding); + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + true, + &binding, + ) + .await; + assert_eq!(observation.answer.unwrap()["result"], "fixture-answer"); + assert_eq!(observation.repository_probes, 1); + assert_eq!(observation.events, ["metadata", "repository", "operation"]); + assert_eq!(observation.requests.len(), 2); + let metadata = &observation.requests[0]; + let operation = &observation.requests[1]; + assert_eq!( + (metadata.method.as_str(), metadata.path.as_str()), + ("GET", "/runtime/identity") + ); + assert_eq!( + (operation.method.as_str(), operation.path.as_str()), + ("POST", "/mcp/tools/call") + ); + for request in [&metadata, &operation] { + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some(AUTHORIZATION) + ); + } + let expected = runtime + .identity + .fingerprint(runtime.mode, runtime.namespace.as_ref().unwrap().token()); + assert_eq!( + operation.headers.get(RUNTIME_IDENTITY_HEADER), + Some(&expected) + ); + assert_eq!( + operation.headers.get(RUNTIME_REPOSITORY_HEADER), + Some(&binding.fingerprint()) + ); + assert!(!metadata.headers.contains_key(RUNTIME_IDENTITY_HEADER)); + assert!(!metadata.headers.contains_key(RUNTIME_REPOSITORY_HEADER)); + let body: serde_json::Value = serde_json::from_slice(&operation.body).unwrap(); + assert_eq!(body["name"], "semantic_locate"); + assert_eq!(body["arguments"]["query"], "fixture-target"); + } + + #[tokio::test] + async fn runtime_request_admission_same_version_other_source_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let mut peer = local_peer(&runtime, &binding); + peer.identity.commit_sha = "b".repeat(40); + assert_eq!(peer.identity.version, runtime.identity.version); + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + true, + &binding, + ) + .await; + assert_refused(&observation, 0); + assert_eq!(observation.events, ["metadata"]); + } + + #[tokio::test] + async fn runtime_request_admission_wrong_mode_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let mut peer = local_peer(&runtime, &binding); + peer.mode = RuntimeMode::Development; + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + true, + &binding, + ) + .await; + assert_refused(&observation, 0); + } + + #[tokio::test] + async fn runtime_request_admission_other_namespace_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let other_home = tempfile::tempdir().unwrap(); + let other_namespace = RuntimeNamespace::capture(other_home.path()).unwrap(); + let mut peer = local_peer(&runtime, &binding); + peer.namespace = Some(other_namespace.token().into()); + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + true, + &binding, + ) + .await; + assert_refused(&observation, 0); + } + + #[tokio::test] + async fn runtime_request_admission_missing_metadata_claim_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let mut metadata = serde_json::to_value(local_peer(&runtime, &binding)).unwrap(); + metadata.as_object_mut().unwrap().remove("identity"); + let observation = observe_request(&runtime, metadata, true, &binding).await; + assert_refused(&observation, 0); + } + + #[tokio::test] + async fn runtime_request_admission_wrong_repository_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let peer = local_peer(&runtime, &binding); + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + false, + &binding, + ) + .await; + assert_refused(&observation, 1); + assert_eq!(observation.events, ["metadata", "repository"]); + } + + #[tokio::test] + async fn runtime_request_admission_missing_repository_claim_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let mut peer = local_peer(&runtime, &binding); + peer.repository = None; + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + true, + &binding, + ) + .await; + assert_refused_after_metadata(&observation, 1, 2); + assert_eq!(observation.events, ["metadata", "repository", "metadata"]); + } + + #[tokio::test] + async fn runtime_request_admission_other_repository_metadata_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let (_other_repository, other) = local_repository(); + let peer = local_peer(&runtime, &other); + assert_ne!( + other.identity().repository_id, + binding.identity().repository_id + ); + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + true, + &binding, + ) + .await; + assert_refused(&observation, 1); + assert_eq!(observation.events, ["metadata", "repository"]); + } + + #[tokio::test] + async fn runtime_request_admission_other_store_metadata_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let (_other_repository, other) = local_repository(); + let mut peer = local_peer(&runtime, &binding); + peer.repository.as_mut().unwrap().store_namespace = + other.identity().store_namespace.clone(); + assert_eq!( + peer.repository.as_ref().unwrap().repository_id, + binding.identity().repository_id + ); + assert_ne!( + other.identity().store_namespace, + binding.identity().store_namespace + ); + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + true, + &binding, + ) + .await; + assert_refused(&observation, 1); + assert_eq!(observation.events, ["metadata", "repository"]); + } + + #[tokio::test] + async fn runtime_request_admission_late_repository_claim_dispatches_after_refresh() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let peer = local_peer(&runtime, &binding); + let mut opening = peer.clone(); + opening.repository = None; + let observation = observe_metadata_sequence( + &runtime, + vec![ + serde_json::to_value(opening).unwrap(), + serde_json::to_value(peer).unwrap(), + ], + RepositoryRoute::Serving, + &binding, + ) + .await; + assert_eq!(observation.answer.unwrap()["result"], "fixture-answer"); + assert_eq!(observation.repository_probes, 1); + assert_eq!( + observation.events, + ["metadata", "repository", "metadata", "operation"] + ); + assert_eq!(observation.requests.len(), 3); + for metadata in &observation.requests[..2] { + assert_eq!(metadata.method, "GET"); + assert_eq!(metadata.path, "/runtime/identity"); + assert!(metadata.body.is_empty()); + assert!(!metadata.headers.contains_key(RUNTIME_IDENTITY_HEADER)); + assert!(!metadata.headers.contains_key(RUNTIME_REPOSITORY_HEADER)); + } + let operation = &observation.requests[2]; + assert_eq!(operation.method, "POST"); + assert_eq!(operation.path, "/mcp/tools/call"); + assert_eq!( + operation.headers.get(RUNTIME_REPOSITORY_HEADER), + Some(&binding.fingerprint()) + ); + let expected = runtime + .identity + .fingerprint(runtime.mode, runtime.namespace.as_ref().unwrap().token()); + assert_eq!( + operation.headers.get(RUNTIME_IDENTITY_HEADER), + Some(&expected) + ); + for request in &observation.requests { + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some(AUTHORIZATION) + ); + } + } + + #[tokio::test] + async fn runtime_request_admission_refreshed_other_repository_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let (_other_repository, other) = local_repository(); + let mut opening = local_peer(&runtime, &binding); + opening.repository = None; + let peer = local_peer(&runtime, &other); + let observation = observe_metadata_sequence( + &runtime, + vec![ + serde_json::to_value(opening).unwrap(), + serde_json::to_value(peer).unwrap(), + ], + RepositoryRoute::Serving, + &binding, + ) + .await; + assert_refused_after_metadata(&observation, 1, 2); + assert_eq!(observation.events, ["metadata", "repository", "metadata"]); + } + + #[tokio::test] + async fn runtime_request_admission_refreshed_other_source_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let mut opening = local_peer(&runtime, &binding); + opening.repository = None; + let mut peer = local_peer(&runtime, &binding); + peer.identity.commit_sha = "b".repeat(40); + let observation = observe_metadata_sequence( + &runtime, + vec![ + serde_json::to_value(opening).unwrap(), + serde_json::to_value(peer).unwrap(), + ], + RepositoryRoute::Serving, + &binding, + ) + .await; + assert_refused_after_metadata(&observation, 1, 2); + assert_eq!(observation.events, ["metadata", "repository", "metadata"]); + } + + #[tokio::test] + async fn runtime_request_admission_warming_repository_sends_no_operation() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let peer = local_peer(&runtime, &binding); + let observation = observe_metadata_sequence( + &runtime, + vec![serde_json::to_value(peer).unwrap()], + RepositoryRoute::Warming, + &binding, + ) + .await; + assert!(matches!( + observation.answer, + Err(DaemonCallError::Warming(_)) + )); + assert_eq!(observation.repository_probes, 1); + assert_eq!(observation.events, ["metadata", "repository"]); + assert_eq!(observation.requests.len(), 1); + assert_eq!(observation.requests[0].method, "GET"); + assert_eq!(observation.requests[0].path, "/runtime/identity"); + assert!(observation.requests[0].body.is_empty()); + } + + #[tokio::test] + async fn runtime_request_admission_hosted_clean_source_is_independent() { + let (_home, mut runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let mut peer = local_peer(&runtime, &binding); + peer.component = "hosted-daemon".into(); + peer.namespace = None; + peer.repository = None; + peer.identity.commit_sha = "b".repeat(40); + peer.identity.version = "0.9.0".into(); + peer.identity.require_managed().unwrap(); + runtime.namespace = Err("hosted caller has no local namespace".into()); + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + false, + &binding, + ) + .await; + assert_eq!(observation.answer.unwrap()["result"], "fixture-answer"); + assert_eq!(observation.repository_probes, 0); + assert_eq!(observation.events, ["metadata", "operation"]); + assert_eq!(observation.requests.len(), 2); + for request in &observation.requests { + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some(AUTHORIZATION) + ); + assert!(!request.headers.contains_key(RUNTIME_IDENTITY_HEADER)); + assert!(!request.headers.contains_key(RUNTIME_REPOSITORY_HEADER)); + } + assert_eq!(observation.requests[1].method, "POST"); + } + + #[tokio::test] + async fn runtime_request_admission_hosted_requires_a_qualified_mode_and_claim() { + let (_home, runtime) = local_runtime(); + let (_repository, binding) = local_repository(); + let mut qualified = local_peer(&runtime, &binding); + qualified.component = "hosted-daemon".into(); + qualified.namespace = None; + qualified.repository = None; + let mut unknown = qualified.clone(); + unknown.identity.source_known = false; + let mut dirty = qualified.clone(); + dirty.identity.source_dirty = true; + let mut development = qualified.clone(); + development.mode = RuntimeMode::Development; + let mut local_namespace = qualified.clone(); + local_namespace.namespace = Some(runtime.namespace.as_ref().unwrap().token().into()); + let mut wrong_schema = qualified.clone(); + wrong_schema.schema = "kin.runtime-service.v0".into(); + let mut wrong_component = qualified; + wrong_component.component = "hosted-supervisor".into(); + for (case, peer) in [ + ("unknown source", unknown), + ("dirty source", dirty), + ("development mode", development), + ("local namespace", local_namespace), + ("wrong schema", wrong_schema), + ("wrong component", wrong_component), + ] { + let observation = observe_request( + &runtime, + serde_json::to_value(peer).unwrap(), + true, + &binding, + ) + .await; + assert_refused(&observation, 0); + assert_eq!(observation.events, ["metadata"], "{case}"); + } + } +} diff --git a/crates/kin-mcp/src/declaration_coverage.rs b/crates/kin-mcp/src/declaration_coverage.rs index debaf1189..3ed4db9fe 100644 --- a/crates/kin-mcp/src/declaration_coverage.rs +++ b/crates/kin-mcp/src/declaration_coverage.rs @@ -523,6 +523,14 @@ pub(crate) fn observe_units( let mut measured_languages: Vec = Vec::new(); let mut residue: Vec = Vec::new(); let mut residue_total = 0usize; + // A declaration two census entries describe alike is one declaration. + let mut residue_seen: std::collections::BTreeSet<( + String, + u32, + String, + String, + Option, + )> = std::collections::BTreeSet::new(); let mut unparsed: Vec = Vec::new(); let mut unparsed_total = 0usize; let mut unmeasured: Vec = Vec::new(); @@ -616,6 +624,16 @@ pub(crate) fn observe_units( .map(Vec::as_slice) .unwrap_or_default(); for declaration in matching(&query.in_file(path), &unadmitted, outer) { + let key = ( + path.to_string(), + declaration.line, + declaration.name.clone(), + declaration.kind.label().to_string(), + declaration.owner.clone(), + ); + if !residue_seen.insert(key) { + continue; + } residue_total += 1; if residue.len() < LISTED_MAX { let mut row = json!({ @@ -892,6 +910,44 @@ pub(crate) fn read_unread_regions( } } +/// Keep only the listed unadmitted declarations whose module `keeps` admits, +/// so a lookup scoped to one part of the repository does not list what sits +/// outside it. A row whose module the records cannot name is passed as +/// `None`. The total drops by what was dropped, and a census left with +/// nothing unadmitted and nothing unparsed says it is complete. +pub(crate) fn scope_residue(reading: &mut Value, keeps: impl Fn(Option<&str>) -> bool) { + let Some(rows) = reading + .get_mut("matching_unadmitted") + .and_then(Value::as_array_mut) + else { + return; + }; + let before = rows.len(); + rows.retain(|row| keeps(row.get("module").and_then(Value::as_str))); + let dropped = (before - rows.len()) as u64; + if dropped == 0 { + return; + } + let total = reading + .get("matching_unadmitted_total") + .and_then(Value::as_u64) + .unwrap_or(before as u64) + .saturating_sub(dropped); + if total == 0 { + if let Some(map) = reading.as_object_mut() { + map.remove("matching_unadmitted"); + map.remove("matching_unadmitted_total"); + } + if reading["state"] == STATE_COMPLETE_EXCEPT + && reading.get("unparsed_files_total").is_none() + { + reading["state"] = json!(STATE_COMPLETE); + } + } else { + reading["matching_unadmitted_total"] = json!(total); + } +} + /// Name where each listed unadmitted declaration sits, from graph records /// alone: the dotted module of its file, and the entity that encloses it /// (`inside_id`) when exactly one entity of its owner's name is declared in @@ -1182,6 +1238,38 @@ fn plural(count: u64) -> &'static str { mod tests { use super::*; + /// A lookup scoped to one part of the repository lists only the + /// unadmitted declarations inside it, lowers the total by what it + /// dropped, and says complete once nothing unadmitted is left. + #[test] + fn a_scoped_lookup_lists_only_the_unadmitted_declarations_inside_its_scope() { + let mut reading = serde_json::json!({ + "state": STATE_COMPLETE_EXCEPT, + "matching_unadmitted": [ + {"name": "validate", "module": "app.routes"}, + {"name": "validate", "module": "tests.routes"}, + {"name": "validate"}, + ], + "matching_unadmitted_total": 5, + }); + scope_residue(&mut reading, |module| { + module.is_none_or(|module| module.starts_with("app")) + }); + assert_eq!(reading["matching_unadmitted"].as_array().unwrap().len(), 2); + assert_eq!(reading["matching_unadmitted_total"], 4); + assert_eq!(reading["state"], STATE_COMPLETE_EXCEPT); + + let mut reading = serde_json::json!({ + "state": STATE_COMPLETE_EXCEPT, + "matching_unadmitted": [{"name": "validate", "module": "tests.routes"}], + "matching_unadmitted_total": 1, + }); + scope_residue(&mut reading, |module| { + module.is_some_and(|module| module.starts_with("app")) + }); + assert!(reading.get("matching_unadmitted").is_none()); + assert_eq!(reading["state"], STATE_COMPLETE); + } fn unadmitted(name: &str, owner: Option<&str>, start: usize, end: usize) -> Unadmitted { Unadmitted { name: name.into(), diff --git a/crates/kin-mcp/src/handlers/common.rs b/crates/kin-mcp/src/handlers/common.rs index 076b62418..2ae012471 100644 --- a/crates/kin-mcp/src/handlers/common.rs +++ b/crates/kin-mcp/src/handlers/common.rs @@ -198,19 +198,24 @@ pub async fn fetch_spine_impact_typed_at( Err(e) => return SpineQuery::Unavailable(format!("failed to build reqwest client: {e}")), }; - let resp = match crate::daemon_delegate::with_auth( + let request = crate::daemon_delegate::with_auth( client.get(format!("{}/spine/impact", daemon_url.trim_end_matches('/'))), ) .query(&[ ("repo", repo_id), ("entity", &entity_id.to_string()), ("depth", &depth.to_string()), - ]) - .send() + ]); + let resp = match crate::daemon_delegate::send_admitted_daemon_request( + "spine impact", + daemon_url, + request, + std::time::Duration::from_secs(10), + ) .await { Ok(resp) => resp, - Err(e) => return SpineQuery::Unavailable(format!("spine request failed: {e}")), + Err(e) => return SpineQuery::Unavailable(format!("spine request failed: {e:?}")), }; match classify_spine_probe(true, Some(resp.status().as_u16())) { @@ -254,15 +259,20 @@ pub async fn fetch_spine_xref_at( Err(e) => return SpineQuery::Unavailable(format!("failed to build reqwest client: {e}")), }; - let resp = match crate::daemon_delegate::with_auth( + let request = crate::daemon_delegate::with_auth( client.get(format!("{}/spine/xref", daemon_url.trim_end_matches('/'))), ) - .query(&[("repo", repo_id), ("entity", &entity_id.to_string())]) - .send() + .query(&[("repo", repo_id), ("entity", &entity_id.to_string())]); + let resp = match crate::daemon_delegate::send_admitted_daemon_request( + "spine xref", + daemon_url, + request, + std::time::Duration::from_secs(10), + ) .await { Ok(resp) => resp, - Err(e) => return SpineQuery::Unavailable(format!("spine request failed: {e}")), + Err(e) => return SpineQuery::Unavailable(format!("spine request failed: {e:?}")), }; match classify_spine_probe(true, Some(resp.status().as_u16())) { @@ -4786,9 +4796,24 @@ fn imports_into<'r>( /// A committed generation for the census: the replayed state's tree, /// entities and relations. +/// +/// The census asks, for every focal, which edges land on it, and, for every +/// file it reads, which imports are written there. A replayed state keeps its +/// relations in one map keyed by relation id, so answering each question by a +/// pass over every relation made a guide of one committed change cost the +/// number of focals times the number of relations: on a repository of 120 +/// thousand relations, most of a minute. The relations are put in order once +/// here, by the entity they land on and by the files their import evidence +/// sits in, and each question reads its own few. Every list keeps the order +/// the map yields, so each answer is the one a full pass gave. struct StateCensus<'h> { state: &'h kin_model::graph::ResolvedGraphState, by_file: HashMap>, + /// The `Calls`, `Imports` and `References` edges landing on each entity. + uses_by_target: HashMap>, + /// The `Imports` edges with evidence in each file, each listed once per + /// file. + imports_by_file: HashMap<&'h str, Vec<&'h kin_model::Relation>>, } impl<'h> StateCensus<'h> { @@ -4802,7 +4827,46 @@ impl<'h> StateCensus<'h> { .push(entity.clone()); } } - Self { state, by_file } + let mut uses_by_target: HashMap> = HashMap::new(); + let mut imports_by_file: HashMap<&'h str, Vec<&'h kin_model::Relation>> = HashMap::new(); + for relation in state.relations.values() { + if !kin_db::StoredUse::KINDS.contains(&relation.kind) { + continue; + } + if let Some(target) = relation.dst.as_entity() { + uses_by_target.entry(target).or_default().push(relation); + } + if relation.kind != RelationKind::Imports { + continue; + } + for evidence in &relation.evidence { + let Some(span) = evidence.source_span.as_ref() else { + continue; + }; + let listed = imports_by_file.entry(span.file.0.as_str()).or_default(); + if !listed + .last() + .is_some_and(|last| std::ptr::eq(*last, relation)) + { + listed.push(relation); + } + } + } + Self { + state, + by_file, + uses_by_target, + imports_by_file, + } + } + + /// The `Calls`, `Imports` and `References` edges landing on `target`. + fn landing_on(&self, target: EntityId) -> impl Iterator + '_ { + self.uses_by_target + .get(&target) + .into_iter() + .flatten() + .copied() } } @@ -4819,11 +4883,11 @@ impl kin_db::call_site_escape::EscapeCensusGraph for StateCensus<'_> { } fn import_evidence_into(&self, target: EntityId) -> Option> { - Some(imports_into(self.state.relations.values(), target)) + Some(imports_into(self.landing_on(target), target)) } fn uses_into(&self, target: EntityId) -> Option> { - uses_into(self.state.relations.values(), target, |source| { + uses_into(self.landing_on(target), target, |source| { Ok::<_, std::convert::Infallible>( self.state .entities @@ -4859,12 +4923,134 @@ impl kin_db::call_site_escape::EscapeCensusGraph for StateCensus<'_> { &self, file: &kin_db::call_site_escape::CensusFile, ) -> Vec { - kin_index::written_imports(self.state.relations.values(), &file.path.0, |id| { + let written_here = self + .imports_by_file + .get(file.path.0.as_str()) + .into_iter() + .flatten() + .copied(); + kin_index::written_imports(written_here, &file.path.0, |id| { self.state.entities.get(&id).map(|entity| entity.kind) }) } } +#[cfg(test)] +mod state_census_tests { + use super::{imports_into, uses_into, StateCensus}; + use kin_db::call_site_escape::{CensusFile, EscapeCensusGraph}; + use kin_model::graph::ResolvedGraphState; + use kin_model::relation::{Relation, RelationEvidence, RelationOrigin}; + use kin_model::{ + EntityId, FilePathId, GraphNodeId, Hash256, LanguageId, RelationKind, SourceSpan, + }; + + fn span(file: &str, start: usize) -> SourceSpan { + SourceSpan { + file: FilePathId::new(file), + start_byte: start, + end_byte: start + 4, + start_line: 1, + start_col: 0, + end_line: 1, + end_col: 4, + } + } + + fn edge(kind: RelationKind, src: EntityId, dst: EntityId, sites: &[(&str, usize)]) -> Relation { + Relation { + id: kin_model::ids::RelationId::new(), + kind, + src: GraphNodeId::Entity(src), + dst: GraphNodeId::Entity(dst), + confidence: 1.0, + origin: RelationOrigin::Parsed, + created_in: None, + import_source: None, + evidence: sites + .iter() + .map(|(file, start)| RelationEvidence { + source_span: Some(span(file, *start)), + token: Some(format!("t{start}")), + ..RelationEvidence::default() + }) + .collect(), + } + } + + fn census_file(path: &str) -> CensusFile { + CensusFile { + path: FilePathId::new(path), + language: LanguageId::TypeScript, + blob: Hash256::from_bytes([0; 32]), + parsed: Some(true), + } + } + + /// The census of a replayed state answers each focal from the edges that + /// land on it, and each file from the imports written there, exactly as a + /// pass over every relation of the state answered them: the same rows in + /// the same order, for edges of every kind the census reads, edges it does + /// not read, edges whose evidence sits in several files or twice in one, + /// and an entity nothing lands on. + #[test] + fn a_replayed_state_census_answers_as_a_pass_over_every_relation_did() { + let focal = EntityId::new(); + let other = EntityId::new(); + let lonely = EntityId::new(); + let caller = EntityId::new(); + let mut state = ResolvedGraphState::default(); + for relation in [ + edge( + RelationKind::Imports, + caller, + focal, + &[("src/a.ts", 0), ("src/b.ts", 8)], + ), + edge( + RelationKind::Imports, + caller, + focal, + &[("src/a.ts", 16), ("src/a.ts", 32)], + ), + edge(RelationKind::Calls, caller, focal, &[("src/a.ts", 40)]), + edge(RelationKind::References, caller, focal, &[]), + edge(RelationKind::Contains, caller, focal, &[("src/a.ts", 48)]), + edge(RelationKind::Imports, caller, other, &[("src/b.ts", 56)]), + edge(RelationKind::Calls, focal, other, &[("src/c.ts", 64)]), + ] { + state.relations.insert(relation.id, relation); + } + let census = StateCensus::new(&state); + let span_of = |_: EntityId| Ok::<_, std::convert::Infallible>(None); + for target in [focal, other, lonely] { + assert_eq!( + census.import_evidence_into(target), + Some(imports_into(state.relations.values(), target)), + ); + assert_eq!( + census.uses_into(target), + uses_into(state.relations.values(), target, span_of), + ); + } + assert_eq!( + census + .import_evidence_into(focal) + .map(|evidence| evidence.len()), + Some(4) + ); + for path in ["src/a.ts", "src/b.ts", "src/c.ts", "src/none.ts"] { + let file = census_file(path); + assert_eq!( + census.imports_written_in(&file), + kin_index::written_imports(state.relations.values(), path, |_| None), + "{path}" + ); + } + assert_eq!(census.imports_written_in(&census_file("src/a.ts")).len(), 3); + } +} + /// Read the exact graph span, refusing an oversized body before copying it. pub fn read_entity_source_exact( held: &HeldSourceAuthority<'_, G>, @@ -7858,3 +8044,63 @@ mod reference_site_tests { } } } + +#[cfg(test)] +mod state_census_import_index_tests { + use super::{imports_into, StateCensus}; + use kin_db::call_site_escape::EscapeCensusGraph; + use kin_model::{EntityId, GraphNodeId, Relation, RelationKind, RelationOrigin}; + + fn edge(kind: RelationKind, src: EntityId, dst: EntityId, rule: &str) -> Relation { + Relation { + id: kin_model::RelationId::new(), + kind, + src: GraphNodeId::Entity(src), + dst: GraphNodeId::Entity(dst), + confidence: 1.0, + origin: RelationOrigin::Parsed, + created_in: None, + import_source: None, + evidence: vec![kin_model::RelationEvidence { + parser_rule: Some(rule.to_string()), + ..Default::default() + }], + } + } + + /// The census reads each focal's imports from an index built once, and + /// it answers exactly what filtering every relation of the generation + /// answered. + #[test] + fn a_replayed_census_reads_the_imports_into_a_focal_from_one_index() { + let (focal, other, importer) = (EntityId::new(), EntityId::new(), EntityId::new()); + let mut state = kin_model::graph::ResolvedGraphState::default(); + for relation in [ + edge(RelationKind::Imports, importer, focal, "first"), + edge(RelationKind::Imports, other, focal, "second"), + edge(RelationKind::Calls, importer, focal, "a call"), + edge(RelationKind::Imports, importer, other, "elsewhere"), + ] { + state.relations.insert(relation.id, relation); + } + let census = StateCensus::new(&state); + let rules = |evidence: Vec| { + let mut rules: Vec = evidence + .into_iter() + .filter_map(|evidence| evidence.parser_rule) + .collect(); + rules.sort(); + rules + }; + for target in [focal, other, EntityId::new()] { + assert_eq!( + rules(census.import_evidence_into(target).unwrap()), + rules(imports_into(state.relations.values(), target)), + ); + } + assert_eq!( + rules(census.import_evidence_into(focal).unwrap()), + vec!["first".to_string(), "second".to_string()] + ); + } +} diff --git a/crates/kin-mcp/src/handlers/entities.rs b/crates/kin-mcp/src/handlers/entities.rs index 135a5277b..3f53bdc20 100644 --- a/crates/kin-mcp/src/handlers/entities.rs +++ b/crates/kin-mcp/src/handlers/entities.rs @@ -309,6 +309,11 @@ pub(crate) fn search_names( if narrowed { &name_matched } else { &entities }, ); crate::declaration_coverage::place_residue(&mut reading, store); + if let Some(within) = &within { + crate::declaration_coverage::scope_residue(&mut reading, |module| { + within.lists_module(module) + }); + } let held = crate::handlers::common::HeldSourceAuthority::new(store, repository_authority); crate::declaration_coverage::name_reexports(&mut reading, store, &|module| { crate::handlers::common::module_surface_text(&held, module, source_scope) @@ -11483,7 +11488,10 @@ missing dependencies remain explicit. Follow `enrichment.page.next_cursor` with dependencies; any observation change or restart rejects continuation. `page.complete` means \ all requested metadata rows fit this page, not that enrichment completed. Cached responses \ carry `enrichment.current=false`. This tool requires the Kin daemon; it does not invent \ -an offline approximation."; +an offline approximation. The answer also lists, under `_kin.flags`, open flags a \ +reviewer set on entities in this repository, each with the reviewer's note. Follow \ +`_kin.flags_next_cursor` as `flags_cursor` for the next 25 flags, separately from the \ +enrichment cursor. Closing or replacing the cursor's flag requires restarting the listing."; #[derive(Debug, Clone, PartialEq, Eq)] pub struct GraphStatusObservation { diff --git a/crates/kin-mcp/src/handlers/mod.rs b/crates/kin-mcp/src/handlers/mod.rs index d48de126c..d57c1e49e 100644 --- a/crates/kin-mcp/src/handlers/mod.rs +++ b/crates/kin-mcp/src/handlers/mod.rs @@ -12669,6 +12669,7 @@ mod tests { kind: kin_model::provenance::ActorKind::Human, display_name: "reviewer".into(), external_refs: vec![], + agent_session: None, }; store.actors_by_id.insert(actor.actor_id, actor); } diff --git a/crates/kin-mcp/src/handlers/sessions.rs b/crates/kin-mcp/src/handlers/sessions.rs index e8826e67f..1d16738af 100644 --- a/crates/kin-mcp/src/handlers/sessions.rs +++ b/crates/kin-mcp/src/handlers/sessions.rs @@ -124,6 +124,10 @@ pub async fn handle_session_start( let transport = parse_transport(transport_str); let pid = args.get("pid").and_then(|v| v.as_u64()).map(|p| p as u32); + let watch_pid = args + .get("watch_pid") + .and_then(|v| v.as_u64()) + .and_then(|p| u32::try_from(p).ok()); let cwd = PathBuf::from(&cwd_str); let capabilities = parse_capabilities(args); @@ -134,6 +138,7 @@ pub async fn handle_session_start( &client_name, transport_str, pid, + watch_pid, &cwd_str, capabilities.as_ref(), ) @@ -1533,11 +1538,12 @@ pub fn reject_truncated_bodies( /// it recorded before this argument existed, so a caller that sends nothing /// sees no change at all. pub fn commit_message_argument(arguments: &HashMap) -> Option { - let summary = arguments - .get("summary") - .and_then(serde_json::Value::as_str)? - .trim(); - (!summary.is_empty()).then(|| summary.to_string()) + ["summary", "message"] + .iter() + .filter_map(|key| arguments.get(*key).and_then(serde_json::Value::as_str)) + .map(str::trim) + .find(|summary| !summary.is_empty()) + .map(str::to_string) } pub const MUTATE_DESC: &str = "\ diff --git a/crates/kin-mcp/src/handlers/work.rs b/crates/kin-mcp/src/handlers/work.rs index ca9df669d..3a6c4cb82 100644 --- a/crates/kin-mcp/src/handlers/work.rs +++ b/crates/kin-mcp/src/handlers/work.rs @@ -483,6 +483,7 @@ pub fn handle_annotation_add( authored_by: kin_model::IdentityRef::assistant("mcp-client"), created_at: Timestamp::now(), staleness: kin_model::StalenessState::Fresh, + review: None, }; store diff --git a/crates/kin-mcp/src/lib.rs b/crates/kin-mcp/src/lib.rs index 69310763f..b2a33b99e 100644 --- a/crates/kin-mcp/src/lib.rs +++ b/crates/kin-mcp/src/lib.rs @@ -44,6 +44,7 @@ pub mod replies; pub(crate) mod reply_budget; pub mod request_schema; pub mod requests; +pub mod review_flags; pub mod routed; #[cfg(test)] pub(crate) mod schema_check; @@ -103,8 +104,9 @@ pub use handlers::LocalRepositoryAuthorityBinding; pub use negative::NEGATIVE_KEY; pub use outside_graph::OUTSIDE_GRAPH_KEY; pub use server::{ - present_for_client, process_daemon_message, process_message, run_stdio, run_stdio_daemon, - BoundRepo, McpServerConfig, RepoBinder, SessionAuthorityMode, WorkspaceBinding, + present_flagged_for_client, present_for_client, process_daemon_message, process_message, + run_stdio, run_stdio_daemon, BoundRepo, McpServerConfig, RepoBinder, SessionAuthorityMode, + WorkspaceBinding, }; pub use session::{ AssistantSession, CommitRefusal, CommitRefusalCode, CoordinationEnforcementMode, diff --git a/crates/kin-mcp/src/plain_reply.rs b/crates/kin-mcp/src/plain_reply.rs index 739b354b2..3c9a8f613 100644 --- a/crates/kin-mcp/src/plain_reply.rs +++ b/crates/kin-mcp/src/plain_reply.rs @@ -480,6 +480,12 @@ fn plain_answer_with_details(mut machine: Map, tool: &str, detail } if let Some(unadmitted) = unadmitted { compact_unadmitted_declarations(&mut plain, &unadmitted); + // After grouping, which reads rows aligned with the census: rows still + // alike, such as one callback declared in sixteen tests, are said once + // in a short reply. Details lists every declaration as it is. + if let Some(Value::Array(rows)) = plain.get_mut("not_held") { + *rows = crate::answer_account::said_once(std::mem::take(rows)); + } } hold_to_account(tool, &account, &plain); plain @@ -4510,6 +4516,7 @@ fn plain_name_search( folded_attributes, folded_total, not_held_total, + not_held_listed, closure, artifacts, } = NameSearchAccount::read(&machine, &verdict, &rows, details, plain_not_held_row); @@ -4553,10 +4560,10 @@ fn plain_name_search( "Answer: no entity Kin holds is named {query}, but {} named like it {} where Kin keeps no entity; not_held lists {}. Kin cannot read {} on its own; locate with mode literal finds recorded code that mentions it.", counted(not_held_total, "declaration", "declarations"), plural(not_held_total, "sits", "sit"), - listed_of(not_held.len() as u64, not_held_total), + listed_of(not_held_listed, not_held_total), plural(not_held_total, "it", "one"), ); - if let Some(all) = closure.stated(&query, 0, not_held.len() as u64, folded_total) { + if let Some(all) = closure.stated(&query, 0, not_held_listed, folded_total) { text.push(' '); text.push_str(&all); } @@ -4575,7 +4582,7 @@ fn plain_name_search( let stated = closure.stated( &query, rows.len() as u64, - not_held.len() as u64, + not_held_listed, folded_total, ); // A reply of nothing but declarations named exactly so leads with @@ -4596,7 +4603,7 @@ fn plain_name_search( " {} more named like it {} where Kin keeps no entity; not_held lists {}.", counted(not_held_total, "declaration", "declarations"), plural(not_held_total, "sits", "sit"), - listed_of(not_held.len() as u64, not_held_total), + listed_of(not_held_listed, not_held_total), )); } if let (Some(all), false) = (&stated, leads) { diff --git a/crates/kin-mcp/src/plain_reply_tests.rs b/crates/kin-mcp/src/plain_reply_tests.rs index 14a836ffb..5893b7a92 100644 --- a/crates/kin-mcp/src/plain_reply_tests.rs +++ b/crates/kin-mcp/src/plain_reply_tests.rs @@ -1996,6 +1996,38 @@ fn name_lookup(query: &str, rows: &[&str], not_held: &[&str]) -> Map, + /// Open flags the answer reaches beyond the ones listed. + #[serde(default, skip_serializing_if = "is_zero")] + pub more: usize, + /// Every currently open flag, including earlier status pages. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, + /// Continue a status flag listing independently of its enrichment page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// The answer is the repository's whole list of open flags, not the flags + /// on entities it returned. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub listing: bool, +} + +fn is_zero(count: &usize) -> bool { + *count == 0 +} + +impl Delivered { + /// Whether there is anything to write into a reply. With no flag to + /// list there is not, so while no flag is open every answer is served + /// byte for byte as it was. + pub fn is_silent(&self) -> bool { + self.flags.is_empty() + } + + /// Fold what another call of the same `tools/call` reached into this. + fn absorb(&mut self, other: Delivered) { + self.listing |= other.listing; + self.more += other.more; + if other.next_cursor.is_some() { + self.next_cursor = other.next_cursor; + } + if other.total.is_some() { + self.total = other.total; + } + for flag in other.flags { + if !self.flags.iter().any(|held| held.entity == flag.entity) { + self.flags.push(flag); + } + } + } +} + +/// Put what an answer reaches beside a daemon's tool result. A result that +/// reaches no flag is left exactly as it was. +pub fn attach(result: &mut Value, delivered: &Delivered) { + if delivered.is_silent() { + return; + } + let Some(result) = result.as_object_mut() else { + return; + }; + let Ok(value) = serde_json::to_value(delivered) else { + return; + }; + let meta = result + .entry("_meta") + .or_insert_with(|| Value::Object(Map::new())); + if let Some(meta) = meta.as_object_mut() { + meta.insert(META_KEY.to_string(), value); + } +} + +/// Take what a daemon's tool result says its answer reaches, leaving the +/// result as a daemon that knows no flags would have sent it. +pub fn take(result: &mut Value) -> Option { + let fields = result.as_object_mut()?; + let meta = fields.get_mut("_meta")?.as_object_mut()?; + let delivered = meta.remove(META_KEY)?; + if meta.is_empty() { + fields.remove("_meta"); + } + serde_json::from_value(delivered).ok() +} + +tokio::task_local! { + static REACHED: Arc>; +} + +/// Run one `tools/call`, and hand back with it the flags its daemon answers +/// reached. A call can ask the daemon more than once, a write that begins, +/// stages and commits for one, and what each answer reached is kept once. +pub async fn collecting(call: F) -> (F::Output, Delivered) { + let reached = Arc::new(Mutex::new(Delivered::default())); + let output = REACHED.scope(Arc::clone(&reached), call).await; + let delivered = reached + .lock() + .map(|held| held.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()); + (output, delivered) +} + +/// Keep what one daemon answer reached for the `tools/call` being run. +/// Outside [`collecting`] there is no reply to write it into, and it is +/// dropped. +pub(crate) fn note_reached(delivered: Delivered) { + let _ = REACHED.try_with(|reached| { + reached + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .absorb(delivered); + }); +} + +/// A reviewer's note as the middle of a sentence: one line, with no closing +/// punctuation of its own. +fn spoken_note(note: &str) -> String { + let line = note.split_whitespace().collect::>().join(" "); + line.trim_end_matches(['.', '!', '?', ';', ':', ',']) + .to_string() +} + +/// One flag, in words. +fn sentence(flag: &FlagNotice) -> String { + let note = spoken_note(&flag.note); + let said = if note.is_empty() { + format!("A reviewer flagged {} and left no note.", flag.name) + } else { + format!("A reviewer flagged {}: {note}.", flag.name) + }; + if flag.source_unavailable { + format!( + "{said} Kin could not confirm its current source; the flag is still open. Address \ + it before committing." + ) + } else if flag.changed_since { + format!( + "{said} It has changed since the flag was set, and the flag is still open; check \ + that the change answers it before committing." + ) + } else if note.is_empty() { + format!("{said} Look at it again before committing.") + } else { + format!("{said} Address it before committing.") + } +} + +fn entities(count: usize) -> String { + if count == 1 { + "1 entity".to_string() + } else { + format!("{count} entities") + } +} + +/// What an answer's flags say, as the sentences an agent acts on. `None` when +/// there is nothing to say. +pub fn advice(delivered: &Delivered) -> Option { + let total = delivered + .total + .unwrap_or(delivered.flags.len() + delivered.more); + if delivered.listing { + if total == 0 { + return None; + } + let shown = delivered.flags.len(); + let listed = if delivered.more == 0 && total == shown { + "Each is in _kin.flags with the reviewer's note.".to_string() + } else { + format!("This page lists {shown} in _kin.flags with the reviewer's note.") + }; + let continuation = if delivered.next_cursor.is_some() { + " Continue status with flags_cursor from _kin.flags_next_cursor." + } else { + "" + }; + return Some(format!( + "A reviewer flagged {} in this repository. {listed} Address them before committing.{continuation}", + entities(total) + )); + } + if delivered.flags.is_empty() { + return None; + } + let mut said: Vec = delivered + .flags + .iter() + .take(SPOKEN_FLAGS_MAX) + .map(sentence) + .collect(); + let unspoken = total.saturating_sub(said.len()); + if unspoken > 0 { + said.push(if unspoken == 1 { + "1 more entity in this answer is flagged; status lists every open flag.".to_string() + } else { + format!( + "{unspoken} more entities in this answer are flagged; status lists every open \ + flag." + ) + }); + } + Some(said.join(" ")) +} + +fn presented_cursor(delivered: &Delivered, handles: Option<&SessionHandles>) -> Option { + let cursor = delivered.next_cursor.as_ref()?; + let mut value = json!({"flags_next_cursor": cursor}); + if let Some(handles) = handles { + handles.shorten_value(&mut value); + } + value["flags_next_cursor"].as_str().map(str::to_string) +} + +/// The flags as `_kin.flags` lists them, each entity under the handle this +/// connection serves it by. +fn listed(delivered: &Delivered, handles: Option<&SessionHandles>) -> Value { + Value::Array( + delivered + .flags + .iter() + .map(|flag| { + let mut flag = flag.clone(); + if let Some(handles) = handles { + flag.entity = handles.handle_for_uuid(&flag.entity); + } + serde_json::to_value(flag).unwrap_or(Value::Null) + }) + .collect(), + ) +} + +/// Text answers have no `_kin.flags` block, so every handed note must be +/// written here even when the JSON advice would only summarize it. +fn text_notices(delivered: &Delivered, handles: Option<&SessionHandles>) -> String { + let mut notices = Vec::new(); + if delivered.listing { + notices.push(format!( + "A reviewer flagged {} in this repository.", + entities( + delivered + .total + .unwrap_or(delivered.flags.len() + delivered.more) + ) + )); + } + for flag in &delivered.flags { + let entity = handles + .map(|handles| handles.handle_for_uuid(&flag.entity)) + .unwrap_or_else(|| flag.entity.clone()); + let note = if flag.note.trim().is_empty() { + "The reviewer left no note." + } else { + &flag.note + }; + let action = if flag.source_unavailable { + "Kin could not confirm its current source; the flag is still open. Address it \ + before committing." + } else if flag.changed_since { + "It has changed since the flag was set, and the flag is still open; check that the \ + change answers it before committing." + } else { + "Address it before committing." + }; + notices.push(format!( + "Reviewer flag on {} ({entity}), by {}:\n{note}\n{action}", + flag.name, flag.by + )); + } + if delivered.more > 0 { + notices.push(format!( + "{} more flagged {} not listed here; status lists open flags.", + delivered.more, + if delivered.more == 1 { + "entity is" + } else { + "entities are" + } + )); + } + if let Some(cursor) = presented_cursor(delivered, handles) { + notices.push(format!( + "Continue status with flags_cursor: {cursor}. This is separate from the enrichment cursor." + )); + } + notices.join("\n\n") +} + +/// Write the flags into one JSON answer's `_kin` block, after whatever advice +/// it already carries. +fn stamp_payload( + payload: &mut Map, + delivered: &Delivered, + handles: Option<&SessionHandles>, +) { + let key = crate::envelope::ENVELOPE_KEY; + let envelope = payload + .entry(key) + .or_insert_with(|| Value::Object(Map::new())); + let Some(envelope) = envelope.as_object_mut() else { + return; + }; + envelope.insert("flags".to_string(), listed(delivered, handles)); + if let Some(cursor) = presented_cursor(delivered, handles) { + envelope.insert("flags_next_cursor".to_string(), json!(cursor)); + } + if let Some(flagged) = advice(delivered) { + let said = match envelope.get("advice").and_then(Value::as_str) { + Some(existing) if !existing.trim().is_empty() => format!("{existing} {flagged}"), + _ => flagged, + }; + envelope.insert("advice".to_string(), json!(said)); + } +} + +/// Write the flags an answer reached into the reply the agent reads. +/// +/// Runs last, on the reply as it ships. A JSON answer gets `_kin.flags` and +/// the sentences in `_kin.advice`; an answer in words gets every handed note +/// as its first lines. Prompt messages and refusals carry the same notes. A +/// reply that reached no flag is left byte for byte as it was. +pub fn stamp( + response: &mut JsonRpcResponse, + delivered: &Delivered, + handles: Option<&SessionHandles>, +) { + if delivered.is_silent() { + return; + } + if let Some(error) = response.error.as_mut() { + error.message = format!("{}\n\n{}", text_notices(delivered, handles), error.message); + } + let Some(result) = response.result.as_mut() else { + return; + }; + if let Some(structured) = result + .get_mut("structuredContent") + .and_then(Value::as_object_mut) + { + stamp_payload(structured, delivered, handles); + } + if let Some(text) = result + .pointer_mut("/messages/0/content/text") + .filter(|text| text.is_string()) + { + *text = Value::String(format!( + "{}\n\n{}", + text_notices(delivered, handles), + text.as_str().unwrap_or_default() + )); + return; + } + let Some(block) = result + .get_mut("content") + .and_then(Value::as_array_mut) + .and_then(|content| content.first_mut()) + else { + return; + }; + let Some(text) = block.get("text").and_then(Value::as_str) else { + return; + }; + let stamped = match serde_json::from_str::(text) { + Ok(Value::Object(mut payload)) => { + stamp_payload(&mut payload, delivered, handles); + match crate::budget::render_in_format_of(&Value::Object(payload), text) { + Ok(rendered) => rendered, + Err(_) => return, + } + } + _ => format!("{}\n\n{text}", text_notices(delivered, handles)), + }; + block["text"] = Value::String(stamped); +} + +#[cfg(test)] +mod tests { + use super::*; + + const SEND: &str = "50ba786c-1111-4222-8333-444455556666"; + const NOT_FOUND: &str = "7c0ffee0-1111-4222-8333-444455556666"; + + fn flag(entity: &str, name: &str, note: &str) -> FlagNotice { + FlagNotice { + entity: entity.to_string(), + name: name.to_string(), + note: note.to_string(), + by: "Ada Reviewer".to_string(), + at: "2026-10-13T23:40:00Z".to_string(), + changed_since: false, + source_unavailable: false, + } + } + + fn reached(flags: Vec) -> Delivered { + Delivered { + flags, + more: 0, + total: None, + next_cursor: None, + listing: false, + } + } + + fn reply(text: &str) -> JsonRpcResponse { + JsonRpcResponse::success( + Some(json!(1)), + json!({"content": [{"type": "text", "text": text}]}), + ) + } + + fn text_of(response: &JsonRpcResponse) -> String { + response.result.as_ref().unwrap()["content"][0]["text"] + .as_str() + .unwrap() + .to_string() + } + + #[test] + fn a_flag_is_said_in_one_sentence_that_names_the_entity_and_the_note() { + let noted = flag(SEND, "ServerReply.send", "a second send must fail loudly."); + assert_eq!( + sentence(¬ed), + "A reviewer flagged ServerReply.send: a second send must fail loudly. \ + Address it before committing." + ); + let bare = flag(SEND, "ServerReply.send", " "); + assert_eq!( + sentence(&bare), + "A reviewer flagged ServerReply.send and left no note. Look at it again before \ + committing." + ); + let mut moved = flag(SEND, "ServerReply.send", "throw\non a second send"); + moved.changed_since = true; + let said = sentence(&moved); + assert!( + said.starts_with("A reviewer flagged ServerReply.send: throw on a second send. "), + "{said}" + ); + assert!(said.contains("changed since the flag was set"), "{said}"); + assert!(said.contains("still open"), "{said}"); + + moved.source_unavailable = true; + let said = sentence(&moved); + assert!( + said.contains("could not confirm its current source"), + "{said}" + ); + assert!(!said.contains("has changed"), "{said}"); + } + + #[test] + fn a_json_answer_carries_the_flags_in_its_envelope_and_its_advice() { + let mut response = reply( + &json!({ + "_kin": {"meaning": "Answer: 1 declaration.", "verdict": "complete"}, + "results": [{"entity_id": SEND, "name": "ServerReply.send"}], + }) + .to_string(), + ); + let delivered = reached(vec![flag( + SEND, + "ServerReply.send", + "throw on a second send", + )]); + stamp(&mut response, &delivered, None); + let text = text_of(&response); + // The advice is the first thing the answer says. + assert!( + text.starts_with("{\"_kin\":{\"advice\":\"A reviewer flagged ServerReply.send: "), + "{text}" + ); + let payload: Value = serde_json::from_str(&text).unwrap(); + assert_eq!( + payload["_kin"]["flags"], + json!([{ + "entity": SEND, "name": "ServerReply.send", "note": "throw on a second send", + "by": "Ada Reviewer", "at": "2026-10-13T23:40:00Z", + }]) + ); + assert_eq!(payload["_kin"]["verdict"], "complete"); + assert_eq!(payload["_kin"]["meaning"], "Answer: 1 declaration."); + assert_eq!(payload["results"][0]["entity_id"], SEND); + } + + #[test] + fn a_flag_names_its_entity_by_the_handle_the_connection_serves() { + let handles = SessionHandles::default(); + let short = handles.handle_for_uuid(SEND); + assert_ne!(short, SEND); + let mut response = reply(&json!({"_kin": {"meaning": "m"}}).to_string()); + stamp( + &mut response, + &reached(vec![flag(SEND, "ServerReply.send", "n")]), + Some(&handles), + ); + let payload: Value = serde_json::from_str(&text_of(&response)).unwrap(); + assert_eq!(payload["_kin"]["flags"][0]["entity"], json!(short)); + } + + #[test] + fn advice_an_answer_already_gives_stays_first() { + let mut response = reply( + &json!({"_kin": {"advice": "This answer is from another repository."}}).to_string(), + ); + stamp( + &mut response, + &reached(vec![flag(SEND, "ServerReply.send", "n")]), + None, + ); + let payload: Value = serde_json::from_str(&text_of(&response)).unwrap(); + assert_eq!( + payload["_kin"]["advice"], + "This answer is from another repository. A reviewer flagged ServerReply.send: n. \ + Address it before committing." + ); + } + + #[test] + fn an_answer_in_words_says_the_flag_first_and_structured_content_agrees() { + let mut response = JsonRpcResponse::success( + Some(json!(1)), + json!({ + "content": [{"type": "text", "text": "ServerReply.send method"}], + "structuredContent": {"_kin": {"verdict": "complete"}}, + }), + ); + stamp( + &mut response, + &reached(vec![flag(SEND, "ServerReply.send", "n")]), + None, + ); + assert_eq!( + text_of(&response), + format!( + "Reviewer flag on ServerReply.send ({SEND}), by Ada Reviewer:\nn\n\ + Address it before committing.\n\nServerReply.send method" + ) + ); + let structured = &response.result.as_ref().unwrap()["structuredContent"]; + assert_eq!(structured["_kin"]["flags"][0]["name"], "ServerReply.send"); + assert_eq!(structured["_kin"]["verdict"], "complete"); + } + + #[test] + fn a_text_answer_writes_every_handed_note_with_its_entity_handle() { + let handles = SessionHandles::default(); + let flags: Vec = (0..ANSWER_FLAGS_MAX) + .map(|index| { + flag( + &format!("0000000{index}-1111-4222-8333-444455556666"), + &format!("entity_{index}"), + &format!("note_{index}\nKeep this second line."), + ) + }) + .collect(); + let mut response = reply("Answer (complete): five declarations."); + stamp(&mut response, &reached(flags.clone()), Some(&handles)); + let text = text_of(&response); + for flag in flags { + assert!(text.contains(&flag.note), "{text}"); + assert!( + text.contains(&handles.handle_for_uuid(&flag.entity)), + "{text}" + ); + assert!(!text.contains(&flag.entity), "{text}"); + } + assert!(text.ends_with("Answer (complete): five declarations.")); + } + + #[test] + fn a_text_listing_writes_notes_instead_of_pointing_at_an_absent_json_block() { + let mut listing = reached(vec![ + flag(SEND, "ServerReply.send", "throw on a second send"), + flag(NOT_FOUND, "notFound", "check the fallback"), + ]); + listing.listing = true; + listing.more = 1; + let mut response = reply("Graph health is settled."); + stamp(&mut response, &listing, None); + let text = text_of(&response); + assert!( + text.starts_with("A reviewer flagged 3 entities in this repository."), + "{text}" + ); + assert!(text.contains("throw on a second send"), "{text}"); + assert!(text.contains("check the fallback"), "{text}"); + assert!( + text.contains("1 more flagged entity is not listed here"), + "{text}" + ); + assert!(!text.contains("_kin.flags"), "{text}"); + } + + #[test] + fn a_status_flag_cursor_is_separate_from_enrichment_and_reads_back_under_handles() { + let handles = SessionHandles::default(); + let mut listing = reached(vec![flag(SEND, "ServerReply.send", "n")]); + listing.listing = true; + listing.more = 1; + listing.total = Some(27); + listing.next_cursor = Some(format!("review-flags:{NOT_FOUND}")); + let mut response = reply( + &json!({ + "_kin": {"meaning": "Health report."}, + "enrichment": {"page": {"next_cursor": "metadata-page"}}, + }) + .to_string(), + ); + stamp(&mut response, &listing, Some(&handles)); + let payload: Value = serde_json::from_str(&text_of(&response)).unwrap(); + let cursor = payload["_kin"]["flags_next_cursor"].as_str().unwrap(); + assert_ne!(cursor, listing.next_cursor.as_deref().unwrap()); + assert_eq!( + payload["enrichment"]["page"]["next_cursor"], + "metadata-page" + ); + assert!(payload["_kin"]["advice"] + .as_str() + .unwrap() + .contains("27 entities")); + let mut arguments = json!({"flags_cursor": cursor}); + assert!(handles.expand_arguments(&mut arguments)); + assert_eq!(arguments["flags_cursor"], listing.next_cursor.unwrap()); + } + + #[test] + fn a_prompt_message_carries_every_handed_note() { + let delivered = reached(vec![flag( + SEND, + "ServerReply.send", + "throw on a second send", + )]); + let mut response = JsonRpcResponse::success( + Some(json!(1)), + json!({ + "description": "Kin's answer", + "messages": [{"role": "user", "content": { + "type": "text", "text": "Who calls send?\n\nKin's answer." + }}], + }), + ); + stamp(&mut response, &delivered, None); + let result = response.result.unwrap(); + let text = result + .pointer("/messages/0/content/text") + .unwrap() + .as_str() + .unwrap(); + assert!(text.contains("throw on a second send"), "{text}"); + assert!(text.contains(SEND), "{text}"); + assert!(text.ends_with("Who calls send?\n\nKin's answer."), "{text}"); + assert_eq!(result["messages"][0]["role"], "user"); + assert_eq!(result["description"], "Kin's answer"); + } + + #[test] + fn a_refused_composite_call_still_carries_the_flags_its_subcalls_handed_back() { + let mut response = JsonRpcResponse::error(Some(json!(1)), -32602, "ask failed".into()); + stamp( + &mut response, + &reached(vec![flag( + SEND, + "ServerReply.send", + "throw on a second send", + )]), + None, + ); + let error = response.error.unwrap(); + assert_eq!(error.code, -32602); + assert!( + error.message.contains("throw on a second send"), + "{}", + error.message + ); + assert!(error.message.ends_with("ask failed"), "{}", error.message); + assert!(response.result.is_none()); + } + + #[test] + fn an_answer_that_reached_no_flag_is_left_as_it_was() { + let original = json!({"_kin": {"meaning": "m"}, "results": []}).to_string(); + let mut response = reply(&original); + stamp(&mut response, &Delivered::default(), None); + assert_eq!(text_of(&response), original); + } + + #[test] + fn the_listing_counts_every_open_flag_and_says_so_when_there_are_none() { + let mut listing = reached(vec![ + flag(SEND, "ServerReply.send", "n"), + flag(NOT_FOUND, "notFound", ""), + ]); + listing.listing = true; + assert_eq!( + advice(&listing).unwrap(), + "A reviewer flagged 2 entities in this repository. Each is in _kin.flags with the \ + reviewer's note. Address them before committing." + ); + listing.more = 30; + assert!(advice(&listing) + .unwrap() + .starts_with("A reviewer flagged 32 entities in this repository. This page lists 2")); + + // With none open there is nothing to list, and the answer is left as + // it was. + let none = Delivered { + listing: true, + ..Default::default() + }; + assert!(none.is_silent()); + assert_eq!(advice(&none), None); + let original = json!({"_kin": {"meaning": "Health report."}}).to_string(); + let mut response = reply(&original); + stamp(&mut response, &none, None); + assert_eq!(text_of(&response), original); + } + + #[test] + fn flags_past_the_spoken_ones_are_counted_not_dropped() { + let flags: Vec = (0..5) + .map(|index| { + flag( + &format!("0000000{index}-1111-4222-8333-444455556666"), + &format!("entity_{index}"), + "n", + ) + }) + .collect(); + let mut delivered = reached(flags); + delivered.more = 2; + let said = advice(&delivered).unwrap(); + assert!( + said.contains("entity_0") && said.contains("entity_2"), + "{said}" + ); + assert!(!said.contains("entity_3"), "{said}"); + assert!( + said.ends_with( + "4 more entities in this answer are flagged; status lists every open flag." + ), + "{said}" + ); + } + + #[test] + fn the_daemon_hands_flags_back_beside_the_answer_and_the_server_takes_them() { + let answer = json!({"content": [{"type": "text", "text": "{}"}]}); + let mut sent = answer.clone(); + attach(&mut sent, &Delivered::default()); + assert_eq!(sent, answer, "an answer that reached no flag is unchanged"); + + let delivered = reached(vec![flag(SEND, "ServerReply.send", "n")]); + attach(&mut sent, &delivered); + assert_eq!(sent["content"], answer["content"]); + assert!(sent["_meta"][META_KEY].is_object()); + // An older server reads the same result and ignores what it does not know. + let read: crate::ToolCallResult = serde_json::from_value(sent.clone()).unwrap(); + assert_eq!(read.content.len(), 1); + + assert_eq!(take(&mut sent), Some(delivered)); + assert_eq!(sent, answer); + assert_eq!(take(&mut sent), None); + } + + #[tokio::test] + async fn one_call_keeps_what_each_daemon_answer_reached_once() { + let ((), delivered) = collecting(async { + note_reached(reached(vec![flag(SEND, "ServerReply.send", "n")])); + note_reached(reached(vec![ + flag(SEND, "ServerReply.send", "n"), + flag(NOT_FOUND, "notFound", ""), + ])); + }) + .await; + assert_eq!(delivered.flags.len(), 2); + // Outside a call there is nothing to write into, and nothing is kept. + note_reached(reached(vec![flag(SEND, "ServerReply.send", "n")])); + let ((), empty) = collecting(async {}).await; + assert!(empty.is_silent()); + } +} diff --git a/crates/kin-mcp/src/routed.rs b/crates/kin-mcp/src/routed.rs index cf15a00d0..c1d4e18be 100644 --- a/crates/kin-mcp/src/routed.rs +++ b/crates/kin-mcp/src/routed.rs @@ -694,7 +694,7 @@ fn registered(tool: &str) -> Option<&'static ToolDefinition> { /// Whether the registry annotates `tool` read-only. Anything else, `kin_init` /// included, is a write, and the read-only surface never runs it. -fn is_read_only(tool: &str) -> bool { +pub(crate) fn is_read_only(tool: &str) -> bool { registered(tool).is_some_and(|definition| definition.annotations.read_only_hint) } diff --git a/crates/kin-mcp/src/server.rs b/crates/kin-mcp/src/server.rs index 3833d14f9..e61af2b80 100644 --- a/crates/kin-mcp/src/server.rs +++ b/crates/kin-mcp/src/server.rs @@ -457,6 +457,7 @@ pub async fn run_stdio_daemon( repo_binder: Option, startup: Option>, ) -> Result<()> { + daemon_delegate::capture_request_runtime(); if !config.session_authority_mode.requires_daemon() { return Err(McpError::Other( "daemon stdio mode requires daemon session authority".to_string(), @@ -808,16 +809,27 @@ where config.connection_session.current().await } .map(daemon_delegate::ConnectionSessionTag::new); - let response = daemon_delegate::with_connection_session(session_tag.clone(), async { - match call_progress.as_mut() { - Some(notifier) => { - forward_reporting_progress(&message, &config, notifier, &mut *writer, framed) + // What the daemon says this call's answers reach of a reviewer's open + // flags is collected beside the response and written into it last. + let (response, review_flags) = crate::review_flags::collecting( + daemon_delegate::with_connection_session(session_tag.clone(), async { + match call_progress.as_mut() { + Some(notifier) => { + forward_reporting_progress( + &message, + &config, + notifier, + &mut *writer, + framed, + ) .await + } + None => Ok(process_daemon_message(&message, &config).await), } - None => Ok(process_daemon_message(&message, &config).await), - } - }) - .await?; + }), + ) + .await; + let response = response?; // The daemon no longer holds the session: reaped after its idle // window, or lost to a restart. Open a new one now, so the agent is // shown again from its next call. @@ -831,7 +843,7 @@ where if is_tools_call(&message) { stamp_client_folder(&mut response, binding.repo_root.as_deref(), &config); } - let response = present_for_client(response, &message, &config); + let response = present_flagged_for_client(response, &message, &config, &review_flags); let response_json = serde_json::to_string(&response).map_err(McpError::Json)?; write_stdio_message(&mut *writer, &response_json, framed).await?; } @@ -1991,11 +2003,14 @@ pub fn serves_structured_output(config: &McpServerConfig) -> bool { /// context rather than on its own, so it is a budget rather than a page. /// Grok delivers it as a 1,194-byte synthetic user message on turn one. /// +/// 1,200 until the line about a reviewer's flags, which took 101 bytes and +/// has not been measured with a model the way the routing lines were. +/// /// Held by the compile-time assertion below the string rather than by a test. /// There is no state in which this crate should build with an instructions /// string a client cannot afford to inject, and a test that fails after the /// bytes are written is a slower way to learn the same thing. -pub(crate) const SERVER_INSTRUCTIONS_BUDGET: usize = 1_200; +pub(crate) const SERVER_INSTRUCTIONS_BUDGET: usize = 1_300; /// Usage instructions returned at initialize time so a connecting agent knows /// what this server is before its first tool call. @@ -2038,6 +2053,12 @@ pub(crate) const SERVER_INSTRUCTIONS_BUDGET: usize = 1_200; /// this profile does not serve, and of four characters on two other lines. /// The routed profiles' strings have no room for it and are unchanged. /// +/// One line says where a reviewer's open flags arrive and that `status` lists +/// them all, on every profile but the citable ones. An answer that reaches a +/// flag says so in its own advice, so the line's work is to tell an agent +/// asked to address what was flagged where to start. See +/// [`crate::review_flags`]. +/// /// The routed profiles' wording is [`ROUTED_SERVER_INSTRUCTIONS`] and /// [`ROUTED_QUERY_SERVER_INSTRUCTIONS`], the tool-search profile's is /// [`SEARCH_SERVER_INSTRUCTIONS`], and the citable profiles keep @@ -2057,6 +2078,7 @@ Read _kin.verdict: - complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer. - partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step. Explore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format. +A reviewer's open flags arrive in _kin.flags; address each before committing. status lists them all. If hidden, search for \"kin\" first, to find Kin's tools."; /// [`SERVER_INSTRUCTIONS`] for `agent-search`, which serves a measured @@ -2074,6 +2096,7 @@ Read _kin.verdict: - complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer. - partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step. Explore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format. +A reviewer's open flags arrive in _kin.flags; address each before committing. status lists them all. If hidden, search for \"kin\" first, to find Kin's tools."; /// [`SERVER_INSTRUCTIONS`] for a named profile that also lists [`crate::ask`] @@ -2090,6 +2113,7 @@ Read _kin.verdict: - complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer. - partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step. Explore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format. +A reviewer's open flags arrive in _kin.flags; address each before committing. status lists them all. If hidden, search for \"kin\" first, to find Kin's tools."; /// The instructions for `agent-ask`, which serves [`crate::ask`] alone: every @@ -2101,6 +2125,7 @@ Read each answer's Answer (verdict) line: - complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer. - partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format. +A reviewer's open flags lead the answers they concern; address each before committing. If hidden, search for \"kin\" first, to find Kin's tools."; /// [`SERVER_INSTRUCTIONS`] for `agent-routed`, where every command is reached @@ -2122,6 +2147,7 @@ Read _kin.verdict: - complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer. - partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step. Explore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format. +A reviewer's open flags arrive in _kin.flags; address each before committing. kin status lists them all. If hidden, search for \"kin\" first, to find Kin's tools. kin describe lists every other Kin tool, and kin call runs one."; /// [`ROUTED_SERVER_INSTRUCTIONS`] for `agent-routed-query`, which serves the @@ -2139,6 +2165,7 @@ Read _kin.verdict: - complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer. - partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step. Explore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format. +A reviewer's open flags arrive in _kin.flags; address each before committing. kin status lists them all. If hidden, search for \"kin\" first, to find Kin's tools. kin describe lists every other read-only Kin tool, and kin call runs one."; /// Historical instructions for the benchmark profiles, with retired file catalogs @@ -2596,6 +2623,20 @@ pub fn present_for_client( response } +/// [`present_for_client`], and then the reviewer's open flags the call's +/// daemon answers reached, written into the reply last and under the handles +/// this connection serves. See [`crate::review_flags`]. +pub fn present_flagged_for_client( + response: JsonRpcResponse, + request: &str, + config: &McpServerConfig, + reached: &crate::review_flags::Delivered, +) -> JsonRpcResponse { + let mut response = present_for_client(response, request, config); + crate::review_flags::stamp(&mut response, reached, config.active_handles()); + response +} + /// The last step of an agent reply on a connection that serves session /// handles: the reply in compact text, or its JSON with every long opaque /// value shortened. Runs after every byte budget, so it only ever shortens. diff --git a/crates/kin-mcp/src/session_idle_floor.rs b/crates/kin-mcp/src/session_idle_floor.rs index 7dd076d80..6fcb84a66 100644 --- a/crates/kin-mcp/src/session_idle_floor.rs +++ b/crates/kin-mcp/src/session_idle_floor.rs @@ -170,10 +170,14 @@ impl FloorTransport for HttpTransport { "client": CLIENT_NAME, "lease": lease, })); - let response = crate::daemon_delegate::with_auth(request) - .send() - .await - .map_err(|error| error.to_string())?; + let response = crate::daemon_delegate::send_admitted_daemon_request( + "session idle floor hold", + base, + crate::daemon_delegate::with_auth(request), + REQUEST_BUDGET, + ) + .await + .map_err(|error| format!("{error:?}"))?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); @@ -196,10 +200,14 @@ impl FloorTransport for HttpTransport { base.trim_end_matches('/') )) .json(&serde_json::json!({ "lease": lease })); - let response = crate::daemon_delegate::with_auth(request) - .send() - .await - .map_err(|error| error.to_string())?; + let response = crate::daemon_delegate::send_admitted_daemon_request( + "session idle floor release", + base, + crate::daemon_delegate::with_auth(request), + REQUEST_BUDGET, + ) + .await + .map_err(|error| format!("{error:?}"))?; if response.status().is_success() { Ok(()) } else { diff --git a/crates/kin-mcp/src/source_pages.rs b/crates/kin-mcp/src/source_pages.rs index d86ea1309..8cea587e7 100644 --- a/crates/kin-mcp/src/source_pages.rs +++ b/crates/kin-mcp/src/source_pages.rs @@ -994,6 +994,39 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::Duration; + if std::env::var("SOURCE_EXCERPTS_TEST_PARENT").as_deref() != Ok("1") { + let home = tempfile::tempdir().unwrap(); + let repo = home.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "source_pages::tests::a_long_source_read_through_the_daemon_is_served_as_excerpts", + "--nocapture", + ]) + .current_dir(&repo) + .env("HOME", home.path().join("private-home")) + .env("KIN_HOME", home.path().join("kin-home")) + .env("KIN_REGISTRY_PATH", home.path().join("kin-home/registry.toml")) + .env("KIN_NO_DAEMON", "1") + .env("SOURCE_EXCERPTS_TEST_PARENT", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "the fixture process ran nothing: {}", + String::from_utf8_lossy(&output.stdout) + ); + return; + } + let repo = std::env::current_dir().unwrap(); + let runtime = crate::daemon_delegate::RuntimeDaemonFixture::new(&repo); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let address = listener.local_addr().unwrap(); @@ -1041,8 +1074,14 @@ mod tests { break (head, request[end + 4..end + 4 + length].to_vec()); } }; - // Every other route is one this stand-in has nothing for. - let (status, reply) = if head.starts_with("POST /mcp/tools/call ") { + let target = head + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or_default(); + let (status, reply) = if let Some(reply) = runtime.route_response(target) { + ("200 OK", reply) + } else if head.starts_with("POST /mcp/tools/call ") { calls .lock() .unwrap() @@ -1060,17 +1099,11 @@ mod tests { } }) }; - let home = tempfile::tempdir().unwrap(); let output = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", FORWARDING_CHILD, "--ignored", "--nocapture"]) - .current_dir(home.path()) + .current_dir(&repo) .env("KIN_DAEMON_URL", format!("http://{address}")) .env("KIN_DAEMON_AUTH_TOKEN", "isolated-fixture-token") - .env("KIN_HOME", home.path().join("private-home")) - .env( - "KIN_REGISTRY_PATH", - home.path().join("private-home/registry.toml"), - ) .env("KIN_NO_DAEMON", "1") .env("SOURCE_EXCERPTS_TEST_CHILD", "1") .output() @@ -1111,6 +1144,7 @@ mod tests { std::env::var("SOURCE_EXCERPTS_TEST_CHILD").as_deref(), Ok("1") ); + crate::daemon_delegate::RuntimeDaemonFixture::install_registrar(); let body = huge_function(); let agent = McpServerConfig { allowed_tools: Some(crate::tools::name_set( diff --git a/crates/kin-mcp/src/tools.rs b/crates/kin-mcp/src/tools.rs index e98bc5e54..ca4a5d11e 100644 --- a/crates/kin-mcp/src/tools.rs +++ b/crates/kin-mcp/src/tools.rs @@ -1717,6 +1717,7 @@ fn registered_tools() -> ToolsListResult { "properties": { "dependencies": {"type":"array","maxItems":1024,"items":{"type":"string"},"description":"Exact projection paths whose admitted source and recorded call-site proof must be accounted for. No source bodies are returned."}, "cursor": {"type":"string","maxLength":512,"description":"Continue the same selected-source observation with the same dependencies."}, + "flags_cursor": {"type":"string","maxLength":64,"description":"Continue open review flags from _kin.flags_next_cursor, independently of the enrichment cursor."}, "max_chars": { "type": "integer", "default": crate::budget::RESPONSE_DEFAULT_MAX_CHARS, @@ -1935,7 +1936,11 @@ pub fn agent_routed_tool_names() -> &'static [&'static str] { /// 6,362 once it listed `ask`, the front door that routes a plain question to /// these verbs and composes one reply, served with its question alone: 334 /// bytes for the definition and its annotations, about 85 tokens a turn. -pub const AGENT_QUERY_LIST_CEILING_BYTES: usize = 6_362; +/// 6,368 once `status` said it lists review flags, so an agent asked to +/// address what a reviewer flagged knows where to start: 7 bytes on the one +/// description, 6 where it already had a byte to spare. +/// 6,401 once `status` served its separate review-flag continuation. +pub const AGENT_QUERY_LIST_CEILING_BYTES: usize = 6_401; /// The most bytes `agent-default`'s served `tools/list` may cost: what a bare /// `kin mcp start` hands every client, and what `kin setup` writes for every @@ -1988,7 +1993,8 @@ pub const AGENT_QUERY_LIST_CEILING_BYTES: usize = 6_362; /// a module path, undescribed as on `refs` and `impact`; `sort` re-sorts the 20 /// most relevant rows by proven callers or hops from a root, with its measured /// cost in one clause: 147 bytes for the two properties. -pub const AGENT_DEFAULT_LIST_CEILING_BYTES: usize = 9_203; +/// 9,243 with the review-flag description and its separate status cursor. +pub const AGENT_DEFAULT_LIST_CEILING_BYTES: usize = 9_243; /// The most bytes `agent-default`'s tool definitions may cost, summed one /// definition at a time as a client hands each to its model: name, @@ -2062,7 +2068,8 @@ pub const AGENT_DEFAULT_LIST_CEILING_BYTES: usize = 9_203; /// a module path, undescribed as on `refs` and `impact`; `sort` re-sorts the 20 /// most relevant rows by proven callers or hops from a root, with its measured /// cost in one clause: 147 bytes for the two properties. -pub const AGENT_DEFAULT_DEFINITION_CEILING_BYTES: usize = 7_760; +/// 7,800 with the review-flag description and its separate status cursor. +pub const AGENT_DEFAULT_DEFINITION_CEILING_BYTES: usize = 7_800; /// The most bytes the tool-search profile's `tools/list` may cost, measured on /// the profile AS SERVED. @@ -2098,7 +2105,11 @@ pub const AGENT_DEFAULT_DEFINITION_CEILING_BYTES: usize = 7_760; /// cost in one clause: 149 bytes for the two properties. /// 4,714 once it listed `ask` in its always-on set, served with its question /// alone, the same 334 bytes `agent-query` pays for it. -pub const AGENT_SEARCH_LIST_CEILING_BYTES: usize = 4_714; +/// 4,721 once `status` said it lists review flags, so an agent asked to +/// address what a reviewer flagged knows where to start: 7 bytes on the one +/// description. +/// 4,754 once `status` served its separate review-flag continuation. +pub const AGENT_SEARCH_LIST_CEILING_BYTES: usize = 4_754; /// Tool names for the always-on set the tool-search profile serves. /// diff --git a/crates/kin-mcp/src/trace_scope.rs b/crates/kin-mcp/src/trace_scope.rs index 2fc10b06f..6fb0eb608 100644 --- a/crates/kin-mcp/src/trace_scope.rs +++ b/crates/kin-mcp/src/trace_scope.rs @@ -395,6 +395,12 @@ impl RowScope { self.place(entity) != Placement::Outside } + /// [`Self::lists`] for a row that names only the dotted module it sits + /// in. A row with no module is kept, since where it sits is unknown. + pub fn lists_module(&self, module: Option<&str>) -> bool { + module.is_none_or(|module| under(&module.to_lowercase(), &self.folded)) + } + /// [`Self::lists`] for the entity a row names by `id`. A row whose /// entity the graph does not hold is kept. pub fn lists_id(&self, store: &G, id: &str) -> bool { diff --git a/crates/kin-model/derivation-inputs.toml b/crates/kin-model/derivation-inputs.toml index e599f0c4f..a1a2bc528 100644 --- a/crates/kin-model/derivation-inputs.toml +++ b/crates/kin-model/derivation-inputs.toml @@ -82,8 +82,9 @@ files = [ ] [[excluded]] -reason = "records people, tests and reviews add (work items and annotations, test cases and runs, actors and approvals, reviews, contracts, specs, test evidence); derivation creates none, leaves a change's risk_summary, spec_link and evidence empty, and never builds the Test, Work or VerificationRun node variants whose ids are defined here" +reason = "records people, tests, reviews and agents add (work items and annotations, test cases and runs, actors and approvals, what an agent session did, reviews, contracts, specs, test evidence); derivation creates none, leaves a change's risk_summary, spec_link and evidence empty, and never builds the Test, Work or VerificationRun node variants whose ids are defined here" files = [ + "src/agent_session.rs", "src/contract.rs", "src/evidence.rs", "src/provenance.rs", @@ -188,13 +189,12 @@ items = [ [[excluded_items]] file = "src/repository.rs" -reason = "predicates merge, session and status code ask about a workspace's state; they write nothing derivation reads" +# The uncommitted-work and enrichment-only predicates remain graph inputs: +# exact Git admission reaches them before constructing stored descendants. +reason = "replicated-root comparison and dirty snapshot predicates used by merge, session and status code; exact Git admission does not call these remaining report predicates" items = [ "impl RootBundle / fn has_same_replicated_truth", - "impl WorkspaceSemanticOverlay / fn is_language_server_enrichment_only", - "impl WorkspaceSemanticOverlay / fn is_language_server_enrichment_only_at", "impl WorkspaceSnapshotBinding / fn is_dirty", - "impl WorkspaceState / fn holds_uncommitted_work", "impl WorkspaceState / fn is_dirty", ] diff --git a/crates/kin-model/src/agent_session.rs b/crates/kin-model/src/agent_session.rs new file mode 100644 index 000000000..33c6a691f --- /dev/null +++ b/crates/kin-model/src/agent_session.rs @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +//! What an agent session did in a repository, as repository authority keeps +//! it past the session's end. +//! +//! An agent that connected through Kin is an [`Actor`](crate::Actor), and the +//! actor that stands for its session carries this record: who the session was, +//! when it worked, the tools it called and the entities each call was about, +//! the changes it committed, and the entities each of those commits wrote. An +//! agent that edited with its own tools, and whose client reported the edits, +//! is an actor of the same kind, holding the edits Kin confirmed. Never the +//! question, the arguments or the answer of a call, and never a directory, a +//! process id or a path. +//! +//! It is collaboration authority, so it travels in a repository transaction +//! like a review or a flag, and a daemon that reopens the store reads it back +//! from there. Every field is persisted positionally, so a field is only ever +//! appended and none is skipped. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::ids::EntityId; +use crate::timestamp::Timestamp; + +/// How long a session's calls are kept after it ends. +pub const AGENT_SESSION_KEPT_DAYS: u64 = 30; +/// The ended sessions whose calls are kept: the newest that ended inside +/// [`AGENT_SESSION_KEPT_DAYS`]. +pub const AGENT_SESSIONS_KEPT: usize = 32; +/// The calls one session keeps, its newest. +pub const AGENT_SESSION_CALLS_MAX: usize = 100; +/// The changes one session keeps, its newest. +pub const AGENT_SESSION_CHANGES_MAX: usize = 16; +/// The writes through Kin kept across every session, the newest. +pub const AGENT_WRITES_MAX: usize = 512; +/// The edits made with an agent's own tools kept across every session, the +/// newest. +pub const AGENT_EDITS_MAX: usize = 2048; +/// The entities one write or one edit keeps. +pub const AGENT_ENTITIES_PER_WRITE_MAX: usize = 128; + +/// How a session reached Kin. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum AgentSessionSource { + /// A session connected through Kin: an MCP server, a wrapper or the CLI. + Kin, + /// A session its client named when it reported an edit made with the + /// client's own tools. + Client, +} + +/// One agent session's record, carried by the actor that stands for it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AgentSessionRecord { + /// The session: a Kin session id, or the session a client named. + pub session: String, + pub source: AgentSessionSource, + /// The names its connection or its client gave, made safe to show. + pub vendor: String, + pub client: String, + /// How it connected to Kin. Empty for a client's own session. + pub transport: String, + pub started_at: Option, + /// When it last called Kin, or was last heard from when it never did. + pub last_activity_at: Option, + /// When it ended. Absent while Kin has not seen it end. + pub ended_at: Option, + /// Every call it made, counted, including the ones not kept. + pub calls_total: u64, + /// Its newest calls, oldest first. + pub calls: Vec, + /// The changes it committed, newest first. + pub change_ids: Vec, + /// What each change it committed through Kin wrote, oldest first. + pub writes: Vec, + /// The edits it made with its own tools that Kin confirmed, oldest first. + pub edits: Vec, +} + +impl AgentSessionRecord { + /// A record that holds nothing yet. + pub fn new(session: &str, source: AgentSessionSource) -> Self { + Self { + session: session.to_string(), + source, + vendor: String::new(), + client: String::new(), + transport: String::new(), + started_at: None, + last_activity_at: None, + ended_at: None, + calls_total: 0, + calls: Vec::new(), + change_ids: Vec::new(), + writes: Vec::new(), + edits: Vec::new(), + } + } + + /// Hold every list inside its bound: the newest calls, changes, and the + /// entities each write and edit names. + pub fn bound(&mut self) { + if self.calls.len() > AGENT_SESSION_CALLS_MAX { + let extra = self.calls.len() - AGENT_SESSION_CALLS_MAX; + self.calls.drain(..extra); + } + self.change_ids.truncate(AGENT_SESSION_CHANGES_MAX); + for write in &mut self.writes { + write.entities.truncate(AGENT_ENTITIES_PER_WRITE_MAX); + } + for edit in &mut self.edits { + edit.entities.truncate(AGENT_ENTITIES_PER_WRITE_MAX); + } + if self.writes.len() > AGENT_WRITES_MAX { + let extra = self.writes.len() - AGENT_WRITES_MAX; + self.writes.drain(..extra); + } + if self.edits.len() > AGENT_EDITS_MAX { + let extra = self.edits.len() - AGENT_EDITS_MAX; + self.edits.drain(..extra); + } + } +} + +/// One tool call a session made: the tool, the entities it was about, how +/// much it answered and how long it took. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AgentCall { + /// 1-based position of the call in its session. + pub seq: u64, + pub at: Timestamp, + /// The registered tool Kin ran. + pub tool: String, + /// Entities the call was about: its targets first, then what the answer + /// named. + pub entity_ids: Vec, + /// Distinct entities the answer named. + pub result_count: u64, + /// Size of the answer. + pub bytes: u64, + pub duration_ms: u64, + /// The answer was a refusal or an error. + pub is_error: bool, + /// What a write left behind, when there is something to say: the change + /// it committed, and whether it finished after the agent stopped waiting + /// for it. Absent on every other call, which keeps those at the bytes + /// they always had; it is the last field, so an encoding without it reads + /// back as a call with none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outcome: Option, +} + +/// What one write call of a session left behind. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AgentCallOutcome { + /// The change the call committed, when it committed one. + pub change_id: Option, + /// The call finished after the agent that made it stopped waiting for its + /// answer, so the agent was never told how it ended. + pub caller_left: bool, +} + +impl AgentCallOutcome { + /// The outcome worth keeping for a call that committed `change_id`, if + /// any, and whose caller did or did not wait for it: `None` when there is + /// nothing to say. + pub fn of(change_id: Option, caller_left: bool) -> Option { + (change_id.is_some() || caller_left).then_some(Self { + change_id, + caller_left, + }) + } +} + +/// What one change a session committed through Kin wrote: the entities its +/// own operations named or created, never one that only came along. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AgentWriteRecord { + pub change_id: String, + pub at: Timestamp, + pub entities: Vec, +} + +/// One edit a session made with its own tools, as Kin confirmed it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AgentEditRecord { + /// The change the working copy stood on when the edit was admitted. + pub base: Option, + /// The change that recorded the edit, once one did. + pub change_id: Option, + /// The Kin session the same agent was connected through, when one was. + pub kin_session: Option, + pub at: Timestamp, + pub entities: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_record_stays_inside_its_bounds_keeping_the_newest() { + let mut record = AgentSessionRecord::new("s", AgentSessionSource::Kin); + let call = |seq| AgentCall { + seq, + at: Timestamp::now(), + tool: "semantic_locate".into(), + entity_ids: vec![EntityId::new()], + result_count: 1, + bytes: 1, + duration_ms: 1, + is_error: false, + outcome: None, + }; + let many = u64::try_from(AGENT_SESSION_CALLS_MAX).unwrap() + 7; + record.calls = (1..=many).map(call).collect(); + record.change_ids = (0..AGENT_SESSION_CHANGES_MAX + 3) + .map(|index| format!("{index:064x}")) + .collect(); + record.writes = vec![AgentWriteRecord { + change_id: "a".repeat(64), + at: Timestamp::now(), + entities: (0..AGENT_ENTITIES_PER_WRITE_MAX + 5) + .map(|_| EntityId::new()) + .collect(), + }]; + record.bound(); + assert_eq!(record.calls.len(), AGENT_SESSION_CALLS_MAX); + assert_eq!(record.calls.last().unwrap().seq, many); + assert_eq!(record.change_ids.len(), AGENT_SESSION_CHANGES_MAX); + assert_eq!(record.change_ids[0], format!("{:064x}", 0)); + assert_eq!( + record.writes[0].entities.len(), + AGENT_ENTITIES_PER_WRITE_MAX + ); + } + + #[test] + fn a_record_reads_back_from_the_positional_encoding_the_store_keeps() { + let mut record = AgentSessionRecord::new("s", AgentSessionSource::Client); + record.edits.push(AgentEditRecord { + base: None, + change_id: Some("b".repeat(64)), + kin_session: None, + at: Timestamp::now(), + entities: vec![EntityId::new()], + }); + let read: AgentSessionRecord = + rmp_serde::from_slice(&rmp_serde::to_vec(&record).unwrap()).unwrap(); + assert_eq!(read, record); + } + + /// The call as it was persisted before a call could carry an outcome. + #[derive(Serialize)] + struct CallBeforeOutcomes<'a> { + seq: u64, + at: &'a Timestamp, + tool: &'a str, + entity_ids: &'a [EntityId], + result_count: u64, + bytes: u64, + duration_ms: u64, + is_error: bool, + } + + /// A call with nothing to say keeps the bytes it always had, an encoding + /// written before outcomes reads back as a call with none, and a commit + /// that finished after its agent stopped waiting reads back whole. + #[test] + fn a_call_outcome_is_appended_without_moving_any_stored_call() { + let plain = AgentCall { + seq: 3, + at: Timestamp::now(), + tool: "kin_transaction_commit".into(), + entity_ids: vec![EntityId::new()], + result_count: 1, + bytes: 40, + duration_ms: 460_000, + is_error: false, + outcome: None, + }; + let before = CallBeforeOutcomes { + seq: plain.seq, + at: &plain.at, + tool: &plain.tool, + entity_ids: &plain.entity_ids, + result_count: plain.result_count, + bytes: plain.bytes, + duration_ms: plain.duration_ms, + is_error: plain.is_error, + }; + let stored = rmp_serde::to_vec(&before).unwrap(); + assert_eq!(rmp_serde::to_vec(&plain).unwrap(), stored); + let read: AgentCall = rmp_serde::from_slice(&stored).unwrap(); + assert_eq!(read, plain); + + assert_eq!(AgentCallOutcome::of(None, false), None); + let mut late = plain.clone(); + late.outcome = AgentCallOutcome::of(Some("c".repeat(64)), true); + let mut record = AgentSessionRecord::new("s", AgentSessionSource::Kin); + record.calls = vec![plain, late]; + let read: AgentSessionRecord = + rmp_serde::from_slice(&rmp_serde::to_vec(&record).unwrap()).unwrap(); + assert_eq!(read, record); + let read: AgentSessionRecord = + serde_json::from_str(&serde_json::to_string(&record).unwrap()).unwrap(); + assert_eq!(read, record); + } +} diff --git a/crates/kin-model/src/collaboration.rs b/crates/kin-model/src/collaboration.rs index fedcbe7e8..828fd5777 100644 --- a/crates/kin-model/src/collaboration.rs +++ b/crates/kin-model/src/collaboration.rs @@ -279,6 +279,29 @@ impl CollaborationDelta { /// entries under one key are ambiguous on arrival whatever their values are, /// and ordering by the whole entry would admit that pair as strictly /// increasing. +/// Keyed entries in the one order a delta admits: strictly increasing by the +/// canonical encoding of their key, which is the order [`CollaborationDelta::validate`] +/// holds every keyed collection to. Of two entries under one key, the later +/// is kept. +pub fn in_canonical_key_order( + entries: Vec>, +) -> Result>> { + let mut encoded = Vec::with_capacity(entries.len()); + for entry in entries { + encoded.push((canonical_json_bytes(&entry.key)?, entry)); + } + // Stable, so of two entries under one key the later stays the later. + encoded.sort_by(|left, right| left.0.cmp(&right.0)); + let mut ordered: Vec<(Vec, Keyed)> = Vec::with_capacity(encoded.len()); + for (key, entry) in encoded { + match ordered.last_mut() { + Some((previous, kept)) if *previous == key => *kept = entry, + _ => ordered.push((key, entry)), + } + } + Ok(ordered.into_iter().map(|(_, entry)| entry).collect()) +} + fn keyed_order(collection: &str, entries: &[Keyed]) -> Result<()> { let mut previous: Option> = None; for entry in entries { @@ -384,6 +407,7 @@ pub(crate) mod tests { authored_by: who(), created_at: at(), staleness: StalenessState::Fresh, + review: None, } } @@ -485,6 +509,8 @@ pub(crate) mod tests { duration_ms: None, evidence_blob: None, exit_code: None, + ran_against: Vec::new(), + proof: None, }, )], mock_hints: vec![MockHint { @@ -512,6 +538,7 @@ pub(crate) mod tests { kind: ActorKind::Human, display_name: "actor".to_string(), external_refs: Vec::new(), + agent_session: None, }, )], delegations: vec![Delegation { diff --git a/crates/kin-model/src/graph.rs b/crates/kin-model/src/graph.rs index cfe4c3c6c..349c9ad95 100644 --- a/crates/kin-model/src/graph.rs +++ b/crates/kin-model/src/graph.rs @@ -409,6 +409,13 @@ pub trait ChangeStore: Send + Sync { &self, id: &SemanticChangeId, ) -> std::result::Result, Self::Error>; + /// Whether this store holds `id`, which [`Self::get_change`] answers by + /// reading the whole change. A store that keeps its history encoded + /// answers from its index instead: a change can carry a whole repository, + /// and a ref resolved to it is checked on every read that names one. + fn holds_change(&self, id: &SemanticChangeId) -> std::result::Result { + Ok(self.get_change(id)?.is_some()) + } fn get_changes_since( &self, base: &SemanticChangeId, diff --git a/crates/kin-model/src/lib.rs b/crates/kin-model/src/lib.rs index 5e674b7d7..1c7cabf98 100644 --- a/crates/kin-model/src/lib.rs +++ b/crates/kin-model/src/lib.rs @@ -35,6 +35,7 @@ //! wrongly. pub mod admission; +pub mod agent_session; #[cfg(test)] pub(crate) mod alloc_probe; pub mod binding_debt; @@ -108,7 +109,9 @@ pub use change::{ ChangeOrigin, EntityDelta, LocatedEntry, RelationDelta, ResolvedArtifact, ResolvedTree, SemanticChange, TransactionDelta, TreeDelta, TreeEntry, TreeStateError, }; -pub use collaboration::{CollaborationDelta, Keyed, COLLABORATION_COLLECTIONS}; +pub use collaboration::{ + in_canonical_key_order, CollaborationDelta, Keyed, COLLABORATION_COLLECTIONS, +}; pub use conflict::{ConflictKind, ConflictObject}; pub use context::{ AnnotationEntry, ArtifactContextEntry, ArtifactContextKind, ContextEntry, ContextPack, @@ -213,6 +216,11 @@ pub use value_escape::{ VALUE_ESCAPE_RECORD_KEY, VALUE_ESCAPE_RECORD_VERSION, VALUE_ESCAPE_RECORD_VERSION_MIN, }; +pub use agent_session::{ + AgentCall, AgentCallOutcome, AgentEditRecord, AgentSessionRecord, AgentSessionSource, + AgentWriteRecord, AGENT_EDITS_MAX, AGENT_ENTITIES_PER_WRITE_MAX, AGENT_SESSIONS_KEPT, + AGENT_SESSION_CALLS_MAX, AGENT_SESSION_CHANGES_MAX, AGENT_SESSION_KEPT_DAYS, AGENT_WRITES_MAX, +}; pub use provenance::{ Actor, ActorId, ActorKind, Approval, ApprovalDecision, ApprovalId, AuditEvent, AuditEventId, Delegation, DelegationId, @@ -245,13 +253,14 @@ pub use session::{ }; pub use verification::{ Assertion, AssertionId, CompletionState, ContractCoverageSummary, CoverageSummary, MockHint, - MockHintId, MockStrategy, TestCase, TestId, TestKind, TestRunner, VerificationRun, - VerificationRunId, VerificationStatus, + MockHintId, MockStrategy, RunFingerprint, TestCase, TestId, TestKind, TestRunner, + VerificationProof, VerificationRun, VerificationRunId, VerificationStatus, }; pub use work::{ Annotation, AnnotationFilter, AnnotationId, AnnotationKind, AnnotationTarget, ExternalRef, - IdentityKind, IdentityRef, Priority, SemanticAnchor, StalenessState, WorkFilter, WorkId, - WorkItem, WorkKind, WorkLink, WorkScope, WorkStatus, + IdentityKind, IdentityRef, Priority, ReviewFlag, ReviewFlagResolution, ReviewFlagSighting, + SemanticAnchor, StalenessState, WorkFilter, WorkId, WorkItem, WorkKind, WorkLink, WorkScope, + WorkStatus, REVIEW_FLAG_SIGHTINGS_MAX, }; pub use workspace_tree::{ WorkspaceTreeArtifact, WorkspaceTreeSnapshot, WORKSPACE_TREE_SNAPSHOT_SCHEMA_VERSION, diff --git a/crates/kin-model/src/provenance.rs b/crates/kin-model/src/provenance.rs index 4ecf10855..abbb883ed 100644 --- a/crates/kin-model/src/provenance.rs +++ b/crates/kin-model/src/provenance.rs @@ -175,12 +175,20 @@ impl std::fmt::Display for ApprovalDecision { // Core types // --------------------------------------------------------------------------- +/// Persisted through positional MessagePack, so a field is only ever +/// appended. `agent_session` is left out of the encoding when absent, which +/// keeps every actor that is not an agent session at the bytes, and so the +/// digest, it always had. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct Actor { pub actor_id: ActorId, pub kind: ActorKind, pub display_name: String, pub external_refs: Vec, + /// Present when this actor stands for one agent session: what the + /// session did in this repository. See [`crate::agent_session`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -333,6 +341,7 @@ mod tests { identifier: "alice".into(), url: None, }], + agent_session: None, }; let json = serde_json::to_string(&actor).unwrap(); @@ -343,6 +352,58 @@ mod tests { assert_eq!(parsed.external_refs.len(), 1); } + /// The actor as it was encoded before an actor could carry an agent + /// session's record. + #[derive(Serialize)] + struct ActorBeforeAgentSessions<'a> { + actor_id: ActorId, + kind: ActorKind, + display_name: &'a str, + external_refs: &'a [ExternalRef], + } + + /// An actor that is not an agent session keeps the bytes it always had, + /// in the positional encoding the store hashes and in JSON, and a store + /// written before agent sessions reads back with none. One that is + /// reads back whole from both. + #[test] + fn an_actor_without_an_agent_session_keeps_its_stored_bytes() { + let plain = Actor { + actor_id: ActorId::new(), + kind: ActorKind::Assistant, + display_name: "claude-code/claude-code".into(), + external_refs: Vec::new(), + agent_session: None, + }; + let before = ActorBeforeAgentSessions { + actor_id: plain.actor_id, + kind: plain.kind, + display_name: &plain.display_name, + external_refs: &plain.external_refs, + }; + let stored = rmp_serde::to_vec(&before).unwrap(); + assert_eq!(rmp_serde::to_vec(&plain).unwrap(), stored); + assert_eq!( + serde_json::to_string(&plain).unwrap(), + serde_json::to_string(&before).unwrap() + ); + let read: Actor = rmp_serde::from_slice(&stored).unwrap(); + assert_eq!(read, plain); + + let mut session = plain.clone(); + let mut record = crate::agent_session::AgentSessionRecord::new( + "dbecbbcd-c382-4559-93ba-9f08bbf28ab6", + crate::agent_session::AgentSessionSource::Kin, + ); + record.calls_total = 4; + record.ended_at = Some(Timestamp::now()); + session.agent_session = Some(record); + let read: Actor = rmp_serde::from_slice(&rmp_serde::to_vec(&session).unwrap()).unwrap(); + assert_eq!(read, session); + let read: Actor = serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap(); + assert_eq!(read, session); + } + #[test] fn delegation_serialization_roundtrip() { let d = Delegation { diff --git a/crates/kin-model/src/verification.rs b/crates/kin-model/src/verification.rs index 6419c0fb7..eb7d34746 100644 --- a/crates/kin-model/src/verification.rs +++ b/crates/kin-model/src/verification.rs @@ -9,7 +9,7 @@ use crate::ids::{ContractId, EntityId, FilePathId, Hash256}; use crate::timestamp::Timestamp; -use crate::work::WorkScope; +use crate::work::{WorkId, WorkScope}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -201,6 +201,13 @@ pub struct Assertion { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub enum VerificationStatus { Missing, + /// No outcome yet. A recorded run is pending when its test command could + /// not run: Kin could not start it, or the shell could not find or execute + /// the runner it names. Such a run attests nothing about its tests, and + /// its exit code and evidence say why it did not run. + /// + /// Recorded as this existing variant rather than a new one, so a store + /// holding such a run still opens in a Kin that predates the reading. Pending, Passing, Failing, @@ -269,7 +276,7 @@ impl std::fmt::Display for MockStrategy { // --------------------------------------------------------------------------- /// A single execution of one or more tests with captured evidence. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)] pub struct VerificationRun { pub run_id: VerificationRunId, pub test_ids: Vec, @@ -280,6 +287,78 @@ pub struct VerificationRun { pub duration_ms: Option, pub evidence_blob: Option, pub exit_code: Option, + /// The entities the run was tied to, each with the fingerprint of its + /// source when the run started. A result is current only while each of + /// them still has that fingerprint. A run recorded before this was kept + /// carries none, and is never current. + /// + /// Omitted only when empty and no proof targets follow it, so every run + /// written before these fields existed keeps its exact bytes. The + /// hand-written `Serialize` below decides the omission, because a + /// field-level skip would drop this slot even when `proof` follows it. + #[serde(default)] + pub ran_against: Vec, + /// The proof links admitted with this run. The graph derives its links + /// from these durable targets when a workspace is reopened. + #[serde(default)] + pub proof: Option, +} + +impl VerificationRun { + /// Whether this run's command could not run, so it attests no outcome for + /// any test in it. See [`VerificationStatus::Pending`]. + pub fn could_not_run(&self) -> bool { + self.status == VerificationStatus::Pending + } +} + +impl Serialize for VerificationRun { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + // MessagePack persists this as an array. A skipped interior field + // would decode proof targets as fingerprints, so keep the tenth slot + // whenever the eleventh is present. + let fingerprints = !self.ran_against.is_empty() || self.proof.is_some(); + let mut out = serializer.serialize_struct( + "VerificationRun", + 9 + usize::from(fingerprints) + usize::from(self.proof.is_some()), + )?; + out.serialize_field("run_id", &self.run_id)?; + out.serialize_field("test_ids", &self.test_ids)?; + out.serialize_field("status", &self.status)?; + out.serialize_field("runner", &self.runner)?; + out.serialize_field("started_at", &self.started_at)?; + out.serialize_field("finished_at", &self.finished_at)?; + out.serialize_field("duration_ms", &self.duration_ms)?; + out.serialize_field("evidence_blob", &self.evidence_blob)?; + out.serialize_field("exit_code", &self.exit_code)?; + if fingerprints { + out.serialize_field("ran_against", &self.ran_against)?; + } + if let Some(proof) = &self.proof { + out.serialize_field("proof", proof)?; + } + out.end() + } +} + +/// The entities and work items a recorded run provides proof for. These +/// targets are distinct from its fingerprints, which also include tests and +/// dependencies that the run did not claim to prove. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct VerificationProof { + pub entities: Vec, + pub work_items: Vec, +} + +/// One entity a verification run was tied to, and the fingerprint of its +/// source when the run started. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct RunFingerprint { + pub entity: EntityId, + /// The entity's [`crate::SemanticFingerprint::behavior_hash`], which + /// moves on any edit of its source. + pub behavior_hash: Hash256, } /// Advisory hint that a dependency was mocked during a test run. @@ -506,6 +585,8 @@ mod tests { duration_ms: Some(1234), evidence_blob: Some(Hash256::from_bytes([0xee; 32])), exit_code: Some(0), + ran_against: Vec::new(), + proof: None, }; let json = serde_json::to_string(&run).unwrap(); @@ -517,6 +598,161 @@ mod tests { assert_eq!(parsed.exit_code, Some(0)); } + /// A run persists as a positional MessagePack array. One recorded before + /// it carried fingerprints keeps its exact bytes and still decodes, and + /// one that carries them keeps them. + #[test] + fn a_run_keeps_its_bytes_without_fingerprints_and_its_fingerprints_with_them() { + use crate::timestamp::Timestamp; + + let without = VerificationRun { + run_id: VerificationRunId::new(), + test_ids: vec![TestId::new()], + status: VerificationStatus::Failing, + runner: TestRunner::Custom("npm test".into()), + started_at: Timestamp::now(), + finished_at: None, + duration_ms: None, + evidence_blob: None, + exit_code: Some(1), + ran_against: Vec::new(), + proof: None, + }; + let bytes = rmp_serde::to_vec(&without).unwrap(); + // Nine fields, as every run written before `ran_against` existed. + assert_eq!( + bytes[0], 0x99, + "a run without fingerprints is nine elements" + ); + let legacy = ( + without.run_id, + &without.test_ids, + without.status, + &without.runner, + &without.started_at, + &without.finished_at, + without.duration_ms, + without.evidence_blob, + without.exit_code, + ); + assert_eq!(bytes, rmp_serde::to_vec(&legacy).unwrap()); + let decoded: VerificationRun = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded, without); + + let with = VerificationRun { + ran_against: vec![RunFingerprint { + entity: EntityId::new(), + behavior_hash: Hash256::from_bytes([0x5a; 32]), + }], + ..without.clone() + }; + let bytes = rmp_serde::to_vec(&with).unwrap(); + assert_eq!(bytes[0], 0x9a, "fingerprints are the tenth element"); + let decoded: VerificationRun = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded, with); + + let fingerprint_only = ( + with.run_id, + &with.test_ids, + with.status, + &with.runner, + &with.started_at, + &with.finished_at, + with.duration_ms, + with.evidence_blob, + with.exit_code, + &with.ran_against, + ); + assert_eq!(bytes, rmp_serde::to_vec(&fingerprint_only).unwrap()); + + let targets = VerificationProof { + entities: vec![EntityId::new()], + work_items: vec![WorkId::new()], + }; + for source in [without, with] { + let proved = VerificationRun { + proof: Some(targets.clone()), + ..source + }; + let bytes = rmp_serde::to_vec(&proved).unwrap(); + assert_eq!(bytes[0], 0x9b, "proof keeps the fingerprint slot"); + let decoded: VerificationRun = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded, proved); + let json = serde_json::to_string(&proved).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + proved + ); + } + } + + /// A run whose command could not run is recorded with a status every + /// released Kin already decodes, in the same positional layout as any + /// other run, so a store that holds one still opens in an older Kin. + #[test] + fn a_run_that_could_not_run_keeps_the_released_encoding() { + use crate::timestamp::Timestamp; + + let run = VerificationRun { + run_id: VerificationRunId::new(), + test_ids: vec![TestId::new(), TestId::new()], + status: VerificationStatus::Pending, + runner: TestRunner::Custom("npm test".into()), + started_at: Timestamp::now(), + finished_at: Some(Timestamp::now()), + duration_ms: Some(395), + evidence_blob: Some(Hash256::from_bytes([0x27; 32])), + exit_code: Some(127), + ran_against: vec![RunFingerprint { + entity: EntityId::new(), + behavior_hash: Hash256::from_bytes([0x5a; 32]), + }], + proof: Some(VerificationProof { + entities: vec![EntityId::new()], + work_items: Vec::new(), + }), + }; + assert!(run.could_not_run()); + for status in [ + VerificationStatus::Missing, + VerificationStatus::Passing, + VerificationStatus::Failing, + ] { + assert!(!VerificationRun { + status, + ..run.clone() + } + .could_not_run()); + } + + let bytes = rmp_serde::to_vec(&run).unwrap(); + assert_eq!( + bytes[0], 0x9b, + "eleven positional elements, as any run with proof" + ); + let positional = ( + run.run_id, + &run.test_ids, + "Pending", + &run.runner, + &run.started_at, + &run.finished_at, + run.duration_ms, + run.evidence_blob, + run.exit_code, + &run.ran_against, + &run.proof, + ); + assert_eq!( + bytes, + rmp_serde::to_vec(&positional).unwrap(), + "the status is the released variant name, in the released slot" + ); + let decoded: VerificationRun = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded, run); + assert!(decoded.could_not_run()); + } + #[test] fn mock_hint_serialization_roundtrip() { let hint = MockHint { diff --git a/crates/kin-model/src/work.rs b/crates/kin-model/src/work.rs index 27623ec15..1fec6d0f9 100644 --- a/crates/kin-model/src/work.rs +++ b/crates/kin-model/src/work.rs @@ -354,6 +354,11 @@ impl std::fmt::Display for StalenessState { /// line numbers, so they are designed to stay attached across renames and /// moves. When the anchored entity's fingerprint drifts, staleness is /// detected. +/// +/// Persisted through positional MessagePack, so a field is only ever +/// appended. `review` is left out of the encoding when absent, which keeps +/// every annotation that is not a review flag at the bytes, and so the +/// digest, it always had. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct Annotation { pub annotation_id: AnnotationId, @@ -364,6 +369,112 @@ pub struct Annotation { pub authored_by: IdentityRef, pub created_at: Timestamp, pub staleness: StalenessState, + /// Present when a reviewer set this annotation as a flag on the entity it + /// is scoped to. The annotation's `body` is the reviewer's note, + /// `authored_by` the reviewer and `created_at` when the flag was set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option, +} + +impl Annotation { + /// The review flag this annotation carries, while it is still open. + pub fn open_review_flag(&self) -> Option<&ReviewFlag> { + self.review + .as_ref() + .filter(|flag| flag.resolution.is_none()) + } + + /// The entity a review flag is on: the one entity it is scoped to. + pub fn flagged_entity(&self) -> Option { + self.review.as_ref()?; + self.scopes.iter().find_map(|scope| match scope { + WorkScope::Entity(entity_id) => Some(*entity_id), + _ => None, + }) + } +} + +// --------------------------------------------------------------------------- +// Review flags +// --------------------------------------------------------------------------- + +/// Agent sessions one review flag remembers having reached. Past it, the +/// oldest sighting goes. +pub const REVIEW_FLAG_SIGHTINGS_MAX: usize = 32; + +/// A reviewer's flag on one entity, as the annotation that holds it carries +/// it. +/// +/// A flag is scoped to an entity and to the exact version the reviewer was +/// looking at. It stays open until the reviewer closes it, whatever becomes +/// of the entity in between: committing the entity does not close it, and an +/// entity that changes afterwards is told apart by its fingerprint no longer +/// being the flagged one. A flag is advisory and stops nothing. +/// +/// Positional like the annotation that holds it, so a field is only ever +/// appended. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ReviewFlag { + /// The fingerprint of the entity's syntax as the reviewer saw it. + pub flagged_ast: Hash256, + /// The entity's name when it was flagged, which is what a reader is shown + /// once the graph no longer holds the entity. + pub flagged_name: String, + /// The request that set the flag, so the same request sets it once. + pub operation_id: OperationId, + /// Who closed the flag and when. Absent while it is open. + pub resolution: Option, + /// The agent sessions an answer from Kin handed this flag to, oldest + /// first, each session once. + pub seen_by: Vec, + /// Literal-sensitive fingerprint of the version the reviewer saw. Older + /// flags without this field continue to use their syntax fingerprint. + // Kept as an explicit null when absent: a later source digest must not + // slide into this position in MessagePack. + #[serde(default)] + pub flagged_behavior: Option, + /// Exact reviewed entity span digest, including comments and literals. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flagged_source: Option, +} + +impl ReviewFlag { + /// Record that `sighting`'s session was handed this flag. A session + /// already recorded is left as it was; returns whether anything changed. + pub fn record_sighting(&mut self, sighting: ReviewFlagSighting) -> bool { + if self + .seen_by + .iter() + .any(|known| known.session == sighting.session) + { + return false; + } + self.seen_by.push(sighting); + if self.seen_by.len() > REVIEW_FLAG_SIGHTINGS_MAX { + let excess = self.seen_by.len() - REVIEW_FLAG_SIGHTINGS_MAX; + self.seen_by.drain(..excess); + } + true + } +} + +/// Who closed a review flag, and when. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ReviewFlagResolution { + pub by: IdentityRef, + pub at: Timestamp, + /// The request that closed the flag. + pub operation_id: OperationId, +} + +/// One agent session a review flag reached. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ReviewFlagSighting { + /// The name the session's client gave itself. + pub agent: String, + pub session: SessionId, + /// When Kin first handed the session an answer carrying the flag. + pub at: Timestamp, } // --------------------------------------------------------------------------- @@ -590,6 +701,7 @@ mod tests { authored_by: IdentityRef::human("alice"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }; let json = serde_json::to_string(&ann).unwrap(); @@ -604,6 +716,142 @@ mod tests { assert_eq!(StalenessState::default(), StalenessState::Fresh); } + /// The annotation as every store written before review flags holds it. + #[derive(Serialize, Deserialize)] + struct AnnotationBeforeReviewFlags { + annotation_id: AnnotationId, + kind: AnnotationKind, + body: String, + scopes: Vec, + anchored_fingerprint: Option, + authored_by: IdentityRef, + created_at: Timestamp, + staleness: StalenessState, + } + + fn flagged_annotation(entity_id: EntityId) -> Annotation { + Annotation { + annotation_id: AnnotationId::new(), + kind: AnnotationKind::Warning, + body: "a second send must fail loudly".into(), + scopes: vec![WorkScope::Entity(entity_id)], + anchored_fingerprint: Some(SemanticAnchor { + ast_hash: Hash256::from_bytes([0xaa; 32]), + signature_hash: Hash256::from_bytes([0xbb; 32]), + }), + authored_by: IdentityRef::human("ada"), + created_at: Timestamp::now(), + staleness: StalenessState::Fresh, + review: Some(ReviewFlag { + flagged_ast: Hash256::from_bytes([0xaa; 32]), + flagged_behavior: Some(Hash256::from_bytes([0xbb; 32])), + flagged_source: Some(Hash256::from_bytes([0xcc; 32])), + flagged_name: "ServerReply.send".into(), + operation_id: OperationId::new(), + resolution: None, + seen_by: Vec::new(), + }), + } + } + + /// An annotation that is not a review flag keeps the bytes it always had, + /// in the positional encoding the store hashes and in JSON, and a store + /// written before review flags reads back with none. + #[test] + fn an_annotation_without_a_review_flag_keeps_its_stored_bytes() { + let mut plain = flagged_annotation(EntityId::new()); + plain.review = None; + let before = AnnotationBeforeReviewFlags { + annotation_id: plain.annotation_id, + kind: plain.kind, + body: plain.body.clone(), + scopes: plain.scopes.clone(), + anchored_fingerprint: plain.anchored_fingerprint.clone(), + authored_by: plain.authored_by.clone(), + created_at: plain.created_at.clone(), + staleness: plain.staleness, + }; + let stored = rmp_serde::to_vec(&before).unwrap(); + assert_eq!(rmp_serde::to_vec(&plain).unwrap(), stored); + assert_eq!( + serde_json::to_string(&plain).unwrap(), + serde_json::to_string(&before).unwrap() + ); + let read: Annotation = rmp_serde::from_slice(&stored).unwrap(); + assert_eq!(read, plain); + let read: Annotation = + serde_json::from_str(&serde_json::to_string(&before).unwrap()).unwrap(); + assert_eq!(read, plain); + } + + #[test] + fn a_review_flag_survives_both_encodings_and_names_its_entity() { + let entity_id = EntityId::new(); + let mut flagged = flagged_annotation(entity_id); + let flag = flagged.review.as_mut().unwrap(); + flag.record_sighting(ReviewFlagSighting { + agent: "codex".into(), + session: SessionId::new(), + at: Timestamp::now(), + }); + let read: Annotation = + rmp_serde::from_slice(&rmp_serde::to_vec(&flagged).unwrap()).unwrap(); + assert_eq!(read, flagged); + let read: Annotation = + serde_json::from_str(&serde_json::to_string(&flagged).unwrap()).unwrap(); + assert_eq!(read, flagged); + assert_eq!(flagged.flagged_entity(), Some(entity_id)); + assert!(flagged.open_review_flag().is_some()); + + flagged.review.as_mut().unwrap().resolution = Some(ReviewFlagResolution { + by: IdentityRef::human("ada"), + at: Timestamp::now(), + operation_id: OperationId::new(), + }); + assert!(flagged.open_review_flag().is_none()); + assert_eq!(flagged.flagged_entity(), Some(entity_id)); + + let mut plain = flagged; + plain.review = None; + assert_eq!(plain.flagged_entity(), None); + } + + #[test] + fn exact_source_flag_encoding_keeps_the_behavior_slot_when_absent() { + let mut annotation = flagged_annotation(EntityId::new()); + let flag = annotation.review.as_mut().unwrap(); + flag.flagged_behavior = None; + let expected_source = flag.flagged_source; + let read: Annotation = + rmp_serde::from_slice(&rmp_serde::to_vec(&annotation).unwrap()).unwrap(); + assert_eq!(read.review.as_ref().unwrap().flagged_behavior, None); + assert_eq!( + read.review.as_ref().unwrap().flagged_source, + expected_source + ); + assert_eq!(read, annotation); + } + + #[test] + fn a_review_flag_records_each_session_once_and_keeps_the_newest() { + let mut flagged = flagged_annotation(EntityId::new()); + let flag = flagged.review.as_mut().unwrap(); + let first = SessionId::new(); + let sighting = |session| ReviewFlagSighting { + agent: "agent".into(), + session, + at: Timestamp::now(), + }; + assert!(flag.record_sighting(sighting(first))); + assert!(!flag.record_sighting(sighting(first))); + assert_eq!(flag.seen_by.len(), 1); + for _ in 0..REVIEW_FLAG_SIGHTINGS_MAX { + assert!(flag.record_sighting(sighting(SessionId::new()))); + } + assert_eq!(flag.seen_by.len(), REVIEW_FLAG_SIGHTINGS_MAX); + assert!(flag.seen_by.iter().all(|seen| seen.session != first)); + } + #[test] fn work_id_serialization() { let id = WorkId::new(); diff --git a/crates/kin-model/tests/persisted_schema.rs b/crates/kin-model/tests/persisted_schema.rs index 0661e9577..1a0812b68 100644 --- a/crates/kin-model/tests/persisted_schema.rs +++ b/crates/kin-model/tests/persisted_schema.rs @@ -154,6 +154,13 @@ const HAND_WRITTEN_SERIALIZATIONS: &[(&str, &str, &str)] = &[ "a_change_with_records_keeps_every_field_in_place_and_moves_its_identity", "", ), + ( + // Writes ran_against, even empty, whenever proof follows it, and + // stays nine elements for a run recorded before either existed. + "VerificationRun", + "a_run_keeps_its_bytes_without_fingerprints_and_its_fingerprints_with_them", + "", + ), ( "TransactionDelta", "a_transaction_delta_keeps_its_bytes_until_it_carries_records", diff --git a/crates/kin-parser/src/project_manifest.rs b/crates/kin-parser/src/project_manifest.rs index b4a545499..008d2ff5d 100644 --- a/crates/kin-parser/src/project_manifest.rs +++ b/crates/kin-parser/src/project_manifest.rs @@ -14,6 +14,10 @@ //! crate root takes its crate's name from the Cargo manifest above it. The //! manifests reach an adapter at ingestion through [`ProjectFiles`], and what //! it reads from them travels with the parse as a [`UnitContext`]. +//! +//! A manifest also says how its project's tests are run, where its language +//! has a convention for that ([`ProjectManifest::declared_tests`]). Kin runs a +//! repository's tests the way its own manifest declares them. /// The manifest at the root of a language's projects, and how it declares /// the project's name. @@ -36,8 +40,26 @@ pub struct ProjectManifest { /// The project name the manifest's text declares, as written there, or /// `None` when it declares none. pub declared_name: fn(&str) -> Option, + /// How the manifest's text says the project's tests are run, or `None` + /// when it says nothing Kin can run. + pub declared_tests: fn(&str) -> Option, +} + +/// How a project's manifest says its tests are run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeclaredTests { + /// A test runner Kin knows by name and can narrow to the tests it plans: + /// `cargo`, `pytest` or `go`. + Runner(&'static str), + /// The command the project declares for its tests, as its program and + /// arguments. Kin cannot narrow it, so it runs whole. + Command(&'static [&'static str]), } +/// What `npm init` writes as a package's test script: a placeholder that +/// fails, not a test command. +const NPM_PLACEHOLDER_TEST: &str = "no test specified"; + /// A Python project's: `[project] name`, or Poetry's `[tool.poetry] name`. pub const PYPROJECT: ProjectManifest = ProjectManifest { name: "pyproject.toml", @@ -45,6 +67,11 @@ pub const PYPROJECT: ProjectManifest = ProjectManifest { directory_names_level: false, names_units: false, declared_name: |text| toml_name(text, &["project", "tool.poetry"]), + // A project that configures or depends on pytest is tested with it. + declared_tests: |text| { + text.contains("pytest") + .then_some(DeclaredTests::Runner("pytest")) + }, }; /// A JavaScript or TypeScript package's: `name`, known by the part after its @@ -59,6 +86,14 @@ pub const PACKAGE_JSON: ProjectManifest = ProjectManifest { let name = value.get("name")?.as_str()?; Some(name.rsplit('/').next().unwrap_or(name).to_string()) }, + // The package's own `test` script, through the package manager that + // runs scripts, unless it is the placeholder `npm init` leaves. + declared_tests: |text| { + let value: serde_json::Value = serde_json::from_str(text).ok()?; + let script = value.get("scripts")?.get("test")?.as_str()?.trim(); + (!script.is_empty() && !script.contains(NPM_PLACEHOLDER_TEST)) + .then_some(DeclaredTests::Command(&["npm", "test"])) + }, }; /// A Rust crate's: `[package] name`. @@ -68,6 +103,13 @@ pub const CARGO_TOML: ProjectManifest = ProjectManifest { directory_names_level: false, names_units: true, declared_name: |text| toml_name(text, &["package"]), + // A crate or a workspace of them. + declared_tests: |text| { + text.lines() + .map(str::trim) + .any(|line| line == "[package]" || line == "[workspace]") + .then_some(DeclaredTests::Runner("cargo")) + }, }; /// A Go module's: the last segment of its module path. @@ -85,6 +127,8 @@ pub const GO_MOD: ProjectManifest = ProjectManifest { .map(|module| module.rsplit('/').next().unwrap_or(module).to_string()) }) }, + // A module's packages are tested by `go test`. + declared_tests: |text| (GO_MOD.declared_name)(text).map(|_| DeclaredTests::Runner("go")), }; /// The `name` a TOML document sets in the first of `tables` that sets one. @@ -307,6 +351,52 @@ mod tests { assert!(!is_manifest_path("notCargo.toml")); } + #[test] + fn a_manifest_declares_how_its_project_is_tested_or_says_nothing() { + let package = |scripts: &str| format!(r#"{{"name":"tally","scripts":{scripts}}}"#); + assert_eq!( + (PACKAGE_JSON.declared_tests)(&package(r#"{"test":"node --test"}"#)), + Some(DeclaredTests::Command(&["npm", "test"])) + ); + // What `npm init` leaves is a script that fails, not a test command. + assert_eq!( + (PACKAGE_JSON.declared_tests)(&package( + r#"{"test":"echo \"Error: no test specified\" && exit 1"}"# + )), + None + ); + assert_eq!( + (PACKAGE_JSON.declared_tests)(&package(r#"{"build":"tsc"}"#)), + None + ); + assert_eq!((PACKAGE_JSON.declared_tests)("not json"), None); + + assert_eq!( + (CARGO_TOML.declared_tests)("[package]\nname = \"tally\"\n"), + Some(DeclaredTests::Runner("cargo")) + ); + assert_eq!( + (CARGO_TOML.declared_tests)("[workspace]\nmembers = [\"a\"]\n"), + Some(DeclaredTests::Runner("cargo")) + ); + assert_eq!((CARGO_TOML.declared_tests)("[dependencies]\n"), None); + + assert_eq!( + (GO_MOD.declared_tests)("module example.com/tally\n\ngo 1.22\n"), + Some(DeclaredTests::Runner("go")) + ); + assert_eq!((GO_MOD.declared_tests)("go 1.22\n"), None); + + assert_eq!( + (PYPROJECT.declared_tests)("[tool.pytest.ini_options]\naddopts = \"-q\"\n"), + Some(DeclaredTests::Runner("pytest")) + ); + assert_eq!( + (PYPROJECT.declared_tests)("[project]\nname = \"tally\"\n"), + None + ); + } + #[test] fn the_registered_adapters_name_each_manifest_once() { let names: Vec<&str> = all().iter().map(|manifest| manifest.name).collect(); diff --git a/crates/kin-reconcile/src/batch_debt.rs b/crates/kin-reconcile/src/batch_debt.rs index 72669815b..42db8c88d 100644 --- a/crates/kin-reconcile/src/batch_debt.rs +++ b/crates/kin-reconcile/src/batch_debt.rs @@ -246,7 +246,7 @@ pub(crate) fn retain_removed_bindings( if selected.is_empty() { continue; } - let graph = InMemoryGraph::from_snapshot(GraphSnapshot { + let graph = InMemoryGraph::from_snapshot_without_text_index(GraphSnapshot { entities: prior.entities.clone(), relations: prior.relations.clone(), resolved_tree: prior.tree.clone(), diff --git a/crates/kin-reconcile/src/linker_surface.rs b/crates/kin-reconcile/src/linker_surface.rs index f9b5d404d..8272841da 100644 --- a/crates/kin-reconcile/src/linker_surface.rs +++ b/crates/kin-reconcile/src/linker_surface.rs @@ -56,8 +56,22 @@ const BODY_DERIVED_ENTITY_KEYS: &[&str] = &[ // when another file binds. kin_parser::NESTED_DEFINITIONS_NOT_ADMITTED_KEY, kin_parser::FILE_NESTED_DEFINITIONS_NOT_ADMITTED_KEY, + // The census of computed member writes the file's code makes, each with + // the span it sits at. It discloses what a question about the file's + // members cannot enumerate, and no linker reads it: a member a write does + // mint is an entity of its own, compared like any other declaration. Left + // in, every edit that moved a byte above one `object[key] = value` read as + // a moved surface, and the edit re-derived every file binding against this + // one, re-parsing each of them that still held an import from outside. + kin_model::derivation::MEMBER_COVERAGE_KEY, ]; +/// The parts of a derived member's record that say where its generating +/// syntax sits and which bytes it was read from. The owner, the member key, +/// the rule and its conditions are the declaration another file binds +/// against, and they stay. +const DERIVATION_POSITION_FIELDS: &[&str] = &["generator", "assignment", "source_blob_hash"]; + /// Whether the edit that produced `indexed` over a file whose graph /// declarations are `existing` can change how another file binds. /// @@ -214,6 +228,11 @@ fn declaration(entity: &Entity) -> String { for key in BODY_DERIVED_ENTITY_KEYS { strip(metadata, key); } + if let Some(derivation) = metadata.get_mut(kin_model::derivation::ENTITY_DERIVATION_KEY) { + for field in DERIVATION_POSITION_FIELDS { + strip(derivation, field); + } + } } value.to_string() } @@ -307,4 +326,65 @@ mod tests { let after = indexed("pkg/__init__.py", "VERSION = 1\nfrom .impl import execute as public\nif enabled:\n public = replacement\n"); assert_ne!(LinkerSurface::of(&before), LinkerSurface::of(&after)); } + + /// A JavaScript file that writes members through computed keys carries a + /// census of those writes on its module, each with the span it sits at, + /// and every member a write mints records its generator's span and the + /// bytes it was read from. An edit inside another function's body moves + /// those positions and changes those bytes without changing anything + /// another file binds against, so the surface holds still. Before, it read + /// as moved, and every body edit to such a file re-derived the files + /// binding against it. + #[test] + fn a_body_edit_above_computed_member_writes_leaves_the_surface_where_it_was() { + let source = |label: &str, keys: &str| { + format!( + "const app = {{}}\nfunction describe () {{\n return {label}\n}}\nfunction install (enabled) {{\n for (const key of [{keys}]) {{\n if (enabled) {{ app[key] = () => {{}} }}\n }}\n app[unknown()] = () => {{}}\n}}\nmodule.exports = {{ describe, install }}\n" + ) + }; + let before = indexed("lib/install.js", &source("'installs'", "'get', 'post'")); + let after = indexed( + "lib/install.js", + &source( + "'installs every handler,\\n one per method'", + "'get', 'post'", + ), + ); + for key in [ + kin_model::derivation::MEMBER_COVERAGE_KEY, + kin_model::derivation::ENTITY_DERIVATION_KEY, + ] { + assert!( + carries(&before, key), + "the fixture must carry `{key}`, or it proves nothing about it" + ); + } + let derivations = |indexed: &IndexedFile| -> Vec { + indexed + .entities + .iter() + .filter_map(|entity| { + entity + .metadata + .extra + .get(kin_model::derivation::ENTITY_DERIVATION_KEY) + .cloned() + }) + .collect() + }; + assert_ne!( + derivations(&before), + derivations(&after), + "the edit must move the positions and bytes the records name" + ); + assert_eq!(LinkerSurface::of(&before), LinkerSurface::of(&after)); + + // A member the loop newly mints is a declaration another file can + // bind to, and still moves the surface. + let minted = indexed( + "lib/install.js", + &source("'installs'", "'get', 'post', 'put'"), + ); + assert_ne!(LinkerSurface::of(&before), LinkerSurface::of(&minted)); + } } diff --git a/crates/kin-reconcile/src/reconciler.rs b/crates/kin-reconcile/src/reconciler.rs index f41915309..e5b4aa9bf 100644 --- a/crates/kin-reconcile/src/reconciler.rs +++ b/crates/kin-reconcile/src/reconciler.rs @@ -1265,7 +1265,10 @@ impl Reconciler { relations: snapshot.relations.clone(), ..Default::default() }; - let graph = kin_db::InMemoryGraph::from_snapshot(snapshot) + // A private graph the batch is derived in and dropped with it. Nothing + // searches it, so it carries no text index; see + // `InMemoryGraph::from_snapshot_without_text_index`. + let graph = kin_db::InMemoryGraph::from_snapshot_without_text_index(snapshot) .map_err(|error| ReconcileError::Graph(error.to_string()))?; let mut files = files.to_vec(); // A manifest the batch changes can rename sources whose bytes it does @@ -1482,7 +1485,9 @@ impl Reconciler { if !delta.tree_deltas.is_empty() || delta.admission_policy_delta.is_some() { return Err(batch_invalid("live batch accepts semantic deltas only")); } - let candidate = kin_db::InMemoryGraph::from_snapshot(current) + // Built to apply the delta and compare, then dropped; nothing searches + // it, so it carries no text index. + let candidate = kin_db::InMemoryGraph::from_snapshot_without_text_index(current) .map_err(|error| ReconcileError::Graph(error.to_string()))?; candidate .apply_transaction_delta(delta) @@ -1651,7 +1656,7 @@ impl Reconciler { "source adoption cannot supply admission policy authority", )); } - let whole = kin_db::InMemoryGraph::from_snapshot(observed.clone()) + let whole = kin_db::InMemoryGraph::from_snapshot_without_text_index(observed.clone()) .map_err(|error| ReconcileError::Graph(error.to_string()))?; whole .apply_transaction_delta(whole_delta) diff --git a/crates/kin-review/src/diff.rs b/crates/kin-review/src/diff.rs index fb0b519d5..79690682f 100644 --- a/crates/kin-review/src/diff.rs +++ b/crates/kin-review/src/diff.rs @@ -78,6 +78,12 @@ pub struct SemanticDiff { /// found one edit. #[serde(default)] pub provenance_only_entity_changes: usize, + /// Of those, the entities whose recorded documentation was read again in a + /// different form while their code and the comment above it stayed as they + /// were ([`is_documentation_reread`]). A reader is told about them in one + /// quiet line and never asked to review them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub documentation_rereads: Vec, } /// Whether a recorded modification changed what the entity IS, rather than only @@ -96,14 +102,70 @@ pub struct SemanticDiff { /// added to `Entity` later is therefore treated as semantic until someone /// decides otherwise, which fails toward reporting a change rather than hiding /// one. +/// +/// The documentation summary is the one content field compared by what it +/// says rather than byte for byte ([`documentation_agrees`]). It is read from +/// the comment above a declaration, outside the entity's own text and outside +/// its fingerprints, and a newer reading of an unchanged comment records it in +/// a different form: a store built before the JavaScript and TypeScript adapters +/// read a doc block as its prose holds `* Options: ... * @default ... */` where +/// a re-read now holds `Options: ...`. Editing one declaration re-reads every +/// declaration beside it, so without this every documented neighbour of an +/// edit was reported as modified when nothing about it moved. pub fn is_semantic_modification(old: &Entity, new: &Entity) -> bool { let mut normalized = new.clone(); normalized.span.clone_from(&old.span); normalized.metadata.clone_from(&old.metadata); normalized.created_in = old.created_in; + if documentation_agrees(old.doc_summary.as_deref(), new.doc_summary.as_deref()) { + normalized.doc_summary.clone_from(&old.doc_summary); + } normalized != *old } +/// Whether a recorded modification only re-read the entity's documentation: +/// nothing about the entity changed ([`is_semantic_modification`]), yet the +/// two records hold its documentation in different forms. +pub fn is_documentation_reread(old: &Entity, new: &Entity) -> bool { + old.doc_summary != new.doc_summary && !is_semantic_modification(old, new) +} + +/// Whether two recorded documentation summaries say the same thing. +/// +/// Compared word by word, without the comment punctuation an older reading +/// left in (`*` line leaders, a closing `*/`), so the prose decides. A doc +/// block's tags are not its prose either: the current reading stops at the +/// first `@` tag and an older one ran on through them, so a summary that +/// equals the other's words followed by a tag is the same summary read two +/// ways. Any change to the words themselves is still a change. +pub fn documentation_agrees(old: Option<&str>, new: Option<&str>) -> bool { + if old == new { + return true; + } + let old = documentation_words(old); + let new = documentation_words(new); + if old == new { + return true; + } + let (short, long) = if old.len() < new.len() { + (&old, &new) + } else { + (&new, &old) + }; + long.len() > short.len() && long.starts_with(short) && long[short.len()].starts_with('@') +} + +/// A summary's words, with comment punctuation trimmed from each and words +/// that were nothing but punctuation dropped. +fn documentation_words(summary: Option<&str>) -> Vec<&str> { + summary + .into_iter() + .flat_map(str::split_whitespace) + .map(|word| word.trim_matches(|character| matches!(character, '*' | '/'))) + .filter(|word| !word.is_empty()) + .collect() +} + impl SemanticDiff { pub fn added_entities(&self) -> Vec<&Entity> { self.entity_changes @@ -161,17 +223,26 @@ impl SemanticDiff { /// Drop the modifications that only advanced provenance, returning how many /// were dropped so the caller can publish the count. -fn split_provenance_only(changes: Vec) -> (Vec, usize) { +fn split_provenance_only(changes: Vec) -> (Vec, usize, Vec) { let before = changes.len(); + let mut rereads = Vec::new(); let kept: Vec = changes .into_iter() .filter(|change| match &change.kind { - EntityChangeKind::Modified { old, new } => is_semantic_modification(old, new), + EntityChangeKind::Modified { old, new } => { + if is_semantic_modification(old, new) { + return true; + } + if is_documentation_reread(old, new) { + rereads.push(change.entity_id); + } + false + } EntityChangeKind::Added(_) | EntityChangeKind::Removed { .. } => true, }) .collect(); let suppressed = before - kept.len(); - (kept, suppressed) + (kept, suppressed, rereads) } /// Fold one `Modified` delta into the accumulated state for its entity. @@ -326,9 +397,10 @@ pub fn compute_diff_scoped( .map(|(entity_id, kind)| EntityChange { entity_id, kind }) .collect(); entity_changes.sort_by_key(|change| change.entity_id); - let (entity_changes, provenance_only) = split_provenance_only(entity_changes); + let (entity_changes, provenance_only, rereads) = split_provenance_only(entity_changes); diff.entity_changes = entity_changes; diff.provenance_only_entity_changes = provenance_only; + diff.documentation_rereads = rereads; // Accumulate relation deltas let mut relation_added: HashMap = HashMap::new(); @@ -416,9 +488,10 @@ pub fn diff_from_change(change: &SemanticChange) -> SemanticDiff { }; entity_changes.push(EntityChange { entity_id, kind }); } - let (entity_changes, provenance_only) = split_provenance_only(entity_changes); + let (entity_changes, provenance_only, rereads) = split_provenance_only(entity_changes); diff.entity_changes = entity_changes; diff.provenance_only_entity_changes = provenance_only; + diff.documentation_rereads = rereads; for delta in &change.relation_deltas { let kind = match delta { @@ -488,9 +561,10 @@ pub fn diff_from_changes(changes: &[SemanticChange]) -> SemanticDiff { .map(|(entity_id, kind)| EntityChange { entity_id, kind }) .collect(); entity_changes.sort_by_key(|change| change.entity_id); - let (entity_changes, provenance_only) = split_provenance_only(entity_changes); + let (entity_changes, provenance_only, rereads) = split_provenance_only(entity_changes); diff.entity_changes = entity_changes; diff.provenance_only_entity_changes = provenance_only; + diff.documentation_rereads = rereads; // Accumulate relation deltas let mut relation_added: HashMap = HashMap::new(); @@ -1416,4 +1490,108 @@ mod tests { "a declaration that changed file is a move a reviewer must see" ); } + + /// The summary a store built by the earlier TypeScript reading holds for + /// zod's `$ZodStringBoolParams.case`, and what a re-read of the same, + /// untouched comment holds now. + const EARLIER_READING: &str = + "* Options: `\"sensitive\"`, `\"insensitive\"` * * @default `\"insensitive\"` */"; + const CURRENT_READING: &str = "Options: `\"sensitive\"`, `\"insensitive\"`"; + + /// Editing one declaration re-reads every declaration beside it. One whose + /// comment and code did not move is not modified, however differently its + /// documentation was read the first time. + #[test] + fn a_documentation_reread_is_not_a_modification() { + let mut base = test_entity("$ZodStringBoolParams.case"); + base.doc_summary = Some(EARLIER_READING.to_string()); + let mut reread = base.clone(); + reread.doc_summary = Some(CURRENT_READING.to_string()); + reread.span = Some(SourceSpan { + file: FilePathId::new("src/api.ts"), + start_byte: 70, + end_byte: 120, + start_line: 1709, + start_col: 2, + end_line: 1709, + end_col: 52, + }); + assert!(!is_semantic_modification(&base, &reread)); + assert!(is_documentation_reread(&base, &reread)); + + // A directive the earlier reading kept as a summary and the current + // one reads as none. + let mut directive = test_entity("_email"); + directive.doc_summary = Some("@__NO_SIDE_EFFECTS__".to_string()); + let mut none = directive.clone(); + none.doc_summary = None; + assert!(!is_semantic_modification(&directive, &none)); + assert!(is_documentation_reread(&directive, &none)); + + // Prose that says something else is a change, in either reading. + let mut edited = base.clone(); + edited.doc_summary = Some("Options: `\"sensitive\"` only".to_string()); + assert!(is_semantic_modification(&base, &edited)); + assert!(is_semantic_modification(&reread, &edited)); + assert!(!is_documentation_reread(&base, &edited)); + let undocumented = test_entity("undocumented"); + let mut added = undocumented.clone(); + added.doc_summary = Some("Now documented.".to_string()); + assert!(is_semantic_modification(&undocumented, &added)); + let mut removed = reread.clone(); + removed.doc_summary = None; + assert!(is_semantic_modification(&reread, &removed)); + + // A mention of a tag inside the prose is still prose. + assert!(!documentation_agrees( + Some("Use @Foo for this"), + Some("Use @Foo for that") + )); + // The re-read leaves a code change exactly as visible as before. + let mut rewritten = reread.clone(); + rewritten.fingerprint.behavior_hash = Hash256::from_bytes([4; 32]); + assert!(is_semantic_modification(&base, &rewritten)); + assert!(!is_documentation_reread(&base, &rewritten)); + } + + /// A change, a range and a commit's entity list all read through the same + /// split, so the re-read entity is counted apart and named, never walked. + #[test] + fn a_change_counts_its_documentation_rereads_apart() { + let edited = test_entity("_toLowerCase"); + let mut edited_new = edited.clone(); + edited_new.fingerprint.behavior_hash = Hash256::from_bytes([5; 32]); + let mut documented = test_entity("$ZodStringBoolParams.case"); + documented.doc_summary = Some(EARLIER_READING.to_string()); + let mut documented_new = documented.clone(); + documented_new.doc_summary = Some(CURRENT_READING.to_string()); + let change = SemanticChange { + id: test_change_id(7), + entity_deltas: vec![ + EntityDelta::Modified { + old: edited.clone(), + new: edited_new, + }, + EntityDelta::Modified { + old: documented.clone(), + new: documented_new, + }, + ], + ..reemitted_file_change(0, 0) + }; + + for diff in [ + diff_from_change(&change), + diff_from_changes(std::slice::from_ref(&change)), + ] { + let modified: Vec<&str> = diff + .modified_entities() + .into_iter() + .map(|(_, new)| new.name.as_str()) + .collect(); + assert_eq!(modified, vec!["_toLowerCase"]); + assert_eq!(diff.provenance_only_entity_changes, 1); + assert_eq!(diff.documentation_rereads, vec![documented.id]); + } + } } diff --git a/crates/kin-review/src/format.rs b/crates/kin-review/src/format.rs index b4943712a..6a04f94ba 100644 --- a/crates/kin-review/src/format.rs +++ b/crates/kin-review/src/format.rs @@ -1851,6 +1851,7 @@ mod tests { authored_by: IdentityRef::human("dev"), created_at: kin_model::timestamp::Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }], ..Default::default() }; diff --git a/crates/kin-review/src/lib.rs b/crates/kin-review/src/lib.rs index 33c53b356..b3514866e 100644 --- a/crates/kin-review/src/lib.rs +++ b/crates/kin-review/src/lib.rs @@ -30,7 +30,8 @@ pub use change_shape::{ pub use contract::{signature_changed, tokens_unchanged, ConsumerReach, ContractChange}; pub use diff::{ compute_diff, diff_from_change, diff_from_changes, diff_from_entity_ids, diff_from_files, - is_semantic_modification, EntityChange, EntityChangeKind, SemanticDiff, + documentation_agrees, is_documentation_reread, is_semantic_modification, EntityChange, + EntityChangeKind, SemanticDiff, }; pub use error::ReviewError; pub use format::{ @@ -47,10 +48,11 @@ pub use inline::{ }; pub use override_callers::{OverrideCall, OverrideCaller, OverrideCallers}; pub use ranked_impact::{ - inbound_impact_relations, is_impact_relation, rank_impact, rank_impact_at, CandidateLocation, - ImpactBucket, PriorityScoreComponents, RankedImpactCandidate, RankedImpactReport, - RelationPathStep, StableEntityIdentity, IMPACT_MAX_DEPTH, PRIORITY_SCORE_FORMULA, - RANKED_IMPACT_SCHEMA_VERSION, + inbound_impact_relation_refs, inbound_impact_relations, is_call_route, is_impact_relation, + is_test_unit, rank_impact, rank_impact_at, test_reach_order, test_unit_holding, + CandidateLocation, ImpactBucket, PriorityScoreComponents, RankedImpactCandidate, + RankedImpactReport, RelationPathStep, StableEntityIdentity, TestReach, IMPACT_MAX_DEPTH, + PRIORITY_SCORE_FORMULA, RANKED_IMPACT_SCHEMA_VERSION, }; pub use ref_graph::GraphAtRef; pub use release_gate::{ diff --git a/crates/kin-review/src/ranked_impact.rs b/crates/kin-review/src/ranked_impact.rs index 61465437b..320900d81 100644 --- a/crates/kin-review/src/ranked_impact.rs +++ b/crates/kin-review/src/ranked_impact.rs @@ -152,6 +152,49 @@ pub struct RankedImpactReport { pub score_semantics: String, pub score_formula: String, pub candidates: Vec, + /// The tests this walk reaches, one per test ([`is_test_unit`]), nearest + /// first and, at one distance, the ones whose route is all calls first. + /// Test code that is not itself a test, such as a constant or a type in a + /// test module, is counted as the test that holds it. Defaulted on read so + /// a report recorded before tests were counted this way still reads. + #[serde(default)] + pub tests: Vec, +} + +/// One test this walk reaches. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestReach { + /// The test: a test entity, a test module, or what a recorded `tests` or + /// `covers` relation names. + pub entity_id: EntityId, + pub identity: StableEntityIdentity, + /// Recorded impact edges between the walk's root and the test code. + pub hop: u32, + /// How many of those edges are calls, or recorded `tests` and `covers` + /// relations. Equal to `hop` when running the test runs the root along + /// one recorded route ([`TestReach::by_call`]). + pub call_hops: u32, + /// The candidate the walk reached it through: the test itself, or the + /// test code inside it that is not a test of its own. + pub via: EntityId, +} + +impl TestReach { + /// Whether every edge on the route to the root is a call, so the test + /// runs the root rather than only naming its type or module. + pub fn by_call(&self) -> bool { + self.hop > 0 && self.call_hops == self.hop + } +} + +impl RankedImpactReport { + /// Every candidate of test code in this recorded impact walk, a test or + /// not. [`RankedImpactReport::tests`] is what a reader counts as tests. + pub fn test_candidates(&self) -> impl Iterator { + self.candidates + .iter() + .filter(|candidate| candidate.bucket == ImpactBucket::Test) + } } /// The deepest hop any impact walk answers at. @@ -299,6 +342,7 @@ pub fn rank_impact_at( .then_with(|| left.entity_id.cmp(&right.entity_id)) }); + let tests = test_reach(graph, &candidates)?; Ok(RankedImpactReport { schema_version: RANKED_IMPACT_SCHEMA_VERSION.to_string(), root_entity_id: *root, @@ -308,9 +352,125 @@ pub fn rank_impact_at( .to_string(), score_formula: PRIORITY_SCORE_FORMULA.to_string(), candidates, + tests, }) } +/// The tests among `candidates`, each counted once by its nearest route. +fn test_reach( + graph: &I, + candidates: &[RankedImpactCandidate], +) -> Result, ReviewError> { + let mut best: BTreeMap = BTreeMap::new(); + for candidate in candidates + .iter() + .filter(|candidate| candidate.bucket == ImpactBucket::Test) + { + let Some(entity) = graph.get_entity(&candidate.entity_id)? else { + continue; + }; + let declared = matches!( + candidate.relation, + RelationKind::Tests | RelationKind::Covers + ); + let test = if declared || is_test_unit(&entity) { + Some(entity) + } else { + test_unit_holding(graph, &entity)? + }; + let Some(test) = test else { + continue; + }; + let call_hops = candidate + .path + .iter() + .filter(|step| is_call_route(step.relation_kind)) + .count() as u32; + let reach = TestReach { + entity_id: test.id, + identity: StableEntityIdentity::from_entity(&test), + hop: candidate.hop, + call_hops, + via: candidate.entity_id, + }; + match best.get(&test.id) { + Some(kept) if !reach_is_nearer(&reach, kept) => {} + _ => { + best.insert(test.id, reach); + } + } + } + let mut tests: Vec = best.into_values().collect(); + tests.sort_by(test_reach_order); + Ok(tests) +} + +/// The order tests are read in: nearest first, then the routes with the most +/// calls, so a test that runs the root comes before one that names its type. +pub fn test_reach_order(left: &TestReach, right: &TestReach) -> std::cmp::Ordering { + left.hop + .cmp(&right.hop) + .then_with(|| right.call_hops.cmp(&left.call_hops)) + .then_with(|| left.identity.name.cmp(&right.identity.name)) + .then_with(|| left.identity.cmp(&right.identity)) + .then_with(|| left.entity_id.cmp(&right.entity_id)) +} + +fn reach_is_nearer(reach: &TestReach, kept: &TestReach) -> bool { + test_reach_order(reach, kept) == std::cmp::Ordering::Less + || (reach.hop == kept.hop && reach.call_hops == kept.call_hops && reach.via < kept.via) +} + +/// Whether an edge on a test's route runs its destination: a call, or a +/// recorded `tests` or `covers` relation. +pub fn is_call_route(kind: RelationKind) -> bool { + matches!( + kind, + RelationKind::Calls + | RelationKind::Spawns + | RelationKind::Instantiates + | RelationKind::Tests + | RelationKind::Covers + ) +} + +/// How far up its containers test code is read for the test that holds it. +const TEST_UNIT_CONTAINER_DEPTH: usize = 8; + +/// The test that holds `entity`, test code that is not a test of its own: +/// its nearest container that is one, by the `Contains` relation with the +/// smallest id at each level so one graph always answers the same. +pub fn test_unit_holding( + graph: &I, + entity: &Entity, +) -> Result, ReviewError> { + let mut current = entity.id; + let mut seen = HashSet::from([current]); + for _ in 0..TEST_UNIT_CONTAINER_DEPTH { + let container = graph + .get_all_relations_for_entity(¤t)? + .into_iter() + .filter(|relation| { + relation.kind == RelationKind::Contains + && relation.dst == GraphNodeId::Entity(current) + }) + .filter_map(|relation| relation.src.as_entity().map(|source| (relation.id, source))) + .min() + .map(|(_, container)| container); + let Some(container) = container.filter(|container| seen.insert(*container)) else { + return Ok(None); + }; + let Some(held) = graph.get_entity(&container)? else { + return Ok(None); + }; + if is_test_unit(&held) { + return Ok(Some(held)); + } + current = container; + } + Ok(None) +} + fn candidate_is_better( candidate: &RankedImpactCandidate, existing: &RankedImpactCandidate, @@ -394,6 +554,15 @@ pub fn is_impact_relation(kind: RelationKind) -> bool { /// Both walks call this, rather than each applying the per-kind predicate /// separately, so their agreement is structural instead of conventional. pub fn inbound_impact_relations(all: &[Relation], id: &EntityId) -> Vec { + inbound_impact_relation_refs(all, id) + .into_iter() + .cloned() + .collect() +} + +/// [`inbound_impact_relations`] without the copies, for a caller that reads +/// the relations of every entity in a repository and keeps none of them. +pub fn inbound_impact_relation_refs<'a>(all: &'a [Relation], id: &EntityId) -> Vec<&'a Relation> { let node = GraphNodeId::Entity(*id); let declaring: HashSet = all .iter() @@ -410,17 +579,46 @@ pub fn inbound_impact_relations(all: &[Relation], id: &EntityId) -> Vec bool { + !kin_model::is_derived_member(entity) + && !matches!(entity.role, EntityRole::Generated | EntityRole::Vendored) + && (entity.role == EntityRole::Test || entity.kind == kin_model::EntityKind::Test) +} + +/// Whether this entity is a test a reader counts: test code that is a test +/// by its kind, a function, method or class of test code, or a test module. +/// +/// A constant, a type alias, an interface, a field or a variant inside a test +/// module is what its tests use, not a test. Counted as tests, a module's +/// fixtures multiplied the count: zod's `_toLowerCase` read "144 tests reach +/// it" where half were constants and types inside test modules. Such test +/// code is counted as the test that holds it ([`test_unit_holding`]). +pub fn is_test_unit(entity: &Entity) -> bool { + use kin_model::EntityKind; + is_test_impact_source(entity) + && (matches!( + entity.kind, + EntityKind::Test | EntityKind::Function | EntityKind::Method | EntityKind::Class + ) || kin_model::is_module_container(entity)) +} + fn impact_bucket(entity: &Entity, relation: RelationKind) -> ImpactBucket { if kin_model::is_derived_member(entity) || matches!(entity.role, EntityRole::Generated | EntityRole::Vendored) || relation == RelationKind::DerivedFrom { ImpactBucket::Derived - } else if entity.role == EntityRole::Test + } else if is_test_impact_source(entity) || matches!(relation, RelationKind::Tests | RelationKind::Covers) { ImpactBucket::Test @@ -819,4 +1017,112 @@ mod tests { assert_eq!(candidate.path[0].relation_kind, RelationKind::Spawns); assert!(candidate.path[0].evidence[0].source_span.is_some()); } + + fn test_code(name: &str, file: &str, kind: EntityKind) -> Entity { + let mut value = entity(name, file, 1); + value.id = EntityId::from_content(file, name, &format!("{kind:?}"), 1); + value.kind = kind; + value.role = EntityRole::Test; + value + } + + /// Tests are what a runner runs: test functions and test modules. A + /// constant, an interface or a type a test module declares is counted as + /// the module that holds it, once, and test code no test holds is not + /// counted at all. The nearest come first and, at one distance, the ones + /// that reach the root by calls alone. + #[test] + fn test_reach_counts_tests_not_the_code_inside_them() { + let root = entity("_toLowerCase", "src/core/api.ts", 1128); + let string_class = entity("_ZodString", "src/classic/schemas.ts", 289); + let mut checks = entity("checks", "src/classic/checks.ts", 1); + checks.kind = EntityKind::Module; + let string_test = test_code("string.test", "tests/string.test.ts", EntityKind::Module); + let apply_test = test_code("apply.test", "tests/apply.test.ts", EntityKind::Module); + let schema = test_code("schema", "tests/apply.test.ts", EntityKind::Constant); + let make_schema = test_code("makeSchema", "tests/apply.test.ts", EntityKind::Function); + let object_test = test_code("object.test", "tests/object.test.ts", EntityKind::Module); + let fixture = test_code("Test", "tests/object.test.ts", EntityKind::Constant); + let loose = test_code("Shape", "tests/loose.test.ts", EntityKind::Interface); + + let mut graph = Graph::default(); + for value in [ + &root, + &string_class, + &checks, + &string_test, + &apply_test, + &schema, + &make_schema, + &object_test, + &fixture, + &loose, + ] { + graph.entities.insert(value.id, value.clone()); + } + graph.relations.insert( + root.id, + vec![ + edge(&string_class, &root, RelationKind::Calls, true), + edge(&checks, &root, RelationKind::References, true), + ], + ); + graph.relations.insert( + string_class.id, + vec![ + edge(&string_test, &string_class, RelationKind::Calls, true), + edge(&make_schema, &string_class, RelationKind::Calls, true), + ], + ); + graph.relations.insert( + checks.id, + vec![ + edge(&schema, &checks, RelationKind::UsesType, true), + edge(&fixture, &checks, RelationKind::UsesType, true), + edge(&object_test, &checks, RelationKind::References, true), + edge(&loose, &checks, RelationKind::UsesType, true), + ], + ); + graph.relations.insert( + schema.id, + vec![edge(&apply_test, &schema, RelationKind::Contains, false)], + ); + graph.relations.insert( + fixture.id, + vec![edge(&object_test, &fixture, RelationKind::Contains, false)], + ); + + let report = rank_impact_at(&graph, &root.id, 3).unwrap(); + assert_eq!( + report.test_candidates().count(), + 6, + "every piece of test code is still read as reaching the root" + ); + let read: Vec<(&str, u32, u32, bool)> = report + .tests + .iter() + .map(|test| { + ( + test.identity.name.as_str(), + test.hop, + test.call_hops, + test.by_call(), + ) + }) + .collect(); + assert_eq!( + read, + vec![ + ("makeSchema", 2, 2, true), + ("string.test", 2, 2, true), + ("apply.test", 2, 0, false), + ("object.test", 2, 0, false), + ] + ); + let apply = &report.tests[2]; + assert_eq!(apply.via, schema.id, "a fixture is counted as its module"); + assert!(is_test_unit(&make_schema) && is_test_unit(&apply_test)); + assert!(!is_test_unit(&schema) && !is_test_unit(&loose)); + assert!(!is_test_unit(&string_class), "source code is never a test"); + } } diff --git a/crates/kin-review/src/release_gate.rs b/crates/kin-review/src/release_gate.rs index 0fc6aa9ac..074174d75 100644 --- a/crates/kin-review/src/release_gate.rs +++ b/crates/kin-review/src/release_gate.rs @@ -511,6 +511,8 @@ mod tests { duration_ms: None, evidence_blob: None, exit_code: None, + ran_against: Vec::new(), + proof: None, }; store .create_verification_run(&run) @@ -700,6 +702,7 @@ mod tests { kind, display_name: "reviewer".into(), external_refs: vec![], + agent_session: None, }) .unwrap(); } @@ -867,6 +870,8 @@ mod tests { duration_ms: Some(1), evidence_blob: None, exit_code: Some(0), + ran_against: Vec::new(), + proof: None, }; store.create_verification_run(&run).unwrap(); store diff --git a/crates/kin-review/src/review.rs b/crates/kin-review/src/review.rs index 4c061ecbe..e0cb7e146 100644 --- a/crates/kin-review/src/review.rs +++ b/crates/kin-review/src/review.rs @@ -241,8 +241,27 @@ impl SemanticReview { store: &G, escape_evidence: Option<&crate::enrichment::EscapeEvidence<'_>>, ) -> Result { - let impact_report = - impact::analyze_impact_with_source(store, &semantic_diff, escape_evidence)?; + Self::review_from_diff_with_sources( + semantic_diff, + store, + crate::enrichment::ObservationSources { + escape_evidence, + no_resolver: None, + }, + ) + } + + /// [`Self::review_from_diff_with_source`] with every host observation the + /// review's source holds. A review of live work passes the host's resolver + /// readings, so a caller no resolver can prove on this host reads as + /// unproven for that reason rather than as enrichment still owed, the way + /// impact and the store-wide status read it. + pub fn review_from_diff_with_sources( + semantic_diff: SemanticDiff, + store: &G, + sources: crate::enrichment::ObservationSources<'_>, + ) -> Result { + let impact_report = impact::analyze_impact_with_sources(store, &semantic_diff, sources)?; let risk_summary = risk::assess_risk(&semantic_diff, &impact_report); let inline_comments = inline::collect_inline_comments(&semantic_diff, &impact_report); @@ -518,4 +537,79 @@ mod tests { assert!(review.impact.is_empty()); assert_eq!(review.risk.overall_risk, kin_model::review::RiskLevel::Low); } + + /// A review of live work reads a caller with no ledger the way impact and + /// the store-wide status read it. When no resolver can prove the caller's + /// language on this host, its call sites are unproven for that reason and + /// nothing is owed, so a reader is not told enrichment is still coming. + #[test] + fn a_review_with_the_hosts_resolvers_does_not_owe_what_no_resolver_can_prove() { + use kin_model::EntityStore as _; + + let store = InMemoryGraph::new(); + // A span places the caller in source that can hold calls. Without one + // it has no sites at all and nothing about it is unsettled. + let file = FilePathId::new("src/callers.rs"); + let ledgerless = Entity { + file_origin: Some(file.clone()), + span: Some(kin_model::SourceSpan { + file, + start_byte: 0, + end_byte: 3, + start_line: 1, + start_col: 0, + end_line: 1, + end_col: 3, + }), + ..test_entity("caller_without_a_ledger") + }; + store.upsert_entity(&ledgerless).unwrap(); + let change = SemanticChange { + id: test_change_id(1), + parents: vec![test_change_id(0)], + timestamp: Timestamp::now(), + author: AuthorId::new("test"), + message: "add my_func".into(), + entity_deltas: vec![EntityDelta::Added { + new: test_entity("my_func"), + }], + relation_deltas: vec![], + tree_deltas: vec![], + projected_files: vec![], + spec_link: None, + evidence: vec![], + risk_summary: None, + origin: kin_model::ChangeOrigin::Native, + admission_policy_delta: None, + external_reference_deltas: Vec::new(), + resolution_record_deltas: Vec::new(), + }; + + let unsourced = + SemanticReview::review_from_diff_with_source(diff_from_change(&change), &store, None) + .unwrap(); + let owed = unsourced.impact.enrichment.expect("an observation"); + assert_eq!(owed.owed_entities, 1, "{owed:?}"); + + let no_resolver = |_: LanguageId| Some(kin_model::NoResolver::NoLanguageServer); + let sourced = SemanticReview::review_from_diff_with_sources( + diff_from_change(&change), + &store, + crate::enrichment::ObservationSources { + escape_evidence: None, + no_resolver: Some(&no_resolver), + }, + ) + .unwrap(); + let unproven = sourced.impact.enrichment.expect("an observation"); + assert!(unproven.bounds_answer(), "{unproven:?}"); + assert_eq!(unproven.owed_entities, 0, "{unproven:?}"); + assert_eq!( + unproven + .pending_by_code + .get("call_sites_unproven_no_resolver"), + Some(&1), + "{unproven:?}" + ); + } } diff --git a/crates/kin-review/src/risk.rs b/crates/kin-review/src/risk.rs index 60e290688..6cbdc8c1f 100644 --- a/crates/kin-review/src/risk.rs +++ b/crates/kin-review/src/risk.rs @@ -2328,6 +2328,7 @@ mod tests { authored_by: IdentityRef::human("dev"), created_at: kin_model::timestamp::Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }; let impact = ImpactReport { diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 1223387d6..e623e6d16 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -23,6 +23,54 @@ fi KIN_DIR="$(cd "${WORKSPACE_DIR}/.kin" && pwd -P)" +activate_managed_pair() { + runtime_home="$1" + mkdir -p "${runtime_home}/bin" || return 1 + runtime_home="$(cd "${runtime_home}" && pwd -P)" + cli="${runtime_home}/bin/kin" + daemon="${runtime_home}/bin/kin-daemon" + if [ -e "${cli}" ] || [ -L "${cli}" ] || [ -e "${daemon}" ] || [ -L "${daemon}" ]; then + if [ ! -f "${cli}" ] || [ ! -x "${cli}" ] || [ ! -f "${daemon}" ] || [ ! -x "${daemon}" ]; then + echo "[entrypoint] FATAL: canonical image pair is incomplete; existing files were preserved." >&2 + return 1 + fi + else + staging="$(mktemp -d "${runtime_home}/bin/.image-pair.XXXXXX")" || return 1 + if ! cp /usr/local/bin/kin "${staging}/kin" \ + || ! cp /usr/local/bin/kin-daemon "${staging}/kin-daemon" \ + || ! chmod 0755 "${staging}/kin" "${staging}/kin-daemon"; then + rm -rf "${staging}" + return 1 + fi + if ! ln "${staging}/kin" "${cli}"; then + rm -rf "${staging}" + return 1 + fi + if ! ln "${staging}/kin-daemon" "${daemon}"; then + [ ! "${cli}" -ef "${staging}/kin" ] || rm -f "${cli}" + rm -rf "${staging}" + return 1 + fi + rm -rf "${staging}" + fi + if [ "$(sha256sum < "${cli}")" != "$(sha256sum < /usr/local/bin/kin)" ] \ + || [ "$(sha256sum < "${daemon}")" != "$(sha256sum < /usr/local/bin/kin-daemon)" ]; then + echo "[entrypoint] FATAL: canonical pair differs from image bytes; existing files were preserved." >&2 + return 1 + fi + if ! KIN_HOME="${runtime_home}" KIN_RUNTIME_MODE=managed \ + "${cli}" runtime status --json >&2; then + echo "[entrypoint] FATAL: canonical image pair is not ready in managed mode." >&2 + return 1 + fi + printf '%s\n' "${runtime_home}" +} + +SELECTED_RUNTIME_HOME="${KIN_HOME:-${HOME}/.kin}" +if [ "${KIN_STORAGE:-local}" = local ]; then + SELECTED_RUNTIME_HOME="$(activate_managed_pair "${SELECTED_RUNTIME_HOME}")" || exit 1 +fi + # Paths every layout version creates. HEAD, objects/ and kindb/graph.kndb were # git-shaped artifacts of the pre-authority layout and are NOT produced by a # current `kin init`, so testing for them was unconditionally true and turned the @@ -59,8 +107,12 @@ if ! layout_is_materialized; then echo "[entrypoint] Prepare the repository before anything is mounted under .kin, or mount that volume outside the repository." >&2 exit 1 fi + if [ "${KIN_STORAGE:-local}" != local ]; then + SELECTED_RUNTIME_HOME="$(activate_managed_pair "${SELECTED_RUNTIME_HOME}")" || exit 1 + fi rm -rf "${KIN_DIR}" - kin init "${WORKSPACE_DIR}" + KIN_HOME="${SELECTED_RUNTIME_HOME}" KIN_RUNTIME_MODE=managed \ + "${SELECTED_RUNTIME_HOME}/bin/kin" init "${WORKSPACE_DIR}" fi # Start kin-daemon. Let K8s handle restarts via restartPolicy. Using exec so the @@ -69,4 +121,8 @@ fi # the prepared directory authoritative regardless of any --repo carried by the # image CMD or pod args; configure the workspace via KIN_WORKSPACE_DIR instead. echo "[entrypoint] Starting kin-daemon (storage=${KIN_STORAGE:-local}, workspace=${WORKSPACE_DIR})..." +if [ "${KIN_STORAGE:-local}" = local ]; then + export KIN_HOME="${SELECTED_RUNTIME_HOME}" KIN_RUNTIME_MODE=managed + exec "${SELECTED_RUNTIME_HOME}/bin/kin-daemon" "$@" --repo "${WORKSPACE_DIR}" +fi exec /usr/local/bin/kin-daemon "$@" --repo "${WORKSPACE_DIR}" diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 556c8e732..598a7fee9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -70,7 +70,12 @@ kin init [path] [options] `refs`, `context`, `impact` and `trace` answer as soon as it is published, and derives the full history afterwards in a worker at background priority with a quarter of the machine's threads and a third of its memory ceiling. The derivation is staged beside the repository and replaces - the store the next time no daemon is serving it; a daemon that restarts resumes it. + the store when no daemon is serving it. A daemon that is serving finds the staged history + within seconds and switches at its first quiet point: when no request is being answered and + the store holds no open work (a merge, a stash, an agent's transaction, an unapplied entity + draft or a release being recorded), it stops, and the daemon that takes its place attaches the + history as it starts. Until then `kin status` says what the switch is waiting for. A daemon + that restarts resumes a derivation that was still running. - `none` admits the checked-out commit only and never derives history, for CI jobs, containers and agent sandboxes. It also admits a shallow clone. - `full` admits the whole reachable history before the store is published, as `kin init` always @@ -270,6 +275,8 @@ The commit lands in Kin's own authority, not in Git. Nothing is written to `.git `kin commit --amend` replaces the current change with the full working state and keeps that change's parents. The message is kept unless `-m` supplies a new one. The replaced change stays immutable in history with its author, and the amend is recorded as the caller's operation. An amend is refused while a merge is open and when the selected head moved since it was read. A detached workspace amends only its own workspace and moves no ref. A plain `kin commit` with nothing to record still refuses as before. +`kin commit --entity ` records only the named entities' uncommitted changes and leaves every other change uncommitted. Repeat the flag to choose more entities. The ids are the ones a review guide of uncommitted work gives each step, and an unparsed entity is chosen by its own id and recorded whole. The working copy is not written: the change holds the last commit plus the chosen work, and everything else is still uncommitted afterwards, exactly as it was. Text no entity holds, such as an import between declarations, is recorded once every changed entity around it is chosen. Kin records nothing and says why in one sentence when a chosen entity's change shares lines with one that was not chosen, when recording it alone would leave code that does not parse, when it sits beside an unchosen entity in content no commit holds yet, or when the entity has no uncommitted change of its own. + A commit records its author, so with no author configured it refuses and exits 10. The first line names what is missing, such as `Git has no user.name and no user.email`, and the lines after it give the `git config` commands that set them and the Kin-only alternative. Every command that records an author refuses the same way with the same status. Measuring the working copy never needs one: `kin status`, `kin diff`, `kin admit` and the review guide work with no Git identity configured. ``` @@ -281,6 +288,7 @@ kin commit [options] | `-m, --message ` | | Commit message; amend preserves the existing message when omitted | | `--amend` | | Replace the current change with the full working state, preserving its parents | | `-q, --quiet` | | Suppress progress output (only print final summary) | +| `--entity ` | | Commit only this entity's uncommitted change, leaving every other change uncommitted (repeat for more entities). Conflicts with `--amend`. | ### `kin log` @@ -1102,6 +1110,8 @@ nothing pending. Language-server enrichment derived for the previous state is re Restore an exact path or subtree from immutable repository-v6 history +`kin checkout --entity ` restores one entity instead of a path. Its source goes back to what it was at the head, or at `--change`, as an uncommitted edit, and nothing else in the working copy moves. An entity removed since then comes back, and one added since then is removed. `--expected source:` takes the exact entity span hash a review guide reports in `after.body_hash`, and refuses if that source changed after it was read, including comments and formatting. `--expected absent` guards the restoration of a removed entity. A restore replaces the entity's whole source, so a semantic fingerprint such as `behavior` or `ast` is refused even while it matches: it stays the same when a comment or spacing changes, so it cannot show that nothing was written after the read. An unparsed entity is restored whole, and its fingerprint is its content address. + ``` kin checkout [path] [options] ``` @@ -1114,6 +1124,8 @@ kin checkout [path] [options] | --- | --- | --- | | `--path-hex ` | | Byte-exact repository path as canonical lowercase hexadecimal. Conflicts with `[path]`. | | `--change ` | | Change ID (defaults to current branch head) | +| `--entity ` | | Restore one entity's source, as an uncommitted edit, instead of a path. Conflicts with `[path]` and `--path-hex`. | +| `--expected ` | | Refuse unless the entity's source is exactly as last read: `source:` from a review, or `absent`. Requires `--entity`. | ### `kin merge` @@ -1689,6 +1701,34 @@ recorded consumer once, under the most any change it reaches asks of it, in `consumers_not_updated`, `consumers_may_need_review` and `consumers_need_no_update`, which add up to `recorded_consumers`. +A step an agent wrote carries `authored_by`, one entry per agent session, +newest first: `name` (such as `Codex` or `Claude Code`), `session`, `at` and +`through`. The summary's `authors` lists those sessions with how many steps +each wrote. A step nobody is named on was changed by a person, or by a tool +that told Kin nothing. + +`through` is `kin` for a write the agent made through Kin. It names only the +entity the agent's own operation named or created, so an edit that came along +in the same change, or an entity whose place only moved, carries none. A write +through Kin is recorded as a change the moment it lands, so it appears on a +change or a range guide and never on uncommitted work. + +`through` is `client` for an edit the agent made with its client's own edit +tools, which is how most agents edit. The client runs Kin's paired edit hooks before and after +each edit. They carry the same tool operation id, and the hooks tell the repository's daemon which session made the edit +and what it replaced. The daemon names the agent on the entities whose +content that edit changed. It does so only when the reported edit, applied to +what the entity's source held before, gives exactly what it holds after: an +edit that reached Kin together with someone else's change, or that a formatter +rewrote first, names nobody. `session` is then the client's own session, and +`kin_session` is the Kin session the same agent was connected through when +exactly one session of that client was connected. Such an edit appears on +uncommitted work until a change records it, and on that change and the ranges +that hold it afterwards. + +The daemon keeps both records in the store, so they survive a restart: the +newest 512 writes through Kin and the newest 2048 edits clients reported. + #### `kin review open` Serve the guided review as a local page on this machine @@ -3934,7 +3974,7 @@ kin setup [] [options] | `--auto-daemon` | | Start the Kin daemon on its own when you enter a Kin repository | | `--no-interactive` | | Ask nothing: use the defaults and any flags given | | `--skip-mcp-check` | | Skip the test call that proves each connected AI client can reach Kin (for a scripted install outside a repository) | -| `--clients ` | | Connect only these AI clients, by the ids `kin setup clients` lists, leaving every other client as it is. An empty list (`--clients ""`) runs the rest of setup and connects none | +| `--clients ` | | Connect only these AI clients, by the ids `kin setup clients` lists, leaving every other client as it is. An empty list (`--clients ""`) runs the rest of setup and connects none. Connecting Claude Code also writes Kin's edit hook into its user settings, beside the hooks already there: paired `PreToolUse` and `PostToolUse` groups around `Edit`, `Write` and `MultiEdit`, so a review can name Claude Code on the entities it edited. `kin setup uninstall` removes it with the rest | | `--shell-only` | | Add kin to your shell and nothing else: only setup's shell step (the shell hook, and the line that puts Kin's bin directory on PATH). It asks nothing, connects no AI client, and leaves the daemon, the prompt hook and language servers as they are. Exits 1 when the profile cannot be written | | `--json` | | With `--shell-only`: print the result as one JSON object, `kin.setup-shell.v1` | | `--install-language-servers` | | Install missing language servers without asking: inside a repository, only the servers its languages need; outside one, the servers for the languages Kin supports. An install can write outside Kin's directory, and the run says so before it does, so an interactive run asks first and a scripted one needs this flag | @@ -4008,8 +4048,18 @@ kin setup doctor [options] | Flag | Default | Description | | --- | --- | --- | | `--fix` | | Apply safe automatic repairs (shell hook, MCP configs, config dirs) | +| `--remove-other-copies` | | With `--fix`: also remove every other copy of Kin that has its own uninstall | | `--json` | | Emit the machine-readable health report as JSON | +`--remove-other-copies` runs each copy's own channel: `npm uninstall --global --prefix +` for an npm launcher older than the managed Kin, `cargo uninstall --root +` for a `cargo install`, `brew uninstall ` for a Homebrew formula, and a plain +delete for a standalone release file placed by hand. It never removes a development build or +anything inside the managed Kin home, and it proves each path is gone afterwards. `--fix` alone +never removes a copy, because `kin update` runs `kin setup doctor --fix` unattended at the end of +its chain. `kin setup` asks about each copy instead, defaulting to keep it, and a run with no +terminal only names them. + #### `kin setup clients` List the AI clients Kin can connect, and which are installed and connected @@ -4064,6 +4114,7 @@ kin doctor [options] | Flag | Default | Description | | --- | --- | --- | | `--fix` | | Apply safe automatic repairs (shell hook, MCP configs, config dirs) | +| `--remove-other-copies` | | With `--fix`: also remove every other copy of Kin that has its own uninstall (see `kin setup doctor`) | | `--json` | | Emit the machine-readable health report as JSON | | `--drift` | | Compare an explicit projection observation with graph truth | | `--heal` | | Rematerialize the derived projection from graph truth, DISCARDING uncommitted changes to tracked files that diverge from it | diff --git a/docs/env-vars.md b/docs/env-vars.md index c05190429..eafead01f 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -3,7 +3,7 @@ # Kin environment variables -This is the authoritative list of supported `KIN_*` environment variables (611 total, 397 correctness-relevant), generated from the central registry in `kin-core`. +This is the authoritative list of supported `KIN_*` environment variables (616 total, 398 correctness-relevant), generated from the central registry in `kin-core`. At CLI and daemon startup Kin validates this surface (`KIN_ENV_VALIDATION`, default `warn`): @@ -31,7 +31,7 @@ Sensitivity legend: **correctness** (affects retrieval/ranking/output or data sa | `KIN_BASE_URL` | url | GitHub Releases | operational | release mirror base URL used by the shell and PowerShell installers | | `KIN_BINARY_PATH` | path | *(unset)* | operational | override the kin binary path for bench dispatch | | `KIN_DIR` | path | *(unset)* | operational | compatibility alias for the managed Kin install root; KIN_HOME wins when both are set | -| `KIN_HOME` | path | ~/.kin | operational | preferred root for the managed Kin install used by the launcher, setup, and shell hooks; bounds install and store state, NOT the machine-wide daemon supervisor | +| `KIN_HOME` | path | ~/.kin | operational | preferred root for the admitted Kin runtime, install, stores and supervisor service namespace; development builds use an isolated home; KIN_HOME wins over KIN_DIR | | `KIN_LAUNCHER_ADOPT` | bool | false | operational | force the @kinlab/kin launcher to provision its pinned release, including an intentional downgrade | | `KIN_MANAGED_BIN` | path | *(unset)* | operational | explicit native Kin binary used by the @kinlab/kin launcher instead of provisioning | | `KIN_MCP_AUTO_INIT` | bool | false | operational | allow the @kinlab/kin-mcp wrapper to initialize a missing repository before startup | @@ -49,12 +49,14 @@ Sensitivity legend: **correctness** (affects retrieval/ranking/output or data sa | `KIN_NO_PROVISION` | bool | false | operational | forbid network provisioning by the @kinlab/kin launcher | | `KIN_NO_SETUP` | bool | false | operational | skip the installer's post-install setup wizard when set truthy | | `KIN_REGISTRY_REPAIR` | bool | false | operational | allow the POSIX installer to repair safe registry ownership modes | +| `KIN_RUNTIME_MODE` | enum | *(unset)* | correctness | runtime admission mode: clean release builds default to managed; debug or dirty builds default to development with isolated home and services; explicit managed requires known clean release identity | | `KIN_VERSION` | string | latest | operational | release version selected by the shell and PowerShell installers | ## General | Variable | Kind | Default | Sensitivity | Description | | --- | --- | --- | --- | --- | +| `KIN_ACCEPTANCE_LABEL` | string | *(unset)* | diagnostic | dev/test tooling: the label the product acceptance scripts stamp on their reports, set once for a whole acceptance job; Kin itself does not read it | | `KIN_ACTOR` | string | *(unset)* | operational | actor identity recorded in provenance | | `KIN_AGENT_BELT` | string | default | operational | how much of the server's tool surface `kin agent run` puts on the model: default (the tools an agent needs to answer, edit and publish) or wide (everything the profile serves that the harness does not own) | | `KIN_AGENT_CONTEXT_ACCOUNTING` | enum | heuristic | correctness | agent request accounting: heuristic estimates the prepared request; llama_cpp uses the selected server's rendered prompt and tokenizer | @@ -70,7 +72,9 @@ Sensitivity legend: **correctness** (affects retrieval/ranking/output or data sa | `KIN_BUILD_GRAPH_TIMEOUT_SECS` | seconds>=0 | 60 | operational | timeout for building a historical ref-view graph | | `KIN_BYPASS_EMBEDDING_COVERAGE_CHECK` | bool | false | correctness | bypass the embedding-coverage correctness gate | | `KIN_CI_LANGUAGE_SERVERS_INSTALLED` | enum | *(unset)* | diagnostic | dev/test tooling: `1` once scripts/ci-install-language-servers.sh has installed and proved the language servers, so the kin-daemon reference-enrichment tests require them instead of skipping; Kin itself does not read it | +| `KIN_DEV_TARGET_ROOT` | path | *(unset)* | diagnostic | dev/test tooling: root directory holding cargo target trees outside the checkout; daemon succession treats an executable under it as a development build and leaves it running | | `KIN_DISABLE_SPINE` | bool | false | correctness | disable the spine federation layer, narrowing retrieval scope | +| `KIN_EPHEMERAL_RUNNER` | string | *(unset)* | diagnostic | dev/test tooling: `true` when a CI job runs on a runner that lasts one job, so the CI target scripts restore a cached build target instead of keeping one; Kin itself does not read it | | `KIN_LANGUAGE_SERVER_ASSET_BASE` | url | GitHub Releases | operational | base URL the language-server release binaries are fetched from; set by acceptance checks to drive the standalone install against a fixture | | `KIN_LANGUAGE_SERVER_ASSET_SHA256` | string | *(unset)* | operational | sha256 an overridden language-server asset is verified against; IGNORED unless KIN_LANGUAGE_SERVER_ASSET_BASE is also set, so it can never relax the pinned release check | | `KIN_LANGUAGE_TOOL_SEARCH` | bool | true | operational | look for language servers and the programs they run on beyond the PATH a process inherited: in the directories `kin setup` and `kin doctor --fix` recorded from the shell they ran in, then in the usual per-user install places (rustup's and Go's bin directories, npm's global prefix, nvm, volta, fnm, pyenv, asdf, mise, ~/.local/bin, Homebrew). Each goes behind the inherited entries and ahead of Kin's own tool directories, so a server the operator installed wins. Set falsy to search only the inherited PATH and Kin's own tool directories. Read at process start, so a daemon keeps the value it started with | @@ -182,6 +186,7 @@ Sensitivity legend: **correctness** (affects retrieval/ranking/output or data sa | `KIN_DAEMON_PASS_STALL_SECS` | seconds>=0 | 600 | operational | how long a background pass may run without recording durable progress before the daemon stops it; 0 disables stopping | | `KIN_DAEMON_PERIODIC_FLUSH_SECS` | seconds>=0 | 30 | operational | maximum interval before dirty graph state is flushed | | `KIN_DAEMON_READY_TIMEOUT_SECS` | seconds>=0 | 300 | operational | how long to wait for a starting daemon to become ready | +| `KIN_DAEMON_REQUIRE_RUNTIME_IDENTITY` | bool | false | operational | make a development daemon refuse a local request that lacks the caller's runtime identity and repository claims, as a managed daemon always does; it can only make admission stricter, and exists so a debug build can prove a client sends what a release daemon requires. Read by the daemon at process start | | `KIN_DAEMON_REQUIRE_TOKEN` | bool | true | operational | require a bearer token for all daemon requests; set falsy to opt out | | `KIN_DAEMON_RUNTIME_SHUTDOWN_GRACE_SECS` | seconds>=0 | 8 | operational | bound on tokio runtime teardown waiting for blocking tasks | | `KIN_DAEMON_SCOPE_BUILD_TIMEOUT_SECS` | seconds>=0 | *(unset)* | operational | timeout for a daemon-side scope graph build | diff --git a/docs/gemini-extension-context.md b/docs/gemini-extension-context.md index a7fa629e8..61800e9c3 100644 --- a/docs/gemini-extension-context.md +++ b/docs/gemini-extension-context.md @@ -87,6 +87,19 @@ repository, tests and examples included. A semantic answer is never quietly back raw file search. The `full` profile serves the machine form of every answer, with its verdict codes and the evidence they were computed from. +## A reviewer's flags + +A person reviewing your uncommitted work can flag an entity with a note. The flag is held in +the repository, and every answer that returns the flagged entity carries it: `_kin.flags` +lists each flag as `entity`, `name`, the reviewer's `note`, `by` and `at`, and `_kin.advice` +says it in a sentence. `changed_since` is set once the entity is no longer the version that was +flagged; `source_unavailable` means Kin could not confirm its current recorded source. +Text answers include every delivered note. Address each note before committing. `status` +lists open flags in pages of 25. Continue with `flags_cursor` from `_kin.flags_next_cursor`, +separately from the enrichment `cursor`; restart the listing if that flag is closed or +replaced. Start there when asked to address what was flagged. A flag is advice from the +reviewer: it refuses no call and no commit, and only the reviewer closes it. + ## If the tools have no graph The server answers from the Kin store of the working directory's repository. If there is none, run `kin init .` diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 952455cf9..2cba09702 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -563,6 +563,41 @@ admission has reached both read that way until their graph is loaded. The status also names the daemon that gave it under `_kin.answered_by`: its pid, repository root, route and uptime, the same fields `kin daemon status` prints for it. +### A reviewer's flags + +A person reviewing uncommitted work in the local app can flag an entity with a note. The flag +is held in the repository as an annotation on that entity, scoped to the version the reviewer +was looking at, and it reaches the agent that made the change through the answers it already +reads. New app flags name the exact reviewed entity source span by its digest, including +comments and literal values. Changes elsewhere do not make that entity's flag stale. Older +flags retain their original semantic revision guard: + +- Every answer that returns a flagged entity, from `locate`, `source`, `context`, `trace`, + `refs`, `impact`, `neighbors` or a write, carries `_kin.flags`: one object per open flag with + `entity` (the id to pass back), `name`, the reviewer's `note`, `by` and `at`. `changed_since` + is `true` once the entity is no longer the version that was flagged. At most five are + listed on one answer. `source_unavailable` means Kin could not confirm the current + recorded source, so it makes no claim that the source is unchanged. +- `_kin.advice` says the same in a sentence, for example `A reviewer flagged + ServerReply.send: a second send must fail loudly. Address it before committing.` An answer + served as text carries every delivered flag's entity, author and full note as its first + lines. `ask` prompt answers and refusals carry the same notes. +- `status` lists open flags in `_kin.flags`, oldest first, with up to 25 per page. Pass + `_kin.flags_next_cursor` back as `flags_cursor` to reach the next page, independently of + the enrichment `cursor`. Closing or replacing the cursor's flag makes it stale; restart + without `flags_cursor`. An agent asked to address what was flagged starts there. + With no flag open, no answer + carries `_kin.flags`, and every answer is served exactly as it would be without this + feature. + +The flags are written after the response budget, so an answer that carries them can exceed +its `max_chars` by their size: at most five flags on an answer and 25 on `status`, each note +at most 400 characters. A reviewer's note is never cut to fit. + +A flag is advisory. It refuses no call and no commit, and it stays open until the reviewer +closes it: committing a flagged entity does not close it. The repository records each agent +session an answer handed a flag to, which is how the reviewer sees that the note arrived. + --- ## Configuring the server diff --git a/docs/quickstart.md b/docs/quickstart.md index f6bc0ede7..694fb9683 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -724,6 +724,9 @@ The checks (IDs as emitted in `--json`): | --- | --- | | `kin_binary` | The `kin` binary resolved (reports version + path). | | `kin_daemon_binary` | `kin-daemon` found beside `kin` or on `PATH`. | +| `launcher_version_stamp` | The npm launcher's version stamp, `~/.kin/bin/.kinlab-kin-version`, names the release that is installed. **n/a** when there is no stamp, which is what an install the npm launcher did not provision looks like. **STALE** when it names another release; `kin doctor --fix` rewrites it. | +| `other_kin_copies` | No other copy of Kin can run on this machine. Every `kin`, `kin-daemon` and `kin-mcp` on `PATH`, in Cargo's and Homebrew's directories, in npm global prefixes and in `/usr/local/bin` is named with what it is. A link to the managed Kin, an npm launcher at least as new as it, a release from 0.8.7 on (which hands its commands to the managed Kin) and a development build are named without being flagged. **STALE** names each older or unidentified copy and the uninstall that removes it. | +| `live_daemons_by_release` | Every running `kin-daemon` runs the managed Kin. **STALE** names, by release, each one that runs another copy or a managed release that has since been replaced, with its process id and repository. | | `vfs_projection` | The VFS shim is installed and non-zero in `~/.kin/lib`, and the `kin-vfs` driver beside `kin`, in `~/.kin/bin`, or on PATH runs when probed (macOS/Linux). A driver that is present but will not load is **MISCONFIGURED** and the row quotes the loader; no driver and no shim is **n/a**. On native Windows this is **unsupported**; use WSL2. | | `projection_mode` | Which projection is in force and whether it works, read `mode/mounted/readable/writable/degraded` from a live probe across the shim, an NFS mount, a FUSE mount and Windows ProjFS. **n/a** when no projection is available and none is configured, which is what an install that ships without one looks like; Windows never gets that reading, because ProjFS is on every SKU and only needs enabling. A configured mode that is not running is **MISCONFIGURED**. A shim that is installed but not injected into this process is **STALE**, which is what running `kin` from a shell without the hook looks like. | | `repo_init` | The current directory is inside a Kin repository. | @@ -747,6 +750,13 @@ Each failing/incomplete check prints its own `fix:` line. The most common ones: - **`projection_mode` MISCONFIGURED** → run `kin vfs status` for each mode's probe result, then `kin vfs on` to engage one. See [Filesystem projection](projection.md). - **`kin_daemon_binary` MISSING** → reinstall Kin so `kin-daemon` lands beside `kin`. +- **`launcher_version_stamp` STALE** → run `kin doctor --fix`, which rewrites the stamp to the + installed release under the install lock. `kin update` keeps it current from then on. +- **`other_kin_copies` STALE** → run `kin setup`, which asks about each copy and defaults to + keeping it, or `kin setup doctor --fix --remove-other-copies` to remove every one that has its + own uninstall. Development builds and the managed Kin home are never touched. +- **`live_daemons_by_release` STALE** → run `kin daemon stop` in each repository the row names; + the managed Kin starts its own daemon on the next command there. - **`commit_identity` MISCONFIGURED** → set `git config --global user.name` and `git config --global user.email`, or put `default_author = "Your Name "` in `.kin/config.toml`. Kin refuses to commit rather than attribute a change to nobody, diff --git a/docs/runtime-installation.md b/docs/runtime-installation.md new file mode 100644 index 000000000..9ea8d38ca --- /dev/null +++ b/docs/runtime-installation.md @@ -0,0 +1,40 @@ +# One admitted runtime + +Normal Kin commands use one managed CLI and daemon pair. Its root is `KIN_HOME`, +then `KIN_DIR`, then `~/.kin`; empty overrides are ignored. Both executables live +under that root's `bin` directory. + +```sh +kin runtime status --json +``` + +This checks the installed executable pair without opening a repository or +starting a daemon. The `kin.runtime-status.v1` record reports the paths, versions, +exact source commits, dirty state, build targets and profiles, binary hashes, +recorded update channel, and pending restart acknowledgement. A refusal still +returns JSON and exits nonzero. `ready` requires known clean release identities +that agree, and no pending restart fence. Matching version numbers alone are +insufficient. A channel preference does not prove that a build was published. + +An ordinary release executable found elsewhere on PATH delegates repository, +UI and MCP commands to the admitted managed CLI. It refuses when that pair is +missing or mismatched. `kin setup` writes clients against the admitted pair; +diagnostics and the native updater remain available to repair an installation. +The updater retains its existing transaction and restart acknowledgement fences. + +## Development builds + +Debug and dirty builds default to development mode. Development uses a separate +Kin home and service namespace, derived from the executable directory and build +identity. An explicit isolated `KIN_HOME` can select a test fixture; the ordinary +`~/.kin` home is refused. Development cannot perform global setup, doctor repairs, +or managed update mutations. Read-only checks and dry runs remain available. + +```sh +kin runtime status --json --development --binary /path/to/build/kin +``` + +The selected CLI must have a matching sibling daemon. For an ordinary development +command, set `KIN_RUNTIME_MODE=development`; wrappers pass the admitted isolated +home to its children. Clean release builds default to managed mode, and explicitly +requesting managed mode never admits an unknown, dirty, or debug identity. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 211a83097..843b35980 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -740,7 +740,7 @@ dependencies = [ [[package]] name = "kin-grammar-typescript" -version = "0.8.6" +version = "0.8.7" dependencies = [ "cc", "tree-sitter-language", @@ -748,7 +748,7 @@ dependencies = [ [[package]] name = "kin-io" -version = "0.8.6" +version = "0.8.7" dependencies = [ "cap-std", "kin-blobs", @@ -775,7 +775,7 @@ dependencies = [ [[package]] name = "kin-parser" -version = "0.8.6" +version = "0.8.7" dependencies = [ "kin-grammar-typescript", "kin-io", diff --git a/packages/boundary-contracts/package.json b/packages/boundary-contracts/package.json index 6dadc50e1..1563e28eb 100644 --- a/packages/boundary-contracts/package.json +++ b/packages/boundary-contracts/package.json @@ -1,6 +1,6 @@ { "name": "@kin/boundary-contracts", - "version": "0.8.6", + "version": "0.8.7", "private": false, "description": "Shared contracts for Kin ecosystem boundaries.", "type": "module", diff --git a/packages/boundary-contracts/schemas/annotation.schema.json b/packages/boundary-contracts/schemas/annotation.schema.json index cc3bf2d15..6c5f1fd24 100644 --- a/packages/boundary-contracts/schemas/annotation.schema.json +++ b/packages/boundary-contracts/schemas/annotation.schema.json @@ -31,6 +31,57 @@ }, { "type": "null" } ] + }, + "review": { + "description": "Present when a reviewer set this annotation as a flag on the entity it is scoped to. The annotation's body is the reviewer's note. Absent on every other annotation.", + "type": "object", + "required": ["flagged_ast", "flagged_name", "operation_id", "resolution", "seen_by"], + "properties": { + "flagged_ast": { "$ref": "kin://contracts/hash256" }, + "flagged_name": { "type": "string" }, + "operation_id": { "type": "string" }, + "resolution": { + "oneOf": [ + { + "type": "object", + "required": ["by", "at", "operation_id"], + "properties": { + "by": { + "type": "object", + "required": ["name", "kind"], + "properties": { + "name": { "type": "string" }, + "kind": { "type": "string", "enum": ["Human", "Assistant"] } + } + }, + "at": { "type": "string" }, + "operation_id": { "type": "string" } + } + }, + { "type": "null" } + ] + }, + "seen_by": { + "type": "array", + "items": { + "type": "object", + "required": ["agent", "session", "at"], + "properties": { + "agent": { "type": "string" }, + "session": { "type": "string" }, + "at": { "type": "string" } + } + } + }, + "flagged_behavior": { + "description": "Legacy literal-sensitive revision guard; absent on older flags.", + "oneOf": [{ "$ref": "kin://contracts/hash256" }, { "type": "null" }] + }, + "flagged_source": { + "description": "Exact reviewed entity-span digest, including comments and literals.", + "oneOf": [{ "$ref": "kin://contracts/hash256" }, { "type": "null" }] + } + } } } } diff --git a/packages/boundary-contracts/schemas/mcp-envelope-v2.schema.json b/packages/boundary-contracts/schemas/mcp-envelope-v2.schema.json index 82ff558e4..166b6e949 100644 --- a/packages/boundary-contracts/schemas/mcp-envelope-v2.schema.json +++ b/packages/boundary-contracts/schemas/mcp-envelope-v2.schema.json @@ -614,6 +614,48 @@ "advice": { "type": "string" }, + "flags": { + "description": "A reviewer's open flags on entities this answer returned, each with the reviewer's note. Status pages list up to 25 open flags; pass flags_next_cursor as flags_cursor to continue. Advisory: a flag stops nothing.", + "type": "array", + "items": { + "type": "object", + "properties": { + "entity": { + "type": "string" + }, + "name": { + "type": "string" + }, + "note": { + "type": "string" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string" + }, + "changed_since": { + "type": "boolean" + }, + "source_unavailable": { + "type": "boolean" + } + }, + "required": [ + "entity", + "name", + "by", + "at" + ], + "additionalProperties": true + } + }, + "flags_next_cursor": { + "description": "Continue status review flags by passing this as flags_cursor; independent of the enrichment cursor.", + "type": "string", + "maxLength": 64 + }, "repository": { "type": "object", "properties": { diff --git a/packages/boundary-contracts/schemas/repo-entities.schema.json b/packages/boundary-contracts/schemas/repo-entities.schema.json index 59773d5c5..5183f2293 100644 --- a/packages/boundary-contracts/schemas/repo-entities.schema.json +++ b/packages/boundary-contracts/schemas/repo-entities.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "kin://contracts/repo-entities", - "description": "The entities of one repository's graph, as served by GET /repos/{repo_id}/entities on the daemon. Identity is always present on a row; the ranking numbers and the text ride along only when the request asked for them with include, and total appears only when the request asked for a window with limit or offset. A reader of the identity fields alone keeps reading every response this route has ever served.", + "description": "The entities of one repository's graph, as served by GET /repos/{repo_id}/entities on the daemon. Identity is always present on a row; the ranking numbers and the text ride along only when the request asked for them with include, total appears only when the request asked for a window with limit or offset, and untested appears only when the request asked for it. A reader of the identity fields alone keeps reading every response this route has ever served.", "type": "object", "required": ["repo_id", "entities"], "additionalProperties": false, @@ -18,9 +18,31 @@ "type": "integer", "minimum": 0, "description": "Entities the filter matched before the window was applied. Present only when the request asked for a window, where it is what separates a last page from a full one." + }, + "untested": { + "$ref": "#/$defs/untested", + "description": "The entities others rely on that no test reaches. Present only when the request asked for it with untested, which names how many rows to list." } }, "$defs": { + "untested": { + "type": "object", + "description": "The entities others rely on that no test reaches, most relied-on first. An entity is listed when at least one other depends on it, it is defined in this repository, it is neither a test nor a module, and neither a test entity nor a recorded test case's covered scope reaches it through the recorded dependency graph, including transitive dependencies. This is structural reach, not measured runtime coverage. It is the coverage kin verify --missing reads, collected in the same pass that ranks.", + "required": ["entities", "total"], + "additionalProperties": false, + "properties": { + "entities": { + "type": "array", + "items": { "$ref": "#/$defs/entity" }, + "description": "The most relied-on of them, as many as the request asked for, each with its ranking numbers." + }, + "total": { + "type": "integer", + "minimum": 0, + "description": "How many there are among the entities the request matched." + } + } + }, "entity": { "type": "object", "required": ["id", "name", "kind", "file_path"], diff --git a/packages/boundary-contracts/src/index.d.ts b/packages/boundary-contracts/src/index.d.ts index 6f97527ef..783b3f980 100644 --- a/packages/boundary-contracts/src/index.d.ts +++ b/packages/boundary-contracts/src/index.d.ts @@ -1076,6 +1076,23 @@ export interface RepoEntityEntry { summary?: string; } +/** + * The entities others rely on that no test reaches, most relied-on first. + * + * An entity is listed when at least one other depends on it, it is defined in + * this repository, it is neither a test nor a module, and no test reaches it: + * neither a test entity nor a recorded test case's covered scope reaches it + * through the recorded dependency graph, including transitive dependencies. + * This is structural reach, not measured runtime coverage. It is the coverage + * `kin verify --missing` reads. + */ +export interface RepoUntested { + /** The most relied-on of them, as many as were asked for, each with its ranking numbers. */ + entities: RepoEntityEntry[]; + /** How many there are among the entities the request matched. */ + total: number; +} + /** The entities of one repository's graph. */ export interface RepoEntitiesResponse { repo_id: string; @@ -1085,6 +1102,11 @@ export interface RepoEntitiesResponse { * request asked for one with limit or offset. */ total?: number; + /** + * What others rely on that no test reaches. Present only when the request + * asked for it with untested, which names how many rows to list. + */ + untested?: RepoUntested; } /** diff --git a/packages/boundary-contracts/test/contracts.test.js b/packages/boundary-contracts/test/contracts.test.js index 705139f95..8401c0703 100644 --- a/packages/boundary-contracts/test/contracts.test.js +++ b/packages/boundary-contracts/test/contracts.test.js @@ -1075,6 +1075,29 @@ test('an entity list is identity first, and everything ranked rides along', asyn 'the ranked answer is the same contract with more of it filled in' ); + // What others rely on that no test reaches rides along when asked for: the + // same rows, each with its ranking numbers, and how many there are in all. + const untested = structuredClone(ranked); + untested.untested = { + entities: [{ ...ranked.entities[0] }], + total: 7 + }; + assert.equal((await validateContract('repoEntities', untested)).ok, true); + const { total: _uncounted, ...uncounted } = untested.untested; + assert.equal( + (await validateContract('repoEntities', { ...untested, untested: uncounted })).ok, + false, + 'a list of what is untested without its total cannot say how much it left out' + ); + assert.equal( + (await validateContract('repoEntities', { + ...untested, + untested: { ...untested.untested, reason: 'guessed' } + })).ok, + false, + 'the list carries its rows and its total, and nothing nobody agreed on' + ); + const { file_path: _dropped, ...noPath } = minimalEntities.entities[0]; assert.equal( (await validateContract('repoEntities', { ...minimalEntities, entities: [noPath] })).ok, @@ -1115,6 +1138,11 @@ test('the entity list shape is one contract the Rust, the schema and the declara 'RepoEntitiesResponse', rustStructFields(apiSource, 'RepoEntitiesResponse').fields, Object.keys(schema.properties) + ], + [ + 'RepoUntested', + rustStructFields(apiSource, 'RepoUntested').fields, + Object.keys(schema.$defs.untested.properties) ] ]; @@ -1415,3 +1443,61 @@ test('MCP response accounting validates the canonical serialized budget field na {...envelope.response, chars_after_budget: -1} ]) assert.equal((await validateContract('mcpEnvelopeV2', {...envelope, response})).ok, false); }); + +test("MCP envelope carries a reviewer's open flags in the shape the producer writes", async () => { + const envelope = JSON.parse(await fs.readFile(path.join(packageRoot, + 'test/fixtures/mcp-budget-envelope-v2.json'), 'utf8')); + const flag = {entity: '50ba786c', name: 'ServerReply.send', note: 'a second send must fail loudly', + by: 'Ada Reviewer', at: '2026-10-13T23:40:00Z'}; + const {note, ...noteless} = flag; + for (const flags of [[], [flag], [noteless], [{...flag, changed_since: true}], + [{...flag, source_unavailable: true}]]) { + const checked = await validateContract('mcpEnvelopeV2', {...envelope, flags}); + assert.equal(checked.ok, true, JSON.stringify(checked.errors)); + } + const {name, ...nameless} = flag; + for (const flags of [flag, 'ServerReply.send', [nameless], [{...flag, changed_since: 'yes'}], + [{...flag, note: 7}], [{...flag, source_unavailable: 'yes'}]]) { + assert.equal((await validateContract('mcpEnvelopeV2', {...envelope, flags})).ok, false, + JSON.stringify(flags)); + } + // The schema names the fields the producer serializes, and no other. + const producer = await fs.readFile( + path.join(repositoryRoot, 'crates/kin-mcp/src/review_flags.rs'), 'utf8'); + const declared = producer.match(/pub struct FlagNotice \{([\s\S]*?)\n\}/)[1]; + const fields = [...declared.matchAll(/^\s*pub (\w+):/gm)].map(match => match[1]); + const schema = await loadSchema('mcpEnvelopeV2'); + assert.deepEqual(fields, Object.keys(schema.properties.flags.items.properties)); + assert.equal((await validateContract('mcpEnvelopeV2', { + ...envelope, flags: [flag], flags_next_cursor: 'review-flags:50ba786c-1111-4222-8333-444455556666' + })).ok, true); + assert.equal((await validateContract('mcpEnvelopeV2', { + ...envelope, flags: [flag], flags_next_cursor: 7 + })).ok, false); +}); + +test('the annotation contract declares a review flag as its producer serializes one', async () => { + const schema = JSON.parse(await fs.readFile( + path.join(packageRoot, 'schemas/annotation.schema.json'), 'utf8')); + // An annotation that is not a review flag carries no such field. + assert.equal(schema.required.includes('review'), false); + const producer = await fs.readFile( + path.join(repositoryRoot, 'crates/kin-model/src/work.rs'), 'utf8'); + const fieldsOf = name => { + const declared = producer.match(new RegExp(`pub struct ${name} \\{([\\s\\S]*?)\\n\\}`))[1]; + return [...declared.matchAll(/^\s*pub (\w+):/gm)].map(match => match[1]); + }; + const review = schema.properties.review; + assert.deepEqual(fieldsOf('ReviewFlag'), Object.keys(review.properties)); + assert.deepEqual(fieldsOf('ReviewFlag').filter(field => !['flagged_behavior', 'flagged_source'].includes(field)), review.required); + for (const field of ['flagged_behavior', 'flagged_source']) { + assert.equal(review.required.includes(field), false); + assert.deepEqual(review.properties[field].oneOf, [{ $ref: 'kin://contracts/hash256' }, { type: 'null' }]); + } + const resolution = review.properties.resolution.oneOf[0]; + assert.deepEqual(fieldsOf('ReviewFlagResolution'), Object.keys(resolution.properties)); + assert.deepEqual(fieldsOf('ReviewFlagResolution'), resolution.required); + const sighting = review.properties.seen_by.items; + assert.deepEqual(fieldsOf('ReviewFlagSighting'), Object.keys(sighting.properties)); + assert.deepEqual(fieldsOf('ReviewFlagSighting'), sighting.required); +}); diff --git a/packages/kin-mcp/.npmrc b/packages/kin-mcp/.npmrc new file mode 100644 index 000000000..70dc59df9 --- /dev/null +++ b/packages/kin-mcp/.npmrc @@ -0,0 +1,2 @@ +# Source development links share tracked sibling files. +bin-links=false diff --git a/packages/kin-mcp/README.md b/packages/kin-mcp/README.md index 0c21fb725..f4f52eaad 100644 --- a/packages/kin-mcp/README.md +++ b/packages/kin-mcp/README.md @@ -8,9 +8,11 @@ `@kinlab/kin-mcp` is the npm-friendly launcher for Kin's MCP server (the installed command is still `kin-mcp`). -It downloads the matching Kin release archive from GitHub, verifies the published -SHA-256 checksum, extracts both the `kin` CLI and the `kin-daemon` it depends on -into a local cache, and runs: +Ordinary launches share the canonical `@kinlab/kin` managed runtime at +`$KIN_HOME/bin` (`$KIN_DIR` is an alias; the default is `~/.kin/bin`). The +canonical launcher provisions a first install, migrates legacy releases through a +privately staged official native bootstrap, and certifies the CLI/daemon source +identity before this compatibility package runs: ```sh kin mcp start @@ -137,33 +139,32 @@ Stopping the daemons leaves these in place: | Path | Size | What it holds | | --- | --- | --- | -| `~/Library/Caches/kin-mcp` (macOS), `~/.cache/kin-mcp` (Linux), `%LOCALAPPDATA%\kin-mcp\Cache` (Windows) | about 150 MB per version | the `kin` and `kin-daemon` binaries this wrapper downloaded | +| `~/Library/Caches/kin-mcp` (macOS), `~/.cache/kin-mcp` (Linux), `%LOCALAPPDATA%\kin-mcp\Cache` (Windows) | about 150 MB per version | retained legacy binaries or an explicit development cache | | `~/.kin` | grows with the repositories Kin has embedded | Kin's home: daemon records, logs and the embedding cache | | `~/.cache/huggingface/hub/models--nomic-ai--nomic-embed-text-v1.5` | about 523 MB | the embedding model, fetched the first time Kin embeds a repository | | `~/.kin/stores/` for each repository you initialized (`.kin/` for one an earlier version initialized) | grows with history | that repository's graph | `kin-mcp --stop` prints each path with its size on your machine and the command -that removes it. By hand, once the daemons have stopped (macOS shown): +that removes it. The normal native installation is shared with the CLI and other +clients; use `kin setup uninstall --all` for its managed teardown. Legacy cache +bytes are separate and are not used by ordinary launches. -```sh -rm -rf ~/Library/Caches/kin-mcp ~/.kin ~/.cache/huggingface/hub/models--nomic-ai--nomic-embed-text-v1.5 -rm -rf .kin # in each repository you initialized -``` - -Keep `~/.kin` if you also installed Kin with its installer or `@kinlab/kin`; -`kin setup uninstall --all` removes that install instead. - -Set `KIN_MCP_CACHE_DIR` to move the binary cache. +`KIN_MCP_CACHE_DIR` selects an isolated development cache. Ordinary launches do +not serve the older versioned caches. Existing cache bytes are retained until +their owner removes them. ## Environment -- `KIN_MCP_KIN_BINARY`: run a specific local `kin` binary instead of downloading - one (you are then responsible for its `kin-daemon`) +- `KIN_MCP_KIN_BINARY`: select an explicit development `kin` binary + (you are responsible for its matching `kin-daemon`) - `KIN_BINARY_PATH`: alias for `KIN_MCP_KIN_BINARY` -- `KIN_MCP_CACHE_DIR`: override the cache directory +- `KIN_MANAGED_BIN`: canonical launcher alias for an explicit development binary +- `KIN_RUNTIME_MODE=development` with a separate `KIN_HOME`: explicitly choose a development home; otherwise the native runtime derives an isolated home +- `KIN_HOME`, then `KIN_DIR`: root of the normal managed native install +- `KIN_MCP_CACHE_DIR`: explicitly select an isolated development cache - `KIN_MCP_AUTO_INIT=1`: allow the wrapper to run `kin init .` when no Kin store serves the folder - `KIN_MCP_TOOL_PROFILE`: override the default `agent-default` tool profile -- `KIN_MCP_RELEASE_BASE_URL`: override the GitHub release download base URL +- `KIN_MCP_RELEASE_BASE_URL`: release mirror for an explicitly selected development cache ## Local Check @@ -175,6 +176,13 @@ npx -y @kinlab/kin-mcp --stop # stop idle daemons, list what stays Then initialize a repository and let the MCP client launch `kin-mcp`. +## Source development + +Run `npm ci --ignore-scripts` and `npm test` in this package. Its development +dependency links the sibling `kin` package, so source checks work before a new +release is published. Published installations use the exact canonical package +version declared in `dependencies`. + ## First-run smoke proof `test/smoke-first-run.mjs` exercises the whole first-run path against built Kin diff --git a/packages/kin-mcp/package-lock.json b/packages/kin-mcp/package-lock.json new file mode 100644 index 000000000..02488630a --- /dev/null +++ b/packages/kin-mcp/package-lock.json @@ -0,0 +1,42 @@ +{ + "name": "@kinlab/kin-mcp", + "version": "0.8.7", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@kinlab/kin-mcp", + "version": "0.8.7", + "license": "Apache-2.0", + "dependencies": { + "@kinlab/kin": "0.8.7" + }, + "bin": { + "kin-mcp": "bin/kin-mcp.js" + }, + "devDependencies": { + "@kinlab/kin": "file:../kin" + }, + "engines": { + "node": ">=20" + } + }, + "../kin": { + "name": "@kinlab/kin", + "version": "0.8.7", + "dev": true, + "license": "Apache-2.0", + "bin": { + "kin": "bin/kin.mjs", + "kin-mcp": "bin/kin-mcp.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@kinlab/kin": { + "resolved": "../kin", + "link": true + } + } +} diff --git a/packages/kin-mcp/package.json b/packages/kin-mcp/package.json index f815e2438..f540c10af 100644 --- a/packages/kin-mcp/package.json +++ b/packages/kin-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@kinlab/kin-mcp", - "version": "0.8.6", + "version": "0.8.7", "mcpName": "ai.kinlab/kin", "description": "Start the MCP server for Kin, a graph-native code repository for AI agents and the people who build with them, through an npm-friendly wrapper.", "type": "module", @@ -40,5 +40,11 @@ "license": "Apache-2.0", "publishConfig": { "access": "public" + }, + "dependencies": { + "@kinlab/kin": "0.8.7" + }, + "devDependencies": { + "@kinlab/kin": "file:../kin" } } diff --git a/packages/kin-mcp/src/index.js b/packages/kin-mcp/src/index.js index e8dc2bb5f..97ed10252 100644 --- a/packages/kin-mcp/src/index.js +++ b/packages/kin-mcp/src/index.js @@ -11,6 +11,32 @@ import { createRequire } from 'node:module'; import { instructionsForProfile, startFirstLaunchSession } from './first-launch.js'; +// The compatibility package shares the normal install with @kinlab/kin. +// Source-tree tests run before workspace dependencies are linked; published +// packages load the declared dependency instead. +let managedProvision; +try { + managedProvision = await import('@kinlab/kin/lib/provision.mjs'); +} catch (error) { + if (error.code !== 'ERR_MODULE_NOT_FOUND' || !String(error.message).includes('@kinlab/kin')) throw error; + managedProvision = await import('../../kin/lib/provision.mjs'); +} + +function managedInstaller() { + if (typeof managedProvision.resolveReadyManagedBinary !== 'function') { + throw new Error('the canonical @kinlab/kin launcher is too old to certify native pair identity; update @kinlab/kin before starting this compatibility launcher'); + } + return managedProvision; +} + +function usesDevelopmentCache(env, cacheRoot) { + return Boolean(cacheRoot || env.KIN_MCP_CACHE_DIR); +} + +function normalManagedEnvironment(env, homeDir) { + return { ...env, KIN_HOME: env.KIN_HOME || env.KIN_DIR || path.join(homeDir, '.kin') }; +} + const require = createRequire(import.meta.url); const packageJson = require('../package.json'); @@ -161,11 +187,17 @@ export async function cachedKinBinary({ homeDir = os.homedir(), cacheRoot } = {}) { - const configuredBinary = env.KIN_MCP_KIN_BINARY || env.KIN_BINARY_PATH; + const configuredBinary = env.KIN_MCP_KIN_BINARY || env.KIN_BINARY_PATH || env.KIN_MANAGED_BIN; if (configuredBinary) { const resolved = path.resolve(configuredBinary); await assertRunnable(resolved, platform); - return resolved; + return managedInstaller().resolveDevelopmentBinary(resolved, { env, platform }); + } + + if (!usesDevelopmentCache(env, cacheRoot)) { + return managedInstaller().resolveReadyManagedBinary({ + env: normalManagedEnvironment(env, homeDir), platform, minimumVersion: version + }); } const binaryPath = resolveCachedBinaryPath({ @@ -182,7 +214,7 @@ export async function cachedKinBinary({ (await isRunnable(binaryPath, platform)) && (await isRunnable(daemonPath, platform)) ) { - return binaryPath; + return managedInstaller().resolveDevelopmentBinary(binaryPath, { env, platform }); } return null; } @@ -196,6 +228,14 @@ export async function ensureKinBinary({ cacheRoot, onProgress } = {}) { + const configuredBinary = env.KIN_MCP_KIN_BINARY || env.KIN_BINARY_PATH || env.KIN_MANAGED_BIN; + if (!configuredBinary && !usesDevelopmentCache(env, cacheRoot)) { + return managedInstaller().ensureProvisioned({ + env: normalManagedEnvironment(env, homeDir), platform, arch, onProgress, + minimumVersion: version + }); + } + const cached = await cachedKinBinary({ env, platform, arch, version, homeDir, cacheRoot }); if (cached) { return cached; @@ -211,7 +251,7 @@ export async function ensureKinBinary({ }); await installKinBinary({ binaryPath, env, platform, arch, version, onProgress }); await assertDaemonProvisioned(binaryPath, platform); - return binaryPath; + return managedInstaller().resolveDevelopmentBinary(binaryPath, { env, platform }); } export async function runKinMcp(argv = [], options = {}) { @@ -226,7 +266,7 @@ export async function runKinMcp(argv = [], options = {}) { } if (argv.includes('--version') || argv.includes('-v')) { - stdout.write(`${PACKAGE_VERSION}\n`); + stdout.write(`@kinlab/kin-mcp ${PACKAGE_VERSION} (compatibility launcher; runtime identity: kin runtime status --json)\n`); return 0; } @@ -298,7 +338,7 @@ export async function runKinMcp(argv = [], options = {}) { const spawnOptions = { ...options, - env: childEnv(env, binaryPath, platform) + env: launchEnvironment(env, binaryPath, platform, options.cacheRoot) }; const cwd = options.cwd || process.cwd(); @@ -380,7 +420,7 @@ async function stopKinDaemons(options, { stdout, env, platform }) { if (binaryPath) { code = await spawnKin(binaryPath, ['daemon', 'stop', '--all', '--when-unused'], { ...options, - env: childEnv(env, binaryPath, platform), + env: launchEnvironment(env, binaryPath, platform, options.cacheRoot), cwd: options.cwd || process.cwd(), stdio: options.stdio || 'inherit' }); @@ -411,10 +451,16 @@ async function existingKinBinary({ homeDir = os.homedir(), cacheRoot } = {}) { - const configured = env.KIN_MCP_KIN_BINARY || env.KIN_BINARY_PATH; + const configured = env.KIN_MCP_KIN_BINARY || env.KIN_BINARY_PATH || env.KIN_MANAGED_BIN; if (configured) { const resolved = path.resolve(configured); - return (await isRunnable(resolved, platform)) ? resolved : null; + return (await isRunnable(resolved, platform)) + ? managedInstaller().resolveDevelopmentBinary(resolved, { env, platform }) : null; + } + if (!usesDevelopmentCache(env, cacheRoot)) { + return managedInstaller().resolveReadyManagedBinary({ + env: normalManagedEnvironment(env, homeDir), platform, minimumVersion: '0.0.0' + }); } let asset; try { @@ -425,7 +471,7 @@ async function existingKinBinary({ const root = cacheRoot || resolveCacheRoot(env, platform, homeDir); const own = path.join(root, resolveReleaseTag(version), asset.assetName, asset.binaryName); if (await isRunnable(own, platform)) { - return own; + return managedInstaller().resolveDevelopmentBinary(own, { env, platform }); } let tags = []; try { @@ -436,7 +482,7 @@ async function existingKinBinary({ for (const tag of tags.sort().reverse()) { const candidate = path.join(root, tag, asset.assetName, asset.binaryName); if (await isRunnable(candidate, platform)) { - return candidate; + return managedInstaller().resolveDevelopmentBinary(candidate, { env, platform }); } } return null; @@ -573,14 +619,25 @@ export function resolveDaemonBinaryPath(kinBinaryPath) { return path.join(path.dirname(kinBinaryPath), `${DAEMON_BINARY_NAME}${suffix}`); } +function launchEnvironment(env, binaryPath, platform, cacheRoot) { + const development = usesDevelopmentCache(env, cacheRoot) || env.KIN_MCP_KIN_BINARY || + env.KIN_BINARY_PATH || env.KIN_MANAGED_BIN; + const admitted = development + ? managedInstaller().developmentLaunchEnvironment(binaryPath, { env, platform }) : env; + return childEnv(admitted, binaryPath, platform); +} + export function childEnv(env, kinBinaryPath, platform = process.platform) { const next = { ...env }; + if (env.KIN_MCP_KIN_BINARY || env.KIN_BINARY_PATH || env.KIN_MANAGED_BIN || env.KIN_MCP_CACHE_DIR) { + next.KIN_RUNTIME_MODE = 'development'; + } if (!next.KIN_MCP_TOOL_PROFILE) { next.KIN_MCP_TOOL_PROFILE = DEFAULT_TOOL_PROFILE; } - const usesManagedBinary = !(env.KIN_MCP_KIN_BINARY || env.KIN_BINARY_PATH); + const usesManagedBinary = !(env.KIN_MCP_KIN_BINARY || env.KIN_BINARY_PATH || env.KIN_MANAGED_BIN); if (usesManagedBinary && !next.KIN_DAEMON_BIN) { next.KIN_DAEMON_BIN = resolveDaemonBinaryPath(kinBinaryPath); } @@ -593,12 +650,14 @@ function guidedProvisioningFailure(error) { return [ `kin-mcp could not provision a runnable Kin: ${detail}`, 'Fixes:', - ' - Install Kin directly and run `kin setup` to configure your agent, then point', - ' this wrapper at it with KIN_MCP_KIN_BINARY=/path/to/kin.', + ' - Inspect the managed runtime with `kin runtime status --json`, then use', + ' its `kin update --apply` transaction and re-run `kin setup`.', + ' - For a development build, explicitly select KIN_MCP_KIN_BINARY=/path/to/kin;', + ' that selection requires the isolated runtime contract and a matching daemon.', ' - Or retry on a supported target (macOS, Linux, or Windows x64).', ' Native Windows carries semantic vector search but no filesystem projection;', ' use WSL2 when you need projection.', - ' - Or override the release source with KIN_MCP_RELEASE_BASE_URL if you mirror releases.' + ' - Release mirrors apply only to an explicitly selected development cache.' ].join('\n'); } @@ -616,8 +675,9 @@ Usage: kin-mcp --print-daemon-bin kin-mcp --version -This wrapper downloads a matching Kin release archive on demand, extracts both -the kin CLI and the kin-daemon it depends on into a local cache, and then runs: +This compatibility wrapper shares the canonical managed native pair at +$KIN_HOME/bin (or $KIN_DIR; default ~/.kin/bin), certifies its source identity, +and then runs: kin mcp start @@ -628,7 +688,7 @@ download finishes it hands the session to kin mcp start and sends notifications/tools/list_changed so the client lists Kin's tools. It defaults KIN_MCP_TOOL_PROFILE=agent-default so first-run agents see the small -curated tool surface, and points the kin CLI at the cached kin-daemon via +curated tool surface, and points the kin CLI at the paired kin-daemon via KIN_DAEMON_BIN so it never depends on a stale daemon on PATH. The first tool call starts a background daemon for the repository, plus one diff --git a/packages/kin-mcp/src/server-instructions.json b/packages/kin-mcp/src/server-instructions.json index d4dcadd27..eb811c0dc 100644 --- a/packages/kin-mcp/src/server-instructions.json +++ b/packages/kin-mcp/src/server-instructions.json @@ -1,13 +1,13 @@ { "about": "What `kin mcp start` answers `initialize` with, carried so a first launch can give the same answer before that server exists. Each text copies, byte for byte, the constant in crates/kin-mcp/src/server.rs it is named for, and each profile names the text `instructions_for` picks for it there. A test in this package holds each text to its constant, and a test in kin-cli's commands/mcp.rs holds each profile's text to what the server answers.", "instructions": { - "SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Pick by question:\n- What calls X? refs on X, relation_kinds=[\"calls\"].\n- Overrides? refs, view=\"overrides\", entity_id=X UUID.\n- What must change if X changes? impact on X: callers and overrides.\n- What can end up calling X? trace on X, direction callers, depth 8. If the question limits where callers live, pass scope.\n- Is X used anywhere? refs on X, no relation_kinds.\n- Where is X? locate. Pass returned UUIDs as entity_id, semantic names as query.\n- Code around X: context. X's code: source by entity_id.\n- Why is a caller's receiver typed? refs on X, proof_for_caller=caller UUID.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nIf hidden, search for \"kin\" first, to find Kin's tools.", - "SEARCH_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Pick by question:\n- Any question about the code, in plain words: ask. Its first line says what Kin ran and what it read the question as.\n- Where is X? locate. Pass returned UUIDs as entity_id, semantic names as query.\n- What can end up calling X, or what does X call? trace on X. If the question limits where callers live, pass scope.\n- Code around X: context. X's own code: kin_tool_call, tool source, by entity_id.\n- Anything else: kin_tool_search finds every other read-only Kin tool; kin_tool_call runs it.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nIf hidden, search for \"kin\" first, to find Kin's tools.", - "ROUTED_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Call kin with command and args:\n- What calls X? kin refs on X, relation_kinds=[\"calls\"].\n- Overrides? kin refs, view=\"overrides\", entity_id=X UUID.\n- What must change if X changes? kin impact on X: callers and overrides.\n- What can end up calling X? kin trace on X, direction callers, depth 8.\n- Is X used anywhere? kin refs on X, no relation_kinds.\n- Where is X? kin locate. Pass returned UUIDs as entity_id, semantic names as query.\n- Code around X: kin context. X's own code: kin source by entity_id.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nIf hidden, search for \"kin\" first, to find Kin's tools. kin describe lists every other Kin tool, and kin call runs one.", - "ROUTED_QUERY_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Call kin with command and args:\n- What calls X? kin refs on X, relation_kinds=[\"calls\"].\n- Overrides? kin refs, view=\"overrides\", entity_id=X UUID.\n- What must change if X changes? kin impact on X: callers and overrides.\n- What can end up calling X? kin trace on X, direction callers, depth 8.\n- Is X used anywhere? kin refs on X, no relation_kinds.\n- Where is X? kin locate. Pass returned UUIDs as entity_id, semantic names as query.\n- Code around X: kin context. X's own code: kin source by entity_id.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nIf hidden, search for \"kin\" first, to find Kin's tools. kin describe lists every other read-only Kin tool, and kin call runs one.", + "SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Pick by question:\n- What calls X? refs on X, relation_kinds=[\"calls\"].\n- Overrides? refs, view=\"overrides\", entity_id=X UUID.\n- What must change if X changes? impact on X: callers and overrides.\n- What can end up calling X? trace on X, direction callers, depth 8. If the question limits where callers live, pass scope.\n- Is X used anywhere? refs on X, no relation_kinds.\n- Where is X? locate. Pass returned UUIDs as entity_id, semantic names as query.\n- Code around X: context. X's code: source by entity_id.\n- Why is a caller's receiver typed? refs on X, proof_for_caller=caller UUID.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nA reviewer's open flags arrive in _kin.flags; address each before committing. status lists them all.\nIf hidden, search for \"kin\" first, to find Kin's tools.", + "SEARCH_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Pick by question:\n- Any question about the code, in plain words: ask. Its first line says what Kin ran and what it read the question as.\n- Where is X? locate. Pass returned UUIDs as entity_id, semantic names as query.\n- What can end up calling X, or what does X call? trace on X. If the question limits where callers live, pass scope.\n- Code around X: context. X's own code: kin_tool_call, tool source, by entity_id.\n- Anything else: kin_tool_search finds every other read-only Kin tool; kin_tool_call runs it.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nA reviewer's open flags arrive in _kin.flags; address each before committing. status lists them all.\nIf hidden, search for \"kin\" first, to find Kin's tools.", + "ROUTED_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Call kin with command and args:\n- What calls X? kin refs on X, relation_kinds=[\"calls\"].\n- Overrides? kin refs, view=\"overrides\", entity_id=X UUID.\n- What must change if X changes? kin impact on X: callers and overrides.\n- What can end up calling X? kin trace on X, direction callers, depth 8.\n- Is X used anywhere? kin refs on X, no relation_kinds.\n- Where is X? kin locate. Pass returned UUIDs as entity_id, semantic names as query.\n- Code around X: kin context. X's own code: kin source by entity_id.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nA reviewer's open flags arrive in _kin.flags; address each before committing. kin status lists them all.\nIf hidden, search for \"kin\" first, to find Kin's tools. kin describe lists every other Kin tool, and kin call runs one.", + "ROUTED_QUERY_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Call kin with command and args:\n- What calls X? kin refs on X, relation_kinds=[\"calls\"].\n- Overrides? kin refs, view=\"overrides\", entity_id=X UUID.\n- What must change if X changes? kin impact on X: callers and overrides.\n- What can end up calling X? kin trace on X, direction callers, depth 8.\n- Is X used anywhere? kin refs on X, no relation_kinds.\n- Where is X? kin locate. Pass returned UUIDs as entity_id, semantic names as query.\n- Code around X: kin context. X's own code: kin source by entity_id.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nA reviewer's open flags arrive in _kin.flags; address each before committing. kin status lists them all.\nIf hidden, search for \"kin\" first, to find Kin's tools. kin describe lists every other read-only Kin tool, and kin call runs one.", "LEGACY_SERVER_INSTRUCTIONS": "Kin answers questions about this repository from a semantic graph. Address entities and relationships for questions about code and dependencies. Report missing semantic coverage as a gap.\nIf your client does not show you these tools up front and you have to search for a tool, search for \"kin\" first, to discover the semantic tools; that is also how you reach a tool this connection did not list.\nsemantic_locate: find code by describing what it does, when you do not know the name.\nsemantic_search: find declarations by exact name, kind or language.\nget_context_pack: one token-bounded bundle of the code around an entity or a question.\nfind_references: who calls, imports or references one entity.\ntrace_data_flow: the ordered call chain out from one entity.\ntrace_path: how one entity reaches another.\nimpact_analysis: what a change to one entity could affect.\ngraph_neighborhood: dependencies and dependents of an entity.\nEvery answer carries a `_kin` envelope. Read `_kin.verdict` first: when its `state` is `inconclusive`, treat the counts as a lower bound and do not act on an absence in the answer.", - "ASK_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Pick by question:\n- Any question about the code, in plain words: ask. Its first line says what Kin ran and what it read the question as; if that is not your question, ask again in other words.\n- Overrides? refs, view=\"overrides\", entity_id=X UUID.\n- Where is X? locate. Pass returned UUIDs as entity_id, semantic names as query.\n- Code around X: context. X's code: source by entity_id. The rest: refs, trace, impact.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nIf hidden, search for \"kin\" first, to find Kin's tools.", - "ASK_ONLY_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Ask it any question about the code in plain words with ask: who calls X, what can end up calling X, what must change if X changes, is X used anywhere, which modules declare X, where is X, X's code. Each reply's first line says what Kin ran and what it read the question as; if that is not your question, ask again in other words.\nRead each answer's Answer (verdict) line:\n- complete: checked against the whole graph; answer, don't re-read code.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nKin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nIf hidden, search for \"kin\" first, to find Kin's tools." + "ASK_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Pick by question:\n- Any question about the code, in plain words: ask. Its first line says what Kin ran and what it read the question as; if that is not your question, ask again in other words.\n- Overrides? refs, view=\"overrides\", entity_id=X UUID.\n- Where is X? locate. Pass returned UUIDs as entity_id, semantic names as query.\n- Code around X: context. X's code: source by entity_id. The rest: refs, trace, impact.\nRead _kin.verdict:\n- complete: checked against the whole graph; answer, don't re-read code. A whole body or outline is not all relations.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nExplore entities and relationships. Kin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nA reviewer's open flags arrive in _kin.flags; address each before committing. status lists them all.\nIf hidden, search for \"kin\" first, to find Kin's tools.", + "ASK_ONLY_SERVER_INSTRUCTIONS": "Kin's indexed code graph includes tests. Ask it any question about the code in plain words with ask: who calls X, what can end up calling X, what must change if X changes, is X used anywhere, which modules declare X, where is X, X's code. Each reply's first line says what Kin ran and what it read the question as; if that is not your question, ask again in other words.\nRead each answer's Answer (verdict) line:\n- complete: checked against the whole graph; answer, don't re-read code.\n- complete except the sites listed below, or indirect uses: rows are checked; check only listed places that could apply, then answer.\n- partial: answer from the proven rows and name the gap, unless the reply lists a concrete next step.\nKin's current graph outranks memory of other versions of this code. Answer once in the requested format.\nA reviewer's open flags lead the answers they concern; address each before committing.\nIf hidden, search for \"kin\" first, to find Kin's tools." }, "profiles": { "agent-default": "SERVER_INSTRUCTIONS", diff --git a/packages/kin-mcp/test/index.test.js b/packages/kin-mcp/test/index.test.js index d60e8c703..4f52311d6 100644 --- a/packages/kin-mcp/test/index.test.js +++ b/packages/kin-mcp/test/index.test.js @@ -170,6 +170,14 @@ const fakeKinPreload = [ "const fs = require('node:fs');", "const path = require('node:path');", "const command = path.basename(process.argv[1] || '');", + "if (command === 'runtime') {", + " const suffix = process.platform === 'win32' ? '.exe' : '';", + " const binary = process.execPath;", + " const daemon = path.join(path.dirname(binary), 'kin-daemon' + suffix);", + " const component = file => ({ path: file, version: '99.0.0', commit_sha: 'a'.repeat(40), source_dirty: true, target: 'fixture', build_profile: 'debug', sha256: require('node:crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex') });", + " process.stdout.write(JSON.stringify({ schema: 'kin.runtime-status.v1', ready: true, mode: 'development', kin_home: path.join(process.env.HOME, '.kin-development-fixture'), pair_matches: true, cli: component(binary), daemon: component(daemon) }));", + " process.exit(0);", + "}", "if (command === 'store') {", " if (process.env.KIN_MCP_FAKE_STORE_REPLY) { process.stdout.write(process.env.KIN_MCP_FAKE_STORE_REPLY); process.exit(0); }", " let dir = process.cwd();", @@ -218,12 +226,29 @@ const fakeKinPreload = [ ].join('\n'); async function fakeKinEnvironment(tmpDir, overrides = {}) { - const preloadName = 'fake-kin-preload.cjs'; - await fs.writeFile(path.join(tmpDir, preloadName), fakeKinPreload); + const preloadPath = path.join(tmpDir, 'fake-kin-preload.cjs'); + await fs.writeFile(preloadPath, fakeKinPreload); + const suffix = process.platform === 'win32' ? '.exe' : ''; + const binary = path.join(tmpDir, 'kin' + suffix); + if (!await exists(binary)) await fs.copyFile(process.execPath, binary); + const daemon = path.join(tmpDir, 'kin-daemon' + suffix); + if (!await exists(daemon)) await fs.copyFile(process.execPath, daemon); + if (process.platform === 'darwin') { + // Homebrew's shared Node executable resolves this actual host library + // relative to itself. Keep the copied protocol fixture runnable too. + const libraryName = `libnode.${process.versions.modules}.dylib`; + try { + const actualLibrary = await fs.realpath(path.join(path.dirname(process.execPath), '..', 'lib', libraryName)); + const fixtureLibrary = path.join(tmpDir, libraryName); + if (!await exists(fixtureLibrary)) await fs.symlink(actualLibrary, fixtureLibrary); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } const env = { ...process.env, - NODE_OPTIONS: `--require=./${preloadName}`, - KIN_MCP_KIN_BINARY: process.execPath, + NODE_OPTIONS: `--require=${JSON.stringify(preloadPath)}`, + KIN_MCP_KIN_BINARY: binary, KIN_MCP_FAKE_REPO: tmpDir, ...overrides }; @@ -410,7 +435,7 @@ test( .trim(); assert.match(head, /^[0-9a-f]{40,64}$/); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } } ); @@ -524,7 +549,7 @@ async function buildReleaseArchive( } else { cp.execFileSync('tar', ['-czf', archiveName, assetName], { cwd: tmpDir }); } - await fs.rm(packageDir, { recursive: true, force: true }); + await fs.rm(packageDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); const archiveBytes = await fs.readFile(archivePath); if (platform === 'win32') { @@ -549,7 +574,7 @@ function mockReleaseFetch(t, version, archiveName, archiveBytes, checksum) { return baseUrl; } -test('ensureKinBinary downloads kin and its daemon from a release asset', async t => { +test('a development archive is extracted but cannot serve without runtime identity', async t => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kin-mcp-download-')); const assetName = 'kin-linux-x86_64'; const version = '9.9.9-test'; @@ -562,12 +587,14 @@ test('ensureKinBinary downloads kin and its daemon from a release asset', async }; try { - const binaryPath = await ensureKinBinary({ + const options = { env, platform: 'linux', arch: 'x64', version - }); + }; + await assert.rejects(ensureKinBinary(options), /runtime-isolation contract/); + const binaryPath = resolveCachedBinaryPath(options); assert.equal( binaryPath, @@ -584,11 +611,11 @@ test('ensureKinBinary downloads kin and its daemon from a release asset', async assert.equal(await exists(daemonPath), true); assert.equal(await fs.readFile(daemonPath, 'utf8'), daemonBytes.toString('utf8')); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); -test('ensureKinBinary installs the flat native Windows zip and .exe pair', async t => { +test('ensureKinBinary installs the flat native Windows zip and .exe pair but refuses unsupported identity', async t => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kin-mcp-windows-download-')); const assetName = 'kin-windows-x86_64'; const version = '9.9.9-test'; @@ -601,12 +628,14 @@ test('ensureKinBinary installs the flat native Windows zip and .exe pair', async }); try { - const binaryPath = await ensureKinBinary({ + const options = { env, platform: 'win32', arch: 'x64', version - }); + }; + await assert.rejects(ensureKinBinary(options), /runtime-isolation contract/); + const binaryPath = resolveCachedBinaryPath(options); assert.equal(path.basename(binaryPath), 'kin.exe'); assert.equal(await fs.readFile(binaryPath, 'utf8'), kinBytes.toString('utf8')); @@ -615,7 +644,7 @@ test('ensureKinBinary installs the flat native Windows zip and .exe pair', async assert.equal(path.basename(daemonPath), 'kin-daemon.exe'); assert.equal(await fs.readFile(daemonPath, 'utf8'), daemonBytes.toString('utf8')); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -640,18 +669,20 @@ test( }); try { - const binaryPath = await ensureKinBinary({ + const options = { env, platform: 'linux', arch: 'x64', version - }); + }; + await assert.rejects(ensureKinBinary(options), /runtime-isolation contract/); + const binaryPath = resolveCachedBinaryPath(options); assert.equal(await fs.readFile(binaryPath, 'utf8'), kinBytes.toString('utf8')); const daemonPath = resolveDaemonBinaryPath(binaryPath); assert.equal(await fs.readFile(daemonPath, 'utf8'), daemonBytes.toString('utf8')); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } } ); @@ -677,7 +708,7 @@ test('ensureKinBinary fails with a precise message when the archive omits kin-da /kin-daemon/ ); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -690,7 +721,7 @@ test('ensureKinBinary rejects a failed archive response', async t => { env: { KIN_MCP_CACHE_DIR: tmpDir }, platform: 'linux', arch: 'x64', version: '9.9.9-test' }), /failed to download .*404 Not Found/); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -708,7 +739,7 @@ test('ensureKinBinary rejects archive bytes that disagree with the checksum', as env: { KIN_MCP_CACHE_DIR: tmpDir }, platform: 'linux', arch: 'x64', version })), false); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -732,7 +763,7 @@ test('runKinMcp invokes kin mcp start', async () => { assert.equal(exitCode, 0); assert.equal(await fs.readFile(argsPath, 'utf8'), 'mcp\nstart\n'); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -781,7 +812,7 @@ test('runKinMcp starts the server when the launch directory is no Kin repository 'starting unbound must not initialize a repository behind the user' ); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -834,7 +865,7 @@ test('initialize is served through the wrapper in an empty directory', async () assert.match(result.stderr, /npx -y @kinlab\/kin@\S+ init \./); assert.equal(await exists(path.join(tmpDir, '.kin')), false); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -958,7 +989,7 @@ test('a central store or unreadable discovery does not trigger wrapper auto-init assert.doesNotMatch(stderr, /running kin init|neither is any folder above it/); } } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -993,7 +1024,7 @@ test('runKinMcp names the enclosing repository instead of saying none is bound', assert.equal(await fs.readFile(logPath, 'utf8'), 'mcp\nstart\n', 'no kin init over an enclosing repository'); assert.equal(await exists(path.join(nested, '.kin')), false); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -1082,7 +1113,7 @@ test('--stop stops idle daemons through the cached kin and names what stays on d assert.ok(stdout.includes(`rm -rf -- '${model.replaceAll("'", "'\\''")}'`), stdout); } } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -1110,7 +1141,7 @@ test('--stop never downloads a Kin just to stop daemons', async () => { assert.match(stdout, /no Kin binary is cached here/); assert.equal(await exists(path.join(tmpDir, 'empty-cache')), false, 'nothing was fetched'); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -1135,7 +1166,7 @@ test('runKinMcp forwards the agent-default profile to kin mcp start', async () = assert.equal(exitCode, 0); assert.equal(await fs.readFile(profilePath, 'utf8'), 'agent-default'); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -1160,7 +1191,7 @@ test('runKinMcp emits a guided fix when no binary can be provisioned', async () assert.match(stderr, /could not provision a runnable Kin/); assert.match(stderr, /kin setup/); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -1187,7 +1218,7 @@ test('runKinMcp auto-inits when explicitly allowed', async () => { assert.ok(mcpPos >= 0, 'expected kin mcp start call'); assert.ok(initPos < mcpPos, 'init should run before mcp start'); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -1226,7 +1257,7 @@ test('auto-init keeps MCP stdout protocol-only from process start', async () => assert.match(result.stderr, /init stdout must leave the protocol channel/); assert.match(result.stderr, /init stderr remains diagnostic/); } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 5 : 0 }); } }); @@ -1849,6 +1880,12 @@ const fakeKinSource = [ "const path = require('node:path');", 'const env = process.env;', "const log = line => fs.appendFileSync(env.KIN_MCP_FAKE_SERVER_LOG, JSON.stringify(line) + '\\n');", + "if (process.argv[2] === 'runtime') {", + " const binary = process.env.KIN_MCP_FAKE_INSTALLED_BINARY;", + " const daemon = path.join(path.dirname(binary), 'kin-daemon');", + " process.stdout.write(JSON.stringify({ schema: 'kin.runtime-status.v1', ready: true, mode: 'development', kin_home: path.join(process.env.HOME, '.kin-development-fixture'), pair_matches: true, cli: { path: binary }, daemon: { path: daemon } }));", + " process.exit(0);", + "}", 'log({ argv: process.argv.slice(2), pid: process.pid });', 'const waitFor = (marker, then) => {', ' if (!marker || fs.existsSync(marker)) return then();', @@ -1909,7 +1946,7 @@ async function buildFakeKinArchive(tmpDir, asset) { await fs.mkdir(packageDir, { recursive: true }); await fs.writeFile( path.join(packageDir, 'kin'), - `#!/bin/sh\nexec "${process.execPath}" "${fakeKin}" "$@"\n`, + `#!/bin/sh\nKIN_MCP_FAKE_INSTALLED_BINARY="$0"; export KIN_MCP_FAKE_INSTALLED_BINARY\nexec "${process.execPath}" "${fakeKin}" "$@"\n`, { mode: 0o755 } ); await fs.writeFile(path.join(packageDir, 'kin-daemon'), '#!/bin/sh\nexit 0\n', { diff --git a/packages/kin/README.md b/packages/kin/README.md index b5de0896c..ba95ec453 100644 --- a/packages/kin/README.md +++ b/packages/kin/README.md @@ -95,37 +95,44 @@ runs agent setup and graph-backed MCP tool calls there too. WSL2 remains the rec path for the full Kin experience, so run agent setup inside WSL2. The PowerShell installer makes the same call and does not run setup at all. -## Version pinning - -Every `@kinlab/kin` release pins one managed `kin` release: its own package version. -The pin is the oldest release the launcher runs. The launcher starts one extra process -to check the installed executable's version, even if a receipt from an earlier -installation exists. Updating Kin through another installer cannot make that old -receipt authorize a downgrade or hide a needed upgrade. - -Each run compares that pin against whatever is already installed at `$KIN_HOME`: - -- **installed is older than the pin**: upgrades it automatically (a one-line notice - on stderr, no prompt, no opt-in required). -- **installed is newer than the pin**: runs the newer install. This is what `kin update` - and the shell installer leave behind, since they move `$KIN_HOME` forward without - touching the npm package. The first run prints a one-line notice on stderr and - records the version it confirmed, so later runs are quiet until it changes again. - `npm install -g @kinlab/kin@latest` brings the launcher forward too. -- **installed matches the pin**: runs it as-is. - -`KIN_LAUNCHER_ADOPT=1` always forces a fresh provision of the pinned release, even when -the installed version already matches or is newer. That is the one way to go back to an -older release on purpose. +## One managed runtime + +Ordinary npm, installer and compatibility MCP launches use the same native pair +at `$KIN_HOME/bin` (`$KIN_DIR` is the compatibility alias; the default is `~/.kin`). +Before serving, the launcher reads `kin runtime status --json` and requires a clean +CLI and daemon with matching source commit, target and build profile. Equal version +strings or an old launcher stamp do not certify a pair. A missing daemon or a +differing pair stops with a repair instruction instead of selecting another binary +from PATH or a cache. + +The package version is the minimum supported runtime. An older managed install is +updated through its own `kin update --apply` transaction when it supports the +runtime contract. Legacy releases without that contract use a privately staged +latest official stable release that meets the package minimum. Its native bootstrap +independently verifies published provenance +and commits the existing installation through the same native transaction. The +wrapper binds the official tag's source commit, archive checksum and platform; +it reports success only after the installed pair certifies that exact identity. +An incomplete migration retains its private stage and reports recovery or restart +as pending. npm and the installer do not replace an existing CLI and daemon +separately. A newer certified pair is reused. `KIN_LAUNCHER_ADOPT` is refused +because it would bypass that shared update authority. + +Explicit development selections stay separate: `KIN_MANAGED_BIN` points at a +chosen binary and disables provisioning. The owner of that selection is responsible +for its matching daemon. Development binaries are not silently registered as the +normal managed install. Development launches derive a private Kin home and remove +inherited normal-home values. To supply an isolated home yourself, set +`KIN_RUNTIME_MODE=development` together with that separate `KIN_HOME`. ## Environment | Variable | Effect | | --- | --- | | `KIN_HOME` | Root of the managed install (default `~/.kin`). | -| `KIN_MANAGED_BIN` | Explicit path to a `kin` binary; disables provisioning entirely. | -| `KIN_NO_PROVISION=1` | Never touch the network; fail loud if no binary is present. | -| `KIN_LAUNCHER_ADOPT=1` | Force re-provisioning to the pinned release, including over a newer install, which is a deliberate downgrade. | +| `KIN_DIR` | Compatibility alias, used only when `KIN_HOME` is unset. | +| `KIN_MANAGED_BIN` | Explicit development binary; disables provisioning. | +| `KIN_NO_PROVISION=1` | Resolve and certify an existing pair without network access. | ## MCP setup diff --git a/packages/kin/bin/kin-mcp.mjs b/packages/kin/bin/kin-mcp.mjs index 6f3c5b358..d42fe677c 100644 --- a/packages/kin/bin/kin-mcp.mjs +++ b/packages/kin/bin/kin-mcp.mjs @@ -14,7 +14,7 @@ import os from 'node:os'; import { spawnSync } from 'node:child_process'; import { packageVersion, targetKinVersion, notProvisionedMessage } from '../lib/resolve.mjs'; -import { ensureProvisioned } from '../lib/provision.mjs'; +import { developmentLaunchEnvironment, ensureProvisioned } from '../lib/provision.mjs'; const argv = process.argv.slice(2); @@ -45,7 +45,9 @@ if (!binary) { process.exit(1); } -const result = spawnSync(binary, ['mcp', 'start', ...argv], { stdio: 'inherit' }); +const childEnv = process.env.KIN_MANAGED_BIN + ? developmentLaunchEnvironment(binary) : process.env; +const result = spawnSync(binary, ['mcp', 'start', ...argv], { stdio: 'inherit', env: childEnv }); if (result.error) { process.stderr.write(`kin-mcp: failed to launch managed binary: ${result.error.message}\n`); process.exit(1); diff --git a/packages/kin/bin/kin.mjs b/packages/kin/bin/kin.mjs index 61ce3a9b7..ab70c392b 100644 --- a/packages/kin/bin/kin.mjs +++ b/packages/kin/bin/kin.mjs @@ -14,7 +14,7 @@ import os from 'node:os'; import { spawnSync } from 'node:child_process'; import { packageVersion, targetKinVersion, notProvisionedMessage } from '../lib/resolve.mjs'; -import { ensureProvisioned, setupFollows } from '../lib/provision.mjs'; +import { developmentLaunchEnvironment, ensureProvisioned, setupFollows } from '../lib/provision.mjs'; const argv = process.argv.slice(2); const wantsVersion = argv.includes('--version') || argv.includes('-V'); @@ -52,7 +52,9 @@ if (!binary) { process.exit(1); } -const result = spawnSync(binary, argv, { stdio: 'inherit' }); +const childEnv = process.env.KIN_MANAGED_BIN + ? developmentLaunchEnvironment(binary) : process.env; +const result = spawnSync(binary, argv, { stdio: 'inherit', env: childEnv }); if (result.error) { process.stderr.write(`kin: failed to launch managed binary: ${result.error.message}\n`); process.exit(1); diff --git a/packages/kin/lib/provision.mjs b/packages/kin/lib/provision.mjs index deef3b172..eaba6f406 100644 --- a/packages/kin/lib/provision.mjs +++ b/packages/kin/lib/provision.mjs @@ -23,8 +23,6 @@ import { compareVersions, kinHome, managedBinaryPath, - readLauncherStamp, - resolveManagedBinary, targetKinVersion, writeLauncherStamp, } from './resolve.mjs'; @@ -67,6 +65,76 @@ export function releaseDownloadUrl(version, file) { return `https://github.com/kinlab-ai/kin/releases/download/v${version}/${file}`; } +export function releaseRustTarget(platform = process.platform, arch = process.arch) { + const targets = { + 'darwin:arm64': 'aarch64-apple-darwin', 'darwin:x64': 'x86_64-apple-darwin', + 'linux:arm64': 'aarch64-unknown-linux-musl', 'linux:x64': 'x86_64-unknown-linux-musl', + 'win32:x64': 'x86_64-pc-windows-msvc', + }; + artifactName(platform, arch); // Preserve the supported-platform refusal. + return targets[`${platform}:${arch}`]; +} + +/** Resolve a published tag independently of the executable we will run. */ +export async function resolvePublishedRelease(version, { + platform = process.platform, arch = process.arch, fetchImpl = fetch, releaseMetadata = null, +} = {}) { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error('published bootstrap requires an exact release version'); + } + const archiveName = artifactName(platform, arch); + const base = 'https://api.github.com/repos/kinlab-ai/kin'; + const [releaseBytes, tagBytes] = await Promise.all([ + releaseMetadata ? Buffer.from(JSON.stringify(releaseMetadata)) : fetchBuffer(`${base}/releases/tags/v${version}`, fetchImpl), + fetchBuffer(`${base}/git/ref/tags/v${version}`, fetchImpl), + ]); + let release, tag; + try { release = JSON.parse(releaseBytes); tag = JSON.parse(tagBytes); } catch { + throw new Error('official release/tag metadata is malformed; no managed component was replaced'); + } + if (tag.ref !== `refs/tags/v${version}`) throw new Error('official release metadata did not resolve the exact tag ref'); + let object = tag.object; + for (let depth = 0; object?.type === 'tag' && depth < 5; depth++) { + if (!/^[0-9a-f]{40}$/i.test(object.sha || '')) throw new Error('official annotated tag has no exact source identity'); + let annotated; + try { annotated = JSON.parse(await fetchBuffer(`${base}/git/tags/${object.sha}`, fetchImpl)); } catch { + throw new Error('official annotated tag metadata is unavailable or malformed'); + } + if (annotated.sha !== object.sha) throw new Error('official annotated tag object changed while resolving its source'); + object = annotated.object; + } + const channel = version.includes('-') ? 'alpha' : 'stable'; + const url = releaseDownloadUrl(version, archiveName); + const assets = Array.isArray(release.assets) ? release.assets : []; + const archive = assets.filter(asset => asset.name === archiveName); + const checksum = assets.filter(asset => asset.name === `${archiveName}.sha256`); + if (release.tag_name !== `v${version}` || release.draft !== false || + release.prerelease !== (channel === 'alpha') || object?.type !== 'commit' || !/^[0-9a-f]{40}$/i.test(object.sha || '') || + archive.length !== 1 || archive[0].browser_download_url !== url || + checksum.length !== 1 || checksum[0].browser_download_url !== `${url}.sha256`) { + throw new Error('official published release metadata does not bind the requested tag and archive'); + } + return { version, channel, commitSha: object.sha.toLowerCase(), target: releaseRustTarget(platform, arch), archiveName, url }; +} + +/** Legacy callers converge on stable latest while retaining their supported minimum. */ +export async function resolveLatestPublishedRelease(minimumVersion, { + platform = process.platform, arch = process.arch, fetchImpl = fetch, +} = {}) { + let release; + try { release = JSON.parse(await fetchBuffer('https://api.github.com/repos/kinlab-ai/kin/releases/latest', fetchImpl)); } catch (error) { + throw new Error(`official latest stable metadata is unavailable or malformed: ${error.message}`, { cause: error }); + } + const version = /^v(\d+\.\d+\.\d+)$/.exec(release.tag_name || '')?.[1]; + if (!version || release.draft !== false || release.prerelease !== false) { + throw new Error('official latest release is not a published stable version'); + } + if (compareVersions(version, minimumVersion) < 0) { + throw new Error(`official latest stable Kin ${version} is below this package's supported minimum ${minimumVersion}; no managed component was replaced`); + } + return resolvePublishedRelease(version, { platform, arch, fetchImpl, releaseMetadata: release }); +} + /** * Extract the hex digest from a `shasum -a 256` style checksum file * (" "). Refuses anything that does not look like SHA-256. @@ -241,6 +309,20 @@ function mkdirKinDir(dir) { } /** `value` as one POSIX shell word, quoted only when it needs to be. */ +function assertFreshManagedDirectories(env) { + const root = kinHome(env); + for (const directory of [root, path.join(root, 'bin'), path.join(root, 'lib')]) { + let metadata; + try { metadata = fs.lstatSync(directory); } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`managed install directory must be a real non-symlink directory: ${directory}`); + } + } +} + function shellWord(value) { return /^[A-Za-z0-9/._\-+,:@%=]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; } @@ -588,6 +670,18 @@ export async function provision(version, opts = {}) { setupFollows = false, } = opts; + if ((env.KIN_MANAGED_BIN && env.KIN_MANAGED_BIN.trim()) || env.KIN_RUNTIME_MODE === 'development') { + throw new Error('explicit development selections cannot run managed provisioning or repair'); + } + const existingBinary = managedBinaryPath('kin', env, platform); + const existingDaemon = path.join(kinHome(env), 'bin', binaryName('kin-daemon', platform)); + if (isInstalledComponent(existingBinary) || isInstalledComponent(existingDaemon)) { + if (!probeManagedRuntime(existingBinary, { env, spawnImpl: opts.spawnImpl })) { + return bootstrapPublishedInstall(version, opts); + } + return updateManagedInstall({ env, platform, spawnImpl: opts.spawnImpl, minimumVersion: version, log }); + } + assertFreshManagedDirectories(env); const file = artifactName(platform, arch); const url = releaseDownloadUrl(version, file); // When `kin setup` runs next, the download line is the only one printed @@ -660,6 +754,13 @@ export async function provision(version, opts = {}) { // live notifier from the previous release. const notifierSrc = preflightNotifierBundle(root, platform); + assertFreshManagedDirectories(env); + if (isInstalledComponent(existingBinary) || isInstalledComponent(existingDaemon)) { + if (!probeManagedRuntime(existingBinary, { env, spawnImpl: opts.spawnImpl })) { + return bootstrapPublishedInstall(version, opts); + } + return updateManagedInstall({ env, platform, spawnImpl: opts.spawnImpl, minimumVersion: version, log }); + } const binDir = path.join(kinHome(env), 'bin'); const libDir = path.join(kinHome(env), 'lib'); mkdirKinDir(binDir); @@ -710,22 +811,323 @@ export function probeBinaryVersion(binary, spawnImpl = spawnSync) { : null; } -/** - * The notice a launcher prints the first time it runs a managed install that - * is newer than its own pin. - * - * It names the version that runs, the pin it outranks, what usually put it - * there, and both ways to change the outcome: bring the launcher forward, or - * install the pinned release over the newer one on purpose. The forward move - * comes first because it is the one almost everybody wants. - */ -export function newerInstallNotice(installed, target, installedPath) { - return ( - `kin: running managed kin ${installed} at ${installedPath}, which is newer than the ${target} ` + - 'this @kinlab/kin launcher pins (`kin update` and the shell installer move the managed install ' + - 'forward without touching the npm package). `npm install -g @kinlab/kin@latest` brings the ' + - `launcher forward too; KIN_LAUNCHER_ADOPT=1 installs ${target} over it instead.` - ); +/** Read the native authority's complete CLI/daemon identity, never a version stamp. */ +export function probeManagedRuntime(binary, { env = process.env, spawnImpl = spawnSync, development = false } = {}) { + const args = ['runtime', 'status', '--json', ...(development ? ['--development'] : [])]; + const probeEnv = mergeEnvironment(process.env, env); + if (development) { + if (!Object.hasOwn(env, 'KIN_HOME')) delete probeEnv.KIN_HOME; + if (!Object.hasOwn(env, 'KIN_DIR')) delete probeEnv.KIN_DIR; + } + const result = spawnImpl(binary, args, { + encoding: 'utf8', env: probeEnv, timeout: 20000, + killSignal: 'SIGKILL', maxBuffer: 64 * 1024, + }); + let report; + try { report = JSON.parse(String(result.stdout)); } catch { return null; } + if (result.error || typeof result.status !== 'number' || report.schema !== 'kin.runtime-status.v1') return null; + if (result.status !== 0 && report.ready !== false) return null; + return report; +} + +function isInstalledComponent(candidate) { + try { fs.lstatSync(candidate); return true; } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +function runtimePathIdentity(value, platform) { + let resolved = value; + try { resolved = fs.realpathSync.native(value); } catch {} + if (platform === 'win32') { + resolved = resolved.replace(/^\\\\\?\\UNC\\/i, '\\\\').replace(/^\\\\\?\\/, ''); + return path.win32.resolve(resolved).toLowerCase(); + } + return path.resolve(resolved); +} + +function sameRuntimePath(left, right, platform) { + return runtimePathIdentity(left, platform) === runtimePathIdentity(right, platform); +} + +/** Normal entry points must refuse an incomplete or differing native pair. */ +export function resolveReadyManagedBinary({ + env = process.env, platform = process.platform, minimumVersion = '0.0.0', + spawnImpl = spawnSync, expectedRelease = null, statusReport = undefined, +} = {}) { + const binary = managedBinaryPath('kin', env, platform); + const daemon = path.join(kinHome(env), 'bin', binaryName('kin-daemon', platform)); + if (!isInstalledComponent(binary) && !isInstalledComponent(daemon)) return null; + const report = statusReport === undefined ? probeManagedRuntime(binary, { env, spawnImpl }) : statusReport; + const component = value => value && typeof value.path === 'string' && + typeof value.version === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value.version) && + typeof value.target === 'string' && value.target.length > 0 && + typeof value.build_profile === 'string' && value.build_profile.length > 0 && + /^[0-9a-f]{40}$/i.test(value.commit_sha || '') && + /^[0-9a-f]{64}$/i.test(value.sha256 || '') && value.source_dirty === false; + const matches = report && report.ready === true && report.mode === 'managed' && + report.pair_matches === true && component(report.cli) && component(report.daemon) && + typeof report.kin_home === 'string' && report.restart_needed === false && + sameRuntimePath(report.kin_home, kinHome(env), platform) && + sameRuntimePath(report.cli.path, binary, platform) && + sameRuntimePath(report.daemon.path, daemon, platform) && + report.cli.version === report.daemon.version && + report.cli.commit_sha === report.daemon.commit_sha && + report.cli.target === report.daemon.target && + report.cli.build_profile === report.daemon.build_profile && + compareVersions(report.cli.version, minimumVersion) >= 0 && + (!expectedRelease || (report.cli.version === expectedRelease.version && + report.cli.commit_sha.toLowerCase() === expectedRelease.commitSha && + report.cli.target === expectedRelease.target)); + if (!matches) { + throw new Error( + `managed Kin runtime is not certified at ${binary}: ${report?.reason || 'identity unavailable'}. ` + + 'Kin requires a clean CLI and daemon from the same source. Run the managed ' + + '`kin update --apply` or inspect `kin runtime status --json`; no cached or PATH binary was substituted.', + ); + } + return binary; +} + +/** Only an already explicit development environment may supply its own home. */ +function developmentProbeEnvironment(env) { + const isolated = { ...env, KIN_RUNTIME_MODE: 'development' }; + if (env.KIN_RUNTIME_MODE !== 'development') { + delete isolated.KIN_HOME; + delete isolated.KIN_DIR; + } + return isolated; +} + +function inspectDevelopmentSelection(binary, { env, spawnImpl, platform }) { + if (!isInstalledComponent(binary)) return null; + const isolatedEnv = developmentProbeEnvironment(env); + const report = probeManagedRuntime(binary, { env: isolatedEnv, spawnImpl, development: true }); + let realBinary = binary; + try { realBinary = fs.realpathSync.native(binary); } catch {} + const daemon = path.join(path.dirname(realBinary), binaryName('kin-daemon', platform)); + if (!report || report.ready !== true || report.mode !== 'development' || report.pair_matches !== true || + !report.cli || !report.daemon || typeof report.kin_home !== 'string' || !path.isAbsolute(report.kin_home) || + !sameRuntimePath(report.cli.path || '', binary, platform) || !sameRuntimePath(report.daemon.path || '', daemon, platform)) { + throw new Error( + `selected development build at ${binary} lacks the runtime-isolation contract ` + + `(${report?.reason || 'identity unavailable'}). Rebuild its matching CLI and daemon; ` + + 'no managed runtime or configuration was repaired.', + ); + } + return { binary, env: { ...isolatedEnv, KIN_HOME: report.kin_home, KIN_DIR: report.kin_home } }; +} + +/** Explicit development selections use the isolated native contract too. */ +export function resolveDevelopmentBinary(binary, { + env = process.env, spawnImpl = spawnSync, platform = process.platform, +} = {}) { + return inspectDevelopmentSelection(binary, { env, spawnImpl, platform })?.binary || null; +} + +/** Build real child environment from the home the admitted native status chose. */ +export function developmentLaunchEnvironment(binary, { + env = process.env, spawnImpl = spawnSync, platform = process.platform, +} = {}) { + const selected = inspectDevelopmentSelection(binary, { env, spawnImpl, platform }); + if (!selected) throw new Error(`selected development binary is missing at ${binary}`); + return selected.env; +} + +function hashNativeFile(file) { + const handle = fs.openSync(file, 'r'); + const hash = crypto.createHash('sha256'); + const buffer = Buffer.alloc(64 * 1024); + try { + let count; + while ((count = fs.readSync(handle, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count)); + } finally { fs.closeSync(handle); } + return hash.digest('hex'); +} + +function validateWindowsWorkerStage(handoff, env) { + const directory = path.dirname(handoff.worker_executable); + const expectedNames = ['handoff.json', 'kin-update-worker.exe']; + if (!/^\.update-worker-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(path.basename(directory)) || + !sameRuntimePath(path.dirname(directory), kinHome(env), 'win32') || + !sameRuntimePath(path.dirname(handoff.handoff_record), directory, 'win32') || + path.basename(handoff.worker_executable) !== 'kin-update-worker.exe' || path.basename(handoff.handoff_record) !== 'handoff.json' || + JSON.stringify(fs.readdirSync(directory).sort()) !== JSON.stringify(expectedNames)) { + throw new Error('prepared Windows update worker identity changed; scratch is not an unclaimed owned directory'); + } + const metadata = fs.lstatSync(directory); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error('prepared Windows worker directory is not a real private directory'); + return { directory, record: handoff.handoff_record, recordSha256: hashNativeFile(handoff.handoff_record) }; +} + +/** Native authority owns deletion and authenticates the exited incarnation. */ +function requestWindowsWorkerCleanup(binary, worker, result, { env, spawnImpl, log }) { + if (result?.error || typeof result?.status !== 'number' || !Number.isInteger(result.pid) || result.pid <= 0) return; + try { + const claimed = path.join(worker.directory, 'claimed'); + const marker = fs.lstatSync(claimed); + if (!marker.isFile() || marker.isSymbolicLink() || marker.size <= 0 || marker.size > 64 * 1024) return; + const consumed = JSON.parse(fs.readFileSync(claimed, 'utf8')); + if (consumed.schema !== 'kin.windows-update-worker-consumed.v1' || + consumed.worker_process?.pid !== result.pid || consumed.handoff_sha256 !== worker.recordSha256) return; + const cleanup = spawnImpl(binary, ['update', '--cleanup-windows-worker', worker.record], { + cwd: path.dirname(binary), stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', + env: mergeEnvironment(process.env, env), maxBuffer: 64 * 1024, + }); + if (cleanup.stdout) log(String(cleanup.stdout).trimEnd()); + if (cleanup.stderr) log(String(cleanup.stderr).trimEnd()); + if (cleanup.error || cleanup.status !== 0) { + log(`kin: native Windows worker cleanup deferred (${cleanup.error?.message || cleanup.status}); the update result is unchanged`); + } + } catch (error) { + if (error.code !== 'ENOENT') log(`kin: native Windows worker cleanup deferred (${error.message}); the update result is unchanged`); + } +} + +function nativeUpdateCommand(binary, env, platform, spawnImpl) { + if (platform !== 'win32') return { executable: binary, args: ['update', '--apply'] }; + const prepared = spawnImpl(binary, ['update', '--prepare-windows-worker'], { + encoding: 'utf8', env: mergeEnvironment(process.env, env), maxBuffer: 1024 * 1024, + }); + let handoff; + try { handoff = JSON.parse(String(prepared.stdout)); } catch {} + if (prepared.error || prepared.status !== 0 || handoff?.schema !== 'kin.windows-update-worker.v1' || + typeof handoff.worker_executable !== 'string' || !path.isAbsolute(handoff.worker_executable) || + typeof handoff.handoff_record !== 'string' || !path.isAbsolute(handoff.handoff_record) || + !/^[0-9a-f]{40}$/i.test(handoff.source_commit_sha || '') || !/^[0-9a-f]{64}$/i.test(handoff.worker_sha256 || '') || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(handoff.version || '')) { + throw new Error('the managed Windows runtime could not prepare its native update worker; no wrapper replaced any installed component'); + } + const workerMetadata = fs.lstatSync(handoff.worker_executable); + const recordMetadata = fs.lstatSync(handoff.handoff_record); + if (!workerMetadata.isFile() || workerMetadata.isSymbolicLink() || + !recordMetadata.isFile() || recordMetadata.isSymbolicLink() || + hashNativeFile(handoff.worker_executable) !== handoff.worker_sha256.toLowerCase()) { + throw new Error('prepared Windows update worker identity changed; refusing to launch it'); + } + const workerHandoff = validateWindowsWorkerStage(handoff, env); + return { + executable: handoff.worker_executable, + args: ['update', '--windows-worker', handoff.handoff_record, '--apply'], + cwd: path.dirname(handoff.worker_executable), + workerHandoff, + }; +} + +/** Existing installs have one writer: the native transactional updater. */ +export function updateManagedInstall({ + env = process.env, platform = process.platform, spawnImpl = spawnSync, + minimumVersion = targetKinVersion(), log = () => {}, +} = {}) { + const binary = managedBinaryPath('kin', env, platform); + if (!isInstalledComponent(binary)) { + throw new Error('an incomplete managed install exists; refusing to replace its native components independently'); + } + log(`kin: updating the managed runtime through its native transaction at ${binary}`); + const command = nativeUpdateCommand(binary, env, platform, spawnImpl); + let result; + try { + result = spawnImpl(command.executable, command.args, { + cwd: command.cwd, stdio: ['inherit', 'pipe', 'pipe'], encoding: 'utf8', + env: mergeEnvironment(process.env, env), maxBuffer: 8 * 1024 * 1024, + }); + } finally { + if (command.workerHandoff) requestWindowsWorkerCleanup(binary, command.workerHandoff, result, { env, spawnImpl, log }); + } + if (result.stdout) log(String(result.stdout).trimEnd()); + if (result.stderr) log(String(result.stderr).trimEnd()); + if (result.error || result.status !== 0) { + throw new Error( + `native transactional update failed (${result.error?.message || result.status}); ` + + "no launcher installer replaced the existing CLI or daemon. Follow the native updater recovery instructions.", + ); + } + return resolveReadyManagedBinary({ env, platform, spawnImpl, minimumVersion }); +} + +/** Stage an incoming published helper outside the installation it will migrate. */ +export async function bootstrapPublishedInstall(version, { + env = process.env, platform = process.platform, arch = process.arch, + fetchImpl = fetch, spawnImpl = spawnSync, log = line => process.stderr.write(`${line}\n`), +} = {}) { + if ((env.KIN_MANAGED_BIN && env.KIN_MANAGED_BIN.trim()) || env.KIN_RUNTIME_MODE === 'development') { + throw new Error('explicit development selections cannot migrate a managed installation'); + } + const managedHome = kinHome(env); + const release = await resolveLatestPublishedRelease(version, { platform, arch, fetchImpl }); + let originalHome = managedHome; + try { originalHome = fs.realpathSync.native(originalHome); } catch {} + const stageParent = fs.realpathSync.native(os.tmpdir()); + const relative = path.relative(originalHome, stageParent); + if (!relative || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) { + throw new Error('published bootstrap staging must be outside the managed Kin home'); + } + const stage = fs.mkdtempSync(path.join(stageParent, 'kin-published-bootstrap-')); + let invoked = false; + let completed = false; + try { + const archivePath = path.join(stage, release.archiveName); + const [archiveResult, checksumResult] = await Promise.allSettled([ + downloadToFile(release.url, archivePath, fetchImpl), + fetchBuffer(`${release.url}.sha256`, fetchImpl), + ]); + if (archiveResult.status === 'rejected') throw archiveResult.reason; + if (checksumResult.status === 'rejected') throw checksumResult.reason; + const checksum = checksumResult.value.toString('utf8'); + const match = checksum.match(/^([0-9a-f]{64}) ([^\r\n]+)\n?$/i); + if (!match || match[2] !== release.archiveName || match[1].toLowerCase() !== archiveResult.value.sha256) { + throw new Error('published bootstrap archive checksum does not match its exact release asset'); + } + release.archiveSha256 = archiveResult.value.sha256; + fs.writeFileSync(path.join(stage, 'bootstrap-release.json'), `${JSON.stringify(release, null, 2)}\n`, { mode: 0o600 }); + const toolEnv = mergeEnvironment(process.env, env); + const extraction = archiveExtraction(platform, toolEnv, release.archiveName); + const extracted = spawnSync(extraction.executable, extraction.args, { + cwd: stage, encoding: 'utf8', env: extractorEnvironment(toolEnv), + }); + if (extracted.status !== 0) throw new Error(`published bootstrap extraction failed: ${extracted.stderr || extracted.error?.message || extracted.status}`); + const root = extractionRoot(stage); + validateTree(root); + const cli = path.join(root, binaryName('kin', platform)); + const daemon = path.join(root, binaryName('kin-daemon', platform)); + for (const component of [cli, daemon]) { + if (!fs.existsSync(component) || !fs.lstatSync(component).isFile() || fs.statSync(component).size === 0) { + throw new Error(`published bootstrap archive lacks a regular paired component: ${component}`); + } + } + preflightNotifierBundle(root, platform); + if (platform !== 'win32') fs.chmodSync(cli, 0o755); + const childEnv = mergeEnvironment(process.env, env); + for (const name of ['KIN_DIR', 'KIN_DAEMON_BIN', 'KIN_MANAGED_BIN', 'KIN_BINARY_PATH', 'KIN_MCP_KIN_BINARY', 'KIN_DAEMON_URL']) { + delete childEnv[name]; + } + childEnv.KIN_HOME = managedHome; + childEnv.KIN_RUNTIME_MODE = 'managed'; + const args = ['update', '--bootstrap-published', '--channel', release.channel, + '--expect-version', release.version, '--expect-sha', release.commitSha, + '--expect-archive-sha256', release.archiveSha256, '--expect-target', release.target, + '--expect-archive-name', release.archiveName, '--apply']; + log(`kin: migrating the legacy installation through verified published runtime ${release.version}`); + invoked = true; + const result = spawnImpl(cli, args, { + cwd: root, stdio: ['inherit', 'pipe', 'pipe'], encoding: 'utf8', env: childEnv, maxBuffer: 8 * 1024 * 1024, + }); + if (result.stdout) log(String(result.stdout).trimEnd()); + if (result.stderr) log(String(result.stderr).trimEnd()); + if (result.error || result.status !== 0) { + throw new Error(`native published bootstrap did not complete (${result.error?.message || result.status}); installation may require recovery or restart`); + } + const binary = resolveReadyManagedBinary({ env: childEnv, platform, spawnImpl, minimumVersion: version, expectedRelease: release }); + writeLauncherStamp(release.version, childEnv); + completed = true; + return binary; + } catch (error) { + if (invoked) throw new Error(`${error.message}. Bootstrap stage retained at ${stage}; inspect the managed runtime before retrying.`, { cause: error }); + throw error; + } finally { + if (!invoked || completed) fs.rmSync(stage, { recursive: true, force: true }); + } } /** @@ -746,109 +1148,50 @@ export function storeUpgradeNotice(target) { } /** - * Ensure a managed `kin` binary at least as new as this launcher's pinned - * release is present, provisioning (or re-provisioning) when needed. Returns - * the binary path, or null when nothing usable exists and provisioning is - * unavailable. Never downgrades unless the caller opts in with - * $KIN_LAUNCHER_ADOPT=1, and never runs a version other than the pin without - * saying so. - * - * Policy, in order: - * - $KIN_MANAGED_BIN is an explicit user pin: use it as-is, never provision. - * - $KIN_NO_PROVISION=1: resolve-only, no network. - * - $KIN_LAUNCHER_ADOPT=1: always (re-)provision the pinned release, - * regardless of what is already installed. - * - nothing installed yet: provision the pinned release. - * - installed version is known from a bounded `--version` probe. An old - * launcher stamp cannot describe bytes replaced by `kin update`, the - * shell installer, or a restored backup: - * - installed == pinned: reuse it and refresh a missing or stale stamp. - * - installed < pinned: the pin is an upgrade — provision it - * automatically with a one-line notice. Never a silent no-op. - * - installed > pinned: `kin update` or the shell installer moved the - * install past this launcher. Run the newer binary, which serves - * every command the pin would have. The first run says so and - * records the confirmed version in the stamp, so later runs are - * quiet until the installed version changes again. - * - installed version unparseable (foreign binary that will not report a - * version): can't prove direction, so respect it — notice and reuse. + * Resolve one normal managed runtime. Package versions set a minimum, while + * native pair identity decides whether anything can serve. Explicit binary + * overrides are development selections and never trigger provisioning. */ export async function ensureProvisioned(opts = {}) { const { - env = process.env, - platform = process.platform, - arch = process.arch, - fetchImpl = fetch, - log = (line) => process.stderr.write(`${line}\n`), - spawnImpl = spawnSync, - setupFollows = false, + env = process.env, platform = process.platform, arch = process.arch, + fetchImpl = fetch, log = line => process.stderr.write(`${line}\n`), + spawnImpl = spawnSync, setupFollows = false, minimumVersion = targetKinVersion(), } = opts; - - const target = targetKinVersion(); - if (env.KIN_MANAGED_BIN && env.KIN_MANAGED_BIN.trim()) { - return resolveManagedBinary('kin', env, platform); + return resolveDevelopmentBinary(path.resolve(env.KIN_MANAGED_BIN), { env, platform, spawnImpl }); } - const existing = resolveManagedBinary('kin', env, platform); + if (env.KIN_RUNTIME_MODE === 'development') { + throw new Error('development package launches require an explicit KIN_MANAGED_BIN; no normal runtime was provisioned'); + } + const binary = managedBinaryPath('kin', env, platform); + const daemon = path.join(kinHome(env), 'bin', binaryName('kin-daemon', platform)); + const hasInstall = isInstalledComponent(binary) || isInstalledComponent(daemon); if (isTruthyEnv(env.KIN_NO_PROVISION)) { - return existing; + return resolveReadyManagedBinary({ env, platform, minimumVersion, spawnImpl }); } - - const doProvision = () => - provision(target, { env, platform, arch, fetchImpl, log, setupFollows }); - if (isTruthyEnv(env.KIN_LAUNCHER_ADOPT)) { - return doProvision(); - } - if (!existing) { - return doProvision(); - } - - const installedPath = managedBinaryPath('kin', env, platform); - const stamp = readLauncherStamp(env); - const installed = probeBinaryVersion(existing, spawnImpl); - - if (installed === null) { - log( - `kin: using existing managed kin (version unknown) at ${installedPath} ` + - `(this @kinlab/kin pins ${target}; set KIN_LAUNCHER_ADOPT=1 to re-provision).`, + throw new Error( + 'KIN_LAUNCHER_ADOPT cannot replace a managed pair independently. Use the native ' + + '`kin update --apply` for normal updates or KIN_MANAGED_BIN for an explicit development build.', ); - return existing; } - - const cmp = compareVersions(target, installed); - if (cmp === 0) { - if (stamp !== target) { - try { - writeLauncherStamp(target, env); - } catch (error) { - log(`kin: could not refresh the launcher receipt (${error.code ?? 'write failed'}); using verified kin ${installed}.`); - } + if (hasInstall) { + const installed = probeBinaryVersion(binary, spawnImpl); + const current = probeManagedRuntime(binary, { env, spawnImpl }); + if (!current) { + return bootstrapPublishedInstall(minimumVersion, { env, platform, arch, fetchImpl, spawnImpl, log }); } - return existing; - } - if (cmp > 0) { - log(`kin: managed kin ${installed} is older than the pinned ${target}; upgrading automatically.`); - const upgraded = await doProvision(); - if (upgraded) { - log(storeUpgradeNotice(target)); - } - return upgraded; - } - // The install is newer than the pin. This used to refuse, and because - // `kin update` and the shell installer both move $KIN_HOME forward without - // touching the npm package, every launcher hit that refusal the first time - // its owner updated: every command exited 1, and the only way out it named - // was a downgrade. The newer binary answers every command the pinned one - // would, so the launcher runs it. The stamp is written only after the notice - // has been printed, which is what keeps the notice to one run per version. - if (stamp !== installed) { - log(newerInstallNotice(installed, target, installedPath)); - try { - writeLauncherStamp(installed, env); - } catch (error) { - log(`kin: could not record the launcher receipt (${error.code ?? 'write failed'}), so this notice repeats next run.`); + const currentVersion = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(current.cli?.version || '') + ? current.cli.version : installed; + if (currentVersion && compareVersions(currentVersion, minimumVersion) < 0) { + log(`kin: managed kin ${currentVersion} is older than ${minimumVersion}; using its transactional updater.`); + const upgraded = updateManagedInstall({ env, platform, spawnImpl, minimumVersion, log }); + log(storeUpgradeNotice(minimumVersion)); + return upgraded; } + return resolveReadyManagedBinary({ env, platform, minimumVersion, spawnImpl, statusReport: current }); } - return existing; + await provision(minimumVersion, { env, platform, arch, fetchImpl, log, spawnImpl, setupFollows, onProgress: opts.onProgress }); + return resolveReadyManagedBinary({ env, platform, minimumVersion, spawnImpl }); } diff --git a/packages/kin/lib/resolve.mjs b/packages/kin/lib/resolve.mjs index 4791aa9c7..3205db9cc 100644 --- a/packages/kin/lib/resolve.mjs +++ b/packages/kin/lib/resolve.mjs @@ -11,6 +11,7 @@ // launcher pins — so it is unit testable without spawning or touching the // network. Downloading and installing live in ./provision.mjs. +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -128,9 +129,10 @@ export function binaryName(name, platform = process.platform) { return platform === 'win32' ? `${name}.exe` : name; } -/** Root of Kin's managed install, honoring $KIN_HOME (default ~/.kin). */ +/** Root of Kin's managed install, honoring $KIN_HOME, then $KIN_DIR (default ~/.kin). */ export function kinHome(env = process.env, home = os.homedir()) { - return env.KIN_HOME && env.KIN_HOME.trim() ? env.KIN_HOME : path.join(home, '.kin'); + const root = env.KIN_HOME || env.KIN_DIR; + return root ? path.resolve(root) : path.join(home, '.kin'); } /** @@ -180,9 +182,37 @@ export function readLauncherStamp(env = process.env) { } } -/** Record `version` as the provisioned release (directory must exist). */ +/** + * Record `version` as the provisioned release (directory must exist). + * + * The stamp is replaced, never rewritten in place: the bytes go to a fresh + * temporary file beside it, which is flushed and then renamed over the stamp. + * A reader therefore sees the old version or the new one, never an empty or + * half-written file. That matters because launchers older than this package + * trusted the stamp as the installed version, and an empty read made them + * provision again. The native updater writes the same file the same way. + */ export function writeLauncherStamp(version, env = process.env) { - fs.writeFileSync(launcherStampPath(env), `${version}\n`); + const stamp = launcherStampPath(env); + const temporary = path.join( + path.dirname(stamp), + `.${path.basename(stamp)}.tmp-${process.pid}-${crypto.randomUUID()}`, + ); + let handle = null; + try { + handle = fs.openSync(temporary, 'wx', 0o644); + fs.writeFileSync(handle, `${version}\n`); + fs.fsyncSync(handle); + fs.closeSync(handle); + handle = null; + fs.renameSync(temporary, stamp); + } catch (error) { + if (handle !== null) { + try { fs.closeSync(handle); } catch {} + } + try { fs.rmSync(temporary, { force: true }); } catch {} + throw error; + } } /** Human-readable, honest explanation for when the managed binary is absent. */ diff --git a/packages/kin/package.json b/packages/kin/package.json index 40d794418..9dbb70770 100644 --- a/packages/kin/package.json +++ b/packages/kin/package.json @@ -1,6 +1,6 @@ { "name": "@kinlab/kin", - "version": "0.8.6", + "version": "0.8.7", "mcpName": "ai.kinlab/kin", "description": "Canonical installer and launcher for Kin, a graph-native code repository for AI agents and the people who build with them. Provisions and runs the managed kin + kin-daemon release. MCP is one included mode (kin mcp start).", "license": "Apache-2.0", diff --git a/packages/kin/test/provision.test.mjs b/packages/kin/test/provision.test.mjs index 24fc9b886..01d08f4a6 100644 --- a/packages/kin/test/provision.test.mjs +++ b/packages/kin/test/provision.test.mjs @@ -23,6 +23,14 @@ import { provision, probeBinaryVersion, ensureProvisioned, + resolveReadyManagedBinary, + probeManagedRuntime, + developmentLaunchEnvironment, + updateManagedInstall, + bootstrapPublishedInstall, + resolvePublishedRelease, + resolveLatestPublishedRelease, + releaseRustTarget, isTruthyEnv, persistentPathAdvice, setupFollows, @@ -562,6 +570,12 @@ function seedExistingManagedInstall(home) { return env; } +function refusingCurrentNative(binary, args) { + return { status: 1, stdout: args[0] === 'runtime' + ? JSON.stringify({ schema: 'kin.runtime-status.v1', ready: false, reason: 'repair_required' }) + : '', stderr: 'native transaction refused' }; +} + async function assertMalformedBundleUpgradeIsPreflightOnly(notifier, expectedError) { const { work, fetchImpl } = makeFixture({ notifier }); const home = path.join(work, 'kin-home'); @@ -573,9 +587,10 @@ async function assertMalformedBundleUpgradeIsPreflightOnly(notifier, expectedErr platform: 'darwin', arch: 'arm64', fetchImpl, + spawnImpl: refusingCurrentNative, log: () => {}, }), - expectedError, + /native transactional update failed/, ); assert.equal(fs.readFileSync(path.join(home, 'bin', 'kin'), 'utf8'), 'old-kin'); @@ -591,11 +606,11 @@ async function assertMalformedBundleUpgradeIsPreflightOnly(notifier, expectedErr fs.rmSync(work, { recursive: true, force: true }); } -test('provision refuses an absent macOS bundle before mutating a previous install', async () => { +test('a reinstall cannot bypass native authority to replace an existing pair', async () => { await assertMalformedBundleUpgradeIsPreflightOnly('absent', /carries no KinNotifier\.app/); }); -macArchiveModeTest('provision refuses an incomplete macOS bundle before mutating a previous install', async () => { +macArchiveModeTest('a malformed reinstall also cannot bypass native authority', async () => { await assertMalformedBundleUpgradeIsPreflightOnly('no-plist', /missing Contents\/Info\.plist/); }); @@ -679,7 +694,7 @@ macArchiveModeTest('provision creates Kin directories at 0755 and initializes th // A Kin home an earlier install left group writable is not repaired silently. // The refusal names the directory and the command that repairs it, and the // installed binaries stay as they were. -macArchiveModeTest('provision names the exact repair for a group-writable Kin home and replaces nothing', async () => { +macArchiveModeTest('refused native update preserves an existing group-writable home', async () => { const { work, fetchImpl } = makeFixture({ kinScript: REGISTRY_CHECKING_KIN }); const home = path.join(work, 'kin home'); fs.mkdirSync(path.join(home, 'bin'), { recursive: true }); @@ -691,16 +706,12 @@ macArchiveModeTest('provision names the exact repair for a group-writable Kin ho platform: 'darwin', arch: 'arm64', fetchImpl, + spawnImpl: refusingCurrentNative, log: () => {}, }), (error) => { - assert.match(error.message, /must not be group\/world writable/); - assert.ok( - error.message.includes(`chmod go-w '${home}'`), - `the refusal carries a remedy that can be pasted: ${error.message}`, - ); - assert.match(error.message, /mode 0775/); - assert.match(error.message, /KIN_REGISTRY_REPAIR=1/); + assert.match(error.message, /native transactional update failed/); + assert.match(error.message, /native updater recovery instructions/); return true; }, ); @@ -711,17 +722,30 @@ macArchiveModeTest('provision names the exact repair for a group-writable Kin ho const calls = path.join(work, 'registry-calls.log'); fs.rmSync(calls, { force: true }); fs.chmodSync(home, 0o755); - await provision('9.9.9', { + await assert.rejects(provision('9.9.9', { env: { KIN_HOME: home, KIN_REGISTRY_REPAIR: '1' }, - platform: 'darwin', - arch: 'arm64', - fetchImpl, - log: () => {}, - }); - assert.equal(fs.readFileSync(calls, 'utf8'), 'registry authority --fix --initialize\n'); + platform: 'darwin', arch: 'arm64', fetchImpl, log: () => {}, spawnImpl: refusingCurrentNative, + }), /native transactional update failed/); + assert.equal(fs.existsSync(calls), false, 'npm did not replace the unsupported authority with a staged binary'); + assert.equal(fs.readFileSync(path.join(home, 'bin', 'kin'), 'utf8'), 'old kin'); fs.rmSync(work, { recursive: true, force: true }); }); +macArchiveModeTest('fresh provisioning refuses symlinked component directories before downloading', async t => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-fresh-route-')); + t.after(() => fs.rmSync(home, { recursive: true, force: true })); + const outside = path.join(home, 'outside'); + const root = path.join(home, 'managed'); + fs.mkdirSync(outside); + fs.mkdirSync(root); + fs.symlinkSync(outside, path.join(root, 'bin')); + await assert.rejects(provision('9.9.9', { + env: { KIN_HOME: root }, log: () => {}, + fetchImpl: async () => { throw new Error('no download before directory authority'); }, + }), /real non-symlink directory/); + assert.deepEqual(fs.readdirSync(outside), [], 'no component was written outside the managed root'); +}); + test('provision refuses a checksum mismatch without installing', async () => { const { work, bytes } = makeFixture(); const home = path.join(work, 'kin-home'); @@ -778,16 +802,31 @@ test('probeBinaryVersion parses the kin version line and null on failure', () => assert.equal(probeBinaryVersion('/any', () => ({ status: 0, stdout: 'kin 1.2.3-rc.1+build\n' })), '1.2.3-rc.1+build'); }); -test('ensureProvisioned respects KIN_MANAGED_BIN and never provisions over it', async () => { - const real = process.execPath; +test('an explicit development pair is isolated and never provisioned', async t => { + const { cliPath, report, env } = managedFixture(t); + report.mode = 'development'; + report.cli.source_dirty = true; + report.daemon.source_dirty = true; const result = await ensureProvisioned({ - env: { KIN_MANAGED_BIN: real }, - fetchImpl: async () => { - throw new Error('network must not be touched'); + env: { ...env, KIN_MANAGED_BIN: cliPath }, + fetchImpl: async () => { throw new Error('network must not be touched'); }, + spawnImpl: (binary, args, options) => { + assert.equal(binary, cliPath); + assert.deepEqual(args, ['runtime', 'status', '--json', '--development']); + assert.equal(options.env.KIN_RUNTIME_MODE, 'development'); + return { status: 0, stdout: JSON.stringify(report) }; }, - log: () => {}, }); - assert.equal(result, real); + assert.equal(result, cliPath); +}); + +test('an explicit old development binary cannot silently attach without isolation', async t => { + const { cliPath, env } = managedFixture(t); + await assert.rejects(ensureProvisioned({ + env: { ...env, KIN_MANAGED_BIN: cliPath }, + spawnImpl: () => ({ status: 1, stdout: '', stderr: 'unknown subcommand runtime' }), + fetchImpl: async () => { throw new Error('no install repair permitted'); }, + }), /development build.*runtime-isolation contract/); }); test('ensureProvisioned is resolve-only under KIN_NO_PROVISION', async () => { @@ -803,427 +842,653 @@ test('ensureProvisioned is resolve-only under KIN_NO_PROVISION', async () => { fs.rmSync(home, { recursive: true, force: true }); }); -test('ensureProvisioned verifies a stamped current install without network', async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-stamped-')); +function managedFixture(t, version = '99.0.0', platform = process.platform) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-paired-runtime-')); + t.after(() => fs.rmSync(home, { recursive: true, force: true })); const env = { KIN_HOME: home }; - const binDir = path.join(home, 'bin'); - const kinName = binaryName('kin', process.platform); - fs.mkdirSync(binDir, { recursive: true }); - const { targetKinVersion } = await import('../lib/resolve.mjs'); - fs.writeFileSync(path.join(binDir, kinName), '#!/bin/sh\n'); - writeLauncherStamp(targetKinVersion(), env); - const result = await ensureProvisioned({ - env, - platform: process.platform, - fetchImpl: async () => { - throw new Error('network must not be touched'); + const cliPath = path.join(home, 'bin', binaryName('kin', platform)); + const daemonPath = path.join(home, 'bin', binaryName('kin-daemon', platform)); + fs.mkdirSync(path.dirname(cliPath), { recursive: true }); + fs.writeFileSync(cliPath, 'preserve-cli'); + fs.writeFileSync(daemonPath, 'preserve-daemon'); + const component = candidate => ({ + path: candidate, version, commit_sha: 'a'.repeat(40), source_dirty: false, + target: 'test-target', build_profile: 'release', sha256: sha256Hex(fs.readFileSync(candidate)), + }); + const report = { + schema: 'kin.runtime-status.v1', ready: true, mode: 'managed', kin_home: home, + cli: component(cliPath), daemon: component(daemonPath), pair_matches: true, + channel: 'stable', release_kind: 'published', reason: null, detail: null, + restart_needed: false, + }; + return { home, env, cliPath, daemonPath, report }; +} + +function publishedFixtureFetch(fixture, version, { commitSha = 'c'.repeat(40), mutateRelease = () => {} } = {}) { + const archiveName = artifactName(fixture.platform, fixture.arch); + const url = releaseDownloadUrl(version, archiveName); + const release = { tag_name: `v${version}`, draft: false, prerelease: version.includes('-'), assets: [ + { name: archiveName, browser_download_url: url }, + { name: `${archiveName}.sha256`, browser_download_url: `${url}.sha256` }, + ] }; + mutateRelease(release); + return async address => { + if (!address.startsWith('https://api.github.com/')) return fixture.fetchImpl(address); + const value = address.includes('/git/ref/') ? { ref: `refs/tags/v${version}`, object: { type: 'commit', sha: commitSha } } : release; + const bytes = Buffer.from(JSON.stringify(value)); + return { ok: true, status: 200, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }; + }; +} + +for (const oldVersion of ['0.8.1', '0.8.6']) { + test(`legacy ${oldVersion} uses a private incoming helper and awaits exact canonical proof`, async t => { + const fixture = makeHostProvisionFixture(); + t.after(() => fs.rmSync(fixture.work, { recursive: true, force: true })); + const { home, env, cliPath, daemonPath, report } = managedFixture(t, '99.0.0', fixture.platform); + const expectedTarget = releaseRustTarget(fixture.platform, fixture.arch); + report.cli.commit_sha = report.daemon.commit_sha = 'c'.repeat(40); + report.cli.target = report.daemon.target = expectedTarget; + const calls = []; + let migrated = false; + let incoming; + const selected = await ensureProvisioned({ + env: { ...env, KIN_DIR: '/conflicting-root', KIN_DAEMON_BIN: daemonPath, KIN_DAEMON_URL: 'http://old-daemon.test' }, + platform: fixture.platform, arch: fixture.arch, minimumVersion: '99.0.0', + fetchImpl: publishedFixtureFetch(fixture, '99.0.0'), log: () => {}, + spawnImpl: (binary, args, options) => { + calls.push({ binary, args }); + if (binary === cliPath && args[0] === '--version') return { status: 0, stdout: `kin ${oldVersion}\n` }; + if (args.includes('--bootstrap-published')) { + incoming = binary; + assert.notEqual(binary, cliPath, 'the old installed executable never receives new flags'); + assert.equal(options.cwd, path.dirname(binary), 'incoming Windows/native helper has a private working directory'); + assert.equal(options.env.KIN_HOME, home); + assert.equal(options.env.KIN_DIR, undefined); + assert.equal(options.env.KIN_DAEMON_BIN, undefined); + assert.equal(options.env.KIN_DAEMON_URL, undefined); + assert.equal(options.env.KIN_RUNTIME_MODE, 'managed'); + assert.deepEqual(fs.readdirSync(home), ['bin'], 'staging did not initialize live registry or directories'); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + assert.equal(fs.readFileSync(daemonPath, 'utf8'), 'preserve-daemon'); + assert.deepEqual(args, ['update', '--bootstrap-published', '--channel', 'stable', + '--expect-version', '99.0.0', '--expect-sha', 'c'.repeat(40), + '--expect-archive-sha256', sha256Hex(fixture.bytes), '--expect-target', expectedTarget, + '--expect-archive-name', artifactName(fixture.platform, fixture.arch), '--apply']); + migrated = true; + return { status: 0, stdout: 'fixture native transaction result\n' }; + } + assert.equal(binary, cliPath); + return migrated ? { status: 0, stdout: JSON.stringify(report) } : { status: 2, stdout: 'unknown runtime command' }; + }, + }); + assert.equal(selected, cliPath); + assert.equal(readLauncherStamp(env), '99.0.0', 'stamp follows exact canonical proof'); + assert.equal(fs.existsSync(incoming), false, 'successful stage is removed'); + assert.equal(calls.filter(call => call.binary === cliPath && call.args[0] === 'update').length, 0); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli', 'only native fixture authority could replace installed bytes'); + }); +} + +test('bootstrap preflight refuses unofficial metadata without invoking an installed or incoming updater', async t => { + const fixture = makeHostProvisionFixture(); + t.after(() => fs.rmSync(fixture.work, { recursive: true, force: true })); + const { env, cliPath } = managedFixture(t, '0.8.1', fixture.platform); + await assert.rejects(bootstrapPublishedInstall('99.0.0', { + env, platform: fixture.platform, arch: fixture.arch, log: () => {}, + fetchImpl: publishedFixtureFetch(fixture, '99.0.0', { mutateRelease: release => { release.draft = true; } }), + spawnImpl: () => { throw new Error('no executable before official preflight'); }, + }), /official.*(metadata|published)/); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + assert.equal(readLauncherStamp(env), null); +}); + +test('bootstrap archive checksum failure does not invoke native migration or change the managed home', async t => { + const fixture = makeHostProvisionFixture(); + t.after(() => fs.rmSync(fixture.work, { recursive: true, force: true })); + const { home, env, cliPath } = managedFixture(t, '0.8.6', fixture.platform); + const publishedFetch = publishedFixtureFetch(fixture, '99.0.0'); + await assert.rejects(bootstrapPublishedInstall('99.0.0', { + env, platform: fixture.platform, arch: fixture.arch, log: () => {}, + fetchImpl: async address => { + if (!address.endsWith('.sha256')) return publishedFetch(address); + const bytes = Buffer.from(`${'0'.repeat(64)} ${artifactName(fixture.platform, fixture.arch)}\n`); + return { ok: true, status: 200, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }; + }, + spawnImpl: () => { throw new Error('no executable before archive verification'); }, + }), /archive checksum/); + assert.deepEqual(fs.readdirSync(home), ['bin']); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); +}); + +test('an older compatible npm wrapper migrates legacy Kin with the exact latest stable tuple', async t => { + const fixture = makeHostProvisionFixture(); + t.after(() => fs.rmSync(fixture.work, { recursive: true, force: true })); + const { env, cliPath, report } = managedFixture(t, '0.8.8', fixture.platform); + report.cli.target = report.daemon.target = releaseRustTarget(fixture.platform, fixture.arch); + report.cli.commit_sha = report.daemon.commit_sha = 'c'.repeat(40); + let migrated = false; + const requested = []; + const publishedFetch = publishedFixtureFetch(fixture, '0.8.8'); + const selected = await ensureProvisioned({ + env, platform: fixture.platform, arch: fixture.arch, minimumVersion: '0.8.7', log: () => {}, + fetchImpl: address => { requested.push(address); return publishedFetch(address); }, + spawnImpl: (binary, args) => { + if (args[0] === '--version') return { status: 0, stdout: 'kin 0.8.1\n' }; + if (args.includes('--bootstrap-published')) { + assert.notEqual(binary, cliPath); + assert.deepEqual(args, ['update', '--bootstrap-published', '--channel', 'stable', + '--expect-version', '0.8.8', '--expect-sha', 'c'.repeat(40), + '--expect-archive-sha256', sha256Hex(fixture.bytes), + '--expect-target', releaseRustTarget(fixture.platform, fixture.arch), + '--expect-archive-name', artifactName(fixture.platform, fixture.arch), '--apply']); + migrated = true; + return { status: 0, stdout: '' }; + } + return migrated ? { status: 0, stdout: JSON.stringify(report) } : { status: 2, stdout: 'unsupported runtime command' }; }, - spawnImpl: () => ({ status: 0, stdout: `kin ${targetKinVersion()}\n` }), - log: () => {}, }); - assert.equal(result, path.join(binDir, kinName)); - fs.rmSync(home, { recursive: true, force: true }); + assert.equal(selected, cliPath); + assert.equal(readLauncherStamp(env), '0.8.8'); + assert.ok(requested.some(address => address.endsWith('/releases/latest'))); + assert.ok(requested.some(address => address.endsWith('/git/ref/tags/v0.8.8'))); + assert.equal(requested.some(address => address.includes('v0.8.7')), false); }); -// ── mismatch matrix ───────────────────────────────────────────────────────── -// -// ensureProvisioned's pin-vs-installed policy, exercised on both the stamped -// (this package provisioned it before) and foreign installs, always probing -// via `--version`: an older install upgrades automatically, a newer install -// runs with a one-time notice, an equal install just runs, and -// KIN_LAUNCHER_ADOPT=1 forces a re-provision regardless. +test('latest stable below the npm minimum refuses before staging or managed writes', async t => { + const fixture = makeHostProvisionFixture(); + t.after(() => fs.rmSync(fixture.work, { recursive: true, force: true })); + const { home, env, cliPath } = managedFixture(t, '0.8.1', fixture.platform); + const requested = []; + const publishedFetch = publishedFixtureFetch(fixture, '0.8.6'); + await assert.rejects(ensureProvisioned({ + env, platform: fixture.platform, arch: fixture.arch, minimumVersion: '0.8.7', log: () => {}, + fetchImpl: address => { requested.push(address); return publishedFetch(address); }, + spawnImpl: (binary, args) => { + assert.equal(binary, cliPath); + assert.notEqual(args[0], 'update', 'legacy installed CLI is never invoked to migrate'); + return { status: args[0] === '--version' ? 0 : 2, stdout: args[0] === '--version' ? 'kin 0.8.1\n' : 'unsupported runtime command' }; + }, + }), /latest stable Kin 0\.8\.6 is below.*minimum 0\.8\.7/); + assert.equal(requested.length, 1, 'no tag or archive is staged after an incompatible latest version'); + assert.deepEqual(fs.readdirSync(home), ['bin']); + assert.equal(readLauncherStamp(env), null); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + await assert.rejects(resolveLatestPublishedRelease('0.8.7', { + platform: fixture.platform, arch: fixture.arch, fetchImpl: publishedFetch, + }), /below.*minimum/); +}); -test('ensureProvisioned auto-provisions over an older foreign install (no ADOPT needed)', async () => { - const { work, fetchImpl, platform, arch } = makeHostProvisionFixture(); - const home = path.join(work, 'kin-home'); - const env = { KIN_HOME: home }; - const binDir = path.join(home, 'bin'); - const kinName = binaryName('kin', platform); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(path.join(binDir, kinName), '#!/bin/sh\n'); - const notices = []; - const result = await ensureProvisioned({ - env, - platform, - arch, - fetchImpl, - spawnImpl: () => ({ status: 0, stdout: 'kin 0.0.1-foreign (x)\n' }), - log: (line) => notices.push(line), +for (const refusal of ['native', 'restart', 'wrong-source']) { + test(`bootstrap ${refusal} refusal retains stage and never claims installed success`, async t => { + const fixture = makeHostProvisionFixture(); + t.after(() => fs.rmSync(fixture.work, { recursive: true, force: true })); + const { home, env, cliPath, report } = managedFixture(t, '99.0.0', fixture.platform); + report.cli.target = report.daemon.target = releaseRustTarget(fixture.platform, fixture.arch); + report.cli.commit_sha = report.daemon.commit_sha = refusal === 'wrong-source' ? 'b'.repeat(40) : 'c'.repeat(40); + if (refusal === 'restart') { report.ready = false; report.restart_needed = true; report.reason = 'restart_pending'; } + let incoming; + await assert.rejects(bootstrapPublishedInstall('99.0.0', { + env, platform: fixture.platform, arch: fixture.arch, log: () => {}, + fetchImpl: publishedFixtureFetch(fixture, '99.0.0'), + spawnImpl: (binary, args) => { + if (args.includes('--bootstrap-published')) { + incoming = binary; + return { status: refusal === 'native' ? 1 : 0, stdout: '', stderr: 'fixture native refusal' }; + } + assert.equal(binary, cliPath); + return { status: refusal === 'restart' ? 1 : 0, stdout: JSON.stringify(report) }; + }, + }), /Bootstrap stage retained/); + assert.ok(fs.existsSync(incoming)); + assert.deepEqual(fs.readdirSync(home), ['bin']); + assert.equal(readLauncherStamp(env), null); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + // Fixture cleanup owns only the private stage named by the retained helper. + const stage = path.dirname(incoming); + fs.rmSync(path.basename(stage).startsWith('kin-published-bootstrap-') ? stage : path.dirname(stage), { recursive: true, force: true }); }); - const { targetKinVersion } = await import('../lib/resolve.mjs'); - assert.equal(result, path.join(binDir, kinName)); - assert.match(fs.readFileSync(result, 'utf8'), /9\.9\.9/); - assert.equal(readLauncherStamp(env), targetKinVersion()); - assert.ok(notices.some((l) => l.includes('older') && l.includes('upgrading automatically'))); - assert.ok( - notices.some( - (l) => l.includes('run `kin upgrade`') && l.includes(`npx -y @kinlab/kin@${targetKinVersion()} upgrade`), - ), - notices.join('\n'), - ); - fs.rmSync(work, { recursive: true, force: true }); +} + +test('published tag resolution rejects unknown commit identity and binds the platform release assets', async t => { + const fixture = makeHostProvisionFixture(); + t.after(() => fs.rmSync(fixture.work, { recursive: true, force: true })); + const release = await resolvePublishedRelease('99.0.0', { + platform: fixture.platform, arch: fixture.arch, fetchImpl: publishedFixtureFetch(fixture, '99.0.0'), + }); + assert.equal(release.commitSha, 'c'.repeat(40)); + assert.equal(release.target, releaseRustTarget(fixture.platform, fixture.arch)); + await assert.rejects(resolvePublishedRelease('99.0.0', { + platform: fixture.platform, arch: fixture.arch, + fetchImpl: publishedFixtureFetch(fixture, '99.0.0', { commitSha: 'unknown' }), + }), /published release metadata/); }); -test('ensureProvisioned auto-provisions when a stamped install is older than the pin', async () => { - const { work, fetchImpl, platform, arch } = makeHostProvisionFixture(); - const home = path.join(work, 'kin-home'); - const env = { KIN_HOME: home }; - const binDir = path.join(home, 'bin'); - const kinName = binaryName('kin', platform); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(path.join(binDir, kinName), '#!/bin/sh\n'); +test('published source resolution peels the exact annotated tag instead of a same-named branch', async t => { + const fixture = makeHostProvisionFixture(); + t.after(() => fs.rmSync(fixture.work, { recursive: true, force: true })); + const original = publishedFixtureFetch(fixture, '99.0.0'); + const calls = []; + const fetchImpl = async address => { + calls.push(address); + let record; + if (address.includes('/git/ref/')) record = { ref: 'refs/tags/v99.0.0', object: { type: 'tag', sha: 'd'.repeat(40) } }; + else if (address.includes('/git/tags/')) record = { sha: 'd'.repeat(40), object: { type: 'commit', sha: 'e'.repeat(40) } }; + else return original(address); + const bytes = Buffer.from(JSON.stringify(record)); + return { ok: true, status: 200, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }; + }; + const release = await resolvePublishedRelease('99.0.0', { platform: fixture.platform, arch: fixture.arch, fetchImpl }); + assert.equal(release.commitSha, 'e'.repeat(40)); + assert.ok(calls.some(address => address.endsWith(`/git/tags/${'d'.repeat(40)}`))); + assert.equal(calls.some(address => address.includes('/commits/')), false); +}); + +test('normal resolution requires paired source identity even when versions and stamp agree', t => { + const { env, report } = managedFixture(t); + writeLauncherStamp(report.cli.version, env); + for (const mutate of [ + r => { r.daemon = null; }, + r => { r.daemon.commit_sha = 'b'.repeat(40); }, + r => { r.daemon.target = 'different-target'; }, + r => { r.cli.source_dirty = true; }, + r => { r.daemon.path = '/different/runtime/kin-daemon'; }, + r => { r.schema = 'unknown.v2'; }, + r => { r.ready = false; r.reason = 'restart_pending'; }, + ]) { + const changed = structuredClone(report); + mutate(changed); + assert.throws(() => resolveReadyManagedBinary({ + env, spawnImpl: () => ({ status: 0, stdout: JSON.stringify(changed) }), + }), /not certified/); + } +}); + +test('matching paired identity is reused without touching network or launcher stamp', async t => { + const { env, report, cliPath, daemonPath } = managedFixture(t); writeLauncherStamp('0.0.1', env); - const notices = []; - const result = await ensureProvisioned({ + const calls = []; + const selected = await ensureProvisioned({ env, - platform, - arch, - fetchImpl, - spawnImpl: () => ({ status: 0, stdout: 'kin 0.0.1\n' }), - log: (line) => notices.push(line), + fetchImpl: async () => { throw new Error('no download expected'); }, + spawnImpl: (binary, args) => { + calls.push(args); + return { status: 0, stdout: args[0] === '--version' ? `kin ${report.cli.version}\n` : JSON.stringify(report) }; + }, }); - const { targetKinVersion } = await import('../lib/resolve.mjs'); - assert.equal(result, path.join(binDir, kinName)); - assert.match(fs.readFileSync(result, 'utf8'), /9\.9\.9/); - assert.equal(readLauncherStamp(env), targetKinVersion()); - assert.ok(notices.some((l) => l.includes('older') && l.includes('upgrading automatically'))); - assert.ok( - notices.some( - (l) => l.includes('run `kin upgrade`') && l.includes(`npx -y @kinlab/kin@${targetKinVersion()} upgrade`), - ), - notices.join('\n'), - ); - fs.rmSync(work, { recursive: true, force: true }); + assert.equal(selected, cliPath); + assert.deepEqual(calls, [['--version'], ['runtime', 'status', '--json']]); + assert.equal(readLauncherStamp(env), '0.0.1', 'receipt is not rewritten as runtime proof'); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + assert.equal(fs.readFileSync(daemonPath, 'utf8'), 'preserve-daemon'); }); -// `kin update` and the shell installer move $KIN_HOME forward without touching -// the npm package, so a launcher that refused a newer install made every -// command exit 1 the day after an update, and named only a downgrade as the -// way out. The launcher runs the newer install instead and says so once. -test('ensureProvisioned runs a newer foreign install instead of refusing it', async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-newer-foreign-')); - const env = { KIN_HOME: home }; - const binDir = path.join(home, 'bin'); - const kinName = binaryName('kin', process.platform); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(path.join(binDir, kinName), 'preserve the newer executable'); - const { targetKinVersion } = await import('../lib/resolve.mjs'); - const notices = []; - const result = await ensureProvisioned({ +// Launchers before the probe-first rule read the stamp as the installed +// version, so a stamp left at 0.3.6 beside a 0.8.6 runtime told them the +// install was older than their pin and they reinstalled their own release over +// it. A current launcher, whatever release it pins, asks the runtime and +// leaves a newer one alone even when the stamp disagrees. +test('a stale low stamp never makes a launcher replace a newer managed runtime', async t => { + const { env, report, cliPath, daemonPath } = managedFixture(t, '0.8.6'); + writeLauncherStamp('0.3.6', env); + const calls = []; + const selected = await ensureProvisioned({ env, - platform: process.platform, - fetchImpl: async () => { - throw new Error('a newer install must never be replaced by the older pin'); + minimumVersion: '0.6.6', + fetchImpl: async () => { throw new Error('no download expected'); }, + spawnImpl: (binary, args) => { + calls.push(args); + return { status: 0, stdout: args[0] === '--version' ? `kin ${report.cli.version}\n` : JSON.stringify(report) }; }, - spawnImpl: () => ({ status: 0, stdout: 'kin 99.0.0 (newer)\n' }), - log: (line) => notices.push(line), }); - assert.equal(result, path.join(binDir, kinName), 'the newer install is what runs'); - assert.equal( - fs.readFileSync(path.join(binDir, kinName), 'utf8'), - 'preserve the newer executable', - 'the newer binary must be left exactly as it was', - ); - assert.equal(notices.length, 1, `exactly one notice: ${JSON.stringify(notices)}`); - const [notice] = notices; - assert.match(notice, /running managed kin 99\.0\.0/); - assert.ok(notice.includes(`newer than the ${targetKinVersion()}`), notice); - assert.ok(notice.includes('npm install -g @kinlab/kin@latest'), `the forward move is named: ${notice}`); - assert.ok(notice.includes('KIN_LAUNCHER_ADOPT=1'), `the deliberate downgrade is still named: ${notice}`); - assert.equal(/refusing/.test(notice), false, notice); - assert.equal(readLauncherStamp(env), '99.0.0', 'the confirmed version is recorded'); - fs.rmSync(home, { recursive: true, force: true }); + assert.equal(selected, cliPath); + assert.equal(calls.some(args => args[0] === 'update'), false, 'no update or reinstall was attempted'); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + assert.equal(fs.readFileSync(daemonPath, 'utf8'), 'preserve-daemon'); }); -test('a newer install the launcher already confirmed runs without a notice', async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-newer-stamped-')); - const env = { KIN_HOME: home }; - const binDir = path.join(home, 'bin'); - const kinName = binaryName('kin', process.platform); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(path.join(binDir, kinName), '#!/bin/sh\n'); - writeLauncherStamp('99.0.0', env); - const notices = []; - const result = await ensureProvisioned({ - env, - platform: process.platform, - fetchImpl: async () => { - throw new Error('a newer install must never be replaced by the older pin'); +test('an old managed runtime delegates upgrade exclusively to the native updater', async t => { + const { env, report, cliPath, daemonPath } = managedFixture(t, '99.0.0', 'linux'); + const calls = []; + let updated = false; + const selected = await ensureProvisioned({ + env, platform: 'linux', + fetchImpl: async () => { throw new Error('launcher must not download an upgrade'); }, + log: () => {}, + spawnImpl: (binary, args) => { + assert.equal(binary, cliPath); + calls.push(args); + if (args[0] === '--version') return { status: 0, stdout: 'kin 0.0.1\n' }; + if (args[0] === 'update') { updated = true; return { status: 0, stdout: 'native transaction completed\n' }; } + return { status: updated ? 0 : 1, stdout: JSON.stringify(updated ? report : { + schema: 'kin.runtime-status.v1', ready: false, reason: 'upgrade_required', + }) }; }, - spawnImpl: () => ({ status: 0, stdout: 'kin 99.0.0\n' }), - log: (line) => notices.push(line), }); - assert.equal(result, path.join(binDir, kinName)); - assert.deepEqual(notices, [], 'the notice is owed once per installed version, not once per run'); - assert.equal(readLauncherStamp(env), '99.0.0'); - fs.rmSync(home, { recursive: true, force: true }); + assert.equal(selected, cliPath); + assert.deepEqual(calls, [['--version'], ['runtime', 'status', '--json'], ['update', '--apply'], ['runtime', 'status', '--json']]); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + assert.equal(fs.readFileSync(daemonPath, 'utf8'), 'preserve-daemon'); }); -test('the newer-install notice comes back when the installed version moves again', async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-newer-moved-')); - const env = { KIN_HOME: home }; - const binary = path.join(home, 'bin', binaryName('kin', process.platform)); - fs.mkdirSync(path.dirname(binary), { recursive: true }); - fs.writeFileSync(binary, '#!/bin/sh\n'); - let installed = '99.0.0'; - const run = async () => { - const notices = []; - const result = await ensureProvisioned({ - env, - platform: process.platform, - fetchImpl: async () => { throw new Error('must not download'); }, - spawnImpl: () => ({ status: 0, stdout: `kin ${installed}\n` }), - log: (line) => notices.push(line), - }); - assert.equal(result, binary); - return notices; - }; - assert.equal((await run()).length, 1, 'first sight of 99.0.0 is announced'); - assert.equal((await run()).length, 0, 'second run on the same version is quiet'); - installed = '99.1.0'; - const moved = await run(); - assert.equal(moved.length, 1, 'a further update is announced again'); - assert.match(moved[0], /running managed kin 99\.1\.0/); - assert.equal(readLauncherStamp(env), '99.1.0'); - fs.rmSync(home, { recursive: true, force: true }); +test('a refused native upgrade preserves both existing files and never falls back to provisioning', async t => { + const { env, cliPath, daemonPath } = managedFixture(t, '99.0.0', 'linux'); + await assert.rejects(ensureProvisioned({ + env, platform: 'linux', + fetchImpl: async () => { throw new Error('must not download'); }, + spawnImpl: (binary, args) => ({ status: args[0] === '--version' ? 0 : 1, + stdout: args[0] === '--version' ? 'kin 0.0.1\n' : args[0] === 'runtime' + ? JSON.stringify({ schema: 'kin.runtime-status.v1', ready: false, reason: 'upgrade_required' }) : '', + stderr: 'managed runtime is active' }), + log: () => {}, + }), /transactional update failed/); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + assert.equal(fs.readFileSync(daemonPath, 'utf8'), 'preserve-daemon'); }); -test('ensureProvisioned adopts a foreign install when its version already matches the pin', async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-foreign-equal-')); - const env = { KIN_HOME: home }; - const binDir = path.join(home, 'bin'); - const kinName = binaryName('kin', process.platform); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(path.join(binDir, kinName), '#!/bin/sh\n'); - const { targetKinVersion } = await import('../lib/resolve.mjs'); - const result = await ensureProvisioned({ - env, - platform: process.platform, - fetchImpl: async () => { - throw new Error('must not provision when the foreign install already matches'); +test('Windows updates prepare a private native worker and await its exit before status', t => { + const { env, cliPath, report, home, daemonPath } = managedFixture(t, '99.0.0', 'win32'); + const copied = copiedWindowsWorker(home); + const calls = []; + const selected = updateManagedInstall({ + env, platform: 'win32', log: () => {}, + spawnImpl: (binary, args, options) => { + calls.push({ binary, args }); + if (args.includes('--prepare-windows-worker')) return { status: 0, stdout: JSON.stringify(copied.prepared) }; + if (args.includes('--windows-worker')) { + assert.equal(binary, copied.worker); + assert.equal(options.cwd, copied.directory); + assert.deepEqual(args, ['update', '--windows-worker', copied.record, '--apply']); + recordMockWorkerConsumption(copied); + return { status: 0, pid: 4312, stdout: 'native transaction complete' }; + } + if (args.includes('--cleanup-windows-worker')) { + assert.equal(binary, cliPath); + assert.equal(options.cwd, path.dirname(cliPath), 'cleanup runs outside the mapped worker directory'); + assert.deepEqual(args, ['update', '--cleanup-windows-worker', copied.record]); + assert.ok(fs.existsSync(copied.worker), 'wrapper has not deleted runtime-owned scratch'); + return { status: 0, stdout: 'native cleanup complete' }; + } + assert.equal(binary, cliPath); + assert.deepEqual(args, ['runtime', 'status', '--json']); + assert.ok(fs.existsSync(copied.directory), 'only native cleanup owns scratch deletion'); + return { status: 0, stdout: JSON.stringify(report) }; }, - spawnImpl: () => ({ status: 0, stdout: `kin ${targetKinVersion()} (foreign)\n` }), - log: () => {}, }); - assert.equal(result, path.join(binDir, kinName)); - assert.equal(readLauncherStamp(env), targetKinVersion()); - fs.rmSync(home, { recursive: true, force: true }); + assert.equal(selected, cliPath); + assert.equal(calls.length, 4); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + assert.equal(fs.readFileSync(daemonPath, 'utf8'), 'preserve-daemon'); }); -for (const stamped of ['0.0.1', '99.0.0']) { - test(`a stale ${stamped} stamp cannot override the matching installed binary`, async (t) => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-stale-stamp-')); - t.after(() => fs.rmSync(home, { recursive: true, force: true })); - const env = { KIN_HOME: home }; - const binary = path.join(home, 'bin', binaryName('kin', process.platform)); - fs.mkdirSync(path.dirname(binary), { recursive: true }); - fs.writeFileSync(binary, 'preserve the existing executable'); - writeLauncherStamp(stamped, env); - const { targetKinVersion } = await import('../lib/resolve.mjs'); - let probes = 0; - const result = await ensureProvisioned({ - env, - fetchImpl: async () => { throw new Error('matching installed bytes must not be replaced'); }, - spawnImpl: (command, args) => { - assert.equal(command, binary); - assert.deepEqual(args, ['--version']); - probes += 1; - return { status: 0, stdout: `kin ${targetKinVersion()} (installed separately)\n` }; +function copiedWindowsWorker(home) { + const directory = path.join(home, `.update-worker-${crypto.randomUUID()}`); + fs.mkdirSync(directory); + const worker = path.join(directory, 'kin-update-worker.exe'); + const record = path.join(directory, 'handoff.json'); + fs.writeFileSync(worker, 'copied worker bytes'); + fs.writeFileSync(record, 'private handoff bytes'); + return { directory, worker, record, prepared: { + schema: 'kin.windows-update-worker.v1', worker_executable: worker, handoff_record: record, + source_commit_sha: 'a'.repeat(40), version: '99.0.0', worker_sha256: sha256Hex(fs.readFileSync(worker)), + } }; +} + +// These injected records test consumer dispatch only. Native Windows controls +// execute the actual worker and its process/object-bound consumption proof. +function recordMockWorkerConsumption(copied, overrides = {}) { + const consumed = { + schema: 'kin.windows-update-worker-consumed.v1', + worker_process: { pid: 4312, boot_id: 'mock boot', birth_token: 'mock birth' }, + directory_binding: { mock: true }, handoff_binding: { mock: true }, + handoff_sha256: sha256Hex(fs.readFileSync(copied.record)), + ...overrides, + }; + fs.writeFileSync(path.join(copied.directory, 'claimed'), JSON.stringify(consumed)); +} + +for (const outcome of ['no-update', 'failure']) { + test(`consumed Windows worker ${outcome} requests native cleanup after exit and preserves its result`, t => { + const { home, env, cliPath, report, daemonPath } = managedFixture(t, '99.0.0', 'win32'); + const copied = copiedWindowsWorker(home); + const calls = []; + const run = () => updateManagedInstall({ + env, platform: 'win32', log: () => {}, + spawnImpl: (binary, args) => { + calls.push(args); + if (args.includes('--prepare-windows-worker')) return { status: 0, stdout: JSON.stringify(copied.prepared) }; + if (args.includes('--windows-worker')) { + assert.ok(fs.existsSync(copied.worker)); + recordMockWorkerConsumption(copied); + return { status: outcome === 'failure' ? 17 : 0, pid: 4312, + stdout: outcome === 'no-update' ? 'already current' : '', stderr: 'fixture failure' }; + } + if (args.includes('--cleanup-windows-worker')) { + assert.equal(binary, cliPath); + return { status: 0, stdout: '' }; + } + return { status: 0, stdout: JSON.stringify(report) }; }, - log: () => {}, }); - assert.equal(result, binary); - assert.equal(probes, 1); - assert.equal(readLauncherStamp(env), targetKinVersion()); - assert.equal(fs.readFileSync(binary, 'utf8'), 'preserve the existing executable'); + if (outcome === 'failure') assert.throws(run, /transactional update failed \(17\)/); + else assert.equal(run(), cliPath); + assert.deepEqual(calls.slice(0, 3), [ + ['update', '--prepare-windows-worker'], ['update', '--windows-worker', copied.record, '--apply'], + ['update', '--cleanup-windows-worker', copied.record], + ]); + assert.ok(fs.existsSync(copied.directory), 'the mock native cleanup did not delete; the wrapper must not do so'); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + assert.equal(fs.readFileSync(daemonPath, 'utf8'), 'preserve-daemon'); }); } -test('a stale older stamp cannot authorize downgrading a newer installed binary', async (t) => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-stale-downgrade-')); - t.after(() => fs.rmSync(home, { recursive: true, force: true })); - const env = { KIN_HOME: home }; - const binary = path.join(home, 'bin', binaryName('kin', process.platform)); - fs.mkdirSync(path.dirname(binary), { recursive: true }); - fs.writeFileSync(binary, 'preserve the newer executable'); - writeLauncherStamp('0.0.1', env); - let probes = 0; - const notices = []; - const result = await ensureProvisioned({ - env, - fetchImpl: async () => { throw new Error('must not download a downgrade'); }, - spawnImpl: () => { - probes += 1; - return { status: 0, stdout: 'kin 99.0.0 (installed separately)\n' }; - }, - log: (line) => notices.push(line), +for (const cleanupOutcome of ['refusal', 'throw']) { + test(`native Windows cleanup ${cleanupOutcome} never replaces the worker exit result`, t => { + const { home, env, cliPath } = managedFixture(t, '99.0.0', 'win32'); + const copied = copiedWindowsWorker(home); + const logs = []; + assert.throws(() => updateManagedInstall({ + env, platform: 'win32', log: line => logs.push(line), + spawnImpl: (binary, args) => { + if (args.includes('--prepare-windows-worker')) return { status: 0, stdout: JSON.stringify(copied.prepared) }; + if (args.includes('--windows-worker')) { + recordMockWorkerConsumption(copied); + return { status: 17, pid: 4312, stdout: '', stderr: 'original worker failure' }; + } + assert.equal(binary, cliPath); + assert.deepEqual(args, ['update', '--cleanup-windows-worker', copied.record]); + if (cleanupOutcome === 'throw') throw new Error('native cleanup invocation failed'); + return { status: 19, stdout: '', stderr: 'native recovery fence retained scratch' }; + }, + }), /transactional update failed \(17\)/); + assert.ok(logs.some(line => line.includes('cleanup deferred'))); + assert.ok(fs.existsSync(copied.worker)); + assert.ok(fs.existsSync(copied.record)); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); }); - assert.equal(result, binary); - assert.equal(probes, 1); - assert.equal(fs.readFileSync(binary, 'utf8'), 'preserve the newer executable'); - assert.ok(notices.some((line) => line.includes('running managed kin 99.0.0')), notices.join('\n')); - assert.equal(readLauncherStamp(env), '99.0.0'); +} + +test('native cleanup refusal does not turn a successful worker into a failed update', t => { + const { home, env, cliPath, report } = managedFixture(t, '99.0.0', 'win32'); + const copied = copiedWindowsWorker(home); + assert.equal(updateManagedInstall({ + env, platform: 'win32', log: () => {}, + spawnImpl: (binary, args) => { + if (args.includes('--prepare-windows-worker')) return { status: 0, stdout: JSON.stringify(copied.prepared) }; + if (args.includes('--windows-worker')) { + recordMockWorkerConsumption(copied); + return { status: 0, pid: 4312, stdout: '' }; + } + if (args.includes('--cleanup-windows-worker')) return { status: 1, stderr: 'scratch preserved', stdout: '' }; + return { status: 0, stdout: JSON.stringify(report) }; + }, + }), cliPath); + assert.ok(fs.existsSync(copied.worker)); }); -test('a receipt write failure repeats the newer-install notice rather than failing the run', async (t) => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-newer-receipt-')); - t.after(() => fs.rmSync(home, { recursive: true, force: true })); - const env = { KIN_HOME: home }; - const binary = path.join(home, 'bin', binaryName('kin', process.platform)); - // A directory at the receipt path refuses writes even for privileged test users. - fs.mkdirSync(path.join(home, 'bin', '.kinlab-kin-version'), { recursive: true }); - fs.writeFileSync(binary, 'preserve the newer executable'); - const notices = []; - const result = await ensureProvisioned({ - env, - fetchImpl: async () => { throw new Error('must not download a downgrade'); }, - spawnImpl: () => ({ status: 0, stdout: 'kin 99.0.0\n' }), - log: (line) => notices.push(line), +for (const consumption of ['unclaimed', 'empty', 'wrong-schema', 'wrong-pid', 'wrong-handoff-hash', 'linked-marker']) { + test(`Windows wrapper refuses native cleanup dispatch for ${consumption} consumption proof`, { + skip: consumption === 'linked-marker' && process.platform === 'win32', + }, t => { + const { home, env, cliPath, report } = managedFixture(t, '99.0.0', 'win32'); + const copied = copiedWindowsWorker(home); + let cleanupCalls = 0; + assert.equal(updateManagedInstall({ + env, platform: 'win32', log: () => {}, + spawnImpl: (binary, args) => { + if (args.includes('--prepare-windows-worker')) return { status: 0, stdout: JSON.stringify(copied.prepared) }; + if (args.includes('--windows-worker')) { + const marker = path.join(copied.directory, 'claimed'); + if (consumption === 'empty') fs.writeFileSync(marker, ''); + else if (consumption !== 'unclaimed') { + const overrides = consumption === 'wrong-schema' ? { schema: 'unknown' } + : consumption === 'wrong-pid' ? { worker_process: { pid: 9999 } } + : consumption === 'wrong-handoff-hash' ? { handoff_sha256: '0'.repeat(64) } : {}; + recordMockWorkerConsumption(copied, overrides); + if (consumption === 'linked-marker') { + const retained = path.join(home, 'retained-claim'); + fs.renameSync(marker, retained); + fs.symlinkSync(retained, marker); + } + } + return { status: 0, pid: 4312, stdout: '' }; + } + if (args.includes('--cleanup-windows-worker')) cleanupCalls++; + return { status: 0, stdout: JSON.stringify(report) }; + }, + }), cliPath); + assert.equal(cleanupCalls, 0); + assert.ok(fs.existsSync(copied.worker)); + assert.ok(fs.existsSync(copied.record)); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); }); - assert.equal(result, binary); - assert.ok(notices.some((line) => line.includes('running managed kin 99.0.0'))); - assert.ok(notices.some((line) => line.includes('could not record the launcher receipt'))); -}); +} -// The whole bin, end to end: the launcher probes the real file it would run and -// forwards the command to it. On a newer install this used to exit 1 before the -// command ran at all. -const posixLauncherTest = process.platform === 'win32' ? test.skip : test; -posixLauncherTest('the kin bin forwards a command to a newer managed install and mirrors its exit', (t) => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-launcher-forward-')); - t.after(() => fs.rmSync(home, { recursive: true, force: true })); - const binary = path.join(home, 'bin', 'kin'); - fs.mkdirSync(path.dirname(binary), { recursive: true }); - fs.writeFileSync( - binary, - '#!/bin/sh\n' + - 'if [ "$1" = "--version" ]; then echo "kin 99.0.0"; exit 0; fi\n' + - 'echo "forwarded: $*"\n' + - 'exit 3\n', - ); - fs.chmodSync(binary, 0o755); - const launcher = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'kin.mjs'); - const env = { ...process.env, KIN_HOME: home }; - delete env.KIN_MANAGED_BIN; - delete env.KIN_NO_PROVISION; - delete env.KIN_LAUNCHER_ADOPT; - const first = spawnSync(process.execPath, [launcher, 'status', '--json'], { env, encoding: 'utf8' }); - assert.equal(first.stdout, 'forwarded: status --json\n', `stderr: ${first.stderr}`); - assert.equal(first.status, 3, 'the managed binary\'s own exit is mirrored'); - assert.match(first.stderr, /running managed kin 99\.0\.0/); - const second = spawnSync(process.execPath, [launcher, 'status'], { env, encoding: 'utf8' }); - assert.equal(second.stdout, 'forwarded: status\n'); - assert.equal(second.stderr, '', 'the notice is not repeated once the version is recorded'); +test('Windows prepare cannot direct wrapper cleanup outside the exact managed worker directory', t => { + const { home, env, cliPath } = managedFixture(t, '99.0.0', 'win32'); + const otherHome = path.join(home, 'unrelated-data'); + fs.mkdirSync(otherHome); + const copied = copiedWindowsWorker(otherHome); + let calls = 0; + assert.throws(() => updateManagedInstall({ + env, platform: 'win32', + spawnImpl: () => { calls++; return { status: 0, stdout: JSON.stringify(copied.prepared) }; }, + }), /scratch is not an unclaimed owned directory/); + assert.equal(calls, 1, 'worker outside managed direct child was never launched'); + assert.ok(fs.existsSync(copied.worker)); + assert.ok(fs.existsSync(copied.record)); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); }); -test('a stale matching stamp cannot hide an older installed binary', async (t) => { - const { work, fetchImpl, platform, arch } = makeHostProvisionFixture(); - t.after(() => fs.rmSync(work, { recursive: true, force: true })); - const env = { KIN_HOME: path.join(work, 'kin-home') }; - const binary = path.join(env.KIN_HOME, 'bin', binaryName('kin', platform)); - fs.mkdirSync(path.dirname(binary), { recursive: true }); - fs.writeFileSync(binary, 'old executable'); - const { targetKinVersion } = await import('../lib/resolve.mjs'); - writeLauncherStamp(targetKinVersion(), env); - const result = await ensureProvisioned({ - env, platform, arch, fetchImpl, - spawnImpl: () => ({ status: 0, stdout: 'kin 0.0.1 (restored backup)\n' }), - log: () => {}, - }); - assert.equal(result, binary); - assert.match(fs.readFileSync(binary, 'utf8'), /9\.9\.9/); +test('a preclaimed Windows handoff remains intact and is never replayed by the wrapper', t => { + const { home, env } = managedFixture(t, '99.0.0', 'win32'); + const copied = copiedWindowsWorker(home); + fs.writeFileSync(path.join(copied.directory, 'claimed'), ''); + let calls = 0; + assert.throws(() => updateManagedInstall({ + env, platform: 'win32', + spawnImpl: () => { calls++; return { status: 0, stdout: JSON.stringify(copied.prepared) }; }, + }), /scratch is not an unclaimed owned directory/); + assert.equal(calls, 1); + assert.ok(fs.existsSync(copied.worker)); + assert.ok(fs.existsSync(copied.record)); }); -test('a stale stamp is not version evidence when the installed binary cannot be probed', async (t) => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-stale-unknown-')); - t.after(() => fs.rmSync(home, { recursive: true, force: true })); - const env = { KIN_HOME: home }; - const binary = path.join(home, 'bin', binaryName('kin', process.platform)); - fs.mkdirSync(path.dirname(binary), { recursive: true }); - fs.writeFileSync(binary, 'preserve unknown executable'); - writeLauncherStamp('0.0.1', env); - const notices = []; - const result = await ensureProvisioned({ - env, - fetchImpl: async () => { throw new Error('cannot establish upgrade direction'); }, - spawnImpl: () => ({ status: 1, stdout: '' }), - log: (line) => notices.push(line), +for (const uncertain of ['no-exit-status', 'no-child-pid', 'wait-threw']) { + test(`Windows worker scratch is retained when synchronous completion is uncertain: ${uncertain}`, t => { + const { home, env, cliPath } = managedFixture(t, '99.0.0', 'win32'); + const copied = copiedWindowsWorker(home); + let cleanupCalls = 0; + assert.throws(() => updateManagedInstall({ + env, platform: 'win32', log: () => {}, + spawnImpl: (binary, args) => { + if (args.includes('--prepare-windows-worker')) return { status: 0, stdout: JSON.stringify(copied.prepared) }; + if (args.includes('--cleanup-windows-worker')) cleanupCalls++; + assert.equal(binary, copied.worker); + recordMockWorkerConsumption(copied); + if (uncertain === 'wait-threw') throw new Error('synchronous worker wait failed'); + if (uncertain === 'no-child-pid') return { status: 1, stdout: '' }; + return { status: null, error: new Error('no confirmed worker exit'), stdout: '' }; + }, + }), /worker wait failed|no confirmed worker exit|transactional update failed \(1\)/); + assert.equal(cleanupCalls, 0); + assert.ok(fs.existsSync(copied.worker)); + assert.ok(fs.existsSync(copied.record)); + assert.ok(fs.existsSync(path.join(copied.directory, 'claimed'))); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); }); - assert.equal(result, binary); - assert.ok(notices.some((line) => line.includes('version unknown'))); - assert.equal(readLauncherStamp(env), '0.0.1'); - assert.equal(fs.readFileSync(binary, 'utf8'), 'preserve unknown executable'); -}); +} -test('receipt write failure does not block a verified matching installed binary', async (t) => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-receipt-write-')); - t.after(() => fs.rmSync(home, { recursive: true, force: true })); - const env = { KIN_HOME: home }; - const binary = path.join(home, 'bin', binaryName('kin', process.platform)); - // A directory at the receipt path refuses writes even for privileged test users. - fs.mkdirSync(path.join(home, 'bin', '.kinlab-kin-version'), { recursive: true }); - fs.writeFileSync(binary, 'preserve the verified executable'); - const { targetKinVersion } = await import('../lib/resolve.mjs'); - const notices = []; - const result = await ensureProvisioned({ - env, - fetchImpl: async () => { throw new Error('matching installed binary must not be replaced'); }, - spawnImpl: () => ({ status: 0, stdout: `kin ${targetKinVersion()}\n` }), - log: (line) => notices.push(line), - }); - assert.equal(result, binary); - assert.ok(notices.some((line) => line.includes('could not refresh the launcher receipt'))); - assert.equal(fs.readFileSync(binary, 'utf8'), 'preserve the verified executable'); +test('a changed Windows worker is refused before launch or installed-file mutation', t => { + const { env, home, cliPath } = managedFixture(t, '99.0.0', 'win32'); + const worker = path.join(home, 'worker.exe'); + const handoff = path.join(home, 'handoff.json'); + fs.writeFileSync(worker, 'changed bytes'); + fs.writeFileSync(handoff, 'handoff fixture'); + let calls = 0; + assert.throws(() => updateManagedInstall({ + env, platform: 'win32', + spawnImpl: () => { + calls += 1; + return { status: 0, stdout: JSON.stringify({ + schema: 'kin.windows-update-worker.v1', worker_executable: worker, handoff_record: handoff, + source_commit_sha: 'a'.repeat(40), version: '99.0.0', worker_sha256: '0'.repeat(64), + }) }; + }, + }), /worker identity changed/); + assert.equal(calls, 1); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); }); -test('KIN_LAUNCHER_ADOPT=1 forces re-provisioning even when the stamped version already matches', async () => { - const { work, fetchImpl, platform, arch } = makeHostProvisionFixture(); - const home = path.join(work, 'kin-home'); - const env = { KIN_HOME: home, KIN_LAUNCHER_ADOPT: '1' }; - const binDir = path.join(home, 'bin'); - const kinName = binaryName('kin', platform); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(path.join(binDir, kinName), '#!/bin/sh\n'); - const { targetKinVersion } = await import('../lib/resolve.mjs'); - writeLauncherStamp(targetKinVersion(), env); - const result = await ensureProvisioned({ - env, - platform, - arch, - fetchImpl, - spawnImpl: () => { - throw new Error('ADOPT must skip the probe and provision unconditionally'); +test('development launch strips an inherited normal root and uses the admitted isolated home', t => { + const { env, cliPath, report, home } = managedFixture(t); + report.mode = 'development'; + report.kin_home = path.join(home, 'isolated-home'); + const inherited = { ...env, KIN_DIR: env.KIN_HOME }; + const launched = developmentLaunchEnvironment(cliPath, { + env: inherited, + spawnImpl: (binary, args, options) => { + assert.equal(options.env.KIN_RUNTIME_MODE, 'development'); + assert.equal(options.env.KIN_HOME, undefined); + assert.equal(options.env.KIN_DIR, undefined); + return { status: 0, stdout: JSON.stringify(report) }; }, - log: () => {}, }); - assert.equal(result, path.join(binDir, kinName)); - // Reprovisioned from the fixture archive, not the pre-existing stub. - assert.match(fs.readFileSync(result, 'utf8'), /9\.9\.9/); - fs.rmSync(work, { recursive: true, force: true }); + assert.equal(launched.KIN_HOME, report.kin_home); + assert.equal(launched.KIN_DIR, report.kin_home); + assert.equal(inherited.KIN_HOME, env.KIN_HOME, 'the caller environment was not mutated'); }); -test('KIN_LAUNCHER_ADOPT=1 forces the downgrade the launcher otherwise never makes', async () => { - const { work, fetchImpl, platform, arch } = makeHostProvisionFixture(); - const home = path.join(work, 'kin-home'); - const env = { KIN_HOME: home, KIN_LAUNCHER_ADOPT: '1' }; - const binDir = path.join(home, 'bin'); - const kinName = binaryName('kin', platform); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(path.join(binDir, kinName), '#!/bin/sh\n'); +test('old status API cannot be certified by stamp when official migration metadata is unavailable', async t => { + const { env, cliPath } = managedFixture(t); writeLauncherStamp('99.0.0', env); - const result = await ensureProvisioned({ - env, - platform, - arch, - fetchImpl, - spawnImpl: () => { - throw new Error('ADOPT must skip the probe and provision unconditionally'); - }, - log: () => {}, - }); - assert.equal(result, path.join(binDir, kinName)); - assert.match(fs.readFileSync(result, 'utf8'), /9\.9\.9/); - fs.rmSync(work, { recursive: true, force: true }); + for (const version of ['kin 99.0.0\n', 'unknown binary']) { + await assert.rejects(ensureProvisioned({ + env, spawnImpl: (binary, args) => ({ status: 0, stdout: args[0] === '--version' ? version : 'old CLI' }), + fetchImpl: async () => { throw new Error('official release unavailable'); }, + }), /official release unavailable/); + } + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); +}); + +test('an explicit development selection cannot use direct managed provisioning', async t => { + const { env, cliPath } = managedFixture(t); + await assert.rejects(provision('99.0.0', { + env: { ...env, KIN_MANAGED_BIN: cliPath }, + fetchImpl: async () => { throw new Error('no download permitted'); }, + spawnImpl: () => { throw new Error('no managed repair permitted'); }, + }), /development selections cannot run managed provisioning/); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); +}); + +test('independent adoption cannot overwrite an existing managed pair', async t => { + const { env, cliPath, daemonPath } = managedFixture(t); + await assert.rejects(ensureProvisioned({ + env: { ...env, KIN_LAUNCHER_ADOPT: '1' }, + spawnImpl: () => { throw new Error('must not launch'); }, + fetchImpl: async () => { throw new Error('must not download'); }, + }), /cannot replace a managed pair independently/); + assert.equal(fs.readFileSync(cliPath, 'utf8'), 'preserve-cli'); + assert.equal(fs.readFileSync(daemonPath, 'utf8'), 'preserve-daemon'); }); // FIR-2628. `npm install -g` dies with EACCES on a root-owned global prefix, diff --git a/packages/kin/test/resolve.test.mjs b/packages/kin/test/resolve.test.mjs index c21642ec6..ad9f2c5c4 100644 --- a/packages/kin/test/resolve.test.mjs +++ b/packages/kin/test/resolve.test.mjs @@ -85,9 +85,10 @@ test('binaryName adds .exe only on Windows', () => { }); test('kinHome honors $KIN_HOME then falls back to ~/.kin', () => { - assert.equal(kinHome({ KIN_HOME: '/custom/kin' }, '/home/u'), '/custom/kin'); + assert.equal(kinHome({ KIN_HOME: '/custom/kin' }, '/home/u'), path.resolve('/custom/kin')); assert.equal(kinHome({}, '/home/u'), path.join('/home/u', '.kin')); - assert.equal(kinHome({ KIN_HOME: ' ' }, '/home/u'), path.join('/home/u', '.kin')); + assert.equal(kinHome({ KIN_HOME: '' }, '/home/u'), path.join('/home/u', '.kin')); + assert.equal(kinHome({ KIN_HOME: ' ' }, '/home/u'), path.resolve(' '), 'nonempty path spelling matches the shell and native resolver'); }); test('managedBinaryPath defaults under kinHome and is overridable', () => { @@ -123,9 +124,55 @@ test('launcher stamp round-trips under kinHome and reads null when absent', () = fs.rmSync(home, { recursive: true, force: true }); }); +test('launcher stamp is replaced by rename and leaves no temporary file behind', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-stamp-atomic-')); + try { + const env = { KIN_HOME: home }; + const bin = path.join(home, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(launcherStampPath(env), '0.6.6\n'); + const before = fs.statSync(launcherStampPath(env)); + + writeLauncherStamp('0.8.7', env); + + // An in-place write keeps the inode; a rename installs a new one, which is + // what lets a concurrent reader see only the whole old or the whole new + // stamp. + const after = fs.statSync(launcherStampPath(env)); + assert.notEqual(after.ino, before.ino); + assert.equal(fs.readFileSync(launcherStampPath(env), 'utf8'), '0.8.7\n'); + assert.deepEqual(fs.readdirSync(bin), ['.kinlab-kin-version']); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test('a launcher stamp write that cannot rename removes its temporary file', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-stamp-refused-')); + try { + const env = { KIN_HOME: home }; + const bin = path.join(home, 'bin'); + // A directory where the stamp belongs refuses the rename. + fs.mkdirSync(path.join(launcherStampPath(env), 'occupied'), { recursive: true }); + + assert.throws(() => writeLauncherStamp('0.8.7', env)); + + assert.deepEqual(fs.readdirSync(bin), ['.kinlab-kin-version']); + assert.ok(fs.statSync(launcherStampPath(env)).isDirectory()); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + test('notProvisionedMessage names the expected path and stays honest', () => { const msg = notProvisionedMessage('kin', { KIN_MANAGED_BIN: '/opt/kin/bin/kin' }, 'linux'); assert.match(msg, /managed "kin" binary not found/); assert.match(msg, /\/opt\/kin\/bin\/kin/); assert.match(msg, /provisioning failed or was disabled/); }); + +test('KIN_DIR is the install-root alias and KIN_HOME takes precedence', () => { + assert.equal(kinHome({ KIN_DIR: '/legacy-root' }, '/home/fixture'), path.resolve('/legacy-root')); + assert.equal(kinHome({ KIN_HOME: '/preferred', KIN_DIR: '/legacy-root' }, '/home/fixture'), path.resolve('/preferred')); + assert.equal(kinHome({ KIN_HOME: '', KIN_DIR: '/legacy-root' }, '/home/fixture'), path.resolve('/legacy-root')); +}); diff --git a/packages/kin/test/runtime-routing.test.mjs b/packages/kin/test/runtime-routing.test.mjs new file mode 100644 index 000000000..051fe541f --- /dev/null +++ b/packages/kin/test/runtime-routing.test.mjs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { cachedKinBinary } from '../../kin-mcp/src/index.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const canonicalLauncher = path.resolve(here, '../bin/kin.mjs'); +const compatibilityLauncher = path.resolve(here, '../../kin-mcp/bin/kin-mcp.js'); +const nativeFixture = `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const cli = process.argv[1]; +const daemon = path.join(path.dirname(cli), 'kin-daemon'); +const identity = JSON.parse(fs.readFileSync(path.join(path.dirname(cli), 'fixture-identity.json'))); +if (process.argv[2] === '--version') { console.log('kin 99.0.0'); process.exit(0); } +if (process.argv[2] === 'runtime') { + const component = (file, commit) => ({ path: file, version: '99.0.0', commit_sha: commit, + source_dirty: false, target: 'fixture', build_profile: 'release', + sha256: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') }); + const ready = identity.cli === identity.daemon; + const development = process.env.KIN_RUNTIME_MODE === 'development'; + console.log(JSON.stringify({ schema: 'kin.runtime-status.v1', ready, + mode: development ? 'development' : 'managed', kin_home: development ? path.join(process.env.HOME, '.kin-development-fixture') : path.dirname(path.dirname(cli)), + cli: component(cli, identity.cli), daemon: component(daemon, identity.daemon), + pair_matches: ready, channel: development ? 'development' : 'stable', + release_kind: development ? 'development' : 'published', restart_needed: false, + reason: ready ? null : 'pair_source_mismatch', detail: null })); + process.exit(ready ? 0 : 1); +} +console.log(JSON.stringify({ selected: cli, mode: process.env.KIN_RUNTIME_MODE || 'managed', argv: process.argv.slice(2) })); +`; + +function fixture(t) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-routing-boundary-')); + t.after(() => fs.rmSync(home, { recursive: true, force: true })); + const root = path.join(home, 'managed root'); + fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); + const cli = path.join(root, 'bin', 'kin'); + fs.writeFileSync(cli, nativeFixture, { mode: 0o755 }); + fs.writeFileSync(path.join(root, 'bin', 'kin-daemon'), 'fixture daemon', { mode: 0o755 }); + const identityPath = path.join(root, 'bin', 'fixture-identity.json'); + fs.writeFileSync(identityPath, JSON.stringify({ cli: 'a'.repeat(40), daemon: 'a'.repeat(40) })); + const env = { ...process.env, HOME: home, USERPROFILE: home, KIN_HOME: root }; + for (const key of ['KIN_MANAGED_BIN', 'KIN_MCP_KIN_BINARY', 'KIN_BINARY_PATH', 'KIN_MCP_CACHE_DIR', 'KIN_RUNTIME_MODE', 'KIN_LAUNCHER_ADOPT', 'KIN_NO_PROVISION']) delete env[key]; + return { home, root, cli, identityPath, env }; +} + +test('ordinary canonical and legacy launchers select the same managed runtime', { skip: process.platform === 'win32' }, t => { + const { env, cli } = fixture(t); + const ordinary = spawnSync(process.execPath, [canonicalLauncher, 'status'], { env, encoding: 'utf8' }); + assert.equal(ordinary.status, 0, ordinary.stderr); + assert.equal(JSON.parse(ordinary.stdout).selected, cli); + const legacy = spawnSync(process.execPath, [compatibilityLauncher, '--print-bin'], { env, encoding: 'utf8' }); + assert.equal(legacy.status, 0, legacy.stderr); + assert.equal(legacy.stdout.trim(), cli); +}); + +test('KIN_DIR alone selects that same pair in both launchers', { skip: process.platform === 'win32' }, t => { + const { env, root, cli } = fixture(t); + delete env.KIN_HOME; + env.KIN_DIR = root; + for (const [launcher, args] of [[canonicalLauncher, ['status']], [compatibilityLauncher, ['--print-bin']]]) { + const result = spawnSync(process.execPath, [launcher, ...args], { env, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(launcher === canonicalLauncher ? JSON.parse(result.stdout).selected : result.stdout.trim(), cli); + } +}); + +test('same-version differing source identities refuse both normal entry points', { skip: process.platform === 'win32' }, t => { + const { env, identityPath } = fixture(t); + fs.writeFileSync(identityPath, JSON.stringify({ cli: 'a'.repeat(40), daemon: 'b'.repeat(40) })); + for (const [launcher, args] of [[canonicalLauncher, ['status']], [compatibilityLauncher, ['--print-bin']]]) { + const result = spawnSync(process.execPath, [launcher, ...args], { env, encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not certified/); + assert.equal(result.stdout, '', 'an unaccepted runtime cannot execute the requested command'); + } +}); + +test('ordinary legacy lookup ignores retained versioned caches', async t => { + const { env, home } = fixture(t); + const cache = path.join(home, 'Library', 'Caches', 'kin-mcp', 'v99.0.0', 'kin-macos-aarch64'); + fs.mkdirSync(cache, { recursive: true }); + for (const name of ['kin', 'kin-daemon']) fs.writeFileSync(path.join(cache, name), 'retained legacy bytes', { mode: 0o755 }); + fs.rmSync(path.join(env.KIN_HOME, 'bin'), { recursive: true }); + assert.equal(await cachedKinBinary({ env, homeDir: home }), null); + assert.equal(fs.readFileSync(path.join(cache, 'kin'), 'utf8'), 'retained legacy bytes'); +}); + +test('an explicit development launcher enters the isolated lane without updating managed files', { skip: process.platform === 'win32' }, t => { + const { env, cli } = fixture(t); + env.KIN_MANAGED_BIN = cli; + const result = spawnSync(process.execPath, [canonicalLauncher, 'status'], { env, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).mode, 'development'); + assert.equal(fs.readFileSync(cli, 'utf8'), nativeFixture); +}); + +test('the Unix installer cannot replace an existing pair outside its native transaction', { skip: process.platform === 'win32' }, () => { + const script = path.resolve(here, '../../../scripts/test-managed-installer-routing.py'); + const result = spawnSync('python3', [script], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/scripts/acceptance/agent_write_publish_repro.py b/scripts/acceptance/agent_write_publish_repro.py index dc5d10c73..e18db41bb 100755 --- a/scripts/acceptance/agent_write_publish_repro.py +++ b/scripts/acceptance/agent_write_publish_repro.py @@ -85,6 +85,7 @@ import threading import time import uuid +from runtime_fixture import prepare_runtime_fixture # An agent profile serves each answer in plain words and leaves out the verdict # codes and evidence blocks this suite reads. Ask every kin it starts for the @@ -756,6 +757,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self.last_result = None self.last_requests = None self.last_tool_error = None + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def git(self, cwd, args): # The caller's global and system git config stay out of the fixture: a hooks diff --git a/scripts/acceptance/blame_attribution_repro.py b/scripts/acceptance/blame_attribution_repro.py index c0ccb2e0c..0abc7d903 100755 --- a/scripts/acceptance/blame_attribution_repro.py +++ b/scripts/acceptance/blame_attribution_repro.py @@ -32,6 +32,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS, FAIL, UNREADABLE = "PASS", "FAIL", "UNREADABLE" TICKET = "FIR-2961" @@ -247,6 +248,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): if daemon: self.env["KIN_DAEMON_BIN"] = daemon self._repo = None + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, os.path.join(workdir, "kin-home")) def kin_run(self, args, timeout=600): rc, out, err = run([self.kin] + args, cwd=self.repo(), env=self.env, timeout=timeout) diff --git a/scripts/acceptance/bridge_reach_repro.py b/scripts/acceptance/bridge_reach_repro.py index 5b741a26c..41b0ca1c7 100755 --- a/scripts/acceptance/bridge_reach_repro.py +++ b/scripts/acceptance/bridge_reach_repro.py @@ -85,6 +85,7 @@ import sys import tempfile import threading +from runtime_fixture import prepare_runtime_fixture try: from http.server import BaseHTTPRequestHandler, HTTPServer @@ -458,6 +459,8 @@ def __init__(self, kin, workdir, verbose=False): self.env.pop("KIN_DAEMON_URL", None) self.env.pop("KIN_NO_DAEMON", None) self.repos = {} + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, None, self.env, self.env["KIN_HOME"]) def git(self, args, cwd): proc = subprocess.run( diff --git a/scripts/acceptance/brownfield_repro.py b/scripts/acceptance/brownfield_repro.py index c726a6d73..c609e72b1 100755 --- a/scripts/acceptance/brownfield_repro.py +++ b/scripts/acceptance/brownfield_repro.py @@ -130,6 +130,7 @@ import sys import tempfile import time +from runtime_fixture import prepare_runtime_fixture # An agent profile serves each answer in plain words and leaves out the verdict # codes and evidence blocks this suite reads. Ask every kin it starts for the @@ -860,6 +861,8 @@ def __init__(self, kin, workdir, corpus_cache, daemon=None, offline=False, self.env.pop("KIN_DIR", None) if daemon: self.env["KIN_DAEMON_BIN"] = daemon + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) # ---------------------------------------------------------------- plumbing diff --git a/scripts/acceptance/coverage_read_open.py b/scripts/acceptance/coverage_read_open.py index bb7a88d38..420e77d8a 100755 --- a/scripts/acceptance/coverage_read_open.py +++ b/scripts/acceptance/coverage_read_open.py @@ -63,6 +63,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" @@ -394,6 +395,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): os.makedirs(self.env["KIN_HOME"], exist_ok=True) self.repo = None self.owned_repos = set() + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def shutdown(self): """Stop only this run's repositories, including a failed initialization.""" @@ -613,27 +616,33 @@ def check_diverged(suite): # The control. Nothing can bring a daemon up, so nothing can admit, and the # read must say so rather than repeat the verdict above. # - # A real executable that REFUSES, never a missing path: an absent binary - # tests "the binary is absent" and this arm is about "no daemon can run". - # And stopping the daemon is not enough, because `kin admit` starts one. - refusing = os.path.join(suite.workdir, "kin-daemon-that-refuses") - with open(refusing, "w") as handle: - handle.write("#!/bin/sh\necho 'stub daemon refuses' >&2\nexit 1\n") - os.chmod(refusing, 0o755) - if not os.access(refusing, os.X_OK): - result.unknown("the refusing-daemon stub is not executable, so the " - "control would test an absent binary instead") + # Keep the verified selected pair. Managed routing cannot adopt a shell + # override, so that would test selection refusal instead of admission. + # The real daemon refuses an unauthenticated non-loopback bind before it + # creates a socket. Explicitly disable default loopback-token enforcement, + # or its auto-provisioned token would make this a healthy authenticated bind. + rc, out = suite.kin_run(["daemon", "stop", "--json"]) + try: + start = out.find("{") + end = out.rfind("}") + stopped = json.loads(out[start : end + 1]) if start != -1 and end != -1 else {} + except (TypeError, ValueError): + stopped = {} + if (rc != 0 or stopped.get("schema") != "kin.daemon-stop.v1" + or stopped.get("scope") != "current-repo" or stopped.get("all_stopped") is not True): + result.unknown("the control could not establish absence of its existing daemon: %s" + % tail(out, 300)) return result - suite.kin_run(["daemon", "stop"]) - restore = suite.env.get("KIN_DAEMON_BIN") - suite.env["KIN_DAEMON_BIN"] = refusing + restore = suite.env + suite.env = dict(restore) + suite.env["KIN_DAEMON_BIND_HOST"] = "0.0.0.0" + suite.env.pop("KIN_DAEMON_AUTH_TOKEN", None) + suite.env["KIN_DAEMON_REQUIRE_TOKEN"] = "0" + suite.env.pop("KIN_DAEMON_URL", None) try: blind = suite.status_text() finally: - if restore is None: - suite.env.pop("KIN_DAEMON_BIN", None) - else: - suite.env["KIN_DAEMON_BIN"] = restore + suite.env = restore if ADMITTED_BASIS.search(blind): result.bad( "with no daemon able to run, the read still claimed a fresh " diff --git a/scripts/acceptance/detached_head_commit_repro.py b/scripts/acceptance/detached_head_commit_repro.py index 750c302b9..a8d8ba366 100755 --- a/scripts/acceptance/detached_head_commit_repro.py +++ b/scripts/acceptance/detached_head_commit_repro.py @@ -57,6 +57,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -356,6 +357,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): if daemon: self.env["KIN_DAEMON_BIN"] = daemon self._repos = {} + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, os.path.join(workdir, "kin-home")) def kin_run(self, repo, args, timeout=900): rc, out, err = run([self.kin] + args, cwd=repo, env=self.env, timeout=timeout) diff --git a/scripts/acceptance/diff_content_repro.py b/scripts/acceptance/diff_content_repro.py index 3f436d663..bc6ab8a0a 100755 --- a/scripts/acceptance/diff_content_repro.py +++ b/scripts/acceptance/diff_content_repro.py @@ -31,6 +31,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS, FAIL, UNREADABLE = "PASS", "FAIL", "UNREADABLE" TICKET = "FIR-2961" @@ -243,6 +244,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self.env["KIN_DAEMON_BIN"] = daemon self._repo = None self.base_change = None + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, os.path.join(workdir, "kin-home")) def kin_run(self, args, timeout=600): rc, out, err = run([self.kin] + args, cwd=self.repo(), env=self.env, timeout=timeout) diff --git a/scripts/acceptance/eject_journal_repro.py b/scripts/acceptance/eject_journal_repro.py index 56a816fdd..435c564df 100755 --- a/scripts/acceptance/eject_journal_repro.py +++ b/scripts/acceptance/eject_journal_repro.py @@ -73,6 +73,7 @@ import sys import tempfile import uuid +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -458,6 +459,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self.env["KIN_DAEMON_BIN"] = daemon self.repos = {} self.commits = 0 + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def log(self, line): if self.verbose: diff --git a/scripts/acceptance/first_contact_honesty.py b/scripts/acceptance/first_contact_honesty.py index 68bd23830..760454e87 100755 --- a/scripts/acceptance/first_contact_honesty.py +++ b/scripts/acceptance/first_contact_honesty.py @@ -83,6 +83,8 @@ from http.server import BaseHTTPRequestHandler, HTTPServer import tempfile +from runtime_fixture import prepare_runtime_fixture + PASS = "PASS" FAIL = "FAIL" UNREADABLE = "UNREADABLE" @@ -662,6 +664,7 @@ def check_1(suite): # one its own commands put there. Without this the probe resolves the # operator's installed kin and passes a block that installs nothing. env["PATH"] = suite.sanitized_path(work) + suite.prepare_runtime(env) rc, out, err = run(["bash", "-euo", "pipefail", "-c", script], cwd=work, env=env, timeout=900) said = flatten(out + " " + err) @@ -719,6 +722,12 @@ def check_2(suite): env["HOME"] = home env["PATH"] = binpath env["KIN_HOME"] = os.path.join(home, ".kin") + # The built PATH is the only place a server may be found. Kin otherwise + # also searches the usual install places, and a runner image that ships + # the servers in /usr/local/bin or a rustup proxy under CARGO_HOME then + # reads every recipe as installed, so nothing is attempted and this check + # grades silence. + env["KIN_LANGUAGE_TOOL_SEARCH"] = "0" # A real npm against a port nothing listens on: the error text is npm's own, # not this script's. A stubbed npm printing the words the classifier looks # for would be a check that cannot fail. @@ -742,13 +751,14 @@ def check_2(suite): # terminal otherwise prints only the short summary. command = [suite.kin, "setup", "--no-interactive", "--verbose", "--skip-mcp-check", "--install-language-servers"] + command[0] = suite.prepare_runtime(env) rc, out, err = run(command, cwd=work, env=env, timeout=1800) # The installer's full account, beside the files it touched: the verdict # below quotes 220 characters of it. keep_probe_output( work, "install-language-servers", command, rc, out, err, {name: env.get(name) for name in ( - "HOME", "KIN_HOME", "PATH", "npm_config_registry", + "HOME", "KIN_HOME", "PATH", "KIN_LANGUAGE_TOOL_SEARCH", "npm_config_registry", "KIN_LANGUAGE_SERVER_ASSET_BASE", "npm_config_prefix", "npm_config_fetch_retries")}) ok, detail = grade_network_diagnosis(out + "\n" + err) @@ -780,7 +790,8 @@ def doctor_report(suite, extra_env=None): work = os.path.join(suite.workdir, "no-repository") if not os.path.isdir(work): os.makedirs(work) - rc, out, err = run([suite.kin, "doctor", "--json"], cwd=work, env=env, timeout=600) + kin = suite.prepare_runtime(env) + rc, out, err = run([kin, "doctor", "--json"], cwd=work, env=env, timeout=600) if not out.strip(): return None, "`kin doctor --json` exited %d and printed nothing: %s" % ( rc, flatten(failure_excerpt(err))) @@ -1156,9 +1167,11 @@ def check_4(suite): for name in TOOLCHAIN_HOME_VARS: env.pop(name, None) + kin = suite.prepare_runtime(env) + work = suite.scratch("toolchain-free-repo") suite.own(work, env) - rc, out, err = run([suite.kin, "init", "."], cwd=work, env=env, timeout=900) + rc, out, err = run([kin, "init", "."], cwd=work, env=env, timeout=900) if rc != 0: res.unknown("kin init exited %d in the fixture repository: %s" % (rc, flatten(failure_excerpt(err)))) @@ -1173,7 +1186,7 @@ def check_4(suite): with open(os.path.join(work, "src", "lib.rs"), "w") as source: source.write("pub fn answer() -> u32 {\n 42\n}\n") rc, out, err = run( - [suite.kin, "doctor", "--fix", "--install-language-servers"], + [kin, "doctor", "--fix", "--install-language-servers"], cwd=work, env=env, timeout=900) combined = (out or "") + "\n" + (err or "") @@ -1362,6 +1375,430 @@ def grade_unbound_start(verdict, alive, stderr_text, stdout_lines, kin_created): "behind the user, and the notice named the gap and the repair on stderr") +def _setup_expect(res, name, conditions, transcript=""): + """Keep the named Managed controls inside the existing first-contact row.""" + missing = [label for label, holds in conditions if not holds] + if missing: + res.bad("Managed setup %s: %s; %s" % ( + name, "; ".join(missing), flatten(failure_excerpt(transcript)))) + else: + res.ok("Managed setup %s" % name) + + +def _setup_fixture(suite, name, claude=None): + root = suite.scratch("managed-setup-" + name) + home, cwd = os.path.join(root, "home"), os.path.join(root, "outside-repository") + os.makedirs(home, mode=0o755) + os.makedirs(cwd, mode=0o755) + env = suite.base_env() + env.update({"HOME": home, "USERPROFILE": home, + "XDG_CONFIG_HOME": os.path.join(home, ".config"), + "APPDATA": os.path.join(home, "AppData", "Roaming"), + "LOCALAPPDATA": os.path.join(home, "AppData", "Local"), + "KIN_HOME": os.path.join(home, ".kin"), "KIN_NO_DAEMON": "1", + "KIN_REGISTRY_PATH": os.path.join(home, ".kin", "registry.toml")}) + for key in ("KIN_STORE", "KIN_STORE_LAYOUT", "KIN_MCP_SCAN_ROOT", + "KIN_RUNTIME_MODE", "KIN_MCP_KIN_BINARY"): + env.pop(key, None) + if claude is not None: + with open(os.path.join(home, ".claude.json"), "wb") as stream: + stream.write(claude) + kin = suite.prepare_runtime(env) + with open(os.path.join(env["KIN_HOME"], "acceptance-runtime.json")) as stream: + receipt = json.load(stream) + if (receipt.get("schema") != "kin.acceptance-runtime-fixture.v1" + or receipt.get("mode") != "managed" + or env.get("KIN_RUNTIME_MODE") != "managed"): + raise ValueError("setup behavior acceptance needs a genuine admitted Managed release pair") + return {"root": root, "home": home, "cwd": cwd, "env": env, "kin": kin} + + +def _setup_run(fixture, name, args, cwd=None, umask=False, kin=None): + command = [kin or fixture["kin"]] + args + if umask: + command = ["/bin/sh", "-c", 'umask 0002 && exec "$0" "$@"'] + command + rc, out, err = run(command, cwd=cwd or fixture["cwd"], env=fixture["env"], timeout=240) + keep_probe_output(fixture["root"], name, command, rc, out, err, + {key: fixture["env"].get(key) for key in + ("HOME", "KIN_HOME", "KIN_RUNTIME_MODE", "KIN_REGISTRY_PATH", "PATH")}) + return rc, out, err + + +def _setup_read(path, binary=False): + try: + with open(path, "rb" if binary else "r") as stream: + return stream.read() + except OSError: + return None + + +def _setup_client_written(home): + try: + value = json.loads(_setup_read(os.path.join(home, ".claude.json")) or "null") + return isinstance(value.get("mcpServers", {}).get("kin"), dict) + except (ValueError, AttributeError): + return False + + +def _setup_home_snapshot(home): + entries = {} + for directory, directories, files in os.walk(home, followlinks=False): + for name in directories + files: + path = os.path.join(directory, name) + info = os.lstat(path) + relative = os.path.relpath(path, home) + if stat.S_ISLNK(info.st_mode): + entries[relative] = ("symlink", os.readlink(path)) + elif stat.S_ISDIR(info.st_mode): + entries[relative] = ("directory", stat.S_IMODE(info.st_mode)) + elif stat.S_ISREG(info.st_mode): + entries[relative] = ("file", stat.S_IMODE(info.st_mode), + hashlib.sha256(_setup_read(path, True)).hexdigest()) + else: + entries[relative] = ("other", info.st_mode) + return entries + + +def _setup_summary_conditions(rc, out): + return [("setup failed", rc == 0), + ("missing noninteractive disclosure", "This run asks nothing and takes the defaults." in out), + ("missing closing verdict", "Kin is set up on" in out), + ("missing defaults disclosure", "taken without asking; kin setup --verbose lists each one" in out), + ("verbose record leaked into the short summary", + "=== Health checklist ===" not in out and "Hardware check" not in out), + ("summary contains escape codes", "\x1b" not in out), + ("summary exceeds 30 lines", len(out.splitlines()) <= 30)] + + +def _setup_round_trip_line(out): + if "MCP round trip" not in out: + return "" + return next((line.strip() for line in out.split("MCP round trip", 1)[1].splitlines() + if "Claude Code: " in line), "") + + +def _setup_shell_report_conditions(rc, report, refused=False): + report = report if isinstance(report, dict) else {} + conditions = [("wrong exit status", rc == (1 if refused else 0)), + ("missing shell report schema", report.get("schema") == "kin.setup-shell.v1"), + ("wrong shell", report.get("shell") == "zsh"), + ("wrong shell status", report.get("status") == ("not_written" if refused else "installed")), + ("shell-only connected a client", report.get("clients_connected") == [])] + if refused: + conditions += [("missing profile refusal", bool(report.get("reason"))), + ("unwritten hook claimed loadable", report.get("next") is None), + ("missing manual profile additions", bool(report.get("add_by_hand")))] + else: + conditions += [("unexpected refusal", report.get("reason") is None), + ("unexpected manual additions", report.get("add_by_hand") == [])] + return conditions + + +def _setup_authority_file_snapshot(path): + observed = os.lstat(path) + return {"mode": stat.S_IMODE(observed.st_mode), "regular": stat.S_ISREG(observed.st_mode), + "device": observed.st_dev, "inode": observed.st_ino, + "uid": observed.st_uid, "gid": observed.st_gid, + "sha256": hashlib.sha256(_setup_read(path, True)).hexdigest()} + + +def _setup_registry_repair_conditions(rc, out, before, after): + identity = ("regular", "device", "inode", "uid", "gid", "sha256") + return [("doctor --fix failed", rc == 0), + ("registry permission repair was not reported", "repaired registry authority permissions to 0600" in out), + ("unsafe authority control was vacuous", all(row.get("mode") == 0o644 for row in before.values()) + and set(before) == {"registry", "lock"}), + ("registry or lock was not repaired to 0600", set(after) == set(before) + and all(row.get("mode") == 0o600 for row in after.values())), + ("permission-only repair changed content, owner or file identity", + set(after) == set(before) and all( + before[name].get("regular") is True + and all(after[name].get(field) == row.get(field) for field in identity) + for name, row in before.items()))] + + +def managed_setup_controls(suite, res): + """Preserve setup's release behavior while debug tests prove only refusal. + + Every positive uses runtime_fixture's independently probed release pair in + a fresh canonical home. No install ledger or publication provenance is + fabricated to admit it. These are assertions on check 5, not new gate rows. + """ + agent = ["setup", "--no-interactive", "--verbose", "--intent", "agent", + "--skip-mcp-check", "--shell", "zsh"] + invalid = b"this is not JSON\n" + fixture = _setup_fixture(suite, "invalid-client", invalid) + rc, out, err = _setup_run(fixture, "invalid-client", agent) + _setup_expect(res, "invalid-client-preserved-and-diagnosed", [ + ("invalid client config exited successfully", rc != 0), + ("client refusal omitted its cause", "kin setup could not configure Claude Code" in err + and "not valid JSON" in err), + ("invalid client config changed", _setup_read(os.path.join(fixture["home"], ".claude.json"), True) == invalid), + ("setup did not finish its health record", "=== Health checklist ===" in out)], out + err) + + fixture = _setup_fixture(suite, "writable-client", b"{}") + if os.name == "posix": + shim = os.path.join(fixture["root"], "client-bin") + os.makedirs(shim) + path = os.path.join(shim, "codex") + with open(path, "w") as stream: + stream.write("#!/bin/sh\nexit 0\n") + os.chmod(path, 0o755) + fixture["env"]["PATH"] = shim + os.pathsep + fixture["env"].get("PATH", "") + rc, out, err = _setup_run(fixture, "writable-client", agent) + conditions = [("writable setup failed", rc == 0), + ("no Claude Code Kin entry", _setup_client_written(fixture["home"]))] + if os.name == "posix": + conditions.append(("missing first-install Codex deferral", "Codex CLI is not configured yet" in out)) + _setup_expect(res, "writable-client-and-honest-deferral", conditions, out + err) + + fixture = _setup_fixture(suite, "plain-summary") + short_args = ["setup", "--no-interactive", "--intent", "agent", "--skip-mcp-check", "--shell", "zsh"] + rc, out, err = _setup_run(fixture, "short-summary", short_args) + _setup_expect(res, "short-noninteractive-summary", _setup_summary_conditions(rc, out), out + err) + rc, out, err = _setup_run(fixture, "verbose-summary", short_args + ["--verbose"]) + _setup_expect(res, "verbose-full-record", [("verbose setup failed", rc == 0), + ("verbose decisions or health record absent", "=== Health checklist ===" in out + and "This run was not interactive, so it answered these for you:" in out)], out + err) + rc, out, err = _setup_run(fixture, "ignored-mode", short_args + ["--mode", "native"]) + _setup_expect(res, "legacy-mode-ignored", [("legacy flag refused", rc == 0), + ("legacy flag silently accepted", "--mode is ignored" in err)], out + err) + rc, out, err = _setup_run(fixture, "setup-help", ["setup", "--help"]) + _setup_expect(res, "legacy-mode-hidden-from-help", [("help failed", rc == 0), + ("legacy flag advertised", "--mode" not in out)], out + err) + + fixture = _setup_fixture(suite, "unusable-profile", b"{}") + zshrc = os.path.join(fixture["home"], ".zshrc") + os.makedirs(zshrc) + rc, out, err = _setup_run(fixture, "unusable-profile", agent) + _setup_expect(res, "shell-refusal-still-configures-client", [ + ("partial setup failed", rc == 0), ("client was not configured", _setup_client_written(fixture["home"])), + ("profile refusal unnamed", "Shell integration was not written: failed to read " + zshrc in out), + ("manual repair absent", "To finish it by hand" in out), + ("unwritten hook claimed loadable", "Open a new shell session to load the shell hook" not in out)], out + err) + if os.name == "posix": + fixture = _setup_fixture(suite, "readonly-profile", b"{}") + store = os.path.join(fixture["home"], "store") + os.makedirs(store) + managed = os.path.join(store, "zshrc") + seeded = "# managed by home-manager\n" + with open(managed, "w") as stream: + stream.write(seeded) + os.chmod(managed, 0o444) + zshrc = os.path.join(fixture["home"], ".zshrc") + os.symlink(managed, zshrc) + try: + with open(managed, "a"): + pass + except PermissionError: + rc, out, err = _setup_run(fixture, "readonly-profile", agent) + tail = out.split("To finish it by hand", 1)[-1] + _setup_expect(res, "readonly-profile-partial-success", [ + ("partial setup failed", rc == 0), ("client was not configured", _setup_client_written(fixture["home"])), + ("refusal omitted file or cause", "Shell integration was not written: failed to update " + zshrc in out + and "Permission denied" in out), + ("manual hook missing or writable profile included", "To finish it by hand" in out + and "to " + zshrc + ":" in tail and "source " in tail and ".zshenv" not in tail), + ("read-only target changed", _setup_read(managed) == seeded and os.path.islink(zshrc)), + ("writable PATH profile absent", fixture["env"]["KIN_HOME"] + "/bin" in (_setup_read(os.path.join(fixture["home"], ".zshenv")) or "")), + ("client step did not precede shell step", 0 <= out.find("AI client MCP configuration:") < out.find("Shell integration:")), + ("health record absent", "=== Health checklist ===" in out)], out + err) + else: + res.ok("Managed setup readonly-profile: this user can write mode 0444; directory-refusal control exercised instead") + + for installed in (True, False): + fixture = _setup_fixture(suite, "editor-installed" if installed else "editor-missing") + if installed: + extension = os.path.join(fixture["home"], ".vscode", "extensions", "firelock.kin-editor-test") + os.makedirs(extension) + with open(os.path.join(extension, "package.json"), "w") as stream: + json.dump({"publisher": "firelock", "name": "kin-editor", "version": "0.0.0-test"}, stream) + rc, out, err = _setup_run(fixture, "editor", ["setup", "--intent", "editor", "--no-interactive", "--skip-mcp-check"]) + lower = out.lower() + conditions = [("editor setup failed", rc == 0)] + if installed: + conditions += [("installed extension not acknowledged", "kin-editor extension already installed" in lower + and "kin-editor is already installed" in lower), + ("installed extension recommended again", "install the kin-editor" not in lower)] + else: + section = out.split("Editor extension:", 1)[-1].split("=== Health checklist ===", 1)[0] + conditions += [("missing extension plan absent", "plan: editor (vs code + kin-editor)" in lower + and "plus how to install the kin-editor extension" in lower), + ("actionable bounded editor guidance absent", "Editor extension:" in out + and "=== Health checklist ===" in out + and "Install the kin-editor VS Code extension for the entity explorer," in section + and "semantic search, and trace surfaces. See the kin-editor README." in section)] + _setup_expect(res, "editor-installed" if installed else "editor-missing", conditions, out + err) + + fixture = _setup_fixture(suite, "mcp-round-trip", b"{}") + repo = os.path.join(fixture["root"], "repo") + os.makedirs(repo) + suite.own(repo, fixture["env"]) + rc, out, err = _setup_run(fixture, "init", ["init", repo]) + _setup_expect(res, "mcp-fixture-initialized", [("repository init failed", rc == 0)], out + err) + if rc == 0: + rc, out, err = _setup_run(fixture, "store-path", ["store", "path", "--json"], repo) + try: + layout = json.loads(out) + except ValueError: + layout = {} + store = layout.get("store") + bound = (rc == 0 and layout.get("schema") == "kin.store-path.v1" + and layout.get("external") is True + and os.path.realpath(layout.get("working_dir", "")) == os.path.realpath(repo) + and isinstance(store, str) + and os.path.commonpath([os.path.realpath(store), + os.path.realpath(fixture["env"]["KIN_HOME"])]) + == os.path.realpath(fixture["env"]["KIN_HOME"]) + and os.path.isfile(os.path.join(store, "manifest.json"))) + _setup_expect(res, "mcp-fixture-private-central-store", [ + ("product resolver did not bind the fixture's central store", bound)], out + err) + if bound: + suite.owned_stores[os.path.abspath(repo)] = store + mcp_args = ["setup", "--no-interactive", "--verbose", "--intent", "agent"] + for name, cwd, extra in (("answered", repo, []), ("skip", repo, ["--skip-mcp-check"]), + ("no-repository", fixture["cwd"], [])): + fixture["env"]["KIN_MCP_SCAN_ROOT"] = cwd + rc, out, err = _setup_run(fixture, "mcp-" + name, mcp_args + extra, cwd) + line = _setup_round_trip_line(out) + conditions = [("setup failed", rc == 0), ("Claude Code round trip row absent", bool(line))] + if name == "answered": + conditions += [("recorded entry did not answer the real status call", "the entry answered" in line + and "status reports" in line and "no repo daemon is serving" in line), + ("working entry reported unproven", "were not shown to work" not in out)] + else: + conditions += [("skip or missing repository not named", "not exercised" in line + and ("--skip-mcp-check" if name == "skip" else "no initialized Kin repository") in line), + ("missing repository reported broken", "failed at" not in line)] + _setup_expect(res, "mcp-" + name, conditions, out + err) + + fixture = _setup_fixture(suite, "broken-launcher", b"{}") + with open(fixture["kin"], "wb") as stream: + stream.write(b"this is not a Kin binary\n") + before = _setup_home_snapshot(fixture["home"]) + rc, out, err = _setup_run(fixture, "broken-launcher", agent, kin=suite.kin) + _setup_expect(res, "broken-launcher-refused-before-writes", [ + ("broken canonical launcher accepted", rc != 0), + ("native admission refusal absent", "runtime admission refused" in out + err), + ("broken launcher omitted", fixture["kin"] in out + err), + ("setup wrote before admission", _setup_home_snapshot(fixture["home"]) == before), + ("unlaunched entry claimed exercised", "MCP round trip" not in out and "=== Health checklist ===" not in out)], out + err) + + fixture = _setup_fixture(suite, "shell-only", b'{"mcpServers": {}}\n') + claude = _setup_read(os.path.join(fixture["home"], ".claude.json"), True) + args = ["setup", "--shell-only", "--shell", "zsh"] + rc, out, err = _setup_run(fixture, "shell-only-json", args + ["--json"]) + try: + report = json.loads(out) + except ValueError: + report = None + _setup_expect(res, "shell-only-one-json-object", _setup_shell_report_conditions(rc, report), out + err) + zshrc = _setup_read(os.path.join(fixture["home"], ".zshrc")) + zshenv = _setup_read(os.path.join(fixture["home"], ".zshenv")) or "" + other_clients = [".cursor/mcp.json", ".codex/config.toml", ".gemini/settings.json", + ".codeium/windsurf/mcp_config.json", ".lmstudio/mcp.json", ".grok/config.toml"] + _setup_expect(res, "shell-only-profile-client-and-ledger", [ + ("shell hook absent", "kin-vfs" in (zshrc or "")), + ("canonical bin absent from PATH profile", os.path.join(fixture["env"]["KIN_HOME"], "bin") in zshenv), + ("Claude config changed", _setup_read(os.path.join(fixture["home"], ".claude.json"), True) == claude), + ("shell-only created client configs", not any(os.path.exists(os.path.join(fixture["home"], path)) for path in other_clients)), + ("shell step missing from setup ledger", "zsh" in (_setup_read(os.path.join(fixture["env"]["KIN_HOME"], "config", "setup-ledger.json")) or ""))], out + err) + rc, out, err = _setup_run(fixture, "shell-only-idempotent", args) + _setup_expect(res, "shell-only-idempotent-human-summary", [("second run failed", rc == 0), + ("human next step or no-client verdict absent", "No AI client was connected" in out and "Open a new terminal" in out), + ("second run duplicated the hook", _setup_read(os.path.join(fixture["home"], ".zshrc")) == zshrc)], out + err) + fixture = _setup_fixture(suite, "shell-only-refusal") + os.makedirs(os.path.join(fixture["home"], ".zshrc")) + rc, out, err = _setup_run(fixture, "shell-only-refusal", args + ["--json"]) + try: + report = json.loads(out) + except ValueError: + report = None + _setup_expect(res, "shell-only-profile-refusal", _setup_shell_report_conditions(rc, report, True), out + err) + + if os.name != "posix": + res.ok("Managed setup permissive-umask controls apply only to Unix") + return + fixture = _setup_fixture(suite, "registry-file-permission-repair", b"{}") + os.chmod(fixture["root"], 0o700) + os.chmod(fixture["env"]["KIN_HOME"], 0o700) + paths = {"registry": os.path.join(fixture["root"], "registry.toml"), + "lock": os.path.join(fixture["root"], "registry.lock")} + for name, content in (("registry", b"repos = []\n"), ("lock", b"")): + with open(paths[name], "wb") as stream: + stream.write(content) + os.chmod(paths[name], 0o600) + fixture["env"]["KIN_REGISTRY_PATH"] = paths["registry"] + fixture["env"]["PATH"] = "/usr/bin:/bin" + # Seed a genuinely configured client under the final authority and launch + # environment. Then introduce only permission drift, not a fabricated + # entry/ledger or an unrelated invalid client that doctor would also repair. + rc, out, err = _setup_run(fixture, "registry-client-baseline", agent) + _setup_expect(res, "registry-repair-configured-client-baseline", [ + ("client baseline setup failed", rc == 0), + ("client baseline lacks a real Kin entry", _setup_client_written(fixture["home"])), + ("baseline changed literal registry bytes", _setup_read(paths["registry"], True) == b"repos = []\n"), + ("baseline changed literal lock bytes", _setup_read(paths["lock"], True) == b"")], out + err) + for path in paths.values(): + os.chmod(path, 0o644) + before = {name: _setup_authority_file_snapshot(path) for name, path in paths.items()} + home_before = _setup_home_snapshot(fixture["home"]) + rc, out, err = _setup_run(fixture, "registry-file-permission-repair", ["doctor", "--fix"], fixture["root"]) + after = {name: _setup_authority_file_snapshot(path) for name, path in paths.items()} + home_after = _setup_home_snapshot(fixture["home"]) + client_roots = (".claude.json", ".claude", ".cursor", ".codex", ".gemini", + ".codeium", ".lmstudio", ".grok", ".continue", "CLAUDE.md", "AGENTS.md") + + def client_entries(snapshot): + return {path: value for path, value in snapshot.items() + if any(path == root or path.startswith(root + os.sep) for root in client_roots)} + + conditions = _setup_registry_repair_conditions(rc, out, before, after) + conditions += [("authority root mode changed", stat.S_IMODE(os.stat(fixture["root"]).st_mode) == 0o700), + ("unrelated configured clients changed", client_entries(home_after) == client_entries(home_before))] + _setup_expect(res, "doctor-explicit-registry-file-permission-only-repair", conditions, out + err) + with open(os.path.join(fixture["root"], "registry-permission-repair-footprint.json"), "w") as stream: + json.dump({"schema": "kin.acceptance-registry-permission-repair.v1", + "authority_before": before, "authority_after": after, + "home_before": home_before, "home_after": home_after, + "client_configs_unchanged": client_entries(home_after) == client_entries(home_before)}, + stream, indent=2) + stream.write("\n") + fixture = _setup_fixture(suite, "permissive-umask", b"{}") + fixture["env"].pop("KIN_REGISTRY_PATH", None) + rc, out, err = _setup_run(fixture, "permissive-umask", short_args, umask=True) + kin_home = fixture["env"]["KIN_HOME"] + _setup_expect(res, "permissive-umask-setup-created-config", [("setup failed", rc == 0), + ("setup created no config", os.path.isfile(os.path.join(kin_home, "config", "setup.toml")))], out + err) + rc, out, err = _setup_run(fixture, "registry-initialize", ["registry", "authority", "--initialize"], umask=True) + exposed = [] + for relative, entry in _setup_home_snapshot(fixture["home"]).items(): + if (relative == ".kin" or relative.startswith(".kin" + os.sep) + or relative in (".zshenv", ".zshrc", ".claude.json")): + if entry[0] in ("file", "directory") and entry[1] & 0o022: + exposed.append(relative) + _setup_expect(res, "permissive-umask-state-private", [("registry initialization failed", rc == 0), + ("registry not persisted", os.path.isfile(os.path.join(kin_home, "registry.toml"))), + ("group or world writable Kin state: " + repr(exposed), not exposed)], out + err) + fixture = _setup_fixture(suite, "unsafe-home") + fixture["env"].pop("KIN_REGISTRY_PATH", None) + kin_home = fixture["env"]["KIN_HOME"] + os.chmod(kin_home, 0o775) + for extra in ([], ["--verbose"]): + rc, out, err = _setup_run(fixture, "unsafe-home" + ("-verbose" if extra else ""), short_args + extra, umask=True) + _setup_expect(res, "unsafe-home-refusal" + ("-verbose" if extra else ""), [ + ("unsafe registry accepted", rc != 0), + ("registry refusal absent", "kin setup cannot use the local registry" in err), + ("exact chmod repair absent", "chmod go-w " + kin_home in out + err), + ("mode problem called unsafe object", "move the unsafe object aside" not in out + err), + ("setup silently repaired mode", stat.S_IMODE(os.stat(kin_home).st_mode) == 0o775)], out + err) + rc, out, err = _setup_run(fixture, "unsafe-home-repair", ["doctor", "--fix"], umask=True) + _setup_expect(res, "doctor-consented-mode-repair", [ + ("doctor did not remove shared write", stat.S_IMODE(os.stat(kin_home).st_mode) == 0o755)], out + err) + rc, out, err = _setup_run(fixture, "safe-home-after-repair", short_args, umask=True) + _setup_expect(res, "setup-succeeds-after-mode-repair", [("repaired setup failed", rc == 0)], out + err) + + def check_5(suite): """The advertised MCP entry serves `initialize` outside a repository. @@ -1419,6 +1856,7 @@ def check_5(suite): res.ok("the prober tells a served handshake from a process that exits, proven both ways " "before the product was read") + env["KIN_MCP_KIN_BINARY"] = suite.prepare_runtime(env) verdict, out_lines, err, alive = mcp_initialize([node, wrapper], work, env, timeout=300) kin_created = os.path.exists(os.path.join(work, ".kin")) ok, detail = grade_unbound_start(verdict, alive, err, out_lines, kin_created) @@ -1429,6 +1867,7 @@ def check_5(suite): res.bad(detail) return res res.ok(detail) + managed_setup_controls(suite, res) return res @@ -1724,9 +2163,10 @@ def check_6(suite): skipped_env["HOME"] = home skipped_env["KIN_HOME"] = os.path.join(home, ".kin") skipped_env["PATH"] = os.pathsep.join([served_only, path]) + skipped_kin = suite.prepare_runtime(skipped_env) suite.own(work, skipped_env) - rc, out, err = run([suite.kin, "init", "."], cwd=work, env=skipped_env, timeout=1800) + rc, out, err = run([skipped_kin, "init", "."], cwd=work, env=skipped_env, timeout=1800) combined = (out or "") + "\n" + (err or "") if rc != 0: res.unknown("kin init exited %d on the fixture: %s" @@ -1743,7 +2183,7 @@ def check_6(suite): # The sibling command, on the store that run just built. It holds the whole # status object and used to read two numbers out of it. - rc, out, err = run([suite.kin, "daemon", "sweep"], cwd=work, env=skipped_env, timeout=1800) + rc, out, err = run([skipped_kin, "daemon", "sweep"], cwd=work, env=skipped_env, timeout=1800) sweep_output = (out or "") + "\n" + (err or "") if rc != 0: res.unknown("kin daemon sweep exited %d: %s" % (rc, flatten(failure_excerpt(err)))) @@ -1768,9 +2208,10 @@ def check_6(suite): control_env["HOME"] = control_home control_env["KIN_HOME"] = os.path.join(control_home, ".kin") control_env["PATH"] = os.pathsep.join([both, path]) + control_kin = suite.prepare_runtime(control_env) suite.own(control, control_env) - rc, out, err = run([suite.kin, "init", "."], cwd=control, env=control_env, timeout=1800) + rc, out, err = run([control_kin, "init", "."], cwd=control, env=control_env, timeout=1800) control_output = (out or "") + "\n" + (err or "") if rc != 0: res.unknown("the control kin init exited %d: %s" @@ -1852,7 +2293,8 @@ def check_7(suite): work = os.path.join(suite.workdir, "no-repository") if not os.path.isdir(work): os.makedirs(work) - rc, out, err = run([suite.kin, "doctor"], cwd=work, env=env, timeout=600) + kin = suite.prepare_runtime(env) + rc, out, err = run([kin, "doctor"], cwd=work, env=env, timeout=600) page = flatten(strip_ansi(out or "")) if not page: res.unknown("`kin doctor` exited %d and printed no page: %s" @@ -1885,6 +2327,13 @@ def __init__(self, kin, daemon, workdir, repo_root, verbose=False): self.verbose = verbose # Repository path to the environment it was built and initialized with. self.owned_repos = {} + self.owned_stores = {} + self.runtime_paths = {} + + def prepare_runtime(self, env): + kin, daemon = prepare_runtime_fixture(self.kin, self.daemon, env, env["KIN_HOME"]) + self.runtime_paths[env["KIN_HOME"]] = (kin, daemon) + return kin def own(self, repo, env=None): """Record a repository this run creates, before any init touches it.""" @@ -1910,7 +2359,8 @@ def shutdown(self): record.update({"home": env.get("HOME"), "kin_home": env.get("KIN_HOME")}) records.append(record) # Never let discovery walk upward and select an unrelated repository. - if not os.path.isfile(os.path.join(repo, ".kin", "manifest.json")): + store = self.owned_stores.get(repo, os.path.join(repo, ".kin")) + if not os.path.isfile(os.path.join(store, "manifest.json")): record["error"] = "fixture manifest missing; stop was not attempted" errors.append("%s: %s" % (repo, record["error"])) continue @@ -1919,8 +2369,9 @@ def shutdown(self): errors.append("%s: %s" % (repo, record["error"])) continue try: + kin = self.runtime_paths.get(env.get("KIN_HOME"), (self.kin, self.daemon))[0] proc = subprocess.run( - [self.kin, "daemon", "stop", "--json"], cwd=repo, env=env, + [kin, "daemon", "stop", "--json"], cwd=repo, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=60) rc, out, err = proc.returncode, proc.stdout, proc.stderr @@ -1949,8 +2400,13 @@ def scratch(self, name): def base_env(self): env = dict(os.environ) - # This fixture inspects/copies an in-tree store; central layout is - # qualified separately by brownfield_repro --store-layout central. + home = os.path.join(self.workdir, "base-home") + env["HOME"] = home + env["KIN_HOME"] = os.path.join(home, ".kin") + for name in ("KIN_REGISTRY_PATH", "KIN_MCP_REPO", "KIN_DIR", "KIN_DAEMON_URL"): + env.pop(name, None) + # Legacy sweep fixtures inspect/copy in-tree stores. Managed setup's + # MCP fixture clears this override and verifies its private central store. env["KIN_STORE_LAYOUT"] = "in-tree" # The same posture the other suites hold: no inference, no projection, # and nothing that could reach an operator's real store. @@ -2004,6 +2460,116 @@ def npm_env(self, home): # ------------------------------------------------------------------- self test +def self_test_managed_setup_controls(expect): + """Falsify the added graders without launching any runtime or daemon.""" + from unittest import mock + + def passed(conditions): + return all(holds for _, holds in conditions) + + summary = ("This run asks nothing and takes the defaults.\n" + "3 defaults taken without asking; kin setup --verbose lists each one\n" + "Kin is set up on this machine.\n") + expect("Managed short summary positive", passed(_setup_summary_conditions(0, summary)), True) + for name, rc, text in (("failed exit", 1, summary), + ("silent defaults", 0, summary.replace("taken without asking", "taken")), + ("verbose leak", 0, summary + "=== Health checklist ===\n"), + ("escape sequence", 0, "\x1b[32m" + summary), + ("excessive lines", 0, summary + "line\n" * 31)): + expect("Managed summary rejects " + name, passed(_setup_summary_conditions(rc, text)), False) + + installed = {"schema": "kin.setup-shell.v1", "shell": "zsh", "status": "installed", + "clients_connected": [], "reason": None, "add_by_hand": []} + refused = dict(installed, status="not_written", reason="profile is a directory", + next=None, add_by_hand=[{"profile": "/home/.zshrc", "lines": ["source hook"]}]) + expect("Managed shell JSON positive", passed(_setup_shell_report_conditions(0, installed)), True) + expect("Managed shell refusal positive", passed(_setup_shell_report_conditions(1, refused, True)), True) + for name, value in (("wrong schema", dict(installed, schema="other")), + ("client connection", dict(installed, clients_connected=["Claude Code"])), + ("missing client disclosure", {k: v for k, v in installed.items() if k != "clients_connected"}), + ("non-object JSON", [])): + expect("Managed shell JSON rejects " + name, passed(_setup_shell_report_conditions(0, value)), False) + expect("Managed shell refusal rejects missing manual hook", + passed(_setup_shell_report_conditions(1, dict(refused, add_by_hand=[]), True)), False) + expect("Managed shell refusal rejects loadable claim", + passed(_setup_shell_report_conditions(1, dict(refused, next="Open a terminal"), True)), False) + + line = "Claude Code: the entry answered; status reports no repo daemon is serving" + expect("round-trip grader takes only the actual MCP section", + _setup_round_trip_line("Claude Code: configured\nMCP round trip\n " + line + "\n"), line) + expect("configuration alone is not a round trip", _setup_round_trip_line("Claude Code: configured"), "") + result = Result("5", "control", "control") + _setup_expect(result, "positive", [("missing", True)]) + expect("named Managed assertion passes only with evidence", result.status, PASS) + _setup_expect(result, "negative", [("missing field", False)], "probe output") + expect("named Managed assertion rejects a false condition", result.status, FAIL) + expect("named Managed assertion keeps the failed name", "negative" in result.detail, True) + _setup_expect(result, "later positive", [("missing", True)]) + expect("later positive cannot erase an earlier Managed failure", result.status, FAIL) + expect("later positive preserves the earlier failure detail", "negative" in result.detail, True) + + authority = {"mode": 0o644, "regular": True, "device": 3, "inode": 7, + "uid": 501, "gid": 20, "sha256": "unchanged-fixture-content"} + before = {"registry": dict(authority), "lock": dict(authority, inode=8)} + after = {name: dict(row, mode=0o600) for name, row in before.items()} + repaired = "repaired registry authority permissions to 0600" + expect("Managed registry permission-only repair positive", + passed(_setup_registry_repair_conditions(0, repaired, before, after)), True) + for field, value in (("mode", 0o644), ("inode", 9), ("uid", 0), + ("sha256", "changed-content"), ("regular", False)): + changed = dict(after, registry=dict(after["registry"], **{field: value})) + expect("Managed registry repair rejects changed " + field, + passed(_setup_registry_repair_conditions(0, repaired, before, changed)), False) + expect("Managed registry repair rejects already safe control", + passed(_setup_registry_repair_conditions(0, repaired, after, after)), False) + expect("Managed registry repair requires native repair report", + passed(_setup_registry_repair_conditions(0, "", before, after)), False) + + with tempfile.TemporaryDirectory(prefix="first-contact-managed-setup-self-test-") as root: + suite = Suite("/not-executed/kin", "/not-executed/kin-daemon", root, root) + + def prepared(env): + # A mock receipt tests the harness branch only. It never admits or + # measures a binary, and cannot reach the live acceptance entrypoint. + os.makedirs(env["KIN_HOME"]) + env["KIN_RUNTIME_MODE"] = "managed" + with open(os.path.join(env["KIN_HOME"], "acceptance-runtime.json"), "w") as stream: + json.dump({"schema": "kin.acceptance-runtime-fixture.v1", "mode": "managed"}, stream) + return "/not-executed/canonical/kin" + + with mock.patch.object(suite, "prepare_runtime", side_effect=prepared): + fixture = _setup_fixture(suite, "harness", b"{}") + expect("Managed fixture gives clients their isolated home", fixture["env"]["USERPROFILE"], fixture["home"]) + expect("Managed fixture disables repo daemon discovery", fixture["env"]["KIN_NO_DAEMON"], "1") + expect("Managed fixture keeps central store behavior", "KIN_STORE_LAYOUT" in fixture["env"], False) + expect("Managed fixture keeps registry and central store authority in its Kin home", + os.path.dirname(fixture["env"]["KIN_REGISTRY_PATH"]), fixture["env"]["KIN_HOME"]) + expect("Managed fixture detects seeded Claude client", _setup_read(os.path.join(fixture["home"], ".claude.json"), True), b"{}") + before = _setup_home_snapshot(fixture["home"]) + with open(os.path.join(fixture["home"], ".zshrc"), "w") as stream: + stream.write("unexpected write\n") + expect("admission snapshot catches a new profile", _setup_home_snapshot(fixture["home"]) == before, False) + with open(os.path.join(fixture["home"], ".claude.json"), "w") as stream: + json.dump({"mcpServers": {"kin": {"command": "canonical"}}}, stream) + expect("client grader requires the actual entry", _setup_client_written(fixture["home"]), True) + + def development(env): + prepared(env) + env["KIN_RUNTIME_MODE"] = "development" + return "/not-executed/development/kin" + + with mock.patch.object(suite, "prepare_runtime", side_effect=development): + try: + _setup_fixture(suite, "development") + except ValueError: + rejected = True + else: + rejected = False + expect("Managed behavior checks refuse Development fixture", rejected, True) + expect("first-contact result inventory stays eight checks", [key for key, _ in CHECKS], + [str(number) for number in range(8)]) + + def self_test_toolchain_fixture(expect): """Exercise check 4's real environment preparation without running Kin. @@ -2061,6 +2627,7 @@ def repair(cmd, cwd=None, env=None, timeout=None): with mock.patch(__name__ + "._serve_asset", side_effect=serve), \ mock.patch(__name__ + ".run", side_effect=repair), \ + mock.patch.object(suite, "prepare_runtime", return_value=suite.kin), \ mock.patch.object(subprocess, "Popen", side_effect=AssertionError("unexpected process")), \ mock.patch(__name__ + ".HTTPServer", side_effect=AssertionError("unexpected listener")): result = check_4(suite) @@ -2264,6 +2831,9 @@ def drive(mode="confirmed", checks=None, extra=(), patches=()): env = {"FIRST_CONTACT_STUB_LOG": log, "FIRST_CONTACT_STUB_STOP": mode} with contextlib.ExitStack() as stack: stack.enter_context(mock.patch.dict(os.environ, env)) + # The cleanup stub is intentionally not a native runtime pair. + stack.enter_context(mock.patch.object( + Suite, "prepare_runtime", lambda suite, child_env: suite.kin)) if checks is not None: stack.enter_context(mock.patch(__name__ + ".CHECKS", checks)) for patch in patches: @@ -2853,6 +3423,7 @@ def release_failure(reason): expect("the control on no output is UNREADABLE", grade_served_sweep_outcome("")[0], None) + self_test_managed_setup_controls(expect) self_test_toolchain_fixture(expect) # A run root goes only when nothing was lost and nobody asked to keep it. diff --git a/scripts/acceptance/first_query_readiness_repro.py b/scripts/acceptance/first_query_readiness_repro.py index 39db7398c..3832085a1 100755 --- a/scripts/acceptance/first_query_readiness_repro.py +++ b/scripts/acceptance/first_query_readiness_repro.py @@ -58,6 +58,7 @@ import sys import tempfile import time +from runtime_fixture import prepare_runtime_fixture # An agent profile serves each answer in plain words and leaves out the verdict # codes and evidence blocks this suite reads. Ask every kin it starts for the @@ -417,6 +418,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): os.makedirs(path, exist_ok=True) self._ready = None self._setup_error = None + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def shutdown(self): """Stop only this run's repositories, including a failed initialization.""" diff --git a/scripts/acceptance/graph_viz_local_render.py b/scripts/acceptance/graph_viz_local_render.py index 2c9cd8397..5204abe65 100644 --- a/scripts/acceptance/graph_viz_local_render.py +++ b/scripts/acceptance/graph_viz_local_render.py @@ -74,6 +74,7 @@ import urllib.error import urllib.request from pathlib import Path +from runtime_fixture import prepare_runtime_fixture TICKET = "FIR-3498" @@ -433,7 +434,7 @@ def free_port(): return probe.getsockname()[1] -def suite_env(home, daemon): +def suite_env(home, daemon, kin): env = { # This witness reads the in-tree store directly. Central-store # qualification runs separately; this preserves its original scope. @@ -449,6 +450,7 @@ def suite_env(home, daemon): } if daemon: env["KIN_DAEMON_BIN"] = str(daemon) + prepare_runtime_fixture(kin, daemon, env, home) return env @@ -699,7 +701,7 @@ def main(): home_a = tmp / "home-a" home_a.mkdir() work_a = tmp / "work-a" - env_a = suite_env(home_a, daemon) + env_a = suite_env(home_a, daemon, kin) # Owned before its init, so a store whose init failed is still stopped. owned[work_a] = env_a init_a = build_store(kin, env_a, work_a) @@ -746,7 +748,7 @@ def main(): home_b = tmp / "home-b" home_b.mkdir() work_b = tmp / "work-b" - env_b = suite_env(home_b, daemon) + env_b = suite_env(home_b, daemon, kin) owned[work_b] = dict(env_b) init_b = build_store(kin, env_b, work_b) if init_b.returncode != 0: @@ -801,7 +803,7 @@ def main(): home_c = tmp / "home-c" home_c.mkdir() work_c = tmp / "work-c" - env_c = suite_env(home_c, daemon) + env_c = suite_env(home_c, daemon, kin) owned[work_c] = dict(env_c) init_c = build_store(kin, env_c, work_c) if init_c.returncode != 0: diff --git a/scripts/acceptance/hydration_semantics_repro.py b/scripts/acceptance/hydration_semantics_repro.py index 9f24706a1..011d1f8f0 100755 --- a/scripts/acceptance/hydration_semantics_repro.py +++ b/scripts/acceptance/hydration_semantics_repro.py @@ -62,6 +62,7 @@ import tempfile import time import uuid +from runtime_fixture import prepare_runtime_fixture # An agent profile serves each answer in plain words and leaves out the verdict # codes and evidence blocks this suite reads. Ask every kin it starts for the @@ -649,6 +650,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self._doors = None self._transfer = None self._stop_repos = [] + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def log(self, line): if self.verbose: diff --git a/scripts/acceptance/init_budget_refusal.py b/scripts/acceptance/init_budget_refusal.py index 81acf191c..add0c5b1f 100755 --- a/scripts/acceptance/init_budget_refusal.py +++ b/scripts/acceptance/init_budget_refusal.py @@ -96,6 +96,7 @@ import tempfile import threading import time +from runtime_fixture import prepare_runtime_fixture print = functools.partial(print, flush=True) @@ -451,6 +452,8 @@ def __init__(self, kin, workdir, verbose=False): # share would decide check 5's control arm instead of the check doing it. self.env.pop(DAEMON_BUDGET_ENV, None) self.repos = {} + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, None, self.env, self.env["KIN_HOME"]) def git(self, args, cwd): proc = subprocess.run( @@ -1206,9 +1209,30 @@ def grade_report_shape(expect): # the row was replaced, not what it was replaced with. Check 0 grades the # stub's silence as a FAIL and that is a graded row. stub = os.path.join(scratch, "kin-stub") + stub_identity = {"version": "0.0.0", "commit_sha": "0" * 40, + "source_dirty": True, "source_known": True, + "target": "report-writer-fixture", "build_profile": "debug"} + stub_build = dict(zip( + ("kin_version", "kin_commit", "kin_dirty", "kin_source_known", + "kin_target", "kin_build_profile"), stub_identity.values()), + schema="kin.runtime-build-identity.v1") with open(stub, "w") as handle: - handle.write("#!/bin/sh\nexit 9\n") + handle.write("#!%s\nimport json, sys\n" + "if sys.argv[1:2] == ['bench-meta']:\n" + " print(json.dumps(%r)); sys.exit(0)\n" + "sys.exit(9)\n" % (sys.executable, stub_build)) os.chmod(stub, 0o755) + # Activation observes a matching Development pair, while the actual + # check still receives exit 9. This arm tests report wiring, not a + # release runtime or the check's product behavior. + daemon_stub = os.path.join(scratch, "kin-daemon") + with open(daemon_stub, "w") as handle: + handle.write("#!%s\nimport json, sys\n" + "if sys.argv[1:2] == ['--compat-json']:\n" + " print(json.dumps(%r)); sys.exit(0)\n" + "sys.exit(9)\n" % + (sys.executable, {"runtime_identity": stub_identity})) + os.chmod(daemon_stub, 0o755) answered_path = os.path.join(scratch, "answered.json") subprocess.run( [sys.executable, os.path.abspath(__file__), diff --git a/scripts/acceptance/language_server_warning.py b/scripts/acceptance/language_server_warning.py index ad3553f9c..28afb0f93 100755 --- a/scripts/acceptance/language_server_warning.py +++ b/scripts/acceptance/language_server_warning.py @@ -57,6 +57,7 @@ import sys import tempfile from pathlib import Path +from runtime_fixture import prepare_runtime_fixture TICKET = "FIR-2777" @@ -238,6 +239,7 @@ def run_arm(kin, tmp, name, path_value): "KIN_EMBED_BACKEND": "cpu", "TERM": "dumb", } + kin, _ = prepare_runtime_fixture(kin, None, env, home) subprocess.run([kin, "init", "."], cwd=str(work), env=env, capture_output=True, text=True) got = subprocess.run([kin, "graph", "status"], cwd=str(work), env=env, diff --git a/scripts/acceptance/magic_repro.py b/scripts/acceptance/magic_repro.py index 6247f5070..8da4f15f3 100755 --- a/scripts/acceptance/magic_repro.py +++ b/scripts/acceptance/magic_repro.py @@ -68,6 +68,7 @@ os.environ.setdefault("KIN_MCP_MACHINE_REPLIES", "1") from trace_pages import mcp_references +from runtime_fixture import native_request_headers, prepare_runtime_fixture ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]") @@ -201,6 +202,8 @@ def __init__(self, kin, workdir, verbose=False, daemon=None): self.env.pop("KIN_MCP_REPO", None) if daemon: self.env["KIN_DAEMON_BIN"] = daemon + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, os.path.join(workdir, "kin-home")) # ---------------------------------------------------------------- plumbing @@ -652,6 +655,7 @@ def await_absence_control_ready(self, repo, timeout=90): with open(os.path.join(repo, ".kin", "daemon.token")) as handle: token = handle.read().strip() endpoint = "http://127.0.0.1:%d/lsp/sweep/status" % port + headers = native_request_headers(self.kin, self.env, endpoint, token, cwd=repo) started = time.monotonic() deadline = started + timeout samples = 0 @@ -671,8 +675,7 @@ def remaining(): raise RuntimeError("isolated graph status failed: %s" % failure_excerpt(err or out)) graph = json.loads(out) - request = urllib.request.Request(endpoint, headers={ - "Authorization": "Bearer " + token}) + request = urllib.request.Request(endpoint, headers=headers) with urllib.request.urlopen(request, timeout=remaining()) as response: work = json.load(response) samples += 1 @@ -694,12 +697,12 @@ def await_enrichment(self, repo, timeout=90): with open(os.path.join(repo, ".kin", "daemon.token")) as handle: token = handle.read().strip() endpoint = "http://127.0.0.1:%d/lsp/sweep/status" % port + headers = native_request_headers(self.kin, self.env, endpoint, token, cwd=repo) started = time.monotonic() deadline = started + timeout samples = 0 while True: - request = urllib.request.Request(endpoint, headers={ - "Authorization": "Bearer " + token}) + request = urllib.request.Request(endpoint, headers=headers) with urllib.request.urlopen(request, timeout=5) as response: state = json.load(response) samples += 1 diff --git a/scripts/acceptance/mcp_spawn_admission_repro.py b/scripts/acceptance/mcp_spawn_admission_repro.py index d983334f1..6784fc4e9 100755 --- a/scripts/acceptance/mcp_spawn_admission_repro.py +++ b/scripts/acceptance/mcp_spawn_admission_repro.py @@ -55,6 +55,7 @@ import threading import time from pathlib import Path +from runtime_fixture import prepare_runtime_fixture # An agent profile serves each answer in plain words and leaves out the verdict # codes and evidence blocks this suite reads. Ask every kin it starts for the @@ -268,6 +269,8 @@ def session(kin: str, daemon_bin: str, repo: Path, kin_home: Path, cwd: Path, for leaked in ("KIN_DAEMON_URL", "KIN_NO_DAEMON", "KIN_MCP_REPO"): env.pop(leaked, None) + kin, daemon_bin = prepare_runtime_fixture(kin, daemon_bin, env, kin_home) + proc = subprocess.Popen([kin, "mcp", "start"], cwd=str(cwd), env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1) @@ -357,6 +360,7 @@ def check_handshake_starts_no_daemon(kin, daemon_bin, workdir) -> dict: # This fixture inspects/copies an in-tree store; central layout is # qualified separately by brownfield_repro --store-layout central. env["KIN_STORE_LAYOUT"] = "in-tree" + kin, daemon_bin = prepare_runtime_fixture(kin, daemon_bin, env, home) repo = build_fixture(root, kin, env) outcome = session(kin, daemon_bin, repo, home, repo, None, None, WATCH_SECONDS) if outcome["daemons"]: @@ -383,6 +387,7 @@ def check_roots_answer_starts_no_daemon(kin, daemon_bin, workdir) -> dict: # This fixture inspects/copies an in-tree store; central layout is # qualified separately by brownfield_repro --store-layout central. env["KIN_STORE_LAYOUT"] = "in-tree" + kin, daemon_bin = prepare_runtime_fixture(kin, daemon_bin, env, home) repo = build_fixture(root, kin, env) outcome = session(kin, daemon_bin, repo, home, outside, repo, None, WATCH_SECONDS) if outcome["daemons"]: @@ -407,6 +412,7 @@ def check_a_tool_call_still_starts_the_daemon(kin, daemon_bin, workdir) -> dict: # This fixture inspects/copies an in-tree store; central layout is # qualified separately by brownfield_repro --store-layout central. env["KIN_STORE_LAYOUT"] = "in-tree" + kin, daemon_bin = prepare_runtime_fixture(kin, daemon_bin, env, home) repo = build_fixture(root, kin, env) outcome = session(kin, daemon_bin, repo, home, repo, None, "semantic_locate", WATCH_SECONDS * 2, stop_when="daemon") @@ -428,6 +434,7 @@ def check_a_tool_call_still_dispatches_the_embed(kin, daemon_bin, workdir) -> di # This fixture inspects/copies an in-tree store; central layout is # qualified separately by brownfield_repro --store-layout central. env["KIN_STORE_LAYOUT"] = "in-tree" + kin, daemon_bin = prepare_runtime_fixture(kin, daemon_bin, env, home) repo = build_fixture(root, kin, env) outcome = session(kin, daemon_bin, repo, home, repo, None, "semantic_locate", WATCH_SECONDS * 2, stop_when="embed") diff --git a/scripts/acceptance/memory_pressure_refusal.py b/scripts/acceptance/memory_pressure_refusal.py index 009fdd674..cfef60fa8 100755 --- a/scripts/acceptance/memory_pressure_refusal.py +++ b/scripts/acceptance/memory_pressure_refusal.py @@ -96,6 +96,7 @@ """ import argparse +import http.client import http.server import json import os @@ -107,6 +108,10 @@ import tempfile import threading import time +from runtime_fixture import (IDENTITY_HEADER, REPOSITORY_IDENTITY_HEADER, + fixture_daemon_metadata, prepare_runtime_fixture, + repository_fingerprint, runtime_fingerprint, + runtime_namespace) # An agent profile serves each answer in plain words and leaves out the verdict # codes and evidence blocks this suite reads. Ask every kin it starts for the @@ -895,10 +900,18 @@ class DaemonStub(object): annotation and negative-verdict pipeline without opening a graph store or competing for the shared daemon/GPU. """ - def __init__(self, resources_status, resources_body, graph_status_coverage=None): + def __init__(self, resources_status, resources_body, graph_status_coverage=None, + runtime_metadata=None, health=None): self.resources_status = resources_status self.resources_body = resources_body self.graph_status_coverage = graph_status_coverage + self.runtime_metadata = runtime_metadata + # The repository's own daemon's /health, replayed. A client admits a + # local endpoint only once it reads ready and names the repository it + # serves, so a stub that answers neither is refused before any tool + # call reaches it. + self.health = health + self.handler_calls = 0 self.requests = [] self.server = None self.thread = None @@ -931,6 +944,20 @@ def _json(self, status, payload): def do_POST(self): body = self._capture() + metadata = owner.runtime_metadata + if metadata is None or self.headers.get(IDENTITY_HEADER) != runtime_fingerprint( + metadata["identity"], metadata["mode"], metadata["namespace"]): + self._json(409, {"error": "controlled peer requires the selected runtime fingerprint"}) + return + try: + expected_repository = repository_fingerprint(metadata.get("repository")) + except (TypeError, ValueError): + self._json(409, {"error": "controlled peer has no valid repository descriptor"}) + return + if self.headers.get(REPOSITORY_IDENTITY_HEADER) != expected_repository: + self._json(409, {"error": "controlled peer requires the selected repository fingerprint"}) + return + owner.handler_calls += 1 if self.path == "/mcp/tools/call": try: tool = json.loads(body).get("name") @@ -970,8 +997,14 @@ def do_POST(self): def do_GET(self): self._capture() + if self.path == "/runtime/identity" and owner.runtime_metadata is not None: + self._json(200, owner.runtime_metadata) + return if self.path == "/health": - self._json(200, {}) + self._json(200, owner.health if owner.health is not None else {}) + return + if self.path == "/readiness" and owner.health is not None: + self._json(200, {"ready": True, "warming": False}) return self._json(404, {"error": "unknown stub route"}) @@ -992,6 +1025,85 @@ def __exit__(self, _exc_type, _exc, _traceback): if self.thread is not None: self.thread.join(timeout=5) + +def daemon_stub_admission_self_test(): + """Exercise the controlled HTTP boundary, without executing native binaries.""" + failures = [] + total = 0 + with tempfile.TemporaryDirectory(prefix="kin-peer-admission-") as root: + home, store, other = (os.path.join(root, name) for name in ("home", "store", "other")) + for path in (home, store, other): + os.mkdir(path) + # Explicit Development metadata is protocol input, not Managed build proof. + identity = {"version": "0.0.0-control", "commit_sha": "unknown", + "source_dirty": True, "source_known": False, + "target": "control-target", "build_profile": "debug"} + repository = {"repository_id": "fixture-repository-control", + "store_namespace": runtime_namespace(store)} + metadata = {"schema": "kin.runtime-service.v1", "component": "daemon", + "identity": identity, "mode": "development", + "namespace": runtime_namespace(home), "repository": repository} + source_pin = runtime_fingerprint(identity, "development", metadata["namespace"]) + repository_pin = repository_fingerprint(repository) + accepted = {IDENTITY_HEADER: source_pin, REPOSITORY_IDENTITY_HEADER: repository_pin} + other_repository = dict(repository, repository_id="another-repository") + other_store = dict(repository, store_namespace=runtime_namespace(other)) + other_source = runtime_fingerprint(identity, "development", runtime_namespace(other)) + + def request(peer, method, path, headers): + connection = http.client.HTTPConnection("127.0.0.1", peer.server.server_address[1], timeout=5) + try: + body = json.dumps({"name": "semantic_locate"}) if method == "POST" else None + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + resource_body = {"error": "original controlled resources failure"} + with DaemonStub(503, resource_body, runtime_metadata=metadata) as peer: + refused = [ + ("missing-both", {}), + ("missing-source", {REPOSITORY_IDENTITY_HEADER: repository_pin}), + ("wrong-source", dict(accepted, **{IDENTITY_HEADER: "wrong-source"})), + ("missing-repository", {IDENTITY_HEADER: source_pin}), + ("wrong-repository", dict(accepted, **{REPOSITORY_IDENTITY_HEADER: "wrong-repository"})), + ("other-repository", dict(accepted, **{REPOSITORY_IDENTITY_HEADER: repository_fingerprint(other_repository)})), + ("other-store", dict(accepted, **{REPOSITORY_IDENTITY_HEADER: repository_fingerprint(other_store)})), + ("other-home", dict(accepted, **{IDENTITY_HEADER: other_source})), + ] + for name, headers in refused: + total += 1 + status, _payload = request(peer, "POST", "/mcp/tools/call", headers) + if status != 409 or peer.handler_calls != 0: + failures.append("controlled peer %s did not refuse before handlers" % name) + for path, expected_status, expected_body in ( + ("/mcp/tools/call", 200, None), + ("/commands/resources", 503, resource_body)): + total += 1 + before = peer.handler_calls + status, payload = request(peer, "POST", path, accepted) + if (status != expected_status or peer.handler_calls != before + 1 + or (expected_body is not None and payload != expected_body) + or (expected_body is None and not payload.get("content"))): + failures.append("controlled peer changed the admitted %s payload" % path) + for path, expected in (("/runtime/identity", metadata), ("/health", {})): + total += 1 + before = peer.handler_calls + status, payload = request(peer, "GET", path, {}) + if status != 200 or payload != expected or peer.handler_calls != before: + failures.append("controlled peer changed pure %s diagnostics" % path) + + for invalid in (None, dict(repository, repository_id=""), + dict(repository, store_namespace=17)): + total += 1 + with DaemonStub(200, {}, runtime_metadata=dict(metadata, repository=invalid)) as peer: + status, _payload = request(peer, "POST", "/mcp/tools/call", accepted) + if status != 409 or peer.handler_calls != 0: + failures.append("controlled peer adopted a malformed repository descriptor") + return failures, total + + class Suite(object): def __init__(self, kin, workdir, daemon=None, verbose=False): self.kin = kin @@ -1025,6 +1137,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): if daemon: self.env["KIN_DAEMON_BIN"] = daemon self.repos = {} + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def git(self, args, cwd): base = ["git", @@ -1121,6 +1235,27 @@ def wait_for_exit(self, pid, seconds=30): time.sleep(0.2) return False + def served_health(self, repo): + """The /health this repository's own daemon answers, for a stub to replay. + + Starts a daemon when none serves the repository, the way any command + that needs one does. + """ + self.kin_run(["graph", "status"], repo) + endpoint = self.daemon_endpoint(repo) + if endpoint is None: + raise RuntimeError("no daemon published an endpoint for %s" % repo) + connection = http.client.HTTPConnection(endpoint[len("http://"):], timeout=30) + try: + connection.request("GET", "/health") + response = connection.getresponse() + body = response.read() + finally: + connection.close() + if response.status != 200: + raise RuntimeError("the repository daemon's /health answered HTTP %d" % response.status) + return json.loads(body) + def daemon_endpoint(self, repo): """The endpoint this store's daemon published, or None. @@ -2513,7 +2648,13 @@ def check_13(suite): "MCP suppresses only exact embedding completion and qualifies the selected session", ) repo = suite.fixture("mcp-pressure-response") + try: + served_health = suite.served_health(repo) + except (OSError, RuntimeError, ValueError) as error: + result.unknown("could not read the repository daemon's health to replay: %s" % error) + return result run([suite.kin, "daemon", "stop"], cwd=repo, env=suite.env, timeout=180) + runtime_metadata = fixture_daemon_metadata(suite.kin, suite.env, cwd=repo) embed_refusal = { "work": "embed-batch", @@ -2592,7 +2733,8 @@ def check_13(suite): embed_refusal, ) session_id = "memory-pressure-%s" % name - with DaemonStub(status, body) as daemon: + with DaemonStub(status, body, runtime_metadata=runtime_metadata, + health=served_health) as daemon: try: payload = suite.mcp( repo, @@ -2667,7 +2809,8 @@ def check_13(suite): [embed_refusal, lsp_refusal, future_refusal], embed_refusal, ) - with DaemonStub(status, body, graph_status_coverage=coverage) as daemon: + with DaemonStub(status, body, graph_status_coverage=coverage, + runtime_metadata=runtime_metadata, health=served_health) as daemon: try: payload = suite.mcp( repo, @@ -2723,7 +2866,7 @@ def self_test(): opposite verdict. This runs before any build in CI, so a broken grader is named in seconds rather than after three minutes of compiling. """ - failures = [] + failures, admission_cases = daemon_stub_admission_self_test() row_cases = [ (True, {"id": ROW_ID, "status": "degraded", @@ -3227,7 +3370,8 @@ def _scripted(sequence): + len(kill_row_cases) + len(warning_cases) + len(line_cases) + len(tail_cases) + len(grade_cases) + len(proportional_cases) + len(charge_cases) - + len(child_cases) + len(settle_cases) + len(pair_cases)) + + len(child_cases) + len(settle_cases) + len(pair_cases) + + admission_cases) print("kin-memory-pressure-repro: self-test %d/%d cases" % (total - len(failures), total)) return 1 if failures else 0 diff --git a/scripts/acceptance/merge_precedence_repro.py b/scripts/acceptance/merge_precedence_repro.py index 35d36a16d..7917e9159 100755 --- a/scripts/acceptance/merge_precedence_repro.py +++ b/scripts/acceptance/merge_precedence_repro.py @@ -93,6 +93,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -412,6 +413,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): if daemon: self.env["KIN_DAEMON_BIN"] = daemon self._repos = {} + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, os.path.join(workdir, "kin-home")) def git(self, args, repo): base = ["git", "-c", "core.hooksPath=/dev/null", diff --git a/scripts/acceptance/parse_hole_repro.py b/scripts/acceptance/parse_hole_repro.py index e30993018..cf07ab954 100755 --- a/scripts/acceptance/parse_hole_repro.py +++ b/scripts/acceptance/parse_hole_repro.py @@ -57,6 +57,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -337,6 +338,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self.env["KIN_DAEMON_BIN"] = daemon self.repos = {} self.owned_repos = set() + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def git(self, args, cwd): base = ["git", diff --git a/scripts/acceptance/prose_query_parity_repro.py b/scripts/acceptance/prose_query_parity_repro.py index 7443ff440..adb58c8b0 100755 --- a/scripts/acceptance/prose_query_parity_repro.py +++ b/scripts/acceptance/prose_query_parity_repro.py @@ -62,6 +62,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture TICKET = "FIR-2918" PASS = "PASS" @@ -454,6 +455,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): os.makedirs(path, exist_ok=True) self._arms = None self._setup_error = None + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) # ------------------------------------------------------------- plumbing diff --git a/scripts/acceptance/published_store_upgrade_repro.py b/scripts/acceptance/published_store_upgrade_repro.py index d13d4fae3..584452404 100644 --- a/scripts/acceptance/published_store_upgrade_repro.py +++ b/scripts/acceptance/published_store_upgrade_repro.py @@ -67,6 +67,8 @@ import time import urllib.request +from runtime_fixture import prepare_runtime_fixture + print = functools.partial(print, flush=True) PASS = "PASS" @@ -432,6 +434,8 @@ def __init__(self, kin, daemon, published_kin, workdir, verbose=False): self.cleanup_kin = self.published_kin self.cleanup_home = "home-published" self.cleanup_daemon = self.published_daemon + self.cleanup_env = None + self.candidate_env = None def shutdown(self): """Stop only this run's repositories, including a failed initialization.""" @@ -449,7 +453,8 @@ def shutdown(self): continue try: proc = subprocess.run( - [self.cleanup_kin, "daemon", "stop", "--json"], cwd=repo, env=self.env(self.cleanup_home, self.cleanup_daemon), + [self.cleanup_kin, "daemon", "stop", "--json"], cwd=repo, + env=self.cleanup_env or self.env(self.cleanup_home, self.cleanup_daemon), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=120) rc, out, err = proc.returncode, proc.stdout, proc.stderr record.update({"returncode": rc, "stdout": out, "stderr": err}) @@ -477,6 +482,7 @@ def env(self, home, daemon): # test must not share a supervisor or a registry, and neither may reach # the operator's own stores. Nothing here runs inference. env["KIN_HOME"] = os.path.join(self.workdir, home) + env["HOME"] = os.path.join(self.workdir, home + "-user") env["KIN_DAEMON_BIN"] = daemon env["KIN_DAEMON_AUTO_EMBED"] = "0" env["KIN_EMBED_BACKEND"] = "cpu" @@ -485,9 +491,20 @@ def env(self, home, daemon): env["KIN_INIT_HISTORY"] = "full" env["KIN_DAEMON_DISABLE_LSP"] = "1" env["KIN_DAEMON_READY_TIMEOUT_SECS"] = str(SERVE_BOUND_SECONDS) - for name in ("KIN_MCP_REPO", "KIN_DIR", "KIN_DAEMON_URL"): + for name in ("KIN_MCP_REPO", "KIN_DIR", "KIN_DAEMON_URL", "KIN_REGISTRY_PATH"): env.pop(name, None) os.makedirs(env["KIN_HOME"], exist_ok=True) + os.makedirs(env["HOME"], exist_ok=True) + # The private configuration home still carries an author, as every + # operator's does. Without one neither build can name who admits the + # working copy, and `kin status` reports it unmeasured (exit 9), so + # the upgrade is never reached. It is the identity the fixture's own + # commits are made under. + gitconfig = os.path.join(env["HOME"], ".gitconfig") + if not os.path.exists(gitconfig): + with open(gitconfig, "w") as handle: + handle.write("[user]\n\tname = kin-published-store-upgrade\n" + "\temail = repro@example.invalid\n") return env def published(self, args, timeout=900): @@ -495,11 +512,17 @@ def published(self, args, timeout=900): env=self.env("home-published", self.published_daemon), timeout=timeout) def under_test(self, args, timeout=SERVE_BOUND_SECONDS + 60, stdout_only=False): - self.cleanup_kin = self.kin + if self.candidate_env is None: + env = self.env("home-under-test", self.daemon) + self.candidate_kin, self.candidate_daemon = prepare_runtime_fixture( + self.kin, self.daemon, env, env["KIN_HOME"]) + self.candidate_env = env + self.cleanup_kin = self.candidate_kin self.cleanup_home = "home-under-test" - self.cleanup_daemon = self.daemon - return run([self.kin] + args, cwd=self.repo, - env=self.env("home-under-test", self.daemon), timeout=timeout, + self.cleanup_daemon = self.candidate_daemon + self.cleanup_env = dict(self.candidate_env) + return run([self.candidate_kin] + args, cwd=self.repo, + env=self.candidate_env, timeout=timeout, stdout_only=stdout_only) def git(self, args): diff --git a/scripts/acceptance/ref_grammar_repro.py b/scripts/acceptance/ref_grammar_repro.py index 061551162..92a65b32e 100755 --- a/scripts/acceptance/ref_grammar_repro.py +++ b/scripts/acceptance/ref_grammar_repro.py @@ -81,6 +81,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -588,6 +589,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self._measured = None self._printed_id = None self._changes = None + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, os.path.join(workdir, "kin-home")) def kin_run(self, args, timeout=600): rc, out, err = run([self.kin] + args, cwd=self.repo(), env=self.env, timeout=timeout) diff --git a/scripts/acceptance/registry_home_isolation.py b/scripts/acceptance/registry_home_isolation.py index 63d596f2e..479082631 100755 --- a/scripts/acceptance/registry_home_isolation.py +++ b/scripts/acceptance/registry_home_isolation.py @@ -63,6 +63,8 @@ import sys import tempfile +from runtime_fixture import prepare_runtime_fixture + PASS = "PASS" FAIL = "FAIL" UNREADABLE = "UNREADABLE" @@ -352,6 +354,7 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): base["HOME"] = self.real_home base.pop("KIN_MCP_REPO", None) base.pop("KIN_DIR", None) + base.pop("KIN_DAEMON_URL", None) # This suite is explicitly about enabled sibling pinning. An inherited # opt-out would make the foreign-home assertion look sealed and disable # the unbindable-sibling control at the same time. @@ -375,6 +378,12 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self.real_env["KIN_REGISTRY_PATH"] = self.real_registry self.scratch_registry = os.path.join(self.scratch_home, "registry.toml") + scratch_pair = prepare_runtime_fixture(kin, daemon, self.env, self.scratch_home) + real_pair = prepare_runtime_fixture(kin, daemon, self.real_env, + self.real_env["KIN_HOME"]) + self.runtime_paths = {self.env["KIN_HOME"]: scratch_pair, + self.real_env["KIN_HOME"]: real_pair} + self.kin = scratch_pair[0] self.repos = {} self.bulk_pin_failures = 0 self.other_location_rows = 0 @@ -414,14 +423,16 @@ def fixture(self, name, env): rc, out = self.git(["commit", "-m", "a python library"], repo) if rc != 0: raise RuntimeError("git commit failed in %s: %s" % (repo, tail(out))) - rc, out = run([self.kin, "init"], cwd=repo, env=env, timeout=900) + rc, out = run([self.runtime_paths[env["KIN_HOME"]][0], "init"], + cwd=repo, env=env, timeout=900) if rc != 0: raise RuntimeError("kin init failed in %s: %s" % (repo, tail(out))) self.repos[name] = repo return repo def stop_daemon(self, repo, env): - run([self.kin, "daemon", "stop"], cwd=repo, env=env, timeout=180) + run([self.runtime_paths[env["KIN_HOME"]][0], "daemon", "stop"], + cwd=repo, env=env, timeout=180) def unbind(self, repo, env): """Stop this repository's daemon, then take its `.kin` away. @@ -734,14 +745,8 @@ def check_2(suite): def check_3(suite): - """KIN_HOME moves the registry and still does NOT move the supervisor. - - The other half of the same seam, and the reason this fix is a split rather - than a move. The supervisor is a machine-level singleton on purpose: one - supervisor holds daemons from several managed homes, and following KIN_HOME - would hide from a pinned session the daemons it shares the box with. - """ - res = Result("3", TICKET, "the supervisor stays machine-level") + """The supervisor is scoped to the selected home and runtime mode.""" + res = Result("3", TICKET, "the supervisor is bounded by KIN_HOME and runtime mode") rc, out = suite.kin_run(["graph", "status"]) if rc != 0: res.unknown("kin graph status exited %d, so no daemon was started to " @@ -750,27 +755,32 @@ def check_3(suite): marks = ("supervisor.pid", "supervisor.port", "supervisor.lock") real_dir = os.path.join(suite.real_home, ".kin") + selected_dir = os.path.join(suite.scratch_home, "services", suite.env["KIN_RUNTIME_MODE"]) + at_selected = [m for m in marks if os.path.exists(os.path.join(selected_dir, m))] at_real = [m for m in marks if os.path.exists(os.path.join(real_dir, m))] at_scratch = [m for m in marks if os.path.exists(os.path.join(suite.scratch_home, m))] - if not at_real and not at_scratch: + if not at_selected and not at_real and not at_scratch: res.unknown("no supervisor file appeared in either home, so where the " "supervisor lives cannot be read from this run") return res + if at_selected: + res.ok("the supervisor is in the selected runtime namespace at %s (%s)" + % (selected_dir, ", ".join(at_selected))) + else: + res.bad("no supervisor file is in the selected runtime namespace at %s" % selected_dir) if at_real: - res.ok("the supervisor is in the real home at %s (%s)" - % (real_dir, ", ".join(at_real))) + res.bad("supervisor files remain in the obsolete global directory %s (%s)" + % (real_dir, ", ".join(at_real))) else: - res.bad("no supervisor file is in the real home at %s, so the supervisor " - "did not stay machine-level" % real_dir) + res.ok("no supervisor file is in the obsolete global directory %s" % real_dir) if at_scratch: - res.bad("the supervisor followed KIN_HOME into %s (%s), which would hide " - "from a pinned session the daemons it shares the box with" + res.bad("supervisor files are outside the selected runtime mode in %s (%s)" % (suite.scratch_home, ", ".join(at_scratch))) else: - res.ok("no supervisor file followed KIN_HOME into %s" % suite.scratch_home) - # The registry, unlike the supervisor, MUST have moved. Without this the - # check would pass on a build where KIN_HOME moved nothing at all. + res.ok("no supervisor file is outside the selected runtime mode in %s" % suite.scratch_home) + # A selected supervisor namespace without an isolated registry would leave + # the cross-repository authority leak intact. if os.path.exists(suite.scratch_registry): res.ok("control: the registry did move with KIN_HOME, to %s" % suite.scratch_registry) @@ -1169,7 +1179,7 @@ def main(argv): if suite is not None: for name, repo in suite.repos.items(): env = suite.real_env if name == "outsider" else suite.env - run([kin, "daemon", "stop"], cwd=repo, env=env, timeout=180) + suite.stop_daemon(repo, env) if not args.keep: shutil.rmtree(workdir, ignore_errors=True) diff --git a/scripts/acceptance/response_budget_elisions.py b/scripts/acceptance/response_budget_elisions.py index b75030628..8ff4b3a19 100755 --- a/scripts/acceptance/response_budget_elisions.py +++ b/scripts/acceptance/response_budget_elisions.py @@ -73,6 +73,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture # An agent profile serves each answer in plain words and leaves out the verdict # codes and evidence blocks this suite reads. Ask every kin it starts for the @@ -712,6 +713,7 @@ def __init__(self, kin, workdir, verbose, daemon=None): self.env["KIN_STORE_LAYOUT"] = "in-tree" self.env["KIN_HOME"] = self.kin_home self.env["KIN_DAEMON_AUTO_EMBED"] = "0" + self.env["KIN_DAEMON_DISABLE_LSP"] = "1" self.env["KIN_VFS_DISABLE"] = "1" # Written against a store that holds its complete history. self.env["KIN_INIT_HISTORY"] = "full" @@ -721,6 +723,8 @@ def __init__(self, kin, workdir, verbose, daemon=None): self.env["KIN_DAEMON_BIN"] = os.path.abspath(daemon) self.repo = None self.owned_repos = set() + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def shutdown(self): """Stop only this run's repositories, including a failed initialization.""" @@ -1246,7 +1250,7 @@ def grade_impact_pages(whole, paged): if (paged.get(key) or []) != (whole.get(key) or []): problems.append("impact pages changed or lost %s" % key) for key in ("degradations", "edge_coverage", "negative"): - if paged.get(key) != whole.get(key): + if without_observation_times(paged.get(key)) != without_observation_times(whole.get(key)): problems.append("impact pages changed the original %s reading" % key) for key in ("verdict", "completeness"): if (paged.get("_kin") or {}).get(key) != (whole.get("_kin") or {}).get(key): diff --git a/scripts/acceptance/runtime_fixture.py b/scripts/acceptance/runtime_fixture.py new file mode 100644 index 000000000..32012d7d9 --- /dev/null +++ b/scripts/acceptance/runtime_fixture.py @@ -0,0 +1,250 @@ +"""Select an isolated acceptance runtime and bind direct native HTTP requests. + +Clean release measurements use the verified canonical pair in the fixture's +KIN_HOME. Debug or dirty pairs remain explicit Development measurements. +This helper is opt-in, so legacy-entrypoint refusal controls stay unmodified. +""" + +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import urllib.parse +import urllib.request + + +IDENTITY_HEADER = "x-kin-runtime-identity" +REPOSITORY_IDENTITY_HEADER = "x-kin-repository-identity" +IDENTITY_FIELDS = ( + "version", "commit_sha", "source_dirty", "source_known", "target", "build_profile", +) +BUILD_FIELDS = ( + "kin_version", "kin_commit", "kin_dirty", "kin_source_known", "kin_target", + "kin_build_profile", +) + + +def _identity(value): + if not isinstance(value, dict): + raise ValueError("runtime identity is not an object") + result = {key: value.get(key) for key in IDENTITY_FIELDS} + for key in ("version", "commit_sha", "target", "build_profile"): + if not isinstance(result[key], str) or not result[key]: + raise ValueError("runtime identity lacks " + key) + for key in ("source_dirty", "source_known"): + if type(result[key]) is not bool: + raise ValueError("runtime identity lacks boolean " + key) + return result + + +def _probe(command, env, cwd=None): + result = subprocess.run(command, env=env, cwd=cwd, capture_output=True, + text=True, timeout=30) + if result.returncode: + raise RuntimeError("runtime metadata probe failed: %s" % + (result.stderr.strip() or result.stdout.strip())) + return json.loads(result.stdout) + + +def selected_cli_identity(kin, env, cwd=None): + metadata = _probe([str(kin), "bench-meta", "--json", "--runtime-identity-only"], + env, cwd) + if metadata.get("schema") != "kin.runtime-build-identity.v1": + raise ValueError("unsupported selected CLI runtime metadata") + return _identity(dict(zip(IDENTITY_FIELDS, + (metadata.get(key) for key in BUILD_FIELDS)))) + + +def runtime_fingerprint(identity, mode, namespace): + identity = _identity(identity) + if mode not in ("managed", "development"): + raise ValueError("unsupported runtime mode") + if not isinstance(namespace, str) or not namespace: + raise ValueError("runtime service has no namespace token") + # Rust serializes struct fields in declaration order, not JSON map order. + body = json.dumps(identity, ensure_ascii=False, separators=(",", ":")).encode() + return hashlib.sha256(body + b"\0" + mode.encode() + b"\0" + + namespace.encode()).hexdigest() + + +def runtime_namespace(kin_home): + """Capture the Unix directory identity used by the native HTTP suites.""" + if os.name != "posix": + raise RuntimeError("native HTTP acceptance namespace capture requires Unix") + home = Path(kin_home) + canonical = home.resolve(strict=True) + descriptor = os.open(home, os.O_RDONLY | os.O_DIRECTORY) + try: + observed = os.fstat(descriptor) + current = home.stat() + if (home.resolve(strict=True) != canonical + or (current.st_dev, current.st_ino) != (observed.st_dev, observed.st_ino)): + raise ValueError("selected fixture home changed during namespace capture") + directory = observed.st_dev.to_bytes(8, "little") + observed.st_ino.to_bytes(8, "little") + return hashlib.sha256(os.fsencode(canonical) + b"\0" + directory).hexdigest() + finally: + os.close(descriptor) + + +def repository_fingerprint(repository): + """Match the native repository claim's independently pinned wire digest.""" + if not isinstance(repository, dict): + raise ValueError("local runtime has no repository claim") + fields = [repository.get(key) for key in ("repository_id", "store_namespace")] + if any(not isinstance(value, str) or not value.strip() for value in fields): + raise ValueError("local runtime repository claim lacks a nonempty identity") + wire = json.dumps(["kin.runtime-repository.v1", *fields], ensure_ascii=False, + separators=(",", ":")).encode("utf-8") + return hashlib.sha256(wire).hexdigest() + + +def _selected_repository_identity(kin, env, cwd): + """Read only the selected fixture's store config, never adopt a peer's root.""" + selected_cwd = Path(cwd or os.getcwd()).resolve(strict=True) + layout = _probe([str(kin), "store", "path", "--json"], env, str(selected_cwd)) + store = layout.get("store") if isinstance(layout, dict) else None + if (not isinstance(layout, dict) or layout.get("schema") != "kin.store-path.v1" + or not isinstance(store, str) or not store + or not isinstance(layout.get("working_dir"), str) + or Path(layout["working_dir"]).resolve(strict=True) != selected_cwd): + raise ValueError("selected CLI did not bind the intended fixture repository store") + store = Path(store) + descriptor = os.open(store, os.O_RDONLY | os.O_DIRECTORY) + try: + retained = os.fstat(descriptor) + namespace = runtime_namespace(store) + observed = store.stat() + if (observed.st_dev, observed.st_ino) != (retained.st_dev, retained.st_ino): + raise ValueError("selected fixture store changed before manifest capture") + manifest_descriptor = os.open("manifest.json", os.O_RDONLY, dir_fd=descriptor) + with os.fdopen(manifest_descriptor, "r", encoding="utf-8") as stream: + manifest = json.load(stream) + repository = {"repository_id": manifest.get("repo_id") if isinstance(manifest, dict) else None, + "store_namespace": namespace} + repository_fingerprint(repository) + current = store.stat() + if (runtime_namespace(store) != namespace + or (current.st_dev, current.st_ino) != (retained.st_dev, retained.st_ino)): + raise ValueError("selected fixture store changed during manifest capture") + return repository + finally: + os.close(descriptor) + + +def _sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def prepare_runtime_fixture(kin, daemon, env, kin_home): + """Return selected paths, leaving the operator's home and actors untouched.""" + kin = Path(kin).resolve(strict=True) + daemon = Path(daemon or kin.with_name("kin-daemon" + kin.suffix)).resolve(strict=True) + home = Path(kin_home).resolve() + home.mkdir(parents=True, exist_ok=True) + env["KIN_HOME"] = str(home) + cli_identity = selected_cli_identity(kin, env) + daemon_metadata = _probe([str(daemon), "--compat-json"], env) + daemon_identity = _identity(daemon_metadata.get("runtime_identity")) + if daemon_identity != cli_identity: + raise ValueError("acceptance CLI and daemon have different build identities") + managed = bool(cli_identity["source_known"] and not cli_identity["source_dirty"] + and cli_identity["build_profile"] == "release" + and re.fullmatch(r"[0-9a-f]{40}", cli_identity["commit_sha"])) + if cli_identity["build_profile"] == "release" and not cli_identity["source_dirty"] and not managed: + raise ValueError("clean release qualification needs a known full source identity") + mode = "managed" if managed else "development" + env["KIN_RUNTIME_MODE"] = mode + hashes = {"cli": _sha256(kin), "daemon": _sha256(daemon)} + if managed: + binary_dir = home / "bin" + binary_dir.mkdir(exist_ok=True) + selected = [] + for source, name, digest in ((kin, "kin" + kin.suffix, hashes["cli"]), + (daemon, "kin-daemon" + daemon.suffix, hashes["daemon"])): + target = binary_dir / name + if target.exists() or target.is_symlink(): + if target.is_symlink() or _sha256(target) != digest: + raise ValueError("fixture canonical runtime already differs: " + str(target)) + else: + shutil.copy2(source, target) + if _sha256(target) != digest: + raise ValueError("fixture canonical copy changed runtime bytes") + selected.append(target) + kin, daemon = selected + env["KIN_DAEMON_BIN"] = str(daemon) + status = _probe([str(kin), "runtime", "status", "--json"], env) + if (status.get("schema") != "kin.runtime-status.v1" + or status.get("mode") != "managed" or status.get("ready") is not True + or status.get("pair_matches") is not True): + raise ValueError("fixture canonical runtime is not Managed ready") + for key, path in (("cli", kin), ("daemon", daemon)): + if (status.get(key, {}).get("sha256") != hashes[key] + or Path(status.get(key, {}).get("path", "")) != path + or _identity(status.get(key, {})) != cli_identity): + raise ValueError("canonical status differs from the independently probed pair") + else: + env["KIN_DAEMON_BIN"] = str(daemon) + receipt = {"schema": "kin.acceptance-runtime-fixture.v1", "mode": mode, + "identity": cli_identity, "kin_home": str(home), + "cli": str(kin), "daemon": str(daemon), "sha256": hashes, + "release_kind": "unknown" if managed else "development"} + (home / "acceptance-runtime.json").write_text(json.dumps(receipt, indent=2) + "\n") + return str(kin), str(daemon) + + +def native_request_headers(kin, env, endpoint, token=None, cwd=None, timeout=5): + """Pin source, home and the intended repository before a local request.""" + address = urllib.parse.urlsplit(endpoint) + if address.scheme != "http" or address.hostname not in ("127.0.0.1", "::1", "localhost"): + raise ValueError("native runtime headers are only for controlled loopback services") + expected = selected_cli_identity(kin, env, cwd) + namespace = runtime_namespace(env["KIN_HOME"]) + path = address.path + if path.startswith("/v2/"): + path = path[3:] + diagnostic = path in ("/health", "/ready", "/readiness", "/auth/rotation", + "/runtime/identity") + repository = None if diagnostic else _selected_repository_identity(kin, env, cwd) + headers = {"Authorization": "Bearer " + token} if token else {} + identity_url = urllib.parse.urlunsplit((address.scheme, address.netloc, + "/runtime/identity", "", "")) + request = urllib.request.Request(identity_url, headers=headers) + with urllib.request.urlopen(request, timeout=timeout) as response: + service = json.load(response) + if service.get("schema") != "kin.runtime-service.v1" or service.get("component") != "daemon": + raise ValueError("unsupported local daemon runtime metadata") + if _identity(service.get("identity")) != expected: + raise ValueError("native service differs from the independently selected CLI") + mode = service.get("mode") + if mode != env.get("KIN_RUNTIME_MODE"): + raise ValueError("native service uses a different runtime mode") + if service.get("namespace") != namespace or runtime_namespace(env["KIN_HOME"]) != namespace: + raise ValueError("native service uses a different or replaced fixture namespace") + headers[IDENTITY_HEADER] = runtime_fingerprint(expected, mode, namespace) + if not diagnostic: + claimed = service.get("repository") + repository_fingerprint(claimed) + if claimed != repository: + raise ValueError("native service uses a different fixture repository or store") + if _selected_repository_identity(kin, env, cwd) != repository: + raise ValueError("intended fixture repository or store changed during metadata capture") + headers[REPOSITORY_IDENTITY_HEADER] = repository_fingerprint(repository) + return headers + + +def fixture_daemon_metadata(kin, env, cwd=None): + """Identify an opt-in controlled HTTP peer used by consumer contract tests.""" + mode = env["KIN_RUNTIME_MODE"] + if mode not in ("managed", "development"): + raise ValueError("unsupported fixture runtime mode") + return {"schema": "kin.runtime-service.v1", "component": "daemon", + "identity": selected_cli_identity(kin, env, cwd), "mode": mode, + "namespace": runtime_namespace(env["KIN_HOME"]), "pid": os.getpid(), + "repository": _selected_repository_identity(kin, env, cwd) if cwd is not None else None} diff --git a/scripts/acceptance/same_owner_call_repro.py b/scripts/acceptance/same_owner_call_repro.py index f8411f375..a75de3da2 100755 --- a/scripts/acceptance/same_owner_call_repro.py +++ b/scripts/acceptance/same_owner_call_repro.py @@ -58,6 +58,7 @@ import sys import tempfile import time +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -305,6 +306,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self.env["KIN_DAEMON_BIN"] = daemon self._repos = {} self.owned_repos = set() + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, os.path.join(workdir, "kin-home")) def shutdown(self): """Stop only this run's repositories, including a failed initialization.""" diff --git a/scripts/acceptance/source_view_authority_opens.py b/scripts/acceptance/source_view_authority_opens.py index d600d23d7..83242ccdc 100755 --- a/scripts/acceptance/source_view_authority_opens.py +++ b/scripts/acceptance/source_view_authority_opens.py @@ -61,6 +61,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -209,6 +210,7 @@ def run_arm(kin, root, file_count, home): env["KIN_EMBED_BACKEND"] = "cpu" env["KIN_DAEMON_AUTO_EMBED"] = "0" env["RUST_LOG"] = "kin_core=info,kin_daemon=info" + kin, _ = prepare_runtime_fixture(kin, None, env, home) subprocess.call([kin, "init"], cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) log = os.path.join(root, ".kin", "daemon.log") diff --git a/scripts/acceptance/test_acceptance_budget_contracts.py b/scripts/acceptance/test_acceptance_budget_contracts.py index b26a6d121..0a58bbe2f 100644 --- a/scripts/acceptance/test_acceptance_budget_contracts.py +++ b/scripts/acceptance/test_acceptance_budget_contracts.py @@ -26,6 +26,21 @@ def test_advertised_compaction_restores_exact_safety_readings(self): 'per-entity verdicts at {"generation":2}; current inputs') self.assertEqual(answer['negative']['advice'], '{negative.subject} at {_kin.graph_as_of}; {negative.trust_reason}') + clause_answer = { + '_kin': {'source_derivation': {'identity': 'current'}, + 'graph_as_of': {'generation': 2}}, + 'negative': { + 'subject': 'all direct call and non-call references to Target', + 'trust_reason': 'local_binding_unproven: prior-local binding evidence could not be validated; reference_completion_unmeasured: non-call completion is unmeasured', + 'advice': '{negative.subject} at {_kin.graph_as_of}; local_binding_unproven; reference_completion_unmeasured ({negative.trust_reason})', + }, + } + clause_restored = restore_compacted(copy.deepcopy(clause_answer), compacted) + self.assertEqual( + clause_restored['negative']['advice'], + 'all direct call and non-call references to Target at {"generation":2}; ' + + clause_answer['negative']['trust_reason'], + ) for changed in ( dict(answer, negative={'advice': '{negative.subject}'}), dict(answer, negative=dict(answer['negative'], advice='unadvertised content')), diff --git a/scripts/acceptance/test_magic_repro_case16.py b/scripts/acceptance/test_magic_repro_case16.py index 73fbe2cb2..c2fd84a49 100644 --- a/scripts/acceptance/test_magic_repro_case16.py +++ b/scripts/acceptance/test_magic_repro_case16.py @@ -260,7 +260,8 @@ def test_wait_reads_only_independent_status_and_keeps_one_deadline(self): kin.mkdir() (kin / "daemon.port").write_text("4219\n") (kin / "daemon.token").write_text("fixture-token") - suite = m.Suite("unused", repo) + with mock.patch.object(m, "prepare_runtime_fixture", return_value=("unused", "unused-daemon")): + suite = m.Suite("unused", repo) suite.kin_run = mock.Mock(return_value=(0, json.dumps(self.graph(settled=False)), "")) suite.mcp = mock.Mock(side_effect=AssertionError("readiness must not query a verdict")) elapsed = [0.0] @@ -268,6 +269,7 @@ def sleep(seconds): elapsed[0] += seconds with mock.patch.object(m.time, "monotonic", side_effect=lambda: elapsed[0]), \ mock.patch.object(m.time, "sleep", side_effect=sleep), \ + mock.patch.object(m, "native_request_headers", return_value={"Authorization": "Bearer fixture-token"}), \ mock.patch.object(m.urllib.request, "urlopen", side_effect=lambda *a, **k: io.StringIO(json.dumps(self.work()))): with self.assertRaisesRegex(RuntimeError, "did not settle in 0.5s"): diff --git a/scripts/acceptance/test_run_root_retention.py b/scripts/acceptance/test_run_root_retention.py index 7b4f1f6ed..4593b6c13 100755 --- a/scripts/acceptance/test_run_root_retention.py +++ b/scripts/acceptance/test_run_root_retention.py @@ -89,6 +89,12 @@ def _load(name): "stopped": [], "all_stopped": True} +def prepare_cleanup_stub(kin, daemon, env, kin_home): + """Keep fixture isolation without probing the cleanup-only CLI stub.""" + env["KIN_HOME"] = str(kin_home) + return kin, daemon + + class StopConfirmed(unittest.TestCase): """Every suite's own stop_confirmed reads the product's report the same way.""" @@ -219,7 +225,8 @@ def check(suite, ident=ident, status=statuses[index], first=index == 0): checks.append((ident, check)) argv = ["--kin", str(self.kin), "--daemon", str(self.kin), "--json", str(self.report)] + (["--keep"] if keep else []) - with mock.patch.object(trace, "CHECKS", checks): + with mock.patch.object(trace, "CHECKS", checks), \ + mock.patch.object(trace, "prepare_runtime_fixture", prepare_cleanup_stub): with contextlib.redirect_stdout(io.StringIO()): code = trace.main(argv) return code, self.read_report() @@ -281,7 +288,8 @@ def test_trace_spine_keep_keeps_the_root(self): def test_trace_spine_a_failed_init_is_still_owned(self): self.init_fails() - suite = trace.Suite(str(self.kin), str(self.scratch)) + with mock.patch.object(trace, "prepare_runtime_fixture", prepare_cleanup_stub): + suite = trace.Suite(str(self.kin), str(self.scratch)) with self.assertRaises(RuntimeError): suite.repo() self.assertIn(os.path.join(str(self.scratch), "sessions"), suite.owned_repos) @@ -409,6 +417,7 @@ def run_refusal(kin, env, work, port, timeout=180): "--json", str(self.report)] + (["--keep"] if keep else []) with contextlib.ExitStack() as stack: for name, stub in (("build_store", build_store), + ("prepare_runtime_fixture", prepare_cleanup_stub), ("fetch_payload", fetch_payload), ("namespace_of", namespace_of), ("run_refusal", run_refusal)): @@ -515,6 +524,7 @@ def check(suite, ident=str(index), status=status): argv += ["--keep"] if keep else [] argv += ["--workdir", str(workdir)] if workdir else [] with mock.patch.object(budget, "CHECKS", checks), \ + mock.patch.object(budget, "prepare_runtime_fixture", prepare_cleanup_stub), \ mock.patch.object(budget.Suite, "fixture", fixture), \ contextlib.redirect_stdout(io.StringIO()), \ contextlib.redirect_stderr(io.StringIO()): @@ -576,7 +586,8 @@ def test_response_budget_an_explicit_workdir_is_never_removed(self): def test_response_budget_a_failed_init_is_still_owned(self): self.init_fails() - suite = budget.Suite(str(self.kin), str(self.scratch), False) + with mock.patch.object(budget, "prepare_runtime_fixture", prepare_cleanup_stub): + suite = budget.Suite(str(self.kin), str(self.scratch), False) with self.assertRaises(budget.SetupError): suite.fixture() self.assertIn(os.path.join(str(self.scratch), "fixture-" + suite.run_id), diff --git a/scripts/acceptance/test_runtime_fixture.py b/scripts/acceptance/test_runtime_fixture.py new file mode 100644 index 000000000..2bb5cc2c7 --- /dev/null +++ b/scripts/acceptance/test_runtime_fixture.py @@ -0,0 +1,341 @@ +"""Consumer contract tests. No product daemon or operator home is used.""" + +import copy +import hashlib +import io +import json +from pathlib import Path +import tempfile +import unittest +import urllib.error +import urllib.request +from unittest.mock import patch + +from runtime_fixture import (BUILD_FIELDS, IDENTITY_FIELDS, IDENTITY_HEADER, + REPOSITORY_IDENTITY_HEADER, fixture_daemon_metadata, + native_request_headers, prepare_runtime_fixture, + repository_fingerprint, runtime_fingerprint, + runtime_namespace) + + +IDENTITY = dict(zip(IDENTITY_FIELDS, ( + "0.8.7", "0123456789abcdef0123456789abcdef01234567", False, True, + "aarch64-apple-darwin", "release", +))) + + +class RuntimeFixtureTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.cli = self.root / "source" / "kin" + self.daemon = self.cli.with_name("kin-daemon") + self.cli.parent.mkdir() + self.cli.write_bytes(b"selected-cli-bytes") + self.daemon.write_bytes(b"selected-daemon-bytes") + self.home = self.root / "fixture-home" + self.env = {} + self.identity = copy.deepcopy(IDENTITY) + self.commands = [] + self.repo = self.root / "fixture-repo" + self.repo.mkdir() + self.store = self.root / "selected-store" + self.store.mkdir() + (self.store / "manifest.json").write_text(json.dumps({"repo_id": "legacy/名"})) + + def probe(self, command, env, cwd=None): + self.commands.append(command) + if "bench-meta" in command: + return dict(zip(BUILD_FIELDS, self.identity.values()), + schema="kin.runtime-build-identity.v1") + if "--compat-json" in command: + return {"runtime_identity": self.identity} + if command[1:] == ["store", "path", "--json"]: + self.assertEqual(cwd, str(self.repo)) + return {"schema": "kin.store-path.v1", "store": str(self.store), + "working_dir": str(self.repo), "external": True} + self.assertEqual(command[1:], ["runtime", "status", "--json"]) + self.assertEqual(env["KIN_HOME"], str(self.home)) + self.assertEqual(env["KIN_RUNTIME_MODE"], "managed") + status = {"schema": "kin.runtime-status.v1", "mode": "managed", + "ready": True, "pair_matches": True} + for key, name in (("cli", "kin"), ("daemon", "kin-daemon")): + path = self.home / "bin" / name + status[key] = dict(self.identity, path=str(path), + sha256=hashlib.sha256(path.read_bytes()).hexdigest()) + return status + + def test_managed_fixture_copies_verified_pair_and_checks_canonical_status(self): + with patch("runtime_fixture._probe", side_effect=self.probe): + cli, daemon = prepare_runtime_fixture(self.cli, self.daemon, self.env, self.home) + self.assertEqual(Path(cli).read_bytes(), self.cli.read_bytes()) + self.assertEqual(Path(daemon).read_bytes(), self.daemon.read_bytes()) + self.assertEqual(self.env["KIN_DAEMON_BIN"], daemon) + self.assertEqual(len(self.commands), 3) + receipt = json.loads((self.home / "acceptance-runtime.json").read_text()) + self.assertEqual(receipt["mode"], "managed") + self.assertEqual(receipt["release_kind"], "unknown") + + def test_development_fixture_never_claims_managed_or_copies_pair(self): + self.identity.update(source_dirty=True, build_profile="debug") + with patch("runtime_fixture._probe", side_effect=self.probe): + cli, daemon = prepare_runtime_fixture(self.cli, self.daemon, self.env, self.home) + self.assertEqual(cli, str(self.cli)) + self.assertEqual(daemon, str(self.daemon)) + self.assertEqual(self.env["KIN_RUNTIME_MODE"], "development") + self.assertFalse((self.home / "bin").exists()) + self.assertEqual(len(self.commands), 2) + + def test_clean_unknown_release_is_refused(self): + self.identity["source_known"] = False + with patch("runtime_fixture._probe", side_effect=self.probe): + with self.assertRaisesRegex(ValueError, "known full source"): + prepare_runtime_fixture(self.cli, self.daemon, self.env, self.home) + self.assertFalse((self.home / "bin").exists()) + + def test_mismatched_pair_is_refused_before_copy(self): + metadata = dict(zip(BUILD_FIELDS, self.identity.values()), + schema="kin.runtime-build-identity.v1") + wrong = dict(self.identity, commit_sha="f" * 40) + with patch("runtime_fixture._probe", side_effect=[metadata, {"runtime_identity": wrong}]): + with self.assertRaisesRegex(ValueError, "different build identities"): + prepare_runtime_fixture(self.cli, self.daemon, self.env, self.home) + self.assertFalse((self.home / "bin").exists()) + + def test_existing_different_canonical_bytes_are_not_overwritten(self): + target = self.home / "bin" / "kin" + target.parent.mkdir(parents=True) + target.write_bytes(b"another-fixture-cli") + with patch("runtime_fixture._probe", side_effect=self.probe): + with self.assertRaisesRegex(ValueError, "already differs"): + prepare_runtime_fixture(self.cli, self.daemon, self.env, self.home) + self.assertEqual(target.read_bytes(), b"another-fixture-cli") + + def test_fingerprint_matches_independent_wire_format_vector(self): + scrambled = dict(reversed(tuple(IDENTITY.items()))) + value = runtime_fingerprint(scrambled, "managed", "namespace-test-token") + self.assertEqual(value, "eb3e41afeee8714da6f0157260842e1c9a6fc57f02e98fc9ab3bc0869090435d") + self.assertNotEqual(value, runtime_fingerprint(scrambled, "development", "namespace-test-token")) + self.assertNotEqual(value, runtime_fingerprint(scrambled, "managed", "other-namespace")) + + def service(self, **overrides): + self.home.mkdir(exist_ok=True) + return dict({"schema": "kin.runtime-service.v1", "component": "daemon", + "identity": self.identity, "mode": "managed", "pid": 1, + "namespace": runtime_namespace(self.home), + "repository": {"repository_id": "legacy/名", + "store_namespace": runtime_namespace(self.store)}}, **overrides) + + def headers(self, service, endpoint="/lsp/sweep/status", on_metadata=None): + def reply(*args, **kwargs): + if on_metadata: + on_metadata() + return io.BytesIO(json.dumps(service).encode()) + + with patch("runtime_fixture._probe", side_effect=self.probe), \ + patch("runtime_fixture.urllib.request.urlopen", + side_effect=reply) as opened: + headers = native_request_headers(self.cli, {"KIN_RUNTIME_MODE": "managed", + "KIN_HOME": str(self.home)}, + "http://127.0.0.1:12345" + endpoint, + token="fixture-token", cwd=self.repo) + self.assertEqual(opened.call_args.args[0].full_url, + "http://127.0.0.1:12345/runtime/identity") + return headers + + def test_positive_native_request_verifies_selected_cli_before_deriving_header(self): + headers = self.headers(self.service()) + self.assertEqual(headers["Authorization"], "Bearer fixture-token") + self.assertEqual(headers[IDENTITY_HEADER], runtime_fingerprint( + IDENTITY, "managed", runtime_namespace(self.home))) + self.assertEqual(headers[REPOSITORY_IDENTITY_HEADER], repository_fingerprint( + {"repository_id": "legacy/名", "store_namespace": runtime_namespace(self.store)})) + self.assertEqual(self.commands[0][1:], + ["bench-meta", "--json", "--runtime-identity-only"]) + + def test_repository_fingerprint_matches_independent_utf8_wire_vector(self): + claim = {"store_namespace": "namespace-token", "repository_id": "legacy/名"} + # Fixed bytes hashed independently of this helper's JSON serializer. + expected = "ce6ab8d12b725df66b4fd95d79f8da5b281e9ece8c2f267abc2ae6346d9cee30" + self.assertEqual(repository_fingerprint(claim), expected) + for altered in (dict(claim, repository_id="other"), dict(claim, store_namespace="other")): + self.assertNotEqual(repository_fingerprint(altered), expected) + + def test_protected_headers_refuse_absent_malformed_or_swapped_repository_claim(self): + for claim in (None, {}, {"repository_id": "legacy/名"}, + {"repository_id": "", "store_namespace": "x"}, + {"repository_id": 1, "store_namespace": "x"}, + {"repository_id": "other", "store_namespace": runtime_namespace(self.store)}, + {"repository_id": "legacy/名", "store_namespace": "other-native-store"}): + with self.subTest(claim=claim), self.assertRaises(ValueError): + self.headers(self.service(repository=claim)) + + def test_selected_manifest_identity_must_be_present_nonempty_string(self): + for manifest in ({}, {"repo_id": ""}, {"repo_id": " "}, {"repo_id": 1}): + (self.store / "manifest.json").write_text(json.dumps(manifest)) + self.home.mkdir(exist_ok=True) + env = {"KIN_HOME": str(self.home), "KIN_RUNTIME_MODE": "managed"} + with self.subTest(manifest=manifest), patch("runtime_fixture._probe", side_effect=self.probe), \ + patch("runtime_fixture.urllib.request.urlopen") as opened: + with self.assertRaises(ValueError): + native_request_headers(self.cli, env, "http://127.0.0.1:12345/locate", cwd=self.repo) + opened.assert_not_called() + + def test_store_config_change_during_metadata_is_refused(self): + def replace_manifest(): + (self.store / "manifest.json").write_text(json.dumps({"repo_id": "changed"})) + with self.assertRaisesRegex(ValueError, "changed during metadata"): + self.headers(self.service(), on_metadata=replace_manifest) + + def test_same_repo_id_with_replaced_native_store_during_metadata_is_refused(self): + def replace_store(): + self.store.rename(self.root / "retained-original-store") + self.store.mkdir() + (self.store / "manifest.json").write_text(json.dumps({"repo_id": "legacy/名"})) + with self.assertRaisesRegex(ValueError, "changed during metadata"): + self.headers(self.service(), on_metadata=replace_store) + + def test_store_replacement_during_anchored_manifest_read_is_refused(self): + import os + original_fdopen = os.fdopen + + def replace_before_read(descriptor, *args, **kwargs): + self.store.rename(self.root / "retained-manifest-store") + self.store.mkdir() + (self.store / "manifest.json").write_text(json.dumps({"repo_id": "other"})) + return original_fdopen(descriptor, *args, **kwargs) + + self.home.mkdir() + env = {"KIN_HOME": str(self.home), "KIN_RUNTIME_MODE": "managed"} + with patch("runtime_fixture._probe", side_effect=self.probe), \ + patch("runtime_fixture.os.fdopen", side_effect=replace_before_read), \ + patch("runtime_fixture.urllib.request.urlopen") as opened: + with self.assertRaisesRegex(ValueError, "changed during manifest"): + native_request_headers(self.cli, env, "http://127.0.0.1:12345/locate", cwd=self.repo) + opened.assert_not_called() + + def test_selected_cli_must_bind_the_intended_working_directory(self): + self.home.mkdir() + env = {"KIN_HOME": str(self.home), "KIN_RUNTIME_MODE": "managed"} + + def wrong_directory(command, env, cwd=None): + value = self.probe(command, env, cwd) + if "store" in command: + value["working_dir"] = str(self.root) + return value + + with patch("runtime_fixture._probe", side_effect=wrong_directory), \ + patch("runtime_fixture.urllib.request.urlopen") as opened: + with self.assertRaisesRegex(ValueError, "intended fixture repository"): + native_request_headers(self.cli, env, "http://127.0.0.1:12345/locate", cwd=self.repo) + opened.assert_not_called() + + def test_warming_diagnostics_do_not_fabricate_repository_pin(self): + for endpoint in ("/health", "/ready", "/readiness", "/auth/rotation", "/runtime/identity", + "/v2/health", "/v2/runtime/identity"): + with self.subTest(endpoint=endpoint): + self.commands.clear() + headers = self.headers(self.service(repository=None), endpoint) + self.assertIn(IDENTITY_HEADER, headers) + self.assertNotIn(REPOSITORY_IDENTITY_HEADER, headers) + self.assertEqual(len(self.commands), 1) + + def test_fixture_peer_metadata_uses_the_selected_actual_store_claim(self): + self.home.mkdir() + env = {"KIN_RUNTIME_MODE": "managed", "KIN_HOME": str(self.home)} + with patch("runtime_fixture._probe", side_effect=self.probe): + metadata = fixture_daemon_metadata(self.cli, env, cwd=self.repo) + self.assertEqual(metadata["repository"], {"repository_id": "legacy/名", + "store_namespace": runtime_namespace(self.store)}) + + def test_header_refuses_wrong_build_mode_namespace_or_hosted_component(self): + for service in (self.service(identity=dict(IDENTITY, version="0.8.6")), + self.service(mode="development"), self.service(namespace=None), + self.service(namespace="different-same-build-fixture"), + self.service(component="hosted-daemon"), self.service(schema="legacy")): + with self.subTest(service=service), self.assertRaises(ValueError): + self.headers(service) + + def test_hosted_url_is_not_given_native_headers(self): + with patch("runtime_fixture._probe") as probe: + with self.assertRaisesRegex(ValueError, "controlled loopback"): + native_request_headers(self.cli, {}, "https://hosted.invalid/locate") + probe.assert_not_called() + + def test_same_path_replacement_changes_namespace(self): + self.home.mkdir() + original = runtime_namespace(self.home) + self.home.rename(self.root / "retained-original-home") + self.home.mkdir() + self.assertNotEqual(original, runtime_namespace(self.home)) + + def test_controlled_peer_requires_handshake_and_refuses_before_handlers(self): + from memory_pressure_refusal import DaemonStub + + service = self.service() + env = {"KIN_HOME": str(self.home), "KIN_RUNTIME_MODE": "managed"} + with DaemonStub(200, {}, runtime_metadata=service) as peer: + url = peer.url + "/mcp/tools/call" + body = json.dumps({"name": "semantic_locate"}).encode() + correct_runtime = runtime_fingerprint(IDENTITY, "managed", runtime_namespace(self.home)) + for headers in ({}, {IDENTITY_HEADER: "wrong-generation"}, + {IDENTITY_HEADER: correct_runtime}, + {IDENTITY_HEADER: correct_runtime, REPOSITORY_IDENTITY_HEADER: "wrong-repository"}): + request = urllib.request.Request(url, data=body, headers=headers) + with self.assertRaises(urllib.error.HTTPError) as error: + urllib.request.urlopen(request, timeout=5) + self.assertEqual(error.exception.code, 409) + error.exception.close() + self.assertEqual(peer.handler_calls, 0) + with patch("runtime_fixture._probe", side_effect=self.probe): + headers = native_request_headers(self.cli, env, url, cwd=self.repo) + request = urllib.request.Request(url, data=body, headers=headers) + with urllib.request.urlopen(request, timeout=5) as response: + self.assertEqual(response.status, 200) + self.assertTrue(json.load(response)["content"]) + self.assertEqual(peer.handler_calls, 1) + + def test_coverage_control_keeps_selected_pair_and_requires_owned_stop(self): + from coverage_read_open import (IN_SYNC, PASS, UNMEASURED_BASIS, + UNREADABLE, check_diverged) + + for all_stopped in (True, False): + with self.subTest(all_stopped=all_stopped): + repo = self.root / ("coverage-" + str(all_stopped)) + (repo / "src").mkdir(parents=True) + original_env = {"KIN_RUNTIME_MODE": "managed", + "KIN_DAEMON_BIN": "selected-canonical-daemon", + "KIN_DAEMON_AUTH_TOKEN": "fixture-token"} + test = self + + class Suite: + def status(self): + return {IN_SYNC: True}, "" + + def status_text(self): + if self.env is original_env: + return "as admitted 0s ago" + test.assertEqual(self.env["KIN_DAEMON_BIN"], original_env["KIN_DAEMON_BIN"]) + test.assertEqual(self.env["KIN_RUNTIME_MODE"], "managed") + test.assertEqual(self.env["KIN_DAEMON_BIND_HOST"], "0.0.0.0") + test.assertEqual(self.env["KIN_DAEMON_REQUIRE_TOKEN"], "0") + test.assertNotIn("KIN_DAEMON_AUTH_TOKEN", self.env) + return UNMEASURED_BASIS + + def kin_run(self, args): + test.assertEqual(args, ["daemon", "stop", "--json"]) + return 0, json.dumps({"schema": "kin.daemon-stop.v1", + "scope": "current-repo", + "all_stopped": all_stopped}) + + suite = Suite() + suite.repo = str(repo) + suite.env = original_env + result = check_diverged(suite) + self.assertEqual(result.status, PASS if all_stopped else UNREADABLE) + self.assertIs(suite.env, original_env) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/acceptance/trace_pages.py b/scripts/acceptance/trace_pages.py index d36a4fbc0..e3aeb58a0 100644 --- a/scripts/acceptance/trace_pages.py +++ b/scripts/acceptance/trace_pages.py @@ -65,6 +65,18 @@ def drain_locate(fetch, requested=None): return _drain_pages(fetch, requested, "entities") +def _clause_codes(reason): + codes = [] + for clause in reason.split("; "): + if ": " not in clause: + return None + code, _ = clause.split(": ", 1) + if not code or not all(c.islower() or c.isdigit() or c == "_" for c in code): + return None + codes.append(code) + return "; ".join(codes) + + def restore_compacted(answer, entries): """Restore only repetitions the page explicitly points to in its own answer.""" if not isinstance(entries, list): @@ -118,14 +130,15 @@ def value(path): for reference in references: if reference not in ("negative.subject", "_kin.graph_as_of", "negative.trust_reason"): raise ValueError("page advice names an unknown compacted reference") - marker = "{%s}" % reference - if reference in used or marker not in text: - raise ValueError("compacted advice does not carry its advertised reference") - used.add(reference) original = value(reference) replacement = (original if isinstance(original, str) else json.dumps(original, separators=(",", ":"), ensure_ascii=False, sort_keys=True)) + codes = _clause_codes(replacement) if reference == "negative.trust_reason" else None + marker = "%s ({%s})" % (codes, reference) if codes else "{%s}" % reference + if reference in used or marker not in text: + raise ValueError("compacted advice does not carry its advertised reference") + used.add(reference) text = text.replace(marker, replacement) parent[key] = text return answer @@ -413,7 +426,7 @@ def fetch(cursor): json.dump({"tool": tool, "arguments": args, "result": result}, transcript) transcript.write("\n") content = result.get("content") or [] - if (result.get("isError") and tool in ("trace_data_flow", "impact_analysis", "semantic_locate")) or not content or "text" not in content[0]: + if (result.get("isError") and tool in ("trace_data_flow", "impact_analysis", "semantic_locate", "find_references", "refs")) or not content or "text" not in content[0]: raise ValueError("trace MCP refused the page: %s" % result) wire = content[0]["text"] return json.loads(wire), wire diff --git a/scripts/acceptance/trace_spine_clipping_repro.py b/scripts/acceptance/trace_spine_clipping_repro.py index 912487277..cbb6d2f65 100755 --- a/scripts/acceptance/trace_spine_clipping_repro.py +++ b/scripts/acceptance/trace_spine_clipping_repro.py @@ -66,6 +66,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -965,6 +966,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self.owned_repos = set() self._elision_arms = None self._caller_trace = None + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def git(self, args, repo): base = ["git", "-c", "core.hooksPath=/dev/null", diff --git a/scripts/acceptance/vcs_read_surfaces_repro.py b/scripts/acceptance/vcs_read_surfaces_repro.py index 5818caf6d..fd07dfe36 100755 --- a/scripts/acceptance/vcs_read_surfaces_repro.py +++ b/scripts/acceptance/vcs_read_surfaces_repro.py @@ -102,6 +102,7 @@ import subprocess import sys import tempfile +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -554,6 +555,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): if daemon: self.env["KIN_DAEMON_BIN"] = daemon self._repo = None + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, os.path.join(workdir, "kin-home")) def kin_run(self, args, timeout=600): rc, out, err = run([self.kin] + args, cwd=self.repo(), env=self.env, timeout=timeout) diff --git a/scripts/acceptance/verdict_limits_repro.py b/scripts/acceptance/verdict_limits_repro.py index 33297247d..1b79dfa07 100755 --- a/scripts/acceptance/verdict_limits_repro.py +++ b/scripts/acceptance/verdict_limits_repro.py @@ -87,6 +87,7 @@ class in `completeness.classes` may read anything but `present` os.environ.setdefault("KIN_MCP_MACHINE_REPLIES", "1") from trace_pages import mcp_references +from runtime_fixture import prepare_runtime_fixture PASS = "PASS" FAIL = "FAIL" @@ -615,6 +616,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False): self.env["KIN_DAEMON_BIN"] = daemon self.repo = None self.payloads = {} + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def log(self, line): if self.verbose: @@ -716,7 +719,13 @@ def references(self, kinds=None, attempts=10, extra=None): payload = None for attempt in range(attempts): self.kin_run(["graph", "status"], repo) - payload = self.mcp(repo, "find_references", args) + try: + payload = self.mcp(repo, "find_references", args) + except (ValueError, RuntimeError): + if attempt + 1 >= attempts: + raise + time.sleep(4) + continue classes = ((payload.get("_kin") or {}).get("completeness") or {}).get("classes") or {} if (all(classes.get(c) == "present" for c in classes if c != "imports") and not readiness_pending(payload)): diff --git a/scripts/acceptance/working_copy_freshness_repro.py b/scripts/acceptance/working_copy_freshness_repro.py index 0705e6180..481481427 100755 --- a/scripts/acceptance/working_copy_freshness_repro.py +++ b/scripts/acceptance/working_copy_freshness_repro.py @@ -69,6 +69,7 @@ import sys import tempfile import time +from runtime_fixture import prepare_runtime_fixture # An agent profile serves each answer in plain words and leaves out the verdict # codes and evidence blocks this suite reads. Ask every kin it starts for the @@ -624,7 +625,7 @@ def grade_admitted_reference(payload, focal_source, caller_source, expected_modu or type(caller.get("site_count")) is not int or caller["site_count"] != 1 or caller.get("sites_absent_reason") is not None - or caller.get("sites_partial_reason") is not None + or caller.get("sites_partial_reason") not in (None, "language_server_edge") or payload.get("candidates") != [] or type(payload.get("unconfirmed_candidates")) is not int or payload["unconfirmed_candidates"] != 0): @@ -672,10 +673,13 @@ def grade_admitted_reference(payload, focal_source, caller_source, expected_modu def reference_completion_qualification(payload, negative, verdict): """A settled Call census does not certify requested non-call completion. - Accept only this recorded gap, with a lower bound and no absence claim. - This qualifies the returned positives; it never certifies their whole set. + Accept only this recorded gap (along with an unproven prior local binding + when LSP has enriched an uncommitted working copy), with a lower bound and + no absence claim. This qualifies the returned positives; it never certifies + their whole set. """ code = "reference_completion_unmeasured" + binding_code = "local_binding_unproven" kinds = payload.get("relation_kinds") inputs = verdict.get("inputs") comp = (payload.get("_kin") or {}).get("completeness") or {} @@ -689,15 +693,28 @@ def reference_completion_qualification(payload, negative, verdict): or inputs.get("absence_gate") != "inconclusive" or inputs.get("call_sites") != "certified"): return False, "the verdict does not separately attribute the non-call completion gap" + unproven_binding = inputs.get("source_derivation") == "inconclusive" + allowed_inputs = ("reference_completion", "absence_gate") + ( + ("source_derivation",) if unproven_binding else ()) if any(reading not in ("certified", "not_applicable") for name, reading in inputs.items() - if name not in ("reference_completion", "absence_gate")): + if name not in allowed_inputs): return False, "another verdict input limits the positive beyond non-call completion" codes = [part.strip() for part in str(verdict.get("limiting_factor") or "").split(";") if part.strip()] + expected_codes = [binding_code, code] if unproven_binding else [code] + expected_reason = ( + binding_code + + ": prior-local binding evidence could not be validated for the checked scope, " + + "so complete dependency or call-shape knowledge is unproven; " + + code + ": non-call completion is unmeasured" + if unproven_binding + else code + ": non-call completion is unmeasured" + ) reason = negative.get("trust_reason") - if (codes != [code] - or reason != code + ": non-call completion is unmeasured" - or code not in str(negative.get("advice") or "")): + advice = str(negative.get("advice") or "") + if (codes != expected_codes + or reason != expected_reason + or any(expected not in advice for expected in expected_codes)): return False, "non-call completion provenance does not match its exact limiting code" if (verdict.get("absence_claim") != "not_applicable" or verdict.get("safe_to_conclude_absent") is not False @@ -864,6 +881,8 @@ def __init__(self, kin, workdir, daemon=None, verbose=False, evidence_dir=None): self.env["KIN_DAEMON_BIN"] = daemon self._repo = None self.unadmitted_path = "linkgraph/predicates.py" + self.kin, self.daemon = prepare_runtime_fixture( + self.kin, daemon, self.env, self.env["KIN_HOME"]) def git(self, args, repo): base = ["git", "-c", "core.hooksPath=/dev/null", @@ -1532,6 +1551,22 @@ def qualify(payload): expect("a settled Call census preserves separately unmeasured Reference completion", qualify(completion), PASS) + unproven_binding = copy.deepcopy(completion) + unproven_binding["_kin"]["verdict"]["inputs"]["source_derivation"] = "inconclusive" + unproven_binding["_kin"]["verdict"]["limiting_factor"] = ( + "local_binding_unproven; reference_completion_unmeasured") + unproven_binding["negative"]["trust_reason"] = ( + "local_binding_unproven: prior-local binding evidence could not be validated for the checked scope, " + "so complete dependency or call-shape knowledge is unproven; " + "reference_completion_unmeasured: non-call completion is unmeasured") + unproven_binding["negative"]["advice"] = ( + "local_binding_unproven, reference_completion_unmeasured") + expect("a settled Call census over an uncommitted LSP-enriched tree qualifies", + qualify(unproven_binding), PASS) + unproven_mismatch = copy.deepcopy(unproven_binding) + unproven_mismatch["_kin"]["verdict"]["limiting_factor"] = "reference_completion_unmeasured" + expect("source_derivation inconclusive without local_binding_unproven fails", + qualify(unproven_mismatch), FAIL) unsettled = copy.deepcopy(completion) unsettled["_kin"]["verdict"]["inputs"].update( call_sites="inconclusive", reference_completion="not_applicable") @@ -1566,6 +1601,13 @@ def admitted(mutate, side=0): mutate(args[side] if side < 3 else args) return grade_admitted_reference(*args) + expect("an admitted use also enriched by the language server passes", + admitted(lambda p: p["references"][0].update(sites_partial_reason="language_server_edge")), + PASS) + expect("an admitted use with incomplete call evidence fails", + admitted(lambda p: p["references"][0].update(sites_partial_reason="incomplete_call_evidence")), + FAIL) + for label, side, mutate in ( ("a focal in another module", 0, lambda p: p["focal_entity"].update(module="linkgraph.other")), diff --git a/scripts/assert-windows-init-contract.sh b/scripts/assert-windows-init-contract.sh index 0effffa60..ad521ec93 100755 --- a/scripts/assert-windows-init-contract.sh +++ b/scripts/assert-windows-init-contract.sh @@ -53,7 +53,7 @@ unset KIN_STORE KIN_STORE_LAYOUT KIN_REGISTRY_PATH # `kin init` starts a daemon for each repository it admits. Reap them rather # than leaving runner processes behind. -trap 'taskkill.exe /F /IM kin-daemon.exe >/dev/null 2>&1 || true' EXIT +trap 'MSYS_NO_PATHCONV=1 taskkill.exe /F /IM kin-daemon.exe >/dev/null 2>&1 || true; rm -rf "$scratch"/*/kin-home/bin 2>/dev/null || true' EXIT # What `kin init` prints when it takes a populated directory in. The colon is # part of the wording: a Git import reports "Uncommitted worktree state:", which @@ -138,6 +138,10 @@ fixture_kin() { shift 2 local home home="$(fixture_home "$dir")" + if [ ! -x "$home/bin/kin.exe" ]; then + mkdir -p "$home/bin" + cp "$kin_bin" "$(dirname "$kin_bin")/kin-daemon.exe" "$home/bin/" + fi if command -v cygpath > /dev/null 2>&1; then home="$(cygpath -w "$home")" fi diff --git a/scripts/check-rc-build-drift.test.mjs b/scripts/check-rc-build-drift.test.mjs index a47689bb4..a1e44af79 100644 --- a/scripts/check-rc-build-drift.test.mjs +++ b/scripts/check-rc-build-drift.test.mjs @@ -79,7 +79,7 @@ test("prose and registry reads are not compiles", () => { // 2. rc-build.yml produces exactly the located archives and the image. test("an archive RC Build stops building, or builds for another target, is reported", () => { - const dropped = replaceOnce(rc, " - os: depot-windows-2022-16\n target: x86_64-pc-windows-msvc\n artifact: kin-windows-x86_64\n cross: false\n", ""); + const dropped = replaceOnce(rc, " - os: depot-windows-2022-16\n target: x86_64-pc-windows-msvc\n artifact: kin-windows-x86_64\n cross: false\n github_os: windows-latest\n", ""); const problems = check(release, dropped); assert.ok(problems.some((problem) => /builds kin-linux-x86_64, .* but scripts\/locate-rc-build\.mjs locates/.test(problem)), problems.join("\n")); const retargeted = replaceOnce(rc, " target: aarch64-unknown-linux-musl\n artifact: kin-linux-aarch64", " target: aarch64-unknown-linux-gnu\n artifact: kin-linux-aarch64"); diff --git a/scripts/docker-entrypoint-runtime.test.mjs b/scripts/docker-entrypoint-runtime.test.mjs new file mode 100644 index 000000000..3386f3257 --- /dev/null +++ b/scripts/docker-entrypoint-runtime.test.mjs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Firelock, LLC + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const entrypoint = fs.readFileSync(path.join(root, "docker-entrypoint.sh"), "utf8"); +const hash = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +function fixture(run) { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "kin-entrypoint-runtime-"))); + try { + const home = path.join(dir, "home"), runtime = path.join(home, ".kin"), workspace = path.join(dir, "workspace"); + const image = path.join(dir, "image"), events = path.join(dir, "events.jsonl"); + fs.mkdirSync(image); fs.mkdirSync(home); fs.mkdirSync(workspace); + for (const name of ["kin", "kin-daemon"]) fs.writeFileSync(path.join(image, name), `#!/usr/bin/env node +const fs=require("node:fs"),path=require("node:path"); +const args=process.argv.slice(2),name=${JSON.stringify(name)}; +const event={name,args,exe:fs.realpathSync(process.argv[1]),home:process.env.HOME,kin_home:process.env.KIN_HOME,mode:process.env.KIN_RUNTIME_MODE,layout:process.env.KIN_STORE_LAYOUT,digest:process.env.KIN_RELEASE_DAEMON_DIGEST_INTERNAL}; +fs.appendFileSync(process.env.FIXTURE_EVENTS,JSON.stringify(event)+"\\n"); +if(name==="kin"&&args[0]==="runtime") {console.log(JSON.stringify({schema:"kin.runtime-status.v1",ready:process.env.FIXTURE_READY!=="0",mode:event.mode}));process.exit(process.env.FIXTURE_READY==="0"?1:0);} +if(name==="kin"&&args[0]==="init") {const repo=path.join(args[1],".kin");fs.mkdirSync(path.join(repo,"kindb"),{recursive:true});for(const file of ["manifest.json","config.toml","version"])fs.writeFileSync(path.join(repo,file),"fixture");} +`, { mode: 0o755 }); + const script = path.join(dir, "entrypoint.sh"); + fs.writeFileSync(script, entrypoint.replaceAll("/usr/local/bin/kin-daemon", path.join(image, "kin-daemon")).replaceAll("/usr/local/bin/kin", path.join(image, "kin"))); + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^_?KIN_|^(?:HOME|USERPROFILE|FIXTURE_)/.test(key))); + Object.assign(env, { HOME: home, KIN_HOME: runtime, KIN_WORKSPACE_DIR: workspace, FIXTURE_EVENTS: events }); + const execute = (over = {}) => { + const result = spawnSync("sh", [script, "--port", "4219"], { env: { ...env, ...over }, encoding: "utf8", timeout: 15_000 }); + const observed = fs.existsSync(events) ? fs.readFileSync(events, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse) : []; + return { ...result, observed }; + }; + const seed = () => { fs.mkdirSync(path.join(runtime, "bin"), { recursive: true }); for (const name of ["kin", "kin-daemon"]) fs.copyFileSync(path.join(image, name), path.join(runtime, "bin", name)); }; + run({ dir, home, runtime, workspace, image, execute, seed }); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +} + +test("both image stages seed root-owned default canonical hardlinks without fixing KIN_HOME", () => { + const docker = fs.readFileSync(path.join(root, "Dockerfile"), "utf8"); + assert.equal((docker.match(/ln \/usr\/local\/bin\/kin \/home\/kin\/\.kin\/bin\/kin/g) ?? []).length, 2); + assert.equal((docker.match(/ln \/usr\/local\/bin\/kin-daemon \/home\/kin\/\.kin\/bin\/kin-daemon/g) ?? []).length, 2); + assert.ok(!/^ENV KIN_HOME=/m.test(docker)); + assert.ok(!/chown[^\n]*\/usr\/local\/bin/.test(docker)); +}); + +test("fresh local runtime verifies image bytes before init and executes the canonical daemon", () => fixture(({ runtime, image, execute }) => { + const result = execute(); assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.observed.map((x) => x.args[0]), ["runtime", "init", "--port"]); + assert.ok(result.observed.every((x) => x.mode === "managed" && x.kin_home === runtime)); + assert.equal(result.observed.at(-1).exe, path.join(runtime, "bin", "kin-daemon")); + for (const name of ["kin", "kin-daemon"]) assert.equal(hash(path.join(runtime, "bin", name)), hash(path.join(image, name))); +})); + +test("matching mounted pair is reused without replacing either executable", () => fixture(({ runtime, seed, execute }) => { + seed(); const before = ["kin", "kin-daemon"].map((name) => fs.statSync(path.join(runtime, "bin", name)).ino); + const result = execute(); assert.equal(result.status, 0, result.stderr); + assert.deepEqual(["kin", "kin-daemon"].map((name) => fs.statSync(path.join(runtime, "bin", name)).ino), before); +})); + +for (const arm of ["partial", "mismatch"]) test(`${arm} mounted pair refuses before init and preserves repository and binary bytes`, () => fixture(({ runtime, workspace, seed, execute }) => { + seed(); const cli = path.join(runtime, "bin", "kin"), daemon = path.join(runtime, "bin", "kin-daemon"); + if (arm === "partial") fs.unlinkSync(daemon); else fs.appendFileSync(cli, "\n// different image\n"); + const before = hash(cli); fs.mkdirSync(path.join(workspace, ".kin")); + const sentinel = path.join(workspace, ".kin", "preserve"); fs.writeFileSync(sentinel, "existing repository"); + const result = execute(); assert.equal(result.status, 1); assert.equal(result.observed.length, 0); + assert.equal(hash(cli), before); assert.equal(fs.readFileSync(sentinel, "utf8"), "existing repository"); + if (arm === "partial") assert.ok(!fs.existsSync(daemon)); +})); + +test("not-ready managed predicate refuses before initialization or daemon launch", () => fixture(({ execute }) => { + const result = execute({ FIXTURE_READY: "0" }); assert.equal(result.status, 1); + assert.deepEqual(result.observed.map((x) => x.args[0]), ["runtime"]); +})); + +test("GCS init preserves selected persistent home and layout while final daemon retains hosted environment and path", () => fixture(({ home, runtime, image, execute }) => { + const result = execute({ KIN_STORAGE: "gcs", KIN_RUNTIME_MODE: "development", KIN_STORE_LAYOUT: "in-tree", KIN_RELEASE_DAEMON_DIGEST_INTERNAL: "fixture-image-digest" }); + assert.equal(result.status, 0, result.stderr); + const init = result.observed.find((x) => x.args[0] === "init"), daemon = result.observed.at(-1); + assert.equal(init.home, home); assert.equal(init.kin_home, runtime); assert.equal(init.mode, "managed"); assert.equal(init.layout, "in-tree"); + assert.equal(daemon.exe, path.join(image, "kin-daemon")); assert.equal(daemon.mode, "development"); + assert.equal(daemon.kin_home, runtime); assert.equal(daemon.digest, "fixture-image-digest"); +})); + +test("materialized GCS repository launches original hosted daemon without touching an unrelated managed slot", () => fixture(({ runtime, workspace, image, execute }) => { + fs.mkdirSync(path.join(workspace, ".kin", "kindb"), { recursive: true }); + for (const name of ["manifest.json", "config.toml", "version"]) fs.writeFileSync(path.join(workspace, ".kin", name), "existing"); + fs.mkdirSync(path.join(runtime, "bin"), { recursive: true }); fs.writeFileSync(path.join(runtime, "bin", "kin"), "unrelated managed slot"); + const before = hash(path.join(runtime, "bin", "kin")); + const result = execute({ KIN_STORAGE: "gcs" }); assert.equal(result.status, 0, result.stderr); + assert.equal(result.observed.length, 1); assert.equal(result.observed[0].exe, path.join(image, "kin-daemon")); + assert.equal(hash(path.join(runtime, "bin", "kin")), before); +})); diff --git a/scripts/falsify-installer-archive-binaries.py b/scripts/falsify-installer-archive-binaries.py index 65da87800..006ea4726 100755 --- a/scripts/falsify-installer-archive-binaries.py +++ b/scripts/falsify-installer-archive-binaries.py @@ -9,10 +9,10 @@ extracting an archive and the names the release actually packs into it, and requires `scripts/verify-installer-archive-binaries.py` to fail. -The first probe reproduces the defect itself: the mandatory-daemon assertion -naming a bare `kin-daemon` inside a Windows archive that carries -`kin-daemon.exe`, which aborted the install after the download had already been -fetched and verified. +Supported POSIX platforms must install the mandatory names their archives carry. +Windows POSIX entry points must instead refuse before any download or managed +write and give the official PowerShell command. Windows release packaging still +has its independent mandatory `.exe` authority check. The last two probes are about the guard's own honesty rather than about a mismatched name. A guard that cannot read what the installer asks for, or that @@ -103,33 +103,48 @@ def expect_failure(label: str, expected: str, mutate: Callable[[Path], None]) -> PROBES: tuple[tuple[str, str, Callable[[Path], None]], ...] = ( ( - "the shipped defect: mandatory daemon named without its suffix", - "install.sh failed against a complete kin-windows-x86_64 archive", + "the mandatory daemon assertion names a missing Unix binary", + "install.sh failed against a complete kin-linux-x86_64 archive", lambda tree: poison( tree, "scripts/install.sh", 'if [ ! -f "$EXTRACT_DIR/kin-daemon$BIN_EXT" ]; then', - 'if [ ! -f "$EXTRACT_DIR/kin-daemon" ]; then', + 'if [ ! -f "$EXTRACT_DIR/kin-daemon.unexpected" ]; then', ), ), ( - "the suffix derivation neutered for windows", - "install.sh failed against a complete kin-windows-x86_64 archive", + "the POSIX suffix incorrectly requires Windows binaries", + "install.sh failed against a complete kin-linux-x86_64 archive", lambda tree: poison( tree, "scripts/install.sh", - 'windows) BIN_EXT=".exe" ;;', - 'windows) BIN_EXT="" ;;', + 'BIN_EXT=""', + 'BIN_EXT=".exe"', ), ), ( "binaries installed under names the archive never carried", - "kin-windows-x86_64", + "kin-linux-x86_64", lambda tree: poison( tree, "scripts/install.sh", 'mv "$EXTRACT_DIR/$bin$BIN_EXT" "$KIN_BIN/$bin$BIN_EXT"', - 'mv "$EXTRACT_DIR/$bin$BIN_EXT" "$KIN_BIN/$bin"', + 'mv "$EXTRACT_DIR/$bin$BIN_EXT" "$KIN_BIN/$bin.unexpected"', + ), + ), + ( + "Windows redirect removed", + "official PowerShell redirect", + lambda tree: poison( + tree, "scripts/install.sh", 'if [ "$OS" = windows ]; then', 'if [ "$OS" = unsupported ]; then' + ), + ), + ( + "Windows refusal writes managed directories", + "official PowerShell redirect", + lambda tree: poison( + tree, "scripts/install.sh", 'if [ "$OS" = windows ]; then', + 'if [ "$OS" = windows ]; then\n mkdir -p "$KIN_BIN" "$KIN_LIB"', ), ), ( diff --git a/scripts/falsify-installer-release-assets.py b/scripts/falsify-installer-release-assets.py index 51a28ea8f..381e38cee 100644 --- a/scripts/falsify-installer-release-assets.py +++ b/scripts/falsify-installer-release-assets.py @@ -8,9 +8,9 @@ plants exactly one disagreement between what an install surface asks a release for and what that release publishes, and requires `scripts/verify-installer-release-assets.py` to fail and to name the asset it -could not find. The first probe reproduces the defect that shipped through every -release to date: the Windows leg published only a `.zip` while the POSIX -installer asked every platform, Windows included, for a `.tar.gz`. +could not find. Windows POSIX entry points must redirect before download or +managed mutation, while the ZIP installer surfaces and separately declared +compatibility TAR containers keep their publication and provenance checks. The last two probes are about the guard's own honesty rather than about a missing asset: a new release platform must not arrive unguarded, and a probe @@ -135,8 +135,8 @@ def unmodified_tree_passes(tree: Path) -> None: def windows_tarball_unpublished(tree: Path) -> None: - # The exact state every release shipped in: the POSIX installer asks Windows - # for a tarball and the release publishes only the zip. + # The release's separate compatibility declaration still requires the TAR, + # although the current POSIX installer redirects Windows users to PowerShell. poison( tree, ".github/workflows/release.yml", @@ -207,6 +207,23 @@ def windows_zip_unpublished(tree: Path) -> None: expect_failure("the Windows zip is not published", tree, "kin-windows-x86_64.zip") +def windows_redirect_removed(tree: Path) -> None: + poison(tree, "scripts/install.sh", 'if [ "$OS" = windows ]; then', 'if [ "$OS" = unsupported ]; then') + expect_failure("Windows POSIX entry no longer redirects", tree, "official PowerShell redirect") + + +def windows_redirect_writes_managed_home(tree: Path) -> None: + poison(tree, "scripts/install.sh", 'if [ "$OS" = windows ]; then', + 'if [ "$OS" = windows ]; then\n mkdir -p "$KIN_BIN" "$KIN_LIB"') + expect_failure("Windows refusal mutates managed home", tree, "official PowerShell redirect") + + +def compatibility_declaration_unreadable(tree: Path) -> None: + poison(tree, ".github/workflows/release.yml", 'additionalArchives: ["kin-windows-x86_64.tar.gz"],', + 'additionalArchives: compatibilityArchives(),') + expect_failure("compatibility archives cannot be derived", tree, "compatibility archive declarations") + + def installer_extension_drift(tree: Path) -> None: # The names must be read out of the installer rather than restated here: a # changed extension has to show up as a changed requirement. @@ -389,6 +406,9 @@ def staged_release_assets(tree: Path) -> None: ("windows tarball unattested", windows_tarball_unattested), ("windows tarball uninventoried", windows_tarball_uninventoried), ("windows zip unpublished", windows_zip_unpublished), + ("windows redirect removed", windows_redirect_removed), + ("windows redirect writes managed home", windows_redirect_writes_managed_home), + ("compatibility declaration unreadable", compatibility_declaration_unreadable), ("installer extension drift", installer_extension_drift), ("unguarded release platform", unguarded_release_platform), ("unreadable installer probe", unreadable_installer_probe), diff --git a/scripts/falsify-release-version-guards.py b/scripts/falsify-release-version-guards.py index 988741ca7..51f2e9c07 100755 --- a/scripts/falsify-release-version-guards.py +++ b/scripts/falsify-release-version-guards.py @@ -61,6 +61,12 @@ "Cargo.toml", "server.json", "packages/kin/package.json", + "packages/kin/bin/kin.mjs", + "packages/kin/bin/kin-mcp.mjs", + "packages/kin/lib/resolve.mjs", + "packages/kin/lib/provision.mjs", + "packages/kin-mcp/package.json", + "packages/kin-mcp/package-lock.json", ) diff --git a/scripts/generate-release-rehearsal.py b/scripts/generate-release-rehearsal.py index f934264b1..c8fa4c80d 100644 --- a/scripts/generate-release-rehearsal.py +++ b/scripts/generate-release-rehearsal.py @@ -83,20 +83,8 @@ SOURCE_PATH: ${{ inputs.source_path }} run: | $ErrorActionPreference = "Stop" - & "rehearsal-source/$env:SOURCE_PATH/scripts/test-windows-install-mirror.ps1" -InstallerPath "rehearsal-source/$env:SOURCE_PATH/scripts/install.ps1" -OutputRoot "$env:RUNNER_TEMP/rehearsal/mirror-boundary" - - name: Install the baseline with the candidate Windows installer - if: inputs.proof_mode == 'compatibility-baseline' - shell: pwsh - env: - SOURCE_PATH: ${{ inputs.source_path }} - KIN_VERSION: ${{ inputs.release_tag }} - KIN_NO_SETUP: '1' - run: | - $ErrorActionPreference = "Stop" - $env:KIN_VERSION = $env:KIN_VERSION.TrimStart('v') - & "rehearsal-source/$env:SOURCE_PATH/scripts/install.ps1" - "$HOME\.kin\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Rehearse stable baseline setup and Git author identity + & "rehearsal-source/$env:SOURCE_PATH/scripts/test-windows-install-mirror.ps1" -InstallerPath "rehearsal-source/$env:SOURCE_PATH/scripts/install.ps1" -OutputRoot "$env:RUNNER_TEMP/rehearsal/mirror-boundary" -RetainBaselineArchive + - name: Prepare verified legacy baseline and rehearse its setup if: inputs.proof_mode == 'compatibility-baseline' shell: bash env: @@ -104,6 +92,7 @@ run: | set -euo pipefail python "rehearsal-source/$SOURCE_PATH/scripts/release-rehearsal-compat.py" \ + --baseline-archive "$RUNNER_TEMP/rehearsal/mirror-boundary/kin-windows-x86_64.zip" \ --output "$RUNNER_TEMP/rehearsal/compatibility" ''' PREPARE = r''' - name: Prepare candidate Windows installer and exact metadata diff --git a/scripts/install.ps1 b/scripts/install.ps1 index fc8420a16..8761477b5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -29,6 +29,7 @@ $KinDir = if ($env:KIN_HOME) { } else { Join-Path $HOME ".kin" } +$KinDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($KinDir) $KinBin = Join-Path $KinDir "bin" $KinLib = Join-Path $KinDir "lib" $env:KIN_HOME = $KinDir @@ -224,6 +225,118 @@ function Resolve-ArchiveChecksum { return $UniqueHashes[0] } +function Normalize-KinRuntimePath([string]$Value) { + if ($Value.StartsWith('\\?\UNC\', [StringComparison]::OrdinalIgnoreCase)) { + $Value = '\\' + $Value.Substring(8) + } elseif ($Value.StartsWith('\\?\', [StringComparison]::OrdinalIgnoreCase)) { + $Value = $Value.Substring(4) + } + return [IO.Path]::GetFullPath($Value).TrimEnd('\', '/') +} + +function Test-SelectedRuntimeIdentity($Status, [string]$Version, [string]$SourceSha, [string]$RustTarget) { + if ($null -eq $Status -or $Status.schema -cne "kin.runtime-status.v1" -or + $Status.ready -cne $true -or $Status.mode -cne "managed" -or + $Status.pair_matches -cne $true -or $Status.restart_needed -cne $false) { return $false } + try { + if ((Normalize-KinRuntimePath $Status.kin_home) -ne (Normalize-KinRuntimePath $KinDir) -or + (Normalize-KinRuntimePath $Status.cli.path) -ne (Normalize-KinRuntimePath $ExistingKinExe) -or + (Normalize-KinRuntimePath $Status.daemon.path) -ne (Normalize-KinRuntimePath $ExistingDaemonExe)) { return $false } + } catch { return $false } + foreach ($Component in @($Status.cli, $Status.daemon)) { + if ($null -eq $Component -or $Component.version -cne $Version -or + $Component.commit_sha -cne $SourceSha -or $Component.target -cne $RustTarget -or + $Component.source_dirty -cne $false -or $Component.sha256 -notmatch '^[0-9a-fA-F]{64}$' -or + [string]::IsNullOrEmpty($Component.build_profile)) { return $false } + } + return $Status.cli.build_profile -ceq $Status.daemon.build_profile +} + +function ConvertTo-KinWindowsArgument([string]$Value) { + # Start-Process joins ArgumentList on Windows. Quote each argument using the + # native argv backslash rules, including paths with spaces or trailing slashes. + $Escaped = [regex]::Replace($Value, '(\\*)"', '$1$1\"') + $Escaped = [regex]::Replace($Escaped, '(\\+)$', '$1$1') + return '"' + $Escaped + '"' +} + +function Invoke-KinWindowsProcess( + [string]$Executable, [string[]]$Arguments, [string]$WorkingDirectory, + [switch]$CaptureOutput +) { + $Parameters = @{ + FilePath = $Executable + ArgumentList = @($Arguments | ForEach-Object { ConvertTo-KinWindowsArgument $_ }) + WorkingDirectory = $WorkingDirectory + NoNewWindow = $true + Wait = $true + PassThru = $true + } + $CaptureDirectory = $null + $Child = $null + try { + if ($CaptureOutput) { + $CaptureDirectory = Join-Path ([IO.Path]::GetTempPath()) ('kin-native-output-' + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $CaptureDirectory | Out-Null + $Parameters.RedirectStandardOutput = Join-Path $CaptureDirectory 'stdout.txt' + $Parameters.RedirectStandardError = Join-Path $CaptureDirectory 'stderr.txt' + } + $Child = Start-Process @Parameters + $Child.WaitForExit() + $Child.Refresh() + if ($null -eq $Child.ExitCode -or $Child.Id -le 0) { + throw 'The native child returned without a known exit code and process identity.' + } + $Result = [pscustomobject]@{ ExitCode = [int]$Child.ExitCode; ProcessId = [int]$Child.Id; Stdout = $null; Stderr = $null } + if ($CaptureOutput) { + $Result.Stdout = [IO.File]::ReadAllText($Parameters.RedirectStandardOutput) + $Result.Stderr = [IO.File]::ReadAllText($Parameters.RedirectStandardError) + } + return $Result + } finally { + if ($null -ne $Child) { $Child.Dispose() } + if ($null -ne $CaptureDirectory) { + foreach ($Name in @('stdout.txt', 'stderr.txt')) { + Remove-Item -LiteralPath (Join-Path $CaptureDirectory $Name) -ErrorAction SilentlyContinue + } + try { [IO.Directory]::Delete($CaptureDirectory, $false) } catch { } + } + } +} + +function Invoke-KinWindowsWorkerCleanup( + [string]$CanonicalCli, $Worker, $WorkerResult, [string]$HandoffHash, + [string]$ManagedHome +) { + # Native owns object identity, actor exit, recovery fences and deletion. + # This observation ties a consumed handoff to the child this caller waited for. + try { + if ($null -eq $WorkerResult -or $WorkerResult.ExitCode -isnot [int] -or + $WorkerResult.ProcessId -isnot [int] -or $WorkerResult.ProcessId -le 0) { return $false } + $ClaimPath = Join-Path ([IO.Path]::GetDirectoryName($Worker.worker_executable)) 'claimed' + $ClaimFile = Get-Item -LiteralPath $ClaimPath -ErrorAction Stop + if ($ClaimFile.PSIsContainer -or + ($ClaimFile.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + $ClaimFile.Length -le 0 -or $ClaimFile.Length -gt 65536) { return $false } + $ClaimStream = [IO.File]::Open($ClaimPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + if ($ClaimStream.Length -le 0 -or $ClaimStream.Length -gt 65536) { return $false } + $Reader = [IO.StreamReader]::new($ClaimStream, [Text.Encoding]::UTF8, $true) + try { $Claim = $Reader.ReadToEnd() | ConvertFrom-Json } + finally { $Reader.Dispose() } + } finally { $ClaimStream.Dispose() } + if ($Claim.schema -cne 'kin.windows-update-worker-consumed.v1' -or + $Claim.worker_process.pid -ne $WorkerResult.ProcessId -or + $Claim.handoff_sha256 -cne $HandoffHash) { return $false } + $Cleanup = Invoke-KinWindowsProcess $CanonicalCli @( + 'update', '--cleanup-windows-worker', $Worker.handoff_record + ) $ManagedHome + return $Cleanup.ExitCode -eq 0 + } catch { + return $false + } +} + # ── Detect architecture ───────────────────────────────────────────────── $Arch = $null @@ -253,17 +366,72 @@ Write-Host " ! $NativeWindowsSupportNotice" -ForegroundColor Yellow Write-Host " Details: https://github.com/kinlab-ai/kin/blob/main/docs/windows-wsl2.md" -ForegroundColor DarkGray Write-Host "" -# ── Detect an existing install (reinstall / upgrade) ────────────────── +# ── Existing installs use the native transactional updater ───────────── $PreviousVersion = $null +$LegacyBootstrap = $false $ExistingKinExe = Join-Path $KinBin "kin.exe" -if (Test-Path $ExistingKinExe) { - $PreviousVersion = (& $ExistingKinExe --version 2>$null | Select-Object -First 1) - if ($PreviousVersion) { - Write-Info "Existing install found: kin $PreviousVersion (will be replaced)" - } else { - Write-Info "Existing install found in $KinDir (will be replaced)" +$ExistingDaemonExe = Join-Path $KinBin "kin-daemon.exe" +if ((Test-Path $ExistingKinExe) -or (Test-Path $ExistingDaemonExe)) { + if (!(Test-Path $ExistingKinExe -PathType Leaf) -or + ((Get-Item -LiteralPath $ExistingKinExe).Attributes -band [IO.FileAttributes]::ReparsePoint)) { + Write-Err "An incomplete or invalid managed install exists at $KinBin. No component was replaced." + exit 1 } + if ($env:KIN_VERSION -or $env:KIN_BASE_URL) { + Write-Err "Pinned or mirrored reinstalls cannot replace an existing managed runtime independently." + Write-Err "Use kin update with its verified release pins, or select a separate development binary." + exit 1 + } + $RuntimeProbe = $null + try { + $Probe = Invoke-KinWindowsProcess $ExistingKinExe @('runtime', 'status', '--json') $KinDir -CaptureOutput + $RuntimeProbe = $Probe.Stdout | ConvertFrom-Json + } catch { $RuntimeProbe = $null } + if ($null -ne $RuntimeProbe -and $RuntimeProbe.schema -ceq "kin.runtime-status.v1") { + Write-Info "Updating the existing managed runtime through its native transaction..." + $Prepared = Invoke-KinWindowsProcess $ExistingKinExe @('update', '--prepare-windows-worker') $KinDir -CaptureOutput + if ($Prepared.Stderr) { [Console]::Error.Write($Prepared.Stderr) } + if ($Prepared.ExitCode -ne 0) { exit $Prepared.ExitCode } + $Worker = $Prepared.Stdout | ConvertFrom-Json + if ($Worker.schema -ne "kin.windows-update-worker.v1" -or + ![IO.Path]::IsPathRooted($Worker.worker_executable) -or + ![IO.Path]::IsPathRooted($Worker.handoff_record) -or + $Worker.source_commit_sha -notmatch '^[0-9a-fA-F]{40}$' -or + $Worker.worker_sha256 -notmatch '^[0-9a-fA-F]{64}$' -or + $Worker.version -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$') { + Write-Err "Native Windows worker preparation returned an invalid identity. No component was replaced." + exit 1 + } + $WorkerFile = Get-Item -LiteralPath $Worker.worker_executable + $RecordFile = Get-Item -LiteralPath $Worker.handoff_record + if ($WorkerFile.PSIsContainer -or $RecordFile.PSIsContainer -or + ($WorkerFile.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + ($RecordFile.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + (Get-FileHash -LiteralPath $Worker.worker_executable -Algorithm SHA256).Hash -ne $Worker.worker_sha256) { + Write-Err "Prepared Windows worker identity changed. Refusing to launch it." + exit 1 + } + $HandoffHash = (Get-FileHash -LiteralPath $Worker.handoff_record -Algorithm SHA256).Hash.ToLowerInvariant() + # Waiting through Start-Process preserves native nonzero exits even when + # PowerShell's inherited native-error policy turns failures into exceptions. + $WorkerResult = Invoke-KinWindowsProcess $Worker.worker_executable @( + 'update', '--windows-worker', $Worker.handoff_record, '--apply' + ) $WorkerFile.DirectoryName + $WorkerExit = $WorkerResult.ExitCode + if (-not (Invoke-KinWindowsWorkerCleanup $ExistingKinExe $Worker $WorkerResult $HandoffHash $KinDir)) { + Write-Info 'Native worker cleanup retained pending or changed authority; the update result is unchanged.' + } + if ($WorkerExit -ne 0) { exit $WorkerExit } + $FinalRuntime = Invoke-KinWindowsProcess $ExistingKinExe @('runtime', 'status', '--json') $KinDir -CaptureOutput + if ($FinalRuntime.Stdout) { [Console]::Out.Write($FinalRuntime.Stdout) } + if ($FinalRuntime.Stderr) { [Console]::Error.Write($FinalRuntime.Stderr) } + if ($FinalRuntime.ExitCode -ne 0) { exit $FinalRuntime.ExitCode } + Write-Ok "Managed runtime updated and verified" + exit 0 + } + $LegacyBootstrap = $true + Write-Info "Older managed runtime detected. Staging an official release for native migration..." } # ── Resolve version ───────────────────────────────────────────────────── @@ -289,10 +457,61 @@ $Url = "$BaseUrl/download/v$Version/$Archive" Write-Info "Downloading $Archive..." -$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "kin-install-$(Get-Random)" -New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null +$StageParent = [System.IO.Path]::GetTempPath() +if ($LegacyBootstrap) { + $ManagedRoot = Normalize-KinRuntimePath $KinDir + $StageParentPath = Normalize-KinRuntimePath $StageParent + if ($StageParentPath.Equals($ManagedRoot, [StringComparison]::OrdinalIgnoreCase) -or + $StageParentPath.StartsWith($ManagedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw "Migration staging must be outside the managed Kin home." + } +} +$TmpDir = Join-Path $StageParent "kin-install-$([Guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $TmpDir | Out-Null +$KeepStage = $false +$SavedRuntimeEnvironment = $null try { +if ($LegacyBootstrap) { + $ManagedRoot = Normalize-KinRuntimePath $KinDir + $StageRoot = Normalize-KinRuntimePath $TmpDir + if ($StageRoot.Equals($ManagedRoot, [StringComparison]::OrdinalIgnoreCase) -or + $StageRoot.StartsWith($ManagedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw "Migration staging must be outside the managed Kin home." + } + if ($Version -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$') { + throw "Official release metadata returned an invalid version. No component was replaced." + } + $Prerelease = ($Version -replace '\+.*$', '') -match '-' + $Channel = if ($Prerelease) { 'alpha' } else { 'stable' } + $RustTarget = 'x86_64-pc-windows-msvc' + $ReleaseMetadata = Invoke-RestMethod "https://api.github.com/repos/$GitHubOrg/$GitHubRepo/releases/tags/v$Version" + if ($ReleaseMetadata.tag_name -cne "v$Version" -or $ReleaseMetadata.draft -cne $false -or + $ReleaseMetadata.prerelease -cne $Prerelease) { + throw "Official release metadata does not admit the selected version and channel." + } + $ReleaseMetadata | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath (Join-Path $TmpDir 'release.json') + $TagMetadata = Invoke-RestMethod "https://api.github.com/repos/$GitHubOrg/$GitHubRepo/git/ref/tags/v$Version" + if ($TagMetadata.ref -cne "refs/tags/v$Version") { throw 'Official tag metadata did not identify the exact selected release tag.' } + $TagMetadata | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath (Join-Path $TmpDir 'tag-ref.json') + $TagDepth = 0 + while ($true) { + if ($TagMetadata.object.sha -notmatch '^[0-9a-fA-F]{40}$') { + throw "Official tag metadata did not resolve to a full source commit. No component was replaced." + } + $SourceSha = $TagMetadata.object.sha.ToLowerInvariant() + if ($TagMetadata.object.type -ceq 'commit') { break } + if ($TagMetadata.object.type -cne 'tag') { throw 'Official release tag does not resolve to a commit.' } + if ($TagDepth -ge 5) { throw 'Official tag metadata exceeded the supported annotation depth.' } + $TagDepth++ + $RequestedTagSha = $SourceSha + $TagMetadata = Invoke-RestMethod "https://api.github.com/repos/$GitHubOrg/$GitHubRepo/git/tags/$SourceSha" + if ($TagMetadata.sha -notmatch '^[0-9a-fA-F]{40}$' -or $TagMetadata.sha.ToLowerInvariant() -cne $RequestedTagSha) { + throw 'Official annotation metadata did not identify the requested tag object.' + } + $TagMetadata | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath (Join-Path $TmpDir "tag-$TagDepth.json") + } +} # `irm ... | iex` still has a real ConsoleHost, so first installs launched from # the documented one-liner get live byte/percent feedback. Redirected and CI # hosts skip progress records while preserving the exact same download bytes. @@ -355,8 +574,10 @@ Write-Ok "SHA-256 checksum verified" Write-Info "Installing to $KinDir..." -New-Item -ItemType Directory -Path $KinBin -Force | Out-Null -New-Item -ItemType Directory -Path $KinLib -Force | Out-Null +if (-not $LegacyBootstrap) { + New-Item -ItemType Directory -Path $KinBin -Force | Out-Null + New-Item -ItemType Directory -Path $KinLib -Force | Out-Null +} Expand-Archive -Path (Join-Path $TmpDir $Archive) -DestinationPath $TmpDir -Force @@ -368,6 +589,48 @@ if (-not (Test-Path (Join-Path $TmpDir "kin-daemon.exe"))) { exit 1 } +if ($LegacyBootstrap) { + $StagedCli = Join-Path $TmpDir 'kin.exe' + foreach ($ComponentPath in @($StagedCli, (Join-Path $TmpDir 'kin-daemon.exe'))) { + $ComponentFile = Get-Item -LiteralPath $ComponentPath + if ($ComponentFile.PSIsContainer -or ($ComponentFile.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "The official archive contains unsafe native components. No component was replaced." + } + } + $KeepStage = $true + $SavedRuntimeEnvironment = @{} + foreach ($Name in @('KIN_DIR', 'KIN_DAEMON_BIN', 'KIN_MANAGED_BIN', 'KIN_BINARY_PATH', 'KIN_MCP_KIN_BINARY', 'KIN_DAEMON_URL', 'KIN_RUNTIME_MODE')) { + $SavedRuntimeEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process') + Remove-Item -LiteralPath "Env:$Name" -ErrorAction SilentlyContinue + } + $env:KIN_HOME = $KinDir + $env:KIN_RUNTIME_MODE = 'managed' + Push-Location -LiteralPath $TmpDir + try { + & $StagedCli update --bootstrap-published --channel $Channel ` + --expect-version $Version --expect-sha $SourceSha ` + --expect-archive-sha256 $ActualHash --expect-target $RustTarget ` + --expect-archive-name $Archive --apply + $BootstrapExit = $LASTEXITCODE + } finally { Pop-Location } + if ($BootstrapExit -ne 0) { + Write-Err 'Native migration refused or requires recovery. The installer performed no direct component replacement.' + exit $BootstrapExit + } + $FinalJson = & $ExistingKinExe runtime status --json + $FinalExit = $LASTEXITCODE + $FinalJson -join "`n" | Set-Content -LiteralPath (Join-Path $TmpDir 'final-status.json') + $FinalStatus = $null + try { $FinalStatus = ($FinalJson -join "`n") | ConvertFrom-Json } catch { } + if ($FinalExit -ne 0 -or -not (Test-SelectedRuntimeIdentity $FinalStatus $Version $SourceSha $RustTarget)) { + Write-Err 'Native migration has not produced the selected ready runtime. Completion remains pending.' + exit 1 + } + $KeepStage = $false + Write-Ok 'Managed runtime migrated and verified' + exit 0 +} + # Report the downloaded binary's registry-authority capability before replacing # an installed binary. Windows currently reports the Unix ownership/mode # contract as not applicable; it does not claim to repair Windows ACLs. @@ -400,6 +663,9 @@ if (Test-Path $shimSrc) { $HaveShim = $true } +& (Join-Path $KinBin "kin.exe") runtime status --json +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Ok "Binaries installed (kin, kin-daemon)" if ($HaveVfs -and $HaveShim) { @@ -448,10 +714,19 @@ if (Test-KinTruthy $env:KIN_NO_SETUP) { # ── Cleanup ───────────────────────────────────────────────────────────── } finally { + if ($null -ne $SavedRuntimeEnvironment) { + foreach ($Name in $SavedRuntimeEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($Name, $SavedRuntimeEnvironment[$Name], 'Process') + } + } # `finally` runs for success, terminating errors, and every `exit` above. # Cleanup remains best-effort so a filesystem cleanup error cannot replace # the installer failure that the caller actually needs to diagnose. - Remove-Item -LiteralPath $TmpDir -Recurse -Force -ErrorAction SilentlyContinue + if ($KeepStage) { + Write-Err "Migration is pending. Verified staging files were retained at $TmpDir." + } else { + Remove-Item -LiteralPath $TmpDir -Recurse -Force -ErrorAction SilentlyContinue + } } Write-Host "" diff --git a/scripts/install.sh b/scripts/install.sh index 875e413e8..ab36f2f8b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -29,6 +29,10 @@ umask go-w # ── Config ────────────────────────────────────────────────────────────── KIN_DIR="${KIN_HOME:-${KIN_DIR:-$HOME/.kin}}" +case "$KIN_DIR" in + /*) ;; + *) KIN_DIR="$(pwd)/$KIN_DIR" ;; +esac KIN_BIN="$KIN_DIR/bin" KIN_LIB="$KIN_DIR/lib" export KIN_HOME="$KIN_DIR" @@ -72,6 +76,82 @@ detect_arch() { has_cmd() { command -v "$1" >/dev/null 2>&1; } +# Migration needs parsed official metadata and a parsed final identity. Refuse +# rather than infer those values from text when no JSON parser is available. +json_value() { + json_file="$1" + json_path="$2" + if has_cmd python3; then + python3 -c 'import json,sys +v=json.load(open(sys.argv[1])) +for k in sys.argv[2].split("."): v=v[k] +if not isinstance(v,(str,bool,int)): raise ValueError("expected scalar") +print(str(v).lower() if isinstance(v,bool) else v)' "$json_file" "$json_path" + elif has_cmd node; then + node -e 'let v=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")); for(const k of process.argv[2].split("."))v=v[k]; if(!["string","boolean","number"].includes(typeof v))throw Error("expected scalar"); process.stdout.write(String(v)+"\n")' "$json_file" "$json_path" + elif has_cmd jq; then + jq -r ".$json_path | if type == \"string\" or type == \"boolean\" or type == \"number\" then . else error(\"expected scalar\") end" "$json_file" + else + err "Migrating an older install requires python3, node, or jq to verify official release metadata and runtime identity." + return 1 + fi +} + +fetch_json() { + if has_cmd curl; then + curl -fsSL -H 'Accept: application/vnd.github+json' -H 'User-Agent: Kin-Installer' "$1" -o "$2" + elif has_cmd wget; then + wget -q --header='Accept: application/vnd.github+json' --header='User-Agent: Kin-Installer' "$1" -O "$2" + else + err "Neither curl nor wget found. Install one and retry." + return 1 + fi +} + +physical_runtime_path() ( + case "$1" in /*) ;; *) return 1 ;; esac + runtime_parent=$(dirname "$1") + runtime_name=$(basename "$1") + runtime_parent=$(cd "$runtime_parent" && pwd -P) || return 1 + printf '%s/%s\n' "$runtime_parent" "$runtime_name" +) + +verify_selected_runtime() { + status_file="$1" + for check in 'schema:kin.runtime-status.v1' 'ready:true' 'mode:managed' 'pair_matches:true' 'restart_needed:false'; do + field="${check%%:*}" + wanted="${check#*:}" + [ "$(json_value "$status_file" "$field" 2>/dev/null)" = "$wanted" ] || return 1 + done + reported_home=$(json_value "$status_file" kin_home 2>/dev/null) || return 1 + case "$reported_home" in /*) ;; *) return 1 ;; esac + [ "$(cd "$reported_home" && pwd -P)" = "$MANAGED_ROOT" ] || return 1 + for component in cli daemon; do + [ "$(json_value "$status_file" "$component.version" 2>/dev/null)" = "$VERSION" ] || return 1 + [ "$(json_value "$status_file" "$component.commit_sha" 2>/dev/null)" = "$SOURCE_SHA" ] || return 1 + [ "$(json_value "$status_file" "$component.target" 2>/dev/null)" = "$RUST_TARGET" ] || return 1 + [ "$(json_value "$status_file" "$component.source_dirty" 2>/dev/null)" = false ] || return 1 + json_value "$status_file" "$component.sha256" 2>/dev/null | grep -Eq '^[0-9a-fA-F]{64}$' || return 1 + done + cli_profile=$(json_value "$status_file" cli.build_profile 2>/dev/null) || return 1 + [ -n "$cli_profile" ] && [ "$cli_profile" = "$(json_value "$status_file" daemon.build_profile 2>/dev/null)" ] || return 1 + [ "$(physical_runtime_path "$(json_value "$status_file" cli.path 2>/dev/null)")" = "$MANAGED_ROOT/bin/kin$BIN_EXT" ] || return 1 + [ "$(physical_runtime_path "$(json_value "$status_file" daemon.path 2>/dev/null)")" = "$MANAGED_ROOT/bin/kin-daemon$BIN_EXT" ] || return 1 +} + +INSTALL_STAGE="" +KEEP_STAGE=0 +cleanup_stage() { + if [ -n "$INSTALL_STAGE" ]; then + if [ "$KEEP_STAGE" = 1 ]; then + err "Migration is pending. Verified staging files were retained at $INSTALL_STAGE." + else + rm -rf "$INSTALL_STAGE" + fi + fi +} +trap cleanup_stage EXIT + # Succeeds when $KIN_DIR is a real directory the installing user owns that its # group or other users can write. find does not follow a symlink here, so a # link in that position never matches. @@ -82,18 +162,17 @@ kin_dir_is_shared_write() { # ── Detect platform ──────────────────────────────────────────────────── OS="$(detect_os)" +if [ "$OS" = windows ]; then + err "Native Windows installs and updates require the official PowerShell installer." + err "Open PowerShell and run: irm https://get.kinlab.dev/install.ps1 | iex" + exit 1 +fi ARCH="$(detect_arch)" TARGET="${OS}-${ARCH}" -# Windows release archives carry `.exe`-suffixed binaries; every other platform -# ships bare names. Derive the suffix once from the detected OS and apply it -# everywhere a binary is named after extraction. Naming a bare `kin-daemon` -# under a Windows archive aborted the install after the download had already -# been fetched and verified, which is a worse failure than the 404 it replaced. -case "$OS" in - windows) BIN_EXT=".exe" ;; - *) BIN_EXT="" ;; -esac +# The POSIX installer consumes the macOS and Linux tar archives. PowerShell +# owns native Windows ZIP extraction and its synchronous update worker. +BIN_EXT="" printf '\n' printf ' \033[1;36mKin Installer\033[0m\n' @@ -114,16 +193,36 @@ if ! has_cmd git; then info 'git not found. Kin installs and runs without it, but `kin setup` inside a Git repository and adopting an existing project both require it.' fi -# ── Detect an existing install (reinstall / upgrade) ────────────────── +# ── Existing installs use the native transactional updater ───────────── PREVIOUS_VERSION="" -if [ -x "$KIN_BIN/kin$BIN_EXT" ]; then - PREVIOUS_VERSION=$("$KIN_BIN/kin$BIN_EXT" --version 2>/dev/null | awk '{print $2}') - if [ -n "$PREVIOUS_VERSION" ]; then - info "Existing install found: kin $PREVIOUS_VERSION (will be replaced)" - else - info "Existing install found in $KIN_DIR (will be replaced)" +LEGACY_BOOTSTRAP=0 +if [ -e "$KIN_BIN/kin$BIN_EXT" ] || [ -L "$KIN_BIN/kin$BIN_EXT" ] || [ -e "$KIN_BIN/kin-daemon$BIN_EXT" ] || [ -L "$KIN_BIN/kin-daemon$BIN_EXT" ]; then + if [ ! -x "$KIN_BIN/kin$BIN_EXT" ] || [ -L "$KIN_BIN/kin$BIN_EXT" ]; then + err "An incomplete or invalid managed install exists at $KIN_BIN. No component was replaced." + exit 1 fi + if [ -n "${KIN_VERSION:-}" ] || [ -n "${KIN_BASE_URL:-}" ]; then + err "Pinned or mirrored reinstalls cannot replace an existing managed runtime independently." + err "Use kin update with its verified release pins, or select a separate development binary." + exit 1 + fi + # A typed refusal still identifies a current API whose updater can repair + # the pair. An old release's unknown command must never authorize raw copies. + RUNTIME_PROBE=$("$KIN_BIN/kin$BIN_EXT" runtime status --json 2>/dev/null) || true + if printf '%s\n' "$RUNTIME_PROBE" | grep -Eq '"schema"[[:space:]]*:[[:space:]]*"kin\.runtime-status\.v1"'; then + info "Updating the existing managed runtime through its native transaction..." + "$KIN_BIN/kin$BIN_EXT" update --apply + "$KIN_BIN/kin$BIN_EXT" runtime status --json + if ! is_truthy "${KIN_NO_SETUP:-}"; then + "$KIN_BIN/kin$BIN_EXT" setup --no-interactive + fi + ok "Managed runtime updated and verified" + exit 0 + fi + LEGACY_BOOTSTRAP=1 + ORIGINAL_KIN_HOME="$KIN_DIR" + info "Older managed runtime detected. Staging an official release for native migration..." fi # ── Resolve version ──────────────────────────────────────────────────── @@ -163,8 +262,74 @@ CHECKSUM_URL="${URL}.sha256" info "Downloading $ARCHIVE..." -TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT +if [ "$LEGACY_BOOTSTRAP" = 1 ]; then + MANAGED_ROOT=$(cd "$KIN_DIR" && pwd -P) + STAGE_PARENT=$(cd "${TMPDIR:-/tmp}" && pwd -P) + case "$STAGE_PARENT/" in + "$MANAGED_ROOT/"*) err "Migration staging must be outside the managed Kin home."; exit 1 ;; + esac +fi +INSTALL_STAGE=$(mktemp -d "${TMPDIR:-/tmp}/kin-install.XXXXXXXX") +chmod 700 "$INSTALL_STAGE" +if [ "$LEGACY_BOOTSTRAP" = 1 ]; then + if ! printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then + err "Official release metadata returned an invalid version. No component was replaced." + exit 1 + fi + RELEASE_VERSION="${VERSION%%+*}" + case "$RELEASE_VERSION" in + *-*) CHANNEL=alpha; PRERELEASE=true ;; + *) CHANNEL=stable; PRERELEASE=false ;; + esac + case "$TARGET" in + linux-x86_64) RUST_TARGET=x86_64-unknown-linux-musl ;; + linux-aarch64) RUST_TARGET=aarch64-unknown-linux-musl ;; + macos-x86_64) RUST_TARGET=x86_64-apple-darwin ;; + macos-aarch64) RUST_TARGET=aarch64-apple-darwin ;; + *) err "No supported native migration target for $TARGET."; exit 1 ;; + esac + fetch_json "https://api.github.com/repos/$GITHUB_ORG/$GITHUB_REPO/releases/tags/v${VERSION}" "$INSTALL_STAGE/release.json" + if [ "$(json_value "$INSTALL_STAGE/release.json" tag_name)" != "v$VERSION" ] || + [ "$(json_value "$INSTALL_STAGE/release.json" draft)" != false ] || + [ "$(json_value "$INSTALL_STAGE/release.json" prerelease)" != "$PRERELEASE" ]; then + err "Official release metadata does not admit the selected version and channel." + exit 1 + fi + fetch_json "https://api.github.com/repos/$GITHUB_ORG/$GITHUB_REPO/git/ref/tags/v${VERSION}" "$INSTALL_STAGE/tag-ref.json" + if [ "$(json_value "$INSTALL_STAGE/tag-ref.json" ref)" != "refs/tags/v$VERSION" ]; then + err "Official tag metadata did not identify the exact selected release tag." + exit 1 + fi + TAG_RECORD="$INSTALL_STAGE/tag-ref.json" + TAG_DEPTH=0 + while :; do + SOURCE_SHA=$(json_value "$TAG_RECORD" object.sha) + SOURCE_TYPE=$(json_value "$TAG_RECORD" object.type) + if ! printf '%s\n' "$SOURCE_SHA" | grep -Eq '^[0-9a-fA-F]{40}$'; then + err "Official tag metadata did not resolve to a full source commit. No component was replaced." + exit 1 + fi + SOURCE_SHA=$(printf '%s' "$SOURCE_SHA" | tr '[:upper:]' '[:lower:]') + case "$SOURCE_TYPE" in + commit) break ;; + tag) + if [ "$TAG_DEPTH" -ge 5 ]; then + err "Official tag metadata exceeded the supported annotation depth." + exit 1 + fi + TAG_DEPTH=$((TAG_DEPTH + 1)) + REQUESTED_TAG_SHA="$SOURCE_SHA" + TAG_RECORD="$INSTALL_STAGE/tag-$TAG_DEPTH.json" + fetch_json "https://api.github.com/repos/$GITHUB_ORG/$GITHUB_REPO/git/tags/$SOURCE_SHA" "$TAG_RECORD" + if [ "$(json_value "$TAG_RECORD" sha | tr '[:upper:]' '[:lower:]')" != "$REQUESTED_TAG_SHA" ]; then + err "Official annotation metadata did not identify the requested tag object." + exit 1 + fi + ;; + *) err "Official release tag does not resolve to a commit."; exit 1 ;; + esac + done +fi # Download the archive and its per-artifact checksum. Interactive terminals get # the downloader's live byte/percent meter for the large archive; piped and CI @@ -173,41 +338,42 @@ trap 'rm -rf "$TMPDIR"' EXIT # -a 256 format: " "). Both downloads must succeed. if has_cmd curl; then if [ -t 2 ]; then - curl -fL "$URL" -o "$TMPDIR/$ARCHIVE" + curl -fL "$URL" -o "$INSTALL_STAGE/$ARCHIVE" else - curl -fsSL "$URL" -o "$TMPDIR/$ARCHIVE" + curl -fsSL "$URL" -o "$INSTALL_STAGE/$ARCHIVE" fi # Tolerate a failed checksum fetch here so the explicit, friendly error # below fires (instead of a bare curl error under `set -e`). - curl -fsSL "$CHECKSUM_URL" -o "$TMPDIR/$ARCHIVE.sha256" 2>/dev/null || true + curl -fsSL "$CHECKSUM_URL" -o "$INSTALL_STAGE/$ARCHIVE.sha256" 2>/dev/null || true elif has_cmd wget; then if [ -t 2 ]; then - wget "$URL" -O "$TMPDIR/$ARCHIVE" + wget "$URL" -O "$INSTALL_STAGE/$ARCHIVE" else - wget -q "$URL" -O "$TMPDIR/$ARCHIVE" + wget -q "$URL" -O "$INSTALL_STAGE/$ARCHIVE" fi - wget -q "$CHECKSUM_URL" -O "$TMPDIR/$ARCHIVE.sha256" 2>/dev/null || true + wget -q "$CHECKSUM_URL" -O "$INSTALL_STAGE/$ARCHIVE.sha256" 2>/dev/null || true fi # ── Verify checksum ─────────────────────────────────────────────────── -if [ ! -s "$TMPDIR/$ARCHIVE.sha256" ]; then +if [ ! -s "$INSTALL_STAGE/$ARCHIVE.sha256" ]; then err "Could not download checksum file: $CHECKSUM_URL" err "Refusing to install an unverified download. Aborting." exit 1 fi -EXPECTED=$(cut -d' ' -f1 "$TMPDIR/$ARCHIVE.sha256") -if [ -z "$EXPECTED" ]; then +EXPECTED=$(cut -d' ' -f1 "$INSTALL_STAGE/$ARCHIVE.sha256") +if ! printf '%s\n' "$EXPECTED" | grep -Eq '^[0-9a-fA-F]{64}$'; then err "Checksum file was empty or malformed: $CHECKSUM_URL" err "Refusing to install an unverified download. Aborting." exit 1 fi +EXPECTED=$(printf '%s' "$EXPECTED" | tr '[:upper:]' '[:lower:]') if has_cmd shasum; then - ACTUAL=$(shasum -a 256 "$TMPDIR/$ARCHIVE" | cut -d' ' -f1) + ACTUAL=$(shasum -a 256 "$INSTALL_STAGE/$ARCHIVE" | cut -d' ' -f1) elif has_cmd sha256sum; then - ACTUAL=$(sha256sum "$TMPDIR/$ARCHIVE" | cut -d' ' -f1) + ACTUAL=$(sha256sum "$INSTALL_STAGE/$ARCHIVE" | cut -d' ' -f1) else err "No SHA-256 tool (shasum or sha256sum) found." err "Cannot verify the download integrity. Install one and retry. Aborting." @@ -227,7 +393,9 @@ ok "SHA-256 checksum verified" info "Installing to $KIN_DIR..." -mkdir -p "$KIN_BIN" "$KIN_LIB" +if [ "$LEGACY_BOOTSTRAP" = 0 ]; then + mkdir -p "$KIN_BIN" "$KIN_LIB" +fi # Everything under $KIN_DIR must end owned by the user doing the install. GNU # tar restores the uid/gid recorded in the archive when it runs as root, and @@ -240,14 +408,14 @@ mkdir -p "$KIN_BIN" "$KIN_LIB" # --no-same-owner is the fix, but it is not universal: busybox tar spells it -o # and rejects the long option. Fall back to a plain extraction and repair the # ownership directly, which is the only case where the chown has anything to do. -if ! tar --no-same-owner -xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" 2>/dev/null; then - tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" +if ! tar --no-same-owner -xzf "$INSTALL_STAGE/$ARCHIVE" -C "$INSTALL_STAGE" 2>/dev/null; then + tar xzf "$INSTALL_STAGE/$ARCHIVE" -C "$INSTALL_STAGE" fi # Find the extracted directory (archive contains a subdirectory) -EXTRACT_DIR=$(find "$TMPDIR" -maxdepth 1 -type d -name "kin-*" | head -1) +EXTRACT_DIR=$(find "$INSTALL_STAGE" -mindepth 1 -maxdepth 1 -type d -name "kin-*" | head -1) if [ -z "$EXTRACT_DIR" ]; then - EXTRACT_DIR="$TMPDIR" + EXTRACT_DIR="$INSTALL_STAGE" fi # Only root can be handed foreign ownership by tar, and only root can repair it. @@ -306,6 +474,56 @@ if [ "$OS" = "macos" ]; then fi fi +if [ "$LEGACY_BOOTSTRAP" = 1 ]; then + if [ -L "$EXTRACT_DIR/kin$BIN_EXT" ] || [ ! -f "$EXTRACT_DIR/kin$BIN_EXT" ] || + [ -L "$EXTRACT_DIR/kin-daemon$BIN_EXT" ]; then + err "The official archive contains unsafe native components. No component was replaced." + exit 1 + fi + chmod +x "$EXTRACT_DIR/kin$BIN_EXT" + KEEP_STAGE=1 + BOOTSTRAP_EXIT=0 + ( + unset KIN_DIR KIN_DAEMON_BIN KIN_MANAGED_BIN KIN_BINARY_PATH KIN_MCP_KIN_BINARY KIN_DAEMON_URL + KIN_HOME="$ORIGINAL_KIN_HOME" + KIN_RUNTIME_MODE=managed + export KIN_HOME KIN_RUNTIME_MODE + cd "$EXTRACT_DIR" + "$EXTRACT_DIR/kin$BIN_EXT" update --bootstrap-published \ + --channel "$CHANNEL" --expect-version "$VERSION" --expect-sha "$SOURCE_SHA" \ + --expect-archive-sha256 "$ACTUAL" --expect-target "$RUST_TARGET" \ + --expect-archive-name "$ARCHIVE" --apply + ) || BOOTSTRAP_EXIT=$? + if [ "$BOOTSTRAP_EXIT" -ne 0 ]; then + err "Native migration refused or requires recovery. The installer performed no direct component replacement." + exit "$BOOTSTRAP_EXIT" + fi + STATUS_EXIT=0 + ( + unset KIN_DIR KIN_DAEMON_BIN KIN_MANAGED_BIN KIN_BINARY_PATH KIN_MCP_KIN_BINARY KIN_DAEMON_URL + KIN_HOME="$ORIGINAL_KIN_HOME" + KIN_RUNTIME_MODE=managed + export KIN_HOME KIN_RUNTIME_MODE + "$KIN_BIN/kin$BIN_EXT" runtime status --json + ) > "$INSTALL_STAGE/final-status.json" || STATUS_EXIT=$? + if [ "$STATUS_EXIT" -ne 0 ] || ! verify_selected_runtime "$INSTALL_STAGE/final-status.json"; then + err "Native migration has not produced the selected ready runtime. Completion remains pending." + exit 1 + fi + KEEP_STAGE=0 + if ! is_truthy "${KIN_NO_SETUP:-}"; then + ( + unset KIN_DIR KIN_DAEMON_BIN KIN_MANAGED_BIN KIN_BINARY_PATH KIN_MCP_KIN_BINARY KIN_DAEMON_URL + KIN_HOME="$ORIGINAL_KIN_HOME" + KIN_RUNTIME_MODE=managed + export KIN_HOME KIN_RUNTIME_MODE + "$KIN_BIN/kin$BIN_EXT" setup --no-interactive + ) + fi + ok "Managed runtime migrated and verified" + exit 0 +fi + # The freshly downloaded binary owns the registry-authority contract used by # `kin doctor` and `kin update`. Run its content-free check before replacing # any installed binary. Unsafe existing state is never silently chmodded or @@ -382,6 +600,8 @@ if [ -d "$EXTRACT_DIR/KinNotifier.app" ]; then HAVE_NOTIFIER=1 fi +"$KIN_BIN/kin$BIN_EXT" runtime status --json + ok "Binaries installed (kin, kin-daemon)" if [ "$HAVE_NOTIFIER" = "1" ]; then @@ -521,12 +741,7 @@ case "$OS" in add_bash_to_path fi ;; - linux|windows) - # The windows arm is reached from the MSYS, MINGW, and Cygwin shells, - # whose startup files are the same ones Linux uses. It could not run at - # all until the post-extract path above stopped aborting on those hosts, - # so a Windows install that got this far previously left the CLI off - # PATH with nothing said about it. + linux) if uses_bash; then add_bash_to_path fi diff --git a/scripts/io-lint/allowed-sites.txt b/scripts/io-lint/allowed-sites.txt index e4d242180..8c20dea78 100644 --- a/scripts/io-lint/allowed-sites.txt +++ b/scripts/io-lint/allowed-sites.txt @@ -102,6 +102,7 @@ crates/kin-cli/src/commands/daemon.rs canonical std::path::Path::canonicalize crates/kin-cli/src/commands/daemon.rs install_owned_stop_target std::fs::symlink_metadata crates/kin-cli/src/commands/daemon.rs is_managed_daemon_executable std::path::Path::canonicalize crates/kin-cli/src/commands/daemon.rs managed_daemon_processes std::path::Path::canonicalize +crates/kin-cli/src/commands/daemon.rs replace_install_owned_daemons_for_update std::fs::symlink_metadata crates/kin-cli/src/commands/daemon.rs stop_all_inner std::fs::symlink_metadata crates/kin-cli/src/commands/daemon.rs stop_current_repo_outcome std::path::Path::is_file crates/kin-cli/src/commands/daemon.rs stop_supervisor_identity std::fs::read_to_string @@ -199,6 +200,27 @@ crates/kin-cli/src/commands/init.rs reserve_external_store std::fs::remove_dir crates/kin-cli/src/commands/init.rs reserve_external_store std::path::Path::canonicalize crates/kin-cli/src/commands/init.rs reuse_prepared_store std::path::Path::canonicalize crates/kin-cli/src/commands/init.rs store_already_excluded kin_blobs::authority::Grant::ambient +crates/kin-cli/src/commands/kin_copies.rs canonical std::fs::canonicalize +crates/kin-cli/src/commands/kin_copies.rs cargo_install_index std::fs::read +crates/kin-cli/src/commands/kin_copies.rs cargo_install_index std::fs::read_to_string +crates/kin-cli/src/commands/kin_copies.rs classify std::fs::read_link +crates/kin-cli/src/commands/kin_copies.rs from_environment std::fs::read_dir +crates/kin-cli/src/commands/kin_copies.rs group_live_daemons std::fs::metadata +crates/kin-cli/src/commands/kin_copies.rs is_development_path std::path::Path::is_file +crates/kin-cli/src/commands/kin_copies.rs is_symlink std::fs::symlink_metadata +crates/kin-cli/src/commands/kin_copies.rs npm_package_of std::fs::read +crates/kin-cli/src/commands/kin_copies.rs npm_package_of std::path::Path::is_file +crates/kin-cli/src/commands/kin_copies.rs npm_shim_package std::fs::read +crates/kin-cli/src/commands/kin_copies.rs removal_invocation std::path::Path::is_file +crates/kin-cli/src/commands/kin_copies.rs remove std::fs::remove_file +crates/kin-cli/src/commands/kin_copies.rs remove std::fs::symlink_metadata +crates/kin-cli/src/commands/kin_copies.rs remove std::process::Command::new +crates/kin-cli/src/commands/kin_copies.rs same_file std::fs::metadata +crates/kin-cli/src/commands/kin_copies.rs scan std::fs::read_dir +crates/kin-cli/src/commands/kin_copies.rs scan std::fs::read_link +crates/kin-cli/src/commands/kin_copies.rs scan std::fs::symlink_metadata +crates/kin-cli/src/commands/kin_copies.rs scan std::path::Path::is_dir +crates/kin-cli/src/commands/kin_copies.rs starts_with_script_marker std::fs::File::open crates/kin-cli/src/commands/language_server_release.rs download_to_temp std::fs::File::create crates/kin-cli/src/commands/language_server_release.rs download_to_temp std::fs::remove_file crates/kin-cli/src/commands/language_server_release.rs expand_into_place std::fs::File::create @@ -324,6 +346,8 @@ crates/kin-cli/src/commands/release_orch.rs update_manifest_pin std::fs::read_to crates/kin-cli/src/commands/release_orch.rs update_manifest_pin std::fs::write crates/kin-cli/src/commands/release_orch.rs workspace_root std::path::Path::is_dir crates/kin-cli/src/commands/resolve.rs read_resolution_body std::fs::File::open +crates/kin-cli/src/commands/runtime.rs absolute_path std::path::Path::canonicalize +crates/kin-cli/src/commands/runtime.rs installation_grant kin_blobs::authority::Grant::ambient crates/kin-cli/src/commands/session_run.rs command std::process::Command::new crates/kin-cli/src/commands/session_run.rs discard std::fs::remove_dir_all crates/kin-cli/src/commands/session_run.rs discard std::path::Path::is_symlink @@ -376,6 +400,7 @@ crates/kin-cli/src/commands/setup.rs git_authority_output_with_resolution_policy crates/kin-cli/src/commands/setup.rs has_kin_mcp_config std::fs::read_to_string crates/kin-cli/src/commands/setup.rs has_kin_mcp_config std::path::Path::exists crates/kin-cli/src/commands/setup.rs incomplete_full_uninstall_artifacts std::fs::read_dir +crates/kin-cli/src/commands/setup.rs install_claude_edit_hook std::fs::create_dir_all crates/kin-cli/src/commands/setup.rs install_claude_prompt_hook std::fs::create_dir_all crates/kin-cli/src/commands/setup.rs install_root_identity std::fs::OpenOptions::open crates/kin-cli/src/commands/setup.rs install_root_identity std::fs::symlink_metadata @@ -504,10 +529,12 @@ crates/kin-cli/src/commands/update.rs ensure_managed_dirs std::fs::symlink_metad crates/kin-cli/src/commands/update.rs ensure_managed_dirs std::path::Path::canonicalize crates/kin-cli/src/commands/update.rs ensure_no_incomplete_windows_uninstall std::fs::read_dir crates/kin-cli/src/commands/update.rs ensure_private_updater_temp_container std::fs::DirBuilder::create +crates/kin-cli/src/commands/update.rs executable_build_identity std::fs::File::open +crates/kin-cli/src/commands/update.rs executable_build_identity std::fs::metadata crates/kin-cli/src/commands/update.rs executable_identity_for_process std::fs::metadata crates/kin-cli/src/commands/update.rs file_identity std::fs::symlink_metadata crates/kin-cli/src/commands/update.rs file_identity_from_open_file std::fs::File::metadata -crates/kin-cli/src/commands/update.rs install_staged_bundle_locked_with_hook std::fs::symlink_metadata +crates/kin-cli/src/commands/update.rs install_staged_bundle_locked_with_hooks std::fs::symlink_metadata crates/kin-cli/src/commands/update.rs load_from std::fs::read_to_string crates/kin-cli/src/commands/update.rs load_unattended_deferral std::fs::read crates/kin-cli/src/commands/update.rs normalize_runtime_binding std::path::Path::canonicalize @@ -518,14 +545,20 @@ crates/kin-cli/src/commands/update.rs optional_file_identity std::fs::symlink_me crates/kin-cli/src/commands/update.rs pending_install_identity std::fs::read crates/kin-cli/src/commands/update.rs persist_unattended_deferral std::fs::OpenOptions::open crates/kin-cli/src/commands/update.rs persist_unattended_deferral std::fs::rename +crates/kin-cli/src/commands/update.rs prepare_windows_update_worker std::fs::File::open +crates/kin-cli/src/commands/update.rs prepare_windows_update_worker std::fs::OpenOptions::open crates/kin-cli/src/commands/update.rs private_temp_directory_age std::fs::symlink_metadata crates/kin-cli/src/commands/update.rs private_temp_root_identity std::fs::symlink_metadata crates/kin-cli/src/commands/update.rs process_path_matches_component std::fs::read_link crates/kin-cli/src/commands/update.rs read_journal std::fs::read crates/kin-cli/src/commands/update.rs read_journal std::fs::symlink_metadata -crates/kin-cli/src/commands/update.rs recover_stale_transactions std::path::Path::exists +crates/kin-cli/src/commands/update.rs read_launcher_stamp_path std::fs::File::open +crates/kin-cli/src/commands/update.rs read_launcher_stamp_path std::fs::symlink_metadata +crates/kin-cli/src/commands/update.rs recover_stale_transaction_dirs std::path::Path::exists crates/kin-cli/src/commands/update.rs refuse_new_update_while_restart_marker_exists std::fs::symlink_metadata crates/kin-cli/src/commands/update.rs refuse_restart_marker_before_remote_preflight std::fs::symlink_metadata +crates/kin-cli/src/commands/update.rs refuse_windows_worker_cleanup_with_recovery_fence std::fs::read_dir +crates/kin-cli/src/commands/update.rs refuse_windows_worker_cleanup_with_recovery_fence std::fs::symlink_metadata crates/kin-cli/src/commands/update.rs reject_active_managed_runtime std::path::Path::canonicalize crates/kin-cli/src/commands/update.rs remove_quarantined_private_temp_root std::fs::remove_dir_all crates/kin-cli/src/commands/update.rs remove_unchanged std::fs::symlink_metadata @@ -567,6 +600,8 @@ crates/kin-cli/src/commands/update.rs write_managed_component_atomically std::pa crates/kin-cli/src/commands/update.rs write_staged_component std::fs::File::create crates/kin-cli/src/commands/update_windows.rs capture windows_sys::Win32::Storage::FileSystem::CreateFileW crates/kin-cli/src/commands/update_windows.rs capture_component_generation std::fs::symlink_metadata +crates/kin-cli/src/commands/update_windows.rs capture_handoff_target windows_sys::Win32::Storage::FileSystem::CreateFileW +crates/kin-cli/src/commands/update_windows.rs claim std::fs::OpenOptions::open crates/kin-cli/src/commands/update_windows.rs create std::fs::remove_dir crates/kin-cli/src/commands/update_windows.rs create_current_user_private_file_with_disposition windows_sys::Win32::Storage::FileSystem::CreateFileW crates/kin-cli/src/commands/update_windows.rs create_managed_config_staged_file windows_sys::Win32::Storage::FileSystem::CreateFileW @@ -579,14 +614,21 @@ crates/kin-cli/src/commands/update_windows.rs open_current_user_private_file_exi crates/kin-cli/src/commands/update_windows.rs open_managed_config_for_exact_quarantine_inner windows_sys::Win32::Storage::FileSystem::CreateFileW crates/kin-cli/src/commands/update_windows.rs open_managed_config_for_terminal_observation windows_sys::Win32::Storage::FileSystem::CreateFileW crates/kin-cli/src/commands/update_windows.rs open_path_handle windows_sys::Win32::Storage::FileSystem::CreateFileW +crates/kin-cli/src/commands/update_windows.rs read_record std::fs::metadata +crates/kin-cli/src/commands/update_windows.rs read_record std::fs::read +crates/kin-cli/src/commands/update_windows.rs require_names std::fs::read_dir +crates/kin-cli/src/commands/update_windows.rs require_worker_cleanup_absent std::fs::symlink_metadata crates/kin-cli/src/commands/update_windows.rs revalidate_managed_file_path windows_sys::Win32::Storage::FileSystem::CreateFileW crates/kin-cli/src/commands/update_windows.rs revalidate_visible windows_sys::Win32::Storage::FileSystem::CreateFileW +crates/kin-cli/src/commands/update_windows.rs revalidate_worker_cleanup_object windows_sys::Win32::Storage::FileSystem::CreateFileW crates/kin-cli/src/commands/update_windows.rs seal_staged_bundle std::fs::symlink_metadata crates/kin-cli/src/commands/upgrade.rs remove_legacy_owed_work_records std::fs::remove_file crates/kin-cli/src/commands/upgrade.rs rewrite_upgrade_sidecar std::fs::File::open crates/kin-cli/src/commands/upgrade.rs rewrite_upgrade_sidecar tempfile::NamedTempFile::new_in crates/kin-cli/src/commands/verify.rs verification_command std::process::Command::new +crates/kin-cli/src/commands/workspace_git_remote.rs stage_projection tempfile::TempDir::new crates/kin-cli/src/commands/workspace_graph.rs make_editor_uri std::path::Path::canonicalize +crates/kin-cli/src/commands/workspace_home.rs look_around kin_blobs::authority::Grant::ambient crates/kin-cli/src/commands/workspace_home.rs look_at kin_blobs::authority::Grant::ambient crates/kin-cli/src/commands/workspace_home.rs resolve_location std::path::Path::canonicalize crates/kin-cli/src/commands/workspace_home.rs run_child kin_blobs::authority::Grant::ambient @@ -661,6 +703,7 @@ crates/kin-cli/src/daemon_client/starting_owner.rs inspect std::fs::symlink_meta crates/kin-cli/src/daemon_client/starting_owner.rs inspect std::path::Path::canonicalize crates/kin-cli/src/daemon_client/starting_owner.rs publish_starting_daemon_owner std::path::Path::canonicalize crates/kin-cli/src/daemon_client/starting_owner.rs publish_starting_daemon_owner tempfile::Builder::tempfile_in +crates/kin-cli/src/daemon_client/succession.rs runs_from_managed_generation std::path::Path::canonicalize crates/kin-cli/src/embed_model.rs accumulate_bytes std::fs::DirEntry::file_type crates/kin-cli/src/embed_model.rs accumulate_bytes std::fs::DirEntry::metadata crates/kin-cli/src/embed_model.rs accumulate_bytes std::fs::read_dir @@ -716,6 +759,8 @@ crates/kin-core/src/git_init.rs init_current_commit_from_git_with_hooks kin_blob crates/kin-core/src/git_init.rs refresh_git_coexistence_config kin_blobs::authority::Grant::ambient crates/kin-core/src/git_init.rs require_git_boundary std::fs::symlink_metadata crates/kin-core/src/git_init.rs source_is_shallow gix::open_opts +crates/kin-core/src/git_update.rs prepare_git_update kin_blobs::authority::Grant::ambient +crates/kin-core/src/history_attach.rs holds_an_entry std::fs::read_dir crates/kin-core/src/history_attach.rs new_stage_parent std::fs::create_dir crates/kin-core/src/history_attach.rs read_entries std::fs::read_dir crates/kin-core/src/history_attach.rs reap_abandoned_stages std::fs::read_dir @@ -731,8 +776,6 @@ crates/kin-core/src/history_attach.rs roll_forward std::fs::remove_file crates/kin-core/src/history_attach.rs roll_forward std::fs::rename crates/kin-core/src/history_attach.rs roll_forward std::path::Path::exists crates/kin-core/src/history_attach.rs sync_directory std::fs::File::open -crates/kin-core/src/history_attach.rs verify_nothing_would_be_lost std::fs::read_dir -crates/kin-core/src/history_attach.rs verify_nothing_would_be_lost std::fs::symlink_metadata crates/kin-core/src/history_attach.rs write_journal std::fs::File::create crates/kin-core/src/history_attach.rs write_journal std::fs::rename crates/kin-core/src/hydration_semantics.rs invalidate_for_unversioned_transfer std::fs::remove_file @@ -936,6 +979,8 @@ crates/kin-core/src/registry.rs write_registry_atomically std::fs::rename crates/kin-core/src/registry.rs write_registry_atomically std::fs::write crates/kin-core/src/registry.rs write_registry_atomically_at libc::renameat crates/kin-core/src/repository_origin.rs git_config_origin_url gix::open +crates/kin-core/src/runtime_identity.rs capture cap_std::ambient_authority +crates/kin-core/src/runtime_identity.rs capture std::path::Path::canonicalize crates/kin-core/src/shims.rs ensure_shim_dir std::fs::create_dir_all crates/kin-core/src/shims.rs write_shim std::fs::set_permissions crates/kin-core/src/shims.rs write_shim std::fs::write @@ -1019,6 +1064,7 @@ crates/kin-daemon-spawn/src/lib.rs write_open_transaction std::fs::rename crates/kin-daemon-spawn/src/lib.rs write_open_transaction std::fs::write crates/kin-daemon-spawn/src/lib.rs write_sweep_interruptions std::fs::write crates/kin-daemon-spawn/src/runtime_authority.rs acquire_lifecycle_coordination std::fs::OpenOptions::open +crates/kin-daemon-spawn/src/runtime_authority.rs repository_runtime_authority_is_held std::fs::OpenOptions::open crates/kin-daemon-spawn/src/runtime_authority.rs try_acquire_repository_runtime_authority std::fs::OpenOptions::open crates/kin-daemon-spawn/src/runtime_authority.rs try_acquire_repository_runtime_authority std::path::Path::canonicalize crates/kin-daemon-spawn/src/test_runtime.rs durable_json std::fs::File::open @@ -1047,6 +1093,8 @@ crates/kin-daemon/src/auth_rotation.rs write_window_record std::fs::File::create crates/kin-daemon/src/auth_rotation.rs write_window_record std::fs::create_dir_all crates/kin-daemon/src/auth_rotation.rs write_window_record std::fs::remove_file crates/kin-daemon/src/auth_rotation.rs write_window_record std::fs::rename +crates/kin-daemon/src/bin/kin-daemon.rs require_managed_daemon_authority std::fs::canonicalize +crates/kin-daemon/src/bin/kin-daemon.rs require_managed_daemon_authority std::fs::symlink_metadata crates/kin-daemon/src/bin/kin-daemon.rs resolve_layout std::path::Path::canonicalize crates/kin-daemon/src/bin/kin-daemon.rs resolve_layout std::path::Path::is_dir crates/kin-daemon/src/daemon.rs classify_control_plane std::path::Path::exists @@ -1080,6 +1128,7 @@ crates/kin-daemon/src/entity_drafts.rs scan std::fs::read_dir crates/kin-daemon/src/entity_drafts.rs scan std::fs::symlink_metadata crates/kin-daemon/src/entity_drafts.rs sync_dir std::fs::OpenOptions::open crates/kin-daemon/src/history_schedule.rs kin_binary std::path::Path::is_file +crates/kin-daemon/src/history_schedule.rs start_successor std::process::Command::new crates/kin-daemon/src/language_server_command.rs proxy_rustup_for std::fs::canonicalize crates/kin-daemon/src/language_server_command.rs proxy_rustup_for std::fs::metadata crates/kin-daemon/src/language_server_command.rs run_rustup std::process::Command::new diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 13acecd5a..18e15b900 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -55,6 +55,44 @@ function replaceSpinePin(manifest, from, to) { return manifest.replace(pattern, `$1${to}$2`); } +/** Keep a compatibility launcher and its canonical runtime resolver in lockstep. */ +export function stampNpmVersion(manifest, from, to) { + if (manifest.version !== from && manifest.version !== to) { + throw new Error(`${manifest.name} is ${manifest.version}, expected ${from} or ${to}`); + } + const updated = { ...manifest, version: to }; + if (manifest.name === '@kinlab/kin-mcp') { + updated.dependencies = { ...manifest.dependencies, '@kinlab/kin': to }; + } + return updated; +} + +/** Stamp the npm-generated local development link without inventing registry bytes. */ +export function stampCompatibilityNpmLock(lock, from, to) { + const root = lock.packages?.['']; + const sibling = lock.packages?.['../kin']; + const link = lock.packages?.['node_modules/@kinlab/kin']; + if (lock.lockfileVersion !== 3 || lock.name !== '@kinlab/kin-mcp' || + root?.name !== '@kinlab/kin-mcp' || sibling?.name !== '@kinlab/kin' || + root.devDependencies?.['@kinlab/kin'] !== 'file:../kin' || + link?.link !== true || link.resolved !== '../kin' || + sibling.resolved || sibling.integrity || link.integrity) { + throw new Error('compatibility npm lock must use the generated local canonical development link'); + } + if (lock.version !== from && lock.version !== to) { + throw new Error(`${lock.name} lock is ${lock.version}, expected ${from} or ${to}`); + } + return { + ...lock, + version: to, + packages: { + ...lock.packages, + '': stampNpmVersion(root, from, to), + '../kin': stampNpmVersion(sibling, from, to), + }, + }; +} + /** * The MCP Registry entry at `to`, or a refusal when it names neither version. * @@ -293,16 +331,19 @@ async function main() { for (const path of NPM_MANIFESTS) { const manifest = JSON.parse(await fs.readFile(path, 'utf8')); - if (manifest.version === workingVersion && workingVersion !== target) { - manifest.version = target; - await fs.writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`); - } else if (manifest.version !== target) { - throw new Error( - `${path} is ${manifest.version}, expected ${workingVersion} or ${target}`, - ); + const updated = stampNpmVersion(manifest, workingVersion, target); + if (JSON.stringify(updated) !== JSON.stringify(manifest)) { + await fs.writeFile(path, `${JSON.stringify(updated, null, 2)}\n`); } } + const compatibilityLockPath = 'packages/kin-mcp/package-lock.json'; + const compatibilityLock = JSON.parse(await fs.readFile(compatibilityLockPath, 'utf8')); + const stampedCompatibilityLock = stampCompatibilityNpmLock(compatibilityLock, workingVersion, target); + if (JSON.stringify(stampedCompatibilityLock) !== JSON.stringify(compatibilityLock)) { + await fs.writeFile(compatibilityLockPath, `${JSON.stringify(stampedCompatibilityLock, null, 2)}\n`); + } + const serverPath = 'server.json'; const server = JSON.parse(await fs.readFile(serverPath, 'utf8')); const stamped = stampServerVersion(server, workingVersion, target); diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs index 327a423d2..cdf5f5366 100644 --- a/scripts/prepare-release.test.mjs +++ b/scripts/prepare-release.test.mjs @@ -12,10 +12,94 @@ import { fileURLToPath } from 'node:url'; import { removeChangelogSection, stampServerVersion, + stampNpmVersion, + stampCompatibilityNpmLock, updateWorkspaceLock, upsertChangelogSection, } from './prepare-release.mjs'; +test('compatibility package upgrades cannot retain an older canonical resolver dependency', () => { + const source = { + name: '@kinlab/kin-mcp', version: '0.8.6', + dependencies: { '@kinlab/kin': '0.8.6', preserved: '2.0.0' }, + }; + const upgraded = stampNpmVersion(source, '0.8.6', '0.8.7'); + assert.equal(upgraded.version, '0.8.7'); + assert.equal(upgraded.dependencies['@kinlab/kin'], '0.8.7'); + assert.equal(upgraded.dependencies.preserved, '2.0.0'); + assert.equal(source.dependencies['@kinlab/kin'], '0.8.6'); + assert.deepEqual(stampNpmVersion(upgraded, '0.8.6', '0.8.7'), upgraded); +}); + +test('an already bumped compatibility manifest repairs its stale resolver pin', () => { + const manifest = { name: '@kinlab/kin-mcp', version: '0.8.7', dependencies: { '@kinlab/kin': '0.8.6' } }; + assert.equal(stampNpmVersion(manifest, '0.8.6', '0.8.7').dependencies['@kinlab/kin'], '0.8.7'); + assert.throws(() => stampNpmVersion({ ...manifest, version: 'unrelated' }, '0.8.6', '0.8.7'), /expected/); +}); + +test('compatibility source lock advances both manifests while preserving the local development link', () => { + const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + const source = JSON.parse(fs.readFileSync(path.join(root, 'packages/kin-mcp/package-lock.json'), 'utf8')); + const from = source.version; + const to = '99.0.0'; + const updated = stampCompatibilityNpmLock(source, from, to); + assert.equal(updated.version, to); + assert.equal(updated.packages[''].version, to); + assert.equal(updated.packages[''].dependencies['@kinlab/kin'], to); + assert.equal(updated.packages['../kin'].version, to); + assert.equal(updated.packages[''].devDependencies['@kinlab/kin'], 'file:../kin'); + assert.deepEqual(updated.packages['node_modules/@kinlab/kin'], source.packages['node_modules/@kinlab/kin']); + assert.equal(updated.dependencies, undefined, 'v3 lock must not gain invented registry dependency records'); + assert.equal(source.version, from); + assert.deepEqual(stampCompatibilityNpmLock(updated, from, to), updated); +}); + +test('compatibility lock stamping refuses registry-byte claims and unrelated local versions', () => { + const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + const source = JSON.parse(fs.readFileSync(path.join(root, 'packages/kin-mcp/package-lock.json'), 'utf8')); + for (const property of ['integrity', 'resolved']) { + const changed = structuredClone(source); + changed.packages['../kin'][property] = 'untrusted-registry-claim'; + assert.throws(() => stampCompatibilityNpmLock(changed, source.version, '99.0.0'), /generated local/); + } + const changed = structuredClone(source); + changed.packages['../kin'].version = 'unrelated'; + assert.throws(() => stampCompatibilityNpmLock(changed, source.version, '99.0.0'), /expected/); +}); + +test('a future source version installs offline through the generated lock before registry publication', () => { + const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + const canonical = path.join(root, 'packages/kin'); + const compatibility = path.join(root, 'packages/kin-mcp'); + const lock = JSON.parse(fs.readFileSync(path.join(compatibility, 'package-lock.json'), 'utf8')); + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-source-lock-')); + const sibling = path.join(fixture, 'kin'); + const project = path.join(fixture, 'kin-mcp'); + const version = '99.0.0'; + try { + fs.mkdirSync(sibling); + fs.mkdirSync(project); + for (const directory of ['bin', 'lib']) fs.cpSync(path.join(canonical, directory), path.join(sibling, directory), { recursive: true }); + for (const [source, destination] of [[canonical, sibling], [compatibility, project]]) { + const manifest = JSON.parse(fs.readFileSync(path.join(source, 'package.json'), 'utf8')); + fs.writeFileSync(path.join(destination, 'package.json'), JSON.stringify(stampNpmVersion(manifest, lock.version, version))); + } + fs.writeFileSync(path.join(project, 'package-lock.json'), JSON.stringify(stampCompatibilityNpmLock(lock, lock.version, version))); + const env = { ...process.env, npm_config_cache: path.join(fixture, 'cache'), npm_config_registry: 'http://127.0.0.1:9' }; + execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund', '--offline'], { + cwd: project, env, encoding: 'utf8', shell: process.platform === 'win32', + }); + assert.equal(fs.realpathSync(path.join(project, 'node_modules/@kinlab/kin')), fs.realpathSync(sibling)); + const output = execFileSync(process.execPath, ['--input-type=module', '--eval', + "import { targetKinVersion } from '@kinlab/kin/lib/resolve.mjs'; process.stdout.write(targetKinVersion());"], { + cwd: project, env, encoding: 'utf8', + }); + assert.equal(output, version); + const installed = JSON.parse(fs.readFileSync(path.join(project, 'package.json'), 'utf8')); + assert.equal(installed.dependencies['@kinlab/kin'], version, 'published dependency remains an exact registry version'); + } finally { fs.rmSync(fixture, { recursive: true, force: true }); } +}); + test('updates only local Kin workspace packages in Cargo.lock', () => { const source = `version = 4 diff --git a/scripts/prove-windows-npm-first-run.mjs b/scripts/prove-windows-npm-first-run.mjs index eacffecfc..ae0eecabc 100644 --- a/scripts/prove-windows-npm-first-run.mjs +++ b/scripts/prove-windows-npm-first-run.mjs @@ -511,6 +511,8 @@ async function proveCanonical({ workRoot, builtKin, builtDaemon, gitBinary }) { const env = hermeticEnv({ root, managedBinDir: path.dirname(managedKin) }); setEnv(env, 'KIN_HOME', kinHome); + setEnv(env, 'KIN_MANAGED_BIN', managedKin); + setEnv(env, 'KIN_RUNTIME_MODE', 'development'); setEnv(env, 'KIN_NO_PROVISION', '1'); setEnv(env, 'KIN_MCP_TOOL_PROFILE', 'agent-default'); deleteEnv(env, 'KIN_DAEMON_BIN'); @@ -628,6 +630,7 @@ async function proveCompatibility({ workRoot, builtKin, builtDaemon, gitBinary } const env = hermeticEnv({ root, managedBinDir: path.dirname(cachedKin) }); setEnv(env, 'KIN_HOME', path.join(root, 'kin-home')); + setEnv(env, 'KIN_RUNTIME_MODE', 'development'); setEnv(env, 'KIN_MCP_CACHE_DIR', cacheRoot); setEnv(env, 'KIN_MCP_AUTO_INIT', '1'); setEnv(env, 'KIN_MCP_RELEASE_BASE_URL', 'http://127.0.0.1:9/kin-offline-proof'); diff --git a/scripts/release-proof/VENDORED.json b/scripts/release-proof/VENDORED.json index 04ddd7fbf..8c09b239b 100644 --- a/scripts/release-proof/VENDORED.json +++ b/scripts/release-proof/VENDORED.json @@ -1,13 +1,13 @@ { "source_repository": "firelock-ai/kin-ecosystem", "source_commit": "c1cbeeb25f6070e59178badb401e9195537d91de", - "note": "The hashes are the authority for the exact proof bytes this tree runs. source_commit identifies the last umbrella vendor snapshot. bin/kin-lane is local to this tree. bin/kin-release-preflight, bin/kin-release-preflight.d/proof-flow.sh, and bin/kin-release-preflight.d/validate.mjs carry the Kin-side VFS retirement update: future release packages omit the external kin-vfs executable and preload shims while complete legacy archives remain verifiable. bin/kin-release-preflight.d/proof-flow.sh and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side per-client MCP profile update: the MCP smoke lists and calls the tool the launched entry's profile serves, and each client config is held to the profile setup assigns it. bin/kin-release-preflight.d/proof-flow.sh also parses its kin status --json capture from stdout alone, with stderr kept beside it, as the public install proof does. bin/kin-release-preflight.d/proof-flow.sh and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side agent tool verbs update: the MCP smoke searches with the served locate verb, by name or as the routed locate command. bin/kin-release-preflight.d/proof-flow.sh and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side entity search proof: the MCP smoke parses the reply rows and requires the seeded entity by name and module, never a path in the rendered reply. Re-vendor the corresponding umbrella update before calling those copies byte-identical again. The capability validator also carries the Kin-side engine-free archive profile: local inference and Metal absent, HTTP support present, no model selected by default. bin/kin-release-preflight, bin/kin-evidence-publish and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side repository move: they name kinlab-ai/kin, and the validator is stamped against the install proof whose attestation checks pin kinlab-ai/kin's release.yml. bin/kin-release-preflight.d/proof-flow.sh and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side central store and engine-free archive proof: the daemon endpoint is read from the store `kin store path` names, `kin embed` must refuse with the reason an archive without a local model has, and semantic readiness is unsupported before and after. bin/kin-acceptance-suite also carries the Kin-side sibling staging fix: a suite written to --dest now carries the acceptance modules it imports beside it, so the staged copy runs outside its tree. bin/kin-release-preflight.d/validate.mjs is also stamped against the install proof whose Windows admission step holds a populated directory with no Git history to either its admission or the refusal of a release built before that admission; no assertion the validator mirrors moved.", + "note": "The hashes are the authority for the exact proof bytes this tree runs. source_commit identifies the last umbrella vendor snapshot. bin/kin-lane is local to this tree. bin/kin-release-preflight, bin/kin-release-preflight.d/proof-flow.sh, and bin/kin-release-preflight.d/validate.mjs carry the Kin-side VFS retirement update: future release packages omit the external kin-vfs executable and preload shims while complete legacy archives remain verifiable. bin/kin-release-preflight.d/proof-flow.sh and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side per-client MCP profile update: the MCP smoke lists and calls the tool the launched entry's profile serves, and each client config is held to the profile setup assigns it. bin/kin-release-preflight.d/proof-flow.sh also parses its kin status --json capture from stdout alone, with stderr kept beside it, as the public install proof does. bin/kin-release-preflight.d/proof-flow.sh and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side agent tool verbs update: the MCP smoke searches with the served locate verb, by name or as the routed locate command. bin/kin-release-preflight.d/proof-flow.sh and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side entity search proof: the MCP smoke parses the reply rows and requires the seeded entity by name and module, never a path in the rendered reply. Re-vendor the corresponding umbrella update before calling those copies byte-identical again. The capability validator also carries the Kin-side engine-free archive profile: local inference and Metal absent, HTTP support present, no model selected by default. bin/kin-release-preflight, bin/kin-evidence-publish and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side repository move: they name kinlab-ai/kin, and the validator is stamped against the install proof whose attestation checks pin kinlab-ai/kin's release.yml. bin/kin-release-preflight.d/proof-flow.sh and bin/kin-release-preflight.d/validate.mjs also carry the Kin-side central store and engine-free archive proof: the daemon endpoint is read from the store `kin store path` names, `kin embed` must refuse with the reason an archive without a local model has, and semantic readiness is unsupported before and after. bin/kin-acceptance-suite also carries the Kin-side sibling staging fix: a suite written to --dest carries its exact trace_pages.py and runtime_fixture.py dependencies from the selected source tree beside it, so the staged copy runs outside its tree. bin/kin-release-preflight.d/validate.mjs is also stamped against the install proof whose Windows admission step holds a populated directory with no Git history to either its admission or the refusal of a release built before that admission; no assertion the validator mirrors moved.", "files": { "bin/kin-release-preflight": {"source": "bin/kin-release-preflight", "sha256": "affe7e4196cdf4de9f4497a2e8e77717d0be69091cda3077760ed3698474a0d8"}, "bin/kin-release-preflight.d/Dockerfile": {"source": "bin/kin-release-preflight.d/Dockerfile", "sha256": "ae616f8898dcf1c093d16f90ce655072a495e29e74d61e3f30733646ae43b394"}, "bin/kin-release-preflight.d/proof-flow.sh": {"source": "bin/kin-release-preflight.d/proof-flow.sh", "sha256": "32dcb286ecfc0a46c2c416b5952672c2d71c12306492e5558c50631af846f5e2"}, - "bin/kin-release-preflight.d/validate.mjs": {"source": "bin/kin-release-preflight.d/validate.mjs", "sha256": "483518261b1e44e7d641da94f7649bd577eccd4c925e47424d09701cb86c3406"}, + "bin/kin-release-preflight.d/validate.mjs": {"source": "bin/kin-release-preflight.d/validate.mjs", "sha256": "2d9aeac5c58d30ceb90298308dfd44c4c612239100c76114a104480b31a00953"}, "bin/kin-evidence-publish": {"source": "bin/kin-evidence-publish", "sha256": "eee85300ad29762d889f447f161e35f1d550ff4bb2f43124c74105e37869459f"}, - "bin/kin-acceptance-suite": {"source": "bin/kin-acceptance-suite", "sha256": "63bfd7f418cdee21b19d4f137ac6623e7b2f2dc1cb84a12d4008dcfbb0518aef"} + "bin/kin-acceptance-suite": {"source": "bin/kin-acceptance-suite", "sha256": "ba6ba978eb76cf9773663619ae97c682e6b8be9a3e99b78939813a4ec1a46196"} } } diff --git a/scripts/release-proof/bin/kin-acceptance-suite b/scripts/release-proof/bin/kin-acceptance-suite index c516d6f3c..fff44d9a3 100755 --- a/scripts/release-proof/bin/kin-acceptance-suite +++ b/scripts/release-proof/bin/kin-acceptance-suite @@ -114,8 +114,8 @@ SUITES = { # the script's own directory, so a suite staged alone at --dest dies on # import; the siblings travel beside it, from the same tree and ref. SUITE_SIBLINGS = { - "magic": ("scripts/acceptance/trace_pages.py",), - "brownfield": ("scripts/acceptance/trace_pages.py",), + "magic": ("scripts/acceptance/trace_pages.py", "scripts/acceptance/runtime_fixture.py"), + "brownfield": ("scripts/acceptance/trace_pages.py", "scripts/acceptance/runtime_fixture.py"), } BASELINE_REF = "origin/main" diff --git a/scripts/release-proof/bin/kin-release-preflight.d/validate.mjs b/scripts/release-proof/bin/kin-release-preflight.d/validate.mjs index 029bb1c9d..3b388423e 100644 --- a/scripts/release-proof/bin/kin-release-preflight.d/validate.mjs +++ b/scripts/release-proof/bin/kin-release-preflight.d/validate.mjs @@ -208,7 +208,7 @@ import path from "node:path"; // no assertion moved. export const PORTED_FROM = { file: ".github/workflows/install-proof.yml", - sha256: "b7ec0ce75f717d238463a3385c5efadf541e072f25ecd21d86e6c3da03386eac", + sha256: "162b6ce38ad685b359ae0fe798eb14087fb94cc7f886582141d4855f6040645a", }; class Unreadable extends Error {} diff --git a/scripts/release-proof/vendored.test.mjs b/scripts/release-proof/vendored.test.mjs index 5d02433a1..40059f89a 100644 --- a/scripts/release-proof/vendored.test.mjs +++ b/scripts/release-proof/vendored.test.mjs @@ -77,23 +77,105 @@ test('the lock shim keeps the acquire, holder and release contract the preflight assert.notEqual(run('frobnicate').status, 0); }); -test('a staged acceptance suite carries the sibling modules it imports', (t) => { +for (const suite of ['magic', 'brownfield']) { + test(`a staged ${suite} suite carries its exact sibling modules in a clean environment`, (t) => { + const python = spawnSync('python3', ['--version'], { encoding: 'utf8' }); + if (python.error) return t.skip('python3 unavailable'); + const root = path.join(HERE, '..', '..'); + const staged = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-suite-')); + t.after(() => fs.rmSync(staged, { recursive: true, force: true })); + const dest = path.join(staged, `${suite}_repro.py`); + const env = { PATH: process.env.PATH, HOME: staged, LANG: 'C.UTF-8' }; + // The resolver exit also carries its drift tripwire verdict, which is not + // what this test grades; the staging writes land before that verdict. + spawnSync('python3', [path.join(HERE, 'bin', 'kin-acceptance-suite'), suite, + '--checkout', root, '--dest', dest, '--no-fetch'], { env, encoding: 'utf8' }); + assert.ok(fs.existsSync(dest), 'the suite was not staged'); + for (const name of ['trace_pages.py', 'runtime_fixture.py']) { + const sibling = path.join(staged, name); + assert.ok(fs.existsSync(sibling), `${name} was not staged beside the suite`); + assert.equal(sha256(sibling), sha256(path.join(root, 'scripts', 'acceptance', name))); + } + const help = spawnSync('python3', [dest, '--help'], { cwd: staged, env, encoding: 'utf8' }); + assert.equal(help.status, 0, help.stderr); + assert.doesNotMatch(help.stderr, /ModuleNotFoundError/); + }); +} + +test('rehearsal receipts and candidate staging bind the exact runtime helper bytes', (t) => { const python = spawnSync('python3', ['--version'], { encoding: 'utf8' }); if (python.error) return t.skip('python3 unavailable'); const root = path.join(HERE, '..', '..'); - const staged = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-suite-')); - const dest = path.join(staged, 'kin-magic-repro'); - // The resolver exit also carries its drift tripwire verdict, which is not - // what this test grades; the staging writes land before that verdict. - spawnSync('python3', [path.join(HERE, 'bin', 'kin-acceptance-suite'), 'magic', - '--checkout', root, '--dest', dest, '--no-fetch'], { encoding: 'utf8' }); - assert.ok(fs.existsSync(dest), 'the suite was not staged'); - const sibling = path.join(staged, 'trace_pages.py'); - assert.ok(fs.existsSync(sibling), 'trace_pages.py was not staged beside the suite'); - assert.equal(sha256(sibling), sha256(path.join(root, 'scripts', 'acceptance', 'trace_pages.py'))); - const help = spawnSync('python3', [dest, '--help'], { encoding: 'utf8' }); - assert.equal(help.status, 0, help.stderr); - assert.doesNotMatch(help.stderr, /ModuleNotFoundError/); + const staged = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-rehearsal-inputs-')); + t.after(() => fs.rmSync(staged, { recursive: true, force: true })); + const fixture = String.raw` +import hashlib, importlib.util, json, os, pathlib, shutil, subprocess, sys, types, zipfile +upstream, root = map(pathlib.Path, sys.argv[1:]) +spec = importlib.util.spec_from_file_location('rehearsal_policy', upstream / 'scripts/release-rehearsal.py') +policy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(policy) +source = root / 'source' +source.mkdir() +for name in policy.INPUTS: + target = source / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(upstream / name, target) +subprocess.run(['git', 'init', '-q', '-b', 'main', str(source)], check=True) +subprocess.run(['git', 'add', '.'], cwd=source, check=True) +subprocess.run(['git', '-c', 'user.name=Rehearsal fixture', '-c', 'user.email=fixture@kin.invalid', + '-c', 'commit.gpgsign=false', 'commit', '-q', '-s', '-m', 'Create rehearsal input fixture'], cwd=source, check=True) +candidate = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=source, text=True).strip() +helper_name = 'scripts/acceptance/runtime_fixture.py' +helper = source / helper_name +helper_bytes = helper.read_bytes() +helper_hash = hashlib.sha256(helper_bytes).hexdigest() +assert policy.identity(source, candidate, checked_out=True)['inputs_sha256'][helper_name] == helper_hash +receipt_path = root / 'imports.json' +policy.imports(types.SimpleNamespace(source=source, candidate=candidate, output=receipt_path)) +receipt = json.loads(receipt_path.read_text()) +assert receipt['status'] == 'PASS' and receipt['candidate'] == candidate +assert set(receipt['staged']) == {'magic', 'brownfield'} +for suite, entry in receipt['staged'].items(): + assert entry['runtime_fixture_sha256'] == helper_hash + assert entry['sibling_sha256'] == hashlib.sha256((source / 'scripts/acceptance/trace_pages.py').read_bytes()).hexdigest() + assert (root / 'imports' / suite / 'runtime_fixture.py').read_bytes() == helper_bytes + +# The archive contains inert fixture bytes; only staging and provenance run. +directory = root / 'archive' +directory.mkdir() +archive = directory / 'kin-windows-x86_64.zip' +components = {'kin.exe': b'fixture CLI', 'kin-daemon.exe': b'fixture daemon'} +with zipfile.ZipFile(archive, 'w') as bundle: + for name, body in components.items(): + bundle.writestr(name, body) +archive_hash = hashlib.sha256(archive.read_bytes()).hexdigest() +pathlib.Path(str(archive) + '.sha256').write_text(archive_hash + ' ' + archive.name + '\n') +lock = hashlib.sha256((source / 'Cargo.lock').read_bytes()).hexdigest() +version = json.loads((source / 'packages/kin/package.json').read_text())['version'] +os.environ.update(GITHUB_RUN_ID='123', GITHUB_REPOSITORY='fixture/kin') +manifest = {'schema_version': 2, 'artifact': 'kin-windows-x86_64', + 'kin': {'commit': candidate, 'cargo_lock_sha256': lock, 'embedded_dependency_provenance': lock}, + 'release_candidate': {'version': version}, + 'archive': {'name': archive.name, 'sha256': archive_hash}, + 'workflow': {'run_id': 123, 'repository': 'fixture/kin'}, + 'archive_contents': [{'name': name, 'sha256': hashlib.sha256(body).hexdigest()} for name, body in components.items()]} +(directory / 'kin-windows-x86_64.provenance.json').write_text(json.dumps(manifest)) +args = types.SimpleNamespace(source=source, candidate=candidate, directory=directory) +policy.stage_candidate(args) +assert (directory / 'install.ps1').read_bytes() == (source / 'scripts/install.ps1').read_bytes() +identity = json.loads((directory / 'candidate-identity.json').read_text()) +assert identity['commit'] == candidate and identity['archive_sha256'] == archive_hash +assert identity['components'] == {name: hashlib.sha256(body).hexdigest() for name, body in components.items()} +helper.write_bytes(helper_bytes + b'\n# changed fixture helper\n') +try: + policy.stage_candidate(args) +except ValueError as error: + assert helper_name in str(error) and 'committed candidate bytes' in str(error) +else: + raise AssertionError('candidate staging accepted changed runtime helper bytes') +`; + const result = spawnSync('python3', ['-c', fixture, root, staged], { encoding: 'utf8', timeout: 30000 }); + assert.equal(result.status, 0, result.stderr || result.stdout); }); test('the preflight resolves the shim beside itself rather than the fleet tool', () => { @@ -106,3 +188,122 @@ test('the preflight resolves the shim beside itself rather than the fleet tool', assert.equal(execFileSync('bash', ['-n', path.join(HERE, 'bin', 'kin-release-preflight.d', 'proof-flow.sh')], { encoding: 'utf8' }), ''); assert.equal(execFileSync('bash', ['-n', SHIM], { encoding: 'utf8' }), ''); }); + +test('the standalone rehearsal policy pins legacy preparation and preserves candidate proof controls', (t) => { + const python = spawnSync('python3', ['--version'], { encoding: 'utf8' }); + if (python.error) return t.skip('python3 unavailable'); + const root = path.join(HERE, '..', '..'); + const staged = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-rehearsal-receipts-')); + t.after(() => fs.rmSync(staged, { recursive: true, force: true })); + const fixture = String.raw` +import copy, importlib.util, json, os, pathlib, sys, types +from unittest import mock +upstream, root = map(pathlib.Path, sys.argv[1:]) +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, upstream / 'scripts' / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module +policy = load('rehearsal_policy', 'release-rehearsal.py') +compat = load('compatibility_policy', 'release-rehearsal-compat.py') +assert policy.BASELINE_IDENTITY == compat.BASELINE +assert policy.BASELINE_ARCHIVE == compat.BASELINE_ARCHIVE +preparation = {'schema': 'kin.legacy-baseline-preparation.v1', 'status': 'PASS', + 'coverage': 'verified-legacy-archive-materialization', + 'source_archive': dict(copy.deepcopy(policy.BASELINE_ARCHIVE), checksum_sha256='d' * 64), + 'installed': {}, 'candidate_installer_invoked': False, 'modern_managed_admission': False} +installed = {} +for filename, pin in policy.BASELINE_ARCHIVE['components'].items(): + entry = dict(pin, path='C:/ci/.kin/bin/' + filename) + preparation['installed'][filename] = entry + installed[filename.removesuffix('.exe')] = dict(entry, version_text=filename.removesuffix('.exe') + ' 0.8.6') +compatibility = {'schema': 'kin.release-rehearsal-compat.v1', 'status': 'PASS', + 'coverage': 'compatibility-baseline', 'baseline': copy.deepcopy(policy.BASELINE_IDENTITY), + 'preparation': preparation, 'installed': installed} +source = {'candidate': 'a' * 40, 'source_tree': 'b' * 40, 'inputs_sha256': {'Cargo.lock': 'c' * 64}} +common = dict(source, schema=policy.SCHEMA, status='PASS', run_id=123, repository='fixture/kin', + mode='compatibility-baseline', baseline_tag=policy.BASELINE, event='push', ref='refs/heads/main') +tooling = dict(common, leg='tooling', native_handoff='PASS', + imports={'status': 'PASS', 'candidate': source['candidate']}, + environment={'status': 'PASS', 'candidate': source['candidate']}) +def receipt_for(value): + return dict(common, legs={'windows': dict(common, leg='windows', baseline=value['baseline'], compatibility=value), + 'tooling': copy.deepcopy(tooling)}) +policy.validate_compatibility_baseline(compatibility) +policy.validate_receipt(receipt_for(compatibility), source, 123, 'fixture/kin', 'compatibility-baseline') +output = root / 'windows.json' +(root / 'compatibility').mkdir() +def record(value): + (root / 'compatibility/result.json').write_text(json.dumps(value)) + args = types.SimpleNamespace(source=upstream, candidate=source['candidate'], leg='windows', + mode='compatibility-baseline', release_tag=policy.BASELINE, output=output) + with mock.patch.object(policy, 'identity', return_value=source), mock.patch.dict(os.environ, + GITHUB_RUN_ID='123', GITHUB_REPOSITORY='fixture/kin', GITHUB_EVENT_NAME='push', GITHUB_REF='refs/heads/main'): + policy.record(args) +record(compatibility) +assert json.loads(output.read_text())['compatibility'] == compatibility +output.unlink() +def refuse(call): + try: + call() + except ValueError: + return + raise AssertionError('receipt policy accepted a substituted legacy proof') +cases = [ + (('preparation',), None), + (('baseline', 'commit'), '1' * 40), + (('baseline', 'lock_sha256'), '2' * 64), + (('preparation', 'source_archive', 'sha256'), '3' * 64), + (('preparation', 'source_archive', 'checksum_sha256'), ''), + (('preparation', 'source_archive', 'components', 'kin-daemon.exe', 'sha256'), '4' * 64), + (('preparation', 'source_archive', 'components', 'kin.exe', 'size_bytes'), float(105574912)), + (('preparation', 'installed', 'kin.exe', 'path'), 'C:/elsewhere/kin.exe'), + (('preparation', 'installed', 'kin-daemon.exe', 'sha256'), '5' * 64), + (('installed', 'kin-daemon', 'size_bytes'), 1), + (('preparation', 'candidate_installer_invoked'), True), + (('preparation', 'modern_managed_admission'), True), + (('preparation', 'modern_managed_admission'), 0), + (('preparation', 'schema'), 'other.preparation.v1'), + (('preparation', 'status'), 'FAIL'), + (('preparation', 'coverage'), 'candidate-full'), +] +for keys, value in cases: + poisoned = copy.deepcopy(compatibility) + target = poisoned + for key in keys[:-1]: + target = target[key] + target[keys[-1]] = value + refuse(lambda: policy.validate_receipt(receipt_for(poisoned), source, 123, 'fixture/kin', 'compatibility-baseline')) + refuse(lambda: record(poisoned)) + assert not output.exists() +missing_component = copy.deepcopy(compatibility) +del missing_component['preparation']['source_archive']['components']['kin-daemon.exe'] +refuse(lambda: policy.validate_receipt(receipt_for(missing_component), source, 123, 'fixture/kin', 'compatibility-baseline')) +outer = receipt_for(compatibility) +outer['legs']['windows']['baseline'] = dict(policy.BASELINE_IDENTITY, commit='6' * 40) +refuse(lambda: policy.validate_receipt(outer, source, 123, 'fixture/kin', 'compatibility-baseline')) + +# Candidate qualification remains its separate exact archive proof. +full_common = dict(common, mode='candidate-full', baseline_tag=None) +full_tooling = dict(tooling, mode='candidate-full', baseline_tag=None) +binary = {'commit': source['candidate'], 'lock_sha256': source['inputs_sha256']['Cargo.lock'], + 'archive_sha256': '7' * 64, 'provenance_sha256': '8' * 64, 'version': '0.8.7', + 'components': {'kin.exe': '9' * 64, 'kin-daemon.exe': 'e' * 64}} +full = dict(full_common, legs={'tooling': full_tooling, + 'windows': dict(full_common, leg='windows', candidate_binary=binary, full_install_proof='PASS')}) +policy.validate_receipt(full, source, 123, 'fixture/kin') +full['legs']['windows']['candidate_binary']['commit'] = 'f' * 40 +refuse(lambda: policy.validate_receipt(full, source, 123, 'fixture/kin')) +`; + const result = spawnSync('python3', ['-c', fixture, root, staged], { encoding: 'utf8', timeout: 30000 }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test('legacy archive preparation and setup contract controls run in the release proof gate', (t) => { + const python = spawnSync('python3', ['--version'], { encoding: 'utf8' }); + if (python.error) return t.skip('python3 unavailable'); + const root = path.join(HERE, '..', '..'); + const result = spawnSync('python3', [path.join(root, 'scripts', 'test-release-rehearsal-compat.py')], + { env: process.env, encoding: 'utf8', timeout: 30000 }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); diff --git a/scripts/release-rehearsal-compat.py b/scripts/release-rehearsal-compat.py index a0534e3f6..cde4d4cc3 100644 --- a/scripts/release-rehearsal-compat.py +++ b/scripts/release-rehearsal-compat.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Firelock, LLC -"""Exercise candidate installer/setup tooling against reviewed stable binaries. +"""Exercise candidate proof tooling against reviewed stable setup behaviour. -Run after install.ps1 installs v0.8.6 with KIN_NO_SETUP=1. This script deliberately -uses only that release's setup, status, doctor and provenance commands. Its +Prepare genuine v0.8.6 bytes from the independently verified retained release +archive, then use only that release's setup, status, doctor and provenance commands. Its receipt is compatibility-baseline evidence, never candidate-binary qualification. Git identity, shell and client configuration changes belong to a disposable GitHub-hosted Windows account; persistent accounts are refused. @@ -21,6 +21,7 @@ import shutil import subprocess import sys +import zipfile SCHEMA = "kin.release-rehearsal-compat.v1" BASELINE = { @@ -29,6 +30,14 @@ "commit": "522ede0d0dfbdd33ff8ba3a5798bb3bed6902c08", "lock_sha256": "9db830e29224527847b5a122e0883a2c9f968d806e34b4bf4d5052c4ceb60495", } +BASELINE_ARCHIVE = { + "name": "kin-windows-x86_64.zip", + "sha256": "b40bb7d72b2313dceaf806b76c58dd8c7b06cbf62a2a57cdd21d7a31f228e392", + "components": { + "kin.exe": {"sha256": "5e9729490b7a7bcf7a803d5333fab6476849dc1765d2f5c57deea71ca86249ab", "size_bytes": 105574912}, + "kin-daemon.exe": {"sha256": "923fbf6d669d6d32fab65a68b3338173af15ef4ea99d43f6d5b589d0882d7696", "size_bytes": 120715776}, + }, +} REQUIRED_HEALTHY = ( "kin_binary", "kin_daemon_binary", "shell_path", "setup_ledger", "commit_author", ) @@ -117,6 +126,56 @@ def binary_info(path: Path) -> dict: return {"path": str(path), "sha256": digest(path), "size_bytes": path.stat().st_size} +def read_baseline_archive(archive: Path) -> tuple[dict, dict[str, bytes]]: + """Recheck the reviewed release bytes without trusting the mirror receipt.""" + checksum = Path(str(archive) + ".sha256") + for path in (archive, checksum): + if path.is_symlink() or not path.is_file(): + raise ValueError("baseline archive and sidecar must be regular files") + have = digest(archive) + if archive.name != BASELINE_ARCHIVE["name"] or have != BASELINE_ARCHIVE["sha256"]: + raise ValueError("baseline archive differs from the independently reviewed release") + checksum_text = checksum.read_text(encoding="utf-8-sig") + if not re.fullmatch(re.escape(have) + r"[ \t]+\*?" + re.escape(archive.name) + r"\r?\n?", checksum_text): + raise ValueError("baseline sidecar does not bind the exact reviewed archive hash and name") + components = {} + with zipfile.ZipFile(archive) as bundle: + entries = bundle.infolist() + for name, expected in BASELINE_ARCHIVE["components"].items(): + matches = [entry for entry in entries if entry.filename == name] + if len(matches) != 1 or matches[0].is_dir() or ((matches[0].external_attr >> 16) & 0o170000) == 0o120000: + raise ValueError(f"baseline archive has no unique regular {name}") + body = bundle.read(matches[0]) + if len(body) != expected["size_bytes"] or hashlib.sha256(body).hexdigest() != expected["sha256"]: + raise ValueError(f"baseline archive {name} differs from its reviewed component") + components[name] = body + return {"name": archive.name, "sha256": have, "checksum_sha256": digest(checksum), + "components": dict(BASELINE_ARCHIVE["components"])}, components + + +def prepare_baseline(archive: Path, home: Path) -> dict: + """Materialize only a fresh disposable account's known legacy fixture pair.""" + source, components = read_baseline_archive(archive) + root = home / ".kin" + bin_dir = root / "bin" + if root.is_symlink() or bin_dir.exists() or bin_dir.is_symlink(): + raise ValueError("legacy baseline preparation refuses an existing or linked account bin directory") + bin_dir.mkdir(parents=True, exist_ok=False) + installed = {} + for name, body in components.items(): + target = bin_dir / name + with target.open("xb") as destination: + destination.write(body) + info = binary_info(target) + if {key: info[key] for key in ("sha256", "size_bytes")} != source["components"][name]: + raise ValueError(f"prepared {name} differs from the independently verified release archive") + installed[name] = info + return {"schema": "kin.legacy-baseline-preparation.v1", "status": "PASS", + "coverage": "verified-legacy-archive-materialization", "source_archive": source, + "installed": installed, "candidate_installer_invoked": False, + "modern_managed_admission": False} + + def run_command(command: list[str], label: str, output: Path, cwd: Path, env: dict, timeout: int = 120) -> str: """Preserve command output and exit evidence before raising on failure.""" @@ -140,7 +199,7 @@ def run_command(command: list[str], label: str, output: Path, cwd: Path, return stdout.decode("utf-8-sig").strip() -def rehearse(output: Path) -> dict: +def rehearse(output: Path, baseline_archive: Path) -> dict: validate_host(sys.platform, platform.machine(), os.environ) # This directory belongs to one invocation. Never overwrite earlier proof. output.mkdir(parents=True, exist_ok=False) @@ -148,18 +207,19 @@ def rehearse(output: Path) -> dict: "schema": SCHEMA, "status": "FAIL", "coverage": "compatibility-baseline", "baseline": dict(BASELINE), "platform": sys.platform, "architecture": platform.machine(), "host_scope": "disposable GitHub-hosted Windows account", - "scope": "installed stable CLI/daemon identity and repository-free agent setup; " + "scope": "independently verified legacy archive, stable CLI/daemon identity and repository-free agent setup; " "candidate binaries require separate RC Build and preflight qualification", } try: home = Path.home() + result["preparation"] = prepare_baseline(baseline_archive, home) bin_dir = home / ".kin" / "bin" kin = bin_dir / "kin.exe" daemon = bin_dir / "kin-daemon.exe" work = output / "repo-free" work.mkdir() # Inherited binary/registry overrides cannot substitute for the real - # account install or global Git identity. No new Kin environment keys. + # account fixture or global Git identity. No new Kin environment keys. env = {key: value for key, value in os.environ.items() if not re.match(r"^(?:_?KIN_|GIT_|LD_|DYLD_)", key, re.IGNORECASE)} env["PATH"] = str(bin_dir) + os.pathsep + env.get("PATH", "") @@ -251,9 +311,11 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", required=True, type=Path, help="new capture directory outside any Git repository") + parser.add_argument("--baseline-archive", required=True, type=Path, + help="retained genuine reviewed ZIP with its public .sha256 sidecar") args = parser.parse_args() try: - result = rehearse(args.output.resolve()) + result = rehearse(args.output.resolve(), args.baseline_archive.resolve()) except Exception as error: print(f"Compatibility baseline failed: {error}", file=sys.stderr) return 1 diff --git a/scripts/release-rehearsal.py b/scripts/release-rehearsal.py index f8434c75b..40e1c571d 100644 --- a/scripts/release-rehearsal.py +++ b/scripts/release-rehearsal.py @@ -23,6 +23,22 @@ SCHEMA = 'kin.release-rehearsal.v2' BASELINE = 'v0.8.6' +# These reviewed pins keep the extracted verifier independent of candidate code. +BASELINE_IDENTITY = { + 'tag': BASELINE, 'version': '0.8.6', + 'commit': '522ede0d0dfbdd33ff8ba3a5798bb3bed6902c08', + 'lock_sha256': '9db830e29224527847b5a122e0883a2c9f968d806e34b4bf4d5052c4ceb60495', +} +BASELINE_ARCHIVE = { + 'name': 'kin-windows-x86_64.zip', + 'sha256': 'b40bb7d72b2313dceaf806b76c58dd8c7b06cbf62a2a57cdd21d7a31f228e392', + 'components': { + 'kin.exe': {'sha256': '5e9729490b7a7bcf7a803d5333fab6476849dc1765d2f5c57deea71ca86249ab', + 'size_bytes': 105574912}, + 'kin-daemon.exe': {'sha256': '923fbf6d669d6d32fab65a68b3338173af15ef4ea99d43f6d5b589d0882d7696', + 'size_bytes': 120715776}, + }, +} MODES = ('compatibility-baseline', 'candidate-full') SHA = re.compile(r'^[0-9a-f]{40}$') HASH = re.compile(r'^[0-9a-f]{64}$') @@ -42,6 +58,7 @@ 'scripts/acceptance/magic_repro.py', 'scripts/acceptance/brownfield_repro.py', 'scripts/acceptance/trace_pages.py', + 'scripts/acceptance/runtime_fixture.py', ) @@ -160,11 +177,15 @@ def imports(args) -> None: help_text = run(sys.executable, str(destination), '--help', cwd=root, env=clean) if 'usage:' not in help_text.lower(): raise ValueError(f'staged {suite} suite did not reach its actual argument parser') - siblings = destination.parent / 'trace_pages.py' - if siblings.read_bytes() != (args.source / 'scripts/acceptance/trace_pages.py').read_bytes(): - raise ValueError(f'staged {suite} imported a stale acceptance sibling') + siblings = {} + for name in ('trace_pages.py', 'runtime_fixture.py'): + sibling = destination.parent / name + if sibling.read_bytes() != (args.source / 'scripts/acceptance' / name).read_bytes(): + raise ValueError(f'staged {suite} imported a stale acceptance sibling: {name}') + siblings[name] = digest(sibling.read_bytes()) staged[suite] = {'entry_sha256': digest(destination.read_bytes()), - 'sibling_sha256': digest(siblings.read_bytes()), + 'sibling_sha256': siblings['trace_pages.py'], + 'runtime_fixture_sha256': siblings['runtime_fixture.py'], 'help_sha256': digest(help_text.encode()), 'resolution': json.loads(resolution.read_text())} help_text = run('bash', str(args.source / 'scripts/release-proof/bin/kin-release-preflight'), @@ -221,11 +242,7 @@ def record(args) -> None: common['native_handoff'] = 'PASS' elif args.mode == 'compatibility-baseline': compatibility = json.loads((args.output.parent / 'compatibility/result.json').read_text()) - if (compatibility.get('schema') != 'kin.release-rehearsal-compat.v1' - or compatibility.get('status') != 'PASS' - or compatibility.get('coverage') != 'compatibility-baseline' - or compatibility.get('baseline', {}).get('tag') != BASELINE): - raise ValueError('Windows compatibility baseline did not pass its actual stable setup proof') + validate_compatibility_baseline(compatibility) common['compatibility'] = compatibility common['baseline'] = compatibility['baseline'] else: @@ -249,6 +266,49 @@ def record(args) -> None: write(args.output, common) +def validate_compatibility_baseline(compatibility: dict) -> None: + if (not isinstance(compatibility, dict) + or compatibility.get('schema') != 'kin.release-rehearsal-compat.v1' + or compatibility.get('status') != 'PASS' + or compatibility.get('coverage') != 'compatibility-baseline' + or compatibility.get('baseline') != BASELINE_IDENTITY): + raise ValueError('Windows compatibility baseline differs from the exact reviewed stable identity') + preparation = compatibility.get('preparation', {}) + if (not isinstance(preparation, dict) + or preparation.get('schema') != 'kin.legacy-baseline-preparation.v1' + or preparation.get('status') != 'PASS' + or preparation.get('coverage') != 'verified-legacy-archive-materialization' + or preparation.get('candidate_installer_invoked') is not False + or preparation.get('modern_managed_admission') is not False): + raise ValueError('Windows baseline has no verified legacy archive preparation') + archive = preparation.get('source_archive', {}) + if (not isinstance(archive, dict) + or archive.get('name') != BASELINE_ARCHIVE['name'] + or archive.get('sha256') != BASELINE_ARCHIVE['sha256'] + or not isinstance(archive.get('checksum_sha256'), str) + or not HASH.fullmatch(archive['checksum_sha256']) + or archive.get('components') != BASELINE_ARCHIVE['components']): + raise ValueError('Windows baseline preparation differs from the reviewed archive or components') + prepared = preparation.get('installed', {}) + executed = compatibility.get('installed', {}) + if (not isinstance(prepared, dict) or set(prepared) != set(BASELINE_ARCHIVE['components']) + or not isinstance(executed, dict) or set(executed) != {'kin', 'kin-daemon'}): + raise ValueError('Windows baseline has no complete prepared and executed component identity') + for filename, pin in BASELINE_ARCHIVE['components'].items(): + component = archive['components'][filename] + installed = prepared[filename] + observed = executed[filename.removesuffix('.exe')] + if (not isinstance(installed, dict) or not isinstance(observed, dict) + or type(component.get('size_bytes')) is not int + or not isinstance(installed.get('path'), str) or not installed['path'] + or type(installed.get('size_bytes')) is not int + or any(installed.get(key) != value for key, value in pin.items()) + or any(type(observed.get(key)) is not type(installed.get(key)) + or observed.get(key) != installed.get(key) + for key in ('path', 'sha256', 'size_bytes'))): + raise ValueError(f'Windows baseline prepared and executed {filename} identities disagree') + + def validate_receipt(receipt: dict, source: dict, run_id: int, repository: str, mode: str = 'candidate-full') -> None: if receipt.get('schema') != SCHEMA or receipt.get('status') != 'PASS': @@ -292,10 +352,8 @@ def validate_receipt(receipt: dict, source: dict, run_id: int, repository: str, else: baseline = windows.get('baseline', {}) compatibility = windows.get('compatibility', {}) - if (baseline.get('tag') != BASELINE or not SHA.fullmatch(baseline.get('commit', '')) - or not HASH.fullmatch(baseline.get('lock_sha256', '')) - or compatibility.get('coverage') != mode or compatibility.get('status') != 'PASS' - or compatibility.get('baseline') != baseline): + validate_compatibility_baseline(compatibility) + if baseline != BASELINE_IDENTITY: raise ValueError('Windows rehearsal has no actual reviewed compatibility-baseline proof') tooling = legs['tooling'] if tooling.get('native_handoff') != 'PASS': diff --git a/scripts/release_authority/test_helpers.py b/scripts/release_authority/test_helpers.py index 51c0ce8fe..2a9b51c64 100644 --- a/scripts/release_authority/test_helpers.py +++ b/scripts/release_authority/test_helpers.py @@ -477,7 +477,7 @@ ( ".github/workflows/rc-build.yml", "build", - ): "d548aec1dfb72f778e575467b70033f3c9d80688181ec23bb91adbbaf9694c1c", + ): "b298f6eb53e648ae57b5ffdf660709fa7f3865ebf63dc58d55de96079ef61825", ( ".github/workflows/rc-build.yml", "capability", diff --git a/scripts/release_authority/test_rc_build.py b/scripts/release_authority/test_rc_build.py index 0234e63fc..d97f6e4aa 100644 --- a/scripts/release_authority/test_rc_build.py +++ b/scripts/release_authority/test_rc_build.py @@ -72,6 +72,22 @@ (RC_BUILD, "build"): frozenset({"depot-macos-26", "depot-windows-2022-16"}), (RC_BUILD, "image"): frozenset({"depot-ubuntu-24.04-16"}), } +# This boolean selects reviewed literals; it never supplies a runner label. +# Its scope stays with the two pretag producers, before any immutable tag. +RC_BUILD_RUNNER_SELECTOR = ( + "${{ vars.KIN_RC_USE_GITHUB_RUNNERS == 'true' && matrix.github_os || matrix.os }}" +) +RC_IMAGE_RUNNER_SELECTOR = ( + "${{ vars.KIN_RC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-latest' " + "|| 'depot-ubuntu-24.04-16' }}" +) +RC_NATIVE_GITHUB_RUNNERS = { + "x86_64-unknown-linux-musl": "ubuntu-latest", + "aarch64-unknown-linux-musl": "ubuntu-24.04-arm", + "x86_64-apple-darwin": "macos-15-intel", + "aarch64-apple-darwin": "macos-latest", + "x86_64-pc-windows-msvc": "windows-latest", +} RELEASE_CRITICAL_MATRIX_JOBS = frozenset( { (RC_BUILD, "build"), @@ -136,8 +152,20 @@ def assert_release_critical_runner_policy( f"scalar for {workflow.name}:{job_id}; found {runs_on}" ) runner = runs_on[0] - if runner == "${{ matrix.os }}": - matrix_key = (workflow, job_id) + matrix_key = (workflow, job_id) + if matrix_key == (RC_BUILD, "build") and runner != RC_BUILD_RUNNER_SELECTOR: + raise AssertionError( + "release-critical runner policy requires the exact RC build selector" + ) + if matrix_key == (RC_BUILD, "image"): + if runner != RC_IMAGE_RUNNER_SELECTOR: + raise AssertionError( + "release-critical runner policy requires the exact RC image selector" + ) + continue + if runner == "${{ matrix.os }}" or ( + matrix_key == (RC_BUILD, "build") and runner == RC_BUILD_RUNNER_SELECTOR + ): if matrix_key not in RELEASE_CRITICAL_MATRIX_JOBS: raise AssertionError( "release-critical runner policy rejects an unreviewed runner " @@ -181,6 +209,37 @@ def assert_release_critical_runner_policy( f"unreviewed matrix labels {rejected} at " f"{workflow.name}:{job_id}" ) + if matrix_key == (RC_BUILD, "build"): + # Check the native fallback independently of the matrix + # hash, so repinning cannot admit a paid or wrong-arch row. + fallback_rows = re.split( + r"(?m)^ {8}- os: ", matrix_source + )[1:] + fallback_targets: dict[str, str] = {} + for row in fallback_rows: + fields = re.findall( + r"(?m)^ {10}(github_os|target):\s*(\S+)\s*$", row + ) + if len(fields) != 2 or {key for key, _ in fields} != { + "github_os", "target" + }: + raise AssertionError( + "release-critical runner policy rejects unreviewed matrix labels " + "or incomplete native fallback routes" + ) + values = dict(fields) + target = values["target"] + if target in fallback_targets: + raise AssertionError( + "release-critical runner policy rejects unreviewed matrix labels " + "or duplicate native fallback targets" + ) + fallback_targets[target] = values["github_os"] + if fallback_targets != RC_NATIVE_GITHUB_RUNNERS: + raise AssertionError( + "release-critical runner policy rejects unreviewed matrix labels " + "or non-native fallback routes" + ) seen_matrix_jobs.add(matrix_key) continue admitted = RELEASE_CRITICAL_STANDARD_RUNNERS | PRETAG_PAID_RUNNERS.get( @@ -496,8 +555,36 @@ def check_release_critical_runner_pools(workflow_sources: dict[Path, str]) -> No ( "RC image moves onto an unreviewed Depot size", RC_BUILD, - " runs-on: depot-ubuntu-24.04-16\n", - " runs-on: depot-ubuntu-24.04-32\n", + f" runs-on: {RC_IMAGE_RUNNER_SELECTOR}\n", + " runs-on: " + RC_IMAGE_RUNNER_SELECTOR.replace( + "depot-ubuntu-24.04-16", "depot-ubuntu-24.04-32" + ) + "\n", + ), + ( + "RC build lets the outage variable name an arbitrary runner", + RC_BUILD, + f" runs-on: {RC_BUILD_RUNNER_SELECTOR}\n", + " runs-on: ${{ vars.KIN_RC_USE_GITHUB_RUNNERS || matrix.os }}\n", + ), + ( + "RC image accepts any nonempty outage value", + RC_BUILD, + f" runs-on: {RC_IMAGE_RUNNER_SELECTOR}\n", + " runs-on: " + RC_IMAGE_RUNNER_SELECTOR.replace( + " == 'true'", "" + ) + "\n", + ), + ( + "tag mint moves onto the pretag outage selector", + RELEASE_TAG, + " runs-on: ubuntu-latest\n", + f" runs-on: {RC_IMAGE_RUNNER_SELECTOR}\n", + ), + ( + "RC capability reuses the pretag producer selector", + RC_BUILD, + " runs-on: ${{ matrix.os }}\n", + f" runs-on: {RC_BUILD_RUNNER_SELECTOR}\n", ), ( "tagged release returns to a paid runner", @@ -544,6 +631,27 @@ def check_release_critical_runner_pools(workflow_sources: dict[Path, str]) -> No " - os: ubuntu-latest\n", " - os: macos-15-large\n", ), + ( + "a repinned RC fallback still rejects a custom runner", + RC_BUILD, + "build", + " github_os: macos-latest\n", + " github_os: kin-custom-macos\n", + ), + ( + "a repinned RC fallback still requires native Intel execution", + RC_BUILD, + "build", + " github_os: macos-15-intel\n", + " github_os: macos-latest\n", + ), + ( + "a repinned RC fallback still requires a runner on every row", + RC_BUILD, + "build", + " github_os: windows-latest\n", + "", + ), ( "a repinned RC capability matrix still rejects a paid runner", RC_BUILD, diff --git a/scripts/release_authority/test_release.py b/scripts/release_authority/test_release.py index d64fe0a83..98c10bc8d 100644 --- a/scripts/release_authority/test_release.py +++ b/scripts/release_authority/test_release.py @@ -594,8 +594,13 @@ def check_npm_release_path_and_action_pins( start = release.index(f" {job}:") end = release.index(f"\n {next_job}:", start + 3) publish_job = release[start:end] + publication_needs = ( + "needs: [publish, install_proof, npm_publish_preflight, publish_npm_canonical]" + if job == "publish_npm_compatibility" + else "needs: [publish, install_proof, npm_publish_preflight]" + ) for policy in ( - "needs: [publish, install_proof, npm_publish_preflight]", + publication_needs, "needs.publish.result == 'success'", "needs.install_proof.result == 'success'", "needs.npm_publish_preflight.result == 'success'", @@ -606,6 +611,12 @@ def check_npm_release_path_and_action_pins( package_dir, ): require(publish_job, policy, f"{job} trusted publishing") + if job == "publish_npm_compatibility": + require( + publish_job, + "needs.publish_npm_canonical.result == 'success'", + "compatibility publication requires its exact canonical dependency", + ) install_proof_start = release.index(" install_proof:") install_proof_end = release.index("\n npm_publish_preflight:", install_proof_start) diff --git a/scripts/release_authority/test_release_train.py b/scripts/release_authority/test_release_train.py index ed28c3092..a87267282 100644 --- a/scripts/release_authority/test_release_train.py +++ b/scripts/release_authority/test_release_train.py @@ -395,6 +395,55 @@ def assert_release_branch_allowlist_covers_generator(release_train: str) -> None ) +def declared_generated_git_paths(release_train: str, command: str) -> set[str]: + """Read a literal restore/stage list from the protected branch step.""" + + step = workflow_step_source( + "release train", release_train, " - name: Create or coalesce the release branch\n" + ) + lines = step.splitlines() + start = next((index for index, line in enumerate(lines) if line.strip() == f"{command} \\"), None) + if start is None: + raise AssertionError(f"release train is missing its generated-path {command} list") + paths: set[str] = set() + for line in lines[start + 1:]: + literal = line.strip() + continues = literal.endswith("\\") + candidate = literal[:-1].strip() if continues else literal + if not GENERATED_PATH_LITERAL.fullmatch(candidate): + raise AssertionError(f"release train {command} list contains a non-literal generated path") + paths.add(candidate) + if not continues: + return paths + raise AssertionError(f"release train {command} list never ends") + + +def assert_generated_branch_paths_restored_and_staged(release_train: str) -> None: + generated = prepared_release_paths(PREPARE_RELEASE.read_text(encoding="utf-8")) + for command in ("git restore --source refs/remotes/origin/main --", "git add"): + declared = declared_generated_git_paths(release_train, command) + missing = sorted(generated - declared) + extra = sorted(declared - generated) + if missing: + raise AssertionError(f"{command} omits generated release paths: {', '.join(missing)}") + if extra: + raise AssertionError(f"{command} admits non-generated release paths: {', '.join(extra)}") + + +def assert_compatibility_lock_allowlist_is_exact(release_train: str) -> None: + allowed = re.compile(release_branch_allowlist(release_train)) + for path in ( + "packages/kin/package-lock.json", + "packages/boundary-contracts/package-lock.json", + "packages/kin-mcp/npm-shrinkwrap.json", + "packages/kin-mcp/.npmrc", + "packages/kin-mcp/src/index.js", + "packages/kin-mcp/package-lock.json.extra", + ): + if allowed.match(path): + raise AssertionError(f"release branch lock allowlist admits non-generated path: {path}") + + def assert_release_hold_marker_contract( release_train: str, hold_alarm: str, @@ -706,6 +755,32 @@ def check_release_train_authority(release_train: str) -> None: ), ) assert_release_branch_allowlist_covers_generator(release_train) + assert_generated_branch_paths_restored_and_staged(release_train) + assert_compatibility_lock_allowlist_is_exact(release_train) + expect_assertion( + "the compatibility lock is removed from release branch admission", + "the release branch guard refuses paths the release generator writes", + lambda: assert_release_branch_allowlist_covers_generator( + release_train.replace(r"|packages/kin-mcp/package-lock\.json", "", 1) + ), + ) + lock_line = " packages/kin-mcp/package-lock.json \\\n" + for command, altered in ( + ("git restore --source refs/remotes/origin/main --", release_train.replace(lock_line, "", 1)), + ("git add", "".join(release_train.rsplit(lock_line, 1))), + ): + expect_assertion( + f"the compatibility lock is omitted from {command}", + f"{command} omits generated release paths: packages/kin-mcp/package-lock.json", + lambda altered=altered: assert_generated_branch_paths_restored_and_staged(altered), + ) + expect_assertion( + "compatibility lock admission expands to unrelated package locks", + "release branch lock allowlist admits non-generated path", + lambda: assert_compatibility_lock_allowlist_is_exact( + release_train.replace(r"packages/kin-mcp/package-lock\.json", r"packages/(kin-mcp|kin|boundary-contracts)/package-lock\.json", 1) + ), + ) diff --git a/scripts/test-env/setup.sh b/scripts/test-env/setup.sh index 467b87e43..84e81bf27 100755 --- a/scripts/test-env/setup.sh +++ b/scripts/test-env/setup.sh @@ -32,8 +32,9 @@ # otherwise create stores and index entries in the developer's real Kin home, # and leave them behind when its working tree is deleted. Each run gets # KIN_REGISTRY_PATH=/kin-home/registry.toml, which moves the registry, -# the store index and the central stores together. KIN_HOME is left alone so -# installed tools and model caches resolve as before. +# the store index and the central stores together. The same existing directory +# is KIN_HOME, so local runtime handshakes bind the test profile rather than a +# developer's installed runtime. It is selected before any test thread starts. set -u [ -n "${NEXTEST_ENV:-}" ] || exit 0 @@ -73,6 +74,7 @@ if [ -n "$run_dir" ]; then fi if mkdir -p -m 700 "$run_dir/kin-home"; then echo "KIN_REGISTRY_PATH=$run_dir/kin-home/registry.toml" >> "$NEXTEST_ENV" + echo "KIN_HOME=$run_dir/kin-home" >> "$NEXTEST_ENV" fi fi diff --git a/scripts/test-env/test_setup.py b/scripts/test-env/test_setup.py index ac2d156af..35c0286da 100644 --- a/scripts/test-env/test_setup.py +++ b/scripts/test-env/test_setup.py @@ -19,6 +19,7 @@ PROBE = """#!/bin/sh for arg in "$@"; do printf 'arg=%s\\n' "$arg"; done printf 'KIN_REGISTRY_PATH=%s\\n' "${KIN_REGISTRY_PATH-}" +printf 'KIN_HOME=%s\\n' "${KIN_HOME-}" printf 'KIN_TEST_FLUSH=%s\\n' "${KIN_TEST_FLUSH-}" printf 'TMPDIR=%s\\n' "${TMPDIR-}" if [ -n "${PROBE_SIGNAL:-}" ]; then kill -s "$PROBE_SIGNAL" $$; fi @@ -40,6 +41,7 @@ def test_shared_scratch_keeps_stores_and_projection_on_one_filesystem(self): settings = dict(line.split("=", 1) for line in exports.read_text().splitlines()) self.assertNotIn("TMPDIR", settings, "shared mode must retain the caller's scratch") registry = Path(settings["KIN_REGISTRY_PATH"]) + self.assertEqual(Path(settings["KIN_HOME"]), registry.parent) self.assertTrue(registry.is_relative_to(scratch), settings) self.assertEqual(registry.parent.stat().st_dev, scratch.stat().st_dev) self.assertEqual(registry.parent.stat().st_mode & 0o777, 0o700) @@ -96,6 +98,7 @@ def test_a_program_cargo_runs_passes_through_unchanged(self): self.assertEqual(result.returncode, 7, result.stderr) self.assertEqual(passed, args) self.assertEqual(seen["KIN_REGISTRY_PATH"], "") + self.assertEqual(seen["KIN_HOME"], "") self.assertEqual(seen["KIN_TEST_FLUSH"], "") self.assertEqual(seen["TMPDIR"], owned) @@ -113,6 +116,7 @@ def test_a_test_binary_starts_in_the_test_environment(self): self.assertEqual(result.returncode, 3, result.stderr) self.assertEqual(passed, ["--exact", "a::test"]) registry = Path(seen["KIN_REGISTRY_PATH"]) + self.assertEqual(Path(seen["KIN_HOME"]), registry.parent) self.assertTrue(registry.is_relative_to(owned), seen) self.assertTrue(registry.parent.is_dir(), seen) self.assertEqual(seen["KIN_TEST_FLUSH"], "fsync") @@ -126,6 +130,7 @@ def test_nextest_has_already_set_the_environment(self): Path(owned) / "target/debug/deps", [], TMPDIR=owned, **marker) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(seen["KIN_REGISTRY_PATH"], "") + self.assertEqual(seen["KIN_HOME"], "") self.assertEqual(seen["TMPDIR"], owned) diff --git a/scripts/test-io-lint.py b/scripts/test-io-lint.py index 6f7d470cc..83d488a42 100644 --- a/scripts/test-io-lint.py +++ b/scripts/test-io-lint.py @@ -187,6 +187,34 @@ def test_a_second_call_of_a_listed_method_in_the_same_function_is_accepted(self) found = [self.reported("crates/a/src/lib.rs", "load", "std::fs::read", line=n) for n in (3, 4)] self.assertEqual(lint.compare(found, allowed, None), ([], [])) + def test_windows_worker_cleanup_allowances_are_exact_sites(self): + update = "crates/kin-cli/src/commands/update.rs" + windows = "crates/kin-cli/src/commands/update_windows.rs" + native_open = "windows_sys::Win32::Storage::FileSystem::CreateFileW" + positives = { + lint.Site(update, "refuse_windows_worker_cleanup_with_recovery_fence", "std::fs::read_dir"), + lint.Site(update, "refuse_windows_worker_cleanup_with_recovery_fence", "std::fs::symlink_metadata"), + lint.Site(windows, "require_names", "std::fs::read_dir"), + lint.Site(windows, "require_worker_cleanup_absent", "std::fs::symlink_metadata"), + lint.Site(windows, "revalidate_worker_cleanup_object", native_open), + } + allowed = lint.load_allowed(lint.ALLOWED_SITES) + configured = lint.configured_methods(lint.CONFIG_DIR / "clippy.toml") + for site in positives: + self.assertIn(site, allowed) + self.assertIn(site.method, configured) + self.assertIn("std::fs::read_to_string", configured) + negatives = {lint.Site(windows, "validate", native_open)} + for site in positives: + negatives.update({ + lint.Site(site.file, site.function + "_semantic_neighbor", site.method), + lint.Site(site.file, site.function, "std::fs::read_to_string"), + lint.Site("crates/kin-cli/src/commands/search.rs", site.function, site.method), + }) + found = [lint.Reported(site, 1) for site in positives | negatives] + new, _ = lint.compare(found, allowed, None) + self.assertEqual({item.site for item in new}, negatives) + def test_the_list_round_trips_sorted(self): path = Path(tempfile.mkdtemp()) / "allowed.txt" sites = { diff --git a/scripts/test-managed-installer-routing.py b/scripts/test-managed-installer-routing.py new file mode 100644 index 000000000..08f268b5f --- /dev/null +++ b/scripts/test-managed-installer-routing.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Firelock, LLC + +"""Falsify direct replacement during an existing-install installer run.""" + +import os +import hashlib +import json +from pathlib import Path +import platform +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import unittest + + +INSTALLER = Path(__file__).with_name("install.sh") + + +class ManagedInstallerRouting(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="kin-installer-routing-") + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.root = self.home / "managed root" + self.bin = self.root / "bin" + self.bin.mkdir(parents=True) + self.calls = self.home / "native calls.jsonl" + self.tools = self.home / "tools" + self.tools.mkdir() + curl = self.tools / "curl" + curl.write_text("#!/bin/sh\nprintf 'unexpected network\\n' >&2\nexit 97\n") + curl.chmod(0o755) + self.cli = self.bin / "kin" + self.cli.write_text( + "#!/bin/sh\n" + "printf '%s\\n' \"$*\" >> \"$KIN_TEST_NATIVE_CALLS\"\n" + "case \"$1\" in\n" + " update) exit \"${KIN_TEST_UPDATE_EXIT:-0}\" ;;\n" + " runtime) printf '{\"schema\":\"kin.runtime-status.v1\",\"ready\":true}\\n'; " + "exit \"${KIN_TEST_STATUS_EXIT:-0}\" ;;\n" + " *) exit 91 ;;\n" + "esac\n" + ) + self.cli.chmod(0o755) + self.daemon = self.bin / "kin-daemon" + self.daemon.write_bytes(b"existing daemon bytes") + self.before_cli = self.cli.read_bytes() + self.before_daemon = self.daemon.read_bytes() + + def run_installer(self, **overrides): + env = { + **os.environ, + "HOME": str(self.home), + "KIN_HOME": str(self.root), + "KIN_NO_SETUP": "1", + "KIN_TEST_NATIVE_CALLS": str(self.calls), + "PATH": f"{self.tools}:/usr/bin:/bin", + } + for key in ["KIN_VERSION", "KIN_BASE_URL", "KIN_DIR", "KIN_RUNTIME_MODE"]: + env.pop(key, None) + env.update(overrides) + return subprocess.run( + ["sh", str(INSTALLER)], env=env, capture_output=True, text=True, timeout=10 + ) + + def assert_retained(self): + self.assertEqual(self.cli.read_bytes(), self.before_cli) + self.assertEqual(self.daemon.read_bytes(), self.before_daemon) + + def test_existing_install_only_calls_transaction_then_status(self): + result = self.run_installer() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.calls.read_text().splitlines(), ["runtime status --json", "update --apply", "runtime status --json"]) + self.assert_retained() + + def test_refused_transaction_does_not_replace_or_download(self): + result = self.run_installer(KIN_TEST_UPDATE_EXIT="17") + self.assertEqual(result.returncode, 17) + self.assertEqual(self.calls.read_text().splitlines(), ["runtime status --json", "update --apply"]) + self.assert_retained() + + def test_successful_transaction_without_ready_status_is_not_install_success(self): + result = self.run_installer(KIN_TEST_STATUS_EXIT="1") + self.assertEqual(result.returncode, 1) + self.assertEqual(self.calls.read_text().splitlines(), ["runtime status --json", "update --apply", "runtime status --json"]) + self.assertNotIn("Managed runtime updated and verified", result.stdout) + self.assert_retained() + + + def test_partial_existing_pair_is_not_treated_as_first_install(self): + self.cli.unlink() + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertIn("incomplete", result.stderr) + self.assertFalse(self.calls.exists()) + self.assertEqual(self.daemon.read_bytes(), self.before_daemon) + + def test_arbitrary_pin_cannot_be_reported_as_latest_update(self): + result = self.run_installer(KIN_VERSION="0.1.0") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Pinned or mirrored reinstalls", result.stderr) + self.assertFalse(self.calls.exists()) + self.assert_retained() + + def test_windows_posix_installer_directs_powershell_before_probe_or_mutation(self): + uname = self.tools / "uname" + uname.write_text("#!/bin/sh\nprintf 'MINGW64_NT-10.0\\n'\n") + uname.chmod(0o755) + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertIn("official PowerShell installer", result.stderr) + self.assertIn("irm https://get.kinlab.dev/install.ps1 | iex", result.stderr) + self.assertFalse(self.calls.exists()) + self.assertNotIn("unexpected network", result.stderr) + self.assert_retained() + + +class LegacyInstallerBootstrap(unittest.TestCase): + version = "9.9.9" + source = "b" * 40 + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="kin-installer-bootstrap-") + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name).resolve() + self.root = self.home / "managed root" + (self.root / "bin").mkdir(parents=True) + (self.root / "lib").mkdir() + self.stage_parent = self.home / "private stages" + self.stage_parent.mkdir() + self.tools = self.home / "tools" + self.tools.mkdir() + self.native_calls = self.home / "bootstrap.jsonl" + self.old_calls = self.home / "old-calls.log" + self.network_calls = self.home / "network.jsonl" + self.complete = self.home / "native-completed" + self.status_file = self.home / "final-status.json" + self.cli = self.root / "bin" / "kin" + self.daemon = self.root / "bin" / "kin-daemon" + self.daemon.write_bytes(b"old daemon bytes") + self.daemon.chmod(0o755) + (self.root / "registry.json").write_bytes(b"existing registry bytes") + (self.root / "lib" / "optional-shim").write_bytes(b"old optional bytes") + machine = {"arm64": "aarch64", "aarch64": "aarch64", "x86_64": "x86_64", "amd64": "x86_64"}[platform.machine().lower()] + os_name = {"Darwin": "macos", "Linux": "linux"}[platform.system()] + self.target = f"{os_name}-{machine}" + self.rust_target = f"{machine}-apple-darwin" if os_name == "macos" else f"{machine}-unknown-linux-musl" + self.archive_name = f"kin-{self.target}.tar.gz" + self.set_legacy_version("0.8.1") + identity = { + "schema": "kin.runtime-status.v1", "ready": True, "mode": "managed", + "pair_matches": True, "restart_needed": False, "kin_home": str(self.root.resolve()), + } + for component, filename in (("cli", "kin"), ("daemon", "kin-daemon")): + identity[component] = { + "path": str((self.root / "bin" / filename).resolve()), "version": self.version, + "commit_sha": self.source, "source_dirty": False, "target": self.rust_target, + "build_profile": "release", "sha256": "c" * 64, + } + self.identity = identity + self.status_file.write_text(json.dumps(identity)) + self.release_file = self.home / "release.json" + self.release_file.write_text(json.dumps({"tag_name": "v" + self.version, "draft": False, "prerelease": False})) + self.commit_file = self.home / "commit.json" + self.commit_file.write_text(json.dumps({"ref": "refs/tags/v" + self.version, "object": {"type": "commit", "sha": self.source}, "target_commitish": "ignored-branch"})) + archive_root = self.home / f"kin-{self.target}" + archive_root.mkdir() + staged = archive_root / "kin" + staged.write_text( + f"#!{sys.executable}\n" + "import json,os,pathlib,sys\n" + "record={'argv':sys.argv[1:],'cwd':os.getcwd(),'kin_home':os.environ.get('KIN_HOME'),'kin_dir':os.environ.get('KIN_DIR'),'mode':os.environ.get('KIN_RUNTIME_MODE'),'overrides':{name:os.environ.get(name) for name in ('KIN_DAEMON_BIN','KIN_MANAGED_BIN','KIN_BINARY_PATH','KIN_MCP_KIN_BINARY','KIN_DAEMON_URL')}}\n" + "with open(os.environ['KIN_TEST_BOOTSTRAP_CALLS'],'a') as f: f.write(json.dumps(record)+'\\n')\n" + "if sys.argv[1:3] != ['update','--bootstrap-published']: sys.exit(91)\n" + "result=int(os.environ.get('KIN_TEST_BOOTSTRAP_EXIT','0'))\n" + "if result == 0: pathlib.Path(os.environ['KIN_TEST_COMPLETED']).write_text('native transaction complete')\n" + "sys.exit(result)\n" + ) + staged.chmod(0o755) + (archive_root / "kin-daemon").write_bytes(b"incoming daemon bytes") + if os_name == "macos": + contents = archive_root / "KinNotifier.app" / "Contents" + (contents / "MacOS").mkdir(parents=True) + (contents / "Info.plist").write_text("fixture") + notifier = contents / "MacOS" / "KinNotifier" + notifier.write_text("#!/bin/sh\nexit 0\n") + notifier.chmod(0o755) + self.archive = self.home / self.archive_name + with tarfile.open(self.archive, "w:gz") as bundle: + bundle.add(archive_root, arcname=archive_root.name) + self.digest = hashlib.sha256(self.archive.read_bytes()).hexdigest() + self.checksum = self.home / (self.archive_name + ".sha256") + self.checksum.write_text(f"{self.digest} {self.archive_name}\n") + self.network_map = self.home / "network-map.json" + self.network_map.write_text(json.dumps({ + f"https://api.github.com/repos/kinlab-ai/kin/releases/tags/v{self.version}": str(self.release_file), + f"https://api.github.com/repos/kinlab-ai/kin/git/ref/tags/v{self.version}": str(self.commit_file), + f"https://github.com/kinlab-ai/kin/releases/download/v{self.version}/{self.archive_name}": str(self.archive), + f"https://github.com/kinlab-ai/kin/releases/download/v{self.version}/{self.archive_name}.sha256": str(self.checksum), + })) + curl = self.tools / "curl" + curl.write_text( + f"#!{sys.executable}\n" + "import json,os,shutil,sys\n" + "args=sys.argv[1:]; url=next(a for a in args if a.startswith('https://'))\n" + "with open(os.environ['KIN_TEST_NETWORK_CALLS'],'a') as f: f.write(json.dumps({'url':url})+'\\n')\n" + "if url.endswith('/releases/latest'): print('https://github.com/kinlab-ai/kin/releases/tag/v9.9.9',end=''); sys.exit(0)\n" + "mapping=json.load(open(os.environ['KIN_TEST_NETWORK_MAP']))\n" + "if url not in mapping: sys.exit(97)\n" + "shutil.copyfile(mapping[url],args[args.index('-o')+1])\n" + ) + curl.chmod(0o755) + self.baseline = self.snapshot() + + def set_legacy_version(self, version): + self.cli.write_text( + "#!/bin/sh\n" + "printf '%s\\n' \"$*\" >> \"$KIN_TEST_OLD_CALLS\"\n" + "if [ \"$1\" = runtime ] && [ -f \"$KIN_TEST_COMPLETED\" ]; then\n" + " cat \"$KIN_TEST_FINAL_STATUS\"; exit \"${KIN_TEST_FINAL_STATUS_EXIT:-0}\"\nfi\n" + f"if [ \"$1\" = --version ]; then printf 'kin {version}\\n'; exit 0; fi\n" + "printf 'unknown command runtime\\n' >&2\nexit 2\n" + ) + self.cli.chmod(0o755) + + def snapshot(self): + return {str(p.relative_to(self.root)): (stat.S_IMODE(p.stat().st_mode), p.read_bytes() if p.is_file() else None) + for p in self.root.rglob("*")} + + def run_installer(self, **overrides): + env = {**os.environ, "HOME": str(self.home), "TMPDIR": str(self.stage_parent), + "KIN_HOME": str(self.root), "KIN_DIR": "ignored alias", "KIN_RUNTIME_MODE": "development", + "KIN_NO_SETUP": "1", "KIN_TEST_BOOTSTRAP_CALLS": str(self.native_calls), + "KIN_TEST_OLD_CALLS": str(self.old_calls), "KIN_TEST_COMPLETED": str(self.complete), + "KIN_TEST_FINAL_STATUS": str(self.status_file), "KIN_TEST_NETWORK_MAP": str(self.network_map), + "KIN_TEST_NETWORK_CALLS": str(self.network_calls), + "KIN_DAEMON_BIN": "/unrelated/custom-daemon", "KIN_MANAGED_BIN": "/unrelated/custom-kin", + "KIN_BINARY_PATH": "/unrelated/custom-cli", "KIN_MCP_KIN_BINARY": "/unrelated/custom-mcp", + "KIN_DAEMON_URL": "http://unrelated-daemon.invalid", + "PATH": f"{self.tools}:{Path(sys.executable).parent}:/usr/bin:/bin"} + for key in ("KIN_VERSION", "KIN_BASE_URL"): + env.pop(key, None) + env.update(overrides) + return subprocess.run(["sh", str(INSTALLER)], env=env, capture_output=True, text=True, timeout=20, cwd=self.home) + + def test_legacy_081_and_086_stage_exact_official_tuple_without_direct_mutation(self): + for version in ("0.8.1", "0.8.6"): + with self.subTest(version=version): + self.set_legacy_version(version) + baseline = self.snapshot() + self.complete.unlink(missing_ok=True) + self.native_calls.unlink(missing_ok=True) + self.old_calls.unlink(missing_ok=True) + result = self.run_installer() + self.assertEqual(result.returncode, 0, result.stderr) + calls = [json.loads(line) for line in self.native_calls.read_text().splitlines()] + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["argv"], ["update", "--bootstrap-published", "--channel", "stable", + "--expect-version", self.version, "--expect-sha", self.source, + "--expect-archive-sha256", self.digest, "--expect-target", self.rust_target, + "--expect-archive-name", self.archive_name, "--apply"]) + self.assertEqual(calls[0]["kin_home"], str(self.root)) + self.assertIsNone(calls[0]["kin_dir"]) + self.assertEqual(calls[0]["mode"], "managed") + self.assertTrue(all(value is None for value in calls[0]["overrides"].values())) + self.assertTrue(Path(calls[0]["cwd"]).is_relative_to(self.stage_parent)) + self.assertFalse(Path(calls[0]["cwd"]).is_relative_to(self.root)) + self.assertEqual(self.old_calls.read_text().splitlines(), ["runtime status --json", "runtime status --json"]) + self.assertEqual(self.snapshot(), baseline) + self.assertEqual(list(self.stage_parent.iterdir()), []) + + def test_native_preflight_refusal_preserves_installed_state_and_exact_stage(self): + result = self.run_installer(KIN_TEST_BOOTSTRAP_EXIT="17") + self.assertEqual(result.returncode, 17, result.stderr) + self.assertEqual(self.snapshot(), self.baseline) + self.assertEqual(self.old_calls.read_text().splitlines(), ["runtime status --json"]) + stages = list(self.stage_parent.iterdir()) + self.assertEqual(len(stages), 1) + self.assertEqual(hashlib.sha256((stages[0] / self.archive_name).read_bytes()).hexdigest(), self.digest) + self.assertTrue((stages[0] / "release.json").is_file()) + self.assertTrue((stages[0] / "tag-ref.json").is_file()) + self.assertIn("Migration is pending", result.stderr) + self.assertIn(str(stages[0]), result.stderr) + + def test_final_status_failure_retains_stage_and_never_claims_completion(self): + self.identity["ready"] = False + self.status_file.write_text(json.dumps(self.identity)) + result = self.run_installer(KIN_TEST_FINAL_STATUS_EXIT="1") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(self.snapshot(), self.baseline) + self.assertEqual(len(list(self.stage_parent.iterdir())), 1) + self.assertIn("Completion remains pending", result.stderr) + self.assertNotIn("Managed runtime migrated and verified", result.stdout) + + def test_same_version_wrong_source_does_not_pass_final_proof(self): + self.identity["daemon"]["commit_sha"] = "d" * 40 + self.status_file.write_text(json.dumps(self.identity)) + result = self.run_installer() + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(self.snapshot(), self.baseline) + self.assertEqual(len(list(self.stage_parent.iterdir())), 1) + self.assertNotIn("Managed runtime migrated and verified", result.stdout) + + def test_unresolved_tag_or_draft_release_refuses_before_native_execution(self): + for bad_metadata in ({"object": {"type": "commit", "sha": "main"}}, {"object": {"type": "commit", "sha": "b" * 39}}, {"object": {"type": "tree", "sha": "b" * 40}}): + with self.subTest(metadata=bad_metadata): + self.commit_file.write_text(json.dumps({"ref": "refs/tags/v" + self.version, **bad_metadata})) + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.snapshot(), self.baseline) + self.assertFalse(self.native_calls.exists()) + self.assertEqual(list(self.stage_parent.iterdir()), []) + self.commit_file.write_text(json.dumps({"ref": "refs/tags/v" + self.version, "object": {"type": "commit", "sha": self.source}})) + self.release_file.write_text(json.dumps({"tag_name": "v" + self.version, "draft": True, "prerelease": False})) + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.snapshot(), self.baseline) + self.assertFalse(self.native_calls.exists()) + + def test_archive_checksum_mismatch_refuses_before_native_execution(self): + self.checksum.write_text(f"{'f' * 64} {self.archive_name}\n") + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertIn("SHA-256 checksum mismatch", result.stderr) + self.assertFalse(self.native_calls.exists()) + self.assertEqual(self.snapshot(), self.baseline) + + def test_relative_original_home_survives_private_staging_cwd(self): + result = self.run_installer(KIN_HOME="managed root") + self.assertEqual(result.returncode, 0, result.stderr) + call = json.loads(self.native_calls.read_text()) + self.assertEqual(call["kin_home"], str(self.root)) + self.assertEqual(self.snapshot(), self.baseline) + + def test_stage_parent_inside_managed_home_refuses_without_creating_entries(self): + result = self.run_installer(TMPDIR=str(self.root)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("outside the managed Kin home", result.stderr) + self.assertEqual(self.snapshot(), self.baseline) + self.assertFalse(self.native_calls.exists()) + + def test_annotated_tag_is_peeled_and_annotation_identity_is_verified(self): + tag_sha = "e" * 40 + self.commit_file.write_text(json.dumps({"ref": "refs/tags/v" + self.version, + "object": {"type": "tag", "sha": tag_sha}})) + annotation = self.home / "annotation.json" + annotation.write_text(json.dumps({"sha": tag_sha, "object": {"type": "commit", "sha": self.source}})) + mapping = json.loads(self.network_map.read_text()) + mapping[f"https://api.github.com/repos/kinlab-ai/kin/git/tags/{tag_sha}"] = str(annotation) + self.network_map.write_text(json.dumps(mapping)) + result = self.run_installer() + self.assertEqual(result.returncode, 0, result.stderr) + call = json.loads(self.native_calls.read_text()) + self.assertEqual(call["argv"][call["argv"].index("--expect-sha") + 1], self.source) + self.assertEqual(self.snapshot(), self.baseline) + self.complete.unlink() + self.native_calls.unlink() + annotation.write_text(json.dumps({"sha": "f" * 40, "object": {"type": "commit", "sha": self.source}})) + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertIn("requested tag object", result.stderr) + self.assertFalse(self.native_calls.exists()) + self.assertEqual(self.snapshot(), self.baseline) + + def test_missing_json_parser_refuses_legacy_migration_without_installed_mutation(self): + no_parser_tools = self.home / "tools-without-parser" + no_parser_tools.mkdir() + for command in ("sh", "uname", "git", "grep", "sed", "mktemp", "chmod", "rm", "id", "tr", "awk"): + executable = shutil.which(command) + if executable is not None: + (no_parser_tools / command).symlink_to(executable) + (no_parser_tools / "curl").symlink_to(self.tools / "curl") + result = self.run_installer(PATH=str(no_parser_tools)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("requires python3, node, or jq", result.stderr) + self.assertEqual(self.snapshot(), self.baseline) + self.assertFalse(self.native_calls.exists()) + self.assertEqual(list(self.stage_parent.iterdir()), []) + + def test_annotated_tag_cycle_has_a_bounded_refusal(self): + tag_sha = "e" * 40 + self.commit_file.write_text(json.dumps({"ref": "refs/tags/v" + self.version, + "object": {"type": "tag", "sha": tag_sha}})) + annotation = self.home / "cyclic-annotation.json" + annotation.write_text(json.dumps({"sha": tag_sha, "object": {"type": "tag", "sha": tag_sha}})) + mapping = json.loads(self.network_map.read_text()) + annotation_url = f"https://api.github.com/repos/kinlab-ai/kin/git/tags/{tag_sha}" + mapping[annotation_url] = str(annotation) + self.network_map.write_text(json.dumps(mapping)) + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertIn("annotation depth", result.stderr) + requests = [json.loads(line)["url"] for line in self.network_calls.read_text().splitlines()] + self.assertEqual(requests.count(annotation_url), 5) + self.assertFalse(self.native_calls.exists()) + self.assertEqual(self.snapshot(), self.baseline) + + def test_same_version_wrong_target_dirty_source_or_restart_fence_is_not_ready(self): + for component, field, value in (("cli", "target", "different-target"), + ("daemon", "source_dirty", True), + (None, "restart_needed", True)): + with self.subTest(field=field): + status = json.loads(json.dumps(self.identity)) + (status[component] if component else status)[field] = value + self.status_file.write_text(json.dumps(status)) + self.complete.unlink(missing_ok=True) + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("Managed runtime migrated and verified", result.stdout) + self.assertEqual(self.snapshot(), self.baseline) + + def test_native_report_with_equivalent_physical_parent_alias_is_accepted(self): + alias = self.home / "root alias" + alias.symlink_to(self.root, target_is_directory=True) + self.identity["kin_home"] = str(alias) + self.identity["cli"]["path"] = str(alias / "bin" / "kin") + self.identity["daemon"]["path"] = str(alias / "bin" / "kin-daemon") + self.status_file.write_text(json.dumps(self.identity)) + result = self.run_installer() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.snapshot(), self.baseline) + + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-release-rehearsal-compat.py b/scripts/test-release-rehearsal-compat.py index e77b74f2c..d3e4c4bed 100644 --- a/scripts/test-release-rehearsal-compat.py +++ b/scripts/test-release-rehearsal-compat.py @@ -3,12 +3,15 @@ # Copyright 2026 Firelock, LLC """Negative controls for the fixed, released Windows compatibility baseline.""" import importlib.util +import hashlib import json import os from pathlib import Path import sys import tempfile import unittest +from unittest import mock +import zipfile SOURCE = Path(__file__).with_name("release-rehearsal-compat.py") SPEC = importlib.util.spec_from_file_location("release_rehearsal_compat", SOURCE) @@ -177,5 +180,85 @@ def test_failure_retains_stdout_stderr_and_exit_code(self): self.assertEqual(json.loads((output / "negative.exit.json").read_text())["exit_code"], 3) +class ArchivePreparation(unittest.TestCase): + """Inert archive data controls; no executable or native readiness is simulated.""" + def fixture(self, root, entries=None): + entries = entries or [("kin.exe", b"inert CLI bytes"), ("kin-daemon.exe", b"inert daemon bytes")] + archive = root / "kin-windows-x86_64.zip" + with zipfile.ZipFile(archive, "w") as bundle: + for name, body in entries: + bundle.writestr(name, body) + checksum = hashlib.sha256(archive.read_bytes()).hexdigest() + Path(str(archive) + ".sha256").write_text(checksum + " " + archive.name + "\n") + expected = {"name": archive.name, "sha256": checksum, + "components": {name: {"sha256": hashlib.sha256(body).hexdigest(), "size_bytes": len(body)} + for name, body in entries}} + return archive, expected + + def test_preparation_copies_only_verified_bytes_and_makes_no_admission_claim(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive, expected = self.fixture(root) + with mock.patch.object(COMPAT, "BASELINE_ARCHIVE", expected): + report = COMPAT.prepare_baseline(archive, root / "fixture-account") + self.assertFalse(report["candidate_installer_invoked"]) + self.assertFalse(report["modern_managed_admission"]) + self.assertEqual(report["source_archive"]["sha256"], expected["sha256"]) + for name, component in expected["components"].items(): + info = report["installed"][name] + self.assertEqual({key: info[key] for key in ("sha256", "size_bytes")}, component) + + def test_wrong_archive_pin_is_refused_before_account_creation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive, expected = self.fixture(root) + expected["sha256"] = "0" * 64 + home = root / "fixture-account" + with mock.patch.object(COMPAT, "BASELINE_ARCHIVE", expected), self.assertRaisesRegex(ValueError, "reviewed release"): + COMPAT.prepare_baseline(archive, home) + self.assertFalse(home.exists()) + + def test_sidecar_must_bind_exact_archive_name_and_hash(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive, expected = self.fixture(root) + for text in (expected["sha256"] + " other.zip\n", "0" * 64 + " " + archive.name + "\n", "invalid"): + Path(str(archive) + ".sha256").write_text(text) + with mock.patch.object(COMPAT, "BASELINE_ARCHIVE", expected), self.subTest(text=text), self.assertRaisesRegex(ValueError, "sidecar"): + COMPAT.read_baseline_archive(archive) + + def test_missing_or_duplicate_daemon_cannot_be_materialized(self): + for entries in ([('kin.exe', b'inert CLI')], + [('kin.exe', b'inert CLI'), ('kin-daemon.exe', b'inert daemon'), ('kin-daemon.exe', b'duplicate')]): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive, expected = self.fixture(root, entries) + expected["components"]["kin-daemon.exe"] = {"sha256": hashlib.sha256(b'inert daemon').hexdigest(), "size_bytes": len(b'inert daemon')} + with mock.patch.object(COMPAT, "BASELINE_ARCHIVE", expected), self.assertRaisesRegex(ValueError, "unique regular"): + COMPAT.read_baseline_archive(archive) + + def test_component_hash_and_size_have_independent_checks(self): + for key, value in (("sha256", "0" * 64), ("size_bytes", 1)): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive, expected = self.fixture(root) + expected["components"]["kin-daemon.exe"][key] = value + with mock.patch.object(COMPAT, "BASELINE_ARCHIVE", expected), self.subTest(key=key), self.assertRaisesRegex(ValueError, "reviewed component"): + COMPAT.read_baseline_archive(archive) + + def test_existing_bin_directory_is_preserved_and_refused(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive, expected = self.fixture(root) + home = root / "fixture-account" + installed = home / ".kin" / "bin" / "kin.exe" + installed.parent.mkdir(parents=True) + installed.write_bytes(b"existing account pair") + with mock.patch.object(COMPAT, "BASELINE_ARCHIVE", expected), self.assertRaisesRegex(ValueError, "existing or linked"): + COMPAT.prepare_baseline(archive, home) + self.assertEqual(installed.read_bytes(), b"existing account pair") + self.assertFalse(installed.with_name("kin-daemon.exe").exists()) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/test-windows-install-mirror.ps1 b/scripts/test-windows-install-mirror.ps1 index b85203bde..e307c51b3 100644 --- a/scripts/test-windows-install-mirror.ps1 +++ b/scripts/test-windows-install-mirror.ps1 @@ -5,8 +5,9 @@ Exercise the real Windows installer against a local release mirror. .DESCRIPTION Downloads the reviewed v0.8.6 ZIP and its public checksum once, then tests the candidate install.ps1 in child pwsh processes using file:// paths with spaces. -The receipt covers installer boundaries against stable binaries. Candidate -binaries still require their own RC Build and release qualification. +The receipt covers checksum/extraction boundaries and the exact expected +runtime refusal of legacy stable binaries. Current-protocol binaries require +positive runtime readiness and their own RC Build and release qualification. User PATH changes require a disposable GitHub-hosted Windows x86_64 account. #> [CmdletBinding()] @@ -16,7 +17,9 @@ param( [string]$OutputRoot, [ValidateNotNullOrEmpty()] - [string]$InstallerPath = (Join-Path $PSScriptRoot "install.ps1") + [string]$InstallerPath = (Join-Path $PSScriptRoot "install.ps1"), + + [switch]$RetainBaselineArchive ) $ErrorActionPreference = "Stop" @@ -148,6 +151,69 @@ function Get-ZipComponents { return $Components } +function Get-RuntimeBoundaryEvidence { + param([string]$PayloadVersion, [int]$ExitCode, [string]$Stdout, [string]$Stderr) + $VersionMatch = [regex]::Match($PayloadVersion, '^\d+\.\d+\.\d+(?:[-+].*)?$') + if (-not $VersionMatch.Success) { throw "Payload version cannot select a runtime protocol." } + $CoreVersion = [version]($PayloadVersion -replace '[-+].*$', '') + $Unsupported = "error: unrecognized subcommand 'runtime'" + if ($Stdout -cnotmatch [regex]::Escape("[ok] SHA-256 checksum verified")) { + throw "Valid file mirror did not report its actual checksum verification." + } + if ($CoreVersion -lt [version]"0.8.7") { + if ($ExitCode -ne 2 -or $Stderr -cnotmatch ("(?m)^" + [regex]::Escape($Unsupported) + "\r?$") ) { + throw "Legacy payload must refuse runtime admission with exit 2 and its exact unsupported-command diagnostic." + } + return [ordered]@{ + coverage = "checksum-extraction-and-legacy-runtime-refusal" + installer_succeeded = $false + runtime_ready = $false + runtime_admission = "expected-legacy-refusal" + expected_installer_exit_code = 2 + expected_runtime_diagnostic = $Unsupported + } + } + if ($ExitCode -ne 0 -or $Stderr -cmatch [regex]::Escape($Unsupported)) { + throw "Current-protocol payload requires positive runtime admission; installer exit was $ExitCode." + } + return [ordered]@{ + coverage = "checksum-extraction-and-runtime-readiness" + installer_succeeded = $true + runtime_ready = $true + runtime_admission = "ready" + expected_installer_exit_code = 0 + } +} + +function Test-RuntimeBoundaryEvidence { + $Verified = "[ok] SHA-256 checksum verified" + $Unsupported = "error: unrecognized subcommand 'runtime'" + $Controls = @( + @{ version = "0.8.6"; code = 2; stdout = $Verified; stderr = $Unsupported; accepts = $true; ready = $false }, + @{ version = "0.8.6"; code = 0; stdout = $Verified; stderr = $Unsupported; accepts = $false; ready = $false }, + @{ version = "0.8.6"; code = 2; stdout = $Verified; stderr = "different failure"; accepts = $false; ready = $false }, + @{ version = "0.8.6"; code = 2; stdout = ""; stderr = $Unsupported; accepts = $false; ready = $false }, + @{ version = "0.8.7"; code = 0; stdout = $Verified; stderr = ""; accepts = $true; ready = $true }, + @{ version = "0.8.7"; code = 2; stdout = $Verified; stderr = $Unsupported; accepts = $false; ready = $false }, + @{ version = "0.8.7"; code = 0; stdout = $Verified; stderr = $Unsupported; accepts = $false; ready = $false }, + @{ version = "0.8.7"; code = 1; stdout = $Verified; stderr = "not ready"; accepts = $false; ready = $false }, + @{ version = "0.8.8"; code = 0; stdout = $Verified; stderr = ""; accepts = $true; ready = $true }, + @{ version = "unknown"; code = 0; stdout = $Verified; stderr = ""; accepts = $false; ready = $false } + ) + foreach ($Control in $Controls) { + $Accepted = $false + $Evidence = $null + try { + $Evidence = Get-RuntimeBoundaryEvidence $Control.version $Control.code $Control.stdout $Control.stderr + $Accepted = $true + } catch {} + if ($Accepted -ne $Control.accepts -or ($Accepted -and $Evidence.runtime_ready -ne $Control.ready)) { + throw "Runtime boundary data control failed for $($Control.version), exit $($Control.code)." + } + } + return $Controls.Count +} + function Invoke-InstallCase { param([System.Collections.IDictionary]$Case) $Capture = Join-Path $OutputRoot $Case.name @@ -180,7 +246,8 @@ function Invoke-InstallCase { checksum_fixture = $Case.checksum checksum_sha256 = if (Test-Path -LiteralPath $Sidecar) { Get-Sha256 $Sidecar } else { $null } mirror_uri = $MirrorUri - expected_success = $Case.success + expected_boundary_success = $Case.success + expected_success = $Case.success -and ([version]$Version -ge [version]"0.8.7") installer_exit_code = $null installed = [ordered]@{} } @@ -214,7 +281,6 @@ function Invoke-InstallCase { throw "The installer did not finish within its deadline." } if ($Case.success) { - if ($Child.ExitCode -ne 0) { throw "Valid file mirror install failed with exit $($Child.ExitCode)." } foreach ($Name in @("kin.exe", "kin-daemon.exe")) { if (-not $Row.installed.Contains($Name)) { throw "Valid mirror install omitted $Name." } if ( @@ -229,13 +295,16 @@ function Invoke-InstallCase { $Row.installed[$Name]["version_exit_code"] = $VersionResult.ExitCode $Row.installed[$Name]["version_text"] = $VersionResult.Stdout.Trim() $Program = [regex]::Escape(($Name -replace '\.exe$', '')) + $ExpectedVersion = [regex]::Escape($Version) if ( $VersionResult.TimedOut -or $VersionResult.ExitCode -ne 0 -or - $VersionResult.Stdout.Trim() -cnotmatch "^$Program 0\.8\.6(?: \([^\r\n]*\))?$" + $VersionResult.Stdout.Trim() -cnotmatch "^$Program $ExpectedVersion(?: \([^\r\n]*\))?$" ) { throw "Installed $Name did not report the stable baseline version." } } + $Evidence = Get-RuntimeBoundaryEvidence $Version $Child.ExitCode $Child.Stdout $Child.Stderr + foreach ($Key in $Evidence.Keys) { $Row[$Key] = $Evidence[$Key] } } else { if ($Child.ExitCode -eq 0) { throw "Invalid mirror install unexpectedly succeeded." } if ($Row.installed.Count -ne 0) { throw "Invalid mirror install left installed binaries." } @@ -271,14 +340,21 @@ $Result = [ordered]@{ baseline = [ordered]@{ tag = "v0.8.6"; version = $Version; archive_sha256 = $ExpectedArchiveHash } installer = [ordered]@{ path = $InstallerPath; sha256 = Get-Sha256 $InstallerPath } host_scope = "disposable GitHub-hosted native Windows x86_64 account" - scope = "real installer file-mirror/checksum/archive boundaries using stable released bytes; candidate binaries require separate qualification" + scope = "real stable checksum/extraction and exact legacy runtime refusal; no legacy readiness claim; current-protocol binaries require separate positive qualification" + runtime_protocol_min_version = "0.8.7" cases = [System.Collections.Generic.List[object]]::new() environment_restored = $false user_path_restored = $false scratch_removed = $false } $Succeeded = $false +$RetainedArchive = $null +$RetainedArchiveVerified = $false try { + $Result["runtime_classifier_controls"] = [ordered]@{ + coverage = "process-data-only" + passed = Test-RuntimeBoundaryEvidence + } Add-Type -AssemblyName System.IO.Compression.FileSystem $SourceArchive = Join-Path $Scratch $ArchiveName $SourceChecksum = Join-Path $OutputRoot "$ArchiveName.sha256" @@ -335,6 +411,18 @@ try { if ((Get-Sha256 $InstallerPath) -cne $Result.installer.sha256) { throw "Candidate installer bytes changed during the proof." } + if ($RetainBaselineArchive) { + $RetainedArchive = Join-Path $OutputRoot $ArchiveName + if (Test-Path -LiteralPath $RetainedArchive) { + throw "Refusing to replace an existing retained baseline archive." + } + Copy-Item -LiteralPath $SourceArchive -Destination $RetainedArchive + if ((Get-Sha256 $RetainedArchive) -cne $ExpectedArchiveHash) { + throw "Retained baseline archive differs from the reviewed source ZIP." + } + $RetainedArchiveVerified = $true + $Result.source_archive["captured_file"] = $ArchiveName + } $Succeeded = $true $Result.status = "PASS" } catch { @@ -372,7 +460,18 @@ try { try { Remove-Item -LiteralPath $Scratch -Recurse -Force $Result.scratch_removed = $true - if ($Result.Contains("source_archive")) { $Result.source_archive.retained = $false } + if ($Result.Contains("source_archive")) { + $Result.source_archive.retained = $false + if ($RetainedArchiveVerified) { + if ( + -not (Test-Path -LiteralPath $RetainedArchive -PathType Leaf) -or + (Get-Sha256 $RetainedArchive) -cne $ExpectedArchiveHash + ) { + throw "Verified retained baseline archive is missing or changed during cleanup." + } + $Result.source_archive.retained = $true + } + } } catch { $CleanupErrors.Add($_.Exception.Message) } diff --git a/scripts/test-windows-worker-cleanup.ps1 b/scripts/test-windows-worker-cleanup.ps1 new file mode 100644 index 000000000..ea8cc3f50 --- /dev/null +++ b/scripts/test-windows-worker-cleanup.ps1 @@ -0,0 +1,229 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Firelock, LLC + +# Exercise actual release binaries and native worker authority in an isolated +# home. Lock obstruction consumes the worker before any HTTP request. Markers +# and object identities are created exclusively by the actual native commands. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$OutputRoot, + [Parameter(Mandatory = $true)] + [string]$KinCliPath, + [Parameter(Mandatory = $true)] + [string]$KinDaemonPath, + [string]$InstallerPath = (Join-Path $PSScriptRoot 'install.ps1') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$global:LASTEXITCODE = 0 +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + throw 'Native Windows worker controls require Windows.' +} +$OutputRoot = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputRoot) +if (Test-Path -LiteralPath $OutputRoot) { throw 'OutputRoot must be new.' } +New-Item -ItemType Directory -Path $OutputRoot | Out-Null +$KinCliPath = (Resolve-Path -LiteralPath $KinCliPath).Path +$KinDaemonPath = (Resolve-Path -LiteralPath $KinDaemonPath).Path + +# Load only the production argument, synchronous transport and native-dispatch +# helpers. No installer entry point or wrapper filesystem cleanup is invoked. +$Tokens = $null +$ParseErrors = $null +$Ast = [System.Management.Automation.Language.Parser]::ParseFile($InstallerPath, [ref]$Tokens, [ref]$ParseErrors) +if (@($ParseErrors).Count -ne 0) { throw "Installer parse failed: $ParseErrors" } +foreach ($HelperName in @('Normalize-KinRuntimePath', 'ConvertTo-KinWindowsArgument', + 'Invoke-KinWindowsProcess', 'Invoke-KinWindowsWorkerCleanup')) { + $Definitions = @($Ast.FindAll({ + param($Node) + $Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -ceq $HelperName + }, $true)) + if ($Definitions.Count -ne 1) { throw "Expected exactly one production helper $HelperName." } + Invoke-Expression $Definitions[0].Extent.Text +} + +$FixtureHome = Join-Path $OutputRoot 'managed home with spaces' +$FixtureBin = Join-Path $FixtureHome 'bin' +New-Item -ItemType Directory -Path $FixtureBin -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $FixtureHome 'lib') | Out-Null +$CanonicalCli = Join-Path $FixtureBin 'kin.exe' +$CanonicalDaemon = Join-Path $FixtureBin 'kin-daemon.exe' +Copy-Item -LiteralPath $KinCliPath -Destination $CanonicalCli +Copy-Item -LiteralPath $KinDaemonPath -Destination $CanonicalDaemon +$PairHashes = @{ + cli = (Get-FileHash -LiteralPath $CanonicalCli -Algorithm SHA256).Hash + daemon = (Get-FileHash -LiteralPath $CanonicalDaemon -Algorithm SHA256).Hash +} +$Rows = [Collections.Generic.List[object]]::new() +$SavedEnvironment = @{} +foreach ($VariableName in @('KIN_HOME', 'KIN_DIR', 'KIN_RUNTIME_MODE', 'KIN_DAEMON_BIN', 'KIN_MANAGED_BIN', + 'KIN_BINARY_PATH', 'KIN_MCP_KIN_BINARY', 'KIN_DAEMON_URL', 'KIN_VERSION', 'KIN_BASE_URL')) { + $SavedEnvironment[$VariableName] = [Environment]::GetEnvironmentVariable($VariableName, 'Process') + Remove-Item -LiteralPath "Env:$VariableName" -ErrorAction SilentlyContinue +} +$env:KIN_HOME = $FixtureHome +$env:KIN_RUNTIME_MODE = 'managed' + +function Assert-Control([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } +} +function Run-Control([string]$ControlName, [scriptblock]$Test) { + & $Test + $Rows.Add([pscustomobject]@{ name = $ControlName; status = 'PASS' }) +} +function Prepare-RealWorker { + $Prepared = Invoke-KinWindowsProcess $CanonicalCli @('update', '--prepare-windows-worker') $FixtureHome -CaptureOutput + Assert-Control ($Prepared.ExitCode -eq 0) "Native prepare failed: $($Prepared.Stderr)" + $Worker = $Prepared.Stdout | ConvertFrom-Json + Assert-Control ($Worker.schema -ceq 'kin.windows-update-worker.v1') 'Native prepare schema mismatch.' + $LockPath = Normalize-KinRuntimePath $Worker.authority_lock_file + $LockFile = Get-Item -LiteralPath $LockPath + Assert-Control (-not $LockFile.PSIsContainer -and + -not ($LockFile.Attributes -band [IO.FileAttributes]::ReparsePoint)) 'Reported native lock is not a real file.' + Assert-Control ((Normalize-KinRuntimePath $LockFile.DirectoryName) -eq (Normalize-KinRuntimePath $OutputRoot)) 'Reported native lock is outside the fixture parent.' + Assert-Control ($LockFile.Name -match '^\.kin-install-authority-[0-9a-f]{64}\.lock$') 'Reported native lock basename mismatch.' + return [pscustomobject]@{ + Worker = $Worker + Directory = [IO.Path]::GetDirectoryName($Worker.worker_executable) + LockPath = $LockPath + HandoffHash = (Get-FileHash -LiteralPath $Worker.handoff_record -Algorithm SHA256).Hash.ToLowerInvariant() + Result = $null + } +} +function Consume-RealWorkerOffline($Fixture) { + $SavedLock = Join-Path $OutputRoot ('owned-lock-' + [Guid]::NewGuid().ToString('N')) + Move-Item -LiteralPath $Fixture.LockPath -Destination $SavedLock + New-Item -ItemType Directory -Path $Fixture.LockPath | Out-Null + try { + $Fixture.Result = Invoke-KinWindowsProcess $Fixture.Worker.worker_executable @( + 'update', '--windows-worker', $Fixture.Worker.handoff_record, '--apply' + ) $Fixture.Directory -CaptureOutput + Assert-Control ($Fixture.Result.ExitCode -ne 0) 'Owned lock obstruction did not fail the real worker.' + $Claim = Get-Content -LiteralPath (Join-Path $Fixture.Directory 'claimed') -Raw | ConvertFrom-Json + Assert-Control ($Claim.schema -ceq 'kin.windows-update-worker-consumed.v1') 'Actual worker did not emit the consumed schema.' + Assert-Control ($Claim.worker_process.pid -eq $Fixture.Result.ProcessId) 'Consumed identity does not match waited child.' + Assert-Control ($Claim.handoff_sha256 -ceq $Fixture.HandoffHash) 'Consumed identity does not bind original handoff.' + return $Fixture + } finally { + # Remove only the empty obstruction created by this fixture, then put + # back the identical native-owned lock. No recursive deletion is used. + [IO.Directory]::Delete($Fixture.LockPath, $false) + Move-Item -LiteralPath $SavedLock -Destination $Fixture.LockPath + } +} +function Clean-RealWorker($Fixture) { + return Invoke-KinWindowsWorkerCleanup $CanonicalCli $Fixture.Worker $Fixture.Result $Fixture.HandoffHash $FixtureHome +} +function Assert-Retained($Fixture) { + Assert-Control (Test-Path -LiteralPath $Fixture.Worker.worker_executable) 'Worker helper was unexpectedly removed.' + Assert-Control (Test-Path -LiteralPath $Fixture.Worker.handoff_record) 'Handoff authority was unexpectedly removed.' +} +function Assert-Cleaned($Fixture) { + Assert-Control (Clean-RealWorker $Fixture) 'Actual native cleanup did not remove the consumed handoff.' + Assert-Control (-not (Test-Path -LiteralPath $Fixture.Directory)) 'Native cleanup left the consumed directory.' +} + +try { + $Status = Invoke-KinWindowsProcess $CanonicalCli @('runtime', 'status', '--json') $FixtureHome -CaptureOutput + Assert-Control ($Status.ExitCode -eq 0) "Actual candidate pair is not ready: $($Status.Stdout) $($Status.Stderr)" + $InitialStatus = $Status.Stdout | ConvertFrom-Json + Assert-Control ($InitialStatus.ready -eq $true -and $InitialStatus.pair_matches -eq $true -and + $InitialStatus.mode -ceq 'managed' -and $InitialStatus.cli.source_dirty -eq $false) 'Candidate pair does not have clean managed identity.' + + Run-Control 'real-consumed-failure-cleans-with-original-exit-preserved' { + $Fixture = Consume-RealWorkerOffline (Prepare-RealWorker) + $OriginalExit = $Fixture.Result.ExitCode + Assert-Cleaned $Fixture + Assert-Control ($Fixture.Result.ExitCode -eq $OriginalExit -and $OriginalExit -ne 0) 'Cleanup rewrote a failed update result.' + } + Run-Control 'inherited-native-error-policy-cannot-hide-returned-child' { + $PreviousNativePolicy = Get-Variable PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue + $SavedNativePolicy = if ($null -ne $PreviousNativePolicy) { $PreviousNativePolicy.Value } else { $null } + try { + $PSNativeCommandUseErrorActionPreference = $true + $Fixture = Consume-RealWorkerOffline (Prepare-RealWorker) + Assert-Control ($Fixture.Result.ExitCode -ne 0 -and $Fixture.Result.ProcessId -gt 0) 'Inherited policy hid native completion.' + Assert-Cleaned $Fixture + } finally { + if ($null -ne $PreviousNativePolicy) { $PSNativeCommandUseErrorActionPreference = $SavedNativePolicy } + else { Remove-Variable PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue } + } + } + Run-Control 'unconsumed-native-handoff-is-preserved' { + $Fixture = Prepare-RealWorker + $Cleanup = Invoke-KinWindowsProcess $CanonicalCli @('update', '--cleanup-windows-worker', $Fixture.Worker.handoff_record) $FixtureHome -CaptureOutput + Assert-Control ($Cleanup.ExitCode -ne 0) 'Native cleanup accepted an unconsumed handoff.' + Assert-Retained $Fixture + $Fixture = Consume-RealWorkerOffline $Fixture + Assert-Cleaned $Fixture + } + $BackupFenceName = '.update-backup-' + [Guid]::NewGuid().ToString() + foreach ($FenceName in @($BackupFenceName, 'update-restart-ack-required.json', 'update-mcp-repair-pending.json')) { + Run-Control "native-recovery-fence-$FenceName-preserves" { + $Fixture = Consume-RealWorkerOffline (Prepare-RealWorker) + $FencePath = Join-Path $FixtureHome $FenceName + [IO.File]::WriteAllText($FencePath, 'malformed fixture authority') + try { + Assert-Control (-not (Clean-RealWorker $Fixture)) 'Native cleanup ignored a recovery fence.' + Assert-Retained $Fixture + Assert-Control ([IO.File]::ReadAllText($FencePath) -ceq 'malformed fixture authority') 'Recovery fence was altered.' + } finally { Remove-Item -LiteralPath $FencePath } + Assert-Cleaned $Fixture + } + } + Run-Control 'unknown-private-entry-preserves-whole-native-directory' { + $Fixture = Consume-RealWorkerOffline (Prepare-RealWorker) + $UnknownPath = Join-Path $Fixture.Directory 'unknown.txt' + [IO.File]::WriteAllText($UnknownPath, 'unknown fixture contents') + try { + Assert-Control (-not (Clean-RealWorker $Fixture)) 'Unknown private entry was ignored.' + Assert-Retained $Fixture + Assert-Control ([IO.File]::ReadAllText($UnknownPath) -ceq 'unknown fixture contents') 'Unknown entry was altered.' + } finally { Remove-Item -LiteralPath $UnknownPath } + Assert-Cleaned $Fixture + } + foreach ($ComponentName in @('worker_executable', 'handoff_record')) { + Run-Control "equal-bytes-replaced-native-$ComponentName-is-preserved" { + $Fixture = Consume-RealWorkerOffline (Prepare-RealWorker) + $OriginalPath = $Fixture.Worker.$ComponentName + $OriginalObject = Join-Path $OutputRoot ('owned-original-' + [Guid]::NewGuid().ToString('N')) + Move-Item -LiteralPath $OriginalPath -Destination $OriginalObject + Copy-Item -LiteralPath $OriginalObject -Destination $OriginalPath + try { + Assert-Control (-not (Clean-RealWorker $Fixture)) 'Equal bytes hid a changed native object.' + Assert-Retained $Fixture + } finally { + Remove-Item -LiteralPath $OriginalPath + Move-Item -LiteralPath $OriginalObject -Destination $OriginalPath + } + Assert-Cleaned $Fixture + } + } + Run-Control 'caller-waited-pid-and-record-hash-bind-native-dispatch' { + $Fixture = Consume-RealWorkerOffline (Prepare-RealWorker) + $WrongResult = [pscustomobject]@{ ExitCode = $Fixture.Result.ExitCode; ProcessId = $Fixture.Result.ProcessId + 4 } + Assert-Control (-not (Invoke-KinWindowsWorkerCleanup $CanonicalCli $Fixture.Worker $WrongResult $Fixture.HandoffHash $FixtureHome)) 'Caller accepted another worker PID.' + Assert-Control (-not (Invoke-KinWindowsWorkerCleanup $CanonicalCli $Fixture.Worker $Fixture.Result ('0' * 64) $FixtureHome)) 'Caller accepted another handoff hash.' + Assert-Retained $Fixture + Assert-Cleaned $Fixture + } + Assert-Control ((Get-FileHash -LiteralPath $CanonicalCli -Algorithm SHA256).Hash -ceq $PairHashes.cli) 'Offline controls changed canonical CLI.' + Assert-Control ((Get-FileHash -LiteralPath $CanonicalDaemon -Algorithm SHA256).Hash -ceq $PairHashes.daemon) 'Offline controls changed canonical daemon.' + $Receipt = [ordered]@{ + schema = 'kin.windows-worker-cleanup-controls.v2' + installer_sha256 = (Get-FileHash -LiteralPath $InstallerPath -Algorithm SHA256).Hash.ToLowerInvariant() + powershell_version = $PSVersionTable.PSVersion.ToString() + cli_sha256 = $PairHashes.cli.ToLowerInvariant() + daemon_sha256 = $PairHashes.daemon.ToLowerInvariant() + runtime_identity = $InitialStatus + controls = @($Rows) + release_install_qualification = $false + } + $Receipt | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath (Join-Path $OutputRoot 'receipt.json') + Write-Host "Native Windows copied-worker controls passed: $($Rows.Count)" +} finally { + foreach ($VariableName in $SavedEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($VariableName, $SavedEnvironment[$VariableName], 'Process') + } +} diff --git a/scripts/test-zero-file-boundary-pins.py b/scripts/test-zero-file-boundary-pins.py index 4ceb0d495..46815bd3e 100644 --- a/scripts/test-zero-file-boundary-pins.py +++ b/scripts/test-zero-file-boundary-pins.py @@ -62,6 +62,10 @@ "crates/kin-daemon/src/history_schedule.rs", "crates/kin-daemon/src/bin/kin-daemon.rs", "crates/kin-daemon-spawn/src/runtime_authority.rs", + "crates/kin-cli/src/commands/runtime.rs", + "crates/kin-io/src/confined.rs", + "crates/kin-io/src/store.rs", + "crates/kin-core/src/runtime_identity.rs", ) UNPINNED_FILES = ( "crates/kin-agent/src/belt.rs", diff --git a/scripts/test_install_optional_vfs.py b/scripts/test_install_optional_vfs.py index 6065f2465..c63196e28 100644 --- a/scripts/test_install_optional_vfs.py +++ b/scripts/test_install_optional_vfs.py @@ -424,7 +424,6 @@ def test_a_group_writable_kin_home_is_named_with_its_exact_repair(self) -> None: result, kin_home = self.run_installer( vfs_exit=0, umask=0o002, - seed_current_install=True, group_writable_kin_home=True, ) @@ -434,15 +433,12 @@ def test_a_group_writable_kin_home_is_named_with_its_exact_repair(self) -> None: self.assertIn("KIN_REGISTRY_REPAIR=1", result.stderr) self.assertNotIn("For permission-only drift", result.stderr) self.assertEqual(stat.S_IMODE(kin_home.stat().st_mode), 0o775) - self.assertIn( - "old-kin", (kin_home / "bin" / "kin").read_text(encoding="utf-8") - ) + self.assertFalse((kin_home / "bin" / "kin").exists()) def test_registry_repair_consent_covers_a_group_writable_kin_home(self) -> None: result, kin_home = self.run_installer( vfs_exit=0, umask=0o002, - seed_current_install=True, group_writable_kin_home=True, extra_env={"KIN_REGISTRY_REPAIR": "1"}, ) @@ -489,26 +485,16 @@ def test_an_archive_without_projection_installs_and_says_so(self) -> None: self.assertTrue((kin_home / "bin" / "kin-daemon").exists()) self.assertFalse((kin_home / "bin" / "kin-vfs").exists()) - def test_upgrading_past_the_retirement_clears_the_stale_projection(self) -> None: - """A v0.7.17 kin-vfs must not outlive the release that retired it. - - Left in place it sits on PATH beside a daemon several releases newer, - and the installs that most need this are exactly the ones recovering - from an updater that cannot move itself. - """ - + def test_existing_projection_is_not_removed_outside_native_transaction(self) -> None: result, kin_home = self.run_installer( - vfs_exit=0, - archive_vfs=False, - seed_current_install=True, + vfs_exit=0, archive_vfs=False, seed_current_install=True, seed_stale_projection=True, ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("Removed the filesystem projection", result.stdout) - self.assertFalse((kin_home / "bin" / "kin-vfs").exists()) - self.assertFalse(any((kin_home / "lib").glob("libkin_vfs_shim.*"))) - self.assertTrue((kin_home / "bin" / "kin-daemon").exists()) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Pinned or mirrored reinstalls", result.stderr) + self.assertTrue((kin_home / "bin" / "kin-vfs").exists()) + self.assertTrue(any((kin_home / "lib").glob("libkin_vfs_shim.*"))) + self.assertIn("old-daemon", (kin_home / "bin" / "kin-daemon").read_text()) def test_extraction_refuses_the_archives_recorded_ownership(self) -> None: # tar running as root restores the uid/gid the archive records, and @@ -573,28 +559,14 @@ def test_interactive_archive_download_exposes_live_byte_percent_meter(self) -> N self.assertIn("% Total", result.stderr) self.assertIn("% Received", result.stderr) - def test_same_version_managed_reinstall_restores_a_missing_bundle(self) -> None: + def test_same_version_pin_cannot_repair_bundles_outside_native_transaction(self) -> None: result, kin_home = self.run_installer( - vfs_exit=0, - pretend_macos=True, - seed_current_install=True, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("Existing install found: kin 9.9.9 (will be replaced)", result.stdout) - notifier = ( - kin_home - / "lib" - / "KinNotifier.app" - / "Contents" - / "MacOS" - / "KinNotifier" - ) - self.assertIn("new-notifier", notifier.read_text(encoding="utf-8")) - self.assertTrue(notifier.stat().st_mode & stat.S_IXUSR) - self.assertTrue( - (kin_home / "lib" / "KinNotifier.app" / "Contents" / "Info.plist").is_file() + vfs_exit=0, pretend_macos=True, seed_current_install=True, ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Pinned or mirrored reinstalls", result.stderr) + self.assertIn("old-kin", (kin_home / "bin" / "kin").read_text()) + self.assertFalse((kin_home / "lib" / "KinNotifier.app").exists()) def test_malformed_macos_archive_refuses_before_replacing_existing_install(self) -> None: result, kin_home = self.run_installer( @@ -606,7 +578,7 @@ def test_malformed_macos_archive_refuses_before_replacing_existing_install(self) ) self.assertNotEqual(result.returncode, 0) - self.assertIn("No installed binary or notification bundle was replaced", result.stderr) + self.assertIn("Pinned or mirrored reinstalls", result.stderr) self.assertIn( "kin 9.9.9", (kin_home / "bin" / "kin").read_text(encoding="utf-8"), @@ -634,8 +606,7 @@ def test_symlinked_bundle_ancestor_refuses_before_replacing_install_or_stamp(sel ) self.assertNotEqual(result.returncode, 0) - self.assertIn("contains a symlink or special entry", result.stderr) - self.assertIn("No installed binary or notification bundle was replaced", result.stderr) + self.assertIn("Pinned or mirrored reinstalls", result.stderr) self.assertEqual( (kin_home / "bin" / "kin").read_text(encoding="utf-8"), "#!/bin/sh\n# old-kin\nprintf 'kin 9.9.9\\n'\n", @@ -666,6 +637,16 @@ def test_symlinked_bundle_ancestor_refuses_before_replacing_install_or_stamp(sel "8.8.8\n", ) + def test_fresh_bootstrap_rejects_symlinked_notification_bundle_before_native_bytes(self) -> None: + result, kin_home = self.run_installer( + vfs_exit=0, pretend_macos=True, symlinked_notifier_ancestry=True, + existing_notifier=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("contains a symlink or special entry", result.stderr) + self.assertFalse((kin_home / "bin" / "kin").exists()) + self.assertIn("old-notifier", (kin_home / "lib" / "KinNotifier.app" / "Contents" / "MacOS" / "KinNotifier").read_text()) + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify-installer-archive-binaries.py b/scripts/verify-installer-archive-binaries.py index bb79b5329..2048b1120 100755 --- a/scripts/verify-installer-archive-binaries.py +++ b/scripts/verify-installer-archive-binaries.py @@ -97,6 +97,8 @@ ("windows x86_64 (MSYS)", "MSYS_NT-10.0-22631", "x86_64"), ("windows x86_64 (Cygwin)", "CYGWIN_NT-10.0-22631", "amd64"), ) +WINDOWS_UNAME_PREFIXES = ("MINGW", "MSYS", "CYGWIN") +OFFICIAL_POWERSHELL_COMMAND = "irm https://get.kinlab.dev/install.ps1 | iex" class GuardError(RuntimeError): @@ -285,7 +287,7 @@ def installer_env(root: Path, fake_bin: Path, releases: Path, home: Path) -> dic return env -def requested_asset(uname_s: str, uname_m: str) -> str: +def requested_asset(uname_s: str, uname_m: str) -> str | None: """Ask install.sh which artifact this host is served. The release directory is empty on purpose: the installer prints the name it @@ -306,6 +308,14 @@ def requested_asset(uname_s: str, uname_m: str) -> str: capture_output=True, text=True, ) + managed_home_created = (home / ".kin").exists() or (home / ".kin").is_symlink() + output = ANSI.sub("", result.stdout + result.stderr) + if uname_s.startswith(WINDOWS_UNAME_PREFIXES): + if result.returncode == 0 or DOWNLOAD_LINE.search(output) or managed_home_created or OFFICIAL_POWERSHELL_COMMAND not in output: + raise GuardError( + "install.sh must provide the official PowerShell redirect before any Windows download or managed write" + ) + return None for line in ANSI.sub("", result.stdout).splitlines(): match = DOWNLOAD_LINE.search(line) if match: @@ -353,6 +363,8 @@ def build_archive(directory: Path, leg: Leg) -> Path: def check_host(label: str, uname_s: str, uname_m: str, resolved: dict[str, Leg]) -> str: artifact = requested_asset(uname_s, uname_m) + if artifact is None: + return f"{label}: refused before download or managed write with official PowerShell redirect" leg = resolved.get(artifact) if leg is None: raise GuardError( diff --git a/scripts/verify-installer-release-assets.py b/scripts/verify-installer-release-assets.py index f9a9fda92..fc126896b 100644 --- a/scripts/verify-installer-release-assets.py +++ b/scripts/verify-installer-release-assets.py @@ -8,9 +8,10 @@ and the release publishes a fixed list of asset names. Nothing compared the two lists, so a disagreement shipped through every release to date: the POSIX installer builds `kin--.tar.gz` on every platform it supports, -including the MSYS, MINGW, and CYGWIN shells it maps to `windows`, while the -Windows leg published only a `.zip`. Piping the documented curl command into a -shell on Windows therefore 404'd. +while native Windows uses PowerShell and npm ZIP archives. The POSIX entry +point must refuse MSYS, MINGW and CYGWIN before downloading or installing and +direct the user to the official PowerShell command. Published compatibility +archives retain their separate release inventory and attestation requirements. The names checked here are DERIVED from the installers rather than restated. `scripts/install.sh` is executed once per platform against a stubbed `uname` and @@ -24,6 +25,7 @@ from __future__ import annotations import argparse +import json import os import re import shutil @@ -59,6 +61,10 @@ ATTEST_SUBJECT_KEY = " subject-path: |" INVENTORY_STEP = " - name: Verify complete release asset inventory" INVENTORY_ASSETS = re.compile(r"^\s+assets=\(\n(?P(?:\s+\S+\n)+?)\s*\)\s*$", re.M) +COMPATIBILITY_STEP = " - name: Verify and aggregate release provenance" +COMPATIBILITY_ARCHIVES = re.compile(r"^\s+additionalArchives:\s*\[(?P[^\]]*)\],\s*$", re.M) +OFFICIAL_POWERSHELL_COMMAND = "irm https://get.kinlab.dev/install.ps1 | iex" +WINDOWS_UNAME_PREFIXES = ("MINGW", "MSYS", "CYGWIN") class GuardError(RuntimeError): @@ -85,9 +91,8 @@ class Platform: Platform("linux aarch64", "kin-linux-aarch64", "Linux", "aarch64", ("linux", "arm64")), Platform("macos x86_64", "kin-macos-x86_64", "Darwin", "x86_64", ("darwin", "x64")), Platform("macos aarch64", "kin-macos-aarch64", "Darwin", "arm64", ("darwin", "arm64")), - # The three shells whose uname the installer maps to `windows`. All three - # reach the same published artifact, and all three 404'd before the Windows - # leg published a tarball. + # Windows shells must receive the PowerShell redirect, while the native + # PowerShell/npm surfaces still resolve their release ZIP independently. Platform( "windows x86_64 (Git Bash)", "kin-windows-x86_64", @@ -114,7 +119,7 @@ def executable(path: Path, body: str) -> None: path.chmod(path.stat().st_mode | stat.S_IXUSR) -def posix_installer_asset(install_sh: Path, platform: Platform) -> str: +def posix_installer_asset(install_sh: Path, platform: Platform) -> str | None: """Ask install.sh itself what it downloads on this platform.""" with tempfile.TemporaryDirectory() as temp: root = Path(temp) @@ -156,8 +161,16 @@ def posix_installer_asset(install_sh: Path, platform: Platform) -> str: timeout=180, check=False, ) + managed_home_created = (home / ".kin").exists() or (home / ".kin").is_symlink() output = ANSI.sub("", completed.stdout + completed.stderr) names = DOWNLOAD_LINE.findall(output) + if platform.uname_s.startswith(WINDOWS_UNAME_PREFIXES): + if completed.returncode == 0 or names or managed_home_created or OFFICIAL_POWERSHELL_COMMAND not in output: + raise GuardError( + f"{install_sh.name} must refuse {platform.label} with the official PowerShell redirect " + f"before any download or managed write. Output:\n{output}" + ) + return None if len(names) != 1: raise GuardError( f"{install_sh.name} named {len(names)} archives on {platform.label} " @@ -306,6 +319,25 @@ def inventory_assets(workflow_source: str) -> set[str]: return {line.strip() for line in bodies[0].splitlines() if line.strip()} +def compatibility_archives(workflow_source: str) -> list[str]: + """Derive separately published legacy containers from their provenance step.""" + source = "\n".join(workflow_step_lines(workflow_source, COMPATIBILITY_STEP)) + declarations = re.findall(r"^\s+additionalArchives\s*:", source, re.M) + matches = COMPATIBILITY_ARCHIVES.findall(source) + if not declarations or len(matches) != len(declarations): + raise GuardError("release compatibility archive declarations are missing or unreadable") + archives: list[str] = [] + for items in matches: + try: + names = json.loads(f"[{items}]") + except json.JSONDecodeError as error: + raise GuardError("release compatibility archive declaration is not a literal list") from error + if not names or not all(isinstance(name, str) and ASSET_NAME.fullmatch(name) for name in names): + raise GuardError("release compatibility archive declaration contains an invalid name") + archives.extend(names) + return archives + + def asset_sources(assets_dir: Path | None, workflow_source: str) -> list[tuple[str, set[str]]]: """Every list a published asset has to appear in, with a name for each. @@ -355,7 +387,7 @@ def guarded_platform_artifacts(workflow_source: str) -> None: ) -def required_assets() -> dict[str, Requirement]: +def required_assets(workflow_source: str) -> dict[str, Requirement]: required: dict[str, Requirement] = {} def record(asset: str, surface: str) -> None: @@ -365,10 +397,9 @@ def record(asset: str, surface: str) -> None: for platform in PLATFORMS: if platform.uname_s: - record( - posix_installer_asset(INSTALL_SH, platform), - f"scripts/install.sh on {platform.label}", - ) + asset = posix_installer_asset(INSTALL_SH, platform) + if asset is not None: + record(asset, f"scripts/install.sh on {platform.label}") # The npm launcher and the PowerShell installer detect their platform # from the running process rather than from uname, so they are reported # against the artifact they resolve instead of against a shell name. @@ -384,6 +415,8 @@ def record(asset: str, surface: str) -> None: ) if not required: raise GuardError("no installer asset names were derived; the check is vacuous") + for asset in compatibility_archives(workflow_source): + record(asset, "the release provenance compatibility archive declaration") return required @@ -404,7 +437,7 @@ def main() -> int: workflow_source = RELEASE_WORKFLOW.read_text(encoding="utf-8") try: guarded_platform_artifacts(workflow_source) - required = required_assets() + required = required_assets(workflow_source) sources = asset_sources(args.assets_dir, workflow_source) except GuardError as error: print(f"installer asset guard: {error}", file=sys.stderr) diff --git a/scripts/verify-native-release-provenance.mjs b/scripts/verify-native-release-provenance.mjs index 91452be18..72a17165b 100644 --- a/scripts/verify-native-release-provenance.mjs +++ b/scripts/verify-native-release-provenance.mjs @@ -29,9 +29,10 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; const COMMIT_SHA = /^[0-9a-f]{40}$/; const VERSION_LINE_SHA = /\(([0-9a-f]{40})(-dirty)?\s/; @@ -170,8 +171,89 @@ export function judgeBuildMeta(meta, { expectedCommit, expectedVersion, lockSha, } } -const run = (file, args) => - execFileSync(file, args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], maxBuffer: 64 * 1024 * 1024 }); +const run = (file, args, options = {}) => + execFileSync(file, args, { ...options, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], maxBuffer: 64 * 1024 * 1024 }); + +const identityFields = ["version", "commit_sha", "source_dirty", "source_known", "target", "build_profile"]; +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const identityFromMeta = (meta) => ({ + version: meta.kin_version, + commit_sha: meta.kin_commit, + source_dirty: meta.kin_dirty, + source_known: meta.kin_source_known, + target: meta.kin_target, + build_profile: meta.kin_build_profile, +}); +const identityFromStatus = (binary) => Object.fromEntries(identityFields.map((key) => [key, binary?.[key]])); + +function activateCanonicalPair(args, { env, exec, log }) { + const root = mkdtempSync(join(realpathSync(args.out), "native-runtime-")); + try { + const home = join(root, "home"), kinHome = join(home, ".kin"), bin = join(kinHome, "bin"); + mkdirSync(bin, { recursive: true, mode: 0o700 }); + const suffix = args.target.includes("windows") ? ".exe" : ""; + const pair = Object.fromEntries([["cli", args.kin, `kin${suffix}`], ["daemon", args.daemon, `kin-daemon${suffix}`]].map(([role, source, name]) => { + const bytes = readFileSync(source), path = join(bin, name), hash = sha256(bytes); + writeFileSync(path, bytes, { flag: "wx", mode: 0o755 }); + if (sha256(readFileSync(path)) !== hash) throw new Error(`canonical ${role} copy changed the selected artifact bytes`); + return [role, { source: realpathSync(source), path, sha256: hash }]; + })); + const scopedEnv = Object.fromEntries(Object.entries(env).filter(([key]) => + !/^(?:_?KIN_|GIT_|DYLD_|LD_|HOME$|USERPROFILE$|APPDATA$|LOCALAPPDATA$|XDG_)/i.test(key))); + Object.assign(scopedEnv, { HOME: home, USERPROFILE: home, KIN_HOME: kinHome, KIN_DIR: kinHome, + KIN_RUNTIME_MODE: "managed", APPDATA: join(home, "appdata"), LOCALAPPDATA: join(home, "local-appdata"), + XDG_CONFIG_HOME: join(home, "config"), XDG_CACHE_HOME: join(home, "cache") }); + const kinVersion = exec(args.kin, ["--version"], { env: scopedEnv }); + const daemonVersion = exec(args.daemon, ["--version"], { env: scopedEnv }); + writeFileSync(join(args.out, "kin-version.txt"), kinVersion); + writeFileSync(join(args.out, "kin-daemon-version.txt"), daemonVersion); + log(kinVersion.trim()); + log(daemonVersion.trim()); + judgeVersionLines([kinVersion.trim(), daemonVersion.trim()], args.expectCommit); + const probe = (binary, argv, name) => { + const text = exec(binary, argv, { env: scopedEnv }); + writeFileSync(join(args.out, name), text); + try { return JSON.parse(text); } + catch (cause) { throw new Error(`${name} did not print JSON: ${cause.message}`, { cause }); } + }; + const cliMeta = probe(pair.cli.path, ["bench-meta", "--json", "--runtime-identity-only"], "kin-runtime-build-identity.json"); + if (cliMeta.schema !== "kin.runtime-build-identity.v1") throw new Error("CLI runtime identity schema is unsupported"); + const identity = identityFromMeta(cliMeta); + if (typeof identity.version !== "string" || !identity.version || + !COMMIT_SHA.test(identity.commit_sha ?? "") || identity.commit_sha !== args.expectCommit || + identity.source_dirty !== false || identity.source_known !== true || + identity.target !== args.target || identity.build_profile !== "release" || + (args.expectVersion !== undefined && identity.version !== args.expectVersion)) { + throw new Error("selected CLI runtime identity did not prove the expected clean, known release target"); + } + const daemon = probe(pair.daemon.path, ["--compat-json"], "kin-daemon-compat.json"); + if (daemon.schema !== "kin.daemon.compat.v2" || !isDeepStrictEqual(daemon.runtime_identity, identity)) { + throw new Error("independent daemon runtime identity does not match the selected CLI"); + } + const status = probe(pair.cli.path, ["runtime", "status", "--json"], "kin-runtime-status.json"); + if (status.schema !== "kin.runtime-status.v1" || status.ready !== true || status.mode !== "managed" || + status.pair_matches !== true || status.kin_home !== kinHome) { + throw new Error("isolated canonical runtime pair is not ready in managed mode"); + } + for (const role of ["cli", "daemon"]) { + if (status[role]?.path !== pair[role].path || status[role]?.sha256 !== pair[role].sha256 || + !isDeepStrictEqual(identityFromStatus(status[role]), identity) || + sha256(readFileSync(pair[role].path)) !== pair[role].sha256 || + sha256(readFileSync(pair[role].source)) !== pair[role].sha256) { + throw new Error(`canonical ${role} path, hash or identity differs from the selected artifact`); + } + } + writeFileSync(join(args.out, "kin-canonical-pair.json"), JSON.stringify({ + schema: "kin.native-provenance-canonical-pair.v1", verified: true, pair, identity, + mode: status.mode, channel: status.channel, release_kind: status.release_kind, + scope: "isolated selected archive execution; no public release or installer first-contact claim", + }, null, 2) + "\n"); + return { root, pair, env: scopedEnv, identity }; + } catch (error) { + rmSync(root, { recursive: true, force: true }); + throw error; + } +} export function verify(args, { env = process.env, exec = run, log = console.log } = {}) { const plan = executionPlan(hostArch(env), args.target); @@ -217,34 +299,43 @@ export function verify(args, { env = process.env, exec = run, log = console.log } mkdirSync(args.out, { recursive: true }); - const kinVersion = exec(args.kin, ["--version"]); - const daemonVersion = exec(args.daemon, ["--version"]); - const benchMeta = exec(args.kin, ["bench-meta", "--json"]); - writeFileSync(join(args.out, "kin-version.txt"), kinVersion); - writeFileSync(join(args.out, "kin-daemon-version.txt"), daemonVersion); - writeFileSync(join(args.out, "kin-build-meta.json"), benchMeta); - log(kinVersion.trim()); - log(daemonVersion.trim()); - - judgeVersionLines([kinVersion.trim(), daemonVersion.trim()], args.expectCommit); - const lockSha = createHash("sha256").update(readFileSync(args.cargoLock)).digest("hex"); - let meta; + const canonical = activateCanonicalPair(args, { env, exec, log }); try { - meta = JSON.parse(benchMeta); - } catch (cause) { - throw new Error(`kin bench-meta --json did not print JSON: ${cause.message}`, { cause }); + const benchMeta = exec(canonical.pair.cli.path, ["bench-meta", "--json"], { env: canonical.env }); + writeFileSync(join(args.out, "kin-build-meta.json"), benchMeta); + const lockSha = createHash("sha256").update(readFileSync(args.cargoLock)).digest("hex"); + let meta; + try { + meta = JSON.parse(benchMeta); + } catch (cause) { + throw new Error(`kin bench-meta --json did not print JSON: ${cause.message}`, { cause }); + } + judgeBuildMeta(meta, { + expectedCommit: args.expectCommit, + expectedVersion: args.expectVersion, + lockSha, + target: args.target, + }); + const fullIdentity = identityFromMeta(meta); + for (const key of identityFields) { + if ((["target", "build_profile"].includes(key) && fullIdentity[key] === undefined)) continue; + if (fullIdentity[key] !== canonical.identity[key]) { + throw new Error("full metadata identity differs from the independently verified canonical pair"); + } + } + for (const binary of Object.values(canonical.pair)) { + if (sha256(readFileSync(binary.path)) !== binary.sha256 || sha256(readFileSync(binary.source)) !== binary.sha256) { + throw new Error("selected artifact bytes changed during full metadata execution"); + } + } + log( + `${args.target}: kin and kin-daemon executed ${plan === "rosetta" ? "under Rosetta 2" : "natively"} ` + + `and proved ${args.expectCommit}, a clean known source, Cargo.lock ${lockSha} and the default archive profile`, + ); + return { executed: true, plan, lockSha }; + } finally { + rmSync(canonical.root, { recursive: true, force: true }); } - judgeBuildMeta(meta, { - expectedCommit: args.expectCommit, - expectedVersion: args.expectVersion, - lockSha, - target: args.target, - }); - log( - `${args.target}: kin and kin-daemon executed ${plan === "rosetta" ? "under Rosetta 2" : "natively"} ` + - `and proved ${args.expectCommit}, a clean known source, Cargo.lock ${lockSha} and the default archive profile`, - ); - return { executed: true, plan, lockSha }; } function isDirectRun() { diff --git a/scripts/verify-native-release-provenance.test.mjs b/scripts/verify-native-release-provenance.test.mjs index 01e052f08..e500bf680 100644 --- a/scripts/verify-native-release-provenance.test.mjs +++ b/scripts/verify-native-release-provenance.test.mjs @@ -97,13 +97,13 @@ test("bench-meta must prove the version, a clean known source, the lock and the function fakeExec(outputs) { const calls = []; - const exec = (file, args) => { + const exec = (file, args, options = {}) => { calls.push([file, ...args]); - const key = [path.basename(file), ...args].join(" "); + const key = [path.basename(file).replace(/\.exe$/, ""), ...args].join(" "); if (!(key in outputs)) throw new Error(`unexpected call ${key}`); const value = outputs[key]; if (value instanceof Error) throw value; - return value; + return typeof value === "function" ? value({ file, args, options, calls }) : value; }; return { exec, calls }; } @@ -111,12 +111,33 @@ function fakeExec(outputs) { function fixture() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kin-native-provenance-")); fs.writeFileSync(path.join(dir, "Cargo.lock"), LOCK); + fs.writeFileSync(path.join(dir, "kin"), "selected CLI artifact bytes\n", { mode: 0o755 }); + fs.writeFileSync(path.join(dir, "kin-daemon"), "selected daemon artifact bytes\n", { mode: 0o755 }); return dir; } -const goodOutputs = (over = {}) => ({ +const runtimeIdentity = (target = "aarch64-apple-darwin", over = {}) => ({ + version: "0.8.3", commit_sha: SHA, source_dirty: false, source_known: true, + target, build_profile: "release", ...over, +}); +const identityMeta = (target = "aarch64-apple-darwin", over = {}) => ({ + schema: "kin.runtime-build-identity.v1", kin_version: "0.8.3", kin_commit: SHA, + kin_dirty: false, kin_source_known: true, kin_target: target, kin_build_profile: "release", ...over, +}); +const runtimeStatus = ({ file, options }, target = "aarch64-apple-darwin", over = {}) => { + const daemon = path.join(path.dirname(file), `kin-daemon${file.endsWith(".exe") ? ".exe" : ""}`); + const binary = (name) => ({ path: name, ...runtimeIdentity(target), + sha256: createHash("sha256").update(fs.readFileSync(name)).digest("hex") }); + return { schema: "kin.runtime-status.v1", ready: true, mode: "managed", pair_matches: true, + kin_home: options.env.KIN_HOME, cli: binary(file), daemon: binary(daemon), + channel: "stable", release_kind: "unknown", ...over }; +}; +const goodOutputs = (over = {}, target = "aarch64-apple-darwin") => ({ "kin --version": `kin 0.8.3 (${SHA} 2026-10-12)\n`, "kin-daemon --version": `kin-daemon 0.8.3 (${SHA} 2026-10-12)\n`, + "kin bench-meta --json --runtime-identity-only": JSON.stringify(identityMeta(target)), + "kin-daemon --compat-json": JSON.stringify({ schema: "kin.daemon.compat.v2", runtime_identity: runtimeIdentity(target) }), + "kin runtime status --json": (context) => JSON.stringify(runtimeStatus(context, target)), "kin bench-meta --json": JSON.stringify(meta()), ...over, }); @@ -203,9 +224,9 @@ test("a binary from another commit or a different lock fails the leg", () => { const dir = fixture(); const other = "b".repeat(40); for (const [outputs, expected] of [ - [goodOutputs({ "kin --version": `kin 0.8.3 (${other} x)\n` }), /do not match checkout/], - [goodOutputs({ "kin bench-meta --json": JSON.stringify(meta({ dependency_provenance: "d".repeat(64) })) }), /Cargo\.lock/], - [goodOutputs({ "kin bench-meta --json": "not json" }), /did not print JSON/], + [goodOutputs({ "kin --version": `kin 0.8.3 (${other} x)\n` }, "x86_64-unknown-linux-musl"), /do not match checkout/], + [goodOutputs({ "kin bench-meta --json": JSON.stringify(meta({ dependency_provenance: "d".repeat(64) })) }, "x86_64-unknown-linux-musl"), /Cargo\.lock/], + [goodOutputs({ "kin bench-meta --json": "not json" }, "x86_64-unknown-linux-musl"), /did not print JSON/], ]) { const { exec } = fakeExec(outputs); assert.throws( @@ -253,13 +274,28 @@ test("the argument parser refuses a loose commit and missing paths", () => { // not only the functions it calls. test("the CLI runs real executables and exits non-zero on a wrong commit", { skip: process.platform === "win32" }, () => { const dir = fixture(); + const arch = process.arch === "arm64" ? "ARM64" : "X64"; + const target = process.arch === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"; const script = (name, version) => - `#!/bin/sh\nif [ "$1" = bench-meta ]; then cat "${path.join(dir, "meta.json")}"; else echo "${name} 0.8.3 (${version} 2026-10-12)"; fi\n`; - fs.writeFileSync(path.join(dir, "meta.json"), JSON.stringify(meta())); + `#!/usr/bin/env node +const fs = require("node:fs"), path = require("node:path"), crypto = require("node:crypto"); +const args = process.argv.slice(2), name = ${JSON.stringify(name)}; +const identity = ${JSON.stringify(runtimeIdentity(target, { commit_sha: version }))}; +const print = (value) => console.log(typeof value === "string" ? value : JSON.stringify(value)); +if (args[0] === "--version") print(${JSON.stringify(`${name} 0.8.3 (${version} 2026-10-12)`)}); +else if (name === "kin-daemon" && args[0] === "--compat-json") print({schema:"kin.daemon.compat.v2",runtime_identity:identity}); +else if (name === "kin" && args[0] === "bench-meta" && args.includes("--runtime-identity-only")) print(${JSON.stringify(identityMeta(target, { kin_commit: version }))}); +else { + const cli = path.join(process.env.KIN_HOME, "bin", "kin"), daemon = path.join(process.env.KIN_HOME, "bin", "kin-daemon"); + if (fs.realpathSync(process.argv[1]) !== cli || process.env.KIN_HOME !== path.join(process.env.HOME, ".kin") || process.env.KIN_RUNTIME_MODE !== "managed") process.exit(9); + const binary = (file) => ({path:file,...identity,sha256:crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}); + if (args[0] === "runtime" && args[1] === "status") print({schema:"kin.runtime-status.v1",ready:true,mode:"managed",pair_matches:true,kin_home:process.env.KIN_HOME,cli:binary(cli),daemon:binary(daemon),channel:"stable",release_kind:"unknown"}); + else if (args[0] === "bench-meta") print(${JSON.stringify(meta({ kin_target: target, kin_commit: version }))}); + else process.exit(3); +} +`; fs.writeFileSync(path.join(dir, "kin"), script("kin", SHA), { mode: 0o755 }); fs.writeFileSync(path.join(dir, "kin-daemon"), script("kin-daemon", SHA), { mode: 0o755 }); - const arch = process.arch === "arm64" ? "ARM64" : "X64"; - const target = process.arch === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"; const cli = (commit) => spawnSync( process.execPath, @@ -285,3 +321,110 @@ test("the CLI runs real executables and exits non-zero on a wrong commit", { ski assert.equal(bad.status, 1); assert.match(bad.stderr, /::error::release binary SHAs/); }); + +test("full metadata executes only after isolated canonical bytes, identity and ready status agree", () => { + const dir = fixture(); + const target = "aarch64-unknown-linux-musl"; + const { exec, calls } = fakeExec(goodOutputs({ + "kin bench-meta --json": ({ file, options }) => { + assert.equal(file, path.join(options.env.KIN_HOME, "bin", "kin")); + assert.equal(options.env.KIN_HOME, path.join(options.env.HOME, ".kin")); + assert.equal(options.env.KIN_RUNTIME_MODE, "managed"); + for (const key of ["KIN_DAEMON_URL", "KIN_DAEMON_BIN", "KIN_MODEL_EMBEDDINGS_PROVIDER", "GIT_DIR", "DYLD_INSERT_LIBRARIES"]) assert.equal(options.env[key], undefined); + assert.equal(fs.readFileSync(file, "utf8"), fs.readFileSync(path.join(dir, "kin"), "utf8")); + assert.equal(fs.readFileSync(path.join(path.dirname(file), "kin-daemon"), "utf8"), fs.readFileSync(path.join(dir, "kin-daemon"), "utf8")); + return JSON.stringify(meta({ kin_target: target })); + }, + }, target)); + verify(argsFor(dir, { target }), { + env: { RUNNER_ARCH: "ARM64", HOME: "/unrelated", KIN_HOME: "/unrelated/.kin", KIN_RUNTIME_MODE: "development", + KIN_DAEMON_URL: "http://unrelated", KIN_DAEMON_BIN: "/unrelated/daemon", KIN_MODEL_EMBEDDINGS_PROVIDER: "external", + GIT_DIR: "/unrelated/git", DYLD_INSERT_LIBRARIES: "/unrelated/library" }, + exec, + log: () => {}, + }); + assert.ok(calls.some((call) => call.slice(1).join(" ") === "bench-meta --json")); + const indexOf = (argv) => calls.findIndex((call) => call.slice(1).join(" ") === argv); + assert.ok(indexOf("bench-meta --json --runtime-identity-only") < indexOf("--compat-json")); + assert.ok(indexOf("--compat-json") < indexOf("runtime status --json")); + assert.ok(indexOf("runtime status --json") < indexOf("bench-meta --json")); + const pair = JSON.parse(fs.readFileSync(path.join(dir, "out", "kin-canonical-pair.json"), "utf8")); + assert.equal(pair.verified, true); + assert.equal(pair.release_kind, "unknown"); + assert.equal(pair.pair.cli.sha256, createHash("sha256").update(fs.readFileSync(path.join(dir, "kin"))).digest("hex")); + assert.ok(!fs.existsSync(pair.pair.cli.path), "temporary executable copies are cleaned after proof"); +}); + +test("untrusted identities and runtime path or hash mismatches refuse before full metadata", () => { + const dir = fixture(); + const target = "aarch64-unknown-linux-musl"; + const bad = [ + { "kin bench-meta --json --runtime-identity-only": JSON.stringify(identityMeta(target, { schema: "unsupported" })) }, + { "kin bench-meta --json --runtime-identity-only": JSON.stringify(identityMeta(target, { kin_commit: "a".repeat(7) })) }, + { "kin bench-meta --json --runtime-identity-only": JSON.stringify(identityMeta(target, { kin_target: "x86_64-unknown-linux-musl" })) }, + { "kin bench-meta --json --runtime-identity-only": JSON.stringify(identityMeta(target, { kin_dirty: true })) }, + { "kin bench-meta --json --runtime-identity-only": JSON.stringify(identityMeta(target, { kin_source_known: false })) }, + { "kin bench-meta --json --runtime-identity-only": JSON.stringify(identityMeta(target, { kin_build_profile: "debug" })) }, + { "kin-daemon --compat-json": JSON.stringify({ schema: "kin.daemon.compat.v2", runtime_identity: runtimeIdentity(target, { commit_sha: "b".repeat(40) }) }) }, + { "kin-daemon --compat-json": JSON.stringify({ schema: "unsupported", runtime_identity: runtimeIdentity(target) }) }, + ...[{ schema: "unsupported" }, { ready: false }, { mode: "development" }, { pair_matches: false }, { kin_home: "/unrelated" }].map((over) => ({ + "kin runtime status --json": (context) => JSON.stringify(runtimeStatus(context, target, over)), + })), + ...["cli", "daemon"].flatMap((role) => [ + ...[{ path: "/unrelated/bin" }, { sha256: "b".repeat(64) }, { source_dirty: true }].map((over) => ({ + "kin runtime status --json": (context) => { + const status = runtimeStatus(context, target); status[role] = { ...status[role], ...over }; return JSON.stringify(status); + }, + })), + ]), + ]; + for (const outputs of bad) { + const { exec, calls } = fakeExec(goodOutputs(outputs, target)); + assert.throws(() => verify(argsFor(dir, { target }), { env: { RUNNER_ARCH: "ARM64" }, exec, log: () => {} }), /runtime identity|canonical runtime|canonical (?:cli|daemon)/); + assert.ok(!calls.some((call) => call.slice(1).join(" ") === "bench-meta --json")); + assert.ok(!fs.readdirSync(path.join(dir, "out")).some((name) => name.startsWith("native-runtime-"))); + } +}); + +test("full metadata cannot substitute another identity after canonical activation", () => { + const dir = fixture(); + const target = "aarch64-unknown-linux-musl"; + const { exec } = fakeExec(goodOutputs({ "kin bench-meta --json": JSON.stringify(meta({ kin_target: target, kin_build_profile: "debug" })) }, target)); + assert.throws(() => verify(argsFor(dir, { target }), { env: { RUNNER_ARCH: "ARM64" }, exec, log: () => {} }), /full metadata identity differs/); + assert.ok(!fs.readdirSync(path.join(dir, "out")).some((name) => name.startsWith("native-runtime-"))); +}); + +test("full metadata keeps its four identity fields while the independent pair proves target and profile", () => { + const dir = fixture(), target = "aarch64-unknown-linux-musl"; + const { exec } = fakeExec(goodOutputs({}, target)); + verify(argsFor(dir, { target }), { env: { RUNNER_ARCH: "ARM64" }, exec, log: () => {} }); + const full = JSON.parse(fs.readFileSync(path.join(dir, "out", "kin-build-meta.json"), "utf8")); + assert.equal(full.kin_target, undefined); + assert.equal(full.kin_build_profile, undefined); + const pair = JSON.parse(fs.readFileSync(path.join(dir, "out", "kin-canonical-pair.json"), "utf8")); + assert.equal(pair.identity.target, target); + assert.equal(pair.identity.build_profile, "release"); +}); + +test("a selected artifact changed after copying refuses before full metadata", () => { + const dir = fixture(), target = "aarch64-unknown-linux-musl"; + const { exec, calls } = fakeExec(goodOutputs({ + "kin runtime status --json": (context) => { + const status = runtimeStatus(context, target); + fs.writeFileSync(path.join(dir, "kin"), "replacement artifact bytes\n"); + return JSON.stringify(status); + }, + }, target)); + assert.throws(() => verify(argsFor(dir, { target }), { env: { RUNNER_ARCH: "ARM64" }, exec, log: () => {} }), /canonical cli path, hash or identity/); + assert.ok(!calls.some((call) => call.slice(1).join(" ") === "bench-meta --json")); +}); + +test("a Windows target stages the canonical executable names with their suffixes", () => { + const dir = fixture(), target = "x86_64-pc-windows-msvc"; + const { exec } = fakeExec(goodOutputs({}, target)); + verify(argsFor(dir, { target }), { env: { RUNNER_ARCH: "X64" }, exec, log: () => {} }); + const receipt = JSON.parse(fs.readFileSync(path.join(dir, "out", "kin-canonical-pair.json"), "utf8")); + assert.equal(path.basename(receipt.pair.cli.path), "kin.exe"); + assert.equal(path.basename(receipt.pair.daemon.path), "kin-daemon.exe"); + assert.equal(receipt.identity.target, target); +}); diff --git a/scripts/workspace-ui.test.cjs b/scripts/workspace-ui.test.cjs index f04f03a3b..62f4b8a6b 100644 --- a/scripts/workspace-ui.test.cjs +++ b/scripts/workspace-ui.test.cjs @@ -150,7 +150,8 @@ function agentsPayload({ agents = null, earlier = [] } = {}) { { seq: 4, at: secondsAgo(31), tool: "get_entity_source", entity_ids: [uuid(5)], result_count: 2, duration_ms: 8, is_error: false }, { seq: 3, at: secondsAgo(40), tool: "semantic_locate", entity_ids: [uuid(5), uuid(3)], result_count: 5, duration_ms: 1800, is_error: false }], open_changes: [{ staged_edits: 1, age_secs: 9 }], intents: [], scope_names: {}, - changes: [{ id: AGENT_CHANGE, title: "Explain how want_bytes treats text", time: secondsAgo(120), entities_changed: 1 }], changes_total: 1 }], + // Committed before the newest changes its repository's summary lists, so Home finds it there or not at all. + changes: [{ id: AGENT_CHANGE, title: "Explain how want_bytes treats text", time: secondsAgo(3600), entities_changed: 1 }], changes_total: 1 }], earlier, names: { [uuid(5)]: { label: "Signer.verify_signature", kind: "method" }, [uuid(2)]: { label: "want_bytes", kind: "function" }, [uuid(3)]: { label: "Signer", kind: "class" } } }; } @@ -299,14 +300,16 @@ function fixture({ hub = false, merge = true, preview = true, initialFailure = f const scoped = !hub && parsed.pathname.startsWith(`${BASE}/api/`); const name = scoped ? parsed.pathname.slice(BASE.length) : parsed.pathname; const method = options.method || "GET"; - // Home reads three things of each repository it opens, and puts a question to each; it writes nothing there. - const opening = hub && method === "GET" && /^\/repositories\/[0-9a-f]{64}\/api\/(workspace\/(overview|agents|ask)|guide)$/.test(name) && (!name.endsWith("/api/guide") || parsed.searchParams.get("working") === "1"); + // Home reads what each repository it opens holds, how an agent's change waiting there reads, where an entity two of its pictures name alike sits, and puts a question to each; it writes nothing there. + const opening = hub && method === "GET" && /^\/repositories\/[0-9a-f]{64}\/api\/(workspace\/(overview|agents|ask|entity)|guide|standing|counts)$/.test(name) && (!name.endsWith("/api/guide") || parsed.searchParams.get("working") === "1" || parsed.searchParams.has("change")); if (parsed.origin !== location.origin || !(scoped || opening || (hub ? /^\/api\/home(\/|$)/.test(name) : name === "/api/home" && method === "GET"))) { strays.push(`${method} ${url}`); return { ok: false, status: 599, json: async () => ({ error: "No such address." }) }; } const keyed = `${method} ${name}`; // The three guides share a path: uncommitted work, what a branch holds, and one change. - const formed = name === "/api/guide" && !parsed.searchParams.has("working") ? `${name}?${parsed.searchParams.has("base") ? "base" : "change"}` : name; + const formed = name.endsWith("/api/guide") && !parsed.searchParams.has("working") ? `${name}?${parsed.searchParams.has("base") ? "base" : "change"}` : name; requests.push({ name, url, method, options }); - for (const key of [keyed, formed, name]) if (held.has(key)) { await held.get(key); break; } + // A held read waits for its release, or for the page to give up on it, as a browser's fetch does. + const abandoned = new Promise((_, reject) => options.signal?.addEventListener?.("abort", () => reject(Object.assign(new Error("The operation was aborted."), { name: "AbortError" })))); + for (const key of [keyed, formed, name]) if (held.has(key)) { await Promise.race([held.get(key), abandoned]); break; } const failure = failures.get(keyed) ?? failures.get(formed) ?? failures.get(name); const at = [keyed, formed, name].find((key) => key in data) ?? name; if (name === "/api/workspace/branch/confirm") data[name].operation_id = JSON.parse(options.body).operation_id; @@ -473,13 +476,13 @@ test("uncommitted work reads as the guided review's sentence of real numbers", ( assert.equal(sentence({ modified: 1, recorded_consumers: 16, recorded_tests: 2 }), "One function changed. 16 other places rely on it. None of them changed. Two tests reach it."); assert.equal(sentence({ modified: 3, recorded_consumers: 13, consumers_not_updated: 2, recorded_tests: 7 }, [{ kind: "function" }, { kind: "method" }]), "Three entities changed. 13 other places rely on them. Two were not updated. Seven tests reach them."); - assert.equal(sentence({ added: 1 }), "One function changed. Nothing outside the change relies on it. No test reaches it."); - assert.equal(sentence({ modified: 2, recorded_consumers: 2, consumers_not_updated: 2 }), "Two functions changed. Two other places rely on them. Neither was updated. No test reaches them."); + assert.equal(sentence({ added: 1 }), "One function changed. Kin found no connections outside this change. Kin found no tests that reach this work."); + assert.equal(sentence({ modified: 2, recorded_consumers: 2, consumers_not_updated: 2 }), "Two functions changed. Two other places rely on them. Neither was updated. Kin found no tests that reach this work."); assert.equal(sentence({}), null); // The guide's verdicts, when it gives them: what the change asks of the places that rely on it. assert.equal(sentence({ modified: 1, recorded_consumers: 16, recorded_tests: 2, consumers_need_no_update: 16, consumers_may_need_review: 0 }), "One function changed. 16 other places rely on it. None need an update. Two tests reach it."); assert.equal(sentence({ modified: 1, recorded_consumers: 16, recorded_tests: 2, consumers_need_no_update: 13, consumers_may_need_review: 3 }), "One function changed. 16 other places rely on it. Check three of them. Two tests reach it."); - assert.equal(sentence({ modified: 1, recorded_consumers: 3, consumers_not_updated: 3, consumers_need_no_update: 0, consumers_may_need_review: 0 }), "One function changed. Three other places rely on it. None were updated. No test reaches it."); + assert.equal(sentence({ modified: 1, recorded_consumers: 3, consumers_not_updated: 3, consumers_need_no_update: 0, consumers_may_need_review: 0 }), "One function changed. Three other places rely on it. None were updated. Kin found no tests that reach this work."); }); test("a recorded Git signature reads as a person's name", () => { @@ -552,7 +555,7 @@ test("the frame holds every element the page looks up and does not build itself" test("a repository reads its own state and the machine's list of repositories, and never a graph sample or a map", async () => { const browser = fixture(); await flush(); const asked = browser.requests.map((request) => `${request.method} ${request.name}${request.name === "/api/guide" ? `?${new URL(request.url, "http://local.test").searchParams.keys().next().value}` : ""}`); - assert.deepEqual(asked, ["GET /api/workspace/session", "GET /api/changes", "GET /api/standing", "GET /api/branches", "GET /api/workspace/branches", "GET /api/workspace/conflicts/details", "GET /api/guide?working", "GET /api/workspace/overview", "GET /api/workspace/agents", "GET /api/home", "GET /api/counts"], + assert.deepEqual(asked, ["GET /api/workspace/session", "GET /api/changes", "GET /api/standing", "GET /api/branches", "GET /api/workspace/branches", "GET /api/workspace/conflicts/details", "GET /api/guide?working", "GET /api/workspace/overview", "GET /api/workspace/agents", "GET /api/home", "GET /api/workspace/remotes", "GET /api/workspace/flags", "GET /api/counts"], "each read once, then each listed change's count, and nothing else: no graph sample and no map"); assert.equal(browser.node("workspace-version").textContent, "Kin 0.8.2"); assert.equal(browser.node("workspace-status").dataset.state, "ready"); @@ -614,7 +617,7 @@ test("Uncommitted leads with what changed, one primary action into the review, a const browser = fixture(); await flush(); assert.equal(browser.main().querySelector("h1").textContent, "Work"); assert.deepEqual(workTabs(browser), [["Uncommitted", "1", "#/uncommitted", "page"], ["Branches", "1", "#/branches", null], ["History", "", "#/history", null]], "uncommitted work, the other branches and what has landed are three views of one section"); - assert.equal(browser.node("work-lede").textContent, "One function changed. 16 other places rely on it. None of them changed. Two tests reach it."); + assert.equal(browser.node("work-lede").textContent, "One function changed. 16 other places rely on it. None of them changed. Two tests reach it.No agent reported writing it, so Kin cannot say whether it is yours or an agent's."); const primary = browser.links().filter((node) => node.className.includes("button-primary")); assert.deepEqual(primary.map((node) => [node.textContent, node.getAttribute("href")]), [["Start review", "#/review/w/1"]], "one primary action"); assert.ok(browser.node("work-actions").contains(primary[0]), "it sits with the page's title"); @@ -705,7 +708,7 @@ test("concurrent refresh clicks share one read cycle", async () => { const before = browser.requests.length; browser.refresh(); const asked = browser.requests.slice(before).map((request) => request.name); - assert.equal(asked.length, 10, "each read is asked once"); + assert.equal(asked.length, 12, "each read is asked once, including previously unavailable optional capabilities"); assert.equal(new Set(asked).size, asked.length); browser.refresh(); assert.equal(browser.requests.length - before, asked.length, "a second click starts nothing while the first is reading"); @@ -736,7 +739,7 @@ test("the list of uncommitted work groups each changed entity by what it asks of const browser = fixture(); browser.data["/api/guide"] = working({ ...FOUR, completeness: { state: "pending", message: "", pending_entities: 4 } }, fourChanged()); await flush(); - assert.equal(browser.node("work-lede").textContent, "Four functions changed. Ten other places rely on them. Eight were not updated. Check one more. One test reaches them.Kin is still mapping 4 entities, so these counts may grow."); + assert.equal(browser.node("work-lede").textContent, "Four functions changed. At least ten other places rely on them. Eight were not updated. Check one more. At least one test reaches them.No agent reported writing any of them, so Kin cannot say whether they are yours or an agent's.Kin is still mapping 4 entities, so these counts may grow."); // What was left behind first, then what may need a look, then the rest, each numbered in the order a review reads it and drawn in the tone of what it asks. const drawn = picture(browser.node("work-flight")); assert.deepEqual(drawn.columns[0], { head: "Changed4", more: [], boxes: [ @@ -747,7 +750,7 @@ test("the list of uncommitted work groups each changed entity by what it asks of // Each entity that relies on the work and did not change is drawn once, in the order the work reaches it, and the rest are counted. assert.deepEqual(drawn.columns[1].boxes.map(([name, , , href]) => [name, href]), [["Serializer.dumps", `#/entity/${uuid(10)}`], ["Signer.sign", `#/entity/${uuid(12)}`], ["Signer.unsign", `#/entity/${uuid(13)}`], ["Signer.derive_key", `#/entity/${uuid(14)}`]], "what changed here too is on the left, not the right"); - assert.equal(drawn.columns[1].head, "Rely on them, and did not change10"); + assert.equal(drawn.columns[1].head, "Rely on them, and did not change≥10"); assert.deepEqual(drawn.columns[1].more, [["and 6 more", null]]); assert.deepEqual(reviewRows(browser.node("work-flight")), []); assert.deepEqual(browser.node("work-actions").querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["Start review", "#/review/w/1"]]); @@ -775,10 +778,10 @@ test("more than the picture holds is listed in full under it, grouped by what ea assert.deepEqual(reviewRows(browser.node("work-flight")), [ { title: "Relied on, and not updated", count: "1", rows: [{ number: "1", name: "want_bytes", place: "Function in encoding", reason: "Signature changed. 8 entities rely on it and were not updated.", mark: "", href: "#/review/w/1" }] }, { title: "Relied on by code that did not change", count: "1", rows: [{ number: "2", name: "old_helper", place: "Function in encoding", reason: "Removed. 1 entity relies on it and did not change here.", mark: "", href: "#/review/w/2" }] }, - { title: "Nothing to follow up", count: "7", rows: [ + { title: "No known follow-up", count: "7", rows: [ { number: "3", name: "quiet", place: "Function in encoding", reason: "1 entity relies on it and needs no update.", mark: "", href: "#/review/w/3" }, - { number: "4", name: "base64_encode", place: "Function in encoding", reason: "New. Nothing outside this change relies on it.", mark: "", href: "#/review/w/4" }, - ...[0, 1, 2, 3, 4].map((i) => ({ number: String(i + 5), name: `extra_${i}`, place: "Function in encoding", reason: "Nothing outside this change relies on it.", mark: "", href: `#/review/w/${i + 5}` }))] }], + { number: "4", name: "base64_encode", place: "Function in encoding", reason: "New. Kin found no connections outside this change.", mark: "", href: "#/review/w/4" }, + ...[0, 1, 2, 3, 4].map((i) => ({ number: String(i + 5), name: `extra_${i}`, place: "Function in encoding", reason: "Kin found no connections outside this change.", mark: "", href: `#/review/w/${i + 5}` }))] }], "every entity is listed below the picture, numbered in the order a review reads it"); }); @@ -814,7 +817,7 @@ test("work Kin counted and did not walk says how much there is, and can still be const browser = fixture(); browser.data["/api/guide"] = guide({ added: 20_000 }, { not_walked: { entities: 20_000 }, working_tree: { differs: true, derived: true, caught_up: true } }); await flush(); - assert.equal(browser.node("work-lede").textContent, "20,000 entities changed. Nothing outside the change relies on them. No test reaches them."); + assert.equal(browser.node("work-lede").textContent, "20,000 entities changed. Kin found no connections outside this change. Kin found no tests that reach this work."); assert.equal(browser.node("work-flight").textContent, "This change is too large to read one entity at a time20,000 entities changed. Kin can still show it whole.Read it on one page"); assert.deepEqual(browser.node("work-flight").querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["Read it on one page", `${BASE}/review/#/w`]], "the whole change, on the page that reads it at once"); assert.deepEqual(browser.node("work-actions").querySelectorAll("button").map((node) => node.textContent), ["Commit"]); @@ -882,13 +885,13 @@ test("the last uncommitted-work reading is shown as of its time until the fresh let release; browser.held.set("/api/guide", new Promise((done) => { release = done; })); browser.data["/api/guide"] = working({ modified: 2, recorded_consumers: 3, consumers_not_updated: 1, recorded_tests: 1 }, [changed(2, "want_bytes"), changed(5, "base64_encode")]); await flush(); - assert.equal(browser.node("work-lede").textContent, "One function changed. One other place relies on it. It did not change. No test reaches it.", "what this tab last saw"); + assert.equal(browser.node("work-lede").textContent, "One function changed. One other place relies on it. It did not change. Kin found no tests that reach this work.No agent reported writing it, so Kin cannot say whether it is yours or an agent's.", "what this tab last saw"); assert.match(browser.node("work-flight").textContent, /^As of .+\. Refreshing/); assert.deepEqual(names(browser), ["want_bytes"]); assert.equal(browser.button("Commit"), undefined, "a commit acts on what is there now, so it is not offered on an old reading"); assert.ok(browser.links().some((node) => node.textContent === "Start review"), "what was last seen can still be read"); release(); await flush(); - assert.equal(browser.node("work-lede").textContent, "Two functions changed. Three other places rely on them. One was not updated. One test reaches them."); + assert.equal(browser.node("work-lede").textContent, "Two functions changed. Three other places rely on them. One was not updated. One test reaches them.No agent reported writing any of them, so Kin cannot say whether they are yours or an agent's."); assert.doesNotMatch(browser.node("work-flight").textContent, /Refreshing/); assert.deepEqual(names(browser), ["want_bytes", "base64_encode"]); assert.ok(browser.button("Commit"), "the fresh reading can be committed"); @@ -955,7 +958,7 @@ test("Commit sends the message once, says what was committed and reads the page assert.equal(calls[0].options.headers["x-kin-review-token"], "synthetic-token"); assert.deepEqual(JSON.parse(calls[0].options.body), { message: "Bound input before decoding\n\nIt was unbounded.", operation_id: uuid(1) }); const said = browser.node("work-commit"); - assert.match(said.textContent, /^CommittedBound input before decoding/); + assert.match(said.textContent, /^Committedwant_bytes is committed as change dddddddd, “Bound input before decoding”\./); assert.equal(said.querySelectorAll("a").find((node) => node.textContent === "Review it").getAttribute("href"), guided(change)); assert.equal(commitForm(browser).field, null, "the form is gone"); assert.equal(browser.count("/api/guide"), reads.guide + 1); @@ -1128,8 +1131,9 @@ test("each step shows what happened to one entity, its source, what relies on it [["Skip", "→", null], ["Flag it", "F", "f"], ["Looks right", "↵", "Enter"]]); assert.equal(step.article.getAttribute("aria-label"), "want_bytes"); assert.equal(step.article.querySelector("h1").querySelector("a").getAttribute("href"), `#/entity/${uuid(2)}`, "its name opens its own page"); - assert.equal(step.place, "Function in encoding"); - assert.equal(step.verdict, "Its signature and its body changed. 8 entities rely on it and were not updated."); + assert.equal(step.place, "Function in encoding · No agent reported this edit"); + // One sentence says its counts one way: as numerals. + assert.equal(step.verdict, "Its signature and its body changed. 8 entities rely on it and were not updated. 1 test reaches it."); assert.match(step.source.textContent, /^Before and after/); assert.deepEqual(diffRows(step.source), [["12", "", "−", "def want_bytes(s):"], ["13", "", "−", " return s.encode()"], ["", "12", "+", "def want_bytes(s, encoding):"], ["", "13", "+", " return s.encode(encoding)"]]); assert.equal(step.relies.querySelector("h2").textContent, "What relies on it9", "the count is the guide's, not the boxes drawn"); @@ -1142,7 +1146,7 @@ test("each step shows what happened to one entity, its source, what relies on it ["Serializer.dumps", "calls it", "Not updated", `#/entity/${uuid(10)}`, "bad"], ["Serializer.loads", "calls it", "Changed here too", `#/entity/${uuid(11)}`, "good"], ["Signer.sign", "refers to it", "Not updated", `#/entity/${uuid(12)}`, "bad"]], more: [["and 6 more that Kin counted and did not list here", null]] }]); assert.equal(step.tests.querySelector("h2").textContent, "Tests that reach it1"); - assert.deepEqual(step.rows(step.tests), ["TestEncoding.test_want_bytes"]); + assert.deepEqual(step.rows(step.tests), ["TestEncoding.test_want_bytes1 step away"], "a test the guide gives no distance for is a direct one"); assert.deepEqual(browser.node("review-head").querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["Uncommitted", "#/uncommitted"]], "the way back to the list"); assert.deepEqual(sideCurrent(browser), ["uncommitted"], "a review of uncommitted work is part of Uncommitted"); @@ -1157,20 +1161,20 @@ test("each step shows what happened to one entity, its source, what relies on it assert.match(step.source.textContent, /^Removed source/); assert.deepEqual(diffRows(step.source), [["50", "", "−", "def old_helper():"], ["51", "", "−", " pass"]]); assert.deepEqual(picture(step.relies).columns.map((column) => column.boxes), [[["old_helper", "Removed here", "", null, ""]], [["Signer.unsign", "calls it", "May need review", `#/entity/${uuid(13)}`, "warn"]]]); - assert.equal(step.tests.textContent, "Tests that reach it0Kin found no test that reaches it."); + assert.equal(step.tests.textContent, "Tests that reach itKin found no tests that reach it."); browser.navigate("review/w/3"); await flush(); step = stepOf(browser); - assert.equal(step.verdict, "Its body changed. Its signature did not. 1 entity relies on it and needs no update."); + assert.equal(step.verdict, "Its body changed. Its signature did not. 1 entity relies on it and needs no update. Kin found no tests that reach it."); assert.deepEqual(picture(step.relies).columns[1].boxes, [["Signer.derive_key", "calls it", "Needs no update", `#/entity/${uuid(14)}`, "quiet"]]); assert.equal(step.source.textContent, "Before and afterNo source was returned for this entity."); browser.navigate("review/w/4"); await flush(); step = stepOf(browser); - assert.equal(step.verdict, "New function. Nothing outside this change relies on it."); + assert.equal(step.verdict, "New function. Kin found no connections outside this change. Kin found no tests that reach it."); assert.match(step.source.textContent, /^New source/); assert.deepEqual(diffRows(step.source), [["", "30", "+", "def base64_encode(s):"], ["", "31", "+", " return s"]]); - assert.equal(step.relies.textContent, "What relies on it0Nothing relies on it yet."); + assert.equal(step.relies.textContent, "What relies on it0Kin found no entities that rely on it."); assert.deepEqual([named(browser.node("review-next")), browser.node("review-next").getAttribute("href")], ["Skip", "#/review/w/done"], "after the last comes the finish"); assert.doesNotMatch(browser.text(), /\.py|src\//, "no locations"); @@ -1178,8 +1182,8 @@ test("each step shows what happened to one entity, its source, what relies on it const mapping = fixture({ hash: "#/review/w/1" }); mapping.data["/api/guide"] = working({ modified: 1, completeness: { state: "pending", message: "", pending_entities: 4 } }, [changed(2, "want_bytes")]); await flush(); - assert.equal(stepOf(mapping).tests.textContent, "Tests that reach it0Kin has found no test that reaches it yet."); - assert.equal(stepOf(mapping).relies.textContent, "What relies on it0Kin found nothing else that relies on it."); + assert.equal(stepOf(mapping).tests.textContent, "Tests that reach itKin found no tests that reach it."); + assert.equal(stepOf(mapping).relies.textContent, "What relies on it0Kin found no entities that rely on it."); }); test("a changed entity's source reads as kept, removed and added lines, numbered from where each version starts", async () => { @@ -1301,9 +1305,9 @@ test("a flag can say why, and the reason stays with the flag until the entity is const flagged = reviewRows(browser.node("review-body")).find((group) => group.title === "Flagged"); assert.deepEqual(flagged.rows.map((row) => [row.number, row.name, row.place, row.reason, row.mark, row.href]), [ ["1", "want_bytes", "Function in encoding", "The callers still pass one argument.", "Flagged", "#/review/w/1"], - ["4", "base64_encode", "Function in encoding", "New. Nothing outside this change relies on it.", "Flagged", "#/review/w/4"]], "each leads back to its own page"); + ["4", "base64_encode", "Function in encoding", "New. Kin found no connections outside this change.", "Flagged", "#/review/w/4"]], "each leads back to its own page"); browser.button("Copy the flags").click(); await flush(); - assert.deepEqual(browser.copied, ["want_bytes (Function in encoding): The callers still pass one argument. Its signature and its body changed. 8 entities rely on it and were not updated.\nbase64_encode (Function in encoding): New function. Nothing outside this change relies on it."]); + assert.deepEqual(browser.copied, ["want_bytes (Function in encoding): The callers still pass one argument. Its signature and its body changed. 8 entities rely on it and were not updated.\nbase64_encode (Function in encoding): New function. Kin found no connections outside this change."]); // Marking it as looking right drops the flag and its reason. A long reason is cut to what a row can hold. browser.navigate("review/w/1"); await flush(); await mark(browser, "fine"); @@ -1315,9 +1319,9 @@ test("a flag can say why, and the reason stays with the flag until the entity is // A reload keeps the reason with the flag. const reloaded = await reviewing("#/review/w/4", { kept }); assert.equal(reloaded.node("review-note").value, ""); - await flagWith(reloaded, "No test reaches it."); + await flagWith(reloaded, "Kin found no tests that reach this work."); reloaded.navigate("review/w/4"); await flush(); - assert.equal(reloaded.node("review-note").value, "No test reaches it."); + assert.equal(reloaded.node("review-note").value, "Kin found no tests that reach this work."); }); test("on a step the arrow keys move, Enter says it looks right and f flags it", async () => { @@ -1376,16 +1380,19 @@ test("Skip and Previous move without marking, and the finish page says what is l for (let i = 0; i < 4; i++) { browser.node("review-next").click(); await flush(); } assert.equal(browser.location.hash, "#/review/w/done"); assert.equal(marksOf(kept), null); - assert.equal(finishTitle(browser), "0 of 4 looked at"); - const goOn = () => browser.node("review-body").querySelectorAll("a").find((node) => node.textContent === "Go on"); - assert.match(browser.node("review-body").textContent, /4 entities left\. Go on/); + assert.equal(finishTitle(browser), "0 of 4 marked"); + const goOn = () => browser.node("review-body").querySelectorAll("a").find((node) => node.textContent === "Continue review"); + assert.match(browser.node("review-body").textContent, /4 entities left unmarked\.Continue review/); assert.equal(goOn().getAttribute("href"), "#/review/w/1"); goOn().click(); await flush(); await mark(browser, "fine"); browser.navigate("review/w/done"); await flush(); - assert.equal(finishTitle(browser), "1 of 4 looked at"); - assert.match(browser.node("review-body").textContent, /3 entities left\. Go on/); + assert.equal(finishTitle(browser), "1 of 4 marked"); + assert.match(browser.node("review-body").textContent, /3 entities left unmarked\.Continue review/); assert.equal(goOn().getAttribute("href"), "#/review/w/2", "it goes on from the first entity not looked at"); + browser.button("Commit anyway").click(); + assert.match(commitForm(browser).field.textContent, /Change want_bytes/); + assert.equal(commitForm(browser).submit.disabled, false, "bypassing unmarked work still drafts the same commit message"); }); test("the finish page of uncommitted work commits the work there, and the change keeps what was marked", async () => { @@ -1397,9 +1404,15 @@ test("the finish page of uncommitted work commits the work there, and the change assert.deepEqual(reviewRows(browser.node("review-body")).map((group) => [group.title, group.count, group.rows.map((row) => row.name)]), [["Flagged", "2", ["old_helper", "base64_encode"]]]); // Committing is how uncommitted work is accepted, so the form is already here, and it is the page: there is nothing to cancel back to. assert.ok(commitForm(browser).field, "the form is here without being asked for"); - assert.equal(commitForm(browser).submit.disabled, true); + // The message starts as a draft of what the commit holds, named by entity, and says it is a draft. + assert.match(commitForm(browser).field.textContent, /^Change want_bytes/); + assert.match(browser.node("review-body").textContent, /Kin drafted this from what changed\. Say why in your own words\./); + assert.equal(commitForm(browser).submit.disabled, false); assert.equal(browser.button("Cancel"), undefined); const hint = () => browser.node("review-body").querySelectorAll("span").find((node) => node.textContent === "A message is needed to commit."); + assert.equal(hint().hidden, true); + browser.type("commit-message", ""); + assert.equal(commitForm(browser).submit.disabled, true, "an emptied message cannot be committed"); assert.equal(hint().hidden, false); browser.type("commit-message", "Bound"); assert.equal(hint().hidden, true, "the form stops asking once it has one"); @@ -1410,7 +1423,7 @@ test("the finish page of uncommitted work commits the work there, and the change await flush(); assert.deepEqual(browser.sent("/api/workspace/commit"), [{ message: "Bound input before decoding", operation_id: uuid(1) }]); const body = browser.node("review-body"); - assert.match(body.textContent, /^CommittedBound input before decoding/); + assert.match(body.textContent, /^Committedquiet, want_bytes and 2 more are committed as change dddddddd, “Bound input before decoding”\./); assert.match(body.textContent, /Nothing is uncommitted/); assert.deepEqual(body.querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["Review it", guided(change)], ["Open History", "#/history"]]); assert.equal(commitForm(browser).field, null); @@ -1467,8 +1480,9 @@ test("the finish page of a change takes the reader's view of it and sends it onc assert.equal(browser.location.hash, "#/review/c/change-2/done"); assert.equal(finishTitle(browser), "All four look right"); assert.deepEqual(browser.node("review-head").querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["History", "#/history"], ["Preserved change", "#/review/c/change-2"]]); - assert.match(viewOf(browser).textContent, /^Your view of this changeHow it read to you\. It stops and starts nothing\./); - assert.deepEqual(viewOf(browser).querySelectorAll("button").map((node) => node.textContent), ["Flagged", "Looks right"]); + assert.match(viewOf(browser).textContent, /^Your view of this changeYour own record of how it reads\. Saving it blocks nothing and changes no code\./); + // The choice reads as the walk's own: "Flag it" acts, and "Flagged" is what it leaves. + assert.deepEqual(viewOf(browser).querySelectorAll("button").map((node) => node.textContent), ["Flag it", "Looks right"]); assert.doesNotMatch(browser.text(), /Approve|Block|Needs work/, "a view is how the change read, not a gate"); assert.doesNotMatch(viewOf(browser).textContent, /Your view now/, "nobody has given one yet"); assert.equal(commitForm(browser).field, null, "a change on record is not committed again"); @@ -1478,7 +1492,7 @@ test("the finish page of a change takes the reader's view of it and sends it onc }; browser.type("review-comment", " The callers were left behind. "); const standing = browser.count("/api/standing"); - browser.button("Flagged").click(); + browser.button("Flag it").click(); assert.ok(viewOf(browser).querySelectorAll("button").every((node) => node.disabled), "one view at a time"); viewOf(browser).querySelectorAll("button").forEach((node) => node.click()); await flush(); @@ -1503,13 +1517,13 @@ test("a view Kin could not save says why and can be sent again, and a session th assert.match(viewOf(browser).textContent, /Your view now: looks right\. A new one replaces it\./, "the view already given is said"); browser.data["/api/decide"] = { __status: 502, error: "Kin could not reach this repository." }; browser.type("review-comment", "The callers were left behind."); - browser.button("Flagged").click(); await flush(); + browser.button("Flag it").click(); await flush(); assert.match(viewOf(browser).querySelector("[role=alert]").textContent, /Your view was not saved.*Kin could not reach this repository\./s); assert.match(viewOf(browser).textContent, /Your view now: looks right\./, "the view that stands is unchanged"); assert.equal(browser.node("review-comment").textContent, "The callers were left behind.", "the note is kept"); assert.ok(viewOf(browser).querySelectorAll("button").every((node) => !node.disabled)); browser.data["/api/decide"] = { reviews: [] }; - browser.button("Flagged").click(); await flush(); + browser.button("Flag it").click(); await flush(); assert.equal(browser.count("/api/decide"), 2); assert.equal(viewOf(browser).querySelector("[role=alert]"), null); assert.match(viewOf(browser).textContent, /Your view now: flagged\./); @@ -1524,7 +1538,7 @@ test("Copy the flags hands over what was flagged as plain text", async () => { const status = () => browser.node("review-body").querySelector("[role=status]").textContent; assert.equal(status(), ""); browser.button("Copy the flags").click(); await flush(); - assert.deepEqual(browser.copied, ["want_bytes (Function in encoding): Its signature and its body changed. 8 entities rely on it and were not updated.\nbase64_encode (Function in encoding): New function. Nothing outside this change relies on it."]); + assert.deepEqual(browser.copied, ["want_bytes (Function in encoding): Its signature and its body changed. 8 entities rely on it and were not updated.\nbase64_encode (Function in encoding): New function. Kin found no connections outside this change."]); assert.equal(status(), "Copied. Paste it to the agent or person who made the change."); browser.clipboard.refuses = true; browser.button("Copy the flags").click(); await flush(); @@ -1562,7 +1576,7 @@ test("what one branch holds beyond another is reviewed the same way, and the vie assert.equal(finishTitle(browser), "Three look right, one flagged"); browser.data["/api/decide"] = { reviews: [] }; browser.type("review-comment", "One helper is still relied on."); - browser.button("Flagged").click(); await flush(); + browser.button("Flag it").click(); await flush(); assert.deepEqual(browser.sent("/api/decide"), [{ change: "feature", base: "main", range: true, state: "needs_work", comment: "One helper is still relied on." }]); assert.match(viewOf(browser).textContent, /Your view now: flagged\./); assert.equal(asked.length, 1); @@ -1612,10 +1626,11 @@ test("a change too large to walk, or with no entity in it, says so and starts no test("a step number that is not in the review leads back to the list", async () => { const change = await reviewingChange("#/review/c/change-1/9"); assert.match(change.node("review-body").textContent, /^That entity is not in this review/); - assert.deepEqual(change.node("review-body").querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["Back to the list", "#/review/c/change-1"]]); + assert.deepEqual(change.node("review-body").querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["Back to the list", "#/review/c/change-1"], ["Finish review", "#/review/c/change-1/done"]]); assert.equal(change.node("review-fine"), null, "there is nothing to mark"); const work = await reviewing("#/review/w/9"); - assert.deepEqual(work.node("review-body").querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["Back to the list", "#/uncommitted"]]); + assert.deepEqual(work.node("review-body").querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["Back to the list", "#/uncommitted"], ["Finish review", "#/review/w/done"]]); + assert.match(work.node("review-body").textContent, /It holds 4 entities now\./); // Work that shrank while it was being read leaves the same way. const shrinking = await reviewing("#/review/w/4"); assert.equal(stepOf(shrinking).position, "4 of 4"); @@ -1624,13 +1639,189 @@ test("a step number that is not in the review leads back to the list", async () assert.match(shrinking.node("review-body").textContent, /^That entity is not in this review/); }); +test("a change Kin read while its graph was being written is read again by itself, with what is happening in plain words and Kin's own words behind Show details", async () => { + // The app server's answer when the engine declined: its own refusal, carried whole. + const refusal = "kin review refused (HTTP 503): no settled graph authority for review: a graph-authority writer was mutating the graph while this read ran. This read reached its 45 s limit for waiting out graph-authority writers."; + const browser = fixture({ hash: "#/review/c/change-1", session: { history: { state: "partial", admitted: 1, total: 2809, deriving: true, staged: false, step: 11, steps: 16 } } }); + browser.failures.set("/api/guide?change", { status: 502, body: { error: refusal } }); + await flush(); + const body = () => browser.node("review-body"); + // What a reader sees outside Show details, and what is behind it. + const plain = () => { const details = body().querySelector("details"); return body().textContent.replace(details ? details.textContent : "", ""); }; + assert.match(plain(), /^Kin was taking in new work in Fixture while it read this change, so it is reading it again\./); + assert.match(plain(), /Fixture is reading its older history in the background, step 11 of 16\. This review reads the change as it was committed, so it does not wait for that\./); + assert.match(plain(), /Kin reads it again in 2 s, attempt 2\./); + assert.doesNotMatch(plain(), /settled graph authority|graph-authority|reconciliation_status|HTTP 503/); + assert.equal(body().querySelector("details").querySelector("summary").textContent, "Show details"); + assert.match(body().querySelector("details").textContent, /no settled graph authority for review/); + assert.equal(body().querySelector("[role=alert]"), null, "a read Kin is asking again for is not shown as a failure"); + // The seconds count down without the reader doing anything, and the page asks again by itself. + await browser.advance(1000); + assert.match(plain(), /Kin reads it again in 1 s, attempt 2\./); + const asked = browser.count("/api/guide"); + let release; browser.held.set("/api/guide?change", new Promise((done) => { release = done; })); + await browser.advance(1000); await flush(); + assert.equal(browser.count("/api/guide"), asked + 1, "asked again after its wait"); + // While it reads again, the page says so at once, and the old answer stays behind Show details. + assert.match(plain(), /^Kin was taking in new work in Fixture while it read this change, so it is reading it again\./); + assert.match(plain(), /Reading it again, attempt 2, 2 s so far\./); + assert.equal(body().querySelector("button"), null, "nothing to press while a read is on its way"); + await browser.advance(5000); + // The clock counts the reader's whole wait, from the first attempt, not only this one. + assert.match(plain(), /Reading it again, attempt 2, 7 s so far\./); + // The graph held still this time: the change is shown. + browser.failures.delete("/api/guide?change"); + browser.held.delete("/api/guide?change"); release(); await flush(); + assert.deepEqual(picture(body()).columns[0].boxes.map(([name]) => name), ["want_bytes"]); + assert.doesNotMatch(body().textContent, /reading it again|Show details/); + await browser.advance(60_000); + assert.equal(browser.count("/api/guide"), asked + 1, "nothing more is asked once the change is shown"); +}); + +test("a change that takes a while to read says how long it has taken, and that it does not wait for older history being read", async () => { + const browser = fixture({ hash: "#/review/c/change-1", session: { history: { state: "partial", admitted: 1, total: 2809, deriving: true, staged: false, step: 5, steps: 16 } } }); + let release; browser.held.set("/api/guide?change", new Promise((done) => { release = done; })); + await flush(); + const body = () => browser.node("review-body"); + assert.match(body().textContent, /^Reading this change\.Fixture is reading its older history in the background, step 5 of 16\./); + await browser.advance(3000); + assert.match(body().textContent, /3 s so far\./); + await browser.advance(9000); + assert.match(body().textContent, /12 s so far\./); + browser.held.delete("/api/guide?change"); release(); await flush(); + assert.deepEqual(picture(body()).columns[0].boxes.map(([name]) => name), ["want_bytes"]); + // A repository whose history is all read says nothing about it, and a quick read says nothing at all. + const quick = fixture({ hash: "#/review/c/change-1" }); + let done; quick.held.set("/api/guide?change", new Promise((resolve) => { done = resolve; })); + await flush(); + assert.equal(quick.node("review-body").textContent, ""); + quick.held.delete("/api/guide?change"); done(); await flush(); + assert.deepEqual(picture(quick.node("review-body")).columns[0].boxes.map(([name]) => name), ["want_bytes"]); +}); + +/** How many times a page asked for one recorded change's guide, apart from its reads of uncommitted work. */ +const changeReads = (page) => page.requests.filter((request) => request.name.endsWith("/api/guide") && request.url.includes("change=")).length; + +test("a review read that takes longer than a minute is waited for, longer on each attempt, and opens when Kin answers", async () => { + const browser = fixture({ hash: "#/review/c/change-1" }); + let release; browser.held.set("/api/guide?change", new Promise((done) => { release = done; })); + await flush(); + const body = () => browser.node("review-body"); + // A minute is not the end of it: the first attempt waits a minute and a half. + await browser.advance(75_000); + assert.equal(changeReads(browser), 1); + assert.match(body().textContent, /75 s so far\./); + assert.equal(body().querySelector("[role=alert]"), null); + await browser.advance(15_000); + assert.match(body().textContent, /Kin reads it again in 2 s, attempt 2\./); + assert.match(body().querySelector("details").textContent, /This read took longer than 90 seconds\./); + await browser.advance(2000); + assert.equal(changeReads(browser), 2, "asked again, where Kin is still reading it"); + // The second attempt waits longer than the first did, and the clock counts the whole wait. + await browser.advance(120_000); + assert.equal(changeReads(browser), 2, "the second attempt is still waiting after two minutes"); + assert.match(body().textContent, /Reading it again, attempt 2, 3 min 32 s so far\./); + browser.held.delete("/api/guide?change"); release(); await flush(); + assert.deepEqual(picture(body()).columns[0].boxes.map(([name]) => name), ["want_bytes"]); + assert.doesNotMatch(body().textContent, /so far|again/); +}); + +test("Kin saying it is still reading a change keeps the attempt going rather than failing it", async () => { + const browser = fixture({ hash: "#/review/c/change-1" }); + const answer = browser.data["/api/guide?change"]; + browser.data["/api/guide?change"] = [{ state: "reading", retry_after_secs: 5 }, { state: "reading" }, answer]; + await flush(); + const body = () => browser.node("review-body"); + assert.equal(body().querySelector("[role=alert]"), null); + assert.doesNotMatch(body().textContent, /attempt|Show details/, "an answer that it is still reading is not a failed attempt"); + await browser.advance(1000); + assert.match(body().textContent, /Kin is still reading it, 1 s so far\./); + await browser.advance(4000); + assert.equal(changeReads(browser), 2, "asked again when Kin said to"); + await browser.advance(3000); + assert.equal(changeReads(browser), 3); + assert.deepEqual(picture(body()).columns[0].boxes.map(([name]) => name), ["want_bytes"]); +}); + +test("a change Home read is handed to its review in the same tab, so it is not read twice", async () => { + const tabStorage = new Map(); + const hub = fixture({ hub: true, tabStorage }); + const rows = [listed(NOTES, "notes", open())]; + hub.data["/api/home"] = homePayload(rows); + hub.data[under(NOTES, "/api/workspace/overview")] = summaryOf({ recent: [ + { id: "n-2", title: "Return two from value", author: "Claude Code", agent: { name: "Claude Code", key: "00000000000000a1" }, time: secondsAgo(120), kin_checkpoint: false }] }); + hub.data[under(NOTES, "/api/standing")] = { standing: {} }; + hub.data[under(NOTES, "/api/counts")] = { counts: { "n-2": { entities: 1, module_containers: 0, containers_through_members: 0 } } }; + hub.data[under(NOTES, "/api/guide?change")] = recorded("n-2", { modified: 1 }, [changed(2, "value")]); + answering(hub, rows); + await flush(); + assert.equal(hub.count(under(NOTES, "/api/guide")), 1); + const kept = JSON.parse(tabStorage.get(`kin.workspace.change-guide:/repositories/${NOTES}:n-2`)); + assert.equal(kept.data.change.id, "n-2", "Home keeps what it read for the review it leads to"); + // The review of that change, opened from Home in this tab, draws it without asking Kin again. + const handed = new Map([[`kin.workspace.change-guide:${BASE}:n-2`, JSON.stringify({ at: START - 60_000, data: kept.data })]]); + const browser = fixture({ hash: "#/review/c/n-2", tabStorage: handed }); + await flush(); + assert.equal(changeReads(browser), 0); + assert.deepEqual(picture(browser.node("review-body")).columns[0].boxes.map(([name]) => name), ["value"]); + // A reading from long ago is read again, and what the review reads is kept for Home in turn. + const old = fixture({ hash: "#/review/c/change-1", tabStorage: new Map([[`kin.workspace.change-guide:${BASE}:change-1`, JSON.stringify({ at: START - 3_600_000, data: kept.data })]]) }); + await flush(); + assert.equal(changeReads(old), 1); + assert.equal(JSON.parse(old.tabStorage.get(`kin.workspace.change-guide:${BASE}:change-1`)).data.change.id, "change-1"); + const home = fixture({ hub: true, tabStorage: new Map([[`kin.workspace.change-guide:/repositories/${NOTES}:n-2`, JSON.stringify({ at: START - 1000, data: kept.data })]]) }); + home.data["/api/home"] = homePayload(rows); + for (const key of [under(NOTES, "/api/workspace/overview"), under(NOTES, "/api/standing"), under(NOTES, "/api/counts")]) home.data[key] = hub.data[key]; + answering(home, rows); + await flush(); + assert.equal(home.count(under(NOTES, "/api/guide")), 0, "Home draws a change its review already read"); + assert.equal(needCards(home)[0].picture.columns[0].boxes[0][0], "value"); +}); + +test("a change Kin keeps declining is asked for less often, then left to the reader with Try again, and Try now asks at once", async () => { + const browser = fixture({ hash: "#/review/c/change-1" }); + browser.failures.set("/api/guide?change", { status: 502, body: { error: "kin review refused (HTTP 503): no settled graph authority for review: a graph-authority writer was mutating the graph while this read ran." } }); + await flush(); + const body = () => browser.node("review-body"); + // Without a history being read, nothing is said about one. + assert.doesNotMatch(body().textContent, /older history/); + // The reader need not wait: Try now asks at once. + const before = browser.count("/api/guide"); + body().querySelector("button").click(); await flush(); + assert.equal(browser.count("/api/guide"), before + 1); + assert.match(body().textContent, /Kin reads it again in 4 s, attempt 3\./); + // Then it waits longer each time, up to a quarter of a minute. + for (const [wait, attempt] of [[4000, 3], [8000, 4], [15_000, 5], [15_000, 6], [15_000, 7]]) { + await browser.advance(wait - 1); await flush(); + assert.equal(browser.count("/api/guide"), before + attempt - 2, `attempt ${attempt} waits ${wait} ms`); + await browser.advance(1); await flush(); + assert.equal(browser.count("/api/guide"), before + attempt - 1); + } + await browser.advance(15_000); await flush(); + assert.equal(browser.count("/api/guide"), before + 7); + // Eight attempts in all: the page stops asking and says it could not load the change, with Kin's words behind Show details. + assert.match(body().querySelector("[role=alert]").textContent, /^Kin couldn't load this change\./); + assert.match(body().querySelector("details").textContent, /no settled graph authority/); + await browser.advance(120_000); await flush(); + assert.equal(browser.count("/api/guide"), before + 7, "it no longer asks by itself"); + // Try again starts over, and shows its progress rather than the old failure while it reads. + let release; browser.held.set("/api/guide?change", new Promise((done) => { release = done; })); + browser.failures.delete("/api/guide?change"); + body().querySelector("button").click(); await flush(); + assert.equal(body().querySelector("[role=alert]"), null); + assert.match(body().textContent, /Reading it again\./); + browser.held.delete("/api/guide?change"); release(); await flush(); + assert.deepEqual(picture(body()).columns[0].boxes.map(([name]) => name), ["want_bytes"]); +}); + test("a change Kin could not read says so with a retry, and uncommitted work that could not be read says so in the review too", async () => { const browser = fixture({ hash: "#/review/c/change-1" }); browser.failures.set("/api/guide?change", { status: 502, body: { error: "Kin could not answer: It did not answer within 20 seconds." } }); await flush(); - assert.match(browser.node("review-body").textContent, /Kin is still reading this change\. Try again in a moment\..*did not answer within 20 seconds/s); + // Kin is only slow, so the page says so and asks again by itself. + assert.match(browser.node("review-body").textContent, /^Kin is still reading this change\. This fills in when it is ready\./); browser.failures.delete("/api/guide?change"); - browser.node("review-body").querySelector("button").click(); await flush(); + await browser.advance(4000); await flush(); assert.deepEqual(picture(browser.node("review-body")).columns[0].boxes.map(([name]) => name), ["want_bytes"]); assert.equal(browser.node("review-head").querySelector("h1").textContent, "Preserved change"); const gone = fixture({ hash: "#/review/c/gone" }); @@ -1727,6 +1918,9 @@ async function look(hub, answer, place = "~/code/itsdangerous") { function answering(hub, rows) { for (const row of rows) { hub.data[under(row.key, "/api/workspace/overview")] ??= summaryOf({ entities: 10, relationships: 5, languages: [["python", 10]] }); + // A repository whose own list of agents is not given here does not answer it when the machine's list counts some, + // so the machine's list is the word Home has; otherwise it lists none, as the machine's list says. + if ((row.agents?.connected || 0) > 0 && !(under(row.key, "/api/workspace/agents") in hub.data)) hub.failures.set(under(row.key, "/api/workspace/agents"), "The agents read did not answer within 20 seconds."); hub.data[under(row.key, "/api/workspace/agents")] ??= agentsPayload({ agents: [] }); hub.data[under(row.key, "/api/guide")] ??= guide(); } @@ -1739,9 +1933,12 @@ const needCards = (hub) => hub.node("home-attention").querySelectorAll("article" }); /** Each repository as its card on Home says it: its name and branch, its counts, its languages, what is in motion, and where it leads. */ const repoCards = (hub) => hub.node("home-repos").querySelectorAll("a[data-state]").map((card) => { - const [head, stats, languages, foot] = card.children; + const [head, ...parts] = card.children; + const live = parts.find((part) => part.className === "repo-card-live"); + const [stats, languages, foot] = parts.filter((part) => part !== live); return { name: head.children[0].textContent, branch: head.children[1]?.textContent ?? "", stats: stats.children.map((stat) => stat.children.map((part) => part.textContent).join(" ")), - languages: languages.children[1]?.children.map((key) => key.textContent) ?? [], foot: foot.children.map((part) => part.textContent), href: card.getAttribute("href"), state: card.dataset.state }; + languages: languages.children[1]?.children.map((key) => key.textContent) ?? [], foot: foot.children.map((part) => part.textContent), href: card.getAttribute("href"), state: card.dataset.state, + ...(live ? { live: live.textContent } : {}) }; }); /** Each agent at work as its card on Home says it: who and where, what it did last and before, and where the card leads. */ const agentCards = (hub) => hub.node("home-agents").querySelectorAll("a[data-state]").map((card) => { @@ -1759,7 +1956,7 @@ const ASKED = () => ({ schema: "kin.workspace.ask.v1", mode: "meaning_and_name", test("Home says first what needs the reader across the machine, then who is at work and how many repositories there are", async () => { // What the list says of each repository's agents is what is counted here. - const said = async (repositories, prepare = () => {}) => { const hub = fixture({ hub: true }); hub.data["/api/home"] = homePayload(repositories); hub.data[under(KEY, "/api/workspace/agents")] = agentsPayload({ agents: [] }); answering(hub, repositories); prepare(hub); await flush(); return headline(hub); }; + const said = async (repositories, prepare = () => {}) => { const hub = fixture({ hub: true }); hub.data["/api/home"] = homePayload(repositories); for (const key of [KEY, NOTES, ARCHIVE]) delete hub.data[under(key, "/api/workspace/agents")]; answering(hub, repositories); prepare(hub); await flush(); return headline(hub); }; const hub = fixture({ hub: true }); await flush(); assert.equal(hub.main().querySelector("h1").textContent, "pallets / Fixture needs you.", "the page's title is what needs the reader"); assert.deepEqual(headline(hub), ["pallets / Fixture needs you.", "One agent is working. Three repositories on this machine."]); @@ -1896,12 +2093,108 @@ test("a card says what it can of work Home has not read, could not read, or is r // What this tab last read of a repository's work is shown, said to be read again, until the new reading lands. const tabStorage = new Map([[`kin.workspace.working:/repositories/${KEY}`, JSON.stringify({ at: START - 60_000, data: working({ modified: 1 }, [changed(2, "want_bytes")]) })]]); hub = await card([listed(KEY, "Fixture", open({ working: { changed: 2, differs: true } }))], (page) => { page.held.set(under(KEY, "/api/guide"), new Promise((done) => { release = done; })); }, { tabStorage }); - assert.deepEqual(needCards(hub)[0].said, ["One function changed. Nothing outside the change relies on it. No test reaches it.", "Reading it again"]); + assert.deepEqual(needCards(hub)[0].said, ["One function changed. Kin found no connections outside this change. Kin found no tests that reach this work.", "Reading it again"]); release(); await flush(); assert.deepEqual(needCards(hub)[0].said, ["Two functions changed. Three other places rely on them. One was not updated. One test reaches them."]); assert.equal(JSON.parse(tabStorage.get(`kin.workspace.working:/repositories/${KEY}`)).data.summary.modified, 2, "the new reading is the one kept"); }); +test("an unreviewed agent commit at the head of a clean repository needs the reader, drawn as what it changed and what relies on that", async () => { + const hub = fixture({ hub: true }); + const rows = [listed(NOTES, "notes", open())]; + hub.data["/api/home"] = homePayload(rows); + hub.data[under(NOTES, "/api/workspace/overview")] = summaryOf({ recent: [ + { id: "n-0", title: "Update Kin's understanding", author: "Kin", time: secondsAgo(60), kin_checkpoint: true }, + { id: "n-2", title: "Return two from value", author: "Claude Code", agent: { name: "Claude Code", key: "00000000000000a1" }, time: secondsAgo(120), kin_checkpoint: false }, + { id: "n-1", title: "Add the notes index", author: "Ana Writer", time: secondsAgo(86_400), kin_checkpoint: false }] }); + hub.data[under(NOTES, "/api/standing")] = { standing: {} }; + // The change's own count answers first; its guide, which draws the picture, takes longer. + hub.data[under(NOTES, "/api/counts")] = { counts: { "n-2": { entities: 2, module_containers: 0, containers_through_members: 0 } } }; + hub.data[under(NOTES, "/api/guide?change")] = recorded("n-2", { modified: 2, recorded_consumers: 2, consumers_may_need_review: 2, recorded_tests: 0 }, [ + changed(2, "value", { authored_by: [{ name: "Claude Code" }], connections: connected([relying(10, "Serializer.dumps")]), consumer_verdict: { verdict: "may_need_review", reason: "body_changed", consumers: 1 } }), + changed(3, "index", { connections: connected([relying(11, "Notes.load")]), consumer_verdict: { verdict: "may_need_review", reason: "body_changed", consumers: 1 } })]); + let release; + hub.held.set(under(NOTES, "/api/guide?change"), new Promise((done) => { release = done; })); + answering(hub, rows); + await flush(); + assert.equal(hub.count(under(NOTES, "/api/standing")), 1, "the standing is read because an agent made the newest commit"); + const [card] = needCards(hub); + assert.deepEqual([card.repository[0], card.actions], ["notesmainAgent commit", [["Review", `${URL_OF(NOTES)}${guided("n-2")}`]]]); + // While the guide is read, the change's own count says how much it holds, and nothing else on Home waits for it. + assert.deepEqual(card.said, ["Claude Code committed “Return two from value” 2 minutes ago, and nobody has reviewed it yet.", "Two entities changed. Reading what relies on them"]); + assert.ok(card.busy > 0, "the picture is drawn as being read"); + assert.ok(repoCards(hub).length === 1 && hub.node("home-agents").textContent.length > 0, "the rest of Home is drawn meanwhile"); + // Nothing waits on the picture, so a guide that takes longer than a minute is still waited for. + await hub.advance(90_000); + assert.deepEqual(needCards(hub)[0].said.at(-1), "Two entities changed. Reading what relies on them"); + release(); await flush(); + const drawn = needCards(hub)[0]; + assert.equal(drawn.busy, 0); + assert.deepEqual(drawn.picture.columns.map((column) => [column.head, column.boxes.map((box) => [box[0], box[1], box[3]])]), [ + ["Changed2", [["value", "Function in encoding · Claude Code", `${URL_OF(NOTES)}${guided("n-2")}/1`], ["index", "Function in encoding", `${URL_OF(NOTES)}${guided("n-2")}/2`]]], + ["Rely on them, and did not change2", [["Serializer.dumps", "Method in Serializer", `${URL_OF(NOTES)}#/entity/${uuid(10)}`], ["Notes.load", "Method in Serializer", `${URL_OF(NOTES)}#/entity/${uuid(11)}`]]]]); + assert.equal(hub.count(under(NOTES, "/api/guide")), 1, "the change is read once, not on every redraw"); + // The commit is said once more, as a recent commit, and not a third time as a list of what agents committed. + assert.deepEqual(hub.node("home-agents").querySelectorAll("a.agent-commit"), []); + assert.doesNotMatch(hub.node("home-agents").textContent, /Committed by agents/); + assert.deepEqual(hub.node("home-recent").querySelectorAll("a").map((node) => node.querySelector(".change-title").textContent), ["Return two from value", "Add the notes index"]); + // Its repository waits on the reader in the sidebar and among the repositories, after work of the reader's own. + const notes = hub.nav().querySelectorAll("a.repo-item").find((node) => node.textContent.startsWith("notes")); + assert.equal(notes.querySelector(".repo-dot").dataset.state, "review"); + // Said as briefly as uncommitted work is, so it fits beside when the repository last changed. + assert.deepEqual(repoCards(hub)[0].foot, ["1 to review", "2 hours ago"]); + // Once someone has reviewed it, it no longer needs the reader. + hub.data[under(NOTES, "/api/standing")] = { standing: { "n-2": { state: "approved" } } }; + hub.data[under(NOTES, "/api/workspace/overview")] = { ...hub.data[under(NOTES, "/api/workspace/overview")], read_at: secondsAgo(-1), counts: { ok: true, entities: 11, relationships: 5 } }; + hub.data["/api/home"] = homePayload([{ ...rows[0], last_change_at: secondsAgo(1) }]); + await hub.advance(10_000); + assert.deepEqual(needCards(hub), []); + assert.equal(hub.nav().querySelectorAll("a.repo-item").find((node) => node.textContent.startsWith("notes")).querySelector(".repo-dot").dataset.state, undefined); +}); + +test("two entities of one name on a Home card are told apart by where each sits, as the review tells them apart", async () => { + const hub = fixture({ hub: true }); + const rows = [listed(NOTES, "zod", open())]; + hub.data["/api/home"] = homePayload(rows); + hub.data[under(NOTES, "/api/workspace/overview")] = summaryOf({ recent: [{ id: "z-1", title: "Lowercase strings with the locale's rules", author: "Claude Code", agent: { name: "Claude Code", key: "00000000000000a1" }, time: secondsAgo(120), kin_checkpoint: false }] }); + hub.data[under(NOTES, "/api/standing")] = { standing: {} }; + hub.data[under(NOTES, "/api/counts")] = { counts: {} }; + // Two modules named checks rely on the changed function, and the guide places neither in anything. + const checks = (n) => relying(n, "checks", { relation: "imports", entity: { id: uuid(n), name: "checks", kind: "module", role: "source", within: "" } }); + hub.data[under(NOTES, "/api/guide?change")] = recorded("z-1", { modified: 1, recorded_consumers: 3, consumers_may_need_review: 3 }, [ + changed(2, "_toLowerCase", { within: "module api", connections: connected([relying(10, "_ZodString", { entity: { id: uuid(10), name: "_ZodString", kind: "function", within: "module schemas" } }), checks(11), checks(12)]), consumer_verdict: { verdict: "may_need_review", reason: "body_changed", consumers: 3 } })]); + // Each is read once, from its own repository, for the place Kin records it under. + hub.data[under(NOTES, "/api/workspace/entity")] = (url) => ({ header: { module: url.searchParams.get("id") === uuid(11) ? "zod.src.v4.classic.checks" : "zod.src.v4.mini.checks" } }); + answering(hub, rows); + await flush(); await flush(); + const boxes = needCards(hub)[0].picture.columns[1].boxes.map(([name, place]) => [name, place]); + assert.deepEqual(boxes, [["_ZodString", "Function in schemas"], ["checks in classic", "Module"], ["checks in mini", "Module"]]); + assert.deepEqual(hub.requests.filter((request) => request.name.endsWith("/api/workspace/entity")).map((request) => [request.name, new URL(request.url, "http://local.test").searchParams.get("id")]), + [[under(NOTES, "/api/workspace/entity"), uuid(11)], [under(NOTES, "/api/workspace/entity"), uuid(12)]]); +}); + +test("an agent change Home could not read in full is still said, with its count, and asked about again later", async () => { + const hub = fixture({ hub: true }); + const rows = [listed(NOTES, "notes", open())]; + hub.data["/api/home"] = homePayload(rows); + hub.data[under(NOTES, "/api/workspace/overview")] = summaryOf({ recent: [ + { id: "n-2", title: "Return two from value", author: "Claude Code", agent: { name: "Claude Code", key: "00000000000000a1" }, time: secondsAgo(120), kin_checkpoint: false }] }); + hub.data[under(NOTES, "/api/standing")] = { standing: {} }; + hub.data[under(NOTES, "/api/counts")] = { counts: { "n-2": { entities: 1, module_containers: 0, containers_through_members: 0 } } }; + hub.failures.set(under(NOTES, "/api/guide?change"), "The review guide did not answer within 45 seconds."); + answering(hub, rows); + await flush(); + assert.deepEqual(needCards(hub)[0].said, ["Claude Code committed “Return two from value” 2 minutes ago, and nobody has reviewed it yet.", "One entity changed. Kin couldn't read what relies on it yet. Its review has the rest."]); + assert.equal(needCards(hub)[0].busy, 0); + hub.failures.delete(under(NOTES, "/api/guide?change")); + hub.data[under(NOTES, "/api/guide?change")] = recorded("n-2", { modified: 1, recorded_consumers: 1, consumers_may_need_review: 1 }, [ + changed(2, "value", { connections: connected([relying(10, "Serializer.dumps")]), consumer_verdict: { verdict: "may_need_review", reason: "body_changed", consumers: 1 } })]); + await hub.advance(10_000); + assert.equal(hub.count(under(NOTES, "/api/guide")), 1, "not asked again at once"); + await hub.advance(30_000); + assert.equal(needCards(hub)[0].picture.columns[0].boxes[0][0], "value"); +}); + test("Home shows the agents at work across the repositories it has open, each in its own words and leading into its repository", async () => { const hub = fixture({ hub: true }); await flush(); assert.equal(hub.node("home-agents").querySelector("h2").textContent, "Agents1"); @@ -1923,8 +2216,26 @@ test("Home shows the agents at work across the repositories it has open, each in quiet.data["/api/home"] = homePayload(quiet.data["/api/home"].repositories.map((row) => ({ ...row, agents: row.agents && { active: 0, connected: 0, names: [] } }))); await flush(); assert.equal(quiet.node("home-agents").querySelector("h2").textContent, "Agents"); - assert.equal(quiet.node("home-agents").textContent, "AgentsConnect an agentNo agent is connected.Lantern last worked in pallets / Fixture 12 minutes ago. Checked what a change to want_bytes would touch."); - assert.deepEqual(quiet.node("home-agents").querySelectorAll("p")[1].querySelectorAll("a").map((node) => node.getAttribute("href")), [`${URL_OF(KEY)}#/agents`, `${URL_OF(KEY)}#/entity/${uuid(2)}`]); + // The session is said by what it came to, the change it committed, and not by the last call it made. + assert.equal(quiet.node("home-agents").textContent, "AgentsConnect an agentNo agent is connected.Lantern last worked in pallets / Fixture 12 minutes ago. Committed “Explain how want_bytes treats text”."); + assert.deepEqual(quiet.node("home-agents").querySelectorAll("p")[1].querySelectorAll("a").map((node) => node.getAttribute("href")), [`${URL_OF(KEY)}#/agents`]); + // Once Home has read how its review stands, it says the change waits for the reader. + const waiting = fixture({ hub: true }); + waiting.data[under(KEY, "/api/workspace/agents")] = quiet.data[under(KEY, "/api/workspace/agents")]; + waiting.data["/api/home"] = quiet.data["/api/home"]; + waiting.data[under(KEY, "/api/workspace/overview")] = summaryOf({ recent: [{ id: AGENT_CHANGE, title: "Explain how want_bytes treats text", author: "Lantern", agent: { name: "Lantern", key: "00000000000000b2" }, time: secondsAgo(720), kin_checkpoint: false }] }); + waiting.data[under(KEY, "/api/standing")] = { standing: {} }; + waiting.data[under(KEY, "/api/counts")] = { counts: {} }; + waiting.data[under(KEY, "/api/guide?change")] = recorded(AGENT_CHANGE, { modified: 1 }, [changed(2, "want_bytes")]); + await flush(); + assert.match(waiting.node("home-agents").textContent, /Lantern last worked in pallets \/ Fixture 12 minutes ago\. Committed “Explain how want_bytes treats text”, waiting for your review\.$/); + // A session that committed nothing is said by its last call, a failed one by what it tried. + const tried = fixture({ hub: true }); + tried.data[under(KEY, "/api/workspace/agents")] = agentsPayload({ agents: [], earlier: [{ ...agentsPayload().agents[0], key: "00000000000000b3", name: "Lantern", state: "ended", open_changes: [], changes: [], changes_total: 0, edited: [], last_activity_at: secondsAgo(720), + recent_calls: [{ seq: 9, at: secondsAgo(720), tool: "kin_transaction_abort", entity_ids: [], result_count: 0, duration_ms: 27_900, is_error: true }] }] }); + tried.data["/api/home"] = quiet.data["/api/home"]; + await flush(); + assert.match(tried.node("home-agents").textContent, /Lantern last worked in pallets \/ Fixture 12 minutes ago\. Tried to drop the change it had started; Kin did not answer within 28 s\.$/); const none = fixture({ hub: true }); none.data[under(KEY, "/api/workspace/agents")] = agentsPayload({ agents: [] }); delete none.data["/api/home/clients"]; @@ -1950,9 +2261,9 @@ test("each repository is a card with how large it is, what it is written in and assert.equal(hub.node("home-repos").querySelector("h2").textContent, "Repositories7"); // What needs the reader first, then where agents are at work, then the most recently changed. assert.deepEqual(repoCards(hub), [ - { name: "pallets / Fixture", branch: "main", stats: ["216 entities", "602 connections"], languages: ["Python 97%", "TOML 3%"], foot: ["2 uncommitted", "1 agent working", "10 minutes ago"], href: URL_OF(KEY), state: "work" }, + { name: "pallets / Fixture", branch: "main", stats: ["216 entities", "602 connections"], languages: ["Python 97%", "TOML 3%"], foot: ["2 uncommitted", "10 minutes ago"], href: URL_OF(KEY), state: "work", live: "Claude Code is working" }, { name: "merging", branch: "main", stats: ["216 entities"], languages: [], foot: ["A merge to settle", "50 minutes ago"], href: URL_OF(repositoryKey("d")), state: "clean" }, - { name: "busy", branch: "main", stats: ["3,200 entities", "9,100 connections"], languages: ["Rust 98%", "TOML 1%"], foot: ["All committed", "2 agents working", "1 hour ago"], href: URL_OF(repositoryKey("1")), state: "clean" }, + { name: "busy", branch: "main", stats: ["3,200 entities", "9,100 connections"], languages: ["Rust 98%", "TOML 1%"], foot: ["All committed", "1 hour ago"], href: URL_OF(repositoryKey("1")), state: "clean", live: "2 agents are working" }, { name: "notes", branch: "main", stats: ["40 entities", "51 connections"], languages: ["Rust 100%"], foot: ["All committed", "yesterday"], href: URL_OF(NOTES), state: "clean" }, { name: "archive", branch: "", stats: ["12 entities", "9 connections"], languages: ["Go 100%"], foot: ["All committed", "last month"], href: URL_OF(ARCHIVE), state: "clean" }, { name: "elsewhere", branch: "", stats: ["10 entities", "5 connections"], languages: ["Python 100%"], foot: ["All committed"], href: "/", state: "clean" }, @@ -1965,7 +2276,7 @@ test("each repository is a card with how large it is, what it is written in and const early = fixture({ hub: true }); early.held.set(under(KEY, "/api/workspace/overview"), new Promise(() => {})); await flush(); - assert.deepEqual(repoCards(early)[0], { name: "pallets / Fixture", branch: "main", stats: ["216 entities"], languages: [], foot: ["Uncommitted work", "1 agent working", "10 minutes ago"], href: URL_OF(KEY), state: "work" }); + assert.deepEqual(repoCards(early)[0], { name: "pallets / Fixture", branch: "main", stats: ["216 entities"], languages: [], foot: ["Uncommitted work", "10 minutes ago"], href: URL_OF(KEY), state: "work", live: "Claude Code is working" }); // A share of the whole never rounds a language up to all of it, and one too small to see is left out. const shares = fixture({ hub: true }); shares.data[under(KEY, "/api/workspace/overview")] = summaryOf({ languages: [["python", 995], ["toml", 5]] }); @@ -2004,7 +2315,7 @@ test("code Kin has seen differ before counting it asks nothing of the reader, an test("Home lists what was committed lately across the repositories it has open, newest first", async () => { const hub = fixture({ hub: true }); await flush(); const rows = () => hub.node("home-recent").querySelectorAll("a").map((row) => [row.querySelector("span").children.map((part) => part.textContent), row.getAttribute("href")]); - assert.equal(hub.node("home-recent").querySelector("h2").textContent, "Recently committed"); + assert.equal(hub.node("home-recent").querySelector("h2").textContent, "Committed in the last two weeks"); assert.deepEqual(rows(), [ [["Bound base64 input", "pallets / FixtureDana Reader10 minutes ago"], `${URL_OF(KEY)}#/review/c/change-1`], [["Add the notes index", "notesAna Writeryesterday"], `${URL_OF(NOTES)}#/review/c/n-1`]], "Kin's own updates are not listed"); @@ -2027,7 +2338,7 @@ test("a question on Home is put to each repository it has open, and each answers assert.deepEqual(asked(), { [KEY]: [], [NOTES]: [], [ARCHIVE]: [] }, "nothing is asked while the reader is still typing"); await hub.advance(220); assert.equal(hub.node("home-body").hidden, true, "the question has the page to itself"); - assert.deepEqual(asked(), { [KEY]: ["ask?q=sign&limit=5"], [NOTES]: ["ask?q=sign&limit=5"], [ARCHIVE]: ["ask?q=sign&limit=5"] }); + assert.deepEqual(asked(), { [KEY]: ["ask?q=sign&limit=10"], [NOTES]: ["ask?q=sign&limit=10"], [ARCHIVE]: ["ask?q=sign&limit=10"] }); const answers = hub.node("home-ask").querySelectorAll("section"); assert.deepEqual(answers.map((answer) => answer.querySelector("h2").textContent), ["pallets / Fixture"], "only the repositories with an answer are listed"); assert.deepEqual(answers[0].querySelectorAll("a").map((node) => node.getAttribute("href")), [`${URL_OF(KEY)}#/explore?q=sign`, `${URL_OF(KEY)}#/entity/${uuid(5)}`, `${URL_OF(KEY)}#/entity/${uuid(2)}`], @@ -2043,7 +2354,7 @@ test("a question on Home is put to each repository it has open, and each answers hub.data[under(ARCHIVE, "/api/workspace/ask")] = hub.data[under(KEY, "/api/workspace/ask")]; hub.node("home-ask-input").value = "zzz"; hub.node("home-ask-input").emit("keydown", { key: "Enter" }); await flush(); - assert.equal(asked()[KEY].at(-1), "ask?q=zzz&limit=5"); + assert.equal(asked()[KEY].at(-1), "ask?q=zzz&limit=10"); assert.equal(hub.node("home-ask").textContent, "Nothing in these repositories matches that."); assert.equal(hub.node("workspace-announcer").textContent, "No results."); // An answer to a question the reader has since changed is not shown. @@ -2063,6 +2374,80 @@ test("a question on Home is put to each repository it has open, and each answers assert.equal(hub.document.activeElement.id, "home-ask-input"); }); +test("a question on Home leads with the strongest answer: a traced chain first, then names that hold the question's words, and a loose match is one line", async () => { + const hub = fixture({ hub: true }); + const row = (n, name, kind, fields = {}) => ({ id: uuid(n), name, kind, matched: ["meaning"], why: "Its embedding is close to the question.", ...fields }); + // The first repository Home lists matched only by meaning, and leads the list it was asked in. + hub.data[under(KEY, "/api/workspace/ask")] = { schema: "kin.workspace.ask.v1", mode: "meaning_and_name", total: 3, results: [ + row(40, "lazyWithGetterOverride", "function", { signature: "function lazyWithGetterOverride(getter: () => T)", container: { id: uuid(41), name: "zod.src.v4.core.util", kind: "module" } }), + row(42, "ZodLazyDef.getter", "method", { signature: "getter: () => T", container: { id: uuid(43), name: "ZodLazyDef", kind: "interface" } }), + row(44, "GET", "function", { signature: "async function GET()" })] }; + // The second answered with a chain Kin traced, and a match whose name holds every word asked. + hub.data[under(NOTES, "/api/workspace/ask")] = { schema: "kin.workspace.ask.v1", mode: "meaning_and_name", total: 2, + answer: { sentence: "Reply.send calls serialize.", steps: [{ id: uuid(50), name: "Reply.send", kind: "method", relation_to_next: "calls" }, { id: uuid(51), name: "serialize", kind: "function" }] }, + results: [row(52, "Reply.getSerializationFunction", "method", { signature: "Reply.prototype.getSerializationFunction = function (schemaOrStatus)", matched: ["name", "meaning"] }), + row(53, "port", "method", { signature: "port?: number", container: { id: uuid(54), name: "FastifyListenOptions", kind: "interface" } }), + row(55, "RecordKeysToLowercase", "typealias", { signature: "type RecordKeysToLowercase = Input" })] }; + // The third repeats one name in many places. + hub.data[under(ARCHIVE, "/api/workspace/ask")] = { schema: "kin.workspace.ask.v1", mode: "meaning_and_name", total: 6, results: [ + ...[60, 61, 62, 63, 64].map((n) => row(n, "read_items", "function", { container: { id: uuid(n + 10), name: `docs_src.tutorial${n}.py310`, kind: "module" } })), + row(65, "items_router", "function")] }; + await flush(); + hub.type("home-ask-input", "how does a reply get serialized"); await hub.advance(320); + const answers = hub.node("home-ask").querySelectorAll("section"); + assert.deepEqual(answers.map((answer) => answer.querySelector("h2").textContent), ["notes"], "the traced answer comes first, and only strong answers are drawn in full"); + assert.equal(answers[0].querySelector(".answer").querySelector("p").textContent, "Reply.send calls serialize."); + const rows = answers[0].querySelectorAll(".entity-row").map((node) => [node.querySelector(".ent-name").textContent, node.querySelector(".entity-container").textContent]); + assert.deepEqual(rows, [["Reply.getSerializationFunction", "Method"], ["port", "Property in FastifyListenOptions"], ["RecordKeysToLowercase", "Type alias"]], + "a member declared as a value is a property, and a type alias is said as two words"); + // A repository whose matches name none of the question's words, or one name many times over, is one line each. + const loose = hub.node("home-ask").querySelectorAll(".home-answer-loose").map((node) => [node.querySelector(".home-answer-loose-name").textContent, node.querySelector(".home-answer-loose-text").textContent]); + assert.deepEqual(loose, [["pallets / Fixture", "Only loose matches: lazyWithGetterOverride, ZodLazyDef.getter and 1 more."], ["archive", "Only loose matches: read_items, items_router and 4 more."]]); + // Opening one draws it in full, with each name once and its repeats counted, and modules by their own names. + hub.node("home-ask").querySelectorAll(".home-answer-loose")[1].querySelector("button").click(); await flush(); + const archive = hub.node("home-ask").querySelectorAll("section").find((node) => node.querySelector("h2").textContent === "archive"); + assert.deepEqual(archive.querySelectorAll(".entity-row").map((node) => node.querySelector(".entity-line").textContent), ["read_itemsFunction in py310and 4 more of this name", "items_routerFunction"]); + hub.data[under(ARCHIVE, "/api/workspace/ask")].results[0].container.name = "zod.src.v4.classic.schemas"; + hub.type("home-ask-input", "read items"); await hub.advance(320); + assert.match(hub.node("home-ask").textContent, /read_itemsFunction in schemasand 4 more of this name/, "a module recorded under a dotted place is named as code names it"); + hub.data[under(ARCHIVE, "/api/workspace/ask")].results[0].container.name = "extract.ws"; + hub.type("home-ask-input", "read item"); await hub.advance(320); + assert.match(hub.node("home-ask").textContent, /read_itemsFunction in wsand 4 more of this name/); + hub.data[under(ARCHIVE, "/api/workspace/ask")].results[0].container.name = "apply.test"; + hub.type("home-ask-input", "reads item"); await hub.advance(320); + assert.match(hub.node("home-ask").textContent, /read_itemsFunction in apply\.testand 4 more of this name/, "a suffix of the module's own name stays"); +}); + +test("a repository that answers a question by name shows those names, folds its looser matches into one line, and names modules as code does", async () => { + const hub = fixture({ hub: true }); + const row = (n, name, kind, fields = {}) => ({ id: uuid(n), name, kind, matched: ["meaning"], why: "Its description and code share words with the question.", ...fields }); + // As zod's ask answers it: a method placed only by the module it is recorded under. + hub.data[under(KEY, "/api/workspace/ask")] = { schema: "kin.workspace.ask.v1", mode: "meaning_and_name", total: 3, results: [ + row(70, "_ZodString.lowercase", "method", { module: "zod.src.v4.classic.schemas", signature: "lowercase(params?: string): this" }), + row(71, "_lowercase", "function", { container: { id: uuid(72), name: "api", kind: "module" }, module: "zod.src.v4.core.api" }), + row(73, "$ZodCheckLowerCaseDef", "interface", { container: { id: uuid(74), name: "checks", kind: "module" } })] }; + // As fastify's answers it: one name that holds a word of the question, and four that do not. + hub.data[under(NOTES, "/api/workspace/ask")] = { schema: "kin.workspace.ask.v1", mode: "meaning_and_name", total: 5, results: [ + row(80, "RecordKeysToLowercase", "typealias", { container: { id: uuid(81), name: "utils", kind: "module" } }), + row(82, "ResolveFastifyRequestType.headers", "method", { signature: "headers: RecordKeysToLowercase" }), + row(83, "HTTPMethods", "typealias", { container: { id: uuid(81), name: "utils", kind: "module" } }), + row(84, "FastifyLoggerOptions.serializers", "method", { signature: "serializers?: { req?: unknown }" }), + row(85, "FastifyReply.serializeInput", "method", { signature: "serializeInput(input: unknown): string" })] }; + hub.data[under(ARCHIVE, "/api/workspace/ask")] = { schema: "kin.workspace.ask.v1", mode: "meaning_and_name", total: 0, results: [] }; + await flush(); + hub.type("home-ask-input", "where are strings lowercased"); await hub.advance(320); + const answers = hub.node("home-ask").querySelectorAll("section"); + assert.deepEqual(answers.map((answer) => answer.querySelector("h2").textContent), ["pallets / Fixture", "notes"], "the repository whose names hold more of the question leads"); + const rows = (answer) => answer.querySelectorAll(".entity-row").map((node) => [node.querySelector(".ent-name").textContent, node.querySelector(".entity-container").textContent]); + assert.deepEqual(rows(answers[0]), [["_ZodString.lowercase", "Method in schemas"], ["_lowercase", "Function in api"], ["$ZodCheckLowerCaseDef", "Interface in checks"]]); + assert.deepEqual(rows(answers[1]), [["RecordKeysToLowercase", "Type alias in utils"]]); + assert.equal(answers[1].querySelector(".home-answer-looser").querySelector(".home-answer-loose-text").textContent, "Also 4 loose matches: ResolveFastifyRequestType.headers, HTTPMethods and 2 more."); + answers[1].querySelector(".home-answer-looser").querySelector("button").click(); await flush(); + const opened = hub.node("home-ask").querySelectorAll("section").find((node) => node.querySelector("h2").textContent === "notes"); + assert.deepEqual(rows(opened).map(([name]) => name), ["RecordKeysToLowercase", "ResolveFastifyRequestType.headers", "HTTPMethods", "FastifyLoggerOptions.serializers", "FastifyReply.serializeInput"]); + assert.equal(opened.querySelector(".home-answer-looser"), null); +}); + test("a repository that could not answer a question on Home is not said to have nothing that matches", async () => { const hub = fixture({ hub: true }); hub.data[under(KEY, "/api/workspace/ask")] = ASKED(); @@ -2071,13 +2456,25 @@ test("a repository that could not answer a question on Home is not said to have await flush(); hub.type("home-ask-input", "sign"); await hub.advance(320); assert.doesNotMatch(hub.node("home-ask").textContent, /Nothing in [^.]*archive[^.]* matches/); + // Nor is one whose search by meaning did not answer and whose names hold nothing. + hub.failures.delete(under(ARCHIVE, "/api/workspace/ask")); + hub.data[under(ARCHIVE, "/api/workspace/ask")] = { schema: "kin.workspace.ask.v1", mode: "name_only", notice: "Meaning search could not answer, so these are name matches only.", results: [], total: 0, names: { ok: true, total: 0 } }; + hub.type("home-ask-input", "signs"); await hub.advance(320); + assert.doesNotMatch(hub.node("home-ask").textContent, /Nothing in [^.]*archive[^.]* matches/); + assert.match(hub.node("home-ask").textContent, /Kin couldn't ask archive just now\./); }); test("each part of Home is drawn again only when what it shows has changed", async () => { - const hub = fixture({ hub: true }); await flush(); + const hub = fixture({ hub: true }); + let releaseArchive; + hub.held.set(under(ARCHIVE, "/api/guide"), new Promise((done) => { releaseArchive = done; })); + await flush(); const parts = ["home-headline", "home-attention", "home-agents", "home-recent"]; const drawn = () => parts.map((id) => hub.node(id).children[0]); const before = drawn(); + releaseArchive(); + await flush(); + assert.deepEqual(drawn().map((node, i) => node === before[i]), [true, true, true, true], "a clean repository finishing its working read leaves the attention card and its lines untouched"); await hub.advance(10_000); assert.deepEqual(drawn().map((node, i) => node === before[i]), [true, true, true, true], "a reading that says the same draws nothing"); hub.data[under(KEY, "/api/workspace/agents")] = agentsPayload({ agents: [{ ...agentsPayload().agents[0], calls_total: 8, recent_calls: [{ seq: 8, at: secondsAgo(1), tool: "get_entity_source", entity_ids: [uuid(3)], result_count: 1, duration_ms: 5, is_error: false }, ...agentsPayload().agents[0].recent_calls] }] }); @@ -2114,6 +2511,153 @@ test("Home's line about agents agrees with the agent cards under it", async () = assert.equal(headline(hub)[1], "No agent is connected. One repository on this machine."); }); +/** Everything Home says at once: its headline, the note under it, each card's repository, each repository card's foot and agent line, the agents panel, and the sidebar's dots. */ +function homeSays(hub) { + return { headline: headline(hub), known: hub.node("home-headline").querySelector(".home-known")?.textContent ?? "", + needs: needCards(hub).map((card) => card.repository[0]), cards: repoCards(hub).map((card) => [card.name, card.foot, card.live ?? ""]), + agents: agentCards(hub).map((card) => card.head), quiet: hub.node("home-agents").querySelector(".quiet-card-title")?.textContent ?? "", + side: sideRepositories(hub).map((row) => [row.name, row.mark]) }; +} + +test("Home holds still while a repository's daemon stops answering, and says that what it shows was last known", async () => { + const hub = fixture({ hub: true }); + await flush(); + const before = homeSays(hub); + assert.deepEqual(before.headline, ["pallets / Fixture needs you.", "One agent is working. Three repositories on this machine."]); + assert.deepEqual(before.side.find(([name]) => name === "pallets / Fixture"), ["pallets / Fixture", "work"]); + assert.equal(before.known, ""); + // The daemon stops answering: an older server lists the repository with nothing in motion, and the repository's own reads fail. + const rows = () => hub.data["/api/home"].repositories; + const [fixtureRow, ...rest] = rows(); + const { working, agents, branch, merge_in_progress, ...silent } = fixtureRow; + hub.data["/api/home"] = homePayload([{ ...silent, runtime: "idle" }, ...rest]); + for (const part of ["/api/workspace/agents", "/api/guide", "/api/workspace/overview"]) hub.failures.set(under(KEY, part), "kin daemon is required: the daemon did not answer."); + const at = new Date(START).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + // Its card says when what it shows was known, in place of when it last changed. + const asOf = (cards) => cards.map(([name, foot, live]) => [name, name === "pallets / Fixture" ? [foot[0], `As of ${at}`] : foot, live]); + for (let poll = 0; poll < 4; poll++) { + await hub.advance(10_000); + const now = homeSays(hub); + // Every part says what it said before, and one line says why. + assert.deepEqual({ ...now, known: "" }, { ...before, cards: asOf(before.cards), known: "" }, `poll ${poll}: nothing moved but the daemon`); + assert.equal(now.known, `Kin can't reach pallets / Fixture right now; showing what it knew at ${at}.`); + } + // A card of what needs the reader says it too. + assert.match(hub.node("home-attention").textContent, /Kin can't reach pallets \/ Fixture right now; showing what it knew at/); + // The daemon answers again: the note goes, and nothing else moved. + for (const part of ["/api/workspace/agents", "/api/guide", "/api/workspace/overview"]) hub.failures.delete(under(KEY, part)); + hub.data["/api/home"] = homePayload([fixtureRow, ...rest]); + await hub.advance(10_000); + const untimed = (says) => ({ ...says, cards: says.cards.map(([name, foot, live]) => [name, foot.slice(0, 1), live]) }); + assert.deepEqual(untimed(homeSays(hub)), untimed(before)); + assert.equal(homeSays(hub).known, ""); +}); + +test("a repository the server says it cannot reach is shown as last known with when, and a stopped one holds no agent", async () => { + const hub = fixture({ hub: true }); + const knownAt = secondsAgo(600); + const at = new Date(Date.parse(knownAt)).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + hub.data["/api/home"] = homePayload([ + listed(KEY, "Fixture", { owner: "pallets", host: "github.com", ...open({ working: { changed: 2, differs: true }, agents: { active: 1, connected: 1, names: ["Claude Code"] } }), reach: "not_answering", known_at: knownAt }), + listed(NOTES, "notes", { runtime: "idle", reach: "stopped", known_at: knownAt, branch: "main", working: { changed: 4, differs: true } })]); + hub.failures.set(under(KEY, "/api/workspace/agents"), "kin daemon is required"); + hub.data[under(NOTES, "/api/guide")] = working({ added: 4 }, [changed(40, "notes"), changed(41, "addNote"), changed(42, "findNote"), changed(43, "main")]); + answering(hub, hub.data["/api/home"].repositories); + await flush(); + const says = homeSays(hub); + assert.deepEqual(says.headline, ["Two repositories need you.", "One agent is working. Two repositories on this machine."]); + assert.equal(says.known, `Kin can't reach pallets / Fixture right now; showing what it knew at ${at}. Kin can't reach notes right now; showing what it knew at ${at}.`); + // The stopped one's work stays, with its dot, and no agent is said to be in it. + assert.deepEqual(says.side.find(([name]) => name === "notes"), ["notes", "work"]); + assert.deepEqual(says.cards.find(([name]) => name === "notes"), ["notes", ["4 uncommitted", `As of ${at}`], ""]); + assert.deepEqual(says.agents, [["Claude Code", "in pallets / Fixture", "Working"]]); +}); + +test("Home's headline, agent cards, repository cards and sidebar all take their agents from the same reading", async () => { + // The machine's list counts an agent; the repository's own list, read after it, has none. + const hub = fixture({ hub: true }); + hub.data["/api/home"] = homePayload([listed(KEY, "Fixture", { owner: "pallets", host: "github.com", ...open({ agents: { active: 1, connected: 1, names: ["Claude Code"] } }) })]); + hub.data[under(KEY, "/api/workspace/agents")] = agentsPayload({ agents: [] }); + hub.data[under(KEY, "/api/guide")] = guide(); + await flush(); + let says = homeSays(hub); + assert.equal(says.headline[1], "No agent is connected. One repository on this machine."); + assert.equal(says.quiet, "No agent is connected."); + assert.deepEqual(says.cards[0][2], "", "no card says an agent is working"); + assert.deepEqual(says.side, [["pallets / Fixture", ""]]); + // The other way round: the repository's own list is old and failing, and the list's newer count is said everywhere. + const counted = fixture({ hub: true }); + counted.data["/api/home"] = homePayload([listed(KEY, "Fixture", { owner: "pallets", host: "github.com", ...open({ agents: { active: 1, connected: 1, names: ["Claude Code"] } }) })]); + counted.failures.set(under(KEY, "/api/workspace/agents"), "The agents read did not answer within 20 seconds."); + counted.data[under(KEY, "/api/guide")] = guide(); + await flush(); + says = homeSays(counted); + assert.equal(says.headline[1], "One agent is working. One repository on this machine."); + assert.deepEqual(says.agents, [["Claude Code", "in pallets / Fixture", "Working"]]); + assert.equal(agentCards(counted)[0].action, "Working through Kin now."); + assert.equal(says.cards[0][2], "Claude Code is working"); + assert.deepEqual(says.side, [["pallets / Fixture", "live"]]); +}); + +test("an agent's commit waiting for review stays on Home when a later summary could not read its recent changes, and after a reload", async () => { + const tabStorage = new Map(); + const hub = fixture({ hub: true, tabStorage }); + const rows = [listed(NOTES, "notes", open())]; + hub.data["/api/home"] = homePayload(rows); + const summary = summaryOf({ recent: [{ id: "n-2", title: "Return two from value", author: "Claude Code", agent: { name: "Claude Code", key: "00000000000000a1" }, time: secondsAgo(120), kin_checkpoint: false }] }); + hub.data[under(NOTES, "/api/workspace/overview")] = summary; + hub.data[under(NOTES, "/api/standing")] = { standing: {} }; + hub.data[under(NOTES, "/api/counts")] = { counts: { "n-2": { entities: 1, module_containers: 0, containers_through_members: 0 } } }; + hub.data[under(NOTES, "/api/guide?change")] = recorded("n-2", { modified: 1 }, [changed(2, "value")]); + answering(hub, rows); + await flush(); + const before = homeSays(hub); + assert.deepEqual(before.needs, ["notesmainAgent commit"]); + // A new change elsewhere in it makes Home read its summary again, and that read times out on its recent changes. + hub.data[under(NOTES, "/api/workspace/overview")] = { ...summary, read_at: secondsAgo(-1), recent_changes: { ok: false, reason: "Kin did not answer within 20 seconds." } }; + hub.data["/api/home"] = homePayload([{ ...rows[0], last_change_at: secondsAgo(1) }]); + await hub.advance(10_000); + assert.ok(hub.count(under(NOTES, "/api/workspace/overview")) >= 2); + assert.deepEqual(homeSays(hub).needs, before.needs, "the card stays"); + assert.deepEqual(homeSays(hub).side, before.side); + // A reload in this tab draws the card from what the tab last read, before the repository answers again. + const reloaded = fixture({ hub: true, tabStorage }); + reloaded.data["/api/home"] = homePayload(rows); + answering(reloaded, rows); + reloaded.held.set(under(NOTES, "/api/workspace/overview"), new Promise(() => {})); + reloaded.held.set(under(NOTES, "/api/standing"), new Promise(() => {})); + await flush(); + assert.deepEqual(homeSays(reloaded).needs, ["notesmainAgent commit"]); + assert.equal(needCards(reloaded)[0].picture.columns[0].boxes[0][0], "value", "drawn from the guide the tab already read"); + assert.equal(reloaded.count(under(NOTES, "/api/guide")) - reloaded.requests.filter((request) => request.url.includes("working=1")).length, 0); +}); + +test("an agent's commit is waiting on Home as soon as its session names it, before a busy repository's summary lists it", async () => { + const hub = fixture({ hub: true }); + const rows = [listed(NOTES, "axum", open())]; + hub.data["/api/home"] = homePayload(rows); + // The summary's recent changes timed out; its other parts answered. + hub.data[under(NOTES, "/api/workspace/overview")] = { ...summaryOf(), recent_changes: { ok: false, reason: "Kin could not read recent changes. It did not answer within 10 seconds." } }; + // The session that committed it has ended, and Kin could not read the change's title yet. + const fresh = "9".repeat(64), old = "8".repeat(64); + hub.data[under(NOTES, "/api/workspace/agents")] = agentsPayload({ agents: [], earlier: [ + { ...agentsPayload().agents[0], key: "00000000000000b1", state: "ended", last_activity_at: secondsAgo(60), open_changes: [], changes: [{ id: fresh }], changes_total: 1 }, + { ...agentsPayload().agents[0], key: "00000000000000b2", state: "ended", last_activity_at: secondsAgo(3 * 86_400), open_changes: [], changes: [{ id: old, title: "An older change", time: secondsAgo(3 * 86_400) }], changes_total: 1 }] }); + hub.data[under(NOTES, "/api/standing")] = { standing: {} }; + hub.data[under(NOTES, "/api/counts")] = { counts: { [fresh]: { entities: 1, module_containers: 0, containers_through_members: 0 } } }; + hub.data[under(NOTES, "/api/guide?change")] = recorded(fresh, { modified: 1 }, [changed(2, "Redirect::to")]); + answering(hub, rows); + await flush(); await flush(); + assert.equal(hub.count(under(NOTES, "/api/standing")), 1, "how its review stands is read because a session names a change"); + const says = homeSays(hub); + assert.deepEqual(says.needs, ["axummainAgent commit"]); + assert.equal(needCards(hub)[0].said[0], "Claude Code committed a change 1 minute ago, and nobody has reviewed it yet."); + assert.deepEqual(needCards(hub)[0].actions, [["Review", `${URL_OF(NOTES)}${guided(fresh)}`]]); + assert.deepEqual(says.side, [["axum", "review"]]); + assert.equal(says.cards[0][1][0], "1 to review"); + assert.match(hub.node("home-agents").textContent, /Claude Code last worked in axum 1 minute ago\. Committed a change, waiting for your review\.$/); +}); + test("a machine with no repository in Kin yet offers, where it says so, the way to bring one in", async () => { const hub = fixture({ hub: true }); hub.data["/api/home"] = homePayload([]); await flush(); assert.deepEqual(headline(hub), ["Bring your first project into Kin.", "Kin converts a project you already have, keeps its history, and maps how its code connects. Your project stays where it is."]); @@ -2325,7 +2869,7 @@ test("the hub's addresses open the add panel and the coding agents", async () => test("coding agents are listed by whether they are here and connected, and Connect asks Kin once", async () => { const hub = fixture({ hub: true, hash: "#/agents" }); await flush(); const rows = () => hub.node("hub-clients").querySelectorAll("li").map((row) => ({ text: row.textContent, button: row.querySelector("button") })); - assert.deepEqual(rows().map((row) => row.text.replace(/Connect$/, "")), ["Claude CodeConnected", "Harbor EditorInstalled, not connected", "LanternNot installed on this machine"], "connected first, then what is waiting, then what is not here"); + assert.deepEqual(rows().map((row) => row.text.replace(/Connect$/, "")), ["Claude CodeConfigured", "Harbor EditorInstalled, not configured", "LanternNot installed on this machine"], "connected first, then what is waiting, then what is not here"); assert.deepEqual(rows().map((row) => Boolean(row.button)), [false, true, false], "only an agent that is here and not connected can be connected"); const done = { operation_id: OPERATION, action: "connect", state: "done", stage: "finishing", phase: "Connected", lines: [] }; hub.data["POST /api/home/clients"] = { operation_id: OPERATION, action: "connect", state: "running", stage: "starting", phase: "Starting", lines: [] }; @@ -2344,7 +2888,7 @@ test("coding agents are listed by whether they are here and connected, and Conne assert.equal(rows()[1].button.disabled, true, "still connecting"); await hub.advance(900); await flush(); assert.equal(hub.requests.filter((request) => request.name === "/api/home/clients" && request.method === "GET").length, listReads + 1, "the list is read again when Kin says it is done"); - assert.deepEqual(rows().map((row) => row.text).sort(), ["Claude CodeConnected", "Harbor EditorConnected", "LanternNot installed on this machine"]); + assert.deepEqual(rows().map((row) => row.text).sort(), ["Claude CodeConfigured", "Harbor EditorConfigured", "LanternNot installed on this machine"]); assert.equal(hub.node("hub-clients").querySelector("[role=alert]"), null); }); @@ -2384,7 +2928,7 @@ test("a server that lists no coding agents leaves their page out of the sidebar without.navigate("agents"); assert.equal(without.node("hub-clients").textContent, "This version of Kin can't list coding agents here."); without.refresh(); await flush(); - assert.equal(without.count("/api/home/clients"), 1, "and it is not asked again"); + assert.equal(without.count("/api/home/clients"), 2, "an explicit refresh retries the unavailable capability"); }); test("the hub reads its list again while it is being looked at, and not while it is hidden", async () => { @@ -2422,10 +2966,11 @@ test("the sidebar is the map of the machine: every repository, and the open one assert.deepEqual(head.map((node) => [node.textContent, node.getAttribute("href")]), [["Home", "/"], ["", "/#/add"]], "the way back to the machine's Home, and the way to add a repository"); assert.ok(browser.nav().querySelectorAll("span").some((node) => node.textContent === "Repositories"), "the list is named"); assert.equal(head[0].getAttribute("aria-current"), null, "Home is the machine's page, not this one"); - // The open repository first, then the ones with something in motion, then the rest. Each dot says what is in motion there. + // The ones with something in motion first, then the rest, each by name. Opening a repository does not move it, + // so the list holds still under the pointer. Each dot says what is in motion there. assert.deepEqual(sideRepositories(browser), [ - { name: "pallets / Fixture", href: "#/uncommitted", mark: "work", branch: "main" }, { name: "notes", href: `/repositories/${NOTES}/workspace/`, mark: "work", branch: "main" }, + { name: "pallets / Fixture", href: "#/uncommitted", mark: "work", branch: "main" }, { name: "busy", href: `/repositories/${repositoryKey("1")}/workspace/`, mark: "live", branch: "main" }, { name: "archive", href: `/repositories/${ARCHIVE}/workspace/`, mark: "", branch: "" }, { name: "elsewhere", href: "/", mark: "", branch: "" }], "the open repository leads into itself, the others to their own addresses and never off this server"); @@ -2443,20 +2988,64 @@ test("the sidebar is the map of the machine: every repository, and the open one assert.deepEqual(sideCurrent(browser), [page], address); } // The open repository's dot and counts follow its own reads: work first, then an agent working, then nothing. + const fixtureRow = () => sideRepositories(browser).find((row) => row.name === "pallets / Fixture"); browser.data["/api/guide"] = guide(); browser.refresh(); await flush(); - assert.equal(sideRepositories(browser)[0].mark, "live"); + assert.equal(fixtureRow().mark, "live"); assert.equal(sideCount(browser, "uncommitted"), null); browser.data["/api/workspace/agents"] = agentsPayload({ agents: [{ ...agentsPayload().agents[0], state: "idle" }] }); browser.refresh(); await flush(); - assert.equal(sideRepositories(browser)[0].mark, ""); + assert.equal(fixtureRow().mark, ""); assert.equal(sideCount(browser, "agents"), null, "an idle agent is not working now"); browser.data["/api/branches"] = { branches: [{ name: "main", active: false, default: true }, { name: "feature", active: true }] }; browser.refresh(); await flush(); - assert.equal(sideRepositories(browser)[0].branch, "feature", "the branch it names is the one that is checked out"); + assert.equal(fixtureRow().branch, "feature", "the branch it names is the one that is checked out"); assert.doesNotMatch(browser.nav().textContent, /null|undefined/, "a part that is left out is left out, not written as a word"); }); +test("an agent's change nobody has reviewed marks its repository in every page's sidebar, after uncommitted work and before an agent at work", async () => { + // Home read it in this tab: notes waits for a review. + const tabStorage = new Map([[`kin.workspace.awaiting-review:${NOTES}`, JSON.stringify(["n-2"])]]); + const browser = fixture({ tabStorage }); + browser.data["/api/home"] = homePayload([ + listed(repositoryKey("1"), "busy", open({ agents: { active: 1, connected: 1, names: ["Lantern"] } })), + listed(NOTES, "notes", open()), + listed(KEY, "Fixture", { owner: "pallets", host: "github.com", ...open() })]); + browser.data["/api/workspace/agents"] = agentsPayload({ agents: [] }); + browser.data["/api/guide"] = guide(); + // This repository's own newest change is an agent's, and nobody has reviewed it. + browser.data["/api/changes"] = { changes: [{ id: "c-9", title: "Return two", author: "Claude Code", agent: { name: "Claude Code", key: AGENT_KEY }, timestamp: secondsAgo(60), entity_deltas: 1 }, ...browser.data["/api/changes"].changes], truncated: false }; + browser.data["/api/standing"] = { standing: {} }; + await flush(); + assert.deepEqual(sideRepositories(browser).map((row) => [row.name, row.mark]), [["notes", "review"], ["pallets / Fixture", "review"], ["busy", "live"]]); + assert.deepEqual(JSON.parse(tabStorage.get(`kin.workspace.awaiting-review:${KEY}`)), ["c-9"], "what this page read of itself is kept for the next page"); + // Reviewed here, it no longer waits. + browser.data["/api/standing"] = { standing: { "c-9": { state: "approved" } } }; + browser.refresh(); await flush(); + assert.deepEqual(sideRepositories(browser).map((row) => [row.name, row.mark]), [["notes", "review"], ["busy", "live"], ["pallets / Fixture", ""]]); +}); + +test("opening a repository from the sidebar keeps the list where it was while the new page reads it", async () => { + // Home drew this tab's list: notes has work, Fixture waits for a review. + const hub = fixture({ hub: true }); + hub.data["/api/home"] = homePayload([listed(KEY, "Fixture", { ...open(), owner: "pallets" }), listed(NOTES, "notes", open({ working: { changed: 2, differs: true } })), listed(ARCHIVE, "archive")]); + hub.data[under(KEY, "/api/workspace/overview")] = summaryOf({ recent: [{ id: "c-9", title: "Return two", author: "Claude Code", agent: { name: "Claude Code", key: AGENT_KEY }, time: secondsAgo(60), kin_checkpoint: false }] }); + hub.data[under(KEY, "/api/standing")] = { standing: {} }; + answering(hub, hub.data["/api/home"].repositories); + await flush(); + const before = sideRepositories(hub).map((row) => [row.name, row.mark]); + assert.deepEqual(before, [["notes", "work"], ["pallets / Fixture", "review"], ["archive", ""]]); + // The repository's own page opens in the same tab, before it has read the machine's list. + const browser = fixture({ tabStorage: hub.tabStorage }); + let release; browser.held.set("/api/home", new Promise((done) => { release = done; })); + browser.data["/api/home"] = homePayload([listed(KEY, "Fixture", { ...open(), owner: "pallets" }), listed(NOTES, "notes", open({ working: { changed: 2, differs: true } })), listed(ARCHIVE, "archive")]); + browser.data["/api/session"] = undefined; + await flush(); + assert.deepEqual(sideRepositories(browser).map((row) => row.name), ["notes", "pallets / Fixture"], "the repositories the session names, in the order the reader last saw"); + release(); await flush(); + assert.deepEqual(sideRepositories(browser).map((row) => row.name), ["notes", "pallets / Fixture", "archive"]); +}); + test("a long list of repositories keeps the open one in the sidebar and leads to the rest", async () => { const browser = fixture(); browser.data["/api/home"] = homePayload([...Array.from({ length: 9 }, (_, i) => listed(repositoryKey(String(i + 1)), `service-${i + 1}`)), listed(KEY, "Fixture", open())]); @@ -2476,14 +3065,14 @@ test("a long list of repositories keeps the open one in the sidebar and leads to assert.doesNotMatch(hub.nav().textContent, /null|undefined/); }); -test("a server with no list of repositories to read shows the ones its session names, and is not asked again", async () => { +test("a server with no list of repositories to read shows the ones its session names and retries on refresh", async () => { const browser = fixture(); delete browser.data["/api/home"]; await flush(); assert.deepEqual(sideRepositories(browser).map((row) => [row.name, row.href]), [["pallets / Fixture", "#/uncommitted"], ["notes", `/repositories/${NOTES}/workspace/`]]); assert.equal(browser.node("workspace-status").dataset.state, "ready", "a list that is not there is not a failed read"); browser.refresh(); await flush(); - assert.equal(browser.count("/api/home"), 1); + assert.equal(browser.count("/api/home"), 2); const alone = fixture({ session: { repositories: [] } }); delete alone.data["/api/home"]; @@ -2577,6 +3166,22 @@ test("explore opens on the most relied-on entities and what the repository holds [["utilsPython30 entities", `#/entity/${uuid(40)}`], ["utilsRust12 entities", `#/entity/${uuid(41)}`], ["signer9 entities", `#/entity/${uuid(42)}`]]); }); +test("explore lists what is relied on and no recorded test reaches, in place of the largest containers", async () => { + const browser = fixture({ hash: "#/explore" }); + browser.data["/api/workspace/overview"] = { ...browser.data["/api/workspace/overview"], untested: { ok: true, total: 2, scope: "recorded_graph", bound: "observed", items: [ + { id: uuid(50), name: "want_bytes", kind: "function", container: { name: "encoding" }, dependents: 23 }, + { id: uuid(51), name: "Signer", kind: "class", container: { name: "signer" }, dependents: 1 }] } }; + await flush(); + // A test that reaches an entity through what it uses counts, so the heading names no test, not no direct one. + assert.match(browser.text(), /Relied on, with no tests found that reach them2/); + assert.doesNotMatch(browser.text(), /no direct test/); + assert.doesNotMatch(browser.text(), /Largest containers/); + const rows = browser.node("explore-results").querySelectorAll("a.structure-row"); + assert.deepEqual(rows.map((node) => node.getAttribute("href")), [`#/entity/${uuid(50)}`, `#/entity/${uuid(51)}`]); + assert.match(rows[0].textContent, /23 rely on it/); + assert.match(rows[1].textContent, /1 relies on it/); +}); + test("explore asks by meaning and by name and shows entity rows with kind, container and signature", async () => { const browser = fixture({ hash: "#/explore" }); await flush(); browser.navigate("explore?q=verify%20a%20signature"); await flush(); @@ -2748,9 +3353,9 @@ test("an entity page draws each part of the sectioned read and links into the re assert.equal(picture.focus.textContent, "Signer.sign"); assert.equal(picture.host.getAttribute("aria-label"), "Signer.sign, with 2 rely on it, and it relies on 1 entity"); // How far a change here reaches is said once, above the tabs; the lists behind it sit with the picture. - assert.equal(browser.node("entity-reach").textContent, "A change here might affect at least 5 entities within 3 steps. Two tests reach it.Some call sites could not be proven on this machine, so the counts are a lower bound.", "a count Kin calls a lower bound is said as one"); + assert.equal(browser.node("entity-reach").textContent, "A change here might affect at least 5 entities within 3 steps. At least two tests reach it.Some call sites could not be proven on this machine, so the counts are a lower bound.", "a count Kin calls a lower bound is said as one"); assert.equal(browser.node("entity-impact").parentNode.getAttribute("aria-label"), "What a change might affect"); - assert.equal(browser.node("entity-impact").textContent, "Further out1Serializer.dump2 steps awayTests that reach it2TestSigner.test_signTestTestSerializer.test_dumps2 steps away"); + assert.equal(browser.node("entity-impact").textContent, "Further out1Serializer.dump2 steps awayTests that reach it≥2TestSigner.test_signTestTestSerializer.test_dumps2 steps away"); assert.doesNotMatch(browser.text(), /directly/, "no second number for what relies on it"); const history = browser.links().find((node) => node.textContent.includes("Bound input")); assert.equal(history.getAttribute("href"), guided("abc123")); @@ -2760,7 +3365,7 @@ test("an entity page draws each part of the sectioned read and links into the re const exact = fixture({ hash: `#/entity/${id}` }); exact.data["/api/workspace/entity"] = () => entityV2(id, { impact: { ok: true, direct: 2, transitive: 5, tests: 0, depth: 3, top: [], bound: "exact" }, tests: { ok: true, items: [], total: 0 } }); await flush(); - assert.equal(exact.node("entity-reach").textContent, "A change here might affect 5 entities within 3 steps. Kin found no test that reaches it."); + assert.equal(exact.node("entity-reach").textContent, "A change here might affect 5 entities within 3 steps. Kin found no tests that reach it."); }); /** An entity's tabs: each one's words, whether it is the one shown, and whether the keyboard stops on it. */ @@ -2897,7 +3502,7 @@ test("an entity reads its quick parts first and draws what relies on it and its assert.deepEqual(connections(browser).reliesOn, [["want_bytes", `#/entity/${uuid(2)}`]], "what it relies on is already there"); release(); await flush(); for (const part of ["entity-history", "entity-connections"]) assert.equal(waiting(browser, part), 0); - assert.match(connections(browser).left.textContent, /^What relies on it7/, "each declaration and each test that relies on it counts once"); + assert.match(connections(browser).left.textContent, /^What relies on it≥7/, "each declaration and each test that relies on it counts once"); assert.deepEqual(connections(browser).relying, [["Serializer.__init__", `#/entity/${uuid(20)}`, "6 overloads"], ["TestSigner.test_sign", `#/entity/${uuid(11)}`, "test"]], "a caller Kin could not confirm is not drawn as relying on it"); assert.doesNotMatch(browser.text(), /more that Kin counted and did not list/, "six declarations count as six entities"); assert.match(browser.node("entity-relations").textContent, /Matched by name only1Serializer\.dumpsby name only/); @@ -2913,7 +3518,7 @@ test("a box that stands for several overloads says so, so the count beside it ad const browser = fixture({ hash: `#/entity/${id}` }); browser.data["/api/workspace/entity"] = () => overloaded(id); await flush(); - assert.match(connections(browser).left.textContent, /^What relies on it7/); + assert.match(connections(browser).left.textContent, /^What relies on it≥7/); assert.equal(connections(browser).relying.length, 2); assert.match(connections(browser).left.textContent, /6 overloads/); }); @@ -2925,7 +3530,7 @@ test("the entity header leaves out what it would repeat or what is not a sentenc browser.data["/api/workspace/entity"] = () => entityV2(id, { relies_on: { ok: true, items: [], total: 0, shown: 0 } }); await flush(); assert.equal(signature(browser).length, 0, "the source's first line already shows the signature"); - assert.match(connections(browser).right.textContent, /^What it relies on0It relies on nothing else here\.$/); + assert.match(connections(browser).right.textContent, /^What it relies on0Kin found no entities it relies on\.$/); const other = fixture({ hash: `#/entity/${id}` }); other.data["/api/workspace/entity"] = () => entityV2(id, { source: { ok: false, reason: "Kin could not read this entity's text." } }); await flush(); @@ -2976,7 +3581,7 @@ test("an entity says one number for what relies on it, the number Explore shows" assert.match(connections(browser).left.textContent, /^What relies on it21/, "the picture says the ranking's number"); assert.match(connections(browser).host.getAttribute("aria-label"), /with 21 rely on it/); assert.doesNotMatch(text, /22 rely on it|What relies on it22|22 of them directly/, "no second number for the same thing"); - assert.match(text, /A change here might affect at least 43 entities within 3 steps\. 21 tests reach it\./); + assert.match(text, /A change here might affect at least 43 entities within 3 steps\. At least 21 tests reach it\./); assert.match(text, /Builds on it1TimestampSignerextends it/, "a subclass is listed apart"); // The picture shows a few, entities other than tests first, then the rest in place. assert.equal(connections(browser).relying.length, 7); @@ -3117,15 +3722,15 @@ test("History says under each of its newest changes what that change left for th assert.deepEqual(guideReads(browser), ["change-1", "change-2"]); assert.deepEqual(consequenceLines(browser), { "Update Kin's understanding of this code for Kin 0.8.2": null, - "Preserved change": ["2 entities that rely on it were not updated. No test reaches it.", "bad"], - "Merge branch": ["1 entity relies on it and did not change. One test reaches it.", "warn"] }); + "Preserved change": ["2 entities that rely on it were not updated. Kin found no tests that reach it.", "bad"], + "Merge branch": ["1 entity relies on it and did not change. 1 test reaches it.", "warn"] }); await browser.advance(60_000); assert.deepEqual(guideReads(browser), ["change-1", "change-2"], "each is read once"); // What relies on a change and needed no update, and a change nothing relies on, are said too; a change that could not be read says nothing. const quiet = fixture({ hash: "#/history" }); quiet.data["/api/guide?change"] = (url) => url.searchParams.get("change") === "change-1" ? guide({ modified: 1, recorded_consumers: 16, consumers_need_no_update: 16, recorded_tests: 2 }) : guide({ modified: 1 }); await flush(); await quiet.advance(0); await quiet.advance(0); - assert.deepEqual([consequenceLines(quiet)["Preserved change"], consequenceLines(quiet)["Merge branch"]], [["16 entities rely on it and need no update. Two tests reach it.", "good"], ["Nothing outside it relies on what it changed. No test reaches it.", "good"]]); + assert.deepEqual([consequenceLines(quiet)["Preserved change"], consequenceLines(quiet)["Merge branch"]], [["16 entities rely on it and need no update. 2 tests reach it.", "good"], ["Kin found no connections outside this change. Kin found no tests that reach it.", "good"]]); const failing = fixture({ hash: "#/history" }); failing.failures.set("/api/guide?change", "Kin could not answer."); await flush(); await failing.advance(0); await failing.advance(0); @@ -3202,8 +3807,8 @@ test("a long history says how much of it is shown and reads older changes when a test("a history Kin is still reading says how far it has got", async () => { const lede = async (history) => { const browser = fixture({ hash: "#/history", session: history ? { history } : {} }); await flush(); return browser.node("changes-lede").textContent; }; - assert.equal(await lede({ state: "partial", admitted: 1200, total: 4000 }), "Three changes. One looks right.Kin is still reading this repository's history: 1,200 of 4,000 commits so far. Changes fill in as it goes."); - assert.equal(await lede({ state: "partial" }), "Three changes. One looks right.Kin is still reading this repository's history. Changes fill in as it goes."); + assert.equal(await lede({ state: "partial", admitted: 1200, total: 4000 }), "Three changes. One looks right.Kin has read 1,200 of 4,000 commits from this repository's history. This list includes the history available so far."); + assert.equal(await lede({ state: "partial" }), "Three changes. One looks right.This repository's imported history is incomplete. This list includes the history available so far."); assert.equal(await lede({ state: "complete", admitted: 4000, total: 4000 }), "Three changes. One looks right."); assert.equal(await lede(null), "Three changes. One looks right."); }); @@ -3405,13 +4010,19 @@ function branchConfirm(browser) { browser.node("workspace-branch-submit").click(); } /** What a branch's row offers, in order. */ -const branchActions = (browser, name) => { const row = browser.node("branches-list").querySelectorAll("li").find((node) => node.textContent.includes(name)); return [...row.querySelectorAll("a"), ...row.querySelectorAll("button")].map((node) => node.textContent); }; +const branchRow = (browser, name) => browser.node("branches-list").querySelectorAll("li").find((node) => node.querySelector(".branch-label")?.textContent === name); +/** What a branch's row offers besides opening it: its buttons. */ +const branchActions = (browser, name) => branchRow(browser, name).querySelectorAll("button").map((node) => node.textContent); +/** Where a branch's row opens, when it opens anywhere: the range of what it holds against the current branch. */ +const branchOpens = (browser, name) => branchRow(browser, name).querySelector("a.branch-open")?.getAttribute("href") ?? null; test("the branch list opens a switch or a merge only when asked", async () => { const browser = fixture({ hash: "#/branches" }); await flush(); assert.match(browser.text(), /You're on main\. One other branch: feature\./); assert.doesNotMatch(browser.text(), /You are here/); - assert.deepEqual(branchActions(browser, "feature"), ["Review", "Switch", "Merge into main"], "a branch that holds something can be reviewed, switched to or merged"); + assert.deepEqual(branchActions(browser, "feature"), ["Switch", "Merge into main"], "a branch that holds something can be switched to or merged"); + assert.equal(branchOpens(browser, "feature"), "#/review/r/main..feature", "and its row opens what it holds, against the current branch"); + assert.equal(branchOpens(browser, "main"), null, "the current branch has nothing to hold against itself"); assert.equal(browser.node("workspace-branch-target"), null); assert.equal(browser.node("workspace-merge-source"), null); assert.equal(browser.node("branches-conflicts").textContent, "", "with no merge to resolve, nothing is said of one"); @@ -3424,8 +4035,8 @@ test("each other branch says what it holds beyond the current one and opens its await flush(); await flush(); assert.match(browser.text(), /featureOne function changed\. Three other places rely on it\. None of them changed\. Two tests reach it\./); assert.match(browser.text(), /1 change ahead of main · Bound base64 input before decoding · Dana Reader, 10 minutes ago/); - const review = browser.links().find((node) => node.textContent === "Review"); - assert.equal(review.getAttribute("href"), "#/review/r/main..feature", "what it holds is reviewed like a change"); + assert.equal(branchOpens(browser, "feature"), "#/review/r/main..feature", "what it holds is reviewed like a change, from anywhere on its row"); + assert.equal(branchRow(browser, "feature").querySelector("a.branch-open").textContent, "feature"); assert.equal(browser.requests.filter((request) => /base=main/.test(request.url)).length, 1, "read once"); }); @@ -3434,15 +4045,15 @@ test("a branch with nothing beyond the current one offers no review and no merge browser.data["/api/branches"] = { branches: [{ name: "main", active: true, default: true }, { name: "feature", active: false }, { name: "level", active: false }] }; browser.data["/api/workspace/branches"] = { schema: "kin.branch-list.v1", branches: [{ name: refWire("main"), active: true }, { name: refWire("feature"), active: false }, { name: refWire("level"), active: false }] }; const ahead = browser.data["/api/guide?base"]; - browser.data["/api/guide?base"] = (url) => url.searchParams.get("change") === "level" ? guide({}, { range: { base_ref: "main", head_ref: "level", changes_total: 0, changes: [] } }) : ahead; + browser.data["/api/guide?base"] = (url) => url.searchParams.get("change") === "level" ? guide({}, { range: { base_ref: "main", head_ref: "level", changes_total: 0, base_changes_total: 0, changes: [] } }) : ahead; let release; browser.held.set("/api/guide?base", new Promise((done) => { release = done; })); await flush(); // Until Kin has said what a branch holds, nothing is withheld. - assert.deepEqual(branchActions(browser, "level"), ["Review", "Switch", "Merge into main"]); + assert.deepEqual([branchActions(browser, "level"), branchOpens(browser, "level")], [["Switch", "Merge into main"], "#/review/r/main..level"]); release(); await flush(); await flush(); - assert.deepEqual(branchActions(browser, "feature"), ["Review", "Switch", "Merge into main"]); - assert.deepEqual(branchActions(browser, "level"), ["Switch"], "there is nothing in it to review or to merge"); - assert.match(browser.node("branches-list").textContent, /levelEven with main/); + assert.deepEqual([branchActions(browser, "feature"), branchOpens(browser, "feature")], [["Switch", "Merge into main"], "#/review/r/main..feature"]); + assert.deepEqual([branchActions(browser, "level"), branchOpens(browser, "level")], [["Switch"], null], "there is nothing in it to review or to merge"); + assert.match(browser.node("branches-list").textContent, /levelNo work of its own, even with main/); assert.match(browser.text(), /You're on main\. Two other branches: feature and level\./); }); @@ -3529,11 +4140,16 @@ test("a branch confirmation cannot reuse a merge preview made before switching", test("a failure is one plain line, one action, and Kin's own words behind Show details", async () => { const browser = fixture({ hash: "#/branches" }); + // A branch Kin is only slow to read says so and is asked for again by itself. browser.failures.set("/api/guide", { status: 502, body: { error: "Kin could not answer: It did not answer within 20 seconds." } }); await flush(); - assert.match(browser.text(), /Kin is still reading this branch\./); + assert.match(browser.text(), /Still reading what [^ ]+ changed/); + // One it cannot read is a failure: one plain line, one action, and Kin's own words behind Show details. + browser.failures.set("/api/guide", { status: 500, body: { error: "Kin could not answer: the branch's record is unreadable." } }); + await browser.advance(4000); await flush(); + assert.match(browser.text(), /Kin couldn't load what this branch holds\./); assert.ok(browser.button("Try again")); - assert.match(browser.text(), /Show details.*did not answer within 20 seconds/s); + assert.match(browser.text(), /Show details.*the branch's record is unreadable/s); }); test("two branches are compared from Branches, and what was typed survives a refresh", async () => { @@ -3760,7 +4376,7 @@ test("an agent's calls read as plain sentences about the entities they were abou assert.equal(say({ tool: "kin_transaction_begin", entity_ids: [] }), "Started a change"); assert.equal(say({ tool: "semantic_locate", entity_ids: [], result_count: 0 }), "Looked something up and found no entity"); assert.equal(say({ tool: "find_references", entity_ids: [uuid(9)] }), "Checked what uses an entity"); - assert.equal(say({ tool: "get_entity_source", entity_ids: [uuid(2)], is_error: true }), "Read want_bytes, but Kin could not answer"); + assert.equal(say({ tool: "get_entity_source", entity_ids: [uuid(2)], is_error: true }), "Tried to read want_bytes; Kin could not do it", "a failed call reads as what it tried"); assert.equal(say({ tool: "some_new_tool", entity_ids: [uuid(2)] }), "Asked Kin about want_bytes"); const groups = A.group([{ tool: "get_entity_source", entity_ids: [uuid(5)] }, { tool: "get_entity_source", entity_ids: [uuid(5)] }, { tool: "find_references", entity_ids: [uuid(5)] }]); assert.deepEqual([...groups.map((entry) => entry.times)], [2, 1]); @@ -3789,8 +4405,8 @@ test("refused tool names do not become reads in an agent's summary", async () => assert.equal(A.family("get_entity"), "other", "registered reads without their own sentence keep the read fallback"); assert.equal(A.family("get_entity_source"), "source"); assert.match(browser.text(), /Five calls to Kin, 2 entities looked at, 1 change committed\./); - assert.match(browser.text(), /Asked Kin about RefusedTarget, but Kin could not answer/, "refused calls remain visible as failed calls"); - assert.match(browser.text(), /Asked Kin about UnknownTarget, but Kin could not answer/); + assert.match(browser.text(), /Tried to ask Kin about RefusedTarget; Kin could not do it/, "refused calls remain visible as failed calls"); + assert.match(browser.text(), /Tried to ask Kin about UnknownTarget; Kin could not do it/); }); /** The Agents page's lanes as a reader meets them: each one's head, where it leads, and the rows under it. */ @@ -3810,7 +4426,7 @@ test("the Agents page lists agents and shows the selected one's calls and change assert.match(text, /One agent is connected and working\./); assert.match(text, /Connected for 4 minutes · through Kin’s MCP server/); assert.match(text, /Seven calls to Kin, 4 entities looked at, 1 change committed\./); - assert.match(text, /Changing the code: 1 edit staged, not committed yet/); + assert.match(text, /Has 1 edit staged, not committed yet/, "in the words Home uses for the same open change"); assert.match(text, /The change it committed/); assert.match(text, /What it did/); assert.match(text, /Committed a change to want_bytes/); @@ -3854,17 +4470,59 @@ test("each agent is a lane: the ones here now first, each with what it is lookin assert.deepEqual(lanes[1].rows, [["Looking at", [["pad", `#/entity/${uuid(31)}`], ["Signer.sign", `#/entity/${uuid(32)}`]]], ["Last", "Read pad · just now"]], "a local inside a function is named for itself, two that read the same are one, and the first leads to its page"); assert.deepEqual(lanes[2].said, ["Connected. It has not asked Kin for anything yet."]); - // A session that is over is one line until it is opened, with no state and with when it was last heard from. + // A session that is over carries no state, says when it was last heard from, and what it read. assert.equal(lanes[3].head.length, 2); assert.match(lanes[3].head[1], /3 calls · 12 minutes ago$/); - assert.equal(lanes[3].said, null); + assert.deepEqual(lanes[3].rows, [["Read", [["want_bytes", `#/entity/${uuid(2)}`]]]]); + assert.deepEqual(lanes[3].said, []); assert.deepEqual(browser.node("agents-list").querySelectorAll("ul").map((list) => list.getAttribute("aria-label")).filter(Boolean), ["Connected now", "Earlier sessions"]); assert.deepEqual(browser.node("agents-list").querySelectorAll("h2").map((node) => node.textContent).filter((words) => words === "Earlier"), ["Earlier"]); // Opening another lane moves what the page holds open to it. browser.navigate("agents/00000000000000b4"); await flush(); await flush(); assert.deepEqual(lanesOf(browser).map((lane) => lane.open), ["false", "false", "false", "true"]); assert.ok(lanesOf(browser)[3].node.contains(browser.node("agents-detail"))); - assert.deepEqual(lanesOf(browser)[3].rows, [["Looked at", [["want_bytes", `#/entity/${uuid(2)}`]]]]); + assert.deepEqual(lanesOf(browser)[3].rows, [["Read", [["want_bytes", `#/entity/${uuid(2)}`]]]]); +}); + +test("an agent that has gone is a lane with what it read, what it edited and the change it committed, kept for the window Kin says", async () => { + const gone = "d".repeat(64); + const base = agentsPayload().agents[0]; + const browser = fixture({ hash: "#/agents/00000000000000b5" }); + browser.data["/api/workspace/agents"] = { ...agentsPayload({ agents: [], earlier: [ + { ...base, key: "00000000000000b4", state: "ended", started_at: secondsAgo(3 * 86_400), last_activity_at: secondsAgo(3 * 86_400 - 60), ended_at: secondsAgo(3 * 86_400 - 30), + calls_total: 4, recent_calls: [], open_changes: [], reads_known: true, read: [uuid(5), uuid(2)], read_total: 4, edited: [uuid(2)], edited_total: 1 }, + { ...base, key: "00000000000000b5", name: "Codex", state: "ended", through: "other", started_at: secondsAgo(4 * 86_400), last_activity_at: secondsAgo(4 * 86_400), + calls_total: 0, recent_calls: [], open_changes: [], reads_known: false, read: [], read_total: 0, edited: [uuid(3)], edited_total: 1, changes: [{ id: gone }], changes_total: 1 }] }), + kept: { days: 30, sessions: 32 } }; + browser.data["/api/workspace/agents/timeline"] = { schema: "kin.workspace.agent-timeline.v1", listed: true, kept: 0, earliest_kept_from_start: false, + agent: { ...base, key: "00000000000000b5", name: "Codex", state: "ended", reads_known: false, edited_total: 1, changes: [{ id: gone }], changes_total: 1, recent_calls: null }, calls: [], names: {} }; + await flush(); await flush(); + assert.match(browser.text(), /No agent is connected right now\. Two agents worked here in the last 30 days\./); + const [claude, codex] = lanesOf(browser); + assert.match(claude.head[1], /^[A-Z][a-z]{2} \d+.* · 4 calls · 1 change committed · 3 days ago$/, "a session from another day says its day"); + assert.deepEqual(claude.rows, [ + ["Read", [["Signer.verify_signature", `#/entity/${uuid(5)}`], ["want_bytes", `#/entity/${uuid(2)}`]]], + ["Edited", [["want_bytes", `#/entity/${uuid(2)}`]]]]); + // What it committed leads the lane, with whether it waits for review, so the lane agrees with its head. + assert.deepEqual(claude.said, ["Committed “Explain how want_bytes treats text”, waiting for your review · Review"]); + assert.deepEqual(claude.node.querySelector("p.lane-outcome").querySelectorAll("a").map((node) => [node.textContent, node.getAttribute("href")]), [["Review", guided(AGENT_CHANGE)]]); + assert.match(claude.node.textContent, /and 2 more/, "what it read past the names shown is counted"); + // One Kin knows only by what it wrote says what it read was not kept; open, it says so of its calls too. + assert.doesNotMatch(codex.head[1], /call/); + assert.equal(codex.open, "true"); + assert.deepEqual(codex.said, ["Kin kept what it wrote, not what it read."]); + assert.deepEqual(codex.rows, [["Edited", [["Signer", `#/entity/${uuid(3)}`]]]], "an open lane leaves its changes to what it did"); + const detail = browser.node("agents-detail").textContent; + assert.match(detail, /^Committed a change, waiting for your review\. 1 entity written, 1 change committed\./, "what the session came to leads its summary"); + assert.match(detail, /Kin kept the changes this agent wrote, not the calls it made\./); + assert.match(detail, /A change it committed/); + assert.match(browser.text(), /Kin keeps what each agent did here for 30 days after it leaves, the latest 32 at most\./); + // Closed, its change still opens for review. + browser.navigate("agents/00000000000000b4"); await flush(); await flush(); + assert.deepEqual(lanesOf(browser)[1].rows, [["Edited", [["Signer", `#/entity/${uuid(3)}`]]]]); + assert.deepEqual(lanesOf(browser)[1].said, ["Committed a change, waiting for your review · Review", "Kin kept what it wrote, not what it read."]); + assert.equal(lanesOf(browser)[1].node.querySelector("p.lane-outcome").querySelector("a").getAttribute("href"), guided(gone)); + assert.doesNotMatch(browser.text(), /session|mcp-agent|\/Users|\.py\b|since this page/i); }); test("a local inside a function is named for itself wherever an agent's call is told on the Agents page", async () => { @@ -3891,6 +4549,305 @@ test("a local inside a function is named for itself on Home's agent cards too", assert.deepEqual(agentCards(hub)[0].before, ["Checked what uses pad"]); }); +// A session as the evaluation met it: it read, started a change, committed it +// outside the calls Kin kept, then failed to drop a change after 27.9 seconds. +const REDIRECT_CHANGE = "e".repeat(64); +const redirectCalls = () => [ + { seq: 3, at: secondsAgo(900), tool: "kin_transaction_abort", entity_ids: [], result_count: 0, duration_ms: 27_900, is_error: true }, + { seq: 2, at: secondsAgo(1400), tool: "kin_transaction_begin", entity_ids: [], result_count: 0, duration_ms: 20, is_error: false }, + { seq: 1, at: secondsAgo(1401), tool: "get_entity_source", entity_ids: [uuid(40), uuid(41)], result_count: 2, duration_ms: 9, is_error: false }]; +const redirectNames = { [uuid(40)]: { label: "Redirect::status_code", kind: "method" }, [uuid(41)]: { label: "Redirect", kind: "struct" } }; +const redirectSession = (fields = {}) => ({ ...agentsPayload().agents[0], state: "ended", started_at: secondsAgo(1500), last_activity_at: secondsAgo(900), ended_at: secondsAgo(890), + calls_total: 3, recent_calls: redirectCalls(), open_changes: [], intents: [], reads_known: true, read: [uuid(40), uuid(41)], read_total: 2, edited: [uuid(40)], edited_total: 1, + changes: [{ id: REDIRECT_CHANGE, title: "Say what status a redirect answers with", time: secondsAgo(1260), entities_changed: 1 }], changes_total: 1, ...fields }); + +test("a session's outcome says what it committed and how its review stands, then what it has open, what it edited, or its last call", () => { + const A = fixture().agents; + const names = { ...redirectNames, [uuid(2)]: { label: "want_bytes" } }; + const say = (agent, review) => { const parts = A.outcome(agent, names, review); return parts && A.plainText(parts); }; + const session = redirectSession(); + assert.equal(say(session, () => "pending"), "Committed “Say what status a redirect answers with”, waiting for your review"); + assert.equal(say(session, () => "approved"), "Committed “Say what status a redirect answers with”, and you marked it as looking right"); + assert.equal(say(session, () => "needs_work"), "Committed “Say what status a redirect answers with”, and you flagged it"); + assert.equal(say(session, () => null), "Committed “Say what status a redirect answers with”", "a review Kin has not read is not guessed"); + assert.equal(say(session), "Committed “Say what status a redirect answers with”", "it needs nothing of the page to say what a session came to"); + assert.equal(say({ ...session, changes_total: 3 }, () => "pending"), "Committed “Say what status a redirect answers with” and 2 others, the latest waiting for your review"); + assert.equal(say({ ...session, changes: [{ id: REDIRECT_CHANGE }] }, () => "pending"), "Committed a change, waiting for your review"); + assert.equal(say({ ...session, changes: [{ id: REDIRECT_CHANGE }], changes_total: 3 }, () => "approved"), "Committed 3 changes, and you marked the latest as looking right"); + const asked = []; + A.outcome(session, names, (id) => { asked.push(id); return "pending"; }); + assert.deepEqual(asked, [REDIRECT_CHANGE], "the review asked about is the change it committed last"); + // With nothing committed: what it has open, then what it edited, then its last call. + const working = { ...session, state: "active", changes: [], changes_total: 0 }; + assert.equal(say({ ...working, open_changes: [{ staged_edits: 2, age_secs: 30 }] }), "Has 2 edits staged, not committed yet"); + assert.equal(say({ ...working, open_changes: [{ staged_edits: 0 }] }), "Started a change, nothing staged yet"); + assert.deepEqual(JSON.parse(JSON.stringify(A.outcome({ ...working, open_changes: [] }, names))), ["Edited ", { id: uuid(40), label: "Redirect::status_code" }], "an entity it edited is a part the page links"); + assert.equal(say({ ...working, edited: [uuid(40), uuid(2)], edited_total: 2 }), "Edited Redirect::status_code and want_bytes"); + assert.equal(say({ ...working, edited: [uuid(40)], edited_total: 4 }), "Edited Redirect::status_code and 3 other entities"); + assert.equal(say({ ...working, edited: [uuid(99)], edited_total: 1 }), "Edited 1 entity"); + assert.equal(say({ ...working, edited: [], edited_total: 0 }), "Tried to drop the change it had started; Kin did not answer within 28 s"); + assert.equal(say({ ...working, edited: [], edited_total: 0, recent_calls: redirectCalls().slice(2) }), "Read Redirect::status_code"); + // A session with nothing Kin kept, and records missing their fields, say nothing rather than something wrong. + assert.equal(A.outcome({ name: "Lantern", state: "idle" }, names), null); + assert.equal(A.outcome(null), null); + assert.equal(say({ recent_calls: [{}] }), "Asked Kin a question"); + assert.equal(say({ changes: [{ title: "No id" }], changes_total: "many", open_changes: [null], edited: ["not-an-id"], recent_calls: [] }), null); +}); + +test("a failed call reads as what it tried and why, and the timeline says whether its change is still open", async () => { + const A = fixture().agents; + const names = { [uuid(5)]: { label: "Signer.verify_signature" }, [uuid(2)]: { label: "want_bytes" } }; + const failed = (tool, ids = [], duration_ms = 40) => A.plainText(A.describe({ tool, entity_ids: ids.map(uuid), result_count: 0, duration_ms, is_error: true }, names)); + assert.equal(failed("kin_transaction_abort", [], 27_900), "Tried to drop the change it had started; Kin did not answer within 28 s"); + assert.equal(failed("kin_transaction_abort"), "Tried to drop the change it had started; Kin could not do it"); + assert.equal(failed("kin_transaction_begin"), "Tried to start a change; Kin could not do it"); + assert.equal(failed("kin_transaction_stage", [2]), "Tried to stage an edit to want_bytes; Kin could not do it"); + assert.equal(failed("kin_transaction_commit", [2, 5], 12_400), "Tried to commit a change to want_bytes; Kin did not answer within 12 s"); + assert.equal(failed("kin_mutate", [2]), "Tried to change want_bytes; Kin could not do it"); + assert.equal(failed("mutate"), "Tried to change the code; Kin could not do it"); + assert.equal(failed("get_entity_source", [2]), "Tried to read want_bytes; Kin could not do it"); + assert.equal(failed("semantic_locate", [5]), "Tried to look up Signer.verify_signature; Kin could not do it"); + assert.equal(failed("semantic_locate"), "Tried to look something up; Kin could not do it"); + assert.equal(failed("find_references", [5]), "Tried to check what uses Signer.verify_signature; Kin could not do it"); + assert.equal(failed("impact_analysis", [2]), "Tried to check what a change to want_bytes would touch; Kin could not do it"); + assert.equal(failed("get_context_pack", [2]), "Tried to read the context around want_bytes; Kin could not do it"); + assert.equal(failed("graph_neighborhood", [2]), "Tried to look at what want_bytes connects to; Kin could not do it"); + assert.equal(failed("trace_path", [5, 2]), "Tried to trace how Signer.verify_signature reaches want_bytes; Kin could not do it"); + assert.equal(failed("kin_provenance_query", [2]), "Tried to read the history of want_bytes; Kin could not do it"); + assert.equal(failed("kin_graph_status"), "Tried to check the repository's status; Kin could not do it"); + assert.equal(failed("unrecognized_tool", [2]), "Tried to ask Kin about want_bytes; Kin could not do it"); + assert.equal(A.plainText(A.describe({ tool: "kin_transaction_abort", is_error: true })), "Tried to drop the change it had started; Kin could not do it", "a call without its timing says only that Kin could not do it"); + + // The session the evaluation opened: its summary leads with the change waiting for review, and the failed drop says what it tried. + const browser = fixture({ hash: `#/agents/${AGENT_KEY}` }); + const session = redirectSession(); + browser.data["/api/workspace/agents"] = { ...agentsPayload({ agents: [], earlier: [session] }), names: redirectNames, kept: { days: 30, sessions: 32 } }; + browser.data["/api/workspace/agents/timeline"] = { schema: "kin.workspace.agent-timeline.v1", listed: true, kept: 3, earliest_kept_from_start: true, agent: { ...session, recent_calls: null }, calls: redirectCalls(), names: redirectNames }; + await flush(); await flush(); await flush(); + const detail = browser.node("agents-detail"); + assert.match(detail.textContent, /^Committed “Say what status a redirect answers with”, waiting for your review\. Three calls to Kin, 2 entities looked at, 1 change committed\./); + const steps = detail.querySelectorAll("li.agent-step"); + assert.deepEqual(steps.map((step) => step.querySelector("p").textContent), ["Tried to drop the change it had started; Kin did not answer within 28 s", "Started a change", "Read Redirect::status_code"]); + assert.ok(steps[0].className.split(" ").includes("agent-step-error"), "a failed call keeps its error colour"); + // The change it started was committed before the drop was tried, so that is what is said under it. + assert.equal(steps[0].querySelector("p.agent-step-open").textContent, `It had already committed the change it started, “Say what status a redirect answers with”, at ${new Date(Date.parse(secondsAgo(1260))).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" })}.`); + assert.equal(detail.querySelectorAll("p.agent-step-open").length, 1); + assert.doesNotMatch(detail.textContent, /could not answer|Dropped a change/); + assert.deepEqual(faults(shownWords(browser, "Agents, a failed drop"), NEVER_SAID), []); + // With no change committed since it started one, a session that has ended no longer reports what it holds. + const uncommitted = fixture({ hash: `#/agents/${AGENT_KEY}` }); + const left = redirectSession({ changes: [], changes_total: 0 }); + uncommitted.data["/api/workspace/agents"] = { ...agentsPayload({ agents: [], earlier: [left] }), names: redirectNames, kept: { days: 30, sessions: 32 } }; + uncommitted.data["/api/workspace/agents/timeline"] = { schema: "kin.workspace.agent-timeline.v1", listed: true, kept: 3, earliest_kept_from_start: true, agent: { ...left, recent_calls: null }, calls: redirectCalls(), names: redirectNames }; + await flush(); await flush(); await flush(); + assert.equal(uncommitted.node("agents-detail").querySelector("p.agent-step-open").textContent, "Kin cannot say now whether that change is still open."); + + // A connected agent says what it holds now, and only under the newest failed attempt at a change. + const timelineOf = (agent, calls) => ({ schema: "kin.workspace.agent-timeline.v1", listed: true, kept: calls.length, earliest_kept_from_start: true, agent: { ...agent, recent_calls: null }, calls, names: redirectNames }); + const openLines = async (open_changes) => { + const live = fixture({ hash: `#/agents/${AGENT_KEY}` }); + const agent = { ...redirectSession(), state: "idle", open_changes, changes: [], changes_total: 0 }; + const calls = [redirectCalls()[0], { seq: 2, at: secondsAgo(1000), tool: "kin_transaction_stage", entity_ids: [uuid(40)], duration_ms: 50, is_error: true }, ...redirectCalls().slice(1)]; + live.data["/api/workspace/agents"] = { ...agentsPayload({ agents: [agent] }), names: redirectNames }; + live.data["/api/workspace/agents/timeline"] = timelineOf(agent, calls); + await flush(); await flush(); await flush(); + const rows = live.node("agents-detail").querySelectorAll("li.agent-step"); + assert.equal(rows[1].querySelector("p").textContent, "Tried to stage an edit to Redirect::status_code; Kin could not do it"); + return rows.map((row) => row.querySelector("p.agent-step-open")?.textContent ?? null); + }; + assert.deepEqual(await openLines([{ staged_edits: 2, age_secs: 40 }]), ["Its change is still open, with 2 edits staged.", null, null, null]); + assert.deepEqual(await openLines([{ staged_edits: 0, age_secs: 40 }]), ["Its change is still open, with nothing staged.", null, null, null]); + assert.deepEqual(await openLines([]), ["No change of its is open now.", null, null, null]); + assert.deepEqual(await openLines(undefined), [null, null, null, null], "when Kin does not say what it holds, nothing is claimed"); +}); + +test("a closed lane says first what its session came to, and agrees with its head", async () => { + const browser = fixture({ hash: "#/agents/00000000000000b2" }); + const lantern = { ...agentsPayload().agents[0], key: "00000000000000b2", name: "Lantern", state: "idle", calls_total: 1, changes: [], changes_total: 0, open_changes: [], recent_calls: [redirectCalls()[0]] }; + browser.data["/api/workspace/agents"] = { ...agentsPayload({ agents: [agentsPayload().agents[0], lantern], earlier: [redirectSession({ key: "00000000000000b4" })] }), names: { ...agentsPayload().names, ...redirectNames } }; + browser.data["/api/workspace/agents/timeline"] = { schema: "kin.workspace.agent-timeline.v1", listed: true, kept: 1, earliest_kept_from_start: true, agent: { ...lantern, recent_calls: null }, calls: lantern.recent_calls, names: redirectNames }; + await flush(); await flush(); await flush(); + const [claude, open, gone] = lanesOf(browser); + assert.match(claude.head[1], / · 1 change committed$/); + assert.deepEqual(claude.said, ["Committed “Explain how want_bytes treats text”, waiting for your review · Review"], "the change its head counts leads the lane"); + assert.equal(claude.node.querySelector("p.agent-open").textContent, "Has 1 edit staged, not committed yet · open for a few seconds", "what it has open since says so in the outcome's words"); + assert.equal(claude.rows.at(-1)[0], "Last"); + assert.equal(open.open, "true"); + assert.match(browser.node("agents-detail").textContent, /^Tried to drop the change it had started; Kin did not answer within 28 s\. One call to Kin/, "with nothing committed, its last call is what it came to"); + assert.deepEqual(gone.said, ["Committed “Say what status a redirect answers with”, waiting for your review · Review"]); + assert.deepEqual(gone.rows.map(([label]) => label), ["Read", "Edited"]); + assert.match(gone.head[1], /3 calls · 1 change committed/); + // A decision on the change reaches the lanes as soon as review decisions are read again, with nothing else changed. + browser.data["/api/standing"] = { standing: { [AGENT_CHANGE]: { state: "approved" }, [REDIRECT_CHANGE]: { state: "needswork" } } }; + const agentsReads = browser.count("/api/workspace/agents"); + browser.refresh(); await flush(); await flush(); + assert.equal(browser.count("/api/workspace/agents"), agentsReads + 1); + assert.deepEqual([lanesOf(browser)[0].said, lanesOf(browser)[2].said], [ + ["Committed “Explain how want_bytes treats text”, and you marked it as looking right · Review"], + ["Committed “Say what status a redirect answers with”, and you flagged it · Review"]]); + // Without review decisions, a commit is said without its review. + const unread = fixture({ hash: "#/agents/00000000000000b2" }); + unread.failures.set("/api/standing", "Review decisions unavailable"); + unread.data["/api/workspace/agents"] = browser.data["/api/workspace/agents"]; + unread.data["/api/workspace/agents/timeline"] = browser.data["/api/workspace/agents/timeline"]; + await flush(); await flush(); await flush(); + assert.equal(lanesOf(unread)[0].said[0], "Committed “Explain how want_bytes treats text” · Review"); +}); + +test("a commit call that names its change leads to its review, and calls with fields missing still read plainly", async () => { + const browser = fixture({ hash: `#/agents/${AGENT_KEY}` }); + const agent = agentsPayload().agents[0]; + browser.data["/api/workspace/agents/timeline"] = { schema: "kin.workspace.agent-timeline.v1", listed: true, kept: 5, earliest_kept_from_start: true, agent: { ...agent, recent_calls: null }, names: agentsPayload().names, + calls: [ + { seq: 5, at: secondsAgo(5), tool: "kin_transaction_commit", entity_ids: [uuid(2), uuid(3)], result_count: 2, duration_ms: 900, is_error: false, change_id: AGENT_CHANGE }, + { seq: 4, at: secondsAgo(6), tool: "kin_transaction_commit", entity_ids: [uuid(2)], result_count: 1, duration_ms: 900, is_error: false, change_id: "f".repeat(64) }, + { seq: 3, at: secondsAgo(7), tool: "kin_transaction_stage", entity_ids: [uuid(2)], is_error: false }, + { seq: 2 }, + { seq: 1, tool: "kin_mutate", entity_ids: "not a list", duration_ms: "slow", is_error: true }] }; + await flush(); await flush(); await flush(); + const steps = browser.node("agents-detail").querySelectorAll("li.agent-step"); + assert.deepEqual(steps.map((step) => step.querySelector("p").textContent), ["Committed a change to want_bytes", "Committed a change to want_bytes", "Staged an edit to want_bytes", "Asked Kin a question", "Tried to change the code; Kin could not do it"]); + assert.deepEqual(steps[0].querySelectorAll("a.more-link").map((node) => [node.textContent, node.getAttribute("href")]), [["Review “Explain how want_bytes treats text”", guided(AGENT_CHANGE)]]); + assert.deepEqual(steps[1].querySelectorAll("a.more-link"), [], "a change that is not one of its own is not linked"); + assert.equal(steps[4].querySelector("p.agent-step-open").textContent, "Its change is still open, with 1 edit staged."); + assert.deepEqual(faults(shownWords(browser, "Agents, calls with fields missing"), NEVER_SAID), []); +}); + +test("an edit or commit that finished after its agent stopped waiting says so, and a mutate that committed leads to its review", async () => { + const browser = fixture({ hash: `#/agents/${AGENT_KEY}` }); + const agent = agentsPayload().agents[0]; + browser.data["/api/workspace/agents/timeline"] = { schema: "kin.workspace.agent-timeline.v1", listed: true, kept: 3, earliest_kept_from_start: true, agent: { ...agent, recent_calls: null }, names: agentsPayload().names, + calls: [ + { seq: 3, at: secondsAgo(5), tool: "kin_mutate", entity_ids: [uuid(2)], result_count: 1, duration_ms: 41_000, is_error: false, change_id: AGENT_CHANGE, caller_left: true }, + { seq: 2, at: secondsAgo(60), tool: "kin_transaction_commit", entity_ids: [uuid(2)], result_count: 1, duration_ms: 31_000, is_error: true, caller_left: true }, + { seq: 1, at: secondsAgo(90), tool: "kin_transaction_begin", entity_ids: [], result_count: 0, duration_ms: 20, is_error: false }] }; + await flush(); await flush(); await flush(); + const steps = browser.node("agents-detail").querySelectorAll("li.agent-step"); + assert.deepEqual(steps.map((step) => step.querySelector("p").textContent), [ + "Changed want_bytes, after it had stopped waiting for the answer, so it was not told", + "Tried to commit a change to want_bytes; Kin did not answer within 31 s", + "Started a change"]); + assert.deepEqual(steps[0].querySelectorAll("a.more-link").map((node) => [node.textContent, node.getAttribute("href")]), [["Review “Explain how want_bytes treats text”", guided(AGENT_CHANGE)]]); +}); + +test("a drop that failed after the change it started was committed says the change was committed", async () => { + const browser = fixture({ hash: `#/agents/${AGENT_KEY}` }); + const agent = { ...agentsPayload().agents[0], state: "ended", open_changes: [], changes: [{ id: AGENT_CHANGE, title: "Say what status a redirect answers with", time: secondsAgo(500), entities_changed: 1 }], changes_total: 1 }; + browser.data["/api/workspace/agents"] = agentsPayload({ agents: [], earlier: [{ ...agent, key: AGENT_KEY }] }); + browser.data["/api/workspace/agents/timeline"] = { schema: "kin.workspace.agent-timeline.v1", listed: true, kept: 3, earliest_kept_from_start: true, agent: { ...agent, recent_calls: null }, names: agentsPayload().names, + calls: [ + { seq: 3, at: secondsAgo(30), tool: "kin_transaction_abort", entity_ids: [], result_count: 0, duration_ms: 27_900, is_error: true }, + { seq: 2, at: secondsAgo(560), tool: "kin_transaction_begin", entity_ids: [], result_count: 0, duration_ms: 20, is_error: false }, + { seq: 1, at: secondsAgo(561), tool: "get_entity_source", entity_ids: [uuid(5)], result_count: 1, duration_ms: 8, is_error: false }] }; + await flush(); await flush(); await flush(); + const steps = browser.node("agents-detail").querySelectorAll("li.agent-step"); + assert.equal(steps[0].querySelector("p").textContent, "Tried to drop the change it had started; Kin did not answer within 28 s"); + assert.equal(steps[0].querySelector("p.agent-step-open").textContent, `It had already committed the change it started, “Say what status a redirect answers with”, at ${new Date(Date.parse(secondsAgo(500))).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" })}.`); +}); + +test("the Agents lede counts agents and the sessions they ran, not sessions as agents", async () => { + const A = fixture().agents; + const session = (name, n, state = "ended") => ({ ...agentsPayload().agents[0], key: `00000000000000${String(n).padStart(2, "0")}`, name, state }); + const kept = { days: 30, sessions: 32 }; + const lede = (agents, earlier, window = kept) => A.headline({ agents, earlier, kept: window }); + assert.equal(lede([], [session("Claude Code", 1), session("Claude Code", 2), session("claude code", 3)]), "No agent is connected right now. Claude Code worked here in three sessions in the last 30 days."); + assert.equal(lede([], [1, 2, 3].map((n) => session("Claude Code", n)).concat([4, 5].map((n) => session("Codex", n)))), "No agent is connected right now. Two agents worked here in five sessions in the last 30 days."); + assert.equal(lede([], [session("Claude Code", 1), session("Codex", 2)]), "No agent is connected right now. Two agents worked here in the last 30 days."); + assert.equal(lede([], [session("Claude Code", 1)], null), "No agent is connected right now. Claude Code worked here earlier."); + assert.equal(lede([], Array.from({ length: 12 }, (_, n) => session("Claude Code", n))), "No agent is connected right now. Claude Code worked here in at least 12 sessions in the last 30 days.", "a full list is the most Kin sends, so its count is a lower bound"); + assert.equal(lede([session("Claude Code", 1, "active"), session("Claude Code", 2, "idle")], []), "Claude Code is connected in two sessions. One is working now."); + assert.equal(lede([session("Claude Code", 1, "active"), session("Claude Code", 2, "idle"), session("Codex", 3, "idle")], []), "Two agents are connected in three sessions. One is working now."); + assert.equal(lede([session("Claude Code", 1, "active"), session("Codex", 2, "active")], []), "Two agents are connected. Both are working."); + // On the page, as the evaluation met it. + const browser = fixture({ hash: "#/agents" }); + browser.data["/api/workspace/agents"] = { ...agentsPayload({ agents: [], earlier: [1, 2, 3].map((n) => redirectSession({ key: `00000000000000c${n}` })) }), names: redirectNames, kept }; + browser.data["/api/workspace/agents/timeline"] = { schema: "kin.workspace.agent-timeline.v1", listed: true, kept: 3, earliest_kept_from_start: true, agent: { ...redirectSession(), recent_calls: null }, calls: redirectCalls(), names: redirectNames }; + await flush(); await flush(); await flush(); + assert.match(browser.node("agents-lede").textContent, /^No agent is connected right now\. Claude Code worked here in three sessions in the last 30 days\./); +}); + +test("History says how far Kin looked when it found no test that reaches a change", async () => { + const browser = fixture({ hash: "#/history" }); + const left = { "change-1": { recorded_consumers: 3, consumers_not_updated: 2, recorded_tests: 0, tests_depth: 3 }, "change-2": { recorded_consumers: 1, consumers_may_need_review: 1, recorded_tests: 0 } }; + browser.data["/api/guide?change"] = (url) => guide({ modified: 1, ...left[url.searchParams.get("change")] }, { change: { id: url.searchParams.get("change") } }); + await flush(); await browser.advance(0); await browser.advance(0); await browser.advance(0); + assert.deepEqual([consequenceLines(browser)["Preserved change"], consequenceLines(browser)["Merge branch"]], [ + ["2 entities that rely on it were not updated. No test found within 3 steps of it.", "bad"], + ["1 entity relies on it and did not change. Kin found no tests that reach it.", "warn"]], "the depth Kin looked to is said when its guide gives it"); +}); + +test("an agent's calls are read with patience, and a read that only took too long is asked again while the last answer stays", async () => { + const path = "/api/workspace/agents/timeline"; + // A first read that takes more than a minute is waited for, then asked again on its own. + const slow = fixture({ hash: `#/agents/${AGENT_KEY}` }); + slow.held.set(path, new Promise(() => {})); + await flush(); await flush(); + await slow.advance(90_000); + assert.equal(slow.count(path), 1); + assert.doesNotMatch(slow.node("agents-detail").textContent, /couldn't load|still reading/, "a read is given more than the page's usual minute"); + await slow.advance(91_000); + assert.match(slow.node("agents-detail").textContent, /^Kin is still reading what this agent did\./); + assert.equal(slow.node("agents-detail").querySelector("div.agent-detail").getAttribute("aria-busy"), "true", "it says so beside the outline of what is coming"); + slow.held.delete(path); + await slow.advance(3_000); + assert.equal(slow.count(path), 2); + assert.match(slow.node("agents-detail").textContent, /Committed a change to want_bytes/); + assert.doesNotMatch(slow.node("agents-detail").textContent, /still reading/); + + // A read again that takes too long keeps what was shown, says Kin is still reading, and asks again further apart. + FakeEventSource.opened.length = 0; + const browser = fixture({ hash: `#/agents/${AGENT_KEY}`, live: true }); + await flush(); await flush(); await flush(); + const first = browser.count(path); + browser.failures.set(path, "Kin did not answer within 20 seconds."); + FakeEventSource.opened.at(-1).emit("agent", { agent: AGENT_KEY }); + await browser.advance(700); + assert.equal(browser.count(path), first + 1); + const detail = () => browser.node("agents-detail").textContent; + assert.match(detail(), /Kin is still reading what this agent did\./); + assert.match(detail(), /Committed a change to want_bytes/, "the last answer stays while Kin reads again"); + assert.doesNotMatch(detail(), /couldn't load|Showing what Kin last saw/); + await browser.advance(2_900); + assert.equal(browser.count(path), first + 1, "it waits before asking again"); + await browser.advance(100); + assert.equal(browser.count(path), first + 2); + await browser.advance(8_000); + assert.equal(browser.count(path), first + 3); + // Once Kin answers, the newer answer replaces the old one. + browser.failures.delete(path); + const answer = browser.data[path](); + browser.data[path] = { ...answer, calls: [{ seq: 8, at: secondsAgo(1), tool: "kin_transaction_abort", entity_ids: [], duration_ms: 30, is_error: false }, ...answer.calls] }; + await browser.advance(20_000); + assert.equal(browser.count(path), first + 4); + assert.match(detail(), /Dropped a change it had started/); + assert.doesNotMatch(detail(), /still reading/); + + // Kin that stays too slow is said to have failed, with the last answer kept; a read that failed for another reason is not asked again. + browser.failures.set(path, "This read took longer than 3 minutes."); + FakeEventSource.opened.at(-1).emit("agent", { agent: AGENT_KEY }); + await browser.advance(700 + 3_000 + 8_000 + 20_000); + assert.equal(browser.count(path), first + 8); + assert.match(detail(), /Showing what Kin last saw\./); + assert.match(detail(), /Dropped a change it had started/); + await browser.advance(60_000); + assert.equal(browser.count(path), first + 8, "it stops asking after a few tries"); + const broken = fixture({ hash: `#/agents/${AGENT_KEY}` }); + broken.failures.set(path, "Daemon unavailable"); + await flush(); await flush(); await broken.advance(30_000); + assert.equal(broken.count(path), 1); + assert.match(broken.node("agents-detail").textContent, /Kin couldn't load what this agent did\./); + + // Moving to another agent stops asking about the one left. + const moved = fixture({ hash: `#/agents/${AGENT_KEY}` }); + moved.failures.set(path, "Kin did not answer within 20 seconds."); + await flush(); await flush(); + const asked = () => moved.requests.filter((request) => request.name === path && request.url.includes(`agent=${AGENT_KEY}`)).length; + assert.equal(asked(), 1); + moved.navigate("history"); await flush(); + await moved.advance(60_000); + assert.equal(asked(), 1, "nobody is looking at the agent, so its calls are not read again"); +}); + test("the Agents page says first where an agent's work meets uncommitted work", async () => { const call = (seq, ago, tool, ids) => ({ seq, at: secondsAgo(ago), tool, entity_ids: ids.map(uuid), result_count: 1, duration_ms: 5, is_error: false }); const browser = fixture({ hash: "#/agents" }); @@ -3907,10 +4864,11 @@ test("the Agents page says first where an agent's work meets uncommitted work", test("with no agent connected, the Agents page says how agents connect and names no command to run", async () => { const browser = fixture({ hash: "#/agents" }); - browser.data["/api/workspace/agents"] = agentsPayload({ agents: [] }); + browser.data["/api/workspace/agents"] = { ...agentsPayload({ agents: [] }), kept: { days: 30, sessions: 32 } }; await flush(); await flush(); assert.match(browser.text(), /No agent is connected right now\./); - assert.match(browser.text(), /No agent has worked here since this page started\./); + assert.match(browser.text(), /No agent has worked here in the last 30 days\./, "the window Kin keeps, not the life of this page"); + assert.match(browser.text(), /Kin keeps what each agent did here for 30 days after it leaves, the latest 32 at most\./); assert.match(browser.text(), /work here through Kin once they are connected\. Connect an agent\./); assert.equal(browser.links().find((node) => node.textContent === "Connect an agent").getAttribute("href"), "/#/agents", "connecting is done at the hub"); assert.doesNotMatch(browser.text(), /kin setup|terminal/i); @@ -3920,12 +4878,21 @@ test("with no agent connected, the Agents page says how agents connect and names browser.data["/api/workspace/agents"] = agentsPayload({ agents: [], earlier: [{ ...agentsPayload().agents[0], state: "ended", last_activity_at: secondsAgo(720), open_changes: [] }] }); browser.data["/api/workspace/agents/timeline"] = { schema: "kin.workspace.agent-timeline.v1", listed: true, kept: 0, earliest_kept_from_start: true, agent: { ...agentsPayload().agents[0], state: "ended", last_activity_at: secondsAgo(720), open_changes: [], recent_calls: null }, calls: [], names: {} }; browser.refresh(); await flush(); await flush(); - assert.match(browser.text(), /No agent is connected right now\. One worked here earlier\./); + assert.match(browser.text(), /No agent is connected right now\. Claude Code worked here earlier\./); assert.match(browser.text(), /Last active 12 minutes ago/); const ended = browser.links().find((node) => node.getAttribute("href") === `#/agents/${AGENT_KEY}`); - assert.match(ended.textContent, /^Claude Codesince /); + assert.match(ended.textContent, /^Claude Code\d{1,2}[:.]\d{2}/, "a session that is over says when it started"); assert.doesNotMatch(ended.textContent, /Disconnected|Active|Idle/, "a session that is over carries no state in the list"); - assert.deepEqual(lanesOf(browser)[0].rows.map(([label]) => label), ["Looked at"], "what it looked at, in the past"); + assert.deepEqual(lanesOf(browser)[0].rows.map(([label]) => label), ["Read"], "what it read; open, its changes are in what it did"); + + // A Kin that keeps nothing past a session's end, and one whose record could not be read, say so. + browser.data["/api/workspace/agents"] = agentsPayload({ agents: [] }); + browser.refresh(); await flush(); await flush(); + assert.match(browser.text(), /No agent has worked here that Kin has a record of\./); + browser.data["/api/workspace/agents"] = { ...agentsPayload({ agents: [] }), earlier_unread: true }; + browser.refresh(); await flush(); await flush(); + assert.match(browser.text(), /Kin couldn't read which agents worked here before\./); + assert.doesNotMatch(browser.text(), /No agent has worked here/); }); test("the agents read fails on its own and the rest of the page keeps working", async () => { @@ -3944,7 +4911,7 @@ test("the agents read fails on its own and the rest of the page keeps working", assert.equal(browser.node("workspace-status").dataset.state, "ready"); }); -test("a server that does not report agents says so, and is neither asked again nor followed live", async () => { +test("a server that does not report agents says so and retries only on explicit refresh without a live feed", async () => { FakeEventSource.opened.length = 0; const browser = fixture({ hash: "#/agents", live: true }); delete browser.data["/api/workspace/agents"]; @@ -3952,7 +4919,7 @@ test("a server that does not report agents says so, and is neither asked again n assert.match(browser.text(), /This version of Kin does not report agent activity\./); assert.equal(browser.node("workspace-status").dataset.state, "ready", "a read the server does not have is not a failed read"); browser.refresh(); await flush(); - assert.equal(browser.count("/api/workspace/agents"), 1); + assert.equal(browser.count("/api/workspace/agents"), 2); assert.equal(FakeEventSource.opened.length, 0); browser.navigate("uncommitted"); assert.equal(browser.node("work-overlaps").textContent, ""); @@ -4087,9 +5054,11 @@ function writtenWords() { for (const [name, code] of [["workspace.js", source], ["agents.js", agentsSource], ["graph.js", graphSource]]) { for (const { value, line, classes } of literals(code)) if (!classes) words.push({ where: `${name}:${line}`, text: value }); } - const frameBody = frameSource.slice(frameSource.indexOf("]*>[\s\S]*?<\/script>/g, ""); + const frameBody = frameSource.slice(frameSource.indexOf("]*>/g, "\n").split("\n")) if (value.trim()) words.push({ where: "index.html", text: value.trim() }); + for (const [, tag, value] of frameBody.matchAll(/<(\/?[a-z0-9-]+)[^>]*>([^<]*)/gi)) { + if (tag.toLowerCase() !== "script" && value.trim()) words.push({ where: "index.html", text: value.trim() }); + } return words; } @@ -4199,9 +5168,9 @@ async function tour() { await mark(browser, "flag", "fine", "fine", "fine"); see(browser, "a change, finished"); browser.type("review-comment", "The callers were left behind."); browser.data["/api/decide"] = { __status: 502, error: "Kin could not reach this repository." }; - await press(browser, "Flagged", "a change, a view that was not saved"); + await press(browser, "Flag it", "a change, a view that was not saved"); browser.data["/api/decide"] = () => { browser.data["/api/standing"] = { standing: { "change-2": { state: "needs_work" } } }; return { reviews: [] }; }; - browser.button("Flagged").click(); see(browser, "a change, saving a view"); await settled(browser, "a change, a view saved"); + browser.button("Flag it").click(); see(browser, "a change, saving a view"); await settled(browser, "a change, a view saved"); await settled(await reviewingChange("#/review/c/change-1/done"), "a change, a view already given"); browser = fixture({ hash: "#/review/c/large" }); browser.data["/api/guide?change"] = (url) => recorded(url.searchParams.get("change"), { added: 20_000 }, [], { not_walked: { entities: 20_000 } }); await settled(browser, "a change too large to walk"); browser = fixture({ hash: "#/review/c/empty" }); browser.data["/api/guide?change"] = (url) => recorded(url.searchParams.get("change"), {}, []); await settled(browser, "a change with no entity"); @@ -4439,3 +5408,868 @@ test("the page never says, as a word alone, the word it retired for a reading in assert.deepEqual(writtenWords().filter(({ text }) => /^Reading$/.test(text)).map(({ where }) => where), []); assert.deepEqual((await touredOnce()).filter(({ text }) => /^Reading$/.test(text)).map(({ where }) => where), []); }); + +const exactChanged = (n, name, fields = {}) => changed(n, name, { + after: { state: "recorded", text: `def ${name}():\n return 1\n`, working: true, body_hash: "a".repeat(64) }, ...fields, +}); + +test("entity undo pins the exact source, refuses a missing guard and preserves its pending receipt on reload", async () => { + const tabStorage = new Map(); + const browser = fixture({ hash: "#/review/w/1", capabilities: { entity_restore: true }, tabStorage }); + browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(2, "want_bytes")]); + browser.data["POST /api/workspace/entity/restore"] = { __status: 409, error: "The entity changed after this reading.", body: { outcome: "refused", remedy: "Refresh this entity and review its latest source." } }; + await flush(); browser.node("review-undo").click(); browser.node("review-undo-confirm").click(); await flush(); + const sent = browser.requests.filter((row) => row.method === "POST"); + assert.equal(sent.length, 1); + assert.deepEqual(JSON.parse(sent[0].options.body), { entity: uuid(2), operation_id: uuid(1), expected: `source:${"a".repeat(64)}` }); + assert.match(browser.text(), /The entity changed after this reading\./, "a refusal says Kin's own sentence"); + assert.ok(browser.node("review-undo-reread"), "a refusal offers a fresh reading"); + assert.equal([...tabStorage.keys()].filter((key) => key.includes("pending-restore")).length, 0, "a refusal removes the pending receipt"); + browser.failures.set("POST /api/workspace/entity/restore", new Error("The answer was lost")); + browser.node("review-undo-confirm").click(); await flush(); + assert.match(browser.text(), /The undo may have happened/); + assert.equal(browser.button("I've checked").disabled, true, "an old read cannot settle uncertainty"); + const reload = fixture({ hash: "#/review/w/1", capabilities: { entity_restore: true }, tabStorage }); + reload.data["/api/guide"] = browser.data["/api/guide"]; await flush(); + assert.match(reload.text(), /The undo may have happened/); + assert.equal(reload.requests.filter((row) => row.method === "POST").length, 0, "reload never replays the undo"); + const missing = fixture({ hash: "#/review/w/1", capabilities: { entity_restore: true } }); + await flush(); assert.equal(missing.node("review-undo").disabled, true); + missing.node("review-undo").click(); assert.equal(missing.requests.some((row) => row.method === "POST"), false); +}); + +test("a refused undo reads the work again and undoes from that reading, never resending the old one", async () => { + const browser = fixture({ hash: "#/review/w/1", capabilities: { entity_restore: true } }); + browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(2, "want_bytes")]); + browser.data["POST /api/workspace/entity/restore"] = { __status: 409, error: "want_bytes changed since it was read, so nothing was restored.", body: { outcome: "refused", remedy: "Nothing was changed. Resolve what is reported, then restore it again." } }; + await flush(); browser.node("review-undo").click(); browser.node("review-undo-confirm").click(); await flush(); + assert.match(browser.text(), /want_bytes changed since it was read, so nothing was restored\./); + assert.doesNotMatch(browser.text(), /\bfiles?\b/i, "a refusal speaks of the entity"); + assert.doesNotMatch(browser.text(), /The undo may have happened/, "a refusal is settled, not uncertain"); + const reads = browser.count("/api/guide"); + browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(2, "want_bytes", { after: { state: "recorded", text: "def want_bytes():\n # A newer comment\n return 1\n", working: true, body_hash: "b".repeat(64) } })]); + browser.node("review-undo-reread").click(); await flush(); + assert.equal(browser.count("/api/guide"), reads + 1, "the work is read again"); + assert.equal(browser.node("review-undo-confirm"), null, "the refused confirmation closes with its reading"); + assert.match(browser.text(), /A newer comment/); + assert.equal(browser.count("/api/workspace/entity/restore"), 1, "the refused undo is never resent"); + browser.data["POST /api/workspace/entity/restore"] = () => { browser.data["/api/guide"] = guide(); return { outcome: "restored", entity: uuid(2), name: "want_bytes", to: "c".repeat(64), replayed: false }; }; + browser.node("review-undo").click(); browser.node("review-undo-confirm").click(); await flush(); + const sent = browser.sent("/api/workspace/entity/restore"); + assert.equal(sent.length, 2); + assert.equal(sent[1].expected, `source:${"b".repeat(64)}`, "the undo names the source it was read again at"); + assert.notEqual(sent[1].operation_id, sent[0].operation_id); +}); + +test("bringing back a removed entity pins its absence", async () => { + const browser = fixture({ hash: "#/review/w/1", capabilities: { entity_restore: true } }); + browser.data["/api/guide"] = working({ removed: 1 }, [changed(2, "gone", { change: "removed" })]); + browser.data["POST /api/workspace/entity/restore"] = () => { browser.data["/api/guide"] = guide(); return { outcome: "restored", entity: uuid(2), name: "gone", to: "b".repeat(64), replayed: false }; }; + await flush(); browser.node("review-undo").click(); browser.node("review-undo-confirm").click(); await flush(); + assert.equal(JSON.parse(browser.requests.find((row) => row.method === "POST").options.body).expected, "absent"); + assert.equal(browser.count("/api/workspace/entity/restore"), 1); +}); + +test("review marks become new again when source changes without a syntax or behavior change", async () => { + const browser = fixture({ hash: "#/review/w/1" }); + browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(2, "want_bytes")]); + await flush(); await mark(browser, "fine"); + browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(2, "want_bytes", { after: { state: "recorded", text: "# A new comment\ndef want_bytes():\n return 1\n", body_hash: "b".repeat(64) } })]); + browser.refresh(); await flush(); browser.navigate("review/w/1"); await flush(); + assert.equal(stepOf(browser).marked, "", "the same behavior hash never carries a review to a different source span"); +}); + +test("selected commits preserve the exact selection when the engine refuses to split new content", async () => { + const browser = fixture({ hash: "#/review/w/1", capabilities: { entity_commit: true } }); + browser.data["/api/guide"] = working({ modified: 2 }, [exactChanged(2, "first"), exactChanged(3, "second")]); + browser.data["POST /api/workspace/commit"] = { __status: 409, error: "New content cannot be split yet.", body: { outcome: "refused", remedy: "Keep this selection uncommitted or explicitly choose to commit all entities." } }; + await flush(); await mark(browser, "fine", "flag"); + assert.equal(browser.node("commit-only-fine").checked, true); + writeCommit(browser, "Keep first"); await flush(); + assert.deepEqual(JSON.parse(browser.requests.find((row) => row.method === "POST").options.body).entities, [uuid(2)]); + assert.equal(browser.node("commit-message").textContent, "Keep first"); + assert.match(browser.text(), /Keep this selection uncommitted/); + browser.node("commit-submit").click(); await flush(); + assert.deepEqual(browser.requests.filter((row) => row.method === "POST").map((row) => JSON.parse(row.options.body).entities), [[uuid(2)], [uuid(2)]], "retry never widens the selection"); +}); + +test("durable flags use exact source and report the agent who saw them without claiming authorship", async () => { + const browser = fixture({ hash: "#/review/w/1", capabilities: { review_flags: true } }); + browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(2, "want_bytes")]); + browser.data["/api/workspace/flags"] = { flags: [] }; + browser.data["POST /api/workspace/flags"] = (_, options) => { + const body = JSON.parse(options.body); + browser.data["/api/workspace/flags"] = { flags: [{ entity_id: body.entity, fingerprint: body.fingerprint, seen_by: [{ agent: "Claude Code", at: secondsAgo(5) }], changed_since: false }] }; + return { outcome: "flagged" }; + }; + browser.data["POST /api/workspace/flags/resolve"] = { outcome: "resolved" }; + await flush(); await flagWith(browser, "Check the callers"); await browser.advance(1); + const request = browser.requests.find((row) => row.method === "POST"); + assert.equal(JSON.parse(request.options.body).fingerprint, `source:${"a".repeat(64)}`); + assert.match(browser.text(), /Seen by Claude Code/); + browser.data["/api/workspace/flags"].flags[0].changed_since = true; + browser.refresh(); await flush(); assert.match(browser.text(), /Changed since you flagged it/); + assert.doesNotMatch(browser.text(), /Claude Code changed it/); + browser.navigate("review/w/1"); await flush(); await mark(browser, "fine"); + assert.equal(browser.count("/api/workspace/flags/resolve"), 1, "the durable entity_id is used when clearing its flag"); +}); + +test("a flag the repository keeps marks its entity in any browser; a mark of looks right stays in the browser that made it", async () => { + // A browser that never marked anything opens uncommitted work the repository holds a flag on. + const browser = fixture({ hash: "#/review/w/1", capabilities: { review_flags: true } }); + browser.data["/api/guide"] = working({ modified: 2 }, [exactChanged(2, "want_bytes"), changed(3, "other", { after: { state: "recorded", text: "def other():\n return 2\n", working: true, body_hash: "b".repeat(64) } })]); + browser.data["/api/workspace/flags"] = { flags: [ + { entity_id: uuid(2), fingerprint: `source:${"a".repeat(64)}`, note: "Check the callers", seen_by: [], changed_since: false }, + // A flag on a version this work no longer shows is about something else. + { entity_id: uuid(3), fingerprint: `source:${"c".repeat(64)}`, note: "Old", seen_by: [], changed_since: true }] }; + await flush(); + assert.equal(stepOf(browser).marked, "Flagged", "the repository's flag on this version marks it here too"); + assert.equal(browser.node("review-note").value, "Check the callers"); + browser.navigate("review/w/done"); await flush(); + assert.match(browser.node("review-body").textContent, /^1 of 2 marked/); + assert.deepEqual(reviewRows(browser.node("review-body")).map((group) => [group.title, group.rows.map((row) => row.name)]), [["Flagged", ["want_bytes"]]]); + // What this browser marks itself wins over what the repository keeps. + browser.navigate("review/w/1"); await flush(); + browser.data["POST /api/workspace/flags/resolve"] = () => { browser.data["/api/workspace/flags"] = { flags: [] }; return { outcome: "resolved" }; }; + browser.node("review-fine").click(); await flush(); await browser.advance(1); + browser.navigate("review/w/1"); await flush(); + assert.equal(stepOf(browser).marked, "Looks right"); + assert.equal(browser.count("/api/workspace/flags/resolve"), 1, "and the repository's flag is closed"); +}); + +test("entities a view would show under one name are told apart by what holds each", async () => { + const browser = fixture({ hash: "#/review/w/1" }); + const module = (n, name) => ({ relation_id: `r-${n}`, relation: "references", origin: "parsed", confidence: 1, entity: { id: uuid(n), name, kind: "module", role: "source" }, changed_here: false }); + const test_ = (n, within) => ({ relation_id: `t-${n}`, relation: "tests", entity: { id: uuid(n), name: "Test", kind: "constant", role: "test", within }, hops: 3 }); + browser.data["/api/guide"] = working({ modified: 1, recorded_consumers: 3, consumers_may_need_review: 3, recorded_tests: 3 }, [ + changed(2, "_toLowerCase", { connections: connected([relying(10, "_ZodString", { relation: "calls", entity: { id: uuid(10), name: "_ZodString", kind: "function", role: "source", within: "module schemas" } }), module(11, "checks"), module(12, "checks")], + [test_(20, "module error-utils.test"), test_(21, "module object.test"), relying(22, "apply.test", { relation: "tests", entity: { id: uuid(22), name: "apply.test", kind: "module", role: "test" }, hops: 3 }), + // Three of one name where two also share what holds them: each is said by the end of its place no other shares. + ...[[23, "test_tutorial001"], [24, "test_tutorial002"], [25, "test_tutorial001"]].map(([n, module]) => ({ relation: "tests", entity: { id: uuid(n), name: "client", kind: "function", role: "test", within: `module ${module}` }, hops: 3 }))]), + consumer_verdict: { verdict: "may_need_review", reason: "body_changed", consumers: 3 } })]); + // Kin places the two modules nowhere in the guide, so each is read once for the place Kin records it under. + browser.data["/api/workspace/entity"] = (url) => { + const id = url.searchParams.get("id"); + const place = { [uuid(11)]: "zod.src.v4.classic.checks", [uuid(12)]: "zod.src.v4.mini.checks", + [uuid(23)]: "tests.test_tutorial.test_query_params.test_tutorial001", [uuid(24)]: "tests.test_tutorial.test_query_params.test_tutorial002", [uuid(25)]: "tests.test_tutorial.test_body.test_tutorial001" }[id]; + return { id, header: { ok: true, name: "checks", kind: "module", module: place }, source: { ok: true, text: "export { _toLowerCase } from \"./api\";", start_line: 1, language: "typescript" } }; + }; + await flush(); await flush(); + const step = stepOf(browser); + const reliers = picture(step.relies).columns[1].boxes.map(([name, place]) => [name, place]); + assert.deepEqual(reliers, [["_ZodString", "calls it"], ["checks in classic", "imports it"], ["checks in mini", "imports it"]]); + // How each uses it says which is which in the same words. + assert.deepEqual(browser.node("review-sites").querySelectorAll(".site-head").map((head) => head.textContent), ["_ZodStringdid not change", "checksin classicdid not change", "checksin minidid not change"]); + assert.deepEqual(browser.node("step-tests").querySelectorAll("li").map((row) => row.textContent), ["Testin error-utils.test3 steps away", "Testin object.test3 steps away", "apply.test3 steps away", + "clientin test_query_params.test_tutorial0013 steps away", "clientin test_tutorial0023 steps away", "clientin test_body.test_tutorial0013 steps away"]); + assert.equal(browser.requests.filter((request) => request.name === "/api/workspace/entity" && /sections=header/.test(request.url)).length, 5, "only the ones that share a name and a holder are read for their place, once each"); + // The picture of the whole work tells them apart the same way. + browser.navigate("uncommitted"); await flush(); + assert.deepEqual(picture(browser.node("work-flight")).columns[1].boxes.map(([name]) => name), ["_ZodString", "checks in classic", "checks in mini"]); +}); + +test("a failed durable flag remains visibly local and never blocks committing", async () => { + const browser = fixture({ hash: "#/review/w/1", capabilities: { review_flags: true } }); + browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(2, "want_bytes")]); + browser.data["POST /api/workspace/flags"] = { __status: 403, error: "This session cannot save flags." }; + await flush(); await mark(browser, "flag"); + assert.match(browser.text(), /Kin could not save the flag for agents/); + assert.match(browser.text(), /Your review mark is still kept in this browser/); + assert.equal(browser.node("commit-submit").disabled, false); +}); + +test("verification shows full-suite failure, unavailable and no-tests outcomes honestly", async () => { + for (const [answer, title] of [[{ state: "unavailable", reason: "No verification plan is configured." }, "Tests are unavailable"], [{ state: "done", outcome: "no_tests", result: { run: null, reach: "targeted", tests: [] } }, "No tests ran"], [{ state: "done", outcome: "failed", result: { run: { outcome: "failed", exit_code: 1 }, reach: "whole", tests: [] } }, "Test command failed"]]) { + const browser = fixture({ capabilities: { verify_run: true } }); + browser.data["POST /api/workspace/verify"] = { operation_id: uuid(1), state: "running", phase: "Planning tests" }; + browser.data[`/api/workspace/operations/${uuid(1)}`] = { operation_id: uuid(1), ...answer }; + if (answer.state === "unavailable") browser.data["POST /api/workspace/verify"] = answer; + await flush(); browser.node("work-run-tests").click(); await flush(); await browser.advance(1000); + assert.match(browser.node("work-verify").textContent, new RegExp(title)); + if (answer.outcome === "no_tests") assert.match(browser.text(), /has not passed a test run/); + if (answer.outcome === "failed") assert.match(browser.text(), /full test suite/); + } +}); + +test("a test command that could not run is said with why, its exit code and its output, and its tests read as not run", async () => { + const notRun = { state: "not_run", at: secondsAgo(2), summary: "The test command could not run, so this test did not run. It exited 127." }; + const tests = [relying(20, "404s.test", { result: notRun }), relying(21, "body-limit.test", { result: notRun })]; + const run = { run_id: "r", outcome: "could_not_run", duration_ms: 395, exit_code: 127, finished_at: secondsAgo(1), reason: "The shell could not find `eslint`, so no test ran.", output_tail: "> fastify@5.6.1 lint\n> eslint\nsh: eslint: command not found" }; + const browser = fixture({ capabilities: { verify_run: true } }); + browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(2, "setErrorHeaders", { tests_result: { not_run: 2 }, connections: connected([], tests) })]); + browser.data["POST /api/workspace/verify"] = { operation_id: uuid(1), state: "running", phase: "Planning the tests that reach this work." }; + browser.data[`/api/workspace/operations/${uuid(1)}`] = { operation_id: uuid(1), state: "done", outcome: "could_not_run", phase: "The test command could not run.", result: { reach: "whole", entities: [], tests: [], run } }; + await flush(); browser.node("work-run-tests").click(); await flush(); await browser.advance(1000); + const note = browser.node("work-verify"); + assert.match(note.textContent, /Test command could not run/); + assert.match(note.textContent, /The shell could not find `eslint`, so no test ran\. It exited 127\. No test passed or failed/); + assert.doesNotMatch(note.textContent, /Test command failed|full test suite|Run failed/, "a command that never reached a test is not said as a failed run"); + assert.equal(note.querySelectorAll(".message-bad").length, 0); + assert.equal(note.querySelectorAll(".message-warn").length, 1); + const details = note.querySelector("details"); + assert.match(details.textContent, /Exit code 127/); + assert.equal(details.querySelector("pre").textContent, run.output_tail, "the end of its output is shown as it printed it"); + assert.match(details.textContent, new RegExp(`Operation ${uuid(1)}`)); + + browser.navigate("review/w/1"); await flush(); + const step = stepOf(browser); + assert.deepEqual(step.rows(step.tests), ["404s.test1 step awayNot run", "body-limit.test1 step awayNot run"]); + const word = step.tests.querySelector("span.state"); + assert.match(word.getAttribute("title"), /^The test command could not run, so this test did not run\. It exited 127\. Tried /); + assert.doesNotMatch(browser.text(), /Run failed|These results describe the test command/); + + // A test command that ran and failed is still a failure, with its exit code and output kept. + const failing = fixture({ capabilities: { verify_run: true } }); + failing.data["POST /api/workspace/verify"] = { operation_id: uuid(1), state: "running", phase: "Planning tests" }; + failing.data[`/api/workspace/operations/${uuid(1)}`] = { operation_id: uuid(1), state: "done", outcome: "failed", result: { reach: "whole", tests: [], run: { outcome: "failed", exit_code: 1, output_tail: "not ok 3 - reply carries the status" } } }; + await flush(); failing.node("work-run-tests").click(); await flush(); await failing.advance(1000); + const failed = failing.node("work-verify"); + assert.match(failed.textContent, /Test command failed/); + assert.match(failed.textContent, /Kin ran the repository's full test suite\. It exited 1\. It did not report individual test outcomes\./); + assert.equal(failed.querySelectorAll(".message-bad").length, 1); + assert.equal(failed.querySelector("details pre").textContent, "not ok 3 - reply carries the status"); +}); + +test("verification with a lost start answer checks its operation without starting another run", async () => { + const browser = fixture({ capabilities: { verify_run: true } }); + browser.failures.set("POST /api/workspace/verify", new Error("Answer lost")); + browser.data[`/api/workspace/operations/${uuid(1)}`] = { operation_id: uuid(1), state: "done", outcome: "passed", result: { reach: "targeted", run: { outcome: "passed" } } }; + await flush(); browser.node("work-run-tests").click(); await flush(); + assert.match(browser.text(), /test run's outcome is unknown/); + assert.equal(browser.node("work-run-tests").disabled, true); + browser.button("Check the run").click(); await browser.advance(1000); + assert.match(browser.text(), /Test command passed/); + assert.equal(browser.count("/api/workspace/verify"), 1, "checking the operation never sends the start again"); +}); + +const remotePayload = (resolve = "fetch_then_merge") => ({ remotes: [{ name: "peer", kind: "kin", can_pull: true, can_push: true, can_fetch: true, standing: { state: "diverged", summary: "Both branches have changes.", push: { state: "blocked" }, pull: { state: "blocked", resolve, resolve_branch: "peer/main" } } }] }); + +test("diverged native work fetches into a separate branch and opens the existing merge preview", async () => { + const browser = fixture({ hash: "#/branches", capabilities: { remote_pull: true, remote_push: true, remote_fetch: true } }); + browser.data["/api/workspace/remotes"] = remotePayload(); + browser.data["POST /api/workspace/fetch"] = (_, options) => { + browser.data["/api/workspace/remotes"] = remotePayload("merge"); + browser.data["/api/branches"].branches.push({ name: "peer/main", active: false }); + return { operation_id: JSON.parse(options.body).operation_id, outcome: "fetched", into_branch: "peer/main", changes: 2, entities: 3 }; + }; + await flush(); assert.equal(browser.button("Pull"), undefined); + browser.button("Fetch for review").click(); await flush(); + assert.deepEqual(JSON.parse(browser.requests.find((row) => row.method === "POST").options.body), { remote: "peer", operation_id: uuid(1) }); + assert.match(browser.text(), /Fetched from peer/); + assert.match(browser.text(), /ready to review and merge/); + browser.button("Preview merge").click(); await flush(); + assert.equal(browser.node("workspace-merge-source").value, "peer/main"); + assert.equal(browser.count("/api/workspace/merge/preview"), 1); + assert.equal(browser.count("/api/workspace/pull"), 0, "divergence is never treated as a fast-forward pull"); +}); + +test("optional capabilities recover on explicit refresh and failed remotes leave work available", async () => { + const browser = fixture({ hash: "#/branches", capabilities: { remote_fetch: true } }); + await flush(); assert.equal(browser.node("branches-remotes").textContent, ""); + browser.data["/api/workspace/remotes"] = remotePayload(); + browser.refresh(); await flush(); assert.ok(browser.button("Fetch for review")); + browser.failures.set("/api/workspace/remotes", "The remote could not be reached."); + browser.refresh(); await flush(); + browser.navigate("uncommitted"); assert.ok(browser.button("Commit")); +}); + +test("a graph-proven test is never also listed as matched by name only", async () => { + const browser = fixture({ hash: `#/entity/${uuid(9)}` }); + browser.data["/api/workspace/entity"] = () => entityV2(uuid(9), { + relied_on_by: { ok: true, total: 2, confirmed: 0, items: [ + { id: uuid(11), name: "TestSigner.test_sign", kind: "method", relationship: "calls", is_test: true, confirmed: false }, + { id: uuid(21), name: "possibleCaller", kind: "function", relationship: "calls", confirmed: false }, + ] }, + }); + await flush(); + const guessed = browser.main().querySelectorAll(".rel-group").find((node) => node.textContent.includes("Matched by name only")); + assert.match(browser.node("entity-impact").textContent, /TestSigner.test_sign/); + assert.ok(guessed); assert.match(guessed.textContent, /possibleCaller/); + assert.doesNotMatch(guessed.textContent, /TestSigner.test_sign/, "coverage is stronger than a name-only candidate for the same entity"); +}); + +test("test labels describe the command and do not count one shared test once for each changed entity", async () => { + const browser = fixture(); + const tests = [relying(20, "testShared", { result: { state: "passed", summary: "The test command passed; Kin does not report individual test outcomes." } })]; + browser.data["/api/guide"] = working({ modified: 2 }, [exactChanged(2, "first", { tests_result: { passed: 1 }, connections: connected([], tests) }), exactChanged(3, "second", { tests_result: { passed: 1 }, connections: connected([], tests) })]); + await flush(); + assert.equal(browser.node("work-verify").textContent, "A test command passed."); + browser.navigate("review/w/1"); await flush(); + assert.match(browser.text(), /Run passed/); + assert.match(browser.text(), /Kin did not report individual test outcomes/); + assert.doesNotMatch(browser.text(), /Two tests pass/); +}); + +test("a lost commit answer stays uncertain across reload without a second write", async () => { + const tabStorage = new Map(); + const browser = fixture({ tabStorage }); await flush(); + browser.failures.set("POST /api/workspace/commit", new Error("Connection lost")); + writeCommit(browser, "Keep work"); await flush(); + const reload = fixture({ tabStorage }); await flush(); + assert.match(reload.node("work-commit").textContent, /commit's outcome is unknown/); + assert.equal(reload.requests.some((row) => row.method === "POST"), false); + assert.equal(reload.button("Commit").disabled, true); +}); + +test("a selected commit keeps unchosen review marks on the new working base", async () => { + const kept = new Map(); + const browser = fixture({ hash: "#/review/w/1", capabilities: { entity_commit: true }, kept }); + const first = exactChanged(2, "first"), second = exactChanged(3, "second"); + browser.data["/api/guide"] = working({ modified: 2 }, [first, second]); + const changeId = "c".repeat(64); + browser.data["POST /api/workspace/commit"] = (_, options) => { + browser.data["/api/guide"] = { ...working({ modified: 1 }, [second]), change: { base: changeId } }; + return { outcome: "committed", change_id: changeId, title: JSON.parse(options.body).message, entities: [uuid(2)] }; + }; + await flush(); await mark(browser, "fine", "flag"); + writeCommit(browser, "Keep first"); await flush(); + const committedMarks = marksOf(kept, changeId); + const unchosenMarks = marksOf(kept, `w:${changeId}`); + assert.deepEqual(committedMarks, { fine: [`${uuid(2)}:${"a".repeat(64)}`], flagged: [], notes: {} }); + assert.deepEqual(unchosenMarks, { fine: [], flagged: [`${uuid(3)}:${"a".repeat(64)}`], notes: {} }); + browser.navigate("review/w/1"); await flush(); assert.equal(stepOf(browser).marked, "Flagged"); +}); + +test("undo uncertainty stays recoverable after the restored entity disappears", async () => { + const browser = fixture({ hash: "#/review/w/1", capabilities: { entity_restore: true } }); + browser.data["/api/guide"] = working({ added: 1 }, [exactChanged(2, "newEntity", { change: "added" })]); + browser.data["POST /api/workspace/entity/restore"] = () => { browser.data["/api/guide"] = guide(); throw new Error("The answer was lost after undoing it"); }; + await flush(); browser.node("review-undo").click(); browser.node("review-undo-confirm").click(); await flush(); + browser.button("Read the work again").click(); await flush(); + assert.match(browser.text(), /That entity is not in this review/); + assert.match(browser.text(), /undo's outcome is unknown/); + assert.ok(browser.button("I've checked"), "recovery belongs to the review, not to a surviving step"); + browser.navigate("uncommitted"); + assert.match(browser.node("work-restore").textContent, /undo's outcome is unknown/); + browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(3, "laterEntity")]); + browser.refresh(); await flush(); + assert.equal(browser.button("Commit").disabled, true, "the recovery message explains the visible lockout"); + browser.button("I've checked").click(); + assert.equal(browser.button("Commit").disabled, false); + assert.equal(browser.node("work-restore").textContent, ""); + assert.equal(browser.count("/api/workspace/entity/restore"), 1, "recovery never replays the undo"); +}); + +test("copied flags do not infer where an unattributed edit happened", async () => { + const browser = fixture({ hash: "#/review/w/1" }); + browser.data["/api/guide"] = working({ modified: 2, authors: [{ name: "Claude Code", entities: 1 }] }, [exactChanged(2, "first", { authored_by: [{ name: "Claude Code" }] }), exactChanged(3, "second")]); + await flush(); await mark(browser, "flag", "flag"); + browser.button("Copy the flags").click(); await flush(); + assert.match(browser.copied[0], /Claude Code:\nfirst/); + assert.match(browser.copied[0], /No agent reported this edit:\nsecond/); + assert.doesNotMatch(browser.copied[0], /outside Kin|you changed/); +}); + +test("a selected history review never presents the working guide while its own read is pending", async () => { + const browser = fixture(); + browser.data["/api/guide"] = { ...working(FOUR, fourChanged()), change: { base: "change-1", title: "Uncommitted work", author: "Current worker" } }; + await flush(); + const changeId = "c".repeat(64); + browser.data["/api/guide?change"] = () => ({ ...recorded(changeId, { modified: 2 }, [changed(2, "keptFirst"), changed(3, "keptSecond")]), change: { id: changeId, title: "Selected change", author: "Original author", timestamp: secondsAgo(600) } }); + let release; browser.held.set("/api/guide?change", new Promise((done) => { release = done; })); + browser.navigate("history"); browser.navigate(`review/c/${changeId}`); + assert.doesNotMatch(browser.node("review-head").textContent, /Uncommitted work|Current worker|Four functions/); + release(); await flush(); + assert.equal(browser.node("review-head").querySelector("h1").textContent, "Selected change"); + assert.match(browser.node("review-head").textContent, /Original author/); + assert.match(browser.node("review-head").textContent, /Two functions changed/); + browser.data["/api/guide"] = { ...working({ modified: 3 }, [changed(30, "newWorking")]), change: { title: "Later uncommitted work", author: "Later worker" } }; + browser.refresh(); await flush(); + assert.equal(browser.node("review-head").querySelector("h1").textContent, "Selected change"); + assert.doesNotMatch(browser.node("review-head").textContent, /Later worker|newWorking/); +}); + +test("graph-negative claims describe what Kin found and incomplete counts remain lower bounds", async () => { + const browser = fixture(); + browser.data["/api/guide"] = working({ modified: 1, recorded_consumers: 3, recorded_tests: 1, completeness: { state: "settled", unresolved_entities: 2 } }, [changed(2, "want_bytes", { connections: connected([relying(10, "caller")], [relying(20, "testIt")], { consumers_total: 3 }) })]); + await flush(); + assert.match(browser.node("work-lede").textContent, /At least three other places/); + assert.match(browser.node("work-lede").textContent, /At least one test reaches/); + assert.doesNotMatch(browser.node("work-lede").textContent, /still mapping/, "unproven connections are not promised to resolve by waiting"); + browser.data["/api/guide"] = working({ modified: 1, completeness: { state: "not_settled", pending_entities: 2 } }, [changed(2, "want_bytes")]); + browser.refresh(); await flush(); + assert.match(browser.node("work-lede").textContent, /Kin found no connections outside this change/); + assert.match(browser.node("work-lede").textContent, /Kin found no tests that reach this work/); + assert.doesNotMatch(browser.node("work-lede").textContent, /No test reaches|Nothing outside/); +}); + +test("an entity count without listed names is disclosed instead of claiming no connections", async () => { + const browser = fixture({ hash: `#/entity/${uuid(9)}` }); + browser.data["/api/workspace/entity"] = () => entityV2(uuid(9), { header: { name: "held", kind: "function", dependents: 1 }, relied_on_by: { ok: true, items: [], total: 1, relies_total: 1, bound: "at_least" } }); + await flush(); + assert.match(connections(browser).left.textContent, /What relies on it≥1/); + assert.match(connections(browser).left.textContent, /1 more that Kin counted and did not list/); + assert.doesNotMatch(connections(browser).left.textContent, /found no entities/); +}); + +test("deeper entities sharing a name disclose their kinds", async () => { + const browser = fixture({ hash: `#/entity/${uuid(9)}` }); + browser.data["/api/workspace/entity"] = () => entityV2(uuid(9), { impact: { ok: true, transitive: 2, tests: 0, depth: 3, bound: "at_least", top: [{ id: uuid(31), name: "fastify", kind: "function", hops: 3 }, { id: uuid(32), name: "fastify", kind: "module", hops: 3 }] } }); + await flush(); + const rows = browser.node("entity-impact").querySelectorAll(".relier").filter((row) => row.textContent.includes("fastify")); + assert.equal(rows.length, 2); + assert.match(rows[0].textContent, /Function.*3 steps away/); + assert.match(rows[1].textContent, /Module.*3 steps away/); +}); + +test("working review repaints after returning from a historical review through an entity", async () => { + const browser = await reviewingChange("#/review/c/change-1/1"); + browser.data["/api/workspace/entity"] = () => entityV2(uuid(9)); + browser.navigate(`entity/${uuid(9)}`); await flush(); + browser.navigate("review/w/1"); await flush(); + assert.ok(browser.node("review-fine")); + assert.equal(stepOf(browser).position, "1 of 1"); + assert.match(browser.node("review-head").textContent, /Uncommitted/); +}); + +test("outgoing connection counts retain lower-bound status in visible and accessible text", async () => { + const browser = fixture({ hash: `#/entity/${uuid(9)}` }); + browser.data["/api/workspace/entity"] = () => entityV2(uuid(9), { relies_on: { ok: true, items: [], total: 3, bound: "at_least" } }); + await flush(); + assert.match(connections(browser).right.textContent, /What it relies on≥3/); + assert.match(connections(browser).right.textContent, /3 more that Kin counted and did not list/); + assert.match(connections(browser).host.getAttribute("aria-label"), /it relies on at least 3 entities/); +}); + +// ── What a real run through an agent's work found ────────────────────────── + +test("Home lists every agent change nobody has reviewed beside uncommitted work, each with its review", async () => { + const hub = fixture({ hub: true }); + const agent = { name: "Claude Code", key: "00000000000000a1" }; + // Uncommitted work, and under it a person's commit made on top of two agent commits, one of them reviewed. + hub.data[under(KEY, "/api/workspace/overview")] = summaryOf({ recent: [ + { id: "p-3", title: "Trim the request id", author: "Dana Reader", time: secondsAgo(60), kin_checkpoint: false }, + { id: "a-2", title: "Prefix request ids", author: "Claude Code", agent, time: secondsAgo(600), kin_checkpoint: false }, + { id: "a-1", title: "Bound the header", author: "Claude Code", agent, time: secondsAgo(900), kin_checkpoint: false }] }); + hub.data[under(KEY, "/api/standing")] = { standing: { "a-1": { state: "approved" } } }; + // A clean repository whose agent committed four changes nobody has reviewed. + hub.data[under(NOTES, "/api/workspace/overview")] = summaryOf({ recent: [1, 2, 3, 4].map((n) => ({ id: `n-${n}`, title: `Note change ${n}`, author: "Claude Code", agent, time: secondsAgo(n * 60), kin_checkpoint: false })) }); + hub.data[under(NOTES, "/api/standing")] = { standing: {} }; + await flush(); await flush(); + const cards = hub.node("home-attention").querySelectorAll("article"); + const fixtureCard = cards.find((card) => card.textContent.includes("Fixture")); + assert.match(fixtureCard.querySelector(".focus-kind").textContent, /^Uncommitted$/, "uncommitted work keeps its card"); + const agentRows = (card) => card.querySelector(".focus-agents").querySelectorAll("li").map((row) => [row.querySelector(".change-title").textContent, row.querySelector("a").textContent, row.querySelector("a").getAttribute("href")]); + assert.deepEqual(agentRows(fixtureCard), [["Prefix request ids", "Review", `${URL_OF(KEY)}${guided("a-2")}`]], "the agent's change under the person's is still waiting, and the reviewed one is not"); + const notesCard = cards.find((card) => card.textContent.includes("notes")); + assert.equal(notesCard.querySelector(".focus-kind").textContent, "Agent commits"); + assert.equal(notesCard.querySelector(".focus-sentence").textContent, "Claude Code committed four changes nobody has reviewed yet."); + assert.deepEqual(agentRows(notesCard).map(([title, , href]) => [title, href]), [1, 2, 3].map((n) => [`Note change ${n}`, `${URL_OF(NOTES)}${guided(`n-${n}`)}`]), "the newest three, so Home stays short"); + assert.match(notesCard.textContent, /1 more in History/); + assert.match(hub.node("home-headline").textContent, /^Two repositories need you\./); +}); + +test("a flag on a recorded change reaches agents only while the entity is still what the change left, and says so when it is not", async () => { + const kept = new Map(); + const browser = fixture({ hash: "#/review/c/change-1/1", capabilities: { review_flags: true }, kept }); + browser.data["/api/guide?change"] = (url) => recorded(url.searchParams.get("change"), { modified: 2 }, [exactChanged(2, "want_bytes"), exactChanged(7, "quiet", { after: { state: "recorded", text: "def quiet():\n return 1\n", body_hash: "c".repeat(64) } })]); + browser.data["/api/workspace/flags"] = { flags: [] }; + // Kin's reading of each entity now: want_bytes is as the change left it, quiet changed since. + browser.data["/api/workspace/entity"] = (url) => ({ id: url.searchParams.get("id"), source: { ok: true, text: "def x():\n return 1\n", body_hash: url.searchParams.get("id") === uuid(2) ? "a".repeat(64) : "d".repeat(64) } }); + browser.data["POST /api/workspace/flags"] = (_, options) => { + const body = JSON.parse(options.body); + browser.data["/api/workspace/flags"] = { flags: [{ entity_id: body.entity, fingerprint: body.fingerprint, seen_by: [], changed_since: false }] }; + return { outcome: "flagged" }; + }; + browser.data["POST /api/workspace/flags/resolve"] = { outcome: "resolved" }; + await flush(); + await flagWith(browser, "The callers still pass one argument."); await browser.advance(1); + assert.deepEqual(browser.sent("/api/workspace/flags").map((body) => [body.entity, body.note, body.fingerprint]), [[uuid(2), "The callers still pass one argument.", `source:${"a".repeat(64)}`]], "shared through the same durable route as a flag on uncommitted work"); + await mark(browser, "flag"); await browser.advance(1); + assert.equal(browser.sent("/api/workspace/flags").length, 1, "quiet changed since, so no flag about a gone version is sent"); + assert.equal(browser.location.hash, "#/review/c/change-1/done"); + const body = browser.node("review-body"); + const states = body.querySelectorAll("li").map((row) => [row.querySelector(".cell-main").textContent, row.querySelectorAll(".cell").at(-1).textContent]); + assert.deepEqual(states, [["want_bytes", "No agent has seen it yet"], ["quiet", "Not shared: it changed since"]]); + assert.match(body.textContent, /quiet changed after this change, so the flag was not shared: an agent would be told about a version that is gone\./); + assert.match(body.textContent, /An agent is told about each flag above that was shared, the next time it touches that entity\./); + // A reload forgets what this page sent; with no flag on that version in the repository, the row says the flag stayed here. + const reloaded = fixture({ hash: "#/review/c/change-1/done", capabilities: { review_flags: true }, kept }); + reloaded.data["/api/guide?change"] = browser.data["/api/guide?change"]; + reloaded.data["/api/workspace/flags"] = browser.data["/api/workspace/flags"]; + await flush(); + const rowsAfter = reloaded.node("review-body").querySelectorAll("li").map((row) => [row.querySelector(".cell-main").textContent, row.querySelectorAll(".cell").at(-1).textContent]); + assert.deepEqual(rowsAfter, [["want_bytes", "No agent has seen it yet"], ["quiet", "Kept in this browser"]]); + // Looking again and finding it right closes the flag that was shared. + browser.navigate("review/c/change-1/1"); await flush(); await mark(browser, "fine"); await browser.advance(1); + assert.equal(browser.count("/api/workspace/flags/resolve"), 1); +}); + +test("saying a recorded change needs work shares the flags this page had not shared yet", async () => { + const kept = new Map([[marksKey("change-1"), JSON.stringify({ fine: [], flagged: [`${uuid(2)}:${"a".repeat(64)}`], notes: { [`${uuid(2)}:${"a".repeat(64)}`]: "Check the callers." } })]]); + const browser = fixture({ hash: "#/review/c/change-1/done", capabilities: { review_flags: true }, kept }); + browser.data["/api/guide?change"] = (url) => recorded(url.searchParams.get("change"), { modified: 1 }, [exactChanged(2, "want_bytes")]); + browser.data["/api/workspace/flags"] = { flags: [] }; + browser.data["/api/workspace/entity"] = (url) => ({ id: url.searchParams.get("id"), source: { ok: true, text: "def x():\n return 1\n", body_hash: "a".repeat(64) } }); + browser.data["POST /api/workspace/flags"] = { outcome: "flagged" }; + browser.data["/api/decide"] = { reviews: [] }; + await flush(); + browser.button("Flag it").click(); await flush(); await browser.advance(1); + assert.deepEqual(browser.sent("/api/decide").map((body) => body.state), ["needs_work"]); + assert.deepEqual(browser.sent("/api/workspace/flags").map((body) => [body.entity, body.note]), [[uuid(2), "Check the callers."]]); +}); + +test("committing what looks right says what was committed, as which change, and what stays, where it was taken", async () => { + const browser = await reviewing("#/review/w/1", { capabilities: { entity_commit: true } }); + await mark(browser, "fine", "flag"); + browser.navigate("review/w/done"); await flush(); + browser.button("Commit anyway").click(); await flush(); + const choice = browser.node("commit-only-fine").parentNode.textContent; + assert.equal(choice, "Commit only the one that looks right. The one you flagged and the two you did not mark stay uncommitted.", "both what was flagged and what was not marked are counted"); + const change = "d".repeat(64); + browser.data["/api/workspace/commit"] = (url, options) => { + const sent = JSON.parse(options.body); + browser.data["/api/guide"] = working({ added: 1, modified: 1, removed: 1 }, fourChanged().filter((step) => step.entity_id !== uuid(2))); + return { operation_id: sent.operation_id, outcome: "committed", change_id: change, title: sent.message, entities: sent.entities }; + }; + writeCommit(browser, "Bound want_bytes"); + browser.node("commit-submit").click(); await flush(); + assert.deepEqual(browser.sent("/api/workspace/commit").map((body) => body.entities), [[uuid(2)]]); + const body = browser.node("review-body"); + assert.match(body.textContent, /^Committedwant_bytes is committed as change dddddddd, “Bound want_bytes”\. 3 entities stay uncommitted\./, "the receipt leads the page the commit was made on"); + assert.equal(body.querySelector("code").textContent, "dddddddd"); + assert.equal(body.querySelectorAll("a").find((node) => node.textContent === "Review it").getAttribute("href"), guided(change)); +}); + +test("an undo says what it put back, and the walk moves on instead of losing its place", async () => { + const browser = fixture({ hash: "#/review/w/2", capabilities: { entity_restore: true } }); + browser.data["/api/guide"] = working({ modified: 2 }, [exactChanged(2, "want_bytes"), exactChanged(5, "base64_decode")]); + await flush(); + assert.equal(stepOf(browser).article.getAttribute("aria-label"), "base64_decode"); + browser.node("review-undo").click(); + browser.data["POST /api/workspace/entity/restore"] = () => { browser.data["/api/guide"] = working({ modified: 1 }, [exactChanged(2, "want_bytes")]); return { outcome: "restored", entity: uuid(5), name: "base64_decode", to: "c".repeat(64), replayed: false }; }; + browser.node("review-undo-confirm").click(); await flush(); await flush(); + assert.equal(browser.location.hash, "#/review/w/done", "the last page is gone, so the walk ends where it can"); + await flush(); + const said = browser.node("restore-receipt").textContent; + assert.match(said, /^Undid the change to base64_decodebase64_decode is back as it was at change cccccccc\. The rest of the work stays as it is\./); + assert.doesNotMatch(browser.node("review-body").textContent, /That entity is not in this review/); + browser.navigate("uncommitted"); await flush(); + assert.match(browser.node("work-restore").textContent, /Undid the change to base64_decode/, "Uncommitted says it too"); + browser.button("Dismiss").click(); + assert.equal(browser.node("work-restore").textContent, ""); +}); + +test("tests that reach an entity show how far each is, open in place, and a search with a reach never reads as a bare zero", async () => { + const tests = Array.from({ length: 10 }, (_, i) => relying(60 + i, `TestReach.test_${i}`, { relation: "tests", ...(i % 2 ? { hops: 3 } : {}) })); + const browser = fixture({ hash: "#/review/w/1" }); + browser.data["/api/guide"] = working({ modified: 2, recorded_tests: 12 }, [ + changed(2, "want_bytes", { connections: connected([], tests, { tests_total: 12, tests_depth: 3 }) }), + changed(7, "quiet", { connections: connected([], [], { tests_total: 0, tests_depth: 3 }) })]); + await flush(); + let step = stepOf(browser); + assert.equal(step.tests.querySelector("h2").textContent, "Tests that reach it12"); + const rows = () => step.tests.querySelectorAll("li").map((row) => row.textContent); + assert.deepEqual(rows().slice(0, 2), ["TestReach.test_01 step away", "TestReach.test_13 steps away"], "an omitted distance is one step, and a far one says how far"); + assert.equal(rows().length, 9); + assert.equal(rows()[8], "Show 2 more of the 12"); + browser.node("step-tests-more").click(); + step = stepOf(browser); + assert.equal(rows().length, 11); + assert.equal(rows()[10], "Kin lists 10 of the 12 here."); + browser.navigate("review/w/2"); await flush(); + step = stepOf(browser); + assert.equal(step.tests.querySelector("h2").textContent, "Tests that reach it", "no bare zero"); + assert.equal(step.tests.querySelector("p").textContent, "No test found within 3 steps of it."); + assert.match(step.verdict, /No test found within 3 steps of it\.$/); + const clean = fixture(); + clean.data["/api/guide"] = working({ modified: 1 }, [changed(7, "quiet", { connections: connected([], [], { tests_total: 0, tests_depth: 3 }) })]); + clean.refresh(); await flush(); + assert.match(clean.node("work-lede").textContent, /No test found within 3 steps of it\./); +}); + +test("tests that reach an entity say within how many steps, how each reaches it, and where same-named ones sit", async () => { + // Rows as the guide orders them: nearest first, and at one distance the ones that reach by calls first. + const testRow = (n, name, fields = {}, within) => relying(n, name, { relation: "references", ...fields, entity: { id: uuid(n), name, kind: "module", role: "test", ...(within ? { within } : {}) } }); + const tests = [ + testRow(80, "helper.test", { relation: "calls", call_hops: 1 }), + testRow(81, "string.test", { relation: "calls", hops: 2, call_hops: 2 }), + testRow(82, "index.test", { relation: "uses_type", hops: 3, call_hops: 1 }, "namespace classic.tests"), + testRow(83, "index.test", { hops: 3 }, "namespace mini.tests"), + testRow(84, "apply.test", { hops: 3 })]; + const browser = fixture({ hash: "#/review/w/1" }); + browser.data["/api/guide"] = working({ modified: 1, recorded_tests: 5, tests_depth: 3 }, [ + changed(2, "_toLowerCase", { connections: connected([], tests, { tests_total: 5, tests_depth: 3 }) })]); + await flush(); + const step = stepOf(browser); + assert.match(step.verdict, /5 tests reach it within 3 steps\.$/, "the count says how far Kin looked"); + assert.deepEqual(step.rows(step.tests), [ + "helper.test1 step away, calls it", + "string.test2 steps away, through calls", + "index.testin classic.tests3 steps away", + "index.testin mini.tests3 steps away", + "apply.test3 steps away"], "same-named tests say where each sits, and a route of calls alone says so"); + browser.navigate("uncommitted"); await flush(); + assert.match(browser.node("work-lede").textContent, /Five tests reach it within 3 steps\./); +}); + +test("documentation Kin only re-read is one quiet line and never a step, and a documentation edit shows what it said before and after", async () => { + const browser = fixture({ hash: "#/review/c/change-9" }); + const documented = changed(3, "$ZodStringBoolParams.case", { kind: "method", fields: [{ field: "doc_summary", before: "Options: sensitive", after: "Options: sensitive or insensitive" }] }); + browser.data["/api/guide?change"] = (url) => recorded(url.searchParams.get("change"), + { modified: 2, documentation_rereads: 1, documentation_reread_names: ["$ZodStringPrefix.case"] }, [changed(2, "_toLowerCase"), documented]); + await flush(); + assert.equal(browser.node("review-rereads").textContent, "Kin re-read the documentation of $ZodStringPrefix.case; the code is identical."); + assert.doesNotMatch(browser.node("review-body").textContent, /\$ZodStringPrefix/, "what was only re-read is not a step"); + browser.navigate("review/c/change-9/2"); await flush(); + const step = stepOf(browser); + assert.equal(step.article.getAttribute("aria-label"), "$ZodStringBoolParams.case"); + assert.match(step.verdict, /^Its documentation changed\. Its code reads the same token for token\./, "the guide's own reading is said, never a bare \"It changed\""); + assert.match(step.source.textContent, /Documentation before“?Options: sensitive”?Documentation after“?Options: sensitive or insensitive”?/); + const many = fixture({ hash: "#/review/c/change-8" }); + many.data["/api/guide?change"] = (url) => recorded(url.searchParams.get("change"), { modified: 1, documentation_rereads: 14, documentation_reread_names: ["a", "b"] }, [changed(2, "_toLowerCase")]); + await flush(); + assert.equal(many.node("review-rereads").textContent, "Kin re-read the documentation of 14 entities; their code is identical."); + const none = fixture({ hash: "#/review/c/change-7" }); + none.data["/api/guide?change"] = (url) => recorded(url.searchParams.get("change"), { modified: 1 }, [changed(2, "_toLowerCase")]); + await flush(); + assert.equal(none.node("review-rereads"), null); +}); + +test("a branch says how far it is ahead of and behind the current one, and is even only when both are counted and none", async () => { + const browser = fixture({ hash: "#/branches" }); + browser.data["/api/branches"] = { branches: [{ name: "main", active: true, default: true }, { name: "behind", active: false }, { name: "both", active: false }, { name: "older", active: false }] }; + const ranges = { behind: [0, 2], both: [1, 2], older: [0, undefined] }; + browser.data["/api/guide?base"] = (url) => { const [ahead, behind] = ranges[url.searchParams.get("change")]; return guide(ahead ? { modified: 1 } : {}, { chapters: ahead ? [{ steps: [{ kind: "function" }] }] : [], range: { base_ref: "main", head_ref: url.searchParams.get("change"), changes_total: ahead, ...(behind === undefined ? {} : { base_changes_total: behind }), changes: [] } }); }; + await flush(); await flush(); + const said = browser.node("branches-list").textContent; + assert.match(said, /behindNo work of its own, 2 changes behind main/); + assert.match(said, /both.*1 change ahead of main, 2 behind/); + assert.match(said, /olderNo work of its own beyond main/, "a server that did not count the other side is not read as even"); + assert.doesNotMatch(said, /even with main/); + assert.deepEqual([branchActions(browser, "behind"), branchOpens(browser, "behind")], [["Switch"], null], "nothing in it to review or merge"); + // What differs from a branch with no work of its own is only where the current branch moved on, which is not this branch's to say. + assert.doesNotMatch(branchRow(browser, "behind").textContent, /changed\./); +}); + +test("a switch is one state at a time: the preview names what comes along in plain words, and the landed switch never sits beside the branch it left", async () => { + const browser = fixture(); + browser.data["/api/workspace/branch/preview"] = { ...browser.data["/api/workspace/branch/preview"], pending_work: { present: true, detail: "Compatible pending work will be carried to the destination; work already present there is retained." }, + limitations: ["This preview reads recorded repository state and does not admit or inspect unrecorded working-copy changes.", "Confirmation may change the working copy. It rechecks exact authority and uses the existing pending-work carry, projection and admission-policy checks; a preview does not guarantee success."] }; + await flush(); await branchPreview(browser); + const preview = browser.main().querySelector(".preview"); + const visible = (node) => node.children.filter((child) => child.tagName !== "DETAILS").map((child) => child.tagName === "DIV" || child.tagName === "P" || child.tagName === "H3" || child.tagName === "LABEL" ? child.textContent : visible(child)).join(" "); + const words = visible(preview); + assert.match(words, /Switch from main to feature/); + assert.match(words, /Your uncommitted change to want_bytes comes along to feature\./, "the entities that come along are named"); + assert.doesNotMatch(words, /admit|authority|projection|admission|pending-work|destination/, "Kin's own words stay behind Show Kin's own words"); + assert.match(preview.querySelector("details").textContent, /^Show Kin's own words.*admission-policy/s); + assert.equal(browser.node("workspace-branch-submit").textContent, "Switch to feature"); + // The switch lands while the branch lists are still being read again. + let release; browser.held.set("/api/workspace/branches", new Promise((done) => { release = done; })); + browser.held.set("/api/branches", new Promise((done) => { release = ((first) => () => { first(); done(); })(release); })); + browser.data["/api/branches"] = { branches: [{ name: "main", active: false, default: true }, { name: "feature", active: true }] }; + browser.data["/api/workspace/branches"] = { schema: "kin.branch-list.v1", branches: [{ name: refWire("main"), active: false }, { name: refWire("feature"), active: true }] }; + branchConfirm(browser); await flush(); + const panel = () => browser.node("workspace-branch-host").textContent; + assert.match(panel(), /Switched to featureReading feature\. Your uncommitted change to want_bytes came along\./); + assert.doesNotMatch(panel(), /only branch|not available|Preview what a switch/); + assert.doesNotMatch(browser.text(), /You're on main/, "the lede does not keep the branch that was left"); + release(); await flush(); await flush(); + assert.match(panel(), /You're on feature now\./); + assert.match(browser.node("branches-lede").textContent, /^You're on feature\./); +}); + +test("a created branch keeps where it started, offers the switch as a button, and the remote's reason is said once", async () => { + const browser = fixture({ hash: "#/branches", capabilities: { branch_create: true } }); + const reason = "Kin holds 1 of this repository's 4,673 commits, so it cannot be pushed or pulled until it holds all of it."; + browser.data["/api/workspace/remotes"] = { remotes: [{ name: "origin", kind: "git", can_pull: false, can_push: false, unavailable_reason: reason, pull_unavailable_reason: reason, standing: { state: "diverged" } }] }; + await flush(); + assert.equal(browser.node("branches-remotes").textContent.split(reason).length - 1, 1); + browser.button("New branch").click(); + browser.type("branch-new-name", "feat/x"); + browser.data["/api/workspace/branch/create"] = () => { + browser.data["/api/branches"] = { branches: [...browser.data["/api/branches"].branches, { name: "feat/x", active: false }] }; + browser.data["/api/workspace/branches"] = { schema: "kin.branch-list.v1", branches: [...browser.data["/api/workspace/branches"].branches, { name: refWire("feat/x"), active: false }] }; + return { outcome: "created", name: "feat/x" }; + }; + newBranch(browser).emit("submit"); await flush(); + assert.match(browser.node("branches-new").textContent, /^Created feat\/xIt starts at main's latest change\. You're still on main\./); + assert.ok(browser.button("Switch to feat/x"), "the next step is a button, not an instruction"); + // On the new branch the receipt still says where it started, never that it starts at itself. + browser.data["/api/branches"] = { branches: [{ name: "main", active: false, default: true }, { name: "feature", active: false }, { name: "feat/x", active: true }] }; + browser.refresh(); await flush(); + assert.match(browser.node("branches-new").textContent, /You're on it now\. It started at main's latest change\./); + assert.doesNotMatch(browser.node("branches-new").textContent, /feat\/x's latest change/); +}); + +test("small words hold together: a long title wraps, a branch name stays whole, counts are numerals, and Kin's record start is its own line", async () => { + const css = fs.readFileSync(path.join(assets, "style.css"), "utf8"); + const rule = (selector) => css.split("\n").find((line) => line.startsWith(`${selector} {`) && line.endsWith("}"))?.slice(selector.length + 2, -1) ?? ""; + // A step's title is its entity's name, which wraps on a narrow window rather than losing its end. + assert.match(rule(".step-name .ent"), /white-space: normal/); + assert.match(rule(".step-name .ent"), /overflow-wrap: anywhere/); + assert.doesNotMatch(rule(".step-name .ent"), /ellipsis/); + // A branch name in a sentence is one word to the reader. + assert.match(rule(".nobreak"), /white-space: nowrap/); + // A test row told apart by a long place wraps rather than pushing its result off a narrow window. + assert.match(css, /\.rows-item:has\(\.name-qualifier\) \{ flex-wrap: wrap;/); + let browser = fixture({ hash: "#/branches" }); + browser.data["/api/branches"] = { branches: [{ name: "main", active: true, default: true }, { name: "feat/payload-size-helper", active: false }, { name: "feat/workflow-driver", active: false }] }; + await flush(); + assert.deepEqual(browser.node("branches-lede").querySelectorAll(".nobreak").map((node) => node.textContent), ["main", "feat/payload-size-helper", "feat/workflow-driver"]); + assert.equal(browser.node("branches-lede").textContent, "You're on main. Two other branches: feat/payload-size-helper and feat/workflow-driver."); + // Comparing names the branch you are on as that, never as Git's word for it. + browser.node("branches-compare-toggle").click(); await flush(); + assert.deepEqual([browser.node("workspace-range-head").value, browser.node("workspace-range-head").getAttribute("placeholder")], ["", "The current branch"]); + browser.node("workspace-range-base").value = "feat/workflow-driver"; + browser.node("workspace-range-base").closest("form").emit("submit"); await flush(); + assert.equal(browser.location.hash, "#/review/r/feat%2Fworkflow-driver..main", "an empty To is the branch you are on"); + browser = fixture({ hash: "#/review/r/main..HEAD" }); await flush(); + assert.equal(browser.node("review-head").querySelector("h1").textContent, "The current branch against main"); + // A step's sentence says its counts as numerals, a lower bound included. + browser = fixture({ hash: "#/review/w/1" }); + browser.data["/api/guide"] = working({ modified: 1, recorded_consumers: 1, recorded_tests: 3, consumers_may_need_review: 1, completeness: { state: "pending", pending_entities: 2 } }, [ + changed(2, "want_bytes", { connections: connected([relying(10, "Serializer.dumps")], [relying(20, "test_a", { relation: "tests" }), relying(21, "test_b", { relation: "tests" }), relying(22, "test_c", { relation: "tests" })]), consumer_verdict: { verdict: "may_need_review", reason: "body_changed", consumers: 1 } })]); + await flush(); + assert.equal(browser.main().querySelector(".step-verdict").textContent, "Its body changed. Its signature did not. At least 1 entity relies on it and did not change here. At least 3 tests reach it."); + // Where Kin's record of a converted repository starts is said under its oldest change, on a line of its own, never as one more fact after a dot. + browser = fixture({ hash: "#/history", session: { history: { state: "partial", admitted: 1, total: 4673 } } }); + await flush(); + const oldest = browser.node("changes-results").querySelectorAll("a.change-row").at(-1); + assert.equal(oldest.querySelector(".change-start").textContent, "Kin's record starts here, with the repository as it was"); + assert.doesNotMatch(oldest.querySelector(".change-meta").textContent, /record starts here/); +}); + +test("the tabs scroll inside their own box, so a narrow window never grows sideways", () => { + const css = fs.readFileSync(path.join(assets, "style.css"), "utf8"); + const rule = (selector) => css.split("\n").find((line) => line.startsWith(`${selector} {`) && line.endsWith("}"))?.slice(selector.length + 2, -1) ?? ""; + assert.match(rule(".tabs"), /overflow-x: auto/); + assert.match(rule(".tabs"), /max-width: 100%/); + assert.match(rule(".tab"), /flex: none/); + assert.doesNotMatch(rule(".tab"), /margin-bottom: -/, "a tab's line is drawn inside the scroll box, where it is not clipped"); + assert.match(css, /\.app \{ grid-template-columns: minmax\(0, 1fr\); grid-template-rows: auto 1fr; \}/, "on a narrow window a short page never stretches the bar above it"); +}); + +test("counts say at least where the summary says they can miss callers, and a search's page size is not a count of matches", async () => { + const browser = fixture({ hash: "#/explore" }); + const overview = browser.data["/api/workspace/overview"]; + browser.data["/api/workspace/overview"] = { ...overview, counts: { ...overview.counts, relationships_bound: "at_least" }, most_relied_on: { ...overview.most_relied_on, bound: "at_least" } }; + await flush(); + assert.match(browser.node("explore-lede").textContent, /^216 entities and at least 602 connections\./); + assert.deepEqual(browser.node("explore-list").querySelectorAll(".entity-side").map((node) => node.textContent), ["at least 23 rely on it", "at least 22 rely on it"]); + const exact = fixture({ hash: "#/explore" }); await flush(); + assert.deepEqual(exact.node("explore-list").querySelectorAll(".entity-side").map((node) => node.textContent), ["23 rely on it", "22 rely on it"], "an unsaid bound is not guessed"); + // Twenty rows are the twenty asked for, not how many matched. + const rows = Array.from({ length: 20 }, (_, i) => ({ id: uuid(100 + i), name: `match_${i}`, kind: "function", container: { id: uuid(1), name: "encoding", kind: "module" } })); + browser.data["/api/workspace/ask"] = { schema: "kin.workspace.ask.v1", mode: "meaning_and_name", results: rows, total: 20 }; + browser.type("explore-input", "request id"); await browser.advance(300); + assert.equal(browser.node("explore-results").querySelector(".result-head").textContent, "Best matchesThe closest 20"); + browser.data["/api/workspace/ask"] = { schema: "kin.workspace.ask.v1", mode: "name_only", results: rows, total: 20, names: { ok: true, total: 37 } }; + browser.type("explore-input", "match"); await browser.advance(300); + assert.equal(browser.node("explore-results").querySelector(".result-head").textContent, "By name20 of 37"); + // Home's repository card says the same of its connections. + const hub = fixture({ hub: true }); + hub.data[under(NOTES, "/api/workspace/overview")] = { ...summaryOf({ entities: 40, relationships: 51 }), counts: { ok: true, entities: 40, relationships: 51, relationships_bound: "at_least" } }; + await flush(); await flush(); + assert.deepEqual(repoCards(hub).find((card) => card.name === "notes").stats, ["40 entities", "≥51 connections"]); +}); + +test("a lower bound that starts a sentence is capitalized, and nothing is said to be at least zero", async () => { + const browser = fixture({ hash: "#/review/w/1" }); + browser.data["/api/guide"] = working({ modified: 2, recorded_consumers: 1 }, [ + changed(2, "want_bytes", { connections: connected([relying(10, "Serializer.dumps")]), consumer_verdict: { verdict: "needs_no_update", reason: "comments_or_layout_only", consumers: 1 } }), + changed(7, "quiet")], { summary: { added: 0, modified: 2, removed: 0, recorded_consumers: 1, recorded_tests: 0, consumers_not_updated: 0, completeness: { state: "not_settled", pending_entities: 2 } } }); + await flush(); + assert.match(stepOf(browser).verdict, /^Its body changed\. Its signature did not\. At least 1 entity relies on it and needs no update\./); + assert.doesNotMatch(browser.text(), /\. at least/); + const alone = fixture(); + alone.data["/api/guide"] = working({ modified: 1 }, [changed(7, "quiet")], { summary: { added: 0, modified: 1, removed: 0, recorded_consumers: 0, recorded_tests: 0, completeness: { state: "not_settled", pending_entities: 1 } } }); + alone.refresh(); await flush(); + assert.equal(picture(alone.node("work-flight")).label, "1 changed entity, and nothing Kin found relies on it from outside"); +}); + +test("an uncommitted entity names the agent that reported it, and otherwise says no agent did, never guessing it is yours", async () => { + const solo = fixture({ hash: "#/review/w/1" }); + // A server that lists no agent sessions has only seen none lately: that is no evidence of who edited. + solo.data["/api/workspace/agents"] = agentsPayload({ agents: [], earlier: [] }); + solo.data["/api/guide"] = working({ modified: 1 }, [changed(2, "want_bytes")]); + await flush(); + assert.equal(stepOf(solo).place, "Function in encoding · No agent reported this edit"); + solo.navigate("uncommitted"); await flush(); + assert.match(solo.node("work-lede").textContent, /No agent reported writing it, so Kin cannot say whether it is yours or an agent's\./); + assert.doesNotMatch(solo.text(), /your edit|are yours\./); + const shared = fixture(); + shared.data["/api/guide"] = working({ modified: 2 }, [changed(2, "want_bytes", { authored_by: [{ name: "Claude Code", session: "s", at: secondsAgo(5), through: "client" }] }), changed(7, "quiet")], + { summary: { added: 0, modified: 2, removed: 0, recorded_consumers: 0, recorded_tests: 0, authors: [{ name: "Claude Code", session: "s", entities: 1, through: "client" }] } }); + shared.refresh(); await flush(); + assert.match(shared.node("work-lede").textContent, /Claude Code changed 1 entity\. No agent reported the other one\./); + shared.navigate("review/w/1"); await flush(); + assert.equal(stepOf(shared).place, "Function in encoding · changed by Claude Code"); +}); + +test("what relies on a changed entity is named by the relation and by what each entity is", async () => { + const browser = fixture({ hash: "#/review/w/1" }); + const consumer = (n, name, kind, relation) => ({ ...relying(n, name, { relation }), entity: { id: uuid(n), name, kind, role: "source", within: "module route" } }); + browser.data["/api/guide"] = working({ modified: 1, recorded_consumers: 4, consumers_may_need_review: 4 }, [changed(2, "getGenReqId", { + connections: connected([consumer(10, "route", "module", "uses_type"), consumer(11, "buildRouting", "function", "uses_type"), consumer(12, "fourOhFour", "function", "calls"), consumer(13, "fastify", "module", "imports")]), + consumer_verdict: { verdict: "may_need_review", reason: "body_changed", consumers: 4 } })]); + await flush(); + const boxes = picture(stepOf(browser).relies).columns[1].boxes.map(([name, how]) => [name, how]); + assert.deepEqual(boxes, [["route", "imports it"], ["buildRouting", "refers to it"], ["fourOhFour", "calls it"], ["fastify", "imports it"]], "a module that imports a function never uses its type"); + const typed = fixture({ hash: "#/review/w/1" }); + typed.data["/api/guide"] = working({ modified: 1, recorded_consumers: 1, consumers_may_need_review: 1 }, [changed(3, "Signer", { kind: "class", connections: connected([consumer(14, "make_signer", "function", "uses_type")]), consumer_verdict: { verdict: "may_need_review", reason: "body_changed", consumers: 1 } })]); + await flush(); + assert.deepEqual(picture(stepOf(typed).relies).columns[1].boxes.map(([name, how]) => [name, how]), [["make_signer", "uses its type"]]); +}); + +test("Kin's drafted message names what the commit holds as the choice changes, and joins its clauses once", async () => { + const browser = await reviewing("#/review/w/1", { capabilities: { entity_commit: true } }); + browser.navigate("review/w/2"); await flush(); + await mark(browser, "flag"); + browser.navigate("review/w/done"); await flush(); + browser.button("Commit anyway").click(); await flush(); + assert.equal(browser.node("commit-message").textContent, "Change want_bytes and quiet, add base64_encode and remove old_helper", "every entity, in one sentence with one final and"); + browser.navigate("review/w/1"); await flush(); await mark(browser, "fine"); + browser.navigate("review/w/done"); await flush(); + assert.equal(browser.node("commit-message").textContent, "Change want_bytes", "once the commit holds only what looks right, the draft says so"); + const toggle = browser.node("commit-only-fine"); toggle.checked = false; toggle.emit("change"); + assert.equal(browser.node("commit-message").textContent, "Change want_bytes and quiet, add base64_encode and remove old_helper"); + browser.type("commit-message", "My own words"); + const again = browser.node("commit-only-fine"); again.checked = true; again.emit("change"); + assert.equal(browser.node("commit-message").textContent, "My own words", "the reader's own message is never replaced"); + // Three names in one clause read "a, b and c", never "a and b and c". + const three = fixture({ hash: "#/review/w/done" }); + three.data["/api/guide"] = working({ modified: 3 }, [changed(2, "want_bytes"), changed(5, "payload_size"), changed(7, "quiet")]); + await flush(); + three.button("Commit anyway").click(); await flush(); + assert.equal(three.node("commit-message").textContent, "Change want_bytes, payload_size and quiet"); +}); + +test("a chosen entity tab is brought inside the tab row's own scroll box", async () => { + const browser = fixture({ hash: `#/entity/${uuid(9)}` }); + browser.data["/api/workspace/entity"] = () => entityV2(uuid(9)); + await flush(); + const history = browser.node("entity-tab-history"); + history.click(); + assert.equal(browser.document.scrolledTo, browser.node("entity-tab-history")); + assert.equal(browser.document.activeElement, browser.node("entity-tab-history")); +}); + +test("an entity's history marks where Kin's record starts while the repository's history is partial", async () => { + const id = uuid(9); + const browser = fixture({ hash: `#/entity/${id}` }); + browser.data["/api/workspace/entity"] = () => entityV2(id, { history: { ok: true, items: [{ change_id: "import-1", title: "docs: clarify the parser", author: "Dana Reader", time: "2026-03-11T10:57:14Z" }], total: 1, shown: 1, record_start: "import-1" } }); + await flush(); + const text = browser.node("entity-history").textContent; + assert.match(text, /Kin's record starts here, with the repository as it was/); + assert.match(text, /earlier changes to this entity are not listed yet/); + browser.data["/api/workspace/entity"] = () => entityV2(id); + browser.refresh(); await flush(); + assert.doesNotMatch(browser.node("entity-history").textContent, /record starts here|not listed yet/, "a complete history carries no marker"); +}); + +test("a pull still being checked says so, then appears once Kin knows it can work", async () => { + const payload = { remotes: [{ name: "origin", kind: "git", can_pull: true, can_push: false, can_fetch: false, standing: { state: "behind", summary: "origin has 2 changes this branch does not." } }] }; + const browser = fixture({ hash: "#/branches", capabilities: { remote_pull: false, remote_pull_checking: true, remote_pull_unavailable_reason: "Kin is checking whether this branch can update from Git." } }); + browser.data["/api/workspace/remotes"] = payload; + let answer = { remote_pull: false, remote_pull_unavailable_reason: "Kin is checking whether this branch can update from Git.", remote_pull_checking: true }; + browser.data["/api/workspace/session/pull"] = () => answer; + await flush(); + assert.equal(browser.button("Pull"), undefined); + assert.ok(browser.button("Checking…"), "the pull says it is being checked rather than disappearing"); + answer = { remote_pull: true, remote_pull_unavailable_reason: null, remote_pull_checking: false, remote_fetch: false }; + browser.refresh(); await flush(); + assert.ok(browser.button("Pull"), "the pull appears once the check answers"); + assert.ok(browser.count("/api/workspace/session/pull") >= 1); +}); diff --git a/scripts/zero-file-search-allowlist.json b/scripts/zero-file-search-allowlist.json index d62ef0891..3e488e17a 100644 --- a/scripts/zero-file-search-allowlist.json +++ b/scripts/zero-file-search-allowlist.json @@ -1,5 +1,17 @@ { "allowlist": [ + { + "file": "crates/kin-core/src/git_update.rs", + "reason": "The typed repository_metadata accessor returns the persisted Git aliases, refs and workspace state held by RepositoryAuthorityState. Its sole authority.metadata() call reads graph-owned repository metadata, never host filesystem metadata. Only that exact expression is pinned; the Git ingestion preparation and every other call remain scanned.", + "owner": "kin-core", + "expiration": "2027-10-13", + "allow_match": [ + { + "expr": "authority.metadata()", + "count": 1 + } + ] + }, { "file": "crates/kin-cli/src/commands/recovery_carrier.rs", "reason": "Explicit recovery input/output boundary. These helpers copy and verify a caller-selected frozen repository state into an outside carrier or an absent restore destination. Filesystem hashes establish artifact completeness, never semantic query answers; native authority, roots and referenced content are validated through KinDB before publication. Only the recovery boundary functions are exempt. The same checked copy also moves a prepared store into an absent `.kin` beside another checkout of the same Git state (`reuse_copy`), with its durability batched; the copied authority is validated through KinDB against the roots the donor was checked at before it is published.", @@ -442,10 +454,11 @@ }, { "file": "crates/kin-daemon-spawn/src/runtime_authority.rs", - "reason": "Shared per-repository runtime capability boundary. try_acquire_repository_runtime_authority canonicalizes the exact .kin root before it opens the never-unlinked daemon.lock and daemon.lifecycle inodes, so authority for one repository cannot open another. It writes only the caller-owned process stamp after exclusive acquisition and answers no repository query. Two exact Drop expressions check the acquiring PID and unlock that owned descriptor after clearing its stamp; a forked child must not release its parent's lock. Owner-stamp validation, retry policy, public wrappers, the rest of Drop, and all new filesystem access remain scanned. No whole-file or whole-Drop exemption.", + "reason": "Shared per-repository runtime capability boundary. try_acquire_repository_runtime_authority canonicalizes the exact .kin root before it opens the never-unlinked daemon.lock and daemon.lifecycle inodes, so authority for one repository cannot open another. It writes only the caller-owned process stamp after exclusive acquisition and answers no repository query. repository_runtime_authority_is_held opens the same daemon.lock inode, never creating it, and only asks whether another process holds it: it takes the lock when it is free and releases it at once, writes nothing and answers no repository query. Two exact Drop expressions check the acquiring PID and unlock that owned descriptor after clearing its stamp; a forked child must not release its parent's lock. Owner-stamp validation, retry policy, public wrappers, the rest of Drop, and all new filesystem access remain scanned. No whole-file or whole-Drop exemption.", "owner": "kin-daemon-spawn", "expiration": "2026-12-31", "allow_fn": [ + "repository_runtime_authority_is_held", "try_acquire_repository_runtime_authority" ], "allow_match": [ @@ -761,7 +774,7 @@ }, { "file": "crates/kin-daemon/src/bin/kin-daemon.rs", - "reason": "Daemon process entry point. The pins cover process exits, the directory probe recognizing a named Kin store, and canonical path equality enforcing that an explicitly named external store serves the requested checkout. These binding checks precede any graph consult and do not read repository content or answer a semantic query. Pinned by expression with exact counts rather than by function, because the four functions involved are most of this binary and exempting their bodies would leave more unscanned than the primitives justify. The eleventh failure exit refuses invalid repository model configuration before startup; it cannot launch a subprocess or supply a semantic answer.", + "reason": "Daemon process entry point. The pins cover process exits, the directory probe recognizing a named Kin store, and canonical path equality enforcing that an explicitly named external store serves the requested checkout. These binding checks precede any graph consult and do not read repository content or answer a semantic query. Pinned by expression with exact counts rather than by function, because the four functions involved are most of this binary and exempting their bodies would leave more unscanned than the primitives justify. The eleventh failure exit refuses invalid repository model configuration before startup; it cannot launch a subprocess or supply a semantic answer. The three exact exit-2 sites refuse invalid local runtime selection, installed-pair authority, or home-namespace capture before profiles, models or graph stores load. The added filesystem expressions inspect only the selected installed daemon executable: its regular non-symlink type and canonical equality with the running executable. They neither read repository source nor execute a subprocess. The process id pin asks only whether this daemon is its container's init, which decides whether it may hand its store to a successor; it reads nothing and starts nothing.", "owner": "kin-daemon", "expiration": "2026-12-31", "allow_match": [ @@ -775,7 +788,16 @@ }, "process::exit(exit_code);", "if names_store && path.is_dir() {", - "let same_tree = match (layout.working_dir().canonicalize(), path.canonicalize()) {" + "let same_tree = match (layout.working_dir().canonicalize(), path.canonicalize()) {", + { + "expr": "process::exit(2);", + "count": 3 + }, + "std::fs::symlink_metadata(&expected)", + "if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {", + "std::fs::canonicalize(&expected)", + ".and_then(std::fs::canonicalize)", + "process::id() != 1" ] }, { @@ -790,6 +812,18 @@ } ] }, + { + "file": "crates/kin-daemon/src/entity_keep.rs", + "reason": "Repository authority lease accessor, not a filesystem probe. Both `.metadata()` expressions are called on a read lease of repository-v6 authority and return graph-owned authority metadata: `workspace_in` reads this working copy's workspace state, and `receipt_in` reads the receipt an operation left, which is how a commit of chosen entities and an entity restore answer a request that already landed. The name is shared with the filesystem metadata probe in the deny set, which is the only reason they appear here. The module carries no raw filesystem primitive: every body it reads comes from repository CAS or ingestion staging through `source_cas`, and the working copy is written only by the kin-core workspace transition a restore publishes through. Pinned by exact occurrence count so every planning path stays scanned and any further `.metadata()` call, filesystem or not, fails the guard until it is justified.", + "owner": "kin-daemon", + "expiration": "2027-04-09", + "allow_match": [ + { + "expr": ".metadata()", + "count": 2 + } + ] + }, { "file": "crates/kin-daemon/src/repository_checkout.rs", "reason": "Repository authority lease accessor, not a filesystem probe. Every `.metadata()` in this module is called on a repository-v6 authority lease and returns graph-owned authority metadata: the workspace set, ref state, and operation receipts. It shares a method name with the filesystem metadata probe in the shared deny set, which is the only reason it appears here. The module carries no raw filesystem primitive at all: no read, open, traversal, existence probe, or subprocess. Pinned by exact occurrence count instead of exempting the surrounding function bodies, so every planning and replay path in the module stays scanned and any new `.metadata()` call, filesystem or not, fails the guard until it is re-justified. Two of the four are reached through a frozen authority handle rather than a lease binding, which is the same graph-owned metadata behind a different accessor.", @@ -1331,6 +1365,12 @@ "owner": "kin-cli", "expiration": "2027-04-23" }, + { + "file": "crates/kin-cli/src/commands/kin_copies.rs", + "reason": "Install-inventory boundary for one Kin per machine. It finds every executable named kin, kin-daemon or kin-mcp on PATH, in the Cargo home, npm global prefixes, Homebrew and /usr/local/bin, and classifies each from install metadata: directory entries and links, an npm package.json, the Cargo install record, Cargo's target-directory markers, and the static build identity an executable carries in its own bytes. It reads the process table for running kin-daemon executables, and removes a copy only through that copy's own uninstall (npm, cargo, brew) or by deleting a standalone release file, when a person or an explicit doctor flag asks. Its subject is the installation, the same boundary as setup.rs and update.rs; it reads no repository content and answers no locate, search, context, trace, review or xref query.", + "owner": "kin-cli", + "expiration": "2027-04-30" + }, { "file": "crates/kin-cli/src/commands/update.rs", "reason": "The updater: downloads, verifies and swaps the installed binaries, holding file locks over the install root. 359 sites, all install-time IO, and it resolves no semantic query. Brought under the guard by FIR-2282 on 2026-09-04, when the command surface stopped being opt-in. This module was never scanned before, by nobody's decision: it simply was not named in QUERY_COMMANDS. The entry preserves that effective scope while making it accountable, and the expiry is when it has to be narrowed to pins or defended again.", @@ -1445,6 +1485,18 @@ "owner": "kin-cli", "expiration": "2027-05-21" }, + { + "file": "crates/kin-cli/src/commands/entity_work.rs", + "reason": "Repository authority lease accessor, not a filesystem probe. The one `.metadata()` expression is in `restored_by_operation`, called on a read lease of repository-v6 authority to learn whether an operation left a receipt, which is how an entity restore whose answer was lost is settled without sending it again. The name is shared with the filesystem metadata probe in the deny set. The module is otherwise request and response types and the daemon transport for entity work, and reads no repository content. Pinned by exact occurrence count so the rest of the module stays scanned.", + "owner": "kin-cli", + "expiration": "2027-04-09", + "allow_match": [ + { + "expr": ".metadata()", + "count": 1 + } + ] + }, { "file": "crates/kin-cli/src/commands/commit_progress.rs", "reason": "What `kin commit` shows while the daemon commits, per its own doc. Progress rendering and the daemon's own progress file, never repository content. Brought under the guard by FIR-2282 on 2026-09-04, when the command surface stopped being opt-in. This module was never scanned before, by nobody's decision: it simply was not named in QUERY_COMMANDS. The entry preserves that effective scope while making it accountable, and the expiry is when it has to be narrowed to pins or defended again.", @@ -2515,7 +2567,7 @@ }, { "file": "crates/kin-db/src/storage/repository.rs", - "reason": "Pinned to the functions that hold its filesystem primitives rather than exempted whole, so every other function in the file, including any added later, stays graded. It moved into crates/ from its own repository on 2026-09-11 and is graded by this checker for the first time. The pinned functions handle the repository store's on-disk layout, publication and authority records. Every query this file answers (ref and tree resolution, name lookup, the locate cache) stays graded, which is where the thesis forbids a raw-file fallback. Review 2026-09-20: The stale metadata pins are now individually attributed: three multiline typed authority accessors select the exact workspace/Native parent; two self.metadata calls bind/materialize a workspace; exact current/next accesses check schema/history; exact lease expressions resolve parent and frozen overlay. They read RepositoryAuthorityState, not filesystem metadata. Counts are measured after existing function scopes and longest exact matches, so no occurrence budget comes from tests or masked bodies. Review 2026-09-23: the re-derivation commit adds one comparison of the current and next authority schema versions, so a successor that raises the schema is persisted whole rather than offered to the frame encoder; both reads are typed RepositoryAuthorityState metadata. Review 2026-09-24: the re-derivation commit's payment adds one typed read, `next.metadata()`, handed to `rederived_by` so a workspace is paid only where the successor records the verifier's witness naming this commit's operation; it is RepositoryAuthorityState metadata, not a filesystem probe. Review 2026-09-27: carrying_workspace_base reads the exact admitted predecessor through `let metadata = current.metadata();`; this typed authority accessor supplies the held change and tree-hash binding and performs no filesystem query.", + "reason": "Pinned to the functions that hold its filesystem primitives rather than exempted whole, so every other function in the file, including any added later, stays graded. It moved into crates/ from its own repository on 2026-09-11 and is graded by this checker for the first time. The pinned functions handle the repository store's on-disk layout, publication and authority records. Every query this file answers (ref and tree resolution, name lookup, the locate cache) stays graded, which is where the thesis forbids a raw-file fallback. Review 2026-09-20: The stale metadata pins are now individually attributed: three multiline typed authority accessors select the exact workspace/Native parent; two self.metadata calls bind/materialize a workspace; exact current/next accesses check schema/history; exact lease expressions resolve parent and frozen overlay. They read RepositoryAuthorityState, not filesystem metadata. Counts are measured after existing function scopes and longest exact matches, so no occurrence budget comes from tests or masked bodies. Review 2026-09-23: the re-derivation commit adds one comparison of the current and next authority schema versions, so a successor that raises the schema is persisted whole rather than offered to the frame encoder; both reads are typed RepositoryAuthorityState metadata. Review 2026-09-24: the re-derivation commit's payment adds one typed read, `next.metadata()`, handed to `rederived_by` so a workspace is paid only where the successor records the verifier's witness naming this commit's operation; it is RepositoryAuthorityState metadata, not a filesystem probe. Review 2026-09-27: carrying_workspace_base reads the exact admitted predecessor through `let metadata = current.metadata();`; this typed authority accessor supplies the held change and tree-hash binding and performs no filesystem query. Review 2026-10-13: frozen-frame and graph-cache integration adds typed authority reads in inherit_workspace_graph, inherited_workspace_graph, operation_identities, lineage_identities and qualify_binding_history_decision_with. The latter now passes next.metadata() to validate_metadata_with together with cached operation identities, so remove the obsolete validate_metadata expression. The seven remaining multiline or next metadata sites belong to workspace_graph_snapshot, inherit_workspace_graph, candidate_frame, workspace_native_admission_snapshot, commit_rederived_repository_transaction and qualify_binding_history_decision_with. The five self.metadata() sites bind or materialize workspace graphs and hash or inspect their operation lineage. All receivers are RepositoryAuthorityState values; none reads filesystem metadata. Counts were verified over the checker's exact scannable lines after function masks and longest pin matches.", "owner": "kin-db", "expiration": "2027-03-22", "allow_fn": [ @@ -2538,19 +2590,18 @@ "let metadata = current.metadata();", { "expr": ".metadata()", - "count": 5 + "count": 7 }, { "expr": "current.metadata().schema_version", "count": 2 }, - "super::binding_history::validate_metadata(next.metadata())?", "target_change_id(lease.metadata(), target)", "lease.metadata(),", "local_overlay_for_workspace(lease.metadata(), workspace)?", { "expr": "self.metadata()", - "count": 2 + "count": 5 }, "self.metadata().roots.generation" ] @@ -3069,7 +3120,7 @@ }, { "file": "crates/kin-core/src/history_attach.rs", - "reason": "History admission and atomic store replacement boundary. Exact pins create and reap repository-identified derivation stages, read the versioned attach journal, refuse replacement during merge/stash state, move store entries and persist/fsync the journal. Directory walks are confined to stage and Kin store entries, never semantic source search. The metadata() pin is AuthorityReadLease graph ref state, not filesystem metadata. Every unpinned expression remains scanned.", + "reason": "History admission and atomic store replacement boundary. Exact pins create and reap repository-identified derivation stages, read the versioned attach journal, wait while the store holds open work (a stash or an entity draft, found by listing the store's own directory for them), move store entries and persist/fsync the journal. Directory walks are confined to stage and Kin store entries, never semantic source search. The metadata() pin is AuthorityReadLease graph ref state, not filesystem metadata. Every unpinned expression remains scanned.", "owner": "kin-core", "expiration": "2027-04-09", "allow_match": [ @@ -3089,13 +3140,14 @@ }, "std::fs::remove_file(&journal_path)", "std::fs::remove_dir_all(&journal.stage_parent)", - "std::fs::symlink_metadata(layout.merge_state_path())", - "std::fs::read_dir(layout.stashes_dir())", { "expr": ".metadata()", "count": 1 }, - "std::fs::read_dir(dir)", + { + "expr": "std::fs::read_dir(dir)", + "count": 2 + }, "std::fs::symlink_metadata(path)", "metadata.is_dir()", "std::fs::remove_dir_all(path)", @@ -3200,25 +3252,34 @@ }, { "file": "crates/kin-daemon/src/history_schedule.rs", - "reason": "History worker deployment boundary. The exact probe checks the Kin executable beside the current daemon executable before starting history derivation. This is process setup, not a repository content probe or semantic fallback.", + "reason": "History worker deployment and daemon handover boundary. The exact probe checks the Kin executable beside the current daemon executable before starting history derivation. The process pins start this daemon's own successor, the same program with the same arguments, environment and output and no input, as a daemon that stopped serving to hand its store over ends. This is process setup, not a repository content probe or semantic fallback.", "owner": "kin-daemon", "expiration": "2027-08-06", "allow_match": [ - "candidate.is_file()" + "candidate.is_file()", + "std::process::Command::new(program)", + "std::process::Stdio::null()" ] }, { "file": "crates/kin-io/src/confined.rs", - "reason": "The capability crate's own directory handle, the boundary every kin-io capability is built on. Each body resolves its path beneath a cap-std directory opened from a Grant, so none can reach outside the directory it was handed: replace and replace_unflushed take the process id for a temporary name beside the record they replace, kind reads one entry's type without following a link, names lists one directory, and read_bounded asks an entry it already opened for its length before reading it. The is_symlink, is_dir and is_file calls in kind read a FileType already returned, not the filesystem. Function-scoped so any other primitive added to this module is reported.", + "reason": "The capability crate's own directory handle, the boundary every kin-io capability is built on. Each body resolves its path beneath a cap-std directory opened from a Grant, so none can reach outside the directory it was handed: replace and replace_unflushed take the process id for a temporary name beside the record they replace, kind reads one entry's type without following a link, names lists one directory, and read_bounded asks an entry it already opened for its length before reading it. The is_symlink, is_dir and is_file calls in kind read a FileType already returned, not the filesystem. Function-scoped so any other primitive added to this module is reported. The new expression pins cover only regular-entry type checks and no-follow metadata beneath the retained cap-std directory, including their method declaration; no new function body is exempted. Each body resolves its path beneath a cap-std directory opened from a Grant, so none can reach outside the directory it was handed: replace and replace_unflushed take the process id for a temporary name beside the record they replace, kind reads one entry's type without following a link, modified reads when one entry or the directory itself last changed without following a link and without opening it, names lists one directory, and read_bounded asks an entry it already opened for its length before reading it.", "owner": "kin-io", "expiration": "2027-04-16", "allow_fn": [ "replace", "kind", + "modified", "names", "read_bounded", "create_file", "replace_unflushed" + ], + "allow_match": [ + "self.dir.symlink_metadata(relative)?.is_file()", + "file.metadata()?.is_file()", + "pub(crate) fn symlink_metadata(&self, relative: &Path) -> io::Result", + "self.dir.symlink_metadata(relative)" ] }, { @@ -3236,11 +3297,15 @@ }, { "file": "crates/kin-io/src/store.rs", - "reason": "The capability crate's store and host directory types. HostDir::create_file hands back an opened std::fs::File for a writer that streams into it, such as a transcript; the entry is created beneath the directory the HostDir was opened on, so it cannot land outside it. Function-scoped so any other primitive added to this module is reported.", + "reason": "The capability crate's store and host directory types. HostDir::create_file hands back an opened std::fs::File for a writer that streams into it, such as a transcript; the entry is created beneath the directory the HostDir was opened on, so it cannot land outside it. Function-scoped so any other primitive added to this module is reported. The new expression pins cover only the HostDir no-follow metadata declaration and delegation to its confined directory; no new function body is exempted.", "owner": "kin-io", "expiration": "2027-04-16", "allow_fn": [ "create_file" + ], + "allow_match": [ + "pub fn symlink_metadata(&self, name: impl AsRef) -> io::Result", + "self.inner.symlink_metadata(name.as_ref())" ] }, { @@ -3256,12 +3321,13 @@ }, { "file": "crates/kin-cli/src/commands/workspace_home.rs", - "reason": "Local human workspace add-repository boundary. resolve_location canonicalizes only a directory the reader named on the page that lists repositories, so that page shows one location and runs one operation per location. run_child starts the two processes that page may start, each through a kin-io Spawner: this same Kin binary with a fixed init or clone argument vector and the reader's location, so adding a repository is Kin's own conversion, and Git, to count a project's commits before it is added. Neither function reads source bytes, searches a directory or supplies a semantic entity, source, relationship, review or merge answer; all such reads are forwarded to an identity-verified daemon. No whole-module exemption.", + "reason": "Local human workspace add-repository boundary. resolve_location canonicalizes only a directory the reader named on the page that lists repositories, or one look_around found for that page to offer, so the page shows one location and runs one operation per location. run_child starts the two processes that page may start, each through a kin-io Spawner: this same Kin binary with a fixed init or clone argument vector and the reader's location, so adding a repository is Kin's own conversion, and Git, to count a project's commits before it is added. look_around finds the projects that page offers to add, so a reader picks one instead of typing where it is: through one kin-io WorktreeSource opened on the reader's home directory it reads directory names and change times in that directory and, one and two levels deep, in the directories there that are named like a code root. It knows a project by the .git or .kin directory at its top or by the name of a build manifest there and opens nothing inside one, follows no link, skips hidden directories, dependency and build trees and the folders an operating system keeps documents and media in, and is bounded in the directories it lists and in time. What it finds is a list of locations a reader may add and nothing else. None of these functions reads source bytes, ranks or searches content, or supplies a semantic entity, source, relationship, review or merge answer; all such reads are forwarded to an identity-verified daemon. No whole-module exemption.", "owner": "kin-cli", "expiration": "2027-04-13", "allow_fn": [ "resolve_location", - "run_child" + "run_child", + "look_around" ] }, { @@ -3272,6 +3338,57 @@ "allow_fn": [ "make_editor_uri" ] + }, + { + "file": "crates/kin-cli/src/commands/runtime.rs", + "reason": "Managed-install admission and runtime-diagnostics boundary. The exact pins resolve selected executable paths, inspect only selected install entries and native update policy/restart metadata, inspect already-opened executable handles for bounded hashing, and declare the owned metadata-probe process handles/imports. The probes use fixed bench-meta --runtime-identity-only, daemon --compat-json or legacy --version arguments; managed-entry delegation forwards unchanged arguments only to the admitted canonical executable. None reads repository source, supplies graph-query results or provides a raw-filesystem fallback. Exact expression/count pins keep neighboring reads, additional subprocess sites and every other line scanned.", + "owner": "kin-cli", + "expiration": "2027-04-30", + "allow_match": [ + "use std::process::Stdio;", + "absolute.canonicalize()", + "metadata.symlink_metadata(\"update.toml\")", + "!entry.is_file()", + "metadata.symlink_metadata(\"update-restart-ack-required.json\")", + "directory.symlink_metadata(name)", + "!metadata.file_type().is_symlink()", + "metadata.is_dir()", + "metadata.is_file()", + { + "expr": "use std::os::unix::process::CommandExt;", + "count": 2 + }, + "file.metadata()?.is_file()", + "file.metadata()?.len()", + "fn stop_probe(child: &mut std::process::Child)" + ] + }, + { + "file": "crates/kin-core/src/runtime_identity.rs", + "reason": "Local runtime identity metadata binds the selected Kin home directory to its canonical path and retained native object identity. The two exact canonicalization expressions capture and recheck only that administrative root; the exact process-id expression reports the current service incarnation. These metadata checks precede graph access and do not read repository contents or supply semantic answers. Every other expression remains scanned.", + "owner": "kin-core", + "expiration": "2027-02-26", + "allow_match": [ + "let canonical = home.canonicalize().map_err(refusal)?;", + "if home.canonicalize().map_err(refusal)? != canonical", + "pid: std::process::id()," + ] + }, + { + "file": "crates/kin-cli/src/daemon_client/succession.rs", + "reason": "Daemon process lifecycle and managed-generation succession boundary. runs_from_managed_generation canonicalizes only the peer daemon's executable path (or its parent directory when an update has already unlinked the replaced binary) and the configured managed Kin home directory so a client can tell whether a running daemon comes from an older generation of that managed install. These path comparisons precede graph access, never read repository content, and cannot produce, rank, fill or repair an answer to locate, search, context, trace, review, xref, or rename. Pinned by exact expression with counts so any additional filesystem primitive in this module fails the guard.", + "owner": "kin-cli", + "expiration": "2027-04-30", + "allow_match": [ + { + "expr": "path.canonicalize()", + "count": 1 + }, + { + "expr": "parent.canonicalize().map(|parent| parent.join(name))", + "count": 1 + } + ] } ] } diff --git a/server.json b/server.json index 4d59741d9..f7c71918b 100644 --- a/server.json +++ b/server.json @@ -8,12 +8,12 @@ "url": "https://github.com/kinlab-ai/kin", "source": "github" }, - "version": "0.8.6", + "version": "0.8.7", "packages": [ { "registryType": "npm", "identifier": "@kinlab/kin", - "version": "0.8.6", + "version": "0.8.7", "packageArguments": [ { "type": "positional", diff --git a/tests/integration/src/p10_acceptance.rs b/tests/integration/src/p10_acceptance.rs index fa7d04a48..52fe3563b 100644 --- a/tests/integration/src/p10_acceptance.rs +++ b/tests/integration/src/p10_acceptance.rs @@ -27,6 +27,7 @@ fn actor_crud() { identifier: "alice".into(), url: None, }], + agent_session: None, }; let assistant = Actor { @@ -34,6 +35,7 @@ fn actor_crud() { kind: ActorKind::Assistant, display_name: "Claude".into(), external_refs: vec![], + agent_session: None, }; store.create_actor(&human).unwrap(); @@ -77,12 +79,14 @@ fn delegation_lifecycle() { kind: ActorKind::Human, display_name: "Alice".into(), external_refs: vec![], + agent_session: None, }; let delegate = Actor { actor_id: ActorId::new(), kind: ActorKind::Assistant, display_name: "Claude".into(), external_refs: vec![], + agent_session: None, }; store.create_actor(&principal).unwrap(); @@ -127,6 +131,7 @@ fn approval_workflow() { kind: ActorKind::Human, display_name: "Bob".into(), external_refs: vec![], + agent_session: None, }; store.create_actor(&approver).unwrap(); @@ -168,12 +173,14 @@ fn audit_event_filtering() { kind: ActorKind::Human, display_name: "Alice".into(), external_refs: vec![], + agent_session: None, }; let actor_b = Actor { actor_id: ActorId::new(), kind: ActorKind::Assistant, display_name: "Claude".into(), external_refs: vec![], + agent_session: None, }; store.create_actor(&actor_a).unwrap(); store.create_actor(&actor_b).unwrap(); @@ -238,6 +245,7 @@ fn review_surfaces_unreviewed_agent_changes() { kind: ActorKind::Assistant, display_name: "Copilot".into(), external_refs: vec![], + agent_session: None, }; store.create_actor(&assistant).unwrap(); diff --git a/tests/integration/src/p8_acceptance.rs b/tests/integration/src/p8_acceptance.rs index cb8b3d555..4b4306643 100644 --- a/tests/integration/src/p8_acceptance.rs +++ b/tests/integration/src/p8_acceptance.rs @@ -41,6 +41,7 @@ fn make_annotation(body: &str, kind: AnnotationKind, entity: &Entity) -> Annotat authored_by: IdentityRef::human("test"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, } } @@ -491,6 +492,7 @@ fn work_relationships_and_work_targeted_annotations_are_queryable() { authored_by: IdentityRef::assistant("acceptance-test"), created_at: Timestamp::now(), staleness: StalenessState::Fresh, + review: None, }; graph.create_annotation(&annotation).unwrap(); graph diff --git a/tests/integration/src/provenance_chain.rs b/tests/integration/src/provenance_chain.rs index d943114c7..4d7ca80c4 100644 --- a/tests/integration/src/provenance_chain.rs +++ b/tests/integration/src/provenance_chain.rs @@ -27,12 +27,14 @@ fn delegation_chain_provenance() { kind: ActorKind::Human, display_name: "Alice (principal)".into(), external_refs: vec![], + agent_session: None, }; let delegate = Actor { actor_id: ActorId::new(), kind: ActorKind::Assistant, display_name: "Claude (delegate)".into(), external_refs: vec![], + agent_session: None, }; store.create_actor(&principal).unwrap(); store.create_actor(&delegate).unwrap(); @@ -130,18 +132,21 @@ fn multi_hop_delegation_chain() { kind: ActorKind::Human, display_name: "Alice".into(), external_refs: vec![], + agent_session: None, }; let bob = Actor { actor_id: ActorId::new(), kind: ActorKind::Human, display_name: "Bob".into(), external_refs: vec![], + agent_session: None, }; let claude = Actor { actor_id: ActorId::new(), kind: ActorKind::Assistant, display_name: "Claude".into(), external_refs: vec![], + agent_session: None, }; store.create_actor(&alice).unwrap(); store.create_actor(&bob).unwrap(); @@ -219,12 +224,14 @@ fn expired_delegation_still_queryable() { kind: ActorKind::Human, display_name: "Owner".into(), external_refs: vec![], + agent_session: None, }; let delegate = Actor { actor_id: ActorId::new(), kind: ActorKind::Service, display_name: "CI Bot".into(), external_refs: vec![], + agent_session: None, }; store.create_actor(&principal).unwrap(); store.create_actor(&delegate).unwrap();