Files
Nicolò Boschi 4ba54d8c8f feat(embed): full Windows support + prefer sibling hindsight-api over uvx (#1250)
* fix(embed): prefer locally-installed hindsight-api over uvx

Falling through to `uvx hindsight-api@...` when hindsight-embed is
installed via `uv pip install --target` (e.g. NixOS, hindsight-all)
downloads a standalone Python whose ABI doesn't match the sibling
site-packages' C extensions, causing `ModuleNotFoundError:
asyncpg.protocol.protocol` at daemon startup (closes #1240).

Check for a sibling `hindsight-api` entry point in `bin/` (or
`Scripts/hindsight-api.exe` on Windows) before falling back to uvx.

* ci(embed): add Windows unit-test job for hindsight-embed

Runs pytest on windows-latest to exercise the Windows code paths in
hindsight-embed (msvcrt file locking, .exe binary detection in
_find_api_command, netstat-based PID lookup).

Skips the test.sh smoke test: the daemon uses POSIX-only
subprocess.Popen(start_new_session=True) and signal.SIGTERM, so making
the full lifecycle Windows-safe is a separate effort.

* ci(embed): add Windows --target install test for issue #1240

Exercises the exact install layout from the issue: `uv pip install
--target` hindsight-embed + hindsight-api-slim, then verify the sibling
`Scripts/hindsight-api.exe` is discovered by `_find_api_command()`
instead of falling back to uvx.

Also runs `hindsight-embed --help` from the installed binary as a
basic smoke check. Daemon startup is still out of scope (needs
secrets + POSIX `start_new_session=True` fix).

* feat(embed): full Windows support for daemon + smoke test

Fixes every platform-specific blocker that previously forced the
Windows CI job to skip the smoke test:

- hindsight-api-slim/daemon.py: skip the double-fork on Windows (no
  fork model). The spawning embed process now drives detachment via
  CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS instead.
- hindsight-embed/daemon_embed_manager.py: centralize detach flags in
  _detach_popen_kwargs(). Windows requires creationflags plus explicit
  stdout/stderr redirection (DETACHED_PROCESS leaves the child with no
  console). POSIX keeps start_new_session=True.
- hindsight-embed/cli.py: reconfigure sys.stdout/stderr to UTF-8 on
  Windows so Rich's box-drawing / ✓ glyphs don't crash the default
  cp1252 codec.
- hindsight-embed/profile_manager.py: seek to byte 0 before msvcrt
  lock/unlock. Windows's msvcrt.locking(LK_UNLCK) requires the file
  pointer at the start of the locked region, which wasn't true after
  json.dump moved the position past the data.
- hindsight-embed/test.sh: detect python vs python3 so Git Bash on
  windows-latest (which only ships `python`) can run the smoke test.
- tests: set USERPROFILE alongside HOME because Path.home() on Windows
  consults USERPROFILE, not HOME.
- HINDSIGHT_EMBED_DAEMON_STARTUP_TIMEOUT env var: bump on Windows CI
  since pg0-embedded's initdb on cold runners is slow.

CI: test-embed-windows now mirrors the Linux test-embed job —
vertexai creds, local-ml/embedded-db extras, HF cache, full smoke
test — on top of the --target install-layout check for issue #1240.

* fix(api-slim): gate mlx/mlx-lm off Windows in local-ml extras

mlx only ships wheels for macOS/Linux, so `uv sync --all-extras` on
win_amd64 errors out with "no source distribution or wheel for the
current platform". Constrain both to `sys_platform != 'win32'` so
Windows resolves local-ml without the Apple Silicon pieces.

* fix(embed): use Path.replace for atomic metadata write on Windows

Path.rename refuses to overwrite an existing destination on Windows
(WinError 183); every profile metadata update after the first one
failed with FileExistsError. Path.replace is the cross-platform
atomic rename added in Python 3.3 precisely for this pattern.

* fix(embed): skip configure prompts when CI env vars are set

do_configure previously gated non-interactive mode on
`sys.stdin.isatty()`: if stdin looked interactive, it went to the
prompt path regardless of env. On Windows GHA pwsh runners stdin
looks like a TTY (it doesn't on Linux headless runners), so the
subprocess-invoked `configure` would block on input and exit with
"Configuration cancelled" — even though HINDSIGHT_API_LLM_* env vars
were set.

Fall through to _do_configure_from_env whenever the required
CI inputs are present (API key set, or provider is ollama/vertexai).

* ci(embed): build and stage hindsight Rust CLI on Windows smoke test

hindsight-embed's retain/recall delegate to the Rust `hindsight` CLI.
On POSIX the embed CLI auto-installs via curl|bash, but on Windows
`bash` routes to WSL (not provisioned) and there's no Windows
installer. Build the CLI from source with cargo and copy the .exe
into ~/.local/bin, which is the first location find_cli_binary()
checks.

Also teach find_cli_binary to look for `hindsight.exe` (and drop the
Unix-only os.access X check on Windows) so the staged binary is
actually picked up.

* fix(cli): update get_graph call to match regenerated client signature

hindsight-clients/rust was regenerated when document_id + chunk_id
query params were added to /banks/{id}/graph; progenitor orders query
params alphabetically, so the call-site now needs three leading
Nones (chunk_id, document_id, limit) and type_filter in the 8th slot.
Building the CLI off the current openapi.json was failing with E0061
"this method takes 9 arguments but 7 arguments were supplied",
blocking the Windows smoke-test cargo build.

* chore(api-slim): bump pg0-embedded to 0.13.0 for Windows support

0.13.0 fixes the "IO error: invalid gzip header" crash that blocked
embedded PostgreSQL startup on Windows, which was the final remaining
blocker for the Windows hindsight-embed smoke test.

* ci(embed): install --target outside repo for sibling-binary verify

_find_api_command's first check looks for a sibling
hindsight-api-slim/ dir via Path(__file__).parent.parent.parent. When
the --target install dir lives inside the monorepo checkout, that
branch matches and the test silently exercises the dev-mode path
instead of the sibling-binary path we're trying to validate.

Move the install into $RUNNER_TEMP so the dev-mode probe misses and
the sibling-binary branch is actually hit.
2026-04-24 14:50:32 +02:00

205 lines
6.5 KiB
Bash
Executable File

#!/bin/bash
#
# Smoke test for hindsight-embed CLI with daemon mode
# Tests retain and recall operations via the background daemon
# Verifies daemon lifecycle, memory retention, and recall functionality
#
set -e
# Windows (Git Bash / MSYS) ships `python` but not `python3`. Let callers
# override explicitly via $PYTHON; otherwise probe for whichever is on PATH.
if [ -z "${PYTHON:-}" ]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
else
PYTHON=python
fi
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
API_DIR="$(cd "$SCRIPT_DIR/../hindsight-api" && pwd)"
echo "=== Hindsight Embed Smoke Test (Daemon Mode) ==="
# Check required environment (load from config if not set)
if [ -f ~/.hindsight/config.env ]; then
source ~/.hindsight/config.env
fi
# vertexai uses GCP service account credentials instead of an API key
if [ -z "$HINDSIGHT_API_LLM_API_KEY" ] && [ -z "$OPENAI_API_KEY" ]; then
if [ "${HINDSIGHT_API_LLM_PROVIDER}" != "vertexai" ]; then
echo "Error: HINDSIGHT_API_LLM_API_KEY or OPENAI_API_KEY is required"
exit 1
fi
fi
# Use a unique bank ID for this test run
BANK_ID="test-$$-$(date +%s)"
echo "Using bank ID: $BANK_ID"
echo "Script dir: $SCRIPT_DIR"
echo "API dir: $API_DIR"
# Show environment info for debugging
echo ""
echo "Environment:"
echo " HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-not set}"
echo " HINDSIGHT_API_LLM_MODEL: ${HINDSIGHT_API_LLM_MODEL:-not set}"
echo " HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:+set (hidden)}"
echo " Python: $("$PYTHON" --version 2>&1)"
echo " uv: $(uv --version 2>&1)"
# Stop any existing daemon
echo ""
echo "Stopping any existing daemon..."
uv run --project "$SCRIPT_DIR" hindsight-embed daemon stop 2>/dev/null || true
sleep 1
# Test 1: Retain (this should start the daemon)
echo ""
echo "Test 1: Retaining a memory (first call - daemon will start)..."
START_TIME=$("$PYTHON" -c "import time; print(time.time())")
set +e # Temporarily disable exit on error to capture output
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed memory retain "$BANK_ID" "The user's favorite color is blue" 2>&1)
EXIT_CODE=$?
set -e
END_TIME=$("$PYTHON" -c "import time; print(time.time())")
DURATION=$("$PYTHON" -c "print(f'{$END_TIME - $START_TIME:.2f}')")
echo "$OUTPUT"
echo "Duration: ${DURATION}s"
echo "Exit code: $EXIT_CODE"
if [ $EXIT_CODE -ne 0 ]; then
echo "FAIL: Command exited with code $EXIT_CODE"
echo ""
echo "Checking daemon logs..."
if [ -f ~/.hindsight/daemon.log ]; then
echo "=== daemon.log ==="
tail -50 ~/.hindsight/daemon.log
else
echo "No daemon.log found"
fi
if [ -f ~/.hindsight/daemon.stderr ]; then
echo ""
echo "=== daemon.stderr ==="
cat ~/.hindsight/daemon.stderr
else
echo "No daemon.stderr found"
fi
echo ""
echo "Checking for hindsight-api..."
which hindsight-api 2>/dev/null || echo "hindsight-api not in PATH"
which uvx 2>/dev/null || echo "uvx not in PATH"
which uv 2>/dev/null || echo "uv not in PATH"
exit 1
fi
if ! echo "$OUTPUT" | grep -qi "retained"; then
echo "FAIL: Expected 'retained' in output"
exit 1
fi
echo "PASS: Memory retained successfully"
# Test 2: Recall (daemon already running - should be faster)
echo ""
echo "Test 2: Recalling memories (daemon already running)..."
START_TIME=$("$PYTHON" -c "import time; print(time.time())")
set +e
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed memory recall "$BANK_ID" "What is the user's favorite color?" 2>&1)
EXIT_CODE=$?
set -e
END_TIME=$("$PYTHON" -c "import time; print(time.time())")
DURATION=$("$PYTHON" -c "print(f'{$END_TIME - $START_TIME:.2f}')")
echo "$OUTPUT"
echo "Duration: ${DURATION}s"
echo "Exit code: $EXIT_CODE"
if [ $EXIT_CODE -ne 0 ]; then
echo "FAIL: Command exited with code $EXIT_CODE"
exit 1
fi
if ! echo "$OUTPUT" | grep -qi "blue"; then
echo "FAIL: Expected 'blue' in recall output"
exit 1
fi
echo "PASS: Memory recalled successfully"
# Test 3: Retain with context (daemon should still be running)
echo ""
echo "Test 3: Retaining memory with context..."
START_TIME=$("$PYTHON" -c "import time; print(time.time())")
set +e
OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed memory retain "$BANK_ID" "User prefers Python over JavaScript" --context work 2>&1)
EXIT_CODE=$?
set -e
END_TIME=$("$PYTHON" -c "import time; print(time.time())")
DURATION=$("$PYTHON" -c "print(f'{$END_TIME - $START_TIME:.2f}')")
echo "$OUTPUT"
echo "Duration: ${DURATION}s"
echo "Exit code: $EXIT_CODE"
if [ $EXIT_CODE -ne 0 ]; then
echo "FAIL: Command exited with code $EXIT_CODE"
exit 1
fi
if ! echo "$OUTPUT" | grep -qi "retained"; then
echo "FAIL: Expected 'retained' in output"
exit 1
fi
echo "PASS: Memory with context retained successfully"
# Test 4: Recall with JSON output
echo ""
echo "Test 4: Recalling with JSON output..."
START_TIME=$("$PYTHON" -c "import time; print(time.time())")
set +e
JSON_OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed memory recall "$BANK_ID" "programming preferences" -o json 2>&1)
EXIT_CODE=$?
set -e
END_TIME=$("$PYTHON" -c "import time; print(time.time())")
DURATION=$("$PYTHON" -c "print(f'{$END_TIME - $START_TIME:.2f}')")
echo "$JSON_OUTPUT"
echo "Duration: ${DURATION}s"
echo "Exit code: $EXIT_CODE"
if [ $EXIT_CODE -ne 0 ]; then
echo "FAIL: Command exited with code $EXIT_CODE"
exit 1
fi
if ! echo "$JSON_OUTPUT" | grep -qi "python"; then
echo "FAIL: Expected 'Python' in recall output"
exit 1
fi
if ! echo "$JSON_OUTPUT" | "$PYTHON" -c "import sys, json; json.load(sys.stdin)" 2>/dev/null; then
echo "FAIL: Expected valid JSON output"
exit 1
fi
echo "PASS: Memory recalled with JSON format successfully"
# Test 5: Check daemon is running
echo ""
echo "Test 5: Verifying daemon is running..."
if curl -s http://127.0.0.1:8888/health | grep -q "healthy"; then
echo "PASS: Daemon is running and healthy"
else
echo "FAIL: Daemon is not running"
exit 1
fi
# Test 6: Daemon status command
echo ""
echo "Test 6: Testing daemon status command..."
STATUS_OUTPUT=$(uv run --project "$SCRIPT_DIR" hindsight-embed daemon status 2>&1)
echo "$STATUS_OUTPUT"
if ! echo "$STATUS_OUTPUT" | grep -qi "running"; then
echo "FAIL: Expected 'running' in daemon status output"
exit 1
fi
echo "PASS: Daemon status command works"
# Cleanup: Stop daemon
echo ""
echo "Stopping daemon..."
uv run --project "$SCRIPT_DIR" hindsight-embed daemon stop 2>/dev/null || true
echo ""
echo "=== All tests passed! ==="