Compare commits
36 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993dc4a525 | ||
|
|
4bfcc694bd | ||
|
|
1ce5f72819 | ||
|
|
60d1d81777 | ||
|
|
e1a2bef109 | ||
|
|
7eefe9087a | ||
|
|
cdee9da609 | ||
|
|
e20c64fa1a | ||
|
|
93c34e7387 | ||
|
|
4f9f4de911 | ||
|
|
0e3c8a1668 | ||
|
|
95a97d63d3 | ||
|
|
6983fd83da | ||
|
|
572aebd282 | ||
|
|
065f106584 | ||
|
|
4cb254801f | ||
|
|
8c11bfd3b8 | ||
|
|
aefe07009b | ||
|
|
5098780f5b | ||
|
|
08717e51c0 | ||
|
|
1f9707f30a | ||
|
|
19087b0168 | ||
|
|
dabd110f92 | ||
|
|
1e3872124b | ||
|
|
bf711f8047 | ||
|
|
13f5e509e4 | ||
|
|
cfca6161b0 | ||
|
|
99724853a0 | ||
|
|
0990969722 | ||
|
|
d01a3841d9 | ||
|
|
3d83bd747e | ||
|
|
c6ed6a41d8 | ||
|
|
e5922e9c90 | ||
|
|
fdf596e58a | ||
|
|
2618a33fd5 | ||
|
|
d3843f3131 |
29 changed files with 1850 additions and 848 deletions
24
.forgejo/workflows/check.yml
Normal file
24
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: check
|
||||||
|
|
||||||
|
# Fast-fail lint/test on short-lived work branches, before it ever reaches
|
||||||
|
# main and triggers a dev-track release build.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['feature/**', 'fix/**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: clippy
|
||||||
|
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked --features full -- -D warnings
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cd src && bash ci/build.sh cargo test --workspace --locked --features full
|
||||||
82
.forgejo/workflows/dev-release.yml
Normal file
82
.forgejo/workflows/dev-release.yml
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
name: dev release
|
||||||
|
|
||||||
|
# Publishes a dev-track build on every push to `main` (the trunk
|
||||||
|
# branch — there is no separate `dev` branch). See bread-ecosystem's
|
||||||
|
# docs/release-channels.md for the release-track policy this is part of.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['main']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch main --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: cd src && bash ci/build.sh cargo build --release --locked --workspace --features full
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cd src && bash ci/build.sh cargo test --release --locked --workspace --features full
|
||||||
|
|
||||||
|
- name: compute dev version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src
|
||||||
|
# Base the dev version off the latest published stable tag,
|
||||||
|
# not Cargo.toml — Cargo.toml can go stale relative to the last
|
||||||
|
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
|
||||||
|
# breadpaper), which would make a dev build sort as OLDER than
|
||||||
|
# what's already installed and bakery would correctly refuse it.
|
||||||
|
LATEST_TAG="$(git ls-remote --tags --refs \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
||||||
|
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
|
||||||
|
if [ -n "${LATEST_TAG}" ]; then
|
||||||
|
CUR="${LATEST_TAG}"
|
||||||
|
else
|
||||||
|
CUR="$(grep -m1 '^version' breadsearch/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||||
|
fi
|
||||||
|
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||||
|
SHA="$(git rev-parse --short HEAD)"
|
||||||
|
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||||
|
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PKG_DIR="/srv/breadway-dl/dev/breadsearch/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
for bin in breadsearch breadmill; do
|
||||||
|
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
strip "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
||||||
|
done
|
||||||
|
cp src/packaging/breadmill.service "${PKG_DIR}/"
|
||||||
|
cp src/config.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadsearch/latest"
|
||||||
|
|
||||||
|
# No GitHub Release upload — dev, like the other non-stable track,
|
||||||
|
# is only distributed via dl.breadway.dev/dev/.
|
||||||
|
- name: regenerate dev index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||||
|
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
||||||
|
# workflows run close together on the same self-hosted runner.
|
||||||
|
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||||
|
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||||
|
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||||
|
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||||
63
.forgejo/workflows/rc-release.yml
Normal file
63
.forgejo/workflows/rc-release.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
name: beta (rc) release
|
||||||
|
|
||||||
|
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
|
||||||
|
# pushed to `main` — there is no separate `beta` branch; "freezing" is
|
||||||
|
# just pausing pushes to main while an RC gets tested. See
|
||||||
|
# bread-ecosystem's docs/release-channels.md for the release-track policy.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: ${{ contains(github.ref_name, '-rc.') }}
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: cd src && bash ci/build.sh cargo build --release --locked --workspace --features full
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cd src && bash ci/build.sh cargo test --release --locked --workspace --features full
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/beta/breadsearch/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
for bin in breadsearch breadmill; do
|
||||||
|
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
strip "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
||||||
|
done
|
||||||
|
cp src/packaging/breadmill.service "${PKG_DIR}/"
|
||||||
|
cp src/config.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadsearch/latest"
|
||||||
|
|
||||||
|
# No GitHub Release upload — beta, like dev, is only distributed via
|
||||||
|
# dl.breadway.dev/beta/.
|
||||||
|
- name: regenerate beta index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||||
|
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
||||||
|
# workflows run close together on the same self-hosted runner.
|
||||||
|
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||||
|
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||||
|
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||||
83
.forgejo/workflows/release.yml
Normal file
83
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
# --features full (breadmill's npu+rocm+cuda combined) ships one binary
|
||||||
|
# that can use any backend at runtime via --npu/--rocm/--cuda or
|
||||||
|
# `backend` in config.toml. All three are ort load-dynamic (dlopen)
|
||||||
|
# EPs, so this doesn't require the NPU/ROCm/CUDA toolkits to be
|
||||||
|
# present on the build host — see breadmill/Cargo.toml.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ ! -f src/ci/build.sh ]; then
|
||||||
|
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd src && bash ci/build.sh cargo build --release --locked --workspace --features full || {
|
||||||
|
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cd src && bash ci/build.sh cargo test --release --locked --workspace --features full
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/breadsearch/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
for bin in breadsearch breadmill; do
|
||||||
|
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
strip "${PKG_DIR}/${bin}-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
||||||
|
done
|
||||||
|
cp src/packaging/breadmill.service "${PKG_DIR}/"
|
||||||
|
cp src/config.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/breadsearch/latest"
|
||||||
|
|
||||||
|
- name: regenerate index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||||
|
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||||
|
|
||||||
|
- name: upload to GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/breadsearch/${VERSION}"
|
||||||
|
gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadsearch \
|
||||||
|
--title "breadsearch v${VERSION}" --generate-notes 2>/dev/null || true
|
||||||
|
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadsearch \
|
||||||
|
"${PKG_DIR}/breadsearch-x86_64" \
|
||||||
|
"${PKG_DIR}/breadmill-x86_64" \
|
||||||
|
"${PKG_DIR}/breadsearch-x86_64.sha256" \
|
||||||
|
"${PKG_DIR}/breadmill-x86_64.sha256" \
|
||||||
|
--clobber
|
||||||
66
.github/workflows/release.yml
vendored
66
.github/workflows/release.yml
vendored
|
|
@ -1,66 +0,0 @@
|
||||||
name: release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ["v*"]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
DL_DIR: /srv/breadway-dl
|
|
||||||
ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: [self-hosted, hestia]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: install build deps
|
|
||||||
run: sudo apt-get install -y libgtk-4-dev librsvg2-dev libdbus-1-dev pkg-config 2>/dev/null || true
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
run: cargo build --release --locked
|
|
||||||
|
|
||||||
- name: test
|
|
||||||
run: cargo test --release --locked --workspace
|
|
||||||
|
|
||||||
- name: prepare artifacts
|
|
||||||
run: |
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
PKG_DIR="${DL_DIR}/breadsearch/${VERSION}"
|
|
||||||
mkdir -p "${PKG_DIR}"
|
|
||||||
for bin in breadsearch breadmill; do
|
|
||||||
cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
|
||||||
strip "${PKG_DIR}/${bin}-x86_64"
|
|
||||||
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
|
||||||
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
|
||||||
done
|
|
||||||
cp packaging/breadmill.service "${PKG_DIR}/"
|
|
||||||
cp config.example.toml "${PKG_DIR}/"
|
|
||||||
cp bakery.toml "${PKG_DIR}/bakery.toml"
|
|
||||||
ln -sfn "${VERSION}" "${DL_DIR}/breadsearch/latest"
|
|
||||||
|
|
||||||
- name: ensure bread-ecosystem
|
|
||||||
run: |
|
|
||||||
rm -rf "${ECOSYSTEM_DIR}"
|
|
||||||
git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}"
|
|
||||||
|
|
||||||
- name: regenerate index.json
|
|
||||||
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh"
|
|
||||||
|
|
||||||
- name: upload to GitHub Release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
PKG_DIR="${DL_DIR}/breadsearch/${VERSION}"
|
|
||||||
gh release create "${GITHUB_REF_NAME}" \
|
|
||||||
--title "breadsearch v${VERSION}" --generate-notes 2>/dev/null || true
|
|
||||||
gh release upload "${GITHUB_REF_NAME}" \
|
|
||||||
"${PKG_DIR}/breadsearch-x86_64" \
|
|
||||||
"${PKG_DIR}/breadmill-x86_64" \
|
|
||||||
"${PKG_DIR}/breadsearch-x86_64.sha256" \
|
|
||||||
"${PKG_DIR}/breadmill-x86_64.sha256" \
|
|
||||||
--clobber
|
|
||||||
50
AGENTS.md
Normal file
50
AGENTS.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# AGENTS.md — Repo hygiene
|
||||||
|
|
||||||
|
Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a
|
||||||
|
short map of the binaries. It is not user-facing project documentation.
|
||||||
|
|
||||||
|
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
|
||||||
|
— read and follow it for any git, branch, or release work here (the
|
||||||
|
single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work,
|
||||||
|
etc). Don't improvise a different workflow. The short version: there is one
|
||||||
|
long-lived branch, `main` — no `dev` or `beta` branch exists. `main`
|
||||||
|
auto-publishes a dev-track build on every push. "Beta" and "stable" are both
|
||||||
|
just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track
|
||||||
|
build, push a plain `vX.Y.Z` tag to cut the signed stable release.
|
||||||
|
"Freezing" for stabilization means pausing pushes to `main`, not moving a
|
||||||
|
branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model
|
||||||
|
after `main` was found to have silently rotted out of sync with `dev`/`beta`
|
||||||
|
across most repos in this ecosystem.
|
||||||
|
|
||||||
|
When starting work on a new feature, create branch `feature/<feature-name>`.
|
||||||
|
When working on a bug or issue, create branch `fix/<issue you are fixing>`.
|
||||||
|
|
||||||
|
## Remotes
|
||||||
|
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
||||||
|
- `github` — GitHub mirror. Push both when publishing.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
- `dev-release.yml` triggers on `push: branches: ['main']`.
|
||||||
|
- `rc-release.yml` triggers on `vX.Y.Z-rc.N` tag pushes (beta track).
|
||||||
|
- `release.yml` triggers on any other `v*` tag push (stable).
|
||||||
|
None of these run on plain commits or PRs beyond what's listed.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two binaries + a shared lib:
|
||||||
|
|
||||||
|
| Crate | Role |
|
||||||
|
|---|---|
|
||||||
|
| `breadsearch-shared` | XDG paths, config, IPC types, Unix-socket client |
|
||||||
|
| `breadmill` | Daemon: walk → extract → chunk → embed → index; serves queries |
|
||||||
|
| `breadsearch` | GTK4 layer-shell overlay: ranked document hits from breadmill |
|
||||||
|
|
||||||
|
breadsearch is document/meaning search. breadbox is the app launcher. They
|
||||||
|
are not the same overlay.
|
||||||
|
|
||||||
|
`--screenshot` (`breadsearch/src/screenshot.rs`) captures the search panel
|
||||||
|
through `bread-screenshots`; do not rewrite it just to retarget the crate pin.
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||||
|
- Don't rewrite `screenshot.rs` as part of pin/docs work.
|
||||||
84
CONTRIBUTING.md
Normal file
84
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
`breadsearch` — Semantic system-wide search for BOS.
|
||||||
|
|
||||||
|
Part of the bread ecosystem; this repo follows the same branch/release
|
||||||
|
workflow as every other ecosystem product.
|
||||||
|
|
||||||
|
## Branches
|
||||||
|
|
||||||
|
There is one long-lived branch: **`main`**. All day-to-day work lands here.
|
||||||
|
Every push to `main` automatically builds and publishes a **dev-track**
|
||||||
|
build (see Tracks below) — a real install you can test before cutting
|
||||||
|
anything more formal.
|
||||||
|
|
||||||
|
New work — features and bug fixes alike — goes on a short-lived branch:
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/<short-name>
|
||||||
|
fix/<issue-number-or-short-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
|
||||||
|
branches get deleted on merge — they never accumulate the kind of drift a
|
||||||
|
second long-lived branch does.
|
||||||
|
|
||||||
|
## The release cycle
|
||||||
|
|
||||||
|
There's no separate `beta` or release branch — "stable" and "beta" are both
|
||||||
|
just **tags** on `main`, not branches that need to be kept in sync:
|
||||||
|
|
||||||
|
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
|
||||||
|
auto-publishes a dev build — install it with `bakery track set dev` and
|
||||||
|
`bakery update --all`, then fix anything broken with another push.
|
||||||
|
2. When you want to stabilize before a real release, tag a release
|
||||||
|
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
|
||||||
|
both remotes). That tag alone triggers a beta-track build —
|
||||||
|
"freezing" is just pausing pushes to `main` while you test it, not a
|
||||||
|
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
|
||||||
|
3. Once an RC has gone without issues, tag the real release:
|
||||||
|
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
|
||||||
|
signed stable release build.
|
||||||
|
|
||||||
|
## Tracks, from a user's perspective
|
||||||
|
|
||||||
|
```
|
||||||
|
bakery track show # what you're currently on (defaults to stable)
|
||||||
|
bakery track set dev # or beta, or stable
|
||||||
|
bakery update --all # pull the latest build on your current track
|
||||||
|
```
|
||||||
|
|
||||||
|
| Track | What it is | Published from |
|
||||||
|
|--------|-----------|-----------------|
|
||||||
|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
|
||||||
|
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
|
||||||
|
| `dev` | Bleeding edge | `main`, on every push |
|
||||||
|
|
||||||
|
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
|
||||||
|
latest published stable tag, so they always sort as newer than what you
|
||||||
|
have installed — no manual version bumping needed. Beta versions are just
|
||||||
|
the RC tag itself (already valid semver, already sorts below the real
|
||||||
|
release it's a candidate for).
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release --workspace --features full
|
||||||
|
cargo test --release --workspace --features full
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
- `dev-release.yml` — triggered on push to `main`.
|
||||||
|
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
|
||||||
|
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
|
||||||
|
stable release.
|
||||||
|
|
||||||
|
All CI runs on a self-hosted runner; nothing runs automatically on plain
|
||||||
|
commits or PRs beyond the track builds above. See
|
||||||
|
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
|
||||||
|
for the full policy, including how a new product gets wired onto these tracks.
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
Open an issue on this repo's Forgejo tracker.
|
||||||
1074
Cargo.lock
generated
1074
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -80,3 +80,9 @@ Start from `breadbox/breadbox/src/main.rs`. **Reuse verbatim:** the gtk4-layer-s
|
||||||
- nomic prefixes + mean-pool + normalize must match between index and query or recall collapses.
|
- nomic prefixes + mean-pool + normalize must match between index and query or recall collapses.
|
||||||
- `ort` linking: prefer the crate's downloaded/bundled ONNX Runtime to avoid version skew with Arch's `onnxruntime`.
|
- `ort` linking: prefer the crate's downloaded/bundled ONNX Runtime to avoid version skew with Arch's `onnxruntime`.
|
||||||
- Office formats (docx/odt) are best-effort in v1; md/txt/org/pdf are the reliable path.
|
- Office formats (docx/odt) are best-effort in v1; md/txt/org/pdf are the reliable path.
|
||||||
|
- GPU EPs (ROCm/CUDA/OpenVINO) fail to register silently at the ONNX Runtime level and fall back to CPU — always
|
||||||
|
check startup logs for `Successfully registered` before trusting a GPU build is actually accelerating. See
|
||||||
|
[README: GPU backend notes](README.md#gpu-backend-notes) for the MIGraphX-vs-ROCMExecutionProvider distinction
|
||||||
|
and the per-shape JIT-compile-and-cache behavior that matters for interactive query latency.
|
||||||
|
- CUDA and OpenVINO are compile-checked only — no NVIDIA or Intel GPU hardware in this dev environment (AMD-only)
|
||||||
|
to runtime-verify against, unlike ROCm which was confirmed end-to-end on real hardware.
|
||||||
|
|
|
||||||
62
EVENTS.md
Normal file
62
EVENTS.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# breadsearch — bread event integration
|
||||||
|
|
||||||
|
breadsearch is a standalone document-search overlay: it works exactly the
|
||||||
|
same with or without `breadd` running. When breadd *is* present, the
|
||||||
|
`breadsearch` GTK overlay publishes events into the shared bread automation
|
||||||
|
fabric. See the parent `bread` repo's `Documentation.md` — specifically its
|
||||||
|
"Namespaces" and "Integrating a bread\* app" sections — for the general
|
||||||
|
convention this follows.
|
||||||
|
|
||||||
|
App id: **`search`**. Transport: `bread-utils`'s `bread_client` module
|
||||||
|
(feature `bread-client`) — the overlay links it directly. Each `emit` is
|
||||||
|
its own short-lived connection (`BreadClient::emit` is fire-and-forget,
|
||||||
|
the same stance as `bread-emit`). Command verbs are only received while
|
||||||
|
`breadsearch listen` is running — that process holds the
|
||||||
|
`bread.command.search.**` subscription open. breadmill, the indexing
|
||||||
|
daemon, does not talk to breadd.
|
||||||
|
|
||||||
|
## Events published (`bread.search.*`)
|
||||||
|
|
||||||
|
| Event | Data | When |
|
||||||
|
|-------|------|------|
|
||||||
|
| `bread.search.opened` | `{}` | The overlay window maps (the search panel is shown). |
|
||||||
|
| `bread.search.opened_result` | `{ "path": "<hit path>" }` | The user opens a hit — Enter / click opens the file, Ctrl+Enter reveals its folder. `path` is the hit's document path, not the parent folder. |
|
||||||
|
| `bread.search.open.done` | `{}` | `bread.command.search.open` was received and `breadsearch` was spawned. This is the command confirmation, not proof the overlay mapped — the spawned process is the same PID-file toggle as a keybind. |
|
||||||
|
| `bread.search.open.failed` | `{ "error": "<message>" }` | `bread.command.search.open` was received but this binary could not be started. |
|
||||||
|
|
||||||
|
## Commands honored (`bread.command.search.*`)
|
||||||
|
|
||||||
|
These are only received while `breadsearch listen` is running. Publishing a
|
||||||
|
command with no subscriber is a silent no-op — that is the documented
|
||||||
|
bread convention, not a breadsearch bug.
|
||||||
|
|
||||||
|
| Verb | Data | Effect |
|
||||||
|
|------|------|--------|
|
||||||
|
| `open` | none | Same as running `breadsearch` (PID-file toggle: show the overlay, or dismiss it if it is already up). Emits `bread.search.open.done` / `.failed`. |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.spawn(function()
|
||||||
|
bread.emit("bread.command.search.open")
|
||||||
|
bread.wait("bread.search.open.done", { timeout = 5000 })
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not implemented: extra verbs
|
||||||
|
|
||||||
|
There is no `query` / `close` / `reindex` command verb. breadmill already
|
||||||
|
has its own query socket; inventing a bus query plane would be a new
|
||||||
|
product surface. If/when that exists, add the corresponding
|
||||||
|
`bread.command.search.*` verb at the same time, not stubbed as a no-op
|
||||||
|
ahead of it.
|
||||||
|
|
||||||
|
## Fail-safe behavior
|
||||||
|
|
||||||
|
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||||
|
(`BreadClient::emit` never blocks or errors the caller) and the
|
||||||
|
command subscription simply never receives anything — breadsearch's
|
||||||
|
overlay and breadmill's indexing/query path are entirely unaffected.
|
||||||
|
- If breadd restarts, the command subscription reconnects automatically
|
||||||
|
(`BreadClient::subscribe`'s background thread has its own backoff
|
||||||
|
loop); no restart of `breadsearch listen` is needed.
|
||||||
|
- If `breadsearch listen` is not running, commands are a graceful no-op at
|
||||||
|
the bus (no subscriber). The overlay CLI still works.
|
||||||
84
README.md
84
README.md
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
Semantic document search for Bread OS. Type a concept — not a keyword — and get ranked hits from your documents.
|
Semantic document search for Bread OS. Type a concept — not a keyword — and get ranked hits from your documents.
|
||||||
|
|
||||||
|
This is **not** [breadbox](https://git.breadway.dev/Breadway/breadbox). breadsearch is document/meaning search over your files; breadbox is the app launcher (fuzzy `.desktop` launch). They are separate overlays with separate binaries, sockets, and keybinds — even though the search panel was originally forked from breadbox's launcher UI.
|
||||||
|
|
||||||
Two binaries in one Cargo workspace:
|
Two binaries in one Cargo workspace:
|
||||||
|
|
||||||
- **breadmill** — background daemon. Walks configured directories, extracts text, chunks and embeds documents with [nomic-embed-text-v1.5](https://huggingface.co/nomic-ai/nomic-embed-text-v1.5) (768-dim ONNX), stores vectors in an HNSW index (usearch) backed by SQLite metadata, and serves queries over a Unix socket. Watches for filesystem changes and re-indexes incrementally.
|
- **breadmill** — background daemon. Walks configured directories, extracts text, chunks and embeds documents with [nomic-embed-text-v1.5](https://huggingface.co/nomic-ai/nomic-embed-text-v1.5) (768-dim ONNX), stores vectors in an HNSW index (usearch) backed by SQLite metadata, and serves queries over a Unix socket. Watches for filesystem changes and re-indexes incrementally.
|
||||||
|
|
@ -22,13 +24,45 @@ Optional features:
|
||||||
| Feature | What it adds |
|
| Feature | What it adds |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `npu` | AMD XDNA NPU via VitisAI ONNX Runtime EP (requires Ryzen AI SDK) |
|
| `npu` | AMD XDNA NPU via VitisAI ONNX Runtime EP (requires Ryzen AI SDK) |
|
||||||
| `rocm` | AMD iGPU via ROCm ONNX Runtime EP |
|
| `rocm` | AMD iGPU via the MIGraphX ONNX Runtime EP (ROCm-backed) |
|
||||||
|
| `cuda` | NVIDIA GPU via the CUDA ONNX Runtime EP |
|
||||||
|
| `openvino` | Intel iGPU/dGPU (Arc) via the OpenVINO ONNX Runtime EP |
|
||||||
|
| `full` | All four of the above in one binary |
|
||||||
|
|
||||||
```
|
```
|
||||||
# NPU build
|
# NPU build
|
||||||
cargo build --release -p breadmill --features npu
|
cargo build --release -p breadmill --features npu
|
||||||
|
|
||||||
|
# ROCm (AMD iGPU/dGPU) build
|
||||||
|
cargo build --release -p breadmill --features rocm
|
||||||
|
|
||||||
|
# CUDA (NVIDIA GPU) build
|
||||||
|
cargo build --release -p breadmill --features cuda
|
||||||
|
|
||||||
|
# OpenVINO (Intel iGPU/dGPU) build
|
||||||
|
cargo build --release -p breadmill --features openvino
|
||||||
|
|
||||||
|
# All backends in one binary (what the release build ships)
|
||||||
|
cargo build --release -p breadmill --features full
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`rocm`/`cuda`/`npu`/`openvino` all use `ort`'s `load-dynamic` mode: at runtime,
|
||||||
|
breadmill dlopens whatever `libonnxruntime.so` the dynamic linker resolves (or
|
||||||
|
`ORT_DYLIB_PATH` if set). GPU acceleration only works if that ONNX Runtime
|
||||||
|
build actually has the matching execution provider compiled in — breadmill
|
||||||
|
logs a clear `Successfully registered` / `not enabled in this build` line for
|
||||||
|
this at startup (see [GPU backend notes](#gpu-backend-notes) below).
|
||||||
|
|
||||||
|
Because all four are dlopen-based, `full` doesn't require the NPU/ROCm/CUDA/
|
||||||
|
OpenVINO toolkits to be installed at build time — only at run time, and only
|
||||||
|
for whichever single backend you actually select via
|
||||||
|
`--npu`/`--rocm`/`--cuda`/`--openvino` or `backend` in config.toml. The
|
||||||
|
**released binaries are built with `full`**: same binary works CPU-only out
|
||||||
|
of the box, and picks up NPU/ROCm/CUDA/OpenVINO acceleration on a machine
|
||||||
|
that has the matching ONNX Runtime available, without needing a different
|
||||||
|
download. An explicit `--npu`/`--rocm`/`--cuda`/`--openvino` flag always
|
||||||
|
overrides `backend` in config.toml, not the other way around.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
**1. Fetch the embedding model** (~550 MB, downloaded once from Hugging Face):
|
**1. Fetch the embedding model** (~550 MB, downloaded once from Hugging Face):
|
||||||
|
|
@ -89,6 +123,8 @@ breadmill status
|
||||||
# Backend flags (requires the matching Cargo feature)
|
# Backend flags (requires the matching Cargo feature)
|
||||||
breadmill --npu
|
breadmill --npu
|
||||||
breadmill --rocm
|
breadmill --rocm
|
||||||
|
breadmill --cuda
|
||||||
|
breadmill --openvino
|
||||||
```
|
```
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
@ -109,7 +145,7 @@ snippet_len = 200 # max characters in result snippet
|
||||||
[model]
|
[model]
|
||||||
name = "nomic-embed-text-v1.5"
|
name = "nomic-embed-text-v1.5"
|
||||||
dim = 768
|
dim = 768
|
||||||
backend = "cpu" # "cpu", "npu", or "rocm"
|
backend = "cpu" # "cpu", "npu", "rocm", "cuda", or "openvino"
|
||||||
```
|
```
|
||||||
|
|
||||||
`roots` and `excludes` support `~/` expansion. The index respects `.gitignore` files found during the walk.
|
`roots` and `excludes` support `~/` expansion. The index respects `.gitignore` files found during the walk.
|
||||||
|
|
@ -124,6 +160,50 @@ Set `backend = "npu"` in config (or pass `--npu`) when running a build compiled
|
||||||
4. `/etc/vaip_config.json`
|
4. `/etc/vaip_config.json`
|
||||||
5. `/opt/xilinx/vaip_config.json`
|
5. `/opt/xilinx/vaip_config.json`
|
||||||
|
|
||||||
|
### GPU backend notes
|
||||||
|
|
||||||
|
`rocm`, `cuda`, and `openvino` all need a system ONNX Runtime that was
|
||||||
|
actually built with the matching execution provider — the crate's own
|
||||||
|
downloaded binary is CPU-only. Point `ORT_DYLIB_PATH` at one, or install a
|
||||||
|
distro package that provides `libonnxruntime.so` with the EP baked in and
|
||||||
|
let the dynamic linker find it.
|
||||||
|
|
||||||
|
**ROCm (`--rocm` / `backend = "rocm"`)** targets ONNX Runtime's **MIGraphX**
|
||||||
|
execution provider, not the classic `ROCMExecutionProvider`. Distro
|
||||||
|
ROCm-enabled ONNX Runtime packages (e.g. Arch's `onnxruntime-rocm`) are
|
||||||
|
commonly built with `--use_migraphx` rather than `--use_rocm`, so this is the
|
||||||
|
EP that's actually available in practice; the classic ROCm EP needs a bespoke
|
||||||
|
`--use_rocm` build most distros don't package. Startup logs a
|
||||||
|
`Successfully registered `MIGraphXExecutionProvider`` line when it's really
|
||||||
|
active — check for it if in doubt, since a failed GPU EP registration falls
|
||||||
|
back to CPU silently at the ONNX Runtime level (breadmill's own log line is
|
||||||
|
only a statement of intent, not a confirmation).
|
||||||
|
|
||||||
|
MIGraphX JIT-compiles the model per distinct input sequence length and caches
|
||||||
|
the compiled kernel to disk (each compile takes ~60–120s and produces a
|
||||||
|
~500MB `.mxr` file). Set `ORT_MIGRAPHX_MODEL_CACHE_PATH=/path/to/cache` so
|
||||||
|
that cost is paid once per shape instead of on every daemon restart. Because
|
||||||
|
query text length varies, expect an occasional multi-second stall the first
|
||||||
|
time a new token length is seen — fine for background document indexing,
|
||||||
|
noticeable for interactive query embedding.
|
||||||
|
|
||||||
|
**CUDA (`--cuda` / `backend = "cuda"`)** targets the standard
|
||||||
|
`CUDAExecutionProvider` and needs a CUDA-enabled ONNX Runtime + a working
|
||||||
|
CUDA/cuDNN install. Unverified on real NVIDIA hardware in this repo — only
|
||||||
|
compile-checked, since development happened on an AMD-only machine.
|
||||||
|
|
||||||
|
**OpenVINO (`--openvino` / `backend = "openvino"`)** targets
|
||||||
|
`OpenVINOExecutionProvider` with `device_type = "GPU"`, covering both Intel
|
||||||
|
iGPUs and Arc dGPUs through the same EP (OpenVINO abstracts Intel's whole
|
||||||
|
hardware line — CPU/GPU/NPU — behind one provider and a device-type string).
|
||||||
|
Needs an OpenVINO-enabled ONNX Runtime and the OpenVINO runtime itself
|
||||||
|
installed. Its options go through a generic key/value FFI interface rather
|
||||||
|
than a fixed C struct (unlike MIGraphX's `OrtMIGraphXProviderOptions`), so it
|
||||||
|
should be less exposed to the kind of ABI-version-skew crash MIGraphX hit —
|
||||||
|
but that's inference from reading the EP's design, not something verified
|
||||||
|
against real Intel GPU hardware. Also unverified on real hardware in this
|
||||||
|
repo — only compile-checked, for the same reason as CUDA.
|
||||||
|
|
||||||
## Runtime paths
|
## Runtime paths
|
||||||
|
|
||||||
| Purpose | Path |
|
| Purpose | Path |
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,30 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadmill"
|
name = "breadmill"
|
||||||
version = "0.1.0"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
npu = ["ort/vitis", "ort/load-dynamic"]
|
npu = ["ort/vitis", "ort/load-dynamic"]
|
||||||
rocm = ["ort/rocm", "ort/load-dynamic"]
|
# "rocm" targets the MIGraphX execution provider, not ONNX Runtime's classic
|
||||||
|
# ROCMExecutionProvider (--use_rocm build). Distro ROCm-enabled ONNX Runtime
|
||||||
|
# packages (e.g. Arch's onnxruntime-rocm) are commonly built with --use_migraphx
|
||||||
|
# instead; the classic ROCm EP needs a bespoke --use_rocm build that ships
|
||||||
|
# libonnxruntime_providers_rocm.so, which most distros don't package.
|
||||||
|
rocm = ["ort/migraphx", "ort/load-dynamic"]
|
||||||
|
cuda = ["ort/cuda", "ort/load-dynamic"]
|
||||||
|
# Intel iGPU/dGPU (Arc) + CPU via OpenVINO. One EP covers Intel's whole
|
||||||
|
# hardware line by device_type string ("CPU"/"GPU"/"GPU.0"/"NPU"/"HETERO:...");
|
||||||
|
# we always request "GPU" since CPU is already covered by the cpu backend.
|
||||||
|
openvino = ["ort/openvino", "ort/load-dynamic"]
|
||||||
|
# All backends in one binary. Safe to combine: every backend here uses
|
||||||
|
# ort's load-dynamic (dlopen) mode, so none of this links against an actual
|
||||||
|
# NPU/ROCm/CUDA/OpenVINO toolkit at build time — which ONNX Runtime actually
|
||||||
|
# gets loaded (and thus which EPs are really available) is decided at
|
||||||
|
# runtime by ORT_DYLIB_PATH / the dynamic linker, per the --npu/--rocm/
|
||||||
|
# --cuda/--openvino flag or `backend` config value in use for that run.
|
||||||
|
full = ["npu", "rocm", "cuda", "openvino"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "breadmill"
|
name = "breadmill"
|
||||||
|
|
@ -25,6 +42,11 @@ breadsearch-shared = { path = "../breadsearch-shared" }
|
||||||
# needed for the plain CPU path even in the npu build.
|
# needed for the plain CPU path even in the npu build.
|
||||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing", "download-binaries", "tls-native", "copy-dylibs", "api-23"] }
|
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing", "download-binaries", "tls-native", "copy-dylibs", "api-23"] }
|
||||||
tokenizers = "0"
|
tokenizers = "0"
|
||||||
|
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
|
|
||||||
|
# Surfaces ort's own EP-registration tracing (e.g. a GPU EP silently failing to
|
||||||
|
# register and falling back to CPU) as visible log output instead of nowhere.
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
# Vector index
|
# Vector index
|
||||||
usearch = "2"
|
usearch = "2"
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,9 @@ fn split_by_chars(chunk: Chunk, max_chars: usize) -> Vec<Chunk> {
|
||||||
let text = &chunk.text;
|
let text = &chunk.text;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut seg_start = 0usize;
|
let mut seg_start = 0usize;
|
||||||
let mut count = 0usize;
|
|
||||||
|
|
||||||
for (byte_idx, _) in text.char_indices() {
|
for (count, (byte_idx, _)) in text.char_indices().enumerate() {
|
||||||
if count > 0 && count % max_chars == 0 {
|
if count > 0 && count.is_multiple_of(max_chars) {
|
||||||
result.push(Chunk {
|
result.push(Chunk {
|
||||||
text: text[seg_start..byte_idx].to_string(),
|
text: text[seg_start..byte_idx].to_string(),
|
||||||
start: chunk.start + seg_start,
|
start: chunk.start + seg_start,
|
||||||
|
|
@ -92,7 +91,6 @@ fn split_by_chars(chunk: Chunk, max_chars: usize) -> Vec<Chunk> {
|
||||||
});
|
});
|
||||||
seg_start = byte_idx;
|
seg_start = byte_idx;
|
||||||
}
|
}
|
||||||
count += 1;
|
|
||||||
}
|
}
|
||||||
if seg_start < text.len() {
|
if seg_start < text.len() {
|
||||||
result.push(Chunk {
|
result.push(Chunk {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use ort::{
|
use bread_onnx::embedding::EmbeddingSession;
|
||||||
session::{Session, builder::{GraphOptimizationLevel, SessionBuilder}},
|
use bread_onnx::Provider;
|
||||||
value::Tensor,
|
|
||||||
};
|
|
||||||
use tokenizers::Tokenizer;
|
|
||||||
|
|
||||||
const DOCUMENT_PREFIX: &str = "search_document: ";
|
const DOCUMENT_PREFIX: &str = "search_document: ";
|
||||||
const QUERY_PREFIX: &str = "search_query: ";
|
const QUERY_PREFIX: &str = "search_query: ";
|
||||||
|
|
@ -19,28 +16,31 @@ pub enum Backend {
|
||||||
/// AMD XDNA NPU via the VitisAI ONNX Runtime execution provider.
|
/// AMD XDNA NPU via the VitisAI ONNX Runtime execution provider.
|
||||||
/// `cache_dir` is used to store the compiled NPU model between runs.
|
/// `cache_dir` is used to store the compiled NPU model between runs.
|
||||||
Npu { cache_dir: PathBuf },
|
Npu { cache_dir: PathBuf },
|
||||||
/// AMD iGPU via the ROCm ONNX Runtime execution provider.
|
/// AMD iGPU via the MIGraphX ONNX Runtime execution provider (ROCm-backed).
|
||||||
|
/// Distro ROCm ONNX Runtime builds (e.g. Arch's onnxruntime-rocm) are
|
||||||
|
/// commonly compiled with `--use_migraphx`, not `--use_rocm`, so this
|
||||||
|
/// targets `MIGraphXExecutionProvider` rather than the classic
|
||||||
|
/// `ROCMExecutionProvider`.
|
||||||
Rocm,
|
Rocm,
|
||||||
|
/// NVIDIA GPU via the CUDA ONNX Runtime execution provider.
|
||||||
|
Cuda,
|
||||||
|
/// Intel iGPU/dGPU (Arc) via the OpenVINO ONNX Runtime execution
|
||||||
|
/// provider, requesting device_type "GPU". `cache_dir` stores OpenVINO's
|
||||||
|
/// compiled-model blobs between runs (its own `with_cache_dir`, not an
|
||||||
|
/// env var — unlike MIGraphX this doesn't need a workaround).
|
||||||
|
OpenVino { cache_dir: PathBuf },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct OrtEmbedder {
|
pub struct OrtEmbedder {
|
||||||
session: Session,
|
inner: EmbeddingSession,
|
||||||
tokenizer: Tokenizer,
|
|
||||||
dim: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OrtEmbedder {
|
impl OrtEmbedder {
|
||||||
pub fn load(model_path: &Path, tokenizer_path: &Path, dim: usize, backend: Backend) -> Result<Self, String> {
|
pub fn load(model_path: &Path, tokenizer_path: &Path, dim: usize, backend: Backend) -> Result<Self, String> {
|
||||||
let builder = Session::builder()
|
let provider = to_provider(backend)?;
|
||||||
.map_err(|e| e.to_string())?
|
let inner = EmbeddingSession::load(model_path, tokenizer_path, dim, MAX_SEQ_LEN, &[provider])
|
||||||
.with_optimization_level(GraphOptimizationLevel::All)
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(Self { inner })
|
||||||
let mut builder = configure_eps(builder, &backend)?;
|
|
||||||
let session = builder.commit_from_file(model_path).map_err(|e| e.to_string())?;
|
|
||||||
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(Self { session, tokenizer, dim })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn embed_document(&mut self, text: &str) -> Result<Vec<f32>, String> {
|
pub fn embed_document(&mut self, text: &str) -> Result<Vec<f32>, String> {
|
||||||
|
|
@ -53,165 +53,71 @@ impl OrtEmbedder {
|
||||||
|
|
||||||
fn embed_with_prefix(&mut self, text: &str, prefix: &str) -> Result<Vec<f32>, String> {
|
fn embed_with_prefix(&mut self, text: &str, prefix: &str) -> Result<Vec<f32>, String> {
|
||||||
let input = format!("{}{}", prefix, text);
|
let input = format!("{}{}", prefix, text);
|
||||||
|
self.inner.embed(&input).map_err(|e| e.to_string())
|
||||||
let encoding = self
|
|
||||||
.tokenizer
|
|
||||||
.encode(input, true)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let mut ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
|
|
||||||
let mut mask: Vec<i64> = encoding
|
|
||||||
.get_attention_mask()
|
|
||||||
.iter()
|
|
||||||
.map(|&x| x as i64)
|
|
||||||
.collect();
|
|
||||||
let mut type_ids: Vec<i64> = encoding
|
|
||||||
.get_type_ids()
|
|
||||||
.iter()
|
|
||||||
.map(|&x| x as i64)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if ids.len() > MAX_SEQ_LEN {
|
|
||||||
eprintln!(
|
|
||||||
"breadmill: truncating {} tokens to {} (chunk too large)",
|
|
||||||
ids.len(),
|
|
||||||
MAX_SEQ_LEN
|
|
||||||
);
|
|
||||||
ids.truncate(MAX_SEQ_LEN);
|
|
||||||
mask.truncate(MAX_SEQ_LEN);
|
|
||||||
type_ids.truncate(MAX_SEQ_LEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
let seq_len = ids.len() as i64;
|
|
||||||
|
|
||||||
let id_tensor =
|
|
||||||
Tensor::<i64>::from_array((vec![1i64, seq_len], ids.clone())).map_err(|e| e.to_string())?;
|
|
||||||
let mask_tensor =
|
|
||||||
Tensor::<i64>::from_array((vec![1i64, seq_len], mask.clone())).map_err(|e| e.to_string())?;
|
|
||||||
let type_tensor =
|
|
||||||
Tensor::<i64>::from_array((vec![1i64, seq_len], type_ids)).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let outputs = self
|
|
||||||
.session
|
|
||||||
.run(ort::inputs! {
|
|
||||||
"input_ids" => id_tensor,
|
|
||||||
"attention_mask" => mask_tensor,
|
|
||||||
"token_type_ids" => type_tensor,
|
|
||||||
})
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// last_hidden_state: shape [1, seq_len, dim]
|
|
||||||
let (shape, data) = outputs["last_hidden_state"]
|
|
||||||
.try_extract_tensor::<f32>()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let actual_seq = shape[1] as usize;
|
|
||||||
let actual_dim = shape[2] as usize;
|
|
||||||
|
|
||||||
// Mean-pool over non-padding positions
|
|
||||||
let mut result = vec![0.0f32; actual_dim];
|
|
||||||
let mut count = 0usize;
|
|
||||||
|
|
||||||
for t in 0..actual_seq {
|
|
||||||
if mask[t] > 0 {
|
|
||||||
for d in 0..actual_dim {
|
|
||||||
result[d] += data[t * actual_dim + d];
|
|
||||||
}
|
|
||||||
count += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
// ---- Backend -> bread_onnx::Provider ----------------------------------------
|
||||||
for x in &mut result {
|
//
|
||||||
*x /= count as f32;
|
// `bread_onnx::session::build_session` (via `EmbeddingSession::load`) is the
|
||||||
}
|
// shared session-builder + EP-fallback + loud-logging code every EP branch
|
||||||
}
|
// below used to hand-roll separately (`configure_eps`/`npu_session`/
|
||||||
|
// `rocm_session`/`cuda_session`/`openvino_session`). What's genuinely
|
||||||
|
// specific to this crate — its cargo feature gates (npu/rocm/cuda/openvino),
|
||||||
|
// and NPU's `vaip_config.json` discovery — stays here.
|
||||||
|
|
||||||
l2_normalize(&mut result);
|
fn to_provider(backend: Backend) -> Result<Provider, String> {
|
||||||
|
|
||||||
// Clamp/pad to configured dim
|
|
||||||
result.truncate(self.dim);
|
|
||||||
while result.len() < self.dim {
|
|
||||||
result.push(0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn l2_normalize(v: &mut Vec<f32>) {
|
|
||||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
||||||
if norm > 1e-10 {
|
|
||||||
for x in v.iter_mut() {
|
|
||||||
*x /= norm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Execution provider selection -------------------------------------------
|
|
||||||
|
|
||||||
fn configure_eps(builder: SessionBuilder, backend: &Backend) -> Result<SessionBuilder, String> {
|
|
||||||
match backend {
|
match backend {
|
||||||
Backend::Cpu => Ok(builder),
|
Backend::Cpu => Ok(Provider::Cpu),
|
||||||
Backend::Npu { cache_dir } => npu_session(builder, cache_dir),
|
Backend::Npu { cache_dir } => npu_provider(cache_dir),
|
||||||
Backend::Rocm => rocm_session(builder),
|
Backend::Rocm => rocm_provider(),
|
||||||
|
Backend::Cuda => cuda_provider(),
|
||||||
|
Backend::OpenVino { cache_dir } => Ok(Provider::OpenVino { device_type: "GPU".to_string(), cache_dir }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "npu")]
|
#[cfg(feature = "npu")]
|
||||||
fn npu_session(builder: SessionBuilder, cache_dir: &Path) -> Result<SessionBuilder, String> {
|
fn npu_provider(cache_dir: PathBuf) -> Result<Provider, String> {
|
||||||
let vitis_ep = build_vitis_ep(cache_dir)?;
|
let vaip_config = find_vaip_config()?;
|
||||||
eprintln!("breadmill: using NPU (VitisAI) execution provider");
|
|
||||||
if std::env::var("ORT_DYLIB_PATH").is_err() {
|
if std::env::var("ORT_DYLIB_PATH").is_err() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"breadmill: hint — set ORT_DYLIB_PATH to the Ryzen AI SDK ORT, e.g.:\n \
|
"breadmill: hint — set ORT_DYLIB_PATH to the Ryzen AI SDK ORT, e.g.:\n \
|
||||||
ORT_DYLIB_PATH=~/.local/share/ryzen-ai-1.7.1/lib/libonnxruntime.so"
|
ORT_DYLIB_PATH=~/.local/share/ryzen-ai-1.7.1/lib/libonnxruntime.so"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
builder
|
Ok(Provider::Vitis {
|
||||||
.with_execution_providers([vitis_ep, ort::ep::CPU::default().build()])
|
config_file: vaip_config,
|
||||||
.map_err(|e| e.to_string())
|
cache_dir: cache_dir.join("npu"),
|
||||||
|
cache_key: "nomic-embed-text-v1.5".to_string(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "npu"))]
|
#[cfg(not(feature = "npu"))]
|
||||||
fn npu_session(builder: SessionBuilder, _cache_dir: &Path) -> Result<SessionBuilder, String> {
|
fn npu_provider(_cache_dir: PathBuf) -> Result<Provider, String> {
|
||||||
eprintln!("breadmill: NPU backend requested but not compiled in (rebuild with --features npu); using CPU");
|
eprintln!("breadmill: NPU backend requested but not compiled in (rebuild with --features npu); using CPU");
|
||||||
Ok(builder)
|
Ok(Provider::Cpu)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- VitisAI EP (NPU) -------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(feature = "npu")]
|
|
||||||
fn build_vitis_ep(cache_dir: &Path) -> Result<ort::ep::ExecutionProviderDispatch, String> {
|
|
||||||
let vaip_config = find_vaip_config()?;
|
|
||||||
let npu_cache = cache_dir.join("npu");
|
|
||||||
std::fs::create_dir_all(&npu_cache).map_err(|e| e.to_string())?;
|
|
||||||
eprintln!("breadmill: vaip_config: {}", vaip_config.display());
|
|
||||||
eprintln!("breadmill: NPU model cache: {}", npu_cache.display());
|
|
||||||
Ok(ort::ep::Vitis::default()
|
|
||||||
.with_config_file(vaip_config.to_string_lossy())
|
|
||||||
.with_cache_dir(npu_cache.to_string_lossy())
|
|
||||||
.with_cache_key("nomic-embed-text-v1.5")
|
|
||||||
.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- ROCm EP (AMD iGPU) -----------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(feature = "rocm")]
|
#[cfg(feature = "rocm")]
|
||||||
fn rocm_session(builder: SessionBuilder) -> Result<SessionBuilder, String> {
|
fn rocm_provider() -> Result<Provider, String> {
|
||||||
eprintln!("breadmill: using ROCm execution provider (device 0)");
|
Ok(Provider::MiGraphX { device_id: 0 })
|
||||||
builder
|
|
||||||
.with_execution_providers([
|
|
||||||
ort::execution_providers::ROCmExecutionProvider::default().build(),
|
|
||||||
ort::ep::CPU::default().build(),
|
|
||||||
])
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "rocm"))]
|
#[cfg(not(feature = "rocm"))]
|
||||||
fn rocm_session(builder: SessionBuilder) -> Result<SessionBuilder, String> {
|
fn rocm_provider() -> Result<Provider, String> {
|
||||||
eprintln!("breadmill: ROCm backend requested but not compiled in (rebuild with --features rocm); using CPU");
|
eprintln!("breadmill: ROCm backend requested but not compiled in (rebuild with --features rocm); using CPU");
|
||||||
Ok(builder)
|
Ok(Provider::Cpu)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "cuda")]
|
||||||
|
fn cuda_provider() -> Result<Provider, String> {
|
||||||
|
Ok(Provider::Cuda { device_id: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "cuda"))]
|
||||||
|
fn cuda_provider() -> Result<Provider, String> {
|
||||||
|
eprintln!("breadmill: CUDA backend requested but not compiled in (rebuild with --features cuda); using CPU");
|
||||||
|
Ok(Provider::Cpu)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate the VitisAI EP config file required by the AMD Ryzen AI SDK.
|
/// Locate the VitisAI EP config file required by the AMD Ryzen AI SDK.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use ignore::WalkBuilder;
|
||||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind};
|
use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::{embed::OrtEmbedder, extract, chunk, power, store::Store};
|
use crate::{embed::OrtEmbedder, extract, chunk, power, store::Store, sync_ext::MutexExt};
|
||||||
|
|
||||||
pub struct SharedState {
|
pub struct SharedState {
|
||||||
pub store: Mutex<Store>,
|
pub store: Mutex<Store>,
|
||||||
|
|
@ -64,7 +64,7 @@ impl Indexer {
|
||||||
pub fn full_reindex(&self) {
|
pub fn full_reindex(&self) {
|
||||||
eprintln!("breadmill: full reindex triggered");
|
eprintln!("breadmill: full reindex triggered");
|
||||||
{
|
{
|
||||||
let mut store = self.state.store.lock().unwrap();
|
let store = self.state.store.lock_recover();
|
||||||
// Clear all state
|
// Clear all state
|
||||||
let _ = store.conn.execute_batch("DELETE FROM chunks; DELETE FROM files;");
|
let _ = store.conn.execute_batch("DELETE FROM chunks; DELETE FROM files;");
|
||||||
let _ = store.index.reserve(4096);
|
let _ = store.index.reserve(4096);
|
||||||
|
|
@ -87,7 +87,7 @@ impl Indexer {
|
||||||
|
|
||||||
// Snapshot existing indexed files
|
// Snapshot existing indexed files
|
||||||
let known: HashMap<String, (i64, String)> = {
|
let known: HashMap<String, (i64, String)> = {
|
||||||
let store = self.state.store.lock().unwrap();
|
let store = self.state.store.lock_recover();
|
||||||
store.all_files()
|
store.all_files()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -155,7 +155,7 @@ impl Indexer {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !to_delete.is_empty() {
|
if !to_delete.is_empty() {
|
||||||
let mut store = self.state.store.lock().unwrap();
|
let mut store = self.state.store.lock_recover();
|
||||||
for path in to_delete {
|
for path in to_delete {
|
||||||
eprintln!("breadmill: removing deleted file: {}", path);
|
eprintln!("breadmill: removing deleted file: {}", path);
|
||||||
let _ = store.delete_file(&path);
|
let _ = store.delete_file(&path);
|
||||||
|
|
@ -163,7 +163,7 @@ impl Indexer {
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = {
|
let count = {
|
||||||
let store = self.state.store.lock().unwrap();
|
let store = self.state.store.lock_recover();
|
||||||
let n = store.chunk_count();
|
let n = store.chunk_count();
|
||||||
let _ = store.save_index(&self.state_dir);
|
let _ = store.save_index(&self.state_dir);
|
||||||
n
|
n
|
||||||
|
|
@ -238,7 +238,7 @@ impl Indexer {
|
||||||
self.handle_fs_event(&path);
|
self.handle_fs_event(&path);
|
||||||
}
|
}
|
||||||
let count = {
|
let count = {
|
||||||
let store = self.state.store.lock().unwrap();
|
let store = self.state.store.lock_recover();
|
||||||
let n = store.chunk_count();
|
let n = store.chunk_count();
|
||||||
let _ = store.save_index(&self.state_dir);
|
let _ = store.save_index(&self.state_dir);
|
||||||
n
|
n
|
||||||
|
|
@ -261,7 +261,7 @@ impl Indexer {
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
let path_str = path.to_string_lossy().into_owned();
|
let path_str = path.to_string_lossy().into_owned();
|
||||||
// File deleted — remove from index
|
// File deleted — remove from index
|
||||||
let mut store = self.state.store.lock().unwrap();
|
let mut store = self.state.store.lock_recover();
|
||||||
let _ = store.delete_file(&path_str);
|
let _ = store.delete_file(&path_str);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -309,7 +309,7 @@ impl Indexer {
|
||||||
|
|
||||||
// Check if hash changed (catches content changes without mtime change)
|
// Check if hash changed (catches content changes without mtime change)
|
||||||
{
|
{
|
||||||
let store = self.state.store.lock().unwrap();
|
let store = self.state.store.lock_recover();
|
||||||
if let Ok(files) = store.all_files() {
|
if let Ok(files) = store.all_files() {
|
||||||
if files.iter().any(|f| f.path == path_str && f.hash == hash) {
|
if files.iter().any(|f| f.path == path_str && f.hash == hash) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -322,20 +322,22 @@ impl Indexer {
|
||||||
// for natural-language files.
|
// for natural-language files.
|
||||||
let chunks = chunk::chunk_text(&text, 400, 80, 2_000);
|
let chunks = chunk::chunk_text(&text, 400, 80, 2_000);
|
||||||
eprintln!("breadmill: embedding {} ({} chars, {} chunks)", path_str, text.len(), chunks.len());
|
eprintln!("breadmill: embedding {} ({} chars, {} chunks)", path_str, text.len(), chunks.len());
|
||||||
let mut embedder_guard = self.state.embedder.lock().unwrap();
|
|
||||||
|
|
||||||
if !self.state.model_ready.load(Ordering::Relaxed) {
|
if !self.state.model_ready.load(Ordering::Relaxed) {
|
||||||
eprintln!("breadmill: model not ready, skipping embed for {}", path_str);
|
eprintln!("breadmill: model not ready, skipping embed for {}", path_str);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let embedder = match embedder_guard.as_mut() {
|
// Confirm the embedder is actually present before committing to
|
||||||
Some(e) => e,
|
// clearing this file's old chunks below — same check as before,
|
||||||
None => return,
|
// just without holding the embedder lock past this one glance (see
|
||||||
};
|
// the per-chunk locking in the loop for why).
|
||||||
|
if self.state.embedder.lock_recover().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut store = self.state.store.lock().unwrap();
|
let mut store = self.state.store.lock_recover();
|
||||||
let _ = store.delete_file(path_str); // remove old chunks/vectors first
|
let _ = store.delete_file(path_str); // remove old chunks/vectors first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,9 +346,18 @@ impl Indexer {
|
||||||
|
|
||||||
for (i, chunk) in chunks.iter().enumerate() {
|
for (i, chunk) in chunks.iter().enumerate() {
|
||||||
eprintln!("breadmill: embed chunk {}/{} ({} chars) for {}", i + 1, chunks.len(), chunk.text.len(), path_str);
|
eprintln!("breadmill: embed chunk {}/{} ({} chars) for {}", i + 1, chunks.len(), chunk.text.len(), path_str);
|
||||||
match embedder.embed_document(&chunk.text) {
|
// Lock the embedder only around this single chunk's embed call —
|
||||||
|
// this used to be held for the whole file's chunk loop, so one
|
||||||
|
// large file needing a fresh MIGraphX JIT compile (60-120s for a
|
||||||
|
// new sequence length) could block every query (serve.rs locks
|
||||||
|
// this same mutex) for the entire file, not just one chunk.
|
||||||
|
let embed_result = match self.state.embedder.lock_recover().as_mut() {
|
||||||
|
Some(embedder) => embedder.embed_document(&chunk.text),
|
||||||
|
None => break, // model was unloaded mid-scan; stop here
|
||||||
|
};
|
||||||
|
match embed_result {
|
||||||
Ok(embedding) => {
|
Ok(embedding) => {
|
||||||
let mut store = self.state.store.lock().unwrap();
|
let mut store = self.state.store.lock_recover();
|
||||||
// Ensure file row exists before inserting chunks (FK constraint)
|
// Ensure file row exists before inserting chunks (FK constraint)
|
||||||
let _ = store.upsert_file(path_str, mtime, &hash);
|
let _ = store.upsert_file(path_str, mtime, &hash);
|
||||||
let _ = store.insert_chunk(
|
let _ = store.insert_chunk(
|
||||||
|
|
@ -367,7 +378,7 @@ impl Indexer {
|
||||||
eprintln!("breadmill: no chunks embedded for {}", path_str);
|
eprintln!("breadmill: no chunks embedded for {}", path_str);
|
||||||
// Record the file so the mtime+hash check skips it on the next startup
|
// Record the file so the mtime+hash check skips it on the next startup
|
||||||
// rather than re-entering the same embed-fail loop.
|
// rather than re-entering the same embed-fail loop.
|
||||||
let store = self.state.store.lock().unwrap();
|
let store = self.state.store.lock_recover();
|
||||||
let _ = store.upsert_file(path_str, mtime, &hash);
|
let _ = store.upsert_file(path_str, mtime, &hash);
|
||||||
} else {
|
} else {
|
||||||
// Increment live so `status` reflects progress before the full scan ends.
|
// Increment live so `status` reflects progress before the full scan ends.
|
||||||
|
|
@ -405,9 +416,9 @@ fn sha256_str(bytes: &[u8]) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expand_home(path: &str) -> PathBuf {
|
pub fn expand_home(path: &str) -> PathBuf {
|
||||||
if path.starts_with("~/") {
|
if let Some(rest) = path.strip_prefix("~/") {
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
||||||
PathBuf::from(home).join(&path[2..])
|
PathBuf::from(home).join(rest)
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from(path)
|
PathBuf::from(path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use std::{
|
use std::{
|
||||||
io::Read,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, atomic::Ordering},
|
sync::{Arc, atomic::Ordering},
|
||||||
};
|
};
|
||||||
|
|
@ -13,10 +12,12 @@ mod indexer;
|
||||||
mod power;
|
mod power;
|
||||||
mod serve;
|
mod serve;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod sync_ext;
|
||||||
|
|
||||||
use embed::{Backend, OrtEmbedder};
|
use embed::{Backend, OrtEmbedder};
|
||||||
use indexer::{Indexer, SharedState};
|
use indexer::{Indexer, SharedState};
|
||||||
use store::Store;
|
use store::Store;
|
||||||
|
use sync_ext::MutexExt;
|
||||||
|
|
||||||
const MODEL_URL: &str =
|
const MODEL_URL: &str =
|
||||||
"https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/onnx/model.onnx";
|
"https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/onnx/model.onnx";
|
||||||
|
|
@ -24,17 +25,29 @@ const TOKENIZER_URL: &str =
|
||||||
"https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/tokenizer.json";
|
"https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/tokenizer.json";
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Surfaces ort's EP-registration warnings/errors (e.g. a GPU EP silently
|
||||||
|
// falling back to CPU) by default, without requiring RUST_LOG to be set.
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,ort=info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
let raw_args: Vec<String> = std::env::args().collect();
|
let raw_args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
// Extract global flags before command dispatch.
|
// Extract global flags before command dispatch.
|
||||||
let use_npu = raw_args.iter().any(|a| a == "--npu");
|
let use_npu = raw_args.iter().any(|a| a == "--npu");
|
||||||
let use_rocm = raw_args.iter().any(|a| a == "--rocm");
|
let use_rocm = raw_args.iter().any(|a| a == "--rocm");
|
||||||
|
let use_cuda = raw_args.iter().any(|a| a == "--cuda");
|
||||||
|
let use_openvino = raw_args.iter().any(|a| a == "--openvino");
|
||||||
|
|
||||||
// Build a view of argv without backend flags for command matching.
|
// Build a view of argv without backend flags for command matching.
|
||||||
|
let backend_flags = ["--npu", "--rocm", "--cuda", "--openvino"];
|
||||||
let args: Vec<&str> = raw_args
|
let args: Vec<&str> = raw_args
|
||||||
.iter()
|
.iter()
|
||||||
.skip(1)
|
.skip(1)
|
||||||
.filter(|a| a.as_str() != "--npu" && a.as_str() != "--rocm")
|
.filter(|a| !backend_flags.contains(&a.as_str()))
|
||||||
.map(|s| s.as_str())
|
.map(|s| s.as_str())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -49,7 +62,7 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("--reindex") | Some("reindex") => {
|
Some("--reindex") | Some("reindex") => {
|
||||||
if let Err(e) = run_daemon(true, use_npu, use_rocm) {
|
if let Err(e) = run_daemon(true, use_npu, use_rocm, use_cuda, use_openvino) {
|
||||||
eprintln!("breadmill: {}", e);
|
eprintln!("breadmill: {}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +79,7 @@ fn main() {
|
||||||
cli_status();
|
cli_status();
|
||||||
}
|
}
|
||||||
None | Some("serve") | Some("--serve") => {
|
None | Some("serve") | Some("--serve") => {
|
||||||
if let Err(e) = run_daemon(false, use_npu, use_rocm) {
|
if let Err(e) = run_daemon(false, use_npu, use_rocm, use_cuda, use_openvino) {
|
||||||
eprintln!("breadmill: {}", e);
|
eprintln!("breadmill: {}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +87,7 @@ fn main() {
|
||||||
Some(cmd) => {
|
Some(cmd) => {
|
||||||
eprintln!("breadmill: unknown command: {}", cmd);
|
eprintln!("breadmill: unknown command: {}", cmd);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"usage: breadmill [serve|reindex|fetch-model|query <text>|status] [--npu|--rocm] [--version]"
|
"usage: breadmill [serve|reindex|fetch-model|query <text>|status] [--npu|--rocm|--cuda|--openvino] [--version]"
|
||||||
);
|
);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +96,13 @@ fn main() {
|
||||||
|
|
||||||
// ---- Daemon -----------------------------------------------------------------
|
// ---- Daemon -----------------------------------------------------------------
|
||||||
|
|
||||||
fn run_daemon(force_reindex: bool, use_npu: bool, use_rocm: bool) -> Result<(), String> {
|
fn run_daemon(
|
||||||
|
force_reindex: bool,
|
||||||
|
use_npu: bool,
|
||||||
|
use_rocm: bool,
|
||||||
|
use_cuda: bool,
|
||||||
|
use_openvino: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
let config = breadsearch_shared::Config::load();
|
let config = breadsearch_shared::Config::load();
|
||||||
let state_dir = breadsearch_shared::state_dir();
|
let state_dir = breadsearch_shared::state_dir();
|
||||||
let cache_dir = breadsearch_shared::cache_dir();
|
let cache_dir = breadsearch_shared::cache_dir();
|
||||||
|
|
@ -95,34 +114,70 @@ fn run_daemon(force_reindex: bool, use_npu: bool, use_rocm: bool) -> Result<(),
|
||||||
std::fs::create_dir_all(&state_dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&state_dir).map_err(|e| e.to_string())?;
|
||||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let backend = if use_npu || config.model.backend == "npu" {
|
// CLI flags always override config — otherwise an explicit --cuda/--npu on
|
||||||
|
// the command line would silently lose to an unrelated `backend = "..."`
|
||||||
|
// already sitting in config.toml, since that's whatever earlier branch a
|
||||||
|
// fixed if/else-if priority order happened to check first.
|
||||||
|
let backend_name = if use_npu {
|
||||||
|
"npu"
|
||||||
|
} else if use_rocm {
|
||||||
|
"rocm"
|
||||||
|
} else if use_cuda {
|
||||||
|
"cuda"
|
||||||
|
} else if use_openvino {
|
||||||
|
"openvino"
|
||||||
|
} else {
|
||||||
|
config.model.backend.as_str()
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend = match backend_name {
|
||||||
|
"npu" => {
|
||||||
eprintln!("breadmill: NPU backend selected");
|
eprintln!("breadmill: NPU backend selected");
|
||||||
Backend::Npu { cache_dir: cache_dir.clone() }
|
Backend::Npu { cache_dir: cache_dir.clone() }
|
||||||
} else if use_rocm || config.model.backend == "rocm" {
|
}
|
||||||
|
"rocm" => {
|
||||||
eprintln!("breadmill: ROCm backend selected");
|
eprintln!("breadmill: ROCm backend selected");
|
||||||
Backend::Rocm
|
Backend::Rocm
|
||||||
} else {
|
}
|
||||||
Backend::Cpu
|
"cuda" => {
|
||||||
|
eprintln!("breadmill: CUDA backend selected");
|
||||||
|
Backend::Cuda
|
||||||
|
}
|
||||||
|
"openvino" => {
|
||||||
|
eprintln!("breadmill: OpenVINO backend selected");
|
||||||
|
Backend::OpenVino { cache_dir: cache_dir.clone() }
|
||||||
|
}
|
||||||
|
_ => Backend::Cpu,
|
||||||
};
|
};
|
||||||
|
|
||||||
let store = Store::open(&state_dir, dim)?;
|
let store = Store::open(&state_dir, dim)?;
|
||||||
let state = Arc::new(SharedState::new(store));
|
let state = Arc::new(SharedState::new(store));
|
||||||
|
|
||||||
// Load embedder if model files present
|
// Load the embedder on a background thread — an OpenVINO/CUDA/etc EP
|
||||||
|
// compile can take minutes or hang outright, and doing this inline used
|
||||||
|
// to block the socket bind below until it finished. That turned "model
|
||||||
|
// still loading" into an indistinguishable "connection refused" for
|
||||||
|
// every client, including the GUI, for as long as the load took. The
|
||||||
|
// socket now opens immediately; serve.rs already answers "model not
|
||||||
|
// ready" (via `model_ready`) for any request that arrives before the
|
||||||
|
// background load finishes.
|
||||||
let model_dir = model_dir(&cache_dir);
|
let model_dir = model_dir(&cache_dir);
|
||||||
let model_path = model_dir.join("model.onnx");
|
let model_path = model_dir.join("model.onnx");
|
||||||
let tokenizer_path = model_dir.join("tokenizer.json");
|
let tokenizer_path = model_dir.join("tokenizer.json");
|
||||||
|
|
||||||
if model_path.exists() && tokenizer_path.exists() {
|
if model_path.exists() && tokenizer_path.exists() {
|
||||||
|
let state_clone = Arc::clone(&state);
|
||||||
|
std::thread::spawn(move || {
|
||||||
eprintln!("breadmill: loading model...");
|
eprintln!("breadmill: loading model...");
|
||||||
match OrtEmbedder::load(&model_path, &tokenizer_path, dim, backend) {
|
match OrtEmbedder::load(&model_path, &tokenizer_path, dim, backend) {
|
||||||
Ok(embedder) => {
|
Ok(embedder) => {
|
||||||
*state.embedder.lock().unwrap() = Some(embedder);
|
*state_clone.embedder.lock_recover() = Some(embedder);
|
||||||
state.model_ready.store(true, Ordering::Relaxed);
|
state_clone.model_ready.store(true, Ordering::Relaxed);
|
||||||
eprintln!("breadmill: model loaded");
|
eprintln!("breadmill: model loaded");
|
||||||
}
|
}
|
||||||
Err(e) => eprintln!("breadmill: model load failed: {} — run --fetch-model", e),
|
Err(e) => eprintln!("breadmill: model load failed: {} — run --fetch-model", e),
|
||||||
}
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"breadmill: model files not found in {} — run: breadmill --fetch-model",
|
"breadmill: model files not found in {} — run: breadmill --fetch-model",
|
||||||
|
|
@ -170,29 +225,10 @@ fn download_if_missing(url: &str, dest: &Path) -> Result<(), String> {
|
||||||
eprintln!(" already present: {}", dest.display());
|
eprintln!(" already present: {}", dest.display());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
// Shared with breadarrd's own (previously reqwest/async, now also this
|
||||||
eprintln!(" downloading {} ...", url);
|
// same sync/ureq implementation) model downloader — see
|
||||||
let agent = ureq::AgentBuilder::new()
|
// bread_onnx::download's doc comment.
|
||||||
.timeout(std::time::Duration::from_secs(300))
|
bread_onnx::download::ensure_file(url, dest, None).map_err(|e| e.to_string())?;
|
||||||
.build();
|
|
||||||
|
|
||||||
let response = agent.get(url).call().map_err(|e| e.to_string())?;
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
response
|
|
||||||
.into_reader()
|
|
||||||
.read_to_end(&mut bytes)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return Err(format!("empty download from {}", url));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write atomically via temp file
|
|
||||||
let tmp = dest.with_extension("tmp");
|
|
||||||
std::fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
|
|
||||||
std::fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
eprintln!(" saved {} ({:.1} MB)", dest.display(), bytes.len() as f64 / 1_048_576.0);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use std::{
|
||||||
use breadsearch_shared::{Request, Response, StatusInfo};
|
use breadsearch_shared::{Request, Response, StatusInfo};
|
||||||
|
|
||||||
use crate::indexer::SharedState;
|
use crate::indexer::SharedState;
|
||||||
|
use crate::sync_ext::MutexExt;
|
||||||
|
|
||||||
pub fn run(socket_path: &Path, state: Arc<SharedState>, snippet_len: usize, search_limit: usize) {
|
pub fn run(socket_path: &Path, state: Arc<SharedState>, snippet_len: usize, search_limit: usize) {
|
||||||
let _ = std::fs::remove_file(socket_path);
|
let _ = std::fs::remove_file(socket_path);
|
||||||
|
|
@ -86,7 +87,7 @@ fn dispatch(
|
||||||
}
|
}
|
||||||
|
|
||||||
let embedding = {
|
let embedding = {
|
||||||
let mut embedder = state.embedder.lock().unwrap();
|
let mut embedder = state.embedder.lock_recover();
|
||||||
match embedder.as_mut() {
|
match embedder.as_mut() {
|
||||||
Some(e) => match e.embed_query(&query) {
|
Some(e) => match e.embed_query(&query) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
|
|
@ -101,7 +102,7 @@ fn dispatch(
|
||||||
};
|
};
|
||||||
|
|
||||||
let limit = limit.min(search_limit).max(1);
|
let limit = limit.min(search_limit).max(1);
|
||||||
let store = state.store.lock().unwrap();
|
let store = state.store.lock_recover();
|
||||||
|
|
||||||
match store.search(&embedding, limit, snippet_len) {
|
match store.search(&embedding, limit, snippet_len) {
|
||||||
Ok(hits) => Response::Hits { hits },
|
Ok(hits) => Response::Hits { hits },
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ use usearch::{Index, IndexOptions, MetricKind, ScalarKind, new_index};
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
pub conn: Connection,
|
pub conn: Connection,
|
||||||
pub index: Index,
|
pub index: Index,
|
||||||
pub dim: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// usearch::Index wraps a raw C++ pointer; access is serialized by the Mutex<Store>.
|
// usearch::Index wraps a raw C++ pointer; access is serialized by the Mutex<Store>.
|
||||||
|
|
@ -57,14 +56,37 @@ impl Store {
|
||||||
let index = new_index(&options).map_err(|e| e.to_string())?;
|
let index = new_index(&options).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
if idx_path.exists() {
|
if idx_path.exists() {
|
||||||
index
|
// NOTE: this only catches corruption that usearch's loader
|
||||||
.load(idx_path.to_str().unwrap())
|
// itself detects and reports as an `Err` (e.g. a recognizable
|
||||||
|
// but wrong/incompatible header). Verified experimentally: a
|
||||||
|
// file that doesn't even look like a usearch index at all (pure
|
||||||
|
// garbage bytes) crashes the *process* with a SIGSEGV inside the
|
||||||
|
// native loader rather than returning an `Err` — no amount of
|
||||||
|
// Rust-side `Result`/`catch_unwind` handling can intercept that,
|
||||||
|
// it's a native-code robustness gap in the usearch library
|
||||||
|
// itself. This recovery path is still worth having (it's the
|
||||||
|
// difference between "won't start" and "rebuilds and starts"
|
||||||
|
// for the errors it *does* catch), but it is not a complete
|
||||||
|
// guarantee against every possible corrupt file. The atomic
|
||||||
|
// save below is the real fix for the common case this was
|
||||||
|
// written for (a crash mid-save) — it now can't produce a
|
||||||
|
// half-written `vectors.usearch` in the first place.
|
||||||
|
if let Err(e) = index.load(idx_path.to_str().unwrap()) {
|
||||||
|
eprintln!(
|
||||||
|
"breadmill: WARNING: {} failed to load ({}) — treating it as corrupt, \
|
||||||
|
discarding it, and rebuilding the index from scratch on the next scan",
|
||||||
|
idx_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
conn.execute_batch("DELETE FROM chunks; DELETE FROM files;")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
index.reserve(4096).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
index.reserve(4096).map_err(|e| e.to_string())?;
|
index.reserve(4096).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self { conn, index, dim })
|
Ok(Self { conn, index })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- file state ---------------------------------------------------------
|
// ---- file state ---------------------------------------------------------
|
||||||
|
|
@ -218,11 +240,18 @@ impl Store {
|
||||||
|
|
||||||
// ---- persistence --------------------------------------------------------
|
// ---- persistence --------------------------------------------------------
|
||||||
|
|
||||||
|
/// Saves to a `.tmp` sibling and renames it into place — a same-
|
||||||
|
/// filesystem rename is atomic, so a crash mid-save leaves only an
|
||||||
|
/// orphaned `.tmp` file rather than a truncated/corrupt
|
||||||
|
/// `vectors.usearch` that would otherwise fail to load on next start
|
||||||
|
/// (see the recovery path in `open`).
|
||||||
pub fn save_index(&self, state_dir: &Path) -> Result<(), String> {
|
pub fn save_index(&self, state_dir: &Path) -> Result<(), String> {
|
||||||
let idx_path = state_dir.join("vectors.usearch");
|
let idx_path = state_dir.join("vectors.usearch");
|
||||||
|
let tmp_path = state_dir.join("vectors.usearch.tmp");
|
||||||
self.index
|
self.index
|
||||||
.save(idx_path.to_str().unwrap())
|
.save(tmp_path.to_str().unwrap())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
std::fs::rename(&tmp_path, &idx_path).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,3 +262,45 @@ fn truncate_to_chars(s: &str, max_chars: usize) -> String {
|
||||||
let truncated: String = s.chars().take(max_chars).collect();
|
let truncated: String = s.chars().take(max_chars).collect();
|
||||||
format!("{}…", truncated.trim_end())
|
format!("{}…", truncated.trim_end())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("breadmill-store-test-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_index_leaves_no_tmp_file_behind_and_is_loadable() {
|
||||||
|
let dir = test_dir("save-atomic");
|
||||||
|
let store = Store::open(&dir, 4).unwrap();
|
||||||
|
store.save_index(&dir).unwrap();
|
||||||
|
|
||||||
|
assert!(dir.join("vectors.usearch").exists());
|
||||||
|
assert!(
|
||||||
|
!dir.join("vectors.usearch.tmp").exists(),
|
||||||
|
"the .tmp staging file should be renamed away, not left behind"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A fresh `open` can load what was just saved without error.
|
||||||
|
Store::open(&dir, 4).unwrap();
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No automated test for the corrupt-index recovery branch in `open`:
|
||||||
|
// the natural way to construct a "corrupt" fixture (writing arbitrary
|
||||||
|
// garbage to vectors.usearch) was tried and crashes the *test process*
|
||||||
|
// with a SIGSEGV inside usearch's native loader before our `Result`
|
||||||
|
// handling ever gets a chance to run — see the comment on that branch
|
||||||
|
// in `open`. A real usearch file with a deliberately-broken-but-still-
|
||||||
|
// parseable header could plausibly hit the `Err` path exercised there
|
||||||
|
// instead, but reverse-engineering that format precisely enough to
|
||||||
|
// build a safe fixture wasn't worth the risk of another flaky/crashing
|
||||||
|
// test. The atomic-save test above covers the actual mechanism that
|
||||||
|
// prevents this scenario from arising in the first place.
|
||||||
|
}
|
||||||
|
|
|
||||||
41
breadmill/src/sync_ext.rs
Normal file
41
breadmill/src/sync_ext.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
//! Poison-tolerant `Mutex` locking.
|
||||||
|
//!
|
||||||
|
//! A panic on any thread while holding `SharedState::store` or
|
||||||
|
//! `SharedState::embedder` (e.g. an untrapped ONNX Runtime panic — only PDF
|
||||||
|
//! extraction is `catch_unwind`-guarded, see `extract.rs`) poisons the
|
||||||
|
//! `Mutex`. Every subsequent plain `.lock().unwrap()` — in the indexer *and*
|
||||||
|
//! in every query handler in `serve.rs` — would then immediately panic too,
|
||||||
|
//! silently bricking indexing and search until the daemon is restarted by
|
||||||
|
//! hand.
|
||||||
|
//!
|
||||||
|
//! `Mutex` poisoning exists to flag "the data guarded by this lock might be
|
||||||
|
//! in an inconsistent state," but every lock scope in this crate is a short,
|
||||||
|
//! single-step SQLite call or usearch operation — nothing here spans a
|
||||||
|
//! multi-step invariant across a single `lock()` call — so recovering the
|
||||||
|
//! guard and logging loudly is a reasonable trade here: continuing to serve
|
||||||
|
//! (and re-attempting the operation that panicked, on the next file/query)
|
||||||
|
//! beats a daemon that silently stops answering everything after one panic.
|
||||||
|
|
||||||
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
|
||||||
|
pub trait MutexExt<T> {
|
||||||
|
/// Like `.lock().unwrap()`, but recovers from a poisoned mutex instead
|
||||||
|
/// of panicking again — logs once per recovery so it's visible in the
|
||||||
|
/// daemon's own output, not just silently swallowed.
|
||||||
|
fn lock_recover(&self) -> MutexGuard<'_, T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> MutexExt<T> for Mutex<T> {
|
||||||
|
fn lock_recover(&self) -> MutexGuard<'_, T> {
|
||||||
|
match self.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => {
|
||||||
|
eprintln!(
|
||||||
|
"breadmill: WARNING: a mutex was poisoned by a panic on another thread; \
|
||||||
|
recovering it and continuing instead of cascading the panic"
|
||||||
|
);
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadsearch-shared"
|
name = "breadsearch-shared"
|
||||||
version = "0.1.0"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ pub fn socket_path() -> PathBuf {
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub index: IndexConfig,
|
pub index: IndexConfig,
|
||||||
|
|
@ -129,7 +129,8 @@ pub struct ModelConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default = "default_dim")]
|
#[serde(default = "default_dim")]
|
||||||
pub dim: usize,
|
pub dim: usize,
|
||||||
/// Compute backend: "cpu" or "npu" (VitisAI/XDNA).
|
/// Compute backend: "cpu", "npu" (VitisAI/XDNA), "rocm" (MIGraphX/AMD GPU),
|
||||||
|
/// "cuda" (NVIDIA GPU), or "openvino" (Intel iGPU/dGPU).
|
||||||
#[serde(default = "default_backend")]
|
#[serde(default = "default_backend")]
|
||||||
pub backend: String,
|
pub backend: String,
|
||||||
}
|
}
|
||||||
|
|
@ -156,16 +157,6 @@ impl Default for ModelConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
index: IndexConfig::default(),
|
|
||||||
search: SearchConfig::default(),
|
|
||||||
model: ModelConfig::default(),
|
|
||||||
power: PowerConfig::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PowerConfig {
|
pub struct PowerConfig {
|
||||||
|
|
@ -254,8 +245,7 @@ pub struct StatusInfo {
|
||||||
pub fn send_request(req: &Request) -> std::io::Result<Response> {
|
pub fn send_request(req: &Request) -> std::io::Result<Response> {
|
||||||
let mut stream = UnixStream::connect(socket_path())?;
|
let mut stream = UnixStream::connect(socket_path())?;
|
||||||
|
|
||||||
let mut line = serde_json::to_string(req)
|
let mut line = serde_json::to_string(req).map_err(std::io::Error::other)?;
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
|
||||||
line.push('\n');
|
line.push('\n');
|
||||||
stream.write_all(line.as_bytes())?;
|
stream.write_all(line.as_bytes())?;
|
||||||
stream.flush()?;
|
stream.flush()?;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadsearch"
|
name = "breadsearch"
|
||||||
version = "0.1.0"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
@ -10,7 +10,13 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
breadsearch-shared = { path = "../breadsearch-shared" }
|
breadsearch-shared = { path = "../breadsearch-shared" }
|
||||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
|
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
|
||||||
|
# Bread event fabric client — emit bread.search.* (fail-silent if breadd is down).
|
||||||
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client", "gtk"] }
|
||||||
|
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
||||||
|
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
gtk4-layer-shell = "0.8"
|
gtk4-layer-shell = "0.8"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
anyhow = "1"
|
||||||
|
|
|
||||||
26
breadsearch/src/bread_events.rs
Normal file
26
breadsearch/src/bread_events.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
//! `bread.search.*` event integration — optional, non-blocking. See
|
||||||
|
//! `EVENTS.md` at the repo root for the full contract. breadsearch works
|
||||||
|
//! identically with or without breadd running; every call here is
|
||||||
|
//! fire-and-forget (`BreadClient::emit` never blocks or errors this
|
||||||
|
//! process) so a missing or restarting breadd never affects the overlay.
|
||||||
|
|
||||||
|
use bread_utils::bread_client::BreadClient;
|
||||||
|
|
||||||
|
/// This app's id in bread's sibling-app namespace registry
|
||||||
|
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.search.*`.
|
||||||
|
pub const APP_ID: &str = "search";
|
||||||
|
|
||||||
|
fn client() -> BreadClient {
|
||||||
|
BreadClient::connect(APP_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_opened() {
|
||||||
|
client().emit("bread.search.opened", serde_json::json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_opened_result(path: &str) {
|
||||||
|
client().emit(
|
||||||
|
"bread.search.opened_result",
|
||||||
|
serde_json::json!({ "path": path }),
|
||||||
|
);
|
||||||
|
}
|
||||||
84
breadsearch/src/listen.rs
Normal file
84
breadsearch/src/listen.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
//! Long-running command subscription for `bread.command.search.*`.
|
||||||
|
//!
|
||||||
|
//! `breadsearch` is still a one-shot toggle overlay by default.
|
||||||
|
//! `breadsearch listen` is the optional persistent process that can honor
|
||||||
|
//! bus commands. See `EVENTS.md`.
|
||||||
|
|
||||||
|
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
||||||
|
|
||||||
|
use crate::bread_events::APP_ID;
|
||||||
|
|
||||||
|
/// Subscribe to `bread.command.search.**` and block until the process is killed.
|
||||||
|
///
|
||||||
|
/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects
|
||||||
|
/// with backoff, and `on_event` simply isn't called until the daemon is up.
|
||||||
|
pub fn run() {
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
if client.health().is_none() {
|
||||||
|
eprintln!(
|
||||||
|
"breadsearch: breadd unreachable; command subscription will connect when it comes back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _commands = client.subscribe("bread.command.search.**", |event| {
|
||||||
|
handle_command(&event);
|
||||||
|
});
|
||||||
|
|
||||||
|
eprintln!("breadsearch: listening for bread.command.search.**");
|
||||||
|
loop {
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to `bread.command.search.*` verbs. Only `open` is honored today —
|
||||||
|
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
||||||
|
fn handle_command(event: &BreadEvent) {
|
||||||
|
let Some(verb) = command_verb(&event.event) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match verb {
|
||||||
|
"open" => handle_open(),
|
||||||
|
other => {
|
||||||
|
eprintln!("breadsearch: ignoring unrecognized bread.command.search.{other}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_open() {
|
||||||
|
// Same as running `breadsearch` from a keybind: the PID-file toggle
|
||||||
|
// shows the overlay (or dismisses it if it is already up).
|
||||||
|
let result = spawn_self();
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
match result {
|
||||||
|
Ok(_) => client.emit("bread.search.open.done", serde_json::json!({})),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("breadsearch: bread.command.search.open failed: {e}");
|
||||||
|
client.emit(
|
||||||
|
"bread.search.open.failed",
|
||||||
|
serde_json::json!({ "error": e.to_string() }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_self() -> std::io::Result<std::process::Child> {
|
||||||
|
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadsearch"));
|
||||||
|
std::process::Command::new(exe).spawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_verb(event_name: &str) -> Option<&str> {
|
||||||
|
event_name.strip_prefix("bread.command.search.")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_verb_strips_search_prefix() {
|
||||||
|
assert_eq!(command_verb("bread.command.search.open"), Some("open"));
|
||||||
|
assert_eq!(command_verb("bread.command.search.query"), Some("query"));
|
||||||
|
assert_eq!(command_verb("bread.command.box.open"), None);
|
||||||
|
assert_eq!(command_verb("bread.search.opened"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
||||||
use breadsearch_shared::{Hit, Request, Response};
|
use breadsearch_shared::{Hit, Request, Response};
|
||||||
use std::{
|
use std::{cell::RefCell, process::Command, rc::Rc, sync::mpsc};
|
||||||
cell::RefCell,
|
|
||||||
env, fs,
|
|
||||||
path::PathBuf,
|
|
||||||
process::Command,
|
|
||||||
rc::Rc,
|
|
||||||
sync::mpsc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use gtk4::{
|
use gtk4::{
|
||||||
glib,
|
glib, pango::EllipsizeMode, prelude::*, Application, Box as GBox, CssProvider,
|
||||||
pango::EllipsizeMode,
|
EventControllerKey, Image, Label, ListBox, Orientation, PolicyType, ScrolledWindow,
|
||||||
prelude::*,
|
SearchEntry, SelectionMode,
|
||||||
Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Image, Label,
|
|
||||||
ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
|
|
||||||
};
|
};
|
||||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
||||||
|
mod bread_events;
|
||||||
|
mod listen;
|
||||||
|
mod screenshot;
|
||||||
|
|
||||||
// ---- Theming ----------------------------------------------------------------
|
// ---- Theming ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -47,39 +41,6 @@ fn build_css(p: &Palette) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- PID file toggle --------------------------------------------------------
|
|
||||||
|
|
||||||
fn pid_file() -> PathBuf {
|
|
||||||
env::var("XDG_RUNTIME_DIR")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
|
||||||
.join("breadsearch.pid")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_breadsearch_pid(pid: u32) -> bool {
|
|
||||||
fs::read_to_string(format!("/proc/{}/comm", pid))
|
|
||||||
.map(|s| s.trim() == "breadsearch")
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_or_continue() -> bool {
|
|
||||||
let pf = pid_file();
|
|
||||||
if let Ok(content) = fs::read_to_string(&pf) {
|
|
||||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
|
||||||
if is_breadsearch_pid(pid) {
|
|
||||||
let _ = Command::new("kill").arg(pid.to_string()).status();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = fs::write(&pf, std::process::id().to_string());
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cleanup_pid() {
|
|
||||||
let _ = fs::remove_file(pid_file());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Row builder ------------------------------------------------------------
|
// ---- Row builder ------------------------------------------------------------
|
||||||
|
|
||||||
fn make_hit_row(hit: &Hit) -> gtk4::ListBoxRow {
|
fn make_hit_row(hit: &Hit) -> gtk4::ListBoxRow {
|
||||||
|
|
@ -191,6 +152,7 @@ fn open_file(path: &str) {
|
||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
.spawn();
|
.spawn();
|
||||||
|
bread_events::emit_opened_result(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_folder(path: &str) {
|
fn open_folder(path: &str) {
|
||||||
|
|
@ -204,14 +166,22 @@ fn open_folder(path: &str) {
|
||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
.spawn();
|
.spawn();
|
||||||
|
bread_events::emit_opened_result(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- UI ---------------------------------------------------------------------
|
// ---- UI ---------------------------------------------------------------------
|
||||||
|
|
||||||
fn run_ui() {
|
fn run_ui(screenshot_req: Option<screenshot::ScreenshotRequest>) {
|
||||||
let app = Application::builder()
|
let mut builder = Application::builder().application_id("com.breadway.breadsearch");
|
||||||
.application_id("com.breadway.breadsearch")
|
if screenshot_req.is_some() {
|
||||||
.build();
|
// GApplication is single-instance by default; this machine typically
|
||||||
|
// already has a real breadsearch instance, so without this a
|
||||||
|
// screenshot run would just message the *existing* instance instead
|
||||||
|
// of starting a fresh one that ever sees `screenshot_req`.
|
||||||
|
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
|
||||||
|
}
|
||||||
|
let app = builder.build();
|
||||||
|
let is_screenshot_run = screenshot_req.is_some();
|
||||||
|
|
||||||
let debounce_id: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None));
|
let debounce_id: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
|
@ -225,20 +195,13 @@ fn run_ui() {
|
||||||
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
|
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
let window = ApplicationWindow::builder().application(app).build();
|
// Full-screen transparent overlay; panel widget is positioned inside it.
|
||||||
window.init_layer_shell();
|
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadsearch");
|
||||||
window.set_namespace(Some("breadsearch"));
|
bread_theme::gtk::bind_window_auto(&window);
|
||||||
window.set_layer(Layer::Overlay);
|
|
||||||
window.set_keyboard_mode(KeyboardMode::Exclusive);
|
|
||||||
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
|
|
||||||
window.set_anchor(edge, true);
|
|
||||||
}
|
|
||||||
window.set_exclusive_zone(0);
|
|
||||||
|
|
||||||
let close_all: Rc<dyn Fn()> = Rc::new({
|
let close_all: Rc<dyn Fn()> = Rc::new({
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
move || {
|
move || {
|
||||||
cleanup_pid();
|
|
||||||
w.close();
|
w.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -294,16 +257,26 @@ fn run_ui() {
|
||||||
let (tx, rx) = mpsc::sync_channel::<std::io::Result<Response>>(1);
|
let (tx, rx) = mpsc::sync_channel::<std::io::Result<Response>>(1);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let req = Request::Query { query: q, limit: 10 };
|
let req = Request::Query {
|
||||||
|
query: q,
|
||||||
|
limit: 10,
|
||||||
|
};
|
||||||
let _ = tx.send(breadsearch_shared::send_request(&req));
|
let _ = tx.send(breadsearch_shared::send_request(&req));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Poll via idle_add_local until the thread delivers its result.
|
// Wait for the thread's result on a bounded timer rather than
|
||||||
// Unix socket round-trips are sub-millisecond so this fires once.
|
// an `idle_add_local` — an idle source has no wait condition
|
||||||
|
// of its own, so GLib re-invokes it on every single main-loop
|
||||||
|
// iteration, i.e. a busy-spin pinning a full CPU core for as
|
||||||
|
// long as the daemon takes to answer (normally sub-ms, but
|
||||||
|
// the daemon's socket has no read timeout of its own, so a
|
||||||
|
// wedged/slow daemon previously meant an indefinite spin).
|
||||||
|
// 15ms is imperceptible added latency for a search box and
|
||||||
|
// caps this at a couple dozen checks per second instead.
|
||||||
let rx = Rc::new(rx);
|
let rx = Rc::new(rx);
|
||||||
let list_t = list_clone.clone();
|
let list_t = list_clone.clone();
|
||||||
|
|
||||||
glib::idle_add_local(move || {
|
glib::timeout_add_local(std::time::Duration::from_millis(15), move || {
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
populate_list(&list_t, result);
|
populate_list(&list_t, result);
|
||||||
|
|
@ -347,36 +320,11 @@ fn run_ui() {
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Down => {
|
Key::Down => {
|
||||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1);
|
bread_utils::gtk_popup::select_next_visible(&list_k);
|
||||||
let mut i = cur + 1;
|
|
||||||
loop {
|
|
||||||
match list_k.row_at_index(i) {
|
|
||||||
Some(r) if r.is_selectable() => {
|
|
||||||
list_k.select_row(Some(&r));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(_) => i += 1,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Up => {
|
Key::Up => {
|
||||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0);
|
bread_utils::gtk_popup::select_prev_visible(&list_k);
|
||||||
let mut i = cur - 1;
|
|
||||||
loop {
|
|
||||||
if i < 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match list_k.row_at_index(i) {
|
|
||||||
Some(r) if r.is_selectable() => {
|
|
||||||
list_k.select_row(Some(&r));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(_) => i -= 1,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
_ => glib::Propagation::Proceed,
|
_ => glib::Propagation::Proceed,
|
||||||
|
|
@ -394,36 +342,65 @@ fn run_ui() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click outside launcher panel → close
|
// Click outside launcher panel → close
|
||||||
let close_outside = Rc::clone(&close_all);
|
|
||||||
let vbox_ref = vbox.clone();
|
|
||||||
let win_ref = window.clone();
|
|
||||||
let outside_click = gtk4::GestureClick::new();
|
|
||||||
outside_click.connect_pressed(move |_, _, x, y| {
|
|
||||||
if let Some(b) = vbox_ref.compute_bounds(&win_ref) {
|
|
||||||
if x < b.x() as f64
|
|
||||||
|| x > (b.x() + b.width()) as f64
|
|
||||||
|| y < b.y() as f64
|
|
||||||
|| y > (b.y() + b.height()) as f64
|
|
||||||
{
|
{
|
||||||
close_outside();
|
let close_outside = Rc::clone(&close_all);
|
||||||
|
bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
window.add_controller(outside_click);
|
|
||||||
|
|
||||||
window.connect_destroy(|_| cleanup_pid());
|
if let Some(req) = screenshot_req.clone() {
|
||||||
|
screenshot::dispatch(&window, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.connect_map(|_| bread_events::emit_opened());
|
||||||
window.present();
|
window.present();
|
||||||
search.grab_focus();
|
search.grab_focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if is_screenshot_run {
|
||||||
|
// GLib's own option parser otherwise rejects --screenshot/--output
|
||||||
|
// before clap ever sees them (`Cli::parse()` already ran in `main`,
|
||||||
|
// over the real argv).
|
||||||
|
app.run_with_args(&[] as &[&str]);
|
||||||
|
} else {
|
||||||
app.run();
|
app.run();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Main -------------------------------------------------------------------
|
// ---- Main -------------------------------------------------------------------
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if !toggle_or_continue() {
|
if std::env::args().nth(1).as_deref() == Some("listen") {
|
||||||
|
listen::run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run_ui();
|
|
||||||
|
use clap::Parser;
|
||||||
|
let cli = screenshot::Cli::parse();
|
||||||
|
let screenshot_req = cli.screenshot_request();
|
||||||
|
|
||||||
|
// `toggle_or_kill` kills whatever's holding the single-instance lock —
|
||||||
|
// a real, already-running breadsearch included. A screenshot run must
|
||||||
|
// never touch it: it's a separate, disposable instance by design (same
|
||||||
|
// reasoning as breadbar's `allow_multiple_instances`), not a toggle of
|
||||||
|
// the operator's real search panel.
|
||||||
|
//
|
||||||
|
// Kept alive for the rest of `main` — dropping it releases the
|
||||||
|
// single-instance lock and removes the pid file, which happens
|
||||||
|
// naturally once `run_ui` returns (after the window closes).
|
||||||
|
let _singleton_guard = if screenshot_req.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match bread_utils::singleton::toggle_or_kill("breadsearch") {
|
||||||
|
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
|
||||||
|
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"breadsearch: single-instance lock unavailable ({e}); continuing without it"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run_ui(screenshot_req);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
93
breadsearch/src/screenshot.rs
Normal file
93
breadsearch/src/screenshot.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
//! `--screenshot` CLI mode: render breadsearch's search panel, capture it
|
||||||
|
//! via `bread-screenshots`, then exit — driven by `bread-ecosystem`'s
|
||||||
|
//! `bread-capture` orchestrator, or run standalone for one-off captures.
|
||||||
|
//!
|
||||||
|
//! Same reasoning as breadbox: one view ("search"), full known-size canvas
|
||||||
|
//! capture since the panel isn't its own layer surface. Captures whatever
|
||||||
|
//! the panel shows at settle time — normally the "Type to search…" empty
|
||||||
|
//! state, since there's no query to type in an automated run.
|
||||||
|
|
||||||
|
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY};
|
||||||
|
use clap::Parser;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "breadsearch")]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Render the named view, capture it, then exit instead of running
|
||||||
|
/// normally. Known views: "search".
|
||||||
|
#[arg(long)]
|
||||||
|
pub screenshot: Option<String>,
|
||||||
|
|
||||||
|
/// PNG path to write the capture to. Required together with --screenshot.
|
||||||
|
#[arg(long)]
|
||||||
|
pub output: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Capture canvas width — matches the isolated compositor's output width
|
||||||
|
/// (`bread-capture --isolate-width`).
|
||||||
|
#[arg(long, default_value_t = DEFAULT_WIDTH)]
|
||||||
|
pub width: u32,
|
||||||
|
|
||||||
|
/// Capture canvas height — see `width`.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_HEIGHT)]
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ScreenshotRequest {
|
||||||
|
pub view: String,
|
||||||
|
pub output: PathBuf,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
/// `None` for a normal run. Exits the process with an error if the
|
||||||
|
/// `--screenshot` / `--output` pair is incomplete, before any GTK setup
|
||||||
|
/// happens.
|
||||||
|
pub fn screenshot_request(&self) -> Option<ScreenshotRequest> {
|
||||||
|
if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) {
|
||||||
|
eprintln!("breadsearch: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Some(ScreenshotRequest {
|
||||||
|
view: self.screenshot.clone()?,
|
||||||
|
output: self.output.clone()?,
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire up the given view's screenshot sequence against an already-built,
|
||||||
|
/// not-yet-presented window. Every path here ends by exiting the process —
|
||||||
|
/// it never returns control to the normal search UI.
|
||||||
|
pub fn dispatch(window: >k4::ApplicationWindow, req: ScreenshotRequest) {
|
||||||
|
match req.view.as_str() {
|
||||||
|
"search" => {
|
||||||
|
let output = req.output;
|
||||||
|
let (width, height) = (req.width as i32, req.height as i32);
|
||||||
|
window.connect_map(move |_| {
|
||||||
|
let output = output.clone();
|
||||||
|
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
|
||||||
|
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("breadsearch: unknown screenshot view '{other}' (known: search)");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(result: anyhow::Result<()>) {
|
||||||
|
match result {
|
||||||
|
Ok(()) => std::process::exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("breadsearch: screenshot capture failed: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ci/bread-ecosystem.rev
Normal file
1
ci/bread-ecosystem.rev
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
147cfbbf96ae4b171027defa1130d2caddb934b1
|
||||||
21
ci/build.sh
Executable file
21
ci/build.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
|
||||||
|
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
|
||||||
|
# files now affect every product's release pipeline, so bumping the pin
|
||||||
|
# is a deliberate act instead of silent drift (see the bread-theme test
|
||||||
|
# that broke here for exactly that reason, before it was pinned by rev).
|
||||||
|
#
|
||||||
|
# Usage: ci/build.sh cargo build --release --locked
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
|
||||||
|
|
||||||
|
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
|
||||||
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-*
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
|
||||||
|
git -C "$CACHE_DIR" checkout --quiet "$REV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash "${CACHE_DIR}/ci/build.sh" breadsearch "$ROOT" "$@"
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Breadmill semantic search indexer
|
Description=Breadmill semantic search indexer
|
||||||
Documentation=https://github.com/breadway/breadsearch
|
Documentation=https://git.breadway.dev/Breadway/breadsearch
|
||||||
After=default.target
|
After=default.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
# Required (not just a perf tweak) when backend = "rocm": without a valid
|
||||||
|
# cache dir, ONNX Runtime's MIGraphX EP reads an uninitialized cache path and
|
||||||
|
# crashes on the first query instead of just recompiling every restart. Inert
|
||||||
|
# for cpu/npu/cuda/openvino backends (openvino's cache dir is set in code,
|
||||||
|
# not via env var). See README.md#gpu-backend-notes.
|
||||||
|
Environment=ORT_MIGRAPHX_MODEL_CACHE_PATH=%h/.cache/breadsearch/migraphx-cache
|
||||||
ExecStart=%h/.cargo/bin/breadmill
|
ExecStart=%h/.cargo/bin/breadmill
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue