diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57cb244d65..544d3b37fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,6 +191,23 @@ jobs: python3 scripts/test-rust-cache-lineage.py python3 scripts/check-rust-cache-lineage.py + # Packager unit tests. They do not need a release binary, so they run + # once here instead of once per release matrix target. + - name: Test release packagers + run: | + python3 scripts/test-check-packaged-lsp-bridge.py + python3 scripts/test-build-mcpb.py + python3 scripts/test-resolve-installed-binary.py + python3 scripts/test-check-packaged-mcp-stdio.py + python3 scripts/test-check-distribution-feature-wiring.py + python3 scripts/test-resolve-release-source-profile.py + python3 scripts/test-package-release-archive.py + python3 scripts/test-plan-release-recovery.py + python3 scripts/test-check-release-artifacts.py + bash scripts/test-check-distribution-clean-source.sh + bash scripts/test-check-distribution-snapshot.sh + bash scripts/test-check-distribution-reuse.sh + # Release guards: drift, safety, installer, PR integrity, bundle check. # Pull requests only; the release PR itself is exempt because it is # the one change allowed to move the version authorities. @@ -745,17 +762,11 @@ jobs: # `--tests` keeps the binaries in the dev-dependency graph the archive # is built from; a `--bins`-only build resolves 60 dependencies with # different features and compiles them a second time (see the Linux - # lane). - - name: Build workspace binaries and tests for the Windows test lane + # lane). The host-CLI example is the same `--workspace` resolution; + # a second cargo invocation would re-resolve it. + - name: Build workspace binaries, tests, and the host-CLI fixture shell: pwsh - run: cargo build --workspace --bins --tests --locked --profile perf --features tracedecay/test-helpers,tracedecay/search-eval - - # `--workspace`, not `-p tracedecay-cli`: the package selection decides - # feature unification, and the narrower one recompiles the code-index - # and extraction crates in a second configuration (see the Linux lane). - - name: Build Windows host-CLI test fixture - shell: pwsh - run: cargo build --workspace --example tracedecay-host-cli-fixture --locked --profile perf --features tracedecay/test-helpers,tracedecay/search-eval + run: cargo build --workspace --bins --tests --example tracedecay-host-cli-fixture --locked --profile perf --features tracedecay/test-helpers,tracedecay/search-eval # The controlled-workload Hotpath parity helpers are provisioned and # verified by the `hotpath-parity` job. Building them here would put a @@ -1047,6 +1058,11 @@ jobs: # forks on the local feature while hotpath::io! expands on the backend. cargo check --workspace --all-targets --features hotpath/hotpath --locked + # One graph walk per push. The release resolver repeats it on the tag it + # builds, since a release is dispatched without waiting on this run. + - name: Check the production feature graph + run: python3 scripts/check-production-feature-profile.py + # The configuration users actually receive. `scripts/resolve-release-source-profile.py` # resolves the release build to `--no-default-features --features production` # on the release profile, and nothing on a push compiled it: `debug-cli` is a diff --git a/.github/workflows/distribution-acceptance.yml b/.github/workflows/distribution-acceptance.yml index 5cd52d26f8..d1e64fcf60 100644 --- a/.github/workflows/distribution-acceptance.yml +++ b/.github/workflows/distribution-acceptance.yml @@ -57,19 +57,6 @@ jobs: echo "TRACEDECAY_SKIP_DASHBOARD_BUILD=1" >> "$GITHUB_ENV" echo "TRACEDECAY_DASHBOARD_BUNDLE_SHA256=${digest}" >> "$GITHUB_ENV" - - name: Validate portable distribution harnesses - run: | - python3 scripts/test-check-packaged-lsp-bridge.py - python3 scripts/test-build-mcpb.py - python3 scripts/test-resolve-installed-binary.py - python3 scripts/test-check-packaged-mcp-stdio.py - python3 scripts/test-check-distribution-feature-wiring.py - python3 scripts/test-resolve-release-source-profile.py - python3 scripts/test-package-release-archive.py - bash scripts/test-check-distribution-clean-source.sh - bash scripts/test-check-distribution-snapshot.sh - bash scripts/test-check-distribution-reuse.sh - # rust-toolchain.toml is the only compiler this job may install. See # release-beta.yml: floating stable fragments rust-cache's env key. - name: Resolve pinned toolchain @@ -96,6 +83,16 @@ jobs: mold-version: 2.41.0 make-default: true + # The acceptance script runs the packaged suites through nextest, and + # the MCP suite's structural-rewrite proof shells out to ast-grep. + - name: Install cargo-nextest + uses: taiki-e/install-action@nextest + + - name: Install ast-grep + uses: ./.github/actions/install-ast-grep + with: + version: "0.44.0" + - name: Build release binary for packaging run: cargo build --package tracedecay-cli --bin tracedecay --release --target ${{ env.TARGET }} --no-default-features --features production --locked diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 8154bf0c57..a1a276f5b0 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -134,19 +134,6 @@ jobs: digest="$(python3 scripts/check-dashboard-bundle.py dashboard/app-dist --print-digest)" echo "TRACEDECAY_DASHBOARD_BUNDLE_SHA256=${digest}" >> "$GITHUB_ENV" - - name: Validate portable distribution harnesses - run: | - python3 scripts/test-check-packaged-lsp-bridge.py - python3 scripts/test-build-mcpb.py - python3 scripts/test-resolve-installed-binary.py - python3 scripts/test-check-packaged-mcp-stdio.py - python3 scripts/test-check-distribution-feature-wiring.py - python3 scripts/test-resolve-release-source-profile.py - python3 scripts/test-package-release-archive.py - bash scripts/test-check-distribution-clean-source.sh - bash scripts/test-check-distribution-snapshot.sh - bash scripts/test-check-distribution-reuse.sh - # rust-toolchain.toml is the only compiler this job may install. # dtolnay/rust-toolchain@stable also installs floating stable, and # rust-cache hashes every rustc into the environment key, so each @@ -163,13 +150,10 @@ jobs: toolchain: ${{ steps.pin.outputs.toolchain }} targets: ${{ matrix.target }} - # Restore before the first cargo invocation. resolve-release-source-profile - # runs `cargo tree` twice (~4 min cold on beta.40) and used to do that - # before rust-cache, so a warm registry sitting in Actions cache never - # reached the step that needed it. Tag runs can restore only same-ref or - # default-branch caches; this shared-key must stay stable so a later - # master writer can seed them. cache-workspace-crates stays false: the - # 10 GB repo budget is already full of CI lineages (~13.8 GB / 28 entries). + # Restore before the packaging compile. Tag runs can restore only + # same-ref or default-branch caches; this shared-key must stay stable so + # a later master writer can seed them. cache-workspace-crates stays + # false: the 10 GB repo budget is already full of CI lineages. - name: Cache Rust build uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -229,11 +213,6 @@ jobs: "$binary" --version "$binary" --help >/dev/null - - name: Get version - id: version - shell: bash - run: echo "version=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT" - - name: Package MCPB shell: bash run: | @@ -246,11 +225,11 @@ jobs: python3 scripts/build-mcpb.py build \ --binary "$binary" \ --output "$bundle" \ - --version "${{ steps.version.outputs.version }}" \ + --version "${RELEASE_TAG#v}" \ --platform "${{ matrix.name }}" python3 scripts/build-mcpb.py verify \ --bundle "$bundle" \ - --version "${{ steps.version.outputs.version }}" \ + --version "${RELEASE_TAG#v}" \ --platform "${{ matrix.name }}" - name: Verify MCPB runtime (unix) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d3b775d4b..0a8abe17eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,12 +113,6 @@ jobs: with: ref: ${{ needs.validate-release.outputs.source_sha }} - - name: Checkout release automation - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.validate-release.outputs.source_sha }} - path: .release-automation - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 @@ -134,7 +128,7 @@ jobs: run: npm run build - name: Validate dashboard bundle - run: python3 .release-automation/scripts/check-dashboard-bundle.py dashboard/app-dist + run: python3 scripts/check-dashboard-bundle.py dashboard/app-dist - name: Upload dashboard artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -176,21 +170,6 @@ jobs: digest="$(python3 scripts/check-dashboard-bundle.py dashboard/app-dist --print-digest)" echo "TRACEDECAY_DASHBOARD_BUNDLE_SHA256=${digest}" >> "$GITHUB_ENV" - - name: Validate portable distribution harnesses - run: | - python3 scripts/test-check-packaged-lsp-bridge.py - python3 scripts/test-build-mcpb.py - python3 scripts/test-resolve-installed-binary.py - python3 scripts/test-check-packaged-mcp-stdio.py - python3 scripts/test-check-distribution-feature-wiring.py - python3 scripts/test-resolve-release-source-profile.py - python3 scripts/test-package-release-archive.py - python3 scripts/test-plan-release-recovery.py - python3 scripts/test-check-release-artifacts.py - bash scripts/test-check-distribution-clean-source.sh - bash scripts/test-check-distribution-snapshot.sh - bash scripts/test-check-distribution-reuse.sh - # rust-toolchain.toml is the only compiler this job may install. # Installing floating stable as well fragments rust-cache's environment # key whenever stable moves (measured on Release Beta 35271070760). @@ -205,11 +184,10 @@ jobs: toolchain: ${{ steps.pin.outputs.toolchain }} targets: ${{ matrix.target }} - # Restore before the first cargo invocation (`cargo tree` in the - # profile resolver). Tag runs can restore only same-ref or default-branch - # caches; keep this shared-key stable so a later master writer can seed - # them. cache-workspace-crates stays false so release blobs stay inside - # the 10 GB GitHub cache budget. + # Restore before the packaging compile. Tag runs can restore only + # same-ref or default-branch caches; keep this shared-key stable so a + # later master writer can seed them. cache-workspace-crates stays false + # so release blobs stay inside the 10 GB GitHub cache budget. - name: Cache Rust build uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -271,13 +249,6 @@ jobs: "$binary" --version "$binary" --help >/dev/null - - name: Get version - id: version - shell: bash - run: | - echo "tag=${{ needs.validate-release.outputs.tag }}" >> "$GITHUB_OUTPUT" - echo "version=${{ needs.validate-release.outputs.version }}" >> "$GITHUB_OUTPUT" - # --- MCP Registry bundle (all platforms) --- - name: Package MCPB @@ -288,16 +259,16 @@ jobs: if [[ "$RUNNER_OS" == "Windows" ]]; then binary="${binary}.exe" fi - bundle="tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.mcpb" + bundle="tracedecay-${{ needs.validate-release.outputs.tag }}-${{ matrix.name }}.mcpb" python3 scripts/build-mcpb.py build \ --binary "$binary" \ --output "$bundle" \ - --version "${{ steps.version.outputs.version }}" \ + --version "${{ needs.validate-release.outputs.version }}" \ --platform "${{ matrix.name }}" test -s "$bundle" python3 scripts/build-mcpb.py verify \ --bundle "$bundle" \ - --version "${{ steps.version.outputs.version }}" \ + --version "${{ needs.validate-release.outputs.version }}" \ --platform "${{ matrix.name }}" - name: Verify MCPB runtime (unix) @@ -305,7 +276,7 @@ jobs: shell: bash run: | set -euo pipefail - bundle="tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.mcpb" + bundle="tracedecay-${{ needs.validate-release.outputs.tag }}-${{ matrix.name }}.mcpb" rm -rf verify-mcpb # unzip applies the mode bits build-mcpb.py records in the archive, # which is what an MCPB host does. `python3 -m zipfile -e` drops @@ -319,7 +290,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: mcpb-${{ matrix.name }} - path: tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.mcpb + path: tracedecay-${{ needs.validate-release.outputs.tag }}-${{ matrix.name }}.mcpb if-no-files-found: error # --- Binary archive (all platforms) --- @@ -336,7 +307,7 @@ jobs: fi python3 scripts/package-release-archive.py \ --binary "$binary" \ - --output "tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.${{ matrix.archive }}" \ + --output "tracedecay-${{ needs.validate-release.outputs.tag }}-${{ matrix.name }}.${{ matrix.archive }}" \ --format "${{ matrix.archive }}" \ --entry-name "$entry_name" \ --epoch "${{ needs.validate-release.outputs.source_epoch }}" @@ -346,7 +317,7 @@ jobs: shell: bash run: | set -euo pipefail - archive="tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.tar.gz" + archive="tracedecay-${{ needs.validate-release.outputs.tag }}-${{ matrix.name }}.tar.gz" rm -rf verify-binary mkdir verify-binary tar xzf "$archive" -C verify-binary @@ -358,7 +329,7 @@ jobs: if: matrix.archive == 'zip' shell: pwsh run: | - $archive = "tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.zip" + $archive = "tracedecay-${{ needs.validate-release.outputs.tag }}-${{ matrix.name }}.zip" Remove-Item verify-binary -Recurse -Force -ErrorAction SilentlyContinue Expand-Archive -Path $archive -DestinationPath verify-binary & "./verify-binary/tracedecay.exe" --version @@ -370,7 +341,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binary-${{ matrix.name }} - path: tracedecay-${{ steps.version.outputs.tag }}-${{ matrix.name }}.${{ matrix.archive }} + path: tracedecay-${{ needs.validate-release.outputs.tag }}-${{ matrix.name }}.${{ matrix.archive }} if-no-files-found: error verify-release: @@ -444,21 +415,10 @@ jobs: id-token: write attestations: write steps: - - name: Checkout release automation + - name: Checkout release source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.validate-release.outputs.source_sha }} - path: .release-automation - persist-credentials: false - - - name: Get release tag - id: release - run: | - echo "tag=${{ needs.validate-release.outputs.tag }}" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ steps.release.outputs.tag }} persist-credentials: false - name: Download newly built binary artifacts @@ -483,20 +443,20 @@ jobs: shell: bash run: | set -euo pipefail - TAG="${{ steps.release.outputs.tag }}" + TAG="${{ needs.validate-release.outputs.tag }}" SOURCE_SHA="${{ needs.validate-release.outputs.source_sha }}" VERSION="${TAG#v}" mkdir binaries mcpbs retained-assets gh api "repos/${{ github.repository }}/releases/tags/${TAG}" \ --jq '.assets[].name' > remote-asset-names - python3 .release-automation/scripts/plan-release-recovery.py \ - --manifest .release-automation/.github/release-targets.json \ + python3 scripts/plan-release-recovery.py \ + --manifest .github/release-targets.json \ --tag "$TAG" \ --profile stable \ --asset-names remote-asset-names \ --retained-output retained-asset-names \ --github-output planner-output - .release-automation/scripts/verify-retained-release-assets.sh \ + scripts/verify-retained-release-assets.sh \ --tag "$TAG" \ --repo "${{ github.repository }}" \ --signer-workflow "${{ github.repository }}/.github/workflows/release.yml" \ @@ -525,8 +485,8 @@ jobs: done shopt -u nullglob fi - python3 .release-automation/scripts/check-release-artifacts.py \ - --manifest .release-automation/.github/release-targets.json \ + python3 scripts/check-release-artifacts.py \ + --manifest .github/release-targets.json \ --tag "$TAG" \ --binaries binaries \ --mcpbs mcpbs \ @@ -534,7 +494,7 @@ jobs: for bundle in mcpbs/*.mcpb; do platform="${bundle#mcpbs/tracedecay-"${TAG}"-}" platform="${platform%.mcpb}" - python3 .release-automation/scripts/build-mcpb.py verify \ + python3 scripts/build-mcpb.py verify \ --bundle "$bundle" \ --version "$VERSION" \ --platform "$platform" @@ -579,10 +539,10 @@ jobs: sha256sum -- *.mcpb ) >> SHA256SUMS assets+=(SHA256SUMS install.sh) - TAG="${{ steps.release.outputs.tag }}" + TAG="${{ needs.validate-release.outputs.tag }}" SOURCE_SHA="${{ needs.validate-release.outputs.source_sha }}" - git -C .release-automation fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}" - test "$(git -C .release-automation rev-list -n 1 "$TAG")" = "$SOURCE_SHA" + git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}" + test "$(git rev-list -n 1 "$TAG")" = "$SOURCE_SHA" gh api "repos/${{ github.repository }}/releases/tags/${TAG}" \ --jq '.assets[].name' > remote-asset-names @@ -650,13 +610,13 @@ jobs: --pattern "$name" --dir remote-assets/binaries ;; esac done < remote-asset-names - python3 .release-automation/scripts/check-release-artifacts.py \ - --manifest .release-automation/.github/release-targets.json \ + python3 scripts/check-release-artifacts.py \ + --manifest .github/release-targets.json \ --tag "$TAG" \ --binaries remote-assets/binaries \ --mcpbs remote-assets/mcpbs \ --allow-missing-targets - .release-automation/scripts/verify-retained-release-assets.sh \ + scripts/verify-retained-release-assets.sh \ --tag "$TAG" \ --repo "${{ github.repository }}" \ --signer-workflow "${{ github.repository }}/.github/workflows/release.yml" \ diff --git a/AGENTS.md b/AGENTS.md index 217e56b0d6..91df6e9a5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,8 @@ unauthorized external action after completing independent, authorized work. - Commits: `(): ` (scope optional; full header ≤ 72 chars) with one of `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, - `style`, `test`. Every non-merge commit message must pass commitlint + `simplify`, `style`, `test`. `simplify` is a behavior-preserving deletion + or dedup; like `refactor` it is hidden from generated release notes. Every non-merge commit message must pass commitlint (`npm run lint:commit`, configured in `commitlint.config.cjs`; the `.githooks/commit-msg` hook runs it locally via `scripts/install-git-hooks.sh`). @@ -157,8 +158,7 @@ unauthorized external action after completing independent, authorized work. production behavior. Wire it now or omit it truthfully. - Comments and docs explain invariants and why; remove narration, stale PR language, and superseded plan authority. `00-plan-set-index.md` is the sole - roadmap precedence; `NEXT.md` records current outcomes only, while historical - plans and benchmarks are archival. + roadmap precedence; historical plans and benchmarks are archival. - Name production modules, APIs, tests, scripts, and CI jobs for durable product capabilities, not PR numbers, milestones, phases, or temporary gates. Keep PR/milestone labels only in clearly archival plans and benchmark provenance. diff --git a/Cargo.lock b/Cargo.lock index b6be9ab4b9..b12a8f5251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5328,18 +5328,15 @@ dependencies = [ "axum", "constant_time_eq", "criterion", - "dirs", "filetime", "futures-util", "gix", "glob", "hex", "hotpath", - "ignore", "jsonschema", "keyring", "libc", - "notify", "regex", "rmcp 3.1.1", "rusqlite", @@ -5348,7 +5345,6 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tempfile", - "thiserror 2.0.19", "tokio", "tokio-rustls", "tokio-stream", @@ -5438,7 +5434,6 @@ dependencies = [ "tracedecay-domain", "tracedecay-framing", "tracedecay-global-db", - "tracedecay-graph-db", "tracedecay-hooks", "tracedecay-host-integration", "tracedecay-lcm", @@ -5487,7 +5482,6 @@ dependencies = [ "gix", "glob", "hex", - "hmac 0.13.0", "hotpath", "same-file", "schemars", @@ -5572,7 +5566,6 @@ dependencies = [ "tracedecay-runtime-core", "tracedecay-session-memory", "tracedecay-session-temporal-store", - "tracedecay-sessions", "tracedecay-store", "tracedecay-temporal-query", "tracedecay-tool-catalog", @@ -5961,7 +5954,6 @@ dependencies = [ "tracedecay-global-db", "tracedecay-graph-db", "tracedecay-graph-query", - "tracedecay-hooks", "tracedecay-lsp", "tracedecay-privacy", "tracedecay-runtime-core", @@ -6071,7 +6063,6 @@ dependencies = [ "tracedecay-domain", "tracedecay-global-db", "tracedecay-graph-db", - "tracedecay-privacy", "tracedecay-runtime-core", "tracedecay-session-memory", "tracedecay-tool-catalog", @@ -6189,7 +6180,6 @@ dependencies = [ "cap-std", "filetime", "hotpath", - "libc", "rusqlite", "serde", "serde_json", @@ -6224,7 +6214,6 @@ dependencies = [ "hotpath", "libc", "rmcp 3.1.1", - "schemars", "serde", "serde_json", "sha2 0.11.0", @@ -6339,7 +6328,6 @@ dependencies = [ "hotpath", "libc", "tempfile", - "thiserror 2.0.19", "windows-sys 0.61.2", ] diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 18b34ae514..3f19d02c48 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -8,6 +8,7 @@ const allowedTypes = [ "perf", "refactor", "revert", + "simplify", "style", "test", ]; diff --git a/crates/tracedecay-agent-hosts/Cargo.toml b/crates/tracedecay-agent-hosts/Cargo.toml index ca7b493e5c..9e20a0b361 100644 --- a/crates/tracedecay-agent-hosts/Cargo.toml +++ b/crates/tracedecay-agent-hosts/Cargo.toml @@ -77,4 +77,3 @@ tempfile = "3" tokio = { version = "1", features = ["full", "test-util"] } tracedecay-code-index-runtime = { path = "../tracedecay-code-index-runtime", version = "0.1.0", default-features = false, features = ["test-helpers"] } tracedecay-global-db = { path = "../tracedecay-global-db", version = "0.1.0", features = ["test-helpers"] } -tracedecay-graph-db = { path = "../tracedecay-graph-db", version = "0.1.0", features = ["test-helpers"] } diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/evidence.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/evidence.rs index a2becc4a6a..737dff0c50 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/evidence.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/evidence.rs @@ -338,20 +338,11 @@ pub(super) fn fixture_context_scout_evidence() -> ContextScoutEvidenceEnvelopeV1 PolicyDecisionRef, TemporalState, }; use tracedecay_domain::{ - CommitId, ComponentVersion, ManifestDigest, ProjectId, RefId, RepositoryId, TemporalModeV1, - WorktreeId, + CommitId, ComponentVersion, ProjectId, RefId, RepositoryId, TemporalModeV1, WorktreeId, }; - fn id(value: &str) -> T - where - T: TryFrom, - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } - fn digest(character: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", character.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; + use tracedecay_domain::test_fixtures::id; let authorized_scope = ResolvedScope::new( id::("project.scout"), diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs index 5efdff117c..92a12c933f 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/owner.rs @@ -136,18 +136,6 @@ pub fn unregister_registered_context_scout_owner( } impl ProjectContextScoutOwnerV1 { - pub async fn startup_configured( - database: Database, - project_id: [u8; 16], - now: UtcMicros, - pin: ContextScoutConfigurationPinV1, - model_config: Option<&AutomationConfig>, - ) -> Option> { - let owner = Self::startup(database, project_id, now, model_config).await?; - owner.install_configuration(pin, model_config).await.ok()?; - Some(owner) - } - #[hotpath::measure( future = true, label = "hosts.agent.context_scout.startup", @@ -853,24 +841,6 @@ impl ProjectContextScoutOwnerV1 { Ok(status_with_recent(status, &recent)) } - pub async fn configure_model(&self, config: &AutomationConfig) { - let configuration = self.configuration.read().await; - let Some(control) = configuration - .as_ref() - .map(ContextScoutConfigurationPinV1::control) - else { - return; - }; - let model = context_scout_model_assistant_from_project_config(Some(config)); - if control - .model_path - .is_some_and(|expected| expected != model.backend()) - { - return; - } - self.runtime.lock().await.replace_model(model); - } - pub async fn claim( &self, address: ContextScoutAddressV1, diff --git a/crates/tracedecay-agent-hosts/src/agents/context_scout/ports.rs b/crates/tracedecay-agent-hosts/src/agents/context_scout/ports.rs index 0bc81ca056..826a8cf72a 100644 --- a/crates/tracedecay-agent-hosts/src/agents/context_scout/ports.rs +++ b/crates/tracedecay-agent-hosts/src/agents/context_scout/ports.rs @@ -514,6 +514,7 @@ impl ProjectContextScoutAddressRegistryV1 { ContextScoutAddressBindOutcomeV1::Bound(address) } + #[cfg(test)] async fn resolve( &self, hook: &AdmittedContextScoutHookV1, @@ -794,50 +795,6 @@ where } } - #[hotpath::measure( - label = "context_scout_assemble_registered", - impl_type = "ContextScoutCanonicalInputAssemblerV1" - )] - pub async fn assemble_registered( - &self, - hook: &AdmittedContextScoutHookV1, - pin: &ContextScoutAuthorityPinV1, - context: &RequestContext, - observed_at: UtcMicros, - ) -> Option { - if !pin.matches_context(context, observed_at) { - return None; - } - let ContextScoutAddressResolveOutcomeV1::Resolved(address) = - self.registry.resolve(hook, pin).await - else { - return None; - }; - self.assemble(address, pin, context, observed_at).await - } - - #[hotpath::measure( - label = "context_scout_assemble_registered_exact", - impl_type = "ContextScoutCanonicalInputAssemblerV1" - )] - pub async fn assemble_registered_exact( - &self, - hook: &AdmittedContextScoutHookV1, - pin: &ContextScoutAuthorityPinV1, - lifecycle: &ContextScoutLifecycleAddressV1, - context: &RequestContext, - observed_at: UtcMicros, - ) -> Option { - let ContextScoutAddressResolveOutcomeV1::Resolved(address) = self - .registry - .resolve_current_exact(hook, pin, lifecycle, context, observed_at) - .await - else { - return None; - }; - self.assemble(address, pin, context, observed_at).await - } - #[hotpath::measure( label = "context_scout_bind_and_assemble", impl_type = "ContextScoutCanonicalInputAssemblerV1" @@ -1165,12 +1122,7 @@ mod tests { (temporary, database) } - fn id>(value: &str) -> T - where - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; fn configuration( revision: &str, diff --git a/crates/tracedecay-agent-hosts/src/agents/host_config_io.rs b/crates/tracedecay-agent-hosts/src/agents/host_config_io.rs index fd635dbf6d..f32dc372a7 100644 --- a/crates/tracedecay-agent-hosts/src/agents/host_config_io.rs +++ b/crates/tracedecay-agent-hosts/src/agents/host_config_io.rs @@ -741,7 +741,8 @@ pub(crate) fn host_home_override(home: &Path, env_key: &str, default_relative: & /// On Windows the returned path uses forward slashes so it can be safely /// embedded in JSON hook commands without backslash-escaping issues. pub fn which_tracedecay() -> Option { - which_tracedecay_path().and_then(|path| path.to_str().map(normalize_path_separators)) + which_tracedecay_path() + .and_then(|path| path.to_str().map(tracedecay_domain::forward_slash_text)) } /// Finds the tracedecay binary without converting its platform-native path. @@ -764,7 +765,7 @@ fn which_tracedecay_from( cargo_target_dir: Option<&Path>, ) -> Option { which_tracedecay_path_from(current_exe, path_var, cargo_target_dir) - .and_then(|path| path.to_str().map(normalize_path_separators)) + .and_then(|path| path.to_str().map(tracedecay_domain::forward_slash_text)) } fn which_tracedecay_path_from( @@ -862,12 +863,6 @@ fn path_component_eq(actual: &std::ffi::OsStr, expected: impl AsRef String { - path.replace('\\', "/") -} - /// Remove explicitly retired sibling plugin trees. /// /// Both the retired suffix and ownership manifest are allow-listed: a name @@ -967,7 +962,7 @@ pub(crate) fn hook_command(tracedecay_bin: &str, subcommand: &str) -> String { fn hook_command_for_platform(tracedecay_bin: &str, subcommand: &str, windows: bool) -> String { let quoted = if windows { - quote_windows_command_arg(&normalize_path_separators(tracedecay_bin)) + quote_windows_command_arg(&tracedecay_domain::forward_slash_text(tracedecay_bin)) } else { quote_posix_command_arg(tracedecay_bin) }; diff --git a/crates/tracedecay-agent-hosts/src/agents/host_config_io/tests.rs b/crates/tracedecay-agent-hosts/src/agents/host_config_io/tests.rs index c9b0242afe..976c996041 100644 --- a/crates/tracedecay-agent-hosts/src/agents/host_config_io/tests.rs +++ b/crates/tracedecay-agent-hosts/src/agents/host_config_io/tests.rs @@ -262,7 +262,7 @@ mod path_normalize_tests { #[test] fn normalizes_windows_backslashes() { assert_eq!( - normalize_path_separators(r"C:\Users\dev\scoop\shims\tracedecay.exe"), + tracedecay_domain::forward_slash_text(r"C:\Users\dev\scoop\shims\tracedecay.exe"), "C:/Users/dev/scoop/shims/tracedecay.exe" ); } @@ -284,7 +284,7 @@ mod path_normalize_tests { assert_eq!( found, - normalize_path_separators(&path_bin.to_string_lossy()) + tracedecay_domain::forward_slash_text(&path_bin.to_string_lossy()) ); } @@ -307,7 +307,7 @@ mod path_normalize_tests { assert_eq!( found, - normalize_path_separators(&path_bin.to_string_lossy()) + tracedecay_domain::forward_slash_text(&path_bin.to_string_lossy()) ); } @@ -332,7 +332,7 @@ mod path_normalize_tests { assert_eq!( found, - normalize_path_separators(&stable_bin.to_string_lossy()) + tracedecay_domain::forward_slash_text(&stable_bin.to_string_lossy()) ); } @@ -350,7 +350,7 @@ mod path_normalize_tests { assert_eq!( found, - normalize_path_separators(¤t_exe.to_string_lossy()) + tracedecay_domain::forward_slash_text(¤t_exe.to_string_lossy()) ); } } diff --git a/crates/tracedecay-agent-hosts/src/agents/prompt_rules.rs b/crates/tracedecay-agent-hosts/src/agents/prompt_rules.rs index 1f2561ace6..6a77f192e9 100644 --- a/crates/tracedecay-agent-hosts/src/agents/prompt_rules.rs +++ b/crates/tracedecay-agent-hosts/src/agents/prompt_rules.rs @@ -201,12 +201,6 @@ pub(crate) fn standard_prompt_rules(marker: &str, options: &PromptRulesOptions) block } -/// The CLI-fallback paragraph every host's rules must carry; exposed so -/// integration tests can assert parity across hosts. -pub fn cli_fallback_paragraph() -> &'static str { - super::CLI_FALLBACK_PROMPT_RULES -} - /// End offset of a managed block whose marker heading ends at `search_from`: /// the next `\n## ` heading, the managed-skill index start marker, or EOF, /// whichever comes first. diff --git a/crates/tracedecay-agent-hosts/src/hooks/analytics.rs b/crates/tracedecay-agent-hosts/src/hooks/analytics.rs index ade1360cd4..dc8a3f657a 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/analytics.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/analytics.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::Value; @@ -440,11 +440,11 @@ pub(crate) fn measure_json_payload_bytes(value: &T) -> Op } pub(crate) fn elapsed_us(started: Instant) -> u64 { - started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64 + tracedecay_runtime_core::tracedecay::saturating_duration_micros(started.elapsed()) } fn duration_as_millis_u64(budget: Duration) -> u64 { - u64::try_from(budget.as_millis()).unwrap_or(u64::MAX) + tracedecay_runtime_core::tracedecay::saturating_duration_millis(budget) } fn bounded_identifier(value: &str) -> String { @@ -698,10 +698,7 @@ fn append_private_jsonl(path: &Path, line: &str) { } fn now_unix_millis() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) - .unwrap_or_default() + tracedecay_runtime_core::tracedecay::unix_millis() } mod readiness; diff --git a/crates/tracedecay-agent-hosts/src/hooks/claude.rs b/crates/tracedecay-agent-hosts/src/hooks/claude.rs index cb47837d63..6b144df4a3 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/claude.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/claude.rs @@ -87,57 +87,7 @@ pub(super) fn is_code_research_prompt(prompt: &str) -> bool { /// Claude Code `SessionStart` hook handler. #[hotpath::measure(future = true, label = "hosts.hooks.claude.session_start")] pub async fn hook_claude_session_start(runtime: &HookRuntimeV1) -> i32 { - let started = Instant::now(); - let event = read_hook_event!(); - let (root, output) = claude_session_start_response(runtime, &event, started).await; - if !super::write_hook_output( - root.as_deref(), - tracedecay_hooks::HookHostV1::ClaudeCode, - &event, - &output, - ) - .await - { - return 1; - } - 0 -} - -/// Returns the identity-resolved root alongside the response so the handler -/// does not repeat the registry-probing resolution for output delivery. -async fn claude_session_start_response( - runtime: &HookRuntimeV1, - event: &str, - started: Instant, -) -> (Option, String) { - let parsed = serde_json::from_str::(event).unwrap_or(Value::Null); - // Resolve the project root the same identity-aware way the printed context - // does, including global-only stores and fresh harness-created worktrees. - let root = event_project_root_with_identity(runtime, &parsed).await; - let hook_telemetry = record_hook_invoked_parsed( - runtime, - root.as_deref(), - HintAgent::Claude, - "SessionStart", - event, - &parsed, - ); - let output = super::dispatch::dispatch_for_scope( - runtime, - tracedecay_hooks::HookHostV1::ClaudeCode, - event, - root.as_deref(), - Some(&hook_telemetry), - started, - ) - .await - .into_recorded_guidance(&hook_telemetry) - .flatten() - .map_or_else( - || serde_json::json!({}).to_string(), - |guidance| additional_context_json("SessionStart", &guidance), - ); - (root, output) + claude_guidance_hook(runtime, "SessionStart").await } /// Claude Code `PostCompact` hook handler. @@ -238,43 +188,28 @@ async fn claude_post_tool_use_response( /// `Stop` hook handler: submits the native turn boundary to the daemon. #[hotpath::measure(future = true, label = "hosts.hooks.claude.stop")] pub async fn hook_stop(runtime: &HookRuntimeV1) -> i32 { - let started = Instant::now(); - let event = read_hook_event!(); - let (root, output) = claude_stop_response_for_event(runtime, &event, started).await; - if !super::write_hook_output( - root.as_deref(), - tracedecay_hooks::HookHostV1::ClaudeCode, - &event, - &output, - ) - .await - { - return 1; - } - 0 + claude_guidance_hook(runtime, "Stop").await } -/// Returns the identity-resolved root alongside the response so the handler -/// does not repeat the registry-probing resolution for output delivery. -async fn claude_stop_response_for_event( - runtime: &HookRuntimeV1, - event: &str, - started: Instant, -) -> (Option, String) { - let parsed = serde_json::from_str::(event).unwrap_or(Value::Null); +/// SessionStart and Stop share one guidance envelope. The project root is +/// resolved once and reused for both dispatch and stdout delivery. +async fn claude_guidance_hook(runtime: &HookRuntimeV1, hook_name: &'static str) -> i32 { + let started = Instant::now(); + let event = read_hook_event!(); + let parsed = serde_json::from_str::(&event).unwrap_or(Value::Null); let root = event_project_root_with_identity(runtime, &parsed).await; let hook_telemetry = record_hook_invoked_parsed( runtime, root.as_deref(), HintAgent::Claude, - "Stop", - event, + hook_name, + &event, &parsed, ); let output = super::dispatch::dispatch_for_scope( runtime, tracedecay_hooks::HookHostV1::ClaudeCode, - event, + &event, root.as_deref(), Some(&hook_telemetry), started, @@ -284,7 +219,17 @@ async fn claude_stop_response_for_event( .flatten() .map_or_else( || serde_json::json!({}).to_string(), - |guidance| additional_context_json("Stop", &guidance), + |guidance| additional_context_json(hook_name, &guidance), ); - (root, output) + if !super::write_hook_output( + root.as_deref(), + tracedecay_hooks::HookHostV1::ClaudeCode, + &event, + &output, + ) + .await + { + return 1; + } + 0 } diff --git a/crates/tracedecay-agent-hosts/src/hooks/codex.rs b/crates/tracedecay-agent-hosts/src/hooks/codex.rs index 23877aeb0f..2c6a9c7411 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/codex.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/codex.rs @@ -122,7 +122,8 @@ pub async fn hook_codex_user_prompt_submit(runtime: &HookRuntimeV1) -> i32 { // Keep recall current, but wait for the native Stop receipt before // reflection so one completed turn schedules one review rather than a // prompt-only review followed immediately by a final-turn review. - let _ = ingest_user_codex_session(runtime, session_id, Some(&hook_telemetry)).await; + let _ = + super::ingest_user_session(runtime, "Codex", session_id, Some(&hook_telemetry)).await; } let context = Box::pin(codex_user_prompt_submit_context_with_root( &parsed, @@ -530,14 +531,6 @@ async fn codex_post_compact( } } -async fn ingest_user_codex_session( - runtime: &HookRuntimeV1, - session_id: Option, - telemetry: Option<&super::analytics::HookTimingSpan>, -) -> bool { - super::ingest_user_session(runtime, "Codex", session_id, telemetry).await -} - fn deduped_codex_hint(parsed: &Value, hint_id: &str, hint: ToolHint) -> Option { deduped_project_hint_with_id( event_project_root(parsed).as_deref(), diff --git a/crates/tracedecay-agent-hosts/src/hooks/daemon_ports.rs b/crates/tracedecay-agent-hosts/src/hooks/daemon_ports.rs index 37a593654d..e35e0f5db5 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/daemon_ports.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/daemon_ports.rs @@ -7,7 +7,7 @@ use std::path::Path; use std::sync::Mutex; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use serde::Deserialize; use tracedecay_contracts::context_scout::{ContextScoutAddressV1, ContextScoutDeliveryReceiptV1}; @@ -123,12 +123,11 @@ struct DaemonAdmissionResponseWireV1 { } pub(crate) fn now_utc() -> UtcMicros { - let micros = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(1, |duration| { - duration.as_micros().min(i64::MAX as u128) as i64 - }); - UtcMicros(micros.max(1)) + UtcMicros( + tracedecay_runtime_core::tracedecay::saturating_utc_now() + .0 + .max(1), + ) } #[hotpath::measure(label = "agent_hosts.hook_ports.admission_decode")] diff --git a/crates/tracedecay-agent-hosts/src/hooks/steering.rs b/crates/tracedecay-agent-hosts/src/hooks/steering.rs index 3283685d10..64d56844df 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/steering.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/steering.rs @@ -214,10 +214,7 @@ fn enforce_context_budget(mut text: String, budget: usize) -> String { if text.len() <= budget { return text; } - let mut end = budget.min(text.len()); - while end > 0 && !text.is_char_boundary(end) { - end -= 1; - } + let end = tracedecay_domain::utf8_prefix_at_or_before(&text, budget.min(text.len())).len(); text.truncate(end); text } diff --git a/crates/tracedecay-agent-hosts/src/native_integration/journey_tests.rs b/crates/tracedecay-agent-hosts/src/native_integration/journey_tests.rs index 3665bd7274..8c660689c7 100644 --- a/crates/tracedecay-agent-hosts/src/native_integration/journey_tests.rs +++ b/crates/tracedecay-agent-hosts/src/native_integration/journey_tests.rs @@ -41,7 +41,7 @@ use tracedecay_contracts::{ use tracedecay_domain::{ ActorId, AuthorityRef, BranchStackEdgeV1, BranchStackId, BranchStackNodeV1, BranchStackRevisionId, BranchStackRevisionV1, BranchStackSourceV1, CapabilityId, CommitId, - ConfigurationRevisionId, FrozenBranchStackSnapshotV1, LocatorDigest, ManifestDigest, + ConfigurationRevisionId, FrozenBranchStackSnapshotV1, LocatorDigest, MechanicalIntegrationModeV1, NativeIntegrationAnalysisCoverageV1, NativeIntegrationAnalysisGapV1, NativeIntegrationApprovalId, NativeIntegrationApprovalV1, NativeIntegrationDirectionV1, NativeIntegrationPreviewDispositionV1, @@ -92,9 +92,7 @@ impl NativeIntegrationAnalysisPort for UnexpectedAnalysis { } } -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn git(root: &Path, arguments: &[&str]) -> String { let output = Command::new(try_git_program().expect("resolve the git program")) diff --git a/crates/tracedecay-agent-hosts/src/native_integration/registry.rs b/crates/tracedecay-agent-hosts/src/native_integration/registry.rs index 9ed4dd5497..f26b91966e 100644 --- a/crates/tracedecay-agent-hosts/src/native_integration/registry.rs +++ b/crates/tracedecay-agent-hosts/src/native_integration/registry.rs @@ -747,9 +747,7 @@ mod tests { ManifestDigest::new(format!("sha256:{}", "5".repeat(64))).expect("policy digest") } - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("digest") - } + use tracedecay_domain::test_fixtures::digest; fn git(root: &Path, arguments: &[&str]) { let status = Command::new(try_git_program().expect("resolve the git program")) diff --git a/crates/tracedecay-agent-hosts/src/native_integration/stack_signals.rs b/crates/tracedecay-agent-hosts/src/native_integration/stack_signals.rs index 77595795cc..872c48c948 100644 --- a/crates/tracedecay-agent-hosts/src/native_integration/stack_signals.rs +++ b/crates/tracedecay-agent-hosts/src/native_integration/stack_signals.rs @@ -180,9 +180,7 @@ mod tests { WorktreeId, WorktreeInventoryEpoch, WorktreeInventorySnapshotId, }; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("digest") - } + use tracedecay_domain::test_fixtures::digest; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("oid") diff --git a/crates/tracedecay-agent-hosts/src/tool_name.rs b/crates/tracedecay-agent-hosts/src/tool_name.rs index eb12bdb4a0..245fbb461b 100644 --- a/crates/tracedecay-agent-hosts/src/tool_name.rs +++ b/crates/tracedecay-agent-hosts/src/tool_name.rs @@ -1,10 +1,9 @@ //! Canonical MCP namespaces for tracedecay's own tools. //! //! Hosts expose the same tool under several namespaces depending on how -//! tracedecay was installed, so the prefixes live here once and every consumer -//! (permission allowlists in `agents::claude`, usage classification in -//! `tracedecay_automation::analytics`) reads them from this module instead of -//! restating a partial list. +//! tracedecay was installed. Permission allowlists read these prefixes. +//! Usage classification restates the same host namespaces as literals in +//! `tracedecay_automation::analytics` so that leaf does not depend on this crate. /// Permission/tool prefix for the tracedecay tools exposed through the Claude /// **plugin** MCP server. Claude namespaces a plugin server's tools as @@ -15,33 +14,7 @@ /// instead of the redundant `plugin tracedecay tracedecay`. pub const PLUGIN_TOOL_PREFIX: &str = "mcp__plugin_tracedecay_graph__"; -/// Prior plugin namespace, from when the plugin MCP server key was also -/// `tracedecay` (`plugin_tracedecay_tracedecay`). Kept so pre-rename installs -/// are still recognized; entries under it are never removed. -pub const PRIOR_PLUGIN_TOOL_PREFIX: &str = "mcp__plugin_tracedecay_tracedecay__"; - /// Legacy config-managed namespace. It does NOT match the plugin namespace, so /// an install that wrote only these entries prompted interactively on every /// plugin tool call; the installer now writes the plugin-namespace twins too. pub const LEGACY_TOOL_PREFIX: &str = "mcp__tracedecay__"; - -/// Single-underscore namespace used by hosts that flatten the MCP separator. -pub const FLAT_TOOL_PREFIX: &str = "mcp_tracedecay_"; - -/// Every namespace a tracedecay tool call can arrive under, longest first so a -/// prefix that contains another is stripped whole. -pub const ALL_TOOL_PREFIXES: [&str; 4] = [ - PRIOR_PLUGIN_TOOL_PREFIX, - PLUGIN_TOOL_PREFIX, - LEGACY_TOOL_PREFIX, - FLAT_TOOL_PREFIX, -]; - -/// Strips the host MCP namespace from a tracedecay tool name, leaving the bare -/// tool name. Names in no known namespace are returned unchanged. -pub fn strip_tool_prefix(name: &str) -> &str { - ALL_TOOL_PREFIXES - .iter() - .find_map(|prefix| name.strip_prefix(prefix)) - .unwrap_or(name) -} diff --git a/crates/tracedecay-agent-hosts/tests/context_scout_evidence.rs b/crates/tracedecay-agent-hosts/tests/context_scout_evidence.rs index 00bab4c590..8e9875b064 100644 --- a/crates/tracedecay-agent-hosts/tests/context_scout_evidence.rs +++ b/crates/tracedecay-agent-hosts/tests/context_scout_evidence.rs @@ -28,21 +28,13 @@ use tracedecay_domain::feedback::{ ProviderEvaluationStateV1, }; use tracedecay_domain::{ - CodeGenerationId, CommitId, ComponentVersion, ManifestDigest, ProjectId, RefId, RepositoryId, + CodeGenerationId, CommitId, ComponentVersion, ProjectId, RefId, RepositoryId, RetrievalAnchorId, SourceSpan, TemporalModeV1, UtcMicros, WorktreeId, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(character: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", character.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn resolved_scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-api/src/configuration.rs b/crates/tracedecay-api/src/configuration.rs index 4db256c463..ba3f7edaab 100644 --- a/crates/tracedecay-api/src/configuration.rs +++ b/crates/tracedecay-api/src/configuration.rs @@ -218,20 +218,12 @@ pub fn configuration_authority_unavailable_error() -> DashboardConfigurationRout pub fn configuration_application_problem_error( problem: ApplicationProblemEnvelope, ) -> DashboardConfigurationRouteErrorV1 { - let status = match problem.problem.kind { - ApplicationProblemKind::InvalidRequest => StatusCode::BAD_REQUEST, - ApplicationProblemKind::NotFoundOrNotAuthorized => StatusCode::NOT_FOUND, - ApplicationProblemKind::Conflict - | ApplicationProblemKind::PartialEffect - | ApplicationProblemKind::Stale => StatusCode::CONFLICT, - ApplicationProblemKind::Unsupported => StatusCode::UNPROCESSABLE_ENTITY, - ApplicationProblemKind::ResetRequired | ApplicationProblemKind::Unavailable => { - StatusCode::SERVICE_UNAVAILABLE - } - ApplicationProblemKind::ExecutionFailed => StatusCode::INTERNAL_SERVER_ERROR, - ApplicationProblemKind::Saturated => StatusCode::TOO_MANY_REQUESTS, - ApplicationProblemKind::Cancelled => StatusCode::CONFLICT, - ApplicationProblemKind::TimedOut => StatusCode::GATEWAY_TIMEOUT, + // Cancellation stays a conflict on this historic configuration route. + // Every other kind uses the canonical application status. + let status = if problem.problem.kind == ApplicationProblemKind::Cancelled { + StatusCode::CONFLICT + } else { + crate::application_problem_status(problem.problem.kind) }; let payload = serde_json::to_value(problem) .unwrap_or_else(|_| json!({ "detail": "configuration mutation was rejected" })); diff --git a/crates/tracedecay-api/src/handoff.rs b/crates/tracedecay-api/src/handoff.rs index 1df582c2f8..6c64e7d366 100644 --- a/crates/tracedecay-api/src/handoff.rs +++ b/crates/tracedecay-api/src/handoff.rs @@ -52,40 +52,13 @@ impl HandoffOperation { matches!(self, Self::ListTaskHandoffs) } - pub const fn operation_id_str(self) -> &'static str { - match self { - Self::IssueTaskHandoff => "operation.handoff.issue_task_handoff", - Self::ListTaskHandoffs => "operation.handoff.list_task_handoffs", - Self::OpenInvestigationHandoff => "operation.handoff.open_investigation_handoff", - Self::OpenTaskHandoff => "operation.handoff.open_task_handoff", - } - } - - pub const fn route_segment(self) -> &'static str { - match self { - Self::IssueTaskHandoff => "issue-task", - Self::ListTaskHandoffs => "list-task", - Self::OpenInvestigationHandoff => "open-investigation", - Self::OpenTaskHandoff => "open-task", - } - } - - pub const fn route_path(self) -> &'static str { - match self { - Self::IssueTaskHandoff => "/handoff/issue-task", - Self::ListTaskHandoffs => "/handoff/list-task", - Self::OpenInvestigationHandoff => "/handoff/open-investigation", - Self::OpenTaskHandoff => "/handoff/open-task", - } - } - - pub const fn application_route_path(self) -> &'static str { - match self { - Self::IssueTaskHandoff => "/application/handoff/issue-task", - Self::ListTaskHandoffs => "/application/handoff/list-task", - Self::OpenInvestigationHandoff => "/application/handoff/open-investigation", - Self::OpenTaskHandoff => "/application/handoff/open-task", - } + mounted_operation_paths! { + id_prefix = "operation.handoff.", + route_prefix = "handoff", + IssueTaskHandoff: "issue_task_handoff", "issue-task"; + ListTaskHandoffs: "list_task_handoffs", "list-task"; + OpenInvestigationHandoff: "open_investigation_handoff", "open-investigation"; + OpenTaskHandoff: "open_task_handoff", "open-task"; } pub fn request_schema_name(self) -> Cow<'static, str> { diff --git a/crates/tracedecay-api/src/http.rs b/crates/tracedecay-api/src/http.rs index 140b209956..d5ce08bdcc 100644 --- a/crates/tracedecay-api/src/http.rs +++ b/crates/tracedecay-api/src/http.rs @@ -334,7 +334,7 @@ where } } -fn application_problem_status(kind: ApplicationProblemKind) -> StatusCode { +pub fn application_problem_status(kind: ApplicationProblemKind) -> StatusCode { match kind { ApplicationProblemKind::InvalidRequest => StatusCode::BAD_REQUEST, ApplicationProblemKind::NotFoundOrNotAuthorized => StatusCode::NOT_FOUND, @@ -416,11 +416,7 @@ pub(crate) fn invalid_request_problem( let diagnostic = SafeDiagnostic::new(code, message)?; adapter_problem( request_id, - ApplicationProblem::InvalidRequest { - diagnostic, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }, + ApplicationProblem::invalid_request_without_action(diagnostic.code, diagnostic.message), ) } diff --git a/crates/tracedecay-api/src/http/tests.rs b/crates/tracedecay-api/src/http/tests.rs index 5d8ef74d92..ce175ad696 100644 --- a/crates/tracedecay-api/src/http/tests.rs +++ b/crates/tracedecay-api/src/http/tests.rs @@ -1,17 +1,67 @@ use std::collections::BTreeSet; +use axum::http::StatusCode; + use super::{ - HttpApplicationOwnerKind, http_application_full_route_path, http_application_owner_kind, - is_http_application_operation_exposed, parse_callable_code_operation, - parse_configuration_operation, parse_context_scout_operation, parse_feedback_read_operation, - parse_git_read_operation, parse_native_integration_operation, + HttpApplicationOwnerKind, application_problem_status, http_application_full_route_path, + http_application_owner_kind, is_http_application_operation_exposed, + parse_callable_code_operation, parse_configuration_operation, parse_context_scout_operation, + parse_feedback_read_operation, parse_git_read_operation, parse_native_integration_operation, }; use tracedecay_contracts::{ - application_http_executable_binding_registry, + ApplicationProblemKind, application_http_executable_binding_registry, configuration::configuration_surface_operation_names, }; use tracedecay_tool_catalog::{ApplicationSurfaceOperation, OperationId, RouteExposureV1}; +#[test] +fn application_problem_status_keeps_the_canonical_http_codes() { + let cases = [ + ( + ApplicationProblemKind::InvalidRequest, + StatusCode::BAD_REQUEST, + ), + ( + ApplicationProblemKind::NotFoundOrNotAuthorized, + StatusCode::NOT_FOUND, + ), + (ApplicationProblemKind::Conflict, StatusCode::CONFLICT), + (ApplicationProblemKind::PartialEffect, StatusCode::CONFLICT), + (ApplicationProblemKind::Stale, StatusCode::CONFLICT), + ( + ApplicationProblemKind::Unsupported, + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + ApplicationProblemKind::ResetRequired, + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + ApplicationProblemKind::Unavailable, + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + ApplicationProblemKind::ExecutionFailed, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + ApplicationProblemKind::Saturated, + StatusCode::TOO_MANY_REQUESTS, + ), + ( + ApplicationProblemKind::Cancelled, + StatusCode::REQUEST_TIMEOUT, + ), + ( + ApplicationProblemKind::TimedOut, + StatusCode::GATEWAY_TIMEOUT, + ), + ]; + for (kind, status) in cases { + assert_eq!(application_problem_status(kind), status, "{kind:?}"); + } +} + #[test] fn git_read_operation_parser_is_exact_and_read_only() { for (route, operation) in [ diff --git a/crates/tracedecay-api/src/lib.rs b/crates/tracedecay-api/src/lib.rs index 87dcae89d2..99b56d1671 100644 --- a/crates/tracedecay-api/src/lib.rs +++ b/crates/tracedecay-api/src/lib.rs @@ -11,6 +11,9 @@ //! #![forbid(unsafe_code)] +#[macro_use] +mod mounted_operation; + pub mod assets; pub mod configuration; pub mod doctor; @@ -45,9 +48,10 @@ pub use handoff::{ pub use http::{ HttpApplicationControls, HttpApplicationInvocationFuture, HttpApplicationOwnerKind, HttpApplicationOwners, HttpApplicationRequest, HttpRouteDocumentV1, adapter_problem_response, - application_problem_response, application_router, configuration_application_router, - feedback_application_router, http_application_full_route_path, http_application_owner_kind, - http_application_route_path, http_route_documents, is_http_application_operation_exposed, + application_problem_response, application_problem_status, application_router, + configuration_application_router, feedback_application_router, + http_application_full_route_path, http_application_owner_kind, http_application_route_path, + http_route_documents, is_http_application_operation_exposed, }; pub use multi_root::{ MultiRootApplicationOwner, MultiRootHttpOperation, MultiRootHttpRequest, diff --git a/crates/tracedecay-api/src/mounted_operation.rs b/crates/tracedecay-api/src/mounted_operation.rs new file mode 100644 index 0000000000..f5f91e5a9e --- /dev/null +++ b/crates/tracedecay-api/src/mounted_operation.rs @@ -0,0 +1,54 @@ +//! One row per mounted operation instead of a match per path projection. +//! +//! The catalog id and both route paths are concatenations of the same key and +//! segment, so a second handwritten match can only drift. + +macro_rules! mounted_operation_paths { + ( + with_key, + id_prefix = $id_prefix:literal, + route_prefix = $route_prefix:literal, + $($variant:ident: $key:literal, $segment:literal;)+ + ) => { + pub const fn operation_key(self) -> &'static str { + match self { + $(Self::$variant => $key,)+ + } + } + + mounted_operation_paths! { + id_prefix = $id_prefix, + route_prefix = $route_prefix, + $($variant: $key, $segment;)+ + } + }; + ( + id_prefix = $id_prefix:literal, + route_prefix = $route_prefix:literal, + $($variant:ident: $key:literal, $segment:literal;)+ + ) => { + pub const fn operation_id_str(self) -> &'static str { + match self { + $(Self::$variant => concat!($id_prefix, $key),)+ + } + } + + pub const fn route_segment(self) -> &'static str { + match self { + $(Self::$variant => $segment,)+ + } + } + + pub const fn route_path(self) -> &'static str { + match self { + $(Self::$variant => concat!("/", $route_prefix, "/", $segment),)+ + } + } + + pub const fn application_route_path(self) -> &'static str { + match self { + $(Self::$variant => concat!("/application/", $route_prefix, "/", $segment),)+ + } + } + }; +} diff --git a/crates/tracedecay-api/src/remote.rs b/crates/tracedecay-api/src/remote.rs index 1f3bfbfe6e..034af4926a 100644 --- a/crates/tracedecay-api/src/remote.rs +++ b/crates/tracedecay-api/src/remote.rs @@ -38,8 +38,7 @@ use tracedecay_contracts::remote::recovery::{ use tracedecay_contracts::remote::replay::RemoteReplayRequestV1; use tracedecay_contracts::remote::transfer::RemoteFrameTransferRequestV1; use tracedecay_contracts::{ - ApplicationContractError, ApplicationProblemKind, CancellationSignal, RequestId, - ResultContractRef, + ApplicationContractError, CancellationSignal, RequestId, ResultContractRef, }; use tracedecay_domain::UtcMicros; use tracedecay_tool_catalog::SchemaId; @@ -452,21 +451,7 @@ fn remote_protocol_response(response: RemoteHttpResponseV1) -> Err(problem) => { let kind = problem.problem.kind(); crate::observe::record_error_class(kind); - match kind { - ApplicationProblemKind::InvalidRequest => StatusCode::BAD_REQUEST, - ApplicationProblemKind::NotFoundOrNotAuthorized => StatusCode::NOT_FOUND, - ApplicationProblemKind::Conflict - | ApplicationProblemKind::PartialEffect - | ApplicationProblemKind::Stale => StatusCode::CONFLICT, - ApplicationProblemKind::Unsupported => StatusCode::UNPROCESSABLE_ENTITY, - ApplicationProblemKind::ResetRequired | ApplicationProblemKind::Unavailable => { - StatusCode::SERVICE_UNAVAILABLE - } - ApplicationProblemKind::ExecutionFailed => StatusCode::INTERNAL_SERVER_ERROR, - ApplicationProblemKind::Saturated => StatusCode::TOO_MANY_REQUESTS, - ApplicationProblemKind::Cancelled => StatusCode::REQUEST_TIMEOUT, - ApplicationProblemKind::TimedOut => StatusCode::GATEWAY_TIMEOUT, - } + crate::application_problem_status(kind) } }; crate::observe::json_response(status, &response) diff --git a/crates/tracedecay-api/src/remote_tests.rs b/crates/tracedecay-api/src/remote_tests.rs index c89054ef6f..ac8399315d 100644 --- a/crates/tracedecay-api/src/remote_tests.rs +++ b/crates/tracedecay-api/src/remote_tests.rs @@ -184,13 +184,7 @@ const fn fixed_remote_clock() -> UtcMicros { const ACTIVE_CREDENTIAL: &[u8; 32] = b"0123456789abcdef0123456789abcdef"; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn repository_scope(snapshot_id: &str) -> RemoteRepositoryScopeV1 { RemoteRepositoryScopeV1 { diff --git a/crates/tracedecay-api/src/work.rs b/crates/tracedecay-api/src/work.rs index 187eaadb5f..65b1aec8db 100644 --- a/crates/tracedecay-api/src/work.rs +++ b/crates/tracedecay-api/src/work.rs @@ -375,10 +375,6 @@ impl WorkOperation { pub const fn is_dashboard_operation(self) -> bool { !matches!(self, Self::StartAttempt) } - - fn parse(segment: &str) -> Option { - Self::from_route_segment(segment) - } } impl FromStr for WorkOperation { @@ -497,7 +493,7 @@ where let request = match hotpath::measure_block!("api.http.admission", { // An operation this build does not mount is concealed the same way an // unauthorised one is, so probing a path cannot reveal what exists. - match WorkOperation::parse(&segment) { + match WorkOperation::from_route_segment(&segment) { None => Err(adapter_problem_response( request_id, ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never), diff --git a/crates/tracedecay-api/src/workflow.rs b/crates/tracedecay-api/src/workflow.rs index 85b4400163..f7e0437063 100644 --- a/crates/tracedecay-api/src/workflow.rs +++ b/crates/tracedecay-api/src/workflow.rs @@ -73,46 +73,26 @@ impl WorkflowOperation { Self::GetRun, ]; - pub const fn operation_id_str(self) -> &'static str { - match self { - Self::RegisterDefinition => "operation.workflow.register_definition", - Self::ActivateDefinition => "operation.workflow.activate_definition", - Self::RetireDefinition => "operation.workflow.retire_definition", - Self::RejectDefinition => "operation.workflow.reject_definition", - Self::ValidateDefinition => "operation.workflow.validate_definition", - Self::GetDefinition => "operation.workflow.get_definition", - Self::ListDefinitions => "operation.workflow.list_definitions", - Self::DefinitionHistory => "operation.workflow.definition_history", - Self::DiffDefinition => "operation.workflow.diff_definition", - Self::HandoffIssue => "operation.workflow.handoff_issue", - Self::HandoffRedeem => "operation.workflow.handoff_redeem", - Self::StartRun => "operation.workflow.start_run", - Self::PauseRun => "operation.workflow.pause_run", - Self::ResumeRun => "operation.workflow.resume_run", - Self::CancelRun => "operation.workflow.cancel_run", - Self::GetRun => "operation.workflow.get_run", - } - } - - pub const fn operation_key(self) -> &'static str { - match self { - Self::RegisterDefinition => "register_definition", - Self::ActivateDefinition => "activate_definition", - Self::RetireDefinition => "retire_definition", - Self::RejectDefinition => "reject_definition", - Self::ValidateDefinition => "validate_definition", - Self::GetDefinition => "get_definition", - Self::ListDefinitions => "list_definitions", - Self::DefinitionHistory => "definition_history", - Self::DiffDefinition => "diff_definition", - Self::HandoffIssue => "handoff_issue", - Self::HandoffRedeem => "handoff_redeem", - Self::StartRun => "start_run", - Self::PauseRun => "pause_run", - Self::ResumeRun => "resume_run", - Self::CancelRun => "cancel_run", - Self::GetRun => "get_run", - } + mounted_operation_paths! { + with_key, + id_prefix = "operation.workflow.", + route_prefix = "workflow", + RegisterDefinition: "register_definition", "register-definition"; + ActivateDefinition: "activate_definition", "activate-definition"; + RetireDefinition: "retire_definition", "retire-definition"; + RejectDefinition: "reject_definition", "reject-definition"; + ValidateDefinition: "validate_definition", "validate-definition"; + GetDefinition: "get_definition", "get-definition"; + ListDefinitions: "list_definitions", "list-definitions"; + DefinitionHistory: "definition_history", "definition-history"; + DiffDefinition: "diff_definition", "diff-definition"; + HandoffIssue: "handoff_issue", "handoff-issue"; + HandoffRedeem: "handoff_redeem", "handoff-redeem"; + StartRun: "start_run", "start-run"; + PauseRun: "pause_run", "pause-run"; + ResumeRun: "resume_run", "resume-run"; + CancelRun: "cancel_run", "cancel-run"; + GetRun: "get_run", "get-run"; } pub fn from_operation_key(key: &str) -> Option { @@ -137,69 +117,6 @@ impl WorkflowOperation { ) } - pub const fn route_segment(self) -> &'static str { - match self { - Self::RegisterDefinition => "register-definition", - Self::ActivateDefinition => "activate-definition", - Self::RetireDefinition => "retire-definition", - Self::RejectDefinition => "reject-definition", - Self::ValidateDefinition => "validate-definition", - Self::GetDefinition => "get-definition", - Self::ListDefinitions => "list-definitions", - Self::DefinitionHistory => "definition-history", - Self::DiffDefinition => "diff-definition", - Self::HandoffIssue => "handoff-issue", - Self::HandoffRedeem => "handoff-redeem", - Self::StartRun => "start-run", - Self::PauseRun => "pause-run", - Self::ResumeRun => "resume-run", - Self::CancelRun => "cancel-run", - Self::GetRun => "get-run", - } - } - - pub const fn route_path(self) -> &'static str { - match self { - Self::RegisterDefinition => "/workflow/register-definition", - Self::ActivateDefinition => "/workflow/activate-definition", - Self::RetireDefinition => "/workflow/retire-definition", - Self::RejectDefinition => "/workflow/reject-definition", - Self::ValidateDefinition => "/workflow/validate-definition", - Self::GetDefinition => "/workflow/get-definition", - Self::ListDefinitions => "/workflow/list-definitions", - Self::DefinitionHistory => "/workflow/definition-history", - Self::DiffDefinition => "/workflow/diff-definition", - Self::HandoffIssue => "/workflow/handoff-issue", - Self::HandoffRedeem => "/workflow/handoff-redeem", - Self::StartRun => "/workflow/start-run", - Self::PauseRun => "/workflow/pause-run", - Self::ResumeRun => "/workflow/resume-run", - Self::CancelRun => "/workflow/cancel-run", - Self::GetRun => "/workflow/get-run", - } - } - - pub const fn application_route_path(self) -> &'static str { - match self { - Self::RegisterDefinition => "/application/workflow/register-definition", - Self::ActivateDefinition => "/application/workflow/activate-definition", - Self::RetireDefinition => "/application/workflow/retire-definition", - Self::RejectDefinition => "/application/workflow/reject-definition", - Self::ValidateDefinition => "/application/workflow/validate-definition", - Self::GetDefinition => "/application/workflow/get-definition", - Self::ListDefinitions => "/application/workflow/list-definitions", - Self::DefinitionHistory => "/application/workflow/definition-history", - Self::DiffDefinition => "/application/workflow/diff-definition", - Self::HandoffIssue => "/application/workflow/handoff-issue", - Self::HandoffRedeem => "/application/workflow/handoff-redeem", - Self::StartRun => "/application/workflow/start-run", - Self::PauseRun => "/application/workflow/pause-run", - Self::ResumeRun => "/application/workflow/resume-run", - Self::CancelRun => "/application/workflow/cancel-run", - Self::GetRun => "/application/workflow/get-run", - } - } - pub fn request_schema_name(self) -> Cow<'static, str> { match self { Self::RegisterDefinition => schema_name::(), diff --git a/crates/tracedecay-api/tests/multi_root_read_model.rs b/crates/tracedecay-api/tests/multi_root_read_model.rs index 94fdf71d43..cf1a41235f 100644 --- a/crates/tracedecay-api/tests/multi_root_read_model.rs +++ b/crates/tracedecay-api/tests/multi_root_read_model.rs @@ -3,13 +3,11 @@ use serde_json::Value; use tracedecay_api::read_model::multi_root::MultiRootQueryReadModelV1; use tracedecay_contracts::{MultiRootContinuationV1, MultiRootQueryPageV1, OpaqueCursor}; use tracedecay_domain::{ - CollectionRevision, ManifestDigest, RootGenerationV1, RootScopeOutcomeV1, ScopeOutcome, - ScopePartialReasonV1, ScopeSetId, ScopeSetRevision, ScopeUnavailableReasonV1, StackRevision, + CollectionRevision, RootGenerationV1, RootScopeOutcomeV1, ScopeOutcome, ScopePartialReasonV1, + ScopeSetId, ScopeSetRevision, ScopeUnavailableReasonV1, StackRevision, }; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; #[test] fn dashboard_read_model_preserves_per_root_partial_truth() { diff --git a/crates/tracedecay-application/Cargo.toml b/crates/tracedecay-application/Cargo.toml index 91e84bf916..d88683dbdb 100644 --- a/crates/tracedecay-application/Cargo.toml +++ b/crates/tracedecay-application/Cargo.toml @@ -33,7 +33,6 @@ cap-std = "4.0.2" getrandom = "0.2" gix = { version = "=0.86.0", default-features = false, features = ["revision", "blob-diff", "parallel", "sha1", "sha256", "status"] } glob = "0.3" -hmac = { version = "0.13.0", features = ["zeroize"] } hex = "0.4" hotpath.workspace = true same-file = "1" diff --git a/crates/tracedecay-application/src/advisory/host_delivery_consume_tests.rs b/crates/tracedecay-application/src/advisory/host_delivery_consume_tests.rs index 68dda3f784..dda1718895 100644 --- a/crates/tracedecay-application/src/advisory/host_delivery_consume_tests.rs +++ b/crates/tracedecay-application/src/advisory/host_delivery_consume_tests.rs @@ -38,8 +38,8 @@ use tracedecay_domain::feedback::{ }; use tracedecay_domain::{ ActorId, CodeGenerationId, CommitId, ComponentVersion, ContentDigest, FileOccurrenceId, - LanguageDescriptorRevision, LanguageId, LocatorDigest, ManifestDigest, ProjectId, ProviderId, - RefId, RepositoryId, RetrievalAnchorId, SourceSpan, SymbolOccurrenceId, UtcMicros, WorktreeId, + LanguageDescriptorRevision, LanguageId, LocatorDigest, ProjectId, ProviderId, RefId, + RepositoryId, RetrievalAnchorId, SourceSpan, SymbolOccurrenceId, UtcMicros, WorktreeId, }; use tracedecay_hooks::{HookFeedbackDeliveryOutcomeV1, HookFeedbackRollbackSwitchV1}; use tracedecay_lsp::{ @@ -63,9 +63,7 @@ use crate::feedback::concrete::{FeedbackRuntime, open_feedback_runtime}; use crate::lsp_runtime::DaemonLspSessionFactory; use crate::source_authorization::ProjectSourceAccessSnapshot; -fn digest(fill: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", fill.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn resolved_scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-application/src/delivery.rs b/crates/tracedecay-application/src/delivery.rs index 8b9118a392..a1157bbe81 100644 --- a/crates/tracedecay-application/src/delivery.rs +++ b/crates/tracedecay-application/src/delivery.rs @@ -2120,12 +2120,13 @@ fn review_body_preview(body: &str) -> ProjectDeliveryReviewBodyPreviewV1 { truncated: false, }; } - let mut cut = MAX_PROJECT_DELIVERY_REVIEW_BODY_PREVIEW_BYTES_V1; - while !body.is_char_boundary(cut) { - cut -= 1; - } + let text = tracedecay_domain::utf8_prefix_at_or_before( + body, + MAX_PROJECT_DELIVERY_REVIEW_BODY_PREVIEW_BYTES_V1, + ) + .to_owned(); ProjectDeliveryReviewBodyPreviewV1 { - text: body[..cut].to_owned(), + text, truncated: true, } } diff --git a/crates/tracedecay-application/src/diagnostics_publication.rs b/crates/tracedecay-application/src/diagnostics_publication.rs index 09416efb3e..6ac67ed93a 100644 --- a/crates/tracedecay-application/src/diagnostics_publication.rs +++ b/crates/tracedecay-application/src/diagnostics_publication.rs @@ -915,16 +915,7 @@ pub async fn publish_compiler_diagnostics_through_code_index_v1( /// a producer's text always satisfies `validate_sanitized_message`. #[must_use] pub fn bounded_notice(message: &str) -> String { - let collapsed: String = message - .chars() - .map(|character| { - if character.is_control() { - ' ' - } else { - character - } - }) - .collect(); + let collapsed = tracedecay_domain::fold_control_characters(message); let collapsed = collapsed.trim(); if collapsed.is_empty() { return "diagnostic reported without a message".to_owned(); @@ -937,17 +928,9 @@ pub fn bounded_notice(message: &str) -> String { mod tests { use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use tracedecay_domain::test_fixtures::id; - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; pub(crate) fn scope(generation: &str) -> CleanGenerationDiagnosticScopeV1 { CleanGenerationDiagnosticScopeV1 { diff --git a/crates/tracedecay-application/src/diagnostics_query.rs b/crates/tracedecay-application/src/diagnostics_query.rs index 44a08bd183..8488c21f52 100644 --- a/crates/tracedecay-application/src/diagnostics_query.rs +++ b/crates/tracedecay-application/src/diagnostics_query.rs @@ -910,17 +910,9 @@ mod tests { }; use tracedecay_runtime_core::db::engine::TestConnection; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use tracedecay_domain::test_fixtures::id; - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn fixture_record(generation: &str, anchor: &str) -> GenerationDiagnosticV1 { let mut record = GenerationDiagnosticV1 { diff --git a/crates/tracedecay-application/src/diagnostics_store.rs b/crates/tracedecay-application/src/diagnostics_store.rs index fdd13e07dc..6ed3251101 100644 --- a/crates/tracedecay-application/src/diagnostics_store.rs +++ b/crates/tracedecay-application/src/diagnostics_store.rs @@ -1618,17 +1618,9 @@ fn db_message(operation: &str, message: impl Into) -> TraceDecayError { mod tests { use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use tracedecay_domain::test_fixtures::id; - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn fixture_record(generation: &str, anchor: &str) -> GenerationDiagnosticV1 { let mut record = GenerationDiagnosticV1 { diff --git a/crates/tracedecay-application/src/feedback/concrete.rs b/crates/tracedecay-application/src/feedback/concrete.rs index 65c1c0cd93..7545d4225e 100644 --- a/crates/tracedecay-application/src/feedback/concrete.rs +++ b/crates/tracedecay-application/src/feedback/concrete.rs @@ -2028,12 +2028,7 @@ mod tests { }; use tracedecay_runtime_core::db::{DatabaseAuthority, TestDatabaseRuntimeMode}; - fn id>(value: &str) -> T - where - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; fn scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-application/src/feedback/cycle_runtime.rs b/crates/tracedecay-application/src/feedback/cycle_runtime.rs index e5fe62b5cb..5a94bd0a94 100644 --- a/crates/tracedecay-application/src/feedback/cycle_runtime.rs +++ b/crates/tracedecay-application/src/feedback/cycle_runtime.rs @@ -639,7 +639,9 @@ impl FeedbackCycleRuntimePort for FeedbackCycleRuntime { let invocation = (runtime.lsp_input)(request).await?; if !lsp_trigger_matches_invocation(trigger, &invocation) { let duration_micros = - u64::try_from(started_at.elapsed().as_micros()).unwrap_or(u64::MAX); + tracedecay_runtime_core::tracedecay::saturating_duration_micros( + started_at.elapsed(), + ); runtime.source_observations.observe_source_event( &invocation.request.input, FeedbackSourceEventV1::ArgumentRejected { @@ -660,7 +662,9 @@ impl FeedbackCycleRuntimePort for FeedbackCycleRuntime { } let input = invocation.request.input.clone(); let admission_duration_micros = - u64::try_from(started_at.elapsed().as_micros()).unwrap_or(u64::MAX); + tracedecay_runtime_core::tracedecay::saturating_duration_micros( + started_at.elapsed(), + ); runtime.source_observations.observe_source_event( &input, lsp_method_state_event( @@ -671,8 +675,9 @@ impl FeedbackCycleRuntimePort for FeedbackCycleRuntime { ), ); let result = Box::pin(runtime.run_once(invocation)).await; - let duration_micros = - u64::try_from(started_at.elapsed().as_micros()).unwrap_or(u64::MAX); + let duration_micros = tracedecay_runtime_core::tracedecay::saturating_duration_micros( + started_at.elapsed(), + ); let outcome = if result.is_ok() { FeedbackOutcomeV1::Completed } else { diff --git a/crates/tracedecay-application/src/feedback/observations.rs b/crates/tracedecay-application/src/feedback/observations.rs index 5ea21c1d7e..5a0be7ab84 100644 --- a/crates/tracedecay-application/src/feedback/observations.rs +++ b/crates/tracedecay-application/src/feedback/observations.rs @@ -854,13 +854,7 @@ mod tests { const SHA256_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; fn digest(value: &str) -> ManifestDigest { ManifestDigest::new(value).unwrap() diff --git a/crates/tracedecay-application/src/graph_health_delta.rs b/crates/tracedecay-application/src/graph_health_delta.rs index 9fbc1e8679..fd5879957d 100644 --- a/crates/tracedecay-application/src/graph_health_delta.rs +++ b/crates/tracedecay-application/src/graph_health_delta.rs @@ -33,15 +33,6 @@ enum PersistHealthDeltaError { Other(TraceDecayError), } -fn health_delta_now() -> UtcMicros { - let micros = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| { - duration.as_micros().min(i64::MAX as u128) as i64 - }); - UtcMicros(micros) -} - fn health_score_ppm(value: f64) -> u64 { (value.clamp(0.0, 1.0) * 1_000_000.0).round() as u64 } @@ -418,7 +409,7 @@ pub async fn compute_verified_health_delta( label = "usecases.graph.health_delta.snapshot" ) .await?; - let observed_at = health_delta_now(); + let observed_at = tracedecay_contracts::now_micros(); let dimensions = health_delta_dimensions(&snapshot); let watermark = health_delta_watermark( &scope, diff --git a/crates/tracedecay-application/src/lsp_runtime/advisory_source_tests.rs b/crates/tracedecay-application/src/lsp_runtime/advisory_source_tests.rs index f8419539a8..e49137b363 100644 --- a/crates/tracedecay-application/src/lsp_runtime/advisory_source_tests.rs +++ b/crates/tracedecay-application/src/lsp_runtime/advisory_source_tests.rs @@ -65,9 +65,7 @@ use crate::source_authorization::ProjectSourceAccessSnapshot; const SOURCE: &str = "fn reviewed() {}\n"; -fn digest(fill: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", fill.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-application/src/lsp_runtime/diagnostic_admission_tests.rs b/crates/tracedecay-application/src/lsp_runtime/diagnostic_admission_tests.rs index 929ae5a877..4775dc26f4 100644 --- a/crates/tracedecay-application/src/lsp_runtime/diagnostic_admission_tests.rs +++ b/crates/tracedecay-application/src/lsp_runtime/diagnostic_admission_tests.rs @@ -8,17 +8,9 @@ use tracedecay_domain::{ FileOccurrenceId, GenerationDiagnosticV1, SourceSpan, UtcMicros, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; /// Builds a record through the real production publication builder so the /// admission rules are exercised against records shaped exactly like the diff --git a/crates/tracedecay-application/src/lsp_runtime/projection_identity_tests.rs b/crates/tracedecay-application/src/lsp_runtime/projection_identity_tests.rs index 575b69c311..06d62c45d4 100644 --- a/crates/tracedecay-application/src/lsp_runtime/projection_identity_tests.rs +++ b/crates/tracedecay-application/src/lsp_runtime/projection_identity_tests.rs @@ -5,16 +5,8 @@ use tracedecay_domain::{ RepositoryId, WorktreeId, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::id; +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-application/src/lsp_support/runtime_adapters.rs b/crates/tracedecay-application/src/lsp_support/runtime_adapters.rs index 420a39c263..cbac753870 100644 --- a/crates/tracedecay-application/src/lsp_support/runtime_adapters.rs +++ b/crates/tracedecay-application/src/lsp_support/runtime_adapters.rs @@ -392,7 +392,7 @@ fn broker_diagnostic(document_uri: &str, diagnostic: CodeDiagnostic) -> GatewayD mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use tracedecay_domain::{CodeGenerationId, ContentDigest, ManifestDigest}; + use tracedecay_domain::{CodeGenerationId, ContentDigest}; use tracedecay_lsp::{ AuthorizedLspWorkspace, CanonicalWorkspaceDiagnosticRefreshRequest, IndexedWorkspaceDocument, ManagedDiagnosticSnapshot, @@ -400,9 +400,7 @@ mod tests { use super::*; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; struct PublishingWorkspaceIndex { reads: AtomicUsize, diff --git a/crates/tracedecay-application/src/native_integration/transaction_tests.rs b/crates/tracedecay-application/src/native_integration/transaction_tests.rs index 888e2abcdf..781a0c191b 100644 --- a/crates/tracedecay-application/src/native_integration/transaction_tests.rs +++ b/crates/tracedecay-application/src/native_integration/transaction_tests.rs @@ -444,9 +444,7 @@ impl NativeIntegrationMechanics for ControlledMechanics { } } -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("object id") diff --git a/crates/tracedecay-application/src/observability/execution_emit.rs b/crates/tracedecay-application/src/observability/execution_emit.rs index 33adf5781e..13496b45d6 100644 --- a/crates/tracedecay-application/src/observability/execution_emit.rs +++ b/crates/tracedecay-application/src/observability/execution_emit.rs @@ -528,13 +528,11 @@ mod tests { NativeIntegrationPreviewProjectionV1, NativeIntegrationStatusProjectionV1, }; use tracedecay_domain::{ - ManifestDigest, NativeIntegrationPhaseV1, NativeIntegrationPreviewId, - NativeIntegrationTransactionId, ProjectId, RefId, RepositoryId, + NativeIntegrationPhaseV1, NativeIntegrationPreviewId, NativeIntegrationTransactionId, + ProjectId, RefId, RepositoryId, }; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn identity(scope_ref: &str) -> ObservabilityProducerIdentityV1 { ObservabilityProducerIdentityV1 { diff --git a/crates/tracedecay-application/src/observability/read_model/rejected_arguments.rs b/crates/tracedecay-application/src/observability/read_model/rejected_arguments.rs index 1d248c17db..ec9ec1c513 100644 --- a/crates/tracedecay-application/src/observability/read_model/rejected_arguments.rs +++ b/crates/tracedecay-application/src/observability/read_model/rejected_arguments.rs @@ -29,7 +29,7 @@ pub(crate) fn unavailable_rejected_arguments( reason: &str, ) -> RejectedArgumentAnalyticsV1 { RejectedArgumentAnalyticsV1 { - coverage: unknown_coverage(), + coverage: super::unknown_coverage(), projector_revision: PROJECTOR_REVISION.to_owned(), watermark: watermark.to_owned(), eligible_attempts: None, @@ -227,15 +227,3 @@ fn finish( unavailable_reason, } } - -const fn unknown_coverage() -> MetricCoverageV1 { - MetricCoverageV1 { - eligible: None, - observed: 0, - completed: 0, - censored: 0, - unknown: 1, - excluded: 0, - state: CoverageStateV1::Unknown, - } -} diff --git a/crates/tracedecay-application/src/observability/read_model/workflow_views.rs b/crates/tracedecay-application/src/observability/read_model/workflow_views.rs index 9fe39653b8..53ec0fccd7 100644 --- a/crates/tracedecay-application/src/observability/read_model/workflow_views.rs +++ b/crates/tracedecay-application/src/observability/read_model/workflow_views.rs @@ -412,13 +412,10 @@ const fn required_names() -> [(&'static str, &'static str, &'static str); 11] { mod tests { use super::*; use tracedecay_domain::{ - ManifestDigest, ObservabilityRetentionClassV1, ObservabilityTerminalResultV1, RunId, - UtcMicros, + ObservabilityRetentionClassV1, ObservabilityTerminalResultV1, RunId, UtcMicros, }; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn envelope(id: &str, payload: ObservabilityPayloadV1) -> ObservabilityEnvelopeV1 { let event_kind = payload.event_kind().to_owned(); diff --git a/crates/tracedecay-application/src/observability/work_conflict_emit.rs b/crates/tracedecay-application/src/observability/work_conflict_emit.rs index c062e2cfbe..e28a834015 100644 --- a/crates/tracedecay-application/src/observability/work_conflict_emit.rs +++ b/crates/tracedecay-application/src/observability/work_conflict_emit.rs @@ -324,9 +324,7 @@ mod tests { use crate::observability::RegisteredObservabilityPortV1; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).unwrap() diff --git a/crates/tracedecay-application/src/observability/work_owner_observation_recovery.rs b/crates/tracedecay-application/src/observability/work_owner_observation_recovery.rs index 64f993b120..95ecca2432 100644 --- a/crates/tracedecay-application/src/observability/work_owner_observation_recovery.rs +++ b/crates/tracedecay-application/src/observability/work_owner_observation_recovery.rs @@ -270,8 +270,8 @@ mod tests { }; use tracedecay_domain::{ ActorId, AttemptId, CoverageStateV1, DuplicateEffectOutcomeV1, DuplicateEffortKindV1, - ManifestDigest, ProjectId, ProjectionGenerationId, QuantityEvidenceClassV1, RepositoryId, - RunId, TaskId, UtcMicros, WorkAttemptIdentityV1, WorkAuthority, WorkCommandId, + ProjectId, ProjectionGenerationId, QuantityEvidenceClassV1, RepositoryId, RunId, TaskId, + UtcMicros, WorkAttemptIdentityV1, WorkAuthority, WorkCommandId, WorkDuplicateAdjudicationCommandV1, WorkDuplicateAdjudicationEvidenceV1, WorkDuplicateAdjudicationQuantitiesV1, WorkDuplicateAdjudicationReceiptV1, WorkDuplicateAdjudicationRevisionV1, WorkTopologyGenerationRefV1, WorktreeId, @@ -289,9 +289,7 @@ mod tests { T::try_from(value.into()).unwrap() } - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn attempt(ordinal: u32, suffix: &str) -> WorkAttemptIdentityV1 { WorkAttemptIdentityV1::new( diff --git a/crates/tracedecay-application/src/operation_stream.rs b/crates/tracedecay-application/src/operation_stream.rs index b6df8a172f..4965d99f42 100644 --- a/crates/tracedecay-application/src/operation_stream.rs +++ b/crates/tracedecay-application/src/operation_stream.rs @@ -273,47 +273,31 @@ impl OperationEventError { retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], }, - Self::InvalidFrontier => ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic::new( - "operation_event.invalid_frontier", - "The requested operation-event frontier is invalid", - )?, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }, + Self::InvalidFrontier => ApplicationProblem::conflict( + "operation_event.invalid_frontier", + "The requested operation-event frontier is invalid", + ), Self::RequestNotAdmitted => ApplicationProblem::timed_out_before_admission(), - Self::Saturated => ApplicationProblem::Saturated { - diagnostic: SafeDiagnostic::new( - "operation_event.saturated", - "Operation-event capacity is temporarily saturated", - )?, - retry: RetryDirective::AfterDelay, - legal_actions: vec![LegalAction::Retry], - }, + Self::Saturated => ApplicationProblem::saturated( + "operation_event.saturated", + "Operation-event capacity is temporarily saturated", + ), // Permanently invalid input: the same request can never succeed, so // the client must correct it rather than retry. Self::InvalidContext(_) | Self::InvalidProgress | Self::InvalidTerminal(_) - | Self::InvalidTestRunEvent => ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic::new( - "operation_event.invalid_request", - "The operation-event request is invalid", - )?, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - }, + | Self::InvalidTestRunEvent => ApplicationProblem::invalid_request( + "operation_event.invalid_request", + "The operation-event request is invalid", + ), // Idempotency facts: the identity or terminal receipt is already // published, so the client re-reads current state instead of // retrying the same publish. - Self::AlreadyBound | Self::TerminalAlreadyPublished => ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic::new( - "operation_event.already_published", - "The operation-event identity is already published", - )?, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }, + Self::AlreadyBound | Self::TerminalAlreadyPublished => ApplicationProblem::conflict( + "operation_event.already_published", + "The operation-event identity is already published", + ), // A misconfigured authority is a deterministic, process-lifetime // failure. It is not the caller's request that is wrong and no // amount of retrying will change the outcome. diff --git a/crates/tracedecay-application/src/primitives/grep_analysis.rs b/crates/tracedecay-application/src/primitives/grep_analysis.rs index 64d18ea4c4..05a14aabda 100644 --- a/crates/tracedecay-application/src/primitives/grep_analysis.rs +++ b/crates/tracedecay-application/src/primitives/grep_analysis.rs @@ -329,7 +329,9 @@ fn effective_scoped_path( match (requested, authorized) { (None, None) => Ok(None), (Some(path), None) | (None, Some(path)) => Ok(Some(path.to_owned())), - (Some(path), Some(scope)) if path == scope || path.starts_with(&format!("{scope}/")) => { + (Some(path), Some(scope)) + if tracedecay_domain::repository_path_matches_scope(path, Some(scope)) => + { Ok(Some(path.to_owned())) } (Some(_), Some(_)) => Err(GrepAnalysisProblemV1::Denied), diff --git a/crates/tracedecay-application/src/primitives/production/affected_tests_tests.rs b/crates/tracedecay-application/src/primitives/production/affected_tests_tests.rs index e3be7747e3..d7e07f143c 100644 --- a/crates/tracedecay-application/src/primitives/production/affected_tests_tests.rs +++ b/crates/tracedecay-application/src/primitives/production/affected_tests_tests.rs @@ -79,9 +79,7 @@ fn generation(value: &str) -> CodeGenerationId { CodeGenerationId::new(value).expect("generation") } -fn digest(value: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", value.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn content(value: char) -> ContentDigest { ContentDigest::new(format!("sha256:{}", value.to_string().repeat(64))).expect("content") diff --git a/crates/tracedecay-application/src/primitives/production/extended_primitive.rs b/crates/tracedecay-application/src/primitives/production/extended_primitive.rs index 34fe39e64e..64c3559db3 100644 --- a/crates/tracedecay-application/src/primitives/production/extended_primitive.rs +++ b/crates/tracedecay-application/src/primitives/production/extended_primitive.rs @@ -43,11 +43,6 @@ pub(super) fn public_module_symbols( nodes: Vec, path: &str, ) -> Result, ()> { - let prefix = if path.ends_with('/') { - path.to_owned() - } else { - format!("{path}/") - }; let mut pub_nodes: Vec = nodes .into_iter() .filter(|node| { @@ -61,7 +56,8 @@ pub(super) fn public_module_symbols( else { return false; }; - metadata.visibility == "public" && (file_path == path || file_path.starts_with(&prefix)) + metadata.visibility == "public" + && tracedecay_domain::path_matches_scope(file_path, Some(path)) }) .collect(); pub_nodes.sort_by(|left, right| { diff --git a/crates/tracedecay-application/src/primitives/production/managed_test_scope_tests.rs b/crates/tracedecay-application/src/primitives/production/managed_test_scope_tests.rs index 5430b69cd5..3704abf56a 100644 --- a/crates/tracedecay-application/src/primitives/production/managed_test_scope_tests.rs +++ b/crates/tracedecay-application/src/primitives/production/managed_test_scope_tests.rs @@ -9,17 +9,9 @@ use tracedecay_lsp::{LspRuntimeFailure, LspRuntimeFuture}; use super::{ManagedTestRunCurrentScopePort, ProductionManagedTestRunCurrentScope}; use crate::lsp_runtime::{LspCodeIndexProjectionIdentity, LspCodeIndexProjectionIdentityPort}; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; struct SealedIdentity; diff --git a/crates/tracedecay-application/src/primitives/runtime.rs b/crates/tracedecay-application/src/primitives/runtime.rs index 55ed5f7351..45152c2cde 100644 --- a/crates/tracedecay-application/src/primitives/runtime.rs +++ b/crates/tracedecay-application/src/primitives/runtime.rs @@ -31,10 +31,10 @@ use tracedecay_contracts::{ ApplicationProblem, ApplicationProblemEnvelope, ApplicationResult, AuthorityReceipt, CancellationContext, CancellationObservation, CancellationStage, CapabilityGrantId, CapabilityGrantSnapshot, CoverageCompleteness, CoverageDomainState, Deadline, DisclosureClass, - EvidenceCoverage, EvidenceDomain, EvidencePacket, FreshnessState, LegalAction, OmissionReason, - OpaqueCursor, OperationBudgetUsage, OperationReceipt, OperationTermination, PageCursor, - PageRequest, PageState, PolicyDecisionRef, RequestAdmission, RequestContext, RequestId, - ResolvedScope, RetrievalEvidence, RetryDirective, SafeDiagnostic, TemporalState, + EvidenceCoverage, EvidenceDomain, EvidencePacket, FreshnessState, OmissionReason, OpaqueCursor, + OperationBudgetUsage, OperationReceipt, OperationTermination, PageCursor, PageRequest, + PageState, PolicyDecisionRef, RequestAdmission, RequestContext, RequestId, ResolvedScope, + RetrievalEvidence, RetryDirective, SafeDiagnostic, TemporalState, }; use tracedecay_domain::{CodeGenerationId, CommitId, ComponentVersion, UtcMicros}; use tracedecay_tool_catalog::SortContractId; @@ -342,12 +342,6 @@ impl PrimitiveProjectRuntime { pub fn database(&self) -> &Database { &self.database } - - /// Releases the project database, dispatch, and all Arc-backed - /// primitive authorities as one teardown unit. - pub fn teardown(self) { - drop(self); - } } impl PrimitiveDispatch for OwnedPrimitiveRuntime { @@ -989,11 +983,7 @@ fn session_structural_refusal_problem( "The request exceeds its admitted session retrieval budget.", ), }; - Ok(ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic::new(code, message)?, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - }) + Ok(ApplicationProblem::invalid_request(code, message)) } const fn session_budget_diagnostic_code(stage: SessionRetrievalBudgetStageV1) -> &'static str { @@ -1754,14 +1744,9 @@ fn primitive_failure( failure: tracedecay_contracts::retrieval::PrimitiveFailure, ) -> Result, ApplicationContractError> { let application_problem = match failure.kind { - PrimitiveFailureKind::InvalidRequest => ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: failure.code, - message: failure.message, - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }, + PrimitiveFailureKind::InvalidRequest => { + ApplicationProblem::invalid_request_without_action(failure.code, failure.message) + } PrimitiveFailureKind::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } @@ -1793,14 +1778,10 @@ fn grep_problem( GrepAnalysisProblemV1::InvalidRequest(message) => problem( context, operation, - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.retrieval.invalid-request".to_owned(), - message, - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }, + ApplicationProblem::invalid_request_without_action( + "application.retrieval.invalid-request", + message, + ), ), GrepAnalysisProblemV1::AuthorityFailed(_) => unavailable(context, operation), } @@ -1826,14 +1807,10 @@ fn invalid_request( problem( context, operation, - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.retrieval.invalid-request".to_owned(), - message: "The primitive request is invalid.".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }, + ApplicationProblem::invalid_request_without_action( + "application.retrieval.invalid-request", + "The primitive request is invalid.", + ), ) } @@ -1858,14 +1835,10 @@ fn saturated( problem( context, operation, - ApplicationProblem::Saturated { - diagnostic: SafeDiagnostic::new( - "application.retrieval.saturated", - "The admitted primitive authority has reached its bounded capacity.", - )?, - retry: RetryDirective::AfterDelay, - legal_actions: vec![LegalAction::Retry], - }, + ApplicationProblem::saturated( + "application.retrieval.saturated", + "The admitted primitive authority has reached its bounded capacity.", + ), ) } diff --git a/crates/tracedecay-application/src/primitives/symbol_graph.rs b/crates/tracedecay-application/src/primitives/symbol_graph.rs index 8f147d2041..f878dff9e1 100644 --- a/crates/tracedecay-application/src/primitives/symbol_graph.rs +++ b/crates/tracedecay-application/src/primitives/symbol_graph.rs @@ -1094,9 +1094,10 @@ fn in_scope_parts(binding: Option<&CodeGraphSymbolBindingV1>, scope: &SymbolGrap let Some(file) = binding.and_then(|binding| binding.logical_path.as_deref()) else { return false; }; - scope.path_prefix.as_deref().is_none_or(|path_prefix| { - tracedecay_runtime_core::path_scope::path_matches_scope(file, Some(path_prefix)) - }) + scope + .path_prefix + .as_deref() + .is_none_or(|path_prefix| tracedecay_domain::path_matches_scope(file, Some(path_prefix))) } fn contains_ignore_ascii_case(value: &str, query: &str) -> bool { diff --git a/crates/tracedecay-application/src/primitives/symbol_graph_ignored_dependency_tests.rs b/crates/tracedecay-application/src/primitives/symbol_graph_ignored_dependency_tests.rs index bad2a214db..7c2e58014b 100644 --- a/crates/tracedecay-application/src/primitives/symbol_graph_ignored_dependency_tests.rs +++ b/crates/tracedecay-application/src/primitives/symbol_graph_ignored_dependency_tests.rs @@ -2,7 +2,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex}; use serde::Serialize; -use sha2::{Digest, Sha256}; use tracedecay_code_index::chunks::CodeIndexImportEvidenceV1; use tracedecay_code_index::graph_projection::{ CODE_GRAPH_PROJECTOR_REVISION, CodeGraphProjectionStore, CodeGraphSymbolBindingV1, @@ -26,6 +25,7 @@ use tracedecay_domain::{ SanitizedCodeFileV1, SanitizerRevision, SessionId, SnapshotFileDispositionV1, SourceSpan, SymbolIdentityDigest, SymbolOccurrenceId, TemporalModeV1, UtcMicros, WorktreeId, }; +use tracedecay_graph_db::graph_stable_identity as stable_identity; use tracedecay_graph_db::{ GraphEntity, GraphEntityId, GraphEntityRef, GraphGenerationManifest, GraphGenerationRelation, GraphLabel, GraphNamespace, GraphProjectorRevision, GraphProperty, GraphPropertyName, @@ -818,14 +818,6 @@ fn symbol_entity( .expect("symbol entity") } -fn stable_identity(kind: &str, value: &str) -> String { - let mut digest = Sha256::new(); - digest.update(kind.as_bytes()); - digest.update([0]); - digest.update(value.as_bytes()); - format!("{kind}:{}", hex::encode(digest.finalize())) -} - fn digest(byte: char) -> T where T: TryFrom, diff --git a/crates/tracedecay-application/src/primitives/symbol_graph_implementation_tests.rs b/crates/tracedecay-application/src/primitives/symbol_graph_implementation_tests.rs index 4cd4ad1f91..af6e3abfb4 100644 --- a/crates/tracedecay-application/src/primitives/symbol_graph_implementation_tests.rs +++ b/crates/tracedecay-application/src/primitives/symbol_graph_implementation_tests.rs @@ -2,7 +2,6 @@ use std::fmt::Debug; use std::sync::Arc; use serde::Serialize; -use sha2::{Digest, Sha256}; use tracedecay_code_index::graph_projection::{ CODE_GRAPH_PROJECTOR_REVISION, CodeGraphProjectionStore, CodeGraphSymbolBindingV1, build_code_graph_manifest, code_graph_projection_identity, @@ -17,6 +16,7 @@ use tracedecay_domain::{ PolicyRevisionId, RelationEdgeKindV1, SanitizerRevision, SensitivityDecision, SensitivityLevelV1, SourceSpan, SymbolIdentityDigest, SymbolOccurrenceId, }; +use tracedecay_graph_db::graph_stable_identity as stable_identity; use tracedecay_graph_db::{ GraphEntityId, GraphNamespace, GraphProjectorRevision, GraphProperty, GraphPropertyName, NeverCancelled, VerifiedGraphSnapshot, @@ -278,13 +278,7 @@ fn edge(from: &str, to: &str, start: u64) -> CanonicalRelationEdgeV1 { } } -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).expect("fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn digest(byte: char) -> T where @@ -300,11 +294,3 @@ struct SymbolRecordFixture { binding: Option, metadata: Option, } - -fn stable_identity(kind: &str, value: &str) -> String { - let mut digest = Sha256::new(); - digest.update(kind.as_bytes()); - digest.update([0]); - digest.update(value.as_bytes()); - format!("{kind}:{}", hex::encode(digest.finalize())) -} diff --git a/crates/tracedecay-application/src/work/work_evidence_retrieval.rs b/crates/tracedecay-application/src/work/work_evidence_retrieval.rs index 71957abfbc..8f7bc98d9d 100644 --- a/crates/tracedecay-application/src/work/work_evidence_retrieval.rs +++ b/crates/tracedecay-application/src/work/work_evidence_retrieval.rs @@ -707,8 +707,8 @@ pub mod tests { WorkTaskSessionReauthorizationPortV1, WorkTaskSessionRequestV1, }; use tracedecay_domain::{ - ActorId, CalibrationProfileId, DiversityPolicy, FusionProfile, ManifestDigest, - PrivacyDomainId, RetrievalAnchorId, RetrievalBudget, RetrievalCursorKeyId, RetrieverKind, + ActorId, CalibrationProfileId, DiversityPolicy, FusionProfile, PrivacyDomainId, + RetrievalAnchorId, RetrievalBudget, RetrievalCursorKeyId, RetrieverKind, ScoreDomainCalibrationV1, ScoreDomainId, SourceStoreId, UtcMicros, WorkGraphVersionV1, WorkProductEventSequenceV1, WorkProductSourceWatermarkV1, }; @@ -718,18 +718,9 @@ pub mod tests { use super::{WorkFederatedQueryAuthorityFutureV1, WorkFederatedQueryAuthorityPortV1}; - pub fn id(value: &str) -> T - where - T: TryFrom, - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("TaskSession fixture identity") - } + pub use tracedecay_domain::test_fixtures::id; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("TaskSession fixture digest") - } + use tracedecay_domain::test_fixtures::digest; pub fn context(scope: ResolvedScope) -> RequestContext { let grant = CapabilityGrantSnapshot::new( diff --git a/crates/tracedecay-application/src/work/workflow_topology.rs b/crates/tracedecay-application/src/work/workflow_topology.rs index 962019f94f..9f4f3d7f81 100644 --- a/crates/tracedecay-application/src/work/workflow_topology.rs +++ b/crates/tracedecay-application/src/work/workflow_topology.rs @@ -5,15 +5,16 @@ use std::fmt; use std::sync::Arc; use serde::Serialize; -use sha2::{Digest, Sha256}; use thiserror::Error; use tracedecay_domain::{WorkflowDefinition, WorkflowStep, WorkflowStepId, canonical_sha256}; +use tracedecay_graph_db::graph_stable_identity as stable_identity; use tracedecay_graph_db::{ GraphCancellation, GraphDbError, GraphEntity, GraphEntityId, GraphEntityRef, GraphGenerationId, GraphGenerationManifest, GraphGenerationRelation, GraphIdempotencyKey, GraphLabel, GraphNamespace, GraphProjectionId, GraphProjectionIdentity, GraphProjectorRevision, - GraphProperty, GraphPropertyName, GraphRelationId, GraphRelationKind, GraphTraversalDirection, - GraphWatermark, SourceGeneration, TraversalRequest, VerifiedGraphSnapshot, + GraphProperty, GraphPropertyName, GraphRelationId, GraphRelationKind, GraphStoreFailureClass, + GraphTraversalDirection, GraphWatermark, SourceGeneration, TraversalRequest, + VerifiedGraphSnapshot, classify_graph_store_error, }; const WORKFLOW_PROJECTION: &str = "workflow-topology"; @@ -42,27 +43,12 @@ pub enum WorkflowTopologyError { impl From for WorkflowTopologyError { fn from(error: GraphDbError) -> Self { - match error { - GraphDbError::Cancelled => Self::Cancelled, - GraphDbError::BudgetExhausted { .. } | GraphDbError::DeadlineExceeded => { - Self::BudgetExhausted - } - GraphDbError::InvalidRequest { message } => Self::Contract(message), - GraphDbError::Corrupt { message } - | GraphDbError::ResetRequired { message } - | GraphDbError::DurabilityUncertain { message } - | GraphDbError::ProjectionMismatch { message, .. } - | GraphDbError::GenerationMismatch { message, .. } => Self::Corrupt(message), - GraphDbError::Conflict { .. } => { - Self::Unavailable("workflow topology publication conflict".to_owned()) - } - GraphDbError::Unavailable { message } - | GraphDbError::SealedStoreImmutable { message } => Self::Unavailable(message), - error @ (GraphDbError::SourceCommitmentsUnavailable { .. } - | GraphDbError::SealedRevisionIncompatible { .. }) => { - Self::Unavailable(error.to_string()) - } - GraphDbError::Closed => Self::Unavailable("graph store is closed".to_owned()), + match classify_graph_store_error(error, "workflow topology publication conflict") { + GraphStoreFailureClass::Cancelled => Self::Cancelled, + GraphStoreFailureClass::BudgetExhausted => Self::BudgetExhausted, + GraphStoreFailureClass::Contract(message) => Self::Contract(message), + GraphStoreFailureClass::Corrupt(message) => Self::Corrupt(message), + GraphStoreFailureClass::Unavailable(message) => Self::Unavailable(message), } } } @@ -430,14 +416,6 @@ fn check_cancelled(cancellation: &dyn GraphCancellation) -> Result<(), WorkflowT } } -fn stable_identity(kind: &str, value: &str) -> String { - let mut digest = Sha256::new(); - digest.update(kind.as_bytes()); - digest.update([0]); - digest.update(value.as_bytes()); - format!("{kind}:{}", hex::encode(digest.finalize())) -} - fn serialize(value: &impl Serialize) -> Result, WorkflowTopologyError> { serde_json::to_vec(value).map_err(|error| WorkflowTopologyError::Contract(error.to_string())) } diff --git a/crates/tracedecay-application/tests/application_suite/github_stack_coordinator.rs b/crates/tracedecay-application/tests/application_suite/github_stack_coordinator.rs index 6f10b7020a..73b8d29b2a 100644 --- a/crates/tracedecay-application/tests/application_suite/github_stack_coordinator.rs +++ b/crates/tracedecay-application/tests/application_suite/github_stack_coordinator.rs @@ -177,9 +177,7 @@ impl StackDeliveryPort for RecordingDelivery { } } -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn scope(index: usize) -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-application/tests/application_suite/native_declared_topology_projection.rs b/crates/tracedecay-application/tests/application_suite/native_declared_topology_projection.rs index 0c052f5fba..fc07aacf00 100644 --- a/crates/tracedecay-application/tests/application_suite/native_declared_topology_projection.rs +++ b/crates/tracedecay-application/tests/application_suite/native_declared_topology_projection.rs @@ -26,7 +26,7 @@ use tracedecay_contracts::{ }; use tracedecay_domain::{ ActorId, BrainId, BranchStackEdgeV1, BranchStackId, BranchStackNodeV1, BranchStackRevisionId, - BranchStackRevisionV1, CommitId, LocatorDigest, ManifestDigest, NativeIntegrationDirectionV1, + BranchStackRevisionV1, CommitId, LocatorDigest, NativeIntegrationDirectionV1, NativeIntegrationSelectionV1, ProjectId, RefId, RepositoryId, ScopeSetId, ScopeSetRevision, StackNodeId, UserProfileId, UtcMicros, WorktreeId, WorktreeInventoryEpoch, WorktreeInventorySnapshotId, @@ -287,9 +287,7 @@ fn run_git(root: &Path, args: &[&str]) -> String { .to_owned() } -fn digest(label: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", label.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; struct DeclaredStackRequest { project: ProjectId, diff --git a/crates/tracedecay-application/tests/application_suite/workflow_topology_contract.rs b/crates/tracedecay-application/tests/application_suite/workflow_topology_contract.rs index 5c8feff17f..b7df2d86d0 100644 --- a/crates/tracedecay-application/tests/application_suite/workflow_topology_contract.rs +++ b/crates/tracedecay-application/tests/application_suite/workflow_topology_contract.rs @@ -7,17 +7,15 @@ use tracedecay_application::work::workflow_topology::{ workflow_topology_namespace, workflow_topology_projection_identity, }; use tracedecay_domain::{ - ManifestDigest, ProjectId, WorkflowDefinition, WorkflowDefinitionId, WorkflowOperationRef, - WorkflowStep, WorkflowStepId, + ProjectId, WorkflowDefinition, WorkflowDefinitionId, WorkflowOperationRef, WorkflowStep, + WorkflowStepId, }; use tracedecay_graph_db::{ GraphIdempotencyKey, GraphNamespace, GraphProjectorRevision, NeverCancelled, VerifiedGraphSnapshot, }; -fn digest(label: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", label.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn step_id(label: &str) -> WorkflowStepId { WorkflowStepId::new(format!("step.{label}")).expect("step ID") diff --git a/crates/tracedecay-automation-runtime/Cargo.toml b/crates/tracedecay-automation-runtime/Cargo.toml index 6241b1fe00..b709de4647 100644 --- a/crates/tracedecay-automation-runtime/Cargo.toml +++ b/crates/tracedecay-automation-runtime/Cargo.toml @@ -57,7 +57,6 @@ tracedecay-private-fs = { path = "../tracedecay-private-fs", version = "0.1.0" } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0" } tracedecay-session-temporal-store = { path = "../tracedecay-session-temporal-store", version = "0.1.0" } tracedecay-lcm = { path = "../tracedecay-lcm", version = "0.1.0" } -tracedecay-sessions = { path = "../tracedecay-sessions", version = "0.1.0" } tracedecay-store = { path = "../tracedecay-store", version = "0.1.0" } tracedecay-temporal-query = { path = "../tracedecay-temporal-query", version = "0.1.0" } tracedecay-tool-catalog = { path = "../tracedecay-tool-catalog", version = "0.1.0" } diff --git a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/input.rs b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/input.rs index 07422d6f0a..b19e03235d 100644 --- a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/input.rs +++ b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/input.rs @@ -7,7 +7,7 @@ use tracedecay_contracts::retained_surfaces::{ use tracedecay_domain::{RunId, UtcMicros}; use crate::automation::runner::{SessionReflectorAutomationOptions, SkillWriterAutomationOptions}; -use crate::ports::session_evidence::{LcmGrepSort, LcmScope}; +use tracedecay_lcm::{LcmGrepSort, LcmScope}; use super::contract::contract_error; use tracedecay_domain::errors::Result; @@ -103,16 +103,8 @@ fn project_skill_writer_input( } fn project_role(role: &str) -> Result { - match role { - "system" => Ok(LcmRoleV1::System), - "user" => Ok(LcmRoleV1::User), - "assistant" => Ok(LcmRoleV1::Assistant), - "tool" => Ok(LcmRoleV1::Tool), - "unknown" => Ok(LcmRoleV1::Unknown), - _ => Err(contract_error(format!( - "session reflector role is not registered: {role}" - ))), - } + LcmRoleV1::parse(role) + .ok_or_else(|| contract_error(format!("session reflector role is not registered: {role}"))) } fn automation_run_request( diff --git a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/problem.rs b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/problem.rs index 58ea1e7eb0..f57b4a82e3 100644 --- a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/problem.rs +++ b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/problem.rs @@ -220,9 +220,7 @@ mod tests { CapabilityGrantSnapshot, Deadline, DisclosureClass, RequestContext, RequestId, ResolvedScope, }; - use tracedecay_domain::{ - ActorId, ManifestDigest, ProjectId, RepositoryId, UtcMicros, WorktreeId, - }; + use tracedecay_domain::{ActorId, ProjectId, RepositoryId, UtcMicros, WorktreeId}; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; use super::{failed_ledger_problem, failure_class_problem, runtime_problem}; @@ -245,10 +243,7 @@ mod tests { .expect("failed ledger") } - fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))) - .expect("fixture digest") - } + use tracedecay_domain::test_fixtures::digest; fn context_with_deadline(deadline: UtcMicros) -> RequestContext { let scope = ResolvedScope::new( diff --git a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/settlement/tests.rs b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/settlement/tests.rs index 0ec35d6f33..946a41543e 100644 --- a/crates/tracedecay-automation-runtime/src/automation/effect_runtime/settlement/tests.rs +++ b/crates/tracedecay-automation-runtime/src/automation/effect_runtime/settlement/tests.rs @@ -19,8 +19,7 @@ use tracedecay_contracts::{ }; use tracedecay_domain::{ ActorId, ComponentVersion, FactId, FactIdentityMaterialV1, FactIdentitySourceV1, FactOwnerV1, - ManifestDigest, ProjectId, ProvenanceId, RepositoryId, RunId, UtcMicros, WorktreeId, - canonical_sha256, + ProjectId, ProvenanceId, RepositoryId, RunId, UtcMicros, WorktreeId, canonical_sha256, }; use tracedecay_tool_catalog::EffectClass; @@ -42,9 +41,7 @@ impl crate::automation::backend::AgentTaskBackend for NeverAutomationBackend { } } -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).expect("fixture digest") -} +use tracedecay_domain::test_fixtures::digest; fn exact_publication(seed: char, payload_len: u64) -> ExactRunPublication { serde_json::from_value(json!({ diff --git a/crates/tracedecay-automation-runtime/src/automation/host_io.rs b/crates/tracedecay-automation-runtime/src/automation/host_io.rs index cabb9f411d..9006aae9e4 100644 --- a/crates/tracedecay-automation-runtime/src/automation/host_io.rs +++ b/crates/tracedecay-automation-runtime/src/automation/host_io.rs @@ -121,42 +121,3 @@ pub fn home_dir() -> Option { pub fn uses_default_user_profile(home: &Path, profile_root: &Path) -> bool { profile_root == home.join(".tracedecay") } - -/// A bundle whose file writes land on disk plainly and whose export sweeps -/// touch no agent host, for tests that exercise automation without a host -/// installer. -#[cfg(test)] -pub(crate) fn plain_file_host_io() -> HostIo { - fn export_to_agents(_: &Path, _: &Path) -> Vec { - Vec::new() - } - - fn export_to_agent_hosts(_: &Path, _: &Path, _: &Path) -> Vec { - Vec::new() - } - - fn write_text(path: &Path, contents: &str, _: Option<&Path>) -> Result<()> { - Ok(std::fs::write(path, contents)?) - } - - fn write_json(path: &Path, value: &Value, _: Option<&Path>) -> Result<()> { - Ok(std::fs::write(path, serde_json::to_vec_pretty(value)?)?) - } - - fn remove_host_file(path: &Path) -> std::io::Result<()> { - std::fs::remove_file(path) - } - - fn codex_agent_files() -> &'static [PluginFile] { - &[] - } - - HostIo { - export_to_agents, - export_to_agent_hosts, - write_text, - write_json, - remove_host_file, - codex_agent_files, - } -} diff --git a/crates/tracedecay-automation-runtime/src/automation/job_webhook.rs b/crates/tracedecay-automation-runtime/src/automation/job_webhook.rs index 63aa907db2..bbb7af6a62 100644 --- a/crates/tracedecay-automation-runtime/src/automation/job_webhook.rs +++ b/crates/tracedecay-automation-runtime/src/automation/job_webhook.rs @@ -6,6 +6,7 @@ use std::time::Duration; use serde_json::Value; use url::{Host, Url}; +use super::job_error; use tracedecay_domain::errors::{Result, TraceDecayError}; pub(crate) fn validate_url(raw: &str) -> Result<()> { @@ -395,12 +396,6 @@ fn host_header(url: &Url) -> Result { } } -fn job_error(message: &str) -> Result { - Err(TraceDecayError::Config { - message: message.to_string(), - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tracedecay-automation-runtime/src/automation/jobs.rs b/crates/tracedecay-automation-runtime/src/automation/jobs.rs index 3d36cce8b8..ee3d208cb7 100644 --- a/crates/tracedecay-automation-runtime/src/automation/jobs.rs +++ b/crates/tracedecay-automation-runtime/src/automation/jobs.rs @@ -12,18 +12,21 @@ use super::backend::{ BackendRetryPolicy, classify_agent_task_error_message, run_agent_task_with_retry_report, }; use super::config::{AutomationBackend, AutomationConfig, AutomationHostMode}; +use super::job_error; use super::job_webhook; use super::lifecycle::{ AutomationRunLedgerPublication, AutomationRunSettlementGuard, RetainedAutomationRun, - generated_run_id, + generated_run_id, publish_ledger_record, }; use super::managed_skills::{ManagedSkillState, load_managed_skill}; use super::run_ledger::{ AutomationRunLedgerRecord, AutomationRunLedgerTaskSummary, AutomationRunStatus, - AutomationTrigger, append_or_reuse_scheduler_diagnostic, append_run_record, - latest_record_by_canonical_completion, load_run_ledger_task_summary, + AutomationTrigger, append_or_reuse_scheduler_diagnostic, latest_record_by_canonical_completion, + load_run_ledger_task_summary, +}; +use super::scheduler::{ + AutomationSchedule, AutomationTaskLock, cron_is_due, elapsed_secs, parse_schedule, }; -use super::scheduler::{AutomationSchedule, AutomationTaskLock, cron_is_due, parse_schedule}; use tracedecay_automation::text::truncate_chars_for_prompt; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_runtime_core::tracedecay::current_timestamp; @@ -447,13 +450,6 @@ fn latest_terminal_job_record<'a>( })) } -fn elapsed_secs(completed_at: i64, now_secs: i64) -> u64 { - if now_secs < completed_at { - return 0; - } - (now_secs - completed_at) as u64 -} - /// Executes one user job through the automation backend, delivering its /// output and recording the run in the shared ledger under /// `user_job:`. @@ -927,12 +923,7 @@ impl JobRunContext<'_> { } async fn publish_terminal(&self, record: &AutomationRunLedgerRecord) -> Result<()> { - match self.ledger_publication { - AutomationRunLedgerPublication::Immediate => { - append_run_record(self.dashboard_root, record).await - } - AutomationRunLedgerPublication::DeferredUntilApplicationSettlement => Ok(()), - } + publish_ledger_record(self.ledger_publication, self.dashboard_root, record).await } } @@ -1053,12 +1044,6 @@ async fn run_pre_run_command(command: &str, project_root: Option<&Path>) -> Resu )) } -fn job_error(message: &str) -> Result { - Err(TraceDecayError::Config { - message: message.to_string(), - }) -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] #[path = "jobs/scheduler_config_tests.rs"] diff --git a/crates/tracedecay-automation-runtime/src/automation/lifecycle.rs b/crates/tracedecay-automation-runtime/src/automation/lifecycle.rs index 3248c30af4..262a419e8a 100644 --- a/crates/tracedecay-automation-runtime/src/automation/lifecycle.rs +++ b/crates/tracedecay-automation-runtime/src/automation/lifecycle.rs @@ -215,6 +215,19 @@ pub(crate) enum AutomationRunLedgerPublication { DeferredUntilApplicationSettlement, } +pub(crate) async fn publish_ledger_record( + publication: AutomationRunLedgerPublication, + dashboard_root: &Path, + record: &AutomationRunLedgerRecord, +) -> Result<()> { + match publication { + AutomationRunLedgerPublication::Immediate => { + append_run_record(dashboard_root, record).await + } + AutomationRunLedgerPublication::DeferredUntilApplicationSettlement => Ok(()), + } +} + impl From for AutomationRunError { fn from(error: TraceDecayError) -> Self { Self::Runtime(error) @@ -453,12 +466,7 @@ impl<'a> AgentTaskRunContext<'a> { } async fn publish_terminal_record(&self, record: &AutomationRunLedgerRecord) -> Result<()> { - match self.ledger_publication { - AutomationRunLedgerPublication::Immediate => { - append_run_record(&self.dashboard_root, record).await - } - AutomationRunLedgerPublication::DeferredUntilApplicationSettlement => Ok(()), - } + publish_ledger_record(self.ledger_publication, &self.dashboard_root, record).await } } @@ -850,12 +858,7 @@ impl<'a> AgentRunFinalizer<'a> { } async fn publish_terminal_record(&self, record: &AutomationRunLedgerRecord) -> Result<()> { - match self.ledger_publication { - AutomationRunLedgerPublication::Immediate => { - append_run_record(self.dashboard_root, record).await - } - AutomationRunLedgerPublication::DeferredUntilApplicationSettlement => Ok(()), - } + publish_ledger_record(self.ledger_publication, self.dashboard_root, record).await } #[must_use] diff --git a/crates/tracedecay-automation-runtime/src/automation/memory_curator.rs b/crates/tracedecay-automation-runtime/src/automation/memory_curator.rs index cdd393a450..31ecc29e17 100644 --- a/crates/tracedecay-automation-runtime/src/automation/memory_curator.rs +++ b/crates/tracedecay-automation-runtime/src/automation/memory_curator.rs @@ -1,7 +1,6 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::BTreeMap; -use std::sync::Arc; use tracedecay_domain::configuration::ConfigurationRevisionId; use tracedecay_domain::{ ActorId, Confidence, FactEventId, FactId, FactOwnerV1, ManifestDigest, canonical_sha256, @@ -20,7 +19,7 @@ use super::lifecycle::{ failed_backend_fallback_report, }; use super::run_ledger::{AutomationRunLedgerRecord, AutomationTrigger}; -use crate::ports::project_runtime::{AutomationProjectContext, ProfileRuntime}; +use crate::ports::project_runtime::AutomationProjectContext; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::RegisteredGlobalDbLeaseV1; use tracedecay_policy::{ @@ -87,7 +86,7 @@ pub async fn run_memory_curator_with_backend( ) -> AutomationRunResult { let sessions_db = super::runner::project_automation_sessions(cg); run_memory_curator_for_store_with_publication( - MemoryCuratorStore::Project { cg, sessions_db }, + MemoryCuratorStore { cg, sessions_db }, config, configuration_revision_id, backend, @@ -114,7 +113,7 @@ pub async fn run_memory_curator_with_backend_for_retained_settlement( let settlement_guard = AutomationRunSettlementGuard::new(); let sessions_db = super::runner::project_automation_sessions(cg); let result = run_memory_curator_for_store_with_publication( - MemoryCuratorStore::Project { cg, sessions_db }, + MemoryCuratorStore { cg, sessions_db }, config, configuration_revision_id, backend, @@ -129,71 +128,24 @@ pub async fn run_memory_curator_with_backend_for_retained_settlement( RetainedAutomationRun::new(result, settlement_guard) } -/// Runs autonomous curation against profile-level user memory. -pub(crate) async fn run_user_memory_curator_with_backend( - profile_root: &std::path::Path, - session_registry: Arc, - config: &AutomationConfig, - configuration_revision_id: &ConfigurationRevisionId, - backend: &dyn AgentTaskBackend, - options: MemoryCuratorAutomationOptions, - run_control: &AutomationRunControl, -) -> AutomationRunResult { - let sessions_db = session_registry.profile_sessions().await?; - run_memory_curator_for_store_with_publication( - MemoryCuratorStore::User { - profile_root, - runtime: session_registry.as_ref(), - sessions_db, - }, - config, - configuration_revision_id, - backend, - options, - run_control, - AutomationRunPublication { - ledger: AutomationRunLedgerPublication::Immediate, - settlement_guard: None, - }, - ) - .await -} - -enum MemoryCuratorStore<'a> { - Project { - cg: &'a AutomationProjectContext, - sessions_db: RegisteredGlobalDbLeaseV1, - }, - User { - profile_root: &'a std::path::Path, - runtime: &'a dyn ProfileRuntime, - sessions_db: RegisteredGlobalDbLeaseV1, - }, +struct MemoryCuratorStore<'a> { + cg: &'a AutomationProjectContext, + sessions_db: RegisteredGlobalDbLeaseV1, } impl MemoryCuratorStore<'_> { fn dashboard_root(&self) -> std::path::PathBuf { - match self { - Self::Project { cg, .. } => cg.dashboard_root.clone(), - Self::User { profile_root, .. } => super::runner::user_automation_root(profile_root), - } + self.cg.dashboard_root.clone() } fn sessions_db(&self) -> RegisteredGlobalDbLeaseV1 { - match self { - Self::Project { sessions_db, .. } | Self::User { sessions_db, .. } => { - sessions_db.clone() - } - } + self.sessions_db.clone() } fn owner(&self) -> Result { - match self { - Self::Project { cg, .. } => Ok(FactOwnerV1::Project { - project_id: cg.project_id.clone(), - }), - Self::User { .. } => Ok(FactOwnerV1::Profile), - } + Ok(FactOwnerV1::Project { + project_id: self.cg.project_id.clone(), + }) } fn curation_authority( @@ -201,27 +153,20 @@ impl MemoryCuratorStore<'_> { configuration_revision_id: &ConfigurationRevisionId, ) -> Result { let actor_id = ActorId::new("automation:memory-curator").map_err(memory_contract_error)?; - let (project_id, profile_id) = match self { - Self::Project { cg, .. } => (Some(cg.project_id.clone()), cg.profile_id.clone()), - Self::User { runtime, .. } => (None, runtime.profile_id().clone()), - }; Ok(CurationApplyAuthorityV1 { actor_id, - project_id, - profile_id, + project_id: Some(self.cg.project_id.clone()), + profile_id: self.cg.profile_id.clone(), configuration_revision_id: configuration_revision_id.clone(), }) } - async fn open_memory_database(&self) -> Result { - match self { - Self::Project { cg, .. } => Ok(cg.project_memory_database.clone()), - Self::User { runtime, .. } => runtime.open_user_memory_db().await, - } + fn open_memory_database(&self) -> tracedecay_runtime_core::db::Database { + self.cg.project_memory_database.clone() } } -// The single funnel every curator entry point (project, user, retained +// The single funnel every curator entry point (project and retained // settlement) flows through: one static run-lifetime span in the futures lane // so suspension and cancellation of long runs stay visible. #[hotpath::measure(future = true, label = "automation.run.memory_curator")] @@ -277,7 +222,7 @@ async fn run_memory_curator_for_store_with_publication( } let owner = store.owner()?; - let database = store.open_memory_database().await?; + let database = store.open_memory_database(); let memory = MemoryApplication::new(owner.clone(), DatabaseFactStore::new(&database)).map_err( |error| TraceDecayError::Config { message: format!("initialize memory curator authority: {error}"), diff --git a/crates/tracedecay-automation-runtime/src/automation/mod.rs b/crates/tracedecay-automation-runtime/src/automation/mod.rs index 961a39e670..5a569dc9a6 100644 --- a/crates/tracedecay-automation-runtime/src/automation/mod.rs +++ b/crates/tracedecay-automation-runtime/src/automation/mod.rs @@ -52,3 +52,16 @@ pub fn config_error(message: impl Into) -> tracedecay_domain::errors::Tr message: message.into(), } } + +pub(crate) fn job_error(message: &str) -> tracedecay_domain::errors::Result { + Err(config_error(message)) +} + +pub(crate) fn normalized_non_empty(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} diff --git a/crates/tracedecay-automation-runtime/src/automation/outcomes.rs b/crates/tracedecay-automation-runtime/src/automation/outcomes.rs index 70c7808a84..bc53223803 100644 --- a/crates/tracedecay-automation-runtime/src/automation/outcomes.rs +++ b/crates/tracedecay-automation-runtime/src/automation/outcomes.rs @@ -296,16 +296,6 @@ pub async fn load_outcomes_snapshot(dashboard_root: &Path) -> Result Result<()> { - let lock = outcomes_snapshot_lock(dashboard_root); - let _guard = lock.lock().await; - save_outcomes_snapshot_unlocked(dashboard_root, snapshot).await -} - async fn save_outcomes_snapshot_unlocked( dashboard_root: &Path, snapshot: &AutomationOutcomesSnapshot, diff --git a/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs b/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs index 12ed92251c..c6897c6578 100644 --- a/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs +++ b/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs @@ -853,13 +853,6 @@ impl AutomationRunLedgerTaskSummary { self.latest_session_evidence_budget_exhausted .map(|index| &self.records[index]) } - - pub fn latest_scheduler_effectful_user_job_terminal( - &self, - ) -> Option<&AutomationRunLedgerRecord> { - self.latest_scheduler_effectful() - .filter(|record| record.task == AgentTaskKind::UserJob) - } } #[hotpath::measure(label = "automation_runtime.run_ledger.load_page", future = true)] diff --git a/crates/tracedecay-automation-runtime/src/automation/runner.rs b/crates/tracedecay-automation-runtime/src/automation/runner.rs index 16ca32482f..3a34654a92 100644 --- a/crates/tracedecay-automation-runtime/src/automation/runner.rs +++ b/crates/tracedecay-automation-runtime/src/automation/runner.rs @@ -1,5 +1,4 @@ use std::path::{Path, PathBuf}; -use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -14,7 +13,6 @@ use super::backend::{ BackendRetryPolicy, run_agent_task_with_retry_report, }; use super::config::AutomationConfig; -use super::host_io::HostIo; use super::lifecycle::{ AgentRunFinalizer, AutomationCommittedReceipt, AutomationRunControl, AutomationRunError, AutomationRunLedgerPublication, AutomationRunResult, BackendTaskRun, SchedulerGate, @@ -27,7 +25,7 @@ use super::skill_writer::{ activation_policy as skill_writer_activation_policy, validate_and_apply_skill_proposals, validate_skill_proposals, }; -use crate::ports::project_runtime::{AutomationProjectContext, ProfileRuntime}; +use crate::ports::project_runtime::AutomationProjectContext; use crate::ports::session_store::AutomationSessionStore; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::{RegisteredGlobalDb, RegisteredGlobalDbLeaseV1}; @@ -41,30 +39,31 @@ mod evidence; mod retrieval; mod session_reflector; mod skill_writer; -mod user_evidence_preflight; -#[cfg(test)] -mod user_scope_tests; - use curation::{combined_review_output, evaluate_skill_curation}; use evidence::{ SessionReflectorEvidenceBundle, SessionReflectorEvidenceOutcome, SkillWriterEvidenceBundle, SkillWriterEvidenceOutcome, build_session_reflector_evidence, build_skill_writer_evidence, canonical_evidence_hash, }; -use retrieval::{production_user_automation_retrieval, unavailable_automation_retrieval}; +use retrieval::unavailable_automation_retrieval; use session_reflector::{ ProposedAgentOutput, SessionReflectorFinalization, build_session_reflector_prompt, finalize_session_reflector_success, validate_session_fact_candidates, }; use skill_writer::{ ProposedSkillOutput, SkillWriterFinalization, build_skill_writer_prompt, - finalize_skill_writer_success, run_user_skill_writer_with_backend_and_retrieval, + finalize_skill_writer_success, }; pub use super::lifecycle::{ AutomationRunSettlementGuard, RetainedAutomationRun, RetainedAutomationSettlementDisposition, ReusedSchedulerSkip, }; +pub use super::memory_curator::{ + CURATION_DEFAULT_FACT_REVIEW_LIMIT, CURATION_DEFAULT_MIN_CONFIDENCE, + MemoryCuratorAutomationOptions, MemoryCuratorAutomationRun, run_memory_curator_with_backend, + run_memory_curator_with_backend_for_retained_settlement, +}; pub use evidence::{AutomationTemporalEvidence, AutomationTemporalEvidenceItem}; pub use retrieval::registered_project_automation_retrieval; pub use retrieval::{ @@ -76,21 +75,11 @@ pub use session_reflector::{ SessionReflectorAutomationRun, run_session_reflector_with_backend, run_session_reflector_with_backend_and_retrieval, run_session_reflector_with_backend_and_retrieval_for_retained_settlement, - run_session_reflector_with_backend_for_retained_settlement, }; pub use skill_writer::{ SkillWriterAutomationOptions, SkillWriterAutomationRun, run_skill_writer_with_backend, run_skill_writer_with_backend_and_retrieval, run_skill_writer_with_backend_and_retrieval_for_retained_settlement, - run_skill_writer_with_backend_for_retained_settlement, -}; -pub(crate) use user_evidence_preflight::run_user_session_reflector_with_backend_and_retrieval; - -pub(crate) use super::memory_curator::run_user_memory_curator_with_backend; -pub use super::memory_curator::{ - CURATION_DEFAULT_FACT_REVIEW_LIMIT, CURATION_DEFAULT_MIN_CONFIDENCE, - MemoryCuratorAutomationOptions, MemoryCuratorAutomationRun, run_memory_curator_with_backend, - run_memory_curator_with_backend_for_retained_settlement, }; const USER_AUTOMATION_DIR: &str = "user-automation"; @@ -122,33 +111,6 @@ fn project_curation_authority( }) } -fn profile_curation_authority( - runtime: &dyn ProfileRuntime, - actor: &'static str, - configuration_revision_id: &ConfigurationRevisionId, -) -> Result { - let actor_id = ActorId::new(actor).map_err(|error| TraceDecayError::Config { - message: format!("invalid curation actor identity: {error}"), - })?; - Ok(CurationApplyAuthorityV1 { - actor_id, - project_id: None, - profile_id: runtime.profile_id().clone(), - configuration_revision_id: configuration_revision_id.clone(), - }) -} - -/// One callable projectless post-session review suitable for host hooks. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct UserSessionAutomationOptions { - #[serde(default)] - pub session_reflector: SessionReflectorAutomationOptions, - #[serde(default)] - pub memory_curator: MemoryCuratorAutomationOptions, - #[serde(default)] - pub skill_writer: SkillWriterAutomationOptions, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct UserSessionAutomationRun { pub session_reflector: SessionReflectorAutomationRun, @@ -171,84 +133,6 @@ struct CombinedReviewPublication<'a> { skill_guard: Option<&'a AutomationRunSettlementGuard>, } -#[hotpath::measure(future = true, label = "automation.run.user_session")] -pub async fn run_user_session_automation_with_backend( - host_io: HostIo, - profile_root: &std::path::Path, - session_registry: Arc, - config: &AutomationConfig, - configuration_revision_id: &ConfigurationRevisionId, - backend: &dyn AgentTaskBackend, - options: UserSessionAutomationOptions, - run_control: &AutomationRunControl, -) -> AutomationRunResult { - let _run = super::scheduler_metrics::RunningGuard::enter(); - let _duration = super::scheduler_metrics::DurationGuard::run(); - let retrieval = production_user_automation_retrieval(profile_root).await; - run_user_session_automation_with_backend_and_retrieval( - host_io, - profile_root, - session_registry, - config, - configuration_revision_id, - AutomationTaskIo { - backend, - retrieval: retrieval.as_ref(), - }, - options, - run_control, - ) - .await -} - -pub(crate) async fn run_user_session_automation_with_backend_and_retrieval( - host_io: HostIo, - profile_root: &std::path::Path, - session_registry: Arc, - config: &AutomationConfig, - configuration_revision_id: &ConfigurationRevisionId, - io: AutomationTaskIo<'_>, - options: UserSessionAutomationOptions, - run_control: &AutomationRunControl, -) -> AutomationRunResult { - let session_reflector = run_user_session_reflector_with_backend_and_retrieval( - profile_root, - Arc::clone(&session_registry), - config, - run_control, - configuration_revision_id, - io, - options.session_reflector, - ) - .await?; - let memory_curator = run_user_memory_curator_with_backend( - profile_root, - Arc::clone(&session_registry), - config, - configuration_revision_id, - io.backend, - options.memory_curator, - run_control, - ) - .await?; - let skill_writer = run_user_skill_writer_with_backend_and_retrieval( - host_io, - profile_root, - session_registry, - config, - configuration_revision_id, - io.backend, - io.retrieval, - options.skill_writer, - ) - .await?; - Ok(UserSessionAutomationRun { - session_reflector, - memory_curator, - skill_writer, - }) -} - /// Options for the scheduler-only combined reflector+skill pass. Manual /// (CLI/dashboard) runs stay per-task; this path exists so one backend call /// can serve both tasks when they are due in the same scheduler tick. @@ -463,27 +347,6 @@ pub async fn run_combined_review_with_backend_and_retrieval( .await } -pub async fn run_combined_review_with_backend_for_retained_settlement( - cg: &AutomationProjectContext, - config: &AutomationConfig, - configuration_revision_id: &ConfigurationRevisionId, - backend: &dyn AgentTaskBackend, - options: CombinedReviewAutomationOptions, - run_control: &AutomationRunControl, -) -> RetainedCombinedReviewRun { - let retrieval = unavailable_automation_retrieval("session_evidence_retrieval_unavailable"); - run_combined_review_with_backend_and_retrieval_for_retained_settlement( - cg, - config, - configuration_revision_id, - backend, - retrieval.as_ref(), - options, - run_control, - ) - .await -} - #[allow(clippy::too_many_arguments)] pub async fn run_combined_review_with_backend_and_retrieval_for_retained_settlement( cg: &AutomationProjectContext, diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/evidence.rs b/crates/tracedecay-automation-runtime/src/automation/runner/evidence.rs index 6454ce9d45..19ae3d5f58 100644 --- a/crates/tracedecay-automation-runtime/src/automation/runner/evidence.rs +++ b/crates/tracedecay-automation-runtime/src/automation/runner/evidence.rs @@ -2,10 +2,11 @@ use serde::Serialize; use serde_json::{Value, json}; use tracedecay_domain::TemporalCoverageCountsV1; -use crate::ports::session_evidence::{LcmGrepHit, LcmGrepSort, LcmScope}; +use tracedecay_lcm::{LcmGrepHit, LcmGrepSort, LcmScope}; use crate::automation::artifacts::sha256_json; use crate::automation::managed_skills::list_managed_skills; +use crate::automation::normalized_non_empty; use crate::automation::skill_usage::{ DEFAULT_SKILL_OVERLAP_LIMIT, ingest_project_analytics_events, skill_overlap_candidates, stale_skill_recommendations, summarize_skill_usage, @@ -280,15 +281,6 @@ fn session_reflector_replay_allowed( matches!(scope, LcmScope::All) || session_id.is_some() } -fn normalized_non_empty(value: &str) -> Option { - let value = value.trim(); - if value.is_empty() { - None - } else { - Some(value.to_string()) - } -} - fn compare_evidence_items( left: &AutomationTemporalEvidenceItem, right: &AutomationTemporalEvidenceItem, diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/retrieval.rs b/crates/tracedecay-automation-runtime/src/automation/runner/retrieval.rs index e00704d211..3877f5bc39 100644 --- a/crates/tracedecay-automation-runtime/src/automation/runner/retrieval.rs +++ b/crates/tracedecay-automation-runtime/src/automation/runner/retrieval.rs @@ -6,9 +6,8 @@ use super::evidence::{ use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; -use std::path::Path; #[cfg(test)] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::time::Duration; @@ -26,10 +25,10 @@ use tracedecay_domain::{ use tracedecay_store::{StoreShardIdV1, StoreShardScopeV1}; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -use crate::ports::session_evidence::LcmScope; use tracedecay_contracts::request_identity::{GlobalRequestSurface, mint_global_request_id}; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::{RegisteredGlobalDb, RegisteredGlobalDbLeaseV1}; +use tracedecay_lcm::LcmScope; use tracedecay_session_memory::context::{ BranchId, CancellationToken, CapabilityDigest, ConfigurationDigest, PolicyDigest, ProfileId, RequestBudgets, ResolvedGitRoute, ResolvedSessionIdentity, SessionRootId, SessionStoreId, @@ -788,12 +787,6 @@ pub(super) fn unavailable_automation_retrieval( }) } -pub(super) async fn production_user_automation_retrieval( - _profile_root: &Path, -) -> Box { - unavailable_automation_retrieval("session_evidence_retrieval_unavailable") -} - #[cfg(test)] mod authority_tests { use tempfile::tempdir; @@ -883,46 +876,6 @@ mod authority_tests { )); } - #[tokio::test] - async fn convenience_retrieval_does_not_create_a_profile_session_database() { - let directory = tempdir().expect("temporary profile"); - let database_path = directory.path().join("user-sessions.db"); - assert!(!database_path.exists()); - - let retrieval = production_user_automation_retrieval(directory.path()).await; - - assert!(!database_path.exists()); - assert!(matches!( - retrieval - .retrieve( - SessionTemporalQuery::new( - SessionId::new("session.automation.test").expect("session id"), - None, - "test", - None, - TemporalModeV1::Forensic, - RetrievalGrainV1::LogicalMessage, - 1, - DiversityLimits { - per_logical_message: 1, - per_turn: 1, - per_session: 1, - per_source: 1, - per_evidence_role: 1, - }, - ContextBudget { - max_bytes: 1024, - max_tokens: 256, - estimator_version: AUTOMATION_SESSION_ESTIMATOR_VERSION.to_string(), - }, - ) - .expect("bounded query"), - ) - .await, - AutomationTemporalRetrieval::Rejected("session_evidence_retrieval_unavailable") - )); - } - #[tokio::test] async fn project_retrieval_rejects_non_project_scope_without_fallback() { let directory = tempdir().expect("temporary profile"); @@ -1023,18 +976,4 @@ mod authority_tests { "session_evidence_retrieval_unavailable" ); } - - #[tokio::test] - async fn path_only_user_convenience_never_fabricates_empty_hits() { - let directory = tempdir().expect("temporary profile"); - let retrieval = production_user_automation_retrieval(directory.path()).await; - assert_eq!( - typed_reject_reason(retrieval.as_ref()).await, - "session_evidence_retrieval_unavailable" - ); - assert!( - !directory.path().join("user-sessions.db").exists(), - "path-only convenience must not invent a session database" - ); - } } diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/session_reflector.rs b/crates/tracedecay-automation-runtime/src/automation/runner/session_reflector.rs index a0ea312214..262ae16e3f 100644 --- a/crates/tracedecay-automation-runtime/src/automation/runner/session_reflector.rs +++ b/crates/tracedecay-automation-runtime/src/automation/runner/session_reflector.rs @@ -21,11 +21,11 @@ use crate::automation::lifecycle::{ use crate::automation::run_ledger::{AutomationRunLedgerRecord, AutomationTrigger}; use crate::automation::session_reflector::validate_fact_candidates; use crate::ports::project_runtime::AutomationProjectContext; -use crate::ports::session_evidence::{LcmGrepSort, LcmScope}; use tracedecay_domain::FactOwnerV1; use tracedecay_domain::configuration::ConfigurationRevisionId; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::RegisteredGlobalDbLeaseV1; +use tracedecay_lcm::{LcmGrepSort, LcmScope}; use tracedecay_runtime_core::tracedecay::current_timestamp; use tracedecay_session_memory::fact_store::DatabaseFactStore; use tracedecay_session_memory::memory::MemoryApplication; @@ -615,42 +615,12 @@ pub(super) async fn finalize_session_reflector_success( - dashboard_root: PathBuf, - sessions_db: RegisteredGlobalDbLeaseV1, - retrieval: &dyn AutomationSessionRetrieval, - memory: &MemoryApplication, - config: &AutomationConfig, - run_control: &AutomationRunControl, - authority: &tracedecay_policy::CurationApplyAuthorityV1, - backend: &dyn AgentTaskBackend, - options: SessionReflectorAutomationOptions, - prebuilt_evidence: Option, -) -> AutomationRunResult { - run_session_reflector_for_store_with_publication( - dashboard_root, - sessions_db, - retrieval, - memory, - config, - run_control, - authority, - backend, - options, - prebuilt_evidence, - AutomationRunLedgerPublication::Immediate, - None, - ) - .await -} - -// The single funnel every reflector entry point (project, user, retained +// The single funnel every reflector entry point (project and retained // settlement) flows through: one static run-lifetime span in the futures lane // so suspension and cancellation of long runs stay visible. #[hotpath::measure(future = true, label = "automation.run.session_reflector")] #[allow(clippy::too_many_arguments)] -async fn run_session_reflector_for_store_with_publication( +pub(super) async fn run_session_reflector_for_store_with_publication( dashboard_root: PathBuf, sessions_db: RegisteredGlobalDbLeaseV1, retrieval: &dyn AutomationSessionRetrieval, @@ -1053,30 +1023,6 @@ pub async fn run_session_reflector_with_backend( .await } -/// Runs one already-admitted retained application effect without publishing -/// its ledger terminal ahead of outer settlement. The retained settlement -/// authority must bind and publish the returned exact record. -pub async fn run_session_reflector_with_backend_for_retained_settlement( - cg: &AutomationProjectContext, - config: &AutomationConfig, - run_control: &AutomationRunControl, - configuration_revision_id: &ConfigurationRevisionId, - backend: &dyn AgentTaskBackend, - options: SessionReflectorAutomationOptions, -) -> RetainedAutomationRun { - let retrieval = unavailable_automation_retrieval("session_evidence_retrieval_unavailable"); - run_session_reflector_with_backend_and_retrieval_for_retained_settlement( - cg, - config, - run_control, - configuration_revision_id, - backend, - retrieval.as_ref(), - options, - ) - .await -} - /// Retained-settlement variant that preserves the caller's canonical session /// retrieval authority instead of silently reopening the production route. #[allow(clippy::too_many_arguments)] diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/skill_writer.rs b/crates/tracedecay-automation-runtime/src/automation/runner/skill_writer.rs index 6bc6eac453..8a989f1ce6 100644 --- a/crates/tracedecay-automation-runtime/src/automation/runner/skill_writer.rs +++ b/crates/tracedecay-automation-runtime/src/automation/runner/skill_writer.rs @@ -16,7 +16,6 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use super::curation::unpersisted_rejected_parts; use super::session_reflector::{default_include_recent_sessions, default_recent_sessions_limit}; -use super::user_evidence_preflight::preflight_user_skill_writer_evidence; use super::*; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -138,28 +137,6 @@ pub async fn run_skill_writer_with_backend( .await } -/// Runs one already-admitted retained application effect without publishing -/// its ledger terminal ahead of outer settlement. The retained settlement -/// authority must bind and publish the returned exact record. -pub async fn run_skill_writer_with_backend_for_retained_settlement( - cg: &AutomationProjectContext, - config: &AutomationConfig, - configuration_revision_id: &ConfigurationRevisionId, - backend: &dyn AgentTaskBackend, - options: SkillWriterAutomationOptions, -) -> RetainedAutomationRun { - let retrieval = unavailable_automation_retrieval("session_evidence_retrieval_unavailable"); - run_skill_writer_with_backend_and_retrieval_for_retained_settlement( - cg, - config, - configuration_revision_id, - backend, - retrieval.as_ref(), - options, - ) - .await -} - /// Retained-settlement variant that preserves the caller's canonical session /// retrieval authority instead of silently reopening the production route. pub async fn run_skill_writer_with_backend_and_retrieval_for_retained_settlement( @@ -241,66 +218,6 @@ async fn run_skill_writer_with_backend_and_retrieval_publication( .await } -pub(crate) async fn run_user_skill_writer_with_backend_and_retrieval( - host_io: HostIo, - profile_root: &std::path::Path, - session_registry: Arc, - config: &AutomationConfig, - configuration_revision_id: &ConfigurationRevisionId, - backend: &dyn AgentTaskBackend, - retrieval: &dyn AutomationSessionRetrieval, - mut options: SkillWriterAutomationOptions, -) -> AutomationRunResult { - options.profile_root = Some(profile_root.to_path_buf()); - let sessions_db = session_registry.profile_sessions().await?; - let authority = profile_curation_authority( - session_registry.as_ref(), - "automation:skill-writer", - configuration_revision_id, - )?; - let prebuilt_evidence = - match preflight_user_skill_writer_evidence(retrieval, config, options.clone()).await? { - Some(SkillWriterEvidenceOutcome::Ready(bundle)) => Some(bundle), - Some(SkillWriterEvidenceOutcome::Skipped { - reason, - evidence_hash, - }) => { - let run = AgentTaskRunContext::new( - user_automation_root(profile_root), - sessions_db.clone(), - options.run_id.clone(), - "skill_writer", - options.trigger, - config, - AgentTaskKind::SkillWriter, - ); - return Ok(rejected_skill_writer_run( - &run, - config, - reason, - evidence_hash, - )); - } - None => None, - }; - run_skill_writer_for_store( - SkillWriterStoreRuntime { - host_io, - dashboard_root: user_automation_root(profile_root), - sessions_db, - analytics_project_root: None, - analytics_db: None, - authority, - }, - retrieval, - config, - backend, - options, - prebuilt_evidence, - ) - .await -} - pub(super) struct SkillWriterStoreRuntime<'a> { pub(super) host_io: HostIo, pub(super) dashboard_root: PathBuf, @@ -310,30 +227,7 @@ pub(super) struct SkillWriterStoreRuntime<'a> { pub(super) authority: CurationApplyAuthorityV1, } -pub(super) async fn run_skill_writer_for_store( - runtime: SkillWriterStoreRuntime<'_>, - retrieval: &dyn AutomationSessionRetrieval, - config: &AutomationConfig, - backend: &dyn AgentTaskBackend, - options: SkillWriterAutomationOptions, - prebuilt_evidence: Option, -) -> AutomationRunResult { - run_skill_writer_for_store_with_publication( - runtime, - retrieval, - config, - backend, - options, - prebuilt_evidence, - AutomationRunPublication { - ledger: AutomationRunLedgerPublication::Immediate, - settlement_guard: None, - }, - ) - .await -} - -// The single funnel every skill-writer entry point (project, user, retained +// The single funnel every skill-writer entry point (project and retained // settlement) flows through: one static run-lifetime span in the futures lane // so suspension and cancellation of long runs stay visible. #[hotpath::measure(future = true, label = "automation.run.skill_writer")] diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/tests.rs b/crates/tracedecay-automation-runtime/src/automation/runner/tests.rs index fb0846c901..1d4306f203 100644 --- a/crates/tracedecay-automation-runtime/src/automation/runner/tests.rs +++ b/crates/tracedecay-automation-runtime/src/automation/runner/tests.rs @@ -52,7 +52,7 @@ use super::{ combined_skill_writer_evidence_or_not_combined, split_skill_runtime_failure, validate_session_fact_candidates, }; -use crate::ports::session_evidence::{LcmGrepSort, LcmScope}; +use tracedecay_lcm::{LcmGrepSort, LcmScope}; use tracedecay_runtime_core::db::{Database, DatabaseAuthority, TestDatabaseRuntimeMode}; use tracedecay_session_memory::fact_store::DatabaseFactStore; diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/tests/early_gate.rs b/crates/tracedecay-automation-runtime/src/automation/runner/tests/early_gate.rs index 8b66a024e4..ec7b66ba42 100644 --- a/crates/tracedecay-automation-runtime/src/automation/runner/tests/early_gate.rs +++ b/crates/tracedecay-automation-runtime/src/automation/runner/tests/early_gate.rs @@ -7,12 +7,13 @@ use tracedecay_domain::{ActorId, FactOwnerV1, SessionId}; use tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime; use tracedecay_policy::CurationApplyAuthorityV1; -use super::super::session_reflector::run_session_reflector_for_store; +use super::super::session_reflector::run_session_reflector_for_store_with_publication; use super::super::*; use crate::automation::backend::{AgentTaskBackend, AgentTaskRequest, AgentTaskResponse}; use crate::automation::config::{ AutomationBackend, AutomationHostMode, AutomationTaskConfig, AutomationTaskSet, }; +use crate::automation::lifecycle::AutomationRunLedgerPublication; use crate::automation::run_ledger::AutomationRunStatus; use tracedecay_runtime_core::db::{Database, DatabaseAuthority, TestDatabaseRuntimeMode}; use tracedecay_session_memory::session::SessionTemporalQuery; @@ -124,7 +125,7 @@ async fn scheduled_disabled_session_reflector_reads_no_evidence_and_runs_no_back let control = run_control(); let authority = curation_authority(); - let run = run_session_reflector_for_store( + let run = run_session_reflector_for_store_with_publication( directory.path().join("automation"), sessions.profile_database_arc(), &retrieval, @@ -139,6 +140,8 @@ async fn scheduled_disabled_session_reflector_reads_no_evidence_and_runs_no_back ..SessionReflectorAutomationOptions::default() }, None, + AutomationRunLedgerPublication::Immediate, + None, ) .await .expect("scheduled-disabled reflector skip"); diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/user_evidence_preflight.rs b/crates/tracedecay-automation-runtime/src/automation/runner/user_evidence_preflight.rs deleted file mode 100644 index ea8a58eff5..0000000000 --- a/crates/tracedecay-automation-runtime/src/automation/runner/user_evidence_preflight.rs +++ /dev/null @@ -1,118 +0,0 @@ -use std::sync::Arc; - -use crate::automation::backend::AgentTaskKind; -use crate::automation::config::AutomationConfig; -use crate::automation::lifecycle::{ - AgentTaskRunContext, AutomationRunControl, AutomationRunResult, task_skip_reason, -}; -use crate::ports::project_runtime::ProfileRuntime; -use tracedecay_domain::FactOwnerV1; -use tracedecay_domain::configuration::ConfigurationRevisionId; -use tracedecay_domain::errors::{Result, TraceDecayError}; -use tracedecay_session_memory::fact_store::DatabaseFactStore; -use tracedecay_session_memory::memory::MemoryApplication; - -use super::AutomationTaskIo; -use super::evidence::{ - SessionReflectorEvidenceOutcome, SkillWriterEvidenceOutcome, build_session_reflector_evidence, - build_skill_writer_evidence, -}; -use super::retrieval::AutomationSessionRetrieval; -use super::session_reflector::{ - SessionReflectorAutomationOptions, SessionReflectorAutomationRun, - rejected_session_reflector_run, run_session_reflector_for_store, -}; -use super::skill_writer::SkillWriterAutomationOptions; - -pub(super) async fn preflight_user_session_reflector_evidence( - retrieval: &dyn AutomationSessionRetrieval, - config: &AutomationConfig, - options: &SessionReflectorAutomationOptions, -) -> Result> { - if !options.trigger.is_on_demand() - || task_skip_reason(config, AgentTaskKind::SessionReflector).is_some() - { - return Ok(None); - } - build_session_reflector_evidence(retrieval, options) - .await - .map(Some) -} - -pub(crate) async fn run_user_session_reflector_with_backend_and_retrieval( - profile_root: &std::path::Path, - session_registry: Arc, - config: &AutomationConfig, - run_control: &AutomationRunControl, - configuration_revision_id: &ConfigurationRevisionId, - io: AutomationTaskIo<'_>, - options: SessionReflectorAutomationOptions, -) -> AutomationRunResult { - let AutomationTaskIo { backend, retrieval } = io; - let authority = super::profile_curation_authority( - session_registry.as_ref(), - "automation:session-reflector", - configuration_revision_id, - )?; - let sessions_db = session_registry.profile_sessions().await?; - let prebuilt_evidence = - match preflight_user_session_reflector_evidence(retrieval, config, &options).await? { - Some(SessionReflectorEvidenceOutcome::Ready(bundle)) => Some(bundle), - Some(SessionReflectorEvidenceOutcome::Skipped { - reason, - evidence_hash, - }) => { - let run = AgentTaskRunContext::new( - super::user_automation_root(profile_root), - sessions_db.clone(), - options.run_id.clone(), - "session_reflector", - options.trigger, - config, - AgentTaskKind::SessionReflector, - ); - return Ok(rejected_session_reflector_run( - &run, - config, - reason, - evidence_hash, - )); - } - None => None, - }; - let memory_db = session_registry.open_user_memory_db().await?; - let memory = MemoryApplication::new(FactOwnerV1::Profile, DatabaseFactStore::new(&memory_db)) - .map_err(|error| TraceDecayError::Config { - message: format!( - "could not initialize profile session reflector memory authority: {error}" - ), - })?; - run_session_reflector_for_store( - super::user_automation_root(profile_root), - sessions_db, - retrieval, - &memory, - config, - run_control, - &authority, - backend, - options, - prebuilt_evidence, - ) - .await -} - -pub(super) async fn preflight_user_skill_writer_evidence( - retrieval: &dyn AutomationSessionRetrieval, - config: &AutomationConfig, - options: SkillWriterAutomationOptions, -) -> Result> { - if !options.trigger.is_on_demand() - || task_skip_reason(config, AgentTaskKind::SkillWriter).is_some() - { - return Ok(None); - } - build_skill_writer_evidence(retrieval, None, None, options) - .await - .map(Some) -} diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/user_scope_tests.rs b/crates/tracedecay-automation-runtime/src/automation/runner/user_scope_tests.rs deleted file mode 100644 index 4fa3e6d362..0000000000 --- a/crates/tracedecay-automation-runtime/src/automation/runner/user_scope_tests.rs +++ /dev/null @@ -1,706 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use serde_json::{Value, json}; -use tempfile::TempDir; -use tracedecay_domain::configuration::{ConfigurationRevisionId, UserProfileId}; -use tracedecay_domain::{Confidence, FactId, FactOwnerV1, SessionId, TemporalCoverageCountsV1}; -use tracedecay_global_db::RegisteredGlobalDbLeaseV1; -use tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime; - -use super::*; -use crate::automation::AutomationRunControl; -use crate::automation::backend::{ - AgentTaskBackend, AgentTaskKind, AgentTaskRequest, AgentTaskResponse, -}; -use crate::automation::config::{ - AutomationBackend, AutomationHostMode, AutomationTaskConfig, AutomationTaskSet, -}; -use crate::automation::run_ledger::AutomationRunStatus; -use crate::ports::project_runtime::{ProfileRuntime, RuntimeFuture}; -use tracedecay_runtime_core::db::{Database, DatabaseAuthority, TestDatabaseRuntimeMode}; -use tracedecay_runtime_core::shard_runtime::VerifiedGraphRuntimePortV1; -use tracedecay_session_memory::fact_store::DatabaseFactStore; -use tracedecay_session_memory::memory::MemoryApplicationError; -use tracedecay_session_memory::memory::{ - MemoryApplication, ProjectMemoryFactAddRequest, ProjectMemoryFactAddRequestOutcome, -}; -use tracedecay_store::{FactStoreError, ProjectMemoryGraphQueryV1}; - -mod user_scope_graph_runtime; -use user_scope_graph_runtime::bind_profile_memory_graph_runtime; - -struct FixtureProfileRuntime { - profile_id: UserProfileId, - sessions: RegisteredGlobalDbLeaseV1, - memory: Database, -} - -impl ProfileRuntime for FixtureProfileRuntime { - fn profile_id(&self) -> &UserProfileId { - &self.profile_id - } - - fn profile_sessions(&self) -> RuntimeFuture<'_, RegisteredGlobalDbLeaseV1> { - Box::pin(async { Ok(self.sessions.clone()) }) - } - - fn open_user_memory_db(&self) -> RuntimeFuture<'_, Database> { - Box::pin(async { Ok(self.memory.clone()) }) - } -} - -struct UserRuntimeHarness { - profile_root: PathBuf, - registry: Arc, - /// Strong graph port; the database keeps only a weak binding, so this - /// handle keeps the profile memory graph mountable for the test lifetime. - _memory_graph_runtime: Arc, - _session_runtime: RegisteredGlobalDbTestRuntime, - _directory: TempDir, -} - -impl UserRuntimeHarness { - async fn open(_label: &str) -> Self { - let directory = tempfile::tempdir().expect("user automation profile"); - let profile_root = directory.path().join("profile"); - let session_runtime = RegisteredGlobalDbTestRuntime::profile(&profile_root) - .await - .expect("registered profile session runtime"); - let memory_path = - tracedecay_session_memory::memory::user::user_memory_db_path(&profile_root); - let authority = - DatabaseAuthority::acquire_test(&memory_path, "profile automation memory fixture") - .expect("profile memory authority"); - let (memory, _) = Database::publish_profile_memory_test_runtime( - &memory_path, - &authority, - TestDatabaseRuntimeMode::Initialize, - ) - .await - .expect("registered profile memory"); - let memory_graph_runtime = bind_profile_memory_graph_runtime(&memory); - let registry: Arc = Arc::new(FixtureProfileRuntime { - profile_id: UserProfileId::new("profile.automation.fixture").expect("profile id"), - sessions: session_runtime.profile_database_arc(), - memory, - }); - Self { - profile_root, - registry, - _memory_graph_runtime: memory_graph_runtime, - _session_runtime: session_runtime, - _directory: directory, - } - } - - async fn memory(&self) -> Database { - self.registry - .open_user_memory_db() - .await - .expect("profile memory") - } -} - -fn configuration_revision() -> ConfigurationRevisionId { - ConfigurationRevisionId::new("config.user-automation-test.v1").expect("configuration revision") -} - -fn no_skill_needed_output() -> Value { - json!({ - "skills": [], - "outcome": "no_skill_needed", - "decision": { - "reason": "No repeated user-scoped evidence warrants a managed skill mutation.", - "remedy": "insufficient_repeated_evidence" - } - }) -} - -fn test_run_control() -> AutomationRunControl { - let interrupted = Arc::new(std::sync::atomic::AtomicBool::new(false)); - AutomationRunControl::from_interrupted({ - let interrupted = Arc::clone(&interrupted); - Arc::new(move || interrupted.load(std::sync::atomic::Ordering::Acquire)) - }) -} - -struct JsonBackend { - task: AgentTaskKind, - output: Value, - calls: AtomicUsize, -} - -impl JsonBackend { - fn new(task: AgentTaskKind, output: Value) -> Self { - Self { - task, - output, - calls: AtomicUsize::new(0), - } - } - - fn calls(&self) -> usize { - self.calls.load(Ordering::SeqCst) - } -} - -impl AgentTaskBackend for JsonBackend { - fn run_task( - &self, - request: &AgentTaskRequest, - ) -> std::result::Result - { - self.calls.fetch_add(1, Ordering::SeqCst); - assert_eq!(request.task, self.task); - Ok(AgentTaskResponse { - run_id: request.run_id.clone(), - task: request.task, - output_text: self.output.to_string(), - output_json: Some(self.output.clone()), - model: Some("fixture-model".to_string()), - provider: Some("fixture-provider".to_string()), - input_tokens: Some(10), - output_tokens: Some(20), - }) - } -} - -enum RetrievalOutcome { - Complete(Box), - Rejected(&'static str), - Empty, -} - -struct TestRetrieval { - anchor_session_id: SessionId, - outcome: RetrievalOutcome, -} - -impl TestRetrieval { - fn message(provider: &str, session_id: &str, message_id: &str, text: &str) -> Self { - Self { - anchor_session_id: SessionId::new(session_id).expect("session id"), - outcome: RetrievalOutcome::Complete(Box::new(AutomationTemporalEvidenceItem { - anchor_id: "user-scope-anchor".to_string(), - stable_id: "user-scope-stable".to_string(), - provider: provider.to_string(), - session_id: session_id.to_string(), - message_id: Some(message_id.to_string()), - source_id: Some("user-scope-occurrence".to_string()), - store_id: Some(1), - role: Some("user".to_string()), - ordinal: Some(1), - session_total_messages: Some(1), - knowledge_at_micros: 1_715_100_001_000_000, - normalized_score_micros: 1_000_000, - snippet: text.to_string(), - })), - } - } - - fn rejected(reason: &'static str) -> Self { - Self { - anchor_session_id: SessionId::new("rejected-user-scope").expect("session id"), - outcome: RetrievalOutcome::Rejected(reason), - } - } - - fn empty() -> Self { - Self { - anchor_session_id: SessionId::new("empty-user-scope").expect("session id"), - outcome: RetrievalOutcome::Empty, - } - } -} - -impl AutomationSessionRetrieval for TestRetrieval { - fn anchor_session_id(&self) -> &SessionId { - &self.anchor_session_id - } - - fn retrieve( - &self, - _query: tracedecay_session_memory::session::SessionTemporalQuery, - ) -> AutomationSessionRetrievalFuture<'_> { - Box::pin(async move { - match &self.outcome { - RetrievalOutcome::Complete(item) => { - AutomationTemporalRetrieval::Complete(AutomationTemporalEvidence { - items: vec![item.as_ref().clone()], - coverage: TemporalCoverageCountsV1 { - visible: 1, - hidden: 0, - unknown: 0, - redacted: 0, - }, - }) - } - RetrievalOutcome::Rejected(reason) => AutomationTemporalRetrieval::Rejected(reason), - RetrievalOutcome::Empty => AutomationTemporalRetrieval::CompleteZero, - } - }) - } -} - -fn enabled_user_config() -> AutomationConfig { - AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, - tasks: AutomationTaskSet { - memory_curator: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - session_reflector: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - }, - ..AutomationConfig::default() - } -} - -#[tokio::test] -async fn projectless_reflection_uses_caller_supplied_automation_configuration() { - let harness = UserRuntimeHarness::open("user-reflection").await; - let backend = JsonBackend::new( - AgentTaskKind::SessionReflector, - json!({ - "facts": [{ - "content": "The user wants projectless conversations stored in profile memory", - "category": "user_pref", - "tags": ["memory", "projectless"], - "entities": ["TraceDecay"], - "trust": 0.9, - "source_span": { - "session_id": "user-session-1", - "message_id": "user-message-1" - }, - "reason": "The user explicitly stated this durable preference" - }] - }), - ); - let config = enabled_user_config(); - let retrieval = TestRetrieval::message( - "hermes", - "user-session-1", - "user-message-1", - "Always keep general conversations in user memory.", - ); - - let run = run_user_session_reflector_with_backend_and_retrieval( - &harness.profile_root, - Arc::clone(&harness.registry), - &config, - &test_run_control(), - &configuration_revision(), - AutomationTaskIo { - backend: &backend, - retrieval: &retrieval, - }, - SessionReflectorAutomationOptions { - provider: "hermes".to_string(), - query: "user memory".to_string(), - ..SessionReflectorAutomationOptions::default() - }, - ) - .await - .expect("profile reflection"); - - assert_eq!(run.report["status"], json!("applied")); - let database = harness.memory().await; - let memory = MemoryApplication::new(FactOwnerV1::Profile, DatabaseFactStore::new(&database)) - .expect("profile memory authority"); - assert_eq!( - memory - .query_current_facts( - tracedecay_store::CurrentFactsQuery::new(FactOwnerV1::Profile, None, 10) - .expect("canonical profile fact query"), - ) - .await - .expect("profile facts") - .len(), - 1 - ); - assert!(database.database_path().is_file()); -} - -#[tokio::test] -async fn projectless_skill_writer_uses_user_ledger() { - let harness = UserRuntimeHarness::open("user-skill-writer").await; - let backend = JsonBackend::new(AgentTaskKind::SkillWriter, no_skill_needed_output()); - let retrieval = TestRetrieval::message( - "hermes", - "user-session-1", - "user-message-1", - "Review recurring automation workflows.", - ); - - let run = run_user_skill_writer_with_backend_and_retrieval( - crate::automation::host_io::plain_file_host_io(), - &harness.profile_root, - Arc::clone(&harness.registry), - &enabled_user_config(), - &configuration_revision(), - &backend, - &retrieval, - SkillWriterAutomationOptions { - provider: "hermes".to_string(), - query: "automation workflows".to_string(), - ..SkillWriterAutomationOptions::default() - }, - ) - .await - .expect("profile skill writer"); - - assert_eq!(backend.calls(), 1); - assert_eq!(run.ledger_record.status, AutomationRunStatus::Succeeded); - let records = crate::automation::run_ledger::load_run_records( - &user_automation_root(&harness.profile_root), - 10, - ) - .await - .expect("user ledger"); - assert_eq!(records.len(), 1); - assert_eq!(records[0].task, AgentTaskKind::SkillWriter); -} - -#[tokio::test] -async fn terminal_evidence_rejections_do_not_run_user_backends() { - for reason in [ - "session_evidence_denied", - "session_evidence_stale", - "session_evidence_partial", - "session_evidence_unavailable", - "session_evidence_budget_exhausted", - "session_evidence_cancelled", - ] { - let harness = UserRuntimeHarness::open("user-rejection").await; - let retrieval = TestRetrieval::rejected(reason); - let reflector_backend = - JsonBackend::new(AgentTaskKind::SessionReflector, json!({ "facts": [] })); - let skill_backend = JsonBackend::new(AgentTaskKind::SkillWriter, no_skill_needed_output()); - let config = enabled_user_config(); - - let reflector = run_user_session_reflector_with_backend_and_retrieval( - &harness.profile_root, - Arc::clone(&harness.registry), - &config, - &test_run_control(), - &configuration_revision(), - AutomationTaskIo { - backend: &reflector_backend, - retrieval: &retrieval, - }, - SessionReflectorAutomationOptions::default(), - ) - .await - .expect("rejected reflector"); - let skill = run_user_skill_writer_with_backend_and_retrieval( - crate::automation::host_io::plain_file_host_io(), - &harness.profile_root, - Arc::clone(&harness.registry), - &config, - &configuration_revision(), - &skill_backend, - &retrieval, - SkillWriterAutomationOptions::default(), - ) - .await - .expect("rejected skill writer"); - - assert_eq!(reflector.ledger_record.error.as_deref(), Some(reason)); - assert_eq!(skill.ledger_record.error.as_deref(), Some(reason)); - assert_eq!(reflector_backend.calls(), 0); - assert_eq!(skill_backend.calls(), 0); - assert!(!user_automation_root(&harness.profile_root).exists()); - } - - let harness = UserRuntimeHarness::open("user-empty-evidence").await; - let retrieval = TestRetrieval::empty(); - let reflector_backend = - JsonBackend::new(AgentTaskKind::SessionReflector, json!({ "facts": [] })); - let skill_backend = JsonBackend::new(AgentTaskKind::SkillWriter, no_skill_needed_output()); - let config = enabled_user_config(); - let reflector = run_user_session_reflector_with_backend_and_retrieval( - &harness.profile_root, - Arc::clone(&harness.registry), - &config, - &test_run_control(), - &configuration_revision(), - AutomationTaskIo { - backend: &reflector_backend, - retrieval: &retrieval, - }, - SessionReflectorAutomationOptions::default(), - ) - .await - .expect("empty reflector"); - let skill = run_user_skill_writer_with_backend_and_retrieval( - crate::automation::host_io::plain_file_host_io(), - &harness.profile_root, - Arc::clone(&harness.registry), - &config, - &configuration_revision(), - &skill_backend, - &retrieval, - SkillWriterAutomationOptions::default(), - ) - .await - .expect("empty skill writer"); - assert_eq!( - reflector.ledger_record.error.as_deref(), - Some("no_session_evidence") - ); - assert_eq!( - skill.ledger_record.error.as_deref(), - Some("no_skill_writer_evidence") - ); -} - -#[tokio::test] -async fn projectless_memory_curator_quarantines_deprecated_operations() { - let harness = UserRuntimeHarness::open("user-curator-deprecated").await; - let database = harness.memory().await; - let run_control = test_run_control(); - let seeded = seed_user_duplicate_facts(&database, &run_control).await; - let backend = JsonBackend::new( - AgentTaskKind::MemoryCurator, - json!({ - "ops": [{ - "op": "delete", - "fact_id": seeded.loser_id, - "confidence": 0.99, - "reason": "legacy operation must not mutate canonical memory" - }] - }), - ); - - let error = run_user_memory_curator_with_backend( - &harness.profile_root, - Arc::clone(&harness.registry), - &enabled_user_config(), - &configuration_revision(), - &backend, - MemoryCuratorAutomationOptions::default(), - &run_control, - ) - .await - .expect_err("deprecated operation must exhaust bounded repair and quarantine"); - - assert_eq!( - error.to_string(), - "config error: memory curator validation repair budget exhausted; output quarantined" - ); - let memory = MemoryApplication::new(FactOwnerV1::Profile, DatabaseFactStore::new(&database)) - .expect("profile memory authority"); - assert!( - memory - .get_project_memory_fact( - tracedecay_store::ProjectMemoryFactIdV1::new(FactOwnerV1::Profile, seeded.loser_id) - .expect("canonical loser target"), - run_control.read_control(), - ) - .await - .expect("canonical loser projection") - .is_some() - ); -} - -#[tokio::test] -async fn projectless_memory_curator_links_profile_memory_with_canonical_ids() { - let harness = UserRuntimeHarness::open("user-curator-link").await; - let database = harness.memory().await; - let run_control = test_run_control(); - let seeded = seed_user_duplicate_facts(&database, &run_control).await; - let backend = JsonBackend::new( - AgentTaskKind::MemoryCurator, - json!({ - "ops": [{ - "op": "link_facts", - "source": { - "fact_id": seeded.winner_id, - "expected_last_event_id": seeded.winner_event_id, - }, - "target": { - "fact_id": seeded.loser_id, - "expected_last_event_id": seeded.loser_event_id, - }, - "relation": "supports", - "evidence_facts": [{ - "fact_id": seeded.winner_id, - "expected_last_event_id": seeded.winner_event_id, - }], - "confidence": 0.99, - "source_label": "user-scope-fixture", - "metadata": {"reason": "The durable profile preferences support each other"} - }] - }), - ); - - let run = run_user_memory_curator_with_backend( - &harness.profile_root, - Arc::clone(&harness.registry), - &enabled_user_config(), - &configuration_revision(), - &backend, - MemoryCuratorAutomationOptions::default(), - &run_control, - ) - .await - .expect("profile memory curator"); - - assert_eq!(run.report["llm_apply"]["applied"], json!(1)); - assert_eq!( - run.report["llm_apply"]["receipts"][0]["receipt"]["facts_linked"], - json!(1) - ); -} - -#[tokio::test] -async fn projectless_memory_curator_normalizes_profile_fact_tags() { - let harness = UserRuntimeHarness::open("user-curator-normalize-tags").await; - let database = harness.memory().await; - let run_control = test_run_control(); - let seeded = seed_user_duplicate_facts(&database, &run_control).await; - let backend = JsonBackend::new( - AgentTaskKind::MemoryCurator, - json!({ - "ops": [{ - "op": "normalize_tags", - "target": { - "fact_id": seeded.winner_id, - "expected_last_event_id": seeded.winner_event_id, - }, - "tags": ["memory", "projectless"], - "evidence_facts": [{ - "fact_id": seeded.loser_id, - "expected_last_event_id": seeded.loser_event_id, - }], - "confidence": 0.99, - }] - }), - ); - - run_user_memory_curator_with_backend( - &harness.profile_root, - Arc::clone(&harness.registry), - &enabled_user_config(), - &configuration_revision(), - &backend, - MemoryCuratorAutomationOptions::default(), - &run_control, - ) - .await - .expect("profile memory curator"); - - let memory = MemoryApplication::new(FactOwnerV1::Profile, DatabaseFactStore::new(&database)) - .expect("profile memory authority"); - let fact = memory - .get_project_memory_fact( - tracedecay_store::ProjectMemoryFactIdV1::new(FactOwnerV1::Profile, seeded.winner_id) - .expect("canonical winner target"), - run_control.read_control(), - ) - .await - .expect("normalized fact") - .expect("fact remains"); - let tracedecay_store::ProjectMemoryFactProjectionV1::Available(fact) = fact else { - panic!("normalized fact payload must remain available"); - }; - assert_eq!(fact.tags(), ["memory", "projectless"]); -} - -#[derive(Clone)] -struct SeededUserDuplicateFacts { - winner_id: FactId, - winner_event_id: tracedecay_domain::FactEventId, - loser_id: FactId, - loser_event_id: tracedecay_domain::FactEventId, -} - -async fn seed_user_duplicate_facts( - database: &Database, - run_control: &AutomationRunControl, -) -> SeededUserDuplicateFacts { - let owner = FactOwnerV1::Profile; - let memory = MemoryApplication::new(owner.clone(), DatabaseFactStore::new(database)) - .expect("profile memory authority"); - let mut facts = Vec::with_capacity(2); - for content in [ - "General conversations belong in user memory.", - "General conversations belong in user memory!", - ] { - let preflight = memory - .preflight_project_memory_fact_add( - ProjectMemoryFactAddRequest { - content: content.to_string(), - category: tracedecay_domain::FactCategoryV1::UserPref, - source_label: Some("user-scope-fixture".to_string()), - tags: vec!["memory".to_string()], - entities: Vec::new(), - trust: Some(Confidence::new(0.95).expect("fixture confidence")), - metadata: json!({}), - }, - None, - ) - .expect("preflight profile fact"); - let write_control = run_control.write_control(); - let outcome = memory - .add_preflighted_project_memory_fact(preflight, &write_control) - .await - .expect("seed profile fact"); - let ProjectMemoryFactAddRequestOutcome::Applied(outcome) = outcome else { - panic!("fixture add must apply"); - }; - let tracedecay_store::ProjectMemoryFactProjectionV1::Available(fact) = outcome.fact() - else { - panic!("fixture fact payload must remain available"); - }; - facts.push((fact.fact_id().clone(), fact.last_event_id().clone())); - } - let roots = facts - .iter() - .map(|(fact_id, _)| fact_id.clone()) - .collect::>(); - let mut graph_current = false; - for _ in 0..512 { - let query = ProjectMemoryGraphQueryV1::new(owner.clone(), roots.clone(), 4_096) - .expect("profile graph readiness query"); - match memory - .project_memory_graph(query, run_control.read_control()) - .await - { - Ok(_) => { - graph_current = true; - break; - } - Err(MemoryApplicationError::Store( - FactStoreError::GraphConflict | FactStoreError::GraphUnavailable, - )) => tokio::task::yield_now().await, - Err(error) => panic!("profile graph readiness failed: {error}"), - } - } - assert!( - graph_current, - "profile graph did not reach the seeded facts" - ); - let (winner_id, winner_event_id) = facts.remove(0); - let (loser_id, loser_event_id) = facts.remove(0); - SeededUserDuplicateFacts { - winner_id, - winner_event_id, - loser_id, - loser_event_id, - } -} diff --git a/crates/tracedecay-automation-runtime/src/automation/runner/user_scope_tests/user_scope_graph_runtime.rs b/crates/tracedecay-automation-runtime/src/automation/runner/user_scope_tests/user_scope_graph_runtime.rs deleted file mode 100644 index b3c0717f32..0000000000 --- a/crates/tracedecay-automation-runtime/src/automation/runner/user_scope_tests/user_scope_graph_runtime.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; - -use tracedecay_graph_db::{ - GraphDbError, GraphGenerationManifest, GraphIdempotencyKey, GraphProjectionIdentity, - NeverCancelled, VerifiedGraphSnapshot, -}; -use tracedecay_store::{FactReadControl, StoreRuntimeBindingV1, VerifiedStoreLocatorV1}; - -use tracedecay_runtime_core::db::Database; -use tracedecay_runtime_core::shard_runtime::VerifiedGraphRuntimePortV1; - -struct ProfileMemoryGraphRuntime { - binding: StoreRuntimeBindingV1, - locator: VerifiedStoreLocatorV1, - manifest: Mutex>, -} - -impl ProfileMemoryGraphRuntime { - fn new(database: &Database) -> Self { - Self { - binding: database.registered_binding().clone(), - locator: database.registered_verified_locator().clone(), - manifest: Mutex::new(None), - } - } - - fn remember( - &self, - manifest: &GraphGenerationManifest, - ) -> Result { - let snapshot = VerifiedGraphSnapshot::memory(manifest.clone(), Arc::new(NeverCancelled))?; - *self - .manifest - .lock() - .map_err(|_| GraphDbError::invalid("profile memory graph fixture lock poisoned"))? = - Some(manifest.clone()); - Ok(snapshot) - } -} - -impl VerifiedGraphRuntimePortV1 for ProfileMemoryGraphRuntime { - fn relational_binding(&self) -> &StoreRuntimeBindingV1 { - &self.binding - } - - fn relational_verified_locator(&self) -> &VerifiedStoreLocatorV1 { - &self.locator - } - - fn cancel_reconciliation(&self) {} - - fn publish_verified_manifest( - &self, - manifest: &GraphGenerationManifest, - _idempotency_key: GraphIdempotencyKey, - cancelled: Arc, - ) -> Result { - if cancelled.load(Ordering::Acquire) { - return Err(GraphDbError::Cancelled); - } - self.remember(manifest) - } - - fn reconcile_verified_manifest( - &self, - manifest: &GraphGenerationManifest, - _idempotency_key: GraphIdempotencyKey, - ) -> Result { - self.remember(manifest) - } - - fn verified_snapshot( - &self, - projection: &GraphProjectionIdentity, - read_control: FactReadControl, - ) -> Result, GraphDbError> { - if read_control.interrupted() { - return Err(GraphDbError::Cancelled); - } - let manifest = self - .manifest - .lock() - .map_err(|_| GraphDbError::invalid("profile memory graph fixture lock poisoned"))? - .clone(); - match manifest { - Some(manifest) if &manifest.projection == projection => Ok(Some( - VerifiedGraphSnapshot::memory(manifest, Arc::new(NeverCancelled))?, - )), - Some(_) | None => Ok(None), - } - } -} - -/// Binds the profile memory graph fixture and returns the strong port. -/// -/// `Database::bind_memory_graph_runtime` retains only a weak binding, so the -/// caller must hold the returned `Arc` for as long as graph operations should -/// stay mountable. -pub(super) fn bind_profile_memory_graph_runtime( - database: &Database, -) -> Arc { - let runtime: Arc = - Arc::new(ProfileMemoryGraphRuntime::new(database)); - database - .bind_memory_graph_runtime(Arc::clone(&runtime)) - .expect("bind profile memory graph fixture"); - runtime -} diff --git a/crates/tracedecay-automation-runtime/src/automation/scheduler.rs b/crates/tracedecay-automation-runtime/src/automation/scheduler.rs index 668195c401..477990f7db 100644 --- a/crates/tracedecay-automation-runtime/src/automation/scheduler.rs +++ b/crates/tracedecay-automation-runtime/src/automation/scheduler.rs @@ -9,7 +9,6 @@ use cap_std::fs::Dir; use cap_std::fs::OpenOptionsExt; use cap_std::time::SystemClock; use serde::{Deserialize, Serialize}; -use tracedecay_automation::config::validate_schedule as validate_leaf_schedule; pub use tracedecay_automation::config::{AutomationSchedule, CronSchedule, parse_schedule}; use tracedecay_automation::evidence_budget::{ SessionEvidenceBudgetBackoff, SessionEvidenceBudgetExceeded, SessionEvidenceBudgetGate, @@ -749,10 +748,6 @@ pub fn stale_lock_secs(config: &AutomationConfig, task: AgentTaskKind) -> Option .or(Some(DEFAULT_STALE_LOCK_SECS)) } -pub fn validate_schedule(schedule: Option<&str>) -> Result<()> { - Ok(validate_leaf_schedule(schedule)?) -} - /// User jobs carry their own schedule/enabled state (see /// `automation::jobs`), so the fixed-task config lookup falls back to a /// disabled default that makes the fixed-task gates skip them. @@ -816,7 +811,7 @@ fn parse_started_at(record: &AutomationRunLedgerRecord) -> Result { canonical_record_started_at_seconds(record, &format!("run '{}' started_at", record.run_id)) } -fn elapsed_secs(completed_at: i64, now_secs: i64) -> u64 { +pub(crate) fn elapsed_secs(completed_at: i64, now_secs: i64) -> u64 { if now_secs < completed_at { return 0; } diff --git a/crates/tracedecay-automation-runtime/src/automation/session_reflector.rs b/crates/tracedecay-automation-runtime/src/automation/session_reflector.rs index 735f5fa528..3b8613a412 100644 --- a/crates/tracedecay-automation-runtime/src/automation/session_reflector.rs +++ b/crates/tracedecay-automation-runtime/src/automation/session_reflector.rs @@ -10,6 +10,7 @@ use tracedecay_store::{ ProjectMemoryFactSearchQuery, ProjectMemoryFactStore, }; +use super::normalized_non_empty; use crate::automation::lifecycle::AutomationRunControl; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_session_memory::memory::MemoryApplication; @@ -446,8 +447,8 @@ fn session_fact_category(category: &str) -> Option { /// Accepts numeric trust in `[0, 1]` plus the `low`/`medium`/`high` bucket /// labels models frequently emit despite the numeric prompt instruction. -/// Buckets map to the representative scores defined next to -/// [`tracedecay_session_memory::memory::trust::trust_bucket`], so they cannot drift out of their +/// Buckets map to the representative scores in +/// [`tracedecay_session_memory::memory::trust`], so they cannot drift out of their /// documented ranges. /// /// Deliberate decision: the prompt forbids string labels, but they are @@ -559,12 +560,3 @@ fn quarantined_fact_with_validation( "validation": validation, })) } - -fn normalized_non_empty(value: &str) -> Option { - let value = value.trim(); - if value.is_empty() { - None - } else { - Some(value.to_string()) - } -} diff --git a/crates/tracedecay-automation-runtime/src/automation/skill_usage.rs b/crates/tracedecay-automation-runtime/src/automation/skill_usage.rs index 020746e0a0..d45d0f3a74 100644 --- a/crates/tracedecay-automation-runtime/src/automation/skill_usage.rs +++ b/crates/tracedecay-automation-runtime/src/automation/skill_usage.rs @@ -204,22 +204,6 @@ pub async fn load_skill_usage_ledger(profile_root: &Path) -> Result Result<()> { - // Split the snapshot. Do not rewrite an aggregate file, and do not delete - // a skill file that this snapshot does not mention. - for record in ledger.records.values() { - let owned = record.clone(); - let skill_id = owned.skill_id.clone(); - let first_seen_at = owned.first_seen_at; - store::update_record(profile_root, &skill_id, first_seen_at, move |slot| { - *slot = owned; - }) - .await?; - } - Ok(()) -} - pub async fn sync_skill_usage_metadata(profile_root: &Path, skill: &ManagedSkill) -> Result<()> { let skill = skill.clone(); let skill_id = skill.metadata.id.clone(); diff --git a/crates/tracedecay-automation-runtime/src/automation/skill_writer.rs b/crates/tracedecay-automation-runtime/src/automation/skill_writer.rs index ca60764ef0..0f68418417 100644 --- a/crates/tracedecay-automation-runtime/src/automation/skill_writer.rs +++ b/crates/tracedecay-automation-runtime/src/automation/skill_writer.rs @@ -16,13 +16,13 @@ use super::skill_usage::{ SkillOverlapCandidate, SkillStaleRecommendation, SkillUsageSummary, skill_improvement_recommendations as usage_skill_improvement_recommendations, }; -use crate::ports::session_evidence::LcmGrepHit; use tracedecay_automation::analytics::ToolFamilySignal; use tracedecay_automation::managed_skills::validate_managed_skill_update; use tracedecay_automation::text::truncate_chars_for_prompt; use tracedecay_domain::errors::Result; +use tracedecay_lcm::LcmGrepHit; -use super::config_error; +use super::{config_error, normalized_non_empty}; mod consolidation; @@ -936,15 +936,6 @@ fn rejected_skill(proposal: &Value, reason: &str) -> Value { }) } -fn normalized_non_empty(value: &str) -> Option { - let value = value.trim(); - if value.is_empty() { - None - } else { - Some(value.to_string()) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tracedecay-automation-runtime/src/ports.rs b/crates/tracedecay-automation-runtime/src/ports.rs index d48e7e2cdd..8f2a2d0c2d 100644 --- a/crates/tracedecay-automation-runtime/src/ports.rs +++ b/crates/tracedecay-automation-runtime/src/ports.rs @@ -10,5 +10,4 @@ pub mod codex_app_server; pub mod project_runtime; -pub mod session_evidence; pub mod session_store; diff --git a/crates/tracedecay-automation-runtime/src/ports/session_evidence.rs b/crates/tracedecay-automation-runtime/src/ports/session_evidence.rs deleted file mode 100644 index e28d0983c8..0000000000 --- a/crates/tracedecay-automation-runtime/src/ports/session_evidence.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! The session-evidence contract automation states its queries in. -//! -//! `automation::runner` builds evidence for the memory curator, session -//! reflector, and skill writer by grepping stored session transcripts. The -//! LCM query engine that answers those greps lives in `tracedecay-lcm` -//! behind a runtime this crate must not open for itself, so the request -//! selectors and the hit shape are declared here and the execution arrives -//! through `runner::retrieval`'s `AutomationSessionRetrieval` port. -//! -//! These deliberately mirror `tracedecay_lcm`'s selectors rather than reusing -//! them: this crate states *what evidence automation wants*, and the session -//! runtime decides how to satisfy it. The serde representations match, so the -//! root adapter is a field-for-field conversion. -//! -//! Root wiring: the root converts between these and -//! `tracedecay_lcm::{LcmScope, LcmGrepSort, LcmGrepHit}` in the adapter it -//! registers as `AutomationSessionRetrieval`. - -/// Canonical definitions live in `tracedecay_lcm::types`; -/// re-exported here so this crate's port keeps its historical path. -pub use tracedecay_lcm::types::{LcmGrepHit, LcmGrepSort, LcmScope}; diff --git a/crates/tracedecay-automation/src/analytics.rs b/crates/tracedecay-automation/src/analytics.rs index 3069d6068b..e804faeb95 100644 --- a/crates/tracedecay-automation/src/analytics.rs +++ b/crates/tracedecay-automation/src/analytics.rs @@ -7,7 +7,8 @@ //! //! Host MCP namespaces are restated here as literals so this contracts leaf //! does not depend on `tracedecay-agent-hosts`. They must stay aligned with -//! `tracedecay_agent_hosts::tool_name::ALL_TOOL_PREFIXES`. +//! the namespaces hosts still emit, including the prior plugin key and the +//! flattened separator. use std::collections::{BTreeMap, BTreeSet}; diff --git a/crates/tracedecay-capture/src/claude/canonical.rs b/crates/tracedecay-capture/src/claude/canonical.rs index 84cb8a1c95..aa2e43c59d 100644 --- a/crates/tracedecay-capture/src/claude/canonical.rs +++ b/crates/tracedecay-capture/src/claude/canonical.rs @@ -571,17 +571,12 @@ fn system_hook_has_signal(native: &Value) -> bool { } fn canonical_role(message: &Value, record_kind: &str) -> CanonicalMessageRoleV1 { - match message - .get("role") - .and_then(Value::as_str) - .unwrap_or(record_kind) - { - "user" => CanonicalMessageRoleV1::User, - "assistant" => CanonicalMessageRoleV1::Assistant, - "system" => CanonicalMessageRoleV1::System, - "tool" => CanonicalMessageRoleV1::Tool, - _ => CanonicalMessageRoleV1::Unknown, - } + CanonicalMessageRoleV1::from_wire_label( + message + .get("role") + .and_then(Value::as_str) + .unwrap_or(record_kind), + ) } fn optional_id(native: &Value, keys: &[&str]) -> Option { diff --git a/crates/tracedecay-capture/src/codex.rs b/crates/tracedecay-capture/src/codex.rs index d2cf0b6a25..01063b2dd7 100644 --- a/crates/tracedecay-capture/src/codex.rs +++ b/crates/tracedecay-capture/src/codex.rs @@ -744,7 +744,7 @@ fn append_codex_response_item_facts( return; } facts.push(CanonicalObservationFactV1::Message { - role: canonical_message_role(role), + role: crate::content::canonical_message_role(role), content, model: payload .get("model") @@ -895,16 +895,6 @@ fn timestamp_from_record(record: &Value) -> Option { .and_then(parse_rfc3339_timestamp) } -fn canonical_message_role(role: Option<&str>) -> CanonicalMessageRoleV1 { - match role { - Some("user") => CanonicalMessageRoleV1::User, - Some("assistant") => CanonicalMessageRoleV1::Assistant, - Some("system" | "developer") => CanonicalMessageRoleV1::System, - Some("tool") => CanonicalMessageRoleV1::Tool, - _ => CanonicalMessageRoleV1::Unknown, - } -} - fn canonical_native_observation_id( native_id: Option<&str>, fallback: &ObservationId, diff --git a/crates/tracedecay-capture/src/content.rs b/crates/tracedecay-capture/src/content.rs index 804e26703b..1abcf7c6c1 100644 --- a/crates/tracedecay-capture/src/content.rs +++ b/crates/tracedecay-capture/src/content.rs @@ -1,4 +1,5 @@ use serde_json::Value; +use tracedecay_domain::CanonicalMessageRoleV1; /// Whether a native message `content` value carries nothing renderable: /// null, blank text, or an empty collection. Numbers and booleans count as @@ -12,3 +13,52 @@ pub fn content_is_empty(content: &Value) -> bool { Value::Bool(_) | Value::Number(_) => false, } } + +/// Cursor and Codex share this map. `developer` is their system alias; every +/// other label, including a missing role, uses the canonical wire parser. +pub(crate) fn canonical_message_role(role: Option<&str>) -> CanonicalMessageRoleV1 { + match role { + Some("developer") => CanonicalMessageRoleV1::System, + Some(label) => CanonicalMessageRoleV1::from_wire_label(label), + None => CanonicalMessageRoleV1::Unknown, + } +} + +#[cfg(test)] +mod tests { + use tracedecay_domain::CanonicalMessageRoleV1; + + use super::canonical_message_role; + + #[test] + fn developer_is_system_and_other_labels_use_the_canonical_map() { + assert_eq!( + canonical_message_role(Some("developer")), + CanonicalMessageRoleV1::System + ); + assert_eq!( + canonical_message_role(Some("user")), + CanonicalMessageRoleV1::User + ); + assert_eq!( + canonical_message_role(Some("assistant")), + CanonicalMessageRoleV1::Assistant + ); + assert_eq!( + canonical_message_role(Some("system")), + CanonicalMessageRoleV1::System + ); + assert_eq!( + canonical_message_role(Some("tool")), + CanonicalMessageRoleV1::Tool + ); + assert_eq!( + canonical_message_role(Some("model")), + CanonicalMessageRoleV1::Unknown + ); + assert_eq!( + canonical_message_role(None), + CanonicalMessageRoleV1::Unknown + ); + } +} diff --git a/crates/tracedecay-capture/src/cursor.rs b/crates/tracedecay-capture/src/cursor.rs index ecf22f8468..7e886cd0da 100644 --- a/crates/tracedecay-capture/src/cursor.rs +++ b/crates/tracedecay-capture/src/cursor.rs @@ -1,10 +1,10 @@ use serde_json::Value; use sha2::{Digest, Sha256}; use tracedecay_domain::{ - CanonicalGitEvidenceKindV1, CanonicalMessageRoleV1, CanonicalObservationEnvelopeV1, - CanonicalObservationEvidenceV1, CanonicalObservationFactV1, CanonicalObservationRelationsV1, - CanonicalReasoningVisibilityV1, CanonicalUnknownStateV1, CanonicalWorkflowEvidenceKindV1, - ObservationId, ObservationOrderingDomainV1, ObservationPositionalOccurrenceV1, ProviderId, + CanonicalGitEvidenceKindV1, CanonicalObservationEnvelopeV1, CanonicalObservationEvidenceV1, + CanonicalObservationFactV1, CanonicalObservationRelationsV1, CanonicalReasoningVisibilityV1, + CanonicalUnknownStateV1, CanonicalWorkflowEvidenceKindV1, ObservationId, + ObservationOrderingDomainV1, ObservationPositionalOccurrenceV1, ProviderId, ProviderUsageCounterSemanticsV1, ProviderUsageCountersV1, ProviderUsageModelV1, ProviderUsageScopeV1, SessionId, }; @@ -137,7 +137,9 @@ fn normalize_cursor_record( if let Some(content) = content { if let Some(message_content) = canonical_cursor_message_content(content) { facts.push(CanonicalObservationFactV1::Message { - role: canonical_message_role(native.get("role").and_then(Value::as_str)), + role: crate::content::canonical_message_role( + native.get("role").and_then(Value::as_str), + ), content: message_content, model: cursor_record_message_model(native, message.unwrap_or(native)).or_else( || { @@ -567,16 +569,6 @@ pub fn cursor_projected_message_id( ObservationId::new(message_id).map_err(|_| ObservationRecordParseErrorV1::NormalizationFailed) } -fn canonical_message_role(role: Option<&str>) -> CanonicalMessageRoleV1 { - match role { - Some("user") => CanonicalMessageRoleV1::User, - Some("assistant") => CanonicalMessageRoleV1::Assistant, - Some("system" | "developer") => CanonicalMessageRoleV1::System, - Some("tool") => CanonicalMessageRoleV1::Tool, - _ => CanonicalMessageRoleV1::Unknown, - } -} - fn canonical_native_observation_id( native_id: Option<&str>, fallback: &ObservationId, diff --git a/crates/tracedecay-capture/src/kimi.rs b/crates/tracedecay-capture/src/kimi.rs index ad9e90b837..8797e1f809 100644 --- a/crates/tracedecay-capture/src/kimi.rs +++ b/crates/tracedecay-capture/src/kimi.rs @@ -359,13 +359,10 @@ fn append_tool_result( } fn canonical_role(role: &str) -> Result { - match role { - "user" => Ok(CanonicalMessageRoleV1::User), - "assistant" => Ok(CanonicalMessageRoleV1::Assistant), - "system" | "_system_prompt" => Ok(CanonicalMessageRoleV1::System), - "tool" => Ok(CanonicalMessageRoleV1::Tool), - _ => Err(invalid()), + if role == "_system_prompt" { + return Ok(CanonicalMessageRoleV1::System); } + CanonicalMessageRoleV1::from_known_label(role).ok_or_else(invalid) } fn content_text(content: &Value) -> Option<&str> { diff --git a/crates/tracedecay-capture/src/opencode.rs b/crates/tracedecay-capture/src/opencode.rs index 4a34f50cb0..4728b06a9e 100644 --- a/crates/tracedecay-capture/src/opencode.rs +++ b/crates/tracedecay-capture/src/opencode.rs @@ -56,7 +56,7 @@ fn normalize_opencode_record( let mut facts = Vec::new(); if let Some(content) = message_content(parts) { facts.push(CanonicalObservationFactV1::Message { - role: canonical_role(role), + role: CanonicalMessageRoleV1::from_wire_label(role), content, model: message .pointer("/model/modelID") @@ -290,16 +290,6 @@ fn message_content(parts: &[Value]) -> Option { (!content.is_empty()).then_some(Value::Array(content)) } -fn canonical_role(role: &str) -> CanonicalMessageRoleV1 { - match role { - "user" => CanonicalMessageRoleV1::User, - "assistant" => CanonicalMessageRoleV1::Assistant, - "system" => CanonicalMessageRoleV1::System, - "tool" => CanonicalMessageRoleV1::Tool, - _ => CanonicalMessageRoleV1::Unknown, - } -} - /// OpenCode's `time.created` is strictly numeric; string forms stay /// unsupported, so only the millis/seconds normalization is shared. fn timestamp_secs(value: Option<&Value>) -> Option { diff --git a/crates/tracedecay-cli/Cargo.toml b/crates/tracedecay-cli/Cargo.toml index ff3248ad49..74b459bdcd 100644 --- a/crates/tracedecay-cli/Cargo.toml +++ b/crates/tracedecay-cli/Cargo.toml @@ -220,7 +220,6 @@ tracedecay-global-db = { path = "../tracedecay-global-db", version = "0.1.0", fe tracedecay-lcm = { path = "../tracedecay-lcm", version = "0.1.0" } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0", features = ["test-helpers"] } tracedecay-session-temporal-store = { path = "../tracedecay-session-temporal-store", version = "0.1.0" } -ureq = { version = "3", features = ["json"] } [target.'cfg(target_os = "linux")'.dev-dependencies] xattr = "1" diff --git a/crates/tracedecay-cli/src/agent_cmd.rs b/crates/tracedecay-cli/src/agent_cmd.rs index 59f9767387..bf1f239b95 100644 --- a/crates/tracedecay-cli/src/agent_cmd.rs +++ b/crates/tracedecay-cli/src/agent_cmd.rs @@ -7,6 +7,9 @@ use tracedecay_session_memory::user_config::UserConfig; mod automation; #[cfg(test)] mod host_cli_fixture; +#[cfg(test)] +#[path = "../../../tests/support/isolated_profile.rs"] +mod isolated_profile; pub(crate) use automation::CodexAutomationInstall; #[cfg(test)] use automation::broker_codex_daemon_automation_project; @@ -1809,7 +1812,6 @@ pub(crate) async fn handle_uninstall_command( #[cfg(test)] mod tests { - use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::sync::{ Arc, @@ -2427,27 +2429,7 @@ mod tests { } } - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - unsafe { std::env::set_var(key, value) }; - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - match self.previous.take() { - Some(previous) => unsafe { std::env::set_var(self.key, previous) }, - None => unsafe { std::env::remove_var(self.key) }, - } - } - } + use super::isolated_profile::EnvVarGuard; /// Keep Kiro lifecycle tests on the native `kiro-cli` route. The compiled /// fixture is a real executable so Windows runners do not rename a shell diff --git a/crates/tracedecay-cli/src/application_cli.rs b/crates/tracedecay-cli/src/application_cli.rs index b37a555fa8..d87953d2e3 100644 --- a/crates/tracedecay-cli/src/application_cli.rs +++ b/crates/tracedecay-cli/src/application_cli.rs @@ -6,7 +6,8 @@ use std::path::Path; use serde::de::DeserializeOwned; use serde_json::Value; use tracedecay_contracts::{ - ApplicationProblem, ApplicationResult, LegalAction, RetryDirective, SafeDiagnostic, + ApplicationProblem, ApplicationProblemEnvelope, ApplicationResult, LegalAction, + ResultContractRef, RetryDirective, SafeDiagnostic, }; use tracedecay_daemon_protocol::DaemonInvocationProblem; use tracedecay_domain::errors::{Result, TraceDecayError}; @@ -29,17 +30,13 @@ impl ApplicationKind { } pub(crate) fn invalid_request(self) -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: format!("invalid_{}_request", self.1), - message: format!( - "The {} request does not match its operation contract", - self.0 - ), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + format!("invalid_{}_request", self.1), + format!( + "The {} request does not match its operation contract", + self.0 + ), + ) } pub(crate) fn daemon_problem(self, problem: DaemonInvocationProblem) -> ApplicationProblem { @@ -78,6 +75,14 @@ impl ApplicationKind { } } +pub(crate) fn problem_envelope( + result_contract: ResultContractRef, + request_id: tracedecay_contracts::RequestId, + problem: ApplicationProblem, +) -> Result { + ApplicationProblemEnvelope::new(result_contract, request_id, problem).map_err(config_error) +} + pub(crate) fn read_request(path: &Path, kind: ApplicationKind) -> Result { let payload = if path == Path::new("-") { let mut payload = String::new(); diff --git a/crates/tracedecay-cli/src/cli.rs b/crates/tracedecay-cli/src/cli.rs index fc3fe9f1a7..31d44b9e77 100644 --- a/crates/tracedecay-cli/src/cli.rs +++ b/crates/tracedecay-cli/src/cli.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand, ValueEnum, builder::PossibleValuesParser}; +use tracedecay_contracts::retained_surfaces::{MessageRelationshipScopeV1, MessageTypeFilterV1}; mod automation; pub mod dispatch; @@ -1104,10 +1105,18 @@ pub(crate) struct SessionsSearchArgs { #[arg(long)] pub(crate) provider: Option, /// Relationship scope: all, parents_only, or subagents_only - #[arg(long, default_value = "all", value_parser = ["all", "parents_only", "subagents_only"])] + #[arg( + long, + default_value = "all", + value_parser = PossibleValuesParser::new(MessageRelationshipScopeV1::WIRE) + )] pub(crate) scope: String, /// Semantic message type: all, direct_user, or tool_result - #[arg(long, default_value = "all", value_parser = ["all", "direct_user", "tool_result"])] + #[arg( + long, + default_value = "all", + value_parser = PossibleValuesParser::new(MessageTypeFilterV1::WIRE) + )] pub(crate) message_type: String, /// Only child sessions belonging to this parent session #[arg(long)] diff --git a/crates/tracedecay-cli/src/commands.rs b/crates/tracedecay-cli/src/commands.rs index d95a552df1..4b52bdc26e 100644 --- a/crates/tracedecay-cli/src/commands.rs +++ b/crates/tracedecay-cli/src/commands.rs @@ -11,7 +11,7 @@ mod storage; pub(crate) use bench::handle_bench; pub(crate) use branch::handle_branch_action; pub(crate) use daemon::{ - daemon_tool_json, daemon_tool_json_until, recover_truncated_mcp_result, + client_handshake, daemon_tool_json, daemon_tool_json_until, recover_truncated_mcp_result, reject_truncation_envelope, retained_effect_payload, retained_tool_payload, }; pub use gain::handle_gain; diff --git a/crates/tracedecay-cli/src/commands/daemon.rs b/crates/tracedecay-cli/src/commands/daemon.rs index 2113fcb1e3..bc97427486 100644 --- a/crates/tracedecay-cli/src/commands/daemon.rs +++ b/crates/tracedecay-cli/src/commands/daemon.rs @@ -7,7 +7,7 @@ use tracedecay_contracts::{ApplicationEnvelope, ApplicationOutcome, ApplicationP /// boundary so a slow CLI invocation can attribute time to client identity /// resolution separately from the daemon round-trip itself. #[hotpath::measure(label = "cli.daemon.handshake")] -fn client_handshake( +pub(crate) fn client_handshake( project_path: Option<&std::path::Path>, ) -> tracedecay_domain::errors::Result { tracedecay::daemon::handshake_for_current_client( @@ -326,7 +326,7 @@ mod tests { ResolvedScope, ResultContractRef, RetrievalEvidence, RetryDirective, TemporalState, }; use tracedecay_domain::{ - ActorId, ComponentVersion, ManifestDigest, ProjectId, RepositoryId, UtcMicros, WorktreeId, + ActorId, ComponentVersion, ProjectId, RepositoryId, UtcMicros, WorktreeId, }; use tracedecay_tool_catalog::{CapabilityId, SchemaId, SortContractId, UseCaseId}; @@ -346,9 +346,7 @@ mod tests { .unwrap() } - fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn context() -> RequestContext { let capability = CapabilityId::new("capability.cli.fixture").unwrap(); diff --git a/crates/tracedecay-cli/src/commands/index.rs b/crates/tracedecay-cli/src/commands/index.rs index 3b99586b84..de5bb20442 100644 --- a/crates/tracedecay-cli/src/commands/index.rs +++ b/crates/tracedecay-cli/src/commands/index.rs @@ -177,11 +177,11 @@ async fn brokered_init( include_folders: &[String], handshake: &tracedecay_daemon_protocol::DaemonHandshake, ) -> tracedecay_domain::errors::Result<()> { - if !skip_folders.is_empty() || !include_folders.is_empty() { - return Err(tracedecay_domain::errors::TraceDecayError::Config { - message: "brokered init does not yet support --skip-folders/--include-folders; configure tracedecay.toml first".to_string(), - }); - } + reject_brokered_folder_options( + skip_folders, + include_folders, + "brokered init does not yet support --skip-folders/--include-folders; configure tracedecay.toml first", + )?; // Init deliberately triggers a cold project open behind this single // status call. The default warming-retry grace is far tighter than a cold // open can take on a debug build or slow shared runner, which surfaced as @@ -246,6 +246,19 @@ async fn brokered_init( Ok(()) } +fn reject_brokered_folder_options( + skip_folders: &[String], + include_folders: &[String], + message: &'static str, +) -> tracedecay_domain::errors::Result<()> { + if skip_folders.is_empty() && include_folders.is_empty() { + return Ok(()); + } + Err(tracedecay_domain::errors::TraceDecayError::Config { + message: message.to_owned(), + }) +} + fn admin_sync_status(envelope: &serde_json::Value) -> Option { let text = envelope .get("content")? @@ -555,21 +568,16 @@ pub(crate) async fn handle_sync( doctor: bool, verbose: bool, ) -> tracedecay_domain::errors::Result<()> { - if !skip_folders.is_empty() || !include_folders.is_empty() { - return Err(tracedecay_domain::errors::TraceDecayError::Config { - message: "brokered sync does not yet support --skip-folders/--include-folders; update tracedecay.toml first".to_string(), - }); - } + reject_brokered_folder_options( + &skip_folders, + &include_folders, + "brokered sync does not yet support --skip-folders/--include-folders; update tracedecay.toml first", + )?; let resolved = super::scope::resolve_project_scope( tracedecay_configuration::resolve_path_with_discovery(path), ) .await?; - let handshake = tracedecay::daemon::handshake_for_current_client( - Some(resolved.project_path.clone()), - None, - false, - false, - )?; + let handshake = super::daemon::client_handshake(Some(&resolved.project_path))?; let result = tracedecay::daemon::call_default_tool( &handshake, "tracedecay_admin_sync", diff --git a/crates/tracedecay-cli/src/commands/settings.rs b/crates/tracedecay-cli/src/commands/settings.rs index a4e6bf767f..9e7e4dce39 100644 --- a/crates/tracedecay-cli/src/commands/settings.rs +++ b/crates/tracedecay-cli/src/commands/settings.rs @@ -104,12 +104,7 @@ async fn invoke_configuration_surface( let cancellation = CancellationSignal::active(format!("cancellation.cli.{}", request_id.as_str())) .map_err(|error| configuration_error(error.to_string()))?; - let handshake = tracedecay::daemon::handshake_for_current_client( - Some(project_path.to_path_buf()), - None, - false, - false, - )?; + let handshake = super::daemon::client_handshake(Some(project_path))?; let client = tracedecay_daemon_identity::invocation_client_for_current(handshake)?; loop { let result = crate::cli::dispatch::resolve_cli_application_surface( diff --git a/crates/tracedecay-cli/src/hook_capture_cmd.rs b/crates/tracedecay-cli/src/hook_capture_cmd.rs index 557188302a..8d238c9a1a 100644 --- a/crates/tracedecay-cli/src/hook_capture_cmd.rs +++ b/crates/tracedecay-cli/src/hook_capture_cmd.rs @@ -248,15 +248,91 @@ fn open_delivery_receipt_spool( ) } +struct PreparedNativeCapture { + outcome: NativeHookCaptureOutcomeV1, + delivery_writer: Option, + delivery_open_error: Option, + delivery_material: Option, + rejection: Option, +} + +impl PreparedNativeCapture { + fn plain(outcome: NativeHookCaptureOutcomeV1) -> Self { + Self { + outcome, + delivery_writer: None, + delivery_open_error: None, + delivery_material: None, + rejection: None, + } + } +} + +fn prepare_native_capture( + source: NativeHookCaptureSourceV1, + payload: &[u8], + working_directory: &std::io::Result, +) -> PreparedNativeCapture { + let Ok(project_root) = working_directory.as_ref() else { + return PreparedNativeCapture::plain(NativeHookCaptureOutcomeV1::Unavailable); + }; + let layout = match tracedecay_runtime_core::storage::resolve_enrolled_layout_for_current_profile( + project_root, + ) { + Ok(Some(layout)) => layout, + Ok(None) => return PreparedNativeCapture::plain(NativeHookCaptureOutcomeV1::Unbound), + Err(_) => return PreparedNativeCapture::plain(NativeHookCaptureOutcomeV1::Unavailable), + }; + let worktree_id = tracedecay_agent_hosts::hooks::hook_worktree_id_for_layout( + &tracedecay::hook_runtime(), + &layout, + ); + let (Some(now), Ok(worktree_id)) = (current_time(), worktree_id) else { + return PreparedNativeCapture::plain(NativeHookCaptureOutcomeV1::Unavailable); + }; + match tracedecay_agent_hosts::hooks::native_capture_material(source, payload, now) { + Ok(material) => { + let outcome = tracedecay_hooks::capture_native_event_for_replay( + &layout.data_root, + worktree_id, + source, + payload, + material, + now, + tracedecay_hooks::HOOK_SYNCHRONOUS_BUDGET, + ); + if outcome != NativeHookCaptureOutcomeV1::Captured { + return PreparedNativeCapture::plain(outcome); + } + let (delivery_writer, delivery_open_error) = + match open_delivery_receipt_spool(&layout.data_root, source.host()) { + Ok(writer) => (Some(writer), None), + Err(error) => (None, Some(error)), + }; + PreparedNativeCapture { + outcome, + delivery_writer, + delivery_open_error, + delivery_material: Some(material), + rejection: None, + } + } + Err( + tracedecay_hooks::NativeHookDecodeError::UnsupportedNativeEvent + | tracedecay_hooks::NativeHookDecodeError::UnsupportedNativeFamily, + ) => PreparedNativeCapture::plain(NativeHookCaptureOutcomeV1::Unsupported), + Err(error) => PreparedNativeCapture { + rejection: Some(error.to_string()), + ..PreparedNativeCapture::plain(NativeHookCaptureOutcomeV1::Rejected) + }, + } +} + pub(crate) fn run_native_capture(source: NativeHookCaptureSourceV1) -> i32 { let payload = match read_bounded_stdin() { Ok(payload) => payload, Err(()) => return refused("stdin was unreadable or exceeded the payload bound"), }; - let mut delivery_writer = None; - let mut delivery_open_error = None; - let mut delivery_material = None; - let mut rejection = None; let working_directory = std::env::current_dir(); // The invocation is analytics-visible whatever the capture outcome: an // unbound, unsupported, or rejected callback still proves the host fired @@ -268,62 +344,8 @@ pub(crate) fn run_native_capture(source: NativeHookCaptureSourceV1) -> i32 { None, &String::from_utf8_lossy(&payload), ); - let outcome = match working_directory { - Ok(project_root) => { - match tracedecay_runtime_core::storage::resolve_enrolled_layout_for_current_profile( - &project_root, - ) { - Ok(Some(layout)) => { - let worktree_id = tracedecay_agent_hosts::hooks::hook_worktree_id_for_layout( - &tracedecay::hook_runtime(), - &layout, - ); - match (current_time(), worktree_id) { - (Some(now), Ok(worktree_id)) => { - match tracedecay_agent_hosts::hooks::native_capture_material( - source, &payload, now, - ) { - Ok(material) => { - let outcome = tracedecay_hooks::capture_native_event_for_replay( - &layout.data_root, - worktree_id, - source, - &payload, - material, - now, - tracedecay_hooks::HOOK_SYNCHRONOUS_BUDGET, - ); - if outcome == NativeHookCaptureOutcomeV1::Captured { - match open_delivery_receipt_spool( - &layout.data_root, - source.host(), - ) { - Ok(writer) => delivery_writer = Some(writer), - Err(error) => delivery_open_error = Some(error), - } - delivery_material = Some(material); - } - outcome - } - Err( - tracedecay_hooks::NativeHookDecodeError::UnsupportedNativeEvent - | tracedecay_hooks::NativeHookDecodeError::UnsupportedNativeFamily, - ) => NativeHookCaptureOutcomeV1::Unsupported, - Err(error) => { - rejection = Some(error.to_string()); - NativeHookCaptureOutcomeV1::Rejected - } - } - } - _ => NativeHookCaptureOutcomeV1::Unavailable, - } - } - Ok(None) => NativeHookCaptureOutcomeV1::Unbound, - Err(_) => NativeHookCaptureOutcomeV1::Unavailable, - } - } - Err(_) => NativeHookCaptureOutcomeV1::Unavailable, - }; + let prepared = prepare_native_capture(source, &payload, &working_directory); + let outcome = prepared.outcome; let stdout = std::io::stdout(); let mut stdout = stdout.lock(); @@ -336,13 +358,14 @@ pub(crate) fn run_native_capture(source: NativeHookCaptureSourceV1) -> i32 { } drop(stdout); if outcome == NativeHookCaptureOutcomeV1::Captured { - let Some(writer) = delivery_writer else { - return refused(match delivery_open_error { + let Some(writer) = prepared.delivery_writer else { + return refused(match prepared.delivery_open_error { Some(error) => format!("native delivery receipt spool unavailable: {error}"), None => "native delivery receipt writer unavailable".to_string(), }); }; - let (Some(material), Some(delivered_at)) = (delivery_material, current_time()) else { + let (Some(material), Some(delivered_at)) = (prepared.delivery_material, current_time()) + else { return refused("native delivery receipt material unavailable"); }; let Some(settlement) = native_hook_delivery_settlement(source, material, delivered_at) @@ -366,7 +389,7 @@ pub(crate) fn run_native_capture(source: NativeHookCaptureSourceV1) -> i32 { | NativeHookCaptureOutcomeV1::Full | NativeHookCaptureOutcomeV1::ResetRequired | NativeHookCaptureOutcomeV1::Unavailable - | NativeHookCaptureOutcomeV1::AdmissionTimedOut => refused(match rejection { + | NativeHookCaptureOutcomeV1::AdmissionTimedOut => refused(match prepared.rejection { Some(reason) => format!("native capture did not land: {outcome:?} ({reason})"), None => format!("native capture did not land: {outcome:?}"), }), diff --git a/crates/tracedecay-cli/src/hook_cmd.rs b/crates/tracedecay-cli/src/hook_cmd.rs index 428172c757..3956a07b19 100644 --- a/crates/tracedecay-cli/src/hook_cmd.rs +++ b/crates/tracedecay-cli/src/hook_cmd.rs @@ -76,7 +76,7 @@ fn handle_hook_command_inner( if let Some(source) = crate::hook_capture_cmd::capture_source_for_command(&command) { return Ok(crate::hook_capture_cmd::run_native_capture(source)); } - if crate::hook_capture_cmd::is_native_hook_command(&command) { + if matches!(command, Commands::HookPreToolUse) { return Ok(0); } unreachable!("non-hook command passed to hook dispatcher") diff --git a/crates/tracedecay-cli/src/lsp_cmd.rs b/crates/tracedecay-cli/src/lsp_cmd.rs index a50534622b..dbce29db39 100644 --- a/crates/tracedecay-cli/src/lsp_cmd.rs +++ b/crates/tracedecay-cli/src/lsp_cmd.rs @@ -59,8 +59,7 @@ async fn run_stdio_bridge(project_root: Option) -> tracedecay_domain::e .map(|binding| binding.project_root.clone()) }) .ok_or_else(|| bridge_config_error("LSP initialize did not identify a workspace root"))?; - let handshake = - tracedecay::daemon::handshake_for_current_client(Some(project_root), None, false, false)?; + let handshake = crate::commands::client_handshake(Some(&project_root))?; let invocation = tracedecay_daemon_identity::invocation_client_for_current(handshake)?; let (deadline, cancellation) = lsp_request_control().map_err(lsp_invocation_error)?; let mut session = DaemonLspSessionClient::open( diff --git a/crates/tracedecay-cli/src/remote_command.rs b/crates/tracedecay-cli/src/remote_command.rs index 614814735b..4f19838feb 100644 --- a/crates/tracedecay-cli/src/remote_command.rs +++ b/crates/tracedecay-cli/src/remote_command.rs @@ -296,8 +296,9 @@ fn protocol_exit_status(response: &RemoteProtocolResponseV1) -> Result<()> Ok(_) => Ok(()), Err(problem) => Err(TraceDecayError::Config { message: format!( - "Remote Brain request {} failed: {}: {}", - response.request_id, problem.problem.code, problem.problem.message + "Remote Brain request {} failed: {}", + response.request_id, + problem.problem.summary() ), }), } @@ -483,7 +484,7 @@ Recovery required: {}\n", fn render_protocol_response(response: &RemoteProtocolResponseV1) -> String { let outcome = match &response.result { Ok(_) => "ok".to_owned(), - Err(problem) => format!("{}: {}", problem.problem.code, problem.problem.message), + Err(problem) => problem.problem.summary(), }; format!( "Request: {}\nAuthority: {}\nOutcome: {}\n", @@ -628,7 +629,7 @@ mod tests { let Err(problem) = &response.result else { panic!("expected typed protocol problem"); }; - let outcome = format!("{}: {}", problem.problem.code, problem.problem.message); + let outcome = problem.problem.summary(); assert_eq!( render_protocol_response(&response), format!("Request: request.cli.remote.7\nAuthority: unavailable\nOutcome: {outcome}\n") diff --git a/crates/tracedecay-cli/src/sessions_cmd/refresh/tests.rs b/crates/tracedecay-cli/src/sessions_cmd/refresh/tests.rs index 89b0477434..729c76876a 100644 --- a/crates/tracedecay-cli/src/sessions_cmd/refresh/tests.rs +++ b/crates/tracedecay-cli/src/sessions_cmd/refresh/tests.rs @@ -18,7 +18,7 @@ use tracedecay_contracts::{ }; use tracedecay_daemon_service::application_surface::retained::decode_request; use tracedecay_domain::{ - ActorId, ComponentVersion, ManifestDigest, ProjectId, RepositoryId, UtcMicros, WorktreeId, + ActorId, ComponentVersion, ProjectId, RepositoryId, UtcMicros, WorktreeId, }; use tracedecay_tool_catalog::SortContractId; @@ -31,9 +31,7 @@ use crate::cli::Cli; const PROFILE_ID: &str = "profile.0f2f1c3d4e5f60718293a4b5c6d7e8f9"; -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-cli/src/tool_command.rs b/crates/tracedecay-cli/src/tool_command.rs index e9a28e8add..981ea66494 100644 --- a/crates/tracedecay-cli/src/tool_command.rs +++ b/crates/tracedecay-cli/src/tool_command.rs @@ -402,13 +402,9 @@ fn dispatch_cli_application_surface_inner( let request = match parse_application_surface_request(operation, tool_args.clone()) { Ok(request) => request, Err(error) => { - if let Ok(handshake) = tracedecay::daemon::handshake_for_current_client( - project.clone(), - None, - false, - false, - ) && let Ok(client) = - tracedecay_daemon_identity::invocation_client_for_current(handshake) + if let Ok(handshake) = crate::commands::client_handshake(project.as_deref()) + && let Ok(client) = + tracedecay_daemon_identity::invocation_client_for_current(handshake) { observe_surface_argument_rejection( Some(&client), diff --git a/crates/tracedecay-cli/src/update_cmd.rs b/crates/tracedecay-cli/src/update_cmd.rs index 9e2ec41121..ed20f80e06 100644 --- a/crates/tracedecay-cli/src/update_cmd.rs +++ b/crates/tracedecay-cli/src/update_cmd.rs @@ -312,11 +312,8 @@ fn current_tracedecay_exe() -> Option { fn current_tracedecay_exe_from(current: Option<&Path>) -> Option { let current = current?; let stem = current.file_stem()?.to_str()?; - (stem == "tracedecay").then(|| normalize_bin_path(current)) -} - -fn normalize_bin_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") + (stem == "tracedecay") + .then(|| tracedecay_domain::forward_slash_text(¤t.to_string_lossy())) } /// How the `post-update` re-exec reacts to the binary-upgrade outcome. @@ -497,7 +494,7 @@ fn post_update_binary(installed: Option<&Path>) -> tracedecay_domain::errors::Re fn post_update_binary_from(installed: Option<&Path>, current: Option<&Path>) -> Option { installed .filter(|path| path.exists()) - .map(normalize_bin_path) + .map(tracedecay_domain::forward_slash_path) .or_else(|| current_tracedecay_exe_from(current)) } @@ -750,7 +747,7 @@ mod tests { use super::{ RefreshPolicy, ReinstallOutcome, current_tracedecay_exe_from, - host_owns_canonical_component_set, install_pass_covers_tracked_agents, normalize_bin_path, + host_owns_canonical_component_set, install_pass_covers_tracked_agents, partition_reinstall_results, post_update_binary, post_update_binary_from, prepare_post_update_lease, refresh_generated_plugins_at, restart_daemon_service_with, run_install_then_refresh, @@ -1292,7 +1289,7 @@ mod tests { let resolved = post_update_binary(Some(&installed)).expect("installed path should resolve"); - assert_eq!(resolved, normalize_bin_path(&installed)); + assert_eq!(resolved, tracedecay_domain::forward_slash_path(&installed)); } #[test] diff --git a/crates/tracedecay-cli/src/work_cli.rs b/crates/tracedecay-cli/src/work_cli.rs index cdd3dc967e..9385069ed9 100644 --- a/crates/tracedecay-cli/src/work_cli.rs +++ b/crates/tracedecay-cli/src/work_cli.rs @@ -13,9 +13,9 @@ use tracedecay_api::WorkOperation; use tracedecay_contracts::{ AcceptWorkProposalRequestV1, AdjudicateWorkLeakCommandV1, AdmitWorkExecutionRequestV1, AdmitWorkPlacementCommand, AdmitWorkSynthesisCommand, ApplicationEnvelope, ApplicationOutcome, - ApplicationProblem, ApplicationProblemEnvelope, ApplicationResult, CancelWorkAttemptCommand, - CancellationSignal, CreateWorkTaskRequestV1, Deadline, ExecutionTopologyMetricsRequestV1, - GenerateProposalRequest, PauseWorkRunCommand, PrepareWorkDuplicateAdjudicationRequestV1, + ApplicationProblem, ApplicationResult, CancelWorkAttemptCommand, CancellationSignal, + CreateWorkTaskRequestV1, Deadline, ExecutionTopologyMetricsRequestV1, GenerateProposalRequest, + PauseWorkRunCommand, PrepareWorkDuplicateAdjudicationRequestV1, PrepareWorkProductMutationRequestV1, ReleaseWorkPlacementCommand, ResultContractRef, ResumeWorkAttemptsCommand, ResumeWorkRunCommand, RetryWorkAttemptCommandV1, ReviewWorkProposalRequestV1, SafeDiagnostic, StartWorkAttemptCommand, @@ -364,11 +364,13 @@ pub async fn invoke_work_cli_with_delivery( let invocation = match decode_work_invocation(operation, body) { Ok(invocation) => invocation, Err(_) => { - return Ok(WorkCliResponse::without_delivery(Err(work_problem( - result_contract, - request_id, - WORK.invalid_request(), - )?))); + return Ok(WorkCliResponse::without_delivery(Err( + crate::application_cli::problem_envelope( + result_contract, + request_id, + WORK.invalid_request(), + )?, + ))); } }; let request = DaemonInvocationRequest::work_application( @@ -378,8 +380,7 @@ pub async fn invoke_work_cli_with_delivery( deadline.clone(), cancellation.context(), ); - let handshake = - tracedecay::daemon::handshake_for_current_client(Some(project_root), None, false, false)?; + let handshake = crate::commands::client_handshake(Some(&project_root))?; let client = tracedecay_daemon_identity::invocation_client_for_current(handshake)?; let result = match client .invoke_controlled_with_delivery( @@ -392,11 +393,13 @@ pub async fn invoke_work_cli_with_delivery( { Ok(result) => result, Err(error) => { - return Ok(WorkCliResponse::without_delivery(Err(work_problem( - result_contract, - request_id, - error.into_application_problem(), - )?))); + return Ok(WorkCliResponse::without_delivery(Err( + crate::application_cli::problem_envelope( + result_contract, + request_id, + error.into_application_problem(), + )?, + ))); } }; let (response, delivery) = result.into_parts(); @@ -419,15 +422,17 @@ pub async fn invoke_work_cli_with_delivery( outcome: erase_work_outcome(outcome)?, }) } - DaemonInvocationOutcome::ApplicationProblem { problem } => { - Err(work_problem(result_contract, request_id.clone(), problem)?) + DaemonInvocationOutcome::ApplicationProblem { problem } => Err( + crate::application_cli::problem_envelope(result_contract, request_id.clone(), problem)?, + ), + DaemonInvocationOutcome::Problem { problem } => { + Err(crate::application_cli::problem_envelope( + result_contract, + request_id.clone(), + WORK.daemon_problem(problem), + )?) } - DaemonInvocationOutcome::Problem { problem } => Err(work_problem( - result_contract, - request_id.clone(), - WORK.daemon_problem(problem), - )?), - _ => Err(work_problem( + _ => Err(crate::application_cli::problem_envelope( result_contract, request_id.clone(), ApplicationProblem::unavailable(SafeDiagnostic { @@ -454,10 +459,10 @@ fn work_delivery_is_eligible(operation: WorkOperation, outcome: &WorkApplication (WorkOperation::StartAttempt, WorkApplicationOutcomeV1::StartAttempt(outcome)) | (WorkOperation::AttemptStatus, WorkApplicationOutcomeV1::AttemptStatus(outcome)) | (WorkOperation::CancelAttempt, WorkApplicationOutcomeV1::CancelAttempt(outcome)) => { - application_outcome_payload(outcome).is_some() + outcome.payload().is_some() } (WorkOperation::HydrateArtifacts, WorkApplicationOutcomeV1::HydrateArtifacts(outcome)) => { - application_outcome_payload(outcome).is_some_and(|hydration| { + outcome.payload().is_some_and(|hydration| { matches!( hydration, tracedecay_contracts::WorkArtifactHydrationV1::Hydrated { attempts, .. } @@ -469,14 +474,6 @@ fn work_delivery_is_eligible(operation: WorkOperation, outcome: &WorkApplication } } -fn application_outcome_payload(outcome: &ApplicationOutcome) -> Option<&T> { - match outcome { - ApplicationOutcome::Evidence(result) => result.payload.as_ref(), - ApplicationOutcome::Preview(result) => result.payload.as_ref(), - ApplicationOutcome::Effect(result) => result.payload.as_ref(), - } -} - fn erase_work_outcome(outcome: WorkApplicationOutcomeV1) -> Result> { let outcome = match outcome { WorkApplicationOutcomeV1::GenerateProposal(outcome) => serde_json::to_value(outcome), @@ -517,14 +514,6 @@ fn erase_work_outcome(outcome: WorkApplicationOutcomeV1) -> Result Result { - ApplicationProblemEnvelope::new(result_contract, request_id, problem).map_err(config_error) -} - fn decode(body: Value) -> Result where T: serde::de::DeserializeOwned, diff --git a/crates/tracedecay-cli/src/work_command.rs b/crates/tracedecay-cli/src/work_command.rs index f986ba36f9..aa0838ebee 100644 --- a/crates/tracedecay-cli/src/work_command.rs +++ b/crates/tracedecay-cli/src/work_command.rs @@ -33,27 +33,17 @@ pub(crate) async fn run(invocation: WorkInvocationArgs) -> tracedecay_domain::er let mut stdout = std::io::stdout().lock(); let write_result = write_work_output(&mut stdout, rendered.as_bytes()); drop(stdout); - let delivery_settlement = classify_work_output(&write_result); match write_result { Ok(()) => { if let Some(delivery) = response.take_delivery() { - match delivery_settlement { - WorkOutputSettlement::Delivered => delivery.acknowledge_delivered().await?, - WorkOutputSettlement::Dropped(reason) => { - let _ = delivery.acknowledge_dropped(reason).await; - } - } + delivery.acknowledge_delivered().await?; } } Err(error) => { if let Some(delivery) = response.take_delivery() { - let reason = match delivery_settlement { - WorkOutputSettlement::Dropped(reason) => reason, - WorkOutputSettlement::Delivered => { - tracedecay_domain::DeliveryDropReasonV1::Disconnected - } - }; - let _ = delivery.acknowledge_dropped(reason).await; + let _ = delivery + .acknowledge_dropped(tracedecay_domain::DeliveryDropReasonV1::Disconnected) + .await; } return Err(error.into()); } @@ -61,29 +51,15 @@ pub(crate) async fn run(invocation: WorkInvocationArgs) -> tracedecay_domain::er Ok(()) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum WorkOutputSettlement { - Delivered, - Dropped(tracedecay_domain::DeliveryDropReasonV1), -} - fn write_work_output(writer: &mut W, rendered: &[u8]) -> std::io::Result<()> { writer.write_all(rendered).and_then(|()| writer.flush()) } -fn classify_work_output(result: &std::io::Result<()>) -> WorkOutputSettlement { - if result.is_ok() { - WorkOutputSettlement::Delivered - } else { - WorkOutputSettlement::Dropped(tracedecay_domain::DeliveryDropReasonV1::Disconnected) - } -} - #[cfg(test)] mod tests { use std::io::{self, Write}; - use super::{WorkOutputSettlement, classify_work_output, write_work_output}; + use super::write_work_output; #[test] fn work_json_line_preserves_the_canonical_typed_problem() { @@ -117,13 +93,5 @@ mod tests { .kind(), io::ErrorKind::BrokenPipe ); - assert_eq!( - classify_work_output(&write_result), - WorkOutputSettlement::Dropped(tracedecay_domain::DeliveryDropReasonV1::Disconnected) - ); - assert_ne!( - classify_work_output(&write_result), - WorkOutputSettlement::Delivered - ); } } diff --git a/crates/tracedecay-cli/src/workflow_cli.rs b/crates/tracedecay-cli/src/workflow_cli.rs index 1c5b6c9125..897c203436 100644 --- a/crates/tracedecay-cli/src/workflow_cli.rs +++ b/crates/tracedecay-cli/src/workflow_cli.rs @@ -9,13 +9,13 @@ use std::path::PathBuf; use serde_json::Value; use tracedecay_api::WorkflowOperation; use tracedecay_contracts::{ - ApplicationEnvelope, ApplicationOutcome, ApplicationProblem, ApplicationProblemEnvelope, - ApplicationResult, CancellationSignal, Deadline, ResultContractRef, SafeDiagnostic, - TaskHandoffIssueRequest, TaskHandoffRedeemRequest, WorkflowDefinitionActivateRequest, - WorkflowDefinitionDiffRequest, WorkflowDefinitionGetRequest, WorkflowDefinitionHistoryRequest, - WorkflowDefinitionListRequest, WorkflowDefinitionRegisterRequest, - WorkflowDefinitionRejectRequest, WorkflowDefinitionRetireRequest, - WorkflowDefinitionValidateRequest, workflow_executable_binding_registry, + ApplicationEnvelope, ApplicationOutcome, ApplicationProblem, ApplicationResult, + CancellationSignal, Deadline, ResultContractRef, SafeDiagnostic, TaskHandoffIssueRequest, + TaskHandoffRedeemRequest, WorkflowDefinitionActivateRequest, WorkflowDefinitionDiffRequest, + WorkflowDefinitionGetRequest, WorkflowDefinitionHistoryRequest, WorkflowDefinitionListRequest, + WorkflowDefinitionRegisterRequest, WorkflowDefinitionRejectRequest, + WorkflowDefinitionRetireRequest, WorkflowDefinitionValidateRequest, + workflow_executable_binding_registry, }; use tracedecay_domain::UtcMicros; use tracedecay_tool_catalog::OperationId; @@ -30,29 +30,7 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use crate::application_cli::{WORKFLOW, config_error}; -fn workflow_cli_deadline(operation: WorkflowOperation, observed_at: UtcMicros) -> Result { - let operation_id = - OperationId::new(operation.operation_id_str().to_owned()).map_err(config_error)?; - let registry = workflow_executable_binding_registry().map_err(config_error)?; - let binding = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - .ok_or_else(|| TraceDecayError::Config { - message: format!( - "Workflow operation {} is not advertised by this build", - operation_id.as_str() - ), - })?; - let maximum_micros = i64::try_from( - std::time::Duration::from_millis(binding.deadline().maximum_millis()).as_micros(), - ) - .map_err(|_| TraceDecayError::Config { - message: "The canonical Workflow deadline exceeds the domain clock".to_owned(), - })?; - Deadline::new(UtcMicros(observed_at.0.saturating_add(maximum_micros))).map_err(config_error) -} - -fn workflow_result_contract(operation: WorkflowOperation) -> Result { +fn workflow_catalog(operation: WorkflowOperation) -> Result<(ResultContractRef, u64)> { let operation_id = OperationId::new(operation.operation_id_str().to_owned()).map_err(config_error)?; let registry = workflow_executable_binding_registry().map_err(config_error)?; @@ -67,11 +45,28 @@ fn workflow_result_contract(operation: WorkflowOperation) -> Result Result { + let (_, maximum_millis) = workflow_catalog(operation)?; + deadline_from_maximum_millis(maximum_millis, observed_at) +} + +fn deadline_from_maximum_millis(maximum_millis: u64, observed_at: UtcMicros) -> Result { + let maximum_micros = i64::try_from( + std::time::Duration::from_millis(maximum_millis).as_micros(), + ) + .map_err(|_| TraceDecayError::Config { + message: "The canonical Workflow deadline exceeds the domain clock".to_owned(), + })?; + Deadline::new(UtcMicros(observed_at.0.saturating_add(maximum_micros))).map_err(config_error) +} + fn decode_workflow_invocation( operation: WorkflowOperation, body: Value, @@ -185,20 +180,20 @@ pub async fn invoke_workflow_cli( operation: WorkflowOperation, body: Value, ) -> Result> { - let result_contract = workflow_result_contract(operation)?; + let (result_contract, maximum_millis) = workflow_catalog(operation)?; let request_id = mint_global_request_id(GlobalRequestSurface::Cli).map_err(|_| TraceDecayError::Config { message: "could not allocate a Workflow CLI request id".to_owned(), })?; let observed_at = invocation_now_micros(); - let deadline = workflow_cli_deadline(operation, observed_at)?; + let deadline = deadline_from_maximum_millis(maximum_millis, observed_at)?; let cancellation = CancellationSignal::active(format!("cancellation.cli.{}", request_id.as_str())) .map_err(config_error)?; let invocation = match decode_workflow_invocation(operation, body) { Ok(invocation) => invocation, Err(_) => { - return Ok(Err(workflow_problem( + return Ok(Err(crate::application_cli::problem_envelope( result_contract, request_id, WORKFLOW.invalid_request(), @@ -212,8 +207,7 @@ pub async fn invoke_workflow_cli( deadline.clone(), cancellation.context(), ); - let handshake = - tracedecay::daemon::handshake_for_current_client(Some(project_root), None, false, false)?; + let handshake = crate::commands::client_handshake(Some(&project_root))?; let response = match tracedecay_daemon_identity::invocation_client_for_current(handshake)? .invoke_controlled( request, @@ -225,7 +219,7 @@ pub async fn invoke_workflow_cli( { Ok(response) => response, Err(error) => { - return Ok(Err(workflow_problem( + return Ok(Err(crate::application_cli::problem_envelope( result_contract, request_id, error.into_application_problem(), @@ -243,15 +237,17 @@ pub async fn invoke_workflow_cli( outcome: erase_workflow_outcome(outcome)?, })) } - DaemonInvocationOutcome::ApplicationProblem { problem } => { - Ok(Err(workflow_problem(result_contract, request_id, problem)?)) + DaemonInvocationOutcome::ApplicationProblem { problem } => Ok(Err( + crate::application_cli::problem_envelope(result_contract, request_id, problem)?, + )), + DaemonInvocationOutcome::Problem { problem } => { + Ok(Err(crate::application_cli::problem_envelope( + result_contract, + request_id, + WORKFLOW.daemon_problem(problem), + )?)) } - DaemonInvocationOutcome::Problem { problem } => Ok(Err(workflow_problem( - result_contract, - request_id, - WORKFLOW.daemon_problem(problem), - )?)), - _ => Ok(Err(workflow_problem( + _ => Ok(Err(crate::application_cli::problem_envelope( result_contract, request_id, ApplicationProblem::unavailable(SafeDiagnostic { @@ -286,14 +282,6 @@ fn erase_workflow_outcome( serde_json::from_value(outcome).map_err(Into::into) } -fn workflow_problem( - result_contract: ResultContractRef, - request_id: tracedecay_contracts::RequestId, - problem: ApplicationProblem, -) -> Result { - ApplicationProblemEnvelope::new(result_contract, request_id, problem).map_err(config_error) -} - fn decode(body: Value) -> Result where T: serde::de::DeserializeOwned, diff --git a/crates/tracedecay-cli/tests/work_loop_journey.rs b/crates/tracedecay-cli/tests/work_loop_journey.rs index 4bd09768c0..d158e47e66 100644 --- a/crates/tracedecay-cli/tests/work_loop_journey.rs +++ b/crates/tracedecay-cli/tests/work_loop_journey.rs @@ -40,7 +40,6 @@ //! actually owns: the forwarded argv, the instructions on stdin, the sealed //! terminal state, and the requested-versus-actual route. -use std::ffi::{OsStr, OsString}; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; @@ -98,32 +97,9 @@ fn lock_env() -> MutexGuard<'static, ()> { .unwrap_or_else(std::sync::PoisonError::into_inner) } -/// Restores a process environment variable when the guard drops. -struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - // The fixture holds the binary-wide environment lock for its whole - // life, so no other thread reads the environment while it is pinned. - unsafe { std::env::set_var(key, value) }; - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match self.previous.take() { - Some(previous) => std::env::set_var(self.key, previous), - None => std::env::remove_var(self.key), - } - } - } -} +#[path = "../../../tests/support/isolated_profile.rs"] +mod isolated_profile; +use isolated_profile::{EnvVarGuard, apply_isolated_profile_env, run_ok}; /// A live daemon over a registered project under a throwaway profile, plus the /// credentials it published for its own HTTP application endpoint. @@ -468,28 +444,8 @@ fn daemon_authority_path(profile_root: &Path) -> PathBuf { fn isolated(home: &Path, profile: &Path) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); + apply_isolated_profile_env(&mut command, home, profile); command - .env("HOME", home) - .env("USERPROFILE", home) - .env("XDG_CONFIG_HOME", home.join(".config")) - .env(USER_DATA_DIR_ENV, profile) - .env(GLOBAL_DB_ENV, profile.join("global.db")) - .env("TRACEDECAY_TEST_ALLOW_INCOMPLETE_HOLDER_SCAN", "1"); - command -} - -fn run_ok(command: &mut Command, label: &str) -> Vec { - let output = command - .output() - .unwrap_or_else(|error| panic!("{label} could not run: {error}")); - assert!( - output.status.success(), - "{label} failed with {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - output.stdout } fn wait_for_authority(daemon: &mut Child, path: &Path) -> Value { diff --git a/crates/tracedecay-cli/tests/work_route_exposure_conformance.rs b/crates/tracedecay-cli/tests/work_route_exposure_conformance.rs index 528d70eb4c..4d7030ac42 100644 --- a/crates/tracedecay-cli/tests/work_route_exposure_conformance.rs +++ b/crates/tracedecay-cli/tests/work_route_exposure_conformance.rs @@ -36,6 +36,7 @@ #[path = "../../tracedecay/tests/common/mod.rs"] mod common; +use common::{EnvVarGuard, apply_isolated_profile_env, run_ok}; #[path = "work_route_exposure_conformance/work_evidence.rs"] mod work_evidence; @@ -45,7 +46,6 @@ mod work_evidence; mod work_task_session; use std::collections::{BTreeMap, BTreeSet}; -use std::ffi::{OsStr, OsString}; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; @@ -91,35 +91,6 @@ const UNKNOWN_PROJECT_ID: &str = "project.route-exposure-conformance-unknown"; /// Guards against a malformed schema cycle producing an unbounded instance. const MAX_SCHEMA_DEPTH: usize = 32; -/// Restores a process environment variable when the guard drops. -struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - // Every test in this binary pins the environment through - // `ProductionDaemon::start`, which holds the shared env lock for the - // fixture's whole life, so no other thread reads the environment while - // it is being pinned. - unsafe { std::env::set_var(key, value) }; - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match self.previous.take() { - Some(previous) => std::env::set_var(self.key, previous), - None => std::env::remove_var(self.key), - } - } - } -} - /// A live daemon over a registered project under a throwaway profile, plus the /// credentials it published for its own HTTP application endpoint. struct ProductionDaemon { @@ -410,28 +381,8 @@ fn read_listening_url(stdout: std::process::ChildStdout, process: &mut Child) -> fn isolated(home: &Path, profile: &Path) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); + apply_isolated_profile_env(&mut command, home, profile); command - .env("HOME", home) - .env("USERPROFILE", home) - .env("XDG_CONFIG_HOME", home.join(".config")) - .env(USER_DATA_DIR_ENV, profile) - .env(GLOBAL_DB_ENV, profile.join("global.db")) - .env("TRACEDECAY_TEST_ALLOW_INCOMPLETE_HOLDER_SCAN", "1"); - command -} - -fn run_ok(command: &mut Command, label: &str) -> Vec { - let output = command - .output() - .unwrap_or_else(|error| panic!("{label} could not run: {error}")); - assert!( - output.status.success(), - "{label} failed with {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - output.stdout } fn wait_for_authority(daemon: &mut Child, path: &Path) -> Value { diff --git a/crates/tracedecay-code-extraction/src/c_extractor.rs b/crates/tracedecay-code-extraction/src/c_extractor.rs index 408b18b932..5d100cbb66 100644 --- a/crates/tracedecay-code-extraction/src/c_extractor.rs +++ b/crates/tracedecay-code-extraction/src/c_extractor.rs @@ -6,10 +6,10 @@ use std::time::Instant; use tree_sitter::{Node as TsNode, Tree}; -use crate::common::local_node_id; +use crate::common::{ExtractionState, local_node_id}; use crate::types::{ - ComplexityAnalysisV1, Edge, EdgeKind, ExtractionResult, Node, NodeKind, UnresolvedRef, - Visibility, generate_node_id, + ComplexityAnalysisV1, Edge, EdgeKind, ExtractionResult, Node, NodeKind, Visibility, + generate_node_id, }; use crate::{ common::{clean_c_comment, docstring_from_preceding_comments, extract_call_expression_sites}, @@ -20,63 +20,7 @@ use crate::{ /// Extracts code graph nodes and edges from C source files using tree-sitter. pub struct CExtractor; -/// Internal state used during AST traversal. -struct ExtractionState<'s> { - nodes: Vec, - edges: Vec, - unresolved_refs: Vec, - errors: Vec, - /// Stack of (name, `node_id`) for building qualified names and parent edges. - node_stack: Vec<(String, String)>, - file_path: String, - source: &'s [u8], - timestamp: u64, -} - impl<'s> ExtractionState<'s> { - fn new(file_path: &str, source: &'s str) -> Self { - let timestamp = crate::common::unix_timestamp_secs(); - Self { - nodes: Vec::new(), - edges: Vec::new(), - unresolved_refs: Vec::new(), - errors: Vec::new(), - node_stack: Vec::new(), - file_path: file_path.to_string(), - source: source.as_bytes(), - timestamp, - } - } - - /// Returns the current qualified name prefix from the node stack. - /// - /// The file root is pushed onto `node_stack` as the first frame when - /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). - fn qualified_prefix(&self) -> String { - self.node_stack - .iter() - .map(|(name, _)| name.as_str()) - .collect::>() - .join("::") - } - - /// Returns the current parent node ID, or None if at file root level. - fn parent_node_id(&self) -> Option<&str> { - self.node_stack.last().map(|(_, id)| id.as_str()) - } - - /// Gets the text of a tree-sitter node from the source. - fn node_text(&self, node: TsNode<'_>) -> &'s str { - node.utf8_text(self.source).unwrap_or("") - } - - /// Borrowed text of a tree-sitter node, sliced straight from the source. - fn node_str(&self, node: TsNode<'_>) -> &'s str { - node.utf8_text(self.source).unwrap_or("") - } - /// Source slice from `node.start_byte()` up to `end_byte`. fn text_before(&self, node: TsNode<'_>, end_byte: usize) -> &str { let start = node.start_byte(); @@ -256,13 +200,13 @@ impl CExtractor { if let Some(declarator) = find_descendant_by_kind(node, "function_declarator") { // The function name is the identifier child of the function_declarator if let Some(ident) = find_direct_child_by_kind(declarator, "identifier") { - return Some(state.node_str(ident)); + return Some(state.node_text(ident)); } // Could also be inside a pointer_declarator -> function_declarator if let Some(ident) = find_direct_child_by_kind(declarator, "parenthesized_declarator") { // For function pointer patterns, try finding identifier deeper if let Some(inner_ident) = find_descendant_by_kind(ident, "identifier") { - return Some(state.node_str(inner_ident)); + return Some(state.node_text(inner_ident)); } } } @@ -311,7 +255,7 @@ impl CExtractor { let name = Self::extract_function_name(state, node).unwrap_or(""); let signature = Some( state - .node_str(node) + .node_text(node) .trim() .trim_end_matches(';') .trim() @@ -384,7 +328,7 @@ impl CExtractor { let signature = Some( state - .node_str(node) + .node_text(node) .trim() .trim_end_matches(';') .trim() @@ -448,24 +392,24 @@ impl CExtractor { if let Some(init_decl) = find_direct_child_by_kind(node, "init_declarator") { // The identifier is the first child of init_declarator if let Some(ident) = find_direct_child_by_kind(init_decl, "identifier") { - return Some(state.node_str(ident)); + return Some(state.node_text(ident)); } // Could be a pointer declarator: `int *x = NULL;` if let Some(ptr_decl) = find_direct_child_by_kind(init_decl, "pointer_declarator") && let Some(ident) = find_direct_child_by_kind(ptr_decl, "identifier") { - return Some(state.node_str(ident)); + return Some(state.node_text(ident)); } } // Direct identifier child (e.g., `int x;`) if let Some(ident) = find_direct_child_by_kind(node, "identifier") { - return Some(state.node_str(ident)); + return Some(state.node_text(ident)); } // Pointer declarator without init (e.g., `char *name;`) if let Some(ptr_decl) = find_direct_child_by_kind(node, "pointer_declarator") && let Some(ident) = find_direct_child_by_kind(ptr_decl, "identifier") { - return Some(state.node_str(ident)); + return Some(state.node_text(ident)); } None } diff --git a/crates/tracedecay-code-extraction/src/cobol_extractor.rs b/crates/tracedecay-code-extraction/src/cobol_extractor.rs index d820e943a6..3796fe3f82 100644 --- a/crates/tracedecay-code-extraction/src/cobol_extractor.rs +++ b/crates/tracedecay-code-extraction/src/cobol_extractor.rs @@ -8,7 +8,7 @@ use std::time::Instant; use tree_sitter::{Node as TsNode, Tree}; -use crate::common::local_node_id; +use crate::common::{ExtractionState, local_node_id}; use crate::traversal::find_direct_child_by_kind; use crate::types::{ ComplexityAnalysisV1, Edge, EdgeKind, ExtractionResult, Node, NodeKind, UnresolvedRef, @@ -18,58 +18,7 @@ use crate::types::{ /// Extracts code graph nodes and edges from COBOL source files using tree-sitter. pub struct CobolExtractor; -/// Internal state used during AST traversal. -struct ExtractionState<'s> { - nodes: Vec, - edges: Vec, - unresolved_refs: Vec, - errors: Vec, - /// Stack of (name, `node_id`) for building qualified names and parent edges. - node_stack: Vec<(String, String)>, - file_path: String, - source: &'s [u8], - timestamp: u64, -} - impl<'s> ExtractionState<'s> { - fn new(file_path: &str, source: &'s str) -> Self { - let timestamp = crate::common::unix_timestamp_secs(); - Self { - nodes: Vec::new(), - edges: Vec::new(), - unresolved_refs: Vec::new(), - errors: Vec::new(), - node_stack: Vec::new(), - file_path: file_path.to_string(), - source: source.as_bytes(), - timestamp, - } - } - - /// Returns the current qualified name prefix from the node stack. - /// - /// The file root is pushed onto `node_stack` as the first frame when - /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). - fn qualified_prefix(&self) -> String { - self.node_stack - .iter() - .map(|(name, _)| name.as_str()) - .collect::>() - .join("::") - } - - /// Returns the current parent node ID, or None if at file root level. - fn parent_node_id(&self) -> Option<&str> { - self.node_stack.last().map(|(_, id)| id.as_str()) - } - - /// Gets the text of a tree-sitter node from the source. - fn node_text(&self, node: TsNode<'_>) -> &'s str { - node.utf8_text(self.source).unwrap_or("") - } - /// Extracts the full source line at a given byte offset. /// /// COBOL comment nodes in tree-sitter-cobol have zero-width byte ranges, diff --git a/crates/tracedecay-code-extraction/src/cpp_extractor.rs b/crates/tracedecay-code-extraction/src/cpp_extractor.rs index f53229c7fd..0684748115 100644 --- a/crates/tracedecay-code-extraction/src/cpp_extractor.rs +++ b/crates/tracedecay-code-extraction/src/cpp_extractor.rs @@ -58,8 +58,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/csharp_extractor.rs b/crates/tracedecay-code-extraction/src/csharp_extractor.rs index e15dcc1e6c..e2fd772a5e 100644 --- a/crates/tracedecay-code-extraction/src/csharp_extractor.rs +++ b/crates/tracedecay-code-extraction/src/csharp_extractor.rs @@ -50,8 +50,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/dart_extractor.rs b/crates/tracedecay-code-extraction/src/dart_extractor.rs index e4e32c8186..a1cedffd37 100644 --- a/crates/tracedecay-code-extraction/src/dart_extractor.rs +++ b/crates/tracedecay-code-extraction/src/dart_extractor.rs @@ -51,8 +51,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() @@ -71,10 +70,6 @@ impl<'s> ExtractionState<'s> { node.utf8_text(self.source).unwrap_or("") } - fn node_str(&self, node: TsNode<'_>) -> &'s str { - node.utf8_text(self.source).unwrap_or("") - } - fn text_before(&self, node: TsNode<'_>, end_byte: usize) -> &str { let start = node.start_byte(); let end = end_byte.min(self.source.len()).max(start); @@ -1273,7 +1268,7 @@ impl DartExtractor { let visibility = Self::dart_visibility(&name); let docstring = Self::extract_docstring(state, decl_node); - let text = state.node_str(decl_node); + let text = state.node_text(decl_node); let signature = if let Some(body) = decl_node.child_by_field_name("body") { Some( state @@ -1346,7 +1341,7 @@ impl DartExtractor { // ---------------------------------- fn visit_operator(state: &mut ExtractionState, decl_node: TsNode<'_>, _sig_node: TsNode<'_>) { - let text = state.node_str(decl_node); + let text = state.node_text(decl_node); let name = text.find("operator").map_or_else( || "operator".to_string(), |pos| { @@ -1701,7 +1696,7 @@ impl DartExtractor { .trim() .to_string(); } - let text = state.node_str(node); + let text = state.node_text(node); if let Some(brace_pos) = text.find('{') { text[..brace_pos].trim().to_string() } else { diff --git a/crates/tracedecay-code-extraction/src/dockerfile_extractor.rs b/crates/tracedecay-code-extraction/src/dockerfile_extractor.rs index 7226ca9961..911ac977c1 100644 --- a/crates/tracedecay-code-extraction/src/dockerfile_extractor.rs +++ b/crates/tracedecay-code-extraction/src/dockerfile_extractor.rs @@ -49,8 +49,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/incremental.rs b/crates/tracedecay-code-extraction/src/incremental.rs index b78ee635a4..461a442d55 100644 --- a/crates/tracedecay-code-extraction/src/incremental.rs +++ b/crates/tracedecay-code-extraction/src/incremental.rs @@ -599,15 +599,6 @@ impl RetainedParseDocument { self.reparse_normalized(next_identity, new_source.into(), None, None) } - pub fn reparse_prepared( - &mut self, - next_identity: ParseDocumentIdentity, - new_source: impl Into, - new_parsed_source: impl Into, - ) -> Result { - self.reparse_prepared_with_control(next_identity, new_source, new_parsed_source, None) - } - pub fn reparse_prepared_with_control( &mut self, next_identity: ParseDocumentIdentity, diff --git a/crates/tracedecay-code-extraction/src/java_extractor.rs b/crates/tracedecay-code-extraction/src/java_extractor.rs index 0e351986d9..350d2522f1 100644 --- a/crates/tracedecay-code-extraction/src/java_extractor.rs +++ b/crates/tracedecay-code-extraction/src/java_extractor.rs @@ -15,6 +15,7 @@ use crate::{ AnnotationEmitterState, emit_annotation_usage, scan_children_for_annotation_kinds, }, complexity::{JAVA_COMPLEXITY, count_complexity}, + traversal::has_direct_child_kind, }; /// Extracts code graph nodes and edges from Java source files using tree-sitter. @@ -58,8 +59,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() @@ -77,10 +77,6 @@ impl<'s> ExtractionState<'s> { fn node_text(&self, node: TsNode<'_>) -> &'s str { node.utf8_text(self.source).unwrap_or("") } - - fn node_str(&self, node: TsNode<'_>) -> &'s str { - node.utf8_text(self.source).unwrap_or("") - } } impl<'s> AnnotationEmitterState for ExtractionState<'s> { @@ -101,7 +97,7 @@ impl<'s> AnnotationEmitterState for ExtractionState<'s> { } fn node_str(&self, node: TsNode<'_>) -> &str { - ExtractionState::node_str(self, node) + self.node_text(node) } fn timestamp(&self) -> u64 { @@ -687,7 +683,7 @@ impl JavaExtractor { // 2. It is in an interface and has no body (no `block` child). let has_abstract_modifier = Self::has_modifier(node, state, "abstract"); let has_body = - node.child_by_field_name("body").is_some() || Self::has_child_of_kind(node, "block"); + node.child_by_field_name("body").is_some() || has_direct_child_kind(node, "block"); let is_abstract = has_abstract_modifier || (state.inside_interface && !has_body); let kind = if is_abstract { @@ -988,21 +984,6 @@ impl JavaExtractor { false } - fn has_child_of_kind(node: TsNode<'_>, kind: &str) -> bool { - let mut cursor = node.walk(); - if cursor.goto_first_child() { - loop { - if cursor.node().kind() == kind { - return true; - } - if !cursor.goto_next_sibling() { - break; - } - } - } - false - } - /// Extract the declaration signature (text from start up to the opening `{`). fn extract_declaration_signature(state: &ExtractionState, node: TsNode<'_>) -> String { let text = state.node_text(node); diff --git a/crates/tracedecay-code-extraction/src/julia_extractor.rs b/crates/tracedecay-code-extraction/src/julia_extractor.rs index b37440c426..bcda9a1b64 100644 --- a/crates/tracedecay-code-extraction/src/julia_extractor.rs +++ b/crates/tracedecay-code-extraction/src/julia_extractor.rs @@ -2,7 +2,7 @@ use std::time::Instant; use tree_sitter::{Node as TsNode, Tree}; -use crate::common::local_node_id; +use crate::common::{ExtractionState, local_node_id}; use crate::complexity::{ComplexityMetrics, JULIA_COMPLEXITY, count_complexity}; use crate::types::{ ComplexityAnalysisV1, Edge, EdgeKind, ExtractionResult, Node, NodeKind, UnresolvedRef, @@ -25,54 +25,7 @@ impl NodeText { } } -struct ExtractionState<'s> { - nodes: Vec, - edges: Vec, - unresolved_refs: Vec, - errors: Vec, - node_stack: Vec<(String, String)>, - file_path: String, - source: &'s [u8], - timestamp: u64, -} - impl<'s> ExtractionState<'s> { - fn new(file_path: &str, source: &'s str) -> Self { - let timestamp = crate::common::unix_timestamp_secs(); - Self { - nodes: Vec::new(), - edges: Vec::new(), - unresolved_refs: Vec::new(), - errors: Vec::new(), - node_stack: Vec::new(), - file_path: file_path.to_string(), - source: source.as_bytes(), - timestamp, - } - } - - /// Returns the current qualified name prefix from the node stack. - /// - /// The file root is pushed onto `node_stack` as the first frame when - /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). - fn qualified_prefix(&self) -> String { - self.node_stack - .iter() - .map(|(name, _)| name.as_str()) - .collect::>() - .join("::") - } - - fn parent_node_id(&self) -> Option<&str> { - self.node_stack.last().map(|(_, id)| id.as_str()) - } - - fn node_text(&self, node: TsNode<'_>) -> &'s str { - node.utf8_text(self.source).unwrap_or("") - } - fn push_node( &mut self, kind: NodeKind, diff --git a/crates/tracedecay-code-extraction/src/kotlin_extractor.rs b/crates/tracedecay-code-extraction/src/kotlin_extractor.rs index f2fb6a0cfa..f0935f8aa8 100644 --- a/crates/tracedecay-code-extraction/src/kotlin_extractor.rs +++ b/crates/tracedecay-code-extraction/src/kotlin_extractor.rs @@ -61,8 +61,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() @@ -80,10 +79,6 @@ impl<'s> ExtractionState<'s> { fn node_text(&self, node: TsNode<'_>) -> &'s str { node.utf8_text(self.source).unwrap_or("") } - - fn node_str(&self, node: TsNode<'_>) -> &'s str { - node.utf8_text(self.source).unwrap_or("") - } } impl<'s> AnnotationEmitterState for ExtractionState<'s> { @@ -104,7 +99,7 @@ impl<'s> AnnotationEmitterState for ExtractionState<'s> { } fn node_str(&self, node: TsNode<'_>) -> &str { - ExtractionState::node_str(self, node) + self.node_text(node) } fn timestamp(&self) -> u64 { diff --git a/crates/tracedecay-code-extraction/src/lib.rs b/crates/tracedecay-code-extraction/src/lib.rs index 72aa410221..df019ba1b5 100644 --- a/crates/tracedecay-code-extraction/src/lib.rs +++ b/crates/tracedecay-code-extraction/src/lib.rs @@ -365,6 +365,15 @@ pub struct LanguageRegistry { by_extension: HashMap, } +/// Required by `clippy::new_without_default` for the argument-less `new` +/// below, so this is API surface the lint owns rather than an uncalled entry +/// point a dead-surface pass may drop. +impl Default for LanguageRegistry { + fn default() -> Self { + Self::new() + } +} + impl LanguageRegistry { /// Creates a new registry with all built-in language extractors. pub fn new() -> Self { @@ -482,12 +491,6 @@ impl LanguageRegistry { } } - #[cfg(any(test, feature = "test-helpers"))] - #[doc(hidden)] - pub fn from_extractors_for_test(extractors: Vec>) -> Self { - Self::from_extractors(extractors) - } - /// Returns the extractor for a file path based on its extension. pub fn extractor_for_file(&self, path: &str) -> Option<&dyn LanguageExtractor> { let extractor = path.rsplit('.').next().and_then(|ext| { @@ -509,9 +512,3 @@ impl LanguageRegistry { .collect() } } - -impl Default for LanguageRegistry { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/tracedecay-code-extraction/src/markdown_extractor.rs b/crates/tracedecay-code-extraction/src/markdown_extractor.rs index 367a48810f..527b9ba56a 100644 --- a/crates/tracedecay-code-extraction/src/markdown_extractor.rs +++ b/crates/tracedecay-code-extraction/src/markdown_extractor.rs @@ -18,6 +18,7 @@ use std::time::Instant; use tree_sitter::{Node as TsNode, Parser, Range, Tree}; use crate::common::local_node_id; +use crate::traversal::find_direct_child_by_kind; use crate::types::{ ComplexityAnalysisV1, Edge, EdgeKind, ExtractionResult, Node, NodeKind, Visibility, generate_node_id, @@ -448,7 +449,7 @@ impl MarkdownExtractor { match node.kind() { "inline_link" => Self::visit_link(state, node), "image" => { - if child_of_kind(node, "link_destination").is_some() { + if find_direct_child_by_kind(node, "link_destination").is_some() { Self::visit_link(state, node); } else { Self::queue_reference_link(state, node); @@ -471,10 +472,10 @@ impl MarkdownExtractor { } fn visit_reference_definition(state: &mut ExtractionState, node: TsNode<'_>) { - let Some(label_node) = child_of_kind(node, "link_label") else { + let Some(label_node) = find_direct_child_by_kind(node, "link_label") else { return; }; - let Some(dest_node) = child_of_kind(node, "link_destination") else { + let Some(dest_node) = find_direct_child_by_kind(node, "link_destination") else { return; }; let label = normalize_link_label(&strip_label_brackets(state.node_text(label_node))); @@ -495,12 +496,15 @@ impl MarkdownExtractor { return; }; let label = match node.kind() { - "full_reference_link" | "image" => child_of_kind(node, "link_label") + "full_reference_link" | "image" => find_direct_child_by_kind(node, "link_label") .map(|n| strip_label_brackets(state.node_text(n))) .or_else(|| { - child_of_kind(node, "link_text").map(|n| state.node_text(n).to_string()) + find_direct_child_by_kind(node, "link_text") + .map(|n| state.node_text(n).to_string()) }), - _ => child_of_kind(node, "link_text").map(|n| state.node_text(n).to_string()), + _ => { + find_direct_child_by_kind(node, "link_text").map(|n| state.node_text(n).to_string()) + } }; let Some(label) = label.map(|raw| normalize_link_label(&raw)) else { return; @@ -526,7 +530,7 @@ impl MarkdownExtractor { } fn visit_link(state: &mut ExtractionState, node: TsNode<'_>) { - let Some(url_node) = child_of_kind(node, "link_destination") else { + let Some(url_node) = find_direct_child_by_kind(node, "link_destination") else { return; }; let Some(parent_id) = state @@ -562,22 +566,6 @@ impl MarkdownExtractor { } } -fn child_of_kind<'tree>(node: TsNode<'tree>, kind: &str) -> Option> { - let mut cursor = node.walk(); - if cursor.goto_first_child() { - loop { - let child = cursor.node(); - if child.kind() == kind { - return Some(child); - } - if !cursor.goto_next_sibling() { - break; - } - } - } - None -} - /// CommonMark label matching: trim, collapse whitespace, case-fold. fn normalize_link_label(label: &str) -> String { label diff --git a/crates/tracedecay-code-extraction/src/objc_extractor.rs b/crates/tracedecay-code-extraction/src/objc_extractor.rs index 99c0336a8a..f5ff046285 100644 --- a/crates/tracedecay-code-extraction/src/objc_extractor.rs +++ b/crates/tracedecay-code-extraction/src/objc_extractor.rs @@ -52,8 +52,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() @@ -72,10 +71,6 @@ impl<'s> ExtractionState<'s> { node.utf8_text(self.source).unwrap_or("") } - fn node_str(&self, node: TsNode<'_>) -> &'s str { - node.utf8_text(self.source).unwrap_or("") - } - fn text_before(&self, node: TsNode<'_>, end_byte: usize) -> &str { let start = node.start_byte(); let end = end_byte.min(self.source.len()).max(start); @@ -329,7 +324,7 @@ impl ObjcExtractor { .to_string() }) .or_else(|| { - let text = state.node_str(node); + let text = state.node_text(node); text.find('{').map(|pos| text[..pos].trim().to_string()) }); let qualified_name = format!("{}::{}", state.qualified_prefix(), enum_name); @@ -1340,7 +1335,7 @@ impl ObjcExtractor { .trim() .to_string(); } - let text = state.node_str(node); + let text = state.node_text(node); if let Some(brace_pos) = text.find('{') { text[..brace_pos].trim().to_string() } else { diff --git a/crates/tracedecay-code-extraction/src/pascal_extractor.rs b/crates/tracedecay-code-extraction/src/pascal_extractor.rs index 8d4cf2882b..a4f592afec 100644 --- a/crates/tracedecay-code-extraction/src/pascal_extractor.rs +++ b/crates/tracedecay-code-extraction/src/pascal_extractor.rs @@ -60,8 +60,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/perl_extractor.rs b/crates/tracedecay-code-extraction/src/perl_extractor.rs index 770e48311a..6b9a471709 100644 --- a/crates/tracedecay-code-extraction/src/perl_extractor.rs +++ b/crates/tracedecay-code-extraction/src/perl_extractor.rs @@ -51,8 +51,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/php_extractor.rs b/crates/tracedecay-code-extraction/src/php_extractor.rs index b58e0ca423..479071f53b 100644 --- a/crates/tracedecay-code-extraction/src/php_extractor.rs +++ b/crates/tracedecay-code-extraction/src/php_extractor.rs @@ -51,8 +51,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/powershell_extractor.rs b/crates/tracedecay-code-extraction/src/powershell_extractor.rs index 11646da95b..f0117a895a 100644 --- a/crates/tracedecay-code-extraction/src/powershell_extractor.rs +++ b/crates/tracedecay-code-extraction/src/powershell_extractor.rs @@ -7,7 +7,7 @@ use tree_sitter::{Node as TsNode, Tree}; use crate::common::{ExtractionState, local_node_id}; use crate::complexity::{POWERSHELL_COMPLEXITY, count_complexity}; -use crate::traversal::find_direct_child_by_kind; +use crate::traversal::{find_descendant_by_kind, find_direct_child_by_kind}; use crate::types::{ ComplexityAnalysisV1, Edge, EdgeKind, ExtractionResult, Node, NodeKind, UnresolvedRef, Visibility, generate_node_id, @@ -173,12 +173,12 @@ impl PowerShellExtractor { return; }; - let Some(cast) = Self::find_descendant_by_kind(left, "cast_expression") else { + let Some(cast) = find_descendant_by_kind(left, "cast_expression") else { return; }; // The variable is a child of the cast_expression. - let Some(var_node) = Self::find_descendant_by_kind(cast, "variable") else { + let Some(var_node) = find_descendant_by_kind(cast, "variable") else { return; }; @@ -387,32 +387,6 @@ impl PowerShellExtractor { } } - /// Find the first descendant of a node with a given kind (recursive DFS). - fn find_descendant_by_kind<'a>(node: TsNode<'a>, kind: &str) -> Option> { - let mut stack = vec![node]; - while let Some(current) = stack.pop() { - if current.kind() == kind { - return Some(current); - } - // Push children via cursor (O(N) per node) and reverse so the - // first child pops first. Previous revision used `current.child(i)` - // in a `for i in (0..N).rev()` loop, which is O(N²) per node - // because `child(i)` walks sibling links from index 0. - let start = stack.len(); - let mut cursor = current.walk(); - if cursor.goto_first_child() { - loop { - stack.push(cursor.node()); - if !cursor.goto_next_sibling() { - break; - } - } - } - stack[start..].reverse(); - } - None - } - /// Build the final `ExtractionResult` from the accumulated state. fn build_result(state: ExtractionState, start: Instant) -> ExtractionResult { ExtractionResult { diff --git a/crates/tracedecay-code-extraction/src/proto_extractor.rs b/crates/tracedecay-code-extraction/src/proto_extractor.rs index c077280ddf..c7ea37cd3c 100644 --- a/crates/tracedecay-code-extraction/src/proto_extractor.rs +++ b/crates/tracedecay-code-extraction/src/proto_extractor.rs @@ -55,8 +55,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/python_extractor.rs b/crates/tracedecay-code-extraction/src/python_extractor.rs index 2eb0a4cfd4..5afa90ba8d 100644 --- a/crates/tracedecay-code-extraction/src/python_extractor.rs +++ b/crates/tracedecay-code-extraction/src/python_extractor.rs @@ -55,8 +55,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/quint_extractor.rs b/crates/tracedecay-code-extraction/src/quint_extractor.rs index 7bdd49cacf..0220dc9bb9 100644 --- a/crates/tracedecay-code-extraction/src/quint_extractor.rs +++ b/crates/tracedecay-code-extraction/src/quint_extractor.rs @@ -62,12 +62,8 @@ impl<'s> ExtractionState<'s> { } } - fn node_str(&self, node: TsNode<'_>) -> &'s str { - node.utf8_text(self.source).unwrap_or("") - } - fn node_text(&self, node: TsNode<'_>) -> &'s str { - self.node_str(node) + node.utf8_text(self.source).unwrap_or("") } } @@ -96,7 +92,7 @@ impl TokenWalker { // The `.` operator extends an import path; everything else // is a terminator handled below. let extends_import = - matches!(kind, "identifier") || (kind == "operator" && state.node_str(child) == "."); + matches!(kind, "identifier") || (kind == "operator" && state.node_text(child) == "."); if !extends_import && let Some((parts, line)) = self.import_collect.take() { QuintExtractor::commit_import(state, &parts, line); } @@ -125,7 +121,7 @@ impl TokenWalker { } } "keyword" => { - let text = state.node_str(child); + let text = state.node_text(child); if text == "module" { self.pending = Some(PendingKind::Module); } else if text == "import" { @@ -136,7 +132,7 @@ impl TokenWalker { "storage_modifier" => { // `pure` prefixes `def`/`val`; we just keep updating `pending` // until the meaningful storage modifier arrives. - if let Some(kind) = quint_storage_kind(state.node_str(child)) { + if let Some(kind) = quint_storage_kind(state.node_text(child)) { self.pending = Some(kind); } } diff --git a/crates/tracedecay-code-extraction/src/ruby_extractor.rs b/crates/tracedecay-code-extraction/src/ruby_extractor.rs index 7b8e53c7a0..3deceaed4e 100644 --- a/crates/tracedecay-code-extraction/src/ruby_extractor.rs +++ b/crates/tracedecay-code-extraction/src/ruby_extractor.rs @@ -51,8 +51,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/rust_extractor.rs b/crates/tracedecay-code-extraction/src/rust_extractor.rs index 973e7b59c4..6039667648 100644 --- a/crates/tracedecay-code-extraction/src/rust_extractor.rs +++ b/crates/tracedecay-code-extraction/src/rust_extractor.rs @@ -14,6 +14,7 @@ use crate::extraction_artifact::{ ExtractedImportEvidenceV1, ExtractionArtifactV1, ImportNamespaceV1, ImportReexportScopeV1, import_module_kind, }; +use crate::traversal::find_direct_child_by_kind; use crate::types::{ ComplexityAnalysisV1, Edge, EdgeKind, ExtractionResult, Node, NodeKind, SourceSpan, UnresolvedRef, Visibility, generate_node_id, @@ -165,8 +166,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() @@ -2031,7 +2031,7 @@ impl RustExtractor { } } } - let Some(where_clause) = Self::child_of_kind(item, "where_clause") else { + let Some(where_clause) = find_direct_child_by_kind(item, "where_clause") else { return; }; let mut cursor = where_clause.walk(); @@ -2084,22 +2084,6 @@ impl RustExtractor { clause } - fn child_of_kind<'t>(node: TsNode<'t>, kind: &str) -> Option> { - let mut cursor = node.walk(); - if !cursor.goto_first_child() { - return None; - } - loop { - let child = cursor.node(); - if child.kind() == kind { - return Some(child); - } - if !cursor.goto_next_sibling() { - return None; - } - } - } - /// The type a `let` initialiser states in syntax: a `T { .. }` literal, /// optionally behind `?`. Method names are never return-type evidence. /// Abstain rather than fabricate a receiver type. diff --git a/crates/tracedecay-code-extraction/src/scala_extractor.rs b/crates/tracedecay-code-extraction/src/scala_extractor.rs index 827d6ad676..21e5852965 100644 --- a/crates/tracedecay-code-extraction/src/scala_extractor.rs +++ b/crates/tracedecay-code-extraction/src/scala_extractor.rs @@ -56,8 +56,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/source_mask.rs b/crates/tracedecay-code-extraction/src/source_mask.rs index b383572d2f..e210a14420 100644 --- a/crates/tracedecay-code-extraction/src/source_mask.rs +++ b/crates/tracedecay-code-extraction/src/source_mask.rs @@ -44,13 +44,6 @@ impl MaskOptions { }; } -/// Returns a copy of `source` with comment and string/char literal contents -/// blanked, preserving implicit format captures. Equivalent to -/// [`masked_rust_source_with`] using [`MaskOptions::UNUSED_IMPORTS`]. -pub fn masked_rust_source(source: &str) -> String { - masked_rust_source_with(source, MaskOptions::UNUSED_IMPORTS) -} - /// Returns a copy of `source` with comments and string/char literals blanked. /// `opts` controls whether implicit format captures survive. /// Blanked bytes become ASCII spaces; newlines and total byte length are @@ -408,7 +401,7 @@ fn format_capture_identifier_end(bytes: &[u8], start: usize) -> Option { #[cfg(test)] mod tests { - use super::{MaskOptions, masked_rust_source, masked_rust_source_with}; + use super::{MaskOptions, masked_rust_source_with}; /// Whole-token match used by the scanners: does `identifier` appear as a /// real token (non-identifier boundaries) anywhere on `line`? Mirrors the @@ -436,7 +429,7 @@ mod tests { /// Does `identifier` survive default import-discovery masking of `source`? fn referenced(source: &str, identifier: &str) -> bool { - masked_rust_source(source) + masked_rust_source_with(source, MaskOptions::UNUSED_IMPORTS) .lines() .any(|line| contains_token(line, identifier)) } @@ -584,7 +577,7 @@ mod tests { #[test] fn masking_preserves_line_count_and_length() { let src = "fn f() {\n // comment HashMap\n let s = \"str\";\n}\n"; - let masked = masked_rust_source(src); + let masked = masked_rust_source_with(src, MaskOptions::UNUSED_IMPORTS); assert_eq!(masked.len(), src.len()); assert_eq!(masked.lines().count(), src.lines().count()); } diff --git a/crates/tracedecay-code-extraction/src/swift_extractor.rs b/crates/tracedecay-code-extraction/src/swift_extractor.rs index b2f4a8d695..355c901589 100644 --- a/crates/tracedecay-code-extraction/src/swift_extractor.rs +++ b/crates/tracedecay-code-extraction/src/swift_extractor.rs @@ -51,8 +51,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/typescript_extractor.rs b/crates/tracedecay-code-extraction/src/typescript_extractor.rs index bf7c83df5d..4c5bb687c4 100644 --- a/crates/tracedecay-code-extraction/src/typescript_extractor.rs +++ b/crates/tracedecay-code-extraction/src/typescript_extractor.rs @@ -68,8 +68,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/vbnet_extractor.rs b/crates/tracedecay-code-extraction/src/vbnet_extractor.rs index ab15161e51..bc3f14a9ad 100644 --- a/crates/tracedecay-code-extraction/src/vbnet_extractor.rs +++ b/crates/tracedecay-code-extraction/src/vbnet_extractor.rs @@ -80,8 +80,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/src/zig_extractor.rs b/crates/tracedecay-code-extraction/src/zig_extractor.rs index 34a9469824..4d72f6c8a1 100644 --- a/crates/tracedecay-code-extraction/src/zig_extractor.rs +++ b/crates/tracedecay-code-extraction/src/zig_extractor.rs @@ -51,8 +51,7 @@ impl<'s> ExtractionState<'s> { /// /// The file root is pushed onto `node_stack` as the first frame when /// extraction begins, so iterating the stack already yields the file - /// path as the leading segment. Prepending `self.file_path` here was - /// a leftover that duplicated the prefix (`::::Type::method`). + /// path as the leading segment. fn qualified_prefix(&self) -> String { self.node_stack .iter() diff --git a/crates/tracedecay-code-extraction/tests/main/bash.rs b/crates/tracedecay-code-extraction/tests/main/bash.rs index 72534e8269..5ff298ddbe 100644 --- a/crates/tracedecay-code-extraction/tests/main/bash.rs +++ b/crates/tracedecay-code-extraction/tests/main/bash.rs @@ -8,13 +8,7 @@ use tracedecay_code_extraction::parsed_extraction::{ }; use tracedecay_domain::*; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Display, -{ - T::try_from(value.to_owned()).unwrap_or_else(|error| panic!("{value}: {error}")) -} +use tracedecay_domain::test_fixtures::id; fn bash_overlay(version: i64, content: &str) -> ParseDocumentIdentity { ParseDocumentIdentity::SessionOverlay { diff --git a/crates/tracedecay-code-extraction/tests/main/incremental_parse.rs b/crates/tracedecay-code-extraction/tests/main/incremental_parse.rs index 4ed28af5c3..6c200d4e09 100644 --- a/crates/tracedecay-code-extraction/tests/main/incremental_parse.rs +++ b/crates/tracedecay-code-extraction/tests/main/incremental_parse.rs @@ -16,13 +16,7 @@ use tracedecay_domain::{ RepositoryDirtyStateV1, RepositoryId, SourceSpan, TreeId, WorktreeId, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Display, -{ - T::try_from(value.to_owned()).unwrap_or_else(|error| panic!("{value}: {error}")) -} +use tracedecay_domain::test_fixtures::id; fn identity(commit: &str, tree: &str, dirty: RepositoryDirtyStateV1) -> ParseDocumentIdentity { identity_in_worktree(commit, tree, dirty, "worktree.incremental") diff --git a/crates/tracedecay-code-extraction/tests/main/metal.rs b/crates/tracedecay-code-extraction/tests/main/metal.rs index de93ed7358..67a58f05ce 100644 --- a/crates/tracedecay-code-extraction/tests/main/metal.rs +++ b/crates/tracedecay-code-extraction/tests/main/metal.rs @@ -55,13 +55,7 @@ fn names(result: &ExtractionResult, kind: NodeKind) -> Vec<&str> { .collect() } -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Display, -{ - T::try_from(value.to_owned()).unwrap_or_else(|error| panic!("{value}: {error}")) -} +use tracedecay_domain::test_fixtures::id; #[test] fn metal_dispatches_on_its_extension() { diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index e6009275fb..4f14d84089 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -67,12 +67,12 @@ pub use text_artifacts::{ }; use generation_transactions::{ - GENERATION_RECEIPT_STORE, acquire_graph_replay_pool_lock_checked, - cleanup_committed_transaction, cleanup_committed_transaction_under_graph_replay_pool_lock, - clear_transaction, expose_staged_generations_under_graph_replay_pool_lock, load_transaction, - open_file_sha256_hex_cancellable, path_still_names_open_file, persist_transaction, - receipt_is_durable, regular_file_exists, remove_empty_stage_root, rollback_staged_transaction, - stage_collectable_generations, transaction_path, write_receipt, + GENERATION_RECEIPT_STORE, GENERATION_TRANSACTION_JOURNAL, + acquire_graph_replay_pool_lock_checked, cleanup_committed_transaction, + cleanup_committed_transaction_under_graph_replay_pool_lock, + expose_staged_generations_under_graph_replay_pool_lock, open_file_sha256_hex_cancellable, + path_still_names_open_file, regular_file_exists, remove_empty_stage_root, + rollback_staged_transaction, stage_collectable_generations, transaction_path, }; #[cfg(test)] use generation_transactions::{ @@ -84,14 +84,14 @@ use receipt_store::receipt_digest_file_component; use scope_roots::is_code_index_scope_hash; #[cfg(test)] use scope_roots::{ - ScopeRootRetentionTransactionV1, build_scope_receipt, persist_scope_transaction, - scope_receipt_digest, scope_receipt_path, scope_stage_root, scope_transaction_path, - validate_scope_transaction, write_scope_receipt, + SCOPE_RECEIPT_STORE, SCOPE_TRANSACTION_JOURNAL, ScopeRootRetentionTransactionV1, + build_scope_receipt, scope_receipt_digest, scope_receipt_path, scope_stage_root, + scope_transaction_path, validate_scope_transaction, }; #[cfg(test)] use text_artifacts::{ - build_text_artifact_receipt, persist_text_artifact_transaction, - stage_collectable_text_artifacts, total_text_artifact_bytes, write_text_artifact_receipt, + TEXT_ARTIFACT_RECEIPT_STORE, TEXT_ARTIFACT_TRANSACTION_JOURNAL, build_text_artifact_receipt, + stage_collectable_text_artifacts, total_text_artifact_bytes, }; use text_artifacts::{ execute_text_artifact_retention_under_store_lock, plan_collectable_text_artifacts_cancellable, @@ -1553,7 +1553,7 @@ pub fn execute_code_generation_retention_cancellable( )?), None => None, }; - persist_transaction(store_root, &transaction)?; + journal::persist_journal(store_root, &GENERATION_TRANSACTION_JOURNAL, &transaction)?; let result = (|| { // Durable before the first unlink: the pointer must never name a @@ -1579,7 +1579,12 @@ pub fn execute_code_generation_retention_cancellable( // the replay reconciler never observes a receipt whose pool // survival events are missing. graph_replay_release::write_events(store_root, &receipt)?; - write_receipt(store_root, &receipt)?; + receipt_store::write_receipt( + store_root, + &GENERATION_RECEIPT_STORE, + &receipt.receipt_digest, + &receipt, + )?; cleanup_committed_transaction_under_graph_replay_pool_lock( store_root, &transaction, @@ -1592,13 +1597,18 @@ pub fn execute_code_generation_retention_cancellable( graph_replay_pool_root, is_cancelled, )?; - clear_transaction(store_root) + journal::clear_journal(store_root, &GENERATION_TRANSACTION_JOURNAL) })(); if let Err(error) = result { drop(graph_replay_pool_lock); - if !receipt_is_durable(store_root, &receipt)? { + if !receipt_store::receipt_is_durable( + store_root, + &GENERATION_RECEIPT_STORE, + &receipt.receipt_digest, + &receipt, + )? { rollback_staged_transaction(store_root, &transaction, graph_replay_pool_root)?; - clear_transaction(store_root)?; + journal::clear_journal(store_root, &GENERATION_TRANSACTION_JOURNAL)?; } return Err(error); } @@ -1769,11 +1779,17 @@ fn recover_pending_transaction_unlocked( graph_replay_pool_root: Option<&Path>, is_cancelled: &dyn Fn() -> bool, ) -> Result<(), CodeGenerationRetentionErrorV1> { - let Some(transaction) = load_transaction(store_root)? else { + let Some(transaction) = journal::load_journal(store_root, &GENERATION_TRANSACTION_JOURNAL)? + else { return Ok(()); }; - if receipt_is_durable(store_root, &transaction.receipt)? { + if receipt_store::receipt_is_durable( + store_root, + &GENERATION_RECEIPT_STORE, + &transaction.receipt.receipt_digest, + &transaction.receipt, + )? { cleanup_committed_transaction( store_root, &transaction, @@ -1784,7 +1800,7 @@ fn recover_pending_transaction_unlocked( } else { rollback_staged_transaction(store_root, &transaction, graph_replay_pool_root)?; } - clear_transaction(store_root) + journal::clear_journal(store_root, &GENERATION_TRANSACTION_JOURNAL) } fn read_active_pointer( diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_transactions.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_transactions.rs index a87577aeee..20b52ab396 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/generation_transactions.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/generation_transactions.rs @@ -17,11 +17,8 @@ use tracedecay_domain::CodeGenerationId; use tracedecay_domain::canonical_text::{encode_lowercase_hex, is_lowercase_hex}; use super::graph_replay_release; -use super::journal::{ - BoundedJournalSpec, clear_journal, journal_path, load_journal, persist_journal, -}; +use super::journal::{BoundedJournalSpec, journal_path}; use super::locking::{CodeGenerationStoreLockV1, try_acquire_code_generation_store_lock}; -use super::receipt_store; use super::receipt_store::ReceiptStoreSpec; use super::{ CodeGenerationRetentionErrorV1, CodeGenerationRetentionGenerationV1, @@ -32,14 +29,15 @@ use super::{ sync_directory, total_bytes, validate_generation_file, write_active_pointer, }; -const GENERATION_TRANSACTION_JOURNAL: BoundedJournalSpec = - BoundedJournalSpec { - file_name: TRANSACTION_FILE, - max_bytes: MAX_TRANSACTION_BYTES, - label: "retention transaction", - write_context: "code-generation-retention-transaction", - validate: validate_transaction, - }; +pub(super) const GENERATION_TRANSACTION_JOURNAL: BoundedJournalSpec< + CodeGenerationRetentionTransactionV1, +> = BoundedJournalSpec { + file_name: TRANSACTION_FILE, + max_bytes: MAX_TRANSACTION_BYTES, + label: "retention transaction", + write_context: "code-generation-retention-transaction", + validate: validate_transaction, +}; pub(super) const GENERATION_RECEIPT_STORE: ReceiptStoreSpec = ReceiptStoreSpec { directory: RECEIPTS_DIRECTORY, @@ -58,19 +56,6 @@ pub(super) fn transaction_stage_root( .join(&receipt.receipt_digest) } -pub(super) fn persist_transaction( - store_root: &Path, - transaction: &CodeGenerationRetentionTransactionV1, -) -> Result<(), CodeGenerationRetentionErrorV1> { - persist_journal(store_root, &GENERATION_TRANSACTION_JOURNAL, transaction) -} - -pub(super) fn load_transaction( - store_root: &Path, -) -> Result, CodeGenerationRetentionErrorV1> { - load_journal(store_root, &GENERATION_TRANSACTION_JOURNAL) -} - pub(super) fn validate_transaction( transaction: &CodeGenerationRetentionTransactionV1, ) -> Result<(), CodeGenerationRetentionErrorV1> { @@ -140,30 +125,6 @@ pub(super) fn validate_transaction( Ok(()) } -pub(super) fn receipt_is_durable( - store_root: &Path, - receipt: &CodeGenerationRetentionReceiptV1, -) -> Result { - receipt_store::receipt_is_durable( - store_root, - &GENERATION_RECEIPT_STORE, - &receipt.receipt_digest, - receipt, - ) -} - -pub(super) fn write_receipt( - store_root: &Path, - receipt: &CodeGenerationRetentionReceiptV1, -) -> Result<(), CodeGenerationRetentionErrorV1> { - receipt_store::write_receipt( - store_root, - &GENERATION_RECEIPT_STORE, - &receipt.receipt_digest, - receipt, - ) -} - #[hotpath::measure(label = "usecases.retention.stage")] pub(super) fn stage_collectable_generations( store_root: &Path, @@ -955,10 +916,6 @@ pub(super) fn ensure_transaction_liveness( Ok(()) } -pub(super) fn clear_transaction(store_root: &Path) -> Result<(), CodeGenerationRetentionErrorV1> { - clear_journal(store_root, &GENERATION_TRANSACTION_JOURNAL) -} - pub(super) fn remove_empty_stage_root( stage_root: &Path, ) -> Result<(), CodeGenerationRetentionErrorV1> { diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/scope_roots.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/scope_roots.rs index ec3d0b611b..34dc440402 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/scope_roots.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/scope_roots.rs @@ -39,7 +39,7 @@ use super::{ storage, }; -const SCOPE_TRANSACTION_JOURNAL: BoundedJournalSpec = +pub(super) const SCOPE_TRANSACTION_JOURNAL: BoundedJournalSpec = BoundedJournalSpec { file_name: SCOPE_RETENTION_TRANSACTION_FILE, max_bytes: MAX_SCOPE_TRANSACTION_BYTES, @@ -48,16 +48,17 @@ const SCOPE_TRANSACTION_JOURNAL: BoundedJournalSpec = - BoundedJournalSpec { - file_name: SCOPE_BINDING_CLEANUP_INTENT_FILE, - max_bytes: MAX_SCOPE_BINDING_CLEANUP_INTENT_BYTES, - label: "scope binding cleanup intent", - write_context: "code-index-scope-binding-cleanup-intent", - validate: validate_scope_binding_cleanup_intent, - }; +pub(super) const SCOPE_BINDING_CLEANUP_INTENT_JOURNAL: BoundedJournalSpec< + ScopeRootBindingCleanupIntentV1, +> = BoundedJournalSpec { + file_name: SCOPE_BINDING_CLEANUP_INTENT_FILE, + max_bytes: MAX_SCOPE_BINDING_CLEANUP_INTENT_BYTES, + label: "scope binding cleanup intent", + write_context: "code-index-scope-binding-cleanup-intent", + validate: validate_scope_binding_cleanup_intent, +}; -const SCOPE_RECEIPT_STORE: ReceiptStoreSpec = ReceiptStoreSpec { +pub(super) const SCOPE_RECEIPT_STORE: ReceiptStoreSpec = ReceiptStoreSpec { directory: SCOPE_RETENTION_RECEIPTS_DIRECTORY, label: "scope reconciliation receipt", }; @@ -644,7 +645,7 @@ pub fn execute_scope_root_retention( "scope liveness authority changed at the quarantine boundary".to_owned(), )); } - match load_scope_binding_cleanup_intent(store_root)? { + match load_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL)? { Some(intent) if intent == expected_binding_cleanup_intent => {} Some(_) => { return Err(CodeGenerationRetentionErrorV1::UnsafeState( @@ -716,18 +717,28 @@ pub fn execute_scope_root_retention( receipt: receipt.clone(), scope_identities: quarantine.scope_identities().clone(), }; - persist_scope_transaction(store_root, &transaction)?; + persist_journal(store_root, &SCOPE_TRANSACTION_JOURNAL, &transaction)?; let result = (|| { quarantine.stage(&transaction.receipt.collected_scopes)?; - write_scope_receipt(store_root, &receipt)?; + receipt_store::write_receipt( + store_root, + &SCOPE_RECEIPT_STORE, + &receipt.receipt_digest, + &receipt, + )?; quarantine.cleanup_committed(&transaction.receipt.collected_scopes)?; - clear_scope_transaction(store_root) + clear_journal(store_root, &SCOPE_TRANSACTION_JOURNAL) })(); if let Err(error) = result { - if !scope_receipt_is_durable(store_root, &receipt)? { + if !receipt_store::receipt_is_durable( + store_root, + &SCOPE_RECEIPT_STORE, + &receipt.receipt_digest, + &receipt, + )? { quarantine.rollback(&transaction.receipt.collected_scopes)?; - clear_scope_transaction(store_root)?; + clear_journal(store_root, &SCOPE_TRANSACTION_JOURNAL)?; } return Err(error); } @@ -792,8 +803,8 @@ pub fn prepare_scope_root_binding_cleanup( "scope binding cleanup cannot begin while filesystem recovery is pending".to_owned(), )); } - match load_scope_binding_cleanup_intent(store_root)? { - None => persist_scope_binding_cleanup_intent(store_root, &intent), + match load_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL)? { + None => persist_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL, &intent), Some(existing) if existing == intent => Ok(()), Some(_) => Err(CodeGenerationRetentionErrorV1::UnsafeState( "a different scope binding cleanup intent is already pending".to_owned(), @@ -816,11 +827,16 @@ pub fn recover_scope_root_binding_cleanup( "scope binding cleanup requires filesystem transaction recovery first".to_owned(), )); } - let Some(intent) = load_scope_binding_cleanup_intent(store_root)? else { + let Some(intent) = load_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL)? else { return Ok(None); }; let source_exists = scope_directory_exists(&scope_root_path(store_root, &intent.scope_hash)?)?; - if scope_receipt_is_durable(store_root, &intent.receipt)? { + if receipt_store::receipt_is_durable( + store_root, + &SCOPE_RECEIPT_STORE, + &intent.receipt.receipt_digest, + &intent.receipt, + )? { if source_exists { return Err(CodeGenerationRetentionErrorV1::UnsafeState( "scope binding cleanup receipt is durable but its source scope remains".to_owned(), @@ -833,7 +849,7 @@ pub fn recover_scope_root_binding_cleanup( })); } if source_exists { - clear_scope_binding_cleanup_intent(store_root)?; + clear_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL)?; return Ok(None); } Err(CodeGenerationRetentionErrorV1::UnsafeState( @@ -853,11 +869,12 @@ pub fn complete_scope_root_binding_cleanup( "scope binding cleanup cannot complete while filesystem recovery is pending".to_owned(), )); } - let intent = load_scope_binding_cleanup_intent(store_root)?.ok_or_else(|| { - CodeGenerationRetentionErrorV1::UnsafeState( - "scope binding cleanup completion has no pending intent".to_owned(), - ) - })?; + let intent = + load_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL)?.ok_or_else(|| { + CodeGenerationRetentionErrorV1::UnsafeState( + "scope binding cleanup completion has no pending intent".to_owned(), + ) + })?; if intent.scope_hash != replay.scope_hash || intent.source_scope != replay.source_scope || intent.liveness_proof != replay.liveness_proof @@ -866,7 +883,12 @@ pub fn complete_scope_root_binding_cleanup( "scope binding cleanup completion does not match its pending intent".to_owned(), )); } - if !scope_receipt_is_durable(store_root, &intent.receipt)? { + if !receipt_store::receipt_is_durable( + store_root, + &SCOPE_RECEIPT_STORE, + &intent.receipt.receipt_digest, + &intent.receipt, + )? { return Err(CodeGenerationRetentionErrorV1::UnsafeState( "scope binding cleanup completion has no durable filesystem receipt".to_owned(), )); @@ -876,13 +898,13 @@ pub fn complete_scope_root_binding_cleanup( "scope binding cleanup completion found its source scope present".to_owned(), )); } - clear_scope_binding_cleanup_intent(store_root) + clear_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL) } pub(super) fn recover_pending_scope_transaction_unlocked( store_root: &Path, ) -> Result<(), CodeGenerationRetentionErrorV1> { - let Some(transaction) = load_scope_transaction(store_root)? else { + let Some(transaction) = load_journal(store_root, &SCOPE_TRANSACTION_JOURNAL)? else { return Ok(()); }; let mut quarantine = ScopeQuarantineAuthority::recover( @@ -890,12 +912,17 @@ pub(super) fn recover_pending_scope_transaction_unlocked( &transaction.receipt.receipt_digest, transaction.scope_identities.clone(), )?; - if scope_receipt_is_durable(store_root, &transaction.receipt)? { + if receipt_store::receipt_is_durable( + store_root, + &SCOPE_RECEIPT_STORE, + &transaction.receipt.receipt_digest, + &transaction.receipt, + )? { quarantine.cleanup_committed(&transaction.receipt.collected_scopes)?; } else { quarantine.rollback(&transaction.receipt.collected_scopes)?; } - clear_scope_transaction(store_root) + clear_journal(store_root, &SCOPE_TRANSACTION_JOURNAL) } pub(super) fn scope_transaction_path(store_root: &Path) -> PathBuf { @@ -1279,68 +1306,6 @@ pub(super) fn validate_scope_binding_cleanup_intent( Ok(()) } -pub(super) fn persist_scope_transaction( - store_root: &Path, - transaction: &ScopeRootRetentionTransactionV1, -) -> Result<(), CodeGenerationRetentionErrorV1> { - persist_journal(store_root, &SCOPE_TRANSACTION_JOURNAL, transaction) -} - -pub(super) fn load_scope_transaction( - store_root: &Path, -) -> Result, CodeGenerationRetentionErrorV1> { - load_journal(store_root, &SCOPE_TRANSACTION_JOURNAL) -} - -pub(super) fn clear_scope_transaction( - store_root: &Path, -) -> Result<(), CodeGenerationRetentionErrorV1> { - clear_journal(store_root, &SCOPE_TRANSACTION_JOURNAL) -} - -pub(super) fn persist_scope_binding_cleanup_intent( - store_root: &Path, - intent: &ScopeRootBindingCleanupIntentV1, -) -> Result<(), CodeGenerationRetentionErrorV1> { - persist_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL, intent) -} - -pub(super) fn load_scope_binding_cleanup_intent( - store_root: &Path, -) -> Result, CodeGenerationRetentionErrorV1> { - load_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL) -} - -pub(super) fn clear_scope_binding_cleanup_intent( - store_root: &Path, -) -> Result<(), CodeGenerationRetentionErrorV1> { - clear_journal(store_root, &SCOPE_BINDING_CLEANUP_INTENT_JOURNAL) -} - -pub(super) fn scope_receipt_is_durable( - store_root: &Path, - receipt: &ScopeRootRetentionReceiptV1, -) -> Result { - receipt_store::receipt_is_durable( - store_root, - &SCOPE_RECEIPT_STORE, - &receipt.receipt_digest, - receipt, - ) -} - -pub(super) fn write_scope_receipt( - store_root: &Path, - receipt: &ScopeRootRetentionReceiptV1, -) -> Result<(), CodeGenerationRetentionErrorV1> { - receipt_store::write_receipt( - store_root, - &SCOPE_RECEIPT_STORE, - &receipt.receipt_digest, - receipt, - ) -} - pub(super) fn scope_directory_exists(path: &Path) -> Result { match std::fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_dir() => Ok(true), diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index e97d9ee5be..0c6f6b8d1e 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -1,6 +1,7 @@ #![allow(clippy::expect_used, clippy::unwrap_used)] use super::*; +use super::{journal, receipt_store}; use tracedecay_domain::sha256_hex_suffix; mod graph_replay_pool_lock_tests; @@ -1304,8 +1305,12 @@ fn text_artifact_recovery_rolls_back_before_receipt_and_commits_after_receipt() active_pointer: plan.active_pointer.clone(), receipt: receipt.clone(), }; - persist_text_artifact_transaction(store.path(), &transaction) - .expect("journal artifact retention"); + journal::persist_journal( + store.path(), + &TEXT_ARTIFACT_TRANSACTION_JOURNAL, + &transaction, + ) + .expect("journal artifact retention"); stage_collectable_text_artifacts(store.path(), &transaction).expect("quarantine artifact"); assert!(!orphan_path.exists()); recover_code_generation_retention(store.path(), &BTreeSet::new(), None) @@ -1315,11 +1320,21 @@ fn text_artifact_recovery_rolls_back_before_receipt_and_commits_after_receipt() "uncommitted artifact staging must roll back" ); - persist_text_artifact_transaction(store.path(), &transaction) - .expect("journal second transaction"); + journal::persist_journal( + store.path(), + &TEXT_ARTIFACT_TRANSACTION_JOURNAL, + &transaction, + ) + .expect("journal second transaction"); stage_collectable_text_artifacts(store.path(), &transaction) .expect("quarantine second artifact"); - write_text_artifact_receipt(store.path(), &receipt).expect("durably commit artifact receipt"); + receipt_store::write_receipt( + store.path(), + &TEXT_ARTIFACT_RECEIPT_STORE, + &receipt.receipt_digest, + &receipt, + ) + .expect("durably commit artifact receipt"); recover_code_generation_retention(store.path(), &BTreeSet::new(), None) .expect("finish a committed artifact transaction"); assert!( @@ -1352,8 +1367,12 @@ fn cancellable_recovery_preserves_pending_artifact_journal_for_retry() { active_pointer: plan.active_pointer.clone(), receipt, }; - persist_text_artifact_transaction(store.path(), &transaction) - .expect("journal artifact retention"); + journal::persist_journal( + store.path(), + &TEXT_ARTIFACT_TRANSACTION_JOURNAL, + &transaction, + ) + .expect("journal artifact retention"); stage_collectable_text_artifacts(store.path(), &transaction) .expect("quarantine uncommitted candidate"); assert!(!orphan_path.exists()); @@ -1781,7 +1800,8 @@ fn recovery_restores_quarantined_generations_without_a_durable_receipt() { let generations_root = store.path().join(GENERATIONS_DIRECTORY); let staged_root = transaction_stage_root(store.path(), &receipt); - persist_transaction(store.path(), &transaction).expect("persist transaction journal"); + journal::persist_journal(store.path(), &GENERATION_TRANSACTION_JOURNAL, &transaction) + .expect("persist transaction journal"); stage_collectable_generations(store.path(), &transaction).expect("stage generation"); assert!(!generations_root.join(&collectable.generation_file).exists()); assert!(staged_root.join(&collectable.generation_file).is_file()); @@ -2366,7 +2386,8 @@ fn scope_recovery_restores_quarantined_scopes_without_a_durable_receipt() { let staged_root = scope_stage_root(store.path(), &receipt); // Crash exactly between quarantine and the durable receipt. - persist_scope_transaction(store.path(), &transaction).expect("persist journal"); + journal::persist_journal(store.path(), &SCOPE_TRANSACTION_JOURNAL, &transaction) + .expect("persist journal"); quarantine .stage(&transaction.receipt.collected_scopes) .expect("quarantine stranded scope"); @@ -2412,11 +2433,18 @@ fn scope_recovery_completes_collection_once_the_receipt_is_durable() { // Crash after the receipt is durable but before the quarantine is // unlinked: the decision is committed, so recovery rolls forward. - persist_scope_transaction(store.path(), &transaction).expect("persist journal"); + journal::persist_journal(store.path(), &SCOPE_TRANSACTION_JOURNAL, &transaction) + .expect("persist journal"); quarantine .stage(&transaction.receipt.collected_scopes) .expect("quarantine stranded scope"); - write_scope_receipt(store.path(), &receipt).expect("commit reconciliation receipt"); + receipt_store::write_receipt( + store.path(), + &SCOPE_RECEIPT_STORE, + &receipt.receipt_digest, + &receipt, + ) + .expect("commit reconciliation receipt"); recover_scope_root_retention(store.path()).expect("recover committed reconciliation"); @@ -2999,7 +3027,8 @@ fn pointer_rewrite_fixture() -> PointerRewriteFixture { active_pointer: Some(original.clone()), receipt, }; - persist_transaction(store.path(), &transaction).expect("journal the collection unit"); + journal::persist_journal(store.path(), &GENERATION_TRANSACTION_JOURNAL, &transaction) + .expect("journal the collection unit"); PointerRewriteFixture { store, original, @@ -3077,8 +3106,13 @@ fn recovery_keeps_the_rewritten_index_once_the_receipt_is_durable() { .expect("publish the rewritten index"); stage_collectable_generations(fixture.store.path(), &fixture.transaction) .expect("quarantine the collectable generations"); - write_receipt(fixture.store.path(), &fixture.transaction.receipt) - .expect("commit the deletion receipt"); + receipt_store::write_receipt( + fixture.store.path(), + &GENERATION_RECEIPT_STORE, + &fixture.transaction.receipt.receipt_digest, + &fixture.transaction.receipt, + ) + .expect("commit the deletion receipt"); recover_code_generation_retention(fixture.store.path(), &BTreeSet::new(), None) .expect("finish a committed collection unit"); @@ -3099,8 +3133,13 @@ fn recovery_completes_a_committed_rewrite_that_never_reached_the_pointer() { let fixture = pointer_rewrite_fixture(); stage_collectable_generations(fixture.store.path(), &fixture.transaction) .expect("quarantine the collectable generations"); - write_receipt(fixture.store.path(), &fixture.transaction.receipt) - .expect("commit the deletion receipt"); + receipt_store::write_receipt( + fixture.store.path(), + &GENERATION_RECEIPT_STORE, + &fixture.transaction.receipt.receipt_digest, + &fixture.transaction.receipt, + ) + .expect("commit the deletion receipt"); assert_eq!( read_active_pointer(fixture.store.path()).expect("read pointer"), fixture.original, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_release_tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_release_tests.rs index d8a0ad9bf8..d1a181eec6 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_release_tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests/graph_replay_release_tests.rs @@ -193,7 +193,8 @@ fn missing_staged_generation_blocks_pool_exposure_before_receipt() { active_pointer: plan.active_pointer.clone(), receipt: receipt.clone(), }; - persist_transaction(store.path(), &transaction).expect("persist transaction journal"); + journal::persist_journal(store.path(), &GENERATION_TRANSACTION_JOURNAL, &transaction) + .expect("persist transaction journal"); stage_collectable_generations(store.path(), &transaction).expect("stage generation"); let missing = transaction_stage_root(store.path(), &receipt) .join(&receipt.deleted_generations[0].generation_file); @@ -241,7 +242,8 @@ fn stale_reconciler_retirement_interleaves_with_retention_without_orphan_or_miss }; // Retention: journal, quarantine, and expose before the receipt. - persist_transaction(store.path(), &transaction).expect("persist transaction journal"); + journal::persist_journal(store.path(), &GENERATION_TRANSACTION_JOURNAL, &transaction) + .expect("persist transaction journal"); stage_collectable_generations(store.path(), &transaction).expect("stage generation"); { let pool_lock = @@ -261,7 +263,13 @@ fn stale_reconciler_retirement_interleaves_with_retention_without_orphan_or_miss // Retention: the receipt and its release event become durable, in the // same order the production executor uses (events before receipt). graph_replay_release::write_events(store.path(), &receipt).expect("write release events"); - write_receipt(store.path(), &receipt).expect("write durable receipt"); + receipt_store::write_receipt( + store.path(), + &GENERATION_RECEIPT_STORE, + &receipt.receipt_digest, + &receipt, + ) + .expect("write durable receipt"); let page = code_generation_graph_replay_release_page(store.path(), None) .expect("read durable release event"); assert_eq!(page.releases.len(), 1); @@ -333,7 +341,8 @@ fn stale_reconciler_retirement_interleaves_with_retention_without_orphan_or_miss &|| false, ) .expect("cleanup retries after release completion"); - clear_transaction(store.path()).expect("clear transaction journal"); + journal::clear_journal(store.path(), &GENERATION_TRANSACTION_JOURNAL) + .expect("clear transaction journal"); // No orphan: the consumed release's pool copy must not be resurrected, // and no release event survives without its pool copy. diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs index a6e90c8cd9..b1a938e25d 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/text_artifacts.rs @@ -34,7 +34,7 @@ use super::{ validate_sealed_generation_identity, validate_text_artifact_descriptor, }; -const TEXT_ARTIFACT_TRANSACTION_JOURNAL: BoundedJournalSpec< +pub(super) const TEXT_ARTIFACT_TRANSACTION_JOURNAL: BoundedJournalSpec< CodeTextArtifactRetentionTransactionV1, > = BoundedJournalSpec { file_name: TEXT_ARTIFACT_TRANSACTION_FILE, @@ -44,7 +44,7 @@ const TEXT_ARTIFACT_TRANSACTION_JOURNAL: BoundedJournalSpec< validate: validate_text_artifact_transaction, }; -const TEXT_ARTIFACT_RECEIPT_STORE: ReceiptStoreSpec = ReceiptStoreSpec { +pub(super) const TEXT_ARTIFACT_RECEIPT_STORE: ReceiptStoreSpec = ReceiptStoreSpec { directory: TEXT_ARTIFACT_RECEIPTS_DIRECTORY, label: "text-artifact retention receipt", }; @@ -653,7 +653,7 @@ pub(super) fn execute_text_artifact_retention_under_store_lock( active_pointer: active_pointer.cloned(), receipt: receipt.clone(), }; - persist_text_artifact_transaction(store_root, &transaction)?; + persist_journal(store_root, &TEXT_ARTIFACT_TRANSACTION_JOURNAL, &transaction)?; let result = (|| { if observe_cancel(is_cancelled) { return Err(CodeGenerationRetentionErrorV1::Cancelled); @@ -671,14 +671,24 @@ pub(super) fn execute_text_artifact_retention_under_store_lock( if observe_cancel(is_cancelled) { return Err(CodeGenerationRetentionErrorV1::Cancelled); } - write_text_artifact_receipt(store_root, &receipt)?; + receipt_store::write_receipt( + store_root, + &TEXT_ARTIFACT_RECEIPT_STORE, + &receipt.receipt_digest, + &receipt, + )?; cleanup_committed_text_artifact_transaction(store_root, &transaction)?; - clear_text_artifact_transaction(store_root) + clear_journal(store_root, &TEXT_ARTIFACT_TRANSACTION_JOURNAL) })(); if let Err(error) = result { - if !text_artifact_receipt_is_durable(store_root, &receipt)? { + if !receipt_store::receipt_is_durable( + store_root, + &TEXT_ARTIFACT_RECEIPT_STORE, + &receipt.receipt_digest, + &receipt, + )? { rollback_staged_text_artifact_transaction(store_root, &transaction)?; - clear_text_artifact_transaction(store_root)?; + clear_journal(store_root, &TEXT_ARTIFACT_TRANSACTION_JOURNAL)?; } return Err(error); } @@ -688,15 +698,20 @@ pub(super) fn execute_text_artifact_retention_under_store_lock( pub(super) fn recover_pending_text_artifact_transaction_unlocked( store_root: &Path, ) -> Result<(), CodeGenerationRetentionErrorV1> { - let Some(transaction) = load_text_artifact_transaction(store_root)? else { + let Some(transaction) = load_journal(store_root, &TEXT_ARTIFACT_TRANSACTION_JOURNAL)? else { return Ok(()); }; - if text_artifact_receipt_is_durable(store_root, &transaction.receipt)? { + if receipt_store::receipt_is_durable( + store_root, + &TEXT_ARTIFACT_RECEIPT_STORE, + &transaction.receipt.receipt_digest, + &transaction.receipt, + )? { cleanup_committed_text_artifact_transaction(store_root, &transaction)?; } else { rollback_staged_text_artifact_transaction(store_root, &transaction)?; } - clear_text_artifact_transaction(store_root) + clear_journal(store_root, &TEXT_ARTIFACT_TRANSACTION_JOURNAL) } pub(super) fn text_artifact_transaction_path(store_root: &Path) -> PathBuf { @@ -712,19 +727,6 @@ pub(super) fn text_artifact_transaction_stage_root( .join(&receipt.receipt_digest) } -pub(super) fn persist_text_artifact_transaction( - store_root: &Path, - transaction: &CodeTextArtifactRetentionTransactionV1, -) -> Result<(), CodeGenerationRetentionErrorV1> { - persist_journal(store_root, &TEXT_ARTIFACT_TRANSACTION_JOURNAL, transaction) -} - -pub(super) fn load_text_artifact_transaction( - store_root: &Path, -) -> Result, CodeGenerationRetentionErrorV1> { - load_journal(store_root, &TEXT_ARTIFACT_TRANSACTION_JOURNAL) -} - pub(super) fn validate_text_artifact_transaction( transaction: &CodeTextArtifactRetentionTransactionV1, ) -> Result<(), CodeGenerationRetentionErrorV1> { @@ -815,18 +817,6 @@ pub(super) fn validate_text_artifact_candidate( Ok(()) } -pub(super) fn text_artifact_receipt_is_durable( - store_root: &Path, - receipt: &CodeTextArtifactRetentionReceiptV1, -) -> Result { - receipt_store::receipt_is_durable( - store_root, - &TEXT_ARTIFACT_RECEIPT_STORE, - &receipt.receipt_digest, - receipt, - ) -} - #[cfg(test)] pub(super) fn stage_collectable_text_artifacts( store_root: &Path, @@ -1008,12 +998,6 @@ pub(super) fn ensure_text_artifact_transaction_liveness( Ok(()) } -pub(super) fn clear_text_artifact_transaction( - store_root: &Path, -) -> Result<(), CodeGenerationRetentionErrorV1> { - clear_journal(store_root, &TEXT_ARTIFACT_TRANSACTION_JOURNAL) -} - pub(super) fn build_text_artifact_receipt( plan: &CodeGenerationRetentionPlanV1, active_pointer: Option<&DurablePublicationPointerV1>, @@ -1055,18 +1039,6 @@ pub(super) fn build_text_artifact_receipt( }) } -pub(super) fn write_text_artifact_receipt( - store_root: &Path, - receipt: &CodeTextArtifactRetentionReceiptV1, -) -> Result<(), CodeGenerationRetentionErrorV1> { - receipt_store::write_receipt( - store_root, - &TEXT_ARTIFACT_RECEIPT_STORE, - &receipt.receipt_digest, - receipt, - ) -} - pub(super) fn total_text_artifact_bytes(artifacts: &[CodeTextArtifactRetentionCandidateV1]) -> u64 { artifacts.iter().fold(0_u64, |total, artifact| { total.saturating_add(artifact.size_bytes) diff --git a/crates/tracedecay-code-index-runtime/Cargo.toml b/crates/tracedecay-code-index-runtime/Cargo.toml index eb76dce256..8521c904d8 100644 --- a/crates/tracedecay-code-index-runtime/Cargo.toml +++ b/crates/tracedecay-code-index-runtime/Cargo.toml @@ -83,7 +83,6 @@ notify = "6" [dev-dependencies] filetime = "0.2" rusqlite = { version = "0.40.1", default-features = false, features = ["backup"] } -tempfile = "3" tokio = { version = "1", features = ["full", "test-util"] } tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] } tracedecay-code-index = { path = "../tracedecay-code-index", version = "0.1.0", default-features = false, features = [ diff --git a/crates/tracedecay-code-index-runtime/src/code_index_executor.rs b/crates/tracedecay-code-index-runtime/src/code_index_executor.rs index a9e79db843..a45245ff44 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_executor.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_executor.rs @@ -623,7 +623,7 @@ impl tracedecay_query::retrieval::ports::Retriev } fn elapsed_micros(&self) -> u64 { - u64::try_from(self.started.elapsed().as_micros()).unwrap_or(u64::MAX) + tracedecay_runtime_core::tracedecay::saturating_duration_micros(self.started.elapsed()) } } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs index b1f2a2b3a7..4629023a5d 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs @@ -818,7 +818,7 @@ fn bounded_result( } fn path_is_in_code_query_scope(path: &str, scope: &tracedecay_contracts::CodeQueryScope) -> bool { - tracedecay_runtime_core::path_scope::path_matches_scope(path, scope.path_prefix.as_deref()) + tracedecay_domain::path_matches_scope(path, scope.path_prefix.as_deref()) } fn relation_edge_kind_name(kind: RelationEdgeKindV1) -> &'static str { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries/graph_control.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries/graph_control.rs index 1e30be50fd..f0e9a6d5c8 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries/graph_control.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries/graph_control.rs @@ -25,7 +25,7 @@ impl RetrievalExecutionControl for CallableRetrievalExecutionControl { } fn elapsed_micros(&self) -> u64 { - u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX) + tracedecay_runtime_core::tracedecay::saturating_duration_micros(self.started_at.elapsed()) } } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs index b548f36d21..d8aa4ffeeb 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs @@ -56,6 +56,8 @@ mod query_authority; mod reconcile_failure_isolation_tests; mod scope_identity; #[cfg(test)] +mod seat_swap_tests; +#[cfg(test)] mod serving_readiness_tests; mod serving_reads; @@ -352,6 +354,15 @@ impl ServingSwapOutcomeV1 { } } +/// An unfinished text projection withholds the serving seat only when exact +/// or lexical owners are still missing. +/// +/// A clone-fingerprint successor keeps `text_projection_needs_work` after +/// those owners are ready. That is not `published_text_owner_unfinished`. +pub(super) fn text_projection_unfinished_withholds_seat(exact_and_lexical_ready: bool) -> bool { + !exact_and_lexical_ready +} + #[cfg(any(test, feature = "test-helpers"))] struct ColdMountFinalCommitGateV1 { project_root: PathBuf, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs index 41dd125fb4..e00e30d869 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs @@ -899,6 +899,9 @@ impl CodeIndexSchedulerRegistryV1 { "code-index background reconcile pass started" ); let shutting_down = Arc::clone(&worker_shutting_down); + let bind_serving_generation = Arc::clone(&worker_serving_generation); + let bind_serving_source_witness = Arc::clone(&worker_serving_source_witness); + let bind_source_freshness = worker_source_freshness.clone(); let source_result = hotpath::future!( tokio::task::spawn_blocking(move || { let mut scheduler = @@ -952,7 +955,7 @@ impl CodeIndexSchedulerRegistryV1 { }, )); } - if let Some(metadata) = retained_text_metadata { + let outcome = if let Some(metadata) = retained_text_metadata { match scheduler.reconcile_retained_text_generation_with( &metadata, !graph_activation_enabled, @@ -968,7 +971,28 @@ impl CodeIndexSchedulerRegistryV1 { scheduler.activate_or_reconcile() } else { scheduler.reconcile_now() + }?; + // A seat whose publishing pass could not prove its + // source (`code_index_post_projection_source_unverified`) + // installs without a currency witness. The swap arm + // re-proves such a seat as `Offered`, but a retained + // native graph that already serves skips the graph + // prepare and with it the swap, so no later pass ever + // reached that arm. This unchanged pass verified + // exactly the snapshot the seat was sealed from, so + // bind that proof here, while this pass still holds + // the scheduler: a reader that holds the scheduler to + // keep a seat unproven must not see the proof land + // after the pass has already let go. + if let CodeIndexReconcileOutcomeV1::Noop(evidence) = &outcome { + Self::bind_unproven_seat_to_verified_source( + &bind_serving_generation, + &bind_serving_source_witness, + &bind_source_freshness, + &evidence.snapshot_content_identity, + ); } + Ok(outcome) }), // Sealing moved inside this blocking reconcile pipeline. // Keep the outer future labeled so default reports retain @@ -1677,6 +1701,11 @@ impl CodeIndexSchedulerRegistryV1 { // A conflict verdict identical to the previous // attempt's for this same generation is deterministic // and falls through to the terminal arm instead. + // + // The prepared text candidate stays. Wiping it to + // `Ok((Err, None, None))` skipped the serving swap, + // so search kept the predecessor while graph backoff + // ran. if error.is_retryable_activation() && !repeated_conflict { last_seat_conflict = error .activation_conflict_context() @@ -1746,6 +1775,18 @@ impl CodeIndexSchedulerRegistryV1 { // and serving-swap boundary. Graph work above ran only when // the outcome was ready. if let Some(outcome) = published_text_projection_outcome.take() { + // A clone-fingerprint successor is still `Unfinished` work + // after exact and lexical owners are ready. That must not + // clear the prepared generation the way a missing owner does. + let owners_ready = exact_and_lexical_ready_for_graph(graph_text.as_ref()); + let outcome = match outcome { + PublishedTextProjectionOutcomeV1::Unfinished + if !super::text_projection_unfinished_withholds_seat(owners_ready) => + { + PublishedTextProjectionOutcomeV1::Finished + } + other => other, + }; match outcome { PublishedTextProjectionOutcomeV1::Finished => { // The seat needs only the ready exact/lexical @@ -2098,25 +2139,9 @@ impl CodeIndexSchedulerRegistryV1 { && worker_source_freshness .ready_without_stat(&worker_project_root, &worker_shutting_down) { - // A seat whose publishing pass could not prove its - // source (`code_index_post_projection_source_unverified`) - // installs without a currency witness. The swap arm - // re-proves such a seat as `Offered`, but a retained - // native graph that already serves skips the graph - // prepare and with it the swap, so no later pass ever - // reached that arm: every ready probe kept requesting a - // reconcile and the route stayed `stale / verifying` - // indefinitely. This unchanged pass verified exactly - // the snapshot the seat was sealed from, so bind that - // proof here. - if let CodeIndexReconcileOutcomeV1::Noop(evidence) = outcome { - Self::bind_unproven_seat_to_verified_source( - &worker_serving_generation, - &worker_serving_source_witness, - &worker_source_freshness, - &evidence.snapshot_content_identity, - ); - } + // The pass bound the seat's source proof under the + // scheduler lock; announce the change now that the + // proof is public. worker_serving_generation_changed.send_replace(()); // The clone-backfill continuation was stamped before // this pass dropped `reconcile_in_progress`. diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs new file mode 100644 index 0000000000..cb0ad8e762 --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs @@ -0,0 +1,13 @@ +/// Clone-fingerprint backfill is still unfinished after exact and lexical +/// owners are ready. That successor is not `published_text_owner_unfinished`. +#[test] +fn unfinished_clone_fingerprint_successor_is_not_text_projection_unfinished() { + assert!( + !super::text_projection_unfinished_withholds_seat(true), + "ready exact and lexical owners must still seat while the clone successor runs" + ); + assert!( + super::text_projection_unfinished_withholds_seat(false), + "missing exact or lexical owners still withhold the seat" + ); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs index 78c802ef5a..627a324706 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs @@ -2034,16 +2034,6 @@ impl LatestCompleteCodeIndexV1 { pub fn lexical(&self) -> &[Arc] { self.generation.chunks().chunks() } - - #[cfg(test)] - pub fn graph_edges(&self) -> &[tracedecay_domain::CanonicalRelationEdgeV1] { - self.generation.edges() - } - - #[cfg(test)] - pub fn graph_abstentions(&self) -> &[crate::code_index::chunks::CodeIndexEdgeAbstentionV1] { - self.generation.edge_abstentions() - } } impl LatestCodeTextGenerationV1 { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs index 7414952744..19980637ab 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs @@ -7267,13 +7267,7 @@ async fn compiler_diagnostics_published_under_registry_identity_are_admitted_by_ } } - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use tracedecay_domain::test_fixtures::id; let source = "pub fn alpha() -> u32 {\n let value: u32 = \"nope\";\n value\n}\n"; let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); diff --git a/crates/tracedecay-code-index-runtime/src/git_transactions/test_support.rs b/crates/tracedecay-code-index-runtime/src/git_transactions/test_support.rs index c71fb913eb..9a90f217dc 100644 --- a/crates/tracedecay-code-index-runtime/src/git_transactions/test_support.rs +++ b/crates/tracedecay-code-index-runtime/src/git_transactions/test_support.rs @@ -224,17 +224,9 @@ impl GitIndexPolicyRecheckPort for TestPolicy { } } -pub fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture identity") -} +pub use tracedecay_domain::test_fixtures::id; -pub fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("fixture digest") -} +pub use tracedecay_domain::test_fixtures::digest; pub fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("fixture object id") diff --git a/crates/tracedecay-code-index/Cargo.toml b/crates/tracedecay-code-index/Cargo.toml index db864b358f..261f638d68 100644 --- a/crates/tracedecay-code-index/Cargo.toml +++ b/crates/tracedecay-code-index/Cargo.toml @@ -89,7 +89,7 @@ tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1. tree-sitter = "0.26" [dev-dependencies] -criterion = { version = "0.5", features = ["html_reports"] } +criterion = "0.5" rusqlite = { version = "0.40.1", default-features = false, features = ["backup"] } tempfile = "3" tracedecay-code-extraction = { path = "../tracedecay-code-extraction", version = "0.1.0", default-features = false, features = ["lite", "test-helpers"] } diff --git a/crates/tracedecay-code-index/src/capabilities.rs b/crates/tracedecay-code-index/src/capabilities.rs index b30bb3f85c..9bc5598283 100644 --- a/crates/tracedecay-code-index/src/capabilities.rs +++ b/crates/tracedecay-code-index/src/capabilities.rs @@ -43,20 +43,9 @@ pub const GENERATION_SEAL_SEPARATOR: &str = "tracedecay.code-generation-seal.v1" /// The chunk schema revision pinned by this implementation. pub const CHUNK_SCHEMA_REVISION_V1: &str = "code-search-chunk.v1"; -/// The exact-term kinds the query chunker emits. -pub const BASE_EXACT_TERM_KINDS: &[ExactTechnicalTermKindV1] = &[ - ExactTechnicalTermKindV1::WholeSymbol, - ExactTechnicalTermKindV1::QualifiedName, - ExactTechnicalTermKindV1::Path, - ExactTechnicalTermKindV1::CompilerErrorCode, - ExactTechnicalTermKindV1::CompilerErrorText, - ExactTechnicalTermKindV1::RuntimeErrorCode, - ExactTechnicalTermKindV1::RuntimeErrorText, - ExactTechnicalTermKindV1::CliFlag, - ExactTechnicalTermKindV1::ToolName, - ExactTechnicalTermKindV1::ConfigurationKey, - ExactTechnicalTermKindV1::CommitIdentifier, -]; +/// The exact-term kinds the query chunker emits, in codec ordinal order. +pub const BASE_EXACT_TERM_KINDS: &[ExactTechnicalTermKindV1] = + ExactTechnicalTermKindV1::ORDER.as_slice(); /// The edge-authority classes query tree-sitter extraction declares: edges /// derived purely from syntax are `SyntaxExact`; unresolved constructs are @@ -241,18 +230,13 @@ impl BaseCapabilityEmitter { .iter() .map(|descriptor| descriptor.descriptor_revision.clone()) .collect(); - let mut available_grains = vec![ - CodeSearchChunkGrainV1::SymbolSignature, - CodeSearchChunkGrainV1::SymbolBody, - CodeSearchChunkGrainV1::FilePreamble, - CodeSearchChunkGrainV1::FileWindow, - ]; - if descriptors + let admit_members = descriptors .iter() - .any(|descriptor| descriptor.stable_member_spans) - { - available_grains.push(CodeSearchChunkGrainV1::SymbolMember); - } + .any(|descriptor| descriptor.stable_member_spans); + let mut available_grains: Vec = CodeSearchChunkGrainV1::ORDER + .into_iter() + .filter(|grain| *grain != CodeSearchChunkGrainV1::SymbolMember || admit_members) + .collect(); available_grains.sort(); available_grains.dedup(); @@ -372,10 +356,7 @@ mod tests { use crate::languages::StaticLanguageRegistry; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("valid digest") - } + use tracedecay_domain::test_fixtures::digest; fn generation_manifest() -> CodeGenerationManifestV1 { let registry = StaticLanguageRegistry::new(); diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index 15fd1f44b0..b44ab44213 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -585,13 +585,6 @@ impl DeterministicCodeChunker { } } - /// Pin the sensitivity level recorded on every chunk of this generation. - #[must_use] - pub fn with_sensitivity_level(mut self, level: SensitivityLevelV1) -> Self { - self.sensitivity_level = level; - self - } - /// The generation this chunker is bound to. pub fn generation_id(&self) -> &CodeGenerationId { &self.generation_id @@ -606,7 +599,16 @@ impl DeterministicCodeChunker { descriptor: &LanguageDescriptorV1, cancellation: &dyn ExtractionCancellation, ) -> Result { - self.build_file_artifacts(file, batch, descriptor, cancellation) + let mut clone_build = ClonePayloadBuildContextV1::new(None); + self.build_file_artifacts_with_parse( + file, + batch, + descriptor, + None, + self.sensitivity_level, + cancellation, + &mut clone_build, + ) } /// Index one receipt-bound file and return the opaque capability required @@ -1133,28 +1135,6 @@ impl CodeChunker for DeterministicCodeChunker { } impl DeterministicCodeChunker { - /// Build all parser-backed file artifacts. The legacy chunk-only port - /// delegates here so chunk, lineage, and graph evidence are always - /// derived from the same bounded parser result. - fn build_file_artifacts( - &self, - file: &ReceiptBoundCodeFileV1, - batch: &ExtractionBatchV1, - descriptor: &LanguageDescriptorV1, - cancellation: &dyn ExtractionCancellation, - ) -> Result { - let mut clone_build = ClonePayloadBuildContextV1::new(None); - self.build_file_artifacts_with_parse( - file, - batch, - descriptor, - None, - self.sensitivity_level, - cancellation, - &mut clone_build, - ) - } - #[allow(clippy::too_many_arguments)] fn build_file_artifacts_with_parse( &self, @@ -2759,17 +2739,9 @@ mod tests { } } - fn id(value: &str) -> T - where - T: TryFrom, - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use tracedecay_domain::test_fixtures::id; - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn fixture_function_row( source: &str, @@ -4278,6 +4250,68 @@ pub fn real_symbol() {} assert_eq!(identities(&reformatted), identities(&artifacts)); } + /// The graph-rebuild refresh batch is one no-call function per file. + /// File-rooted `Contains` edges abstain because the file node is not a + /// symbol row, and primitive `u32` refs have no import or glob, so the + /// sealed relation census is empty. A same-file call still binds, so an + /// empty edge list is that fixture's shape rather than a dead emitter. + #[test] + fn no_call_refresh_probe_seals_zero_relation_edges() { + let index = |source: &str| { + let file = validated_file("src/refresh_batch/file_0000.rs", source.as_bytes()); + let batch = batch_for(&file, ParseOutcomeV1::Complete); + chunker() + .index_file(&file, &batch, &rust_descriptor(), &NeverCancelled) + .expect("indexing succeeds") + }; + + let probe = "pub fn refresh_probe_0000_000(input: u32) -> u32 { input + 0 }\n"; + let artifacts = index(probe); + assert_eq!(artifacts.symbols.len(), 1, "{:?}", artifacts.symbols); + assert!( + artifacts.edges.is_empty(), + "no-call probe must not seal relation edges: {:?}", + artifacts.edges + ); + assert!( + artifacts.edge_abstentions.iter().all(|abstention| { + abstention.reason == CodeIndexEdgeAbstentionReasonV1::MissingSymbolEndpoint + && abstention.legacy_kind == EdgeKind::Contains.as_str() + && abstention.source_node_id.starts_with("file:") + }), + "file Contains must abstain, not vanish: {:?}", + artifacts.edge_abstentions + ); + assert!( + !artifacts.edge_abstentions.is_empty(), + "the file node still emits a Contains edge that the census drops" + ); + assert!( + artifacts + .unresolved_references + .iter() + .all(|reference| reference.reference_name == "u32" + && matches!( + reference.kind, + RelationEdgeKindV1::TypeOf | RelationEdgeKindV1::Returns + )), + "primitive type refs stay unresolved, not edges: {:?}", + artifacts.unresolved_references + ); + + let calling = "pub fn caller() -> u32 { refresh_probe_0000_000(1) }\n\ + pub fn refresh_probe_0000_000(input: u32) -> u32 { input + 0 }\n"; + let calling = index(calling); + assert!( + calling + .edges + .iter() + .any(|edge| edge.kind == RelationEdgeKindV1::Calls), + "a same-file call must still seal, so the empty probe is not a dead path: {:?}", + calling.edges + ); + } + /// A body larger than the extractor's traversal budget reaches this path /// as an incomplete analysis: the lineage record carries the state and /// offers no exact counters, while ordinary bodies stay exact. diff --git a/crates/tracedecay-code-index/src/clones.rs b/crates/tracedecay-code-index/src/clones.rs index 065c2eb3bb..ba989e0a53 100644 --- a/crates/tracedecay-code-index/src/clones.rs +++ b/crates/tracedecay-code-index/src/clones.rs @@ -13,7 +13,7 @@ use tracedecay_code_extraction::{ }; use tracedecay_domain::{ CodeGenerationId, ManifestDigest, ProjectId, RepositoryId, SourceSpan, SymbolOccurrenceId, - WorktreeId, canonical_json_bytes, canonical_sha256, + WorktreeId, canonical_json_bytes, canonical_sha256, nonnegative_sha256_prefix, }; const BODY_DIGEST_DOMAIN: &str = "tracedecay.clone-body.v1"; @@ -616,11 +616,7 @@ fn winnow_clone_tokens( .map(|window| { let bytes = canonical_json_bytes(&(FINGERPRINT_DOMAIN, window)) .map_err(|error| error.to_string())?; - let digest = Sha256::digest(bytes); - let prefix: [u8; 8] = digest[..8] - .try_into() - .map_err(|error: std::array::TryFromSliceError| error.to_string())?; - Ok(u64::from_be_bytes(prefix) & i64::MAX as u64) + Ok(nonnegative_sha256_prefix(Sha256::digest(bytes).as_slice())) }) .collect::, String>>()?; select_rightmost_minima(&hashes) diff --git a/crates/tracedecay-code-index/src/extract.rs b/crates/tracedecay-code-index/src/extract.rs index 3aedf93cb1..6aab34a550 100644 --- a/crates/tracedecay-code-index/src/extract.rs +++ b/crates/tracedecay-code-index/src/extract.rs @@ -234,13 +234,6 @@ impl TreeSitterExtractor { } } - /// Create the adapter over an existing extraction registry. - pub fn from_registry(parsers: tracedecay_code_extraction::LanguageRegistry) -> Self { - Self { - parsers: Arc::new(parsers), - } - } - /// Share one generation-scoped registry with downstream chunking. pub fn from_shared_registry( parsers: Arc, diff --git a/crates/tracedecay-code-index/src/generations.rs b/crates/tracedecay-code-index/src/generations.rs index cc5a2a79c5..a1bff3552e 100644 --- a/crates/tracedecay-code-index/src/generations.rs +++ b/crates/tracedecay-code-index/src/generations.rs @@ -663,10 +663,10 @@ fn parse_minted_generation_id(generation_id: &CodeGenerationId) -> Option<(Strin Some((discriminator.to_owned(), sequence.parse().ok()?)) } -/// A well-formed placeholder digest, replaced by the computed seal before the -/// manifest is returned. The seal payload excludes the seal itself, so the -/// placeholder never influences the computed digest. -fn placeholder_digest() -> ManifestDigest { +/// Canonical zero digest substituted until the caller computes the real digest. +/// Callers exclude this placeholder from that computation, so it never +/// influences the result. +pub(crate) fn placeholder_digest() -> ManifestDigest { ManifestDigest::new(format!("sha256:{}", "0".repeat(64))) .expect("a zeroed sha256 digest is canonical") } @@ -758,13 +758,7 @@ mod tests { .expect("valid digest") } - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use tracedecay_domain::test_fixtures::id; fn repository() -> RepositoryId { id("repository.fixture") diff --git a/crates/tracedecay-code-index/src/git_projection.rs b/crates/tracedecay-code-index/src/git_projection.rs index 9981c3468a..72174ac835 100644 --- a/crates/tracedecay-code-index/src/git_projection.rs +++ b/crates/tracedecay-code-index/src/git_projection.rs @@ -7,18 +7,19 @@ use std::fmt; use std::sync::Arc; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use thiserror::Error; use tracedecay_domain::{ GitCommitMetadataV1, GitCoverageV1, GitHeadStateV1, GitHistoryV1, GitOidV1, ManifestDigest, RefId, RepositoryId, canonical_sha256, }; +use tracedecay_graph_db::graph_stable_identity as stable_identity; use tracedecay_graph_db::{ GraphCancellation, GraphDbError, GraphEntity, GraphEntityId, GraphEntityRef, GraphGenerationId, GraphGenerationManifest, GraphGenerationRelation, GraphIdempotencyKey, GraphLabel, GraphNamespace, GraphProjectionId, GraphProjectionIdentity, GraphProjectorRevision, - GraphProperty, GraphPropertyName, GraphRelationId, GraphRelationKind, GraphTraversalDirection, - GraphWatermark, SourceGeneration, TraversalRequest, VerifiedGraphSnapshot, + GraphProperty, GraphPropertyName, GraphRelationId, GraphRelationKind, GraphStoreFailureClass, + GraphTraversalDirection, GraphWatermark, SourceGeneration, TraversalRequest, + VerifiedGraphSnapshot, classify_graph_store_error, }; use declared_topology::validate_declared_topology; @@ -151,27 +152,12 @@ pub enum GitTopologyProjectionError { impl From for GitTopologyProjectionError { fn from(error: GraphDbError) -> Self { - match error { - GraphDbError::Cancelled => Self::Cancelled, - GraphDbError::BudgetExhausted { .. } | GraphDbError::DeadlineExceeded => { - Self::BudgetExhausted - } - GraphDbError::InvalidRequest { message } => Self::Contract(message), - GraphDbError::Corrupt { message } - | GraphDbError::ResetRequired { message } - | GraphDbError::DurabilityUncertain { message } - | GraphDbError::ProjectionMismatch { message, .. } - | GraphDbError::GenerationMismatch { message, .. } => Self::Corrupt(message), - GraphDbError::Conflict { .. } => { - Self::Unavailable("Git topology publication conflict".to_owned()) - } - GraphDbError::Unavailable { message } - | GraphDbError::SealedStoreImmutable { message } => Self::Unavailable(message), - error @ (GraphDbError::SourceCommitmentsUnavailable { .. } - | GraphDbError::SealedRevisionIncompatible { .. }) => { - Self::Unavailable(error.to_string()) - } - GraphDbError::Closed => Self::Unavailable("graph store is closed".to_owned()), + match classify_graph_store_error(error, "Git topology publication conflict") { + GraphStoreFailureClass::Cancelled => Self::Cancelled, + GraphStoreFailureClass::BudgetExhausted => Self::BudgetExhausted, + GraphStoreFailureClass::Contract(message) => Self::Contract(message), + GraphStoreFailureClass::Corrupt(message) => Self::Corrupt(message), + GraphStoreFailureClass::Unavailable(message) => Self::Unavailable(message), } } } @@ -751,14 +737,6 @@ fn metadata_entity_id() -> Result { GraphEntityId::new(stable_identity("metadata", GIT_PROJECTION)).map_err(Into::into) } -fn stable_identity(kind: &str, value: &str) -> String { - let mut digest = Sha256::new(); - digest.update(kind.as_bytes()); - digest.update([0]); - digest.update(value.as_bytes()); - format!("{kind}:{}", hex::encode(digest.finalize())) -} - fn serialize(value: &impl Serialize) -> Result, GitTopologyProjectionError> { serde_json::to_vec(value) .map_err(|error| GitTopologyProjectionError::Contract(error.to_string())) diff --git a/crates/tracedecay-code-index/src/graph_projection.rs b/crates/tracedecay-code-index/src/graph_projection.rs index 223b7386c1..c639740882 100644 --- a/crates/tracedecay-code-index/src/graph_projection.rs +++ b/crates/tracedecay-code-index/src/graph_projection.rs @@ -478,25 +478,12 @@ impl InMemoryCodeGraphProjectionBuilder { freshness: SourceFreshness, cancellation: &CancellationSignal, ) -> Result { - let snapshot = self - .snapshot - .read() - .map_err(|_| { - CodeGraphProjectionError::Unavailable( - "code graph verified snapshot lock is poisoned".to_owned(), - ) - })? - .clone() - .ok_or_else(|| { - CodeGraphProjectionError::Unavailable( - "code graph generation is not published".to_owned(), - ) - })?; - CodeGraphProjectionStore::from_verified_snapshot( - snapshot.as_ref().clone(), - generation.clone(), - )? - .evidence_reader(generation, repository_id, freshness, cancellation) + self.verified_store(generation)?.evidence_reader( + generation, + repository_id, + freshness, + cancellation, + ) } } diff --git a/crates/tracedecay-code-index/src/graph_projection/interactive/tests.rs b/crates/tracedecay-code-index/src/graph_projection/interactive/tests.rs index 1065660fbe..dc01163abe 100644 --- a/crates/tracedecay-code-index/src/graph_projection/interactive/tests.rs +++ b/crates/tracedecay-code-index/src/graph_projection/interactive/tests.rs @@ -47,13 +47,7 @@ impl GraphCancellation for CancelAfter { } } -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn digest(byte: char) -> T where diff --git a/crates/tracedecay-code-index/src/graph_projection/schema.rs b/crates/tracedecay-code-index/src/graph_projection/schema.rs index a051f23adc..7cb4a1f74c 100644 --- a/crates/tracedecay-code-index/src/graph_projection/schema.rs +++ b/crates/tracedecay-code-index/src/graph_projection/schema.rs @@ -1,8 +1,8 @@ //! Durable labels, properties, and identities for the code-graph projection. use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use tracedecay_domain::FileOccurrenceId; +pub(super) use tracedecay_graph_db::graph_stable_identity as stable_identity; use tracedecay_graph_db::{ GraphEntity, GraphEntityId, GraphProperty, GraphPropertyName, GraphRelationId, }; @@ -18,14 +18,6 @@ pub(super) const FILE_LABEL: &str = "CodeFile"; pub(super) const IMPORT_LABEL: &str = "CodeImport"; pub(super) const FILE_IMPORT_EDGE_KIND: &str = "CodeFileContainsImport"; -pub(super) fn stable_identity(kind: &str, value: &str) -> String { - let mut digest = Sha256::new(); - digest.update(kind.as_bytes()); - digest.update([0]); - digest.update(value.as_bytes()); - format!("{kind}:{}", hex::encode(digest.finalize())) -} - pub(super) fn file_entity_id( file: &FileOccurrenceId, ) -> Result { diff --git a/crates/tracedecay-code-index/src/incremental.rs b/crates/tracedecay-code-index/src/incremental.rs index a153e075b8..75d6bf500d 100644 --- a/crates/tracedecay-code-index/src/incremental.rs +++ b/crates/tracedecay-code-index/src/incremental.rs @@ -19,7 +19,7 @@ use tracedecay_domain::{ }; use super::chunks::{ChunkingFailureV1, CodeFileChunksV1, symbol_occurrence_id}; -use super::generations::{FileExtractionActionV1, GenerationIncrementPlanV1}; +use super::generations::{FileExtractionActionV1, GenerationIncrementPlanV1, placeholder_digest}; use super::lineage::{ GenerationSymbolIndexV1, LineageResolutionErrorV1, LineageSymbolRecordV1, SymbolLineageCandidateV1, SymbolLineageResolver, @@ -659,11 +659,6 @@ fn map_lineage_error(error: LineageResolutionErrorV1) -> ChunkIncrementErrorV1 { ) } -fn placeholder_digest() -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", "0".repeat(64))) - .expect("a zeroed sha256 digest is canonical") -} - #[cfg(test)] mod tests { use std::collections::BTreeSet; diff --git a/crates/tracedecay-code-index/src/parallelism.rs b/crates/tracedecay-code-index/src/parallelism.rs index 507a445125..d0c8e7191a 100644 --- a/crates/tracedecay-code-index/src/parallelism.rs +++ b/crates/tracedecay-code-index/src/parallelism.rs @@ -363,39 +363,6 @@ fn environment_override_value() -> Result, CodeIndexWorkerPlanErr } } -/// Preview the worker status without constructing a pool or installing any -/// process authority. `available_memory_bytes` must come from the caller's -/// canonical resident-memory authority (`limit - used`), never a second -/// estimator. Environment precedence and every typed refusal are identical to -/// [`install_worker_plan`]. -pub fn preview_worker_plan( - configured: CodeIndexWorkerSelectionV1, - available_memory_bytes: u64, -) -> Result { - let environment_override = environment_override_value()?; - preview_worker_plan_from( - configured, - detected_cores(), - available_memory_bytes, - environment_override.as_deref(), - ) -} - -fn preview_worker_plan_from( - configured: CodeIndexWorkerSelectionV1, - available_logical_cpus: usize, - available_memory_bytes: u64, - environment_override: Option<&str>, -) -> Result { - worker_plan_from( - configured, - available_logical_cpus, - available_memory_bytes, - environment_override, - ) - .map(CodeIndexWorkerPlanV1::status) -} - fn compare_installed_plan( existing: &CodeIndexWorkerPlanV1, requested: &CodeIndexWorkerPlanV1, diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source.rs b/crates/tracedecay-code-index/src/production/lexical_page_source.rs index fbc77afea7..e587d326a2 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source.rs @@ -461,10 +461,6 @@ impl VerifiedSealedLexicalPageV1 { &self.symbol_displays } - pub fn symbol_display_capacity(&self) -> usize { - self.symbol_displays.capacity() - } - pub fn imports(&self) -> &[CodeIndexImportEvidenceV1] { &self.imports } diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index 2660154924..7e3bf3360a 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -318,14 +318,6 @@ impl CodeIndexGenerationScopeV1 { } } - pub fn for_branch_stack_node(node: &tracedecay_domain::BranchStackNodeV1) -> Self { - Self { - repository: node.repository_id.clone(), - reference: Some(node.reference.clone()), - worktree: node.worktree_id.clone(), - } - } - /// Whether two scopes name the same physical checkout. /// /// Repository and worktree are checkout identity: a generation sealed @@ -1947,7 +1939,7 @@ where let started = crate::hotpath_observe::start_build_to_queryable(); crate::hotpath_observe::record_generation_state("building"); crate::hotpath_observe::record_rebuild_state("unknown"); - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let ignored_source_roster = IgnoredSourceRosterV1::admit( &request.snapshot, &request.repository_parse_identity, @@ -1956,7 +1948,7 @@ where let scope = CodeIndexGenerationScopeV1::for_snapshot(&request.snapshot); let lookup = self.lookup_active_generation(&scope)?; let active = lookup.reusable; - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let intake = self.intake_at(request.sealed_at, registry_for_snapshot(&request.snapshot)?); let capability = intake @@ -1974,7 +1966,7 @@ where .fold(0_u64, u64::saturating_add), ); } - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let planner = GenerationPlanner::new( self.config.project_id.clone(), @@ -2027,7 +2019,7 @@ where None, ), }; - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let parser_registry = Arc::new(tracedecay_code_extraction::LanguageRegistry::new()); let extractor = TreeSitterExtractor::from_shared_registry(Arc::clone(&parser_registry)); @@ -2079,7 +2071,7 @@ where )); } }; - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let candidate = hotpath::measure_block!("code_index.build.assemble", { let coverage = coverage_summary(&validated.snapshot, &staged.files); let changes = match (active.as_ref(), staged.parent_shared_occurrences.as_ref()) { @@ -2157,10 +2149,10 @@ where changes, &staged.chunks, )?; - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let projection = project_for_publication(&mut self.projection, projection_request) .map_err(CodeIndexProductionErrorV1::Projection)?; - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let imports = hotpath::measure_block!( "code_index.build.assemble.import_evidence", derive_import_evidence(&staged.files) @@ -2242,22 +2234,6 @@ where } } - fn checkpoint( - control: &dyn CodeIndexExecutionControlV1, - ) -> Result<(), CodeIndexProductionErrorV1> { - if control.is_cancelled() { - Err(CodeIndexProductionErrorV1::Interrupted( - CodeIndexInterruptionV1::Cancelled, - )) - } else if control.is_deadline_exceeded() { - Err(CodeIndexProductionErrorV1::Interrupted( - CodeIndexInterruptionV1::DeadlineExceeded, - )) - } else { - Ok(()) - } - } - fn interruption_error(control: &dyn CodeIndexExecutionControlV1) -> CodeIndexProductionErrorV1 { if control.is_deadline_exceeded() { CodeIndexProductionErrorV1::Interrupted(CodeIndexInterruptionV1::DeadlineExceeded) @@ -2295,7 +2271,7 @@ where CodeIndexProductionErrorV1, > { crate::hotpath_observe::measure_hot_loop!("code_index.materialize.file", { - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let captured = captured_files .get(&file.file_occurrence_id) .ok_or(CodeIndexInputErrorV1::MissingCapturedFile)?; @@ -2334,7 +2310,7 @@ where u64::try_from(reused.artifacts.clone_bodies.len()).unwrap_or(u64::MAX), 0, ); - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let clone_stats = ClonePayloadBuildStatsV1 { reused: u64::try_from(reused.artifacts.clone_bodies.len()).unwrap_or(u64::MAX), computed: 0, @@ -2384,7 +2360,7 @@ where control, ) { Ok((parse_artifacts, parsed_len)) => { - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; extractor .extract_preparsed( &receipt_bound, @@ -2408,12 +2384,12 @@ where .. }), ) => { - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; return Err(error); } Err(error) => return Err(error), }; - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let (artifacts, exact_authority, clone_stats) = chunker .index_file_with_authority_from_extraction_reusing( &receipt_bound, @@ -2428,7 +2404,7 @@ where error => CodeIndexProductionErrorV1::Chunk(error), })?; physical_artifacts.record_clone_payloads(clone_stats.reused, clone_stats.computed); - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let (authority, extraction, _) = extraction.into_parts(); let artifact = Arc::new(FileGenerationArtifactsV1 { authority, @@ -2518,11 +2494,11 @@ where // subsequent physical reuse remain deterministic. let mut files = Vec::with_capacity(extracted.len()); for (reuse_key, artifact, _) in extracted { - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; physical_artifacts.insert(reuse_key, &artifact); files.push(artifact); } - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; staged_generation(manifest.generation_id.clone(), files, Vec::new(), None) } @@ -2630,7 +2606,7 @@ where worker| -> Result<(usize, IncrementFileMaterializationV1), CodeIndexProductionErrorV1> { let (index, file_plan) = *item; - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let materialization = match &file_plan.action { FileExtractionActionV1::CarryForward { file_occurrence_id, @@ -2816,7 +2792,7 @@ where let mut clone_stale_invalidations = 0_u64; for materialization in file_materializations { - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; match materialization { IncrementFileMaterializationV1::CarryForward { artifact, @@ -2851,7 +2827,7 @@ where } } } - Self::checkpoint(control)?; + lexical_page_source::checkpoint(control)?; let mut staged = staged_generation( manifest.generation_id.clone(), diff --git a/crates/tracedecay-code-index/src/receipts.rs b/crates/tracedecay-code-index/src/receipts.rs index a9047e881e..5c99953a22 100644 --- a/crates/tracedecay-code-index/src/receipts.rs +++ b/crates/tracedecay-code-index/src/receipts.rs @@ -30,6 +30,8 @@ use tracedecay_domain::{ ProjectionOperationV1, ProjectionOutcomeV1, ProjectionReplayReasonV1, canonical_sha256, }; +use super::generations::placeholder_digest; + /// Domain separator for the canonical projection-batch-request digest. pub const PROJECTION_REQUEST_SEPARATOR: &str = "tracedecay.projection-batch-request.v1"; @@ -558,13 +560,6 @@ fn check_decision( Ok(()) } -/// A well-formed placeholder digest, replaced by the computed publication -/// digest before the batch is returned. -fn placeholder_digest() -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", "0".repeat(64))) - .expect("a zeroed sha256 digest is canonical") -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tracedecay-code-index/src/retained_parse.rs b/crates/tracedecay-code-index/src/retained_parse.rs index 5398dc2282..a69b87ec68 100644 --- a/crates/tracedecay-code-index/src/retained_parse.rs +++ b/crates/tracedecay-code-index/src/retained_parse.rs @@ -640,13 +640,7 @@ mod tests { }; use tracedecay_domain::RepositoryDirtyStateV1; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid test identity") - } + use tracedecay_domain::test_fixtures::id; fn identity() -> ParseDocumentIdentity { ParseDocumentIdentity::Repository { diff --git a/crates/tracedecay-code-index/src/source_walk.rs b/crates/tracedecay-code-index/src/source_walk.rs index 0427aca2dd..ea8499ae64 100644 --- a/crates/tracedecay-code-index/src/source_walk.rs +++ b/crates/tracedecay-code-index/src/source_walk.rs @@ -142,7 +142,7 @@ pub fn source_walk(project_root: &Path, path_glob: Option<&str>) -> Result Arc { - relative.to_string_lossy().replace('\\', "/").into() + tracedecay_domain::forward_slash_path(relative).into() } fn build_overrides( diff --git a/crates/tracedecay-code-index/src/unmounted_files.rs b/crates/tracedecay-code-index/src/unmounted_files.rs index dbd9431398..ebd362be2e 100644 --- a/crates/tracedecay-code-index/src/unmounted_files.rs +++ b/crates/tracedecay-code-index/src/unmounted_files.rs @@ -211,10 +211,7 @@ impl ProjectFiles { /// Project-relative, forward-slashed rendering of `path`. pub(super) fn relative_display(root: &Path, path: &Path) -> String { - path.strip_prefix(root) - .unwrap_or(path) - .to_string_lossy() - .replace('\\', "/") + tracedecay_domain::forward_slash_path(path.strip_prefix(root).unwrap_or(path)) } /// Lexical `.`/`..` normalization. diff --git a/crates/tracedecay-code-index/tests/code_index_suite/chunk_incremental.rs b/crates/tracedecay-code-index/tests/code_index_suite/chunk_incremental.rs index 68ea89d660..dfa7b4ea44 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/chunk_incremental.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/chunk_incremental.rs @@ -1,4 +1,3 @@ -use std::fmt::Debug; use std::sync::Arc; use tracedecay_code_index::chunks::{CodeFileChunksV1, content_digest}; @@ -20,13 +19,7 @@ use tracedecay_domain::{ SymbolIdentityDigest, SymbolOccurrenceId, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn generation(sequence: u64) -> CodeGenerationId { id(&format!("generation.v1.aaaaaaaa.{sequence:08}")) diff --git a/crates/tracedecay-code-index/tests/code_index_suite/generations.rs b/crates/tracedecay-code-index/tests/code_index_suite/generations.rs index c218f12e7b..fe803b8056 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/generations.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/generations.rs @@ -1,5 +1,4 @@ use std::collections::BTreeSet; -use std::fmt::Debug; use tracedecay_code_index::capabilities::expected_seal_digest; use tracedecay_code_index::generations::{ @@ -15,13 +14,7 @@ use tracedecay_domain::{ UtcMicros, canonical_sha256, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn content_digest(byte: char) -> ContentDigest { id(&format!("sha256:{}", byte.to_string().repeat(64))) diff --git a/crates/tracedecay-code-index/tests/code_index_suite/git_topology_edge_cases.rs b/crates/tracedecay-code-index/tests/code_index_suite/git_topology_edge_cases.rs index 5b7f8deecb..693c8d9c0a 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/git_topology_edge_cases.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/git_topology_edge_cases.rs @@ -15,7 +15,7 @@ use tracedecay_code_index::git_projection::{ }; use tracedecay_domain::{ GitCommitIdentityV1, GitCommitMetadataV1, GitCoverageV1, GitHeadStateV1, GitHistoryV1, - GitOidV1, ManifestDigest, RefId, RepositoryId, UtcMicros, + GitOidV1, RefId, RepositoryId, UtcMicros, }; use tracedecay_graph_db::{ GraphCancellation, GraphGenerationManifest, GraphProjectionIdentity, GraphProjectorRevision, @@ -61,9 +61,7 @@ fn oid(label: char) -> GitOidV1 { GitOidV1::new(label.to_string().repeat(40)).expect("oid") } -fn digest(label: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", label.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn commit(label: char, parents: &[char]) -> GitCommitMetadataV1 { let identity = GitCommitIdentityV1 { diff --git a/crates/tracedecay-code-index/tests/code_index_suite/git_topology_projection.rs b/crates/tracedecay-code-index/tests/code_index_suite/git_topology_projection.rs index c1e85a19af..6ff40f0710 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/git_topology_projection.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/git_topology_projection.rs @@ -7,7 +7,7 @@ use tracedecay_code_index::git_projection::{ }; use tracedecay_domain::{ GitCommitIdentityV1, GitCommitMetadataV1, GitCoverageV1, GitHeadStateV1, GitHistoryV1, - GitOidV1, ManifestDigest, RefId, RepositoryId, UtcMicros, + GitOidV1, RefId, RepositoryId, UtcMicros, }; use tracedecay_graph_db::{ GraphNamespace, GraphProjectorRevision, NeverCancelled, VerifiedGraphSnapshot, @@ -17,9 +17,7 @@ fn oid(label: char) -> GitOidV1 { GitOidV1::new(label.to_string().repeat(40)).expect("oid") } -fn digest(label: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", label.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn commit(label: char, parents: &[char]) -> GitCommitMetadataV1 { let identity = GitCommitIdentityV1 { diff --git a/crates/tracedecay-code-index/tests/code_index_suite/lineage.rs b/crates/tracedecay-code-index/tests/code_index_suite/lineage.rs index 91bb2ee5a7..1f0de1911e 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/lineage.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/lineage.rs @@ -10,13 +10,7 @@ use tracedecay_domain::{ SymbolIdentityDigest, SymbolOccurrenceId, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn digest(byte: char) -> T where diff --git a/crates/tracedecay-code-index/tests/code_index_suite/projection_receipts.rs b/crates/tracedecay-code-index/tests/code_index_suite/projection_receipts.rs index db49ec491a..a60729fd61 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/projection_receipts.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/projection_receipts.rs @@ -12,13 +12,7 @@ use tracedecay_domain::{ ProjectionKindV1, ProjectionOperationV1, ProjectionOutcomeV1, ProjectionReplayReasonV1, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn digest(byte: char) -> T where diff --git a/crates/tracedecay-code-index/tests/code_index_suite/support.rs b/crates/tracedecay-code-index/tests/code_index_suite/support.rs index e268f5e114..8bb913b670 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/support.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/support.rs @@ -1,27 +1,17 @@ -use std::fmt::Debug; - use tracedecay_code_index::chunks::content_digest; use tracedecay_code_index::intake::{CodeIndexIntake, ReceiptBoundCodeFileV1, SanitizedCodeIntake}; use tracedecay_code_index::languages::{LanguageRegistry, StaticLanguageRegistry}; use tracedecay_domain::{ - CodeGenerationId, FileOccurrenceId, LanguageDescriptorV1, LanguageId, ManifestDigest, - ProjectId, RepositoryId, SanitizationReceiptId, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, - SanitizerRevision, SnapshotFileDispositionV1, UtcMicros, ValidatedCodeFileV1, + CodeGenerationId, FileOccurrenceId, LanguageDescriptorV1, LanguageId, ProjectId, RepositoryId, + SanitizationReceiptId, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, SanitizerRevision, + SnapshotFileDispositionV1, UtcMicros, ValidatedCodeFileV1, }; pub const RUST_SOURCE: &str = "//! Module documentation.\n\nuse std::collections::HashMap;\n\n/// Increment a value.\npub fn alpha(value: u32) -> u32 {\n value + 1\n}\n\npub struct Holder {\n map: HashMap,\n}\n\nimpl Holder {\n pub fn get(&self, key: u32) -> Option {\n self.map.get(&key).copied()\n }\n}\n\n// trailing window text\n"; -pub fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +pub use tracedecay_domain::test_fixtures::id; -pub fn digest(byte: char) -> ManifestDigest { - id(&format!("sha256:{}", byte.to_string().repeat(64))) -} +pub use tracedecay_domain::test_fixtures::digest; pub fn registry() -> StaticLanguageRegistry { StaticLanguageRegistry::new() diff --git a/crates/tracedecay-configuration/src/config/analyzer.rs b/crates/tracedecay-configuration/src/config/analyzer.rs index f96df691a6..de8c5cf885 100644 --- a/crates/tracedecay-configuration/src/config/analyzer.rs +++ b/crates/tracedecay-configuration/src/config/analyzer.rs @@ -79,13 +79,7 @@ mod tests { use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("fixture id is canonical") - } + use tracedecay_domain::test_fixtures::id; fn analyzer_key() -> SettingKey { SettingKey::new(ANALYZER_SETTINGS_SETTING_KEY).unwrap() diff --git a/crates/tracedecay-configuration/src/config/mod.rs b/crates/tracedecay-configuration/src/config/mod.rs index 1d8f93b5fd..f93675f95a 100644 --- a/crates/tracedecay-configuration/src/config/mod.rs +++ b/crates/tracedecay-configuration/src/config/mod.rs @@ -320,22 +320,6 @@ pub fn required_string_list( } } -/// An optional text setting (canonical JSON policy trees are stored as text). -/// Absence is `None`; presence with another type is an error. -pub fn optional_text_setting<'a>( - snapshot: &'a ConfigurationSnapshotV1, - key_name: &str, -) -> Result> { - match snapshot.effective_values.get(&setting_key(key_name)?) { - None => Ok(None), - Some(ConfigurationValueV1::Text(value)) => Ok(Some(value)), - Some(value) => Err(config_error(format!( - "resolved configuration setting '{key_name}' has wrong type: expected text, got {:?}", - value.kind() - ))), - } -} - fn config_error(message: impl Into) -> TraceDecayError { TraceDecayError::Config { message: message.into(), diff --git a/crates/tracedecay-configuration/src/config/model.rs b/crates/tracedecay-configuration/src/config/model.rs index 80589af683..d9b10a40ea 100644 --- a/crates/tracedecay-configuration/src/config/model.rs +++ b/crates/tracedecay-configuration/src/config/model.rs @@ -55,6 +55,18 @@ fn has_minified_suffix(path: &str) -> bool { path.rfind(".min.").is_some_and(|idx| idx + 5 < path.len()) } +fn default_true() -> bool { + true +} + +fn default_false() -> bool { + false +} + +fn default_thirty_day_retention() -> Option { + Some(30) +} + /// Default glob-pattern exclude list for [`TraceDecayConfig::default`]. /// /// Built from [`GENERATED_DIR_SEGMENTS`] (both the `segment/**` root form @@ -117,7 +129,7 @@ pub struct TraceDecayConfig { /// Whether to track call-site locations for edges. pub track_call_sites: bool, /// Whether to respect `.gitignore` rules when scanning files. - #[serde(default = "default_git_ignore")] + #[serde(default = "default_true")] pub git_ignore: bool, /// Whether a cold `tracedecay_diagnostics` call prewarms in the background /// (detached dependency build + immediate `warming` status) instead of @@ -128,7 +140,7 @@ pub struct TraceDecayConfig { /// Whether the persistent native code graph may activate for this project. /// Disabling it leaves exact and lexical retrieval available and reports /// graph capability as unavailable. - #[serde(default = "default_native_graph_activation")] + #[serde(default = "default_true")] pub native_graph_activation: bool, /// Index-freshness auto-sync settings (git-metadata watcher, serve-stale, /// branch lifecycle). Absent in older `config.json` files, so defaulted. @@ -140,20 +152,6 @@ pub struct TraceDecayConfig { pub telemetry: TelemetryConfig, } -fn default_git_ignore() -> bool { - true -} - -fn default_native_graph_activation() -> bool { - true -} - -fn default_sync_auto_watch() -> bool { - false -} -fn default_sync_watch_linked_worktrees() -> bool { - false -} fn default_sync_watch_debounce_ms() -> u64 { 2000 } @@ -163,15 +161,9 @@ fn default_sync_watch_max_delay_ms() -> u64 { fn default_sync_watch_max_projects() -> usize { 32 } -fn default_sync_read_refresh() -> bool { - true -} fn default_sync_read_cooldown_secs() -> u64 { 30 } -fn default_sync_session_start_sync() -> bool { - true -} fn default_sync_session_start_stale_threshold_secs() -> u64 { 600 } @@ -190,12 +182,6 @@ fn default_sync_branch_gc_days() -> u64 { fn default_sync_orphan_db_gc_days() -> u64 { 7 } -fn default_sync_auto_init() -> bool { - true -} -fn default_sync_auto_track_pr_branches() -> bool { - false -} fn default_sync_auto_track_pr_poll_secs() -> u64 { 300 } @@ -203,14 +189,6 @@ fn default_retention_interval_hours() -> u64 { 24 } -fn default_orphan_store_gc_days() -> Option { - Some(30) -} - -fn default_incident_debris_retention_days() -> Option { - Some(30) -} - fn default_compaction_threshold() -> Option { Some(CompactionThresholdConfig::default()) } @@ -230,11 +208,11 @@ pub struct RetentionConfig { pub observation: tracedecay_global_db::observation::retention::ObservationRetentionConfig, /// Orphan profile-sharded store collection window (days). `None` disables /// the sweep; the Doctor surface still reports findings read-only. - #[serde(default = "default_orphan_store_gc_days")] + #[serde(default = "default_thirty_day_retention")] pub orphan_store_gc_days: Option, /// Retention window for quarantined recovery/corruption artifacts (days). /// `None` disables collection while Doctor continues surfacing debris. - #[serde(default = "default_incident_debris_retention_days")] + #[serde(default = "default_thirty_day_retention")] pub incident_debris_retention_days: Option, /// Incremental-vacuum compaction trigger. `None` disables compaction. #[serde(default = "default_compaction_threshold")] @@ -254,8 +232,8 @@ impl Default for RetentionConfig { session_lcm: tracedecay_lcm::LcmRetentionConfig::default(), observation: tracedecay_global_db::observation::retention::ObservationRetentionConfig::default(), - orphan_store_gc_days: default_orphan_store_gc_days(), - incident_debris_retention_days: default_incident_debris_retention_days(), + orphan_store_gc_days: default_thirty_day_retention(), + incident_debris_retention_days: default_thirty_day_retention(), compaction: default_compaction_threshold(), store_soft_budgets_bytes: BTreeMap::new(), interval_hours: default_retention_interval_hours(), @@ -325,20 +303,16 @@ impl RetentionConfig { /// clamped up to this. pub const MIN_AUTO_TRACK_PR_POLL_SECS: u64 = 60; -fn default_telemetry_timings() -> bool { - true -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TelemetryConfig { - #[serde(default = "default_telemetry_timings")] + #[serde(default = "default_true")] pub timings: bool, } impl Default for TelemetryConfig { fn default() -> Self { Self { - timings: default_telemetry_timings(), + timings: default_true(), } } } @@ -360,11 +334,11 @@ impl Default for TelemetryConfig { )] pub struct SyncConfig { /// Enable the daemon git-metadata watcher. - #[serde(default = "default_sync_auto_watch")] + #[serde(default = "default_false")] pub auto_watch: bool, /// Admit linked worktrees into the daemon watcher without an explicit /// branch-indexing request. - #[serde(default = "default_sync_watch_linked_worktrees")] + #[serde(default = "default_false")] pub watch_linked_worktrees: bool, /// Per-project quiet-period debounce before a watcher-triggered sync (ms). #[serde(default = "default_sync_watch_debounce_ms")] @@ -376,13 +350,13 @@ pub struct SyncConfig { #[serde(default = "default_sync_watch_max_projects")] pub watch_max_projects: usize, /// Enable non-blocking sync-on-read for query tools. - #[serde(default = "default_sync_read_refresh")] + #[serde(default = "default_true")] pub read_refresh: bool, /// Cooldown between read-triggered background refreshes (seconds). #[serde(default = "default_sync_read_cooldown_secs")] pub read_cooldown_secs: u64, /// Fire a catch-up sync on session start. - #[serde(default = "default_sync_session_start_sync")] + #[serde(default = "default_true")] pub session_start_sync: bool, /// Staleness threshold above which session-start sync runs (seconds). #[serde(default = "default_sync_session_start_stale_threshold_secs")] @@ -403,13 +377,13 @@ pub struct SyncConfig { #[serde(default = "default_sync_orphan_db_gc_days")] pub orphan_db_gc_days: u64, /// Auto-initialise never-indexed repos on first contact. - #[serde(default = "default_sync_auto_init")] + #[serde(default = "default_true")] pub auto_init: bool, /// Enable the daemon PR-branch auto-tracking mode: when on, the daemon polls /// the repo's GitHub remote for open PRs and tracks/untracks each PR head /// branch through the normal branch-tracking machinery. Off by default for /// back-compat. - #[serde(default = "default_sync_auto_track_pr_branches")] + #[serde(default = "default_false")] pub auto_track_pr_branches: bool, /// Poll cadence (seconds) for PR-branch auto-tracking discovery. Clamped up /// to [`MIN_AUTO_TRACK_PR_POLL_SECS`] at read time. @@ -432,22 +406,22 @@ impl SyncConfig { impl Default for SyncConfig { fn default() -> Self { Self { - auto_watch: default_sync_auto_watch(), - watch_linked_worktrees: default_sync_watch_linked_worktrees(), + auto_watch: default_false(), + watch_linked_worktrees: default_false(), watch_debounce_ms: default_sync_watch_debounce_ms(), watch_max_delay_ms: default_sync_watch_max_delay_ms(), watch_max_projects: default_sync_watch_max_projects(), - read_refresh: default_sync_read_refresh(), + read_refresh: default_true(), read_cooldown_secs: default_sync_read_cooldown_secs(), - session_start_sync: default_sync_session_start_sync(), + session_start_sync: default_true(), session_start_stale_threshold_secs: default_sync_session_start_stale_threshold_secs(), backstop_interval_mins: default_sync_backstop_interval_mins(), full_sync_escalation_files: default_sync_full_sync_escalation_files(), max_concurrent_syncs: default_sync_max_concurrent_syncs(), branch_gc_days: default_sync_branch_gc_days(), orphan_db_gc_days: default_sync_orphan_db_gc_days(), - auto_init: default_sync_auto_init(), - auto_track_pr_branches: default_sync_auto_track_pr_branches(), + auto_init: default_true(), + auto_track_pr_branches: default_false(), auto_track_pr_poll_secs: default_sync_auto_track_pr_poll_secs(), retention: RetentionConfig::default(), } @@ -551,9 +525,9 @@ impl Default for TraceDecayConfig { max_file_size: 1_048_576, extract_docstrings: true, track_call_sites: true, - git_ignore: default_git_ignore(), + git_ignore: default_true(), diagnostics_prewarm: false, - native_graph_activation: default_native_graph_activation(), + native_graph_activation: default_true(), sync: SyncConfig::default(), telemetry: TelemetryConfig::default(), } @@ -825,25 +799,19 @@ pub(crate) fn is_ignored_by_explicit_global_excludes( })) } -#[cfg(test)] fn git_subprocess_path() -> OsString { std::env::var_os("PATH").unwrap_or_else(|| { - #[cfg(windows)] + #[cfg(all(test, not(windows)))] { - OsString::new() + OsString::from("/usr/bin:/bin") } - #[cfg(not(windows))] + #[cfg(not(all(test, not(windows))))] { - OsString::from("/usr/bin:/bin") + OsString::new() } }) } -#[cfg(not(test))] -fn git_subprocess_path() -> OsString { - std::env::var_os("PATH").unwrap_or_default() -} - fn is_in_local_gitignore(project_path: &Path) -> bool { let dir_name = active_data_dir_name(project_path); let gitignore = project_path.join(".gitignore"); diff --git a/crates/tracedecay-configuration/src/config/scope_control.rs b/crates/tracedecay-configuration/src/config/scope_control.rs index 0e618283f8..cbea9f5902 100644 --- a/crates/tracedecay-configuration/src/config/scope_control.rs +++ b/crates/tracedecay-configuration/src/config/scope_control.rs @@ -157,17 +157,9 @@ mod tests { }; use tracedecay_domain::{LocatorDigest, ProjectId}; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; #[test] fn protected_dry_run_is_redacted_and_actor_bound() { diff --git a/crates/tracedecay-configuration/src/config/topology.rs b/crates/tracedecay-configuration/src/config/topology.rs index 8196a7ae28..94099eff90 100644 --- a/crates/tracedecay-configuration/src/config/topology.rs +++ b/crates/tracedecay-configuration/src/config/topology.rs @@ -10,7 +10,7 @@ use thiserror::Error; use tracedecay_domain::DomainError; use tracedecay_domain::configuration::{ ConfigurationSnapshotV1, ConfigurationValueV1, SettingKey, WORK_TOPOLOGY_POLICY_SETTING_KEY, - WorkTopologyPolicyV1, safe_work_topology_policy_v1, + WorkTopologyPolicyV1, }; #[derive(Debug, Error)] @@ -40,12 +40,6 @@ pub fn resolved_work_topology_policy( } } -/// Exposes the exact safe policy used by the typed registry before any -/// operator publishes a protected replacement. -pub fn safe_default_work_topology_policy() -> WorkTopologyPolicyV1 { - safe_work_topology_policy_v1() -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -63,13 +57,7 @@ mod tests { use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("fixture id is canonical") - } + use tracedecay_domain::test_fixtures::id; fn topology_key() -> SettingKey { SettingKey::new(WORK_TOPOLOGY_POLICY_SETTING_KEY).unwrap() diff --git a/crates/tracedecay-configuration/src/configuration/authorization.rs b/crates/tracedecay-configuration/src/configuration/authorization.rs index f59f7edd35..cb5d69ca0f 100644 --- a/crates/tracedecay-configuration/src/configuration/authorization.rs +++ b/crates/tracedecay-configuration/src/configuration/authorization.rs @@ -135,7 +135,7 @@ mod tests { use tracedecay_domain::configuration::{ ConfigurationGrantReceiptId, ConfigurationMutationGrantReceiptV1, }; - use tracedecay_domain::{AccessPolicyDigest, ActorId, ManifestDigest}; + use tracedecay_domain::{AccessPolicyDigest, ActorId}; use tracedecay_policy::configuration::{ ConfigurationMutationGrantStateV1, ConfigurationMutationPermissionV1, }; @@ -155,17 +155,9 @@ mod tests { } } - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn policy_digest(byte: char) -> AccessPolicyDigest { AccessPolicyDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() diff --git a/crates/tracedecay-configuration/src/configuration/operations.rs b/crates/tracedecay-configuration/src/configuration/operations.rs index 68f70dce45..398b746c40 100644 --- a/crates/tracedecay-configuration/src/configuration/operations.rs +++ b/crates/tracedecay-configuration/src/configuration/operations.rs @@ -614,30 +614,20 @@ mod tests { ConfigurationMutationGrantReceiptV1, ConfigurationSnapshotV1, ConfigurationValueV1, ProtectedChange, ScopeSourceBinding, SettingKey, SourceBindingId, SourceKindV1, }; - use tracedecay_domain::{ - AccessPolicyDigest, ActorId, LocatorDigest, ManifestDigest, ProjectId, - }; + use tracedecay_domain::{AccessPolicyDigest, ActorId, LocatorDigest, ProjectId}; use tracedecay_global_db::configuration::contracts::ports::{ ConfigurationControlStore, ConfigurationCurrentStateV1, ConfigurationOperationFuture, CurrentConfigurationMutationAuthorizationV1, }; use tracedecay_global_db::configuration::contracts::types::ConfigurationSettlementAuthorityV1; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn policy_digest(byte: char) -> AccessPolicyDigest { AccessPolicyDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() } - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; struct Store { current: ConfigurationCurrentStateV1, diff --git a/crates/tracedecay-contracts/src/authorization/service.rs b/crates/tracedecay-contracts/src/authorization/service.rs index c18adef10a..6c4e11754a 100644 --- a/crates/tracedecay-contracts/src/authorization/service.rs +++ b/crates/tracedecay-contracts/src/authorization/service.rs @@ -7,9 +7,7 @@ use tracedecay_policy::authorization::{ use crate::context::{RequestAdmission, RequestContext}; use crate::handlers::ApplicationOperation; -use crate::result::{ - ApplicationProblem, AuthorityReceipt, PolicyDecisionRef, RetryDirective, SafeDiagnostic, -}; +use crate::result::{ApplicationProblem, AuthorityReceipt, PolicyDecisionRef, RetryDirective}; use super::{ AuthorizationPhase, AuthorizationPort, AuthorizationPortOutcome, AuthorizationRequest, @@ -101,15 +99,10 @@ where let policy = self.policy_reference(&decision)?; AuthorityReceipt::from_context(context, policy, observed_at).map_err(|_| { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic::new( - "application.authorization.invalid-context", - "The request context is invalid.", - ) - .expect("static safe diagnostic is valid"), - retry: RetryDirective::Never, - legal_actions: Vec::new(), - } + ApplicationProblem::invalid_request_without_action( + "application.authorization.invalid-context", + "The request context is invalid.", + ) }) } @@ -207,14 +200,11 @@ where .ok_or_else(|| self.non_disclosure.proof_problem())?; let policy = self.policy_reference(&decision)?; let receipt = AuthorityReceipt::from_context(request.context, policy, request.observed_at) - .map_err(|_| ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic::new( + .map_err(|_| { + ApplicationProblem::invalid_request_without_action( "application.authorization.invalid-context", "The request context is invalid.", ) - .expect("static safe diagnostic is valid"), - retry: RetryDirective::Never, - legal_actions: Vec::new(), })?; Ok(AuthorizationAdmission { diff --git a/crates/tracedecay-contracts/src/doctor/report.rs b/crates/tracedecay-contracts/src/doctor/report.rs index 314882fae8..2b22453cee 100644 --- a/crates/tracedecay-contracts/src/doctor/report.rs +++ b/crates/tracedecay-contracts/src/doctor/report.rs @@ -911,16 +911,7 @@ fn storage_unavailable( /// statement: control characters folded to spaces, trimmed, and bounded so the /// composed statement stays inside the statement contract. fn sanitized_detail(detail: &str) -> Option { - let folded: String = detail - .chars() - .map(|character| { - if character.is_control() { - ' ' - } else { - character - } - }) - .collect(); + let folded = tracedecay_domain::fold_control_characters(detail); let bounded = truncate_at_char_boundary(folded.trim(), PLACEHOLDER_DETAIL_MAX_BYTES); let bounded = bounded.trim(); if bounded.is_empty() { diff --git a/crates/tracedecay-contracts/src/doctor/sources.rs b/crates/tracedecay-contracts/src/doctor/sources.rs index 565406c498..51b2d8a80d 100644 --- a/crates/tracedecay-contracts/src/doctor/sources.rs +++ b/crates/tracedecay-contracts/src/doctor/sources.rs @@ -77,26 +77,14 @@ fn source_finding( fn bounded_statement(statement: &str) -> String { const STATEMENT_LIMIT_BYTES: usize = 512; const TRUNCATION_MARK: &str = "…"; - let sanitized = statement - .chars() - .map(|character| { - if character.is_control() { - ' ' - } else { - character - } - }) - .collect::(); + let sanitized = tracedecay_domain::fold_control_characters(statement); let sanitized = sanitized.trim(); if sanitized.len() <= STATEMENT_LIMIT_BYTES { return sanitized.to_owned(); } let budget = STATEMENT_LIMIT_BYTES - TRUNCATION_MARK.len(); - let mut cut = budget; - while cut > 0 && !sanitized.is_char_boundary(cut) { - cut -= 1; - } - format!("{}{TRUNCATION_MARK}", sanitized[..cut].trim_end()) + let cut = tracedecay_domain::utf8_prefix_at_or_before(sanitized, budget); + format!("{}{TRUNCATION_MARK}", cut.trim_end()) } /// Build an honest non-healthy finding for an unobservable source read. diff --git a/crates/tracedecay-contracts/src/execution_topology_metrics/rollup_read.rs b/crates/tracedecay-contracts/src/execution_topology_metrics/rollup_read.rs index 84c1792c90..1419f4c3d8 100644 --- a/crates/tracedecay-contracts/src/execution_topology_metrics/rollup_read.rs +++ b/crates/tracedecay-contracts/src/execution_topology_metrics/rollup_read.rs @@ -2,7 +2,7 @@ //! partial-day boundary pages. use serde::{Deserialize, Serialize}; -use tracedecay_domain::{CoverageStateV1, UtcMicros}; +use tracedecay_domain::CoverageStateV1; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; use crate::clock::now_micros; @@ -10,7 +10,7 @@ use crate::observability::{ ObservabilityFuture, ObservabilityHorizonV1, ObservabilityQueryPort, ObservabilityQueryV1, }; use crate::work::work_authority; -use crate::{ApplicationProblem, RequestAdmission, RequestContext, RetryDirective}; +use crate::{ApplicationProblem, RequestContext, RetryDirective}; use super::projection::TELEMETRY_DROP_EVENT_KIND_V1; use super::rollup::{ @@ -20,7 +20,7 @@ use super::rollup::{ canonical_execution_topology_rollup_fragment_bytes, project_execution_topology_fragments_with_boundaries, }; -use super::support::{invalid_problem, unavailable_model, unavailable_model_with_state_at}; +use super::support::{unavailable_model, unavailable_model_with_state_at}; use super::{ EXECUTION_TOPOLOGY_CAPABILITY_ID_V1, EXECUTION_TOPOLOGY_EVENT_KINDS_V1, EXECUTION_TOPOLOGY_USE_CASE_ID_V1, ExecutionMetricUnavailableV1, @@ -77,7 +77,7 @@ where { validate_request(request)?; let observed_at = now_micros(); - admit(context, observed_at)?; + ApplicationProblem::ensure_admitted(context, observed_at)?; authorize(context)?; let authorized_scope_ref = work_authority(context)?.project_id().as_str().to_owned(); let observed_at_micros = observed_at.0; @@ -106,7 +106,7 @@ where ExecutionMetricUnavailableV1::EventBudgetExceeded, )); } - admit(context, now_micros())?; + ApplicationProblem::ensure_admitted(context, now_micros())?; let page = match observations .query(boundary_query( &authorized_scope_ref, @@ -182,7 +182,7 @@ where } let fragments = if let Some(horizon) = full_days { - admit(context, now_micros())?; + ApplicationProblem::ensure_admitted(context, now_micros())?; let page = match rollups .query_rollup_fragments(ExecutionTopologyRollupFragmentQueryV1 { authorized_scope_ref: authorized_scope_ref.clone(), @@ -227,13 +227,13 @@ where fn validate_request(request: &ExecutionTopologyMetricsRequestV1) -> Result<(), ApplicationProblem> { if request.horizon.until_micros <= request.horizon.since_micros { - return Err(invalid_problem( + return Err(ApplicationProblem::invalid_request( "application.execution-topology-rollup.invalid-horizon", "The execution topology metrics horizon must end after it starts.", )); } if request.max_events == 0 || request.max_events > MAX_EXECUTION_TOPOLOGY_EVENTS_V1 { - return Err(invalid_problem( + return Err(ApplicationProblem::invalid_request( "application.execution-topology-rollup.invalid-event-budget", "The execution topology metrics event budget must be between 1 and 10000.", )); @@ -241,23 +241,15 @@ fn validate_request(request: &ExecutionTopologyMetricsRequestV1) -> Result<(), A Ok(()) } -fn admit(context: &RequestContext, observed_at: UtcMicros) -> Result<(), ApplicationProblem> { - match context.admission_at(observed_at) { - RequestAdmission::Admitted => Ok(()), - RequestAdmission::Cancelled => Err(ApplicationProblem::cancelled_before_admission()), - RequestAdmission::TimedOut => Err(ApplicationProblem::timed_out_before_admission()), - } -} - fn authorize(context: &RequestContext) -> Result<(), ApplicationProblem> { let capability = CapabilityId::new(EXECUTION_TOPOLOGY_CAPABILITY_ID_V1).map_err(|_| { - invalid_problem( + ApplicationProblem::invalid_request( "application.execution-topology-rollup.invalid-authority", "The execution topology metrics authority is unavailable.", ) })?; let use_case = UseCaseId::new(EXECUTION_TOPOLOGY_USE_CASE_ID_V1).map_err(|_| { - invalid_problem( + ApplicationProblem::invalid_request( "application.execution-topology-rollup.invalid-authority", "The execution topology metrics authority is unavailable.", ) diff --git a/crates/tracedecay-contracts/src/execution_topology_metrics/support.rs b/crates/tracedecay-contracts/src/execution_topology_metrics/support.rs index c2c04c3f90..e627073d12 100644 --- a/crates/tracedecay-contracts/src/execution_topology_metrics/support.rs +++ b/crates/tracedecay-contracts/src/execution_topology_metrics/support.rs @@ -1,11 +1,5 @@ use tracedecay_domain::CoverageStateV1; -use crate::observability::{ - MetricCohortV1, MetricCoverageV1, MetricEvidenceClassV1, MetricProvenanceV1, MetricSourceV1, - MetricTemporalV1, MetricUncertaintyV1, MetricValueV1, ObservabilityHorizonV1, -}; -use crate::{ApplicationProblem, LegalAction, RetryDirective, SafeDiagnostic}; - use super::projection::ProjectionContext; use super::{ CONFLICT_MIN_ADJUDICATED_CASES_V1, EXECUTION_TOPOLOGY_DESCRIPTOR_REVISION_V1, @@ -14,6 +8,10 @@ use super::{ MAX_CENSORING_RATIO_V1, MAX_METRIC_DIMENSIONS_V1, MIN_COVERAGE_RATIO_V1, RATE_MIN_ELIGIBLE_CASES_V1, }; +use crate::observability::{ + MetricCohortV1, MetricCoverageV1, MetricEvidenceClassV1, MetricProvenanceV1, MetricSourceV1, + MetricTemporalV1, MetricUncertaintyV1, MetricValueV1, ObservabilityHorizonV1, +}; const SOURCE_REVISION_V1: &str = "observability-envelope.v1"; @@ -493,17 +491,6 @@ fn span(start: i64, end: i64) -> u64 { end.abs_diff(start) } -pub(super) fn invalid_problem(code: &str, message: &str) -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: message.to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } -} - #[cfg(test)] #[path = "support_descriptor_tests.rs"] mod descriptor_tests; diff --git a/crates/tracedecay-contracts/src/external_source.rs b/crates/tracedecay-contracts/src/external_source.rs index 3565860310..874600b9a9 100644 --- a/crates/tracedecay-contracts/src/external_source.rs +++ b/crates/tracedecay-contracts/src/external_source.rs @@ -274,14 +274,6 @@ impl SourceCanonicalRefetchAuthorityV1 { self.binding == *refresh.binding() && self.original_refresh_digest == *refresh.receipt_digest() } - - /// Reports whether this opaque capability names the exact refresh. - /// - /// The capability still exposes no binding fields or constructor, so a - /// provider or transport cannot mint or retarget it. - pub fn authorizes(&self, refresh: &SourceRefreshReceiptV1) -> bool { - self.matches(refresh) - } } #[derive(Clone, Debug)] diff --git a/crates/tracedecay-contracts/src/external_source_tests.rs b/crates/tracedecay-contracts/src/external_source_tests.rs index db9a641bec..8570454cde 100644 --- a/crates/tracedecay-contracts/src/external_source_tests.rs +++ b/crates/tracedecay-contracts/src/external_source_tests.rs @@ -6,9 +6,7 @@ use tracedecay_domain::{ SourcePartitionIdV1, SourceSnapshotIdV1, }; -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn acquisition_contract( capture_mode: SourceCaptureModeV1, diff --git a/crates/tracedecay-contracts/src/feedback/proximity_read.rs b/crates/tracedecay-contracts/src/feedback/proximity_read.rs index fb5678c546..1ed3acf44f 100644 --- a/crates/tracedecay-contracts/src/feedback/proximity_read.rs +++ b/crates/tracedecay-contracts/src/feedback/proximity_read.rs @@ -473,9 +473,7 @@ mod tests { use super::*; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("digest") - } + use tracedecay_domain::test_fixtures::digest; fn scope() -> FeedbackScopeV1 { FeedbackScopeV1 { diff --git a/crates/tracedecay-contracts/src/feedback/read.rs b/crates/tracedecay-contracts/src/feedback/read.rs index 0fd6097ea9..52eec7e291 100644 --- a/crates/tracedecay-contracts/src/feedback/read.rs +++ b/crates/tracedecay-contracts/src/feedback/read.rs @@ -24,8 +24,8 @@ use crate::error::ApplicationContractError; use crate::handlers::ApplicationOperation; use crate::result::{ ApplicationEnvelope, ApplicationProblem, ApplicationProblemEnvelope, ApplicationResult, - AuthorityReceipt, EvidencePacket, LegalAction, OpaqueCursor, OperationReceipt, - OperationTermination, PageCursor, RetrievalEvidence, RetryDirective, SafeDiagnostic, + AuthorityReceipt, EvidencePacket, OpaqueCursor, OperationReceipt, OperationTermination, + PageCursor, RetrievalEvidence, RetryDirective, SafeDiagnostic, }; use crate::retrieval::{ AnchorExpandRequest, AnchorExpandResult, PageRequest, RetrievalPortOutcome, @@ -605,14 +605,10 @@ fn invalid_request( problem_envelope( context, operation, - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic::new( - "application.feedback.invalid-request", - "The feedback read request is invalid.", - )?, - retry: RetryDirective::Never, - legal_actions: Vec::::new(), - }, + ApplicationProblem::invalid_request_without_action( + "application.feedback.invalid-request", + "The feedback read request is invalid.", + ), ) } @@ -688,9 +684,7 @@ mod invocation_tests { FeedbackFindingId, FeedbackFindingLifecycleV1, FeedbackFindingV1, FeedbackResultId, FeedbackScopeV1, ProviderEvaluationStateV1, }; - use tracedecay_domain::{ - CommitId, ManifestDigest, ProjectId, RepositoryId, RetrievalAnchorId, WorktreeId, - }; + use tracedecay_domain::{CommitId, ProjectId, RepositoryId, RetrievalAnchorId, WorktreeId}; use super::{ CanonicalAffectedTestsProjectionV1, CanonicalFeedbackImpactProjectionV1, @@ -841,7 +835,5 @@ mod invocation_tests { FeedbackCycleId::new("cycle.feedback-test").expect("cycle") } - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("digest") - } + use tracedecay_domain::test_fixtures::digest; } diff --git a/crates/tracedecay-contracts/src/git/effect_surface.rs b/crates/tracedecay-contracts/src/git/effect_surface.rs new file mode 100644 index 0000000000..8b9fa1cd10 --- /dev/null +++ b/crates/tracedecay-contracts/src/git/effect_surface.rs @@ -0,0 +1,51 @@ +//! Effect versus read terminal, cancellation, and deadline shapes shared by +//! the public Git and native-integration catalogs. The two surfaces differ +//! in operations, not in these effect-class consequences. + +use tracedecay_tool_catalog::{CancellationPoint, DeadlineBehavior, EffectClass, TerminalState}; + +pub(super) fn cancellation_points(effect: EffectClass) -> Vec { + if effect.is_effect() { + vec![ + CancellationPoint::BeforeAdmission, + CancellationPoint::BeforeEffect, + CancellationPoint::EffectInFlight, + CancellationPoint::AfterCommit, + ] + } else { + vec![ + CancellationPoint::BeforeAdmission, + CancellationPoint::BeforeRead, + CancellationPoint::DuringRead, + ] + } +} + +pub(super) fn deadline_behavior(effect: EffectClass) -> DeadlineBehavior { + if effect.is_effect() { + DeadlineBehavior::ReturnEffectReceipt + } else { + DeadlineBehavior::ReturnOperationReceipt + } +} + +pub(super) fn terminal_states(effect: EffectClass) -> Vec { + if effect.is_effect() { + vec![ + TerminalState::Completed, + TerminalState::Cancelled, + TerminalState::TimedOut, + TerminalState::Failed, + TerminalState::EffectUnknown, + TerminalState::Partial, + ] + } else { + vec![ + TerminalState::Completed, + TerminalState::Cancelled, + TerminalState::TimedOut, + TerminalState::Failed, + TerminalState::Partial, + ] + } +} diff --git a/crates/tracedecay-contracts/src/git/mod.rs b/crates/tracedecay-contracts/src/git/mod.rs index d69ffdb810..671eb9d10c 100644 --- a/crates/tracedecay-contracts/src/git/mod.rs +++ b/crates/tracedecay-contracts/src/git/mod.rs @@ -1,6 +1,7 @@ //! Git index transaction application boundary. mod catalog; +mod effect_surface; mod native_integration; mod native_integration_surface; mod public_wire; diff --git a/crates/tracedecay-contracts/src/git/native_integration_surface.rs b/crates/tracedecay-contracts/src/git/native_integration_surface.rs index af87e142bd..014f91ce5f 100644 --- a/crates/tracedecay-contracts/src/git/native_integration_surface.rs +++ b/crates/tracedecay-contracts/src/git/native_integration_surface.rs @@ -3,15 +3,8 @@ //! `apply_native_integration`, `native_integration_status`, and //! `cancel_native_integration`. //! -//! Plan 36 slice 1 extends "the shipped application and CLI/MCP surfaces with -//! `stack_snapshot` and `preflight_native_integration`", slice 3 adds -//! `apply_native_integration`, `native_integration_status`, and -//! `cancel_native_integration`, `approve_native_integration` is the -//! owner-decided (2026-08-07) sixth operation that issues the one-use -//! apply approval, and slice 4 requires the whole journey to be -//! exposed consistently through CLI and MCP over one application result. That -//! is a different family from the Plan 08 Git *index-transaction* bindings, -//! which stay limited to `git_preview`/`git_apply`; this module never exposes +//! This is a different family from the Git index-transaction bindings, which +//! stay limited to `git_preview` / `git_apply`. This module never exposes //! `stage_hunks`, `unstage_hunks`, or `commit_index`. //! //! Requests carry exact typed identity only. Filesystem paths, free-form @@ -33,14 +26,14 @@ use tracedecay_domain::{ }; use tracedecay_tool_catalog::{ ApplicationSurfaceOperation, AvailabilityContract, BindingId, BindingSurface, - CancellationContract, CancellationPoint, CapabilityId, CapabilityManifestV1, - CatalogContributionInputV1, CatalogContributionV1, ContributionId, DeadlineBehavior, - DeadlineContract, DeniedDisclosurePolicy, EffectClass, ExecutableSchemaAuthority, - LifecycleClass, PrivacyClass, ProfileId, RevalidationContract, RevalidationPoint, - RoutingContractV1, SchemaId, SchemaRef, ScopeDimension, ScopeRequirement, StreamingContract, - TerminalState, TerminalStateContract, UseCaseId, + CancellationContract, CapabilityId, CapabilityManifestV1, CatalogContributionInputV1, + CatalogContributionV1, ContributionId, DeadlineContract, DeniedDisclosurePolicy, EffectClass, + ExecutableSchemaAuthority, LifecycleClass, PrivacyClass, ProfileId, RevalidationContract, + RevalidationPoint, RoutingContractV1, SchemaId, SchemaRef, ScopeDimension, ScopeRequirement, + StreamingContract, TerminalStateContract, UseCaseId, }; +use super::effect_surface::{cancellation_points, deadline_behavior, terminal_states}; use crate::CancellationSignal; use crate::capability_manifest::{ ApplicationCapabilityManifestInput, application_capability_manifest, @@ -912,52 +905,6 @@ fn capability( )?) } -fn cancellation_points(effect: EffectClass) -> Vec { - if effect.is_effect() { - vec![ - CancellationPoint::BeforeAdmission, - CancellationPoint::BeforeEffect, - CancellationPoint::EffectInFlight, - CancellationPoint::AfterCommit, - ] - } else { - vec![ - CancellationPoint::BeforeAdmission, - CancellationPoint::BeforeRead, - CancellationPoint::DuringRead, - ] - } -} - -fn deadline_behavior(effect: EffectClass) -> DeadlineBehavior { - if effect.is_effect() { - DeadlineBehavior::ReturnEffectReceipt - } else { - DeadlineBehavior::ReturnOperationReceipt - } -} - -fn terminal_states(effect: EffectClass) -> Vec { - if effect.is_effect() { - vec![ - TerminalState::Completed, - TerminalState::Cancelled, - TerminalState::TimedOut, - TerminalState::Failed, - TerminalState::EffectUnknown, - TerminalState::Partial, - ] - } else { - vec![ - TerminalState::Completed, - TerminalState::Cancelled, - TerminalState::TimedOut, - TerminalState::Failed, - TerminalState::Partial, - ] - } -} - fn handler_descriptor( spec: &NativeIntegrationSurfaceSpec, ) -> Result { diff --git a/crates/tracedecay-contracts/src/git/stack_signal_expand.rs b/crates/tracedecay-contracts/src/git/stack_signal_expand.rs index fc84d198c2..7b9b344540 100644 --- a/crates/tracedecay-contracts/src/git/stack_signal_expand.rs +++ b/crates/tracedecay-contracts/src/git/stack_signal_expand.rs @@ -270,9 +270,7 @@ impl GitHubStackSignalExpandPortError { mod tests { use super::*; - fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).expect("digest") - } + use tracedecay_domain::test_fixtures::digest; fn native_preview() -> GitHubStackSignalNativePreviewV1 { GitHubStackSignalNativePreviewV1 { diff --git a/crates/tracedecay-contracts/src/git/surface_catalog.rs b/crates/tracedecay-contracts/src/git/surface_catalog.rs index f8680af3c1..55fa215c6a 100644 --- a/crates/tracedecay-contracts/src/git/surface_catalog.rs +++ b/crates/tracedecay-contracts/src/git/surface_catalog.rs @@ -9,14 +9,14 @@ use schemars::JsonSchema; use tracedecay_domain::{GitIndexPreviewV1, GitIndexTransactionReceiptV1}; use tracedecay_tool_catalog::{ ApplicationSurfaceOperation, AvailabilityContract, BindingId, BindingSurface, - CancellationContract, CancellationPoint, CapabilityId, CapabilityManifestV1, - CatalogContributionInputV1, CatalogContributionV1, ContributionId, DeadlineBehavior, - DeadlineContract, DeniedDisclosurePolicy, EffectClass, ExecutableSchemaAuthority, - LifecycleClass, PrivacyClass, ProfileId, RevalidationContract, RevalidationPoint, - RoutingContractV1, SchemaId, SchemaRef, ScopeDimension, ScopeRequirement, StreamingContract, - TerminalState, TerminalStateContract, UseCaseId, + CancellationContract, CapabilityId, CapabilityManifestV1, CatalogContributionInputV1, + CatalogContributionV1, ContributionId, DeadlineContract, DeniedDisclosurePolicy, EffectClass, + ExecutableSchemaAuthority, LifecycleClass, PrivacyClass, ProfileId, RevalidationContract, + RevalidationPoint, RoutingContractV1, SchemaId, SchemaRef, ScopeDimension, ScopeRequirement, + StreamingContract, TerminalStateContract, UseCaseId, }; +use super::effect_surface::{cancellation_points, deadline_behavior, terminal_states}; use crate::capability_manifest::{ ApplicationCapabilityManifestInput, application_capability_manifest, }; @@ -341,52 +341,6 @@ fn capability( )?) } -fn cancellation_points(effect: EffectClass) -> Vec { - if effect.is_effect() { - vec![ - CancellationPoint::BeforeAdmission, - CancellationPoint::BeforeEffect, - CancellationPoint::EffectInFlight, - CancellationPoint::AfterCommit, - ] - } else { - vec![ - CancellationPoint::BeforeAdmission, - CancellationPoint::BeforeRead, - CancellationPoint::DuringRead, - ] - } -} - -fn deadline_behavior(effect: EffectClass) -> DeadlineBehavior { - if effect.is_effect() { - DeadlineBehavior::ReturnEffectReceipt - } else { - DeadlineBehavior::ReturnOperationReceipt - } -} - -fn terminal_states(effect: EffectClass) -> Vec { - if effect.is_effect() { - vec![ - TerminalState::Completed, - TerminalState::Cancelled, - TerminalState::TimedOut, - TerminalState::Failed, - TerminalState::EffectUnknown, - TerminalState::Partial, - ] - } else { - vec![ - TerminalState::Completed, - TerminalState::Cancelled, - TerminalState::TimedOut, - TerminalState::Failed, - TerminalState::Partial, - ] - } -} - fn handler_descriptor( spec: &SurfaceSpec, ) -> Result { diff --git a/crates/tracedecay-contracts/src/git/tests.rs b/crates/tracedecay-contracts/src/git/tests.rs index 952cbae2b6..e43e4d51b7 100644 --- a/crates/tracedecay-contracts/src/git/tests.rs +++ b/crates/tracedecay-contracts/src/git/tests.rs @@ -4,7 +4,7 @@ use tracedecay_domain::{ ActorId, ComponentVersion, GitCommitIdentityV1, GitCoverageV1, GitHeadStateV1, GitIndexCommitIntentV1, GitIndexPreviewDispositionV1, GitIndexPreviewId, GitIndexPreviewV1, GitIndexSigningPolicyV1, GitIndexTransactionOperationV1, GitObjectFormatV1, GitOidV1, - GitOperationStateV1, ManifestDigest, ProjectId, RefId, RepositoryId, RepositoryIndexSnapshotV1, + GitOperationStateV1, ProjectId, RefId, RepositoryId, RepositoryIndexSnapshotV1, RepositoryIndexStateV1, RepositoryStateSnapshotV1, RepositoryWorkingTreeSnapshotV1, RepositoryWorkingTreeStateV1, UtcMicros, WorktreeId, }; @@ -21,17 +21,9 @@ use crate::{ RequestContext, RequestId, ResolvedScope, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture id") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("fixture digest") -} +use tracedecay_domain::test_fixtures::digest; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("fixture oid") diff --git a/crates/tracedecay-contracts/src/invocation.rs b/crates/tracedecay-contracts/src/invocation.rs index df598ff8b2..10e1a3b124 100644 --- a/crates/tracedecay-contracts/src/invocation.rs +++ b/crates/tracedecay-contracts/src/invocation.rs @@ -245,28 +245,10 @@ impl ApplicationRequest { } } - #[hotpath::skip] - pub const fn is_stream(&self) -> bool { - matches!(self, Self::OperationEvents { .. }) - } - #[hotpath::skip] pub const fn is_cancellation(&self) -> bool { matches!(self, Self::OperationCancel { .. }) } - - pub fn feedback_observation_parts(&self) -> Option<(&ManifestDigest, UtcMicros, &Value)> { - match self { - Self::FeedbackObservation { - configuration_digest, - observed_at, - event, - } => Some((configuration_digest, *observed_at, event)), - Self::Surface { .. } | Self::OperationEvents { .. } | Self::OperationCancel { .. } => { - None - } - } - } } /// One complete transport-neutral invocation. diff --git a/crates/tracedecay-contracts/src/remote/credential_admission.rs b/crates/tracedecay-contracts/src/remote/credential_admission.rs index 1810c1a0e4..0aae85b18f 100644 --- a/crates/tracedecay-contracts/src/remote/credential_admission.rs +++ b/crates/tracedecay-contracts/src/remote/credential_admission.rs @@ -745,13 +745,7 @@ mod tests { } } - fn id(value: &str) -> T - where - T: TryFrom, - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; fn scope() -> RemoteRepositoryScopeV1 { RemoteRepositoryScopeV1 { diff --git a/crates/tracedecay-contracts/src/remote/protocol.rs b/crates/tracedecay-contracts/src/remote/protocol.rs index 9588335a5a..6a20a65453 100644 --- a/crates/tracedecay-contracts/src/remote/protocol.rs +++ b/crates/tracedecay-contracts/src/remote/protocol.rs @@ -555,22 +555,14 @@ pub fn remote_protocol_problem( retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], }, - RemoteProtocolFailureV1::AuthorityReachable => ApplicationProblem::Conflict { - diagnostic: safe_diagnostic( - "remote.authority_reachable", - "Offline capture is rejected while the owning authority is reachable", - )?, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }, - RemoteProtocolFailureV1::SpoolSaturated => ApplicationProblem::Saturated { - diagnostic: safe_diagnostic( - "remote.spool_saturated", - "The remote offline-capture spool has no remaining capacity", - )?, - retry: RetryDirective::AfterDelay, - legal_actions: vec![LegalAction::Retry], - }, + RemoteProtocolFailureV1::AuthorityReachable => ApplicationProblem::conflict( + "remote.authority_reachable", + "Offline capture is rejected while the owning authority is reachable", + ), + RemoteProtocolFailureV1::SpoolSaturated => ApplicationProblem::saturated( + "remote.spool_saturated", + "The remote offline-capture spool has no remaining capacity", + ), RemoteProtocolFailureV1::AuthorityUnavailable => ApplicationProblem::Unavailable { classification: crate::ApplicationUnavailableClassV1::Authority, diagnostic: safe_diagnostic( diff --git a/crates/tracedecay-contracts/src/remote/query.rs b/crates/tracedecay-contracts/src/remote/query.rs index 4dc8ee2351..3e18d12a1c 100644 --- a/crates/tracedecay-contracts/src/remote/query.rs +++ b/crates/tracedecay-contracts/src/remote/query.rs @@ -35,8 +35,6 @@ use crate::{ }; pub const REMOTE_QUERY_SCHEMA_REVISION_V1: u16 = 1; -pub const REMOTE_EXACT_OBSERVATION_QUERY_USE_CASE_V1: &str = - "use-case.remote.query.exact-observation"; static REMOTE_EXACT_OBSERVATION_QUERY_RESULT_CONTRACT_V1: LazyLock = LazyLock::new(|| { diff --git a/crates/tracedecay-contracts/src/request_identity.rs b/crates/tracedecay-contracts/src/request_identity.rs index b68a4ea378..a89e3deba2 100644 --- a/crates/tracedecay-contracts/src/request_identity.rs +++ b/crates/tracedecay-contracts/src/request_identity.rs @@ -317,6 +317,11 @@ pub fn mcp_connection_request_id(id: &Value, connection_scope: &str) -> Option Option { + mcp_connection_request_id(id, connection_scope).map(|request_id| request_id.as_str().to_owned()) +} + pub struct McpConnectionIdentityAuthority { instance_id: Option, next_connection: AtomicU64, @@ -468,6 +473,10 @@ mod tests { "request.mcp.connection.6b86b273ff34fce19d6b804eff5a3f57" ); assert!(mcp_connection_request_id(&Value::Null, "connection").is_none()); + assert_eq!( + mcp_connection_request_key(&json!(1), "connection").as_deref(), + Some(numeric.as_str()) + ); } #[test] diff --git a/crates/tracedecay-contracts/src/result/envelope.rs b/crates/tracedecay-contracts/src/result/envelope.rs index 9377fc40a8..8ba383834e 100644 --- a/crates/tracedecay-contracts/src/result/envelope.rs +++ b/crates/tracedecay-contracts/src/result/envelope.rs @@ -93,6 +93,16 @@ pub enum ApplicationOutcome { Effect(EffectResult), } +impl ApplicationOutcome { + pub fn payload(&self) -> Option<&T> { + match self { + Self::Evidence(result) => result.payload.as_ref(), + Self::Preview(result) => result.payload.as_ref(), + Self::Effect(result) => result.payload.as_ref(), + } + } +} + /// Successful application result with a stable contract, request, and scope. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -183,6 +193,13 @@ pub struct ApplicationProblemRecord { source: ApplicationProblem, } +impl ApplicationProblemRecord { + /// `code: message`, the single line adapters print for a refusal. + pub fn summary(&self) -> String { + format!("{}: {}", self.code, self.message) + } +} + impl<'de> Deserialize<'de> for ApplicationProblemRecord { fn deserialize(deserializer: D) -> Result where @@ -726,13 +743,10 @@ mod tests { use super::*; use crate::{EffectTermination, IdempotencyKey}; use serde_json::Value; - use tracedecay_domain::{ActorId, ManifestDigest, ProjectId, RepositoryId, WorktreeId}; + use tracedecay_domain::{ActorId, ProjectId, RepositoryId, WorktreeId}; use tracedecay_tool_catalog::{EffectClass, SchemaId, UseCaseId}; - fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))) - .expect("fixture digest is valid") - } + use tracedecay_domain::test_fixtures::digest; fn receipt() -> EffectReceipt { let expected_state = digest('a'); diff --git a/crates/tracedecay-contracts/src/result/problem.rs b/crates/tracedecay-contracts/src/result/problem.rs index a44e21648e..e4358f3146 100644 --- a/crates/tracedecay-contracts/src/result/problem.rs +++ b/crates/tracedecay-contracts/src/result/problem.rs @@ -1,8 +1,10 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use tracedecay_domain::UtcMicros; use tracedecay_domain::errors::TraceDecayError; use super::{CancellationStage, EffectReceipt, EffectTermination}; +use crate::context::{RequestAdmission, RequestContext}; use crate::error::ApplicationContractError; /// Diagnostic code for an admitted route whose owner is still registering @@ -688,6 +690,24 @@ impl ApplicationProblem { } } + /// Invalid request that offers no recovery action. Never retries. + /// + /// Empty legal actions are part of the refusal: adapters must not invent + /// `CorrectRequest` for these problems. + pub fn invalid_request_without_action( + code: impl Into, + message: impl Into, + ) -> Self { + Self::InvalidRequest { + diagnostic: SafeDiagnostic { + code: code.into(), + message: message.into(), + }, + retry: RetryDirective::Never, + legal_actions: Vec::new(), + } + } + pub fn cancelled_before_admission() -> Self { Self::Cancelled { stage: CancellationStage::BeforeAdmission, @@ -704,6 +724,42 @@ impl ApplicationProblem { } } + /// Admitted requests continue. Cancellation and deadline expiry become + /// the matching pre-admission problems. + pub fn ensure_admitted(context: &RequestContext, observed_at: UtcMicros) -> Result<(), Self> { + match context.admission_at(observed_at) { + RequestAdmission::Admitted => Ok(()), + RequestAdmission::Cancelled => Err(Self::cancelled_before_admission()), + RequestAdmission::TimedOut => Err(Self::timed_out_before_admission()), + } + } + + /// Conflict that must be revalidated. Retry is `AfterRevalidate` and the + /// only legal action is `Refresh`. + pub fn conflict(code: impl Into, message: impl Into) -> Self { + Self::Conflict { + diagnostic: SafeDiagnostic { + code: code.into(), + message: message.into(), + }, + retry: RetryDirective::AfterRevalidate, + legal_actions: vec![LegalAction::Refresh], + } + } + + /// Invalid request that must not be retried unchanged. The only legal + /// action is `CorrectRequest`. + pub fn invalid_request(code: impl Into, message: impl Into) -> Self { + Self::InvalidRequest { + diagnostic: SafeDiagnostic { + code: code.into(), + message: message.into(), + }, + retry: RetryDirective::Never, + legal_actions: vec![LegalAction::CorrectRequest], + } + } + pub fn cancelled(stage: CancellationStage) -> Result { let problem = Self::Cancelled { stage, @@ -809,6 +865,18 @@ impl ApplicationProblem { } } + /// Capacity refusal. Retry only after a delay. + pub fn saturated(code: impl Into, message: impl Into) -> Self { + Self::Saturated { + diagnostic: SafeDiagnostic { + code: code.into(), + message: message.into(), + }, + retry: RetryDirective::AfterDelay, + legal_actions: vec![LegalAction::Retry], + } + } + pub fn reset_required(diagnostic: SafeDiagnostic) -> Self { Self::ResetRequired { diagnostic, diff --git a/crates/tracedecay-contracts/src/result/problem/tests.rs b/crates/tracedecay-contracts/src/result/problem/tests.rs index a4cfcb21b8..942f05e0e8 100644 --- a/crates/tracedecay-contracts/src/result/problem/tests.rs +++ b/crates/tracedecay-contracts/src/result/problem/tests.rs @@ -6,6 +6,97 @@ use super::{ RetryDirective, SafeDiagnostic, }; +#[test] +fn invalid_request_offers_correction_and_never_retries() { + let diagnostic = SafeDiagnostic { + code: "application.invalid-request".to_owned(), + message: "The request is invalid.".to_owned(), + }; + let problem = + ApplicationProblem::invalid_request(diagnostic.code.clone(), diagnostic.message.clone()); + + assert_eq!( + problem, + ApplicationProblem::InvalidRequest { + diagnostic, + retry: RetryDirective::Never, + legal_actions: vec![LegalAction::CorrectRequest], + } + ); + assert_eq!(problem.safe_message(), "The request is invalid."); + assert_eq!(problem.reason_code(), "application.invalid-request"); +} + +#[test] +fn invalid_request_without_action_offers_no_recovery() { + let diagnostic = SafeDiagnostic { + code: "application.invalid-request.closed".to_owned(), + message: "The request is invalid.".to_owned(), + }; + let problem = ApplicationProblem::invalid_request_without_action( + diagnostic.code.clone(), + diagnostic.message.clone(), + ); + + assert_eq!( + problem, + ApplicationProblem::InvalidRequest { + diagnostic, + retry: RetryDirective::Never, + legal_actions: Vec::new(), + } + ); + assert_eq!(problem.safe_message(), "The request is invalid."); + assert!(problem.legal_actions().is_empty()); +} + +#[test] +fn saturated_retries_after_delay() { + let diagnostic = SafeDiagnostic { + code: "application.saturated".to_owned(), + message: "The authority has no remaining capacity.".to_owned(), + }; + let problem = + ApplicationProblem::saturated(diagnostic.code.clone(), diagnostic.message.clone()); + + assert_eq!( + problem, + ApplicationProblem::Saturated { + diagnostic, + retry: RetryDirective::AfterDelay, + legal_actions: vec![LegalAction::Retry], + } + ); + assert_eq!( + problem.safe_message(), + "The authority has no remaining capacity." + ); + assert_eq!(problem.reason_code(), "application.saturated"); +} + +#[test] +fn conflict_retries_only_after_revalidate_and_refresh() { + let diagnostic = SafeDiagnostic { + code: "application.conflict".to_owned(), + message: "The request conflicts with current state.".to_owned(), + }; + let problem = ApplicationProblem::conflict(diagnostic.code.clone(), diagnostic.message.clone()); + + assert_eq!( + problem, + ApplicationProblem::Conflict { + diagnostic, + retry: RetryDirective::AfterRevalidate, + legal_actions: vec![LegalAction::Refresh], + } + ); + assert_eq!( + problem.safe_message(), + "The request conflicts with current state." + ); + assert_eq!(problem.reason_code(), "application.conflict"); +} + #[test] fn reset_required_is_a_distinct_non_retryable_terminal() { let problem = ApplicationProblem::reset_required( diff --git a/crates/tracedecay-contracts/src/retained_receipts.rs b/crates/tracedecay-contracts/src/retained_receipts.rs index b04ac77bd5..afd31411ab 100644 --- a/crates/tracedecay-contracts/src/retained_receipts.rs +++ b/crates/tracedecay-contracts/src/retained_receipts.rs @@ -688,7 +688,7 @@ mod tests { use std::collections::BTreeSet; use tracedecay_domain::{ - ActorId, ManifestDigest, ProjectId, RepositoryId, UtcMicros, WorktreeId, canonical_sha256, + ActorId, ProjectId, RepositoryId, UtcMicros, WorktreeId, canonical_sha256, }; use crate::retained_surfaces::{ @@ -704,10 +704,7 @@ mod tests { use super::session_refresh_effect_outcome; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("valid digest") - } + use tracedecay_domain::test_fixtures::digest; fn refresh_effect_settlement( reconciliation_required: bool, diff --git a/crates/tracedecay-contracts/src/retained_surfaces/sdk.rs b/crates/tracedecay-contracts/src/retained_surfaces/sdk.rs index 547788760a..19119ec77a 100644 --- a/crates/tracedecay-contracts/src/retained_surfaces/sdk.rs +++ b/crates/tracedecay-contracts/src/retained_surfaces/sdk.rs @@ -178,6 +178,66 @@ pub enum MessageRelationshipScopeV1 { SubagentsOnly, } +impl MessageRelationshipScopeV1 { + /// Advertised order for MCP schemas and the CLI parser. Labels are the + /// serde `snake_case` names. + pub const WIRE: [&'static str; 3] = [ + Self::All.as_str(), + Self::ParentsOnly.as_str(), + Self::SubagentsOnly.as_str(), + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::All => "all", + Self::ParentsOnly => "parents_only", + Self::SubagentsOnly => "subagents_only", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "all" => Some(Self::All), + "parents_only" => Some(Self::ParentsOnly), + "subagents_only" => Some(Self::SubagentsOnly), + _ => None, + } + } +} + +#[cfg(test)] +mod relationship_scope_wire_tests { + use serde_json::json; + + use super::MessageRelationshipScopeV1; + + #[test] + fn scope_labels_match_serde() { + let scopes = [ + MessageRelationshipScopeV1::All, + MessageRelationshipScopeV1::ParentsOnly, + MessageRelationshipScopeV1::SubagentsOnly, + ]; + let labels: Vec<&str> = scopes + .iter() + .copied() + .map(MessageRelationshipScopeV1::as_str) + .collect(); + assert_eq!(labels.as_slice(), MessageRelationshipScopeV1::WIRE); + for scope in scopes { + assert_eq!( + serde_json::to_value(scope).expect("scope serializes"), + json!(scope.as_str()) + ); + assert_eq!( + MessageRelationshipScopeV1::parse(scope.as_str()), + Some(scope) + ); + } + assert_eq!(MessageRelationshipScopeV1::parse(" all"), None); + } +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum MessageTypeFilterV1 { @@ -186,6 +246,66 @@ pub enum MessageTypeFilterV1 { ToolResult, } +impl MessageTypeFilterV1 { + /// Advertised order for MCP schemas and the CLI parser. Labels are the + /// serde `snake_case` names. + pub const WIRE: [&'static str; 3] = [ + Self::All.as_str(), + Self::DirectUser.as_str(), + Self::ToolResult.as_str(), + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::All => "all", + Self::DirectUser => "direct_user", + Self::ToolResult => "tool_result", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "all" => Some(Self::All), + "direct_user" => Some(Self::DirectUser), + "tool_result" => Some(Self::ToolResult), + _ => None, + } + } +} + +#[cfg(test)] +mod message_type_wire_tests { + use serde_json::json; + + use super::MessageTypeFilterV1; + + #[test] + fn message_type_labels_match_serde() { + let types = [ + MessageTypeFilterV1::All, + MessageTypeFilterV1::DirectUser, + MessageTypeFilterV1::ToolResult, + ]; + let labels: Vec<&str> = types + .iter() + .copied() + .map(MessageTypeFilterV1::as_str) + .collect(); + assert_eq!(labels.as_slice(), MessageTypeFilterV1::WIRE); + for message_type in types { + assert_eq!( + serde_json::to_value(message_type).expect("message type serializes"), + json!(message_type.as_str()) + ); + assert_eq!( + MessageTypeFilterV1::parse(message_type.as_str()), + Some(message_type) + ); + } + assert_eq!(MessageTypeFilterV1::parse("tool"), None); + } +} + /// Exact public input accepted by `tracedecay_message_search`. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -351,6 +471,68 @@ pub enum LcmRoleV1 { Unknown, } +impl LcmRoleV1 { + /// Advertised order for MCP schemas. Labels are [`Self::as_str`], which is + /// the serde `snake_case` name, so the catalog cannot drift from the wire. + pub const WIRE: [&'static str; 5] = [ + Self::System.as_str(), + Self::User.as_str(), + Self::Assistant.as_str(), + Self::Tool.as_str(), + Self::Unknown.as_str(), + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::System => "system", + Self::User => "user", + Self::Assistant => "assistant", + Self::Tool => "tool", + Self::Unknown => "unknown", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "system" => Some(Self::System), + "user" => Some(Self::User), + "assistant" => Some(Self::Assistant), + "tool" => Some(Self::Tool), + "unknown" => Some(Self::Unknown), + _ => None, + } + } +} + +#[cfg(test)] +mod lcm_role_wire_tests { + use serde_json::json; + + use super::LcmRoleV1; + + #[test] + fn role_labels_match_serde_and_reject_host_aliases() { + let roles = [ + LcmRoleV1::System, + LcmRoleV1::User, + LcmRoleV1::Assistant, + LcmRoleV1::Tool, + LcmRoleV1::Unknown, + ]; + let labels: Vec<&str> = roles.iter().copied().map(LcmRoleV1::as_str).collect(); + assert_eq!(labels.as_slice(), LcmRoleV1::WIRE); + for role in roles { + assert_eq!( + serde_json::to_value(role).expect("role serializes"), + json!(role.as_str()) + ); + assert_eq!(LcmRoleV1::parse(role.as_str()), Some(role)); + } + assert_eq!(LcmRoleV1::parse("developer"), None); + assert_eq!(LcmRoleV1::parse(" model"), None); + } +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct LcmGrepRequestV1 { diff --git a/crates/tracedecay-contracts/src/retained_surfaces/sdk/results/session.rs b/crates/tracedecay-contracts/src/retained_surfaces/sdk/results/session.rs index 4c00ce099e..0b5e131812 100644 --- a/crates/tracedecay-contracts/src/retained_surfaces/sdk/results/session.rs +++ b/crates/tracedecay-contracts/src/retained_surfaces/sdk/results/session.rs @@ -1,5 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use tracedecay_domain::{ + HydrationStateV1, SessionSourceCoverageReasonV1, SessionSourceCoverageStateV1, TemporalModeV1, +}; use super::{RetainedErrorV1, RetainedOutcomeStatusV1}; @@ -98,6 +101,21 @@ pub enum HydrationStateResultV1 { UnverifiableLegacy, } +impl From for HydrationStateResultV1 { + fn from(value: HydrationStateV1) -> Self { + match value { + HydrationStateV1::Available => Self::Available, + HydrationStateV1::RetainedButUnavailable => Self::RetainedButUnavailable, + HydrationStateV1::Redacted => Self::Redacted, + HydrationStateV1::Deleted => Self::Deleted, + HydrationStateV1::RetentionExpired => Self::RetentionExpired, + HydrationStateV1::Unauthorized => Self::Unauthorized, + HydrationStateV1::Locked => Self::Locked, + HydrationStateV1::UnverifiableLegacy => Self::UnverifiableLegacy, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct TemporalOmissionV1 { @@ -151,6 +169,17 @@ pub enum SessionCoverageModeV1 { Forensic, } +impl From for SessionCoverageModeV1 { + fn from(value: TemporalModeV1) -> Self { + match value { + TemporalModeV1::Current => Self::Current, + TemporalModeV1::AsOf { cutoff } => Self::AsOf { cutoff: cutoff.0 }, + TemporalModeV1::Evolution => Self::Evolution, + TemporalModeV1::Forensic => Self::Forensic, + } + } +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SessionCoverageStateV1 { @@ -163,6 +192,20 @@ pub enum SessionCoverageStateV1 { Unavailable, } +impl From for SessionCoverageStateV1 { + fn from(value: SessionSourceCoverageStateV1) -> Self { + match value { + SessionSourceCoverageStateV1::Fresh => Self::Fresh, + SessionSourceCoverageStateV1::Stale => Self::Stale, + SessionSourceCoverageStateV1::Partial => Self::Partial, + SessionSourceCoverageStateV1::Locked => Self::Locked, + SessionSourceCoverageStateV1::Redacted => Self::Redacted, + SessionSourceCoverageStateV1::RetentionWithheld => Self::RetentionWithheld, + SessionSourceCoverageStateV1::Unavailable => Self::Unavailable, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct SessionCoverageIntervalV1 { @@ -204,6 +247,31 @@ pub enum SessionCoverageReasonV1 { Unavailable, } +impl From<&SessionSourceCoverageReasonV1> for SessionCoverageReasonV1 { + fn from(value: &SessionSourceCoverageReasonV1) -> Self { + match value { + SessionSourceCoverageReasonV1::CaughtUp => Self::CaughtUp, + SessionSourceCoverageReasonV1::ProjectionBehindSource { lag } => { + Self::ProjectionBehindSource { lag: *lag } + } + SessionSourceCoverageReasonV1::SourceBehindTarget { lag } => { + Self::SourceBehindTarget { lag: *lag } + } + SessionSourceCoverageReasonV1::ProjectionAndSourceBehind { + projection_lag, + source_lag, + } => Self::ProjectionAndSourceBehind { + projection_lag: *projection_lag, + source_lag: *source_lag, + }, + SessionSourceCoverageReasonV1::Locked => Self::Locked, + SessionSourceCoverageReasonV1::Redacted => Self::Redacted, + SessionSourceCoverageReasonV1::RetentionWithheld => Self::RetentionWithheld, + SessionSourceCoverageReasonV1::Unavailable => Self::Unavailable, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct TemporalMetadataV1 { @@ -595,3 +663,44 @@ pub struct WorkflowsResultV1 { #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, } + +#[cfg(test)] +mod coverage_projection_tests { + use tracedecay_domain::{ + HydrationStateV1, SessionSourceCoverageReasonV1, SessionSourceCoverageStateV1, + TemporalModeV1, UtcMicros, + }; + + use super::{ + HydrationStateResultV1, SessionCoverageModeV1, SessionCoverageReasonV1, + SessionCoverageStateV1, + }; + + #[test] + fn coverage_projection_keeps_mode_cutoff_and_status_labels() { + assert_eq!( + SessionCoverageModeV1::from(TemporalModeV1::AsOf { + cutoff: UtcMicros(7), + }), + SessionCoverageModeV1::AsOf { cutoff: 7 } + ); + assert_eq!( + SessionCoverageModeV1::from(TemporalModeV1::Forensic), + SessionCoverageModeV1::Forensic + ); + assert_eq!( + SessionCoverageStateV1::from(SessionSourceCoverageStateV1::RetentionWithheld), + SessionCoverageStateV1::RetentionWithheld + ); + assert_eq!( + SessionCoverageReasonV1::from(&SessionSourceCoverageReasonV1::ProjectionBehindSource { + lag: 4 + }), + SessionCoverageReasonV1::ProjectionBehindSource { lag: 4 } + ); + assert_eq!( + HydrationStateResultV1::from(HydrationStateV1::UnverifiableLegacy), + HydrationStateResultV1::UnverifiableLegacy + ); + } +} diff --git a/crates/tracedecay-contracts/src/retained_surfaces/service.rs b/crates/tracedecay-contracts/src/retained_surfaces/service.rs index f59b51f139..f2fbdf1463 100644 --- a/crates/tracedecay-contracts/src/retained_surfaces/service.rs +++ b/crates/tracedecay-contracts/src/retained_surfaces/service.rs @@ -23,8 +23,7 @@ use crate::retrieval::{ }; use crate::{ ApplicationOperation, ApplicationOutcome, ApplicationProblem, CancellationSignal, - CancellationStage, EffectReceipt, LegalAction, RequestAdmission, RequestContext, - RetryDirective, SafeDiagnostic, + CancellationStage, EffectReceipt, LegalAction, RequestContext, RetryDirective, SafeDiagnostic, }; pub type RetainedSurfaceExecutionFutureV1<'a> = Pin< @@ -219,7 +218,7 @@ impl<'a> RetainedSurfaceServiceV1<'a> { observed_at: UtcMicros, request: &RetainedSurfaceRequestV1, ) -> Result, ApplicationProblem> { - admit(context, observed_at)?; + ApplicationProblem::ensure_admitted(context, observed_at)?; if cancellation.context().token_id != context.cancellation().token_id { return Err(ApplicationProblem::not_found_or_not_authorized( RetryDirective::Never, @@ -515,14 +514,6 @@ pub const fn retained_surface_operation_is_effect(operation: RetainedSurfaceOper ) } -fn admit(context: &RequestContext, observed_at: UtcMicros) -> Result<(), ApplicationProblem> { - match context.admission_at(observed_at) { - RequestAdmission::Admitted => Ok(()), - RequestAdmission::Cancelled => Err(ApplicationProblem::cancelled_before_admission()), - RequestAdmission::TimedOut => Err(ApplicationProblem::timed_out_before_admission()), - } -} - /// Canonical semantic problem projection for a retained runtime failure. pub fn retained_surface_execution_problem( error: RetainedSurfaceExecutionErrorV1, @@ -532,25 +523,17 @@ pub fn retained_surface_execution_problem( RetainedSurfaceExecutionErrorV1::StructuralRefusal(refusal) => { structural_refusal_problem(refusal) } - RetainedSurfaceExecutionErrorV1::InvalidRequest => ApplicationProblem::InvalidRequest { - diagnostic: diagnostic( - "application.retained.invalid-request", - "The retained operation request is invalid.", - ), - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - }, + RetainedSurfaceExecutionErrorV1::InvalidRequest => ApplicationProblem::invalid_request( + "application.retained.invalid-request", + "The retained operation request is invalid.", + ), RetainedSurfaceExecutionErrorV1::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } - RetainedSurfaceExecutionErrorV1::Conflict => ApplicationProblem::Conflict { - diagnostic: diagnostic( - "application.retained.conflict", - "The retained operation conflicts with current state.", - ), - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }, + RetainedSurfaceExecutionErrorV1::Conflict => ApplicationProblem::conflict( + "application.retained.conflict", + "The retained operation conflicts with current state.", + ), RetainedSurfaceExecutionErrorV1::PartialEffect { reason_code, committed_receipt, @@ -576,14 +559,10 @@ pub fn retained_surface_execution_problem( retry: RetryDirective::Never, legal_actions: vec![LegalAction::CorrectRequest], }, - RetainedSurfaceExecutionErrorV1::Saturated => ApplicationProblem::Saturated { - diagnostic: diagnostic( - "application.retained.saturated", - "The retained authority cannot admit more work right now.", - ), - retry: RetryDirective::AfterDelay, - legal_actions: vec![LegalAction::Retry], - }, + RetainedSurfaceExecutionErrorV1::Saturated => ApplicationProblem::saturated( + "application.retained.saturated", + "The retained authority cannot admit more work right now.", + ), // Structural budget refusal uses InvalidRequest so the wire kind stays // unchanged and non-retryable. Callers must narrow scope or limit. RetainedSurfaceExecutionErrorV1::Unavailable { detail } => { @@ -753,11 +732,7 @@ fn structural_refusal_problem(refusal: RetainedStructuralRefusalV1) -> Applicati "The authorized session scope exceeds the cursor manifest byte limit. Narrow the session scope.", ), }; - ApplicationProblem::InvalidRequest { - diagnostic, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request(diagnostic.code, diagnostic.message) } fn diagnostic(code: &'static str, message: &'static str) -> SafeDiagnostic { @@ -779,7 +754,7 @@ mod tests { CapabilityGrantSnapshot, Deadline, EffectTermination, IdempotencyKey, ProblemTerminality, RequestId, ResolvedScope, }; - use tracedecay_domain::{ActorId, ManifestDigest, ProjectId, RepositoryId, WorktreeId}; + use tracedecay_domain::{ActorId, ProjectId, RepositoryId, WorktreeId}; use tracedecay_tool_catalog::EffectClass; struct ErrorMemoryPort(RetainedSurfaceExecutionErrorV1); @@ -799,18 +774,9 @@ mod tests { } } - fn id(value: &str) -> T - where - T: TryFrom, - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("fixture identity is valid") - } + use tracedecay_domain::test_fixtures::id; - fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))) - .expect("fixture digest is valid") - } + use tracedecay_domain::test_fixtures::digest; fn scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-contracts/src/retrieval/callable_code_service.rs b/crates/tracedecay-contracts/src/retrieval/callable_code_service.rs index 29061705d7..8af708668d 100644 --- a/crates/tracedecay-contracts/src/retrieval/callable_code_service.rs +++ b/crates/tracedecay-contracts/src/retrieval/callable_code_service.rs @@ -543,13 +543,8 @@ fn invalid_code_query_outcome_problem() -> ApplicationProblem { } fn invalid_code_query_problem() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic::new( - "application.code-query.invalid-request", - "The callable code-intelligence request is invalid.", - ) - .expect("static safe diagnostic is valid"), - retry: RetryDirective::Never, - legal_actions: Vec::new(), - } + ApplicationProblem::invalid_request_without_action( + "application.code-query.invalid-request", + "The callable code-intelligence request is invalid.", + ) } diff --git a/crates/tracedecay-contracts/src/sdk_catalog.rs b/crates/tracedecay-contracts/src/sdk_catalog.rs index c7b3b9376d..00bd3f1c89 100644 --- a/crates/tracedecay-contracts/src/sdk_catalog.rs +++ b/crates/tracedecay-contracts/src/sdk_catalog.rs @@ -228,11 +228,7 @@ fn project_http_binding( disposition: unavailable_disposition(availability), }); }; - let RouteExposureV1::Public { - binding_id, - route_path, - } = executable.exposure() - else { + let Some((binding_id, route_path)) = executable.public_route() else { return Ok(SdkExecutableBindingAvailabilityV1::Unavailable { operation_id: executable.operation_id().clone(), disposition: ExecutableUnavailableDispositionV1::RouteUnavailable, @@ -244,7 +240,7 @@ fn project_http_binding( binding_id.clone(), sdk_method, SdkTransportBindingV1::Http { - route_path: route_path.clone(), + route_path: route_path.to_owned(), }, )?; Ok(SdkExecutableBindingAvailabilityV1::available(binding)) diff --git a/crates/tracedecay-contracts/src/storage/findings.rs b/crates/tracedecay-contracts/src/storage/findings.rs index a86a03a7ae..04a9fff88b 100644 --- a/crates/tracedecay-contracts/src/storage/findings.rs +++ b/crates/tracedecay-contracts/src/storage/findings.rs @@ -94,17 +94,12 @@ fn evidence( )) } -/// Truncate to at most `max` bytes, cutting at a char boundary so the result -/// stays valid UTF-8 (and a truncated reference identifier stays well formed). +/// Truncate to at most `max` bytes on a char boundary. +/// +/// The cut is `utf8_prefix_at_or_before`. This only owns the `String` the +/// evidence identifiers store. pub(crate) fn truncate_at_char_boundary(value: &str, max: usize) -> String { - if value.len() <= max { - return value.to_string(); - } - let mut end = max; - while end > 0 && !value.is_char_boundary(end) { - end -= 1; - } - value[..end].to_string() + tracedecay_domain::utf8_prefix_at_or_before(value, max).to_string() } fn coverage( diff --git a/crates/tracedecay-contracts/src/storage/telemetry.rs b/crates/tracedecay-contracts/src/storage/telemetry.rs index 87f30c7774..2f708c2f78 100644 --- a/crates/tracedecay-contracts/src/storage/telemetry.rs +++ b/crates/tracedecay-contracts/src/storage/telemetry.rs @@ -122,11 +122,6 @@ impl TableGrowthSampleV1 { pub fn growth_bytes(&self) -> StorageByteSizeV1 { self.current_bytes.saturating_sub(self.previous_bytes) } - - #[must_use] - pub fn is_growing(&self) -> bool { - self.current_bytes > self.previous_bytes - } } /// An owner-configured soft size budget for one store. diff --git a/crates/tracedecay-contracts/src/work.rs b/crates/tracedecay-contracts/src/work.rs index 7831c08db9..4e75df764b 100644 --- a/crates/tracedecay-contracts/src/work.rs +++ b/crates/tracedecay-contracts/src/work.rs @@ -9,7 +9,7 @@ use tracedecay_policy::work_loop::{ WorkRouteOverrideV1, }; -use crate::{ApplicationProblem, LegalAction, RequestContext, RetryDirective, SafeDiagnostic}; +use crate::{ApplicationProblem, RequestContext}; #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum WorkRoutingSnapshotErrorV1 { @@ -75,12 +75,10 @@ pub(crate) fn work_authority( context.actor().clone(), context.grant().digest.clone(), ) - .map_err(|_| ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.work.invalid-history".to_owned(), - message: "The Work command or stored history is invalid.".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], + .map_err(|_| { + ApplicationProblem::invalid_request( + "application.work.invalid-history", + "The Work command or stored history is invalid.", + ) }) } diff --git a/crates/tracedecay-contracts/src/work_artifact_hydration.rs b/crates/tracedecay-contracts/src/work_artifact_hydration.rs index 352345c6f0..cd2ac9dd05 100644 --- a/crates/tracedecay-contracts/src/work_artifact_hydration.rs +++ b/crates/tracedecay-contracts/src/work_artifact_hydration.rs @@ -22,7 +22,7 @@ use crate::work_attempt::{ WorkAttemptListCursorV1, WorkAttemptStorageError, WorkAttemptTopologyBindingV1, WorkAttemptTopologyStateV1, }; -use crate::{ApplicationProblem, LegalAction, RequestContext, RetryDirective, SafeDiagnostic}; +use crate::{ApplicationProblem, RequestContext, RetryDirective, SafeDiagnostic}; /// One page of attempt rows joined with their sealed evidence records, in the /// same stable task/run/attempt identity order as the attempt list, read @@ -149,7 +149,7 @@ where topology: impl FnOnce(&WorkAuthority) -> Result, ) -> Result { if request.page_size == 0 || request.page_size > MAX_WORK_ATTEMPT_LIST_PAGE_SIZE { - return Err(invalid_problem( + return Err(ApplicationProblem::invalid_request( "application.work-artifact-hydration.invalid-page-size", "The Work artifact hydration page size must be between 1 and 1000.", )); @@ -279,14 +279,3 @@ fn page_contract_problem() -> ApplicationProblem { message: "The Work attempt storage returned an inconsistent hydration page.".to_owned(), }) } - -fn invalid_problem(code: &str, message: &str) -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: message.to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } -} diff --git a/crates/tracedecay-contracts/src/work_attempt.rs b/crates/tracedecay-contracts/src/work_attempt.rs index a86a388b73..250252d116 100644 --- a/crates/tracedecay-contracts/src/work_attempt.rs +++ b/crates/tracedecay-contracts/src/work_attempt.rs @@ -22,7 +22,7 @@ use tracedecay_domain::{ }; use crate::work::work_authority; -use crate::{ApplicationProblem, RequestAdmission, RequestContext}; +use crate::{ApplicationProblem, RequestContext}; mod capacity; mod problem; @@ -34,8 +34,8 @@ pub use capacity::{ WorkAttemptCapacityVerdictV1, }; use problem::{ - conflict_problem, contract_problem, denied_problem, invalid_problem, - list_page_contract_problem, not_found_problem, stale_cursor_problem, storage_problem, + contract_problem, denied_problem, list_page_contract_problem, not_found_problem, + stale_cursor_problem, storage_problem, }; pub use product_admission::WorkProductAttemptServiceV1; pub(crate) use product_admission::{ @@ -299,7 +299,7 @@ pub struct WorkAttemptEvidenceRecordV1 { impl WorkAttemptEvidenceRecordV1 { pub fn digest(&self) -> Result { canonical_sha256(&(WORK_ATTEMPT_EVIDENCE_DOMAIN, self)).map_err(|_| { - invalid_problem( + ApplicationProblem::invalid_request( "application.work-attempt.invalid-evidence", "The Work attempt evidence record could not be canonicalized.", ) @@ -491,7 +491,7 @@ where topology: impl FnOnce() -> Result, ) -> Result { if request.page_size == 0 || request.page_size > MAX_WORK_ATTEMPT_LIST_PAGE_SIZE { - return Err(invalid_problem( + return Err(ApplicationProblem::invalid_request( "application.work-attempt.invalid-page-size", "The Work attempt list page size must be between 1 and 1000.", )); @@ -559,7 +559,7 @@ where context: &RequestContext, command: CancelWorkAttemptCommand, ) -> Result { - admit(context, command.occurred_at)?; + ApplicationProblem::ensure_admitted(context, command.occurred_at)?; let authority = work_authority(context)?; let identity = WorkAttemptIdentityV1::new( command.task_id.clone(), @@ -575,7 +575,7 @@ where return if request.request_id() == &command.request_id { Ok(attempt) } else { - Err(conflict_problem( + Err(ApplicationProblem::conflict( "application.work-attempt.cancellation-conflict", "A different cancellation request is already recorded.", )) @@ -587,7 +587,7 @@ where | WorkAttemptStateV1::Running | WorkAttemptStateV1::RecoveryRequired ) { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-attempt.not-cancellable", "Only an open Work attempt can accept a cancellation request.", )); @@ -623,7 +623,7 @@ where context: &RequestContext, command: &ResumeWorkAttemptsCommand, ) -> Result { - admit(context, command.occurred_at)?; + ApplicationProblem::ensure_admitted(context, command.occurred_at)?; let authority = work_authority(context)?; let open = self .attempts @@ -750,7 +750,7 @@ where .load(&authority, identity) .map_err(storage_problem)?; let WorkCancellationStateV1::Requested(request) = attempt.cancellation().clone() else { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-attempt.cancellation-not-requested", "There is no pending cancellation request to acknowledge.", )); @@ -788,7 +788,7 @@ where .map_err(storage_problem)?; let WorkCancellationStateV1::Acknowledged(acknowledgement) = attempt.cancellation().clone() else { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-attempt.cancellation-not-acknowledged", "There is no acknowledged cancellation to escalate.", )); @@ -879,7 +879,7 @@ where .load(&authority, identity) .map_err(storage_problem)?; if attempt.state() != WorkAttemptStateV1::RecoveryRequired { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-attempt.not-recovery-required", "Only an attempt awaiting recovery can be failed this way.", )); @@ -1082,7 +1082,7 @@ pub fn require_registered_work_topology( if snapshot.topology() == registered_topology { return Ok(()); } - Err(conflict_problem( + Err(ApplicationProblem::conflict( "application.work-attempt.topology-conflict", "The Work attempt topology differs from the registered runtime authority.", )) @@ -1128,11 +1128,3 @@ fn cancellation_request(state: &WorkCancellationStateV1) -> Option<&WorkCancella } } } - -fn admit(context: &RequestContext, observed_at: UtcMicros) -> Result<(), ApplicationProblem> { - match context.admission_at(observed_at) { - RequestAdmission::Admitted => Ok(()), - RequestAdmission::Cancelled => Err(ApplicationProblem::cancelled_before_admission()), - RequestAdmission::TimedOut => Err(ApplicationProblem::timed_out_before_admission()), - } -} diff --git a/crates/tracedecay-contracts/src/work_attempt/capacity.rs b/crates/tracedecay-contracts/src/work_attempt/capacity.rs index 7024b32f93..9336a96993 100644 --- a/crates/tracedecay-contracts/src/work_attempt/capacity.rs +++ b/crates/tracedecay-contracts/src/work_attempt/capacity.rs @@ -7,7 +7,7 @@ use tracedecay_domain::{TaskId, WorkTopologyPolicyV1, configuration::TopologyCon use crate::work::work_authority; use crate::{ApplicationProblem, RequestContext}; -use super::{WorkAttemptService, WorkAttemptStoragePort, invalid_problem, storage_problem}; +use super::{WorkAttemptService, WorkAttemptStoragePort, storage_problem}; /// Maximum prospective task identities in one exact capacity census. pub const MAX_WORK_ATTEMPT_CAPACITY_TASKS: usize = u16::MAX as usize; @@ -151,7 +151,7 @@ where } fn capacity_query_problem() -> ApplicationProblem { - invalid_problem( + ApplicationProblem::invalid_request( "application.work-attempt.invalid-capacity-query", "Capacity task identities must be strictly sorted, unique, and within the batch bound.", ) diff --git a/crates/tracedecay-contracts/src/work_attempt/problem.rs b/crates/tracedecay-contracts/src/work_attempt/problem.rs index 82cedd1f05..99a2885e13 100644 --- a/crates/tracedecay-contracts/src/work_attempt/problem.rs +++ b/crates/tracedecay-contracts/src/work_attempt/problem.rs @@ -7,31 +7,26 @@ use super::WorkAttemptStorageError; pub(super) fn storage_problem(error: WorkAttemptStorageError) -> ApplicationProblem { match error { WorkAttemptStorageError::NotFoundOrNotAuthorized => not_found_problem(), - WorkAttemptStorageError::AttemptConflict => conflict_problem( + WorkAttemptStorageError::AttemptConflict => ApplicationProblem::conflict( "application.work-attempt.identity-conflict", "The Work attempt identity was already used with different content.", ), - WorkAttemptStorageError::RunAdmissionConflict => conflict_problem( + WorkAttemptStorageError::RunAdmissionConflict => ApplicationProblem::conflict( "application.work-attempt.run-admission-conflict", "The Work attempt differs from this run's first admitted deadline or topology.", ), - WorkAttemptStorageError::ReservationFenced => conflict_problem( + WorkAttemptStorageError::ReservationFenced => ApplicationProblem::conflict( "application.work-attempt.reservation-fenced", "The Work run control authority fenced new attempt reservations.", ), - WorkAttemptStorageError::FenceConflict => conflict_problem( + WorkAttemptStorageError::FenceConflict => ApplicationProblem::conflict( "application.work-attempt.fence-conflict", "The Work attempt lease fence changed after this transition was prepared.", ), - WorkAttemptStorageError::CapacityExceeded => ApplicationProblem::Saturated { - diagnostic: SafeDiagnostic { - code: "application.work-attempt.capacity-exhausted".to_owned(), - message: "The registered Work topology has no parallel attempt capacity." - .to_owned(), - }, - retry: RetryDirective::AfterDelay, - legal_actions: vec![LegalAction::Retry], - }, + WorkAttemptStorageError::CapacityExceeded => ApplicationProblem::saturated( + "application.work-attempt.capacity-exhausted", + "The registered Work topology has no parallel attempt capacity.", + ), WorkAttemptStorageError::Unavailable => ApplicationProblem::unavailable(SafeDiagnostic { code: "application.work-attempt.storage-unavailable".to_owned(), message: "The Work attempt authority is unavailable.".to_owned(), @@ -40,7 +35,7 @@ pub(super) fn storage_problem(error: WorkAttemptStorageError) -> ApplicationProb } pub(super) fn contract_problem(_error: WorkRuntimeContractError) -> ApplicationProblem { - invalid_problem( + ApplicationProblem::invalid_request( "application.work-attempt.invalid-transition", "The Work attempt command or stored state is invalid.", ) @@ -75,25 +70,3 @@ pub(super) fn denied_problem(code: &str, message: &str) -> ApplicationProblem { legal_actions: vec![LegalAction::Refresh], } } - -pub(super) fn invalid_problem(code: &str, message: &str) -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: message.to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } -} - -pub(super) fn conflict_problem(code: &str, message: &str) -> ApplicationProblem { - ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: message.to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - } -} diff --git a/crates/tracedecay-contracts/src/work_attempt/product_admission.rs b/crates/tracedecay-contracts/src/work_attempt/product_admission.rs index 0608704b46..65190db05f 100644 --- a/crates/tracedecay-contracts/src/work_attempt/product_admission.rs +++ b/crates/tracedecay-contracts/src/work_attempt/product_admission.rs @@ -11,19 +11,18 @@ use tracedecay_domain::{ }; use crate::{ - ApplicationProblem, RequestAdmission, RequestContext, WorkGraphReadPortV1, - WorkGraphReadRequestV1, WorkGraphReadV1, WorkProductApplicationErrorV1, - WorkProductAttemptAdmissionErrorV1, WorkProductAttemptAdmissionOutcomeV1, - WorkProductAttemptAdmissionPortV1, WorkProductAttemptAdmissionV1, WorkProductBindingV1, - WorkProductEventDraftV1, WorkProductOwnerAuthorizationErrorV1, - WorkProductOwnerAuthorizationPortV1, WorkProductPortContextV1, WorkProductRevisionPinsV1, - WorkProductSelectionScopeV1, WorkRelationScopeV1, + ApplicationProblem, RequestContext, WorkGraphReadPortV1, WorkGraphReadRequestV1, + WorkGraphReadV1, WorkProductApplicationErrorV1, WorkProductAttemptAdmissionErrorV1, + WorkProductAttemptAdmissionOutcomeV1, WorkProductAttemptAdmissionPortV1, + WorkProductAttemptAdmissionV1, WorkProductBindingV1, WorkProductEventDraftV1, + WorkProductOwnerAuthorizationErrorV1, WorkProductOwnerAuthorizationPortV1, + WorkProductPortContextV1, WorkProductRevisionPinsV1, WorkProductSelectionScopeV1, + WorkRelationScopeV1, }; use super::{ StartWorkAttemptCommand, WorkAttemptAdmissionKind, WorkAttemptStorageError, - WorkAttemptStoragePort, conflict_problem, contract_problem, denied_problem, not_found_problem, - storage_problem, + WorkAttemptStoragePort, contract_problem, denied_problem, not_found_problem, storage_problem, }; const WORK_PRODUCT_START_INPUT_DIGEST_DOMAIN: &str = @@ -50,11 +49,7 @@ pub(crate) fn admit_product_attempt_request( if !context.allows(binding.capability_id(), binding.use_case_id()) { return Err(not_found_problem()); } - match context.admission_at(observed_at) { - RequestAdmission::Admitted => Ok(()), - RequestAdmission::Cancelled => Err(ApplicationProblem::cancelled_before_admission()), - RequestAdmission::TimedOut => Err(ApplicationProblem::timed_out_before_admission()), - } + ApplicationProblem::ensure_admitted(context, observed_at) } pub(crate) fn replayed_attempt_matches_command( @@ -208,38 +203,28 @@ pub(crate) fn product_admission_problem( ) -> ApplicationProblem { match error { WorkProductAttemptAdmissionErrorV1::InvalidAdmission => { - ApplicationProblem::InvalidRequest { - diagnostic: crate::SafeDiagnostic { - code: "application.work-attempt.invalid-product-admission".to_owned(), - message: "The Work attempt does not match the canonical product graph." - .to_owned(), - }, - retry: crate::RetryDirective::Never, - legal_actions: vec![crate::LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "application.work-attempt.invalid-product-admission", + "The Work attempt does not match the canonical product graph.", + ) } WorkProductAttemptAdmissionErrorV1::NotFoundOrNotAuthorized => not_found_problem(), - WorkProductAttemptAdmissionErrorV1::VersionConflict => conflict_problem( + WorkProductAttemptAdmissionErrorV1::VersionConflict => ApplicationProblem::conflict( "application.work-attempt.product-version-conflict", "The canonical Work product graph changed before attempt admission.", ), - WorkProductAttemptAdmissionErrorV1::IdentityConflict => conflict_problem( + WorkProductAttemptAdmissionErrorV1::IdentityConflict => ApplicationProblem::conflict( "application.work-attempt.identity-conflict", "The Work attempt identity was already used with different content.", ), - WorkProductAttemptAdmissionErrorV1::IdempotencyConflict => conflict_problem( + WorkProductAttemptAdmissionErrorV1::IdempotencyConflict => ApplicationProblem::conflict( "application.work-attempt.idempotency-conflict", "The Work attempt command identity was already used with different input.", ), - WorkProductAttemptAdmissionErrorV1::CapacityExceeded => ApplicationProblem::Saturated { - diagnostic: crate::SafeDiagnostic { - code: "application.work-attempt.capacity-exhausted".to_owned(), - message: "The registered Work topology has no parallel attempt capacity." - .to_owned(), - }, - retry: crate::RetryDirective::AfterDelay, - legal_actions: vec![crate::LegalAction::Retry], - }, + WorkProductAttemptAdmissionErrorV1::CapacityExceeded => ApplicationProblem::saturated( + "application.work-attempt.capacity-exhausted", + "The registered Work topology has no parallel attempt capacity.", + ), WorkProductAttemptAdmissionErrorV1::Unavailable => { ApplicationProblem::unavailable(crate::SafeDiagnostic { code: "application.work-attempt.product-admission-unavailable".to_owned(), @@ -291,7 +276,7 @@ where ) -> Result { admit_product_attempt_request(context, binding, command.occurred_at)?; if command.execution_snapshot.topology() != topology { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-attempt.topology-conflict", "The Work attempt topology does not match the registered runtime authority.", )); @@ -312,7 +297,7 @@ where if admission_kind != WorkAttemptAdmissionKind::Ordinary || !replayed_attempt_matches_command(context, &command, &identity, &existing)? { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-attempt.identity-conflict", "The Work attempt identity was already used with different content.", )); @@ -470,7 +455,7 @@ fn product_problem(error: WorkProductApplicationErrorV1) -> ApplicationProblem { } WorkProductApplicationErrorV1::TimedOut => ApplicationProblem::timed_out_before_admission(), WorkProductApplicationErrorV1::VersionConflict - | WorkProductApplicationErrorV1::RevisionConflict => conflict_problem( + | WorkProductApplicationErrorV1::RevisionConflict => ApplicationProblem::conflict( "application.work-attempt.product-version-conflict", "The canonical Work product graph changed before attempt admission.", ), @@ -482,7 +467,7 @@ fn product_problem(error: WorkProductApplicationErrorV1) -> ApplicationProblem { .to_owned(), }) } - WorkProductApplicationErrorV1::IdempotencyConflict => conflict_problem( + WorkProductApplicationErrorV1::IdempotencyConflict => ApplicationProblem::conflict( "application.work-attempt.product-idempotency-conflict", "The canonical Work product admission identity conflicts.", ), @@ -491,18 +476,12 @@ fn product_problem(error: WorkProductApplicationErrorV1) -> ApplicationProblem { // and the remedy are both specific: the selection covers a slice of // the journal, and widening it is what makes admission possible. WorkProductApplicationErrorV1::SelectionCoverageIncomplete => { - ApplicationProblem::InvalidRequest { - diagnostic: crate::SafeDiagnostic { - code: "application.work-attempt.product-selection-coverage-incomplete" - .to_owned(), - message: "The Work selection covers only part of the owner's journal, so \ + ApplicationProblem::invalid_request( + "application.work-attempt.product-selection-coverage-incomplete", + "The Work selection covers only part of the owner's journal, so \ no attempt can be admitted against it; widen the selection to \ - the relation scopes the excluded events were admitted under." - .to_owned(), - }, - retry: crate::RetryDirective::Never, - legal_actions: vec![crate::LegalAction::CorrectRequest], - } + the relation scopes the excluded events were admitted under.", + ) } WorkProductApplicationErrorV1::EventAuthorityUnavailable | WorkProductApplicationErrorV1::GraphAuthorityUnavailable @@ -517,14 +496,10 @@ fn product_problem(error: WorkProductApplicationErrorV1) -> ApplicationProblem { } fn invalid_start_problem() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: crate::SafeDiagnostic { - code: "application.work-attempt.invalid-product-admission".to_owned(), - message: "The Work attempt command is invalid.".to_owned(), - }, - retry: crate::RetryDirective::Never, - legal_actions: vec![crate::LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "application.work-attempt.invalid-product-admission", + "The Work attempt command is invalid.", + ) } #[cfg(test)] diff --git a/crates/tracedecay-contracts/src/work_attempt/product_synthesis_admission.rs b/crates/tracedecay-contracts/src/work_attempt/product_synthesis_admission.rs index 3e3ecc7dea..02ce016590 100644 --- a/crates/tracedecay-contracts/src/work_attempt/product_synthesis_admission.rs +++ b/crates/tracedecay-contracts/src/work_attempt/product_synthesis_admission.rs @@ -16,7 +16,7 @@ use crate::{ use super::{ CurrentWorkProductAttemptGraphV1, StartWorkAttemptCommand, WorkAttemptStorageError, WorkAttemptStoragePort, WorkSynthesisAdmissionStoragePort, WorkSynthesisInsertOutcome, - accepted_attempt_draft, admit_product_attempt_request, conflict_problem, contract_problem, + accepted_attempt_draft, admit_product_attempt_request, contract_problem, current_work_product_attempt_graph, denied_problem, not_found_problem, product_admission_problem, product_attempt_projection_binding, storage_problem, }; @@ -259,7 +259,7 @@ where } fn identity_conflict() -> ApplicationProblem { - conflict_problem( + ApplicationProblem::conflict( "application.work-attempt.identity-conflict", "The Work attempt identity was already used with different content.", ) diff --git a/crates/tracedecay-contracts/src/work_attempt_effect.rs b/crates/tracedecay-contracts/src/work_attempt_effect.rs index 4fc3a6b27e..4f24120cfb 100644 --- a/crates/tracedecay-contracts/src/work_attempt_effect.rs +++ b/crates/tracedecay-contracts/src/work_attempt_effect.rs @@ -13,7 +13,7 @@ use thiserror::Error; use tracedecay_domain::{UtcMicros, WorkAttemptIdentityV1, WorkAuthority, WorkEffectStateV1}; use crate::work::work_authority; -use crate::{ApplicationProblem, LegalAction, RequestContext, RetryDirective, SafeDiagnostic}; +use crate::{ApplicationProblem, RequestContext, RetryDirective, SafeDiagnostic}; #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum WorkAttemptEffectHolderErrorV1 { @@ -257,15 +257,10 @@ fn effect_problem(error: WorkAttemptEffectStorageErrorV1) -> ApplicationProblem WorkAttemptEffectStorageErrorV1::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } - WorkAttemptEffectStorageErrorV1::Conflict => ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: "application.work-attempt-effect.conflict".to_owned(), - message: "The Work attempt effect receipt conflicts with its prior dispatch." - .to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }, + WorkAttemptEffectStorageErrorV1::Conflict => ApplicationProblem::conflict( + "application.work-attempt-effect.conflict", + "The Work attempt effect receipt conflicts with its prior dispatch.", + ), WorkAttemptEffectStorageErrorV1::Unavailable => { ApplicationProblem::unavailable(SafeDiagnostic { code: "application.work-attempt-effect.unavailable".to_owned(), @@ -276,14 +271,10 @@ fn effect_problem(error: WorkAttemptEffectStorageErrorV1) -> ApplicationProblem } fn invalid_holder_problem() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.work-attempt-effect.invalid-holder".to_owned(), - message: "The Work attempt effect lifecycle time is invalid.".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "application.work-attempt-effect.invalid-holder", + "The Work attempt effect lifecycle time is invalid.", + ) } #[cfg(test)] diff --git a/crates/tracedecay-contracts/src/work_duplicate_adjudication.rs b/crates/tracedecay-contracts/src/work_duplicate_adjudication.rs index d96b988eb4..ea1a0deafe 100644 --- a/crates/tracedecay-contracts/src/work_duplicate_adjudication.rs +++ b/crates/tracedecay-contracts/src/work_duplicate_adjudication.rs @@ -14,10 +14,7 @@ use tracedecay_domain::{ }; use crate::work::work_authority; -use crate::{ - ApplicationProblem, LegalAction, RequestAdmission, RequestContext, RetryDirective, - SafeDiagnostic, -}; +use crate::{ApplicationProblem, RequestContext, RetryDirective, SafeDiagnostic}; pub fn work_duplicate_adjudication_input_digest( command: &WorkDuplicateAdjudicationCommandV1, @@ -200,7 +197,7 @@ where context: &RequestContext, command: WorkDuplicateAdjudicationCommandV1, ) -> Result { - admit(context, command.occurred_at)?; + ApplicationProblem::ensure_admitted(context, command.occurred_at)?; let authority = work_authority(context)?; let command = command.canonicalized(); command.validate().map_err(|_| invalid_problem())?; @@ -240,7 +237,7 @@ where command_id: WorkCommandId, occurred_at: UtcMicros, ) -> Result { - admit(context, occurred_at)?; + ApplicationProblem::ensure_admitted(context, occurred_at)?; let authority = work_authority(context)?; if request.first_attempt == request.second_attempt { return Err(invalid_problem()); @@ -274,7 +271,7 @@ where context: &RequestContext, request: WorkDuplicateAttemptClassificationRequestV1, ) -> Result { - admit(context, request.observed_at)?; + ApplicationProblem::ensure_admitted(context, request.observed_at)?; let authority = work_authority(context)?; let mut attempts = request.attempts; attempts.sort(); @@ -402,23 +399,11 @@ fn classify_complete_attempt_relations( } } -fn admit(context: &RequestContext, observed_at: UtcMicros) -> Result<(), ApplicationProblem> { - match context.admission_at(observed_at) { - RequestAdmission::Admitted => Ok(()), - RequestAdmission::Cancelled => Err(ApplicationProblem::cancelled_before_admission()), - RequestAdmission::TimedOut => Err(ApplicationProblem::timed_out_before_admission()), - } -} - fn invalid_problem() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.work.duplicate-adjudication.invalid".to_owned(), - message: "The duplicate Work adjudication is invalid.".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "application.work.duplicate-adjudication.invalid", + "The duplicate Work adjudication is invalid.", + ) } fn storage_problem(error: WorkDuplicateAdjudicationStorageErrorV1) -> ApplicationProblem { @@ -426,25 +411,15 @@ fn storage_problem(error: WorkDuplicateAdjudicationStorageErrorV1) -> Applicatio WorkDuplicateAdjudicationStorageErrorV1::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } - WorkDuplicateAdjudicationStorageErrorV1::RevisionConflict => ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: "application.work.duplicate-adjudication.revision-conflict".to_owned(), - message: "The duplicate Work adjudication changed after this command was prepared." - .to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }, + WorkDuplicateAdjudicationStorageErrorV1::RevisionConflict => ApplicationProblem::conflict( + "application.work.duplicate-adjudication.revision-conflict", + "The duplicate Work adjudication changed after this command was prepared.", + ), WorkDuplicateAdjudicationStorageErrorV1::IdempotencyConflict => { - ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: "application.work.duplicate-adjudication.idempotency-conflict".to_owned(), - message: "The duplicate Work adjudication command identity was already used with different input." - .to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - } + ApplicationProblem::conflict( + "application.work.duplicate-adjudication.idempotency-conflict", + "The duplicate Work adjudication command identity was already used with different input.", + ) } WorkDuplicateAdjudicationStorageErrorV1::Unavailable => { ApplicationProblem::unavailable(SafeDiagnostic { @@ -465,13 +440,7 @@ mod tests { WorkTopologyGenerationRefV1, }; - fn id(value: &str) -> T - where - T: TryFrom, - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; fn attempt(name: &str) -> WorkAttemptIdentityV1 { WorkAttemptIdentityV1::new( diff --git a/crates/tracedecay-contracts/src/work_evidence/tests.rs b/crates/tracedecay-contracts/src/work_evidence/tests.rs index f54d4c607e..60972a9319 100644 --- a/crates/tracedecay-contracts/src/work_evidence/tests.rs +++ b/crates/tracedecay-contracts/src/work_evidence/tests.rs @@ -5,14 +5,14 @@ use std::sync::{ }; use tracedecay_domain::{ - ActorId, AttemptId, BrainId, InitiativeId, ManifestDigest, MilestoneId, - ObservationSourceIdentityV1, ProjectId, ProviderId, RepositoryId, RetrievalAnchorId, RunId, - SessionId, SourceStoreId, TaskEvidenceLinkId, TaskEvidenceLinkV1, TaskId, UserProfileId, - UtcMicros, WorkAcceptanceCriterionV1, WorkAttemptIdentityV1, WorkGraphChangeV1, - WorkGraphVersionV1, WorkHierarchyV1, WorkInitiativeV1, WorkItemInputV1, WorkItemV1, - WorkMilestoneV1, WorkPlanId, WorkPlanV1, WorkProductEventSequenceV1, WorkProductGraphV1, - WorkProductSourceWatermarkV1, WorkProposalV1, WorkProviderRouteId, WorkProviderRouteV1, - WorkRouteDecisionV1, WorkScoreKindV1, WorkShapeAssessmentV1, WorkSizingV1, WorktreeId, + ActorId, AttemptId, BrainId, InitiativeId, MilestoneId, ObservationSourceIdentityV1, ProjectId, + ProviderId, RepositoryId, RetrievalAnchorId, RunId, SessionId, SourceStoreId, + TaskEvidenceLinkId, TaskEvidenceLinkV1, TaskId, UserProfileId, UtcMicros, + WorkAcceptanceCriterionV1, WorkAttemptIdentityV1, WorkGraphChangeV1, WorkGraphVersionV1, + WorkHierarchyV1, WorkInitiativeV1, WorkItemInputV1, WorkItemV1, WorkMilestoneV1, WorkPlanId, + WorkPlanV1, WorkProductEventSequenceV1, WorkProductGraphV1, WorkProductSourceWatermarkV1, + WorkProposalV1, WorkProviderRouteId, WorkProviderRouteV1, WorkRouteDecisionV1, WorkScoreKindV1, + WorkShapeAssessmentV1, WorkSizingV1, WorktreeId, }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; @@ -22,17 +22,9 @@ use crate::{ DisclosureClass, RequestId, ResolvedScope, WorkAttemptProviderOutcomeV1, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn selection() -> WorkProductSelectionScopeV1 { WorkProductSelectionScopeV1::relations(BTreeSet::from([ diff --git a/crates/tracedecay-contracts/src/work_leak_adjudication.rs b/crates/tracedecay-contracts/src/work_leak_adjudication.rs index 9908312212..6a0a7525b0 100644 --- a/crates/tracedecay-contracts/src/work_leak_adjudication.rs +++ b/crates/tracedecay-contracts/src/work_leak_adjudication.rs @@ -16,8 +16,8 @@ use tracedecay_domain::{ use crate::work::work_authority; use crate::{ - ApplicationProblem, CancellationStage, LegalAction, RequestAdmission, RequestContext, - RetryDirective, SafeDiagnostic, + ApplicationProblem, CancellationStage, LegalAction, RequestContext, RetryDirective, + SafeDiagnostic, }; pub const MAX_WORK_LEAK_EVIDENCE_REFS_V1: usize = 8; @@ -269,7 +269,7 @@ where scan_started_at: UtcMicros, scan_deadline: UtcMicros, ) -> Result { - admit(context, scan_started_at)?; + ApplicationProblem::ensure_admitted(context, scan_started_at)?; if !command.validate() || scan_deadline.0 < scan_started_at.0 || u64::try_from(scan_deadline.0.saturating_sub(scan_started_at.0)) @@ -296,7 +296,7 @@ where .inspect(&authority, &command, scan_started_at, scan_deadline) .map_err(evidence_problem)?; if !evidence.validate_for(&command, scan_started_at, scan_deadline) { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-leak.evidence-conflict", "The bounded leak scan did not prove a valid verdict.", )); @@ -334,34 +334,11 @@ fn canonical_label(value: &str, maximum: usize) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'-' | b'_')) } -fn admit(context: &RequestContext, observed_at: UtcMicros) -> Result<(), ApplicationProblem> { - match context.admission_at(observed_at) { - RequestAdmission::Admitted => Ok(()), - RequestAdmission::Cancelled => Err(ApplicationProblem::cancelled_before_admission()), - RequestAdmission::TimedOut => Err(ApplicationProblem::timed_out_before_admission()), - } -} - fn invalid_problem() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.work-leak.invalid".to_owned(), - message: "The Work leak adjudication request is invalid.".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } -} - -fn conflict_problem(code: &str, message: &str) -> ApplicationProblem { - ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: message.to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - } + ApplicationProblem::invalid_request( + "application.work-leak.invalid", + "The Work leak adjudication request is invalid.", + ) } fn evidence_problem(error: WorkLeakEvidenceErrorV1) -> ApplicationProblem { @@ -369,7 +346,7 @@ fn evidence_problem(error: WorkLeakEvidenceErrorV1) -> ApplicationProblem { WorkLeakEvidenceErrorV1::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } - WorkLeakEvidenceErrorV1::Conflict => conflict_problem( + WorkLeakEvidenceErrorV1::Conflict => ApplicationProblem::conflict( "application.work-leak.evidence-conflict", "The Work leak evidence changed during inspection.", ), @@ -390,11 +367,11 @@ fn storage_problem(error: WorkLeakAdjudicationStorageErrorV1) -> ApplicationProb WorkLeakAdjudicationStorageErrorV1::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } - WorkLeakAdjudicationStorageErrorV1::RevisionConflict => conflict_problem( + WorkLeakAdjudicationStorageErrorV1::RevisionConflict => ApplicationProblem::conflict( "application.work-leak.revision-conflict", "The Work leak adjudication changed before publication.", ), - WorkLeakAdjudicationStorageErrorV1::IdempotencyConflict => conflict_problem( + WorkLeakAdjudicationStorageErrorV1::IdempotencyConflict => ApplicationProblem::conflict( "application.work-leak.idempotency-conflict", "The Work leak command identity was already used with different input.", ), diff --git a/crates/tracedecay-contracts/src/work_placement.rs b/crates/tracedecay-contracts/src/work_placement.rs index c067bfc19c..cce98ffde5 100644 --- a/crates/tracedecay-contracts/src/work_placement.rs +++ b/crates/tracedecay-contracts/src/work_placement.rs @@ -34,10 +34,7 @@ use tracedecay_domain::{ }; use crate::work::work_authority; -use crate::{ - ApplicationProblem, LegalAction, RequestAdmission, RequestContext, RetryDirective, - SafeDiagnostic, -}; +use crate::{ApplicationProblem, LegalAction, RequestContext, RetryDirective, SafeDiagnostic}; #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum WorkPlacementStorageError { @@ -176,7 +173,7 @@ where &WorkPlacementTargetV1, ) -> Result, ) -> Result { - admit(context, request.occurred_at)?; + ApplicationProblem::ensure_admitted(context, request.occurred_at)?; let authority = work_authority(context)?; let identity = WorkPlacementIdentityV1::new(request.task_id, request.run_id); self.evaluate(&authority, identity, request.target, observe) @@ -196,7 +193,7 @@ where &WorkPlacementTargetV1, ) -> Result, ) -> Result { - admit(context, command.occurred_at)?; + ApplicationProblem::ensure_admitted(context, command.occurred_at)?; let authority = work_authority(context)?; let identity = WorkPlacementIdentityV1::new(command.task_id, command.run_id); let existing = self @@ -211,7 +208,7 @@ where { Ok(existing) } else { - Err(conflict_problem( + Err(ApplicationProblem::conflict( "application.work-placement.identity-conflict", "The Work run already holds a different placement.", )) @@ -270,7 +267,7 @@ where &WorkPlacementTargetV1, ) -> Result, ) -> Result { - admit(context, command.occurred_at)?; + ApplicationProblem::ensure_admitted(context, command.occurred_at)?; let authority = work_authority(context)?; let identity = WorkPlacementIdentityV1::new(command.task_id, command.run_id); let current = self @@ -337,14 +334,6 @@ where } } -fn admit(context: &RequestContext, observed_at: UtcMicros) -> Result<(), ApplicationProblem> { - match context.admission_at(observed_at) { - RequestAdmission::Admitted => Ok(()), - RequestAdmission::Cancelled => Err(ApplicationProblem::cancelled_before_admission()), - RequestAdmission::TimedOut => Err(ApplicationProblem::timed_out_before_admission()), - } -} - fn storage_problem(error: WorkPlacementStorageError) -> ApplicationProblem { match error { WorkPlacementStorageError::NotFoundOrNotAuthorized => not_found_problem(), @@ -358,11 +347,11 @@ fn storage_problem(error: WorkPlacementStorageError) -> ApplicationProblem { fn contract_problem(error: WorkPlacementContractError) -> ApplicationProblem { match error { - WorkPlacementContractError::AlreadyReleased => conflict_problem( + WorkPlacementContractError::AlreadyReleased => ApplicationProblem::conflict( "application.work-placement.already-released", "The Work placement was already released.", ), - WorkPlacementContractError::NonMonotonicTransition => conflict_problem( + WorkPlacementContractError::NonMonotonicTransition => ApplicationProblem::conflict( "application.work-placement.non-monotonic", "The Work placement transition is older than the published state.", ), @@ -391,18 +380,14 @@ fn blocked_problem( .map(placement_blocker_name) .collect::>() .join(", "); - ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: "application.work-placement.blocked".to_owned(), - message: format!("The Work placement is blocked by: {named}."), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - } + ApplicationProblem::conflict( + "application.work-placement.blocked", + format!("The Work placement is blocked by: {named}."), + ) } fn authority_conflict_problem() -> ApplicationProblem { - conflict_problem( + ApplicationProblem::conflict( "application.work-placement.authority-conflict", "The Work placement authority version changed after this command was prepared.", ) @@ -412,17 +397,6 @@ fn not_found_problem() -> ApplicationProblem { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } -fn conflict_problem(code: &str, message: &str) -> ApplicationProblem { - ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: message.to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - } -} - /// Wire names for the closed blocker vocabulary. Kept as a match so a new /// variant cannot silently become `"unknown"` through a JSON round-trip. const fn placement_blocker_name(blocker: WorkPlacementBlockerV1) -> &'static str { diff --git a/crates/tracedecay-contracts/src/work_retry.rs b/crates/tracedecay-contracts/src/work_retry.rs index f6ddfbf23e..fcaaf8e954 100644 --- a/crates/tracedecay-contracts/src/work_retry.rs +++ b/crates/tracedecay-contracts/src/work_retry.rs @@ -26,11 +26,10 @@ use crate::work_attempt_effect::{ WorkAttemptEffectResolutionV1, WorkAttemptEffectStorageErrorV1, WorkAttemptEffectStoragePortV1, }; use crate::{ - ApplicationContractError, ApplicationProblem, LegalAction, RequestAdmission, RequestContext, - RetryDirective, SafeDiagnostic, WorkGraphReadPortV1, WorkProductAttemptAdmissionPortV1, - WorkProductAttemptAdmissionV1, WorkProductBindingV1, WorkProductOwnerAuthorizationPortV1, - WorkProductRetryAdmissionV1, WorkProductRevisionPinsV1, WorkflowFanOutAttemptBindingV1, - WorkflowRunAppendRequest, + ApplicationContractError, ApplicationProblem, RequestContext, RetryDirective, SafeDiagnostic, + WorkGraphReadPortV1, WorkProductAttemptAdmissionPortV1, WorkProductAttemptAdmissionV1, + WorkProductBindingV1, WorkProductOwnerAuthorizationPortV1, WorkProductRetryAdmissionV1, + WorkProductRevisionPinsV1, WorkflowFanOutAttemptBindingV1, WorkflowRunAppendRequest, }; const RETRY_INPUT_DIGEST_DOMAIN: &str = "tracedecay.application.work-retry-input.v1"; @@ -376,7 +375,7 @@ where restarted_at: UtcMicros, workflow_rebind: Option, ) -> Result { - admit(context, restarted_at)?; + ApplicationProblem::ensure_admitted(context, restarted_at)?; if !command.validate() { return Err(invalid_problem()); } @@ -393,7 +392,7 @@ where .map_err(storage_problem)? { if replayed.receipt().canonical_input_digest != input_digest { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.idempotency-conflict", "The Work retry command identity was already used with different input.", )); @@ -447,7 +446,7 @@ where .map_err(evidence_problem)?; validate_failure(&command, &original, &failure)?; if failure.observed_at.0 > restarted_at.0 { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.failure-conflict", "The retry failure was observed after retry admission.", )); @@ -518,7 +517,7 @@ fn prepare_workflow_rebind( .get(&rebind.binding.step_id) .is_none_or(|plan| plan.plan_digest != rebind.binding.plan_digest) { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.workflow-binding-conflict", "The workflow child binding no longer matches its admitted plan.", )); @@ -541,7 +540,7 @@ fn prepare_workflow_rebind( && retry_receipt_digest == &receipt.owner_receipt_digest ); if !exact_replay { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.workflow-binding-conflict", "The workflow command identity was already used by another transition.", )); @@ -555,7 +554,7 @@ fn prepare_workflow_rebind( .projection .planned_fan_out_attempt(&receipt.command.original_attempt) .ok_or_else(|| { - conflict_problem( + ApplicationProblem::conflict( "application.work-retry.workflow-binding-conflict", "The original Work attempt is not the active workflow child.", ) @@ -578,7 +577,7 @@ fn prepare_workflow_rebind( }, ) .map_err(|_| { - conflict_problem( + ApplicationProblem::conflict( "application.work-retry.workflow-binding-conflict", "The workflow child retry transition is no longer authorized.", ) @@ -600,7 +599,7 @@ fn require_product_retry_admission( if !item.is_execution_admitted() || item.accepted_proposal() != Some(attempt.projection_binding().accepted_proposal()) { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.product-conflict", "The canonical Work product graph no longer admits this retry.", )); @@ -628,7 +627,7 @@ where { Ok(()) } else { - Err(conflict_problem( + Err(ApplicationProblem::conflict( "application.work-retry.effect-unknown", "The original Work attempt has an unresolved non-repeatable effect.", )) @@ -653,7 +652,7 @@ where if original.execution().execution_snapshot().topology() != topology || restarted_at.0 >= original.execution().deadline().0 { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.admission-conflict", "The original Work admission no longer permits this retry.", )); @@ -734,14 +733,14 @@ fn validate_failure( failure: &VerifiedWorkRetryFailureV1, ) -> Result<(), ApplicationProblem> { if failure.selector != command.failure || failure.evidence_digest.validate().is_err() { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.failure-conflict", "The resolved failure does not authorize this Work retry.", )); } if command.failure.cause == WorkRetryCauseV1::RestartRecoveryRequired { let WorkRecoveryStateV1::RecoveryRequired { observed_at, .. } = original.recovery() else { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.recovery-conflict", "The original Work attempt no longer requires restart recovery.", )); @@ -749,7 +748,7 @@ fn validate_failure( if original.state() != WorkAttemptStateV1::RecoveryRequired || observed_at != &failure.observed_at { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.recovery-conflict", "The restart recovery evidence no longer matches the original attempt.", )); @@ -757,7 +756,7 @@ fn validate_failure( return Ok(()); } let Some(terminal) = original.terminal() else { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.original-not-terminal", "A runtime failure retry requires terminal evidence.", )); @@ -781,7 +780,7 @@ fn validate_failure( } => (evidence_digest, observed_at, false), }; if !eligible || digest != &failure.evidence_digest || observed_at != &failure.observed_at { - return Err(conflict_problem( + return Err(ApplicationProblem::conflict( "application.work-retry.runtime-evidence-conflict", "The runtime failure no longer matches the original terminal receipt.", )); @@ -789,23 +788,11 @@ fn validate_failure( Ok(()) } -fn admit(context: &RequestContext, observed_at: UtcMicros) -> Result<(), ApplicationProblem> { - match context.admission_at(observed_at) { - RequestAdmission::Admitted => Ok(()), - RequestAdmission::Cancelled => Err(ApplicationProblem::cancelled_before_admission()), - RequestAdmission::TimedOut => Err(ApplicationProblem::timed_out_before_admission()), - } -} - fn invalid_problem() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.work-retry.invalid".to_owned(), - message: "The Work retry command is invalid.".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "application.work-retry.invalid", + "The Work retry command is invalid.", + ) } fn retry_receipt_problem(_error: ApplicationContractError) -> ApplicationProblem { @@ -815,17 +802,6 @@ fn retry_receipt_problem(_error: ApplicationContractError) -> ApplicationProblem }) } -fn conflict_problem(code: &str, message: &str) -> ApplicationProblem { - ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: message.to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - } -} - fn not_found_problem() -> ApplicationProblem { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } @@ -833,7 +809,7 @@ fn not_found_problem() -> ApplicationProblem { fn evidence_problem(error: WorkRetryEvidenceErrorV1) -> ApplicationProblem { match error { WorkRetryEvidenceErrorV1::NotFoundOrNotAuthorized => not_found_problem(), - WorkRetryEvidenceErrorV1::Conflict => conflict_problem( + WorkRetryEvidenceErrorV1::Conflict => ApplicationProblem::conflict( "application.work-retry.failure-conflict", "The Work retry failure evidence changed.", ), @@ -847,17 +823,17 @@ fn evidence_problem(error: WorkRetryEvidenceErrorV1) -> ApplicationProblem { fn storage_problem(error: WorkAttemptStorageError) -> ApplicationProblem { match error { WorkAttemptStorageError::NotFoundOrNotAuthorized => not_found_problem(), - WorkAttemptStorageError::CapacityExceeded => conflict_problem( + WorkAttemptStorageError::CapacityExceeded => ApplicationProblem::conflict( "application.work-retry.capacity-exhausted", "Work retry capacity is exhausted.", ), - WorkAttemptStorageError::ReservationFenced => conflict_problem( + WorkAttemptStorageError::ReservationFenced => ApplicationProblem::conflict( "application.work-retry.reservation-fenced", "The Work run does not currently admit a retry reservation.", ), WorkAttemptStorageError::AttemptConflict | WorkAttemptStorageError::RunAdmissionConflict - | WorkAttemptStorageError::FenceConflict => conflict_problem( + | WorkAttemptStorageError::FenceConflict => ApplicationProblem::conflict( "application.work-retry.conflict", "The Work retry authority changed.", ), @@ -873,7 +849,7 @@ fn effect_storage_problem(error: WorkAttemptEffectStorageErrorV1) -> Application WorkAttemptEffectStorageErrorV1::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } - WorkAttemptEffectStorageErrorV1::Conflict => conflict_problem( + WorkAttemptEffectStorageErrorV1::Conflict => ApplicationProblem::conflict( "application.work-retry.effect-conflict", "The original Work attempt effect receipt changed.", ), diff --git a/crates/tracedecay-contracts/src/work_run_control.rs b/crates/tracedecay-contracts/src/work_run_control.rs index 97b51937e3..1a8f83b2b7 100644 --- a/crates/tracedecay-contracts/src/work_run_control.rs +++ b/crates/tracedecay-contracts/src/work_run_control.rs @@ -39,10 +39,7 @@ use tracedecay_domain::{ }; use crate::work::work_authority; -use crate::{ - ApplicationProblem, LegalAction, RequestAdmission, RequestContext, RetryDirective, - SafeDiagnostic, -}; +use crate::{ApplicationProblem, RequestContext, RetryDirective, SafeDiagnostic}; #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum WorkRunControlStorageError { @@ -304,7 +301,7 @@ where command: PauseWorkRunCommand, ) -> Result { hotpath::measure_block!("application.work.run_control.pause", { - admit(context, command.occurred_at)?; + ApplicationProblem::ensure_admitted(context, command.occurred_at)?; let authority = work_authority(context)?; let frontier = self .storage @@ -399,7 +396,7 @@ where command: ResumeWorkRunCommand, ) -> Result { hotpath::measure_block!("application.work.run_control.resume", { - admit(context, command.occurred_at)?; + ApplicationProblem::ensure_admitted(context, command.occurred_at)?; let authority = work_authority(context)?; let frontier = self .storage @@ -409,7 +406,7 @@ where let current = frontier.control.clone().ok_or_else(|| { // A run that was never paused has nothing to resume, and // answering "resumed" would be a false receipt. - conflict_problem( + ApplicationProblem::conflict( "application.work-run-control.not-paused", "The Work run has no published control state to resume.", ) @@ -501,7 +498,7 @@ where // are indistinguishable from an idle one. hotpath::gauge!("application.work.run_control.reservation.denied_paused") .inc(1u64); - Err(conflict_problem( + Err(ApplicationProblem::conflict( "application.work-run-control.paused", "The Work run is paused, so no new attempt reservation is admitted.", )) @@ -583,14 +580,6 @@ fn check_expected( } } -fn admit(context: &RequestContext, observed_at: UtcMicros) -> Result<(), ApplicationProblem> { - match context.admission_at(observed_at) { - RequestAdmission::Admitted => Ok(()), - RequestAdmission::Cancelled => Err(ApplicationProblem::cancelled_before_admission()), - RequestAdmission::TimedOut => Err(ApplicationProblem::timed_out_before_admission()), - } -} - fn storage_problem(error: WorkRunControlStorageError) -> ApplicationProblem { match error { WorkRunControlStorageError::NotFoundOrNotAuthorized => not_found_problem(), @@ -606,15 +595,15 @@ fn storage_problem(error: WorkRunControlStorageError) -> ApplicationProblem { fn contract_problem(error: WorkRunControlContractError) -> ApplicationProblem { match error { - WorkRunControlContractError::AlreadyPaused => conflict_problem( + WorkRunControlContractError::AlreadyPaused => ApplicationProblem::conflict( "application.work-run-control.already-paused", "The Work run is already paused.", ), - WorkRunControlContractError::NotPaused => conflict_problem( + WorkRunControlContractError::NotPaused => ApplicationProblem::conflict( "application.work-run-control.not-paused", "The Work run is not paused.", ), - WorkRunControlContractError::NonMonotonicTransition => conflict_problem( + WorkRunControlContractError::NonMonotonicTransition => ApplicationProblem::conflict( "application.work-run-control.non-monotonic", "The Work run control transition is older than the published state.", ), @@ -625,28 +614,19 @@ fn contract_problem(error: WorkRunControlContractError) -> ApplicationProblem { | WorkRunControlContractError::DuplicateFencedAttempt | WorkRunControlContractError::InvalidBlockedIntervalRevision | WorkRunControlContractError::InvalidBlockedIntervalClosure => { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.work-run-control.invalid-transition".to_owned(), - message: "The Work run control command or stored state is invalid.".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "application.work-run-control.invalid-transition", + "The Work run control command or stored state is invalid.", + ) } } } fn invalid_pending_interval_limit_problem() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.work-run-control.invalid-pending-interval-limit".to_owned(), - message: "The Work blocked-interval recovery page limit must be between 1 and 128." - .to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "application.work-run-control.invalid-pending-interval-limit", + "The Work blocked-interval recovery page limit must be between 1 and 128.", + ) } fn workflow_steps_for_live_attempts( @@ -673,19 +653,14 @@ fn workflow_steps_for_live_attempts( } fn invalid_open_interval_durable_problem() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.work-run-control.open-interval-durable".to_owned(), - message: "Only a settled Work blocked interval can be marked durably delivered." - .to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "application.work-run-control.open-interval-durable", + "Only a settled Work blocked interval can be marked durably delivered.", + ) } fn authority_conflict_problem() -> ApplicationProblem { - conflict_problem( + ApplicationProblem::conflict( "application.work-run-control.authority-conflict", "The Work run control authority version changed after this command was prepared.", ) @@ -694,14 +669,3 @@ fn authority_conflict_problem() -> ApplicationProblem { fn not_found_problem() -> ApplicationProblem { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } - -fn conflict_problem(code: &str, message: &str) -> ApplicationProblem { - ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: message.to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - } -} diff --git a/crates/tracedecay-contracts/src/work_synthesis.rs b/crates/tracedecay-contracts/src/work_synthesis.rs index dfc9f2bf64..c848f38257 100644 --- a/crates/tracedecay-contracts/src/work_synthesis.rs +++ b/crates/tracedecay-contracts/src/work_synthesis.rs @@ -33,9 +33,9 @@ use crate::work_attempt::{ }; use crate::workflow_synthesis::WorkflowSynthesisDraft; use crate::{ - ApplicationProblem, LegalAction, RequestContext, RetryDirective, SafeDiagnostic, - WorkGraphReadPortV1, WorkProductAttemptAdmissionPortV1, WorkProductBindingV1, - WorkProductOwnerAuthorizationPortV1, WorkProductRevisionPinsV1, + ApplicationProblem, RequestContext, SafeDiagnostic, WorkGraphReadPortV1, + WorkProductAttemptAdmissionPortV1, WorkProductBindingV1, WorkProductOwnerAuthorizationPortV1, + WorkProductRevisionPinsV1, }; const WORK_SYNTHESIS_SOURCE_SET_DOMAIN: &str = @@ -218,7 +218,7 @@ where registered_topology, )?; if command.sources.is_empty() { - return Err(invalid_problem( + return Err(ApplicationProblem::invalid_request( "application.work-synthesis.no-sources", "A synthesis attempt must name at least one source attempt.", )); @@ -226,7 +226,7 @@ where let mut seen = BTreeSet::new(); for source in &command.sources { if !seen.insert(source.clone()) { - return Err(invalid_problem( + return Err(ApplicationProblem::invalid_request( "application.work-synthesis.duplicate-source", "A synthesis source attempt was named more than once.", )); @@ -235,7 +235,7 @@ where && source.run_id() == &command.start.run_id && source.attempt_id() == &command.start.attempt_id { - return Err(invalid_problem( + return Err(ApplicationProblem::invalid_request( "application.work-synthesis.self-citation", "A synthesis attempt cannot name itself as a source.", )); @@ -394,17 +394,6 @@ fn evidence_groups(sources: &[WorkSynthesisSourceEnvelopeV1]) -> Vec ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: message.to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } -} - fn contract_problem() -> ApplicationProblem { ApplicationProblem::unavailable(SafeDiagnostic { code: "application.work-synthesis.evidence-inconsistent".to_owned(), diff --git a/crates/tracedecay-contracts/tests/contracts_suite/common/mod.rs b/crates/tracedecay-contracts/tests/contracts_suite/common/mod.rs index dfe55961f3..af2fa312d3 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/common/mod.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/common/mod.rs @@ -4,7 +4,6 @@ mod work_product_attempt_support; use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use std::fmt; use std::sync::{Arc, Mutex}; use tracedecay_contracts::{ @@ -57,27 +56,13 @@ pub const SHA256_B: &str = const SOURCE_AUTHORIZATION_TRUTH_TABLES: &str = include_str!("../../../../tracedecay-policy/tests/fixtures/source_authorization/core.json"); -pub fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture identity is canonical") -} +pub use tracedecay_domain::test_fixtures::id; pub fn digest(value: &str) -> ManifestDigest { ManifestDigest::new(value).expect("fixture digest is canonical") } -/// Platform-absolute fixture root: the work and registered-root contracts -/// require `Path::is_absolute`, which a bare `/...` literal fails on Windows. -pub fn fixture_abs_root(posix: &str) -> String { - if cfg!(windows) { - format!("C:{}", posix.replace('/', "\\")) - } else { - posix.to_owned() - } -} +pub use tracedecay_domain::test_fixtures::fixture_abs_root; /// Canonical digest fixture for the Work-attempt product journey. pub fn work_digest(value: char) -> ManifestDigest { diff --git a/crates/tracedecay-contracts/tests/contracts_suite/execution_topology_metrics.rs b/crates/tracedecay-contracts/tests/contracts_suite/execution_topology_metrics.rs index 1744e523ca..eefca511f0 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/execution_topology_metrics.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/execution_topology_metrics.rs @@ -31,13 +31,7 @@ use tracedecay_domain::{ }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn context() -> RequestContext { context_with( diff --git a/crates/tracedecay-contracts/tests/contracts_suite/execution_topology_rollup.rs b/crates/tracedecay-contracts/tests/contracts_suite/execution_topology_rollup.rs index b07f96a820..4878b6b2eb 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/execution_topology_rollup.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/execution_topology_rollup.rs @@ -31,13 +31,7 @@ mod stack_drift; const DAY_MICROS: i64 = 86_400_000_000; const SCOPE: &str = "project.execution-topology-rollup"; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn rollup_context() -> RequestContext { let scope = ResolvedScope::new( id::(SCOPE), diff --git a/crates/tracedecay-contracts/tests/contracts_suite/handoff_open.rs b/crates/tracedecay-contracts/tests/contracts_suite/handoff_open.rs index 5da701f329..d6b930acf7 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/handoff_open.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/handoff_open.rs @@ -173,9 +173,7 @@ impl HandoffOpenTargetPort for CurrentTargets { } } -fn digest(fill: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", fill.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-contracts/tests/contracts_suite/multi_root_query.rs b/crates/tracedecay-contracts/tests/contracts_suite/multi_root_query.rs index 60e2fc75e2..ad3993a764 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/multi_root_query.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/multi_root_query.rs @@ -1,5 +1,4 @@ use std::collections::BTreeSet; -use std::fmt; use std::sync::Mutex; use schemars::schema_for; @@ -19,17 +18,9 @@ use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; const CAPABILITY: &str = "capability.multi-root.query"; const USE_CASE: &str = "use-case.multi-root.query"; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context(worktree: &str, suffix: &str) -> RequestContext { let scope = ResolvedScope::new( diff --git a/crates/tracedecay-contracts/tests/contracts_suite/multi_root_scope_set.rs b/crates/tracedecay-contracts/tests/contracts_suite/multi_root_scope_set.rs index 2e617f6788..d876d9d366 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/multi_root_scope_set.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/multi_root_scope_set.rs @@ -1,7 +1,6 @@ use crate::common; use std::collections::BTreeSet; -use std::fmt; use serde_json::json; use tracedecay_contracts::{ @@ -11,25 +10,17 @@ use tracedecay_contracts::{ RequestId, ResolvedScope, SharedProfileStoreLocatorV1, }; use tracedecay_domain::{ - ActorId, BrainId, ManifestDigest, ProjectId, RefId, RepositoryId, ScopeSetId, ScopeSetRevision, - UserProfileId, UtcMicros, WorktreeId, + ActorId, BrainId, ProjectId, RefId, RepositoryId, ScopeSetId, ScopeSetRevision, UserProfileId, + UtcMicros, WorktreeId, }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; const CAPABILITY: &str = "capability.multi-root.query"; const USE_CASE: &str = "use-case.multi-root.query"; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context(worktree: &str, suffix: &str) -> RequestContext { context_at("project.fixture", "repository.fixture", worktree, suffix) diff --git a/crates/tracedecay-contracts/tests/contracts_suite/policy_composition.rs b/crates/tracedecay-contracts/tests/contracts_suite/policy_composition.rs index 774a4bc294..8a5f662f36 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/policy_composition.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/policy_composition.rs @@ -9,8 +9,7 @@ use tracedecay_contracts::{ }; use tracedecay_domain::configuration::{ConfigurationRevisionId, ConfigurationSnapshotV1}; use tracedecay_domain::{ - ActorId, ManifestDigest, ProjectId, RefId, RepositoryId, ShardId, UtcMicros, VectorWatermark, - WorktreeId, + ActorId, ProjectId, RefId, RepositoryId, ShardId, UtcMicros, VectorWatermark, WorktreeId, }; use tracedecay_policy::routing::{ CapabilityAvailabilityV1, CapabilityEffectClassV1, CapabilityRoutingDispositionV1, @@ -18,17 +17,9 @@ use tracedecay_policy::routing::{ }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn evaluation_context_for( capability: CapabilityId, diff --git a/crates/tracedecay-contracts/tests/contracts_suite/work_artifact_hydration_service.rs b/crates/tracedecay-contracts/tests/contracts_suite/work_artifact_hydration_service.rs index 1aa740671e..2982fabf72 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/work_artifact_hydration_service.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/work_artifact_hydration_service.rs @@ -15,22 +15,14 @@ use tracedecay_contracts::{ WorkAttemptTopologyBindingV1, WorkAttemptTopologyStateV1, }; use tracedecay_domain::{ - ActorId, ManifestDigest, ProjectId, ProviderId, RepositoryId, UtcMicros, WorkArtifactRefV1, + ActorId, ProjectId, ProviderId, RepositoryId, UtcMicros, WorkArtifactRefV1, WorkAttemptIdentityV1, WorkAuthority, WorkProviderRouteId, WorkProviderRouteV1, WorktreeId, }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context(project: &str) -> RequestContext { let scope = ResolvedScope::new( diff --git a/crates/tracedecay-contracts/tests/contracts_suite/work_placement_service.rs b/crates/tracedecay-contracts/tests/contracts_suite/work_placement_service.rs index 7369ba6e59..d8c744b159 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/work_placement_service.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/work_placement_service.rs @@ -24,24 +24,16 @@ use tracedecay_contracts::{ WorkPlacementStorageError, WorkPlacementStoragePort, }; use tracedecay_domain::{ - ActorId, ManifestDigest, ProjectId, RepositoryId, RunId, TaskId, UtcMicros, WorkAuthority, + ActorId, ProjectId, RepositoryId, RunId, TaskId, UtcMicros, WorkAuthority, WorkPlacementBlockerV1, WorkPlacementIdentityV1, WorkPlacementKindV1, WorkPlacementObservationV1, WorkPlacementStateV1, WorkPlacementTargetV1, WorkPlacementV1, WorktreeId, }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context(actor: &str) -> RequestContext { let scope = ResolvedScope::new( diff --git a/crates/tracedecay-contracts/tests/contracts_suite/work_product_application.rs b/crates/tracedecay-contracts/tests/contracts_suite/work_product_application.rs index 7686d0ca80..674b40c01b 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/work_product_application.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/work_product_application.rs @@ -35,17 +35,9 @@ use tracedecay_domain::{ }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn binding() -> WorkProductBindingV1 { WorkProductBindingV1::new( diff --git a/crates/tracedecay-contracts/tests/contracts_suite/work_proposal_planner.rs b/crates/tracedecay-contracts/tests/contracts_suite/work_proposal_planner.rs index f48bb65b3a..17efabfdc4 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/work_proposal_planner.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/work_proposal_planner.rs @@ -21,11 +21,11 @@ use tracedecay_contracts::{ WorkRoutingSnapshotErrorV1, WorkRoutingSnapshotPortV1, WorkRoutingSnapshotV1, }; use tracedecay_domain::{ - ActorId, InitiativeId, ManifestDigest, MilestoneId, ProjectId, ProjectionGenerationId, - ProposalId, RepositoryId, TaskId, UtcMicros, WorkApprovalPolicy, WorkEgressPolicy, - WorkExecutionLimits, WorkFallbackTopology, WorkFilesystemPolicy, WorkGraphVersionV1, - WorkHierarchyV1, WorkInitiativeV1, WorkItemInputV1, WorkItemV1, WorkMilestoneV1, WorkPlanId, - WorkPlanV1, WorkProductGraphV1, WorkProductProjectionBundleV1, WorkProductSourceWatermarkV1, + ActorId, InitiativeId, MilestoneId, ProjectId, ProjectionGenerationId, ProposalId, + RepositoryId, TaskId, UtcMicros, WorkApprovalPolicy, WorkEgressPolicy, WorkExecutionLimits, + WorkFallbackTopology, WorkFilesystemPolicy, WorkGraphVersionV1, WorkHierarchyV1, + WorkInitiativeV1, WorkItemInputV1, WorkItemV1, WorkMilestoneV1, WorkPlanId, WorkPlanV1, + WorkProductGraphV1, WorkProductProjectionBundleV1, WorkProductSourceWatermarkV1, WorkProjectionSequenceV1, WorkRouteExecutionProfileV1, WorkRuntimeProjectionCoverageV1, WorkRuntimeProjectionV1, WorkSandboxPolicy, WorktreeId, }; @@ -44,17 +44,9 @@ const CREATED_AT: UtcMicros = UtcMicros(10); /// incomparable and never enter the calibration cohort. const EVALUATED_AT: UtcMicros = UtcMicros(50); -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context(project: &str) -> RequestContext { let scope = ResolvedScope::new( diff --git a/crates/tracedecay-contracts/tests/contracts_suite/work_run_control_service.rs b/crates/tracedecay-contracts/tests/contracts_suite/work_run_control_service.rs index 90ea815213..622fae4494 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/work_run_control_service.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/work_run_control_service.rs @@ -24,25 +24,17 @@ use tracedecay_contracts::{ WorkRunControlStoragePort, WorkRunLiveAttemptV1, }; use tracedecay_domain::{ - ActorId, AttemptId, ManifestDigest, ProjectId, RepositoryId, RunId, TaskId, UtcMicros, - WorkAuthority, WorkBlockedIntervalReceiptV1, WorkRunControlAuthorityV1, WorkRunControlReasonV1, + ActorId, AttemptId, ProjectId, RepositoryId, RunId, TaskId, UtcMicros, WorkAuthority, + WorkBlockedIntervalReceiptV1, WorkRunControlAuthorityV1, WorkRunControlReasonV1, WorkRunControlStateV1, WorkRunControlV1, WorkflowStepId, WorktreeId, }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; const ADMITTED_DEADLINE: UtcMicros = UtcMicros(10_000); -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn task() -> TaskId { id::("task.run-control") diff --git a/crates/tracedecay-contracts/tests/contracts_suite/work_synthesis_service.rs b/crates/tracedecay-contracts/tests/contracts_suite/work_synthesis_service.rs index 6c92df29da..dfe050a65c 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/work_synthesis_service.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/work_synthesis_service.rs @@ -34,17 +34,9 @@ use tracedecay_domain::{ }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context(project: &str, actor: &str) -> RequestContext { let scope = ResolvedScope::new( diff --git a/crates/tracedecay-contracts/tests/contracts_suite/work_topology_view.rs b/crates/tracedecay-contracts/tests/contracts_suite/work_topology_view.rs index 9ef7c55007..c47efc2084 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/work_topology_view.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/work_topology_view.rs @@ -22,27 +22,19 @@ use tracedecay_contracts::{ }; use tracedecay_domain::configuration::safe_work_topology_policy_v1; use tracedecay_domain::{ - ActorId, CommitId, ConfigurationRevisionId, ConfigurationSnapshotId, ManifestDigest, ProjectId, - ProviderId, RefId, RepositoryId, TaskId, UtcMicros, WorkApprovalPolicy, WorkAuthority, - WorkEffectStateV1, WorkEgressPolicy, WorkExecutableReference, WorkExecutionLimits, - WorkExecutionSnapshot, WorkExecutionSnapshotInput, WorkFallbackTopology, WorkFilesystemPolicy, + ActorId, CommitId, ConfigurationRevisionId, ConfigurationSnapshotId, ProjectId, ProviderId, + RefId, RepositoryId, TaskId, UtcMicros, WorkApprovalPolicy, WorkAuthority, WorkEffectStateV1, + WorkEgressPolicy, WorkExecutableReference, WorkExecutionLimits, WorkExecutionSnapshot, + WorkExecutionSnapshotInput, WorkFallbackTopology, WorkFilesystemPolicy, WorkPlacementIdentityV1, WorkPlacementKindV1, WorkPlacementObservationV1, WorkPlacementStateV1, WorkPlacementTargetV1, WorkPlacementV1, WorkProviderBackendV1, WorkProviderProtocol, WorkProviderRouteId, WorkProviderRouteV1, WorkSandboxPolicy, WorkflowOperationRef, WorktreeId, }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context(project: &str) -> RequestContext { let scope = ResolvedScope::new( diff --git a/crates/tracedecay-contracts/tests/contracts_suite/workflow_coordination.rs b/crates/tracedecay-contracts/tests/contracts_suite/workflow_coordination.rs index aea9d981f5..51a42d1616 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/workflow_coordination.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/workflow_coordination.rs @@ -20,17 +20,9 @@ use tracedecay_domain::{ }; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn workflow_context( actor: ActorId, diff --git a/crates/tracedecay-contracts/tests/contracts_suite/workflow_dag_execution.rs b/crates/tracedecay-contracts/tests/contracts_suite/workflow_dag_execution.rs index b7f2c091ad..e1c29de4fc 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/workflow_dag_execution.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/workflow_dag_execution.rs @@ -8,26 +8,18 @@ use tracedecay_contracts::{ }; use tracedecay_domain::configuration::safe_work_topology_policy_v1; use tracedecay_domain::{ - AttemptId, ManifestDigest, ProjectId, ProviderId, RunId, TaskId, UtcMicros, WorkArtifactId, - WorkArtifactRefV1, WorkAttemptIdentityV1, WorkCommandId, WorkProviderBackendV1, - WorkProviderRouteId, WorkProviderRouteV1, WorkflowDefinition, WorkflowDefinitionId, - WorkflowOperationRef, WorkflowOutputArtifact, WorkflowOutputName, WorkflowOutputReference, - WorkflowPlacementReceipt, WorkflowRunCommand, WorkflowRunEvent, WorkflowRunEventContext, - WorkflowRunProjection, WorkflowRunStatus, WorkflowStep, WorkflowStepEffectOutcome, - WorkflowStepEffectReceipt, WorkflowStepId, WorkflowStepOutput, + AttemptId, ProjectId, ProviderId, RunId, TaskId, UtcMicros, WorkArtifactId, WorkArtifactRefV1, + WorkAttemptIdentityV1, WorkCommandId, WorkProviderBackendV1, WorkProviderRouteId, + WorkProviderRouteV1, WorkflowDefinition, WorkflowDefinitionId, WorkflowOperationRef, + WorkflowOutputArtifact, WorkflowOutputName, WorkflowOutputReference, WorkflowPlacementReceipt, + WorkflowRunCommand, WorkflowRunEvent, WorkflowRunEventContext, WorkflowRunProjection, + WorkflowRunStatus, WorkflowStep, WorkflowStepEffectOutcome, WorkflowStepEffectReceipt, + WorkflowStepId, WorkflowStepOutput, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context(command: &str, input: char, occurred_at: i64) -> WorkflowRunEventContext { WorkflowRunEventContext { diff --git a/crates/tracedecay-contracts/tests/contracts_suite/workflow_fan_out_census.rs b/crates/tracedecay-contracts/tests/contracts_suite/workflow_fan_out_census.rs index edcfc0c9d0..470b301347 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/workflow_fan_out_census.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/workflow_fan_out_census.rs @@ -31,13 +31,7 @@ use tracedecay_domain::{ WorkflowRunEventContext, WorkflowRunProjection, WorkflowStep, WorkflowStepId, WorktreeId, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn digest(byte: char) -> ManifestDigest { let hex = format!("{:02x}", u32::from(byte) & 0xff); diff --git a/crates/tracedecay-contracts/tests/contracts_suite/workflow_provider_registry.rs b/crates/tracedecay-contracts/tests/contracts_suite/workflow_provider_registry.rs index a599f11ef4..0dc7658876 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/workflow_provider_registry.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/workflow_provider_registry.rs @@ -4,21 +4,13 @@ use tracedecay_contracts::{ }; use tracedecay_domain::configuration::safe_work_topology_policy_v1; use tracedecay_domain::{ - ManifestDigest, ProviderId, RunId, WorkProviderBackendV1, WorkProviderRouteId, - WorkProviderRouteV1, WorkflowStepId, + ProviderId, RunId, WorkProviderBackendV1, WorkProviderRouteId, WorkProviderRouteV1, + WorkflowStepId, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn registration( provider: &str, diff --git a/crates/tracedecay-contracts/tests/contracts_suite/workflow_runtime.rs b/crates/tracedecay-contracts/tests/contracts_suite/workflow_runtime.rs index 71056d3dc8..3f1ac19263 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/workflow_runtime.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/workflow_runtime.rs @@ -20,17 +20,9 @@ use tracedecay_domain::{ WorkflowStepId, WorktreeId, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn fan_out_input(identity: &str, input_digest: ManifestDigest) -> WorkflowFanOutInput { let task_id = id::(&format!("task.workflow.runtime.{identity}")); diff --git a/crates/tracedecay-daemon-control/src/service.rs b/crates/tracedecay-daemon-control/src/service.rs index 3e7c130638..bb3b28d1d2 100644 --- a/crates/tracedecay-daemon-control/src/service.rs +++ b/crates/tracedecay-daemon-control/src/service.rs @@ -18,6 +18,12 @@ mod runner; mod unit_file; mod windows_task; +/// Declared once for the whole module: both test children below need the +/// shared harness, and loading the same file as two modules is a clippy error. +#[cfg(test)] +#[path = "../../../tests/support/isolated_profile.rs"] +mod isolated_profile; + #[cfg(test)] #[allow(clippy::expect_used)] mod tests; @@ -578,8 +584,8 @@ impl DaemonServiceSpec { let _ = write!( environment, " {}\n {}\n", - plist_xml_escape(&key), - plist_xml_escape(&value) + xml_escape(&key), + xml_escape(&value) ); } @@ -591,12 +597,12 @@ impl DaemonServiceSpec { {}\n\ --remote-tls-key\n\ {}\n", - plist_xml_escape(&config.listen().to_string()), - plist_xml_escape(managed_remote_tls_path_text( + xml_escape(&config.listen().to_string()), + xml_escape(managed_remote_tls_path_text( "certificate chain", config.certificate_chain(), )?), - plist_xml_escape(managed_remote_tls_path_text( + xml_escape(managed_remote_tls_path_text( "private key", config.private_key(), )?), @@ -656,12 +662,12 @@ impl DaemonServiceSpec { {stderr}\n\ \n\ \n", - label = plist_xml_escape(LAUNCHD_LABEL), - bin = plist_xml_escape(&self.tracedecay_bin.display().to_string()), - socket = plist_xml_escape(&self.socket_path.display().to_string()), + label = xml_escape(LAUNCHD_LABEL), + bin = xml_escape(&self.tracedecay_bin.display().to_string()), + socket = xml_escape(&self.socket_path.display().to_string()), open_file_limit = DAEMON_OPEN_FILE_LIMIT, - stdout = plist_xml_escape(&data_dir.join("daemon.out.log").display().to_string()), - stderr = plist_xml_escape(&data_dir.join("daemon.err.log").display().to_string()), + stdout = xml_escape(&data_dir.join("daemon.out.log").display().to_string()), + stderr = xml_escape(&data_dir.join("daemon.err.log").display().to_string()), )) } @@ -798,7 +804,7 @@ fn systemd_escape_env_value(value: &str) -> String { .replace('%', "%%") } -fn plist_xml_escape(value: &str) -> String { +fn xml_escape(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); for ch in value.chars() { match ch { @@ -813,7 +819,7 @@ fn plist_xml_escape(value: &str) -> String { escaped } -fn plist_xml_unescape(value: &str) -> String { +fn xml_unescape(value: &str) -> String { value .replace(""", "\"") .replace("'", "'") diff --git a/crates/tracedecay-daemon-control/src/service/tests.rs b/crates/tracedecay-daemon-control/src/service/tests.rs index 8ccb333339..82f47fc72b 100644 --- a/crates/tracedecay-daemon-control/src/service/tests.rs +++ b/crates/tracedecay-daemon-control/src/service/tests.rs @@ -1,4 +1,3 @@ -use std::ffi::{OsStr, OsString}; use std::io::Write; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -23,40 +22,7 @@ use tracedecay_runtime_core::config::{ const TEST_BUILD_VERSION: &str = "0.1.0-test+service-probe"; -struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } - - fn unset(key: &'static str) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::remove_var(key); - } - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - if let Some(previous) = self.previous.take() { - std::env::set_var(self.key, previous); - } else { - std::env::remove_var(self.key); - } - } - } -} +use super::isolated_profile::EnvVarGuard; #[cfg(target_os = "linux")] fn systemctl_log_contains_sequence(log: &str, expected: &[&str]) -> bool { diff --git a/crates/tracedecay-daemon-control/src/service/unit_file.rs b/crates/tracedecay-daemon-control/src/service/unit_file.rs index 3695818ff0..2641f34e27 100644 --- a/crates/tracedecay-daemon-control/src/service/unit_file.rs +++ b/crates/tracedecay-daemon-control/src/service/unit_file.rs @@ -8,7 +8,7 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use super::runner::ServicePlatform; use super::{ DaemonServiceSpec, LAUNCHD_PLIST_NAME, SERVICE_TEMP_SEQUENCE, home_for_service_env, - plist_xml_escape, plist_xml_unescape, windows_task, + windows_task, xml_escape, xml_unescape, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -332,38 +332,38 @@ pub(super) fn remote_tls_from_service_unit( } pub(super) fn socket_path_from_launchd_plist(plist: &str) -> Option { - let program_arguments_start = plist.find("ProgramArguments")?; - let arguments_text = &plist[program_arguments_start..]; - let array_start = arguments_text.find("")? + "".len(); - let after_array_start = &arguments_text[array_start..]; - let array_end = after_array_start.find("")?; - let array_text = &after_array_start[..array_end]; - let strings = plist_string_values(array_text); - + let strings = launchd_program_arguments(plist).ok()??; socket_path_from_args(strings.iter().map(String::as_str)) } pub(super) fn remote_tls_from_launchd_plist( plist: &str, ) -> Result> { + let Some(strings) = launchd_program_arguments(plist)? else { + return Ok(None); + }; + remote_tls_from_args(strings.iter().map(String::as_str)) +} + +fn launchd_program_arguments(plist: &str) -> Result>> { let Some(program_arguments_start) = plist.find("ProgramArguments") else { return Ok(None); }; let arguments_text = &plist[program_arguments_start..]; - let array_start = arguments_text - .find("") - .ok_or_else(|| TraceDecayError::Config { - message: "installed launchd daemon service has malformed program arguments".to_string(), - })? - + "".len(); - let after_array_start = &arguments_text[array_start..]; - let array_end = after_array_start - .find("") - .ok_or_else(|| TraceDecayError::Config { - message: "installed launchd daemon service has malformed program arguments".to_string(), - })?; - let strings = plist_string_values(&after_array_start[..array_end]); - remote_tls_from_args(strings.iter().map(String::as_str)) + let Some(array_relative) = arguments_text.find("") else { + return Err(malformed_launchd_program_arguments()); + }; + let after_array_start = &arguments_text[array_relative + "".len()..]; + let Some(array_end) = after_array_start.find("") else { + return Err(malformed_launchd_program_arguments()); + }; + Ok(Some(plist_string_values(&after_array_start[..array_end]))) +} + +fn malformed_launchd_program_arguments() -> TraceDecayError { + TraceDecayError::Config { + message: "installed launchd daemon service has malformed program arguments".to_string(), + } } pub(super) fn launchd_plist_env_value(plist: &str, name: &str) -> Option { @@ -374,7 +374,7 @@ pub(super) fn launchd_plist_env_value(plist: &str, name: &str) -> Option let dict_end = after_dict_start.find("")?; let dict_text = &after_dict_start[..dict_end]; - let key_tag = format!("{}", plist_xml_escape(name)); + let key_tag = format!("{}", xml_escape(name)); let key_end = dict_text.find(&key_tag)? + key_tag.len(); plist_string_values(&dict_text[key_end..]) .into_iter() @@ -390,7 +390,7 @@ fn plist_string_values(text: &str) -> Vec { let Some(end) = after_start.find("") else { break; }; - values.push(plist_xml_unescape(&after_start[..end])); + values.push(xml_unescape(&after_start[..end])); remaining = &after_start[end + "".len()..]; } values diff --git a/crates/tracedecay-daemon-control/src/service/update_restore_tests.rs b/crates/tracedecay-daemon-control/src/service/update_restore_tests.rs index 5a84ee931f..6fd2610130 100644 --- a/crates/tracedecay-daemon-control/src/service/update_restore_tests.rs +++ b/crates/tracedecay-daemon-control/src/service/update_restore_tests.rs @@ -7,8 +7,6 @@ //! and the readiness probe keeps failing closed with a typed identity //! mismatch when the daemon that answers is not the expected version. -#[cfg(unix)] -use std::ffi::{OsStr, OsString}; #[cfg(unix)] use std::io::{BufRead, Write}; #[cfg(unix)] @@ -42,34 +40,7 @@ fn quiesced_guard() -> QuiescedDaemonLifecycle { } #[cfg(unix)] -struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -#[cfg(unix)] -impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } -} - -#[cfg(unix)] -impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - if let Some(previous) = self.previous.take() { - std::env::set_var(self.key, previous); - } else { - std::env::remove_var(self.key); - } - } - } -} +use super::isolated_profile::EnvVarGuard; #[cfg(unix)] fn serve_initialize_identity( diff --git a/crates/tracedecay-daemon-control/src/service/windows_task.rs b/crates/tracedecay-daemon-control/src/service/windows_task.rs index 01fe0e94c2..57ada8292d 100644 --- a/crates/tracedecay-daemon-control/src/service/windows_task.rs +++ b/crates/tracedecay-daemon-control/src/service/windows_task.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use tracedecay_domain::errors::{Result, TraceDecayError}; -use super::{DaemonServiceSpec, DaemonServiceState}; +use super::{DaemonServiceSpec, DaemonServiceState, xml_escape, xml_unescape}; #[cfg(any(windows, test))] const TASK_NAME_PREFIX: &str = "TraceDecay Daemon"; @@ -1814,30 +1814,6 @@ fn missing_task(operation: &str) -> TraceDecayError { } } -fn xml_escape(value: &str) -> String { - let mut escaped = String::with_capacity(value.len()); - for character in value.chars() { - match character { - '&' => escaped.push_str("&"), - '<' => escaped.push_str("<"), - '>' => escaped.push_str(">"), - '"' => escaped.push_str("""), - '\'' => escaped.push_str("'"), - _ => escaped.push(character), - } - } - escaped -} - -fn xml_unescape(value: &str) -> String { - value - .replace(""", "\"") - .replace("'", "'") - .replace("<", "<") - .replace(">", ">") - .replace("&", "&") -} - fn xml_element_text<'a>(xml: &'a str, element: &str) -> Option<&'a str> { let opening = format!("<{element}>"); let closing = format!(""); diff --git a/crates/tracedecay-daemon-identity/src/authority.rs b/crates/tracedecay-daemon-identity/src/authority.rs index addb4b59db..043dc2edff 100644 --- a/crates/tracedecay-daemon-identity/src/authority.rs +++ b/crates/tracedecay-daemon-identity/src/authority.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Deserializer, Serialize}; use std::fmt; use std::fs::File; #[cfg(not(windows))] @@ -5,9 +6,6 @@ use std::fs::OpenOptions; use std::io::{Read, Seek, SeekFrom, Write}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use serde::{Deserialize, Deserializer, Serialize}; use tracedecay_domain::{BrainId, UserProfileId}; use tracedecay_runtime_core::path_safety::{ canonicalize_existing_prefix, collapse_relative_components, @@ -165,13 +163,10 @@ impl DaemonAuthority { let profile_identity = crate::profile_identity::load_or_create_pinned(&profile_root, pinned_identity)?; let prior_epoch = prior_record.as_ref().map_or(0, |record| record.epoch); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); let record = DaemonAuthorityRecord { pid: std::process::id(), process_run_id: tracedecay_runtime_core::runtime_identity::process_run_id().to_string(), - started_at_unix_secs: i64::try_from(now.as_secs()).unwrap_or(i64::MAX), + started_at_unix_secs: tracedecay_runtime_core::tracedecay::saturating_unix_secs(), epoch: prior_epoch.saturating_add(1), version: version.to_string(), endpoint: canonical_endpoint(endpoint)?, diff --git a/crates/tracedecay-daemon-identity/src/connection.rs b/crates/tracedecay-daemon-identity/src/connection.rs index 5af26b45a6..122208fe44 100644 --- a/crates/tracedecay-daemon-identity/src/connection.rs +++ b/crates/tracedecay-daemon-identity/src/connection.rs @@ -68,6 +68,23 @@ impl DaemonLivenessProbe for AuthorityLivenessProbe { } } +fn connection_from_record(record: authority::DaemonAuthorityRecord) -> ResolvedDaemonConnection { + ResolvedDaemonConnection { + endpoint: record.endpoint.clone(), + auth_token: Some(record.auth_token.clone()), + authority_record: Some(record), + } +} + +#[cfg(unix)] +fn unix_endpoint_matches_socket(endpoint: &DaemonEndpoint, socket_path: &Path) -> bool { + let DaemonEndpoint::Unix(authority_path) = endpoint else { + return false; + }; + authority::canonical_identity_path(authority_path).ok() + == authority::canonical_identity_path(socket_path).ok() +} + fn ensure_record_current( expected: &authority::DaemonAuthorityRecord, request_label: &str, @@ -113,33 +130,24 @@ pub fn current_daemon_connection() -> Result { "TraceDecay daemon authority record is not available. Start or restart the daemon." .to_string(), })?; - Ok(ResolvedDaemonConnection { - endpoint: record.endpoint.clone(), - auth_token: Some(record.auth_token.clone()), - authority_record: Some(record), - }) + Ok(connection_from_record(record)) } #[cfg(unix)] pub fn connection_for_socket_path(socket_path: &Path) -> ResolvedDaemonConnection { if let Ok(connection) = current_daemon_connection() - && let DaemonEndpoint::Unix(authority_path) = &connection.endpoint - && authority::canonical_identity_path(authority_path).ok() - == authority::canonical_identity_path(socket_path).ok() + && connection + .authority_record + .as_ref() + .is_some_and(|record| unix_endpoint_matches_socket(&record.endpoint, socket_path)) { return connection; } if let Some(profile_root) = socket_path.parent() && let Ok(Some(record)) = authority::current_record(profile_root) - && let DaemonEndpoint::Unix(authority_path) = &record.endpoint - && authority::canonical_identity_path(authority_path).ok() - == authority::canonical_identity_path(socket_path).ok() + && unix_endpoint_matches_socket(&record.endpoint, socket_path) { - return ResolvedDaemonConnection { - endpoint: record.endpoint.clone(), - auth_token: Some(record.auth_token.clone()), - authority_record: Some(record), - }; + return connection_from_record(record); } // Explicit paths are retained for test harnesses and legacy one-shot // callers without a discoverable authority record. Default production diff --git a/crates/tracedecay-daemon-protocol/src/connection.rs b/crates/tracedecay-daemon-protocol/src/connection.rs index 3ef6739f83..0f1556d9f2 100644 --- a/crates/tracedecay-daemon-protocol/src/connection.rs +++ b/crates/tracedecay-daemon-protocol/src/connection.rs @@ -176,15 +176,21 @@ pub async fn ensure_daemon_connection_live( Ok(()) } -#[hotpath::measure(label = "daemon_protocol.client.response.wait", future = true)] -pub async fn next_daemon_response_line( +/// Reads the next daemon frame, polling `ensure_live` whenever the read is +/// still pending. +/// +/// The reader stays held across polls. `read_mcp_line` is dropped when the +/// poll wins `select!`; the accumulator lives on the line reader. +pub async fn poll_daemon_response_line( reader: &mut R, - connection: &DaemonConnection, request_label: &str, liveness_poll_interval: Duration, + mut ensure_live: F, ) -> Result> where R: tokio::io::AsyncBufRead + Unpin, + F: FnMut() -> Fut, + Fut: std::future::Future>, { let mut line_reader = BoundedLineReader::new(reader); loop { @@ -203,19 +209,40 @@ where }; } () = tokio::time::sleep(liveness_poll_interval) => { - ensure_daemon_connection_live(connection, request_label).await?; + ensure_live().await?; } } } } -#[hotpath::measure(label = "daemon_protocol.client.preamble", future = true)] -pub async fn write_daemon_preamble( - writer: &mut tokio::io::WriteHalf, +#[hotpath::measure(label = "daemon_protocol.client.response.wait", future = true)] +pub async fn next_daemon_response_line( + reader: &mut R, connection: &DaemonConnection, + request_label: &str, + liveness_poll_interval: Duration, +) -> Result> +where + R: tokio::io::AsyncBufRead + Unpin, +{ + poll_daemon_response_line(reader, request_label, liveness_poll_interval, || { + ensure_daemon_connection_live(connection, request_label) + }) + .await +} + +/// Writes the optional auth preface and the handshake line. +/// +/// Callers that already hold a [`DaemonConnection`] use +/// [`write_daemon_preamble`]. The composition-root client uses this directly +/// because its connection type is the authority record, not the protocol +/// connection. +pub async fn write_daemon_handshake_preamble( + writer: &mut (impl tokio::io::AsyncWrite + Unpin), + auth_token: Option<&str>, handshake: &DaemonHandshake, ) -> Result<()> { - if let Some(token) = connection.auth_token.as_deref() { + if let Some(token) = auth_token { writer .write_all(DaemonAuthPreface::new(token).to_line()?.as_bytes()) .await?; @@ -226,6 +253,15 @@ pub async fn write_daemon_preamble( Ok(()) } +#[hotpath::measure(label = "daemon_protocol.client.preamble", future = true)] +pub async fn write_daemon_preamble( + writer: &mut tokio::io::WriteHalf, + connection: &DaemonConnection, + handshake: &DaemonHandshake, +) -> Result<()> { + write_daemon_handshake_preamble(writer, connection.auth_token.as_deref(), handshake).await +} + pub fn is_transient_daemon_connect_error(kind: std::io::ErrorKind) -> bool { matches!( kind, diff --git a/crates/tracedecay-daemon-protocol/src/contract/mod.rs b/crates/tracedecay-daemon-protocol/src/contract/mod.rs index e62ffb3c75..c36fd9a229 100644 --- a/crates/tracedecay-daemon-protocol/src/contract/mod.rs +++ b/crates/tracedecay-daemon-protocol/src/contract/mod.rs @@ -76,6 +76,14 @@ fn valid_lsp_control(deadline: &Deadline, cancellation: &CancellationContext) -> deadline.expires_at.0 > 0 && cancellation.token_id.as_str().len() <= MAX_OPAQUE_HANDLE_BYTES } +fn valid_observation_window( + observed_at: &UtcMicros, + deadline: &Deadline, + cancellation: &CancellationContext, +) -> bool { + observed_at.0 > 0 && valid_lsp_control(deadline, cancellation) +} + /// Stable discriminator for the closed post-handshake invocation protocol. pub const DAEMON_INVOCATION_PROTOCOL: &str = "tracedecay.daemon.invocation"; /// Initial revision of the daemon-owned invocation wire shape. @@ -1823,9 +1831,7 @@ impl DaemonInvocationRequest { deadline, cancellation, } => { - if observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + if !valid_observation_window(observed_at, deadline, cancellation) || MultiRootScopeSetReadRequestV1::new(request.scope_set_id.clone()).is_err() { return Err(DaemonInvocationProblem::InvalidRequest); @@ -1837,9 +1843,7 @@ impl DaemonInvocationRequest { deadline, cancellation, } => { - if observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + if !valid_observation_window(observed_at, deadline, cancellation) || request.validate().is_err() { return Err(DaemonInvocationProblem::InvalidRequest); @@ -1851,9 +1855,7 @@ impl DaemonInvocationRequest { deadline, cancellation, } => { - if observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + if !valid_observation_window(observed_at, deadline, cancellation) || request.validate().is_err() { return Err(DaemonInvocationProblem::InvalidRequest); @@ -1866,10 +1868,7 @@ impl DaemonInvocationRequest { deadline, cancellation, } => { - if observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES - { + if !valid_observation_window(observed_at, deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } let expected = match &request.request { @@ -1900,10 +1899,7 @@ impl DaemonInvocationRequest { deadline, cancellation, } => { - if observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES - { + if !valid_observation_window(observed_at, deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } let expected = match request { @@ -2027,10 +2023,7 @@ impl DaemonInvocationRequest { cancellation, .. } => { - if observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES - { + if !valid_observation_window(observed_at, deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } } @@ -2042,9 +2035,7 @@ impl DaemonInvocationRequest { .. } => { if !(1..=365).contains(&request.window_days) - || observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + || !valid_observation_window(observed_at, deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } @@ -2055,10 +2046,7 @@ impl DaemonInvocationRequest { cancellation, .. } => { - if observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES - { + if !valid_observation_window(observed_at, deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } } @@ -2073,7 +2061,7 @@ impl DaemonInvocationRequest { if observed_at.0 <= 0 || deadline.expires_at.0 <= 0 || PageRequest::new(page.page_size, page.cursor.clone()).is_err() - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + || !valid_lsp_control(deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } @@ -2111,7 +2099,7 @@ impl DaemonInvocationRequest { if observed_at.0 <= 0 || deadline.expires_at.0 <= 0 || PageRequest::new(page.page_size, page.cursor.clone()).is_err() - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + || !valid_lsp_control(deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } @@ -2155,9 +2143,7 @@ impl DaemonInvocationRequest { cancellation, .. } => { - if observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + if !valid_observation_window(observed_at, deadline, cancellation) || !request.matches(*surface_operation) || matches!( request, @@ -2175,10 +2161,7 @@ impl DaemonInvocationRequest { cancellation, .. } => { - if observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES - { + if !valid_observation_window(observed_at, deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } } @@ -2220,9 +2203,7 @@ impl DaemonInvocationRequest { cancellation, } => { if !valid_token(request_handle, MAX_OPAQUE_HANDLE_BYTES) - || observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + || !valid_observation_window(observed_at, deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } @@ -2234,9 +2215,7 @@ impl DaemonInvocationRequest { cancellation, } => { if !valid_printable(document_uri, MAX_ROOT_HINT_BYTES) - || observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + || !valid_observation_window(observed_at, deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } @@ -2246,10 +2225,7 @@ impl DaemonInvocationRequest { deadline, cancellation, } => { - if request.observed_at.0 <= 0 - || deadline.expires_at.0 <= 0 - || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES - { + if !valid_observation_window(&request.observed_at, deadline, cancellation) { return Err(DaemonInvocationProblem::InvalidRequest); } } diff --git a/crates/tracedecay-daemon-protocol/src/lib.rs b/crates/tracedecay-daemon-protocol/src/lib.rs index 10b4fe309b..eed99da37a 100644 --- a/crates/tracedecay-daemon-protocol/src/lib.rs +++ b/crates/tracedecay-daemon-protocol/src/lib.rs @@ -81,7 +81,8 @@ pub use connection::{ DaemonLivenessProbe, MAX_TOOL_REQUEST_DEADLINE, TOOL_REQUEST_DEADLINE_ENV, connect_to_daemon_connection, daemon_connect_failure, daemon_response_stalled, daemon_response_stalled_during, daemon_tool_response_bound, next_daemon_response_line, - tool_request_deadline, write_daemon_preamble, + poll_daemon_response_line, tool_request_deadline, write_daemon_handshake_preamble, + write_daemon_preamble, }; pub use contract::{ DAEMON_INVOCATION_PROTOCOL, DAEMON_INVOCATION_REVISION, DAEMON_SHUTDOWN_METHOD, diff --git a/crates/tracedecay-daemon-service/src/application_surface.rs b/crates/tracedecay-daemon-service/src/application_surface.rs index c739179d14..5f2d9327a5 100644 --- a/crates/tracedecay-daemon-service/src/application_surface.rs +++ b/crates/tracedecay-daemon-service/src/application_surface.rs @@ -36,7 +36,8 @@ use tracedecay_daemon_protocol::{ }; use tracedecay_domain::{ProjectId, ScopeOutcome, ScopePartialReasonV1, ScopeUnavailableReasonV1}; use tracedecay_tool_catalog::{ - ApplicationSurfaceOperation, BindingSurface, CapabilityId, CatalogSnapshotV1, UseCaseId, + ApplicationSurfaceOperation, BindingSurface, CapabilityId, CatalogSnapshotV1, + ExecutableBindingRegistryV1, OperationId, RouteExposureV1, UseCaseId, }; mod catalog; @@ -77,6 +78,26 @@ use request_control::application_http_context; pub use workflow::invoke_workflow_operation; use workflow::router_with_executor as workflow_application_router_with_executor; +pub(super) fn require_public_catalog_route( + registry: &ExecutableBindingRegistryV1, + operation_id: &OperationId, + expected_route: &str, +) -> Result<(), ApplicationSurfaceAdapterError> { + let Some(binding) = registry + .get(operation_id) + .and_then(|availability| availability.binding()) + else { + return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); + }; + let RouteExposureV1::Public { route_path, .. } = binding.exposure() else { + return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); + }; + if route_path != expected_route { + return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); + } + Ok(()) +} + const DEFAULT_DEADLINE_MICROS: i64 = 30_000_000; const APPLICATION_PROTOCOL_REVISION: u32 = 1; const HTTP_DEADLINE_HEADER: &str = "x-tracedecay-deadline-micros"; diff --git a/crates/tracedecay-daemon-service/src/application_surface/handoff.rs b/crates/tracedecay-daemon-service/src/application_surface/handoff.rs index 3ae9364f30..e8e450ca5a 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/handoff.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/handoff.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use super::registered_http::invoke_registered_http; +use super::require_public_catalog_route; use axum::response::Response; use tracedecay_api::HandoffOperation; use tracedecay_contracts::{ @@ -7,9 +9,6 @@ use tracedecay_contracts::{ ListTaskHandoffsResultV1, OpenInvestigationHandoffRequestV1, OpenInvestigationHandoffResultV1, OpenTaskHandoffRequestV1, OpenTaskHandoffResultV1, }; -use tracedecay_tool_catalog::RouteExposureV1; - -use super::registered_http::invoke_registered_http; use tracedecay_daemon_protocol::ApplicationSurfaceAdapterError; use tracedecay_daemon_protocol::DaemonInvocationExecutor; use tracedecay_daemon_protocol::{HandoffApplicationInvocationV1, HandoffApplicationOutcomeV1}; @@ -30,18 +29,7 @@ pub(super) fn validate_catalog_bindings() -> Result<(), ApplicationSurfaceAdapte let operation_id = tracedecay_tool_catalog::OperationId::new(operation.operation_id_str().to_owned()) .map_err(ApplicationSurfaceAdapterError::Identifier)?; - let Some(binding) = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - let RouteExposureV1::Public { route_path, .. } = binding.exposure() else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - if route_path != operation.application_route_path() { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - } + require_public_catalog_route(®istry, &operation_id, operation.application_route_path())?; } Ok(()) } diff --git a/crates/tracedecay-daemon-service/src/application_surface/multi_root_http.rs b/crates/tracedecay-daemon-service/src/application_surface/multi_root_http.rs index 299156ab5a..b8b2734801 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/multi_root_http.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/multi_root_http.rs @@ -10,19 +10,18 @@ use std::sync::Arc; +use super::registered_http::{RegisteredHttpOperation, invoke_registered_http}; +use super::require_public_catalog_route; use axum::response::Response; use tracedecay_api::MultiRootHttpOperation; use tracedecay_contracts::multi_root::{ MultiRootApplicationOperation, multi_root_executable_binding_registry, }; use tracedecay_contracts::{ - ApplicationProblem, AuthorizedScopeSet, LegalAction, MultiRootExecuteRequestV1, - MultiRootQueryPageV1, MultiRootScopeSetCasRequestV1, MultiRootScopeSetCasResultV1, - MultiRootScopeSetReadRequestV1, RequestId, RetryDirective, SafeDiagnostic, + ApplicationProblem, AuthorizedScopeSet, MultiRootExecuteRequestV1, MultiRootQueryPageV1, + MultiRootScopeSetCasRequestV1, MultiRootScopeSetCasResultV1, MultiRootScopeSetReadRequestV1, + RequestId, SafeDiagnostic, }; -use tracedecay_tool_catalog::RouteExposureV1; - -use super::registered_http::{RegisteredHttpOperation, invoke_registered_http}; use tracedecay_daemon_protocol::ApplicationSurfaceAdapterError; use tracedecay_daemon_protocol::DaemonInvocationExecutor; use tracedecay_daemon_protocol::{DaemonInvocationOutcome, DaemonInvocationRequest}; @@ -45,18 +44,7 @@ pub(super) fn validate_catalog_bindings() -> Result<(), ApplicationSurfaceAdapte let operation_id = tracedecay_tool_catalog::OperationId::new(operation.operation_id().to_owned()) .map_err(ApplicationSurfaceAdapterError::Identifier)?; - let Some(binding) = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - let RouteExposureV1::Public { route_path, .. } = binding.exposure() else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - if route_path != operation.application_route_path() { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - } + require_public_catalog_route(®istry, &operation_id, operation.application_route_path())?; } Ok(()) } @@ -208,11 +196,7 @@ fn invalid_request_response(request_id: RequestId) -> Response { }; tracedecay_api::adapter_problem_response( request_id, - ApplicationProblem::InvalidRequest { - diagnostic, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - }, + ApplicationProblem::invalid_request(diagnostic.code, diagnostic.message), ) } diff --git a/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs b/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs index 66e1286cc4..c945cff93a 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs @@ -831,52 +831,36 @@ pub(super) fn operation_event_problem( legal_actions: vec![LegalAction::Refresh], } } - OperationEventError::InvalidFrontier => ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: "operation_event.invalid_frontier".to_owned(), - message: "The requested operation-event frontier is invalid".to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }, + OperationEventError::InvalidFrontier => ApplicationProblem::conflict( + "operation_event.invalid_frontier", + "The requested operation-event frontier is invalid", + ), OperationEventError::RequestNotAdmitted => ApplicationProblem::TimedOut { stage: CancellationStage::BeforeAdmission, retry: RetryDirective::Never, legal_actions: Vec::new(), }, - OperationEventError::Saturated => ApplicationProblem::Saturated { - diagnostic: SafeDiagnostic { - code: "operation_event.saturated".to_owned(), - message: "Operation-event capacity is temporarily saturated".to_owned(), - }, - retry: RetryDirective::AfterDelay, - legal_actions: vec![LegalAction::Retry], - }, + OperationEventError::Saturated => ApplicationProblem::saturated( + "operation_event.saturated", + "Operation-event capacity is temporarily saturated", + ), // Permanently invalid input: the same request can never succeed, so the // client must correct it rather than retry. OperationEventError::InvalidContext(_) | OperationEventError::InvalidProgress | OperationEventError::InvalidTerminal(_) - | OperationEventError::InvalidTestRunEvent => ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "operation_event.invalid_request".to_owned(), - message: "The operation-event request is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - }, + | OperationEventError::InvalidTestRunEvent => ApplicationProblem::invalid_request( + "operation_event.invalid_request", + "The operation-event request is invalid", + ), // Idempotency facts: the identity or terminal receipt is already // published, so the client re-reads current state instead of retrying // the same publish. OperationEventError::AlreadyBound | OperationEventError::TerminalAlreadyPublished => { - ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: "operation_event.already_published".to_owned(), - message: "The operation-event identity is already published".to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - } + ApplicationProblem::conflict( + "operation_event.already_published", + "The operation-event identity is already published", + ) } // A misconfigured authority is a deterministic, process-lifetime // failure. It is not the caller's request that is wrong and no amount diff --git a/crates/tracedecay-daemon-service/src/application_surface/problems.rs b/crates/tracedecay-daemon-service/src/application_surface/problems.rs index 351aacb6ad..108ddadc58 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/problems.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/problems.rs @@ -54,14 +54,10 @@ pub(super) fn application_contract_error_response(error: ApplicationContractErro } fn invalid_surface_request_problem(message: String) -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.surface.invalid_request".to_owned(), - message, - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - } + ApplicationProblem::invalid_request_without_action( + "application.surface.invalid_request", + message, + ) } pub(super) fn http_adapter_problem( @@ -154,14 +150,10 @@ pub(super) fn invocation_problem( Ok(match problem { tracedecay_daemon_protocol::DaemonInvocationProblem::InvalidRequest | tracedecay_daemon_protocol::DaemonInvocationProblem::UnsupportedRevision => { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic::new( - "application.surface.invalid_request", - "The daemon rejected the application request", - )?, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - } + ApplicationProblem::invalid_request_without_action( + "application.surface.invalid_request", + "The daemon rejected the application request", + ) } tracedecay_daemon_protocol::DaemonInvocationProblem::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) @@ -201,23 +193,15 @@ pub(super) fn invocation_contract_problem( ApplicationProblem::timed_out_before_admission() } tracedecay_contracts::InvocationError::InvalidRequest => { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic::new( - "application.surface.invalid_request", - "The daemon rejected the application request", - )?, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - } + ApplicationProblem::invalid_request_without_action( + "application.surface.invalid_request", + "The daemon rejected the application request", + ) } - tracedecay_contracts::InvocationError::Conflict => ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic::new( - "application.surface.conflict", - "The application request conflicts with current state", - )?, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }, + tracedecay_contracts::InvocationError::Conflict => ApplicationProblem::conflict( + "application.surface.conflict", + "The application request conflicts with current state", + ), tracedecay_contracts::InvocationError::Unavailable => { ApplicationProblem::unavailable(SafeDiagnostic::new( "application.surface.unavailable", diff --git a/crates/tracedecay-daemon-service/src/application_surface/registered_http.rs b/crates/tracedecay-daemon-service/src/application_surface/registered_http.rs index 596f93f10b..31ad79be98 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/registered_http.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/registered_http.rs @@ -1,3 +1,4 @@ +use super::problems::{application_contract_error_response, registered_adapter_unavailable}; use axum::response::Response; use serde::Serialize; use tracedecay_api::{ @@ -5,15 +6,12 @@ use tracedecay_api::{ WorkflowOperation, }; use tracedecay_contracts::{ - ApplicationEnvelope, ApplicationProblem, ApplicationProblemEnvelope, LegalAction, - ProblemOwningLayer, RequestId, ResultContractRef, RetryDirective, SafeDiagnostic, + ApplicationEnvelope, ApplicationProblem, ApplicationProblemEnvelope, ProblemOwningLayer, + RequestId, ResultContractRef, RetryDirective, SafeDiagnostic, }; use tracedecay_daemon_protocol::{ ApplicationSurfaceAdapterError, DaemonInvocationError, InvocationCancellationPolicy, }; -use tracedecay_tool_catalog::RouteExposureV1; - -use super::problems::{application_contract_error_response, registered_adapter_unavailable}; pub(crate) trait RegisteredHttpOperation: Copy { fn operation_id(self) -> String; @@ -248,7 +246,7 @@ where &format!("The {family} operation is not advertised by this build"), ); }; - let RouteExposureV1::Public { binding_id, .. } = binding.exposure() else { + let Some((binding_id, _)) = binding.public_route() else { return registered_adapter_unavailable( request_id, &problem_code("route_unavailable"), @@ -332,7 +330,7 @@ where &format!("The {family} operation is not advertised by this build"), ); }; - let RouteExposureV1::Public { binding_id, .. } = binding.exposure() else { + let Some((binding_id, _)) = binding.public_route() else { return registered_adapter_unavailable( request_id, &problem_code("route_unavailable"), @@ -388,14 +386,7 @@ where tracedecay_daemon_protocol::DaemonInvocationOutcome::Problem { problem } => match problem { tracedecay_daemon_protocol::DaemonInvocationProblem::InvalidRequest | tracedecay_daemon_protocol::DaemonInvocationProblem::UnsupportedRevision => { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: problem_code("invalid_request"), - message: format!("The {family} application request is invalid"), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request(problem_code("invalid_request"), format!("The {family} application request is invalid")) } tracedecay_daemon_protocol::DaemonInvocationProblem::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) diff --git a/crates/tracedecay-daemon-service/src/application_surface/retained.rs b/crates/tracedecay-daemon-service/src/application_surface/retained.rs index 1ece683e02..4566d3a7b1 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/retained.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/retained.rs @@ -17,6 +17,7 @@ use tracedecay_contracts::retained_surfaces::{ use tracedecay_tool_catalog::RouteExposureV1; use super::registered_http::{RegisteredHttpOperation, invoke_registered_http}; +use super::require_public_catalog_route; use tracedecay_daemon_protocol::ApplicationSurfaceAdapterError; use tracedecay_daemon_protocol::DaemonInvocationExecutor; use tracedecay_daemon_protocol::{DaemonInvocationOutcome, DaemonInvocationRequest}; @@ -38,18 +39,11 @@ fn validate_catalog_bindings() -> Result<(), ApplicationSurfaceAdapterError> { tracedecay_api::retained_operation_id(operation), ) .map_err(ApplicationSurfaceAdapterError::Identifier)?; - let Some(binding) = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - let RouteExposureV1::Public { route_path, .. } = binding.exposure() else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - if route_path != &tracedecay_api::retained_application_route_path(operation) { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - } + require_public_catalog_route( + ®istry, + &operation_id, + &tracedecay_api::retained_application_route_path(operation), + )?; } Ok(()) } diff --git a/crates/tracedecay-daemon-service/src/application_surface/retained_http_identity_tests.rs b/crates/tracedecay-daemon-service/src/application_surface/retained_http_identity_tests.rs index 118186cda1..642ab177bf 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/retained_http_identity_tests.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/retained_http_identity_tests.rs @@ -23,9 +23,7 @@ use super::super::registered_http::{RegisteredHttpOperation, invoke_registered_h use super::validated_daemon_outcome; use tracedecay_api::WorkOperation; -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).expect("fixture digest") -} +use tracedecay_domain::test_fixtures::digest; fn retained_scope(seed: &str) -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-daemon-service/src/application_surface/work.rs b/crates/tracedecay-daemon-service/src/application_surface/work.rs index 627b2fba5b..e3b3aa3754 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/work.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/work.rs @@ -6,6 +6,8 @@ use std::sync::Arc; +use super::registered_http::invoke_registered_http; +use super::require_public_catalog_route; use axum::response::Response; use tracedecay_api::{WorkHttpRequest, WorkOperation}; use tracedecay_contracts::{ @@ -26,16 +28,13 @@ use tracedecay_contracts::{ WorkProposalComparisonV1, WorkRunControlReadingV1, WorkRunControlRequestV1, WorkSynthesisAttemptV1, WorkTopologyViewRequestV1, }; +use tracedecay_daemon_protocol::ApplicationSurfaceAdapterError; +use tracedecay_daemon_protocol::DaemonInvocationExecutor; +use tracedecay_daemon_protocol::{WorkApplicationInvocationV1, WorkApplicationOutcomeV1}; use tracedecay_domain::{ WorkAttemptV1, WorkDuplicateAdjudicationCommandV1, WorkPlacementPreflightV1, WorkPlacementV1, WorkRunControlV1, }; -use tracedecay_tool_catalog::RouteExposureV1; - -use super::registered_http::invoke_registered_http; -use tracedecay_daemon_protocol::ApplicationSurfaceAdapterError; -use tracedecay_daemon_protocol::DaemonInvocationExecutor; -use tracedecay_daemon_protocol::{WorkApplicationInvocationV1, WorkApplicationOutcomeV1}; pub(super) fn router_with_executor( executor: Arc, @@ -63,18 +62,7 @@ pub(crate) fn validate_catalog_bindings() -> Result<(), ApplicationSurfaceAdapte for operation in WorkOperation::ALL { let operation_id = tracedecay_tool_catalog::OperationId::new(operation.operation_id()) .map_err(ApplicationSurfaceAdapterError::Identifier)?; - let Some(binding) = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - let RouteExposureV1::Public { route_path, .. } = binding.exposure() else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - if route_path != operation.application_route_path() { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - } + require_public_catalog_route(registry, &operation_id, operation.application_route_path())?; } Ok(()) } diff --git a/crates/tracedecay-daemon-service/src/application_surface/workflow.rs b/crates/tracedecay-daemon-service/src/application_surface/workflow.rs index 93e45d049f..fe796483d5 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/workflow.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/workflow.rs @@ -2,6 +2,8 @@ use std::sync::Arc; +use super::registered_http::invoke_registered_http; +use super::require_public_catalog_route; use axum::response::Response; use tracedecay_api::WorkflowOperation; use tracedecay_contracts::{ @@ -12,9 +14,6 @@ use tracedecay_contracts::{ WorkflowDefinitionValidateRequest, WorkflowRunCancelRequest, WorkflowRunGetRequest, WorkflowRunPauseRequest, WorkflowRunResumeRequest, WorkflowRunStartRequest, }; -use tracedecay_tool_catalog::RouteExposureV1; - -use super::registered_http::invoke_registered_http; use tracedecay_daemon_protocol::ApplicationSurfaceAdapterError; use tracedecay_daemon_protocol::DaemonInvocationExecutor; use tracedecay_daemon_protocol::{WorkflowApplicationInvocation, WorkflowApplicationOutcome}; @@ -35,18 +34,7 @@ pub(super) fn validate_catalog_bindings() -> Result<(), ApplicationSurfaceAdapte let operation_id = tracedecay_tool_catalog::OperationId::new(operation.operation_id_str().to_owned()) .map_err(ApplicationSurfaceAdapterError::Identifier)?; - let Some(binding) = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - let RouteExposureV1::Public { route_path, .. } = binding.exposure() else { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - }; - if route_path != operation.application_route_path() { - return Err(ApplicationSurfaceAdapterError::UnknownOrNotAuthorized); - } + require_public_catalog_route(registry, &operation_id, operation.application_route_path())?; } Ok(()) } diff --git a/crates/tracedecay-daemon-service/src/automation_effect/journal/tests.rs b/crates/tracedecay-daemon-service/src/automation_effect/journal/tests.rs index 03d83ac465..a40c89af90 100644 --- a/crates/tracedecay-daemon-service/src/automation_effect/journal/tests.rs +++ b/crates/tracedecay-daemon-service/src/automation_effect/journal/tests.rs @@ -15,8 +15,8 @@ use tracedecay_contracts::{ OperationReceipt, PolicyDecisionRef, ReconciliationState, RequestId, ResolvedScope, }; use tracedecay_domain::{ - ActorId, ComponentVersion, FactOwnerV1, ManifestDigest, ProjectId, RepositoryId, RunId, - UtcMicros, WorktreeId, canonical_sha256, + ActorId, ComponentVersion, FactOwnerV1, ProjectId, RepositoryId, RunId, UtcMicros, WorktreeId, + canonical_sha256, }; use tracedecay_tool_catalog::EffectClass; @@ -41,9 +41,7 @@ impl tracedecay_automation_runtime::automation::backend::AgentTaskBackend } } -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).expect("fixture digest") -} +use tracedecay_domain::test_fixtures::digest; fn scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay-daemon-service/src/context_scout_lifecycle/tests.rs b/crates/tracedecay-daemon-service/src/context_scout_lifecycle/tests.rs index 0774879391..494dcc4b8c 100644 --- a/crates/tracedecay-daemon-service/src/context_scout_lifecycle/tests.rs +++ b/crates/tracedecay-daemon-service/src/context_scout_lifecycle/tests.rs @@ -18,12 +18,7 @@ use super::*; use tracedecay_project::test_support::host_admission::HostAdmissionTestRuntimeV1; use tracedecay_sessions::admission::HostAdmissionScope; -fn id>(value: &str) -> T -where - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn complete_native_observation() -> CanonicalObservationEnvelopeV1 { let relations = CanonicalObservationRelationsV1::new(id::("session.native.codex")) diff --git a/crates/tracedecay-daemon-service/src/doctor_kernel.rs b/crates/tracedecay-daemon-service/src/doctor_kernel.rs index d0fe8bc9bf..b260033bd5 100644 --- a/crates/tracedecay-daemon-service/src/doctor_kernel.rs +++ b/crates/tracedecay-daemon-service/src/doctor_kernel.rs @@ -11,8 +11,6 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; - use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; use tracedecay_code_index_runtime::code_index_scheduler::identity::repository_id_for; use tracedecay_contracts::doctor::{ @@ -1188,12 +1186,7 @@ pub fn doctor_report_request_context( } fn now_secs() -> i64 { - i64::try_from( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()), - ) - .unwrap_or(i64::MAX) + tracedecay_runtime_core::tracedecay::saturating_unix_secs() } #[cfg(test)] diff --git a/crates/tracedecay-daemon-service/src/invocation/clock.rs b/crates/tracedecay-daemon-service/src/invocation/clock.rs index db0e823791..492d15c2d2 100644 --- a/crates/tracedecay-daemon-service/src/invocation/clock.rs +++ b/crates/tracedecay-daemon-service/src/invocation/clock.rs @@ -1,13 +1,8 @@ //! Shared wall-clock readings for daemon invocation admission and expiry. -use std::time::{SystemTime, UNIX_EPOCH}; - pub use tracedecay_contracts::clock::now_micros; pub use tracedecay_contracts::clock::now_micros as current_micros; pub fn now_millis() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)) - .unwrap_or_default() + tracedecay_runtime_core::tracedecay::unix_millis() } diff --git a/crates/tracedecay-daemon-service/src/invocation/configuration.rs b/crates/tracedecay-daemon-service/src/invocation/configuration.rs index 425d0bd76f..7e1313cd1e 100644 --- a/crates/tracedecay-daemon-service/src/invocation/configuration.rs +++ b/crates/tracedecay-daemon-service/src/invocation/configuration.rs @@ -762,14 +762,10 @@ fn is_safe_configuration_validation_reason(reason: &str) -> bool { } fn invalid_configuration_request() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "configuration.invalid_request".to_owned(), - message: "The configuration request is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - } + ApplicationProblem::invalid_request_without_action( + "configuration.invalid_request", + "The configuration request is invalid", + ) } pub(super) fn configuration_problem(error: ConfigurationError) -> ApplicationProblem { @@ -781,14 +777,10 @@ pub(super) fn configuration_problem(error: ConfigurationError) -> ApplicationPro ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) } ConfigurationError::RevisionConflict | ConfigurationError::IdempotencyConflict => { - ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: "configuration.conflict".to_owned(), - message: "The configuration request conflicts with current state".to_owned(), - }, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![tracedecay_contracts::LegalAction::Refresh], - } + ApplicationProblem::conflict( + "configuration.conflict", + "The configuration request conflicts with current state", + ) } ConfigurationError::PlanExpired | ConfigurationError::PlanStale => { ApplicationProblem::stale(SafeDiagnostic { @@ -796,22 +788,18 @@ pub(super) fn configuration_problem(error: ConfigurationError) -> ApplicationPro message: "The configuration preview is stale".to_owned(), }) } - ConfigurationError::PolicyWideningForbidden => ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "configuration.policy_widening_forbidden".to_owned(), - message: "Configuration policy widening is forbidden".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }, - ConfigurationError::Validation(reason) => ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "configuration.invalid_request".to_owned(), - message: safe_configuration_validation_message(&reason), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }, + ConfigurationError::PolicyWideningForbidden => { + ApplicationProblem::invalid_request_without_action( + "configuration.policy_widening_forbidden", + "Configuration policy widening is forbidden", + ) + } + ConfigurationError::Validation(reason) => { + ApplicationProblem::invalid_request_without_action( + "configuration.invalid_request", + safe_configuration_validation_message(&reason), + ) + } ConfigurationError::Unavailable => ApplicationProblem::unavailable(SafeDiagnostic { code: "configuration.unavailable".to_owned(), message: "The configuration authority is unavailable".to_owned(), diff --git a/crates/tracedecay-daemon-service/src/invocation/configuration/settlement.rs b/crates/tracedecay-daemon-service/src/invocation/configuration/settlement.rs index 761dd972f3..097ac08ef1 100644 --- a/crates/tracedecay-daemon-service/src/invocation/configuration/settlement.rs +++ b/crates/tracedecay-daemon-service/src/invocation/configuration/settlement.rs @@ -418,9 +418,7 @@ mod tests { assert!(!requires_daemon_restart(&pending, &advanced).unwrap()); } - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn authority( scope: &ResolvedScope, diff --git a/crates/tracedecay-daemon-service/src/invocation/feedback.rs b/crates/tracedecay-daemon-service/src/invocation/feedback.rs index 4abd6ba406..7c6fac4178 100644 --- a/crates/tracedecay-daemon-service/src/invocation/feedback.rs +++ b/crates/tracedecay-daemon-service/src/invocation/feedback.rs @@ -204,14 +204,10 @@ where .await } _ => { - return Err(ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "feedback.invalid_operation".to_owned(), - message: "The feedback read operation is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }); + return Err(ApplicationProblem::invalid_request_without_action( + "feedback.invalid_operation", + "The feedback read operation is invalid", + )); } } .map_err(feedback_owner_problem)?; diff --git a/crates/tracedecay-daemon-service/src/invocation/git.rs b/crates/tracedecay-daemon-service/src/invocation/git.rs index 52a58d09f7..cd31e22e7a 100644 --- a/crates/tracedecay-daemon-service/src/invocation/git.rs +++ b/crates/tracedecay-daemon-service/src/invocation/git.rs @@ -1068,14 +1068,10 @@ pub(super) fn stable_digest( } fn invalid_git_request() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "git_index.invalid_request".to_owned(), - message: "The Git index request is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - } + ApplicationProblem::invalid_request_without_action( + "git_index.invalid_request", + "The Git index request is invalid", + ) } fn map_git_error(error: GitIndexTransactionApplicationError) -> ApplicationProblem { diff --git a/crates/tracedecay-daemon-service/src/invocation/github_stack_signal.rs b/crates/tracedecay-daemon-service/src/invocation/github_stack_signal.rs index 23e6ae4da6..51941a46cd 100644 --- a/crates/tracedecay-daemon-service/src/invocation/github_stack_signal.rs +++ b/crates/tracedecay-daemon-service/src/invocation/github_stack_signal.rs @@ -194,13 +194,8 @@ fn github_stack_signal_evidence( } fn invalid_github_stack_signal_request() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "invalid_github_stack_signal_expand_request".to_owned(), - message: "The GitHub stack signal request does not match its operation contract" - .to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "invalid_github_stack_signal_expand_request", + "The GitHub stack signal request does not match its operation contract", + ) } diff --git a/crates/tracedecay-daemon-service/src/invocation/native_integration.rs b/crates/tracedecay-daemon-service/src/invocation/native_integration.rs index 7b45dc34fa..70f695e4e6 100644 --- a/crates/tracedecay-daemon-service/src/invocation/native_integration.rs +++ b/crates/tracedecay-daemon-service/src/invocation/native_integration.rs @@ -911,15 +911,10 @@ fn stack_coordinator_contract_error( /// its operation contract, or whose bounded authority receipt cannot be /// minted from the values the request supplied. fn invalid_native_integration_request() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "invalid_native_integration_request".to_owned(), - message: "The native-integration request does not match its operation contract" - .to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "invalid_native_integration_request", + "The native-integration request does not match its operation contract", + ) } /// Mint the request context and authority for exactly one native-integration diff --git a/crates/tracedecay-daemon-service/src/invocation/observatory.rs b/crates/tracedecay-daemon-service/src/invocation/observatory.rs index 4598f28020..22db6fa6ee 100644 --- a/crates/tracedecay-daemon-service/src/invocation/observatory.rs +++ b/crates/tracedecay-daemon-service/src/invocation/observatory.rs @@ -188,14 +188,10 @@ fn observatory_evidence( } fn invalid_observatory_request() -> ApplicationProblem { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.observatory.invalid-request".to_owned(), - message: "The Observatory read request is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - } + ApplicationProblem::invalid_request_without_action( + "application.observatory.invalid-request", + "The Observatory read request is invalid", + ) } fn observatory_unavailable(request_id: String, code: &str) -> DaemonInvocationResponse { diff --git a/crates/tracedecay-daemon-service/src/invocation/primitive.rs b/crates/tracedecay-daemon-service/src/invocation/primitive.rs index c0739d28d6..d4b66a3f19 100644 --- a/crates/tracedecay-daemon-service/src/invocation/primitive.rs +++ b/crates/tracedecay-daemon-service/src/invocation/primitive.rs @@ -323,14 +323,10 @@ pub(super) async fn execute_callable_code( fn invalid_callable_code_request(wire_request_id: String) -> DaemonInvocationResponse { application_problem( wire_request_id, - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "callable_code.invalid_query".to_owned(), - message: "The callable code query is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }, + ApplicationProblem::invalid_request_without_action( + "callable_code.invalid_query", + "The callable code query is invalid", + ), ) } @@ -360,15 +356,12 @@ pub fn callable_code_request_context( RetryDirective::Never, )); } - let request_id = - RequestId::new(wire_request_id).map_err(|_| ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "callable_code.invalid_request_id".to_owned(), - message: "The callable code request identifier is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - })?; + let request_id = RequestId::new(wire_request_id).map_err(|_| { + ApplicationProblem::invalid_request_without_action( + "callable_code.invalid_request_id", + "The callable code request identifier is invalid", + ) + })?; // Correlation IDs stay on the RequestContext. The route authority is a // function of the access and the operation, so the same authorized call // resolves the same grant from any surface and across durable retries. diff --git a/crates/tracedecay-daemon-service/src/invocation/source_edit.rs b/crates/tracedecay-daemon-service/src/invocation/source_edit.rs index cf79d48429..224f3113c2 100644 --- a/crates/tracedecay-daemon-service/src/invocation/source_edit.rs +++ b/crates/tracedecay-daemon-service/src/invocation/source_edit.rs @@ -4,10 +4,13 @@ use std::sync::Arc; use tracedecay_contracts::{ ApplicationExecutionFailureClassV1, ApplicationProblem, CancellationContext, Deadline, - LegalAction, RequestId, RetryDirective, SafeDiagnostic, SourceEditInvocationV1, - SourceEditReconciliationInvocationV1, SourceEditRollbackInvocationV1, + RequestId, SafeDiagnostic, SourceEditInvocationV1, SourceEditReconciliationInvocationV1, + SourceEditRollbackInvocationV1, }; use tracedecay_daemon_protocol::DaemonInvocationProblem; + +#[cfg(test)] +use tracedecay_contracts::RetryDirective; use tracedecay_domain::UtcMicros; use tracedecay_domain::errors::TraceDecayError; @@ -40,14 +43,10 @@ pub(super) async fn execute_source_edit( Err(_) => { return application_problem( request_id, - ApplicationProblem::InvalidRequest { - diagnostic: tracedecay_contracts::SafeDiagnostic { - code: "source_edit.invalid_request_id".to_owned(), - message: "The source-edit request id is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - }, + ApplicationProblem::invalid_request( + "source_edit.invalid_request_id", + "The source-edit request id is invalid", + ), ); } }; @@ -94,14 +93,10 @@ pub(super) async fn execute_source_edit_reconcile( Err(_) => { return application_problem( request_id, - ApplicationProblem::InvalidRequest { - diagnostic: tracedecay_contracts::SafeDiagnostic { - code: "source_edit.invalid_request_id".to_owned(), - message: "The source-edit request id is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - }, + ApplicationProblem::invalid_request( + "source_edit.invalid_request_id", + "The source-edit request id is invalid", + ), ); } }; @@ -148,14 +143,10 @@ pub(super) async fn execute_source_edit_rollback( Err(_) => { return application_problem( request_id, - ApplicationProblem::InvalidRequest { - diagnostic: tracedecay_contracts::SafeDiagnostic { - code: "source_edit.invalid_request_id".to_owned(), - message: "The source-edit request id is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - }, + ApplicationProblem::invalid_request( + "source_edit.invalid_request_id", + "The source-edit request id is invalid", + ), ); } }; @@ -189,15 +180,10 @@ fn map_source_edit_error( SourceEditOwnerError::NotAuthorized => concealed_application_problem(request_id), SourceEditOwnerError::InvalidContract => application_problem( request_id, - ApplicationProblem::InvalidRequest { - diagnostic: tracedecay_contracts::SafeDiagnostic { - code: "source_edit.invalid_request".to_owned(), - message: "The source-edit request does not match its invocation contract" - .to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - }, + ApplicationProblem::invalid_request( + "source_edit.invalid_request", + "The source-edit request does not match its invocation contract", + ), ), SourceEditOwnerError::Cancelled => { application_problem(request_id, ApplicationProblem::cancelled_before_admission()) @@ -217,25 +203,14 @@ const SOURCE_EDIT_SYMBOL_EVIDENCE_UNAVAILABLE: &str = "source-edit-symbol-eviden const SOURCE_EDIT_DIAGNOSTICS_UNAVAILABLE: &str = "source_edit_diagnostics_unavailable"; fn sanitize_safe_diagnostic_text(value: &str, limit: usize) -> String { - let collapsed: String = value - .chars() - .map(|character| { - if character.is_control() { - ' ' - } else { - character - } - }) - .collect(); + let collapsed = tracedecay_domain::fold_control_characters(value); let trimmed = collapsed.trim(); if trimmed.is_empty() { return String::new(); } - let mut end = trimmed.len().min(limit); - while end > 0 && !trimmed.is_char_boundary(end) { - end -= 1; - } - trimmed[..end].trim_end().to_owned() + tracedecay_domain::utf8_prefix_at_or_before(trimmed, limit) + .trim_end() + .to_owned() } fn source_edit_kernel_cause(error: &TraceDecayError) -> (String, String) { @@ -277,11 +252,10 @@ fn source_edit_execution_problem( let diagnostic = source_edit_safe_diagnostic(code, message)?; match diagnostic.code.as_str() { SOURCE_EDIT_EXPECTED_STATE_MISMATCH => Ok(ApplicationProblem::stale(diagnostic)), - SOURCE_EDIT_IDEMPOTENCY_CONFLICT => Ok(ApplicationProblem::Conflict { - diagnostic, - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }), + SOURCE_EDIT_IDEMPOTENCY_CONFLICT => Ok(ApplicationProblem::conflict( + diagnostic.code, + diagnostic.message, + )), SOURCE_EDIT_SYMBOL_EVIDENCE_UNAVAILABLE | SOURCE_EDIT_DIAGNOSTICS_UNAVAILABLE => { Ok(ApplicationProblem::unavailable(diagnostic)) } diff --git a/crates/tracedecay-daemon-service/src/invocation/tests/feedback_tests.rs b/crates/tracedecay-daemon-service/src/invocation/tests/feedback_tests.rs index cc96b8a536..6ec8dd5829 100644 --- a/crates/tracedecay-daemon-service/src/invocation/tests/feedback_tests.rs +++ b/crates/tracedecay-daemon-service/src/invocation/tests/feedback_tests.rs @@ -47,14 +47,10 @@ impl DaemonAdvisoryCycleInvocationPort for MountedAdvisoryCycle { _request: DaemonAdvisoryCycleInvocationRequest, ) -> DaemonAdvisoryCycleInvocationFuture<'_> { Box::pin(async { - Err(ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "feedback.test-mounted-advisory-owner".to_owned(), - message: "The mounted advisory owner received the request".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }) + Err(ApplicationProblem::invalid_request_without_action( + "feedback.test-mounted-advisory-owner", + "The mounted advisory owner received the request", + )) }) } @@ -63,14 +59,10 @@ impl DaemonAdvisoryCycleInvocationPort for MountedAdvisoryCycle { _request: DaemonFeedbackProximityInvocationRequest, ) -> DaemonFeedbackProximityInvocationFuture<'_> { Box::pin(async { - Err(ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "feedback.test-mounted-proximity-owner".to_owned(), - message: "The mounted proximity owner received the request".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }) + Err(ApplicationProblem::invalid_request_without_action( + "feedback.test-mounted-proximity-owner", + "The mounted proximity owner received the request", + )) }) } } diff --git a/crates/tracedecay-daemon-service/src/invocation/tests/invocation_observability_tests.rs b/crates/tracedecay-daemon-service/src/invocation/tests/invocation_observability_tests.rs index a36227925d..7f60ef6fc7 100644 --- a/crates/tracedecay-daemon-service/src/invocation/tests/invocation_observability_tests.rs +++ b/crates/tracedecay-daemon-service/src/invocation/tests/invocation_observability_tests.rs @@ -77,15 +77,10 @@ fn scoped_retained_invalid_request_preserves_rejection_classification() { let response = DaemonInvocationResponse::retained_application_problem( "request.retained.observability", scope, - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic::new( - "retained.observability.invalid", - "The retained observability fixture request is invalid", - ) - .expect("diagnostic"), - retry: RetryDirective::Never, - legal_actions: Vec::new(), - }, + ApplicationProblem::invalid_request_without_action( + "retained.observability.invalid", + "The retained observability fixture request is invalid", + ), ); assert_eq!( diff --git a/crates/tracedecay-daemon-service/src/invocation/work/attempt_operations.rs b/crates/tracedecay-daemon-service/src/invocation/work/attempt_operations.rs index 31835c757e..6714debd2a 100644 --- a/crates/tracedecay-daemon-service/src/invocation/work/attempt_operations.rs +++ b/crates/tracedecay-daemon-service/src/invocation/work/attempt_operations.rs @@ -5,10 +5,10 @@ use std::sync::Arc; use tracedecay_application::observability::BoundedObservabilityProducerV1; use tracedecay_contracts::{ - AdmitWorkSynthesisCommand, ApplicationProblem, CancelWorkAttemptCommand, Deadline, LegalAction, - RequestContext, RequestId, ResumeWorkAttemptsCommand, RetryDirective, - RetryWorkAttemptCommandV1, SafeDiagnostic, StartWorkAttemptCommand, WorkAttemptStatusRequestV1, - WorkSynthesisAttemptV1, WorkflowArtifactStorePort, + AdmitWorkSynthesisCommand, ApplicationProblem, CancelWorkAttemptCommand, Deadline, + RequestContext, RequestId, ResumeWorkAttemptsCommand, RetryWorkAttemptCommandV1, + SafeDiagnostic, StartWorkAttemptCommand, WorkAttemptStatusRequestV1, WorkSynthesisAttemptV1, + WorkflowArtifactStorePort, }; use tracedecay_domain::{ManifestDigest, UtcMicros, WorkAttemptStateV1, WorkAttemptV1}; use tracedecay_tool_catalog::UseCaseId; @@ -26,14 +26,7 @@ use super::{ }; fn consume_synthesis_bytes(remaining: &mut u64, bytes: u64) -> Result<(), ApplicationProblem> { - *remaining = remaining.checked_sub(bytes).ok_or_else(|| ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "application.work-synthesis.source-context-oversized".to_owned(), - message: "The synthesis instructions and source payloads exceed the admitted protocol byte bound.".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - })?; + *remaining = remaining.checked_sub(bytes).ok_or_else(|| ApplicationProblem::invalid_request("application.work-synthesis.source-context-oversized", "The synthesis instructions and source payloads exceed the admitted protocol byte bound."))?; Ok(()) } @@ -461,15 +454,10 @@ pub(super) fn resume_attempts( // still owns would strand the durable attempt on a new epoch while the old // task can no longer settle it. let report = if attempt_processes.holds_worktree(&context.scope().worktree_id) { - Err(ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic { - code: "application.work-attempt.live-holder".to_owned(), - message: "Work attempt recovery requires the current worktree to have no live provider holder." - .to_owned(), - }, - retry: tracedecay_contracts::RetryDirective::AfterRevalidate, - legal_actions: vec![tracedecay_contracts::LegalAction::Refresh], - }) + Err(ApplicationProblem::conflict( + "application.work-attempt.live-holder", + "Work attempt recovery requires the current worktree to have no live provider holder.", + )) } else { services.attempts().resume(context, &command) }; diff --git a/crates/tracedecay-daemon-service/src/invocation/work/outcome.rs b/crates/tracedecay-daemon-service/src/invocation/work/outcome.rs index 1e546ffbfb..8b9e86d7b2 100644 --- a/crates/tracedecay-daemon-service/src/invocation/work/outcome.rs +++ b/crates/tracedecay-daemon-service/src/invocation/work/outcome.rs @@ -69,32 +69,23 @@ pub(super) fn work_product_problem(error: WorkProductApplicationErrorV1) -> Appl ApplicationProblem::cancelled_before_admission() } WorkProductApplicationErrorV1::TimedOut => ApplicationProblem::timed_out_before_admission(), - WorkProductApplicationErrorV1::InvalidRequest => ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "work.invalid_graph_operation".to_owned(), - message: "The Work graph request is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - }, + WorkProductApplicationErrorV1::InvalidRequest => ApplicationProblem::invalid_request( + "work.invalid_graph_operation", + "The Work graph request is invalid", + ), // A read under this selection succeeds and discloses what it left out; // a mutation cannot, because the head it would pin is the covered // slice's, not the journal's. The refusal therefore names the cause and // the remedy instead of hiding behind the concealed not-found answer // the old fail-closed refusal produced. WorkProductApplicationErrorV1::SelectionCoverageIncomplete => { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "work.selection_coverage_incomplete".to_owned(), - message: "The Work selection covers only part of the owner's journal, so no \ + ApplicationProblem::invalid_request( + "work.selection_coverage_incomplete", + "The Work selection covers only part of the owner's journal, so no \ graph mutation can be prepared or submitted against it; widen the \ selection to the relation scopes the excluded events were admitted \ - under" - .to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - } + under", + ) } WorkProductApplicationErrorV1::VersionConflict => { ApplicationProblem::stale(SafeDiagnostic { diff --git a/crates/tracedecay-daemon-service/src/invocation/work/workflow_dispatch.rs b/crates/tracedecay-daemon-service/src/invocation/work/workflow_dispatch.rs index 4fb37d445f..43d93df99b 100644 --- a/crates/tracedecay-daemon-service/src/invocation/work/workflow_dispatch.rs +++ b/crates/tracedecay-daemon-service/src/invocation/work/workflow_dispatch.rs @@ -247,11 +247,10 @@ pub(crate) async fn execute_workflow_application( { return DaemonInvocationResponse::application_problem( request_id, - tracedecay_contracts::ApplicationProblem::InvalidRequest { - diagnostic, - retry: tracedecay_contracts::RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - }, + tracedecay_contracts::ApplicationProblem::invalid_request( + diagnostic.code, + diagnostic.message, + ), ); } complete_workflow_read( diff --git a/crates/tracedecay-daemon-service/src/invocation/work/workflow_effect_journal.rs b/crates/tracedecay-daemon-service/src/invocation/work/workflow_effect_journal.rs index 5c51fa9ba3..40287d47f0 100644 --- a/crates/tracedecay-daemon-service/src/invocation/work/workflow_effect_journal.rs +++ b/crates/tracedecay-daemon-service/src/invocation/work/workflow_effect_journal.rs @@ -236,11 +236,10 @@ pub(super) fn execute_journaled_workflow_effect( { return DaemonInvocationResponse::application_problem( request_id, - ApplicationProblem::InvalidRequest { - diagnostic: diagnostic.clone(), - retry: tracedecay_contracts::RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - }, + ApplicationProblem::invalid_request( + diagnostic.code.clone(), + diagnostic.message.clone(), + ), ); } let outcome = match workflow_effect_outcome(terminal) { diff --git a/crates/tracedecay-daemon-service/src/invocation/work/workflow_run_control.rs b/crates/tracedecay-daemon-service/src/invocation/work/workflow_run_control.rs index 9fa63a25a1..4f52af0327 100644 --- a/crates/tracedecay-daemon-service/src/invocation/work/workflow_run_control.rs +++ b/crates/tracedecay-daemon-service/src/invocation/work/workflow_run_control.rs @@ -7,8 +7,8 @@ use std::sync::atomic::AtomicBool; use tracedecay_application::work::workflow_topology::WorkflowTopologyError; use tracedecay_contracts::{ - ApplicationProblem, LegalAction, RequestContext, RetryDirective, SafeDiagnostic, - WorkflowCatalogAdmissionError, WorkflowCoordinationError, WorkflowRunStoragePort, + ApplicationProblem, RequestContext, SafeDiagnostic, WorkflowCatalogAdmissionError, + WorkflowCoordinationError, WorkflowRunStoragePort, }; use tracedecay_domain::{ManifestDigest, UtcMicros}; @@ -447,11 +447,10 @@ pub(super) fn workflow_coordination_application_problem( | WorkflowCoordinationError::DefinitionNotFound | WorkflowCoordinationError::AuthorityUnavailable(_) => return None, }; - Some(ApplicationProblem::InvalidRequest { - diagnostic, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - }) + Some(ApplicationProblem::invalid_request( + diagnostic.code, + diagnostic.message, + )) } pub(super) fn workflow_run_storage_problem( diff --git a/crates/tracedecay-daemon-service/src/invocation/work_attempt_exec/tests.rs b/crates/tracedecay-daemon-service/src/invocation/work_attempt_exec/tests.rs index b26654acb7..8d0e48a8f0 100644 --- a/crates/tracedecay-daemon-service/src/invocation/work_attempt_exec/tests.rs +++ b/crates/tracedecay-daemon-service/src/invocation/work_attempt_exec/tests.rs @@ -73,17 +73,9 @@ const CODEX_EXEC_JSON_ARGV: [&str; 3] = ["exec", "--json", "-"]; // In-memory attempt authority // --------------------------------------------------------------------------- -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn sha256_digest(bytes: &[u8]) -> ManifestDigest { ManifestDigest::new(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))).unwrap() diff --git a/crates/tracedecay-daemon-service/src/profile_host_admission_replay.rs b/crates/tracedecay-daemon-service/src/profile_host_admission_replay.rs index 255e0622ef..866dbcc414 100644 --- a/crates/tracedecay-daemon-service/src/profile_host_admission_replay.rs +++ b/crates/tracedecay-daemon-service/src/profile_host_admission_replay.rs @@ -639,9 +639,14 @@ impl ProfileHostAdmissionBootstrapWorker { event = "profile_host_admission_bootstrap_exhausted", reason_code, attempts = consecutive_retryable, - elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX), + elapsed_ms = + tracedecay_runtime_core::tracedecay::saturating_duration_millis( + elapsed, + ), budget_ms = - u64::try_from(self.retry_budget.as_millis()).unwrap_or(u64::MAX), + tracedecay_runtime_core::tracedecay::saturating_duration_millis( + self.retry_budget, + ), "profile host admission bootstrap gave up after its retry budget; \ it resumes on the next admission or daemon restart" ); diff --git a/crates/tracedecay-daemon-service/src/query_mcp_admission.rs b/crates/tracedecay-daemon-service/src/query_mcp_admission.rs index a3144169b1..f524ab410d 100644 --- a/crates/tracedecay-daemon-service/src/query_mcp_admission.rs +++ b/crates/tracedecay-daemon-service/src/query_mcp_admission.rs @@ -327,13 +327,7 @@ mod tests { use super::{QueryMcpAdmissionUnavailableV1, admit_query_mcp_read_at}; - fn id(value: &str) -> T - where - T: TryFrom, - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("typed fixture id") - } + use tracedecay_domain::test_fixtures::id; fn scoped( project: &str, diff --git a/crates/tracedecay-dashboard-api/Cargo.toml b/crates/tracedecay-dashboard-api/Cargo.toml index c85f9419f5..042a8f1e64 100644 --- a/crates/tracedecay-dashboard-api/Cargo.toml +++ b/crates/tracedecay-dashboard-api/Cargo.toml @@ -32,7 +32,6 @@ tracedecay-domain = { path = "../tracedecay-domain", version = "0.1.0" } tracedecay-global-db = { path = "../tracedecay-global-db", version = "0.1.0" } tracedecay-graph-db = { path = "../tracedecay-graph-db", version = "0.1.0" } tracedecay-graph-query = { path = "../tracedecay-graph-query", version = "0.1.0" } -tracedecay-hooks = { path = "../tracedecay-hooks", version = "0.1.0" } tracedecay-lsp = { path = "../tracedecay-lsp", version = "0.1.0" } tracedecay-privacy = { path = "../tracedecay-privacy", version = "0.1.0" } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0" } diff --git a/crates/tracedecay-dashboard-api/src/analytics_api.rs b/crates/tracedecay-dashboard-api/src/analytics_api.rs index 3f9eebaa8e..31f819e5f1 100644 --- a/crates/tracedecay-dashboard-api/src/analytics_api.rs +++ b/crates/tracedecay-dashboard-api/src/analytics_api.rs @@ -246,7 +246,7 @@ pub async fn overview( durable_events.as_deref(), Some(&project_id), ), - typed_usage_summary(state.lcm_db.as_deref(), durable_events.as_deref()), + usage_summary(state.lcm_db.as_deref(), durable_events.as_deref()), typed_diagnostics_summary(&state, durable_events.as_deref()), ); let usage = match usage { @@ -858,7 +858,7 @@ pub async fn usage( hotpath::future!( async move { let durable_events = durable_analytics_rows_for_state(&state).await; - match typed_usage_summary(state.lcm_db.as_deref(), durable_events.as_deref()).await { + match usage_summary(state.lcm_db.as_deref(), durable_events.as_deref()).await { Ok(payload) if !payload.available => Json(DashboardEnvelopeV1::unavailable( scope_from_state(&state), Some(payload), @@ -1052,10 +1052,7 @@ async fn durable_analytics_rows( } pub fn hint_summary_from_events(events: &[AnalyticsEventRecord]) -> AnalyticsHintsPayloadV1 { - let mut by_category: BTreeMap = HINT_CATEGORIES - .iter() - .map(|category| ((*category).to_string(), HintCounts::default())) - .collect(); + let mut by_category = zero_hint_counts(); for event in events { let category = event.hint_category.as_deref().unwrap_or(""); @@ -1094,41 +1091,25 @@ pub fn hint_summary_from_events(events: &[AnalyticsEventRecord]) -> AnalyticsHin } pub fn hint_summary_from_counts(counts: &[AnalyticsHintCounts]) -> Value { - let mut by_category: BTreeMap = HINT_CATEGORIES - .iter() - .map(|category| ((*category).to_string(), HintCounts::default())) - .collect(); - for row in counts { - by_category.insert( - row.category.clone(), - HintCounts { - emitted: row.emitted, - followed: row.followed, - ignored: row.ignored, - suppressed: row.suppressed, - }, - ); - } + let summary = typed_hint_summary_from_counts(counts); + // MCP callers omit `error`. The typed payload keeps it for dashboard + // envelopes, including the explicit null when the read succeeded. json!({ - "available": true, - "source": "analytics_events", - "by_category": by_category.into_iter().map(|(category, counts)| { - json!({ - "category": category, - "emitted": counts.emitted, - "followed": counts.followed, - "ignored": counts.ignored, - "suppressed": counts.suppressed, - }) - }).collect::>(), + "available": summary.available, + "source": summary.source, + "by_category": summary.by_category, }) } -fn typed_hint_summary_from_counts(counts: &[AnalyticsHintCounts]) -> AnalyticsHintsPayloadV1 { - let mut by_category: BTreeMap = HINT_CATEGORIES +fn zero_hint_counts() -> BTreeMap { + HINT_CATEGORIES .iter() .map(|category| ((*category).to_string(), HintCounts::default())) - .collect(); + .collect() +} + +fn typed_hint_summary_from_counts(counts: &[AnalyticsHintCounts]) -> AnalyticsHintsPayloadV1 { + let mut by_category = zero_hint_counts(); for row in counts { by_category.insert( row.category.clone(), @@ -1356,19 +1337,9 @@ fn increment_usage_count(counts: &mut BTreeMap<(String, String), i64>, kind: &st .or_default() += 1; } -/// The contract form of the usage summary, shared by `GET .../usage` and the -/// `usage` member of the overview payload. +/// Shared by `GET .../usage` and the `usage` member of the overview payload. /// -/// Absent `source` / `event_count` stay `None` on the struct so serde writes -/// them as explicit nulls. The previous JSON literals omitted those keys and -/// had to round-trip through this type to keep that distinction. -async fn typed_usage_summary( - db: Option<&RegisteredGlobalDb>, - durable_events: Option<&[AnalyticsEventRecord]>, -) -> Result { - usage_summary(db, durable_events).await -} - +/// Absent `source` / `event_count` stay `None` so serde writes explicit nulls. async fn usage_summary( db: Option<&RegisteredGlobalDb>, durable_events: Option<&[AnalyticsEventRecord]>, diff --git a/crates/tracedecay-dashboard-api/src/automation_authority.rs b/crates/tracedecay-dashboard-api/src/automation_authority.rs index 9fe4ff5cb4..bc1a745bd8 100644 --- a/crates/tracedecay-dashboard-api/src/automation_authority.rs +++ b/crates/tracedecay-dashboard-api/src/automation_authority.rs @@ -68,28 +68,7 @@ impl DashboardAutomationAuthorityErrorV1 { } fn application_problem_status(problem: &ApplicationProblemEnvelope) -> StatusCode { - match problem.problem.kind() { - tracedecay_contracts::ApplicationProblemKind::PartialEffect - | tracedecay_contracts::ApplicationProblemKind::Conflict - | tracedecay_contracts::ApplicationProblemKind::Stale => StatusCode::CONFLICT, - tracedecay_contracts::ApplicationProblemKind::InvalidRequest => StatusCode::BAD_REQUEST, - tracedecay_contracts::ApplicationProblemKind::NotFoundOrNotAuthorized => { - StatusCode::NOT_FOUND - } - tracedecay_contracts::ApplicationProblemKind::Unsupported => { - StatusCode::UNPROCESSABLE_ENTITY - } - tracedecay_contracts::ApplicationProblemKind::ResetRequired - | tracedecay_contracts::ApplicationProblemKind::Unavailable => { - StatusCode::SERVICE_UNAVAILABLE - } - tracedecay_contracts::ApplicationProblemKind::ExecutionFailed => { - StatusCode::INTERNAL_SERVER_ERROR - } - tracedecay_contracts::ApplicationProblemKind::Saturated => StatusCode::TOO_MANY_REQUESTS, - tracedecay_contracts::ApplicationProblemKind::Cancelled => StatusCode::REQUEST_TIMEOUT, - tracedecay_contracts::ApplicationProblemKind::TimedOut => StatusCode::GATEWAY_TIMEOUT, - } + tracedecay_api::application_problem_status(problem.problem.kind()) } pub(crate) fn automation_authority_error_response( diff --git a/crates/tracedecay-dashboard-api/src/automation_fact_receipts_api.rs b/crates/tracedecay-dashboard-api/src/automation_fact_receipts_api.rs index 76d5a7ff10..d86fccd5cb 100644 --- a/crates/tracedecay-dashboard-api/src/automation_fact_receipts_api.rs +++ b/crates/tracedecay-dashboard-api/src/automation_fact_receipts_api.rs @@ -4,10 +4,11 @@ use axum::response::Json; use serde::Deserialize; use serde_json::{Value, json}; -use super::util::{JsonQuery, coerce_limit, http_detail}; +use super::util::{JsonQuery, coerce_limit, json_error}; use super::{DashboardState, RequestControl}; -use crate::memory_api::control::{fact_read_control, request_terminal_state, terminal_read_code}; -use crate::read_model::DashboardDomainStateV1; +use crate::memory_api::control::{ + fact_read_control, request_terminal_state, terminal_read_response, +}; use crate::tracedecay::facts::memory_application_for_db; use tracedecay_automation_runtime::automation::automatic_facts::{ AutomaticFactReceipt, AutomaticFactState, list_automatic_fact_receipts, @@ -30,7 +31,7 @@ pub async fn list( let receipt_state = match params.state.as_deref() { Some(value) => match AutomaticFactState::parse(value) { Ok(state) => Some(state), - Err(err) => return (StatusCode::BAD_REQUEST, Json(http_detail(&err.to_string()))), + Err(err) => return json_error(StatusCode::BAD_REQUEST, err.to_string()), }, None => None, }; @@ -39,31 +40,15 @@ pub async fn list( 50, MAX_PROJECT_MEMORY_AUTOMATIC_FACT_RECEIPTS as i64, ) as usize; - let memory = match memory_application_for_db(state.memory_owner.clone(), state.mem_db.as_ref()) - { + let memory = match open_receipt_memory(&state) { Ok(memory) => memory, - Err(err) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(http_detail(&format!( - "Failed to initialize automatic fact receipt authority: {err}" - ))), - ); - } + Err(error) => return error, }; let result = list_automatic_fact_receipts(&memory, receipt_state, limit, &fact_read_control(&control)) .await; if let Some(state) = request_terminal_state(&control) { - let (code, detail) = terminal_read_code(state); - return ( - if state == DashboardDomainStateV1::TimedOut { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::REQUEST_TIMEOUT - }, - Json(json!({"detail": detail, "code": code})), - ); + return terminal_read_response(state); } match result { Ok(receipts) => { @@ -78,11 +63,9 @@ pub async fn list( })), ) } - Err(err) => ( + Err(err) => json_error( StatusCode::INTERNAL_SERVER_ERROR, - Json(http_detail(&format!( - "Failed to load automatic fact receipts: {err}" - ))), + format!("Failed to load automatic fact receipts: {err}"), ), } } @@ -93,47 +76,43 @@ pub async fn view( RequestControl(control): RequestControl, AxumPath(id): AxumPath, ) -> (StatusCode, Json) { - let memory = match memory_application_for_db(state.memory_owner.clone(), state.mem_db.as_ref()) - { + let memory = match open_receipt_memory(&state) { Ok(memory) => memory, - Err(err) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(http_detail(&format!( - "Failed to initialize automatic fact receipt authority: {err}" - ))), - ); - } + Err(error) => return error, }; let result = load_automatic_fact_receipt(&memory, &id, &fact_read_control(&control)).await; if let Some(state) = request_terminal_state(&control) { - let (code, detail) = terminal_read_code(state); - return ( - if state == DashboardDomainStateV1::TimedOut { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::REQUEST_TIMEOUT - }, - Json(json!({"detail": detail, "code": code})), - ); + return terminal_read_response(state); } match result { Ok(Some(receipt)) => (StatusCode::OK, Json(receipt_payload(&receipt))), - Ok(None) => ( + Ok(None) => json_error( StatusCode::NOT_FOUND, - Json(http_detail(&format!( - "automatic fact receipt not found: {id}" - ))), + format!("automatic fact receipt not found: {id}"), ), - Err(err) => ( + Err(err) => json_error( StatusCode::INTERNAL_SERVER_ERROR, - Json(http_detail(&format!( - "Failed to load automatic fact receipt: {err}" - ))), + format!("Failed to load automatic fact receipt: {err}"), ), } } +fn open_receipt_memory( + state: &DashboardState, +) -> std::result::Result< + tracedecay_session_memory::memory::MemoryApplication< + tracedecay_session_memory::fact_store::DatabaseFactStore<'_>, + >, + (StatusCode, Json), +> { + memory_application_for_db(state.memory_owner.clone(), state.mem_db.as_ref()).map_err(|err| { + json_error( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to initialize automatic fact receipt authority: {err}"), + ) + }) +} + fn receipt_payload(receipt: &AutomaticFactReceipt) -> Value { json!({ "receipt": receipt, diff --git a/crates/tracedecay-dashboard-api/src/automation_jobs_api.rs b/crates/tracedecay-dashboard-api/src/automation_jobs_api.rs index 4122e13d65..e2696ced13 100644 --- a/crates/tracedecay-dashboard-api/src/automation_jobs_api.rs +++ b/crates/tracedecay-dashboard-api/src/automation_jobs_api.rs @@ -16,7 +16,7 @@ use serde::Deserialize; use serde_json::{Value, json}; use super::DashboardState; -use super::util::{JsonError, http_detail, internal_error}; +use super::util::{JsonError, internal_error, json_error}; use super::{ DashboardAutomationRunRequestV1, DashboardHttpRequestControlV1, automation_authority_error_response, exact_automation_authority, @@ -97,7 +97,7 @@ pub async fn list(State(state): State) -> ApiResult { #[hotpath::measure(label = "dashboard_api.jobs.create", future = true)] pub async fn create(State(state): State, Json(body): Json) -> ApiResult { let body = serde_json::from_value::(body) - .map_err(|err| bad_request(&format!("invalid job: {err}")))?; + .map_err(|err| json_error(StatusCode::BAD_REQUEST, format!("invalid job: {err}")))?; let now = current_timestamp(); let job = AutomationJob { id: match body.id { @@ -117,7 +117,7 @@ pub async fn create(State(state): State, Json(body): Json updated_at: now, extra: BTreeMap::new(), }; - validate_job(&job).map_err(|err| bad_request(&err.to_string()))?; + validate_job(&job).map_err(|err| json_error(StatusCode::BAD_REQUEST, err.to_string()))?; let job_for_write = job.clone(); let result = super::automation_run_service::execute_dashboard_automation_write( &state, @@ -139,7 +139,10 @@ pub async fn create(State(state): State, Json(body): Json .await .map_err(|err| internal_error(&err))?; if result["conflict"] == true { - return Err(bad_request(&format!("job '{}' already exists", job.id))); + return Err(json_error( + StatusCode::BAD_REQUEST, + format!("job '{}' already exists", job.id), + )); } Ok(Json(json!({ "job": job }))) } @@ -160,7 +163,7 @@ pub async fn update( Json(body): Json, ) -> ApiResult { let patch = serde_json::from_value::(body) - .map_err(|err| bad_request(&format!("invalid job patch: {err}")))?; + .map_err(|err| json_error(StatusCode::BAD_REQUEST, format!("invalid job patch: {err}")))?; let job_id_for_write = job_id.clone(); let result = super::automation_run_service::execute_dashboard_automation_write( &state, @@ -219,7 +222,7 @@ pub async fn update( return Err(not_found(&job_id)); } if let Some(message) = result["validation_error"].as_str() { - return Err(bad_request(message)); + return Err(json_error(StatusCode::BAD_REQUEST, message)); } let updated = serde_json::from_value::(result["job"].clone()) .map_err(|err| internal_error(&err))?; @@ -293,7 +296,7 @@ async fn load_job_or_404( state: &DashboardState, job_id: &str, ) -> std::result::Result { - validate_job_id(job_id).map_err(|err| bad_request(&err.to_string()))?; + validate_job_id(job_id).map_err(|err| json_error(StatusCode::BAD_REQUEST, err.to_string()))?; match find_job(&state.dashboard_root, job_id).await { Ok(Some(job)) => Ok(job), Ok(None) => Err(not_found(job_id)), @@ -331,13 +334,9 @@ fn generated_job_id(name: &str) -> String { format!("{slug}-{}", micros_now() % 1_000_000) } -fn bad_request(message: &str) -> JsonError { - (StatusCode::BAD_REQUEST, Json(http_detail(message))) -} - fn not_found(job_id: &str) -> JsonError { - ( + json_error( StatusCode::NOT_FOUND, - Json(http_detail(&format!("automation job '{job_id}' not found"))), + format!("automation job '{job_id}' not found"), ) } diff --git a/crates/tracedecay-dashboard-api/src/automation_outcomes_api.rs b/crates/tracedecay-dashboard-api/src/automation_outcomes_api.rs index 3aea8afb6b..c436016006 100644 --- a/crates/tracedecay-dashboard-api/src/automation_outcomes_api.rs +++ b/crates/tracedecay-dashboard-api/src/automation_outcomes_api.rs @@ -10,8 +10,9 @@ use serde_json::{Value, json}; use super::automation_authority_error_response; use super::exact_automation_authority; use super::{DashboardAutomationAuthorityErrorV1, DashboardState, RequestControl}; -use crate::memory_api::control::{fact_read_control, request_terminal_state, terminal_read_code}; -use crate::read_model::DashboardDomainStateV1; +use crate::memory_api::control::{ + fact_read_control, request_terminal_state, terminal_read_response, +}; use tracedecay_automation_runtime::automation::managed_skills::list_managed_skills; use tracedecay_automation_runtime::automation::outcomes::{ AutomationOutcomesSnapshot, compute_fact_outcomes, compute_skill_outcomes, @@ -29,15 +30,7 @@ pub async fn outcomes( ) -> (StatusCode, Json) { let result = outcomes_payload(&state, &fact_read_control(&control)).await; if let Some(state) = request_terminal_state(&control) { - let (code, detail) = terminal_read_code(state); - return ( - if state == DashboardDomainStateV1::TimedOut { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::REQUEST_TIMEOUT - }, - Json(json!({"detail": detail, "code": code})), - ); + return terminal_read_response(state); } match result { Ok(payload) => (StatusCode::OK, Json(payload)), diff --git a/crates/tracedecay-dashboard-api/src/automation_run_api.rs b/crates/tracedecay-dashboard-api/src/automation_run_api.rs index 46e539a785..c50e2f4f32 100644 --- a/crates/tracedecay-dashboard-api/src/automation_run_api.rs +++ b/crates/tracedecay-dashboard-api/src/automation_run_api.rs @@ -5,7 +5,7 @@ use serde::Deserialize; use serde_json::{Value, json}; use super::DashboardState; -use super::util::http_detail; +use super::util::{internal_error, json_error}; use tracedecay_automation_runtime::automation::run_ledger::{ AutomationRunArtifact, AutomationRunArtifactKind, AutomationRunLedgerRecord, find_run_record, read_published_artifact_chain, read_run_artifact_payload, @@ -57,7 +57,7 @@ pub async fn run_list( })), ) } - Err(err) => internal_error(&format!("Failed to read automation run ledger: {err}")), + Err(err) => internal_error(format!("Failed to read automation run ledger: {err}")), } } @@ -129,8 +129,11 @@ pub async fn artifact_list( })), ) } - Ok(None) => not_found(&format!("automation run '{run_id}' not found")), - Err(err) => internal_error(&format!("Failed to load automation run artifacts: {err}")), + Ok(None) => json_error( + StatusCode::NOT_FOUND, + format!("automation run '{run_id}' not found"), + ), + Err(err) => internal_error(format!("Failed to load automation run artifacts: {err}")), } } @@ -142,16 +145,20 @@ pub async fn artifact_payload( let record = match find_run_record(&state.dashboard_root, &run_id).await { Ok(Some(record)) => record, Ok(None) => { - return not_found(&format!("automation run '{run_id}' not found")); + return json_error( + StatusCode::NOT_FOUND, + format!("automation run '{run_id}' not found"), + ); } Err(err) => { - return internal_error(&format!("Failed to load automation run artifact: {err}")); + return internal_error(format!("Failed to load automation run artifact: {err}")); } }; let Some(artifact) = find_artifact(&record.artifacts, &kind) else { - return not_found(&format!( - "automation run artifact '{kind}' not found for run '{run_id}'" - )); + return json_error( + StatusCode::NOT_FOUND, + format!("automation run artifact '{kind}' not found for run '{run_id}'"), + ); }; // Heavy per-run payloads (proposed/applied ops, validation reports) are // read and parsed here; this span scales with artifact size while the @@ -171,21 +178,10 @@ pub async fn artifact_payload( "error": "", })), ), - Err(err) => internal_error(&format!("Failed to read automation run artifact: {err}")), + Err(err) => internal_error(format!("Failed to read automation run artifact: {err}")), } } -fn not_found(message: &str) -> (StatusCode, Json) { - (StatusCode::NOT_FOUND, Json(http_detail(message))) -} - -fn internal_error(message: &str) -> (StatusCode, Json) { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(http_detail(message)), - ) -} - fn find_artifact<'a>( artifacts: &'a [AutomationRunArtifact], kind: &str, diff --git a/crates/tracedecay-dashboard-api/src/automation_skills_api.rs b/crates/tracedecay-dashboard-api/src/automation_skills_api.rs index 82b9cf0a5d..53bc094bfa 100644 --- a/crates/tracedecay-dashboard-api/src/automation_skills_api.rs +++ b/crates/tracedecay-dashboard-api/src/automation_skills_api.rs @@ -6,7 +6,7 @@ use axum::http::StatusCode; use serde::Deserialize; use serde_json::{Value, json}; -use super::util::{JsonError, http_detail, internal_error}; +use super::util::{JsonError, internal_error, json_error}; use super::{ DashboardManagedSkillCommandOutcomeV1, DashboardManagedSkillCommandV1, DashboardState, automation_authority_error_response, exact_automation_authority, @@ -86,7 +86,7 @@ pub async fn view(State(state): State, Path(id): Path) - let skill = load_managed_skill(profile_root, &id) .await .map_err(|err| not_found_or_internal(&err))?; - skill_payload(profile_root, skill).await + skill_payload_with_deployment(profile_root, skill, None).await } #[hotpath::measure(label = "dashboard_api.skills.create", future = true)] @@ -164,10 +164,6 @@ async fn execute_skill_command( skill_payload_with_deployment(authority.profile_root(), skill, Some(deployment)).await } -async fn skill_payload(profile_root: &std::path::Path, skill: ManagedSkill) -> ApiResult { - skill_payload_with_deployment(profile_root, skill, None).await -} - async fn skill_payload_with_deployment( profile_root: &std::path::Path, skill: ManagedSkill, @@ -215,10 +211,6 @@ fn profile_root(state: &DashboardState) -> std::result::Result<&std::path::Path, Ok(automation_authority(state)?.profile_root()) } -fn bad_request(err: &impl ToString) -> JsonError { - (StatusCode::BAD_REQUEST, Json(http_detail(&err.to_string()))) -} - fn bad_request_or_internal(err: &impl ToString) -> JsonError { client_error_or_internal(err, false, true) } @@ -234,11 +226,11 @@ fn client_error_or_internal( ) -> JsonError { let message = err.to_string(); if allow_not_found && is_not_found(&message) { - not_found(&message) + json_error(StatusCode::NOT_FOUND, message) } else if allow_bad_request && is_bad_request(&message) { - bad_request(&message) + json_error(StatusCode::BAD_REQUEST, message) } else { - internal_error(&message) + internal_error(message) } } @@ -246,10 +238,6 @@ fn is_not_found(message: &str) -> bool { message.contains("No such file") || message.contains("not found") } -fn not_found(message: &str) -> JsonError { - (StatusCode::NOT_FOUND, Json(http_detail(message))) -} - fn is_bad_request(message: &str) -> bool { message.contains("unsafe") || message.contains("cannot be empty") diff --git a/crates/tracedecay-dashboard-api/src/code_diagnostics_api.rs b/crates/tracedecay-dashboard-api/src/code_diagnostics_api.rs index f85a7a82a6..2316abaef9 100644 --- a/crates/tracedecay-dashboard-api/src/code_diagnostics_api.rs +++ b/crates/tracedecay-dashboard-api/src/code_diagnostics_api.rs @@ -6,7 +6,7 @@ use axum::http::StatusCode; use serde::{Deserialize, Deserializer}; use serde_json::{Value, json}; -use super::util::{JsonError, http_detail, internal_error}; +use super::util::{JsonError, http_detail, internal_error, json_error}; use super::{DashboardHttpRequestControlV1, DashboardState, RequestControl}; use crate::application::dashboard_diagnostics::{ DashboardDiagnosticsAuthorityV1, DashboardDiagnosticsErrorV1, settings_revision, @@ -70,7 +70,10 @@ pub async fn patch_settings( Json(patch): Json, ) -> ApiResult { let patch = serde_json::from_value::(patch).map_err(|error| { - bad_request(&format!("invalid code diagnostics settings patch: {error}")) + json_error( + StatusCode::BAD_REQUEST, + format!("invalid code diagnostics settings patch: {error}"), + ) })?; let request = diagnostics_request(&control)?; let snapshot = authority(&state)? @@ -181,19 +184,12 @@ where }) } -fn bad_request(error: &impl ToString) -> JsonError { - ( - StatusCode::BAD_REQUEST, - Json(json!({ - "detail": error.to_string(), - })), - ) -} - fn authority_error(error: DashboardDiagnosticsErrorV1) -> JsonError { match &error { DashboardDiagnosticsErrorV1::AdapterUnavailable { .. } - | DashboardDiagnosticsErrorV1::LanguageDisabled { .. } => bad_request(&error), + | DashboardDiagnosticsErrorV1::LanguageDisabled { .. } => { + json_error(StatusCode::BAD_REQUEST, error.to_string()) + } DashboardDiagnosticsErrorV1::RevisionConflict { expected, actual } => ( StatusCode::CONFLICT, Json(json!({ diff --git a/crates/tracedecay-dashboard-api/src/explorer_api.rs b/crates/tracedecay-dashboard-api/src/explorer_api.rs index 6ca69b561f..5cda18704a 100644 --- a/crates/tracedecay-dashboard-api/src/explorer_api.rs +++ b/crates/tracedecay-dashboard-api/src/explorer_api.rs @@ -31,6 +31,7 @@ use super::read_model::{ DashboardCoverageV1, DashboardDomainStateV1, DashboardEnvelopeV1, DashboardFreshnessV1, DashboardLegalActionKindV1, DashboardLegalActionRefV1, now_micros, scope_from_state, }; +use super::util::json_error; use super::{DashboardHttpRequestControlV1, DashboardState, RequestControl, graph_service}; use crate::request_identity::{GlobalOpaqueIdentityKind, mint_global_opaque_id}; use tracedecay_session_memory::context::CancellationToken; @@ -269,27 +270,19 @@ fn new_run_id() -> Option { } fn bad_request(message: impl Into) -> Response { - ( - StatusCode::BAD_REQUEST, - Json(json!({"detail": message.into()})), - ) - .into_response() + json_error(StatusCode::BAD_REQUEST, message).into_response() } fn not_found(run_id: &str) -> Response { - ( + json_error( StatusCode::NOT_FOUND, - Json(json!({"detail": format!("explorer query run not found: {run_id}")})), + format!("explorer query run not found: {run_id}"), ) - .into_response() + .into_response() } fn internal_error(message: impl Into) -> Response { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"detail": message.into()})), - ) - .into_response() + json_error(StatusCode::INTERNAL_SERVER_ERROR, message).into_response() } fn validate_query(request: &mut ExplorerQueryRequestV1) -> Result<(), &'static str> { @@ -423,11 +416,11 @@ pub async fn create_query( return bad_request(message); } let Some(owner) = run_owner(&state) else { - return ( + return json_error( StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"detail": "exact registered project scope is unavailable"})), + "exact registered project scope is unavailable", ) - .into_response(); + .into_response(); }; let Some(run_id) = new_run_id() else { return internal_error("could not allocate explorer query run identity"); @@ -491,11 +484,11 @@ pub async fn cancel_query( }; let mut run = stored.run.write().await; if run.state != ExplorerRunStateV1::Pending { - return ( + return json_error( StatusCode::CONFLICT, - Json(json!({"detail": format!("explorer query run is already terminal: {run_id}")})), + format!("explorer query run is already terminal: {run_id}"), ) - .into_response(); + .into_response(); } stored.cancellation.cancel(); mark_cancelled(&mut run); diff --git a/crates/tracedecay-dashboard-api/src/graph_structure_api.rs b/crates/tracedecay-dashboard-api/src/graph_structure_api.rs index 55e1b2811c..792f314eba 100644 --- a/crates/tracedecay-dashboard-api/src/graph_structure_api.rs +++ b/crates/tracedecay-dashboard-api/src/graph_structure_api.rs @@ -1109,14 +1109,8 @@ fn line_for_byte_offset(source: &[u8], byte_offset: u64) -> Option { } fn simple_symbol_name(qualified_name: &str) -> &str { - match qualified_name - .rsplit("::") - .next() - .and_then(|name| name.rsplit('.').next()) - { - Some(name) => name, - None => qualified_name, - } + let segment = qualified_name.rsplit("::").next().unwrap_or(qualified_name); + segment.rsplit('.').next().unwrap_or(segment) } fn is_callable_kind(kind: &str) -> bool { diff --git a/crates/tracedecay-dashboard-api/src/hooks.rs b/crates/tracedecay-dashboard-api/src/hooks.rs deleted file mode 100644 index 8c0ecb08e1..0000000000 --- a/crates/tracedecay-dashboard-api/src/hooks.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Hook contracts plus the readiness projection installed by the composition root. - -pub use tracedecay_application::analytics_bridge::{ - HookReadinessProjectionPort, aggregate_hook_completed_readiness, - install_hook_readiness_projection, -}; -pub use tracedecay_hooks::*; diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index 4aaf17458e..9b77f7c611 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -145,7 +145,6 @@ pub mod feedback_api; mod graph_api; mod graph_service; mod graph_structure_api; -pub mod hooks; mod lcm_api; mod remote_status_api; pub use lcm_api::{ diff --git a/crates/tracedecay-dashboard-api/src/memory_api.rs b/crates/tracedecay-dashboard-api/src/memory_api.rs index e40cf19af0..06630f5004 100644 --- a/crates/tracedecay-dashboard-api/src/memory_api.rs +++ b/crates/tracedecay-dashboard-api/src/memory_api.rs @@ -31,7 +31,7 @@ mod overview_contract; use control::{ fact_read_control, read_error_envelope, request_deadline_elapsed, request_terminal_state, - terminal_read_code, + terminal_read_code, terminal_read_response, }; pub(super) use overview_contract::MemoryOverviewPayloadV1; use overview_contract::{ @@ -719,15 +719,7 @@ pub async fn fact_trust_history( let result = fact_trust_history_payload(&state, fact_id, &fact_read_control(&control)).await; if let Some(state) = request_terminal_state(&control) { - let (code, detail) = terminal_read_code(state); - return ( - if state == DashboardDomainStateV1::TimedOut { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::REQUEST_TIMEOUT - }, - Json(json!({"detail": detail, "code": code})), - ); + return terminal_read_response(state); } match result { Ok(Some(payload)) => (StatusCode::OK, Json(payload)), diff --git a/crates/tracedecay-dashboard-api/src/memory_api/control.rs b/crates/tracedecay-dashboard-api/src/memory_api/control.rs index f841e8657b..d2fd919f9d 100644 --- a/crates/tracedecay-dashboard-api/src/memory_api/control.rs +++ b/crates/tracedecay-dashboard-api/src/memory_api/control.rs @@ -2,6 +2,9 @@ use std::sync::Arc; +use axum::Json; +use axum::http::StatusCode; +use serde_json::{Value, json}; use tracedecay_store::FactReadControl; use crate::DashboardHttpRequestControlV1; @@ -71,6 +74,18 @@ pub(crate) fn terminal_read_code(state: DashboardDomainStateV1) -> (&'static str } } +/// HTTP shape shared by routes that answer a finished deadline or cancellation +/// before their payload. Timed out is 504; every other terminal state is 408. +pub(crate) fn terminal_read_response(state: DashboardDomainStateV1) -> (StatusCode, Json) { + let (code, detail) = terminal_read_code(state); + let status = if state == DashboardDomainStateV1::TimedOut { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::REQUEST_TIMEOUT + }; + (status, Json(json!({ "detail": detail, "code": code }))) +} + #[cfg(test)] mod tests { use serde_json::Value; diff --git a/crates/tracedecay-dashboard-api/src/request_deadline.rs b/crates/tracedecay-dashboard-api/src/request_deadline.rs index 43d138fbef..b3e85183c6 100644 --- a/crates/tracedecay-dashboard-api/src/request_deadline.rs +++ b/crates/tracedecay-dashboard-api/src/request_deadline.rs @@ -1,30 +1,25 @@ pub(super) fn dashboard_http_request_deadline_micros(path: &str) -> i64 { - if path == "/api/application/retained/fact_store_curate" - || is_project_scoped_automation_run_path(path) - || is_user_job_run_path(path) - { + let automation_run = path == "/api/application/retained/fact_store_curate" + || one_segment_then( + path, + "/api/projects/", + "application/retained/fact_store_curate", + ) + || one_segment_then(path, "/api/automation/jobs/", "run"); + if automation_run { super::DASHBOARD_AUTOMATION_RUN_REQUEST_DEADLINE_MICROS } else { super::DASHBOARD_CODE_GRAPH_REQUEST_DEADLINE_MICROS } } -fn is_user_job_run_path(path: &str) -> bool { - let Some(job_and_action) = path.strip_prefix("/api/automation/jobs/") else { +/// `{prefix}{nonempty}/{tail}` with `tail` compared in full, including slashes. +fn one_segment_then(path: &str, prefix: &str, tail: &str) -> bool { + let Some(rest) = path.strip_prefix(prefix) else { return false; }; - let Some((job_id, action)) = job_and_action.split_once('/') else { + let Some((head, rest_tail)) = rest.split_once('/') else { return false; }; - !job_id.is_empty() && action == "run" -} - -fn is_project_scoped_automation_run_path(path: &str) -> bool { - let Some(project_and_tail) = path.strip_prefix("/api/projects/") else { - return false; - }; - let Some((project_id, tail)) = project_and_tail.split_once('/') else { - return false; - }; - !project_id.is_empty() && tail == "application/retained/fact_store_curate" + !head.is_empty() && rest_tail == tail } diff --git a/crates/tracedecay-dashboard-api/src/savings_api.rs b/crates/tracedecay-dashboard-api/src/savings_api.rs index 202d7721af..ac4f022462 100644 --- a/crates/tracedecay-dashboard-api/src/savings_api.rs +++ b/crates/tracedecay-dashboard-api/src/savings_api.rs @@ -502,12 +502,8 @@ fn price_deltas<'a>( price_provider_usage(&aggregate, prices, 0) } -fn count_i64(value: usize) -> i64 { - i64::try_from(value).unwrap_or(i64::MAX) -} - -fn count_u64(value: u64) -> i64 { - i64::try_from(value).unwrap_or(i64::MAX) +fn count_i64(value: impl TryInto) -> i64 { + value.try_into().unwrap_or(i64::MAX) } fn cost_basis_label(cost_usd: Option) -> &'static str { @@ -594,9 +590,9 @@ fn provider_spend( summary.unpriced_events, subtotal.priced_events, ), - usage_events: count_u64(summary.usage_events), - priced_events: count_u64(subtotal.priced_events), - unpriced_events: count_u64(summary.unpriced_events), + usage_events: count_i64(summary.usage_events), + priced_events: count_i64(subtotal.priced_events), + unpriced_events: count_i64(summary.unpriced_events), unknown_model_events: count_i64( deltas.iter().filter(|delta| delta.model.is_none()).count(), ), @@ -629,9 +625,9 @@ fn provider_day_point( SavingsProviderDayPointV1 { day, provider: provider.to_owned(), - usage_events: count_u64(summary.usage_events), - priced_events: count_u64(subtotal.priced_events), - unpriced_events: count_u64(summary.unpriced_events), + usage_events: count_i64(summary.usage_events), + priced_events: count_i64(subtotal.priced_events), + unpriced_events: count_i64(summary.unpriced_events), priced_cost_usd: subtotal.priced_cost_usd, total_cost_usd: summary.total_cost_usd, total_tokens: total_tokens_of(actual.as_ref()), @@ -712,7 +708,7 @@ fn provider_usage_attribution( SavingsProviderModelSpendV1 { provider, model: (!model.is_empty()).then_some(model), - usage_events: count_u64(priced.usage_events), + usage_events: count_i64(priced.usage_events), cost_usd: priced.total_cost_usd, total_tokens: total_tokens_of(actual.as_ref()), cost_basis: cost_basis_label(priced.total_cost_usd).to_owned(), @@ -728,7 +724,7 @@ fn provider_usage_attribution( let (_, actual) = actual_for_deltas(deltas.into_iter()); SavingsProviderDaySpendV1 { day, - usage_events: count_u64(priced.usage_events), + usage_events: count_i64(priced.usage_events), cost_usd: priced.total_cost_usd, total_tokens: total_tokens_of(actual.as_ref()), provider_actual: actual, diff --git a/crates/tracedecay-dashboard-api/src/storage_telemetry_api.rs b/crates/tracedecay-dashboard-api/src/storage_telemetry_api.rs index 75488d3520..237c794fb1 100644 --- a/crates/tracedecay-dashboard-api/src/storage_telemetry_api.rs +++ b/crates/tracedecay-dashboard-api/src/storage_telemetry_api.rs @@ -554,7 +554,11 @@ fn telemetry_entry( ) }); let budget = budget_dimension(&sampled.store, sample, retention); - let growth = growth_dimension(total_bytes, free_bytes); + // Current bytes stay on the store-size read. A status sample has no + // execution-owned watermarks, so it cannot become a growth series. + let growth = StoreGrowthDimensionV1::Unknown { + reason: GROWTH_UNKNOWN_REASON.to_string(), + }; let table_growth = table_growth_dimension(TableGrowthTelemetryReadV1::Unsupported { store: StoreKeyV1::new(sanitize_store_key(&sampled.store)) .unwrap_or_else(|_| fallback_store_key()), @@ -612,15 +616,6 @@ fn budget_dimension( } } -/// Project the absence of execution-owned watermarks. Current bytes remain -/// available in the store-size read, but a status read cannot honestly turn -/// them into a growth series. -fn growth_dimension(_total_bytes: Option, _free_bytes: Option) -> StoreGrowthDimensionV1 { - StoreGrowthDimensionV1::Unknown { - reason: GROWTH_UNKNOWN_REASON.to_string(), - } -} - fn store_file_name(path: &str) -> String { std::path::Path::new(path) .file_name() diff --git a/crates/tracedecay-domain/src/canonical_text.rs b/crates/tracedecay-domain/src/canonical_text.rs index ed5d6c87f4..9c35ac0b5e 100644 --- a/crates/tracedecay-domain/src/canonical_text.rs +++ b/crates/tracedecay-domain/src/canonical_text.rs @@ -150,6 +150,18 @@ pub fn sha256_hex_suffix(value: &str) -> Option<&str> { value.strip_prefix("sha256:") } +/// First eight bytes of a SHA-256 digest as a non-negative integer. +/// +/// Lexical artifact row ids and clone fingerprints both need a value that +/// fits in a signed 64-bit integer, so the high bit is cleared. `digest` +/// must be at least eight bytes; a full SHA-256 digest is 32. +#[must_use] +pub fn nonnegative_sha256_prefix(digest: &[u8]) -> u64 { + let mut prefix = [0_u8; 8]; + prefix.copy_from_slice(&digest[..8]); + u64::from_be_bytes(prefix) & i64::MAX as u64 +} + /// The hex body of a `sha256:`-tagged digest, without the algorithm tag. /// /// Identities that embed a digest under their own namespace all need the @@ -434,6 +446,15 @@ mod tests { ); } + #[test] + fn nonnegative_sha256_prefix_clears_the_high_bit() { + assert_eq!(nonnegative_sha256_prefix(&[0xff; 32]), i64::MAX as u64); + assert_eq!( + nonnegative_sha256_prefix(&[0x01, 0, 0, 0, 0, 0, 0, 2]), + 0x0100_0000_0000_0002 + ); + } + #[test] fn sha256_hex_suffix_strips_only_the_sha256_tag() { let hex = "a".repeat(64); diff --git a/crates/tracedecay-domain/src/code_intelligence/identity.rs b/crates/tracedecay-domain/src/code_intelligence/identity.rs index 6d28992667..ed050cae24 100644 --- a/crates/tracedecay-domain/src/code_intelligence/identity.rs +++ b/crates/tracedecay-domain/src/code_intelligence/identity.rs @@ -18,6 +18,12 @@ use crate::research::id::digest_id; /// Whether a canonical repository-relative path is exactly the requested /// scope or one of its descendants. +/// +/// A trailing slash is a literal prefix character. `src/` matches `src/` and +/// `src//lib.rs`, not `src/lib.rs`. An empty prefix matches only the empty +/// path and paths that start with `/`. [`path_matches_scope`] is the other +/// spelling: a prefix that already ends in `/` matches that directory's +/// children without requiring a second slash. pub fn repository_path_matches_scope(path: &str, scope_prefix: Option<&str>) -> bool { scope_prefix.is_none_or(|prefix| { path == prefix @@ -27,6 +33,27 @@ pub fn repository_path_matches_scope(path: &str, scope_prefix: Option<&str>) -> }) } +/// Whether `path` is exactly `prefix`, or a descendant of it. +/// +/// A prefix that does not end in `/` matches that path and `prefix/...`. +/// A prefix that ends in `/` matches names that start with that exact +/// spelling (`src/` matches `src/lib.rs` and `src/`, not `src`). An empty +/// prefix matches the empty path and any path that starts with `/`. `None` +/// matches every path. +/// +/// Unlike [`repository_path_matches_scope`], a single trailing slash is the +/// directory separator, not a character that must be followed by another `/`. +pub fn path_matches_scope(path: &str, scope_prefix: Option<&str>) -> bool { + scope_prefix.is_none_or(|prefix| { + let with_slash = if prefix.ends_with('/') { + prefix.to_string() + } else { + format!("{prefix}/") + }; + path.starts_with(&with_slash) || path == prefix + }) +} + /// Reject code identities that are empty, untrimmed, over 512 bytes, or carry /// control characters. use crate::canonical_text::validate_canonical_identity as validate_code_identity; diff --git a/crates/tracedecay-domain/src/code_intelligence/index.rs b/crates/tracedecay-domain/src/code_intelligence/index.rs index 65ad2f76b1..bed3d0a1bd 100644 --- a/crates/tracedecay-domain/src/code_intelligence/index.rs +++ b/crates/tracedecay-domain/src/code_intelligence/index.rs @@ -518,17 +518,9 @@ fn validate_language_revisions( mod tests { use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use crate::test_fixtures::id; - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use crate::test_fixtures::repeated_sha256_text as digest; fn snapshot() -> SanitizedCodeSnapshotV1 { SanitizedCodeSnapshotV1 { diff --git a/crates/tracedecay-domain/src/code_intelligence/language.rs b/crates/tracedecay-domain/src/code_intelligence/language.rs index fc67883850..eef9d0cbc3 100644 --- a/crates/tracedecay-domain/src/code_intelligence/language.rs +++ b/crates/tracedecay-domain/src/code_intelligence/language.rs @@ -155,13 +155,7 @@ impl EdgeAuthorityV1 { mod tests { use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use crate::test_fixtures::id; fn descriptor() -> LanguageDescriptorV1 { LanguageDescriptorV1 { diff --git a/crates/tracedecay-domain/src/code_intelligence/search.rs b/crates/tracedecay-domain/src/code_intelligence/search.rs index 6a0d9b3fb0..aa98bd37dd 100644 --- a/crates/tracedecay-domain/src/code_intelligence/search.rs +++ b/crates/tracedecay-domain/src/code_intelligence/search.rs @@ -181,6 +181,18 @@ pub enum CodeSearchChunkGrainV1 { FileWindow, } +impl CodeSearchChunkGrainV1 { + /// Row-codec grain ordinal. Append new grains; reordering changes sealed + /// artifact bytes. Capability emission sorts a filtered copy of this list. + pub const ORDER: [Self; 5] = [ + Self::SymbolSignature, + Self::SymbolBody, + Self::SymbolMember, + Self::FilePreamble, + Self::FileWindow, + ]; +} + /// Where one chunk lives inside one generation. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -247,6 +259,26 @@ pub enum ExactTechnicalTermKindV1 { CommitIdentifier, } +impl ExactTechnicalTermKindV1 { + /// Lexical row-codec ordinal and the capability manifest's kind set. + /// + /// The codec stores the index in this array. Append new kinds; reordering + /// changes sealed artifact bytes. + pub const ORDER: [Self; 11] = [ + Self::WholeSymbol, + Self::QualifiedName, + Self::Path, + Self::CompilerErrorCode, + Self::CompilerErrorText, + Self::RuntimeErrorCode, + Self::RuntimeErrorText, + Self::CliFlag, + Self::ToolName, + Self::ConfigurationKey, + Self::CommitIdentifier, + ]; +} + /// One whole exact technical term extracted as evidence. Extraction /// evidence only; protected lexical policy is applied separately. /// @@ -1052,15 +1084,6 @@ impl ChangedCodeChunkSetV1 { Ok((reused.len() as u64, reused_digest)) } - /// Like [`Self::seal_reused_partition_refs`], but skips per-row identity - /// validation. Callers must pass already-validated manifest rows. - pub fn seal_reused_partition_refs_trusted( - reused: &[(&CodeSearchChunkId, &ContentDigest)], - ) -> Result<(u64, ManifestDigest), DomainError> { - let reused_digest = code_reused_partition_digest_refs_trusted(reused)?; - Ok((reused.len() as u64, reused_digest)) - } - /// Seal Arc-shared reuse from the parent full-replay commitment. /// /// Use at Arc-share publish only. Pair-list sealing stays on the mixed / @@ -1581,17 +1604,9 @@ mod tests { use crate::code_intelligence::language::EdgeAuthorityV1; use crate::research::id::{PrivacyDomainId, SanitizationReceiptId}; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use crate::test_fixtures::id; - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use crate::test_fixtures::repeated_sha256_text as digest; #[test] fn ephemeral_query_view_is_bounded_and_redacts_its_text() { diff --git a/crates/tracedecay-domain/src/configuration/topology.rs b/crates/tracedecay-domain/src/configuration/topology.rs index ace8747122..2084581a8f 100644 --- a/crates/tracedecay-domain/src/configuration/topology.rs +++ b/crates/tracedecay-domain/src/configuration/topology.rs @@ -348,16 +348,6 @@ pub enum BranchNameSeparatorV1 { Slash, } -impl BranchNameSeparatorV1 { - pub const fn as_char(self) -> char { - match self { - Self::Hyphen => '-', - Self::Underscore => '_', - Self::Slash => '/', - } - } -} - #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum BranchCollisionPolicyV1 { diff --git a/crates/tracedecay-domain/src/diagnostics.rs b/crates/tracedecay-domain/src/diagnostics.rs index db10a5097c..9dd29e8597 100644 --- a/crates/tracedecay-domain/src/diagnostics.rs +++ b/crates/tracedecay-domain/src/diagnostics.rs @@ -347,17 +347,9 @@ fn validate_sanitized_message(message: &str) -> Result<(), DomainError> { mod tests { use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use crate::test_fixtures::id; - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use crate::test_fixtures::repeated_sha256_text as digest; fn fixture_record() -> GenerationDiagnosticV1 { let mut record = GenerationDiagnosticV1 { diff --git a/crates/tracedecay-domain/src/external_source.rs b/crates/tracedecay-domain/src/external_source.rs index 0b9061d32e..1f414752d6 100644 --- a/crates/tracedecay-domain/src/external_source.rs +++ b/crates/tracedecay-domain/src/external_source.rs @@ -1689,9 +1689,7 @@ impl SourceSnapshotCompletionV1 { mod tests { use super::*; - fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).unwrap() - } + use crate::test_fixtures::digest; fn binding_identity() -> SourceBindingIdentityV1 { SourceBindingIdentityV1 { diff --git a/crates/tracedecay-domain/src/feedback/mod.rs b/crates/tracedecay-domain/src/feedback/mod.rs index 665db6f0c8..57a3920b2e 100644 --- a/crates/tracedecay-domain/src/feedback/mod.rs +++ b/crates/tracedecay-domain/src/feedback/mod.rs @@ -1587,17 +1587,9 @@ impl FeedbackCycleObservationV1 { mod tests { use super::*; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use crate::test_fixtures::digest; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use crate::test_fixtures::id; fn request(content: FeedbackContentIdentityV1) -> FeedbackCycleRequestV1 { FeedbackCycleRequestV1::new( diff --git a/crates/tracedecay-domain/src/feedback/proximity.rs b/crates/tracedecay-domain/src/feedback/proximity.rs index b6077d04d3..b93d341cf9 100644 --- a/crates/tracedecay-domain/src/feedback/proximity.rs +++ b/crates/tracedecay-domain/src/feedback/proximity.rs @@ -191,20 +191,6 @@ impl ProximityContributionV1 { observed_at.0 >= self.expires_at.0 } - /// Records presentation suppression without discarding the evidence, - /// threshold provenance, or expiry that produced the duplicate warning. - pub fn suppressed_duplicate(mut self) -> Result { - self.validate()?; - if self.inclusion != ProximityInclusionV1::Included { - return Err(DomainError::NonCanonical { - field: "proximity duplicate suppression input", - }); - } - self.inclusion = ProximityInclusionV1::SuppressedDuplicate; - self.validate()?; - Ok(self) - } - pub fn validate(&self) -> Result<(), DomainError> { self.contribution_id.validate()?; self.warning_id.validate()?; diff --git a/crates/tracedecay-domain/src/git/read_model.rs b/crates/tracedecay-domain/src/git/read_model.rs index 726f9c168c..600cf0bba9 100644 --- a/crates/tracedecay-domain/src/git/read_model.rs +++ b/crates/tracedecay-domain/src/git/read_model.rs @@ -65,15 +65,6 @@ pub enum GitObjectFormatV1 { Sha256, } -impl GitObjectFormatV1 { - pub const fn oid_hex_len(self) -> usize { - match self { - Self::Sha1 => 40, - Self::Sha256 => 64, - } - } -} - fn validate_git_oid(value: &str, field: &'static str) -> Result<(), DomainError> { if value.is_empty() { return Err(DomainError::Empty { field }); diff --git a/crates/tracedecay-domain/src/lib.rs b/crates/tracedecay-domain/src/lib.rs index c4bcc4d8ba..af5881df5b 100644 --- a/crates/tracedecay-domain/src/lib.rs +++ b/crates/tracedecay-domain/src/lib.rs @@ -26,6 +26,8 @@ pub mod retrieval; pub mod session; pub mod session_derived; pub mod source_path_policy; +pub mod test_fixtures; +pub mod text; pub mod work; pub mod work_duplicate_adjudication; pub mod work_execution_snapshot; @@ -42,7 +44,7 @@ pub mod workflow_receipt; pub mod workflow_run; pub use automation::{SESSION_EVIDENCE_BUDGET_EXHAUSTED, SESSION_EVIDENCE_BUDGET_SUPPRESSED}; -pub use canonical_text::{encode_lowercase_hex, sha256_hex_suffix}; +pub use canonical_text::{encode_lowercase_hex, nonnegative_sha256_prefix, sha256_hex_suffix}; pub use code_intelligence::{ BoundedSanitizedText, CanonicalRelationEdgeV1, ChangedCodeChunkSetV1, ChangedCodeChunkV1, ChunkLogicalIdentityV1, ChunkerRevision, CodeChunkProjectionReceiptV1, CodeGenerationId, @@ -67,8 +69,8 @@ pub use code_intelligence::{ is_cli_flag_token, is_commit_hash, is_commit_identifier_token, is_compiler_error_code_token, is_configuration_key_token, is_identifier_token, is_path_shape, is_path_token, is_qualified_name_token, is_runtime_error_code_token, is_technical_token_char, - is_tool_name_token, projection_batch_publication_digest, repository_path_matches_scope, - split_subtokens, technical_tokens, validate_code_logical_path, + is_tool_name_token, path_matches_scope, projection_batch_publication_digest, + repository_path_matches_scope, split_subtokens, technical_tokens, validate_code_logical_path, }; pub use configuration::{ ACCESS_RULES_SETTING_KEY, ANALYZER_SETTINGS_SETTING_KEY, AUTOMATION_SETTINGS_SETTING_KEY, @@ -334,8 +336,9 @@ pub use research::{ WorkflowOutputName, WorkflowStepId, WorktreeCaptureAnchorRefV1, WorktreeId, WorktreeInventoryEpoch, WorktreeInventorySnapshotId, canonical_json_bytes, canonical_json_bytes_and_sha256, canonical_json_value, canonical_sha256, - derive_exact_observation_anchor_id, derive_exact_source_occurrence_anchor_id, - derive_git_topology_anchor_id, validate_anchor_lineage_v3, zero_digest, + decode_with_canonical_digest, derive_exact_observation_anchor_id, + derive_exact_source_occurrence_anchor_id, derive_git_topology_anchor_id, + validate_anchor_lineage_v3, zero_digest, }; pub use resource_policy::host_cpu_target; pub use retrieval::{ @@ -381,6 +384,10 @@ pub use session_derived::{ SessionDerivedEvidenceRecordV1, derive_session_evidence_from_occurrences, }; pub use source_path_policy::{GENERATED_DIR_SEGMENTS, is_generated_dir_segment}; +pub use text::{ + collapse_whitespace, fold_control_characters, forward_slash_path, forward_slash_text, + utf8_prefix_at_or_before, +}; pub use work::{RuntimeEvidenceRef, WorkAuthority, WorkContractError, WorkVersion}; pub use work_duplicate_adjudication::{ MAX_WORK_DUPLICATE_REASON_BYTES_V1, WorkDuplicateAdjudicationCommandV1, diff --git a/crates/tracedecay-domain/src/memory/fact_tests.rs b/crates/tracedecay-domain/src/memory/fact_tests.rs index d32846f7a8..c9c5b9c051 100644 --- a/crates/tracedecay-domain/src/memory/fact_tests.rs +++ b/crates/tracedecay-domain/src/memory/fact_tests.rs @@ -3,9 +3,7 @@ use crate::observation::{SanitizerDispositionV1, SensitivityV1}; use crate::research::SanitizationReceiptRefV1; use serde_json::json; -fn id>(value: &str) -> T { - T::try_from(value.to_owned()).unwrap() -} +use crate::test_fixtures::id; fn fact_id(owner: FactOwnerV1, operation: &str) -> FactId { FactId::derive( diff --git a/crates/tracedecay-domain/src/memory/lineage.rs b/crates/tracedecay-domain/src/memory/lineage.rs index 0a8aa7d13d..9eab522703 100644 --- a/crates/tracedecay-domain/src/memory/lineage.rs +++ b/crates/tracedecay-domain/src/memory/lineage.rs @@ -288,12 +288,7 @@ mod tests { }; use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - { - T::try_from(value.to_owned()).unwrap() - } + use crate::test_fixtures::id; fn fact_id(operation: &str) -> FactId { fact_id_for(&FactOwnerV1::Profile, operation) diff --git a/crates/tracedecay-domain/src/memory/relation.rs b/crates/tracedecay-domain/src/memory/relation.rs index 8e9d35265d..58426b9f6c 100644 --- a/crates/tracedecay-domain/src/memory/relation.rs +++ b/crates/tracedecay-domain/src/memory/relation.rs @@ -301,12 +301,7 @@ use crate::research::{ }; #[cfg(test)] -fn id(value: &str) -> T -where - T: TryFrom, -{ - T::try_from(value.to_owned()).unwrap() -} +use crate::test_fixtures::id; #[cfg(test)] pub(in crate::memory) fn fact_id_for(owner: &FactOwnerV1, operation: &str) -> FactId { diff --git a/crates/tracedecay-domain/src/observability/workflow.rs b/crates/tracedecay-domain/src/observability/workflow.rs index 3d8379a808..931093c5b2 100644 --- a/crates/tracedecay-domain/src/observability/workflow.rs +++ b/crates/tracedecay-domain/src/observability/workflow.rs @@ -150,9 +150,7 @@ impl WorkflowResourceObservedV1 { mod tests { use super::*; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use crate::test_fixtures::digest; #[test] fn lifecycle_requires_exact_journal_coverage() { diff --git a/crates/tracedecay-domain/src/observation.rs b/crates/tracedecay-domain/src/observation.rs index 954855587e..4953f5ff27 100644 --- a/crates/tracedecay-domain/src/observation.rs +++ b/crates/tracedecay-domain/src/observation.rs @@ -1100,6 +1100,36 @@ struct StructureLimitSerializer<'count> { depth: usize, } +impl<'count> StructureLimitSerializer<'count> { + fn count_node(self) -> Result<(), StructureLimitError> { + visit_structure_value(self.values, self.depth) + } + + fn descend( + self, + extra_ancestor: bool, + ) -> Result, StructureLimitError> { + visit_structure_value(self.values, self.depth)?; + if extra_ancestor { + visit_structure_value(self.values, self.depth + 1)?; + } + Ok(StructureLimitCompound { + values: self.values, + child_depth: self.depth + 1 + usize::from(extra_ancestor), + }) + } +} + +macro_rules! count_scalar { + ($($name:ident ( $($params:tt)* );)*) => { + $( + fn $name(self, $($params)*) -> Result<(), StructureLimitError> { + self.count_node() + } + )* + }; +} + impl<'count> serde::Serializer for StructureLimitSerializer<'count> { type Ok = (); type Error = StructureLimitError; @@ -1112,64 +1142,30 @@ impl<'count> serde::Serializer for StructureLimitSerializer<'count> { type SerializeStruct = StructureLimitCompound<'count>; type SerializeStructVariant = StructureLimitCompound<'count>; - fn serialize_bool(self, _value: bool) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_i8(self, _value: i8) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_i16(self, _value: i16) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_i32(self, _value: i32) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_i64(self, _value: i64) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_i128(self, _value: i128) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_u8(self, _value: u8) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_u16(self, _value: u16) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_u32(self, _value: u32) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_u64(self, _value: u64) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_u128(self, _value: u128) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_f32(self, _value: f32) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_f64(self, _value: f64) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_char(self, _value: char) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_str(self, _value: &str) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) + count_scalar! { + serialize_bool(_value: bool); + serialize_i8(_value: i8); + serialize_i16(_value: i16); + serialize_i32(_value: i32); + serialize_i64(_value: i64); + serialize_i128(_value: i128); + serialize_u8(_value: u8); + serialize_u16(_value: u16); + serialize_u32(_value: u32); + serialize_u64(_value: u64); + serialize_u128(_value: u128); + serialize_f32(_value: f32); + serialize_f64(_value: f64); + serialize_char(_value: char); + serialize_str(_value: &str); + serialize_none(); + serialize_unit(); + serialize_unit_struct(_name: &'static str); + serialize_unit_variant( + _name: &'static str, + _variant_index: u32, + _variant: &'static str + ); } fn serialize_bytes(self, value: &[u8]) -> Result<(), StructureLimitError> { @@ -1181,10 +1177,6 @@ impl<'count> serde::Serializer for StructureLimitSerializer<'count> { Ok(()) } - fn serialize_none(self) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - fn serialize_some(self, value: &T) -> Result<(), StructureLimitError> where T: ?Sized + Serialize, @@ -1192,23 +1184,6 @@ impl<'count> serde::Serializer for StructureLimitSerializer<'count> { value.serialize(self) } - fn serialize_unit(self) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_unit_struct(self, _name: &'static str) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - - fn serialize_unit_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - ) -> Result<(), StructureLimitError> { - visit_structure_value(self.values, self.depth) - } - fn serialize_newtype_struct( self, _name: &'static str, @@ -1241,11 +1216,7 @@ impl<'count> serde::Serializer for StructureLimitSerializer<'count> { self, _len: Option, ) -> Result, StructureLimitError> { - visit_structure_value(self.values, self.depth)?; - Ok(StructureLimitCompound { - values: self.values, - child_depth: self.depth + 1, - }) + self.descend(false) } fn serialize_tuple( @@ -1270,23 +1241,14 @@ impl<'count> serde::Serializer for StructureLimitSerializer<'count> { _variant: &'static str, _len: usize, ) -> Result, StructureLimitError> { - visit_structure_value(self.values, self.depth)?; - visit_structure_value(self.values, self.depth + 1)?; - Ok(StructureLimitCompound { - values: self.values, - child_depth: self.depth + 2, - }) + self.descend(true) } fn serialize_map( self, _len: Option, ) -> Result, StructureLimitError> { - visit_structure_value(self.values, self.depth)?; - Ok(StructureLimitCompound { - values: self.values, - child_depth: self.depth + 1, - }) + self.descend(false) } fn serialize_struct( @@ -1294,11 +1256,7 @@ impl<'count> serde::Serializer for StructureLimitSerializer<'count> { _name: &'static str, _len: usize, ) -> Result, StructureLimitError> { - visit_structure_value(self.values, self.depth)?; - Ok(StructureLimitCompound { - values: self.values, - child_depth: self.depth + 1, - }) + self.descend(false) } fn serialize_struct_variant( @@ -1308,19 +1266,14 @@ impl<'count> serde::Serializer for StructureLimitSerializer<'count> { _variant: &'static str, _len: usize, ) -> Result, StructureLimitError> { - visit_structure_value(self.values, self.depth)?; - visit_structure_value(self.values, self.depth + 1)?; - Ok(StructureLimitCompound { - values: self.values, - child_depth: self.depth + 2, - }) + self.descend(true) } fn collect_str(self, _value: &T) -> Result<(), StructureLimitError> where T: ?Sized + fmt::Display, { - visit_structure_value(self.values, self.depth) + self.count_node() } } @@ -1343,69 +1296,30 @@ impl StructureLimitCompound<'_> { } } -impl serde::ser::SerializeSeq for StructureLimitCompound<'_> { - type Ok = (); - type Error = StructureLimitError; - - fn serialize_element(&mut self, value: &T) -> Result<(), StructureLimitError> - where - T: ?Sized + Serialize, - { - self.child(value) - } - - fn end(self) -> Result<(), StructureLimitError> { - Ok(()) - } -} - -impl serde::ser::SerializeTuple for StructureLimitCompound<'_> { - type Ok = (); - type Error = StructureLimitError; - - fn serialize_element(&mut self, value: &T) -> Result<(), StructureLimitError> - where - T: ?Sized + Serialize, - { - self.child(value) - } +macro_rules! impl_structure_limit_compound { + ($trait:path, $method:ident, ($($extra:tt)*)) => { + impl $trait for StructureLimitCompound<'_> { + type Ok = (); + type Error = StructureLimitError; - fn end(self) -> Result<(), StructureLimitError> { - Ok(()) - } -} - -impl serde::ser::SerializeTupleStruct for StructureLimitCompound<'_> { - type Ok = (); - type Error = StructureLimitError; - - fn serialize_field(&mut self, value: &T) -> Result<(), StructureLimitError> - where - T: ?Sized + Serialize, - { - self.child(value) - } + fn $method(&mut self, $($extra)* value: &T) -> Result<(), StructureLimitError> + where + T: ?Sized + Serialize, + { + self.child(value) + } - fn end(self) -> Result<(), StructureLimitError> { - Ok(()) - } + fn end(self) -> Result<(), StructureLimitError> { + Ok(()) + } + } + }; } -impl serde::ser::SerializeTupleVariant for StructureLimitCompound<'_> { - type Ok = (); - type Error = StructureLimitError; - - fn serialize_field(&mut self, value: &T) -> Result<(), StructureLimitError> - where - T: ?Sized + Serialize, - { - self.child(value) - } - - fn end(self) -> Result<(), StructureLimitError> { - Ok(()) - } -} +impl_structure_limit_compound!(serde::ser::SerializeSeq, serialize_element, ()); +impl_structure_limit_compound!(serde::ser::SerializeTuple, serialize_element, ()); +impl_structure_limit_compound!(serde::ser::SerializeTupleStruct, serialize_field, ()); +impl_structure_limit_compound!(serde::ser::SerializeTupleVariant, serialize_field, ()); impl serde::ser::SerializeMap for StructureLimitCompound<'_> { type Ok = (); @@ -1431,45 +1345,16 @@ impl serde::ser::SerializeMap for StructureLimitCompound<'_> { } } -impl serde::ser::SerializeStruct for StructureLimitCompound<'_> { - type Ok = (); - type Error = StructureLimitError; - - fn serialize_field( - &mut self, - _key: &'static str, - value: &T, - ) -> Result<(), StructureLimitError> - where - T: ?Sized + Serialize, - { - self.child(value) - } - - fn end(self) -> Result<(), StructureLimitError> { - Ok(()) - } -} - -impl serde::ser::SerializeStructVariant for StructureLimitCompound<'_> { - type Ok = (); - type Error = StructureLimitError; - - fn serialize_field( - &mut self, - _key: &'static str, - value: &T, - ) -> Result<(), StructureLimitError> - where - T: ?Sized + Serialize, - { - self.child(value) - } - - fn end(self) -> Result<(), StructureLimitError> { - Ok(()) - } -} +impl_structure_limit_compound!( + serde::ser::SerializeStruct, + serialize_field, + (_key: &'static str,) +); +impl_structure_limit_compound!( + serde::ser::SerializeStructVariant, + serialize_field, + (_key: &'static str,) +); struct ByteLimitWriter { written: usize, @@ -1728,6 +1613,20 @@ pub enum CanonicalMessageRoleV1 { Unknown, } +impl CanonicalMessageRoleV1 { + /// Role label written into canonical projections. It is the serde name so + /// projection and the wire encoding cannot drift. + pub const fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Assistant => "assistant", + Self::System => "system", + Self::Tool => "tool", + Self::Unknown => "unknown", + } + } +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum CanonicalReasoningVisibilityV1 { @@ -1737,6 +1636,69 @@ pub enum CanonicalReasoningVisibilityV1 { NotApplicable, } +impl CanonicalMessageRoleV1 { + /// Exact canonical role label. Host aliases (`developer`, `model`, + /// `_system_prompt`) stay at the host boundary so a foreign alias cannot + /// become a role for every provider. + pub fn from_known_label(value: &str) -> Option { + match value { + "user" => Some(Self::User), + "assistant" => Some(Self::Assistant), + "system" => Some(Self::System), + "tool" => Some(Self::Tool), + _ => None, + } + } + + /// `from_known_label`, with every other label recorded as [`Self::Unknown`] + /// instead of refused. + pub fn from_wire_label(value: &str) -> Self { + match Self::from_known_label(value) { + Some(role) => role, + None => Self::Unknown, + } + } +} + +#[cfg(test)] +mod canonical_message_role_label_tests { + use super::CanonicalMessageRoleV1; + + #[test] + fn known_labels_parse_and_foreign_aliases_stay_unknown() { + assert_eq!( + CanonicalMessageRoleV1::from_known_label("user"), + Some(CanonicalMessageRoleV1::User) + ); + assert_eq!( + CanonicalMessageRoleV1::from_known_label("assistant"), + Some(CanonicalMessageRoleV1::Assistant) + ); + assert_eq!( + CanonicalMessageRoleV1::from_known_label("system"), + Some(CanonicalMessageRoleV1::System) + ); + assert_eq!( + CanonicalMessageRoleV1::from_known_label("tool"), + Some(CanonicalMessageRoleV1::Tool) + ); + assert_eq!(CanonicalMessageRoleV1::from_known_label("unknown"), None); + assert_eq!(CanonicalMessageRoleV1::from_known_label("developer"), None); + assert_eq!( + CanonicalMessageRoleV1::from_wire_label("user"), + CanonicalMessageRoleV1::User + ); + assert_eq!( + CanonicalMessageRoleV1::from_wire_label("developer"), + CanonicalMessageRoleV1::Unknown + ); + assert_eq!( + CanonicalMessageRoleV1::from_wire_label(""), + CanonicalMessageRoleV1::Unknown + ); + } +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum CanonicalGitEvidenceKindV1 { diff --git a/crates/tracedecay-domain/src/repository.rs b/crates/tracedecay-domain/src/repository.rs index e016af7844..ad8c4d9bc9 100644 --- a/crates/tracedecay-domain/src/repository.rs +++ b/crates/tracedecay-domain/src/repository.rs @@ -489,12 +489,7 @@ mod tests { const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; const TREE: &str = "89abcdef0123456789abcdef0123456789abcdef"; - fn id(value: &str) -> T - where - T: TryFrom, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use crate::test_fixtures::id; fn evidence() -> RepositoryEvidenceV1 { RepositoryEvidenceV1::new( diff --git a/crates/tracedecay-domain/src/research/anchor.rs b/crates/tracedecay-domain/src/research/anchor.rs index 0df14d5a4b..02b8c72a26 100644 --- a/crates/tracedecay-domain/src/research/anchor.rs +++ b/crates/tracedecay-domain/src/research/anchor.rs @@ -936,6 +936,17 @@ impl RetrievalAnchorRecordV2 { &self.owner } + /// JSON stored in `retrieval_anchors.owner_json`. Callers compare or insert + /// this text; they do not re-serialize the owner column themselves. + pub fn owner_column_json(&self) -> Result { + serde_json::to_string(self.owner()) + } + + /// Whether a stored `owner_json` cell is this record's owner column. + pub fn owner_column_matches(&self, stored: &str) -> bool { + self.owner_column_json().ok().as_deref() == Some(stored) + } + pub fn aliases(&self) -> &[NativeAliasV2] { &self.aliases } diff --git a/crates/tracedecay-domain/src/research/canonical.rs b/crates/tracedecay-domain/src/research/canonical.rs index 914e0493a3..63e9d232d6 100644 --- a/crates/tracedecay-domain/src/research/canonical.rs +++ b/crates/tracedecay-domain/src/research/canonical.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use serde::Serialize; +use serde::de::DeserializeOwned; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -118,6 +119,20 @@ pub fn canonical_json_bytes_and_sha256( Ok((bytes, digest)) } +/// Decode a stored JSON payload and require its canonical digest to match the +/// column that was persisted with it. Journal rows use this so a rewritten +/// payload cannot reuse another row's digest. +pub fn decode_with_canonical_digest(payload: &str, stored_digest: &str) -> Option +where + T: Serialize + DeserializeOwned, +{ + let value: T = serde_json::from_str(payload).ok()?; + match canonical_sha256(&value) { + Ok(digest) if digest.as_str() == stored_digest => Some(value), + _ => None, + } +} + #[cfg(test)] #[path = "canonical_tests.rs"] mod tests; diff --git a/crates/tracedecay-domain/src/research/canonical_tests.rs b/crates/tracedecay-domain/src/research/canonical_tests.rs index 1c17f2bbb5..b4ce6ff4b7 100644 --- a/crates/tracedecay-domain/src/research/canonical_tests.rs +++ b/crates/tracedecay-domain/src/research/canonical_tests.rs @@ -8,10 +8,23 @@ use super::super::canonical_sink::{ use super::super::canonical_value::{keys_are_canonically_ordered, write_canonical}; use super::{ canonical_json_bytes, canonical_json_bytes_and_sha256, canonical_json_value, canonical_sha256, + decode_with_canonical_digest, }; use serde_json::json; +#[test] +fn decode_with_canonical_digest_accepts_matching_payload_and_rejects_tamper() { + let value = json!({"b": 1, "a": 2}); + let payload = serde_json::to_string(&value).unwrap(); + let digest = canonical_sha256(&value).unwrap(); + let decoded: Value = + decode_with_canonical_digest(&payload, digest.as_str()).expect("matching digest"); + assert_eq!(decoded, value); + assert!(decode_with_canonical_digest::(&payload, "sha256:dead").is_none()); + assert!(decode_with_canonical_digest::("{", digest.as_str()).is_none()); +} + #[test] fn canonical_outputs_match_for_nested_ordering_and_scalars() { let value = json!({ diff --git a/crates/tracedecay-domain/src/research/mod.rs b/crates/tracedecay-domain/src/research/mod.rs index ac0ccd61ef..dc2f5e14cc 100644 --- a/crates/tracedecay-domain/src/research/mod.rs +++ b/crates/tracedecay-domain/src/research/mod.rs @@ -47,12 +47,7 @@ mod tests { use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use crate::test_fixtures::id; #[test] fn ids_reject_invalid_deserialized_values() { diff --git a/crates/tracedecay-domain/src/research/watermark.rs b/crates/tracedecay-domain/src/research/watermark.rs index 2bcb1b50a7..14cf5a2e0f 100644 --- a/crates/tracedecay-domain/src/research/watermark.rs +++ b/crates/tracedecay-domain/src/research/watermark.rs @@ -29,17 +29,6 @@ impl VectorWatermark { (false, false) => None, } } - - pub fn merge_max(&self, other: &Self) -> Self { - let mut components = self.components.clone(); - for (shard, sequence) in &other.components { - components - .entry(shard.clone()) - .and_modify(|current| *current = (*current).max(*sequence)) - .or_insert(*sequence); - } - Self { components } - } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/crates/tracedecay-domain/src/retrieval.rs b/crates/tracedecay-domain/src/retrieval.rs index 26baba2a11..571939fd72 100644 --- a/crates/tracedecay-domain/src/retrieval.rs +++ b/crates/tracedecay-domain/src/retrieval.rs @@ -1281,13 +1281,7 @@ mod tests { const ONE_DIGEST: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use crate::test_fixtures::id; fn freshness() -> SourceFreshness { SourceFreshness { diff --git a/crates/tracedecay-domain/src/test_fixtures.rs b/crates/tracedecay-domain/src/test_fixtures.rs new file mode 100644 index 0000000000..eb5eb15cc9 --- /dev/null +++ b/crates/tracedecay-domain/src/test_fixtures.rs @@ -0,0 +1,72 @@ +//! Shared fixture constructors for tests in every crate that already depends +//! on this one. +//! +//! The values are not hashes of anything. Suites used to each re-roll the same +//! `sha256:` spelling and the same `TryFrom` identity parse. + +use std::fmt::Debug; + +use crate::ManifestDigest; + +/// `sha256:` plus `digit` repeated 64 times. +/// +/// Not a hash. One lowercase hex digit parses as a manifest digest and stays +/// distinct from its siblings. +pub fn repeated_sha256_text(digit: char) -> String { + format!("sha256:{}", digit.to_string().repeat(64)) +} + +/// [`repeated_sha256_text`] parsed as a manifest digest. +/// +/// An illegal digit is a broken fixture, so this fails immediately rather than +/// handing each suite its own `unwrap`. +pub fn digest(digit: char) -> ManifestDigest { + ManifestDigest::new(repeated_sha256_text(digit)).expect("fixture digest is canonical") +} + +/// Absolute fixture root. Work and registry contracts require +/// `Path::is_absolute`, and a bare `/...` literal is not absolute on Windows. +pub fn fixture_abs_root(posix: &str) -> String { + if cfg!(windows) { + format!("C:{}", posix.replace('/', "\\")) + } else { + posix.to_owned() + } +} + +/// Parses a fixture identity. The value must already be canonical for `T`. +pub fn id(value: &str) -> T +where + T: TryFrom, + T::Error: Debug, +{ + T::try_from(value.to_owned()).expect("fixture id is canonical") +} + +#[cfg(test)] +mod tests { + use super::{digest, id, repeated_sha256_text}; + + #[test] + fn repeated_hex_digest_matches_the_shared_spelling() { + let spelled = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + assert_eq!(repeated_sha256_text('a'), spelled); + assert_eq!(digest('a').as_str(), spelled); + assert_eq!( + repeated_sha256_text('0'), + crate::ManifestDigest::zero().unwrap().as_str() + ); + } + + #[test] + fn fixture_id_accepts_a_canonical_project_id() { + let project: crate::ProjectId = id("project.fixture"); + assert_eq!(project.as_str(), "project.fixture"); + } + + #[test] + fn fixture_abs_root_is_absolute_on_this_host() { + let root = super::fixture_abs_root("/workspace/fixture"); + assert!(std::path::Path::new(&root).is_absolute(), "{root}"); + } +} diff --git a/crates/tracedecay-domain/src/text.rs b/crates/tracedecay-domain/src/text.rs new file mode 100644 index 0000000000..a28a487b35 --- /dev/null +++ b/crates/tracedecay-domain/src/text.rs @@ -0,0 +1,122 @@ +//! UTF-8, whitespace, and path-text cuts shared by crates that sit below the +//! runtime kernel. +//! +//! Boundary behavior is the contract: a byte budget that lands inside a +//! multibyte character walks back, and an empty budget or an empty string +//! stays empty. Callers that trim, mark truncation, or refuse a mid-character +//! budget still do that themselves. + +/// Replace Unicode control characters with an ASCII space. +/// +/// Newline, carriage return, and tab are controls, so they become spaces. +/// Non-controls, including ordinary spaces and `/`, are unchanged. An empty +/// string stays empty. This does not trim. +#[must_use] +pub fn fold_control_characters(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect() +} + +/// Replace `\` with `/`. +/// +/// Trailing separators stay. `\` becomes `/`, `foo\` becomes `foo/`, and +/// `foo/` is unchanged. An empty string stays empty. This does not trim, +/// lowercase a drive letter, or strip a `\\?\` prefix. +#[must_use] +pub fn forward_slash_text(text: &str) -> String { + text.replace('\\', "/") +} + +/// [`forward_slash_text`] of a path's lossy display form. +/// +/// A trailing separator on the path is kept. A non-UTF-8 component is the +/// usual `U+FFFD` replacement, the same spelling `to_string_lossy` already +/// produced at the deleted call sites. +#[must_use] +pub fn forward_slash_path(path: &std::path::Path) -> String { + forward_slash_text(&path.to_string_lossy()) +} + +/// Join Unicode whitespace-separated pieces with a single ASCII space. +/// +/// Leading, trailing, and repeated whitespace disappear. An empty or +/// all-whitespace input stays empty. Newlines become spaces. Characters +/// that are not whitespace, including a trailing `/`, are preserved. +#[must_use] +pub fn collapse_whitespace(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +/// Longest prefix of `text` whose byte length is at most `max_bytes`. +/// +/// A cut inside a multibyte character walks back to the previous char +/// boundary. `max_bytes == 0`, and a leading multibyte character whose +/// budget cannot hold it, both yield an empty prefix. An index past the +/// end returns the whole string. +#[must_use] +pub fn utf8_prefix_at_or_before(text: &str, max_bytes: usize) -> &str { + &text[..text.floor_char_boundary(max_bytes)] +} + +#[cfg(test)] +mod tests { + use super::{ + collapse_whitespace, fold_control_characters, forward_slash_text, utf8_prefix_at_or_before, + }; + + #[test] + fn control_characters_become_spaces_without_trimming() { + assert_eq!(fold_control_characters(""), ""); + assert_eq!(fold_control_characters("a\nb\tc\r"), "a b c "); + assert_eq!(fold_control_characters(" src/ "), " src/ "); + assert_eq!(fold_control_characters("é"), "é"); + } + + #[test] + fn forward_slashes_keep_trailing_separators_and_an_empty_string() { + assert_eq!(forward_slash_text(""), ""); + assert_eq!(forward_slash_text(r"\"), "/"); + assert_eq!(forward_slash_text(r"foo\"), "foo/"); + assert_eq!(forward_slash_text("foo/"), "foo/"); + assert_eq!(forward_slash_text(r"C:\repo\\"), "C:/repo//"); + assert_eq!(forward_slash_text(r"\\?\C:\repo"), "//?/C:/repo"); + assert_eq!( + super::forward_slash_path(std::path::Path::new(r"foo\bar\")), + "foo/bar/" + ); + } + + #[test] + fn collapse_whitespace_keeps_non_space_and_drops_only_whitespace() { + assert_eq!(collapse_whitespace(" a \n\t b "), "a b"); + assert_eq!(collapse_whitespace(" "), ""); + assert_eq!(collapse_whitespace(""), ""); + assert_eq!(collapse_whitespace("src/"), "src/"); + } + + #[test] + fn walks_back_when_the_cut_lands_inside_a_multibyte_char() { + let text = format!("{}é", "a".repeat(20)); + assert_eq!(utf8_prefix_at_or_before(&text, 21), "a".repeat(20)); + } + + #[test] + fn empty_budget_on_a_leading_multibyte_char_is_empty() { + assert_eq!(utf8_prefix_at_or_before("🦀tail", 2), ""); + assert_eq!(utf8_prefix_at_or_before("🦀tail", 0), ""); + } + + #[test] + fn budget_past_the_end_returns_the_whole_string() { + assert_eq!(utf8_prefix_at_or_before("abc", 10), "abc"); + assert_eq!(utf8_prefix_at_or_before("", 4), ""); + } +} diff --git a/crates/tracedecay-domain/src/work_placement.rs b/crates/tracedecay-domain/src/work_placement.rs index 71cc566d64..f6af579cc7 100644 --- a/crates/tracedecay-domain/src/work_placement.rs +++ b/crates/tracedecay-domain/src/work_placement.rs @@ -564,17 +564,7 @@ mod tests { } } - /// `Path::is_absolute` is host-specific: a bare `/workspace/...` literal - /// is not absolute on Windows, where an absolute path needs a drive or a - /// UNC prefix. The fixture must name a root the running host agrees is - /// absolute, or the contract rejects it as `InvalidTargetRoot`. - fn absolute_root(posix: &str) -> String { - if cfg!(windows) { - format!("C:{}", posix.replace('/', "\\")) - } else { - posix.to_owned() - } - } + use crate::test_fixtures::fixture_abs_root as absolute_root; fn linked() -> WorkPlacementTargetV1 { WorkPlacementTargetV1::new( diff --git a/crates/tracedecay-domain/tests/domain_suite/code_search_contract.rs b/crates/tracedecay-domain/tests/domain_suite/code_search_contract.rs index 7c8a9c5577..10efffa9aa 100644 --- a/crates/tracedecay-domain/tests/domain_suite/code_search_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/code_search_contract.rs @@ -1,5 +1,4 @@ use std::collections::BTreeMap; -use std::fmt; use tracedecay_domain::{ CandidateContribution, CompactCandidate, EvidenceRole, ExactAdmissionProof, @@ -11,13 +10,7 @@ use tracedecay_domain::{ const ZERO_DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn freshness() -> SourceFreshness { SourceFreshness { diff --git a/crates/tracedecay-domain/tests/domain_suite/configuration_contract.rs b/crates/tracedecay-domain/tests/domain_suite/configuration_contract.rs index 9dac403d57..e99ee1972a 100644 --- a/crates/tracedecay-domain/tests/domain_suite/configuration_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/configuration_contract.rs @@ -9,21 +9,12 @@ use tracedecay_domain::configuration::{ resolve_restrictive_capabilities, }; use tracedecay_domain::{ - AccessPolicyDigest, ActorId, CapabilityId, LocatorDigest, ManifestDigest, ProjectId, UtcMicros, + AccessPolicyDigest, ActorId, CapabilityId, LocatorDigest, ProjectId, UtcMicros, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture id is canonical") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("fixture digest is canonical") -} +use tracedecay_domain::test_fixtures::digest; fn locator_digest(byte: char) -> LocatorDigest { LocatorDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) diff --git a/crates/tracedecay-domain/tests/domain_suite/external_source_foundation_contract.rs b/crates/tracedecay-domain/tests/domain_suite/external_source_foundation_contract.rs index 43944c157a..9f87d0cedb 100644 --- a/crates/tracedecay-domain/tests/domain_suite/external_source_foundation_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/external_source_foundation_contract.rs @@ -1,18 +1,16 @@ use std::collections::{BTreeMap, BTreeSet}; use tracedecay_domain::{ - LocatorDigest, ManifestDigest, PrivacyDomainId, ProjectId, ProviderId, - SourceAcquisitionCapabilitiesV1, SourceAcquisitionContractV1, SourceAggregateFrontierV1, - SourceBindingOwnerV1, SourceBindingV1, SourceCaptureModeV1, SourceContentStateV1, - SourceCoverageV1, SourceCursorV1, SourceDefinitionV1, SourceDeletionSemanticsV1, - SourceInstanceId, SourceNativeObjectIdV1, SourceObjectObservationV1, SourceObjectRevisionV1, - SourcePartitionFrontierV1, SourcePartitionIdV1, SourceRefetchStrategyV1, SourceSnapshotIdV1, - UserProfileId, canonical_sha256, + LocatorDigest, PrivacyDomainId, ProjectId, ProviderId, SourceAcquisitionCapabilitiesV1, + SourceAcquisitionContractV1, SourceAggregateFrontierV1, SourceBindingOwnerV1, SourceBindingV1, + SourceCaptureModeV1, SourceContentStateV1, SourceCoverageV1, SourceCursorV1, + SourceDefinitionV1, SourceDeletionSemanticsV1, SourceInstanceId, SourceNativeObjectIdV1, + SourceObjectObservationV1, SourceObjectRevisionV1, SourcePartitionFrontierV1, + SourcePartitionIdV1, SourceRefetchStrategyV1, SourceSnapshotIdV1, UserProfileId, + canonical_sha256, }; -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn definition() -> SourceDefinitionV1 { let capabilities = SourceAcquisitionCapabilitiesV1::new( diff --git a/crates/tracedecay-domain/tests/domain_suite/feedback_contract.rs b/crates/tracedecay-domain/tests/domain_suite/feedback_contract.rs index f544893147..d7c547723a 100644 --- a/crates/tracedecay-domain/tests/domain_suite/feedback_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/feedback_contract.rs @@ -8,22 +8,13 @@ use tracedecay_domain::feedback::{ FeedbackTargetV1, FeedbackTriggerV1, ProviderEvaluationStateV1, }; use tracedecay_domain::{ - AgentInstanceId, CodeGenerationId, CommitId, FileOccurrenceId, HostInstanceId, ManifestDigest, - ProjectId, RepositoryId, RetrievalAnchorId, SessionId, UtcMicros, WorktreeId, + AgentInstanceId, CodeGenerationId, CommitId, FileOccurrenceId, HostInstanceId, ProjectId, + RepositoryId, RetrievalAnchorId, SessionId, UtcMicros, WorktreeId, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture id is canonical") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("fixture digest is canonical") -} +use tracedecay_domain::test_fixtures::digest; fn scope() -> FeedbackScopeV1 { FeedbackScopeV1 { diff --git a/crates/tracedecay-domain/tests/domain_suite/git_index_transaction_contract.rs b/crates/tracedecay-domain/tests/domain_suite/git_index_transaction_contract.rs index 8b18ea2f4f..6054ec7d94 100644 --- a/crates/tracedecay-domain/tests/domain_suite/git_index_transaction_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/git_index_transaction_contract.rs @@ -13,22 +13,13 @@ use tracedecay_domain::{ ManifestDigest, ProjectId, RepositoryId, UtcMicros, WorktreeId, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture id is canonical") -} +use tracedecay_domain::test_fixtures::id; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("fixture oid is canonical") } -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("fixture digest is canonical") -} +use tracedecay_domain::test_fixtures::digest; #[test] fn receipt_outcome_schema_preserves_the_exact_wire_states() { diff --git a/crates/tracedecay-domain/tests/domain_suite/git_topology_anchor_contract.rs b/crates/tracedecay-domain/tests/domain_suite/git_topology_anchor_contract.rs index 4cdd31829f..85e8afa664 100644 --- a/crates/tracedecay-domain/tests/domain_suite/git_topology_anchor_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/git_topology_anchor_contract.rs @@ -15,35 +15,25 @@ use tracedecay_domain::{ GitIndexTransactionId, GitIndexTransactionOperationV1, GitIndexTransactionReceiptV1, GitObjectFormatV1, GitOidV1, GitOperationStateV1, GitTopologyAnchorTargetV1, GitTopologyGenerationRefV1, GitTopologySourceRoleV1, IntegrationReceiptAnchorRefV1, - ManifestDigest, NativeGitObjectAnchorRefV1, NativeGitObjectKindV1, ObservationScopeV1, - PayloadAccessState, PreflightPreviewAnchorRefV1, PrivacyDomainBoundLocatorDigest, - PrivacyDomainId, ProjectId, ProjectionGenerationId, PullRequestSnapshotAnchorRefV1, RefId, - RefSnapshotAnchorRefV1, RefSnapshotKindV1, RepositoryCaptureAnchorRefV1, - RepositoryDirtyStateV1, RepositoryEvidenceV1, RepositoryId, RepositoryIndexSnapshotV1, - RepositoryIndexStateV1, RepositoryProvenanceV1, RepositoryRemoteIdentityV1, - RepositoryStateSnapshotV1, RepositoryWorkingTreeSnapshotV1, RepositoryWorkingTreeStateV1, - ResolutionAuthorizationV1, RetentionClass, RetrievalAnchorRecordV2, - RetrievalAnchorRecordV2Parts, RetrievalAnchorTargetV2, ScopeResolutionId, ShardId, UtcMicros, - VectorWatermark, WorktreeCaptureAnchorRefV1, WorktreeId, canonical_sha256, - derive_git_topology_anchor_id, + NativeGitObjectAnchorRefV1, NativeGitObjectKindV1, ObservationScopeV1, PayloadAccessState, + PreflightPreviewAnchorRefV1, PrivacyDomainBoundLocatorDigest, PrivacyDomainId, ProjectId, + ProjectionGenerationId, PullRequestSnapshotAnchorRefV1, RefId, RefSnapshotAnchorRefV1, + RefSnapshotKindV1, RepositoryCaptureAnchorRefV1, RepositoryDirtyStateV1, RepositoryEvidenceV1, + RepositoryId, RepositoryIndexSnapshotV1, RepositoryIndexStateV1, RepositoryProvenanceV1, + RepositoryRemoteIdentityV1, RepositoryStateSnapshotV1, RepositoryWorkingTreeSnapshotV1, + RepositoryWorkingTreeStateV1, ResolutionAuthorizationV1, RetentionClass, + RetrievalAnchorRecordV2, RetrievalAnchorRecordV2Parts, RetrievalAnchorTargetV2, + ScopeResolutionId, ShardId, UtcMicros, VectorWatermark, WorktreeCaptureAnchorRefV1, WorktreeId, + canonical_sha256, derive_git_topology_anchor_id, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture id is canonical") -} +use tracedecay_domain::test_fixtures::id; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("fixture oid is canonical") } -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("fixture digest is canonical") -} +use tracedecay_domain::test_fixtures::digest; fn snapshot(epoch: u64, head: char) -> RepositoryStateSnapshotV1 { RepositoryStateSnapshotV1::new( diff --git a/crates/tracedecay-domain/tests/domain_suite/multi_root_contract.rs b/crates/tracedecay-domain/tests/domain_suite/multi_root_contract.rs index 53c5a5362e..a04b0773f1 100644 --- a/crates/tracedecay-domain/tests/domain_suite/multi_root_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/multi_root_contract.rs @@ -1,12 +1,10 @@ use schemars::schema_for; use tracedecay_domain::{ - CollectionRevision, ManifestDigest, RootGenerationV1, ScopeOutcome, ScopePartialReasonV1, - ScopeSetId, ScopeSetRevision, ScopeUnavailableReasonV1, StackRevision, + CollectionRevision, RootGenerationV1, ScopeOutcome, ScopePartialReasonV1, ScopeSetId, + ScopeSetRevision, ScopeUnavailableReasonV1, StackRevision, }; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; #[test] fn scope_set_and_root_revision_identities_are_typed_and_nonzero() { diff --git a/crates/tracedecay-domain/tests/domain_suite/repository_scope_contract.rs b/crates/tracedecay-domain/tests/domain_suite/repository_scope_contract.rs index 6a9320cd96..9717659341 100644 --- a/crates/tracedecay-domain/tests/domain_suite/repository_scope_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/repository_scope_contract.rs @@ -1,4 +1,4 @@ -use tracedecay_domain::repository_path_matches_scope; +use tracedecay_domain::{path_matches_scope, repository_path_matches_scope}; #[test] fn scope_matches_itself_and_descendants_only() { @@ -19,3 +19,27 @@ fn scope_matches_itself_and_descendants_only() { Some("src") )); } + +#[test] +fn trailing_slash_is_literal_for_repository_scope_and_a_separator_for_path_scope() { + assert!(repository_path_matches_scope("src/", Some("src/"))); + assert!(repository_path_matches_scope("src//lib.rs", Some("src/"))); + assert!(!repository_path_matches_scope("src/lib.rs", Some("src/"))); + assert!(repository_path_matches_scope("", Some(""))); + assert!(repository_path_matches_scope("/src", Some(""))); + assert!(!repository_path_matches_scope("src", Some(""))); + assert!(!repository_path_matches_scope("/src", Some("/"))); + + assert!(path_matches_scope("src/lib.rs", Some("src"))); + assert!(path_matches_scope("src", Some("src"))); + assert!(!path_matches_scope("src2/lib.rs", Some("src"))); + assert!(path_matches_scope("src/lib.rs", None)); + assert!(path_matches_scope("src/lib.rs", Some("src/"))); + assert!(path_matches_scope("src/", Some("src/"))); + assert!(!path_matches_scope("src", Some("src/"))); + assert!(path_matches_scope("", Some(""))); + assert!(path_matches_scope("/a", Some(""))); + assert!(!path_matches_scope("a", Some(""))); + assert!(path_matches_scope("/src", Some("/"))); + assert!(!path_matches_scope("src", Some("/"))); +} diff --git a/crates/tracedecay-domain/tests/domain_suite/repository_state_contract.rs b/crates/tracedecay-domain/tests/domain_suite/repository_state_contract.rs index 7abc741946..2970fe7e85 100644 --- a/crates/tracedecay-domain/tests/domain_suite/repository_state_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/repository_state_contract.rs @@ -3,26 +3,17 @@ use tracedecay_domain::git::repository_state::{ RepositoryWorkingTreeSnapshotV1, RepositoryWorkingTreeStateV1, }; use tracedecay_domain::{ - GitCoverageV1, GitHeadStateV1, GitObjectFormatV1, GitOidV1, GitOperationStateV1, - ManifestDigest, ProjectId, RepositoryId, UtcMicros, WorktreeId, + GitCoverageV1, GitHeadStateV1, GitObjectFormatV1, GitOidV1, GitOperationStateV1, ProjectId, + RepositoryId, UtcMicros, WorktreeId, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture id is canonical") -} +use tracedecay_domain::test_fixtures::id; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("fixture oid is canonical") } -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("fixture digest is canonical") -} +use tracedecay_domain::test_fixtures::digest; fn snapshot(head: GitOidV1) -> RepositoryStateSnapshotV1 { RepositoryStateSnapshotV1::new( diff --git a/crates/tracedecay-domain/tests/domain_suite/work_duplicate_adjudication_contract.rs b/crates/tracedecay-domain/tests/domain_suite/work_duplicate_adjudication_contract.rs index f1bc1b5120..edc161c718 100644 --- a/crates/tracedecay-domain/tests/domain_suite/work_duplicate_adjudication_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/work_duplicate_adjudication_contract.rs @@ -7,13 +7,7 @@ use tracedecay_domain::{ WorkDuplicateAdjudicationRevisionV1, WorkTopologyGenerationRefV1, WorktreeId, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn attempt(task: &str, run: &str, attempt: &str) -> WorkAttemptIdentityV1 { WorkAttemptIdentityV1::new( diff --git a/crates/tracedecay-domain/tests/domain_suite/work_execution_snapshot_contract.rs b/crates/tracedecay-domain/tests/domain_suite/work_execution_snapshot_contract.rs index a845d8a3c4..4104df6535 100644 --- a/crates/tracedecay-domain/tests/domain_suite/work_execution_snapshot_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/work_execution_snapshot_contract.rs @@ -2,25 +2,16 @@ use std::collections::BTreeSet; use tracedecay_domain::{ AutomaticWorktreeGcV1, ConfigurationRevisionId, ConfigurationSnapshotId, CredentialReferenceId, - CrossMergeModeV1, ManifestDigest, ProviderId, TopologyNotificationLevelV1, UtcMicros, - WorkApprovalPolicy, WorkEgressPolicy, WorkExecutableReference, WorkExecutionLimits, - WorkExecutionSnapshot, WorkExecutionSnapshotInput, WorkFallbackTopology, WorkFilesystemPolicy, - WorkProviderBackendV1, WorkProviderProtocol, WorkProviderRouteId, WorkProviderRouteV1, - WorkRuntimeContractError, WorkSandboxPolicy, WorktreeCleanlinessRequirementV1, - safe_work_topology_policy_v1, + CrossMergeModeV1, ProviderId, TopologyNotificationLevelV1, UtcMicros, WorkApprovalPolicy, + WorkEgressPolicy, WorkExecutableReference, WorkExecutionLimits, WorkExecutionSnapshot, + WorkExecutionSnapshotInput, WorkFallbackTopology, WorkFilesystemPolicy, WorkProviderBackendV1, + WorkProviderProtocol, WorkProviderRouteId, WorkProviderRouteV1, WorkRuntimeContractError, + WorkSandboxPolicy, WorktreeCleanlinessRequirementV1, safe_work_topology_policy_v1, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn route(provider: &str, route: &str) -> WorkProviderRouteV1 { WorkProviderRouteV1::new(id::(provider), id::(route)).unwrap() diff --git a/crates/tracedecay-domain/tests/domain_suite/work_product_contract.rs b/crates/tracedecay-domain/tests/domain_suite/work_product_contract.rs index 2f4d69b4d9..5182d96967 100644 --- a/crates/tracedecay-domain/tests/domain_suite/work_product_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/work_product_contract.rs @@ -2,33 +2,25 @@ use std::collections::{BTreeMap, BTreeSet}; use tracedecay_domain::{ AcceptanceCriterionId, AttemptId, InitiativeId, MAX_WORK_PRODUCT_EVENT_EVIDENCE, - MAX_WORK_PRODUCT_EVENT_RELATION_SCOPES, MAX_WORK_PRODUCT_EVENT_SOURCE_WATERMARKS, - ManifestDigest, MilestoneId, ProjectionGenerationId, ProposalId, RetrievalAnchorId, RunId, - SourceStoreId, TaskEvidenceLinkId, TaskEvidenceLinkV1, TaskId, UtcMicros, - WorkAcceptanceCriterionV1, WorkAttemptIdentityV1, WorkAttemptStateV1, WorkGraphChangeV1, - WorkGraphVersionV1, WorkHierarchyV1, WorkInitiativeV1, WorkItemInputV1, WorkItemV1, WorkPlanId, - WorkPlanV1, WorkProductAuthorizedRelationScopeV1, WorkProductContractError, - WorkProductEventContractError, WorkProductEventEvidenceV1, WorkProductEventInputV1, - WorkProductEventPayloadV1, WorkProductEventSequenceV1, WorkProductEventV1, WorkProductGraphV1, - WorkProductProfileScopeV1, WorkProductProjectionBundleV1, WorkProductRelationV1, - WorkProductSourceWatermarkV1, WorkProjectionSequenceV1, WorkProposalDispositionV1, - WorkProposalV1, WorkProposedChildV1, WorkRelationReplanProposalV1, WorkRouteDecisionV1, - WorkRuntimeAttemptProjectionV1, WorkRuntimeProjectionCoverageV1, WorkRuntimeProjectionV1, - WorkScoreKindV1, WorkShapeAssessmentV1, WorkSizingV1, WorkTaskEvidenceCoverageV1, - WorkTaskEvidenceV1, WorkTimelineLaneV1, canonical_json_bytes, + MAX_WORK_PRODUCT_EVENT_RELATION_SCOPES, MAX_WORK_PRODUCT_EVENT_SOURCE_WATERMARKS, MilestoneId, + ProjectionGenerationId, ProposalId, RetrievalAnchorId, RunId, SourceStoreId, + TaskEvidenceLinkId, TaskEvidenceLinkV1, TaskId, UtcMicros, WorkAcceptanceCriterionV1, + WorkAttemptIdentityV1, WorkAttemptStateV1, WorkGraphChangeV1, WorkGraphVersionV1, + WorkHierarchyV1, WorkInitiativeV1, WorkItemInputV1, WorkItemV1, WorkPlanId, WorkPlanV1, + WorkProductAuthorizedRelationScopeV1, WorkProductContractError, WorkProductEventContractError, + WorkProductEventEvidenceV1, WorkProductEventInputV1, WorkProductEventPayloadV1, + WorkProductEventSequenceV1, WorkProductEventV1, WorkProductGraphV1, WorkProductProfileScopeV1, + WorkProductProjectionBundleV1, WorkProductRelationV1, WorkProductSourceWatermarkV1, + WorkProjectionSequenceV1, WorkProposalDispositionV1, WorkProposalV1, WorkProposedChildV1, + WorkRelationReplanProposalV1, WorkRouteDecisionV1, WorkRuntimeAttemptProjectionV1, + WorkRuntimeProjectionCoverageV1, WorkRuntimeProjectionV1, WorkScoreKindV1, + WorkShapeAssessmentV1, WorkSizingV1, WorkTaskEvidenceCoverageV1, WorkTaskEvidenceV1, + WorkTimelineLaneV1, canonical_json_bytes, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn hierarchy() -> WorkHierarchyV1 { WorkHierarchyV1::new( diff --git a/crates/tracedecay-domain/tests/domain_suite/work_runtime_contract.rs b/crates/tracedecay-domain/tests/domain_suite/work_runtime_contract.rs index 10e01902e8..a93980f14e 100644 --- a/crates/tracedecay-domain/tests/domain_suite/work_runtime_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/work_runtime_contract.rs @@ -2,13 +2,13 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::json; use tracedecay_domain::{ - AttemptId, CommitId, ConfigurationRevisionId, ConfigurationSnapshotId, ManifestDigest, - ProjectId, ProposalId, ProviderId, RefId, RepositoryId, RunId, SourceStoreId, TaskId, - UtcMicros, WorkApprovalPolicy, WorkArtifactId, WorkArtifactRefV1, WorkAttemptIdentityV1, - WorkAttemptProjectionBindingV1, WorkAttemptStateV1, WorkAttemptV1, - WorkCancellationAcknowledgementV1, WorkCancellationEscalationV1, WorkCancellationRequestId, - WorkCancellationRequestV1, WorkCancellationStateV1, WorkEffectStateV1, WorkEgressPolicy, - WorkExecutableReference, WorkExecutionEnvelopeV1, WorkExecutionLimits, WorkExecutionSnapshot, + AttemptId, CommitId, ConfigurationRevisionId, ConfigurationSnapshotId, ProjectId, ProposalId, + ProviderId, RefId, RepositoryId, RunId, SourceStoreId, TaskId, UtcMicros, WorkApprovalPolicy, + WorkArtifactId, WorkArtifactRefV1, WorkAttemptIdentityV1, WorkAttemptProjectionBindingV1, + WorkAttemptStateV1, WorkAttemptV1, WorkCancellationAcknowledgementV1, + WorkCancellationEscalationV1, WorkCancellationRequestId, WorkCancellationRequestV1, + WorkCancellationStateV1, WorkEffectStateV1, WorkEgressPolicy, WorkExecutableReference, + WorkExecutionEnvelopeV1, WorkExecutionLimits, WorkExecutionSnapshot, WorkExecutionSnapshotInput, WorkFallbackTopology, WorkFenceEpochV1, WorkFilesystemPolicy, WorkGraphChangeV1, WorkGraphVersionV1, WorkHierarchyV1, WorkInitiativeV1, WorkItemInputV1, WorkItemV1, WorkLeaseFenceV1, WorkLeaseId, WorkMilestoneV1, WorkPlanId, WorkPlanV1, @@ -19,17 +19,9 @@ use tracedecay_domain::{ WorkflowOperationRef, WorktreeId, safe_work_topology_policy_v1, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn route(provider: &str, route: &str) -> WorkProviderRouteV1 { WorkProviderRouteV1::new(id::(provider), id::(route)).unwrap() @@ -107,16 +99,7 @@ fn execution_snapshot() -> WorkExecutionSnapshot { .unwrap() } -/// `WorkExecutionEnvelopeV1` requires `Path::is_absolute`, which is -/// host-specific: a bare `/tmp/...` literal is not absolute on Windows, where -/// an absolute path needs a drive or a UNC prefix. -fn absolute_root(posix: &str) -> String { - if cfg!(windows) { - format!("C:{}", posix.replace('/', "\\")) - } else { - posix.to_owned() - } -} +use tracedecay_domain::test_fixtures::fixture_abs_root as absolute_root; fn execution( attempt_identity: WorkAttemptIdentityV1, diff --git a/crates/tracedecay-domain/tests/domain_suite/workflow_definition_contract.rs b/crates/tracedecay-domain/tests/domain_suite/workflow_definition_contract.rs index b682fe8970..f5617d590d 100644 --- a/crates/tracedecay-domain/tests/domain_suite/workflow_definition_contract.rs +++ b/crates/tracedecay-domain/tests/domain_suite/workflow_definition_contract.rs @@ -4,27 +4,19 @@ use serde_json::json; use tracedecay_domain::configuration::safe_work_topology_policy_v1; use tracedecay_domain::{ AttemptId, MAX_WORKFLOW_FAN_OUT, MAX_WORKFLOW_INPUTS, MAX_WORKFLOW_OUTPUTS, - MAX_WORKFLOW_PREDECESSORS, MAX_WORKFLOW_STEPS, ManifestDigest, ProjectId, ProviderId, RunId, - TaskId, UtcMicros, WorkArtifactId, WorkArtifactRefV1, WorkAttemptIdentityV1, WorkCommandId, - WorkProviderBackendV1, WorkProviderRouteId, WorkProviderRouteV1, WorkflowDefinition, - WorkflowDefinitionError, WorkflowDefinitionId, WorkflowFanOut, WorkflowOperationRef, - WorkflowOutputArtifact, WorkflowOutputName, WorkflowOutputReference, WorkflowPlacementReceipt, - WorkflowRunCommand, WorkflowRunEvent, WorkflowRunEventContext, WorkflowRunProjection, - WorkflowRunStateError, WorkflowRunStatus, WorkflowStep, WorkflowStepEffectOutcome, - WorkflowStepEffectReceipt, WorkflowStepId, WorkflowStepOutput, WorkflowStepStatus, + MAX_WORKFLOW_PREDECESSORS, MAX_WORKFLOW_STEPS, ProjectId, ProviderId, RunId, TaskId, UtcMicros, + WorkArtifactId, WorkArtifactRefV1, WorkAttemptIdentityV1, WorkCommandId, WorkProviderBackendV1, + WorkProviderRouteId, WorkProviderRouteV1, WorkflowDefinition, WorkflowDefinitionError, + WorkflowDefinitionId, WorkflowFanOut, WorkflowOperationRef, WorkflowOutputArtifact, + WorkflowOutputName, WorkflowOutputReference, WorkflowPlacementReceipt, WorkflowRunCommand, + WorkflowRunEvent, WorkflowRunEventContext, WorkflowRunProjection, WorkflowRunStateError, + WorkflowRunStatus, WorkflowStep, WorkflowStepEffectOutcome, WorkflowStepEffectReceipt, + WorkflowStepId, WorkflowStepOutput, WorkflowStepStatus, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn step( step_id: &str, diff --git a/crates/tracedecay-global-db/Cargo.toml b/crates/tracedecay-global-db/Cargo.toml index 7eeadf9d8d..0935da236e 100644 --- a/crates/tracedecay-global-db/Cargo.toml +++ b/crates/tracedecay-global-db/Cargo.toml @@ -58,7 +58,7 @@ tracedecay-temporal-query = { path = "../tracedecay-temporal-query", version = " tempfile = { version = "3", optional = true } [dev-dependencies] -criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } +criterion = { version = "0.5", features = ["async_tokio"] } tempfile = "3" tokio = { version = "1", features = ["full", "test-util"] } # Test-only helper surfaces this crate's test targets reach across crate diff --git a/crates/tracedecay-global-db/src/configuration/resolver.rs b/crates/tracedecay-global-db/src/configuration/resolver.rs index 5899e7b975..7f158a9864 100644 --- a/crates/tracedecay-global-db/src/configuration/resolver.rs +++ b/crates/tracedecay-global-db/src/configuration/resolver.rs @@ -183,13 +183,7 @@ mod tests { ConfigurationValueV1, UserProfileId, }; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; #[test] fn same_value_in_a_higher_layer_changes_provenance_not_behavior() { diff --git a/crates/tracedecay-global-db/src/configuration/store/tests/mod.rs b/crates/tracedecay-global-db/src/configuration/store/tests/mod.rs index 7ac673bd55..8484c6bf39 100644 --- a/crates/tracedecay-global-db/src/configuration/store/tests/mod.rs +++ b/crates/tracedecay-global-db/src/configuration/store/tests/mod.rs @@ -7,7 +7,7 @@ use super::{ ConfigurationMutationAuthority, ConfigurationMutationReceiptV1, ConfigurationProtectedOperationV1, ConfigurationProtectedPlanRecordV1, ConfigurationRevisionId, ConfigurationRevisionRecordV1, ConfigurationSnapshotV1, ConfigurationValueV1, - GlobalDbConfigurationControlStore, ManifestDigest, TestConnection, + GlobalDbConfigurationControlStore, TestConnection, }; use crate::configuration::contracts::ScopeRevalidationEvidenceV1; use crate::configuration::registry::ConfigurationRegistry; @@ -27,9 +27,7 @@ use tracedecay_domain::{ AccessPolicyDigest, ActorId, LocatorDigest, ProjectId, UtcMicros, canonical_sha256, }; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; #[test] fn incomplete_snapshot_requires_reset_instead_of_default_repair() { @@ -190,13 +188,7 @@ async fn count( rows.next().await.unwrap().unwrap().get::(0).unwrap() } -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn root_revision() -> ConfigurationRevisionRecordV1 { let snapshot = resolve_configuration(&ConfigurationRegistry::core().unwrap(), &[]) diff --git a/crates/tracedecay-global-db/src/git_index_transactions/store.rs b/crates/tracedecay-global-db/src/git_index_transactions/store.rs index 3db2cbf1cf..b8a06771f5 100644 --- a/crates/tracedecay-global-db/src/git_index_transactions/store.rs +++ b/crates/tracedecay-global-db/src/git_index_transactions/store.rs @@ -1196,16 +1196,15 @@ fn receipt_outcome_code(outcome: GitIndexReceiptOutcomeV1) -> &'static str { } fn encode(value: &T) -> GitIndexTransactionStoreResult { - serde_json::to_string(value).map_err(|error| invalid(error.to_string())) + crate::sqlite_persist::encode_json(value).map_err(|error| invalid(error.to_string())) } fn decode(value: &str) -> GitIndexTransactionStoreResult { - serde_json::from_str(value).map_err(|error| invalid(error.to_string())) + crate::sqlite_persist::decode_json(value).map_err(|error| invalid(error.to_string())) } fn text(row: &Row, column: i32, field: &'static str) -> GitIndexTransactionStoreResult { - row.get::(column) - .map_err(|error| invalid(format!("read {field}: {error}"))) + crate::sqlite_persist::row_text(row, column, field).map_err(invalid) } fn optional_text( @@ -1213,8 +1212,7 @@ fn optional_text( column: i32, field: &'static str, ) -> GitIndexTransactionStoreResult> { - row.get::>(column) - .map_err(|error| invalid(format!("read {field}: {error}"))) + crate::sqlite_persist::row_optional_text(row, column, field).map_err(invalid) } fn invalid(message: impl Into) -> GitIndexTransactionStoreError { diff --git a/crates/tracedecay-global-db/src/git_index_transactions/tests.rs b/crates/tracedecay-global-db/src/git_index_transactions/tests.rs index 867168a82b..8de103fe9c 100644 --- a/crates/tracedecay-global-db/src/git_index_transactions/tests.rs +++ b/crates/tracedecay-global-db/src/git_index_transactions/tests.rs @@ -4,9 +4,9 @@ use tracedecay_domain::{ GitIndexPreviewId, GitIndexPreviewInputV1, GitIndexPreviewV1, GitIndexReceiptId, GitIndexReceiptOutcomeV1, GitIndexSigningPolicyV1, GitIndexTransactionId, GitIndexTransactionJournalV1, GitIndexTransactionOperationV1, GitIndexTransactionReceiptV1, - GitObjectFormatV1, GitOidV1, ManifestDigest, ProjectId, RepositoryId, - RepositoryIndexSnapshotV1, RepositoryIndexStateV1, RepositoryStateSnapshotV1, - RepositoryWorkingTreeSnapshotV1, RepositoryWorkingTreeStateV1, UtcMicros, WorktreeId, + GitObjectFormatV1, GitOidV1, ProjectId, RepositoryId, RepositoryIndexSnapshotV1, + RepositoryIndexStateV1, RepositoryStateSnapshotV1, RepositoryWorkingTreeSnapshotV1, + RepositoryWorkingTreeStateV1, UtcMicros, WorktreeId, }; use tracedecay_runtime_core::db::engine::params; use tracedecay_store::{ @@ -52,17 +52,9 @@ async fn repositories_page( } } -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture identity") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("fixture digest") -} +use tracedecay_domain::test_fixtures::digest; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("fixture object id") diff --git a/crates/tracedecay-global-db/src/git_topology_anchor.rs b/crates/tracedecay-global-db/src/git_topology_anchor.rs index 85ca263ddd..1cf5ef7b8b 100644 --- a/crates/tracedecay-global-db/src/git_topology_anchor.rs +++ b/crates/tracedecay-global-db/src/git_topology_anchor.rs @@ -58,7 +58,8 @@ impl RegisteredGitTopologyAnchorAuthorityV2 { } let anchor_json = serde_json::to_string(&candidate) .map_err(|_| GitTopologyAnchorAuthorityErrorV2::Conflict)?; - let owner_json = serde_json::to_string(candidate.owner()) + let owner_json = candidate + .owner_column_json() .map_err(|_| GitTopologyAnchorAuthorityErrorV2::Conflict)?; transaction .execute( @@ -209,7 +210,7 @@ fn decode_record( record .validate() .map_err(|_| GitTopologyAnchorAuthorityErrorV2::ResetRequired)?; - if serde_json::to_string(record.owner()).ok().as_deref() != Some(owner_json) + if !record.owner_column_matches(owner_json) || record.projection_generation().as_str() != projection_generation { return Err(GitTopologyAnchorAuthorityErrorV2::ResetRequired); diff --git a/crates/tracedecay-global-db/src/lib.rs b/crates/tracedecay-global-db/src/lib.rs index 2cfbce86dc..d8e822b3c9 100644 --- a/crates/tracedecay-global-db/src/lib.rs +++ b/crates/tracedecay-global-db/src/lib.rs @@ -10,10 +10,9 @@ //! //! ## Dependency edges //! -//! Depends on `tracedecay-runtime-core` (kernel db/errors/storage/config), +//! Depends on `tracedecay-runtime-core` (kernel db/errors/storage/config) and //! `tracedecay-sessions` (session runtime, `lcm::contracts`, -//! `retrieval_content`), and `tracedecay-semantic` (resource ceilings, default -//! embedding model). All three are proven acyclic, `cargo tree -p -e +//! `retrieval_content`). Both edges are acyclic: `cargo tree -p -e //! normal` never names this crate. `RuntimeExternalSourceStore` and //! `GlobalDbObservationStore` is deliberately a root-owned adapter. It takes //! a guarded database client issued by the registered owner, so the composition @@ -63,8 +62,6 @@ pub use observation_projection::{ converge_projection_predecessor, project_observation, project_queued_observations, rebuild_projection, }; -#[cfg(test)] -pub use observation_projection::{project_observation_with_engine, rebuild_projection_with_engine}; pub use tracedecay_domain::CoverageStateV1; pub use workflow_adapter::GlobalDbWorkflowStore; mod observation_store; diff --git a/crates/tracedecay-global-db/src/native_integration/store.rs b/crates/tracedecay-global-db/src/native_integration/store.rs index 7175630fae..cf0308655d 100644 --- a/crates/tracedecay-global-db/src/native_integration/store.rs +++ b/crates/tracedecay-global-db/src/native_integration/store.rs @@ -976,13 +976,13 @@ fn terminal_outcome_code(outcome: NativeIntegrationTerminalOutcomeV1) -> &'stati } pub(super) fn encode(value: &T) -> NativeIntegrationStoreResult { - serde_json::to_string(value).map_err(|error| invalid(error.to_string())) + crate::sqlite_persist::encode_json(value).map_err(|error| invalid(error.to_string())) } pub(super) fn decode( value: &str, ) -> NativeIntegrationStoreResult { - serde_json::from_str(value).map_err(|error| invalid(error.to_string())) + crate::sqlite_persist::decode_json(value).map_err(|error| invalid(error.to_string())) } pub(super) fn text( @@ -990,8 +990,7 @@ pub(super) fn text( column: i32, field: &'static str, ) -> NativeIntegrationStoreResult { - row.get::(column) - .map_err(|error| invalid(format!("read {field}: {error}"))) + crate::sqlite_persist::row_text(row, column, field).map_err(invalid) } fn optional_text( @@ -999,8 +998,7 @@ fn optional_text( column: i32, field: &'static str, ) -> NativeIntegrationStoreResult> { - row.get::>(column) - .map_err(|error| invalid(format!("read {field}: {error}"))) + crate::sqlite_persist::row_optional_text(row, column, field).map_err(invalid) } pub(super) fn invalid(message: impl Into) -> NativeIntegrationStoreError { diff --git a/crates/tracedecay-global-db/src/native_integration/tests.rs b/crates/tracedecay-global-db/src/native_integration/tests.rs index 70bc768ec7..b9b8dd4379 100644 --- a/crates/tracedecay-global-db/src/native_integration/tests.rs +++ b/crates/tracedecay-global-db/src/native_integration/tests.rs @@ -1,11 +1,11 @@ use tracedecay_domain::{ ActorId, CapabilityId, CodeGenerationId, ContentDigest, FrozenIndependentBranchSelectionV1, - GitHeadStateV1, GitObjectFormatV1, GitOidV1, GitOperationStateV1, ManifestDigest, - MechanicalIntegrationModeV1, NativeIntegrationAnalysisCoverageV1, - NativeIntegrationAnalysisLaneV1, NativeIntegrationAnalysisReportV1, - NativeIntegrationApprovalId, NativeIntegrationApprovalV1, NativeIntegrationGenerationBindingV1, - NativeIntegrationPhaseV1, NativeIntegrationPreviewDispositionV1, NativeIntegrationPreviewId, - NativeIntegrationPreviewV1, NativeIntegrationReceiptV1, NativeIntegrationRepositorySnapshotV1, + GitHeadStateV1, GitObjectFormatV1, GitOidV1, GitOperationStateV1, MechanicalIntegrationModeV1, + NativeIntegrationAnalysisCoverageV1, NativeIntegrationAnalysisLaneV1, + NativeIntegrationAnalysisReportV1, NativeIntegrationApprovalId, NativeIntegrationApprovalV1, + NativeIntegrationGenerationBindingV1, NativeIntegrationPhaseV1, + NativeIntegrationPreviewDispositionV1, NativeIntegrationPreviewId, NativeIntegrationPreviewV1, + NativeIntegrationReceiptV1, NativeIntegrationRepositorySnapshotV1, NativeIntegrationSelectionV1, NativeIntegrationTerminalOutcomeV1, NativeIntegrationTransactionId, NativeIntegrationTransactionStatusV1, ProjectId, RefId, RepositoryId, UtcMicros, WorktreeInventoryEpoch, WorktreeInventorySnapshotId, @@ -17,9 +17,7 @@ use tracedecay_store::{ use super::store::GlobalDbNativeIntegrationStore; use crate::{RegisteredGlobalDb, tests::harness::RegisteredGlobalDbHarness}; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("fixture digest") -} +use tracedecay_domain::test_fixtures::digest; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("fixture object id") diff --git a/crates/tracedecay-global-db/src/observation_adapter.rs b/crates/tracedecay-global-db/src/observation_adapter.rs index 965107ef73..180efb8f70 100644 --- a/crates/tracedecay-global-db/src/observation_adapter.rs +++ b/crates/tracedecay-global-db/src/observation_adapter.rs @@ -1303,7 +1303,7 @@ async fn read_stored_observations_from_snapshot( .map_err(|error| runtime_storage_error(operation, error))?; let expected_repository_owner = repository_anchor .as_ref() - .map(|anchor: &RetrievalAnchorRecordV2| serde_json::to_string(anchor.owner())) + .map(RetrievalAnchorRecordV2::owner_column_json) .transpose() .map_err(|error| runtime_storage_error(operation, error))?; if repository_owner != expected_repository_owner { diff --git a/crates/tracedecay-global-db/src/observation_projection.rs b/crates/tracedecay-global-db/src/observation_projection.rs index 2b58d746cd..7d84eb109a 100644 --- a/crates/tracedecay-global-db/src/observation_projection.rs +++ b/crates/tracedecay-global-db/src/observation_projection.rs @@ -15,8 +15,6 @@ pub use rebuild::{ converge_projection_predecessor, project_observation, project_queued_observations, rebuild_projection, }; -#[cfg(test)] -pub use rebuild::{project_observation_with_engine, rebuild_projection_with_engine}; pub(crate) use schema::{ OBSERVATION_PROJECTION_BINDING_TRIGGERS_SQL, OBSERVATION_PROJECTION_PERFORMANCE_INDEX_SQL, OBSERVATION_PROJECTION_SCHEMA_SQL, diff --git a/crates/tracedecay-global-db/src/observation_projection/apply.rs b/crates/tracedecay-global-db/src/observation_projection/apply.rs index 5bcf7aabf8..f27ca237b5 100644 --- a/crates/tracedecay-global-db/src/observation_projection/apply.rs +++ b/crates/tracedecay-global-db/src/observation_projection/apply.rs @@ -10,14 +10,11 @@ use tracedecay_store::{ ObservationProjection, PROVIDER_USAGE_PROJECTOR_VERSION, ProjectionSkipReason, ProjectionStoreError, ProjectionStoreResult, SESSION_MESSAGE_PROJECTOR_VERSION, SessionMessageProjection, SessionMessageRecord, SessionRecord, WorkflowFactProjection, - WorkflowFactRecord, derive_canonical_projection, workflow_semantic_kind, + WorkflowFactRecord, workflow_semantic_kind, }; use tracedecay_lcm::contracts::LcmError; use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, params}; -use tracedecay_sessions::runtime::claude::{ - ClaudeRecordContext, ClaudeRecordDisposition, map_sanitized_claude_record, -}; use tracedecay_sessions::runtime::store_access::find_preceding_codex_goal_response; use super::state::{ @@ -39,84 +36,7 @@ fn decode_canonical_envelope( pub(in super::super) fn derive_projection( observation: &DurableObservationV1, ) -> ProjectionStoreResult { - match observation.source().provider().as_str() { - "claude" if decode_canonical_envelope(observation.payload()).is_ok() => { - derive_canonical_projection(observation) - } - "claude" => derive_claude_projection(observation), - _ => derive_canonical_projection(observation), - } -} - -fn derive_claude_projection( - observation: &DurableObservationV1, -) -> ProjectionStoreResult { - let session_id = observation.source().session_id().as_str(); - let payload = observation.payload(); - let durable_message_id = payload - .pointer("/message/id") - .and_then(serde_json::Value::as_str) - .or_else(|| payload.get("uuid").and_then(serde_json::Value::as_str)) - .filter(|id| !id.is_empty()); - let durable_tool_event_ids = payload - .pointer("/message/content") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|item| { - item.get("id") - .or_else(|| item.get("tool_use_id")) - .and_then(serde_json::Value::as_str) - .filter(|id| !id.is_empty()) - .map(str::to_owned) - }) - .collect::>(); - let (project_key, project_path) = match observation.scope() { - ObservationScopeV1::Profile => ("user", "user"), - ObservationScopeV1::Project { project_id } => (project_id.as_str(), project_id.as_str()), - }; - let source_path = (observation.source().source_key() != observation.source().session_id()) - .then(|| observation.source().source_key().as_str()); - let context = ClaudeRecordContext { - session_id, - project_key, - project_path, - file_generation: observation.identity().generation().file_id(), - offset: observation.identity().position().start(), - session_cwd: None, - source_path, - raw_message_id: durable_message_id, - raw_tool_event_ids: &durable_tool_event_ids, - raw_hook_tool_use_id: None, - }; - - match map_sanitized_claude_record(payload, &context) { - ClaudeRecordDisposition::Message { draft, message } => { - let draft = *draft; - let message = *message; - let timestamp = message.timestamp; - let session = SessionRecord { - provider: "claude".to_string(), - session_id: draft.session_id, - project_key: draft.project_key, - project_path: draft.project_path, - title: draft.title, - started_at: timestamp, - ended_at: timestamp, - transcript_path: None, - metadata_json: draft.metadata_json, - parent_session_id: draft.parent_session_id, - is_subagent: draft.is_subagent, - agent_id: draft.agent_id, - parent_tool_use_id: draft.parent_tool_use_id, - }; - ObservationProjection::for_message(observation, session, message) - } - ClaudeRecordDisposition::NonConversational => ObservationProjection::for_skip( - observation, - ProjectionSkipReason::NonConversationalRecord, - ), - } + tracedecay_session_temporal_store::derive_projection(observation) } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs index 2501f2712d..ff5ad8457f 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs @@ -4,8 +4,6 @@ use tracedecay_domain::{CanonicalObservationIdV1, DurableObservationV1}; use tracedecay_lcm::retrieval_content::{ derived_text_for_index, derived_text_for_snippet, projected_content_hash, }; -#[cfg(test)] -use tracedecay_runtime_core::db::engine::{Connection, TransactionBehavior}; use tracedecay_runtime_core::db::{ Database, engine::{Executor, QueryExecutor, Row, params}, @@ -292,81 +290,6 @@ async fn projection_queue_has_items(conn: &impl QueryExecutor) -> ProjectionStor .is_some()) } -#[cfg(test)] -pub async fn project_observation_with_engine( - conn: &Connection, - observation_id: &CanonicalObservationIdV1, -) -> ProjectionStoreResult { - let transaction = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .await - .map_err(|error| storage("begin projection transaction", error))?; - let now_micros = tracedecay_contracts::clock::now_micros().0; - if let Some(retry) = projection_retry_state(&transaction, observation_id).await? - && retry.next_retry_at_micros > now_micros - { - transaction - .rollback() - .await - .map_err(|error| storage("rollback deferred projection transaction", error))?; - return Err(ProjectionStoreError::RetryDeferred { - attempt_count: retry.attempt_count, - next_retry_at_micros: retry.next_retry_at_micros, - last_error: retry.last_error.ok_or_else(|| { - storage_message( - "read deferred projection retry", - "deferred projection retry has no failure detail", - ) - })?, - }); - } - match project_observation_in_transaction(&transaction, observation_id).await { - Ok(outcome) => match transaction.commit().await { - Ok(()) => Ok(outcome), - Err(commit_error) => { - let error = storage("commit projection transaction", commit_error); - persist_projection_retry_with_engine( - conn, - observation_id, - now_micros, - &error.durable_detail(), - ) - .await?; - Err(error) - } - }, - Err(error) => { - transaction.rollback().await.map_err(|rollback_error| { - storage("rollback failed projection transaction", rollback_error) - })?; - if matches!(error, ProjectionStoreError::Storage { .. }) { - persist_projection_retry_with_engine( - conn, - observation_id, - now_micros, - &error.durable_detail(), - ) - .await?; - } else if matches!(error, ProjectionStoreError::Contract(_)) { - persist_projection_rejection_with_engine( - conn, - observation_id, - ProjectionSkipReason::InvalidContract, - ) - .await?; - } else if matches!(error, ProjectionStoreError::SanitizationRefused { .. }) { - persist_projection_rejection_with_engine( - conn, - observation_id, - ProjectionSkipReason::SanitizationRefused, - ) - .await?; - } - Err(error) - } - } -} - fn projection_retry_delay_micros(attempt_count: u32) -> i64 { let shift = attempt_count.saturating_sub(1).min(16); PROJECTION_RETRY_BASE_MICROS @@ -439,73 +362,6 @@ async fn persist_projection_rejection_on_database( .map_err(|error| storage("commit projection rejection transaction", error)) } -#[cfg(test)] -async fn persist_projection_retry_with_engine( - conn: &Connection, - observation_id: &CanonicalObservationIdV1, - now_micros: i64, - last_error: &str, -) -> ProjectionStoreResult<()> { - let transaction = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .await - .map_err(|error| storage("begin projection retry transaction", error))?; - let retry = projection_retry_state(&transaction, observation_id) - .await? - .ok_or(ProjectionStoreError::NotQueued)?; - let attempt_count = retry.attempt_count.saturating_add(1); - let next_retry_at_micros = - now_micros.saturating_add(projection_retry_delay_micros(attempt_count)); - schedule_projection_retry( - &transaction, - observation_id, - attempt_count, - next_retry_at_micros, - last_error, - ) - .await?; - transaction - .commit() - .await - .map_err(|error| storage("commit projection retry transaction", error)) -} - -#[cfg(test)] -async fn persist_projection_rejection_with_engine( - conn: &Connection, - observation_id: &CanonicalObservationIdV1, - reason: ProjectionSkipReason, -) -> ProjectionStoreResult<()> { - let transaction = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .await - .map_err(|error| storage("begin projection rejection transaction", error))?; - let checkpoint = read_checkpoint(&transaction).await?; - let Some((sequence, observation)) = read_observation(&transaction, observation_id).await? - else { - return Err(ProjectionStoreError::ObservationNotFound); - }; - let expected = checkpoint.last_sequence().saturating_add(1); - if sequence > checkpoint.last_sequence() && sequence != expected { - return Err(ProjectionStoreError::Gap { - expected, - actual: sequence, - }); - } - if queued_sequence(&transaction, observation_id).await? != Some(sequence) { - return Err(ProjectionStoreError::NotQueued); - } - apply_skip_disposition(&transaction, &observation, reason).await?; - consume_projection_queue_item(&transaction, observation_id).await?; - if sequence > checkpoint.last_sequence() { - write_checkpoint(&transaction, sequence).await?; - } - transaction - .commit() - .await - .map_err(|error| storage("commit projection rejection transaction", error)) -} - #[hotpath::measure( future = true, label = "global_db.observation_projection.persist.rebuild" @@ -673,122 +529,6 @@ async fn advance_projection_rebuild( } } -#[cfg(test)] -pub async fn rebuild_projection_with_engine( - conn: &Connection, - frontier_sequence: u64, -) -> ProjectionStoreResult { - rebuild_projection_until_cancelled_with_engine(conn, frontier_sequence, &NEVER_CANCELLED).await -} - -#[cfg(test)] -async fn rebuild_projection_until_cancelled_with_engine( - conn: &Connection, - frontier_sequence: u64, - cancelled: &AtomicBool, -) -> ProjectionStoreResult { - prepare_projection_rebuild_with_engine(conn, frontier_sequence).await?; - for _ in 0..REBUILD_MAX_STEPS_PER_INVOCATION { - if cancelled.load(Ordering::Acquire) { - break; - } - match advance_projection_rebuild_with_engine(conn, frontier_sequence, cancelled).await? { - RebuildAdvance::Pending => {} - RebuildAdvance::Complete(outcome) => return Ok(outcome), - } - } - projection_rebuild_progress_on(conn).await -} - -#[cfg(test)] -async fn prepare_projection_rebuild_with_engine( - conn: &Connection, - frontier_sequence: u64, -) -> ProjectionStoreResult<()> { - let transaction = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .await - .map_err(|error| storage("begin projection rebuild staging", error))?; - start_or_resume_projection_rebuild_transaction(&transaction, frontier_sequence).await?; - transaction - .commit() - .await - .map_err(|error| storage("commit projection rebuild staging", error)) -} - -#[cfg(test)] -async fn advance_projection_rebuild_with_engine( - conn: &Connection, - frontier_sequence: u64, - cancelled: &AtomicBool, -) -> ProjectionStoreResult { - let job = read_rebuild_job(conn).await?; - match job.state { - RebuildState::Aliasing => { - stage_projection_alias_batch_with_engine(conn).await?; - Ok(RebuildAdvance::Pending) - } - RebuildState::Building => { - stage_projection_rebuild_batch_with_engine(conn, cancelled).await?; - Ok(RebuildAdvance::Pending) - } - RebuildState::Ready => activate_projection_rebuild_with_engine(conn, frontier_sequence) - .await - .map(RebuildAdvance::Complete), - } -} - -#[cfg(test)] -async fn stage_projection_alias_batch_with_engine(conn: &Connection) -> ProjectionStoreResult<()> { - let transaction = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .await - .map_err(|error| storage("begin projection alias staging", error))?; - stage_projection_alias_batch_transaction(&transaction).await?; - transaction - .commit() - .await - .map_err(|error| storage("commit projection alias batch", error)) -} - -#[cfg(test)] -async fn stage_projection_rebuild_batch_with_engine( - conn: &Connection, - cancelled: &AtomicBool, -) -> ProjectionStoreResult<()> { - let transaction = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .await - .map_err(|error| storage("begin projection rebuild batch", error))?; - let outcome = stage_projection_rebuild_batch_transaction(&transaction, cancelled).await?; - let commit_operation = match outcome { - RebuildBatchStage::AlreadyReady => "commit completed projection rebuild batch", - RebuildBatchStage::Advanced => "commit projection rebuild batch", - }; - transaction - .commit() - .await - .map_err(|error| storage(commit_operation, error))?; - Ok(()) -} - -#[cfg(test)] -async fn activate_projection_rebuild_with_engine( - conn: &Connection, - frontier_sequence: u64, -) -> ProjectionStoreResult { - let transaction = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .await - .map_err(|error| storage("begin projection rebuild activation", error))?; - let outcome = activate_projection_rebuild_transaction(&transaction, frontier_sequence).await?; - transaction - .commit() - .await - .map_err(|error| storage("commit projection rebuild activation", error))?; - Ok(outcome) -} - async fn project_observation_in_transaction( transaction: &impl Executor, observation_id: &CanonicalObservationIdV1, diff --git a/crates/tracedecay-global-db/src/observation_projection/source_transition.rs b/crates/tracedecay-global-db/src/observation_projection/source_transition.rs index e3b37b83fb..4c6a3177ea 100644 --- a/crates/tracedecay-global-db/src/observation_projection/source_transition.rs +++ b/crates/tracedecay-global-db/src/observation_projection/source_transition.rs @@ -76,7 +76,8 @@ pub(crate) async fn verify_native_source_supersession( verify_workflow_effects(conn, effect.workflow_facts()).await?; } let (old_anchor, new_anchor) = read_transition_anchors(conn, predecessor, &successor).await?; - let owner = serde_json::to_string(old_anchor.owner()) + let owner = old_anchor + .owner_column_json() .map_err(|error| storage("encode native source owner", error))?; let mut disposition = conn .query( @@ -444,7 +445,8 @@ async fn promote_native_source_anchor( successor: &DurableObservationV1, ) -> ProjectionStoreResult<()> { let (old, new) = read_transition_anchors(conn, predecessor, successor).await?; - let owner = serde_json::to_string(old.owner()) + let owner = old + .owner_column_json() .map_err(|error| storage("encode native source anchor owner", error))?; let disposition = RetrievalAnchorDispositionRecordV1::new( format!( diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 04e38b4b13..9843cb0725 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -12,6 +12,9 @@ use tracedecay_lcm::LcmStorageKind; use tracedecay_lcm::retrieval_content::{derived_text_for_index, projected_content_hash}; use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, Row, params}; use tracedecay_sessions::runtime::shared::durable_project_path_key; +use tracedecay_sessions::runtime::store_access::{ + message_record_from_row, session_record_from_row, +}; use super::apply::{derive_projection_with_alias, verify_provenance}; @@ -277,31 +280,9 @@ pub(super) async fn read_session( else { return Ok(None); }; - macro_rules! cell { - ($index:literal) => { - row.get($index) - .map_err(|error| storage("decode projected session", error))? - }; - ($index:literal, $ty:ty) => { - row.get::<$ty>($index) - .map_err(|error| storage("decode projected session", error))? - }; - } - Ok(Some(SessionRecord { - provider: cell!(0), - session_id: cell!(1), - project_key: cell!(2), - project_path: cell!(3), - title: cell!(4), - started_at: cell!(5), - ended_at: cell!(6), - transcript_path: cell!(7), - metadata_json: cell!(8), - parent_session_id: cell!(9), - is_subagent: cell!(10, i64) != 0, - agent_id: cell!(11), - parent_tool_use_id: cell!(12), - })) + session_record_from_row(&row) + .map(Some) + .map_err(|error| storage("decode projected session", error.source)) } pub(super) async fn read_message( @@ -325,27 +306,9 @@ pub(super) async fn read_message( else { return Ok(None); }; - macro_rules! cell { - ($index:literal) => { - row.get($index) - .map_err(|error| storage("decode projected message", error))? - }; - } - Ok(Some(SessionMessageRecord { - provider: cell!(0), - message_id: cell!(1), - session_id: cell!(2), - role: cell!(3), - timestamp: cell!(4), - ordinal: cell!(5), - text: cell!(6), - kind: cell!(7), - model: cell!(8), - tool_names: cell!(9), - source_path: cell!(10), - source_offset: cell!(11), - metadata_json: cell!(12), - })) + message_record_from_row(&row, 0) + .map(Some) + .map_err(|error| storage("decode projected message", error.source)) } fn output_owner_lookup_sql(select_expr: &str, ordering: &str) -> String { @@ -893,27 +856,8 @@ pub(in super::super) async fn read_projection_rows_batch( .await .map_err(|error| storage("read projected messages", error))? { - macro_rules! cell { - ($index:literal) => { - row.get($index) - .map_err(|error| storage("decode projected messages", error))? - }; - } - let message = SessionMessageRecord { - provider: cell!(0), - message_id: cell!(1), - session_id: cell!(2), - role: cell!(3), - timestamp: cell!(4), - ordinal: cell!(5), - text: cell!(6), - kind: cell!(7), - model: cell!(8), - tool_names: cell!(9), - source_path: cell!(10), - source_offset: cell!(11), - metadata_json: cell!(12), - }; + let message = message_record_from_row(&row, 0) + .map_err(|error| storage("decode projected messages", error.source))?; messages.insert( (message.provider.clone(), message.message_id.clone()), message, @@ -1006,31 +950,8 @@ pub(in super::super) async fn read_projection_rows_batch( .await .map_err(|error| storage("read projected sessions", error))? { - macro_rules! cell { - ($index:literal) => { - row.get($index) - .map_err(|error| storage("decode projected sessions", error))? - }; - ($index:literal, $ty:ty) => { - row.get::<$ty>($index) - .map_err(|error| storage("decode projected sessions", error))? - }; - } - let session = SessionRecord { - provider: cell!(0), - session_id: cell!(1), - project_key: cell!(2), - project_path: cell!(3), - title: cell!(4), - started_at: cell!(5), - ended_at: cell!(6), - transcript_path: cell!(7), - metadata_json: cell!(8), - parent_session_id: cell!(9), - is_subagent: cell!(10, i64) != 0, - agent_id: cell!(11), - parent_tool_use_id: cell!(12), - }; + let session = session_record_from_row(&row) + .map_err(|error| storage("decode projected sessions", error.source))?; sessions.insert( (session.provider.clone(), session.session_id.clone()), session, diff --git a/crates/tracedecay-global-db/src/profile_registry_maintenance.rs b/crates/tracedecay-global-db/src/profile_registry_maintenance.rs index 1d7a4cf163..fcf85b277b 100644 --- a/crates/tracedecay-global-db/src/profile_registry_maintenance.rs +++ b/crates/tracedecay-global-db/src/profile_registry_maintenance.rs @@ -10,8 +10,6 @@ use std::path::{Component, Path, PathBuf}; use crate::{ ProjectRegistryContext, RegisteredGlobalDb, RegisteredGlobalDbLeaseV1, registry_maintenance::ForgetRegistryProjectRows, registry_maintenance::RegistryGcReport, - registry_maintenance::RegistryOrphanRelinkApplyReport, - registry_maintenance::RegistryOrphanRelinkReport, registry_maintenance::forget_registry_project, }; @@ -246,18 +244,6 @@ impl ProfileRegistryMaintenanceRuntime { }) } - #[hotpath::measure(label = "daemon.profile_registry.apply_orphan_relink", future = true)] - pub async fn apply_orphan_relink( - &self, - report: &RegistryOrphanRelinkReport, - ) -> std::result::Result> { - crate::registry_maintenance::apply_registry_orphan_relink_report( - self.profile_database.as_ref(), - report, - ) - .await - } - #[hotpath::measure(label = "daemon.profile_registry.gc", future = true)] pub async fn registry_gc( &self, diff --git a/crates/tracedecay-global-db/src/registered_lcm.rs b/crates/tracedecay-global-db/src/registered_lcm.rs index d19382697a..982d5484cf 100644 --- a/crates/tracedecay-global-db/src/registered_lcm.rs +++ b/crates/tracedecay-global-db/src/registered_lcm.rs @@ -497,35 +497,6 @@ impl RegisteredGlobalDb { Ok((work, has_more)) } - #[hotpath::skip] - pub async fn lcm_payload_health_detail( - &self, - storage_root: &Path, - provider: &str, - session_id: Option<&str>, - deep: bool, - sample_limit: usize, - cfg: &LcmGcConfig, - ) -> Result { - SessionStoreAccess::new(self) - .lcm_payload_health_detail(storage_root, provider, session_id, deep, sample_limit, cfg) - .await - } - - #[hotpath::skip] - pub async fn lcm_preview_payload_gc( - &self, - storage_root: &Path, - provider: &str, - session_id: Option<&str>, - cfg: &LcmGcConfig, - now: i64, - ) -> Result { - SessionStoreAccess::new(self) - .lcm_preview_payload_gc(storage_root, provider, session_id, cfg, now) - .await - } - #[hotpath::skip] pub async fn lcm_run_payload_gc_apply( &self, diff --git a/crates/tracedecay-global-db/src/registry_maintenance.rs b/crates/tracedecay-global-db/src/registry_maintenance.rs index 360e007303..a0665d2685 100644 --- a/crates/tracedecay-global-db/src/registry_maintenance.rs +++ b/crates/tracedecay-global-db/src/registry_maintenance.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -9,7 +9,6 @@ use crate::{ RegisteredGlobalDbWriteTransaction, StoreArtifactUpsert, StoreInstanceUpsert, }; use tracedecay_runtime_core::branch_meta; -use tracedecay_runtime_core::db::engine::{Executor, IntoParams, QueryExecutor, params}; use tracedecay_runtime_core::storage::{ STORE_MANIFEST_FILENAME, STORE_MANIFEST_SCHEMA_VERSION, StorageMode, StoreKind, read_legacy_enrollment_marker, read_repository_identity_marker, read_store_manifest, @@ -56,15 +55,6 @@ pub struct RegistryOrphanRelinkReport { pub issues: Vec, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] -pub struct RegistryOrphanRelinkApplyReport { - pub projects: usize, - pub aliases: usize, - pub stores: usize, - pub graph_scopes: usize, - pub artifacts: usize, -} - /// Canonical read-only plan returned by the daemon and consumed by the /// `registry-gc` CLI. Apply fills only the deletion counters after executing /// the same plan under the active database mutation authority. @@ -95,320 +85,11 @@ impl RegistryGcReport { } } -fn encode_registry_identity( - value: &T, - label: impl std::fmt::Display, -) -> std::result::Result { - serde_json::to_string(value).map_err(|error| format!("could not encode {label}: {error}")) -} - -#[hotpath::measure(future = true, label = "global_db.registry_maintenance.persist")] -pub async fn apply_registry_orphan_relink_report( - db: &RegisteredGlobalDb, - report: &RegistryOrphanRelinkReport, -) -> std::result::Result> { - let transaction = db.begin_write_transaction().await.map_err(|error| { - vec![format!( - "could not start atomic registry orphan relink: {error}" - )] - })?; - let issues = preflight_registry_orphan_relink(&transaction, report).await; - if !issues.is_empty() { - return Err(issues); - } - let applied = apply_registry_orphan_relink_rows(&transaction, report) - .await - .map_err(|issue| vec![issue])?; - transaction.commit().await.map_err(|error| { - vec![format!( - "could not commit atomic registry orphan relink: {error}" - )] - })?; - Ok(applied) -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests; -pub async fn apply_single_registry_orphan_relink_report( - db: &RegisteredGlobalDb, - report: &RegistryOrphanRelinkReport, -) -> std::result::Result> { - let [plan] = report.plans.as_slice() else { - return Err(vec![format!( - "migration cutover requires exactly one registry orphan relink plan, found {}", - report.plans.len() - )]); - }; - if plan.status != RegistryOrphanRelinkStatus::Eligible { - return Err(vec![format!( - "migration cutover registry orphan relink plan for '{}' is {:?}: {}", - plan.project.project_id, - plan.status, - plan.status_reason.as_deref().unwrap_or("not eligible") - )]); - } - apply_registry_orphan_relink_report(db, report).await -} - -async fn preflight_registry_orphan_relink( - conn: &Q, - report: &RegistryOrphanRelinkReport, -) -> Vec -where - Q: QueryExecutor + ?Sized, -{ - let mut issues = report.issues.clone(); - let mut project_roots = BTreeMap::::new(); - let mut aliases = BTreeMap::::new(); - let mut stores = BTreeMap::::new(); - let mut store_paths = BTreeMap::::new(); - let mut scopes = BTreeMap::::new(); - let mut scope_paths = BTreeMap::::new(); - - for plan in &report.plans { - match plan.status { - RegistryOrphanRelinkStatus::Eligible => {} - RegistryOrphanRelinkStatus::Stale | RegistryOrphanRelinkStatus::Retired => { - continue; - } - RegistryOrphanRelinkStatus::Blocked => { - issues.push(format!( - "{} reconstruction plan for '{}' is blocked: {}", - plan.manifest_path.display(), - plan.project.project_id, - plan.status_reason.as_deref().unwrap_or("not eligible") - )); - continue; - } - } - let project = &plan.project; - let root = RegisteredGlobalDb::canonical_project_key(&project.project_root); - let root_alias = RegisteredGlobalDb::project_path_alias_key(&project.project_root); - record_batch_owner( - &mut project_roots, - &root_alias, - &project.project_id, - "canonical project root", - &mut issues, - ); - match query_optional_text( - conn, - "SELECT canonical_root FROM code_projects WHERE project_id=?1", - params![project.project_id.as_str()], - ) - .await - { - Ok(Some(existing)) if existing != root => issues.push(format!( - "project '{}' already owns canonical root '{}' instead of '{}'", - project.project_id, existing, root - )), - Err(error) => issues.push(error), - _ => {} - } - match query_all_text( - conn, - "SELECT project_id FROM project_aliases WHERE alias_path=?1", - params![root_alias.as_str()], - ) - .await - { - Ok(owners) => { - for owner in owners { - if owner != project.project_id { - issues.push(format!( - "canonical root '{root}' is already owned by project '{owner}'" - )); - } - } - } - Err(error) => issues.push(error), - } - for alias in &project.aliases { - let alias = RegisteredGlobalDb::project_path_alias_key(alias); - record_batch_owner( - &mut aliases, - &alias, - &project.project_id, - "project alias", - &mut issues, - ); - match query_optional_text( - conn, - "SELECT project_id FROM project_aliases WHERE alias_path=?1", - params![alias.as_str()], - ) - .await - { - Ok(Some(owner)) if owner != project.project_id => issues.push(format!( - "alias '{alias}' is already owned by project '{owner}'" - )), - Err(error) => issues.push(error), - _ => {} - } - } - - let store_identity = match encode_registry_identity( - &( - &plan.store.project_id, - &plan.store.store_kind, - &plan.store.storage_mode, - &plan.store.store_relpath, - &plan.store.manifest_relpath, - ), - format!("store '{}'", plan.store.store_id), - ) { - Ok(identity) => identity, - Err(error) => { - issues.push(error); - continue; - } - }; - record_batch_owner( - &mut stores, - &plan.store.store_id, - &store_identity, - "store id", - &mut issues, - ); - match query_optional_text( - conn, - "SELECT json_array(project_id, store_kind, storage_mode, store_relpath, manifest_relpath) - FROM store_instances WHERE store_id=?1", - params![plan.store.store_id.as_str()], - ) - .await - { - Ok(Some(existing)) if existing != store_identity => issues.push(format!( - "store '{}' already has conflicting ownership or location", - plan.store.store_id - )), - Err(error) => issues.push(error), - _ => {} - } - for physical_path in std::iter::once(plan.store.store_relpath.as_str()) - .chain(plan.store.manifest_relpath.as_deref()) - { - record_batch_owner( - &mut store_paths, - physical_path, - &plan.store.store_id, - "physical store path", - &mut issues, - ); - match query_all_text( - conn, - "SELECT store_id FROM store_instances - WHERE store_relpath=?1 OR manifest_relpath=?1", - params![physical_path], - ) - .await - { - Ok(owners) => { - for owner in owners { - if owner != plan.store.store_id { - issues.push(format!( - "physical store path '{physical_path}' is already owned by store '{owner}'" - )); - } - } - } - Err(error) => issues.push(error), - } - } - - for scope in &plan.graph_scopes { - let scope_identity = match encode_registry_identity( - &( - &scope.project_id, - &scope.store_id, - &scope.branch_name, - &scope.db_relpath, - &scope.parent_scope_id, - ), - format!("graph scope '{}'", scope.graph_scope_id), - ) { - Ok(identity) => identity, - Err(error) => { - issues.push(error); - continue; - } - }; - record_batch_owner( - &mut scopes, - &scope.graph_scope_id, - &scope_identity, - "graph scope id", - &mut issues, - ); - match query_optional_text( - conn, - "SELECT json_array(project_id, store_id, branch_name, db_relpath, parent_scope_id) - FROM graph_scopes WHERE graph_scope_id=?1", - params![scope.graph_scope_id.as_str()], - ) - .await - { - Ok(Some(existing)) - if existing != scope_identity - && !graph_scope_location_drift_is_repairable(&existing, scope) => - { - issues.push(format!( - "graph scope '{}' already has conflicting ownership", - scope.graph_scope_id - )); - } - Err(error) => issues.push(error), - _ => {} - } - record_batch_owner( - &mut scope_paths, - &scope.db_relpath, - &scope.graph_scope_id, - "physical graph database path", - &mut issues, - ); - match query_all_text( - conn, - "SELECT graph_scope_id FROM graph_scopes WHERE db_relpath=?1", - params![scope.db_relpath.as_str()], - ) - .await - { - Ok(owners) => { - for owner in owners { - if owner != scope.graph_scope_id { - issues.push(format!( - "physical graph database path '{}' is already owned by scope '{}'", - scope.db_relpath, owner - )); - } - } - } - Err(error) => issues.push(error), - } - } - } - issues -} - -fn record_batch_owner( - owners: &mut BTreeMap, - key: &str, - owner: &str, - label: &str, - issues: &mut Vec, -) { - if let Some(existing) = owners.insert(key.to_string(), owner.to_string()) - && existing != owner - { - issues.push(format!( - "{label} '{key}' has conflicting batch owners '{existing}' and '{owner}'" - )); - } -} - +#[cfg(test)] fn graph_scope_location_drift_is_repairable(existing: &str, expected: &GraphScopeUpsert) -> bool { serde_json::from_str::<(String, String, String, String, Option)>(existing).is_ok_and( |(project_id, store_id, branch_name, _, _)| { @@ -418,180 +99,3 @@ fn graph_scope_location_drift_is_repairable(existing: &str, expected: &GraphScop }, ) } - -async fn query_optional_text( - conn: &Q, - sql: &str, - params: P, -) -> std::result::Result, String> -where - Q: QueryExecutor + ?Sized, - P: IntoParams, -{ - let mut rows = conn - .query(sql, params) - .await - .map_err(|error| format!("registry orphan relink preflight query failed: {error}"))?; - rows.next() - .await - .map_err(|error| format!("registry orphan relink preflight row failed: {error}"))? - .map(|row| { - row.get::(0) - .map_err(|error| format!("registry orphan relink preflight value failed: {error}")) - }) - .transpose() -} - -async fn query_all_text( - conn: &Q, - sql: &str, - params: P, -) -> std::result::Result, String> -where - Q: QueryExecutor + ?Sized, - P: IntoParams, -{ - let mut rows = conn - .query(sql, params) - .await - .map_err(|error| format!("registry orphan relink preflight query failed: {error}"))?; - let mut values = Vec::new(); - while let Some(row) = rows - .next() - .await - .map_err(|error| format!("registry orphan relink preflight row failed: {error}"))? - { - values.push( - row.get::(0).map_err(|error| { - format!("registry orphan relink preflight value failed: {error}") - })?, - ); - } - Ok(values) -} - -async fn apply_registry_orphan_relink_rows( - conn: &E, - report: &RegistryOrphanRelinkReport, -) -> std::result::Result -where - E: Executor + ?Sized, -{ - let mut applied = RegistryOrphanRelinkApplyReport::default(); - let now = tracedecay_runtime_core::tracedecay::current_timestamp(); - for plan in &report.plans { - if plan.status != RegistryOrphanRelinkStatus::Eligible { - continue; - } - let project = &plan.project; - let canonical_root = RegisteredGlobalDb::canonical_project_key(&project.project_root); - applied.projects += usize::try_from( - conn.execute( - "INSERT OR IGNORE INTO code_projects( - project_id, canonical_root, display_root, git_common_dir, git_remote_url, - default_branch, created_at, last_seen_at - ) VALUES(?1, ?2, ?3, NULL, NULL, ?4, ?5, ?5)", - params![ - project.project_id.as_str(), - canonical_root, - project.project_root.to_string_lossy().to_string(), - project.default_branch.as_deref(), - now, - ], - ) - .await - .map_err(|error| format!("failed to insert code project: {error}"))?, - ) - .unwrap_or(usize::MAX); - for alias in &project.aliases { - applied.aliases += usize::try_from( - conn.execute( - "INSERT OR IGNORE INTO project_aliases(alias_path, project_id, last_seen_at) - VALUES(?1, ?2, ?3)", - params![ - RegisteredGlobalDb::project_path_alias_key(alias), - project.project_id.as_str(), - now, - ], - ) - .await - .map_err(|error| format!("failed to insert project alias: {error}"))?, - ) - .unwrap_or(usize::MAX); - } - applied.stores += usize::try_from( - conn.execute( - "INSERT OR IGNORE INTO store_instances( - store_id, project_id, store_kind, storage_mode, store_relpath, - manifest_relpath, created_at, last_verified_at, last_write_at - ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - plan.store.store_id.as_str(), - plan.store.project_id.as_str(), - plan.store.store_kind.as_str(), - plan.store.storage_mode.as_str(), - plan.store.store_relpath.as_str(), - plan.store.manifest_relpath.as_deref(), - now, - plan.store.last_verified_at, - plan.store.last_write_at, - ], - ) - .await - .map_err(|error| format!("failed to insert store instance: {error}"))?, - ) - .unwrap_or(usize::MAX); - for scope in &plan.graph_scopes { - applied.graph_scopes += usize::try_from( - conn.execute( - "INSERT INTO graph_scopes( - graph_scope_id, project_id, store_id, branch_name, db_relpath, - parent_scope_id, last_synced_at, writable - ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(graph_scope_id) DO UPDATE SET - project_id = excluded.project_id, - store_id = excluded.store_id, - branch_name = excluded.branch_name, - db_relpath = excluded.db_relpath, - parent_scope_id = excluded.parent_scope_id, - last_synced_at = excluded.last_synced_at, - writable = excluded.writable", - params![ - scope.graph_scope_id.as_str(), - scope.project_id.as_str(), - scope.store_id.as_str(), - scope.branch_name.as_str(), - scope.db_relpath.as_str(), - scope.parent_scope_id.as_deref(), - scope.last_synced_at, - i64::from(scope.writable), - ], - ) - .await - .map_err(|error| format!("failed to insert graph scope: {error}"))?, - ) - .unwrap_or(usize::MAX); - } - for artifact in &plan.artifacts { - applied.artifacts += usize::try_from( - conn.execute( - "INSERT OR IGNORE INTO store_artifacts( - store_id, artifact_kind, relpath, size_bytes, schema_version, updated_at - ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)", - params![ - artifact.store_id.as_str(), - artifact.artifact_kind.as_str(), - artifact.relpath.as_str(), - artifact.size_bytes, - artifact.schema_version.as_deref(), - artifact.updated_at, - ], - ) - .await - .map_err(|error| format!("failed to insert store artifact: {error}"))?, - ) - .unwrap_or(usize::MAX); - } - } - Ok(applied) -} diff --git a/crates/tracedecay-global-db/src/registry_maintenance/orphan.rs b/crates/tracedecay-global-db/src/registry_maintenance/orphan.rs index 7ac78ac5b4..82fef14cab 100644 --- a/crates/tracedecay-global-db/src/registry_maintenance/orphan.rs +++ b/crates/tracedecay-global-db/src/registry_maintenance/orphan.rs @@ -165,8 +165,8 @@ fn inspect_registry_orphan_manifest_inner( project_id, store_kind: "code_project".to_string(), storage_mode: "profile_sharded".to_string(), - store_relpath: path_string(&store_relpath), - manifest_relpath: Some(path_string(&manifest_relpath)), + store_relpath: tracedecay_domain::forward_slash_path(&store_relpath), + manifest_relpath: Some(tracedecay_domain::forward_slash_path(&manifest_relpath)), last_verified_at: Some(verified_at), last_write_at: None, }, @@ -392,7 +392,7 @@ fn reconstruct_graph_scopes( project_id: project_id.to_string(), store_id: store_id.to_string(), branch_name: branch_name.clone(), - db_relpath: path_string(&profile_db_relpath), + db_relpath: tracedecay_domain::forward_slash_path(&profile_db_relpath), parent_scope_id: entry .parent .as_ref() @@ -427,7 +427,7 @@ fn push_artifact_if_present( artifacts.push(StoreArtifactUpsert { store_id: store_id.to_string(), artifact_kind: artifact_kind.to_string(), - relpath: path_string(&relpath), + relpath: tracedecay_domain::forward_slash_path(&relpath), size_bytes: i64::try_from(meta.len()).ok(), schema_version, updated_at: Some(updated_at), @@ -447,7 +447,3 @@ fn is_safe_relpath(path: &Path) -> bool { .components() .all(|component| matches!(component, Component::Normal(_))) } - -fn path_string(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") -} diff --git a/crates/tracedecay-global-db/src/schema_contract/validation.rs b/crates/tracedecay-global-db/src/schema_contract/validation.rs index f3d8f0b4cf..a018ce1dad 100644 --- a/crates/tracedecay-global-db/src/schema_contract/validation.rs +++ b/crates/tracedecay-global-db/src/schema_contract/validation.rs @@ -215,43 +215,43 @@ fn index_matches(actual: &ActualIndex, expected: &Index) -> bool { || name.eq_ignore_ascii_case("idx_session_refresh_operations_one_running") || name.eq_ignore_ascii_case("idx_observations_valid_session_sequence") }); - expected + if expected .name - .is_none_or(|name| actual.name.eq_ignore_ascii_case(name)) - && actual.unique == expected.unique - && actual.origin.eq_ignore_ascii_case(expected.origin) - && actual.partial == expected_partial - && actual.columns.len() == expected.columns.len() - && actual - .columns + .is_some_and(|name| !actual.name.eq_ignore_ascii_case(name)) + || actual.unique != expected.unique + || !actual.origin.eq_ignore_ascii_case(expected.origin) + || actual.partial != expected_partial + || actual.columns.len() != expected.columns.len() + { + return false; + } + let descending = expected.name.and_then(|name| { + INDEX_DESCENDING_COLUMNS .iter() - .zip(expected.columns) - .all(|(actual, expected_column)| { - if expected_column.eq_ignore_ascii_case(INDEX_EXPRESSION_COLUMN) { - actual.cid == -2 && actual.name.is_none() - } else { - actual.cid >= 0 - && actual.descending - == expected - .name - .and_then(|name| { - INDEX_DESCENDING_COLUMNS - .iter() - .find(|(index, _)| index.eq_ignore_ascii_case(name)) - .map(|(_, columns)| *columns) - }) - .is_some_and(|columns| { - columns - .iter() - .any(|column| column.eq_ignore_ascii_case(expected_column)) - }) - && actual.collation.eq_ignore_ascii_case("BINARY") - && actual - .name - .as_deref() - .is_some_and(|name| name.eq_ignore_ascii_case(expected_column)) - } - }) + .find(|(index, _)| index.eq_ignore_ascii_case(name)) + .map(|(_, columns)| *columns) + }); + actual + .columns + .iter() + .zip(expected.columns) + .all(|(actual, expected_column)| { + if expected_column.eq_ignore_ascii_case(INDEX_EXPRESSION_COLUMN) { + return actual.cid == -2 && actual.name.is_none(); + } + actual.cid >= 0 + && actual.descending + == descending.is_some_and(|columns| { + columns + .iter() + .any(|column| column.eq_ignore_ascii_case(expected_column)) + }) + && actual.collation.eq_ignore_ascii_case("BINARY") + && actual + .name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(expected_column)) + }) } fn primary_key_index_columns(contract: &Table) -> Option> { diff --git a/crates/tracedecay-global-db/src/sqlite_persist.rs b/crates/tracedecay-global-db/src/sqlite_persist.rs index e20beb065d..3281e2aa1b 100644 --- a/crates/tracedecay-global-db/src/sqlite_persist.rs +++ b/crates/tracedecay-global-db/src/sqlite_persist.rs @@ -11,6 +11,11 @@ use std::future::Future; use tracedecay_runtime_core::db::engine::{self, Transaction}; +pub(crate) use tracedecay_runtime_core::db::{ + decode_stored_json as decode_json, encode_stored_json as encode_json, + optional_text_column as row_optional_text, text_column as row_text, +}; + use crate::RegisteredGlobalDbWriteTransaction; /// Write transaction that can commit or roll back through the engine. diff --git a/crates/tracedecay-global-db/src/stack_delivery.rs b/crates/tracedecay-global-db/src/stack_delivery.rs index ffedfbeb15..1e0df42ad0 100644 --- a/crates/tracedecay-global-db/src/stack_delivery.rs +++ b/crates/tracedecay-global-db/src/stack_delivery.rs @@ -118,11 +118,6 @@ pub enum GitHubStackSignalAppendOutcomeV1 { } impl GitHubStackSignalAppendOutcomeV1 { - #[hotpath::skip] - pub const fn is_saturated(&self) -> bool { - matches!(self, Self::Saturated { .. }) - } - #[hotpath::skip] pub const fn pending_count(&self) -> usize { match self { diff --git a/crates/tracedecay-global-db/src/transcript.rs b/crates/tracedecay-global-db/src/transcript.rs index 811eb42268..53e1b6f633 100644 --- a/crates/tracedecay-global-db/src/transcript.rs +++ b/crates/tracedecay-global-db/src/transcript.rs @@ -1,4 +1,4 @@ -use super::{ParseOffset, RegisteredGlobalDb, TranscriptBatch}; +use super::{ParseOffset, RegisteredGlobalDb}; use tracedecay_sessions::runtime::{ SessionMessageRecord, SessionRecord, SessionStoreAccess, TranscriptGitEvidence, TranscriptPersistenceError, @@ -102,21 +102,6 @@ impl RegisteredGlobalDb { .await } - #[hotpath::measure( - future = true, - label = "global_db.transcript.upsert_projection_batches" - )] - pub async fn upsert_transcript_projection_batches( - &self, - batches: &[TranscriptBatch], - parse_offset_path: &str, - parse_offset: ParseOffset, - ) -> Result<(), String> { - SessionStoreAccess::new(self) - .upsert_transcript_projection_batches(batches, parse_offset_path, parse_offset) - .await - } - #[hotpath::measure(future = true, label = "global_db.transcript.get_parse_offset")] pub async fn get_parse_offset(&self, path: &str) -> Option { SessionStoreAccess::new(self).get_parse_offset(path).await diff --git a/crates/tracedecay-global-db/tests/session_relation_graph.rs b/crates/tracedecay-global-db/tests/session_relation_graph.rs index f342b7347a..12121c9935 100644 --- a/crates/tracedecay-global-db/tests/session_relation_graph.rs +++ b/crates/tracedecay-global-db/tests/session_relation_graph.rs @@ -35,13 +35,7 @@ impl GraphCancellation for TestCancellation { } } -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid identity") -} +use tracedecay_domain::test_fixtures::id; fn occurrence_id(seed: &str) -> MessageOccurrenceIdV1 { let digest = Sha256::digest(seed.as_bytes()); diff --git a/crates/tracedecay-graph-db/Cargo.toml b/crates/tracedecay-graph-db/Cargo.toml index 2ef568a829..5505c4c908 100644 --- a/crates/tracedecay-graph-db/Cargo.toml +++ b/crates/tracedecay-graph-db/Cargo.toml @@ -83,7 +83,7 @@ tracedecay-store = { path = "../tracedecay-store", version = "0.1.0" } tracing = "0.1" [dev-dependencies] -criterion = { version = "0.5", features = ["html_reports"] } +criterion = "0.5" rusqlite = { version = "0.40.1", default-features = false, features = ["backup"] } tempfile = "3" tracedecay-rusqlite-runtime = { path = "../tracedecay-rusqlite-runtime", version = "0.1.0" } diff --git a/crates/tracedecay-graph-db/src/error.rs b/crates/tracedecay-graph-db/src/error.rs index 3911c4ef56..2dead63e82 100644 --- a/crates/tracedecay-graph-db/src/error.rs +++ b/crates/tracedecay-graph-db/src/error.rs @@ -145,6 +145,56 @@ pub enum GraphDbError { Closed, } +/// Store failure folded into a projection error that does not carry graph-db +/// context fields. Callers that keep richer variants map [`GraphDbError`] +/// themselves. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GraphStoreFailureClass { + Cancelled, + BudgetExhausted, + Contract(String), + Corrupt(String), + Unavailable(String), +} + +/// Classify a store error the way git topology and workflow topology do. +/// +/// `conflict_unavailable` is the caller-specific conflict message. Every other +/// arm is identical for those projections, including the closed-store wording. +#[must_use] +pub fn classify_graph_store_error( + error: GraphDbError, + conflict_unavailable: &str, +) -> GraphStoreFailureClass { + match error { + GraphDbError::Cancelled => GraphStoreFailureClass::Cancelled, + GraphDbError::BudgetExhausted { .. } | GraphDbError::DeadlineExceeded => { + GraphStoreFailureClass::BudgetExhausted + } + GraphDbError::InvalidRequest { message } => GraphStoreFailureClass::Contract(message), + GraphDbError::Corrupt { message } + | GraphDbError::ResetRequired { message } + | GraphDbError::DurabilityUncertain { message } + | GraphDbError::ProjectionMismatch { message, .. } + | GraphDbError::GenerationMismatch { message, .. } => { + GraphStoreFailureClass::Corrupt(message) + } + GraphDbError::Conflict { .. } => { + GraphStoreFailureClass::Unavailable(conflict_unavailable.to_owned()) + } + GraphDbError::Unavailable { message } | GraphDbError::SealedStoreImmutable { message } => { + GraphStoreFailureClass::Unavailable(message) + } + error @ (GraphDbError::SourceCommitmentsUnavailable { .. } + | GraphDbError::SealedRevisionIncompatible { .. }) => { + GraphStoreFailureClass::Unavailable(error.to_string()) + } + GraphDbError::Closed => { + GraphStoreFailureClass::Unavailable("graph store is closed".to_owned()) + } + } +} + impl From for GraphDbError { fn from(error: GraphPublicationStoreErrorV1) -> Self { match error { @@ -232,7 +282,10 @@ pub(crate) fn rollback_failure( #[cfg(test)] mod tests { - use super::{GraphBudgetKind, GraphDbError, rollback_failure}; + use super::{ + GraphBudgetKind, GraphDbError, GraphStoreFailureClass, classify_graph_store_error, + rollback_failure, + }; #[test] fn budget_kind_from_name_round_trips_and_rejects_unnamed() { @@ -256,6 +309,25 @@ mod tests { assert_eq!(GraphBudgetKind::from_name("unnamed"), None); } + #[test] + fn store_failure_classification_keeps_shared_verdicts() { + assert_eq!( + classify_graph_store_error(GraphDbError::Cancelled, "unused"), + GraphStoreFailureClass::Cancelled + ); + assert_eq!( + classify_graph_store_error(GraphDbError::Closed, "unused"), + GraphStoreFailureClass::Unavailable("graph store is closed".to_owned()) + ); + assert_eq!( + classify_graph_store_error( + GraphDbError::conflict("site"), + "Git topology publication conflict", + ), + GraphStoreFailureClass::Unavailable("Git topology publication conflict".to_owned()) + ); + } + #[test] fn rollback_failure_preserves_both_errors_and_context() { assert_eq!( diff --git a/crates/tracedecay-graph-db/src/generation.rs b/crates/tracedecay-graph-db/src/generation.rs index d35a9affee..7771911523 100644 --- a/crates/tracedecay-graph-db/src/generation.rs +++ b/crates/tracedecay-graph-db/src/generation.rs @@ -389,9 +389,24 @@ impl GraphGenerationManifest { MAX_VERIFIED_GENERATION_RELATIONS, )); } - let dependencies = checked_sorted_dependencies(dependencies, check)?; - let entities = checked_sorted_entities(entities, check)?; - let relations = checked_sorted_relations(relations, check)?; + let dependencies = checked_sorted_by( + dependencies, + check, + Ord::cmp, + "a graph generation repeats a dependency", + )?; + let entities = checked_sorted_by( + entities, + check, + |left, right| left.identity.cmp(&right.identity), + "a graph generation repeats an entity identity", + )?; + let relations = checked_sorted_by( + relations, + check, + |left, right| left.identity.cmp(&right.identity), + "a graph generation repeats a relation identity", + )?; let manifest = Self { projection, generation, @@ -777,58 +792,22 @@ impl GraphGenerationManifest { } } -fn checked_sorted_dependencies( - mut dependencies: Vec, +fn checked_sorted_by( + mut items: Vec, check: &dyn Fn() -> Result<(), GraphDbError>, -) -> Result, GraphDbError> { + mut order: impl FnMut(&T, &T) -> std::cmp::Ordering, + duplicate: &'static str, +) -> Result, GraphDbError> { check()?; - dependencies.sort_unstable(); - for pair in dependencies.windows(2) { + items.sort_unstable_by(&mut order); + for pair in items.windows(2) { check()?; - if pair[0] == pair[1] { - return Err(GraphDbError::invalid( - "a graph generation repeats a dependency", - )); + if order(&pair[0], &pair[1]).is_eq() { + return Err(GraphDbError::invalid(duplicate)); } } check()?; - Ok(dependencies) -} - -fn checked_sorted_entities( - mut entities: Vec, - check: &dyn Fn() -> Result<(), GraphDbError>, -) -> Result, GraphDbError> { - check()?; - entities.sort_unstable_by(|left, right| left.identity.cmp(&right.identity)); - for pair in entities.windows(2) { - check()?; - if pair[0].identity == pair[1].identity { - return Err(GraphDbError::invalid( - "a graph generation repeats an entity identity", - )); - } - } - check()?; - Ok(entities) -} - -fn checked_sorted_relations( - mut relations: Vec, - check: &dyn Fn() -> Result<(), GraphDbError>, -) -> Result, GraphDbError> { - check()?; - relations.sort_unstable_by(|left, right| left.identity.cmp(&right.identity)); - for pair in relations.windows(2) { - check()?; - if pair[0].identity == pair[1].identity { - return Err(GraphDbError::invalid( - "a graph generation repeats a relation identity", - )); - } - } - check()?; - Ok(relations) + Ok(items) } /// The dependency-closure digest, shared by the full manifest and its @@ -2041,7 +2020,7 @@ mod checked_vec_writer_tests { use super::{ CheckedVecWriter, GraphDbError, GraphGenerationManifest, ManifestDigestChunk, ManifestDigestChunkEncoding, ManifestDigestPipelineConfig, ManifestDigestPipelineMetrics, - canonical_buffer_allocation_growths, checked_canonical_bytes, checked_sorted_entities, + canonical_buffer_allocation_growths, checked_canonical_bytes, checked_sorted_by, encode_manifest_digest_chunk, frame_length_headers, recovered_generation_digest, recovered_generation_digest_with_config, reset_canonical_buffer_allocation_growths, }; @@ -2263,7 +2242,13 @@ mod checked_vec_writer_tests { entities.shrink_to_fit(); let allocation = entities.as_ptr(); - let sorted = checked_sorted_entities(entities, &|| Ok(())).unwrap(); + let sorted = checked_sorted_by( + entities, + &|| Ok(()), + |left, right| left.identity.cmp(&right.identity), + "a graph generation repeats an entity identity", + ) + .unwrap(); assert_eq!( sorted.as_ptr(), diff --git a/crates/tracedecay-graph-db/src/lease.rs b/crates/tracedecay-graph-db/src/lease.rs index 735fc06778..12cac0505b 100644 --- a/crates/tracedecay-graph-db/src/lease.rs +++ b/crates/tracedecay-graph-db/src/lease.rs @@ -421,16 +421,14 @@ impl VerifiedGraphSnapshot { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - self.with_operation(|| { - self.with_head_database(|database| { - database.outgoing_relation_ids( - &self.head.locator.physical_namespace()?, - starts, - relation_kinds, - max_relations, - cancellation, - ) - }) + self.head_fanout(|database, namespace| { + database.outgoing_relation_ids( + &namespace, + starts, + relation_kinds, + max_relations, + cancellation, + ) }) } @@ -452,16 +450,14 @@ impl VerifiedGraphSnapshot { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - self.with_operation(|| { - self.with_head_database(|database| { - database.incoming_relation_ids( - &self.head.locator.physical_namespace()?, - starts, - relation_kinds, - max_relations, - cancellation, - ) - }) + self.head_fanout(|database, namespace| { + database.incoming_relation_ids( + &namespace, + starts, + relation_kinds, + max_relations, + cancellation, + ) }) } @@ -477,17 +473,15 @@ impl VerifiedGraphSnapshot { limit: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - self.with_operation(|| { - self.with_head_database(|database| { - database.outgoing_relation_ids_page( - &self.head.locator.physical_namespace()?, - starts, - relation_kinds, - after, - limit, - cancellation, - ) - }) + self.head_fanout(|database, namespace| { + database.outgoing_relation_ids_page( + &namespace, + starts, + relation_kinds, + after, + limit, + cancellation, + ) }) } @@ -503,17 +497,15 @@ impl VerifiedGraphSnapshot { limit: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - self.with_operation(|| { - self.with_head_database(|database| { - database.incoming_relation_ids_page( - &self.head.locator.physical_namespace()?, - starts, - relation_kinds, - after, - limit, - cancellation, - ) - }) + self.head_fanout(|database, namespace| { + database.incoming_relation_ids_page( + &namespace, + starts, + relation_kinds, + after, + limit, + cancellation, + ) }) } @@ -532,16 +524,14 @@ impl VerifiedGraphSnapshot { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - self.with_operation(|| { - self.with_head_database(|database| { - database.outgoing_relations( - &self.head.locator.physical_namespace()?, - starts, - relation_kinds, - max_relations, - cancellation, - ) - }) + self.head_fanout(|database, namespace| { + database.outgoing_relations( + &namespace, + starts, + relation_kinds, + max_relations, + cancellation, + ) }) } @@ -558,16 +548,14 @@ impl VerifiedGraphSnapshot { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - self.with_operation(|| { - self.with_head_database(|database| { - database.outgoing_relations_truncated( - &self.head.locator.physical_namespace()?, - starts, - relation_kinds, - max_relations, - cancellation, - ) - }) + self.head_fanout(|database, namespace| { + database.outgoing_relations_truncated( + &namespace, + starts, + relation_kinds, + max_relations, + cancellation, + ) }) } @@ -582,16 +570,14 @@ impl VerifiedGraphSnapshot { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - self.with_operation(|| { - self.with_head_database(|database| { - database.outgoing_relation_targets( - &self.head.locator.physical_namespace()?, - starts, - relation_kinds, - max_relations, - cancellation, - ) - }) + self.head_fanout(|database, namespace| { + database.outgoing_relation_targets( + &namespace, + starts, + relation_kinds, + max_relations, + cancellation, + ) }) } @@ -602,16 +588,14 @@ impl VerifiedGraphSnapshot { cancellation: Arc, visitor: &mut dyn FnMut(crate::GraphRelationTarget), ) -> Result { - self.with_operation(|| { - self.with_head_database(|database| { - database.visit_outgoing_relation_targets( - &self.head.locator.physical_namespace()?, - start, - relation_kinds, - cancellation, - visitor, - ) - }) + self.head_fanout(|database, namespace| { + database.visit_outgoing_relation_targets( + &namespace, + start, + relation_kinds, + cancellation, + visitor, + ) }) } @@ -627,16 +611,14 @@ impl VerifiedGraphSnapshot { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - self.with_operation(|| { - self.with_head_database(|database| { - database.incoming_relations( - &self.head.locator.physical_namespace()?, - starts, - relation_kinds, - max_relations, - cancellation, - ) - }) + self.head_fanout(|database, namespace| { + database.incoming_relations( + &namespace, + starts, + relation_kinds, + max_relations, + cancellation, + ) }) } @@ -653,16 +635,14 @@ impl VerifiedGraphSnapshot { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - self.with_operation(|| { - self.with_head_database(|database| { - database.incoming_relations_truncated( - &self.head.locator.physical_namespace()?, - starts, - relation_kinds, - max_relations, - cancellation, - ) - }) + self.head_fanout(|database, namespace| { + database.incoming_relations_truncated( + &namespace, + starts, + relation_kinds, + max_relations, + cancellation, + ) }) } @@ -700,6 +680,17 @@ impl VerifiedGraphSnapshot { Ok(()) } + fn head_fanout( + &self, + operation: impl FnOnce(&crate::GraphDb, GraphNamespace) -> Result, + ) -> Result { + self.with_operation(|| { + self.with_head_database(|database| { + operation(database, self.head.locator.physical_namespace()?) + }) + }) + } + fn with_operation( &self, operation: impl FnOnce() -> Result, diff --git a/crates/tracedecay-graph-db/src/lib.rs b/crates/tracedecay-graph-db/src/lib.rs index baf03eab9d..5d5e36f9e6 100644 --- a/crates/tracedecay-graph-db/src/lib.rs +++ b/crates/tracedecay-graph-db/src/lib.rs @@ -33,7 +33,10 @@ pub use bundle::{ SealedReadBundleWriterV1, load_sealed_read_bundle_artifact, retire_sealed_read_bundle, sweep_aborted_sealed_read_bundle_temporaries, }; -pub use error::{GraphBudgetKind, GraphConflictContextV1, GraphDbError}; +pub use error::{ + GraphBudgetKind, GraphConflictContextV1, GraphDbError, GraphStoreFailureClass, + classify_graph_store_error, +}; pub use generation::{ GraphEntityRef, GraphGenerationDependency, GraphGenerationManifest, GraphGenerationManifestIdentity, GraphGenerationManifestProvider, GraphGenerationRelation, @@ -96,6 +99,7 @@ pub use registry::{ GraphPublicationPreparationV1, ProvenGraphPublicationV1, }; pub use runtime::{GraphDb, GraphDbRuntimeState, GraphSnapshot}; +pub use schema::graph_stable_identity; pub use sealed_store::{SealedStoreCensusV1, census_sealed_store}; /// What hydration decoded on **this thread** since the last take. diff --git a/crates/tracedecay-graph-db/src/projection.rs b/crates/tracedecay-graph-db/src/projection.rs index 6a884fa6ff..6e3011ab49 100644 --- a/crates/tracedecay-graph-db/src/projection.rs +++ b/crates/tracedecay-graph-db/src/projection.rs @@ -33,6 +33,14 @@ impl GraphCancellation for NeverCancelled { } } +pub(crate) fn check_cancelled(cancellation: &dyn GraphCancellation) -> Result<(), GraphDbError> { + if cancellation.is_cancelled() { + Err(GraphDbError::Cancelled) + } else { + Ok(()) + } +} + macro_rules! opaque_id { ($name:ident) => { #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] diff --git a/crates/tracedecay-graph-db/src/projection_identity_index.rs b/crates/tracedecay-graph-db/src/projection_identity_index.rs index 968392698c..a5022ace4c 100644 --- a/crates/tracedecay-graph-db/src/projection_identity_index.rs +++ b/crates/tracedecay-graph-db/src/projection_identity_index.rs @@ -27,6 +27,7 @@ use std::sync::{Arc, RwLock}; use grafeo_common::types::Value; use grafeo_engine::GrafeoDB; +use crate::projection::check_cancelled; use crate::projection_read::IdentityScope; use crate::schema::{has_native_label, nodes_with_label}; use crate::{GraphCancellation, GraphDbError}; @@ -220,11 +221,3 @@ fn build_identity_index( node_count, })) } - -fn check_cancelled(cancellation: &dyn GraphCancellation) -> Result<(), GraphDbError> { - if cancellation.is_cancelled() { - Err(GraphDbError::Cancelled) - } else { - Ok(()) - } -} diff --git a/crates/tracedecay-graph-db/src/projection_read.rs b/crates/tracedecay-graph-db/src/projection_read.rs index 2c7921ac28..d0779a8696 100644 --- a/crates/tracedecay-graph-db/src/projection_read.rs +++ b/crates/tracedecay-graph-db/src/projection_read.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use grafeo_common::types::Value; use grafeo_engine::GrafeoDB; +use crate::projection::check_cancelled; use crate::schema::{ ENTITY_ID_PROPERTY, ENTITY_LABEL, RELATION_ID_PROPERTY, RELATION_LABEL, entity_projection_label, relation_projection_label, @@ -473,14 +474,6 @@ fn validate_optional_page_limit(limit: usize) -> Result<(), GraphDbError> { } } -fn check_cancelled(cancellation: &dyn GraphCancellation) -> Result<(), GraphDbError> { - if cancellation.is_cancelled() { - Err(GraphDbError::Cancelled) - } else { - Ok(()) - } -} - fn persisted_identity_error(description: &str, error: GraphDbError) -> GraphDbError { GraphDbError::Corrupt { message: format!("invalid persisted {description} identity: {error}"), diff --git a/crates/tracedecay-graph-db/src/registry/support.rs b/crates/tracedecay-graph-db/src/registry/support.rs index 89991c5231..1ba228a650 100644 --- a/crates/tracedecay-graph-db/src/registry/support.rs +++ b/crates/tracedecay-graph-db/src/registry/support.rs @@ -14,6 +14,7 @@ use super::{ }; use crate::error::rollback_failure; use crate::location::PersistentGraphStoreState; +use crate::projection::check_cancelled; use crate::{ GraphCancellation, GraphDb, GraphDbError, GraphDbLocation, GraphDbOpenOptions, GraphDbOwner, GraphDbRuntimeState, GraphDurability, GraphFormatVersion, @@ -195,14 +196,6 @@ fn registered_open_options( } } -fn check_cancelled(cancellation: &dyn GraphCancellation) -> Result<(), GraphDbError> { - if cancellation.is_cancelled() { - Err(GraphDbError::Cancelled) - } else { - Ok(()) - } -} - /// `op` names the registered operation whose deadline is being enforced. /// `DeadlineExceeded` is a unit error, so without this label a failure deep /// in a projection or publication cannot be attributed to the registration diff --git a/crates/tracedecay-graph-db/src/runtime.rs b/crates/tracedecay-graph-db/src/runtime.rs index 7ad3f099c0..c30858627f 100644 --- a/crates/tracedecay-graph-db/src/runtime.rs +++ b/crates/tracedecay-graph-db/src/runtime.rs @@ -503,33 +503,51 @@ impl GraphDb { Ok(result) } - #[hotpath::measure(label = "graph_db.traversal.outgoing_ids", impl_type = "GraphDb")] - pub fn outgoing_relation_ids( + #[allow(clippy::too_many_arguments)] + fn fanout_relation_rows( &self, namespace: &GraphNamespace, starts: &[GraphEntityId], relation_kinds: &BTreeSet, max_relations: usize, cancellation: Arc, - ) -> Result>, GraphDbError> { + direction: grafeo_core::graph::Direction, + overflow: traversal::RelationFanoutOverflow, + ) -> Result>, GraphDbError> { + self.fanout_batches(namespace, starts, |database, approve, _, _| { + traversal::directed_relations( + database, + namespace, + starts, + relation_kinds, + max_relations, + direction, + cancellation.as_ref(), + approve, + overflow, + ) + }) + } + + fn fanout_batches( + &self, + namespace: &GraphNamespace, + starts: &[GraphEntityId], + read: impl FnOnce( + &GrafeoDB, + &dyn Fn(&GraphNamespace, &GraphProjectionId) -> Result<(), GraphDbError>, + &crate::epoch_cache::LabelKeyCache, + &crate::adjacency_id_index::AdjacencyIdIndexCache, + ) -> Result>, GraphDbError>, + ) -> Result>, GraphDbError> { let guard = self.read_guard()?; let database = guard.as_ref().ok_or(GraphDbError::Closed)?; self.ensure_start_projections_readable(database, namespace, starts)?; - let approve_projection = |namespace: &GraphNamespace, projection: &GraphProjectionId| { - self.approve_projection(namespace, projection) - }; - let batches = traversal::outgoing_relation_ids( - traversal::RelationIdReadContext::new( - database, - &approve_projection, - &self.inner.label_keys, - &self.inner.adjacency_ids, - ), - namespace, - starts, - relation_kinds, - max_relations, - cancellation.as_ref(), + let batches = read( + database, + &|namespace, projection| self.approve_projection(namespace, projection), + &self.inner.label_keys, + &self.inner.adjacency_ids, )?; #[cfg(feature = "hotpath")] { @@ -542,6 +560,37 @@ impl GraphDb { Ok(batches) } + #[hotpath::measure(label = "graph_db.traversal.outgoing_ids", impl_type = "GraphDb")] + pub fn outgoing_relation_ids( + &self, + namespace: &GraphNamespace, + starts: &[GraphEntityId], + relation_kinds: &BTreeSet, + max_relations: usize, + cancellation: Arc, + ) -> Result>, GraphDbError> { + self.fanout_batches( + namespace, + starts, + |database, approve, label_keys, adjacency_ids| { + traversal::directed_relation_ids( + database, + namespace, + starts, + relation_kinds, + max_relations, + false, + cancellation.as_ref(), + approve, + label_keys, + adjacency_ids, + traversal::RelationFanoutOverflow::Refuse, + None, + ) + }, + ) + } + /// Bulk kind-filtered incoming fan-out: the counterpart of /// [`Self::outgoing_relation_ids`], with identical budget and /// cancellation semantics. @@ -554,34 +603,26 @@ impl GraphDb { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - let guard = self.read_guard()?; - let database = guard.as_ref().ok_or(GraphDbError::Closed)?; - self.ensure_start_projections_readable(database, namespace, starts)?; - let approve_projection = |namespace: &GraphNamespace, projection: &GraphProjectionId| { - self.approve_projection(namespace, projection) - }; - let batches = traversal::incoming_relation_ids( - traversal::RelationIdReadContext::new( - database, - &approve_projection, - &self.inner.label_keys, - &self.inner.adjacency_ids, - ), + self.fanout_batches( namespace, starts, - relation_kinds, - max_relations, - cancellation.as_ref(), - )?; - #[cfg(feature = "hotpath")] - { - let edges = batches.iter().map(Vec::len).sum(); - crate::hotpath_observe::record_counts(starts.len(), edges, 0, 0); - crate::hotpath_observe::record_hydration_source( - crate::hotpath_observe::HydrationSource::Live, - ); - } - Ok(batches) + |database, approve, label_keys, adjacency_ids| { + traversal::directed_relation_ids( + database, + namespace, + starts, + relation_kinds, + max_relations, + true, + cancellation.as_ref(), + approve, + label_keys, + adjacency_ids, + traversal::RelationFanoutOverflow::Refuse, + None, + ) + }, + ) } /// Cursor-exclusive ID page over outgoing adjacency. @@ -598,35 +639,26 @@ impl GraphDb { limit: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - let guard = self.read_guard()?; - let database = guard.as_ref().ok_or(GraphDbError::Closed)?; - self.ensure_start_projections_readable(database, namespace, starts)?; - let approve_projection = |namespace: &GraphNamespace, projection: &GraphProjectionId| { - self.approve_projection(namespace, projection) - }; - let batches = traversal::outgoing_relation_ids_page( - traversal::RelationIdReadContext::new( - database, - &approve_projection, - &self.inner.label_keys, - &self.inner.adjacency_ids, - ), + self.fanout_batches( namespace, starts, - relation_kinds, - after, - limit, - cancellation.as_ref(), - )?; - #[cfg(feature = "hotpath")] - { - let edges = batches.iter().map(Vec::len).sum(); - crate::hotpath_observe::record_counts(starts.len(), edges, 0, 0); - crate::hotpath_observe::record_hydration_source( - crate::hotpath_observe::HydrationSource::Live, - ); - } - Ok(batches) + |database, approve, label_keys, adjacency_ids| { + traversal::directed_relation_ids( + database, + namespace, + starts, + relation_kinds, + limit, + false, + cancellation.as_ref(), + approve, + label_keys, + adjacency_ids, + traversal::RelationFanoutOverflow::Truncate, + after, + ) + }, + ) } /// Cursor-exclusive ID page over incoming adjacency. See @@ -641,35 +673,26 @@ impl GraphDb { limit: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - let guard = self.read_guard()?; - let database = guard.as_ref().ok_or(GraphDbError::Closed)?; - self.ensure_start_projections_readable(database, namespace, starts)?; - let approve_projection = |namespace: &GraphNamespace, projection: &GraphProjectionId| { - self.approve_projection(namespace, projection) - }; - let batches = traversal::incoming_relation_ids_page( - traversal::RelationIdReadContext::new( - database, - &approve_projection, - &self.inner.label_keys, - &self.inner.adjacency_ids, - ), + self.fanout_batches( namespace, starts, - relation_kinds, - after, - limit, - cancellation.as_ref(), - )?; - #[cfg(feature = "hotpath")] - { - let edges = batches.iter().map(Vec::len).sum(); - crate::hotpath_observe::record_counts(starts.len(), edges, 0, 0); - crate::hotpath_observe::record_hydration_source( - crate::hotpath_observe::HydrationSource::Live, - ); - } - Ok(batches) + |database, approve, label_keys, adjacency_ids| { + traversal::directed_relation_ids( + database, + namespace, + starts, + relation_kinds, + limit, + true, + cancellation.as_ref(), + approve, + label_keys, + adjacency_ids, + traversal::RelationFanoutOverflow::Truncate, + after, + ) + }, + ) } #[hotpath::measure(label = "graph_db.traversal.outgoing", impl_type = "GraphDb")] @@ -681,27 +704,15 @@ impl GraphDb { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - let guard = self.read_guard()?; - let database = guard.as_ref().ok_or(GraphDbError::Closed)?; - self.ensure_start_projections_readable(database, namespace, starts)?; - let batches = traversal::outgoing_relations( - database, + self.fanout_relation_rows( namespace, starts, relation_kinds, max_relations, - cancellation.as_ref(), - &|namespace, projection| self.approve_projection(namespace, projection), - )?; - #[cfg(feature = "hotpath")] - { - let edges = batches.iter().map(Vec::len).sum(); - crate::hotpath_observe::record_counts(starts.len(), edges, 0, 0); - crate::hotpath_observe::record_hydration_source( - crate::hotpath_observe::HydrationSource::Live, - ); - } - Ok(batches) + cancellation, + grafeo_core::graph::Direction::Outgoing, + traversal::RelationFanoutOverflow::Refuse, + ) } /// Same shape as [`Self::outgoing_relations`], but stops at `max_relations` @@ -715,27 +726,15 @@ impl GraphDb { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - let guard = self.read_guard()?; - let database = guard.as_ref().ok_or(GraphDbError::Closed)?; - self.ensure_start_projections_readable(database, namespace, starts)?; - let batches = traversal::outgoing_relations_truncated( - database, + self.fanout_relation_rows( namespace, starts, relation_kinds, max_relations, - cancellation.as_ref(), - &|namespace, projection| self.approve_projection(namespace, projection), - )?; - #[cfg(feature = "hotpath")] - { - let edges = batches.iter().map(Vec::len).sum(); - crate::hotpath_observe::record_counts(starts.len(), edges, 0, 0); - crate::hotpath_observe::record_hydration_source( - crate::hotpath_observe::HydrationSource::Live, - ); - } - Ok(batches) + cancellation, + grafeo_core::graph::Direction::Outgoing, + traversal::RelationFanoutOverflow::Truncate, + ) } #[hotpath::measure(label = "graph_db.traversal.outgoing_targets", impl_type = "GraphDb")] @@ -747,27 +746,17 @@ impl GraphDb { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - let guard = self.read_guard()?; - let database = guard.as_ref().ok_or(GraphDbError::Closed)?; - self.ensure_start_projections_readable(database, namespace, starts)?; - let batches = traversal::outgoing_relation_targets( - database, - namespace, - starts, - relation_kinds, - max_relations, - cancellation.as_ref(), - &|namespace, projection| self.approve_projection(namespace, projection), - )?; - #[cfg(feature = "hotpath")] - { - let edges = batches.iter().map(Vec::len).sum(); - crate::hotpath_observe::record_counts(starts.len(), edges, 0, 0); - crate::hotpath_observe::record_hydration_source( - crate::hotpath_observe::HydrationSource::Live, - ); - } - Ok(batches) + self.fanout_batches(namespace, starts, |database, approve, _, _| { + traversal::outgoing_relation_targets( + database, + namespace, + starts, + relation_kinds, + max_relations, + cancellation.as_ref(), + approve, + ) + }) } #[hotpath::measure( @@ -815,27 +804,15 @@ impl GraphDb { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - let guard = self.read_guard()?; - let database = guard.as_ref().ok_or(GraphDbError::Closed)?; - self.ensure_start_projections_readable(database, namespace, starts)?; - let batches = traversal::incoming_relations( - database, + self.fanout_relation_rows( namespace, starts, relation_kinds, max_relations, - cancellation.as_ref(), - &|namespace, projection| self.approve_projection(namespace, projection), - )?; - #[cfg(feature = "hotpath")] - { - let edges = batches.iter().map(Vec::len).sum(); - crate::hotpath_observe::record_counts(starts.len(), edges, 0, 0); - crate::hotpath_observe::record_hydration_source( - crate::hotpath_observe::HydrationSource::Live, - ); - } - Ok(batches) + cancellation, + grafeo_core::graph::Direction::Incoming, + traversal::RelationFanoutOverflow::Refuse, + ) } /// Same shape as [`Self::incoming_relations`], but stops at `max_relations` @@ -849,27 +826,15 @@ impl GraphDb { max_relations: usize, cancellation: Arc, ) -> Result>, GraphDbError> { - let guard = self.read_guard()?; - let database = guard.as_ref().ok_or(GraphDbError::Closed)?; - self.ensure_start_projections_readable(database, namespace, starts)?; - let batches = traversal::incoming_relations_truncated( - database, + self.fanout_relation_rows( namespace, starts, relation_kinds, max_relations, - cancellation.as_ref(), - &|namespace, projection| self.approve_projection(namespace, projection), - )?; - #[cfg(feature = "hotpath")] - { - let edges = batches.iter().map(Vec::len).sum(); - crate::hotpath_observe::record_counts(starts.len(), edges, 0, 0); - crate::hotpath_observe::record_hydration_source( - crate::hotpath_observe::HydrationSource::Live, - ); - } - Ok(batches) + cancellation, + grafeo_core::graph::Direction::Incoming, + traversal::RelationFanoutOverflow::Truncate, + ) } #[allow(clippy::too_many_arguments)] diff --git a/crates/tracedecay-graph-db/src/schema.rs b/crates/tracedecay-graph-db/src/schema.rs index 66bd8fa2e4..2d8130b3c1 100644 --- a/crates/tracedecay-graph-db/src/schema.rs +++ b/crates/tracedecay-graph-db/src/schema.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use grafeo_common::types::{EdgeId, NodeId, Value}; use grafeo_core::graph::GraphStore; use grafeo_core::graph::lpg::{Edge, Node}; +use sha2::{Digest, Sha256}; use crate::limits::{ MAX_GRAPH_ENTITY_LABEL_BYTES, MAX_GRAPH_ENTITY_LABELS, MAX_GRAPH_IDENTIFIER_BYTES, @@ -73,6 +74,21 @@ pub(crate) const INDEXED_PROPERTIES: [&str; 6] = [ QUARANTINE_KEY_PROPERTY, ]; +/// Durable `{kind}:{sha256}` stem used for graph entity and relation ids. +/// +/// Kind and value are separated by a NUL byte so a kind cannot be smuggled +/// in as a prefix of the value. Code-graph symbols, git topology, and +/// workflow topology all mint ids through this function; the byte layout is +/// already sealed in stored graphs. +#[must_use] +pub fn graph_stable_identity(kind: &str, value: &str) -> String { + let mut digest = Sha256::new(); + digest.update(kind.as_bytes()); + digest.update([0]); + digest.update(value.as_bytes()); + format!("{kind}:{}", hex::encode(digest.finalize())) +} + pub(crate) fn encoded_namespace_key(namespace: &GraphNamespace) -> String { hex::encode(namespace.as_str().as_bytes()) } @@ -819,3 +835,20 @@ fn persisted_validation_error(description: &str, error: GraphDbError) -> GraphDb message: format!("invalid persisted {description}: {error}"), } } + +#[cfg(test)] +mod graph_stable_identity_tests { + use super::graph_stable_identity; + + #[test] + fn kind_and_value_stay_separated_by_a_nul() { + assert_eq!( + graph_stable_identity("symbol", "occ"), + "symbol:199f069a8ccddbb90bd0626b5904f52fbb2d92879bdbbf2c5dc29c1ea4ab66fb" + ); + assert_ne!( + graph_stable_identity("symbol", "occ"), + graph_stable_identity("symbolo", "cc") + ); + } +} diff --git a/crates/tracedecay-graph-db/src/state.rs b/crates/tracedecay-graph-db/src/state.rs index 08a855bd80..a995594ae4 100644 --- a/crates/tracedecay-graph-db/src/state.rs +++ b/crates/tracedecay-graph-db/src/state.rs @@ -141,15 +141,22 @@ impl ExistingBatchState { entity_locator_keys.retain(|key, _| !entity_keys.contains_key(key)); let entities = hotpath::measure_block!( "graph_db.mutation.existing_state.entity_records", - load_requested_entities(database, &batch.namespace, entity_keys, batch) + load_requested(entity_keys, batch, |identity| { + load_entity(database, &batch.namespace, identity) + }) )?; let entity_locators = hotpath::measure_block!( "graph_db.mutation.existing_state.endpoint_locators", - load_requested_entity_locators(database, &batch.namespace, entity_locator_keys, batch,) + load_requested(entity_locator_keys, batch, |identity| { + load_entity_locator(database, &batch.namespace, identity) + }) )?; + let mut endpoints = EndpointIdentityCache::default(); let relations = hotpath::measure_block!( "graph_db.mutation.existing_state.relation_records", - load_requested_relations(database, &batch.namespace, relation_keys, batch) + load_requested(relation_keys, batch, |identity| { + load_relation_by_key(database, &batch.namespace, identity, &mut endpoints) + }) )?; Ok(Self { entities, @@ -356,57 +363,18 @@ pub(crate) fn load_entity( })) } -fn load_requested_entities( - database: &GrafeoDB, - namespace: &GraphNamespace, - requested: HashMap, - batch: &GraphWriteBatch, -) -> Result, GraphDbError> { - let mut loaded = BTreeMap::new(); - for (index, (key, identity)) in requested.into_iter().enumerate() { - if index % 256 == 0 && batch.cancellation.is_cancelled() { - return Err(GraphDbError::Cancelled); - } - if let Some(entity) = load_entity(database, namespace, identity)? { - loaded.insert(key, entity); - } - } - Ok(loaded) -} - -fn load_requested_entity_locators( - database: &GrafeoDB, - namespace: &GraphNamespace, - requested: HashMap, - batch: &GraphWriteBatch, -) -> Result, GraphDbError> { - let mut loaded = BTreeMap::new(); - for (index, (key, identity)) in requested.into_iter().enumerate() { - if index % 256 == 0 && batch.cancellation.is_cancelled() { - return Err(GraphDbError::Cancelled); - } - if let Some(entity) = load_entity_locator(database, namespace, identity)? { - loaded.insert(key, entity); - } - } - Ok(loaded) -} - -fn load_requested_relations( - database: &GrafeoDB, - namespace: &GraphNamespace, - requested: HashMap, +fn load_requested( + requested: HashMap, batch: &GraphWriteBatch, -) -> Result, GraphDbError> { + mut load: impl FnMut(&K) -> Result, GraphDbError>, +) -> Result, GraphDbError> { let mut loaded = BTreeMap::new(); - let mut endpoints = EndpointIdentityCache::default(); for (index, (key, identity)) in requested.into_iter().enumerate() { if index % 256 == 0 && batch.cancellation.is_cancelled() { return Err(GraphDbError::Cancelled); } - if let Some(relation) = load_relation_cached(database, namespace, identity, &mut endpoints)? - { - loaded.insert(key, relation); + if let Some(value) = load(identity)? { + loaded.insert(key, value); } } Ok(loaded) @@ -442,7 +410,7 @@ pub(crate) fn load_relation( namespace: &GraphNamespace, identity: &GraphRelationId, ) -> Result, GraphDbError> { - load_relation_cached( + load_relation_by_key( database, namespace, identity, @@ -450,15 +418,6 @@ pub(crate) fn load_relation( ) } -pub(crate) fn load_relation_cached( - database: &GrafeoDB, - namespace: &GraphNamespace, - identity: &GraphRelationId, - cache: &mut EndpointIdentityCache, -) -> Result, GraphDbError> { - load_relation_by_key(database, namespace, identity, cache) -} - pub(crate) fn load_relation_by_edge( database: &GrafeoDB, edge_id: EdgeId, diff --git a/crates/tracedecay-graph-db/src/traversal.rs b/crates/tracedecay-graph-db/src/traversal.rs index a7c3075e3f..b18559edec 100644 --- a/crates/tracedecay-graph-db/src/traversal.rs +++ b/crates/tracedecay-graph-db/src/traversal.rs @@ -147,189 +147,6 @@ pub(crate) fn traverse( } } -pub(crate) struct RelationIdReadContext<'a> { - database: &'a GrafeoDB, - ensure_projection_readable: - &'a dyn Fn(&GraphNamespace, &GraphProjectionId) -> Result<(), GraphDbError>, - label_keys_cache: &'a LabelKeyCache, - adjacency_ids: &'a AdjacencyIdIndexCache, -} - -impl<'a> RelationIdReadContext<'a> { - pub(crate) fn new( - database: &'a GrafeoDB, - ensure_projection_readable: &'a dyn Fn( - &GraphNamespace, - &GraphProjectionId, - ) -> Result<(), GraphDbError>, - label_keys_cache: &'a LabelKeyCache, - adjacency_ids: &'a AdjacencyIdIndexCache, - ) -> Self { - Self { - database, - ensure_projection_readable, - label_keys_cache, - adjacency_ids, - } - } -} - -pub(crate) fn outgoing_relation_ids( - context: RelationIdReadContext<'_>, - namespace: &GraphNamespace, - starts: &[GraphEntityId], - relation_kinds: &BTreeSet, - max_relations: usize, - cancellation: &dyn GraphCancellation, -) -> Result>, GraphDbError> { - directed_relation_ids( - context.database, - namespace, - starts, - relation_kinds, - max_relations, - false, - cancellation, - context.ensure_projection_readable, - context.label_keys_cache, - context.adjacency_ids, - RelationFanoutOverflow::Refuse, - None, - ) -} - -pub(crate) fn outgoing_relation_ids_page( - context: RelationIdReadContext<'_>, - namespace: &GraphNamespace, - starts: &[GraphEntityId], - relation_kinds: &BTreeSet, - after: Option<&GraphRelationId>, - limit: usize, - cancellation: &dyn GraphCancellation, -) -> Result>, GraphDbError> { - directed_relation_ids( - context.database, - namespace, - starts, - relation_kinds, - limit, - false, - cancellation, - context.ensure_projection_readable, - context.label_keys_cache, - context.adjacency_ids, - RelationFanoutOverflow::Truncate, - after, - ) -} - -/// Bulk kind-filtered incoming fan-out: the exact counterpart of -/// [`outgoing_relation_ids`], carrying the same batch, cancellation, dedupe, -/// and `max_relations` budget semantics. -/// -/// Plan 39 G7b needs this so an interactive caller/impact read can resolve -/// reverse adjacency through the graph store instead of a SQL `edges` join. -/// Only the traversal direction differs from the outgoing form, so both -/// delegate to [`directed_relations`]. -pub(crate) fn incoming_relation_ids( - context: RelationIdReadContext<'_>, - namespace: &GraphNamespace, - starts: &[GraphEntityId], - relation_kinds: &BTreeSet, - max_relations: usize, - cancellation: &dyn GraphCancellation, -) -> Result>, GraphDbError> { - directed_relation_ids( - context.database, - namespace, - starts, - relation_kinds, - max_relations, - true, - cancellation, - context.ensure_projection_readable, - context.label_keys_cache, - context.adjacency_ids, - RelationFanoutOverflow::Refuse, - None, - ) -} - -pub(crate) fn incoming_relation_ids_page( - context: RelationIdReadContext<'_>, - namespace: &GraphNamespace, - starts: &[GraphEntityId], - relation_kinds: &BTreeSet, - after: Option<&GraphRelationId>, - limit: usize, - cancellation: &dyn GraphCancellation, -) -> Result>, GraphDbError> { - directed_relation_ids( - context.database, - namespace, - starts, - relation_kinds, - limit, - true, - cancellation, - context.ensure_projection_readable, - context.label_keys_cache, - context.adjacency_ids, - RelationFanoutOverflow::Truncate, - after, - ) -} - -pub(crate) fn outgoing_relations( - database: &GrafeoDB, - namespace: &GraphNamespace, - starts: &[GraphEntityId], - relation_kinds: &BTreeSet, - max_relations: usize, - cancellation: &dyn GraphCancellation, - ensure_projection_readable: &dyn Fn( - &GraphNamespace, - &GraphProjectionId, - ) -> Result<(), GraphDbError>, -) -> Result>, GraphDbError> { - directed_relations( - database, - namespace, - starts, - relation_kinds, - max_relations, - Direction::Outgoing, - cancellation, - ensure_projection_readable, - RelationFanoutOverflow::Refuse, - ) -} - -pub(crate) fn outgoing_relations_truncated( - database: &GrafeoDB, - namespace: &GraphNamespace, - starts: &[GraphEntityId], - relation_kinds: &BTreeSet, - max_relations: usize, - cancellation: &dyn GraphCancellation, - ensure_projection_readable: &dyn Fn( - &GraphNamespace, - &GraphProjectionId, - ) -> Result<(), GraphDbError>, -) -> Result>, GraphDbError> { - directed_relations( - database, - namespace, - starts, - relation_kinds, - max_relations, - Direction::Outgoing, - cancellation, - ensure_projection_readable, - RelationFanoutOverflow::Truncate, - ) -} - pub(crate) fn outgoing_relation_targets( database: &GrafeoDB, namespace: &GraphNamespace, @@ -442,57 +259,6 @@ pub(crate) fn visit_outgoing_relation_targets( Ok(visited) } -/// Bulk kind-filtered incoming fan-out. See [`incoming_relation_ids`]. -pub(crate) fn incoming_relations( - database: &GrafeoDB, - namespace: &GraphNamespace, - starts: &[GraphEntityId], - relation_kinds: &BTreeSet, - max_relations: usize, - cancellation: &dyn GraphCancellation, - ensure_projection_readable: &dyn Fn( - &GraphNamespace, - &GraphProjectionId, - ) -> Result<(), GraphDbError>, -) -> Result>, GraphDbError> { - directed_relations( - database, - namespace, - starts, - relation_kinds, - max_relations, - Direction::Incoming, - cancellation, - ensure_projection_readable, - RelationFanoutOverflow::Refuse, - ) -} - -pub(crate) fn incoming_relations_truncated( - database: &GrafeoDB, - namespace: &GraphNamespace, - starts: &[GraphEntityId], - relation_kinds: &BTreeSet, - max_relations: usize, - cancellation: &dyn GraphCancellation, - ensure_projection_readable: &dyn Fn( - &GraphNamespace, - &GraphProjectionId, - ) -> Result<(), GraphDbError>, -) -> Result>, GraphDbError> { - directed_relations( - database, - namespace, - starts, - relation_kinds, - max_relations, - Direction::Incoming, - cancellation, - ensure_projection_readable, - RelationFanoutOverflow::Truncate, - ) -} - /// How [`ordered_relation_ids`] admits a start's neighborhood. enum RelationIdIndexMode { /// Walk every incident edge, sort, and publish the epoch index. @@ -505,7 +271,7 @@ enum RelationIdIndexMode { } #[allow(clippy::too_many_arguments)] -fn directed_relation_ids( +pub(crate) fn directed_relation_ids( database: &GrafeoDB, namespace: &GraphNamespace, starts: &[GraphEntityId], @@ -708,7 +474,7 @@ fn relation_projection_cached( /// [`RelationFanoutOverflow::Truncate`] stops and returns the prefix. #[allow(clippy::too_many_arguments)] #[hotpath::measure(label = "graph_db.compact.directed_relations")] -fn directed_relations( +pub(crate) fn directed_relations( database: &GrafeoDB, namespace: &GraphNamespace, starts: &[GraphEntityId], diff --git a/crates/tracedecay-graph-query/Cargo.toml b/crates/tracedecay-graph-query/Cargo.toml index 5b08132b13..ac88f43c10 100644 --- a/crates/tracedecay-graph-query/Cargo.toml +++ b/crates/tracedecay-graph-query/Cargo.toml @@ -23,7 +23,6 @@ tracedecay-code-extraction = { path = "../tracedecay-code-extraction", version = tracedecay-code-index = { path = "../tracedecay-code-index", version = "0.1.0", default-features = false } tracedecay-domain = { path = "../tracedecay-domain", version = "0.1.0" } tracedecay-graph-db = { path = "../tracedecay-graph-db", version = "0.1.0" } -tracedecay-privacy = { path = "../tracedecay-privacy", version = "0.1.0" } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0" } tracedecay-session-memory = { path = "../tracedecay-session-memory", version = "0.1.0" } diff --git a/crates/tracedecay-graph-query/src/context/markdown_sections.rs b/crates/tracedecay-graph-query/src/context/markdown_sections.rs index a14f6e684a..bb289d9e68 100644 --- a/crates/tracedecay-graph-query/src/context/markdown_sections.rs +++ b/crates/tracedecay-graph-query/src/context/markdown_sections.rs @@ -35,6 +35,7 @@ use serde_json::{Value, json}; use tracedecay_code_extraction::markdown_structure::{ MarkdownSectionStructure, parse_section_structure, }; +use tracedecay_domain::collapse_whitespace; use tracedecay_session_memory::response_handles::store_response_handle; @@ -311,12 +312,6 @@ fn field_str<'a>(value: &'a Value, key: &str) -> &'a str { value.get(key).and_then(Value::as_str).unwrap_or_default() } -/// Squashes newlines and runs of blanks so a multi-line preview stays on one -/// summary line. -fn collapse_whitespace(text: &str) -> String { - text.split_whitespace().collect::>().join(" ") -} - /// The section body: 1-based inclusive lines `start ..= end`, empty when the /// heading carries no body or the span points past the end of the file. pub fn section_body(source: &str, start: u32, end: u32) -> &str { diff --git a/crates/tracedecay-graph-query/src/context/read_modes.rs b/crates/tracedecay-graph-query/src/context/read_modes.rs index 36e98d0d32..ffe005cc35 100644 --- a/crates/tracedecay-graph-query/src/context/read_modes.rs +++ b/crates/tracedecay-graph-query/src/context/read_modes.rs @@ -301,13 +301,7 @@ mod tests { const FILE: &str = "src/main.rs"; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use tracedecay_domain::test_fixtures::id; fn digest(byte: char) -> T where diff --git a/crates/tracedecay-graph-query/src/health.rs b/crates/tracedecay-graph-query/src/health.rs index 5e39895a23..f36c8f7152 100644 --- a/crates/tracedecay-graph-query/src/health.rs +++ b/crates/tracedecay-graph-query/src/health.rs @@ -118,10 +118,6 @@ pub async fn compute_verified_health_snapshot( }) } -// --------------------------------------------------------------------------- -// Task 2: Gini Coefficient -// --------------------------------------------------------------------------- - /// Computes the Gini coefficient for a slice of non-negative values. /// Returns 0.0 for empty slices, single-element slices, or all-zero slices. /// Result is in \[0.0, 1.0\] where 0.0 = perfect equality. @@ -167,10 +163,6 @@ pub fn gini_label(gini: f64) -> &'static str { } } -// --------------------------------------------------------------------------- -// Task 3: Tarjan's SCC / Acyclicity Score -// --------------------------------------------------------------------------- - /// Computes the acyclicity score for a directed graph. /// Uses Tarjan's SCC algorithm. Score = 1.0 - (`edges_in_nontrivial_SCCs` / `total_edges`). /// Returns (score, `number_of_edges_in_cycles`). @@ -213,10 +205,6 @@ pub fn acyclicity_score( (score, edges_in_cycles) } -// --------------------------------------------------------------------------- -// Task 4: Dependency Depth -// --------------------------------------------------------------------------- - /// A chain entry representing a file and the longest dependency chain reaching it. pub struct DepthChain { pub file: String, @@ -242,7 +230,6 @@ pub fn dependency_depth( adj: &HashMap, S1>, limit: usize, ) -> DepthResult { - // Collect all nodes let mut all_nodes: HashSet = adj.keys().cloned().collect(); for targets in adj.values() { all_nodes.extend(targets.iter().cloned()); @@ -264,7 +251,6 @@ pub fn dependency_depth( (file_count as f64).log2().ceil() as usize }; - // Step 1: Run Tarjan's SCC, map each node to its SCC index let sccs = tarjan_scc(adj); let mut node_to_scc: HashMap = HashMap::new(); for (idx, scc) in sccs.iter().enumerate() { @@ -273,7 +259,6 @@ pub fn dependency_depth( } } - // Step 2: Build DAG over SCC indices let scc_count = sccs.len(); let mut scc_adj: HashMap> = HashMap::new(); for (src, targets) in adj { @@ -286,7 +271,6 @@ pub fn dependency_depth( } } - // Step 3: Kahn's algorithm for topological sort let mut in_degree = vec![0usize; scc_count]; for targets in scc_adj.values() { for &tgt in targets { @@ -309,7 +293,6 @@ pub fn dependency_depth( } } - // Step 4: DP for longest path with predecessor tracking let mut dist = vec![0usize; scc_count]; let mut pred = vec![usize::MAX; scc_count]; @@ -324,7 +307,7 @@ pub fn dependency_depth( } } - // Step 5: Rank every SCC before applying the response limit. Reconstructing + // Rank every SCC before applying the response limit. Reconstructing // only the selected chains keeps small-limit callers proportional to their // requested result count. let max_depth = dist.iter().copied().max().unwrap_or(0); @@ -340,7 +323,6 @@ pub fn dependency_depth( for scc_idx in ranked_sccs { let depth = dist[scc_idx]; - // Reconstruct the chain by walking predecessors. let mut chain_sccs: Vec = Vec::new(); let mut cur = scc_idx; loop { @@ -353,7 +335,6 @@ pub fn dependency_depth( } chain_sccs.reverse(); - // Map SCC indices back to representative file names. let chain: Vec = chain_sccs.iter().map(|&si| sccs[si][0].clone()).collect(); let mut scc_files = sccs[scc_idx].clone(); @@ -464,10 +445,6 @@ pub fn depth_score(max_depth: usize, ideal_depth: usize) -> f64 { (ideal_depth as f64 / max_depth as f64).min(1.0) } -// --------------------------------------------------------------------------- -// Task 5: Modularity Score -// --------------------------------------------------------------------------- - /// Estimates modularity by removing hub nodes and counting connected components. /// Hub nodes = files with (fan\_in + fan\_out) > mean + 2\*stddev. /// Score = 1.0 - (1.0 / component\_count), clamped to \[0, 1\]. @@ -480,18 +457,12 @@ pub fn modularity_score( return (1.0, 0); } - // Collect all nodes let mut all_nodes: HashSet = adj.keys().cloned().collect(); for targets in adj.values() { all_nodes.extend(targets.iter().cloned()); } hotpath::gauge!("usecases.graph.modularity.nodes_total").inc(all_nodes.len() as u64); - if all_nodes.is_empty() { - return (1.0, 0); - } - - // Build undirected connectivity count per node (fan_in + fan_out) let mut connectivity: HashMap<&str, usize> = HashMap::new(); for node in &all_nodes { connectivity.insert(node.as_str(), 0); @@ -503,7 +474,6 @@ pub fn modularity_score( } } - // Compute mean and stddev let n = connectivity.len() as f64; let values: Vec = connectivity.values().map(|&v| v as f64).collect(); let mean = values.iter().sum::() / n; @@ -511,14 +481,12 @@ pub fn modularity_score( let stddev = variance.sqrt(); let threshold = mean + 2.0 * stddev; - // Identify hub nodes let hubs: HashSet<&str> = connectivity .iter() .filter(|&(_, &v)| v as f64 > threshold) .map(|(&k, _)| k) .collect(); - // Build undirected graph without hubs let non_hub_nodes: Vec<&str> = all_nodes .iter() .map(String::as_str) @@ -552,7 +520,6 @@ pub fn modularity_score( } } - // Count connected components via BFS let mut visited: HashSet<&str> = HashSet::new(); let mut components = 0; @@ -580,10 +547,6 @@ pub fn modularity_score( (score, components) } -// --------------------------------------------------------------------------- -// Task 6: Composite Health Score -// --------------------------------------------------------------------------- - /// All five health dimensions, each in \[0.0, 1.0\]. #[derive(Debug, Clone)] pub struct HealthDimensions { diff --git a/crates/tracedecay-graph-query/src/lib.rs b/crates/tracedecay-graph-query/src/lib.rs index 589e76be45..d5e94e8fd7 100644 --- a/crates/tracedecay-graph-query/src/lib.rs +++ b/crates/tracedecay-graph-query/src/lib.rs @@ -31,11 +31,7 @@ pub use projection::{ application_graph_cancellation, map_code_graph_read_runtime_error, map_projection_error, request_graph_cancellation, }; -pub use queries::{ - FileAdjacencyScan, GraphQueryManager, NodeMetrics, VerifiedHealthFileAggregateV1, -}; -#[cfg(any(test, feature = "test-helpers"))] -pub use verified_query::admitted_verified_graph_query_port; +pub use queries::{FileAdjacencyScan, GraphQueryManager, VerifiedHealthFileAggregateV1}; pub use verified_query::{ AdmittedVerifiedGraphQueryPort, VerifiedGraphQuery, VerifiedGraphQueryFuture, VerifiedGraphQueryPort, VerifiedGraphQueryRequest, diff --git a/crates/tracedecay-graph-query/src/queries.rs b/crates/tracedecay-graph-query/src/queries.rs index 4addc3d0f3..62f04641e8 100644 --- a/crates/tracedecay-graph-query/src/queries.rs +++ b/crates/tracedecay-graph-query/src/queries.rs @@ -28,16 +28,6 @@ const HEALTH_EDGE_KINDS: [RelationEdgeKindV1; 8] = [ RelationEdgeKindV1::Annotates, ]; -#[derive(Debug, Clone)] -pub struct NodeMetrics { - pub incoming_edge_count: usize, - pub outgoing_edge_count: usize, - pub call_count: usize, - pub caller_count: usize, - pub child_count: usize, - pub depth: usize, -} - #[derive(Debug)] pub struct FileAdjacencyScan { pub adjacency: HashMap>, @@ -210,16 +200,7 @@ impl<'a> GraphQueryManager<'a> { self.reader .edges_among( &occurrences, - &[ - RelationEdgeKindV1::Calls, - RelationEdgeKindV1::Uses, - RelationEdgeKindV1::TypeOf, - RelationEdgeKindV1::Implements, - RelationEdgeKindV1::Extends, - RelationEdgeKindV1::Returns, - RelationEdgeKindV1::Receives, - RelationEdgeKindV1::Annotates, - ], + &HEALTH_EDGE_KINDS, MAX_ANALYTICAL_RELATIONS, Arc::clone(&self.cancellation), ) @@ -256,9 +237,7 @@ impl<'a> GraphQueryManager<'a> { .binding .as_ref() .and_then(|binding| binding.logical_path.as_deref()) - .is_some_and(|path| { - tracedecay_runtime_core::path_scope::path_matches_scope(path, path_prefix) - }) + .is_some_and(|path| tracedecay_domain::path_matches_scope(path, path_prefix)) && (kind_filter.is_empty() || kind_filter.contains(metadata.kind.as_str())) && (include_public || metadata.visibility != "public") && metadata.simple_name != "main" @@ -287,65 +266,8 @@ impl<'a> GraphQueryManager<'a> { Ok(dead) } - #[hotpath::measure(label = "usecases.graph.node_metrics", future = true)] - pub async fn get_node_metrics(&self, node_id: &str) -> Result { - let occurrence = SymbolOccurrenceId::new(node_id.to_owned()).map_err(|error| { - TraceDecayError::Config { - message: error.to_string(), - } - })?; - let counts = self - .reader - .edge_kind_counts(&occurrence, Arc::clone(&self.cancellation)) - .map_err(|error| { - super::map_code_graph_read_runtime_error(map_projection_error(error)) - })?; - let incoming_edge_count = - usize::try_from(counts.incoming.values().sum::()).unwrap_or(usize::MAX); - let outgoing_edge_count = - usize::try_from(counts.outgoing.values().sum::()).unwrap_or(usize::MAX); - Ok(NodeMetrics { - incoming_edge_count, - outgoing_edge_count, - call_count: usize::try_from( - counts - .outgoing - .get(&RelationEdgeKindV1::Calls) - .copied() - .unwrap_or(0), - ) - .unwrap_or(usize::MAX), - caller_count: usize::try_from( - counts - .incoming - .get(&RelationEdgeKindV1::Calls) - .copied() - .unwrap_or(0), - ) - .unwrap_or(usize::MAX), - child_count: usize::try_from( - counts - .outgoing - .get(&RelationEdgeKindV1::Contains) - .copied() - .unwrap_or(0), - ) - .unwrap_or(usize::MAX), - depth: 0, - }) - } - - #[hotpath::measure(label = "usecases.graph.file_dependencies", future = true)] - pub async fn get_file_dependencies(&self, file_path: &str) -> Result> { - self.file_neighbors(file_path, false) - } - #[hotpath::measure(label = "usecases.graph.file_dependents", future = true)] pub async fn get_file_dependents(&self, file_path: &str) -> Result> { - self.file_neighbors(file_path, true) - } - - fn file_neighbors(&self, file_path: &str, incoming: bool) -> Result> { let symbols = hotpath::measure_block!("usecases.graph.file_neighbors.symbols", { self.reader .symbols_in_logical_file( @@ -371,22 +293,16 @@ impl<'a> GraphQueryManager<'a> { return Ok(Vec::new()); } let edges = hotpath::measure_block!("usecases.graph.file_neighbors.edges", { - if incoming { - self.reader.callers( - &seeds, - &[RelationEdgeKindV1::Calls, RelationEdgeKindV1::Uses], - MAX_ANALYTICAL_RELATIONS, - Arc::clone(&self.cancellation), - ) - } else { - self.reader.callees( + self.reader + .callers( &seeds, &[RelationEdgeKindV1::Calls, RelationEdgeKindV1::Uses], MAX_ANALYTICAL_RELATIONS, Arc::clone(&self.cancellation), ) - } - .map_err(|error| super::map_code_graph_read_runtime_error(map_projection_error(error))) + .map_err(|error| { + super::map_code_graph_read_runtime_error(map_projection_error(error)) + }) })?; let mut paths = edges .into_iter() @@ -578,36 +494,6 @@ impl<'a> GraphQueryManager<'a> { }) } - #[hotpath::measure(label = "usecases.graph.health_file_aggregates", future = true)] - pub async fn health_file_aggregates( - &self, - path_prefix: Option<&str>, - ) -> Result> { - let logical_paths = match path_prefix { - Some(prefix) => Some( - self.reader - .files(MAX_ANALYTICAL_SYMBOLS, Arc::clone(&self.cancellation)) - .map_err(|error| { - super::map_code_graph_read_runtime_error(map_projection_error(error)) - })? - .into_iter() - .map(|file| file.logical_path) - .filter(|path| path_is_within(path, prefix)) - .collect::>(), - ), - None => None, - }; - let (symbols, edges, external_test_markers) = - self.health_evidence(logical_paths.as_ref())?; - let metadata = health_symbol_metadata(&symbols)?; - Ok(fold_health_aggregates( - metadata, - &edges, - external_test_markers, - path_prefix, - )) - } - /// Health symbols, the induced edge set, and the test markers only the /// scoped `callers` walk can see: its far endpoints legitimately sit /// outside the scoped symbol census, so their marker metadata cannot be diff --git a/crates/tracedecay-graph-query/src/test_risk.rs b/crates/tracedecay-graph-query/src/test_risk.rs index bc06c54dc8..a23b07d2c5 100644 --- a/crates/tracedecay-graph-query/src/test_risk.rs +++ b/crates/tracedecay-graph-query/src/test_risk.rs @@ -159,7 +159,7 @@ pub async fn analyze_test_risk( && !n.skip_test_coverage && !n.qualified_name.contains("::tests::") }) - .filter(|n| tracedecay_runtime_core::path_scope::path_matches_scope(&n.file, path_prefix)) + .filter(|n| tracedecay_domain::path_matches_scope(&n.file, path_prefix)) .collect(); let excluded_count = eligible_fns @@ -208,7 +208,7 @@ pub async fn analyze_test_risk( && n.skip_test_coverage && !is_test_file(&n.file) && is_source_file(&n.file) - && tracedecay_runtime_core::path_scope::path_matches_scope(&n.file, path_prefix) + && tracedecay_domain::path_matches_scope(&n.file, path_prefix) && !n.qualified_name.contains("::tests::") }) .count(); @@ -341,9 +341,7 @@ pub fn verified_test_evidence( files .into_iter() .map(|file| file.logical_path) - .filter(|path| { - tracedecay_runtime_core::path_scope::path_matches_scope(path, Some(prefix)) - }) + .filter(|path| tracedecay_domain::path_matches_scope(path, Some(prefix))) .collect::>() }) }) diff --git a/crates/tracedecay-graph-query/src/verified_query.rs b/crates/tracedecay-graph-query/src/verified_query.rs index 1978b8144d..7e9a610b60 100644 --- a/crates/tracedecay-graph-query/src/verified_query.rs +++ b/crates/tracedecay-graph-query/src/verified_query.rs @@ -23,7 +23,7 @@ use tracedecay_domain::{ }; use tracedecay_graph_db::GraphCancellation; -use super::queries::{GraphQueryManager, NodeMetrics, VerifiedHealthFileAggregateV1}; +use super::queries::GraphQueryManager; use super::source_authority::{ AdmittedSourceAuthority, graph_source_scope_mismatch, graph_source_unbound, }; @@ -115,15 +115,6 @@ impl VerifiedGraphQueryPort for AdmittedVerifiedGraphQueryPort { } } -#[cfg(any(test, feature = "test-helpers"))] -#[must_use] -pub fn admitted_verified_graph_query_port( - admission: Arc, - projection: Arc, -) -> Arc { - admitted_verified_graph_query_port_with_source(admission, projection, None) -} - #[must_use] pub fn admitted_verified_graph_query_port_with_source( admission: Arc, @@ -223,24 +214,14 @@ impl VerifiedGraphQuery { #[hotpath::skip] async fn await_bound(&self, future: impl Future>) -> Result { self.refuse_if_bound_closed()?; - match run_deadline_signal_interruptible( + let result = await_graph_port_wait( self.request_context.deadline(), &self.live_cancellation, future, ) - .await - { - Ok(result) => { - self.refuse_if_bound_closed()?; - result - } - Err(RequestInterruption::Cancelled) => Err(map_code_graph_read_runtime_error( - super::CodeGraphReadError::Cancelled, - )), - Err(RequestInterruption::DeadlineExceeded) => Err(map_code_graph_read_runtime_error( - super::CodeGraphReadError::TimedOut, - )), - } + .await?; + self.refuse_if_bound_closed()?; + result } pub fn project_root(&self) -> Result<&Path> { @@ -298,27 +279,6 @@ impl VerifiedGraphQuery { .await } - #[hotpath::measure(label = "usecases.graph.verified.file_dependencies", future = true)] - pub async fn get_file_dependencies(&self, file_path: &str) -> Result> { - self.await_bound(self.manager().get_file_dependencies(file_path)) - .await - } - - #[hotpath::measure(label = "usecases.graph.verified.node_metrics", future = true)] - pub async fn get_node_metrics(&self, node_id: &str) -> Result { - self.await_bound(self.manager().get_node_metrics(node_id)) - .await - } - - #[hotpath::measure(label = "usecases.graph.verified.health_aggregates", future = true)] - pub async fn health_file_aggregates( - &self, - path_prefix: Option<&str>, - ) -> Result> { - self.await_bound(self.manager().health_file_aggregates(path_prefix)) - .await - } - #[hotpath::measure(label = "usecases.graph.verified.health_snapshot", future = true)] pub async fn verified_health_snapshot( &self, @@ -372,11 +332,6 @@ impl VerifiedGraphQuery { ) } - pub fn render_signatures(&self, file_path: &str) -> Result { - self.refuse_if_bound_closed()?; - read_modes::render_signatures(&self.reader, Arc::clone(&self.cancellation), file_path) - } - pub fn generation(&self) -> &CodeGenerationId { self.reader.generation() } @@ -607,7 +562,7 @@ impl VerifiedGraphQuery { max_relations: usize, ) -> Result> { self.refuse_if_bound_closed()?; - let (symbols, scoped) = match logical_paths { + let symbols = match logical_paths { Some(requested) => { let mut symbols = Vec::new(); for path in requested { @@ -628,7 +583,7 @@ impl VerifiedGraphQuery { } symbols.append(&mut in_file); } - (symbols, true) + symbols } None => { let page = self.symbols_page(None, max_symbols)?; @@ -637,7 +592,7 @@ impl VerifiedGraphQuery { "verified test-attribution census exceeded its symbol budget", )); } - (page.symbols, false) + page.symbols } }; let mut paths = HashMap::new(); @@ -657,7 +612,7 @@ impl VerifiedGraphQuery { test_markers.insert(symbol.occurrence.clone()); } } - if scoped { + if logical_paths.is_some() { if test_markers.is_empty() { return Ok(HashSet::new()); } @@ -671,7 +626,6 @@ impl VerifiedGraphQuery { .into_iter() .flatten() .filter_map(|edge| paths.get(&edge.edge.to_occurrence).cloned()) - .filter(|path| logical_paths.is_none_or(|requested| requested.contains(path))) .collect()); } let occurrences = symbols @@ -687,7 +641,6 @@ impl VerifiedGraphQuery { .into_iter() .filter(|edge| test_markers.contains(&edge.from_occurrence)) .filter_map(|edge| paths.get(&edge.to_occurrence).cloned()) - .filter(|path| logical_paths.is_none_or(|requested| requested.contains(path))) .collect()) } } diff --git a/crates/tracedecay-hooks/src/core_events.rs b/crates/tracedecay-hooks/src/core_events.rs index 9c9ed310c4..87e27ce7e3 100644 --- a/crates/tracedecay-hooks/src/core_events.rs +++ b/crates/tracedecay-hooks/src/core_events.rs @@ -90,10 +90,6 @@ impl DaemonHookEvent { self } - pub fn cursor_after_file_edit(rel_paths: Vec) -> Self { - Self::new(HookAgent::Cursor, "afterFileEdit", rel_paths, None, None) - } - pub fn cursor_after_shell_execution(cwd: PathBuf) -> Self { Self::new( HookAgent::Cursor, @@ -104,16 +100,6 @@ impl DaemonHookEvent { ) } - pub fn cursor_workspace_open(cwd: PathBuf) -> Self { - Self::new( - HookAgent::Cursor, - "workspaceOpen", - Vec::new(), - None, - Some(cwd), - ) - } - /// A provider session started: let the daemon own branch tracking and /// index refresh for the session's actual working directory. pub fn session_start(agent: HookAgent, cwd: PathBuf) -> Self { @@ -130,8 +116,4 @@ impl DaemonHookEvent { pub fn post_tool_use_shell(agent: HookAgent, cwd: PathBuf) -> Self { Self::new(agent, "postToolUseShell", Vec::new(), None, Some(cwd)) } - - pub fn kiro_post_tool_use(rel_paths: Vec, cwd: Option) -> Self { - Self::new(HookAgent::Kiro, "postToolUse", rel_paths, None, cwd) - } } diff --git a/crates/tracedecay-host-admission/src/authorities.rs b/crates/tracedecay-host-admission/src/authorities.rs index 07aed131de..58e9016abe 100644 --- a/crates/tracedecay-host-admission/src/authorities.rs +++ b/crates/tracedecay-host-admission/src/authorities.rs @@ -23,7 +23,7 @@ pub struct HostAdmissionAuthorities<'a> { } impl<'a> HostAdmissionAuthorities<'a> { - pub fn registered_for_project( + pub fn for_project( brain_id: BrainId, profile_id: UserProfileId, project_id: ProjectId, @@ -40,7 +40,7 @@ impl<'a> HostAdmissionAuthorities<'a> { } } - pub(crate) fn registered_for_profile( + pub fn for_profile( brain_id: BrainId, profile_id: UserProfileId, registered: &'a RegisteredGlobalDb, @@ -64,23 +64,6 @@ impl<'a> HostAdmissionAuthorities<'a> { self } - pub fn for_project( - brain_id: BrainId, - profile_id: UserProfileId, - project_id: ProjectId, - registered: &'a RegisteredGlobalDb, - ) -> Self { - Self::registered_for_project(brain_id, profile_id, project_id, registered) - } - - pub fn for_profile( - brain_id: BrainId, - profile_id: UserProfileId, - registered: &'a RegisteredGlobalDb, - ) -> Self { - Self::registered_for_profile(brain_id, profile_id, registered) - } - /// Adds the registered profile-session authority to project admission. #[must_use] pub fn with_profile_registered( diff --git a/crates/tracedecay-host-admission/src/lib.rs b/crates/tracedecay-host-admission/src/lib.rs index bee8a4d8f9..0e90fb01ac 100644 --- a/crates/tracedecay-host-admission/src/lib.rs +++ b/crates/tracedecay-host-admission/src/lib.rs @@ -553,12 +553,15 @@ impl<'a> HostAdmissionFacade<'a> { ) .await .map_err(|error| classify_error(&error))?; - project_captured_outcome( + let mut projected = project_captured_outcomes( database, self.authorities.repository_provenance.as_ref(), - outcome, + vec![outcome], ) - .await + .await?; + projected.pop().ok_or_else(|| { + HostAdmissionOutcome::retained_unavailable("external_source_commit_failed") + }) } /// Sanitize then persist a bounded window through one store-owned batch. @@ -984,38 +987,6 @@ fn classify_external_source_error( } } -async fn project_captured_outcome( - database: &RegisteredGlobalDb, - repository_provenance: Option<&RepositoryProvenanceAdmissionContext>, - outcome: CaptureObservationOutcome, -) -> Result { - let CaptureObservationOutcome::Persisted { - outcome: persisted, .. - } = &outcome - else { - return Ok(outcome); - }; - let projection = - tracedecay_session_memory::external_source_store::RuntimeExternalSourceStore::new( - database.runtime_client(), - ) - .capture_host_observation(persisted.receipt()) - .await - .map_err(classify_external_source_error)?; - publish_canonical_git_evidence( - database, - repository_provenance, - std::slice::from_ref(&outcome), - ) - .await?; - let outcome = if let tracedecay_session_memory::external_source_store::RuntimeSourceCaptureOutcomeV1::ProjectionPending(receipt) = projection { - accepted_for_external_source_replay(outcome, receipt)? - } else { - outcome - }; - Ok(outcome) -} - async fn project_captured_outcomes( database: &RegisteredGlobalDb, repository_provenance: Option<&RepositoryProvenanceAdmissionContext>, @@ -1168,7 +1139,7 @@ fn accepted_for_external_source_replay( } fn classify_store_error(error: &ObservationStoreError) -> HostAdmissionOutcome { - match error { + let reason_code = match error { ObservationStoreError::BatchRequiresScalarFallback { cause } => { return HostAdmissionOutcome::batch_requires_scalar_fallback(*cause); } @@ -1187,9 +1158,9 @@ fn classify_store_error(error: &ObservationStoreError) -> HostAdmissionOutcome { "observation_retrieval_anchor_alias_collision", ); } - _ => {} - } - let reason_code = match error { + ObservationStoreError::CursorConflict { .. } | ObservationStoreError::Storage { .. } => { + unreachable!("retryable store failures are classified before static reason mapping") + } ObservationStoreError::CursorObservationMismatch => "observation_cursor_mismatch", ObservationStoreError::CursorCoverageMismatch => "observation_cursor_coverage_mismatch", ObservationStoreError::CursorAdvanceCollision => "observation_cursor_advance_collision", @@ -1199,10 +1170,6 @@ fn classify_store_error(error: &ObservationStoreError) -> HostAdmissionOutcome { ObservationStoreError::CursorSanitizationReceiptMismatch => { "observation_cursor_sanitization_receipt_mismatch" } - ObservationStoreError::ObservationCollision { .. } => "observation_identity_collision", - ObservationStoreError::SanitizationReceiptCollision => { - "observation_sanitization_receipt_collision" - } ObservationStoreError::RetrievalAnchorObservationMismatch => { "observation_retrieval_anchor_observation_mismatch" } @@ -1231,14 +1198,8 @@ fn classify_store_error(error: &ObservationStoreError) -> HostAdmissionOutcome { ObservationStoreError::RepositoryProvenanceContract(_) => { "observation_repository_provenance_contract_invalid" } - ObservationStoreError::RetrievalAnchorAliasCollision { .. } => { - "observation_retrieval_anchor_alias_collision" - } ObservationStoreError::InvalidReplayLimit { .. } => "observation_replay_limit_invalid", ObservationStoreError::Contract(_) => "observation_store_contract_invalid", - ObservationStoreError::CursorConflict { .. } | ObservationStoreError::Storage { .. } => { - unreachable!("retryable store failures are classified before static reason mapping") - } _ => "observation_store_failed", }; admission_outcome(HostAdmissionStatus::Degraded, false, Some(reason_code)) diff --git a/crates/tracedecay-hotpath-guard/Cargo.toml b/crates/tracedecay-hotpath-guard/Cargo.toml index 72f5df1a5b..6636a377ab 100644 --- a/crates/tracedecay-hotpath-guard/Cargo.toml +++ b/crates/tracedecay-hotpath-guard/Cargo.toml @@ -20,7 +20,6 @@ hotpath-mcp = ["hotpath", "hotpath/hotpath-mcp"] hotpath.workspace = true [dev-dependencies] -hotpath.workspace = true serde_json = "1" [[test]] diff --git a/crates/tracedecay-hotpath-guard/src/lib.rs b/crates/tracedecay-hotpath-guard/src/lib.rs index 89121cb917..def93497ff 100644 --- a/crates/tracedecay-hotpath-guard/src/lib.rs +++ b/crates/tracedecay-hotpath-guard/src/lib.rs @@ -21,11 +21,9 @@ pub fn with_functions_display_limit( } fn functions_display_limit() -> Option { - parse_usize_env("HOTPATH_FUNCTIONS_LIMIT").or_else(|| parse_usize_env("HOTPATH_LIMIT")) -} - -fn parse_usize_env(name: &str) -> Option { - std::env::var(name).ok().and_then(|raw| raw.parse().ok()) + ["HOTPATH_FUNCTIONS_LIMIT", "HOTPATH_LIMIT"] + .into_iter() + .find_map(|name| std::env::var(name).ok().and_then(|raw| raw.parse().ok())) } #[cfg(test)] diff --git a/crates/tracedecay-lcm/src/compression.rs b/crates/tracedecay-lcm/src/compression.rs index 09717cb955..3d0f531970 100644 --- a/crates/tracedecay-lcm/src/compression.rs +++ b/crates/tracedecay-lcm/src/compression.rs @@ -13,7 +13,7 @@ use super::compression_decision::{ CondensationDecision, CondensationDecisionInput, OverflowRecoveryCapInput, PreflightDecisionInput, }; -use super::compression_policy::is_policy_anchor_role; +use super::compression_policy::{is_policy_anchor_role, source_token_count}; use super::extraction; use super::summarizer::CompressionSummarizerAdapter; use super::types::{LcmExtractionResult, LcmRelationProjectionStatus, LcmSummarySourceRange}; @@ -114,14 +114,7 @@ pub async fn update_lifecycle( conn: &impl Executor, update: LcmLifecycleUpdate, ) -> Result { - upsert_lifecycle_state(conn, &update).await?; - replace_maintenance_debt( - conn, - &update.provider, - &update.conversation_id, - &update.maintenance_debt, - ) - .await?; + persist_lifecycle_update(conn, &update).await?; lifecycle_state(conn, &update.provider, &update.conversation_id).await } @@ -206,14 +199,6 @@ async fn link_session_boundary( conn: &impl Executor, request: &LcmSessionBoundaryRequest, old_session_id: &str, -) -> Result { - link_in_transaction(conn, request, old_session_id).await -} - -async fn link_in_transaction( - conn: &impl Executor, - request: &LcmSessionBoundaryRequest, - old_session_id: &str, ) -> Result { ensure_session(conn, &request.provider, &request.session_id).await?; let old_state = @@ -237,14 +222,7 @@ async fn link_in_transaction( last_finalized_frontier_store_id: carried_frontier, maintenance_debt: old_state.maintenance_debt.clone(), }; - upsert_lifecycle_state(conn, &update).await?; - replace_maintenance_debt( - conn, - &update.provider, - &update.conversation_id, - &update.maintenance_debt, - ) - .await?; + persist_lifecycle_update(conn, &update).await?; Ok(session_boundary_response( true, @@ -1050,14 +1028,7 @@ async fn persist_compression_transaction_writes<'a>( last_finalized_frontier_store_id: write.existing_frontier.last_finalized_frontier_store_id, maintenance_debt: debt_for_deferred_backlog(remaining_backlog), }; - upsert_lifecycle_state(conn, &update).await?; - replace_maintenance_debt( - conn, - &update.provider, - &update.conversation_id, - &update.maintenance_debt, - ) - .await?; + persist_lifecycle_update(conn, &update).await?; Ok(CompressionTransactionWriteResult { created_summaries, @@ -1066,6 +1037,20 @@ async fn persist_compression_transaction_writes<'a>( }) } +async fn persist_lifecycle_update( + conn: &impl Executor, + update: &LcmLifecycleUpdate, +) -> Result<(), LcmError> { + upsert_lifecycle_state(conn, update).await?; + replace_maintenance_debt( + conn, + &update.provider, + &update.conversation_id, + &update.maintenance_debt, + ) + .await +} + async fn upsert_lifecycle_state( conn: &impl Executor, update: &LcmLifecycleUpdate, @@ -1852,14 +1837,7 @@ async fn condense_summary_nodes_if_ready( last_finalized_frontier_store_id: existing_frontier.last_finalized_frontier_store_id, maintenance_debt: existing_frontier.maintenance_debt.clone(), }; - upsert_lifecycle_state(conn, &update).await?; - replace_maintenance_debt( - conn, - &update.provider, - &update.conversation_id, - &update.maintenance_debt, - ) - .await?; + persist_lifecycle_update(conn, &update).await?; let frontier = lifecycle_state(conn, &update.provider, &update.conversation_id).await?; // Mirrors hermes-lcm: `_assemble_context` always follows // `_maybe_condense`, so a condensation-only pass still returns the @@ -2640,13 +2618,6 @@ fn summary_replay_message(summary: &LcmSummaryNode) -> Value { }) } -fn source_token_count(backlog: &[LcmRawMessage]) -> i64 { - backlog - .iter() - .map(|message| crate::lcm_budget_tokens(&message.content)) - .sum::() -} - fn debt_for_deferred_backlog(deferred_backlog: &[LcmRawMessage]) -> Vec { match (deferred_backlog.first(), deferred_backlog.last()) { (Some(first), Some(last)) => vec![LcmMaintenanceDebt::RawBacklog { diff --git a/crates/tracedecay-lcm/src/compression_policy.rs b/crates/tracedecay-lcm/src/compression_policy.rs index b0d404d26b..ca9a97cd3b 100644 --- a/crates/tracedecay-lcm/src/compression_policy.rs +++ b/crates/tracedecay-lcm/src/compression_policy.rs @@ -203,12 +203,7 @@ pub fn forced_overflow_pressure( current_tokens: Option, max_assembly_tokens: Option, ) -> bool { - match (current_tokens, max_assembly_tokens) { - (Some(current_tokens), Some(max_assembly_tokens)) if max_assembly_tokens > 0 => { - current_tokens >= max_assembly_tokens - } - _ => false, - } + threshold_pressure(current_tokens, max_assembly_tokens) } pub fn source_token_count(backlog: &[LcmRawMessage]) -> i64 { diff --git a/crates/tracedecay-lcm/src/gc.rs b/crates/tracedecay-lcm/src/gc.rs index 63bfc4fc86..42be3fa5f5 100644 --- a/crates/tracedecay-lcm/src/gc.rs +++ b/crates/tracedecay-lcm/src/gc.rs @@ -625,7 +625,8 @@ pub async fn run_payload_gc_in_transaction( report.ended_at = now; if apply { - let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + let duration_ms = + tracedecay_runtime_core::tracedecay::saturating_duration_millis(started.elapsed()); let status = if report.errors.is_empty() { "ok" } else { diff --git a/crates/tracedecay-lcm/src/lib.rs b/crates/tracedecay-lcm/src/lib.rs index 7ad1f70976..bd334a8166 100644 --- a/crates/tracedecay-lcm/src/lib.rs +++ b/crates/tracedecay-lcm/src/lib.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use tracedecay_contracts::retained_surfaces::{MessageRelationshipScopeV1, MessageTypeFilterV1}; pub mod compression; pub mod compression_decision; @@ -188,23 +189,32 @@ pub enum SessionMessageType { ToolResult, } +impl From for SessionMessageType { + fn from(value: MessageTypeFilterV1) -> Self { + match value { + MessageTypeFilterV1::All => Self::All, + MessageTypeFilterV1::DirectUser => Self::DirectUser, + MessageTypeFilterV1::ToolResult => Self::ToolResult, + } + } +} + impl SessionMessageType { - pub fn parse(value: &str) -> Option { - match value.trim() { - "all" => Some(Self::All), - "direct_user" => Some(Self::DirectUser), - "tool_result" => Some(Self::ToolResult), - _ => None, + const fn filter(self) -> MessageTypeFilterV1 { + match self { + Self::All => MessageTypeFilterV1::All, + Self::DirectUser => MessageTypeFilterV1::DirectUser, + Self::ToolResult => MessageTypeFilterV1::ToolResult, } } + pub fn parse(value: &str) -> Option { + MessageTypeFilterV1::parse(value.trim()).map(Self::from) + } + #[hotpath::skip] pub const fn as_str(self) -> &'static str { - match self { - Self::All => "all", - Self::DirectUser => "direct_user", - Self::ToolResult => "tool_result", - } + self.filter().as_str() } } @@ -215,23 +225,32 @@ pub enum SessionSearchScope { SubagentsOnly, } +impl From for SessionSearchScope { + fn from(value: MessageRelationshipScopeV1) -> Self { + match value { + MessageRelationshipScopeV1::All => Self::All, + MessageRelationshipScopeV1::ParentsOnly => Self::ParentsOnly, + MessageRelationshipScopeV1::SubagentsOnly => Self::SubagentsOnly, + } + } +} + impl SessionSearchScope { - pub fn parse(value: &str) -> Option { - match value.trim() { - "all" => Some(Self::All), - "parents_only" => Some(Self::ParentsOnly), - "subagents_only" => Some(Self::SubagentsOnly), - _ => None, + const fn relationship(self) -> MessageRelationshipScopeV1 { + match self { + Self::All => MessageRelationshipScopeV1::All, + Self::ParentsOnly => MessageRelationshipScopeV1::ParentsOnly, + Self::SubagentsOnly => MessageRelationshipScopeV1::SubagentsOnly, } } + pub fn parse(value: &str) -> Option { + MessageRelationshipScopeV1::parse(value.trim()).map(Self::from) + } + #[hotpath::skip] pub const fn as_str(self) -> &'static str { - match self { - Self::All => "all", - Self::ParentsOnly => "parents_only", - Self::SubagentsOnly => "subagents_only", - } + self.relationship().as_str() } } @@ -305,3 +324,70 @@ mod budget_tests { } } } + +#[cfg(test)] +mod relationship_scope_map_tests { + use tracedecay_contracts::retained_surfaces::MessageRelationshipScopeV1; + + use super::SessionSearchScope; + + #[test] + fn session_scope_uses_the_contract_wire_label() { + let scopes = [ + (SessionSearchScope::All, MessageRelationshipScopeV1::All), + ( + SessionSearchScope::ParentsOnly, + MessageRelationshipScopeV1::ParentsOnly, + ), + ( + SessionSearchScope::SubagentsOnly, + MessageRelationshipScopeV1::SubagentsOnly, + ), + ]; + for (scope, contract) in scopes { + assert_eq!(scope.as_str(), contract.as_str()); + assert_eq!(SessionSearchScope::from(contract), scope); + assert_eq!(SessionSearchScope::parse(scope.as_str()), Some(scope)); + assert_eq!( + SessionSearchScope::parse(&format!(" {} ", scope.as_str())), + Some(scope) + ); + } + assert_eq!(SessionSearchScope::parse("all_sessions"), None); + } +} + +#[cfg(test)] +mod message_type_map_tests { + use tracedecay_contracts::retained_surfaces::MessageTypeFilterV1; + + use super::SessionMessageType; + + #[test] + fn session_message_type_uses_the_contract_wire_label() { + let types = [ + (SessionMessageType::All, MessageTypeFilterV1::All), + ( + SessionMessageType::DirectUser, + MessageTypeFilterV1::DirectUser, + ), + ( + SessionMessageType::ToolResult, + MessageTypeFilterV1::ToolResult, + ), + ]; + for (message_type, contract) in types { + assert_eq!(message_type.as_str(), contract.as_str()); + assert_eq!(SessionMessageType::from(contract), message_type); + assert_eq!( + SessionMessageType::parse(message_type.as_str()), + Some(message_type) + ); + assert_eq!( + SessionMessageType::parse(&format!(" {} ", message_type.as_str())), + Some(message_type) + ); + } + assert_eq!(SessionMessageType::parse("user"), None); + } +} diff --git a/crates/tracedecay-lcm/src/payload/filesystem_authority.rs b/crates/tracedecay-lcm/src/payload/filesystem_authority.rs index ac10ba64b5..487d617319 100644 --- a/crates/tracedecay-lcm/src/payload/filesystem_authority.rs +++ b/crates/tracedecay-lcm/src/payload/filesystem_authority.rs @@ -34,6 +34,17 @@ pub(super) struct PayloadFileIdentity { file_id: [u8; 16], } +pub(super) fn same_payload_file_identity( + actual: &PayloadFileIdentity, + expected: &PayloadFileIdentity, +) -> Result<(), LcmError> { + if actual == expected { + Ok(()) + } else { + Err(LcmError::InvalidPayloadRef) + } +} + /// Opaque proof that a payload's locator, stable file identity, digest, and /// byte/character sizes were observed together. /// @@ -366,18 +377,6 @@ fn payload_file_identity( }) } -#[cfg(unix)] -pub(super) fn same_payload_file_identity( - actual: &PayloadFileIdentity, - expected: &PayloadFileIdentity, -) -> Result<(), LcmError> { - if actual == expected { - Ok(()) - } else { - Err(LcmError::InvalidPayloadRef) - } -} - #[cfg(windows)] fn same_file_identity( file: &fs::File, @@ -385,7 +384,7 @@ fn same_file_identity( _lstat: &fs::Metadata, path: &Path, ) -> Result<(), LcmError> { - let current = verification_file_options() + let current = private_file_options() .read(true) .open(path) .map_err(|err| classify_payload_open_error(path, err))?; @@ -404,18 +403,6 @@ fn payload_file_identity( windows_file_identity(file) } -#[cfg(windows)] -pub(super) fn same_payload_file_identity( - actual: &PayloadFileIdentity, - expected: &PayloadFileIdentity, -) -> Result<(), LcmError> { - if actual == expected { - Ok(()) - } else { - Err(LcmError::InvalidPayloadRef) - } -} - #[cfg(all(not(unix), not(windows)))] #[allow(clippy::unnecessary_wraps)] // Keep platform implementations signature-compatible. fn same_file_identity( @@ -435,15 +422,6 @@ fn payload_file_identity( Ok(PayloadFileIdentity {}) } -#[cfg(all(not(unix), not(windows)))] -#[allow(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)] // Keep the identity API uniform even where the platform identity is opaque. -pub(super) fn same_payload_file_identity( - _actual: &PayloadFileIdentity, - _expected: &PayloadFileIdentity, -) -> Result<(), LcmError> { - Ok(()) -} - fn ensure_regular_non_reparse_file(metadata: &fs::Metadata) -> Result<(), LcmError> { if metadata.file_type().is_symlink() || metadata_is_reparse_point(metadata) @@ -967,15 +945,6 @@ fn private_create_file_options() -> fs::OpenOptions { options } -#[cfg(windows)] -fn verification_file_options() -> fs::OpenOptions { - let mut options = fs::OpenOptions::new(); - options - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE); - options -} - #[cfg(windows)] fn private_directory_options() -> fs::OpenOptions { let mut options = fs::OpenOptions::new(); diff --git a/crates/tracedecay-lcm/src/query.rs b/crates/tracedecay-lcm/src/query.rs index 59f2914c72..59815f9c6f 100644 --- a/crates/tracedecay-lcm/src/query.rs +++ b/crates/tracedecay-lcm/src/query.rs @@ -993,30 +993,19 @@ fn grep_order_by( recency_column: &str, role_penalty_expr: Option<&str>, ) -> String { - match sort { - LcmGrepSort::Relevance => match role_penalty_expr { - Some(role_penalty_expr) => { - format!("rank ASC, {role_penalty_expr} ASC, {recency_column} DESC") - } - None => format!("rank ASC, {recency_column} DESC"), - }, - LcmGrepSort::Hybrid => { - let blended = format!( + let (leading, trailing) = match sort { + LcmGrepSort::Relevance => ("rank ASC".to_string(), format!("{recency_column} DESC")), + LcmGrepSort::Hybrid => ( + format!( "(rank / (1 + (MAX(0.0, ((strftime('%s','now') - {recency_column}) / 3600.0)) * {AGE_DECAY_RATE})))" - ); - match role_penalty_expr { - Some(role_penalty_expr) => { - format!("{blended} ASC, {role_penalty_expr} ASC, {recency_column} DESC") - } - None => format!("{blended} ASC, {recency_column} DESC"), - } - } - LcmGrepSort::Recency => match role_penalty_expr { - Some(role_penalty_expr) => { - format!("{recency_column} DESC, {role_penalty_expr} ASC, rank ASC") - } - None => format!("{recency_column} DESC, rank ASC"), - }, + ), + format!("{recency_column} DESC"), + ), + LcmGrepSort::Recency => (format!("{recency_column} DESC"), "rank ASC".to_string()), + }; + match role_penalty_expr { + Some(penalty) => format!("{leading}, {penalty} ASC, {trailing}"), + None => format!("{leading}, {trailing}"), } } diff --git a/crates/tracedecay-lcm/src/raw.rs b/crates/tracedecay-lcm/src/raw.rs index c5fa60493f..e10c568c03 100644 --- a/crates/tracedecay-lcm/src/raw.rs +++ b/crates/tracedecay-lcm/src/raw.rs @@ -365,20 +365,23 @@ fn externalized_payload_placeholder( field_path: &str, quarantine_reason: Option<&str>, ) -> String { - if let Some(reason) = quarantine_reason { - return format!( - "[Externalized LCM ingest payload: assistant output quarantined; kind={}; reason={}; field={}; chars={}; bytes={}; ref={}]", + let body = payload_placeholder_body(payload_ref, field_path); + match quarantine_reason { + Some(reason) => format!( + "[Externalized LCM ingest payload: assistant output quarantined; kind={}; reason={}; {body}]", safe_placeholder_metadata(&payload_ref.kind), safe_placeholder_metadata(reason), - safe_placeholder_metadata(field_path), - payload_ref.char_count, - payload_ref.byte_count, - payload_ref.payload_ref - ); + ), + None => format!( + "[Externalized LCM ingest payload: kind={}; {body}]", + safe_placeholder_metadata(&payload_ref.kind) + ), } +} + +fn payload_placeholder_body(payload_ref: &LcmPayloadRef, field_path: &str) -> String { format!( - "[Externalized LCM ingest payload: kind={}; field={}; chars={}; bytes={}; ref={}]", - safe_placeholder_metadata(&payload_ref.kind), + "field={}; chars={}; bytes={}; ref={}", safe_placeholder_metadata(field_path), payload_ref.char_count, payload_ref.byte_count, @@ -395,6 +398,37 @@ async fn upsert_inline_raw_message( let snippet = derived_text_for_snippet(text); let index = derived_text_for_index(text); let content_hash = projected_content_hash(text); + upsert_owned_raw_message( + conn, + message, + OwnedRawMessageWrite { + content: Some(text), + content_hash: content_hash.as_str(), + storage_kind: LcmStorageKind::Inline, + payload_ref: None, + snippet: snippet.as_str(), + index_text: index.as_str(), + metadata_json, + }, + ) + .await +} + +struct OwnedRawMessageWrite<'a> { + content: Option<&'a str>, + content_hash: &'a str, + storage_kind: LcmStorageKind, + payload_ref: Option<&'a str>, + snippet: &'a str, + index_text: &'a str, + metadata_json: Option<&'a str>, +} + +async fn upsert_owned_raw_message( + conn: &(impl Executor + ?Sized), + message: &SessionMessageRecord, + write: OwnedRawMessageWrite<'_>, +) -> Result<(), LcmError> { let affected = conn .execute( "INSERT INTO lcm_raw_messages ( @@ -402,7 +436,7 @@ async fn upsert_inline_raw_message( content, content_hash, storage_kind, payload_ref, snippet_text, index_text, legacy_source, legacy_truncated, metadata_json ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?11, 0, 0, ?12) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, 0, ?13) ON CONFLICT(provider, message_id) DO UPDATE SET session_id = excluded.session_id, role = excluded.role, @@ -425,12 +459,13 @@ async fn upsert_inline_raw_message( message.role.as_str(), message.ordinal, message.timestamp, - text, - content_hash.as_str(), - LcmStorageKind::Inline.as_str(), - snippet.as_str(), - index.as_str(), - metadata_json, + write.content, + write.content_hash, + write.storage_kind.as_str(), + write.payload_ref, + write.snippet, + write.index_text, + write.metadata_json, ], ) .await?; @@ -468,6 +503,20 @@ async fn persist_raw_predecessor_range( /// [`PREDECESSOR_RANGE_UPSERT_BY_STORE_RANGE`]) instead of per call. fn predecessor_range_upsert_sql(current_predicate: &str) -> String { let role_list = crate::compression_policy::policy_anchor_role_sql_in_list(); + let bound = |order: &str| { + format!( + "SELECT candidate.store_id + FROM lcm_raw_messages AS candidate + WHERE candidate.provider = current.provider + AND candidate.session_id = current.session_id + AND candidate.store_id < current.store_id + AND candidate.role NOT IN ({role_list}) + ORDER BY candidate.store_id{order} + LIMIT 1" + ) + }; + let first = bound(""); + let prior = bound(" DESC"); format!( "INSERT INTO lcm_raw_predecessor_ranges ( provider, message_id, session_id, from_store_id, to_store_id @@ -477,25 +526,11 @@ fn predecessor_range_upsert_sql(current_predicate: &str) -> String { FROM lcm_raw_messages AS current JOIN lcm_raw_messages AS first ON first.store_id = ( - SELECT candidate.store_id - FROM lcm_raw_messages AS candidate - WHERE candidate.provider = current.provider - AND candidate.session_id = current.session_id - AND candidate.store_id < current.store_id - AND candidate.role NOT IN ({role_list}) - ORDER BY candidate.store_id - LIMIT 1 + {first} ) JOIN lcm_raw_messages AS prior ON prior.store_id = ( - SELECT candidate.store_id - FROM lcm_raw_messages AS candidate - WHERE candidate.provider = current.provider - AND candidate.session_id = current.session_id - AND candidate.store_id < current.store_id - AND candidate.role NOT IN ({role_list}) - ORDER BY candidate.store_id DESC - LIMIT 1 + {prior} ) WHERE {current_predicate} ON CONFLICT(provider, message_id) DO UPDATE SET @@ -791,48 +826,20 @@ pub async fn commit_staged_raw_message( }); }; payload::upsert_payload_metadata(conn, &whole_message.payload_ref).await?; - let affected = conn - .execute( - "INSERT INTO lcm_raw_messages ( - provider, message_id, session_id, role, ordinal, timestamp, - content, content_hash, storage_kind, payload_ref, snippet_text, - index_text, legacy_source, legacy_truncated, metadata_json - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8, ?9, ?10, ?11, 0, 0, ?12) - ON CONFLICT(provider, message_id) DO UPDATE SET - session_id = excluded.session_id, - role = excluded.role, - ordinal = excluded.ordinal, - timestamp = excluded.timestamp, - content = excluded.content, - content_hash = excluded.content_hash, - storage_kind = excluded.storage_kind, - payload_ref = excluded.payload_ref, - snippet_text = excluded.snippet_text, - index_text = excluded.index_text, - legacy_source = 0, - legacy_truncated = 0, - metadata_json = excluded.metadata_json - WHERE lcm_raw_messages.session_id = excluded.session_id", - params![ - message.provider.as_str(), - message.message_id.as_str(), - message.session_id.as_str(), - message.role.as_str(), - message.ordinal, - message.timestamp, - whole_message.payload_ref.content_hash.as_str(), - LcmStorageKind::External.as_str(), - whole_message.payload_ref.payload_ref.as_str(), - whole_message.placeholder.as_str(), - whole_message.placeholder.as_str(), - whole_message.metadata_json.as_str(), - ], - ) - .await?; - if affected != 1 { - return Err(LcmError::SummarySourceNotOwnedBySession); - } + upsert_owned_raw_message( + conn, + message, + OwnedRawMessageWrite { + content: None, + content_hash: whole_message.payload_ref.content_hash.as_str(), + storage_kind: LcmStorageKind::External, + payload_ref: Some(whole_message.payload_ref.payload_ref.as_str()), + snippet: whole_message.placeholder.as_str(), + index_text: whole_message.placeholder.as_str(), + metadata_json: Some(whole_message.metadata_json.as_str()), + }, + ) + .await?; persist_raw_predecessor_range(conn, message).await?; Ok(RawMessageUpsert { projection_text: whole_message.placeholder, @@ -1144,14 +1151,7 @@ fn externalize_spans( } fn ingest_payload_placeholder(payload_ref: &LcmPayloadRef, field_path: &str) -> String { - format!( - "[Externalized LCM ingest payload: kind={}; field={}; chars={}; bytes={}; ref={}]", - safe_placeholder_metadata(&payload_ref.kind), - safe_placeholder_metadata(field_path), - payload_ref.char_count, - payload_ref.byte_count, - payload_ref.payload_ref - ) + externalized_payload_placeholder(payload_ref, field_path, None) } fn safe_placeholder_metadata(value: &str) -> String { diff --git a/crates/tracedecay-lcm/src/security.rs b/crates/tracedecay-lcm/src/security.rs index dc26d5cc11..74851e7026 100644 --- a/crates/tracedecay-lcm/src/security.rs +++ b/crates/tracedecay-lcm/src/security.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::{Arc, LazyLock, Mutex, PoisonError}; use regex::Regex; +use tracedecay_domain::collapse_whitespace; const LARGE_TOOL_OUTPUT_CHARS: usize = 256 * 1024; // Mirrors hermes-lcm `_GENERIC_BASE64_MIN_CHARS` (ingest_protection.py:103). @@ -71,7 +72,7 @@ pub fn heartbeat_noise_reason(role: &str, content: &str) -> Option<&'static str> ) { return None; } - let normalized = content.split_whitespace().collect::>().join(" "); + let normalized = collapse_whitespace(content); if normalized.is_empty() || char_count_exceeds(&normalized, 256) { return None; } @@ -114,8 +115,7 @@ pub fn ignore_message_reason_with_compiled( } pub fn matches_any_pattern>(patterns: &[S], value: &str) -> bool { - let compiled = cached_session_patterns(patterns); - matches_any_compiled_pattern(&compiled, value) + cached_session_patterns(patterns).is_match(value) } /// Session pattern lists come from configuration and repeat on every LCM @@ -140,10 +140,6 @@ fn cached_session_patterns>(patterns: &[S]) -> Arc bool { - patterns.is_match(value) -} - #[hotpath::measure(label = "sessions.lcm.compile_session")] pub fn compile_session_patterns>(patterns: &[S]) -> CompiledPatternSet { compile_patterns(patterns, |pattern| { @@ -234,7 +230,7 @@ fn assistant_output_is_high_repetition(content: &str) -> bool { return false; } - let normalized = content.split_whitespace().collect::>().join(" "); + let normalized = collapse_whitespace(content); let tokens = word_tokens(&normalized); if tokens.len() < QUARANTINED_ASSISTANT_MIN_TOKENS { return tokens.len() >= 20 && distinct_char_count(&normalized) <= 12; @@ -285,7 +281,7 @@ fn word_tokens(text: &str) -> Vec { fn repetition_segments(text: &str) -> Vec { text.split(['\n', '.', '!', '?']) - .map(|segment| segment.split_whitespace().collect::>().join(" ")) + .map(collapse_whitespace) .map(|segment| segment.to_ascii_lowercase()) .filter(|segment| char_count_at_least(segment, 32)) .collect() diff --git a/crates/tracedecay-lcm/src/summary_convergence.rs b/crates/tracedecay-lcm/src/summary_convergence.rs index 508ec574e9..fd524b9531 100644 --- a/crates/tracedecay-lcm/src/summary_convergence.rs +++ b/crates/tracedecay-lcm/src/summary_convergence.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::collections::hash_map::Entry; -use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, params}; +use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, Row, params}; use crate::{LcmCompressionResponse, LcmError, schema}; @@ -682,21 +682,7 @@ pub async fn next_candidate( let Some(row) = rows.next().await? else { return Ok(None); }; - let failure_count = u32::try_from(row.get::(5)?).map_err(|error| { - LcmError::Db(format!( - "invalid LCM summary convergence failure count: {error}" - )) - })?; - Ok(Some(LcmSummaryConvergenceCandidate { - provider: row.get(0)?, - session_id: row.get(1)?, - newest_raw_store_id: row.get(2)?, - protection_frontier_store_id: row.get(3)?, - attempted_raw_store_id: row.get(4)?, - failure_count, - raw_revision_generation: row.get(6)?, - stale_from_store_id: row.get(7)?, - })) + Ok(Some(convergence_candidate_from_row(&row)?)) } pub async fn candidate_for_session( @@ -719,12 +705,16 @@ pub async fn candidate_for_session( let Some(row) = rows.next().await? else { return Ok(None); }; + Ok(Some(convergence_candidate_from_row(&row)?)) +} + +fn convergence_candidate_from_row(row: &Row) -> Result { let failure_count = u32::try_from(row.get::(5)?).map_err(|error| { LcmError::Db(format!( "invalid LCM summary convergence failure count: {error}" )) })?; - Ok(Some(LcmSummaryConvergenceCandidate { + Ok(LcmSummaryConvergenceCandidate { provider: row.get(0)?, session_id: row.get(1)?, newest_raw_store_id: row.get(2)?, @@ -733,7 +723,7 @@ pub async fn candidate_for_session( failure_count, raw_revision_generation: row.get(6)?, stale_from_store_id: row.get(7)?, - })) + }) } pub async fn record_current_protection_progress( diff --git a/crates/tracedecay-lsp/src/analyzer/activity.rs b/crates/tracedecay-lsp/src/analyzer/activity.rs index df9a153f1c..e9ead1a9a4 100644 --- a/crates/tracedecay-lsp/src/analyzer/activity.rs +++ b/crates/tracedecay-lsp/src/analyzer/activity.rs @@ -131,15 +131,6 @@ fn matches_adapter_extension(adapter: &LspAdapterDefinition, file: &str) -> bool }) } -pub fn adapter_workspace_root( - project_root: &Path, - adapter: &LspAdapterDefinition, - file: &str, -) -> Option { - let project_root = canonicalize_project_root(project_root).ok()?; - adapter_workspace_root_from_canonical_root(&project_root, adapter, file) -} - pub(crate) fn adapter_workspace_root_from_canonical_root( project_root: &Path, adapter: &LspAdapterDefinition, diff --git a/crates/tracedecay-lsp/src/analyzer/client.rs b/crates/tracedecay-lsp/src/analyzer/client.rs index 568992246e..0e08f3b4fc 100644 --- a/crates/tracedecay-lsp/src/analyzer/client.rs +++ b/crates/tracedecay-lsp/src/analyzer/client.rs @@ -1411,18 +1411,10 @@ fn code_diagnostic( // resolved later via `DiagnosticBroker::resolve_enclosing_nodes`, // which has access to the indexed nodes for the file. enclosing_node: None, - updated_at: now_unix(), + updated_at: tracedecay_runtime_core::tracedecay::saturating_unix_secs(), } } -fn now_unix() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| { - i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) - }) -} - fn code_to_string(value: NumberOrString) -> String { match value { NumberOrString::String(value) => value, diff --git a/crates/tracedecay-lsp/src/analyzer/host_ownership.rs b/crates/tracedecay-lsp/src/analyzer/host_ownership.rs index 54dcb9b100..19f5f13f18 100644 --- a/crates/tracedecay-lsp/src/analyzer/host_ownership.rs +++ b/crates/tracedecay-lsp/src/analyzer/host_ownership.rs @@ -303,11 +303,7 @@ mod tests { /// Host-absolute fixture path: `$XDG_CONFIG_HOME` only wins when it is /// absolute, and a bare `/xdg/...` literal is not absolute on Windows. fn absolute_fixture_path(posix: &str) -> PathBuf { - if cfg!(windows) { - PathBuf::from(format!("C:{}", posix.replace('/', "\\"))) - } else { - PathBuf::from(posix) - } + PathBuf::from(tracedecay_domain::test_fixtures::fixture_abs_root(posix)) } #[test] diff --git a/crates/tracedecay-lsp/src/compile_diagnostics/cache.rs b/crates/tracedecay-lsp/src/compile_diagnostics/cache.rs index 4963339652..ec53e0b99d 100644 --- a/crates/tracedecay-lsp/src/compile_diagnostics/cache.rs +++ b/crates/tracedecay-lsp/src/compile_diagnostics/cache.rs @@ -31,6 +31,7 @@ struct CachedDiagnostics { #[derive(Debug, Clone, PartialEq, Eq)] enum DiagnosticsCacheRevision { + #[cfg(test)] WorkspaceChange(u64), Recovery(DiagnosticsFingerprint), } @@ -100,25 +101,6 @@ impl DiagnosticsCache { .await } - /// Run diagnostics under the code index's worktree-change authority. - /// - /// A generation is exactly as fresh as the index used by search: hook - /// hints and Git metadata changes are observed immediately, while other - /// out-of-band edits are observed by the 30-second stat-signature ladder. - /// Until that ladder runs, diagnostics intentionally reuse the preceding - /// generation rather than deriving a second workspace-change authority. - pub async fn run_for_generation( - &self, - project_root: &Path, - scope: &Scope, - generation: u64, - ) -> Result> { - self.run_with_generation(project_root, scope, generation, || { - run_all(project_root, scope) - }) - .await - } - #[hotpath::measure(label = "compile_diagnostics.cache.run", future = true)] pub(crate) async fn run_with( &self, @@ -157,6 +139,7 @@ impl DiagnosticsCache { .await } + #[cfg(test)] pub(crate) async fn run_with_generation( &self, project_root: &Path, diff --git a/crates/tracedecay-lsp/src/diagnostics.rs b/crates/tracedecay-lsp/src/diagnostics.rs index d4a8bb6325..770f66324f 100644 --- a/crates/tracedecay-lsp/src/diagnostics.rs +++ b/crates/tracedecay-lsp/src/diagnostics.rs @@ -436,14 +436,7 @@ fn utf16_column_to_byte_offset( } pub(crate) fn truncate_utf8(value: &mut String, max_bytes: usize) { - if value.len() <= max_bytes { - return; - } - let mut boundary = max_bytes; - while !value.is_char_boundary(boundary) { - boundary -= 1; - } - value.truncate(boundary); + value.truncate(value.floor_char_boundary(max_bytes)); } #[cfg(test)] diff --git a/crates/tracedecay-lsp/src/overlay/retention.rs b/crates/tracedecay-lsp/src/overlay/retention.rs index a76af17565..de4608d5b6 100644 --- a/crates/tracedecay-lsp/src/overlay/retention.rs +++ b/crates/tracedecay-lsp/src/overlay/retention.rs @@ -33,14 +33,10 @@ impl OverlayDiagnosticDebouncer { #[cfg(test)] mod tests { - use tracedecay_domain::ManifestDigest; - use super::*; use crate::session::AuthorizedLspWorkspace; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; #[test] fn nested_workspace_overlay_belongs_only_to_the_deepest_root() { diff --git a/crates/tracedecay-lsp/src/protocol.rs b/crates/tracedecay-lsp/src/protocol.rs index ee0d88379b..35142b599f 100644 --- a/crates/tracedecay-lsp/src/protocol.rs +++ b/crates/tracedecay-lsp/src/protocol.rs @@ -431,9 +431,7 @@ where .overlays .change(&uri, version, &changes) .map_err(|error| self.close_for_overlay_error(error))?; - self.diagnostics.workspace_snapshots.clear(); - self.diagnostics.workspace_failures.clear(); - self.discard_document_context(&uri); + self.invalidate_document(&uri); self.diagnostics.native_upstream.remove(&uri); self.lifecycle .control @@ -461,9 +459,7 @@ where .overlays .close(&uri) .map_err(overlay_failure)?; - self.diagnostics.workspace_snapshots.clear(); - self.diagnostics.workspace_failures.clear(); - self.discard_document_context(&uri); + self.invalidate_document(&uri); self.diagnostics.native_upstream.remove(&uri); self.lifecycle .control @@ -486,9 +482,7 @@ where self.require_ready()?; let uri = required_nonempty_string(text_document(params)?, "uri")?; self.require_document_root(&uri)?; - self.diagnostics.workspace_snapshots.clear(); - self.diagnostics.workspace_failures.clear(); - self.discard_document_context(&uri); + self.invalidate_document(&uri); if matches!( self.lifecycle.gateway.document_saved(uri.clone()), FeedbackCycleResponse::Accepted @@ -504,6 +498,12 @@ where } Ok(()) } + + fn invalidate_document(&mut self, uri: &str) { + self.diagnostics.workspace_snapshots.clear(); + self.diagnostics.workspace_failures.clear(); + self.discard_document_context(uri); + } } #[cfg(test)] diff --git a/crates/tracedecay-lsp/src/protocol/context_controller.rs b/crates/tracedecay-lsp/src/protocol/context_controller.rs index 14a2afa8ae..74fa10c4b3 100644 --- a/crates/tracedecay-lsp/src/protocol/context_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/context_controller.rs @@ -1,10 +1,11 @@ use serde::Serialize; use super::diagnostics_controller::refresh_pending_failure; +use super::semantic_controller::admission_refusal; use super::{ - Arc, BTreeMap, BTreeSet, CodeGenerationId, CommitId, CompletionDisposition, ContentDigest, - ContextCoverage, ContextExpansionEnvelope, ContextExpansionOutcome, ContextExpansionRequest, - ContextFreshness, ContextProducerState, ContextProjectionChange, ContextProjectionEnvelope, + Arc, BTreeMap, BTreeSet, CodeGenerationId, CommitId, ContentDigest, ContextCoverage, + ContextExpansionEnvelope, ContextExpansionOutcome, ContextExpansionRequest, ContextFreshness, + ContextProducerState, ContextProjectionChange, ContextProjectionEnvelope, ContextProjectionIdentity, ContextProjectionKind, ContextProjectionOutcome, ContextProjectionPort, ContextProjectionRegistration, ContextProjectionRequest, ContextSubscribeRequest, DaemonLspProtocolSession, DiagnosticSnapshotPort, FeedbackCyclePort, @@ -14,7 +15,7 @@ use super::{ ProcessLocalRequestSequence, RpcFailure, SemanticProviderPort, TRACEDECAY_CONTEXT_CHANGED_METHOD, TRACEDECAY_CONTEXT_EXPAND_METHOD, TRACEDECAY_CONTEXT_METHOD, TRACEDECAY_SUBSCRIBE_METHOD, Value, error_response, is_supported_context_projection, json, - request_id, success_response, + request_id, }; struct CountingSink { @@ -216,67 +217,41 @@ where now_ms: u64, ) { let deadline = now_ms.saturating_add(self.lifecycle.request_deadline_ms); - match self.lifecycle.control.admit_request_with_deadline( + let admission = self.lifecycle.control.admit_request_with_deadline( request_id.clone(), document, Some(deadline), - ) { - crate::session::RequestAdmission::Accepted => { - let Ok(operation_id) = - NEXT_CONTEXT_OPERATION_ID.next_string("lsp-context-operation-") - else { - self.complete_context_request( - request_id, + ); + if let Some(failure) = admission_refusal(admission) { + let _ = self.enqueue_value(error_response(response_id, failure)); + return; + } + let Ok(operation_id) = NEXT_CONTEXT_OPERATION_ID.next_string("lsp-context-operation-") + else { + self.finish_admitted_request( + request_id, + response_id, + Err(RpcFailure::request_failure( + LspRequestFailure::ServerCancelled { + retrigger_request: true, + }, + )), + ); + return; + }; + let operation_id = LspRequestId::String(operation_id); + match self.context_snapshot_value(&operation_id, &request) { + Ok(None) => { + self.context.pending_requests.insert( + request_id, + PendingContextRequest { response_id, - Err(RpcFailure::request_failure( - LspRequestFailure::ServerCancelled { - retrigger_request: true, - }, - )), - ); - return; - }; - let operation_id = LspRequestId::String(operation_id); - match self.context_snapshot_value(&operation_id, &request) { - Ok(None) => { - self.context.pending_requests.insert( - request_id, - PendingContextRequest { - response_id, - operation_id, - request, - }, - ); - } - result => self.complete_context_request(request_id, response_id, result), - } - } - crate::session::RequestAdmission::DuplicateId => { - let _ = self.enqueue_value(error_response( - response_id, - RpcFailure { - code: -32600, - message: "Invalid Request", - data: json!({ "detail": "duplicate request id" }), + operation_id, + request, }, - )); - } - crate::session::RequestAdmission::SessionUnavailable => { - let _ = self.enqueue_value(error_response( - response_id, - RpcFailure::request_failure(LspRequestFailure::ServerCancelled { - retrigger_request: true, - }), - )); - } - crate::session::RequestAdmission::Saturated { retrigger_request } => { - let _ = self.enqueue_value(error_response( - response_id, - RpcFailure::request_failure(LspRequestFailure::ServerCancelled { - retrigger_request, - }), - )); + ); } + result => self.finish_admitted_request(request_id, response_id, result), } } @@ -288,91 +263,40 @@ where now_ms: u64, ) { let deadline = now_ms.saturating_add(self.lifecycle.request_deadline_ms); - match self.lifecycle.control.admit_request_with_deadline( + let admission = self.lifecycle.control.admit_request_with_deadline( request_id.clone(), None, Some(deadline), - ) { - crate::session::RequestAdmission::Accepted => { - let Ok(operation_id) = - NEXT_CONTEXT_OPERATION_ID.next_string("lsp-context-expansion-") - else { - self.complete_context_request( - request_id, - response_id, - Err(RpcFailure::request_failure( - LspRequestFailure::ServerCancelled { - retrigger_request: true, - }, - )), - ); - return; - }; - let operation_id = LspRequestId::String(operation_id); - match self.context_expansion_value(&operation_id, &request) { - Ok(None) => { - self.context.pending_expansions.insert( - request_id, - PendingContextExpansion { - response_id, - operation_id, - }, - ); - } - result => self.complete_context_request(request_id, response_id, result), - } - } - crate::session::RequestAdmission::DuplicateId => { - let _ = self.enqueue_value(error_response( - response_id, - RpcFailure { - code: -32600, - message: "Invalid Request", - data: json!({ "detail": "duplicate request id" }), - }, - )); - } - crate::session::RequestAdmission::SessionUnavailable => { - let _ = self.enqueue_value(error_response( - response_id, - RpcFailure::request_failure(LspRequestFailure::ServerCancelled { - retrigger_request: true, - }), - )); - } - crate::session::RequestAdmission::Saturated { retrigger_request } => { - let _ = self.enqueue_value(error_response( - response_id, - RpcFailure::request_failure(LspRequestFailure::ServerCancelled { - retrigger_request, - }), - )); - } + ); + if let Some(failure) = admission_refusal(admission) { + let _ = self.enqueue_value(error_response(response_id, failure)); + return; } - } - - pub(super) fn complete_context_request( - &mut self, - request_id: LspRequestId, - response_id: Value, - result: Result, RpcFailure>, - ) { - let completion = self.lifecycle.control.complete_request(&request_id); - if let Some(failure) = completion.failure() { - let _ = self.enqueue_value(error_response( + let Ok(operation_id) = NEXT_CONTEXT_OPERATION_ID.next_string("lsp-context-expansion-") + else { + self.finish_admitted_request( + request_id, response_id, - RpcFailure::request_failure(failure), - )); - } else if completion == CompletionDisposition::Publish { - match result { - Ok(Some(value)) => { - let _ = self.enqueue_value(success_response(response_id, value)); - } - Ok(None) => {} - Err(error) => { - let _ = self.enqueue_value(error_response(response_id, error)); - } + Err(RpcFailure::request_failure( + LspRequestFailure::ServerCancelled { + retrigger_request: true, + }, + )), + ); + return; + }; + let operation_id = LspRequestId::String(operation_id); + match self.context_expansion_value(&operation_id, &request) { + Ok(None) => { + self.context.pending_expansions.insert( + request_id, + PendingContextExpansion { + response_id, + operation_id, + }, + ); } + result => self.finish_admitted_request(request_id, response_id, result), } } @@ -520,7 +444,7 @@ where .get(&pending.request.kind) .copied() else { - self.complete_context_request( + self.finish_admitted_request( request_id, pending.response_id, Err(RpcFailure::unavailable( @@ -531,7 +455,7 @@ where continue; }; let result = self.context_projection_value(&pending.request, revision, outcome); - self.complete_context_request(request_id, pending.response_id, result); + self.finish_admitted_request(request_id, pending.response_id, result); } } @@ -619,7 +543,7 @@ where continue; }; let result = self.context_expansion_outcome_value(outcome); - self.complete_context_request(request_id, pending.response_id, result); + self.finish_admitted_request(request_id, pending.response_id, result); } } diff --git a/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs b/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs index a4b880e2a6..a108482e5d 100644 --- a/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs @@ -393,7 +393,7 @@ where { let _ = cancellation.cancel_upstream(&root, &request_id); } - self.complete_context_request( + self.finish_admitted_request( request_id, pending.response_id, Err(RpcFailure::request_failure( @@ -413,7 +413,7 @@ where { let _ = context.cancel_request(&root, &pending.operation_id); } - self.complete_context_request( + self.finish_admitted_request( request_id, pending.response_id, Err(RpcFailure::request_failure( @@ -430,7 +430,7 @@ where { let _ = context.cancel_request(&root, &pending.operation_id); } - self.complete_context_request( + self.finish_admitted_request( request_id, pending.response_id, Err(RpcFailure::request_failure( @@ -756,7 +756,7 @@ where .or_else(|| context_pending.map(|pending| pending.response_id)) .or_else(|| expansion_pending.map(|pending| pending.response_id)); if let Some(response_id) = response_id { - self.complete_context_request( + self.finish_admitted_request( id.clone(), response_id, Err(RpcFailure::request_failure( diff --git a/crates/tracedecay-lsp/src/protocol/semantic_controller.rs b/crates/tracedecay-lsp/src/protocol/semantic_controller.rs index 053ea12d46..8aa2c0d8c2 100644 --- a/crates/tracedecay-lsp/src/protocol/semantic_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/semantic_controller.rs @@ -1,3 +1,5 @@ +use crate::session::RequestAdmission; + use super::{ AnalyzerCancellationPort, Arc, BTreeMap, CompletionDisposition, DaemonLspProtocolSession, DiagnosticSnapshotPort, FeedbackCyclePort, GatewayResponse, LspRequestFailure, LspRequestId, @@ -46,55 +48,17 @@ where return; }; let deadline = now_ms.saturating_add(self.lifecycle.request_deadline_ms); - match self.lifecycle.control.admit_request_with_deadline( + let admission = self.lifecycle.control.admit_request_with_deadline( request_id.clone(), document, Some(deadline), - ) { - crate::session::RequestAdmission::Accepted => { - let result = route(self); - let completion = self.lifecycle.control.complete_request(&request_id); - if let Some(failure) = completion.failure() { - let _ = self - .enqueue_value(error_response(id, RpcFailure::request_failure(failure))); - } else if completion == CompletionDisposition::Publish { - match result { - Ok(value) => { - let _ = self.enqueue_value(success_response(id, value)); - } - Err(error) => { - let _ = self.enqueue_value(error_response(id, error)); - } - } - } - } - crate::session::RequestAdmission::DuplicateId => { - let _ = self.enqueue_value(error_response( - id, - RpcFailure { - code: -32600, - message: "Invalid Request", - data: json!({ "detail": "duplicate request id" }), - }, - )); - } - crate::session::RequestAdmission::SessionUnavailable => { - let _ = self.enqueue_value(error_response( - id, - RpcFailure::request_failure(LspRequestFailure::ServerCancelled { - retrigger_request: true, - }), - )); - } - crate::session::RequestAdmission::Saturated { retrigger_request } => { - let _ = self.enqueue_value(error_response( - id, - RpcFailure::request_failure(LspRequestFailure::ServerCancelled { - retrigger_request, - }), - )); - } + ); + if let Some(failure) = admission_refusal(admission) { + let _ = self.enqueue_value(error_response(id, failure)); + return; } + let result = route(self); + self.finish_admitted_request(request_id, id, result.map(Some)); } #[hotpath::measure( @@ -116,51 +80,26 @@ where return; }; let deadline = now_ms.saturating_add(self.lifecycle.request_deadline_ms); - match self.lifecycle.control.admit_request_with_deadline( + let admission = self.lifecycle.control.admit_request_with_deadline( request_id.clone(), document, Some(deadline), - ) { - crate::session::RequestAdmission::Accepted => { - match self.semantic_request_value(&request_id, &request) { - Ok(None) => { - self.semantic.pending.insert( - request_id, - PendingSemanticRequest { - response_id, - request, - }, - ); - } - result => self.complete_semantic_request(request_id, response_id, result), - } - } - crate::session::RequestAdmission::DuplicateId => { - let _ = self.enqueue_value(error_response( - response_id, - RpcFailure { - code: -32600, - message: "Invalid Request", - data: json!({ "detail": "duplicate request id" }), + ); + if let Some(failure) = admission_refusal(admission) { + let _ = self.enqueue_value(error_response(response_id, failure)); + return; + } + match self.semantic_request_value(&request_id, &request) { + Ok(None) => { + self.semantic.pending.insert( + request_id, + PendingSemanticRequest { + response_id, + request, }, - )); - } - crate::session::RequestAdmission::SessionUnavailable => { - let _ = self.enqueue_value(error_response( - response_id, - RpcFailure::request_failure(LspRequestFailure::ServerCancelled { - retrigger_request: true, - }), - )); - } - crate::session::RequestAdmission::Saturated { retrigger_request } => { - let _ = self.enqueue_value(error_response( - response_id, - RpcFailure::request_failure(LspRequestFailure::ServerCancelled { - retrigger_request, - }), - )); + ); } + result => self.finish_admitted_request(request_id, response_id, result), } } @@ -187,7 +126,22 @@ where } } - pub(super) fn complete_semantic_request( + pub(super) fn poll_semantic_requests(&mut self) { + let request_ids = self.semantic.pending.keys().cloned().collect::>(); + for request_id in request_ids { + let Some(pending) = self.semantic.pending.get(&request_id).cloned() else { + continue; + }; + let result = self.semantic_request_value(&request_id, &pending.request); + if matches!(result, Ok(None)) { + continue; + } + self.semantic.pending.remove(&request_id); + self.finish_admitted_request(request_id, pending.response_id, result); + } + } + + pub(super) fn finish_admitted_request( &mut self, request_id: LspRequestId, response_id: Value, @@ -211,19 +165,23 @@ where } } } +} - pub(super) fn poll_semantic_requests(&mut self) { - let request_ids = self.semantic.pending.keys().cloned().collect::>(); - for request_id in request_ids { - let Some(pending) = self.semantic.pending.get(&request_id).cloned() else { - continue; - }; - let result = self.semantic_request_value(&request_id, &pending.request); - if matches!(result, Ok(None)) { - continue; - } - self.semantic.pending.remove(&request_id); - self.complete_semantic_request(request_id, pending.response_id, result); - } +pub(super) fn admission_refusal(admission: RequestAdmission) -> Option { + match admission { + RequestAdmission::Accepted => None, + RequestAdmission::DuplicateId => Some(RpcFailure { + code: -32600, + message: "Invalid Request", + data: json!({ "detail": "duplicate request id" }), + }), + RequestAdmission::SessionUnavailable => Some(RpcFailure::request_failure( + LspRequestFailure::ServerCancelled { + retrigger_request: true, + }, + )), + RequestAdmission::Saturated { retrigger_request } => Some(RpcFailure::request_failure( + LspRequestFailure::ServerCancelled { retrigger_request }, + )), } } diff --git a/crates/tracedecay-lsp/src/protocol/tests/workspace_diagnostics.rs b/crates/tracedecay-lsp/src/protocol/tests/workspace_diagnostics.rs index 934d60d393..2750bb485e 100644 --- a/crates/tracedecay-lsp/src/protocol/tests/workspace_diagnostics.rs +++ b/crates/tracedecay-lsp/src/protocol/tests/workspace_diagnostics.rs @@ -1,9 +1,7 @@ use super::*; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn initialize_workspace( roots: Vec, diff --git a/crates/tracedecay-maintenance/Cargo.toml b/crates/tracedecay-maintenance/Cargo.toml index 13f8363ad3..19a5bd6983 100644 --- a/crates/tracedecay-maintenance/Cargo.toml +++ b/crates/tracedecay-maintenance/Cargo.toml @@ -41,12 +41,8 @@ tracedecay-store = { path = "../tracedecay-store", version = "0.1.0" } tracedecay-store-runtime = { path = "../tracedecay-store-runtime", version = "0.1.0" } tracedecay-tool-catalog = { path = "../tracedecay-tool-catalog", version = "0.1.0" } -[target.'cfg(any(all(target_os = "linux", target_env = "gnu"), target_os = "macos"))'.dependencies] -libc = "0.2" - [dev-dependencies] filetime = "0.2" -tempfile = "3" tokio = { version = "1", features = ["full", "test-util"] } tracedecay-global-db = { path = "../tracedecay-global-db", version = "0.1.0", features = ["test-helpers"] } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0", features = ["test-helpers"] } diff --git a/crates/tracedecay-maintenance/src/retention/diagnostics.rs b/crates/tracedecay-maintenance/src/retention/diagnostics.rs index f6e2ccabfc..f4ca8f20f1 100644 --- a/crates/tracedecay-maintenance/src/retention/diagnostics.rs +++ b/crates/tracedecay-maintenance/src/retention/diagnostics.rs @@ -257,19 +257,14 @@ fn permits_synchronous_exhaustive_scan(root: &Path) -> bool { } fn bounded_statement(statement: &str) -> String { - let cleaned: String = statement - .chars() - .map(|ch| if ch.is_control() { ' ' } else { ch }) - .collect(); + let cleaned = tracedecay_domain::fold_control_characters(statement); let cleaned = cleaned.trim(); if cleaned.len() <= DOCTOR_TEXT_LIMIT { return cleaned.to_string(); } - let mut end = DOCTOR_TEXT_LIMIT; - while end > 0 && !cleaned.is_char_boundary(end) { - end -= 1; - } - cleaned[..end].trim().to_string() + tracedecay_domain::utf8_prefix_at_or_before(cleaned, DOCTOR_TEXT_LIMIT) + .trim() + .to_string() } fn orphan_store_doctor_finding(finding: &OrphanStoreFinding) -> Option { diff --git a/crates/tracedecay-maintenance/src/retention/orphan_stores.rs b/crates/tracedecay-maintenance/src/retention/orphan_stores.rs index 817a26cdb2..e374c02427 100644 --- a/crates/tracedecay-maintenance/src/retention/orphan_stores.rs +++ b/crates/tracedecay-maintenance/src/retention/orphan_stores.rs @@ -240,15 +240,6 @@ pub struct CollectionPlan { pub unverifiable: Vec, } -impl CollectionPlan { - /// Total bytes that collecting [`Self::collect`] would reclaim. - pub fn collectable_bytes(&self) -> u64 { - self.collect - .iter() - .fold(0u64, |acc, f| acc.saturating_add(f.size_bytes)) - } -} - /// Partition findings under a retention window. Live stores are dropped from /// the plan entirely, they are never a retention concern. Pure. pub fn plan_collection(findings: Vec, retention_secs: i64) -> CollectionPlan { diff --git a/crates/tracedecay-maintenance/src/retention/orphan_stores/pages.rs b/crates/tracedecay-maintenance/src/retention/orphan_stores/pages.rs index e6b2b1fa04..328395e94b 100644 --- a/crates/tracedecay-maintenance/src/retention/orphan_stores/pages.rs +++ b/crates/tracedecay-maintenance/src/retention/orphan_stores/pages.rs @@ -586,15 +586,6 @@ pub struct UnregisteredCollectionPlan { pub retained_immature: Vec, } -impl UnregisteredCollectionPlan { - /// Total bytes that collecting [`Self::collect`] would reclaim. - pub fn collectable_bytes(&self) -> u64 { - self.collect - .iter() - .fold(0u64, |acc, f| acc.saturating_add(f.size_bytes)) - } -} - /// Partition findings under a retention window. Pure. pub fn plan_unregistered_collection( findings: Vec, diff --git a/crates/tracedecay-mcp-catalog/src/definitions.rs b/crates/tracedecay-mcp-catalog/src/definitions.rs index 2f83213e38..e5c28ca1d8 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions.rs @@ -12,7 +12,10 @@ use serde_json::{Value, json}; use std::collections::BTreeSet; use std::sync::LazyLock; -use tracedecay_tool_catalog::{ApplicationSurfaceOperation, ScopeDimension}; +use tracedecay_tool_catalog::{ + ApplicationSurfaceOperation, CatalogValidationError, ExecutableBindingRegistryV1, OperationId, + ScopeDimension, +}; use crate::McpCatalogError; use crate::ToolDefinition; @@ -25,7 +28,6 @@ mod application_schema; pub mod ast_grep; mod edit; mod git; -mod git_scope; mod graph; mod lcm; mod memory; @@ -47,7 +49,6 @@ use git::*; use graph::*; pub use graph::{SEARCH_MAX_LEXICAL_ANCHOR_BYTES, SEARCH_MAX_LEXICAL_ANCHORS}; use lcm::*; -use memory::*; use multi_root::*; use skills::*; use testing::*; @@ -454,20 +455,9 @@ fn build_maximal_tool_definitions() -> Result, McpCatalogErr def_diagnose(), def_derives(), def_run_affected_tests(), - def_fact_store_add(request_schema("fact_store_add")?), - def_fact_store_search(request_schema("fact_store_search")?), - def_fact_store_probe(request_schema("fact_store_probe")?), - def_fact_store_related(request_schema("fact_store_related")?), - def_fact_store_reason(request_schema("fact_store_reason")?), - def_fact_store_contradict(request_schema("fact_store_contradict")?), - def_fact_store_get(request_schema("fact_store_get")?), - def_fact_store_update(request_schema("fact_store_update")?), - def_fact_store_remove(request_schema("fact_store_remove")?), - def_fact_store_supersede(request_schema("fact_store_supersede")?), - def_fact_store_list(request_schema("fact_store_list")?), - def_fact_feedback(request_schema("fact_feedback")?), - def_memory_status(request_schema("memory_status")?), - def_fact_store_curate(request_schema("fact_store_curate")?), + ]; + definitions.extend(memory::memory_definitions(&request_schema)?); + definitions.extend([ def_automation_run_list(), def_automation_run_view(), def_automation_run_artifact_view(), @@ -504,7 +494,7 @@ fn build_maximal_tool_definitions() -> Result, McpCatalogErr def_source_edit_reconcile(), def_source_edit_rollback(), def_find_exact_symbol(), - ]; + ]); definitions.extend(application_definitions()?); let work = work_worker.join().map_err(|_| { McpCatalogError::Initialization( @@ -527,6 +517,52 @@ fn build_maximal_tool_definitions() -> Result, McpCatalogErr Ok(definitions) } +pub(super) struct FamilyOperation { + pub operation_id: String, + pub name: String, + pub title: String, + pub description: String, +} + +/// Project one executable registry into MCP tools. Callers own the transport +/// prefix, title, and description; the registry owns the schema and effect. +pub(super) fn project_executable_family( + registry: &ExecutableBindingRegistryV1, + operations: &[FamilyOperation], + incomplete: (&'static str, &'static str), + identity: (&'static str, &'static str), + missing: (&'static str, &'static str), +) -> Result, McpCatalogError> { + if registry.iter().count() != operations.len() { + return Err(catalog_invalid(incomplete.0, incomplete.1)); + } + operations + .iter() + .map(|operation| { + let operation_id = OperationId::new(operation.operation_id.clone()) + .map_err(|_| catalog_invalid(identity.0, identity.1))?; + let binding = registry + .get(&operation_id) + .and_then(|availability| availability.binding()) + .ok_or_else(|| catalog_invalid(missing.0, missing.1))?; + Ok(ToolDefinition { + name: operation.name.clone(), + description: operation.description.clone(), + input_schema: binding.request_schema().body().clone(), + annotations: Some(json!({ + "readOnlyHint": binding.effect().is_read_only(), + "title": operation.title, + })), + meta: None, + }) + }) + .collect() +} + +fn catalog_invalid(field: &'static str, reason: &'static str) -> McpCatalogError { + CatalogValidationError::InvalidValue { field, reason }.into() +} + fn spawn_definition_worker( name: &'static str, worker: impl FnOnce() -> Result, McpCatalogError> + Send + 'static, diff --git a/crates/tracedecay-mcp-catalog/src/definitions/git_scope.rs b/crates/tracedecay-mcp-catalog/src/definitions/git_scope.rs deleted file mode 100644 index 6a8ccc6b98..0000000000 --- a/crates/tracedecay-mcp-catalog/src/definitions/git_scope.rs +++ /dev/null @@ -1,22 +0,0 @@ -use serde_json::{Value, json}; - -pub(super) fn branch_schema(description: &str) -> Value { - json!({ - "type": "string", - "description": description - }) -} - -pub(super) fn worktree_schema(description: &str) -> Value { - json!({ - "type": "string", - "description": description - }) -} - -pub(super) fn commit_schema(description: &str) -> Value { - json!({ - "type": "string", - "description": description - }) -} diff --git a/crates/tracedecay-mcp-catalog/src/definitions/lcm.rs b/crates/tracedecay-mcp-catalog/src/definitions/lcm.rs index fe46ce79fa..f20b384c41 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions/lcm.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions/lcm.rs @@ -1,8 +1,11 @@ //! LCM session-store and session health-baseline tool definitions. use serde_json::json; +use tracedecay_contracts::retained_surfaces::{ + LcmRoleV1, MessageRelationshipScopeV1, MessageTypeFilterV1, +}; -use super::{def, git_scope}; +use super::{def, string_property}; use crate::ToolDefinition; pub(super) fn def_lcm_status() -> ToolDefinition { @@ -139,12 +142,12 @@ pub(super) fn def_lcm_grep() -> ToolDefinition { }, "relationship_scope": { "type": "string", - "enum": ["all", "parents_only", "subagents_only"], + "enum": MessageRelationshipScopeV1::WIRE, "description": "Optional parent/subagent relationship filter across sessions. Default: all." }, "message_type": { "type": "string", - "enum": ["all", "direct_user", "tool_result"], + "enum": MessageTypeFilterV1::WIRE, "description": "Semantic raw-message filter. direct_user excludes provider-mislabeled tool results; tool_result recognizes role, kind, and tool-event metadata. Default: all." }, "session_id": { @@ -168,7 +171,7 @@ pub(super) fn def_lcm_grep() -> ToolDefinition { }, "role": { "type": "string", - "enum": ["system", "user", "assistant", "tool", "unknown"], + "enum": LcmRoleV1::WIRE, "description": "Optional raw-message role filter. When supplied, summary results are omitted." }, "start_time": { @@ -220,9 +223,9 @@ pub(super) fn def_lcm_grep() -> ToolDefinition { "minimum": 0, "description": "Required cutoff in UTC microseconds when temporal_mode=as_of." }, - "branch": git_scope::branch_schema("Optional git branch filter: only LCM snippets from sessions active on this branch (via the session-git correlation index)."), - "worktree": git_scope::worktree_schema("Optional git worktree root path filter: only LCM snippets from sessions active in this worktree (via the session-git correlation index)."), - "commit": git_scope::commit_schema("Optional commit sha filter (full or >=6-char hex prefix): only LCM snippets from sessions attributed to this commit (via the session-git correlation index).") + "branch": string_property("Optional git branch filter: only LCM snippets from sessions active on this branch (via the session-git correlation index)."), + "worktree": string_property("Optional git worktree root path filter: only LCM snippets from sessions active in this worktree (via the session-git correlation index)."), + "commit": string_property("Optional commit sha filter (full or >=6-char hex prefix): only LCM snippets from sessions attributed to this commit (via the session-git correlation index).") }, "required": ["query"] }), diff --git a/crates/tracedecay-mcp-catalog/src/definitions/memory.rs b/crates/tracedecay-mcp-catalog/src/definitions/memory.rs index aec86d0ab0..36eac3be9c 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions/memory.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions/memory.rs @@ -3,130 +3,115 @@ use serde_json::Value; use super::{def, def_rw}; -use crate::ToolDefinition; +use crate::{McpCatalogError, ToolDefinition}; -pub(super) fn def_fact_store_add(input_schema: Value) -> ToolDefinition { - def_rw( - "tracedecay_fact_store_add", - "Fact Store Add", - "Add one holographic memory fact. The result includes a write-time diff report for near duplicates, possible conflicts, and rejected secret-like content. Calibrate trust to the evidence instead of defaulting high.", - input_schema, - ) +struct MemoryTool { + operation: &'static str, + title: &'static str, + description: &'static str, + write: bool, } -pub(super) fn def_fact_store_curate(input_schema: Value) -> ToolDefinition { - def_rw( - "tracedecay_fact_store_curate", - "Fact Store Curate", - "Run the daemon-owned automatic Memory Curator. Callers may bound review size and confidence only; TraceDecay derives the run, operations, validation, policy, and apply authority. Inspect the durable terminal with the read-only automation run tools.", - input_schema, - ) -} - -pub(super) fn def_fact_store_search(input_schema: Value) -> ToolDefinition { - def( - "tracedecay_fact_store_search", - "Fact Store Search", - "Search durable project or user memory facts by text and trust.", - input_schema, - ) -} - -pub(super) fn def_fact_store_probe(input_schema: Value) -> ToolDefinition { - def( - "tracedecay_fact_store_probe", - "Fact Store Probe", - "Find holographic memory facts connected to one entity.", - input_schema, - ) -} - -pub(super) fn def_fact_store_related(input_schema: Value) -> ToolDefinition { - def( - "tracedecay_fact_store_related", - "Fact Store Related", - "List entities related to one entity through holographic memory facts.", - input_schema, - ) -} - -pub(super) fn def_fact_store_reason(input_schema: Value) -> ToolDefinition { - def( - "tracedecay_fact_store_reason", - "Fact Store Reason", - "Reason over holographic memory facts connecting multiple entities.", - input_schema, - ) -} - -pub(super) fn def_fact_store_contradict(input_schema: Value) -> ToolDefinition { - def( - "tracedecay_fact_store_contradict", - "Fact Store Contradict", - "Find potentially contradictory holographic memory facts above an optional threshold.", - input_schema, - ) -} - -pub(super) fn def_fact_store_get(input_schema: Value) -> ToolDefinition { - def( - "tracedecay_fact_store_get", - "Fact Store Get", - "Get one holographic memory fact, including trust history explaining score changes.", - input_schema, - ) -} - -pub(super) fn def_fact_store_update(input_schema: Value) -> ToolDefinition { - def_rw( - "tracedecay_fact_store_update", - "Fact Store Update", - "Update one existing holographic memory fact without changing its identity.", - input_schema, - ) -} - -pub(super) fn def_fact_store_remove(input_schema: Value) -> ToolDefinition { - def_rw( - "tracedecay_fact_store_remove", - "Fact Store Remove", - "Remove one holographic memory fact by exact fact id.", - input_schema, - ) -} - -pub(super) fn def_fact_store_supersede(input_schema: Value) -> ToolDefinition { - def_rw( - "tracedecay_fact_store_supersede", - "Fact Store Supersede", - "Mark one holographic memory fact as superseded by another fact id. The old fact leaves default list/search/probe results but stays readable by id through its history; payload and trust are untouched. Use this when a newer fact corrects an older one instead of removing the old one.", - input_schema, - ) -} - -pub(super) fn def_fact_store_list(input_schema: Value) -> ToolDefinition { - def( - "tracedecay_fact_store_list", - "Fact Store List", - "List holographic memory facts with optional category, trust, and project selectors.", - input_schema, - ) -} - -pub(super) fn def_fact_feedback(input_schema: Value) -> ToolDefinition { - def_rw( - "tracedecay_fact_feedback", - "Fact Feedback", - "Record whether an active-project memory fact materially helped or misled the current work, and adjust its trust score.", - input_schema, - ) -} - -pub(super) fn def_memory_status(input_schema: Value) -> ToolDefinition { - def( - "tracedecay_memory_status", - "Memory Status", - "Inspect canonical memory state: return the owner, fact/entity counts, algebra identity and capacity, trust distribution, below-threshold facts, feedback totals, and retrieval funnel. Defaults to the active project; pass an exact project_selector only when intentionally checking another registered project. Human/operator equivalent: `tracedecay memory status`.", - input_schema, - ) +const MEMORY_TOOLS: &[MemoryTool] = &[ + MemoryTool { + operation: "fact_store_add", + title: "Fact Store Add", + description: "Add one holographic memory fact. The result includes a write-time diff report for near duplicates, possible conflicts, and rejected secret-like content. Calibrate trust to the evidence instead of defaulting high.", + write: true, + }, + MemoryTool { + operation: "fact_store_search", + title: "Fact Store Search", + description: "Search durable project or user memory facts by text and trust.", + write: false, + }, + MemoryTool { + operation: "fact_store_probe", + title: "Fact Store Probe", + description: "Find holographic memory facts connected to one entity.", + write: false, + }, + MemoryTool { + operation: "fact_store_related", + title: "Fact Store Related", + description: "List entities related to one entity through holographic memory facts.", + write: false, + }, + MemoryTool { + operation: "fact_store_reason", + title: "Fact Store Reason", + description: "Reason over holographic memory facts connecting multiple entities.", + write: false, + }, + MemoryTool { + operation: "fact_store_contradict", + title: "Fact Store Contradict", + description: "Find potentially contradictory holographic memory facts above an optional threshold.", + write: false, + }, + MemoryTool { + operation: "fact_store_get", + title: "Fact Store Get", + description: "Get one holographic memory fact, including trust history explaining score changes.", + write: false, + }, + MemoryTool { + operation: "fact_store_update", + title: "Fact Store Update", + description: "Update one existing holographic memory fact without changing its identity.", + write: true, + }, + MemoryTool { + operation: "fact_store_remove", + title: "Fact Store Remove", + description: "Remove one holographic memory fact by exact fact id.", + write: true, + }, + MemoryTool { + operation: "fact_store_supersede", + title: "Fact Store Supersede", + description: "Mark one holographic memory fact as superseded by another fact id. The old fact leaves default list/search/probe results but stays readable by id through its history; payload and trust are untouched. Use this when a newer fact corrects an older one instead of removing the old one.", + write: true, + }, + MemoryTool { + operation: "fact_store_list", + title: "Fact Store List", + description: "List holographic memory facts with optional category, trust, and project selectors.", + write: false, + }, + MemoryTool { + operation: "fact_feedback", + title: "Fact Feedback", + description: "Record whether an active-project memory fact materially helped or misled the current work, and adjust its trust score.", + write: true, + }, + MemoryTool { + operation: "memory_status", + title: "Memory Status", + description: "Inspect canonical memory state: return the owner, fact/entity counts, algebra identity and capacity, trust distribution, below-threshold facts, feedback totals, and retrieval funnel. Defaults to the active project; pass an exact project_selector only when intentionally checking another registered project. Human/operator equivalent: `tracedecay memory status`.", + write: false, + }, + MemoryTool { + operation: "fact_store_curate", + title: "Fact Store Curate", + description: "Run the daemon-owned automatic Memory Curator. Callers may bound review size and confidence only; TraceDecay derives the run, operations, validation, policy, and apply authority. Inspect the durable terminal with the read-only automation run tools.", + write: true, + }, +]; + +pub(super) fn memory_definitions( + mut schema: impl FnMut(&'static str) -> Result, +) -> Result, McpCatalogError> { + MEMORY_TOOLS + .iter() + .map(|tool| { + let name = format!("tracedecay_{}", tool.operation); + let input_schema = schema(tool.operation)?; + Ok(if tool.write { + def_rw(&name, tool.title, tool.description, input_schema) + } else { + def(&name, tool.title, tool.description, input_schema) + }) + }) + .collect() } diff --git a/crates/tracedecay-mcp-catalog/src/definitions/session.rs b/crates/tracedecay-mcp-catalog/src/definitions/session.rs index 94691f2430..a9762d3b19 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions/session.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions/session.rs @@ -1,6 +1,7 @@ use serde_json::{Value, json}; +use tracedecay_contracts::retained_surfaces::{MessageRelationshipScopeV1, MessageTypeFilterV1}; -use super::{def, def_rw, git_scope, project_selector_object}; +use super::{def, def_rw, project_selector_object, string_property}; use crate::ToolDefinition; pub(super) fn def_session_refresh_begin(input_schema: Value) -> ToolDefinition { @@ -85,13 +86,13 @@ pub(super) fn def_message_search() -> ToolDefinition { "type": "string", "default": "all", "description": "Relationship scope for search results (default: all).", - "enum": ["all", "parents_only", "subagents_only"] + "enum": MessageRelationshipScopeV1::WIRE, }, "message_type": { "type": "string", "default": "all", "description": "Semantic message filter. direct_user excludes provider-mislabeled tool results; tool_result includes role-, kind-, and metadata-identified tool output. Default: all.", - "enum": ["all", "direct_user", "tool_result"] + "enum": MessageTypeFilterV1::WIRE, }, "limit": { "type": "integer", @@ -108,9 +109,9 @@ pub(super) fn def_message_search() -> ToolDefinition { "description": "all_registered fans the search out over every registered project's durable session store (bounded, deterministic merge, per-root provenance). Cannot be combined with project_selector, cursor, or catch_up.", "enum": ["all_registered"] }, - "branch": git_scope::branch_schema("Optional git branch filter: only messages from sessions active on this branch (via the session-git correlation index)."), - "worktree": git_scope::worktree_schema("Optional git worktree root path filter: only messages from sessions active in this worktree (via the session-git correlation index)."), - "commit": git_scope::commit_schema("Optional commit sha filter (full or >=6-char hex prefix): only messages from sessions attributed to this commit (via the session-git correlation index)."), + "branch": string_property("Optional git branch filter: only messages from sessions active on this branch (via the session-git correlation index)."), + "worktree": string_property("Optional git worktree root path filter: only messages from sessions active in this worktree (via the session-git correlation index)."), + "commit": string_property("Optional commit sha filter (full or >=6-char hex prefix): only messages from sessions attributed to this commit (via the session-git correlation index)."), "workflow_run": workflow_run_scope_schema(), "workflow_agent": workflow_agent_scope_schema(), "format": { @@ -187,9 +188,9 @@ pub(super) fn def_workflows() -> ToolDefinition { "type": "string", "description": "With run_id, drill into a single agent of that run by its label (e.g. 'mine:claude-transcripts')." }, - "branch": git_scope::branch_schema("List workflow runs whose parent session was active on this git branch (via the session-git correlation index)."), - "worktree": git_scope::worktree_schema("List workflow runs whose parent session was active in this git worktree root path."), - "commit": git_scope::commit_schema("List workflow runs whose parent session was attributed to this commit sha (full or >=6-char hex prefix)."), + "branch": string_property("List workflow runs whose parent session was active on this git branch (via the session-git correlation index)."), + "worktree": string_property("List workflow runs whose parent session was active in this git worktree root path."), + "commit": string_property("List workflow runs whose parent session was attributed to this commit sha (full or >=6-char hex prefix)."), "limit": { "type": "integer", "minimum": 1, diff --git a/crates/tracedecay-mcp-catalog/src/definitions/work.rs b/crates/tracedecay-mcp-catalog/src/definitions/work.rs index 5faa25758c..8353679043 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions/work.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions/work.rs @@ -3,10 +3,9 @@ //! The executable owns Work's operation set, request schemas, effects, and //! availability. MCP contributes only its transport prefix and presentation. -use serde_json::json; use tracedecay_api::WorkOperation; -use tracedecay_tool_catalog::{CatalogValidationError, OperationId}; +use super::{FamilyOperation, project_executable_family}; use crate::ToolDefinition; type DiscoveryResult = Result; @@ -21,46 +20,31 @@ type DiscoveryResult = Result; pub(super) fn work_definitions() -> DiscoveryResult> { let registry = tracedecay_contracts::work_executable_binding_registry() .map_err(crate::McpCatalogError::CatalogValidation)?; - if registry.iter().count() != WorkOperation::ALL.len() { - return Err(invalid_work_discovery( - "MCP Work executable registry", - "must expose exactly every canonical Work operation", - )); - } - WorkOperation::ALL + let operations = WorkOperation::ALL .into_iter() - .map(|operation| { - let operation_id = OperationId::new(operation.operation_id()).map_err(|_| { - invalid_work_discovery( - "MCP Work operation identity", - "must name one canonical Work operation", - ) - })?; - let binding = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - .ok_or_else(|| { - invalid_work_discovery( - "MCP Work executable binding", - "canonical Work operation is not executable", - ) - })?; - Ok(ToolDefinition { - name: format!("tracedecay_work_{}", operation.operation_key()), - description: operation.description().to_owned(), - input_schema: binding.request_schema().body().clone(), - annotations: Some(json!({ - "readOnlyHint": binding.effect().is_read_only(), - "title": format!("Work {}", operation.operation_key()), - })), - meta: None, - }) + .map(|operation| FamilyOperation { + operation_id: operation.operation_id(), + name: format!("tracedecay_work_{}", operation.operation_key()), + title: format!("Work {}", operation.operation_key()), + description: operation.description().to_owned(), }) - .collect() -} - -fn invalid_work_discovery(field: &'static str, reason: &'static str) -> crate::McpCatalogError { - CatalogValidationError::InvalidValue { field, reason }.into() + .collect::>(); + project_executable_family( + registry, + &operations, + ( + "MCP Work executable registry", + "must expose exactly every canonical Work operation", + ), + ( + "MCP Work operation identity", + "must name one canonical Work operation", + ), + ( + "MCP Work executable binding", + "canonical Work operation is not executable", + ), + ) } #[cfg(test)] diff --git a/crates/tracedecay-mcp-catalog/src/definitions/workflow.rs b/crates/tracedecay-mcp-catalog/src/definitions/workflow.rs index 4cd41186a8..54db627769 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions/workflow.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions/workflow.rs @@ -1,14 +1,12 @@ //! MCP discovery projected from the canonical Workflow executable registry. //! //! The executable owns Workflow's operation set, request schemas, effects, and -//! availability. MCP contributes only its transport prefix and presentation. -//! the same division the Work family holds, so the two closed families cannot -//! drift into two different discovery stories. +//! availability. MCP contributes only its transport prefix and presentation, +//! the same division the Work family holds. -use serde_json::json; use tracedecay_api::WorkflowOperation; -use tracedecay_tool_catalog::{CatalogValidationError, OperationId}; +use super::{FamilyOperation, project_executable_family}; use crate::ToolDefinition; type DiscoveryResult = Result; @@ -17,55 +15,38 @@ type DiscoveryResult = Result; /// /// Discovery fails loudly if the registry is incomplete rather than silently /// omitting a callable operation: a Workflow operation that no adapter -/// publishes is invisible to every agent, which is exactly how all sixteen of -/// them stayed off MCP while CLI and HTTP carried them. +/// publishes is invisible to every agent. pub(super) fn workflow_definitions() -> DiscoveryResult> { let registry = tracedecay_contracts::workflow_executable_binding_registry() .map_err(crate::McpCatalogError::CatalogValidation)?; - if registry.iter().count() != WorkflowOperation::ALL.len() { - return Err(invalid_workflow_discovery( - "MCP Workflow executable registry", - "must expose exactly every canonical Workflow operation", - )); - } - WorkflowOperation::ALL + let operations = WorkflowOperation::ALL .into_iter() .map(|operation| { - let operation_id = - OperationId::new(operation.operation_id_str().to_owned()).map_err(|_| { - invalid_workflow_discovery( - "MCP Workflow operation identity", - "must name one canonical Workflow operation", - ) - })?; - let binding = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - .ok_or_else(|| { - invalid_workflow_discovery( - "MCP Workflow executable binding", - "canonical Workflow operation is not executable", - ) - })?; - Ok(ToolDefinition { - name: format!("tracedecay_workflow_{}", operation.operation_key()), - description: format!( - "Invoke the Workflow {} operation.", - operation.operation_key() - ), - input_schema: binding.request_schema().body().clone(), - annotations: Some(json!({ - "readOnlyHint": binding.effect().is_read_only(), - "title": format!("Workflow {}", operation.operation_key()), - })), - meta: None, - }) + let key = operation.operation_key(); + FamilyOperation { + operation_id: operation.operation_id_str().to_owned(), + name: format!("tracedecay_workflow_{key}"), + title: format!("Workflow {key}"), + description: format!("Invoke the Workflow {key} operation."), + } }) - .collect() -} - -fn invalid_workflow_discovery(field: &'static str, reason: &'static str) -> crate::McpCatalogError { - CatalogValidationError::InvalidValue { field, reason }.into() + .collect::>(); + project_executable_family( + registry, + &operations, + ( + "MCP Workflow executable registry", + "must expose exactly every canonical Workflow operation", + ), + ( + "MCP Workflow operation identity", + "must name one canonical Workflow operation", + ), + ( + "MCP Workflow executable binding", + "canonical Workflow operation is not executable", + ), + ) } #[cfg(test)] diff --git a/crates/tracedecay-mcp/Cargo.toml b/crates/tracedecay-mcp/Cargo.toml index 27b87249a7..172abdf15c 100644 --- a/crates/tracedecay-mcp/Cargo.toml +++ b/crates/tracedecay-mcp/Cargo.toml @@ -41,7 +41,6 @@ hotpath.workspace = true libc = "0.2" rmcp = { version = "3.0.1", default-features = false, features = ["server"] } # The multi-root execute tool derives its input schema from the contracts DTO. -schemars = "1.2.1" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" diff --git a/crates/tracedecay-mcp/src/handlers/analysis/mod.rs b/crates/tracedecay-mcp/src/handlers/analysis/mod.rs index ea03ff9330..b35c5c5644 100644 --- a/crates/tracedecay-mcp/src/handlers/analysis/mod.rs +++ b/crates/tracedecay-mcp/src/handlers/analysis/mod.rs @@ -54,7 +54,7 @@ fn path_is_rust(path: &str) -> bool { } fn path_matches_optional_scope(path: &str, scope_prefix: Option<&str>) -> bool { - tracedecay_runtime_core::path_scope::path_matches_scope(path, scope_prefix) + tracedecay_domain::path_matches_scope(path, scope_prefix) } const ANALYSIS_SYMBOL_BUDGET: usize = 500_000; diff --git a/crates/tracedecay-mcp/src/handlers/analysis/unmounted_files.rs b/crates/tracedecay-mcp/src/handlers/analysis/unmounted_files.rs index e0e797d46a..ee9668ea03 100644 --- a/crates/tracedecay-mcp/src/handlers/analysis/unmounted_files.rs +++ b/crates/tracedecay-mcp/src/handlers/analysis/unmounted_files.rs @@ -75,10 +75,7 @@ pub async fn handle_unmounted_files( .map(move |entry| (ecosystem.ecosystem, entry)) }) .filter(|(_, entry)| { - tracedecay_runtime_core::path_scope::path_matches_scope( - &entry.file, - path_filter.as_deref(), - ) + tracedecay_domain::path_matches_scope(&entry.file, path_filter.as_deref()) }) .collect::>(); let unmounted_file_count = matching.len(); diff --git a/crates/tracedecay-mcp/src/handlers/dependency_hints.rs b/crates/tracedecay-mcp/src/handlers/dependency_hints.rs index 8a3670a6e8..5332cb4889 100644 --- a/crates/tracedecay-mcp/src/handlers/dependency_hints.rs +++ b/crates/tracedecay-mcp/src/handlers/dependency_hints.rs @@ -11,6 +11,7 @@ use tracedecay_contracts::retrieval::{ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_graph_query::VerifiedGraphQuery; +use crate::handlers::graph::user_line; use crate::tool_context::McpToolContext; use crate::tools::render::{self, Md}; @@ -227,7 +228,3 @@ pub fn append_external_import_hint_md(md: &mut Md, value: &Value) { } } } - -fn user_line(line: u32) -> u32 { - line.saturating_add(1) -} diff --git a/crates/tracedecay-mcp/src/handlers/edit.rs b/crates/tracedecay-mcp/src/handlers/edit.rs index 68ffab992c..fd83432c97 100644 --- a/crates/tracedecay-mcp/src/handlers/edit.rs +++ b/crates/tracedecay-mcp/src/handlers/edit.rs @@ -682,8 +682,7 @@ mod tests { }; use tracedecay_contracts::{ ApplicationInvocation, ApplicationInvocationExecutor, ApplicationInvocationFuture, - ApplicationProblem, ApplicationResponse, InvocationError, LegalAction, RetryDirective, - SafeDiagnostic, + ApplicationProblem, ApplicationResponse, InvocationError, RetryDirective, SafeDiagnostic, }; use tracedecay_daemon_protocol::{ DaemonInvocationError, DaemonInvocationExecutorFuture, DaemonInvocationPayload, @@ -1025,15 +1024,10 @@ mod tests { #[tokio::test] async fn kernel_conflict_reaches_mcp_with_reason_code_and_retryability() { let error = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { - problem: ApplicationProblem::Conflict { - diagnostic: SafeDiagnostic::new( - "source_edit.idempotency_conflict", - "source edit idempotency key conflicts with a prior input", - ) - .unwrap(), - retry: RetryDirective::AfterRevalidate, - legal_actions: vec![LegalAction::Refresh], - }, + problem: ApplicationProblem::conflict( + "source_edit.idempotency_conflict", + "source edit idempotency key conflicts with a prior input", + ), }) .await; let (reason_code, retryable, _) = error diff --git a/crates/tracedecay-mcp/src/handlers/git/context.rs b/crates/tracedecay-mcp/src/handlers/git/context.rs index fde95a605f..6f279458ad 100644 --- a/crates/tracedecay-mcp/src/handlers/git/context.rs +++ b/crates/tracedecay-mcp/src/handlers/git/context.rs @@ -888,7 +888,7 @@ impl PrContextControls { } fn elapsed_micros(started: std::time::Instant) -> u64 { - u64::try_from(started.elapsed().as_micros()).map_or(u64::MAX, |value| value) + tracedecay_runtime_core::tracedecay::saturating_duration_micros(started.elapsed()) } fn pr_context_impact_snapshot( diff --git a/crates/tracedecay-mcp/src/handlers/graph/context_support.rs b/crates/tracedecay-mcp/src/handlers/graph/context_support.rs index 9a38af6c96..62455f0ff2 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/context_support.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/context_support.rs @@ -11,6 +11,7 @@ use tracedecay_contracts::{ CancellationSignal, Deadline, now_micros, retained_surface_execution_problem, }; use tracedecay_domain::Confidence; +use tracedecay_domain::collapse_whitespace; use tracedecay_session_memory::memory::memory_application_error; use tracedecay_store::{ FactReadControl, ProjectMemoryFactSearchFilterV1, ProjectMemoryFactSearchKindV1, @@ -135,7 +136,7 @@ pub(super) fn context_memory_section( context_fact_category(hit.fact.category), f64::from(hit.fact.trust_score_millionths) / 1_000_000.0, f64::from(hit.scores.score_millionths) / 1_000_000.0, - compact_memory_content(&hit.fact.content) + collapse_whitespace(&hit.fact.content) ); } section.push('\n'); @@ -153,10 +154,6 @@ pub(super) fn context_memory_section( None } -fn compact_memory_content(content: &str) -> String { - content.split_whitespace().collect::>().join(" ") -} - const fn context_fact_category(category: FactCategoryV1) -> &'static str { match category { FactCategoryV1::General => "general", diff --git a/crates/tracedecay-mcp/src/handlers/graph/search.rs b/crates/tracedecay-mcp/src/handlers/graph/search.rs index e6738a5e2e..9768d9b8b3 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/search.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/search.rs @@ -49,7 +49,7 @@ use super::verified::CODE_SYMBOL_EVIDENCE_PREFIX; use super::{ graph_occurrence_id, graph_symbol_end_line, graph_symbol_paths, graph_symbols_in_scope, line_for_byte_offset, node_not_found as node_not_found_result, required_graph_file_path, - required_graph_metadata, single_graph_adjacency_batch, + required_graph_metadata, single_graph_adjacency_batch, user_line, }; use super::{lexical_routing, search_evidence}; @@ -130,10 +130,6 @@ fn coverage_value(coverage: &tracedecay_query::code_search::CodeIndexSearchCover }) } -fn user_line(line: u32) -> u32 { - line.saturating_add(1) -} - fn rendered_tool_result( ctx: &McpToolContext<'_>, args: &Value, diff --git a/crates/tracedecay-mcp/src/handlers/grep.rs b/crates/tracedecay-mcp/src/handlers/grep.rs index 6890ca89be..5f636878db 100644 --- a/crates/tracedecay-mcp/src/handlers/grep.rs +++ b/crates/tracedecay-mcp/src/handlers/grep.rs @@ -137,9 +137,7 @@ pub async fn handle_grep( .hits .into_iter() .map(GrepHit::from) - .filter(|hit| { - tracedecay_runtime_core::path_scope::path_matches_scope(hit.file.as_str(), scope_prefix) - }) + .filter(|hit| tracedecay_domain::path_matches_scope(hit.file.as_str(), scope_prefix)) .collect::>(); let truncated = scan.truncated || hits.len() > max_results; hits.truncate(max_results); diff --git a/crates/tracedecay-mcp/src/handlers/health/reports.rs b/crates/tracedecay-mcp/src/handlers/health/reports.rs index 2a187709df..2c6faee0b9 100644 --- a/crates/tracedecay-mcp/src/handlers/health/reports.rs +++ b/crates/tracedecay-mcp/src/handlers/health/reports.rs @@ -119,7 +119,7 @@ fn verified_gini_values( "verified Gini symbol is missing lineage metadata", ) })?; - if tracedecay_runtime_core::path_scope::path_matches_scope(path, path_prefix) { + if tracedecay_domain::path_matches_scope(path, path_prefix) { symbols.push((symbol.occurrence, path.clone(), metadata.clone())); } } diff --git a/crates/tracedecay-mcp/src/handlers/hook_runtime/context_scout.rs b/crates/tracedecay-mcp/src/handlers/hook_runtime/context_scout.rs index 6915b276dd..70b6fa2659 100644 --- a/crates/tracedecay-mcp/src/handlers/hook_runtime/context_scout.rs +++ b/crates/tracedecay-mcp/src/handlers/hook_runtime/context_scout.rs @@ -130,7 +130,7 @@ pub(super) async fn admit_native_context_scout_lifecycle( Err(_) => return false, }; let binding = sessions.binding(); - let authorities = HostAdmissionAuthorities::registered_for_project( + let authorities = HostAdmissionAuthorities::for_project( binding.shard_id.brain_id.clone(), binding.shard_id.profile_id.clone(), project_id, diff --git a/crates/tracedecay-mcp/src/handlers/hook_runtime/envelope.rs b/crates/tracedecay-mcp/src/handlers/hook_runtime/envelope.rs index dfacf3af8b..8ff918f0b7 100644 --- a/crates/tracedecay-mcp/src/handlers/hook_runtime/envelope.rs +++ b/crates/tracedecay-mcp/src/handlers/hook_runtime/envelope.rs @@ -1,18 +1,11 @@ use serde_json::Value; use sha2::{Digest, Sha256}; -use std::time::{SystemTime, UNIX_EPOCH}; use tracedecay_automation_runtime::automation::config_error; use tracedecay_domain::errors::Result; use tracedecay_domain::{ObservationSourceRangeV1, SessionId, UtcMicros}; pub(super) fn hook_now() -> UtcMicros { - UtcMicros( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(1, |duration| { - duration.as_micros().min(i64::MAX as u128) as i64 - }), - ) + tracedecay_runtime_core::tracedecay::utc_now_or_one() } pub(super) fn hook_v2_envelope( diff --git a/crates/tracedecay-mcp/src/handlers/hook_runtime/test_support.rs b/crates/tracedecay-mcp/src/handlers/hook_runtime/test_support.rs index 4ebc1b9c7d..43a43c5fdf 100644 --- a/crates/tracedecay-mcp/src/handlers/hook_runtime/test_support.rs +++ b/crates/tracedecay-mcp/src/handlers/hook_runtime/test_support.rs @@ -15,8 +15,7 @@ use tracedecay_contracts::{ }; use tracedecay_domain::feedback::{FeedbackContentIdentityV1, FeedbackScopeV1}; use tracedecay_domain::{ - CodeGenerationId, ComponentVersion, ManifestDigest, RefId, RetrievalAnchorId, TemporalModeV1, - UtcMicros, + CodeGenerationId, ComponentVersion, RefId, RetrievalAnchorId, TemporalModeV1, UtcMicros, }; pub(super) fn admission_test_envelope( @@ -75,9 +74,7 @@ pub(super) fn retained_claim(id: u8) -> ContextScoutDurableClaimV1 { T::try_from(value.to_owned()).unwrap() } - fn digest(character: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", character.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn evidence(id: u8) -> ContextScoutEvidenceEnvelopeV1 { let scope = ResolvedScope::new( diff --git a/crates/tracedecay-mcp/src/handlers/info/body.rs b/crates/tracedecay-mcp/src/handlers/info/body.rs index d31e3f97ab..d382ec63eb 100644 --- a/crates/tracedecay-mcp/src/handlers/info/body.rs +++ b/crates/tracedecay-mcp/src/handlers/info/body.rs @@ -173,9 +173,8 @@ fn body_candidates( for candidate in candidates { let path = required_file_path(&candidate)?; let metadata = required_metadata(&candidate)?; - if scope_prefix.is_none_or(|scope| { - tracedecay_runtime_core::path_scope::path_matches_scope(path, Some(scope)) - }) { + if scope_prefix.is_none_or(|scope| tracedecay_domain::path_matches_scope(path, Some(scope))) + { let preference = NodeKind::from_str(&metadata.kind) .map_or(u8::MAX, |kind| body_kind_preference(&kind)); scoped.push((preference, candidate)); diff --git a/crates/tracedecay-mcp/src/handlers/info/files.rs b/crates/tracedecay-mcp/src/handlers/info/files.rs index f4615e320e..07392a243c 100644 --- a/crates/tracedecay-mcp/src/handlers/info/files.rs +++ b/crates/tracedecay-mcp/src/handlers/info/files.rs @@ -25,12 +25,7 @@ pub async fn handle_files( .await?; if let Some(dir) = effective_path(&args, scope_prefix) { - let prefix = if dir.ends_with('/') { - dir.to_string() - } else { - format!("{dir}/") - }; - files.retain(|f| f.path.starts_with(&prefix) || f.path == dir); + files.retain(|f| tracedecay_domain::path_matches_scope(&f.path, Some(dir))); } if let Some(pat) = args.get("pattern").and_then(|v| v.as_str()) { diff --git a/crates/tracedecay-mcp/src/handlers/info/signature_search.rs b/crates/tracedecay-mcp/src/handlers/info/signature_search.rs index 2ca96ba7a9..09a275d327 100644 --- a/crates/tracedecay-mcp/src/handlers/info/signature_search.rs +++ b/crates/tracedecay-mcp/src/handlers/info/signature_search.rs @@ -52,7 +52,7 @@ pub async fn handle_signature_search( continue; } if let Some(prefix) = path_filter - && !tracedecay_runtime_core::path_scope::path_matches_scope(file_path, Some(prefix)) + && !tracedecay_domain::path_matches_scope(file_path, Some(prefix)) { continue; } diff --git a/crates/tracedecay-mcp/src/handlers/info/todos.rs b/crates/tracedecay-mcp/src/handlers/info/todos.rs index 3eb85fea94..c59d0a3ab1 100644 --- a/crates/tracedecay-mcp/src/handlers/info/todos.rs +++ b/crates/tracedecay-mcp/src/handlers/info/todos.rs @@ -106,7 +106,7 @@ pub async fn handle_todos( 'outer: for file in &files { if let Some(prefix) = path.as_deref() - && !tracedecay_runtime_core::path_scope::path_matches_scope(file, Some(prefix)) + && !tracedecay_domain::path_matches_scope(file, Some(prefix)) { continue; } diff --git a/crates/tracedecay-mcp/src/handlers/info/verified.rs b/crates/tracedecay-mcp/src/handlers/info/verified.rs index ae411effd9..9f5453bc07 100644 --- a/crates/tracedecay-mcp/src/handlers/info/verified.rs +++ b/crates/tracedecay-mcp/src/handlers/info/verified.rs @@ -83,14 +83,13 @@ pub(super) fn symbols_in_dir( directory: &str, kinds: &[NodeKind], ) -> Result> { + // Trim every trailing slash first. `repository_path_matches_scope` treats + // a leftover `/` as a literal character, so `src/` would miss `src/lib.rs`. let prefix = directory.trim_end_matches('/'); let mut selected = Vec::new(); for symbol in all_symbols(graph)? { let (metadata, path) = required_symbol_parts(&symbol)?; - let path_matches = path == prefix - || path - .strip_prefix(prefix) - .is_some_and(|rest| rest.starts_with('/')); + let path_matches = tracedecay_domain::repository_path_matches_scope(path, Some(prefix)); let kind_matches = NodeKind::from_str(&metadata.kind).is_some_and(|kind| kinds.contains(&kind)); if path_matches && kind_matches { diff --git a/crates/tracedecay-mcp/src/handlers/multi_root.rs b/crates/tracedecay-mcp/src/handlers/multi_root.rs index 0d4b842edf..17c64bb07a 100644 --- a/crates/tracedecay-mcp/src/handlers/multi_root.rs +++ b/crates/tracedecay-mcp/src/handlers/multi_root.rs @@ -5,15 +5,15 @@ use serde_json::{Value, json}; use tracedecay_contracts::multi_root::MultiRootApplicationOperation; use tracedecay_contracts::{ ApplicationEnvelope, ApplicationOutcome, ApplicationProblem, ApplicationProblemEnvelope, - CancellationSignal, Deadline, LegalAction, MultiRootExecuteRequestV1, - MultiRootScopeSetCasRequestV1, MultiRootScopeSetReadRequestV1, ProblemOwningLayer, RequestId, - ResultContractRef, RetryDirective, SafeDiagnostic, + CancellationSignal, Deadline, MultiRootExecuteRequestV1, MultiRootScopeSetCasRequestV1, + MultiRootScopeSetReadRequestV1, ProblemOwningLayer, RequestId, ResultContractRef, + RetryDirective, SafeDiagnostic, }; use tracedecay_domain::UtcMicros; use tracedecay_tool_catalog::{BindingId, SchemaId}; use crate::ToolResult; -use crate::handlers::support::unknown_tool_error; +use crate::handlers::support::{json_result, unknown_tool_error}; use tracedecay_contracts::request_identity::{GlobalRequestSurface, mint_global_request_id}; use tracedecay_daemon_protocol::{ DaemonInvocationExecutor, InvocationCancellationPolicy, invocation_now_micros, @@ -24,13 +24,6 @@ use tracedecay_daemon_protocol::{ }; use tracedecay_domain::errors::{Result, TraceDecayError}; -fn json_result(value: &Value) -> ToolResult { - ToolResult::new( - json!({ "content": [{ "type": "text", "text": value.to_string() }] }), - Vec::new(), - ) -} - const DEFAULT_DEADLINE_MICROS: i64 = 30_000_000; #[hotpath::measure(future = true, label = "mcp.dispatch.multi_root")] @@ -236,14 +229,10 @@ fn invalid_request( problem_result( operation, request_id, - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "multi_root.invalid_request".to_owned(), - message: "The multi-root application request is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - }, + ApplicationProblem::invalid_request( + "multi_root.invalid_request", + "The multi-root application request is invalid", + ), ) } @@ -271,14 +260,10 @@ fn problem_result( fn daemon_problem(problem: DaemonInvocationProblem) -> ApplicationProblem { match problem { DaemonInvocationProblem::InvalidRequest | DaemonInvocationProblem::UnsupportedRevision => { - ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "multi_root.invalid_request".to_owned(), - message: "The multi-root application request is invalid".to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], - } + ApplicationProblem::invalid_request( + "multi_root.invalid_request", + "The multi-root application request is invalid", + ) } DaemonInvocationProblem::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) diff --git a/crates/tracedecay-mcp/src/handlers/retained_response.rs b/crates/tracedecay-mcp/src/handlers/retained_response.rs index e9586c0a4c..3097d35dce 100644 --- a/crates/tracedecay-mcp/src/handlers/retained_response.rs +++ b/crates/tracedecay-mcp/src/handlers/retained_response.rs @@ -100,14 +100,10 @@ pub fn validated_retained_response( fn invocation_problem(problem: DaemonInvocationProblem) -> Result { Ok(match problem { DaemonInvocationProblem::InvalidRequest | DaemonInvocationProblem::UnsupportedRevision => { - ApplicationProblem::InvalidRequest { - diagnostic: retained_safe_diagnostic( - "application.surface.invalid_request", - "The daemon rejected the retained application request", - )?, - retry: RetryDirective::Never, - legal_actions: Vec::new(), - } + ApplicationProblem::invalid_request_without_action( + "application.surface.invalid_request", + "The daemon rejected the retained application request", + ) } DaemonInvocationProblem::NotFoundOrNotAuthorized => { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) diff --git a/crates/tracedecay-mcp/src/handlers/work.rs b/crates/tracedecay-mcp/src/handlers/work.rs index 86e36627c2..482a7b0ce6 100644 --- a/crates/tracedecay-mcp/src/handlers/work.rs +++ b/crates/tracedecay-mcp/src/handlers/work.rs @@ -18,12 +18,7 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_tool_catalog::OperationId; use crate::ToolResult; -use crate::handlers::support::unknown_tool_error; -use crate::text_tool_result; - -fn json_result(value: &Value) -> ToolResult { - text_tool_result(&value.to_string(), Vec::new()) -} +use crate::handlers::support::{json_result, unknown_tool_error}; #[hotpath::measure(future = true, label = "mcp.work.total")] pub async fn handle_work( diff --git a/crates/tracedecay-mcp/src/handlers/workflow_family.rs b/crates/tracedecay-mcp/src/handlers/workflow_family.rs index 2c769a0222..dea181858e 100644 --- a/crates/tracedecay-mcp/src/handlers/workflow_family.rs +++ b/crates/tracedecay-mcp/src/handlers/workflow_family.rs @@ -18,12 +18,7 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_tool_catalog::OperationId; use crate::ToolResult; -use crate::handlers::support::unknown_tool_error; -use crate::text_tool_result; - -fn json_result(value: &Value) -> ToolResult { - text_tool_result(&value.to_string(), Vec::new()) -} +use crate::handlers::support::{json_result, unknown_tool_error}; #[hotpath::measure(future = true, label = "mcp.workflow.total")] pub async fn handle_workflow( diff --git a/crates/tracedecay-mcp/src/response_handles.rs b/crates/tracedecay-mcp/src/response_handles.rs index a7178f6061..2873cf38c5 100644 --- a/crates/tracedecay-mcp/src/response_handles.rs +++ b/crates/tracedecay-mcp/src/response_handles.rs @@ -392,7 +392,7 @@ fn timestamp_json(value: i64) -> Value { } fn duration_micros_u64(duration: Duration) -> u64 { - duration.as_micros().min(u128::from(u64::MAX)) as u64 + tracedecay_runtime_core::tracedecay::saturating_duration_micros(duration) } fn error_class(error: &TraceDecayError) -> &'static str { diff --git a/crates/tracedecay-mcp/src/server/connection.rs b/crates/tracedecay-mcp/src/server/connection.rs index fbcca9de68..2215675f5f 100644 --- a/crates/tracedecay-mcp/src/server/connection.rs +++ b/crates/tracedecay-mcp/src/server/connection.rs @@ -12,6 +12,7 @@ use serde_json::Value; use crate::lifecycle::{McpConnectionLifecyclePort, McpRequestActivity}; use crate::transport::{McpTransport, write_wire_oversized_rejection}; use crate::{ErrorCode, JsonRpcRequest, JsonRpcResponse, serialize_response_line}; +use tracedecay_contracts::request_identity::mcp_connection_request_key as application_surface_request_id; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_framing::is_wire_oversized_io_error; @@ -306,11 +307,6 @@ where request.id.clone() } -fn application_surface_request_id(id: &Value, connection_scope: &str) -> Option { - tracedecay_contracts::request_identity::mcp_connection_request_id(id, connection_scope) - .map(|request_id| request_id.as_str().to_owned()) -} - fn queued_cancellable_request_key( pending_lines: &VecDeque, request_id: &Value, diff --git a/crates/tracedecay-mcp/src/tool_analytics.rs b/crates/tracedecay-mcp/src/tool_analytics.rs index ad3151de32..39499b0a1f 100644 --- a/crates/tracedecay-mcp/src/tool_analytics.rs +++ b/crates/tracedecay-mcp/src/tool_analytics.rs @@ -2,6 +2,7 @@ use std::path::Path; use serde_json::{Value, json}; use tracedecay_domain::canonical_text::sha256_hex; +use tracedecay_domain::collapse_whitespace; use crate::hook_events::HookEvent; use tracedecay_global_db::{AnalyticsEventInsert, RegisteredGlobalDb}; @@ -60,7 +61,7 @@ const LOOKUP_IDENTIFIER_MAX_BYTES: usize = 256; /// [`FAILURE_REASON_MAX_CHARS`] characters (never argument bodies, callers /// must derive `reason` from response/error text only). pub fn bounded_failure_reason(reason: &str) -> String { - let collapsed: String = reason.split_whitespace().collect::>().join(" "); + let collapsed = collapse_whitespace(reason); if collapsed.chars().count() <= FAILURE_REASON_MAX_CHARS { collapsed } else { diff --git a/crates/tracedecay-mcp/src/tools/binding.rs b/crates/tracedecay-mcp/src/tools/binding.rs index 53f47eb3df..6752333ada 100644 --- a/crates/tracedecay-mcp/src/tools/binding.rs +++ b/crates/tracedecay-mcp/src/tools/binding.rs @@ -17,10 +17,10 @@ use tracedecay_contracts::RetainedSurfaceOperation; use tracedecay_contracts::multi_root::multi_root_capability_manifest; use tracedecay_tool_catalog::{ ApplicationSurfaceOperation, BindingSurface, CancellationContract, CancellationPoint, - EffectClass, ExecutableBindingV1, McpDeadlineContractV1, McpDispatchAvailability, - McpDispatchCatalogV1, McpDispatchContractInputV1, McpDispatchContractV1, - McpDispatchUnavailableReason, McpIdempotencyContract, McpInverseContract, - McpInverseUnavailableReason, McpTerminalState, + EffectClass, ExecutableBindingRegistryV1, ExecutableBindingV1, McpDeadlineContractV1, + McpDispatchAvailability, McpDispatchCatalogV1, McpDispatchContractInputV1, + McpDispatchContractV1, McpDispatchUnavailableReason, McpIdempotencyContract, + McpInverseContract, McpInverseUnavailableReason, McpTerminalState, }; mod work; @@ -239,131 +239,89 @@ pub struct McpToolBinding { pub project: RegisteredProjectAccess, } +struct BindingGroup { + group: Option, + project: RegisteredProjectAccess, + names: &'static [&'static str], +} + +macro_rules! binding_groups { + ($([$group:expr, $project:expr, $($name:literal),+ $(,)?]),+ $(,)?) => { + &[$(BindingGroup { group: $group, project: $project, names: &[$($name),+] },)+] + }; +} + +/// Dispatch-catalog order. Consecutive tools that share a group and project +/// access are one slice; a different access starts a new slice. #[rustfmt::skip] -const MCP_TOOL_BINDING_SPECS: &[McpToolBinding] = &[ - McpToolBinding { name: "tracedecay_search", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_grep", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_ast_grep_search", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_retrieve", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_context", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_callers", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_callees", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_impact", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_node", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_similar", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_redundancy", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_rename_preview", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_implementations", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_callers_for", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_find_exact_symbol", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_by_qualified_name", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_signature", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_impls", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_derives", group: Some(McpToolDispatchGroup::Graph), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_status", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_remote_status", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_active_project", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_project_list", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_project_search", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_project_context", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_files", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_admin_sync", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_port_status", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_port_order", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_type_hierarchy", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_body", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_todos", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_read", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_outline", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_config", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_signature_search", group: Some(McpToolDispatchGroup::Info), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_hook_runtime", group: Some(McpToolDispatchGroup::Admin), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_admin_cli", group: Some(McpToolDispatchGroup::Admin), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_admin_project", group: Some(McpToolDispatchGroup::Admin), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_dead_code", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_circular", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_hotspots", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_unmounted_files", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_rank", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_largest", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_coupling", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_inheritance_depth", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_distribution", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_recursion", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_complexity", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_doc_coverage", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_god_class", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_unsafe_patterns", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_constructors", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_field_sites", group: Some(McpToolDispatchGroup::Analysis), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_admin_branch_add", group: Some(McpToolDispatchGroup::Git), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_affected", group: Some(McpToolDispatchGroup::Git), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_diff_context", group: Some(McpToolDispatchGroup::Git), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_changelog", group: Some(McpToolDispatchGroup::Git), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_commit_context", group: Some(McpToolDispatchGroup::Git), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_pr_context", group: Some(McpToolDispatchGroup::Git), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_branch_search", group: Some(McpToolDispatchGroup::Git), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_branch_diff", group: Some(McpToolDispatchGroup::Git), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_branch_list", group: Some(McpToolDispatchGroup::Git), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_str_replace", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_multi_str_replace", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_insert_at", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_ast_grep_rewrite", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_replace_symbol", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_insert_at_symbol", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_move_symbol", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_rename_symbol", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_source_edit_reconcile", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_source_edit_rollback", group: Some(McpToolDispatchGroup::Edit), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_test_map", group: Some(McpToolDispatchGroup::Health), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_gini", group: Some(McpToolDispatchGroup::Health), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_dependency_depth", group: Some(McpToolDispatchGroup::Health), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_health", group: Some(McpToolDispatchGroup::Health), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_runtime", group: Some(McpToolDispatchGroup::Health), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_dsm", group: Some(McpToolDispatchGroup::Health), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_test_risk", group: Some(McpToolDispatchGroup::Health), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_automation_run_list", group: Some(McpToolDispatchGroup::Memory), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_automation_run_view", group: Some(McpToolDispatchGroup::Memory), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_automation_run_artifact_view", group: Some(McpToolDispatchGroup::Memory), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_analytics", group: Some(McpToolDispatchGroup::Memory), project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_skill_list", group: Some(McpToolDispatchGroup::Memory), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_skill_view", group: Some(McpToolDispatchGroup::Memory), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_hermes_skill_bridge", group: Some(McpToolDispatchGroup::Memory), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_multi_root_scope_set_read", group: Some(McpToolDispatchGroup::MultiRoot), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_multi_root_scope_set_compare_and_swap", group: Some(McpToolDispatchGroup::MultiRoot), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_multi_root_execute", group: Some(McpToolDispatchGroup::MultiRoot), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_diagnose", group: Some(McpToolDispatchGroup::SessionWorkflow), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_run_affected_tests", group: Some(McpToolDispatchGroup::SessionWorkflow), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_dashboard", group: Some(McpToolDispatchGroup::SessionWorkflow), project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_fact_feedback", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_lcm_describe", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_lcm_doctor", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_lcm_expand", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_lcm_expand_query", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_lcm_grep", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_lcm_load_session", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_lcm_status", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_session_refresh_begin", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_session_refresh_status", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_session_refresh_cancel", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_sessions_for", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_workflows", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_fact_store_curate", group: None, project: RegisteredProjectAccess::ActiveProjectOnly }, - McpToolBinding { name: "tracedecay_fact_store_add", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_search", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_probe", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_related", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_reason", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_contradict", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_get", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_update", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_remove", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_supersede", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_fact_store_list", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_memory_status", group: None, project: RegisteredProjectAccess::SelectorOnly }, - McpToolBinding { name: "tracedecay_message_search", group: None, project: RegisteredProjectAccess::SelectorOnly }, +const BINDING_GROUPS: &[BindingGroup] = binding_groups![ + [Some(McpToolDispatchGroup::Graph), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_search", "tracedecay_grep", "tracedecay_ast_grep_search", "tracedecay_retrieve", + "tracedecay_context", "tracedecay_callers", "tracedecay_callees", "tracedecay_impact", + "tracedecay_node", "tracedecay_similar", "tracedecay_redundancy", "tracedecay_rename_preview", + "tracedecay_implementations", "tracedecay_callers_for", "tracedecay_find_exact_symbol", + "tracedecay_by_qualified_name", "tracedecay_signature", "tracedecay_impls", "tracedecay_derives"], + [Some(McpToolDispatchGroup::Info), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_status", "tracedecay_remote_status", "tracedecay_active_project", + "tracedecay_project_list", "tracedecay_project_search"], + [Some(McpToolDispatchGroup::Info), RegisteredProjectAccess::SelectorOnly, + "tracedecay_project_context"], + [Some(McpToolDispatchGroup::Info), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_files", "tracedecay_admin_sync", "tracedecay_port_status", "tracedecay_port_order", + "tracedecay_type_hierarchy", "tracedecay_body", "tracedecay_todos", "tracedecay_read", + "tracedecay_outline", "tracedecay_config", "tracedecay_signature_search"], + [Some(McpToolDispatchGroup::Admin), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_hook_runtime", "tracedecay_admin_cli", "tracedecay_admin_project"], + [Some(McpToolDispatchGroup::Analysis), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_dead_code", "tracedecay_circular", "tracedecay_hotspots", "tracedecay_unmounted_files", + "tracedecay_rank", "tracedecay_largest", "tracedecay_coupling", "tracedecay_inheritance_depth", + "tracedecay_distribution", "tracedecay_recursion", "tracedecay_complexity", "tracedecay_doc_coverage", + "tracedecay_god_class", "tracedecay_unsafe_patterns", "tracedecay_constructors", "tracedecay_field_sites"], + [Some(McpToolDispatchGroup::Git), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_admin_branch_add", "tracedecay_affected", "tracedecay_diff_context", "tracedecay_changelog", + "tracedecay_commit_context", "tracedecay_pr_context", "tracedecay_branch_search", + "tracedecay_branch_diff", "tracedecay_branch_list"], + [Some(McpToolDispatchGroup::Edit), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_str_replace", "tracedecay_multi_str_replace", "tracedecay_insert_at", "tracedecay_ast_grep_rewrite", + "tracedecay_replace_symbol", "tracedecay_insert_at_symbol", "tracedecay_move_symbol", "tracedecay_rename_symbol", + "tracedecay_source_edit_reconcile", "tracedecay_source_edit_rollback"], + [Some(McpToolDispatchGroup::Health), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_test_map", "tracedecay_gini", "tracedecay_dependency_depth", "tracedecay_health", + "tracedecay_runtime", "tracedecay_dsm", "tracedecay_test_risk"], + [Some(McpToolDispatchGroup::Memory), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_automation_run_list", "tracedecay_automation_run_view", "tracedecay_automation_run_artifact_view"], + [Some(McpToolDispatchGroup::Memory), RegisteredProjectAccess::SelectorOnly, "tracedecay_analytics"], + [Some(McpToolDispatchGroup::Memory), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_skill_list", "tracedecay_skill_view", "tracedecay_hermes_skill_bridge"], + [Some(McpToolDispatchGroup::MultiRoot), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_multi_root_scope_set_read", "tracedecay_multi_root_scope_set_compare_and_swap", + "tracedecay_multi_root_execute"], + [Some(McpToolDispatchGroup::SessionWorkflow), RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_diagnose", "tracedecay_run_affected_tests", "tracedecay_dashboard"], + [None, RegisteredProjectAccess::SelectorOnly, "tracedecay_fact_feedback"], + [None, RegisteredProjectAccess::ActiveProjectOnly, + "tracedecay_lcm_describe", "tracedecay_lcm_doctor", "tracedecay_lcm_expand", "tracedecay_lcm_expand_query", + "tracedecay_lcm_grep", "tracedecay_lcm_load_session", "tracedecay_lcm_status", + "tracedecay_session_refresh_begin", "tracedecay_session_refresh_status", "tracedecay_session_refresh_cancel", + "tracedecay_sessions_for", "tracedecay_workflows", "tracedecay_fact_store_curate"], + [None, RegisteredProjectAccess::SelectorOnly, + "tracedecay_fact_store_add", "tracedecay_fact_store_search", "tracedecay_fact_store_probe", + "tracedecay_fact_store_related", "tracedecay_fact_store_reason", "tracedecay_fact_store_contradict", + "tracedecay_fact_store_get", "tracedecay_fact_store_update", "tracedecay_fact_store_remove", + "tracedecay_fact_store_supersede", "tracedecay_fact_store_list", "tracedecay_memory_status", + "tracedecay_message_search"], ]; +fn binding_specs() -> impl Iterator { + BINDING_GROUPS.iter().flat_map(|group| { + group.names.iter().copied().map(|name| McpToolBinding { + name, + group: group.group, + project: group.project, + }) + }) +} + pub static MCP_TOOL_BINDINGS: LazyLock> = LazyLock::new(assemble_mcp_tool_bindings); @@ -379,12 +337,11 @@ fn registered_project_readers() -> &'static HashSet<&'static str> { fn assemble_mcp_tool_bindings() -> Vec { let readers = registered_project_readers(); let mut assigned = HashSet::new(); - let bindings = MCP_TOOL_BINDING_SPECS - .iter() + let bindings = binding_specs() .map(|spec| { assert!( spec.project != RegisteredProjectAccess::Reader, - "MCP_TOOL_BINDING_SPECS must not encode Reader for '{}'; list it in tracedecay-mcp::project_access", + "binding specs must not encode Reader for '{}'; list it in tracedecay-mcp::project_access", spec.name ); let project = if readers.contains(spec.name) { @@ -409,7 +366,7 @@ fn assemble_mcp_tool_bindings() -> Vec { .collect(); assert!( missing.is_empty(), - "tracedecay-mcp reader tools have no MCP_TOOL_BINDING_SPECS row: {missing:?}" + "tracedecay-mcp reader tools have no binding spec row: {missing:?}" ); bindings } @@ -517,6 +474,40 @@ pub(super) struct DispatchCatalogBinding { pub(super) executable_binding: Option<&'static ExecutableBindingV1>, } +pub(super) fn project_family_dispatch( + group: McpToolDispatchGroup, + registry: &'static ExecutableBindingRegistryV1, + operations: impl IntoIterator, + identity: (&'static str, &'static str), + missing: (&'static str, &'static str), +) -> Result, super::dispatch::McpDispatchMetadataError> { + operations + .into_iter() + .map(|(name, operation_id)| { + let operation_id = tracedecay_tool_catalog::OperationId::new(operation_id) + .map_err(|_| dispatch_invalid(identity.0, identity.1))?; + let binding = registry + .get(&operation_id) + .and_then(|availability| availability.binding()) + .ok_or_else(|| dispatch_invalid(missing.0, missing.1))?; + Ok(DispatchCatalogBinding { + name, + group: Some(group), + executable_binding: Some(binding), + }) + }) + .collect() +} + +fn dispatch_invalid( + field: &'static str, + reason: &'static str, +) -> super::dispatch::McpDispatchMetadataError { + super::dispatch::McpDispatchMetadataError::CatalogValidation( + tracedecay_tool_catalog::CatalogValidationError::InvalidValue { field, reason }, + ) +} + fn dispatch_catalog_bindings() -> Result, super::dispatch::McpDispatchMetadataError> { let mut bindings = MCP_TOOL_BINDINGS diff --git a/crates/tracedecay-mcp/src/tools/binding/work.rs b/crates/tracedecay-mcp/src/tools/binding/work.rs index 8daf4b81c9..3fd513fa09 100644 --- a/crates/tracedecay-mcp/src/tools/binding/work.rs +++ b/crates/tracedecay-mcp/src/tools/binding/work.rs @@ -47,37 +47,26 @@ pub(super) fn dispatch_catalog_bindings() -> Result, super::super::dispatch::McpDispatchMetadataError> { let registry = tracedecay_contracts::work_executable_binding_registry() .map_err(super::super::dispatch::McpDispatchMetadataError::CatalogValidation)?; - tracedecay_api::WorkOperation::ALL - .into_iter() - .map(|operation| { - let name = format!("tracedecay_work_{}", operation.operation_key()); - let operation_id = tracedecay_tool_catalog::OperationId::new(operation.operation_id()) - .map_err(|_| { - super::super::dispatch::McpDispatchMetadataError::CatalogValidation( - tracedecay_tool_catalog::CatalogValidationError::InvalidValue { - field: "MCP Work operation identity", - reason: "must name one canonical Work operation", - }, - ) - })?; - let binding = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - .ok_or({ - super::super::dispatch::McpDispatchMetadataError::CatalogValidation( - tracedecay_tool_catalog::CatalogValidationError::InvalidValue { - field: "MCP Work executable binding", - reason: "canonical Work operation is not executable", - }, - ) - })?; - Ok(DispatchCatalogBinding { - name, - group: Some(McpToolDispatchGroup::Work), - executable_binding: Some(binding), - }) - }) - .collect() + super::project_family_dispatch( + McpToolDispatchGroup::Work, + registry, + tracedecay_api::WorkOperation::ALL + .into_iter() + .map(|operation| { + ( + format!("tracedecay_work_{}", operation.operation_key()), + operation.operation_id(), + ) + }), + ( + "MCP Work operation identity", + "must name one canonical Work operation", + ), + ( + "MCP Work executable binding", + "canonical Work operation is not executable", + ), + ) } #[cfg(test)] diff --git a/crates/tracedecay-mcp/src/tools/binding/workflow.rs b/crates/tracedecay-mcp/src/tools/binding/workflow.rs index 20125081e0..bdeae23e10 100644 --- a/crates/tracedecay-mcp/src/tools/binding/workflow.rs +++ b/crates/tracedecay-mcp/src/tools/binding/workflow.rs @@ -33,6 +33,17 @@ pub(super) fn workflow_executable_binding_for_tool( .and_then(|availability| availability.binding())) } +fn invalid_workflow_binding( + reason: &'static str, +) -> super::super::dispatch::McpDispatchMetadataError { + super::super::dispatch::McpDispatchMetadataError::CatalogValidation( + tracedecay_tool_catalog::CatalogValidationError::InvalidValue { + field: "MCP Workflow executable binding", + reason, + }, + ) +} + /// Project every canonical Workflow executable into a dispatch entry. /// /// Resolves the registry once and looks each operation up in it, rather than @@ -42,38 +53,25 @@ pub(super) fn dispatch_catalog_bindings() -> Result, super::super::dispatch::McpDispatchMetadataError> { let registry = tracedecay_contracts::workflow_executable_binding_registry() .map_err(super::super::dispatch::McpDispatchMetadataError::CatalogValidation)?; - tracedecay_api::WorkflowOperation::ALL - .into_iter() - .map(|operation| { - let name = format!("tracedecay_workflow_{}", operation.operation_key()); - let operation_id = - tracedecay_tool_catalog::OperationId::new(operation.operation_id_str().to_owned()) - .map_err(|_| { - invalid_workflow_binding("must name one canonical Workflow operation") - })?; - let binding = registry - .get(&operation_id) - .and_then(|availability| availability.binding()) - .ok_or_else(|| { - invalid_workflow_binding("canonical Workflow operation is not executable") - })?; - Ok(DispatchCatalogBinding { - name, - group: Some(McpToolDispatchGroup::Workflow), - executable_binding: Some(binding), - }) - }) - .collect() -} - -fn invalid_workflow_binding( - reason: &'static str, -) -> super::super::dispatch::McpDispatchMetadataError { - super::super::dispatch::McpDispatchMetadataError::CatalogValidation( - tracedecay_tool_catalog::CatalogValidationError::InvalidValue { - field: "MCP Workflow executable binding", - reason, - }, + super::project_family_dispatch( + McpToolDispatchGroup::Workflow, + registry, + tracedecay_api::WorkflowOperation::ALL + .into_iter() + .map(|operation| { + ( + format!("tracedecay_workflow_{}", operation.operation_key()), + operation.operation_id_str().to_owned(), + ) + }), + ( + "MCP Workflow executable binding", + "must name one canonical Workflow operation", + ), + ( + "MCP Workflow executable binding", + "canonical Workflow operation is not executable", + ), ) } diff --git a/crates/tracedecay-mcp/src/tools/catalog_discovery.rs b/crates/tracedecay-mcp/src/tools/catalog_discovery.rs index fc3797e807..354596e11f 100644 --- a/crates/tracedecay-mcp/src/tools/catalog_discovery.rs +++ b/crates/tracedecay-mcp/src/tools/catalog_discovery.rs @@ -306,24 +306,6 @@ pub fn catalog_discovery_tools_list_payload( Ok(payload) } -pub fn get_catalog_filtered_tool_definitions_with_warming_budget( - budget: u8, - profile_id: &ProfileId, - authorized_capabilities: &BTreeSet, - available_scope: &BTreeSet, - registry_mode: ToolRegistryMode, -) -> Result, McpDispatchMetadataError> { - let entry = discovery_cache_get_or_insert( - profile_id, - authorized_capabilities, - available_scope, - registry_mode, - )?; - let mut definitions = (*entry.tools).clone(); - apply_context_description(&mut definitions, &context_warming_description(budget)); - Ok(definitions) -} - pub fn default_catalog_discovery_authority() -> Result, tracedecay_daemon_protocol::ApplicationSurfaceAdapterError> { Ok( diff --git a/crates/tracedecay-mcp/src/tools/render.rs b/crates/tracedecay-mcp/src/tools/render.rs index 6597864090..d8a7ccc10c 100644 --- a/crates/tracedecay-mcp/src/tools/render.rs +++ b/crates/tracedecay-mcp/src/tools/render.rs @@ -101,10 +101,8 @@ pub fn truncated_json_envelope_with_handle(project_root: Option<&Path>, formatte let original_chars = formatted.chars().count(); let mut end = formatted.len().min(MAX_RESPONSE_CHARS.saturating_sub(1024)); loop { - while end > 0 && !formatted.is_char_boundary(end) { - end -= 1; - } - let preview = &formatted[..end]; + let preview = utf8_prefix_at_or_before(formatted, end); + end = preview.len(); let mut envelope = serde_json::json!({ "truncated": true, "original_chars": original_chars, diff --git a/crates/tracedecay-mcp/src/tools/renderers.rs b/crates/tracedecay-mcp/src/tools/renderers.rs index bf4303789d..bb89e83daa 100644 --- a/crates/tracedecay-mcp/src/tools/renderers.rs +++ b/crates/tracedecay-mcp/src/tools/renderers.rs @@ -1,6 +1,7 @@ use std::fmt::Write as _; use serde_json::Value; +use tracedecay_domain::collapse_whitespace; use super::render::{self, Md}; @@ -243,10 +244,7 @@ fn append_skill_item(md: &mut Md, skill: &Value) { let summary = value_str(metadata, "/summary"); if !summary.is_empty() { - md.line(&format!( - " summary: {}", - summary.split_whitespace().collect::>().join(" ") - )); + md.line(&format!(" summary: {}", collapse_whitespace(summary))); } let category = value_str(metadata, "/category"); let targets = string_array(metadata.get("targets")); diff --git a/crates/tracedecay-policy/src/configuration.rs b/crates/tracedecay-policy/src/configuration.rs index 6b65aea1de..38587fea6a 100644 --- a/crates/tracedecay-policy/src/configuration.rs +++ b/crates/tracedecay-policy/src/configuration.rs @@ -156,17 +156,9 @@ mod tests { use super::*; use tracedecay_domain::configuration::ConfigurationGrantReceiptId; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() - } + use tracedecay_domain::test_fixtures::digest; fn policy_digest(byte: char) -> AccessPolicyDigest { AccessPolicyDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() diff --git a/crates/tracedecay-policy/src/work_loop.rs b/crates/tracedecay-policy/src/work_loop.rs index abaca41ea2..0e71578737 100644 --- a/crates/tracedecay-policy/src/work_loop.rs +++ b/crates/tracedecay-policy/src/work_loop.rs @@ -1026,21 +1026,21 @@ impl WorkProposalEvaluator for WorkProposalEvaluatorV1 { plan, ); } + if input.execution_admitted && terminal_attempt_count > 0 { + reasons.push(WorkProposalReasonV1::TerminalEvidenceObserved); + return self.planned_decision( + self.decision( + input, + WorkProposalDispositionV1::Allow, + Some(WorkProposalActionV1::Replan), + false, + reasons, + comparison, + ), + plan, + ); + } if input.execution_admitted { - if terminal_attempt_count > 0 { - reasons.push(WorkProposalReasonV1::TerminalEvidenceObserved); - return self.planned_decision( - self.decision( - input, - WorkProposalDispositionV1::Allow, - Some(WorkProposalActionV1::Replan), - false, - reasons, - comparison, - ), - plan, - ); - } reasons.push(WorkProposalReasonV1::ExecutionInFlight); return self.planned_decision( self.decision( diff --git a/crates/tracedecay-policy/tests/policy_suite/curation_apply.rs b/crates/tracedecay-policy/tests/policy_suite/curation_apply.rs index ee8d86c5e6..759c2b925c 100644 --- a/crates/tracedecay-policy/tests/policy_suite/curation_apply.rs +++ b/crates/tracedecay-policy/tests/policy_suite/curation_apply.rs @@ -5,9 +5,7 @@ use tracedecay_policy::{ CurationApplySubjectV1, CurationValidationDispositionV1, evaluate_curation_apply, }; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn input( subject: CurationApplySubjectV1, diff --git a/crates/tracedecay-policy/tests/policy_suite/routing_admission.rs b/crates/tracedecay-policy/tests/policy_suite/routing_admission.rs index db50d8a972..b9543fc125 100644 --- a/crates/tracedecay-policy/tests/policy_suite/routing_admission.rs +++ b/crates/tracedecay-policy/tests/policy_suite/routing_admission.rs @@ -5,7 +5,7 @@ use tracedecay_domain::configuration::{ AnalyzerLanguageSelectionV1, AnalyzerPrivacyClassV1, AnalyzerResourceLimitsV1, AnalyzerRestartPolicyV1, AnalyzerSettingsV1, }; -use tracedecay_domain::{CapabilityId, ManifestDigest, UtcMicros}; +use tracedecay_domain::{CapabilityId, UtcMicros}; use tracedecay_policy::analyzer::{ AnalyzerAdmissionDispositionV1, AnalyzerAdmissionEvaluator, AnalyzerAdmissionEvaluatorV1, AnalyzerAdmissionInputV1, AnalyzerAdmissionSnapshotV1, AnalyzerAvailabilityV1, @@ -26,18 +26,9 @@ use tracedecay_policy::routing::{ TruthFreshnessRequirementV1, TruthSourceStateV1, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identifier") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("valid fixture digest") -} +use tracedecay_domain::test_fixtures::digest; fn analyzer_settings() -> AnalyzerSettingsV1 { AnalyzerSettingsV1 { diff --git a/crates/tracedecay-policy/tests/policy_suite/work_planner.rs b/crates/tracedecay-policy/tests/policy_suite/work_planner.rs index 7797c3e662..94f5e38e62 100644 --- a/crates/tracedecay-policy/tests/policy_suite/work_planner.rs +++ b/crates/tracedecay-policy/tests/policy_suite/work_planner.rs @@ -6,7 +6,7 @@ //! dimensions into a score, or lets a human override outrank an exclusion. use tracedecay_domain::{ - ManifestDigest, TaskId, UtcMicros, WorkApprovalPolicy, WorkEgressPolicy, WorkExecutionLimits, + TaskId, UtcMicros, WorkApprovalPolicy, WorkEgressPolicy, WorkExecutionLimits, WorkFallbackTopology, WorkFilesystemPolicy, WorkRouteExecutionProfileV1, WorkSandboxPolicy, }; use tracedecay_policy::work_loop::{ @@ -21,10 +21,7 @@ use tracedecay_policy::work_loop::{ const LOCAL_WATERMARK: i64 = 10; const EVALUATED_AT: i64 = 100; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("fixture digest is canonical") -} +use tracedecay_domain::test_fixtures::digest; fn base_input() -> WorkProposalPolicyInputV1 { WorkProposalPolicyInputV1 { diff --git a/crates/tracedecay-privacy/src/lcm.rs b/crates/tracedecay-privacy/src/lcm.rs index ce2da2f62b..4b5707ea95 100644 --- a/crates/tracedecay-privacy/src/lcm.rs +++ b/crates/tracedecay-privacy/src/lcm.rs @@ -202,6 +202,50 @@ fn redact_text( protected } +struct SecretSpan { + secret_start: usize, + secret_end: usize, + consumed_to: usize, +} + +fn secret_span(text: &str, mut pos: usize) -> SecretSpan { + pos = skip_chars(text, pos, char::is_whitespace); + let Some(quote) = text[pos..] + .chars() + .next() + .filter(|ch| matches!(*ch, '"' | '\'')) + else { + let end = skip_chars(text, pos, |ch| { + !ch.is_whitespace() && !matches!(ch, ',' | '"' | '\'' | ']' | '}') + }); + return SecretSpan { + secret_start: pos, + secret_end: end, + consumed_to: end, + }; + }; + pos += quote.len_utf8(); + let secret_start = pos; + while pos < text.len() { + let Some(ch) = text[pos..].chars().next() else { + break; + }; + if ch == quote || matches!(ch, '\r' | '\n' | ']' | '}') { + break; + } + pos += ch.len_utf8(); + } + let secret_end = pos; + if text[pos..].chars().next().is_some_and(|ch| ch == quote) { + pos += quote.len_utf8(); + } + SecretSpan { + secret_start, + secret_end, + consumed_to: pos, + } +} + fn record_pattern_change( protected: &mut String, next: String, @@ -237,35 +281,11 @@ fn redact_assignments(text: &str, keys: &[&str], min_secret_len: usize) -> Strin continue; } pos += 1; - pos = skip_chars(text, pos, char::is_whitespace); - let mut secret_start = pos; - let (secret_end, consumed_to) = if let Some(quote) = text[pos..] - .chars() - .next() - .filter(|ch| matches!(*ch, '"' | '\'')) - { - pos += quote.len_utf8(); - secret_start = pos; - while pos < text.len() { - let Some(ch) = text[pos..].chars().next() else { - break; - }; - if ch == quote || matches!(ch, '\r' | '\n' | ']' | '}') { - break; - } - pos += ch.len_utf8(); - } - let secret_end = pos; - if text[pos..].chars().next().is_some_and(|ch| ch == quote) { - pos += quote.len_utf8(); - } - (secret_end, pos) - } else { - pos = skip_chars(text, pos, |ch| { - !ch.is_whitespace() && !matches!(ch, ',' | '"' | '\'' | ']' | '}') - }); - (pos, pos) - }; + let SecretSpan { + secret_start, + secret_end, + consumed_to, + } = secret_span(text, pos); if text[secret_start..secret_end].chars().count() < min_secret_len { out.push_str(&text[cursor..consumed_to]); cursor = consumed_to; diff --git a/crates/tracedecay-private-fs/Cargo.toml b/crates/tracedecay-private-fs/Cargo.toml index 95ef1b3a53..79914a3f1c 100644 --- a/crates/tracedecay-private-fs/Cargo.toml +++ b/crates/tracedecay-private-fs/Cargo.toml @@ -11,7 +11,6 @@ repository = "https://github.com/ScriptedAlchemy/tracedecay" cap-fs-ext = "4.0.2" cap-std = "4.0.2" hotpath.workspace = true -thiserror = "2" [dev-dependencies] tempfile = "3" diff --git a/crates/tracedecay-private-fs/src/framed_log.rs b/crates/tracedecay-private-fs/src/framed_log.rs index 539641cd1e..a1c42f7579 100644 --- a/crates/tracedecay-private-fs/src/framed_log.rs +++ b/crates/tracedecay-private-fs/src/framed_log.rs @@ -157,17 +157,11 @@ fn open_no_follow(path: &Path) -> io::Result { options.open(path).map_err(normalize_no_follow_error) } -#[cfg(unix)] fn normalize_no_follow_error(error: io::Error) -> io::Error { + #[cfg(unix)] if error.raw_os_error() == Some(libc::ELOOP) { - io::Error::new(io::ErrorKind::InvalidInput, "path is a symbolic link") - } else { - error + return io::Error::new(io::ErrorKind::InvalidInput, "path is a symbolic link"); } -} - -#[cfg(not(unix))] -fn normalize_no_follow_error(error: io::Error) -> io::Error { error } @@ -477,17 +471,7 @@ pub fn rename_noreplace(source: &Path, destination: &Path) -> io::Result<()> { platform_rename_noreplace(source, destination) } -#[cfg(target_os = "linux")] -fn platform_rename_noreplace(source: &Path, destination: &Path) -> io::Result<()> { - crate::rename_noreplace::rename_noreplace_at( - libc::AT_FDCWD, - source.as_os_str(), - libc::AT_FDCWD, - destination.as_os_str(), - ) -} - -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn platform_rename_noreplace(source: &Path, destination: &Path) -> io::Result<()> { crate::rename_noreplace::rename_noreplace_at( libc::AT_FDCWD, diff --git a/crates/tracedecay-project/Cargo.toml b/crates/tracedecay-project/Cargo.toml index 6fccb31a3c..d9092b7a68 100644 --- a/crates/tracedecay-project/Cargo.toml +++ b/crates/tracedecay-project/Cargo.toml @@ -37,10 +37,11 @@ test-helpers = [ "tracedecay-session-temporal-store/test-helpers", "tracedecay-code-index/test-helpers", "tracedecay-graph-query/test-helpers", + "dep:tracedecay-temporal-query", ] # Mirrors the composition root's `test-transport`: the standalone -# `TraceDecay::init` / `open` / `open_read_only` entry points route through +# `TraceDecay::init` / `open` / `open_read_only_with_options` entry points route through # the registered test runtime instead of the exclusive maintenance lease. test-transport = [ "test-helpers", @@ -91,7 +92,7 @@ tracedecay-sessions = { path = "../tracedecay-sessions", version = "0.1.0" } tracedecay-source-edit = { path = "../tracedecay-source-edit", version = "0.1.0" } tracedecay-store = { path = "../tracedecay-store", version = "0.1.0" } tracedecay-store-runtime = { path = "../tracedecay-store-runtime", version = "0.1.0" } -tracedecay-temporal-query = { path = "../tracedecay-temporal-query", version = "0.1.0" } +tracedecay-temporal-query = { path = "../tracedecay-temporal-query", version = "0.1.0", optional = true } tracedecay-tool-catalog = { path = "../tracedecay-tool-catalog", version = "0.1.0" } [dev-dependencies] diff --git a/crates/tracedecay-project/src/config.rs b/crates/tracedecay-project/src/config.rs index d66157c108..365a929675 100644 --- a/crates/tracedecay-project/src/config.rs +++ b/crates/tracedecay-project/src/config.rs @@ -6,8 +6,8 @@ use tracedecay_contracts::clock::now_micros; use tracedecay_domain::ProjectId; use tracedecay_domain::configuration::{ CodeIndexWorkerSelectionV1, ConfigurationLayerIdV1, ConfigurationRevisionId, - ConfigurationSnapshotV1, ConfigurationValueV1, SOURCE_BINDINGS_SETTING_KEY, SettingKey, - UserProfileId, + ConfigurationSnapshotV1, ConfigurationValueV1, SOURCE_BINDINGS_SETTING_KEY, ScopeSourceBinding, + SettingKey, UserProfileId, }; use tracedecay_configuration::{ @@ -15,7 +15,9 @@ use tracedecay_configuration::{ load_config_from_path, }; use tracedecay_domain::errors::{Result, TraceDecayError}; -use tracedecay_global_db::configuration::contracts::ports::ConfigurationControlStore; +use tracedecay_global_db::configuration::contracts::ports::{ + ConfigurationControlStore, ConfigurationCurrentStateV1, +}; use tracedecay_global_db::configuration::contracts::types::ConfigurationError; use tracedecay_global_db::configuration::{ GlobalDbConfigurationControlStore, ProfileCodeIndexWorkerConfigurationStore, @@ -455,18 +457,8 @@ async fn initialize_canonical_project_configuration( .map_err(|error| { config_error(format!("invalid initial configuration revision: {error}")) })?; - let daemon_binding = - tracedecay_configuration::config::scope_control::daemon_owned_project_source_binding( - &target.project_id, - &target.project_root, - ) - .map_err(|error| { - config_error(format!( - "daemon project source binding could not be derived: {error}" - )) - })?; - let source_bindings_key = SettingKey::new(SOURCE_BINDINGS_SETTING_KEY) - .map_err(|error| config_error(format!("invalid source bindings setting key: {error}")))?; + let daemon_binding = daemon_project_source_binding(target)?; + let source_bindings_key = source_bindings_setting_key()?; let resolution = resolver::resolve_configuration( ®istry, &[resolver::ConfigurationLayerV1 { @@ -489,10 +481,6 @@ async fn initialize_canonical_project_configuration( .map_err(map_configuration_error) } -#[expect( - clippy::too_many_lines, - reason = "Open converges the durable current revision and verifies the daemon-owned source binding before any caller sees the pin." -)] async fn open_runtime_configuration_from_store( target: RuntimeConfigurationTarget, store: &GlobalDbConfigurationControlStore<'_>, @@ -507,29 +495,16 @@ async fn open_runtime_configuration_from_store( } initialize_canonical_project_configuration(store, &target).await?; } - let daemon_binding = - tracedecay_configuration::config::scope_control::daemon_owned_project_source_binding( - &target.project_id, - &target.project_root, - ) - .map_err(|error| { - config_error(format!( - "daemon project source binding could not be derived: {error}" - )) - })?; + let daemon_binding = daemon_project_source_binding(&target)?; let current = store.current().await.map_err(map_configuration_error)?; let mut current = match store .converge_registered_registry_shape(¤t.revision_id, now_micros()) .await { Ok(state) => state, - Err(ConfigurationError::RevisionConflict) => { - store.current().await.map_err(map_configuration_error)? - } - Err(error) => return Err(map_configuration_error(error)), + Err(error) => current_after_conflict(store, error).await?, }; - let source_bindings_key = SettingKey::new(SOURCE_BINDINGS_SETTING_KEY) - .map_err(|error| config_error(format!("invalid source bindings setting key: {error}")))?; + let source_bindings_key = source_bindings_setting_key()?; enum SourceBindingCheck { Verified, LocatorDigestDrift, @@ -576,6 +551,8 @@ async fn open_runtime_configuration_from_store( // instead of demanding a reset. SourceBindingCheck::LocatorDigestDrift if !rebind_attempted => { rebind_attempted = true; + // A concurrent open may have won the swap; adopt what it + // published and re-verify it exactly. current = match store .rebind_daemon_project_source_binding( ¤t.revision_id, @@ -585,12 +562,7 @@ async fn open_runtime_configuration_from_store( .await { Ok(state) => state, - // A concurrent open won the swap; adopt what it - // published and re-verify it exactly. - Err(ConfigurationError::RevisionConflict) => { - store.current().await.map_err(map_configuration_error)? - } - Err(error) => return Err(map_configuration_error(error)), + Err(error) => current_after_conflict(store, error).await?, }; } SourceBindingCheck::LocatorDigestDrift | SourceBindingCheck::Mismatch => { @@ -679,6 +651,37 @@ fn validate_registered_configuration_database( } } +fn daemon_project_source_binding( + target: &RuntimeConfigurationTarget, +) -> Result { + tracedecay_configuration::config::scope_control::daemon_owned_project_source_binding( + &target.project_id, + &target.project_root, + ) + .map_err(|error| { + config_error(format!( + "daemon project source binding could not be derived: {error}" + )) + }) +} + +fn source_bindings_setting_key() -> Result { + SettingKey::new(SOURCE_BINDINGS_SETTING_KEY) + .map_err(|error| config_error(format!("invalid source bindings setting key: {error}"))) +} + +async fn current_after_conflict( + store: &GlobalDbConfigurationControlStore<'_>, + error: ConfigurationError, +) -> Result { + match error { + ConfigurationError::RevisionConflict => { + store.current().await.map_err(map_configuration_error) + } + error => Err(map_configuration_error(error)), + } +} + fn map_configuration_error(error: ConfigurationError) -> TraceDecayError { match error { ConfigurationError::ResetRequired { reason } => { diff --git a/crates/tracedecay-project/src/project/facts.rs b/crates/tracedecay-project/src/project/facts.rs index eea371eabb..da5da6476e 100644 --- a/crates/tracedecay-project/src/project/facts.rs +++ b/crates/tracedecay-project/src/project/facts.rs @@ -23,15 +23,14 @@ impl TraceDecay { &self.db_path(), &self.store_layout.graph_db_path, ) { - Ok(ProjectMemoryDbHandle::Active(&self.db)) - } else { - let database = if self.read_only { - self.open_project_store_db_read_only()? - } else { - self.open_project_store_db()? - }; - Ok(ProjectMemoryDbHandle::Owned(Box::new(database))) + return Ok(ProjectMemoryDbHandle::Active(&self.db)); } + let database = if self.read_only { + self.open_project_store_db_read_only()? + } else { + self.open_project_store_db()? + }; + Ok(ProjectMemoryDbHandle::Owned(Box::new(database))) } /// Resolves the project-memory owner and database into one owner-bound diff --git a/crates/tracedecay-project/src/project/lifecycle/branches.rs b/crates/tracedecay-project/src/project/lifecycle/branches.rs index cb36d409fd..f8ad8e4a7e 100644 --- a/crates/tracedecay-project/src/project/lifecycle/branches.rs +++ b/crates/tracedecay-project/src/project/lifecycle/branches.rs @@ -260,13 +260,7 @@ impl TraceDecay { let _ = tracedecay_agent_hosts::agents::context_scout::owner::ProjectContextScoutOwnerV1::startup( graph.db.clone(), project_id, - tracedecay_domain::UtcMicros( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(1, |duration| { - duration.as_micros().min(i64::MAX as u128) as i64 - }), - ), + tracedecay_runtime_core::tracedecay::utc_now_or_one(), None, ) .await; diff --git a/crates/tracedecay-project/src/project/lifecycle/mod.rs b/crates/tracedecay-project/src/project/lifecycle/mod.rs index 5812e816f5..2e616ef06a 100644 --- a/crates/tracedecay-project/src/project/lifecycle/mod.rs +++ b/crates/tracedecay-project/src/project/lifecycle/mod.rs @@ -433,13 +433,7 @@ impl TraceDecay { let _ = tracedecay_agent_hosts::agents::context_scout::owner::ProjectContextScoutOwnerV1::startup( ts.db.clone(), project_id, - tracedecay_domain::UtcMicros( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(1, |duration| { - duration.as_micros().min(i64::MAX as u128) as i64 - }), - ), + tracedecay_runtime_core::tracedecay::utc_now_or_one(), None, ) .await; @@ -660,13 +654,7 @@ impl TraceDecay { let _ = tracedecay_agent_hosts::agents::context_scout::owner::ProjectContextScoutOwnerV1::startup( ts.db.clone(), project_id, - tracedecay_domain::UtcMicros( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(1, |duration| { - duration.as_micros().min(i64::MAX as u128) as i64 - }), - ), + tracedecay_runtime_core::tracedecay::utc_now_or_one(), None, ) .await; @@ -713,11 +701,6 @@ impl TraceDecay { /// sentinels, clear markers, or rewrite corrupted DBs. It is intended for /// status/verification commands that must be able to inspect read-only /// stores without mutating them. - #[hotpath::skip] - pub async fn open_read_only(project_root: &Path) -> Result { - Self::open_read_only_with_options(project_root, TraceDecayOpenOptions::default()).await - } - #[hotpath::skip] pub async fn open_read_only_with_options( project_root: &Path, @@ -896,12 +879,7 @@ mod tests { tracedecay_agent_hosts::agents::context_scout::ports::ContextScoutLifecycleAddressV1, tracedecay_contracts::context_scout::ContextScoutAddressV1, ) { - fn id>(value: &str) -> T - where - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; let observed_at = UtcMicros(10); let project_id = id::("project.scout.fixture"); diff --git a/crates/tracedecay-project/src/test_support/host_admission.rs b/crates/tracedecay-project/src/test_support/host_admission.rs index 6a1838bac7..d1e0cd45e8 100644 --- a/crates/tracedecay-project/src/test_support/host_admission.rs +++ b/crates/tracedecay-project/src/test_support/host_admission.rs @@ -952,15 +952,13 @@ impl HostAdmissionTestRuntimeV1 { pub fn facade(&self) -> HostAdmissionFacade<'_> { let authorities = match (self.project_id.as_ref(), self.project_registered.as_ref()) { - (Some(project_id), Some(project_registered)) => { - HostAdmissionAuthorities::registered_for_project( - self.brain_id.clone(), - self.profile_id.clone(), - project_id.clone(), - project_registered, - ) - .with_profile_registered(self.profile_id.clone(), self.profile_registered.as_ref()) - } + (Some(project_id), Some(project_registered)) => HostAdmissionAuthorities::for_project( + self.brain_id.clone(), + self.profile_id.clone(), + project_id.clone(), + project_registered, + ) + .with_profile_registered(self.profile_id.clone(), self.profile_registered.as_ref()), _ => HostAdmissionAuthorities::for_profile( self.brain_id.clone(), self.profile_id.clone(), diff --git a/crates/tracedecay-project/src/test_support/host_admission/profile_registry_test_support.rs b/crates/tracedecay-project/src/test_support/host_admission/profile_registry_test_support.rs index 688dedc544..8fed6e5cc8 100644 --- a/crates/tracedecay-project/src/test_support/host_admission/profile_registry_test_support.rs +++ b/crates/tracedecay-project/src/test_support/host_admission/profile_registry_test_support.rs @@ -217,36 +217,6 @@ impl HostAdmissionTestRuntimeV1 { self.profile_database.apply_registry_reap(&plan).await } - #[doc(hidden)] - pub async fn apply_registry_orphan_relink_report( - &self, - report: &tracedecay_global_db::registry_maintenance::RegistryOrphanRelinkReport, - ) -> std::result::Result< - tracedecay_global_db::registry_maintenance::RegistryOrphanRelinkApplyReport, - Vec, - > { - tracedecay_global_db::registry_maintenance::apply_registry_orphan_relink_report( - self.profile_database.as_ref(), - report, - ) - .await - } - - #[doc(hidden)] - pub async fn apply_single_registry_orphan_relink_report( - &self, - report: &tracedecay_global_db::registry_maintenance::RegistryOrphanRelinkReport, - ) -> std::result::Result< - tracedecay_global_db::registry_maintenance::RegistryOrphanRelinkApplyReport, - Vec, - > { - tracedecay_global_db::registry_maintenance::apply_single_registry_orphan_relink_report( - self.profile_database.as_ref(), - report, - ) - .await - } - #[doc(hidden)] pub async fn upsert_graph_scope( &self, diff --git a/crates/tracedecay-query/src/bench_support.rs b/crates/tracedecay-query/src/bench_support.rs new file mode 100644 index 0000000000..297d0221f4 --- /dev/null +++ b/crates/tracedecay-query/src/bench_support.rs @@ -0,0 +1,348 @@ +//! Shared fixture helpers for the daemon-free index and search benches. +//! +//! Both binaries walk the same committed corpus, seal through the same +//! in-memory publication and projection authorities, and drain sealed pages +//! with the production batch cursor. The page budgets stay at each binary: +//! indexing profiles the daemon's commit width, search profiles a narrower +//! query ingest. + +use std::fmt; +use std::io::Cursor; +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tracedecay_code_index::languages::{LanguageRegistry, StaticLanguageRegistry}; +use tracedecay_code_index::production::{ + CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, + CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, + VerifiedSealedLexicalPageBatchBoundsV1, VerifiedSealedLexicalPageBatchReadV1, + VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, + VerifiedSealedLexicalSourceReceiptV1, +}; +use tracedecay_code_index::projection::{ + ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, + ProjectionSinkErrorV1, ProjectionSinkReceiptV1, +}; +use tracedecay_domain::{ + CodeGenerationId, LanguageId, ManifestDigest, ProjectionBatchRequestV1, ProjectionOperationV1, + ProjectionOutcomeV1, +}; + +const DEFAULT_CORPUS_RELATIVE: &str = "benchmark_data/index-bench/corpus"; + +pub(crate) struct CorpusFile { + relative_path: String, + language: LanguageId, + bytes: Vec, +} + +pub(crate) struct AdmittedFile { + pub(crate) logical_path: String, + pub(crate) language: LanguageId, + pub(crate) bytes: Arc<[u8]>, +} + +pub(crate) struct SealedDrainBounds { + pub(crate) batch_pages: usize, + pub(crate) batch_retained_bytes: usize, + pub(crate) page_chunks: usize, + pub(crate) page_bytes: usize, +} + +/// Working directory of the profiling job is not the crate root. +pub(crate) fn default_corpus_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(DEFAULT_CORPUS_RELATIVE) +} + +/// Ordered, `.gitignore`-blind directory walk. An ignore-crate walk would +/// consult repository and global ignore files, which makes the admitted file +/// set depend on the machine. +pub(crate) fn load_corpus(root: &Path) -> Result, String> { + let registry = StaticLanguageRegistry::new(); + let mut files = Vec::new(); + collect_corpus(root, root, ®istry, &mut files)?; + files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + if files.is_empty() { + return Err(format!( + "corpus {} admitted no files with a known language extension", + root.display() + )); + } + Ok(files) +} + +fn collect_corpus( + root: &Path, + directory: &Path, + registry: &StaticLanguageRegistry, + files: &mut Vec, +) -> Result<(), String> { + let mut entries = std::fs::read_dir(directory) + .map_err(|error| format!("read {}: {error}", directory.display()))? + .collect::, _>>() + .map_err(|error| format!("read {}: {error}", directory.display()))?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| format!("stat {}: {error}", path.display()))?; + if file_type.is_dir() { + collect_corpus(root, &path, registry, files)?; + continue; + } + if !file_type.is_file() { + continue; + } + let Some(extension) = path.extension().and_then(std::ffi::OsStr::to_str) else { + continue; + }; + let Some(descriptor) = registry.descriptor_for_extension(&extension.to_lowercase()) else { + continue; + }; + if !descriptor.capabilities.extraction { + continue; + } + let relative = path + .strip_prefix(root) + .map_err(|error| format!("relativize {}: {error}", path.display()))?; + let Some(relative_path) = relative.to_str() else { + return Err(format!("corpus path {} is not Unicode", relative.display())); + }; + let bytes = + std::fs::read(&path).map_err(|error| format!("read {}: {error}", path.display()))?; + files.push(CorpusFile { + relative_path: relative_path.replace('\\', "/"), + language: descriptor.language.clone(), + bytes, + }); + } + Ok(()) +} + +pub(crate) fn replicate(corpus: &[CorpusFile], replicas: usize) -> Vec { + let mut admitted = Vec::with_capacity(corpus.len().saturating_mul(replicas)); + for replica in 0..replicas { + for file in corpus { + let logical_path = if replica == 0 { + file.relative_path.clone() + } else { + format!("replica{replica:02}/{}", file.relative_path) + }; + admitted.push(AdmittedFile { + logical_path, + language: file.language.clone(), + bytes: Arc::from(file.bytes.clone()), + }); + } + } + // Canonical file order is over the whole admitted set, not per replica. + admitted.sort_by(|left, right| left.logical_path.cmp(&right.logical_path)); + admitted +} + +pub(crate) struct ActiveControl; + +impl CodeIndexExecutionControlV1 for ActiveControl { + fn is_cancelled(&self) -> bool { + false + } + + fn is_deadline_exceeded(&self) -> bool { + false + } +} + +/// In-memory compare-and-swap publication authority. The benchmark measures +/// indexing or query evaluation, not the daemon's database publication store. +#[derive(Default)] +pub(crate) struct MemoryPublicationStore { + active: Arc< + Mutex< + std::collections::BTreeMap< + CodeIndexGenerationScopeV1, + Arc, + >, + >, + >, +} + +impl CodeIndexAtomicPublicationPort for MemoryPublicationStore { + fn load_active( + &self, + scope: &CodeIndexGenerationScopeV1, + ) -> Result>, CodeIndexPublicationStoreErrorV1> { + Ok(self + .active + .lock() + .map_err(|_| CodeIndexPublicationStoreErrorV1::CompareAndSwap)? + .get(scope) + .map(Arc::clone)) + } + + fn publish_atomically( + &mut self, + scope: &CodeIndexGenerationScopeV1, + expected_active_generation: Option<&CodeGenerationId>, + generation: Arc, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let mut active = self + .active + .lock() + .map_err(|_| CodeIndexPublicationStoreErrorV1::CompareAndSwap)?; + if active + .get(scope) + .map(|current| current.manifest().generation_id.clone()) + .as_ref() + != expected_active_generation + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + active.insert(scope.clone(), generation); + Ok(()) + } +} + +/// Applies every decision without a downstream model or store. +pub(crate) struct ApplyingProjectionSink; + +impl CodeChunkProjectionSink for ApplyingProjectionSink { + fn project_changed_chunks( + &mut self, + request: &ProjectionBatchRequestV1, + receipt_builder: ProjectionReceiptBuilderV1<'_>, + ) -> Result { + let mut decisions = Vec::with_capacity( + request.changes.added_or_changed.len() + request.changes.deleted.len(), + ); + decisions.extend(request.changes.added_or_changed.iter().map(|change| { + ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: if change.prior_digest.is_some() { + ProjectionOperationV1::Updated + } else { + ProjectionOperationV1::Added + }, + outcome: ProjectionOutcomeV1::Applied, + output_digest: change.current_digest.clone(), + } + })); + decisions.extend( + request + .changes + .deleted + .iter() + .map(|change| ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: None, + operation: ProjectionOperationV1::Deleted, + outcome: ProjectionOutcomeV1::Applied, + output_digest: None, + }), + ); + receipt_builder + .build(&decisions) + .map_err(|error| ProjectionSinkErrorV1::Rejected(error.to_string())) + } +} + +pub(crate) fn sealed_state_digest(sealed: &[u8]) -> Result { + let envelope: serde_json::Value = serde_json::from_slice(sealed) + .map_err(|error| format!("decode sealed generation envelope: {error}"))?; + let digest = envelope + .get("state_digest") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "sealed generation envelope has no state digest".to_owned())?; + ManifestDigest::try_from(digest.to_owned()) + .map_err(|error| format!("sealed generation state digest: {error:?}")) +} + +/// Drain the sealed generation through the bounded batch path the daemon uses +/// to ingest an artifact. Page budgets stay with the caller so the two benches +/// do not silently share a commit width. +pub(crate) fn drain_pages( + sealed: &[u8], + sealed_len: u64, + state_digest: &ManifestDigest, + control: &impl CodeIndexExecutionControlV1, + bounds: SealedDrainBounds, +) -> Result< + ( + Vec, + VerifiedSealedLexicalSourceReceiptV1, + ), + String, +> { + let batch = VerifiedSealedLexicalPageBatchBoundsV1::new( + bounds.batch_pages, + bounds.batch_retained_bytes, + ) + .map_err(|error| format!("sealed lexical batch bounds: {error}"))?; + let mut source = VerifiedSealedLexicalPageSourceV1::open( + Cursor::new(sealed.to_vec()), + sealed_len, + state_digest.clone(), + bounds.page_chunks, + bounds.page_bytes, + control, + ) + .map_err(|error| format!("open sealed lexical page source: {error}"))?; + let mut pages = Vec::new(); + loop { + let read = source + .next_page_batch_if(control, batch, |staged| { + NonZeroUsize::new(staged.len()) + .ok_or_else(|| "sealed lexical batch staged no pages".to_owned()) + }) + .map_err(|error| format!("stage sealed lexical page batch: {error}"))? + .map_err(|error| format!("admit sealed lexical page batch: {error}"))?; + match read { + VerifiedSealedLexicalPageBatchReadV1::Pages(batch) => pages.extend(batch), + VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) => { + return Ok((pages, receipt)); + } + } + } +} + +/// Peak resident set size in bytes, or `None` off Linux. +pub(crate) fn peak_rss_bytes() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + for line in status.lines() { + let Some(value) = line.strip_prefix("VmHWM:") else { + continue; + }; + let kilobytes = value.split_whitespace().next()?.parse::().ok()?; + return kilobytes.checked_mul(1024); + } + None +} + +pub(crate) fn millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +pub(crate) fn percentile(sorted: &[u64], percent: usize) -> u64 { + if sorted.is_empty() { + return 0; + } + let rank = (sorted.len() * percent).div_ceil(100); + sorted[rank.saturating_sub(1).min(sorted.len() - 1)] +} + +pub(crate) fn identity(value: &str) -> T +where + T: TryFrom, + >::Error: fmt::Debug, +{ + T::try_from(value.to_owned()).unwrap_or_else(|error| { + panic!("deterministic benchmark identity {value:?} must be valid: {error:?}") + }) +} diff --git a/crates/tracedecay-query/src/bin/tracedecay_index_bench.rs b/crates/tracedecay-query/src/bin/tracedecay_index_bench.rs index 4c5eaa23ff..59a1b4729e 100644 --- a/crates/tracedecay-query/src/bin/tracedecay_index_bench.rs +++ b/crates/tracedecay-query/src/bin/tracedecay_index_bench.rs @@ -49,36 +49,33 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] +#[path = "../bench_support.rs"] +mod artifact_bench; + +use artifact_bench::{ + ActiveControl, AdmittedFile, ApplyingProjectionSink, MemoryPublicationStore, SealedDrainBounds, + default_corpus_root, drain_pages, identity, load_corpus, millis, peak_rss_bytes, percentile, + replicate, sealed_state_digest, +}; use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; -use std::io::Cursor; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::process::ExitCode; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::{Duration, Instant}; use tracedecay_code_index::chunks::content_digest; use tracedecay_code_index::clones::{CloneBodyEligibilityV1, CloneNormalizationClassV1}; -use tracedecay_code_index::languages::{LanguageRegistry, StaticLanguageRegistry}; use tracedecay_code_index::production::{ - CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, - CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexProductionConfigV1, - CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, + CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexExecutionControlV1, + CodeIndexProductionConfigV1, CodeIndexProductionOwnerV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, PhysicalCodeArtifactPoolStatsV1, - VerifiedSealedLexicalPageBatchBoundsV1, VerifiedSealedLexicalPageBatchReadV1, - VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, - VerifiedSealedLexicalSourceReceiptV1, -}; -use tracedecay_code_index::projection::{ - ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, - ProjectionSinkErrorV1, ProjectionSinkReceiptV1, + VerifiedSealedLexicalPageV1, VerifiedSealedLexicalSourceReceiptV1, }; use tracedecay_domain::{ - ChunkerRevision, CodeGenerationId, ComponentRevision, ContentDigest, FileOccurrenceId, - FreshnessCompatibilityV1, LanguageId, ManifestDigest, PolicyRevisionId, PrivacyDomainId, - ProjectId, ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionKindV1, ProjectionOperationV1, - ProjectionOutcomeV1, RepositoryDirtyStateV1, RepositoryId, SanitizationReceiptId, + ChunkerRevision, ComponentRevision, ContentDigest, FileOccurrenceId, FreshnessCompatibilityV1, + ManifestDigest, PolicyRevisionId, PrivacyDomainId, ProjectId, ProjectionKeyV1, + ProjectionKindV1, RepositoryDirtyStateV1, RepositoryId, SanitizationReceiptId, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, SanitizerRevision, ScoreDomainId, SensitivityLevelV1, SnapshotFileDispositionV1, SourceFreshness, SourceInstanceKey, SourceNamespace, SymbolOccurrenceId, TreeId, UtcMicros, @@ -97,7 +94,6 @@ use tracedecay_query::retrieval::lexical::{ /// Bumped whenever the workload shape changes, so a profile comparison /// across a shape change is visibly not comparable. const WORKLOAD_REVISION: &str = "index-bench.v1"; -const DEFAULT_CORPUS_RELATIVE: &str = "benchmark_data/index-bench/corpus"; const CORPUS_ENV: &str = "TRACEDECAY_INDEX_BENCH_CORPUS"; const REPLICAS_ENV: &str = "TRACEDECAY_INDEX_BENCH_REPLICAS"; @@ -295,119 +291,6 @@ fn parse_replicas(value: &str) -> Result { Ok(replicas) } -/// The corpus lives beside the workspace this binary was compiled from, so -/// the default resolves from `CARGO_MANIFEST_DIR` rather than the process -/// working directory: the profiling job invokes the binary by path, not -/// from the crate root. -fn default_corpus_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .join(DEFAULT_CORPUS_RELATIVE) -} - -// --------------------------------------------------------------------------- -// Corpus admission -// --------------------------------------------------------------------------- - -struct CorpusFile { - relative_path: String, - language: LanguageId, - bytes: Vec, -} - -/// Ordered, `.gitignore`-blind directory walk. `ignore`-crate walking would -/// consult repository and global ignore files, which makes the admitted file -/// set depend on the machine - unacceptable for a head-vs-base comparison. -fn load_corpus(root: &Path) -> Result, String> { - let registry = StaticLanguageRegistry::new(); - let mut files = Vec::new(); - collect_corpus(root, root, ®istry, &mut files)?; - files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); - if files.is_empty() { - return Err(format!( - "corpus {} admitted no files with a known language extension", - root.display() - )); - } - Ok(files) -} - -fn collect_corpus( - root: &Path, - directory: &Path, - registry: &StaticLanguageRegistry, - files: &mut Vec, -) -> Result<(), String> { - let mut entries = std::fs::read_dir(directory) - .map_err(|error| format!("read {}: {error}", directory.display()))? - .collect::, _>>() - .map_err(|error| format!("read {}: {error}", directory.display()))?; - entries.sort_by_key(std::fs::DirEntry::file_name); - for entry in entries { - let path = entry.path(); - let file_type = entry - .file_type() - .map_err(|error| format!("stat {}: {error}", path.display()))?; - if file_type.is_dir() { - collect_corpus(root, &path, registry, files)?; - continue; - } - if !file_type.is_file() { - continue; - } - let Some(extension) = path.extension().and_then(std::ffi::OsStr::to_str) else { - continue; - }; - let Some(descriptor) = registry.descriptor_for_extension(&extension.to_lowercase()) else { - continue; - }; - if !descriptor.capabilities.extraction { - continue; - } - let relative = path - .strip_prefix(root) - .map_err(|error| format!("relativize {}: {error}", path.display()))?; - let Some(relative_path) = relative.to_str() else { - return Err(format!("corpus path {} is not Unicode", relative.display())); - }; - let bytes = - std::fs::read(&path).map_err(|error| format!("read {}: {error}", path.display()))?; - files.push(CorpusFile { - relative_path: relative_path.replace('\\', "/"), - language: descriptor.language.clone(), - bytes, - }); - } - Ok(()) -} - -/// One admitted source file, already replicated and identified. -struct AdmittedFile { - logical_path: String, - language: LanguageId, - bytes: Arc<[u8]>, -} - -fn replicate(corpus: &[CorpusFile], replicas: usize) -> Vec { - let mut admitted = Vec::with_capacity(corpus.len().saturating_mul(replicas)); - for replica in 0..replicas { - for file in corpus { - let logical_path = if replica == 0 { - file.relative_path.clone() - } else { - format!("replica{replica:02}/{}", file.relative_path) - }; - admitted.push(AdmittedFile { - logical_path, - language: file.language.clone(), - bytes: Arc::from(file.bytes.clone()), - }); - } - } - admitted.sort_by(|left, right| left.logical_path.cmp(&right.logical_path)); - admitted -} - /// Deterministic incremental edit: append a distinguishing trailing comment /// to every `EDIT_STRIDE`-th file. Appending keeps the edit a genuine /// suffix change so the retained-parse pool exercises its incremental path @@ -523,22 +406,6 @@ fn build_body_refresh( )) } -// --------------------------------------------------------------------------- -// In-memory production authorities -// --------------------------------------------------------------------------- - -struct ActiveControl; - -impl CodeIndexExecutionControlV1 for ActiveControl { - fn is_cancelled(&self) -> bool { - false - } - - fn is_deadline_exceeded(&self) -> bool { - false - } -} - struct CancelledControl; impl CodeIndexExecutionControlV1 for CancelledControl { @@ -550,99 +417,6 @@ impl CodeIndexExecutionControlV1 for CancelledControl { false } } - -/// In-memory compare-and-swap publication authority. The real daemon store -/// is a database; the benchmark deliberately measures indexing rather than -/// storage, so publication is a map behind a mutex. -#[derive(Default)] -struct MemoryPublicationStore { - active: Arc>>>, -} - -impl CodeIndexAtomicPublicationPort for MemoryPublicationStore { - fn load_active( - &self, - scope: &CodeIndexGenerationScopeV1, - ) -> Result>, CodeIndexPublicationStoreErrorV1> { - Ok(self - .active - .lock() - .map_err(|_| CodeIndexPublicationStoreErrorV1::CompareAndSwap)? - .get(scope) - .map(Arc::clone)) - } - - fn publish_atomically( - &mut self, - scope: &CodeIndexGenerationScopeV1, - expected_active_generation: Option<&CodeGenerationId>, - generation: Arc, - ) -> Result<(), CodeIndexPublicationStoreErrorV1> { - let mut active = self - .active - .lock() - .map_err(|_| CodeIndexPublicationStoreErrorV1::CompareAndSwap)?; - if active - .get(scope) - .map(|current| current.manifest().generation_id.clone()) - .as_ref() - != expected_active_generation - { - return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); - } - active.insert(scope.clone(), generation); - Ok(()) - } -} - -/// Applies every decision without a downstream model or store, so the -/// profile attributes time to extraction and sealing rather than to a -/// projection backend this workload does not mount. -struct ApplyingProjectionSink; - -impl CodeChunkProjectionSink for ApplyingProjectionSink { - fn project_changed_chunks( - &mut self, - request: &ProjectionBatchRequestV1, - receipt_builder: ProjectionReceiptBuilderV1<'_>, - ) -> Result { - let mut decisions = Vec::with_capacity( - request.changes.added_or_changed.len() + request.changes.deleted.len(), - ); - decisions.extend(request.changes.added_or_changed.iter().map(|change| { - ChunkProjectionDecisionV1 { - chunk_id: change.chunk_id.clone(), - prior_chunk_digest: change.prior_digest.clone(), - current_chunk_digest: change.current_digest.clone(), - operation: if change.prior_digest.is_some() { - ProjectionOperationV1::Updated - } else { - ProjectionOperationV1::Added - }, - outcome: ProjectionOutcomeV1::Applied, - output_digest: change.current_digest.clone(), - } - })); - decisions.extend( - request - .changes - .deleted - .iter() - .map(|change| ChunkProjectionDecisionV1 { - chunk_id: change.chunk_id.clone(), - prior_chunk_digest: change.prior_digest.clone(), - current_chunk_digest: None, - operation: ProjectionOperationV1::Deleted, - outcome: ProjectionOutcomeV1::Applied, - output_digest: None, - }), - ); - receipt_builder - .build(&decisions) - .map_err(|error| ProjectionSinkErrorV1::Rejected(error.to_string())) - } -} - // --------------------------------------------------------------------------- // Workload // --------------------------------------------------------------------------- @@ -792,7 +566,18 @@ fn run(options: &Options) -> Result { // Pass 3 - drain the sealed generation as bounded page batches. let drain_started = Instant::now(); - let (pages, source_receipt) = drain_pages(&sealed, sealed_len, &state_digest, &control)?; + let (pages, source_receipt) = drain_pages( + &sealed, + sealed_len, + &state_digest, + &control, + SealedDrainBounds { + batch_pages: BATCH_MAX_PAGES, + batch_retained_bytes: BATCH_MAX_RETAINED_BYTES, + page_chunks: MAX_PAGE_CHUNKS, + page_bytes: MAX_PAGE_BYTES, + }, + )?; let drain_wall = drain_started.elapsed(); // Pass 4 - ingest the pages into an isolated on-disk lexical artifact. @@ -933,64 +718,6 @@ fn benchmark_file_occurrence_id(file: &AdmittedFile, digest: &ContentDigest) -> occurrence.as_str().trim_start_matches("sha256:") )) } - -fn sealed_state_digest(sealed: &[u8]) -> Result { - let envelope: serde_json::Value = serde_json::from_slice(sealed) - .map_err(|error| format!("decode sealed generation envelope: {error}"))?; - let digest = envelope - .get("state_digest") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| "sealed generation envelope has no state digest".to_owned())?; - ManifestDigest::try_from(digest.to_owned()) - .map_err(|error| format!("sealed generation state digest: {error:?}")) -} - -/// Drain the sealed generation through the bounded batch path, which is the -/// shape the daemon's artifact ingestion uses. The single-page path is -/// asserted to agree on the source receipt so a regression that desynchronizes -/// the two cursors fails here instead of skewing the comparison. -fn drain_pages( - sealed: &[u8], - sealed_len: u64, - state_digest: &ManifestDigest, - control: &ActiveControl, -) -> Result< - ( - Vec, - VerifiedSealedLexicalSourceReceiptV1, - ), - String, -> { - let bounds = - VerifiedSealedLexicalPageBatchBoundsV1::new(BATCH_MAX_PAGES, BATCH_MAX_RETAINED_BYTES) - .map_err(|error| format!("sealed lexical batch bounds: {error}"))?; - let mut source = VerifiedSealedLexicalPageSourceV1::open( - Cursor::new(sealed.to_vec()), - sealed_len, - state_digest.clone(), - MAX_PAGE_CHUNKS, - MAX_PAGE_BYTES, - control, - ) - .map_err(|error| format!("open sealed lexical page source: {error}"))?; - let mut pages = Vec::new(); - loop { - let read = source - .next_page_batch_if(control, bounds, |staged| { - NonZeroUsize::new(staged.len()) - .ok_or_else(|| "sealed lexical batch staged no pages".to_owned()) - }) - .map_err(|error| format!("stage sealed lexical page batch: {error}"))? - .map_err(|error| format!("admit sealed lexical page batch: {error}"))?; - match read { - VerifiedSealedLexicalPageBatchReadV1::Pages(batch) => pages.extend(batch), - VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) => { - return Ok((pages, receipt)); - } - } - } -} - fn projection_metadata( generation: &CodeIndexPublishedGenerationV1, repository: &RepositoryId, @@ -1379,22 +1106,6 @@ impl Scratch { .map_err(|error| format!("remove scratch {}: {error}", self.path.display())) } } - -/// Peak resident set size in bytes, or `None` off Linux. The profiling job -/// budgets 4 GB; reporting the high-water mark makes a breach visible in the -/// run log instead of only as an OOM kill. -fn peak_rss_bytes() -> Option { - let status = std::fs::read_to_string("/proc/self/status").ok()?; - for line in status.lines() { - let Some(value) = line.strip_prefix("VmHWM:") else { - continue; - }; - let kilobytes = value.split_whitespace().next()?.parse::().ok()?; - return kilobytes.checked_mul(1024); - } - None -} - // --------------------------------------------------------------------------- // Summary // --------------------------------------------------------------------------- @@ -1492,19 +1203,6 @@ fn summary(fields: SummaryFields<'_>) -> String { }); serde_json::to_string_pretty(&report).unwrap_or_else(|_| report.to_string()) } - -fn millis(duration: Duration) -> u64 { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) -} - -fn percentile(sorted: &[u64], percent: usize) -> u64 { - if sorted.is_empty() { - return 0; - } - let rank = (sorted.len() * percent).div_ceil(100); - sorted[rank.saturating_sub(1).min(sorted.len() - 1)] -} - fn host_facts() -> serde_json::Value { let cpu_model = std::fs::read_to_string("/proc/cpuinfo") .ok() @@ -1533,17 +1231,6 @@ fn host_facts() -> serde_json::Value { "memory_bytes": memory_bytes, }) } - -fn identity(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap_or_else(|error| { - panic!("deterministic benchmark identity {value:?} must be valid: {error:?}") - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tracedecay-query/src/bin/tracedecay_search_bench.rs b/crates/tracedecay-query/src/bin/tracedecay_search_bench.rs index b70ccc4d61..c84de10e31 100644 --- a/crates/tracedecay-query/src/bin/tracedecay_search_bench.rs +++ b/crates/tracedecay-query/src/bin/tracedecay_search_bench.rs @@ -27,40 +27,37 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] +#[path = "../bench_support.rs"] +mod artifact_bench; + +use artifact_bench::{ + ActiveControl, AdmittedFile, ApplyingProjectionSink, MemoryPublicationStore, SealedDrainBounds, + default_corpus_root, drain_pages, identity, load_corpus, millis, peak_rss_bytes, percentile, + replicate, sealed_state_digest, +}; use std::collections::BTreeSet; -use std::fmt; -use std::io::{Cursor, Read}; -use std::num::NonZeroUsize; +use std::io::Read; use std::path::{Path, PathBuf}; use std::process::ExitCode; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::{Duration, Instant}; use tracedecay_code_index::chunks::content_digest; -use tracedecay_code_index::languages::{LanguageRegistry, StaticLanguageRegistry}; use tracedecay_code_index::production::{ - CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, - CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexProductionConfigV1, - CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, - CodeIndexRepositoryParseIdentityV1, VerifiedSealedLexicalPageBatchBoundsV1, - VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageSourceV1, + CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexProductionConfigV1, + CodeIndexProductionOwnerV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, VerifiedSealedLexicalPageV1, VerifiedSealedLexicalSourceReceiptV1, }; -use tracedecay_code_index::projection::{ - ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, - ProjectionSinkErrorV1, ProjectionSinkReceiptV1, -}; use tracedecay_domain::{ AuthorizationRevision, ChunkerRevision, CodeGenerationId, ComponentRevision, ExactAdmissionRuleRevision, FileOccurrenceId, FreshnessCompatibilityV1, FreshnessVectorDigest, - FusionProfileId, LanguageId, ManifestDigest, PolicyRevisionId, PrincipalId, PrivacyDomainId, - ProjectId, ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionKindV1, ProjectionOperationV1, - ProjectionOutcomeV1, QueryNormalizationRevision, RepositoryDirtyStateV1, RepositoryId, - RetrievalBudget, RetrievalRequest, RetrievalScope, RetrievalSnapshot, RetrieverBatch, - RetrieverOutcome, SanitizationReceiptId, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, - SanitizerRevision, ScoreDomainId, SensitivityLevelV1, SingleRootScopeV1, - SnapshotFileDispositionV1, SourceFreshness, SourceInstanceKey, SourceNamespace, TemporalModeV1, - TreeId, UtcMicros, VectorWatermark, + FusionProfileId, ManifestDigest, PolicyRevisionId, PrincipalId, PrivacyDomainId, ProjectId, + ProjectionKeyV1, ProjectionKindV1, QueryNormalizationRevision, RepositoryDirtyStateV1, + RepositoryId, RetrievalBudget, RetrievalRequest, RetrievalScope, RetrievalSnapshot, + RetrieverBatch, RetrieverOutcome, SanitizationReceiptId, SanitizedCodeFileV1, + SanitizedCodeSnapshotV1, SanitizerRevision, ScoreDomainId, SensitivityLevelV1, + SingleRootScopeV1, SnapshotFileDispositionV1, SourceFreshness, SourceInstanceKey, + SourceNamespace, TemporalModeV1, TreeId, UtcMicros, VectorWatermark, }; use tracedecay_query::retrieval::exact::{ CentralExactAdmissionAuthorityV1, ExactAdmissionAuthority, ExactLane, ExactLaneRequest, @@ -81,7 +78,6 @@ use tracedecay_query::retrieval::{ /// Bumped whenever the workload shape changes, so a profile comparison /// across a shape change is visibly not comparable. const WORKLOAD_REVISION: &str = "search-bench.v1"; -const DEFAULT_CORPUS_RELATIVE: &str = "benchmark_data/index-bench/corpus"; const CORPUS_ENV: &str = "TRACEDECAY_SEARCH_BENCH_CORPUS"; const REPLICAS_ENV: &str = "TRACEDECAY_SEARCH_BENCH_REPLICAS"; const KEEP_SCRATCH_ENV: &str = "TRACEDECAY_SEARCH_BENCH_KEEP_SCRATCH"; @@ -350,129 +346,6 @@ fn parse_count(flag: &str, value: &str, minimum: usize) -> Result Ok(count) } -fn default_corpus_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .join(DEFAULT_CORPUS_RELATIVE) -} - -// --------------------------------------------------------------------------- -// Corpus admission (identical walk to `tracedecay-index-bench`) -// --------------------------------------------------------------------------- - -struct CorpusFile { - relative_path: String, - language: LanguageId, - bytes: Vec, -} - -fn load_corpus(root: &Path) -> Result, String> { - let registry = StaticLanguageRegistry::new(); - let mut files = Vec::new(); - collect_corpus(root, root, ®istry, &mut files)?; - files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); - if files.is_empty() { - return Err(format!( - "corpus {} admitted no files with a known language extension", - root.display() - )); - } - Ok(files) -} - -fn collect_corpus( - root: &Path, - directory: &Path, - registry: &StaticLanguageRegistry, - files: &mut Vec, -) -> Result<(), String> { - let mut entries = std::fs::read_dir(directory) - .map_err(|error| format!("read {}: {error}", directory.display()))? - .collect::, _>>() - .map_err(|error| format!("read {}: {error}", directory.display()))?; - entries.sort_by_key(std::fs::DirEntry::file_name); - for entry in entries { - let path = entry.path(); - let file_type = entry - .file_type() - .map_err(|error| format!("stat {}: {error}", path.display()))?; - if file_type.is_dir() { - collect_corpus(root, &path, registry, files)?; - continue; - } - if !file_type.is_file() { - continue; - } - let Some(extension) = path.extension().and_then(std::ffi::OsStr::to_str) else { - continue; - }; - let Some(descriptor) = registry.descriptor_for_extension(&extension.to_lowercase()) else { - continue; - }; - if !descriptor.capabilities.extraction { - continue; - } - let relative = path - .strip_prefix(root) - .map_err(|error| format!("relativize {}: {error}", path.display()))?; - let Some(relative_path) = relative.to_str() else { - return Err(format!("corpus path {} is not Unicode", relative.display())); - }; - let bytes = - std::fs::read(&path).map_err(|error| format!("read {}: {error}", path.display()))?; - files.push(CorpusFile { - relative_path: relative_path.replace('\\', "/"), - language: descriptor.language.clone(), - bytes, - }); - } - Ok(()) -} - -struct AdmittedFile { - logical_path: String, - language: LanguageId, - bytes: Arc<[u8]>, -} - -fn replicate(corpus: &[CorpusFile], replicas: usize) -> Vec { - let mut admitted = Vec::with_capacity(corpus.len().saturating_mul(replicas)); - for replica in 0..replicas { - for file in corpus { - let logical_path = if replica == 0 { - file.relative_path.clone() - } else { - format!("replica{replica:02}/{}", file.relative_path) - }; - admitted.push(AdmittedFile { - logical_path, - language: file.language.clone(), - bytes: Arc::from(file.bytes.clone()), - }); - } - } - // The snapshot contract requires canonical file order over the whole - // admitted set, not per replica. - admitted.sort_by(|left, right| left.logical_path.cmp(&right.logical_path)); - admitted -} - -// --------------------------------------------------------------------------- -// In-memory production authorities (identical to `tracedecay-index-bench`) -// --------------------------------------------------------------------------- - -struct ActiveControl; - -impl CodeIndexExecutionControlV1 for ActiveControl { - fn is_cancelled(&self) -> bool { - false - } - - fn is_deadline_exceeded(&self) -> bool { - false - } -} - impl RetrievalExecutionControl for ActiveControl { fn is_cancelled(&self) -> bool { false @@ -482,100 +355,6 @@ impl RetrievalExecutionControl for ActiveControl { 0 } } - -#[derive(Default)] -struct MemoryPublicationStore { - active: Arc< - Mutex< - std::collections::BTreeMap< - CodeIndexGenerationScopeV1, - Arc, - >, - >, - >, -} - -impl CodeIndexAtomicPublicationPort for MemoryPublicationStore { - fn load_active( - &self, - scope: &CodeIndexGenerationScopeV1, - ) -> Result>, CodeIndexPublicationStoreErrorV1> { - Ok(self - .active - .lock() - .map_err(|_| CodeIndexPublicationStoreErrorV1::CompareAndSwap)? - .get(scope) - .map(Arc::clone)) - } - - fn publish_atomically( - &mut self, - scope: &CodeIndexGenerationScopeV1, - expected_active_generation: Option<&CodeGenerationId>, - generation: Arc, - ) -> Result<(), CodeIndexPublicationStoreErrorV1> { - let mut active = self - .active - .lock() - .map_err(|_| CodeIndexPublicationStoreErrorV1::CompareAndSwap)?; - if active - .get(scope) - .map(|current| current.manifest().generation_id.clone()) - .as_ref() - != expected_active_generation - { - return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); - } - active.insert(scope.clone(), generation); - Ok(()) - } -} - -struct ApplyingProjectionSink; - -impl CodeChunkProjectionSink for ApplyingProjectionSink { - fn project_changed_chunks( - &mut self, - request: &ProjectionBatchRequestV1, - receipt_builder: ProjectionReceiptBuilderV1<'_>, - ) -> Result { - let mut decisions = Vec::with_capacity( - request.changes.added_or_changed.len() + request.changes.deleted.len(), - ); - decisions.extend(request.changes.added_or_changed.iter().map(|change| { - ChunkProjectionDecisionV1 { - chunk_id: change.chunk_id.clone(), - prior_chunk_digest: change.prior_digest.clone(), - current_chunk_digest: change.current_digest.clone(), - operation: if change.prior_digest.is_some() { - ProjectionOperationV1::Updated - } else { - ProjectionOperationV1::Added - }, - outcome: ProjectionOutcomeV1::Applied, - output_digest: change.current_digest.clone(), - } - })); - decisions.extend( - request - .changes - .deleted - .iter() - .map(|change| ChunkProjectionDecisionV1 { - chunk_id: change.chunk_id.clone(), - prior_chunk_digest: change.prior_digest.clone(), - current_chunk_digest: None, - operation: ProjectionOperationV1::Deleted, - outcome: ProjectionOutcomeV1::Applied, - output_digest: None, - }), - ); - receipt_builder - .build(&decisions) - .map_err(|error| ProjectionSinkErrorV1::Rejected(error.to_string())) - } -} - // --------------------------------------------------------------------------- // Workload // --------------------------------------------------------------------------- @@ -629,7 +408,18 @@ fn run(options: &Options) -> Result { let state_digest = sealed_state_digest(&sealed)?; let drain_started = Instant::now(); - let (pages, source_receipt) = drain_pages(&sealed, sealed_len, &state_digest, &control)?; + let (pages, source_receipt) = drain_pages( + &sealed, + sealed_len, + &state_digest, + &control, + SealedDrainBounds { + batch_pages: BATCH_MAX_PAGES, + batch_retained_bytes: BATCH_MAX_RETAINED_BYTES, + page_chunks: MAX_PAGE_CHUNKS, + page_bytes: MAX_PAGE_BYTES, + }, + )?; let drain_wall = drain_started.elapsed(); let scratch = Scratch::create()?; @@ -1042,15 +832,6 @@ fn phase_stats( "max": values.last().copied().unwrap_or_default(), }) } - -fn percentile(sorted: &[u64], percent: usize) -> u64 { - if sorted.is_empty() { - return 0; - } - let rank = (sorted.len() * percent).div_ceil(100); - sorted[rank.saturating_sub(1).min(sorted.len() - 1)] -} - fn build_request( repository: &RepositoryId, sanitizer_revision: &SanitizerRevision, @@ -1109,60 +890,6 @@ fn build_request( }, } } - -fn sealed_state_digest(sealed: &[u8]) -> Result { - let envelope: serde_json::Value = serde_json::from_slice(sealed) - .map_err(|error| format!("decode sealed generation envelope: {error}"))?; - let digest = envelope - .get("state_digest") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| "sealed generation envelope has no state digest".to_owned())?; - ManifestDigest::try_from(digest.to_owned()) - .map_err(|error| format!("sealed generation state digest: {error:?}")) -} - -fn drain_pages( - sealed: &[u8], - sealed_len: u64, - state_digest: &ManifestDigest, - control: &ActiveControl, -) -> Result< - ( - Vec, - VerifiedSealedLexicalSourceReceiptV1, - ), - String, -> { - let bounds = - VerifiedSealedLexicalPageBatchBoundsV1::new(BATCH_MAX_PAGES, BATCH_MAX_RETAINED_BYTES) - .map_err(|error| format!("sealed lexical batch bounds: {error}"))?; - let mut source = VerifiedSealedLexicalPageSourceV1::open( - Cursor::new(sealed.to_vec()), - sealed_len, - state_digest.clone(), - MAX_PAGE_CHUNKS, - MAX_PAGE_BYTES, - control, - ) - .map_err(|error| format!("open sealed lexical page source: {error}"))?; - let mut pages = Vec::new(); - loop { - let read = source - .next_page_batch_if(control, bounds, |staged| { - NonZeroUsize::new(staged.len()) - .ok_or_else(|| "sealed lexical batch staged no pages".to_owned()) - }) - .map_err(|error| format!("stage sealed lexical page batch: {error}"))? - .map_err(|error| format!("admit sealed lexical page batch: {error}"))?; - match read { - VerifiedSealedLexicalPageBatchReadV1::Pages(batch) => pages.extend(batch), - VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) => { - return Ok((pages, receipt)); - } - } - } -} - fn projection_metadata( generation: &CodeIndexPublishedGenerationV1, repository: &RepositoryId, @@ -1285,33 +1012,6 @@ fn hash_file(path: &Path) -> Result<(ManifestDigest, u64), String> { .map_err(|error| format!("artifact digest: {error}"))?; Ok((digest, file_size_bytes)) } - -fn peak_rss_bytes() -> Option { - let status = std::fs::read_to_string("/proc/self/status").ok()?; - for line in status.lines() { - let Some(value) = line.strip_prefix("VmHWM:") else { - continue; - }; - let kilobytes = value.split_whitespace().next()?.parse::().ok()?; - return kilobytes.checked_mul(1024); - } - None -} - -fn millis(duration: Duration) -> u64 { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) -} - fn micros(duration: Duration) -> u64 { u64::try_from(duration.as_micros()).unwrap_or(u64::MAX) } - -fn identity(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap_or_else(|error| { - panic!("deterministic benchmark identity {value:?} must be valid: {error:?}") - }) -} diff --git a/crates/tracedecay-query/src/retrieval/diversity.rs b/crates/tracedecay-query/src/retrieval/diversity.rs index 42b3429ae9..fda12b1686 100644 --- a/crates/tracedecay-query/src/retrieval/diversity.rs +++ b/crates/tracedecay-query/src/retrieval/diversity.rs @@ -280,13 +280,7 @@ mod cap_key_tests { FreshnessCompatibilityV1, RetrievalAnchorId, SourceFreshness, UtcMicros, }; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use tracedecay_domain::test_fixtures::id; fn occurrence(name: &str, file: &str) -> OccurrenceProvenance { OccurrenceProvenance { diff --git a/crates/tracedecay-query/src/retrieval/exact/tests.rs b/crates/tracedecay-query/src/retrieval/exact/tests.rs index 230fca18d8..ff260038d5 100644 --- a/crates/tracedecay-query/src/retrieval/exact/tests.rs +++ b/crates/tracedecay-query/src/retrieval/exact/tests.rs @@ -36,13 +36,7 @@ impl RetrievalExecutionControl for ActiveControl { } } -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn digest_id(byte: char) -> T where diff --git a/crates/tracedecay-query/src/retrieval/fusion.rs b/crates/tracedecay-query/src/retrieval/fusion.rs index 3f3d285228..622dca803a 100644 --- a/crates/tracedecay-query/src/retrieval/fusion.rs +++ b/crates/tracedecay-query/src/retrieval/fusion.rs @@ -1366,13 +1366,7 @@ mod attach_same_source_decisions_tests { use super::*; use tracedecay_domain::{EvidenceRole, FreshnessCompatibilityV1}; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid fixture identity") - } + use tracedecay_domain::test_fixtures::id; fn freshness() -> SourceFreshness { SourceFreshness { diff --git a/crates/tracedecay-query/src/retrieval/graph/tests.rs b/crates/tracedecay-query/src/retrieval/graph/tests.rs index 3726c80b50..8b19201603 100644 --- a/crates/tracedecay-query/src/retrieval/graph/tests.rs +++ b/crates/tracedecay-query/src/retrieval/graph/tests.rs @@ -59,13 +59,7 @@ fn graph_control() -> Arc { }) } -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn digest_id(byte: char) -> T where diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection.rs b/crates/tracedecay-query/src/retrieval/lexical/projection.rs index 433b2dee35..3b61929551 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection.rs @@ -5,10 +5,11 @@ use serde::{Deserialize, Serialize}; use tracedecay_code_index::production::VerifiedSealedLexicalSymbolDisplayV1; use tracedecay_domain::{ BoundedSanitizedText, CodeGenerationId, CodeSearchChunkAnchorV1, CodeSearchChunkGrainV1, - CodeSearchChunkId, CodeSearchChunkV1, ComponentRevision, ExactFieldV1, - ExactTechnicalTermKindV1, ExactTechnicalTermV1, FileOccurrenceId, LanguageDescriptorRevision, - RepositoryId, RetrievalAnchorId, ScoreDomainId, SourceFreshness, exact_search_canonical, - split_subtokens, technical_tokens, validate_code_logical_path, + CodeSearchChunkId, CodeSearchChunkV1, CompactCandidate, ComponentRevision, EvidenceRole, + ExactAdmissionProof, ExactFieldV1, ExactTechnicalTermKindV1, ExactTechnicalTermV1, + FileOccurrenceId, FixedPointScore, LanguageDescriptorRevision, LogicalEvidenceId, RepositoryId, + RetrievalAnchorId, RetrieverKind, ScoreDomainId, SourceFreshness, SourceOccurrenceId, + exact_search_canonical, split_subtokens, technical_tokens, validate_code_logical_path, }; use super::{ @@ -16,7 +17,9 @@ use super::{ normalize_lexical, }; use crate::retrieval::exact::{ExactAdmissionAuthority, ExactLaneRequest}; -use crate::retrieval::ports::{RetrievalPortError, contract_error}; +use crate::retrieval::ports::{ + CodeCandidateBindingV1, CodeOccurrenceRefV1, RetrievalPortError, contract_error, +}; mod artifact; #[cfg(feature = "search-eval")] @@ -658,6 +661,87 @@ impl LexicalFieldTextV1 for ProjectedChunkV1 { } } +/// Row identity shared by the in-memory projection and the artifact reader. +trait LexicalIndexedRow { + fn chunk_id(&self) -> &CodeSearchChunkId; + fn anchor(&self) -> &CodeSearchChunkAnchorV1; + fn language_descriptor_revision(&self) -> &LanguageDescriptorRevision; +} + +impl LexicalIndexedRow for ProjectedChunkV1 { + fn chunk_id(&self) -> &CodeSearchChunkId { + &self.id + } + + fn anchor(&self) -> &CodeSearchChunkAnchorV1 { + &self.anchor + } + + fn language_descriptor_revision(&self) -> &LanguageDescriptorRevision { + &self.language_descriptor_revision + } +} + +fn lexical_lane_candidate( + row: &impl LexicalIndexedRow, + freshness: &SourceFreshness, + repository_id: Option, + retriever: RetrieverKind, + retriever_revision: ComponentRevision, + score_domain: ScoreDomainId, + exact_admission_proof: Option, +) -> Result { + let lane = retriever.as_str(); + let chunk_id = row.chunk_id().as_str(); + let generation = row.anchor().generation_id.as_str(); + let evidence_id = row.anchor().symbol_occurrence_id.as_ref().map_or_else( + || format!("code-chunk:{chunk_id}"), + |symbol| format!("code-symbol:{}", symbol.as_str()), + ); + Ok(CompactCandidate { + anchor_id: retrieval_anchor(evidence_id.clone())?, + logical_evidence_id: LogicalEvidenceId::new(evidence_id).map_err(contract_error)?, + source_occurrence_id: SourceOccurrenceId::new(format!( + "code-chunk:{generation}:{chunk_id}" + )) + .map_err(contract_error)?, + file_occurrence_id: Some(row.anchor().file_occurrence_id.clone()), + source_namespace: freshness.source_namespace.clone(), + repository_id, + session_or_thread_id: None, + logical_copy_cluster_id: None, + logical_copy_evidence_anchor: None, + evidence_role: EvidenceRole::Primary, + retriever, + retriever_revision, + score_domain, + raw_score: FixedPointScore::ZERO, + ordinal_rank: 0, + exact_admission_proof, + retriever_evidence_anchor: retrieval_anchor(format!("code-lexical:{lane}:{chunk_id}"))?, + freshness: freshness.clone(), + }) +} + +fn lexical_lane_binding( + row: &impl LexicalIndexedRow, + candidate: &CompactCandidate, + matched_term_kinds: Vec, +) -> CodeCandidateBindingV1 { + CodeCandidateBindingV1 { + candidate_anchor: candidate.anchor_id.clone(), + occurrence: CodeOccurrenceRefV1 { + generation: row.anchor().generation_id.clone(), + file: row.anchor().file_occurrence_id.clone(), + symbol: row.anchor().symbol_occurrence_id.clone(), + chunk: Some(row.chunk_id().clone()), + }, + language_descriptor_revision: row.language_descriptor_revision().clone(), + matched_term_kinds, + source_occurrence: candidate.source_occurrence_id.clone(), + } +} + fn normalized_field_text<'a>( row: &'a impl LexicalFieldTextV1, field: LexicalFieldV1, @@ -754,6 +838,24 @@ fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { .any(|window| window == needle) } +fn pack_byte_ngram(bytes: &[u8]) -> u32 { + debug_assert!((1..=3).contains(&bytes.len())); + bytes + .iter() + .enumerate() + .fold((bytes.len() as u32) << 24, |packed, (index, byte)| { + packed | (u32::from(*byte) << (index * 8)) + }) +} + +fn packed_query_ngrams(bytes: &[u8]) -> BTreeSet { + let width = bytes.len().min(3); + if width == 0 { + return BTreeSet::new(); + } + bytes.windows(width).map(pack_byte_ngram).collect() +} + fn add_score(scores: &mut BTreeMap, field: LexicalFieldV1, score: u64) { scores .entry(field) @@ -761,6 +863,132 @@ fn add_score(scores: &mut BTreeMap, field: LexicalFieldV1, .or_insert(score); } +/// Shared exact/fuzzy/phrase/proximity scoring for the in-memory projection +/// and the artifact reader. Callers supply term frequencies and BM25 inputs; +/// the loop, fuzzy discount, phrase boost, and echo penalty stay one place. +#[allow(clippy::too_many_arguments)] +fn score_lexical_row( + row: &impl LexicalFieldTextV1, + exact_terms: &[ExactTechnicalTermV1], + prepared: &PreparedLexicalQueryV1<'_>, + fuzzy: &FuzzyExpansionsV1, + phrase_document_frequencies: &BTreeMap, + mut term_frequency: impl FnMut(LexicalFieldV1, &str) -> usize, + mut document_frequency: impl FnMut(LexicalFieldV1, &str) -> usize, + mut bm25: impl FnMut(LexicalFieldV1, usize, usize) -> u64, +) -> LexicalRowScoreV1 { + let mut field_scores = BTreeMap::new(); + let mut matched_whole_terms = BTreeSet::new(); + let mut matched_subtokens = BTreeSet::new(); + let mut matched_phrases = BTreeSet::new(); + let mut matched_proximities = BTreeSet::new(); + let mut spelling_variants = BTreeSet::new(); + let mut matched_kinds = BTreeSet::new(); + let mut typo_recovery_applied = false; + for field in row.field_lengths().keys().copied() { + if field != LexicalFieldV1::Subtoken { + for (query_term, normalized) in &prepared.whole_terms { + let exact_tf = term_frequency(field, normalized); + if exact_tf > 0 { + add_score( + &mut field_scores, + field, + bm25(field, exact_tf, document_frequency(field, normalized)), + ); + matched_whole_terms.insert((*query_term).to_owned()); + collect_term_kinds(exact_terms, normalized, &mut matched_kinds); + } + if let Some(expansions) = fuzzy.by_query.get(*query_term) { + for expansion in expansions { + let fuzzy_tf = term_frequency(field, expansion); + if fuzzy_tf == 0 { + continue; + } + let score = bm25(field, fuzzy_tf, document_frequency(field, expansion)) + .saturating_mul(FUZZY_SCORE_MILLIS) + / 1_000; + add_score(&mut field_scores, field, score); + matched_whole_terms.insert((*query_term).to_owned()); + spelling_variants.insert(LexicalSpellingVariantV1 { + query: (*query_term).to_owned(), + alternative: expansion.clone(), + }); + typo_recovery_applied = true; + collect_term_kinds(exact_terms, expansion, &mut matched_kinds); + } + } + } + } else { + for (subtoken, normalized) in &prepared.subtokens { + let tf = term_frequency(field, normalized); + if tf > 0 { + add_score( + &mut field_scores, + field, + bm25(field, tf, document_frequency(field, normalized)), + ); + matched_subtokens.insert((*subtoken).to_owned()); + } + } + } + } + for (phrase, normalized) in &prepared.phrases { + for field in row.field_lengths().keys().copied() { + let Some(text) = normalized_field_text(row, field) else { + continue; + }; + let tf = substring_count(&text, normalized); + if tf == 0 { + continue; + } + let score = bm25( + field, + tf, + phrase_document_frequencies + .get(normalized) + .copied() + .unwrap_or_default(), + ) + .saturating_mul(PHRASE_SCORE_MILLIS) + / 1_000; + add_score(&mut field_scores, field, score); + matched_phrases.insert((*phrase).to_owned()); + } + } + for proximity in &prepared.proximities { + for field in row.field_lengths().keys().copied() { + let Some(text) = normalized_field_text(row, field) else { + continue; + }; + let tf = proximity_count(&text, &proximity.terms, proximity.original.maximum_gap); + if tf == 0 { + continue; + } + let score = bm25(field, tf, 1).saturating_mul(PHRASE_SCORE_MILLIS) / 1_000; + add_score(&mut field_scores, field, score); + matched_proximities.insert(proximity.original.clone()); + } + } + let echo_penalty_applied = + !prepared.echo_query.is_empty() && prepared.echo_query == row.normalized_text().trim(); + if echo_penalty_applied { + for score in field_scores.values_mut() { + *score = score.saturating_mul(ECHO_SCORE_MILLIS) / 1_000; + } + } + LexicalRowScoreV1 { + field_scores: field_scores.into_iter().collect(), + matched_whole_terms: matched_whole_terms.into_iter().collect(), + matched_subtokens: matched_subtokens.into_iter().collect(), + matched_phrases: matched_phrases.into_iter().collect(), + matched_proximities: matched_proximities.into_iter().collect(), + spelling_variants: spelling_variants.into_iter().collect(), + matched_kinds: matched_kinds.into_iter().collect(), + typo_recovery_applied, + echo_penalty_applied, + } +} + fn field_weight_millis(field: LexicalFieldV1) -> u64 { match field { LexicalFieldV1::SymbolName => 4_000, diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index f844c615b4..1edad59958 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -34,9 +34,10 @@ use super::format::{ BASE_SECTION_NAMES, CodeLexicalArtifactSectionDigestV1, RECEIPT_RESERVATION_BYTES, SECTION_NAMES, SERVING_INDEX_STEP_COUNT_V11, STATISTICS_STEP_COUNT_V11, VerifiedCodeLexicalArtifactV1, absorb_page_base_sections_receipt, artifact_digest, - decode_padded_receipt, decode_padded_receipt_with_control, finish_base_section_receipt_fold, - initial_base_section_receipt_fold, metadata_digest, new_verified_receipt, padded_receipt, - section_names, verify_artifact_table_layout, verify_required_artifact_indexes, + contract_number, decode_padded_receipt, decode_padded_receipt_with_control, + finish_base_section_receipt_fold, hash_bytes, initial_base_section_receipt_fold, + metadata_digest, new_verified_receipt, padded_receipt, section_names, + verify_artifact_table_layout, verify_required_artifact_indexes, }; use super::postings::document_ngram_scratch; use super::prepared::{ @@ -3996,7 +3997,7 @@ fn derive_clone_fingerprint_postings( .map_err(sqlite_error) } -fn derive_clone_fingerprint_counts( +pub(super) fn derive_clone_fingerprint_counts( transaction: &Transaction<'_>, ) -> Result<(), CodeLexicalArtifactErrorV1> { transaction @@ -6496,16 +6497,6 @@ fn hash_value(hasher: &mut Sha256, value: ValueRef<'_>) -> Result<(), CodeLexica Ok(()) } -fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { - hasher.update( - u64::try_from(bytes.len()) - .map_err(contract_number)? - .to_le_bytes(), - ); - hasher.update(bytes); - Ok(()) -} - fn read_receipt( connection: &Connection, ) -> Result, CodeLexicalArtifactErrorV1> { @@ -6555,177 +6546,169 @@ fn verify_source_receipt( Ok(()) } -#[cfg(feature = "hotpath")] fn record_finalization_step(step: &CodeLexicalArtifactFinalizationStepV1) { - match step { - CodeLexicalArtifactFinalizationStepV1::Pending { completed_rows, .. } => { - hotpath::gauge!("query.artifact.finalization.outcome.pending_total").inc(1u64); - crate::hotpath_metrics::Residency::Rebuilding.record("query.artifact.residency"); - hotpath::gauge!("query.artifact.rows").set(*completed_rows); - } - CodeLexicalArtifactFinalizationStepV1::Ready(receipt) => { - hotpath::gauge!("query.artifact.finalization.outcome.ready_total").inc(1u64); - crate::hotpath_metrics::Residency::Warm.record("query.artifact.residency"); - hotpath::gauge!("query.artifact.pages").set(receipt.page_count()); - hotpath::gauge!("query.artifact.bytes").set(receipt.file_size_bytes()); + #[cfg(feature = "hotpath")] + { + match step { + CodeLexicalArtifactFinalizationStepV1::Pending { completed_rows, .. } => { + hotpath::gauge!("query.artifact.finalization.outcome.pending_total").inc(1u64); + crate::hotpath_metrics::Residency::Rebuilding.record("query.artifact.residency"); + hotpath::gauge!("query.artifact.rows").set(*completed_rows); + } + CodeLexicalArtifactFinalizationStepV1::Ready(receipt) => { + hotpath::gauge!("query.artifact.finalization.outcome.ready_total").inc(1u64); + crate::hotpath_metrics::Residency::Warm.record("query.artifact.residency"); + hotpath::gauge!("query.artifact.pages").set(receipt.page_count()); + hotpath::gauge!("query.artifact.bytes").set(receipt.file_size_bytes()); + } } } -} - -#[cfg(not(feature = "hotpath"))] -fn record_finalization_step(step: &CodeLexicalArtifactFinalizationStepV1) { + #[cfg(not(feature = "hotpath"))] let _ = step; } -#[cfg(feature = "hotpath")] fn record_batch_outcome( result: &Result, ) { - match result { - Ok(_) => { - hotpath::gauge!("query.artifact.batch.outcome.committed_total").inc(1u64); - } - Err(CodeLexicalArtifactErrorV1::Interrupted(_)) => { - hotpath::gauge!("query.artifact.batch.outcome.interrupted_total").inc(1u64); - } - Err(_) => { - hotpath::gauge!("query.artifact.batch.outcome.failed_total").inc(1u64); + #[cfg(feature = "hotpath")] + { + match result { + Ok(_) => { + hotpath::gauge!("query.artifact.batch.outcome.committed_total").inc(1u64); + } + Err(CodeLexicalArtifactErrorV1::Interrupted(_)) => { + hotpath::gauge!("query.artifact.batch.outcome.interrupted_total").inc(1u64); + } + Err(_) => { + hotpath::gauge!("query.artifact.batch.outcome.failed_total").inc(1u64); + } } } -} - -#[cfg(not(feature = "hotpath"))] -fn record_batch_outcome( - result: &Result, -) { + #[cfg(not(feature = "hotpath"))] let _ = result; } -#[cfg(feature = "hotpath")] -fn record_prepared_batch_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { - let documents = pages.iter().map(|page| page.documents.len()).sum::(); - let source_bytes = pages - .iter() - .map(PreparedCodeLexicalArtifactPageV1::source_retained_bytes) - .sum::(); - let prepared_bytes = pages - .iter() - .map(PreparedCodeLexicalArtifactPageV1::retained_owned_bytes) - .sum::(); - let effective_workers = tracedecay_code_index::parallelism::indexing_workers().min(pages.len()); - let mut scratch = pages - .iter() - .map(PreparedCodeLexicalArtifactPageV1::preparation_scratch_bytes) - .collect::>(); - scratch.sort_unstable_by(|left, right| right.cmp(left)); - let active_scratch = scratch.into_iter().take(effective_workers).sum::(); - hotpath::gauge!("query.artifact.batch.prepared_pages_total").inc(pages.len() as u64); - hotpath::gauge!("query.artifact.batch.prepared_documents_total").inc(documents as u64); - hotpath::gauge!("query.artifact.batch.source_bytes_total").inc(source_bytes as u64); - hotpath::gauge!("query.artifact.batch.prepared_bytes_total").inc(prepared_bytes as u64); - hotpath::gauge!("query.artifact.batch.active_scratch_bytes_total").inc(active_scratch as u64); - hotpath::gauge!("query.artifact.batch.effective_workers").set(effective_workers as u64); -} - -#[cfg(not(feature = "hotpath"))] fn record_prepared_batch_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + #[cfg(feature = "hotpath")] + { + let documents = pages.iter().map(|page| page.documents.len()).sum::(); + let source_bytes = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::source_retained_bytes) + .sum::(); + let prepared_bytes = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::retained_owned_bytes) + .sum::(); + let effective_workers = + tracedecay_code_index::parallelism::indexing_workers().min(pages.len()); + let mut scratch = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::preparation_scratch_bytes) + .collect::>(); + scratch.sort_unstable_by(|left, right| right.cmp(left)); + let active_scratch = scratch.into_iter().take(effective_workers).sum::(); + hotpath::gauge!("query.artifact.batch.prepared_pages_total").inc(pages.len() as u64); + hotpath::gauge!("query.artifact.batch.prepared_documents_total").inc(documents as u64); + hotpath::gauge!("query.artifact.batch.source_bytes_total").inc(source_bytes as u64); + hotpath::gauge!("query.artifact.batch.prepared_bytes_total").inc(prepared_bytes as u64); + hotpath::gauge!("query.artifact.batch.active_scratch_bytes_total") + .inc(active_scratch as u64); + hotpath::gauge!("query.artifact.batch.effective_workers").set(effective_workers as u64); + } + #[cfg(not(feature = "hotpath"))] let _ = pages; } -#[cfg(feature = "hotpath")] -fn record_batch_import_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { - let imports = pages.iter().map(|page| page.imports.len()).sum::(); - hotpath::gauge!("query.artifact.batch.import_rows_total").inc(imports as u64); -} - -#[cfg(not(feature = "hotpath"))] fn record_batch_import_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + #[cfg(feature = "hotpath")] + { + let imports = pages.iter().map(|page| page.imports.len()).sum::(); + hotpath::gauge!("query.artifact.batch.import_rows_total").inc(imports as u64); + } + #[cfg(not(feature = "hotpath"))] let _ = pages; } -#[cfg(feature = "hotpath")] -fn record_batch_posting_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { - let relational_postings = pages - .iter() - .flat_map(|page| &page.documents) - .map(|document| document.term_postings.len() + document.exact_postings.len()) - .sum::(); - let ngram_shards = pages - .iter() - .map(|page| page.ngram_shards.len()) - .sum::(); - let ngram_documents = pages - .iter() - .flat_map(|page| &page.ngram_shards) - .map(|shard| shard.cardinality) - .sum::(); - let ngram_bytes = pages - .iter() - .flat_map(|page| &page.ngram_shards) - .map(|shard| shard.documents.len()) - .sum::(); - hotpath::gauge!("query.artifact.batch.posting_rows_total").inc(relational_postings as u64); - hotpath::gauge!("query.artifact.batch.ngram_shard_rows_total").inc(ngram_shards as u64); - hotpath::gauge!("query.artifact.batch.ngram_documents_total").inc(ngram_documents); - hotpath::gauge!("query.artifact.batch.ngram_bytes_total").inc(ngram_bytes as u64); -} - -#[cfg(not(feature = "hotpath"))] fn record_batch_posting_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + #[cfg(feature = "hotpath")] + { + let relational_postings = pages + .iter() + .flat_map(|page| &page.documents) + .map(|document| document.term_postings.len() + document.exact_postings.len()) + .sum::(); + let ngram_shards = pages + .iter() + .map(|page| page.ngram_shards.len()) + .sum::(); + let ngram_documents = pages + .iter() + .flat_map(|page| &page.ngram_shards) + .map(|shard| shard.cardinality) + .sum::(); + let ngram_bytes = pages + .iter() + .flat_map(|page| &page.ngram_shards) + .map(|shard| shard.documents.len()) + .sum::(); + hotpath::gauge!("query.artifact.batch.posting_rows_total").inc(relational_postings as u64); + hotpath::gauge!("query.artifact.batch.ngram_shard_rows_total").inc(ngram_shards as u64); + hotpath::gauge!("query.artifact.batch.ngram_documents_total").inc(ngram_documents); + hotpath::gauge!("query.artifact.batch.ngram_bytes_total").inc(ngram_bytes as u64); + } + #[cfg(not(feature = "hotpath"))] let _ = pages; } -#[cfg(feature = "hotpath")] -fn record_batch_row_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { - let rows = pages.iter().map(|page| page.documents.len()).sum::(); - hotpath::gauge!("query.artifact.batch.document_rows_total").inc(rows as u64); -} - -#[cfg(not(feature = "hotpath"))] fn record_batch_row_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + #[cfg(feature = "hotpath")] + { + let rows = pages.iter().map(|page| page.documents.len()).sum::(); + hotpath::gauge!("query.artifact.batch.document_rows_total").inc(rows as u64); + } + #[cfg(not(feature = "hotpath"))] let _ = pages; } -#[cfg(feature = "hotpath")] -fn record_batch_receipt_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { - hotpath::gauge!("query.artifact.batch.receipt_rows_total").inc(pages.len() as u64); -} - -#[cfg(not(feature = "hotpath"))] fn record_batch_receipt_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.batch.receipt_rows_total").inc(pages.len() as u64); + } + #[cfg(not(feature = "hotpath"))] let _ = pages; } -#[cfg(feature = "hotpath")] fn record_batch_prefix_limit(limit: CodeLexicalArtifactBatchLimitV1) { - match limit { - CodeLexicalArtifactBatchLimitV1::Memory => { - hotpath::gauge!("query.artifact.batch.prefix_limited.memory_total").inc(1u64); - } - CodeLexicalArtifactBatchLimitV1::PreparedRows => { - hotpath::gauge!("query.artifact.batch.prefix_limited.prepared_rows_total").inc(1u64); - } - CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes => { - hotpath::gauge!("query.artifact.batch.prefix_limited.estimated_write_bytes_total") - .inc(1u64); + #[cfg(feature = "hotpath")] + { + match limit { + CodeLexicalArtifactBatchLimitV1::Memory => { + hotpath::gauge!("query.artifact.batch.prefix_limited.memory_total").inc(1u64); + } + CodeLexicalArtifactBatchLimitV1::PreparedRows => { + hotpath::gauge!("query.artifact.batch.prefix_limited.prepared_rows_total") + .inc(1u64); + } + CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes => { + hotpath::gauge!("query.artifact.batch.prefix_limited.estimated_write_bytes_total") + .inc(1u64); + } } } -} - -#[cfg(not(feature = "hotpath"))] -fn record_batch_prefix_limit(limit: CodeLexicalArtifactBatchLimitV1) { + #[cfg(not(feature = "hotpath"))] let _ = limit; } -#[cfg(feature = "hotpath")] -fn record_artifact_progress(progress: &CodeLexicalArtifactBuildProgressV1) { - hotpath::gauge!("query.artifact.pages").set(progress.next_page_ordinal); - hotpath::gauge!("query.artifact.rows").set(progress.completed_chunks); - hotpath::gauge!("query.artifact.bytes").set(progress.completed_payload_bytes); -} - -#[cfg(not(feature = "hotpath"))] fn record_artifact_progress(progress: &CodeLexicalArtifactBuildProgressV1) { + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.pages").set(progress.next_page_ordinal); + hotpath::gauge!("query.artifact.rows").set(progress.completed_chunks); + hotpath::gauge!("query.artifact.bytes").set(progress.completed_payload_bytes); + } + #[cfg(not(feature = "hotpath"))] let _ = progress; } @@ -6831,10 +6814,6 @@ fn require_integrity( Ok(()) } -fn contract_number(error: impl std::fmt::Display) -> CodeLexicalArtifactErrorV1 { - CodeLexicalArtifactErrorV1::Contract(error.to_string()) -} - #[cfg(test)] mod tests { use super::super::format::encode_ngram_bitmap; diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/clone_successor.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/clone_successor.rs index bb09cccdfa..520ed70b01 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/clone_successor.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/clone_successor.rs @@ -14,8 +14,8 @@ use tracedecay_private_fs::{create_private_file_retained, open_private_file}; use super::super::CodeLexicalProjectionMetadataV1; use super::builder::{ - BuilderMutationGuardV1, compute_clone_section_digests, install_clone_freeze, - register_builder_mutation_gate, sqlite_file_size, verify_clone_rows, + BuilderMutationGuardV1, compute_clone_section_digests, derive_clone_fingerprint_counts, + install_clone_freeze, register_builder_mutation_gate, sqlite_file_size, verify_clone_rows, }; use super::format::{ RECEIPT_RESERVATION_BYTES, VerifiedCodeLexicalArtifactV1, artifact_digest, @@ -810,16 +810,3 @@ fn verify_source_receipt( } Ok(()) } - -fn derive_clone_fingerprint_counts( - transaction: &rusqlite::Transaction<'_>, -) -> Result<(), CodeLexicalArtifactErrorV1> { - transaction - .execute_batch( - "INSERT INTO clone_fingerprint_counts(language, class, normalization_revision, fingerprint, posting_count) - SELECT language, class, normalization_revision, fingerprint, COUNT(*) - FROM clone_fingerprint_postings - GROUP BY language, class, normalization_revision, fingerprint;", - ) - .map_err(sqlite_error) -} diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/fingerprints.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/fingerprints.rs index 5d9125de82..742d0ff9dc 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/fingerprints.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/fingerprints.rs @@ -11,7 +11,7 @@ use tracedecay_code_index::clones::{ use tracedecay_code_index::production::CodeIndexExecutionControlV1; use tracedecay_domain::{ManifestDigest, RetrieverCoverage, SymbolOccurrenceId, canonical_sha256}; -use super::format::VerifiedCodeLexicalArtifactV1; +use super::format::{VerifiedCodeLexicalArtifactV1, contract_number}; use super::reader::{CloneArtifactCursorPositionV1, CloneArtifactCursorV1, CloneArtifactPageV1}; use super::schema::LexicalArtifactLayoutV1; use super::{CodeLexicalArtifactErrorV1, sqlite_error}; @@ -732,10 +732,6 @@ fn interrupt( } } -fn contract_number(error: impl std::fmt::Display) -> CodeLexicalArtifactErrorV1 { - CodeLexicalArtifactErrorV1::Contract(error.to_string()) -} - #[cfg(test)] mod tests { use super::candidate_size_ratio_admitted; diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs index a75dbe64c7..70bcfe2642 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs @@ -138,12 +138,12 @@ impl PageBaseSectionReceiptBuilderV1 { pub(super) fn text(&mut self, value: &str) -> Result<(), CodeLexicalArtifactErrorV1> { self.hasher.update([3]); - hash_receipt_bytes(&mut self.hasher, value.as_bytes()) + hash_bytes(&mut self.hasher, value.as_bytes()) } pub(super) fn blob(&mut self, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { self.hasher.update([4]); - hash_receipt_bytes(&mut self.hasher, value) + hash_bytes(&mut self.hasher, value) } pub(super) fn finish( @@ -162,13 +162,20 @@ impl PageBaseSectionReceiptBuilderV1 { } } -fn hash_receipt_bytes(hasher: &mut Sha256, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { +pub(super) fn contract_number(error: impl std::fmt::Display) -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Contract(error.to_string()) +} + +pub(super) fn hash_bytes( + hasher: &mut Sha256, + bytes: &[u8], +) -> Result<(), CodeLexicalArtifactErrorV1> { hasher.update( - u64::try_from(value.len()) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + u64::try_from(bytes.len()) + .map_err(contract_number)? .to_le_bytes(), ); - hasher.update(value); + hasher.update(bytes); Ok(()) } @@ -217,7 +224,7 @@ pub(super) fn initial_base_section_receipt_fold() .map(|name| { let mut hasher = Sha256::new(); hasher.update(b"tracedecay.code-lexical-artifact-base-receipt-fold.v1\0initial"); - hash_receipt_bytes(&mut hasher, name.as_bytes())?; + hash_bytes(&mut hasher, name.as_bytes())?; Ok(hasher.finalize().to_vec()) }) .collect::, CodeLexicalArtifactErrorV1>>()?; @@ -246,10 +253,10 @@ pub(super) fn absorb_page_base_sections_receipt( })?; let mut hasher = Sha256::new(); hasher.update(b"tracedecay.code-lexical-artifact-base-receipt-fold.v1\0page"); - hash_receipt_bytes(&mut hasher, section.name.as_bytes())?; + hash_bytes(&mut hasher, section.name.as_bytes())?; hasher.update(page_ordinal.to_le_bytes()); hasher.update(section.row_count.to_le_bytes()); - hash_receipt_bytes(&mut hasher, section.digest.as_str().as_bytes())?; + hash_bytes(&mut hasher, section.digest.as_str().as_bytes())?; hasher.update(previous); accumulators[ordinal] = hasher.finalize().to_vec(); row_counts[ordinal] = row_counts[ordinal] @@ -287,7 +294,7 @@ pub(super) fn finish_base_section_receipt_fold( })?; let mut hasher = Sha256::new(); hasher.update(b"tracedecay.code-lexical-artifact-base-receipt-fold.v1\0final"); - hash_receipt_bytes(&mut hasher, name.as_bytes())?; + hash_bytes(&mut hasher, name.as_bytes())?; hasher.update(row_counts[ordinal].to_le_bytes()); hasher.update(accumulator); let digest = ManifestDigest::from_sha256_bytes(&hasher.finalize()) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs index 211674f880..7667481d54 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs @@ -90,7 +90,7 @@ pub(super) fn document_ngrams( if observed.is_multiple_of(4_096) { checkpoint(control)?; } - ngrams.push(pack_byte_ngram(window)); + ngrams.push(super::super::pack_byte_ngram(window)); observed = observed.checked_add(1).ok_or_else(|| { CodeLexicalArtifactErrorV1::Contract( "lexical artifact n-gram work count overflowed".to_owned(), @@ -104,21 +104,7 @@ pub(super) fn document_ngrams( } pub(super) fn query_ngrams(bytes: &[u8]) -> BTreeSet { - let width = bytes.len().min(3); - if width == 0 { - return BTreeSet::new(); - } - bytes.windows(width).map(pack_byte_ngram).collect() -} - -fn pack_byte_ngram(bytes: &[u8]) -> u32 { - debug_assert!((1..=3).contains(&bytes.len())); - bytes - .iter() - .enumerate() - .fold((bytes.len() as u32) << 24, |packed, (index, byte)| { - packed | (u32::from(*byte) << (index * 8)) - }) + super::super::packed_query_ngrams(bytes) } #[cfg(test)] diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs index 947e64e912..e74bfe4732 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs @@ -13,8 +13,9 @@ use super::super::{ exact_field_for_kind, normalized_search_text, }; use super::format::{ - ArtifactRowV1, BASE_SECTION_NAMES, PageBaseSectionReceiptBuilderV1, encode_exact_field, - encode_field, encode_ngram_bitmap, encode_page_base_sections_receipt, ngram_page_digest, + ArtifactRowV1, BASE_SECTION_NAMES, PageBaseSectionReceiptBuilderV1, contract_number, + encode_exact_field, encode_field, encode_ngram_bitmap, encode_page_base_sections_receipt, + hash_bytes, ngram_page_digest, }; use super::postings::{NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, document_ngrams}; use super::row_codec::{RowDictionaryTableV1, encode_artifact_row}; @@ -645,16 +646,6 @@ fn hash_blob(hasher: &mut Sha256, value: &[u8]) -> Result<(), CodeLexicalArtifac hash_bytes(hasher, value) } -fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { - hasher.update( - u64::try_from(bytes.len()) - .map_err(contract_number)? - .to_le_bytes(), - ); - hasher.update(bytes); - Ok(()) -} - fn import_integrity_digest( canonical: &[u8], evidence: &[u8], @@ -958,7 +949,3 @@ fn prepared_charge_overflow() -> CodeLexicalArtifactErrorV1 { "prepared lexical page retained-byte charge overflowed".to_owned(), ) } - -fn contract_number(error: impl std::fmt::Display) -> CodeLexicalArtifactErrorV1 { - CodeLexicalArtifactErrorV1::Contract(error.to_string()) -} diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs index af80a42cbe..10d7952d02 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs @@ -24,11 +24,10 @@ use tracedecay_code_index::clones::{ }; use tracedecay_code_index::production::CodeIndexExecutionControlV1; use tracedecay_domain::{ - CodeGenerationId, CodeSearchChunkGrainV1, CodeSearchChunkId, CompactCandidate, - ComponentRevision, EvidenceRole, ExactAdmissionProof, ExactFieldV1, ExactTechnicalTermKindV1, - FixedPointScore, LogicalEvidenceId, ManifestDigest, RetrieverBatch, RetrieverCoverage, - RetrieverKind, RetrieverOutcome, ScoreDomainId, SourceOccurrenceId, SourceSpan, - SymbolOccurrenceId, canonical_sha256, + CodeGenerationId, CodeSearchChunkAnchorV1, CodeSearchChunkGrainV1, CodeSearchChunkId, + CompactCandidate, ExactFieldV1, ExactTechnicalTermKindV1, LanguageDescriptorRevision, + ManifestDigest, RetrieverBatch, RetrieverCoverage, RetrieverKind, RetrieverOutcome, + SourceOccurrenceId, SourceSpan, SymbolOccurrenceId, canonical_sha256, }; use tracedecay_private_fs::open_private_file; @@ -58,22 +57,22 @@ use super::{ use crate::retrieval::exact::{ExactAdmissionAuthority, ExactLaneEvidence, ExactLaneRequest}; use crate::retrieval::ports::RetrievalExecutionControl; use crate::retrieval::ports::{ - CodeCandidateBindingV1, CodeOccurrenceRefV1, ExactTermPostingReadPort, LexicalPostingReadPort, + CodeCandidateBindingV1, ExactTermPostingReadPort, LexicalPostingReadPort, RETRIEVAL_CANDIDATE_BATCH_SIZE, RetrievalPortError, contract_error, lane_candidate_cap, retrieval_checkpoint, }; use super::super::{ - ECHO_SCORE_MILLIS, ExactMatchRowViewV1, FUZZY_SCORE_MILLIS, FuzzyExpansionsV1, - FuzzyQueryGroupV1, LexicalFieldTextV1, LexicalRowScoreV1, LiteralProofCacheV1, - PHRASE_SCORE_MILLIS, PreparedLexicalQueryV1, add_score, bm25_score_micros, collect_term_kinds, - exact_matches, field_weight_millis, fuzzy_distance_bound, matches_phrase, normalize_lexical, - normalized_field_text, proximity_count, retrieval_anchor, substring_count, + ExactMatchRowViewV1, FuzzyExpansionsV1, FuzzyQueryGroupV1, LexicalFieldTextV1, + LexicalIndexedRow, LexicalRowScoreV1, LiteralProofCacheV1, PreparedLexicalQueryV1, + bm25_score_micros, exact_matches, field_weight_millis, fuzzy_distance_bound, + lexical_lane_binding, lexical_lane_candidate, matches_phrase, normalize_lexical, + score_lexical_row, }; use crate::retrieval::lexical::{ LexicalFieldFilterV1, LexicalFieldV1, LexicalLaneEvidence, LexicalLaneRequest, - LexicalSpellingVariantV1, MAX_FUZZY_TERM_EXPANSIONS_V1, MAX_LEXICAL_QUERY_TERM_BYTES_V1, - admit_candidate_sources, candidate_admission_outcome, field_admitted, + MAX_FUZZY_TERM_EXPANSIONS_V1, MAX_LEXICAL_QUERY_TERM_BYTES_V1, admit_candidate_sources, + candidate_admission_outcome, field_admitted, }; impl LexicalFieldTextV1 for ArtifactRowV1 { @@ -110,6 +109,20 @@ impl LexicalFieldTextV1 for ArtifactRowV1 { } } +impl LexicalIndexedRow for ArtifactRowV1 { + fn chunk_id(&self) -> &CodeSearchChunkId { + &self.id + } + + fn anchor(&self) -> &CodeSearchChunkAnchorV1 { + &self.anchor + } + + fn language_descriptor_revision(&self) -> &LanguageDescriptorRevision { + &self.language_descriptor_revision + } +} + #[derive(Clone)] pub struct CodeLexicalArtifactReaderV1 { connection: Arc>, @@ -151,18 +164,6 @@ pub(super) enum CloneArtifactCursorPositionV1 { } impl CloneArtifactCursorV1 { - /// Digests identifying the last completed fingerprint candidate when this - /// cursor continues a near-clone page; `None` for exact-posting cursors. - pub fn fingerprint_continuation_digests(&self) -> Option<(&ManifestDigest, &ManifestDigest)> { - match &self.after { - CloneArtifactCursorPositionV1::Fingerprint { - body_digest, - payload_digest, - } => Some((body_digest, payload_digest)), - CloneArtifactCursorPositionV1::Exact(_) => None, - } - } - pub fn encode(&self) -> Result { serde_json::to_vec(self) .map(hex::encode) @@ -2166,7 +2167,7 @@ impl<'a> ArtifactQueryV1<'a> { &phrase_frequencies, &stats, &frequencies, - )?; + ); let Some(ranking) = admitted_score_micros(&score, &request.field_filters)? else { return Ok(()); }; @@ -2175,10 +2176,9 @@ impl<'a> ArtifactQueryV1<'a> { retain_bounded( &mut ranked, cap, - RankedLexicalEntryV1 { + Keyed { key: (Reverse(ranking), row.id.as_str().to_owned(), document), - score, - row, + value: (score, row), }, ); Ok(()) @@ -2192,14 +2192,14 @@ impl<'a> ArtifactQueryV1<'a> { if ordinal.is_multiple_of(RETRIEVAL_CANDIDATE_BATCH_SIZE) { retrieval_checkpoint(control)?; } - let RankedLexicalEntryV1 { - key: (_, _, _), - score, - row, + let Keyed { + value: (score, row), + .. } = entry; - let mut candidate = candidate( - self.receipt, + let mut candidate = lexical_lane_candidate( &row, + self.receipt.freshness(), + self.receipt.repository_id().cloned(), RetrieverKind::Lexical, self.metadata.lexical_retriever_revision.clone(), request.score_domain.clone(), @@ -2207,7 +2207,7 @@ impl<'a> ArtifactQueryV1<'a> { )?; candidate.ordinal_rank = ordinal as u32; let evidence = LexicalLaneEvidence { - binding: binding(&row, &candidate, score.matched_kinds), + binding: lexical_lane_binding(&row, &candidate, score.matched_kinds), field_scores_micros: score.field_scores, matched_whole_terms: score.matched_whole_terms, matched_subtokens: score.matched_subtokens, @@ -2271,15 +2271,13 @@ impl<'a> ArtifactQueryV1<'a> { retain_bounded( &mut ranked, cap, - RankedExactEntryV1 { + Keyed { key: ( Reverse(matched_literals.len()), row.id.as_str().to_owned(), document, ), - admitted_ordinal, - matched_literals, - matched_kinds, + value: (admitted_ordinal, matched_literals, matched_kinds), }, ); Ok(()) @@ -2293,11 +2291,9 @@ impl<'a> ArtifactQueryV1<'a> { if ordinal.is_multiple_of(RETRIEVAL_CANDIDATE_BATCH_SIZE) { retrieval_checkpoint(request.control)?; } - let RankedExactEntryV1 { + let Keyed { key: (_, _, document), - admitted_ordinal, - matched_literals, - matched_kinds, + value: (admitted_ordinal, matched_literals, matched_kinds), } = entry; let proof = proofs.admitted_proof(admitted_ordinal)?; let matched_literals = matched_literals @@ -2305,9 +2301,10 @@ impl<'a> ArtifactQueryV1<'a> { .map(|literal| request.literals[*literal].clone()) .collect::>(); let row = self.row(document)?; - let mut candidate = candidate( - self.receipt, + let mut candidate = lexical_lane_candidate( &row, + self.receipt.freshness(), + self.receipt.repository_id().cloned(), RetrieverKind::ExactLiteral, self.metadata.exact_retriever_revision.clone(), self.metadata.exact_score_domain.clone(), @@ -2315,7 +2312,7 @@ impl<'a> ArtifactQueryV1<'a> { )?; candidate.ordinal_rank = ordinal as u32; let evidence = ExactLaneEvidence { - binding: binding(&row, &candidate, matched_kinds), + binding: lexical_lane_binding(&row, &candidate, matched_kinds), matched_literals, admission_proof: proof, }; @@ -2785,157 +2782,23 @@ impl<'a> ArtifactQueryV1<'a> { phrase_frequencies: &BTreeMap, stats: &LexicalStatsCacheV1, frequencies: &LexicalTermFrequenciesV1, - ) -> Result { + ) -> LexicalRowScoreV1 { crate::hotpath_metrics::measure_frequent("query.lane.lexical.score_row", || { - self.score_row_inner(row, prepared, fuzzy, phrase_frequencies, stats, frequencies) - }) - } - - fn score_row_inner( - &self, - row: &ArtifactRowV1, - prepared: &PreparedLexicalQueryV1<'_>, - fuzzy: &FuzzyExpansionsV1, - phrase_frequencies: &BTreeMap, - stats: &LexicalStatsCacheV1, - frequencies: &LexicalTermFrequenciesV1, - ) -> Result { - let mut field_scores = BTreeMap::new(); - let mut matched_whole_terms = BTreeSet::new(); - let mut matched_subtokens = BTreeSet::new(); - let mut matched_phrases = BTreeSet::new(); - let mut matched_proximities = BTreeSet::new(); - let mut spelling_variants = BTreeSet::new(); - let mut matched_kinds = BTreeSet::new(); - let mut typo_recovery_applied = false; - for field in row.field_lengths.keys() { - if *field != LexicalFieldV1::Subtoken { - for (query_term, normalized) in &prepared.whole_terms { - let exact_tf = term_frequency(frequencies, *field, normalized); - if exact_tf > 0 { - add_score( - &mut field_scores, - *field, - self.term_score(*field, normalized, exact_tf, row, stats), - ); - matched_whole_terms.insert((*query_term).to_owned()); - collect_term_kinds(&row.exact_terms, normalized, &mut matched_kinds); - } - if let Some(expansions) = fuzzy.by_query.get(*query_term) { - for expansion in expansions { - let tf = term_frequency(frequencies, *field, expansion); - if tf == 0 { - continue; - } - let score = self - .term_score(*field, expansion, tf, row, stats) - .saturating_mul(FUZZY_SCORE_MILLIS) - / 1_000; - add_score(&mut field_scores, *field, score); - matched_whole_terms.insert((*query_term).to_owned()); - spelling_variants.insert(LexicalSpellingVariantV1 { - query: (*query_term).to_owned(), - alternative: expansion.clone(), - }); - typo_recovery_applied = true; - collect_term_kinds(&row.exact_terms, expansion, &mut matched_kinds); - } - } - } - } else { - for (subtoken, normalized) in &prepared.subtokens { - let tf = term_frequency(frequencies, *field, normalized); - if tf > 0 { - add_score( - &mut field_scores, - *field, - self.term_score(*field, normalized, tf, row, stats), - ); - matched_subtokens.insert((*subtoken).to_owned()); - } - } - } - } - for (phrase, normalized) in &prepared.phrases { - for field in row.field_lengths.keys() { - let Some(text) = normalized_field_text(row, *field) else { - continue; - }; - let tf = substring_count(&text, normalized); - if tf == 0 { - continue; - } - let score = self - .term_score_with_df( - *field, - tf, - row, - phrase_frequencies - .get(normalized) - .copied() - .unwrap_or_default(), - stats, - ) - .saturating_mul(PHRASE_SCORE_MILLIS) - / 1_000; - add_score(&mut field_scores, *field, score); - matched_phrases.insert((*phrase).to_owned()); - } - } - for proximity in &prepared.proximities { - for field in row.field_lengths.keys() { - let Some(text) = normalized_field_text(row, *field) else { - continue; - }; - let tf = proximity_count(&text, &proximity.terms, proximity.original.maximum_gap); - if tf == 0 { - continue; - } - let score = self - .term_score_with_df(*field, tf, row, 1, stats) - .saturating_mul(PHRASE_SCORE_MILLIS) - / 1_000; - add_score(&mut field_scores, *field, score); - matched_proximities.insert(proximity.original.clone()); - } - } - let echo_penalty_applied = - !prepared.echo_query.is_empty() && prepared.echo_query == row.normalized_text.trim(); - if echo_penalty_applied { - for score in field_scores.values_mut() { - *score = score.saturating_mul(ECHO_SCORE_MILLIS) / 1_000; - } - } - Ok(LexicalRowScoreV1 { - field_scores: field_scores.into_iter().collect(), - matched_whole_terms: matched_whole_terms.into_iter().collect(), - matched_subtokens: matched_subtokens.into_iter().collect(), - matched_phrases: matched_phrases.into_iter().collect(), - matched_proximities: matched_proximities.into_iter().collect(), - spelling_variants: spelling_variants.into_iter().collect(), - matched_kinds: matched_kinds.into_iter().collect(), - typo_recovery_applied, - echo_penalty_applied, + score_lexical_row( + row, + &row.exact_terms, + prepared, + fuzzy, + phrase_frequencies, + |field, term| term_frequency(frequencies, field, term), + |field, term| stats.document_frequency(field, term), + |field, term_frequency, document_frequency| { + self.term_score_with_df(field, term_frequency, row, document_frequency, stats) + }, + ) }) } - fn term_score( - &self, - field: LexicalFieldV1, - term: &str, - term_frequency: usize, - row: &ArtifactRowV1, - stats: &LexicalStatsCacheV1, - ) -> u64 { - self.term_score_with_df( - field, - term_frequency, - row, - stats.document_frequency(field, term), - stats, - ) - } - fn term_score_with_df( &self, field: LexicalFieldV1, @@ -3028,120 +2891,29 @@ impl LexicalStatsCacheV1 { } } -fn candidate( - receipt: &VerifiedCodeLexicalArtifactV1, - row: &ArtifactRowV1, - retriever: RetrieverKind, - retriever_revision: ComponentRevision, - score_domain: ScoreDomainId, - exact_admission_proof: Option, -) -> Result { - let lane = retriever.as_str(); - let chunk_id = row.id.as_str(); - let generation = row.anchor.generation_id.as_str(); - let evidence_id = row.anchor.symbol_occurrence_id.as_ref().map_or_else( - || format!("code-chunk:{chunk_id}"), - |symbol| format!("code-symbol:{}", symbol.as_str()), - ); - Ok(CompactCandidate { - anchor_id: retrieval_anchor(evidence_id.clone())?, - logical_evidence_id: LogicalEvidenceId::new(evidence_id).map_err(contract_error)?, - source_occurrence_id: SourceOccurrenceId::new(format!( - "code-chunk:{generation}:{chunk_id}" - )) - .map_err(contract_error)?, - file_occurrence_id: Some(row.anchor.file_occurrence_id.clone()), - source_namespace: receipt.freshness().source_namespace.clone(), - repository_id: receipt.repository_id().cloned(), - session_or_thread_id: None, - logical_copy_cluster_id: None, - logical_copy_evidence_anchor: None, - evidence_role: EvidenceRole::Primary, - retriever, - retriever_revision, - score_domain, - raw_score: FixedPointScore::ZERO, - ordinal_rank: 0, - exact_admission_proof, - retriever_evidence_anchor: retrieval_anchor(format!("code-lexical:{lane}:{chunk_id}"))?, - freshness: receipt.freshness().clone(), - }) -} - -fn binding( - row: &ArtifactRowV1, - candidate: &CompactCandidate, - matched_term_kinds: Vec, -) -> CodeCandidateBindingV1 { - CodeCandidateBindingV1 { - candidate_anchor: candidate.anchor_id.clone(), - occurrence: CodeOccurrenceRefV1 { - generation: row.anchor.generation_id.clone(), - file: row.anchor.file_occurrence_id.clone(), - symbol: row.anchor.symbol_occurrence_id.clone(), - chunk: Some(row.id.clone()), - }, - language_descriptor_revision: row.language_descriptor_revision.clone(), - matched_term_kinds, - source_occurrence: candidate.source_occurrence_id.clone(), - } -} - -/// One admitted exact candidate retained during bounded selection: the -/// canonical ranking key plus ordinals into the request literals, the -/// admitting literal and every matched literal. Winner materialization -/// resolves the proof from the per-request cache and clones the literals -/// only then. Ordering is by key alone. -struct RankedExactEntryV1 { - key: (Reverse, String, u32), - admitted_ordinal: usize, - matched_literals: Vec, - matched_kinds: Vec, -} - -/// One lexical winner retained during bounded selection. Carrying its decoded -/// row and score avoids both winner rehydration and score recomputation. -struct RankedLexicalEntryV1 { - key: (Reverse, String, u32), - score: LexicalRowScoreV1, - row: ArtifactRowV1, -} - -impl PartialEq for RankedLexicalEntryV1 { - fn eq(&self, other: &Self) -> bool { - self.key == other.key - } -} - -impl Eq for RankedLexicalEntryV1 {} - -impl PartialOrd for RankedLexicalEntryV1 { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for RankedLexicalEntryV1 { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.key.cmp(&other.key) - } +/// Heap entry ordered by `key` alone. Payload is excluded from equality so a +/// worst-first `BinaryHeap` ranks capped winners without comparing row +/// material. +struct Keyed { + key: K, + value: V, } -impl PartialEq for RankedExactEntryV1 { +impl PartialEq for Keyed { fn eq(&self, other: &Self) -> bool { self.key == other.key } } -impl Eq for RankedExactEntryV1 {} +impl Eq for Keyed {} -impl PartialOrd for RankedExactEntryV1 { +impl PartialOrd for Keyed { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl Ord for RankedExactEntryV1 { +impl Ord for Keyed { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.key.cmp(&other.key) } diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/row_codec.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/row_codec.rs index 258ae41391..0883c9a173 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/row_codec.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/row_codec.rs @@ -58,27 +58,10 @@ const FIELD_LENGTH_ORDER: [LexicalFieldV1; 9] = [ LexicalFieldV1::Documentation, ]; -const GRAIN_ORDER: [CodeSearchChunkGrainV1; 5] = [ - CodeSearchChunkGrainV1::SymbolSignature, - CodeSearchChunkGrainV1::SymbolBody, - CodeSearchChunkGrainV1::SymbolMember, - CodeSearchChunkGrainV1::FilePreamble, - CodeSearchChunkGrainV1::FileWindow, -]; +const GRAIN_ORDER: &[CodeSearchChunkGrainV1] = CodeSearchChunkGrainV1::ORDER.as_slice(); -const EXACT_TERM_KIND_ORDER: [ExactTechnicalTermKindV1; 11] = [ - ExactTechnicalTermKindV1::WholeSymbol, - ExactTechnicalTermKindV1::QualifiedName, - ExactTechnicalTermKindV1::Path, - ExactTechnicalTermKindV1::CompilerErrorCode, - ExactTechnicalTermKindV1::CompilerErrorText, - ExactTechnicalTermKindV1::RuntimeErrorCode, - ExactTechnicalTermKindV1::RuntimeErrorText, - ExactTechnicalTermKindV1::CliFlag, - ExactTechnicalTermKindV1::ToolName, - ExactTechnicalTermKindV1::ConfigurationKey, - ExactTechnicalTermKindV1::CommitIdentifier, -]; +const EXACT_TERM_KIND_ORDER: &[ExactTechnicalTermKindV1] = + ExactTechnicalTermKindV1::ORDER.as_slice(); /// Compact row payload: drop identities already stored as columns or /// generation metadata, and reconstruct ASCII-normalized text on read. @@ -520,12 +503,12 @@ fn encode_binary( } put_varint(&mut out, row.anchor.source_span.start_byte); put_varint(&mut out, row.anchor.source_span.end_byte); - out.push(ordinal_of(&GRAIN_ORDER, &row.anchor.grain, "grain")?); + out.push(ordinal_of(GRAIN_ORDER, &row.anchor.grain, "grain")?); put_varint(&mut out, u64::from(row.anchor.ordinal)); put_varint(&mut out, length_u64(row.exact_terms.len())?); for term in &row.exact_terms { out.push(ordinal_of( - &EXACT_TERM_KIND_ORDER, + EXACT_TERM_KIND_ORDER, &term.kind(), "exact term kind", )?); @@ -664,7 +647,7 @@ fn decode_binary( start_byte: cursor.take_varint()?, end_byte: cursor.take_varint()?, }; - let grain = *from_ordinal(&GRAIN_ORDER, cursor.take_u8()?, "grain")?; + let grain = *from_ordinal(GRAIN_ORDER, cursor.take_u8()?, "grain")?; let ordinal = u32::try_from(cursor.take_varint()?).map_err(corrupt)?; let term_count = usize::try_from(cursor.take_varint()?).map_err(corrupt)?; if term_count > cursor.bytes.len() { @@ -674,7 +657,7 @@ fn decode_binary( } let mut exact_terms = Vec::with_capacity(term_count); for _ in 0..term_count { - let kind = *from_ordinal(&EXACT_TERM_KIND_ORDER, cursor.take_u8()?, "exact term kind")?; + let kind = *from_ordinal(EXACT_TERM_KIND_ORDER, cursor.take_u8()?, "exact term kind")?; let original_bytes = cursor.take_bytes()?.to_vec(); let span = SourceSpan { start_byte: cursor.take_varint()?, diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/schema.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/schema.rs index 5abf876c08..e6409d8cfe 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/schema.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/schema.rs @@ -7,7 +7,7 @@ use super::super::LexicalFieldV1; use super::prepared::PreparedCodeLexicalArtifactPageV1; use super::{CodeLexicalArtifactErrorV1, checkpoint}; use tracedecay_code_index::production::CodeIndexExecutionControlV1; -use tracedecay_domain::ExactFieldV1; +use tracedecay_domain::{ExactFieldV1, nonnegative_sha256_prefix}; /// Revision 10 is the last TEXT-term posting layout. Revision 11 interns /// terms, stores integer field codes, drops redundant serving indexes, and @@ -351,23 +351,24 @@ pub(super) fn exact_field_code_from_encoded( /// batch order, so one-page and multi-page commits of the same source would /// disagree on `vocabulary` / `term_stats` section receipts. pub(super) fn stable_term_id(term: &str) -> i64 { - let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact.term-id.v11\0"); - hasher.update(term.as_bytes()); - let digest = hasher.finalize(); - let mut prefix = [0u8; 8]; - prefix.copy_from_slice(&digest[..8]); - (u64::from_be_bytes(prefix) & i64::MAX as u64) as i64 + stable_prefixed_id( + b"tracedecay.code-lexical-artifact.term-id.v11\0", + term.as_bytes(), + ) } pub(super) fn stable_exact_term_id(term: &[u8]) -> i64 { + stable_prefixed_id( + b"tracedecay.code-lexical-artifact.exact-term-id.v12\0", + term, + ) +} + +fn stable_prefixed_id(domain: &[u8], payload: &[u8]) -> i64 { let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact.exact-term-id.v12\0"); - hasher.update(term); - let digest = hasher.finalize(); - let mut prefix = [0u8; 8]; - prefix.copy_from_slice(&digest[..8]); - (u64::from_be_bytes(prefix) & i64::MAX as u64) as i64 + hasher.update(domain); + hasher.update(payload); + nonnegative_sha256_prefix(hasher.finalize().as_slice()) as i64 } /// Content-addressed `row_dictionary` key over the encoded entry. Pages are @@ -375,13 +376,10 @@ pub(super) fn stable_exact_term_id(term: &[u8]) -> i64 { /// boundaries; a digest of the entry does, and lets a reader verify each /// resolved entry against the id its row referenced. pub(super) fn stable_row_dictionary_id(entry: &[u8]) -> i64 { - let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact.row-dictionary-id.v14\0"); - hasher.update(entry); - let digest = hasher.finalize(); - let mut prefix = [0u8; 8]; - prefix.copy_from_slice(&digest[..8]); - (u64::from_be_bytes(prefix) & i64::MAX as u64) as i64 + stable_prefixed_id( + b"tracedecay.code-lexical-artifact.row-dictionary-id.v14\0", + entry, + ) } /// Stage every dictionary entry the batch references under its page ordinal. diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/in_memory.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/in_memory.rs index cdb752f968..7b6afc811b 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/in_memory.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/in_memory.rs @@ -11,30 +11,26 @@ use std::time::{Duration, Instant}; use roaring::RoaringBitmap; use tracedecay_code_index::production::VerifiedSealedLexicalSymbolDisplayV1; use tracedecay_domain::{ - CodeGenerationId, CodeSearchChunkV1, CompactCandidate, ComponentRevision, EvidenceRole, - ExactFieldV1, ExactTechnicalTermKindV1, ExactTechnicalTermV1, ExtractionAdmittedChunkV1, - FixedPointScore, FreshnessCompatibilityV1, LogicalEvidenceId, RetrieverBatch, - RetrieverCoverage, RetrieverKind, RetrieverOutcome, ScoreDomainId, SourceOccurrenceId, - SymbolOccurrenceId, + CodeGenerationId, CodeSearchChunkV1, ExactFieldV1, ExactTechnicalTermV1, + ExtractionAdmittedChunkV1, FreshnessCompatibilityV1, RetrieverBatch, RetrieverCoverage, + RetrieverKind, RetrieverOutcome, SymbolOccurrenceId, }; use super::super::{ - LexicalFieldV1, LexicalLaneEvidence, LexicalLaneRequest, LexicalSpellingVariantV1, - MAX_FUZZY_TERM_EXPANSIONS_V1, admit_candidate_sources, candidate_admission_outcome, + LexicalFieldV1, LexicalLaneEvidence, LexicalLaneRequest, MAX_FUZZY_TERM_EXPANSIONS_V1, + admit_candidate_sources, candidate_admission_outcome, }; use super::{ - CodeLexicalProjectionMetadataV1, ECHO_SCORE_MILLIS, ExactMatchRowViewV1, FUZZY_SCORE_MILLIS, - FuzzyExpansionsV1, FuzzyQueryGroupV1, LexicalRowScoreV1, LiteralProofCacheV1, - PHRASE_SCORE_MILLIS, PreparedLexicalQueryV1, ProjectedChunkV1, add_score, bm25_score_micros, - canonical_projected_exact_term, collect_term_kinds, exact_field_for_kind, exact_matches, - field_weight_millis, fuzzy_distance_bound, matches_phrase, normalize_lexical, - normalized_field_text, normalized_search_text, proximity_count, retrieval_anchor, - substring_count, + CodeLexicalProjectionMetadataV1, ExactMatchRowViewV1, FuzzyExpansionsV1, FuzzyQueryGroupV1, + LexicalRowScoreV1, LiteralProofCacheV1, PreparedLexicalQueryV1, ProjectedChunkV1, + bm25_score_micros, canonical_projected_exact_term, exact_field_for_kind, exact_matches, + field_weight_millis, fuzzy_distance_bound, lexical_lane_binding, lexical_lane_candidate, + matches_phrase, normalize_lexical, normalized_search_text, score_lexical_row, }; use crate::retrieval::exact::{ExactAdmissionAuthority, ExactLaneEvidence, ExactLaneRequest}; use crate::retrieval::ports::{ - CodeCandidateBindingV1, CodeOccurrenceRefV1, ExactTermPostingReadPort, LexicalPostingReadPort, - RETRIEVAL_CANDIDATE_BATCH_SIZE, RetrievalPortError, contract_error, retrieval_checkpoint, + ExactTermPostingReadPort, LexicalPostingReadPort, RETRIEVAL_CANDIDATE_BATCH_SIZE, + RetrievalPortError, contract_error, retrieval_checkpoint, }; mod postings; @@ -865,15 +861,17 @@ impl CodeLexicalProjectionAdapterV1 { excluded += 1; continue; } - let candidate = self.candidate( + let candidate = lexical_lane_candidate( row, + &self.metadata.freshness, + self.metadata.repository_id.clone(), RetrieverKind::Lexical, self.metadata.lexical_retriever_revision.clone(), request.score_domain.clone(), None, )?; let evidence = LexicalLaneEvidence { - binding: self.binding(row, &candidate, score.matched_kinds), + binding: lexical_lane_binding(row, &candidate, score.matched_kinds), field_scores_micros: score.field_scores, matched_whole_terms: score.matched_whole_terms, matched_subtokens: score.matched_subtokens, @@ -1000,237 +998,27 @@ impl CodeLexicalProjectionAdapterV1 { phrase_document_frequencies: &BTreeMap, ) -> LexicalRowScoreV1 { crate::hotpath_metrics::measure_frequent("query.lane.lexical.score_row", || { - self.score_row_inner(document, row, prepared, fuzzy, phrase_document_frequencies) - }) - } - - fn score_row_inner( - &self, - document: u32, - row: &ProjectedChunkV1, - prepared: &PreparedLexicalQueryV1<'_>, - fuzzy: &FuzzyExpansionsV1, - phrase_document_frequencies: &BTreeMap, - ) -> LexicalRowScoreV1 { - let mut field_scores: BTreeMap = BTreeMap::new(); - let mut matched_whole_terms = BTreeSet::new(); - let mut matched_subtokens = BTreeSet::new(); - let mut matched_phrases = BTreeSet::new(); - let mut matched_proximities = BTreeSet::new(); - let mut spelling_variants = BTreeSet::new(); - let mut matched_kinds = BTreeSet::new(); - let mut typo_recovery_applied = false; - for field in row.field_lengths.keys() { - if *field != LexicalFieldV1::Subtoken { - for (query_term, normalized_query) in &prepared.whole_terms { - let exact_tf = self - .postings - .term_frequency(*field, normalized_query, document); - if exact_tf > 0 { - add_score( - &mut field_scores, - *field, - self.term_score(*field, normalized_query, exact_tf, row), - ); - matched_whole_terms.insert((*query_term).to_owned()); - collect_term_kinds(&row.exact_terms, normalized_query, &mut matched_kinds); - } - if let Some(expansions) = fuzzy.by_query.get(*query_term) { - for expansion in expansions { - let fuzzy_tf = - self.postings.term_frequency(*field, expansion, document); - if fuzzy_tf == 0 { - continue; - } - let score = self - .term_score(*field, expansion, fuzzy_tf, row) - .saturating_mul(FUZZY_SCORE_MILLIS) - / 1_000; - add_score(&mut field_scores, *field, score); - matched_whole_terms.insert((*query_term).to_owned()); - spelling_variants.insert(LexicalSpellingVariantV1 { - query: (*query_term).to_owned(), - alternative: expansion.clone(), - }); - typo_recovery_applied = true; - collect_term_kinds(&row.exact_terms, expansion, &mut matched_kinds); - } - } - } - } - if *field == LexicalFieldV1::Subtoken { - for (subtoken, normalized) in &prepared.subtokens { - let tf = self.postings.term_frequency(*field, normalized, document); - if tf > 0 { - add_score( - &mut field_scores, - *field, - self.term_score(*field, normalized, tf, row), - ); - matched_subtokens.insert((*subtoken).to_owned()); - } - } - } - } - for (phrase, normalized) in &prepared.phrases { - for field in row.field_lengths.keys() { - let Some(text) = normalized_field_text(row, *field) else { - continue; - }; - let tf = substring_count(&text, normalized); - if tf == 0 { - continue; - } - let score = self - .phrase_score( - *field, - tf, - row, - phrase_document_frequencies - .get(normalized) - .copied() - .unwrap_or_default(), + score_lexical_row( + row, + &row.exact_terms, + prepared, + fuzzy, + phrase_document_frequencies, + |field, term| self.postings.term_frequency(field, term, document), + |field, term| self.postings.document_frequency(field, term), + |field, term_frequency, document_frequency| { + bm25_score_micros( + self.rows.len(), + document_frequency, + term_frequency, + row.field_lengths.get(&field).copied().unwrap_or(0).max(1), + self.postings.average_field_length(field), + field_weight_millis(field), ) - .saturating_mul(PHRASE_SCORE_MILLIS) - / 1_000; - add_score(&mut field_scores, *field, score); - matched_phrases.insert((*phrase).to_owned()); - } - } - for proximity in &prepared.proximities { - for field in row.field_lengths.keys() { - let Some(text) = normalized_field_text(row, *field) else { - continue; - }; - let tf = proximity_count(&text, &proximity.terms, proximity.original.maximum_gap); - if tf == 0 { - continue; - } - let score = self - .phrase_score(*field, tf, row, 1) - .saturating_mul(PHRASE_SCORE_MILLIS) - / 1_000; - add_score(&mut field_scores, *field, score); - matched_proximities.insert(proximity.original.clone()); - } - } - let echo_penalty_applied = - !prepared.echo_query.is_empty() && prepared.echo_query == row.normalized_text.trim(); - if echo_penalty_applied { - for score in field_scores.values_mut() { - *score = score.saturating_mul(ECHO_SCORE_MILLIS) / 1_000; - } - } - LexicalRowScoreV1 { - field_scores: field_scores.into_iter().collect(), - matched_whole_terms: matched_whole_terms.into_iter().collect(), - matched_subtokens: matched_subtokens.into_iter().collect(), - matched_phrases: matched_phrases.into_iter().collect(), - matched_proximities: matched_proximities.into_iter().collect(), - spelling_variants: spelling_variants.into_iter().collect(), - matched_kinds: matched_kinds.into_iter().collect(), - typo_recovery_applied, - echo_penalty_applied, - } - } - - fn term_score( - &self, - field: LexicalFieldV1, - term: &str, - term_frequency: usize, - row: &ProjectedChunkV1, - ) -> u64 { - let document_frequency = self.postings.document_frequency(field, term); - let document_length = row.field_lengths.get(&field).copied().unwrap_or(0).max(1); - let average_length = self.postings.average_field_length(field); - bm25_score_micros( - self.rows.len(), - document_frequency, - term_frequency, - document_length, - average_length, - field_weight_millis(field), - ) - } - - fn phrase_score( - &self, - field: LexicalFieldV1, - term_frequency: usize, - row: &ProjectedChunkV1, - document_frequency: usize, - ) -> u64 { - let document_length = row.field_lengths.get(&field).copied().unwrap_or(0).max(1); - bm25_score_micros( - self.rows.len(), - document_frequency, - term_frequency, - document_length, - self.postings.average_field_length(field), - field_weight_millis(field), - ) - } - - fn candidate( - &self, - row: &ProjectedChunkV1, - retriever: RetrieverKind, - retriever_revision: ComponentRevision, - score_domain: ScoreDomainId, - exact_admission_proof: Option, - ) -> Result { - let lane = retriever.as_str(); - let chunk_id = row.id.as_str(); - let generation = row.anchor.generation_id.as_str(); - let evidence_id = row.anchor.symbol_occurrence_id.as_ref().map_or_else( - || format!("code-chunk:{chunk_id}"), - |symbol| format!("code-symbol:{}", symbol.as_str()), - ); - Ok(CompactCandidate { - anchor_id: retrieval_anchor(evidence_id.clone())?, - logical_evidence_id: LogicalEvidenceId::new(evidence_id).map_err(contract_error)?, - source_occurrence_id: SourceOccurrenceId::new(format!( - "code-chunk:{generation}:{chunk_id}" - )) - .map_err(contract_error)?, - file_occurrence_id: Some(row.anchor.file_occurrence_id.clone()), - source_namespace: self.metadata.freshness.source_namespace.clone(), - repository_id: self.metadata.repository_id.clone(), - session_or_thread_id: None, - logical_copy_cluster_id: None, - logical_copy_evidence_anchor: None, - evidence_role: EvidenceRole::Primary, - retriever, - retriever_revision, - score_domain, - raw_score: FixedPointScore::ZERO, - ordinal_rank: 0, - exact_admission_proof, - retriever_evidence_anchor: retrieval_anchor(format!("code-lexical:{lane}:{chunk_id}"))?, - freshness: self.metadata.freshness.clone(), + }, + ) }) } - - fn binding( - &self, - row: &ProjectedChunkV1, - candidate: &CompactCandidate, - matched_term_kinds: Vec, - ) -> CodeCandidateBindingV1 { - CodeCandidateBindingV1 { - candidate_anchor: candidate.anchor_id.clone(), - occurrence: CodeOccurrenceRefV1 { - generation: row.anchor.generation_id.clone(), - file: row.anchor.file_occurrence_id.clone(), - symbol: row.anchor.symbol_occurrence_id.clone(), - chunk: Some(row.id.clone()), - }, - language_descriptor_revision: row.language_descriptor_revision.clone(), - matched_term_kinds, - source_occurrence: candidate.source_occurrence_id.clone(), - } - } } impl LexicalPostingReadPort for CodeLexicalProjectionAdapterV1 { @@ -1294,15 +1082,17 @@ where .iter() .map(|ordinal| request.literals[*ordinal].clone()) .collect::>(); - let candidate = self.projection.candidate( + let candidate = lexical_lane_candidate( row, + &self.projection.metadata.freshness, + self.projection.metadata.repository_id.clone(), RetrieverKind::ExactLiteral, self.projection.metadata.exact_retriever_revision.clone(), self.projection.metadata.exact_score_domain.clone(), Some(proof.clone()), )?; let evidence = ExactLaneEvidence { - binding: self.projection.binding(row, &candidate, matched_kinds), + binding: lexical_lane_binding(row, &candidate, matched_kinds), matched_literals, admission_proof: proof, }; diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/in_memory/postings.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/in_memory/postings.rs index 19b56ab0b8..343989df23 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/in_memory/postings.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/in_memory/postings.rs @@ -102,7 +102,7 @@ impl ByteNgramPostings { })?; debug_assert_eq!(unique.capacity(), scratch_entries); for width in 1..=bytes.len().min(3) { - unique.extend(bytes.windows(width).map(pack_byte_ngram)); + unique.extend(bytes.windows(width).map(super::super::pack_byte_ngram)); } unique.sort_unstable(); unique.dedup(); @@ -242,15 +242,7 @@ impl ByteNgramPostings { } pub(super) fn candidate_documents(&self, needle: &[u8]) -> RoaringBitmap { - let width = needle.len().min(3); - if width == 0 { - return RoaringBitmap::new(); - } - let ngrams = needle - .windows(width) - .map(pack_byte_ngram) - .collect::>(); - let mut ngrams = ngrams.into_iter(); + let mut ngrams = super::super::packed_query_ngrams(needle).into_iter(); let Some(first) = ngrams.next() else { return RoaringBitmap::new(); }; @@ -358,16 +350,6 @@ impl ByteNgramBudget { } } -fn pack_byte_ngram(bytes: &[u8]) -> u32 { - debug_assert!((1..=3).contains(&bytes.len())); - bytes - .iter() - .enumerate() - .fold((bytes.len() as u32) << 24, |packed, (index, byte)| { - packed | (u32::from(*byte) << (index * 8)) - }) -} - fn pack_posting(ngram: u32, document: u32) -> u64 { (u64::from(ngram) << 32) | u64::from(document) } diff --git a/crates/tracedecay-query/src/retrieval/lexical/routes/tests.rs b/crates/tracedecay-query/src/retrieval/lexical/routes/tests.rs index ccccf797bf..0d40540d68 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/routes/tests.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/routes/tests.rs @@ -3,7 +3,6 @@ //! lane input with anchor-named evidence. use std::collections::BTreeMap; -use std::fmt; use tracedecay_domain::{ CodeGenerationId, CompactCandidate, EvidenceRole, ExactTechnicalTermKindV1, FixedPointScore, @@ -23,13 +22,7 @@ use crate::retrieval::lexical::{ }; use crate::retrieval::ports::{CodeCandidateBindingV1, CodeOccurrenceRefV1, RetrievalPortError}; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn anchors(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() diff --git a/crates/tracedecay-query/src/retrieval/lexical/tests.rs b/crates/tracedecay-query/src/retrieval/lexical/tests.rs index eeda445556..061add520e 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/tests.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/tests.rs @@ -58,13 +58,7 @@ impl RetrievalExecutionControl for CancelledControl { static CANCELLED_CONTROL: CancelledControl = CancelledControl; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn digest_id(byte: char) -> T where diff --git a/crates/tracedecay-query/src/retrieval/tests.rs b/crates/tracedecay-query/src/retrieval/tests.rs index 8fecd39279..896ee00066 100644 --- a/crates/tracedecay-query/src/retrieval/tests.rs +++ b/crates/tracedecay-query/src/retrieval/tests.rs @@ -20,13 +20,7 @@ use tracedecay_domain::{ use super::fusion::CompositionLaneInput; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn digest_id(byte: char) -> T where diff --git a/crates/tracedecay-query/src/search_quality/candidate_output.rs b/crates/tracedecay-query/src/search_quality/candidate_output.rs index e329d77b26..32f157b799 100644 --- a/crates/tracedecay-query/src/search_quality/candidate_output.rs +++ b/crates/tracedecay-query/src/search_quality/candidate_output.rs @@ -16,6 +16,7 @@ use tracedecay_code_index::chunks::content_digest; use tracedecay_contracts::historical_query::HistoricalGitReadUnavailableReasonV1; use tracedecay_contracts::is_canonical_repository_relative_path; use tracedecay_domain::canonical_text::encode_tagged_lowercase_hex; +use tracedecay_domain::collapse_whitespace; use tracedecay_domain::git::GitOidV1; use tracedecay_domain::{ CalibrationProfileId, DiversityPolicy, DiversityPolicyId, FusionProfile, FusionProfileId, @@ -502,10 +503,6 @@ fn normalized_document_prose(bytes: &[u8]) -> String { collapse_whitespace(&joined) } -fn collapse_whitespace(value: &str) -> String { - value.split_whitespace().collect::>().join(" ") -} - fn compute_corpus_digest_from_document_bytes<'a>( workload: &CandidateWorkloadV1, mut document_bytes: impl FnMut(&CorpusDocumentV1) -> Result, CandidateOutputError>, diff --git a/crates/tracedecay-query/tests/canonical_execution_equivalence.rs b/crates/tracedecay-query/tests/canonical_execution_equivalence.rs index 3fa79fc2e5..e25f0d7f32 100644 --- a/crates/tracedecay-query/tests/canonical_execution_equivalence.rs +++ b/crates/tracedecay-query/tests/canonical_execution_equivalence.rs @@ -26,13 +26,7 @@ use tracedecay_query::retrieval::{ route_authenticated_prepared_query_cursor, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn digest(byte: char) -> T where diff --git a/crates/tracedecay-query/tests/retrieval_contract_spine.rs b/crates/tracedecay-query/tests/retrieval_contract_spine.rs index 9774e25bb9..37d846af1d 100644 --- a/crates/tracedecay-query/tests/retrieval_contract_spine.rs +++ b/crates/tracedecay-query/tests/retrieval_contract_spine.rs @@ -1,5 +1,4 @@ use std::collections::BTreeMap; -use std::fmt; use tracedecay_domain::{ AuthorizationRevision, CodeGenerationId, EphemeralSanitizedQueryViewV1, ExactAdmissionProof, @@ -25,13 +24,7 @@ impl RetrievalExecutionControl for ActiveControl { } } -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn request_and_proof() -> (ExactLaneRequest<'static>, ExactAdmissionProof) { let scope = RetrievalScope { diff --git a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs index 9009f6ecdd..1447d228f8 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -886,13 +886,7 @@ impl TestArtifactSourceStaging for CodeLexicalArtifactBuilderV1 { } } -pub(crate) fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +pub(crate) use tracedecay_domain::test_fixtures::id; pub(crate) fn digest_id(byte: char) -> T where diff --git a/crates/tracedecay-runtime-core/src/branch/tracking.rs b/crates/tracedecay-runtime-core/src/branch/tracking.rs index 3a69a1f0ba..d3e7b97f9f 100644 --- a/crates/tracedecay-runtime-core/src/branch/tracking.rs +++ b/crates/tracedecay-runtime-core/src/branch/tracking.rs @@ -548,10 +548,7 @@ pub(crate) fn parse_unix_secs(ts: &str) -> u64 { } pub(crate) fn now_unix_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() + crate::tracedecay::unix_secs() } #[cfg(test)] diff --git a/crates/tracedecay-runtime-core/src/branch_meta.rs b/crates/tracedecay-runtime-core/src/branch_meta.rs index 8e41faa6ba..a89b69d42c 100644 --- a/crates/tracedecay-runtime-core/src/branch_meta.rs +++ b/crates/tracedecay-runtime-core/src/branch_meta.rs @@ -554,11 +554,7 @@ fn update_synced_timestamp_with(tracedecay_dir: &Path, branch: &str, after_lock: } fn now_unix_str() -> String { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - format!("{secs}") + crate::tracedecay::unix_secs().to_string() } /// Formats a UNIX timestamp string as a human-readable relative time. @@ -566,10 +562,7 @@ pub fn format_timestamp(ts: &str) -> String { let Ok(secs) = ts.parse::() else { return ts.to_string(); }; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + let now = crate::tracedecay::unix_secs(); let age = now.saturating_sub(secs); if age < 60 { "just now".to_string() diff --git a/crates/tracedecay-runtime-core/src/db/access.rs b/crates/tracedecay-runtime-core/src/db/access.rs index 88f8614a94..56c8e826aa 100644 --- a/crates/tracedecay-runtime-core/src/db/access.rs +++ b/crates/tracedecay-runtime-core/src/db/access.rs @@ -582,14 +582,6 @@ impl ExactSqlWriteAuthority for DatabaseAuthority { ExactSqlWriteIntent::ExecuteBatch => { "execute registered global database statement batch" } - ExactSqlWriteIntent::Vacuum => { - if self.role() != DatabaseAuthorityRole::Maintenance { - return Err(ExactSqlError::AuthorityDenied( - "whole-database vacuum requires exclusive maintenance authority".to_owned(), - )); - } - "vacuum registered global database under exclusive maintenance" - } ExactSqlWriteIntent::BeginTransaction => "begin registered global database transaction", ExactSqlWriteIntent::Commit => "commit registered global database transaction", }; diff --git a/crates/tracedecay-runtime-core/src/db/access/owner_io.rs b/crates/tracedecay-runtime-core/src/db/access/owner_io.rs index 706df4e045..e650112e9c 100644 --- a/crates/tracedecay-runtime-core/src/db/access/owner_io.rs +++ b/crates/tracedecay-runtime-core/src/db/access/owner_io.rs @@ -481,12 +481,6 @@ fn sanitize_metadata(value: &str) -> String { const MAX_METADATA_BYTES: usize = 256; let mut sanitized = value.replace(['\t', '\r', '\n'], " "); - if sanitized.len() > MAX_METADATA_BYTES { - let mut boundary = MAX_METADATA_BYTES; - while !sanitized.is_char_boundary(boundary) { - boundary -= 1; - } - sanitized.truncate(boundary); - } + sanitized.truncate(sanitized.floor_char_boundary(MAX_METADATA_BYTES)); sanitized } diff --git a/crates/tracedecay-runtime-core/src/db/engine/connection.rs b/crates/tracedecay-runtime-core/src/db/engine/connection.rs index c4ee65e924..68272cea59 100644 --- a/crates/tracedecay-runtime-core/src/db/engine/connection.rs +++ b/crates/tracedecay-runtime-core/src/db/engine/connection.rs @@ -10,8 +10,7 @@ pub use tracedecay_rusqlite_runtime::reader::{ReaderPoolSnapshot, ReaderPoolStat #[cfg(any(test, feature = "test-helpers"))] use super::Statement; use super::{ - Error, IntoParams, ReadSnapshot, Result, Rows, Transaction, TransactionBehavior, Value, - WriteStatement, + Error, IntoParams, ReadSnapshot, Result, Rows, Transaction, TransactionBehavior, WriteStatement, }; const READER_WAIT: Duration = Duration::from_secs(5); @@ -166,30 +165,14 @@ impl Connection { }) .await .map_err(join_error)??; - Ok(Rows::from_parts( - rows.columns, - rows.rows - .into_iter() - .map(|row| { - super::Row::from_values(row.values.into_iter().map(Value::from).collect()) - }) - .collect(), - )) + Ok(Rows::from_exact(rows)) } #[hotpath::skip] pub async fn checkpoint_wal_truncate(&self) -> Result { let runtime = Arc::clone(&self.runtime); let rows = runtime.checkpoint_wal_truncate_async().await?; - Ok(Rows::from_parts( - rows.columns, - rows.rows - .into_iter() - .map(|row| { - super::Row::from_values(row.values.into_iter().map(Value::from).collect()) - }) - .collect(), - )) + Ok(Rows::from_exact(rows)) } #[hotpath::skip] @@ -212,15 +195,6 @@ impl Connection { .map_err(Into::into) } - #[hotpath::skip] - pub async fn repair_incremental_auto_vacuum(&self) -> Result<()> { - let runtime = Arc::clone(&self.runtime); - runtime - .repair_incremental_auto_vacuum_async() - .await - .map_err(Into::into) - } - #[cfg(any(test, feature = "test-helpers"))] #[hotpath::skip] pub async fn prepare(&self, sql: &str) -> Result> { diff --git a/crates/tracedecay-runtime-core/src/db/engine/row.rs b/crates/tracedecay-runtime-core/src/db/engine/row.rs index 9fa898cc26..ec66bf7e41 100644 --- a/crates/tracedecay-runtime-core/src/db/engine/row.rs +++ b/crates/tracedecay-runtime-core/src/db/engine/row.rs @@ -38,6 +38,16 @@ impl Rows { } } + pub(super) fn from_exact(rows: tracedecay_rusqlite_runtime::exact_sql::ExactSqlRows) -> Self { + Self::from_parts( + rows.columns, + rows.rows + .into_iter() + .map(|row| Row::from_values(row.values.into_iter().map(Value::from).collect())) + .collect(), + ) + } + pub fn column_count(&self) -> i32 { i32::try_from(self.columns.len()).unwrap_or(i32::MAX) } diff --git a/crates/tracedecay-runtime-core/src/db/engine/snapshot.rs b/crates/tracedecay-runtime-core/src/db/engine/snapshot.rs index b92717e3a4..e2919001b5 100644 --- a/crates/tracedecay-runtime-core/src/db/engine/snapshot.rs +++ b/crates/tracedecay-runtime-core/src/db/engine/snapshot.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use tracedecay_rusqlite_runtime::exact_sql::ExactSqlReadSnapshot; -use super::{IntoParams, Result, Rows, Value, connection::statement}; +use super::{IntoParams, Result, Rows, connection::statement}; pub struct ReadSnapshot { /// One snapshot holds one pooled reader worker for its whole lifetime, and @@ -33,15 +33,7 @@ impl ReadSnapshot { }) .await .map_err(join_error)??; - Ok(Rows::from_parts( - rows.columns, - rows.rows - .into_iter() - .map(|row| { - super::Row::from_values(row.values.into_iter().map(Value::from).collect()) - }) - .collect(), - )) + Ok(Rows::from_exact(rows)) } } diff --git a/crates/tracedecay-runtime-core/src/db/engine/transaction.rs b/crates/tracedecay-runtime-core/src/db/engine/transaction.rs index 3c4947c140..48b4c256af 100644 --- a/crates/tracedecay-runtime-core/src/db/engine/transaction.rs +++ b/crates/tracedecay-runtime-core/src/db/engine/transaction.rs @@ -6,7 +6,7 @@ use tracedecay_rusqlite_runtime::exact_sql::{ ExactSqlAttachment, ExactSqlHandle, ExactSqlTransaction as RuntimeTransaction, }; -use super::{Error, IntoParams, Result, Rows, Value, WriteStatement, connection::statement}; +use super::{Error, IntoParams, Result, Rows, WriteStatement, connection::statement}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TransactionBehavior { @@ -128,15 +128,7 @@ impl Transaction { }) .await .map_err(join_error)??; - Ok(Rows::from_parts( - rows.columns, - rows.rows - .into_iter() - .map(|row| { - super::Row::from_values(row.values.into_iter().map(Value::from).collect()) - }) - .collect(), - )) + Ok(Rows::from_exact(rows)) } #[hotpath::skip] diff --git a/crates/tracedecay-runtime-core/src/db/migrations/tests.rs b/crates/tracedecay-runtime-core/src/db/migrations/tests.rs index 2e4bc6570b..4f24404cbf 100644 --- a/crates/tracedecay-runtime-core/src/db/migrations/tests.rs +++ b/crates/tracedecay-runtime-core/src/db/migrations/tests.rs @@ -23,14 +23,8 @@ mod fts; struct AllowSchemaWrites; impl ExactSqlWriteAuthority for AllowSchemaWrites { - fn verify(&self, intent: ExactSqlWriteIntent) -> Result<(), ExactSqlError> { - if intent == ExactSqlWriteIntent::Vacuum { - Err(ExactSqlError::AuthorityDenied( - "ordinary schema fixture cannot vacuum".to_owned(), - )) - } else { - Ok(()) - } + fn verify(&self, _intent: ExactSqlWriteIntent) -> Result<(), ExactSqlError> { + Ok(()) } } diff --git a/crates/tracedecay-runtime-core/src/db/mod.rs b/crates/tracedecay-runtime-core/src/db/mod.rs index c025e37328..e0354c3fc1 100644 --- a/crates/tracedecay-runtime-core/src/db/mod.rs +++ b/crates/tracedecay-runtime-core/src/db/mod.rs @@ -1,6 +1,8 @@ mod access; mod connection; pub mod engine; +mod row_codec; +pub use row_codec::{decode_stored_json, encode_stored_json, optional_text_column, text_column}; mod evidence_assembly; mod external_source; mod file_identity; diff --git a/crates/tracedecay-runtime-core/src/db/row_codec.rs b/crates/tracedecay-runtime-core/src/db/row_codec.rs new file mode 100644 index 0000000000..b6f49e1a39 --- /dev/null +++ b/crates/tracedecay-runtime-core/src/db/row_codec.rs @@ -0,0 +1,52 @@ +//! Shared JSON and text-column decode for durable SQL rows. +//! +//! Git-index, native integration, and the fact store used to repeat the same +//! `serde_json` and `row.get` mappings. The column message names the field so +//! a missing cell is the same failure in every caller. + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use super::engine::Row; + +pub fn encode_stored_json(value: &T) -> Result { + serde_json::to_string(value) +} + +pub fn decode_stored_json(value: &str) -> Result { + serde_json::from_str(value) +} + +pub fn text_column(row: &Row, column: i32, field: &'static str) -> Result { + row.get::(column) + .map_err(|error| format!("read {field}: {error}")) +} + +pub fn optional_text_column( + row: &Row, + column: i32, + field: &'static str, +) -> Result, String> { + row.get::>(column) + .map_err(|error| format!("read {field}: {error}")) +} + +#[cfg(test)] +mod tests { + use super::super::engine::{Row, Value}; + use super::{optional_text_column, text_column}; + + #[test] + fn text_column_names_the_field_and_keeps_null_optional() { + let row = Row::from_values(vec![Value::Text("kept".to_owned()), Value::Null]); + assert_eq!(text_column(&row, 0, "preview").expect("text"), "kept"); + assert_eq!( + text_column(&row, 2, "preview").expect_err("missing"), + "read preview: invalid column index 2" + ); + assert_eq!( + optional_text_column(&row, 1, "receipt").expect("null"), + None + ); + } +} diff --git a/crates/tracedecay-runtime-core/src/lib.rs b/crates/tracedecay-runtime-core/src/lib.rs index d4dd1a2c35..20a6329b01 100644 --- a/crates/tracedecay-runtime-core/src/lib.rs +++ b/crates/tracedecay-runtime-core/src/lib.rs @@ -108,7 +108,6 @@ pub mod logging; pub mod operation_task_owner; pub mod os_str_bytes; pub mod path_safety; -pub mod path_scope; mod profiled_lock; pub mod resident_memory; pub mod runtime_identity; diff --git a/crates/tracedecay-runtime-core/src/path_scope.rs b/crates/tracedecay-runtime-core/src/path_scope.rs deleted file mode 100644 index bea9effcf1..0000000000 --- a/crates/tracedecay-runtime-core/src/path_scope.rs +++ /dev/null @@ -1,23 +0,0 @@ -pub fn path_matches_scope(path: &str, scope_prefix: Option<&str>) -> bool { - scope_prefix.is_none_or(|prefix| { - let with_slash = if prefix.ends_with('/') { - prefix.to_string() - } else { - format!("{prefix}/") - }; - path.starts_with(&with_slash) || path == prefix - }) -} - -#[cfg(test)] -mod tests { - use super::path_matches_scope; - - #[test] - fn scope_prefix_matches_exact_file_or_descendant() { - assert!(path_matches_scope("src/lib.rs", Some("src"))); - assert!(path_matches_scope("src", Some("src"))); - assert!(!path_matches_scope("src2/lib.rs", Some("src"))); - assert!(path_matches_scope("src/lib.rs", None)); - } -} diff --git a/crates/tracedecay-runtime-core/src/runtime_telemetry.rs b/crates/tracedecay-runtime-core/src/runtime_telemetry.rs index a5346b8b35..ba7caf7011 100644 --- a/crates/tracedecay-runtime-core/src/runtime_telemetry.rs +++ b/crates/tracedecay-runtime-core/src/runtime_telemetry.rs @@ -640,7 +640,7 @@ impl ProcessSampler { Some(age), ) => ProcessTelemetry::Stale { sampled_at: *sampled_at, - age_millis: age.as_millis().min(u128::from(u64::MAX)) as u64, + age_millis: crate::tracedecay::saturating_duration_millis(age), snapshot: snapshot.clone(), }, (Some(sample), _) => sample.telemetry.clone(), diff --git a/crates/tracedecay-runtime-core/src/shard_runtime/registry.rs b/crates/tracedecay-runtime-core/src/shard_runtime/registry.rs index 0bdfd1abc3..b218d83607 100644 --- a/crates/tracedecay-runtime-core/src/shard_runtime/registry.rs +++ b/crates/tracedecay-runtime-core/src/shard_runtime/registry.rs @@ -823,17 +823,6 @@ impl tracedecay_rusqlite_runtime::exact_sql::ExactSqlWriteAuthority tracedecay_rusqlite_runtime::exact_sql::ExactSqlWriteIntent::ExecuteBatch => { "execute registered exact SQL statement batch" } - tracedecay_rusqlite_runtime::exact_sql::ExactSqlWriteIntent::Vacuum => { - if self.authority.role() != crate::db::DatabaseAuthorityRole::Maintenance { - return Err( - tracedecay_rusqlite_runtime::exact_sql::ExactSqlError::AuthorityDenied( - "whole-database vacuum requires exclusive maintenance authority" - .to_owned(), - ), - ); - } - "vacuum registered database under exclusive maintenance" - } tracedecay_rusqlite_runtime::exact_sql::ExactSqlWriteIntent::BeginTransaction => { "begin registered exact SQL transaction" } diff --git a/crates/tracedecay-runtime-core/src/shard_runtime/registry/close.rs b/crates/tracedecay-runtime-core/src/shard_runtime/registry/close.rs index ed6e46a05a..952051b72b 100644 --- a/crates/tracedecay-runtime-core/src/shard_runtime/registry/close.rs +++ b/crates/tracedecay-runtime-core/src/shard_runtime/registry/close.rs @@ -414,7 +414,6 @@ impl StoreRuntimeRegistry { #[cfg(test)] mod tests { - use std::fmt::Debug; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -439,13 +438,7 @@ mod tests { }; use crate::shard_runtime::shard::ShardRuntime; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; fn profile_shard() -> StoreShardIdV1 { StoreShardIdV1::profile( diff --git a/crates/tracedecay-runtime-core/src/shard_runtime/registry/destructive.rs b/crates/tracedecay-runtime-core/src/shard_runtime/registry/destructive.rs index 90df2e7fb5..48bc8fb8af 100644 --- a/crates/tracedecay-runtime-core/src/shard_runtime/registry/destructive.rs +++ b/crates/tracedecay-runtime-core/src/shard_runtime/registry/destructive.rs @@ -408,13 +408,7 @@ mod tests { }; use crate::shard_runtime::shard::ShardRuntime; - fn id(value: &str) -> T - where - T: TryFrom, - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).unwrap() - } + use tracedecay_domain::test_fixtures::id; fn profile_shard() -> StoreShardIdV1 { StoreShardIdV1::profile( diff --git a/crates/tracedecay-runtime-core/src/shard_runtime/registry/retirement/tests.rs b/crates/tracedecay-runtime-core/src/shard_runtime/registry/retirement/tests.rs index b89ffb9696..41dc98fbeb 100644 --- a/crates/tracedecay-runtime-core/src/shard_runtime/registry/retirement/tests.rs +++ b/crates/tracedecay-runtime-core/src/shard_runtime/registry/retirement/tests.rs @@ -1,4 +1,3 @@ -use std::fmt::Debug; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -172,13 +171,7 @@ impl StoreRuntimeOwnerAttachmentRetirementReservationV1 for FailingOwnerAttachme } } -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn profile_shard(profile: &str) -> StoreShardIdV1 { StoreShardIdV1::profile( diff --git a/crates/tracedecay-runtime-core/src/shard_runtime/registry/tests/support.rs b/crates/tracedecay-runtime-core/src/shard_runtime/registry/tests/support.rs index f9b4a28891..b199433e19 100644 --- a/crates/tracedecay-runtime-core/src/shard_runtime/registry/tests/support.rs +++ b/crates/tracedecay-runtime-core/src/shard_runtime/registry/tests/support.rs @@ -1,4 +1,3 @@ -use std::fmt::Debug; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -15,23 +14,12 @@ use tracedecay_store::{ use super::super::*; -pub(super) fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +pub(super) use tracedecay_domain::test_fixtures::id; /// Host-absolute fixture path: store locators require `Path::is_absolute`, -/// which a bare `/...` literal fails on Windows, where the same fixture is -/// spelled `C:\...`. +/// which a bare `/...` literal fails on Windows. pub(super) fn absolute_fixture_path(posix: &str) -> PathBuf { - if cfg!(windows) { - PathBuf::from(format!("C:{}", posix.replace('/', "\\"))) - } else { - PathBuf::from(posix) - } + PathBuf::from(tracedecay_domain::test_fixtures::fixture_abs_root(posix)) } pub(super) fn incarnation() -> StoreIncarnationV1 { diff --git a/crates/tracedecay-runtime-core/src/shard_runtime/shard.rs b/crates/tracedecay-runtime-core/src/shard_runtime/shard.rs index 9cd35aaae1..b4d418f1f1 100644 --- a/crates/tracedecay-runtime-core/src/shard_runtime/shard.rs +++ b/crates/tracedecay-runtime-core/src/shard_runtime/shard.rs @@ -1043,7 +1043,6 @@ impl Drop for ShardRuntimeQueuedWork<'_> { #[cfg(test)] mod tests { - use std::fmt::Debug; use std::sync::Barrier; use tracedecay_domain::{BrainId, ProjectId, UserProfileId}; @@ -1087,13 +1086,7 @@ mod tests { ShardRuntimeLeaseKind::Client, ]; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: Debug, - { - T::try_from(value.to_owned()).expect("canonical fixture identity") - } + use tracedecay_domain::test_fixtures::id; fn binding() -> StoreRuntimeBindingV1 { StoreRuntimeBindingV1::new( diff --git a/crates/tracedecay-runtime-core/src/shard_runtime/telemetry.rs b/crates/tracedecay-runtime-core/src/shard_runtime/telemetry.rs index c9c0d60573..c5e5611362 100644 --- a/crates/tracedecay-runtime-core/src/shard_runtime/telemetry.rs +++ b/crates/tracedecay-runtime-core/src/shard_runtime/telemetry.rs @@ -5,7 +5,6 @@ //! metrics backend, retain global state, or derive identity from a locator. use std::cmp::Ordering; -use std::time::Duration; use tracedecay_store::{ AdmissionConfigV1, QueueBudgetV1, RuntimeMaintenanceStateV1, StoreRuntimeBindingV1, WalBudgetV1, @@ -397,7 +396,7 @@ fn project_shard( memory_estimate_bytes: entry.physical.memory_estimate_bytes, health: health.health, pinned_profile: health.pinned_profile, - idle_for_ms: duration_millis(entry.eviction.idle_for), + idle_for_ms: crate::tracedecay::saturating_duration_millis(entry.eviction.idle_for), eviction_eligible: entry.eviction.is_eligible(), eviction_blocker_count: bounded_count(entry.eviction.blockers.len()), } @@ -418,17 +417,13 @@ fn sum_complete_sample(total: Option, sample: Option) -> Option { Some(total?.saturating_add(sample?)) } -fn duration_millis(duration: Duration) -> u64 { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) -} - const fn count_if(value: bool) -> u32 { if value { 1 } else { 0 } } #[cfg(test)] mod tests { - use std::fmt::Debug; + use std::time::Duration; use tracedecay_domain::{BrainId, ProjectId, UserProfileId}; use tracedecay_store::{StoreAuthorityEpochV1, StoreIncarnationV1, StoreShardIdV1}; @@ -438,13 +433,7 @@ mod tests { ShardRuntime, ShardRuntimeEvictionBlocker, ShardRuntimeLeaseKind, }; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: Debug, - { - T::try_from(value.to_owned()).expect("canonical test identity") - } + use tracedecay_domain::test_fixtures::id; fn binding(project: &str, incarnation: u64, epoch: u64) -> StoreRuntimeBindingV1 { StoreRuntimeBindingV1::new( diff --git a/crates/tracedecay-runtime-core/src/storage/paths_and_io.rs b/crates/tracedecay-runtime-core/src/storage/paths_and_io.rs index 79dfe20921..77f46b52e1 100644 --- a/crates/tracedecay-runtime-core/src/storage/paths_and_io.rs +++ b/crates/tracedecay-runtime-core/src/storage/paths_and_io.rs @@ -97,7 +97,7 @@ impl ProjectPath { } pub fn relative_path_string(&self) -> String { - self.relative_path.to_string_lossy().replace('\\', "/") + tracedecay_domain::forward_slash_path(&self.relative_path) } } diff --git a/crates/tracedecay-runtime-core/src/text.rs b/crates/tracedecay-runtime-core/src/text.rs index eb4777dc18..c19a075b71 100644 --- a/crates/tracedecay-runtime-core/src/text.rs +++ b/crates/tracedecay-runtime-core/src/text.rs @@ -7,15 +7,7 @@ /// This is the safe replacement for `&s[..max_bytes]` when `s` may contain /// non-ASCII text and the caller has a byte budget rather than a char budget. pub fn utf8_prefix_at_or_before(s: &str, max_bytes: usize) -> &str { - if s.len() <= max_bytes { - return s; - } - - let mut end = max_bytes; - while !s.is_char_boundary(end) && end > 0 { - end -= 1; - } - &s[..end] + &s[..s.floor_char_boundary(max_bytes)] } /// Formats a token count as a compact string (e.g. "1.2M", "45.3k"). diff --git a/crates/tracedecay-runtime-core/src/tracedecay.rs b/crates/tracedecay-runtime-core/src/tracedecay.rs index c73d2c735e..559ab54f79 100644 --- a/crates/tracedecay-runtime-core/src/tracedecay.rs +++ b/crates/tracedecay-runtime-core/src/tracedecay.rs @@ -1,12 +1,12 @@ //! Kernel-owned slice of the root `tracedecay` orchestrator module. //! -//! Wall-clock stamps for this crate share [`wall_clock_since_epoch`]: a -//! pre-epoch clock saturates to a zero duration. Microsecond stamps then -//! saturate overflow to `i64::MAX`; second stamps keep the prior `as i64` -//! conversion. This crate cannot depend on `tracedecay_contracts::clock` -//! (that crate is the ports/contracts layer; taking it would pull policy, -//! tool-catalog, and schemars into a kernel that currently has no -//! application edge). +//! Wall-clock stamps for this crate share one read. A pre-epoch clock saturates +//! to a zero duration, except [`utc_now_or_one`], which reads as `1` so a failed +//! stamp stays distinct from an absent `UtcMicros(0)`. Microsecond overflow +//! saturates to `i64::MAX`; second stamps keep the prior `as i64` conversion. +//! This crate cannot depend on `tracedecay_contracts::clock` (that crate is the +//! ports/contracts layer; taking it would pull policy, tool-catalog, and +//! schemars into a kernel that currently has no application edge). use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -19,9 +19,22 @@ fn wall_clock_since_epoch() -> Duration { .unwrap_or_default() } +/// Unix seconds since the epoch. A pre-epoch clock is `0`. +pub fn unix_secs() -> u64 { + wall_clock_since_epoch().as_secs() +} + /// Returns the current UNIX timestamp in seconds. +/// +/// Overflow keeps the historical wrapping `as i64` cast. Callers that must +/// saturate use [`saturating_unix_secs`]. pub fn current_timestamp() -> i64 { - wall_clock_since_epoch().as_secs() as i64 + unix_secs() as i64 +} + +/// Unix seconds as `i64`. A pre-epoch clock is `0`; overflow is `i64::MAX`. +pub fn saturating_unix_secs() -> i64 { + i64::try_from(wall_clock_since_epoch().as_secs()).unwrap_or(i64::MAX) } /// Shared saturating wall clock for shard, registry, and fact-runtime stamps. @@ -30,5 +43,57 @@ pub fn current_timestamp() -> i64 { /// `i64::MAX`. This is the kernel-local equivalent of /// `tracedecay_contracts::clock::now_micros`. pub fn saturating_utc_now() -> UtcMicros { - UtcMicros(i64::try_from(wall_clock_since_epoch().as_micros()).unwrap_or(i64::MAX)) + UtcMicros(unix_micros_saturating(0)) +} + +/// Saturating microseconds since the epoch. A pre-epoch clock reads as `1`. +pub fn utc_now_or_one() -> UtcMicros { + UtcMicros(unix_micros_saturating(1)) +} + +fn unix_micros_saturating(pre_epoch: i64) -> i64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => i64::try_from(duration.as_micros()).unwrap_or(i64::MAX), + Err(_) => pre_epoch, + } +} + +/// Microseconds in `duration`, saturating to `u64::MAX` on overflow. +pub fn saturating_duration_micros(duration: Duration) -> u64 { + u64::try_from(duration.as_micros()).unwrap_or(u64::MAX) +} + +/// Milliseconds in `duration`, saturating to `u64::MAX` on overflow. +pub fn saturating_duration_millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +/// Unix milliseconds since the epoch. A pre-epoch clock is `0`. +pub fn unix_millis() -> u64 { + saturating_duration_millis(wall_clock_since_epoch()) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{saturating_duration_micros, saturating_duration_millis}; + + #[test] + fn saturating_duration_micros_keeps_small_spans_and_clamps_overflow() { + assert_eq!(saturating_duration_micros(Duration::from_micros(7)), 7); + assert_eq!( + saturating_duration_micros(Duration::from_secs(u64::MAX)), + u64::MAX + ); + } + + #[test] + fn saturating_duration_millis_keeps_small_spans_and_clamps_overflow() { + assert_eq!(saturating_duration_millis(Duration::from_millis(7)), 7); + assert_eq!( + saturating_duration_millis(Duration::from_secs(u64::MAX)), + u64::MAX + ); + } } diff --git a/crates/tracedecay-rusqlite-runtime/src/checkpoint/tests.rs b/crates/tracedecay-rusqlite-runtime/src/checkpoint/tests.rs index d5a6e57fd2..4eaa9e42c5 100644 --- a/crates/tracedecay-rusqlite-runtime/src/checkpoint/tests.rs +++ b/crates/tracedecay-rusqlite-runtime/src/checkpoint/tests.rs @@ -102,13 +102,7 @@ fn controller( .expect("fake driver configures") } -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; /// Test authority that admits one externally canonical publication and issues /// a permit only from an observed clear drain. It never derives an identity diff --git a/crates/tracedecay-rusqlite-runtime/src/exact_sql/command.rs b/crates/tracedecay-rusqlite-runtime/src/exact_sql/command.rs index b1b09dde95..3d3c9b9020 100644 --- a/crates/tracedecay-rusqlite-runtime/src/exact_sql/command.rs +++ b/crates/tracedecay-rusqlite-runtime/src/exact_sql/command.rs @@ -15,12 +15,12 @@ use std::{ use rusqlite::{Connection, DropBehavior, ErrorCode, Transaction, TransactionBehavior}; -use super::guard::{AuthorizedDatabaseOperation, with_exact_sql_guard}; +use super::guard::with_exact_sql_guard; use super::{ EXACT_SQL_TRANSACTION_IDLE_LIMIT, EXACT_SQL_TRANSACTION_LIMIT, ExactSqlAttachment, ExactSqlCommitReceipt, ExactSqlError, ExactSqlRollbackReceipt, ExactSqlRows, ExactSqlStatement, ExactSqlWriteAuthority, ExactSqlWriteIntent, ExecutionPolicy, MAX_EXACT_SQL_ATTACHMENTS, - SqlRequest, SqlResult, TransactionPolicy, attach_database, detach_database, execute_batch, + SqlRequest, SqlResult, TransactionPolicy, attach_database, detach_database, execute_query_unchecked, execute_request, publish_last_insert_rowid, sqlite_error, verify_write_authority, }; @@ -46,10 +46,6 @@ pub(crate) enum WriterCommand { reply: async_channel::Sender>, authority: Option>, }, - Vacuum { - reply: async_channel::Sender>, - authority: Option>, - }, } /// Pause between busy-begin attempts so the acquire deadline is the real bound. @@ -362,62 +358,6 @@ pub(crate) fn run_writer_command( }); let _ = reply.try_send(result); } - WriterCommand::Vacuum { reply, authority } => { - let Some(authority) = authority else { - let _ = reply.try_send(Err(ExactSqlError::AuthorityDenied( - "exclusive-maintenance vacuum requires attached write authority".to_owned(), - ))); - return; - }; - if let Err(error) = - verify_write_authority(Some(authority.as_ref()), ExactSqlWriteIntent::Vacuum) - { - let _ = reply.try_send(Err(error)); - return; - } - let previous_attachment_limit = - match connection.set_limit(Limit::SQLITE_LIMIT_ATTACHED, 1) { - Ok(previous) => previous, - Err(error) => { - let _ = reply.try_send(Err(sqlite_error( - "open exclusive-maintenance vacuum attachment slot", - error, - ))); - return; - } - }; - let mut result = hotpath::measure_block!("rusqlite.exact_sql.vacuum", { - with_exact_sql_guard( - connection, - false, - true, - Some(Arc::clone(shutdown_requested)), - None, - true, - Some((Arc::clone(&authority), ExactSqlWriteIntent::Vacuum)), - crate::connection::authorize_writer, - true, - Some(AuthorizedDatabaseOperation::Vacuum), - None, - || { - execute_batch(connection, "PRAGMA auto_vacuum = INCREMENTAL; VACUUM") - .map(|_| ()) - }, - ) - }); - if let Err(error) = - connection.set_limit(Limit::SQLITE_LIMIT_ATTACHED, previous_attachment_limit) - { - shutdown_requested.store(true, Ordering::Release); - if result.is_ok() { - result = Err(sqlite_error( - "restore exclusive-maintenance vacuum attachment limit", - error, - )); - } - } - let _ = reply.try_send(result); - } } } @@ -432,9 +372,6 @@ pub(crate) fn reject_writer_command(command: WriterCommand) { WriterCommand::CheckpointWalTruncate { reply, .. } => { let _ = reply.try_send(Err(ExactSqlError::WriterUnavailable)); } - WriterCommand::Vacuum { reply, .. } => { - let _ = reply.try_send(Err(ExactSqlError::WriterUnavailable)); - } } } diff --git a/crates/tracedecay-rusqlite-runtime/src/exact_sql/guard.rs b/crates/tracedecay-rusqlite-runtime/src/exact_sql/guard.rs index 925ff8ed7e..0bfcc54e96 100644 --- a/crates/tracedecay-rusqlite-runtime/src/exact_sql/guard.rs +++ b/crates/tracedecay-rusqlite-runtime/src/exact_sql/guard.rs @@ -34,7 +34,6 @@ pub(super) struct InsertTracker { pub(super) enum AuthorizedDatabaseOperation { Attach, Detach(String), - Vacuum, } #[allow(clippy::too_many_arguments)] @@ -202,7 +201,7 @@ fn authorize_exact_sql_writer( AuthAction::Attach { .. } if matches!( database_operation, - Some(AuthorizedDatabaseOperation::Attach | AuthorizedDatabaseOperation::Vacuum) + Some(AuthorizedDatabaseOperation::Attach) ) => { return Authorization::Allow; @@ -216,7 +215,7 @@ fn authorize_exact_sql_writer( } if code == rusqlite::ffi::SQLITE_ATTACH && matches!( database_operation, - Some(AuthorizedDatabaseOperation::Attach | AuthorizedDatabaseOperation::Vacuum) + Some(AuthorizedDatabaseOperation::Attach) ) => { return Authorization::Allow; @@ -226,9 +225,6 @@ fn authorize_exact_sql_writer( database_operation, Some(AuthorizedDatabaseOperation::Detach(expected)) if database_name.eq_ignore_ascii_case(expected) - ) || matches!( - database_operation, - Some(AuthorizedDatabaseOperation::Vacuum) ) => { return Authorization::Allow; diff --git a/crates/tracedecay-rusqlite-runtime/src/exact_sql/mod.rs b/crates/tracedecay-rusqlite-runtime/src/exact_sql/mod.rs index 8f8cd4ab5c..5c05e3522b 100644 --- a/crates/tracedecay-rusqlite-runtime/src/exact_sql/mod.rs +++ b/crates/tracedecay-rusqlite-runtime/src/exact_sql/mod.rs @@ -486,34 +486,6 @@ impl ExactSqlHandle { Ok(merge_memory_release(readers, writer)) } - /// Enables incremental auto-vacuum through its fixed maintenance rebuild. - fn enqueue_repair_incremental_auto_vacuum( - &self, - ) -> Result>, ExactSqlError> { - let (reply, response) = async_channel::bounded(1); - self.writer - .as_ref() - .ok_or(ExactSqlError::WriterUnavailable)? - .try_send(WriterCommand::Vacuum { - reply, - authority: self.write_authority.clone(), - }) - .map_err(map_writer_send_error)?; - Ok(response) - } - - pub fn repair_incremental_auto_vacuum(&self) -> Result<(), ExactSqlError> { - recv_writer_reply(self.enqueue_repair_incremental_auto_vacuum()?) - .map_err(|_| ExactSqlError::WriterUnavailable)? - } - - pub async fn repair_incremental_auto_vacuum_async(&self) -> Result<(), ExactSqlError> { - self.enqueue_repair_incremental_auto_vacuum()? - .recv() - .await - .map_err(|_| ExactSqlError::WriterUnavailable)? - } - /// Interactive read snapshot. Admits against the whole general lane. pub fn begin_read_snapshot( &self, diff --git a/crates/tracedecay-rusqlite-runtime/src/exact_sql/types.rs b/crates/tracedecay-rusqlite-runtime/src/exact_sql/types.rs index eb74bee2a6..0e571abed0 100644 --- a/crates/tracedecay-rusqlite-runtime/src/exact_sql/types.rs +++ b/crates/tracedecay-rusqlite-runtime/src/exact_sql/types.rs @@ -210,7 +210,6 @@ pub enum ExactSqlWriteIntent { Execute, Query, ExecuteBatch, - Vacuum, BeginTransaction, Commit, } diff --git a/crates/tracedecay-rusqlite-runtime/src/repository/external_source/tests.rs b/crates/tracedecay-rusqlite-runtime/src/repository/external_source/tests.rs index 5a458fb972..f503561957 100644 --- a/crates/tracedecay-rusqlite-runtime/src/repository/external_source/tests.rs +++ b/crates/tracedecay-rusqlite-runtime/src/repository/external_source/tests.rs @@ -8,7 +8,7 @@ use tracedecay_domain::feedback::{ FeedbackScopeV1, GitHubPullRequestIdV1, GitHubReviewReadOperationV1, }; use tracedecay_domain::{ - AccessPolicyDigest, CapabilityId, CommitId, ComponentVersion, LocatorDigest, ManifestDigest, + AccessPolicyDigest, CapabilityId, CommitId, ComponentVersion, LocatorDigest, PrivacyDomainBoundLocatorDigest, PrivacyDomainId, ProjectId, ProviderId, RepositoryId, ResolutionAuthorizationV1, RetrievalAnchorId, SanitizationReceiptId, SanitizationReceiptRefV1, ScopeResolutionId, SourceAcquisitionCapabilitiesV1, SourceAcquisitionContractV1, @@ -29,9 +29,7 @@ use tracedecay_store::{ use super::*; -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn fixture() -> (SourceCommitV1, SourceBindingIdentityV1) { let definition = SourceDefinitionV1::new( diff --git a/crates/tracedecay-rusqlite-runtime/src/repository/graph_publication/support.rs b/crates/tracedecay-rusqlite-runtime/src/repository/graph_publication/support.rs index 502d2252bd..361cc6b1a6 100644 --- a/crates/tracedecay-rusqlite-runtime/src/repository/graph_publication/support.rs +++ b/crates/tracedecay-rusqlite-runtime/src/repository/graph_publication/support.rs @@ -1509,9 +1509,7 @@ mod dependency_batch_tests { GraphPublicationOperationContextV1::new(&control, probe).unwrap() } - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; /// A valid hex digit character for `digest()`, cycling over `index`. /// Unlike a plain ASCII letter range, every value this returns is diff --git a/crates/tracedecay-rusqlite-runtime/src/repository/graph_publication/tests.rs b/crates/tracedecay-rusqlite-runtime/src/repository/graph_publication/tests.rs index 959e3cbb39..59f47ebe69 100644 --- a/crates/tracedecay-rusqlite-runtime/src/repository/graph_publication/tests.rs +++ b/crates/tracedecay-rusqlite-runtime/src/repository/graph_publication/tests.rs @@ -232,9 +232,7 @@ fn control_and_probe( ) } -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn projection(name: &str) -> GraphProjectionIdentityV1 { projection_for_project("project.fixture", name) diff --git a/crates/tracedecay-rusqlite-runtime/src/watermark/tests.rs b/crates/tracedecay-rusqlite-runtime/src/watermark/tests.rs index 8b055bd7ea..d163875e9e 100644 --- a/crates/tracedecay-rusqlite-runtime/src/watermark/tests.rs +++ b/crates/tracedecay-rusqlite-runtime/src/watermark/tests.rs @@ -8,13 +8,7 @@ use tracedecay_store::{ use super::*; use crate::read_consistency::{CommitWatermarkSource, WatermarkSourceState}; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn binding(project: &str) -> StoreRuntimeBindingV1 { StoreRuntimeBindingV1::new( diff --git a/crates/tracedecay-rusqlite-runtime/src/work_attempt.rs b/crates/tracedecay-rusqlite-runtime/src/work_attempt.rs index fabd6ef1ac..74e0d25773 100644 --- a/crates/tracedecay-rusqlite-runtime/src/work_attempt.rs +++ b/crates/tracedecay-rusqlite-runtime/src/work_attempt.rs @@ -16,8 +16,8 @@ use tracedecay_domain::{ use crate::exact_sql::ExactSqlValue; use crate::work::{ - WorkSqliteStorage, authority_params_owned, exact_sql_integer, exact_sql_statement, - exact_sql_text, registered_work_query, + RegisteredWorkQuery, WorkSqliteStorage, authority_params_owned, exact_sql_integer, + exact_sql_statement, exact_sql_text, registered_work_query, }; mod rooted_evidence; @@ -41,26 +41,8 @@ fn insert_attempt( .begin_immediate() .map_err(|_| WorkAttemptStorageError::Unavailable)?; let outcome = insert_attempt_in_transaction(&transaction, authority, attempt, concurrency); - match outcome { - Ok(WorkAttemptInsertOutcome::Inserted) => { - transaction - .commit() - .map_err(|_| WorkAttemptStorageError::Unavailable)?; - Ok(WorkAttemptInsertOutcome::Inserted) - } - Ok(WorkAttemptInsertOutcome::Replayed(attempt)) => { - transaction - .rollback() - .map_err(|_| WorkAttemptStorageError::Unavailable)?; - Ok(WorkAttemptInsertOutcome::Replayed(attempt)) - } - Err(error) => { - transaction - .rollback() - .map_err(|_| WorkAttemptStorageError::Unavailable)?; - Err(error) - } - } + let commit = matches!(outcome, Ok(WorkAttemptInsertOutcome::Inserted)); + finish_immediate(transaction, outcome, commit) }) } @@ -76,10 +58,9 @@ pub(crate) fn insert_attempt_in_transaction( synthesis: None, }) .map_err(|_| WorkAttemptStorageError::Unavailable)?; - if let Some(existing) = load_payload(transaction, authority, attempt.identity())? { + if let Some(existing) = load_attempt_payload(transaction, authority, attempt.identity())? { return if existing == payload { - let record: StoredWorkAttemptV1 = serde_json::from_str(&existing) - .map_err(|_| WorkAttemptStorageError::Unavailable)?; + let record = decode_stored(&existing)?; Ok(WorkAttemptInsertOutcome::Replayed(Box::new(record.attempt))) } else { Err(WorkAttemptStorageError::AttemptConflict) @@ -96,32 +77,7 @@ pub(crate) fn insert_attempt_in_transaction( )?; } hotpath::measure_block!("rusqlite.work_attempt.cas.insert", { - transaction - .execute( - exact_sql_statement( - "INSERT INTO work_attempts_v1 ( - project_id, repository_id, worktree_id, actor_id, policy_digest, - task_id, run_id, attempt_id, state, lease_id, fence_epoch, - terminal, attempt_payload, evidence_payload - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, NULL)", - authority_params_owned(authority) - .into_iter() - .chain(identity_params(attempt.identity())) - .chain([ - ExactSqlValue::Text(state_text(attempt.state())), - ExactSqlValue::Text(attempt.lease().lease_id().as_str().to_owned()), - ExactSqlValue::Integer( - i64::try_from(attempt.lease().epoch().get()) - .map_err(|_| WorkAttemptStorageError::Unavailable)?, - ), - ExactSqlValue::Integer(i64::from(attempt.is_terminal())), - ExactSqlValue::Text(payload), - ]) - .collect(), - ) - .map_err(|_| WorkAttemptStorageError::Unavailable)?, - ) - .map_err(|_| WorkAttemptStorageError::Unavailable)?; + insert_attempt_row(transaction, authority, attempt, payload)?; Ok(WorkAttemptInsertOutcome::Inserted) }) } @@ -201,26 +157,9 @@ impl WorkAttemptStoragePort for WorkSqliteStorage { authority: &WorkAuthority, identity: &WorkAttemptIdentityV1, ) -> Result { - let rows = registered_work_query( - self.handle(), - "SELECT attempt_payload FROM work_attempts_v1 - WHERE project_id = ?1 AND repository_id = ?2 AND worktree_id = ?3 - AND actor_id = ?4 AND policy_digest = ?5 - AND task_id = ?6 AND run_id = ?7 AND attempt_id = ?8", - authority_params_owned(authority) - .into_iter() - .chain(identity_params(identity)) - .collect(), - ) - .map_err(|_| WorkAttemptStorageError::Unavailable)?; - let payload = rows - .rows - .first() - .and_then(|row| exact_sql_text(&row.values, 0)) + let payload = load_attempt_payload(self.handle(), authority, identity)? .ok_or(WorkAttemptStorageError::NotFoundOrNotAuthorized)?; - serde_json::from_str::(payload) - .map(|record| record.attempt) - .map_err(|_| WorkAttemptStorageError::Unavailable) + Ok(decode_stored(&payload)?.attempt) } fn load_admission_kind( @@ -228,10 +167,9 @@ impl WorkAttemptStoragePort for WorkSqliteStorage { authority: &WorkAuthority, identity: &WorkAttemptIdentityV1, ) -> Result { - let payload = load_payload_from_handle(self.handle(), authority, identity)? + let payload = load_attempt_payload(self.handle(), authority, identity)? .ok_or(WorkAttemptStorageError::NotFoundOrNotAuthorized)?; - let record: StoredWorkAttemptV1 = - serde_json::from_str(&payload).map_err(|_| WorkAttemptStorageError::Unavailable)?; + let record = decode_stored(&payload)?; Ok(if record.synthesis.is_some() { WorkAttemptAdmissionKind::Synthesis } else { @@ -256,10 +194,9 @@ impl WorkAttemptStoragePort for WorkSqliteStorage { .handle() .begin_immediate() .map_err(|_| WorkAttemptStorageError::Unavailable)?; - let existing = load_payload(&transaction, authority, next.identity())? + let existing = load_attempt_payload(&transaction, authority, next.identity())? .ok_or(WorkAttemptStorageError::NotFoundOrNotAuthorized)?; - let mut record: StoredWorkAttemptV1 = serde_json::from_str(&existing) - .map_err(|_| WorkAttemptStorageError::Unavailable)?; + let mut record = decode_stored(&existing)?; record.attempt = next.clone(); let payload = serde_json::to_string(&record).map_err(|_| WorkAttemptStorageError::Unavailable)?; @@ -338,16 +275,7 @@ impl WorkAttemptStoragePort for WorkSqliteStorage { ); let rows = registered_work_query(self.handle(), &sql, authority_params_owned(authority)) .map_err(|_| WorkAttemptStorageError::Unavailable)?; - rows.rows - .into_iter() - .map(|row| { - let payload = - exact_sql_text(&row.values, 0).ok_or(WorkAttemptStorageError::Unavailable)?; - serde_json::from_str::(payload) - .map(|record| record.attempt) - .map_err(|_| WorkAttemptStorageError::Unavailable) - }) - .collect() + rows.rows.into_iter().map(attempt_from_row).collect() } fn has_open_attempts_in_exact_scope( @@ -433,13 +361,7 @@ impl WorkAttemptStoragePort for WorkSqliteStorage { let attempts = rows .rows .into_iter() - .map(|row| { - let payload = exact_sql_text(&row.values, 0) - .ok_or(WorkAttemptStorageError::Unavailable)?; - serde_json::from_str::(payload) - .map(|record| record.attempt) - .map_err(|_| WorkAttemptStorageError::Unavailable) - }) + .map(attempt_from_row) .collect::, _>>()?; Ok(WorkAttemptListPageV1 { attempts, @@ -461,26 +383,8 @@ fn insert_synthesis_record( .begin_immediate() .map_err(|_| WorkAttemptStorageError::Unavailable)?; let outcome = insert_synthesis_in_transaction(&transaction, authority, record, concurrency); - match outcome { - Ok(WorkSynthesisInsertOutcome::Inserted) => { - transaction - .commit() - .map_err(|_| WorkAttemptStorageError::Unavailable)?; - Ok(WorkSynthesisInsertOutcome::Inserted) - } - Ok(WorkSynthesisInsertOutcome::Replayed(result)) => { - transaction - .rollback() - .map_err(|_| WorkAttemptStorageError::Unavailable)?; - Ok(WorkSynthesisInsertOutcome::Replayed(result)) - } - Err(error) => { - transaction - .rollback() - .map_err(|_| WorkAttemptStorageError::Unavailable)?; - Err(error) - } - } + let commit = matches!(outcome, Ok(WorkSynthesisInsertOutcome::Inserted)); + finish_immediate(transaction, outcome, commit) }) } @@ -497,9 +401,8 @@ pub(crate) fn insert_synthesis_in_transaction( synthesis: Some(record.clone()), }) .map_err(|_| WorkAttemptStorageError::Unavailable)?; - if let Some(existing) = load_payload(transaction, authority, attempt.identity())? { - let existing: StoredWorkAttemptV1 = - serde_json::from_str(&existing).map_err(|_| WorkAttemptStorageError::Unavailable)?; + if let Some(existing) = load_attempt_payload(transaction, authority, attempt.identity())? { + let existing = decode_stored(&existing)?; return match existing.synthesis { Some(existing) if existing.request_digest == record.request_digest => Ok( WorkSynthesisInsertOutcome::Replayed(Box::new(existing.result)), @@ -518,32 +421,7 @@ pub(crate) fn insert_synthesis_in_transaction( )?; } hotpath::measure_block!("rusqlite.work_attempt.cas.synthesis", { - transaction - .execute( - exact_sql_statement( - "INSERT INTO work_attempts_v1 ( - project_id, repository_id, worktree_id, actor_id, policy_digest, - task_id, run_id, attempt_id, state, lease_id, fence_epoch, - terminal, attempt_payload, evidence_payload - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, NULL)", - authority_params_owned(authority) - .into_iter() - .chain(identity_params(attempt.identity())) - .chain([ - ExactSqlValue::Text(state_text(attempt.state())), - ExactSqlValue::Text(attempt.lease().lease_id().as_str().to_owned()), - ExactSqlValue::Integer( - i64::try_from(attempt.lease().epoch().get()) - .map_err(|_| WorkAttemptStorageError::Unavailable)?, - ), - ExactSqlValue::Integer(i64::from(attempt.is_terminal())), - ExactSqlValue::Text(payload), - ]) - .collect(), - ) - .map_err(|_| WorkAttemptStorageError::Unavailable)?, - ) - .map_err(|_| WorkAttemptStorageError::Unavailable)?; + insert_attempt_row(transaction, authority, attempt, payload)?; Ok(WorkSynthesisInsertOutcome::Inserted) }) } @@ -571,10 +449,9 @@ impl WorkSynthesisAdmissionStoragePort for WorkSqliteStorage { authority: &WorkAuthority, identity: &WorkAttemptIdentityV1, ) -> Result { - let payload = load_payload_from_handle(self.handle(), authority, identity)? + let payload = load_attempt_payload(self.handle(), authority, identity)? .ok_or(WorkAttemptStorageError::NotFoundOrNotAuthorized)?; - serde_json::from_str::(&payload) - .map_err(|_| WorkAttemptStorageError::Unavailable)? + decode_stored(&payload)? .synthesis .ok_or(WorkAttemptStorageError::AttemptConflict) } @@ -641,9 +518,7 @@ impl WorkAttemptEvidenceReadPort for WorkSqliteStorage { .map(|row| { let payload = exact_sql_text(&row.values, 0) .ok_or(WorkAttemptStorageError::Unavailable)?; - let attempt = serde_json::from_str::(payload) - .map_err(|_| WorkAttemptStorageError::Unavailable)? - .attempt; + let attempt = decode_stored(payload)?.attempt; let evidence = match exact_sql_text(&row.values, 1) { None => None, Some(evidence_payload) => Some( @@ -663,36 +538,73 @@ impl WorkAttemptEvidenceReadPort for WorkSqliteStorage { } } -fn load_payload_from_handle( - handle: &crate::exact_sql::ExactSqlHandle, - authority: &WorkAuthority, - identity: &WorkAttemptIdentityV1, -) -> Result, WorkAttemptStorageError> { - let rows = registered_work_query( - handle, - "SELECT attempt_payload FROM work_attempts_v1 - WHERE project_id = ?1 AND repository_id = ?2 AND worktree_id = ?3 - AND actor_id = ?4 AND policy_digest = ?5 - AND task_id = ?6 AND run_id = ?7 AND attempt_id = ?8", - authority_params_owned(authority) - .into_iter() - .chain(identity_params(identity)) - .collect(), - ) - .map_err(|_| WorkAttemptStorageError::Unavailable)?; - Ok(rows - .rows - .first() - .and_then(|row| exact_sql_text(&row.values, 0).map(str::to_owned))) +fn finish_immediate( + transaction: crate::exact_sql::ExactSqlTransaction, + outcome: Result, + commit: bool, +) -> Result { + let settled = if commit { + transaction.commit().map(|_| ()) + } else { + transaction.rollback().map(|_| ()) + }; + settled.map_err(|_| WorkAttemptStorageError::Unavailable)?; + outcome } -fn load_payload( +fn insert_attempt_row( transaction: &crate::exact_sql::ExactSqlTransaction, authority: &WorkAuthority, + attempt: &WorkAttemptV1, + payload: String, +) -> Result<(), WorkAttemptStorageError> { + transaction + .execute( + exact_sql_statement( + "INSERT INTO work_attempts_v1 ( + project_id, repository_id, worktree_id, actor_id, policy_digest, + task_id, run_id, attempt_id, state, lease_id, fence_epoch, + terminal, attempt_payload, evidence_payload + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, NULL)", + authority_params_owned(authority) + .into_iter() + .chain(identity_params(attempt.identity())) + .chain([ + ExactSqlValue::Text(state_text(attempt.state())), + ExactSqlValue::Text(attempt.lease().lease_id().as_str().to_owned()), + ExactSqlValue::Integer( + i64::try_from(attempt.lease().epoch().get()) + .map_err(|_| WorkAttemptStorageError::Unavailable)?, + ), + ExactSqlValue::Integer(i64::from(attempt.is_terminal())), + ExactSqlValue::Text(payload), + ]) + .collect(), + ) + .map_err(|_| WorkAttemptStorageError::Unavailable)?, + ) + .map_err(|_| WorkAttemptStorageError::Unavailable)?; + Ok(()) +} + +fn decode_stored(payload: &str) -> Result { + serde_json::from_str(payload).map_err(|_| WorkAttemptStorageError::Unavailable) +} + +fn attempt_from_row( + row: crate::exact_sql::ExactSqlRow, +) -> Result { + let payload = exact_sql_text(&row.values, 0).ok_or(WorkAttemptStorageError::Unavailable)?; + Ok(decode_stored(payload)?.attempt) +} + +fn load_attempt_payload( + source: &impl RegisteredWorkQuery, + authority: &WorkAuthority, identity: &WorkAttemptIdentityV1, ) -> Result, WorkAttemptStorageError> { let rows = registered_work_query( - transaction, + source, "SELECT attempt_payload FROM work_attempts_v1 WHERE project_id = ?1 AND repository_id = ?2 AND worktree_id = ?3 AND actor_id = ?4 AND policy_digest = ?5 @@ -743,8 +655,7 @@ fn require_first_run_admission( else { return Ok(()); }; - let first: StoredWorkAttemptV1 = - serde_json::from_str(payload).map_err(|_| WorkAttemptStorageError::Unavailable)?; + let first = decode_stored(payload)?; if first.attempt.execution().deadline() == attempt.execution().deadline() && first.attempt.execution().execution_snapshot().topology() == attempt.execution().execution_snapshot().topology() diff --git a/crates/tracedecay-rusqlite-runtime/src/workflow.rs b/crates/tracedecay-rusqlite-runtime/src/workflow.rs index 74f49186c0..6533275355 100644 --- a/crates/tracedecay-rusqlite-runtime/src/workflow.rs +++ b/crates/tracedecay-rusqlite-runtime/src/workflow.rs @@ -96,12 +96,10 @@ impl WorkflowSqliteAuthority { let Some(ExactSqlValue::Text(stored_digest)) = row.values.get(1) else { return Err(WorkflowSqliteAuthorityBuildError::ResetRequired); }; - let definition: WorkflowDefinition = serde_json::from_str(payload) - .map_err(|_| WorkflowSqliteAuthorityBuildError::ResetRequired)?; - let digest = canonical_sha256(&definition) - .map_err(|_| WorkflowSqliteAuthorityBuildError::ResetRequired)?; - if digest.as_str() != stored_digest - || definition.definition_id() != definition_id + let definition: WorkflowDefinition = + tracedecay_domain::decode_with_canonical_digest(payload, stored_digest) + .ok_or(WorkflowSqliteAuthorityBuildError::ResetRequired)?; + if definition.definition_id() != definition_id || definition.definition_version() != definition_version { return Err(WorkflowSqliteAuthorityBuildError::ResetRequired); @@ -277,13 +275,8 @@ fn decode_definition_source_row( let Some(ExactSqlValue::Text(stored_digest)) = row.values.get(1) else { return Err(definition_authority_unavailable()); }; - let definition: WorkflowDefinition = - serde_json::from_str(payload).map_err(|_| definition_authority_unavailable())?; - let digest = canonical_sha256(&definition).map_err(|_| definition_authority_unavailable())?; - if digest.as_str() != stored_digest { - return Err(definition_authority_unavailable()); - } - Ok(definition) + tracedecay_domain::decode_with_canonical_digest(payload, stored_digest) + .ok_or_else(definition_authority_unavailable) } fn definition_authority_unavailable() -> WorkflowDefinitionAuthorityError { @@ -435,17 +428,11 @@ fn statement(sql: &str, params: Vec) -> Result Option<&str> { - match values.get(index)? { - ExactSqlValue::Text(value) => Some(value), - _ => None, - } + crate::exact_sql::text_column(values, index) } fn sql_integer(values: &[ExactSqlValue], index: usize) -> Option { - match values.get(index)? { - ExactSqlValue::Integer(value) => Some(*value), - _ => None, - } + crate::exact_sql::integer_column(values, index) } fn version_i64(version: u64) -> Result { @@ -461,11 +448,11 @@ fn encode_definition(definition: &WorkflowDefinition) -> Result { } fn encode_json(value: &T) -> Result { - serde_json::to_string(value).map_err(|_| ()) + crate::exact_sql::encode_json(value, |_| ()) } fn decode_json(payload: &str) -> Result { - serde_json::from_str(payload).map_err(|_| ()) + crate::exact_sql::decode_json(payload, |_| ()) } fn query_tx( diff --git a/crates/tracedecay-rusqlite-runtime/src/workflow/census.rs b/crates/tracedecay-rusqlite-runtime/src/workflow/census.rs index 13701106fc..f087ad2e68 100644 --- a/crates/tracedecay-rusqlite-runtime/src/workflow/census.rs +++ b/crates/tracedecay-rusqlite-runtime/src/workflow/census.rs @@ -5,13 +5,13 @@ use tracedecay_contracts::{ WorkflowFanOutCensusStoragePort, }; use tracedecay_domain::{ - ObservabilityTerminalResultV1, RunId, WorkAuthority, WorkflowFanOutCensusV1, WorkflowRunEvent, + ObservabilityTerminalResultV1, RunId, WorkAuthority, WorkflowFanOutCensusV1, WorkflowRunProjection, WorkflowRunStatus, canonical_sha256, }; use super::{ - ExactSqlTransaction, ExactSqlValue, WorkflowSqliteAuthority, decode_json, encode_json, - execute_tx, execute_tx_changed, query_tx, sql_text, + ExactSqlTransaction, ExactSqlValue, WorkflowSqliteAuthority, encode_json, execute_tx, + execute_tx_changed, query_tx, sql_text, }; fn unavailable(_: E) -> WorkflowFanOutCensusError { @@ -23,15 +23,11 @@ fn decode_census( stored_digest: &str, ) -> Result { let census: WorkflowFanOutCensusV1 = - decode_json(payload).map_err(|_| WorkflowFanOutCensusError::InvalidHistory)?; + tracedecay_domain::decode_with_canonical_digest(payload, stored_digest) + .ok_or(WorkflowFanOutCensusError::InvalidHistory)?; census .validate() .map_err(|_| WorkflowFanOutCensusError::InvalidHistory)?; - let digest = - canonical_sha256(&census).map_err(|_| WorkflowFanOutCensusError::InvalidHistory)?; - if digest.as_str() != stored_digest { - return Err(WorkflowFanOutCensusError::InvalidHistory); - } Ok(census) } @@ -117,14 +113,8 @@ fn projection_through_tx( sql_text(&row.values, 0).ok_or(WorkflowFanOutCensusError::InvalidHistory)?; let stored_digest = sql_text(&row.values, 1).ok_or(WorkflowFanOutCensusError::InvalidHistory)?; - let event: WorkflowRunEvent = - decode_json(payload).map_err(|_| WorkflowFanOutCensusError::InvalidHistory)?; - let digest = - canonical_sha256(&event).map_err(|_| WorkflowFanOutCensusError::InvalidHistory)?; - if digest.as_str() != stored_digest { - return Err(WorkflowFanOutCensusError::InvalidHistory); - } - Ok(event) + tracedecay_domain::decode_with_canonical_digest(payload, stored_digest) + .ok_or(WorkflowFanOutCensusError::InvalidHistory) }) .collect::, _>>()?; let projection = WorkflowRunProjection::rebuild(&history) diff --git a/crates/tracedecay-rusqlite-runtime/src/workflow/run_journal.rs b/crates/tracedecay-rusqlite-runtime/src/workflow/run_journal.rs index e74819366a..d8334c1a16 100644 --- a/crates/tracedecay-rusqlite-runtime/src/workflow/run_journal.rs +++ b/crates/tracedecay-rusqlite-runtime/src/workflow/run_journal.rs @@ -18,8 +18,8 @@ use tracedecay_domain::{ }; use super::{ - ExactSqlTransaction, ExactSqlValue, WorkflowSqliteAuthority, decode_json, encode_json, - execute_tx, query_tx, sql_text, + ExactSqlTransaction, ExactSqlValue, WorkflowSqliteAuthority, encode_json, execute_tx, query_tx, + sql_text, }; fn run_journal_unavailable(_: E) -> WorkflowRunStorageError { @@ -30,13 +30,8 @@ fn decode_event( payload: &str, stored_digest: &str, ) -> Result { - let event: WorkflowRunEvent = - decode_json(payload).map_err(|_| WorkflowRunStorageError::InvalidHistory)?; - let digest = canonical_sha256(&event).map_err(|_| WorkflowRunStorageError::InvalidHistory)?; - if digest.as_str() != stored_digest { - return Err(WorkflowRunStorageError::InvalidHistory); - } - Ok(event) + tracedecay_domain::decode_with_canonical_digest(payload, stored_digest) + .ok_or(WorkflowRunStorageError::InvalidHistory) } fn history_tx( diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/common/mod.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/common/mod.rs index 8b1ac310bc..ba4e206faf 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/common/mod.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/common/mod.rs @@ -1,9 +1 @@ -/// Platform-absolute fixture root: the work and registered-root contracts -/// require `Path::is_absolute`, which a bare `/...` literal fails on Windows. -pub fn fixture_abs_root(posix: &str) -> String { - if cfg!(windows) { - format!("C:{}", posix.replace('/', "\\")) - } else { - posix.to_owned() - } -} +pub use tracedecay_domain::test_fixtures::fixture_abs_root; diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/handoff_open_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/handoff_open_storage.rs index a9d5be7c6b..6492b245d3 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/handoff_open_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/handoff_open_storage.rs @@ -13,7 +13,7 @@ use tracedecay_contracts::{ RequestId, ResolvedScope, TaskHandoffTokenStateV1, }; use tracedecay_domain::{ - ActorId, ManifestDigest, ProjectId, RepositoryId, TaskId, UtcMicros, WorkVersion, WorktreeId, + ActorId, ProjectId, RepositoryId, TaskId, UtcMicros, WorkVersion, WorktreeId, }; use tracedecay_rusqlite_runtime::handoff::HandoffOpenSqliteAuthority; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; @@ -37,17 +37,9 @@ impl HandoffOpenTargetPort for CurrentTarget { } } -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(fill: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", fill.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context(request_id: &str) -> RequestContext { context_for_actor(request_id, "actor.handoff.runtime-store") diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/multi_root_scope_set.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/multi_root_scope_set.rs index ffeb72fddd..ae1c354fb3 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/multi_root_scope_set.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/multi_root_scope_set.rs @@ -1,5 +1,4 @@ use std::collections::BTreeSet; -use std::fmt; use std::path::PathBuf; use rusqlite::{Connection, Savepoint}; @@ -10,8 +9,8 @@ use tracedecay_contracts::{ RequestId, ResolvedScope, SharedProfileStoreLocatorV1, }; use tracedecay_domain::{ - ActorId, BrainId, LocatorDigest, ManifestDigest, ProjectId, RefId, RepositoryId, ScopeSetId, - ScopeSetRevision, UserProfileId, UtcMicros, WorktreeId, + ActorId, BrainId, LocatorDigest, ProjectId, RefId, RepositoryId, ScopeSetId, ScopeSetRevision, + UserProfileId, UtcMicros, WorktreeId, }; use tracedecay_rusqlite_runtime::exact_sql::ExactSqlHandle; use tracedecay_rusqlite_runtime::reader::{ExistingReaderLocator, ReaderPool, ReaderQueryExecutor}; @@ -139,17 +138,9 @@ fn registered_locator(binding: &StoreRuntimeBindingV1) -> VerifiedStoreLocatorV1 ) } -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn context_for_actor(worktree: &str, suffix: &str, actor: &str) -> RequestContext { let scope = ResolvedScope::new( diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/repository_attachment.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/repository_attachment.rs index 114146e7b2..79b3777f88 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/repository_attachment.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/repository_attachment.rs @@ -1,5 +1,3 @@ -use std::fmt::Debug; - use rusqlite::Connection; use tempfile::TempDir; use tracedecay_domain::{ @@ -20,13 +18,7 @@ use tracedecay_store::{ VerifiedStoreLocatorV1, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn binding() -> StoreRuntimeBindingV1 { serde_json::from_value(serde_json::json!({ diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/runtime_actor/support.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/runtime_actor/support.rs index 2be587dbd5..0649a484be 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/runtime_actor/support.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/runtime_actor/support.rs @@ -55,9 +55,7 @@ impl TestBinding { } } -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn priority_name(priority: OperationPriorityV1) -> &'static str { match priority { diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/transactional_inbox.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/transactional_inbox.rs index 5878f59d46..4a7c470d44 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/transactional_inbox.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/transactional_inbox.rs @@ -60,9 +60,7 @@ impl TestDatabase { } } -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; /// A real (non-mock) `StorageOperationExecutor`: it performs a genuine SQL /// insert against the savepoint `apply_inbox` hands it, keyed by the applied diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_attempt_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_attempt_storage.rs index d7dc2fae5b..928e61da59 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_attempt_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_attempt_storage.rs @@ -27,7 +27,7 @@ use tracedecay_contracts::{ }; use tracedecay_domain::configuration::TopologyConcurrencyPolicyV1; use tracedecay_domain::{ - ActorId, AttemptId, CommitId, ConfigurationRevisionId, ConfigurationSnapshotId, ManifestDigest, + ActorId, AttemptId, CommitId, ConfigurationRevisionId, ConfigurationSnapshotId, ObservationSourceIdentityV1, ProjectId, ProposalId, ProviderId, RefId, RepositoryId, RunId, SessionId, TaskId, UtcMicros, WorkApprovalPolicy, WorkArtifactRefV1, WorkAttemptIdentityV1, WorkAttemptProjectionBindingV1, WorkAttemptStateV1, WorkAttemptV1, WorkAuthority, @@ -45,17 +45,9 @@ use common::fixture_abs_root; use tracedecay_rusqlite_runtime::workflow::install_workflow_schema; use work_registered_store::RegisteredWorkStore; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn authority(actor: &str) -> WorkAuthority { authority_in_worktree(actor, "worktree.attempt.storage") diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_duplicate_adjudication_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_duplicate_adjudication_storage.rs index fd4b77ab40..ccca0529dd 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_duplicate_adjudication_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_duplicate_adjudication_storage.rs @@ -20,17 +20,9 @@ use tracedecay_domain::{ use work_registered_store::RegisteredWorkStore; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn topology_ref(byte: char) -> WorkTopologyGenerationRefV1 { WorkTopologyGenerationRefV1::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_leak_adjudication_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_leak_adjudication_storage.rs index 6a2a75095b..3fa698b98c 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_leak_adjudication_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_leak_adjudication_storage.rs @@ -11,8 +11,8 @@ use tracedecay_contracts::{ }; use tracedecay_domain::{ ActorId, AttemptId, CommitId, ConfigurationRevisionId, ConfigurationSnapshotId, - CoverageStateV1, LeakOwnerClassV1, ManifestDigest, ProjectId, ProposalId, ProviderId, RefId, - RepositoryId, RunId, TaskId, UtcMicros, WorkApprovalPolicy, WorkAttemptIdentityV1, + CoverageStateV1, LeakOwnerClassV1, ProjectId, ProposalId, ProviderId, RefId, RepositoryId, + RunId, TaskId, UtcMicros, WorkApprovalPolicy, WorkAttemptIdentityV1, WorkAttemptProjectionBindingV1, WorkAttemptStateV1, WorkAttemptV1, WorkAuthority, WorkCancellationStateV1, WorkCommandId, WorkEffectStateV1, WorkEgressPolicy, WorkExecutableReference, WorkExecutionEnvelopeV1, WorkExecutionLeakKindV1, @@ -27,17 +27,9 @@ use tracedecay_domain::{ use common::fixture_abs_root; use work_registered_store::RegisteredWorkStore; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn authority() -> WorkAuthority { WorkAuthority::new( diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_placement_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_placement_storage.rs index 1e90977e06..b8eefa8b74 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_placement_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_placement_storage.rs @@ -14,7 +14,7 @@ use std::collections::BTreeSet; use tracedecay_contracts::{WorkPlacementStorageError, WorkPlacementStoragePort}; use tracedecay_domain::{ - ActorId, ManifestDigest, ProjectId, RepositoryId, RunId, TaskId, UtcMicros, WorkAuthority, + ActorId, ProjectId, RepositoryId, RunId, TaskId, UtcMicros, WorkAuthority, WorkPlacementBlockerV1, WorkPlacementIdentityV1, WorkPlacementKindV1, WorkPlacementObservationV1, WorkPlacementPreflightV1, WorkPlacementStateV1, WorkPlacementTargetV1, WorkPlacementV1, WorktreeId, @@ -26,17 +26,9 @@ use work_registered_store::RegisteredWorkStore; static ROOT: std::sync::LazyLock = std::sync::LazyLock::new(|| fixture_abs_root("/workspace/placement-storage")); -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn authority(actor: &str) -> WorkAuthority { authority_in_worktree_with_policy(actor, "worktree.placement.storage", 'a') diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_product_graph_authority.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_product_graph_authority.rs index db9131f1d8..2d16d00d07 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_product_graph_authority.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_product_graph_authority.rs @@ -26,7 +26,7 @@ use tracedecay_contracts::{ }; use tracedecay_domain::{ AcceptanceCriterionId, ActorId, CatalogGenerationId, ConfigurationRevisionId, InitiativeId, - ManifestDigest, MilestoneId, PolicyRevisionId, ProjectId, RepositoryId, TaskId, UtcMicros, + MilestoneId, PolicyRevisionId, ProjectId, RepositoryId, TaskId, UtcMicros, WorkAcceptanceCriterionV1, WorkCommandId, WorkGraphVersionV1, WorkHierarchyV1, WorkInitiativeV1, WorkItemInputV1, WorkItemV1, WorkMilestoneV1, WorkPlanId, WorkPlanV1, WorkProductEventPayloadV1, WorkProductEventSequenceV1, WorkProductGraphV1, @@ -43,17 +43,9 @@ const REPOSITORY: &str = "repository.work-product.fixture"; /// `occurred_at`, so a projection is never asked to describe its own future. const PROJECTED_AT: UtcMicros = UtcMicros(400); -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn binding() -> WorkProductBindingV1 { WorkProductBindingV1::new( diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_product_query_authority.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_product_query_authority.rs index 1547ac9067..048597c289 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_product_query_authority.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_product_query_authority.rs @@ -38,11 +38,11 @@ use tracedecay_contracts::{ }; use tracedecay_domain::{ AcceptanceCriterionId, ActorId, CatalogGenerationId, ConfigurationRevisionId, InitiativeId, - ManifestDigest, MilestoneId, PolicyRevisionId, ProjectId, RepositoryId, RetrievalAnchorId, - TaskEvidenceLinkId, TaskEvidenceLinkV1, TaskId, UtcMicros, WorkAcceptanceCriterionV1, - WorkCommandId, WorkGraphVersionV1, WorkHierarchyV1, WorkInitiativeV1, WorkItemInputV1, - WorkItemV1, WorkMilestoneV1, WorkPlanId, WorkPlanV1, WorkProductEventSequenceV1, - WorkProductGraphV1, WorkTaskEvidenceCoverageV1, WorktreeId, + MilestoneId, PolicyRevisionId, ProjectId, RepositoryId, RetrievalAnchorId, TaskEvidenceLinkId, + TaskEvidenceLinkV1, TaskId, UtcMicros, WorkAcceptanceCriterionV1, WorkCommandId, + WorkGraphVersionV1, WorkHierarchyV1, WorkInitiativeV1, WorkItemInputV1, WorkItemV1, + WorkMilestoneV1, WorkPlanId, WorkPlanV1, WorkProductEventSequenceV1, WorkProductGraphV1, + WorkTaskEvidenceCoverageV1, WorktreeId, }; use tracedecay_rusqlite_runtime::work::WorkSqliteStorage; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; @@ -56,17 +56,9 @@ const REPOSITORY: &str = "repository.work-product-query.fixture"; const OBSERVED_AT: UtcMicros = UtcMicros(400); const TASK: &str = "task.deliver"; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn binding() -> WorkProductBindingV1 { WorkProductBindingV1::new( diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_run_control_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_run_control_storage.rs index d35f5f8301..6d4ba5f425 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_run_control_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/work_run_control_storage.rs @@ -19,10 +19,10 @@ use tracedecay_contracts::{ WorkRunControlStoragePort, }; use tracedecay_domain::{ - ActorId, AttemptId, CommitId, ConfigurationRevisionId, ConfigurationSnapshotId, ManifestDigest, - ProjectId, ProposalId, ProviderId, RefId, RepositoryId, RunId, TaskId, UtcMicros, - WorkApprovalPolicy, WorkAttemptIdentityV1, WorkAttemptProjectionBindingV1, WorkAttemptStateV1, - WorkAttemptV1, WorkAuthority, WorkBlockedIntervalCauseV1, WorkBlockedIntervalClosureV1, + ActorId, AttemptId, CommitId, ConfigurationRevisionId, ConfigurationSnapshotId, ProjectId, + ProposalId, ProviderId, RefId, RepositoryId, RunId, TaskId, UtcMicros, WorkApprovalPolicy, + WorkAttemptIdentityV1, WorkAttemptProjectionBindingV1, WorkAttemptStateV1, WorkAttemptV1, + WorkAuthority, WorkBlockedIntervalCauseV1, WorkBlockedIntervalClosureV1, WorkBlockedIntervalIdentityV1, WorkBlockedIntervalReceiptV1, WorkCancellationStateV1, WorkEffectStateV1, WorkEgressPolicy, WorkExecutableReference, WorkExecutionEnvelopeV1, WorkExecutionLimits, WorkExecutionSnapshot, WorkExecutionSnapshotInput, WorkFallbackTopology, @@ -39,17 +39,9 @@ use work_registered_store::RegisteredWorkStore; const ADMITTED_DEADLINE: UtcMicros = UtcMicros(1_000_000); -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn authority(actor: &str) -> WorkAuthority { WorkAuthority::new( diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_fan_out_census_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_fan_out_census_storage.rs index 1f3ad60ee9..0ff22554f1 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_fan_out_census_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_fan_out_census_storage.rs @@ -31,13 +31,7 @@ use crate::registered_workflow_store; use registered_workflow_store::RegisteredWorkflowStore; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn digest(byte: char) -> ManifestDigest { let hex = format!("{:02x}", u32::from(byte) & 0xff); diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_run_journal_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_run_journal_storage.rs index a5d3c9e10f..a6474202fb 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_run_journal_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_run_journal_storage.rs @@ -39,13 +39,7 @@ use crate::registered_workflow_store; use registered_workflow_store::RegisteredWorkflowStore; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; /// A distinct, valid `sha256:`-tagged digest per input byte. fn digest(byte: char) -> ManifestDigest { diff --git a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_runtime_storage.rs b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_runtime_storage.rs index bd05d1dae0..d2ae0fa316 100644 --- a/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_runtime_storage.rs +++ b/crates/tracedecay-rusqlite-runtime/tests/rusqlite_suite/workflow_runtime_storage.rs @@ -26,13 +26,7 @@ use crate::registered_workflow_store; use registered_workflow_store::RegisteredWorkflowStore; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; /// A distinct, valid `sha256:`-tagged digest per input byte. /// diff --git a/crates/tracedecay-sdk/src/client.rs b/crates/tracedecay-sdk/src/client.rs index 4703be105d..314e77fe42 100644 --- a/crates/tracedecay-sdk/src/client.rs +++ b/crates/tracedecay-sdk/src/client.rs @@ -300,31 +300,31 @@ impl Client { || schema_id != Some(Operation::RESULT_SCHEMA_ID) || schema_revision != Some(u64::from(Operation::RESULT_SCHEMA_REVISION)) { - return Err(ClientError::Protocol { - status: Some(response.status()), - message: format!( + return Err(protocol( + response.status(), + format!( "daemon returned mismatched contracts for {}", Operation::OPERATION_ID ), - }); + )); } - let outcome = - response - .envelope() - .get("outcome") - .ok_or_else(|| ClientError::Protocol { - status: Some(response.status()), - message: format!("daemon omitted the {} outcome", Operation::OPERATION_ID), - })?; + let outcome = response.envelope().get("outcome").ok_or_else(|| { + protocol( + response.status(), + format!("daemon omitted the {} outcome", Operation::OPERATION_ID), + ) + })?; let outcome_kind = outcome .get("outcome") .and_then(Value::as_str) - .ok_or_else(|| ClientError::Protocol { - status: Some(response.status()), - message: format!( - "daemon omitted the {} outcome kind", - Operation::OPERATION_ID - ), + .ok_or_else(|| { + protocol( + response.status(), + format!( + "daemon omitted the {} outcome kind", + Operation::OPERATION_ID + ), + ) })?; let lifecycle_shape_is_legal = matches!( (Operation::RECEIPT, Operation::RECONCILIATION, outcome_kind), @@ -339,100 +339,107 @@ impl Client { ) ); if !lifecycle_shape_is_legal { - return Err(ClientError::Protocol { - status: Some(response.status()), - message: format!( + return Err(protocol( + response.status(), + format!( "daemon returned outcome {outcome_kind} outside the {} receipt contract", Operation::OPERATION_ID ), - }); + )); } let execution = outcome .get("value") .and_then(|value| value.get("execution")) - .ok_or_else(|| ClientError::Protocol { - status: Some(response.status()), - message: format!( - "daemon omitted the {} execution receipt", - Operation::OPERATION_ID - ), + .ok_or_else(|| { + protocol( + response.status(), + format!( + "daemon omitted the {} execution receipt", + Operation::OPERATION_ID + ), + ) })?; let termination = execution .get("termination") .and_then(Value::as_str) - .ok_or_else(|| ClientError::Protocol { - status: Some(response.status()), - message: format!( - "daemon omitted the {} terminal state", - Operation::OPERATION_ID - ), + .ok_or_else(|| { + protocol( + response.status(), + format!( + "daemon omitted the {} terminal state", + Operation::OPERATION_ID + ), + ) })?; if !Operation::TERMINAL_STATES .iter() .copied() .any(|state| terminal_state_name(state) == termination) { - return Err(ClientError::Protocol { - status: Some(response.status()), - message: format!( + return Err(protocol( + response.status(), + format!( "daemon returned terminal state {termination} outside the {} contract", Operation::OPERATION_ID ), - }); + )); } if let Some(cancellation) = execution.get("cancellation") && !cancellation.is_null() { if !Operation::CANCELLABLE { - return Err(ClientError::Protocol { - status: Some(response.status()), - message: format!( + return Err(protocol( + response.status(), + format!( "daemon returned cancellation evidence for non-cancellable {}", Operation::OPERATION_ID ), - }); + )); } let stage = cancellation .get("stage") .and_then(Value::as_str) - .ok_or_else(|| ClientError::Protocol { - status: Some(response.status()), - message: format!( - "daemon returned malformed cancellation evidence for {}", - Operation::OPERATION_ID - ), + .ok_or_else(|| { + protocol( + response.status(), + format!( + "daemon returned malformed cancellation evidence for {}", + Operation::OPERATION_ID + ), + ) })?; if !Operation::CANCELLATION_POINTS .iter() .copied() .any(|point| cancellation_point_name(point) == stage) { - return Err(ClientError::Protocol { - status: Some(response.status()), - message: format!( + return Err(protocol( + response.status(), + format!( "daemon returned cancellation stage {stage} outside the {} contract", Operation::OPERATION_ID ), - }); + )); } } - let payload = response - .payload() - .cloned() - .ok_or_else(|| ClientError::Protocol { - status: Some(response.status()), - message: format!( + let payload = response.payload().cloned().ok_or_else(|| { + protocol( + response.status(), + format!( "daemon omitted the {} result payload", Operation::OPERATION_ID ), - })?; + ) + })?; let request_id = response .envelope() .get("request_id") .and_then(Value::as_str) - .ok_or_else(|| ClientError::Protocol { - status: Some(response.status()), - message: "daemon omitted the application request ID".into(), + .ok_or_else(|| { + protocol( + response.status(), + "daemon omitted the application request ID", + ) })? .to_owned(); if !crate::semantic::response_matches( @@ -442,22 +449,23 @@ impl Client { &request, &payload, ) { - return Err(ClientError::Protocol { - status: Some(response.status()), - message: format!( + return Err(protocol( + response.status(), + format!( "daemon returned a semantically invalid {} result", Operation::OPERATION_ID ), - }); + )); } - let result = - serde_json::from_value(payload).map_err(|error| ClientError::Protocol { - status: Some(response.status()), - message: format!( + let result = serde_json::from_value(payload).map_err(|error| { + protocol( + response.status(), + format!( "daemon returned a malformed {} result: {error}", Operation::OPERATION_ID ), - })?; + ) + })?; Ok(TypedResponse { request_id, result, @@ -521,9 +529,11 @@ impl Client { return Err(ClientError::Authentication(status.as_u16())); } let body: Value = crate::observe::body_decode(|| { - response.json().map_err(|error| ClientError::Protocol { - status: Some(status.as_u16()), - message: format!("daemon returned malformed cancellation JSON: {error}"), + response.json().map_err(|error| { + protocol( + status.as_u16(), + format!("daemon returned malformed cancellation JSON: {error}"), + ) }) })?; if body.get("kind").and_then(Value::as_str) == Some("problem") { @@ -533,11 +543,12 @@ impl Client { "cancellation problem envelope has no value", )); } - let value: OperationCancellation = - serde_json::from_value(body).map_err(|error| ClientError::Protocol { - status: Some(status.as_u16()), - message: format!("daemon returned malformed cancellation JSON: {error}"), - })?; + let value: OperationCancellation = serde_json::from_value(body).map_err(|error| { + protocol( + status.as_u16(), + format!("daemon returned malformed cancellation JSON: {error}"), + ) + })?; let valid_status = matches!( (status, value.status), (StatusCode::ACCEPTED, CancellationStatus::Requested) @@ -545,10 +556,10 @@ impl Client { | (StatusCode::OK, CancellationStatus::AlreadyTerminal) ); if !valid_status { - return Err(ClientError::Protocol { - status: Some(status.as_u16()), - message: "daemon returned a non-canonical cancellation response".into(), - }); + return Err(protocol( + status.as_u16(), + "daemon returned a non-canonical cancellation response", + )); } Ok(value) })()) @@ -612,24 +623,23 @@ impl Client { return Err(ClientError::Authentication(status.as_u16())); } if media_type(response.headers()) != Some("application/json") { - return Err(ClientError::Protocol { - status: Some(status.as_u16()), - message: "daemon response is not application/json".into(), - }); + return Err(protocol( + status.as_u16(), + "daemon response is not application/json", + )); } - let body: Value = response.json().map_err(|error| ClientError::Protocol { - status: Some(status.as_u16()), - message: format!("daemon returned malformed application JSON: {error}"), + let body: Value = response.json().map_err(|error| { + protocol( + status.as_u16(), + format!("daemon returned malformed application JSON: {error}"), + ) })?; match body.get("kind").and_then(Value::as_str) { Some("success") if status.is_success() => { let value = body .get("value") .cloned() - .ok_or_else(|| ClientError::Protocol { - status: Some(status.as_u16()), - message: "success envelope has no value".into(), - })?; + .ok_or_else(|| protocol(status.as_u16(), "success envelope has no value"))?; ApplicationResponse::new(value, status.as_u16()) } Some("problem") if !status.is_success() => { @@ -640,10 +650,10 @@ impl Client { if expected_request_id .is_some_and(|expected| response_request_id != Some(expected.as_str())) { - return Err(ClientError::Protocol { - status: Some(status.as_u16()), - message: "daemon returned a different application request ID".into(), - }); + return Err(protocol( + status.as_u16(), + "daemon returned a different application request ID", + )); } Err(problem_error( &body, @@ -651,10 +661,10 @@ impl Client { "problem envelope has no value", )) } - _ => Err(ClientError::Protocol { - status: Some(status.as_u16()), - message: "daemon returned an inconsistent HTTP envelope".into(), - }), + _ => Err(protocol( + status.as_u16(), + "daemon returned an inconsistent HTTP envelope", + )), } } } @@ -784,10 +794,7 @@ fn media_type(headers: &HeaderMap) -> Option<&str> { /// carries no value, because only the caller knows which surface it read. fn problem_error(body: &Value, status: u16, missing_value: &'static str) -> ClientError { let Some(envelope) = body.get("value").cloned() else { - return ClientError::Protocol { - status: Some(status), - message: missing_value.into(), - }; + return protocol(status, missing_value); }; match ProblemError::new(status, envelope) { Ok(problem) => ClientError::Problem(Box::new(problem)), @@ -1084,9 +1091,11 @@ impl OperationStream { let media_type = media_type(response.headers()); if !status.is_success() && media_type == Some("application/json") { let body: Value = crate::observe::body_decode(|| { - response.json().map_err(|error| ClientError::Protocol { - status: Some(status.as_u16()), - message: format!("daemon returned malformed stream problem JSON: {error}"), + response.json().map_err(|error| { + protocol( + status.as_u16(), + format!("daemon returned malformed stream problem JSON: {error}"), + ) }) })?; if body.get("kind").and_then(Value::as_str) == Some("problem") { @@ -1096,16 +1105,16 @@ impl OperationStream { "stream problem envelope has no value", )); } - return Err(ClientError::Protocol { - status: Some(status.as_u16()), - message: "daemon returned an unknown stream problem envelope".into(), - }); + return Err(protocol( + status.as_u16(), + "daemon returned an unknown stream problem envelope", + )); } if !status.is_success() || media_type != Some("text/event-stream") { - return Err(ClientError::Protocol { - status: Some(status.as_u16()), - message: "daemon did not open a canonical event stream".into(), - }); + return Err(protocol( + status.as_u16(), + "daemon did not open a canonical event stream", + )); } self.reader = Some(BufReader::new(response)); Ok(()) diff --git a/crates/tracedecay-sdk/src/remote_client.rs b/crates/tracedecay-sdk/src/remote_client.rs index 96d738d6d8..4ee6e73b60 100644 --- a/crates/tracedecay-sdk/src/remote_client.rs +++ b/crates/tracedecay-sdk/src/remote_client.rs @@ -564,22 +564,7 @@ fn take_response_field( } fn status_matches_problem(status: reqwest::StatusCode, kind: ApplicationProblemKind) -> bool { - let expected = match kind { - ApplicationProblemKind::InvalidRequest => reqwest::StatusCode::BAD_REQUEST, - ApplicationProblemKind::NotFoundOrNotAuthorized => reqwest::StatusCode::NOT_FOUND, - ApplicationProblemKind::Conflict - | ApplicationProblemKind::PartialEffect - | ApplicationProblemKind::Stale => reqwest::StatusCode::CONFLICT, - ApplicationProblemKind::Unsupported => reqwest::StatusCode::UNPROCESSABLE_ENTITY, - ApplicationProblemKind::Unavailable | ApplicationProblemKind::ResetRequired => { - reqwest::StatusCode::SERVICE_UNAVAILABLE - } - ApplicationProblemKind::ExecutionFailed => reqwest::StatusCode::INTERNAL_SERVER_ERROR, - ApplicationProblemKind::Saturated => reqwest::StatusCode::TOO_MANY_REQUESTS, - ApplicationProblemKind::Cancelled => reqwest::StatusCode::REQUEST_TIMEOUT, - ApplicationProblemKind::TimedOut => reqwest::StatusCode::GATEWAY_TIMEOUT, - }; - status == expected + status.as_u16() == tracedecay_api::application_problem_status(kind).as_u16() } #[cfg(test)] diff --git a/crates/tracedecay-sdk/tests/sdk_suite/main.rs b/crates/tracedecay-sdk/tests/sdk_suite/main.rs index 68726569b7..20a9de5547 100644 --- a/crates/tracedecay-sdk/tests/sdk_suite/main.rs +++ b/crates/tracedecay-sdk/tests/sdk_suite/main.rs @@ -7,7 +7,22 @@ //! `production_daemon::` module by that prefix). `remote_client_proxy` stays //! its own binary because it mutates the process-wide proxy environment. +#[path = "../../../../tests/support/isolated_profile.rs"] +mod isolated_profile; + mod client; mod facade; mod production_daemon; mod semantic_replay; + +fn production_binary() -> std::path::PathBuf { + let path = std::env::var_os("TRACEDECAY_TEST_BIN") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from("../../target/debug/tracedecay")); + std::fs::canonicalize(&path) + .unwrap_or_else(|error| panic!("missing production daemon {}: {error}", path.display())) +} + +fn run(command: &mut std::process::Command) -> Vec { + isolated_profile::run_ok(command, "command") +} diff --git a/crates/tracedecay-sdk/tests/sdk_suite/production_daemon.rs b/crates/tracedecay-sdk/tests/sdk_suite/production_daemon.rs index 6e2ead1c76..28b3d9d933 100644 --- a/crates/tracedecay-sdk/tests/sdk_suite/production_daemon.rs +++ b/crates/tracedecay-sdk/tests/sdk_suite/production_daemon.rs @@ -3,7 +3,7 @@ use std::fs; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::{Child, Command, Stdio}; use std::sync::Arc; use std::thread; @@ -578,34 +578,8 @@ fn assert_workflow_get_definition_route_conceals_missing_definition(client: &Cli ); } -fn production_binary() -> PathBuf { - let path = std::env::var_os("TRACEDECAY_TEST_BIN") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("../../target/debug/tracedecay")); - fs::canonicalize(&path) - .unwrap_or_else(|error| panic!("missing production daemon {}: {error}", path.display())) -} - -fn isolated(command: &mut Command, home: &Path, profile: &Path) { - command - .env("HOME", home) - .env("USERPROFILE", home) - .env("XDG_CONFIG_HOME", home.join(".config")) - .env("TRACEDECAY_DATA_DIR", profile) - .env("TRACEDECAY_GLOBAL_DB", profile.join("global.db")) - .env("TRACEDECAY_TEST_ALLOW_INCOMPLETE_HOLDER_SCAN", "1"); -} - -fn run(command: &mut Command) -> Vec { - let output = command.output().unwrap(); - assert!( - output.status.success(), - "command failed: {}\n{}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); - output.stdout -} +use crate::isolated_profile::apply_isolated_profile_env as isolated; +use crate::{production_binary, run}; fn wait_for_authority(child: &mut Child, path: &Path) -> Value { wait_for_authority_record(child, path, false) diff --git a/crates/tracedecay-sdk/tests/sdk_suite/semantic_replay.rs b/crates/tracedecay-sdk/tests/sdk_suite/semantic_replay.rs index e073ce9bfb..246b922b28 100644 --- a/crates/tracedecay-sdk/tests/sdk_suite/semantic_replay.rs +++ b/crates/tracedecay-sdk/tests/sdk_suite/semantic_replay.rs @@ -1,7 +1,7 @@ #![cfg(unix)] use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::{Child, Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -343,34 +343,8 @@ fn application_run_record_count(profile: &Path, run_id: &str) -> usize { .count() } -fn production_binary() -> PathBuf { - let path = std::env::var_os("TRACEDECAY_TEST_BIN") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("../../target/debug/tracedecay")); - fs::canonicalize(&path) - .unwrap_or_else(|error| panic!("missing production daemon {}: {error}", path.display())) -} - -fn isolated(command: &mut Command, home: &Path, profile: &Path) { - command - .env("HOME", home) - .env("USERPROFILE", home) - .env("XDG_CONFIG_HOME", home.join(".config")) - .env("TRACEDECAY_DATA_DIR", profile) - .env("TRACEDECAY_GLOBAL_DB", profile.join("global.db")) - .env("TRACEDECAY_TEST_ALLOW_INCOMPLETE_HOLDER_SCAN", "1"); -} - -fn run(command: &mut Command) -> Vec { - let output = command.output().expect("run subprocess"); - assert!( - output.status.success(), - "command failed: {}\n{}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); - output.stdout -} +use crate::isolated_profile::apply_isolated_profile_env as isolated; +use crate::{production_binary, run}; fn wait_for_authority(child: &mut Child, path: &Path, prior_epoch: Option) -> Value { let deadline = Instant::now() + Duration::from_secs(15); diff --git a/crates/tracedecay-search-eval/src/controlled_workloads.rs b/crates/tracedecay-search-eval/src/controlled_workloads.rs index cbfebe4dbd..da48571084 100644 --- a/crates/tracedecay-search-eval/src/controlled_workloads.rs +++ b/crates/tracedecay-search-eval/src/controlled_workloads.rs @@ -244,40 +244,58 @@ pub fn run_cursor_parse_batch_workload() } fn compose_cursor_batch(records: &[Vec]) -> std::io::Result<(u64, Option)> { - let mut offset = 0_u64; - let mut digests = Sha256::new(); - let mut bytes = 0_u64; - for record in records { - let end = offset.saturating_add(record.len() as u64); - let range = ObservationSourceRangeV1::new(offset, end).map_err(|error| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, error.to_string()) - })?; - let parsed = parse_normalized_observation_record_v1( - record, - range, - ObservationOrderingDomainV1::FileBytes, - |native| { - let record_id = - cursor::observation_native_record_id("cursor", "cursor-eval-session", &native)?; - cursor::normalize_cursor_observation( - &native, - "cursor-eval-session", - record_id, - range, - None, - None, - ) - }, - ) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()))?; - digests.update(parsed.raw_digest()); - bytes = bytes.saturating_add(record.len() as u64); - offset = end; - } - Ok((bytes, Some(hex::encode(digests.finalize())))) + digest_normalized_records( + records, + ObservationOrderingDomainV1::FileBytes, + |_index, range, native| { + let record_id = + cursor::observation_native_record_id("cursor", "cursor-eval-session", &native)?; + cursor::normalize_cursor_observation( + &native, + "cursor-eval-session", + record_id, + range, + None, + None, + ) + }, + ) } fn compose_composer_batch(records: &[Vec]) -> std::io::Result<(u64, Option)> { + digest_normalized_records( + records, + ObservationOrderingDomainV1::SnapshotOrder, + |index, range, native| { + let position = index as u64 + 1; + let record_id = cursor_composer::cursor_composer_native_record_id( + "comp-eval", + &format!("b-{position}"), + ) + .map_err(|_| tracedecay_capture::ObservationRecordParseErrorV1::NormalizationFailed)?; + cursor_composer::normalize_cursor_composer_observation( + &native, + "comp-eval", + record_id, + range, + position, + ) + }, + ) +} + +fn digest_normalized_records( + records: &[Vec], + ordering: ObservationOrderingDomainV1, + mut normalize: impl FnMut( + usize, + ObservationSourceRangeV1, + serde_json::Value, + ) -> Result< + tracedecay_domain::CanonicalObservationEnvelopeV1, + tracedecay_capture::ObservationRecordParseErrorV1, + >, +) -> std::io::Result<(u64, Option)> { let mut offset = 0_u64; let mut digests = Sha256::new(); let mut bytes = 0_u64; @@ -286,28 +304,9 @@ fn compose_composer_batch(records: &[Vec]) -> std::io::Result<(u64, Option FactStoreError { - match error { - HolographicEncodingError::DimensionMismatch { expected, actual } => { - FactStoreError::HolographicDimensionMismatch { expected, actual } - } - } -} - fn classification_similarity( encoder: &HolographicEncoder, proposed_tokens: &[String], @@ -51,10 +43,10 @@ fn classification_similarity( let candidate_tokens = project_memory_tokens(candidate.content()); let mut similarity = project_memory_jaccard(proposed_tokens, &candidate_tokens); let candidate_vector = - project_memory_fact_vector(encoder, candidate).map_err(holographic_store_error)?; + project_memory_fact_vector(encoder, candidate).map_err(project_memory_holographic_error)?; let holographic = encoder .similarity(proposed_vector, &candidate_vector) - .map_err(holographic_store_error)?; + .map_err(project_memory_holographic_error)?; if holographic >= 0.85 && holographic > similarity { similarity = holographic; } @@ -156,7 +148,7 @@ pub(super) async fn classify_project_memory_add_tx( let proposed_tokens = project_memory_tokens(content); let proposed_vector = encoder .encode_fact(content, entities) - .map_err(holographic_store_error)?; + .map_err(project_memory_holographic_error)?; let mut closest: Option<(&ProjectMemoryFactV1, u32)> = None; for candidate in &candidates { let similarity = diff --git a/crates/tracedecay-session-memory/src/fact_store/primitives.rs b/crates/tracedecay-session-memory/src/fact_store/primitives.rs index 463653efa8..0ce5f591f3 100644 --- a/crates/tracedecay-session-memory/src/fact_store/primitives.rs +++ b/crates/tracedecay-session-memory/src/fact_store/primitives.rs @@ -4,7 +4,9 @@ use std::error::Error; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Serialize, de::DeserializeOwned}; -use tracedecay_runtime_core::db::DatabaseMemoryTransaction as Transaction; +use tracedecay_runtime_core::db::{ + DatabaseMemoryTransaction as Transaction, decode_stored_json, encode_stored_json, +}; use tracedecay_domain::{FactCategoryV1, FactOwnerV1, PayloadAccessState, UtcMicros}; use tracedecay_store::{FactReadControl, FactStoreError, FactStoreResult}; @@ -114,14 +116,14 @@ pub(super) fn to_json( value: &T, operation: &'static str, ) -> FactStoreResult { - serde_json::to_string(value).map_err(|error| storage_error(operation, error)) + encode_stored_json(value).map_err(|error| storage_error(operation, error)) } pub(super) fn from_json( value: &str, operation: &'static str, ) -> FactStoreResult { - serde_json::from_str(value).map_err(|error| storage_error(operation, error)) + decode_stored_json(value).map_err(|error| storage_error(operation, error)) } pub(super) fn row_string( diff --git a/crates/tracedecay-session-memory/src/memory/curation.rs b/crates/tracedecay-session-memory/src/memory/curation.rs index 4c4f4a1d07..6334df418d 100644 --- a/crates/tracedecay-session-memory/src/memory/curation.rs +++ b/crates/tracedecay-session-memory/src/memory/curation.rs @@ -473,22 +473,6 @@ impl MemoryApplication { .map_err(MemoryApplicationError::from) } - /// Constructs an owner-bound update command from the canonical store patch. - pub fn canonical_fact_update_command( - &self, - target: ProjectMemoryFactMutationTarget, - patch: ProjectMemoryFactUpdatePatchV1, - context: &MemoryOperationContext, - ) -> Result { - update_command( - &self.owner, - target, - patch, - context.operation_id().clone(), - context.actor().cloned(), - ) - } - /// Constructs an owner-bound compare-and-set remove command. pub fn canonical_fact_remove_command( &self, diff --git a/crates/tracedecay-session-memory/src/memory/tests.rs b/crates/tracedecay-session-memory/src/memory/tests.rs index 98f5395c88..9c4547cc74 100644 --- a/crates/tracedecay-session-memory/src/memory/tests.rs +++ b/crates/tracedecay-session-memory/src/memory/tests.rs @@ -564,12 +564,7 @@ fn owner() -> FactOwnerV1 { } } -fn id(value: &str) -> T -where - T: TryFrom, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn fact_id(owner: FactOwnerV1, operation: &str) -> FactId { FactId::derive( diff --git a/crates/tracedecay-session-memory/src/memory/trust.rs b/crates/tracedecay-session-memory/src/memory/trust.rs index 0930dc15ee..110b5dc47f 100644 --- a/crates/tracedecay-session-memory/src/memory/trust.rs +++ b/crates/tracedecay-session-memory/src/memory/trust.rs @@ -4,26 +4,8 @@ pub const TRUST_MIN: f64 = 0.0; pub const TRUST_MAX: f64 = 1.0; pub const DEFAULT_TRUST: f64 = 0.5; pub const DEFAULT_MIN_TRUST: f64 = 0.3; -/// Lower bound of the "high" bucket in [`trust_bucket`]; scores in -/// `[DEFAULT_MIN_TRUST, HIGH_TRUST_THRESHOLD)` are "medium". -pub(crate) const HIGH_TRUST_THRESHOLD: f64 = 0.75; /// Representative score for a "low" trust label, inside the low bucket. pub const LOW_TRUST_REPRESENTATIVE: f64 = 0.15; /// Representative score for a "high" trust label, inside the high bucket. /// `DEFAULT_TRUST` is the representative for "medium". pub const HIGH_TRUST_REPRESENTATIVE: f64 = 0.85; - -pub fn clamp_trust(score: f64) -> f64 { - score.clamp(TRUST_MIN, TRUST_MAX) -} - -pub fn trust_bucket(score: f64) -> &'static str { - let clamped = clamp_trust(score); - if clamped < DEFAULT_MIN_TRUST { - "low" - } else if clamped < HIGH_TRUST_THRESHOLD { - "medium" - } else { - "high" - } -} diff --git a/crates/tracedecay-session-memory/src/monitor_ring.rs b/crates/tracedecay-session-memory/src/monitor_ring.rs index 0e831f3b62..dd256c168c 100644 --- a/crates/tracedecay-session-memory/src/monitor_ring.rs +++ b/crates/tracedecay-session-memory/src/monitor_ring.rs @@ -143,10 +143,7 @@ fn write_entry_inner( mmap[off + EOFF_DELTA..off + EOFF_DELTA + 8].copy_from_slice(&delta.to_le_bytes()); mmap[off + EOFF_BEFORE..off + EOFF_BEFORE + 8].copy_from_slice(&before.to_le_bytes()); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + let timestamp = tracedecay_runtime_core::tracedecay::unix_secs(); mmap[off + EOFF_TIMESTAMP..off + EOFF_TIMESTAMP + 8].copy_from_slice(×tamp.to_le_bytes()); // Increment write_idx (reader sees this last). diff --git a/crates/tracedecay-session-memory/src/session/lcm/mod.rs b/crates/tracedecay-session-memory/src/session/lcm/mod.rs index 83193cbba4..bf732b13f8 100644 --- a/crates/tracedecay-session-memory/src/session/lcm/mod.rs +++ b/crates/tracedecay-session-memory/src/session/lcm/mod.rs @@ -1,16 +1,10 @@ -//! Application ownership of the DB-free LCM compatibility surface. +//! Application-owned LCM authority boundary. //! -//! `contracts` holds the retrieval value types and their containment rules; -//! `compression_policy` holds provider-neutral token, overflow, and atomic -//! chunk-selection rules; `render` holds the truncation and typed-omission -//! shaping applied after canonical hydration. The LCM engine crate owns the -//! contract and policy surfaces, and the registered temporal adapters depend -//! on them directly, so neither side has to reach through the other. +//! Retrieval contracts and compression policy live in `tracedecay-lcm`. +//! Hydration shaping lives in `tracedecay-session-temporal-store`. Neither +//! surface is re-exported here; callers already depend on those crates. pub mod authority; -pub mod render; -pub use tracedecay_lcm::compression_policy; -pub use tracedecay_lcm::contracts; pub use authority::{ LcmAuthorityFuture, LcmAuthorityInvocation, LcmAuthorityOperation, LcmAuthorityOutcome, @@ -19,11 +13,3 @@ pub use authority::{ LcmCompressionEvidence, LcmDoctorQuery, LcmHostProtocol, LcmStatusQuery, LcmTranscriptIngestCommand, lcm_authority_operation_identity, }; -pub use contracts::{ - LcmContentRange, LcmContentSlice, LcmDescribeExternalPayload, LcmDescribeRequest, - LcmDescribeResponse, LcmDescribeSourceOverview, LcmDescribeSummaryNode, LcmDescribeTarget, - LcmError, LcmExpandRequest, LcmExpandResponse, LcmExpandSourcePagination, LcmExpandTarget, - LcmExpandedSummarySource, LcmPayloadExpansion, LcmPayloadRef, LcmRawMessage, - LcmRawMessageOverview, LcmSourceRef, LcmStorageKind, LcmSummaryNode, LcmSummaryNodeOverview, - validate_payload_ref, -}; diff --git a/crates/tracedecay-session-memory/src/session/lcm/render.rs b/crates/tracedecay-session-memory/src/session/lcm/render.rs deleted file mode 100644 index e05bcc7bc2..0000000000 --- a/crates/tracedecay-session-memory/src/session/lcm/render.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Compatibility re-exports for global-DB-owned LCM hydration shaping. - -pub use tracedecay_session_temporal_store::render::*; diff --git a/crates/tracedecay-session-memory/src/session/mod.rs b/crates/tracedecay-session-memory/src/session/mod.rs index 48d9e9835d..3829067929 100644 --- a/crates/tracedecay-session-memory/src/session/mod.rs +++ b/crates/tracedecay-session-memory/src/session/mod.rs @@ -1,6 +1,5 @@ mod hotpath_observe; pub mod lcm; -mod ports; mod refresh; mod refresh_service; mod retrieval; @@ -8,10 +7,6 @@ mod retrieval; mod tests; mod types; -pub use ports::{ - AuthorizedTemporalExecutionRequest, SessionTemporalExecutionError, - SessionTemporalExecutionPort, SessionTemporalExecutionReport, TemporalExecutionFuture, -}; pub use refresh::{ SessionRefreshConfiguration, SessionRefreshDigest, SessionRefreshHandle, SessionRefreshOutcome, SessionRefreshRequestError, SessionRefreshSchedulerError, SessionRefreshService, @@ -31,6 +26,10 @@ pub use tracedecay_contracts::retrieval::{ SessionRetrievalBudgetAccountingV1, SessionRetrievalBudgetObservationV1, SessionRetrievalBudgetStageV1, }; +pub use tracedecay_session_temporal_store::execution::{ + AuthorizedTemporalExecutionRequest, SessionTemporalExecutionError, + SessionTemporalExecutionPort, SessionTemporalExecutionReport, TemporalExecutionFuture, +}; pub use types::{ AuthorizationGrantId, AuthorizedSessionScope, SessionAccess, SessionAuthorizationError, SessionAuthorizationGrant, SessionDataFreshness, SessionFreshnessPolicy, SessionRequestBinding, diff --git a/crates/tracedecay-session-memory/src/session/ports.rs b/crates/tracedecay-session-memory/src/session/ports.rs deleted file mode 100644 index d6430f8457..0000000000 --- a/crates/tracedecay-session-memory/src/session/ports.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Authorized temporal execution contract. -//! -//! The registered global database owns both the only production executor and -//! this contract. Re-exporting that authority keeps retrieval callers and the -//! executor on one type identity instead of maintaining a second, structurally -//! identical port inside the use-case crate. - -pub use tracedecay_session_temporal_store::execution::{ - AuthorizedTemporalExecutionRequest, SessionTemporalExecutionError, - SessionTemporalExecutionPort, SessionTemporalExecutionReport, TemporalExecutionFuture, -}; diff --git a/crates/tracedecay-session-memory/src/session/retrieval.rs b/crates/tracedecay-session-memory/src/session/retrieval.rs index da4653155a..4dbafefc4f 100644 --- a/crates/tracedecay-session-memory/src/session/retrieval.rs +++ b/crates/tracedecay-session-memory/src/session/retrieval.rs @@ -27,14 +27,15 @@ use crate::context::{ application_observed_at, application_request_interruption, run_application_request_interruptible, }; -use crate::session::ports::{ - AuthorizedTemporalExecutionRequest, SessionTemporalExecutionError, SessionTemporalExecutionPort, -}; use crate::session::types::{ SessionAccess, SessionAuthorizationError, SessionDataFreshness, SessionFreshnessPolicy, SessionRequestBinding, SessionRetrievalOutcome, SessionRetrievalScope, SessionScopeAuthorizationRequest, SessionScopeAuthorizer, }; +use tracedecay_session_temporal_store::execution::{ + AuthorizedTemporalExecutionRequest, SessionTemporalExecutionError, + SessionTemporalExecutionPort, SessionTemporalExecutionReport, +}; mod task_session; pub use task_session::TaskSessionRetrievalOutcomeV1; @@ -425,7 +426,7 @@ fn temporal_authorized_root( } fn map_report( - report: crate::session::ports::SessionTemporalExecutionReport, + report: SessionTemporalExecutionReport, freshness_policy: SessionFreshnessPolicy, ) -> SessionRetrievalOutcome { let (result, freshness) = report.into_parts(); diff --git a/crates/tracedecay-session-runtime/src/retained.rs b/crates/tracedecay-session-runtime/src/retained.rs index a87d42d3b3..9abf1c4d51 100644 --- a/crates/tracedecay-session-runtime/src/retained.rs +++ b/crates/tracedecay-session-runtime/src/retained.rs @@ -17,6 +17,7 @@ pub mod lcm; pub mod profile; pub mod session; pub mod session_refresh; +mod wire; pub use lcm::DirectRetainedLcmPortV1; pub use profile::{ diff --git a/crates/tracedecay-session-runtime/src/retained/lcm.rs b/crates/tracedecay-session-runtime/src/retained/lcm.rs index 8a38681dd8..e94ac9eb6f 100644 --- a/crates/tracedecay-session-runtime/src/retained/lcm.rs +++ b/crates/tracedecay-session-runtime/src/retained/lcm.rs @@ -8,7 +8,7 @@ use tracedecay_contracts::retained_surfaces::{ LcmDoctorHealthV1, LcmDoctorProjectionStateV1, LcmDoctorProjectionV1, LcmDoctorRequestV1, LcmDoctorResultV1, LcmExpandQueryRequestV1, LcmExpandRequestV1, LcmGrepRequestV1, LcmLifecycleStatusV1, LcmLoadSessionRequestV1, LcmPayloadCoverageStateV1, LcmPayloadCoverageV1, - LcmPayloadGcStatusV1, LcmPayloadStatusV1, LcmRedactionStatusV1, LcmRoleV1, LcmStatusRequestV1, + LcmPayloadGcStatusV1, LcmPayloadStatusV1, LcmRedactionStatusV1, LcmStatusRequestV1, LcmStatusResultV1, LcmStatusV1, LcmStoreStatusV1, LcmStoreTokenCoverageV1, LcmTemporalModeV1, MessageRelationshipScopeV1, MessageTypeFilterV1, RetainedOutcomeStatusV1, RetainedSurfaceOperation, RetainedSurfaceResultV1, RetainedTimeFilterV1, @@ -998,29 +998,11 @@ pub(super) fn temporal_mode( } pub(super) fn relationship_scope(value: Option) -> SessionSearchScope { - match value.unwrap_or(MessageRelationshipScopeV1::All) { - MessageRelationshipScopeV1::All => SessionSearchScope::All, - MessageRelationshipScopeV1::ParentsOnly => SessionSearchScope::ParentsOnly, - MessageRelationshipScopeV1::SubagentsOnly => SessionSearchScope::SubagentsOnly, - } + SessionSearchScope::from(value.unwrap_or(MessageRelationshipScopeV1::All)) } pub(super) fn message_type(value: Option) -> SessionMessageType { - match value.unwrap_or(MessageTypeFilterV1::All) { - MessageTypeFilterV1::All => SessionMessageType::All, - MessageTypeFilterV1::DirectUser => SessionMessageType::DirectUser, - MessageTypeFilterV1::ToolResult => SessionMessageType::ToolResult, - } -} - -pub(super) const fn role_name(value: LcmRoleV1) -> &'static str { - match value { - LcmRoleV1::System => "system", - LcmRoleV1::User => "user", - LcmRoleV1::Assistant => "assistant", - LcmRoleV1::Tool => "tool", - LcmRoleV1::Unknown => "unknown", - } + SessionMessageType::from(value.unwrap_or(MessageTypeFilterV1::All)) } pub(super) fn time_filter( diff --git a/crates/tracedecay-session-runtime/src/retained/lcm/output.rs b/crates/tracedecay-session-runtime/src/retained/lcm/output.rs index 975bfeb0ee..9c571b80d2 100644 --- a/crates/tracedecay-session-runtime/src/retained/lcm/output.rs +++ b/crates/tracedecay-session-runtime/src/retained/lcm/output.rs @@ -2,23 +2,17 @@ use tracedecay_contracts::RetainedSurfaceExecutionErrorV1; use tracedecay_contracts::retained_surfaces::{ - ClosedUtcIntervalV1, CompactLineageEdgeV1, HydrationStateResultV1, LcmContentRangeV1, - LcmDescribeExternalPayloadV1, LcmDescribeSourceOverviewV1, LcmDescribeSummaryNodeV1, - LcmDescriptionV1, LcmExpandQueryBudgetV1, LcmExpandQueryContextBlockV1, LcmExpandQueryMatchV1, + CompactLineageEdgeV1, HydrationStateResultV1, LcmContentRangeV1, LcmDescribeExternalPayloadV1, + LcmDescribeSourceOverviewV1, LcmDescribeSummaryNodeV1, LcmDescriptionV1, + LcmExpandQueryBudgetV1, LcmExpandQueryContextBlockV1, LcmExpandQueryMatchV1, LcmExpandQueryPaginationV1, LcmExpandQueryResultV1, LcmExpandQuerySynthesisPromptV1, LcmExpandedSourceV1, LcmExpansionV1, LcmGrepHitV1, LcmMessageV1, LcmRawMessageMetadataV1, LcmRawMessageOverviewV1, LcmRawMessageV1, LcmRetrievalOutcomeV1, LcmSourcePaginationV1, LcmSourceRefV1, LcmStorageKindV1, LcmSummaryNodeOverviewV1, LcmSummaryNodeV1, - LcmTemporalFieldsV1, RetainedOutcomeStatusV1, SessionCoverageIntervalV1, SessionCoverageModeV1, - SessionCoverageReasonV1, SessionCoverageRequestV1, SessionCoverageStateV1, - SessionSourceCoverageV1 as RetainedSourceCoverageV1, TemporalExplanationV1, - TemporalFreshnessV1, TemporalOmissionV1, TemporalWatermarksV1, ValidCoverageIntervalV1, -}; -use tracedecay_domain::{ - CompactContextLineageEdgeV1, HydrationStateV1, SessionSourceCoverageIntervalV1, - SessionSourceCoverageReasonV1, SessionSourceCoverageStateV1, SessionSourceCoverageV1, - TemporalModeV1, ValidCoverageIntervalV1 as DomainValidCoverageIntervalV1, + LcmTemporalFieldsV1, RetainedOutcomeStatusV1, TemporalExplanationV1, TemporalFreshnessV1, + TemporalOmissionV1, }; +use tracedecay_domain::CompactContextLineageEdgeV1; use tracedecay_lcm::contracts::{ LcmContentRange, LcmDataFreshness, LcmDescribeResponse, LcmExpandResponse, LcmRawMessage, LcmRawMessageMetadata, LcmRetrievalOutcome, LcmSourceRef, LcmStorageKind, LcmSummaryNode, @@ -33,6 +27,8 @@ use tracedecay_temporal_query::context::OrderedTextContextAssembler; use crate::session_retrieval::SessionTemporalMetadataView; +use super::super::wire::{coverage, source_coverage, temporal_watermarks}; + #[hotpath::measure(label = "daemon.retained.lcm.hydrate_temporal")] pub(super) fn temporal_fields(value: SessionTemporalMetadataView) -> LcmTemporalFieldsV1 { LcmTemporalFieldsV1 { @@ -41,20 +37,9 @@ pub(super) fn temporal_fields(value: SessionTemporalMetadataView) -> LcmTemporal .into_iter() .map(|anchor| anchor.as_str().to_owned()) .collect(), - watermarks: TemporalWatermarksV1 { - generation: value.watermarks.generation, - source: value.watermarks.source, - projection: value.watermarks.projection, - index: value.watermarks.index, - summary: value.watermarks.summary, - }, + watermarks: temporal_watermarks(value.watermarks), authorized_root: value.authorized_root, - coverage: tracedecay_contracts::retained_surfaces::TemporalCoverageV1 { - visible: value.coverage.visible, - hidden: value.coverage.hidden, - unknown: value.coverage.unknown, - redacted: value.coverage.redacted, - }, + coverage: coverage(value.coverage), source_coverage: value .source_coverage .into_iter() @@ -74,106 +59,13 @@ pub(super) fn temporal_fields(value: SessionTemporalMetadataView) -> LcmTemporal .map(|item| TemporalOmissionV1 { rank: item.rank, anchor: item.anchor.as_str().to_owned(), - reason: hydration(item.reason), + reason: HydrationStateResultV1::from(item.reason), }) .collect(), next_cursor: value.cursor, } } -fn source_coverage(value: SessionSourceCoverageV1) -> RetainedSourceCoverageV1 { - RetainedSourceCoverageV1 { - source_id: value.source_id().as_str().to_owned(), - observed_frontier: value.observed_frontier().value(), - committed_frontier: value.committed_frontier().value(), - target_watermark: value.target_watermark().value(), - request: SessionCoverageRequestV1 { - mode: coverage_mode(value.request().mode()), - }, - covered_intervals: value - .covered_intervals() - .iter() - .cloned() - .map(coverage_interval) - .collect(), - missing_intervals: value - .missing_intervals() - .iter() - .cloned() - .map(coverage_interval) - .collect(), - state: coverage_state(value.state()), - reason: coverage_reason(value.reason()), - } -} - -fn coverage_interval(value: SessionSourceCoverageIntervalV1) -> SessionCoverageIntervalV1 { - SessionCoverageIntervalV1 { - knowledge: closed_interval(value.knowledge), - valid: match value.valid { - DomainValidCoverageIntervalV1::Known(interval) => { - ValidCoverageIntervalV1::Known(closed_interval(interval)) - } - DomainValidCoverageIntervalV1::Unknown => ValidCoverageIntervalV1::Unknown, - }, - } -} - -fn closed_interval(value: tracedecay_domain::ClosedUtcIntervalV1) -> ClosedUtcIntervalV1 { - ClosedUtcIntervalV1 { - from_inclusive: value.from_inclusive().map(|value| value.0), - through_inclusive: value.through_inclusive().map(|value| value.0), - } -} - -const fn coverage_mode(value: TemporalModeV1) -> SessionCoverageModeV1 { - match value { - TemporalModeV1::Current => SessionCoverageModeV1::Current, - TemporalModeV1::AsOf { cutoff } => SessionCoverageModeV1::AsOf { cutoff: cutoff.0 }, - TemporalModeV1::Evolution => SessionCoverageModeV1::Evolution, - TemporalModeV1::Forensic => SessionCoverageModeV1::Forensic, - } -} - -const fn coverage_state(value: SessionSourceCoverageStateV1) -> SessionCoverageStateV1 { - match value { - SessionSourceCoverageStateV1::Fresh => SessionCoverageStateV1::Fresh, - SessionSourceCoverageStateV1::Stale => SessionCoverageStateV1::Stale, - SessionSourceCoverageStateV1::Partial => SessionCoverageStateV1::Partial, - SessionSourceCoverageStateV1::Locked => SessionCoverageStateV1::Locked, - SessionSourceCoverageStateV1::Redacted => SessionCoverageStateV1::Redacted, - SessionSourceCoverageStateV1::RetentionWithheld => { - SessionCoverageStateV1::RetentionWithheld - } - SessionSourceCoverageStateV1::Unavailable => SessionCoverageStateV1::Unavailable, - } -} - -fn coverage_reason(value: &SessionSourceCoverageReasonV1) -> SessionCoverageReasonV1 { - match value { - SessionSourceCoverageReasonV1::CaughtUp => SessionCoverageReasonV1::CaughtUp, - SessionSourceCoverageReasonV1::ProjectionBehindSource { lag } => { - SessionCoverageReasonV1::ProjectionBehindSource { lag: *lag } - } - SessionSourceCoverageReasonV1::SourceBehindTarget { lag } => { - SessionCoverageReasonV1::SourceBehindTarget { lag: *lag } - } - SessionSourceCoverageReasonV1::ProjectionAndSourceBehind { - projection_lag, - source_lag, - } => SessionCoverageReasonV1::ProjectionAndSourceBehind { - projection_lag: *projection_lag, - source_lag: *source_lag, - }, - SessionSourceCoverageReasonV1::Locked => SessionCoverageReasonV1::Locked, - SessionSourceCoverageReasonV1::Redacted => SessionCoverageReasonV1::Redacted, - SessionSourceCoverageReasonV1::RetentionWithheld => { - SessionCoverageReasonV1::RetentionWithheld - } - SessionSourceCoverageReasonV1::Unavailable => SessionCoverageReasonV1::Unavailable, - } -} - pub(super) fn sliced_message( result: SessionMessageSearchResult, slice: LcmContentSlice, @@ -362,7 +254,7 @@ pub(super) fn expansion(value: LcmExpandResponse) -> LcmExpansionV1 { .into_iter() .map(|source| LcmExpandedSourceV1 { source_ref: source_ref(source.source_ref), - state: hydration(source.state), + state: HydrationStateResultV1::from(source.state), content: source.content, content_range: source.content_range.map(content_range), content_truncated: source.content_truncated, @@ -454,7 +346,7 @@ pub(super) fn expand_query_result( kind: page.kind, node_id: page.node_id, source_ref: page.source_ref.map(source_ref), - state: page.state.map(hydration), + state: page.state.map(HydrationStateResultV1::from), next_content_offset: page.next_content_offset, has_more: page.has_more, }) @@ -595,19 +487,6 @@ fn rebuild_synthesis_user_prompt(result: &mut LcmExpandQueryResultV1) { synthesis.user = format!("QUESTION:\n{prompt}\n\nEXPANDED CONTEXT:\n{context}"); } -pub(super) const fn hydration(value: HydrationStateV1) -> HydrationStateResultV1 { - match value { - HydrationStateV1::Available => HydrationStateResultV1::Available, - HydrationStateV1::RetainedButUnavailable => HydrationStateResultV1::RetainedButUnavailable, - HydrationStateV1::Redacted => HydrationStateResultV1::Redacted, - HydrationStateV1::Deleted => HydrationStateResultV1::Deleted, - HydrationStateV1::RetentionExpired => HydrationStateResultV1::RetentionExpired, - HydrationStateV1::Unauthorized => HydrationStateResultV1::Unauthorized, - HydrationStateV1::Locked => HydrationStateResultV1::Locked, - HydrationStateV1::UnverifiableLegacy => HydrationStateResultV1::UnverifiableLegacy, - } -} - const fn storage_kind(value: LcmStorageKind) -> LcmStorageKindV1 { match value { LcmStorageKind::Inline => LcmStorageKindV1::Inline, diff --git a/crates/tracedecay-session-runtime/src/retained/lcm/retrieval.rs b/crates/tracedecay-session-runtime/src/retained/lcm/retrieval.rs index a9c557535f..7af90825ee 100644 --- a/crates/tracedecay-session-runtime/src/retained/lcm/retrieval.rs +++ b/crates/tracedecay-session-runtime/src/retained/lcm/retrieval.rs @@ -5,10 +5,11 @@ use std::collections::BTreeMap; use futures_util::stream::{self, StreamExt}; use tracedecay_contracts::retained_surfaces::{ - LcmDescribeRequestV1, LcmDescribeResultV1, LcmDescribeTargetV1, LcmExpandQueryRequestV1, - LcmExpandRequestV1, LcmExpandResultV1, LcmExpandTargetV1, LcmGrepRequestV1, LcmGrepResultV1, - LcmGrepSortV1, LcmLoadSessionRequestV1, LcmLoadSessionResultV1, LcmNodeIdV1, LcmSearchScopeV1, - RetainedOutcomeStatusV1, RetainedSurfaceOperation, RetainedSurfaceResultV1, + HydrationStateResultV1, LcmDescribeRequestV1, LcmDescribeResultV1, LcmDescribeTargetV1, + LcmExpandQueryRequestV1, LcmExpandRequestV1, LcmExpandResultV1, LcmExpandTargetV1, + LcmGrepRequestV1, LcmGrepResultV1, LcmGrepSortV1, LcmLoadSessionRequestV1, + LcmLoadSessionResultV1, LcmNodeIdV1, LcmSearchScopeV1, RetainedOutcomeStatusV1, + RetainedSurfaceOperation, RetainedSurfaceResultV1, }; use tracedecay_contracts::{ ApplicationOutcome, RetainedSurfaceExecutionContextV1, RetainedSurfaceExecutionErrorV1, @@ -29,7 +30,7 @@ use tracedecay_temporal_query::ranking::DiversityLimits; use super::output; use super::{ cursor, message_type, optional_provider, optional_usize, relationship_scope, required, - role_name, session_id, specific_provider, temporal_mode, time_filter, trimmed, unsigned_i64, + session_id, specific_provider, temporal_mode, time_filter, trimmed, unsigned_i64, }; use crate::retained::session_retrieval_unavailable_detail; use crate::session_retrieval::{ @@ -186,7 +187,7 @@ pub(super) async fn execute_grep( let message_type = message_type(request.message_type); let roles = request .role - .map(|role| vec![role_name(role).to_owned()]) + .map(|role| vec![role.as_str().to_owned()]) .unwrap_or_default(); let start = request.start_time.as_ref().or(request.since.as_ref()); let end = request.end_time.as_ref().or(request.until.as_ref()); @@ -357,7 +358,7 @@ pub(super) async fn execute_describe( provider: Some(provider.to_owned()), session_id: Some(session_id.as_str().to_owned()), grain: Some(grain.as_str().to_owned()), - state: Some(output::hydration(state)), + state: Some(HydrationStateResultV1::from(state)), lineage: Some(output::lineage(lineage)), retrieval: Some(output::retrieval(retrieval)), omitted: Some(retrieval.omitted()), @@ -379,7 +380,7 @@ pub(super) async fn execute_describe( provider: Some(provider.to_owned()), session_id: Some(session_id.as_str().to_owned()), grain: Some(grain.as_str().to_owned()), - state: state.map(output::hydration), + state: state.map(HydrationStateResultV1::from), lineage: Some(output::lineage(lineage)), retrieval: Some(output::retrieval(retrieval)), omitted: Some(retrieval.omitted()), @@ -908,7 +909,7 @@ fn expand_result( provider: Some(provider.to_owned()), session_id: Some(session_id.as_str().to_owned()), grain: grain.map(|value| value.as_str().to_owned()), - state: state.map(output::hydration), + state: state.map(HydrationStateResultV1::from), retrieval: Some(output::retrieval(retrieval)), omitted: Some(retrieval.omitted()), temporal: Some(output::temporal_fields(temporal)), diff --git a/crates/tracedecay-session-runtime/src/retained/profile.rs b/crates/tracedecay-session-runtime/src/retained/profile.rs index fe08cb1563..83bb9dfad6 100644 --- a/crates/tracedecay-session-runtime/src/retained/profile.rs +++ b/crates/tracedecay-session-runtime/src/retained/profile.rs @@ -387,7 +387,7 @@ mod tests { use tracedecay_domain::{ BrainId, CanonicalMessageRoleV1, CanonicalObservationEnvelopeV1, CanonicalObservationEvidenceV1, CanonicalObservationFactV1, - CanonicalObservationRelationsV1, DurableObservationV1, ManifestDigest, ObservationId, + CanonicalObservationRelationsV1, DurableObservationV1, ObservationId, ObservationIdentityMaterialV1, ObservationOrderingDomainV1, ObservationScopeV1, ObservationSourceCursorV1, ObservationSourceGenerationV1, ObservationSourceIdentityV1, ObservationSourceRangeV1, PayloadReferenceV1, ProjectId, ProjectionGenerationId, @@ -429,10 +429,7 @@ mod tests { DaemonSessionRetrievalRoot::profile(serving).expect("profile retrieval root") } - fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("manifest digest") - } + use tracedecay_domain::test_fixtures::digest; fn session(provider: &str, session_id: &str, project_key: &str) -> SessionRecord { SessionRecord { diff --git a/crates/tracedecay-session-runtime/src/retained/session.rs b/crates/tracedecay-session-runtime/src/retained/session.rs index 5ce05e0b45..0ce0a8dad4 100644 --- a/crates/tracedecay-session-runtime/src/retained/session.rs +++ b/crates/tracedecay-session-runtime/src/retained/session.rs @@ -3,16 +3,12 @@ use std::sync::Arc; use std::time::Duration; use tracedecay_contracts::retained_surfaces::{ - ClosedUtcIntervalV1, GitScopeV1, HydrationStateResultV1, MessageRelationshipScopeV1, - MessageSearchHitV1, MessageSearchRequestV1, MessageSearchResultV1, MessageTypeFilterV1, - RetainedOutcomeStatusV1, RetainedSurfaceOperation, RetainedSurfaceResultV1, - SessionCoverageIntervalV1, SessionCoverageModeV1, SessionCoverageReasonV1, - SessionCoverageRequestV1, SessionCoverageStateV1, SessionMessageV1, SessionRecordV1, - SessionRefreshRequestV1, SessionRefreshScopeV1, - SessionSourceCoverageV1 as WireSourceCoverageV1, SessionsForRequestV1, - TemporalCoverageOmissionV1, TemporalCoverageV1, TemporalExplanationV1, TemporalFreshnessV1, - TemporalMetadataV1, TemporalOmissionV1, TemporalPopulationCountV1, TemporalWatermarksV1, - ValidCoverageIntervalV1, WorkflowsRequestV1, + GitScopeV1, HydrationStateResultV1, MessageSearchHitV1, MessageSearchRequestV1, + MessageSearchResultV1, RetainedOutcomeStatusV1, RetainedSurfaceOperation, + RetainedSurfaceResultV1, SessionMessageV1, SessionRecordV1, SessionRefreshRequestV1, + SessionRefreshScopeV1, SessionsForRequestV1, TemporalCoverageOmissionV1, TemporalExplanationV1, + TemporalFreshnessV1, TemporalMetadataV1, TemporalOmissionV1, TemporalPopulationCountV1, + WorkflowsRequestV1, }; use tracedecay_contracts::{ ApplicationOutcome, RequestAdmission, RetainedSessionExecutionPortV1, RetainedSessionRequestV1, @@ -20,10 +16,8 @@ use tracedecay_contracts::{ RetainedSurfaceExecutionFutureV1, now_micros, }; use tracedecay_domain::{ - HydrationStateV1, ManifestDigest, ProjectId, RetrievalGrainV1, SessionId, - SessionSourceCoverageIntervalV1, SessionSourceCoverageReasonV1, SessionSourceCoverageStateV1, - SessionSourceCoverageV1, TemporalCoverageCountsV1, TemporalModeV1, UserProfileId, - ValidCoverageIntervalV1 as DomainValidCoverageIntervalV1, canonical_sha256, + ManifestDigest, ProjectId, RetrievalGrainV1, SessionId, TemporalModeV1, UserProfileId, + canonical_sha256, }; use tracedecay_session_memory::context::{ResolvedSessionIdentity, SessionRootId, SessionStoreId}; use tracedecay_session_memory::session::{ @@ -37,8 +31,7 @@ use tracedecay_sessions::runtime::{ }; use tracedecay_temporal_query::context::ContextBudget; use tracedecay_temporal_query::ports::{ - TemporalCandidateFilterV1, TemporalCandidatePopulationCount, TemporalMessageTypeFilterV1, - TemporalSessionScopeFilterV1, + TemporalCandidateFilterV1, TemporalCandidatePopulationCount, }; use tracedecay_temporal_query::ranking::DiversityLimits; @@ -49,7 +42,8 @@ use super::session_refresh::{ use crate::session_retrieval::{ DaemonSessionRetrievalService, SessionApplicationRetrievalPortV1, SessionRetrievalCoverageOmissionView, SessionRetrievalPageView, SessionRetrievalServiceOutcome, - SessionRetrievalStoreScope, SessionTemporalMetadataView, + SessionRetrievalStoreScope, SessionTemporalMetadataView, temporal_message_type, + temporal_session_scope, }; use tracedecay_contracts::retained_receipts::{evidence_outcome, session_refresh_effect_outcome}; use tracedecay_domain::errors::TraceDecayError; @@ -445,6 +439,30 @@ impl RetainedSessionExecutionPortV1 for DirectRetainedSessionPortV1<'_> { } } +/// The provider parser names the offending value and the accepted set; keep +/// that corrective diagnostic in the refusal instead of collapsing it to the +/// generic invalid-request problem. A value the sanitized diagnostic cannot +/// carry (oversized or control characters) still refuses with the generic +/// problem. +fn invalid_provider(error: String) -> RetainedSurfaceExecutionErrorV1 { + tracedecay_contracts::SafeDiagnostic::new( + "application.retained.message-search-provider-invalid", + error, + ) + .map_or( + RetainedSurfaceExecutionErrorV1::InvalidRequest, + |diagnostic| { + RetainedSurfaceExecutionErrorV1::ApplicationProblem( + tracedecay_contracts::ApplicationProblem::InvalidRequest { + diagnostic, + retry: tracedecay_contracts::RetryDirective::Never, + legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], + }, + ) + }, + ) +} + struct MessageSearchInput { query: String, goals: bool, @@ -473,49 +491,17 @@ impl MessageSearchInput { None if goals => String::new(), None => return Err(RetainedSurfaceExecutionErrorV1::InvalidRequest), }; - // The provider parser names the offending value and the accepted set; - // keep that corrective diagnostic in the refusal instead of collapsing - // it to the generic invalid-request problem. A value the sanitized - // diagnostic cannot carry (oversized or control characters) still - // refuses with the generic problem. let provider = - ProviderScope::parse_optional(request.provider.as_deref()).map_err(|error| { - tracedecay_contracts::SafeDiagnostic::new( - "application.retained.message-search-provider-invalid", - error.clone(), - ) - .map_or( - RetainedSurfaceExecutionErrorV1::InvalidRequest, - |diagnostic| { - RetainedSurfaceExecutionErrorV1::ApplicationProblem( - tracedecay_contracts::ApplicationProblem::InvalidRequest { - diagnostic, - retry: tracedecay_contracts::RetryDirective::Never, - legal_actions: vec![ - tracedecay_contracts::LegalAction::CorrectRequest, - ], - }, - ) - }, - ) - })?; + ProviderScope::parse_optional(request.provider.as_deref()).map_err(invalid_provider)?; let include_subagents = request.include_subagents.unwrap_or(true); - let mut scope = match request.scope.unwrap_or(MessageRelationshipScopeV1::All) { - MessageRelationshipScopeV1::All => SessionSearchScope::All, - MessageRelationshipScopeV1::ParentsOnly => SessionSearchScope::ParentsOnly, - MessageRelationshipScopeV1::SubagentsOnly => SessionSearchScope::SubagentsOnly, - }; + let mut scope = super::lcm::relationship_scope(request.scope); if !include_subagents && scope == SessionSearchScope::SubagentsOnly { return Err(RetainedSurfaceExecutionErrorV1::InvalidRequest); } if !include_subagents && scope == SessionSearchScope::All { scope = SessionSearchScope::ParentsOnly; } - let message_type = match request.message_type.unwrap_or(MessageTypeFilterV1::All) { - MessageTypeFilterV1::All => SessionMessageType::All, - MessageTypeFilterV1::DirectUser => SessionMessageType::DirectUser, - MessageTypeFilterV1::ToolResult => SessionMessageType::ToolResult, - }; + let message_type = super::lcm::message_type(request.message_type); let workflow_run = optional_string(request.workflow_run.as_deref())?; let workflow_agent = optional_string(request.workflow_agent.as_deref())?; if workflow_agent.is_some() && workflow_run.is_none() { @@ -564,16 +550,8 @@ impl MessageSearchInput { parent_session_id: self.parent_session_id.clone(), source: None, include_summaries: false, - session_scope: match self.scope { - SessionSearchScope::All => TemporalSessionScopeFilterV1::All, - SessionSearchScope::ParentsOnly => TemporalSessionScopeFilterV1::ParentsOnly, - SessionSearchScope::SubagentsOnly => TemporalSessionScopeFilterV1::SubagentsOnly, - }, - message_type: match self.message_type { - SessionMessageType::All => TemporalMessageTypeFilterV1::All, - SessionMessageType::DirectUser => TemporalMessageTypeFilterV1::DirectUser, - SessionMessageType::ToolResult => TemporalMessageTypeFilterV1::ToolResult, - }, + session_scope: temporal_session_scope(self.scope), + message_type: temporal_message_type(self.message_type), roles: Vec::new(), start_time: self.since, end_time: self.until, @@ -1036,13 +1014,7 @@ fn temporal( .into_iter() .map(|anchor| anchor.as_str().to_owned()) .collect(), - watermarks: TemporalWatermarksV1 { - generation: value.watermarks.generation, - source: value.watermarks.source, - projection: value.watermarks.projection, - index: value.watermarks.index, - summary: value.watermarks.summary, - }, + watermarks: temporal_watermarks(value.watermarks), coverage: coverage(value.coverage), source_coverage: value .source_coverage @@ -1063,7 +1035,7 @@ fn temporal( .map(|omission| TemporalOmissionV1 { rank: omission.rank, anchor: omission.anchor.as_str().to_owned(), - reason: hydration(omission.reason), + reason: HydrationStateResultV1::from(omission.reason), }) .collect(), coverage_omissions: value @@ -1104,119 +1076,7 @@ fn coverage_omission(omission: SessionRetrievalCoverageOmissionView) -> Temporal } } -const fn coverage(value: TemporalCoverageCountsV1) -> TemporalCoverageV1 { - TemporalCoverageV1 { - visible: value.visible, - hidden: value.hidden, - unknown: value.unknown, - redacted: value.redacted, - } -} - -pub(super) fn source_coverage(value: SessionSourceCoverageV1) -> WireSourceCoverageV1 { - WireSourceCoverageV1 { - source_id: value.source_id().as_str().to_owned(), - observed_frontier: value.observed_frontier().value(), - committed_frontier: value.committed_frontier().value(), - target_watermark: value.target_watermark().value(), - request: SessionCoverageRequestV1 { - mode: coverage_mode(value.request().mode()), - }, - covered_intervals: value - .covered_intervals() - .iter() - .cloned() - .map(coverage_interval) - .collect(), - missing_intervals: value - .missing_intervals() - .iter() - .cloned() - .map(coverage_interval) - .collect(), - state: coverage_state(value.state()), - reason: coverage_reason(value.reason()), - } -} - -fn coverage_interval(value: SessionSourceCoverageIntervalV1) -> SessionCoverageIntervalV1 { - SessionCoverageIntervalV1 { - knowledge: ClosedUtcIntervalV1 { - from_inclusive: value.knowledge.from_inclusive().map(|value| value.0), - through_inclusive: value.knowledge.through_inclusive().map(|value| value.0), - }, - valid: match value.valid { - DomainValidCoverageIntervalV1::Known(interval) => { - ValidCoverageIntervalV1::Known(ClosedUtcIntervalV1 { - from_inclusive: interval.from_inclusive().map(|value| value.0), - through_inclusive: interval.through_inclusive().map(|value| value.0), - }) - } - DomainValidCoverageIntervalV1::Unknown => ValidCoverageIntervalV1::Unknown, - }, - } -} - -const fn coverage_mode(value: TemporalModeV1) -> SessionCoverageModeV1 { - match value { - TemporalModeV1::Current => SessionCoverageModeV1::Current, - TemporalModeV1::AsOf { cutoff } => SessionCoverageModeV1::AsOf { cutoff: cutoff.0 }, - TemporalModeV1::Evolution => SessionCoverageModeV1::Evolution, - TemporalModeV1::Forensic => SessionCoverageModeV1::Forensic, - } -} - -const fn coverage_state(value: SessionSourceCoverageStateV1) -> SessionCoverageStateV1 { - match value { - SessionSourceCoverageStateV1::Fresh => SessionCoverageStateV1::Fresh, - SessionSourceCoverageStateV1::Stale => SessionCoverageStateV1::Stale, - SessionSourceCoverageStateV1::Partial => SessionCoverageStateV1::Partial, - SessionSourceCoverageStateV1::Locked => SessionCoverageStateV1::Locked, - SessionSourceCoverageStateV1::Redacted => SessionCoverageStateV1::Redacted, - SessionSourceCoverageStateV1::RetentionWithheld => { - SessionCoverageStateV1::RetentionWithheld - } - SessionSourceCoverageStateV1::Unavailable => SessionCoverageStateV1::Unavailable, - } -} - -fn coverage_reason(value: &SessionSourceCoverageReasonV1) -> SessionCoverageReasonV1 { - match value { - SessionSourceCoverageReasonV1::CaughtUp => SessionCoverageReasonV1::CaughtUp, - SessionSourceCoverageReasonV1::ProjectionBehindSource { lag } => { - SessionCoverageReasonV1::ProjectionBehindSource { lag: *lag } - } - SessionSourceCoverageReasonV1::SourceBehindTarget { lag } => { - SessionCoverageReasonV1::SourceBehindTarget { lag: *lag } - } - SessionSourceCoverageReasonV1::ProjectionAndSourceBehind { - projection_lag, - source_lag, - } => SessionCoverageReasonV1::ProjectionAndSourceBehind { - projection_lag: *projection_lag, - source_lag: *source_lag, - }, - SessionSourceCoverageReasonV1::Locked => SessionCoverageReasonV1::Locked, - SessionSourceCoverageReasonV1::Redacted => SessionCoverageReasonV1::Redacted, - SessionSourceCoverageReasonV1::RetentionWithheld => { - SessionCoverageReasonV1::RetentionWithheld - } - SessionSourceCoverageReasonV1::Unavailable => SessionCoverageReasonV1::Unavailable, - } -} - -const fn hydration(value: HydrationStateV1) -> HydrationStateResultV1 { - match value { - HydrationStateV1::Available => HydrationStateResultV1::Available, - HydrationStateV1::RetainedButUnavailable => HydrationStateResultV1::RetainedButUnavailable, - HydrationStateV1::Redacted => HydrationStateResultV1::Redacted, - HydrationStateV1::Deleted => HydrationStateResultV1::Deleted, - HydrationStateV1::RetentionExpired => HydrationStateResultV1::RetentionExpired, - HydrationStateV1::Unauthorized => HydrationStateResultV1::Unauthorized, - HydrationStateV1::Locked => HydrationStateResultV1::Locked, - HydrationStateV1::UnverifiableLegacy => HydrationStateResultV1::UnverifiableLegacy, - } -} +pub(super) use super::wire::{coverage, source_coverage, temporal_watermarks}; #[cfg(test)] mod refusal_tests { diff --git a/crates/tracedecay-session-runtime/src/retained/wire.rs b/crates/tracedecay-session-runtime/src/retained/wire.rs new file mode 100644 index 0000000000..fc7601f5cf --- /dev/null +++ b/crates/tracedecay-session-runtime/src/retained/wire.rs @@ -0,0 +1,130 @@ +//! One retained projection of domain coverage and hydration onto wire results. + +use tracedecay_contracts::retained_surfaces::{ + ClosedUtcIntervalV1, SessionCoverageIntervalV1, SessionCoverageModeV1, SessionCoverageReasonV1, + SessionCoverageRequestV1, SessionCoverageStateV1, + SessionSourceCoverageV1 as WireSourceCoverageV1, TemporalCoverageV1, TemporalWatermarksV1, + ValidCoverageIntervalV1, +}; +use tracedecay_domain::{ + ClosedUtcIntervalV1 as DomainClosedUtcIntervalV1, SessionSourceCoverageIntervalV1, + SessionSourceCoverageReasonV1, SessionSourceCoverageStateV1, SessionSourceCoverageV1, + TemporalCoverageCountsV1, TemporalModeV1, + ValidCoverageIntervalV1 as DomainValidCoverageIntervalV1, +}; + +use crate::session_retrieval::SessionTemporalWatermarksView; + +pub(super) const fn coverage(value: TemporalCoverageCountsV1) -> TemporalCoverageV1 { + TemporalCoverageV1 { + visible: value.visible, + hidden: value.hidden, + unknown: value.unknown, + redacted: value.redacted, + } +} + +pub(super) const fn temporal_watermarks( + value: SessionTemporalWatermarksView, +) -> TemporalWatermarksV1 { + TemporalWatermarksV1 { + generation: value.generation, + source: value.source, + projection: value.projection, + index: value.index, + summary: value.summary, + } +} + +pub(super) fn source_coverage(value: SessionSourceCoverageV1) -> WireSourceCoverageV1 { + WireSourceCoverageV1 { + source_id: value.source_id().as_str().to_owned(), + observed_frontier: value.observed_frontier().value(), + committed_frontier: value.committed_frontier().value(), + target_watermark: value.target_watermark().value(), + request: SessionCoverageRequestV1 { + mode: coverage_mode(value.request().mode()), + }, + covered_intervals: value + .covered_intervals() + .iter() + .cloned() + .map(coverage_interval) + .collect(), + missing_intervals: value + .missing_intervals() + .iter() + .cloned() + .map(coverage_interval) + .collect(), + state: coverage_state(value.state()), + reason: coverage_reason(value.reason()), + } +} + +fn coverage_interval(value: SessionSourceCoverageIntervalV1) -> SessionCoverageIntervalV1 { + SessionCoverageIntervalV1 { + knowledge: closed_interval(value.knowledge), + valid: match value.valid { + DomainValidCoverageIntervalV1::Known(interval) => { + ValidCoverageIntervalV1::Known(closed_interval(interval)) + } + DomainValidCoverageIntervalV1::Unknown => ValidCoverageIntervalV1::Unknown, + }, + } +} + +fn closed_interval(value: DomainClosedUtcIntervalV1) -> ClosedUtcIntervalV1 { + ClosedUtcIntervalV1 { + from_inclusive: value.from_inclusive().map(|value| value.0), + through_inclusive: value.through_inclusive().map(|value| value.0), + } +} + +const fn coverage_mode(value: TemporalModeV1) -> SessionCoverageModeV1 { + match value { + TemporalModeV1::Current => SessionCoverageModeV1::Current, + TemporalModeV1::AsOf { cutoff } => SessionCoverageModeV1::AsOf { cutoff: cutoff.0 }, + TemporalModeV1::Evolution => SessionCoverageModeV1::Evolution, + TemporalModeV1::Forensic => SessionCoverageModeV1::Forensic, + } +} + +const fn coverage_state(value: SessionSourceCoverageStateV1) -> SessionCoverageStateV1 { + match value { + SessionSourceCoverageStateV1::Fresh => SessionCoverageStateV1::Fresh, + SessionSourceCoverageStateV1::Stale => SessionCoverageStateV1::Stale, + SessionSourceCoverageStateV1::Partial => SessionCoverageStateV1::Partial, + SessionSourceCoverageStateV1::Locked => SessionCoverageStateV1::Locked, + SessionSourceCoverageStateV1::Redacted => SessionCoverageStateV1::Redacted, + SessionSourceCoverageStateV1::RetentionWithheld => { + SessionCoverageStateV1::RetentionWithheld + } + SessionSourceCoverageStateV1::Unavailable => SessionCoverageStateV1::Unavailable, + } +} + +fn coverage_reason(value: &SessionSourceCoverageReasonV1) -> SessionCoverageReasonV1 { + match value { + SessionSourceCoverageReasonV1::CaughtUp => SessionCoverageReasonV1::CaughtUp, + SessionSourceCoverageReasonV1::ProjectionBehindSource { lag } => { + SessionCoverageReasonV1::ProjectionBehindSource { lag: *lag } + } + SessionSourceCoverageReasonV1::SourceBehindTarget { lag } => { + SessionCoverageReasonV1::SourceBehindTarget { lag: *lag } + } + SessionSourceCoverageReasonV1::ProjectionAndSourceBehind { + projection_lag, + source_lag, + } => SessionCoverageReasonV1::ProjectionAndSourceBehind { + projection_lag: *projection_lag, + source_lag: *source_lag, + }, + SessionSourceCoverageReasonV1::Locked => SessionCoverageReasonV1::Locked, + SessionSourceCoverageReasonV1::Redacted => SessionCoverageReasonV1::Redacted, + SessionSourceCoverageReasonV1::RetentionWithheld => { + SessionCoverageReasonV1::RetentionWithheld + } + SessionSourceCoverageReasonV1::Unavailable => SessionCoverageReasonV1::Unavailable, + } +} diff --git a/crates/tracedecay-session-runtime/src/session_retrieval.rs b/crates/tracedecay-session-runtime/src/session_retrieval.rs index 9ff356d24d..2de0cd6590 100644 --- a/crates/tracedecay-session-runtime/src/session_retrieval.rs +++ b/crates/tracedecay-session-runtime/src/session_retrieval.rs @@ -106,6 +106,7 @@ pub use contract::{ SessionRetrievalServiceOutcome, SessionRetrievalStoreScope, SessionRetrievalUnavailable, SessionRetrievalUnavailableReason, SessionTemporalMetadataView, SessionTemporalWatermarksView, }; +pub(crate) use contract::{temporal_message_type, temporal_session_scope}; pub use primitive::DaemonSessionLookupPrimitiveV1; /// Serving identity of the store the daemon currently serves, extracted diff --git a/crates/tracedecay-session-runtime/src/session_retrieval/contract.rs b/crates/tracedecay-session-runtime/src/session_retrieval/contract.rs index 89b70a57a4..1d83dca639 100644 --- a/crates/tracedecay-session-runtime/src/session_retrieval/contract.rs +++ b/crates/tracedecay-session-runtime/src/session_retrieval/contract.rs @@ -69,6 +69,26 @@ impl SessionRetrievalCommand { } } +pub(crate) const fn temporal_session_scope( + scope: SessionSearchScope, +) -> TemporalSessionScopeFilterV1 { + match scope { + SessionSearchScope::All => TemporalSessionScopeFilterV1::All, + SessionSearchScope::ParentsOnly => TemporalSessionScopeFilterV1::ParentsOnly, + SessionSearchScope::SubagentsOnly => TemporalSessionScopeFilterV1::SubagentsOnly, + } +} + +pub(crate) const fn temporal_message_type( + message_type: SessionMessageType, +) -> TemporalMessageTypeFilterV1 { + match message_type { + SessionMessageType::All => TemporalMessageTypeFilterV1::All, + SessionMessageType::DirectUser => TemporalMessageTypeFilterV1::DirectUser, + SessionMessageType::ToolResult => TemporalMessageTypeFilterV1::ToolResult, + } +} + fn temporal_candidate_filter( filters: &SessionRetrievalFilters, goals: bool, @@ -81,16 +101,8 @@ fn temporal_candidate_filter( parent_session_id: filters.parent_session_id.clone(), source: filters.source.clone(), include_summaries: filters.include_summaries, - session_scope: match filters.scope { - SessionSearchScope::All => TemporalSessionScopeFilterV1::All, - SessionSearchScope::ParentsOnly => TemporalSessionScopeFilterV1::ParentsOnly, - SessionSearchScope::SubagentsOnly => TemporalSessionScopeFilterV1::SubagentsOnly, - }, - message_type: match filters.message_type { - SessionMessageType::All => TemporalMessageTypeFilterV1::All, - SessionMessageType::DirectUser => TemporalMessageTypeFilterV1::DirectUser, - SessionMessageType::ToolResult => TemporalMessageTypeFilterV1::ToolResult, - }, + session_scope: temporal_session_scope(filters.scope), + message_type: temporal_message_type(filters.message_type), roles, start_time: filters.time_range.start_time, end_time: filters.time_range.end_time, @@ -514,3 +526,41 @@ pub enum SessionRetrievalServiceOutcome { TimedOut, Cancelled, } + +#[cfg(test)] +mod temporal_filter_wire_tests { + use serde_json::json; + + use super::{ + SessionMessageType, SessionSearchScope, temporal_message_type, temporal_session_scope, + }; + + #[test] + fn temporal_scope_keeps_the_session_wire_label() { + for scope in [ + SessionSearchScope::All, + SessionSearchScope::ParentsOnly, + SessionSearchScope::SubagentsOnly, + ] { + assert_eq!( + serde_json::to_value(temporal_session_scope(scope)).expect("scope serializes"), + json!(scope.as_str()) + ); + } + } + + #[test] + fn temporal_message_type_keeps_the_session_wire_label() { + for message_type in [ + SessionMessageType::All, + SessionMessageType::DirectUser, + SessionMessageType::ToolResult, + ] { + assert_eq!( + serde_json::to_value(temporal_message_type(message_type)) + .expect("message type serializes"), + json!(message_type.as_str()) + ); + } + } +} diff --git a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/wake.rs b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/wake.rs index fc7f3bab35..aa243d5e05 100644 --- a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/wake.rs +++ b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/wake.rs @@ -2,8 +2,6 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use std::sync::PoisonError; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - use tracedecay_domain::SessionId; use tracedecay_store::SessionRefreshBeginOrJoinRequestV1; use tracedecay_temporal_query::ports::ExecutionControl; @@ -542,11 +540,7 @@ impl SessionTemporalRefreshWakeState { telemetry.durable_backlog = durable_backlog; telemetry.last_pass_made_progress = made_progress; if made_progress { - let micros = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_micros() - .min(i64::MAX as u128) as i64; + let micros = tracedecay_runtime_core::tracedecay::saturating_utc_now().0; telemetry.last_progress_at_unix_micros = Some(micros); } if telemetry.depths_published { diff --git a/crates/tracedecay-session-temporal-store/src/expand.rs b/crates/tracedecay-session-temporal-store/src/expand.rs index 9b0c501719..340f1f626b 100644 --- a/crates/tracedecay-session-temporal-store/src/expand.rs +++ b/crates/tracedecay-session-temporal-store/src/expand.rs @@ -22,7 +22,7 @@ use tracedecay_store::{ }; use tracedecay_temporal_query::ports::{ExecutionControl, TemporalPortError}; -use super::query::{now_micros, storage, storage_message}; +use super::query::{decode_generation_i64, now_micros, storage, storage_message}; use super::relations::{SessionRelationError, SummarySourceRef}; use super::retrieval::partial_summary_invalidation_exists; use super::store::execution_control_graph_cancellation; @@ -837,14 +837,6 @@ fn decode_summary_publication(encoded: &str) -> SessionStoreResult SessionStoreResult { - let generation = u64::try_from(value).map_err(|error| storage(operation, error))?; - SessionProjectionGenerationV1::new(generation).map_err(SessionStoreError::from) -} - fn decode_frozen_watermarks( encoded: &str, active_generation: SessionProjectionGenerationV1, diff --git a/crates/tracedecay-session-temporal-store/src/hydration.rs b/crates/tracedecay-session-temporal-store/src/hydration.rs index 36447df97d..8b924d96c4 100644 --- a/crates/tracedecay-session-temporal-store/src/hydration.rs +++ b/crates/tracedecay-session-temporal-store/src/hydration.rs @@ -842,7 +842,7 @@ async fn open_occurrence_content( fn content_matches_descriptor(content: &[u8], descriptor: &PayloadDescriptor) -> bool { content.len() == descriptor.byte_count - && content_hash_matches(&descriptor.content_hash, content) + && content_hash_equals(&descriptor.content_hash, &sha256_hex(content)) } pub(super) fn hydration_failure(error: impl std::fmt::Display) -> HydrationError { @@ -1417,10 +1417,6 @@ fn nonnegative_usize(value: Option) -> Result { .ok_or_else(|| hydration_failure("payload size is not a nonnegative usize")) } -fn content_hash_matches(expected: &str, bytes: &[u8]) -> bool { - content_hash_equals(expected, &sha256_hex(bytes)) -} - fn content_hash_equals(expected: &str, actual_hex: &str) -> bool { sha256_hex_suffix(expected).unwrap_or(expected) == actual_hex } @@ -2150,9 +2146,7 @@ mod tests { RetrievalAnchorId::new("anchor-1").expect("anchor") } - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn snapshot(control: ExecutionControl) -> TemporalExecutionSnapshot { TemporalExecutionSnapshot::new_authorized( diff --git a/crates/tracedecay-session-temporal-store/src/hydration/file_stream_tests.rs b/crates/tracedecay-session-temporal-store/src/hydration/file_stream_tests.rs index 717e4d2c4e..15c8371552 100644 --- a/crates/tracedecay-session-temporal-store/src/hydration/file_stream_tests.rs +++ b/crates/tracedecay-session-temporal-store/src/hydration/file_stream_tests.rs @@ -252,9 +252,7 @@ impl TemporalHydrationBackend for ExternalPayloadBackend<'_> { } } -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn snapshot(control: ExecutionControl) -> TemporalExecutionSnapshot { let limits = ExecutionLimits::default(); diff --git a/crates/tracedecay-session-temporal-store/src/lib.rs b/crates/tracedecay-session-temporal-store/src/lib.rs index 7da50f883f..2a85858386 100644 --- a/crates/tracedecay-session-temporal-store/src/lib.rs +++ b/crates/tracedecay-session-temporal-store/src/lib.rs @@ -11,6 +11,7 @@ mod handle; mod schema_constants; mod support; +pub use support::derive_projection; #[cfg(test)] mod test_registered_impls; #[cfg(test)] diff --git a/crates/tracedecay-session-temporal-store/src/operations/message_anchor.rs b/crates/tracedecay-session-temporal-store/src/operations/message_anchor.rs index e2698fb086..b155665056 100644 --- a/crates/tracedecay-session-temporal-store/src/operations/message_anchor.rs +++ b/crates/tracedecay-session-temporal-store/src/operations/message_anchor.rs @@ -322,7 +322,7 @@ fn require_session_owned_observation( || observation.source().session_id().as_str() != session_id || observation.scope() != expected_scope || anchor.owner() != observation.scope() - || serde_json::to_string(anchor.owner()).ok().as_deref() != Some(owner_json) + || !anchor.owner_column_matches(owner_json) || retained_receipt_id != observation.receipt().receipt().receipt_id().as_str() { return Err(LcmError::SummarySourceNotOwnedBySession); @@ -825,7 +825,7 @@ mod tests { &malformed.to_string(), &observation, &anchor, - &serde_json::to_string(anchor.owner()).expect("owner json"), + &anchor.owner_column_json().expect("owner json"), ) .await; @@ -862,7 +862,7 @@ mod tests { &malformed.to_string(), &observation, &anchor, - &serde_json::to_string(anchor.owner()).expect("owner json"), + &anchor.owner_column_json().expect("owner json"), ) .await; @@ -931,7 +931,7 @@ mod tests { &serde_json::to_string(&observation).expect("observation json"), &observation, &foreign_anchor, - &serde_json::to_string(foreign_anchor.owner()).expect("owner json"), + &foreign_anchor.owner_column_json().expect("owner json"), ) .await; @@ -1123,7 +1123,7 @@ mod tests { &serde_json::to_string(&observation).expect("observation json"), &observation, &anchor, - &serde_json::to_string(anchor.owner()).expect("owner json"), + &anchor.owner_column_json().expect("owner json"), index + 1, ) .await; @@ -1247,7 +1247,7 @@ mod tests { &serde_json::to_string(&canonical).expect("observation json"), &canonical, &canonical_anchor, - &serde_json::to_string(canonical_anchor.owner()).expect("owner json"), + &canonical_anchor.owner_column_json().expect("owner json"), 1, ) .await; @@ -1268,7 +1268,7 @@ mod tests { &serde_json::to_string(&observation).expect("observation json"), &observation, &anchor, - &serde_json::to_string(anchor.owner()).expect("owner json"), + &anchor.owner_column_json().expect("owner json"), sequence, ) .await; diff --git a/crates/tracedecay-session-temporal-store/src/operations/publication.rs b/crates/tracedecay-session-temporal-store/src/operations/publication.rs index e17c61426a..005844de28 100644 --- a/crates/tracedecay-session-temporal-store/src/operations/publication.rs +++ b/crates/tracedecay-session-temporal-store/src/operations/publication.rs @@ -418,8 +418,7 @@ async fn verify_summary_anchor( .ok() .is_some_and(|anchor| { anchor.anchor_id().as_str() == manifest.summary_anchor_id - && serde_json::to_string(anchor.owner()).ok().as_deref() - == Some(actual_owner_json.as_str()) + && anchor.owner_column_matches(actual_owner_json.as_str()) && matches!( anchor.target(), RetrievalAnchorTargetV2::Entity(entity) diff --git a/crates/tracedecay-session-temporal-store/src/operations/sources.rs b/crates/tracedecay-session-temporal-store/src/operations/sources.rs index 8d5ec33665..4ef9d132bd 100644 --- a/crates/tracedecay-session-temporal-store/src/operations/sources.rs +++ b/crates/tracedecay-session-temporal-store/src/operations/sources.rs @@ -634,7 +634,8 @@ pub(super) async fn insert_summary_anchor( typed_anchor: Option<&RetrievalAnchorRecord>, ) -> Result<(), LcmError> { let stored_owner_json = match typed_anchor { - Some(anchor) => serde_json::to_string(anchor.owner()) + Some(anchor) => anchor + .owner_column_json() .map_err(|error| LcmError::Db(format!("encode summary anchor owner: {error}")))?, None => owner_json.to_string(), }; diff --git a/crates/tracedecay-session-temporal-store/src/participant_freeze.rs b/crates/tracedecay-session-temporal-store/src/participant_freeze.rs index 7d5bd8ae3d..18c85fde4a 100644 --- a/crates/tracedecay-session-temporal-store/src/participant_freeze.rs +++ b/crates/tracedecay-session-temporal-store/src/participant_freeze.rs @@ -539,9 +539,7 @@ mod tests { .expect("valid authorized root") } - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn execution_request() -> AuthorizedTemporalExecutionRequest { let snapshot = TemporalSnapshotRequest::new( diff --git a/crates/tracedecay-session-temporal-store/src/query.rs b/crates/tracedecay-session-temporal-store/src/query.rs index ae0ab6b4de..fbaa55c50c 100644 --- a/crates/tracedecay-session-temporal-store/src/query.rs +++ b/crates/tracedecay-session-temporal-store/src/query.rs @@ -57,6 +57,14 @@ pub(super) fn frontier_i64(frontier: u64, operation: &'static str) -> SessionSto i64::try_from(frontier).map_err(|error| storage(operation, error)) } +pub(super) fn decode_generation_i64( + value: i64, + operation: &'static str, +) -> SessionStoreResult { + let value = u64::try_from(value).map_err(|error| storage(operation, error))?; + SessionProjectionGenerationV1::new(value).map_err(SessionStoreError::from) +} + pub(super) fn encode_watermarks( watermarks: &SessionFrozenWatermarksV1, operation: &'static str, @@ -128,10 +136,7 @@ pub(super) async fn read_active_generation( return Ok(None); }; let value: i64 = row.get(0).map_err(|error| storage(operation, error))?; - let value = u64::try_from(value).map_err(|error| storage(operation, error))?; - SessionProjectionGenerationV1::new(value) - .map(Some) - .map_err(SessionStoreError::from) + decode_generation_i64(value, operation).map(Some) } pub(super) async fn require_active_generation( diff --git a/crates/tracedecay-session-temporal-store/src/refresh.rs b/crates/tracedecay-session-temporal-store/src/refresh.rs index 027e8932f6..ae80471ed9 100644 --- a/crates/tracedecay-session-temporal-store/src/refresh.rs +++ b/crates/tracedecay-session-temporal-store/src/refresh.rs @@ -26,8 +26,8 @@ use super::projection::{ validate_final_projection_receipt, }; use super::query::{ - encode_watermarks, frontier_i64, generation_i64, now_micros, read_generation, storage, - storage_message, + decode_generation_i64, encode_watermarks, frontier_i64, generation_i64, now_micros, + read_generation, storage, storage_message, }; use super::rebuild::{ checkpoint_relation_rebuild_control, rebuild_candidate_session_relations, @@ -1259,14 +1259,6 @@ async fn next_generation( decode_generation_i64(value, BEGIN_REFRESH) } -fn decode_generation_i64( - value: i64, - operation: &'static str, -) -> SessionStoreResult { - let value = u64::try_from(value).map_err(|error| storage(operation, error))?; - SessionProjectionGenerationV1::new(value).map_err(SessionStoreError::from) -} - const SQLITE_CONSTRAINT: i32 = 19; const SQLITE_CONSTRAINT_PRIMARYKEY: i32 = 1555; const SQLITE_CONSTRAINT_UNIQUE: i32 = 2067; diff --git a/crates/tracedecay-session-temporal-store/src/relation_projection.rs b/crates/tracedecay-session-temporal-store/src/relation_projection.rs index 520304afb8..ccedf01482 100644 --- a/crates/tracedecay-session-temporal-store/src/relation_projection.rs +++ b/crates/tracedecay-session-temporal-store/src/relation_projection.rs @@ -1198,38 +1198,37 @@ async fn reconstruct_session_metadata( Ok((parent, memberships)) } +fn projection_budget(count: usize, add: usize) -> SessionStoreResult { + count + .checked_add(add) + .ok_or_else(|| storage(RECONSTRUCT_OPERATION, SessionRelationError::BudgetExhausted)) +} + fn enforce_projection_bounds( projection: &SessionRelationProjection, max_entities: usize, max_relations: usize, ) -> SessionStoreResult<()> { - let summary_relations = projection - .summaries - .iter() - .try_fold(0usize, |count, summary| { - count.checked_add(summary.sources.len()).and_then(|count| { - count.checked_add(usize::from(summary.predecessor_summary_id.is_some())) - }) - }) - .ok_or_else(|| storage(RECONSTRUCT_OPERATION, SessionRelationError::BudgetExhausted))?; - let relation_count = [ + let mut summary_relations = 0usize; + for summary in &projection.summaries { + summary_relations = projection_budget(summary_relations, summary.sources.len())?; + summary_relations = projection_budget( + summary_relations, + usize::from(summary.predecessor_summary_id.is_some()), + )?; + } + let mut relation_count = summary_relations; + for count in [ projection.logical_copies.len(), projection.thread_hierarchy.len(), projection.agent_hierarchy.len(), usize::from(projection.parent_session_id.is_some()), projection.workflow_agents.len(), - ] - .into_iter() - .try_fold(summary_relations, usize::checked_add) - .ok_or_else(|| storage(RECONSTRUCT_OPERATION, SessionRelationError::BudgetExhausted))?; - let entity_count = - projection - .summaries - .len() - .checked_add(relation_count.checked_mul(2).ok_or_else(|| { - storage(RECONSTRUCT_OPERATION, SessionRelationError::BudgetExhausted) - })?) - .ok_or_else(|| storage(RECONSTRUCT_OPERATION, SessionRelationError::BudgetExhausted))?; + ] { + relation_count = projection_budget(relation_count, count)?; + } + let entity_count = projection_budget(relation_count, relation_count) + .and_then(|doubled| projection_budget(projection.summaries.len(), doubled))?; if relation_count > max_relations || entity_count > max_entities { return Err(storage( RECONSTRUCT_OPERATION, diff --git a/crates/tracedecay-session-temporal-store/src/relations/projection_read.rs b/crates/tracedecay-session-temporal-store/src/relations/projection_read.rs index d9f435fa6a..234191f821 100644 --- a/crates/tracedecay-session-temporal-store/src/relations/projection_read.rs +++ b/crates/tracedecay-session-temporal-store/src/relations/projection_read.rs @@ -459,10 +459,7 @@ fn string_property<'a>( relation: &'a tracedecay_graph_db::GraphRelation, property: &GraphPropertyName, ) -> Result<&'a str, SessionRelationError> { - match relation.properties.get(property) { - Some(GraphProperty::String(value)) => Ok(value), - _ => Err(SessionRelationError::Corrupt), - } + super::read::string_property(relation, property).ok_or(SessionRelationError::Corrupt) } #[cfg(test)] @@ -478,13 +475,7 @@ mod tests { GraphWatermark, NeverCancelled, ProjectionReplacement, SourceGeneration, }; - fn id(value: &str) -> T - where - T: TryFrom, - T::Error: std::fmt::Debug, - { - T::try_from(value.to_owned()).expect("valid test identity") - } + use tracedecay_domain::test_fixtures::id; fn relation_projection() -> SessionRelationProjection { SessionRelationProjection { diff --git a/crates/tracedecay-session-temporal-store/src/relations/read.rs b/crates/tracedecay-session-temporal-store/src/relations/read.rs index c5447f1be8..f40d3906ab 100644 --- a/crates/tracedecay-session-temporal-store/src/relations/read.rs +++ b/crates/tracedecay-session-temporal-store/src/relations/read.rs @@ -296,7 +296,7 @@ fn require_budget(max_relations: usize) -> Result<(), SessionRelationError> { } } -fn string_property<'a>( +pub(super) fn string_property<'a>( relation: &'a GraphRelation, property: &GraphPropertyName, ) -> Option<&'a str> { diff --git a/crates/tracedecay-session-temporal-store/src/retrieval/graph_relation_tests.rs b/crates/tracedecay-session-temporal-store/src/retrieval/graph_relation_tests.rs index 3ac93f8698..bd4f217013 100644 --- a/crates/tracedecay-session-temporal-store/src/retrieval/graph_relation_tests.rs +++ b/crates/tracedecay-session-temporal-store/src/retrieval/graph_relation_tests.rs @@ -23,9 +23,7 @@ use crate::relations::{ WorkflowAgentMembership, }; -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn project() -> ProjectId { ProjectId::new("project-retrieval").expect("project") diff --git a/crates/tracedecay-session-temporal-store/src/retrieval/tests.rs b/crates/tracedecay-session-temporal-store/src/retrieval/tests.rs index 7fc90ceb71..a31eee6b9b 100644 --- a/crates/tracedecay-session-temporal-store/src/retrieval/tests.rs +++ b/crates/tracedecay-session-temporal-store/src/retrieval/tests.rs @@ -52,9 +52,7 @@ fn normalize_plan_detail(detail: &str) -> String { .to_ascii_uppercase() } -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn snapshot(generation: u64) -> TemporalExecutionSnapshot { TemporalExecutionSnapshot::new_authorized( diff --git a/crates/tracedecay-session-temporal-store/src/support.rs b/crates/tracedecay-session-temporal-store/src/support.rs index 1025f2b120..d765ab9357 100644 --- a/crates/tracedecay-session-temporal-store/src/support.rs +++ b/crates/tracedecay-session-temporal-store/src/support.rs @@ -53,8 +53,8 @@ pub(crate) fn record_hydration_emitted_bytes(count: usize) { /// Same composition as the observation-projection derive path: canonical /// envelopes go through store authority; legacy Claude records use the public -/// sessions mapper. Kept here so this crate does not depend on global-db. -pub(crate) fn derive_projection( +/// sessions mapper. Global-db calls this so the two projections cannot drift. +pub fn derive_projection( observation: &DurableObservationV1, ) -> ProjectionStoreResult { match observation.source().provider().as_str() { diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill.rs index bbd82edd58..edbb87d441 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill.rs @@ -73,15 +73,6 @@ impl SessionActivityRow { _ => None, } } - - /// The activity timestamp the incremental backfill orders and watermarks by: - /// the newest message time, else the declared end, else the start. Mirrors - /// the `COALESCE(MAX(m.timestamp), s.ended_at, s.started_at)` key used by - /// [`session_activity_page_after`], so the returned value compares directly - /// against the persisted watermark (both are raw, un-normalized bounds). - pub fn activity_sort_key(&self) -> Option { - self.message_max_ts.or(self.ended_at).or(self.started_at) - } } /// One `HEAD` position in a worktree's reflog timeline: the branch `HEAD` diff --git a/crates/tracedecay-sessions/src/runtime/hosts/claude/canonical_projection.rs b/crates/tracedecay-sessions/src/runtime/hosts/claude/canonical_projection.rs index 10e52e1ec7..a5ab409eb3 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/claude/canonical_projection.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/claude/canonical_projection.rs @@ -3,8 +3,8 @@ use std::path::{Path, PathBuf}; use serde::Serialize; use serde_json::{Map, Value}; use tracedecay_domain::{ - CanonicalBoundaryKindV1, CanonicalGitEvidenceKindV1, CanonicalMessageRoleV1, - CanonicalObservationEnvelopeV1, CanonicalObservationFactV1, CanonicalWorkflowEvidenceKindV1, + CanonicalBoundaryKindV1, CanonicalGitEvidenceKindV1, CanonicalObservationEnvelopeV1, + CanonicalObservationFactV1, CanonicalWorkflowEvidenceKindV1, }; use tracedecay_runtime_core::logging::log_daemon_event; @@ -179,7 +179,7 @@ pub(super) fn map_canonical_claude_record( .as_str() .to_owned(), session_id: context.session_id.to_owned(), - role: canonical_role(*role).to_owned(), + role: role.as_str().to_owned(), timestamp: timestamp.or_else(|| envelope.evidence().native_timestamp()), ordinal: offset, text, @@ -492,16 +492,6 @@ fn insert_canonical_envelope( } } -fn canonical_role(role: CanonicalMessageRoleV1) -> &'static str { - match role { - CanonicalMessageRoleV1::User => "user", - CanonicalMessageRoleV1::Assistant => "assistant", - CanonicalMessageRoleV1::System => "system", - CanonicalMessageRoleV1::Tool => "tool", - CanonicalMessageRoleV1::Unknown => "unknown", - } -} - #[cfg(test)] mod canonical_envelope_omission_tests { use super::insert_canonical_envelope; diff --git a/crates/tracedecay-sessions/src/runtime/hosts/codex_app_server.rs b/crates/tracedecay-sessions/src/runtime/hosts/codex_app_server.rs index 247316c405..099e1558f8 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/codex_app_server.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/codex_app_server.rs @@ -1147,9 +1147,16 @@ mod tests { } let temp = tempfile::tempdir().unwrap(); let descendant_pid_path = temp.path().join("descendant.pid"); + // `>` creates the pid file before the shell writes into it, and a poll + // that only checks the path can read it empty. Write to a sibling and + // rename so the file appears with its pid already in it. let mut command = Command::new("sh"); command - .args(["-c", "sleep 30 & echo $! > \"$1\"; wait", "sh"]) + .args([ + "-c", + "sleep 30 & echo $! > \"$1.tmp\" && mv \"$1.tmp\" \"$1\"; wait", + "sh", + ]) .arg(&descendant_pid_path); let child = spawn_codex_app_server(&mut command, "sh").expect("spawn child"); let mut child = ChildGuard { diff --git a/crates/tracedecay-sessions/src/runtime/hosts/cursor_composer.rs b/crates/tracedecay-sessions/src/runtime/hosts/cursor_composer.rs index f1585afec6..f683ebfa08 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/cursor_composer.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/cursor_composer.rs @@ -111,23 +111,7 @@ impl CursorComposerSource { .await } - #[hotpath::skip] - pub async fn ingest_user( - &self, - admission: &dyn crate::admission::HostAdmission, - registered_roots: &[std::path::PathBuf], - envelope_cap: usize, - ) -> CursorComposerSweepResult { - self.ingest_user_capped( - admission, - registered_roots, - envelope_cap, - Some(sqlite::DEFAULT_COMPOSER_SWEEP_BYTES), - ) - .await - } - - /// [`Self::ingest_user`] with an aggregate serialized-payload byte budget. + /// User-scope ingest with an aggregate serialized-payload byte budget. #[hotpath::skip] pub async fn ingest_user_capped( &self, diff --git a/crates/tracedecay-sessions/src/runtime/hosts/hermes/observation.rs b/crates/tracedecay-sessions/src/runtime/hosts/hermes/observation.rs index 227d3c366e..f0d99b111c 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/hermes/observation.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/hermes/observation.rs @@ -345,13 +345,8 @@ pub fn normalize_native_observation( fn canonical_message_role( role: &str, ) -> Result { - match role { - "user" => Ok(CanonicalMessageRoleV1::User), - "assistant" => Ok(CanonicalMessageRoleV1::Assistant), - "system" => Ok(CanonicalMessageRoleV1::System), - "tool" => Ok(CanonicalMessageRoleV1::Tool), - _ => Err(ObservationRecordParseErrorV1::InvalidCanonicalEnvelope), - } + CanonicalMessageRoleV1::from_known_label(role) + .ok_or(ObservationRecordParseErrorV1::InvalidCanonicalEnvelope) } fn append_tool_invocations( diff --git a/crates/tracedecay-sessions/src/runtime/hosts/opencode.rs b/crates/tracedecay-sessions/src/runtime/hosts/opencode.rs index 356ab24ef3..dfd422ba9a 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/opencode.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/opencode.rs @@ -978,7 +978,7 @@ fn opencode_data_dir(home: &Path) -> PathBuf { } } -fn scan_error( +pub(super) fn scan_error( operation: &'static str, path: &Path, error: impl std::error::Error + Send + Sync + 'static, @@ -990,7 +990,7 @@ fn scan_error( } } -const fn invalid_frame() -> TranscriptIngestError { +pub(super) const fn invalid_frame() -> TranscriptIngestError { TranscriptIngestError::InvalidFrameState { provider: PROVIDER } } diff --git a/crates/tracedecay-sessions/src/runtime/hosts/opencode_part_scan.rs b/crates/tracedecay-sessions/src/runtime/hosts/opencode_part_scan.rs index 70cbc65be4..867903f1ac 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/opencode_part_scan.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/opencode_part_scan.rs @@ -4,16 +4,14 @@ use std::path::Path; use rusqlite::params; use crate::runtime::host_scan::HostScanBudget; -use crate::runtime::source::{TranscriptIngestError, TranscriptIngestResult}; +use crate::runtime::source::TranscriptIngestResult; use super::opencode::{ MAX_ID_BYTES, MAX_MESSAGES_PER_PAGE, OpenCodeMessageRef, OpenCodePageCursor, - OpenCodeReferencePage, OpenCodeScanSource, install_progress_handler, open_scan_connection, - sql_text, + OpenCodeReferencePage, OpenCodeScanSource, install_progress_handler, invalid_frame, + open_scan_connection, scan_error, sql_text, }; -const PROVIDER: &str = "opencode"; - pub(super) fn scan_part_reference_page( source: &OpenCodeScanSource, cursor: OpenCodePageCursor, @@ -177,19 +175,3 @@ pub(super) fn scan_part_reference_page( budget, )) } - -fn scan_error( - operation: &'static str, - path: &Path, - error: impl std::error::Error + Send + Sync + 'static, -) -> TranscriptIngestError { - TranscriptIngestError::ScanIo { - operation, - path: path.to_path_buf(), - source: std::io::Error::other(error), - } -} - -const fn invalid_frame() -> TranscriptIngestError { - TranscriptIngestError::InvalidFrameState { provider: PROVIDER } -} diff --git a/crates/tracedecay-sessions/src/runtime/hosts/opencode_snapshot.rs b/crates/tracedecay-sessions/src/runtime/hosts/opencode_snapshot.rs index 817973c624..bc63b8548f 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/opencode_snapshot.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/opencode_snapshot.rs @@ -5,6 +5,7 @@ use sha2::{Digest, Sha256}; use tracedecay_domain::ObservationSourceGenerationV1; use tracedecay_runtime_core::sqlite_read_snapshot::SnapshotDatabase; +use super::opencode::scan_error; use crate::runtime::host_scan::HostScanBudget; use crate::runtime::source::{TranscriptIngestError, TranscriptIngestResult}; @@ -188,15 +189,3 @@ pub(super) fn snapshot_scratch_root() -> Option { .ok() .map(|root| root.join("scratch/sqlite-read/opencode")) } - -fn scan_error( - operation: &'static str, - path: &Path, - error: impl std::error::Error + Send + Sync + 'static, -) -> TranscriptIngestError { - TranscriptIngestError::ScanIo { - operation, - path: path.to_path_buf(), - source: std::io::Error::other(error), - } -} diff --git a/crates/tracedecay-sessions/src/runtime/observation/snapshot_observation.rs b/crates/tracedecay-sessions/src/runtime/observation/snapshot_observation.rs index 1b80ac451d..69eab54194 100644 --- a/crates/tracedecay-sessions/src/runtime/observation/snapshot_observation.rs +++ b/crates/tracedecay-sessions/src/runtime/observation/snapshot_observation.rs @@ -220,13 +220,11 @@ pub fn canonical_snapshot_envelope( range: ObservationSourceRangeV1, ) -> Result { let invalid = || ObservationRecordParseErrorV1::NormalizationFailed; - let role = match native.get("role").and_then(Value::as_str) { - Some("user") => CanonicalMessageRoleV1::User, - Some("assistant") => CanonicalMessageRoleV1::Assistant, - Some("system") => CanonicalMessageRoleV1::System, - Some("tool") => CanonicalMessageRoleV1::Tool, - _ => CanonicalMessageRoleV1::Unknown, - }; + let role = native + .get("role") + .and_then(Value::as_str) + .map(CanonicalMessageRoleV1::from_wire_label) + .unwrap_or(CanonicalMessageRoleV1::Unknown); let timestamp = native.get("timestamp").and_then(Value::as_i64); let mut facts = Vec::new(); if let Some(text) = native.get("text").cloned() { diff --git a/crates/tracedecay-sessions/src/runtime/shared.rs b/crates/tracedecay-sessions/src/runtime/shared.rs index aa89606de2..7c2d9ce945 100644 --- a/crates/tracedecay-sessions/src/runtime/shared.rs +++ b/crates/tracedecay-sessions/src/runtime/shared.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use std::time::{Duration, Instant}; use serde_json::Value; +use tracedecay_domain::collapse_whitespace; use tracedecay_lcm::message_storage_text; use tracedecay_runtime_core::git_discovery::{ GitRepositoryIdentityOutcome, discover_repository_identity_cli_first, @@ -716,7 +717,7 @@ fn normalized_paths_equal(a: &Path, b: &Path) -> bool { /// unfinished-run evidence) so a multi-line blob never smears a table, bullet, /// or stored column. pub fn one_line_truncated(text: &str, max: usize) -> String { - let collapsed = text.split_whitespace().collect::>().join(" "); + let collapsed = collapse_whitespace(text); if collapsed.chars().count() <= max { return collapsed; } @@ -739,7 +740,7 @@ pub fn preview_truncated(text: &str, max_bytes: usize) -> String { /// Collapse whitespace and clip to a short preview suitable for a session title. pub fn preview_title(text: &str) -> String { const MAX_TITLE_CHARS: usize = 80; - let collapsed = text.split_whitespace().collect::>().join(" "); + let collapsed = collapse_whitespace(text); if collapsed.chars().count() <= MAX_TITLE_CHARS { collapsed } else { diff --git a/crates/tracedecay-sessions/src/runtime/store_access/lcm.rs b/crates/tracedecay-sessions/src/runtime/store_access/lcm.rs index 0890e5275f..9abf793d50 100644 --- a/crates/tracedecay-sessions/src/runtime/store_access/lcm.rs +++ b/crates/tracedecay-sessions/src/runtime/store_access/lcm.rs @@ -320,42 +320,6 @@ impl<'a, D: SessionRegisteredDb + Sync> SessionStoreAccess<'a, D> { compression::preflight(&snapshot, request).await } - #[hotpath::skip] - pub async fn lcm_payload_health_detail( - &self, - storage_root: &Path, - provider: &str, - session_id: Option<&str>, - deep: bool, - sample_limit: usize, - cfg: &LcmGcConfig, - ) -> Result { - let snapshot = self.lcm_read_snapshot().await?; - query::payload_health_detail( - &snapshot, - storage_root, - provider, - session_id, - deep, - sample_limit, - cfg, - ) - .await - } - - #[hotpath::skip] - pub async fn lcm_preview_payload_gc( - &self, - storage_root: &Path, - provider: &str, - session_id: Option<&str>, - cfg: &LcmGcConfig, - now: i64, - ) -> Result { - let snapshot = self.lcm_read_snapshot().await?; - gc::run_payload_gc(&snapshot, storage_root, provider, session_id, cfg, now).await - } - #[hotpath::skip] pub async fn lcm_run_payload_gc_apply( &self, diff --git a/crates/tracedecay-sessions/src/runtime/store_access/mod.rs b/crates/tracedecay-sessions/src/runtime/store_access/mod.rs index 299a0bf858..e107663a5f 100644 --- a/crates/tracedecay-sessions/src/runtime/store_access/mod.rs +++ b/crates/tracedecay-sessions/src/runtime/store_access/mod.rs @@ -20,6 +20,7 @@ pub use search::{ pub(crate) use sessions::EXISTING_SESSION_MESSAGE_IDS_SQL; pub(crate) use sessions::SESSION_MESSAGE_ID_LOOKUP_MAX; pub use sessions::SESSION_MESSAGES_AFTER_SQL; +pub use sessions::{SqlColumnError, message_record_from_row, session_record_from_row}; pub use transcript::{ TranscriptGitEvidence, get_parse_offset, require_expected_offset, set_parse_offset, }; diff --git a/crates/tracedecay-sessions/src/runtime/store_access/sessions.rs b/crates/tracedecay-sessions/src/runtime/store_access/sessions.rs index 6cfd4e1b77..0aed0f6e9c 100644 --- a/crates/tracedecay-sessions/src/runtime/store_access/sessions.rs +++ b/crates/tracedecay-sessions/src/runtime/store_access/sessions.rs @@ -5,7 +5,7 @@ use std::path::Path; use serde_json::Value as JsonValue; use tracedecay_domain::errors::TraceDecayError; -use tracedecay_runtime_core::db::engine::Value; +use tracedecay_runtime_core::db::engine::{Error as EngineError, FromValue, Row, Value}; use tracedecay_store::{SESSION_MESSAGE_PROJECTOR_VERSION, SessionMessageRecord, SessionRecord}; use crate::runtime::SessionMessageSearchResult; @@ -991,100 +991,71 @@ fn workflow_column_error(column: &str, error: &dyn std::fmt::Display) -> String format!("failed to decode workflow fact column '{column}': {error}") } -fn row_to_session( - row: &tracedecay_runtime_core::db::engine::Row, -) -> std::result::Result { +/// Column decode failure. Callers keep their own error text; the column name +/// is the sessions spelling and the source is the engine error. +pub struct SqlColumnError { + pub column: &'static str, + pub source: EngineError, +} + +fn column(row: &Row, index: i32, name: &'static str) -> Result { + row.get(index).map_err(|source| SqlColumnError { + column: name, + source, + }) +} + +/// `sessions` row in the column order both the session store and the +/// observation projection read. +pub fn session_record_from_row(row: &Row) -> Result { Ok(SessionRecord { - provider: row - .get(0) - .map_err(|error| session_column_error("provider", &error))?, - session_id: row - .get(1) - .map_err(|error| session_column_error("session_id", &error))?, - project_key: row - .get(2) - .map_err(|error| session_column_error("project_key", &error))?, - project_path: row - .get(3) - .map_err(|error| session_column_error("project_path", &error))?, - title: row - .get(4) - .map_err(|error| session_column_error("title", &error))?, - started_at: row - .get(5) - .map_err(|error| session_column_error("started_at", &error))?, - ended_at: row - .get(6) - .map_err(|error| session_column_error("ended_at", &error))?, - transcript_path: row - .get(7) - .map_err(|error| session_column_error("transcript_path", &error))?, - metadata_json: row - .get(8) - .map_err(|error| session_column_error("metadata_json", &error))?, - parent_session_id: row - .get(9) - .map_err(|error| session_column_error("parent_session_id", &error))?, - is_subagent: row - .get::(10) - .map_err(|error| session_column_error("is_subagent", &error))? - != 0, - agent_id: row - .get(11) - .map_err(|error| session_column_error("agent_id", &error))?, - parent_tool_use_id: row - .get(12) - .map_err(|error| session_column_error("parent_tool_use_id", &error))?, + provider: column(row, 0, "provider")?, + session_id: column(row, 1, "session_id")?, + project_key: column(row, 2, "project_key")?, + project_path: column(row, 3, "project_path")?, + title: column(row, 4, "title")?, + started_at: column(row, 5, "started_at")?, + ended_at: column(row, 6, "ended_at")?, + transcript_path: column(row, 7, "transcript_path")?, + metadata_json: column(row, 8, "metadata_json")?, + parent_session_id: column(row, 9, "parent_session_id")?, + is_subagent: column::(row, 10, "is_subagent")? != 0, + agent_id: column(row, 11, "agent_id")?, + parent_tool_use_id: column(row, 12, "parent_tool_use_id")?, }) } -fn row_to_message( - row: &tracedecay_runtime_core::db::engine::Row, +/// `session_messages` row, starting at `offset`, in the shared column order. +pub fn message_record_from_row( + row: &Row, offset: i32, -) -> std::result::Result { +) -> Result { Ok(SessionMessageRecord { - provider: row - .get(offset) - .map_err(|error| message_column_error("provider", &error))?, - message_id: row - .get(offset + 1) - .map_err(|error| message_column_error("message_id", &error))?, - session_id: row - .get(offset + 2) - .map_err(|error| message_column_error("session_id", &error))?, - role: row - .get(offset + 3) - .map_err(|error| message_column_error("role", &error))?, - timestamp: row - .get(offset + 4) - .map_err(|error| message_column_error("timestamp", &error))?, - ordinal: row - .get(offset + 5) - .map_err(|error| message_column_error("ordinal", &error))?, - text: row - .get(offset + 6) - .map_err(|error| message_column_error("text", &error))?, - kind: row - .get(offset + 7) - .map_err(|error| message_column_error("kind", &error))?, - model: row - .get(offset + 8) - .map_err(|error| message_column_error("model", &error))?, - tool_names: row - .get(offset + 9) - .map_err(|error| message_column_error("tool_names", &error))?, - source_path: row - .get(offset + 10) - .map_err(|error| message_column_error("source_path", &error))?, - source_offset: row - .get(offset + 11) - .map_err(|error| message_column_error("source_offset", &error))?, - metadata_json: row - .get(offset + 12) - .map_err(|error| message_column_error("metadata_json", &error))?, + provider: column(row, offset, "provider")?, + message_id: column(row, offset + 1, "message_id")?, + session_id: column(row, offset + 2, "session_id")?, + role: column(row, offset + 3, "role")?, + timestamp: column(row, offset + 4, "timestamp")?, + ordinal: column(row, offset + 5, "ordinal")?, + text: column(row, offset + 6, "text")?, + kind: column(row, offset + 7, "kind")?, + model: column(row, offset + 8, "model")?, + tool_names: column(row, offset + 9, "tool_names")?, + source_path: column(row, offset + 10, "source_path")?, + source_offset: column(row, offset + 11, "source_offset")?, + metadata_json: column(row, offset + 12, "metadata_json")?, }) } +fn row_to_session(row: &Row) -> std::result::Result { + session_record_from_row(row).map_err(|error| session_column_error(error.column, &error.source)) +} + +fn row_to_message(row: &Row, offset: i32) -> std::result::Result { + message_record_from_row(row, offset) + .map_err(|error| message_column_error(error.column, &error.source)) +} + fn row_to_workflow_message( row: &tracedecay_runtime_core::db::engine::Row, offset: i32, diff --git a/crates/tracedecay-sessions/src/runtime/store_access/transcript.rs b/crates/tracedecay-sessions/src/runtime/store_access/transcript.rs index ec37288060..b4c1c7c9fa 100644 --- a/crates/tracedecay-sessions/src/runtime/store_access/transcript.rs +++ b/crates/tracedecay-sessions/src/runtime/store_access/transcript.rs @@ -3,7 +3,6 @@ use tracedecay_store::{ParseOffset, SessionMessageRecord, SessionRecord, StoreSh use tracedecay_lcm::payload::PayloadFileRollback; use tracedecay_lcm::raw; -use tracedecay_lcm::retrieval_content::derived_text_for_index; use super::super::git_correlation::{ CommitSessionRecord, SpanObservation, enqueue_git_evidence_publication, @@ -13,12 +12,6 @@ use super::super::shared::{durable_project_path_key, path_identity_key}; use super::codex_goal_reconciliation::find_preceding_codex_goal_response; use super::types::{TranscriptBatch, TranscriptPersistenceError}; -#[derive(Debug, Clone, Copy)] -enum TranscriptWritePolicy { - Full { expected_offset: ParseOffset }, - ProjectionOnly, -} - /// Exact Git evidence staged atomically with one transcript write. #[derive(Debug, Clone, Copy)] pub struct TranscriptGitEvidence<'a> { @@ -533,7 +526,7 @@ impl SessionStoreAccess<'_, D> { std::slice::from_ref(&batch), parse_offset_path, parse_offset, - TranscriptWritePolicy::Full { expected_offset }, + expected_offset, None, ) .await @@ -573,7 +566,7 @@ impl SessionStoreAccess<'_, D> { std::slice::from_ref(&batch), parse_offset_path, parse_offset, - TranscriptWritePolicy::Full { expected_offset }, + expected_offset, Some(git_evidence), ) .await @@ -595,34 +588,13 @@ impl SessionStoreAccess<'_, D> { .map_err(|error| TranscriptPersistenceError::storage("commit transcript batch", error)) } - /// Atomically upserts several transcript sessions (and their messages), - /// writing only the searchable `session_messages` projection, never - /// `lcm_raw_messages`, and then advances one shared parse cursor. - #[hotpath::skip] - pub async fn upsert_transcript_projection_batches( - &self, - batches: &[TranscriptBatch], - parse_offset_path: &str, - parse_offset: ParseOffset, - ) -> Result<(), String> { - self.upsert_transcript_batches_inner( - batches, - parse_offset_path, - parse_offset, - TranscriptWritePolicy::ProjectionOnly, - None, - ) - .await - .map_err(|error| error.to_string()) - } - #[hotpath::measure(label = "sessions.store.transcript.write_batches", future = true)] async fn upsert_transcript_batches_inner( &self, batches: &[TranscriptBatch], parse_offset_path: &str, parse_offset: ParseOffset, - policy: TranscriptWritePolicy, + expected_offset: ParseOffset, git_evidence: Option>, ) -> Result<(), TranscriptPersistenceError> { let storage_root = self @@ -630,25 +602,19 @@ impl SessionStoreAccess<'_, D> { .parent() .unwrap_or_else(|| std::path::Path::new(".")); let mut payload_rollback = PayloadFileRollback::begin_cancellation_safe(storage_root); - let staged_messages = match policy { - TranscriptWritePolicy::Full { .. } => { - stage_full_transcript_messages(storage_root, batches, &mut payload_rollback)? - } - TranscriptWritePolicy::ProjectionOnly => Vec::new(), - }; + let staged_messages = + stage_full_transcript_messages(storage_root, batches, &mut payload_rollback)?; let mut staged_messages = staged_messages.into_iter(); let transaction = self.begin_transcript_transaction().await?; let write_result: Result<(), TranscriptPersistenceError> = async { let mut projection_statements = Vec::with_capacity(TRANSCRIPT_STATEMENT_WINDOW); - if let TranscriptWritePolicy::Full { expected_offset } = policy { - // Full batches are one-winner compare-and-swap on the durable - // parse cursor. `actual == next_offset` is not a retry grant: - // a competing writer can share that destination while carrying - // different parse products. Post-commit publication retries - // must not re-enter this CAS with a stale expected cursor. - require_expected_offset(&transaction, parse_offset_path, expected_offset).await?; - } + // Full batches are one-winner compare-and-swap on the durable + // parse cursor. `actual == next_offset` is not a retry grant: + // a competing writer can share that destination while carrying + // different parse products. Post-commit publication retries + // must not re-enter this CAS with a stale expected cursor. + require_expected_offset(&transaction, parse_offset_path, expected_offset).await?; for batch in batches { if !Self::upsert_session_in_existing_tx(&transaction, &batch.session).await { return Err(TranscriptPersistenceError::message( @@ -671,32 +637,16 @@ impl SessionStoreAccess<'_, D> { .await?; reconcile_codex_goal_response(&transaction, message).await?; } - match policy { - TranscriptWritePolicy::Full { .. } => { - let staged = staged_messages.next().ok_or_else(|| { - TranscriptPersistenceError::message( - "upsert LCM raw message", - "staged transcript message count did not match the write batch", - ) - })?; - projection_statements.push( - self.upsert_session_message_in_existing_tx( - &transaction, - message, - staged, - ) - .await?, - ); - } - TranscriptWritePolicy::ProjectionOnly => { - let text = derived_text_for_index(&message.text); - projection_statements.push(Self::session_message_projection_statement( - message, - &text, - message.metadata_json.as_deref(), - )?); - } - } + let staged = staged_messages.next().ok_or_else(|| { + TranscriptPersistenceError::message( + "upsert LCM raw message", + "staged transcript message count did not match the write batch", + ) + })?; + projection_statements.push( + self.upsert_session_message_in_existing_tx(&transaction, message, staged) + .await?, + ); if projection_statements.len() >= TRANSCRIPT_STATEMENT_WINDOW { flush_transcript_statement_window(&transaction, &mut projection_statements) .await?; @@ -704,9 +654,7 @@ impl SessionStoreAccess<'_, D> { } } flush_transcript_statement_window(&transaction, &mut projection_statements).await?; - if matches!(policy, TranscriptWritePolicy::Full { .. }) - && staged_messages.next().is_some() - { + if staged_messages.next().is_some() { return Err(TranscriptPersistenceError::message( "upsert LCM raw message", "staged transcript message count exceeded the write batch", @@ -724,19 +672,7 @@ impl SessionStoreAccess<'_, D> { TranscriptPersistenceError::storage("stage transcript git evidence", error) })?; } - if matches!(policy, TranscriptWritePolicy::Full { .. }) { - set_parse_offset(&transaction, parse_offset_path, parse_offset).await?; - } else { - Self::set_parse_offset_monotonic_in_existing_tx( - &transaction, - parse_offset_path, - parse_offset, - ) - .await - .map_err(|message| { - TranscriptPersistenceError::message("advance projection parse offset", message) - })?; - } + set_parse_offset(&transaction, parse_offset_path, parse_offset).await?; Ok(()) } .await; diff --git a/crates/tracedecay-source-edit/src/edits/ast_grep.rs b/crates/tracedecay-source-edit/src/edits/ast_grep.rs index 13d933e447..b199b5cf21 100644 --- a/crates/tracedecay-source-edit/src/edits/ast_grep.rs +++ b/crates/tracedecay-source-edit/src/edits/ast_grep.rs @@ -24,11 +24,7 @@ pub(crate) async fn ast_grep_rewrite( rewrite: &str, dry_run: bool, ) -> Result { - let rel_path = super::primitives::resolve_path(project_root, path).ok_or_else(|| { - TraceDecayError::Config { - message: "path is not within the project".to_string(), - } - })?; + let rel_path = super::primitives::require_project_path(project_root, path)?; let file = SourceEditFileAuthority::open(project_root, Path::new(&rel_path))?; let (source, source_identity) = file.read_to_string(path)?; @@ -38,49 +34,45 @@ pub(crate) async fn ast_grep_rewrite( ); if check_output.is_err() { - if can_use_literal_rewrite_fallback(pattern) { - if !source.contains(pattern) { - return Ok(AstGrepResult { - success: false, - file_path: rel_path.clone(), - pattern: pattern.to_string(), - rewrite: rewrite.to_string(), - dry_run, - diff: None, - message: "pattern not found (built-in literal fallback)".to_string(), - }); - } - let modified = source.replace(pattern, rewrite); - let diff = super::primitives::commit_or_preview_edit( - &rel_path, - &file, - &source_identity, - &source, - &modified, + if !can_use_literal_rewrite_fallback(pattern) { + return Ok(refused_ast_grep( + rel_path, + pattern, + rewrite, dry_run, - ) - .await?; - return Ok(AstGrepResult { - success: true, - file_path: rel_path, - pattern: pattern.to_string(), - rewrite: rewrite.to_string(), + "ast-grep is not installed and this pattern needs SGPattern matching. Simple literal rewrites are handled by the built-in fallback.", + )); + } + if !source.contains(pattern) { + return Ok(refused_ast_grep( + rel_path, + pattern, + rewrite, dry_run, - diff, - message: edit_success_message( - dry_run, - "literal rewrite completed using built-in fallback", - ), - }); + "pattern not found (built-in literal fallback)", + )); } + let modified = source.replace(pattern, rewrite); + let diff = super::primitives::commit_or_preview_edit( + &rel_path, + &file, + &source_identity, + &source, + &modified, + dry_run, + ) + .await?; return Ok(AstGrepResult { - success: false, - file_path: rel_path.clone(), + success: true, + file_path: rel_path, pattern: pattern.to_string(), rewrite: rewrite.to_string(), dry_run, - diff: None, - message: "ast-grep is not installed and this pattern needs SGPattern matching. Simple literal rewrites are handled by the built-in fallback.".to_string(), + diff, + message: edit_success_message( + dry_run, + "literal rewrite completed using built-in fallback", + ), }); } @@ -136,15 +128,9 @@ pub(crate) async fn ast_grep_rewrite( File: {rel_path}, pattern: {pattern:?}" ) }; - return Ok(AstGrepResult { - success: false, - file_path: rel_path.clone(), - pattern: pattern.to_string(), - rewrite: rewrite.to_string(), - dry_run, - diff: None, - message, - }); + return Ok(refused_ast_grep( + rel_path, pattern, rewrite, dry_run, message, + )); } let modified = reconstruct_ast_grep_rewrite(&source, &output.stdout)?; @@ -238,6 +224,24 @@ fn can_use_literal_rewrite_fallback(pattern: &str) -> bool { && !pattern.contains('\r') } +fn refused_ast_grep( + file_path: String, + pattern: &str, + rewrite: &str, + dry_run: bool, + message: impl Into, +) -> AstGrepResult { + AstGrepResult { + success: false, + file_path, + pattern: pattern.to_string(), + rewrite: rewrite.to_string(), + dry_run, + diff: None, + message: message.into(), + } +} + #[cfg(test)] mod tests { use super::reconstruct_ast_grep_rewrite; diff --git a/crates/tracedecay-source-edit/src/edits/primitives.rs b/crates/tracedecay-source-edit/src/edits/primitives.rs index cddccf322f..ccd27c1444 100644 --- a/crates/tracedecay-source-edit/src/edits/primitives.rs +++ b/crates/tracedecay-source-edit/src/edits/primitives.rs @@ -44,19 +44,33 @@ pub(crate) fn splice_lines>( joined } +fn refused_multi_edit(file_path: String, dry_run: bool, message: String) -> MultiEditResult { + MultiEditResult { + success: false, + file_path, + applied_count: 0, + dry_run, + diff: None, + message, + } +} + /// Resolves a path to a relative path string. /// If the path is already relative, validates that it stays in the project. /// If absolute, strips the `project_root` prefix. -pub(super) fn resolve_path(project_root: &Path, path: &str) -> Option { +pub(super) fn require_project_path(project_root: &Path, path: &str) -> Result { let path = Path::new(path); let relative = if path.is_absolute() { - path.strip_prefix(project_root).ok()? + path.strip_prefix(project_root).ok() } else { - path + Some(path) }; - normalize_source_edit_relative_path(relative) - .ok() + relative + .and_then(|relative| normalize_source_edit_relative_path(relative).ok()) .map(|path| path.to_string_lossy().replace('\\', "/")) + .ok_or_else(|| TraceDecayError::Config { + message: "path is not within the project".to_string(), + }) } /// Write-or-preview gate shared by every edit primitive. On a real run this @@ -105,9 +119,7 @@ pub(crate) async fn str_replace( new_str: &str, dry_run: bool, ) -> Result { - let rel_path = resolve_path(project_root, path).ok_or_else(|| TraceDecayError::Config { - message: "path is not within the project".to_string(), - })?; + let rel_path = require_project_path(project_root, path)?; let file = SourceEditFileAuthority::open(project_root, Path::new(&rel_path))?; let (source, source_identity) = file.read_to_string(path)?; @@ -177,9 +189,7 @@ pub(crate) async fn multi_str_replace( replacements: &[(&str, &str)], dry_run: bool, ) -> Result { - let rel_path = resolve_path(project_root, path).ok_or_else(|| TraceDecayError::Config { - message: "path is not within the project".to_string(), - })?; + let rel_path = require_project_path(project_root, path)?; let file = SourceEditFileAuthority::open(project_root, Path::new(&rel_path))?; let (source, source_identity) = file.read_to_string(path)?; @@ -194,17 +204,14 @@ pub(crate) async fn multi_str_replace( for (old, new) in replacements { let mut hits = source.match_indices(old); let Some((start, matched)) = hits.next() else { - return Ok(MultiEditResult { - success: false, - file_path: rel_path.clone(), - applied_count: 0, + return Ok(refused_multi_edit( + rel_path, dry_run, - diff: None, - message: format!( + format!( "replacement '{}' matches 0 times, must match exactly once", tracedecay_runtime_core::text::utf8_prefix_at_or_before(old, 20) ), - }); + )); }; if hits.next().is_some() { // Two matches already consumed from `hits`; the remainder it @@ -212,18 +219,14 @@ pub(crate) async fn multi_str_replace( // that count plus the two already seen, no need for a // redundant full-string `source.matches(old).count()` pass. let count = 2 + hits.count(); - return Ok(MultiEditResult { - success: false, - file_path: rel_path.clone(), - applied_count: 0, + return Ok(refused_multi_edit( + rel_path, dry_run, - diff: None, - message: format!( - "replacement '{}' matches {} times, must match exactly once", + format!( + "replacement '{}' matches {count} times, must match exactly once", tracedecay_runtime_core::text::utf8_prefix_at_or_before(old, 20), - count ), - }); + )); } spans.push((start, start + matched.len(), old, new)); } @@ -236,18 +239,15 @@ pub(crate) async fn multi_str_replace( let (_, prev_end, prev_old, _) = window[0]; let (next_start, _, next_old, _) = window[1]; if next_start < prev_end { - return Ok(MultiEditResult { - success: false, - file_path: rel_path.clone(), - applied_count: 0, + return Ok(refused_multi_edit( + rel_path, dry_run, - diff: None, - message: format!( + format!( "replacements '{}' and '{}' target overlapping ranges; apply them separately", tracedecay_runtime_core::text::utf8_prefix_at_or_before(prev_old, 20), tracedecay_runtime_core::text::utf8_prefix_at_or_before(next_old, 20) ), - }); + )); } } @@ -294,9 +294,7 @@ pub(crate) async fn insert_at( before: bool, dry_run: bool, ) -> Result { - let rel_path = resolve_path(project_root, path).ok_or_else(|| TraceDecayError::Config { - message: "path is not within the project".to_string(), - })?; + let rel_path = require_project_path(project_root, path)?; let file = SourceEditFileAuthority::open(project_root, Path::new(&rel_path))?; let (source, source_identity) = file.read_to_string(path)?; diff --git a/crates/tracedecay-source-edit/src/execute.rs b/crates/tracedecay-source-edit/src/execute.rs index bdf7efe4f8..c6511f86f3 100644 --- a/crates/tracedecay-source-edit/src/execute.rs +++ b/crates/tracedecay-source-edit/src/execute.rs @@ -244,6 +244,17 @@ where let durability = SourceEditDurability::for_graph(graph); let _lock = durability.lock()?; let input_digest = request.input_digest().map_err(application_contract_error)?; + let refuse = |authority: &tracedecay_contracts::SourceEditAuthorizationAdmissionV1, + state: PreEffectState| { + fail_pre_effect( + &durability, + operation, + &request, + authority, + &input_digest, + state, + ) + }; let requested_authority = tracedecay_contracts::SourceEditAuthorizationAdmissionV1::new( request.authority.clone(), request.proof.clone(), @@ -276,23 +287,15 @@ where { Ok(admission) => admission, Err(_) => { - return fail_pre_effect( - &durability, - operation, - &request, + return refuse( &requested_authority, - &input_digest, PreEffectState::unpreviewed(request.expected_state.clone()), ); } }; if admission.receipt != request.authority || admission.proof != request.proof { - return fail_pre_effect( - &durability, - operation, - &request, + return refuse( &requested_authority, - &input_digest, PreEffectState::unpreviewed(request.expected_state.clone()), ); } @@ -302,12 +305,8 @@ where { Ok(authority) if authority_still_matches(&authority, &request) => authority, Ok(_) => { - return fail_pre_effect( - &durability, - operation, - &request, + return refuse( &requested_authority, - &input_digest, PreEffectState::unpreviewed(request.expected_state.clone()), ); } @@ -317,12 +316,8 @@ where { return Err(application_problem(error)); } - return fail_pre_effect( - &durability, - operation, - &request, + return refuse( &admission, - &input_digest, PreEffectState::unpreviewed(request.expected_state.clone()), ); } @@ -386,12 +381,8 @@ where .expected_state .ok_or_else(|| config_error("successful source edit preview omitted expected state"))?; if !request.edit.dry_run() && current_state != request.expected_state { - return fail_pre_effect( - &durability, - operation, - &request, + return refuse( ¤t_authority, - &input_digest, PreEffectState { expected: request.expected_state.clone(), predicted: Some(predicted_state), @@ -430,12 +421,8 @@ where { Ok(authority) if authority_still_matches(&authority, &request) => authority, _ => { - return fail_pre_effect( - &durability, - operation, - &request, + return refuse( ¤t_authority, - &input_digest, PreEffectState { expected: current_state, predicted: Some(predicted_state), @@ -469,12 +456,8 @@ where { Ok(authority) if authority_still_matches(&authority, &request) => authority, _ => { - return fail_pre_effect( - &durability, - operation, - &request, + return refuse( ¤t_authority, - &input_digest, PreEffectState { expected: request.expected_state.clone(), predicted: Some(predicted_state), @@ -486,12 +469,8 @@ where let recaptured_state = match source_edit_state_digest(graph.project_root(), &candidate_files) { Ok(state) => state, Err(_) => { - return fail_pre_effect( - &durability, - operation, - &request, + return refuse( ¤t_authority, - &input_digest, PreEffectState { expected: request.expected_state.clone(), predicted: Some(predicted_state), @@ -501,12 +480,8 @@ where } }; if recaptured_state != request.expected_state { - return fail_pre_effect( - &durability, - operation, - &request, + return refuse( ¤t_authority, - &input_digest, PreEffectState { expected: request.expected_state.clone(), predicted: Some(predicted_state), diff --git a/crates/tracedecay-store-runtime/Cargo.toml b/crates/tracedecay-store-runtime/Cargo.toml index 66df53c455..d581c9bf45 100644 --- a/crates/tracedecay-store-runtime/Cargo.toml +++ b/crates/tracedecay-store-runtime/Cargo.toml @@ -81,6 +81,5 @@ tokio = { version = "1", features = ["full", "test-util"] } # the process worker plan instead of answering WorkerPlanNotInstalled. tracedecay-code-index = { path = "../tracedecay-code-index", version = "0.1.0", default-features = false, features = ["test-helpers"] } tracedecay-code-index-runtime = { path = "../tracedecay-code-index-runtime", version = "0.1.0", default-features = false, features = ["test-helpers"] } -tracedecay-graph-db = { path = "../tracedecay-graph-db", version = "0.1.0" } tracedecay-global-db = { path = "../tracedecay-global-db", version = "0.1.0", features = ["test-helpers"] } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0", features = ["test-helpers"] } diff --git a/crates/tracedecay-store-runtime/src/store_locator_resolver.rs b/crates/tracedecay-store-runtime/src/store_locator_resolver.rs index 60e91d10c3..9fe4c1c73f 100644 --- a/crates/tracedecay-store-runtime/src/store_locator_resolver.rs +++ b/crates/tracedecay-store-runtime/src/store_locator_resolver.rs @@ -1471,7 +1471,6 @@ fn is_known_local_filesystem(filesystem_type: &str) -> bool { #[cfg(test)] mod tests { - use std::fmt::Debug; use std::fs; use tempfile::TempDir; @@ -1481,13 +1480,7 @@ mod tests { use super::*; - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: Debug, - { - T::try_from(value.to_owned()).expect("canonical fixture identity") - } + use tracedecay_domain::test_fixtures::id; fn incarnation() -> StoreIncarnationV1 { StoreIncarnationV1::new(1).expect("non-zero fixture incarnation") diff --git a/crates/tracedecay-store/src/canonical_projection.rs b/crates/tracedecay-store/src/canonical_projection.rs index 0ee649de97..9751240815 100644 --- a/crates/tracedecay-store/src/canonical_projection.rs +++ b/crates/tracedecay-store/src/canonical_projection.rs @@ -2,8 +2,8 @@ use serde::Deserialize; use tracedecay_domain::{ - CanonicalGitEvidenceKindV1, CanonicalMessageRoleV1, CanonicalObservationEnvelopeV1, - CanonicalObservationFactV1, CanonicalReasoningVisibilityV1, CanonicalWorkflowEvidenceKindV1, + CanonicalGitEvidenceKindV1, CanonicalObservationEnvelopeV1, CanonicalObservationFactV1, + CanonicalReasoningVisibilityV1, CanonicalWorkflowEvidenceKindV1, CanonicalWorkflowSemanticKindV1, DurableObservationV1, ObservationContractError, ObservationScopeV1, }; @@ -406,7 +406,7 @@ fn canonical_message_metadata_for( rendering, envelope.provider().as_str(), envelope.native_record_kind(), - canonical_role(*role), + role.as_str(), content, envelope.relations().message_id() != Some(envelope.stable_record_id()), ) @@ -787,7 +787,7 @@ fn canonical_message_fields_for( .iter() .find(|fact| matches!(fact, CanonicalObservationFactV1::Message { .. })) { - let role = canonical_role(*role); + let role = role.as_str(); let text = canonical_fact_text(content)?; if let Some(semantics) = rendering_message_semantics( rendering, @@ -957,16 +957,6 @@ pub fn canonical_fact_text(value: &serde_json::Value) -> ProjectionStoreResult &'static str { - match role { - CanonicalMessageRoleV1::User => "user", - CanonicalMessageRoleV1::Assistant => "assistant", - CanonicalMessageRoleV1::System => "system", - CanonicalMessageRoleV1::Tool => "tool", - CanonicalMessageRoleV1::Unknown => "unknown", - } -} - fn reasoning_kind(visibility: CanonicalReasoningVisibilityV1) -> &'static str { match visibility { CanonicalReasoningVisibilityV1::Visible => "reasoning_visible", @@ -1013,11 +1003,11 @@ pub fn workflow_semantic_kind(kind: CanonicalWorkflowSemanticKindV1) -> &'static mod tests { use serde_json::json; use tracedecay_domain::{ - CanonicalBoundaryKindV1, CanonicalObservationEvidenceV1, CanonicalObservationRelationsV1, - ComponentVersion, ObservationId, ObservationIdentityMaterialV1, - ObservationOrderingDomainV1, ObservationSourceGenerationV1, ObservationSourceIdentityV1, - ObservationSourceRangeV1, PayloadReferenceV1, ProviderId, RetentionClass, - SanitizationReceiptId, SanitizationReceiptRefV1, SanitizationReceiptV1, + CanonicalBoundaryKindV1, CanonicalMessageRoleV1, CanonicalObservationEvidenceV1, + CanonicalObservationRelationsV1, ComponentVersion, ObservationId, + ObservationIdentityMaterialV1, ObservationOrderingDomainV1, ObservationSourceGenerationV1, + ObservationSourceIdentityV1, ObservationSourceRangeV1, PayloadReferenceV1, ProviderId, + RetentionClass, SanitizationReceiptId, SanitizationReceiptRefV1, SanitizationReceiptV1, SanitizerDispositionV1, SensitivityV1, SessionId, }; diff --git a/crates/tracedecay-store/src/memory/tests.rs b/crates/tracedecay-store/src/memory/tests.rs index 3f1673771b..bdb18c7e50 100644 --- a/crates/tracedecay-store/src/memory/tests.rs +++ b/crates/tracedecay-store/src/memory/tests.rs @@ -16,12 +16,7 @@ use super::*; mod add_material; -fn id(value: &str) -> T -where - T: TryFrom, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn fact_id(owner: FactOwnerV1, operation: &str) -> FactId { FactId::derive( diff --git a/crates/tracedecay-store/src/runtime/graph_publication/tests.rs b/crates/tracedecay-store/src/runtime/graph_publication/tests.rs index a77b556305..b767cbd607 100644 --- a/crates/tracedecay-store/src/runtime/graph_publication/tests.rs +++ b/crates/tracedecay-store/src/runtime/graph_publication/tests.rs @@ -13,9 +13,7 @@ fn projection(project: &str) -> GraphProjectionIdentityV1 { } } -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; #[test] fn replay_payload_and_digests_are_closed_and_validated() { diff --git a/crates/tracedecay-store/src/runtime/identity.rs b/crates/tracedecay-store/src/runtime/identity.rs index 44ab00934b..09f27ac3cd 100644 --- a/crates/tracedecay-store/src/runtime/identity.rs +++ b/crates/tracedecay-store/src/runtime/identity.rs @@ -537,13 +537,7 @@ mod tests { absolute_temp_root.join(name) } - fn id(value: &str) -> T - where - T: TryFrom, - >::Error: fmt::Debug, - { - T::try_from(value.to_owned()).expect("canonical fixture identity") - } + use tracedecay_domain::test_fixtures::id; #[test] fn profile_memory_has_a_distinct_mutable_wire_identity() { diff --git a/crates/tracedecay-store/tests/store_suite/configuration_contract.rs b/crates/tracedecay-store/tests/store_suite/configuration_contract.rs index f1961dd3aa..1767d8060e 100644 --- a/crates/tracedecay-store/tests/store_suite/configuration_contract.rs +++ b/crates/tracedecay-store/tests/store_suite/configuration_contract.rs @@ -2,22 +2,14 @@ use tracedecay_domain::configuration::{ ProtectedChangePlan, RedactedConfigurationChangeV1, RollbackModeV1, ScopeControlOperationV1, SettingKey, }; -use tracedecay_domain::{AccessPolicyDigest, ManifestDigest, UtcMicros}; +use tracedecay_domain::{AccessPolicyDigest, UtcMicros}; use tracedecay_store::configuration::{ ConfigurationProtectedOperationV1, ConfigurationProtectedPlanRecordV1, }; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture id is canonical") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; #[test] fn protected_plan_records_bind_the_redacted_plan_to_the_exact_operation() { diff --git a/crates/tracedecay-store/tests/store_suite/diagnostics_contract.rs b/crates/tracedecay-store/tests/store_suite/diagnostics_contract.rs index 4eecc6f9ce..2342cd64a0 100644 --- a/crates/tracedecay-store/tests/store_suite/diagnostics_contract.rs +++ b/crates/tracedecay-store/tests/store_suite/diagnostics_contract.rs @@ -6,17 +6,9 @@ use tracedecay_domain::{ }; use tracedecay_store::{DiagnosticStoreError, SanitizedCleanDiagnosticSnapshotV1}; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn fixture_record(generation: &str, anchor: &str) -> GenerationDiagnosticV1 { let mut record = GenerationDiagnosticV1 { diff --git a/crates/tracedecay-store/tests/store_suite/external_source_commit.rs b/crates/tracedecay-store/tests/store_suite/external_source_commit.rs index 21e9e31dbb..5d43976a59 100644 --- a/crates/tracedecay-store/tests/store_suite/external_source_commit.rs +++ b/crates/tracedecay-store/tests/store_suite/external_source_commit.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use tracedecay_domain::{ - AccessPolicyDigest, CapabilityId, ComponentVersion, LocatorDigest, ManifestDigest, + AccessPolicyDigest, CapabilityId, ComponentVersion, LocatorDigest, PrivacyDomainBoundLocatorDigest, PrivacyDomainId, ProjectId, ProviderId, ResolutionAuthorizationV1, RetrievalAnchorId, SanitizationReceiptId, SanitizationReceiptRefV1, ScopeResolutionId, SourceAcquisitionCapabilitiesV1, SourceAcquisitionContractV1, @@ -19,9 +19,7 @@ use tracedecay_store::{ apply_source_projection, build_source_projection, }; -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn definition() -> SourceDefinitionV1 { definition_with_max(4) diff --git a/crates/tracedecay-store/tests/store_suite/multi_root_cas_contract.rs b/crates/tracedecay-store/tests/store_suite/multi_root_cas_contract.rs index 4e08a5831d..4b769d2ebf 100644 --- a/crates/tracedecay-store/tests/store_suite/multi_root_cas_contract.rs +++ b/crates/tracedecay-store/tests/store_suite/multi_root_cas_contract.rs @@ -1,11 +1,9 @@ -use tracedecay_domain::{ManifestDigest, ScopeSetId, ScopeSetRevision}; +use tracedecay_domain::{ScopeSetId, ScopeSetRevision}; use tracedecay_store::runtime::{ AuthorizedScopeSetRecordV1, ScopeSetCompareAndSwapV1, ScopeSetStoreContractError, }; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn record(revision: u64) -> AuthorizedScopeSetRecordV1 { AuthorizedScopeSetRecordV1::new( diff --git a/crates/tracedecay-store/tests/store_suite/storage_runtime_contract.rs b/crates/tracedecay-store/tests/store_suite/storage_runtime_contract.rs index cd882c109b..240785f68c 100644 --- a/crates/tracedecay-store/tests/store_suite/storage_runtime_contract.rs +++ b/crates/tracedecay-store/tests/store_suite/storage_runtime_contract.rs @@ -13,13 +13,7 @@ use tracedecay_domain::{ }; use tracedecay_store::*; -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: Debug, -{ - T::try_from(value.to_owned()).expect("fixture id is canonical") -} +use tracedecay_domain::test_fixtures::id; fn digest(byte: char) -> CommandDigestV1 { CommandDigestV1::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap() diff --git a/crates/tracedecay-temporal-query/src/cursor.rs b/crates/tracedecay-temporal-query/src/cursor.rs index 1107e844b6..5bf069e64f 100644 --- a/crates/tracedecay-temporal-query/src/cursor.rs +++ b/crates/tracedecay-temporal-query/src/cursor.rs @@ -525,9 +525,7 @@ mod tests { } } - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn snapshot_for(session: &str, access: char, projection: u64) -> TemporalExecutionSnapshot { snapshot_for_key(session, access, projection, "key-1", 1) diff --git a/crates/tracedecay-temporal-query/src/hydration.rs b/crates/tracedecay-temporal-query/src/hydration.rs index c247fa624b..6e9c3bdb91 100644 --- a/crates/tracedecay-temporal-query/src/hydration.rs +++ b/crates/tracedecay-temporal-query/src/hydration.rs @@ -327,9 +327,7 @@ mod tests { serde_json::from_str(&format!("\"{value}\"")).expect("valid anchor") } - fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) - } + use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn snapshot() -> TemporalExecutionSnapshot { snapshot_with_limits(ExecutionLimits::default()) diff --git a/crates/tracedecay-temporal-query/src/ports/tests.rs b/crates/tracedecay-temporal-query/src/ports/tests.rs index 9a3a587d1e..82382e738f 100644 --- a/crates/tracedecay-temporal-query/src/ports/tests.rs +++ b/crates/tracedecay-temporal-query/src/ports/tests.rs @@ -23,9 +23,7 @@ fn session_id() -> SessionId { serde_json::from_str("\"session-1\"").expect("valid session id") } -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn participant(session: &str, source: &str, generation: u64) -> TemporalParticipantGeneration { TemporalParticipantGeneration::new( diff --git a/crates/tracedecay-temporal-query/src/ranking.rs b/crates/tracedecay-temporal-query/src/ranking.rs index 1f398d209d..4ae765df44 100644 --- a/crates/tracedecay-temporal-query/src/ranking.rs +++ b/crates/tracedecay-temporal-query/src/ranking.rs @@ -280,18 +280,10 @@ fn prepare_candidates( stable_id: candidate.stable_id.clone(), }); } - if existing.logical_message.is_none() { - existing.logical_message = candidate.logical_message.as_deref(); - } - if existing.turn.is_none() { - existing.turn = candidate.turn.as_deref(); - } - if existing.session.is_none() { - existing.session = candidate.session.as_deref(); - } - if existing.evidence_role.is_none() { - existing.evidence_role = candidate.evidence_role.as_deref(); - } + fill_absent(&mut existing.logical_message, &candidate.logical_message); + fill_absent(&mut existing.turn, &candidate.turn); + fill_absent(&mut existing.session, &candidate.session); + fill_absent(&mut existing.evidence_role, &candidate.evidence_role); } None => { metadata_by_id.insert( @@ -370,6 +362,12 @@ fn prepare_candidates( Ok(prepared) } +fn fill_absent<'a>(slot: &mut Option<&'a str>, value: &'a Option) { + if slot.is_none() { + *slot = value.as_deref(); + } +} + fn merged_metadata_conflicts(existing: &MergedMetadata<'_>, candidate: &RankingCandidate) -> bool { existing.first.anchor_id != candidate.anchor_id || existing.first.knowledge_at_micros != candidate.knowledge_at_micros @@ -429,45 +427,41 @@ const fn rank_tier(channel: CandidateChannel) -> RankTier { } fn encode_score(tier: RankTier, within_tier: u64) -> u64 { - let capped = if within_tier < TIER_SPAN { - within_tier - } else { - TIER_SPAN - 1 - }; (tier as u64) .saturating_mul(TIER_SPAN) - .saturating_add(capped) + .saturating_add(within_tier.min(TIER_SPAN - 1)) } fn apply_diversity(ranked: Vec, limits: DiversityLimits) -> Vec { - let mut logical_messages = BTreeMap::new(); - let mut turns = BTreeMap::new(); - let mut sessions = BTreeMap::new(); - let mut sources = BTreeMap::new(); - let mut evidence_roles = BTreeMap::new(); + let mut counts = [(); 5].map(|_| BTreeMap::new()); ranked .into_iter() .filter(|candidate| { - if at_limit( - &logical_messages, + let keys = [ candidate.logical_message.as_deref(), + candidate.turn.as_deref(), + candidate.session.as_deref(), + candidate.source.as_deref(), + candidate.evidence_role.as_deref(), + ]; + let caps = [ limits.per_logical_message, - ) || at_limit(&turns, candidate.turn.as_deref(), limits.per_turn) - || at_limit(&sessions, candidate.session.as_deref(), limits.per_session) - || at_limit(&sources, candidate.source.as_deref(), limits.per_source) - || at_limit( - &evidence_roles, - candidate.evidence_role.as_deref(), - limits.per_evidence_role, - ) + limits.per_turn, + limits.per_session, + limits.per_source, + limits.per_evidence_role, + ]; + if keys + .iter() + .zip(caps) + .enumerate() + .any(|(index, (key, cap))| at_limit(&counts[index], *key, cap)) { return false; } - increment(&mut logical_messages, candidate.logical_message.as_deref()); - increment(&mut turns, candidate.turn.as_deref()); - increment(&mut sessions, candidate.session.as_deref()); - increment(&mut sources, candidate.source.as_deref()); - increment(&mut evidence_roles, candidate.evidence_role.as_deref()); + for (index, key) in keys.into_iter().enumerate() { + increment(&mut counts[index], key); + } true }) .collect() diff --git a/crates/tracedecay-temporal-query/src/resolution.rs b/crates/tracedecay-temporal-query/src/resolution.rs index a35d685c4e..87b4c18ec1 100644 --- a/crates/tracedecay-temporal-query/src/resolution.rs +++ b/crates/tracedecay-temporal-query/src/resolution.rs @@ -12,7 +12,7 @@ pub use self::summary::{ evaluate_summary_lineage_eligibility, evaluate_summary_lineage_eligibility_controlled, }; pub use self::types::{ - ResolutionAssertion, ResolutionCertainty, ResolutionCheckpoint, ResolutionEvidence, - ResolutionInputError, ResolutionLineageEdge, ResolutionLineageEdgeKind, ResolutionOccurrence, - ResolvedOccurrence, TemporalResolution, ValidatedAuthorization, + ResolutionAssertion, ResolutionCheckpoint, ResolutionEvidence, ResolutionInputError, + ResolutionLineageEdge, ResolutionLineageEdgeKind, ResolutionOccurrence, ResolvedOccurrence, + TemporalResolution, ValidatedAuthorization, }; diff --git a/crates/tracedecay-temporal-query/src/resolution/types.rs b/crates/tracedecay-temporal-query/src/resolution/types.rs index 46de986132..3135cdad76 100644 --- a/crates/tracedecay-temporal-query/src/resolution/types.rs +++ b/crates/tracedecay-temporal-query/src/resolution/types.rs @@ -100,23 +100,6 @@ pub struct ResolvedOccurrence { pub supporting_anchor_ids: BTreeSet, } -impl ResolvedOccurrence { - #[hotpath::skip] - pub const fn certainty(&self) -> ResolutionCertainty { - if self.uncertain { - ResolutionCertainty::AuthorizedUnknown - } else { - ResolutionCertainty::Known - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ResolutionCertainty { - Known, - AuthorizedUnknown, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum ResolutionLineageEdgeKind { Correction, diff --git a/crates/tracedecay-temporal-query/src/tests.rs b/crates/tracedecay-temporal-query/src/tests.rs index ddf20a4d79..01ba6b2c06 100644 --- a/crates/tracedecay-temporal-query/src/tests.rs +++ b/crates/tracedecay-temporal-query/src/tests.rs @@ -229,9 +229,7 @@ impl VersionedTokenEstimator for Words { } } -fn digest(byte: char) -> String { - format!("sha256:{}", byte.to_string().repeat(64)) -} +use tracedecay_domain::test_fixtures::repeated_sha256_text as digest; fn anchor(value: &str) -> RetrievalAnchorId { RetrievalAnchorId::new(value).expect("valid anchor") diff --git a/crates/tracedecay-tool-catalog/src/executable.rs b/crates/tracedecay-tool-catalog/src/executable.rs index d5d4c23fa1..164b7d938c 100644 --- a/crates/tracedecay-tool-catalog/src/executable.rs +++ b/crates/tracedecay-tool-catalog/src/executable.rs @@ -364,6 +364,19 @@ impl ExecutableBindingV1 { &self.exposure } + /// The catalog route when this binding is public. Internal composition + /// returns `None` so a missing route and a private binding stay indistinguishable + /// to adapters. + pub fn public_route(&self) -> Option<(&BindingId, &str)> { + match &self.exposure { + RouteExposureV1::Public { + binding_id, + route_path, + } => Some((binding_id, route_path.as_str())), + RouteExposureV1::Internal => None, + } + } + pub const fn effect(&self) -> EffectClass { self.effect } @@ -547,27 +560,28 @@ impl SdkExecutableBindingV1 { transport: SdkTransportBindingV1, ) -> Result { transport.validate()?; - match (&transport, executable.exposure()) { - ( - SdkTransportBindingV1::Http { route_path }, - RouteExposureV1::Public { - binding_id: executable_binding_id, - route_path: executable_route_path, - }, - ) if binding_id == *executable_binding_id && route_path == executable_route_path => {} - (SdkTransportBindingV1::Http { .. }, _) => { - return Err(CatalogValidationError::InvalidValue { - field: "SDK HTTP binding", - reason: "must exactly match the executable public route", - }); + match &transport { + SdkTransportBindingV1::Http { route_path } => { + let matches_public = executable.public_route().is_some_and( + |(executable_binding_id, executable_route_path)| { + &binding_id == executable_binding_id + && route_path.as_str() == executable_route_path + }, + ); + if !matches_public { + return Err(CatalogValidationError::InvalidValue { + field: "SDK HTTP binding", + reason: "must exactly match the executable public route", + }); + } } - (SdkTransportBindingV1::McpTool { .. }, RouteExposureV1::Internal) => {} - (SdkTransportBindingV1::McpTool { .. }, RouteExposureV1::Public { .. }) => { + SdkTransportBindingV1::McpTool { .. } if executable.public_route().is_some() => { return Err(CatalogValidationError::InvalidValue { field: "SDK MCP binding", reason: "must not alias an HTTP executable route", }); } + SdkTransportBindingV1::McpTool { .. } => {} } Ok(Self { executable, diff --git a/crates/tracedecay-tool-catalog/src/hotpath_observe.rs b/crates/tracedecay-tool-catalog/src/hotpath_observe.rs index 2c9bf43722..f0000d280c 100644 --- a/crates/tracedecay-tool-catalog/src/hotpath_observe.rs +++ b/crates/tracedecay-tool-catalog/src/hotpath_observe.rs @@ -6,68 +6,62 @@ /// Size of a successfully assembled MCP dispatch catalog. #[inline] -#[cfg(feature = "hotpath")] pub(crate) fn mcp_catalog_entries(entries: usize) { + #[cfg(feature = "hotpath")] hotpath::gauge!("tool_catalog.mcp.entries").set(entries as f64); + #[cfg(not(feature = "hotpath"))] + let _ = entries; } -#[inline] -#[cfg(not(feature = "hotpath"))] -pub(crate) fn mcp_catalog_entries(_entries: usize) {} - /// Per-dispatch contract lookup outcome; a miss is recorded, never silent. #[inline] -#[cfg(feature = "hotpath")] pub(crate) fn mcp_contract_lookup(hit: bool) { + #[cfg(feature = "hotpath")] if hit { hotpath::gauge!("tool_catalog.mcp.lookup_hits").inc(1.0); } else { hotpath::gauge!("tool_catalog.mcp.lookup_misses").inc(1.0); } + #[cfg(not(feature = "hotpath"))] + let _ = hit; } -#[inline] -#[cfg(not(feature = "hotpath"))] -pub(crate) fn mcp_contract_lookup(_hit: bool) {} - /// Sizes of a successfully built catalog snapshot. #[inline] -#[cfg(feature = "hotpath")] pub(crate) fn snapshot_entries(capabilities: usize, bindings: usize, profiles: usize) { - hotpath::gauge!("tool_catalog.snapshot.capabilities").set(capabilities as f64); - hotpath::gauge!("tool_catalog.snapshot.bindings").set(bindings as f64); - hotpath::gauge!("tool_catalog.snapshot.profiles").set(profiles as f64); + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("tool_catalog.snapshot.capabilities").set(capabilities as f64); + hotpath::gauge!("tool_catalog.snapshot.bindings").set(bindings as f64); + hotpath::gauge!("tool_catalog.snapshot.profiles").set(profiles as f64); + } + #[cfg(not(feature = "hotpath"))] + { + let _ = (capabilities, bindings, profiles); + } } -#[inline] -#[cfg(not(feature = "hotpath"))] -pub(crate) fn snapshot_entries(_capabilities: usize, _bindings: usize, _profiles: usize) {} - /// Binding resolution outcome. A miss deliberately covers unknown, /// unavailable, feature-incompatible, profile-hidden, and /// protocol-incompatible entries alike, mirroring the public contract. #[inline] -#[cfg(feature = "hotpath")] pub(crate) fn binding_resolution(resolved: bool) { + #[cfg(feature = "hotpath")] if resolved { hotpath::gauge!("tool_catalog.resolve.hits").inc(1.0); } else { hotpath::gauge!("tool_catalog.resolve.misses").inc(1.0); } + #[cfg(not(feature = "hotpath"))] + let _ = resolved; } -#[inline] -#[cfg(not(feature = "hotpath"))] -pub(crate) fn binding_resolution(_resolved: bool) {} - /// Number of bindings published by the last discovery listing, including /// empty listings for hidden or disabled surfaces. #[inline] -#[cfg(feature = "hotpath")] pub(crate) fn visible_bindings_published(count: usize) { + #[cfg(feature = "hotpath")] hotpath::gauge!("tool_catalog.discovery.visible_bindings").set(count as f64); + #[cfg(not(feature = "hotpath"))] + let _ = count; } - -#[inline] -#[cfg(not(feature = "hotpath"))] -pub(crate) fn visible_bindings_published(_count: usize) {} diff --git a/crates/tracedecay/Cargo.toml b/crates/tracedecay/Cargo.toml index a6616a03e4..da35d4ac8b 100644 --- a/crates/tracedecay/Cargo.toml +++ b/crates/tracedecay/Cargo.toml @@ -203,6 +203,7 @@ test-helpers = [ "tracedecay-session-runtime/test-helpers", "tracedecay-sessions/test-helpers", "tracedecay-store-runtime/test-helpers", + "dep:tracedecay-temporal-query", ] test-transport = [ @@ -313,10 +314,11 @@ tracedecay-session-memory = { path = "../tracedecay-session-memory", version = " tracedecay-session-runtime = { path = "../tracedecay-session-runtime", version = "0.1.0" } tracedecay-store-runtime = { path = "../tracedecay-store-runtime", version = "0.1.0" } tracedecay-session-temporal-store = { path = "../tracedecay-session-temporal-store", version = "0.1.0" } +# Only the `test-helpers` session-temporal bench fixture names this crate. +tracedecay-temporal-query = { path = "../tracedecay-temporal-query", version = "0.1.0", optional = true } tracedecay-lcm = { path = "../tracedecay-lcm", version = "0.1.0" } tracedecay-sessions = { path = "../tracedecay-sessions", version = "0.1.0" } tracedecay-source-edit = { path = "../tracedecay-source-edit", version = "0.1.0" } -tracedecay-temporal-query = { path = "../tracedecay-temporal-query", version = "0.1.0" } tracedecay-rusqlite-runtime = { path = "../tracedecay-rusqlite-runtime", version = "0.1.0" } rusqlite = { version = "0.40.1", default-features = false, features = ["backup"] } tracedecay-store = { path = "../tracedecay-store", version = "0.1.0" } @@ -324,25 +326,15 @@ tracedecay-tool-catalog = { path = "../tracedecay-tool-catalog", version = "0.1. tracedecay-application = { path = "../tracedecay-application", version = "0.1.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" -toml = "1" ureq = { version = "3", features = ["json"] } url = "2" tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1", features = ["sync"] } -thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] } sha2 = "0.11" -glob = "0.3" walkdir = "2" -ignore = "0.4" gix.workspace = true -dirs = "6" -hex = "0.4" -# Raw filesystem watcher for the daemon git-metadata watcher (src/daemon/git_watch.rs). -# We deliberately use the raw watcher (not notify-debouncer-full) and debounce -# ourselves so a monorepo's git-ref churn coalesces into a single sync. -notify = "6" zeroize = "1.9.0" keyring = { version = "4.1.5", features = ["v1"] } tempfile = "3" @@ -354,21 +346,21 @@ rmcp = { version = "3.0.1", default-features = false, features = ["server"] } tracedecay-search-eval = { path = "../tracedecay-search-eval", version = "0.1.0", optional = true } # `kill(2)` for the daemon integration suites' physical-restart journeys -# (tests/daemon_suite). Store-locality detection moved with the locator -# resolver to tracedecay-store-runtime. -[target.'cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))'.dependencies] +# (tests/daemon_suite). Store-locality detection lives in tracedecay-store-runtime. +[target.'cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))'.dev-dependencies] libc = "0.2" [dev-dependencies] tree-sitter = "0.26" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } -sha2 = "0.11" +glob = "0.3" hex = "0.4" +toml = "1" regex = "1.12.3" filetime = "0.2" rmcp = { version = "3.0.1", default-features = false, features = ["client", "server", "transport-async-rw"] } -criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } +criterion = { version = "0.5", features = ["async_tokio"] } # test-util enables tokio::test(start_paused = true) so timer-driven unit # tests (daemon restart-grace windows) run on virtual time instead of real # sleeps. diff --git a/crates/tracedecay/src/daemon/branch_admin/project_retirement.rs b/crates/tracedecay/src/daemon/branch_admin/project_retirement.rs index bf33c8f44c..46fd8d9c5b 100644 --- a/crates/tracedecay/src/daemon/branch_admin/project_retirement.rs +++ b/crates/tracedecay/src/daemon/branch_admin/project_retirement.rs @@ -313,6 +313,30 @@ pub(super) async fn track_aborted_retirement_task( track_project_server_retirement(retirements, owner, task, true).await; } +async fn shutdown_receipt( + completions: Vec<( + String, + tokio::sync::watch::Receiver, + )>, + deadline: tokio::time::Instant, +) -> ShutdownTaskReceipt { + let mut receipt = ShutdownTaskReceipt::default(); + for (owner, completion) in completions { + let status = + match tokio::time::timeout_at(deadline, wait_for_project_server_retirement(completion)) + .await + { + Ok(ProjectServerRetirementStatus::Clean) => ShutdownTaskStatus::Clean, + Ok(ProjectServerRetirementStatus::Failed(error)) => { + ShutdownTaskStatus::Failed(error) + } + Ok(ProjectServerRetirementStatus::Pending) | Err(_) => ShutdownTaskStatus::TimedOut, + }; + receipt.outcomes.push(ShutdownTaskOutcome { owner, status }); + } + receipt +} + pub(super) async fn settle_project_retirements( retirements: &tokio::sync::Mutex>, profile_root: &std::path::Path, @@ -334,21 +358,7 @@ pub(super) async fn settle_project_retirements( ) }) .collect::>(); - let mut receipt = ShutdownTaskReceipt::default(); - for (owner, completion) in completions { - let status = - match tokio::time::timeout_at(deadline, wait_for_project_server_retirement(completion)) - .await - { - Ok(ProjectServerRetirementStatus::Clean) => ShutdownTaskStatus::Clean, - Ok(ProjectServerRetirementStatus::Failed(error)) => { - ShutdownTaskStatus::Failed(error) - } - Ok(ProjectServerRetirementStatus::Pending) => ShutdownTaskStatus::TimedOut, - Err(_) => ShutdownTaskStatus::TimedOut, - }; - receipt.outcomes.push(ShutdownTaskOutcome { owner, status }); - } + let receipt = shutdown_receipt(completions, deadline).await; retirements.lock().await.retain(|retirement| { !matches!( &*retirement.completion.borrow(), @@ -449,23 +459,7 @@ impl StoreAdministration { return ShutdownTaskReceipt::timed_out("project_server_retirement_registry"); } }; - let mut receipt = ShutdownTaskReceipt::default(); - for (owner, completion) in completions { - let status = match tokio::time::timeout_at( - deadline, - wait_for_project_server_retirement(completion), - ) - .await - { - Ok(ProjectServerRetirementStatus::Clean) => ShutdownTaskStatus::Clean, - Ok(ProjectServerRetirementStatus::Failed(error)) => { - ShutdownTaskStatus::Failed(error) - } - Ok(ProjectServerRetirementStatus::Pending) => ShutdownTaskStatus::TimedOut, - Err(_) => ShutdownTaskStatus::TimedOut, - }; - receipt.outcomes.push(ShutdownTaskOutcome { owner, status }); - } + let receipt = shutdown_receipt(completions, deadline).await; if let Ok(mut retirements) = tokio::time::timeout_at(deadline, self.project_server_retirements.lock()).await { diff --git a/crates/tracedecay/src/daemon/connection_serving.rs b/crates/tracedecay/src/daemon/connection_serving.rs index 4d3d087f86..18ad9346c3 100644 --- a/crates/tracedecay/src/daemon/connection_serving.rs +++ b/crates/tracedecay/src/daemon/connection_serving.rs @@ -7,7 +7,7 @@ use super::*; use tracedecay_daemon_protocol::DaemonInvocationPayload; use tracedecay_daemon_service::ProfileHostAdmissionBootstrapStatus; -use tracedecay_daemon_service::{DaemonInvocationService, Lease}; +use tracedecay_daemon_service::{DaemonInvocationService, DaemonLspSessionAccess, Lease}; use tracedecay_mcp::BrokerSelectedResponseLease; use tracedecay_runtime_core::logging::log_daemon_event; use tracedecay_session_memory::context::CancellationToken; @@ -478,14 +478,14 @@ impl DaemonWorkDeliveryDescriptorV1 { | WorkApplicationOutcomeV1::CancelAttempt(outcome), .. }, - ) => application_outcome_payload(outcome).is_some(), + ) => outcome.payload().is_some(), ( DaemonWorkDeliveryKindV1::ArtifactPage, DaemonInvocationOutcome::WorkApplication { outcome: WorkApplicationOutcomeV1::HydrateArtifacts(outcome), .. }, - ) => application_outcome_payload(outcome).is_some_and(|hydration| { + ) => outcome.payload().is_some_and(|hydration| { matches!( hydration, tracedecay_contracts::WorkArtifactHydrationV1::Hydrated { attempts, .. } @@ -566,7 +566,7 @@ impl DaemonWorkDeliveryDescriptorV1 { return Vec::new(); }; let Some(tracedecay_contracts::WorkArtifactHydrationV1::Hydrated { attempts, .. }) = - application_outcome_payload(outcome) + outcome.payload() else { return Vec::new(); }; @@ -577,16 +577,6 @@ impl DaemonWorkDeliveryDescriptorV1 { } } -fn application_outcome_payload( - outcome: &tracedecay_contracts::ApplicationOutcome, -) -> Option<&T> { - match outcome { - tracedecay_contracts::ApplicationOutcome::Evidence(result) => result.payload.as_ref(), - tracedecay_contracts::ApplicationOutcome::Preview(result) => result.payload.as_ref(), - tracedecay_contracts::ApplicationOutcome::Effect(result) => result.payload.as_ref(), - } -} - fn offer_daemon_work_delivery( recorder: Option< &Arc, @@ -803,227 +793,237 @@ async fn serve_broker_socket_client( serve_broker_socket_client_inner(stream, engine, auth_token, admission_class).await } -/// LSP sessions this connection owns are cleaned up exactly once, on every -/// exit path of the invocation loop. -#[cfg(unix)] +/// Drive one retained daemon-invocation connection: write each response, +/// settle its delivery ACK, and keep reading until the peer or the daemon +/// stops the connection. +/// +/// Unix and portable brokers differ only in how a request becomes a response. +/// The ACK state machine is one path so a dropped write, a missed deadline, +/// and a mismatched ACK id settle the same attempts on both. #[expect( clippy::too_many_lines, - reason = "LSP sessions this connection owns are cleaned up exactly once, on every exit path of the invocation loop." + reason = "Delivery ACK settlement is one state machine shared by the Unix and portable brokers." )] -async fn serve_retained_invocation_connection( +async fn drive_retained_invocation_responses<'a>( mut invocation: std::result::Result, - mut transport: BrokerStreamTransport, - engine: DaemonEngine, - handshake: DaemonHandshake, + transport: &mut BrokerStreamTransport, + service: &DaemonInvocationService, + handshake: &DaemonHandshake, + lifecycle: &DaemonLifecycle, + owned_lsp_sessions: &mut HashMap, + mut execute: impl FnMut( + DaemonInvocationRequest, + ) -> std::pin::Pin< + Box + Send + 'a>, + >, ) -> Result<()> { - let mut owned_lsp_sessions = HashMap::new(); let mut pending_line = None; - // Keep the retained invocation loop out of the broker connection - // future's inline state. With Hotpath enabled the surrounding - // transport wrapper is polled on Tokio's ordinary worker stack; - // embedding this loop there makes construction alone exceed that - // stack before the first request can be served. - let result = boxed_broker_connection_phase(async { - loop { - let delivery = invocation.as_ref().ok().and_then(|request| { - DaemonWorkDeliveryDescriptorV1::from_request(request, &handshake) - }); - let request_id = invocation - .as_ref() - .ok() - .map(|request| request.request_id.clone()); - let ack_deadline = invocation - .as_ref() - .ok() - .and_then(|request| request.delivery_ack_deadline()) - .cloned(); - let session_transition = invocation - .as_ref() - .ok() - .and_then(invocation_lsp_session_transition); - let response = match invocation { - Ok(request) => { - Box::pin(execute_daemon_invocation(&engine, &handshake, request)).await - } - Err(response) => response, - }; - update_connection_lsp_sessions( - &mut owned_lsp_sessions, - session_transition.as_ref(), - &response, - ); - let delivery = - delivery.filter(|delivery| delivery.is_successful_delivery(&response)); - // Resolve fan-out bindings before the socket response crosses - // the wire. The same immutable attempts are used for a - // Delivered or Dropped ACK; no mutable Work lookup occurs at - // terminal-ACK time. - let delivery_attempts = if let Some(delivery) = delivery { - Some( - delivery - .attempts( - &engine.invocation.service, - handshake.project_path.as_deref(), - &response, - ) - .await, - ) - } else { - None - }; - let write_result = - write_daemon_invocation_response(&mut transport, &response).await; - if let Err(error) = write_result { - let recorder = engine - .invocation - .service - .delivery_settlement_recorder(handshake.project_path.as_deref()) - .await; + loop { + let delivery = invocation + .as_ref() + .ok() + .and_then(|request| DaemonWorkDeliveryDescriptorV1::from_request(request, handshake)); + let request_id = invocation + .as_ref() + .ok() + .map(|request| request.request_id.clone()); + let ack_deadline = invocation + .as_ref() + .ok() + .and_then(|request| request.delivery_ack_deadline()) + .cloned(); + let session_transition = invocation + .as_ref() + .ok() + .and_then(invocation_lsp_session_transition); + let response = match invocation { + Ok(request) => execute(request).await, + Err(response) => response, + }; + update_connection_lsp_sessions(owned_lsp_sessions, session_transition.as_ref(), &response); + let delivery = delivery.filter(|delivery| delivery.is_successful_delivery(&response)); + // Resolve fan-out bindings before the socket response crosses + // the wire. The same immutable attempts are used for a + // Delivered or Dropped ACK; no mutable Work lookup occurs at + // terminal-ACK time. + let delivery_attempts = if let Some(delivery) = delivery { + Some( + delivery + .attempts(service, handshake.project_path.as_deref(), &response) + .await, + ) + } else { + None + }; + let write_result = write_daemon_invocation_response(transport, &response).await; + if let Err(error) = write_result { + let recorder = service + .delivery_settlement_recorder(handshake.project_path.as_deref()) + .await; + let _ = settle_daemon_work_delivery( + delivery_attempts.as_deref(), + recorder.as_ref(), + tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, + Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), + ); + return Err(error); + } + if delivery_attempts.is_some() { + let recorder = service + .delivery_settlement_recorder(handshake.project_path.as_deref()) + .await; + let ack_timeout = ack_deadline + .as_ref() + .and_then(tracedecay_daemon_protocol::deadline_remaining); + let Some(ack_timeout) = ack_timeout else { + let _ = settle_daemon_work_delivery( + delivery_attempts.as_deref(), + recorder.as_ref(), + tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, + Some(tracedecay_domain::DeliveryDropReasonV1::Deadline), + ); + return Ok(()); + }; + let delivery_cancellation = request_id + .as_deref() + .and_then(|request_id| service.request_cancellations().register(request_id)); + let cancellation = delivery_cancellation.as_ref().map(Lease::token); + let ack_line = match await_daemon_delivery_ack( + transport, + ack_timeout, + cancellation, + lifecycle.wait_for_draining(), + ) + .await + { + Ok(wait) => match classify_daemon_delivery_ack_wait(wait) { + Ok(line) => line, + Err(reason) => { let _ = settle_daemon_work_delivery( delivery_attempts.as_deref(), recorder.as_ref(), tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), + Some(reason), ); - return Err(error); + return Ok(()); } - if delivery_attempts.is_some() { - let recorder = engine - .invocation - .service - .delivery_settlement_recorder(handshake.project_path.as_deref()) - .await; - let ack_timeout = ack_deadline - .as_ref() - .and_then(tracedecay_daemon_protocol::deadline_remaining); - let Some(ack_timeout) = ack_timeout else { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Deadline), - ); - return Ok(()); - }; - let delivery_cancellation = request_id.as_deref().and_then(|request_id| { - engine - .invocation - .service - .request_cancellations() - .register(request_id) - }); - let cancellation = delivery_cancellation - .as_ref() - .map(Lease::token); - let ack_line = match await_daemon_delivery_ack( - &mut transport, - ack_timeout, - cancellation, - engine.lifecycle.wait_for_draining(), - ) - .await - { - Ok(wait) => match classify_daemon_delivery_ack_wait(wait) { - Ok(line) => line, - Err(reason) => { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(reason), - ); - return Ok(()); - } - }, - Err(error) => { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), - ); - return Err(error); - } - }; - match ack_line { - Some(line) => { - let ack = tracedecay_daemon_protocol::parse_daemon_invocation_delivery_ack_request( - &line, - ); - if let Some(ack) = ack.filter(|ack| { - request_id - .as_deref() - .is_some_and(|request_id| ack.target_request_id() == request_id) - }) { - let target_request_id = ack.target_request_id().to_owned(); - let (outcome, drop_reason) = ack.outcome(); - let settlement_result = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - outcome, - drop_reason, - ); - let ack_response = match &settlement_result { - Ok(()) => { - tracedecay_daemon_protocol::DaemonInvocationDeliveryAckResponse::accepted( - target_request_id.clone(), - ) - } - Err(reason) => { - tracedecay_daemon_protocol::DaemonInvocationDeliveryAckResponse::rejected( - target_request_id.clone(), - *reason, - ) - } - }; - write_daemon_delivery_ack_response(&mut transport, &ack_response) - .await?; - if let Err(reason) = settlement_result { - return Err(TraceDecayError::Config { - message: format!( - "daemon could not durably record Work delivery ACK: {reason:?}" - ), - }); - } - } else { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Invalid), - ); - pending_line = Some(line); - } + }, + Err(error) => { + let _ = settle_daemon_work_delivery( + delivery_attempts.as_deref(), + recorder.as_ref(), + tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, + Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), + ); + return Err(error); + } + }; + match ack_line { + Some(line) => { + let ack = + tracedecay_daemon_protocol::parse_daemon_invocation_delivery_ack_request( + &line, + ); + if let Some(ack) = ack.filter(|ack| { + request_id + .as_deref() + .is_some_and(|request_id| ack.target_request_id() == request_id) + }) { + let target_request_id = ack.target_request_id().to_owned(); + let (outcome, drop_reason) = ack.outcome(); + let settlement_result = settle_daemon_work_delivery( + delivery_attempts.as_deref(), + recorder.as_ref(), + outcome, + drop_reason, + ); + let ack_response = match &settlement_result { + Ok(()) => { + tracedecay_daemon_protocol::DaemonInvocationDeliveryAckResponse::accepted( + target_request_id.clone(), + ) } - None => { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), - ); - return Ok(()) + Err(reason) => { + tracedecay_daemon_protocol::DaemonInvocationDeliveryAckResponse::rejected( + target_request_id.clone(), + *reason, + ) } + }; + write_daemon_delivery_ack_response(transport, &ack_response).await?; + if let Err(reason) = settlement_result { + return Err(TraceDecayError::Config { + message: format!( + "daemon could not durably record Work delivery ACK: {reason:?}" + ), + }); } - } - let next_line = if let Some(line) = pending_line.take() { - Some(line) } else { - tokio::select! { - result = read_line_handling_wire_oversized(&mut transport) => result?, - () = engine.lifecycle.wait_for_draining() => return Ok(()), - } - }; - let Some(next_line) = next_line else { - return Ok(()); - }; - let Some(next_invocation) = parse_daemon_invocation_request(&next_line) else { - return Ok(()); - }; - invocation = next_invocation; + let _ = settle_daemon_work_delivery( + delivery_attempts.as_deref(), + recorder.as_ref(), + tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, + Some(tracedecay_domain::DeliveryDropReasonV1::Invalid), + ); + pending_line = Some(line); + } } - }) - .await; + None => { + let _ = settle_daemon_work_delivery( + delivery_attempts.as_deref(), + recorder.as_ref(), + tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, + Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), + ); + return Ok(()); + } + } + } + let next_line = if let Some(line) = pending_line.take() { + Some(line) + } else { + tokio::select! { + result = read_line_handling_wire_oversized(transport) => result?, + () = lifecycle.wait_for_draining() => return Ok(()), + } + }; + let Some(next_line) = next_line else { + return Ok(()); + }; + let Some(next_invocation) = parse_daemon_invocation_request(&next_line) else { + return Ok(()); + }; + invocation = next_invocation; + } +} + +/// LSP sessions this connection owns are cleaned up exactly once, on every +/// exit path of the invocation loop. +#[cfg(unix)] +async fn serve_retained_invocation_connection( + invocation: std::result::Result, + mut transport: BrokerStreamTransport, + engine: DaemonEngine, + handshake: DaemonHandshake, +) -> Result<()> { + let mut owned_lsp_sessions = HashMap::new(); + let service = engine.invocation.service.clone(); + let lifecycle = engine.lifecycle.clone(); + // Keep the retained invocation loop out of the broker connection + // future's inline state. With Hotpath enabled the surrounding + // transport wrapper is polled on Tokio's ordinary worker stack; + // embedding this loop there makes construction alone exceed that + // stack before the first request can be served. + let result = boxed_broker_connection_phase(drive_retained_invocation_responses( + invocation, + &mut transport, + &service, + &handshake, + &lifecycle, + &mut owned_lsp_sessions, + |request| Box::pin(execute_daemon_invocation(&engine, &handshake, request)), + )) + .await; cleanup_connection_lsp_sessions(&engine.invocation, owned_lsp_sessions).await; result } @@ -1811,217 +1811,30 @@ pub(super) async fn serve_windows_broker_client_with_class_and_invocation( return Ok(()); } if let Some(invocation_request) = parse_daemon_invocation_request(first_request.raw()) { - let mut invocation_request = invocation_request; let mut owned_lsp_sessions = HashMap::new(); - let mut pending_line = None; - let result = async { - loop { - let delivery = invocation_request.as_ref().ok().and_then(|request| { - DaemonWorkDeliveryDescriptorV1::from_request(request, &handshake) - }); - let request_id = invocation_request - .as_ref() - .ok() - .map(|request| request.request_id.clone()); - let ack_deadline = invocation_request - .as_ref() - .ok() - .and_then(|request| request.delivery_ack_deadline()) - .cloned(); - let session_transition = invocation_request - .as_ref() - .ok() - .and_then(invocation_lsp_session_transition); - let response = match invocation_request { - Ok(request) => { - Box::pin(execute_portable_daemon_invocation( - lifecycle.clone(), - store_administration.clone(), - Arc::clone(&project_open_gates), - &handshake, - &invocation, - http_application_registry.clone(), - request, - #[cfg(test)] - project_open_attempts.clone(), - )) - .await - } - Err(response) => response, - }; - update_connection_lsp_sessions( - &mut owned_lsp_sessions, - session_transition.as_ref(), - &response, - ); - let delivery = - delivery.filter(|delivery| delivery.is_successful_delivery(&response)); - // Resolve fan-out bindings before the socket response crosses - // the wire. The same immutable attempts are used for a - // Delivered or Dropped ACK; no mutable Work lookup occurs at - // terminal-ACK time. - let delivery_attempts = if let Some(delivery) = delivery { - Some( - delivery - .attempts( - &invocation.service, - handshake.project_path.as_deref(), - &response, - ) - .await, - ) - } else { - None - }; - let write_result = - write_daemon_invocation_response(&mut transport, &response).await; - if let Err(error) = write_result { - let recorder = invocation - .service - .delivery_settlement_recorder(handshake.project_path.as_deref()) - .await; - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), - ); - return Err(error); - } - if delivery_attempts.is_some() { - let recorder = invocation - .service - .delivery_settlement_recorder(handshake.project_path.as_deref()) - .await; - let ack_timeout = ack_deadline - .as_ref() - .and_then(tracedecay_daemon_protocol::deadline_remaining); - let Some(ack_timeout) = ack_timeout else { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Deadline), - ); - return Ok(()); - }; - let delivery_cancellation = request_id.as_deref().and_then(|request_id| { - invocation - .service - .request_cancellations() - .register(request_id) - }); - let cancellation = delivery_cancellation - .as_ref() - .map(Lease::token); - let ack_line = match await_daemon_delivery_ack( - &mut transport, - ack_timeout, - cancellation, - lifecycle.wait_for_draining(), - ) - .await - { - Ok(wait) => match classify_daemon_delivery_ack_wait(wait) { - Ok(line) => line, - Err(reason) => { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(reason), - ); - return Ok(()); - } - }, - Err(error) => { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), - ); - return Err(error); - } - }; - match ack_line { - Some(line) => { - let ack = tracedecay_daemon_protocol::parse_daemon_invocation_delivery_ack_request( - &line, - ); - if let Some(ack) = ack.filter(|ack| { - request_id - .as_deref() - .is_some_and(|request_id| ack.target_request_id() == request_id) - }) { - let target_request_id = ack.target_request_id().to_owned(); - let (outcome, drop_reason) = ack.outcome(); - let settlement_result = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - outcome, - drop_reason, - ); - let ack_response = match &settlement_result { - Ok(()) => { - tracedecay_daemon_protocol::DaemonInvocationDeliveryAckResponse::accepted( - target_request_id.clone(), - ) - } - Err(reason) => { - tracedecay_daemon_protocol::DaemonInvocationDeliveryAckResponse::rejected( - target_request_id.clone(), - *reason, - ) - } - }; - write_daemon_delivery_ack_response(&mut transport, &ack_response) - .await?; - if let Err(reason) = settlement_result { - return Err(TraceDecayError::Config { - message: format!( - "daemon could not durably record Work delivery ACK: {reason:?}" - ), - }); - } - } else { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Invalid), - ); - pending_line = Some(line); - } - } - None => { - let _ = settle_daemon_work_delivery( - delivery_attempts.as_deref(), - recorder.as_ref(), - tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, - Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), - ); - return Ok(()) - } - } - } - let next_line = if let Some(line) = pending_line.take() { - Some(line) - } else { - tokio::select! { - result = read_line_handling_wire_oversized(&mut transport) => result?, - () = lifecycle.wait_for_draining() => return Ok(()), - } - }; - let Some(next_line) = next_line else { - return Ok(()); - }; - let Some(next_invocation) = parse_daemon_invocation_request(&next_line) else { - return Ok(()); - }; - invocation_request = next_invocation; - } - } + let service = invocation.service.clone(); + let lifecycle = lifecycle.clone(); + let result = Box::pin(drive_retained_invocation_responses( + invocation_request, + &mut transport, + &service, + &handshake, + &lifecycle, + &mut owned_lsp_sessions, + |request| { + Box::pin(execute_portable_daemon_invocation( + lifecycle.clone(), + store_administration.clone(), + Arc::clone(&project_open_gates), + &handshake, + &invocation, + http_application_registry.clone(), + request, + #[cfg(test)] + project_open_attempts.clone(), + )) + }, + )) .await; cleanup_connection_lsp_sessions(&invocation, owned_lsp_sessions).await; return result; diff --git a/crates/tracedecay/src/daemon/core_admission.rs b/crates/tracedecay/src/daemon/core_admission.rs index f2bdd1d2eb..f8d69bd7e7 100644 --- a/crates/tracedecay/src/daemon/core_admission.rs +++ b/crates/tracedecay/src/daemon/core_admission.rs @@ -15,7 +15,7 @@ use super::{ binary_version, classify_mcp_method, parse_daemon_invocation_request, read_line_handling_wire_oversized, write_json_rpc_response, }; -use tracedecay_contracts::{ApplicationProblem, LegalAction, RetryDirective, SafeDiagnostic}; +use tracedecay_contracts::ApplicationProblem; use tracedecay_daemon_protocol::DAEMON_SHUTDOWN_METHOD; use tracedecay_mcp::ErrorCode; use tracedecay_runtime_core::logging::log_daemon_event; @@ -459,14 +459,7 @@ fn invocation_saturation_response( }; Some(super::DaemonInvocationResponse::application_problem( request.request_id, - ApplicationProblem::Saturated { - diagnostic: SafeDiagnostic { - code: code.to_owned(), - message: "The owning TraceDecay daemon has no request capacity".to_owned(), - }, - retry: RetryDirective::AfterDelay, - legal_actions: vec![LegalAction::Retry], - }, + ApplicationProblem::saturated(code, "The owning TraceDecay daemon has no request capacity"), )) } diff --git a/crates/tracedecay/src/daemon/core_client.rs b/crates/tracedecay/src/daemon/core_client.rs index efefbe4471..6cbd946672 100644 --- a/crates/tracedecay/src/daemon/core_client.rs +++ b/crates/tracedecay/src/daemon/core_client.rs @@ -13,41 +13,22 @@ use tracedecay_daemon_control::default_socket_path; #[cfg(not(unix))] use tracedecay_daemon_identity::current_daemon_connection; use tracedecay_daemon_identity::{ResolvedDaemonConnection, client_connection}; -use tracedecay_framing::{BoundedLineReader, WIRE_RECORD_TOO_LARGE, is_wire_oversized_io_error}; - pub(crate) use tracedecay_daemon_protocol::DAEMON_TOOL_LIVENESS_POLL_INTERVAL; -use tracedecay_daemon_protocol::{DAEMON_TOOL_RESPONSE_GRACE, tool_request_deadline}; +pub(crate) use tracedecay_daemon_protocol::connection::{ + DAEMON_RESTART_GRACE, DAEMON_RESTART_POLL_INTERVAL, daemon_connect_failure_advice, + is_transient_daemon_connect_error, +}; +pub use tracedecay_daemon_protocol::daemon_tool_response_bound; +use tracedecay_daemon_protocol::tool_request_deadline; #[cfg(unix)] use super::unavailable_error; use super::{ - BrokerStream, DaemonAuthPreface, DaemonClientDeadline, DaemonHandshake, JsonRpcError, - JsonRpcRequest, JsonRpcResponse, PROJECT_OPEN_RETRY_GRACE, PROJECT_OPEN_RETRY_INTERVAL, Result, + BrokerStream, DaemonClientDeadline, DaemonHandshake, JsonRpcError, JsonRpcRequest, + JsonRpcResponse, PROJECT_OPEN_RETRY_GRACE, PROJECT_OPEN_RETRY_INTERVAL, Result, TraceDecayError, error_is_project_open_retryable, tool_call_transport_error_is_retryable, }; -/// Bounded grace a client keeps reading for *after* the caller's request -/// deadline has elapsed. -/// -/// The request deadline belongs to the daemon: it is what admission measures -/// and what the retained owners settle against, and its whole purpose is to -/// produce a typed terminal, a `PartialEffect` carrying a committed receipt, a -/// typed timeout, rather than silence. Bounding the client's *read* by that -/// same instant made every one of those terminals unobservable through this -/// transport: the client abandoned the connection moments before the envelope -/// it had asked for arrived and reported "outcome may be unknown" while the -/// outcome was already on the wire. The read bound must therefore outlive the -/// request deadline; this is by how much. It bounds only a dead or wedged -/// daemon, never the request. -/// The local read bound for a request whose caller deadline is `request_deadline`. -pub fn daemon_tool_response_bound(request_deadline: Instant) -> Result { - request_deadline - .checked_add(DAEMON_TOOL_RESPONSE_GRACE) - .ok_or_else(|| TraceDecayError::Config { - message: "daemon tool response bound exceeds the supported monotonic range".to_string(), - }) -} - /// The caller's request deadline as an absolute wall-clock instant, for the /// wire. /// @@ -65,17 +46,6 @@ fn wire_request_deadline_micros(request_deadline: Instant) -> tracedecay_domain: ) } -/// How long daemon clients keep retrying a failed connect before giving up. -/// -/// An explicit restart, or an update of a service that was already running, -/// briefly unlinks the socket before the replacement binds it. Connects in -/// that bounded window fail with `NotFound` or `ConnectionRefused`. Long-lived -/// MCP sessions (Cursor's `tracedecay serve` stdio proxy) reconnect per request -/// so a live session can ride out replacement without surfacing a hard -/// JSON-RPC error. This grace does not start an intentionally held service. -pub(crate) const DAEMON_RESTART_GRACE: Duration = Duration::from_secs(8); -pub(crate) const DAEMON_RESTART_POLL_INTERVAL: Duration = Duration::from_millis(200); - /// How long a liveness probe waits for the daemon endpoint to accept a /// connection before the in-flight request is declared unreachable. const DAEMON_TOOL_HEALTH_CONNECT_TIMEOUT: Duration = Duration::from_secs(1); @@ -133,29 +103,13 @@ pub(crate) async fn next_daemon_response_line( where R: tokio::io::AsyncBufRead + Unpin, { - // Hold the reader across liveness polls. `read_mcp_line` is dropped when - // the poll wins `select!`; the accumulator lives on `line_reader`. - let mut line_reader = BoundedLineReader::new(reader); - loop { - tokio::select! { - result = line_reader.read_mcp_line() => { - return match result { - Ok(line) => Ok(line), - Err(error) if is_wire_oversized_io_error(&error) => { - Err(TraceDecayError::Config { - message: format!( - "daemon {request_label} response exceeded wire message bound ({WIRE_RECORD_TOO_LARGE})" - ), - }) - } - Err(error) => Err(error.into()), - }; - } - () = tokio::time::sleep(liveness_poll_interval) => { - ensure_daemon_connection_live(connection, request_label).await?; - } - } - } + tracedecay_daemon_protocol::poll_daemon_response_line( + reader, + request_label, + liveness_poll_interval, + || ensure_daemon_connection_live(connection, request_label), + ) + .await } pub(crate) async fn write_daemon_preamble( @@ -163,15 +117,12 @@ pub(crate) async fn write_daemon_preamble( connection: &ResolvedDaemonConnection, handshake: &DaemonHandshake, ) -> Result<()> { - if let Some(token) = connection.auth_token.as_deref() { - writer - .write_all(DaemonAuthPreface::new(token).to_line()?.as_bytes()) - .await?; - writer.write_all(b"\n").await?; - } - writer.write_all(handshake.to_line()?.as_bytes()).await?; - writer.write_all(b"\n").await?; - Ok(()) + tracedecay_daemon_protocol::write_daemon_handshake_preamble( + writer, + connection.auth_token.as_deref(), + handshake, + ) + .await } pub(crate) fn default_available_socket_path() -> Result { @@ -191,27 +142,6 @@ pub(crate) fn default_available_socket_path() -> Result { } } -pub(crate) fn is_transient_daemon_connect_error(kind: std::io::ErrorKind) -> bool { - matches!( - kind, - std::io::ErrorKind::NotFound - | std::io::ErrorKind::ConnectionRefused - | std::io::ErrorKind::WouldBlock - ) -} - -pub(crate) fn is_saturated_daemon_connect_error(kind: std::io::ErrorKind) -> bool { - kind == std::io::ErrorKind::WouldBlock -} - -pub(crate) fn daemon_connect_failure_advice(kind: std::io::ErrorKind) -> &'static str { - if is_saturated_daemon_connect_error(kind) { - "The daemon is up but not accepting connections, likely overloaded. Retry shortly, or check `tracedecay daemon status`." - } else { - "The daemon may be restarting (e.g. after `tracedecay update`). Retry shortly, or check `tracedecay daemon status`." - } -} - pub(crate) async fn connect_to_current_daemon_within( socket_path: &Path, client_deadline: Option, @@ -442,7 +372,7 @@ pub async fn call_tool( /// Calls a daemon tool with `deadline` as the *caller's request deadline*. /// /// The deadline is sent to the daemon, which enforces it; the local read runs -/// on that deadline plus [`DAEMON_TOOL_RESPONSE_GRACE`] so a deadline-elapsed +/// on that deadline plus [`tracedecay_daemon_protocol::DAEMON_TOOL_RESPONSE_GRACE`] so a deadline-elapsed /// typed terminal is read rather than raced. pub async fn call_tool_within( socket_path: &Path, diff --git a/crates/tracedecay/src/daemon/hook_v2_replay_consumer.rs b/crates/tracedecay/src/daemon/hook_v2_replay_consumer.rs index 65ad57b5bd..cfb0b8e52b 100644 --- a/crates/tracedecay/src/daemon/hook_v2_replay_consumer.rs +++ b/crates/tracedecay/src/daemon/hook_v2_replay_consumer.rs @@ -247,11 +247,8 @@ async fn drain_admitted_host_spool( fn hook_replay_now() -> UtcMicros { UtcMicros( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(1, |duration| { - duration.as_micros().min(i64::MAX as u128) as i64 - }) + tracedecay_runtime_core::tracedecay::saturating_utc_now() + .0 .max(1), ) } diff --git a/crates/tracedecay/src/daemon/http_application.rs b/crates/tracedecay/src/daemon/http_application.rs index 543477933f..cc910d0608 100644 --- a/crates/tracedecay/src/daemon/http_application.rs +++ b/crates/tracedecay/src/daemon/http_application.rs @@ -29,8 +29,7 @@ use tower::ServiceExt; use tracedecay_contracts::remote::auth::RemoteEnrollmentAdmissionEvidenceV1; use tracedecay_contracts::remote::status::RemoteOperationalStatusReadV1; use tracedecay_contracts::{ - APPLICATION_REQUEST_ID_HEADER, ApplicationProblem, LegalAction, RequestId, RetryDirective, - SafeDiagnostic, + APPLICATION_REQUEST_ID_HEADER, ApplicationProblem, RequestId, RetryDirective, SafeDiagnostic, }; use tracedecay_daemon_control::RemoteBrainTlsConfig; use tracedecay_daemon_service::remote_http_transport::RemoteBrainTlsListener; @@ -587,11 +586,7 @@ fn project_router_problem_response( ) else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; - ApplicationProblem::Saturated { - diagnostic, - retry: RetryDirective::AfterDelay, - legal_actions: vec![LegalAction::Retry], - } + ApplicationProblem::saturated(diagnostic.code, diagnostic.message) } ProjectRouterProblem::TimedOut => ApplicationProblem::timed_out_before_admission(), ProjectRouterProblem::Unavailable => { diff --git a/crates/tracedecay/src/daemon/invocation_dispatch.rs b/crates/tracedecay/src/daemon/invocation_dispatch.rs index cce0886799..812804b335 100644 --- a/crates/tracedecay/src/daemon/invocation_dispatch.rs +++ b/crates/tracedecay/src/daemon/invocation_dispatch.rs @@ -178,6 +178,43 @@ fn scope_set_cas_admission( Some((request, *observed_at, deadline, cancellation)) } +struct InvocationCancellation { + request_id: String, + scope_set_cas_lease: Option, + lsp_lease: Option, + lsp_cancellation: Option, + request_cancellation: Option, +} + +fn invocation_cancellation( + request: &DaemonInvocationRequest, + service: &DaemonInvocationService, +) -> Option { + let request_id = request.request_id.clone(); + let request_cancellations = service.request_cancellations(); + let scope_set_cas_lease = if scope_set_cas_admission(request).is_some() { + Some(request_cancellations.register(&request_id)?) + } else { + None + }; + let lsp_lease = if request.operation() == DaemonInvocationOperation::LspOpen { + Some(request_cancellations.register(&request_id)?) + } else { + None + }; + let lsp_cancellation = lsp_lease.as_ref().map(Lease::token); + let request_cancellation = lsp_cancellation + .clone() + .or_else(|| scope_set_cas_lease.as_ref().map(Lease::token)); + Some(InvocationCancellation { + request_id, + scope_set_cas_lease, + lsp_lease, + lsp_cancellation, + request_cancellation, + }) +} + fn selected_root_handshake(handshake: &DaemonHandshake, root: &Path) -> DaemonHandshake { DaemonHandshake { project_path: Some(root.to_path_buf()), @@ -312,38 +349,21 @@ pub(super) async fn execute_portable_daemon_invocation( if let Some(response) = invalid_multi_root_invocation_response(&request) { return response; } - let request_id = request.request_id.clone(); - let request_cancellations = invocation.service.request_cancellations(); - let scope_set_cas_cancellation_lease = if scope_set_cas_admission(&request).is_some() { - match request_cancellations.register(&request_id) { - Some(lease) => Some(lease), - None => { - return DaemonInvocationResponse::problem( - request_id, - DaemonInvocationProblem::InvalidRequest, - ); - } - } - } else { - None - }; - let lsp_cancellation_lease = if request.operation() == DaemonInvocationOperation::LspOpen { - match request_cancellations.register(&request_id) { - Some(lease) => Some(lease), - None => { - return DaemonInvocationResponse::problem( - request_id, - DaemonInvocationProblem::InvalidRequest, - ); - } + let InvocationCancellation { + request_id, + scope_set_cas_lease: _scope_set_cas_lease, + lsp_lease: _lsp_lease, + lsp_cancellation, + request_cancellation, + } = match invocation_cancellation(&request, &invocation.service) { + Some(cancellation) => cancellation, + None => { + return DaemonInvocationResponse::problem( + request.request_id.clone(), + DaemonInvocationProblem::InvalidRequest, + ); } - } else { - None }; - let lsp_cancellation = lsp_cancellation_lease.as_ref().map(Lease::token); - let request_cancellation = lsp_cancellation - .clone() - .or_else(|| scope_set_cas_cancellation_lease.as_ref().map(Lease::token)); let lsp_project_open_gates = Arc::clone(&project_open_gates); #[cfg(test)] let lsp_project_open_attempts = project_open_attempts.clone(); @@ -694,38 +714,21 @@ pub(super) async fn execute_daemon_invocation( if let Some(response) = invalid_multi_root_invocation_response(&request) { return response; } - let request_id = request.request_id.clone(); - let request_cancellations = engine.invocation.service.request_cancellations(); - let scope_set_cas_cancellation_lease = if scope_set_cas_admission(&request).is_some() { - match request_cancellations.register(&request_id) { - Some(lease) => Some(lease), - None => { - return DaemonInvocationResponse::problem( - request_id, - DaemonInvocationProblem::InvalidRequest, - ); - } - } - } else { - None - }; - let lsp_cancellation_lease = if request.operation() == DaemonInvocationOperation::LspOpen { - match request_cancellations.register(&request_id) { - Some(lease) => Some(lease), - None => { - return DaemonInvocationResponse::problem( - request_id, - DaemonInvocationProblem::InvalidRequest, - ); - } + let InvocationCancellation { + request_id, + scope_set_cas_lease: _scope_set_cas_lease, + lsp_lease: _lsp_lease, + lsp_cancellation, + request_cancellation, + } = match invocation_cancellation(&request, &engine.invocation.service) { + Some(cancellation) => cancellation, + None => { + return DaemonInvocationResponse::problem( + request.request_id.clone(), + DaemonInvocationProblem::InvalidRequest, + ); } - } else { - None }; - let lsp_cancellation = lsp_cancellation_lease.as_ref().map(Lease::token); - let request_cancellation = lsp_cancellation - .clone() - .or_else(|| scope_set_cas_cancellation_lease.as_ref().map(Lease::token)); let git_operation = invocation_is_git_operation(request.operation()); let workflow_application = request.is_workflow_application(); let mut project_path = None; diff --git a/crates/tracedecay/src/daemon/invocation_tests/work_evidence_journey_tests.rs b/crates/tracedecay/src/daemon/invocation_tests/work_evidence_journey_tests.rs index cc418c5be4..22cd39094d 100644 --- a/crates/tracedecay/src/daemon/invocation_tests/work_evidence_journey_tests.rs +++ b/crates/tracedecay/src/daemon/invocation_tests/work_evidence_journey_tests.rs @@ -30,18 +30,9 @@ use tracedecay_lsp::LspSessionRegistry; use tracedecay_session_memory::context::{BranchId, ProfileId, SessionRootId, SessionStoreId}; use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("Work evidence journey identity") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))) - .expect("Work evidence journey digest") -} +use tracedecay_domain::test_fixtures::digest; fn product_task(task_id: TaskId) -> (WorkInitiativeV1, WorkPlanV1, WorkMilestoneV1, WorkItemV1) { let created_at = UtcMicros(10); diff --git a/crates/tracedecay/src/daemon/invocation_tests/work_tests.rs b/crates/tracedecay/src/daemon/invocation_tests/work_tests.rs index ddd6cf9294..41b1d66d13 100644 --- a/crates/tracedecay/src/daemon/invocation_tests/work_tests.rs +++ b/crates/tracedecay/src/daemon/invocation_tests/work_tests.rs @@ -661,14 +661,10 @@ async fn registered_work_services_dispatch_the_core_lifecycle() { }; assert_eq!( problem, - tracedecay_contracts::ApplicationProblem::InvalidRequest { - diagnostic: tracedecay_contracts::SafeDiagnostic { - code: "work.invalid_graph_operation".to_owned(), - message: "The Work graph request is invalid".to_owned(), - }, - retry: tracedecay_contracts::RetryDirective::Never, - legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], - }, + tracedecay_contracts::ApplicationProblem::invalid_request( + "work.invalid_graph_operation", + "The Work graph request is invalid" + ), "an unroutable admission is a request to correct, not an authority to retry" ); } diff --git a/crates/tracedecay/src/daemon/production_harness.rs b/crates/tracedecay/src/daemon/production_harness.rs index 1dc0a29b43..7912b7cce5 100644 --- a/crates/tracedecay/src/daemon/production_harness.rs +++ b/crates/tracedecay/src/daemon/production_harness.rs @@ -21,6 +21,7 @@ use super::project_server_lifecycle::{detach_project_servers, shutdown_detached_ use super::*; #[cfg(unix)] use tracedecay_application::pr_tracking::try_acquire_manual_branch_lifecycle; +use tracedecay_code_index_runtime::CodeIndexSchedulerRegistryV1; #[cfg(all(unix, feature = "test-transport"))] use tracedecay_code_index_runtime::git_transactions; use tracedecay_daemon_identity::profile_identity; @@ -957,6 +958,47 @@ impl ProductionProjectCompositionHarnessV1 { } } +/// Liveness floor for the readiness probe above. +/// +/// Every seat install and every source revalidation that keeps an unchanged +/// generation seated signals the serving watch, so the common case wakes on +/// the publication itself. This bound only covers the terminal answers that +/// install no seat — a route that has not mounted yet, and a verified source +/// that publishes no generation at all. +const CODE_INDEX_READINESS_BACKSTOP: Duration = Duration::from_millis(100); + +/// Park until the mounted route seats a generation, or until the backstop. +/// +/// The probe this paces canonicalizes the root, takes the scheduler registry's +/// mounted mutex several times, offloads a Git-metadata freshness capture to +/// the blocking pool, and emits a decline event. Re-running it on a fixed +/// millisecond cadence spends that on the same cores as the reconcile it is +/// waiting for, so the wait is driven by the serving watch instead. +async fn await_serving_generation_change( + schedulers: &CodeIndexSchedulerRegistryV1, + project_root: &Path, + serving_changed: &mut Option>, +) { + if serving_changed.is_none() { + *serving_changed = schedulers + .subscribe_serving_generation_changes(project_root) + .await; + } + let Some(changed) = serving_changed.as_mut() else { + tokio::time::sleep(CODE_INDEX_READINESS_BACKSTOP).await; + return; + }; + match timeout(CODE_INDEX_READINESS_BACKSTOP, changed.changed()).await { + Ok(Ok(())) | Err(_) => {} + // The route retired its watch. Drop it and let the next probe report + // whatever typed state replaced the mount. + Ok(Err(_)) => { + *serving_changed = None; + tokio::time::sleep(CODE_INDEX_READINESS_BACKSTOP).await; + } + } +} + #[hotpath::measure(label = "daemon.harness.wait_code_index", future = true)] async fn wait_for_production_composition_code_index( invocation: &DaemonInvocationState, @@ -975,6 +1017,12 @@ async fn wait_for_production_composition_code_index( return Ok(()); } let wait_started = Instant::now(); + // Subscribe before the first probe so a seat installed between the probe + // and the wait still wakes this loop. + let mut serving_changed = invocation + .code_index_schedulers + .subscribe_serving_generation_changes(project_root) + .await; let publication = timeout(Duration::from_secs(20), async { loop { // Scope-aware readiness is the authenticated demand boundary that @@ -1025,7 +1073,12 @@ async fn wait_for_production_composition_code_index( { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + await_serving_generation_change( + &invocation.code_index_schedulers, + project_root, + &mut serving_changed, + ) + .await; } }) .await; diff --git a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/model.rs b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/model.rs index be83d06288..f9eeeba590 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/model.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/model.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tracedecay_application::lsp_runtime::DaemonLspSessionFactory; -use tracedecay_contracts::{ApplicationProblem, Deadline, RetryDirective, SafeDiagnostic}; +use tracedecay_contracts::{ApplicationProblem, Deadline}; use tracedecay_domain::UtcMicros; use tracedecay_lsp::analyzer::broker::{DiagnosticBroker, MountedLspProvider}; use tracedecay_session_memory::context::MonotonicDeadline; @@ -51,13 +51,10 @@ pub(super) fn advisory_monotonic_deadline_from_remaining( observed_at .checked_add(remaining) .map(MonotonicDeadline::at) - .ok_or_else(|| ApplicationProblem::InvalidRequest { - diagnostic: SafeDiagnostic { - code: "feedback.advisory-cycle.deadline".to_owned(), - message: "The advisory feedback cycle deadline is outside the supported horizon" - .to_owned(), - }, - retry: RetryDirective::Never, - legal_actions: Vec::new(), + .ok_or_else(|| { + ApplicationProblem::invalid_request_without_action( + "feedback.advisory-cycle.deadline", + "The advisory feedback cycle deadline is outside the supported horizon", + ) }) } diff --git a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/scout_journey_tests.rs b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/scout_journey_tests.rs index 4a471304aa..b366b21c85 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/scout_journey_tests.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/scout_journey_tests.rs @@ -23,8 +23,7 @@ use tracedecay_contracts::{ use tracedecay_domain::configuration::ConfigurationValueV1; use tracedecay_domain::feedback::FeedbackContentIdentityV1; use tracedecay_domain::{ - CodeGenerationId, ComponentVersion, ManifestDigest, ProjectId, RefId, RetrievalAnchorId, - TemporalModeV1, + CodeGenerationId, ComponentVersion, ProjectId, RefId, RetrievalAnchorId, TemporalModeV1, }; use tracedecay_runtime_core::cancellation::CancellationToken; @@ -50,9 +49,7 @@ where T::try_from(value.to_owned()).unwrap() } -fn digest(character: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", character.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn configured_model_evidence(marker: u8) -> ContextScoutEvidenceEnvelopeV1 { let scope = ResolvedScope::new( diff --git a/crates/tracedecay/src/daemon/scheduler.rs b/crates/tracedecay/src/daemon/scheduler.rs index cd94a210df..43b3308ff4 100644 --- a/crates/tracedecay/src/daemon/scheduler.rs +++ b/crates/tracedecay/src/daemon/scheduler.rs @@ -29,10 +29,9 @@ use effect_admission::{ }; use host_receipt_review::run_host_receipt_review; -pub(super) fn scheduler_task_log_fields( +fn scheduler_project_task_fields( project_path: &Path, - task: tracedecay_automation_runtime::automation::backend::AgentTaskKind, - outcome: &str, + task: AgentTaskKind, ) -> Vec<(&'static str, String)> { vec![ ("project", project_path.display().to_string()), @@ -40,10 +39,19 @@ pub(super) fn scheduler_task_log_fields( "task", tracedecay_automation_runtime::automation::backend::task_key(task).to_string(), ), - ("outcome", outcome.to_string()), ] } +pub(super) fn scheduler_task_log_fields( + project_path: &Path, + task: AgentTaskKind, + outcome: &str, +) -> Vec<(&'static str, String)> { + let mut fields = scheduler_project_task_fields(project_path, task); + fields.push(("outcome", outcome.to_string())); + fields +} + fn log_scheduler_task_start( project_path: &Path, task: tracedecay_automation_runtime::automation::backend::AgentTaskKind, @@ -56,17 +64,12 @@ fn log_scheduler_task_start( fn scheduler_task_error_log_fields( project_path: &Path, - task: tracedecay_automation_runtime::automation::backend::AgentTaskKind, + task: AgentTaskKind, error: &impl std::fmt::Display, ) -> Vec<(&'static str, String)> { - vec![ - ("project", project_path.display().to_string()), - ( - "task", - tracedecay_automation_runtime::automation::backend::task_key(task).to_string(), - ), - ("error", error.to_string()), - ] + let mut fields = scheduler_project_task_fields(project_path, task); + fields.push(("error", error.to_string())); + fields } fn log_scheduler_task_error( @@ -85,40 +88,28 @@ fn log_scheduler_automation_replay( task: tracedecay_automation_runtime::automation::backend::AgentTaskKind, terminal: &tracedecay_automation_runtime::automation::effect_runtime::AutomationSettledTerminal, ) { - log_daemon_event( - "scheduler_task_application_replay", - &[ - ("project", project_path.display().to_string()), - ( - "task", - tracedecay_automation_runtime::automation::backend::task_key(task).to_owned(), - ), - ( - "terminal", - if terminal.is_completed() { - "completed" - } else if terminal.problem().is_some() { - "problem" - } else { - "skipped" - } - .to_owned(), - ), - ], - ); + let mut fields = scheduler_project_task_fields(project_path, task); + fields.push(( + "terminal", + if terminal.is_completed() { + "completed" + } else if terminal.problem().is_some() { + "problem" + } else { + "skipped" + } + .to_owned(), + )); + log_daemon_event("scheduler_task_application_replay", &fields); } pub(super) fn scheduler_application_problem_log_fields( project_path: &Path, - task: tracedecay_automation_runtime::automation::backend::AgentTaskKind, + task: AgentTaskKind, problem: &tracedecay_automation_runtime::automation::effect_runtime::AutomationSettledProblem, ) -> Vec<(&'static str, String)> { - vec![ - ("project", project_path.display().to_string()), - ( - "task", - tracedecay_automation_runtime::automation::backend::task_key(task).to_owned(), - ), + let mut fields = scheduler_project_task_fields(project_path, task); + fields.extend([ ("request_id", problem.problem.request_id.as_str().to_owned()), ("run_id", problem.run_id.as_str().to_owned()), ( @@ -130,7 +121,8 @@ pub(super) fn scheduler_application_problem_log_fields( "committed_receipt_count", problem.committed_receipts.len().to_string(), ), - ] + ]); + fields } fn scheduler_run_observer( diff --git a/crates/tracedecay/src/daemon/scheduler/effect_admission.rs b/crates/tracedecay/src/daemon/scheduler/effect_admission.rs index 8b551ca686..7044e7226d 100644 --- a/crates/tracedecay/src/daemon/scheduler/effect_admission.rs +++ b/crates/tracedecay/src/daemon/scheduler/effect_admission.rs @@ -27,16 +27,12 @@ pub(super) fn log_scheduler_pre_admission_problem( task: tracedecay_automation_runtime::automation::backend::AgentTaskKind, problem: &tracedecay_contracts::ApplicationProblemEnvelope, ) { - let mut fields = vec![ - ("project", project_path.display().to_string()), - ( - "task", - tracedecay_automation_runtime::automation::backend::task_key(task).to_owned(), - ), + let mut fields = super::scheduler_project_task_fields(project_path, task); + fields.extend([ ("request_id", problem.request_id.as_str().to_owned()), ("problem_kind", format!("{:?}", problem.problem.kind())), ("problem_code", problem.problem.code.clone()), - ]; + ]); match serde_json::to_string(problem) { Ok(envelope) => fields.push(("application_problem", envelope)), Err(error) => fields.push(("observation_error", error.to_string())), @@ -48,18 +44,12 @@ pub(super) fn log_scheduler_admission_conflict( project_path: &Path, task: tracedecay_automation_runtime::automation::backend::AgentTaskKind, ) { - log_daemon_event( - "scheduler_task_automation_admission_conflict", - &[ - ("project", project_path.display().to_string()), - ( - "task", - tracedecay_automation_runtime::automation::backend::task_key(task).to_owned(), - ), - ("outcome", "skipped".to_owned()), - ("reason", "durable_admission_conflict".to_owned()), - ], - ); + let mut fields = super::scheduler_project_task_fields(project_path, task); + fields.extend([ + ("outcome", "skipped".to_owned()), + ("reason", "durable_admission_conflict".to_owned()), + ]); + log_daemon_event("scheduler_task_automation_admission_conflict", &fields); } fn log_scheduler_schedule_skip( @@ -70,18 +60,12 @@ fn log_scheduler_schedule_skip( // Not-due/disabled tasks never reach durable admission; without this // counter a silent schedule skip is indistinguishable from a lost tick. hotpath::gauge!("daemon.effect_admission.deferred_total").inc(1_u64); - log_daemon_event( - "scheduler_task", - &[ - ("project", project_path.display().to_string()), - ( - "task", - tracedecay_automation_runtime::automation::backend::task_key(task).to_owned(), - ), - ("outcome", "skipped".to_owned()), - ("reason", reason.as_str().to_owned()), - ], - ); + let mut fields = super::scheduler_project_task_fields(project_path, task); + fields.extend([ + ("outcome", "skipped".to_owned()), + ("reason", reason.as_str().to_owned()), + ]); + log_daemon_event("scheduler_task", &fields); } #[hotpath::measure(label = "daemon.scheduler.fixed_task_decision", future = true)] diff --git a/crates/tracedecay/src/daemon/tests.rs b/crates/tracedecay/src/daemon/tests.rs index e4a0ec963c..09798889ae 100644 --- a/crates/tracedecay/src/daemon/tests.rs +++ b/crates/tracedecay/src/daemon/tests.rs @@ -52,6 +52,17 @@ mod scheduler_config; mod scheduler_shutdown; mod socket; +#[cfg(unix)] +fn git(root: &std::path::Path, args: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .status() + .expect("run Git fixture command"); + assert!(status.success(), "git {args:?}"); +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum ObservedMcpRoute { Rmcp, @@ -288,32 +299,7 @@ fn test_daemon_engine_for_profile(profile_root: &std::path::Path) -> DaemonEngin engine } -struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - if let Some(previous) = self.previous.take() { - std::env::set_var(self.key, previous); - } else { - std::env::remove_var(self.key); - } - } - } -} +use crate::isolated_profile::EnvVarGuard; /// Pins the codex app-server launcher to a path that cannot exist so any /// automation tick reached during the test fails with the typed spawn error diff --git a/crates/tracedecay/src/daemon/tests/code_index_hydration.rs b/crates/tracedecay/src/daemon/tests/code_index_hydration.rs index 27908f88e8..3669b4cd63 100644 --- a/crates/tracedecay/src/daemon/tests/code_index_hydration.rs +++ b/crates/tracedecay/src/daemon/tests/code_index_hydration.rs @@ -25,13 +25,7 @@ use tracedecay_query::retrieval::hydrate::{ HydrationWorkPermitV1, LateHydrationSource, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("valid fixture identity") -} +use tracedecay_domain::test_fixtures::id; fn request(max_hydration_bytes: u64) -> RetrievalRequest { RetrievalRequest { diff --git a/crates/tracedecay/src/daemon/tests/invocation_ownership.rs b/crates/tracedecay/src/daemon/tests/invocation_ownership.rs index bb453ae7d7..ac120348ea 100644 --- a/crates/tracedecay/src/daemon/tests/invocation_ownership.rs +++ b/crates/tracedecay/src/daemon/tests/invocation_ownership.rs @@ -1,9 +1,7 @@ #![cfg(unix)] use std::future::Future; -use std::path::Path; use std::pin::Pin; -use std::process::Command; use tempfile::TempDir; use tracedecay_contracts::retained_surfaces::{MemoryStatusRequestV1, RetainedSurfaceRequestV1}; @@ -30,16 +28,6 @@ use tracedecay_daemon_service::{ DaemonInvocationProblem, ProjectRuntimePublicationStateV1, RegisteredRetainedRuntime, }; -fn git(root: &Path, args: &[&str]) { - let status = Command::new("git") - .arg("-C") - .arg(root) - .args(args) - .status() - .expect("run Git fixture command"); - assert!(status.success(), "git {args:?}"); -} - async fn committed_fixture( label: &str, ) -> ( @@ -74,14 +62,14 @@ async fn unopened_committed_fixture( .expect("committed invocation source"); let client_identity = test_client_identity_for(profile_root.clone()); initialize_test_project(&project, &client_identity).await; - git(&project, &["init", "--quiet"]); - git(&project, &["config", "user.name", "TraceDecay Test"]); - git( + super::git(&project, &["init", "--quiet"]); + super::git(&project, &["config", "user.name", "TraceDecay Test"]); + super::git( &project, &["config", "user.email", "tracedecay@example.invalid"], ); - git(&project, &["add", "."]); - git(&project, &["commit", "--quiet", "-m", "base"]); + super::git(&project, &["add", "."]); + super::git(&project, &["commit", "--quiet", "-m", "base"]); let project_alias = temp.path().join("project-alias"); std::os::unix::fs::symlink(&project, &project_alias).expect("committed project alias"); let handshake = DaemonHandshake { diff --git a/crates/tracedecay/src/daemon/tests/multi_root_execute_mcp.rs b/crates/tracedecay/src/daemon/tests/multi_root_execute_mcp.rs index 2cafb1a661..e3f8734989 100644 --- a/crates/tracedecay/src/daemon/tests/multi_root_execute_mcp.rs +++ b/crates/tracedecay/src/daemon/tests/multi_root_execute_mcp.rs @@ -6,9 +6,6 @@ #![cfg(unix)] -use std::path::Path; -use std::process::Command; - use serde_json::{Value, json}; use tempfile::TempDir; use tracedecay_contracts::{ @@ -33,30 +30,20 @@ const SCOPE_SET_ID: &str = "scope-set.mcp-execute-proof"; const ALPHA_NAME: &str = "alpha_marker"; const BETA_NAME: &str = "beta_marker"; -fn git(root: &Path, args: &[&str]) { - let status = Command::new("git") - .arg("-C") - .arg(root) - .args(args) - .status() - .expect("run Git fixture command"); - assert!(status.success(), "git {args:?}"); -} - fn repository(source: &str) -> TempDir { let repository = TempDir::new().expect("repository"); - git(repository.path(), &["init", "--quiet"]); - git( + super::git(repository.path(), &["init", "--quiet"]); + super::git( repository.path(), &["config", "user.name", "TraceDecay Test"], ); - git( + super::git( repository.path(), &["config", "user.email", "tracedecay@example.com"], ); std::fs::write(repository.path().join("lib.rs"), source).expect("source"); - git(repository.path(), &["add", "."]); - git(repository.path(), &["commit", "--quiet", "-m", "base"]); + super::git(repository.path(), &["add", "."]); + super::git(repository.path(), &["commit", "--quiet", "-m", "base"]); repository } diff --git a/crates/tracedecay/src/daemon/tests/multi_root_journey.rs b/crates/tracedecay/src/daemon/tests/multi_root_journey.rs index d9d7f18a9a..372b664c71 100644 --- a/crates/tracedecay/src/daemon/tests/multi_root_journey.rs +++ b/crates/tracedecay/src/daemon/tests/multi_root_journey.rs @@ -1,7 +1,6 @@ #![cfg(unix)] use std::path::Path; -use std::process::Command; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::{SystemTime, UNIX_EPOCH}; @@ -27,24 +26,14 @@ use tracedecay_daemon_service::{ DaemonInvocationRequest, parse_daemon_invocation_request, }; -fn git(root: &Path, args: &[&str]) { - let status = Command::new("git") - .arg("-C") - .arg(root) - .args(args) - .status() - .expect("run Git fixture command"); - assert!(status.success(), "git {args:?}"); -} - fn repository() -> TempDir { let repository = TempDir::new().expect("repository"); - git(repository.path(), &["init", "--quiet"]); - git( + super::git(repository.path(), &["init", "--quiet"]); + super::git( repository.path(), &["config", "user.name", "TraceDecay Test"], ); - git( + super::git( repository.path(), &["config", "user.email", "tracedecay@example.com"], ); @@ -53,8 +42,8 @@ fn repository() -> TempDir { "pub fn value() -> u8 { 1 }\n", ) .expect("source"); - git(repository.path(), &["add", "."]); - git(repository.path(), &["commit", "--quiet", "-m", "base"]); + super::git(repository.path(), &["add", "."]); + super::git(repository.path(), &["commit", "--quiet", "-m", "base"]); repository } @@ -67,8 +56,8 @@ fn paginated_repository(prefix: &str) -> TempDir { .collect::>() .concat(); std::fs::write(repository.path().join("lib.rs"), source).expect("paged source"); - git(repository.path(), &["add", "."]); - git( + super::git(repository.path(), &["add", "."]); + super::git( repository.path(), &["commit", "--quiet", "-m", "paged source"], ); diff --git a/crates/tracedecay/src/doctor.rs b/crates/tracedecay/src/doctor.rs index 635f1ff8a7..e3413d8d77 100644 --- a/crates/tracedecay/src/doctor.rs +++ b/crates/tracedecay/src/doctor.rs @@ -819,7 +819,7 @@ async fn configured_upload_enabled(project_path: &Path) -> tracedecay_domain::er let envelope = result.result.map_err( |problem| tracedecay_domain::errors::TraceDecayError::Config { - message: format!("{}: {}", problem.problem.code, problem.problem.message), + message: problem.problem.summary(), }, )?; let ApplicationOutcome::Evidence(evidence) = envelope.outcome else { diff --git a/crates/tracedecay/src/hooks.rs b/crates/tracedecay/src/hooks.rs index 7d3932ccd0..cc19068413 100644 --- a/crates/tracedecay/src/hooks.rs +++ b/crates/tracedecay/src/hooks.rs @@ -7,7 +7,9 @@ use serde_json::Value; struct RootHookReadinessProjection; -impl tracedecay_dashboard_api::hooks::HookReadinessProjectionPort for RootHookReadinessProjection { +impl tracedecay_application::analytics_bridge::HookReadinessProjectionPort + for RootHookReadinessProjection +{ #[hotpath::measure(label = "hints.hook_aggregate")] fn aggregate_hook_completed_readiness(&self, rows: &[Value]) -> Value { let distribution = tracedecay_agent_hosts::hooks::aggregate_hook_completed_readiness(rows); @@ -39,9 +41,9 @@ pub(crate) fn install_dashboard_hook_readiness_projection() -> tracedecay_domain { static INSTALLATION: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - tracedecay_dashboard_api::hooks::install_hook_readiness_projection(std::sync::Arc::new( - RootHookReadinessProjection, - )) + tracedecay_application::analytics_bridge::install_hook_readiness_projection( + std::sync::Arc::new(RootHookReadinessProjection), + ) .map_err(|_| "dashboard hook readiness projection is already installed".to_owned()) }); INSTALLATION diff --git a/crates/tracedecay/src/lib.rs b/crates/tracedecay/src/lib.rs index f35b2e9d0a..a8cdd44416 100644 --- a/crates/tracedecay/src/lib.rs +++ b/crates/tracedecay/src/lib.rs @@ -49,6 +49,11 @@ pub use tracedecay_application::git_query; mod hooks; #[cfg(test)] mod host_admission_test; +/// Declared once for the crate: several test modules need the shared harness, +/// and loading the same file as two modules is a clippy error. +#[cfg(test)] +#[path = "../../../tests/support/isolated_profile.rs"] +mod isolated_profile; pub mod mcp; pub use tracedecay_project::product_runtime::{ ProductRuntimeError, ProductRuntimeProvider, ProductSourceProvenance, product_runtime, diff --git a/crates/tracedecay/src/mcp/server.rs b/crates/tracedecay/src/mcp/server.rs index dc12bdfb59..972b9b990f 100644 --- a/crates/tracedecay/src/mcp/server.rs +++ b/crates/tracedecay/src/mcp/server.rs @@ -19,7 +19,9 @@ pub(crate) use tracedecay_code_index_runtime::code_index_scheduler::{ use tracedecay_contracts::code_index_freshness::{ CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT, CodeIndexConvergenceParkedV1, }; -use tracedecay_contracts::request_identity::McpConnectionIdentityAuthority; +use tracedecay_contracts::request_identity::{ + McpConnectionIdentityAuthority, mcp_connection_request_key as application_surface_request_id, +}; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::RegisteredGlobalDbLeaseV1; use tracedecay_host_admission::TerminalReason; @@ -1499,34 +1501,9 @@ fn json_rpc_request_id_string(id: &Value) -> Option { } } -fn application_surface_request_id(id: &Value, connection_scope: &str) -> Option { - tracedecay_contracts::request_identity::mcp_connection_request_id(id, connection_scope) - .map(|request_id| request_id.as_str().to_owned()) -} - #[cfg(test)] mod cancel_candidate_journey; -#[cfg(test)] -mod application_surface_request_id_tests { - use serde_json::json; - - use super::application_surface_request_id; - - #[test] - fn request_id_hash_preserves_json_rpc_id_type() { - let numeric = application_surface_request_id(&json!(1), "connection").unwrap(); - let string = application_surface_request_id(&json!("1"), "connection").unwrap(); - - assert_ne!(numeric, string); - assert_eq!( - numeric, - application_surface_request_id(&json!(1), "connection").unwrap() - ); - assert!(application_surface_request_id(&json!(null), "connection").is_none()); - } -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod work_evidence_mount_tests; diff --git a/crates/tracedecay/src/mcp/server/connection.rs b/crates/tracedecay/src/mcp/server/connection.rs index a412c35c50..0898a6ff69 100644 --- a/crates/tracedecay/src/mcp/server/connection.rs +++ b/crates/tracedecay/src/mcp/server/connection.rs @@ -248,10 +248,7 @@ impl McpServer { ) { config.pending_upload = 0; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; + let now = crate::project::current_timestamp(); config.last_upload_at = now; } if let Err(err) = config.save() { diff --git a/crates/tracedecay/src/mcp/server/ledger.rs b/crates/tracedecay/src/mcp/server/ledger.rs index e833384b6f..b6c77a997c 100644 --- a/crates/tracedecay/src/mcp/server/ledger.rs +++ b/crates/tracedecay/src/mcp/server/ledger.rs @@ -313,10 +313,7 @@ impl McpServer { /// never await configuration or cloud I/O and shutdown still drains it. #[hotpath::measure(label = "mcp.ledger.flush_worldwide")] pub(crate) fn maybe_flush_worldwide(self: &Arc) { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; + let now = crate::project::current_timestamp(); let last = self.last_flush_at.load(Ordering::Relaxed); if now - last < 30 { return; @@ -593,10 +590,7 @@ fn persist_worldwide_delta(delta: u64, upload_enabled: bool) -> bool { && tracedecay_dashboard_api::cloud::flush_pending(config.pending_upload).is_some() { config.pending_upload = 0; - config.last_upload_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; + config.last_upload_at = crate::project::current_timestamp(); } match config.save() { Ok(()) => true, diff --git a/crates/tracedecay/src/mcp/server/lifecycle.rs b/crates/tracedecay/src/mcp/server/lifecycle.rs index 3242786378..9f39c9ff20 100644 --- a/crates/tracedecay/src/mcp/server/lifecycle.rs +++ b/crates/tracedecay/src/mcp/server/lifecycle.rs @@ -283,10 +283,7 @@ impl McpServer { return; } } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; + let now = crate::project::current_timestamp(); self.last_staleness_check_at.store(now, Ordering::Release); self.startup_catch_up.settle(); @@ -336,10 +333,7 @@ impl McpServer { return; } let cg = self.cg_snapshot().await; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; + let now = crate::project::current_timestamp(); let previous = self.last_staleness_check_at.load(Ordering::Acquire); if previous != 0 && now.saturating_sub(previous) < 30 { return; diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_test_support.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_test_support.rs index 506ce3408c..56ad2cc904 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_test_support.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_test_support.rs @@ -1,5 +1,4 @@ use std::collections::BTreeSet; -use std::ffi::{OsStr, OsString}; use std::path::Path; use std::sync::Arc; @@ -255,8 +254,8 @@ pub(super) fn verified_graph_error_options<'a>( let mut options = verified_graph_options(cg, options); options.code_graph_projection_read_port = Some(Arc::new(FailingFixtureCodeGraphProjection { error })); - options.verified_graph_query_port = - Some(tracedecay_graph_query::admitted_verified_graph_query_port( + options.verified_graph_query_port = Some( + tracedecay_graph_query::admitted_verified_graph_query_port_with_source( options .code_graph_read_admission_port .clone() @@ -265,7 +264,9 @@ pub(super) fn verified_graph_error_options<'a>( .code_graph_projection_read_port .clone() .expect("graph fixture projection"), - )); + None, + ), + ); options } @@ -305,32 +306,7 @@ pub(super) async fn init_sibling_registered_fixture( (graph, sibling) } -struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - if let Some(previous) = self.previous.take() { - std::env::set_var(self.key, previous); - } else { - std::env::remove_var(self.key); - } - } - } -} +use crate::isolated_profile::EnvVarGuard; pub(super) struct SelectorEnv { _home: EnvVarGuard, diff --git a/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs index 690c880efd..0a5d49978b 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs @@ -29,9 +29,7 @@ use super::*; use crate::config::lock_user_data_dir_test_env; use crate::project::TraceDecay; -fn digest(seed: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", seed.to_string().repeat(64))).expect("fixture digest") -} +use tracedecay_domain::test_fixtures::digest; fn retained_scope() -> ResolvedScope { ResolvedScope::new( diff --git a/crates/tracedecay/src/mcp/tools/handlers/stack_snapshot_behavior_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/stack_snapshot_behavior_tests.rs index c96ab32c98..79d1e29695 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/stack_snapshot_behavior_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/stack_snapshot_behavior_tests.rs @@ -17,8 +17,8 @@ use tracedecay_contracts::{ native_integration_surface_operation, }; use tracedecay_domain::{ - ActorId, ManifestDigest, ProjectId, RefId, RepositoryId, ScopeSetId, ScopeSetRevision, - UtcMicros, WorktreeId, WorktreeInventoryEpoch, WorktreeInventorySnapshotId, + ActorId, ProjectId, RefId, RepositoryId, ScopeSetId, ScopeSetRevision, UtcMicros, WorktreeId, + WorktreeInventoryEpoch, WorktreeInventorySnapshotId, }; use tracedecay_mcp::McpTransport; use tracedecay_runtime_core::git::try_git_program; @@ -53,9 +53,7 @@ impl McpTransport for CaptureTransport { } } -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; fn git(root: &Path, arguments: &[&str]) { let status = Command::new(try_git_program().expect("git program")) diff --git a/crates/tracedecay/tests/automation_runner_test/session_reflector.rs b/crates/tracedecay/tests/automation_runner_test/session_reflector.rs index 24179d4773..ec12495410 100644 --- a/crates/tracedecay/tests/automation_runner_test/session_reflector.rs +++ b/crates/tracedecay/tests/automation_runner_test/session_reflector.rs @@ -46,7 +46,7 @@ impl AutomationSessionRetrieval for StructuralBudgetRefusalRetrieval { #[cfg(feature = "test-transport")] use std::sync::atomic::Ordering; #[cfg(feature = "test-transport")] -use tracedecay_automation_runtime::ports::session_evidence::{LcmGrepSort, LcmScope}; +use tracedecay_lcm::{LcmGrepSort, LcmScope}; #[cfg(feature = "test-transport")] use tracedecay_store::{ProjectMemoryFactSearchKindV1, ProjectMemoryFactSearchQuery}; diff --git a/crates/tracedecay/tests/common/mcp_response.rs b/crates/tracedecay/tests/common/mcp_response.rs new file mode 100644 index 0000000000..4cb87ba816 --- /dev/null +++ b/crates/tracedecay/tests/common/mcp_response.rs @@ -0,0 +1,99 @@ +//! Reading an MCP tool answer that outgrew the response frame. +//! +//! A body over the frame cap does not arrive as the JSON a journey reads. MCP +//! stores the original, answers with `{"truncated": true, "handle": …, +//! "preview": …}`, and `preview` is a *string* holding a prefix of that JSON. +//! Every field a predicate looks for — `results`, `code_generation` — is then +//! absent at the top level, and a wait that reads them as missing cannot tell +//! a truncated answer from a generation that is still warming. +//! +//! So read the stored original through `tracedecay_retrieve` the way an agent +//! does, and keep that one authority: the page size that fits the frame is a +//! property of how much ranking provenance a candidate carries, not something +//! a journey should be guessing at. + +use std::path::Path; + +use serde_json::{Value, json}; +use tracedecay::daemon::ProductionProjectCompositionHarnessV1; +use tracedecay_mcp::JsonRpcResponse; + +/// Call one MCP tool on an admitted project and read its JSON answer. +pub async fn tool_json( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + name: &str, + arguments: Value, +) -> Value { + let payload = called_tool_payload(harness, project, name, arguments).await; + resolved_tool_payload(harness, project, payload).await +} + +/// Reassemble a payload the response frame replaced with a retrieval handle. +/// +/// An untruncated payload is returned as it arrived. +pub async fn resolved_tool_payload( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + payload: Value, +) -> Value { + if payload.get("truncated") != Some(&json!(true)) { + return payload; + } + let handle = payload["handle"] + .as_str() + .unwrap_or_else(|| panic!("truncated response omitted its retrieve handle: {payload}")); + let mut content = String::new(); + let mut offset = 0_u64; + loop { + let retrieved = called_tool_payload( + harness, + project, + "tracedecay_retrieve", + json!({"handle": handle, "format": "json", "offset": offset}), + ) + .await; + content.push_str(retrieved["content"].as_str().unwrap_or_else(|| { + panic!("truncated response handle carried no content page: {retrieved}") + })); + if retrieved["has_more"] != json!(true) { + break; + } + let next_offset = retrieved["next_offset"].as_u64().unwrap_or_else(|| { + panic!("retrieve reported more pages without a next offset: {retrieved}") + }); + assert!( + next_offset > offset, + "retrieve did not advance past offset {offset}: {retrieved}" + ); + offset = next_offset; + } + serde_json::from_str(&content).unwrap_or_else(|error| { + panic!("truncated response handle did not retrieve JSON: {error}; content={content}") + }) +} + +/// One tool call, decoded but not reassembled. `tracedecay_retrieve` pages +/// answer within the frame by construction, so the paging loop above reads +/// them through this rather than through [`tool_json`]. +async fn called_tool_payload( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + name: &str, + arguments: Value, +) -> Value { + let response = harness + .call_tool(project, name, arguments) + .await + .unwrap_or_else(|error| panic!("{name} failed: {error}")); + decoded_tool_payload(&response) +} + +fn decoded_tool_payload(response: &JsonRpcResponse) -> Value { + assert!(response.error.is_none(), "{response:?}"); + let result = response.result.as_ref().expect("tool result"); + assert_ne!(result["isError"], true, "tool effect failed: {result}"); + let text = result["content"][0]["text"].as_str().expect("tool text"); + serde_json::from_str(text) + .unwrap_or_else(|error| panic!("tool returned invalid JSON: {error}; text={text}")) +} diff --git a/crates/tracedecay/tests/common/mod.rs b/crates/tracedecay/tests/common/mod.rs index 08bed31dcc..15fc9e1520 100644 --- a/crates/tracedecay/tests/common/mod.rs +++ b/crates/tracedecay/tests/common/mod.rs @@ -1,6 +1,8 @@ #![allow(dead_code)] // shared test support: each suite binary compiles this module and uses a subset pub mod fixture; +#[cfg(feature = "test-transport")] +pub mod mcp_response; pub mod repository_layout; use std::ffi::{OsStr, OsString}; @@ -117,44 +119,10 @@ pub async fn open_test_database( Database::publish_test_runtime(path, &authority, TestDatabaseRuntimeMode::Existing).await } -/// Sets (or removes) an environment variable for its lifetime, restoring the -/// previous value on drop. -pub struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -impl EnvVarGuard { - pub fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } - - /// Removes `key` for the guard's lifetime, so tests can exercise the - /// no-override path. - pub fn unset(key: &'static str) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::remove_var(key); - } - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - if let Some(previous) = self.previous.take() { - std::env::set_var(self.key, previous); - } else { - std::env::remove_var(self.key); - } - } - } -} +#[path = "../../../../tests/support/isolated_profile.rs"] +mod isolated_profile; +#[allow(unused_imports)] // each suite binary uses a subset +pub use isolated_profile::{EnvVarGuard, apply_isolated_profile_env, run_ok}; /// Query lanes a terminal code-index answer must report as `"complete"`. /// Daemon journeys and the MCP readiness wait share this set. diff --git a/crates/tracedecay/tests/daemon_suite/advanced_workflow_journey_test.rs b/crates/tracedecay/tests/daemon_suite/advanced_workflow_journey_test.rs index a767245374..de244a27e0 100644 --- a/crates/tracedecay/tests/daemon_suite/advanced_workflow_journey_test.rs +++ b/crates/tracedecay/tests/daemon_suite/advanced_workflow_journey_test.rs @@ -81,13 +81,7 @@ const PROVIDER_TRANSCRIPT_ASSISTANT_MESSAGE_ID: &str = "message.advanced-workflo const PROVIDER_TRANSCRIPT_REFRESH_MESSAGE_ID: &str = "message.advanced-workflow-provider-participant-refresh"; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("advanced workflow identity") -} +use tracedecay_domain::test_fixtures::id; fn run(command: &mut Command, operation: &str) -> Vec { let output = command diff --git a/crates/tracedecay/tests/daemon_suite/git_watch_test.rs b/crates/tracedecay/tests/daemon_suite/git_watch_test.rs index 07e8291aa5..4f9efc4f5c 100644 --- a/crates/tracedecay/tests/daemon_suite/git_watch_test.rs +++ b/crates/tracedecay/tests/daemon_suite/git_watch_test.rs @@ -15,7 +15,8 @@ use tracedecay_code_index_retention::code_index_generations::{ DurablePublicationPointerV1, scoped_code_index_store_root, }; use tracedecay_domain::configuration::SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY; -use tracedecay_mcp::JsonRpcResponse; + +use crate::common::mcp_response::tool_json; fn git(project: &Path, args: &[&str]) { let output = Command::new("git") .args(["-c", "core.hooksPath=.git/no-hooks"]) @@ -66,29 +67,8 @@ async fn indexed_repo() -> (TempDir, PathBuf, ProductionProjectCompositionHarnes .unwrap(); (root, project, harness) } -fn tool_payload(response: &JsonRpcResponse) -> Value { - assert!(response.error.is_none(), "{response:?}"); - let result = response.result.as_ref().expect("tool result"); - assert_ne!(result["isError"], true, "tool effect failed: {result}"); - let text = result["content"][0]["text"].as_str().expect("tool text"); - serde_json::from_str(text) - .unwrap_or_else(|error| panic!("tool returned invalid JSON: {error}; text={text}")) -} -async fn tool( - harness: &ProductionProjectCompositionHarnessV1, - project: &Path, - name: &str, - arguments: Value, -) -> Value { - tool_payload( - &harness - .call_tool(project, name, arguments) - .await - .unwrap_or_else(|error| panic!("{name} failed: {error}")), - ) -} async fn status(harness: &ProductionProjectCompositionHarnessV1, project: &Path) -> Value { - tool( + tool_json( harness, project, "tracedecay_status", @@ -116,7 +96,7 @@ async fn search( project: &Path, query: &str, ) -> Value { - let payload = tool( + let payload = tool_json( harness, project, "tracedecay_search", @@ -127,53 +107,9 @@ async fn search( payload["reason"], "search_capacity_unavailable", "search {query:?} lost the execution permit race and was refused instead of queued: {payload}" ); - resolve_truncated_tool_payload(harness, project, payload).await + payload } -/// A `tracedecay_search` body over the MCP response cap arrives as a handle -/// envelope whose preview is not the JSON the journey reads. Reassemble the -/// stored original through `tracedecay_retrieve` pages exactly as an agent -/// does, so `code_generation` and `results` come from the full answer. -async fn resolve_truncated_tool_payload( - harness: &ProductionProjectCompositionHarnessV1, - project: &Path, - payload: Value, -) -> Value { - if payload.get("truncated") != Some(&json!(true)) { - return payload; - } - let handle = payload["handle"] - .as_str() - .unwrap_or_else(|| panic!("truncated search omitted retrieve handle: {payload}")); - let mut content = String::new(); - let mut offset = 0_u64; - loop { - let retrieved = tool( - harness, - project, - "tracedecay_retrieve", - json!({"handle": handle, "format": "json", "offset": offset}), - ) - .await; - content.push_str(retrieved["content"].as_str().unwrap_or_else(|| { - panic!("truncated search handle carried no content page: {retrieved}") - })); - if retrieved["has_more"] != json!(true) { - break; - } - let next_offset = retrieved["next_offset"].as_u64().unwrap_or_else(|| { - panic!("retrieve reported more pages without a next offset: {retrieved}") - }); - assert!( - next_offset > offset, - "retrieve did not advance past offset {offset}: {retrieved}" - ); - offset = next_offset; - } - serde_json::from_str(&content).unwrap_or_else(|error| { - panic!("truncated search handle did not retrieve JSON: {error}; content={content}") - }) -} fn symbol_count(payload: &Value, name: &str) -> usize { payload["results"] .as_array() @@ -191,7 +127,7 @@ fn generation_index_len(data_root: &Path, project: &Path) -> usize { pointer.generation_index.len() } async fn request_refresh(harness: &ProductionProjectCompositionHarnessV1, project: &Path) { - let receipt = tool( + let receipt = tool_json( harness, project, "tracedecay_admin_sync", @@ -356,7 +292,7 @@ async fn linked_worktree_requires_mount_then_serves_only_its_exact_generation() // `linked_worktree_disabled` state and never publishes. The opt-in is a // project-layer setting decided at route open, so write it through the // production configuration tool before the worktree route opens. - let receipt = tool( + let receipt = tool_json( &harness, &project, "tracedecay_configuration_set", diff --git a/crates/tracedecay/tests/daemon_suite/invocation_observability.rs b/crates/tracedecay/tests/daemon_suite/invocation_observability.rs index 1e6413558e..47124287e6 100644 --- a/crates/tracedecay/tests/daemon_suite/invocation_observability.rs +++ b/crates/tracedecay/tests/daemon_suite/invocation_observability.rs @@ -16,15 +16,13 @@ use tracedecay_daemon_service::{ }; use tracedecay_domain::{ CoverageStateV1, DeliveryChannelIdentityV1, DeliveryEventClassV1, DeliverySettlementAttemptV1, - DeliverySettlementOutcomeV1, DeliverySettlementV1, DeliverySurfaceFamilyV1, ManifestDigest, + DeliverySettlementOutcomeV1, DeliverySettlementV1, DeliverySurfaceFamilyV1, ObservabilityEnvelopeV1, ObservabilityPayloadV1, ObservabilityRetentionClassV1, ObservabilityTerminalResultV1, ProjectId, RepositoryId, RetrievalQueryObservedV1, UtcMicros, WorktreeId, canonical_sha256, }; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("digest") -} +use tracedecay_domain::test_fixtures::digest; /// A registry mount request whose store-authority fields match `identity`, /// stamping that identity's configuration and policy revisions for the diff --git a/crates/tracedecay/tests/daemon_suite/workflow_handoff_test.rs b/crates/tracedecay/tests/daemon_suite/workflow_handoff_test.rs index c92c3493f0..bf94accf02 100644 --- a/crates/tracedecay/tests/daemon_suite/workflow_handoff_test.rs +++ b/crates/tracedecay/tests/daemon_suite/workflow_handoff_test.rs @@ -19,13 +19,7 @@ use tracedecay_domain::{ WorkflowOutputName, WorkflowStep, WorkflowStepId, WorktreeId, canonical_sha256, }; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; /// A distinct, valid `sha256:`-tagged digest per input byte. /// diff --git a/crates/tracedecay/tests/mcp_suite/health_read_behavior.rs b/crates/tracedecay/tests/mcp_suite/health_read_behavior.rs index 18ad674725..da6690f18f 100644 --- a/crates/tracedecay/tests/mcp_suite/health_read_behavior.rs +++ b/crates/tracedecay/tests/mcp_suite/health_read_behavior.rs @@ -12,7 +12,6 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use std::process::Command; use serde_json::{Value, json}; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; @@ -164,33 +163,7 @@ async fn open_health_project() -> HealthProject { fn seed_project(project_root: &Path) { fs::create_dir_all(project_root.join("src")).expect("project source directory"); fs::write(project_root.join("src/lib.rs"), "pub fn marker() {}\n").expect("source file"); - let git = crate::common::git_program(); - let init = Command::new(&git) - .args(["init", "-q"]) - .current_dir(project_root) - .status() - .expect("git init"); - assert!(init.success(), "git init must succeed"); - let add = Command::new(&git) - .args(["add", "."]) - .current_dir(project_root) - .status() - .expect("git add"); - assert!(add.success(), "git add must succeed"); - let commit = Command::new(&git) - .args([ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay@example.invalid", - "commit", - "-qm", - "health read fixture", - ]) - .current_dir(project_root) - .status() - .expect("git commit"); - assert!(commit.success(), "git commit must succeed"); + crate::support::commit_worktree(project_root, "health read fixture"); } async fn seal_serving_database(fixture: HealthProject) -> HealthProject { diff --git a/crates/tracedecay/tests/mcp_suite/insert_at_symbol_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/insert_at_symbol_behavior_test.rs index baa88c7c28..ae26be88c8 100644 --- a/crates/tracedecay/tests/mcp_suite/insert_at_symbol_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/insert_at_symbol_behavior_test.rs @@ -134,7 +134,7 @@ async fn open_sources(files: &[(&str, &str)]) -> (ProductionSourceEditFixture, T for (relative, body) in files { fs::write(project.join(relative), body).unwrap(); } - let (fixture, _) = init_production_source_edit_project(&project).await; + let fixture = init_production_source_edit_project(&project).await; (fixture, dir) } @@ -142,7 +142,7 @@ async fn open_pair() -> (ProductionSourceEditFixture, TestTempDir) { let dir = test_temp_dir(); let project = dir.path().join("project"); write_pair(&project); - let (fixture, _) = init_production_source_edit_project(&project).await; + let fixture = init_production_source_edit_project(&project).await; (fixture, dir) } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs index fe316b654c..55fcb493dc 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs @@ -97,8 +97,6 @@ mod lcm_load_session_behavior; mod lcm_test; #[cfg(feature = "test-transport")] mod memory_contradiction_contract_test; -#[cfg(feature = "test-transport")] -mod memory_fact_assertions; mod memory_fact_probe_test; #[cfg(feature = "test-transport")] mod memory_fact_store_reason_test; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/active_project_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/active_project_test.rs index b7d22e9525..4ee11b4451 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/active_project_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/active_project_test.rs @@ -4,8 +4,9 @@ #![cfg(feature = "test-transport")] use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::path::PathBuf; + +use crate::common::fixture::git_run as git; use serde_json::{Value, json}; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; @@ -259,16 +260,3 @@ async fn open_checkout() -> OpenedCheckout { _isolation: isolation, } } - -fn git(project: &Path, args: &[&str]) { - let status = Command::new(crate::common::git_program()) - .args(args) - .current_dir(project) - .status() - .unwrap_or_else(|error| panic!("git {args:?} failed to start: {error}")); - assert!( - status.success(), - "git {args:?} exited {status} in {}", - project.display() - ); -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs index 47c5157040..01fed5c1a1 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs @@ -68,7 +68,7 @@ async fn open_project(files: &[(&str, &str)]) -> (ProductionSourceEditFixture, T fs::create_dir_all(path.parent().expect("fixture file has a parent")).unwrap(); fs::write(&path, contents).unwrap(); } - let (fixture, _) = init_production_source_edit_project(&project).await; + let fixture = init_production_source_edit_project(&project).await; (fixture, dir) } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/branch_search_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/branch_search_test.rs index 0071871e8e..0edae6b204 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/branch_search_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/branch_search_test.rs @@ -9,13 +9,12 @@ use std::fs; use std::path::Path; -use std::process::Command; use std::time::Duration; +use crate::common::fixture::git_capture as git; use serde_json::{Value, json}; use tempfile::TempDir; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; -use tracedecay::test_support::git::GIT_FIXTURE_CONFIG; const COMMITTED_SOURCE: &str = "pub fn committed_anchor() -> usize { 1 }\n"; const DIRTY_SOURCE: &str = "\ @@ -195,24 +194,6 @@ fn visible_hits(payload: &Value) -> Value { ) } -fn git(project: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(GIT_FIXTURE_CONFIG) - .args(args) - .current_dir(project) - .output() - .unwrap_or_else(|error| panic!("git {args:?} failed to spawn: {error}")); - assert!( - output.status.success(), - "git {args:?} failed\n{}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout) - .expect("git stdout is UTF-8") - .trim() - .to_owned() -} - async fn wait_until_worktree_search_serves_dirty_anchor( harness: &ProductionProjectCompositionHarnessV1, project: &Path, diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_test.rs index 02b1a7f55f..7f71146621 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_test.rs @@ -3,7 +3,6 @@ use crate::support::*; use serde_json::{Value, json}; use std::fs; -use std::process::Command; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; use tracedecay_domain::errors::{Result as TraceDecayResult, TraceDecayError}; use tracedecay_mcp::ToolResult; @@ -49,32 +48,7 @@ async fn setup_scoped_production_project(scope_prefix: &str) -> ScopedProduction let project_root = isolation.path().join("project"); fs::create_dir_all(&project_root).unwrap(); crate::fixture::write_indexed_fixture_sources(&project_root); - let init = Command::new(crate::common::git_program()) - .args(["init", "-q"]) - .current_dir(&project_root) - .status() - .unwrap(); - assert!(init.success(), "git init must succeed"); - let add = Command::new(crate::common::git_program()) - .args(["add", "."]) - .current_dir(&project_root) - .status() - .unwrap(); - assert!(add.success(), "git add must succeed"); - let commit = Command::new(crate::common::git_program()) - .args([ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay@example.invalid", - "commit", - "-qm", - "scoped production context fixture", - ]) - .current_dir(&project_root) - .status() - .unwrap(); - assert!(commit.success(), "git commit must succeed"); + commit_worktree(&project_root, "scoped production context fixture"); let harness = ProductionProjectCompositionHarnessV1::open_with_scope_prefix( isolation.path(), [project_root.clone()], @@ -179,10 +153,6 @@ async fn test_context_appends_index_coverage_hint_for_skipped_generated_dirs() { fixture.harness.shutdown().await; } -// --------------------------------------------------------------------------- -// 2. tracedecay_context -// --------------------------------------------------------------------------- - #[tokio::test] async fn context_includes_matching_memory_facts() { let fixture = setup_production_project().await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test.rs index 6d6a35e874..78e33a796f 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test.rs @@ -4,13 +4,10 @@ use crate::support::*; use serde_json::{Value, json}; use std::fs; use std::path::Path; -use std::process::Command; use std::time::Duration; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; use tracedecay::mcp::McpServer; -mod lazy_cutover; - struct ScopedDependencyHintFixture { harness: ProductionProjectCompositionHarnessV1, project_root: std::path::PathBuf, @@ -27,35 +24,6 @@ fn write_dependency_declaration(project: &Path, module: &str, declarations: &str .unwrap(); } -fn initialize_git_repository(project: &Path) { - let init = Command::new(crate::common::git_program()) - .args(["init", "-q"]) - .current_dir(project) - .status() - .expect("git init dependency-hint fixture"); - assert!(init.success(), "git init must succeed"); - let add = Command::new(crate::common::git_program()) - .args(["add", "."]) - .current_dir(project) - .status() - .expect("git add dependency-hint fixture"); - assert!(add.success(), "git add must succeed"); - let commit = Command::new(crate::common::git_program()) - .args([ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay@example.invalid", - "commit", - "-qm", - "dependency hint fixture", - ]) - .current_dir(project) - .status() - .expect("git commit dependency-hint fixture"); - assert!(commit.success(), "git commit must succeed"); -} - async fn scoped_dependency_hint_fixture( scope_prefix: &str, write_sources: impl FnOnce(&Path), @@ -64,7 +32,7 @@ async fn scoped_dependency_hint_fixture( let project_root = isolation.path().join("project"); fs::create_dir_all(&project_root).unwrap(); write_sources(&project_root); - initialize_git_repository(&project_root); + commit_worktree(&project_root, "dependency hint fixture"); let harness = ProductionProjectCompositionHarnessV1::open_with_scope_prefix( isolation.path(), [project_root.clone()], @@ -299,3 +267,215 @@ export function IndexedAnchor() { return 1; } ); fixture.harness.shutdown().await; } + +const GENERATION_ADVANCED_REASON: &str = + "application.symbol-graph.ignored-dependency-generation-advanced"; + +enum DependencyImportStyle { + Named, + Default, +} + +async fn lazy_dependency_fixture( + symbol: &str, + import_style: DependencyImportStyle, +) -> ProductionCompositionFixture { + let (declaration, import) = match import_style { + DependencyImportStyle::Named => ( + format!("export interface {symbol} {{ value: string }}\n"), + format!("import type {{ {symbol} }} from \"pkg\";\n"), + ), + DependencyImportStyle::Default => ( + format!("export default interface {symbol} {{ value: string }}\n"), + format!("import type {symbol} from \"pkg\";\n"), + ), + }; + production_composition_fixture_with_sources(move |project| { + fs::create_dir_all(project.join("src")).unwrap(); + write_dependency_declaration(project, "pkg", &declaration); + fs::write( + project.join("src/app.ts"), + "export function GenerationAnchor() { return 1; }\n", + ) + .unwrap(); + fs::write(project.join("src/dependency-types.ts"), import).unwrap(); + }) + .await +} + +fn assert_generation_advanced_retry(response: &Value) { + assert!( + response["result"].is_null(), + "the generation-advancing call must not return a same-call symbol payload: {response}" + ); + assert_eq!( + response["error"]["data"]["reason_code"].as_str(), + Some(GENERATION_ADVANCED_REASON), + "lazy admission must expose the canonical usecase retry reason: {response}" + ); + assert_eq!(response["error"]["data"]["retryable"], true); +} + +fn code_generation(payload: &Value) -> &str { + payload["code_generation"] + .as_str() + .unwrap_or_else(|| panic!("search response code generation: {payload}")) +} + +async fn exact_symbol_payload(server: &McpServer, arguments: Value) -> Value { + let response = + handle_real_server_tool_call_raw(server, "tracedecay_find_exact_symbol", arguments).await; + assert!( + response["error"].is_null(), + "exact-symbol read must succeed: {response}" + ); + serde_json::from_str(extract_real_server_text(&response["result"])) + .expect("exact-symbol response JSON") +} + +#[tokio::test] +async fn exact_symbol_explicit_lazy_admission_advances_generation_then_retry_finds_symbol_once() { + let fixture = + lazy_dependency_fixture("ExactOnlyDependency", DependencyImportStyle::Named).await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + let before = + wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; + + let zero = + exact_symbol_payload(&server, json!({"name": "ExactOnlyDependency", "limit": 5})).await; + assert_eq!( + zero["count"], 0, + "the ignored dependency must be absent before explicit admission: {zero}" + ); + let after_zero = + wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; + assert_eq!(code_generation(&after_zero), code_generation(&before)); + + let arguments = json!({ + "name": "ExactOnlyDependency", + "limit": 5, + "lazy_index_ignored_dependencies": true + }); + let first = handle_real_server_tool_call_raw( + &server, + "tracedecay_find_exact_symbol", + arguments.clone(), + ) + .await; + assert_generation_advanced_retry(&first); + + let after = + wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; + assert_ne!( + code_generation(&after), + code_generation(&before), + "the scheduler must publish a new serving generation before requesting a retry" + ); + + let payload = exact_symbol_payload(&server, arguments).await; + assert_eq!(payload["count"], 1, "retry must find one symbol: {payload}"); + let matches = payload["matches"] + .as_array() + .expect("exact-symbol retry matches"); + assert_eq!(matches.len(), 1, "retry must find one symbol: {payload}"); + assert_eq!( + matches[0]["name"], "ExactOnlyDependency", + "retry must return the requested dependency symbol: {payload}" + ); + assert_eq!( + matches[0]["file"], "node_modules/pkg/index.d.ts", + "retry must return the ignored dependency declaration: {payload}" + ); + fixture.harness.shutdown().await; +} + +#[tokio::test] +async fn search_explicit_lazy_admission_advances_generation_then_retry_finds_dependency_chunk_once() +{ + let fixture = + lazy_dependency_fixture("SearchOnlyDependency", DependencyImportStyle::Default).await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + let before = + wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; + + let zero = search_payload(&server, json!({"query": "default", "limit": 5})).await; + assert_eq!( + zero["results"], + json!([]), + "the ignored dependency must be absent before explicit admission: {zero}" + ); + let after_zero = + wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; + assert_eq!(code_generation(&after_zero), code_generation(&before)); + + let arguments = json!({ + "query": "default", + "limit": 5, + "lazy_index_ignored_dependencies": true + }); + let first = search_payload(&server, arguments.clone()).await; + assert_eq!( + first["results"], + json!([]), + "generation-advancing admission must preserve the successful lexical result: {first}" + ); + assert_eq!( + code_generation(&first), + code_generation(&before), + "the current response remains bound to the generation that produced its lexical result" + ); + + let retry = wait_for_search_payload(&server, arguments.clone()).await; + assert_ne!( + code_generation(&retry), + code_generation(&before), + "the retry must bind the scheduler-published generation" + ); + let results = retry["results"].as_array().expect("search retry results"); + assert_eq!( + results.len(), + 1, + "retry must find one dependency chunk: {retry}" + ); + let result = &results[0]; + let anchor = result["candidate"]["anchor_id"] + .as_str() + .expect("search retry candidate anchor"); + assert!( + anchor.starts_with("code-chunk:"), + "the default keyword must bind the admitted dependency chunk: {retry}" + ); + let chunk_id = anchor + .strip_prefix("code-chunk:") + .expect("search retry chunk anchor"); + let expected_source = format!("code-chunk:{}:{chunk_id}", code_generation(&retry)); + assert!( + result["candidate"]["occurrences"] + .as_array() + .is_some_and(|occurrences| occurrences.iter().any(|occurrence| { + occurrence["source_occurrence_id"].as_str() == Some(expected_source.as_str()) + && occurrence["file_occurrence_id"] + .as_str() + .is_some_and(|file| !file.is_empty()) + })), + "the returned chunk must retain its exact generation, chunk, and file occurrence: {retry}" + ); + let stable = wait_for_search_payload(&server, arguments).await; + assert_eq!( + code_generation(&stable), + code_generation(&retry), + "a positive retry must not schedule another generation: {stable}" + ); + assert_eq!( + stable["results"].as_array().map(Vec::len), + Some(1), + "a positive retry must continue returning exactly one result: {stable}" + ); + fixture.harness.shutdown().await; +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test/lazy_cutover.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test/lazy_cutover.rs deleted file mode 100644 index 19230e1c14..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_hint_test/lazy_cutover.rs +++ /dev/null @@ -1,213 +0,0 @@ -use super::*; - -const GENERATION_ADVANCED_REASON: &str = - "application.symbol-graph.ignored-dependency-generation-advanced"; - -enum DependencyImportStyle { - Named, - Default, -} - -async fn lazy_dependency_fixture( - symbol: &str, - import_style: DependencyImportStyle, -) -> ProductionCompositionFixture { - let (declaration, import) = match import_style { - DependencyImportStyle::Named => ( - format!("export interface {symbol} {{ value: string }}\n"), - format!("import type {{ {symbol} }} from \"pkg\";\n"), - ), - DependencyImportStyle::Default => ( - format!("export default interface {symbol} {{ value: string }}\n"), - format!("import type {symbol} from \"pkg\";\n"), - ), - }; - production_composition_fixture_with_sources(move |project| { - fs::create_dir_all(project.join("src")).unwrap(); - write_dependency_declaration(project, "pkg", &declaration); - fs::write( - project.join("src/app.ts"), - "export function GenerationAnchor() { return 1; }\n", - ) - .unwrap(); - fs::write(project.join("src/dependency-types.ts"), import).unwrap(); - }) - .await -} - -fn assert_generation_advanced_retry(response: &Value) { - assert!( - response["result"].is_null(), - "the generation-advancing call must not return a same-call symbol payload: {response}" - ); - assert_eq!( - response["error"]["data"]["reason_code"].as_str(), - Some(GENERATION_ADVANCED_REASON), - "lazy admission must expose the canonical usecase retry reason: {response}" - ); - assert_eq!(response["error"]["data"]["retryable"], true); -} - -fn code_generation(payload: &Value) -> &str { - payload["code_generation"] - .as_str() - .unwrap_or_else(|| panic!("search response code generation: {payload}")) -} - -async fn exact_symbol_payload(server: &McpServer, arguments: Value) -> Value { - let response = - handle_real_server_tool_call_raw(server, "tracedecay_find_exact_symbol", arguments).await; - assert!( - response["error"].is_null(), - "exact-symbol read must succeed: {response}" - ); - serde_json::from_str(extract_real_server_text(&response["result"])) - .expect("exact-symbol response JSON") -} - -#[tokio::test] -async fn exact_symbol_explicit_lazy_admission_advances_generation_then_retry_finds_symbol_once() { - let fixture = - lazy_dependency_fixture("ExactOnlyDependency", DependencyImportStyle::Named).await; - let server = fixture - .harness - .server(&fixture.project_root) - .expect("production project server"); - let before = - wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; - - let zero = - exact_symbol_payload(&server, json!({"name": "ExactOnlyDependency", "limit": 5})).await; - assert_eq!( - zero["count"], 0, - "the ignored dependency must be absent before explicit admission: {zero}" - ); - let after_zero = - wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; - assert_eq!(code_generation(&after_zero), code_generation(&before)); - - let arguments = json!({ - "name": "ExactOnlyDependency", - "limit": 5, - "lazy_index_ignored_dependencies": true - }); - let first = handle_real_server_tool_call_raw( - &server, - "tracedecay_find_exact_symbol", - arguments.clone(), - ) - .await; - assert_generation_advanced_retry(&first); - - let after = - wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; - assert_ne!( - code_generation(&after), - code_generation(&before), - "the scheduler must publish a new serving generation before requesting a retry" - ); - - let payload = exact_symbol_payload(&server, arguments).await; - assert_eq!(payload["count"], 1, "retry must find one symbol: {payload}"); - let matches = payload["matches"] - .as_array() - .expect("exact-symbol retry matches"); - assert_eq!(matches.len(), 1, "retry must find one symbol: {payload}"); - assert_eq!( - matches[0]["name"], "ExactOnlyDependency", - "retry must return the requested dependency symbol: {payload}" - ); - assert_eq!( - matches[0]["file"], "node_modules/pkg/index.d.ts", - "retry must return the ignored dependency declaration: {payload}" - ); - fixture.harness.shutdown().await; -} - -#[tokio::test] -async fn search_explicit_lazy_admission_advances_generation_then_retry_finds_dependency_chunk_once() -{ - let fixture = - lazy_dependency_fixture("SearchOnlyDependency", DependencyImportStyle::Default).await; - let server = fixture - .harness - .server(&fixture.project_root) - .expect("production project server"); - let before = - wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; - - let zero = search_payload(&server, json!({"query": "default", "limit": 5})).await; - assert_eq!( - zero["results"], - json!([]), - "the ignored dependency must be absent before explicit admission: {zero}" - ); - let after_zero = - wait_for_search_payload(&server, json!({"query": "GenerationAnchor", "limit": 1})).await; - assert_eq!(code_generation(&after_zero), code_generation(&before)); - - let arguments = json!({ - "query": "default", - "limit": 5, - "lazy_index_ignored_dependencies": true - }); - let first = search_payload(&server, arguments.clone()).await; - assert_eq!( - first["results"], - json!([]), - "generation-advancing admission must preserve the successful lexical result: {first}" - ); - assert_eq!( - code_generation(&first), - code_generation(&before), - "the current response remains bound to the generation that produced its lexical result" - ); - - let retry = wait_for_search_payload(&server, arguments.clone()).await; - assert_ne!( - code_generation(&retry), - code_generation(&before), - "the retry must bind the scheduler-published generation" - ); - let results = retry["results"].as_array().expect("search retry results"); - assert_eq!( - results.len(), - 1, - "retry must find one dependency chunk: {retry}" - ); - let result = &results[0]; - let anchor = result["candidate"]["anchor_id"] - .as_str() - .expect("search retry candidate anchor"); - assert!( - anchor.starts_with("code-chunk:"), - "the default keyword must bind the admitted dependency chunk: {retry}" - ); - let chunk_id = anchor - .strip_prefix("code-chunk:") - .expect("search retry chunk anchor"); - let expected_source = format!("code-chunk:{}:{chunk_id}", code_generation(&retry)); - assert!( - result["candidate"]["occurrences"] - .as_array() - .is_some_and(|occurrences| occurrences.iter().any(|occurrence| { - occurrence["source_occurrence_id"].as_str() == Some(expected_source.as_str()) - && occurrence["file_occurrence_id"] - .as_str() - .is_some_and(|file| !file.is_empty()) - })), - "the returned chunk must retain its exact generation, chunk, and file occurrence: {retry}" - ); - let stable = wait_for_search_payload(&server, arguments).await; - assert_eq!( - code_generation(&stable), - code_generation(&retry), - "a positive retry must not schedule another generation: {stable}" - ); - assert_eq!( - stable["results"].as_array().map(Vec::len), - Some(1), - "a positive retry must continue returning exactly one result: {stable}" - ); - fixture.harness.shutdown().await; -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/edit_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/edit_test.rs index f228faada0..13410a542a 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/edit_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/edit_test.rs @@ -35,7 +35,7 @@ async fn source_edit_preview_apply_and_retry_use_daemon_owned_cas_authority() { let initial = b"fn old_name() {}\r\n// exact \xE2\x98\x83\n"; let applied = b"fn new_name() {}\r\n// exact \xE2\x98\x83\n"; fs::write(project.join("src/main.rs"), initial).unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let preview = handle_tool_call( &cg, @@ -153,7 +153,7 @@ async fn path_containment_config_rejects_parent_traversal_before_serving_config( ) .unwrap(); - let (cg, _env) = init_test_project(&project).await; + let cg = init_test_project(&project).await; let result = handle_tool_call( &cg, @@ -179,7 +179,7 @@ async fn path_containment_read_rejects_parent_traversal_before_serving_file() { fs::write(project.join("src/main.rs"), "fn main() {}\n").unwrap(); fs::write(dir.path().join("outside.rs"), "fn leaked() {}\n").unwrap(); - let (cg, _env) = init_test_project(&project).await; + let cg = init_test_project(&project).await; let result = handle_tool_call( &cg, @@ -207,7 +207,7 @@ async fn read_and_outline_preserve_symlink_indexed_file_key() { fs::write(indexed_src.join("lib.rs"), "pub fn through_symlink() {}\n").unwrap(); unix_fs::symlink(&indexed_src, project.join("src")).unwrap(); - let (cg, _env) = init_test_project(&project).await; + let cg = init_test_project(&project).await; wait_for_source_generation(&cg, "through_symlink").await; let read = handle_tool_call( @@ -274,7 +274,7 @@ async fn outline_preserves_generation_payload_and_adds_ast_grep_outline_when_ava let dir = test_temp_dir(); let project = dir.path().join("project"); crate::fixture::write_indexed_fixture_sources(&project); - let (cg, _env) = init_test_project(&project).await; + let cg = init_test_project(&project).await; wait_for_source_generation(&cg, "helper").await; let result = handle_tool_call( &cg, @@ -329,7 +329,7 @@ async fn outline_markdown_section_carries_preview_handle_and_checklist_state() { ), ) .unwrap(); - let (cg, _env) = init_test_project(&project).await; + let cg = init_test_project(&project).await; let result = handle_tool_call( &cg, @@ -414,7 +414,7 @@ async fn path_containment_config_rejects_symlink_escape_before_serving_config() .unwrap(); unix_fs::symlink(&outside_dir, project.join("escape")).unwrap(); - let (cg, _env) = init_test_project(&project).await; + let cg = init_test_project(&project).await; let result = handle_tool_call( &cg, @@ -460,7 +460,7 @@ async fn test_str_replace_not_found() { fs::write(project.join("src/main.rs"), "fn hello() {}\n").unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -491,7 +491,7 @@ async fn test_str_replace_multiple_matches_fails() { fs::write(project.join("src/main.rs"), "fn foo() {}\nfn foo() {}\n").unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -527,7 +527,7 @@ async fn test_multi_str_replace_atomic_failure() { fs::write(project.join("src/main.rs"), "fn foo() {}\nfn baz() {}\n").unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -571,7 +571,7 @@ async fn test_multi_str_replace_unicode_preview_does_not_panic() { let original = "fn main() {}\n"; fs::write(project.join("src/main.rs"), original).unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let missing_old = format!("{}é", "a".repeat(19)); let result = handle_tool_call( @@ -620,7 +620,7 @@ async fn test_multi_str_replace_earlier_insertion_collision_lands_correctly() { ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -664,7 +664,7 @@ async fn test_multi_str_replace_overlapping_ranges_error() { let original = "abcdef\n"; fs::write(project.join("src/main.rs"), original).unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -716,7 +716,7 @@ async fn test_replace_symbol_documented_fn_keeps_single_doc_comment() { ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -766,7 +766,7 @@ async fn test_insert_at_symbol_before_lands_above_attribute() { ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -816,7 +816,7 @@ async fn test_str_replace_unsupported_file_type_succeeds() { "stylesheet fixture must exist before dispatch" ); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; wait_for_source_generation(&cg, "source_edit_anchor").await; let result = handle_tool_call( @@ -860,7 +860,7 @@ async fn ast_grep_rewrite_has_literal_fallback_when_binary_missing() { fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/lib.rs"), "pub fn old_name() {}\n").unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, "tracedecay_ast_grep_rewrite", @@ -896,7 +896,7 @@ async fn ast_grep_rewrite_uses_current_cli_update_flag() { ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, "tracedecay_ast_grep_rewrite", @@ -939,7 +939,7 @@ async fn ast_grep_rewrite_surfaces_useful_error_on_empty_stderr() { fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/lib.rs"), "pub fn foo() {}\n").unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, "tracedecay_ast_grep_rewrite", @@ -991,7 +991,7 @@ async fn test_multi_str_replace_unsupported_file_type_succeeds() { "stylesheet fixture must exist before dispatch" ); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; wait_for_source_generation(&cg, "source_edit_anchor").await; let result = handle_tool_call( @@ -1043,7 +1043,7 @@ async fn test_insert_at_string_anchor_before() { let applied = b"line one\nfirst inserted\nsecond inserted\nline two\nline three\n"; fs::write(project.join("src/main.rs"), initial).unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let preview = handle_tool_call( &cg, @@ -1098,7 +1098,7 @@ async fn test_insert_at_line_number() { ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -1141,7 +1141,7 @@ async fn test_insert_at_anchor_not_found() { fs::write(project.join("src/main.rs"), "line one\nline two\n").unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -1174,7 +1174,7 @@ async fn test_insert_at_unicode_anchor_prefix_does_not_panic() { let original = "line one\nline two\n"; fs::write(project.join("src/main.rs"), original).unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let long_anchor = format!("{}é", "a".repeat(99)); let result = handle_tool_call( @@ -1214,7 +1214,7 @@ async fn test_insert_at_ambiguous_anchor() { ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_search_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_search_behavior_test.rs index fbc7806142..f6fc67d85c 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_search_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_search_behavior_test.rs @@ -9,7 +9,6 @@ use std::fs; use std::path::Path; -use std::process::Command; use std::sync::Arc; use serde_json::{Value, json}; @@ -17,7 +16,7 @@ use tracedecay::daemon::ProductionProjectCompositionHarnessV1; use tracedecay::mcp::McpServer; use crate::support::{ - TestTempDir, extract_real_server_text, handle_real_server_tool_call, + TestTempDir, commit_worktree, extract_real_server_text, handle_real_server_tool_call, handle_real_server_tool_call_raw, production_composition_fixture, test_temp_dir, }; @@ -293,32 +292,7 @@ fn assert_problem(response: &Value, expected: Value) { fn initialize_fact_project(root: &Path) { fs::create_dir_all(root).expect("fact project root"); crate::fixture::write_indexed_fixture_sources(root); - let init = Command::new(crate::common::git_program()) - .args(["init", "-q"]) - .current_dir(root) - .status() - .expect("git init"); - assert!(init.success(), "git init should succeed"); - let add = Command::new(crate::common::git_program()) - .args(["add", "."]) - .current_dir(root) - .status() - .expect("git add"); - assert!(add.success(), "git add should succeed"); - let commit = Command::new(crate::common::git_program()) - .args([ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay@example.invalid", - "commit", - "-qm", - "fact search fixture", - ]) - .current_dir(root) - .status() - .expect("git commit"); - assert!(commit.success(), "git commit should succeed"); + commit_worktree(root, "fact search fixture"); } struct CrossProject { diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs index 9f113bd258..c93a12bd37 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs @@ -1,14 +1,7 @@ #![cfg(feature = "test-transport")] -mod diff_context_behavior; -mod gini; -mod graph_readiness; -mod hotspots; -mod recursion_behavior; - use crate::common::fixture::git_run; use crate::support::*; -use graph_readiness::{find_node_id, wait_for_current_graph}; use serde_json::{Value, json}; use std::fmt::Write as _; use std::fs; @@ -130,17 +123,12 @@ async fn close_test_graph(host: impl AnalysisToolHost) { host.close_analysis_host().await; } -async fn setup_project() -> (ProductionCompositionFixture, ()) { - (production_composition_fixture().await, ()) -} - -async fn setup_empty_analysis_project() -> (ProductionCompositionFixture, (), ()) { - let fixture = production_composition_fixture_with_sources(|project| { +async fn setup_empty_analysis_project() -> ProductionCompositionFixture { + production_composition_fixture_with_sources(|project| { fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/lib.rs"), "").unwrap(); }) - .await; - (fixture, (), ()) + .await } fn write_integration_test_risk_sources(project: &Path) { @@ -167,14 +155,12 @@ fn write_integration_test_risk_sources(project: &Path) { .unwrap(); } -async fn setup_integration_test_risk_project() -> (ProductionCompositionFixture, ()) { - let fixture = - production_composition_fixture_with_sources(write_integration_test_risk_sources).await; - (fixture, ()) +async fn setup_integration_test_risk_project() -> ProductionCompositionFixture { + production_composition_fixture_with_sources(write_integration_test_risk_sources).await } -async fn setup_test_risk_non_src_fixture() -> (ProductionCompositionFixture, ()) { - let fixture = production_composition_fixture_with_sources(|project| { +async fn setup_test_risk_non_src_fixture() -> ProductionCompositionFixture { + production_composition_fixture_with_sources(|project| { write_integration_test_risk_sources(project); fs::write( project.join("build.rs"), @@ -183,12 +169,11 @@ async fn setup_test_risk_non_src_fixture() -> (ProductionCompositionFixture, ()) ) .unwrap(); }) - .await; - (fixture, ()) + .await } -async fn setup_workspace_test_risk_fixture() -> (ProductionCompositionFixture, ()) { - let fixture = production_composition_fixture_with_sources(|project| { +async fn setup_workspace_test_risk_fixture() -> ProductionCompositionFixture { + production_composition_fixture_with_sources(|project| { fs::create_dir_all(project.join("crates/demo/src")).unwrap(); fs::create_dir_all(project.join("crates/demo/tests")).unwrap(); fs::write( @@ -212,12 +197,11 @@ async fn setup_workspace_test_risk_fixture() -> (ProductionCompositionFixture, ( ) .unwrap(); }) - .await; - (fixture, ()) + .await } -async fn setup_ts_describe_it_project() -> (ProductionCompositionFixture, ()) { - let fixture = production_composition_fixture_with_sources(|project| { +async fn setup_ts_describe_it_project() -> ProductionCompositionFixture { + production_composition_fixture_with_sources(|project| { fs::create_dir_all(project.join("src")).unwrap(); fs::write( project.join("package.json"), @@ -236,12 +220,11 @@ async fn setup_ts_describe_it_project() -> (ProductionCompositionFixture, ()) { ) .unwrap(); }) - .await; - (fixture, ()) + .await } -async fn setup_unsafe_block_fixture() -> (ProductionCompositionFixture, ()) { - let fixture = production_composition_fixture_with_sources(|project| { +async fn setup_unsafe_block_fixture() -> ProductionCompositionFixture { + production_composition_fixture_with_sources(|project| { fs::create_dir_all(project.join("src")).unwrap(); fs::write( project.join("Cargo.toml"), @@ -267,11 +250,10 @@ pub fn safe_add(a: u64, b: u64) -> u64 { ) .unwrap(); }) - .await; - (fixture, ()) + .await } -async fn init_test_project(project: &Path) -> (MountedProductionProject, ()) { +async fn init_test_project(project: &Path) -> MountedProductionProject { if !project.join(".git").is_dir() { git_run(project, &["init", "--quiet"]); git_run(project, &["add", "."]); @@ -301,7 +283,7 @@ async fn init_test_project(project: &Path) -> (MountedProductionProject, ()) { project_root: project.to_path_buf(), }; wait_for_current_graph(&mounted).await; - (mounted, ()) + mounted } #[tokio::test] @@ -337,7 +319,7 @@ pub fn recovered() -> BuildOptions { "#, ) .unwrap(); - let (graph, _env) = init_test_project(&project_root).await; + let graph = init_test_project(&project_root).await; let result = handle_tool_call( &graph, @@ -399,7 +381,7 @@ pub mod second { "#, ) .unwrap(); - let (graph, _env) = init_test_project(&project_root).await; + let graph = init_test_project(&project_root).await; let result = handle_tool_call( &graph, @@ -466,7 +448,7 @@ export default defineConfig({ "export const orphan = 1;\n", ) .unwrap(); - let (graph, _env) = init_test_project(&project_root).await; + let graph = init_test_project(&project_root).await; let result = handle_tool_call( &graph, @@ -551,7 +533,7 @@ async fn test_branch_list_reports_live_vs_serving_drift_state() { /// zero below a real negative result rather than an unpopulated index. #[tokio::test] async fn test_dead_code() { - let (cg, _dir) = setup_project().await; + let cg = production_composition_fixture().await; let _populated = find_node_id(&cg, "format_greeting").await; let result = handle_tool_call(&cg, "tracedecay_dead_code", json!({}), None, None) @@ -578,7 +560,7 @@ async fn test_dead_code() { /// of the file's symbols as modified and `main` as impacted downstream. #[tokio::test] async fn test_diff_context() { - let (cg, _dir) = setup_project().await; + let cg = production_composition_fixture().await; wait_for_current_graph(&cg).await; let result = handle_tool_call( &cg, @@ -636,7 +618,7 @@ async fn test_diff_context() { /// a real "no cycles here" rather than "nothing was analysed". #[tokio::test] async fn test_circular() { - let (cg, _dir) = setup_project().await; + let cg = production_composition_fixture().await; let _populated = find_node_id(&cg, "helper").await; let result = handle_tool_call(&cg, "tracedecay_circular", json!({}), None, None) @@ -659,7 +641,7 @@ async fn test_circular() { #[tokio::test] async fn test_rename_preview() { - let (cg, _dir) = setup_project().await; + let cg = production_composition_fixture().await; let node_id = find_node_id(&cg, "helper").await; let result = handle_tool_call( &cg, @@ -716,7 +698,7 @@ async fn test_rename_preview() { /// proves the call edges are present, which is what makes zero meaningful. #[tokio::test] async fn test_recursion() { - let (cg, _dir) = setup_project().await; + let cg = production_composition_fixture().await; let _populated = find_node_id(&cg, "format_greeting").await; let result = handle_tool_call(&cg, "tracedecay_recursion", json!({}), None, None) @@ -807,7 +789,7 @@ async fn pr_context_no_git_returns_structured_git_error() { #[tokio::test] async fn test_port_status() { - let (cg, _dir) = setup_project().await; + let cg = production_composition_fixture().await; wait_for_current_graph(&cg).await; let result = handle_tool_call( &cg, @@ -890,7 +872,7 @@ async fn port_status_does_not_match_methods_of_different_parents() { ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -949,7 +931,7 @@ async fn port_status_matches_methods_with_same_parent_type() { ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -976,7 +958,7 @@ async fn port_status_matches_methods_with_same_parent_type() { #[tokio::test] async fn test_port_order() { - let (cg, _dir) = setup_project().await; + let cg = production_composition_fixture().await; wait_for_current_graph(&cg).await; let result = handle_tool_call( &cg, @@ -1055,7 +1037,7 @@ async fn port_order_sorts_a_tied_level_before_applying_the_limit() { "pub fn zeta() {}\npub fn alpha() {}\npub fn middle() {}\n", ) .unwrap(); - let (cg, _env) = init_test_project(&project_root).await; + let cg = init_test_project(&project_root).await; let result = handle_tool_call( &cg, @@ -1080,7 +1062,7 @@ async fn port_order_sorts_a_tied_level_before_applying_the_limit() { #[tokio::test] async fn test_rename_preview_not_found() { - let (cg, _env, _dir) = setup_empty_analysis_project().await; + let cg = setup_empty_analysis_project().await; let result = handle_tool_call( &cg, "tracedecay_rename_preview", @@ -1119,7 +1101,7 @@ async fn commit_context_clean_worktree_returns_json() { git_run(project, &["add", "."]); git_run(project, &["commit", "-m", "init"]); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, "tracedecay_commit_context", @@ -1173,7 +1155,7 @@ async fn commit_context_staged_source_and_test_reports_symbols() { ); git_run(project, &["add", "src/lib.rs", "tests/invoice_test.rs"]); - let (host, _env) = init_test_project(project).await; + let host = init_test_project(project).await; let result = handle_tool_call( &host, "tracedecay_commit_context", @@ -1235,7 +1217,7 @@ async fn commit_context_config_and_docs_report_chore() { write_project_file(project, "notes.txt", "Ship the invoice total.\n"); git_run(project, &["add", "billing.cfg", "notes.txt"]); - let (host, _env) = init_test_project(project).await; + let host = init_test_project(project).await; let result = handle_tool_call( &host, "tracedecay_commit_context", @@ -1298,7 +1280,7 @@ async fn commit_context_staged_only_excludes_unstaged_file() { ); git_run(project, &["add", "src/lib.rs"]); - let (host, _env) = init_test_project(project).await; + let host = init_test_project(project).await; let staged = handle_tool_call( &host, "tracedecay_commit_context", @@ -1374,7 +1356,7 @@ async fn commit_context_unborn_head_is_git_status_error() { &[("src/lib.rs", "pub fn baseline() {}\n")], "seed context", ); - let (host, _env) = init_test_project(project).await; + let host = init_test_project(project).await; git_run(project, &["symbolic-ref", "HEAD", "refs/heads/unborn"]); let result = handle_tool_call( @@ -1455,7 +1437,7 @@ async fn test_changelog_with_real_git() { git_run(project, &["add", "."]); git_run(project, &["commit", "-m", "add function"]); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -1496,7 +1478,7 @@ async fn test_changelog_with_real_git() { /// breakdown. #[tokio::test] async fn test_health_detailed_includes_raw_signals() { - let (cg, _dir) = setup_project().await; + let cg = production_composition_fixture().await; let result = handle_tool_call( &cg, "tracedecay_health", @@ -1811,7 +1793,7 @@ _No dependency clusters found._ #[tokio::test] async fn test_test_risk() { - let (cg, _dir) = setup_project().await; + let cg = production_composition_fixture().await; let result = handle_tool_call( &cg, "tracedecay_test_risk", @@ -1852,7 +1834,7 @@ async fn test_test_risk() { #[tokio::test] async fn test_test_risk_distinguishes_direct_and_closure_attribution() { - let (cg, _dir) = setup_integration_test_risk_project().await; + let cg = setup_integration_test_risk_project().await; let result = handle_tool_call( &cg, "tracedecay_test_risk", @@ -1928,7 +1910,7 @@ async fn test_test_risk_distinguishes_direct_and_closure_attribution() { #[tokio::test] async fn test_test_risk_scopes_workspace_source_before_following_external_test_callers() { - let (cg, _dir) = setup_workspace_test_risk_fixture().await; + let cg = setup_workspace_test_risk_fixture().await; let result = handle_tool_call( &cg, "tracedecay_test_risk", @@ -1958,7 +1940,7 @@ async fn test_test_risk_scopes_workspace_source_before_following_external_test_c #[tokio::test] async fn test_test_risk_attributes_ts_describe_it_tests() { - let (cg, _dir) = setup_ts_describe_it_project().await; + let cg = setup_ts_describe_it_project().await; let result = handle_tool_call( &cg, "tracedecay_test_risk", @@ -1998,7 +1980,7 @@ async fn test_test_risk_attributes_ts_describe_it_tests() { #[tokio::test] async fn test_test_map_lists_ts_it_title_as_covering_test() { - let (cg, _dir) = setup_ts_describe_it_project().await; + let cg = setup_ts_describe_it_project().await; let result = handle_tool_call( &cg, "tracedecay_test_map", @@ -2028,7 +2010,7 @@ async fn test_test_map_lists_ts_it_title_as_covering_test() { #[tokio::test] async fn test_test_risk_excludes_non_src_functions_from_denominator_and_risks() { - let (cg, _dir) = setup_test_risk_non_src_fixture().await; + let cg = setup_test_risk_non_src_fixture().await; let result = handle_tool_call( &cg, "tracedecay_test_risk", @@ -2096,7 +2078,7 @@ fn helper() { "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; wait_for_current_graph(&cg).await; let result = handle_tool_call(&cg, "tracedecay_todos", json!({}), None, None) @@ -2145,7 +2127,7 @@ fn main() { "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -2184,7 +2166,7 @@ pub fn second() { dep::shared(); } ) .unwrap(); fs::write(project.join("src/dep.rs"), "pub fn shared() {}\n").unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -2223,13 +2205,13 @@ async fn recursion_keeps_direct_recursion() { "pub fn recurse(n: u32) -> u32 {\n if n == 0 { 0 } else { recurse(n - 1) }\n}\n\npub fn nonrecursive() -> u32 { 42 }\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call(&cg, "tracedecay_recursion", json!({}), None, None) .await .unwrap(); let output = extract_json(&result.value); assert_eq!( - recursion_behavior::public_recursion_report(&output), + public_recursion_report(&output), json!({ "cycle_count": 1, "cycles": [{ @@ -2242,7 +2224,7 @@ async fn recursion_keeps_direct_recursion() { }), "direct recursion must be the only cycle, and `nonrecursive` must stay out: {output}" ); - recursion_behavior::assert_reported_cycles_close(&output); + assert_reported_cycles_close(&output); } #[tokio::test] @@ -2257,13 +2239,13 @@ async fn recursion_filters_self_edge_artifacts() { "pub fn recurse(n: u32) -> u32 {\n if n == 0 { 0 } else { recurse(n - 1) }\n}\n\npub struct Triplet {\n rows: Vec,\n}\n\nimpl Triplet {\n pub fn push(&mut self, row: usize) {\n self.rows.push(row);\n }\n}\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call(&cg, "tracedecay_recursion", json!({}), None, None) .await .unwrap(); let output = extract_json(&result.value); assert_eq!( - recursion_behavior::public_recursion_report(&output), + public_recursion_report(&output), json!({ "cycle_count": 1, "cycles": [{ @@ -2276,7 +2258,7 @@ async fn recursion_filters_self_edge_artifacts() { }), "`self.rows.push` must not become a cycle while `recurse` is reported: {output}" ); - recursion_behavior::assert_reported_cycles_close(&output); + assert_reported_cycles_close(&output); } #[tokio::test] @@ -2295,13 +2277,13 @@ pub fn c() { a(); } "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call(&cg, "tracedecay_recursion", json!({}), None, None) .await .unwrap(); let output = extract_json(&result.value); assert_eq!( - recursion_behavior::public_recursion_report(&output), + public_recursion_report(&output), json!({ "cycle_count": 1, "cycles": [{ @@ -2316,7 +2298,7 @@ pub fn c() { a(); } }), "the only cycle is a -> b -> c -> a: {output}" ); - recursion_behavior::assert_reported_cycles_close(&output); + assert_reported_cycles_close(&output); } /// `tracedecay_changelog`'s response must not list directories under @@ -2344,7 +2326,7 @@ async fn changelog_filters_directory_paths() { fs::write(project.join("src/sub/added.rs"), "pub fn a() {}\n").unwrap(); git_run(project, &["add", "."]); git_run(project, &["commit", "-m", "two"]); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -2391,7 +2373,7 @@ pub fn caller() { called(); } "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let default_result = handle_tool_call(&cg, "tracedecay_dead_code", json!({}), None, None) .await @@ -2445,7 +2427,7 @@ async fn diagnose_normalizes_absolute_and_backslash_paths() { "pub fn target() {}\npub fn caller() { target(); }\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let abs_path = project.join("src/lib.rs"); let abs_str = abs_path.to_string_lossy().to_string(); @@ -2511,7 +2493,7 @@ pub fn helper() {} "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let caller_id = find_node_id(&cg, "caller").await; let result = handle_tool_call( @@ -2570,7 +2552,7 @@ impl Default for B { fn default() -> Self { B } } "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -2630,7 +2612,7 @@ async fn circular_reports_one_entry_per_scc_not_per_walk() { "use crate::a::a_fn;\npub fn c_fn() { a_fn(); }\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call(&cg, "tracedecay_circular", json!({}), None, None) .await .unwrap(); @@ -2681,7 +2663,7 @@ pub fn leaf() {} "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, "tracedecay_port_order", @@ -2743,7 +2725,7 @@ pub fn h() { a(); } "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, "tracedecay_port_order", @@ -2821,7 +2803,7 @@ impl Triplet { "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, "tracedecay_port_order", @@ -2858,7 +2840,7 @@ pub trait Leaf: Middle {} "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call(&cg, "tracedecay_inheritance_depth", json!({}), None, None) .await .unwrap(); @@ -2908,7 +2890,7 @@ async fn analysis_symbol_locations_are_one_based() { "function abandonedHelper(): number { return 7; }\nexport function entry(): number { return 1; }\n", ) .unwrap(); - let (graph, _env) = init_test_project(&project_root).await; + let graph = init_test_project(&project_root).await; for (tool, arguments, collection, symbol, expected_line) in [ ( @@ -3013,7 +2995,7 @@ async fn typescript_typed_variables_reach_public_type_relation_queries() { export let fallback: Greeter = primary;\n", ) .unwrap(); - let (graph, ()) = init_test_project(&project_root).await; + let graph = init_test_project(&project_root).await; let greeter_id = find_node_id(&graph, "Greeter").await; let variable_ids = [ ("primary", find_node_id(&graph, "primary").await), @@ -3103,7 +3085,7 @@ function helper() { return unrelated; } "#, ) .unwrap(); - let (cg, _env) = init_test_project(&project_root).await; + let cg = init_test_project(&project_root).await; let parent_id = find_node_id(&cg, "SettingsEditable").await; let hierarchy = handle_tool_call( @@ -3257,7 +3239,7 @@ async fn circular_emits_disjoint_sccs_under_load() { ) .unwrap(); } - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; // Disjointness is only observable when every member is listed, so raise the // member bound above this fixture's 15-file component. let result = handle_tool_call( @@ -3309,7 +3291,7 @@ async fn diff_context_dedupes_modified_symbols_on_duplicate_input() { "pub struct S; pub fn one() {} pub fn two() {}\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -3358,7 +3340,7 @@ async fn changelog_filters_deleted_directory_entries() { fs::remove_dir_all(project.join("crates")).unwrap(); git_run(project, &["add", "-A"]); git_run(project, &["commit", "-m", "drop crates"]); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, "tracedecay_changelog", @@ -3418,7 +3400,7 @@ async fn pr_context_collapses_cargo_toml_keys() { git_run(project, &["add", "."]); git_run(project, &["commit", "-m", "deps"]); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -3592,7 +3574,7 @@ async fn pr_context_reports_the_pinned_feature_summary() { Some("2020-01-03T03:04:05Z"), ); - let (host, _env) = init_test_project(project).await; + let host = init_test_project(project).await; let feature = pr_context_json( &host, json!({"format": "json", "base_ref": "master", "head_ref": "feature"}), @@ -3808,7 +3790,7 @@ fn dead_helper_with_attr() {} "#, ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call(&cg, "tracedecay_dead_code", json!({}), None, None) .await @@ -3833,7 +3815,7 @@ fn dead_helper_with_attr() {} /// must surface the site. #[tokio::test] async fn unsafe_patterns_reports_unsafe_block_in_markdown_and_json() { - let (cg, _project) = setup_unsafe_block_fixture().await; + let cg = setup_unsafe_block_fixture().await; // Markdown is the runtime default; request it explicitly so the test helper // (which force-injects `format=json` for tools outside its allowlist) does @@ -3928,7 +3910,7 @@ pub fn write_both(target: &mut Target, other: &mut Other) { "#, ) .unwrap(); - let (host, _env) = init_test_project(&project_root).await; + let host = init_test_project(&project_root).await; let result = handle_tool_call( &host, @@ -3993,7 +3975,7 @@ pub fn while_let_then_sibling(target: &Target, mut other: Option) -> u32 let shadow_root = shadow_dir.path().join("project"); fs::create_dir_all(shadow_root.join("src")).unwrap(); fs::write(shadow_root.join("src/lib.rs"), shadow_source).unwrap(); - let (shadow_host, _shadow_env) = init_test_project(&shadow_root).await; + let shadow_host = init_test_project(&shadow_root).await; let error = expect_tool_error( handle_tool_call( &shadow_host, @@ -4016,15 +3998,21 @@ async fn field_sites_ignores_field_text_in_real_rust_literals() { let dir = test_temp_dir(); let project_root = dir.path().join("project"); fs::create_dir_all(project_root.join("src")).unwrap(); - fs::write( - project_root.join("src/lib.rs"), - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../tracedecay-session-memory/src/monitor_ring.rs" - )), - ) - .unwrap(); - let (host, _env) = init_test_project(&project_root).await; + let source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../tracedecay-session-memory/src/monitor_ring.rs" + )); + let line_of = |needle: &str| { + source + .lines() + .position(|line| line.contains(needle)) + .map(|index| index as u64 + 1) + .unwrap_or_else(|| panic!("fixture lost the line containing {needle:?}")) + }; + let literal_line = line_of("\"monitor.mmap\""); + let write_line = line_of("self.mmap = unsafe"); + fs::write(project_root.join("src/lib.rs"), source).unwrap(); + let host = init_test_project(&project_root).await; let result = handle_tool_call( &host, @@ -4043,10 +4031,13 @@ async fn field_sites_ignores_field_text_in_real_rust_literals() { assert!( output["read_sites"] .as_array() - .is_some_and(|sites| sites.iter().all(|site| site["line"] != 38)), + .is_some_and(|sites| sites.iter().all(|site| site["line"] != literal_line)), "string literal was reported as a field site: {output}" ); - assert_eq!(output["write_sites"][0]["line"], 256, "payload: {output}"); + assert_eq!( + output["write_sites"][0]["line"], write_line, + "payload: {output}" + ); } fn field_site(line: u64, enclosing: &str, snippet: &str) -> Value { @@ -4133,7 +4124,7 @@ async fn field_sites_behavior_reports_literal_read_and_write_sites() { let project_root = dir.path().join("project"); fs::create_dir_all(project_root.join("src")).unwrap(); fs::write(project_root.join("src/lib.rs"), FIELD_BEHAVIOR_SOURCE).unwrap(); - let (host, _env) = init_test_project(&project_root).await; + let host = init_test_project(&project_root).await; let read_method = "src/lib.rs::Counter::read"; let bump = "src/lib.rs::bump"; @@ -4247,7 +4238,7 @@ async fn field_sites_behavior_reports_literal_read_and_write_sites() { let qualified_root = qualified_dir.path().join("project"); fs::create_dir_all(qualified_root.join("src")).unwrap(); fs::write(qualified_root.join("src/lib.rs"), FIELD_QUALIFIED_SOURCE).unwrap(); - let (qualified_host, _qualified_env) = init_test_project(&qualified_root).await; + let qualified_host = init_test_project(&qualified_root).await; let qualified = call_field_sites( &qualified_host, json!({"field": "Counter::n", "format": "json"}), @@ -4318,7 +4309,7 @@ pub fn closure_then_sibling(counter: &Counter) -> u32 { "#, ) .unwrap(); - let (host, _env) = init_test_project(&project_root).await; + let host = init_test_project(&project_root).await; let error = expect_tool_error( handle_tool_call( @@ -4337,3 +4328,1231 @@ pub fn closure_then_sibling(counter: &Counter) -> u32 { close_test_graph(host).await; } + +async fn wait_for_current_graph(host: &impl AnalysisToolHost) { + tokio::time::timeout(Duration::from_secs(20), async { + loop { + let status = handle_tool_call( + host, + "tracedecay_status", + json!({ + "format": "json", + "include_branch_diagnostics": false, + "include_storage_health": false, + "include_session_ingest": false, + "include_staleness": false, + }), + None, + None, + ) + .await + .expect("typed project status while awaiting the current graph"); + let status: Value = serde_json::from_str(extract_text(&status.value)) + .expect("typed project status JSON"); + let freshness = &status["code_index_freshness"]; + let serving = &freshness["worktree"]["code_graph_serving"]; + match ( + freshness["status"].as_str(), + serving["state"].as_str(), + serving["reason"].as_str(), + freshness["worktree"]["staleness_state"].as_str(), + ) { + (Some("current"), Some("ready"), _, _) => break, + (Some("warming"), _, _, _) + | (Some("stale"), Some("ready"), _, Some("verifying")) + | (_, Some("pending"), _, _) + | (_, Some("unavailable"), Some("generation_unavailable"), _) => { + tokio::task::yield_now().await; + } + (_, Some("refused"), _, _) | (_, _, Some("activation_disabled"), _) => { + panic!("graph readiness was refused: {status}"); + } + actual => panic!("graph readiness became {actual:?}: {status}"), + } + } + }) + .await + .expect("graph did not become current within the publication budget"); +} + +async fn find_node_id(host: &impl AnalysisToolHost, name: &str) -> String { + wait_for_current_graph(host).await; + let result = handle_tool_call( + host, + "tracedecay_find_exact_symbol", + json!({"name": name, "limit": 20}), + None, + None, + ) + .await + .unwrap_or_else(|error| panic!("production exact-symbol read failed: {error}")); + let payload: Value = + serde_json::from_str(extract_text(&result.value)).expect("exact-symbol JSON"); + payload["matches"] + .as_array() + .and_then(|matches| { + matches + .iter() + .find(|result| result["name"].as_str() == Some(name)) + }) + .and_then(|result| result["id"].as_str()) + .unwrap_or_else(|| panic!("node '{name}' not found in production generation: {payload}")) + .to_owned() +} + +// `tracedecay_diff_context` as an agent host observes it: one production +// MCP `tools/call`, then the JSON text the caller reads. +// +// The fixture is one crate. `tier_b` calls `tier_c`, and `tier_a` calls +// `tier_b`. `#[test]` on `checks_tier_c` is itself a modified symbol +// (`annotation_usage` named `test`). Lines are the extractor's 0-based +// tree-sitter rows. + +const LIB_RS: &str = "mod tier_a;\nmod tier_b;\nmod tier_c;\n"; +const TIER_A_RS: &str = "use crate::tier_b::tier_b;\n\npub fn tier_a() -> u8 {\n tier_b()\n}\n"; +const TIER_B_RS: &str = "use crate::tier_c::tier_c;\n\npub fn tier_b() -> u8 {\n tier_c()\n}\n"; +const TIER_C_RS: &str = "\ +pub fn tier_c() -> u8 {\n\ + 1\n\ +}\n\ +\n\ +#[test]\n\ +fn checks_tier_c() {\n\ + let _ = tier_c();\n\ +}\n"; + +fn diff_symbol_facts(symbols: &Value) -> Vec { + let Some(symbols) = symbols.as_array() else { + panic!("diff_context symbol list is not an array: {symbols}"); + }; + let mut facts = symbols + .iter() + .map(|symbol| { + json!({ + "name": symbol["name"], + "kind": symbol["kind"], + "file": symbol["file"], + "line": symbol["line"], + }) + }) + .collect::>(); + facts.sort_by(|left, right| { + ( + left["file"].as_str(), + left["name"].as_str(), + left["line"].as_u64(), + ) + .cmp(&( + right["file"].as_str(), + right["name"].as_str(), + right["line"].as_u64(), + )) + }); + facts +} + +fn write_call_chain(project: &Path) { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), LIB_RS).unwrap(); + fs::write(project.join("src/tier_a.rs"), TIER_A_RS).unwrap(); + fs::write(project.join("src/tier_b.rs"), TIER_B_RS).unwrap(); + fs::write(project.join("src/tier_c.rs"), TIER_C_RS).unwrap(); +} + +#[tokio::test] +async fn diff_context_reports_changed_symbols_callers_and_refuses_invalid_input() { + let dir = test_temp_dir(); + let project = dir.path().join("project"); + write_call_chain(&project); + let host = init_test_project(&project).await; + + let changed = handle_tool_call( + &host, + "tracedecay_diff_context", + json!({"files": ["src/tier_c.rs"], "depth": 1, "format": "json"}), + None, + None, + ) + .await + .expect("depth-1 diff_context"); + let changed = extract_json(&changed.value); + assert_eq!(changed["changed_files"], json!(["src/tier_c.rs"])); + // `tier_a` calls `tier_b`, so a depth-1 walk stops with callers still + // unexplored. The tool must say so instead of pretending the radius is + // complete. + assert_eq!(changed["impact_complete"], json!(false), "{changed}"); + assert_eq!( + diff_symbol_facts(&changed["modified_symbols"]), + vec![ + json!({"name": "checks_tier_c", "kind": "function", "file": "src/tier_c.rs", "line": 5}), + json!({"name": "test", "kind": "annotation_usage", "file": "src/tier_c.rs", "line": 4}), + json!({"name": "tier_c", "kind": "function", "file": "src/tier_c.rs", "line": 0}), + ], + "modified symbols: {changed}" + ); + assert_eq!(changed["impacted_symbols_count"], json!(1)); + assert_eq!( + diff_symbol_facts(&changed["impacted_symbols"]), + vec![json!({"name": "tier_b", "kind": "function", "file": "src/tier_b.rs", "line": 2}),], + "direct callers of tier_c: {changed}" + ); + assert_eq!( + changed["affected_tests"], + json!(["src/tier_c.rs"]), + "{changed}" + ); + + let wider = handle_tool_call( + &host, + "tracedecay_diff_context", + json!({"files": ["src/tier_c.rs"], "depth": 2, "format": "json"}), + None, + None, + ) + .await + .expect("depth-2 diff_context"); + let wider = extract_json(&wider.value); + assert_eq!(wider["impact_complete"], json!(true), "{wider}"); + assert_eq!(wider["impacted_symbols_count"], json!(2)); + assert_eq!( + diff_symbol_facts(&wider["impacted_symbols"]), + vec![ + json!({"name": "tier_a", "kind": "function", "file": "src/tier_a.rs", "line": 2}), + json!({"name": "tier_b", "kind": "function", "file": "src/tier_b.rs", "line": 2}), + ], + "depth 2 also reaches tier_a, which only calls tier_b: {wider}" + ); + + let duplicated = handle_tool_call( + &host, + "tracedecay_diff_context", + json!({ + "files": ["src/tier_c.rs", "src/tier_c.rs"], + "depth": 1, + "format": "json" + }), + None, + None, + ) + .await + .expect("duplicate-path diff_context"); + let duplicated = extract_json(&duplicated.value); + assert_eq!(duplicated["changed_files"], json!(["src/tier_c.rs"])); + assert_eq!( + diff_symbol_facts(&duplicated["modified_symbols"]), + vec![ + json!({"name": "checks_tier_c", "kind": "function", "file": "src/tier_c.rs", "line": 5}), + json!({"name": "test", "kind": "annotation_usage", "file": "src/tier_c.rs", "line": 4}), + json!({"name": "tier_c", "kind": "function", "file": "src/tier_c.rs", "line": 0}), + ] + ); + assert_eq!( + diff_symbol_facts(&duplicated["impacted_symbols"]), + vec![json!({"name": "tier_b", "kind": "function", "file": "src/tier_b.rs", "line": 2}),] + ); + + // A path this generation never published carries no symbols, so the + // affected-test walk has no seeds and answers empty and complete rather + // than turning "no such file here" into an invalid request. + let absent = handle_tool_call( + &host, + "tracedecay_diff_context", + json!({"files": ["src/not_in_repo.rs"], "format": "json"}), + None, + None, + ) + .await + .expect("unpublished-path diff_context"); + assert_eq!( + extract_json(&absent.value), + json!({ + "changed_files": ["src/not_in_repo.rs"], + "modified_symbols": [], + "impacted_symbols_count": 0, + "impacted_symbols": [], + "impact_complete": true, + "affected_tests": [] + }) + ); + + let empty_files = handle_tool_call( + &host, + "tracedecay_diff_context", + json!({"files": [], "format": "json"}), + None, + None, + ) + .await + .expect("empty-files diff_context"); + assert_eq!( + extract_json(&empty_files.value), + json!({ + "changed_files": [], + "modified_symbols": [], + "impacted_symbols_count": 0, + "impacted_symbols": [], + "impact_complete": true, + "affected_tests": [] + }) + ); + + let missing_files = handle_tool_call( + &host, + "tracedecay_diff_context", + json!({"format": "json"}), + None, + None, + ) + .await; + assert_eq!( + missing_files + .expect_err("missing files must be refused") + .to_string(), + "config error: tracedecay_diff_context failed over production MCP: missing required parameter: files (array of strings)" + ); + + let files_not_array = handle_tool_call( + &host, + "tracedecay_diff_context", + json!({"files": "src/tier_c.rs", "format": "json"}), + None, + None, + ) + .await; + assert_eq!( + files_not_array + .expect_err("a string files argument must be refused") + .to_string(), + "config error: tracedecay_diff_context failed over production MCP: missing required parameter: files (array of strings)" + ); + + let not_object = handle_tool_call( + &host, + "tracedecay_diff_context", + json!(["src/tier_c.rs"]), + None, + None, + ) + .await; + assert_eq!( + not_object + .expect_err("non-object arguments must be refused") + .to_string(), + "config error: tracedecay_diff_context failed over production MCP: tool execution failed: config error: invalid arguments: tracedecay_diff_context expects a JSON object" + ); + + let zero_depth = handle_tool_call( + &host, + "tracedecay_diff_context", + json!({"files": ["src/tier_c.rs"], "depth": 0, "format": "json"}), + None, + None, + ) + .await; + assert_eq!( + zero_depth.expect_err("depth 0 must be refused").to_string(), + "config error: tracedecay_diff_context failed over production MCP: tool project route failed: reason_code=code-graph-invalid-request retryable=false: the code-graph read request is invalid: code graph impact depth must be positive" + ); + + close_test_graph(host).await; +} + +// Literal `tracedecay_gini` results for a fixture whose metric values are +// fixed by source shape, not by reading the coefficient back out of the tool. +// +// The handler rounds `2*Σ(i*x_i)/(n*Σx) - (n+1)/n` (1-indexed `i` on values +// sorted ascending) to four decimals. One file, or a missing path, is the +// empty-or-singleton case and the coefficient is exactly 0. + +fn write_gini_distribution_sources(project: &Path) { + std::fs::create_dir_all(project.join("src/spans")).unwrap(); + // Body is one block and no branch: complexity 1, line span 1. + std::fs::write( + project.join("src/spans/short.rs"), + "pub fn short() -> i32 { 1 }\n", + ) + .unwrap(); + // Same complexity 1, line span 3 (declaration, body, closing brace). + std::fs::write( + project.join("src/spans/tall.rs"), + "pub fn tall() -> i32 {\n 1\n}\n", + ) + .unwrap(); + // Tiny has one field, Big has three. `plain` is a single block (complexity + // 1). `branched` is if + else (2 branches) inside a body block, so the + // inner blocks reach nesting 2 and the symbol score is 4. + std::fs::write( + project.join("src/kinds.rs"), + "\ +pub struct Tiny {\n \ + pub only: i32,\n\ +}\n\ +\n\ +pub struct Big {\n \ + pub a: i32,\n \ + pub b: i32,\n \ + pub c: i32,\n\ +}\n\ +\n\ +pub fn plain() -> i32 { 1 }\n\ +\n\ +pub fn branched(n: i32) -> i32 {\n \ + if n > 0 {\n \ + n\n \ + } else {\n \ + 0\n \ + }\n\ +}\n", + ) + .unwrap(); +} + +async fn gini_json(host: &impl AnalysisToolHost, args: Value) -> Value { + let result = handle_tool_call(host, "tracedecay_gini", args, None, None) + .await + .expect("tracedecay_gini over production MCP"); + extract_json(&result.value) +} + +/// Equal values do not define an outlier rank. Sort by name so the assertion +/// stays on the reported rows rather than `HashMap` iteration order. +fn outliers_sorted_by_name(mut payload: Value) -> Value { + if let Some(outliers) = payload.get_mut("outliers").and_then(Value::as_array_mut) { + outliers.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); + } + payload +} + +#[tokio::test] +async fn gini_reports_literal_coefficients_for_known_distributions() { + let host = production_composition_fixture_with_sources(write_gini_distribution_sources).await; + + let lines = gini_json( + &host, + json!({ + "format": "json", + "metric": "lines", + "scope": "file", + "path": "src/spans", + }), + ) + .await; + assert_eq!( + lines, + json!({ + "gini": 0.25, + "interpretation": "moderate inequality", + "total_items": 2, + "metric": "lines", + "scope": "file", + "incomplete_complexity_symbols": 0, + "outliers": [ + {"name": "src/spans/tall.rs", "value": 3.0, "pct_of_max": 100.0}, + {"name": "src/spans/short.rs", "value": 1.0, "pct_of_max": 33.0}, + ], + }), + "line spans 1 and 3 must produce Gini 0.25: {lines}" + ); + + let truncated = gini_json( + &host, + json!({ + "format": "json", + "metric": "lines", + "scope": "file", + "path": "src/spans", + "limit": 1, + }), + ) + .await; + assert_eq!( + truncated, + json!({ + "gini": 0.25, + "interpretation": "moderate inequality", + "total_items": 2, + "metric": "lines", + "scope": "file", + "incomplete_complexity_symbols": 0, + "outliers": [ + {"name": "src/spans/tall.rs", "value": 3.0, "pct_of_max": 100.0}, + ], + }), + "limit truncates the ranking and keeps the census: {truncated}" + ); + + let one_file = gini_json( + &host, + json!({ + "format": "json", + "metric": "lines", + "path": "src/spans/tall.rs", + }), + ) + .await; + assert_eq!( + one_file, + json!({ + "gini": 0.0, + "interpretation": "low inequality (healthy)", + "total_items": 1, + "metric": "lines", + "scope": "file", + "incomplete_complexity_symbols": 0, + "outliers": [ + {"name": "src/spans/tall.rs", "value": 3.0, "pct_of_max": 100.0}, + ], + }), + "a single file is perfect equality: {one_file}" + ); + + let missing = gini_json( + &host, + json!({ + "format": "json", + "metric": "lines", + "path": "src/nowhere", + }), + ) + .await; + assert_eq!( + missing, + json!({ + "gini": 0.0, + "interpretation": "low inequality (healthy)", + "total_items": 0, + "metric": "lines", + "scope": "file", + "incomplete_complexity_symbols": 0, + "outliers": [], + }), + "a path with no symbols is an empty census, not the unfiltered one: {missing}" + ); + + // Defaults are complexity + file. Both span functions score 1, so this + // coefficient is 0. The lines call above is 0.25 for the same path. + let defaults = gini_json( + &host, + json!({ + "format": "json", + "path": "src/spans", + }), + ) + .await; + assert_eq!( + outliers_sorted_by_name(defaults.clone()), + json!({ + "gini": 0.0, + "interpretation": "low inequality (healthy)", + "total_items": 2, + "metric": "complexity", + "scope": "file", + "incomplete_complexity_symbols": 0, + "outliers": [ + {"name": "src/spans/short.rs", "value": 1.0, "pct_of_max": 100.0}, + {"name": "src/spans/tall.rs", "value": 1.0, "pct_of_max": 100.0}, + ], + }), + "default metric is complexity, not lines: {defaults}" + ); + + let members = gini_json( + &host, + json!({ + "format": "json", + "metric": "members", + "path": "src/kinds.rs", + }), + ) + .await; + assert_eq!( + members, + json!({ + "gini": 0.25, + "interpretation": "moderate inequality", + "total_items": 2, + "metric": "members", + "scope": "file", + "incomplete_complexity_symbols": 0, + "outliers": [ + {"name": "Big", "value": 3.0, "pct_of_max": 100.0}, + {"name": "Tiny", "value": 1.0, "pct_of_max": 33.0}, + ], + }), + "struct member counts 1 and 3 must produce Gini 0.25: {members}" + ); + + let symbols = gini_json( + &host, + json!({ + "format": "json", + "metric": "complexity", + "scope": "symbol", + "path": "src/kinds.rs", + }), + ) + .await; + assert_eq!( + symbols, + json!({ + "gini": 0.3, + "interpretation": "moderate inequality", + "total_items": 2, + "metric": "complexity", + "scope": "symbol", + "incomplete_complexity_symbols": 0, + "outliers": [ + {"name": "src/kinds.rs:branched", "value": 4.0, "pct_of_max": 100.0}, + {"name": "src/kinds.rs:plain", "value": 1.0, "pct_of_max": 25.0}, + ], + }), + "symbol scores 1 and 4 must produce Gini 0.3: {symbols}" + ); + + close_test_graph(host).await; +} + +#[tokio::test] +async fn gini_empty_index_reports_perfect_equality() { + let host = setup_empty_analysis_project().await; + let payload = gini_json(&host, json!({"format": "json"})).await; + assert_eq!( + payload, + json!({ + "gini": 0.0, + "interpretation": "low inequality (healthy)", + "total_items": 0, + "metric": "complexity", + "scope": "file", + "incomplete_complexity_symbols": 0, + "outliers": [], + }), + "an empty index is not a missing field: {payload}" + ); + close_test_graph(host).await; +} + +// `tracedecay_hotspots` through production MCP `tools/call`. +// +// Occurrence ids are minted per project, so two equal totals may swap order +// across runs. The host-visible ranking of distinct degrees, the line and +// degree of each named symbol, the default page, the clamped page, and the +// zero-limit rejection are stable and asserted literally. + +const CHAIN_SOURCE: &str = "\ +export function quiet(): number {\n\ + return 0;\n\ +}\n\ +\n\ +export function leaf(): number {\n\ + return 1;\n\ +}\n\ +\n\ +export function mid(): number {\n\ + return leaf();\n\ +}\n\ +\n\ +export function hub(): number {\n\ + return mid();\n\ +}\n\ +"; + +fn write_package(project: &Path, name: &str) { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write( + project.join("package.json"), + format!("{{\"name\":\"{name}\",\"private\":true,\"type\":\"module\"}}\n"), + ) + .unwrap(); +} + +fn write_chain_project(project: &Path) { + write_package(project, "hotspots-chain"); + fs::write(project.join("src/calls.ts"), CHAIN_SOURCE).unwrap(); +} + +/// `hub` plus 101 callers. Returns the source length the savings footer +/// measures for `src/fanout.ts`. +fn write_fanout_project(project: &Path) -> usize { + write_package(project, "hotspots-fanout"); + let mut source = String::from("export function hub(): number { return 1; }\n"); + for index in 0..101 { + source.push_str(&format!( + "export function caller{index}(): number {{ return hub(); }}\n" + )); + } + let bytes = source.len(); + fs::write(project.join("src/fanout.ts"), source).unwrap(); + bytes +} + +async fn call_hotspots(host: &MountedProductionProject, arguments: Value) -> Value { + let result = handle_tool_call(host, "tracedecay_hotspots", arguments, None, None) + .await + .unwrap_or_else(|error| panic!("tracedecay_hotspots failed over production MCP: {error}")); + result.value +} + +fn content(result: &Value) -> &[Value] { + result["content"] + .as_array() + .unwrap_or_else(|| panic!("hotspots content missing: {result}")) +} + +fn body_text(result: &Value) -> &str { + let item = &content(result)[0]; + assert_eq!(item["type"], "text", "{result}"); + item["text"] + .as_str() + .unwrap_or_else(|| panic!("hotspots text missing: {result}")) +} + +fn parse_body(result: &Value) -> Value { + let text = body_text(result); + serde_json::from_str(text) + .unwrap_or_else(|error| panic!("hotspots JSON did not parse: {error}\n{text}")) +} + +fn assert_savings_footer(result: &Value, source_bytes: usize) { + let items = content(result); + assert_eq!(items.len(), 2, "{result}"); + assert_eq!(items[1]["type"], "text", "{result}"); + let footer = items[1]["text"] + .as_str() + .unwrap_or_else(|| panic!("hotspots footer missing: {result}")); + assert_eq!( + footer, + format!( + "\ntracedecay_metrics: before={} after={}", + source_bytes / 4, + body_text(result).len() / 4 + ) + ); +} + +fn assert_symbol_id(id: &str) { + let prefix = "symbol.v1.sha256:"; + let Some(hex) = id.strip_prefix(prefix) else { + panic!("hotspot id {id} is not a sealed symbol occurrence"); + }; + assert_eq!(hex.len(), 64, "{id}"); + assert!( + hex.chars().all(|character| character.is_ascii_hexdigit()), + "{id}" + ); +} + +fn assert_exact_hotspot( + row: &Value, + name: &str, + file: &str, + line: u64, + incoming: u64, + outgoing: u64, + total: u64, +) { + let id = row["id"] + .as_str() + .unwrap_or_else(|| panic!("hotspot id missing: {row}")); + assert_symbol_id(id); + assert_eq!( + row, + &json!({ + "id": id, + "name": name, + "kind": "function", + "file": file, + "line": line, + "incoming": incoming, + "outgoing": outgoing, + "total": total, + }), + "{row}" + ); +} + +fn hotspots(payload: &Value) -> &[Value] { + let rows = payload["hotspots"] + .as_array() + .unwrap_or_else(|| panic!("hotspots array missing: {payload}")); + assert_eq!( + payload["hotspot_count"].as_u64(), + Some(u64::try_from(rows.len()).expect("hotspot count fits")), + "{payload}" + ); + rows +} + +fn assert_chain_ranking(payload: &Value) { + let rows = hotspots(payload); + assert_eq!(rows.len(), 4, "{payload}"); + assert_exact_hotspot(&rows[0], "mid", "src/calls.ts", 9, 1, 1, 2); + assert_exact_hotspot(&rows[3], "quiet", "src/calls.ts", 1, 0, 0, 0); + let mut tied = [rows[1].clone(), rows[2].clone()]; + tied.sort_by(|left, right| { + left["name"] + .as_str() + .unwrap_or("") + .cmp(right["name"].as_str().unwrap_or("")) + }); + assert_exact_hotspot(&tied[0], "hub", "src/calls.ts", 13, 0, 1, 1); + assert_exact_hotspot(&tied[1], "leaf", "src/calls.ts", 5, 1, 0, 1); + assert!( + rows.windows(2) + .all(|pair| pair[0]["total"].as_u64() >= pair[1]["total"].as_u64()), + "chain ranking is not highest degree first: {payload}" + ); +} + +fn assert_fanout_page(payload: &Value, expected_count: usize) { + let rows = hotspots(payload); + assert_eq!(rows.len(), expected_count, "{payload}"); + assert_exact_hotspot(&rows[0], "hub", "src/fanout.ts", 1, 101, 0, 101); + let mut seen = Vec::new(); + for row in rows.iter().skip(1) { + let name = row["name"] + .as_str() + .unwrap_or_else(|| panic!("caller name missing: {row}")); + let index: u64 = name + .strip_prefix("caller") + .unwrap_or_else(|| panic!("non-caller in the fan-out page: {row}")) + .parse() + .unwrap_or_else(|_| panic!("caller index missing: {row}")); + assert!(index < 101, "caller outside the fixture: {row}"); + assert_exact_hotspot(row, name, "src/fanout.ts", index + 2, 0, 1, 1); + seen.push(index); + } + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen.len(), expected_count - 1, "{payload}"); +} + +fn assert_clamped_truncation(payload: &Value) { + assert_eq!(payload["truncated"], true, "{payload}"); + assert_eq!(payload["retrieve_tool"], "tracedecay_retrieve", "{payload}"); + assert_eq!(payload["retrieve_ttl_seconds"], 86_400, "{payload}"); + let preview_chars = payload["preview_chars"] + .as_u64() + .unwrap_or_else(|| panic!("preview_chars missing: {payload}")); + assert_eq!(preview_chars, 11_928, "{payload}"); + let original_chars = payload["original_chars"] + .as_u64() + .unwrap_or_else(|| panic!("original_chars missing: {payload}")); + assert!( + original_chars > preview_chars, + "clamped body must not fit in the preview: {payload}" + ); + let preview = payload["preview"] + .as_str() + .unwrap_or_else(|| panic!("preview missing: {payload}")); + assert_eq!(preview.chars().count() as u64, preview_chars, "{preview}"); + let marker = r#"{"hotspot_count":100,"hotspots":["#; + let array = preview + .strip_prefix(marker) + .unwrap_or_else(|| panic!("clamped preview did not start with 100 rows: {preview}")); + assert!( + array.starts_with('{'), + "clamped preview omitted the hub object: {preview}" + ); + let mut depth = 0_i32; + let mut end = None; + for (index, byte) in array.bytes().enumerate() { + match byte { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + end = Some(index); + break; + } + } + _ => {} + } + } + let end = end.unwrap_or_else(|| panic!("clamped hub object was cut off: {preview}")); + let first: Value = serde_json::from_str(&array[..=end]).unwrap_or_else(|error| { + panic!( + "clamped hub object did not parse: {error}\n{}", + &array[..=end] + ) + }); + assert_exact_hotspot(&first, "hub", "src/fanout.ts", 1, 101, 0, 101); + + let handle = payload["handle"] + .as_str() + .unwrap_or_else(|| panic!("truncation handle missing: {payload}")); + assert!( + handle.starts_with("rh_") && handle.len() > "rh_".len(), + "{payload}" + ); + let expires = payload["retrieve_expires_at"] + .as_i64() + .unwrap_or_else(|| panic!("retrieve expiry missing: {payload}")); + let instruction = payload["retrieve_instruction"] + .as_str() + .unwrap_or_else(|| panic!("retrieve instruction missing: {payload}")); + assert!(instruction.contains(handle), "{instruction}"); + assert!(instruction.contains(&expires.to_string()), "{instruction}"); + assert!( + instruction.contains(&preview_chars.to_string()), + "{instruction}" + ); + assert!( + instruction.contains(&original_chars.to_string()), + "{instruction}" + ); + assert!(instruction.contains("tracedecay_retrieve"), "{instruction}"); +} + +#[tokio::test] +async fn hotspots_ranks_symbols_by_edge_degree_and_clamps_limit() { + let chain_dir = test_temp_dir(); + let chain_root = chain_dir.path().join("project"); + write_chain_project(&chain_root); + let chain = init_test_project(&chain_root).await; + + let chain_default = call_hotspots(&chain, json!({"format": "json"})).await; + let chain_limit_one = call_hotspots(&chain, json!({"format": "json", "limit": 1})).await; + let chain_markdown = call_hotspots(&chain, json!({"format": "markdown", "limit": 1})).await; + let chain_rejected = chain + .harness + .call_tool( + &chain.project_root, + "tracedecay_hotspots", + json!({"limit": 0, "format": "json"}), + ) + .await + .expect("zero limit still reaches the MCP server"); + close_test_graph(chain).await; + + let chain_default_payload = parse_body(&chain_default); + assert_chain_ranking(&chain_default_payload); + assert_savings_footer(&chain_default, CHAIN_SOURCE.len()); + + let chain_one_payload = parse_body(&chain_limit_one); + let one = hotspots(&chain_one_payload); + assert_eq!(one.len(), 1, "{chain_one_payload}"); + assert_exact_hotspot(&one[0], "mid", "src/calls.ts", 9, 1, 1, 2); + assert_savings_footer(&chain_limit_one, CHAIN_SOURCE.len()); + + let mid_id = one[0]["id"].as_str().expect("mid occurrence id").to_owned(); + assert_eq!( + body_text(&chain_markdown), + format!( + "**hotspot_count:** 1\n\n## hotspots\n- **mid**\n **kind:** function\n **file:** src/calls.ts\n **line:** 9\n **id:** `{mid_id}`\n **incoming:** 1\n **outgoing:** 1\n **total:** 2\n" + ) + ); + assert_savings_footer(&chain_markdown, CHAIN_SOURCE.len()); + + let rejected = chain_rejected.error.expect("zero limit is a tool error"); + assert_eq!(rejected.code, -32603); + assert_eq!( + rejected.message, + "tool execution failed: config error: invalid parameter: tracedecay_hotspots requires limit to be at least 1" + ); + assert_eq!( + rejected.data, + Some(json!({ + "tool": "tracedecay_hotspots", + "cli_fallback": "This tool is also available from the shell: `tracedecay tool hotspots ...` (`tracedecay tool hotspots --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." + })) + ); + + let fanout_dir = test_temp_dir(); + let fanout_root = fanout_dir.path().join("project"); + let fanout_bytes = write_fanout_project(&fanout_root); + let fanout = init_test_project(&fanout_root).await; + let fanout_default = call_hotspots(&fanout, json!({"format": "json"})).await; + let fanout_capped = call_hotspots(&fanout, json!({"format": "json", "limit": 250})).await; + let fanout_one = call_hotspots(&fanout, json!({"format": "json", "limit": 1})).await; + close_test_graph(fanout).await; + + let fanout_default_payload = parse_body(&fanout_default); + assert_fanout_page(&fanout_default_payload, 10); + assert_savings_footer(&fanout_default, fanout_bytes); + + let fanout_one_payload = parse_body(&fanout_one); + let fanout_top = hotspots(&fanout_one_payload); + assert_eq!(fanout_top.len(), 1, "{fanout_one_payload}"); + assert_exact_hotspot(&fanout_top[0], "hub", "src/fanout.ts", 1, 101, 0, 101); + assert_savings_footer(&fanout_one, fanout_bytes); + + assert_clamped_truncation(&parse_body(&fanout_capped)); + assert_savings_footer(&fanout_capped, fanout_bytes); +} + +// Literal `tracedecay_recursion` results from production MCP `tools/call`. +// +// `length` is the number of call edges in the cycle. The chain repeats its +// start symbol so the path is closed. Occurrence ids include the temp +// project path, so which node the search starts on changes between runs. +// Comparisons rotate each chain to its smallest `(name, file, line)` and +// then pin names, kinds, files, and lines. Ids are still required to close +// the cycle. + +const DIRECT_SOURCE: &str = "\ +pub fn recurse(n: u32) -> u32 { + if n == 0 { 0 } else { recurse(n - 1) } +} + +pub fn leaf() -> u32 { 1 } +"; + +const MUTUAL_SOURCE: &str = "\ +pub fn ping() { pong(); } +pub fn pong() { ping(); } +"; + +const NOISE_SOURCE: &str = "\ +pub struct Triplet { + rows: Vec, +} + +impl Triplet { + pub fn push(&mut self, row: usize) { + self.rows.push(row); + } +} +"; + +fn direct_cycle() -> Value { + json!({ + "length": 1, + "chain": [ + {"name": "recurse", "kind": "function", "file": "src/direct.rs", "line": 1}, + {"name": "recurse", "kind": "function", "file": "src/direct.rs", "line": 1} + ] + }) +} + +fn mutual_cycle() -> Value { + json!({ + "length": 2, + "chain": [ + {"name": "ping", "kind": "function", "file": "src/mutual.rs", "line": 1}, + {"name": "pong", "kind": "function", "file": "src/mutual.rs", "line": 2}, + {"name": "ping", "kind": "function", "file": "src/mutual.rs", "line": 1} + ] + }) +} + +fn full_report() -> Value { + json!({ + "cycle_count": 2, + "cycles": [direct_cycle(), mutual_cycle()] + }) +} + +fn public_recursion_report(payload: &Value) -> Value { + let keys = sorted_keys(payload, "recursion payload"); + assert_eq!( + keys, + ["cycle_count", "cycles"], + "recursion payload keys drifted: {payload}" + ); + let cycles = payload["cycles"] + .as_array() + .unwrap_or_else(|| panic!("cycles must be an array: {payload}")); + let cycles = cycles + .iter() + .map(|cycle| { + let cycle_keys = sorted_keys(cycle, "cycle"); + assert_eq!( + cycle_keys, + ["chain", "length"], + "cycle keys drifted: {cycle}" + ); + let chain = cycle["chain"] + .as_array() + .unwrap_or_else(|| panic!("chain must be an array: {cycle}")); + json!({ + "length": cycle["length"], + "chain": canonical_public_chain(chain), + }) + }) + .collect::>(); + let mut cycles = cycles; + cycles.sort_by(|left, right| cycle_order_key(left).cmp(&cycle_order_key(right))); + json!({ + "cycle_count": payload["cycle_count"], + "cycles": cycles, + }) +} + +fn cycle_order_key(cycle: &Value) -> (i64, String) { + ( + cycle["length"].as_i64().unwrap_or(i64::MAX), + cycle["chain"].to_string(), + ) +} + +fn canonical_public_chain(chain: &[Value]) -> Vec { + let public = chain.iter().map(public_chain_node).collect::>(); + assert!( + public.len() >= 2, + "a cycle chain must repeat its start: {public:?}" + ); + assert_eq!( + public.first(), + public.last(), + "a cycle chain must close on the same symbol: {public:?}" + ); + let body = &public[..public.len() - 1]; + let start = body + .iter() + .enumerate() + .min_by(|(_, left), (_, right)| public_node_order(left).cmp(&public_node_order(right))) + .map(|(index, _)| index) + .expect("a cycle body is non-empty"); + let mut rotated = body[start..] + .iter() + .chain(&body[..start]) + .cloned() + .collect::>(); + rotated.push(rotated[0].clone()); + rotated +} + +fn public_node_order(node: &Value) -> (String, String, i64) { + ( + node["name"].as_str().unwrap_or_default().to_owned(), + node["file"].as_str().unwrap_or_default().to_owned(), + node["line"].as_i64().unwrap_or(i64::MAX), + ) +} + +fn sorted_keys<'a>(value: &'a Value, label: &str) -> Vec<&'a str> { + let mut keys = value + .as_object() + .unwrap_or_else(|| panic!("{label} must be an object: {value}")) + .keys() + .map(String::as_str) + .collect::>(); + keys.sort_unstable(); + keys +} + +fn public_chain_node(node: &Value) -> Value { + let keys = sorted_keys(node, "chain node"); + assert_eq!( + keys, + ["file", "id", "kind", "line", "name"], + "chain node keys drifted: {node}" + ); + assert!( + node["id"].as_str().is_some_and(|id| !id.is_empty()), + "chain node id must be a non-empty string: {node}" + ); + json!({ + "name": node["name"], + "kind": node["kind"], + "file": node["file"], + "line": node["line"], + }) +} + +fn assert_reported_cycles_close(payload: &Value) { + let cycles = payload["cycles"] + .as_array() + .unwrap_or_else(|| panic!("cycles must be an array: {payload}")); + for cycle in cycles { + let chain = cycle["chain"] + .as_array() + .unwrap_or_else(|| panic!("chain must be an array: {cycle}")); + let start = chain + .first() + .and_then(|node| node["id"].as_str()) + .unwrap_or_else(|| panic!("cycle is missing its start id: {cycle}")); + let end = chain + .last() + .and_then(|node| node["id"].as_str()) + .unwrap_or_else(|| panic!("cycle is missing its closing id: {cycle}")); + assert_eq!( + start, end, + "a reported cycle must return to its start symbol: {cycle}" + ); + } +} + +async fn call_recursion(graph: &impl AnalysisToolHost, arguments: Value) -> Value { + let result = handle_tool_call(graph, "tracedecay_recursion", arguments, None, None) + .await + .unwrap_or_else(|error| panic!("tracedecay_recursion failed: {error}")); + extract_json(&result.value) +} + +#[tokio::test] +async fn recursion_reports_literal_cycles_and_refuses_non_positive_limit() { + let dir = test_temp_dir(); + let project_root = dir.path().join("project"); + fs_write_fixture(&project_root); + let graph = init_test_project(&project_root).await; + + let payload = call_recursion(&graph, json!({"format": "json", "limit": 10})).await; + assert_eq!( + public_recursion_report(&payload), + full_report(), + "default-sized recursion report: {payload}" + ); + assert_reported_cycles_close(&payload); + + let scoped = call_recursion(&graph, json!({"format": "json", "path": "src/direct.rs"})).await; + assert_eq!( + public_recursion_report(&scoped), + json!({"cycle_count": 1, "cycles": [direct_cycle()]}), + "path filter must keep only the direct cycle: {scoped}" + ); + + let mutual = call_recursion( + &graph, + json!({"format": "json", "path": "src/mutual.rs", "limit": 10}), + ) + .await; + assert_eq!( + public_recursion_report(&mutual), + json!({"cycle_count": 1, "cycles": [mutual_cycle()]}), + "path filter must keep only the mutual cycle: {mutual}" + ); + + let noise = call_recursion(&graph, json!({"format": "json", "path": "src/noise.rs"})).await; + assert_eq!( + public_recursion_report(&noise), + json!({"cycle_count": 0, "cycles": []}), + "receiver `.push` must not be a cycle when the same graph has real cycles: {noise}" + ); + + let limited = call_recursion(&graph, json!({"format": "json", "limit": 1})).await; + assert_eq!( + public_recursion_report(&limited), + json!({"cycle_count": 1, "cycles": [direct_cycle()]}), + "limit 1 keeps the shortest cycle: {limited}" + ); + + let error = expect_tool_error( + handle_tool_call( + &graph, + "tracedecay_recursion", + json!({"format": "json", "limit": 0}), + None, + None, + ) + .await, + ); + assert_eq!( + error, + "config error: tracedecay_recursion failed over production MCP: tool execution failed: config error: invalid parameter: tracedecay_recursion requires limit to be at least 1" + ); + close_test_graph(graph).await; +} + +fn fs_write_fixture(project_root: &Path) { + std::fs::create_dir_all(project_root.join("src")).unwrap(); + std::fs::write( + project_root.join("src/lib.rs"), + "pub mod direct;\npub mod mutual;\npub mod noise;\n", + ) + .unwrap(); + std::fs::write(project_root.join("src/direct.rs"), DIRECT_SOURCE).unwrap(); + std::fs::write(project_root.join("src/mutual.rs"), MUTUAL_SOURCE).unwrap(); + std::fs::write(project_root.join("src/noise.rs"), NOISE_SOURCE).unwrap(); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/diff_context_behavior.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/diff_context_behavior.rs deleted file mode 100644 index 2386e449b9..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/diff_context_behavior.rs +++ /dev/null @@ -1,262 +0,0 @@ -//! `tracedecay_diff_context` as an agent host observes it: one production -//! MCP `tools/call`, then the JSON text the caller reads. -//! -//! The fixture is one crate. `tier_b` calls `tier_c`, and `tier_a` calls -//! `tier_b`. `#[test]` on `checks_tier_c` is itself a modified symbol -//! (`annotation_usage` named `test`). Lines are the extractor's 0-based -//! tree-sitter rows. - -use super::{close_test_graph, handle_tool_call, init_test_project}; -use crate::support::{extract_json, test_temp_dir}; -use serde_json::{Value, json}; -use std::fs; -use std::path::Path; - -const LIB_RS: &str = "mod tier_a;\nmod tier_b;\nmod tier_c;\n"; -const TIER_A_RS: &str = "use crate::tier_b::tier_b;\n\npub fn tier_a() -> u8 {\n tier_b()\n}\n"; -const TIER_B_RS: &str = "use crate::tier_c::tier_c;\n\npub fn tier_b() -> u8 {\n tier_c()\n}\n"; -const TIER_C_RS: &str = "\ -pub fn tier_c() -> u8 {\n\ - 1\n\ -}\n\ -\n\ -#[test]\n\ -fn checks_tier_c() {\n\ - let _ = tier_c();\n\ -}\n"; - -fn symbol_facts(symbols: &Value) -> Vec { - let Some(symbols) = symbols.as_array() else { - panic!("diff_context symbol list is not an array: {symbols}"); - }; - let mut facts = symbols - .iter() - .map(|symbol| { - json!({ - "name": symbol["name"], - "kind": symbol["kind"], - "file": symbol["file"], - "line": symbol["line"], - }) - }) - .collect::>(); - facts.sort_by(|left, right| { - ( - left["file"].as_str(), - left["name"].as_str(), - left["line"].as_u64(), - ) - .cmp(&( - right["file"].as_str(), - right["name"].as_str(), - right["line"].as_u64(), - )) - }); - facts -} - -fn write_call_chain(project: &Path) { - fs::create_dir_all(project.join("src")).unwrap(); - fs::write(project.join("src/lib.rs"), LIB_RS).unwrap(); - fs::write(project.join("src/tier_a.rs"), TIER_A_RS).unwrap(); - fs::write(project.join("src/tier_b.rs"), TIER_B_RS).unwrap(); - fs::write(project.join("src/tier_c.rs"), TIER_C_RS).unwrap(); -} - -#[tokio::test] -async fn diff_context_reports_changed_symbols_callers_and_refuses_invalid_input() { - let dir = test_temp_dir(); - let project = dir.path().join("project"); - write_call_chain(&project); - let (host, _) = init_test_project(&project).await; - - let changed = handle_tool_call( - &host, - "tracedecay_diff_context", - json!({"files": ["src/tier_c.rs"], "depth": 1, "format": "json"}), - None, - None, - ) - .await - .expect("depth-1 diff_context"); - let changed = extract_json(&changed.value); - assert_eq!(changed["changed_files"], json!(["src/tier_c.rs"])); - // `tier_a` calls `tier_b`, so a depth-1 walk stops with callers still - // unexplored. The tool must say so instead of pretending the radius is - // complete. - assert_eq!(changed["impact_complete"], json!(false), "{changed}"); - assert_eq!( - symbol_facts(&changed["modified_symbols"]), - vec![ - json!({"name": "checks_tier_c", "kind": "function", "file": "src/tier_c.rs", "line": 5}), - json!({"name": "test", "kind": "annotation_usage", "file": "src/tier_c.rs", "line": 4}), - json!({"name": "tier_c", "kind": "function", "file": "src/tier_c.rs", "line": 0}), - ], - "modified symbols: {changed}" - ); - assert_eq!(changed["impacted_symbols_count"], json!(1)); - assert_eq!( - symbol_facts(&changed["impacted_symbols"]), - vec![json!({"name": "tier_b", "kind": "function", "file": "src/tier_b.rs", "line": 2}),], - "direct callers of tier_c: {changed}" - ); - assert_eq!( - changed["affected_tests"], - json!(["src/tier_c.rs"]), - "{changed}" - ); - - let wider = handle_tool_call( - &host, - "tracedecay_diff_context", - json!({"files": ["src/tier_c.rs"], "depth": 2, "format": "json"}), - None, - None, - ) - .await - .expect("depth-2 diff_context"); - let wider = extract_json(&wider.value); - assert_eq!(wider["impact_complete"], json!(true), "{wider}"); - assert_eq!(wider["impacted_symbols_count"], json!(2)); - assert_eq!( - symbol_facts(&wider["impacted_symbols"]), - vec![ - json!({"name": "tier_a", "kind": "function", "file": "src/tier_a.rs", "line": 2}), - json!({"name": "tier_b", "kind": "function", "file": "src/tier_b.rs", "line": 2}), - ], - "depth 2 also reaches tier_a, which only calls tier_b: {wider}" - ); - - let duplicated = handle_tool_call( - &host, - "tracedecay_diff_context", - json!({ - "files": ["src/tier_c.rs", "src/tier_c.rs"], - "depth": 1, - "format": "json" - }), - None, - None, - ) - .await - .expect("duplicate-path diff_context"); - let duplicated = extract_json(&duplicated.value); - assert_eq!(duplicated["changed_files"], json!(["src/tier_c.rs"])); - assert_eq!( - symbol_facts(&duplicated["modified_symbols"]), - vec![ - json!({"name": "checks_tier_c", "kind": "function", "file": "src/tier_c.rs", "line": 5}), - json!({"name": "test", "kind": "annotation_usage", "file": "src/tier_c.rs", "line": 4}), - json!({"name": "tier_c", "kind": "function", "file": "src/tier_c.rs", "line": 0}), - ] - ); - assert_eq!( - symbol_facts(&duplicated["impacted_symbols"]), - vec![json!({"name": "tier_b", "kind": "function", "file": "src/tier_b.rs", "line": 2}),] - ); - - // A path this generation never published carries no symbols, so the - // affected-test walk has no seeds and answers empty and complete rather - // than turning "no such file here" into an invalid request. - let absent = handle_tool_call( - &host, - "tracedecay_diff_context", - json!({"files": ["src/not_in_repo.rs"], "format": "json"}), - None, - None, - ) - .await - .expect("unpublished-path diff_context"); - assert_eq!( - extract_json(&absent.value), - json!({ - "changed_files": ["src/not_in_repo.rs"], - "modified_symbols": [], - "impacted_symbols_count": 0, - "impacted_symbols": [], - "impact_complete": true, - "affected_tests": [] - }) - ); - - let empty_files = handle_tool_call( - &host, - "tracedecay_diff_context", - json!({"files": [], "format": "json"}), - None, - None, - ) - .await - .expect("empty-files diff_context"); - assert_eq!( - extract_json(&empty_files.value), - json!({ - "changed_files": [], - "modified_symbols": [], - "impacted_symbols_count": 0, - "impacted_symbols": [], - "impact_complete": true, - "affected_tests": [] - }) - ); - - let missing_files = handle_tool_call( - &host, - "tracedecay_diff_context", - json!({"format": "json"}), - None, - None, - ) - .await; - assert_eq!( - missing_files - .expect_err("missing files must be refused") - .to_string(), - "config error: tracedecay_diff_context failed over production MCP: missing required parameter: files (array of strings)" - ); - - let files_not_array = handle_tool_call( - &host, - "tracedecay_diff_context", - json!({"files": "src/tier_c.rs", "format": "json"}), - None, - None, - ) - .await; - assert_eq!( - files_not_array - .expect_err("a string files argument must be refused") - .to_string(), - "config error: tracedecay_diff_context failed over production MCP: missing required parameter: files (array of strings)" - ); - - let not_object = handle_tool_call( - &host, - "tracedecay_diff_context", - json!(["src/tier_c.rs"]), - None, - None, - ) - .await; - assert_eq!( - not_object - .expect_err("non-object arguments must be refused") - .to_string(), - "config error: tracedecay_diff_context failed over production MCP: tool execution failed: config error: invalid arguments: tracedecay_diff_context expects a JSON object" - ); - - let zero_depth = handle_tool_call( - &host, - "tracedecay_diff_context", - json!({"files": ["src/tier_c.rs"], "depth": 0, "format": "json"}), - None, - None, - ) - .await; - assert_eq!( - zero_depth.expect_err("depth 0 must be refused").to_string(), - "config error: tracedecay_diff_context failed over production MCP: tool project route failed: reason_code=code-graph-invalid-request retryable=false: the code-graph read request is invalid: code graph impact depth must be positive" - ); - - close_test_graph(host).await; -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/gini.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/gini.rs deleted file mode 100644 index c290d998ff..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/gini.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Literal `tracedecay_gini` results for a fixture whose metric values are -//! fixed by source shape, not by reading the coefficient back out of the tool. -//! -//! The handler rounds `2*Σ(i*x_i)/(n*Σx) - (n+1)/n` (1-indexed `i` on values -//! sorted ascending) to four decimals. One file, or a missing path, is the -//! empty-or-singleton case and the coefficient is exactly 0. - -use std::path::Path; - -use serde_json::{Value, json}; - -use crate::support::{extract_json, production_composition_fixture_with_sources}; - -use super::{AnalysisToolHost, close_test_graph, handle_tool_call, setup_empty_analysis_project}; - -fn write_gini_distribution_sources(project: &Path) { - std::fs::create_dir_all(project.join("src/spans")).unwrap(); - // Body is one block and no branch: complexity 1, line span 1. - std::fs::write( - project.join("src/spans/short.rs"), - "pub fn short() -> i32 { 1 }\n", - ) - .unwrap(); - // Same complexity 1, line span 3 (declaration, body, closing brace). - std::fs::write( - project.join("src/spans/tall.rs"), - "pub fn tall() -> i32 {\n 1\n}\n", - ) - .unwrap(); - // Tiny has one field, Big has three. `plain` is a single block (complexity - // 1). `branched` is if + else (2 branches) inside a body block, so the - // inner blocks reach nesting 2 and the symbol score is 4. - std::fs::write( - project.join("src/kinds.rs"), - "\ -pub struct Tiny {\n \ - pub only: i32,\n\ -}\n\ -\n\ -pub struct Big {\n \ - pub a: i32,\n \ - pub b: i32,\n \ - pub c: i32,\n\ -}\n\ -\n\ -pub fn plain() -> i32 { 1 }\n\ -\n\ -pub fn branched(n: i32) -> i32 {\n \ - if n > 0 {\n \ - n\n \ - } else {\n \ - 0\n \ - }\n\ -}\n", - ) - .unwrap(); -} - -async fn gini_json(host: &impl AnalysisToolHost, args: Value) -> Value { - let result = handle_tool_call(host, "tracedecay_gini", args, None, None) - .await - .expect("tracedecay_gini over production MCP"); - extract_json(&result.value) -} - -/// Equal values do not define an outlier rank. Sort by name so the assertion -/// stays on the reported rows rather than `HashMap` iteration order. -fn outliers_sorted_by_name(mut payload: Value) -> Value { - if let Some(outliers) = payload.get_mut("outliers").and_then(Value::as_array_mut) { - outliers.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); - } - payload -} - -#[tokio::test] -async fn gini_reports_literal_coefficients_for_known_distributions() { - let host = production_composition_fixture_with_sources(write_gini_distribution_sources).await; - - let lines = gini_json( - &host, - json!({ - "format": "json", - "metric": "lines", - "scope": "file", - "path": "src/spans", - }), - ) - .await; - assert_eq!( - lines, - json!({ - "gini": 0.25, - "interpretation": "moderate inequality", - "total_items": 2, - "metric": "lines", - "scope": "file", - "incomplete_complexity_symbols": 0, - "outliers": [ - {"name": "src/spans/tall.rs", "value": 3.0, "pct_of_max": 100.0}, - {"name": "src/spans/short.rs", "value": 1.0, "pct_of_max": 33.0}, - ], - }), - "line spans 1 and 3 must produce Gini 0.25: {lines}" - ); - - let truncated = gini_json( - &host, - json!({ - "format": "json", - "metric": "lines", - "scope": "file", - "path": "src/spans", - "limit": 1, - }), - ) - .await; - assert_eq!( - truncated, - json!({ - "gini": 0.25, - "interpretation": "moderate inequality", - "total_items": 2, - "metric": "lines", - "scope": "file", - "incomplete_complexity_symbols": 0, - "outliers": [ - {"name": "src/spans/tall.rs", "value": 3.0, "pct_of_max": 100.0}, - ], - }), - "limit truncates the ranking and keeps the census: {truncated}" - ); - - let one_file = gini_json( - &host, - json!({ - "format": "json", - "metric": "lines", - "path": "src/spans/tall.rs", - }), - ) - .await; - assert_eq!( - one_file, - json!({ - "gini": 0.0, - "interpretation": "low inequality (healthy)", - "total_items": 1, - "metric": "lines", - "scope": "file", - "incomplete_complexity_symbols": 0, - "outliers": [ - {"name": "src/spans/tall.rs", "value": 3.0, "pct_of_max": 100.0}, - ], - }), - "a single file is perfect equality: {one_file}" - ); - - let missing = gini_json( - &host, - json!({ - "format": "json", - "metric": "lines", - "path": "src/nowhere", - }), - ) - .await; - assert_eq!( - missing, - json!({ - "gini": 0.0, - "interpretation": "low inequality (healthy)", - "total_items": 0, - "metric": "lines", - "scope": "file", - "incomplete_complexity_symbols": 0, - "outliers": [], - }), - "a path with no symbols is an empty census, not the unfiltered one: {missing}" - ); - - // Defaults are complexity + file. Both span functions score 1, so this - // coefficient is 0. The lines call above is 0.25 for the same path. - let defaults = gini_json( - &host, - json!({ - "format": "json", - "path": "src/spans", - }), - ) - .await; - assert_eq!( - outliers_sorted_by_name(defaults.clone()), - json!({ - "gini": 0.0, - "interpretation": "low inequality (healthy)", - "total_items": 2, - "metric": "complexity", - "scope": "file", - "incomplete_complexity_symbols": 0, - "outliers": [ - {"name": "src/spans/short.rs", "value": 1.0, "pct_of_max": 100.0}, - {"name": "src/spans/tall.rs", "value": 1.0, "pct_of_max": 100.0}, - ], - }), - "default metric is complexity, not lines: {defaults}" - ); - - let members = gini_json( - &host, - json!({ - "format": "json", - "metric": "members", - "path": "src/kinds.rs", - }), - ) - .await; - assert_eq!( - members, - json!({ - "gini": 0.25, - "interpretation": "moderate inequality", - "total_items": 2, - "metric": "members", - "scope": "file", - "incomplete_complexity_symbols": 0, - "outliers": [ - {"name": "Big", "value": 3.0, "pct_of_max": 100.0}, - {"name": "Tiny", "value": 1.0, "pct_of_max": 33.0}, - ], - }), - "struct member counts 1 and 3 must produce Gini 0.25: {members}" - ); - - let symbols = gini_json( - &host, - json!({ - "format": "json", - "metric": "complexity", - "scope": "symbol", - "path": "src/kinds.rs", - }), - ) - .await; - assert_eq!( - symbols, - json!({ - "gini": 0.3, - "interpretation": "moderate inequality", - "total_items": 2, - "metric": "complexity", - "scope": "symbol", - "incomplete_complexity_symbols": 0, - "outliers": [ - {"name": "src/kinds.rs:branched", "value": 4.0, "pct_of_max": 100.0}, - {"name": "src/kinds.rs:plain", "value": 1.0, "pct_of_max": 25.0}, - ], - }), - "symbol scores 1 and 4 must produce Gini 0.3: {symbols}" - ); - - close_test_graph(host).await; -} - -#[tokio::test] -async fn gini_empty_index_reports_perfect_equality() { - let (host, _, _) = setup_empty_analysis_project().await; - let payload = gini_json(&host, json!({"format": "json"})).await; - assert_eq!( - payload, - json!({ - "gini": 0.0, - "interpretation": "low inequality (healthy)", - "total_items": 0, - "metric": "complexity", - "scope": "file", - "incomplete_complexity_symbols": 0, - "outliers": [], - }), - "an empty index is not a missing field: {payload}" - ); - close_test_graph(host).await; -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/graph_readiness.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/graph_readiness.rs deleted file mode 100644 index 075d8352e3..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/graph_readiness.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::time::Duration; - -use serde_json::{Value, json}; - -use crate::support::extract_text; - -use super::{AnalysisToolHost, handle_tool_call}; - -pub(super) async fn wait_for_current_graph(host: &impl AnalysisToolHost) { - tokio::time::timeout(Duration::from_secs(20), async { - loop { - let status = handle_tool_call( - host, - "tracedecay_status", - json!({ - "format": "json", - "include_branch_diagnostics": false, - "include_storage_health": false, - "include_session_ingest": false, - "include_staleness": false, - }), - None, - None, - ) - .await - .expect("typed project status while awaiting the current graph"); - let status: Value = serde_json::from_str(extract_text(&status.value)) - .expect("typed project status JSON"); - let freshness = &status["code_index_freshness"]; - let serving = &freshness["worktree"]["code_graph_serving"]; - match ( - freshness["status"].as_str(), - serving["state"].as_str(), - serving["reason"].as_str(), - freshness["worktree"]["staleness_state"].as_str(), - ) { - (Some("current"), Some("ready"), _, _) => break, - (Some("warming"), _, _, _) - | (Some("stale"), Some("ready"), _, Some("verifying")) - | (_, Some("pending"), _, _) - | (_, Some("unavailable"), Some("generation_unavailable"), _) => { - tokio::task::yield_now().await; - } - (_, Some("refused"), _, _) | (_, _, Some("activation_disabled"), _) => { - panic!("graph readiness was refused: {status}"); - } - actual => panic!("graph readiness became {actual:?}: {status}"), - } - } - }) - .await - .expect("graph did not become current within the publication budget"); -} - -pub(super) async fn find_node_id(host: &impl AnalysisToolHost, name: &str) -> String { - wait_for_current_graph(host).await; - let result = handle_tool_call( - host, - "tracedecay_find_exact_symbol", - json!({"name": name, "limit": 20}), - None, - None, - ) - .await - .unwrap_or_else(|error| panic!("production exact-symbol read failed: {error}")); - let payload: Value = - serde_json::from_str(extract_text(&result.value)).expect("exact-symbol JSON"); - payload["matches"] - .as_array() - .and_then(|matches| { - matches - .iter() - .find(|result| result["name"].as_str() == Some(name)) - }) - .and_then(|result| result["id"].as_str()) - .unwrap_or_else(|| panic!("node '{name}' not found in production generation: {payload}")) - .to_owned() -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/hotspots.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/hotspots.rs deleted file mode 100644 index d313de4eac..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/hotspots.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! `tracedecay_hotspots` through production MCP `tools/call`. -//! -//! Occurrence ids are minted per project, so two equal totals may swap order -//! across runs. The host-visible ranking of distinct degrees, the line and -//! degree of each named symbol, the default page, the clamped page, and the -//! zero-limit rejection are stable and asserted literally. - -use std::fs; -use std::path::Path; - -use serde_json::{Value, json}; - -use super::{MountedProductionProject, close_test_graph, handle_tool_call, init_test_project}; -use crate::support::test_temp_dir; - -const CHAIN_SOURCE: &str = "\ -export function quiet(): number {\n\ - return 0;\n\ -}\n\ -\n\ -export function leaf(): number {\n\ - return 1;\n\ -}\n\ -\n\ -export function mid(): number {\n\ - return leaf();\n\ -}\n\ -\n\ -export function hub(): number {\n\ - return mid();\n\ -}\n\ -"; - -fn write_package(project: &Path, name: &str) { - fs::create_dir_all(project.join("src")).unwrap(); - fs::write( - project.join("package.json"), - format!("{{\"name\":\"{name}\",\"private\":true,\"type\":\"module\"}}\n"), - ) - .unwrap(); -} - -fn write_chain_project(project: &Path) { - write_package(project, "hotspots-chain"); - fs::write(project.join("src/calls.ts"), CHAIN_SOURCE).unwrap(); -} - -/// `hub` plus 101 callers. Returns the source length the savings footer -/// measures for `src/fanout.ts`. -fn write_fanout_project(project: &Path) -> usize { - write_package(project, "hotspots-fanout"); - let mut source = String::from("export function hub(): number { return 1; }\n"); - for index in 0..101 { - source.push_str(&format!( - "export function caller{index}(): number {{ return hub(); }}\n" - )); - } - let bytes = source.len(); - fs::write(project.join("src/fanout.ts"), source).unwrap(); - bytes -} - -async fn call_hotspots(host: &MountedProductionProject, arguments: Value) -> Value { - let result = handle_tool_call(host, "tracedecay_hotspots", arguments, None, None) - .await - .unwrap_or_else(|error| panic!("tracedecay_hotspots failed over production MCP: {error}")); - result.value -} - -fn content(result: &Value) -> &[Value] { - result["content"] - .as_array() - .unwrap_or_else(|| panic!("hotspots content missing: {result}")) -} - -fn body_text(result: &Value) -> &str { - let item = &content(result)[0]; - assert_eq!(item["type"], "text", "{result}"); - item["text"] - .as_str() - .unwrap_or_else(|| panic!("hotspots text missing: {result}")) -} - -fn parse_body(result: &Value) -> Value { - let text = body_text(result); - serde_json::from_str(text) - .unwrap_or_else(|error| panic!("hotspots JSON did not parse: {error}\n{text}")) -} - -fn assert_savings_footer(result: &Value, source_bytes: usize) { - let items = content(result); - assert_eq!(items.len(), 2, "{result}"); - assert_eq!(items[1]["type"], "text", "{result}"); - let footer = items[1]["text"] - .as_str() - .unwrap_or_else(|| panic!("hotspots footer missing: {result}")); - assert_eq!( - footer, - format!( - "\ntracedecay_metrics: before={} after={}", - source_bytes / 4, - body_text(result).len() / 4 - ) - ); -} - -fn assert_symbol_id(id: &str) { - let prefix = "symbol.v1.sha256:"; - let Some(hex) = id.strip_prefix(prefix) else { - panic!("hotspot id {id} is not a sealed symbol occurrence"); - }; - assert_eq!(hex.len(), 64, "{id}"); - assert!( - hex.chars().all(|character| character.is_ascii_hexdigit()), - "{id}" - ); -} - -fn assert_exact_hotspot( - row: &Value, - name: &str, - file: &str, - line: u64, - incoming: u64, - outgoing: u64, - total: u64, -) { - let id = row["id"] - .as_str() - .unwrap_or_else(|| panic!("hotspot id missing: {row}")); - assert_symbol_id(id); - assert_eq!( - row, - &json!({ - "id": id, - "name": name, - "kind": "function", - "file": file, - "line": line, - "incoming": incoming, - "outgoing": outgoing, - "total": total, - }), - "{row}" - ); -} - -fn hotspots(payload: &Value) -> &[Value] { - let rows = payload["hotspots"] - .as_array() - .unwrap_or_else(|| panic!("hotspots array missing: {payload}")); - assert_eq!( - payload["hotspot_count"].as_u64(), - Some(u64::try_from(rows.len()).expect("hotspot count fits")), - "{payload}" - ); - rows -} - -fn assert_chain_ranking(payload: &Value) { - let rows = hotspots(payload); - assert_eq!(rows.len(), 4, "{payload}"); - assert_exact_hotspot(&rows[0], "mid", "src/calls.ts", 9, 1, 1, 2); - assert_exact_hotspot(&rows[3], "quiet", "src/calls.ts", 1, 0, 0, 0); - let mut tied = [rows[1].clone(), rows[2].clone()]; - tied.sort_by(|left, right| { - left["name"] - .as_str() - .unwrap_or("") - .cmp(right["name"].as_str().unwrap_or("")) - }); - assert_exact_hotspot(&tied[0], "hub", "src/calls.ts", 13, 0, 1, 1); - assert_exact_hotspot(&tied[1], "leaf", "src/calls.ts", 5, 1, 0, 1); - assert!( - rows.windows(2) - .all(|pair| pair[0]["total"].as_u64() >= pair[1]["total"].as_u64()), - "chain ranking is not highest degree first: {payload}" - ); -} - -fn assert_fanout_page(payload: &Value, expected_count: usize) { - let rows = hotspots(payload); - assert_eq!(rows.len(), expected_count, "{payload}"); - assert_exact_hotspot(&rows[0], "hub", "src/fanout.ts", 1, 101, 0, 101); - let mut seen = Vec::new(); - for row in rows.iter().skip(1) { - let name = row["name"] - .as_str() - .unwrap_or_else(|| panic!("caller name missing: {row}")); - let index: u64 = name - .strip_prefix("caller") - .unwrap_or_else(|| panic!("non-caller in the fan-out page: {row}")) - .parse() - .unwrap_or_else(|_| panic!("caller index missing: {row}")); - assert!(index < 101, "caller outside the fixture: {row}"); - assert_exact_hotspot(row, name, "src/fanout.ts", index + 2, 0, 1, 1); - seen.push(index); - } - seen.sort_unstable(); - seen.dedup(); - assert_eq!(seen.len(), expected_count - 1, "{payload}"); -} - -fn assert_clamped_truncation(payload: &Value) { - assert_eq!(payload["truncated"], true, "{payload}"); - assert_eq!(payload["retrieve_tool"], "tracedecay_retrieve", "{payload}"); - assert_eq!(payload["retrieve_ttl_seconds"], 86_400, "{payload}"); - let preview_chars = payload["preview_chars"] - .as_u64() - .unwrap_or_else(|| panic!("preview_chars missing: {payload}")); - assert_eq!(preview_chars, 11_928, "{payload}"); - let original_chars = payload["original_chars"] - .as_u64() - .unwrap_or_else(|| panic!("original_chars missing: {payload}")); - assert!( - original_chars > preview_chars, - "clamped body must not fit in the preview: {payload}" - ); - let preview = payload["preview"] - .as_str() - .unwrap_or_else(|| panic!("preview missing: {payload}")); - assert_eq!(preview.chars().count() as u64, preview_chars, "{preview}"); - let marker = r#"{"hotspot_count":100,"hotspots":["#; - let array = preview - .strip_prefix(marker) - .unwrap_or_else(|| panic!("clamped preview did not start with 100 rows: {preview}")); - assert!( - array.starts_with('{'), - "clamped preview omitted the hub object: {preview}" - ); - let mut depth = 0_i32; - let mut end = None; - for (index, byte) in array.bytes().enumerate() { - match byte { - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - end = Some(index); - break; - } - } - _ => {} - } - } - let end = end.unwrap_or_else(|| panic!("clamped hub object was cut off: {preview}")); - let first: Value = serde_json::from_str(&array[..=end]).unwrap_or_else(|error| { - panic!( - "clamped hub object did not parse: {error}\n{}", - &array[..=end] - ) - }); - assert_exact_hotspot(&first, "hub", "src/fanout.ts", 1, 101, 0, 101); - - let handle = payload["handle"] - .as_str() - .unwrap_or_else(|| panic!("truncation handle missing: {payload}")); - assert!( - handle.starts_with("rh_") && handle.len() > "rh_".len(), - "{payload}" - ); - let expires = payload["retrieve_expires_at"] - .as_i64() - .unwrap_or_else(|| panic!("retrieve expiry missing: {payload}")); - let instruction = payload["retrieve_instruction"] - .as_str() - .unwrap_or_else(|| panic!("retrieve instruction missing: {payload}")); - assert!(instruction.contains(handle), "{instruction}"); - assert!(instruction.contains(&expires.to_string()), "{instruction}"); - assert!( - instruction.contains(&preview_chars.to_string()), - "{instruction}" - ); - assert!( - instruction.contains(&original_chars.to_string()), - "{instruction}" - ); - assert!(instruction.contains("tracedecay_retrieve"), "{instruction}"); -} - -#[tokio::test] -async fn hotspots_ranks_symbols_by_edge_degree_and_clamps_limit() { - let chain_dir = test_temp_dir(); - let chain_root = chain_dir.path().join("project"); - write_chain_project(&chain_root); - let (chain, _env) = init_test_project(&chain_root).await; - - let chain_default = call_hotspots(&chain, json!({"format": "json"})).await; - let chain_limit_one = call_hotspots(&chain, json!({"format": "json", "limit": 1})).await; - let chain_markdown = call_hotspots(&chain, json!({"format": "markdown", "limit": 1})).await; - let chain_rejected = chain - .harness - .call_tool( - &chain.project_root, - "tracedecay_hotspots", - json!({"limit": 0, "format": "json"}), - ) - .await - .expect("zero limit still reaches the MCP server"); - close_test_graph(chain).await; - - let chain_default_payload = parse_body(&chain_default); - assert_chain_ranking(&chain_default_payload); - assert_savings_footer(&chain_default, CHAIN_SOURCE.len()); - - let chain_one_payload = parse_body(&chain_limit_one); - let one = hotspots(&chain_one_payload); - assert_eq!(one.len(), 1, "{chain_one_payload}"); - assert_exact_hotspot(&one[0], "mid", "src/calls.ts", 9, 1, 1, 2); - assert_savings_footer(&chain_limit_one, CHAIN_SOURCE.len()); - - let mid_id = one[0]["id"].as_str().expect("mid occurrence id").to_owned(); - assert_eq!( - body_text(&chain_markdown), - format!( - "**hotspot_count:** 1\n\n## hotspots\n- **mid**\n **kind:** function\n **file:** src/calls.ts\n **line:** 9\n **id:** `{mid_id}`\n **incoming:** 1\n **outgoing:** 1\n **total:** 2\n" - ) - ); - assert_savings_footer(&chain_markdown, CHAIN_SOURCE.len()); - - let rejected = chain_rejected.error.expect("zero limit is a tool error"); - assert_eq!(rejected.code, -32603); - assert_eq!( - rejected.message, - "tool execution failed: config error: invalid parameter: tracedecay_hotspots requires limit to be at least 1" - ); - assert_eq!( - rejected.data, - Some(json!({ - "tool": "tracedecay_hotspots", - "cli_fallback": "This tool is also available from the shell: `tracedecay tool hotspots ...` (`tracedecay tool hotspots --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." - })) - ); - - let fanout_dir = test_temp_dir(); - let fanout_root = fanout_dir.path().join("project"); - let fanout_bytes = write_fanout_project(&fanout_root); - let (fanout, _env) = init_test_project(&fanout_root).await; - let fanout_default = call_hotspots(&fanout, json!({"format": "json"})).await; - let fanout_capped = call_hotspots(&fanout, json!({"format": "json", "limit": 250})).await; - let fanout_one = call_hotspots(&fanout, json!({"format": "json", "limit": 1})).await; - close_test_graph(fanout).await; - - let fanout_default_payload = parse_body(&fanout_default); - assert_fanout_page(&fanout_default_payload, 10); - assert_savings_footer(&fanout_default, fanout_bytes); - - let fanout_one_payload = parse_body(&fanout_one); - let fanout_top = hotspots(&fanout_one_payload); - assert_eq!(fanout_top.len(), 1, "{fanout_one_payload}"); - assert_exact_hotspot(&fanout_top[0], "hub", "src/fanout.ts", 1, 101, 0, 101); - assert_savings_footer(&fanout_one, fanout_bytes); - - assert_clamped_truncation(&parse_body(&fanout_capped)); - assert_savings_footer(&fanout_capped, fanout_bytes); -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/recursion_behavior.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/recursion_behavior.rs deleted file mode 100644 index a238036b7d..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test/recursion_behavior.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Literal `tracedecay_recursion` results from production MCP `tools/call`. -//! -//! `length` is the number of call edges in the cycle. The chain repeats its -//! start symbol so the path is closed. Occurrence ids include the temp -//! project path, so which node the search starts on changes between runs. -//! Comparisons rotate each chain to its smallest `(name, file, line)` and -//! then pin names, kinds, files, and lines. Ids are still required to close -//! the cycle. - -use std::path::Path; - -use serde_json::{Value, json}; - -use super::{close_test_graph, handle_tool_call, init_test_project}; -use crate::support::{expect_tool_error, extract_json, test_temp_dir}; - -const DIRECT_SOURCE: &str = "\ -pub fn recurse(n: u32) -> u32 { - if n == 0 { 0 } else { recurse(n - 1) } -} - -pub fn leaf() -> u32 { 1 } -"; - -const MUTUAL_SOURCE: &str = "\ -pub fn ping() { pong(); } -pub fn pong() { ping(); } -"; - -const NOISE_SOURCE: &str = "\ -pub struct Triplet { - rows: Vec, -} - -impl Triplet { - pub fn push(&mut self, row: usize) { - self.rows.push(row); - } -} -"; - -fn direct_cycle() -> Value { - json!({ - "length": 1, - "chain": [ - {"name": "recurse", "kind": "function", "file": "src/direct.rs", "line": 1}, - {"name": "recurse", "kind": "function", "file": "src/direct.rs", "line": 1} - ] - }) -} - -fn mutual_cycle() -> Value { - json!({ - "length": 2, - "chain": [ - {"name": "ping", "kind": "function", "file": "src/mutual.rs", "line": 1}, - {"name": "pong", "kind": "function", "file": "src/mutual.rs", "line": 2}, - {"name": "ping", "kind": "function", "file": "src/mutual.rs", "line": 1} - ] - }) -} - -fn full_report() -> Value { - json!({ - "cycle_count": 2, - "cycles": [direct_cycle(), mutual_cycle()] - }) -} - -pub(super) fn public_recursion_report(payload: &Value) -> Value { - let keys = sorted_keys(payload, "recursion payload"); - assert_eq!( - keys, - ["cycle_count", "cycles"], - "recursion payload keys drifted: {payload}" - ); - let cycles = payload["cycles"] - .as_array() - .unwrap_or_else(|| panic!("cycles must be an array: {payload}")); - let cycles = cycles - .iter() - .map(|cycle| { - let cycle_keys = sorted_keys(cycle, "cycle"); - assert_eq!( - cycle_keys, - ["chain", "length"], - "cycle keys drifted: {cycle}" - ); - let chain = cycle["chain"] - .as_array() - .unwrap_or_else(|| panic!("chain must be an array: {cycle}")); - json!({ - "length": cycle["length"], - "chain": canonical_public_chain(chain), - }) - }) - .collect::>(); - let mut cycles = cycles; - cycles.sort_by(|left, right| cycle_order_key(left).cmp(&cycle_order_key(right))); - json!({ - "cycle_count": payload["cycle_count"], - "cycles": cycles, - }) -} - -fn cycle_order_key(cycle: &Value) -> (i64, String) { - ( - cycle["length"].as_i64().unwrap_or(i64::MAX), - cycle["chain"].to_string(), - ) -} - -fn canonical_public_chain(chain: &[Value]) -> Vec { - let public = chain.iter().map(public_chain_node).collect::>(); - assert!( - public.len() >= 2, - "a cycle chain must repeat its start: {public:?}" - ); - assert_eq!( - public.first(), - public.last(), - "a cycle chain must close on the same symbol: {public:?}" - ); - let body = &public[..public.len() - 1]; - let start = body - .iter() - .enumerate() - .min_by(|(_, left), (_, right)| public_node_order(left).cmp(&public_node_order(right))) - .map(|(index, _)| index) - .expect("a cycle body is non-empty"); - let mut rotated = body[start..] - .iter() - .chain(&body[..start]) - .cloned() - .collect::>(); - rotated.push(rotated[0].clone()); - rotated -} - -fn public_node_order(node: &Value) -> (String, String, i64) { - ( - node["name"].as_str().unwrap_or_default().to_owned(), - node["file"].as_str().unwrap_or_default().to_owned(), - node["line"].as_i64().unwrap_or(i64::MAX), - ) -} - -fn sorted_keys<'a>(value: &'a Value, label: &str) -> Vec<&'a str> { - let mut keys = value - .as_object() - .unwrap_or_else(|| panic!("{label} must be an object: {value}")) - .keys() - .map(String::as_str) - .collect::>(); - keys.sort_unstable(); - keys -} - -fn public_chain_node(node: &Value) -> Value { - let keys = sorted_keys(node, "chain node"); - assert_eq!( - keys, - ["file", "id", "kind", "line", "name"], - "chain node keys drifted: {node}" - ); - assert!( - node["id"].as_str().is_some_and(|id| !id.is_empty()), - "chain node id must be a non-empty string: {node}" - ); - json!({ - "name": node["name"], - "kind": node["kind"], - "file": node["file"], - "line": node["line"], - }) -} - -pub(super) fn assert_reported_cycles_close(payload: &Value) { - let cycles = payload["cycles"] - .as_array() - .unwrap_or_else(|| panic!("cycles must be an array: {payload}")); - for cycle in cycles { - let chain = cycle["chain"] - .as_array() - .unwrap_or_else(|| panic!("chain must be an array: {cycle}")); - let start = chain - .first() - .and_then(|node| node["id"].as_str()) - .unwrap_or_else(|| panic!("cycle is missing its start id: {cycle}")); - let end = chain - .last() - .and_then(|node| node["id"].as_str()) - .unwrap_or_else(|| panic!("cycle is missing its closing id: {cycle}")); - assert_eq!( - start, end, - "a reported cycle must return to its start symbol: {cycle}" - ); - } -} - -async fn call_recursion(graph: &impl super::AnalysisToolHost, arguments: Value) -> Value { - let result = handle_tool_call(graph, "tracedecay_recursion", arguments, None, None) - .await - .unwrap_or_else(|error| panic!("tracedecay_recursion failed: {error}")); - extract_json(&result.value) -} - -#[tokio::test] -async fn recursion_reports_literal_cycles_and_refuses_non_positive_limit() { - let dir = test_temp_dir(); - let project_root = dir.path().join("project"); - fs_write_fixture(&project_root); - let (graph, ()) = init_test_project(&project_root).await; - - let payload = call_recursion(&graph, json!({"format": "json", "limit": 10})).await; - assert_eq!( - public_recursion_report(&payload), - full_report(), - "default-sized recursion report: {payload}" - ); - assert_reported_cycles_close(&payload); - - let scoped = call_recursion(&graph, json!({"format": "json", "path": "src/direct.rs"})).await; - assert_eq!( - public_recursion_report(&scoped), - json!({"cycle_count": 1, "cycles": [direct_cycle()]}), - "path filter must keep only the direct cycle: {scoped}" - ); - - let mutual = call_recursion( - &graph, - json!({"format": "json", "path": "src/mutual.rs", "limit": 10}), - ) - .await; - assert_eq!( - public_recursion_report(&mutual), - json!({"cycle_count": 1, "cycles": [mutual_cycle()]}), - "path filter must keep only the mutual cycle: {mutual}" - ); - - let noise = call_recursion(&graph, json!({"format": "json", "path": "src/noise.rs"})).await; - assert_eq!( - public_recursion_report(&noise), - json!({"cycle_count": 0, "cycles": []}), - "receiver `.push` must not be a cycle when the same graph has real cycles: {noise}" - ); - - let limited = call_recursion(&graph, json!({"format": "json", "limit": 1})).await; - assert_eq!( - public_recursion_report(&limited), - json!({"cycle_count": 1, "cycles": [direct_cycle()]}), - "limit 1 keeps the shortest cycle: {limited}" - ); - - let error = expect_tool_error( - handle_tool_call( - &graph, - "tracedecay_recursion", - json!({"format": "json", "limit": 0}), - None, - None, - ) - .await, - ); - assert_eq!( - error, - "config error: tracedecay_recursion failed over production MCP: tool execution failed: config error: invalid parameter: tracedecay_recursion requires limit to be at least 1" - ); - close_test_graph(graph).await; -} - -fn fs_write_fixture(project_root: &Path) { - std::fs::create_dir_all(project_root.join("src")).unwrap(); - std::fs::write( - project_root.join("src/lib.rs"), - "pub mod direct;\npub mod mutual;\npub mod noise;\n", - ) - .unwrap(); - std::fs::write(project_root.join("src/direct.rs"), DIRECT_SOURCE).unwrap(); - std::fs::write(project_root.join("src/mutual.rs"), MUTUAL_SOURCE).unwrap(); - std::fs::write(project_root.join("src/noise.rs"), NOISE_SOURCE).unwrap(); -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/insert_at_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/insert_at_test.rs index 5dcbc7eea1..9f2549e842 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/insert_at_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/insert_at_test.rs @@ -43,7 +43,7 @@ async fn open_project(files: &[(&str, &str)]) -> InsertProject { fs::create_dir_all(path.parent().unwrap_or(&project_root)).unwrap(); fs::write(path, contents).unwrap(); } - let (fixture, _) = init_production_source_edit_project(&project_root).await; + let fixture = init_production_source_edit_project(&project_root).await; let server = fixture .harness .server(&fixture.project_root) diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_fact_assertions.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_fact_assertions.rs deleted file mode 100644 index a68bd2e761..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_fact_assertions.rs +++ /dev/null @@ -1,25 +0,0 @@ -use serde_json::Value; - -pub(super) fn assert_fact_list(payload: &Value, included: &str, excluded: &str, context: &str) { - let facts = payload["facts"] - .as_array() - .unwrap_or_else(|| panic!("{context} must return canonical facts: {payload}")); - assert_eq!(facts.len(), 1, "{context}: {payload}"); - let contents: Vec<&str> = facts - .iter() - .map(|projection| { - assert_eq!(projection["kind"], "available", "{context}: {payload}"); - projection["fact"]["content"] - .as_str() - .unwrap_or_else(|| panic!("{context} fact content: {payload}")) - }) - .collect(); - assert!( - contents.iter().any(|content| content.contains(included)), - "{context}: {payload}" - ); - assert!( - contents.iter().all(|content| !content.contains(excluded)), - "{context}: {payload}" - ); -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_facts_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_facts_test.rs index b13a49dee6..22a460b77d 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_facts_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_facts_test.rs @@ -5,10 +5,31 @@ use serde_json::{Value, json}; use std::collections::BTreeSet; use std::fs; use std::path::Path; -use std::process::Command; use std::sync::Arc; -use super::memory_fact_assertions::assert_fact_list; +fn assert_fact_list(payload: &Value, included: &str, excluded: &str, context: &str) { + let facts = payload["facts"] + .as_array() + .unwrap_or_else(|| panic!("{context} must return canonical facts: {payload}")); + assert_eq!(facts.len(), 1, "{context}: {payload}"); + let contents: Vec<&str> = facts + .iter() + .map(|projection| { + assert_eq!(projection["kind"], "available", "{context}: {payload}"); + projection["fact"]["content"] + .as_str() + .unwrap_or_else(|| panic!("{context} fact content: {payload}")) + }) + .collect(); + assert!( + contents.iter().any(|content| content.contains(included)), + "{context}: {payload}" + ); + assert!( + contents.iter().all(|content| !content.contains(excluded)), + "{context}: {payload}" + ); +} /// The fact-store surfaces are daemon-owned application operations. Keep these /// tests on the production composition so they cannot accidentally exercise @@ -148,32 +169,7 @@ impl FactStoreCrossProjectFixture { fn initialize_production_fact_project(root: &Path) { fs::create_dir_all(root).expect("cross-project fact fixture root"); crate::fixture::write_indexed_fixture_sources(root); - let init = Command::new(crate::common::git_program()) - .args(["init", "-q"]) - .current_dir(root) - .status() - .expect("initialize cross-project fact fixture"); - assert!(init.success(), "git init should succeed"); - let add = Command::new(crate::common::git_program()) - .args(["add", "."]) - .current_dir(root) - .status() - .expect("stage cross-project fact fixture"); - assert!(add.success(), "git add should succeed"); - let commit = Command::new(crate::common::git_program()) - .args([ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay@example.invalid", - "commit", - "-qm", - "production fact-store fixture", - ]) - .current_dir(root) - .status() - .expect("commit cross-project fact fixture"); - assert!(commit.success(), "git commit should succeed"); + commit_worktree(root, "production fact-store fixture"); } pub(super) async fn fact_store_cross_project_fixture() -> FactStoreCrossProjectFixture { diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/move_symbol_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/move_symbol_behavior_test.rs index b676abb5f9..b71c5e4bf5 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/move_symbol_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/move_symbol_behavior_test.rs @@ -169,7 +169,7 @@ async fn dry_run_then_apply_moves_compute_grand_total() { let project_root = dir.path().join("project"); let project = project_root.as_path(); write_pricing_crate(project); - let (fixture, ()) = init_production_source_edit_project(project).await; + let fixture = init_production_source_edit_project(project).await; let server = fixture .harness .server(project) @@ -294,7 +294,7 @@ async fn move_symbol_refuses_unsafe_or_stale_requests() { let project_root = dir.path().join("project"); let project = project_root.as_path(); write_pricing_crate(project); - let (fixture, ()) = init_production_source_edit_project(project).await; + let fixture = init_production_source_edit_project(project).await; let server = fixture .harness .server(project) diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/move_symbol_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/move_symbol_test.rs index 3ba160ae9c..d756f37a0f 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/move_symbol_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/move_symbol_test.rs @@ -16,7 +16,7 @@ async fn test_move_symbol_dry_run_reports_impact_and_writes_nothing() { let project_root = dir.path().join("project"); let project = project_root.as_path(); move_pricing_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let before_pricing = fs::read_to_string(project.join("src/pricing.rs")).unwrap(); let before_orders = fs::read_to_string(project.join("src/orders.rs")).unwrap(); @@ -101,7 +101,7 @@ async fn test_move_symbol_resolves_qualified_names_like_bare_names() { let project_root = dir.path().join("project"); let project = project_root.as_path(); move_pricing_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let before_pricing = fs::read_to_string(project.join("src/pricing.rs")).unwrap(); let before_orders = fs::read_to_string(project.join("src/orders.rs")).unwrap(); @@ -177,7 +177,7 @@ async fn test_move_symbol_only_prefers_callable_for_bare_names() { "pub mod common {\n pub struct same;\n}\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let before_a = fs::read_to_string(project.join("src/a.rs")).unwrap(); let before_b = fs::read_to_string(project.join("src/b.rs")).unwrap(); @@ -240,7 +240,7 @@ async fn test_move_symbol_apply_moves_and_rerun_errors_cleanly() { let project_root = dir.path().join("project"); let project = project_root.as_path(); move_pricing_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -314,7 +314,7 @@ async fn test_move_symbol_clean_move_has_empty_impact() { "//! b\n\npub fn other() -> u32 {\n 0\n}\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -354,7 +354,7 @@ async fn test_move_symbol_dest_collision_refuses() { "//! b\n\npub fn dup() -> u32 {\n 2\n}\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let before_b = fs::read_to_string(project.join("src/b.rs")).unwrap(); let result = handle_tool_call( @@ -401,7 +401,7 @@ async fn test_move_symbol_private_dependency_hints() { "//! b\n\npub fn other() -> u32 {\n 0\n}\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -471,7 +471,7 @@ async fn test_move_symbol_qualified_caller_hint() { }\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -529,7 +529,7 @@ async fn test_move_symbol_first_in_file_docs_travel() { "//! b\n\npub fn other() -> u32 {\n 0\n}\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -577,7 +577,7 @@ async fn test_move_symbol_dot_prefixed_same_file_refuses() { let project_root = dir.path().join("project"); let project = project_root.as_path(); move_pricing_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let before_pricing = fs::read_to_string(project.join("src/pricing.rs")).unwrap(); let result = handle_tool_call( @@ -617,7 +617,7 @@ async fn test_move_symbol_symlink_escape_refuses() { move_pricing_fixture(project).await; let outside = tempfile::tempdir().unwrap(); unix_fs::symlink(outside.path(), project.join("src/escape")).unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let before = fs::read_to_string(project.join("src/pricing.rs")).unwrap(); let result = handle_tool_call( @@ -665,7 +665,7 @@ async fn test_move_symbol_aliases_to_source_refuse() { } else { unix_fs::symlink(&source, &alias).unwrap(); } - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let before = fs::read_to_string(&source).unwrap(); let result = handle_tool_call( @@ -703,7 +703,7 @@ async fn test_move_symbol_dot_prefixed_dest_normalizes() { let project_root = dir.path().join("project"); let project = project_root.as_path(); move_pricing_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -752,7 +752,7 @@ async fn test_move_symbol_leaves_contiguous_module_doc_behind() { "//! b\n\npub fn other() -> u32 {\n 0\n}\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let result = handle_tool_call( &cg, @@ -807,7 +807,7 @@ async fn test_move_symbol_non_utf8_destination_refuses() { "//! a\n\npub fn movable() -> u32 {\n 1\n}\n", ) .unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let server = cg .harness .server(&cg.project_root) diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/multi_str_replace_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/multi_str_replace_behavior_test.rs index 5dfdf5c246..7535a56a8e 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/multi_str_replace_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/multi_str_replace_behavior_test.rs @@ -29,7 +29,7 @@ async fn open_fixture(files: &[(&str, &str)]) -> (TestTempDir, ProductionSourceE fs::create_dir_all(path.parent().expect("fixture file has a parent")).unwrap(); fs::write(&path, contents).unwrap(); } - let (fixture, ()) = init_production_source_edit_project(&project).await; + let fixture = init_production_source_edit_project(&project).await; (dir, fixture) } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs index ef25ce74b6..000c600c8f 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs @@ -6,7 +6,6 @@ use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use serde_json::{Value, json}; use tracedecay::mcp::McpServer; @@ -581,11 +580,6 @@ fn directory(path: &Path) -> PathBuf { fn git_repository(path: &Path) -> PathBuf { let root = directory(path); - let status = Command::new(crate::common::git_program()) - .args(["init", "--quiet"]) - .current_dir(&root) - .status() - .expect("git init"); - assert!(status.success(), "git init failed in {}", root.display()); + crate::common::fixture::git_run(&root, &["init", "--quiet"]); root } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/release_placement_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/release_placement_test.rs index 4234b33d50..442af45477 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/release_placement_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/release_placement_test.rs @@ -8,9 +8,10 @@ #![cfg(all(feature = "test-transport", unix))] use std::path::Path; -use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::common::fixture::git_run as git; + use serde_json::{Value, json}; use crate::support::{ @@ -45,20 +46,6 @@ fn now_micros() -> i64 { .expect("current time fits UtcMicros") } -fn git(root: &Path, args: &[&str]) { - let output = Command::new(crate::common::git_program()) - .args(args) - .current_dir(root) - .output() - .unwrap_or_else(|error| panic!("git {args:?} in {}: {error}", root.display())); - assert!( - output.status.success(), - "git {args:?} in {} failed: {}", - root.display(), - String::from_utf8_lossy(&output.stderr) - ); -} - fn path_arg(path: &Path) -> String { path.to_str() .unwrap_or_else(|| panic!("{} is not UTF-8", path.display())) diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rename_symbol_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rename_symbol_test.rs index 987d9e6102..1839094674 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rename_symbol_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/rename_symbol_test.rs @@ -333,7 +333,7 @@ async fn test_rename_symbol_dry_run_default_reports_plan_and_writes_nothing() { let project_root = dir.path().join("project"); let project = project_root.as_path(); rename_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let node = preview_node(&cg, "compute_grand_total").await; assert_eq!(node["name"], "compute_grand_total"); @@ -415,7 +415,7 @@ async fn test_rename_symbol_apply_rewrites_declaration_and_callers() { let project_root = dir.path().join("project"); let project = project_root.as_path(); rename_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let node = preview_node(&cg, "compute_grand_total").await; let preview = preview_rename(&cg, &node, "calculate_total_cents").await; @@ -490,7 +490,7 @@ async fn test_rename_symbol_stale_tree_refuses_before_writing() { let project_root = dir.path().join("project"); let project = project_root.as_path(); rename_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let node = preview_node(&cg, "compute_grand_total").await; let preview = preview_rename(&cg, &node, "calculate_total_cents").await; @@ -595,7 +595,7 @@ async fn test_rename_symbol_denies_invalid_and_colliding_names() { let project_root = dir.path().join("project"); let project = project_root.as_path(); rename_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let node = preview_node(&cg, "compute_grand_total").await; @@ -676,7 +676,7 @@ async fn test_rename_symbol_blocks_unresolved_cross_module_spelling() { let project = project_root.as_path(); rename_fixture(project).await; fs::write(project.join("src/nested/orders.rs"), ORDERS_CROSS_MODULE).unwrap(); - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let node = preview_node(&cg, "compute_grand_total").await; let payload = call_json( @@ -787,7 +787,7 @@ async fn test_rename_symbol_publication_failure_preserves_preimage() { let project_root = dir.path().join("project"); let project = project_root.as_path(); rename_fixture(project).await; - let (cg, _env) = init_test_project(project).await; + let cg = init_test_project(project).await; let node = preview_node(&cg, "compute_grand_total").await; let preview = preview_rename(&cg, &node, "calculate_total_cents").await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/replace_symbol_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/replace_symbol_test.rs index 3dda74f4a1..2efb317167 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/replace_symbol_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/replace_symbol_test.rs @@ -153,7 +153,7 @@ async fn open_sources( fs::create_dir_all(path.parent().expect("source file has a parent")).unwrap(); fs::write(&path, contents).unwrap(); } - let (fixture, _) = init_test_project(&project_root).await; + let fixture = init_test_project(&project_root).await; (dir, project_root, fixture) } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_support.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_support.rs deleted file mode 100644 index 2b9f49c73b..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_support.rs +++ /dev/null @@ -1,54 +0,0 @@ -use serde_json::{Value, json}; - -pub(super) fn retrieve_json_arguments(handle: &str) -> Value { - json!({ "format": "json", "handle": handle }) -} - -#[cfg(feature = "test-transport")] -pub(super) async fn retrieve_all_json_pages( - fixture: &crate::support::ProductionCompositionFixture, - handle: &str, -) -> String { - let mut offset = 0usize; - let mut content = String::new(); - loop { - let page = call_production_tool( - fixture, - "tracedecay_retrieve", - json!({"format": "json", "handle": handle, "offset": offset}), - ) - .await; - let page: Value = serde_json::from_str(crate::support::extract_text(&page.value)) - .expect("retrieve page JSON"); - let page_content = page["content"].as_str().expect("retrieve page content"); - content.push_str(page_content); - if !page["has_more"].as_bool().expect("retrieve has_more") { - return content; - } - offset = page["next_offset"].as_u64().expect("retrieve next_offset") as usize; - } -} - -#[cfg(feature = "test-transport")] -pub(super) async fn call_production_tool( - fixture: &crate::support::ProductionCompositionFixture, - tool_name: &str, - arguments: Value, -) -> tracedecay_mcp::ToolResult { - let response = fixture - .harness - .call_tool(&fixture.project_root, tool_name, arguments) - .await - .unwrap_or_else(|error| panic!("{tool_name} production invocation failed: {error}")); - assert!( - response.error.is_none(), - "{tool_name} returned a production MCP error: {:?}", - response.error.as_ref().map(|error| &error.message) - ); - tracedecay_mcp::ToolResult::new( - response - .result - .unwrap_or_else(|| panic!("{tool_name} returned no production MCP result")), - Vec::new(), - ) -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_test.rs index 24ae903fad..77b28b037b 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/retrieve_truncation_test.rs @@ -3,13 +3,56 @@ use serde_json::{Value, json}; #[cfg(feature = "test-transport")] use std::fmt::Write as _; use std::fs; -#[path = "retrieve_truncation_support.rs"] -mod retrieve_truncation_support; + +fn retrieve_json_arguments(handle: &str) -> Value { + json!({ "format": "json", "handle": handle }) +} + #[cfg(feature = "test-transport")] -use retrieve_truncation_support::call_production_tool; +async fn retrieve_all_json_pages(fixture: &ProductionCompositionFixture, handle: &str) -> String { + let mut offset = 0usize; + let mut content = String::new(); + loop { + let page = call_production_tool( + fixture, + "tracedecay_retrieve", + json!({"format": "json", "handle": handle, "offset": offset}), + ) + .await; + let page: Value = + serde_json::from_str(extract_text(&page.value)).expect("retrieve page JSON"); + let page_content = page["content"].as_str().expect("retrieve page content"); + content.push_str(page_content); + if !page["has_more"].as_bool().expect("retrieve has_more") { + return content; + } + offset = page["next_offset"].as_u64().expect("retrieve next_offset") as usize; + } +} + #[cfg(feature = "test-transport")] -use retrieve_truncation_support::retrieve_all_json_pages; -use retrieve_truncation_support::retrieve_json_arguments; +async fn call_production_tool( + fixture: &ProductionCompositionFixture, + tool_name: &str, + arguments: Value, +) -> tracedecay_mcp::ToolResult { + let response = fixture + .harness + .call_tool(&fixture.project_root, tool_name, arguments) + .await + .unwrap_or_else(|error| panic!("{tool_name} production invocation failed: {error}")); + assert!( + response.error.is_none(), + "{tool_name} returned a production MCP error: {:?}", + response.error.as_ref().map(|error| &error.message) + ); + tracedecay_mcp::ToolResult::new( + response + .result + .unwrap_or_else(|| panic!("{tool_name} returned no production MCP result")), + Vec::new(), + ) +} #[tokio::test] async fn retrieve_tool_returns_full_stored_response() { diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_cancel_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_cancel_test.rs index d5c1322c9b..f62b4ab778 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_cancel_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_cancel_test.rs @@ -7,13 +7,13 @@ //! cancelled in the store. Missing, unknown, and stale handles, and an //! unmounted profile refresh authority, are typed refusals. -use crate::support::{GLOBAL_DB_ENV_LOCK, GlobalDbEnvGuard, HomeEnvGuard, extract_text}; #[cfg(feature = "test-transport")] -use crate::{common, fixture}; +use crate::common::fixture::git_run as git; +#[cfg(feature = "test-transport")] +use crate::fixture; +use crate::support::{GLOBAL_DB_ENV_LOCK, GlobalDbEnvGuard, HomeEnvGuard, extract_text}; use serde_json::{Value, json}; use std::path::Path; -#[cfg(feature = "test-transport")] -use std::process::Command; use std::sync::Arc; use std::time::Duration; #[cfg(feature = "test-transport")] @@ -213,16 +213,6 @@ async fn production_call( tool_envelope(&result) } -#[cfg(feature = "test-transport")] -fn git(project: &Path, args: &[&str]) { - let status = Command::new(common::git_program()) - .args(args) - .current_dir(project) - .status() - .unwrap_or_else(|error| panic!("git {args:?} failed to start: {error}")); - assert!(status.success(), "git {args:?} failed: {status}"); -} - /// The production MCP server answers cancel the way an agent calls it: /// typed refusals for bad handles, and the already-written complete receipt /// once the refresh has finished. diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_status_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_status_test.rs index 876c537c0c..ea3f7e6086 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_status_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_status_test.rs @@ -4,14 +4,14 @@ //! assertions name the envelope a host reads, not scheduler or store calls. use std::path::Path; -use std::process::Command; use std::time::Duration; +use crate::common::fixture::git_run as git; + use serde_json::{Value, json}; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; -use crate::common; use crate::fixture; use crate::support::{GLOBAL_DB_ENV_LOCK, HomeEnvGuard, test_temp_dir}; @@ -42,15 +42,6 @@ fn refresh_arguments(session_id: &str, handle: Option<&str>) -> Value { arguments } -fn git(project: &Path, args: &[&str]) { - let status = Command::new(common::git_program()) - .args(args) - .current_dir(project) - .status() - .unwrap_or_else(|error| panic!("git {args:?} failed to start: {error}")); - assert!(status.success(), "git {args:?} failed"); -} - async fn call_tool( harness: &ProductionProjectCompositionHarnessV1, project: &Path, diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_search_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_search_test.rs index ff713e337b..31107a797b 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_search_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_search_test.rs @@ -508,32 +508,7 @@ async fn production_codex_hook_ingest_survives_message_search_reopen() { let project = isolation.join("project"); std::fs::create_dir_all(&project).expect("production composition project"); fixture::write_indexed_fixture_sources(&project); - let init = Command::new(common::git_program()) - .args(["init", "-q"]) - .current_dir(&project) - .status() - .expect("git init"); - assert!(init.success(), "git init must succeed"); - let add = Command::new(common::git_program()) - .args(["add", "."]) - .current_dir(&project) - .status() - .expect("git add"); - assert!(add.success(), "git add must succeed"); - let commit = Command::new(common::git_program()) - .args([ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay@example.invalid", - "commit", - "-qm", - "production Codex transcript fixture", - ]) - .current_dir(&project) - .status() - .expect("git commit"); - assert!(commit.success(), "git commit must succeed"); + commit_worktree(&project, "production Codex transcript fixture"); write_production_codex_rollout(&home, &project); let harness = ProductionProjectCompositionHarnessV1::open_for_session_retrieval( diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs index 5a422d5000..963bbdc529 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs @@ -46,7 +46,7 @@ async fn open_project() -> OpenedProject { let project = dir.path().join("project"); fs::create_dir_all(project.join("src/locked")).unwrap(); fs::write(project.join(RELATIVE_PATH), PREIMAGE).unwrap(); - let (fixture, ()) = init_production_source_edit_project(&project).await; + let fixture = init_production_source_edit_project(&project).await; OpenedProject { file: project.join(RELATIVE_PATH), fixture, diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs index 1753dfc23b..f370421b08 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs @@ -90,7 +90,7 @@ async fn open_moved_project() -> MovedProject { fs::write(project.join("src/lib.rs"), LIB_RS).unwrap(); fs::write(project.join("src/source.rs"), SOURCE_RS).unwrap(); fs::write(project.join("src/dest.rs"), DEST_RS).unwrap(); - let (fixture, _) = init_production_source_edit_project(&project).await; + let fixture = init_production_source_edit_project(&project).await; let server = fixture .harness .server(&fixture.project_root) @@ -423,7 +423,7 @@ async fn source_edit_rollback_refuses_an_edit_without_retained_preimages() { let project = dir.path().join("project"); fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/main.rs"), "fn old_name() {}\n").unwrap(); - let (fixture, _) = init_production_source_edit_project(&project).await; + let fixture = init_production_source_edit_project(&project).await; let preview = tool_payload( &call_tool( diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/str_replace_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/str_replace_behavior_test.rs index 2746223fc8..b4de374f59 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/str_replace_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/str_replace_behavior_test.rs @@ -34,7 +34,7 @@ async fn open_file( let file = project.join(relative); fs::create_dir_all(file.parent().expect("fixture file has a parent")).unwrap(); fs::write(&file, bytes).unwrap(); - let (fixture, ()) = init_production_source_edit_project(&project).await; + let fixture = init_production_source_edit_project(&project).await; (fixture, dir, file) } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/test_risk_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/test_risk_behavior_test.rs index c330dcf170..7e7b7f7977 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/test_risk_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/test_risk_behavior_test.rs @@ -9,7 +9,8 @@ use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; + +use crate::common::fixture::git_run as git; use serde_json::{Value, json}; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; @@ -268,15 +269,6 @@ async fn open_ranked_project() -> RankedProject { } } -fn git(project: &Path, args: &[&str]) { - let status = Command::new(crate::common::git_program()) - .args(args) - .current_dir(project) - .status() - .unwrap_or_else(|error| panic!("git {args:?}: {error}")); - assert!(status.success(), "git {args:?} exited {status}"); -} - fn commit(project: &Path, message: &str) { git( project, diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/work_resume_attempts_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/work_resume_attempts_test.rs index bd0b8cc70f..3d9603bf9a 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/work_resume_attempts_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/work_resume_attempts_test.rs @@ -8,8 +8,11 @@ //! settle the child recovery is supposed to find still open, so the live //! daemon is a separate process stopped with SIGKILL. +use crate::common::fixture::git_capture; use crate::fixture; -use crate::support::{extract_real_server_text, handle_real_server_tool_call, test_temp_dir}; +use crate::support::{ + commit_worktree, extract_real_server_text, handle_real_server_tool_call, test_temp_dir, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::BTreeSet; @@ -688,16 +691,7 @@ async fn start_attempt( } fn fixture_commit(project_root: &Path) -> String { - let commit = Command::new(crate::common::git_program()) - .args(["rev-parse", "HEAD"]) - .current_dir(project_root) - .output() - .expect("read fixture commit"); - assert!(commit.status.success(), "git rev-parse must succeed"); - String::from_utf8(commit.stdout) - .expect("commit is UTF-8") - .trim() - .to_owned() + git_capture(project_root, &["rev-parse", "HEAD"]) } async fn configure_hold_provider( @@ -791,39 +785,7 @@ async fn configure_hold_provider( fn seed_project(project_root: &Path) { std::fs::create_dir_all(project_root).expect("project root"); fixture::write_indexed_fixture_sources(project_root); - let git = crate::common::git_program(); - assert!( - Command::new(&git) - .args(["init", "-q"]) - .current_dir(project_root) - .status() - .expect("git init") - .success() - ); - assert!( - Command::new(&git) - .args(["add", "."]) - .current_dir(project_root) - .status() - .expect("git add") - .success() - ); - assert!( - Command::new(git) - .args([ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay@example.invalid", - "commit", - "-qm", - "resume attempts fixture", - ]) - .current_dir(project_root) - .status() - .expect("git commit") - .success() - ); + commit_worktree(project_root, "resume attempts fixture"); } fn now_micros() -> i64 { diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test.rs index aee840519c..f537348cae 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test.rs @@ -15,10 +15,6 @@ mod multi_root_scope_set_read_test; mod protocol_test; mod retrieve_behavior_test; pub(crate) mod support; -mod unused_imports_test; -mod workflow_create; -mod workflow_delete_test; -mod workflow_update_test; // Backwards-compatible path for `crate::mcp_server_test::…` consumers. pub(crate) use support::run_client_connection_with_messages; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/hooks_branch_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/hooks_branch_test.rs index 6f628d7e8d..41bcf1ef2a 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/hooks_branch_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/hooks_branch_test.rs @@ -1,3 +1,4 @@ +use crate::common::fixture::git_run as git; use crate::mcp_server_test::support::*; use serde_json::{Value, json}; use std::fs; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs index 8bdd707732..50ca37537c 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test.rs @@ -1,19 +1,18 @@ use crate::mcp_server_test::support::*; use serde_json::{Value, json}; use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; use tempfile::TempDir; +use tracedecay::daemon::ProductionProjectCompositionHarnessV1; +use tracedecay::mcp::McpServer; use tracedecay::project::current_timestamp; use tracedecay_mcp::response_handles::{ RESPONSE_HANDLE_TTL_SECS, cleanup_expired_response_handles, store_response_handle, }; use tracedecay_runtime_core::storage::resolve_response_handle_root; -mod initialize_routing; - -// --------------------------------------------------------------------------- -// 1. test_initialize -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_initialize() { let (server, _dir) = setup_server().await; @@ -38,18 +37,14 @@ async fn test_initialize() { #[tokio::test] async fn initialize_roots_route_registered_reader_tools_without_explicit_selector() { - initialize_routing::assert_registered_reader_uses_initialize_root().await; + assert_registered_reader_uses_initialize_root().await; } #[tokio::test] async fn initialize_root_route_rejects_caller_project_path_spoof() { - initialize_routing::assert_legacy_selectors_cannot_spoof_initialize_root().await; + assert_legacy_selectors_cannot_spoof_initialize_root().await; } -// --------------------------------------------------------------------------- -// 2. notifications -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_any_notification_without_id_produces_no_response() { let (server, _dir) = setup_server().await; @@ -120,10 +115,6 @@ async fn test_tools_call_explicit_null_id_is_still_a_request() { ); } -// --------------------------------------------------------------------------- -// 5. test_tools_list -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_tools_list() { let (server, _dir) = setup_server().await; @@ -153,10 +144,6 @@ async fn test_tools_list() { ); } -// --------------------------------------------------------------------------- -// 6. test_tools_call_search -// --------------------------------------------------------------------------- - #[cfg(feature = "test-transport")] #[tokio::test] async fn test_tools_call_search() { @@ -280,10 +267,6 @@ async fn test_tools_call_plain_text_failure_sets_is_error() { ); } -// --------------------------------------------------------------------------- -// 6b. test_tools_call_timings_flag -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_tools_call_timings_enabled_by_default() { let (server, _dir) = setup_server().await; @@ -625,10 +608,6 @@ async fn cancellable_tool_call_fails_connection_on_peer_write_failure() { fixture.harness.shutdown().await; } -// --------------------------------------------------------------------------- -// 7. test_tools_call_status -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_tools_call_status() { let (server, _dir) = setup_server().await; @@ -669,10 +648,6 @@ async fn test_tools_call_status() { ); } -// --------------------------------------------------------------------------- -// 8. test_tools_call_missing_params -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_tools_call_missing_params() { let (server, _dir) = setup_server().await; @@ -707,10 +682,6 @@ async fn test_tools_call_missing_params() { ); } -// --------------------------------------------------------------------------- -// 9. test_tools_call_missing_name -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_tools_call_missing_name() { let (server, _dir) = setup_server().await; @@ -881,10 +852,6 @@ async fn test_tracedecay_retrieve_handle_read_failure_returns_actionable_interna ); } -// --------------------------------------------------------------------------- -// 10. test_unknown_method -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_unknown_method() { let (server, _dir) = setup_server().await; @@ -904,10 +871,6 @@ async fn test_unknown_method() { ); } -// --------------------------------------------------------------------------- -// 11. test_malformed_json -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_malformed_json() { let (server, _dir) = setup_server().await; @@ -951,10 +914,6 @@ async fn test_malformed_json() { ); } -// --------------------------------------------------------------------------- -// 11b. test_foreign_protocol_version_is_rejected_before_dispatch -// --------------------------------------------------------------------------- - /// The envelope rule is enforced once at the transport boundary: a frame /// whose `jsonrpc` is not exactly `"2.0"` is answered with `InvalidRequest` /// carrying its id and never reaches the handler, so it cannot execute a tool @@ -1009,10 +968,6 @@ async fn test_foreign_protocol_version_is_rejected_before_dispatch() { ); } -// --------------------------------------------------------------------------- -// 12. test_blank_lines_skipped -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_blank_lines_skipped() { let (server, _dir) = setup_server().await; @@ -1303,10 +1258,6 @@ async fn test_server_stats_after_run() { assert_eq!(stats["ratios"]["tool_calls_per_jsonrpc_message"], 0.25); } -// --------------------------------------------------------------------------- -// 16. test_error_tracking -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_error_tracking() { let (server, _dir) = setup_server().await; @@ -1362,10 +1313,6 @@ async fn test_error_tracking() { ); } -// --------------------------------------------------------------------------- -// 17. test_initialize_has_resources_capability -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_initialize_has_resources_capability() { let (server, _dir) = setup_server().await; @@ -1382,10 +1329,6 @@ async fn test_initialize_has_resources_capability() { ); } -// --------------------------------------------------------------------------- -// 19. test_resources_list -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_resources_list() { let (server, _dir) = setup_server().await; @@ -1438,10 +1381,6 @@ async fn test_resources_list() { } } -// --------------------------------------------------------------------------- -// 20. test_resources_read_status -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_resources_read_status() { let (server, _dir) = setup_server().await; @@ -1483,10 +1422,6 @@ async fn test_resources_read_status() { ); } -// --------------------------------------------------------------------------- -// 21. test_resources_read_files -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_resources_read_files() { let (server, _dir) = setup_server().await; @@ -1526,10 +1461,6 @@ async fn test_resources_read_files() { ); } -// --------------------------------------------------------------------------- -// 22. test_resources_read_overview -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_resources_read_overview() { let (server, _dir) = setup_server().await; @@ -1575,10 +1506,6 @@ async fn test_resources_read_overview() { ); } -// --------------------------------------------------------------------------- -// 23. test_resources_read_unknown_uri -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_resources_read_unknown_uri() { let (server, _dir) = setup_server().await; @@ -1609,10 +1536,6 @@ async fn test_resources_read_unknown_uri() { ); } -// --------------------------------------------------------------------------- -// 24. test_resources_read_missing_uri -// --------------------------------------------------------------------------- - #[tokio::test] async fn test_resources_read_missing_uri() { let (server, _dir) = setup_server().await; @@ -1637,10 +1560,6 @@ async fn test_resources_read_missing_uri() { ); } -// --------------------------------------------------------------------------- -// Regression: logging/setLevel must be handled (not return MethodNotFound) -// --------------------------------------------------------------------------- - /// The MCP client sends `logging/setLevel` immediately after initialisation /// whenever the server advertises the `logging` capability. Before the fix the /// server returned -32601 (MethodNotFound), which Claude Code logged as an @@ -1755,10 +1674,6 @@ async fn test_run_returns_transport_read_errors() { ); } -// --------------------------------------------------------------------------- -// search_call_writes_savings_ledger_row -// --------------------------------------------------------------------------- - // Repeated serve-mode LCM calls must keep working while the project session // DB schema is ensured at most once per process: after the first write-path // call creates the store and runs the migrations, later write-path calls @@ -1923,3 +1838,178 @@ async fn repeated_serve_lcm_calls_do_not_rerun_migrations() { stat_sessions_db("after-second-serve"), ); } + +fn initialize_protocol_fixture(project: &Path, module: &str) { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write( + project.join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + fs::write(project.join("src/lib.rs"), format!("pub mod {module};\n")).unwrap(); + fs::write( + project.join(format!("src/{module}.rs")), + format!("pub fn {module}_marker() {{}}\n"), + ) + .unwrap(); + for args in [ + &["init", "--quiet"][..], + &["add", "."][..], + &[ + "-c", + "user.name=TraceDecay Tests", + "-c", + "user.email=tests@tracedecay.invalid", + "commit", + "--quiet", + "-m", + "fixture", + ][..], + ] { + assert!( + Command::new("git") + .args(args) + .current_dir(project) + .status() + .unwrap() + .success() + ); + } +} + +async fn fixture() -> ( + TempDir, + ProductionProjectCompositionHarnessV1, + Arc, + PathBuf, +) { + let isolation = TempDir::new().unwrap(); + let active_project = isolation.path().join("active-project"); + let target_project = isolation.path().join("target-project"); + initialize_protocol_fixture(&active_project, "active"); + initialize_protocol_fixture(&target_project, "target"); + let harness = ProductionProjectCompositionHarnessV1::open( + isolation.path(), + [active_project.clone(), target_project.clone()], + ) + .await + .unwrap(); + let server = harness.server(&active_project).unwrap(); + (isolation, harness, server, target_project) +} + +fn initialize_request(target_project: &Path) -> String { + let target_root_uri = url::Url::from_file_path(target_project) + .expect("target project has a portable file URI") + .to_string(); + jsonrpc_request( + json!(1), + "initialize", + json!({ + "clientInfo": {"name": "codex", "version": "test"}, + "roots": [{"uri": target_root_uri, "name": "target-project"}] + }), + ) +} + +fn files_request(id: u64, arguments: Value) -> String { + jsonrpc_request( + json!(id), + "tools/call", + json!({ + "name": "tracedecay_files", + "arguments": arguments, + }), + ) +} + +async fn assert_registered_reader_uses_initialize_root() { + let (_isolation, _harness, server, target_project) = fixture().await; + let responses = run_server_with_messages( + server, + vec![ + initialize_request(&target_project), + files_request(2, json!({"layout": "flat"})), + ], + ) + .await; + + let files_response = response_with_id(&responses, json!(2)); + let text = files_response["result"]["content"][0]["text"] + .as_str() + .expect("files response text"); + assert!( + text.contains("src/target.rs"), + "initialize root should route reader tools to target project, got {text}" + ); + assert!( + !text.contains("src/active.rs"), + "implicit initialize-root routing should not read the active project: {text}" + ); +} + +async fn assert_legacy_selectors_cannot_spoof_initialize_root() { + let (_isolation, _harness, server, target_project) = fixture().await; + let active_graph = server.cg().await; + let active_root = active_graph.project_root().to_string_lossy().into_owned(); + let active_project_id = active_graph + .store_layout() + .identity + .project_id + .clone() + .expect("active project has a registered identity"); + + let spoof_cases = [ + ( + "top-level project_path", + json!({"layout": "flat", "project_path": active_root.clone()}), + ), + ( + "top-level project_root", + json!({"layout": "flat", "project_root": active_root.clone()}), + ), + ( + "nested selector path", + json!({"layout": "flat", "project_selector": {"path": active_root.clone()}}), + ), + ( + "nested selector project_path", + json!({"layout": "flat", "project_selector": {"project_path": active_root}}), + ), + ( + "top-level project_id alias", + json!({"layout": "flat", "project_id": active_project_id}), + ), + ]; + let mut messages = vec![initialize_request(&target_project)]; + for (offset, (_, arguments)) in spoof_cases.iter().enumerate() { + messages.push(files_request(10 + offset as u64, arguments.clone())); + } + messages.push(files_request(100, json!({"layout": "flat"}))); + + let responses = run_server_with_messages(server, messages).await; + for (offset, (case, _)) in spoof_cases.iter().enumerate() { + let response = response_with_id(&responses, json!(10 + offset as u64)); + assert_eq!( + response["error"]["code"], -32602, + "{case} must be rejected as invalid parameters instead of overriding the initialize-root route: {response}" + ); + assert!( + response["result"].is_null(), + "{case} must not return a tool result after invalid-parameter rejection: {response}" + ); + assert!( + !response.to_string().contains("src/active.rs"), + "{case} must not serve spoof-project data: {response}" + ); + } + + let clean_response = response_with_id(&responses, json!(100)); + let clean_text = clean_response["result"]["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("clean files response text: {clean_response}")); + assert!( + clean_text.contains("src/target.rs") && !clean_text.contains("src/active.rs"), + "rejected spoof attempts must not disturb the initialize-root route: {clean_text}" + ); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test/initialize_routing.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test/initialize_routing.rs deleted file mode 100644 index 70205196ad..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/protocol_test/initialize_routing.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::Arc; - -use serde_json::{Value, json}; -use tempfile::TempDir; -use tracedecay::daemon::ProductionProjectCompositionHarnessV1; -use tracedecay::mcp::McpServer; - -use crate::mcp_server_test::support::{ - jsonrpc_request, response_with_id, run_server_with_messages, -}; - -fn initialize_protocol_fixture(project: &Path, module: &str) { - fs::create_dir_all(project.join("src")).unwrap(); - fs::write( - project.join("Cargo.toml"), - "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", - ) - .unwrap(); - fs::write(project.join("src/lib.rs"), format!("pub mod {module};\n")).unwrap(); - fs::write( - project.join(format!("src/{module}.rs")), - format!("pub fn {module}_marker() {{}}\n"), - ) - .unwrap(); - for args in [ - &["init", "--quiet"][..], - &["add", "."][..], - &[ - "-c", - "user.name=TraceDecay Tests", - "-c", - "user.email=tests@tracedecay.invalid", - "commit", - "--quiet", - "-m", - "fixture", - ][..], - ] { - assert!( - Command::new("git") - .args(args) - .current_dir(project) - .status() - .unwrap() - .success() - ); - } -} - -async fn fixture() -> ( - TempDir, - ProductionProjectCompositionHarnessV1, - Arc, - PathBuf, -) { - let isolation = TempDir::new().unwrap(); - let active_project = isolation.path().join("active-project"); - let target_project = isolation.path().join("target-project"); - initialize_protocol_fixture(&active_project, "active"); - initialize_protocol_fixture(&target_project, "target"); - let harness = ProductionProjectCompositionHarnessV1::open( - isolation.path(), - [active_project.clone(), target_project.clone()], - ) - .await - .unwrap(); - let server = harness.server(&active_project).unwrap(); - (isolation, harness, server, target_project) -} - -fn initialize_request(target_project: &Path) -> String { - let target_root_uri = url::Url::from_file_path(target_project) - .expect("target project has a portable file URI") - .to_string(); - jsonrpc_request( - json!(1), - "initialize", - json!({ - "clientInfo": {"name": "codex", "version": "test"}, - "roots": [{"uri": target_root_uri, "name": "target-project"}] - }), - ) -} - -fn files_request(id: u64, arguments: Value) -> String { - jsonrpc_request( - json!(id), - "tools/call", - json!({ - "name": "tracedecay_files", - "arguments": arguments, - }), - ) -} - -pub(super) async fn assert_registered_reader_uses_initialize_root() { - let (_isolation, _harness, server, target_project) = fixture().await; - let responses = run_server_with_messages( - server, - vec![ - initialize_request(&target_project), - files_request(2, json!({"layout": "flat"})), - ], - ) - .await; - - let files_response = response_with_id(&responses, json!(2)); - let text = files_response["result"]["content"][0]["text"] - .as_str() - .expect("files response text"); - assert!( - text.contains("src/target.rs"), - "initialize root should route reader tools to target project, got {text}" - ); - assert!( - !text.contains("src/active.rs"), - "implicit initialize-root routing should not read the active project: {text}" - ); -} - -pub(super) async fn assert_legacy_selectors_cannot_spoof_initialize_root() { - let (_isolation, _harness, server, target_project) = fixture().await; - let active_graph = server.cg().await; - let active_root = active_graph.project_root().to_string_lossy().into_owned(); - let active_project_id = active_graph - .store_layout() - .identity - .project_id - .clone() - .expect("active project has a registered identity"); - - let spoof_cases = [ - ( - "top-level project_path", - json!({"layout": "flat", "project_path": active_root.clone()}), - ), - ( - "top-level project_root", - json!({"layout": "flat", "project_root": active_root.clone()}), - ), - ( - "nested selector path", - json!({"layout": "flat", "project_selector": {"path": active_root.clone()}}), - ), - ( - "nested selector project_path", - json!({"layout": "flat", "project_selector": {"project_path": active_root}}), - ), - ( - "top-level project_id alias", - json!({"layout": "flat", "project_id": active_project_id}), - ), - ]; - let mut messages = vec![initialize_request(&target_project)]; - for (offset, (_, arguments)) in spoof_cases.iter().enumerate() { - messages.push(files_request(10 + offset as u64, arguments.clone())); - } - messages.push(files_request(100, json!({"layout": "flat"}))); - - let responses = run_server_with_messages(server, messages).await; - for (offset, (case, _)) in spoof_cases.iter().enumerate() { - let response = response_with_id(&responses, json!(10 + offset as u64)); - assert_eq!( - response["error"]["code"], -32602, - "{case} must be rejected as invalid parameters instead of overriding the initialize-root route: {response}" - ); - assert!( - response["result"].is_null(), - "{case} must not return a tool result after invalid-parameter rejection: {response}" - ); - assert!( - !response.to_string().contains("src/active.rs"), - "{case} must not serve spoof-project data: {response}" - ); - } - - let clean_response = response_with_id(&responses, json!(100)); - let clean_text = clean_response["result"]["content"][0]["text"] - .as_str() - .unwrap_or_else(|| panic!("clean files response text: {clean_response}")); - assert!( - clean_text.contains("src/target.rs") && !clean_text.contains("src/active.rs"), - "rejected spoof attempts must not disturb the initialize-root route: {clean_text}" - ); -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/support.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/support.rs index 1062b729b8..d78b8925d2 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/support.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/support.rs @@ -13,10 +13,6 @@ use tracedecay::test_support::host_admission::HostAdmissionTestRuntimeV1; use tracedecay_mcp::transport::{ChannelTransport, McpTransport}; use tracedecay_runtime_core::storage::resolve_response_handle_root; -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - /// Creates a temporary Rust project and returns a direct protocol server. /// /// Graph journeys use [`crate::support::production_composition_fixture`] @@ -404,10 +400,3 @@ pub(crate) fn analytics_metadata(event: &tracedecay_global_db::AnalyticsEventRec ) .expect("analytics event metadata is JSON") } - -// --------------------------------------------------------------------------- -// Repository setup used by routed hook journeys. -// --------------------------------------------------------------------------- -pub(crate) fn git(project: &std::path::Path, args: &[&str]) { - crate::common::fixture::git_run(project, args); -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/unused_imports_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/unused_imports_test.rs deleted file mode 100644 index c813f2f711..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/unused_imports_test.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Host-visible `tools/call` for the retired unused-import scan. -//! -//! The scan was removed because a graph-only walk reported a clean tree on -//! real code. An empty success would look the same. This test calls the MCP -//! name a host still sends and asserts the refusal the server returns. - -use std::fs; - -use serde_json::json; -use tempfile::TempDir; -use tracedecay::mcp::McpServer; - -use crate::mcp_server_test::support::call_tool; - -#[tokio::test] -async fn unused_imports_tool_call_refuses_the_retired_scan() { - let dir = TempDir::new().expect("temporary project"); - let project = dir.path(); - fs::create_dir_all(project.join("src")).expect("src directory"); - fs::write(project.join("src/main.rs"), "fn main() {}\n").expect("fixture source"); - let graph = crate::fixture::init_project_from_template(project) - .await - .expect("project fixture"); - let server = Box::pin(McpServer::new(graph, None)).await; - - let response = call_tool( - server, - 41, - "tracedecay_unused_imports", - json!({ "limit": 50 }), - ) - .await; - - assert_eq!( - response, - json!({ - "jsonrpc": "2.0", - "id": 41, - "error": { - "code": -32603, - "message": "tool execution failed: config error: unknown tool: tracedecay_unused_imports", - "data": { - "tool": "tracedecay_unused_imports", - "cli_fallback": "This tool is also available from the shell: `tracedecay tool unused_imports ...` (`tracedecay tool unused_imports --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." - } - } - }) - ); -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/workflow_create.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/workflow_create.rs deleted file mode 100644 index 7c48c43ae5..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/workflow_create.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Host-visible behavior of `tracedecay_workflow_create`. -//! -//! That name is not a mounted Workflow operation. A caller still reaches it -//! through `tools/call`, and the name is rejected before the body is decoded, -//! so a definition-shaped body and an empty body must return the same -//! JSON-RPC error. - -use serde_json::json; - -use super::support::{jsonrpc_request, response_with_id, run_server_with_messages, setup_server}; - -fn refused_workflow_create(id: i64) -> serde_json::Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32603, - "message": "tool execution failed: config error: unknown tool: tracedecay_workflow_create", - "data": { - "tool": "tracedecay_workflow_create", - "cli_fallback": "This tool is also available from the shell: `tracedecay tool workflow_create ...` (`tracedecay tool workflow_create --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." - } - } - }) -} - -#[tokio::test] -async fn workflow_create_call_refuses_a_definition_body_and_an_empty_body() { - let (server, _dir) = setup_server().await; - let responses = run_server_with_messages( - server, - vec![ - jsonrpc_request( - json!(2), - "tools/call", - json!({ - "name": "tracedecay_workflow_create", - "arguments": { - "definition_id": "workflow.definition.release-review", - "name": "release-review" - } - }), - ), - jsonrpc_request( - json!(3), - "tools/call", - json!({ - "name": "tracedecay_workflow_create", - "arguments": {} - }), - ), - ], - ) - .await; - - assert_eq!( - response_with_id(&responses, json!(2)), - refused_workflow_create(2) - ); - assert_eq!( - response_with_id(&responses, json!(3)), - refused_workflow_create(3) - ); -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/workflow_delete_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/workflow_delete_test.rs deleted file mode 100644 index 13e9056479..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/workflow_delete_test.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! `tracedecay_workflow_delete` is not a Workflow operation. -//! -//! Retirement and rejection are the typed lifecycle transitions. A host that -//! calls the delete name must see the unknown-tool JSON-RPC error, while the -//! advertised retire tool stays in the same discovery payload. - -use crate::mcp_server_test::support::{ - jsonrpc_request, response_with_id, run_server_with_messages, setup_server, -}; -use serde_json::json; - -#[tokio::test] -async fn workflow_delete_call_is_the_unknown_tool_error() { - let (server, _dir) = setup_server().await; - let responses = run_server_with_messages( - server, - vec![ - jsonrpc_request(json!(1), "tools/list", json!({})), - jsonrpc_request( - json!(2), - "tools/call", - json!({ - "name": "tracedecay_workflow_delete", - "arguments": { - "definition_id": "workflow.definition.proof-delete", - "definition_version": 1, - "expected_revision": 1 - } - }), - ), - ], - ) - .await; - - let listed = response_with_id(&responses, json!(1)); - let tools = listed["result"]["tools"] - .as_array() - .expect("tools/list returns a tools array"); - let names = tools - .iter() - .filter_map(|tool| tool["name"].as_str()) - .collect::>(); - let workflow_names = names - .iter() - .copied() - .filter(|name| name.starts_with("tracedecay_workflow_")) - .collect::>(); - assert!( - workflow_names.contains(&"tracedecay_workflow_retire_definition"), - "retire stays the typed non-delete transition; workflow tools: {workflow_names:?}" - ); - assert!( - !names.contains(&"tracedecay_workflow_delete"), - "delete must not be advertised" - ); - - let called = response_with_id(&responses, json!(2)); - assert_eq!( - called, - json!({ - "jsonrpc": "2.0", - "id": 2, - "error": { - "code": -32603, - "message": "tool execution failed: config error: unknown tool: tracedecay_workflow_delete", - "data": { - "tool": "tracedecay_workflow_delete", - "cli_fallback": "This tool is also available from the shell: `tracedecay tool workflow_delete ...` (`tracedecay tool workflow_delete --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." - } - } - }) - ); -} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/workflow_update_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/workflow_update_test.rs deleted file mode 100644 index 9653089548..0000000000 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test/workflow_update_test.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Host-visible behavior of calling `tracedecay_workflow_update`. -//! -//! Workflow definitions are immutable: a new version is registered, never -//! patched in place. This name is outside that closed operation set, so the -//! host sees the catalog refusal rather than a Workflow problem envelope or -//! an empty success. - -use serde_json::json; - -use crate::mcp_server_test::support::{ - jsonrpc_request, response_with_id, run_server_with_messages, setup_server, -}; - -const TOOL_NAME: &str = "tracedecay_workflow_update"; - -#[tokio::test] -async fn calling_workflow_update_is_refused_as_an_unknown_tool() { - let (server, _project) = setup_server().await; - let responses = run_server_with_messages( - server, - vec![ - jsonrpc_request(json!(1), "tools/list", json!({})), - jsonrpc_request( - json!(2), - "tools/call", - json!({ - "name": TOOL_NAME, - "arguments": { - "definition_id": "wfdef_checkout", - "expected_revision": 3, - "summary": "retry payment capture after the adapter timeout" - } - }), - ), - ], - ) - .await; - - let listed = response_with_id(&responses, json!(1)); - let advertised: Vec<&str> = listed["result"]["tools"] - .as_array() - .expect("tools/list returns the advertised catalog") - .iter() - .filter_map(|tool| tool["name"].as_str()) - .collect(); - assert!( - advertised.contains(&"tracedecay_workflow_list_definitions"), - "discovery must still advertise the closed Workflow family: {advertised:?}" - ); - assert!( - !advertised.contains(&TOOL_NAME), - "{TOOL_NAME} must stay off the advertised catalog: {advertised:?}" - ); - - let called = response_with_id(&responses, json!(2)); - assert_eq!( - called, - json!({ - "jsonrpc": "2.0", - "id": 2, - "error": { - "code": -32603, - "message": "tool execution failed: config error: unknown tool: tracedecay_workflow_update", - "data": { - "cli_fallback": "This tool is also available from the shell: `tracedecay tool workflow_update ...` (`tracedecay tool workflow_update --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly.", - "tool": "tracedecay_workflow_update" - } - } - }) - ); -} diff --git a/crates/tracedecay/tests/mcp_suite/status_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/status_behavior_test.rs index aaf367db6d..f8b099b6ed 100644 --- a/crates/tracedecay/tests/mcp_suite/status_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/status_behavior_test.rs @@ -6,14 +6,14 @@ #![cfg(feature = "test-transport")] -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::path::PathBuf; use std::time::{Duration, Instant}; +use crate::common::fixture::{git_capture as git_stdout, git_run as git}; + use serde_json::{Value, json}; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; -use crate::common; use crate::fixture; use crate::support::{TestTempDir, test_temp_dir}; @@ -26,36 +26,6 @@ struct StatusProject { _isolation: TestTempDir, } -fn git(project: &Path, args: &[&str]) { - let status = Command::new(common::git_program()) - .args(args) - .current_dir(project) - .status() - .expect("git"); - assert!( - status.success(), - "git {args:?} failed in {}", - project.display() - ); -} - -fn git_stdout(project: &Path, args: &[&str]) -> String { - let output = Command::new(common::git_program()) - .args(args) - .current_dir(project) - .output() - .expect("git"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout) - .expect("git stdout") - .trim() - .to_owned() -} - async fn open_status_project() -> StatusProject { let isolation = test_temp_dir(); let project_root = isolation.path().join("project"); diff --git a/crates/tracedecay/tests/mcp_suite/support.rs b/crates/tracedecay/tests/mcp_suite/support.rs index 8ba30526f3..e59a8227f6 100644 --- a/crates/tracedecay/tests/mcp_suite/support.rs +++ b/crates/tracedecay/tests/mcp_suite/support.rs @@ -13,8 +13,6 @@ use std::fs; use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; #[cfg(feature = "test-transport")] -use std::process::Command; -#[cfg(feature = "test-transport")] use std::sync::Arc; #[cfg(feature = "test-transport")] use std::sync::atomic::{AtomicU64, Ordering}; @@ -299,11 +297,16 @@ pub(crate) async fn wait_for_code_index_generation(server: &McpServer, query: &s last_search = serde_json::from_str(extract_real_server_text(&result)).expect("search payload JSON"); let incomplete = common::incomplete_code_index_query_lanes(&last_search); + // `status = current` and complete lanes prove the generation, but the + // seat can still owe its source proof to a continuation pass, and a + // read taken before that pass binds it reports `verifying`. A settled + // seat answers `fresh`; take the first search that reports it. if freshness["status"] == "current" && last_search["reason"].as_str() != Some("authority_unavailable") && last_search["code_generation"].as_str() == status_generation && status_generation.is_some() && incomplete.is_empty() + && last_search["freshness"] == json!({ "state": "fresh" }) { return; } @@ -311,7 +314,7 @@ pub(crate) async fn wait_for_code_index_generation(server: &McpServer, query: &s } let incomplete = common::incomplete_code_index_query_lanes(&last_search); panic!( - "code-index search did not complete lane coverage within the polling budget: incomplete lanes={incomplete:?}; status={last_status}; search={last_search}" + "code-index search did not settle within the polling budget: incomplete lanes={incomplete:?}; status={last_status}; search={last_search}" ); } @@ -371,6 +374,14 @@ pub(crate) struct ProductionCompositionFixture { _isolation: TestTempDir, } +/// `git init`, stage everything, and commit. Identity, hooks, and gc come +/// from the shared fixture git config so each suite does not fork its own. +pub(crate) fn commit_worktree(project: &Path, message: &str) { + crate::common::fixture::git_run(project, &["init", "-q"]); + crate::common::fixture::git_run(project, &["add", "."]); + crate::common::fixture::git_run(project, &["commit", "-qm", message]); +} + #[cfg(feature = "test-transport")] pub(crate) async fn production_composition_fixture() -> ProductionCompositionFixture { production_composition_fixture_with_sources(fixture::write_indexed_fixture_sources).await @@ -387,32 +398,7 @@ pub(crate) async fn production_composition_fixture_with_sources( let project_root = isolation.path().join("project"); fs::create_dir_all(&project_root).expect("production composition project"); write_sources(&project_root); - let init = Command::new(common::git_program()) - .args(["init", "-q"]) - .current_dir(&project_root) - .status() - .expect("git init"); - assert!(init.success(), "git init must succeed"); - let add = Command::new(common::git_program()) - .args(["add", "."]) - .current_dir(&project_root) - .status() - .expect("git add"); - assert!(add.success(), "git add must succeed"); - let commit = Command::new(common::git_program()) - .args([ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay@example.invalid", - "commit", - "-qm", - "production composition fixture", - ]) - .current_dir(&project_root) - .status() - .expect("git commit"); - assert!(commit.success(), "git commit must succeed"); + commit_worktree(&project_root, "production composition fixture"); let harness = Box::pin(ProductionProjectCompositionHarnessV1::open( isolation.path(), vec![project_root.clone()], @@ -437,49 +423,21 @@ pub(crate) struct ProductionSourceEditFixture { #[cfg(feature = "test-transport")] pub(crate) async fn init_production_source_edit_project( project_root: &Path, -) -> (ProductionSourceEditFixture, ()) { +) -> ProductionSourceEditFixture { let isolation_root = project_root .parent() .expect("source-edit project has an isolation parent"); - let init = Command::new(common::git_program()) - .args(["init", "-q"]) - .current_dir(project_root) - .status() - .expect("git init source-edit fixture"); - assert!(init.success(), "git init must succeed"); - let add = Command::new(common::git_program()) - .args(["add", "."]) - .current_dir(project_root) - .status() - .expect("git add source-edit fixture"); - assert!(add.success(), "git add must succeed"); - let commit = Command::new(common::git_program()) - .args([ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay@example.invalid", - "commit", - "-qm", - "source edit fixture", - ]) - .current_dir(project_root) - .status() - .expect("git commit source-edit fixture"); - assert!(commit.success(), "git commit must succeed"); + commit_worktree(project_root, "source edit fixture"); let harness = Box::pin(ProductionProjectCompositionHarnessV1::open( isolation_root, [project_root.to_path_buf()], )) .await .expect("production source-edit composition"); - ( - ProductionSourceEditFixture { - harness, - project_root: project_root.to_path_buf(), - }, - (), - ) + ProductionSourceEditFixture { + harness, + project_root: project_root.to_path_buf(), + } } #[cfg(feature = "test-transport")] @@ -1035,9 +993,6 @@ pub(crate) fn canonicalize_test_db_path(path: &Path) -> PathBuf { ) } -// --------------------------------------------------------------------------- -// Shared setup -// --------------------------------------------------------------------------- pub(crate) struct TestTempDir { pub(crate) dir: Option, } @@ -1838,11 +1793,6 @@ pub(crate) async fn persist_temporal_lcm_observation_with_access( } } -#[cfg(feature = "test-transport")] -pub(crate) async fn project_lcm_conn(cg: &TraceDecay) -> Arc { - open_active_project_session_db(cg).await -} - #[cfg(feature = "test-transport")] pub(crate) async fn lcm_raw_store_id(cg: &TraceDecay, message_id: &str) -> i64 { lcm_raw_store_id_for_provider(cg, "cursor", message_id).await @@ -1854,7 +1804,7 @@ pub(crate) async fn lcm_raw_store_id_for_provider( provider: &str, message_id: &str, ) -> i64 { - project_lcm_conn(cg) + open_active_project_session_db(cg) .await .lcm_load_raw_message_for_test(provider, message_id) .await diff --git a/crates/tracedecay/tests/product_surface_suite/api_application_parity.rs b/crates/tracedecay/tests/product_surface_suite/api_application_parity.rs index 0e428fbfab..c7885901ce 100644 --- a/crates/tracedecay/tests/product_surface_suite/api_application_parity.rs +++ b/crates/tracedecay/tests/product_surface_suite/api_application_parity.rs @@ -26,8 +26,8 @@ use tracedecay_daemon_service::application_surface::{ use tracedecay_domain::{ GitCommitIdentityV1, GitCoverageV1, GitDiffScopeV1, GitHeadStateV1, GitIndexCommitIntentV1, GitIndexPreviewDispositionV1, GitIndexPreviewId, GitIndexPreviewV1, GitIndexSigningPolicyV1, - GitIndexTransactionOperationV1, GitObjectFormatV1, GitOidV1, ManifestDigest, ProjectId, - RepositoryId, RepositoryIndexSnapshotV1, RepositoryIndexStateV1, RepositoryStateSnapshotV1, + GitIndexTransactionOperationV1, GitObjectFormatV1, GitOidV1, ProjectId, RepositoryId, + RepositoryIndexSnapshotV1, RepositoryIndexStateV1, RepositoryStateSnapshotV1, RepositoryWorkingTreeSnapshotV1, RepositoryWorkingTreeStateV1, UtcMicros, WorktreeId, }; use tracedecay_mcp::tools::dispatch::resolve_mcp_application_surface_dispatch; @@ -803,17 +803,9 @@ fn git_requests() -> (ApplicationSurfaceRequest, ApplicationSurfaceRequest) { ) } -fn id(value: &str) -> T -where - T: TryFrom, - >::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).expect("fixture identity") -} +use tracedecay_domain::test_fixtures::id; -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("fixture digest") -} +use tracedecay_domain::test_fixtures::digest; fn oid(byte: char) -> GitOidV1 { GitOidV1::new(byte.to_string().repeat(40)).expect("fixture object id") diff --git a/crates/tracedecay/tests/runtime_acceptance_suite/daemon_runtime_acceptance.rs b/crates/tracedecay/tests/runtime_acceptance_suite/daemon_runtime_acceptance.rs index d1d00a45d8..5cd9274f40 100644 --- a/crates/tracedecay/tests/runtime_acceptance_suite/daemon_runtime_acceptance.rs +++ b/crates/tracedecay/tests/runtime_acceptance_suite/daemon_runtime_acceptance.rs @@ -24,8 +24,7 @@ use tracedecay_contracts::{ }; use tracedecay_domain::feedback::{FeedbackContentIdentityV1, FeedbackScopeV1}; use tracedecay_domain::{ - CodeGenerationId, ComponentVersion, ManifestDigest, RefId, RetrievalAnchorId, TemporalModeV1, - UtcMicros, + CodeGenerationId, ComponentVersion, RefId, RetrievalAnchorId, TemporalModeV1, UtcMicros, }; use tracedecay_hooks::{ HookConfigurationFileReaderV1, HookConfigurationReadOutcomeV1, HookConfigurationSubscriberV1, @@ -36,17 +35,9 @@ use tracedecay_tool_catalog::BindingSurface; use crate::common; -fn id(value: &str) -> T -where - T: TryFrom, - T::Error: std::fmt::Debug, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; -fn digest(character: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", character.to_string().repeat(64))).unwrap() -} +use tracedecay_domain::test_fixtures::digest; fn scout_evidence(now: UtcMicros) -> ContextScoutEvidenceEnvelopeV1 { let scope = ResolvedScope::new( diff --git a/crates/tracedecay/tests/runtime_acceptance_suite/runtime_surface_acceptance.rs b/crates/tracedecay/tests/runtime_acceptance_suite/runtime_surface_acceptance.rs index 4e2fb7c1df..036556beae 100644 --- a/crates/tracedecay/tests/runtime_acceptance_suite/runtime_surface_acceptance.rs +++ b/crates/tracedecay/tests/runtime_acceptance_suite/runtime_surface_acceptance.rs @@ -58,8 +58,7 @@ use tracedecay_domain::configuration::{ AuthorityRef, ConfigurationRevisionId, ScopeSourceBinding, SourceBindingId, SourceKindV1, }; use tracedecay_domain::{ - ActorId, CommitId, LocatorDigest, ManifestDigest, ProjectId, RefId, RepositoryId, UtcMicros, - WorktreeId, + ActorId, CommitId, LocatorDigest, ProjectId, RefId, RepositoryId, UtcMicros, WorktreeId, }; #[cfg(all(unix, feature = "test-transport"))] use tracedecay_domain::{ @@ -3641,6 +3640,4 @@ fn cancelled_receipt(context: &RequestContext) -> OperationReceipt { receipt } -fn digest(byte: char) -> ManifestDigest { - ManifestDigest::new(format!("sha256:{}", byte.to_string().repeat(64))).expect("manifest digest") -} +use tracedecay_domain::test_fixtures::digest; diff --git a/crates/tracedecay/tests/session_suite/anchor_tombstone_expiry.rs b/crates/tracedecay/tests/session_suite/anchor_tombstone_expiry.rs index 08a64f868c..9999341391 100644 --- a/crates/tracedecay/tests/session_suite/anchor_tombstone_expiry.rs +++ b/crates/tracedecay/tests/session_suite/anchor_tombstone_expiry.rs @@ -7,12 +7,12 @@ use std::sync::Arc; use tempfile::TempDir; use tracedecay_domain::{ AccessPolicyDigest, AnchorDurabilityClass, AnchorSourceGenerationV2, CapabilityId, - ComponentVersion, Confidence, CoverageReportV1, DomainError, EntityId, EntityKind, EntityRef, - EvidenceClass, FactAssertionKindV1, FactAssertionV1, FactCategoryV1, FactEventId, - FactEvidenceRefV1, FactEvidenceRelationV1, FactId, FactIdentityMaterialV1, - FactIdentitySourceV1, FactLineageEventKindV1, FactLineageEventV1, FactOwnerV1, FactPayloadV1, - ObservationScopeV1, PayloadAccessState, PayloadReferenceV1, PrivacyDomainBoundLocatorDigest, - PrivacyDomainId, ProjectId, ProjectionGenerationId, ResolutionAuthorizationV1, RetentionClass, + ComponentVersion, Confidence, CoverageReportV1, EntityId, EntityKind, EntityRef, EvidenceClass, + FactAssertionKindV1, FactAssertionV1, FactCategoryV1, FactEventId, FactEvidenceRefV1, + FactEvidenceRelationV1, FactId, FactIdentityMaterialV1, FactIdentitySourceV1, + FactLineageEventKindV1, FactLineageEventV1, FactOwnerV1, FactPayloadV1, ObservationScopeV1, + PayloadAccessState, PayloadReferenceV1, PrivacyDomainBoundLocatorDigest, PrivacyDomainId, + ProjectId, ProjectionGenerationId, ResolutionAuthorizationV1, RetentionClass, RetrievalAnchorId, RetrievalAnchorRecordV2, RetrievalAnchorRecordV2Parts, RetrievalAnchorTargetV2, SanitizationReceiptId, SanitizationReceiptRefV1, SanitizationReceiptV1, SanitizerDispositionV1, ScopeResolutionId, SensitivityV1, UtcMicros, @@ -31,12 +31,7 @@ use crate::common::open_graph_db_from_template; const DIGEST_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const DIGEST_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; -fn id(value: &str) -> T -where - T: TryFrom, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn owner() -> FactOwnerV1 { FactOwnerV1::Profile diff --git a/crates/tracedecay/tests/storage_suite/fact_merge_hydration_test.rs b/crates/tracedecay/tests/storage_suite/fact_merge_hydration_test.rs index 6c1dcd4f58..b3b3793c29 100644 --- a/crates/tracedecay/tests/storage_suite/fact_merge_hydration_test.rs +++ b/crates/tracedecay/tests/storage_suite/fact_merge_hydration_test.rs @@ -55,12 +55,7 @@ async fn setup_db() -> TestDb { TestDb { db, _dir: dir } } -fn id(value: &str) -> T -where - T: TryFrom, -{ - T::try_from(value.to_owned()).unwrap() -} +use tracedecay_domain::test_fixtures::id; fn sqlite_text_literal(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) diff --git a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs index 6e741f8222..8af6721fad 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs @@ -23,6 +23,8 @@ use serde_json::{Value, json}; use tracedecay::daemon::ProductionProjectCompositionHarnessV1; use tracedecay_mcp::JsonRpcResponse; +use crate::common::mcp_response::tool_json; + const RECEIPT_TIMEOUT: Duration = Duration::from_secs(90); /// Pause between status polls while the daemon reconciles. @@ -144,7 +146,7 @@ async fn tool( } async fn status(harness: &ProductionProjectCompositionHarnessV1, project: &Path) -> Value { - tool( + tool_json( harness, project, "tracedecay_status", @@ -336,7 +338,7 @@ async fn background_refresh_and_reopen_report_only_servable_generations_inner() install_background_batch(isolation.path(), &project); commit_all(&project, "install background refresh batch"); let refreshed_revision = head(&project); - let receipt = tool( + let receipt = tool_json( &harness, &project, "tracedecay_admin_sync", diff --git a/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs b/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs index 7b872770f3..38163ea872 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/v2_surface_mount_conformance.rs @@ -39,6 +39,7 @@ //! product call. use crate::common; +use crate::common::run_ok; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -259,20 +260,6 @@ fn isolated_command(home: &Path) -> Command { command } -fn run_ok(command: &mut Command, label: &str) -> Vec { - let output = command - .output() - .unwrap_or_else(|error| panic!("{label} could not run: {error}")); - assert!( - output.status.success(), - "{label} failed with {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - output.stdout -} - fn wait_for_http_authority(path: &Path) -> Value { common::poll_until( Instant::now() + Duration::from_secs(90), diff --git a/docs/V2-OPERATING-MODEL.md b/docs/V2-OPERATING-MODEL.md index fab26882ec..5e72fed7a8 100644 --- a/docs/V2-OPERATING-MODEL.md +++ b/docs/V2-OPERATING-MODEL.md @@ -4,9 +4,8 @@ This is a concise operator and contributor summary of final-V2 storage, scope, host ingestion, and retrieval. The [V2 roadmap](plans/tracedecay-v2/00-plan-set-index.md) is the sole authority for precedence, rejected mechanisms, delivery order, and acceptance; its numbered -plans own detailed behavior, and -[`NEXT.md`](plans/tracedecay-v2/NEXT.md) reports current delivery status. -Runtime status remains the truth for capabilities not yet delivered. +plans own detailed behavior. Runtime status remains the truth for capabilities +not yet delivered. ## Authorities diff --git a/docs/plans/2026-02-26-codegraph-rust-implementation-plan.md b/docs/plans/2026-02-26-codegraph-rust-implementation-plan.md deleted file mode 100644 index b4e6740c44..0000000000 --- a/docs/plans/2026-02-26-codegraph-rust-implementation-plan.md +++ /dev/null @@ -1,3308 +0,0 @@ -# CodeGraph Rust port. Implementation plan - -> **Archived record, not implementation authority.** This document preserves -> historical intent and evidence. Current requirements come only from the -> `docs/plans/tracedecay-v2/` hierarchy. Exact tests and counts, source-string -> checks, branch/commit/worktree choreography, snapshots, receipts, -> attestations, PR packets, and gate matrices below are not rebuild -> instructions; validate current parser, runtime, and product behavior directly. - -**Goal:** Port CodeGraph from TypeScript to Rust as a single-binary code intelligence tool for Rust codebases. - -**Architecture:** Single crate with module-based structure. SQLite for storage (rusqlite), tree-sitter-rust for AST parsing, ort for ONNX embeddings, clap for CLI, tokio for async MCP server. All data flows through a central `CodeGraph` orchestrator. - -**Tech Stack:** Rust 2021, rusqlite (bundled), tree-sitter + tree-sitter-rust, ort, clap, serde/serde_json, tokio, thiserror, tracing, sha2 - ---- - -## Task 1: Project Scaffold & Core Types - -**Files:** -- Create: `Cargo.toml` -- Create: `src/lib.rs` -- Create: `src/main.rs` -- Create: `src/types.rs` -- Create: `src/errors.rs` -- Test: `tests/types_test.rs` - -**Step 1: Initialize Cargo project** - -```bash -cd /Users/enzolombardi/Code/code-graph -cargo init --name codegraph -``` - -**Step 2: Set up Cargo.toml with all dependencies** - -```toml -[package] -name = "codegraph" -version = "0.1.0" -edition = "2021" -description = "Code intelligence tool that builds a semantic knowledge graph from Rust codebases" - -[dependencies] -rusqlite = { version = "0.31", features = ["bundled", "vtab"] } -tree-sitter = "0.24" -tree-sitter-rust = "0.23" -ort = { version = "2", features = ["load-dynamic"] } -ndarray = "0.16" -clap = { version = "4", features = ["derive"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["full"] } -thiserror = "2" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -sha2 = "0.10" -glob = "0.3" -walkdir = "2" - -[dev-dependencies] -tempfile = "3" -``` - -**Step 3: Write the failing test for core types** - -Create `tests/types_test.rs`: - -```rust -use codegraph::types::*; - -#[test] -fn test_node_kind_display() { - assert_eq!(NodeKind::Function.as_str(), "function"); - assert_eq!(NodeKind::Struct.as_str(), "struct"); - assert_eq!(NodeKind::Impl.as_str(), "impl"); - assert_eq!(NodeKind::Use.as_str(), "use"); -} - -#[test] -fn test_edge_kind_display() { - assert_eq!(EdgeKind::Contains.as_str(), "contains"); - assert_eq!(EdgeKind::Calls.as_str(), "calls"); - assert_eq!(EdgeKind::Implements.as_str(), "implements"); -} - -#[test] -fn test_node_kind_from_str() { - assert_eq!(NodeKind::from_str("function"), Some(NodeKind::Function)); - assert_eq!(NodeKind::from_str("struct"), Some(NodeKind::Struct)); - assert_eq!(NodeKind::from_str("bogus"), None); -} - -#[test] -fn test_edge_kind_from_str() { - assert_eq!(EdgeKind::from_str("calls"), Some(EdgeKind::Calls)); - assert_eq!(EdgeKind::from_str("contains"), Some(EdgeKind::Contains)); - assert_eq!(EdgeKind::from_str("bogus"), None); -} - -#[test] -fn test_visibility_default() { - assert_eq!(Visibility::default(), Visibility::Private); -} - -#[test] -fn test_node_id_generation_is_deterministic() { - let id1 = generate_node_id("src/main.rs", NodeKind::Function, "main", 1); - let id2 = generate_node_id("src/main.rs", NodeKind::Function, "main", 1); - assert_eq!(id1, id2); - - let id3 = generate_node_id("src/main.rs", NodeKind::Function, "other", 1); - assert_ne!(id1, id3); -} - -#[test] -fn test_node_id_format() { - let id = generate_node_id("src/main.rs", NodeKind::Function, "main", 1); - assert!(id.starts_with("function:")); - assert_eq!(id.len(), "function:".len() + 32); // kind: + 32-char hash -} - -#[test] -fn test_node_serde_roundtrip() { - let node = Node { - id: "function:abc123".to_string(), - kind: NodeKind::Function, - name: "main".to_string(), - qualified_name: "src/main.rs::main".to_string(), - file_path: "src/main.rs".to_string(), - start_line: 1, - end_line: 5, - start_column: 0, - end_column: 1, - signature: Some("fn main()".to_string()), - docstring: None, - visibility: Visibility::Private, - is_async: false, - updated_at: 0, - }; - - let json = serde_json::to_string(&node).unwrap(); - let deserialized: Node = serde_json::from_str(&json).unwrap(); - assert_eq!(node.id, deserialized.id); - assert_eq!(node.kind, deserialized.kind); - assert_eq!(node.name, deserialized.name); -} - -#[test] -fn test_edge_serde_roundtrip() { - let edge = Edge { - source: "function:abc".to_string(), - target: "function:def".to_string(), - kind: EdgeKind::Calls, - line: Some(10), - }; - - let json = serde_json::to_string(&edge).unwrap(); - let deserialized: Edge = serde_json::from_str(&json).unwrap(); - assert_eq!(edge.source, deserialized.source); - assert_eq!(edge.kind, deserialized.kind); -} -``` - -**Step 4: Run test to verify it fails** - -```bash -cargo test --test types_test -``` - -Expected: FAIL, module `codegraph::types` not found. - -**Step 5: Implement types.rs** - -Create `src/types.rs`: - -```rust -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -/// The kind of code symbol a node represents. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum NodeKind { - File, - Module, - Struct, - Enum, - EnumVariant, - Trait, - Function, - Method, - Impl, - Const, - Static, - TypeAlias, - Field, - Macro, - Use, -} - -impl NodeKind { - pub fn as_str(&self) -> &'static str { - match self { - Self::File => "file", - Self::Module => "module", - Self::Struct => "struct", - Self::Enum => "enum", - Self::EnumVariant => "enum_variant", - Self::Trait => "trait", - Self::Function => "function", - Self::Method => "method", - Self::Impl => "impl", - Self::Const => "constant", - Self::Static => "static", - Self::TypeAlias => "type_alias", - Self::Field => "field", - Self::Macro => "macro", - Self::Use => "use", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "file" => Some(Self::File), - "module" => Some(Self::Module), - "struct" => Some(Self::Struct), - "enum" => Some(Self::Enum), - "enum_variant" => Some(Self::EnumVariant), - "trait" => Some(Self::Trait), - "function" => Some(Self::Function), - "method" => Some(Self::Method), - "impl" => Some(Self::Impl), - "constant" => Some(Self::Const), - "static" => Some(Self::Static), - "type_alias" => Some(Self::TypeAlias), - "field" => Some(Self::Field), - "macro" => Some(Self::Macro), - "use" => Some(Self::Use), - _ => None, - } - } -} - -/// The kind of relationship between two nodes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EdgeKind { - Contains, - Calls, - Uses, - Implements, - TypeOf, - Returns, - DerivesMacro, -} - -impl EdgeKind { - pub fn as_str(&self) -> &'static str { - match self { - Self::Contains => "contains", - Self::Calls => "calls", - Self::Uses => "uses", - Self::Implements => "implements", - Self::TypeOf => "type_of", - Self::Returns => "returns", - Self::DerivesMacro => "derives_macro", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "contains" => Some(Self::Contains), - "calls" => Some(Self::Calls), - "uses" => Some(Self::Uses), - "implements" => Some(Self::Implements), - "type_of" => Some(Self::TypeOf), - "returns" => Some(Self::Returns), - "derives_macro" => Some(Self::DerivesMacro), - _ => None, - } - } -} - -/// Visibility of a code symbol. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum Visibility { - Pub, - PubCrate, - PubSuper, - #[default] - Private, -} - -/// A code symbol extracted from the AST. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Node { - pub id: String, - pub kind: NodeKind, - pub name: String, - pub qualified_name: String, - pub file_path: String, - pub start_line: u32, - pub end_line: u32, - pub start_column: u32, - pub end_column: u32, - pub signature: Option, - pub docstring: Option, - pub visibility: Visibility, - pub is_async: bool, - pub updated_at: i64, -} - -/// A relationship between two nodes. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Edge { - pub source: String, - pub target: String, - pub kind: EdgeKind, - pub line: Option, -} - -/// A tracked file in the project. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FileRecord { - pub path: String, - pub content_hash: String, - pub size: u64, - pub modified_at: i64, - pub indexed_at: i64, - pub node_count: u32, -} - -/// An unresolved reference found during extraction. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UnresolvedRef { - pub from_node_id: String, - pub reference_name: String, - pub reference_kind: EdgeKind, - pub line: u32, - pub column: u32, - pub file_path: String, -} - -/// Result of extracting symbols from a single file. -#[derive(Debug, Clone, Default)] -pub struct ExtractionResult { - pub nodes: Vec, - pub edges: Vec, - pub unresolved_refs: Vec, - pub errors: Vec, - pub duration_ms: u64, -} - -/// A subset of the graph. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Subgraph { - pub nodes: Vec, - pub edges: Vec, - pub roots: Vec, -} - -/// A search result with relevance score. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchResult { - pub node: Node, - pub score: f64, -} - -/// Options for graph traversal. -#[derive(Debug, Clone)] -pub struct TraversalOptions { - pub max_depth: usize, - pub edge_kinds: Vec, - pub node_kinds: Vec, - pub direction: TraversalDirection, - pub limit: usize, - pub include_start: bool, -} - -impl Default for TraversalOptions { - fn default() -> Self { - Self { - max_depth: usize::MAX, - edge_kinds: Vec::new(), - node_kinds: Vec::new(), - direction: TraversalDirection::Outgoing, - limit: usize::MAX, - include_start: true, - } - } -} - -/// Direction of graph traversal. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TraversalDirection { - Outgoing, - Incoming, - Both, -} - -/// Statistics about the graph database. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GraphStats { - pub node_count: usize, - pub edge_count: usize, - pub file_count: usize, - pub nodes_by_kind: Vec<(String, usize)>, - pub edges_by_kind: Vec<(String, usize)>, - pub db_size_bytes: u64, - pub last_updated: i64, -} - -/// Options for building task context. -#[derive(Debug, Clone)] -pub struct BuildContextOptions { - pub max_nodes: usize, - pub max_code_blocks: usize, - pub max_code_block_size: usize, - pub include_code: bool, - pub format: OutputFormat, - pub search_limit: usize, - pub traversal_depth: usize, - pub min_score: f64, -} - -impl Default for BuildContextOptions { - fn default() -> Self { - Self { - max_nodes: 20, - max_code_blocks: 5, - max_code_block_size: 1500, - include_code: true, - format: OutputFormat::Markdown, - search_limit: 3, - traversal_depth: 1, - min_score: 0.3, - } - } -} - -/// Output format for context. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputFormat { - Markdown, - Json, -} - -/// Task context built for an AI query. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskContext { - pub query: String, - pub summary: String, - pub subgraph: Subgraph, - pub entry_points: Vec, - pub code_blocks: Vec, - pub related_files: Vec, -} - -/// A block of source code. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CodeBlock { - pub content: String, - pub file_path: String, - pub start_line: u32, - pub end_line: u32, - pub node_id: Option, -} - -/// Generates a deterministic node ID from its identifying properties. -pub fn generate_node_id(file_path: &str, kind: NodeKind, name: &str, line: u32) -> String { - let input = format!("{}:{}:{}:{}", file_path, kind.as_str(), name, line); - let mut hasher = Sha256::new(); - hasher.update(input.as_bytes()); - let hash = hasher.finalize(); - let hex = hex::encode(hash); - format!("{}:{}", kind.as_str(), &hex[..32]) -} - -/// Result of reference resolution. -#[derive(Debug, Clone, Default)] -pub struct ResolutionResult { - pub resolved: Vec, - pub unresolved: Vec, - pub total: usize, - pub resolved_count: usize, -} - -/// A resolved reference. -#[derive(Debug, Clone)] -pub struct ResolvedRef { - pub original: UnresolvedRef, - pub target_node_id: String, - pub confidence: f64, - pub resolved_by: String, -} -``` - -**Step 6: Implement errors.rs** - -Create `src/errors.rs`: - -```rust -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum CodeGraphError { - #[error("file error: {message} (path: {path})")] - File { message: String, path: String }, - - #[error("parse error: {message} (path: {path}, line: {line:?})")] - Parse { - message: String, - path: String, - line: Option, - }, - - #[error("database error: {message} (operation: {operation})")] - Database { message: String, operation: String }, - - #[error("search error: {message} (query: {query})")] - Search { message: String, query: String }, - - #[error("config error: {message}")] - Config { message: String }, - - #[error("vector error: {message}")] - Vector { message: String }, - - #[error("io error: {0}")] - Io(#[from] std::io::Error), - - #[error("sqlite error: {0}")] - Sqlite(#[from] rusqlite::Error), - - #[error("json error: {0}")] - Json(#[from] serde_json::Error), -} - -pub type Result = std::result::Result; -``` - -**Step 7: Wire up lib.rs** - -Create `src/lib.rs`: - -```rust -pub mod errors; -pub mod types; -``` - -Add `hex` dependency to `Cargo.toml` under `[dependencies]`: - -```toml -hex = "0.4" -``` - -**Step 8: Create minimal main.rs** - -Create `src/main.rs`: - -```rust -fn main() { - println!("codegraph - code intelligence for Rust"); -} -``` - -**Step 9: Run tests to verify they pass** - -```bash -cargo test --test types_test -``` - -Expected: All tests PASS. - -**Step 10: Commit** - -```bash -git init -git add Cargo.toml src/ tests/ -git commit -m "feat: scaffold project with core types and error handling" -``` - ---- - -## Task 2: Configuration Module - -**Files:** -- Create: `src/config.rs` -- Modify: `src/lib.rs` -- Test: `tests/config_test.rs` - -**Step 1: Write the failing test** - -Create `tests/config_test.rs`: - -```rust -use codegraph::config::*; -use tempfile::TempDir; - -#[test] -fn test_default_config_has_rust_patterns() { - let config = CodeGraphConfig::default(); - assert!(config.include.iter().any(|p| p == "**/*.rs")); - assert!(config.exclude.iter().any(|p| p == "target/**")); -} - -#[test] -fn test_save_and_load_config() { - let dir = TempDir::new().unwrap(); - let config = CodeGraphConfig::default(); - save_config(dir.path(), &config).unwrap(); - let loaded = load_config(dir.path()).unwrap(); - assert_eq!(config.version, loaded.version); - assert_eq!(config.include, loaded.include); -} - -#[test] -fn test_should_include_file() { - let config = CodeGraphConfig::default(); - assert!(should_include_file("src/main.rs", &config)); - assert!(!should_include_file("target/debug/foo", &config)); - assert!(!should_include_file("node_modules/foo.rs", &config)); -} - -#[test] -fn test_codegraph_dir_creation() { - let dir = TempDir::new().unwrap(); - let cg_dir = get_codegraph_dir(dir.path()); - assert!(cg_dir.ends_with(".codegraph")); -} - -#[test] -fn test_config_serde_roundtrip() { - let config = CodeGraphConfig::default(); - let json = serde_json::to_string_pretty(&config).unwrap(); - let deserialized: CodeGraphConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(config.version, deserialized.version); - assert_eq!(config.max_file_size, deserialized.max_file_size); -} -``` - -**Step 2: Run test to verify it fails** - -```bash -cargo test --test config_test -``` - -Expected: FAIL, module `codegraph::config` not found. - -**Step 3: Implement config.rs** - -Create `src/config.rs`: - -```rust -use crate::errors::{CodeGraphError, Result}; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; - -const CONFIG_FILENAME: &str = "config.json"; -const CODEGRAPH_DIR: &str = ".codegraph"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CodeGraphConfig { - pub version: u32, - pub root_dir: String, - pub include: Vec, - pub exclude: Vec, - pub max_file_size: u64, - pub extract_docstrings: bool, - pub track_call_sites: bool, - pub enable_embeddings: bool, -} - -impl Default for CodeGraphConfig { - fn default() -> Self { - Self { - version: 1, - root_dir: ".".to_string(), - include: vec!["**/*.rs".to_string()], - exclude: vec![ - "target/**".to_string(), - ".git/**".to_string(), - ".codegraph/**".to_string(), - "node_modules/**".to_string(), - "vendor/**".to_string(), - "**/*.min.*".to_string(), - ], - max_file_size: 1_048_576, - extract_docstrings: true, - track_call_sites: true, - enable_embeddings: false, - } - } -} - -/// Get the path to the .codegraph directory for a project. -pub fn get_codegraph_dir(project_root: &Path) -> PathBuf { - project_root.join(CODEGRAPH_DIR) -} - -/// Get the path to the config file. -pub fn get_config_path(project_root: &Path) -> PathBuf { - get_codegraph_dir(project_root).join(CONFIG_FILENAME) -} - -/// Load configuration from disk, falling back to defaults. -pub fn load_config(project_root: &Path) -> Result { - let config_path = get_config_path(project_root); - if !config_path.exists() { - return Ok(CodeGraphConfig::default()); - } - let contents = std::fs::read_to_string(&config_path).map_err(|e| CodeGraphError::Config { - message: format!("failed to read config: {}", e), - })?; - let config: CodeGraphConfig = - serde_json::from_str(&contents).map_err(|e| CodeGraphError::Config { - message: format!("failed to parse config: {}", e), - })?; - Ok(config) -} - -/// Save configuration to disk atomically. -pub fn save_config(project_root: &Path, config: &CodeGraphConfig) -> Result<()> { - let cg_dir = get_codegraph_dir(project_root); - std::fs::create_dir_all(&cg_dir)?; - - let config_path = get_config_path(project_root); - let tmp_path = config_path.with_extension("json.tmp"); - - let json = - serde_json::to_string_pretty(config).map_err(|e| CodeGraphError::Config { - message: format!("failed to serialize config: {}", e), - })?; - - std::fs::write(&tmp_path, &json)?; - std::fs::rename(&tmp_path, &config_path)?; - Ok(()) -} - -/// Check if a file path should be included based on config patterns. -pub fn should_include_file(file_path: &str, config: &CodeGraphConfig) -> bool { - let path = Path::new(file_path); - - // Check excludes first - for pattern in &config.exclude { - if glob_match(pattern, file_path) { - return false; - } - } - - // Check includes - for pattern in &config.include { - if glob_match(pattern, file_path) { - return true; - } - } - - false -} - -/// Simple glob matching supporting ** and * patterns. -fn glob_match(pattern: &str, path: &str) -> bool { - let glob = glob::Pattern::new(pattern); - match glob { - Ok(g) => g.matches_with( - path, - glob::MatchOptions { - case_sensitive: true, - require_literal_separator: false, - require_literal_leading_dot: false, - }, - ), - Err(_) => false, - } -} -``` - -**Step 4: Add to lib.rs** - -```rust -pub mod config; -pub mod errors; -pub mod types; -``` - -**Step 5: Run tests to verify they pass** - -```bash -cargo test --test config_test -``` - -Expected: All PASS. - -**Step 6: Commit** - -```bash -git add src/config.rs src/lib.rs tests/config_test.rs -git commit -m "feat: add configuration module with glob-based file filtering" -``` - ---- - -## Task 3: SQLite Database Layer - -**Files:** -- Create: `src/db/mod.rs` -- Create: `src/db/connection.rs` -- Create: `src/db/queries.rs` -- Create: `src/db/schema.sql` -- Modify: `src/lib.rs` -- Test: `tests/db_test.rs` - -**Step 1: Write the failing test** - -Create `tests/db_test.rs`: - -```rust -use codegraph::db::*; -use codegraph::types::*; -use tempfile::TempDir; - -#[test] -fn test_initialize_creates_database() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("codegraph.db"); - let conn = Database::initialize(&db_path).unwrap(); - assert!(db_path.exists()); - conn.close(); -} - -#[test] -fn test_insert_and_get_node() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("codegraph.db"); - let db = Database::initialize(&db_path).unwrap(); - - let node = Node { - id: "function:abc123def456abc123def456abc12345".to_string(), - kind: NodeKind::Function, - name: "main".to_string(), - qualified_name: "src/main.rs::main".to_string(), - file_path: "src/main.rs".to_string(), - start_line: 1, - end_line: 5, - start_column: 0, - end_column: 1, - signature: Some("fn main()".to_string()), - docstring: None, - visibility: Visibility::Private, - is_async: false, - updated_at: 1000, - }; - - db.insert_node(&node).unwrap(); - - let fetched = db.get_node_by_id(&node.id).unwrap(); - assert!(fetched.is_some()); - let fetched = fetched.unwrap(); - assert_eq!(fetched.name, "main"); - assert_eq!(fetched.kind, NodeKind::Function); - assert_eq!(fetched.signature, Some("fn main()".to_string())); - - db.close(); -} - -#[test] -fn test_insert_and_get_edge() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("codegraph.db"); - let db = Database::initialize(&db_path).unwrap(); - - let node1 = Node { - id: "function:aaa".to_string(), - kind: NodeKind::Function, - name: "caller".to_string(), - qualified_name: "caller".to_string(), - file_path: "src/lib.rs".to_string(), - start_line: 1, end_line: 5, - start_column: 0, end_column: 1, - signature: None, docstring: None, - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - let node2 = Node { - id: "function:bbb".to_string(), - kind: NodeKind::Function, - name: "callee".to_string(), - qualified_name: "callee".to_string(), - file_path: "src/lib.rs".to_string(), - start_line: 10, end_line: 15, - start_column: 0, end_column: 1, - signature: None, docstring: None, - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - db.insert_node(&node1).unwrap(); - db.insert_node(&node2).unwrap(); - - let edge = Edge { - source: "function:aaa".to_string(), - target: "function:bbb".to_string(), - kind: EdgeKind::Calls, - line: Some(3), - }; - db.insert_edge(&edge).unwrap(); - - let outgoing = db.get_outgoing_edges("function:aaa", &[]).unwrap(); - assert_eq!(outgoing.len(), 1); - assert_eq!(outgoing[0].kind, EdgeKind::Calls); - - let incoming = db.get_incoming_edges("function:bbb", &[]).unwrap(); - assert_eq!(incoming.len(), 1); - assert_eq!(incoming[0].source, "function:aaa"); - - db.close(); -} - -#[test] -fn test_upsert_file() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("codegraph.db"); - let db = Database::initialize(&db_path).unwrap(); - - let file = FileRecord { - path: "src/main.rs".to_string(), - content_hash: "abc123".to_string(), - size: 1024, - modified_at: 1000, - indexed_at: 1001, - node_count: 5, - }; - db.upsert_file(&file).unwrap(); - - let fetched = db.get_file("src/main.rs").unwrap(); - assert!(fetched.is_some()); - assert_eq!(fetched.unwrap().content_hash, "abc123"); - - db.close(); -} - -#[test] -fn test_fts_search() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("codegraph.db"); - let db = Database::initialize(&db_path).unwrap(); - - let node = Node { - id: "function:search_test".to_string(), - kind: NodeKind::Function, - name: "process_request".to_string(), - qualified_name: "server::process_request".to_string(), - file_path: "src/server.rs".to_string(), - start_line: 10, end_line: 20, - start_column: 0, end_column: 1, - signature: Some("fn process_request(req: Request) -> Response".to_string()), - docstring: Some("Processes an incoming HTTP request".to_string()), - visibility: Visibility::Pub, - is_async: true, updated_at: 0, - }; - db.insert_node(&node).unwrap(); - - let results = db.search_nodes("process", 10).unwrap(); - assert!(!results.is_empty()); - assert_eq!(results[0].node.name, "process_request"); - - db.close(); -} - -#[test] -fn test_get_stats() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("codegraph.db"); - let db = Database::initialize(&db_path).unwrap(); - - let node = Node { - id: "function:stats_test".to_string(), - kind: NodeKind::Function, - name: "test_fn".to_string(), - qualified_name: "test_fn".to_string(), - file_path: "src/lib.rs".to_string(), - start_line: 1, end_line: 5, - start_column: 0, end_column: 1, - signature: None, docstring: None, - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - db.insert_node(&node).unwrap(); - - let stats = db.get_stats().unwrap(); - assert_eq!(stats.node_count, 1); - - db.close(); -} - -#[test] -fn test_delete_nodes_by_file() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("codegraph.db"); - let db = Database::initialize(&db_path).unwrap(); - - let node = Node { - id: "function:del_test".to_string(), - kind: NodeKind::Function, - name: "to_delete".to_string(), - qualified_name: "to_delete".to_string(), - file_path: "src/old.rs".to_string(), - start_line: 1, end_line: 5, - start_column: 0, end_column: 1, - signature: None, docstring: None, - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - db.insert_node(&node).unwrap(); - assert!(db.get_node_by_id("function:del_test").unwrap().is_some()); - - db.delete_nodes_by_file("src/old.rs").unwrap(); - assert!(db.get_node_by_id("function:del_test").unwrap().is_none()); - - db.close(); -} - -#[test] -fn test_unresolved_refs() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("codegraph.db"); - let db = Database::initialize(&db_path).unwrap(); - - let uref = UnresolvedRef { - from_node_id: "function:caller".to_string(), - reference_name: "some_fn".to_string(), - reference_kind: EdgeKind::Calls, - line: 10, - column: 4, - file_path: "src/lib.rs".to_string(), - }; - db.insert_unresolved_ref(&uref).unwrap(); - - let refs = db.get_unresolved_refs().unwrap(); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].reference_name, "some_fn"); - - db.close(); -} -``` - -**Step 2: Run test to verify it fails** - -```bash -cargo test --test db_test -``` - -Expected: FAIL, module `codegraph::db` not found. - -**Step 3: Create the SQL schema** - -Create `src/db/schema.sql`: - -```sql --- Schema version tracking -CREATE TABLE IF NOT EXISTS schema_versions ( - version INTEGER PRIMARY KEY, - applied_at INTEGER NOT NULL, - description TEXT -); - --- Code symbols -CREATE TABLE IF NOT EXISTS nodes ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - name TEXT NOT NULL, - qualified_name TEXT NOT NULL, - file_path TEXT NOT NULL, - start_line INTEGER NOT NULL, - end_line INTEGER NOT NULL, - start_column INTEGER NOT NULL, - end_column INTEGER NOT NULL, - docstring TEXT, - signature TEXT, - visibility TEXT NOT NULL DEFAULT 'private', - is_async INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL -); - --- Relationships between nodes -CREATE TABLE IF NOT EXISTS edges ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source TEXT NOT NULL, - target TEXT NOT NULL, - kind TEXT NOT NULL, - line INTEGER, - FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE, - FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE -); - --- Tracked files -CREATE TABLE IF NOT EXISTS files ( - path TEXT PRIMARY KEY, - content_hash TEXT NOT NULL, - size INTEGER NOT NULL, - modified_at INTEGER NOT NULL, - indexed_at INTEGER NOT NULL, - node_count INTEGER NOT NULL DEFAULT 0 -); - --- Pending reference resolution -CREATE TABLE IF NOT EXISTS unresolved_refs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_node_id TEXT NOT NULL, - reference_name TEXT NOT NULL, - reference_kind TEXT NOT NULL, - line INTEGER NOT NULL, - col INTEGER NOT NULL, - file_path TEXT NOT NULL, - FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE -); - --- Embedding vectors -CREATE TABLE IF NOT EXISTS vectors ( - node_id TEXT PRIMARY KEY, - embedding BLOB NOT NULL, - model TEXT NOT NULL, - created_at INTEGER NOT NULL, - FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE -); - --- Full-text search on nodes -CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5( - name, qualified_name, docstring, signature, - content='nodes', - content_rowid='rowid' -); - --- Triggers to keep FTS in sync -CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN - INSERT INTO nodes_fts(rowid, name, qualified_name, docstring, signature) - VALUES (new.rowid, new.name, new.qualified_name, new.docstring, new.signature); -END; - -CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN - INSERT INTO nodes_fts(nodes_fts, rowid, name, qualified_name, docstring, signature) - VALUES ('delete', old.rowid, old.name, old.qualified_name, old.docstring, old.signature); -END; - -CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN - INSERT INTO nodes_fts(nodes_fts, rowid, name, qualified_name, docstring, signature) - VALUES ('delete', old.rowid, old.name, old.qualified_name, old.docstring, old.signature); - INSERT INTO nodes_fts(rowid, name, qualified_name, docstring, signature) - VALUES (new.rowid, new.name, new.qualified_name, new.docstring, new.signature); -END; - --- Indexes -CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind); -CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name); -CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name); -CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path); -CREATE INDEX IF NOT EXISTS idx_nodes_file_line ON nodes(file_path, start_line); - -CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source); -CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target); -CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind); -CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source, kind); -CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target, kind); - -CREATE INDEX IF NOT EXISTS idx_unresolved_from ON unresolved_refs(from_node_id); -CREATE INDEX IF NOT EXISTS idx_unresolved_name ON unresolved_refs(reference_name); -CREATE INDEX IF NOT EXISTS idx_unresolved_file ON unresolved_refs(file_path); - --- Record initial schema version -INSERT OR IGNORE INTO schema_versions (version, applied_at, description) -VALUES (1, strftime('%s', 'now'), 'Initial schema'); -``` - -**Step 4: Implement connection.rs and queries** - -Create `src/db/mod.rs`: - -```rust -mod connection; -mod queries; - -pub use connection::Database; -``` - -Create `src/db/connection.rs`: - -```rust -use crate::errors::{CodeGraphError, Result}; -use rusqlite::Connection; -use std::path::Path; - -pub struct Database { - conn: Connection, -} - -impl Database { - /// Initialize a new database with schema. - pub fn initialize(db_path: &Path) -> Result { - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent)?; - } - - let conn = Connection::open(db_path).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "initialize".to_string(), - })?; - - // Set pragmas for performance - conn.execute_batch( - "PRAGMA journal_mode = WAL; - PRAGMA foreign_keys = ON; - PRAGMA busy_timeout = 120000; - PRAGMA synchronous = NORMAL; - PRAGMA cache_size = -65536; - PRAGMA temp_store = MEMORY; - PRAGMA mmap_size = 268435456;", - ) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "set_pragmas".to_string(), - })?; - - // Apply schema - let schema = include_str!("schema.sql"); - conn.execute_batch(schema).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "apply_schema".to_string(), - })?; - - Ok(Self { conn }) - } - - /// Open an existing database. - pub fn open(db_path: &Path) -> Result { - if !db_path.exists() { - return Err(CodeGraphError::Database { - message: format!("database not found: {}", db_path.display()), - operation: "open".to_string(), - }); - } - Self::initialize(db_path) - } - - /// Get a reference to the underlying connection. - pub(crate) fn conn(&self) -> &Connection { - &self.conn - } - - /// Close the database connection. - pub fn close(self) { - drop(self.conn); - } - - /// Optimize the database (VACUUM + ANALYZE). - pub fn optimize(&self) -> Result<()> { - self.conn - .execute_batch("VACUUM; ANALYZE;") - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "optimize".to_string(), - }) - } - - /// Get database file size in bytes. - pub fn size(&self) -> Result { - let path: String = - self.conn - .query_row("PRAGMA database_list", [], |row| row.get(2)) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_size".to_string(), - })?; - Ok(std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)) - } -} -``` - -Create `src/db/queries.rs`. This is a larger file implementing all query methods on `Database`: - -```rust -use crate::errors::{CodeGraphError, Result}; -use crate::types::*; -use rusqlite::params; - -use super::Database; - -impl Database { - // ── Node Operations ── - - pub fn insert_node(&self, node: &Node) -> Result<()> { - self.conn().execute( - "INSERT OR REPLACE INTO nodes - (id, kind, name, qualified_name, file_path, - start_line, end_line, start_column, end_column, - docstring, signature, visibility, is_async, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - params![ - node.id, - node.kind.as_str(), - node.name, - node.qualified_name, - node.file_path, - node.start_line, - node.end_line, - node.start_column, - node.end_column, - node.docstring, - node.signature, - visibility_to_str(node.visibility), - node.is_async as i32, - node.updated_at, - ], - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "insert_node".to_string(), - })?; - Ok(()) - } - - pub fn insert_nodes(&self, nodes: &[Node]) -> Result<()> { - let tx = self.conn().unchecked_transaction().map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "insert_nodes_tx".to_string(), - })?; - for node in nodes { - self.insert_node(node)?; - } - tx.commit().map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "insert_nodes_commit".to_string(), - })?; - Ok(()) - } - - pub fn get_node_by_id(&self, id: &str) -> Result> { - let mut stmt = self.conn().prepare( - "SELECT id, kind, name, qualified_name, file_path, - start_line, end_line, start_column, end_column, - docstring, signature, visibility, is_async, updated_at - FROM nodes WHERE id = ?1" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_node_by_id".to_string(), - })?; - - let node = stmt.query_row(params![id], row_to_node).optional().map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_node_by_id".to_string(), - })?; - - Ok(node) - } - - pub fn get_nodes_by_file(&self, file_path: &str) -> Result> { - let mut stmt = self.conn().prepare( - "SELECT id, kind, name, qualified_name, file_path, - start_line, end_line, start_column, end_column, - docstring, signature, visibility, is_async, updated_at - FROM nodes WHERE file_path = ?1 ORDER BY start_line" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_nodes_by_file".to_string(), - })?; - - let nodes = stmt.query_map(params![file_path], row_to_node) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_nodes_by_file".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - - Ok(nodes) - } - - pub fn get_nodes_by_kind(&self, kind: NodeKind) -> Result> { - let mut stmt = self.conn().prepare( - "SELECT id, kind, name, qualified_name, file_path, - start_line, end_line, start_column, end_column, - docstring, signature, visibility, is_async, updated_at - FROM nodes WHERE kind = ?1" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_nodes_by_kind".to_string(), - })?; - - let nodes = stmt.query_map(params![kind.as_str()], row_to_node) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_nodes_by_kind".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - - Ok(nodes) - } - - pub fn get_all_nodes(&self) -> Result> { - let mut stmt = self.conn().prepare( - "SELECT id, kind, name, qualified_name, file_path, - start_line, end_line, start_column, end_column, - docstring, signature, visibility, is_async, updated_at - FROM nodes" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_all_nodes".to_string(), - })?; - - let nodes = stmt.query_map([], row_to_node) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_all_nodes".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - - Ok(nodes) - } - - pub fn delete_nodes_by_file(&self, file_path: &str) -> Result<()> { - // Delete edges referencing these nodes first - self.conn().execute( - "DELETE FROM edges WHERE source IN (SELECT id FROM nodes WHERE file_path = ?1) - OR target IN (SELECT id FROM nodes WHERE file_path = ?1)", - params![file_path], - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "delete_edges_by_file".to_string(), - })?; - - // Delete unresolved refs - self.conn().execute( - "DELETE FROM unresolved_refs WHERE from_node_id IN (SELECT id FROM nodes WHERE file_path = ?1)", - params![file_path], - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "delete_unresolved_by_file".to_string(), - })?; - - // Delete vectors - self.conn().execute( - "DELETE FROM vectors WHERE node_id IN (SELECT id FROM nodes WHERE file_path = ?1)", - params![file_path], - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "delete_vectors_by_file".to_string(), - })?; - - // Delete nodes - self.conn().execute( - "DELETE FROM nodes WHERE file_path = ?1", - params![file_path], - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "delete_nodes_by_file".to_string(), - })?; - - Ok(()) - } - - // ── Edge Operations ── - - pub fn insert_edge(&self, edge: &Edge) -> Result<()> { - self.conn().execute( - "INSERT INTO edges (source, target, kind, line) VALUES (?1, ?2, ?3, ?4)", - params![edge.source, edge.target, edge.kind.as_str(), edge.line], - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "insert_edge".to_string(), - })?; - Ok(()) - } - - pub fn insert_edges(&self, edges: &[Edge]) -> Result<()> { - let tx = self.conn().unchecked_transaction().map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "insert_edges_tx".to_string(), - })?; - for edge in edges { - self.insert_edge(edge)?; - } - tx.commit().map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "insert_edges_commit".to_string(), - })?; - Ok(()) - } - - pub fn get_outgoing_edges(&self, source_id: &str, kinds: &[EdgeKind]) -> Result> { - if kinds.is_empty() { - let mut stmt = self.conn().prepare( - "SELECT source, target, kind, line FROM edges WHERE source = ?1" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_outgoing_edges".to_string(), - })?; - - let edges = stmt.query_map(params![source_id], row_to_edge) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_outgoing_edges".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(edges) - } else { - let kind_strs: Vec<&str> = kinds.iter().map(|k| k.as_str()).collect(); - let placeholders: Vec = (0..kind_strs.len()).map(|i| format!("?{}", i + 2)).collect(); - let sql = format!( - "SELECT source, target, kind, line FROM edges WHERE source = ?1 AND kind IN ({})", - placeholders.join(", ") - ); - let mut stmt = self.conn().prepare(&sql).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_outgoing_edges_filtered".to_string(), - })?; - - let mut params_vec: Vec> = vec![Box::new(source_id.to_string())]; - for k in &kind_strs { - params_vec.push(Box::new(k.to_string())); - } - - let edges = stmt.query_map(rusqlite::params_from_iter(params_vec.iter().map(|b| b.as_ref())), row_to_edge) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_outgoing_edges_filtered".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(edges) - } - } - - pub fn get_incoming_edges(&self, target_id: &str, kinds: &[EdgeKind]) -> Result> { - if kinds.is_empty() { - let mut stmt = self.conn().prepare( - "SELECT source, target, kind, line FROM edges WHERE target = ?1" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_incoming_edges".to_string(), - })?; - - let edges = stmt.query_map(params![target_id], row_to_edge) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_incoming_edges".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(edges) - } else { - let kind_strs: Vec<&str> = kinds.iter().map(|k| k.as_str()).collect(); - let placeholders: Vec = (0..kind_strs.len()).map(|i| format!("?{}", i + 2)).collect(); - let sql = format!( - "SELECT source, target, kind, line FROM edges WHERE target = ?1 AND kind IN ({})", - placeholders.join(", ") - ); - let mut stmt = self.conn().prepare(&sql).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_incoming_edges_filtered".to_string(), - })?; - - let mut params_vec: Vec> = vec![Box::new(target_id.to_string())]; - for k in &kind_strs { - params_vec.push(Box::new(k.to_string())); - } - - let edges = stmt.query_map(rusqlite::params_from_iter(params_vec.iter().map(|b| b.as_ref())), row_to_edge) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_incoming_edges_filtered".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(edges) - } - } - - pub fn delete_edges_by_source(&self, source_id: &str) -> Result<()> { - self.conn().execute( - "DELETE FROM edges WHERE source = ?1", - params![source_id], - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "delete_edges_by_source".to_string(), - })?; - Ok(()) - } - - // ── File Operations ── - - pub fn upsert_file(&self, file: &FileRecord) -> Result<()> { - self.conn().execute( - "INSERT OR REPLACE INTO files - (path, content_hash, size, modified_at, indexed_at, node_count) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![file.path, file.content_hash, file.size, file.modified_at, file.indexed_at, file.node_count], - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "upsert_file".to_string(), - })?; - Ok(()) - } - - pub fn get_file(&self, path: &str) -> Result> { - let mut stmt = self.conn().prepare( - "SELECT path, content_hash, size, modified_at, indexed_at, node_count - FROM files WHERE path = ?1" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_file".to_string(), - })?; - - let file = stmt.query_row(params![path], row_to_file).optional().map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_file".to_string(), - })?; - Ok(file) - } - - pub fn get_all_files(&self) -> Result> { - let mut stmt = self.conn().prepare( - "SELECT path, content_hash, size, modified_at, indexed_at, node_count FROM files" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_all_files".to_string(), - })?; - - let files = stmt.query_map([], row_to_file) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_all_files".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(files) - } - - pub fn delete_file(&self, path: &str) -> Result<()> { - self.delete_nodes_by_file(path)?; - self.conn().execute("DELETE FROM files WHERE path = ?1", params![path]) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "delete_file".to_string(), - })?; - Ok(()) - } - - // ── Unresolved References ── - - pub fn insert_unresolved_ref(&self, uref: &UnresolvedRef) -> Result<()> { - self.conn().execute( - "INSERT INTO unresolved_refs - (from_node_id, reference_name, reference_kind, line, col, file_path) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - uref.from_node_id, - uref.reference_name, - uref.reference_kind.as_str(), - uref.line, - uref.column, - uref.file_path, - ], - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "insert_unresolved_ref".to_string(), - })?; - Ok(()) - } - - pub fn insert_unresolved_refs(&self, refs: &[UnresolvedRef]) -> Result<()> { - let tx = self.conn().unchecked_transaction().map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "insert_unresolved_refs_tx".to_string(), - })?; - for uref in refs { - self.insert_unresolved_ref(uref)?; - } - tx.commit().map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "insert_unresolved_refs_commit".to_string(), - })?; - Ok(()) - } - - pub fn get_unresolved_refs(&self) -> Result> { - let mut stmt = self.conn().prepare( - "SELECT from_node_id, reference_name, reference_kind, line, col, file_path - FROM unresolved_refs" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_unresolved_refs".to_string(), - })?; - - let refs = stmt.query_map([], row_to_unresolved_ref) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_unresolved_refs".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(refs) - } - - pub fn clear_unresolved_refs(&self) -> Result<()> { - self.conn().execute("DELETE FROM unresolved_refs", []) - .map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "clear_unresolved_refs".to_string(), - })?; - Ok(()) - } - - // ── Search ── - - pub fn search_nodes(&self, query: &str, limit: usize) -> Result> { - // Try FTS5 first - let fts_query = format!("{}*", query); - let mut stmt = self.conn().prepare( - "SELECT n.id, n.kind, n.name, n.qualified_name, n.file_path, - n.start_line, n.end_line, n.start_column, n.end_column, - n.docstring, n.signature, n.visibility, n.is_async, n.updated_at, - rank - FROM nodes_fts fts - JOIN nodes n ON n.rowid = fts.rowid - WHERE nodes_fts MATCH ?1 - ORDER BY rank - LIMIT ?2" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "search_nodes_fts".to_string(), - })?; - - let results: Vec = stmt.query_map(params![fts_query, limit as i64], |row| { - let node = row_to_node(row)?; - let rank: f64 = row.get(14)?; - Ok(SearchResult { node, score: -rank }) // FTS5 rank is negative - }).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "search_nodes_fts".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - - if !results.is_empty() { - return Ok(results); - } - - // Fall back to LIKE search - let like_query = format!("%{}%", query); - let mut stmt = self.conn().prepare( - "SELECT id, kind, name, qualified_name, file_path, - start_line, end_line, start_column, end_column, - docstring, signature, visibility, is_async, updated_at - FROM nodes - WHERE name LIKE ?1 OR qualified_name LIKE ?1 - LIMIT ?2" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "search_nodes_like".to_string(), - })?; - - let results = stmt.query_map(params![like_query, limit as i64], |row| { - let node = row_to_node(row)?; - Ok(SearchResult { node, score: 0.5 }) - }).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "search_nodes_like".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - - Ok(results) - } - - // ── Statistics ── - - pub fn get_stats(&self) -> Result { - let node_count: usize = self.conn() - .query_row("SELECT COUNT(*) FROM nodes", [], |row| row.get(0)) - .unwrap_or(0); - - let edge_count: usize = self.conn() - .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0)) - .unwrap_or(0); - - let file_count: usize = self.conn() - .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0)) - .unwrap_or(0); - - let mut stmt = self.conn().prepare( - "SELECT kind, COUNT(*) FROM nodes GROUP BY kind ORDER BY COUNT(*) DESC" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_stats_nodes_by_kind".to_string(), - })?; - - let nodes_by_kind: Vec<(String, usize)> = stmt.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?)) - }).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_stats_nodes_by_kind".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - - let mut stmt = self.conn().prepare( - "SELECT kind, COUNT(*) FROM edges GROUP BY kind ORDER BY COUNT(*) DESC" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_stats_edges_by_kind".to_string(), - })?; - - let edges_by_kind: Vec<(String, usize)> = stmt.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?)) - }).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "get_stats_edges_by_kind".to_string(), - })? - .filter_map(|r| r.ok()) - .collect(); - - let db_size_bytes = self.size().unwrap_or(0); - - Ok(GraphStats { - node_count, - edge_count, - file_count, - nodes_by_kind, - edges_by_kind, - db_size_bytes, - last_updated: 0, - }) - } - - // ── Clear ── - - pub fn clear(&self) -> Result<()> { - self.conn().execute_batch( - "DELETE FROM vectors; - DELETE FROM unresolved_refs; - DELETE FROM edges; - DELETE FROM nodes; - DELETE FROM files;" - ).map_err(|e| CodeGraphError::Database { - message: e.to_string(), - operation: "clear".to_string(), - })?; - Ok(()) - } -} - -// ── Row Conversion Functions ── - -fn row_to_node(row: &rusqlite::Row) -> rusqlite::Result { - Ok(Node { - id: row.get(0)?, - kind: NodeKind::from_str(&row.get::<_, String>(1)?).unwrap_or(NodeKind::Function), - name: row.get(2)?, - qualified_name: row.get(3)?, - file_path: row.get(4)?, - start_line: row.get(5)?, - end_line: row.get(6)?, - start_column: row.get(7)?, - end_column: row.get(8)?, - docstring: row.get(9)?, - signature: row.get(10)?, - visibility: visibility_from_str(&row.get::<_, String>(11)?), - is_async: row.get::<_, i32>(12)? != 0, - updated_at: row.get(13)?, - }) -} - -fn row_to_edge(row: &rusqlite::Row) -> rusqlite::Result { - Ok(Edge { - source: row.get(0)?, - target: row.get(1)?, - kind: EdgeKind::from_str(&row.get::<_, String>(2)?).unwrap_or(EdgeKind::Contains), - line: row.get(3)?, - }) -} - -fn row_to_file(row: &rusqlite::Row) -> rusqlite::Result { - Ok(FileRecord { - path: row.get(0)?, - content_hash: row.get(1)?, - size: row.get(2)?, - modified_at: row.get(3)?, - indexed_at: row.get(4)?, - node_count: row.get(5)?, - }) -} - -fn row_to_unresolved_ref(row: &rusqlite::Row) -> rusqlite::Result { - Ok(UnresolvedRef { - from_node_id: row.get(0)?, - reference_name: row.get(1)?, - reference_kind: EdgeKind::from_str(&row.get::<_, String>(2)?).unwrap_or(EdgeKind::Calls), - line: row.get(3)?, - column: row.get(4)?, - file_path: row.get(5)?, - }) -} - -fn visibility_to_str(v: Visibility) -> &'static str { - match v { - Visibility::Pub => "public", - Visibility::PubCrate => "pub_crate", - Visibility::PubSuper => "pub_super", - Visibility::Private => "private", - } -} - -fn visibility_from_str(s: &str) -> Visibility { - match s { - "public" => Visibility::Pub, - "pub_crate" => Visibility::PubCrate, - "pub_super" => Visibility::PubSuper, - _ => Visibility::Private, - } -} - -// Extension trait for optional query results -trait OptionalExt { - fn optional(self) -> rusqlite::Result>; -} - -impl OptionalExt for rusqlite::Result { - fn optional(self) -> rusqlite::Result> { - match self { - Ok(v) => Ok(Some(v)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } -} -``` - -**Step 5: Update lib.rs** - -```rust -pub mod config; -pub mod db; -pub mod errors; -pub mod types; -``` - -**Step 6: Run tests to verify they pass** - -```bash -cargo test --test db_test -``` - -Expected: All PASS. - -**Step 7: Commit** - -```bash -git add src/db/ src/lib.rs tests/db_test.rs -git commit -m "feat: add SQLite database layer with FTS5 search" -``` - ---- - -## Task 4: Tree-Sitter Extraction for Rust - -**Files:** -- Create: `src/extraction/mod.rs` -- Create: `src/extraction/rust_extractor.rs` -- Modify: `src/lib.rs` -- Test: `tests/extraction_test.rs` - -**Step 1: Write the failing test** - -Create `tests/extraction_test.rs`: - -```rust -use codegraph::extraction::RustExtractor; -use codegraph::types::*; - -#[test] -fn test_extract_function() { - let source = r#" -/// Adds two numbers. -pub fn add(a: i32, b: i32) -> i32 { - a + b -} -"#; - let result = RustExtractor::extract("src/math.rs", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - - let functions: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Function).collect(); - assert_eq!(functions.len(), 1); - assert_eq!(functions[0].name, "add"); - assert_eq!(functions[0].visibility, Visibility::Pub); - assert!(functions[0].signature.as_ref().unwrap().contains("fn add")); - assert!(functions[0].docstring.as_ref().unwrap().contains("Adds two numbers")); -} - -#[test] -fn test_extract_struct_with_fields() { - let source = r#" -pub struct Point { - pub x: f64, - pub y: f64, -} -"#; - let result = RustExtractor::extract("src/geo.rs", source); - - let structs: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Struct).collect(); - assert_eq!(structs.len(), 1); - assert_eq!(structs[0].name, "Point"); - - let fields: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Field).collect(); - assert_eq!(fields.len(), 2); - - // Check contains edges - let contains: Vec<_> = result.edges.iter().filter(|e| e.kind == EdgeKind::Contains).collect(); - assert!(contains.len() >= 2); // struct contains fields -} - -#[test] -fn test_extract_enum() { - let source = r#" -pub enum Color { - Red, - Green, - Blue, -} -"#; - let result = RustExtractor::extract("src/color.rs", source); - - let enums: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Enum).collect(); - assert_eq!(enums.len(), 1); - assert_eq!(enums[0].name, "Color"); - - let variants: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::EnumVariant).collect(); - assert_eq!(variants.len(), 3); -} - -#[test] -fn test_extract_trait() { - let source = r#" -pub trait Drawable { - fn draw(&self); - fn area(&self) -> f64; -} -"#; - let result = RustExtractor::extract("src/draw.rs", source); - - let traits: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Trait).collect(); - assert_eq!(traits.len(), 1); - assert_eq!(traits[0].name, "Drawable"); - - let methods: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Method).collect(); - assert_eq!(methods.len(), 2); -} - -#[test] -fn test_extract_impl_block() { - let source = r#" -struct Circle { - radius: f64, -} - -impl Circle { - pub fn new(radius: f64) -> Self { - Circle { radius } - } - - pub fn area(&self) -> f64 { - std::f64::consts::PI * self.radius * self.radius - } -} -"#; - let result = RustExtractor::extract("src/circle.rs", source); - - let impls: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Impl).collect(); - assert_eq!(impls.len(), 1); - - let methods: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Method).collect(); - assert_eq!(methods.len(), 2); -} - -#[test] -fn test_extract_trait_impl() { - let source = r#" -trait Greet { - fn hello(&self) -> String; -} - -struct Person { - name: String, -} - -impl Greet for Person { - fn hello(&self) -> String { - format!("Hello, {}", self.name) - } -} -"#; - let result = RustExtractor::extract("src/greet.rs", source); - - let implements: Vec<_> = result.edges.iter().filter(|e| e.kind == EdgeKind::Implements).collect(); - assert!(!implements.is_empty(), "should have implements edge"); -} - -#[test] -fn test_extract_use_declarations() { - let source = r#" -use std::collections::HashMap; -use crate::types::Node; -"#; - let result = RustExtractor::extract("src/lib.rs", source); - - let uses: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Use).collect(); - assert_eq!(uses.len(), 2); -} - -#[test] -fn test_extract_call_sites() { - let source = r#" -fn helper() -> i32 { - 42 -} - -fn main() { - let x = helper(); - println!("{}", x); -} -"#; - let result = RustExtractor::extract("src/main.rs", source); - - // Should have unresolved call references - assert!(!result.unresolved_refs.is_empty(), "should have unresolved refs for calls"); - let call_refs: Vec<_> = result.unresolved_refs.iter() - .filter(|r| r.reference_kind == EdgeKind::Calls) - .collect(); - assert!(!call_refs.is_empty(), "should have call refs"); -} - -#[test] -fn test_extract_async_function() { - let source = r#" -pub async fn fetch_data(url: &str) -> Result { - Ok("data".to_string()) -} -"#; - let result = RustExtractor::extract("src/http.rs", source); - - let functions: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Function).collect(); - assert_eq!(functions.len(), 1); - assert!(functions[0].is_async); -} - -#[test] -fn test_extract_const_and_static() { - let source = r#" -pub const MAX_SIZE: usize = 1024; -static COUNTER: AtomicU64 = AtomicU64::new(0); -"#; - let result = RustExtractor::extract("src/globals.rs", source); - - let consts: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Const).collect(); - assert_eq!(consts.len(), 1); - assert_eq!(consts[0].name, "MAX_SIZE"); - - let statics: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Static).collect(); - assert_eq!(statics.len(), 1); - assert_eq!(statics[0].name, "COUNTER"); -} - -#[test] -fn test_extract_type_alias() { - let source = r#" -pub type Result = std::result::Result; -"#; - let result = RustExtractor::extract("src/types.rs", source); - - let aliases: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::TypeAlias).collect(); - assert_eq!(aliases.len(), 1); - assert_eq!(aliases[0].name, "Result"); -} - -#[test] -fn test_extract_module() { - let source = r#" -pub mod utils { - pub fn helper() {} -} -"#; - let result = RustExtractor::extract("src/lib.rs", source); - - let modules: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Module).collect(); - assert_eq!(modules.len(), 1); - assert_eq!(modules[0].name, "utils"); -} - -#[test] -fn test_extract_derive_macros() { - let source = r#" -#[derive(Debug, Clone, Serialize)] -pub struct Config { - pub name: String, -} -"#; - let result = RustExtractor::extract("src/config.rs", source); - - let derives: Vec<_> = result.edges.iter().filter(|e| e.kind == EdgeKind::DerivesMacro).collect(); - assert!(!derives.is_empty(), "should have derives_macro edges"); -} - -#[test] -fn test_file_node_is_root() { - let source = "fn main() {}"; - let result = RustExtractor::extract("src/main.rs", source); - - let files: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::File).collect(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].name, "src/main.rs"); -} - -#[test] -fn test_qualified_names() { - let source = r#" -mod server { - pub fn handle_request() {} -} -"#; - let result = RustExtractor::extract("src/lib.rs", source); - - let fns: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Function).collect(); - assert_eq!(fns.len(), 1); - assert!(fns[0].qualified_name.contains("server")); - assert!(fns[0].qualified_name.contains("handle_request")); -} -``` - -**Step 2: Run test to verify it fails** - -```bash -cargo test --test extraction_test -``` - -Expected: FAIL, module `codegraph::extraction` not found. - -**Step 3: Implement the Rust extractor** - -Create `src/extraction/mod.rs`: - -```rust -mod rust_extractor; - -pub use rust_extractor::RustExtractor; -``` - -Create `src/extraction/rust_extractor.rs`. This is the core AST extraction module. It uses `tree-sitter-rust` to parse Rust source and emit nodes and edges. The implementation should: - -1. Parse source with tree-sitter -2. Create a file node as root -3. Walk the AST recursively with `visit_node()` -4. Maintain a `node_stack` for parent context and qualified names -5. For each relevant AST node type, extract a `Node` with metadata -6. Emit `Contains` edges from parent to child automatically -7. Emit unresolved references for call sites, use declarations -8. Extract docstrings from preceding comment nodes -9. Extract signatures from function/method declarations -10. Detect visibility from `visibility_modifier` nodes -11. Detect async functions -12. Extract derive macro attributes - -Key tree-sitter-rust node types to handle: -- `function_item` → Function or Method (if inside impl) -- `struct_item` → Struct -- `enum_item` → Enum -- `enum_variant` → EnumVariant -- `trait_item` → Trait -- `impl_item` → Impl -- `use_declaration` → Use -- `const_item` → Const -- `static_item` → Static -- `type_item` → TypeAlias -- `field_declaration` → Field -- `mod_item` → Module -- `call_expression` → unresolved Calls ref -- `macro_invocation` → unresolved Calls ref -- `attribute_item` with `derive` → DerivesMacro edges - -**Step 4: Update lib.rs** - -```rust -pub mod config; -pub mod db; -pub mod errors; -pub mod extraction; -pub mod types; -``` - -**Step 5: Run tests to verify they pass** - -```bash -cargo test --test extraction_test -``` - -Expected: All PASS. - -**Step 6: Commit** - -```bash -git add src/extraction/ src/lib.rs tests/extraction_test.rs -git commit -m "feat: add tree-sitter Rust extraction with full AST node coverage" -``` - ---- - -## Task 5: Reference Resolution - -**Files:** -- Create: `src/resolution/mod.rs` -- Create: `src/resolution/imports.rs` -- Create: `src/resolution/names.rs` -- Modify: `src/lib.rs` -- Test: `tests/resolution_test.rs` - -**Step 1: Write the failing test** - -Create `tests/resolution_test.rs`: - -```rust -use codegraph::db::Database; -use codegraph::resolution::ReferenceResolver; -use codegraph::types::*; -use tempfile::TempDir; - -fn setup_db_with_nodes() -> (TempDir, Database) { - let dir = TempDir::new().unwrap(); - let db = Database::initialize(&dir.path().join("test.db")).unwrap(); - - // Insert a function that is called - let callee = Node { - id: generate_node_id("src/utils.rs", NodeKind::Function, "helper", 1), - kind: NodeKind::Function, - name: "helper".to_string(), - qualified_name: "src/utils.rs::helper".to_string(), - file_path: "src/utils.rs".to_string(), - start_line: 1, end_line: 5, - start_column: 0, end_column: 1, - signature: Some("fn helper() -> i32".to_string()), - docstring: None, - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - - // Insert the caller - let caller = Node { - id: generate_node_id("src/main.rs", NodeKind::Function, "main", 1), - kind: NodeKind::Function, - name: "main".to_string(), - qualified_name: "src/main.rs::main".to_string(), - file_path: "src/main.rs".to_string(), - start_line: 1, end_line: 5, - start_column: 0, end_column: 1, - signature: Some("fn main()".to_string()), - docstring: None, - visibility: Visibility::Private, - is_async: false, updated_at: 0, - }; - - db.insert_node(&callee).unwrap(); - db.insert_node(&caller).unwrap(); - - (dir, db) -} - -#[test] -fn test_resolve_exact_name_match() { - let (_dir, db) = setup_db_with_nodes(); - let resolver = ReferenceResolver::new(&db); - - let uref = UnresolvedRef { - from_node_id: generate_node_id("src/main.rs", NodeKind::Function, "main", 1), - reference_name: "helper".to_string(), - reference_kind: EdgeKind::Calls, - line: 3, - column: 12, - file_path: "src/main.rs".to_string(), - }; - - let result = resolver.resolve_one(&uref); - assert!(result.is_some(), "should resolve 'helper' by exact name"); - let resolved = result.unwrap(); - assert!(resolved.confidence >= 0.7); -} - -#[test] -fn test_resolve_all() { - let (_dir, db) = setup_db_with_nodes(); - let resolver = ReferenceResolver::new(&db); - - let refs = vec![ - UnresolvedRef { - from_node_id: generate_node_id("src/main.rs", NodeKind::Function, "main", 1), - reference_name: "helper".to_string(), - reference_kind: EdgeKind::Calls, - line: 3, column: 12, - file_path: "src/main.rs".to_string(), - }, - ]; - - let result = resolver.resolve_all(&refs); - assert_eq!(result.total, 1); - assert_eq!(result.resolved_count, 1); - assert_eq!(result.resolved.len(), 1); -} - -#[test] -fn test_unresolvable_reference() { - let (_dir, db) = setup_db_with_nodes(); - let resolver = ReferenceResolver::new(&db); - - let uref = UnresolvedRef { - from_node_id: "function:caller".to_string(), - reference_name: "nonexistent_function".to_string(), - reference_kind: EdgeKind::Calls, - line: 5, column: 8, - file_path: "src/main.rs".to_string(), - }; - - let result = resolver.resolve_one(&uref); - assert!(result.is_none(), "should not resolve nonexistent function"); -} - -#[test] -fn test_creates_edges_from_resolved() { - let (_dir, db) = setup_db_with_nodes(); - let resolver = ReferenceResolver::new(&db); - - let resolved = ResolvedRef { - original: UnresolvedRef { - from_node_id: generate_node_id("src/main.rs", NodeKind::Function, "main", 1), - reference_name: "helper".to_string(), - reference_kind: EdgeKind::Calls, - line: 3, column: 12, - file_path: "src/main.rs".to_string(), - }, - target_node_id: generate_node_id("src/utils.rs", NodeKind::Function, "helper", 1), - confidence: 0.9, - resolved_by: "exact-match".to_string(), - }; - - let edges = resolver.create_edges(&[resolved]); - assert_eq!(edges.len(), 1); - assert_eq!(edges[0].kind, EdgeKind::Calls); - assert_eq!(edges[0].line, Some(3)); -} -``` - -**Step 2: Run test to verify it fails** - -```bash -cargo test --test resolution_test -``` - -**Step 3: Implement resolution module** - -Create `src/resolution/mod.rs`, `src/resolution/imports.rs`, `src/resolution/names.rs`. - -The resolver should: -1. Build in-memory caches from all nodes (by name, qualified name, kind) -2. For each unresolved ref, try strategies in order: - - Exact name match (confidence 0.9 for single match, 0.7 for multiple with scoring) - - Qualified name match (confidence 0.95) - - Use-path resolution (follow `use crate::` paths) -3. Score candidates: same file +100, same module +50, exported +10 -4. Create edges from resolved references - -**Step 4: Update lib.rs** - -```rust -pub mod config; -pub mod db; -pub mod errors; -pub mod extraction; -pub mod resolution; -pub mod types; -``` - -**Step 5: Run tests** - -```bash -cargo test --test resolution_test -``` - -**Step 6: Commit** - -```bash -git add src/resolution/ src/lib.rs tests/resolution_test.rs -git commit -m "feat: add reference resolution with name and import-based matching" -``` - ---- - -## Task 6: Graph Traversal & Queries - -**Files:** -- Create: `src/graph/mod.rs` -- Create: `src/graph/traversal.rs` -- Create: `src/graph/queries.rs` -- Modify: `src/lib.rs` -- Test: `tests/graph_test.rs` - -**Step 1: Write the failing test** - -Create `tests/graph_test.rs`: - -```rust -use codegraph::db::Database; -use codegraph::graph::{GraphTraverser, GraphQueryManager}; -use codegraph::types::*; -use tempfile::TempDir; - -fn setup_call_graph() -> (TempDir, Database) { - let dir = TempDir::new().unwrap(); - let db = Database::initialize(&dir.path().join("test.db")).unwrap(); - - // Create: main -> process -> validate -> check - let nodes = vec!["main", "process", "validate", "check"]; - for (i, name) in nodes.iter().enumerate() { - let node = Node { - id: format!("function:{}", name), - kind: NodeKind::Function, - name: name.to_string(), - qualified_name: format!("src/lib.rs::{}", name), - file_path: "src/lib.rs".to_string(), - start_line: (i as u32) * 10 + 1, - end_line: (i as u32) * 10 + 9, - start_column: 0, end_column: 1, - signature: Some(format!("fn {}()", name)), - docstring: None, - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - db.insert_node(&node).unwrap(); - } - - let call_edges = vec![ - ("main", "process"), - ("process", "validate"), - ("validate", "check"), - ]; - for (source, target) in call_edges { - let edge = Edge { - source: format!("function:{}", source), - target: format!("function:{}", target), - kind: EdgeKind::Calls, - line: None, - }; - db.insert_edge(&edge).unwrap(); - } - - (dir, db) -} - -#[test] -fn test_get_callers() { - let (_dir, db) = setup_call_graph(); - let traverser = GraphTraverser::new(&db); - - let callers = traverser.get_callers("function:process", 1).unwrap(); - assert_eq!(callers.len(), 1); - assert_eq!(callers[0].0.name, "main"); -} - -#[test] -fn test_get_callees() { - let (_dir, db) = setup_call_graph(); - let traverser = GraphTraverser::new(&db); - - let callees = traverser.get_callees("function:process", 1).unwrap(); - assert_eq!(callees.len(), 1); - assert_eq!(callees[0].0.name, "validate"); -} - -#[test] -fn test_impact_radius() { - let (_dir, db) = setup_call_graph(); - let traverser = GraphTraverser::new(&db); - - // Impact of "check" should include validate, process, main - let impact = traverser.get_impact_radius("function:check", 10).unwrap(); - assert!(impact.nodes.len() >= 3, "impact should include transitive callers"); -} - -#[test] -fn test_call_graph_bidirectional() { - let (_dir, db) = setup_call_graph(); - let traverser = GraphTraverser::new(&db); - - let graph = traverser.get_call_graph("function:process", 2).unwrap(); - // Should include main (caller) and validate (callee) - assert!(graph.nodes.len() >= 3); -} - -#[test] -fn test_bfs_traversal_with_depth_limit() { - let (_dir, db) = setup_call_graph(); - let traverser = GraphTraverser::new(&db); - - let opts = TraversalOptions { - max_depth: 1, - direction: TraversalDirection::Outgoing, - ..Default::default() - }; - - let subgraph = traverser.traverse_bfs("function:main", &opts).unwrap(); - // Depth 1: main + process only - assert!(subgraph.nodes.len() <= 2); -} - -#[test] -fn test_find_dead_code() { - let (_dir, db) = setup_call_graph(); - let qm = GraphQueryManager::new(&db); - - // Add an isolated function (no incoming edges) - let orphan = Node { - id: "function:orphan".to_string(), - kind: NodeKind::Function, - name: "orphan".to_string(), - qualified_name: "src/lib.rs::orphan".to_string(), - file_path: "src/lib.rs".to_string(), - start_line: 50, end_line: 55, - start_column: 0, end_column: 1, - signature: None, docstring: None, - visibility: Visibility::Private, // private, no callers = dead code - is_async: false, updated_at: 0, - }; - db.insert_node(&orphan).unwrap(); - - let dead = qm.find_dead_code(&[NodeKind::Function]).unwrap(); - let dead_names: Vec<_> = dead.iter().map(|n| n.name.as_str()).collect(); - assert!(dead_names.contains(&"orphan"), "orphan should be dead code"); - // main has no incoming edges but is named "main", should be excluded -} -``` - -**Step 2: Run test to verify it fails** - -```bash -cargo test --test graph_test -``` - -**Step 3: Implement graph module** - -Implement `src/graph/traversal.rs` with `GraphTraverser` providing BFS/DFS traversal, callers/callees, impact radius, call graph, type hierarchy, and path finding. - -Implement `src/graph/queries.rs` with `GraphQueryManager` providing dead code detection, node metrics, file dependencies, and circular dependency detection. - -**Step 4: Run tests** - -```bash -cargo test --test graph_test -``` - -**Step 5: Commit** - -```bash -git add src/graph/ src/lib.rs tests/graph_test.rs -git commit -m "feat: add graph traversal with BFS/DFS, impact analysis, and dead code detection" -``` - ---- - -## Task 7: CLI Interface - -**Files:** -- Modify: `src/main.rs` -- Create: `src/codegraph.rs` (main orchestrator) -- Modify: `src/lib.rs` -- Test: manual CLI testing - -**Step 1: Implement the CodeGraph orchestrator** - -Create `src/codegraph.rs`, the central orchestrator that wires all subsystems together: - -```rust -pub struct CodeGraph { - db: Database, - config: CodeGraphConfig, - project_root: PathBuf, -} -``` - -Methods: -- `init(project_root)`, create `.codegraph/`, init DB, save config -- `open(project_root)`, open existing project -- `index_all()`, scan files, extract, resolve, store -- `sync()`, incremental update via content hashing -- `search(query, limit)`. FTS5 search -- `get_stats()`, graph statistics -- All graph query delegations (callers, callees, impact, etc.) - -**Step 2: Implement CLI with clap** - -Modify `src/main.rs`: - -```rust -use clap::{Parser, Subcommand}; - -#[derive(Parser)] -#[command(name = "codegraph", about = "Code intelligence for Rust codebases")] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - Init { path: Option }, - Index { path: Option, #[arg(short, long)] force: bool }, - Sync { path: Option }, - Status { path: Option, #[arg(short, long)] json: bool }, - Query { search: String, #[arg(short, long)] path: Option, #[arg(short, long, default_value = "10")] limit: usize }, - Context { task: String, #[arg(short, long)] path: Option }, - Serve { #[arg(short, long)] path: Option }, -} -``` - -**Step 3: Test CLI manually** - -```bash -cargo run -- init . -cargo run -- index . -cargo run -- status . -cargo run -- query "main" -``` - -**Step 4: Commit** - -```bash -git add src/main.rs src/codegraph.rs src/lib.rs -git commit -m "feat: add CLI with init, index, sync, status, query, context, serve commands" -``` - ---- - -## Task 8: Context Builder - -**Files:** -- Create: `src/context/mod.rs` -- Create: `src/context/builder.rs` -- Create: `src/context/formatter.rs` -- Modify: `src/lib.rs` -- Test: `tests/context_test.rs` - -**Step 1: Write the failing test** - -Create `tests/context_test.rs`: - -```rust -use codegraph::context::*; -use codegraph::db::Database; -use codegraph::graph::GraphTraverser; -use codegraph::types::*; -use tempfile::TempDir; - -fn setup_context_db() -> (TempDir, Database) { - let dir = TempDir::new().unwrap(); - let db = Database::initialize(&dir.path().join("test.db")).unwrap(); - - let node = Node { - id: "function:process_request".to_string(), - kind: NodeKind::Function, - name: "process_request".to_string(), - qualified_name: "src/server.rs::process_request".to_string(), - file_path: "src/server.rs".to_string(), - start_line: 10, end_line: 25, - start_column: 0, end_column: 1, - signature: Some("pub fn process_request(req: Request) -> Response".to_string()), - docstring: Some("Handles incoming HTTP requests".to_string()), - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - db.insert_node(&node).unwrap(); - (dir, db) -} - -#[test] -fn test_extract_symbols_from_query() { - let symbols = extract_symbols_from_query("fix the process_request function"); - assert!(symbols.contains(&"process_request".to_string())); -} - -#[test] -fn test_extract_camel_case_symbols() { - let symbols = extract_symbols_from_query("update UserService handler"); - assert!(symbols.contains(&"UserService".to_string())); -} - -#[test] -fn test_format_context_markdown() { - let context = TaskContext { - query: "test query".to_string(), - summary: "Test summary".to_string(), - subgraph: Subgraph::default(), - entry_points: vec![], - code_blocks: vec![], - related_files: vec![], - }; - - let md = format_context_as_markdown(&context); - assert!(md.contains("## Code Context")); - assert!(md.contains("test query")); -} - -#[test] -fn test_format_context_json() { - let context = TaskContext { - query: "test".to_string(), - summary: "Summary".to_string(), - subgraph: Subgraph::default(), - entry_points: vec![], - code_blocks: vec![], - related_files: vec![], - }; - - let json = format_context_as_json(&context); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed["query"], "test"); -} -``` - -**Step 2: Implement context module** - -The `ContextBuilder` should: -1. Extract symbol names from natural language queries (CamelCase, snake_case patterns) -2. Search for matching nodes via FTS5 and exact name lookup -3. Expand graph around entry points using BFS -4. Extract code blocks by reading source files -5. Format output as markdown or JSON - -**Step 3: Run tests, commit** - -```bash -cargo test --test context_test -git add src/context/ tests/context_test.rs -git commit -m "feat: add context builder with symbol extraction and markdown/JSON formatting" -``` - ---- - -## Task 9: Vector Embeddings - -**Files:** -- Create: `src/vectors/mod.rs` -- Create: `src/vectors/embedder.rs` -- Create: `src/vectors/search.rs` -- Modify: `src/lib.rs` -- Test: `tests/vectors_test.rs` - -**Step 1: Write the failing test** - -Create `tests/vectors_test.rs`: - -```rust -use codegraph::vectors::*; -use codegraph::db::Database; -use codegraph::types::*; -use tempfile::TempDir; - -#[test] -fn test_cosine_similarity_identical() { - let a = vec![1.0, 0.0, 0.0]; - let b = vec![1.0, 0.0, 0.0]; - let sim = cosine_similarity(&a, &b); - assert!((sim - 1.0).abs() < 1e-6); -} - -#[test] -fn test_cosine_similarity_orthogonal() { - let a = vec![1.0, 0.0]; - let b = vec![0.0, 1.0]; - let sim = cosine_similarity(&a, &b); - assert!(sim.abs() < 1e-6); -} - -#[test] -fn test_store_and_retrieve_vector() { - let dir = TempDir::new().unwrap(); - let db = Database::initialize(&dir.path().join("test.db")).unwrap(); - - // Must have a node to reference - let node = Node { - id: "function:test_fn".to_string(), - kind: NodeKind::Function, - name: "test_fn".to_string(), - qualified_name: "test_fn".to_string(), - file_path: "src/lib.rs".to_string(), - start_line: 1, end_line: 5, - start_column: 0, end_column: 1, - signature: None, docstring: None, - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - db.insert_node(&node).unwrap(); - - let embedding: Vec = vec![0.1, 0.2, 0.3, 0.4, 0.5]; - store_vector(&db, "function:test_fn", &embedding, "test-model").unwrap(); - - let retrieved = get_vector(&db, "function:test_fn").unwrap(); - assert!(retrieved.is_some()); - let retrieved = retrieved.unwrap(); - assert_eq!(retrieved.len(), 5); - assert!((retrieved[0] - 0.1).abs() < 1e-6); -} - -#[test] -fn test_brute_force_search() { - let dir = TempDir::new().unwrap(); - let db = Database::initialize(&dir.path().join("test.db")).unwrap(); - - // Insert nodes and embeddings - for i in 0..5 { - let node = Node { - id: format!("function:fn_{}", i), - kind: NodeKind::Function, - name: format!("fn_{}", i), - qualified_name: format!("fn_{}", i), - file_path: "src/lib.rs".to_string(), - start_line: i + 1, end_line: i + 5, - start_column: 0, end_column: 1, - signature: None, docstring: None, - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - db.insert_node(&node).unwrap(); - - let mut embedding = vec![0.0f32; 5]; - embedding[i as usize] = 1.0; // one-hot encoding - store_vector(&db, &format!("function:fn_{}", i), &embedding, "test").unwrap(); - } - - // Search for vector close to fn_2 - let query = vec![0.0, 0.0, 0.9, 0.1, 0.0]; - let results = brute_force_search(&db, &query, 3).unwrap(); - assert!(!results.is_empty()); - assert_eq!(results[0].0, "function:fn_2"); // closest match -} - -#[test] -fn test_create_node_text() { - let node = Node { - id: "function:test".to_string(), - kind: NodeKind::Function, - name: "process_data".to_string(), - qualified_name: "src/lib.rs::process_data".to_string(), - file_path: "src/lib.rs".to_string(), - start_line: 1, end_line: 10, - start_column: 0, end_column: 1, - signature: Some("fn process_data(input: &str) -> Result".to_string()), - docstring: Some("Processes raw data input".to_string()), - visibility: Visibility::Pub, - is_async: false, updated_at: 0, - }; - - let text = create_node_text(&node); - assert!(text.contains("process_data")); - assert!(text.contains("function")); - assert!(text.contains("Processes raw data")); -} -``` - -**Step 2: Implement vectors module** - -The vectors module should provide: -- `cosine_similarity(a, b)`, compute cosine similarity -- `store_vector(db, node_id, embedding, model)`, store as BLOB -- `get_vector(db, node_id)`, retrieve and decode BLOB -- `brute_force_search(db, query, limit)`, load all vectors, compute similarity, return top-k -- `create_node_text(node)`, create searchable text representation -- `TextEmbedder`, wrapper around `ort` for ONNX inference (initialize with model path, embed text, embed query) - -For the ONNX embedder, use the `ort` crate with `nomic-embed-text-v1.5` model. Add "search_query: " / "search_document: " prefixes per nomic model requirements. - -**Step 3: Run tests, commit** - -```bash -cargo test --test vectors_test -git add src/vectors/ tests/vectors_test.rs -git commit -m "feat: add vector embeddings with brute-force cosine similarity search" -``` - ---- - -## Task 10: MCP Server - -**Files:** -- Create: `src/mcp/mod.rs` -- Create: `src/mcp/server.rs` -- Create: `src/mcp/tools.rs` -- Create: `src/mcp/transport.rs` -- Modify: `src/lib.rs` -- Test: `tests/mcp_test.rs` - -**Step 1: Write the failing test** - -Create `tests/mcp_test.rs`: - -```rust -use codegraph::mcp::transport::*; -use codegraph::mcp::tools::*; -use serde_json::json; - -#[test] -fn test_parse_jsonrpc_request() { - let msg = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list", - "params": {} - }); - - let request: JsonRpcRequest = serde_json::from_value(msg).unwrap(); - assert_eq!(request.method, "tools/list"); - assert_eq!(request.id, serde_json::Value::Number(1.into())); -} - -#[test] -fn test_tool_definitions() { - let tools = get_tool_definitions(); - assert!(!tools.is_empty()); - - let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); - assert!(tool_names.contains(&"codegraph_search")); - assert!(tool_names.contains(&"codegraph_context")); - assert!(tool_names.contains(&"codegraph_callers")); - assert!(tool_names.contains(&"codegraph_callees")); - assert!(tool_names.contains(&"codegraph_impact")); - assert!(tool_names.contains(&"codegraph_node")); - assert!(tool_names.contains(&"codegraph_status")); -} - -#[test] -fn test_serialize_jsonrpc_response() { - let response = JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: serde_json::Value::Number(1.into()), - result: Some(json!({"tools": []})), - error: None, - }; - - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"jsonrpc\":\"2.0\"")); -} - -#[test] -fn test_error_response() { - let response = JsonRpcResponse::error( - serde_json::Value::Number(1.into()), - ErrorCode::MethodNotFound, - "Method not found".to_string(), - ); - - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("-32601")); -} -``` - -**Step 2: Implement MCP module** - -The MCP server should: -- Read JSON-RPC 2.0 messages from stdin line-by-line -- Handle `initialize`, `tools/list`, `tools/call`, `ping` requests -- Expose 7 tools: search, context, callers, callees, impact, node, status -- Format output for minimal token usage -- Truncate responses > 15000 chars -- Use `tokio` for async I/O - -**Step 3: Run tests, commit** - -```bash -cargo test --test mcp_test -git add src/mcp/ tests/mcp_test.rs -git commit -m "feat: add MCP server with JSON-RPC transport and tool handlers" -``` - ---- - -## Task 11: Incremental Sync - -**Files:** -- Create: `src/sync.rs` -- Modify: `src/codegraph.rs` -- Modify: `src/lib.rs` -- Test: `tests/sync_test.rs` - -**Step 1: Write the failing test** - -Create `tests/sync_test.rs`: - -```rust -use codegraph::sync::*; - -#[test] -fn test_content_hash_deterministic() { - let content = "fn main() {}"; - let hash1 = content_hash(content); - let hash2 = content_hash(content); - assert_eq!(hash1, hash2); -} - -#[test] -fn test_content_hash_different_for_different_content() { - let hash1 = content_hash("fn main() {}"); - let hash2 = content_hash("fn main() { println!(\"hello\"); }"); - assert_ne!(hash1, hash2); -} - -#[test] -fn test_detect_changed_files() { - // Test that changed files are detected by comparing stored vs current hashes - use codegraph::db::Database; - use codegraph::types::FileRecord; - use tempfile::TempDir; - - let dir = TempDir::new().unwrap(); - let db = Database::initialize(&dir.path().join("test.db")).unwrap(); - - // Store a file with hash "old_hash" - db.upsert_file(&FileRecord { - path: "src/main.rs".to_string(), - content_hash: "old_hash".to_string(), - size: 100, - modified_at: 1000, - indexed_at: 1001, - node_count: 5, - }).unwrap(); - - // Current hash is different - let current_hashes = vec![ - ("src/main.rs".to_string(), "new_hash".to_string()), - ]; - - let stale = find_stale_files(&db, ¤t_hashes).unwrap(); - assert_eq!(stale.len(), 1); - assert_eq!(stale[0], "src/main.rs"); -} -``` - -**Step 2: Implement sync module** - -The sync module should: -- `content_hash(content)`. SHA256 hash of file content -- `find_stale_files(db, current_hashes)`, compare stored vs current content hashes -- `find_new_files(db, current_files)`, files not yet in database -- `find_removed_files(db, current_files)`, files in DB but not on disk - -**Step 3: Run tests, commit** - -```bash -cargo test --test sync_test -git add src/sync.rs tests/sync_test.rs -git commit -m "feat: add incremental sync with content hash change detection" -``` - ---- - -## Task 12: Integration Test & Polish - -**Files:** -- Create: `tests/integration_test.rs` -- Modify: various files for fixes - -**Step 1: Write end-to-end integration test** - -Create `tests/integration_test.rs`: - -```rust -use codegraph::codegraph::CodeGraph; -use tempfile::TempDir; -use std::fs; - -#[test] -fn test_full_pipeline() { - let dir = TempDir::new().unwrap(); - let project = dir.path(); - - // Create a small Rust project - fs::create_dir_all(project.join("src")).unwrap(); - fs::write(project.join("src/main.rs"), r#" -use crate::utils::helper; - -mod utils; - -fn main() { - let result = helper(); - println!("{}", result); -} -"#).unwrap(); - - fs::write(project.join("src/utils.rs"), r#" -/// Returns a greeting string. -pub fn helper() -> String { - format_greeting("world") -} - -fn format_greeting(name: &str) -> String { - format!("Hello, {}!", name) -} -"#).unwrap(); - - // Init - let cg = CodeGraph::init(project).unwrap(); - - // Index - let index_result = cg.index_all().unwrap(); - assert!(index_result.file_count > 0); - assert!(index_result.node_count > 0); - - // Stats - let stats = cg.get_stats().unwrap(); - assert!(stats.node_count > 0); - assert!(stats.file_count >= 2); - - // Search - let results = cg.search("helper", 10).unwrap(); - assert!(!results.is_empty()); - assert!(results.iter().any(|r| r.node.name == "helper")); - - // Status - let stats = cg.get_stats().unwrap(); - assert!(stats.edge_count > 0); // should have contains + calls edges -} - -#[test] -fn test_incremental_sync() { - let dir = TempDir::new().unwrap(); - let project = dir.path(); - - fs::create_dir_all(project.join("src")).unwrap(); - fs::write(project.join("src/lib.rs"), "pub fn original() {}").unwrap(); - - let cg = CodeGraph::init(project).unwrap(); - cg.index_all().unwrap(); - - let initial_stats = cg.get_stats().unwrap(); - - // Modify file - fs::write(project.join("src/lib.rs"), "pub fn modified() {}\npub fn added() {}").unwrap(); - - // Sync - let sync_result = cg.sync().unwrap(); - assert!(sync_result.files_modified > 0 || sync_result.files_added > 0); - - let new_stats = cg.get_stats().unwrap(); - // Should have the new function - let results = cg.search("modified", 10).unwrap(); - assert!(!results.is_empty()); -} -``` - -**Step 2: Run integration tests** - -```bash -cargo test --test integration_test -``` - -**Step 3: Fix any issues found** - -**Step 4: Run full test suite** - -```bash -cargo test -``` - -**Step 5: Run clippy and fix warnings** - -```bash -cargo clippy --all-targets -cargo fmt --all -``` - -**Step 6: Commit** - -```bash -git add . -git commit -m "feat: add integration tests and polish for full pipeline" -``` - ---- - -## Summary - -| Task | Module | Estimated Complexity | -|------|--------|---------------------| -| 1 | Project scaffold, types, errors | Low | -| 2 | Configuration | Low | -| 3 | SQLite database layer | Medium | -| 4 | Tree-sitter Rust extraction | High | -| 5 | Reference resolution | Medium | -| 6 | Graph traversal & queries | Medium | -| 7 | CLI interface | Low | -| 8 | Context builder | Medium | -| 9 | Vector embeddings | Medium | -| 10 | MCP server | Medium | -| 11 | Incremental sync | Low | -| 12 | Integration tests & polish | Low | diff --git a/docs/plans/2026-02-26-codegraph-rust-port-design.md b/docs/plans/2026-02-26-codegraph-rust-port-design.md deleted file mode 100644 index 27ecd8efd9..0000000000 --- a/docs/plans/2026-02-26-codegraph-rust-port-design.md +++ /dev/null @@ -1,266 +0,0 @@ -# CodeGraph Rust port. Design document - -> **Archived record, not implementation authority.** This document preserves -> historical intent and evidence. Current requirements come only from the -> `docs/plans/tracedecay-v2/` hierarchy. Exact tests and counts, source-string -> checks, branch/commit/worktree choreography, snapshots, receipts, -> attestations, PR packets, and gate matrices below are not rebuild -> instructions; validate current parser, runtime, and product behavior directly. - -**Date:** 2026-02-26 -**Goal:** Replace the TypeScript CodeGraph implementation with a Rust-native version -**Status:** Design approved - -## Motivation - -The Rust version will become the canonical CodeGraph implementation, replacing the TypeScript version entirely. Benefits include single-binary distribution (no Node.js dependency), better performance for large codebases, and lower memory usage. - -## Scope - -### In Scope -- Tree-sitter AST extraction (Rust language only) -- SQLite graph database with FTS5 full-text search -- Graph queries (callers, callees, impact radius, call graph, dead code, type hierarchy) -- Vector embeddings via `ort` (ONNX Runtime) for semantic search -- MCP server for Claude Code integration (stdio transport) -- CLI interface - -### Out of Scope -- Multi-language support (Rust only for now, can be added later) -- Framework-specific resolvers (React, Express, Laravel, etc.) -- Interactive installer (unnecessary for a single binary) - -## Architecture - -Single crate, module-based structure: - -``` -code-graph/ -├── Cargo.toml -├── src/ -│ ├── main.rs # CLI entry point -│ ├── lib.rs # Library root -│ ├── config.rs # Configuration -│ ├── types.rs # Core types (Node, Edge, etc.) -│ ├── db/ # SQLite layer -│ │ ├── mod.rs -│ │ ├── connection.rs -│ │ ├── queries.rs -│ │ └── schema.sql -│ ├── extraction/ # Tree-sitter parsing -│ │ ├── mod.rs -│ │ └── rust.rs # Rust-specific extraction -│ ├── resolution/ # Reference resolution -│ │ ├── mod.rs -│ │ ├── imports.rs -│ │ └── names.rs -│ ├── graph/ # Graph traversal & queries -│ │ ├── mod.rs -│ │ ├── traversal.rs -│ │ └── queries.rs -│ ├── vectors/ # Embeddings -│ │ ├── mod.rs -│ │ ├── embedder.rs -│ │ └── search.rs -│ ├── context/ # Context building -│ │ ├── mod.rs -│ │ └── formatter.rs -│ ├── sync.rs # Incremental updates -│ └── mcp/ # MCP server -│ ├── mod.rs -│ ├── server.rs -│ └── tools.rs -``` - -## Core Types - -### Node Kinds - -```rust -enum NodeKind { - File, - Module, - Struct, - Enum, - EnumVariant, - Trait, - Function, - Method, - Impl, - Const, - Static, - TypeAlias, - Field, - Macro, - Use, -} -``` - -### Node - -```rust -struct Node { - id: String, // deterministic: "file_path::symbol_path" - kind: NodeKind, - name: String, - file_path: String, - start_line: u32, - end_line: u32, - signature: Option, - docstring: Option, - visibility: Visibility, // Pub, PubCrate, Private - body_hash: Option, -} -``` - -### Edge Kinds - -```rust -enum EdgeKind { - Contains, // file/module contains items - Calls, // function calls function - Uses, // references a symbol (use statement) - Implements, // impl Trait for Struct - TypeOf, // field/variable type references - Returns, // function return type - DerivesMacro, // #[derive(Debug, Clone)] -} -``` - -### Edge - -```rust -struct Edge { - source_id: String, - target_id: String, - kind: EdgeKind, - line: Option, -} -``` - -## SQLite Schema - -Tables: -- `nodes`, all extracted symbols with metadata -- `edges`, relationships between nodes -- `files`, tracked files with content hashes (for incremental sync) -- `nodes_fts`. FTS5 virtual table on node names and signatures -- `vectors`, embeddings stored as BLOBs with node_id foreign key - -## Extraction Pipeline - -Uses `tree-sitter` + `tree-sitter-rust` (native bindings, not WASM). - -### What We Extract - -| Rust Construct | Node Kind | Edges Emitted | -|---|---|---| -| `fn foo()` | Function | Contains (from parent), Calls (to callees), Returns | -| `struct Foo` | Struct | Contains (fields) | -| `enum Bar` | Enum | Contains (variants) | -| `impl Trait for S` | Impl | Implements (trait → struct) | -| `impl S` | Impl | Contains (methods) | -| `use crate::x` | Use | Uses (resolved target) | -| `#[derive(..)]` | — | DerivesMacro edges | -| `mod foo` | Module | Contains | -| `const`/`static` | Const/Static | TypeOf | - -### Processing Flow - -``` -file path → read source → tree-sitter parse → walk AST → emit Nodes + Edges - → resolve use statements → resolve call targets → store in SQLite -``` - -## Reference Resolution - -Two strategies (no framework-specific resolvers): - -1. **Use-statement resolution:** Follow `use` paths to find target symbols. Handles `use crate::`, `use super::`, `use self::`, and external crate references. - -2. **Name-based matching:** For method calls (`.foo()`), match by method name against all known methods. Ranking: same module > same crate > external. - -Type-informed matching (narrowing method resolution by receiver type) is a stretch goal, treat as best-effort. - -## Graph Queries - -| Query | Description | Implementation | -|---|---|---| -| `callers(node_id)` | What calls this function? | edges WHERE target = ? AND kind = calls | -| `callees(node_id)` | What does this call? | edges WHERE source = ? AND kind = calls | -| `impact(node_id, depth)` | Transitive callers to N levels | BFS over caller edges | -| `call_graph(node_id)` | Bidirectional call relationships | BFS both directions | -| `dead_code()` | Unreferenced symbols | Zero in-degree, excluding main/#[test]/pub | -| `type_hierarchy(node_id)` | Trait implementation chain | Follow Implements edges | -| `search(query)` | Full-text symbol search | FTS5 on nodes_fts | -| `semantic_search(query, k)` | Vector similarity | Cosine similarity on embeddings | - -## MCP Server - -Stdio transport, JSON-RPC protocol. Tools exposed to Claude Code: - -- `codegraph_search`, find symbols by name (FTS5) -- `codegraph_context`, build context for a task (semantic search + graph expansion) -- `codegraph_callers` / `codegraph_callees`, call relationships -- `codegraph_impact`, impact radius analysis -- `codegraph_node`, get full symbol details -- `codegraph_status`, index stats and health - -Implementation: `tokio` async I/O, read/write JSON-RPC over stdin/stdout. - -## CLI - -``` -codegraph init [path] # Create .codegraph/ config -codegraph index [path] # Full index -codegraph sync [path] # Incremental update -codegraph status [path] # Show stats -codegraph query # Search symbols -codegraph context # Build context -codegraph serve # Start MCP server (stdio) -``` - -## Dependencies - -| Crate | Purpose | -|---|---| -| `rusqlite` (bundled) | SQLite database | -| `tree-sitter` | AST parsing framework | -| `tree-sitter-rust` | Rust grammar | -| `ort` | ONNX Runtime for embeddings | -| `clap` | CLI argument parsing | -| `serde` / `serde_json` | Serialization | -| `tokio` | Async runtime (MCP server) | -| `thiserror` | Error types | -| `tracing` | Structured logging | -| `sha2` | Content hashing for sync | - -## Configuration - -Per-project `.codegraph/config.json`: - -```json -{ - "version": 1, - "root_dir": ".", - "include": ["**/*.rs"], - "exclude": ["target/**", "tests/**"], - "max_file_size": 1048576, - "extract_docstrings": true, - "track_call_sites": true, - "enable_embeddings": false -} -``` - -## Implementation Order - -1. **Types & config**. Core types, configuration, error handling -2. **SQLite layer**. Schema, connection, CRUD operations, FTS5 -3. **Tree-sitter extraction**. Parse Rust files, emit nodes and edges -4. **Reference resolution**. Use-statement and name-based resolution -5. **Graph queries**. Callers, callees, impact, dead code, etc. -6. **CLI**. Init, index, sync, status, query commands -7. **Context builder**. Semantic search + graph expansion for context -8. **Vector embeddings**. ONNX runtime integration, embedding storage -9. **MCP server**. Stdio JSON-RPC transport, tool handlers -10. **Incremental sync**. Content hashing, dirty detection, partial re-index diff --git a/docs/plans/2026-02-26-multi-language-implementation-plan.md b/docs/plans/2026-02-26-multi-language-implementation-plan.md deleted file mode 100644 index 163520fb33..0000000000 --- a/docs/plans/2026-02-26-multi-language-implementation-plan.md +++ /dev/null @@ -1,1371 +0,0 @@ -# Multi-Language Support (Go + Java) Implementation Plan - -> **Archived record, not implementation authority.** This document preserves -> historical intent and evidence. Current requirements come only from the -> `docs/plans/tracedecay-v2/` hierarchy. Exact tests and counts, source-string -> checks, branch/commit/worktree choreography, snapshots, receipts, -> attestations, PR packets, and gate matrices below are not rebuild -> instructions; validate current parser, runtime, and product behavior directly. - -**Goal:** Add Go and Java language support to codegraph with deep extraction, using a trait-based abstraction layer. - -**Architecture:** Introduce a `LanguageExtractor` trait and `LanguageRegistry` that dispatches to per-language extractors based on file extension. Each extractor uses tree-sitter with a language-specific grammar. The existing `RustExtractor` is retrofitted to implement the trait. - -**Tech Stack:** tree-sitter, tree-sitter-go, tree-sitter-java, Rust traits - ---- - -### Task 1: Add tree-sitter dependencies to Cargo.toml - -**Files:** -- Modify: `Cargo.toml` - -**Step 1: Add dependencies** - -Add `tree-sitter-go` and `tree-sitter-java` to `[dependencies]`: - -```toml -tree-sitter-go = "0.23" -tree-sitter-java = "0.23" -``` - -**Step 2: Verify it compiles** - -Run: `cargo check` -Expected: compiles successfully (new deps are unused but that's OK) - -**Step 3: Commit** - -```bash -git add Cargo.toml Cargo.lock -git commit -m "feat: add tree-sitter-go and tree-sitter-java dependencies" -``` - ---- - -### Task 2: Expand NodeKind and EdgeKind enums - -**Files:** -- Modify: `src/types.rs` -- Test: `tests/types_test.rs` - -**Step 1: Write tests for new NodeKind variants** - -Add to `tests/types_test.rs`: - -```rust -#[test] -fn test_new_node_kinds_roundtrip() { - let kinds = vec![ - (NodeKind::Class, "class"), - (NodeKind::Interface, "interface"), - (NodeKind::Constructor, "constructor"), - (NodeKind::Annotation, "annotation"), - (NodeKind::AnnotationUsage, "annotation_usage"), - (NodeKind::Package, "package"), - (NodeKind::InnerClass, "inner_class"), - (NodeKind::InitBlock, "init_block"), - (NodeKind::AbstractMethod, "abstract_method"), - (NodeKind::InterfaceType, "interface_type"), - (NodeKind::StructMethod, "struct_method"), - (NodeKind::GoPackage, "go_package"), - (NodeKind::StructTag, "struct_tag"), - (NodeKind::GenericParam, "generic_param"), - ]; - for (kind, expected_str) in kinds { - assert_eq!(kind.as_str(), expected_str); - assert_eq!(NodeKind::from_str(expected_str), Some(kind)); - } -} - -#[test] -fn test_new_edge_kinds_roundtrip() { - let kinds = vec![ - (EdgeKind::Extends, "extends"), - (EdgeKind::Annotates, "annotates"), - (EdgeKind::Receives, "receives"), - ]; - for (kind, expected_str) in kinds { - assert_eq!(kind.as_str(), expected_str); - assert_eq!(EdgeKind::from_str(expected_str), Some(kind)); - } -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cargo test --test types_test test_new_node_kinds_roundtrip test_new_edge_kinds_roundtrip` -Expected: FAIL, variants don't exist yet - -**Step 3: Add NodeKind variants** - -In `src/types.rs`, add to `NodeKind` enum after `Use`: - -```rust - // Java-specific - Class, - Interface, - Constructor, - Annotation, - AnnotationUsage, - Package, - InnerClass, - InitBlock, - AbstractMethod, - // Go-specific - InterfaceType, - StructMethod, - GoPackage, - StructTag, - // Shared - GenericParam, -``` - -Add corresponding arms to `as_str()`: - -```rust - NodeKind::Class => "class", - NodeKind::Interface => "interface", - NodeKind::Constructor => "constructor", - NodeKind::Annotation => "annotation", - NodeKind::AnnotationUsage => "annotation_usage", - NodeKind::Package => "package", - NodeKind::InnerClass => "inner_class", - NodeKind::InitBlock => "init_block", - NodeKind::AbstractMethod => "abstract_method", - NodeKind::InterfaceType => "interface_type", - NodeKind::StructMethod => "struct_method", - NodeKind::GoPackage => "go_package", - NodeKind::StructTag => "struct_tag", - NodeKind::GenericParam => "generic_param", -``` - -Add corresponding arms to `from_str()`: - -```rust - "class" => Some(NodeKind::Class), - "interface" => Some(NodeKind::Interface), - "constructor" => Some(NodeKind::Constructor), - "annotation" => Some(NodeKind::Annotation), - "annotation_usage" => Some(NodeKind::AnnotationUsage), - "package" => Some(NodeKind::Package), - "inner_class" => Some(NodeKind::InnerClass), - "init_block" => Some(NodeKind::InitBlock), - "abstract_method" => Some(NodeKind::AbstractMethod), - "interface_type" => Some(NodeKind::InterfaceType), - "struct_method" => Some(NodeKind::StructMethod), - "go_package" => Some(NodeKind::GoPackage), - "struct_tag" => Some(NodeKind::StructTag), - "generic_param" => Some(NodeKind::GenericParam), -``` - -**Step 4: Add EdgeKind variants** - -In `src/types.rs`, add to `EdgeKind` enum after `DerivesMacro`: - -```rust - Extends, - Annotates, - Receives, -``` - -Add to `EdgeKind::as_str()`: - -```rust - EdgeKind::Extends => "extends", - EdgeKind::Annotates => "annotates", - EdgeKind::Receives => "receives", -``` - -Add to `EdgeKind::from_str()`: - -```rust - "extends" => Some(EdgeKind::Extends), - "annotates" => Some(EdgeKind::Annotates), - "receives" => Some(EdgeKind::Receives), -``` - -**Step 5: Run tests to verify they pass** - -Run: `cargo test --test types_test` -Expected: PASS - -**Step 6: Run full test suite to check no regressions** - -Run: `cargo test` -Expected: PASS, existing code doesn't break since we only added new variants - -**Step 7: Commit** - -```bash -git add src/types.rs tests/types_test.rs -git commit -m "feat: expand NodeKind and EdgeKind enums for Go and Java support" -``` - ---- - -### Task 3: Create LanguageExtractor trait and LanguageRegistry - -**Files:** -- Modify: `src/extraction/mod.rs` -- Test: `tests/extraction_test.rs` - -**Step 1: Write test for language registry** - -Add to `tests/extraction_test.rs`: - -```rust -use codegraph::extraction::LanguageRegistry; - -#[test] -fn test_language_registry_finds_rust_extractor() { - let registry = LanguageRegistry::new(); - assert!(registry.extractor_for_file("src/main.rs").is_some()); - assert!(registry.extractor_for_file("lib.rs").is_some()); -} - -#[test] -fn test_language_registry_finds_go_extractor() { - let registry = LanguageRegistry::new(); - assert!(registry.extractor_for_file("main.go").is_some()); - assert!(registry.extractor_for_file("pkg/server.go").is_some()); -} - -#[test] -fn test_language_registry_finds_java_extractor() { - let registry = LanguageRegistry::new(); - assert!(registry.extractor_for_file("Main.java").is_some()); - assert!(registry.extractor_for_file("src/com/example/App.java").is_some()); -} - -#[test] -fn test_language_registry_returns_none_for_unknown() { - let registry = LanguageRegistry::new(); - assert!(registry.extractor_for_file("script.py").is_none()); - assert!(registry.extractor_for_file("style.css").is_none()); - assert!(registry.extractor_for_file("README.md").is_none()); -} - -#[test] -fn test_language_registry_supported_extensions() { - let registry = LanguageRegistry::new(); - let exts = registry.supported_extensions(); - assert!(exts.contains(&"rs")); - assert!(exts.contains(&"go")); - assert!(exts.contains(&"java")); -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cargo test --test extraction_test test_language_registry` -Expected: FAIL, `LanguageRegistry` doesn't exist - -**Step 3: Define trait and registry in `src/extraction/mod.rs`** - -Replace the contents of `src/extraction/mod.rs` with: - -```rust -/// Tree-sitter based source code extraction module. -/// -/// This module provides extractors that parse source files using tree-sitter -/// and produce structured graph nodes and edges. -mod rust_extractor; -mod go_extractor; -mod java_extractor; - -pub use rust_extractor::RustExtractor; -pub use go_extractor::GoExtractor; -pub use java_extractor::JavaExtractor; - -use crate::types::ExtractionResult; - -/// Trait for language-specific source code extractors. -/// -/// Each implementation handles a single programming language, -/// using tree-sitter to parse source and emit graph nodes and edges. -pub trait LanguageExtractor: Send + Sync { - /// File extensions this extractor handles (without leading dot). - fn extensions(&self) -> &[&str]; - - /// Human-readable language name. - fn language_name(&self) -> &str; - - /// Extract nodes, edges, and unresolved refs from source code. - /// - /// `file_path` is the relative path used for qualified names and node IDs. - /// `source` is the source code to parse. - fn extract(&self, file_path: &str, source: &str) -> ExtractionResult; -} - -/// Registry of all available language extractors. -/// -/// Dispatches to the correct extractor based on file extension. -pub struct LanguageRegistry { - extractors: Vec>, -} - -impl LanguageRegistry { - /// Creates a new registry with all built-in language extractors. - pub fn new() -> Self { - Self { - extractors: vec![ - Box::new(RustExtractor), - Box::new(GoExtractor), - Box::new(JavaExtractor), - ], - } - } - - /// Returns the extractor for a file path based on its extension. - pub fn extractor_for_file(&self, path: &str) -> Option<&dyn LanguageExtractor> { - let ext = path.rsplit('.').next()?; - self.extractors - .iter() - .find(|e| e.extensions().contains(&ext)) - .map(|e| e.as_ref()) - } - - /// Returns all supported file extensions across all extractors. - pub fn supported_extensions(&self) -> Vec<&str> { - self.extractors - .iter() - .flat_map(|e| e.extensions().iter().copied()) - .collect() - } -} - -impl Default for LanguageRegistry { - fn default() -> Self { - Self::new() - } -} -``` - -**Step 4: Create stub Go and Java extractors** - -Create `src/extraction/go_extractor.rs` with a minimal stub: - -```rust -/// Tree-sitter based Go source code extractor. -use crate::extraction::LanguageExtractor; -use crate::types::ExtractionResult; - -/// Extracts code graph nodes and edges from Go source files. -pub struct GoExtractor; - -impl LanguageExtractor for GoExtractor { - fn extensions(&self) -> &[&str] { - &["go"] - } - - fn language_name(&self) -> &str { - "Go" - } - - fn extract(&self, _file_path: &str, _source: &str) -> ExtractionResult { - ExtractionResult { - nodes: Vec::new(), - edges: Vec::new(), - unresolved_refs: Vec::new(), - errors: vec!["Go extraction not yet implemented".to_string()], - duration_ms: 0, - } - } -} -``` - -Create `src/extraction/java_extractor.rs` with a minimal stub: - -```rust -/// Tree-sitter based Java source code extractor. -use crate::extraction::LanguageExtractor; -use crate::types::ExtractionResult; - -/// Extracts code graph nodes and edges from Java source files. -pub struct JavaExtractor; - -impl LanguageExtractor for JavaExtractor { - fn extensions(&self) -> &[&str] { - &["java"] - } - - fn language_name(&self) -> &str { - "Java" - } - - fn extract(&self, _file_path: &str, _source: &str) -> ExtractionResult { - ExtractionResult { - nodes: Vec::new(), - edges: Vec::new(), - unresolved_refs: Vec::new(), - errors: vec!["Java extraction not yet implemented".to_string()], - duration_ms: 0, - } - } -} -``` - -**Step 5: Implement LanguageExtractor for RustExtractor** - -Add to bottom of `src/extraction/rust_extractor.rs`: - -```rust -impl crate::extraction::LanguageExtractor for RustExtractor { - fn extensions(&self) -> &[&str] { - &["rs"] - } - - fn language_name(&self) -> &str { - "Rust" - } - - fn extract(&self, file_path: &str, source: &str) -> ExtractionResult { - RustExtractor::extract(file_path, source) - } -} -``` - -**Step 6: Run tests to verify they pass** - -Run: `cargo test --test extraction_test` -Expected: PASS, all existing extraction tests still pass, plus new registry tests - -**Step 7: Commit** - -```bash -git add src/extraction/mod.rs src/extraction/rust_extractor.rs src/extraction/go_extractor.rs src/extraction/java_extractor.rs tests/extraction_test.rs -git commit -m "feat: add LanguageExtractor trait, LanguageRegistry, and stub extractors" -``` - ---- - -### Task 4: Integrate LanguageRegistry into CodeGraph - -**Files:** -- Modify: `src/codegraph.rs` -- Modify: `src/config.rs` - -**Step 1: Update config defaults** - -In `src/config.rs`, change the `Default` impl: - -Replace the `include` default: -```rust -include: vec!["**/*.rs".to_string()], -``` -with: -```rust -include: vec![ - "**/*.rs".to_string(), - "**/*.go".to_string(), - "**/*.java".to_string(), -], -``` - -Add to the `exclude` default list: -```rust - "bin/**".to_string(), - "build/**".to_string(), - "out/**".to_string(), - ".gradle/**".to_string(), -``` - -**Step 2: Update CodeGraph to use LanguageRegistry** - -In `src/codegraph.rs`: - -1. Add import: `use crate::extraction::LanguageRegistry;` -2. Remove: `use crate::extraction::RustExtractor;` -3. Add `registry` field to the `CodeGraph` struct: - ```rust - pub struct CodeGraph { - db: Database, - config: CodeGraphConfig, - project_root: PathBuf, - registry: LanguageRegistry, - } - ``` -4. Add `registry: LanguageRegistry::new()` to both `init()` and `open()` constructors. -5. In `index_all()`, replace line 146: - ```rust - let result = RustExtractor::extract(file_path, &source); - ``` - with: - ```rust - let extractor = match self.registry.extractor_for_file(file_path) { - Some(e) => e, - None => continue, - }; - let result = extractor.extract(file_path, &source); - ``` -6. In `sync()`, replace line 227: - ```rust - let result = RustExtractor::extract(file_path, &source); - ``` - with: - ```rust - let extractor = match self.registry.extractor_for_file(file_path) { - Some(e) => e, - None => continue, - }; - let result = extractor.extract(file_path, &source); - ``` - -**Step 3: Verify compilation and tests** - -Run: `cargo test` -Expected: PASS, all existing tests pass, Rust extraction behavior unchanged - -**Step 4: Commit** - -```bash -git add src/codegraph.rs src/config.rs -git commit -m "feat: integrate LanguageRegistry into CodeGraph for multi-language dispatch" -``` - ---- - -### Task 5: implement go extractor, types and package - -**Files:** -- Modify: `src/extraction/go_extractor.rs` -- Test: `tests/go_extraction_test.rs` (new) - -**Step 1: Write tests for Go package and struct extraction** - -Create `tests/go_extraction_test.rs`: - -```rust -use codegraph::extraction::GoExtractor; -use codegraph::extraction::LanguageExtractor; -use codegraph::types::*; - -#[test] -fn test_go_extract_package() { - let source = r#"package main - -import "fmt" - -func main() { - fmt.Println("hello") -} -"#; - let extractor = GoExtractor; - let result = extractor.extract("main.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let pkgs: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::GoPackage).collect(); - assert_eq!(pkgs.len(), 1); - assert_eq!(pkgs[0].name, "main"); -} - -#[test] -fn test_go_extract_function() { - let source = r#"package main - -// Add adds two numbers. -func Add(a, b int) int { - return a + b -} - -func helper() {} -"#; - let extractor = GoExtractor; - let result = extractor.extract("math.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let fns: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Function).collect(); - assert_eq!(fns.len(), 2); - let add_fn = fns.iter().find(|f| f.name == "Add").unwrap(); - assert_eq!(add_fn.visibility, Visibility::Pub); // uppercase = exported - assert!(add_fn.docstring.as_ref().unwrap().contains("Add adds two numbers")); - let helper_fn = fns.iter().find(|f| f.name == "helper").unwrap(); - assert_eq!(helper_fn.visibility, Visibility::Private); // lowercase = unexported -} - -#[test] -fn test_go_extract_struct_with_fields() { - let source = r#"package model - -// Point represents a 2D point. -type Point struct { - X float64 - Y float64 - label string -} -"#; - let extractor = GoExtractor; - let result = extractor.extract("model/point.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let structs: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Struct).collect(); - assert_eq!(structs.len(), 1); - assert_eq!(structs[0].name, "Point"); - assert_eq!(structs[0].visibility, Visibility::Pub); - let fields: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Field).collect(); - assert_eq!(fields.len(), 3); - // X is exported, label is not - let x_field = fields.iter().find(|f| f.name == "X").unwrap(); - assert_eq!(x_field.visibility, Visibility::Pub); - let label_field = fields.iter().find(|f| f.name == "label").unwrap(); - assert_eq!(label_field.visibility, Visibility::Private); -} - -#[test] -fn test_go_extract_struct_tags() { - let source = r#"package model - -type Config struct { - Name string `json:"name" yaml:"name"` - Port int `json:"port"` -} -"#; - let extractor = GoExtractor; - let result = extractor.extract("model/config.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let tags: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::StructTag).collect(); - assert!(tags.len() >= 2, "should extract struct tags"); -} - -#[test] -fn test_go_extract_interface() { - let source = r#"package io - -// Reader is the interface for reading. -type Reader interface { - Read(p []byte) (n int, err error) -} -"#; - let extractor = GoExtractor; - let result = extractor.extract("io/reader.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let ifaces: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::InterfaceType).collect(); - assert_eq!(ifaces.len(), 1); - assert_eq!(ifaces[0].name, "Reader"); - assert_eq!(ifaces[0].visibility, Visibility::Pub); -} - -#[test] -fn test_go_extract_method_with_receiver() { - let source = r#"package model - -type Circle struct { - Radius float64 -} - -// Area calculates the area. -func (c *Circle) Area() float64 { - return 3.14159 * c.Radius * c.Radius -} - -func (c Circle) String() string { - return "circle" -} -"#; - let extractor = GoExtractor; - let result = extractor.extract("model/circle.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let methods: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::StructMethod).collect(); - assert_eq!(methods.len(), 2); - // Check Receives edges - let receives: Vec<_> = result.edges.iter().filter(|e| e.kind == EdgeKind::Receives).collect(); - assert!(!receives.is_empty(), "should have Receives edges for methods with receivers"); -} - -#[test] -fn test_go_extract_imports() { - let source = r#"package main - -import ( - "fmt" - "os" - "github.com/pkg/errors" -) -"#; - let extractor = GoExtractor; - let result = extractor.extract("main.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let uses: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Use).collect(); - assert_eq!(uses.len(), 3); -} - -#[test] -fn test_go_extract_const_and_var() { - let source = r#"package main - -const MaxSize = 1024 - -var counter int -"#; - let extractor = GoExtractor; - let result = extractor.extract("main.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let consts: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Const).collect(); - assert_eq!(consts.len(), 1); - assert_eq!(consts[0].name, "MaxSize"); - let statics: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Static).collect(); - assert_eq!(statics.len(), 1); - assert_eq!(statics[0].name, "counter"); -} - -#[test] -fn test_go_extract_call_sites() { - let source = r#"package main - -import "fmt" - -func greet(name string) { - fmt.Println("Hello", name) -} - -func main() { - greet("world") -} -"#; - let extractor = GoExtractor; - let result = extractor.extract("main.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let call_refs: Vec<_> = result.unresolved_refs.iter() - .filter(|r| r.reference_kind == EdgeKind::Calls) - .collect(); - assert!(!call_refs.is_empty(), "should have call refs"); -} - -#[test] -fn test_go_extract_type_alias() { - let source = r#"package main - -type StringSlice = []string -"#; - let extractor = GoExtractor; - let result = extractor.extract("main.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let aliases: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::TypeAlias).collect(); - assert_eq!(aliases.len(), 1); - assert_eq!(aliases[0].name, "StringSlice"); -} - -#[test] -fn test_go_extract_interface_embedding() { - let source = r#"package io - -type Reader interface { - Read(p []byte) (int, error) -} - -type ReadWriter interface { - Reader - Write(p []byte) (int, error) -} -"#; - let extractor = GoExtractor; - let result = extractor.extract("io/io.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - // Should have an Extends edge or unresolved ref for Reader embedded in ReadWriter - let has_extends = result.edges.iter().any(|e| e.kind == EdgeKind::Extends) - || result.unresolved_refs.iter().any(|r| r.reference_kind == EdgeKind::Extends); - assert!(has_extends, "should detect interface embedding as Extends"); -} - -#[test] -fn test_go_extract_generic_function() { - let source = r#"package main - -func Map[T any, U any](s []T, f func(T) U) []U { - r := make([]U, len(s)) - for i, v := range s { - r[i] = f(v) - } - return r -} -"#; - let extractor = GoExtractor; - let result = extractor.extract("main.go", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let fns: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Function).collect(); - assert_eq!(fns.len(), 1); - assert_eq!(fns[0].name, "Map"); - let generics: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::GenericParam).collect(); - assert!(generics.len() >= 2, "should extract generic type params T and U"); -} - -#[test] -fn test_go_file_node_is_root() { - let source = r#"package main - -func main() {} -"#; - let extractor = GoExtractor; - let result = extractor.extract("main.go", source); - let files: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::File).collect(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].name, "main.go"); -} - -#[test] -fn test_go_contains_edges() { - let source = r#"package main - -type Foo struct { - Bar int -} - -func (f Foo) Baz() {} -"#; - let extractor = GoExtractor; - let result = extractor.extract("main.go", source); - let contains: Vec<_> = result.edges.iter().filter(|e| e.kind == EdgeKind::Contains).collect(); - // File contains: GoPackage, Struct, StructMethod; Struct contains: Field - assert!(contains.len() >= 4, "should have Contains edges: {:?}", contains.len()); -} - -#[test] -fn test_go_qualified_names() { - let source = r#"package server - -func HandleRequest() {} -"#; - let extractor = GoExtractor; - let result = extractor.extract("pkg/server/handler.go", source); - let fns: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Function).collect(); - assert_eq!(fns.len(), 1); - assert!(fns[0].qualified_name.contains("HandleRequest")); - assert!(fns[0].qualified_name.contains("handler.go")); -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cargo test --test go_extraction_test` -Expected: FAIL. Go extractor is a stub - -**Step 3: Implement the full Go extractor** - -Replace `src/extraction/go_extractor.rs` with the full implementation. The implementation follows the same `ExtractionState` pattern as `RustExtractor`: - -- `parse_source()` uses `tree_sitter_go::LANGUAGE` -- `visit_node()` dispatches on tree-sitter Go node kinds: - - `package_clause` → `GoPackage` - - `function_declaration` → `Function` - - `method_declaration` → `StructMethod` + `Receives` edge - - `type_declaration` → dispatches on child type spec: - - `struct_type` → `Struct` with `Field` children (with `StructTag`) - - `interface_type` → `InterfaceType` with embedded interface `Extends` edges - - type alias (has `=`) → `TypeAlias` - - `import_declaration` → `Use` nodes (one per import spec) - - `const_declaration` → `Const` nodes - - `var_declaration` → `Static` nodes -- Visibility: first character uppercase → `Pub`, lowercase → `Private` -- Doc comments: collect `comment` nodes preceding declarations -- Signatures: text from start to `{` -- Call sites: scan for `call_expression` and `selector_expression` calls -- Generics: `type_parameter_list` → `GenericParam` nodes - -**Step 4: Run tests to verify they pass** - -Run: `cargo test --test go_extraction_test` -Expected: PASS - -**Step 5: Run full test suite** - -Run: `cargo test` -Expected: PASS, no regressions - -**Step 6: Commit** - -```bash -git add src/extraction/go_extractor.rs tests/go_extraction_test.rs -git commit -m "feat: implement Go extractor with deep extraction support" -``` - ---- - -### Task 6: Implement Java extractor - -**Files:** -- Modify: `src/extraction/java_extractor.rs` -- Test: `tests/java_extraction_test.rs` (new) - -**Step 1: Write tests for Java extraction** - -Create `tests/java_extraction_test.rs`: - -```rust -use codegraph::extraction::JavaExtractor; -use codegraph::extraction::LanguageExtractor; -use codegraph::types::*; - -#[test] -fn test_java_extract_package() { - let source = r#"package com.example.app; - -public class Main { - public static void main(String[] args) {} -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("src/Main.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let pkgs: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Package).collect(); - assert_eq!(pkgs.len(), 1); - assert_eq!(pkgs[0].name, "com.example.app"); -} - -#[test] -fn test_java_extract_class() { - let source = r#"package com.example; - -/** - * A simple calculator. - */ -public class Calculator { - public int add(int a, int b) { - return a + b; - } -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Calculator.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let classes: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Class).collect(); - assert_eq!(classes.len(), 1); - assert_eq!(classes[0].name, "Calculator"); - assert_eq!(classes[0].visibility, Visibility::Pub); - assert!(classes[0].docstring.as_ref().unwrap().contains("simple calculator")); -} - -#[test] -fn test_java_extract_methods() { - let source = r#" -public class Foo { - public void doSomething() {} - private int compute(int x) { return x * 2; } - protected String getName() { return "foo"; } -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Foo.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let methods: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Method).collect(); - assert_eq!(methods.len(), 3); - let do_something = methods.iter().find(|m| m.name == "doSomething").unwrap(); - assert_eq!(do_something.visibility, Visibility::Pub); - let compute = methods.iter().find(|m| m.name == "compute").unwrap(); - assert_eq!(compute.visibility, Visibility::Private); - let get_name = methods.iter().find(|m| m.name == "getName").unwrap(); - assert_eq!(get_name.visibility, Visibility::PubCrate); // protected maps to PubCrate -} - -#[test] -fn test_java_extract_constructor() { - let source = r#" -public class Person { - private String name; - public Person(String name) { - this.name = name; - } -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Person.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let constructors: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Constructor).collect(); - assert_eq!(constructors.len(), 1); - assert_eq!(constructors[0].name, "Person"); -} - -#[test] -fn test_java_extract_interface() { - let source = r#" -public interface Drawable { - void draw(); - double area(); -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Drawable.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let ifaces: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Interface).collect(); - assert_eq!(ifaces.len(), 1); - assert_eq!(ifaces[0].name, "Drawable"); - let methods: Vec<_> = result.nodes.iter() - .filter(|n| n.kind == NodeKind::Method || n.kind == NodeKind::AbstractMethod) - .collect(); - assert_eq!(methods.len(), 2); -} - -#[test] -fn test_java_extract_enum() { - let source = r#" -public enum Color { - RED, - GREEN, - BLUE -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Color.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let enums: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Enum).collect(); - assert_eq!(enums.len(), 1); - let variants: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::EnumVariant).collect(); - assert_eq!(variants.len(), 3); -} - -#[test] -fn test_java_extract_fields() { - let source = r#" -public class Config { - public static final int MAX_SIZE = 1024; - private String name; - protected int port; -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Config.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let fields: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Field).collect(); - assert_eq!(fields.len(), 3); - let max_size = fields.iter().find(|f| f.name == "MAX_SIZE").unwrap(); - assert_eq!(max_size.visibility, Visibility::Pub); -} - -#[test] -fn test_java_extract_imports() { - let source = r#" -import java.util.List; -import java.util.Map; -import static java.lang.Math.PI; - -public class Foo {} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Foo.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let uses: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Use).collect(); - assert_eq!(uses.len(), 3); -} - -#[test] -fn test_java_extract_extends_implements() { - let source = r#" -interface Runnable { void run(); } -class Base {} -class Worker extends Base implements Runnable { - public void run() {} -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Worker.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let has_extends = result.edges.iter().any(|e| e.kind == EdgeKind::Extends) - || result.unresolved_refs.iter().any(|r| r.reference_kind == EdgeKind::Extends); - assert!(has_extends, "should detect extends"); - let has_implements = result.edges.iter().any(|e| e.kind == EdgeKind::Implements) - || result.unresolved_refs.iter().any(|r| r.reference_kind == EdgeKind::Implements); - assert!(has_implements, "should detect implements"); -} - -#[test] -fn test_java_extract_annotations() { - let source = r#" -import java.lang.Override; - -public class Foo { - @Override - public String toString() { - return "Foo"; - } - - @Deprecated - public void oldMethod() {} -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Foo.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let annots: Vec<_> = result.nodes.iter() - .filter(|n| n.kind == NodeKind::AnnotationUsage) - .collect(); - assert!(annots.len() >= 2, "should extract annotation usages"); - let has_annotates = result.edges.iter().any(|e| e.kind == EdgeKind::Annotates) - || result.unresolved_refs.iter().any(|r| r.reference_kind == EdgeKind::Annotates); - assert!(has_annotates, "should have Annotates edges"); -} - -#[test] -fn test_java_extract_inner_class() { - let source = r#" -public class Outer { - public class Inner { - public void innerMethod() {} - } -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Outer.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let inners: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::InnerClass).collect(); - assert_eq!(inners.len(), 1); - assert_eq!(inners[0].name, "Inner"); -} - -#[test] -fn test_java_extract_static_init_block() { - let source = r#" -public class Registry { - private static Map cache; - static { - cache = new HashMap<>(); - } -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Registry.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let init_blocks: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::InitBlock).collect(); - assert_eq!(init_blocks.len(), 1); -} - -#[test] -fn test_java_extract_abstract_method() { - let source = r#" -public abstract class Shape { - public abstract double area(); - public void describe() { System.out.println("shape"); } -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Shape.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let abstract_methods: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::AbstractMethod).collect(); - assert_eq!(abstract_methods.len(), 1); - assert_eq!(abstract_methods[0].name, "area"); -} - -#[test] -fn test_java_extract_generics() { - let source = r#" -public class Box { - private T value; - public T getValue() { return value; } -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Box.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let generics: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::GenericParam).collect(); - assert!(generics.len() >= 1, "should extract generic type param T"); -} - -#[test] -fn test_java_extract_call_sites() { - let source = r#" -public class App { - public void run() { - System.out.println("hello"); - helper(); - new ArrayList<>(); - } - private void helper() {} -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("App.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let call_refs: Vec<_> = result.unresolved_refs.iter() - .filter(|r| r.reference_kind == EdgeKind::Calls) - .collect(); - assert!(!call_refs.is_empty(), "should have call refs"); -} - -#[test] -fn test_java_extract_annotation_type() { - let source = r#" -public @interface MyAnnotation { - String value(); -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("MyAnnotation.java", source); - assert!(result.errors.is_empty(), "errors: {:?}", result.errors); - let annots: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Annotation).collect(); - assert_eq!(annots.len(), 1); - assert_eq!(annots[0].name, "MyAnnotation"); -} - -#[test] -fn test_java_file_node_is_root() { - let source = "public class Main {}"; - let extractor = JavaExtractor; - let result = extractor.extract("src/Main.java", source); - let files: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::File).collect(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].name, "src/Main.java"); -} - -#[test] -fn test_java_contains_edges() { - let source = r#" -public class Foo { - private int x; - public void bar() {} -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("Foo.java", source); - let contains: Vec<_> = result.edges.iter().filter(|e| e.kind == EdgeKind::Contains).collect(); - // File contains: Class; Class contains: Field, Method - assert!(contains.len() >= 3, "should have Contains edges: {}", contains.len()); -} - -#[test] -fn test_java_qualified_names() { - let source = r#" -package com.example; - -public class App { - public void run() {} -} -"#; - let extractor = JavaExtractor; - let result = extractor.extract("src/App.java", source); - let methods: Vec<_> = result.nodes.iter().filter(|n| n.kind == NodeKind::Method).collect(); - assert_eq!(methods.len(), 1); - assert!(methods[0].qualified_name.contains("App")); - assert!(methods[0].qualified_name.contains("run")); -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cargo test --test java_extraction_test` -Expected: FAIL. Java extractor is a stub - -**Step 3: Implement the full Java extractor** - -Replace `src/extraction/java_extractor.rs` with the full implementation. Same `ExtractionState` pattern: - -- `parse_source()` uses `tree_sitter_java::LANGUAGE` -- `visit_node()` dispatches on tree-sitter Java node kinds: - - `package_declaration` → `Package` - - `class_declaration` → `Class` (or `InnerClass` when nested inside another class) - - `interface_declaration` → `Interface` - - `enum_declaration` → `Enum` + `EnumVariant` children - - `annotation_type_declaration` → `Annotation` - - `constructor_declaration` → `Constructor` - - `method_declaration` → `Method` or `AbstractMethod` (detect `abstract` modifier) - - `field_declaration` → `Field` (one per variable declarator) - - `import_declaration` → `Use` (detect `static` keyword) - - `static_initializer` → `InitBlock` - - `marker_annotation` / `annotation` → `AnnotationUsage` + `Annotates` edge -- Visibility: scan for `modifiers` child → `public`=`Pub`, `protected`=`PubCrate`, `private`=`Private`, none=`Private` -- Doc comments: `block_comment` starting with `/**` preceding declarations -- Signatures: text from declaration start to `{` -- Call sites: `method_invocation`, `object_creation_expression` -- `extends`/`implements` from `superclass`/`interfaces` fields → `Extends`/`Implements` edges -- Generics: `type_parameters` → `GenericParam` nodes - -**Step 4: Run tests to verify they pass** - -Run: `cargo test --test java_extraction_test` -Expected: PASS - -**Step 5: Run full test suite** - -Run: `cargo test` -Expected: PASS, no regressions - -**Step 6: Commit** - -```bash -git add src/extraction/java_extractor.rs tests/java_extraction_test.rs -git commit -m "feat: implement Java extractor with deep extraction support" -``` - ---- - -### Task 7: Update resolver for new callable kinds - -**Files:** -- Modify: `src/resolution/resolver.rs` -- Test: `tests/resolution_test.rs` - -**Step 1: Update the `find_best_match` scoring** - -In `src/resolution/resolver.rs`, in `find_best_match()`, the callable kind bonus check (line ~206) currently only checks `Function` and `Method`. Add the new callable kinds: - -Replace: -```rust -if uref.reference_kind == EdgeKind::Calls - && (node.kind == NodeKind::Function || node.kind == NodeKind::Method) -{ - score += 25; -} -``` - -With: -```rust -if uref.reference_kind == EdgeKind::Calls - && matches!( - node.kind, - NodeKind::Function - | NodeKind::Method - | NodeKind::StructMethod - | NodeKind::Constructor - | NodeKind::AbstractMethod - ) -{ - score += 25; -} -``` - -**Step 2: Run tests** - -Run: `cargo test` -Expected: PASS, all tests pass including resolution tests - -**Step 3: Commit** - -```bash -git add src/resolution/resolver.rs -git commit -m "feat: update resolver scoring for Go/Java callable kinds" -``` - ---- - -### Task 8: Run clippy and final verification - -**Step 1: Run clippy** - -Run: `cargo clippy --all` -Expected: No warnings (fix any that appear) - -**Step 2: Run fmt** - -Run: `cargo fmt --all` - -**Step 3: Run full test suite** - -Run: `cargo test` -Expected: All tests pass - -**Step 4: Commit any fixes** - -```bash -git add -A -git commit -m "chore: clippy and fmt fixes" -``` - ---- - -## Summary - -| Task | Description | Key Files | -|------|-------------|-----------| -| 1 | Add tree-sitter deps | `Cargo.toml` | -| 2 | Expand NodeKind/EdgeKind | `src/types.rs` | -| 3 | Create trait + registry + stubs | `src/extraction/mod.rs`, `*_extractor.rs` | -| 4 | Integrate registry into CodeGraph | `src/codegraph.rs`, `src/config.rs` | -| 5 | Implement Go extractor (full) | `src/extraction/go_extractor.rs` | -| 6 | Implement Java extractor (full) | `src/extraction/java_extractor.rs` | -| 7 | Update resolver scoring | `src/resolution/resolver.rs` | -| 8 | Clippy + fmt + final verification | All files | diff --git a/docs/plans/2026-02-26-multi-language-support-design.md b/docs/plans/2026-02-26-multi-language-support-design.md deleted file mode 100644 index dcca32a4f5..0000000000 --- a/docs/plans/2026-02-26-multi-language-support-design.md +++ /dev/null @@ -1,158 +0,0 @@ -# Multi-Language Support Design: Go & Java - -> **Archived record, not implementation authority.** This document preserves -> historical intent and evidence. Current requirements come only from the -> `docs/plans/tracedecay-v2/` hierarchy. Exact tests and counts, source-string -> checks, branch/commit/worktree choreography, snapshots, receipts, -> attestations, PR packets, and gate matrices below are not rebuild -> instructions; validate current parser, runtime, and product behavior directly. - -**Date:** 2026-02-26 -**Status:** Approved - -## Overview - -Add Go and Java language support to codegraph, which currently only supports Rust. This requires: -1. An abstraction layer (trait + registry) to make language support pluggable -2. Expanded NodeKind/EdgeKind enums for language-specific constructs -3. Two new extractors: Go and Java (deep extraction) -4. Config and integration changes - -## 1. Extractor Trait & Language Registry - -### Trait Definition (`src/extraction/mod.rs`) - -```rust -pub trait LanguageExtractor: Send + Sync { - fn extensions(&self) -> &[&str]; - fn language_name(&self) -> &str; - fn extract(&self, file_path: &str, source: &str) -> Result; -} -``` - -### Language Registry - -```rust -pub struct LanguageRegistry { - extractors: Vec>, -} - -impl LanguageRegistry { - pub fn new() -> Self { /* register all extractors */ } - pub fn extractor_for_file(&self, path: &str) -> Option<&dyn LanguageExtractor>; - pub fn supported_extensions(&self) -> Vec<&str>; -} -``` - -- `CodeGraph` owns a `LanguageRegistry` -- `scan_files()` uses `registry.supported_extensions()` for include patterns -- `index_all()`/`sync()` call `registry.extractor_for_file(path)` -- `RustExtractor` implements `LanguageExtractor` - -## 2. Expanded NodeKind Enum - -### New Java variants: -- `Class`, class declarations -- `Interface`, interface declarations -- `Constructor`, constructor methods -- `Annotation`, annotation types (`@interface`) -- `AnnotationUsage`, annotation applications (`@Override`) -- `Package`, package declarations -- `InnerClass`, nested/inner classes -- `InitBlock`, static/instance initializer blocks -- `AbstractMethod`, abstract method declarations - -### New Go variants: -- `InterfaceType`. Go interface type definitions -- `StructMethod`, methods with receivers -- `GoPackage`. Go package declaration -- `StructTag`, struct field tags - -### Shared: -- `GenericParam`, type parameters - -### New EdgeKind variants: -- `Extends`. Java class inheritance, Go interface embedding -- `Annotates`, annotation → target -- `Receives`. Go method receiver type link - -## 3. Go Extractor (`src/extraction/go_extractor.rs`) - -Uses `tree-sitter-go`. - -### Declarations: -- `package_clause` → `GoPackage` -- `type_declaration` → `struct_type` → `Struct` + `Field` (with `StructTag`) -- `type_declaration` → `interface_type` → `InterfaceType` + method specs -- `function_declaration` → `Function` -- `method_declaration` → `StructMethod` (with `Receives` edge to receiver type) -- `const_declaration` / `var_declaration` → `Const` / `Static` -- `type_alias` → `TypeAlias` -- `import_declaration` → `Use` nodes - -### Edges: -- `Contains`, package → types → methods/fields -- `Calls`, scan bodies for `call_expression`, `selector_expression` -- `Receives`, method → receiver type -- `Uses`, import references -- `Extends`, interface embedding - -### Deep features: -- Generic type params → `GenericParam` nodes -- Doc comments (`//` preceding declarations) -- Visibility: uppercase = `Pub`, lowercase = `Private` -- Signatures: full function signature text -- Init functions: `func init()` detected - -## 4. Java Extractor (`src/extraction/java_extractor.rs`) - -Uses `tree-sitter-java`. - -### Declarations: -- `package_declaration` → `Package` -- `class_declaration` → `Class` or `InnerClass` (when nested) -- `interface_declaration` → `Interface` -- `enum_declaration` → `Enum` + `EnumVariant` -- `annotation_type_declaration` → `Annotation` -- `constructor_declaration` → `Constructor` -- `method_declaration` → `Method` or `AbstractMethod` -- `field_declaration` → `Field` -- `import_declaration` → `Use` (static imports flagged) -- `static_initializer` / `instance_initializer` → `InitBlock` - -### Edges: -- `Contains`, package → class → method/field, class → inner class -- `Calls`, scan bodies for `method_invocation`, `object_creation_expression` -- `Implements`, from `implements` clause -- `Extends`, from `extends` clause -- `Annotates`, `AnnotationUsage` → annotated element -- `Uses`, import references - -### Deep features: -- `GenericParam`, type parameters on classes/methods -- Annotations: `marker_annotation`, `annotation` → `AnnotationUsage` -- Visibility: `public` → `Pub`, `protected` → `PubCrate`, `private` → `Private` -- Doc comments: Javadoc `/** */` -- Signatures: method/constructor signature text -- Modifiers: `static`, `final`, `abstract`, `synchronized` captured in signature - -## 5. Integration & Config Changes - -### Config (`config.rs`): -- Default `include`: `["**/*.rs", "**/*.go", "**/*.java"]` -- Default `exclude` adds: `["vendor/**", "bin/**", "build/**", "out/**", ".gradle/**"]` - -### `codegraph.rs`: -- `CodeGraph::new()` creates `LanguageRegistry` -- `scan_files()` uses registry extensions -- `index_all()`/`sync()` use `registry.extractor_for_file()` - -### `Cargo.toml`: -- Add `tree-sitter-go` and `tree-sitter-java` dependencies - -### Resolver: -- Mostly language-agnostic already -- Qualified name separator stays `::` across all languages - -### DB Schema: -- No changes needed. NodeKind/EdgeKind stored as strings diff --git a/docs/plans/2026-07-01-macos-launchd-daemon-support.md b/docs/plans/2026-07-01-macos-launchd-daemon-support.md deleted file mode 100644 index 0b689af8ca..0000000000 --- a/docs/plans/2026-07-01-macos-launchd-daemon-support.md +++ /dev/null @@ -1,544 +0,0 @@ -# Final Plan: macOS launchd Support for the TraceDecay Daemon - -> **Archived record, not implementation authority.** This document preserves -> historical intent and evidence. Current requirements come only from the -> `docs/plans/tracedecay-v2/` hierarchy. Exact tests and counts, source-string -> checks, branch/commit/worktree choreography, snapshots, receipts, -> attestations, PR packets, and gate matrices below are not rebuild -> instructions; validate current parser, runtime, and product behavior directly. - -**Status:** Final implementation plan -**Target branch:** `main` -**Scope:** `src/daemon.rs`, focused tests, README / user guide / security docs -**Outcome:** macOS gets the same user-facing daemon service support Linux already -has: `tracedecay daemon install-service`, `uninstall-service`, `status`, and -post-update service refresh work for a per-user background daemon. - ---- - -## 1. Goal - -Make the existing Linux daemon-service workflow work on macOS with native -launchd: - -```bash -tracedecay daemon install-service -tracedecay daemon status -tracedecay daemon uninstall-service -tracedecay update -``` - -Today macOS fails because the service layer always goes through -`systemd_user_service_path()` in `src/daemon.rs`, which hard-errors outside -Linux: - -> daemon service install is currently supported on Linux systemd user services - -After this change, macOS users can install TraceDecay as a per-user LaunchAgent -that starts at GUI login, restarts on failure, serves the same Unix socket -daemon as `tracedecay daemon run`, and is refreshed by `tracedecay update`. - -**Linux parity means OS-managed daemon process parity.** The macOS service should -match what Linux systemd support provides today: - -- write the service definition; -- start and enable it when requested; -- preserve a previously installed custom socket path during refresh; -- stop/disable/remove it during uninstall; -- report service path, socket reachability, and useful log/service commands; -- refresh the installed service after an update. - -## 2. Non-goals - -- **Windows service support.** Windows remains on the existing non-Unix fallback. -- **Auto-installing the daemon from `install --agent X`.** Linux does not do this - either; users explicitly opt into the OS service with `daemon install-service`. -- **Persisted project scheduler registry.** The daemon scheduler is currently - seeded when project clients connect and send a `DaemonHandshake`. This plan - does not add a boot-time registry of projects to resume before any client has - connected. That would be beyond Linux parity and should be a separate design. -- **Changing storage roots.** macOS continues to use the current TraceDecay - `user_data_dir()` behavior (`~/.tracedecay` unless `TRACEDECAY_DATA_DIR` is - set). Do not silently move daemon sockets or logs to - `~/Library/Application Support`. - -## 3. Existing Code Shape - -The current service API is already narrow enough for a clean platform dispatch. - -| Function | Current behavior | -|---|---| -| `install_service(spec, start)` | writes systemd unit, optionally `daemon-reload` + `enable --now` | -| `refresh_service(spec)` | rewrites systemd unit, `daemon-reload`, `enable`, `restart` | -| `refresh_installed_service(spec)` | skips missing unit, preserves installed socket path, refreshes | -| `uninstall_service(stop)` | optionally `disable --now`, removes unit, `daemon-reload` | -| `installed_service_socket_path()` | reads installed unit and parses `--socket` | -| `service_status(socket_path)` | prints service path, socket state, log command | - -Every one of those paths currently depends on `systemd_user_service_path()`. -That is the right seam to replace with platform dispatch. - -The daemon engine itself is already usable on macOS: - -- `run_foreground_unix` binds a Unix socket and handles SIGTERM; -- `notify_hook_event` has a Unix implementation; -- the scheduler code is Unix-gated, not Linux-specific; -- client handshake/profile handling is independent of systemd. - -## 4. Architecture Decision - -Keep the public Rust API signature-compatible and add private platform service -helpers inside `src/daemon.rs`. - -```text -Public API - install_service - refresh_service - refresh_installed_service - uninstall_service - installed_service_socket_path - service_status - | - v -ServiceRunner::current() - | - +-- Linux -> systemd user service - +-- macOS -> launchd per-user LaunchAgent - +-- other -> existing unsupported-service error -``` - -Use an enum, not traits, because there are only two supported backends and the -implementation is private: - -```rust -enum ServiceRunner { - Systemd, - Launchd, -} -``` - -The pure rendering/parsing helpers must remain unit-testable on any platform. -The process-control helpers are platform-gated and tested with fake command -runners where possible. - -## 5. macOS launchd Behavior - -Use modern launchd domain commands. Do **not** use legacy `launchctl load` / -`unload` for the implementation because they hide many errors and are explicitly -documented as legacy on current macOS. - -### 5.1 LaunchAgent identity - -```rust -const LAUNCHD_LABEL: &str = "com.tracedecay.daemon"; -const LAUNCHD_PLIST_NAME: &str = "com.tracedecay.daemon.plist"; -``` - -Paths: - -| Item | macOS path | -|---|---| -| LaunchAgent plist | `~/Library/LaunchAgents/com.tracedecay.daemon.plist` | -| socket | `/daemon.sock` unless `--socket` overrides | -| stdout log | `/daemon.out.log` | -| stderr log | `/daemon.err.log` | - -The plist path follows macOS convention. The socket/log paths follow existing -TraceDecay storage behavior for parity with the current daemon code. - -### 5.2 Launchctl domain helpers - -Add: - -```rust -#[cfg(target_os = "macos")] -fn launchd_domain() -> Result; // "gui/" - -#[cfg(target_os = "macos")] -fn launchd_service_target() -> Result; // "gui//com.tracedecay.daemon" - -#[cfg(target_os = "macos")] -fn run_launchctl(args: &[&str]) -> Result; -``` - -`run_launchctl` should capture stdout/stderr and include both in errors. Keep the -shape close to `run_systemctl`, but return output for status checks. - -Use `gui/` because this is a per-user LaunchAgent that should start at GUI -login. If a future headless/background-user mode is needed, that should be a -separate option. - -### 5.3 Service-control mapping - -| Operation | Linux systemd | macOS launchd | -|---|---|---| -| install with start | `daemon-reload`; `enable --now tracedecay.service` | write plist; `bootout gui//com.tracedecay.daemon` (tolerating not-loaded, for idempotent re-install); `enable ...`; `bootstrap gui/ `; `kickstart -k ...` | -| install with `--no-start` | write unit only | write plist; `disable gui//com.tracedecay.daemon` so launchd does not autostart the agent at the next login | -| refresh | write unit; `daemon-reload`; `enable`; `restart` | write plist; `bootout gui//com.tracedecay.daemon` (tolerating not-loaded); `enable ...`; `bootstrap gui/ `; `kickstart -k ...` | -| uninstall with stop | `disable --now`; remove unit; `daemon-reload` | `bootout gui//com.tracedecay.daemon` if loaded; `disable gui//com.tracedecay.daemon`; remove plist | -| uninstall with `--no-stop` | remove unit only | remove plist only | -| status | unit path + socket + journald hint | plist path + socket + `launchctl print` / log hints | - -Implementation details: - -- Treat "not bootstrapped/not found" during `bootout` as non-fatal for uninstall - and refresh, just like the Linux uninstall ignores failed `disable --now`. -- After install/refresh with start, verify either the socket becomes connectable - briefly or `launchctl print ` succeeds. This catches command - failures that otherwise appear only in logs. -- Do not call `bootstrap` or `kickstart` for `--no-start`; persist a `disable` - instead so the plist in `~/Library/LaunchAgents` stays inert at login. - -## 6. Plist Rendering - -Add: - -```rust -impl DaemonServiceSpec { - pub fn render_launchd_plist(&self) -> Result; -} -``` - -The plist: - -```xml - - - - - Label - com.tracedecay.daemon - - ProgramArguments - - {absolute_tracedecay_bin} - daemon - run - --socket - {socket_path} - - - EnvironmentVariables - - PATH - {daemon_service_path_env(bin)} - HOME - {home} - - - RunAtLoad - - - KeepAlive - - SuccessfulExit - - - - ThrottleInterval - 2 - - StandardOutPath - {user_data_dir}/daemon.out.log - - StandardErrorPath - {user_data_dir}/daemon.err.log - - -``` - -Renderer requirements: - -- XML-escape `&`, `<`, `>`, `"`, and `'`. -- Require an absolute binary path for launchd. `which_tracedecay()` should already - produce one in normal installs; error clearly if it does not. -- Include `TRACEDECAY_DATA_DIR` in `EnvironmentVariables` when it is set during - install. This preserves custom profile roots across launchd restarts. -- Create the log/socket data directory before bootstrapping, because launchd can - create log files but cannot create missing parent directories. -- Set LaunchAgent plist permissions explicitly after writing. Use at most `0644` - and avoid group/world-writable files. - -## 7. Plist Parsing - -Add: - -```rust -fn socket_path_from_launchd_plist(plist: &str) -> Option; -``` - -Minimum acceptable parser: - -1. find the `ProgramArguments` array; -2. collect `...` values in order; -3. XML-unescape those string values; -4. return the value after `--socket`, or the value from `--socket=...` if ever - emitted in the future. - -Do not return escaped XML text. Existing custom socket preservation depends on -this parser during `refresh_installed_service`. - -If adding a small plist parsing dependency is acceptable, prefer a real plist -parser. If not, keep the ad hoc parser tightly scoped and heavily tested. - -## 8. `src/daemon.rs` Changes - -### 8.1 Platform path helpers - -Replace internal calls to `systemd_user_service_path()` with: - -```rust -fn service_unit_path() -> Result; -``` - -Behavior: - -- Linux: existing `~/.config/systemd/user/tracedecay.service`; -- macOS: `~/Library/LaunchAgents/com.tracedecay.daemon.plist`; -- other: service install unsupported. - -Keep `systemd_user_service_path()` as a private Linux helper. - -### 8.2 Render/parse dispatch - -Add: - -```rust -impl DaemonServiceSpec { - fn render_unit(&self) -> Result; -} - -fn socket_path_from_unit_text(text: &str) -> Option; -``` - -Linux dispatches to existing systemd helpers. macOS dispatches to the new plist -helpers. - -### 8.3 ServiceRunner methods - -```rust -impl ServiceRunner { - fn current() -> Result; - fn install(&self, service_path: &Path, start: bool, socket_path: &Path) -> Result<()>; - fn refresh(&self, service_path: &Path, socket_path: &Path) -> Result<()>; - fn uninstall(&self, service_path: &Path, stop: bool) -> Result<()>; - fn log_hint(&self) -> String; - fn service_detail_hint(&self) -> Option; -} -``` - -Use `socket_path` only for optional post-start verification. Keep the public API -signatures unchanged. - -### 8.4 Public function rewiring - -Refactor: - -- `install_service` -- `refresh_service` -- `refresh_installed_service` -- `write_service_unit` -- `installed_service_socket_path` -- `service_socket_path_from_unit_file` -- `uninstall_service` -- `service_status` - -The public surface remains unchanged. Only internal platform dispatch changes. - -### 8.5 Status output - -Keep status stable and useful: - -```text -service: /Users/you/Library/LaunchAgents/com.tracedecay.daemon.plist -socket: /Users/you/.tracedecay/daemon.sock (connectable) -service-detail: launchctl print gui/501/com.tracedecay.daemon -logs: tail -f "/Users/you/.tracedecay/daemon.err.log" -``` - -For Linux, keep the existing journald hint. - -Do not make status depend on parsing unstable `launchctl print` output. It is -fine to include the command as a diagnostic hint. If the implementation probes -service load state, treat it as best-effort. - -## 9. Docs - -Update all docs that currently describe daemon support as Linux-only or absent: - -- `README.md` - - daemon debugging section: show Linux and macOS commands; - - CLI reference: remove "Linux systemd" qualifier from `daemon install-service`; - - mention macOS logs under `/daemon.err.log`. -- `docs/USER-GUIDE.md` - - add macOS daemon setup under the install or keeping-fresh flow; - - show install, status, uninstall. -- `SECURITY.md` - - replace the current "No background daemon" statement with an accurate - opt-in model: no daemon is installed by default, but users can explicitly - install a per-user systemd/launchd service that runs with standard user - privileges. - -## 10. Tests - -### 10.1 Ungated unit tests - -These run on every platform: - -- `render_launchd_plist_includes_label_program_arguments_socket_and_logs` -- `render_launchd_plist_escapes_xml_special_characters` -- `render_launchd_plist_includes_trace_decay_data_dir_when_set` -- `socket_path_from_launchd_plist_round_trips_rendered_socket` -- `socket_path_from_launchd_plist_unescapes_xml` -- `socket_path_from_launchd_plist_returns_none_for_malformed_input` -- `service_unit_path_unsupported_platform_error_mentions_service_install` - if practical to test via helper injection. - -### 10.2 Linux regression tests - -Existing Linux tests must keep passing: - -- `user_service_runs_daemon_with_socket_path` -- `refresh_service_rewrites_unit_and_restarts_daemon` -- `refresh_installed_service_skips_missing_unit` -- `refresh_installed_service_preserves_existing_socket_path` - -If the systemd renderer signature changes to return `Result`, update the -tests mechanically without changing expected Linux output. - -### 10.3 macOS command tests without real launchd - -Add tests around command planning/fake command runner, not real `launchctl`: - -- install with start plans `bootout` (tolerated), `enable`, `bootstrap`, - `kickstart`; -- install with `--no-start` writes the plist and disables the agent; -- refresh preserves existing socket path and plans `bootout` (tolerated), - `enable`, `bootstrap`, `kickstart`; -- uninstall with stop plans `bootout`, `disable`, remove plist; -- uninstall with `--no-stop` removes plist only. - -These should not require root, a GUI session, or a real LaunchAgent. - -### 10.4 Optional ignored macOS smoke test - -One ignored/manual test is acceptable, but it must avoid clobbering a user's real -daemon: - -- use a test-only label like `com.tracedecay.daemon.test.`; -- write to a temporary plist path; -- use a temporary `TRACEDECAY_DATA_DIR`; -- always attempt cleanup with `bootout` and file removal. - -Do not use `com.tracedecay.daemon` in ignored tests. - -## 11. Risks and Decisions - -| Risk | Decision | -|---|---| -| legacy `launchctl load/unload` masks failures | use `bootstrap/bootout/enable/kickstart` | -| plist parent/log parent missing | create LaunchAgents dir and data dir before bootstrap | -| plist rejected due to permissions | set plist permissions explicitly | -| custom `TRACEDECAY_DATA_DIR` lost under launchd | persist it into plist env when set | -| custom socket path lost on update | parse plist and preserve existing `--socket` during refresh | -| status overpromises service state | show socket state and diagnostic launchctl command; probe best-effort only | -| scheduler expectations after reboot | document Linux parity: daemon starts at login; project schedulers start after project handshake | -| Homebrew binary path changes | `tracedecay update` refreshes plist with current binary path | -| log growth | same operational class as journald, but file rotation is a follow-up | - -## 12. Implementation Order - -1. **Pure service dispatch refactor** - - add `ServiceRunner`; - - add `service_unit_path`; - - add render/parse dispatch helpers; - - keep Linux behavior identical; - - run existing daemon tests. - -2. **Add launchd plist renderer/parser** - - add XML escape/unescape; - - add data-dir/log path handling; - - add ungated parser/renderer tests. - -3. **Add macOS launchctl backend** - - implement domain/target helpers; - - implement `bootstrap`, `bootout`, `enable`, `disable`, `kickstart`; - - create required directories and set plist permissions; - - add fake-command tests. - -4. **Wire refresh/uninstall/status** - - preserve existing socket paths; - - add macOS log and `launchctl print` hints; - - keep public API and CLI unchanged. - -5. **Docs** - - README; - - user guide; - - SECURITY.md. - -6. **Manual macOS verification** - - run on a real macOS GUI session; - - verify install/start/status/refresh/uninstall; - - verify reboot/login start. - -## 13. Verification - -Automated: - -```bash -cargo nextest run -p tracedecay daemon -cargo test -p tracedecay daemon_install_service_command_parses_socket_and_no_start -``` - -Manual macOS: - -```bash -tracedecay daemon install-service -tracedecay daemon status -launchctl print "gui/$(id -u)/com.tracedecay.daemon" -tail -f ~/.tracedecay/daemon.err.log -tracedecay update -tracedecay daemon uninstall-service -``` - -Expected: - -- plist exists at `~/Library/LaunchAgents/com.tracedecay.daemon.plist`; -- socket reports connectable after install/start; -- `launchctl print gui/$(id -u)/com.tracedecay.daemon` succeeds while installed; -- killing the daemon process causes launchd to restart it; -- after reboot and GUI login, launchd starts the daemon; -- `tracedecay update` refreshes plist binary path and keeps the installed socket - path; -- uninstall removes the plist and the launchd job. - -Scheduler verification, matching current Linux behavior: - -1. enable scheduler config for a project; -2. connect a project client through the daemon once; -3. verify `event=scheduler_tick` and task logs in `daemon.err.log`; -4. restart the daemon and reconnect the project client; -5. verify scheduler starts again. - -Do not require scheduler ticks immediately after reboot before any project -client has connected; that is not Linux parity. - -## 14. Source References - -| Topic | File:line | -|---|---| -| Linux-only service path gate | `src/daemon.rs:1715 systemd_user_service_path` | -| systemd unit renderer | `src/daemon.rs:154 render_systemd_user_unit` | -| public install/refresh/uninstall/status | `src/daemon.rs:455 / :466 / :474 / :540 / :560` | -| socket-path parser | `src/daemon.rs:523 socket_path_from_service_unit` | -| systemctl runner | `src/daemon.rs:1731 run_systemctl` | -| PATH env helper | `src/daemon.rs:177 daemon_service_path_env` | -| default socket path | `src/daemon.rs:237 default_socket_path` | -| foreground Unix daemon | `src/daemon.rs:988 run_foreground_unix` | -| scheduler starts from project server | `src/daemon.rs:1097 project_server` | -| scheduler config gate | `src/daemon.rs:1421 automation_scheduler_configured` | -| daemon action dispatch | `src/main.rs:742 Commands::Daemon` | -| post-update daemon refresh | `src/main.rs:413 refresh_daemon_service` | -| daemon CLI enum | `src/cli.rs:446 DaemonAction` | -| current security doc conflict | `SECURITY.md:103 No background daemon` | diff --git a/docs/plans/2026-07-04-tool-cli-args-agent-ergonomics.md b/docs/plans/2026-07-04-tool-cli-args-agent-ergonomics.md deleted file mode 100644 index 83af21025e..0000000000 --- a/docs/plans/2026-07-04-tool-cli-args-agent-ergonomics.md +++ /dev/null @@ -1,842 +0,0 @@ -# `tracedecay tool` CLI arguments, reimagined for the AI-agent consumer - -> **Archived record, not implementation authority.** This document preserves -> historical intent and evidence. Current requirements come only from the -> `docs/plans/tracedecay-v2/` hierarchy. Exact tests and counts, source-string -> checks, branch/commit/worktree choreography, snapshots, receipts, -> attestations, PR packets, and gate matrices below are not rebuild -> instructions; validate current parser, runtime, and product behavior directly. - -Date: 2026-07-04 -Branch: `codex/cli-args-stdin` (PR #286, "Support stdin for tool args") -Status: implemented on `codex/cli-args-stdin`, the branch now includes the -JSON-first CLI contract, validation gate, `--dry-run`, corrective errors, -help/skill/steering updates, per-key repairs, and hermetic eval coverage. - ---- - -## 1. Context and scope - -`tracedecay tool [args...]` invokes any of the ~100 MCP tools from the -shell. Its consumers, in rough order of real traffic: - -1. **AI coding agents** shelling out because MCP is unavailable: the server - errored/timed out/disconnected, the host never configured it, or the - context (subagent, hook, CI script) has shell access but no MCP client. - Every plugin skill, steering hook, and prompt rule routes agents here - (`src/hooks/steering.rs:137-143`, `plugin/skills/using-the-cli/SKILL.md`, - `plugin/rules/tracedecay.mdc:35`, `src/agents/mod.rs:816`, - `src/agents/hermes/templates/skill.md:15`). -2. **Generated machine glue**, the Hermes plugin's `tools.py` - (`src/agents/hermes/templates.rs:233-272`) dispatches every tool call - through this CLI. -3. **Humans** debugging, exploring, or scripting ad hoc. - -PR #286 added stdin support to the `--args` whole-payload escape hatch -(`--args -`, `--args @-`, bare file path, back-compat `@file`), plus per-key -`@path`/`@-` file/stdin values with memoized stdin, and a two-scenario -hermetic eval corpus. That work is committed on this branch -(`src/tool_command.rs:1-30` module doc; `f4af7ce4`, `846081c8`, `14ae43de`). - -This plan steps back and asks the prior question the PR did not: **what -should the agent-facing argument surface be at all?** It evaluates the -abstraction first (Section 2), documents the current mechanics precisely -(Section 3), ranks the observed agent-facing frictions with empirical -evidence from the dev binary (Section 4), proposes the design (Section 5), -specifies the discoverability changes so the taught model matches the parser -(Section 6), defines a detailed eval plan to prove the change with real -Sonnet and Codex sessions (Section 7), and phases the rollout (Section 8). - -Everything below cites the state of this branch at commit `14ae43de`. All -empirical outputs come from this worktree's debug binary run against an -isolated `TRACEDECAY_DATA_DIR` and a throwaway indexed project (Appendix A). - ---- - -## 2. The abstraction question: should agents get a `--key value` surface at all? - -Before polishing the argument parser, decide what the agent-facing contract -is. Four candidate models, evaluated against the code as it exists. - -### 2.0 The 30-second version of how it works today - -`tracedecay tool` is declared in clap as a name plus a raw trailing vector -(`src/cli.rs:101-114`, `trailing_var_arg = true, allow_hyphen_values = -true`), clap does no parsing of tool args. A hand-rolled parser -(`src/tool_command.rs:351-483`) walks the tool's JSON Schema -(`src/mcp/tools/definitions.rs`) to convert `--key value` strings into a -`serde_json::Value` object, with per-type coercion (`coerce_value`, -`src/tool_command.rs:514-560`). The finished object is then handed, -verbatim, to the daemon over a Unix socket as a standard MCP -`tools/call` JSON-RPC request (`dispatch_daemon_tool` → -`call_default_tool` → `call_tool`, `src/daemon.rs:1177-1244`), falling -back to an in-process dispatch of the same registry when the daemon is -down (`src/tool_command.rs:212-267`). - -So the CLI is *already* a thin MCP client. The only thing the arg surface -does is **construct the `params.arguments` JSON object from argv**. That -framing is what makes the abstraction question sharp: the per-key grammar -is a lossy re-encoding of a JSON object into shell words, immediately -decoded back into JSON. - -### 2.1 Option 1, status quo: `--key value` with schema coercion as the agent path - -The taught model everywhere today ("every tool is also a shell command: -`tracedecay tool --key value`"). For an agent this means learning a -second parameter language on top of the MCP schema it already knows: - -- booleans need explicit values (`--include-code true`; bare `--include-code` - eats the next token. Appendix A.3/A.4); -- `--key=value` is not accepted, unlike every clap subcommand in the same - binary (Appendix A.1/A.2); -- arrays are repetition or comma-splitting (`finalize_arrays`, - `src/tool_command.rs:587-617`), which **destroys** inline JSON for - array-of-array/object params (Appendix A.7); -- nested objects and enum constraints are not expressible/validated at all - (`coerce_value` falls through to string, enums pass unchecked, - Appendix A.8); -- unknown keys are silently forwarded and silently ignored by handlers - (Appendix A.6), a typo produces wrong behavior with no error; -- any value containing quotes/newlines is a shell-quoting minefield in the - one-shot command an agent must emit. - -Fixing all of that is possible (Section 5.4 keeps a subset), but the end -state is still: an agent that knows `{"path": ..., "replacements": -[[...]]}` must transliterate it into a different grammar, correctly, in one -shot, under shell quoting. Every rule added to make that grammar richer is -one more rule the agent must have internalized *before* the failure moment -in which it reaches for the CLI. **Rejected as the agent-primary path.** - -### 2.2 Option 2. JSON-first agent path: `--key value` is human sugar; agents pass the MCP arguments object - -Treat the argument surface as two contracts: - -- **Agent contract**: `tracedecay tool --args ` where the - payload **is exactly the MCP `arguments` object** the agent would have - sent over MCP, inline for short quote-free payloads, stdin (`--args -`, - typically a quoted heredoc) or a file for everything else. Nothing new to - learn beyond one sentence, because the agent already knows every tool's - schema; MCP knowledge transfers byte-for-byte. -- **Human contract**: `--key value` (plus positional-required-string - binding) stays for interactive use, where flags genuinely beat typing - JSON. - -Evidence this is the natural machine interface, from this repo itself: - -- The Hermes plugin's generated `tools.py`, the one *programmatic* consumer - of `tracedecay tool` in the codebase, never uses `--key value`. It - serializes the arguments dict and passes `--json --args `, - spilling to `--args @tempfile` above ~100 KB to dodge Linux's 128 KiB - per-argv-string cap (`src/agents/hermes/templates.rs:89-98,260-272`; - pinned by `tests/agent_suite/agent_test.rs:1117-1120`). -- `memory curate --llm-ops ` established the same whole-payload - convention for LLM-constructed JSON (`src/commands.rs:46-66`). -- PR #286 already built the payload plumbing: inline / `-` / bare path / - `@file` / `@-`, memoized stdin (`resolve_args_payload`, - `src/tool_command.rs:491-509`). - -Shell quoting, the classic objection to JSON-on-the-CLI, is solved by the -stdin form with a quoted heredoc, a pattern agents already use daily for -`git commit -m "$(cat <<'EOF' ...)"`: - -```bash -tracedecay tool multi_str_replace --args - <<'JSON' -{"path":"src/lib.rs","replacements":[["old, with 'quotes'","new $body"]]} -JSON -``` - -No escaping, no argv cap, arbitrary newlines, byte-exact MCP parity. -**Recommended.** The rest of this plan is the working-out of this option. - -### 2.3 Option 3, raw daemon/MCP passthrough: the CLI accepts a JSON-RPC frame - -The maximal-parity idea: `tracedecay tool` (or a new `tracedecay rpc`) -reads a whole `tools/call` JSON-RPC request from stdin, forwards it to the -daemon socket, prints the response. Research findings: - -- The daemon already speaks exactly this protocol: a handshake line then - line-delimited JSON-RPC, with `tools/call` handled server-side - (`src/daemon.rs:2145-2184`); `tracedecay serve` proxies full MCP stdio to - it (`proxy_stdio_to_default_daemon`, `src/daemon.rs:1168-1174`; - `src/cli.rs:289-291`). -- But the CLI *already is* this passthrough for the part that carries - information: `call_tool` builds the envelope, `jsonrpc`, `id`, `method: - "tools/call"`, `params.name`, from values the CLI already has - (`src/daemon.rs:1183-1193`). The only agent-authored content in the frame - is `params.arguments`, which is precisely what `--args` transports. -- A raw-frame mode would *add* agent-visible failure modes (wrong method - string, malformed envelope, id bookkeeping) while *removing* things the - CLI quietly does correctly: the handshake carries client identity, - profile root, global-DB path, and project routing - (`DaemonHandshake::for_current_client`, `src/daemon.rs:195-231`; - project resolution in `DaemonToolDispatch::project_scoped`, - `src/tool_command.rs:181-194`), and `dispatch_daemon_tool` falls back to - in-process execution when the daemon socket is absent - (`src/tool_command.rs:269-296`), on Windows there is no socket at all - (`src/daemon.rs` `#[cfg(not(unix))]` stubs). An agent hand-writing frames - would have to replicate an undocumented, version-coupled handshake or - lose all of that. - -Option 3 therefore collapses into Option 2: **passthrough of the -`arguments` object, not the envelope**. The envelope is boilerplate the CLI -should keep owning. Rejected as a separate surface; one nicety survives as -`--dry-run` (Section 5.3), which prints the fully-resolved arguments object -(and optionally the frame) without dispatching, giving scripts and evals -the "show me the request" affordance without a second protocol. - -### 2.4 Option 4, does the agent need this surface at all? - -When does an agent actually land here? - -- **MCP transport failure mid-session**, the steering text injected into - every Codex session names this exact moment and prescribes the CLI - (`src/hooks/steering.rs:137-143`); the `using-the-cli` skill is the - Claude-side equivalent (its trigger description names failures, - timeouts, disconnected/unconfigured servers). -- **Contexts that never had MCP**, subagents and hooks with shell but no - MCP client (`plugin/skills/using-the-cli/SKILL.md:50-52`), CI scripts, - and hosts where tracedecay's MCP server isn't registered. These are - by-design consumers, not failure recovery. -- **Permission-denied MCP** in restricted harness configurations where - `Bash` is allowed but the MCP tool isn't. - -Alternative remedies considered: - -- *Re-establish MCP* (restart server, ToolSearch reload): host-level and - frequently outside the agent's control; the skill already covers the - "deferred but healthy" case separately (`SKILL.md:63-69`). Not a - substitute for the genuinely-no-MCP contexts. -- *`tracedecay serve` as ad hoc MCP*: an agent could spawn it and speak MCP - over stdio, but a one-shot shell tool cannot reasonably hold a - bidirectional initialize/call/shutdown conversation. Impractical. -- *Only expose plumbing (`--print-request`) and let the agent pipe frames - itself*: strictly worse than Option 3 for the same reasons. - -Conclusion: the fallback surface must exist, agents are its primary -consumers, and the **only part of it agents should have to think about is -the arguments object they already know**. The per-key grammar continues to -exist for humans, but every agent-facing document, hook, and error message -should converge on the JSON path. - -### 2.5 Tensions resolved explicitly - -| Tension | Resolution | -|---|---| -| Human vs agent ergonomics | Two documented contracts on one command: `--key value` for humans, `--args` JSON for agents/scripts. Neither is deprecated; they are *taught to different audiences*. | -| MCP parity vs CLI convention | Parity wins for agents: the payload is the MCP `arguments` object, so nothing new to learn. The CLI-only conventions (`--json`, `--project`, `--dry-run`) are transport concerns, not argument concerns. | -| Forgiving vs unambiguous parsing | Per-key parsing stays forgiving for humans but gains a single validation gate (Section 5.2) that turns every silent divergence into a corrective error. JSON path is unambiguous by construction. | -| Back-compat vs one-clean-rule | All currently-working invocations keep working (Section 8). The one intentional break: unknown keys and invalid enum values stop being silently ignored, that silence is the bug. | -| Altitude: schema coercion in the CLI duplicates the MCP layer | Correct diagnosis: `coerce_value` re-derives types the schema already declares, and handlers re-validate (or fail to). The fix is not more coercion but one schema-driven validation pass over the *final JSON object*, shared by both paths, next to the schemas it validates. | - ---- - -## 3. Current state, precisely - -### 3.1 Parse pipeline - -`run()` (`src/tool_command.rs:81-132`): resolve tool name via -`canonical_tool_name` (strip `tracedecay_`, dash→underscore, alias -`query`→`search`; `src/tool_command.rs:49,148-156`) → `parse_invocation` → -help or dispatch. - -`parse_invocation_with_stdin` (`src/tool_command.rs:351-483`), one pass over -the raw arg vector: - -- Reserved flags: `-h/--help` short-circuits; `--json` sets raw output; - `--project` takes a value (`:387-395`). -- `--args ` (`:396-412`): `resolve_args_payload` - (`:491-509`) resolves inline JSON (leading `{`/`[`) verbatim, `-` → - memoized stdin (`:330-349`), `@file`/`@-` via `resolve_at_file`, anything - else as a bare file path. Must parse to a JSON **object**; mutually - exclusive with any other tool flag or positional (`:426-435`). -- Any other `--flag` (`:414-421`): key = kebab→snake, next token is the - value (`take_value`, `:620-624`), `@`-prefixed values are read from - file/stdin (`resolve_at_file`, `:630-642`), then `coerce_value` - (`:514-560`) coerces by schema type: string pass-through; boolean accepts - `true/1/yes/on`/`false/0/no/off` else errors; integer/number parse with - whole-number-stays-integer care; **`array` → returns the raw string**; - **anything else (incl. `object`) → returns the raw string**. Repeated - flags accumulate into an array (`merge_value`, `:569-581`). -- Non-flag tokens are positionals, bound in-order to *required* properties - not already set (`:439-466`); leftovers error. -- Missing required params error (`:468-478`). -- `finalize_arrays` (`:480,587-617`): for every schema property of type - `array` whose collected value is a single string, **split on commas** if - the string contains any, else wrap as a one-element array. No JSON - detection, no `items`-type awareness. - -Notable absences: no `--key=value` handling (the whole token becomes an -unknown key), no unknown-key rejection (missing `prop_schema` just means -"coerce as string and forward"), no enum validation anywhere, no -object-typed value parsing. - -### 3.2 Dispatch - -The parsed object goes to the daemon as MCP `tools/call` -(`src/daemon.rs:1177-1244`), or in-process through the same registry when -the socket is unavailable (`src/tool_command.rs:227-296`). Neither the -daemon (`src/daemon.rs:2181-2184` checks only that `params.name` exists) -nor the handlers validate arguments against the schema; handlers `.get()` -fields and default what's missing. Output: joined `content[*].text` blocks, -or raw JSON with `--json` (`:298-326`). - -### 3.3 Schema surface the parser must cover - -From `src/mcp/tools/definitions.rs` (100 tools listed by the binary): - -- ~23 array-typed params; most are `array` (comma-split works), - but at least four are arrays of arrays/objects: `multi_str_replace. - replacements` (`:1726-1748`, `[[old,new],…]`), and message/query arrays - on `lcm_expand_query` (`:2955`), `lcm_preflight` (`:3009`), - `lcm_compress` (`:3100`). -- 30 `enum` declarations (e.g. `gini.metric` - `["complexity","lines","fan_in","fan_out","members"]`, `:1792-1811`). -- Nested-object params: `project_selector` on `search`/`context`/etc. - (`project_selector_object`, `:946-965`). -- Long multi-line strings: `str_replace.old_str/new_str`, - `replace_symbol.new_source`, `insert_at.content`, `ast_grep_rewrite. - pattern/rewrite` (`:3231-3259`), `diagnose.cargo_output`, - `fact_store` text. - -### 3.4 What the agent is taught, and where it diverges from the parser - -| Surface | What it says | Divergence | -|---|---|---| -| Codex steering, injected every session (`src/hooks/steering.rs:137-143`) | "every tool is also a shell command: `tracedecay tool --key value`" | Teaches the grammar with the most traps; never mentions `--args`/stdin. | -| `using-the-cli` skill (`plugin/skills/using-the-cli/SKILL.md:18-23`) | `--key value` first; `--args`/`@`/stdin as a parenthetical | JSON path presented as an afterthought, not the machine path. | -| Arg catalog (`plugin/skills/using-the-cli/references/tool-arg-catalog.md:56`) | `multi_str_replace` required flags: `--path`, `--replacements` (`[[old,new],…]`) | **Actively teaches a shape the parser destroys**, comma-splitting mangles the JSON (Appendix A.7). Also stale (`body` documented as `--node-id (or --symbol)`; schema is `symbol` + `limit`, `definitions.rs:3290-3312`). | -| Per-tool `--help` (`render_tool_cli_help`, `src/mcp/tools/mod.rs:85-160`) | `--replacements array required "Array of [old_str, new_str] pairs"` | Invites inline per-key JSON that will be mangled; **does not print enum values** (Appendix A.8 help output); does not say array/object params need `--args`. | -| `tracedecay tool --help` trailer (`src/cli/help.rs:86-106`) | Correctly says `--args @file.json … required for array/object parameters` | The one place that states the rule, but it's on the *subcommand* help, which the discovery flow (list → per-tool `--help`) skips right past. | -| Bare list footer (`src/tool_command.rs:693-697`) vs per-tool footer (`mod.rs:150-157`) vs subcommand trailer | Three different reserved-flag/stdin footnotes | Wording drift; per-tool footer still leads with the `@` sigil model, list footer with the new whole-payload model. | -| ~10 other skills + Cursor rule + Hermes template + install prompt rules (`plugin/skills/*/SKILL.md`, `plugin/rules/tracedecay.mdc:35`, `src/agents/hermes/templates/skill.md:15`, `src/agents/mod.rs:816`) | All repeat `--key value` verbatim | The grammar is ossified in a dozen prose surfaces, and beyond the repo, into users' own CLAUDE.md files. Whatever we teach next should be *stable*, which favors the schema-parity JSON contract over flag ergonomics. | - -The net mental model handed to an agent, "alternating `--key value` -flags, kebab-case", is **correct for scalar-only tools and wrong for -exactly the tools whose payloads are hardest to construct**, with the -authoritative reference (the catalog) actively wrong for -`multi_str_replace`. - ---- - -## 4. Usability analysis for an AI-agent consumer - -An LLM invoking a CLI constructs the entire command in one shot from -steering + `--help` + prior knowledge; it cannot tab-complete or -experiment cheaply; it generalizes one convention across all 100 tools; and -its recovery loop is exactly as good as the error text. Ranked friction -points, each grounded in an empirical run (Appendix A) or code: - -**F1. Silent wrong behavior (worst class: no error to learn from).** -- Typo'd/unknown optional flag: `tool search --query gamma --limt 2` runs - successfully with the default limit; `--limt` is forwarded and ignored - (A.6; `src/tool_command.rs:414-421`, no schema check). -- Invalid enum: `tool gini --metric bogus` returns a computed result - labelled `metric: bogus` (A.8), 30 enum params, zero validation. -- Object-typed param per-key: `--project-selector '{"project_id":"x"}'` - arrives as a *string*; handlers ignore it (`coerce_value` fall-through, - `:558`). - -**F2. The taught shape for array-of-JSON params fails, with a -non-corrective error.** `tool multi_str_replace --path lib.rs ---replacements '[["alpha","gamma"]]'`, the exact catalog shape, dies with -`each replacement must be an array of exactly 2 strings` (A.7), a -*handler* error produced after comma-splitting mangled the JSON, hinting -nothing about `--args`. The one-shot construction fails and the retry has -no signpost. - -**F3. GNU `=` form rejected, confusingly.** `--query=foo` → -``flag `--query=foo` requires a value`` (A.1); worse, `--query=foo --json` -consumes `--json` as the unknown key's value and then reports `missing -required parameter --query` (A.2). Clap accepts `=` everywhere else in the -binary, so an agent's prior from `tracedecay sync --path=X` actively -misleads it. - -**F4. Boolean flags aren't presence flags.** `--include-code` alone at -end: ``requires a value``; mid-command it swallows the next token, -`--include-code --json` at least errors with `expected a boolean -(true/false), got '--json'` (A.3/A.4), which is corrective, but the -`requires a value` variant never states the fix (`pass true or false`). - -**F5. Shell quoting of inline JSON.** Any `--args '{...}'` or per-key -value containing a single quote forces the `'"'"'` dance; multi-line -bodies (replacement text, ast-grep patterns, cargo output) are effectively -impossible inline. The escape (`@file`, `@-`, `--args -`) exists and is -good, but it's taught as a footnote (Section 3.4) rather than as *the* -agent form, and nothing in an error message ever points to it. - -**F6, `--help` is insufficient for one-shot construction on the hard -tools.** No enum values (A.8 help), no `items` shape for arrays, no -example, no statement that array/object params require `--args`. For -`multi_str_replace`, help + catalog steer the agent straight into F2. - -**F7. Single-dash and positional misbinding.** `-query foo` silently -binds `-query` to the required `query` and errors about leftover `foo` -(A.5), the message points at the wrong token. `allow_hyphen_values` -(`src/cli.rs:112`) means clap can't catch it. - -**F8. Minor consistency debt.** `--json` (raw envelope) vs per-tool -`--format json` (markdown/JSON payload switch) is a two-knob surprise; -three drifting footers (Section 3.4); usage errors print as -`Error: config error: …` (`TraceDecayError::Config` display), mislabeling -user-input problems as configuration problems. - -What already works well and must be preserved: required-param enforcement -with good message shape (``missing required parameter `--query` for tool -`search` ``), name normalization (prefix/dash/alias, -`:148-156`), repetition-for-arrays, `@file`/`@-` per-key, the whole-payload -`--args` family with stdin memoization, positional binding for quick human -queries, and grouped discovery via bare `tracedecay tool` (`:647-698`). - ---- - -## 5. Reimagined design - -### 5.1 The one generalizable rule - -> **The arguments of `tracedecay tool ` are the tool's MCP -> `arguments` object.** Pass it whole with `--args` (inline JSON, `-` for -> stdin, use a quoted heredoc, or a file path). Or, for quick scalar -> calls, spell top-level fields as `--key value` flags; values are -> interpreted by the tool's schema, and anything that isn't a scalar is -> JSON. - -Everything an agent needs beyond its existing MCP knowledge is that one -paragraph. How each argument kind maps: - -| Kind | Agent form (taught) | Human form (kept) | -|---|---|---| -| Whole payload | `--args -` + `<<'JSON'` heredoc; `--args '{…}'` inline when short and quote-free; `--args payload.json` | same | -| Scalar string / integer / number | inside `--args` | `--key value`, `--key=value` (new), positional for required strings | -| Boolean | inside `--args` | `--key true|false` (unchanged; corrective error gains the exact fix text) | -| Enum | inside `--args` | `--key value`, now validated with allowed values in the error | -| Array of strings | inside `--args` | repeat `--key a --key b`, or `--key a,b`, or `--key '["a","b"]'` (new: JSON accepted) | -| Array of arrays/objects | inside `--args` (the only sane form) | `--key ''` (new: parsed as JSON because schema type is array; comma-split only applies when the value doesn't parse as JSON) | -| Nested object | inside `--args` | `--key '{"…":…}'` (new: parsed as JSON because schema type is object) | -| Multi-line string | inside the heredoc payload | `--key @file` / `--key @-` (unchanged) | -| Payload > 128 KiB argv cap | `--args -` or `--args file` | same | - -### 5.2 One validation gate, shared by both paths - -New function in `src/tool_command.rs` (name suggestion: -`validate_tool_args(def: &ToolDefinition, args: &Map) -> -Result<()>`), called at the end of `parse_invocation_with_stdin` on the -final object, whether it came from `--args` or from per-key collection -(insert after `finalize_arrays`, `:480`, and on the `--args` branch, -`:426-435`). It walks `input_schema` once and enforces: - -1. **Unknown keys** → error listing the unknown key, a did-you-mean - suggestion (nearest by edit distance over property names), and the - valid keys. Catches F1-typos on *both* paths (an `--args` payload with - a misspelled key gets the same protection MCP hosts give). -2. **Enum membership** → error with the allowed values verbatim (F1-enums). -3. **Type agreement** on the final JSON (string vs array vs object vs - number/boolean) → corrective error naming the expected JSON type and - showing the `--args -` heredoc form for non-scalars (F2 backstop). -4. **Required presence** → keep the existing message (`:468-478`), now also - enforced for `--args` payloads (today a payload missing required keys - goes to the handler and fails handler-side or silently). - -Implementation notes: hand-roll the walker (~100 lines; the schemas use -only `type`/`enum`/`items`/`required`/`properties`) rather than adding a -`jsonschema` crate dependency; validate against the same -`get_tool_definitions()` the dispatch uses so conditionally-advertised -tools (`ast_grep_rewrite` retention, `definitions.rs:340`) stay -consistent; treat schemas without `properties` as opaque (skip validation) -so profile-scoped/dynamic tools cannot be bricked by a stale walker. - -This is the altitude fix: validation happens once, on the final JSON, -next to the schema, not scattered through string coercion, and not -duplicated per-handler. It also makes the CLI *stricter than the daemon*, -which is correct: the daemon trusts validated MCP clients -(`src/daemon.rs:2181-2184`); the CLI's caller is the thing that needs the -teaching. - -### 5.3 `--dry-run` (reserved flag) - -Parse + validate + print the final arguments object as pretty JSON to -stdout, exit 0 (or the corrective error, exit ≠ 0), no daemon, no -handler, no side effects. One `if` in `run()` after `parse_invocation` -(`src/tool_command.rs:102-113`), one field in `ParsedInvocation` -(`:137-142`). Value: agents can self-check destructive edit-tool payloads -before applying; evals get a deterministic, side-effect-free probe of -"did the agent construct the right object" (Section 7.4); humans get -"show me what would be sent". This subsumes the `--print-request` idea -from Option 3, if desired later, `--dry-run --json` can print the full -`tools/call` frame, but the arguments object is the useful part. - -### 5.4 Per-key repairs (human path, kept deliberately small) - -In `parse_invocation_with_stdin`: - -1. **`--key=value`** (F3): in the `flag if flag.starts_with("--")` arm - (`:414`), split on the first `=` before kebab→snake conversion; the - remainder is the value (no `take_value`). `--args=…`, `--project=…` - likewise in the reserved-flag matches. -2. **JSON-typed per-key values** (F2, F1-objects): in `coerce_value` - (`:514-560`), for schema type `array` or `object`, first attempt - `serde_json::from_str`; accept if the parsed type matches the schema - type; otherwise fall back to current behavior (string, later - comma-split for arrays) so `--keywords auth,login` keeps working. - `finalize_arrays` (`:587-617`) then skips values that are already - arrays (it does today, `:612`). -3. **Corrective boolean/missing-value errors** (F4): `take_value` error - becomes ``flag `--include-code` requires a value, pass `--include-code - true` or `--include-code false``` for booleans (thread the schema type - through, or special-case in the caller); generic flags get ``flag `--x` - requires a value, write `--x ` or `--x=` ``. -4. **Single-dash guard** (F7): a positional starting with a single `-` and - matching a known property name (after dash→underscore) errors with - ``did you mean `--query`?`` instead of binding as a positional. - -Explicitly *not* doing: presence-style booleans (`--include-code` alone). -With `allow_hyphen_values` and positionals in play, presence booleans are -ambiguous (`--before src/x.rs` on `insert_at`, flag-then-positional or -flag-with-value?); the corrective error is the safe fix. - -### 5.5 The corrective-error contract - -Every rejection must tell the agent exactly how to fix the call, the -error message is the CLI's tab-completion. The contract to implement and -test (messages abbreviated; all end by pointing at `--help` only when the -fix isn't already fully stated): - -| Rejection | Today (`src/tool_command.rs`) | Contract | -|---|---|---| -| Unknown tool (`:94-99`) | names the tool, points at list | + nearest-name suggestion (`did you mean 'dead_code'?`) | -| Unknown key (new) | *silent* (F1) | ``unknown parameter `--limt` for `search`, did you mean `--limit`? Valid: --query (required), --limit, --format, --project-id, --project-path, --project-selector`` | -| Invalid enum (new) | *silent* (F1) | ``--metric: `bogus` is not one of: complexity, lines, fan_in, fan_out, members`` | -| Array/object param given a non-JSON scalar (new) | comma-split mangle → handler error (F2) | ``--replacements expects a JSON array. Pass JSON: --replacements '[["old","new"]]', or the whole payload via stdin: tracedecay tool multi_str_replace --args - <<'JSON' … JSON`` | -| `--key=value` (F3) | ``flag `--query=foo` requires a value`` | *accepted* (5.4.1); until then: ``write `--query foo` or `--query=foo` `` | -| Bare boolean (F4) | ``requires a value`` | ``--include-code requires true or false, e.g. `--include-code true` `` | -| Boolean swallowed a flag (`:522-531`) | states expected/got (good) | keep; append the `true/false` example | -| Missing flag value (`:620-624`) | ``flag `--x` requires a value`` | + `--x ` example (5.4.3) | -| Missing required (`:468-478`) | good | keep; append one-line usage: ``e.g. tracedecay tool search --query ""`` | -| `--args` invalid JSON (`:403-406`) | serde error, positioned | + ``if the payload contains quotes or newlines, pipe it: --args - <<'JSON' … JSON`` | -| `--args` non-object (`:407-411`) | ``must be a JSON object`` | + ``the same object you would pass as MCP arguments, e.g. {"query":"…"}`` | -| `--args` + other flags (`:426-435`) | states exclusivity | + ``either put everything in --args, or use only --key value flags`` | -| `--args` unreadable path (`:503-508`) | states the three forms (good) | keep | -| `@file` missing (`:636-638`) | ``failed to read @path: `` | + note the path is cwd-relative; suggest `--args -` for literals that begin with `@` | -| Unexpected positional (`:456-465`) | suggests flags + help (good) | + if it starts with `-`, the 5.4.4 did-you-mean | -| stdin read failure (`:342-345`) | io error | keep | - -Cosmetic but worthwhile: introduce a distinct display prefix for these -(`usage error:` rather than `config error:`), either a new -`TraceDecayError` variant or message-prefix convention (F8). - -### 5.6 Alternative considered and rejected: full clap-native dynamic subcommands - -Generate a real clap `Command` per tool at startup (schema → typed -`Arg`s), getting `=` handling, unknown-flag errors, and `--help` for free. -Rejected: clap's dynamic builder would re-encode JSON Schema into clap's -type system (losing enums-with-values-in-errors unless hand-fed, -struggling with array-of-array items), boot-time cost on a "hot-ish path" -that deliberately skips even the reinstall scan (`src/main.rs:790-812`), -and it polishes exactly the surface Section 2 demoted, while `--args` -passthrough, positionals, and `@` values would still need the hand-rolled -layer. The 100-line validation walker buys the same agent-visible wins at -a fraction of the risk, and keeps one parser instead of two. - -### 5.7 Files and functions touched (implementer map) - -| Change | Where | -|---|---| -| `validate_tool_args` + call sites (both paths) | `src/tool_command.rs` (new fn; hook at `:426-435` and after `:480`) | -| `--dry-run` flag + `ParsedInvocation.dry_run` + early return | `src/tool_command.rs:81-142,387-395` | -| `--key=value` split; boolean/missing-value error text; single-dash guard | `src/tool_command.rs:387-424,514-560,620-624` | -| JSON-typed per-key values for array/object | `src/tool_command.rs:514-560` (`coerce_value`), `:587-617` (`finalize_arrays` no-op on real arrays, already true) | -| Error-contract wording | same file; unit tests in `src/tool_command/tests.rs` (one test per table row) | -| Help: enum values, items shape, generated `--args -` example for tools with non-scalar params, unified footer | `src/mcp/tools/mod.rs:85-160` (`render_tool_cli_help`); list footer `src/tool_command.rs:693-697`; trailer `src/cli/help.rs:76-106` | -| Skill/catalog/steering rewrite | Section 6 | -| Eval corpus + harness extensions | Section 7 | -| Contract tests pinning taught text ↔ parser | `tests/agent_suite/agent_test.rs` (exists for hermes `tools.py`, `:1117-1120`) + the shared/plugin skill contract tests PR #286 already exercises | - ---- - -## 6. Discoverability: make the taught model identical to the parser - -The principle: **an agent should see the same one-paragraph contract in -every place it can learn from, steering, skill, catalog, `--help`, and -error messages, and that contract should be Section 5.1 verbatim.** - -1. **Codex/Cursor steering** (`src/hooks/steering.rs:137-143`, mirrored - in `src/agents/mod.rs:816` and `src/agents/hermes/templates/skill.md:15`): - replace "`tracedecay tool --key value`" with: - > every tool is also a shell command: `tracedecay tool --args - > ''`, the same JSON arguments object as the MCP tool; pipe it - > via `--args -` (heredoc) when it has quotes/newlines. `tracedecay - > tool` lists tools; `tracedecay tool --help` shows parameters. - Keep it to ~2 lines; steering is paid for in every session. -2. **`using-the-cli` SKILL.md**: invert the Invocation section. JSON-first - with the heredoc example as the canonical form; `--key value` follows - as "quick scalar calls"; document `--dry-run` for pre-flighting edit - tools; keep the discovery flow and retrieval sections as-is. -3. **`tool-arg-catalog.md`**: fix the actively-wrong rows *immediately* - (in PR #286): `multi_str_replace` → ``--args -`` heredoc example; - `body` → `--symbol`. Restructure each row to show *the MCP argument - names* (which are the `--key` names modulo kebab-case) plus one - ready-to-copy example per hard-shape tool. Add a top "Invocation - grammar" that is Section 5.1's paragraph. -4. **Per-tool `--help`** (`render_tool_cli_help`): append enum values - (`one of: complexity | lines | …`) and array item shapes - (`array of [old, new] string pairs` derived from `items`); for any tool - with an array/object param, emit a generated example: - ``` - Example: - tracedecay tool multi_str_replace --args - <<'JSON' - {"path": "", "replacements": [["", ""]]} - JSON - ``` - (constructed mechanically from `properties` + `required`, placeholders - from the property names). Unify the three footers to the same two - lines (payload forms; `@`/`@-` per-key). -5. **Other skills / Cursor rule / prompt rules** (the dozen `--key value` - citations in Section 3.4): mechanical rewrite to "`tracedecay tool - ` (see `tracedecay:using-the-cli`)", stop repeating the grammar - in surfaces that can drift; the skill is the single source. -6. **Contract tests**: extend the plugin/shared skill contract tests (run - in PR #286's test list) to assert the steering string, SKILL.md, and - catalog all contain the `--args -` form and do **not** teach per-key - for `replacements`, so the taught model cannot silently drift from the - parser again. - ---- - -## 7. Eval plan - -Goal: measure, with real Sonnet and Codex sessions in the hermetic -harness, whether an agent that must fall back to the CLI can construct -correct tool calls across the hard shapes, and whether it self-corrects -when it can't, before and after the changes. - -### 7.1 Harness (existing, verified) - -`evals/hermetic/run.sh` builds this worktree's binary, stages it at a -non-cargo path, installs the plugin into an isolated -`CLAUDE_CONFIG_DIR`/`CODEX_HOME`/`TRACEDECAY_DATA_DIR`, indexes a target -project, then runs each corpus line via `claude -p … --model sonnet` -(`run.sh:244-247`) or `codex exec --json` (`run.sh:252-258`) and scores -the isolated transcript with `score.py`. A scenario passes when every -`expected_tools` fragment appears among MCP tool names, every -`expected_cli` fragment appears among captured shell command strings, and -no `anti_tools` appear (`score.py:203-231`; `evals/hermetic/README.md:87-110`). -The existing two-scenario corpus (`corpora/tool-args-ergonomics.jsonl`) -already demonstrates the MCP-first vs CLI-fallback pattern; it stays -untouched as a continuity check. - -Key property exploited below: for Claude, the Bash `tool_use` input -contains the **full command text including heredoc bodies** -(`score.py:137-143`), so fragment matching sees inside `--args -` -payloads; Codex command strings are captured equivalently -(`score.py:147-167`). - -### 7.2 Harness extensions (small, additive) - -1. **`verify_cmd`** (per-scenario, optional): a shell command run by - `run.sh` in the scenario's `project_dir` *after* the agent session, - with the env's staged binary first on PATH; its exit status is passed - to `score.py` (new `--verify-status` arg) and folded into `pass` as - `verify_pass`. This is how edit-tool scenarios assert *effect* (file - actually contains the replacement) rather than command shape. -2. **Attempt counting**: `score.py` gains `tool_cmd_attempts` = number of - captured commands containing `tracedecay tool ` - (per-scenario `attempt_tool` field), and `self_corrected` = - `pass && tool_cmd_attempts > 1`. This turns "did the corrective error - teach the retry" into a metric without a smarter judge. -3. **Reset between reps**: `run.sh run` gains `--reps N` (re-run the - corpus N times, appending to `results.jsonl` with a `rep` field); - `verify_cmd` scenarios provide a `setup_cmd` to restore fixture state - (e.g. `git checkout -- lib.rs` in the fixture project) run before each - rep. - -Corpus schema additions documented in `evals/hermetic/README.md:87-110`. - -### 7.3 Fixture - -A tiny dedicated fixture project (3–4 files, committed under -`evals/hermetic/fixtures/tool-args/`, copied into the env and indexed by -`run.sh index`) rather than the tracedecay repo itself: edit scenarios -must mutate files deterministically, and `verify_cmd`/`setup_cmd` need -stable content. One file carries a function whose body contains a comma, -both quote characters, and a `$`, the quoting gauntlet. A second -registered project (one file) is indexed to exercise `project_selector`. -A >128 KiB `cargo-output.txt` fixture feeds the argv-cap scenario. - -### 7.4 Corpus: `evals/hermetic/corpora/tool-args-agent-path.jsonl` - -All prompts begin from the same fiction the existing corpus uses -("Assume the TraceDecay MCP server is unavailable; use the tracedecay CLI -fallback"), include `providers: ["sonnet","codex"]`, and anti-tools ban -raw DB access (`sqlite3`, `.tracedecay/`). Fragment expectations are -chosen to be *shape-agnostic* where multiple correct forms exist (a -fragment like `"fan_in"` matches per-key, inline JSON, and heredoc alike); -effects are verified where the tool has one. - -| id | Forces | Prompt sketch | Pass signal | -|---|---|---|---| -| `ap-array-of-pairs` | array of `[old,new]` pairs | apply two replacements in `lib.rs`, one new string containing `', '` and `$x` | `expected_cli: ["tracedecay tool multi_str_replace"]`; `verify_cmd`: grep the file for both new strings; `attempt_tool: multi_str_replace` | -| `ap-multiline-string` | multi-line string param | insert a 5-line doc comment (contains both quote types) above a named function via `insert_at` | `expected_cli: ["tracedecay tool insert_at"]`; `verify_cmd`: grep for a sentinel line | -| `ap-nested-object` | object param | search for symbol `zeta` in *the other registered project* using a project selector | `expected_cli: ["project_selector"]` or `["project-path"]` (either correct spelling); success text mentions the hit | -| `ap-enum-param` | enum | "compute inequality of fan in per file" (phrasing tempts `fanin`/`fan-in`) | `expected_cli: ["tool gini","fan_in"]` | -| `ap-whole-payload-stdin` | argv cap + stdin | diagnose the provided >128 KiB `cargo-output.txt`, "pipe the file, do not paste it inline" | `expected_cli: ["tool diagnose","--args"]`; passing `@`/`-`/file all count (fragment `--args`) | -| `ap-typo-recovery` | unknown-key correction | "search for `gamma` capping results with the `max_results` option" (real param: `limit`) | `expected_cli: ["--limit"]` or `["\"limit\""]`; **baseline expectation: fail silently** (agent uses `--max-results`, sees success, never corrects); after: corrective error → `self_corrected` | -| `ap-help-one-shot` | discoverability | construct a `fact_store` add for a given decision text using only `--help` (catalog withheld by prompt) | `expected_cli: ["tool fact_store"]`; `verify_cmd`: `tracedecay tool fact_store --args '{"action":"search","query":…}' --json | grep `; `tool_cmd_attempts ≤ 3` | -| `ap-dry-run-preflight` *(candidate arm only, Phase 1+)* | `--dry-run` | validate a `multi_str_replace` payload **without applying it**, then apply | `expected_cli: ["--dry-run"]`; `verify_cmd` checks final content | - -Eight scenarios × 2 agents. `ap-dry-run-preflight` is excluded from the -baseline arm (flag doesn't exist there); it establishes the Phase-1 -affordance is discoverable from help alone. - -### 7.5 Protocol: baseline vs after - -Two arms, identical corpus, identical fixture, same models: - -- **Arm A (baseline)**: binary + plugin from `master` (pre-#286 docs), via - `run.sh setup` on a master checkout. -- **Arm B (candidate)**: this branch (per phase: B0 = docs/steering only; - B1 = + validation/`--dry-run`; B2 = + help generation). - -Per arm: `setup` → `index` (fixture + second project) → `run --corpus -tool-args-agent-path.jsonl --reps 3` for `--agent claude --model sonnet` -and `--agent codex`. 8 scenarios × 2 agents × 3 reps × 2 arms ≈ 96 short -sessions, same manual, cost-gated posture as the existing harness (no -CI; `evals/memory/run_real_model.py` sets the precedent for explicit cost -consent). Record per-arm `summary.md` pass rates and mean -`tool_cmd_attempts`; store both as durable facts per the README's -post-merge protocol (`evals/hermetic/README.md:144-158`). - -**Success bar:** Arm B ≥ Arm A on every scenario (majority-of-3 reps); -the four trap scenarios (`array-of-pairs`, `multiline`, `enum`, -`typo-recovery`) move from expected-fail/flaky to ≥ 2/3 pass per agent; -mean attempts on hard shapes ≤ 2; zero silent-failure passes (a -`verify_cmd` failing while fragments pass counts as fail, that -combination *is* the silent-failure detector). - -### 7.6 Hypotheses (falsifiable, grounded in Section 4) - -| Scenario | Baseline prediction (why) | After prediction | -|---|---|---| -| `ap-array-of-pairs` | Mostly fail or multi-attempt: catalog/help steer to per-key `--replacements` (F2); handler error doesn't mention `--args`; some agents recover by inventing `--args`, then fight quoting (F5) | 1–2 attempts via heredoc; corrective error catches per-key strays | -| `ap-multiline-string` | Flaky: inline quoting breaks (F5); some agents write a temp file + `@file` (fine, passes) | heredoc first-shot | -| `ap-nested-object` | Fail: per-key object arrives as string, silently ignored (F1) → wrong-project results; fragments may pass while `verify` fails, recorded as silent failure | JSON path or corrective type error | -| `ap-enum-param` | Split: `fan_in` guessable from description text, but `fan-in`/`fanin` silently accepted (F1) → wrong output, no correction | corrective enum error → self-correct ≤ 2 attempts | -| `ap-whole-payload-stdin` | Mixed: argv-cap unknown to some agents; inline attempt may hit E2BIG with an opaque OS error | taught stdin form, first-shot | -| `ap-typo-recovery` | Fail silently (F1): `--max-results` ignored, agent reports success | unknown-key error names `--limit` → self-correct | -| `ap-help-one-shot` | Multi-attempt: help lacks enum/shape info (F6) | ≤ 3 attempts with generated example in help | - -If Arm B0 (docs only) already clears most of the bar, that is a -finding: the parser was adequate and the *teaching* was the bug. Phases -1–2 then stand on the remaining deltas (`typo-recovery` and -`enum` cannot pass B0; they need the validation gate). - ---- - -## 8. Rollout - -Phased, lowest-risk first; every phase independently shippable and -re-evaluated (Section 7.5 arms map to phases). - -**Phase 0, reshape PR #286 (docs + evals, zero parser changes).** -The branch's parser work (payload conventions, stdin memoization, tests) -is correct and stays. The PR grows: the Section 6 rewrites of -`using-the-cli` SKILL.md, `tool-arg-catalog.md` (fixing the actively-wrong -`multi_str_replace`/`body` rows), steering strings -(`src/hooks/steering.rs`, `src/agents/mod.rs:816`, hermes template), and -`src/cli/help.rs` trailer; the new corpus + fixtures + `verify_cmd`/ -`attempts`/`--reps` harness extensions; contract-test updates pinning the -new taught text. What PR #286 *becomes*: "the CLI's agent contract is the -MCP arguments object over `--args`/stdin; docs, steering, and evals now -say so", a docs-and-measurement PR on top of already-landed plumbing. -Back-compat: total (prose + additive harness changes only). - -**Phase 1, validation gate + corrective errors + `--dry-run`.** -`validate_tool_args`, the Section 5.5 error contract, `--dry-run`; unit -tests per contract row; re-run Arm B1. Back-compat break, intentional and -narrow: unknown keys and invalid enums now error (previously silent). -Hermes `tools.py` is safe (it sends schema-exact dicts, and injected keys -like `messages`/`storage_scope`/`hermes_home` exist in the LCM/memory -schemas, verify against `PROFILE_SCOPED_LCM_TOOLS` handling in -`src/tool_command.rs:50-78` during implementation; if any injected key is -absent from a schema, add it to the schema rather than weakening the -gate). Call the break out in the changelog. - -**Phase 2, help generation.** Enum values, item shapes, generated -`--args -` example, unified footers (`render_tool_cli_help`, -`src/mcp/tools/mod.rs:85-160`). Re-run Arm B2 (expect `ap-help-one-shot` -delta). No behavior change. - -**Phase 3 (optional, human polish), per-key repairs.** `--key=value`, -JSON-typed per-key array/object values, single-dash guard (Section 5.4). -Lowest urgency: by now agents are on the JSON path; this serves humans. -Each is individually testable in `src/tool_command/tests.rs`. - -Explicit non-goals: no new top-level command, no raw JSON-RPC mode, no -clap dynamic subcommands, no removal of positionals/`--key value`/ -`@file`, no MCP protocol changes. - ---- - -## 9. Open questions and risks - -1. **Strictness vs unknown consumers.** Phase 1's unknown-key/enum - rejection could break third-party scripts relying on silently-ignored - params. Judged acceptable (that silence is a latent bug), but if - telemetry or issue reports say otherwise, the fallback is - downgrade-to-warning on stderr for one release. Decide at Phase 1 - review. -2. **Schemas that intentionally accept extra keys.** The validation gate - assumes `properties` is exhaustive. Audit for handlers reading - undeclared keys (the Hermes-injected `messages`/`hermes_home` pattern - is the known case); fix schemas, not the gate. Risk: one missed case - bricks a working integration, mitigated by running the full - `agent_suite` and hermes plugin tests in Phase 1. -3. **Steering token budget.** The 2-line steering rewrite is - cost-neutral, but adding a heredoc example to session-injected text is - not free at fleet scale. Current stance: example lives in `--help` and - the skill, one-sentence rule in steering. Revisit if `ap-*` scenarios - show agents not finding the heredoc form from steering alone. -4. **Eval judge fidelity.** Fragment matching can false-pass (command - emitted but failed), `verify_cmd` closes this for edit tools, but - read-only scenarios (`ap-enum-param`, `ap-nested-object`) still lean on - fragments; a transcript-level LLM judge is out of scope (README's - stated philosophy: the harness guarantees isolation, not sophisticated - grading). Accepted; attempts + fragments + effects triangulate well - enough for a before/after signal at N=3. -5. **Windows.** No daemon socket; dispatch already falls back in-process - (`src/tool_command.rs:206-209,227-267`). stdin/heredoc guidance holds - in POSIX-ish shells agents use; PowerShell heredoc syntax differs, - the docs should show the `--args payload.json` form as the - portable alternative. Low priority; agent fleet is overwhelmingly - POSIX. -6. **`--json` vs `--format json`** (F8): consolidating the two knobs is - out of scope here (it spans handler output shaping); document the - distinction in the skill for now, and consider folding `--format` into - the help footer text. -7. **Error-variant taxonomy.** Whether to add a `TraceDecayError::Usage` - variant or keep `Config` with reworded messages, cosmetic; decide in - Phase 1 code review. -8. **External ossification.** Users' own CLAUDE.md files and memory facts - teach `--key value` (this repo's owner included). Nothing we ship - un-teaches those; the corrective errors are the safety net that - retrains stale habits in one round-trip. That is precisely why the - error contract, not the happy path, is the highest-leverage surface. - ---- - -## Appendix A, empirical evidence log - -Environment: this worktree's `target/debug/tracedecay` (0.0.29, commit -`14ae43de`), `TRACEDECAY_DATA_DIR` isolated to a scratch dir, throwaway -project `tiny-proj/lib.rs` (`fn alpha() {}\nfn beta() { alpha(); }`) -indexed via `tracedecay init` (1 file, 3 nodes). Boilerplate note lines -elided. - -| # | Command | Output (verbatim, trimmed) | -|---|---|---| -| A.1 | `tool search --query=foo` | ``Error: config error: flag `--query=foo` requires a value`` | -| A.2 | `tool search --query=foo --json` | ``Error: config error: missing required parameter `--query` for tool `search` `` (the unknown key `query=foo` consumed `--json` as its value, then both vanished) | -| A.3 | `tool context "how" --include-code` | ``Error: config error: flag `--include-code` requires a value`` | -| A.4 | `tool context "how" --include-code --json` | ``Error: config error: --include-code: expected a boolean (true/false), got `--json` `` | -| A.5 | `tool search -query foo` | ``Error: config error: unexpected positional argument(s): foo, use --key value flags or run `tracedecay tool search --help` `` (`-query` silently bound to `query`) | -| A.6 | `tool search --query gamma --limt 2 --json` | Succeeds; returns results with default limit, `--limt` silently forwarded and ignored | -| A.7 | `tool multi_str_replace --path lib.rs --replacements '[["alpha","gamma"]]'` (the catalog-taught shape) | ``Error: config error: each replacement must be an array of exactly 2 strings``, comma-split mangled the JSON before the handler saw it; file untouched. Same edit via `--args '{"path":"lib.rs","replacements":[["alpha","gamma"]]}'` parses correctly (handler then rightly rejects the ambiguous 2-site match) | -| A.8 | `tool gini --metric bogus` | Succeeds: `gini: 0 … metric: bogus`, invalid enum accepted end-to-end. `tool gini --help` shows `--metric string optional Metric to measure inequality for (default: complexity)`, allowed values not shown | diff --git a/docs/plans/2026-08-08-v2-rc-recovery-design.md b/docs/plans/2026-08-08-v2-rc-recovery-design.md deleted file mode 100644 index 972fa98667..0000000000 --- a/docs/plans/2026-08-08-v2-rc-recovery-design.md +++ /dev/null @@ -1,194 +0,0 @@ -# TraceDecay V2 RC Recovery Design - -> **RECONCILED INTO NEXT.md (2026-08-13).** Its live remainder is carried -> by `docs/plans/tracedecay-v2/NEXT.md`, which is the delivery authority. -> This document is retained as the design record for the recovery, not as -> implementation authority. Its implementation-plan sibling, -> `docs/superpowers/plans/2026-08-08-v2-rc-recovery.md`, was stamped at the -> same time; this one was missed. - -**Status:** Approved on 2026-08-08 - -## Purpose - -Finish the latest Claude Code root session's interrupted V2 delivery in place, -preserve the useful implementation already present on -`codex/tracedecay-total-redesign-plan`, and produce a release-candidate branch -whose product surfaces are wired through real production journeys. - -The recovery is not a new roadmap. The sole roadmap precedence remains -`docs/plans/tracedecay-v2/00-plan-set-index.md`; this design reconciles the -unfinished checkout with that authority. - -## Recovered State - -The latest Claude root session was -`99dc84b5-f5ec-4ebb-8b96-318e1b20f871`. It referenced 140 ordinary subagents -and one 36-agent code-review workflow. Every referenced agent transcript, -metadata record, and task output was recovered. The root session stopped after -Claude hit its monthly quota and never produced its requested final checkpoint. - -The checkout contains two local commits beyond the remote integration floor and -an unstaged implementation spanning Work, workflow fan-out, worktrees, -observability, host integration, privacy, LSP, retained context, SDKs, and the -dashboard. A late queue run established that workspace compilation succeeds, -but the broad root library run still had 98 failures. Several green results in -older Claude logs were invalid because shell pipelines masked Cargo failures; -only direct exit status and non-vacuous test counts are accepted below. - -## Product Outcome - -The RC branch must provide the promised V2 product through the production -daemon and supported host journeys. Contracts, fakes, generated clients, and -dashboard components do not count as delivered unless a production caller can -exercise them and the relevant failure states remain typed. - -RC readiness means: - -- canonical Rust authorities own every wire shape; -- Work and workflow operations are admitted, dispatched, persisted, observed, - and retrievable through their promised surfaces; -- SDK, MCP, HTTP, CLI, host, and dashboard availability claims match mounted - production behavior; -- host installation and execution preserve operator state and isolate test - state; -- privacy, identity, staleness, denial, rollback, and replay boundaries fail - closed with falsifiable tests; -- generated artifacts are regenerated only from canonical authorities; -- focused and aggregate verification reports zero unclassified failures. - -## Recovery Strategy - -Preserve and finish Claude's dirty tree in place. Each dirty module is handled -in one of three ways: - -1. complete its real production journey and retain it; -2. fold it into the canonical authority that already owns the behavior; or -3. delete it when no V2 acceptance requirement or shipped compatibility - obligation justifies it. - -The recovery will not checkpoint the entire dirty tree as a mixed WIP commit, -reset it to the remote branch, or build parallel shadow authorities. - -## Delivery Slices - -### 1. Correctness and security foundation - -Repair the load-bearing defects before mounting additional callers: - -- make work-synthesis replay byte-stable by persisting the complete admitted - result atomically rather than recomputing source and draft state; -- bind a run's deadline and topology to durable run identity rather than - lexical attempt ordering or caller self-attestation; -- clear the environment of spawned provider processes and restore only the - admitted snapshot; -- return truthful fresh-store reset, Doctor observation, retained-source, - graph-generation, and ownership states instead of fabricated defaults; -- preserve typed absent, unsupported, denied, stale, and unavailable outcomes. - -### 2. Work and workflow runtime - -Complete the canonical Work journey: - -- mount all 26 Work operations through definitions, binding, dispatch, - application ownership, and production handlers; -- resolve and pin topology from registered control-plane authority before a - provider starts; -- provide real worktree inventory and cleanup adapters with partial, stale, - foreign, denial, reconcile, and rollback behavior; -- enforce fan-out fences and `max_parallel` through the real run-control path; -- persist checkpoints only when a production consumer retrieves or hands them - off; otherwise remove the unused contract; -- emit topology/run/attempt/handoff events and project them through a - generation-bound read model. - -### 3. Shared transports and hosts - -Expose only mounted capabilities: - -- finish Context Scout and the V2 SDK operations that have canonical schemas - and production executors; operations without a real journey remain typed - unavailable rather than fabricated; -- wire required remote enrollment, status, replay, backup, restore, and - failover operations through their promised CLI/MCP/SDK/dashboard surfaces; -- make LSP advisory registrations, snapshots, and denied outcomes derive from - real daemon authority; -- finish Claude's existing Kiro CLI lifecycle lane without redesigning the - integration: isolate the child environment and working directory, preserve - peer configuration, prove rollback, and reconcile its docs; do not add a - Kiro Power, OpenVSX extension, or new bundle architecture; -- parse and sanitize structured provider metadata before any GitHub, fact, or - session sink sees it. - -### 4. Contracts and dashboard - -After Rust request/result shapes settle: - -- export dashboard schemas and regenerate TypeScript contracts; -- regenerate Rust and TypeScript SDKs from the same registry; -- update fixtures to embed `WorkTopologyPolicyV1` rather than the obsolete - `topology_policy_digest`; -- mount the recovered Observatory views and Work topology accounting in real - navigation and data routes; -- bind joined dashboard data to one generation and label capped denominators as - partial rather than exact; -- add DOM journey tests for user-visible V2 behavior. Automated functional UI - coverage remains required; manual screen-reader polish is not an RC blocker. - -### 5. Cutover, cleanup, and release evidence - -Complete cutovers instead of keeping branch-local compatibility: - -- remove the root application compatibility shim after migrating its callers; -- split newly created or materially touched hand-written modules that exceed - the repository's 1,000-line ceiling; -- remove dead flags, aliases, test-only production ports, stale docs, and - unmounted claims; -- run formatting, compiler, lint, contract, dashboard, host, integration, - aggregate Rust, and current CI checks with direct exit-status evidence; -- record release evidence and the exact remaining operator actions. - -## Execution Model - -Implementation uses test-driven slices. Every behavioral change begins with a -focused failing test, records the expected failure, adds the minimum production -change, and records the passing result. Generated artifacts are the explicit -exception: their generator and drift check are the test. - -Each slice has one implementation owner at a time because all agents share this -dirty checkout. Terra workers own cohesive integration slices, Luna workers own -small mechanical tests, fixtures, and generated-artifact work, and Sol workers -own subtle concurrency, replay, identity, and security boundaries. Workers must -re-read files before editing, stage only their declared ownership, preserve peer -changes, and create conventional commits. A separate worker reviews every slice -for spec compliance and code quality before the next dependent slice begins. - -## Verification - -Focused development checks are followed by: - -- `cargo fmt --all -- --check`; -- `cargo check --workspace --all-targets --all-features`; -- repository clippy policy with warnings denied; -- non-vacuous focused Rust tests and then the full workspace suite; -- dashboard contract generation/check, typecheck, tests, and production build; -- SDK generation and conformance checks; -- supported-host bundle, install/update/uninstall, and isolation journeys; -- end-to-end Work, workflow, retained-context, remote, LSP, and dashboard - journeys; -- current GitHub CI for the final pushed commit. - -Failures are fixed at their root. Assertions are not weakened, timeouts are not -raised to hide races, and tests are not ignored or filtered into vacuous greens. - -## Human and Operator Gates - -The code branch can be RC-ready while clearly recording these external actions: - -- npm trusted-publisher/OIDC configuration, which the user will provide; -- designated live semantic evaluation/profile runs; -- machine-specific Doctor/Cursor/Kiro journeys where the real host is required; -- the planned large-store garbage-collection observation. - -No RC tag or package publication occurs until those required external gates are -classified and the user authorizes publication. diff --git a/docs/plans/tracedecay-v2/00-plan-set-index.md b/docs/plans/tracedecay-v2/00-plan-set-index.md index 8df8344591..202afe49d7 100644 --- a/docs/plans/tracedecay-v2/00-plan-set-index.md +++ b/docs/plans/tracedecay-v2/00-plan-set-index.md @@ -7,9 +7,8 @@ tests, and normal CI remain active delivery work. The repository is not green. This file is the sole authority for V2 precedence, rejected mechanisms, delivery order, and acceptance. Numbered plans own semantic product behavior, failure semantics, fresh-store cutover, and direct acceptance; they are not independent -queues and do not require one delivery branch per document. `NEXT.md` tracks -current outcomes and blockers only. Historical gap ledgers and contract-spine -artifacts are records, not parallel authorities. +queues and do not require one delivery branch per document. Historical gap +ledgers and contract-spine artifacts are records, not parallel authorities. The `TraceDecay V2` roadmap name is independent of contract/schema versioning. Only an actually independently released public wire/API protocol may retain an diff --git a/docs/plans/tracedecay-v2/24-canonical-task-plan-graph-and-multi-agent-executor.md b/docs/plans/tracedecay-v2/24-canonical-task-plan-graph-and-multi-agent-executor.md index c541d76680..ce784dbba1 100644 --- a/docs/plans/tracedecay-v2/24-canonical-task-plan-graph-and-multi-agent-executor.md +++ b/docs/plans/tracedecay-v2/24-canonical-task-plan-graph-and-multi-agent-executor.md @@ -75,7 +75,7 @@ placement, workflow definitions, automation execution, expertise/calibration, fan-out/synthesis/recovery, and host/LSP handoff. Session-derived tasks, an independent Kanban database, or dashboard-owned task authority remain rejected. -Roadmap Markdown, `NEXT.md`, PR sequences, contributor checklists, and +Roadmap Markdown, PR sequences, contributor checklists, and completion ledgers are documentation and Git evidence only. The workflow runtime never parses, imports, schedules, or executes them. Product work enters through explicit application commands or an authorized product-data import. diff --git a/docs/plans/tracedecay-v2/39-embedded-grafeo-graph-database.md b/docs/plans/tracedecay-v2/39-embedded-grafeo-graph-database.md index 9aa583f33c..ccaaf2b2e5 100644 --- a/docs/plans/tracedecay-v2/39-embedded-grafeo-graph-database.md +++ b/docs/plans/tracedecay-v2/39-embedded-grafeo-graph-database.md @@ -7,8 +7,7 @@ > the daemon consumes it through > `src/daemon/code_index_scheduler/graph_activation.rs`; and the SQLite graph > authority was deleted in `79683eb527`. Treat the unchecked boxes as -> historical planning, not open work. Residual graph work is tracked in -> `NEXT.md` (Grafeo memory-relations restart/isolation journey). +> historical planning, not open work. **Goal:** Replace custom adjacency structures and graph-shaped SQLite storage with one embedded Grafeo runtime boundary while retaining SQLite only for genuinely relational, transactional, and content-bearing records. @@ -883,7 +882,6 @@ git diff --check - Modify: `scripts/tool-sweep.sh` - Modify: `.github/workflows/ci.yml` - Modify: `docs/plans/tracedecay-v2/33-end-to-end-performance-optimization.md` -- Modify: `docs/plans/tracedecay-v2/NEXT.md` **Interfaces:** - Consumes: final graph-db-backed production journeys. diff --git a/docs/plans/tracedecay-v2/NEXT.md b/docs/plans/tracedecay-v2/NEXT.md deleted file mode 100644 index 0f8d8a126f..0000000000 --- a/docs/plans/tracedecay-v2/NEXT.md +++ /dev/null @@ -1,88 +0,0 @@ -# V2 current outcomes - -`00-plan-set-index.md` remains sole roadmap/acceptance authority. -This file records outcomes only. Last reconciled: 2026-09-17 (post-#707 merge to master). - -Branch: `master` (PR #707 merged 2026-09-17 as `e13a35319f1c5de99e310a32d180dab3f54a802a`; tip has moved further). -Workspace: 38 crates under `crates/` (virtual root; counted from workspace -`members` in the root `Cargo.toml`). - -## Outcomes since 2026-08-19 - -- Usecase code-index generation retention (journal + receipt store) lives in - `tracedecay-code-index-retention`, not `tracedecay-application/src/retention`. -- GitHub read path: fail-closed REST protocol envelope in - `crates/tracedecay-application/src/advisory/github_runtime/protocol.rs` - (`Retry-After` / `Link`). One static GraphQL query is unchanged. -- Canonical clock routing: usecases wall-clock reads go through - `tracedecay-contracts` `clock` (`now_micros` / `try_now_micros` in - `crates/tracedecay-contracts/src/clock.rs`). -- Hook-identity canonicalization: `envelope_identity_hash16` in - `crates/tracedecay-hooks/src/lib.rs` (`HookHostV1` aliases domain - `NativeHostIdentityV1`). -- `tracedecay-runtime-core` no longer depends on `tracedecay-lsp`. -- Root decomp: `tracedecay-code-index-runtime` owns the scheduler previously - at `src/daemon/code_index_scheduler/`. Also extracted: `tracedecay-mcp`, - `tracedecay-session-temporal-store`, `tracedecay-maintenance`, - `tracedecay-source-edit`, `tracedecay-host-admission`, - `tracedecay-daemon-protocol`, `tracedecay-daemon-control`, - `tracedecay-automation-runtime`. - `tracedecay-cli` absorbs the work, workflow, remote, and upgrade verbs. -- Daemon lifecycle: start a dead installed unit after update - (`crates/tracedecay-daemon-control/src/service.rs`); treat `WouldBlock` - connect as saturation; doctor names a stopped-and-disabled installed unit - (`crates/tracedecay/src/doctor.rs`). -- Domain move-ups: file-document / extraction / lineage records left domain - for code-index; root-only graph shapes moved to the root crate. - `review_labels` is deleted, no current vocabulary; Plan 26 archival - mentions only. -- Machine compile cache is kache, not sccache. - -## 2026-08-29 Landing wave - -Already landed on the redesign tip (now merged to `master` via PR #707) when this file was -reconciled: - -- Scheduler cluster repair: early publish with serving-seat wait, lock-park - remount, graph-off memory-pressure rebuilds, bounded activation, ready - abstain, lock-free reconcile slot, publication identity, text-head reopen, - and label-move CAS. -- Streaming sealed seat: committed-WAL recovery streams instead of - materializing. -- Grafeo checkpoints are crash-atomic (out-of-place generation + authenticated - header flip). Catalog format-version guard is pinned; torn-vector checkpoint - recovery reopens serving search. -- `GrafeoDB::close` skips the checkpoint when the container is already current. -- Durable Ready receipt is reattached after remount. -- Status reads serve the cached background process sample. -- Transport phases are spanned (daemon wire, LSP outbound, automation/MCP). - -## 2026-08-30 Landing wave - -- Retention convergence landed with a binary handoff; the store was growing - 64.9→83.3 GiB per 2 h on the old binary. -- Deferred-bind proxy (#752). -- `semantic activate` journey shipped with typed revision refusal (#753). -- Vector-commit fix (#754): peak RSS 3044→236 MiB, wall 184→24 s at 120k×768. -- Skew-crash poll-frame fix; the daemon DoS is closed. -- Sealed decode cut: 55.3→29.6 s, RSS 2678→1875 MiB. -- Publication gate split: sealed-store build no longer blocks retrieval; - verify-once markers fixed the double 84 s verify. -- Typed-park plus self-heal for owner-privacy roots. -- Storage-status blocking fixes: 431 ms warm. -- Session journal paging. -- Redundancy sanitizer coordinate fix. -- Fact-store category and telemetry fixes. -- ANN wiring landed, held on exact-flat pending the latency verdict. -- Seconds-as-micros sweep. -- Wave review verdict: sound; 92 local failures proven environmental. - -## Unverified on HEAD - -These were remaining work on earlier handoffs and were not re-proven here: - -- Physical-daemon memory/session/LCM restart across CLI, MCP, HTTP, and SDKs. -- Operator doctor plus a clean Cursor agents/in-composer install → upgrade. -- Incremental indexing matrix (save, rename, delete, ref switch, overflow, - cancel, restart). -- npm trusted-publisher OIDC (operator-owned). diff --git a/docs/plans/tracedecay-v2/README.md b/docs/plans/tracedecay-v2/README.md index 81e6296293..286588a3f5 100644 --- a/docs/plans/tracedecay-v2/README.md +++ b/docs/plans/tracedecay-v2/README.md @@ -2,13 +2,11 @@ Status: active product rewrite. Temporal capture is complete. Retrieval, production integration, dashboard journeys, stable active contracts, direct -tests, and normal CI remain active delivery work. [NEXT.md](NEXT.md) records -the current product outcomes and blockers. +tests, and normal CI remain active delivery work. [00-plan-set-index.md](00-plan-set-index.md) is the sole precedence, rejection, -delivery, and acceptance authority. [NEXT.md](NEXT.md) tracks current outcomes -and blockers only. These are contributor documents only and never product -runtime input. +delivery, and acceptance authority. These are contributor documents only and +never product runtime input. Numbered plans define component behavior and boundaries, not separate crate-first work queues. @@ -161,10 +159,9 @@ removes duplicate authorities and follows inventories, registries, matrices, and generated declarations remain non-product and never justify implementation or CI by themselves. -The current executable slice is always [NEXT.md](NEXT.md). This roadmap is -contributor documentation, never daemon input, workflow input, product state, -or a source of completion truth. Integration completes only after direct -product tests and normal cross-platform CI are stable. +This roadmap is contributor documentation, never daemon input, workflow input, +product state, or a source of completion truth. Integration completes only +after direct product tests and normal cross-platform CI are stable. ## Release diff --git a/docs/plans/tracedecay-v2/audits/ci-triage-plan-2026-08-14.md b/docs/plans/tracedecay-v2/audits/ci-triage-plan-2026-08-14.md deleted file mode 100644 index 42883fe1d7..0000000000 --- a/docs/plans/tracedecay-v2/audits/ci-triage-plan-2026-08-14.md +++ /dev/null @@ -1,874 +0,0 @@ -# CI failure classification, run 31788759294 - -Scope: `codex/tracedecay-total-redesign-plan`, failed head `c962cd627`, local head -`2ff144f83`. Analysis is read-only. No Cargo command or test was run. - -## Executive result - -The supplied count of 484 does not match the log. - -- 490 unique names occur in literal `test ... FAILED` lines. -- Nextest adds 11 unique failures with no matching libtest failure line: - one `ABRT` (stack overflow) and ten `TMT` identities. -- The complete extracted inventory is therefore **501 distinct identities**. -- 24 of the 490 libtest identities passed on retry in every job where they - failed; 477 identities remained terminally failed/aborted/timed out in at - least one job. -- Literal libtest identities by job: Dashboard 17, macOS 423, Linux 390. - macOS/Linux overlap is 340. Windows ran zero tests: its build shard failed - first, and the Test Windows aggregator reported `skipped`. - -The primary partition below is exhaustive and sums to 501. Some diagnostic -families overlap the primary partition (for example a test can log both a busy -WAL checkpoint and an application-surface timeout); those correlations are -listed separately and must not be double-counted as independent failures. - -## Method - -1. Removed the log's literal `^[[...m` ANSI encoding. -2. Extracted every `test ... FAILED` identity. -3. Parsed nextest `FAIL`, `ABRT`, and `TMT` status lines and added identities - not emitted by libtest. -4. For each identity, inspected its retry block and captured the panic/error - text. Appendix A contains one compact evidence line for every identity. -5. Compared the failed head and local head, inspected the named reform - commits, and checked current production/test symbols. - -## Primary class table - -| Class | Count | Verdict | Single-concern task | -|---|---:|---|---| -| C01 retry-only failures | 24 | ENV/INFRA | Preserve as flake evidence; reproduce under equivalent load before changing code. | -| C02 Kiro live-handler fixture | 1 | STALE-TEST | Make the fixture provide the restored live-handler daemon boundary or assert the intentional fail-open diagnostic. | -| C03 semantic byte pins | 2 | STALE-TEST | Repin both workload hashes to the one authoritative 2170-chunk corpus. | -| C04 fact-store first touch | 1 | STALE-TEST | Replace removed `fact_store` dispatcher use with the canonical exact tool and daemon-owned profile setup. | -| C05 read-only ResetRequired authority | 1 | STALE-TEST | Expose the actual error, then assert the settled typed reset contract without pinning a superseded authority string. | -| C06 skill/tool discovery coverage | 1 | PRODUCT-BUG | Teach or explicitly internalize all 79 uncovered public tools; replace source-string coverage with behavioral discovery evidence. | -| C07a transcript concurrent CAS | 1 | PRODUCT-BUG | Restore one-winner compare-and-swap for concurrent full transcript batches. | -| C07b transcript summary projection fixtures | 2 | STALE-TEST | Publish summaries through the current verified lineage path or update counts to the intentional non-projection contract. | -| C08 worktree guard authority lifetime | 2 | STALE-TEST | Explicitly close or reuse the first runtime authority before the registry read; retain canonical-root assertions. | -| C09 LCM foreign-session canary | 1 | STALE-TEST | Use a non-secret canary; `sk-proj-...` is correctly rejected by privacy admission before lineage is exercised. | -| C10 update second-writer roster | 1 | STALE-TEST | Align the Cline expectation with the current stock-host canonical component set. | -| C11 socket Git stack overflow | 1 | PRODUCT-BUG | Isolate the recursive preview/apply route causing deterministic SIGABRT. | -| C12 session-registry convergence fixture | 4 | LIKELY-FIXED-BY `42bbaeb6a` + `f88610f33` | Confirm the four directly edited convergence tests under construction-time daemon scope. | -| C13 semantic evaluator paging timeouts | 7 | LIKELY-FIXED-BY `f8fec7b55` + `f282b313d` | Recheck the seven evaluator TMT identities after paged vector commit admission. | -| C14 missing database authority | 22 | STALE-TEST | Convert remaining fixtures to explicit daemon or exclusive-maintenance scopes; never widen ambient test authority. | -| C15 daemon/maintenance overlap | 7 | STALE-TEST | Remove nested incompatible scopes from fixture construction. | -| C16 macOS daemon clean early exit | 54 | ENV/INFRA | Isolate test daemons from nextest/process-group signals and record the terminating signal. | -| C17 configuration reset propagation | 4 | STALE-TEST | Seed the final configuration schema or assert ResetRequired; do not expect in-place migration. | -| C18 daemon disconnect/project retirement | 10 | PRODUCT-BUG | Keep the selected server alive through response settlement and close only after in-flight joins. | -| C19 application surface never mounts | 6 | PRODUCT-BUG | Find the owner that leaves the runtime in `mounting` for the full retry budget. | -| C21 SQLite lease expiry | 9 | PRODUCT-BUG | Renew/shorten bounded transactions so Linux load cannot expire live work. | -| C22 unverifiable summary timestamp | 4 | STALE-TEST | Add authoritative raw-source timestamps to the compression fixtures. | -| C23 memory graph publication conflict | 9 | PRODUCT-BUG | Make curator graph publication idempotent against its own verified head. | -| C24 WAL/graph lock lifecycle | 3 | PRODUCT-BUG | Drain readers/leases before checkpoint, reopen, or close. | -| C25 runtime timeout/deadlock | 22 | PRODUCT-BUG | Split by owner and fix missing completion/cancellation joins; do not raise timeouts. | -| C26 Dashboard response drift | 17 | STALE-TEST | Regenerate/align Dashboard fixtures to current typed envelopes and CAS fields. | -| C-P-AUTOMATION | 12 | PRODUCT-BUG | Repair automation execution/ledger/runtime failures. | -| C-P-DAEMON | 28 | PRODUCT-BUG | Repair daemon ownership, readiness, cancellation, and scheduler failures. | -| C-P-HOST | 19 | PRODUCT-BUG | Repair host mutation/rollback and privacy-admission behavior. | -| C-P-LCM | 7 | PRODUCT-BUG | Repair LCM privacy receipt and retained transport failures. | -| C-P-MCP | 21 | PRODUCT-BUG | Repair MCP dispatch, settlement, and process-tree behavior. | -| C-P-MEMORY | 4 | PRODUCT-BUG | Repair fact identity/lineage behavior. | -| C-P-MISC | 19 | PRODUCT-BUG | Repair isolated typed runtime defects not covered above. | -| C-P-SEARCH | 4 | PRODUCT-BUG | Repair remaining code-index/search activation failures. | -| C-P-SESSION | 1 | PRODUCT-BUG | Repair the remaining session ingestion failure. | -| C-P-STORAGE | 10 | PRODUCT-BUG | Repair storage identity, lock, and durable-state failures. | -| C-P-WORK | 5 | PRODUCT-BUG | Repair work/workflow routing and settlement failures. | -| C-S-AUTOMATION | 16 | STALE-TEST | Align automation DTO/status/count fixtures. | -| C-S-DAEMON | 23 | STALE-TEST | Align daemon contract assertions where the new typed state is intentional. | -| C-S-HOST | 35 | STALE-TEST | Align host/plugin/hook fixture contracts and generated assets. | -| C-S-LCM | 11 | STALE-TEST | Align LCM privacy, redaction, and retained-envelope fixtures. | -| C-S-MCP | 30 | STALE-TEST | Align tool names, schemas, parsers, and affected-test result DTOs. | -| C-S-MEMORY | 1 | STALE-TEST | Align the remaining fact-store expectation. | -| C-S-MISC | 14 | STALE-TEST | Align isolated renamed status/error expectations. | -| C-S-SEARCH | 5 | STALE-TEST | Align remaining search/eval pins and expected states. | -| C-S-SESSION | 5 | STALE-TEST | Align session/temporal fixture counts and hashes. | -| C-S-STORAGE | 10 | STALE-TEST | Align final-schema and storage error expectations. | -| C-S-WORK | 5 | STALE-TEST | Align work/workflow contract fixtures. | - -## Hypothesis verification - -### Known intentional reforms - -All six named reform commits are ancestors of the failed head, not post-run -fixes: - -- `c5c0a7663` live Hermes/Kiro handlers: ancestor of `c962cd627`. -- `2ec565ad5` unbound-observer/profile-minting behavior: ancestor. -- `e7a740457` 2170-chunk workload retarget: ancestor. -- `39629c4f5` daemon-pin corpus alignment: ancestor. -- `132b5b0ac` typed ResetRequired settlement: ancestor. -- `79406945a` canonical isolated-path spelling fix: ancestor. - -Therefore C02-C05 are expectation/fixture drift at the failed head; they are -not fixed merely because those commits exist locally. - -Evidence: - -- C02 actual: Kiro exits 0 with `{}` but emits - `local counter reset daemon call failed`; the test invokes a restored live - handler without a daemon and still requires empty stderr. -- C03 both tests compare actual - `sha256:068e...5610` against stale `sha256:a8e1...1347`. -- C04 fails before initialization with `unknown tool: 'fact_store'`; the - removed broad dispatcher cannot prove first-touch behavior. -- C05 still checks `authority == "graph store"` and does not print the actual - typed error, while the settlement reform preserves the owning authority's - ResetRequired state. If adding diagnostic context shows a non-ResetRequired - variant, reclassify this one test as PRODUCT-BUG rather than weakening it. - -### Skill coverage - -C06 is not one missing tool. The panic lists **79** uncovered tools: - -`tracedecay_github_stack_signal_expand`, `tracedecay_stack_snapshot`, -the five native-integration lifecycle tools, three multi-root tools, five -feedback tools, `tracedecay_affected_tests`, six fact-store read/reason tools, -`tracedecay_memory_status`, `tracedecay_session_refresh`, -`tracedecay_rename_symbol`, `tracedecay_observatory_read`, 32 work tools, -16 workflow-definition/run/handoff tools, and five worktree cleanup/inventory -tools. - -Exact uncovered set: - -- Stack/native/multi-root: - `tracedecay_github_stack_signal_expand`, `tracedecay_stack_snapshot`, - `tracedecay_preflight_native_integration`, - `tracedecay_approve_native_integration`, - `tracedecay_apply_native_integration`, - `tracedecay_native_integration_status`, - `tracedecay_cancel_native_integration`, - `tracedecay_multi_root_scope_set_read`, - `tracedecay_multi_root_scope_set_compare_and_swap`, - `tracedecay_multi_root_execute`. -- Feedback/memory/session: - `tracedecay_feedback_diagnostics`, `tracedecay_feedback_get`, - `tracedecay_feedback_expand`, `tracedecay_feedback_list`, - `tracedecay_feedback_impact`, `tracedecay_affected_tests`, - `tracedecay_fact_store_probe`, `tracedecay_fact_store_related`, - `tracedecay_fact_store_reason`, `tracedecay_fact_store_contradict`, - `tracedecay_fact_store_get`, `tracedecay_fact_store_list`, - `tracedecay_memory_status`, `tracedecay_session_refresh`, - `tracedecay_rename_symbol`, `tracedecay_observatory_read`. -- Work: - `tracedecay_work_generate_proposal`, `tracedecay_work_create`, - `tracedecay_work_review_proposal`, `tracedecay_work_accept_proposal`, - `tracedecay_work_admit_execution`, `tracedecay_work_start_attempt`, - `tracedecay_work_synthesize`, `tracedecay_work_attempt_status`, - `tracedecay_work_cancel_attempt`, `tracedecay_work_resume_attempts`, - `tracedecay_work_retry_attempt`, `tracedecay_work_list_attempts`, - `tracedecay_work_execution_history`, `tracedecay_work_hydrate_artifacts`, - `tracedecay_work_retrieve_evidence`, `tracedecay_work_views`, - `tracedecay_work_experience`, `tracedecay_work_compare_proposal`, - `tracedecay_work_prepare_graph_mutation`, - `tracedecay_work_mutate_graph`, `tracedecay_work_topology`, - `tracedecay_work_topology_metrics`, - `tracedecay_work_prepare_duplicate_adjudication`, - `tracedecay_work_adjudicate_duplicate`, - `tracedecay_work_adjudicate_leak`, `tracedecay_work_pause_run`, - `tracedecay_work_resume_run`, `tracedecay_work_run_control`, - `tracedecay_work_placement_preflight`, - `tracedecay_work_admit_placement`, - `tracedecay_work_placement_status`, - `tracedecay_work_release_placement`. -- Workflow: - `tracedecay_workflow_register_definition`, - `tracedecay_workflow_activate_definition`, - `tracedecay_workflow_retire_definition`, - `tracedecay_workflow_reject_definition`, - `tracedecay_workflow_validate_definition`, - `tracedecay_workflow_get_definition`, - `tracedecay_workflow_list_definitions`, - `tracedecay_workflow_definition_history`, - `tracedecay_workflow_diff_definition`, - `tracedecay_workflow_handoff_issue`, - `tracedecay_workflow_handoff_redeem`, - `tracedecay_workflow_start_run`, `tracedecay_workflow_pause_run`, - `tracedecay_workflow_resume_run`, `tracedecay_workflow_cancel_run`, - `tracedecay_workflow_get_run`. -- Worktree: - `tracedecay_worktree_inventory`, - `tracedecay_worktree_cleanup_inspect`, - `tracedecay_worktree_cleanup_confirm`, - `tracedecay_worktree_cleanup_remove`, - `tracedecay_worktree_cleanup_reconcile`. - -This is a real discoverability gap, although the current test's body-string -scan is not acceptable as the final behavioral acceptance mechanism. - -### macOS path hypothesis is disproved - -At failed head `c962cd627`, `under_isolated_root` already: - -1. tries raw `starts_with`; -2. canonicalizes the root through the deepest existing ancestor; -3. canonicalizes the candidate path the same way; -4. compares the canonical spellings. - -That is exactly the `/var` -> `/private/var` settlement from `79406945a`. -Moreover, the errors are not macOS-only: - -- missing managed-daemon/maintenance authority: 90 failed attempts across - 26 tests (macOS 48, Linux 42); -- daemon/maintenance overlap: 28 failed attempts across seven tests - (macOS 14, Linux 14). - -The remaining issue is fixture scope ownership, not path spelling. The -post-run `f88610f33` diff confirms this diagnosis by keeping a -`DaemonDatabaseScope` alive in the four convergence fixtures. - -### Environment/lifecycle families - -| Signature | Raw occurrences | Distinct tests | OS conclusion | Classification | -|---|---:|---:|---|---| -| daemon exits status 0 before socket | 107 | 54 | macOS only | ENV/INFRA signal leakage | -| WAL checkpoint `busy=1` | 34 | 15 | macOS + Linux | secondary lifecycle symptom | -| `application.surface.unavailable` | 95 | 17 | macOS + Linux | PRODUCT-BUG unless a stale reset fixture precedes it | -| configuration persisted shape reset | 16 | 4 | macOS + Linux | STALE-TEST fixture | -| daemon closed connection | 17 | 6 | macOS + Linux | PRODUCT-BUG | -| retained server retired | 14 | 12 | macOS + Linux | PRODUCT-BUG | -| child did not exit within 20s | 5 | 3 | Linux only | PRODUCT-BUG/process leak | -| SQLite transaction lease expired | 26 | 16 | Linux only | PRODUCT-BUG for terminal failures; retry-pass subset is ENV/INFRA | - -`run_foreground_unix` has no ordinary status-0 pre-socket return for these -fresh-home fixtures: after excluding the fresh-home-inapplicable account -deletion resume, a clean return comes from its Ctrl-C/SIGTERM select branch. -The macOS-only, empty-stderr pattern therefore points to test-runner/process- -group signal leakage, not a product startup rejection. - -The WAL line is often only logged by the daemon while the test later fails for -another reason. `checkpoint_result` truthfully returns busy/incomplete; the -dispatch task is lifecycle drainage, not weakening the error. - -### Named residual families - -- Transcript concurrent full batches: both writers return `Ok(())`; this is a - real split-brain/CAS defect. -- Transcript late-cursor and stale-higher tests: raw rows commit but summary - count remains zero. Their synthetic summary bypasses the current immutable - lineage publication contract; align the fixture before declaring a storage - regression. -- Worktree canonical-root pair: both fail before their canonical-root - assertions because a prior `TraceDecay` authority is still incompatible - with `HostAdmissionTestRuntimeV1::profile`; these fixtures use `drop` instead - of the explicit consuming `TraceDecay::close`. -- LCM canary: `FOREIGN_CANARY` is - `sk-proj-lineage-foreign-canary-1234567890`, intentionally secret-shaped; - privacy admission blocks setup before foreign-lineage disclosure is tested. -- Update second-writer: `host_owns_canonical_component_set` derives from all - stock host kinds with non-empty default components; Cline is now in that - canonical set, while the test still puts it with Zed/Roo/Kilo. -- Nextest-only abort: - `daemon::tests::socket::socket_git_preview_apply_replay_and_pre_admission_problems_are_canonical` - deterministically stack-overflows and SIGABRTs on both macOS and Linux. - -## Post-run commits that plausibly remove failures - -- `42bbaeb6a`: production now schedules historical schema convergence instead - of leaving it pending forever. -- `f88610f33`: maintenance mode is fixed at registry construction and the four - convergence tests now hold daemon database scope for the fixture lifetime. -- `5a00ee063`..`f282b313d`: semantic evaluation now exposes source errors, - isolates source projection, uses an identity receipt, pages durable vector - commits, and admits page-local change-set digests. -- `2ff144f83`: rustfmt-only checkpoint; it plausibly clears the Format job. - -These are **likely-fixed**, not verified: this task forbids Cargo runs. - -## Non-test CI blockers - -1. **Windows compile. PRODUCT-BUG, still open.** - `crates/tracedecay-usecases/src/retention/code_index_generations/scope_quarantine.rs:422` - calls `creation_time()` through the wrong metadata extension trait on - Windows (`cap_fs_ext::Metadata` implements `MetadataExt`, not - `OsMetadataExt`). The Windows test job consequently ran zero tests. -2. **Clippy. PRODUCT-BUG, still open.** - - `crates/tracedecay-policy/src/work_loop.rs:515`: 8 arguments. - - `crates/tracedecay-store/src/memory/project_memory/mod.rs:116`: 9 arguments. - - `crates/tracedecay-store/src/memory/project_memory/curation/operations.rs:226`: - redundant closure. - No post-run commit touches those files. -3. **Format. LIKELY-FIXED-BY `2ff144f83`.** - The failed paths (`paths_and_io.rs`, `semantic_evaluation.rs`, - `hook_cmd.rs`, `grafeo_restart_acceptance.rs`) are in the post-run rustfmt - delta. - -## Ordered fix dispatch - -1. Windows compile (`creation_time`) so the Windows shard can execute. -2. C11 deterministic stack overflow; it is a process abort, not assertion drift. -3. C16 macOS test-daemon signal isolation; it masks 54 identities. -4. Recheck C12 on the four directly changed convergence tests. -5. Recheck C13 semantic TMT tests after `f8fec7b55`/`f282b313d`. -6. C14/C15 fixture authority cutover; keep explicit production scopes. -7. C18/C19/C24/C25 daemon settlement, mounting, checkpoint, and join defects. -8. C21 Linux transaction lease expiry. -9. C07a transcript CAS. -10. C23 memory graph publication conflict. -11. C08 worktree authority lifetime. -12. C06 public-tool discovery/skill coverage. -13. C17/C22/C09 stale reset/timestamp/canary fixtures. -14. C02-C05 and C10 narrow stale reform expectations. -15. C26 Dashboard typed contract/CAS fixture alignment. -16. C-S-MCP and C-S-HOST generated tool/host contract alignment. -17. Remaining subsystem product lanes, then remaining stale-test lanes. -18. Clippy cleanup. -19. Run narrow non-vacuous checks per lane, then a fresh complete main-CI run. - -## Class membership index - -Appendix A is the affected-test list. The following selectors make the primary -partition reproducible: - -- C01 contains these 24 retry-only identities: - `agents::context_scout_model::tests::configured_model_measures_usage_when_backend_omits_token_counts`, - `agents::context_scout_model::tests::denied_backend_surfaces_denied_not_unavailable`, - `agents::context_scout_model::tests::disconnected_backend_surfaces_disconnect_not_unavailable`, - `agents::context_scout_model::tests::production_adapter_sends_only_bounded_candidates_and_retains_usage`, - `daemon::code_index_scheduler::tests::registry_feeds_publications_and_bounded_freshness_reads`, - `daemon::service::invocation::tests::lsp_tests::lsp_disconnect_expiry_settles_unacknowledged_outbound_as_dropped`, - `daemon::tests::bootstrap::remote_account_deletion_joins_admitted_open_before_enumeration_and_reconciles_restart`, - `exact_sql::tests::authority::long_lease_transaction_renews_its_lease_after_successful_bounded_steps`, - `global_db::upsert_session_message_preserves_oversized_text_losslessly`, - `jobs::concurrent_manual_job_triggers_do_not_double_execute`, - `lcm_payload::delete_external_payload_rejects_referenced_payload_without_hash_verification`, - `lcm_payload::replay_and_successor_reuse_reject_mutated_payload_global_authority`, - `lcm_payload::summary_publication_binds_external_payload_manifest_and_sanitization_receipt`, - `lcm_query::expand::expand_returns_sliced_raw_summary_and_payload_content_with_ranges`, - `lcm_query::status::status_reports_payload_gc_run_metadata_after_apply`, - `lcm_raw::transcript_ingest_preserves_lossless_raw_content`, - `persistence_failure_cannot_be_rewritten_as_a_clean_terminal`, - `runtime::lcm::payload::rollback_tests::direct_store_failure_rolls_back_metadata_and_payload_file`, - `session_temporal_benchmark::tests::fixture_refresh_persists_progress_before_measurement`, - `tool_daemon_test::cursor_after_shell_missing_daemon_exits_promptly_without_children`, - `tool_daemon_test::status_json_requests_compact_daemon_payload_noninteractively`, - `tool_daemon_test::tool_cli_invokes_mcp_tool_through_daemon_socket`, - `tool_daemon_test::tool_cli_rejects_truncated_json_rpc_response_without_hanging`, - `tool_daemon_test::tool_cli_skips_daemon_notifications_until_matching_response`. -- C02-C11 are the exact named tests in their table descriptions. -- C12 is the four `session_registry::tests` entries directly changed by - `f88610f33`: daemon admission, duplicate attach, convergence checkpoint, and - degraded convergence. -- C13 is the six nextest-only `candidate_output::tests` TMT entries plus - `packaged_assets::tests::packaged_evaluator_runs_against_an_unrelated_project`. -- C14-C24 select Appendix entries by their literal evidence phrase: - missing database authority, scope overlap, status-0 daemon exit, - configuration reset, daemon-close/retired-server, application-surface - unavailable, transaction expiry, unverifiable timestamp, memory graph - publication conflict, and WAL/graph lock respectively. Earlier exact classes - win when an identity matches more than one selector. -- C25 selects remaining terminal timeout/deadline/`Elapsed` identities. -- C26 is every Dashboard identity. -- Remaining identities are partitioned by subsystem prefix and then by - evidence: expected-value/schema/name/DTO drift is `C-S-*`; runtime errors, - conflicts, unavailable states, lock failures, and invariant violations are - `C-P-*`. - -## Appendix A, all failing identities and compact error snippets - -Format: `identity [jobs], first useful panic/error text`. ``, ``, -and `` normalize unstable values. `nextest` marks failures that had no -`test ... FAILED` line. - -- `advanced_workflow_journey_test::mounted_fan_out_recovers_then_synthesizes_and_hands_off` [Linux/macOS], advanced Work TaskSession journey requires the byte-pinned FastEmbed fixture in TRACEDECAY_DISTRIBUTION_FASTEMBED_FIXTURE -- `agent_cmd::tests::codex_core_rollback_restores_generated_agent_exports_byte_for_byte` [Linux/macOS], called `Result::unwrap()` on an `Err` value: NativeUpdateRequired -- `agent_cmd::tests::codex_native_removed_retry_cleans_receipt_owned_source` [Linux/macOS], called `Result::unwrap()` on an `Err` value: bundle ownership marker conflicts or is ambiguous -- `agent_cmd::tests::kimi_canonical_component_set_fails_before_direct_host_mutation` [Linux/macOS], assertion failed: error does not contain `host capability is unsupported` -- `agent_cmd::tests::kiro_context_mcp_apply_converges_without_rollback` [macOS]. Kiro Install apply did not converge after an interrupted host-bundle operation -- `agent_cmd::tests::opencode_core_rollback_restores_every_registration_side_effect` [Linux/macOS], `StalePreview` at host component registration -- `agents::codex::mcp_registry::tests::add_and_remove_preserve_an_operator_owned_peer_server` [macOS], host add changed Codex-owned peer state and left host state unaccepted -- `agents::context_scout_model::tests::configured_model_measures_usage_when_backend_omits_token_counts` [Linux], `DeadlineExceeded` -- `agents::context_scout_model::tests::denied_backend_surfaces_denied_not_unavailable` [Linux], `Err(DeadlineExceeded)` vs `Err(Denied)` -- `agents::context_scout_model::tests::disconnected_backend_surfaces_disconnect_not_unavailable` [Linux], `Err(DeadlineExceeded)` vs `Err(Disconnected)` -- `agents::context_scout_model::tests::production_adapter_sends_only_bounded_candidates_and_retains_usage` [Linux], `DeadlineExceeded` -- `agents::copilot::tests::add_and_remove_preserve_an_operator_owned_peer_server` [macOS], host add changed peer MCP servers and left host state unaccepted -- `agents::host_cli::tests::env_shebang_interpreter_is_resolved_before_ambient_path_is_cleared` [macOS], interpreter command bytes differ despite the same rendered command -- `agents::kiro::tests::add_and_remove_preserve_an_operator_owned_peer_server` [macOS], forced host add changed peer MCP servers and left host state unaccepted -- `agents::kiro::tests::rollback_refuses_a_foreign_registry_write_after_cli_apply` [macOS], fake native add returned `StorageFailure` -- `agents::path_normalize_tests::path_lookup_preserves_non_unicode_parent_components` [macOS], `Illegal byte sequence` -- `analytics_api::tests::diagnostics_summary_aggregates_real_hook_completed_rows_safely` [Linux/macOS], `"unavailable"` vs `"measured"` -- `api::automation_outcomes_endpoint_returns_live_read_only_outcomes` [Dashboard], expected activated skill outcome; response contains facts but no skills -- `api::holographic_dashboard_endpoints_return_seeded_payloads` [Dashboard], graph-assist coverage is null, expected `"complete"` -- `api::holographic_fact_detail_returns_full_content_and_entities` [Dashboard], expected `linked_entities` -- `api::lcm_endpoints_cover_seeded_fts_and_like_fallback` [Dashboard], null vs true -- `api::lcm_project_store_wins_over_global_accounting_override` [Dashboard], null vs `"profile_sharded"` -- `api::lcm_serves_project_session_store_without_global_override` [Dashboard], null vs `"profile_sharded"` -- `application_surface::tests::catalog_bound_compatibility_tools_resolve_before_retained_dispatch` [Linux/macOS], live catalog-bound compatibility-tool set differs from expected set -- `authentic_callback_to_all_delivery_surfaces` [Linux/macOS]. LSP surface absent -- `authority_tests::a_selected_project_answers_feedback_and_work_reads_and_nothing_else_by_post` [Linux/macOS], `None` vs `Some(Work)` -- `authority_tests::application_routes_are_active_project_only` [Linux/macOS]. HTTP 404 vs 204 -- `authority_tests::dashboard_user_job_run_without_automation_authority_fails_closed` [Linux/macOS]. HTTP 500 vs 503 -- `authority_tests::graph_overview_returns_the_canonical_dashboard_envelope` [Linux/macOS], `"unknown"` vs `"ready"` -- `authority_tests::memory_status_returns_the_canonical_dashboard_envelope` [Linux/macOS], `"error"` vs `"ready"` -- `automation::automation_run_artifact_api_serves_verified_sidecar_payloads` [Dashboard], malformed automation ledger `started_at` -- `automation::final_self_improvement_smoke_covers_autonomous_curation_and_skill_deployment` [Dashboard], settings patch lacks `expected_revision_id` -- `automation::jobs::effect_receipt::tests::file_write_failure_after_open_is_a_bound_partial_effect` [macOS], post-open failure was not a partial effect -- `automation_jobs::dashboard_user_job_history_appears_only_after_retained_settlement` [Dashboard], user-job backend never reached admitted execution -- `automation_skills::managed_skills_are_dashboard_controllable_with_direct_activation` [Dashboard], disable did not export to Claude -- `backend::classifies_backend_failures_for_retry_policy` [Linux/macOS], `Disconnected` vs `Unavailable` -- `backend::failure_disposition_heals_stale_recorded_retryability` [Linux/macOS], `Some(Disconnected)` vs `Some(Unavailable)` -- `cancellation_before_relational_cas_keeps_the_prior_head_current` [macOS], `Conflict` -- `cancelling_generic_tool_reaps_child_and_closes_request` [Linux/macOS], daemon exited status 0 before accepting connections -- `candidate_output::tests::candidate_bytes_match_direct_production_calls` [nextest]. TMT after 360s -- `candidate_output::tests::direct_outputs_cover_train_and_validation` [nextest]. TMT after 360s -- `candidate_output::tests::evaluation_rejects_optional_stage_status_that_disagrees_with_profile` [nextest]. TMT after 360s -- `candidate_output::tests::published_corpus_maps_production_source_occurrences` [Linux/macOS], `Unavailable(ReadFailed)` vs `Complete` -- `candidate_output::tests::query_phrase_and_historical_queries_reach_their_checked_in_anchors` [Linux/macOS], checked-in historical anchor absent -- `candidate_output::tests::rerank_profiles_remain_pending_when_no_rerank_measurement_ran` [nextest]. TMT after 360s -- `candidate_output::tests::resource_evidence_enforces_state_budgets_and_exact_catalog` [nextest]. TMT after 360s -- `candidate_output::tests::semantic_profiles_do_not_claim_a_comparison_when_only_fallback_ran` [nextest]. TMT after 360s -- `claude_plugin_bundle_test::claude_agents_allow_only_live_read_only_mcp_tools` [Linux/macOS], automation-auditor grants `tracedecay_analytics`, whose live `readOnlyHint` is not true -- `claude_plugin_schema_test::claude_bundle_hooks_config_matches_the_claude_hooks_schema` [Linux/macOS]. Claude hooks schema rejects `PostCompact` -- `cli_args_contract_test::arg_catalog_table_flags_exist_in_tool_schemas` [Linux/macOS], catalog documents `--keywords` absent from context schema and documents removed `fact_store` -- `cli_args_contract_test::managed_skill_guidance_matches_automatic_activation` [Linux/macOS], managed-skill approval/review-queue guidance is stale -- `cli_mcp_and_http_dispatch_the_same_callable_contracts` [Linux/macOS], `github_stack_signal_expand` lacks parity-golden accounting -- `cli_non_interactive_test::automation_config_enable_writes_canonical_project_setting_noninteractively` [Linux/macOS], application surface unavailable, then not-found/not-authorized -- `cli_non_interactive_test::automation_config_set_rejects_unimplemented_external_backend` [Linux/macOS], stderr lacks `unknown automation backend` -- `cli_non_interactive_test::automation_config_set_writes_complete_canonical_project_setting_noninteractively` [Linux/macOS], application surface unavailable, then not-found/not-authorized -- `cli_non_interactive_test::branch_add_tracks_the_branch_on_the_single_project_store` [Linux/macOS], code-index scheduler unavailable for branch activation -- `cli_non_interactive_test::branch_gc_preserves_profile_shard_without_repository_evidence` [Linux/macOS], configuration persisted shape requires reset -- `cli_non_interactive_test::branch_list_reads_profile_sharded_branch_meta` [Linux/macOS], fixture project never mounted -- `cli_non_interactive_test::branch_remove_deletes_branch_db_from_profile_shard` [Linux/macOS], configuration persisted shape requires reset -- `cli_non_interactive_test::branch_remove_deletes_branch_local_memory_without_cutover_receipt` [Linux/macOS], configuration persisted shape requires reset -- `cli_non_interactive_test::branch_removeall_deletes_profile_shard_branch_dbs` [Linux/macOS], configuration persisted shape requires reset -- `cli_non_interactive_test::fact_store_curate_records_backend_disabled_skip_and_preserves_read_only_inspection` [Linux/macOS], null vs `"memory_curator"` -- `cli_non_interactive_test::gitignore_reads_effective_config_for_primary_and_linked_worktrees` [Linux], application surface unavailable while reading effective configuration -- `cli_non_interactive_test::init_skips_gitignore_prompt_when_stdin_not_a_terminal` [Linux/macOS], initialization stderr no longer matches the expected noninteractive text -- `cli_non_interactive_test::install_codex_automation_enables_daemon_owned_project_configuration_noninteractively` [Linux/macOS], `Text file busy` -- `cli_non_interactive_test::list_all_reports_orphan_manifest_reconstructable_store` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::list_all_reports_profile_sharded_store_without_stale_label` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::list_all_uses_registry_profile_shard_when_enrollment_marker_missing` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::projects_context_resolves_linked_worktree_path_by_git_common_dir` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::projects_context_resolves_project_id_and_path` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::projects_list_json_reads_global_registry` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::projects_search_text_matches_registered_alias` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::sessions_search_omits_absent_optional_filters_and_preserves_provider` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::sessions_unfinished_lists_workflow_state_evidence` [Linux/macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::status_reports_uninitialized_project_without_creating_it` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::status_surfaces_split_identity_conflict_without_suggesting_init` [Linux/macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::storage_report_prints_registered_store_size_and_unregistered_backlog` [Linux/macOS], report JSON is empty/EOF -- `cli_non_interactive_test::storage_report_uses_active_daemon_authority_without_hanging` [macOS], daemon exited status 0 before accepting connections -- `cli_non_interactive_test::wipe_all_removes_profile_sharded_store_and_global_row` [macOS], shard `tracedecay.db` remains -- `cli_non_interactive_test::wipe_all_removes_registry_backed_profile_shard_without_enrollment_marker` [macOS], registry-backed profile shard remains -- `code_diagnostics::code_diagnostics_dashboard_api_exposes_engines_and_applies_settings` [Dashboard]. HTTP 503 vs 200 -- `codex_compaction::codex_post_compact_hook_commits_app_server_summary_through_daemon_effect` [Linux/macOS], daemon exited status 0 before accepting connections -- `codex_goals::codex_workflow_lifecycle_secret_content_is_sanitized_before_persistence` [Linux/macOS], secret-bearing goal was not sanitized before persistence -- `codex_response_items::codex_goal_response_item_is_cataloged_as_context` [Linux/macOS]. LCM privacy sanitizer receipt construction failed -- `codex_response_items::codex_response_item_skips_developer_messages_and_keeps_reasoning_summaries` [Linux/macOS]. LCM privacy sanitizer receipt construction failed -- `codex_usage::codex_structured_events_produce_full_row_mix` [Linux/macOS]. LCM privacy sanitizer receipt construction failed -- `commands::storage::wipe_target_tests::registered_project_paths_preserve_non_unicode_roots` [macOS], expected non-Unicode path absent -- `compare_reports_unmeasured_semantic_and_rerank_stages_as_pending` [Linux/macOS], `"fail"` vs `"pending"` -- `config::tests::runtime_configuration_cutover::resolve_runtime_configuration_pins_registered_project_when_cache_is_cold` [Linux/macOS], configuration ResetRequired: no canonical revision -- `config::validation_rejects_zero_scheduler_tick_secs` [Linux/macOS], error no longer contains `scheduler_tick_secs` -- `cursor::cursor_pre_compact_without_native_payload_is_read_only_and_unavailable` [Linux/macOS], daemon exited status 0 before accepting connections -- `cursor::cursor_transcript_ingest_retries_after_mid_batch_db_failure` [Linux/macOS], required projection-audit trigger missing -- `cursor_composer::composer_envelope_todo_secret_is_sanitized_before_persistence` [Linux/macOS], secret-bearing todo was not sanitized -- `cursor_native_extension_receipt_matches_embedded_assets` [Linux/macOS], `Corrupt` vs `Current` -- `daemon::automation_effect::journal::tests::cancellation_observed_under_lock_leaves_foreign_reservation_pending` [Linux/macOS], curation receipt missing `accepted_operations` -- `daemon::automation_effect::journal::tests::durable_journal_rejects_swapped_partial_receipts_before_write` [Linux/macOS], curation receipt missing `accepted_operations` -- `daemon::automation_effect::journal::tests::durable_journal_reports_changed_project_owner_as_a_conflict` [Linux/macOS], prepared-effect binding now rejected as inconsistent -- `daemon::automation_effect::journal::tests::durable_journal_reports_changed_scope_identity_as_a_conflict` [Linux/macOS], recovery problem now rejected as inconsistent -- `daemon::automation_effect::journal::tests::durable_journal_reports_changed_task_identity_as_a_conflict` [Linux/macOS], recovery problem now rejected as inconsistent -- `daemon::automation_effect::journal::tests::foreign_reservation_recovery_persists_exact_partial_terminal` [Linux/macOS], curation receipt missing `accepted_operations` -- `daemon::automation_effect::journal::tests::pending_index_survives_physical_reopen_and_closes_after_terminal` [Linux/macOS], curation receipt missing `accepted_operations` -- `daemon::automation_effect::journal::tests::physical_reopen_rejects_a_corrupt_swapped_terminal` [Linux/macOS], corrupt fixture rejected during construction as inconsistent -- `daemon::automation_effect::journal::tests::project_open_repairs_corrupt_append_intent_at_clean_eof_without_pending_journals` [Linux/macOS], corrupt append-intent path missing -- `daemon::automation_effect::journal::tests::reserved_admission_conflict_preserves_recovery_index` [Linux/macOS], prepared-effect binding rejected as inconsistent -- `daemon::automation_effect::journal::tests::reserved_read_removes_an_orphan_terminal_sidecar` [Linux/macOS], expected reservation read error did not occur -- `daemon::automation_effect::journal::tests::retained_projector_panic_finishes_recovery_before_releasing_task_lock` [nextest]. TMT after 360s -- `daemon::automation_effect::journal::tests::terminal_admission_conflict_preserves_existing_cleanup_authority` [Linux/macOS], prepared-effect binding rejected as inconsistent -- `daemon::automation_effect::projection::tests::all_noop_curation_projects_accepted_effects_without_mutation_or_anchors` [Linux/macOS], canonical all-noop receipt contains another owner's fact -- `daemon::branch_admin::tests::profile_bootstrap_preserves_future_spool_reset_without_retry_mapping` [Linux/macOS], profile identity root permissions are not 0700 -- `daemon::code_index_scheduler::activation_tests::cold_mount_defers_sealed_decode_and_truth_verification_to_the_retained_owner` [macOS], retained owner did not activate the sealed generation -- `daemon::code_index_scheduler::branch_generations::tests::mounted_store_diffs_two_clean_exact_commit_generations` [Linux/macOS], exact-generation read timed out -- `daemon::code_index_scheduler::ignored_dependencies_tests::cancellation_tests::admitted_source_read_observes_live_cancellation_between_chunks` [macOS], unexpected `IgnoredDependency(SymlinkEscape)` -- `daemon::code_index_scheduler::tests::registry_feeds_publications_and_bounded_freshness_reads` [Linux], initial publication timed out -- `daemon::git_transactions::native::tests::apply_rematerializes_exact_commit_input_after_executor_restart` [Linux/macOS], preview is `Unsupported` -- `daemon::git_transactions::native::tests::files_ref_backend_exposes_no_destination_publication_window` [Linux/macOS], typed preview is `Unsupported` -- `daemon::git_transactions::native::tests::native_blockers_never_mint_a_preview_from_stale_caller_state` [Linux/macOS], stale materialization did not return `StalePreview` -- `daemon::git_transactions::native::tests::snapshot_capture_agrees_across_symlink_repository_root_aliases` [Linux/macOS], drifted preview CAS did not report stale preview -- `daemon::http_application_tests::daemon_http_authenticated_operations_cancel_and_resume_through_canonical_owner` [Linux/macOS], cancel returned HTTP 503 -- `daemon::invocation_executor::controlled_invocation_tests::in_process_effect_without_settlement_returns_reset_required` [Linux/macOS], authoritative join timed out -- `daemon::lcm_effects::tests::codex_and_cursor_daemon_adapters_commit_exact_authoritative_summaries` [Linux/macOS], `"needs_summary"` vs `"ok"` -- `daemon::lcm_effects::tests::compression_producer_apply_read_and_rollback_stay_one_authority` [Linux/macOS], relation reads reconstructed a pending projection -- `daemon::production_harness::configuration_idempotency_journey_test::configuration_set_has_cli_mcp_http_sdk_parity_and_replays_after_restart` [Linux/macOS], first CLI configuration effect is not-found/not-authorized -- `daemon::production_harness::configuration_idempotency_journey_test::credential_effect_uses_the_durable_request_operation_digest` [Linux/macOS], typed tool payload is invalid JSON -- `daemon::production_harness::configuration_idempotency_journey_test::user_profile_configuration_batch_has_cli_dashboard_parity_after_restart` [Linux/macOS], session relation graph database remains locked on restart -- `daemon::production_harness::generation_retention_test::linked_worktree_scope_retention_crash_replay_and_pure_inventory_journey` [Linux/macOS], distribution FastEmbed fixture missing -- `daemon::production_harness::generation_retention_test::mounted_daemon_maintenance_retains_activation_lease_and_converges_after_restart` [Linux/macOS], committed query-only profile did not expose known-empty retention authority -- `daemon::production_harness::semantic_activation_journey_test::public_semantic_activation_rollback_and_exact_retry_preserve_graph_authority` [Linux/macOS], distribution FastEmbed fixture missing -- `daemon::project_open_owners::code_index_reads::ignored_dependency_admission_tests::writable_binding_returns_only_after_exact_scope_generation_is_warm_and_serving` [Linux/macOS], code graph projection had not completed activation -- `daemon::query_authority_provider::tests::activation_tests::committed_query_routes_install_and_rollback_as_one_revision` [macOS], active vector generation is `None` -- `daemon::retained_owner::memory_target::tests::selected_project_opens_its_exact_read_only_store_not_the_active_store` [macOS], `Unavailable` -- `daemon::retained_owner::session::retained_effect_tests::retained_begin_and_join_report_partial_effect_and_restart_recovers_same_operation` [Linux/macOS], manifest digest mismatch -- `daemon::retained_owner::session::retained_effect_tests::retained_cancel_reports_partial_effect_with_canonical_cancelled_receipt` [Linux/macOS], manifest digest mismatch -- `daemon::scheduler::combined_effect::tests::conflicting_reflector_abandons_only_the_fresh_skill_reservation` [macOS], 0 vs 1 -- `daemon::scheduler::combined_effect::tests::conflicting_skill_abandons_only_the_fresh_reflector_reservation` [macOS], 0 vs 1 -- `daemon::scheduler::combined_effect::tests::partial_replay_reuses_prior_scheduler_skip_without_current_publication` [macOS], duplicate project authority -- `daemon::service::invocation::tests::dispatch_tests::feedback_handles_fail_closed_without_an_owner` [Linux/macOS], absent owner returns `Unavailable`, not expected application problem shape -- `daemon::service::invocation::tests::dispatch_tests::multi_root_payloads_are_not_served_by_the_per_project_service` [Linux/macOS], response is not `InvalidRequest` -- `daemon::service::invocation::tests::lsp_tests::lsp_disconnect_expiry_settles_unacknowledged_outbound_as_dropped` [Linux], observability persistence deadline -- `daemon::service::invocation::tests::project_lifecycle_tests::recovery_quiescence_retires_only_the_selected_projects_lsp_owners` [Linux/macOS], authorized-root scope set is invalid -- `daemon::service::project_runtime::observability_tests::each_producer_lifetime_uses_a_disjoint_ordered_stream` [Linux/macOS], delivery settlement recorder already running -- `daemon::service::project_runtime::observability_tests::exact_profile_routing_collapses_linked_roots_without_crossing_profiles` [Linux/macOS], expected distinct brain IDs but both are `brain.test-runtime` -- `daemon::session_sync::tests::cancel_in_alias_activation_gap_mirrors_primary_terminal_receipt` [Linux/macOS], project cannot bind a foreign session shard -- `daemon::store_runtime::session_registry::code_graph::seals::tests::project_replay_pool_serializes_same_digest_from_distinct_sources` [macOS], `Conflict` -- `daemon::store_runtime::session_registry::code_graph::seals::tests::replay_seal_publish_preserves_foreign_existing_destination` [Linux/macOS], `Corrupt` vs `Conflict` -- `daemon::store_runtime::session_registry::project_memory_relation_graph_contract_tests::registered_memory_relation_graph_survives_restart_and_isolates_topologies` [Linux/macOS], mounted graph reconciliation did not settle -- `daemon::store_runtime::session_registry::remote_recovery::publication::tests::failed_phase_transition_preserves_the_previous_active_fence` [Linux/macOS], rollback unexpectedly required -- `daemon::store_runtime::session_registry::remote_recovery::publication::tests::stale_mounted_runtime_rejects_the_replacement_identity` [Linux/macOS], error lacks `mounted identity` -- `daemon::store_runtime::session_registry::remote_recovery::publication::tests::unverified_destination_is_quarantined_before_retained_rollback_is_restored` [Linux/macOS], expected file missing -- `daemon::store_runtime::session_registry::tests::background_convergence_commits_the_durable_authority_checkpoint` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::store_runtime::session_registry::tests::background_convergence_failure_remains_observable_as_degraded` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::store_runtime::session_registry::tests::cached_project_sessions_reject_conflicting_enrollment_authority` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::store_runtime::session_registry::tests::daemon_admission_returns_while_historical_convergence_is_blocked` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::store_runtime::session_registry::tests::duplicate_project_attaches_schedule_one_historical_convergence` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::store_runtime::session_registry::tests::existing_profile_memory_uses_final_schema_and_canonical_linked_lineage` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::store_runtime::session_registry::tests::profile_sessions_mount_rejects_incompatible_schema_through_registered_runtime` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::store_runtime::session_registry::tests::profile_sessions_mount_uses_the_durable_profile_identity_and_profile_pin` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::store_runtime::session_registry::tests::project_sessions_mount_uses_typed_enrollment_and_is_idempotent` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::store_runtime::session_registry::tests::read_only_worktree_mount_never_recreates_a_deleted_database` [Linux/macOS], missing managed-daemon/maintenance authority -- `daemon::tests::bootstrap::account_tombstone_denies_projectless_memory_and_profile_automation` [macOS], account deletion fails with authority unavailable -- `daemon::tests::bootstrap::daemon_restart_resumes_account_tombstone_without_ordinary_admission` [macOS], missing managed-daemon/maintenance authority -- `daemon::tests::bootstrap::direct_tool_cache_miss_returns_warming_while_project_opens_in_background` [Linux/macOS], bounded warming response timed out -- `daemon::tests::bootstrap::linked_route_reuses_primary_authority_while_shadow_writer_is_held` [Linux/macOS], routes do not resolve one retained server -- `daemon::tests::bootstrap::mcp_bootstrap_catalog_bypasses_project_writer_gate` [Linux/macOS], initialize waits on writer gate -- `daemon::tests::bootstrap::portable_broker_bootstrap_bypasses_project_writer_gate` [Linux/macOS], portable initialize waits on writer gate -- `daemon::tests::bootstrap::production_composition_dashboard_persists_project_settings_over_http` [Linux/macOS], settings patch lacks `idempotency_key` -- `daemon::tests::bootstrap::production_composition_harness_dispatches_application_invocations_in_process` [Linux/macOS], `tracedecay_storage_status` exceeds absolute deadline -- `daemon::tests::bootstrap::production_composition_harness_reads_retained_profile_analytics_authority` [Linux/macOS], retained analytics event absent -- `daemon::tests::bootstrap::production_composition_harness_shutdown_allows_immediate_profile_reopen` [Linux/macOS], session relation graph remains locked -- `daemon::tests::bootstrap::production_composition_harness_wires_cross_project_resolver` [Linux/macOS], top-level `project_path` rejected as a selector -- `daemon::tests::bootstrap::production_composition_mounts_core_query_without_optional_stage_evaluation` [Linux/macOS], core query authority never becomes ready -- `daemon::tests::bootstrap::project_open_shutdown_retains_noncooperative_task_until_retry_joins_it` [Linux/macOS], zero-deadline shutdown unexpectedly succeeds -- `daemon::tests::bootstrap::remote_account_deletion_joins_admitted_open_before_enumeration_and_reconciles_restart` [Linux], account tombstone persistence timed out -- `daemon::tests::bootstrap::unenrolled_ambient_directory_is_rejected_before_project_warmup` [macOS], authority error masks missing-enrollment error -- `daemon::tests::bootstrap::unenrolled_leaf_is_rejected_from_cache_and_direct_open` [macOS], authority error masks missing-enrollment error -- `daemon::tests::handshake::daemon_refreshes_once_only_after_generation_change` [Linux/macOS], socket client lacks managed-daemon/maintenance authority -- `daemon::tests::handshake::initialized_ack_preserves_pending_catalog_refresh_notification` [Linux/macOS], socket client lacks managed-daemon/maintenance authority -- `daemon::tests::invocation_ownership::committed_project_invocation_routes_mounted_operations` [Linux/macOS]. Work route returns not-found/not-authorized -- `daemon::tests::multi_root_journey::authenticated_multi_root_journey_reaches_scope_set_storage` [Linux/macOS], journey thread panics -- `daemon::tests::ownership::fresh_committed_project_open_mounts_feedback_before_lsp` [Linux/macOS], committed Git identity has no feedback cycle -- `daemon::tests::ownership::released_automation_tombstone_allows_one_eventual_replacement` [Linux/macOS], replacement owner-key mismatch panic -- `daemon::tests::remote_project_recovery::recovery_quiesces_only_a_and_remounts_its_retry_route` [Linux/macOS], graph runtime close conflicts -- `daemon::tests::replay::client_identity_startup_replays_retained_profile_receipts` [macOS], 0 vs 1 -- `daemon::tests::replay::projectless_user_session_setup_failure_returns_json_rpc_error` [Linux/macOS], authenticated profile mismatch -- `daemon::tests::rmcp_route::portable_production_route_selects_rmcp_after_initialize` [Linux/macOS], cancelled route does not terminate -- `daemon::tests::rmcp_route::production_rmcp_cancels_registered_and_pre_registration_requests` [Linux/macOS], cancelled requests do not terminate -- `daemon::tests::rmcp_route::selected_target_rmcp_flushes_response_and_full_disconnect_cancels_target` [Linux/macOS], daemon and maintenance scopes overlap -- `daemon::tests::rmcp_route::unix_production_route_selects_rmcp_only_after_initialize` [Linux/macOS], cancelled route does not terminate -- `daemon::tests::runtime_identity::concurrent_same_identity_worktrees_keep_exact_server_and_scheduler_bindings` [Linux/macOS], follow-up sees code graph unavailable instead of retaining linked route -- `daemon::tests::scheduler_config::cached_project_reconciles_cli_enabled_automation_without_cache_probe` [Linux/macOS], scheduler key remains when expected absent -- `daemon::tests::scheduler_config::daemon_scheduler_discovery_without_work_does_not_wait_for_writer_gate` [Linux/macOS], read-only scheduler discovery waits on writer gate -- `daemon::tests::scheduler_config::daemon_scheduler_skips_stale_owner_key_after_rekey` [Linux/macOS], scheduler starts under a stale owner key -- `daemon::tests::scheduler_config::disabled_scheduler_reconcile_cannot_acknowledge_an_owner_that_then_exits` [Linux/macOS], `RunningNotified` vs `Started` -- `daemon::tests::scheduler_config::fresh_v2_project_starts_the_required_automation_scheduler` [Linux/macOS], scheduler key absent -- `daemon::tests::scheduler_config::profile_reconcile_broadcasts_to_cached_projects_without_opening_uncached_projects` [macOS], projectless profile reconcile sees authenticated profile mismatch -- `daemon::tests::socket::daemon_linked_worktree_route_repairs_primary_identity_and_keeps_alias` [Linux/macOS], linked-root path remains canonical instead of primary path -- `daemon::tests::socket::socket_client_requires_user_storage_scope_without_project` [Linux/macOS], projectless handshake message changed -- `daemon::tests::socket::socket_git_preview_apply_replay_and_pre_admission_problems_are_canonical` [nextest], stack overflow; SIGABRT on both attempts -- `daemon::tests::socket::user_session_read_bypasses_unregistered_project_route` [Linux/macOS], user-session read times out -- `dashboard_project_settings_commit_through_the_daemon_control_plane` [Linux/macOS], daemon exited status 0 before accepting connections -- `dashboard_user_settings_replay_through_application_restart` [Linux/macOS], daemon exited status 0 before accepting connections -- `direct_lifecycle_entry_points_retain_production_authority` [Linux/macOS], graph database locked by another process -- `dropped_reservation_releases_its_fence_after_caller_cancellation` [macOS], `Conflict` -- `duplicate_receipt_corrections_choose_the_latest_revision_across_anchors_and_fragment_order` [Linux/macOS], `IncompatibleFragments` -- `embedded_component_sets_complete_lifecycle_for_all_supported_hosts` [Linux/macOS], `ArtifactContentMismatch` -- `every_cursor_carrying_code_operation_mints_and_spends_a_continuation` [Linux/macOS], code-symbol search remains `application.surface.unavailable` after ~60s -- `every_journey_operation_binds_to_cli_and_mcp_and_withholds_http` [Linux/macOS], operation count 11 vs 6 -- `exact_project_profile_identity_reuses_one_persistent_handle` [macOS], `Conflict` -- `exact_search_does_not_wait_for_semantic_projection` [Linux/macOS], `tracedecay init` rejects stale `--quiet` -- `exact_sql::tests::authority::long_lease_transaction_renews_its_lease_after_successful_bounded_steps` [macOS], `TransactionExpired` -- `exact_verified_generation_lease_blocks_retirement_until_activation_releases_it` [macOS], `Conflict` -- `expired_deadline_does_not_open_or_close_a_registered_store` [macOS], `Conflict` -- `explorer::explorer_query_coordinates_real_sources_without_inventing_a_merge` [Dashboard], `"unavailable"` vs `"ready"` -- `explorer::explorer_session_routes_reuse_lcm_size_and_read_context_authority` [Dashboard], `"unknown"` vs `"ready"` -- `fact_merge_hydration::contradictions_are_recorded_explicitly_in_lineage` [Linux/macOS], `FactNotFound` returned where storage error expected -- `fact_merge_hydration::failed_fact_batch_rolls_back_identity_assertion_anchor_and_lineage` [Linux/macOS], `FactNotFound` returned after staged writes -- `fact_merge_hydration_test::contradictions_are_recorded_explicitly_in_lineage` [Linux/macOS], `FactNotFound` returned where storage error expected -- `fact_merge_hydration_test::failed_fact_batch_rolls_back_identity_assertion_anchor_and_lineage` [Linux/macOS], `FactNotFound` returned after staged writes -- `fixture_authority_test::committing_a_fixture_tree_never_stages_enrollment_state` [Linux/macOS], daemon and maintenance scopes overlap -- `fixture_authority_test::enrolled_layout_comes_from_the_opened_graph` [Linux/macOS], daemon and maintenance scopes overlap -- `fixture_authority_test::one_profile_serves_two_projects_with_distinct_stores` [Linux/macOS], daemon and maintenance scopes overlap -- `generic_tool_accepts_slow_byte_stream` [Linux/macOS], daemon exited status 0 before accepting connections -- `generic_tool_accepts_split_json_rpc_frame` [macOS], daemon exited status 0 before accepting connections -- `generic_tool_handles_concurrent_requests_without_crosstalk` [Linux/macOS], daemon exited status 0 before accepting connections -- `generic_tool_preserves_late_reply_within_response_grace` [Linux/macOS], daemon exited status 0 before accepting connections -- `generic_tool_rejects_semantic_truncation_envelope_without_output` [macOS], daemon exited status 0 before accepting connections -- `generic_tool_rejects_truncated_frame_without_output` [Linux/macOS], daemon exited status 0 before accepting connections -- `generic_tool_rejects_unrepresentable_deadline` [macOS], daemon exited status 0 before accepting connections -- `git_index_transactions::tests::configured_merge_diff_and_filter_drivers_are_preview_only` [Linux/macOS], external drivers still present -- `global_db::open_at_upgrades_existing_global_db_with_analytics_events_table` [Linux/macOS], session-temporal ResetRequired replaces upgrade -- `global_db::open_at_upgrades_existing_sessions_table_with_parent_columns` [Linux/macOS], session-temporal ResetRequired replaces upgrade -- `global_db::search_session_messages_git_scoped_by_branch_with_hyphen_term` [Linux/macOS]. ProjectSessions authority required -- `global_db::upsert_session_message_externalizes_tool_payload_without_indexing_body_or_metadata` [Linux/macOS], session-message upsert returns false -- `global_db::upsert_session_message_preserves_oversized_text_losslessly` [Linux], session-message upsert returns false -- `global_registry_test::project_tokens_saved_schema_and_queries_still_work` [macOS], byte-distinct path vectors render identically but compare unequal -- `graph_store_survives_reopen_and_preserves_superseded_generations` [Linux/macOS], externalized state serialized before sealing -- `hook_lifecycle_lease_test::native_hook_captures_only_bound_transport_spool_records` [Linux/macOS], captured hook count 0 vs 1 -- `hook_replay_test::replayed_provider_hooks_record_attributed_rows_and_bridge_to_analytics_events` [Linux/macOS]. Codex prompt hook exits 1 -- `hooks::codex::tests::codex_session_context_resolves_global_only_and_preserves_nudge` [Linux/macOS], global-only repo reports `Generic` vs `Initialized` -- `hooks::tests::daemon_tool_json_returns_project_warming_without_retrying` [Linux/macOS], hook daemon call retries warming until timeout -- `host_admission_test::host_ingress_binds_provenance_to_authoritative_project_and_replays_stably` [Linux/macOS], observation remains `AcceptedForReplay/Pending` -- `host_admission_test::registered_profile_runtime_is_required_and_mismatch_never_falls_back` [Linux/macOS], expected committed persisted outcome absent -- `host_admission_test::registered_project_runtime_is_exact_and_revocation_never_falls_back` [Linux/macOS], expected committed persisted outcome absent -- `immediate_concurrent_and_repeated_opens_publish_one_callable_owner` [Linux/macOS], test-results surface remains unavailable after ~60s -- `interrupted_convergence_serves_the_prior_snapshot_and_replays_identically` [macOS], `Conflict` -- `jobs::concurrent_manual_job_triggers_do_not_double_execute` [macOS], automation lock parent missing -- `jobs::user_job_delivers_output_to_file_and_records_ledger` [Linux/macOS], null output path -- `labeled_byte_record_entities_reach_a_verified_head` [macOS], `Conflict` -- `lcm_bridge::generated_skill_mirrors_session_context_retrieval_contract` [Linux/macOS], generated skill lacks `begin` marker -- `lcm_bridge::generated_tools_bridge_preserves_message_kwargs_in_json_args` [Linux/macOS], generated subprocess bridge changes message kwargs -- `lcm_compression::frontier::late_summary_projection_failure_rolls_back_payload_files_and_canonical_rows` [Linux/macOS], `SummarySourceUnavailable(unverifiable_timestamp)` -- `lcm_compression::overflow::overflow_recovery_keeps_preserved_objective_scaffold_when_evicting_tail` [Linux/macOS], session-message upsert returns false -- `lcm_compression::replay::idless_compression_replay_does_not_reingest_existing_raw_messages` [Linux/macOS], `SummarySourceUnavailable(unverifiable_timestamp)` -- `lcm_compression::tool_transactions::bounded_leaf_chunk_backs_off_before_multi_tool_transaction` [Linux/macOS], `SummarySourceUnavailable(unverifiable_timestamp)` -- `lcm_compression::tool_transactions::budget_and_overflow_replay_never_split_tool_transaction` [Linux/macOS], `SummarySourceUnavailable(unverifiable_timestamp)` -- `lcm_compression::tool_transactions::fresh_tail_boundary_keeps_multi_tool_transaction_atomic_and_shrinking` [Linux/macOS], `SummarySourceUnavailable(unverifiable_timestamp)` -- `lcm_dag::summary_expansion_marks_external_raw_sources_without_silent_empty_content` [Linux]. SQLite transaction lease expired -- `lcm_payload::api_alias_assignments_redact_apikey_and_apitoken` [Linux/macOS], privacy sanitizer receipt construction failed -- `lcm_payload::delete_external_payload_rejects_referenced_payload_without_hash_verification` [Linux]. SQLite transaction lease expired -- `lcm_payload::denies_cross_session_payload_expansion` [Linux]. SQLite transaction lease expired -- `lcm_payload::denies_expansion_after_message_updates_to_new_payload_ref` [Linux]. SQLite transaction lease expired -- `lcm_payload::externalizes_large_tool_payload_with_recoverable_ref` [Linux]. SQLite transaction lease expired -- `lcm_payload::lcm_ingest_uses_the_canonical_privacy_detector_without_local_policy` [Linux/macOS], redacted content lacks `canonicallcmcanary` -- `lcm_payload::private_key_redaction_cannot_be_disabled_by_local_metadata` [Linux/macOS], privacy sanitizer receipt construction failed -- `lcm_payload::quoted_password_assignment_redacts_full_quoted_value` [Linux/macOS], canonical credential redaction marker absent -- `lcm_payload::replay_and_successor_reuse_reject_mutated_payload_global_authority` [Linux]. SQLite transaction lease expired -- `lcm_payload::sensitive_redaction_is_canonical_lossy_and_not_indexed` [Linux/macOS], canonical credential redaction marker absent -- `lcm_payload::summary_publication_binds_external_payload_manifest_and_sanitization_receipt` [Linux]. SQLite transaction lease expired -- `lcm_query::describe::describe_gives_session_overview_without_full_payload_bodies` [Linux]. SQLite transaction lease expired -- `lcm_query::describe::describe_node_and_external_payload_return_metadata_without_body_leaks` [Linux]. SQLite transaction lease expired -- `lcm_query::expand::expand_cross_session_external_row_can_hydrate_payload_via_two_step_expand` [Linux]. SQLite transaction lease expired -- `lcm_query::expand::expand_returns_sliced_raw_summary_and_payload_content_with_ranges` [Linux]. SQLite transaction lease expired -- `lcm_query::status::status_reports_payload_gc_run_metadata_after_apply` [Linux]. SQLite transaction lease expired -- `lcm_query::status::status_reports_schema_frontier_payload_and_debt_counts` [Linux]. SQLite transaction lease expired -- `lcm_raw::transcript_ingest_preserves_lossless_raw_content` [Linux]. SQLite transaction lease expired -- `lcm_summary_lineage_review::immutable_summary_lineage_rejects_foreign_session_canary_sources_without_disclosure` [Linux/macOS], secret-shaped canary session-message upsert returns false -- `mcp::scope::tests::dotdot_request_resolves_the_same_worktree_scope_as_daemon_authority` [Linux/macOS], dotdot spelling resolves a different scope identity -- `mcp::scope::tests::symlink_request_resolves_the_same_worktree_scope_as_daemon_authority` [Linux/macOS], symlink spelling resolves a different scope identity -- `mcp::server::connection::cancellable_queue_tests::cancellation_during_route_resolution_reaches_selected_live_target` [macOS], cancellation response times out -- `mcp::server::hook_boundary_failure_matrix_tests::matrix_backpressure_overflow_rejects_before_reconcile_without_pending_growth` [Linux], count 2 vs 1 -- `mcp::server::hook_boundary_failure_matrix_tests::matrix_daemon_unavailable_without_broker_skips_reconcile_and_frontier` [Linux], unavailable path opens reconcile sink -- `mcp::server::hook_boundary_failure_matrix_tests::matrix_identical_notifications_are_distinct_without_frontier_corruption` [Linux], count 3 vs 2 -- `mcp::server::hook_boundary_failure_matrix_tests::matrix_unavailable_then_success_keeps_sticky_retained_failure_frontier` [Linux], count 2 vs 1 -- `mcp::server::host_admission_tests::add_branch_at_replay_rejects_stale_branch_after_switch` [Linux], count 1 vs 0 -- `mcp::server::host_admission_tests::add_branch_at_replay_rejects_stale_root_after_adversarial_replace` [Linux], stale root writes -- `mcp::server::host_admission_tests::add_branch_at_restart_replay_rejects_common_dir_drift` [Linux], count 1 vs 0 -- `mcp::server::host_admission_tests::add_branch_at_restart_replay_rejects_symlink_swap` [Linux], count 1 vs 0 -- `mcp::server::host_admission_tests::add_branch_replay_rejects_stale_branch_after_delayed_switch` [Linux], count 1 vs 0 -- `mcp::server::host_admission_tests::add_branch_restart_replay_rejects_stale_branch_after_switch` [Linux], count 1 vs 0 -- `mcp::server::host_admission_tests::cancelled_canonical_attempt_is_recovered_and_replayed` [Linux], count 0 vs 1 -- `mcp::server::host_admission_tests::commit_before_ack_replays_once_and_acknowledges_exact_duplicate` [Linux], count 2 vs 1 -- `mcp::server::host_admission_tests::durable_route_survives_unavailable_effect_for_same_connection_retry` [Linux], `Committed` vs `Unavailable` -- `mcp::server::host_admission_tests::malformed_semantic_payload_is_explicit_and_quarantined_across_reopen` [Linux], effect was attempted unexpectedly -- `mcp::server::host_admission_tests::malformed_source_does_not_starve_valid_sibling_source` [Linux], count 2 vs 1 -- `mcp::server::host_admission_tests::oversized_event_is_rejected_before_canonical_attempt` [Linux], effect was attempted unexpectedly -- `mcp::server::host_admission_tests::quarantine_releases_active_capacity_then_full_fails_closed` [Linux], count 1 vs 0 -- `mcp::server::host_admission_tests::sync_current_branch_replay_rejects_stale_branch_after_delayed_switch` [Linux], count 1 vs 0 -- `mcp::server::host_admission_tests::sync_current_branch_restart_replay_rejects_stale_branch_after_switch` [Linux], count 1 vs 0 -- `mcp::server::host_admission_tests::unsupported_payload_version_is_retryable_and_retained_across_reopen` [Linux], effect was attempted unexpectedly -- `mcp::server::lcm_claude_recall_tests::lcm_expand_query_returns_every_matching_claude_message` [Linux/macOS], retained transport unavailable -- `mcp::server::lcm_claude_recall_tests::lcm_expand_reads_every_live_raw_message_store_id` [Linux/macOS], retained transport unavailable -- `mcp::server::lcm_claude_recall_tests::lcm_grep_finds_a_term_stored_in_exactly_one_message` [Linux/macOS], retained transport unavailable -- `mcp::server::lcm_claude_recall_tests::lcm_grep_returns_every_matching_claude_message` [Linux/macOS], retained transport unavailable -- `mcp::tools::definitions::tests::catalog_filter_preserves_non_catalog_tools_and_filters_catalog_bindings` [Linux/macOS], legacy production tools are no longer discoverable -- `mcp::tools::handlers::analysis::unmounted_files::rust::tests::a_non_utf8_file_name_is_reported_without_panicking` [macOS], `Illegal byte sequence` -- `mcp::tools::handlers::configuration_dispatch_tests::available_configuration_effect_reaches_canonical_executor` [Linux/macOS], effect admission does not win settlement before daemon invocation -- `mcp::tools::handlers::configuration_dispatch_tests::every_other_configuration_effect_reaches_the_authoritative_daemon_executor` [Linux/macOS], configuration-unset does not claim settlement -- `mcp::tools::handlers::context_scout_control_dispatch_tests::context_scout_pause_and_resume_preserve_caller_idempotency_keys` [Linux/macOS], context-scout pause does not claim settlement -- `mcp::tools::handlers::dispatch_tests::a_warm_call_is_unaffected_by_the_ceiling` [Linux/macOS], warm context sees code index unavailable -- `mcp::tools::handlers::dispatch_tests::graph_reader_selector_dispatch_policy_is_allowlisted` [Linux/macOS], required selector keys are null -- `mcp::tools::handlers::dispatch_tests::graph_tools_reject_blank_node_ids_and_zero_depth_with_typed_errors` [Linux/macOS], blank node ID maps to noncanonical occurrence instead of expected typed error -- `mcp::tools::handlers::dispatch_tests::unavailable_user_lcm_effect_is_rejected_before_profile_store_open` [Linux/macOS], error does not contain expected `unknown` -- `mcp::tools::handlers::dispatch_tests::user_lcm_doctor_reports_a_missing_store_without_opening_it` [Linux/macOS], profile retained authority unavailable -- `mcp::tools::handlers::retained_timeout_dispatch_tests::fact_store_curate_forwards_only_bounds_and_preserves_canonical_success` [Linux/macOS], zero-second dispatch ceiling -- `mcp::tools::handlers::retained_timeout_dispatch_tests::fact_store_curate_pre_commit_cancellation_does_not_mutate` [Linux/macOS], zero-second dispatch ceiling -- `mcp::tools::handlers::retained_timeout_dispatch_tests::fact_store_curate_rejects_a_partial_receipt_from_another_scope` [Linux/macOS], zero-second dispatch ceiling -- `mcp::tools::handlers::workflow::affected_tests_tests::cancellation_retains_results_completed_before_the_later_test` [Linux/macOS], `"invalid_test_identity"` vs `"cargo"` -- `mcp::tools::handlers::workflow::affected_tests_tests::directly_changed_test_file_dispatches_each_full_test_identity` [Linux/macOS], dispatched test list is null -- `mcp::tools::handlers::workflow::affected_tests_tests::nested_source_module_dispatches_the_crate_relative_test_identity` [Linux/macOS], dispatched test list is null -- `mcp::tools::handlers::workflow::affected_tests_tests::reported_passing_and_failing_tests_complete_with_observed_results` [Linux/macOS], completion state is null -- `mcp::tools::handlers::workflow::affected_tests_tests::timed_out_test_runner_returns_a_terminal_receipt` [Linux/macOS], `"invalid_test_identity"` vs `"cargo"` -- `mcp::tools::handlers::workflow::affected_tests_tests::vacuous_or_nonzero_test_output_is_a_failed_terminal` [Linux/macOS], `"invalid_test_identity"` vs `"cargo"` -- `mcp::tools::handlers::workflow::test_runner::tests::deadline_terminates_and_reaps_the_complete_test_process_tree` [Linux], child marker missing -- `mcp::tools::plugin_conformance_tests::plugin_tool_mentions_resolve_to_registered_tools` [Linux/macOS], plugin mentions removed session-refresh/work tools -- `mcp::tools::plugin_conformance_tests::readme_mcp_allowlist_matches_read_only_tools` [Linux/macOS]. README allowlist differs from live `readOnlyHint=true` set -- `mcp::tools::plugin_conformance_tests::registered_tools_are_referenced_by_the_plugin_bundle` [Linux/macOS], many registered tools unreferenced by Cursor plugin -- `mcp_cli_serve_test::explicit_initialized_path_ignores_initialize_roots` [macOS], daemon exited status 0 before accepting connections -- `mcp_cli_serve_test::initialize_roots_auto_initializes_unindexed_git_repo` [Linux/macOS], daemon exited status 0 before accepting connections / child timeout -- `mcp_cli_serve_test::initialize_roots_decode_file_uri_localhost_and_percent_escapes` [macOS], daemon exited status 0 before accepting connections -- `mcp_cli_serve_test::no_explicit_path_auto_initializes_unindexed_git_cwd` [Linux/macOS], daemon exited status 0 before accepting connections / child timeout -- `mcp_cli_serve_test::no_explicit_path_prefers_discovered_cwd_over_initialize_roots` [Linux/macOS], daemon exited status 0 before accepting connections / child timeout -- `mcp_cli_serve_test::no_explicit_path_prefers_initialize_roots_over_global_fallback` [macOS], daemon exited status 0 before accepting connections -- `mcp_cli_serve_test::no_explicit_path_without_roots_still_uses_global_fallback` [macOS], daemon exited status 0 before accepting connections -- `mcp_cli_serve_test::serve_daemon_proxy_reports_daemon_disconnect_as_json_rpc_error` [nextest]. TMT after 360s -- `mcp_cli_serve_test::serve_stdio_smokes_automation_run_artifact_view` [macOS], daemon exited status 0 before accepting connections -- `mcp_cli_serve_test::serve_stdio_smokes_managed_skill_list_and_view` [macOS], daemon exited status 0 before accepting connections -- `mcp_cli_serve_test::serve_with_reachable_daemon_proxies_before_opening_explicit_project` [macOS], serve does not connect before project resolution -- `mcp_cli_serve_test::serve_without_daemon_socket_reports_daemon_unavailable` [macOS], command unexpectedly succeeds -- `mcp_cli_serve_test::unexpanded_template_path_prefers_initialize_roots_over_discovered_cwd` [macOS], daemon exited status 0 before accepting connections -- `mcp_configuration_write_persists_and_rejects_stale_cas` [Linux/macOS], daemon exited status 0 before accepting connections -- `mcp_handler_test::admin_test::project_registry_tools_missing_registry_carries_stable_shape` [Linux/macOS], registered selection unresolved before dispatch -- `mcp_handler_test::lcm_test::lcm_expand_query_context_max_tokens_is_independent_of_max_tokens` [Linux/macOS], retained transport unavailable -- `mcp_handler_test::lcm_test::lcm_grep_rejects_invalid_scope` [Linux/macOS], generic invalid retained request replaces expected argument error -- `mcp_handler_test::lcm_test::lcm_grep_rejects_invalid_scope_without_searching_all_sessions` [Linux/macOS], generic invalid retained request replaces expected argument error -- `mcp_handler_test::lcm_test::lcm_load_session_missing_store_uses_typed_empty_messages_without_creating_sessions_db` [Linux/macOS], messages null vs empty array -- `mcp_handler_test::lcm_test::lcm_read_only_tools_return_not_ingested_without_creating_sessions_db` [Linux/macOS], retained transport unavailable -- `mcp_handler_test::lcm_test::lcm_status_cli_bridge_accepts_json_args` [Linux/macOS], retained authority unavailable -- `mcp_handler_test::retrieve_truncation_test::retrieve_tool_reports_missing_and_expired_handles_actionably` [Linux/macOS], `Option::unwrap()` on `None` -- `mcp_handler_test::schema_test::exact_memory_tool_definitions_exclude_legacy_payload_aliases` [Linux/macOS], schema type is `["number","null"]`, expected `"number"` -- `mcp_handler_test::schema_test::schema_required_arguments_match_representative_handler_parsers` [Linux/macOS], route availability masks missing-parameter parser error -- `mcp_handler_test::session_search_test::message_search_rejects_all_registered_with_project_selector` [Linux/macOS], unresolved registered selection masks selector rejection -- `mcp_handler_test::session_search_test::message_search_rejects_invalid_scope` [Linux/macOS], generic invalid retained request replaces expected error -- `mcp_handler_test::session_search_test::message_search_rejects_unsupported_project_scope` [Linux/macOS], call unexpectedly succeeds -- `mcp_handler_test::status_runtime_test::test_status` [Linux/macOS], branch diagnostics absent -- `memory_curation::automatic_fact_receipt_endpoints_expose_terminal_applied_and_quarantined_receipts` [Dashboard], count 2 vs 1 -- `memory_curation::retained_admin_journey_commits_add_update_feedback_and_remove` [Dashboard], tombstone read does not preserve expected feedback lineage -- `memory_curation::retained_mutations_deny_foreign_project_scope_without_a_receipt` [Dashboard], `"runtime"` vs `"application"` -- `memory_curator::backend_failures::memory_curator_runner_ledgers_malformed_backend_output` [Linux/macOS], count 0 vs 1 -- `memory_curator::backend_failures::memory_curator_runner_records_noop_fallback_when_backend_run_task_fails` [Linux/macOS], verified memory graph publication conflicted -- `memory_curator::manual_trigger::manual_memory_curator_runs_when_scheduling_and_task_are_disabled` [Linux/macOS], count 0 vs 1 -- `memory_curator::memory_curator_persists_transient_transient_success_retry_receipt` [Linux/macOS], verified memory graph publication conflicted -- `memory_curator::memory_curator_quarantines_legacy_output_after_bounded_repair_exhaustion` [Linux/macOS], count 0 vs 2 -- `memory_curator::memory_curator_repairs_then_applies_validated_ops_and_records_ledger` [Linux/macOS], verified memory graph publication conflicted -- `memory_curator::memory_curator_runner_applies_validated_ops_under_apply_policy` [Linux/macOS], verified memory graph publication conflicted -- `memory_curator::memory_curator_runner_artifacts_block_handoff_without_validation_examples` [Linux/macOS], verified memory graph publication conflicted -- `memory_curator::memory_curator_runner_artifacts_mark_handoff_ready_for_accepted_only_examples` [Linux/macOS], verified memory graph publication conflicted -- `memory_curator::memory_curator_runner_auto_applies_validated_operations` [Linux/macOS], verified memory graph publication conflicted -- `memory_curator::memory_curator_stops_before_backend_or_apply_when_caller_is_interrupted` [Linux/macOS], error lacks `interrupted` -- `memory_curator::pagination::memory_curator_resumes_from_the_durable_next_page_cursor` [Linux/macOS], verified memory graph publication conflicted -- `memory_curator::scheduler_memory_curator_applies_validated_ops_automatically` [Linux/macOS], verified memory graph publication conflicted -- `memory_eval_test::eval_memory_feedback_trust` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_multiturn_continuity` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_no_pollution` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_ranking_feedback_promotes` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_ranking_morphology` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_ranking_retrieval_reinforcement` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_ranking_supersession` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_ranking_trust_bias` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_secret_rejection` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_skip_local` [Linux/macOS], daemon exited status 0 before accepting connections -- `memory_eval_test::eval_memory_supersede_without_dup` [Linux/macOS], daemon exited status 0 before accepting connections -- `multi_connection_test::split_brain_is_rejected_and_unavailable_daemon_fails_closed_until_restart` [Linux/macOS], daemon exited status 0 before opening socket -- `multi_connection_test::twelve_mcp_cli_and_hook_clients_share_one_daemon_profile_store_owner` [Linux/macOS], daemon exited status 0 before opening socket -- `native_host_event_fixtures_execute_provider_admission_paths` [Linux/macOS]. Hermes supported stdout is `{}` instead of empty -- `observation_store::legacy_idempotency_column_rows_migrate_before_reads_and_writes` [Linux/macOS], session-temporal ResetRequired replaces migration -- `observation_workflow_projection::workflow_projection_rolls_back_rebuilds_restarts_and_audits` [Linux/macOS], injected projection failure remains `RetryDeferred` -- `operation_family_executes_through_cli_mcp_and_http` [Linux/macOS], retained project server retired during init -- `packaged_assets::tests::packaged_evaluator_runs_against_an_unrelated_project` [nextest]. TMT after 360s -- `packaged_host_ingest_delivers_a_registered_advisory_cycle` [macOS], advisory cycle fails `feedback-document-outside-root` -- `persisted_topology_wakes_idle_rollup_after_unrelated_queue_tail` [Linux/macOS], topology source does not bypass five-minute idle poll -- `persistence_failure_cannot_be_rewritten_as_a_clean_terminal` [Linux], count 0 vs 2 -- `post_retention_corrections_join_retained_bounded_evidence_exactly` [Linux/macOS], `IncompatibleFragments` -- `pr_autotrack_test::discovery_classifies_same_repo_and_fork_pull_heads` [Linux/macOS], daemon and maintenance scopes overlap -- `pr_autotrack_test::failed_discovery_is_not_reported_as_an_empty_success` [Linux/macOS], daemon and maintenance scopes overlap -- `pr_autotrack_test::reconciliation_without_scheduler_fails_before_git_or_state_mutation` [Linux/macOS], daemon and maintenance scopes overlap -- `pre_cancelled_application_has_cli_mcp_http_parity` [macOS], daemon exited status 0 before accepting connections -- `primitive_config_markdown_json_parity` [Linux/macOS], daemon exited status 0 before accepting connections -- `production_lsp_negotiates_and_projects_canonical_context` [Linux/macOS], daemon exited status 0 before accepting connections -- `production_primitive_code_routes_have_cli_mcp_http_parity` [Linux/macOS], daemon exited status 0 before accepting connections -- `production_primitive_reads_agree_across_mcp_http_and_cli` [Linux/macOS], daemon authority wait timed out -- `production_project_open_serves_a_paginated_symbol_graph_read` [Linux/macOS], symbol search remains unavailable after ~60s -- `profile_memory_scope_uses_exact_profile_authority` [macOS], `Conflict` -- `profile_sessions_scope_uses_exact_profile_authority` [macOS], `Conflict` -- `profile_storage_reset_test::branch_open_rejects_a_mismatched_maintenance_profile` [Linux/macOS], error text changed from `branch snapshot open` to `branch open` -- `profile_storage_reset_test::incompatible_profile_store_requires_reset_without_in_place_changes` [Linux/macOS]. ResetRequired authority/reason differs -- `profile_storage_reset_test::trace_decay_open_branch_uses_shared_profile_store` [Linux/macOS], branch tracking is absent -- `project_open_application_boundary` [Linux/macOS], daemon exited status 0 before accepting connections -- `project_session_and_code_scopes_keep_distinct_locator_authority` [macOS], `Conflict` -- `projects::project_scoped_plugin_routes_read_selected_project_store` [Dashboard], selected-project graph coverage contract differs -- `public_executable_routes_are_served_by_the_production_daemon` [macOS], daemon exited status 0 before publishing authority -- `report_tests::baseline_report_is_self_validating_but_not_activation_evidence` [Linux/macOS], activation now requires a passing direct evaluation -- `research_anchors::authorized_resolution_rejects_unknown_fields_and_incoherent_states` [Linux/macOS], incoherent wire value decodes -- `research_anchors::canonical_retrieval_anchor_rejects_payload_unknown_fields_and_claimed_ids` [Linux/macOS], unknown-field diagnostic changed -- `reset_required_is_retained_until_an_explicit_reopen` [macOS], `Conflict` -- `reset_required_shape_is_recreated_fresh_and_republished_from_the_manifest` [macOS], `Conflict` -- `restart_reverification_installs_once_and_steady_reads_need_no_authority` [macOS], `Conflict` -- `retained_surfaces::sdk::results::automation::curation::tests::all_noop_receipt_retains_acceptance_without_fabricating_mutations_or_anchors` [Linux/macOS], noncanonical ManifestDigest -- `retained_surfaces::sdk::results::tests::automation_terminal_selects_only_its_exact_result_variant` [Linux/macOS], terminal DTO matches no retained-surface variant -- `retention::orphan_stores::tests::unregistered_collection_rejects_profile_contained_data_root_symlink` [Linux/macOS], `InspectFailed` vs `OutsideProfile` -- `retention::storage_report::tests::an_unreadable_store_reports_size_with_unsampled_free_pages` [Linux/macOS], offline snapshot lacks managed-daemon/maintenance authority -- `retention::storage_report::tests::full_profile_report_creates_no_entries_under_the_profile_root` [Linux/macOS], offline snapshot lacks managed-daemon/maintenance authority -- `retention::storage_report::tests::full_profile_total_includes_session_and_generation_files` [Linux/macOS], offline snapshot lacks managed-daemon/maintenance authority -- `retention::storage_report::tests::report_sizes_every_registered_store_and_counts_unregistered_dirs` [Linux/macOS], offline snapshot lacks managed-daemon/maintenance authority -- `retention::storage_report::tests::sizing_a_store_copies_nothing_and_leaves_no_scratch` [Linux/macOS], offline snapshot lacks managed-daemon/maintenance authority -- `retention::storage_report::tests::storage_report_preserves_the_exact_live_global_database_family` [Linux/macOS], offline snapshot lacks managed-daemon/maintenance authority -- `retention::storage_report::tests::unregistered_sizing_does_not_follow_symlinks` [Linux/macOS], offline snapshot lacks managed-daemon/maintenance authority -- `retention_cleanup_failure_keeps_cancel_fence_until_retry` [nextest]. TMT after 360s -- `run_ledger::run_ledger_limit_and_malformed_lines_are_handled` [Linux/macOS], malformed ledger row lacks status -- `run_ledger::run_ledger_rejects_legacy_rfc3339_with_subsecond_micros` [Linux/macOS], legacy timestamp is no longer rejected -- `runtime::git_correlation::backfill::bounded::native::resume::tests::non_utf8_local_ref_is_sealed_without_fabricated_branch_text` [macOS], child command fails -- `runtime::git_correlation::backfill::bounded::tests::non_utf8_canonical_worktree_resumes_exactly_then_fails_typed_publish` [macOS], `Illegal byte sequence` -- `runtime::hermes::tests::hermes_reader_supports_non_utf8_database_paths` [macOS]. SQLite cannot open non-Unicode path -- `runtime::lcm::payload::rollback_tests::direct_store_failure_rolls_back_metadata_and_payload_file` [Linux], `TransactionExpired` -- `runtime::opencode::tests::durable_sql_frontier_reaches_rows_beyond_a_poisoned_pass_after_restart` [Linux], count 0 vs 1 -- `runtime::opencode::tests::steady_state_restart_keeps_high_water_without_per_row_durability_reads` [Linux], count 2 vs 1 -- `runtime::opencode::tests::wal_part_append_replaces_the_durable_message_with_complete_content` [Linux], unwraps missing value -- `scheduler::config_validates_scheduler_idle_and_lock_bounds` [Linux/macOS], validation no longer mentions `min_idle_secs` -- `selected_project_source_route_survives_physical_daemon_restart` [macOS], daemon exited status 0 before accepting connections -- `serve_template_path_test::literal_template_without_daemon_fails_closed_before_mcp_handshake` [macOS], command unexpectedly succeeds -- `session_ingest_tests::cancelled_user_pass_reports_partial_coverage` [Linux/macOS], deferred units 11 vs 9 -- `session_reflector::session_reflector_runner_skips_when_task_is_disabled` [Linux/macOS], count 1 vs 0 -- `session_temporal_benchmark::tests::contract_matches_checked_in_artifacts` [Linux/macOS], implementation hash mismatch -- `session_temporal_benchmark::tests::fixture_refresh_persists_progress_before_measurement` [Linux/macOS], root refresh deadline exceeded -- `session_temporal_benchmark::tests::fresh_benchmark_db_provisions_key_for_rank_and_hydration` [Linux/macOS], root refresh deadline exceeded -- `shutdown_terminal_linearizes_after_concurrent_admission` [Linux], observability shutdown deadline -- `skill_lint_cursor_test::cursor_skill_references_resolve` [Linux/macOS], six references name removed session-refresh/work tools -- `skill_targets_test::lifecycle_export_sweep_deploys_and_retracts_across_detected_agents` [Linux/macOS], only Cursor exports; expected Claude and Cursor -- `skill_targets_test::lifecycle_export_sweep_isolates_per_agent_failures` [Linux/macOS]. Claude failure not reported -- `skill_targets_test::uninstall_all_removes_inverse_order_legacy_orphan_and_slugged_block` [Linux/macOS], managed-skill prompt markers unbalanced -- `skill_targets_test::uninstall_all_removes_legacy_orphan_alongside_slugged_block` [Linux/macOS], managed-skill prompt markers unbalanced -- `skill_targets_test::uninstall_repairs_legacy_orphan_end_without_claiming_user_text` [Linux/macOS], managed-skill prompt markers unbalanced -- `skill_usage_test::repeated_skill_patches_recommend_improvement_review` [Linux/macOS], `"repair_candidate"` vs `"patch_review"` -- `skill_usage_test::stale_scoring_explains_archive_candidates_and_exclusions` [Linux/macOS], `"archive_candidate"` vs `"archive_review"` -- `skill_writer::skill_writer_runner_skips_when_task_is_disabled` [Linux/macOS], count 1 vs 0 -- `sqlite_writer_uses_production_wal_normal_policy` [Linux/macOS], count 2 vs 1 -- `stack_snapshot_decodes_into_the_typed_journey_request` [Linux/macOS], `InvalidSurfaceRequest` -- `stale_binding_cannot_close_or_rebind_the_registered_store` [macOS], `Conflict` -- `stdio_bridge_exits_successfully_after_client_shutdown_and_exit` [macOS], daemon exited status 0 before accepting connections -- `storage_resolver_test::init_and_open::trace_decay_init_registers_default_profile_shard_globally` [Linux/macOS], legacy checkout did not migrate to durable repository identity -- `temporal_application::canonical_digest_binds_every_semantic_input_and_excludes_resume_ephemera` [Linux/macOS], `ProfileIdentityWithoutProject` -- `temporal_derived_evidence::frozen_temporal_page_returns_projected_occurrences_and_lineage` [Linux/macOS], count 0 vs 1 -- `tests::default_validation_uses_byte_pinned_activation_workload` [Linux/macOS], workload hash mismatch -- `the_dashboard_work_surface_answers_who_worked_on_a_task_on_both_published_mounts` [macOS], daemon exited status 0 before publishing authority -- `the_parity_golden_accounts_for_every_catalog_operation` [Linux/macOS], eight application operations unaccounted -- `the_work_surface_answers_real_requests_on_both_published_mounts` [macOS], daemon exited status 0 before publishing authority -- `tool_command::tests::array_value_collected_via_repetition` [Linux/macOS], `--keywords` removed from context schema -- `tool_command::tests::bare_boolean_flag_at_end_of_args_defaults_to_true` [Linux/macOS], bare `--include-code` now requires a value -- `tool_command::tests::bare_boolean_flag_before_next_flag_does_not_swallow_it` [Linux/macOS], `raw_json` false -- `tool_command::tests::boolean_flag_with_explicit_value_after_it_is_still_consumed` [Linux/macOS], string `"false"` vs boolean false -- `tool_command::tests::boolean_flag_with_invalid_explicit_value_still_errors` [Linux/macOS], invalid `"maybe"` parses successfully -- `tool_command::tests::coerces_boolean_flag` [Linux/macOS], string `"true"` vs boolean true -- `tool_command::tests::dispatch_routing_keys_bypass_unknown_key_gate` [Linux/macOS], `--project-root` removed from `fact_store_list` -- `tool_command::tests::fact_feedback_bare_helpful_flag_does_not_swallow_note_flag` [Linux/macOS], `--helpful` removed from `fact_feedback` -- `tool_command::tests::finalize_arrays_splits_csv` [Linux/macOS], unwraps missing array value -- `tool_daemon_test::configuration_tool_cli_persists_effects_and_fails_on_stale_cas` [Linux/macOS], observed configuration state never becomes available -- `tool_daemon_test::cursor_after_shell_missing_daemon_exits_promptly_without_children` [macOS], retained project server retired during init -- `tool_daemon_test::daemon_first_touch_uses_registered_runtime_without_rewriting_legacy_config` [Linux/macOS], daemon closes connection before result -- `tool_daemon_test::daemon_project_cache_is_scoped_by_client_identity` [Linux/macOS], daemon closes connection before result -- `tool_daemon_test::daemon_project_handshake_uses_client_profile_identity` [Linux/macOS], daemon closes connection before result -- `tool_daemon_test::daemon_project_handshake_uses_registered_remote_store_after_rename` [Linux/macOS], daemon closes connection before result -- `tool_daemon_test::daemon_project_handshake_uses_registry_backed_profile_store_without_marker` [Linux/macOS], daemon closes connection before result -- `tool_daemon_test::daemon_reuses_project_engine_across_tool_clients` [Linux/macOS], first status call counts 2 vs 1 -- `tool_daemon_test::daemon_sigterm_exits_while_authenticated_project_client_is_connected` [Linux/macOS], daemon socket wait times out -- `tool_daemon_test::hermes_read_only_preflight_keeps_project_lcm_grep_available` [Linux/macOS], project open completes without publishing a server -- `tool_daemon_test::kiro_hooks_capture_prompt_boundary_and_type_post_tool_use_unsupported` [Linux/macOS], restored Kiro handler writes daemon-unavailable counter-reset stderr -- `tool_daemon_test::status_json_requests_compact_daemon_payload_noninteractively` [Linux/macOS], retained project server retired during init -- `tool_daemon_test::tool_cli_invokes_mcp_tool_through_daemon_socket` [macOS], daemon authority wait times out -- `tool_daemon_test::tool_cli_rejects_truncated_json_rpc_response_without_hanging` [macOS], retained project server retired during init -- `tool_daemon_test::tool_cli_skips_daemon_notifications_until_matching_response` [macOS], retained project server retired during init -- `tool_first_touch_test::fact_store_creates_profile_store_on_first_touch` [Linux/macOS], removed `fact_store` tool is unknown -- `tool_skill_coverage_test::every_mcp_tool_is_taught_by_at_least_one_bundled_skill` [Linux/macOS], 79 MCP tools unreferenced in canonical skill view -- `tracedecay::lifecycle::tests::nonempty_wrong_schema_read_only_open_returns_reset_required` [Linux/macOS]. ResetRequired authority is not `"graph store"` -- `tracedecay_test::daemon_tool_str_replace_updates_source` [Linux/macOS], source edit now requires fresh idempotency key and preview expected-state -- `transcript_store::concurrent_full_batches_converge_without_split_brain_or_partial_writes` [Linux/macOS], both concurrent full batches return `Ok(())` -- `transcript_store::late_cursor_failure_rolls_back_every_transcript_write_then_retries` [Linux/macOS], summary count 0 vs 1 after retry -- `transcript_store::stale_higher_batch_is_rejected_until_reparsed_from_durable_cursor` [Linux/macOS], summary count 0 vs 1 after reparse -- `update_cmd::tests::canonical_component_set_hosts_are_not_refreshed_by_a_second_writer` [Linux/macOS]. Cline now owns a canonical component set -- `verified_generations_keep_old_reads_dependencies_and_leases_stable` [macOS], `Conflict` -- `workflow_json_preserves_a_typed_application_problem_envelope` [macOS], count 0 vs 1 -- `workload_fixture::semantic_workload_and_incremental_fixture_are_byte_exact` [Linux/macOS], workload hash mismatch -- `worktree_canonical_root_guard_test::opening_from_linked_worktree_keeps_canonical_root_on_primary` [Linux/macOS], incompatible database authority before canonical-root assertion -- `worktree_canonical_root_guard_test::stale_worktree_canonical_root_heals_on_next_touch` [Linux/macOS], incompatible database authority before healing assertion diff --git a/docs/plans/tracedecay-v2/audits/lane-staleness-2026-08-13.md b/docs/plans/tracedecay-v2/audits/lane-staleness-2026-08-13.md deleted file mode 100644 index e885692030..0000000000 --- a/docs/plans/tracedecay-v2/audits/lane-staleness-2026-08-13.md +++ /dev/null @@ -1,51 +0,0 @@ -# NEXT.md "Remaining work by lane" staleness audit (2026-08-13, HEAD 38d8f266a) - -Scope: docs/plans/tracedecay-v2/NEXT.md lines 369-508 ("Remaining work by lane"), -excluding the two bullets already confirmed DONE-and-marked in the file -(schema_unavailable bindings :390-397; github_runtime stack :442-453). - -| # | Bullet (handle · NEXT.md line) | Verdict | Evidence | -|---|---|---|---| -| 1 | Work/TaskSession: evaluated query profile + task-to-session correlation via MCP+SDK, restart (:373-376) | GENUINELY-OPEN | No hits for "evaluated_query_profile" / "task-to-session correlation" anywhere in src/crates/tests. The nearby `activate_evaluated_semantic_profile` (task_session.rs) is semantic-config activation, a different feature. Nothing implements or tests this specific journey. | -| 2 | Work/TaskSession: extend dashboard journey (who worked on a task, provider-qualified evidence, exact continuation, rank-final revocation, restart, 4 temporal modes) (:377-379) | GENUINELY-OPEN | No matches for "who worked on" / "provider_qualified" in tests or dashboard/src. No dashboard journey test found beyond the already-verified task-root journey NEXT.md itself cites as the baseline. | -| 3 | Terminal propagation: keep strict core from `850265033c` (ResetRequired=Never+[Reset], PartialEffect=Never+[Reconcile], fallible envelope) (:383-385) | STALE-DONE (invariant holds, not a task) | Verified directly in `crates/tracedecay-application/src/result/problem.rs:786-792` (`reset_required()` → `retry: Never, legal_actions: [Reset]`) and `crates/tracedecay-application/src/retained_surfaces/service.rs:648-660` (`PartialEffect` → `retry: Never, legal_actions: [Reconcile]`). No regression from `850265033c`; this bullet is a standing constraint that is currently satisfied, not open work. | -| 4 | Terminal propagation: drive PartialEffect/ResetRequired through HTTP, MCP, CLI, both SDKs, survive physical restart (:386-389) | PARTIAL | Typed surface exists everywhere named: HTTP (`src/application_surface/retained_http_identity_tests.rs`), MCP (`src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs`), CLI (`src/work_cli.rs:557,672-673`), Rust SDK (`crates/tracedecay-sdk/src/client.rs`, `remote_client.rs`), TS SDK (`sdks/typescript/src/{client,types,operations}.ts` + `test/client.test.ts`). **No test combines `PartialEffect`/`ResetRequired` with a physical daemon restart**, grepped every `tests/**/*.rs` file containing `PartialEffect` for "restart": zero matches. The restart-survival half is genuinely open exactly as NEXT.md states. | -| 5 | *(schema_unavailable, already marked DONE 2026-08-10/verified 2026-08-13)* |, skipped per task scope, | | -| 6 | Retained surfaces: one physical-restart journey (memory reads/effects, session-refresh begin/status/cancel, LCM retrieval, exact identity) across CLI/MCP/HTTP/both SDKs (:401-404) | CANNOT-VERIFY-CHEAPLY (code exists, unclear if it's *one* unified journey) | `tests/grafeo_restart_acceptance.rs:524` (`memory_relation_graph_survives_physical_daemon_restart_and_isolates_profile_and_projects`, added by wind-down checkpoint `29ebe000d`, not `#[ignore]`d) covers memory read/effects + restart. No evidence it also drives session-refresh begin/status/cancel + LCM retrieval *in the same journey*, nor that it's been run to green since the checkpoint (commit message has no run evidence). Running it is a multi-thread daemon-spin-up integration test, not cheap, so not executed here. | -| 7 | Retained surfaces: prove cancellation/unavailable/partial-effect/reset-required/post-restart-reconciliation as externally observed terminals in that journey (:405-408) | Same as #6 | Depends on #6's journey; same evidence gap. | -| 8 | Observability: recover/checkpoint Work lifecycle, retry/leak, blocked-interval, native-integration, fan-out, reduced-rollup emitters without duplicating authority (:412-415) | PARTIAL | `src/daemon/service/invocation/work_blocked_interval_recovery.rs` and sibling `work*.rs` files exist and are current (not stale/orphaned per `tracedecay_dead_code`-style check not run, but file is actively referenced). No evidence of a "without duplicating authority" audit pass having been checkpointed since wind-down; can't confirm the recovery/dedup half without running the full observability suite. | -| 9 | Observability: reconstruct delivery settlement checkpoint from tree `52f68b8897...` (RMCP disconnect settlement, durable hook ACK/replay, cancellation, terminal CLI ACK) (:416-419) | GENUINELY-OPEN | The cited tree hash resolves (`git cat-file -t` → `tree`) but is **unreachable from any ref** (`git rev-list --objects --all` found 0 hits), it's an orphaned wind-down snapshot, not yet merged. Grepped current HEAD for "RMCP disconnect", "hook_ack"/"HookAck", "durable hook ACK", "CliAck": **zero matches**, this functionality does not exist in the tree yet. Confirmed genuinely open. | -| 10 | Observability: run execution-topology metrics/rollup/compaction/retry/cancellation/restart journeys, not contract inventories (:420-421) | CANNOT-VERIFY-CHEAPLY | Requires running real journeys; no single cheap probe distinguishes "journey ran" from "contract inventory only" without executing the suite. | -| 11 | Observability: wire canonical CI failure-localization owner into delivery-evidence composition (`ProjectDeliveryFailureLocalizationSourceV1`) (:422-428) | GENUINELY-OPEN (confirmed exact match to doc) | Read `crates/tracedecay-usecases/src/delivery.rs:219-226` directly: `ProjectDeliveryFailureLocalizationSourceV1` still has exactly one variant, `NotConfigured`, with the identical doc-comment NEXT.md quotes. No owner wired. | -| 12 | Wound-down handoffs: Grafeo full daemon restart/isolation journey (not the narrow registry test) (:432-435) | GENUINELY-OPEN (code exists, unexecuted per doc's own framing) | `tests/grafeo_restart_acceptance.rs:524` implements exactly this test, added in wind-down checkpoint commit `29ebe000d` ("chore(integration): checkpoint active final-v2 work", no body, no pass evidence). This matches NEXT.md's own description ("terminated before execution") rather than contradicting it, the code is staged but not proven green. | -| 13 | Wound-down handoffs: finish Hermes plugin/unit/stock changes, fresh binary, 8-check stock rerun (:436-441) | GENUINELY-OPEN | `git log --since=2026-08-09 --grep=Hermes -i` → 0 commits. `c635423a56` / `c061d3b883` (cited as already-landed prerequisites) are both ancestors of HEAD, but no follow-up work landed since. | -| 14 | *(github_runtime stack, already marked DONE 2026-08-13)* |, skipped per task scope, | | -| 15 | Wound-down handoffs: canonical-parent cutover removing remaining `crate::application` facade imports + unreleased compat modules/aliases from daemon/project-runtime/session-sync/MCP/root (:454-458) | **STALE-DONE** | `src/application.rs` (the root facade) was deleted in `540b6a605` "refactor(usecases): remove root application facade" (2026-08-10, ancestor of HEAD); a further `05924ecdf` "refactor(core): remove internal type facade" (2026-08-12) continued the cleanup. Confirmed at HEAD: `find src -iname "application*"` shows only `application_surface.rs`/`application_output` remain (no bare `application.rs`), and `grep -rn "use crate::application\b" src` returns zero hits. This is a third stale bullet joining the two already-known ones. | -| 16 | Wound-down handoffs: semantic config table ownership, real accepted-profile/Linux evaluation + live profile-activation journey (retrieval must stay available meanwhile) (:459-463) | PARTIAL | `tests/daemon_suite/advanced_workflow_journey/task_session.rs:249` (`activate_evaluated_semantic_profile`) and `:195` (`configure_restart_and_activate_semantic_profile`) implement exactly this, reopen lifecycle owner, wait for generation, activate profile, restart. Same wind-down-checkpoint caveat as #6/#12: code exists (checkpoint `29ebe000d`/`76a5da86c`), not confirmed run-to-green since. | -| 17 | Wound-down handoffs: doctor authority-audit + clean Cursor agents/in-composer install→bump→doctor lifecycle (:464-466) | GENUINELY-OPEN | No test/commit combines "cursor" + "doctor" lifecycle. Recent Cursor commits (`812c7b7f7` "align bundled hook expectation", `a832b80c5` hook ingest budget) are narrower fixes, not the full lifecycle journey. | -| 18 | Wound-down handoffs: re-run Grafeo/feedback-SDK/workflow-metadata-privacy/structured-privacy/Costs/LSP/application-final-surface suites in the aggregate matrix (:467-470) | CANNOT-VERIFY-CHEAPLY | Explicitly requires a full aggregate-matrix run; not a single/fast test. No aggregate-run artifact found on disk. | -| 19 | Wound-down handoffs: incremental indexing through save/rename/delete/ref-switch/overflow/cancellation/restart, serve-during-refresh, exact identity (:471-473) | PARTIAL (strong code coverage, unconfirmed pass) | `tests/daemon_suite/indexing_lifecycle_test.rs` (last touched `e9b956cb4` "verify ignored dependencies survive restart") has `mounted_incremental_lifecycle_preserves_only_complete_compatible_generations` covering save (`deliver_save`), rename (`fs::rename` + assertion the old path is gone), delete (`fs::remove_file`), ref-switch (`git checkout feature/lifecycle`), overflow (`inject_overflow`/`wait_for_overflow_cadence_receipt`), and cancellation (`write_cancellation_batch`); a sibling test `ignored_dependency_admission_survives_physical_daemon_restart_without_widening` covers restart. This is a full daemon-spin-up integration test, not run here for time-budget reasons, existence and shape strongly suggest this lane is closer to done than NEXT.md implies, but "serve-during-refresh" specifically wasn't confirmed in the body excerpt read. | -| 20 | Dashboard/release: freeze Rust source, run canonical contract generator + `contracts:check` (:477-478) | CANNOT-VERIFY-CHEAPLY | Process-shaped instruction (run generator, don't hand-edit) rather than a fixed artifact to diff; needs an actual generator run to confirm zero drift. | -| 21 | Dashboard/release: re-run Automations DOM tests after scheduler schema regen (:479-481) | PARTIAL / substantially satisfied | Ran `AutomationsPage.dom.test.tsx` directly (`npx vitest run`, 2.8s): **4/4 passed**. Recent scheduler fixes landed today (`2523c857e`, `bf7312fb9`, `9db1fdc88`), but the DOM test files themselves were last touched by `ed3775692`/`98b123b37`, predating today's scheduler work, so "after schema regen" freshness isn't fully confirmed, but the suite is green on the current tree right now. | -| 22 | Dashboard/release: regenerate SDK operations/types only after Work/TaskSession/terminal-problems/source-edit/retained-surfaces/native-topology/automation compile together (:482-484) | CANNOT-VERIFY-CHEAPLY | Ordering/gating instruction; would need a full cross-crate compile + generator run to confirm the precondition and outcome. | -| 23 | Dashboard/release: fresh binary + host install/update/doctor/stock journeys (Claude, Codex, Cursor agents/in-composer, Kimi, Kiro, opencode) (:485-487) | CANNOT-VERIFY-CHEAPLY | Multi-host smoke-test matrix; not cheaply verifiable by inspection. | -| 24 | Dashboard/release: complete default package/install/start journey (npm OIDC is the only remaining operator action) (:488-489) | CANNOT-VERIFY-CHEAPLY | Same, needs an actual install/start smoke run. | -| 25 | Dashboard/release: `src/doctor.rs:953` domain-symbol-extraction-unimplemented gap, decide implement-or-retire (:490-493) | GENUINELY-OPEN (confirmed exact match) | Read `src/doctor.rs` around the cited line: `domain_symbol_rules_warning()` still emits "domain symbol extraction is unimplemented" verbatim. No decision made either way. | -| 26 | Backend perf: refresh stale perf evidence, same-host `perf-gate.sh`, repaired session benchmark `--refresh-contract`, real Work rollup latency/throughput (:497-501) | PARTIAL | Active recent benchmark work exists: `d580c2e55` "perf(observability): benchmark work rollup journey" (2026-08-11) directly targets the named gap; `93d2978ab` "benchmark LCM and work readiness" and `5a3807eb5`/`ec24fc896` "benchmark mismatch replay recovery"/"scope mismatch benchmark support" (2026-08-12/13) show ongoing perf work. No published evidence artifact found on disk (`find -iname "*perf*evidence*"` empty) and no commit evidence of an actual `perf-gate.sh` run completing, code/benchmark scaffolding is progressing but the "evidence" (run + publish) deliverable isn't confirmed done. | -| 27 | Backend perf: build real `dashboard/app-dist`, then full `cargo nextest run --workspace --all-features --no-fail-fast` + dashboard typecheck/tests/build + contract checks + SDK tests + host bundle/stock tests + commitlint + release drift + packaging/install smoke (:502-506) | CANNOT-VERIFY-CHEAPLY | This is explicitly the full verification matrix; per project convention (long-verification-timeouts memory) this needs a detached multi-hour run, out of scope for a token-frugal audit pass. | -| 28 | Backend perf: treat zero-test filters/skipped suites/partial runs/timeouts/stale artifacts/synthetic evidence as unresolved (:507-508) | N/A (policy statement, not a task) | Nothing to verify, this is a standing acceptance-criteria rule for lane 27, not itself a deliverable. | - -## Distilled genuinely-open items, ranked by RC criticality - -1. **#9. Delivery settlement reconstruction (RMCP disconnect settlement, durable hook ACK/replay, cancellation, terminal CLI ACK)**: confirmed zero implementation at HEAD; source tree (`52f68b8897...`) is an unmerged orphan. This blocks the whole observability/delivery lane and is explicitly gated behind Work+observability stabilizing first. -2. **#4. PartialEffect/ResetRequired physical-restart survival**: typed surface is fully built (HTTP/MCP/CLI/both SDKs) but zero test proves it survives a daemon restart. This is the one missing link for the terminal-propagation lane to be journey-complete. -3. **#1/#2. Work/TaskSession retrieval journeys** (evaluated query profile + task-to-session correlation; dashboard "who worked on a task" extension): no code or test evidence found at all, the least-started lane of the set. -4. **#11. CI failure-localization owner**: confirmed still `NotConfigured`-only; small, well-scoped, but blocks delivery-evidence composition from being fully truthful. -5. **#13. Hermes fresh binary + 8-check stock rerun**: prerequisite commits landed 2+ days ago; zero follow-up since. Cheap to finish, currently just not scheduled. -6. **#17. Cursor doctor authority-audit + full lifecycle**: no evidence of a combined journey; isolated Cursor fixes only. -7. **#25, `doctor.rs:953` implement-or-retire decision**: trivial to resolve (a decision, not a build), still open and non-blocking per the doc's own note. -8. **#6/#7/#12/#16/#19, restart/activation journeys with code staged but unverified-green** (retained-surfaces restart, Grafeo isolation restart, semantic-profile activation, incremental-indexing lifecycle): these are *not* zero-progress, substantial, non-trivial test code already exists from the wind-down checkpoint commits (`29ebe000d`, `76a5da86c`). The real remaining work is executing and greenlighting them, not writing them from scratch. Recommend running these as the next verification pass rather than treating them as unstarted. - -## Additional stale-bullet found (beyond the two seeded in the task) - -- **#15, canonical-parent facade cutover**: `src/application.rs` was already deleted in `540b6a605` (2026-08-10) and `05924ecdf` (2026-08-12), both ancestors of HEAD. `crate::application`/`use crate::application` no longer appear anywhere in `src/`. This bullet should be marked DONE in NEXT.md alongside the two already-known stale entries. diff --git a/docs/plans/tracedecay-v2/audits/v2-implementation-audit-2026-08-14.md b/docs/plans/tracedecay-v2/audits/v2-implementation-audit-2026-08-14.md deleted file mode 100644 index 4ea86e53f0..0000000000 --- a/docs/plans/tracedecay-v2/audits/v2-implementation-audit-2026-08-14.md +++ /dev/null @@ -1,389 +0,0 @@ -# V2 implementation audit, 2026-08-14 - -## Method - -- Date: 2026-08-14. -- Audited revision: `f478c323a` (the checker reports were produced across the - immediately preceding `42acf504b` snapshot; the only intervening commit - surfaces semantic-evaluation rejection detail and is reflected below). -- Inputs: all 45 Markdown witness reports present in - `/tmp/v2-impl-check/`: 39 distinct plan shards, one production-mount census, - and five duplicate-plan witnesses. -- Checker models: GPT-5.6 Luna and GPT-5.6 Terra. Consolidation and conflict - adjudication: GPT-5.6 Sol. -- Method: merge every non-superseded gap; deduplicate cross-plan themes; require - a non-test caller for a production mount; adjudicate disputed call edges with - `tracedecay tool callers` and targeted current-tree reads. No Cargo, npm, or - journey command was run during consolidation. -- RC authority: `NEXT.md:692-700`. `RC-BLOCKING` means code or a production - mount/typed state is absent. `RC-REQUIRED-EVIDENCE` means the implementation - is present but the required current-tree journey or measurement is not. - `POST-RC` means an active-plan ambition beyond the stated RC bar. - -The register excludes explicitly superseded items. In particular, it does not -reopen the Delivery failure-localization owner decision -(`NEXT.md:493-502`), unsafe `commit_index` publication, removed public Work -snapshot/delta/replan/accept-task routes, Plan 39's historical procedure, or -operator-owned npm trusted-publisher setup. - -## Counts - -| Section | Entries | -|---|---:| -| Mountless surfaces | 20 | -| Missing deliverables | 17 | -| Partial work themes | 17 | -| Adjudicated conflicts | 10 | -| Evidence-only gaps | 18 | - -Across the 72 unique gap/evidence entries (conflict rows only reference those -entries): 33 are `RC-BLOCKING`, 15 are `RC-REQUIRED-EVIDENCE`, 21 are -`POST-RC`, and 3 are `RECORDED` (A2, A3, A12). - -## A. MOUNTLESS SURFACES - -Per repository policy, each implemented-but-unmounted item below must be wired -to a real production caller or deleted. - -| ID | Rank | Consolidated item | Evidence and witness verdicts | -| A22 | OWNER-DESIGN | Noncooperative authoritative-effect task retention | Added 2026-08-14 (settlement wave): a permanently noncooperative authoritative effect now correctly returns typed `ResetRequired` after the response grace, but its detached task remains unjoined; safely retaining/joining that task needs explicit owner design (settlement agent's architectural handoff, see dispatch settlement commits `232ed9411`…`f5f255818`). | -| A21 | RC-BLOCKING | Advisory host-delivery consume path | Added post-consolidation (hawk rerun triage, 2026-08-14): `crates/tracedecay-usecases/src/advisory/host_delivery.rs` delivery/consume/hook-notice surface is registered at daemon startup (`src/daemon/service/invocation/registrars.rs`) but the production call site uses only `.runtime().run_once()`; the consume/deliver half has zero production callers. Wire the delivery consumption or retire that half. | -|---|---|---|---| -| A1 | RC-BLOCKING | Generalized external-source acquisition, canonical refetch, correction, and tombstone production | Host-observation specialization is mounted, but `GitHubExternalSourceAcquisitionV1` and the generalized owner remain uncalled (`crates/tracedecay-usecases/src/external_source_github.rs:213-425`; `external_source_acquisition.rs:341-510`). Plans 02 and 03 independently verdict this `IMPLEMENTED-UNMOUNTED`. | -| A2 | RECORDED | Context Scout suggestion producer parked; neither remounted nor demolished | See [A2 ruling](#a2-ruling-2026-08-14). `1caf016e5` deliberately unmounted the saved-edit/stop producer and set `claim_authority = None`. `c2c11956d6` mounted the distinct advisory/feedback successor. Remount is not a small slice; deleting only the entry points leaves `prepare_controlled` dead. Owner must choose remount or retire. | -| A3 | RECORDED | V3 evidence assembly persist contract; unused `publish_or_replay` trait deleted | See [A3 ruling](#a3-ruling-2026-08-14). The unimplemented `EvidenceAssemblyStore` façade was deleted. Persisted schema, `RepositoryWritePayloadV1::EvidenceAssembly`, rusqlite publish-or-replay, and V3 exact/Git-topology targets remain sanctioned-pending contract authority. | -| A4 | RC-BLOCKING | Exact generation-bound Git and diagnostic joins | `GitReadAuthorityV1::join_generation` has only the regression caller (`crates/tracedecay-usecases/src/git_reads.rs:429`; `tests/git_intelligence_regression.rs:679`). `GenerationDiagnosticJoinV1` is referenced by contracts/composite types and tests, not a producer (`crates/tracedecay-code-index/src/diagnostics.rs:108-315`). `25-code-intelligence.md` says both are `IMPLEMENTED-UNMOUNTED`; `25-code-index.md` inferred `IMPLEMENTED+MOUNTED` from broader Git/LSP routes. Exact caller evidence resolves both as unmounted. | -| A5 | RC-BLOCKING | Canonical index and retrieval-pipeline observability emission | `record_index` exists at `crates/tracedecay-usecases/src/observability/emit.rs:560`; `emit_retrieval_pipeline` exists at `observability/retrieval_emit.rs:466`, but its only caller is an in-module test. Query execution computes an observation without calling the canonical emitter. | -| A6 | RC-BLOCKING | Conflict-prediction and linked-outcome emitters | `WorkConflictPredictionObservedV1` and `WorkConflictOutcomeLinkedV1` are closed payload/projection contracts (`crates/tracedecay-domain/src/observability/payload.rs:41-42`) with no production producer, so their confusion matrices cannot be truthful. | -| A7 | RC-BLOCKING | Adoption/consent observability emitters | Eligibility/outcome/consent payloads and helpers exist (`crates/tracedecay-domain/src/observability/retrieval.rs:194-285`; `crates/tracedecay-usecases/src/observability/emit.rs:488-513`) with no production caller. | -| A8 | RC-BLOCKING | `NoProgressObservedV1` | The type and validation exist (`crates/tracedecay-domain/src/observability/runtime.rs:42-54`; payload variant at `observability/payload.rs:35`), but there is no deadline/frontier producer. | -| A9 | RC-BLOCKING | GitHub stack capability/drift canonical emitters | Helpers exist in `crates/tracedecay-usecases/src/observability/github_stack_emit.rs:124-542`; `record_github_stack_drifts` has only its focused test caller. The stack/advisory runtime itself is mounted, but not these canonical observations. | -| A10 | POST-RC | Independent-review/task-outcome label emission | The closed vocabulary is implemented and tested (`crates/tracedecay-domain/src/observability/review_labels.rs:93-432`) without a root emitter or Plan 24 consumer. | -| A11 | RC-BLOCKING | Remote Brain node sender and operational plane | `EnrolledRemoteClient::capture` and transfer/query/recovery methods have zero production callers (`crates/tracedecay-sdk/src/remote_client.rs:197-355`). Inbound authority routes are mounted, but project composition supplies `RemoteOperationalReadV1::Unavailable` (`src/daemon/project_composition.rs:567`); no live Settings/Dashboard/Doctor state, replica/cache refresh, or remote clean-diagnostic publisher is mounted. | -| A12 | RECORDED | Supported-host registration breadth | See [A12 ruling](#a12-ruling-2026-08-14). Codex hook seed is empty by design and filled at install; Kimi has no global-hook or non-interactive plugin CLI; Codex Core now drives `codex plugin add` / `remove`. Kiro prompt-boundary and Kimi/OpenCode capture-fast-path remain out of this slice. | -| A13 | RC-BLOCKING | General typed workflow step executor | `WorkflowStepExecutionService::execute_ready_step` is implemented at `crates/tracedecay-application/src/workflow_run.rs:546`, but all callers are in `crates/tracedecay-application/tests/workflow_dag_execution.rs`. Production workflow start uses the narrower Work fan-out path. | -| A14 | POST-RC | Nineteen per-verb LSP gateway façade methods | Methods such as `DaemonLspGateway::declaration` (`crates/tracedecay-lsp/src/gateway.rs:2377-2599`) have zero callers; the live protocol correctly uses `semantic_request` (`gateway.rs:2649-2681`). Both Plan 35 witnesses agree this parallel façade should be folded in or deleted. | -| A15 | POST-RC | Derived HTTP route documents | `http_route_documents` derives catalog-backed route documentation (`crates/tracedecay-api/src/http.rs:489-532`) but has only a catalog test caller. | -| A16 | POST-RC | Policy source-authorization replay | Exact/recorded/current-best-effort replay exists at `crates/tracedecay-policy/src/replay.rs:17-174`; all callers are policy tests. | -| A17 | POST-RC | Dashboard Work topology accounting read | `dashboard/src/workspaces/work/workTopologyAccounting.ts` explicitly states that its read model is not published; there is no generated contract or route for that advanced accounting sub-surface. | -| A18 | RC-BLOCKING | Dashboard code-index-generation event | `DashboardEventKindV1::CodeIndexGenerationPublished` is explicitly "Declared but unfed" (`crates/tracedecay-dashboard-api/src/events_api.rs:109-112`) and is only constructed in a serialization test. The scheduler's internal publication bus is a different mounted event. | -| A19 | RC-BLOCKING | Plan 24 branch-stack/integration Work surface | Integration observation/contracts exist, but `WorkOperation::ALL` has no Work integration apply/review/stack operation (`crates/tracedecay-api/src/work.rs:62-345`). Native integration exists as a separate family, so it does not mount this Work-context requirement. | -| A20 | RC-BLOCKING | Exact PR-head/manual branch activation | PR-head poll and manual branch-add activation are now wired: bootstrap injects the daemon-owned scheduler into `pr_autotrack`, `track_pr` and `activate_manual_branch_head` prepare a linked worktree then mount it through shared `activate_linked_worktree` (`src/daemon/bootstrap.rs`; `src/daemon/pr_autotrack.rs`; `src/daemon/branch_add.rs`). Public `reconcile_project` and `activate_manual_branch` stay fail-closed because those APIs have no scheduler to inject (`reconciliation_without_scheduler_fails_*`; `manual_branch_without_scheduler_fails_*`). | - -## B. MISSING DELIVERABLES - -| ID | Rank | Consolidated item | Evidence | -|---|---|---|---| -| B1 | RC-BLOCKING | Real `ResetRequired` settlement and final-shape cutover | Project-open does not settle a real reset-required store (`src/daemon/project_open_handshake.rs:229`; ignored journey `tests/typed_terminal_restart_acceptance.rs:351-415`). Live observation schema migration/backfill remains (`crates/tracedecay-global-db/src/observation/schema.rs:67-154,238-372`) and registered `migrate_and_attach*` paths remain (`registered.rs:349-388`), contradicting the final-V2 no-migration rule. | -| B2 | RC-BLOCKING | Typed auxiliary-provider catalog descriptors | The tool catalog has no descriptor model for executable/version/protocol/model/sandbox/approval/stream/resume/fallback evidence; native Claude/Codex provider runtime exists elsewhere but is not catalog-described (`crates/tracedecay-tool-catalog/src/`). | -| B3 | RC-BLOCKING | Workflow activation semantic admission | Activation calls structural `definition.validate()` (`crates/tracedecay-application/src/workflow_coordination.rs:462-480`), but the validator does not validate operation existence, schemas, capabilities, privilege/effect compatibility, or recursive execution (`crates/tracedecay-domain/src/workflow.rs:158-296`). | -| B4 | RC-BLOCKING | Provider discovery negotiated against the pinned configuration snapshot | Configuration exposes the current snapshot but does not perform the Plan 20/32 executable-capability negotiation or typed unavailable-executable decision (`crates/tracedecay-usecases/src/configuration/runtime.rs:466-483`). | -| B5 | RC-BLOCKING | Legacy-data privacy remediation authority | No production owner performs at-rest rescan, quarantine overlay, derivative rebuild, resumable checkpoints, or backup/restore replay of newer deletion/quarantine policy. Privacy-specific Doctor/UI state is consequently absent (`crates/tracedecay-sessions/src/runtime/lcm/raw.rs:463-553` only protects new ingest). | -| B6 | RC-BLOCKING | Saved multi-root collection/default/source-binding resolver | The mounted substrate is `AuthorizedScopeSet`; named `QueryCollection`/`WorkspaceCollection`, optional defaults, and Plan 20 source bindings are absent. Dashboard truthfully reports no mounted multi-root set (`crates/tracedecay-dashboard-api/src/lib.rs:1829-1841`). | -| B7 | RC-BLOCKING | Native-integration Dashboard handoff and LSP notification | CLI/MCP operations are mounted, but no Dashboard consumer or LSP notification exists (`tests/native_integration_surface_mount.rs:82-116`; no matching source under `dashboard/src` or `crates/tracedecay-lsp`). | -| B8 | RC-BLOCKING | Dashboard workflow-definition/run-control journey | HTTP, CLI, MCP, and SDK workflow routes exist, but Dashboard has no `/application/workflow` consumer or workflow definition/run-control UI. | -| B9 | POST-RC | Canonical benchmark/comparison observability events | `BenchmarkRunAttemptedV1`, `BenchmarkRunTerminalV1`, and `BenchmarkComparisonRecordedV1` (or an equivalent production family) do not exist. | -| B10 | POST-RC | Bounded policy exploration evaluator | The policy crate has no allowlist/floor/ceiling/share/rollback/circuit-breaker/propensity evaluator. | -| B11 | POST-RC | Code co-change source and Disagreement field | No file-pair co-change endpoint, provider-attributed source, or rendered disagreement field exists for Plan 11b. | -| B12 | POST-RC | Advanced Work checkpoint/discovery and controls | No Work-specific checkpoint/skill/hint/provider-discovery operation, task-title Command-K provider, expertise/calibration view, or host/LSP handoff control is mounted. | -| B13 | POST-RC | Quantitative secret-detector corpus and calibration | Assessment contracts exist, but no checked-in positive/negative corpus, precision/recall/FP/FN runner, or held-out calibration artifact exists. | -| B14 | POST-RC | Typed external extension framework | Plan 19's typed capability/revision/canonical-operation extension declaration has no production implementation. | -| B15 | POST-RC | Whole-store dashboard history | Current storage telemetry deliberately returns growth/history as unknown until a daemon-owned history exists (`crates/tracedecay-dashboard-api/src/storage_telemetry_api.rs:1-21,146-148`). | -| B16 | RC-BLOCKING | Public SDK bidirectional/enrolled-remote parity | Public SDKs provide local HTTP/SSE and a separate remote-protocol subset, not one generated operation set over an enrolled remote authority; the Plan 35 bidirectional negotiated session and complete two-token handoff journey are absent (`crates/tracedecay-sdk/src/client.rs:27-73`; `remote_client.rs:197-291`). | -| B17 | POST-RC | Multi-worktree artifact reuse and move/delete lifecycle authority | No Plan 16 production authority proves content reuse without logical identity sharing, move-preserved identity, or delete/recreate identity replacement. | - -## C. PARTIAL ITEMS BY WORK THEME - -| ID | Rank | Theme | Remaining coherent slice | -|---|---|---|---| -| P1 | RC-BLOCKING | Typed terminals and stream lifecycle | Core `ResetRequired`/`PartialEffect` validation is strict, but HTTP/MCP/SDK transport paths, SSE resume-expiry/duplicate/disconnect/drop accounting, post-commit cancellation, and reset settlement are not one complete mounted behavior. | -| P2 | RC-BLOCKING | Work and TaskSession | Dashboard positive continuation/rank-final revocation requires an activated federated authority; `Anchor` continuation is unconditionally unavailable (`src/daemon/work_evidence_retrieval.rs:225-230`). `ProfileOwnedNoGit` selection poisoning remains an unresolved owner decision (`NEXT.md:429-437`). | -| P3 | RC-BLOCKING | Multi-root federation | Scope-set CAS, LSP folders, inventory, and generic fanout are mounted, but full Plan 05 fusion/hydration, immutable distributed pagination, per-member coverage, dashboard/CLI parity, and stack-to-Git receipts are incomplete. | -| P4 | RC-BLOCKING | Code-index lifecycle and shared descriptor authority | Markdown structure is admitted but normal `.md` grep visibility remains open (`NEXT.md:582-585`); descriptor-to-analyzer/LSP authority is unproven; rewrite still shells out to host ast-grep; combined cancelled-refresh/restart/branch-split publication and disk-full/concurrent-build paths are incomplete. | -| P5 | RC-BLOCKING | Workflow ownership and control | `admit_workflow_child` internally accepts proposals/admission (`src/daemon/service/invocation/work/workflow_fan_out.rs:583-712`); the durable aggregate lacks a shared deadline/cancellation generation/budget ledger; fairness/no-progress, native approval/EffectUnknown, backup/remote-worker fencing, and registry-derived CLI deadline are incomplete. | -| P6 | RC-BLOCKING | Observability read models and production accounting | Topology metrics/rollup bounds, LSP events, provider pricing provenance, stack observations, review labels, and card-by-card population/coverage parity remain incomplete even where the canonical envelope/read service is mounted. | -| P7 | RC-BLOCKING | Hook/advisory semantics | Hook event-family breadth, revision quarantine, exact debounce/timing, rollback switch, complete failure matrix, Scout feedback delivery, and one shared suggestion channel remain incomplete; advisory successor notices do not make Scout production-ready. | -| P8 | POST-RC | Dashboard workspace product depth | Core routes/pages are mounted, but Sessions replay/raw boundaries, Agents tree/handoff frontier, Knowledge contradictions, Observatory flow/latency, Costs latency, advanced Work controls, CORTEX channels, Loom controls, and full renderer parity remain plan ambitions beyond basic RC usability. | -| P9 | RC-BLOCKING | SDK lifecycle parity | Generated schemas are current, but complete local/enrolled-remote operation parity, handoff failure cases, post-commit cancellation/reconnect, and cross-surface semantic comparison are incomplete. | -| P10 | RC-BLOCKING | Privacy sinks and analyzer isolation | New ingest is sanitized, but full taint propagation and every logs/metrics/API/UI/export/diagnostic sink are not proven; LSP authorized-analyzer and remote capability/disclosure enforcement is incomplete. | -| P11 | RC-BLOCKING | Git/native-integration product contract | Core preview/apply and native owner are mounted, but exact PR thread/comment contract equivalence, checked-out destination variants, approval-to-receipt public journey, failure injection, and Plan 16/LSP-originated selection are incomplete. | -| P12 | RC-BLOCKING | Semantic evaluation, hydration, and rollback | Runtime/vector/query fallback is mounted. `f478c323a` now preserves `SearchEvalError` detail in the typed rejection, but accepted-profile Linux evidence, correction of the surfaced evaluation failure, rollback drill, federated hydration/revocation, and split-store conformance remain incomplete. | -| P13 | RC-BLOCKING | Configuration protected changes and execution snapshot | Direct CAS/configuration is mounted, but protected preview/apply/rollback lacks a production journey; complete provider/work snapshot, mid-attempt no-reread, adapter-default rejection, unsafe-Git combinations, and requested/actual drift views are incomplete. | -| P14 | POST-RC | Policy outcome/replan/calibration | Provider-admission re-evaluation, committed-outcome-driven unapplied replan, complete cohort/horizon/error/drift calibration, self-grading separation, and exploration remain incomplete. | -| P15 | POST-RC | Storage retention operations | Retention is mounted, but the historical orphan backlog is operator-unverified and code-generation retention lacks the separately promised semantic-publication trigger. | -| P16 | POST-RC | Defragmentation and compatibility cleanup | Canonical application routing is real, but release evidence for retained delegates, broad duplicate-wrapper deletion, wildcard parent/child imports, and all ownership-boundary negatives are not complete. | -| P17 | POST-RC | Grafeo breadth after superseded landing plan | Core graph domains are mounted. Remaining historical ambitions are workflow run/attempt/handoff topology, exhaustive old graph-shaped-row deletion proof, aggregate cross-domain rerun, and a pre-Grafeo performance comparison. | - -## D. CONFLICTS ADJUDICATED - -| ID | Rank | Conflict | Ruling | -|---|---|---|---| -| D1 | RECORDED | Scout contracts/hooks "mounted" versus zero selection/owner callers | Controls, address registry, and advisory successor remain mounted; Scout envelope production remains parked. See [A2 ruling](#a2-ruling-2026-08-14). | -| D2 | RC-BLOCKING | Plan 25 duplicate witnesses disagree on Git/diagnostic joins | Broader Git and LSP routes are mounted, but the exact generation join entrypoints are not. Caller/type evidence resolves A4 as unmounted. | -| D3 | POST-RC | One Plan 35 witness reported missing host/feedback mounts; the other found the production route | Current project-open source mounts feedback/advisory and LSP semantic authorities (`src/daemon/project_open_owners/advisory_runtime.rs:291-308,423-628`; LSP protocol uses `semantic_request`). Those product paths are mounted. Only the 19 convenience façades remain unmounted (A14). | -| D4 | RC-REQUIRED-EVIDENCE | Plan 36 witnesses disagree on native approval/fanout mounting | Current source mounts the owner, six operations, exact topology, and coordinator preflight. Approval is structurally mounted; the gap is the public approval-to-receipt/restart journey, not an absent handler. Manual branch activation remains independently unavailable (A20). | -| D5 | RC-BLOCKING | Plan 37 witnesses disagree on PR auto-track and CI localization | Background discovery/stack/advisory CI localization are mounted. Delivery failure localization is intentionally `NotConfigured` and superseded by decision. PR-head poll activation and manual `branch_add` are now scheduler-mounted through the same linked-worktree path. Public reconcile and `activate_manual_branch` without a scheduler remain fail-closed. | -| D6 | RC-BLOCKING | Semantic path called "implemented-unmounted" versus Plan 31 mounted runtime | Runtime, vector publication, and query fallback are mounted. The live acceptance failure is evaluation/activation rejection, not absence of a semantic production caller. P12/E3 govern. | -| D7 | RC-BLOCKING | Plan 27 calls Kimi/OpenCode plugin artifacts mounted; mount census calls live hooks capture-only | Artifact generation/install and handler functions exist, but the pre-main capture fast path prevents live handler dispatch for those command forms. The census's executable-path evidence wins. A12's Codex/Kimi registration slice is recorded separately; this row is the capture-fast-path only. | -| D8 | POST-RC | `task_activity` listed as an unmounted conformance exception | Current daemon code publishes `ActivityFamilyV1::Task` after committed Work mutation (`src/daemon/service/invocation/work.rs:107-123`), and Dashboard subscribes. The exception is stale test/ledger maintenance, not a product gap. | -| D9 | POST-RC | Plan 34 requests read-only LSP rename candidate/preview; Plan 35 explicitly keeps rename unavailable | The current gateway intentionally returns unavailable (`crates/tracedecay-lsp/src/gateway.rs:2683-2693`) and never applies edits. Plan 35 is the more specific current LSP authority; treat Plan 34's read-only rename binding as a post-RC plan-authority reconciliation, not an RC edit-safety defect. | -| D10 | RC-BLOCKING | Plan 19 reports fresh final shape complete; Plan 12 finds live migrations/backfills | Direct current source shows live observation migration/backfill and `migrate_and_attach*`. The narrow fresh-store/read-only paths do not satisfy the universal cutover. B1 governs. | - -## E. EVIDENCE-ONLY GAPS - -These entries require current-tree runs, not new contract inventories. - -| ID | Rank | Required run | Success criterion | -|---|---|---|---| -| E1 | RC-REQUIRED-EVIDENCE | Typed-terminal physical-restart matrix | Drive real `PartialEffect` and `ResetRequired` through HTTP, MCP, Rust SDK, and TypeScript SDK; preserve receipt/legal action across daemon kill/respawn. CLI `PartialEffect` is already proven. | -| E2 | RC-REQUIRED-EVIDENCE | Retained-surfaces restart parity | One real project/profile-store journey covering memory reads/effects, session-refresh begin/status/cancel, LCM retrieval, unavailable families, reconciliation, and CLI/MCP/HTTP/both SDKs. | -| E3 | RC-REQUIRED-EVIDENCE | Plan 15 Linux semantic evaluation/activation | Run the pinned 1x/10x sanitized corpus offline; retain raw resource/quality evidence; produce a diagnosable pass/fail; activate only a passing profile; execute rollback. | -| E4 | RC-REQUIRED-EVIDENCE | Incremental-index lifecycle | Save, rename, delete, ref switch, overflow, cancellation, physical restart, serve-during-refresh, and exact compatible republish in one non-vacuous journey. | -| E5 | RC-REQUIRED-EVIDENCE | Supported-host lifecycle fleet | Fresh binary install/update/repair/Doctor/stock journeys for Claude, Codex, Cursor agents/in-composer, Kimi, Kiro, and OpenCode. Hermes is already closed and must not be rerun as an open gap. | -| E6 | RC-REQUIRED-EVIDENCE | Plan 16 same-name multi-root journey | Same-name repositories, linked worktrees, nested folders, denied sibling, immutable pagination across restart, exact anchors, and CLI/MCP/HTTP/UI/LSP parity. | -| E7 | RC-REQUIRED-EVIDENCE | Plan 36 public native-integration journey | Start from Plan 16/LSP selection; exercise pair and declared edge, checked-out/unoccupied destinations, preview/approval/apply/status/cancel, all three modes, daemon restart, and final native receipt. | -| E8 | RC-REQUIRED-EVIDENCE | Remote Brain multi-machine journey | Offline capture, authority change, transfer/replay/duplicate receipt, query coverage, diagnostics, backup, isolated restore, promotion, and old-authority rejection. | -| E9 | RC-REQUIRED-EVIDENCE | Observability topology/settlement journey | Current-tree execution-topology sampling, fanout/dedupe/drop settlement, rollup, compaction, retry/leak, blocked intervals, cancellation, restart, and cross-transport read parity. | -| E10 | RC-REQUIRED-EVIDENCE | Performance refresh | Same-host release `scripts/perf-gate.sh`; clean Linux session-temporal `--refresh-contract`; Work-rollup latency/throughput. Current CI does not mount perf-gate and no fresh artifacts exist. | -| E11 | RC-REQUIRED-EVIDENCE | Real LSP host clients | Live Claude and OpenCode negotiated lifecycle/navigation/diagnostics/cancel/reconnect plus Cursor native-diagnostic merge and exactly-one-analyzer install/repair/rollback/uninstall. | -| E12 | RC-REQUIRED-EVIDENCE | Basic real-Chrome Dashboard usability | Exercise all published routes at required viewport families, keyboard/focus, reduced motion, fallback rendering, and truthful partial/unavailable states. | -| E13 | RC-REQUIRED-EVIDENCE | Default package/install/start smoke | Fresh package artifact, isolated profile/project, daemon start, installed SDK/client operation, and uninstall/cleanup; npm publication itself remains operator-owned. | -| E14 | RC-REQUIRED-EVIDENCE | Doctor authority audit and Cursor lifecycle | Re-run the Doctor remediation/re-observation journey and clean Cursor agents/in-composer install → version bump → Doctor, preserving drift versus ownership-conflict states. | -| E15 | RC-REQUIRED-EVIDENCE | Final RC aggregate gate | Build Dashboard assets, then non-vacuous workspace all-feature nextest, Dashboard typecheck/tests/build, contract and SDK drift checks, host bundle/stock checks, commitlint, release drift, and packaging smoke. | -| E16 | POST-RC | Manual assistive-technology and usability study | Manual NVDA/VoiceOver and the plan's multi-participant study are not part of the basic RC usability bar. | -| E17 | POST-RC | Oldest-supported/Windows SDK matrix | Installed Rust and TypeScript package combinations against current and oldest supported daemons on Linux/Windows. | -| E18 | POST-RC | Grafeo aggregate/performance provenance | Full cross-domain Grafeo journey plus p50/p95/p99, RSS, bytes, write amplification, and reopen comparison; Plan 39's historical task list is superseded, so this is diagnostic/post-RC evidence. | - -## Recommended next fix dispatches - -Each task is deliberately single-concern. - -1. Make project-open settle one incompatible store as typed `ResetRequired`; - unignore only that exact restart regression. -2. Closed: A2. Do not reconstruct the deleted hook-cycle mount and do not - demolish the parked Scout runtime. Owner chooses remount (restore - `run_production_hook_cycle` + lifecycle + claim authority) or retire - (delete producer entry points and `prepare_controlled` together). -3. Register one generalized GitHub external-source acquisition owner at - project-open; prove one canonical refetch reaches the existing store. -4. Closed: A3. Do not invent a V3 evidence-assembly producer. Plan 23 owns - retriever-contribution publication; Git-topology V3 producers remain the - owning plans in Plan 13's pending-producer inventory. -5. Mount an `EnrolledRemoteClient` node-side capture/transfer scheduler without - changing the authority-side protocol. -6. Route production workflow ready-step execution through - `WorkflowStepExecutionService`, or delete that parallel executor and its - advertised general-step claim. -7. Add pre-activation workflow operation/schema/capability/effect validation - against the executable catalog. -8. Remove the Kimi/OpenCode capture-fast-path bypass so their installed native - events reach the existing live handlers. -9. Produce and persist one conflict-prediction plus linked-outcome observation - from existing Work/native evidence. -10. Run the Plan 15 Linux evaluation and fix the first surfaced - `SearchEvalError`, rather than widening deadlines or weakening the oracle. - -## A2 ruling (2026-08-14) - -Decision: **RECORD-RULING**. The producer mount was deliberately parked, not -abandoned mid-build. **WIRE** and **DELETE** are both rejected without an -owner remount-or-retire choice. A2 stays parked, not silently green. - -### Caller-intent evidence - -- Before `1caf016e5` (`refactor(runtime): complete V2 authority cutovers`, - 2026-08-09) the producer was mounted. `run_production_hook_cycle` in - `src/daemon/project_open_owners.rs` mapped SavedEdit/Stop/Explicit to - `ContextScoutCanonicalInputV1::selection_input` and - `ProjectContextScoutOwnerV1::prepare_configured`, then mounted claim - authority on enqueue. `src/daemon/project_open_owners/scout_journey_tests.rs` - (378 lines) covered that journey. -- `1caf016e5` deleted that mount on purpose: `context_scout_lifecycle.rs` - (−1303), the scout journey tests, and ~2203 lines from - `project_open_owners.rs` including `run_production_hook_cycle`. The same - commit replaced live claim-authority resolution - (`resolve_current_context_scout_claim_authority`) with - `let claim_authority = None` in - `src/mcp/tools/handlers/hook_runtime/admission.rs`. That assignment is - why the ready-guidance branch is unreachable: it is a cutover disable, - not a forgotten `None`. -- Current `admit_hook_orchestration` - (`src/daemon/service/invocation/types.rs`) has no success path. SavedEdit, - session End/TurnComplete, and explicit prepare all return `Unavailable`; - every other event returns `UnsupportedTrigger`. `hook_v2_scout_prepare` - only calls that stub. This is a parking brake, not a missing wire. -- `c2c11956d6` (`feat(advisory): mount production feedback runtime`, - 2026-08-12) mounted the hook-notice successor. Admission now peeks - `peek_advisory_hook_notice`. That surface is distinct from Scout - suggestion envelopes (D1). -- `tracedecay tool callers` on `prepare_configured` - (`method:db3b71de8fa41fdfb4e5aa1f22ab09e9`) and `selection_input` - (`method:a3e64357a89b62413d0774fe9ef8d57b`) returns none. - `bind_and_assemble` and `ContextScoutCanonicalInputAssemblerV1` have no - production callers. `run_production_hook_cycle` no longer exists. - `resolve_current_context_scout_claim_authority` no longer exists. -- NEXT.md has no Scout remount or retire ruling. Plan 22 still describes a - saved-edit/stop envelope journey; a plan ambition is not a mount. - -### Why not WIRE - -The "smallest honest slice" is not a hook-to-`prepare_configured` call. -`selection_input` requires a fully assembled canonical packet (address -registry bind, authority pin, `RequestContext`, lifecycle, committed -publication, candidates). That assembler was fed by the deleted lifecycle -and `run_production_hook_cycle`. Reconstructing those is a product remount, -not a one-call connect. Wiring a thinner envelope from the hook alone -would fabricate Scout evidence. - -### Why not DELETE - -The stated delete set (`prepare_configured`, `selection_input`, dead -assembler ports) is not warning-free. The only non-test caller of -`ContextScoutDurableRuntimeV1::prepare_controlled` is `prepare_configured`. -Removing the entry points leaves the deterministic runtime unused under -`cargo check --lib`. Deleting `prepare_controlled` as well demolishes the -Plan 22 producer while mounted controls, durable store, address registry, -and claim/delivery/feedback remain. That is a retire decision, not a dead- -port cleanup. `allow(dead_code)` is forbidden. - -### Action taken - -- No producer, advisory, hook-cmd, or daemon-reset code was changed. -- Parking is now an explicit register ruling: owner must remount the - deleted hook-cycle/lifecycle/claim-authority path, or retire the - producer entry points together with `prepare_controlled`. -- Advisory/feedback (`c2c11956d6`) remains the live hook-notice successor - and is not a Scout envelope producer. - -## A3 ruling (2026-08-14) - -Decision: **RECORD** the persist/V3-target contract; **DELETE** the unused -`EvidenceAssemblyStore` trait. **WIRE** is rejected. - -### Caller-intent evidence - -- Plan 13 names `PublishEvidenceAssembly::execute` as - `EvidenceAssemblyStore::publish_or_replay` and says Plan 23 emits - `RetrieverContributionRecordV1` after it freezes scope, temporal mode, and - watermarks. That producer does not exist. Plan 23's live - `RetrieverContribution` / `RetrieverContributionV1` types are application - and temporal-query ranking records, not `EvidenceAssemblyWriteV1`. -- The only historical production-shaped caller was - `RuntimeEvidenceAssemblyStore` in `crates/tracedecay-usecases/src/evidence_assembly.rs`. - It shipped under `#![allow(dead_code)]` and was removed in `a2fea0e7a` - (`refactor(evidence): remove unmounted duplicate adapters`). The usecases - seam note kept "canonical store and runtime capabilities" and deleted the - adapter "until a production journey needs them." -- `tracedecay tool callers` on `publish_or_replay` and on - `RepositoryWritePayloadV1::EvidenceAssembly` returns no production - constructor. The only write-payload construction site is - `crates/tracedecay-rusqlite-runtime/src/writer/tests/authority.rs`. -- The trait had **zero implementors**. Real publish-or-replay is - `EvidenceAssemblyExecutor::execute_write` - (`crates/tracedecay-rusqlite-runtime/src/repository/evidence_assembly/mod.rs`), - already covered by `publish_replay_conflict_and_drilldown_are_atomic`. -- Work evidence retrieval uses a different application - `RetrieverContribution`. GitHub stack publication uses the mounted V2 path - in `stack_anchors.rs`. Observation, diagnostic, CI, resolution, tombstone, - and UI anchors are mounted separately. None of those paths publish - equivalent V3 evidence assemblies, so the persist contract is not - superseded-in-place. -- Git-topology V3 targets are already **SANCTIONED-PENDING** in Plan 13 - (2026-08-07 pending-producer inventory). Owning plans: 36/27/37/24/32/16/03. - Plan 13 forbids deleting those targets as unused breadth. - -### Why not WIRE - -No current retrieval, stack, or work path can construct a honest -`EvidenceAssemblyWriteV1` (occurrence set, verified ordering proof, dual -sanitization receipts, catalog binding, retriever contribution). Wiring a -call without that producer would fabricate evidence. - -### Why not delete the persist stack - -Deletion of the unused trait does **not** destroy schema authority. Deletion -of the persist stack would. Other code reads that authority: - -- `EVIDENCE_ASSEMBLY_SCHEMA` / `EVIDENCE_ASSEMBLY_IMMUTABILITY` are part of - the final-shape expected schema - (`crates/tracedecay-runtime-core/src/db/migrations/final_shape.rs`). -- `RepositoryWritePayloadV1::EvidenceAssembly` and - `ProjectReadOperationV1::EvidenceAssembly` are live store-protocol variants - dispatched by rusqlite `execute`. -- `RetrievalAnchorTargetV3::{ExactSourceOccurrence, ExactEvidenceSpan, - RetrieverContribution}` and the Git-topology target family are the - contracts later producers must bind to. - -### Action taken - -- Deleted the unimplemented `EvidenceAssemblyStore` trait and the - trait-only `EvidenceAssemblyPublicationOutcomeV1` enum. -- Left write/read types, rusqlite executor, final-shape tables, and V3 - target contracts in place for the owning plans. - -## A12 ruling (2026-08-14) - -Decision: **RECORD** the empty Codex hook seed and Kimi's missing global-hook -form; **WIRE** Codex Core activation through `codex plugin add` / `remove`. -Filling `hooks-codex.json` or inventing a Kimi global-hook file is rejected. - -### (a) Codex `hooks-codex.json` is an empty `{}` - -- The source seed at `plugin/hooks/hooks-codex.json` is an empty `hooks` - object by design. `plugin/README.md` and - `codex_plugin_hooks_fills_empty_seed_and_preserves_strict_schema` pin that - the global renderer mutates the seed in place from `CODEX_MANAGED_HOOKS`. -- Handlers exist and are already registered at install time: - `SessionStart` → `hook-codex-session-start`, `UserPromptSubmit` → - `hook-codex-user-prompt-submit`, plus SubagentStart / PostToolUse / - PostCompact / Stop. Repo-local bundles ship no hooks - (`CodexBundlePolicy::include_hooks` is Global-only). -- Codex honors plugin `hooks/hooks.json` after `codex plugin add`. It does - **not** honor a TraceDecay-authored `~/.codex/hooks.json` or forged - `[hooks.state]` trust hashes. Isolated-HOME probe of Codex CLI 0.147.0 - (`codex plugin add tracedecay@personal --json`) wrote activation only; - hook trust stayed empty. -- Filling the source seed would duplicate `CODEX_MANAGED_HOOKS`, break the - empty-seed contract, and leak hooks into repo-local bundles. - -### (b) Kimi has no global-hook form - -- Re-probed `kimi --help` on 2026-08-14: command set is still - `export, provider, acp, web, server, login, doctor, vis, migrate, upgrade`. - No `mcp`, `plugin`, or `hooks` subcommand. Matches the 2026-08-08 Plan 27 - MANUAL-ONLY (a) verdict and `kimi.rs` module ruling. -- Plugin-manifest hooks (`PostToolUse` + `Stop`) are already rendered by - `render_kimi_hook_commands` into `.kimi-plugin/plugin.json`. Those become - live only after the operator runs interactive `/plugins install `. -- There is no documented Kimi global hooks.json, settings hook table, or - non-interactive registration command. Inventing `hooks-kimi.json` would - be a staged half-form the host cannot load. - -### (c) Codex Core plugin activation - -- Plan 27's 2026-08-08 `(a)` verdict is reopened. Codex CLI 0.147.0 - publishes non-interactive `codex plugin add` / `remove` / `list` / - `marketplace`. Isolated-HOME evidence: add exits 0 without a TTY, writes - `[plugins."tracedecay@personal"] enabled = true`, and copies the staged - source into `~/.codex/plugins/cache/personal/tracedecay/`. -- `activate_deployed_host_registration` now drives that CLI (same - host-capability pattern as `codex mcp add`). - `interactive_activation_guidance` is `None` so the catalog transaction - actually calls activate instead of returning `UnsupportedCapability`. -- Hook trust remains interactive (`/hooks`). Doctor reports it; TraceDecay - still never authors `[hooks.state]`. - -### Why not WIRE (a) or (b) - -The host either already fills the registration (Codex global renderer) or -has no registration surface (Kimi). Wiring a file the host does not read -would be a staged half-form. - -### Action taken - -- Added `agents/codex/plugin_registry.rs` and wired Core activate/deactivate - / prepare / update through `codex plugin add` / `remove`. -- Left `hooks-codex.json` as the empty seed; left Kimi plugin-manifest - hooks and interactive `/plugins` deferral in place. -- Kiro prompt-boundary and Kimi/OpenCode capture-fast-path stay on D7 / - their own items; they were not this slice. diff --git a/docs/superpowers/plans/2026-07-31-one-shot-crate-split.md b/docs/superpowers/plans/2026-07-31-one-shot-crate-split.md deleted file mode 100644 index f93935f913..0000000000 --- a/docs/superpowers/plans/2026-07-31-one-shot-crate-split.md +++ /dev/null @@ -1,94 +0,0 @@ -# One-Shot Crate Split (owner decision 2026-07-31) - -> **SUPERSEDED BY LANDING (2026-08-14).** The target map below shipped: -> `tracedecay-agent-hosts`, `tracedecay-application`, `tracedecay-dashboard-api`, -> `tracedecay-global-db`, `tracedecay-runtime-core`, `tracedecay-sessions`, and -> `tracedecay-usecases` are all workspace members with clean -> `cargo check --workspace`; `tracedecay-migrate` was deleted outright -> (`923816ed3`) rather than landed, folding its surface into `global-db` and -> `runtime-core`. The "scar cleanup" item this plan called for, delete each -> mover's `SEAMS.md` as its rows resolve, is done: all seven were retired, -> with their few still-durable contracts (fail-open/fail-closed port -> semantics, dependency/forbidden-edge proofs, sealed benchmark provenance, -> the `tracedecay-application`-vs-`tracedecay-usecases` layer-naming split) -> folded into each crate's `lib.rs` module doc. Treat the target map and -> execution-model sections below as historical planning, not open work. - -Supersedes the phased breakup plans (deleted). Owner rulings: **no phases**, -one mass move of all root subsystems into workspace crates; **breakage during -the move is acceptable** ("move all crate code first, then deal with the -aftermath once the builds will no longer be slow"); validation is the whole -product end to end, not per-move gates. - -## Why - -`src/` is ~700K lines in one crate, every edit recompiles a 1.3 GB rlib that -~56 test binaries relink. Measured duplication with the 21 existing crates is -~0.9%; the mass is genuinely unsplit subsystems. Only moving them out shrinks -the serial build tail. - -## Target map (one landing) - -| From src/ | To crate | Notes | -|---|---|---| -| semantic_code | tracedecay-semantic | DONE (landed) | -| search_eval | tracedecay-search-eval (new) | in flight; bins stay root | -| mcp/core_hooks.rs | tracedecay-hooks | in flight (de-knot) | -| mcp/transport.rs | tracedecay-jsonrpc (new) | in flight (de-knot) | -| mcp CodeIndexSearch types | tracedecay-query | in flight (de-knot) | -| DaemonInvocation* types | tracedecay-application | in flight (de-knot) + ratchet guard | -| sessions/ | tracedecay-sessions (exists, façade) | mover assigned | -| migrate/ | tracedecay-migrate (exists) | mover assigned | -| global_db/ | tracedecay-global-db (new) | mover assigned | -| agents/ + automation/ | tracedecay-agent-hosts (new) | move together (mutual recursion); embedded assets need crate-local build.rs/paths | -| dashboard/ (minus assets.rs) | tracedecay-dashboard-api (new) | assets.rs stays (OUT_DIR embed) | -| errors, types, timeutil, storage, db, store, memory, sync, git, worktree, branch_meta, lifecycle_lease, sqlite_read_snapshot, path_scope, privacy, redundancy, runtime_identity, serde_util, text, os_str_bytes, windows_file, open_store_holders | tracedecay-runtime-core (new) | kernel; verifier report refines composition | -| mcp/ (rest) | tracedecay-mcp (new) | after de-knot lands | -| daemon/ + daemon.rs | tracedecay-daemon (new) | after de-knot lands | -| application/ (rest) + application_surface | tracedecay-application / tracedecay-api | after de-knot lands | - -Root keeps: main.rs, cli*, commands/, *_cmd.rs adapters, bin/, config, -dashboard/assets.rs, hooks/ daemon-side handlers (cycle with daemon), and thin -`pub use` shims for every moved path so tests/ imports survive. - -## Execution model - -- One mover agent per subsystem in an isolated worktree; `git mv` whole - modules; shim files at old paths; each mover compiles only ITS crate - (best-effort), a red root is acceptable and expected mid-landing. -- Lead octopus-merges all mover branches into the split landing, resolves - Cargo.toml/lock unions, then runs the single mass fix-to-green campaign - (fleet of fixer agents on compile errors, then whole-product validation: - build, isolated release validation, CI). -- Cycle edges break by moving shared pure-data types DOWN (never by adding - upward deps). The architecture ratchet tests (compile_isolation + the new - mcp/daemon direction guards) are the only per-move gates that must stay - green at the END of the landing. - -## Aftermath queue (fix after the move) - -Compile errors from split seams (each mover's SEAMS.md is the work order); -feature forwarding (production, test-transport, semantic-fastembed, -lite/full); embedded asset paths (include_bytes/include_str) in agent-hosts; -scripts that name old paths (check-distribution-acceptance.sh); doc -references. All adjudicated by the whole-product gate, not per-crate -ceremony. - -### Test relocation and import taming (owner order 2026-07-31) - -- **Tests move with their subjects.** Unit tests live in the crate that owns - the code; root integration tests that exercise one crate's surface migrate - into that crate's tests/ (with their fixtures, fixing the cargo-package - escapes the movers cataloged). The ~56 root test binaries shrink to the - cross-crate journeys only, that is where the relink win lives. -- **Tame the imports.** The root shims (`pub use tracedecay_x::*`) are - transitional scaffolding, not architecture. After green, ast-grep sweeps - repoint root and test call sites from shim paths (`crate::sessions::X`) - to direct crate imports (`tracedecay_sessions::X`), then the glob shims - shrink and die. No permanent double-path for the same item. -- **Scar cleanup once settled.** Delete each SEAMS.md as its rows resolve; - re-seal the benchmark manifests whose digests pinned old harness paths; - update docs citing src/ paths; audit the mass pub-widenings (613 sessions, - 491 dashboard, 15 agent-hosts) and narrow anything no external caller - names; remove the `// SEAM(...)` markers; drop root Cargo.toml include - entries that moved with their crates. diff --git a/docs/superpowers/plans/2026-08-01-test-support-features.md b/docs/superpowers/plans/2026-08-01-test-support-features.md index b2b7127e82..9efe6bf8c5 100644 --- a/docs/superpowers/plans/2026-08-01-test-support-features.md +++ b/docs/superpowers/plans/2026-08-01-test-support-features.md @@ -1,8 +1,8 @@ # Test-support features for the crate split (2026-08-01) -Companion to `2026-07-31-one-shot-crate-split.md`. That plan moved subsystems -out of `src/` and accepted breakage; lib targets converged first. This plan -covers the **`--all-targets` aftermath**: upstream test-only surfaces are +The one-shot crate split moved subsystems out of `src/` and accepted breakage; +lib targets converged first. This note covers the **`--all-targets` aftermath**: +upstream test-only surfaces are `#[cfg(test)]`-gated, so they vanish at the crate boundary and every downstream crate's `lib test` target fails to resolve them. diff --git a/docs/superpowers/plans/2026-08-08-v2-rc-recovery.md b/docs/superpowers/plans/2026-08-08-v2-rc-recovery.md deleted file mode 100644 index 70efe16d83..0000000000 --- a/docs/superpowers/plans/2026-08-08-v2-rc-recovery.md +++ /dev/null @@ -1,454 +0,0 @@ -# TraceDecay V2 RC Recovery Implementation Plan - -> **RECONCILED INTO NEXT.md (2026-08-13).** This recovery plan's checkboxes -> were never ticked here; its live remainder is carried by -> `docs/plans/tracedecay-v2/NEXT.md` ("Remaining work by lane"), which has -> been kept current through 2026-08-13. Do not treat the 55 unchecked boxes -> below as independently open work, consult NEXT.md for what actually -> remains. - -**Goal:** Convert the interrupted Claude checkout into a fully wired, truthfully typed V2 release-candidate branch with direct production-journey and aggregate test evidence. - -**Architecture:** Preserve the canonical Rust domain/application authorities, finish one production vertical at a time, and derive MCP, HTTP, SDK, host, and dashboard surfaces from those authorities. Stabilize replay, identity, security, and typed-state boundaries first; mount runtime producers and consumers next; regenerate wire artifacts only after Rust contracts stop moving. - -**Tech Stack:** Rust 2024 workspace, rusqlite, axum/HTTP, MCP, TypeScript SDK, React/rsbuild, Vitest, schemars contract generation, GitHub Actions. - -## Global Constraints - -- `docs/plans/tracedecay-v2/00-plan-set-index.md` is the sole roadmap and acceptance authority; `NEXT.md` records current outcomes only. -- Preserve the two pre-recovery local feature commits and all peer-owned dirty files until their owning task either integrates or deliberately removes them. -- Complete every cutover in one delivery slice; do not add compatibility aliases for branch-local or unreleased shapes. -- A capability is available only when a production caller can exercise it; otherwise return its existing typed unavailable/unsupported/denied state. -- Add no production `unwrap`, `expect`, `panic`, silent fallback, fabricated timestamp/default, empty success, swallowed error, dead-code allowance, or test-only production port. -- No new hand-written file may exceed 1,000 lines, and a touched oversized file must not grow; extract a cohesive responsibility where safe. -- Canonical Rust schemas generate dashboard and SDK wire types. Never hand-edit `dashboard/src/contracts/generated.ts`. -- Every behavioral change uses RED → GREEN → REFACTOR and records the focused failing and passing commands. Generated output uses generator drift checks instead of hand-written unit tests. -- Named libtest runs must be non-vacuous; use full module paths or `scripts/require-exact-test.sh` and confirm the executed count is non-zero. -- Preserve byte-exact identity, replay, staleness, denial, isolation, cancellation, and rollback contracts; do not weaken assertions or raise timeouts to mask defects. -- Audit all supported host integrations whenever shared host lifecycle behavior changes. -- Workers share one dirty checkout: re-read before editing, own only named files, never revert peer edits, stage explicit paths, and create coherent conventional commits. -- npm OIDC, live operator-host runs, Plan 15 semantic evaluation, Plan 25 cadence re-observation, and Plan 38 large-store observation are external evidence gates, not permission to fabricate repository evidence. -- Kiro scope is limited to completing and verifying Claude's current CLI-lifecycle changes; do not redesign it as a Power, OpenVSX extension, MCP-only adapter, or new shared bundle system. -- Manual screen-reader polish is not an RC blocker; functional keyboard/DOM behavior and existing automated accessibility checks remain in scope. - ---- - -### Task 1: Make work synthesis replay atomic and byte-stable - -**Files:** -- Modify: `crates/tracedecay-application/src/work_synthesis.rs` -- Modify: `crates/tracedecay-application/src/work_attempt.rs` -- Modify: `crates/tracedecay-application/tests/work_synthesis_service.rs` -- Modify: `crates/tracedecay-rusqlite-runtime/src/work_attempt.rs` -- Modify: `crates/tracedecay-rusqlite-runtime/tests/work_attempt_storage.rs` - -**Interfaces:** -- Consumes: existing synthesis admission/source/draft authorities and work-attempt start port. -- Produces: one durably persisted admitted synthesis result that identical replays return byte-for-byte; changed replay material yields the existing typed conflict. - -- [ ] **Step 1: Write the failing replay test** - -Add a real service/storage test that admits synthesis while one source is `Unknown`, changes that source to `Succeeded`, repeats the identical request, and asserts the second result is byte-identical to the first. Add a second test that changes request identity material and asserts the typed conflict without mutation. - -```rust -let first = service.synthesize(request.clone()).await?; -sources.mark_succeeded(source_id).await?; -let replay = service.synthesize(request.clone()).await?; -assert_eq!(serde_json::to_vec(&replay)?, serde_json::to_vec(&first)?); -assert_eq!(store.committed_result_count(run_id).await?, 1); -``` - -- [ ] **Step 2: Verify RED** - -Run the narrow application test with `scripts/require-exact-test.sh`; expect the replay assertion to fail because source/draft data is recomputed. - -- [ ] **Step 3: Persist complete admission atomically** - -Move replay authority to the durable synthesis record. Store request identity plus the complete returned result in the same transaction that creates the admitted attempt. On duplicate identity return the stored result; on mismatched identity return a typed conflict. Do not re-read mutable sources during replay. - -- [ ] **Step 4: Verify GREEN and regressions** - -Run the focused application test, the selected rusqlite adapter tests, and all work-synthesis tests; require non-zero passing counts and pristine output. - -- [ ] **Step 5: Commit** - -Commit only the synthesis service, port/adapter, and tests as `fix(work): make synthesis replay byte-stable`. - -### Task 2: Bind run admission and provider execution to admitted authority - -**Files:** -- Modify: `crates/tracedecay-rusqlite-runtime/src/work_run_control.rs` -- Modify: `crates/tracedecay-rusqlite-runtime/tests/work_run_control_storage.rs` -- Modify: `crates/tracedecay-domain/src/work_execution_snapshot.rs` -- Modify: `crates/tracedecay-application/src/work_attempt.rs` -- Modify: `crates/tracedecay-application/tests/work_attempt_service.rs` -- Modify: `src/daemon/service/invocation/work.rs` -- Modify: `src/daemon/service/invocation/work_attempt_exec.rs` -- Modify: `src/daemon/service/invocation/work_attempt_exec/tests.rs` - -**Interfaces:** -- Consumes: registered workflow/application topology and daemon environment snapshot authorities. -- Produces: first-admission run deadline/topology persistence, mismatch refusal for every later attempt, and an environment-cleared provider child restored only from admitted variables. - -- [ ] **Step 1: Write three failing tests** - -Add literal behavior tests for: attempt `attempt-2` admitted with deadline D1 followed by `attempt-10` with D2 returns typed conflict and leaves D1 intact; caller-supplied topology that differs from registered topology is refused before provider launch; and a fake child observes an admitted sentinel while an ambient secret is absent. - -```rust -assert_eq!(admit("attempt-2", d1)?.run_deadline, d1); -assert!(matches!(admit("attempt-10", d2), Err(RunControlError::AdmissionConflict { .. }))); -assert_eq!(read_run(run_id)?.deadline, d1); -``` - -- [ ] **Step 2: Verify RED** - -Run the exact rusqlite run-control test, application attempt test, and daemon child-process test; expect deadline/topology/environment assertions to fail for their intended reasons. - -- [ ] **Step 3: Implement durable admission and clean spawning** - -Persist run deadline and topology identity on first admission under the existing transaction/CAS boundary. Compare every later admission to those stored values. Resolve topology from registered authority before `WorkAttemptService::start`. Call `env_clear()` for provider children and add only the admitted snapshot plus unavoidable platform process variables explicitly selected by the existing host policy. - -- [ ] **Step 4: Verify GREEN** - -Run all three focused families, then `cargo check -p tracedecay --lib --tests --all-features`. - -- [ ] **Step 5: Commit** - -Commit as `fix(work): bind attempts to admitted run authority`. - -### Task 3: Replace fabricated operational states with typed truth - -**Files:** -- Modify: `src/tracedecay/lifecycle/mod.rs` -- Modify: `src/daemon/project_open_handshake.rs` -- Modify: `src/daemon/core_doctor.rs` -- Modify: `crates/tracedecay-global-db/src/session_temporal/operations/sources.rs` -- Modify: `crates/tracedecay-global-db/src/session_temporal/operations/message_anchor.rs` -- Modify: `crates/tracedecay-global-db/src/session_temporal/retrieval/records/relations.rs` -- Modify: `crates/tracedecay-global-db/src/session_temporal/retrieval/tests/relation_graph_tests.rs` -- Test: lifecycle, Doctor, session-temporal, and graph projection suites - -**Interfaces:** -- Consumes: canonical store schema, metadata, owner-store hydration, and generation authorities. -- Produces: typed reset-required/unobserved/unavailable/absent-owner/stale-generation outcomes without synthetic schema, size, timestamp, or anchor values. - -- [ ] **Step 1: Write failing boundary tests** - -Cover: a nonempty wrong-schema project open returns the reset-required typed state before normal I/O; a route-live Doctor request with failed metadata/integrity observation returns unavailable fields rather than compiled schema or size `0`; missing message ownership and malformed timestamps do not insert a legacy anchor; stale graph generation cannot satisfy a current read. - -```rust -assert!(matches!(open_result, Err(ProjectOpenError::ResetRequired { .. }))); -assert_eq!(doctor.database.observed_size_bytes, None); -assert!(matches!(source_result, Err(SourceError::OwnerUnavailable { .. }))); -``` - -- [ ] **Step 2: Verify RED** - -Run one exact test in each family and confirm each fails on the fabricated current behavior. - -- [ ] **Step 3: Route through canonical observations** - -Remove defaulting branches. Propagate typed states through daemon/API serialization. Hydrate temporal sources only through each message's owning store and parse authoritative timestamps. Require the read generation to match the canonical current generation. - -- [ ] **Step 4: Verify GREEN** - -Run the full lifecycle/Doctor/session-temporal/relation-graph focused suites and their crate checks. - -- [ ] **Step 5: Commit** - -Commit as `fix(runtime): preserve truthful operational states`. - -### Task 4: Mount the complete Work MCP surface - -**Files:** -- Modify: `src/mcp/tools/definitions.rs` -- Integrate: `src/mcp/tools/definitions/work.rs` -- Modify: `src/mcp/tools/binding.rs` -- Modify: `src/mcp/tools/handlers/mod.rs` -- Create: `src/mcp/tools/handlers/work.rs` -- Modify: `src/application_surface.rs` -- Modify: `crates/tracedecay-application/src/work_catalog.rs` -- Modify: `plugin/README-cursor.md` -- Test: MCP definition, binding, dispatch, API catalog, and live daemon Work tests - -**Interfaces:** -- Consumes: canonical `work_executable_binding_registry` and existing application Work dispatcher. -- Produces: exactly 26 advertised Work tools with definitions, lifecycle annotations, bindings, deadlines, dispatch handlers, typed results, and discovery parity; exactly 11 read-only operations including `Topology`. - -- [ ] **Step 1: Write failing registry and live-dispatch tests** - -Extend existing maximal-registry, every-definition-has-binding, every-binding-has-dispatch, and read-only tests to include the 26 canonical Work operations. Add a live MCP test that invokes one read and one mutation through the daemon and observes the same typed Work result as HTTP. - -```rust -assert_eq!(work_definitions.len(), 26); -assert_eq!(work_definitions.iter().filter(|d| d.read_only).count(), 11); -assert_eq!(mcp_result, http_result); -``` - -- [ ] **Step 2: Verify RED** - -Run the exact catalog/definition/binding tests; expect missing module, binding, or dispatch coverage rather than a compile-only failure. - -- [ ] **Step 3: Derive Work mounting from the canonical registry** - -Register definitions, add a distinct Work dispatch group or reusable Work adapter, and route all tools through the existing application owner. Do not duplicate request/result DTOs or maintain a second operation list. - -- [ ] **Step 4: Verify GREEN** - -Run all MCP tool-definition/binding/handler tests, API application parity, runtime surface acceptance, and the live Work MCP/HTTP journey. - -- [ ] **Step 5: Commit** - -Commit as `feat(mcp): mount the canonical Work surface`. - -### Task 5: Finish worktree, fan-out, checkpoint, and handoff runtime journeys - -**Files:** -- Integrate/refactor: `crates/tracedecay-application/src/worktree_catalog.rs` -- Integrate/refactor: `crates/tracedecay-application/src/worktree_inventory.rs` -- Integrate/refactor: `crates/tracedecay-application/src/worktree_cleanup.rs` -- Integrate/refactor: `crates/tracedecay-application/src/workflow_fan_out.rs` -- Integrate or delete: `crates/tracedecay-domain/src/work_checkpoint.rs` -- Modify: `src/daemon/service/invocation/handoff.rs` -- Modify: `src/daemon/service/invocation/work.rs` -- Modify: `src/application_surface.rs` -- Modify: `crates/tracedecay-application/src/work_catalog.rs` -- Test: application service tests and `tests/work_loop_journey.rs` - -**Interfaces:** -- Consumes: registered project/worktree identity, workflow run-control fence, provider placement, handoff token, and durable Work storage. -- Produces: explicit-root inventory/cleanup, fenced bounded fan-out, durable checkpoint retrieval/handoff when justified, and task-token redemption. - -- [ ] **Step 1: Split oversized dirty modules before adding behavior** - -Extract DTO validation, inventory projection, cleanup planning, and cleanup execution into focused modules so no new hand-written file exceeds 1,000 lines. Replace static-constructor `unwrap`/`expect` with typed initialization results. - -- [ ] **Step 2: Write failing real-journey tests** - -Add one daemon journey covering explicit roots, present/stale/partial/foreign inventory, cleanup denial, inspect→confirm→remove→reconcile, bounded fan-out where observed concurrent children never exceed `max_parallel`, lease/fence loss, restart/replay, and task handoff redemption. Assert stale+present coverage is partial, not complete. - -```rust -assert_eq!(inventory.coverage.completeness, Completeness::Partial); -assert!(max_observed_children <= request.topology.max_parallel); -assert_eq!(redeemed.task_id, issued.task_id); -``` - -- [ ] **Step 3: Verify RED** - -Run exact service tests and the selected work-loop journey; expect unmounted adapter, missing bound, stale coverage, and unavailable handoff failures. - -- [ ] **Step 4: Mount canonical adapters** - -Implement real filesystem/Git worktree adapters with registered identity, use run-control for fan-out admission and concurrency, validate request fences and placement, and make checkpoint state durable only if the handoff/retrieval journey consumes it. If no production consumer is justified by the V2 authority, delete the checkpoint contract and tests instead of staging dead code. - -- [ ] **Step 5: Verify GREEN and commit** - -Run application, rusqlite, daemon Work, host handoff, and work-loop journeys. Commit as `feat(work): complete workflow and worktree journeys`. - -### Task 6: Emit and project execution topology through one generation - -**Files:** -- Refactor/integrate: `crates/tracedecay-application/src/execution_topology_metrics.rs` -- Modify: canonical run/attempt/fan-out/handoff event producers -- Modify: canonical observability persistence/query adapter -- Modify: Work topology HTTP/application catalog route -- Test: application projector and live topology route tests - -**Interfaces:** -- Consumes: 11 canonical execution-topology event kinds from actual owner transitions. -- Produces: persisted, scope-isolated, generation-bound metrics/read model with explicit unknown/partial coverage. - -- [ ] **Step 1: Split the 2,500-line dirty projector** - -Separate event normalization, aggregation, pagination, and public projection into focused modules under 1,000 lines without changing behavior. - -- [ ] **Step 2: Write failing emission and read-model tests** - -Exercise a real provider run with fan-out/retry/handoff and assert expected event kinds are emitted exactly once, the route rejects a mismatched `scope_ref`, two source generations cannot be joined, and unavailable storage returns a typed unavailable state rather than empty metrics. - -- [ ] **Step 3: Verify RED** - -Run the exact application projector and daemon route tests; expect no production emission/adapter/route. - -- [ ] **Step 4: Mount producers and query authority** - -Emit at owner transitions, persist through the canonical observability store, bind one generation into the query envelope and payload, and expose one application/HTTP Work topology read. - -- [ ] **Step 5: Verify GREEN and commit** - -Run focused projection, route, scope-isolation, and work-loop tests. Commit as `feat(observability): publish execution topology`. - -### Task 7: Complete retained context, Context Scout, policy replay, and remote surfaces - -**Files:** -- Modify: `crates/tracedecay-application/src/context_scout.rs` and its production executor/bindings -- Modify: retained application services and root MCP/CLI callers -- Modify: `crates/tracedecay-policy/src/replay.rs` and its production authorization caller -- Modify: remote operation catalog, CLI, MCP, Rust SDK, TypeScript SDK schema authority, and dashboard route ownership -- Modify: `crates/tracedecay-application/src/sdk_catalog.rs` -- Modify: `crates/tracedecay-sdk/src/operations.rs` through its generator -- Modify: `sdks/typescript/src/operations.ts` through its generator -- Test: API/MCP/CLI parity plus retained, Scout, policy, remote, handoff, and multi-root journeys - -**Interfaces:** -- Consumes: canonical retained-content hydration/redaction, source authorization, remote protocol, and mounted application registries. -- Produces: equivalent typed behavior through CLI/MCP/HTTP/SDK/dashboard and accurate SDK availability for all promised V2 operations. - -- [ ] **Step 1: Write failing production-parity tests** - -Cover: the same retained fact and temporal message through CLI/MCP/HTTP/dashboard; Context Scout saved-edit→stop→restart/dedupe/overlay; replay authorization exact/recorded/best-effort unavailable input without mutation; remote offline→replay→backup→restore→fenced failover; SDK reachability for Work, Handoff, Multi-root, Scout, retained, and remote operations. - -- [ ] **Step 2: Verify RED** - -Run each narrow journey and record missing executors/routes or truthful unavailable states. - -- [ ] **Step 3: Mount canonical services** - -Replace direct root handlers with typed application services, mount Scout through durable runtime receipts, call replay authorization from the production authorization path, and add transport adapters over the existing remote protocol. For each of the 38 currently unavailable SDK entries, either mount the canonical request/result schema and production executor or retain a typed unavailable entry with a specific roadmap-sanctioned reason; do not resurrect superseded legacy aliases. - -- [ ] **Step 4: Verify GREEN and commit** - -Run application/API parity, MCP, CLI, SDK generation/conformance, retained, Scout, policy, remote, handoff, and multi-root tests. Commit as `feat(application): complete V2 service parity`. - -### Task 8: Harden shared host lifecycle, privacy, and LSP advisory behavior - -**Files:** -- Modify: shared host CLI runner and `crates/tracedecay-agent-hosts/src/agents/kiro.rs` -- Modify: `crates/tracedecay-agent-hosts/src/agents/kiro/tests.rs` -- Modify: `docs/KIRO-INTEGRATION.md` -- Refactor/modify: `crates/tracedecay-runtime-core/src/privacy/detect.rs` -- Integrate: `crates/tracedecay-runtime-core/src/privacy/structured_text.rs` -- Modify: GitHub/fact/session metadata sinks -- Modify: `crates/tracedecay-lsp/src/capabilities.rs`, `context.rs`, `gateway.rs`, protocol controller, and daemon LSP source -- Test: all-host lifecycle, privacy ingress, and LSP saved-edit journeys - -**Interfaces:** -- Consumes: shared declarative host bundle install/update/uninstall contract, structured sanitizer, and daemon projection authority. -- Produces: operator-state isolation and rollback for every host, a safe completion of Claude's current Kiro CLI lifecycle, structured metadata privacy, real advisory registrations, and distinct absent/unsupported/denied/scope-denied states. - -- [ ] **Step 1: Write failing host tests** - -Use an isolated HOME plus an ambient operator `KIRO_HOME`; assert the operator sentinel is unchanged, peer MCP entries survive install/update/uninstall, CLI failure rolls back byte-for-byte, and the child runs in the admitted working directory/environment. Apply the shared preservation assertions to Claude, Codex, Cursor, Kimi, OpenCode, and every supported host target. - -- [ ] **Step 2: Write failing privacy and LSP tests** - -Ingress nested JSON/YAML-like provider metadata containing `vault_passphrase` through real GitHub, fact, and session sinks and assert sanitized/quarantined output. Exercise saved-edit→LSP advisory projection/clear/authorized expansion and assert negotiated-but-denied maps to a denial reason distinct from `CapabilityNotNegotiated`. - -- [ ] **Step 3: Verify RED** - -Run focused host, privacy, and LSP tests and confirm the expected isolation, leakage, registration, and typed-state failures. - -- [ ] **Step 4: Implement shared boundaries** - -Complete Claude's dirty `kiro-cli mcp add/remove` path without changing the integration model: clear the child environment, restore only admitted variables, set the admitted working directory, preserve peer configuration, and use the existing host transaction/rollback authority. Do not add a Kiro Power, OpenVSX extension, MCP-only rewrite, or new bundle layer. Parse before sanitizing provider metadata, and derive LSP registrations/snapshots from production daemon sources. Extract cohesive privacy/Kiro/LSP responsibilities rather than growing files already above 1,000 lines. - -- [ ] **Step 5: Verify GREEN and commit** - -Run every supported host's lifecycle/bundle tests, runtime privacy suite, LSP crate tests, and the saved-edit integration journey. Commit as `fix(hosts): preserve isolated V2 integration state`. - -### Task 9: Regenerate contracts and mount the final dashboard V2 surface - -**Files:** -- Modify generator authority: `crates/tracedecay-dashboard-api/src/contract_schema.rs` -- Generate: `dashboard/codegen/schemas/dashboard-contracts.schema.json` -- Generate: `dashboard/src/contracts/generated.ts` -- Generate: `crates/tracedecay-sdk/src/operations.rs` -- Generate: `sdks/typescript/src/operations.ts` -- Modify: `dashboard/src/test/workAttemptFixture.ts` -- Integrate: recovered Observatory adoption/outcome/retrieval/family-ledger files -- Integrate: Work topology accounting files and routes -- Modify: dashboard navigation/page owners and DOM tests - -**Interfaces:** -- Consumes: stable canonical Rust Work/topology/retained/remote schemas and one generation-bound topology route. -- Produces: drift-free generated contracts and reachable dashboard views whose partial/unknown semantics match backend coverage. - -- [ ] **Step 1: Add failing dashboard behavior tests** - -Add DOM tests that navigate to each recovered Observatory view and Work topology accounting, render real generated fixture shapes, refuse mixed-generation joins, and display a partial/floor label when a denominator is capped. - -- [ ] **Step 2: Verify RED** - -Run the targeted Vitest files; expect unmounted views, obsolete topology fixture shape, missing generated route/types, and mixed-generation behavior. - -- [ ] **Step 3: Export and regenerate canonical artifacts** - -Register every mounted dashboard contract including the complete synthesis result and execution topology view. Run `npm run contracts:generate`, SDK generation, and formatters. Update fixtures to embed `topology: WorkTopologyPolicyV1`; never hand-edit generated outputs. - -- [ ] **Step 4: Mount views and enforce snapshot semantics** - -Route recovered Observatory and Work components through the existing workspace owners, bind every joined query to one generation, and make capped denominators explicitly partial. Keep automated user-visible behavior in scope; do not add manual screen-reader release bureaucracy. - -- [ ] **Step 5: Verify GREEN and commit** - -Run `npm run contracts:check`, `npm run typecheck`, targeted tests, full `npm test`, and `npm run build` from `dashboard/`; run SDK conformance from the repository root. Commit as `feat(dashboard): complete the V2 product surface`. - -### Task 10: Complete architectural cutovers and remove dead scaffolding - -**Files:** -- Remove after migration: `src/application.rs` -- Modify: every remaining `crate::application::` caller -- Move root HTTP/SPA ownership into `crates/tracedecay-api` where required by Plan 10 -- Refactor touched oversized modules identified by `scripts/check-handwritten-file-size.sh` or repository equivalent -- Remove stale flags, aliases, unmounted declarations, source-shape gates, and docs contradicted by production behavior -- Test: API ownership, SSE resume/terminal, package/bundle, and repository hygiene checks - -**Interfaces:** -- Consumes: the mounted services and stable contracts from Tasks 1–9. -- Produces: one application/API authority without root compatibility façades or dead branch-local contracts. - -- [ ] **Step 1: Write failing ownership tests** - -Exercise API-owned `/`, static assets/cache, API boundary, and SSE resume/terminal behavior. Add behavior tests at the first real consumer for each migrated root caller; do not add source-string scans that merely assert a file or symbol is absent. - -- [ ] **Step 2: Verify RED** - -Run API/dashboard route tests and confirm ownership remains in the root shim where the plan requires the crate boundary. - -- [ ] **Step 3: Migrate callers and delete superseded code** - -Move every root caller to explicit application/API crate imports, then delete the shim and branch-local compatibility. Split touched oversized modules by cohesive responsibility, remove unused dependencies with their last caller, and update durable capability documentation. - -- [ ] **Step 4: Verify GREEN and commit** - -Run API/dashboard/SSE tests, `cargo check --workspace --all-targets --all-features`, dead-code/size/dependency hygiene scripts, and bundle checks. Commit as `refactor(application): complete the V2 cutover`. - -### Task 11: Converge all local tests and produce RC evidence - -**Files:** -- Modify: only source/tests required to root-cause fresh failures -- Modify: `docs/plans/tracedecay-v2/NEXT.md` with current measured outcomes only -- Create: one current RC evidence document under `docs/reports/` -- Modify: release metadata only if the existing release authority requires a repository-side change - -**Interfaces:** -- Consumes: completed Tasks 1–10. -- Produces: zero unclassified repository failures and an exact ledger of external gates without publishing or tagging. - -- [ ] **Step 1: Run formatting and generated-drift gates** - -Run `cargo fmt --all -- --check`, dashboard contract check, SDK generation/conformance, plugin bundle validation, and release-drift checks. Fix root causes and rerun each failing gate to exit 0. - -- [ ] **Step 2: Run compiler and lint gates** - -Run `cargo check --workspace --all-targets --all-features` and the repository's CI-equivalent clippy command with warnings denied. Fix every fresh failure without suppressing lints or adding dead-code allowances. - -- [ ] **Step 3: Run dashboard and focused production journeys** - -Run dashboard typecheck/tests/build; Work/workflow/provider/cancellation/retry/review/replan/no-Git/Git/lease-loss/restart journeys; retained/Scout/remote/LSP/privacy journeys; and every supported host bundle/lifecycle journey. Require direct exit 0 and non-zero test counts. - -- [ ] **Step 4: Run aggregate Rust verification** - -Run `cargo nextest run --workspace --all-features --no-fail-fast --no-tests=fail` or the exact CI-equivalent shards when platform constraints require it. Classify every failure by root cause, fix it with a failing regression test, and repeat until the local supported matrix is green. - -- [ ] **Step 5: Run final review and current CI** - -Dispatch a whole-branch semantic review against the integration floor, fix its complete load-bearing finding set in one wave, rerun scoped review, push the branch, and watch the final commit's CI/SDK/plugin workflows to terminal status. Do not reuse CI from an older SHA. - -- [ ] **Step 6: Record RC evidence and external gates** - -Update `NEXT.md` only with measured current outcomes. Record command, timestamp, commit SHA, executed counts, and exit status for every gate. List npm OIDC, live semantic evaluation, operator host runs, cadence re-observation, and large-store GC separately with no fabricated pass. - -- [ ] **Step 7: Commit** - -Commit evidence and any final root-cause fixes as coherent conventional commits; do not create a tag, GitHub Release, or package publication without explicit user authorization. diff --git a/docs/superpowers/plans/2026-08-23-pr663-agent-handoff-prompt.md b/docs/superpowers/plans/2026-08-23-pr663-agent-handoff-prompt.md deleted file mode 100644 index d5b3d635fd..0000000000 --- a/docs/superpowers/plans/2026-08-23-pr663-agent-handoff-prompt.md +++ /dev/null @@ -1,204 +0,0 @@ -# Copy/Paste agent handoff. Finish PR #663 and remove runtime bottlenecks - -Copy everything below the divider into a fresh agent chat. - ---- - -Work in `/fast/projects/tracedecay` and continue the existing TraceDecay delivery. Read `AGENTS.md` and then read the full plan at: - -`/fast/projects/tracedecay/docs/superpowers/plans/2026-08-23-pr663-performance-recovery.md` - -Use `superpowers:executing-plans` to execute that plan task by task, plus `tracedecay:using-tracedecay`, `tracedecay:reviewing-changes`, `superpowers:test-driven-development`, `performance-profiling`, and `superpowers:verification-before-completion` as applicable. Keep the plan checkboxes and live snapshot current as evidence changes. - -## Objective - -1. Safely review and checkpoint the active dirty work on PR #663. -2. Resolve every current and delayed review comment with behavioral evidence. -3. Make PR #663 genuinely green and merge-ready. -4. Merge PR #663 only into `codex/tracedecay-total-redesign-plan` (PR #421's integration branch). -5. Never merge PR #421 into `master`, never merge PR #559, and never force-push. -6. Build and install the exact post-merge beta, restart the daemon once, then dogfood CLI/MCP on the real corpus. -7. Profile and fix root performance bottlenecks. Do not mask them with retries, longer timeouts, weaker assertions, or reduced work. - -## Exact starting snapshot - -The snapshot below was current at 2026-08-23 05:25 UTC. Refresh every value before editing: - -```text -checkout: /fast/projects/tracedecay -branch: cursor/simplify-pr421-hot-paths -PR: https://github.com/ScriptedAlchemy/tracedecay/pull/663 -head/local/remote: c7eb51ffc7919457a905537774a1000bd96193f3 -target/merge-base: 31d7949c1e298b324931132be8031bd92e64eec4 -target branch: codex/tracedecay-total-redesign-plan -PR state: OPEN, non-draft, MERGEABLE, UNSTABLE -dirty state: 120 modified tracked files plus untracked peer backend_identity.rs and the two untracked plan files -peer owner: Claude root PID 1464997; PID 1746348 is an until-cargo-check retry loop -review state: five original threads resolved; no delayed thread at 05:25 UTC, but delayed review may still add more -``` - -Immediately run: - -```bash -git branch --show-current -git rev-parse HEAD -git rev-parse origin/cursor/simplify-pr421-hot-paths -git rev-parse origin/codex/tracedecay-total-redesign-plan -git merge-base HEAD origin/codex/tracedecay-total-redesign-plan -git status --short -git diff --check -ps -eo pid,ppid,etimes,pcpu,pmem,rss,stat,args --sort=-rss \ - | awk 'BEGIN{IGNORECASE=1} /cargo|rustc|claude/ && $0 !~ /awk/ {print}' \ - | head -n 120 -gh pr view 663 --json state,isDraft,mergeable,mergeStateStatus,baseRefOid,headRefOid,url -gh pr checks 663 --json name,bucket,state,workflow,link -``` - -If refs or dirty ownership changed, update the plan before proceeding. Never reset, stash, format, stage, or commit another owner's work. Re-read each file immediately before editing. Do not launch Cargo while an equivalent peer build is active, and never kill peer builds or the live daemon. - -## Work already completed, do not redo it - -- `150ac00b5 test(release): verify canonical provenance helper` - - Behavioral release-helper guard is fixed. - - `tests/release_safety_test.sh`, `tests/release_drift_check_test.sh`, and hosted `Release Version Drift` are green. -- `4ae63542a test(dashboard): align agents fixture with diagnostics contract` - - Focused Agents dashboard Vitest is 7/7 and dashboard typecheck is green. -- `b8ea46cec fix(automation): restore skill receipt digest authority` - - Restores the canonical `sha256_json` import without a shadow helper. - - Focused `tracedecay-agent-hosts` library check is green. -- `c7eb51ffc fix(dashboard): type hook analytics window bounds` - - Restores explicit `Option` timestamp accumulator types. - - Focused `tracedecay-dashboard-api` library check is green. -- All five original automated review findings were tested, replied to, and resolved: - - released byte-array spool recovery 1/1; - - replay cleanup combined failure 1/1; - - Explorer transient polling 6/6; - - tombstone probe 4/4; - - typed clock failure 1/1. - -Re-query those threads after every push, but do not reimplement them unless new evidence invalidates the receipts. - -## Immediate critical path - -### 1. Wait for and review the peer dirty checkpoint - -The shared checkout contains a broad active Rust rewrite. Do not sweep it into one commit. Determine whether PID 1464997 or its descendants are still changing paths. Once the owner creates coherent commits, review each commit semantically and run the smallest affected tests. Preserve ownership boundaries. - -The highest-risk dirty draft is `crates/tracedecay-agent-hosts/src/automation/backend_identity.rs` plus its scheduler/lifecycle callers. Before accepting it, require: - -- backend identity bound to opened executable revision/content, not only resolved path; -- same-path executable replacement re-admits work; -- `Unavailable` and `Denied` are recoverable/cooldown states, not indefinite permanent suppression; -- only a truly typed deterministic protocol/config failure is permanently suppressed; -- no string-matched error classification or shadow identity authority. - -### 2. Finish the remaining root compilation repairs - -The former shared `sha256_json` blocker is already fixed and pushed. Do not redo it. The next two committed defects are: - -```text -src/daemon/session_sync/git_topology.rs and work.rs -references to removed SESSION_SYNC_POLL_INTERVAL - -src/mcp/server.rs:951-952 -AtomicU64::new(persisted) with no persisted binding -``` - -For session sync, do not blindly restore a global 10ms polling tick. Keep a dedicated bounded journal poll only if no completion notification exists; await `CancellationSignal::cancelled()` for request cancellation; use a single deadline sleep; and connect daemon shutdown to the existing `shutdown_notify` or one canonical async `ObservationCancellation` authority. Add behavioral cancellation/deadline/shutdown tests. - -For MCP token accounting, read persisted tokens exactly once before `Arc::new_cyclic`; use the same successful value for both atomics and the optional accounting upsert. A failed read must not fabricate or upsert zero. `src/mcp/server.rs` also has a peer-owned rustfmt hunk, so stage only the accounting repair. - -There is also a self-owned uncommitted test in `tests/hooks_lsp_suite/hooks_test.rs` for the shipped `codex_additional_context_json` compatibility alias. Preserve it, add a thin production delegation plus re-export, and obtain a non-vacuous RED/GREEN once root compilation reaches the test. - -### 3. Clear independent CI blockers - -- Format: CI reports rustfmt drift across much of the peer-owned dirty Rust sweep. Format only after coherent peer checkpointing. Run `cargo fmt --all -- --check`; commit formatting with the semantic slice it belongs to, not as an unexplained 100-file sweep. -- SDK packages: the job is failed at `c7eb51ffc` while its workflow is still running, so logs are not yet downloadable. Prior canonical generation changed only `sdks/typescript/src/operations.ts`. After root compilation is clean, run `sdks/codegen/generate.sh` followed by `scripts/check-sdk-codegen.sh`; never hand-edit generated operations. -- Stable Clippy after compilation: - - three needless borrows in `crates/tracedecay-code-extraction/src/c_extractor.rs`; - - collapsible nested `if` in `crates/tracedecay-code-extraction/src/common.rs` and `elixir_extractor.rs`; - - unused `encode_tagged_lowercase_hex` in code-generation retention; - - unused-must-use in `lsp_runtime.rs` must be handled truthfully, not discarded blindly. -- Hawk/dead surface after compilation: - - `canonical_session_metadata`; - - `load_relation_by_edge`; - - `load_relation_by_locator`; - - `semantic_lane_readiness_for_request`. - Delete only after exact caller proof; otherwise wire the actual production consumer. -- Windows compatibility: replace unstable Windows `MetadataExt` file identity calls in code-generation retention with `tracedecay_runtime_core::windows_file::information(&File)`, retaining opened-handle identity and replacement refusal. -- Shipped API compatibility: `origin/master` exposed `tracedecay::hooks::codex_additional_context_json`. Restore it as a thin alias to the canonical formatter, re-export it, and add a byte-equality test. - -### 4. Re-run checks from root causes outward - -Use narrow checks first. Confirm nonzero test counts. Then rerun affected packages and the failed hosted jobs. Do not count skipped checks as green and do not debug router/MCP behavior until the shared compile blocker is gone. - -After every push: - -1. verify local and remote head equality; -2. query all review threads with GraphQL; -3. wait several minutes for delayed automated review; -4. address every current comment with RED/GREEN behavioral evidence; -5. reply with exact commit/test evidence, then resolve; -6. refresh CI and distinguish root failure from cascade. - -Only merge #663 when its exact pair is reviewed, required checks are green, no unresolved comment exists, and mergeability is clean. Merge normally into `codex/tracedecay-total-redesign-plan`; do not merge #421 itself. - -## Post-merge beta and performance phase - -Do not profile the dirty source tree or the old binary as if it contained the fixes. First build/install the exact merged integration-branch binary and verify its version/commit. Preserve the old daemon evidence, then restart once. - -Current old-binary baseline: - -```text -version: 0.1.0-beta.37+31d7949c1e29 -PID: 2682560 -CPU: 143% -RSS: 4,440,940 KiB -process swap: 4,150,216 KiB -threads: 118 -physical reads: 1,594,551,470,080 bytes -writes: 135,648,800,204 bytes -tracedecay status: 1.80 seconds, exit 0, graph not ready -retention: processed 104, deferred 149, succeeded=false -historical writer telemetry: 557,009 operations / 556,981 commits -queue wait: ~3,939 seconds -transaction time: ~3,354 seconds -``` - -The leading performance hypothesis is repeated unchanged transcript discovery/read work plus nearly one SQLite commit per admitted operation. Validate it on the installed merged binary before changing code. - -Measure identical cold/warm journeys for `status`, `runtime`, `active_project`, context, grep, and session/message retrieval. Capture wall/user/system time, exit and typed state, daemon CPU/RSS/swap/read bytes, opened transcript files, writer operations/commits/WAL bytes, queue wait, and transaction time. Use bounded `pidstat`, `iostat`, `perf`, and file-open tracing where available. - -Then implement only measured root-cause fixes: - -1. Persist per-source file identity, byte cursor, and directory discovery watermark so unchanged historical transcript files are not reopened after catch-up. -2. Batch bounded independent admissions in one canonical writer transaction while preserving ordered cursor/CAS, cancellation, rollback, memory limits, and digest authority. -3. Parallelize only independent per-file/per-source parsing and hashing; do not create parallel writers for one store or weaken serial digest chains. -4. Keep foreground CLI/MCP responsive through prioritization and bounded background slices, not retries or longer deadlines. - -Acceptance on the same machine/corpus: - -```text -unchanged historical transcript opens after catch-up: 0 in a bounded idle sample -commits per admitted historical record: at least 10x lower than the old baseline -foreground status/runtime p95 while warming: <= 2 seconds -no swap growth or unbounded RSS slope during catch-up -all typed cancellation/restart/authority tests remain equal or stronger -``` - -Commit every proven optimization separately with its baseline/RED, production change, GREEN test, and before/after profile receipt. Never declare victory from lower CPU alone if the same work merely moved to retries or was skipped. - -## Reporting - -Send concise progress updates at meaningful boundaries. Final handoff must include: - -- exact base/head/merge-base and branch target; -- commits and owned paths; -- review-thread state and delayed-review check; -- CI root causes versus cascades; -- exact focused and broader test receipts; -- exact installed binary version/commit; -- before/after performance table; -- unresolved risks, without hiding them behind retries, timeout increases, or skipped work. - ---- diff --git a/docs/superpowers/plans/2026-08-23-pr663-performance-recovery.md b/docs/superpowers/plans/2026-08-23-pr663-performance-recovery.md deleted file mode 100644 index 6673fdb3fd..0000000000 --- a/docs/superpowers/plans/2026-08-23-pr663-performance-recovery.md +++ /dev/null @@ -1,449 +0,0 @@ -# PR 663 Completion and Runtime Performance Recovery Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Safely turn the active dirty `cursor/simplify-pr421-hot-paths` checkout into a verified PR #663, merge only that PR into `codex/tracedecay-total-redesign-plan`, release and install the resulting beta, then profile and remove the remaining TraceDecay CLI/MCP bottlenecks with measured root-cause fixes. - -**Architecture:** Treat branch completion and performance work as separate evidence phases. First preserve current shared-checkout ownership, checkpoint coherent correctness slices, fix the known CI and review blockers, and merge PR #663 into the PR #421 integration branch without merging #421 itself. Only then build/install the exact resulting binary and compare identical before/after workloads; optimize transcript discovery and storage batching where the measurements show repeated work. - -**Tech Stack:** Rust 2024 workspace, Tokio, rusqlite, React/Vitest dashboard, GitHub Actions/`gh`, TraceDecay MCP/CLI, Linux `perf`/`pidstat`/`iostat`/`strace` where available. - -**Spec:** `docs/plans/tracedecay-v2/00-plan-set-index.md` - -## Live handoff snapshot, 2026-08-23 05:25 UTC - -- Checkout: `/fast/projects/tracedecay` -- Branch: `cursor/simplify-pr421-hot-paths` -- PR: [#663](https://github.com/ScriptedAlchemy/tracedecay/pull/663), open, non-draft, mergeable, currently `UNSTABLE` -- Exact local/remote head: `c7eb51ffc7919457a905537774a1000bd96193f3` -- Exact target/merge-base: `31d7949c1e298b324931132be8031bd92e64eec4` (`codex/tracedecay-total-redesign-plan`) -- Shared checkout: 120 modified tracked files plus three untracked paths: peer file `crates/tracedecay-agent-hosts/src/automation/backend_identity.rs` and the two handoff-plan documents in this directory. -- Active peer owner: Claude root PID `1464997`. No `cargo`/`rustc` was active at this snapshot, but PID `1746348` is an `until cargo check --workspace` retry loop and may relaunch at any time. Recheck immediately before every Cargo command. -- Review threads: five total, all replied to with behavioral evidence and resolved; a fresh GraphQL query at this head found no delayed thread. Re-query after every later push because automated review may still arrive. - -Already landed and pushed on the exact head: - -1. `150ac00b5 test(release): verify canonical provenance helper` - - RED: the stale release test required an inline `gh attestation verify` command even though all workflows call the canonical helper. - - GREEN: `tests/release_safety_test.sh` now behaviorally executes `scripts/verify-retained-release-assets.sh`, verifies its exact provenance flags and failure propagation, and `Release Version Drift` is green. -2. `4ae63542a test(dashboard): align agents fixture with diagnostics contract` - - RED: the old fixture fabricated `event_count=0` while supplying four diagnostics composition rows. - - GREEN: usage remains unknown while diagnostics uses the canonical count of four; focused Vitest is 7/7 and dashboard typecheck is green. -3. `b8ea46cec fix(automation): restore skill receipt digest authority` - - Restores the canonical `crate::automation::artifacts::sha256_json` import without adding a shadow helper. - - `TRACEDECAY_SKIP_DASHBOARD_BUILD=1 cargo check -p tracedecay-agent-hosts --lib --locked` is green. -4. `c7eb51ffc fix(dashboard): type hook analytics window bounds` - - Adds explicit `Option` types to the hook analytics oldest/newest timestamp accumulator rather than relying on an unconstrained closure inference. - - `TRACEDECAY_SKIP_DASHBOARD_BUILD=1 cargo check -p tracedecay-dashboard-api --lib --locked` is green. -5. All five original review findings have clean-head focused receipts: spool upgrade 1/1, replay cleanup 1/1, Explorer polling 6/6, tombstone probe 4/4, and typed clock failure 1/1. - -Current root CI classification: - -| Check | Classification | Required action | -| --- | --- | --- | -| Clippy / root compilation | Two remaining committed compile defects, then lint blockers | Repair the session-sync wake design instead of blindly restoring a global 10ms poll constant; initialize MCP `persisted` tokens from the canonical typed accounting read. Then fix the reported Clippy findings without allows. | -| Hawk | Root compile cascade plus real dead/unmounted warnings | After compilation, audit `canonical_session_metadata`, `load_relation_by_edge`, `load_relation_by_locator`, and `semantic_lane_readiness_for_request`; delete only after exact caller proof or wire the production caller. | -| MCP conformance / production-router | Current reruns in progress | The shared `sha256_json` blocker is fixed. Interpret any new failure only after the session-sync and MCP-server compile defects are removed. | -| Format | Real broad dirty-tree drift | CI reports rustfmt diffs across the active peer-owned Rust sweep. Format only after the peer checkpoints a coherent slice; never commit a blind shared-checkout sweep. | -| SDK packages | Failed again at `c7eb51ffc`; logs unavailable until the still-running workflow completes | Prior evidence showed canonical generated drift only in `sdks/typescript/src/operations.ts`; regenerate via `sdks/codegen/generate.sh`, then run `scripts/check-sdk-codegen.sh`. | -| Dashboard / manifest / Claude plugin / publish policy | Green at snapshot | Preserve these receipts; do not rerun unrelated work locally while the shared Cargo lane is contended. | -| Windows / Linux / macOS / remaining integrations | Not all jobs materialized yet | Wait for terminal results and classify root failures separately from compile cascades. | - -Current old-binary performance baseline, captured before restart: - -```text -binary: tracedecay 0.1.0-beta.37+31d7949c1e29 -daemon PID: 2682560, started 2026-08-23 01:09:01 UTC -CPU: 143% -RSS: 4,440,940 KiB -swap attributed to process: 4,150,216 KiB -threads: 118 -/proc read_bytes: 1,594,551,470,080 -/proc write_bytes: 135,648,800,204 -status latency: 1.80 seconds, exit 0 -status state: indexing; graph exact_scope_generation_not_ready -retention log: succeeded=false, processed_stores=104, deferred_stores=149 -retention degradation: semantic configuration/vector authority unavailable and vector census incomplete -``` - -Earlier writer telemetry from the same old daemon showed 557,009 admitted operations and 556,981 commits, with approximately 3,939 seconds of queue wait and 3,354 seconds of transaction time. Treat this as the leading hypothesis, unchanged transcript rescans plus nearly one SQLite commit per operation, not as proof until the exact merged binary is installed and the identical workload is measured again. - -## Global Constraints - -- Never merge PR #421 into `master`; merge PR #663 only into `codex/tracedecay-total-redesign-plan`. -- Never merge PR #559. -- Never force-push, reset, discard, or sweep in another owner's dirty work. -- The primary checkout is shared. At the 2026-08-23 05:25 UTC snapshot it had 120 modified tracked files, one peer untracked source file, and two untracked handoff documents, with another Claude session owning a workspace-check retry loop. -- Before every Cargo launch, inspect active `cargo`/`rustc` processes and wait for equivalent peer work rather than competing or killing it. -- Use TraceDecay graph/context tools before native source search; if the graph returns typed `generation_rebuilding`, use the TraceDecay CLI fallback, then narrow working-tree reads. Never query `.tracedecay` databases directly. -- Preserve typed failures and exact authority/identity contracts. Do not raise timeouts, add retries, weaken assertions, or treat a stale binary as test evidence. -- Use `apply_patch` for edits, conventional commits, narrow anti-vacuous tests, and a fresh final-head review before merge. -- Generated dashboard contracts and SDK files are regenerated through their canonical generators; never hand-edit them. - ---- - -### Task 1: Establish an ownership-safe checkpoint - -**Files:** -- Inspect only: all current dirty paths -- Do not create or repurpose a worktree without operator approval - -**Interfaces:** -- Consumes: current shared checkout and active owner processes -- Produces: an exact ownership map and a clean semantic checkpoint boundary - -- [ ] **Step 1: Refresh exact refs and dirty state** - -Run: - -```bash -git branch --show-current -git rev-parse HEAD -git rev-parse origin/cursor/simplify-pr421-hot-paths -git rev-parse origin/codex/tracedecay-total-redesign-plan -git merge-base HEAD origin/codex/tracedecay-total-redesign-plan -git status --short -git diff --check -gh pr view 663 --json state,isDraft,mergeable,mergeStateStatus,baseRefOid,headRefOid,url -``` - -Expected at the refreshed handoff snapshot: branch `cursor/simplify-pr421-hot-paths`, local/remote head `c7eb51ffc7919457a905537774a1000bd96193f3`, base/merge-base `31d7949c1e298b324931132be8031bd92e64eec4`, and PR #663 open. Treat any difference as new evidence and update the plan before editing. - -- [ ] **Step 2: Find active owners and builds** - -Run: - -```bash -ps -eo pid,ppid,etimes,pcpu,pmem,rss,stat,args --sort=-rss \ - | awk 'BEGIN{IGNORECASE=1} /cargo|rustc|claude/ && $0 !~ /awk/ {print}' \ - | head -n 120 -git status --porcelain=v1 | cut -c4- | while IFS= read -r p; do - test -e "$p" && stat -c '%Y %y %n' "$p" -done | sort -nr | head -n 80 -``` - -Do not edit a path whose mtime or owner process is still advancing. Wait for the active owner to commit/push a coherent slice, then review that commit. Do not count output from a script that continues after `cargo build` fails or reuses a pre-existing binary. - -- [ ] **Step 3: Partition the dirty diff by behavior** - -Use these initial clusters, adjusting to the refreshed diff: - -1. Codex app-server lifetime and automation backend failure settlement. -2. Session checkout authorization and retrieval. -3. CLI/MCP application-error exit status and proxy shutdown/error propagation. -4. Mechanical hot-path simplifications and format/Clippy cleanup. -5. Untouched CI compatibility fixes. - -Each cluster must compile and carry its own focused tests before commit. Never commit all 121 entries as one unexplained sweep. - ---- - -### Task 2: Make the active correctness slices truthful before checkpointing - -**Files:** -- Modify: `crates/tracedecay-sessions/src/runtime/codex_app_server.rs` -- Modify: `crates/tracedecay-agent-hosts/src/automation/backend_identity.rs` -- Modify: `crates/tracedecay-agent-hosts/src/automation/scheduler.rs` -- Modify: `crates/tracedecay-agent-hosts/src/automation/lifecycle.rs` -- Modify: `src/daemon/session_retrieval/admitted.rs` -- Modify: `src/tool_command.rs` -- Modify: `src/tool_command/tests.rs` -- Modify: `src/daemon/core_client.rs` -- Modify: `src/daemon/core_proxy.rs` -- Test: existing in-file and integration tests adjacent to these paths - -**Interfaces:** -- Consumes: `AutomationConfig`, `AgentTaskFailureClass`, `ResolvedScope`, MCP `isError` -- Produces: bounded app-server lifetime, truthful suppression identity, checkout-scoped retrieval, and CLI/MCP error parity - -- [ ] **Step 1: Preserve the app-server stdin lifetime regression** - -Keep stdin open through `turn/completed`; close it only after `wait_for_turn_summary` returns. The focused test must launch the real configured app-server protocol boundary and prove a turn cannot be cancelled merely because the client sent no more requests. - -- [ ] **Step 2: Fix backend suppression identity before committing it** - -The current draft's `backend_executable_identity` is only the resolved path. Replace it with an identity derived from the opened executable, including stable file identity and content/revision evidence, so replacing or upgrading a binary at the same path changes the digest. Reuse existing canonical file/digest authorities; avoid hashing the binary on every scheduler tick by retaining the identity at the configuration/runtime boundary. - -Add a regression that writes backend bytes at one path, records a deterministic failure, replaces the bytes at the same path, and proves the next scheduler decision is `due`, not `backend_identity_suppressed`. - -- [ ] **Step 3: Narrow permanent suppression to truly deterministic classes** - -Do not indefinitely suppress `Unavailable` or `Denied`: installation, credentials, provider policy, and service state can change without the automation config changing. Do not suppress a generic `Disconnected` unless the failure is represented by a distinct typed protocol-contract class. Prefer adding/using a typed protocol violation over matching an error string. Retain cooldown behavior for transient classes. - -Tests must cover: - -```text -same executable + same config + typed permanent protocol failure => suppressed -same-path executable replacement => re-admitted -Unavailable => ordinary cooldown, then re-admitted -Denied => ordinary cooldown, then re-admitted -Timeout/Retryable => ordinary cooldown -``` - -- [ ] **Step 4: Keep session authorization checkout-scoped** - -The request and mounted session scopes may carry different branch refs while naming the same project/repository/worktree. Keep `ResolvedScope::identifies_same_checkout` at the admission boundary and retain the foreign-worktree refusal test. - -- [ ] **Step 5: Keep CLI and MCP failure semantics aligned** - -For compatibility tool dispatch, print the exact daemon payload, then exit nonzero only when the daemon sets top-level `isError: true`. Warming/partial/unavailable typed payloads without `isError` remain exit 0. Test both JSON and markdown payloads, stdout preservation, and nonzero application failure. - -- [ ] **Step 6: Run focused tests and commit by slice** - -Wait for the Cargo lane, then run anti-vacuous focused tests for the files above. Commit at least the app-server/automation, retrieval, and CLI/proxy changes separately with conventional messages. - ---- - -### Task 3: Restore compilation and platform/API compatibility - -**Files:** -- Modify: `crates/tracedecay-agent-hosts/src/automation/runner/skill_writer.rs` -- Modify: `src/daemon/session_sync.rs` -- Modify: `src/daemon/session_sync/work.rs` -- Modify: `src/daemon/session_sync/git_topology.rs` -- Modify: `src/mcp/server.rs` -- Modify: `crates/tracedecay-usecases/src/retention/code_index_generations.rs` -- Modify: `crates/tracedecay-usecases/src/retention/code_index_generations/scope_quarantine.rs` -- Modify: `src/hooks/codex.rs` -- Modify: `src/hooks/mod.rs` -- Modify: `tests/hooks_lsp_suite/hooks_test.rs` - -**Interfaces:** -- Consumes: canonical JSON digest helper, cancellation/deadline wake authorities, persisted token accounting, `tracedecay_runtime_core::windows_file::information`, shared hook JSON formatter -- Produces: event-driven session interruption, truthful MCP accounting initialization, Linux/Windows compilation, and preserved shipped Rust API - -- [x] **Step 1: Restore the canonical skill receipt digest import** - -Completed in `b8ea46cec`. The dirty `skill_writer.rs` now imports the canonical helper from the existing automation artifact authority, the peer formatting hunk remains unstaged, and the focused agent-hosts library check is green. - -- [ ] **Step 2: Repair session-sync interruption waits without restoring hot polling globally** - -Commit `cce801d93 perf(daemon): wait on session-sync permits without polling` removed `SESSION_SYNC_POLL_INTERVAL`, but `session_sync/work.rs` and `session_sync/git_topology.rs` still reference it. Do not simply restore a shared 10ms tick and call the regression fixed. - -Classify the four waits separately: - -1. Coalesced journal completion may retain one dedicated bounded poll only if no durable completion notification exists. -2. Request cancellation must await the existing `CancellationSignal::cancelled()` future. -3. Deadline waits must use one deadline sleep rather than repeated `now_micros()` polling. -4. Daemon shutdown must use `shutdown_notify` or a canonical async extension of `ObservationCancellation`; do not add a second shutdown authority. - -Add cancellation/deadline/shutdown regressions that prove prompt interruption without measuring a magic poll count, then run the smallest session-sync compilation/test slice. - -- [ ] **Step 3: Initialize MCP token accounting from the canonical typed read** - -`src/mcp/server.rs` constructs `tokens_saved` and `last_flushed_tokens` from an undefined `persisted`. Read tokens once before `Arc::new_cyclic`, use that exact result for both atomics and the optional accounting upsert, and preserve the existing rule that a failed read must not fabricate or upsert zero. Prefer one typed read with explicit unavailable behavior over two reads or `unwrap_or(0)`. - -The file also contains an unstaged peer rustfmt hunk around line 646. Stage only the accounting fix and leave peer formatting ownership intact. - -- [ ] **Step 4: Replace unstable Windows metadata APIs** - -Do not use `std::os::windows::fs::MetadataExt::{volume_serial_number,file_index,number_of_links}`. Open the path and compare retained handles using: - -```rust -tracedecay_runtime_core::windows_file::information(&file) -``` - -Compare `volume_serial_number`, `file_index`, and `number_of_links`; keep length/type/modified-time checks as appropriate. Hold the original file handle across verification and reopen the current named path before comparison, preserving rename/replacement refusal. Remove the now-unused Windows `MetadataExt`, the unused `encode_tagged_lowercase_hex`, and the Windows-only unused `OpenOptions` import. - -Run when the Cargo lane is free: - -```bash -TRACEDECAY_SKIP_DASHBOARD_BUILD=1 \ - cargo check -p tracedecay-usecases --lib --target x86_64-pc-windows-gnu --locked -``` - -- [ ] **Step 5: Restore the shipped hook compatibility alias** - -`origin/master` shipped `tracedecay::hooks::codex_additional_context_json`; PR #663 removed it. Restore: - -```rust -pub fn codex_additional_context_json(event_name: &str, additional_context: &str) -> String { - super::additional_context_json(event_name, additional_context) -} -``` - -Re-export it from `src/hooks/mod.rs` and test that it is byte-identical to `additional_context_json`. Do not restore duplicate formatting logic. The test-side import/assertion is already staged only in the working tree at `tests/hooks_lsp_suite/hooks_test.rs`; production alias/re-export and a valid non-vacuous RED/GREEN receipt remain outstanding. - -- [ ] **Step 6: Commit compatibility fixes as coherent slices** - -Run focused session-sync, MCP-server, Windows, and hook checks; rustfmt only exact owned paths; run `git diff --check`; then commit each coherent slice separately. - ---- - -### Task 4: Fix non-cascading CI blockers at their authority - -**Files:** -- Modify: `tests/release_safety_test.sh` -- Verify: `scripts/verify-retained-release-assets.sh` -- Modify: `dashboard/src/workspaces/agents/AgentsPage.dom.test.tsx` -- Regenerate: `sdks/typescript/src/**` through `sdks/codegen/generate.sh` -- Modify/delete only proven dead symbols reported by Clippy/Hawk - -**Interfaces:** -- Consumes: canonical release verification helper, Testing Library query semantics, SDK generator -- Produces: release provenance guard, deterministic dashboard assertion, generated SDK parity, clean Clippy/Hawk - -- [x] **Step 1: Fix the release guard, not the release provenance behavior** - -The workflow already invokes `scripts/verify-retained-release-assets.sh`, and that helper contains `gh attestation verify`, `--signer-workflow`, `--source-ref`, `--source-digest`, and `--deny-self-hosted-runners`. Change `tests/release_safety_test.sh` so workflows must invoke the canonical helper, then separately read and assert those exact provenance properties in the helper. Do not duplicate `gh attestation verify` in three workflow steps and do not satisfy the guard with a comment. - -Run: - -```bash -bash tests/release_safety_test.sh -bash tests/release_drift_check_test.sh -``` - -Completed in `150ac00b5`; both local scripts and hosted `Release Version Drift` are green. - -- [x] **Step 2: Fix the dashboard diagnostics fixture and assertion** - -The first failure was a multiple-match Testing Library query, but tightening it exposed the real defect: the test supplied four canonical diagnostics composition rows while defaulting the required diagnostics `event_count` to zero. Keep usage telemetry unknown, set diagnostics `event_count` to four, assert both charts use `share of 4`, and assert no fabricated `share of 0` appears. - -```ts -expect(screen.queryAllByText(/share of 0$/)).toHaveLength(0); -``` - -Run the exact Vitest file first, then dashboard typecheck and full tests. - -Completed in `4ae63542a`; focused Vitest is 7/7, `npm run typecheck` is green, and the hosted dashboard artifact job is green at `c7eb51ffc`. - -- [ ] **Step 3: Regenerate SDK clients canonically** - -Run: - -```bash -sdks/codegen/generate.sh -scripts/check-sdk-codegen.sh -``` - -Review and commit only the generator-authorized output. Never hand-edit `sdks/typescript/src/operations.ts`. - -- [ ] **Step 4: Clear Clippy/Hawk/format findings semantically** - -Collapse the five reported nested `if` blocks without `allow` attributes. Remove `canonical_session_metadata`, `load_relation_by_edge`, and `load_relation_by_locator` only after an exact caller search confirms they are truly dead; otherwise wire the production caller. Run scoped Clippy before workspace Clippy, then Hawk and rustfmt. - ---- - -### Task 5: Close review threads and make PR #663 merge-ready - -**Files:** -- Review only: PR #663 exact final pair - -**Interfaces:** -- Consumes: final commits, focused test evidence, GitHub review threads/checks -- Produces: zero unresolved threads and green required checks - -- [x] **Step 1: Re-query and substantively close the five existing review threads** - -At the snapshot there were five unresolved threads: one current P1 on released byte-array spool append intents and four outdated P2s on replay cleanup, Explorer polling, tombstone scanning, and invalid clocks. For each, verify final-head behavior with a falsifiable test, reply with the exact commit/test receipt, then resolve. Do not resolve based only on the thread becoming outdated. - -All five are resolved with the receipts listed in the live snapshot. They remain resolved after pushes through `c7eb51ffc`; a fresh GraphQL query at 05:25 UTC found no delayed thread. This checkbox covers only the existing threads; Step 3 remains mandatory after every future push. - -- [ ] **Step 2: Refresh and clear CI** - -Use: - -```bash -gh pr checks 663 --json name,bucket,state,workflow,link -``` - -Distinguish root failures from compile cascades. Re-run only after the corresponding fix is pushed. Do not claim skipped checks as green. - -- [ ] **Step 3: Wait for delayed automated review** - -After the final push and green focused gates, wait several minutes, then query review threads again. Address any new current comment before merge. - -- [ ] **Step 4: Merge only PR #663** - -Revalidate exact base/head, mergeability, zero unresolved comments, and green required checks. Use a normal GitHub merge into `codex/tracedecay-total-redesign-plan`. Do not merge that integration branch into `master` and do not touch PR #559. - ---- - -### Task 6: Build, install, and validate the exact post-merge beta - -**Files:** -- Follow: canonical beta-release/release documentation and workflows -- Install target: `~/.local/bin/tracedecay` - -**Interfaces:** -- Consumes: exact merged `codex/tracedecay-total-redesign-plan` commit -- Produces: version-bound installed binary and before/after daemon receipt - -- [ ] **Step 1: Capture the pre-restart evidence** - -Record daemon PID/version, RSS, CPU, thread count, runtime writer counters, WAL bytes, swap, and representative CLI/MCP latencies. Do not kill the old process until its evidence is durable. - -- [ ] **Step 2: Build from the exact merged commit** - -Use the repository's canonical beta process. Stop immediately on build failure, delete or ignore any old output, and verify the produced binary reports the exact expected version/commit before installation. A script that checks only whether `target/.../tracedecay` exists is invalid. - -- [ ] **Step 3: Install and restart safely** - -Install the verified binary to `~/.local/bin/tracedecay`, restart the daemon once, and confirm startup health plus exact version. Preserve typed startup failure; do not loop restarts. - ---- - -### Task 7: Profile the real CLI/MCP workload before optimizing - -**Files:** -- Add or modify only after profiling identifies the production owners -- Prefer an existing benchmark location under `benchmark_data/runtime/` for reproducible receipts - -**Interfaces:** -- Consumes: installed post-merge beta and real Cursor/Codex session corpus -- Produces: reproducible latency, I/O, CPU, memory, file-open, and writer-amplification baseline - -- [ ] **Step 1: Run identical cold/warm command journeys** - -Measure at least `status`, `runtime`, `active_project`, `context`, `grep`, and session/message retrieval. Capture wall/user/system time, exit code, typed result, p50/p95, daemon CPU/RSS, disk reads, and writer counters. Do not interpret a typed warming/unavailable response as transport failure. - -- [ ] **Step 2: Attribute I/O and CPU** - -Use bounded samples (`pidstat`, `iostat`, `perf record`, and file-open tracing where available) around one known command and one background catch-up interval. Confirm whether unchanged historical transcript files are reopened and reread. - -- [ ] **Step 3: Quantify writer amplification** - -For each store shard, record admitted operations, committed batches, WAL bytes, queue-wait microseconds, transaction microseconds, and messages/records advanced. The pre-fix snapshot was 557,009 admitted operations and 556,981 commits: almost one transaction per operation, with ~3,939 seconds queue wait and ~3,354 seconds transaction time. - ---- - -### Task 8: Remove measured bottlenecks and prove end-to-end gains - -**Files:** -- Likely modify: session provider discovery/cursor owners, runtime writer batching, and scheduler admission paths identified by Task 7 -- Test/benchmark: focused production journeys and `benchmark_data/runtime/` - -**Interfaces:** -- Consumes: Task 7 profile and existing per-source cursor/CAS authorities -- Produces: bounded incremental ingest, batched durable writes, responsive foreground tools, and before/after receipts - -- [ ] **Step 1: Eliminate unchanged transcript rescans** - -Persist and honor per-source file identity, byte cursor, and directory discovery watermark. After catch-up, unchanged historical day directories/files must not be reopened on every tick. Read only appended bytes and invalidate only when stable identity or size regresses. Preserve source-specific ordering and typed replacement/corruption states. - -- [ ] **Step 2: Batch storage admissions** - -Keep one canonical writer/CAS authority per store, but admit a bounded chunk of independent records per transaction instead of committing each message separately. Preserve ordered cursor advancement, cancellation checkpoints, memory budgets, and rollback. Do not create parallel writers or weaken digest chains. - -- [ ] **Step 3: Parallelize only independent work** - -Use bounded parallel extraction across files/sources that own independent cursors. Keep cumulative sealing and same-store cursor/CAS steps ordered. Wide CPU use should come from independent parse/hash work, not concurrent writes to one authority. - -- [ ] **Step 4: Meet falsifiable acceptance criteria** - -On the same corpus and machine: - -```text -unchanged transcript opens after catch-up: zero during a bounded idle sample -committed batches per admitted historical record: at least 10x lower than baseline -foreground status/runtime p95 while warming: <= 2 seconds -no swap growth and no unbounded RSS slope during catch-up -typed correctness/cancellation/restart tests: unchanged or stronger -``` - -If a target is missed, retain the measurement and continue diagnosis; do not raise deadlines or add retry loops. - -- [ ] **Step 5: Commit each proven optimization separately** - -Each commit includes its RED/baseline, production change, GREEN behavior, and before/after profile receipt. Finish with focused tests, broader affected-package gates, Clippy, rustfmt, diff-check, and an independent semantic review. diff --git a/docs/superpowers/plans/v2/pr16-remote-brain.md b/docs/superpowers/plans/v2/pr16-remote-brain.md deleted file mode 100644 index d7613c93a2..0000000000 --- a/docs/superpowers/plans/v2/pr16-remote-brain.md +++ /dev/null @@ -1,56 +0,0 @@ -# PR16 Remote Shared Brain Plan - -> **Archived provenance, not current requirements.** This document records -> historical planning and execution evidence. Current scope and acceptance come -> only from [`00-plan-set-index.md`](../../../plans/tracedecay-v2/00-plan-set-index.md), -> [`NEXT.md`](../../../plans/tracedecay-v2/NEXT.md), and the applicable numbered -> V2 plan. Do not recreate its task checklists, file inventories, -> branch/worktree/SHA or commit protocol, Gate A/B, timing/JUnit receipts, exact -> test names/counts, generated-byte/source-shape checks, PR closure gates, or -> platform gate lattice. -> Historical version/compatibility/migration language cannot resurrect -> branch-only transient scaffolding. Potentially persisted enrollment files, -> spools, replica journals, backups, checkpoints, and receipts keep -> backward-read/replay/recovery until a separately authorized machine/profile -> census proves absence. - -**Goal:** Add enrolled, offline-capable, fenced remote Brain operation with one -writer per mutable shard and verified backup/failover. - -## Historical file and interface inventory - -- Domain/application remote identity and epoch contracts. -- Daemon enrollment, encrypted/offline spool, replay, query coverage, replica, - backup/restore, promotion/failover, Doctor/API/dashboard surfaces. - -Interfaces: `BrainId`, `NodeId`, `ShardId`, `PlacementRevision`, `Epoch`, -`EnrollmentGrant`, `OfflineEnvelope`, `ReplayReceipt`, `ReplicaWatermark`, -`BackupManifest`, and `PromotionReceipt`. - -Writer key is exactly -`(BrainId, shard, generation, placement_revision, epoch)`. Overlays remain -node-local and never become shared mutable authority. - -## Historical ordered slices - -1. Remote contracts and monotone epoch ledger. -2. Enrollment, credential rotation, bounded encrypted offline spool. -3. Fenced duplicate-tolerant replay and cross-node query coverage. -4. Verified replica, backup, staged restore, and integrity receipts. -5. Promotion/failover plus API, Doctor, dashboard, and direct journey. - -## Product outcome contributed - -The work contributed enrolled, offline-capable remote operation with fenced -single-writer authority, duplicate-tolerant replay, query coverage, and -verified backup/failover behavior. Current direct behavior and acceptance live -in the applicable numbered V2 plan. - -## Historical migration, rollback, measurement, and deletion notes - -Enroll remote capability without changing local authority. Restore and verify -before promotion; rollback occurs before promotion and never through -multi-primary fallback. Measure offline append/replay, query latency/coverage, -backup/restore, promotion RTO/RPO, and event-to-ready. Delete ad hoc remote -paths only after fencing, duplicate tolerance, failover, backup/restore, -cross-node query, Doctor/dashboard, and normal CI evidence pass. diff --git a/docs/superpowers/plans/v2/pr18-public-sdks.md b/docs/superpowers/plans/v2/pr18-public-sdks.md deleted file mode 100644 index 98e4422159..0000000000 --- a/docs/superpowers/plans/v2/pr18-public-sdks.md +++ /dev/null @@ -1,67 +0,0 @@ -# PR18 Public SDK Plan - -> **Archived provenance, not current requirements.** This document records -> historical planning and execution evidence. Current scope and acceptance come -> only from [`00-plan-set-index.md`](../../../plans/tracedecay-v2/00-plan-set-index.md), -> [`NEXT.md`](../../../plans/tracedecay-v2/NEXT.md), and the applicable numbered -> V2 plan. Do not recreate its task checklists, file inventories, -> branch/worktree/SHA or commit protocol, Gate A/B, timing/JUnit receipts, exact -> test names/counts, generated-byte/source-shape checks, PR closure gates, or -> platform gate lattice. -> Historical version/compatibility/migration language cannot resurrect -> source-only/internal branch scaffolding. Only an actually independently -> released public API/schema revision may retain protocol compatibility. -> Persisted cursors, idempotency keys, journals, checkpoints, and receipts -> accept only their exact final shape; every other database, store, spool, file, -> or projection returns typed `ResetRequired` and requires explicit reset or -> recreation. No storage reader, migration, backfill, dual write, or census -> path exists. - -**Goal:** Publish Rust and TypeScript SDKs for accepted PR12–PR17 -operations without inventing lifecycle semantics. (The originally planned -Python SDK was dropped: delivery is TypeScript-first plus a retained Rust -SDK for native consumers, with no Python package.) - -## Historical file and interface inventory - -- Rust workspace SDK crate and generated wire types. -- TypeScript package root, generators, conformance fixtures, - package metadata, examples, and release CI. -- Bind local daemon and PR16 remote transports to one operation catalog. - -Generated types cover wire schemas. Handwritten façades cover authentication, -`RequestContext`, paging/cursors, SSE reconnect, cancellation, resume, -idempotency, typed errors, operation receipts, `TaskHandoffToken`, and host -handoff tokens. Names freeze only after each operation's production journey is -accepted. - -## Historical ordered slices - -1. Freeze accepted operation/schema manifest. -2. Generate Rust/TS wire models deterministically. -3. Implement lifecycle façades and local transport. -4. Implement remote transport with identical semantics. -5. Add examples and cross-language golden conformance. -6. Package/install/publish dry runs and compatibility policy. - -## Product outcome contributed - -The work contributed Rust and TypeScript SDK façades over one operation -catalog with equivalent authentication, scope, lifecycle, paging/SSE, -cancellation, idempotency, and typed outcomes. Current direct behavior and -acceptance live in the applicable numbered V2 plan. - -## Historical release, reset, measurement, and deletion notes - -Before first publication, generated schemas change in place. V2 branch-local -data uses the fresh-store cutover: only the exact final persisted shape is -accepted, and every other shape returns typed `ResetRequired` for explicit -reset or recreation. No storage reader, migration, backfill, dual write, or -census path survives. After an actual independent package release, public -schemas follow the accepted major-version compatibility policy. Rollback -unpublishes or yanks a package release according to registry -policy but never changes server semantics. Measure generation, package size, -startup, paging/SSE overhead, and conformance duration. Delete private client -wrappers and aliases only after two-language (Rust and TypeScript) -local/remote conformance, -examples, package/install gates, semver review, and normal CI pass. diff --git a/docs/superpowers/plans/v2/pr19-cutover-runtime.md b/docs/superpowers/plans/v2/pr19-cutover-runtime.md deleted file mode 100644 index 26dfc18c31..0000000000 --- a/docs/superpowers/plans/v2/pr19-cutover-runtime.md +++ /dev/null @@ -1,49 +0,0 @@ -# PR19 Runtime Fresh-Store Reset - -> Historical planning evidence only. Current scope and acceptance come from -> [`00-plan-set-index.md`](../../../plans/tracedecay-v2/00-plan-set-index.md), -> [`NEXT.md`](../../../plans/tracedecay-v2/NEXT.md), and the applicable numbered -> V2 plan. Do not recreate branch/worktree/SHA protocols, gate lattices, -> generated-byte/source-shape checks, or transition-era inventories. - -**Goal:** Admit one exact final V2 persisted shape, return `ResetRequired` for -every other shape, and retain only independently released public API protocol -compatibility. - -Every TraceDecay database, store, spool, file, journal, checkpoint, receipt, -and projection accepts only its exact final shape. A non-final shape is refused -before interpretation and requires an explicit reset or recreation. There is -no stored-data reader, conversion, backfill, dual write, shadow read, census, -or recovery path, including for data written by an older installed binary. - -## Scope - -- Validate final-shape admission at each persisted-state open boundary. -- Preserve one fenced writer and canonical daemon route for a valid final - store. -- Provide an explicit reset/recreation action scoped to a refused target. -- Delete storage-transition code and source-only aliases after internal callers - move. -- Retain a public protocol façade only with evidence of an actual independent - package or API release; it delegates to the canonical operation and owns no - storage or lifecycle behavior. - -## Direct acceptance - -- Exact-final fixtures admit through the canonical daemon route. -- Every older, partial, unknown, unversioned, or foreign persisted fixture - returns `ResetRequired` before read, write, replay, or projection. -- Explicit reset/recreation creates a clean final store without consuming old - bytes. -- Tests prove no stored-data reader, converter, backfill, dual write, shadow - read, census, or recovery route remains. -- Retained public protocol compatibility is independently release-evidenced - and preserves canonical authorization, errors, redaction, effects, - pagination, streaming, cancellation, and retry behavior. - -## Not in PR19 - -- Persisted-data conversion, rollback, retention, or recovery workflows. -- Memory special handling. -- Transition dashboards, execution ledgers, schema-only conformance suites, or - placeholder acceptance baselines. diff --git a/docs/superpowers/plans/v2/pr20-performance.md b/docs/superpowers/plans/v2/pr20-performance.md deleted file mode 100644 index 954353b2de..0000000000 --- a/docs/superpowers/plans/v2/pr20-performance.md +++ /dev/null @@ -1,44 +0,0 @@ -# PR20 Measured Performance and Cleanup Plan - -> **Archived provenance, not current requirements.** This document records -> historical planning and execution evidence. Current scope and acceptance come -> only from [`00-plan-set-index.md`](../../../plans/tracedecay-v2/00-plan-set-index.md), -> [`NEXT.md`](../../../plans/tracedecay-v2/NEXT.md), and the applicable numbered -> V2 plan. Do not recreate its task checklists, file inventories, -> branch/worktree/SHA or commit protocol, Gate A/B, timing/JUnit receipts, exact -> test names/counts, generated-byte/source-shape checks, PR closure gates, or -> platform gate lattice. - -**Outcome contributed:** Measure and improve production event-to-ready time and -other user-visible bottlenecks while preserving equivalent product behavior, -truthful unavailable metrics, and bounded storage. - -## Retired measurement framework - -`EditClassReceipt`, boundary receipts, Gate A/B dispositions, JUnit/timing -receipt schemas, exact test-count or slow-test gates, generated-byte/source-shape -checks, and the default/all/no-default/lite/package/platform gate lattice are -retired. Historical receipts may preserve those fields as provenance, but they -are not prerequisites and must not be recreated. - -## Historical work areas - -- Measure representative production capture/edit/event-to-ready journeys under - comparable conditions. -- Optimize measured runtime/query/index bottlenecks without semantic drift. -- Keep retention/storage behavior bounded and surface missing measurements - truthfully. -- Remove superseded paths when the applicable numbered V2 plan permits it. - -## Product outcome contributed - -The outcome is measured event-to-ready improvement with equivalent -behavior. Current representative journeys, equivalence criteria, storage -bounds, and acceptance are defined by the applicable numbered V2 plans. - -## Historical measurement notes - -Historical experiments isolated optimizations, compared like-for-like samples, -and rejected semantic drift or false claims from unavailable data. Their -thresholds, receipt types, boundary dispositions, platform matrix, and deletion -choreography do not define current closure. diff --git a/scripts/census_attack_the_premise.py b/scripts/census_attack_the_premise.py deleted file mode 100755 index e42b977f84..0000000000 --- a/scripts/census_attack_the_premise.py +++ /dev/null @@ -1,401 +0,0 @@ -#!/usr/bin/env python3 -"""Census which actors hold a role that repeated fixes left in place. - -Two premises failed the same gate more than once on PR #707. This script -counts actors (presence), not magnitudes. Rerun from the repo root: - - python3 scripts/census_attack_the_premise.py - python3 scripts/census_attack_the_premise.py --check - python3 scripts/census_attack_the_premise.py --self-test - -Premise A, nested chunk admission. - "A parent that already holds a background-CPU unit must return that unit - to the global FIFO around every nested chunk join." -Failed the wedge/merge gate as call-site yield wraps (dropped in integration), -restored wraps, then a welded yield inside chunks::fan_out. The return path -left the parent holding the role on every large batch. - -Premise B, terminal publication admission. - "The worker task that observed publication corruption holds terminal - suppress, so a local bool is the admission authority." -Review passes on the same tip both rejected that bool beside the typed park. -A restarted worker on the same mount does not see the bool. The park slot is -the actor every reader already shares. -""" - -from __future__ import annotations - -import argparse -import sys -from dataclasses import dataclass -from pathlib import Path - - -POOL_TOKENS = ("par_iter", "par_chunks", "par_bridge") -YIELD_LEAF = "with_yielded_background_cpu_permits" -YIELD_BOUNDARY = "with_yielded_permits" -PERMIT_LEAF = "with_background_cpu_permit" -TERMINAL_BOOL = "publication_authority_terminal" - - -@dataclass(frozen=True) -class Actor: - kind: str - path: str - line: int - detail: str - - def render(self) -> str: - return f"{self.kind}\t{self.path}:{self.line}\t{self.detail}" - - -def _blank_span(span: str) -> str: - return "".join("\n" if char == "\n" else " " for char in span) - - -def strip_comments_and_strings(source: str) -> str: - """Drop comments and string literals while keeping newlines. - - Line numbers stay aligned with the original file. String literals are - blanked so a test that mentions a token inside `contains("...")` is not - counted as an actor that calls it. - """ - out: list[str] = [] - index = 0 - length = len(source) - while index < length: - if source.startswith("//", index): - end = source.find("\n", index) - if end < 0: - out.append(" " * (length - index)) - break - out.append(" " * (end - index)) - index = end - continue - if source.startswith("/*", index): - end = source.find("*/", index + 2) - if end < 0: - out.append(" " * (length - index)) - break - out.append(_blank_span(source[index : end + 2])) - index = end + 2 - continue - if source.startswith('r#"', index) or source.startswith('r"', index): - if source.startswith('r#"', index): - end = source.find('"#', index + 3) - end = length if end < 0 else end + 2 - else: - end = source.find('"', index + 2) - end = length if end < 0 else end + 1 - out.append(_blank_span(source[index:end])) - index = end - continue - if source[index] == '"': - end = index + 1 - while end < length: - if source[end] == "\\": - end += 2 - continue - if source[end] == '"': - end += 1 - break - end += 1 - out.append(_blank_span(source[index:end])) - index = end - continue - out.append(source[index]) - index += 1 - return "".join(out) - - -def line_of(source: str, offset: int) -> int: - return source.count("\n", 0, offset) + 1 - - -def enclosing_fn(source: str, offset: int) -> str: - window = source[:offset] - marker = window.rfind("fn ") - while marker >= 0: - before = source[marker - 1] if marker else " " - if before.isalnum() or before == "_": - marker = window.rfind("fn ", 0, marker) - continue - name_start = marker + 3 - name_end = name_start - while name_end < len(source) and (source[name_end].isalnum() or source[name_end] == "_"): - name_end += 1 - if name_end > name_start and name_end < len(source) and source[name_end] in "(<": - return source[name_start:name_end] - marker = window.rfind("fn ", 0, marker) - return "" - - -def matching_brace(source: str, open_at: int) -> int | None: - depth = 0 - for index in range(open_at, len(source)): - char = source[index] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return index - return None - - -def closure_body(source: str, call_at: int) -> tuple[int, int] | None: - paren = source.find("(", call_at) - if paren < 0: - return None - brace = source.find("{", paren) - if brace < 0: - return None - end = matching_brace(source, brace) - if end is None: - return None - return brace + 1, end - - -def in_cfg_test(source: str, offset: int) -> bool: - marker = "#[cfg(test)]" - start = 0 - while True: - found = source.find(marker, start) - if found < 0 or found > offset: - return False - brace = source.find("{", found) - if brace < 0 or brace > offset: - return False - end = matching_brace(source, brace) - if end is None: - return False - if brace < offset < end: - return True - start = end + 1 - - -def scan_source(path: str, source: str) -> list[Actor]: - code = strip_comments_and_strings(source) - actors: list[Actor] = [] - for kind, token in ( - ("nested-yield", YIELD_LEAF), - ("pool-boundary-yield", YIELD_BOUNDARY), - ): - start = 0 - while True: - found = code.find(token, start) - if found < 0: - break - start = found + len(token) - if code.startswith("(", found + len(token)) is False and not code[ - found + len(token) : - ].lstrip().startswith("("): - # definition, not a call - if "fn " in code[max(0, found - 24) : found]: - continue - if "fn " in code[max(0, found - 8) : found]: - continue - function = enclosing_fn(code, found) - site_kind = f"test:{kind}" if in_cfg_test(source, found) else kind - if site_kind == "pool-boundary-yield" and function == "install": - actors.append( - Actor(kind, path, line_of(source, found), "install boundary") - ) - continue - if function == "with_yielded_background_cpu_permits": - actors.append( - Actor( - "nested-yield", - path, - line_of(source, found), - "compensation wrapper", - ) - ) - continue - actors.append(Actor(site_kind, path, line_of(source, found), function)) - - start = 0 - while True: - found = code.find(PERMIT_LEAF, start) - if found < 0: - break - start = found + len(PERMIT_LEAF) - # with_background_cpu_permits is the weighted sibling, same role. - body = closure_body(code, found) - if body is None: - continue - snippet = code[body[0] : body[1]] - if any(token in snippet for token in POOL_TOKENS): - kind = "nested-permit-join" - if in_cfg_test(source, found): - kind = f"test:{kind}" - actors.append( - Actor(kind, path, line_of(source, found), enclosing_fn(code, found)) - ) - - start = 0 - while True: - found = code.find(TERMINAL_BOOL, start) - if found < 0: - break - start = found + len(TERMINAL_BOOL) - kind = "worker-local-terminal" - if in_cfg_test(source, found): - kind = f"test:{kind}" - actors.append(Actor(kind, path, line_of(source, found), enclosing_fn(code, found))) - return actors - - -def crate_sources(root: Path) -> list[tuple[str, str]]: - files: list[tuple[str, str]] = [] - crates = root / "crates" - if not crates.is_dir(): - return files - for path in sorted(crates.rglob("*.rs")): - relative = path.relative_to(root).as_posix() - if "/target/" in f"/{relative}/": - continue - files.append((relative, path.read_text(encoding="utf-8"))) - return files - - -def census(root: Path) -> list[Actor]: - actors: list[Actor] = [] - for path, source in crate_sources(root): - actors.extend(scan_source(path, source)) - return actors - - -def violations(actors: list[Actor]) -> list[Actor]: - """Production actors that still hold a role the failed fixes assumed. - - The install-boundary yield is listed in the census and is not a violation: - it is one actor, and the failed gate was nested leaf joins plus the - worker-local terminal flag. Test-module mentions are evidence, not a - production assignment. - """ - return [ - actor - for actor in actors - if not actor.kind.startswith("test:") - and actor.kind in {"nested-yield", "nested-permit-join", "worker-local-terminal"} - ] - - -def render(actors: list[Actor]) -> str: - lines = [ - "actor\tlocation\tdetail", - "----", - ] - if not actors: - lines.append("(no actors hold the repeated role)") - else: - lines.extend(actor.render() for actor in actors) - kinds: dict[str, int] = {} - for actor in actors: - kinds[actor.kind] = kinds.get(actor.kind, 0) + 1 - lines.append("----") - lines.append( - "counts: " - + ", ".join(f"{kind}={count}" for kind, count in sorted(kinds.items()) or ["none"]) - ) - return "\n".join(lines) - - -def self_test() -> int: - nested = """ -fn map_yielding() { - with_yielded_background_cpu_permits(|| { - items.into_par_iter().map(map).collect() - }); -} -""" - leaf = """ -fn collect() { - items.par_iter().map(|item| { - with_background_cpu_permit(|| operation(item)) - }).collect(); -} -""" - inside = """ -fn bad() { - with_background_cpu_permit(|| { - items.par_iter().for_each(|_| {}); - }); -} -""" - boundary = """ -fn install() { - self.background_cpu.with_yielded_permits(|| self.pool.install(operation)) -} -""" - terminal = """ -fn worker() { - let mut publication_authority_terminal = false; - if publication_authority_terminal { - publication_authority_terminal = true; - } -} -""" - # The detector must not count its own documentation of the tokens. - comment = """ -fn note() { - // with_yielded_background_cpu_permits is the failed return path - // publication_authority_terminal was the task-local role -} -""" - mentioned = """ -fn pin() { - assert!(source.contains("with_yielded_background_cpu_permits(")); - assert!(!source.contains("publication_authority_terminal")); -} -""" - cases = { - "nested yield": (nested, {"nested-yield"}), - "leaf permit outside join": (leaf, set()), - "permit closure joins the pool": (inside, {"nested-permit-join"}), - "install boundary": (boundary, {"pool-boundary-yield"}), - "worker-local terminal": (terminal, {"worker-local-terminal"}), - "commented tokens": (comment, set()), - "string mention": (mentioned, set()), - } - failed = False - for name, (source, expected) in cases.items(): - found = {actor.kind for actor in scan_source("fixture.rs", source)} - if found != expected: - print(f"self-test failed: {name}: got {sorted(found)} expected {sorted(expected)}") - failed = True - if failed: - return 1 - print("census_attack_the_premise self-test: ok") - return 0 - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--check", - action="store_true", - help="exit non-zero when a repeated role is still assigned", - ) - parser.add_argument("--self-test", action="store_true", help="run classifier fixtures") - parser.add_argument( - "--root", - type=Path, - default=Path(__file__).resolve().parents[1], - help="repository root (default: parent of scripts/)", - ) - args = parser.parse_args(argv) - if args.self_test: - return self_test() - actors = census(args.root) - print(render(actors)) - failed = violations(actors) - if args.check and failed: - print(f"check failed: {len(failed)} actor(s) still hold a repeated role", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/check-distribution-acceptance.sh b/scripts/check-distribution-acceptance.sh index 72cb5820d3..8962298f25 100755 --- a/scripts/check-distribution-acceptance.sh +++ b/scripts/check-distribution-acceptance.sh @@ -363,6 +363,12 @@ tar -C "$repo" \ --exclude='./node_modules' \ -cf - . | tar -xf - -C "$staged" resolve_clean_source_head "$repo" "$source_git_sha" >/dev/null +# The asset staging below rewrites package-local directories inside `$staged` +# (its `crates/tracedecay/tests/fixtures` becomes the root fixtures), so the +# integration suites that read package-local fixtures run from this untouched +# copy of the same snapshot. +source_snapshot="$work/source" +cp -a -- "$staged" "$source_snapshot" staged_product="$staged/crates/tracedecay" [[ -f "$staged_product/Cargo.toml" ]] || @@ -384,10 +390,16 @@ declare -a staged_root_assets=( "tests/fixtures" "scripts/run-session-temporal-benchmark.sh" ) +# A package directory may already carry its own entry at the destination path, +# as `crates/tracedecay/tests/fixtures` does. `cp -a` merges a directory into +# an existing directory of the same name, which would leave the package-local +# asset a superset of the root one. Clear the destination so the staged asset +# is exactly the root snapshot the assertion below demands. for asset in "${staged_root_assets[@]}"; do [[ -e "$staged/$asset" ]] || die "product package asset is missing from the staged source tree: $asset" mkdir -p -- "$staged_product/$(dirname -- "$asset")" + rm -rf -- "$staged_product/$asset" cp -a -- "$staged/$asset" "$staged_product/$(dirname -- "$asset")/" done @@ -404,6 +416,7 @@ for asset in "${staged_cli_assets[@]}"; do [[ -e "$staged/$asset" ]] || die "CLI package asset is missing from the staged source tree: $asset" mkdir -p -- "$staged_cli_crate/$(dirname -- "$asset")" + rm -rf -- "$staged_cli_crate/$asset" cp -a -- "$staged/$asset" "$staged_cli_crate/$(dirname -- "$asset")/" done @@ -724,15 +737,22 @@ CARGO_NET_OFFLINE=true cargo nextest run \ --config "$patch_config" \ --no-tests=fail +# `cargo package` publishes no integration tests (the root crate's `include` +# whitelist carries only fixtures), and `mcp_suite` requires the +# `test-transport` feature the production graph excludes, so the extracted +# package cannot run this suite. Run it from the untouched source snapshot +# under the `root-transport` CI lens instead, with the packaged CLI as the +# binary the suite spawns. echo "distribution acceptance: checking packaged MCP tool behavior" TRACEDECAY_TEST_BIN="$packaged_cli_bin" \ CARGO_NET_OFFLINE=true cargo nextest run \ - --manifest-path "$root_package/Cargo.toml" \ + --manifest-path "$source_snapshot/Cargo.toml" \ --release \ - --no-default-features \ - --features production \ + -p tracedecay \ --test mcp_suite \ - --config "$patch_config" \ + --features tracedecay/test-transport \ + --no-fail-fast \ + --retries 2 \ --no-tests=fail install_root="$work/install" @@ -786,7 +806,10 @@ print( + " }" ) PY -cat >"$consumer/src/main.rs" <<'RS' +# The host bundle generators sign each bundle with the commit that produced +# it; the packaged product's source head is that commit. +printf 'const GENERATOR_COMMIT: &str = "%s";\n' "$source_git_sha" >"$consumer/src/main.rs" +cat >>"$consumer/src/main.rs" <<'RS' use std::collections::BTreeSet; use tracedecay_contracts::catalog_composition::build_application_catalog_snapshot; @@ -838,11 +861,11 @@ fn main() { ); for host in RECEIPT_BACKED_HOST_KINDS { let components = default_components(host); - let component_set = verified_embedded_default_host_component_set(host, 0) + let component_set = verified_embedded_default_host_component_set(host, 0, GENERATOR_COMMIT) .expect("default packaged host component set must verify"); assert_eq!(component_set.component_set.components.len(), components.len()); for component in components { - let bundle = verified_embedded_host_bundle(host, component, 0) + let bundle = verified_embedded_host_bundle(host, component, 0, GENERATOR_COMMIT) .expect("packaged host bundle must be callable"); bundle .manifest @@ -854,6 +877,12 @@ fn main() { } RS +# A fresh manifest resolves from scratch, and offline resolution refuses a +# version that has since been yanked even when the workspace lockfile pins +# it (bisync 0.3.0 under gix-protocol). Seed the consumer with that +# lockfile, as the extracted packages are, so it resolves what the product +# resolves. +cp -- "$staged/Cargo.lock" "$consumer/Cargo.lock" echo "distribution acceptance: calling packaged catalog and host bundles" CARGO_NET_OFFLINE=true cargo run \ --manifest-path "$consumer/Cargo.toml" \ @@ -879,6 +908,7 @@ fn main() { let _ = McpServer::has_project_session_retrieval_service_for_test; } RS +cp -- "$staged/Cargo.lock" "$test_api_probe/Cargo.lock" echo "distribution acceptance: proving production package omits test APIs" test_api_stderr="$work/test-api-probe.stderr" if CARGO_NET_OFFLINE=true cargo check \ @@ -887,9 +917,15 @@ if CARGO_NET_OFFLINE=true cargo check \ 2>"$test_api_stderr"; then die "production package exposed test-transport APIs" fi -grep -Eq "no function or associated item named .*has_project_session_retrieval_service_for_test" \ - "$test_api_stderr" || +# rustc words this refusal differently across releases ("no function or +# associated item named" before 1.97, "no associated function or constant +# named" from 1.97), so match the error code and the probed name. +grep -Eq "error\[E0599\].*has_project_session_retrieval_service_for_test" \ + "$test_api_stderr" || { + echo "distribution acceptance: test API probe stderr follows" >&2 + tail -n 60 -- "$test_api_stderr" >&2 die "test API probe failed for an unexpected reason" +} binary=$(python3 "$repo/scripts/resolve-installed-binary.py" \ "$install_root" \ diff --git a/scripts/check-production-feature-profile.py b/scripts/check-production-feature-profile.py index f90d9267d5..ea12e82500 100755 --- a/scripts/check-production-feature-profile.py +++ b/scripts/check-production-feature-profile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Prove default and explicit production Cargo profiles resolve identically.""" +"""Prove the production Cargo graph does not enable test-transport.""" from __future__ import annotations @@ -66,35 +66,23 @@ def main() -> int: raise SystemExit("production feature set lost a required member") if "test-transport" in features["production"]: raise SystemExit("production feature directly enables test-transport") - - # `cargo metadata` unifies dev-dependency features across the workspace and - # therefore makes test-only transports look production-reachable. Inspect - # the root package's normal/build tree so this check matches the artifact - # that `cargo build` actually produces. - default_graph = resolved_features(repo) + # `default` is exactly `["production"]`, so a second default-feature walk + # repeats this graph. `cargo metadata` unifies dev-dependency features + # across the workspace and makes test-only transports look reachable; + # this normal/build tree is the artifact `cargo build` produces. production_graph = resolved_features( repo, "--no-default-features", "--features", "production" ) - if default_graph.keys() != production_graph.keys(): - raise SystemExit("default and production resolve different package graphs") - root_id = next( - (package_id for package_id in default_graph if package_id.startswith("tracedecay ")), + ( + package_id + for package_id in production_graph + if package_id.startswith("tracedecay ") + ), None, ) if root_id is None: raise SystemExit("cargo tree omitted the tracedecay root package") - default_graph[root_id].discard("default") - mismatches = [ - package_id - for package_id in sorted(default_graph) - if default_graph[package_id] != production_graph[package_id] - ] - if mismatches: - raise SystemExit( - "default and production resolve different features for: " - + ", ".join(mismatches) - ) contaminated = [ package_id for package_id, package_features in production_graph.items() @@ -105,7 +93,7 @@ def main() -> int: "production graph enables test-transport for: " + ", ".join(contaminated) ) - print("default and production Cargo feature graphs are identical") + print("production Cargo feature graph excludes test-transport") return 0 diff --git a/scripts/resolve-release-source-profile.py b/scripts/resolve-release-source-profile.py index 78c0428ec0..fd9c35b1d3 100755 --- a/scripts/resolve-release-source-profile.py +++ b/scripts/resolve-release-source-profile.py @@ -10,17 +10,6 @@ import tomllib -def production_release_features( - _features: dict[str, object], _target: str | None -) -> tuple[str, ...]: - """Return the artifact feature set for a production-capable source tag.""" - # Hotpath 0.24 uses Cargo features as its process-wide activation - # authority. Feature-enabled gauges, futures, and instrumented locks start - # collectors independently of TraceDecay's process guard, so a release - # executable cannot truthfully make those facilities dormant at runtime. - return ("production",) - - def expand_local_features( features: dict[str, list[str]], selected: list[str] ) -> set[str]: @@ -78,10 +67,11 @@ def main() -> int: check=True, ) profile = "production" - cargo_features = ",".join( - production_release_features(features, arguments.target) - ) - cargo_args = f"--no-default-features --features {cargo_features}" + # Hotpath uses Cargo features as its process-wide activation authority, + # so a release binary does not carry them and then try to switch them + # off at runtime. + cargo_features = "production" + cargo_args = "--no-default-features --features production" else: resolved_defaults = expand_local_features(features, defaults) if "test-transport" in resolved_defaults: diff --git a/scripts/test-check-distribution-snapshot.sh b/scripts/test-check-distribution-snapshot.sh index 01a3af91ff..30d1c00ec2 100644 --- a/scripts/test-check-distribution-snapshot.sh +++ b/scripts/test-check-distribution-snapshot.sh @@ -75,6 +75,11 @@ printf 'wrapper\n' >"$repo/dashboard/hermes-wrapper/fixture" printf 'bundle\n' >"$repo/dashboard/app-dist/fixture" printf '#!/usr/bin/env bash\n' >"$repo/scripts/run-session-temporal-benchmark.sh" +# The product crate carries its own `tests/fixtures`, which occupies the path +# the root asset of the same name is staged onto. +mkdir -p -- "$repo/crates/tracedecay/tests/fixtures/crate_local" +printf 'crate local\n' >"$repo/crates/tracedecay/tests/fixtures/crate_local/fixture" + git -C "$repo" init -q git -C "$repo" config user.name "TraceDecay test" git -C "$repo" config user.email "test@tracedecay.local" @@ -157,4 +162,14 @@ grep -Fxq "original readme" "$staged/crates/tracedecay/README.md" || { } grep -Fxq "mutated live readme" "$repo/README.md" +product_fixtures="$staged/crates/tracedecay/tests/fixtures" +[[ ! -e "$product_fixtures/crate_local" ]] || { + echo "crate-local content survived beside the staged root asset" >&2 + exit 1 +} +[[ -f "$product_fixtures/packaged_host_events/claude.json" ]] || { + echo "staged root asset is missing from the product package" >&2 + exit 1 +} + printf 'distribution staged-snapshot regression passed\n' diff --git a/scripts/test-resolve-release-source-profile.py b/scripts/test-resolve-release-source-profile.py index c97daad52b..45736b8c6c 100755 --- a/scripts/test-resolve-release-source-profile.py +++ b/scripts/test-resolve-release-source-profile.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass -import importlib.util from pathlib import Path import subprocess import sys @@ -14,15 +13,6 @@ RESOLVER = Path(__file__).with_name("resolve-release-source-profile.py") -def load_resolver(): - spec = importlib.util.spec_from_file_location("release_profile_resolver", RESOLVER) - if spec is None or spec.loader is None: - raise SystemExit(f"could not load {RESOLVER}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - @dataclass(frozen=True) class FixtureResult: returncode: int @@ -37,8 +27,22 @@ def run_fixture(manifest: str) -> FixtureResult: # The resolver reads the product package manifest, not the workspace # root: `crates/tracedecay/Cargo.toml` is where the feature table lives. product = source.joinpath("crates", "tracedecay") - product.mkdir(parents=True) + product.joinpath("src").mkdir(parents=True) product.joinpath("Cargo.toml").write_text(manifest, encoding="utf-8") + product.joinpath("src", "lib.rs").write_text("", encoding="utf-8") + # The resolver walks the resolved dependency graph with `cargo tree + # --locked`, so the fixture is a real workspace with a lockfile. It + # has no dependencies, so the lockfile resolves offline. + source.joinpath("Cargo.toml").write_text( + '[workspace]\nmembers = ["crates/tracedecay"]\nresolver = "2"\n', + encoding="utf-8", + ) + subprocess.run( + ["cargo", "generate-lockfile", "--offline"], + cwd=source, + check=True, + capture_output=True, + ) output = source / "github-output.txt" completed = subprocess.run( [ @@ -62,55 +66,48 @@ def run_fixture(manifest: str) -> FixtureResult: def main() -> int: - resolver = load_resolver() - modern_features = { - "production": [], - "hotpath": [], - "hotpath-alloc": [], - "hotpath-cpu": [], - "hotpath-mcp": [], - } - linux_features = resolver.production_release_features( - modern_features, "x86_64-unknown-linux-gnu" - ) - if linux_features != ("production",): - raise SystemExit(f"unexpected Linux release features: {linux_features!r}") - - macos_features = resolver.production_release_features( - modern_features, "aarch64-apple-darwin" - ) - if macos_features != ("production",): - raise SystemExit(f"unexpected macOS release features: {macos_features!r}") - - windows_features = resolver.production_release_features( - modern_features, "x86_64-pc-windows-msvc" - ) - if windows_features != ("production",): - raise SystemExit(f"unexpected Windows release features: {windows_features!r}") + production = run_fixture( + """[package] +name = "tracedecay" +version = "0.1.0" +edition = "2024" - historical_production = resolver.production_release_features( - {"production": []}, None +[features] +default = ["production"] +production = ["token-counting", "lite", "full"] +token-counting = [] +lite = [] +full = [] +""" ) - if historical_production != ("production",): + if production.returncode != 0: + raise SystemExit(production.stderr) + if production.github_output != ( + "profile=production\n" + "cargo_args=--no-default-features --features production\n" + "cargo_features=production\n" + ): raise SystemExit( - f"unexpected historical production features: {historical_production!r}" + f"unexpected production profile output: {production.github_output!r}" ) - historical_macos = resolver.production_release_features( - {"production": []}, "aarch64-apple-darwin" - ) - if historical_macos != ("production",): - raise SystemExit( - f"unexpected historical macOS features: {historical_macos!r}" - ) + contaminated_production = run_fixture( + """[package] +name = "tracedecay" +version = "0.1.0" +edition = "2024" - partial_hotpath = resolver.production_release_features( - {"production": [], "hotpath": []}, "x86_64-unknown-linux-gnu" +[features] +default = ["production"] +production = ["token-counting", "lite", "full", "test-transport"] +token-counting = [] +lite = [] +full = [] +test-transport = [] +""" ) - if partial_hotpath != ("production",): - raise SystemExit( - f"unexpected partial-Hotpath production features: {partial_hotpath!r}" - ) + if contaminated_production.returncode == 0: + raise SystemExit("production test-transport contamination was accepted") legacy = run_fixture( """[package] diff --git a/tests/fixtures/search_quality/incremental/time-after.rs b/tests/fixtures/search_quality/incremental/time-after.rs deleted file mode 100644 index 715d5aafd1..0000000000 --- a/tests/fixtures/search_quality/incremental/time-after.rs +++ /dev/null @@ -1,25 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::error::DomainError; - -/// UTC timestamp represented as microseconds from the Unix epoch. -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[serde(transparent)] -pub struct UtcMicros(pub i64); - -/// Closed half-open occurrence interval. -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[serde(deny_unknown_fields)] -pub struct TimeInterval { - pub start: UtcMicros, - pub end: UtcMicros, -} - -impl TimeInterval { - pub fn validate_bounds(&self) -> Result<(), DomainError> { - if self.start > self.end { - return Err(DomainError::InvalidTimeInterval); - } - Ok(()) - } -} diff --git a/tests/fixtures/shipped-shape-admissions.md b/tests/fixtures/shipped-shape-admissions.md deleted file mode 100644 index e1c796c728..0000000000 --- a/tests/fixtures/shipped-shape-admissions.md +++ /dev/null @@ -1,61 +0,0 @@ -# Shipped persisted shapes and how this binary admits them - -Index of every admission in the workspace that pins a persisted shape — an -exact object inventory, a digest, a trigger body, a version marker, a file -format revision, `PRAGMA user_version`, or the contents of a migrations table — -together with the shape each released binary actually wrote and whether this -binary admits it. - -The released shapes are reconstructed from the tagged sources, never from the -current contract. A fixture derived from the current contract agrees with -whatever the admission expects, including an expectation no release ever wrote, -which is how five of these regressions reached a real profile one install at a -time. - -## Release window - -`v0.1.0-beta.25` through `v0.1.0-beta.37` (the newest tag; `beta.28` was never -tagged). Every marker constant in `crates/**` outside tests was compared -between `v0.1.0-beta.37` and this tree: of 46 markers present in both, five -differ, and only three of those describe persisted state -(`migrations::SCHEMA_VERSION` 34 → 35, `FINAL_CONFIGURATION_SCHEMA_DIGEST`, -`GIT_CORRELATION_SCHEMA_VERSION` 4 → 5). The other two are an in-memory cache -and a pagination cursor. - -## Admissions - -| admission site | store | shipped shape (beta.25 … beta.37) | verdict before | verdict now | test | -| --- | --- | --- | --- | --- | --- | -| `runtime_core::db::migrations::final_shape::{require_exact_final_shape, require_final_shape_except_payload_digests}` | project `tracedecay.db` | one byte-identical inventory: `user_version` 34, 183 objects, digest `0126b4dd550109a6` | **refused**: `database schema has incompatible table 'diagnostic_generation_publications'` | admitted, converged by `released_shape::converge_released_project_schema`, every row retained | `db::migrations::tests::final_shape::released_project_store_migrates_and_retains_every_row` | -| `runtime_core::db::migrations::unsupported_schema_version` | project `tracedecay.db` | — | refused for any stamp but 34 or 35 | unchanged: no release wrote another stamp | `db::migrations::tests::final_shape::stamped_final_store_with_missing_or_tampered_required_shape_is_reset_required` | -| `global_db::session_temporal_schema::admission::classify_registered_schema_admission` | `global.db`, profile and project `sessions.db` | schema marker 3, one 81-trigger authority inventory | **refused**: `released v3 authority trigger contracts are absent or incompatible` | admitted as `ReleasedV3` (fixed in `29def5d8bb`) | `tests::lcm_schema::temporal_catalog::admission::published_v3_authority_triggers_migrate_and_retain_every_session` | -| `global_db::schema_stages` configuration digest | profile configuration | `FINAL_CONFIGURATION_SCHEMA_DIGEST` `sha256:99b8f5f5…` | **refused**: unreleased `2b3eab89e2` re-pinned the digest after `f01d6da607` dropped `configuration_credential_references` | admitted through `RELEASED_CONFIGURATION_SCHEMA_DIGEST` | `configuration::schema` suite | -| `runtime_core::db::migrations::install_runtime_writer_ledger` / `global_db::schema_stages::converge_runtime_writer_ledger` | project `tracedecay.db`, profile `user-sessions.db`, project `sessions.db` | `td_runtime_writer_{checkpoint_v1, idempotency_v1, inbox_v1, outbox_v1}` | **refused**: canonical shape carries `idempotency_v2` | admitted, `v1` folded into `v2` in bounded pages (`b703616d7d`, `7f7cd14dd0`, `63ab366df5`) | `db::migrations::tests::final_shape::runtime_writer_ledger_is_part_of_the_final_shape` | -| `global_db::schema_stages` workflow contracts | `global.db` | `WORKFLOW_SCHEMA_VERSION_V1` 1, byte-identical `WORKFLOW_TABLE_CONTRACTS_V1` | admitted | unchanged | `schema_stages` workflow suite | -| `lcm::schema::require_admissible_lcm_schema` | profile `user-sessions.db` | `session_schema_migrations.lcm` 8 | admitted | unchanged | `lcm::schema` suite | -| `sessions::runtime::git_correlation::ensure_git_correlation_receipt_schema_in_transaction` | `sessions.db` | `session_schema_migrations.git_correlation` 4 | admitted: the install is `CREATE … IF NOT EXISTS` plus a marker upsert, so 4 converges to 5 additively | unchanged | `git_correlation` schema suite | -| `sessions::runtime::workflow_index::ensure_workflow_index_schema` | `sessions.db` | `session_schema_migrations.workflow_indexing` 1 | admitted | unchanged | `workflow_index` suite | -| `global_db::observation::schema::require_admitted_observation_shape` | `global.db` observation authority | canonical columns plus `global_schema_migrations` `observations-v2-canonical-autoincrement` | admitted: the native-source-scheme marker is enrolled on open for every authority that cannot double-count. A populated authority that does carry Cline-like native sources is refused on purpose — re-offering `:ui_messages` would admit its events twice — and the remedy resets only the derived observation authority | unchanged | `observation::schema` suite | -| `global_db::registered_legacy_relations::require_admissible_legacy_relations` | registered session shard | none: the listed tables were already retired at `beta.25` | admitted | unchanged | `registered_legacy_relations` suite | -| `global_db::project_registry` | profile registry | — | admitted: both refusals read row content (a non-canonical `projects.path` key, two ids claiming one root), not a shape | unchanged | `project_registry` suite | -| `code_index::production::sealed_codec::sealed_generation_format_revision_is_compatible` | `code-index-v1` sealed generations, segments, read bundles | format revision 6 at every tag | admitted: `MINIMUM_SEALED_GENERATION_FORMAT_REVISION` is 6 and the compatible set is {6, 7} | unchanged | `sealed_codec` suite | -| `host_admission::spool::frames` | private-fs framed spool | `FRAME_MAGIC` `TDHA`, `FORMAT_VERSION` 1 | admitted: both unchanged | unchanged | `spool::frames` suite | -| `hooks::spool`, `hooks::admission_ledger` | hook spool and admission ledger | `TDH2`/`TDHC`/`TDL1`, spool format 1, checkpoint format 2, ledger format 1 | admitted: all unchanged | unchanged | `hooks::spool` suite | -| `maintenance::profile_backup` | profile backup archive | identity schema version 2 | admitted: unchanged | unchanged | `profile_backup` suite | -| `automation_runtime::automatic_facts` | automatic fact proposals | proposal schema markers unchanged | admitted | unchanged | `automatic_facts` suite | -| `dashboard_api::graph_structure_api::graph_reset_required` | published graph generations | — | admitted: the refusals validate a row's lineage and binding at read time rather than pinning a shape | unchanged | `graph_structure_api` suite | - -## Fixtures - -| fixture | shape | -| --- | --- | -| `crates/tracedecay-runtime-core/tests/fixtures/project-store-released-v34.sql` | the whole released project store, assembled from the tagged DDL constants | -| `crates/tracedecay-global-db/tests/fixtures/session-temporal-released-v3-triggers.sql` | the released session-temporal authority triggers, extracted verbatim from the tag | -| `crates/tracedecay-global-db/tests/fixtures/session-relation-receipts-before-recovery.sql` | the session-relation receipt shape that predates receipt recovery | - -## Not a persisted shape - -`temporal_query::cursor::CURSOR_FORMAT_VERSION` moved from `"2"` to `"3"`. A -pagination cursor is an opaque continuation handed back to a caller within one -session, not persisted state, so an older cursor is refused rather than -migrated and the caller re-runs its query. diff --git a/tests/fixtures/storage_runtime/direct_open_allowlist.json b/tests/fixtures/storage_runtime/direct_open_allowlist.json deleted file mode 100644 index c59864fad8..0000000000 --- a/tests/fixtures/storage_runtime/direct_open_allowlist.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "scan_roots": [ - "src", - "crates/tracedecay-rusqlite-runtime/src" - ], - "direct_open_suffixes": [ - "Builder::new_local", - "Database::initialize", - "Database::open", - "Database::open_read_only", - "Connection::open", - "Connection::open_in_memory", - "Connection::open_with_flags" - ], - "allowed": [ - { - "path": "crates/tracedecay-rusqlite-runtime/src/backup/driver.rs", - "callee": "Connection::open", - "disposition": "private restore staging destination" - }, - { - "path": "crates/tracedecay-rusqlite-runtime/src/connection/mod.rs", - "callee": "Connection::open_with_flags", - "disposition": "permanent daemon-owned shard opener" - }, - { - "path": "crates/tracedecay-rusqlite-runtime/src/effects/sqlite.rs", - "callee": "Connection::open_with_flags", - "disposition": "bounded read-only effect receipt probe over the writer-owned shard" - }, - { - "path": "crates/tracedecay-rusqlite-runtime/src/evidence.rs", - "callee": "Connection::open", - "disposition": "standalone S11 evidence over private fixture copies; never live authority" - }, - { - "path": "crates/tracedecay-rusqlite-runtime/src/evidence.rs", - "callee": "Connection::open_with_flags", - "disposition": "read-only logical evidence over private fixture copies" - }, - { - "path": "crates/tracedecay-rusqlite-runtime/src/graph/fixtures.rs", - "callee": "Connection::open", - "disposition": "test-only graph parity fixture" - }, - { - "path": "crates/tracedecay-rusqlite-runtime/src/writer/backup.rs", - "scope": "StagedBackupDestination::create_new_private_destination", - "callee": "Connection::open_with_flags", - "disposition": "writer-owned private backup staging destination pinned until atomic publication or cleanup" - }, - { - "path": "crates/tracedecay-rusqlite-runtime/src/writer/backup.rs", - "scope": "verify_sqlite", - "callee": "Connection::open_with_flags", - "disposition": "bounded read-only quick-check over the pinned private backup staging destination before publication" - }, - { - "path": "src/sqlite_read_snapshot.rs", - "scope": "SnapshotConnection::open", - "callee": "Connection::open_with_flags", - "disposition": "immutable or private-copy snapshot source opened read-only outside the live store authority" - }, - { - "path": "src/sqlite_read_snapshot.rs", - "scope": "SnapshotDatabase::backup_to", - "callee": "Connection::open_with_flags", - "disposition": "new private standalone backup destination populated from an already-frozen snapshot" - }, - { - "path": "src/sqlite_read_snapshot.rs", - "scope": "materialize_standalone_snapshot", - "callee": "Connection::open_with_flags", - "disposition": "bounded source and private destination opens used only to collapse a frozen copied WAL family into a standalone snapshot" - }, - { - "path": "src/retention/branch_compaction.rs", - "callee": "Connection::open_with_flags", - "disposition": "bounded daemon maintenance for an unmounted non-active branch database; busy files are skipped rather than competing with a writer" - }, - { - "path": "src/retention/storage_report.rs", - "callee": "Connection::open_with_flags", - "disposition": "short read-only free-page sample that degrades on busy or unavailable WAL state and never obtains writer authority" - } - ] -} diff --git a/tests/fixtures/storage_runtime/rollback_rehearsal.json b/tests/fixtures/storage_runtime/rollback_rehearsal.json deleted file mode 100644 index c4d3c06ee3..0000000000 --- a/tests/fixtures/storage_runtime/rollback_rehearsal.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "orchestrator": "crates/tracedecay-rusqlite-runtime/src/backup/orchestrator.rs", - "driver": "crates/tracedecay-rusqlite-runtime/src/backup/driver.rs", - "validation": "crates/tracedecay-rusqlite-runtime/src/backup/validation.rs", - "maintenance": "crates/tracedecay-rusqlite-runtime/src/maintenance/coordinator.rs", - "registry": "src/daemon/store_runtime/registry.rs", - "forbidden_writable_fallbacks": [ - "Builder::new_local", - "Connection::open", - "Database::initialize", - "Database::open", - "GlobalDb::open_at" - ] -} diff --git a/tests/fixtures/storage_runtime_evidence/README.md b/tests/fixtures/storage_runtime_evidence/README.md deleted file mode 100644 index 04ddce9ce0..0000000000 --- a/tests/fixtures/storage_runtime_evidence/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# S11 SQLite runtime evidence - -These fixtures are real SQLite databases generated by `generate.py` with -Python's SQLite API. They are not serialized success responses and contain no -live profile paths. - -## Evidence - -- `wal-pressure.sqlite3` plus `wal-pressure.sqlite3-wal` preserves committed - WAL frames. `wal-pressure.blocker.json` supplies the stable snapshot lease - used by the runtime pressure/blocker test. -- `crash-after-repair-commit.sqlite3` is produced by a child process that - commits the FTS repair receipt transaction and exits with `os._exit(73)` - before acknowledging success. Runtime replay must recover the durable - receipt. -- `fts-stale.sqlite3` has healthy authoritative rows and an intentionally stale - external-content FTS5 projection. Runtime repair must rebuild FTS and commit - its receipt atomically. -- `authoritative-corrupt.sqlite3` has an invalid cell pointer in a populated - authoritative B-tree leaf. SQLite `quick_check` reports corruption while the - source remains readable enough to preserve as quarantine evidence. -- `online-backup-source.sqlite3` and `online-backup-copy.sqlite3` are connected - through SQLite's online backup API. The runtime test performs its own online - backup, isolated staged restore, durable publication, and higher-binding - acknowledgement from the checked source. -- `online-backup-digest-mismatch.sqlite3` is a valid SQLite backup copy changed - through SQLite after backup. It remains structurally healthy but must fail - the clean artifact's digest. - -`storage-runtime-fixture-v1.json` is the sole path-free fixture contract -consumed by the generator, validator, benchmark adapter, and Rust evidence -command. `artifacts.json` records every generated SQLite/WAL/support artifact, -its byte length and SHA-256 digest, the stable logical binding, generator -versions, and expected values. - -## Regeneration and static validation - -From the repository root: - -```sh -python3 tests/fixtures/storage_runtime_evidence/generate.py -python3 tests/fixtures/storage_runtime_evidence/validate.py -``` - -Logical content and corruption locations are deterministic. SQLite WAL salts -are normalized and their checksums recomputed after SQLite creates the real -WAL, making repeated generation byte-for-byte reproducible on the recorded -Python/SQLite versions. Validation regenerates into an isolated directory and -compares every artifact and manifest byte-for-byte. diff --git a/tests/fixtures/storage_runtime_evidence/artifacts.json b/tests/fixtures/storage_runtime_evidence/artifacts.json deleted file mode 100644 index 574d789fb3..0000000000 --- a/tests/fixtures/storage_runtime_evidence/artifacts.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "artifacts": { - "authoritative-corrupt.sqlite3": { - "bytes": 53248, - "sha256": "950a0ec2f085610929d969b66552835e38595ac23bac5fc36cbb65dd4f159d41" - }, - "fts-stale.sqlite3": { - "bytes": 6144, - "sha256": "1203c0e30a31c8465096ca8ea019aa85a1e98f5dc7c33d6a1bc513db3f3c129a" - }, - "online-backup-copy.sqlite3": { - "bytes": 5120, - "sha256": "98157a9aa59243ef7cf5b1b9c8883fccaafee5261b57e3d9e24f85a77e03de3f" - }, - "online-backup-digest-mismatch.sqlite3": { - "bytes": 5120, - "sha256": "0c9e3bf6e2ed6ac69bb4ddd9c86b751cc9b3c8301662642bf9b09ee78909b3e2" - }, - "online-backup-source.sqlite3": { - "bytes": 5120, - "sha256": "18331f506d032fdff86602b3e194251a32a7b36b420624e11b5ca6c8bfab9167" - }, - "wal-pressure.blocker.json": { - "bytes": 305, - "sha256": "99dc029a0a9a13c3f90b7ad13248d3d1cf6e2c0f81f9fd4f2c23f38034e67c12" - }, - "wal-pressure.sqlite3": { - "bytes": 2048, - "sha256": "a2a875b58f67149a887e7a3c74a84aa1ab765c80cf0728d5a62d4210368c4c5d" - }, - "wal-pressure.sqlite3-wal": { - "bytes": 137320, - "sha256": "40dce0b4bf13f35590087e2b12f24cca6a8e549b6be04278ab807811ac8a6a86" - } - }, - "binding": { - "authority_epoch": 19, - "incarnation": 7, - "shard_id": { - "brain_id": "brain.s11.evidence", - "profile_id": "profile.s11.evidence", - "scope": { - "kind": "project", - "project_id": "project.s11.evidence" - } - } - }, - "expectations": { - "authoritative_rows": 200, - "backup_rows": 64, - "crash_evidence_id": "evidence.s11.crash", - "crash_receipt_id": "receipt.s11.crash", - "fts_search_term": "needle", - "replacement_authority_epoch": 20, - "replacement_incarnation": 8, - "wal_pressure_rows": 129 - }, - "generator": { - "byte_reproducibility": "Byte-for-byte on the recorded Python/SQLite versions; generated WAL salts and checksums are normalized.", - "logical_state_deterministic": true, - "python": "3.12.3", - "script": "generate.py", - "sqlite": "3.45.1" - }, - "schema": "tracedecay.storage-runtime-evidence.s11.v1" -} diff --git a/tests/fixtures/storage_runtime_evidence/authoritative-corrupt.sqlite3 b/tests/fixtures/storage_runtime_evidence/authoritative-corrupt.sqlite3 deleted file mode 100644 index 22a17915c6..0000000000 Binary files a/tests/fixtures/storage_runtime_evidence/authoritative-corrupt.sqlite3 and /dev/null differ diff --git a/tests/fixtures/storage_runtime_evidence/crash-after-repair-commit.sqlite3 b/tests/fixtures/storage_runtime_evidence/crash-after-repair-commit.sqlite3 deleted file mode 100644 index 6a29ad818a..0000000000 Binary files a/tests/fixtures/storage_runtime_evidence/crash-after-repair-commit.sqlite3 and /dev/null differ diff --git a/tests/fixtures/storage_runtime_evidence/fts-stale.sqlite3 b/tests/fixtures/storage_runtime_evidence/fts-stale.sqlite3 deleted file mode 100644 index 32a76ba7d6..0000000000 Binary files a/tests/fixtures/storage_runtime_evidence/fts-stale.sqlite3 and /dev/null differ diff --git a/tests/fixtures/storage_runtime_evidence/generate.py b/tests/fixtures/storage_runtime_evidence/generate.py deleted file mode 100644 index 73dffa984e..0000000000 --- a/tests/fixtures/storage_runtime_evidence/generate.py +++ /dev/null @@ -1,420 +0,0 @@ -#!/usr/bin/env python3 -"""Generate S11 crash, corruption, and backup/restore SQLite evidence.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import shutil -import sqlite3 -import subprocess -import sys -import tempfile -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent -PAGE_SIZE = 1024 -ARTIFACT_MANIFEST = "artifacts.json" -RUNTIME_MANIFEST = "storage-runtime-fixture-v1.json" -FIXTURE_FILES = ( - "wal-pressure.sqlite3", - "wal-pressure.sqlite3-wal", - "wal-pressure.blocker.json", - "crash-after-repair-commit.sqlite3", - "fts-stale.sqlite3", - "authoritative-corrupt.sqlite3", - "online-backup-source.sqlite3", - "online-backup-copy.sqlite3", - "online-backup-digest-mismatch.sqlite3", -) -BINDING = { - "shard_id": { - "brain_id": "brain.s11.evidence", - "profile_id": "profile.s11.evidence", - "scope": { - "kind": "project", - "project_id": "project.s11.evidence", - }, - }, - "incarnation": 7, - "authority_epoch": 19, -} - - -def canonical_json(value: object) -> str: - return json.dumps(value, separators=(",", ":"), sort_keys=True) - - -def configure(connection: sqlite3.Connection) -> None: - connection.execute(f"PRAGMA page_size={PAGE_SIZE}") - connection.execute("PRAGMA foreign_keys=ON") - connection.execute("PRAGMA application_id=0x54445331") - connection.execute("PRAGMA user_version=11") - - -def quick_check(connection: sqlite3.Connection) -> list[str]: - return [str(row[0]) for row in connection.execute("PRAGMA quick_check")] - - -def wal_checksum( - content: bytes, - byte_order: str, - state: tuple[int, int] = (0, 0), -) -> tuple[int, int]: - first, second = state - if len(content) % 8 != 0: - raise ValueError("WAL checksum input must contain complete word pairs") - for offset in range(0, len(content), 8): - left = int.from_bytes(content[offset : offset + 4], byte_order) - right = int.from_bytes(content[offset + 4 : offset + 8], byte_order) - first = (first + left + second) & 0xFFFFFFFF - second = (second + right + first) & 0xFFFFFFFF - return first, second - - -def canonicalize_wal(path: Path) -> None: - content = bytearray(path.read_bytes()) - if len(content) < 32: - raise RuntimeError("generated WAL is missing its header") - magic = int.from_bytes(content[0:4], "big") - if magic not in (0x377F0682, 0x377F0683): - raise RuntimeError("generated WAL has an unsupported magic") - page_size = int.from_bytes(content[8:12], "big") - if page_size == 1: - page_size = 65_536 - frame_size = 24 + page_size - if page_size <= 0 or (len(content) - 32) % frame_size != 0: - raise RuntimeError("generated WAL has incomplete frames") - - first_salt = 0x53313145 - second_salt = 0x56494431 - content[16:20] = first_salt.to_bytes(4, "big") - content[20:24] = second_salt.to_bytes(4, "big") - byte_order = "big" if magic & 1 else "little" - checksum = wal_checksum(bytes(content[:24]), byte_order) - content[24:28] = checksum[0].to_bytes(4, "big") - content[28:32] = checksum[1].to_bytes(4, "big") - - for offset in range(32, len(content), frame_size): - content[offset + 8 : offset + 12] = first_salt.to_bytes(4, "big") - content[offset + 12 : offset + 16] = second_salt.to_bytes(4, "big") - checksum_input = ( - bytes(content[offset : offset + 8]) - + bytes(content[offset + 24 : offset + frame_size]) - ) - checksum = wal_checksum(checksum_input, byte_order, checksum) - content[offset + 16 : offset + 20] = checksum[0].to_bytes(4, "big") - content[offset + 20 : offset + 24] = checksum[1].to_bytes(4, "big") - path.write_bytes(content) - - -def create_wal_pressure() -> None: - with tempfile.TemporaryDirectory(prefix="tracedecay-s11-wal-") as temporary: - database = Path(temporary) / "wal-pressure.sqlite3" - writer = sqlite3.connect(database) - configure(writer) - assert writer.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal" - writer.execute("PRAGMA wal_autocheckpoint=0") - writer.executescript( - """ - CREATE TABLE events ( - sequence INTEGER PRIMARY KEY, - payload BLOB NOT NULL - ) STRICT; - INSERT INTO events VALUES (1, zeroblob(700)); - """ - ) - writer.commit() - writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() - - blocker = sqlite3.connect(database) - blocker.execute("BEGIN") - assert blocker.execute("SELECT count(*) FROM events").fetchone()[0] == 1 - writer.executemany( - "INSERT INTO events VALUES (?, zeroblob(700))", - ((sequence,) for sequence in range(2, 130)), - ) - writer.commit() - - wal = Path(f"{database}-wal") - assert wal.stat().st_size > 32 - shutil.copyfile(database, ROOT / database.name) - shutil.copyfile(wal, ROOT / wal.name) - blocker.close() - writer.close() - canonicalize_wal(ROOT / "wal-pressure.sqlite3-wal") - - (ROOT / "wal-pressure.blocker.json").write_text( - json.dumps( - { - "schema": "tracedecay.storage-runtime-evidence.wal-blocker.v1", - "lease_id": "snapshot.s11.wal-pressure", - "snapshot_row_count": 1, - "committed_row_count": 129, - "expected": { - "wal_frames_minimum": 1, - "checkpointed_less_than_log_while_blocked": True, - "hard_drain_required": True, - }, - }, - indent=2, - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - - -def create_documents_schema(connection: sqlite3.Connection) -> None: - configure(connection) - connection.executescript( - """ - CREATE TABLE documents ( - id INTEGER PRIMARY KEY, - body TEXT NOT NULL - ) STRICT; - CREATE VIRTUAL TABLE documents_fts USING fts5( - body, - content='documents', - content_rowid='id' - ); - """ - ) - - -def create_fts_stale() -> None: - path = ROOT / "fts-stale.sqlite3" - connection = sqlite3.connect(path) - create_documents_schema(connection) - connection.execute("INSERT INTO documents VALUES (1, 'stable baseline')") - connection.execute("INSERT INTO documents_fts(documents_fts) VALUES ('rebuild')") - connection.commit() - connection.execute("INSERT INTO documents VALUES (2, 's11 repair needle')") - connection.commit() - assert quick_check(connection) == ["ok"] - assert ( - connection.execute( - "SELECT count(*) FROM documents_fts WHERE documents_fts MATCH 'needle'" - ).fetchone()[0] - == 0 - ) - connection.close() - - -def crash_child(path: Path) -> None: - connection = sqlite3.connect(path) - create_documents_schema(connection) - connection.execute("INSERT INTO documents VALUES (1, 'committed repair needle')") - connection.execute( - """ - CREATE TABLE tracedecay_repair_receipts ( - receipt_id TEXT PRIMARY KEY NOT NULL, - evidence_id TEXT NOT NULL, - binding TEXT NOT NULL - ) STRICT - """ - ) - connection.commit() - connection.execute("BEGIN IMMEDIATE") - connection.execute("INSERT INTO documents_fts(documents_fts) VALUES ('rebuild')") - connection.execute( - """ - INSERT INTO tracedecay_repair_receipts - (receipt_id, evidence_id, binding) - VALUES (?, ?, ?) - """, - ( - canonical_json("receipt.s11.crash"), - canonical_json("evidence.s11.crash"), - canonical_json(BINDING), - ), - ) - connection.commit() - os._exit(73) - - -def create_crash_after_commit() -> None: - path = ROOT / "crash-after-repair-commit.sqlite3" - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve()), "--crash-child", str(path)], - check=False, - ) - if result.returncode != 73: - raise RuntimeError(f"crash child exited {result.returncode}, expected 73") - connection = sqlite3.connect(path) - assert quick_check(connection) == ["ok"] - assert ( - connection.execute("SELECT count(*) FROM tracedecay_repair_receipts").fetchone()[0] - == 1 - ) - connection.close() - - -def create_authoritative_corruption() -> None: - path = ROOT / "authoritative-corrupt.sqlite3" - connection = sqlite3.connect(path) - configure(connection) - connection.execute( - "CREATE TABLE facts (id INTEGER PRIMARY KEY, body TEXT NOT NULL) STRICT" - ) - connection.executemany( - "INSERT INTO facts(body) VALUES (?)", - ((f"authoritative-{number:03d}-" * 12,) for number in range(200)), - ) - connection.commit() - leaf_page = connection.execute( - """ - SELECT pageno - FROM dbstat - WHERE name = 'facts' AND pagetype = 'leaf' - ORDER BY pageno DESC - LIMIT 1 - """ - ).fetchone()[0] - connection.close() - - content = bytearray(path.read_bytes()) - page_offset = (int(leaf_page) - 1) * PAGE_SIZE - cell_count = int.from_bytes(content[page_offset + 3 : page_offset + 5], "big") - if cell_count == 0: - raise RuntimeError("selected authoritative leaf page has no cells") - content[page_offset + 8 : page_offset + 10] = (1).to_bytes(2, "big") - path.write_bytes(content) - - connection = sqlite3.connect(path) - messages = quick_check(connection) - if messages == ["ok"]: - raise RuntimeError("authoritative corruption was not detected") - assert connection.execute("SELECT count(*) FROM facts").fetchone()[0] == 200 - connection.close() - - -def create_online_backup() -> None: - source_path = ROOT / "online-backup-source.sqlite3" - source = sqlite3.connect(source_path) - configure(source) - source.execute( - "CREATE TABLE facts (id INTEGER PRIMARY KEY, body TEXT NOT NULL) STRICT" - ) - source.execute("CREATE TABLE evidence_rows (value TEXT NOT NULL) STRICT") - source.execute("INSERT INTO evidence_rows VALUES ('s11-runtime-baseline')") - source.executemany( - "INSERT INTO facts VALUES (?, ?)", - ( - (number, f"backup-row-{number:03d}") - for number in range(1, 65) - ), - ) - source.commit() - - copy = sqlite3.connect(ROOT / "online-backup-copy.sqlite3") - source.backup(copy, pages=1) - copy.close() - - mismatch = sqlite3.connect(ROOT / "online-backup-digest-mismatch.sqlite3") - source.backup(mismatch, pages=1) - mismatch.execute("INSERT INTO facts VALUES (999, 'digest mismatch')") - mismatch.commit() - mismatch.close() - source.close() - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(64 * 1024), b""): - digest.update(block) - return digest.hexdigest() - - -def write_manifest() -> None: - artifacts = { - name: { - "bytes": (ROOT / name).stat().st_size, - "sha256": sha256(ROOT / name), - } - for name in FIXTURE_FILES - } - artifact_manifest = { - "schema": "tracedecay.storage-runtime-evidence.s11.v1", - "generator": { - "script": "generate.py", - "python": sys.version.split()[0], - "sqlite": sqlite3.sqlite_version, - "logical_state_deterministic": True, - "byte_reproducibility": ( - "Byte-for-byte on the recorded Python/SQLite versions; generated WAL " - "salts and checksums are normalized." - ), - }, - "binding": BINDING, - "artifacts": artifacts, - "expectations": { - "wal_pressure_rows": 129, - "crash_receipt_id": "receipt.s11.crash", - "crash_evidence_id": "evidence.s11.crash", - "fts_search_term": "needle", - "authoritative_rows": 200, - "backup_rows": 64, - "replacement_incarnation": 8, - "replacement_authority_epoch": 20, - }, - } - (ROOT / ARTIFACT_MANIFEST).write_text( - json.dumps(artifact_manifest, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - runtime_manifest = { - "schema_version": 1, - "project_root": ".", - "profile_root": ".", - "fts_queries": { - "graph": "needle", - "session": "needle", - }, - "s11": { - "database": "online-backup-source.sqlite3", - "binding": BINDING, - "evidence_tables": ["evidence_rows", "facts"], - }, - } - (ROOT / RUNTIME_MANIFEST).write_text( - json.dumps(runtime_manifest, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - - -def generate() -> None: - ROOT.mkdir(parents=True, exist_ok=True) - for name in ( - *FIXTURE_FILES, - ARTIFACT_MANIFEST, - RUNTIME_MANIFEST, - ): - (ROOT / name).unlink(missing_ok=True) - create_wal_pressure() - create_crash_after_commit() - create_fts_stale() - create_authoritative_corruption() - create_online_backup() - write_manifest() - - -def main() -> None: - global ROOT - parser = argparse.ArgumentParser() - parser.add_argument("--crash-child", type=Path) - parser.add_argument("--output", type=Path, default=ROOT) - arguments = parser.parse_args() - if arguments.crash_child is not None: - crash_child(arguments.crash_child) - else: - ROOT = arguments.output.resolve() - generate() - - -if __name__ == "__main__": - main() diff --git a/tests/fixtures/storage_runtime_evidence/online-backup-copy.sqlite3 b/tests/fixtures/storage_runtime_evidence/online-backup-copy.sqlite3 deleted file mode 100644 index 325653e6b7..0000000000 Binary files a/tests/fixtures/storage_runtime_evidence/online-backup-copy.sqlite3 and /dev/null differ diff --git a/tests/fixtures/storage_runtime_evidence/online-backup-digest-mismatch.sqlite3 b/tests/fixtures/storage_runtime_evidence/online-backup-digest-mismatch.sqlite3 deleted file mode 100644 index 63ee01efb5..0000000000 Binary files a/tests/fixtures/storage_runtime_evidence/online-backup-digest-mismatch.sqlite3 and /dev/null differ diff --git a/tests/fixtures/storage_runtime_evidence/online-backup-source.sqlite3 b/tests/fixtures/storage_runtime_evidence/online-backup-source.sqlite3 deleted file mode 100644 index 79669b7509..0000000000 Binary files a/tests/fixtures/storage_runtime_evidence/online-backup-source.sqlite3 and /dev/null differ diff --git a/tests/fixtures/storage_runtime_evidence/storage-runtime-fixture-v1.json b/tests/fixtures/storage_runtime_evidence/storage-runtime-fixture-v1.json deleted file mode 100644 index 34b03f9e2a..0000000000 --- a/tests/fixtures/storage_runtime_evidence/storage-runtime-fixture-v1.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "fts_queries": { - "graph": "needle", - "session": "needle" - }, - "profile_root": ".", - "project_root": ".", - "s11": { - "binding": { - "authority_epoch": 19, - "incarnation": 7, - "shard_id": { - "brain_id": "brain.s11.evidence", - "profile_id": "profile.s11.evidence", - "scope": { - "kind": "project", - "project_id": "project.s11.evidence" - } - } - }, - "database": "online-backup-source.sqlite3", - "evidence_tables": [ - "evidence_rows", - "facts" - ] - }, - "schema_version": 1 -} diff --git a/tests/fixtures/storage_runtime_evidence/validate.py b/tests/fixtures/storage_runtime_evidence/validate.py deleted file mode 100644 index eb9fa95b5f..0000000000 --- a/tests/fixtures/storage_runtime_evidence/validate.py +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env python3 -"""Static validation for checked-in S11 SQLite runtime evidence.""" - -from __future__ import annotations - -import hashlib -import importlib.util -import json -import shutil -import sqlite3 -import subprocess -import sys -import tempfile -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent -ARTIFACT_MANIFEST = "artifacts.json" -RUNTIME_MANIFEST = "storage-runtime-fixture-v1.json" -EXPECTED_ARTIFACTS = { - "wal-pressure.sqlite3", - "wal-pressure.sqlite3-wal", - "wal-pressure.blocker.json", - "crash-after-repair-commit.sqlite3", - "fts-stale.sqlite3", - "authoritative-corrupt.sqlite3", - "online-backup-source.sqlite3", - "online-backup-copy.sqlite3", - "online-backup-digest-mismatch.sqlite3", -} - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(64 * 1024), b""): - digest.update(block) - return digest.hexdigest() - - -def quick_check(connection: sqlite3.Connection) -> list[str]: - return [str(row[0]) for row in connection.execute("PRAGMA quick_check")] - - -def validate_hashes(manifest: dict[str, object]) -> None: - artifacts = manifest["artifacts"] - assert isinstance(artifacts, dict) - assert set(artifacts) == EXPECTED_ARTIFACTS - for name, expected in artifacts.items(): - assert isinstance(name, str) - assert isinstance(expected, dict) - assert set(expected) == {"bytes", "sha256"} - assert isinstance(expected["bytes"], int) and expected["bytes"] > 0 - assert ( - isinstance(expected["sha256"], str) - and len(expected["sha256"]) == 64 - and all(character in "0123456789abcdef" for character in expected["sha256"]) - ) - path = ROOT / name - assert path.is_file(), f"missing fixture artifact: {name}" - assert path.stat().st_size == expected["bytes"], name - assert sha256(path) == expected["sha256"], name - content = path.read_bytes() - for live_root in (b"/home/", b"/fast/", b".tracedecay/"): - assert live_root not in content, f"live profile path leaked into {name}" - discovered = { - path.name - for path in ROOT.iterdir() - if path.is_file() - and ( - path.name.endswith(".sqlite3") - or path.name.endswith(".sqlite3-wal") - or path.name.endswith(".sqlite3-shm") - or path.name.endswith(".sqlite3-journal") - or path.name.endswith(".blocker.json") - ) - } - assert discovered == EXPECTED_ARTIFACTS - - -def validate_wal(manifest: dict[str, object]) -> None: - with tempfile.TemporaryDirectory(prefix="tracedecay-s11-wal-validate-") as temporary: - database = Path(temporary) / "wal-pressure.sqlite3" - shutil.copyfile(ROOT / database.name, database) - shutil.copyfile(ROOT / f"{database.name}-wal", Path(f"{database}-wal")) - connection = sqlite3.connect(database) - assert quick_check(connection) == ["ok"] - assert connection.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal" - assert ( - connection.execute("SELECT count(*) FROM events").fetchone()[0] - == manifest["expectations"]["wal_pressure_rows"] - ) - connection.close() - - blocker = json.loads((ROOT / "wal-pressure.blocker.json").read_text()) - assert blocker["snapshot_row_count"] == 1 - assert blocker["committed_row_count"] == manifest["expectations"]["wal_pressure_rows"] - assert blocker["expected"]["hard_drain_required"] is True - assert (ROOT / "wal-pressure.sqlite3-wal").stat().st_size > 32 - - -def validate_crash(manifest: dict[str, object]) -> None: - connection = sqlite3.connect(ROOT / "crash-after-repair-commit.sqlite3") - assert quick_check(connection) == ["ok"] - assert ( - connection.execute( - "SELECT count(*) FROM documents_fts WHERE documents_fts MATCH 'needle'" - ).fetchone()[0] - == 1 - ) - receipt = connection.execute( - "SELECT receipt_id, evidence_id, binding FROM tracedecay_repair_receipts" - ).fetchone() - assert json.loads(receipt[0]) == manifest["expectations"]["crash_receipt_id"] - assert json.loads(receipt[1]) == manifest["expectations"]["crash_evidence_id"] - assert json.loads(receipt[2]) == manifest["binding"] - connection.close() - - -def validate_fts_repair(manifest: dict[str, object]) -> None: - with tempfile.TemporaryDirectory(prefix="tracedecay-s11-fts-validate-") as temporary: - database = Path(temporary) / "fts-stale.sqlite3" - shutil.copyfile(ROOT / database.name, database) - connection = sqlite3.connect(database) - assert quick_check(connection) == ["ok"] - term = manifest["expectations"]["fts_search_term"] - assert ( - connection.execute( - "SELECT count(*) FROM documents WHERE body LIKE ?", - (f"%{term}%",), - ).fetchone()[0] - == 1 - ) - assert ( - connection.execute( - "SELECT count(*) FROM documents_fts WHERE documents_fts MATCH ?", - (term,), - ).fetchone()[0] - == 0 - ) - connection.execute("INSERT INTO documents_fts(documents_fts) VALUES ('rebuild')") - connection.commit() - assert ( - connection.execute( - "SELECT count(*) FROM documents_fts WHERE documents_fts MATCH ?", - (term,), - ).fetchone()[0] - == 1 - ) - assert quick_check(connection) == ["ok"] - connection.close() - - -def validate_authoritative_corruption(manifest: dict[str, object]) -> None: - connection = sqlite3.connect(ROOT / "authoritative-corrupt.sqlite3") - messages = quick_check(connection) - assert messages != ["ok"] - assert any("database main" in message or "facts" in message for message in messages) - assert ( - connection.execute("SELECT count(*) FROM facts").fetchone()[0] - == manifest["expectations"]["authoritative_rows"] - ) - connection.close() - - -def validate_online_backup(manifest: dict[str, object]) -> None: - rows = [] - contents = [] - for name in ( - "online-backup-source.sqlite3", - "online-backup-copy.sqlite3", - "online-backup-digest-mismatch.sqlite3", - ): - connection = sqlite3.connect(ROOT / name) - assert quick_check(connection) == ["ok"] - rows.append(connection.execute("SELECT count(*) FROM facts").fetchone()[0]) - assert connection.execute("SELECT count(*) FROM evidence_rows").fetchone()[0] == 1 - contents.append(connection.execute("SELECT id, body FROM facts ORDER BY id").fetchall()) - connection.close() - assert rows == [manifest["expectations"]["backup_rows"]] * 2 + [ - manifest["expectations"]["backup_rows"] + 1 - ] - assert contents[0] == contents[1] - assert sha256(ROOT / "online-backup-copy.sqlite3") != sha256( - ROOT / "online-backup-digest-mismatch.sqlite3" - ) - - -def validate_reproducible_generation() -> None: - with tempfile.TemporaryDirectory(prefix="tracedecay-s11-reproducible-") as temporary: - regenerated = Path(temporary) - subprocess.run( - [ - sys.executable, - str(ROOT / "generate.py"), - "--output", - str(regenerated), - ], - check=True, - ) - expected = EXPECTED_ARTIFACTS | {ARTIFACT_MANIFEST, RUNTIME_MANIFEST} - assert {path.name for path in regenerated.iterdir()} == expected - for name in sorted(expected): - assert (regenerated / name).read_bytes() == (ROOT / name).read_bytes(), name - - -def validate_consumers() -> None: - repository = ROOT.parents[2] - adapter_path = repository / "benchmark_data/runtime/storage_workloads.py" - specification = importlib.util.spec_from_file_location( - "storage_runtime_workload_kernel", - adapter_path, - ) - assert specification is not None and specification.loader is not None - adapter = importlib.util.module_from_spec(specification) - sys.modules[specification.name] = adapter - specification.loader.exec_module(adapter) - adapter.validate_workloads() - assert adapter.BENCHMARK_AUTHORITY == "measurement_fixture_not_product_contract" - assert "tracedecay-store" in adapter.DECLARED_CRATE_LANES - assert "tracedecay-rusqlite-runtime" in adapter.DECLARED_CRATE_LANES - - identities = adapter.runtime_test_identities( - platform="fixture", - shard="storage-evidence", - storage_mode="isolated-sqlite", - ) - assert any( - identity.crate_tag == "tracedecay-rusqlite-runtime" - for identity in identities - ) - - -def main() -> None: - manifest = json.loads((ROOT / ARTIFACT_MANIFEST).read_text(encoding="utf-8")) - assert manifest["schema"] == "tracedecay.storage-runtime-evidence.s11.v1" - runtime_manifest = json.loads((ROOT / RUNTIME_MANIFEST).read_text(encoding="utf-8")) - assert runtime_manifest == { - "schema_version": 1, - "project_root": ".", - "profile_root": ".", - "fts_queries": {"graph": "needle", "session": "needle"}, - "s11": { - "database": "online-backup-source.sqlite3", - "binding": manifest["binding"], - "evidence_tables": ["evidence_rows", "facts"], - }, - } - validate_hashes(manifest) - validate_wal(manifest) - validate_crash(manifest) - validate_fts_repair(manifest) - validate_authoritative_corruption(manifest) - validate_online_backup(manifest) - validate_reproducible_generation() - validate_consumers() - print( - "validated deterministic S11 SQLite evidence: canonical manifest, " - "WAL blocker, crash receipt, FTS repair, quarantine source, " - "online backup, and digest mismatch" - ) - - -if __name__ == "__main__": - main() diff --git a/tests/fixtures/storage_runtime_evidence/wal-pressure.blocker.json b/tests/fixtures/storage_runtime_evidence/wal-pressure.blocker.json deleted file mode 100644 index 6f32f3d5a5..0000000000 --- a/tests/fixtures/storage_runtime_evidence/wal-pressure.blocker.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "committed_row_count": 129, - "expected": { - "checkpointed_less_than_log_while_blocked": true, - "hard_drain_required": true, - "wal_frames_minimum": 1 - }, - "lease_id": "snapshot.s11.wal-pressure", - "schema": "tracedecay.storage-runtime-evidence.wal-blocker.v1", - "snapshot_row_count": 1 -} diff --git a/tests/fixtures/storage_runtime_evidence/wal-pressure.sqlite3 b/tests/fixtures/storage_runtime_evidence/wal-pressure.sqlite3 deleted file mode 100644 index 857dce3c68..0000000000 Binary files a/tests/fixtures/storage_runtime_evidence/wal-pressure.sqlite3 and /dev/null differ diff --git a/tests/fixtures/v2/research-anchor-manifest.json b/tests/fixtures/v2/research-anchor-manifest.json deleted file mode 100644 index c04073b08a..0000000000 --- a/tests/fixtures/v2/research-anchor-manifest.json +++ /dev/null @@ -1,1493 +0,0 @@ -{ - "envelope": { - "manifest": { - "agent_contributions": [ - { - "confidence": 0.94, - "contributor": { - "actor_id": "actor-synthetic-child-001", - "version": null - }, - "evidence_class": "derived_exact", - "manifest_entries": [ - "research-anchor-child-001" - ], - "outputs": [ - { - "id": "document-synthetic-child-output-001", - "kind": "document" - } - ], - "role": "researched", - "session_id": "session-synthetic-child-001" - }, - { - "confidence": 0.4, - "contributor": { - "actor_id": "actor-synthetic-unknown-001", - "version": null - }, - "evidence_class": "inferred", - "manifest_entries": [ - "research-anchor-copied-coordination-001" - ], - "outputs": [ - { - "id": "document-synthetic-coordination-001", - "kind": "document" - } - ], - "role": "researched", - "session_id": "session-synthetic-child-unknown-001" - } - ], - "anchors": [ - { - "confidence": 1.0, - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 42, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [], - "searched": [ - "shard-synthetic-a" - ], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [], - "unknown_coverage": false - }, - "entry_id": "research-anchor-message-001", - "evidence_class": "observed", - "expected_subject": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Synthetic exact message metadata." - }, - "occurred_window": { - "end": 1767323045000001, - "start": 1767323045000000 - }, - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Resolve the exact synthetic message by canonical metadata." - }, - "related_activity": null, - "retrieval_anchors": [ - "retrieval-anchor-message-001" - ], - "retrieval_recipe_id": "retrieval-recipe-message-001", - "snapshot": { - "components": { - "shard-synthetic-a": 42 - } - }, - "source_observation_ids": [ - "observation-message-001" - ], - "subject": { - "kind": "activity", - "subject": { - "agent_instance_id": "agent-synthetic-parent-001", - "goal_id": "goal-synthetic-parent-001", - "host": "host-synthetic-001", - "message_id": "message-synthetic-exact-001", - "orchestration_agent_label": null, - "orchestration_observation_id": null, - "parent_session_id": null, - "parent_tool_use_id": null, - "provider": "provider-synthetic", - "session_id": "session-synthetic-parent-001", - "source_store_id": "source-store-synthetic-001", - "thread_id": "thread-synthetic-parent-001", - "turn_id": "turn-synthetic-parent-001" - } - } - }, - { - "confidence": 0.94, - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 43, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [], - "searched": [ - "shard-synthetic-a" - ], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [], - "unknown_coverage": false - }, - "entry_id": "research-anchor-child-001", - "evidence_class": "derived_exact", - "expected_subject": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Synthetic child agent with declared parent linkage." - }, - "occurred_window": { - "end": 1767323105000000, - "start": 1767323055000000 - }, - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Resolve a synthetic child agent through declared parent and tool metadata." - }, - "related_activity": null, - "retrieval_anchors": [ - "retrieval-anchor-child-001" - ], - "retrieval_recipe_id": "retrieval-recipe-child-001", - "snapshot": { - "components": { - "shard-synthetic-a": 43 - } - }, - "source_observation_ids": [ - "observation-child-001" - ], - "subject": { - "kind": "activity", - "subject": { - "agent_instance_id": "agent-synthetic-child-001", - "goal_id": "goal-synthetic-child-001", - "host": "host-synthetic-001", - "message_id": "message-synthetic-child-001", - "orchestration_agent_label": "synthetic-child-task", - "orchestration_observation_id": "orchestration-observation-synthetic-child-001", - "parent_session_id": "session-synthetic-parent-001", - "parent_tool_use_id": "tool-use-synthetic-parent-001", - "provider": "provider-synthetic", - "session_id": "session-synthetic-child-001", - "source_store_id": "source-store-synthetic-001", - "thread_id": "thread-synthetic-child-001", - "turn_id": "turn-synthetic-child-001" - } - } - }, - { - "confidence": 0.4, - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 44, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [], - "searched": [ - "shard-synthetic-a" - ], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [], - "unknown_coverage": false - }, - "entry_id": "research-anchor-copied-coordination-001", - "evidence_class": "inferred", - "expected_subject": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Synthetic copied coordination event without direct authorship." - }, - "occurred_window": { - "end": 1767323165000001, - "start": 1767323165000000 - }, - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Retain copied coordination as discovery-only evidence, not ownership." - }, - "related_activity": null, - "retrieval_anchors": [ - "retrieval-anchor-copied-coordination-001" - ], - "retrieval_recipe_id": "retrieval-recipe-copied-coordination-001", - "snapshot": { - "components": { - "shard-synthetic-a": 44 - } - }, - "source_observation_ids": [ - "observation-copied-coordination-001" - ], - "subject": { - "kind": "activity", - "subject": { - "agent_instance_id": "agent-synthetic-child-unknown-001", - "goal_id": null, - "host": "host-synthetic-001", - "message_id": "message-synthetic-copied-coordination-001", - "orchestration_agent_label": null, - "orchestration_observation_id": "orchestration-observation-synthetic-copied-001", - "parent_session_id": "session-synthetic-parent-001", - "parent_tool_use_id": null, - "provider": "provider-synthetic", - "session_id": "session-synthetic-child-unknown-001", - "source_store_id": "source-store-synthetic-001", - "thread_id": "thread-synthetic-child-unknown-001", - "turn_id": "turn-synthetic-child-unknown-001" - } - } - }, - { - "confidence": 0.91, - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 45, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [], - "searched": [ - "shard-synthetic-a" - ], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [], - "unknown_coverage": false - }, - "entry_id": "research-anchor-workflow-001", - "evidence_class": "derived_exact", - "expected_subject": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Synthetic workflow run metadata." - }, - "occurred_window": null, - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Retain a synthetic workflow run as its own canonical retrieval anchor." - }, - "related_activity": { - "agent_instance_id": "agent-synthetic-child-001", - "goal_id": null, - "host": "host-synthetic-001", - "message_id": null, - "orchestration_agent_label": "synthetic-workflow-task", - "orchestration_observation_id": "orchestration-observation-synthetic-workflow-001", - "parent_session_id": "session-synthetic-parent-001", - "parent_tool_use_id": "tool-use-synthetic-parent-001", - "provider": "provider-synthetic", - "session_id": "session-synthetic-child-001", - "source_store_id": "source-store-synthetic-001", - "thread_id": "thread-synthetic-child-001", - "turn_id": null - }, - "retrieval_anchors": [ - "retrieval-anchor-workflow-001" - ], - "retrieval_recipe_id": "retrieval-recipe-workflow-001", - "snapshot": { - "components": { - "shard-synthetic-a": 45 - } - }, - "source_observation_ids": [ - "observation-workflow-001" - ], - "subject": { - "kind": "delivery", - "subject": { - "delivery_entity": { - "id": "workflow-run-synthetic-001", - "kind": "workflow" - }, - "repository_id": "repository-synthetic-001" - } - } - }, - { - "confidence": 0.9, - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 46, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [], - "searched": [ - "shard-synthetic-a" - ], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [], - "unknown_coverage": false - }, - "entry_id": "research-anchor-branch-session-001", - "evidence_class": "derived_exact", - "expected_subject": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Synthetic branch-active session correlation." - }, - "occurred_window": null, - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Keep a synthetic branch-active session distinct from a Git commit anchor." - }, - "related_activity": { - "agent_instance_id": "agent-synthetic-child-001", - "goal_id": null, - "host": "host-synthetic-001", - "message_id": null, - "orchestration_agent_label": null, - "orchestration_observation_id": null, - "parent_session_id": null, - "parent_tool_use_id": null, - "provider": "provider-synthetic", - "session_id": "session-synthetic-branch-active-001", - "source_store_id": "source-store-synthetic-001", - "thread_id": null, - "turn_id": null - }, - "retrieval_anchors": [ - "retrieval-anchor-branch-session-001" - ], - "retrieval_recipe_id": "retrieval-recipe-branch-session-001", - "snapshot": { - "components": { - "shard-synthetic-a": 46 - } - }, - "source_observation_ids": [ - "observation-branch-session-001" - ], - "subject": { - "kind": "git", - "subject": { - "commit_id": null, - "project_id": "project-synthetic-001", - "ref_id": "ref-synthetic-branch-001", - "repository_id": "repository-synthetic-001", - "worktree_id": "worktree-synthetic-001" - } - } - }, - { - "confidence": 1.0, - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 47, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [], - "searched": [ - "shard-synthetic-a" - ], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [], - "unknown_coverage": false - }, - "entry_id": "research-anchor-produced-commit-001", - "evidence_class": "observed", - "expected_subject": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Synthetic produced commit relation." - }, - "occurred_window": null, - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Keep a synthetic produced commit relation explicit." - }, - "related_activity": { - "agent_instance_id": "agent-synthetic-child-001", - "goal_id": null, - "host": "host-synthetic-001", - "message_id": null, - "orchestration_agent_label": null, - "orchestration_observation_id": "orchestration-observation-synthetic-produced-001", - "parent_session_id": "session-synthetic-parent-001", - "parent_tool_use_id": "tool-use-synthetic-parent-001", - "provider": "provider-synthetic", - "session_id": "session-synthetic-child-001", - "source_store_id": "source-store-synthetic-001", - "thread_id": null, - "turn_id": null - }, - "retrieval_anchors": [ - "retrieval-anchor-produced-commit-001" - ], - "retrieval_recipe_id": "retrieval-recipe-produced-commit-001", - "snapshot": { - "components": { - "shard-synthetic-a": 47 - } - }, - "source_observation_ids": [ - "observation-produced-commit-001" - ], - "subject": { - "kind": "git", - "subject": { - "commit_id": "commit-synthetic-produced-001", - "project_id": "project-synthetic-001", - "ref_id": "ref-synthetic-branch-001", - "repository_id": "repository-synthetic-001", - "worktree_id": "worktree-synthetic-001" - } - } - }, - { - "confidence": 0.88, - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 48, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [], - "searched": [ - "shard-synthetic-a" - ], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [], - "unknown_coverage": false - }, - "entry_id": "research-anchor-observed-commit-001", - "evidence_class": "derived_exact", - "expected_subject": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Synthetic observed commit relation." - }, - "occurred_window": null, - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Keep a synthetic observed commit distinct from production evidence." - }, - "related_activity": { - "agent_instance_id": "agent-synthetic-observer-001", - "goal_id": null, - "host": "host-synthetic-001", - "message_id": null, - "orchestration_agent_label": null, - "orchestration_observation_id": "orchestration-observation-synthetic-observed-001", - "parent_session_id": null, - "parent_tool_use_id": null, - "provider": "provider-synthetic", - "session_id": "session-synthetic-observer-001", - "source_store_id": "source-store-synthetic-001", - "thread_id": null, - "turn_id": null - }, - "retrieval_anchors": [ - "retrieval-anchor-observed-commit-001" - ], - "retrieval_recipe_id": "retrieval-recipe-observed-commit-001", - "snapshot": { - "components": { - "shard-synthetic-a": 48 - } - }, - "source_observation_ids": [ - "observation-observed-commit-001" - ], - "subject": { - "kind": "git", - "subject": { - "commit_id": "commit-synthetic-observed-001", - "project_id": "project-synthetic-001", - "ref_id": "ref-synthetic-branch-001", - "repository_id": "repository-synthetic-001", - "worktree_id": "worktree-synthetic-001" - } - } - } - ], - "base_commit": "commit-synthetic-base-001", - "catalog_snapshot": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "created_by": { - "actor_id": "actor-synthetic-parent-001", - "version": null - }, - "digest": "sha256:434a2e25fc976db1dc72bb6c73a55317b1749cd0901480864cbc93188d615c10", - "git_snapshot": { - "captured_at": 1767323045000000, - "dirty": false, - "head_commit": "commit-synthetic-head-001", - "merge_base": "commit-synthetic-merge-base-001", - "refs": [ - [ - "ref-synthetic-branch-001", - "commit-synthetic-head-001" - ] - ], - "repository": "repository-synthetic-001" - }, - "manifest_id": "research-manifest-synthetic-001", - "parent_plan": { - "id": "plan-synthetic-001", - "kind": "plan" - }, - "plan_commit": "commit-synthetic-plan-001", - "private_corpus": null, - "redaction_report": { - "receipts": [ - "sanitization-receipt-synthetic-001" - ], - "redacted": 1, - "rejected": 0, - "sanitizer_version": "synthetic-sanitizer-1.0.0", - "scanned": 9 - }, - "repository": "repository-synthetic-001", - "retrieval_recipes": [ - { - "anchors": [ - "retrieval-anchor-message-001" - ], - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Resolve synthetic exact message metadata." - }, - "recipe_id": "retrieval-recipe-message-001", - "snapshot": { - "components": { - "shard-synthetic-a": 42 - } - }, - "use_case": "use-case-synthetic-message-001" - }, - { - "anchors": [ - "retrieval-anchor-child-001" - ], - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Resolve synthetic child linkage metadata." - }, - "recipe_id": "retrieval-recipe-child-001", - "snapshot": { - "components": { - "shard-synthetic-a": 43 - } - }, - "use_case": "use-case-synthetic-child-001" - }, - { - "anchors": [ - "retrieval-anchor-copied-coordination-001" - ], - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Recover synthetic coordination metadata without authorship." - }, - "recipe_id": "retrieval-recipe-copied-coordination-001", - "snapshot": { - "components": { - "shard-synthetic-a": 44 - } - }, - "use_case": "use-case-synthetic-attribution-gap-001" - }, - { - "anchors": [ - "retrieval-anchor-workflow-001" - ], - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Resolve synthetic workflow metadata." - }, - "recipe_id": "retrieval-recipe-workflow-001", - "snapshot": { - "components": { - "shard-synthetic-a": 45 - } - }, - "use_case": "use-case-synthetic-workflow-001" - }, - { - "anchors": [ - "retrieval-anchor-branch-session-001" - ], - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Resolve synthetic branch session metadata." - }, - "recipe_id": "retrieval-recipe-branch-session-001", - "snapshot": { - "components": { - "shard-synthetic-a": 46 - } - }, - "use_case": "use-case-synthetic-branch-session-001" - }, - { - "anchors": [ - "retrieval-anchor-produced-commit-001" - ], - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Resolve synthetic produced commit metadata." - }, - "recipe_id": "retrieval-recipe-produced-commit-001", - "snapshot": { - "components": { - "shard-synthetic-a": 47 - } - }, - "use_case": "use-case-synthetic-produced-commit-001" - }, - { - "anchors": [ - "retrieval-anchor-observed-commit-001" - ], - "purpose": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Resolve synthetic observed commit metadata." - }, - "recipe_id": "retrieval-recipe-observed-commit-001", - "snapshot": { - "components": { - "shard-synthetic-a": 48 - } - }, - "use_case": "use-case-synthetic-observed-commit-001" - } - ], - "schema_version": "research-bundle/v1", - "store_watermarks": { - "components": { - "shard-synthetic-a": 48 - } - }, - "supersedes": "research-manifest-synthetic-000", - "unresolved_attribution": [ - { - "candidate_sessions": [ - "session-synthetic-child-unknown-001" - ], - "reason": "missing_parent_tool_use", - "repair_recipe": "retrieval-recipe-copied-coordination-001", - "subject": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Synthetic child attribution remains unresolved." - } - }, - { - "candidate_sessions": [ - "session-synthetic-child-unknown-001" - ], - "reason": "copied_coordination_text", - "repair_recipe": "retrieval-recipe-copied-coordination-001", - "subject": { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value": "Synthetic copied coordination cannot prove authorship." - } - } - ] - }, - "retrieval_catalog": { - "records": { - "retrieval-anchor-branch-session-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-branch-session-001", - "canonical_request_digest": "sha256:a54b849958d54b2b2fda697eb7a238adefb99de2916fff97581f102329dacdf6", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:7c67dcc5a00e11280449ba8140a07efe2825eb800199a1fbcc1d470ff290605c", - "durability": "durable_evidence", - "expansion_recipe": { - "bounded_arguments_digest": "sha256:32524450f8613b2bf93454dd4fc09d21bfa80af6a403303e3ec25c26c3e621cc", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "document-synthetic-retrieval-005", - "kind": "document" - } - ], - "payload_access": "eligible", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-retrieval-005" - ], - "resolved_scope_id": "scope-synthetic-retrieval-005", - "retention_class": "retention-synthetic-durable", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 46 - } - }, - "source_identity_class": "project_evidence", - "source_observations": [ - "observation-branch-session-001" - ], - "target": { - "kind": "entity", - "target": { - "id": "document-synthetic-retrieval-005", - "kind": "document" - } - }, - "target_kind": "document", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - }, - "retrieval-anchor-child-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-child-001", - "canonical_request_digest": "sha256:9ba087e4292a86c1fe358193515da719e266239f72c5d58835651c7d100eb6f5", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:0ed4b01bea29136dff5075f95b5ae7273291785b0ed65a1a8534e041fcde924b", - "durability": "durable_evidence", - "expansion_recipe": { - "bounded_arguments_digest": "sha256:ebfce7152077cbf76e956a2ecc3391aa0035ac9c3409762082cfb6b47593c64a", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "document-synthetic-retrieval-002", - "kind": "document" - } - ], - "payload_access": "eligible", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-retrieval-002" - ], - "resolved_scope_id": "scope-synthetic-retrieval-002", - "retention_class": "retention-synthetic-durable", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 43 - } - }, - "source_identity_class": "project_evidence", - "source_observations": [ - "observation-child-001" - ], - "target": { - "kind": "entity", - "target": { - "id": "document-synthetic-retrieval-002", - "kind": "document" - } - }, - "target_kind": "document", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - }, - "retrieval-anchor-copied-coordination-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-copied-coordination-001", - "canonical_request_digest": "sha256:6d2b30f3f00a9172a289d87034ef2403faad18e9b7833c8378a1bbc4fbae4b89", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:33c61167ea76fd779fc9bcaafcf8fcae524c86e3743271e72ca4d7075e96cdd7", - "durability": "durable_evidence", - "expansion_recipe": { - "bounded_arguments_digest": "sha256:1c8dcbaeadd18037611573bb84adfd0fe95a390feb7b076a89d320019a871b6a", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "document-synthetic-retrieval-003", - "kind": "document" - } - ], - "payload_access": "eligible", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-retrieval-003" - ], - "resolved_scope_id": "scope-synthetic-retrieval-003", - "retention_class": "retention-synthetic-durable", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 44 - } - }, - "source_identity_class": "project_evidence", - "source_observations": [ - "observation-copied-coordination-001" - ], - "target": { - "kind": "entity", - "target": { - "id": "document-synthetic-retrieval-003", - "kind": "document" - } - }, - "target_kind": "document", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - }, - "retrieval-anchor-message-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-message-001", - "canonical_request_digest": "sha256:d4a5827385716df498936a331ba03b7d99332fb28295488cb9f9ba73e2f15ea3", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:f469d9e40899b5d8bbe896c8362c3afd97640f7a395f15a79a19cecab98336c4", - "durability": "durable_evidence", - "expansion_recipe": { - "bounded_arguments_digest": "sha256:601223c61549afc0e0c4aca964dbc820de8c78c6dab3167d675a0dd0e95de5b5", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "document-synthetic-retrieval-001", - "kind": "document" - } - ], - "payload_access": "eligible", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-retrieval-001" - ], - "resolved_scope_id": "scope-synthetic-retrieval-001", - "retention_class": "retention-synthetic-durable", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 42 - } - }, - "source_identity_class": "project_evidence", - "source_observations": [ - "observation-message-001" - ], - "target": { - "kind": "entity", - "target": { - "id": "document-synthetic-retrieval-001", - "kind": "document" - } - }, - "target_kind": "document", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - }, - "retrieval-anchor-observed-commit-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-observed-commit-001", - "canonical_request_digest": "sha256:6e2f663eafaa7deecc5a19ae6afa01b60ca0177c6ccb394b9c0ec9db20e4e29a", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:59331da088f184e425cf977bb701d6356cd079ea0da8395ff5d17d506d8058e6", - "durability": "durable_evidence", - "expansion_recipe": { - "bounded_arguments_digest": "sha256:423314258e865f94ee15a2d17ec5e63d77bcc5df6f07e82a93c0ed8b0e0fdbd5", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "document-synthetic-retrieval-007", - "kind": "document" - } - ], - "payload_access": "eligible", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-retrieval-007" - ], - "resolved_scope_id": "scope-synthetic-retrieval-007", - "retention_class": "retention-synthetic-durable", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 48 - } - }, - "source_identity_class": "project_evidence", - "source_observations": [ - "observation-observed-commit-001" - ], - "target": { - "kind": "entity", - "target": { - "id": "document-synthetic-retrieval-007", - "kind": "document" - } - }, - "target_kind": "document", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - }, - "retrieval-anchor-produced-commit-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-produced-commit-001", - "canonical_request_digest": "sha256:ca4a87d2064b11ce784b1abddb32afb12f0906eece1a1b7269813199b31e8f01", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:45f46b7bb873c159eb8771184829285b7475e9cfc8f272f913eac0337327f97a", - "durability": "durable_evidence", - "expansion_recipe": { - "bounded_arguments_digest": "sha256:439c4b755842e61acaf7ee6da102f6ae9b91a97afc1bd2db17dbe61d64f5ca9d", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "document-synthetic-retrieval-006", - "kind": "document" - } - ], - "payload_access": "eligible", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-retrieval-006" - ], - "resolved_scope_id": "scope-synthetic-retrieval-006", - "retention_class": "retention-synthetic-durable", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 47 - } - }, - "source_identity_class": "project_evidence", - "source_observations": [ - "observation-produced-commit-001" - ], - "target": { - "kind": "entity", - "target": { - "id": "document-synthetic-retrieval-006", - "kind": "document" - } - }, - "target_kind": "document", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - }, - "retrieval-anchor-deleted-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-deleted-001", - "canonical_request_digest": "sha256:4444444444444444444444444444444444444444444444444444444444444444", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555", - "durability": "archived", - "expansion_recipe": { - "bounded_arguments_digest": "sha256:6666666666666666666666666666666666666666666666666666666666666666", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "message-synthetic-deleted-001", - "kind": "message" - } - ], - "payload_access": "unavailable", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-deleted-001" - ], - "resolved_scope_id": "scope-synthetic-deleted-001", - "retention_class": "retention-synthetic-deleted", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 51 - } - }, - "source_identity_class": "project_evidence", - "source_observations": [ - "observation-deleted-001" - ], - "target": { - "kind": "entity", - "target": { - "id": "message-synthetic-deleted-001", - "kind": "message" - } - }, - "target_kind": "message", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - }, - "retrieval-anchor-redacted-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-redacted-001", - "canonical_request_digest": "sha256:e3be3858c3750db9b482758446d2f3d0e3a4b8538c8f86096d439a1307834759", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:0570b0b2c27edf5870e1b61422e9d63a46bd6f5dd36933543bb04809a957f502", - "durability": "durable_evidence", - "expansion_recipe": { - "bounded_arguments_digest": "sha256:361569480b8cb49210b83e62759d7f41b801f1867ede6a19790b3d560a58a617", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "document-synthetic-retrieval-008", - "kind": "document" - } - ], - "payload_access": "redacted", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-retrieval-008" - ], - "resolved_scope_id": "scope-synthetic-retrieval-008", - "retention_class": "retention-synthetic-durable", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 49 - } - }, - "source_identity_class": "project_evidence", - "source_observations": [], - "target": { - "kind": "entity", - "target": { - "id": "document-synthetic-retrieval-008", - "kind": "document" - } - }, - "target_kind": "document", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - }, - "retrieval-anchor-response-handle-expired-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-response-handle-expired-001", - "canonical_request_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "durability": { - "retention_bound": { - "expires_at": 1767323224000000 - } - }, - "expansion_recipe": { - "bounded_arguments_digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "response-handle-synthetic-expired-001", - "kind": "response_handle" - } - ], - "payload_access": "retention_expired", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-response-handle-expired-001" - ], - "resolved_scope_id": "scope-synthetic-response-handle-expired-001", - "retention_class": "retention-synthetic-response-handle", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 50 - } - }, - "source_identity_class": "external_delivery", - "source_observations": [ - "observation-response-handle-expired-001" - ], - "target": { - "kind": "entity", - "target": { - "id": "response-handle-synthetic-expired-001", - "kind": "response_handle" - } - }, - "target_kind": "response_handle", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - }, - "retrieval-anchor-workflow-001": { - "access_policy_digest": "sha256:a3edf5d62ee43f32142b9e99ffa37f0e9bbdf46f1a69a05f07c7f4cb1b343651", - "anchor_id": "retrieval-anchor-workflow-001", - "canonical_request_digest": "sha256:d0685d0dc271bd8aad9f66e74d45155c8aacfdb56991c7e6ebf5cf18eb41c72b", - "capability_catalog": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - }, - "created_at": 1767323045000000, - "data_version_digest": "sha256:8555a67ea113432f9f5fa9577a0818296367f3347f9ab6b0f0b8ab096c0b221a", - "durability": "durable_evidence", - "expansion_recipe": { - "bounded_arguments_digest": "sha256:0f4d7c484799b1ddf2dd4e839fdb77d9f60edeff96706b24606273ca1cea0b79", - "capability_id": "capability-synthetic-research-exact-001", - "expansion": "exact_target" - }, - "immutable_source_refs": [ - { - "id": "document-synthetic-retrieval-004", - "kind": "document" - } - ], - "payload_access": "eligible", - "privacy_domain_id": "privacy-domain-synthetic-001", - "projection_version": "research-projection-synthetic-1.0.0", - "provenance": [ - "provenance-synthetic-retrieval-004" - ], - "resolved_scope_id": "scope-synthetic-retrieval-004", - "retention_class": "retention-synthetic-durable", - "schema_registry_digest": "sha256:f6694f27ce029884f3a6f8ae4eb282caa9fd0b5aa9f6f1d0b6c6b035c5f95cfc", - "snapshot": { - "components": { - "shard-synthetic-a": 45 - } - }, - "source_identity_class": "project_evidence", - "source_observations": [ - "observation-workflow-001" - ], - "target": { - "kind": "entity", - "target": { - "id": "document-synthetic-retrieval-004", - "kind": "document" - } - }, - "target_kind": "document", - "view": "entity_version", - "view_algorithm_version": "research-view-synthetic-1.0.0" - } - }, - "snapshot": { - "digest": "sha256:74ec82e8ac9134e6108566e685b077514bf9694399937d968cb75db2d584debe", - "generation": "catalog-synthetic-001" - } - } - }, - "tombstones": [ - { - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 51, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [], - "searched": [], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [ - "shard-synthetic-a" - ], - "unknown_coverage": false - }, - "entry_id": "research-anchor-deleted-001", - "evidence_class": "observed", - "occurred_at": 1767323225000000, - "reason": "deleted", - "receipt": { - "receipt_id": "audit-receipt-synthetic-deleted-001" - }, - "retrieval_anchors": [ - "retrieval-anchor-deleted-001" - ], - "snapshot": { - "components": { - "shard-synthetic-a": 51 - } - }, - "subject": { - "kind": "activity", - "subject": { - "agent_instance_id": null, - "goal_id": null, - "host": "host-synthetic-001", - "message_id": "message-synthetic-deleted-001", - "orchestration_agent_label": null, - "orchestration_observation_id": null, - "parent_session_id": null, - "parent_tool_use_id": null, - "provider": "provider-synthetic", - "session_id": "session-synthetic-deleted-001", - "source_store_id": "source-store-synthetic-001", - "thread_id": null, - "turn_id": null - } - } - }, - { - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 49, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [ - "shard-synthetic-a" - ], - "searched": [], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [], - "unknown_coverage": false - }, - "entry_id": "research-anchor-redacted-001", - "evidence_class": "observed", - "occurred_at": 1767323225000000, - "reason": "redacted", - "receipt": { - "receipt_id": "audit-receipt-synthetic-redacted-001" - }, - "retrieval_anchors": [ - "retrieval-anchor-redacted-001" - ], - "snapshot": { - "components": { - "shard-synthetic-a": 49 - } - }, - "subject": { - "kind": "activity", - "subject": { - "agent_instance_id": null, - "goal_id": null, - "host": "host-synthetic-001", - "message_id": "message-synthetic-redacted-001", - "orchestration_agent_label": null, - "orchestration_observation_id": null, - "parent_session_id": null, - "parent_tool_use_id": null, - "provider": "provider-synthetic", - "session_id": "session-synthetic-redacted-001", - "source_store_id": "source-store-synthetic-001", - "thread_id": null, - "turn_id": null - } - } - }, - { - "coverage": { - "freshness": { - "shard-synthetic-a": { - "outbox_sequence": 50, - "shard_id": "shard-synthetic-a" - } - }, - "incompatible": [], - "locked": [], - "redacted": [], - "searched": [], - "skipped": [], - "stale": [], - "truncated": [], - "unavailable": [ - "shard-synthetic-a" - ], - "unknown_coverage": false - }, - "entry_id": "research-anchor-response-handle-expired-001", - "evidence_class": "observed", - "occurred_at": 1767323225000000, - "reason": "expired", - "receipt": { - "receipt_id": "audit-receipt-synthetic-response-handle-expired-001" - }, - "retrieval_anchors": [ - "retrieval-anchor-response-handle-expired-001" - ], - "snapshot": { - "components": { - "shard-synthetic-a": 50 - } - }, - "subject": { - "kind": "delivery", - "subject": { - "delivery_entity": { - "id": "response-handle-synthetic-expired-001", - "kind": "response_handle" - }, - "repository_id": "repository-synthetic-001" - } - } - } - ], - "sanitization_receipts": [ - { - "receipt": { - "receipt_id": "sanitization-receipt-synthetic-001", - "sanitizer_version": "synthetic-sanitizer-1.0.0" - }, - "value_sha256": [ - "06fcf82b01ef0b44b89b1db3298685dee1d9ed098a5a50ee54177e98f04b21cb", - "0cbe600e3b43b7ea275676f0d1a395bbd13486a08951bc662bac2e5caa4620a8", - "169e7550437e89e9824ce2767ccaef8139e1d00cade449176141145253b10346", - "24a1646610d46876acc0d36aeac6bc97a8e01e7d58fe0b8e2c16c3be96eda5b6", - "32b139576ed8a5a7f4f4e98cba4840e0c0d3e88b44ca26136b980a9b919e38fc", - "50c4d69830aece5f80e905db3f8c07083af15908b188bcb7b15392cf47f93071", - "55303d64ee92a76196cffa5dd7123c10aa461e3cf385b2fc19b3d2277edba0b8", - "5f2f26e4d256cb49a9ac0c4ed43595fde9b5dc3ce26e9348190105f53e55c04e", - "6fdc919bd4c42c475a99aa47e634604102a35dd465ef243215d6240034039aaa", - "7b669232e88b3d62866a64c6212f9d70fee72cba2ba5a03d7849744147869264", - "a28f2215b863e0a2742e1f69cc9aee4733b8c1879d437d863ca66c04f8a70962", - "b2a1c7e4ed9e668a7384ff95c4960a50a6e855b171fadd90afb7d0e7f3a0565b", - "b8a9c2ee7c1671b25988b3f50974ddb0db0049f37e51ac76871e239b3a124e50", - "b9a59fec91771edf93a12eae16af6e10669bc85f048159b01fd6c8fb7cd1c28a", - "be906696285434062b39795758d700a29cd244dbfa0f42bfb01770ad21d90b81", - "d6eda130bbfb1c481f421bc18e2278b1676518731ea36ddc0faaa8c0548933b0", - "d900bad07614ec0d1ed296ff3091e95a905a0fa0ad2584d31eaf41b0f7aa0523", - "dee6460f0bcdc3b012bd1844e15e9407c3ffa42a366c4d4a139711aaebc45444", - "e309c0790bc58271a567e015042fe624122b15e017b369f72573f17d480af493", - "e782b56e9ea2488534fb130978fcff30a12c07edf957013ba3d0d3cbe2ca8dae", - "ee21b026777b6d0547f0b0e804a0ed054211dc5e5d66fb5517be8d68de1a0813", - "fb3ec776fecba21a85a04003d5b1f8c56c96d36c1d00ec96e09bfd5fb2517265", - "ff0477a34d907935439985b7415d7d69b3e8ea232fa201f218dced1520f4e842" - ] - } - ] -} diff --git a/tests/fixtures/workflow_provider/claude-code-result.json b/tests/fixtures/workflow_provider/claude-code-result.json deleted file mode 100644 index 2013070e82..0000000000 --- a/tests/fixtures/workflow_provider/claude-code-result.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"result","subtype":"success","is_error":false,"duration_ms":1267,"duration_api_ms":1184,"num_turns":1,"result":"native claude terminal fixture","session_id":"a4bb0dd6-c409-4620-986f-61aa54cf97d3","total_cost_usd":0.004321,"usage":{"input_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":8,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard"},"modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":3,"outputTokens":8,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.004321,"contextWindow":200000,"maxOutputTokens":64000}},"permission_denials":[],"uuid":"370e208f-5b1a-4874-b230-973240751ebb"} diff --git a/tests/fixtures/workflow_provider/codex-exec.jsonl b/tests/fixtures/workflow_provider/codex-exec.jsonl deleted file mode 100644 index bcb8845114..0000000000 --- a/tests/fixtures/workflow_provider/codex-exec.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"type":"thread.started","thread_id":"019c08bb-8eef-7b50-84fe-6d2681c23c60"} -{"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"native codex terminal fixture"}} -{"type":"turn.completed","usage":{"input_tokens":2841,"cached_input_tokens":2560,"output_tokens":14}} diff --git a/tests/install_script_test.sh b/tests/install_script_test.sh index 8265122ab0..4bb6aaf92c 100755 --- a/tests/install_script_test.sh +++ b/tests/install_script_test.sh @@ -20,27 +20,24 @@ STABLE_ASSET="tracedecay-${STABLE_TAG}-x86_64-linux.tar.gz" # Newer prerelease that Release Please published before assets uploaded. INCOMPLETE_TAG=v9.8.8-beta.1 -cat >"$tmpdir/archive/tracedecay" <<'SH' +stage_release_archive() { + local version=$1 + local asset=$2 + local sums=$3 + cat >"$tmpdir/archive/tracedecay" <SHA256SUMS -) - -cat >"$tmpdir/archive/tracedecay" <<'SH' -#!/usr/bin/env bash -printf 'tracedecay 9.8.7\n' -SH -chmod +x "$tmpdir/archive/tracedecay" -tar -czf "$tmpdir/${STABLE_ASSET}" -C "$tmpdir/archive" tracedecay -( - cd "$tmpdir" - sha256sum "$STABLE_ASSET" >STABLE_SHA256SUMS -) + chmod +x "$tmpdir/archive/tracedecay" + tar -czf "$tmpdir/${asset}" -C "$tmpdir/archive" tracedecay + ( + cd "$tmpdir" + sha256sum "$asset" >"$sums" + ) +} + +stage_release_archive "9.8.7-beta.1" "$BETA_ASSET" SHA256SUMS +stage_release_archive "9.8.7" "$STABLE_ASSET" STABLE_SHA256SUMS # Incomplete first, complete beta second: default latest must skip the empty # prerelease and install the beta that already lists archive + SHA256SUMS. @@ -153,7 +150,7 @@ run_installer() { TRACEDECAY_INSTALL_DIR="$tmpdir/install" \ TEST_RELEASES_JSON="$tmpdir/releases.json" \ TEST_ARCHIVE="$tmpdir/${BETA_ASSET}" \ - TEST_CHECKSUMS="$tmpdir/SHA256SUMS" \ + TEST_CHECKSUMS="${INSTALLER_CHECKSUMS:-$tmpdir/SHA256SUMS}" \ TEST_STABLE_ARCHIVE="$tmpdir/${STABLE_ASSET}" \ TEST_STABLE_CHECKSUMS="$tmpdir/STABLE_SHA256SUMS" \ "$@" @@ -174,14 +171,7 @@ expect_installer_failure() { local checksums=$1 local expected_message=$2 local output="$tmpdir/installer-failure.log" - if PATH="$tmpdir/bin:$PATH" \ - TRACEDECAY_INSTALL_DIR="$tmpdir/install" \ - TEST_RELEASES_JSON="$tmpdir/releases.json" \ - TEST_ARCHIVE="$tmpdir/${BETA_ASSET}" \ - TEST_CHECKSUMS="$checksums" \ - TEST_STABLE_ARCHIVE="$tmpdir/${STABLE_ASSET}" \ - TEST_STABLE_CHECKSUMS="$tmpdir/STABLE_SHA256SUMS" \ - "$INSTALLER" >"$output" 2>&1 + if INSTALLER_CHECKSUMS="$checksums" run_installer "$INSTALLER" >"$output" 2>&1 then echo "installer unexpectedly accepted invalid release inputs" >&2 exit 1 diff --git a/tests/mcp_conformance_smoke_test.sh b/tests/mcp_conformance_smoke_test.sh index 581771c500..df766fc4ee 100755 --- a/tests/mcp_conformance_smoke_test.sh +++ b/tests/mcp_conformance_smoke_test.sh @@ -42,6 +42,17 @@ while (($# > 0)); do esac done +bump_attempts() { + local file=$1 + local attempts=0 + if [[ -f $file ]]; then + attempts=$(<"$file") + fi + attempts=$((attempts + 1)) + printf '%s\n' "$attempts" >"$file" + printf '%s\n' "$attempts" +} + case "$method:$tool" in tools/list:) printf '%s\n' '{"tools":[{"name":"tracedecay_search","inputSchema":{"type":"object"}},{"name":"tracedecay_diagnostics","inputSchema":{"type":"object"}},{"name":"tracedecay_impact","inputSchema":{"type":"object"}},{"name":"tracedecay_affected","inputSchema":{"type":"object"}},{"name":"tracedecay_test_map","inputSchema":{"type":"object"}},{"name":"tracedecay_find_exact_symbol","inputSchema":{"type":"object"}}]}' @@ -56,12 +67,7 @@ case "$method:$tool" in printf '%s\n' '{"content":[{"type":"text","text":"typed evidence"}]}' ;; tools/call:tracedecay_test_map) - attempts=0 - if [[ -f "$FAKE_TEST_MAP_ATTEMPTS" ]]; then - attempts=$(<"$FAKE_TEST_MAP_ATTEMPTS") - fi - attempts=$((attempts + 1)) - printf '%s\n' "$attempts" >"$FAKE_TEST_MAP_ATTEMPTS" + attempts=$(bump_attempts "$FAKE_TEST_MAP_ATTEMPTS") if [[ ${FAKE_TEST_MAP_TERMINAL:-0} == 1 ]]; then echo "Failed to call tool tracedecay_test_map: MCP error -32602: tool project route failed: reason_code=code-graph-invalid-request retryable=false: invalid test-map arguments" >&2 exit 1 @@ -73,12 +79,7 @@ case "$method:$tool" in printf '%s\n' '{"content":[{"type":"text","text":"typed evidence"}]}' ;; tools/call:tracedecay_impact) - attempts=0 - if [[ -f "$FAKE_IMPACT_ATTEMPTS" ]]; then - attempts=$(<"$FAKE_IMPACT_ATTEMPTS") - fi - attempts=$((attempts + 1)) - printf '%s\n' "$attempts" >"$FAKE_IMPACT_ATTEMPTS" + attempts=$(bump_attempts "$FAKE_IMPACT_ATTEMPTS") if ((attempts == 1)); then echo "Failed to call tool tracedecay_impact: MCP error -32603: tool project route failed: reason_code=code-graph-stale retryable=true: the exact graph generation is changing" >&2 exit 1 diff --git a/tests/storage_runtime_rusqlite_suite/repository_parity.rs b/tests/storage_runtime_rusqlite_suite/repository_parity.rs index fc85493905..96f0ed21c0 100644 --- a/tests/storage_runtime_rusqlite_suite/repository_parity.rs +++ b/tests/storage_runtime_rusqlite_suite/repository_parity.rs @@ -42,6 +42,15 @@ impl RuntimeRequestProbeV1 for Probe { } } +fn family_binding(shard_id: StoreShardIdV1) -> StoreRuntimeBindingV1 { + serde_json::from_value(serde_json::json!({ + "shard_id": shard_id, + "incarnation": 1, + "authority_epoch": 1 + })) + .unwrap() +} + fn health_request(binding: StoreRuntimeBindingV1) -> (RuntimeReadRequestV1, Probe) { let cancellation = RuntimeCancellationIdentityV1 { cancellation_id: RuntimeCancellationIdV1::new("cancel.repository-family-health").unwrap(), @@ -107,48 +116,33 @@ fn profile_project_and_session_production_mounts_serve_health_data_ports() { let families = [ ( "profile", - serde_json::from_value(serde_json::json!({ - "shard_id": StoreShardIdV1::profile( - id::("brain.repository-profile"), - id::("profile.repository"), - ), - "incarnation": 1, - "authority_epoch": 1 - })) - .unwrap(), + StoreShardIdV1::profile( + id::("brain.repository-profile"), + id::("profile.repository"), + ), ), ( "project", - serde_json::from_value(serde_json::json!({ - "shard_id": StoreShardIdV1::project( - id::("brain.repository-project"), - id::("profile.repository"), - id::("project.repository"), - ), - "incarnation": 1, - "authority_epoch": 1 - })) - .unwrap(), + StoreShardIdV1::project( + id::("brain.repository-project"), + id::("profile.repository"), + id::("project.repository"), + ), ), ( "sessions", - serde_json::from_value(serde_json::json!({ - "shard_id": StoreShardIdV1::project_sessions( - id::("brain.repository-sessions"), - id::("profile.repository"), - id::("project.repository"), - ), - "incarnation": 1, - "authority_epoch": 1 - })) - .unwrap(), + StoreShardIdV1::project_sessions( + id::("brain.repository-sessions"), + id::("profile.repository"), + id::("project.repository"), + ), ), ]; - for (family, binding) in families { + for (family, shard_id) in families { let path = directory.path().join(format!("{family}.db")); Connection::open(&path).unwrap(); let path = path.canonicalize().unwrap(); - assert_family_mount(binding, &path); + assert_family_mount(family_binding(shard_id), &path); } } diff --git a/tests/storage_runtime_rusqlite_suite/runtime_operations.rs b/tests/storage_runtime_rusqlite_suite/runtime_operations.rs index 9fca99e288..b5c0aa1d35 100644 --- a/tests/storage_runtime_rusqlite_suite/runtime_operations.rs +++ b/tests/storage_runtime_rusqlite_suite/runtime_operations.rs @@ -7,6 +7,7 @@ use tracedecay_store::AdmissionConfigV1; use super::runtime_test_support::{ CountExecutor, Probe, TestDatabase, maintenance_binding, read_request, reader_locator, + seed_acceptance_rows, }; #[test] @@ -14,14 +15,7 @@ fn checkpoint_health_exposes_wal_pressure_while_a_snapshot_blocks_progress() { let binding = maintenance_binding(); let database = TestDatabase::new("runtime-checkpoint.sqlite3"); let mut writer = database.connect(); - writer - .execute_batch( - "PRAGMA journal_mode=WAL; - PRAGMA wal_autocheckpoint=0; - CREATE TABLE acceptance_rows(value INTEGER NOT NULL); - INSERT INTO acceptance_rows(value) VALUES (1);", - ) - .expect("seed checkpoint authority"); + seed_acceptance_rows(&writer, true); let pool = ReaderPool::start( reader_locator(&binding, &database.path), AdmissionConfigV1::default().readers, diff --git a/tests/storage_runtime_rusqlite_suite/runtime_reader.rs b/tests/storage_runtime_rusqlite_suite/runtime_reader.rs index eca8535807..3b8f614961 100644 --- a/tests/storage_runtime_rusqlite_suite/runtime_reader.rs +++ b/tests/storage_runtime_rusqlite_suite/runtime_reader.rs @@ -7,35 +7,22 @@ use tracedecay_rusqlite_runtime::{ runtime::{IntegrityResult, SqliteDoctorHealthLane}, watermark::CommittedWatermarkPublisher, }; -use tracedecay_store::{ - AdmissionConfigV1, CommitSequenceV1, OperationPriorityV1, ShardWatermarkV1, - StoreCommitReceiptV1, UnavailableReasonV1, -}; +use tracedecay_store::{OperationPriorityV1, StoreCommitReceiptV1, UnavailableReasonV1}; use super::runtime_test_support::{ - CountExecutor, Probe, TestDatabase, read_request, reader_locator, reader_runtime_fixture, + CountExecutor, Probe, TestDatabase, acceptance_reader_budget, read_request, reader_locator, + reader_runtime_fixture, seed_acceptance_rows, shard_watermark, }; #[test] fn reader_drain_preserves_inflight_and_reserved_health_capacity() { let fixture = reader_runtime_fixture(); let database = TestDatabase::new("runtime-reader.sqlite3"); - let connection = database.connect(); - connection - .execute_batch( - "PRAGMA journal_mode=WAL; - CREATE TABLE acceptance_rows(value INTEGER NOT NULL); - INSERT INTO acceptance_rows(value) VALUES (1);", - ) - .expect("seed reader authority"); + seed_acceptance_rows(&database.connect(), false); - let mut budget = AdmissionConfigV1::default().readers; - budget.min_per_hot_shard = fixture.reader_budget.min_per_hot_shard; - budget.max_per_hot_shard = fixture.reader_budget.max_per_hot_shard; - budget.idle_burst_retire_ms = fixture.reader_budget.idle_burst_retire_ms; let pool = ReaderPool::start( reader_locator(&fixture.binding, &database.path), - budget, + acceptance_reader_budget(&fixture), CountExecutor, ) .expect("start reader pool"); @@ -77,22 +64,11 @@ fn reader_drain_preserves_inflight_and_reserved_health_capacity() { fn doctor_health_and_commit_watermark_report_the_same_runtime_binding() { let fixture = reader_runtime_fixture(); let database = TestDatabase::new("runtime-health.sqlite3"); - let connection = database.connect(); - connection - .execute_batch( - "PRAGMA journal_mode=WAL; - CREATE TABLE acceptance_rows(value INTEGER NOT NULL); - INSERT INTO acceptance_rows(value) VALUES (1);", - ) - .expect("seed health authority"); + seed_acceptance_rows(&database.connect(), false); - let mut budget = AdmissionConfigV1::default().readers; - budget.min_per_hot_shard = fixture.reader_budget.min_per_hot_shard; - budget.max_per_hot_shard = fixture.reader_budget.max_per_hot_shard; - budget.idle_burst_retire_ms = fixture.reader_budget.idle_burst_retire_ms; let pool = ReaderPool::start( reader_locator(&fixture.binding, &database.path), - budget, + acceptance_reader_budget(&fixture), CountExecutor, ) .expect("start reader pool"); @@ -105,7 +81,7 @@ fn doctor_health_and_commit_watermark_report_the_same_runtime_binding() { assert_eq!(health.integrity_check, Some(IntegrityResult::Healthy)); assert_eq!(health.available_health_readers, 1); - let publisher = CommittedWatermarkPublisher::with_initial_watermarks([watermark( + let publisher = CommittedWatermarkPublisher::with_initial_watermarks([shard_watermark( &fixture.binding, fixture.initial_commit_sequence, )]) @@ -128,7 +104,7 @@ fn doctor_health_and_commit_watermark_report_the_same_runtime_binding() { .expect("publish monotonic watermark"); assert_eq!( publisher.subscribe().current(&fixture.binding.shard_id), - WatermarkSourceState::Available(watermark( + WatermarkSourceState::Available(shard_watermark( &fixture.binding, fixture.published_commit_sequence )) @@ -137,12 +113,3 @@ fn doctor_health_and_commit_watermark_report_the_same_runtime_binding() { let health_request = read_request(&fixture.binding, "health"); assert_eq!(health_request.priority(), OperationPriorityV1::Health); } - -fn watermark(binding: &tracedecay_store::StoreRuntimeBindingV1, sequence: u64) -> ShardWatermarkV1 { - ShardWatermarkV1 { - shard_id: binding.shard_id.clone(), - incarnation: binding.incarnation, - authority_epoch: binding.authority_epoch, - commit_sequence: CommitSequenceV1(sequence), - } -} diff --git a/tests/storage_runtime_rusqlite_suite/runtime_test_support.rs b/tests/storage_runtime_rusqlite_suite/runtime_test_support.rs index a9d79f2b0b..ff8f2a1420 100644 --- a/tests/storage_runtime_rusqlite_suite/runtime_test_support.rs +++ b/tests/storage_runtime_rusqlite_suite/runtime_test_support.rs @@ -15,13 +15,13 @@ use tracedecay_rusqlite_runtime::{ reader::{ExistingReaderLocator, ReaderQueryExecutor}, }; use tracedecay_store::{ - AdmissionConfigV1, CommitSequenceV1, LocatorDigest, RepositoryOperationEnvelopeV1, - RepositoryWritePayloadV1, RuntimeBatchCompatibilityV1, RuntimeCancellationIdentityV1, - RuntimeDeadlineV1, RuntimeInterruptionV1, RuntimeReadCoverageV1, RuntimeReadOutcomeV1, - RuntimeReadRequestV1, RuntimeReadResultV1, RuntimeRequestControlV1, RuntimeRequestProbeV1, - RuntimeSubmitRequestV1, RuntimeTransactionIdV1, RuntimeTransactionScopeV1, ShardWatermarkV1, - StorageRuntimeErrorV1, StoreOperationMetadataV1, StoreRuntimeBindingV1, - TransactionalOutboxEntryV1, VerifiedStoreLocatorV1, + AdmissionConfigV1, CommitSequenceV1, LocatorDigest, ReaderBudgetV1, + RepositoryOperationEnvelopeV1, RepositoryWritePayloadV1, RuntimeBatchCompatibilityV1, + RuntimeCancellationIdentityV1, RuntimeDeadlineV1, RuntimeInterruptionV1, RuntimeReadCoverageV1, + RuntimeReadOutcomeV1, RuntimeReadRequestV1, RuntimeReadResultV1, RuntimeRequestControlV1, + RuntimeRequestProbeV1, RuntimeSubmitRequestV1, RuntimeTransactionIdV1, + RuntimeTransactionScopeV1, ShardWatermarkV1, StorageRuntimeErrorV1, StoreOperationMetadataV1, + StoreRuntimeBindingV1, TransactionalOutboxEntryV1, VerifiedStoreLocatorV1, }; pub(crate) struct ReaderRuntimeFixture { @@ -45,18 +45,68 @@ pub(crate) struct WriterRuntimeFixture { pub(crate) commit_sequences: [u64; 2], } +pub(crate) fn seed_acceptance_rows(connection: &Connection, suspend_autocheckpoint: bool) { + let autocheckpoint = if suspend_autocheckpoint { + "PRAGMA wal_autocheckpoint=0;\n" + } else { + "" + }; + connection + .execute_batch(&format!( + "PRAGMA journal_mode=WAL; + {autocheckpoint}CREATE TABLE acceptance_rows(value INTEGER NOT NULL); + INSERT INTO acceptance_rows(value) VALUES (1);" + )) + .expect("seed acceptance authority"); +} + +pub(crate) fn shard_watermark(binding: &StoreRuntimeBindingV1, sequence: u64) -> ShardWatermarkV1 { + ShardWatermarkV1 { + shard_id: binding.shard_id.clone(), + incarnation: binding.incarnation, + authority_epoch: binding.authority_epoch, + commit_sequence: CommitSequenceV1(sequence), + } +} + +pub(crate) fn acceptance_reader_budget(fixture: &ReaderRuntimeFixture) -> ReaderBudgetV1 { + let mut budget = AdmissionConfigV1::default().readers; + budget.min_per_hot_shard = fixture.reader_budget.min_per_hot_shard; + budget.max_per_hot_shard = fixture.reader_budget.max_per_hot_shard; + budget.idle_burst_retire_ms = fixture.reader_budget.idle_burst_retire_ms; + budget +} + +fn project_scope_binding( + brain_id: &str, + profile_id: &str, + scope_kind: &str, + project_id: &str, + incarnation: u64, + authority_epoch: u64, +) -> StoreRuntimeBindingV1 { + serde_json::from_value(json!({ + "shard_id": { + "brain_id": brain_id, + "profile_id": profile_id, + "scope": { "kind": scope_kind, "project_id": project_id } + }, + "incarnation": incarnation, + "authority_epoch": authority_epoch + })) + .expect("construct runtime binding") +} + pub(crate) fn reader_runtime_fixture() -> ReaderRuntimeFixture { ReaderRuntimeFixture { - binding: serde_json::from_value(json!({ - "shard_id": { - "brain_id": "brain.runtime-reader", - "profile_id": "profile.runtime-reader", - "scope": { "kind": "project", "project_id": "project.runtime-reader" } - }, - "incarnation": 1, - "authority_epoch": 7 - })) - .expect("construct reader runtime binding"), + binding: project_scope_binding( + "brain.runtime-reader", + "profile.runtime-reader", + "project", + "project.runtime-reader", + 1, + 7, + ), reader_budget: ReaderBudgetFixture { min_per_hot_shard: 2, max_per_hot_shard: 2, @@ -68,40 +118,34 @@ pub(crate) fn reader_runtime_fixture() -> ReaderRuntimeFixture { } pub(crate) fn maintenance_binding() -> StoreRuntimeBindingV1 { - serde_json::from_value(json!({ - "shard_id": { - "brain_id": "brain.runtime-maintenance", - "profile_id": "profile.runtime-maintenance", - "scope": { "kind": "project", "project_id": "project.runtime-maintenance" } - }, - "incarnation": 3, - "authority_epoch": 11 - })) - .expect("construct maintenance runtime binding") + project_scope_binding( + "brain.runtime-maintenance", + "profile.runtime-maintenance", + "project", + "project.runtime-maintenance", + 3, + 11, + ) } pub(crate) fn writer_runtime_fixture() -> WriterRuntimeFixture { WriterRuntimeFixture { - origin_binding: serde_json::from_value(json!({ - "shard_id": { - "brain_id": "brain.runtime-writer", - "profile_id": "profile.runtime-writer", - "scope": { "kind": "project", "project_id": "project.runtime-writer-origin" } - }, - "incarnation": 5, - "authority_epoch": 13 - })) - .expect("construct writer origin binding"), - target_binding: serde_json::from_value(json!({ - "shard_id": { - "brain_id": "brain.runtime-writer", - "profile_id": "profile.runtime-writer", - "scope": { "kind": "project_sessions", "project_id": "project.runtime-writer-origin" } - }, - "incarnation": 6, - "authority_epoch": 17 - })) - .expect("construct writer target binding"), + origin_binding: project_scope_binding( + "brain.runtime-writer", + "profile.runtime-writer", + "project", + "project.runtime-writer-origin", + 5, + 13, + ), + target_binding: project_scope_binding( + "brain.runtime-writer", + "profile.runtime-writer", + "project_sessions", + "project.runtime-writer-origin", + 6, + 17, + ), effect_id: "effect.runtime.writer", ordering_key: "project.runtime-writer.serialized", commit_sequences: [1, 2], diff --git a/tests/storage_runtime_rusqlite_suite/writer_serialization.rs b/tests/storage_runtime_rusqlite_suite/writer_serialization.rs index 230432d2f8..2e7a40d7e8 100644 --- a/tests/storage_runtime_rusqlite_suite/writer_serialization.rs +++ b/tests/storage_runtime_rusqlite_suite/writer_serialization.rs @@ -1,10 +1,10 @@ use std::sync::Arc; use tracedecay_rusqlite_runtime::read_consistency::{CommitWatermarkSource, WatermarkSourceState}; -use tracedecay_store::{CommitSequenceV1, RuntimeSubmitOutcomeV1}; +use tracedecay_store::RuntimeSubmitOutcomeV1; use super::runtime_test_support::{ - Probe, TestDatabase, outbox_request, run, writer, writer_runtime_fixture, + Probe, TestDatabase, outbox_request, run, shard_watermark, writer, writer_runtime_fixture, }; #[test] @@ -53,12 +53,7 @@ fn writer_serializes_commit_checkpoints_and_publishes_only_committed_watermarks( assert_eq!(sequences, fixture.commit_sequences.to_vec()); assert_eq!( watermarks.current(&fixture.origin_binding.shard_id), - WatermarkSourceState::Available(tracedecay_store::ShardWatermarkV1 { - shard_id: fixture.origin_binding.shard_id.clone(), - incarnation: fixture.origin_binding.incarnation, - authority_epoch: fixture.origin_binding.authority_epoch, - commit_sequence: CommitSequenceV1(2), - }) + WatermarkSourceState::Available(shard_watermark(&fixture.origin_binding, 2)) ); Arc::try_unwrap(writer) diff --git a/tests/support/isolated_profile.rs b/tests/support/isolated_profile.rs new file mode 100644 index 0000000000..78b99eb8fa --- /dev/null +++ b/tests/support/isolated_profile.rs @@ -0,0 +1,80 @@ +#![allow(dead_code)] // each suite uses a subset of this shared harness + +//! Isolated profile environment shared by crate integration suites. +//! +//! The shipped binary must not see the operator's `HOME` or profile. Suites +//! used to copy the same environment set, success check, and env restore. + +use std::ffi::{OsStr, OsString}; +use std::path::Path; +use std::process::Command; + +/// Restores one process environment variable when the guard drops. +pub struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + pub fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + // SAFETY: callers that pin process-wide env hold that binary's env + // lock for the guard's whole life. + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } + + /// Removes `key` for the guard's lifetime, so tests can exercise the + /// no-override path. + pub fn unset(key: &'static str) -> Self { + let previous = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + if let Some(previous) = self.previous.take() { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); + } + } + } +} + +/// Points a child process at a throwaway home and profile. +/// +/// This is the command-env subset shared by daemon journeys. It does not +/// detach the process group or pin `XDG_RUNTIME_DIR`; callers that need the +/// full hermetic daemon environment still use `apply_tracedecay_home_env`. +pub fn apply_isolated_profile_env(command: &mut Command, home: &Path, profile: &Path) { + command + .env("HOME", home) + .env("USERPROFILE", home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("TRACEDECAY_DATA_DIR", profile) + .env("TRACEDECAY_GLOBAL_DB", profile.join("global.db")) + .env("TRACEDECAY_TEST_ALLOW_INCOMPLETE_HOLDER_SCAN", "1"); +} + +/// Runs a command and returns stdout, panicking with both streams on failure. +pub fn run_ok(command: &mut Command, label: &str) -> Vec { + let output = command + .output() + .unwrap_or_else(|error| panic!("{label} could not run: {error}")); + assert!( + output.status.success(), + "{label} failed with {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output.stdout +} diff --git a/tests/tool_sweep_suite/feedback_journey.py b/tests/tool_sweep_suite/feedback_journey.py index e4b0f92287..6849438ad4 100644 --- a/tests/tool_sweep_suite/feedback_journey.py +++ b/tests/tool_sweep_suite/feedback_journey.py @@ -19,7 +19,7 @@ from orchestrator import _phase_environment, run_bounded_command from runner import McpClient -from outcomes import objects, response_problem_code +from outcomes import decode_json_stdout, objects, response_problem_code RUNTIME = Path.cwd() PROJECT = RUNTIME / "project" @@ -37,10 +37,7 @@ def write_json(name: str, value: object) -> None: def command(args: list[str], *, timeout: int = 120, cwd: Path | None = None) -> dict[str, object]: started = time.monotonic() run = subprocess.run(args, cwd=cwd or PROJECT, text=True, capture_output=True, timeout=timeout) - try: - stdout: object = json.loads(run.stdout) - except json.JSONDecodeError: - stdout = run.stdout + stdout: object = decode_json_stdout(run.stdout) return {"command": args, "exit_code": run.returncode, "elapsed_ms": round((time.monotonic() - started) * 1000), "stdout": stdout, "stderr": run.stderr} diff --git a/tests/tool_sweep_suite/feedback_read_journey.py b/tests/tool_sweep_suite/feedback_read_journey.py index 4dffc022c8..9fc8759345 100644 --- a/tests/tool_sweep_suite/feedback_read_journey.py +++ b/tests/tool_sweep_suite/feedback_read_journey.py @@ -17,7 +17,7 @@ from journeys import _application_payload from orchestrator import _phase_environment, _terminate -from outcomes import objects +from outcomes import decode_json_stdout, objects from runner import MOUNT_RETRY_BUDGET_S, MOUNT_RETRY_DELAY_S, McpClient MISSING = "feedback_journey_missing_symbol" @@ -30,10 +30,7 @@ def write(out: Path, name: str, value: object) -> None: def command(args: list[str], project: Path, out: Path, name: str) -> dict: result = subprocess.run(args, cwd=project, capture_output=True, text=True, timeout=180) - try: - stdout = json.loads(result.stdout) - except json.JSONDecodeError: - stdout = result.stdout + stdout = decode_json_stdout(result.stdout) row = dict(command=args, exit_code=result.returncode, stdout=stdout, stderr=result.stderr) write(out, name, row) return row diff --git a/tests/tool_sweep_suite/orchestrator.py b/tests/tool_sweep_suite/orchestrator.py index cf951a0a5c..c8252fe5bb 100644 --- a/tests/tool_sweep_suite/orchestrator.py +++ b/tests/tool_sweep_suite/orchestrator.py @@ -5,7 +5,6 @@ import argparse from dataclasses import asdict, dataclass -from datetime import UTC, datetime import json import os from pathlib import Path @@ -24,6 +23,7 @@ if str(SUITE_DIR) not in sys.path: sys.path.insert(0, str(SUITE_DIR)) +from outcomes import utc_now from runner import READ_EFFECTS, SweepError, load_manifest, tool_policy @@ -67,10 +67,6 @@ def expired(self) -> bool: return self.remaining_s() == 0.0 -def _utc_now() -> str: - return datetime.now(UTC).isoformat().replace("+00:00", "Z") - - def catalog_entries(manifest: dict[str, Any]) -> list[dict[str, str]]: """Give every live public item one aggregate identity, without a count target.""" entries: list[dict[str, str]] = [] @@ -466,7 +462,7 @@ def run(args: argparse.Namespace) -> int: """Run discovery first, then isolated mutation journeys, always emitting final artifacts.""" deadline = WholeRunDeadline(args.whole_run_deadline_ms) report: dict[str, Any] = { - "schema_version": 1, "phase": "aggregate", "started_at": _utc_now(), "entries": [], + "schema_version": 1, "phase": "aggregate", "started_at": utc_now(), "entries": [], "summary": {"discovered": 0, "completed": 0, "failed": 0, "cancelled": 0}, } phases: list[PhaseResult] = [] @@ -505,8 +501,8 @@ def run(args: argparse.Namespace) -> int: report["fatal"] = str(error) report["fatal_problem_code"] = "tool_sweep.orchestration_failed" finally: - report["started_at"] = report.get("started_at", _utc_now()) - report["finished_at"] = _utc_now() + report["started_at"] = report.get("started_at", utc_now()) + report["finished_at"] = utc_now() report["phases"] = [phase.value() for phase in phases] write_final_report(args.out, report) return 0 if "fatal" not in report and report["summary"]["failed"] == 0 and report["summary"]["cancelled"] == 0 else 1 diff --git a/tests/tool_sweep_suite/outcomes.py b/tests/tool_sweep_suite/outcomes.py index 42c0aa346b..ed8ad73d9d 100644 --- a/tests/tool_sweep_suite/outcomes.py +++ b/tests/tool_sweep_suite/outcomes.py @@ -4,9 +4,21 @@ import json import re +from datetime import UTC, datetime from typing import Any +def utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def decode_json_stdout(stdout: str) -> Any: + try: + return json.loads(stdout) + except json.JSONDecodeError: + return stdout + + _NOT_FOUND = re.compile( r"\b(?:not found|no (?:matching |such )?(?:symbol|node|file|fact|session|response|result)s?\b)", re.IGNORECASE, diff --git a/tests/tool_sweep_suite/runner.py b/tests/tool_sweep_suite/runner.py index bb811bae15..f0b9a2fb9c 100644 --- a/tests/tool_sweep_suite/runner.py +++ b/tests/tool_sweep_suite/runner.py @@ -5,7 +5,6 @@ import argparse from contextlib import contextmanager -from datetime import UTC, datetime import hashlib import json import os @@ -52,6 +51,7 @@ response_problem_code, response_handle, text_blocks, + utc_now, ) def response_row( @@ -246,10 +246,6 @@ def load_manifest(path: Path) -> dict[str, Any]: return canonical -def _utc_now() -> str: - return datetime.now(UTC).isoformat().replace("+00:00", "Z") - - class McpClient: """A bounded stdio MCP client backed by the release binary under test.""" @@ -2863,7 +2859,7 @@ def run_phase(args: argparse.Namespace) -> int: report: dict[str, Any] = { "schema_version": 1, "phase": args.phase, - "started_at": _utc_now(), + "started_at": utc_now(), "entries": [], "summary": {"discovered": 0, "completed": 0, "failed": 0, "cancelled": 0}, } @@ -2956,7 +2952,7 @@ def run_phase(args: argparse.Namespace) -> int: client.close() report["entries"] = sorted(report["entries"], key=lambda row: (row["kind"], row["name"])) report["summary"] = _phase_summary(report["entries"]) - report["finished_at"] = _utc_now() + report["finished_at"] = utc_now() _write_phase_report(args.out, report) return 0 if "fatal" not in report and report["summary"]["failed"] == 0 else 1