diff --git a/.githooks/pre-commit b/.githooks/pre-commit index c9480d00..1d9ef63f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -66,8 +66,12 @@ fi run_node_check "desktop build preflight" scripts/validate/desktop-build-preflight.mjs --mode local run_node_check "content contract check" scripts/verify-content-contract.mjs --check +run_node_check "CI workflow check" scripts/check-ci-workflow.mjs run_node_check "session-review workflow check" scripts/check-session-review-workflow.mjs +echo "Running workflow/report unit tests..." +run_mise node --test scripts/tests/*.test.mjs run_node_check "publishable dependency check" scripts/check-publishable-deps.mjs +run_node_check "rust artifact guardrail check" scripts/check-rust-artifact-guardrails.mjs run_node_check "validation hook guardrail check" scripts/check-validation-hooks.mjs run_node_check "docs portability check" scripts/check-doc-portability.mjs run_node_check "product version sync check" scripts/sync-product-version.mjs --check diff --git a/.github/workflows/ci-deep.yml b/.github/workflows/ci-deep.yml new file mode 100644 index 00000000..a4844d1f --- /dev/null +++ b/.github/workflows/ci-deep.yml @@ -0,0 +1,426 @@ +name: CI Deep + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * *" + +concurrency: + group: ci-deep-${{ github.ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + +jobs: + audit: + name: Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + api-e2e-server: + name: API E2E Server (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.93.0 + - uses: Swatinem/rust-cache@v2 + with: + shared-key: api-e2e-server-${{ matrix.os }} + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Start opensession-server + run: | + mkdir -p .ci-logs .ci-data/server + PORT=3000 \ + BASE_URL=http://127.0.0.1:3000 \ + JWT_SECRET=ci-jwt-secret \ + OPENSESSION_ADMIN_KEY=ci-admin-key \ + OPENSESSION_DATA_DIR="$PWD/.ci-data/server" \ + cargo run -p opensession-server > .ci-logs/server.log 2>&1 & + echo $! > .ci-logs/server.pid + - name: Wait for server health endpoint + run: | + for i in $(seq 1 120); do + if curl -fsS http://127.0.0.1:3000/api/health >/dev/null; then + exit 0 + fi + sleep 1 + done + echo "server did not start in time" + cat .ci-logs/server.log || true + exit 1 + - name: Run server API E2E suite + env: + OPENSESSION_E2E_SERVER_BASE_URL: http://127.0.0.1:3000 + OPENSESSION_E2E_ALLOW_REMOTE: "0" + run: cargo test -p opensession-e2e --test server -- --nocapture + - name: Stop opensession-server + if: always() + run: | + if [ -f .ci-logs/server.pid ]; then + kill "$(cat .ci-logs/server.pid)" || true + fi + - name: Upload server E2E logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: api-e2e-server-logs-${{ matrix.os }} + path: .ci-logs/server.log + if-no-files-found: ignore + + worker-web-live-e2e: + name: Worker + Web Live E2E (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.93.0 + - uses: Swatinem/rust-cache@v2 + with: + shared-key: worker-web-live-e2e-${{ matrix.os }} + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + web/package-lock.json + packages/ui/package-lock.json + - name: Cache Playwright browser + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('web/package-lock.json') }} + - name: Install UI deps + run: npm ci --prefer-offline --no-audit --no-fund --silent + working-directory: packages/ui + - name: Install web deps + run: npm ci --prefer-offline --no-audit --no-fund --silent + working-directory: web + - name: Install Playwright browser (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: npx playwright install --with-deps chromium + working-directory: web + - name: Install Playwright browser (macOS) + if: matrix.os == 'macos-latest' + run: npx playwright install chromium + working-directory: web + - name: Start wrangler dev (local) + run: | + mkdir -p .ci-logs + npx --yes wrangler@4 dev \ + --ip 127.0.0.1 \ + --port 8788 \ + --persist-to .wrangler/state \ + --show-interactive-dev-session=false \ + --log-level=warn \ + --var BASE_URL:http://127.0.0.1:8788 \ + --var OPENSESSION_BASE_URL:http://127.0.0.1:8788 \ + --var JWT_SECRET:ci-jwt-secret \ + --var OPENSESSION_ADMIN_KEY:ci-admin-key \ + --var GITHUB_CLIENT_ID:ci-github-client \ + --var GITHUB_CLIENT_SECRET:ci-github-secret \ + > .ci-logs/wrangler.log 2>&1 & + echo $! > .ci-logs/wrangler.pid + - name: Wait for worker health endpoint + run: | + for i in $(seq 1 360); do + api_code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8788/api/health || true)" + root_code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8788/ || true)" + if [ "$api_code" = "200" ] && [ "$root_code" = "200" ]; then + exit 0 + fi + sleep 1 + done + echo "wrangler dev did not start in time (api=$api_code root=$root_code)" + cat .ci-logs/wrangler.log || true + exit 1 + - name: Start opensession-server for web live API + run: | + mkdir -p .ci-logs .ci-data/server + PORT=3000 \ + BASE_URL=http://127.0.0.1:3000 \ + JWT_SECRET=ci-jwt-secret \ + OPENSESSION_ADMIN_KEY=ci-admin-key \ + OPENSESSION_DATA_DIR="$PWD/.ci-data/server" \ + OPENSESSION_LOCAL_REVIEW_ROOT="$PWD/web/e2e-live/fixtures/local-review" \ + OPENSESSION_ALLOWED_ORIGINS=http://127.0.0.1:8788 \ + cargo run -p opensession-server > .ci-logs/server.log 2>&1 & + echo $! > .ci-logs/server.pid + - name: Wait for server health endpoint + run: | + for i in $(seq 1 120); do + if curl -fsS http://127.0.0.1:3000/api/health >/dev/null; then + exit 0 + fi + sleep 1 + done + echo "opensession-server did not start in time" + cat .ci-logs/server.log || true + exit 1 + - name: Run worker API E2E suite + env: + OPENSESSION_E2E_WORKER_BASE_URL: http://127.0.0.1:8788 + OPENSESSION_E2E_ALLOW_REMOTE: "0" + run: cargo test -p opensession-e2e --test worker -- --nocapture + - name: Run web live Playwright suite + env: + OPENSESSION_E2E_WORKER_BASE_URL: http://127.0.0.1:8788 + OPENSESSION_E2E_SERVER_BASE_URL: http://127.0.0.1:3000 + OPENSESSION_E2E_ALLOW_REMOTE: "0" + CI: "1" + run: npm run test:e2e:live -- --reporter=list,junit --output=e2e-live-results + working-directory: web + - name: Stop opensession-server + if: always() + run: | + if [ -f .ci-logs/server.pid ]; then + kill "$(cat .ci-logs/server.pid)" || true + fi + - name: Stop wrangler dev + if: always() + run: | + if [ -f .ci-logs/wrangler.pid ]; then + kill "$(cat .ci-logs/wrangler.pid)" || true + fi + - name: Upload worker/web live E2E artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: worker-web-live-e2e-${{ matrix.os }} + path: | + .ci-logs/wrangler.log + .ci-logs/server.log + web/e2e-live-results + web/test-results + web/playwright-report + if-no-files-found: ignore + + desktop-e2e: + name: Desktop E2E (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.93.0 + - uses: Swatinem/rust-cache@v2 + with: + shared-key: desktop-e2e-${{ matrix.os }} + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Install Linux desktop test dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + xvfb \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libwebkit2gtk-4.1-dev + - name: Run desktop test suite (Linux) + if: matrix.os == 'ubuntu-latest' + env: + OPENSESSION_E2E_DESKTOP: "1" + run: xvfb-run -a cargo test --manifest-path desktop/src-tauri/Cargo.toml --quiet + - name: Run desktop test suite (macOS) + if: matrix.os == 'macos-latest' + env: + OPENSESSION_E2E_DESKTOP: "1" + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --quiet + + desktop-bundle-verify: + name: Desktop Bundle Verify (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.93.0 + - uses: Swatinem/rust-cache@v2 + with: + shared-key: desktop-bundle-verify-${{ matrix.os }} + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + desktop/package-lock.json + web/package-lock.json + packages/ui/package-lock.json + - name: Install universal Rust targets (macOS) + if: matrix.os == 'macos-latest' + run: rustup target add aarch64-apple-darwin x86_64-apple-darwin + - name: Install Linux desktop build dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + pkg-config \ + xvfb \ + patchelf \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libwebkit2gtk-4.1-dev + - name: Run desktop build preflight (Linux) + if: matrix.os == 'ubuntu-latest' + run: node scripts/validate/desktop-build-preflight.mjs --mode ci --os linux + - name: Run desktop build preflight (macOS) + if: matrix.os == 'macos-latest' + run: node scripts/validate/desktop-build-preflight.mjs --mode ci --os macos + - name: Install UI deps + run: npm ci --prefer-offline --no-audit --no-fund --silent + working-directory: packages/ui + - name: Install web deps + run: npm ci --prefer-offline --no-audit --no-fund --silent + working-directory: web + - name: Install desktop deps + run: npm ci --prefer-offline --no-audit --no-fund --silent + working-directory: desktop + - name: Link @opensession/ui + run: | + mkdir -p node_modules/@opensession + rm -f node_modules/@opensession/ui + ln -sf "${{ github.workspace }}/packages/ui" node_modules/@opensession/ui + working-directory: web + - name: Build desktop bundle (Linux) + if: matrix.os == 'ubuntu-latest' + id: build_linux + run: | + set -euo pipefail + mkdir -p .ci-logs + start="$(date +%s)" + npm --prefix desktop run tauri:build -- --no-sign --ci 2>&1 | tee .ci-logs/desktop-build.log + end="$(date +%s)" + echo "build_seconds=$((end - start))" >> "$GITHUB_OUTPUT" + - name: Build desktop bundle (macOS universal) + if: matrix.os == 'macos-latest' + id: build_macos + run: | + set -euo pipefail + mkdir -p .ci-logs + start="$(date +%s)" + npm --prefix desktop run tauri:build -- --target universal-apple-darwin --bundles app --no-sign --ci 2>&1 | tee .ci-logs/desktop-build.log + end="$(date +%s)" + echo "build_seconds=$((end - start))" >> "$GITHUB_OUTPUT" + - name: Verify desktop bundle (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + set -euo pipefail + mkdir -p .ci-logs + BUNDLE_DIR="desktop/src-tauri/target/release/bundle" + APP_BIN="desktop/src-tauri/target/release/opensession-desktop" + if [ ! -d "$BUNDLE_DIR" ]; then + echo "missing bundle dir: $BUNDLE_DIR" + exit 1 + fi + if ! find "$BUNDLE_DIR" -type f | grep -q .; then + echo "bundle dir has no files: $BUNDLE_DIR" + exit 1 + fi + if [ ! -f "$APP_BIN" ]; then + echo "missing desktop binary: $APP_BIN" + exit 1 + fi + xvfb-run -a sh -c ' + "$1" > "$2" 2>&1 & + pid=$! + sleep 5 + if ! kill -0 "$pid" 2>/dev/null; then + echo "desktop process exited before smoke timeout" + cat "$2" || true + exit 1 + fi + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + ' sh "$APP_BIN" ".ci-logs/desktop-smoke.log" + - name: Verify desktop bundle (macOS universal) + if: matrix.os == 'macos-latest' + run: | + set -euo pipefail + mkdir -p .ci-logs + BUNDLE_DIR="desktop/src-tauri/target/universal-apple-darwin/release/bundle" + APP_PATH="$(find "${BUNDLE_DIR}/macos" -maxdepth 1 -type d -name '*.app' | head -n 1 || true)" + if [ -z "$APP_PATH" ]; then + echo "missing .app bundle in ${BUNDLE_DIR}/macos" + exit 1 + fi + APP_BIN="${APP_PATH}/Contents/MacOS/opensession-desktop" + if [ ! -f "$APP_BIN" ]; then + echo "missing app executable: $APP_BIN" + exit 1 + fi + archs="$(lipo -archs "$APP_BIN" | xargs)" + if [ "$archs" != "x86_64 arm64" ] && [ "$archs" != "arm64 x86_64" ]; then + echo "unexpected universal archs: $archs" + exit 1 + fi + "$APP_BIN" > .ci-logs/desktop-smoke.log 2>&1 & + app_pid=$! + sleep 5 + if ! kill -0 "$app_pid" 2>/dev/null; then + echo "desktop process exited before smoke timeout" + cat .ci-logs/desktop-smoke.log || true + exit 1 + fi + kill "$app_pid" 2>/dev/null || true + wait "$app_pid" 2>/dev/null || true + - name: Collect desktop diagnostics + if: always() + env: + BUILD_SECONDS: ${{ steps.build_linux.outputs.build_seconds || steps.build_macos.outputs.build_seconds || '0' }} + run: | + set -euo pipefail + if [ "${{ matrix.os }}" = "macos-latest" ]; then + BUNDLE_DIR="desktop/src-tauri/target/universal-apple-darwin/release/bundle" + APP_BIN="desktop/src-tauri/target/universal-apple-darwin/release/bundle/macos/OpenSession Desktop.app/Contents/MacOS/opensession-desktop" + OS_LABEL="macos" + else + BUNDLE_DIR="desktop/src-tauri/target/release/bundle" + APP_BIN="desktop/src-tauri/target/release/opensession-desktop" + OS_LABEL="linux" + fi + scripts/ci/collect-desktop-diagnostics.sh \ + --out-dir ".ci-diagnostics/desktop-bundle-${{ matrix.os }}" \ + --os "$OS_LABEL" \ + --bundle-dir "$BUNDLE_DIR" \ + --app-bin "$APP_BIN" \ + --build-seconds "${BUILD_SECONDS}" \ + --smoke-log ".ci-logs/desktop-smoke.log" + - name: Upload desktop bundle diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: desktop-bundle-verify-${{ matrix.os }} + path: | + .ci-logs + .ci-diagnostics/desktop-bundle-${{ matrix.os }} + if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7a5e19b..095d7731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,13 @@ name: CI on: pull_request: branches: [main] + push: + branches: [main] workflow_dispatch: - schedule: - - cron: "0 3 * * *" - workflow_call: + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref || github.run_id }} + cancel-in-progress: true permissions: contents: read @@ -50,16 +53,18 @@ jobs: with: node-version: 22 - run: node scripts/verify-content-contract.mjs --check + - run: node scripts/check-ci-workflow.mjs - run: node scripts/check-session-review-workflow.mjs - run: node scripts/check-validation-hooks.mjs - run: node scripts/check-doc-portability.mjs + - run: node --test scripts/tests/*.test.mjs fmt: name: Format runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.93.0 with: components: rustfmt - run: cargo fmt --all -- --check @@ -70,10 +75,10 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.93.0 with: components: clippy - uses: Swatinem/rust-cache@v2 @@ -88,36 +93,26 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.93.0 - uses: Swatinem/rust-cache@v2 with: shared-key: cargo-test-${{ matrix.os }} save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo test --workspace --exclude opensession-e2e --quiet - audit: - name: Audit - runs-on: ubuntu-latest - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - steps: - - uses: actions/checkout@v4 - - uses: rustsec/audit-check@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - wasm-check: name: Worker (wasm) (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.93.0 with: components: clippy targets: wasm32-unknown-unknown @@ -133,7 +128,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -153,382 +148,3 @@ jobs: working-directory: web - run: npm run check working-directory: web - - api-e2e-server: - name: API E2E Server (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - shared-key: api-e2e-server-${{ matrix.os }} - save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Start opensession-server - run: | - mkdir -p .ci-logs .ci-data/server - PORT=3000 \ - BASE_URL=http://127.0.0.1:3000 \ - JWT_SECRET=ci-jwt-secret \ - OPENSESSION_ADMIN_KEY=ci-admin-key \ - OPENSESSION_DATA_DIR="$PWD/.ci-data/server" \ - cargo run -p opensession-server > .ci-logs/server.log 2>&1 & - echo $! > .ci-logs/server.pid - - name: Wait for server health endpoint - run: | - for i in $(seq 1 120); do - if curl -fsS http://127.0.0.1:3000/api/health >/dev/null; then - exit 0 - fi - sleep 1 - done - echo "server did not start in time" - cat .ci-logs/server.log || true - exit 1 - - name: Run server API E2E suite - env: - OPENSESSION_E2E_SERVER_BASE_URL: http://127.0.0.1:3000 - OPENSESSION_E2E_ALLOW_REMOTE: "0" - run: cargo test -p opensession-e2e --test server -- --nocapture - - name: Stop opensession-server - if: always() - run: | - if [ -f .ci-logs/server.pid ]; then - kill "$(cat .ci-logs/server.pid)" || true - fi - - name: Upload server E2E logs - if: failure() - uses: actions/upload-artifact@v4 - with: - name: api-e2e-server-logs-${{ matrix.os }} - path: .ci-logs/server.log - if-no-files-found: ignore - - worker-web-live-e2e: - name: Worker + Web Live E2E (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: | - web/package-lock.json - packages/ui/package-lock.json - - name: Install UI deps - run: npm ci --prefer-offline --no-audit --no-fund --silent - working-directory: packages/ui - - name: Install web deps - run: npm ci --prefer-offline --no-audit --no-fund --silent - working-directory: web - - name: Install Playwright browser (Ubuntu) - if: matrix.os == 'ubuntu-latest' - run: npx playwright install --with-deps chromium - working-directory: web - - name: Install Playwright browser (macOS) - if: matrix.os == 'macos-latest' - run: npx playwright install chromium - working-directory: web - - name: Start wrangler dev (local) - run: | - mkdir -p .ci-logs - npx --yes wrangler@4 dev \ - --ip 127.0.0.1 \ - --port 8788 \ - --persist-to .wrangler/state \ - --show-interactive-dev-session=false \ - --log-level=warn \ - --var BASE_URL:http://127.0.0.1:8788 \ - --var OPENSESSION_BASE_URL:http://127.0.0.1:8788 \ - --var JWT_SECRET:ci-jwt-secret \ - --var OPENSESSION_ADMIN_KEY:ci-admin-key \ - --var GITHUB_CLIENT_ID:ci-github-client \ - --var GITHUB_CLIENT_SECRET:ci-github-secret \ - > .ci-logs/wrangler.log 2>&1 & - echo $! > .ci-logs/wrangler.pid - - name: Wait for worker health endpoint - run: | - for i in $(seq 1 360); do - api_code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8788/api/health || true)" - root_code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8788/ || true)" - if [ "$api_code" = "200" ] && [ "$root_code" = "200" ]; then - exit 0 - fi - sleep 1 - done - echo "wrangler dev did not start in time (api=$api_code root=$root_code)" - cat .ci-logs/wrangler.log || true - exit 1 - - name: Start opensession-server for web live API - run: | - mkdir -p .ci-logs .ci-data/server - PORT=3000 \ - BASE_URL=http://127.0.0.1:3000 \ - JWT_SECRET=ci-jwt-secret \ - OPENSESSION_ADMIN_KEY=ci-admin-key \ - OPENSESSION_DATA_DIR="$PWD/.ci-data/server" \ - OPENSESSION_LOCAL_REVIEW_ROOT="$PWD/web/e2e-live/fixtures/local-review" \ - OPENSESSION_ALLOWED_ORIGINS=http://127.0.0.1:8788 \ - cargo run -p opensession-server > .ci-logs/server.log 2>&1 & - echo $! > .ci-logs/server.pid - - name: Wait for server health endpoint - run: | - for i in $(seq 1 120); do - if curl -fsS http://127.0.0.1:3000/api/health >/dev/null; then - exit 0 - fi - sleep 1 - done - echo "opensession-server did not start in time" - cat .ci-logs/server.log || true - exit 1 - - name: Run worker API E2E suite - env: - OPENSESSION_E2E_WORKER_BASE_URL: http://127.0.0.1:8788 - OPENSESSION_E2E_ALLOW_REMOTE: "0" - run: cargo test -p opensession-e2e --test worker -- --nocapture - - name: Run web live Playwright suite - env: - OPENSESSION_E2E_WORKER_BASE_URL: http://127.0.0.1:8788 - OPENSESSION_E2E_SERVER_BASE_URL: http://127.0.0.1:3000 - OPENSESSION_E2E_ALLOW_REMOTE: "0" - CI: "1" - run: npm run test:e2e:live -- --reporter=list,junit --output=e2e-live-results - working-directory: web - - name: Stop opensession-server - if: always() - run: | - if [ -f .ci-logs/server.pid ]; then - kill "$(cat .ci-logs/server.pid)" || true - fi - - name: Stop wrangler dev - if: always() - run: | - if [ -f .ci-logs/wrangler.pid ]; then - kill "$(cat .ci-logs/wrangler.pid)" || true - fi - - name: Upload worker/web live E2E artifacts - if: failure() - uses: actions/upload-artifact@v4 - with: - name: worker-web-live-e2e-${{ matrix.os }} - path: | - .ci-logs/wrangler.log - .ci-logs/server.log - web/e2e-live-results - web/test-results - web/playwright-report - if-no-files-found: ignore - - desktop-e2e: - name: Desktop E2E (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - name: Install Linux desktop test dependencies - if: matrix.os == 'ubuntu-latest' - run: | - sudo apt-get update - sudo apt-get install -y \ - xvfb \ - libgtk-3-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ - libwebkit2gtk-4.1-dev - - name: Run desktop test suite (Linux) - if: matrix.os == 'ubuntu-latest' - env: - OPENSESSION_E2E_DESKTOP: "1" - run: xvfb-run -a cargo test --manifest-path desktop/src-tauri/Cargo.toml --quiet - - name: Run desktop test suite (macOS) - if: matrix.os == 'macos-latest' - env: - OPENSESSION_E2E_DESKTOP: "1" - run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --quiet - - desktop-bundle-verify: - name: Desktop Bundle Verify (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: | - desktop/package-lock.json - web/package-lock.json - packages/ui/package-lock.json - - name: Install universal Rust targets (macOS) - if: matrix.os == 'macos-latest' - run: rustup target add aarch64-apple-darwin x86_64-apple-darwin - - name: Install Linux desktop build dependencies - if: matrix.os == 'ubuntu-latest' - run: | - sudo apt-get update - sudo apt-get install -y \ - pkg-config \ - xvfb \ - patchelf \ - libgtk-3-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ - libwebkit2gtk-4.1-dev - - name: Run desktop build preflight (Linux) - if: matrix.os == 'ubuntu-latest' - run: node scripts/validate/desktop-build-preflight.mjs --mode ci --os linux - - name: Run desktop build preflight (macOS) - if: matrix.os == 'macos-latest' - run: node scripts/validate/desktop-build-preflight.mjs --mode ci --os macos - - name: Install UI deps - run: npm ci --prefer-offline --no-audit --no-fund --silent - working-directory: packages/ui - - name: Install web deps - run: npm ci --prefer-offline --no-audit --no-fund --silent - working-directory: web - - name: Install desktop deps - run: npm ci --prefer-offline --no-audit --no-fund --silent - working-directory: desktop - - name: Link @opensession/ui - run: | - mkdir -p node_modules/@opensession - rm -f node_modules/@opensession/ui - ln -sf "${{ github.workspace }}/packages/ui" node_modules/@opensession/ui - working-directory: web - - name: Build desktop bundle (Linux) - if: matrix.os == 'ubuntu-latest' - id: build_linux - run: | - set -euo pipefail - mkdir -p .ci-logs - start="$(date +%s)" - npm --prefix desktop run tauri:build -- --no-sign --ci 2>&1 | tee .ci-logs/desktop-build.log - end="$(date +%s)" - echo "build_seconds=$((end - start))" >> "$GITHUB_OUTPUT" - - name: Build desktop bundle (macOS universal) - if: matrix.os == 'macos-latest' - id: build_macos - run: | - set -euo pipefail - mkdir -p .ci-logs - start="$(date +%s)" - npm --prefix desktop run tauri:build -- --target universal-apple-darwin --bundles app --no-sign --ci 2>&1 | tee .ci-logs/desktop-build.log - end="$(date +%s)" - echo "build_seconds=$((end - start))" >> "$GITHUB_OUTPUT" - - name: Verify desktop bundle (Linux) - if: matrix.os == 'ubuntu-latest' - run: | - set -euo pipefail - mkdir -p .ci-logs - BUNDLE_DIR="desktop/src-tauri/target/release/bundle" - APP_BIN="desktop/src-tauri/target/release/opensession-desktop" - if [ ! -d "$BUNDLE_DIR" ]; then - echo "missing bundle dir: $BUNDLE_DIR" - exit 1 - fi - if ! find "$BUNDLE_DIR" -type f | grep -q .; then - echo "bundle dir has no files: $BUNDLE_DIR" - exit 1 - fi - if [ ! -f "$APP_BIN" ]; then - echo "missing desktop binary: $APP_BIN" - exit 1 - fi - xvfb-run -a sh -c ' - "$1" > "$2" 2>&1 & - pid=$! - sleep 5 - if ! kill -0 "$pid" 2>/dev/null; then - echo "desktop process exited before smoke timeout" - cat "$2" || true - exit 1 - fi - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - ' sh "$APP_BIN" ".ci-logs/desktop-smoke.log" - - name: Verify desktop bundle (macOS universal) - if: matrix.os == 'macos-latest' - run: | - set -euo pipefail - mkdir -p .ci-logs - BUNDLE_DIR="desktop/src-tauri/target/universal-apple-darwin/release/bundle" - APP_PATH="$(find "${BUNDLE_DIR}/macos" -maxdepth 1 -type d -name '*.app' | head -n 1 || true)" - if [ -z "$APP_PATH" ]; then - echo "missing .app bundle in ${BUNDLE_DIR}/macos" - exit 1 - fi - APP_BIN="${APP_PATH}/Contents/MacOS/opensession-desktop" - if [ ! -f "$APP_BIN" ]; then - echo "missing app executable: $APP_BIN" - exit 1 - fi - archs="$(lipo -archs "$APP_BIN" | xargs)" - if [ "$archs" != "x86_64 arm64" ] && [ "$archs" != "arm64 x86_64" ]; then - echo "unexpected universal archs: $archs" - exit 1 - fi - "$APP_BIN" > .ci-logs/desktop-smoke.log 2>&1 & - app_pid=$! - sleep 5 - if ! kill -0 "$app_pid" 2>/dev/null; then - echo "desktop process exited before smoke timeout" - cat .ci-logs/desktop-smoke.log || true - exit 1 - fi - kill "$app_pid" 2>/dev/null || true - wait "$app_pid" 2>/dev/null || true - - name: Collect desktop diagnostics - if: always() - env: - BUILD_SECONDS: ${{ steps.build_linux.outputs.build_seconds || steps.build_macos.outputs.build_seconds || '0' }} - run: | - set -euo pipefail - if [ "${{ matrix.os }}" = "macos-latest" ]; then - BUNDLE_DIR="desktop/src-tauri/target/universal-apple-darwin/release/bundle" - APP_BIN="desktop/src-tauri/target/universal-apple-darwin/release/bundle/macos/OpenSession Desktop.app/Contents/MacOS/opensession-desktop" - OS_LABEL="macos" - else - BUNDLE_DIR="desktop/src-tauri/target/release/bundle" - APP_BIN="desktop/src-tauri/target/release/opensession-desktop" - OS_LABEL="linux" - fi - scripts/ci/collect-desktop-diagnostics.sh \ - --out-dir ".ci-diagnostics/desktop-bundle-${{ matrix.os }}" \ - --os "$OS_LABEL" \ - --bundle-dir "$BUNDLE_DIR" \ - --app-bin "$APP_BIN" \ - --build-seconds "${BUILD_SECONDS}" \ - --smoke-log ".ci-logs/desktop-smoke.log" - - name: Upload desktop bundle diagnostics - if: failure() - uses: actions/upload-artifact@v4 - with: - name: desktop-bundle-verify-${{ matrix.os }} - path: | - .ci-logs - .ci-diagnostics/desktop-bundle-${{ matrix.os }} - if-no-files-found: ignore diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 95f80175..4f271686 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,7 +10,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.93.0 with: targets: wasm32-unknown-unknown diff --git a/.github/workflows/desktop-dryrun.yml b/.github/workflows/desktop-dryrun.yml index 2efa739b..f2f59c4d 100644 --- a/.github/workflows/desktop-dryrun.yml +++ b/.github/workflows/desktop-dryrun.yml @@ -5,6 +5,10 @@ on: schedule: - cron: "30 2 * * *" +concurrency: + group: desktop-dryrun-${{ github.ref || github.run_id }} + cancel-in-progress: true + permissions: contents: read @@ -19,7 +23,11 @@ jobs: os: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.93.0 + - uses: Swatinem/rust-cache@v2 + with: + shared-key: desktop-dryrun-${{ matrix.os }} + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b92d2e10..7465dbe4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: with: node-version: 22 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.93.0 - name: Cache release-plz binary uses: actions/cache@v4 with: @@ -81,7 +81,7 @@ jobs: fetch-depth: 0 ref: main - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.93.0 - name: Run release-plz uses: release-plz/action@v0.5 with: @@ -103,7 +103,7 @@ jobs: fetch-depth: 0 ref: main - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.93.0 - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/.github/workflows/session-review.yml b/.github/workflows/session-review.yml index d061ac63..a9decefb 100644 --- a/.github/workflows/session-review.yml +++ b/.github/workflows/session-review.yml @@ -21,7 +21,8 @@ jobs: if: > github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize') && - github.event.pull_request.head.repo.full_name == github.repository + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.type != 'Bot' steps: - uses: actions/checkout@v4 with: @@ -43,6 +44,29 @@ jobs: set -euo pipefail git fetch --no-tags --depth=1 origin "+${LEDGER_REF}:${LEDGER_REF}" || true + - name: Resolve artifact storage + id: storage + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import os + import pathlib + import tomllib + + archive_branch = "" + config_path = pathlib.Path(".opensession/cleanup/config.toml") + if config_path.exists(): + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + archive_branch = str(data.get("session_archive_branch") or "").strip() + + pr_number = os.environ["PR_NUMBER"] + artifact_branch = archive_branch or f"opensession/pr-{pr_number}-sessions" + print(f"artifact_branch={artifact_branch}") + print(f"persistent={'true' if archive_branch else 'false'}") + PY + - name: Configure git author for artifact branch run: | set -euo pipefail @@ -56,6 +80,8 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} REPO_FULL_NAME: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} + ARTIFACT_BRANCH: ${{ steps.storage.outputs.artifact_branch }} + PERSIST_ARTIFACTS: ${{ steps.storage.outputs.persistent }} run: | set -euo pipefail node scripts/pr_session_report.mjs \ @@ -65,6 +91,8 @@ jobs: --head "$HEAD_SHA" \ --repo "$REPO_FULL_NAME" \ --pr-number "$PR_NUMBER" \ + --artifact-branch "$ARTIFACT_BRANCH" \ + --preserve-existing-artifacts "$PERSIST_ARTIFACTS" \ --publish-artifacts true \ > /tmp/opensession-pr-report.md @@ -117,8 +145,6 @@ jobs: cleanup: name: PR Session Cleanup runs-on: ubuntu-latest - env: - OPENSESSION_ARTIFACT_RETENTION: ${{ vars.OPENSESSION_ARTIFACT_RETENTION || 'next_commit' }} permissions: contents: write pull-requests: write @@ -126,8 +152,8 @@ jobs: if: > (github.event_name == 'pull_request' && github.event.action == 'closed' && - github.event.pull_request.merged == true && - github.event.pull_request.head.repo.full_name == github.repository) || + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.type != 'Bot') || (github.event_name == 'delete' && github.event.ref_type == 'branch') steps: - uses: actions/checkout@v4 @@ -159,24 +185,46 @@ jobs: set -euo pipefail git fetch --no-tags --depth=1 origin "+${LEDGER_REF}:${LEDGER_REF}" || true - - name: Build final report (merged PR only) + - name: Resolve artifact storage if: github.event_name == 'pull_request' + id: storage + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import os + import pathlib + import tomllib + + archive_branch = "" + config_path = pathlib.Path(".opensession/cleanup/config.toml") + if config_path.exists(): + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + archive_branch = str(data.get("session_archive_branch") or "").strip() + + pr_number = os.environ["PR_NUMBER"] + artifact_branch = archive_branch or f"opensession/pr-{pr_number}-sessions" + print(f"artifact_branch={artifact_branch}") + print(f"persistent={'true' if archive_branch else 'false'}") + PY + + - name: Build final report (merged PR only) + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true env: LEDGER_REF: ${{ steps.ledger.outputs.ref }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} REPO_FULL_NAME: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} - RETENTION_MODE: ${{ env.OPENSESSION_ARTIFACT_RETENTION }} + ARTIFACT_BRANCH: ${{ steps.storage.outputs.artifact_branch }} + PERSIST_ARTIFACTS: ${{ steps.storage.outputs.persistent }} run: | set -euo pipefail - retention="$(printf '%s' "$RETENTION_MODE" | tr '[:upper:]' '[:lower:]')" - publish_artifacts=true - case "$retention" in - immediate|delete) - publish_artifacts=false - ;; - esac + publish_artifacts=false + if [ "$PERSIST_ARTIFACTS" = "true" ]; then + publish_artifacts=true + fi node scripts/pr_session_report.mjs \ --mode final \ --ledger-ref "$LEDGER_REF" \ @@ -184,11 +232,13 @@ jobs: --head "$HEAD_SHA" \ --repo "$REPO_FULL_NAME" \ --pr-number "$PR_NUMBER" \ + --artifact-branch "$ARTIFACT_BRANCH" \ + --preserve-existing-artifacts "$PERSIST_ARTIFACTS" \ --publish-artifacts "$publish_artifacts" \ > /tmp/opensession-pr-final.md - name: Post final snapshot comment (merged PR only) - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true uses: actions/github-script@v7 env: REPORT_PATH: /tmp/opensession-pr-final.md @@ -201,6 +251,7 @@ jobs: await github.rest.issues.createComment({ owner, repo, issue_number, body }); - name: Delete ledger ref with retry + if: github.event_name == 'delete' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true) env: LEDGER_REF: ${{ steps.ledger.outputs.ref }} run: | @@ -217,69 +268,20 @@ jobs: echo "::warning::Failed to delete ledger ref $LEDGER_REF after retry" fi - - name: Apply artifact retention policy (merged PR only) - if: github.event_name == 'pull_request' + - name: Delete ephemeral artifact branch on PR close + if: github.event_name == 'pull_request' && steps.storage.outputs.persistent != 'true' env: - CURRENT_ARTIFACT_BRANCH: opensession/pr-${{ github.event.pull_request.number }}-sessions - RETENTION_MODE: ${{ env.OPENSESSION_ARTIFACT_RETENTION }} + ARTIFACT_BRANCH: ${{ steps.storage.outputs.artifact_branch }} run: | set -euo pipefail - retention="$(printf '%s' "$RETENTION_MODE" | tr '[:upper:]' '[:lower:]')" - case "$retention" in - next_commit|next|immediate|delete|keep|forever) - ;; - *) - echo "::warning::Unknown OPENSESSION_ARTIFACT_RETENTION='$RETENTION_MODE'; using next_commit" - retention="next_commit" - ;; - esac - - branches="$(git ls-remote --heads origin 'opensession/pr-*-sessions' | awk '{print $2}' | sed 's#refs/heads/##')" - if [ -z "$branches" ]; then - echo "No artifact branches found." - exit 0 - fi - - deleted=0 - kept=0 - failed=0 - for branch in $branches; do - delete_branch=0 - case "$retention" in - keep|forever) - delete_branch=0 - ;; - immediate|delete) - delete_branch=1 - ;; - next_commit|next) - if [ "$branch" != "$CURRENT_ARTIFACT_BRANCH" ]; then - delete_branch=1 - fi - ;; - esac - - if [ "$delete_branch" -ne 1 ]; then - kept=$((kept + 1)) - continue - fi - - ok=0 - for attempt in 1 2; do - if git push origin ":refs/heads/$branch"; then - ok=1 - break - fi - sleep 2 - done - if [ "$ok" -ne 1 ]; then - failed=1 - echo "::warning::Failed to delete artifact branch $branch after retry" - else - deleted=$((deleted + 1)) + ok=0 + for attempt in 1 2; do + if git push origin ":refs/heads/$ARTIFACT_BRANCH"; then + ok=1 + break fi + sleep 2 done - echo "Artifact retention mode: $retention (deleted=$deleted kept=$kept current=$CURRENT_ARTIFACT_BRANCH)" - if [ "$failed" -ne 0 ]; then - echo "::warning::Artifact retention policy completed with branch deletion failures." + if [ "$ok" -ne 1 ]; then + echo "::warning::Failed to delete artifact branch $ARTIFACT_BRANCH after retry" fi diff --git a/.gitignore b/.gitignore index caa3b609..fc81df41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,15 @@ # Build artifacts +/node_modules/ /target/ /target-*/ /crates/worker/target/ +/crates/worker/build/ +/build/ # Local cargo overrides (dev only) .cargo/config.toml web/node_modules/ +packages/ui/node_modules/ web/build/ web/.svelte-kit/ desktop/node_modules/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7bf17e31..f73007a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,8 @@ 4. For web/runtime-web changes, validate at least one real user path with `wrangler dev` + Playwright live suite. 5. API route changes: update the endpoint table in README. +PR CI is intentionally lean; heavy E2E/desktop jobs are reserved for local validation first and the scheduled/manual `.github/workflows/ci-deep.yml` workflow. + Validation details (local + CI parity) live in `docs/development-validation-flow.md`. ## Project Structure @@ -61,6 +63,9 @@ crates/ ## Code Style - Follow existing patterns in the codebase. +- Rust code targets Edition 2024 and the repo pins Rust 1.93.0 via `mise.toml`. +- New workspace crates should inherit `edition.workspace = true` and `rust-version.workspace = true`. +- Keep `unsafe` in the smallest possible helper and include a `SAFETY:` comment for each block. - Workspace clippy lints apply (see root `Cargo.toml`). ## License diff --git a/Cargo.lock b/Cargo.lock index 8c0b508e..db5937df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,13 +509,34 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -526,7 +547,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2171,9 +2192,13 @@ dependencies = [ "opensession-core", "opensession-git-native", "opensession-local-db", + "opensession-local-store", + "opensession-parser-discovery", "opensession-parsers", + "opensession-paths", "opensession-runtime-config", "opensession-summary", + "opensession-summary-runtime", "regex", "reqwest", "serde", @@ -2202,6 +2227,8 @@ dependencies = [ "serde_json", "sha2", "ts-rs", + "url", + "urlencoding", "uuid", ] @@ -2209,11 +2236,11 @@ dependencies = [ name = "opensession-api-client" version = "0.2.34" dependencies = [ - "anyhow", "opensession-api", "reqwest", "serde", "serde_json", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -2227,8 +2254,6 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2", - "tempfile", "thiserror 2.0.18", "urlencoding", "uuid", @@ -2247,9 +2272,12 @@ dependencies = [ "opensession-core", "opensession-git-native", "opensession-local-db", + "opensession-parser-discovery", "opensession-parsers", + "opensession-paths", "opensession-runtime-config", "opensession-summary", + "opensession-summary-runtime", "regex", "reqwest", "serde", @@ -2304,6 +2332,7 @@ dependencies = [ "chrono", "opensession-api", "opensession-core", + "opensession-paths", "rusqlite", "serde", "serde_json", @@ -2311,6 +2340,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "opensession-local-store" +version = "0.2.34" +dependencies = [ + "opensession-core", + "opensession-paths", + "sha2", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "opensession-parser-discovery" +version = "0.2.34" +dependencies = [ + "glob", + "opensession-paths", + "rusqlite", + "shellexpand", +] + [[package]] name = "opensession-parsers" version = "0.2.34" @@ -2319,6 +2369,7 @@ dependencies = [ "chrono", "glob", "opensession-core", + "opensession-parser-discovery", "regex", "rusqlite", "serde", @@ -2330,6 +2381,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "opensession-paths" +version = "0.2.34" +dependencies = [ + "directories", + "opensession-runtime-config", + "thiserror 2.0.18", +] + [[package]] name = "opensession-runtime-config" version = "0.2.34" @@ -2370,13 +2430,24 @@ dependencies = [ "hex", "opensession-core", "opensession-runtime-config", - "reqwest", "serde", "serde_json", "sha2", "tokio", ] +[[package]] +name = "opensession-summary-runtime" +version = "0.2.34" +dependencies = [ + "opensession-core", + "opensession-runtime-config", + "opensession-summary", + "reqwest", + "serde", + "tokio", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2651,6 +2722,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3779,6 +3861,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3815,6 +3906,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3848,6 +3954,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3860,6 +3972,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3872,6 +3990,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3896,6 +4020,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3908,6 +4038,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3920,6 +4056,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3932,6 +4074,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index e44400c4..e5e0f538 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,14 @@ [workspace] -resolver = "2" +resolver = "3" members = [ "crates/core", + "crates/paths", + "crates/local-store", "crates/runtime-config", "crates/summary", + "crates/summary-runtime", "crates/parsers", + "crates/parser-discovery", "crates/api", "crates/api-client", "crates/local-db", @@ -16,9 +20,13 @@ members = [ ] default-members = [ "crates/core", + "crates/paths", + "crates/local-store", "crates/runtime-config", "crates/summary", + "crates/summary-runtime", "crates/parsers", + "crates/parser-discovery", "crates/api", "crates/api-client", "crates/local-db", @@ -29,17 +37,24 @@ default-members = [ ] exclude = ["crates/worker"] +[workspace.lints.rust] +unsafe_op_in_unsafe_fn = "warn" + [workspace.lints.clippy] all = { level = "warn", priority = -1 } -collapsible_if = "warn" +collapsible_else_if = "allow" +collapsible_if = "allow" empty_line_after_doc_comments = "warn" +field_reassign_with_default = "allow" needless_return = "warn" redundant_closure = "warn" single_match = "warn" +undocumented_unsafe_blocks = "warn" [workspace.package] version = "0.2.34" -edition = "2021" +edition = "2024" +rust-version = "1.93" license = "MIT" repository = "https://github.com/hwisu/opensession" homepage = "https://opensession.io" @@ -47,9 +62,13 @@ authors = ["opensession.io contributors"] [workspace.dependencies] opensession-core = { version = "0.2.34", path = "crates/core" } +opensession-paths = { version = "0.2.34", path = "crates/paths" } +opensession-local-store = { version = "0.2.34", path = "crates/local-store" } opensession-runtime-config = { version = "0.2.34", path = "crates/runtime-config" } opensession-summary = { version = "0.2.34", path = "crates/summary" } +opensession-summary-runtime = { version = "0.2.34", path = "crates/summary-runtime" } opensession-parsers = { version = "0.2.34", path = "crates/parsers" } +opensession-parser-discovery = { version = "0.2.34", path = "crates/parser-discovery" } opensession-api = { version = "0.2.34", path = "crates/api", default-features = false } opensession-api-client = { version = "0.2.34", path = "crates/api-client" } opensession-local-db = { version = "0.2.34", path = "crates/local-db" } @@ -82,10 +101,17 @@ urlencoding = "2" tempfile = "3" gix = "0.79" sea-query = { version = "0.32", features = ["backend-sqlite", "derive"] } +directories = "5" + +[profile.dev] +incremental = false [profile.dev.package."*"] opt-level = 1 +[profile.test] +incremental = false + [profile.release] lto = "thin" codegen-units = 4 diff --git a/README.ko.md b/README.ko.md index 028c0f7b..1f76f884 100644 --- a/README.ko.md +++ b/README.ko.md @@ -8,11 +8,13 @@ OpenSession은 AI 세션 로그를 로컬 우선(local-first)으로 기록/등록/공유/검토하는 워크플로입니다. 웹: [opensession.io](https://opensession.io) -문서: [opensession.io/docs](https://opensession.io/docs) +문서: [opensession.io/docs](https://opensession.io/docs) +한국어 제품 문서: [`docs.ko.md`](docs.ko.md) ## 문서 맵 -- 제품 계약/명령 모델: [`docs.md`](docs.md) +- 제품 계약/명령 모델: [`docs.ko.md`](docs.ko.md) +- 영문 원본 계약 문서: [`docs.md`](docs.md) - 개발/검증 런북: [`docs/development-validation-flow.md`](docs/development-validation-flow.md) - 하네스 루프 정책: [`docs/harness-auto-improve-loop.md`](docs/harness-auto-improve-loop.md) - 파서 소스/재사용 매트릭스: [`docs/parser-source-matrix.md`](docs/parser-source-matrix.md) @@ -53,6 +55,19 @@ cargo install opensession 사용자 표면은 `opensession` CLI입니다. 자동 세션 수집(auto-capture)을 쓰려면 daemon 프로세스가 추가로 실행 중이어야 합니다. +CLI 로컬 전용 경로(backup/summary/handoff): + +```bash +opensession doctor +opensession doctor --fix --profile local +``` + +데스크톱 앱 경로(app + CLI + app-first 기본값): + +```bash +opensession doctor --fix --profile app --open-target app +``` + ## 개발 툴체인 (레포 작업 필수) 로컬 환경 편차를 줄이기 위해 레포 훅/검증은 `mise` 관리 툴체인을 필수로 사용합니다. @@ -75,16 +90,16 @@ cargo install opensession opensession doctor # 3) 권장 설치값 적용 (변경 전 동의 프롬프트) -opensession doctor --fix +opensession doctor --fix --profile local # 선택: fanout 모드를 명시적으로 지정 -opensession doctor --fix --fanout-mode hidden_ref +opensession doctor --fix --profile local --fanout-mode hidden_ref # 선택: view/review 오프너를 명시적으로 지정 -opensession doctor --fix --open-target app +opensession doctor --fix --profile app --open-target app # 자동화/비대화형(non-TTY) -opensession doctor --fix --yes --fanout-mode hidden_ref --open-target app +opensession doctor --fix --yes --profile local --fanout-mode hidden_ref --open-target web ``` `doctor`는 내부적으로 기존 setup 파이프라인을 재사용합니다. @@ -92,7 +107,7 @@ opensession doctor --fix --yes --fanout-mode hidden_ref --open-target app 첫 interactive 적용 시 fanout 저장 모드(`hidden_ref` 또는 `git_notes`)를 선택하며, 선택값은 로컬 git 설정(`.git/config`)의 `opensession.fanout-mode`에 저장됩니다. 같은 설정 흐름에서 `opensession view/review` 오프너(`app` 또는 `web`)도 선택하며 `opensession.open-target`으로 저장됩니다. 비대화형 환경에서는 `--fix`에 `--yes`가 필요하고, 저장된 fanout 모드가 없으면 `--fanout-mode`를 명시해야 합니다. -`--open-target`은 선택사항이며 기본값은 `app`입니다. +`--open-target`은 선택사항이며 기본값이 profile을 따릅니다(`local -> web`, `app -> app`). 자동 수집을 위한 daemon 실행: @@ -179,18 +194,26 @@ opensession inspect os://src/local/ ## 공유(share) ```bash -# local URI -> git 공유 가능 Source URI -opensession share os://src/local/ --git --remote origin +# 원클릭 git 공유 (첫 push만 한 번 확인, 이후 quick 모드에서 자동 push) +opensession share os://src/local/ --quick # 선택적 네트워크 변경 opensession share os://src/local/ --git --remote origin --push +# OpenSession pre-push 훅 설치/업데이트 +opensession doctor +opensession doctor --fix --profile local +# 선택: fanout 실패 시 push 자체를 실패시키고 싶다면 +OPENSESSION_STRICT=1 git push + # remote-resolvable URI -> 웹 URL opensession config init --base-url https://opensession.io opensession share os://src/git//ref//path/ --web ``` `share --web`는 `.opensession/config.toml`이 반드시 필요합니다. +Git-native write는 이제 hidden ledger ref(`refs/opensession/branches/`)를 사용하며, 새 write에 레거시 고정 ref는 쓰지 않습니다. +`opensession doctor --fix`는 훅 안정성을 위해 `~/.local/share/opensession/bin/opensession` shim도 설치합니다. ## Cleanup 자동화 @@ -217,13 +240,17 @@ opensession cleanup run --apply - hidden ref TTL: 30일 - artifact branch TTL: 30일 -- GitHub/GitLab 설정 시 PR/MR 갱신마다 artifact branch를 갱신하고 리뷰 코멘트를 남기는 session-review 자동화 템플릿도 함께 생성됩니다. +- GitHub/GitLab 설정 시 PR/MR 갱신마다 session artifact branch를 갱신하고 리뷰 코멘트를 남기는 session-review 자동화 템플릿도 함께 생성됩니다. +- 기본값은 ephemeral PR/MR artifact branch이며 리뷰가 닫히면 삭제됩니다. `--session-archive-branch `를 설정하면 `pr/sessions` 같은 전용 archive branch에 immutable snapshot을 계속 보관합니다. - session-review 코멘트에는 `Reviewer Quick Digest` 블록이 포함되며, Q&A 발췌(질문/응답), 수정 파일, 추가/수정 테스트가 함께 표시됩니다. 민감한 저장소는 즉시 정리 모드를 권장합니다. ```bash opensession cleanup init --provider auto --hidden-ttl-days 0 --artifact-ttl-days 0 --yes + +# 전용 브랜치에 리뷰 스냅샷 영구 보관 +opensession cleanup init --provider auto --session-archive-branch pr/sessions --yes ``` ## handoff @@ -274,12 +301,12 @@ opensession share os://src/git//ref//path/ --web ``` 2. `share --git`에서 remote 누락: ```bash -opensession share os://src/local/ --git --remote origin +opensession share os://src/local/ --quick ``` 3. git 저장소 밖에서 `share --git` 실행: ```bash cd -opensession share os://src/local/ --git --remote origin +opensession share os://src/local/ --quick ``` 4. `.opensession/config.toml` 없이 `share --web` 실행: ```bash @@ -311,10 +338,10 @@ opensession cleanup run 처음 사용자 5분 복귀 경로: ```bash opensession doctor -opensession doctor --fix +opensession doctor --fix --profile local opensession parse --profile codex ./raw-session.jsonl --out ./session.hail.jsonl opensession register ./session.hail.jsonl -opensession share os://src/local/ --git --remote origin +opensession share os://src/local/ --quick ``` ## 로컬 개발 검증 @@ -325,6 +352,8 @@ opensession share os://src/local/ --git --remote origin ./.githooks/pre-push ``` +PR CI는 의도적으로 가볍게 유지합니다. `.github/workflows/ci.yml`에는 빠른 기본 게이트만 남기고, 무거운 GitHub-hosted E2E/desktop 검증은 `.github/workflows/ci-deep.yml`로 분리해 로컬에서 먼저 검증하는 흐름을 기준으로 둡니다. + ```bash # 웹 런타임 검증 (wrangler + opensession-server 기동 이후) cd web diff --git a/README.md b/README.md index df70cc26..f4098a34 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Docs: [opensession.io/docs](https://opensession.io/docs) ## Documentation Map - Product contract and command model: [`docs.md`](docs.md) +- Korean product contract: [`docs.ko.md`](docs.ko.md) - Development and validation runbook: [`docs/development-validation-flow.md`](docs/development-validation-flow.md) - Harness loop policy: [`docs/harness-auto-improve-loop.md`](docs/harness-auto-improve-loop.md) - Parser source/reuse matrix: [`docs/parser-source-matrix.md`](docs/parser-source-matrix.md) @@ -231,13 +232,17 @@ opensession cleanup run # apply cleanup deletions opensession cleanup run --apply + +# keep review snapshots permanently on a dedicated branch +opensession cleanup init --provider auto --session-archive-branch pr/sessions --yes ``` Defaults: - hidden ref TTL: 30 days - artifact branch TTL: 30 days -- GitHub/GitLab setup also writes PR/MR session-review automation that updates an artifact branch and posts a review comment on PR/MR updates. +- GitHub/GitLab setup also writes PR/MR session-review automation that updates a session artifact branch and posts a review comment on PR/MR updates. +- By default PR/MR artifact branches are ephemeral and are deleted when the review closes; set `--session-archive-branch ` to keep immutable review snapshots on a dedicated archive branch such as `pr/sessions`. - Session review comments now include a `Reviewer Quick Digest` block with Q&A excerpts (question/answer rows), modified files, and added/updated tests. Sensitive repositories can force immediate cleanup: @@ -345,6 +350,8 @@ opensession share os://src/local/ --quick ./.githooks/pre-push ``` +PR CI is intentionally lean: `.github/workflows/ci.yml` keeps only fast baseline gates, while heavy GitHub-hosted E2E/desktop validation lives in `.github/workflows/ci-deep.yml` and should be exercised locally first. + ```bash # Runtime web validation (after starting wrangler + opensession-server) cd web diff --git a/build.sh b/build.sh index 6493f7c1..bef0e87d 100755 --- a/build.sh +++ b/build.sh @@ -28,6 +28,39 @@ run_with_stable_rustc() { "$@" } +stable_target_dir_name() { + if command -v rustup >/dev/null 2>&1; then + RUSTC_BIN=$(rustup which --toolchain stable rustc 2>/dev/null || true) + if [ -n "$RUSTC_BIN" ]; then + RELEASE=$("$RUSTC_BIN" -Vv 2>/dev/null | awk '/^release:/ { print $2; exit }') + if [ -n "$RELEASE" ]; then + printf 'target-rustup-%s\n' "$(printf '%s' "$RELEASE" | tr '.' '_')" + return + fi + fi + fi +} + +prune_rust_build_artifacts() { + rm -rf target/debug/incremental + + for path in target-rustup-*/debug/incremental; do + [ -d "$path" ] || continue + rm -rf "$path" + done + + current_target_dir=$(stable_target_dir_name) + for path in target-rustup-*; do + [ -d "$path" ] || continue + if [ -n "$current_target_dir" ] && [ "$(basename "$path")" = "$current_target_dir" ]; then + continue + fi + rm -rf "$path" + done +} + +prune_rust_build_artifacts + # Frontend cd packages/ui && npm install && cd ../.. cd web && npm install && npm run build && cd .. diff --git a/crates/api-client/Cargo.toml b/crates/api-client/Cargo.toml index 47ad1e42..3fc2c229 100644 --- a/crates/api-client/Cargo.toml +++ b/crates/api-client/Cargo.toml @@ -2,6 +2,7 @@ name = "opensession-api-client" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true description = "Typed HTTP client for the OpenSession API" @@ -20,4 +21,7 @@ serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["time"] } tracing = { workspace = true } -anyhow = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["io-util", "macros", "net", "rt-multi-thread"] } diff --git a/crates/api-client/src/client.rs b/crates/api-client/src/client.rs index adfcc640..45dd6ea3 100644 --- a/crates/api-client/src/client.rs +++ b/crates/api-client/src/client.rs @@ -1,10 +1,27 @@ use std::time::Duration; -use anyhow::{bail, Result}; use serde::Serialize; +use thiserror::Error; use opensession_api::*; +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum ApiClientError { + #[error("auth token not set")] + AuthTokenMissing, + #[error("transport error: {0}")] + Transport(reqwest::Error), + #[error("unexpected API status {status}: {body}")] + UnexpectedStatus { + status: reqwest::StatusCode, + body: String, + }, + #[error("response decode error: {0}")] + Decode(reqwest::Error), +} + /// Typed HTTP client for the OpenSession API. /// /// Provides high-level methods for each API endpoint (using the stored auth @@ -19,7 +36,10 @@ pub struct ApiClient { impl ApiClient { /// Create a new client with the given base URL and timeout. pub fn new(base_url: &str, timeout: Duration) -> Result { - let client = reqwest::Client::builder().timeout(timeout).build()?; + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(ApiClientError::Transport)?; Ok(Self { client, base_url: base_url.trim_end_matches('/').to_string(), @@ -62,16 +82,21 @@ impl ApiClient { format!("{}/api{}", self.base_url, path) } - fn token_or_bail(&self) -> Result<&str> { + fn token_or_err(&self) -> Result<&str> { self.auth_token .as_deref() - .ok_or_else(|| anyhow::anyhow!("auth token not set")) + .ok_or(ApiClientError::AuthTokenMissing) } // ── Health ──────────────────────────────────────────────────────────── pub async fn health(&self) -> Result { - let resp = self.client.get(self.url("/health")).send().await?; + let resp = self + .client + .get(self.url("/health")) + .send() + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } @@ -83,7 +108,8 @@ impl ApiClient { .post(self.url("/auth/login")) .json(req) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } @@ -93,29 +119,32 @@ impl ApiClient { .post(self.url("/auth/register")) .json(req) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } pub async fn verify(&self) -> Result { - let token = self.token_or_bail()?; + let token = self.token_or_err()?; let resp = self .client .post(self.url("/auth/verify")) .bearer_auth(token) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } pub async fn me(&self) -> Result { - let token = self.token_or_bail()?; + let token = self.token_or_err()?; let resp = self .client .get(self.url("/auth/me")) .bearer_auth(token) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } @@ -125,52 +154,68 @@ impl ApiClient { .post(self.url("/auth/refresh")) .json(req) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } pub async fn logout(&self, req: &LogoutRequest) -> Result { - let token = self.token_or_bail()?; + let token = self.token_or_err()?; let resp = self .client .post(self.url("/auth/logout")) .bearer_auth(token) .json(req) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } pub async fn change_password(&self, req: &ChangePasswordRequest) -> Result { - let token = self.token_or_bail()?; + let token = self.token_or_err()?; let resp = self .client .post(self.url("/auth/change-password")) .bearer_auth(token) .json(req) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } pub async fn issue_api_key(&self) -> Result { - let token = self.token_or_bail()?; + let token = self.token_or_err()?; let resp = self .client .post(self.url("/auth/api-keys/issue")) .bearer_auth(token) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } // ── Sessions ────────────────────────────────────────────────────────── + pub async fn upload_session(&self, req: &UploadRequest) -> Result { + let token = self.token_or_err()?; + let resp = self + .client + .post(self.url("/sessions")) + .bearer_auth(token) + .json(req) + .send() + .await + .map_err(ApiClientError::Transport)?; + parse_response(resp).await + } + pub async fn list_sessions(&self, query: &SessionListQuery) -> Result { - let token = self.token_or_bail()?; + let token = self.token_or_err()?; let mut url = self.url("/sessions"); - // Build query string from the struct fields let mut params = Vec::new(); params.push(format!("page={}", query.page)); params.push(format!("per_page={}", query.per_page)); @@ -190,40 +235,49 @@ impl ApiClient { url = format!("{}?{}", url, params.join("&")); } - let resp = self.client.get(&url).bearer_auth(token).send().await?; + let resp = self + .client + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } pub async fn get_session(&self, id: &str) -> Result { - let token = self.token_or_bail()?; + let token = self.token_or_err()?; let resp = self .client .get(self.url(&format!("/sessions/{id}"))) .bearer_auth(token) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } pub async fn delete_session(&self, id: &str) -> Result { - let token = self.token_or_bail()?; + let token = self.token_or_err()?; let resp = self .client .delete(self.url(&format!("/sessions/{id}"))) .bearer_auth(token) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } pub async fn get_session_raw(&self, id: &str) -> Result { - let token = self.token_or_bail()?; + let token = self.token_or_err()?; let resp = self .client .get(self.url(&format!("/sessions/{id}/raw"))) .bearer_auth(token) .send() - .await?; + .await + .map_err(ApiClientError::Transport)?; parse_response(resp).await } @@ -231,22 +285,22 @@ impl ApiClient { /// Authenticated GET returning the raw response. pub async fn get_with_auth(&self, path: &str, token: &str) -> Result { - Ok(self - .client + self.client .get(self.url(path)) .bearer_auth(token) .send() - .await?) + .await + .map_err(ApiClientError::Transport) } /// Authenticated POST (no body) returning the raw response. pub async fn post_with_auth(&self, path: &str, token: &str) -> Result { - Ok(self - .client + self.client .post(self.url(path)) .bearer_auth(token) .send() - .await?) + .await + .map_err(ApiClientError::Transport) } /// Authenticated POST with JSON body returning the raw response. @@ -256,13 +310,13 @@ impl ApiClient { token: &str, body: &T, ) -> Result { - Ok(self - .client + self.client .post(self.url(path)) .bearer_auth(token) .json(body) .send() - .await?) + .await + .map_err(ApiClientError::Transport) } /// Authenticated PUT with JSON body returning the raw response. @@ -272,23 +326,23 @@ impl ApiClient { token: &str, body: &T, ) -> Result { - Ok(self - .client + self.client .put(self.url(path)) .bearer_auth(token) .json(body) .send() - .await?) + .await + .map_err(ApiClientError::Transport) } /// Authenticated DELETE returning the raw response. pub async fn delete_with_auth(&self, path: &str, token: &str) -> Result { - Ok(self - .client + self.client .delete(self.url(path)) .bearer_auth(token) .send() - .await?) + .await + .map_err(ApiClientError::Transport) } /// Unauthenticated POST with JSON body returning the raw response. @@ -297,25 +351,52 @@ impl ApiClient { path: &str, body: &T, ) -> Result { - Ok(self.client.post(self.url(path)).json(body).send().await?) + self.client + .post(self.url(path)) + .json(body) + .send() + .await + .map_err(ApiClientError::Transport) } } -/// Parse an HTTP response: return the deserialized body on 2xx, -/// or an error containing the status and body text. async fn parse_response(resp: reqwest::Response) -> Result { let status = resp.status(); if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - bail!("{status}: {body}"); + let body = match resp.text().await { + Ok(body) => body, + Err(err) => format!(""), + }; + return Err(ApiClientError::UnexpectedStatus { status, body }); } - Ok(resp.json().await?) + resp.json().await.map_err(ApiClientError::Decode) } #[cfg(test)] mod tests { - use super::ApiClient; + use super::{ApiClient, ApiClientError}; use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + async fn serve_once(response: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf).await; + stream + .write_all(response.as_bytes()) + .await + .expect("write response"); + }); + + format!("http://{addr}") + } #[test] fn set_auth_trims_surrounding_whitespace() { @@ -337,4 +418,54 @@ mod tests { client.set_auth(" ".to_string()); assert_eq!(client.auth_token(), None); } + + #[tokio::test] + async fn verify_without_auth_token_returns_typed_error() { + let client = ApiClient::new("https://example.com", Duration::from_secs(1)) + .expect("client should construct"); + + let error = client.verify().await.expect_err("verify should fail"); + assert!(matches!(error, ApiClientError::AuthTokenMissing)); + } + + #[tokio::test] + async fn parse_response_surfaces_unexpected_status_with_body() { + let base_url = serve_once( + "HTTP/1.1 401 Unauthorized\r\nContent-Length: 12\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nmissing auth", + ) + .await; + let client = + ApiClient::new(&base_url, Duration::from_secs(1)).expect("client should construct"); + + let error = client.health().await.expect_err("health should fail"); + match error { + ApiClientError::UnexpectedStatus { status, body } => { + assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); + assert_eq!(body, "missing auth"); + } + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[tokio::test] + async fn parse_response_surfaces_decode_errors() { + let base_url = serve_once( + "HTTP/1.1 200 OK\r\nContent-Length: 8\r\nContent-Type: application/json\r\nConnection: close\r\n\r\nnot-json", + ) + .await; + let client = + ApiClient::new(&base_url, Duration::from_secs(1)).expect("client should construct"); + + let error = client.health().await.expect_err("health should fail"); + assert!(matches!(error, ApiClientError::Decode(_))); + } + + #[tokio::test] + async fn invalid_base_url_surfaces_transport_error() { + let client = + ApiClient::new("not-a-url", Duration::from_secs(1)).expect("client should construct"); + + let error = client.health().await.expect_err("health should fail"); + assert!(matches!(error, ApiClientError::Transport(_))); + } } diff --git a/crates/api-client/src/lib.rs b/crates/api-client/src/lib.rs index b25fd938..3a13de5a 100644 --- a/crates/api-client/src/lib.rs +++ b/crates/api-client/src/lib.rs @@ -1,6 +1,6 @@ pub mod client; pub mod retry; -pub use client::ApiClient; +pub use client::{ApiClient, ApiClientError}; pub use opensession_api; pub use retry::RetryConfig; diff --git a/crates/api-client/src/retry.rs b/crates/api-client/src/retry.rs index d57be394..d358139d 100644 --- a/crates/api-client/src/retry.rs +++ b/crates/api-client/src/retry.rs @@ -1,8 +1,9 @@ use std::time::Duration; -use anyhow::{Context, Result}; use tracing::warn; +use crate::client::{ApiClientError, Result}; + /// Configuration for retry behaviour on upload-style POST requests. pub struct RetryConfig { pub max_retries: usize, @@ -65,7 +66,7 @@ pub async fn retry_post( ); tokio::time::sleep(Duration::from_secs(config.delays[attempt])).await; } else { - return Err(e).context("Failed to connect after retries"); + return Err(ApiClientError::Transport(e)); } } } diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 838b47d5..0a56ddfd 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -2,6 +2,7 @@ name = "opensession-api" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true description = "Shared API types, crypto, and SQL builders for opensession.io" @@ -43,3 +44,5 @@ chacha20poly1305 = { version = "0.10", optional = true } base64 = "0.22" uuid = { version = "1", features = ["v4"] } chrono = { workspace = true } +url = "2" +urlencoding = "2" diff --git a/crates/api/src/auth_types.rs b/crates/api/src/auth_types.rs new file mode 100644 index 00000000..16af9298 --- /dev/null +++ b/crates/api/src/auth_types.rs @@ -0,0 +1,142 @@ +use crate::oauth; +use serde::{Deserialize, Serialize}; + +/// Email + password registration. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct AuthRegisterRequest { + pub email: String, + pub password: String, + pub nickname: String, +} + +/// Email + password login. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +/// Returned on successful login / register / refresh. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct AuthTokenResponse { + pub access_token: String, + pub refresh_token: String, + pub expires_in: u64, + pub user_id: String, + pub nickname: String, +} + +/// Refresh token request. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct RefreshRequest { + pub refresh_token: String, +} + +/// Logout request (invalidate refresh token). +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LogoutRequest { + pub refresh_token: String, +} + +/// Change password request. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct ChangePasswordRequest { + pub current_password: String, + pub new_password: String, +} + +/// Returned by `POST /api/auth/verify` — confirms token validity. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct VerifyResponse { + pub user_id: String, + pub nickname: String, +} + +/// Full user profile returned by `GET /api/auth/me`. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct UserSettingsResponse { + pub user_id: String, + pub nickname: String, + pub created_at: String, + pub email: Option, + pub avatar_url: Option, + #[serde(default)] + pub oauth_providers: Vec, +} + +/// Generic success response for operations that don't return data. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct OkResponse { + pub ok: bool, +} + +/// Response for API key issuance. The key is visible only at issuance time. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct IssueApiKeyResponse { + pub api_key: String, +} + +/// Public metadata for a user-managed git credential. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct GitCredentialSummary { + pub id: String, + pub label: String, + pub host: String, + pub path_prefix: String, + pub header_name: String, + pub created_at: String, + pub updated_at: String, + pub last_used_at: Option, +} + +/// Response for `GET /api/auth/git-credentials`. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct ListGitCredentialsResponse { + #[serde(default)] + pub credentials: Vec, +} + +/// Request for `POST /api/auth/git-credentials`. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct CreateGitCredentialRequest { + pub label: String, + pub host: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_prefix: Option, + pub header_name: String, + pub header_value: String, +} + +/// Response for OAuth link initiation (redirect URL). +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct OAuthLinkResponse { + pub url: String, +} diff --git a/crates/api/src/crypto.rs b/crates/api/src/crypto.rs index c3229f08..7d05bdc8 100644 --- a/crates/api/src/crypto.rs +++ b/crates/api/src/crypto.rs @@ -5,10 +5,10 @@ //! //! Uses pure Rust crates (wasm-compatible, no WebCrypto interop needed). -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use chacha20poly1305::{ - aead::{Aead, KeyInit}, XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit}, }; use hmac::{Hmac, Mac}; use pbkdf2::pbkdf2_hmac; @@ -333,7 +333,7 @@ fn constant_time_eq(lhs: &[u8], rhs: &[u8]) -> bool { #[cfg(test)] mod tests { - use super::{constant_time_eq, CredentialKeyring}; + use super::{CredentialKeyring, constant_time_eq}; #[test] fn credential_keyring_round_trip_encrypt_decrypt() { @@ -354,9 +354,10 @@ mod tests { "k1:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", ) .expect_err("missing active key should fail"); - assert!(err - .message() - .contains("active credential key id `missing` is missing")); + assert!( + err.message() + .contains("active credential key id `missing` is missing") + ); } #[test] diff --git a/crates/api/src/desktop_runtime_types.rs b/crates/api/src/desktop_runtime_types.rs new file mode 100644 index 00000000..b4e11c8f --- /dev/null +++ b/crates/api/src/desktop_runtime_types.rs @@ -0,0 +1,683 @@ +use crate::session_types::SessionSummary; +use serde::{Deserialize, Serialize}; + +/// Canonical desktop IPC contract version shared between Rust and TS clients. +pub const DESKTOP_IPC_CONTRACT_VERSION: &str = "desktop-ipc-v6"; + +/// Desktop handoff build request payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopHandoffBuildRequest { + pub session_id: String, + pub pin_latest: bool, +} + +/// Desktop handoff build response payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopHandoffBuildResponse { + pub artifact_uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned_alias: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_file_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_content: Option, +} + +/// Desktop quick-share request payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopQuickShareRequest { + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote: Option, +} + +/// Desktop quick-share response payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopQuickShareResponse { + pub source_uri: String, + pub shared_uri: String, + pub remote: String, + pub push_cmd: String, + #[serde(default)] + pub pushed: bool, + #[serde(default)] + pub auto_push_consent: bool, +} + +/// Desktop bridge contract/version handshake response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopContractVersionResponse { + pub version: String, +} + +/// Desktop runtime settings payload for App settings UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSettingsResponse { + pub session_default_view: String, + pub summary: DesktopRuntimeSummarySettings, + pub vector_search: DesktopRuntimeVectorSearchSettings, + pub change_reader: DesktopRuntimeChangeReaderSettings, + pub lifecycle: DesktopRuntimeLifecycleSettings, + pub ui_constraints: DesktopRuntimeSummaryUiConstraints, +} + +/// Desktop runtime settings update request. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSettingsUpdateRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_default_view: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vector_search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub change_reader: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lifecycle: Option, +} + +/// Local summary provider detection result for desktop setup/settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopSummaryProviderDetectResponse { + pub detected: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transport: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummaryProviderId { + Disabled, + Ollama, + CodexExec, + ClaudeCli, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummaryProviderTransport { + None, + Cli, + Http, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummarySourceMode { + SessionOnly, + SessionOrGitChanges, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummaryResponseStyle { + Compact, + Standard, + Detailed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummaryOutputShape { + Layered, + FileList, + SecurityFirst, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummaryTriggerMode { + Manual, + OnSessionSave, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummaryStorageBackend { + HiddenRef, + LocalDb, + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummaryBatchExecutionMode { + Manual, + OnAppStart, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummaryBatchScope { + RecentDays, + All, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryProviderSettings { + pub id: DesktopSummaryProviderId, + pub transport: DesktopSummaryProviderTransport, + pub endpoint: String, + pub model: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryPromptSettings { + pub template: String, + pub default_template: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryResponseSettings { + pub style: DesktopSummaryResponseStyle, + pub shape: DesktopSummaryOutputShape, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryStorageSettings { + pub trigger: DesktopSummaryTriggerMode, + pub backend: DesktopSummaryStorageBackend, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryBatchSettings { + pub execution_mode: DesktopSummaryBatchExecutionMode, + pub scope: DesktopSummaryBatchScope, + pub recent_days: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummarySettings { + pub provider: DesktopRuntimeSummaryProviderSettings, + pub prompt: DesktopRuntimeSummaryPromptSettings, + pub response: DesktopRuntimeSummaryResponseSettings, + pub storage: DesktopRuntimeSummaryStorageSettings, + pub source_mode: DesktopSummarySourceMode, + pub batch: DesktopRuntimeSummaryBatchSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryProviderSettingsUpdate { + pub id: DesktopSummaryProviderId, + pub endpoint: String, + pub model: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryPromptSettingsUpdate { + pub template: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryResponseSettingsUpdate { + pub style: DesktopSummaryResponseStyle, + pub shape: DesktopSummaryOutputShape, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryStorageSettingsUpdate { + pub trigger: DesktopSummaryTriggerMode, + pub backend: DesktopSummaryStorageBackend, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryBatchSettingsUpdate { + pub execution_mode: DesktopSummaryBatchExecutionMode, + pub scope: DesktopSummaryBatchScope, + pub recent_days: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummarySettingsUpdate { + pub provider: DesktopRuntimeSummaryProviderSettingsUpdate, + pub prompt: DesktopRuntimeSummaryPromptSettingsUpdate, + pub response: DesktopRuntimeSummaryResponseSettingsUpdate, + pub storage: DesktopRuntimeSummaryStorageSettingsUpdate, + pub source_mode: DesktopSummarySourceMode, + pub batch: DesktopRuntimeSummaryBatchSettingsUpdate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeSummaryUiConstraints { + pub source_mode_locked: bool, + pub source_mode_locked_value: DesktopSummarySourceMode, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopVectorSearchProvider { + Ollama, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopVectorSearchGranularity { + EventLineChunk, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopVectorChunkingMode { + Auto, + Manual, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopVectorInstallState { + NotInstalled, + Installing, + Ready, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopVectorIndexState { + Idle, + Running, + Complete, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeVectorSearchSettings { + pub enabled: bool, + pub provider: DesktopVectorSearchProvider, + pub model: String, + pub endpoint: String, + pub granularity: DesktopVectorSearchGranularity, + pub chunking_mode: DesktopVectorChunkingMode, + pub chunk_size_lines: u16, + pub chunk_overlap_lines: u16, + pub top_k_chunks: u16, + pub top_k_sessions: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeVectorSearchSettingsUpdate { + pub enabled: bool, + pub provider: DesktopVectorSearchProvider, + pub model: String, + pub endpoint: String, + pub granularity: DesktopVectorSearchGranularity, + pub chunking_mode: DesktopVectorChunkingMode, + pub chunk_size_lines: u16, + pub chunk_overlap_lines: u16, + pub top_k_chunks: u16, + pub top_k_sessions: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopChangeReaderScope { + SummaryOnly, + FullContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopChangeReaderVoiceProvider { + Openai, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeChangeReaderVoiceSettings { + pub enabled: bool, + pub provider: DesktopChangeReaderVoiceProvider, + pub model: String, + pub voice: String, + pub api_key_configured: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeChangeReaderVoiceSettingsUpdate { + pub enabled: bool, + pub provider: DesktopChangeReaderVoiceProvider, + pub model: String, + pub voice: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeChangeReaderSettings { + pub enabled: bool, + pub scope: DesktopChangeReaderScope, + pub qa_enabled: bool, + pub max_context_chars: u32, + pub voice: DesktopRuntimeChangeReaderVoiceSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeChangeReaderSettingsUpdate { + pub enabled: bool, + pub scope: DesktopChangeReaderScope, + pub qa_enabled: bool, + pub max_context_chars: u32, + pub voice: DesktopRuntimeChangeReaderVoiceSettingsUpdate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeLifecycleSettings { + pub enabled: bool, + pub session_ttl_days: u32, + pub summary_ttl_days: u32, + pub cleanup_interval_secs: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopRuntimeLifecycleSettingsUpdate { + pub enabled: bool, + pub session_ttl_days: u32, + pub summary_ttl_days: u32, + pub cleanup_interval_secs: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopLifecycleCleanupState { + Idle, + Running, + Complete, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopLifecycleCleanupStatusResponse { + pub state: DesktopLifecycleCleanupState, + pub deleted_sessions: u32, + pub deleted_summaries: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopVectorPreflightResponse { + pub provider: DesktopVectorSearchProvider, + pub endpoint: String, + pub model: String, + pub ollama_reachable: bool, + pub model_installed: bool, + pub install_state: DesktopVectorInstallState, + pub progress_pct: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopVectorInstallStatusResponse { + pub state: DesktopVectorInstallState, + pub model: String, + pub progress_pct: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopVectorIndexStatusResponse { + pub state: DesktopVectorIndexState, + pub processed_sessions: u32, + pub total_sessions: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum DesktopSummaryBatchState { + Idle, + Running, + Complete, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopSummaryBatchStatusResponse { + pub state: DesktopSummaryBatchState, + pub processed_sessions: u32, + pub total_sessions: u32, + pub failed_sessions: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopVectorSessionMatch { + pub session: SessionSummary, + pub score: f32, + pub chunk_id: String, + pub start_line: u32, + pub end_line: u32, + pub snippet: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopVectorSearchResponse { + pub query: String, + #[serde(default)] + pub sessions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub total_candidates: u32, +} + +/// Session summary payload returned by desktop runtime. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopSessionSummaryResponse { + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts", ts(type = "any"))] + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts", ts(type = "any"))] + pub source_details: Option, + #[serde(default)] + #[cfg_attr(feature = "ts", ts(type = "any[]"))] + pub diff_tree: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generation_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopChangeReadRequest { + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopChangeReadResponse { + pub session_id: String, + pub scope: DesktopChangeReaderScope, + pub narrative: String, + #[serde(default)] + pub citations: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopChangeQuestionRequest { + pub session_id: String, + pub question: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopChangeReaderTtsRequest { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopChangeReaderTtsResponse { + pub mime_type: String, + pub audio_base64: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopChangeQuestionResponse { + pub session_id: String, + pub question: String, + pub scope: DesktopChangeReaderScope, + pub answer: String, + #[serde(default)] + pub citations: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option, +} diff --git a/crates/api/src/errors.rs b/crates/api/src/errors.rs new file mode 100644 index 00000000..f6349dbe --- /dev/null +++ b/crates/api/src/errors.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; + +/// Structured desktop bridge error payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopApiError { + pub code: String, + pub status: u16, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts", ts(type = "Record | null"))] + pub details: Option, +} + +/// Framework-agnostic service error. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum ServiceError { + BadRequest(String), + Unauthorized(String), + Forbidden(String), + NotFound(String), + Conflict(String), + Internal(String), +} + +impl ServiceError { + pub fn status_code(&self) -> u16 { + match self { + Self::BadRequest(_) => 400, + Self::Unauthorized(_) => 401, + Self::Forbidden(_) => 403, + Self::NotFound(_) => 404, + Self::Conflict(_) => 409, + Self::Internal(_) => 500, + } + } + + pub fn code(&self) -> &'static str { + match self { + Self::BadRequest(_) => "bad_request", + Self::Unauthorized(_) => "unauthorized", + Self::Forbidden(_) => "forbidden", + Self::NotFound(_) => "not_found", + Self::Conflict(_) => "conflict", + Self::Internal(_) => "internal", + } + } + + pub fn message(&self) -> &str { + match self { + Self::BadRequest(message) + | Self::Unauthorized(message) + | Self::Forbidden(message) + | Self::NotFound(message) + | Self::Conflict(message) + | Self::Internal(message) => message, + } + } + + pub fn from_db(context: &str) -> impl FnOnce(E) -> Self + '_ { + move |error| Self::Internal(format!("{context}: {error}")) + } +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message()) + } +} + +impl std::error::Error for ServiceError {} + +/// API error payload. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct ApiError { + pub code: String, + pub message: String, +} + +impl From<&ServiceError> for ApiError { + fn from(error: &ServiceError) -> Self { + Self { + code: error.code().to_string(), + message: error.message().to_string(), + } + } +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 99bc8564..14435051 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -6,1551 +6,79 @@ //! To regenerate TypeScript types: //! cargo test -p opensession-api -- export_typescript --nocapture -use serde::{Deserialize, Serialize}; - #[cfg(feature = "backend")] pub mod crypto; #[cfg(feature = "backend")] pub mod db; pub mod deploy; pub mod oauth; +pub mod parse_preview_source; #[cfg(feature = "backend")] pub mod service; -// Re-export core HAIL types for convenience +mod auth_types; +mod desktop_runtime_types; +mod errors; +mod local_review_types; +mod parse_preview_types; +mod session_types; +mod shared_types; + +pub use auth_types::{ + AuthRegisterRequest, AuthTokenResponse, ChangePasswordRequest, CreateGitCredentialRequest, + GitCredentialSummary, IssueApiKeyResponse, ListGitCredentialsResponse, LoginRequest, + LogoutRequest, OAuthLinkResponse, OkResponse, RefreshRequest, UserSettingsResponse, + VerifyResponse, +}; +pub use desktop_runtime_types::{ + DESKTOP_IPC_CONTRACT_VERSION, DesktopChangeQuestionRequest, DesktopChangeQuestionResponse, + DesktopChangeReadRequest, DesktopChangeReadResponse, DesktopChangeReaderScope, + DesktopChangeReaderTtsRequest, DesktopChangeReaderTtsResponse, + DesktopChangeReaderVoiceProvider, DesktopContractVersionResponse, DesktopHandoffBuildRequest, + DesktopHandoffBuildResponse, DesktopLifecycleCleanupState, + DesktopLifecycleCleanupStatusResponse, DesktopQuickShareRequest, DesktopQuickShareResponse, + DesktopRuntimeChangeReaderSettings, DesktopRuntimeChangeReaderSettingsUpdate, + DesktopRuntimeChangeReaderVoiceSettings, DesktopRuntimeChangeReaderVoiceSettingsUpdate, + DesktopRuntimeLifecycleSettings, DesktopRuntimeLifecycleSettingsUpdate, + DesktopRuntimeSettingsResponse, DesktopRuntimeSettingsUpdateRequest, + DesktopRuntimeSummaryBatchSettings, DesktopRuntimeSummaryBatchSettingsUpdate, + DesktopRuntimeSummaryPromptSettings, DesktopRuntimeSummaryPromptSettingsUpdate, + DesktopRuntimeSummaryProviderSettings, DesktopRuntimeSummaryProviderSettingsUpdate, + DesktopRuntimeSummaryResponseSettings, DesktopRuntimeSummaryResponseSettingsUpdate, + DesktopRuntimeSummarySettings, DesktopRuntimeSummarySettingsUpdate, + DesktopRuntimeSummaryStorageSettings, DesktopRuntimeSummaryStorageSettingsUpdate, + DesktopRuntimeSummaryUiConstraints, DesktopRuntimeVectorSearchSettings, + DesktopRuntimeVectorSearchSettingsUpdate, DesktopSessionSummaryResponse, + DesktopSummaryBatchExecutionMode, DesktopSummaryBatchScope, DesktopSummaryBatchState, + DesktopSummaryBatchStatusResponse, DesktopSummaryOutputShape, + DesktopSummaryProviderDetectResponse, DesktopSummaryProviderId, + DesktopSummaryProviderTransport, DesktopSummaryResponseStyle, DesktopSummarySourceMode, + DesktopSummaryStorageBackend, DesktopSummaryTriggerMode, DesktopVectorChunkingMode, + DesktopVectorIndexState, DesktopVectorIndexStatusResponse, DesktopVectorInstallState, + DesktopVectorInstallStatusResponse, DesktopVectorPreflightResponse, + DesktopVectorSearchGranularity, DesktopVectorSearchProvider, DesktopVectorSearchResponse, + DesktopVectorSessionMatch, +}; +pub use errors::{ApiError, DesktopApiError, ServiceError}; +pub use local_review_types::{ + LocalReviewBundle, LocalReviewCommit, LocalReviewLayerFileChange, LocalReviewPrMeta, + LocalReviewReviewerDigest, LocalReviewReviewerQa, LocalReviewSemanticSummary, + LocalReviewSession, +}; pub use opensession_core::trace::{ Agent, Content, ContentBlock, Event, EventType, Session, SessionContext, Stats, }; - -// ─── Shared Enums ──────────────────────────────────────────────────────────── - -/// Sort order for session listings. -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum SortOrder { - #[default] - Recent, - Popular, - Longest, -} - -impl SortOrder { - pub fn as_str(&self) -> &str { - match self { - Self::Recent => "recent", - Self::Popular => "popular", - Self::Longest => "longest", - } - } -} - -impl std::fmt::Display for SortOrder { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// Time range filter for queries. -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum TimeRange { - #[serde(rename = "24h")] - Hours24, - #[serde(rename = "7d")] - Days7, - #[serde(rename = "30d")] - Days30, - #[default] - #[serde(rename = "all")] - All, -} - -impl TimeRange { - pub fn as_str(&self) -> &str { - match self { - Self::Hours24 => "24h", - Self::Days7 => "7d", - Self::Days30 => "30d", - Self::All => "all", - } - } -} - -impl std::fmt::Display for TimeRange { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// Type of link between two sessions. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum LinkType { - Handoff, - Related, - Parent, - Child, -} - -impl LinkType { - pub fn as_str(&self) -> &str { - match self { - Self::Handoff => "handoff", - Self::Related => "related", - Self::Parent => "parent", - Self::Child => "child", - } - } -} - -impl std::fmt::Display for LinkType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -// ─── Utilities ─────────────────────────────────────────────────────────────── - -/// Safely convert `u64` to `i64`, saturating at `i64::MAX` instead of wrapping. -pub fn saturating_i64(v: u64) -> i64 { - i64::try_from(v).unwrap_or(i64::MAX) -} - -// ─── Auth ──────────────────────────────────────────────────────────────────── - -/// Email + password registration. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct AuthRegisterRequest { - pub email: String, - pub password: String, - pub nickname: String, -} - -/// Email + password login. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LoginRequest { - pub email: String, - pub password: String, -} - -/// Returned on successful login / register / refresh. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct AuthTokenResponse { - pub access_token: String, - pub refresh_token: String, - pub expires_in: u64, - pub user_id: String, - pub nickname: String, -} - -/// Refresh token request. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct RefreshRequest { - pub refresh_token: String, -} - -/// Logout request (invalidate refresh token). -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LogoutRequest { - pub refresh_token: String, -} - -/// Change password request. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct ChangePasswordRequest { - pub current_password: String, - pub new_password: String, -} - -/// Returned by `POST /api/auth/verify` — confirms token validity. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct VerifyResponse { - pub user_id: String, - pub nickname: String, -} - -/// Full user profile returned by `GET /api/auth/me`. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct UserSettingsResponse { - pub user_id: String, - pub nickname: String, - pub created_at: String, - pub email: Option, - pub avatar_url: Option, - /// Linked OAuth providers. - #[serde(default)] - pub oauth_providers: Vec, -} - -/// Generic success response for operations that don't return data. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct OkResponse { - pub ok: bool, -} - -/// Response for API key issuance. The key is visible only at issuance time. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct IssueApiKeyResponse { - pub api_key: String, -} - -/// Public metadata for a user-managed git credential. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct GitCredentialSummary { - pub id: String, - pub label: String, - pub host: String, - pub path_prefix: String, - pub header_name: String, - pub created_at: String, - pub updated_at: String, - pub last_used_at: Option, -} - -/// Response for `GET /api/auth/git-credentials`. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct ListGitCredentialsResponse { - #[serde(default)] - pub credentials: Vec, -} - -/// Request for `POST /api/auth/git-credentials`. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct CreateGitCredentialRequest { - pub label: String, - pub host: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path_prefix: Option, - pub header_name: String, - pub header_value: String, -} - -/// Response for OAuth link initiation (redirect URL). -#[derive(Debug, Serialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct OAuthLinkResponse { - pub url: String, -} - -// ─── Sessions ──────────────────────────────────────────────────────────────── - -/// Request body for `POST /api/sessions` — upload a recorded session. -#[derive(Debug, Serialize, Deserialize)] -pub struct UploadRequest { - pub session: Session, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub body_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub linked_session_ids: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_remote: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_commit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_repo_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pr_number: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pr_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub score_plugin: Option, -} - -/// Returned on successful session upload — contains the new session ID and URL. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct UploadResponse { - pub id: String, - pub url: String, - #[serde(default)] - pub session_score: i64, - #[serde(default = "default_score_plugin")] - pub score_plugin: String, -} - -/// Flat session summary returned by list/detail endpoints. -/// This is NOT the full HAIL Session — it's a DB-derived summary. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct SessionSummary { - pub id: String, - pub user_id: Option, - pub nickname: Option, - pub tool: String, - pub agent_provider: Option, - pub agent_model: Option, - pub title: Option, - pub description: Option, - /// Comma-separated tags string - pub tags: Option, - pub created_at: String, - pub uploaded_at: String, - pub message_count: i64, - pub task_count: i64, - pub event_count: i64, - pub duration_seconds: i64, - pub total_input_tokens: i64, - pub total_output_tokens: i64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_remote: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_commit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git_repo_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pr_number: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pr_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub files_modified: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub files_read: Option, - #[serde(default)] - pub has_errors: bool, - #[serde(default = "default_max_active_agents")] - pub max_active_agents: i64, - #[serde(default)] - pub session_score: i64, - #[serde(default = "default_score_plugin")] - pub score_plugin: String, -} - -/// Paginated session listing returned by `GET /api/sessions`. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct SessionListResponse { - pub sessions: Vec, - pub total: i64, - pub page: u32, - pub per_page: u32, -} - -/// Canonical desktop IPC contract version shared between Rust and TS clients. -pub const DESKTOP_IPC_CONTRACT_VERSION: &str = "desktop-ipc-v6"; - -/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting. -#[derive(Debug, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct SessionListQuery { - #[serde(default = "default_page")] - pub page: u32, - #[serde(default = "default_per_page")] - pub per_page: u32, - pub search: Option, - pub tool: Option, - pub git_repo_name: Option, - /// Sort order (default: recent) - pub sort: Option, - /// Time range filter (default: all) - pub time_range: Option, -} - -/// Desktop session list query payload passed through Tauri invoke. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopSessionListQuery { - pub page: Option, - pub per_page: Option, - pub search: Option, - pub tool: Option, - pub git_repo_name: Option, - pub sort: Option, - pub time_range: Option, - pub force_refresh: Option, -} - -/// Repo list response used by server/worker/desktop adapters. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct SessionRepoListResponse { - pub repos: Vec, -} - -/// Desktop handoff build request payload. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopHandoffBuildRequest { - pub session_id: String, - pub pin_latest: bool, -} - -/// Desktop handoff build response payload. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopHandoffBuildResponse { - pub artifact_uri: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pinned_alias: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub download_file_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub download_content: Option, -} - -/// Desktop quick-share request payload. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopQuickShareRequest { - pub session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote: Option, -} - -/// Desktop quick-share response payload. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopQuickShareResponse { - pub source_uri: String, - pub shared_uri: String, - pub remote: String, - pub push_cmd: String, - #[serde(default)] - pub pushed: bool, - #[serde(default)] - pub auto_push_consent: bool, -} - -/// Desktop bridge contract/version handshake response. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopContractVersionResponse { - pub version: String, -} - -/// Desktop runtime settings payload for App settings UI. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSettingsResponse { - pub session_default_view: String, - pub summary: DesktopRuntimeSummarySettings, - pub vector_search: DesktopRuntimeVectorSearchSettings, - pub change_reader: DesktopRuntimeChangeReaderSettings, - pub lifecycle: DesktopRuntimeLifecycleSettings, - pub ui_constraints: DesktopRuntimeSummaryUiConstraints, -} - -/// Desktop runtime settings update request. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSettingsUpdateRequest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_default_view: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vector_search: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub change_reader: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lifecycle: Option, -} - -/// Local summary provider detection result for desktop setup/settings. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopSummaryProviderDetectResponse { - pub detected: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub transport: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub endpoint: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummaryProviderId { - Disabled, - Ollama, - CodexExec, - ClaudeCli, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummaryProviderTransport { - None, - Cli, - Http, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummarySourceMode { - SessionOnly, - SessionOrGitChanges, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummaryResponseStyle { - Compact, - Standard, - Detailed, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummaryOutputShape { - Layered, - FileList, - SecurityFirst, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummaryTriggerMode { - Manual, - OnSessionSave, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummaryStorageBackend { - HiddenRef, - LocalDb, - None, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummaryBatchExecutionMode { - Manual, - OnAppStart, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummaryBatchScope { - RecentDays, - All, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryProviderSettings { - pub id: DesktopSummaryProviderId, - pub transport: DesktopSummaryProviderTransport, - pub endpoint: String, - pub model: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryPromptSettings { - pub template: String, - pub default_template: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryResponseSettings { - pub style: DesktopSummaryResponseStyle, - pub shape: DesktopSummaryOutputShape, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryStorageSettings { - pub trigger: DesktopSummaryTriggerMode, - pub backend: DesktopSummaryStorageBackend, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryBatchSettings { - pub execution_mode: DesktopSummaryBatchExecutionMode, - pub scope: DesktopSummaryBatchScope, - pub recent_days: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummarySettings { - pub provider: DesktopRuntimeSummaryProviderSettings, - pub prompt: DesktopRuntimeSummaryPromptSettings, - pub response: DesktopRuntimeSummaryResponseSettings, - pub storage: DesktopRuntimeSummaryStorageSettings, - pub source_mode: DesktopSummarySourceMode, - pub batch: DesktopRuntimeSummaryBatchSettings, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryProviderSettingsUpdate { - pub id: DesktopSummaryProviderId, - pub endpoint: String, - pub model: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryPromptSettingsUpdate { - pub template: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryResponseSettingsUpdate { - pub style: DesktopSummaryResponseStyle, - pub shape: DesktopSummaryOutputShape, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryStorageSettingsUpdate { - pub trigger: DesktopSummaryTriggerMode, - pub backend: DesktopSummaryStorageBackend, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryBatchSettingsUpdate { - pub execution_mode: DesktopSummaryBatchExecutionMode, - pub scope: DesktopSummaryBatchScope, - pub recent_days: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummarySettingsUpdate { - pub provider: DesktopRuntimeSummaryProviderSettingsUpdate, - pub prompt: DesktopRuntimeSummaryPromptSettingsUpdate, - pub response: DesktopRuntimeSummaryResponseSettingsUpdate, - pub storage: DesktopRuntimeSummaryStorageSettingsUpdate, - pub source_mode: DesktopSummarySourceMode, - pub batch: DesktopRuntimeSummaryBatchSettingsUpdate, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeSummaryUiConstraints { - pub source_mode_locked: bool, - pub source_mode_locked_value: DesktopSummarySourceMode, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopVectorSearchProvider { - Ollama, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopVectorSearchGranularity { - EventLineChunk, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopVectorChunkingMode { - Auto, - Manual, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopVectorInstallState { - NotInstalled, - Installing, - Ready, - Failed, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopVectorIndexState { - Idle, - Running, - Complete, - Failed, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeVectorSearchSettings { - pub enabled: bool, - pub provider: DesktopVectorSearchProvider, - pub model: String, - pub endpoint: String, - pub granularity: DesktopVectorSearchGranularity, - pub chunking_mode: DesktopVectorChunkingMode, - pub chunk_size_lines: u16, - pub chunk_overlap_lines: u16, - pub top_k_chunks: u16, - pub top_k_sessions: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeVectorSearchSettingsUpdate { - pub enabled: bool, - pub provider: DesktopVectorSearchProvider, - pub model: String, - pub endpoint: String, - pub granularity: DesktopVectorSearchGranularity, - pub chunking_mode: DesktopVectorChunkingMode, - pub chunk_size_lines: u16, - pub chunk_overlap_lines: u16, - pub top_k_chunks: u16, - pub top_k_sessions: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopChangeReaderScope { - SummaryOnly, - FullContext, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopChangeReaderVoiceProvider { - Openai, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeChangeReaderVoiceSettings { - pub enabled: bool, - pub provider: DesktopChangeReaderVoiceProvider, - pub model: String, - pub voice: String, - pub api_key_configured: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeChangeReaderVoiceSettingsUpdate { - pub enabled: bool, - pub provider: DesktopChangeReaderVoiceProvider, - pub model: String, - pub voice: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api_key: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeChangeReaderSettings { - pub enabled: bool, - pub scope: DesktopChangeReaderScope, - pub qa_enabled: bool, - pub max_context_chars: u32, - pub voice: DesktopRuntimeChangeReaderVoiceSettings, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeChangeReaderSettingsUpdate { - pub enabled: bool, - pub scope: DesktopChangeReaderScope, - pub qa_enabled: bool, - pub max_context_chars: u32, - pub voice: DesktopRuntimeChangeReaderVoiceSettingsUpdate, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeLifecycleSettings { - pub enabled: bool, - pub session_ttl_days: u32, - pub summary_ttl_days: u32, - pub cleanup_interval_secs: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopRuntimeLifecycleSettingsUpdate { - pub enabled: bool, - pub session_ttl_days: u32, - pub summary_ttl_days: u32, - pub cleanup_interval_secs: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopLifecycleCleanupState { - Idle, - Running, - Complete, - Failed, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopLifecycleCleanupStatusResponse { - pub state: DesktopLifecycleCleanupState, - pub deleted_sessions: u32, - pub deleted_summaries: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub started_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub finished_at: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopVectorPreflightResponse { - pub provider: DesktopVectorSearchProvider, - pub endpoint: String, - pub model: String, - pub ollama_reachable: bool, - pub model_installed: bool, - pub install_state: DesktopVectorInstallState, - pub progress_pct: u8, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopVectorInstallStatusResponse { - pub state: DesktopVectorInstallState, - pub model: String, - pub progress_pct: u8, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopVectorIndexStatusResponse { - pub state: DesktopVectorIndexState, - pub processed_sessions: u32, - pub total_sessions: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub started_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub finished_at: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum DesktopSummaryBatchState { - Idle, - Running, - Complete, - Failed, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopSummaryBatchStatusResponse { - pub state: DesktopSummaryBatchState, - pub processed_sessions: u32, - pub total_sessions: u32, - pub failed_sessions: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub started_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub finished_at: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopVectorSessionMatch { - pub session: SessionSummary, - pub score: f32, - pub chunk_id: String, - pub start_line: u32, - pub end_line: u32, - pub snippet: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopVectorSearchResponse { - pub query: String, - #[serde(default)] - pub sessions: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, - pub total_candidates: u32, -} - -/// Session summary payload returned by desktop runtime. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopSessionSummaryResponse { - pub session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "ts", ts(type = "any"))] - pub summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "ts", ts(type = "any"))] - pub source_details: Option, - #[serde(default)] - #[cfg_attr(feature = "ts", ts(type = "any[]"))] - pub diff_tree: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_kind: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generation_kind: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopChangeReadRequest { - pub session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopChangeReadResponse { - pub session_id: String, - pub scope: DesktopChangeReaderScope, - pub narrative: String, - #[serde(default)] - pub citations: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub warning: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopChangeQuestionRequest { - pub session_id: String, - pub question: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopChangeReaderTtsRequest { - pub text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopChangeReaderTtsResponse { - pub mime_type: String, - pub audio_base64: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub warning: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopChangeQuestionResponse { - pub session_id: String, - pub question: String, - pub scope: DesktopChangeReaderScope, - pub answer: String, - #[serde(default)] - pub citations: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub warning: Option, -} - -/// Structured desktop bridge error payload. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct DesktopApiError { - pub code: String, - pub status: u16, - pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "ts", ts(type = "Record | null"))] - pub details: Option, -} - -impl SessionListQuery { - /// Returns true when this query targets the anonymous public feed and is safe to edge-cache. - pub fn is_public_feed_cacheable( - &self, - has_auth_header: bool, - has_session_cookie: bool, - ) -> bool { - !has_auth_header - && !has_session_cookie - && self.search.as_deref().is_none_or(|s| s.trim().is_empty()) - && self - .git_repo_name - .as_deref() - .is_none_or(|repo| repo.trim().is_empty()) - && self.page <= 10 - && self.per_page <= 50 - } -} - -#[cfg(test)] -mod session_list_query_tests { - use super::*; - - fn base_query() -> SessionListQuery { - SessionListQuery { - page: 1, - per_page: 20, - search: None, - tool: None, - git_repo_name: None, - sort: None, - time_range: None, - } - } - - #[test] - fn public_feed_cacheable_when_anonymous_default_feed() { - let q = base_query(); - assert!(q.is_public_feed_cacheable(false, false)); - } - - #[test] - fn public_feed_not_cacheable_with_auth_or_cookie() { - let q = base_query(); - assert!(!q.is_public_feed_cacheable(true, false)); - assert!(!q.is_public_feed_cacheable(false, true)); - } - - #[test] - fn public_feed_not_cacheable_for_search_or_large_page() { - let mut q = base_query(); - q.search = Some("hello".into()); - assert!(!q.is_public_feed_cacheable(false, false)); - - let mut q = base_query(); - q.git_repo_name = Some("org/repo".into()); - assert!(!q.is_public_feed_cacheable(false, false)); - - let mut q = base_query(); - q.page = 11; - assert!(!q.is_public_feed_cacheable(false, false)); - - let mut q = base_query(); - q.per_page = 100; - assert!(!q.is_public_feed_cacheable(false, false)); - } -} - -fn default_page() -> u32 { - 1 -} -fn default_per_page() -> u32 { - 20 -} -fn default_max_active_agents() -> i64 { - 1 -} - -fn default_score_plugin() -> String { - opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string() -} - -/// Single session detail returned by `GET /api/sessions/:id`. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct SessionDetail { - #[serde(flatten)] - #[cfg_attr(feature = "ts", ts(flatten))] - pub summary: SessionSummary, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub linked_sessions: Vec, -} - -/// A link between two sessions (e.g., handoff chain). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct SessionLink { - pub session_id: String, - pub linked_session_id: String, - pub link_type: LinkType, - pub created_at: String, -} - -/// Source descriptor for parser preview requests. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub enum ParseSource { - /// Fetch and parse a raw file from a generic Git remote/ref/path source. - Git { - remote: String, - r#ref: String, - path: String, - }, - /// Fetch and parse a raw file from a public GitHub repository. - Github { - owner: String, - repo: String, - r#ref: String, - path: String, - }, - /// Parse inline file content supplied by clients (for local upload preview). - Inline { - filename: String, - /// Base64-encoded UTF-8 text content. - content_base64: String, - }, -} - -/// Candidate parser ranked by detection confidence. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct ParseCandidate { - pub id: String, - pub confidence: u8, - pub reason: String, -} - -/// Request body for `POST /api/parse/preview`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct ParsePreviewRequest { - pub source: ParseSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parser_hint: Option, -} - -/// Response body for `POST /api/parse/preview`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct ParsePreviewResponse { - pub parser_used: String, - #[serde(default)] - pub parser_candidates: Vec, - #[cfg_attr(feature = "ts", ts(type = "any"))] - pub session: Session, - pub source: ParseSource, - #[serde(default)] - pub warnings: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native_adapter: Option, -} - -/// Structured parser preview error response. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct ParsePreviewErrorResponse { - pub code: String, - pub message: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub parser_candidates: Vec, -} - -/// Local review bundle generated from a PR range. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LocalReviewBundle { - pub review_id: String, - pub generated_at: String, - pub pr: LocalReviewPrMeta, - #[serde(default)] - pub commits: Vec, - #[serde(default)] - pub sessions: Vec, -} - -/// PR metadata for a local review bundle. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LocalReviewPrMeta { - pub url: String, - pub owner: String, - pub repo: String, - pub number: u64, - pub remote: String, - pub base_sha: String, - pub head_sha: String, -} - -/// Reviewer-focused digest extracted from mapped sessions for a commit. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LocalReviewReviewerQa { - pub question: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub answer: Option, -} - -/// Reviewer-focused digest extracted from mapped sessions for a commit. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LocalReviewReviewerDigest { - #[serde(default)] - pub qa: Vec, - #[serde(default)] - pub modified_files: Vec, - #[serde(default)] - pub test_files: Vec, -} - -/// Commit row in a local review bundle. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LocalReviewCommit { - pub sha: String, - pub title: String, - pub author_name: String, - pub author_email: String, - pub authored_at: String, - #[serde(default)] - pub session_ids: Vec, - #[serde(default)] - pub reviewer_digest: LocalReviewReviewerDigest, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub semantic_summary: Option, -} - -/// Layer/file summary section for local review semantic payloads. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LocalReviewLayerFileChange { - pub layer: String, - pub summary: String, - #[serde(default)] - pub files: Vec, -} - -/// Commit-level semantic summary used when session mappings are weak or absent. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LocalReviewSemanticSummary { - pub changes: String, - pub auth_security: String, - #[serde(default)] - pub layer_file_changes: Vec, - pub source_kind: String, - pub generation_kind: String, - pub provider: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(default)] - #[cfg_attr(feature = "ts", ts(type = "any[]"))] - pub diff_tree: Vec, -} - -/// Session payload mapped into a local review bundle. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct LocalReviewSession { - pub session_id: String, - pub ledger_ref: String, - pub hail_path: String, - #[serde(default)] - pub commit_shas: Vec, - #[cfg_attr(feature = "ts", ts(type = "any"))] - pub session: Session, -} - -// ─── Streaming Events ──────────────────────────────────────────────────────── - -/// Request body for `POST /api/sessions/:id/events` — append live events. -#[derive(Debug, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct StreamEventsRequest { - #[cfg_attr(feature = "ts", ts(type = "any"))] - pub agent: Option, - #[cfg_attr(feature = "ts", ts(type = "any"))] - pub context: Option, - #[cfg_attr(feature = "ts", ts(type = "any[]"))] - pub events: Vec, -} - -/// Returned by `POST /api/sessions/:id/events` — number of events accepted. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct StreamEventsResponse { - pub accepted: usize, -} - -// ─── Health ────────────────────────────────────────────────────────────────── - -/// Returned by `GET /api/health` — server liveness check. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct HealthResponse { - pub status: String, - pub version: String, -} - -/// Returned by `GET /api/capabilities` — runtime feature availability. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct CapabilitiesResponse { - pub auth_enabled: bool, - pub parse_preview_enabled: bool, - pub register_targets: Vec, - pub share_modes: Vec, -} - -pub const DEFAULT_REGISTER_TARGETS: &[&str] = &["local", "git"]; -pub const DEFAULT_SHARE_MODES: &[&str] = &["web", "git", "quick", "json"]; - -impl CapabilitiesResponse { - /// Build runtime capability payload with shared defaults. - pub fn for_runtime(auth_enabled: bool, parse_preview_enabled: bool) -> Self { - Self { - auth_enabled, - parse_preview_enabled, - register_targets: DEFAULT_REGISTER_TARGETS - .iter() - .map(|target| (*target).to_string()) - .collect(), - share_modes: DEFAULT_SHARE_MODES - .iter() - .map(|mode| (*mode).to_string()) - .collect(), - } - } -} - -// ─── Service Error ─────────────────────────────────────────────────────────── - -/// Framework-agnostic service error. -/// -/// Each variant maps to an HTTP status code. Both the Axum server and -/// Cloudflare Worker convert this into the appropriate response type. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub enum ServiceError { - BadRequest(String), - Unauthorized(String), - Forbidden(String), - NotFound(String), - Conflict(String), - Internal(String), -} - -impl ServiceError { - /// HTTP status code as a `u16`. - pub fn status_code(&self) -> u16 { - match self { - Self::BadRequest(_) => 400, - Self::Unauthorized(_) => 401, - Self::Forbidden(_) => 403, - Self::NotFound(_) => 404, - Self::Conflict(_) => 409, - Self::Internal(_) => 500, - } - } - - /// Stable machine-readable error code. - pub fn code(&self) -> &'static str { - match self { - Self::BadRequest(_) => "bad_request", - Self::Unauthorized(_) => "unauthorized", - Self::Forbidden(_) => "forbidden", - Self::NotFound(_) => "not_found", - Self::Conflict(_) => "conflict", - Self::Internal(_) => "internal", - } - } - - /// The error message. - pub fn message(&self) -> &str { - match self { - Self::BadRequest(m) - | Self::Unauthorized(m) - | Self::Forbidden(m) - | Self::NotFound(m) - | Self::Conflict(m) - | Self::Internal(m) => m, - } - } - - /// Build a closure that logs a DB/IO error and returns `Internal`. - pub fn from_db(context: &str) -> impl FnOnce(E) -> Self + '_ { - move |e| Self::Internal(format!("{context}: {e}")) - } -} - -impl std::fmt::Display for ServiceError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message()) - } -} - -impl std::error::Error for ServiceError {} - -// ─── Error ─────────────────────────────────────────────────────────────────── - -/// API error payload. -#[derive(Debug, Serialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts", ts(export))] -pub struct ApiError { - pub code: String, - pub message: String, -} - -impl From<&ServiceError> for ApiError { - fn from(e: &ServiceError) -> Self { - Self { - code: e.code().to_string(), - message: e.message().to_string(), - } - } -} - -// ─── TypeScript generation ─────────────────────────────────────────────────── +pub use parse_preview_types::{ + ParseCandidate, ParsePreviewErrorResponse, ParsePreviewRequest, ParsePreviewResponse, + ParseSource, +}; +pub use session_types::{ + CapabilitiesResponse, DEFAULT_REGISTER_TARGETS, DEFAULT_SHARE_MODES, DesktopSessionListQuery, + HealthResponse, SessionDetail, SessionLink, SessionListQuery, SessionListResponse, + SessionRepoListResponse, SessionSummary, StreamEventsRequest, StreamEventsResponse, + UploadRequest, UploadResponse, +}; +pub use shared_types::{LinkType, SortOrder, TimeRange, saturating_i64}; #[cfg(test)] mod schema_tests { @@ -1756,7 +284,6 @@ mod tests { use std::path::PathBuf; use ts_rs::TS; - /// Run with: cargo test -p opensession-api -- export_typescript --nocapture #[test] fn export_typescript() { let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -1770,23 +297,18 @@ mod tests { ); parts.push(String::new()); - // Collect all type declarations. - // Structs: `type X = {...}` → `export interface X {...}` - // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"` macro_rules! collect_ts { ($($t:ty),+ $(,)?) => { $( let decl = <$t>::decl(&cfg); let is_struct_decl = decl.contains(" = {") && !decl.contains("} |"); let decl = if is_struct_decl { - // Struct → export interface decl .replacen("type ", "export interface ", 1) .replace(" = {", " {") .trim_end_matches(';') .to_string() } else { - // Enum/union → export type decl .replacen("type ", "export type ", 1) .trim_end_matches(';') @@ -1799,11 +321,9 @@ mod tests { } collect_ts!( - // Shared enums SortOrder, TimeRange, LinkType, - // Auth AuthRegisterRequest, LoginRequest, AuthTokenResponse, @@ -1818,7 +338,6 @@ mod tests { ListGitCredentialsResponse, CreateGitCredentialRequest, OAuthLinkResponse, - // Sessions UploadResponse, SessionSummary, SessionListResponse, @@ -1902,11 +421,9 @@ mod tests { LocalReviewLayerFileChange, LocalReviewSemanticSummary, LocalReviewSession, - // OAuth oauth::AuthProvidersResponse, oauth::OAuthProviderInfo, oauth::LinkedProvider, - // Health HealthResponse, CapabilitiesResponse, ApiError, @@ -1914,7 +431,6 @@ mod tests { let content = parts.join("\n"); - // Write to file if let Some(parent) = out_dir.parent() { std::fs::create_dir_all(parent).ok(); } diff --git a/crates/api/src/local_review_types.rs b/crates/api/src/local_review_types.rs new file mode 100644 index 00000000..06a0be8d --- /dev/null +++ b/crates/api/src/local_review_types.rs @@ -0,0 +1,117 @@ +use opensession_core::trace::Session; +use serde::{Deserialize, Serialize}; + +/// Local review bundle generated from a PR range. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LocalReviewBundle { + pub review_id: String, + pub generated_at: String, + pub pr: LocalReviewPrMeta, + #[serde(default)] + pub commits: Vec, + #[serde(default)] + pub sessions: Vec, +} + +/// PR metadata for a local review bundle. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LocalReviewPrMeta { + pub url: String, + pub owner: String, + pub repo: String, + pub number: u64, + pub remote: String, + pub base_sha: String, + pub head_sha: String, +} + +/// Reviewer-focused digest extracted from mapped sessions for a commit. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LocalReviewReviewerQa { + pub question: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub answer: Option, +} + +/// Reviewer-focused digest extracted from mapped sessions for a commit. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LocalReviewReviewerDigest { + #[serde(default)] + pub qa: Vec, + #[serde(default)] + pub modified_files: Vec, + #[serde(default)] + pub test_files: Vec, +} + +/// Commit row in a local review bundle. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LocalReviewCommit { + pub sha: String, + pub title: String, + pub author_name: String, + pub author_email: String, + pub authored_at: String, + #[serde(default)] + pub session_ids: Vec, + #[serde(default)] + pub reviewer_digest: LocalReviewReviewerDigest, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub semantic_summary: Option, +} + +/// Layer/file summary section for local review semantic payloads. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LocalReviewLayerFileChange { + pub layer: String, + pub summary: String, + #[serde(default)] + pub files: Vec, +} + +/// Commit-level semantic summary used when session mappings are weak or absent. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LocalReviewSemanticSummary { + pub changes: String, + pub auth_security: String, + #[serde(default)] + pub layer_file_changes: Vec, + pub source_kind: String, + pub generation_kind: String, + pub provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default)] + #[cfg_attr(feature = "ts", ts(type = "any[]"))] + pub diff_tree: Vec, +} + +/// Session payload mapped into a local review bundle. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct LocalReviewSession { + pub session_id: String, + pub ledger_ref: String, + pub hail_path: String, + #[serde(default)] + pub commit_shas: Vec, + #[cfg_attr(feature = "ts", ts(type = "any"))] + pub session: Session, +} diff --git a/crates/api/src/oauth.rs b/crates/api/src/oauth.rs index 6637c1e5..776c30f9 100644 --- a/crates/api/src/oauth.rs +++ b/crates/api/src/oauth.rs @@ -252,7 +252,7 @@ pub fn extract_user_info( return Err(ServiceError::Internal(format!( "OAuth userinfo missing '{}' field", config.field_map.id - ))) + ))); } }; diff --git a/crates/api/src/parse_preview_source.rs b/crates/api/src/parse_preview_source.rs new file mode 100644 index 00000000..0635b035 --- /dev/null +++ b/crates/api/src/parse_preview_source.rs @@ -0,0 +1,593 @@ +use base64::Engine; +use std::collections::HashSet; +use url::Url; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitSource { + pub remote: String, + pub r#ref: String, + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GithubSource { + pub owner: String, + pub repo: String, + pub r#ref: String, + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsePreviewSourceError { + message: String, +} + +impl ParsePreviewSourceError { + pub fn invalid_source(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for ParsePreviewSourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ParsePreviewSourceError {} + +pub fn normalize_git_source( + remote: &str, + r#ref: &str, + path: &str, +) -> Result { + let remote = decode_and_trim(remote, "remote")?; + let url = validate_remote_url(&remote)?; + let remote = normalized_remote_origin(&url); + + let r#ref = decode_and_trim(r#ref, "ref")?; + if !is_valid_ref(r#ref.as_str()) { + return Err(ParsePreviewSourceError::invalid_source( + "ref must match [A-Za-z0-9._/-]{1,255} without '..', '~', '^', ':', or '\\'", + )); + } + + let path = normalize_repo_path(path)?; + + Ok(GitSource { + remote, + r#ref, + path, + }) +} + +pub fn normalize_github_source( + owner: &str, + repo: &str, + r#ref: &str, + path: &str, +) -> Result { + let owner = decode_and_trim(owner, "owner")?; + if !is_valid_owner_repo(owner.as_str()) { + return Err(ParsePreviewSourceError::invalid_source( + "owner must match [A-Za-z0-9._-]{1,100}", + )); + } + + let repo = decode_and_trim(repo, "repo")?; + if !is_valid_owner_repo(repo.as_str()) { + return Err(ParsePreviewSourceError::invalid_source( + "repo must match [A-Za-z0-9._-]{1,100}", + )); + } + + let r#ref = decode_and_trim(r#ref, "ref")?; + if !is_valid_ref(r#ref.as_str()) { + return Err(ParsePreviewSourceError::invalid_source( + "ref must match [A-Za-z0-9._/-]{1,255} without '..', '~', '^', ':', or '\\'", + )); + } + + let path = normalize_repo_path(path)?; + + Ok(GithubSource { + owner, + repo, + r#ref, + path, + }) +} + +pub fn normalize_filename(filename: &str) -> Result { + let decoded = decode_and_trim(filename, "filename")?; + let normalized = decoded + .replace('\\', "/") + .rsplit('/') + .next() + .map(str::trim) + .unwrap_or_default() + .to_string(); + if normalized.is_empty() || normalized == "." || normalized == ".." { + return Err(ParsePreviewSourceError::invalid_source( + "inline filename must be a non-empty filename", + )); + } + if normalized.len() > 255 { + return Err(ParsePreviewSourceError::invalid_source( + "inline filename is too long (max 255 chars)", + )); + } + Ok(normalized) +} + +pub fn decode_inline_content(content_base64: &str) -> Result, ParsePreviewSourceError> { + let trimmed = content_base64.trim(); + if trimmed.is_empty() { + return Err(ParsePreviewSourceError::invalid_source( + "inline content_base64 is required", + )); + } + base64::engine::general_purpose::STANDARD + .decode(trimmed) + .map_err(|_| { + ParsePreviewSourceError::invalid_source("inline content_base64 must be valid base64") + }) +} + +pub fn file_name_from_path(path: &str) -> String { + path.rsplit('/') + .next() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("session.txt") + .to_string() +} + +pub fn repo_path_from_remote(remote: &str) -> Result { + let parsed = validate_remote_url(remote)?; + Ok(repo_path_segments(&parsed)?.join("/")) +} + +pub fn build_git_raw_url( + source: &GitSource, + gitlab_hosts: &HashSet, +) -> Result { + let remote = validate_remote_url(&source.remote)?; + let host = remote + .host_str() + .ok_or_else(|| ParsePreviewSourceError::invalid_source("remote host is required"))? + .to_ascii_lowercase(); + + if host == "github.com" { + return build_github_raw_url(&remote, &source.r#ref, &source.path); + } + if is_gitlab_host(&host, gitlab_hosts) { + return build_gitlab_raw_url(&remote, &source.r#ref, &source.path); + } + + build_generic_raw_url(&remote, &source.r#ref, &source.path) +} + +pub fn provider_for_host(host: &str, gitlab_hosts: &HashSet) -> Option<&'static str> { + if host == "github.com" { + return Some("github"); + } + if is_gitlab_host(host, gitlab_hosts) { + return Some("gitlab"); + } + None +} + +pub fn is_allowed_content_type(content_type: &str) -> bool { + let media_type = content_type + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if media_type.is_empty() { + return true; + } + if media_type.starts_with("text/") { + return true; + } + media_type == "application/json" + || media_type == "application/jsonl" + || media_type == "application/x-ndjson" + || media_type.ends_with("+json") +} + +pub fn looks_binary(bytes: &[u8]) -> bool { + bytes.contains(&0) || std::str::from_utf8(bytes).is_err() +} + +fn decode_and_trim(value: &str, field: &str) -> Result { + urlencoding::decode(value) + .map(|decoded| decoded.trim().to_string()) + .map_err(|_| { + ParsePreviewSourceError::invalid_source(format!( + "{field} contains invalid percent encoding" + )) + }) +} + +fn normalize_repo_path(path: &str) -> Result { + let decoded = decode_and_trim(path, "path")?; + if decoded.is_empty() { + return Err(ParsePreviewSourceError::invalid_source("path is required")); + } + if decoded.starts_with('/') { + return Err(ParsePreviewSourceError::invalid_source( + "path must be repository-relative", + )); + } + + let mut normalized_segments = Vec::::new(); + for segment in decoded.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err(ParsePreviewSourceError::invalid_source( + "path cannot contain empty, '.' or '..' segments", + )); + } + if segment.contains('\\') { + return Err(ParsePreviewSourceError::invalid_source( + "path cannot contain backslash characters", + )); + } + normalized_segments.push(segment.to_string()); + } + + Ok(normalized_segments.join("/")) +} + +fn is_valid_owner_repo(value: &str) -> bool { + let len = value.len(); + (1..=100).contains(&len) + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-') +} + +fn is_valid_ref(value: &str) -> bool { + let len = value.len(); + if !(1..=255).contains(&len) { + return false; + } + if value.contains("..") + || value.contains('~') + || value.contains('^') + || value.contains(':') + || value.contains('\\') + { + return false; + } + if value.starts_with('/') || value.ends_with('/') || value.contains("//") { + return false; + } + + value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-' || b == b'/') +} + +fn validate_remote_url(remote: &str) -> Result { + let parsed = Url::parse(remote).map_err(|_| { + ParsePreviewSourceError::invalid_source("remote must be an absolute http(s) URL") + })?; + + let scheme = parsed.scheme().to_ascii_lowercase(); + if scheme != "https" { + return Err(ParsePreviewSourceError::invalid_source( + "remote must use https", + )); + } + + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(ParsePreviewSourceError::invalid_source( + "remote cannot include credentials", + )); + } + + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(ParsePreviewSourceError::invalid_source( + "remote cannot include query or fragment", + )); + } + + let host = parsed + .host_str() + .ok_or_else(|| ParsePreviewSourceError::invalid_source("remote host is required"))?; + if host.parse::().is_ok() { + return Err(ParsePreviewSourceError::invalid_source( + "remote host must be a DNS name", + )); + } + + if host.eq_ignore_ascii_case("localhost") { + return Err(ParsePreviewSourceError::invalid_source( + "remote host must not be localhost", + )); + } + + Ok(parsed) +} + +fn normalized_remote_origin(url: &Url) -> String { + let mut normalized = url.clone(); + normalized.set_query(None); + normalized.set_fragment(None); + let _ = normalized.set_username(""); + let _ = normalized.set_password(None); + + let trimmed_path = normalized.path().trim_end_matches('/').to_string(); + if trimmed_path.is_empty() { + normalized.set_path("/"); + } else { + normalized.set_path(&trimmed_path); + } + + let mut rendered = normalized.to_string(); + if rendered.ends_with('/') && normalized.path() == "/" { + rendered.pop(); + } + rendered +} + +fn repo_path_segments(url: &Url) -> Result, ParsePreviewSourceError> { + let mut segments: Vec = url + .path_segments() + .ok_or_else(|| { + ParsePreviewSourceError::invalid_source("remote repository path is invalid") + })? + .filter(|segment| !segment.is_empty()) + .map(|segment| { + urlencoding::decode(segment) + .map(|decoded| decoded.trim().to_string()) + .map_err(|_| { + ParsePreviewSourceError::invalid_source( + "remote repository path contains invalid percent encoding", + ) + }) + }) + .collect::, _>>()?; + + if segments.len() < 2 { + return Err(ParsePreviewSourceError::invalid_source( + "remote must include owner/group and repository", + )); + } + + if let Some(last) = segments.last_mut() { + *last = strip_git_suffix(last).to_string(); + } + + if segments + .iter() + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + return Err(ParsePreviewSourceError::invalid_source( + "remote repository path contains invalid segments", + )); + } + + Ok(segments) +} + +fn build_github_raw_url( + url: &Url, + r#ref: &str, + path: &str, +) -> Result { + let segments = repo_path_segments(url)?; + if segments.len() != 2 { + return Err(ParsePreviewSourceError::invalid_source( + "github remote must look like https://github.com/{owner}/{repo}", + )); + } + + Ok(format!( + "https://raw.githubusercontent.com/{}/{}/{}/{}", + segments[0], + segments[1], + encode_segments(r#ref), + encode_segments(path) + )) +} + +fn build_gitlab_raw_url( + url: &Url, + r#ref: &str, + path: &str, +) -> Result { + let project_path = repo_path_segments(url)?.join("/"); + let origin = origin_from_url(url)?; + Ok(format!( + "{}/{}/-/raw/{}/{}", + origin, + encode_segments(&project_path), + encode_segments(r#ref), + encode_segments(path) + )) +} + +fn build_generic_raw_url( + url: &Url, + r#ref: &str, + path: &str, +) -> Result { + let repo_path = repo_path_segments(url)?.join("/"); + let origin = origin_from_url(url)?; + Ok(format!( + "{}/{}/raw/{}/{}", + origin, + encode_segments(&repo_path), + encode_segments(r#ref), + encode_segments(path) + )) +} + +fn origin_from_url(url: &Url) -> Result { + let host = url + .host_str() + .ok_or_else(|| ParsePreviewSourceError::invalid_source("remote host is required"))?; + let mut origin = format!("{}://{host}", url.scheme()); + if let Some(port) = url.port() { + origin.push(':'); + origin.push_str(&port.to_string()); + } + Ok(origin) +} + +fn encode_segments(value: &str) -> String { + value + .split('/') + .map(|segment| urlencoding::encode(segment).into_owned()) + .collect::>() + .join("/") +} + +fn strip_git_suffix(value: &str) -> &str { + value.strip_suffix(".git").unwrap_or(value) +} + +fn is_gitlab_host(host: &str, gitlab_hosts: &HashSet) -> bool { + host == "gitlab.com" + || gitlab_hosts.contains(host) + || host + .strip_prefix("gitlab.") + .is_some_and(|suffix| suffix.contains('.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_repo_path_rejects_parent_segments() { + let err = normalize_git_source("https://github.com/org/repo", "main", "../secret") + .expect_err("path traversal must fail"); + assert!( + err.message() + .contains("path cannot contain empty, '.' or '..' segments") + ); + } + + #[test] + fn decode_inline_content_rejects_empty_input() { + let err = decode_inline_content(" ").expect_err("empty inline payload must fail"); + assert_eq!(err.message(), "inline content_base64 is required"); + } + + #[test] + fn build_git_raw_url_uses_provider_aware_patterns() { + let no_gitlab_hosts = HashSet::new(); + let configured_gitlab_hosts = HashSet::from(["gitlab.internal.example.com".to_string()]); + + let github = build_git_raw_url( + &GitSource { + remote: "https://github.com/hwisu/opensession".to_string(), + r#ref: "main".to_string(), + path: "sessions/demo.hail.jsonl".to_string(), + }, + &no_gitlab_hosts, + ) + .expect("github raw url should build"); + assert_eq!( + github, + "https://raw.githubusercontent.com/hwisu/opensession/main/sessions/demo.hail.jsonl" + ); + + let gitlab = build_git_raw_url( + &GitSource { + remote: "https://gitlab.com/group/sub/repo".to_string(), + r#ref: "feature/one".to_string(), + path: "logs/session.hail.jsonl".to_string(), + }, + &no_gitlab_hosts, + ) + .expect("gitlab raw url should build"); + assert_eq!( + gitlab, + "https://gitlab.com/group/sub/repo/-/raw/feature/one/logs/session.hail.jsonl" + ); + + let gitlab_self_managed = build_git_raw_url( + &GitSource { + remote: "https://gitlab.internal.example.com/team/repo".to_string(), + r#ref: "main".to_string(), + path: "logs/session.hail.jsonl".to_string(), + }, + &configured_gitlab_hosts, + ) + .expect("self-managed gitlab raw url should build"); + assert_eq!( + gitlab_self_managed, + "https://gitlab.internal.example.com/team/repo/-/raw/main/logs/session.hail.jsonl" + ); + + let generic = build_git_raw_url( + &GitSource { + remote: "https://code.example.com/team/repo".to_string(), + r#ref: "release/v1".to_string(), + path: "hail/demo.hail.jsonl".to_string(), + }, + &no_gitlab_hosts, + ) + .expect("generic raw url should build"); + assert_eq!( + generic, + "https://code.example.com/team/repo/raw/release/v1/hail/demo.hail.jsonl" + ); + } + + #[test] + fn provider_for_host_detects_supported_hosts() { + let no_gitlab_hosts = HashSet::new(); + let configured_gitlab_hosts = HashSet::from(["gitlab.internal.example.com".to_string()]); + + assert_eq!( + provider_for_host("github.com", &no_gitlab_hosts), + Some("github") + ); + assert_eq!( + provider_for_host("gitlab.com", &no_gitlab_hosts), + Some("gitlab") + ); + assert_eq!( + provider_for_host("gitlab.internal.example.com", &no_gitlab_hosts), + Some("gitlab") + ); + assert_eq!( + provider_for_host("gitlab.internal.example.com", &configured_gitlab_hosts), + Some("gitlab") + ); + assert_eq!( + provider_for_host("evil-gitlab.example", &no_gitlab_hosts), + None + ); + assert_eq!( + provider_for_host("code.example.com", &no_gitlab_hosts), + None + ); + } + + #[test] + fn is_allowed_content_type_accepts_text_and_json() { + assert!(is_allowed_content_type("text/plain; charset=utf-8")); + assert!(is_allowed_content_type("application/json")); + assert!(is_allowed_content_type("application/vnd.api+json")); + assert!(!is_allowed_content_type("application/octet-stream")); + } + + #[test] + fn looks_binary_rejects_null_and_non_utf8_bytes() { + assert!(looks_binary(b"hello\0world")); + assert!(looks_binary(&[0xff, 0xfe, 0xfd])); + assert!(!looks_binary("hello world".as_bytes())); + } +} diff --git a/crates/api/src/parse_preview_types.rs b/crates/api/src/parse_preview_types.rs new file mode 100644 index 00000000..e8bbbd14 --- /dev/null +++ b/crates/api/src/parse_preview_types.rs @@ -0,0 +1,73 @@ +use opensession_core::trace::Session; +use serde::{Deserialize, Serialize}; + +/// Source descriptor for parser preview requests. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum ParseSource { + Git { + remote: String, + r#ref: String, + path: String, + }, + Github { + owner: String, + repo: String, + r#ref: String, + path: String, + }, + Inline { + filename: String, + content_base64: String, + }, +} + +/// Candidate parser ranked by detection confidence. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct ParseCandidate { + pub id: String, + pub confidence: u8, + pub reason: String, +} + +/// Request body for `POST /api/parse/preview`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct ParsePreviewRequest { + pub source: ParseSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parser_hint: Option, +} + +/// Response body for `POST /api/parse/preview`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct ParsePreviewResponse { + pub parser_used: String, + #[serde(default)] + pub parser_candidates: Vec, + #[cfg_attr(feature = "ts", ts(type = "any"))] + pub session: Session, + pub source: ParseSource, + #[serde(default)] + pub warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_adapter: Option, +} + +/// Structured parser preview error response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct ParsePreviewErrorResponse { + pub code: String, + pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parser_candidates: Vec, +} diff --git a/crates/api/src/session_types.rs b/crates/api/src/session_types.rs new file mode 100644 index 00000000..f75fbc24 --- /dev/null +++ b/crates/api/src/session_types.rs @@ -0,0 +1,309 @@ +use crate::shared_types::{LinkType, SortOrder, TimeRange}; +use opensession_core::trace::{Agent, Event, Session, SessionContext}; +use serde::{Deserialize, Serialize}; + +/// Request body for `POST /api/sessions` — upload a recorded session. +#[derive(Debug, Serialize, Deserialize)] +pub struct UploadRequest { + pub session: Session, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linked_session_ids: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_remote: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_repo_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pr_number: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pr_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub score_plugin: Option, +} + +/// Returned on successful session upload — contains the new session ID and URL. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct UploadResponse { + pub id: String, + pub url: String, + #[serde(default)] + pub session_score: i64, + #[serde(default = "default_score_plugin")] + pub score_plugin: String, +} + +/// Flat session summary returned by list/detail endpoints. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct SessionSummary { + pub id: String, + pub user_id: Option, + pub nickname: Option, + pub tool: String, + pub agent_provider: Option, + pub agent_model: Option, + pub title: Option, + pub description: Option, + pub tags: Option, + pub created_at: String, + pub uploaded_at: String, + pub message_count: i64, + pub task_count: i64, + pub event_count: i64, + pub duration_seconds: i64, + pub total_input_tokens: i64, + pub total_output_tokens: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_remote: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_repo_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pr_number: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pr_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub files_modified: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub files_read: Option, + #[serde(default)] + pub has_errors: bool, + #[serde(default = "default_max_active_agents")] + pub max_active_agents: i64, + #[serde(default)] + pub session_score: i64, + #[serde(default = "default_score_plugin")] + pub score_plugin: String, +} + +/// Paginated session listing returned by `GET /api/sessions`. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct SessionListResponse { + pub sessions: Vec, + pub total: i64, + pub page: u32, + pub per_page: u32, +} + +/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting. +#[derive(Debug, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct SessionListQuery { + #[serde(default = "default_page")] + pub page: u32, + #[serde(default = "default_per_page")] + pub per_page: u32, + pub search: Option, + pub tool: Option, + pub git_repo_name: Option, + pub sort: Option, + pub time_range: Option, +} + +/// Desktop session list query payload passed through Tauri invoke. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct DesktopSessionListQuery { + pub page: Option, + pub per_page: Option, + pub search: Option, + pub tool: Option, + pub git_repo_name: Option, + pub sort: Option, + pub time_range: Option, + pub force_refresh: Option, +} + +/// Repo list response used by server/worker/desktop adapters. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct SessionRepoListResponse { + pub repos: Vec, +} + +/// Single session detail returned by `GET /api/sessions/:id`. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct SessionDetail { + #[serde(flatten)] + #[cfg_attr(feature = "ts", ts(flatten))] + pub summary: SessionSummary, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub linked_sessions: Vec, +} + +/// A link between two sessions (e.g., handoff chain). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct SessionLink { + pub session_id: String, + pub linked_session_id: String, + pub link_type: LinkType, + pub created_at: String, +} + +/// Request body for `POST /api/sessions/:id/events` — append live events. +#[derive(Debug, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct StreamEventsRequest { + #[cfg_attr(feature = "ts", ts(type = "any"))] + pub agent: Option, + #[cfg_attr(feature = "ts", ts(type = "any"))] + pub context: Option, + #[cfg_attr(feature = "ts", ts(type = "any[]"))] + pub events: Vec, +} + +/// Returned by `POST /api/sessions/:id/events` — number of events accepted. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct StreamEventsResponse { + pub accepted: usize, +} + +/// Returned by `GET /api/health` — server liveness check. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct HealthResponse { + pub status: String, + pub version: String, +} + +/// Returned by `GET /api/capabilities` — runtime feature availability. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub struct CapabilitiesResponse { + pub auth_enabled: bool, + pub parse_preview_enabled: bool, + pub register_targets: Vec, + pub share_modes: Vec, +} + +pub const DEFAULT_REGISTER_TARGETS: &[&str] = &["local", "git"]; +pub const DEFAULT_SHARE_MODES: &[&str] = &["web", "git", "quick", "json"]; + +impl CapabilitiesResponse { + /// Build runtime capability payload with shared defaults. + pub fn for_runtime(auth_enabled: bool, parse_preview_enabled: bool) -> Self { + Self { + auth_enabled, + parse_preview_enabled, + register_targets: DEFAULT_REGISTER_TARGETS + .iter() + .map(|target| (*target).to_string()) + .collect(), + share_modes: DEFAULT_SHARE_MODES + .iter() + .map(|mode| (*mode).to_string()) + .collect(), + } + } +} + +impl SessionListQuery { + /// Returns true when this query targets the anonymous public feed and is safe to edge-cache. + pub fn is_public_feed_cacheable( + &self, + has_auth_header: bool, + has_session_cookie: bool, + ) -> bool { + !has_auth_header + && !has_session_cookie + && self.search.as_deref().is_none_or(|s| s.trim().is_empty()) + && self + .git_repo_name + .as_deref() + .is_none_or(|repo| repo.trim().is_empty()) + && self.page <= 10 + && self.per_page <= 50 + } +} + +fn default_page() -> u32 { + 1 +} + +fn default_per_page() -> u32 { + 20 +} + +fn default_max_active_agents() -> i64 { + 1 +} + +fn default_score_plugin() -> String { + opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_query() -> SessionListQuery { + SessionListQuery { + page: 1, + per_page: 20, + search: None, + tool: None, + git_repo_name: None, + sort: None, + time_range: None, + } + } + + #[test] + fn public_feed_cacheable_when_anonymous_default_feed() { + let q = base_query(); + assert!(q.is_public_feed_cacheable(false, false)); + } + + #[test] + fn public_feed_not_cacheable_with_auth_or_cookie() { + let q = base_query(); + assert!(!q.is_public_feed_cacheable(true, false)); + assert!(!q.is_public_feed_cacheable(false, true)); + } + + #[test] + fn public_feed_not_cacheable_for_search_or_large_page() { + let mut q = base_query(); + q.search = Some("hello".into()); + assert!(!q.is_public_feed_cacheable(false, false)); + + let mut q = base_query(); + q.git_repo_name = Some("org/repo".into()); + assert!(!q.is_public_feed_cacheable(false, false)); + + let mut q = base_query(); + q.page = 11; + assert!(!q.is_public_feed_cacheable(false, false)); + + let mut q = base_query(); + q.per_page = 100; + assert!(!q.is_public_feed_cacheable(false, false)); + } +} diff --git a/crates/api/src/shared_types.rs b/crates/api/src/shared_types.rs new file mode 100644 index 00000000..c6b91c6e --- /dev/null +++ b/crates/api/src/shared_types.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; + +/// Sort order for session listings. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum SortOrder { + #[default] + Recent, + Popular, + Longest, +} + +impl SortOrder { + pub fn as_str(&self) -> &str { + match self { + Self::Recent => "recent", + Self::Popular => "popular", + Self::Longest => "longest", + } + } +} + +impl std::fmt::Display for SortOrder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Time range filter for queries. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum TimeRange { + #[serde(rename = "24h")] + Hours24, + #[serde(rename = "7d")] + Days7, + #[serde(rename = "30d")] + Days30, + #[default] + #[serde(rename = "all")] + All, +} + +impl TimeRange { + pub fn as_str(&self) -> &str { + match self { + Self::Hours24 => "24h", + Self::Days7 => "7d", + Self::Days30 => "30d", + Self::All => "all", + } + } +} + +impl std::fmt::Display for TimeRange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Type of link between two sessions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts", ts(export))] +pub enum LinkType { + Handoff, + Related, + Parent, + Child, +} + +impl LinkType { + pub fn as_str(&self) -> &str { + match self { + Self::Handoff => "handoff", + Self::Related => "related", + Self::Parent => "parent", + Self::Child => "child", + } + } +} + +impl std::fmt::Display for LinkType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Safely convert `u64` to `i64`, saturating at `i64::MAX` instead of wrapping. +pub fn saturating_i64(v: u64) -> i64 { + i64::try_from(v).unwrap_or(i64::MAX) +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2a451043..201b07c5 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -2,6 +2,7 @@ name = "opensession" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true description = "CLI for opensession.io - discover, upload, and manage AI coding sessions" @@ -26,9 +27,13 @@ workspace = true [dependencies] opensession-core = { workspace = true } +opensession-paths = { workspace = true } +opensession-local-store = { workspace = true } opensession-runtime-config = { workspace = true } opensession-summary = { workspace = true } +opensession-summary-runtime = { workspace = true } opensession-parsers = { workspace = true } +opensession-parser-discovery = { workspace = true } opensession-api = { workspace = true, default-features = false } opensession-api-client = { workspace = true } opensession-local-db = { workspace = true } diff --git a/crates/cli/src/cat_cmd.rs b/crates/cli/src/cat_cmd.rs index 08c43975..25b2e614 100644 --- a/crates/cli/src/cat_cmd.rs +++ b/crates/cli/src/cat_cmd.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; use clap::Args; -use opensession_core::object_store::read_local_object_from_uri; use opensession_core::source_uri::SourceUri; +use opensession_local_store::read_local_object_from_uri; #[derive(Debug, Clone, Args)] pub struct CatArgs { diff --git a/crates/cli/src/cleanup_cmd.rs b/crates/cli/src/cleanup_cmd.rs index b349a0e5..2145bf70 100644 --- a/crates/cli/src/cleanup_cmd.rs +++ b/crates/cli/src/cleanup_cmd.rs @@ -1,5 +1,5 @@ use crate::user_guidance::guided_error; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use clap::{Args, Subcommand, ValueEnum}; use opensession_git_native::ops::find_repo_root; use serde::{Deserialize, Serialize}; @@ -57,6 +57,9 @@ struct CleanupInitArgs { /// Artifact branch TTL in days. #[arg(long, default_value_t = DEFAULT_TTL_DAYS)] artifact_ttl_days: u16, + /// Persist PR/MR session snapshots on this dedicated branch instead of ephemeral per-PR branches. + #[arg(long)] + session_archive_branch: Option, /// Skip confirmation prompt. #[arg(long)] yes: bool, @@ -107,6 +110,8 @@ struct CleanupConfig { remote: String, hidden_ttl_days: u16, artifact_ttl_days: u16, + #[serde(default)] + session_archive_branch: Option, managed_at: String, managed_by: String, } @@ -129,6 +134,7 @@ struct CleanupPaths { struct InitExecutionReport { provider: CleanupProvider, resolved_remote: String, + session_archive_branch: Option, applied_paths: Vec, manual_steps: Vec, warnings: Vec, @@ -152,6 +158,7 @@ struct JanitorSummary { struct CleanupStatusJson { configured: bool, provider: Option, + session_archive_branch: Option, janitor_present: bool, provider_template_ready: bool, next_action: Option, @@ -273,6 +280,7 @@ pub fn maybe_prompt_cleanup_init_after_push(repo_root: &Path, remote: &str) -> R remote: remote.to_string(), hidden_ttl_days: DEFAULT_TTL_DAYS, artifact_ttl_days: DEFAULT_TTL_DAYS, + session_archive_branch: None, yes: true, json: false, silent: true, @@ -292,6 +300,7 @@ fn run_init(args: CleanupInitArgs) -> Result<()> { remote: args.remote, hidden_ttl_days: args.hidden_ttl_days, artifact_ttl_days: args.artifact_ttl_days, + session_archive_branch: args.session_archive_branch, yes: args.yes, json: args.json, silent: false, @@ -303,6 +312,7 @@ fn run_init(args: CleanupInitArgs) -> Result<()> { "configured": true, "provider": report.provider.as_str(), "resolved_remote": report.resolved_remote, + "session_archive_branch": report.session_archive_branch, "applied_paths": report.applied_paths, "manual_steps": report.manual_steps, "warnings": report.warnings, @@ -313,6 +323,9 @@ fn run_init(args: CleanupInitArgs) -> Result<()> { println!("cleanup configured: provider={}", report.provider.as_str()); println!("remote: {}", report.resolved_remote); + if let Some(branch) = &report.session_archive_branch { + println!("session archive branch: {branch}"); + } if !report.applied_paths.is_empty() { println!("applied:"); for path in &report.applied_paths { @@ -343,6 +356,7 @@ fn run_status(args: CleanupStatusArgs) -> Result<()> { let payload = CleanupStatusJson { configured: false, provider: None, + session_archive_branch: None, janitor_present: paths.janitor.exists(), provider_template_ready: false, next_action: Some("opensession cleanup init --provider auto".to_string()), @@ -388,6 +402,7 @@ fn run_status(args: CleanupStatusArgs) -> Result<()> { let payload = CleanupStatusJson { configured: true, provider: Some(config.provider.as_str().to_string()), + session_archive_branch: config.session_archive_branch.clone(), janitor_present, provider_template_ready: provider_ready, next_action, @@ -404,6 +419,9 @@ fn run_status(args: CleanupStatusArgs) -> Result<()> { "cleanup: configured (provider={})", config.provider.as_str() ); + if let Some(branch) = &config.session_archive_branch { + println!("session archive branch: {branch}"); + } println!( "janitor: {}", if payload.janitor_present { @@ -474,6 +492,7 @@ struct InitRequest { remote: String, hidden_ttl_days: u16, artifact_ttl_days: u16, + session_archive_branch: Option, yes: bool, json: bool, silent: bool, @@ -484,6 +503,7 @@ fn init_cleanup(repo_root: &Path, req: InitRequest) -> Result { let detected = detect_provider_for_remote(&remote.url); @@ -508,6 +528,12 @@ fn init_cleanup(repo_root: &Path, req: InitRequest) -> Result Result Result Result<()> { if config.remote.trim().is_empty() { bail!("cleanup config remote is empty"); } + if config + .session_archive_branch + .as_deref() + .map(str::trim) + .is_some_and(str::is_empty) + { + bail!("cleanup config session_archive_branch is empty"); + } Ok(()) } +fn normalize_optional_branch(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + fn provider_template_ready(paths: &CleanupPaths, provider: CleanupProvider) -> bool { match provider { CleanupProvider::Github => { @@ -1084,6 +1131,7 @@ mod tests { remote: "origin".to_string(), hidden_ttl_days: 30, artifact_ttl_days: 30, + session_archive_branch: Some("pr/sessions".to_string()), managed_at: "2026-02-27T00:00:00Z".to_string(), managed_by: MANAGED_MARKER.to_string(), }; @@ -1094,6 +1142,10 @@ mod tests { assert_eq!(parsed.provider, CleanupProvider::Generic); assert_eq!(parsed.hidden_ttl_days, 30); assert_eq!(parsed.artifact_ttl_days, 30); + assert_eq!( + parsed.session_archive_branch.as_deref(), + Some("pr/sessions") + ); } #[test] diff --git a/crates/cli/src/cli_args.rs b/crates/cli/src/cli_args.rs new file mode 100644 index 00000000..1a8b156f --- /dev/null +++ b/crates/cli/src/cli_args.rs @@ -0,0 +1,331 @@ +use clap::{Command, CommandFactory, FromArgMatches, Parser, Subcommand}; +use std::path::PathBuf; + +use crate::{locale::localize, setup_cmd}; + +#[derive(Parser)] +#[command( + name = "opensession", + about = "OpenSession CLI - local-first source URI workflows", + after_long_help = r"First-user flow (5 minutes): + opensession docs quickstart + +Common next steps: + opensession doctor + opensession doctor --fix --profile local + opensession parse --profile codex ./raw-session.jsonl --out ./session.hail.jsonl + opensession register ./session.hail.jsonl + opensession share os://src/local/ --quick" +)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Commands, +} + +#[derive(Subcommand)] +pub(crate) enum Commands { + /// Register canonical HAIL JSONL into local object store. + Register(crate::register::RegisterArgs), + /// Print canonical JSONL for a local source URI. + Cat(crate::cat_cmd::CatArgs), + /// Inspect summary metadata for source/artifact URIs. + Inspect(crate::inspect::InspectArgs), + /// Resolve sharing outputs from a source URI. + Share(crate::share::ShareArgs), + /// Open a review-centric web view from URI/file/URL/commit targets. + View(crate::view::ViewArgs), + /// Review a GitHub PR using local hidden refs and grouped commit sessions. + Review(crate::review::ReviewArgs), + /// Build and manage immutable handoff artifacts. + Handoff(crate::handoff_v1::HandoffArgs), + /// Parse agent-native logs into canonical HAIL JSONL. + Parse(crate::parse_cmd::ParseArgs), + /// Generate/show local semantic summaries. + Summary(crate::summary_cmd::SummaryArgs), + /// Manage explicit repo config (`.opensession/config.toml`). + Config(crate::config_cmd::ConfigArgs), + /// Configure and run hidden-ref cleanup automation. + Cleanup(crate::cleanup_cmd::CleanupArgs), + /// Install/update OpenSession git hooks and diagnostics. + #[command(hide = true)] + Setup(crate::setup_cmd::SetupArgs), + /// Diagnose and optionally fix local OpenSession setup. + Doctor(crate::doctor_cmd::DoctorArgs), + /// Generate shell completion scripts. + Docs { + #[command(subcommand)] + action: DocsAction, + }, +} + +#[derive(Subcommand)] +pub(crate) enum DocsAction { + /// Generate shell completions. + Completion { + /// Target shell. + #[arg(value_enum)] + shell: clap_complete::Shell, + }, + /// Print a 5-minute first-user flow. + Quickstart { + /// Parser profile used for first parse. + #[arg(long, default_value = "codex")] + profile: String, + /// Setup profile used for doctor/setup defaults. + #[arg(long, value_enum, default_value = "local")] + setup_profile: setup_cmd::SetupProfile, + /// Raw input path for parse. + #[arg(long, default_value = "./raw-session.jsonl")] + input: PathBuf, + /// Canonical output path for parse. + #[arg(long, default_value = "./session.hail.jsonl")] + out: PathBuf, + /// Git remote name used for initial share. + #[arg(long, default_value = "origin")] + remote: String, + }, +} + +pub(crate) fn command() -> Command { + let mut command = ::command(); + localize_command(&mut command); + command +} + +pub(crate) fn parse_cli() -> Cli { + let matches = command().get_matches(); + Cli::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) +} + +fn set_about(command: &mut Command, text: &'static str) { + *command = command.clone().about(text); +} + +fn set_after_help(command: &mut Command, text: &'static str) { + *command = command.clone().after_help(text); +} + +fn localize_command(command: &mut Command) { + match command.get_name() { + "opensession" => { + set_about( + command, + localize( + "OpenSession CLI - local-first source URI workflows", + "OpenSession CLI - 로컬 우선 Source URI 워크플로", + ), + ); + set_after_help( + command, + localize( + "First-user flow (5 minutes):\n opensession docs quickstart\n\nCommon next steps:\n opensession doctor\n opensession doctor --fix --profile local\n opensession parse --profile codex ./raw-session.jsonl --out ./session.hail.jsonl\n opensession register ./session.hail.jsonl\n opensession share os://src/local/ --quick", + "첫 사용자 흐름 (5분):\n opensession docs quickstart\n\n다음으로 많이 쓰는 명령:\n opensession doctor\n opensession doctor --fix --profile local\n opensession parse --profile codex ./raw-session.jsonl --out ./session.hail.jsonl\n opensession register ./session.hail.jsonl\n opensession share os://src/local/ --quick", + ), + ); + } + "register" => { + set_about( + command, + localize( + "Register canonical HAIL JSONL into local object store.", + "canonical HAIL JSONL을 로컬 객체 저장소에 등록합니다.", + ), + ); + } + "cat" => { + set_about( + command, + localize( + "Print canonical JSONL for a local source URI.", + "로컬 source URI의 canonical JSONL을 출력합니다.", + ), + ); + } + "inspect" => { + set_about( + command, + localize( + "Inspect summary metadata for source/artifact URIs.", + "source/artifact URI의 summary 메타데이터를 확인합니다.", + ), + ); + } + "share" => { + set_about( + command, + localize( + "Resolve sharing outputs from a source URI.", + "Source URI에서 공유 출력을 생성합니다.", + ), + ); + } + "view" => { + set_about( + command, + localize( + "Open a review-centric web view from URI/file/URL/commit targets.", + "URI/파일/URL/커밋 대상을 리뷰 중심 웹 보기로 엽니다.", + ), + ); + set_after_help( + command, + localize( + "Recovery examples:\n opensession view --no-open\n opensession view os://src/local/ --no-open\n opensession view ./session.hail.jsonl --no-open\n opensession view HEAD~3..HEAD --no-open", + "복구 예시:\n opensession view --no-open\n opensession view os://src/local/ --no-open\n opensession view ./session.hail.jsonl --no-open\n opensession view HEAD~3..HEAD --no-open", + ), + ); + } + "review" => { + set_about( + command, + localize( + "Review a GitHub PR using local hidden refs and grouped commit sessions.", + "로컬 hidden ref와 grouped commit session을 사용해 GitHub PR을 검토합니다.", + ), + ); + } + "handoff" => { + set_about( + command, + localize( + "Build and manage immutable handoff artifacts.", + "불변 handoff artifact를 생성하고 관리합니다.", + ), + ); + } + "parse" => { + set_about( + command, + localize( + "Parse agent-native logs into canonical HAIL JSONL.", + "agent-native 로그를 canonical HAIL JSONL로 변환합니다.", + ), + ); + set_after_help( + command, + localize( + "Recovery examples:\n opensession parse --profile codex ./raw-session.jsonl --preview\n opensession parse --profile codex ./raw-session.jsonl --out ./session.hail.jsonl", + "복구 예시:\n opensession parse --profile codex ./raw-session.jsonl --preview\n opensession parse --profile codex ./raw-session.jsonl --out ./session.hail.jsonl", + ), + ); + } + "summary" => { + set_about( + command, + localize( + "Generate/show local semantic summaries.", + "로컬 시맨틱 summary를 생성하거나 표시합니다.", + ), + ); + } + "config" => { + set_about( + command, + localize( + "Manage explicit repo config (`.opensession/config.toml`).", + "명시적 레포 설정(`.opensession/config.toml`)을 관리합니다.", + ), + ); + } + "cleanup" => { + set_about( + command, + localize( + "Configure and run hidden-ref cleanup automation.", + "hidden-ref cleanup 자동화를 구성하고 실행합니다.", + ), + ); + } + "setup" => { + set_about( + command, + localize( + "Install/update OpenSession git hooks and diagnostics.", + "OpenSession git hook과 진단 구성을 설치/업데이트합니다.", + ), + ); + } + "doctor" => { + set_about( + command, + localize( + "Diagnose and optionally fix local OpenSession setup.", + "로컬 OpenSession 설정을 진단하고 필요하면 수정합니다.", + ), + ); + } + "docs" => { + set_about( + command, + localize( + "Print shell completions and quickstart guidance.", + "셸 completion과 빠른 시작 안내를 출력합니다.", + ), + ); + } + "completion" => { + set_about( + command, + localize("Generate shell completions.", "셸 completion을 생성합니다."), + ); + } + "quickstart" => { + set_about( + command, + localize( + "Print a 5-minute first-user flow.", + "5분짜리 첫 사용자 흐름을 출력합니다.", + ), + ); + } + _ => {} + } + + for subcommand in command.get_subcommands_mut() { + localize_command(subcommand); + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use std::path::PathBuf; + + use super::{Cli, Commands, DocsAction}; + + #[test] + fn parses_docs_completion_subcommand() { + let cli = Cli::parse_from(["opensession", "docs", "completion", "zsh"]); + match cli.command { + Commands::Docs { + action: DocsAction::Completion { shell }, + } => { + assert_eq!(shell.to_string(), "zsh"); + } + _ => panic!("expected docs completion command"), + } + } + + #[test] + fn quickstart_defaults_profile_and_remote() { + let cli = Cli::parse_from(["opensession", "docs", "quickstart"]); + match cli.command { + Commands::Docs { + action: + DocsAction::Quickstart { + profile, + remote, + input, + out, + .. + }, + } => { + assert_eq!(profile, "codex"); + assert_eq!(remote, "origin"); + assert_eq!(input, PathBuf::from("./raw-session.jsonl")); + assert_eq!(out, PathBuf::from("./session.hail.jsonl")); + } + _ => panic!("expected docs quickstart command"), + } + } +} diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 0d411cb9..ea6ca5eb 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use opensession_runtime_config::{DaemonConfig, CONFIG_FILE_NAME}; +use opensession_runtime_config::DaemonConfig; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -60,15 +60,12 @@ impl Default for DaemonRefConfig { /// Get the config directory path (~/.config/opensession/) pub fn config_dir() -> Result { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .context("Could not determine home directory")?; - Ok(PathBuf::from(home).join(".config").join("opensession")) + opensession_paths::config_dir().context("Could not determine home directory") } /// Canonical config file path. pub fn config_path() -> Result { - Ok(config_dir()?.join(CONFIG_FILE_NAME)) + opensession_paths::runtime_config_path().context("Could not determine config file path") } fn read_config_doc(path: &Path) -> Result { @@ -255,7 +252,7 @@ pub fn set_daemon_watch_paths(repos: Vec) -> Result<()> { #[cfg(test)] mod tests { - use super::load_runtime_config_from_doc; + use super::{config_dir, config_path, load_runtime_config_from_doc}; #[test] fn runtime_config_loads_server_table() { @@ -272,4 +269,20 @@ api_key = "k" assert_eq!(cfg.server.url, "https://opensession.io"); assert_eq!(cfg.server.api_key, "k"); } + + #[test] + fn cli_config_dir_uses_centralized_path() { + assert_eq!( + config_dir().expect("config dir"), + opensession_paths::config_dir().expect("central config dir") + ); + } + + #[test] + fn cli_config_path_uses_centralized_runtime_path() { + assert_eq!( + config_path().expect("config path"), + opensession_paths::runtime_config_path().expect("central runtime config path") + ); + } } diff --git a/crates/cli/src/config_cmd.rs b/crates/cli/src/config_cmd.rs index 39df9a42..5655a828 100644 --- a/crates/cli/src/config_cmd.rs +++ b/crates/cli/src/config_cmd.rs @@ -399,8 +399,7 @@ fn run_summary(action: SummaryConfigAction) -> Result<()> { } fn config_path(cwd: &Path) -> Result { - let root = - opensession_core::object_store::find_repo_root(cwd).unwrap_or_else(|| cwd.to_path_buf()); + let root = opensession_local_store::find_repo_root(cwd).unwrap_or_else(|| cwd.to_path_buf()); Ok(root.join(".opensession").join("config.toml")) } @@ -425,7 +424,7 @@ fn normalize_base_url(value: &str) -> Result { #[cfg(test)] mod tests { - use super::{normalize_base_url, RepoConfig}; + use super::{RepoConfig, normalize_base_url}; #[test] fn default_repo_config_has_base_url() { diff --git a/crates/cli/src/daemon_ctl.rs b/crates/cli/src/daemon_ctl.rs index af165162..fe3db752 100644 --- a/crates/cli/src/daemon_ctl.rs +++ b/crates/cli/src/daemon_ctl.rs @@ -64,9 +64,7 @@ pub fn daemon_stop() -> Result<()> { // Send SIGTERM #[cfg(unix)] { - unsafe { - libc::kill(pid as i32, libc::SIGTERM); - } + terminate_process_unix(pid); } #[cfg(not(unix))] @@ -133,8 +131,7 @@ pub fn is_daemon_running() -> bool { fn is_process_running(pid: u32) -> bool { #[cfg(unix)] { - // kill with signal 0 checks process existence without sending a signal - unsafe { libc::kill(pid as i32, 0) == 0 } + process_exists_unix(pid) } #[cfg(not(unix))] { @@ -146,6 +143,30 @@ fn is_process_running(pid: u32) -> bool { } } +#[cfg(unix)] +fn process_exists_unix(pid: u32) -> bool { + // SAFETY: kill(pid, 0) does not deliver a signal. It is the standard Unix probe for + // process existence and permissions, and we pass the parsed PID value directly to libc. + let rc = unsafe { libc::kill(pid as i32, 0) }; + if rc == 0 { + return true; + } + + matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) +} + +#[cfg(unix)] +fn terminate_process_unix(pid: u32) { + // SAFETY: sending SIGTERM to a parsed PID delegates to the OS for permission and existence + // checks. The libc call does not outlive borrowed data and has no Rust aliasing concerns. + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } +} + /// Find the daemon binary, looking next to the CLI binary first fn find_daemon_binary() -> Result { // Look next to our own binary diff --git a/crates/cli/src/discover.rs b/crates/cli/src/discover.rs index e26ee1e9..2345fdf5 100644 --- a/crates/cli/src/discover.rs +++ b/crates/cli/src/discover.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use opensession_parsers::discover::discover_sessions; +use opensession_parser_discovery::discover_sessions; /// List all locally discovered AI sessions pub fn run_discover() -> Result<()> { diff --git a/crates/cli/src/docs_cmd.rs b/crates/cli/src/docs_cmd.rs new file mode 100644 index 00000000..1c483968 --- /dev/null +++ b/crates/cli/src/docs_cmd.rs @@ -0,0 +1,98 @@ +use std::path::Path; + +use crate::{cli_args::command, locale::localize, setup_cmd}; + +pub(crate) fn run_docs(action: crate::cli_args::DocsAction) -> anyhow::Result<()> { + match action { + crate::cli_args::DocsAction::Completion { shell } => { + let mut cmd = command(); + clap_complete::generate(shell, &mut cmd, "opensession", &mut std::io::stdout()); + Ok(()) + } + crate::cli_args::DocsAction::Quickstart { + profile, + setup_profile, + input, + out, + remote, + } => { + print_quickstart(&profile, setup_profile, &input, &out, &remote); + Ok(()) + } + } +} + +fn print_quickstart( + profile: &str, + setup_profile: setup_cmd::SetupProfile, + input: &Path, + out: &Path, + remote: &str, +) { + println!( + "{}", + localize( + "# OpenSession 5-minute first-user flow", + "# OpenSession 5분 첫 사용자 흐름", + ) + ); + println!(); + println!( + "{}", + localize("# 1) Diagnose and apply setup", "# 1) 설정을 진단하고 적용") + ); + println!("opensession doctor"); + println!( + "opensession doctor --fix --profile {}", + setup_profile.as_str() + ); + if matches!(setup_profile, setup_cmd::SetupProfile::App) { + println!("opensession doctor --fix --profile app --open-target app"); + } + println!(); + println!( + "{}", + localize( + "# 2) Parse raw logs into canonical HAIL JSONL", + "# 2) raw 로그를 canonical HAIL JSONL로 변환", + ) + ); + println!( + "opensession parse --profile {} {} --out {}", + profile, + input.display(), + out.display() + ); + println!(); + println!( + "{}", + localize( + "# 3) Register canonical session locally", + "# 3) canonical 세션을 로컬에 등록", + ) + ); + println!("opensession register {}", out.display()); + println!("# -> os://src/local/"); + println!(); + println!( + "{}", + localize( + "# 4) Share local source URI via quick git flow", + "# 4) quick git 흐름으로 로컬 source URI 공유", + ) + ); + println!( + "opensession share os://src/local/ --quick --remote {}", + remote + ); + println!(); + println!( + "{}", + localize( + "# 5) Optional: convert a remote URI to web URL", + "# 5) 선택: remote URI를 웹 URL로 변환", + ) + ); + println!("opensession config init --base-url https://opensession.io"); + println!("opensession share os://src/git//ref//path/ --web"); +} diff --git a/crates/cli/src/entrypoint.rs b/crates/cli/src/entrypoint.rs new file mode 100644 index 00000000..2d1a65c6 --- /dev/null +++ b/crates/cli/src/entrypoint.rs @@ -0,0 +1,48 @@ +use crate::{ + cat_cmd, cleanup_cmd, + cli_args::{Commands, parse_cli}, + config_cmd, docs_cmd, doctor_cmd, handoff_v1, inspect, + locale::localize, + parse_cmd, register, review, setup_cmd, share, summary_cmd, view, +}; + +pub(crate) async fn run_process() { + let cli = parse_cli(); + + let result = match cli.command { + Commands::Register(args) => register::run(args), + Commands::Cat(args) => cat_cmd::run(args), + Commands::Inspect(args) => inspect::run(args), + Commands::Share(args) => share::run(args), + Commands::View(args) => view::run(args).await, + Commands::Review(args) => review::run(args).await, + Commands::Handoff(args) => handoff_v1::run(args), + Commands::Parse(args) => parse_cmd::run(args), + Commands::Summary(args) => summary_cmd::run(args).await, + Commands::Config(args) => config_cmd::run(args), + Commands::Cleanup(args) => cleanup_cmd::run(args), + Commands::Setup(args) => setup_cmd::run(args), + Commands::Doctor(args) => doctor_cmd::run(args), + Commands::Docs { action } => docs_cmd::run_docs(action), + }; + + if let Err(error) = result { + if debug_errors_enabled() { + eprintln!("{} {error:#}", localize("Error:", "오류:")); + } else { + eprintln!("{} {error}", localize("Error:", "오류:")); + } + std::process::exit(1); + } +} + +fn debug_errors_enabled() -> bool { + matches!( + std::env::var("OPENSESSION_DEBUG"), + Ok(value) + if matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + ) +} diff --git a/crates/cli/src/handoff.rs b/crates/cli/src/handoff.rs index 6df0a889..d83ff65c 100644 --- a/crates/cli/src/handoff.rs +++ b/crates/cli/src/handoff.rs @@ -2,23 +2,25 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; +use opensession_core::Session; use opensession_core::handoff::{ - generate_handoff_markdown_v2, generate_merged_handoff_markdown_v2, merge_summaries, - validate_handoff_summaries, HandoffSummary, HandoffValidationReport, + HandoffSummary, HandoffValidationReport, generate_handoff_markdown_v2, + generate_merged_handoff_markdown_v2, merge_summaries, validate_handoff_summaries, }; use opensession_core::handoff_artifact::{ - sort_sessions_time_asc, source_from_session, HandoffArtifact, HandoffPayloadFormat, - HANDOFF_ARTIFACT_VERSION, HANDOFF_MERGE_POLICY_TIME_ASC, + HANDOFF_ARTIFACT_VERSION, HANDOFF_MERGE_POLICY_TIME_ASC, HandoffArtifact, + HandoffArtifactSource, HandoffPayloadFormat, HandoffSourceStaleReason, SourceFingerprint, + sort_sessions_time_asc, source_from_session, }; -use opensession_core::Session; use opensession_git_native::{ artifact_ref_name, list_handoff_artifact_refs, load_handoff_artifact, ops, store_handoff_artifact, }; -use opensession_parsers::discover::discover_sessions; -use opensession_parsers::{all_parsers, SessionParser}; +use opensession_parsers::ParserRegistry; +use opensession_parser_discovery::discover_sessions; use std::io::{IsTerminal, Write}; +use std::time::UNIX_EPOCH; #[derive(Debug, Clone)] struct ResolvedSession { @@ -122,7 +124,7 @@ pub async fn run_handoff_save( "artifact_id": artifact.artifact_id, "ref": ref_name, "source_count": artifact.sources.len(), - "stale": artifact.is_stale(), + "stale": artifact_is_stale(&artifact), }))? ); Ok(()) @@ -140,7 +142,7 @@ pub async fn run_handoff_artifact_list() -> Result<()> { "created_at": artifact.created_at, "payload_format": artifact.payload_format, "source_count": artifact.sources.len(), - "stale": artifact.is_stale(), + "stale": artifact_is_stale(&artifact), })); } println!("{}", serde_json::to_string_pretty(&rows)?); @@ -150,7 +152,7 @@ pub async fn run_handoff_artifact_list() -> Result<()> { pub async fn run_handoff_artifact_show(id_or_ref: &str) -> Result<()> { let repo_root = resolve_repo_root()?; let artifact = load_artifact(&repo_root, id_or_ref)?; - let stale_reasons = artifact.stale_reasons(); + let stale_reasons = stale_reasons(&artifact); let output = serde_json::json!({ "artifact": artifact, "stale": !stale_reasons.is_empty(), @@ -167,7 +169,7 @@ pub async fn run_handoff_artifact_refresh(id_or_ref: &str) -> Result<()> { bail!("Artifact has no source files to refresh."); } - let stale_reasons = artifact.stale_reasons(); + let stale_reasons = stale_reasons(&artifact); if stale_reasons.is_empty() { println!( "{}", @@ -182,13 +184,13 @@ pub async fn run_handoff_artifact_refresh(id_or_ref: &str) -> Result<()> { } let mut resolved = Vec::new(); - let parsers = all_parsers(); + let registry = ParserRegistry::default(); for source in &artifact.sources { let path = PathBuf::from(&source.source_path); if !path.exists() { continue; } - let session = parse_file(&parsers, &path)?; + let session = parse_file(®istry, &path)?; resolved.push(ResolvedSession { session, source_path: Some(path), @@ -218,7 +220,7 @@ pub async fn run_handoff_artifact_refresh(id_or_ref: &str) -> Result<()> { "ref": ref_name, "refreshed": true, "stale_before": stale_reasons.len(), - "stale_after": artifact.is_stale(), + "stale_after": artifact_is_stale(&artifact), }))? ); Ok(()) @@ -264,6 +266,61 @@ fn load_artifact(repo_root: &Path, id_or_ref: &str) -> Result { Ok(artifact) } +fn artifact_is_stale(artifact: &HandoffArtifact) -> bool { + !stale_reasons(artifact).is_empty() +} + +fn stale_reasons(artifact: &HandoffArtifact) -> Vec { + let mut reasons = Vec::new(); + for source in &artifact.sources { + let path = Path::new(&source.source_path); + let current = match source_fingerprint(path) { + Ok(fingerprint) => fingerprint, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + reasons.push(HandoffSourceStaleReason { + session_id: source.session_id.clone(), + source_path: source.source_path.clone(), + reason: "missing_source_file".to_string(), + }); + continue; + } + Err(_) => { + reasons.push(HandoffSourceStaleReason { + session_id: source.session_id.clone(), + source_path: source.source_path.clone(), + reason: "unreadable_source_file".to_string(), + }); + continue; + } + }; + + if current.mtime_ms != source.source_mtime_ms || current.size != source.source_size { + reasons.push(HandoffSourceStaleReason { + session_id: source.session_id.clone(), + source_path: source.source_path.clone(), + reason: "source_fingerprint_changed".to_string(), + }); + } + } + + reasons +} + +fn source_fingerprint(path: &Path) -> std::io::Result { + let metadata = std::fs::metadata(path)?; + let mtime_ms = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0); + + Ok(SourceFingerprint { + mtime_ms, + size: metadata.len(), + }) +} + fn build_artifact_from_resolved( artifact_id: Option<&str>, payload_format: HandoffPayloadFormat, @@ -311,7 +368,9 @@ fn build_artifact_from_resolved( if !path.exists() { continue; } - if let Ok(source) = source_from_session(&item.session, &path) { + if let Ok(fingerprint) = source_fingerprint(&path) { + let source = + source_from_session(&item.session, path.to_string_lossy().into_owned(), fingerprint); sources.push(source); } } @@ -527,9 +586,9 @@ fn resolve_sessions_with_sources( gemini: Option<&str>, tool_refs: &[String], ) -> Result> { - use crate::session_ref::{tool_flag_to_name, SessionRef}; + use crate::session_ref::{SessionRef, tool_flag_to_name}; - let parsers = all_parsers(); + let registry = ParserRegistry::default(); // If explicit files are given, parse them all if !files.is_empty() { @@ -538,7 +597,7 @@ fn resolve_sessions_with_sources( if !file.exists() { bail!("File not found: {}", file.display()); } - let session = parse_file(&parsers, file)?; + let session = parse_file(®istry, file)?; sessions.push(ResolvedSession { session, source_path: Some(file.clone()), @@ -575,7 +634,7 @@ fn resolve_sessions_with_sources( match sref { SessionRef::File(path) => { sessions.push(ResolvedSession { - session: parse_file(&parsers, path)?, + session: parse_file(®istry, path)?, source_path: Some(path.clone()), }); } @@ -588,7 +647,7 @@ fn resolve_sessions_with_sources( .with_context(|| format!("Session {} has no source_path", row.id))?; let path = PathBuf::from(source); sessions.push(ResolvedSession { - session: parse_file(&parsers, &path)?, + session: parse_file(®istry, &path)?, source_path: Some(path), }); } @@ -604,20 +663,15 @@ fn resolve_sessions_with_sources( let mut sessions = Vec::new(); for path in resolved { sessions.push(ResolvedSession { - session: parse_file(&parsers, &path)?, + session: parse_file(®istry, &path)?, source_path: Some(path), }); } Ok(sessions) } -fn parse_file(parsers: &[Box], file: &Path) -> Result { - let parser: Option<&dyn SessionParser> = parsers - .iter() - .find(|p| p.can_parse(file)) - .map(|p| p.as_ref()); - - let parser = match parser { +fn parse_file(registry: &ParserRegistry, file: &Path) -> Result { + let parser = match registry.parser_for_path(file) { Some(p) => p, None => bail!( "No parser found for file: {}\nSupported formats: Claude Code (.jsonl), Codex (.jsonl), OpenCode (.json), Cline, Amp, Cursor, Gemini", @@ -771,10 +825,12 @@ fn collect_all_session_paths() -> Result> { #[cfg(test)] mod tests { use super::{ - has_error_findings, parse_last_count, populate_prompt, select_existing_session_paths, - write_handoff_to_writer, PopulateProvider, PopulateSpec, + HandoffArtifact, HandoffArtifactSource, HandoffPayloadFormat, HandoffSourceStaleReason, + PopulateProvider, PopulateSpec, SourceFingerprint, artifact_is_stale, has_error_findings, + parse_last_count, populate_prompt, select_existing_session_paths, source_fingerprint, + stale_reasons, write_handoff_to_writer, }; - use opensession_core::handoff::{format_duration, generate_handoff_markdown, HandoffSummary}; + use opensession_core::handoff::{HandoffSummary, format_duration, generate_handoff_markdown}; use opensession_core::handoff::{HandoffValidationReport, ValidationFinding}; use opensession_core::testing; use opensession_core::{Agent, Event, EventType, Session, Stats}; @@ -1010,4 +1066,87 @@ mod tests { assert_eq!(selected[0], path_a); assert_eq!(selected[1], path_b); } + + fn make_artifact(source: HandoffArtifactSource) -> HandoffArtifact { + HandoffArtifact { + version: "1".to_string(), + artifact_id: "artifact-1".to_string(), + created_at: chrono::Utc::now(), + merge_policy: "time_asc".to_string(), + sources: vec![source], + payload_format: HandoffPayloadFormat::Json, + payload: serde_json::json!([]), + derived_markdown: None, + } + } + + #[test] + fn test_stale_reasons_detects_missing_source_file() { + let artifact = make_artifact(HandoffArtifactSource { + session_id: "s1".to_string(), + tool: "codex".to_string(), + model: "gpt-5".to_string(), + source_path: "/definitely/missing/session.jsonl".to_string(), + source_mtime_ms: 0, + source_size: 0, + }); + + let reasons = stale_reasons(&artifact); + assert_eq!(reasons.len(), 1); + assert_eq!( + reasons[0], + HandoffSourceStaleReason { + session_id: "s1".to_string(), + source_path: "/definitely/missing/session.jsonl".to_string(), + reason: "missing_source_file".to_string(), + } + ); + assert!(artifact_is_stale(&artifact)); + } + + #[test] + fn test_stale_reasons_detects_fingerprint_changes() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("session.jsonl"); + std::fs::write(&path, b"before").unwrap(); + let fingerprint = source_fingerprint(&path).unwrap(); + let artifact = make_artifact(HandoffArtifactSource { + session_id: "s1".to_string(), + tool: "codex".to_string(), + model: "gpt-5".to_string(), + source_path: path.to_string_lossy().into_owned(), + source_mtime_ms: fingerprint.mtime_ms, + source_size: fingerprint.size, + }); + + std::thread::sleep(std::time::Duration::from_millis(5)); + std::fs::write(&path, b"after-after").unwrap(); + + let reasons = stale_reasons(&artifact); + assert_eq!(reasons.len(), 1); + assert_eq!(reasons[0].reason, "source_fingerprint_changed"); + assert!(artifact_is_stale(&artifact)); + } + + #[test] + fn test_source_fingerprint_and_source_from_session_round_trip() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("session.jsonl"); + std::fs::write(&path, b"payload").unwrap(); + + let fingerprint = source_fingerprint(&path).unwrap(); + let session = Session::new("session-1".to_string(), make_agent()); + let source = opensession_core::handoff_artifact::source_from_session( + &session, + path.to_string_lossy().into_owned(), + SourceFingerprint { + mtime_ms: fingerprint.mtime_ms, + size: fingerprint.size, + }, + ); + + assert_eq!(source.session_id, "session-1"); + assert_eq!(source.source_size, fingerprint.size); + assert_eq!(source.source_mtime_ms, fingerprint.mtime_ms); + } } diff --git a/crates/cli/src/handoff_v1.rs b/crates/cli/src/handoff_v1.rs index 0734f3ff..ed28c303 100644 --- a/crates/cli/src/handoff_v1.rs +++ b/crates/cli/src/handoff_v1.rs @@ -1,13 +1,13 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use clap::{Args, Subcommand, ValueEnum}; -use opensession_core::handoff::{validate_handoff_summaries, HandoffSummary}; -use opensession_core::object_store::{ - find_repo_root, global_store_root, read_local_object_from_uri, sha256_hex, store_local_object, -}; +use opensession_core::Session; +use opensession_core::handoff::{HandoffSummary, validate_handoff_summaries}; use opensession_core::source_uri::SourceUri; use opensession_core::validate::validate_session; -use opensession_core::Session; use opensession_local_db::{LocalDb, LocalSessionFilter}; +use opensession_local_store::{ + find_repo_root, global_store_root, read_local_object_from_uri, sha256_hex, store_local_object, +}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -390,7 +390,8 @@ fn parse_session_input(path: &Path) -> Result { return Ok(session); } - if let Some(session) = opensession_parsers::parse_with_default_parsers(path) + if let Some(session) = opensession_parsers::ParserRegistry::default() + .parse_path(path) .with_context(|| format!("parse {}", path.display()))? { return Ok(session); @@ -644,8 +645,8 @@ fn is_hash(value: &str) -> bool { #[cfg(test)] mod tests { use super::{canonicalize_summaries, is_hash, validate_alias}; - use opensession_core::testing; use opensession_core::Session; + use opensession_core::testing; #[test] fn hash_validator_accepts_sha256() { diff --git a/crates/cli/src/hooks.rs b/crates/cli/src/hooks.rs index 7bfc5579..3b847fcc 100644 --- a/crates/cli/src/hooks.rs +++ b/crates/cli/src/hooks.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; /// Git hook types managed by opensession. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -557,16 +557,18 @@ pub fn scan_for_secrets(content: &str) -> Vec { for (line_number, line) in content.lines().enumerate() { for (name, pattern) in &patterns { - if let Ok(re) = regex::Regex::new(pattern) { - if re.is_match(line) { - // Redact the matched portion - let redacted = re.replace_all(line, "[REDACTED]").to_string(); - matches.push(SecretMatch { - pattern_name: name.to_string(), - line_number: line_number + 1, - context: redacted, - }); - } + let re = match regex::Regex::new(pattern) { + Ok(re) => re, + Err(_) => continue, + }; + if re.is_match(line) { + // Redact the matched portion + let redacted = re.replace_all(line, "[REDACTED]").to_string(); + matches.push(SecretMatch { + pattern_name: name.to_string(), + line_number: line_number + 1, + context: redacted, + }); } } } @@ -734,11 +736,13 @@ mod tests { assert_eq!(report.len(), 1); assert_eq!(report[0].action, HookInstallAction::BackupAndReplace); assert!(report[0].backup_created); - assert!(report[0] - .backup_path - .as_ref() - .expect("backup path") - .exists()); + assert!( + report[0] + .backup_path + .as_ref() + .expect("backup path") + .exists() + ); } #[test] @@ -827,10 +831,12 @@ mod tests { let dir = TempDir::new().unwrap(); let result = install_hooks(dir.path(), HookType::all()); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Not a git repository")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Not a git repository") + ); } #[test] diff --git a/crates/cli/src/index.rs b/crates/cli/src/index.rs index 7c68aa18..c6f83d5f 100644 --- a/crates/cli/src/index.rs +++ b/crates/cli/src/index.rs @@ -2,7 +2,8 @@ use anyhow::Result; use opensession_core::session::{is_auxiliary_session, working_directory}; use opensession_git_native::extract_git_context; use opensession_local_db::LocalDb; -use opensession_parsers::discover::discover_sessions; +use opensession_parser_discovery::discover_sessions; +use opensession_parsers::ParserRegistry; use std::path::Path; /// Run the index command: discover all local sessions and build/update the local DB index. @@ -42,7 +43,7 @@ pub fn run_index() -> Result<()> { /// Index a single session file. Returns Ok(true) if indexed, Ok(false) if skipped. fn index_one_file(db: &LocalDb, path: &Path) -> Result { - let session = match opensession_parsers::parse_with_default_parsers(path)? { + let session = match ParserRegistry::default().parse_path(path)? { Some(session) => session, None => return Ok(false), }; diff --git a/crates/cli/src/inspect.rs b/crates/cli/src/inspect.rs index 1db1a2d7..42d06705 100644 --- a/crates/cli/src/inspect.rs +++ b/crates/cli/src/inspect.rs @@ -1,9 +1,9 @@ use crate::handoff_v1::{load_artifact_by_hash, resolve_artifact_hash}; use anyhow::{Context, Result}; use clap::Args; -use opensession_core::object_store::read_local_object_from_uri; -use opensession_core::source_uri::SourceUri; use opensession_core::Session; +use opensession_core::source_uri::SourceUri; +use opensession_local_store::read_local_object_from_uri; #[derive(Debug, Clone, Args)] pub struct InspectArgs { diff --git a/crates/cli/src/locale.rs b/crates/cli/src/locale.rs new file mode 100644 index 00000000..66fe0c80 --- /dev/null +++ b/crates/cli/src/locale.rs @@ -0,0 +1,40 @@ +pub(crate) fn is_korean() -> bool { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .into_iter() + .filter_map(|key| std::env::var(key).ok()) + .map(|value| value.trim().to_ascii_lowercase()) + .any(|value| value == "ko" || value.starts_with("ko_") || value.starts_with("ko-")) +} + +pub(crate) fn localize<'a>(en: &'a str, ko: &'a str) -> &'a str { + if is_korean() { ko } else { en } +} + +#[cfg(test)] +mod tests { + use super::is_korean; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + #[test] + fn detects_korean_lang_prefixes() { + let _guard = env_lock().lock().expect("lock env"); + let original = std::env::var("LANG").ok(); + unsafe { + std::env::set_var("LANG", "ko_KR.UTF-8"); + } + assert!(is_korean()); + match original { + Some(value) => unsafe { + std::env::set_var("LANG", value); + }, + None => unsafe { + std::env::remove_var("LANG"); + }, + } + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 83e49b7c..44ff3023 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,10 +1,14 @@ mod cat_cmd; mod cleanup_cmd; +mod cli_args; mod config_cmd; +mod docs_cmd; mod doctor_cmd; +mod entrypoint; mod handoff_v1; mod hooks; mod inspect; +mod locale; mod open_target; mod parse_cmd; mod register; @@ -17,193 +21,7 @@ mod url_opener; mod user_guidance; mod view; -use clap::{Parser, Subcommand}; -use std::path::Path; -use std::path::PathBuf; - -#[derive(Parser)] -#[command( - name = "opensession", - about = "OpenSession CLI - local-first source URI workflows", - after_long_help = r"First-user flow (5 minutes): - opensession docs quickstart - -Common next steps: - opensession doctor - opensession doctor --fix --profile local - opensession parse --profile codex ./raw-session.jsonl --out ./session.hail.jsonl - opensession register ./session.hail.jsonl - opensession share os://src/local/ --quick" -)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Register canonical HAIL JSONL into local object store. - Register(register::RegisterArgs), - /// Print canonical JSONL for a local source URI. - Cat(cat_cmd::CatArgs), - /// Inspect summary metadata for source/artifact URIs. - Inspect(inspect::InspectArgs), - /// Resolve sharing outputs from a source URI. - Share(share::ShareArgs), - /// Open a review-centric web view from URI/file/URL/commit targets. - View(view::ViewArgs), - /// Review a GitHub PR using local hidden refs and grouped commit sessions. - Review(review::ReviewArgs), - /// Build and manage immutable handoff artifacts. - Handoff(handoff_v1::HandoffArgs), - /// Parse agent-native logs into canonical HAIL JSONL. - Parse(parse_cmd::ParseArgs), - /// Generate/show local semantic summaries. - Summary(summary_cmd::SummaryArgs), - /// Manage explicit repo config (`.opensession/config.toml`). - Config(config_cmd::ConfigArgs), - /// Configure and run hidden-ref cleanup automation. - Cleanup(cleanup_cmd::CleanupArgs), - /// Install/update OpenSession git hooks and diagnostics. - #[command(hide = true)] - Setup(setup_cmd::SetupArgs), - /// Diagnose and optionally fix local OpenSession setup. - Doctor(doctor_cmd::DoctorArgs), - /// Generate shell completion scripts. - Docs { - #[command(subcommand)] - action: DocsAction, - }, -} - -#[derive(Subcommand)] -enum DocsAction { - /// Generate shell completions. - Completion { - /// Target shell. - #[arg(value_enum)] - shell: clap_complete::Shell, - }, - /// Print a 5-minute first-user flow. - Quickstart { - /// Parser profile used for first parse. - #[arg(long, default_value = "codex")] - profile: String, - /// Setup profile used for doctor/setup defaults. - #[arg(long, value_enum, default_value = "local")] - setup_profile: setup_cmd::SetupProfile, - /// Raw input path for parse. - #[arg(long, default_value = "./raw-session.jsonl")] - input: PathBuf, - /// Canonical output path for parse. - #[arg(long, default_value = "./session.hail.jsonl")] - out: PathBuf, - /// Git remote name used for initial share. - #[arg(long, default_value = "origin")] - remote: String, - }, -} - #[tokio::main] async fn main() { - let cli = Cli::parse(); - - let result = match cli.command { - Commands::Register(args) => register::run(args), - Commands::Cat(args) => cat_cmd::run(args), - Commands::Inspect(args) => inspect::run(args), - Commands::Share(args) => share::run(args), - Commands::View(args) => view::run(args).await, - Commands::Review(args) => review::run(args).await, - Commands::Handoff(args) => handoff_v1::run(args), - Commands::Parse(args) => parse_cmd::run(args), - Commands::Summary(args) => summary_cmd::run(args).await, - Commands::Config(args) => config_cmd::run(args), - Commands::Cleanup(args) => cleanup_cmd::run(args), - Commands::Setup(args) => setup_cmd::run(args), - Commands::Doctor(args) => doctor_cmd::run(args), - Commands::Docs { action } => run_docs(action), - }; - - if let Err(err) = result { - if debug_errors_enabled() { - eprintln!("Error: {err:#}"); - } else { - eprintln!("Error: {err}"); - } - std::process::exit(1); - } -} - -fn debug_errors_enabled() -> bool { - matches!( - std::env::var("OPENSESSION_DEBUG"), - Ok(value) - if matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - ) -} - -fn run_docs(action: DocsAction) -> anyhow::Result<()> { - match action { - DocsAction::Completion { shell } => { - let mut cmd = ::command(); - clap_complete::generate(shell, &mut cmd, "opensession", &mut std::io::stdout()); - Ok(()) - } - DocsAction::Quickstart { - profile, - setup_profile, - input, - out, - remote, - } => { - print_quickstart(&profile, setup_profile, &input, &out, &remote); - Ok(()) - } - } -} - -fn print_quickstart( - profile: &str, - setup_profile: setup_cmd::SetupProfile, - input: &Path, - out: &Path, - remote: &str, -) { - println!("# OpenSession 5-minute first-user flow"); - println!(); - println!("# 1) Diagnose and apply setup"); - println!("opensession doctor"); - println!( - "opensession doctor --fix --profile {}", - setup_profile.as_str() - ); - if matches!(setup_profile, setup_cmd::SetupProfile::App) { - println!("opensession doctor --fix --profile app --open-target app"); - } - println!(); - println!("# 2) Parse raw logs into canonical HAIL JSONL"); - println!( - "opensession parse --profile {} {} --out {}", - profile, - input.display(), - out.display() - ); - println!(); - println!("# 3) Register canonical session locally"); - println!("opensession register {}", out.display()); - println!("# -> os://src/local/"); - println!(); - println!("# 4) Share local source URI via quick git flow"); - println!( - "opensession share os://src/local/ --quick --remote {}", - remote - ); - println!(); - println!("# 5) Optional: convert a remote URI to web URL"); - println!("opensession config init --base-url https://opensession.io"); - println!("opensession share os://src/git//ref//path/ --web"); + entrypoint::run_process().await; } diff --git a/crates/cli/src/open_target.rs b/crates/cli/src/open_target.rs index 528e0f22..78ae0e6c 100644 --- a/crates/cli/src/open_target.rs +++ b/crates/cli/src/open_target.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use clap::ValueEnum; use std::path::Path; use std::process::Command; @@ -71,7 +71,7 @@ pub fn write_repo_open_target(repo_root: &Path, target: OpenTarget) -> Result<() #[cfg(test)] mod tests { - use super::{read_repo_open_target, write_repo_open_target, OpenTarget}; + use super::{OpenTarget, read_repo_open_target, write_repo_open_target}; use std::process::Command; #[test] diff --git a/crates/cli/src/parse_cmd.rs b/crates/cli/src/parse_cmd.rs index 169e42d4..ffb9e28c 100644 --- a/crates/cli/src/parse_cmd.rs +++ b/crates/cli/src/parse_cmd.rs @@ -2,7 +2,7 @@ use crate::user_guidance::guided_error; use anyhow::{Context, Result}; use clap::Args; use opensession_core::validate::validate_session; -use opensession_parsers::ingest::{preview_parse_bytes, ParseError}; +use opensession_parsers::{ParseError, ParserRegistry}; use std::path::PathBuf; #[derive(Debug, Clone, Args)] @@ -42,8 +42,9 @@ pub fn run(args: ParseArgs) -> Result<()> { .and_then(|value| value.to_str()) .unwrap_or("session"); - let preview = - preview_parse_bytes(filename, &bytes, Some(args.profile.as_str())).map_err(|err| { + let preview = ParserRegistry::default() + .preview_bytes(filename, &bytes, Some(args.profile.as_str())) + .map_err(|err| { match err { ParseError::InvalidParserHint { .. } | ParseError::ParserSelectionRequired { .. } diff --git a/crates/cli/src/register.rs b/crates/cli/src/register.rs index 43c2679f..af4d609c 100644 --- a/crates/cli/src/register.rs +++ b/crates/cli/src/register.rs @@ -1,8 +1,8 @@ use crate::user_guidance::{guided_error, guided_error_with_doc}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use clap::Args; -use opensession_core::object_store::store_local_object; use opensession_core::Session; +use opensession_local_store::store_local_object; use std::path::PathBuf; #[derive(Debug, Clone, Args)] diff --git a/crates/cli/src/review.rs b/crates/cli/src/review.rs index a5f62c86..071c4ea9 100644 --- a/crates/cli/src/review.rs +++ b/crates/cli/src/review.rs @@ -1,14 +1,16 @@ use crate::url_opener::open_url_for_repo; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use clap::{Args, ValueEnum}; use opensession_api::{ LocalReviewBundle, LocalReviewCommit, LocalReviewLayerFileChange, LocalReviewPrMeta, LocalReviewReviewerDigest, LocalReviewReviewerQa, LocalReviewSemanticSummary, LocalReviewSession, }; +use opensession_core::session::is_auxiliary_session; use opensession_core::{ContentBlock, EventType, Session}; use opensession_runtime_config::SummarySettings; -use opensession_summary::{summarize_git_commit, SemanticSummaryArtifact}; +use opensession_summary::SemanticSummaryArtifact; +use opensession_summary_runtime::summarize_git_commit; use reqwest::Url; use serde::Deserialize; use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; @@ -771,6 +773,9 @@ async fn build_review_bundle(input: BuildReviewBundleInput<'_>) -> Result bool { Err(_) => return false, }; - match client.get(url).send().await { + let response = client.get(url).send().await; + match response { Ok(response) => response.status().is_success(), Err(_) => false, } @@ -1082,9 +1088,9 @@ fn run_git(repo_root: &Path, args: &[String]) -> Result<()> { #[cfg(test)] mod tests { use super::{ - build_review_id, build_reviewer_digest_for_commit, parse_github_pr_url, - parse_remote_repo_triplet, refresh_remote_head_fetch_args, resolve_view_mode, - sanitize_path_component, sanitize_review_id_component, GithubPrSpec, ReviewView, + GithubPrSpec, ReviewView, build_review_id, build_reviewer_digest_for_commit, + parse_github_pr_url, parse_remote_repo_triplet, refresh_remote_head_fetch_args, + resolve_view_mode, sanitize_path_component, sanitize_review_id_component, }; use opensession_api::LocalReviewSession; use opensession_core::{Agent, Content, Event, EventType, Session}; diff --git a/crates/cli/src/runtime_settings.rs b/crates/cli/src/runtime_settings.rs index 040f33ad..311262d8 100644 --- a/crates/cli/src/runtime_settings.rs +++ b/crates/cli/src/runtime_settings.rs @@ -1,16 +1,10 @@ use anyhow::{Context, Result}; use opensession_runtime_config::DaemonConfig; -use opensession_summary::provider::LocalSummaryProfile; +use opensession_summary_runtime::LocalSummaryProfile; use std::path::PathBuf; pub fn runtime_config_path() -> Result { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .context("Could not determine home directory")?; - Ok(PathBuf::from(home) - .join(".config") - .join("opensession") - .join(opensession_runtime_config::CONFIG_FILE_NAME)) + opensession_paths::runtime_config_path().context("Could not determine home directory") } pub fn load_runtime_config() -> Result { @@ -37,7 +31,7 @@ pub fn save_runtime_config(config: &DaemonConfig) -> Result { } pub fn detect_local_summary_profile() -> Option { - opensession_summary::detect_summary_provider() + opensession_summary_runtime::detect_local_summary_profile() } pub fn apply_summary_profile(config: &mut DaemonConfig, profile: &LocalSummaryProfile) { diff --git a/crates/cli/src/setup_cmd.rs b/crates/cli/src/setup_cmd.rs index ba65000b..eb923a74 100644 --- a/crates/cli/src/setup_cmd.rs +++ b/crates/cli/src/setup_cmd.rs @@ -1,60 +1,35 @@ -use crate::cleanup_cmd::{self, CleanupDoctorLevel}; -use crate::hooks::{ - install_hooks_with_report, list_installed_hooks, plan_hook_install, HookInstallAction, HookType, -}; -use crate::open_target::{read_repo_open_target, write_repo_open_target, OpenTarget}; -use anyhow::{bail, Context, Result}; +use crate::hooks::{HookType, install_hooks_with_report, plan_hook_install}; +use crate::open_target::{OpenTarget, read_repo_open_target}; +use anyhow::{Context, Result, bail}; use clap::{Args, ValueEnum}; -use opensession_core::sanitize::{sanitize_session, SanitizeConfig}; -use opensession_core::session::{build_git_storage_meta_json_with_git, working_directory, GitMeta}; -use opensession_core::Session; -use opensession_git_native::{ - branch_ledger_ref, extract_git_context, resolve_ledger_branch, NativeGitStorage, +use opensession_git_native::branch_ledger_ref; +use std::path::Path; + +mod branch_sync; +mod doctor; +mod planning; +mod shims; +mod status; +mod validation; + +use branch_sync::sync_branch_session_to_hidden_ledger; +use planning::{ + ensure_fanout_mode, ensure_open_target, print_applied_setup, print_setup_plan, read_fanout_mode, +}; +use shims::{install_cli_shims, plan_cli_shims}; +use status::{ + current_branch, ledger_branch_name, print_daemon_status, print_review_readiness, run_check, +}; +use validation::{ + enforce_apply_mode_requirements, is_interactive_terminal, prompt_apply_confirmation, + validate_setup_args, }; -use opensession_parsers::{discover::discover_sessions, parse_with_default_parsers}; -use opensession_runtime_config::{DaemonConfig, CONFIG_FILE_NAME}; -use std::cmp::Reverse; -use std::collections::HashSet; -use std::io::{self, IsTerminal, Write}; -use std::path::{Path, PathBuf}; -use std::process::Command; const FANOUT_MODE_GIT_CONFIG_KEY: &str = "opensession.fanout-mode"; const SYNC_MAX_CANDIDATES: usize = 128; const SYNC_BRANCH_COMMITS_MAX: usize = 4096; const COMMIT_HINT_GRACE_SECONDS: i64 = 6 * 60 * 60; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DoctorLevel { - Ok, - Info, - Warn, - Fail, -} - -#[derive(Debug, Default, Clone, Copy)] -struct DoctorSummary { - ok: usize, - info: usize, - warn: usize, - fail: usize, -} - -impl DoctorSummary { - fn record(&mut self, level: DoctorLevel) { - match level { - DoctorLevel::Ok => self.ok += 1, - DoctorLevel::Info => self.info += 1, - DoctorLevel::Warn => self.warn += 1, - DoctorLevel::Fail => self.fail += 1, - } - } - - fn issue_categories(&self) -> usize { - self.warn + self.fail - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FanoutMode { HiddenRef, @@ -143,64 +118,6 @@ impl SetupProfile { } } -pub fn run(args: SetupArgs) -> Result<()> { - if let Some(branch) = args.print_ledger_ref { - println!("{}", branch_ledger_ref(&branch)); - return Ok(()); - } - - if args.sync_branch_session.is_none() && args.sync_branch_commit.is_some() { - bail!("--sync-branch-commit requires --sync-branch-session"); - } - - if let Some(branch) = args.sync_branch_session { - let cwd = std::env::current_dir().context("read current directory")?; - let repo_root = opensession_git_native::ops::find_repo_root(&cwd) - .ok_or_else(|| anyhow::anyhow!("current directory is not inside a git repository"))?; - sync_branch_session_to_hidden_ledger(&repo_root, &branch, args.sync_branch_commit)?; - return Ok(()); - } - - if args.print_fanout_mode { - let cwd = std::env::current_dir().context("read current directory")?; - let repo_root = opensession_git_native::ops::find_repo_root(&cwd) - .ok_or_else(|| anyhow::anyhow!("current directory is not inside a git repository"))?; - let mode = read_fanout_mode(&repo_root)?.unwrap_or(FanoutMode::HiddenRef); - println!("{}", mode.as_str()); - return Ok(()); - } - - let cwd = std::env::current_dir().context("read current directory")?; - let repo_root = opensession_git_native::ops::find_repo_root(&cwd) - .ok_or_else(|| anyhow::anyhow!("current directory is not inside a git repository"))?; - - validate_setup_args(&args)?; - if args.check { - return run_check(&repo_root); - } - run_install( - &repo_root, - args.yes, - args.fanout_mode.map(SetupFanoutMode::as_fanout_mode), - args.open_target, - args.profile, - ) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ShimInstallAction { - InstallNew, - ReplaceExisting, - KeepExisting, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ShimInstallPlan { - name: &'static str, - path: PathBuf, - action: ShimInstallAction, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct FanoutInstallPlan { existing: Option, @@ -268,7 +185,7 @@ impl OpenTargetInstallPlan { (Some(existing), None) => format!("keep {existing}", existing = existing.as_str()), (None, None) => format!( "choose interactively (default: {})", - default_open_target_for_profile(profile).as_str() + planning::default_open_target_for_profile(profile).as_str() ), } } @@ -276,29 +193,56 @@ impl OpenTargetInstallPlan { fn suggested_target(self, profile: SetupProfile) -> OpenTarget { self.requested .or(self.existing) - .unwrap_or(default_open_target_for_profile(profile)) + .unwrap_or(planning::default_open_target_for_profile(profile)) } } -fn validate_setup_args(args: &SetupArgs) -> Result<()> { - if args.check && args.yes { - bail!("`--yes` cannot be used with `--check`"); +pub fn run(args: SetupArgs) -> Result<()> { + if let Some(branch) = args.print_ledger_ref { + println!("{}", branch_ledger_ref(&branch)); + return Ok(()); } - if args.check && args.fanout_mode.is_some() { - bail!( - "`--fanout-mode` requires apply mode. next: run `opensession doctor --fix --yes --profile local --fanout-mode hidden_ref`" - ); + + if args.sync_branch_session.is_none() && args.sync_branch_commit.is_some() { + bail!("--sync-branch-commit requires --sync-branch-session"); } - if args.check && args.open_target.is_some() { - bail!( - "`--open-target` requires apply mode. next: run `opensession doctor --fix --yes --profile local --open-target web`" - ); + + if let Some(branch) = args.sync_branch_session { + let cwd = std::env::current_dir().context("read current directory")?; + let repo_root = opensession_git_native::ops::find_repo_root(&cwd) + .ok_or_else(|| anyhow::anyhow!("current directory is not inside a git repository"))?; + sync_branch_session_to_hidden_ledger(&repo_root, &branch, args.sync_branch_commit)?; + return Ok(()); } - Ok(()) + + if args.print_fanout_mode { + let cwd = std::env::current_dir().context("read current directory")?; + let repo_root = opensession_git_native::ops::find_repo_root(&cwd) + .ok_or_else(|| anyhow::anyhow!("current directory is not inside a git repository"))?; + let mode = read_fanout_mode(&repo_root)?.unwrap_or(FanoutMode::HiddenRef); + println!("{}", mode.as_str()); + return Ok(()); + } + + let cwd = std::env::current_dir().context("read current directory")?; + let repo_root = opensession_git_native::ops::find_repo_root(&cwd) + .ok_or_else(|| anyhow::anyhow!("current directory is not inside a git repository"))?; + + validate_setup_args(&args)?; + if args.check { + return run_check(&repo_root); + } + run_install( + &repo_root, + args.yes, + args.fanout_mode.map(SetupFanoutMode::as_fanout_mode), + args.open_target, + args.profile, + ) } fn run_install( - repo_root: &PathBuf, + repo_root: &Path, yes: bool, requested_fanout: Option, requested_open_target: Option, @@ -366,1166 +310,11 @@ fn run_install( Ok(()) } -fn suggested_doctor_command(mode: FanoutMode, target: OpenTarget, profile: SetupProfile) -> String { - format!( - "opensession doctor --fix --yes --profile {} --fanout-mode {} --open-target {}", - profile.as_str(), - mode.as_str(), - target.as_str(), - ) -} - -fn enforce_apply_mode_requirements( - interactive: bool, - yes: bool, - fanout_plan: FanoutInstallPlan, -) -> Result<()> { - let suggested_mode = fanout_plan.suggested_mode(); - if !interactive && !yes { - bail!( - "setup requires explicit approval in non-interactive mode.\nnext: run `{}`", - suggested_doctor_command(suggested_mode, OpenTarget::Web, SetupProfile::Local) - ); - } - if !interactive && fanout_plan.existing.is_none() && fanout_plan.requested.is_none() { - bail!( - "fanout mode is not configured for this repository, and setup cannot prompt in non-interactive mode.\nnext: run `{}`", - suggested_doctor_command( - FanoutMode::HiddenRef, - OpenTarget::Web, - SetupProfile::Local - ) - ); - } - Ok(()) -} - -fn is_interactive_terminal() -> bool { - io::stdin().is_terminal() && io::stdout().is_terminal() -} - -fn prompt_apply_confirmation( - mode_hint: FanoutMode, - open_target_hint: OpenTarget, - profile: SetupProfile, -) -> Result<()> { - print!("Apply these changes? [y/N]: "); - io::stdout().flush().context("flush stdout")?; - let mut line = String::new(); - io::stdin() - .read_line(&mut line) - .context("read setup confirmation")?; - if parse_apply_confirmation(&line) { - return Ok(()); - } - bail!( - "setup cancelled by user.\nnext: run `{}`", - suggested_doctor_command(mode_hint, open_target_hint, profile) - ); -} - -fn parse_apply_confirmation(input: &str) -> bool { - matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes") -} - -fn plan_cli_shims() -> Result> { - let exe = std::env::current_exe().context("resolve current opensession executable path")?; - let mut plans = Vec::new(); - for name in ["opensession", "ops"] { - plans.push(plan_cli_shim(name, &exe)?); - } - Ok(plans) -} - -fn plan_cli_shim(name: &'static str, exe: &Path) -> Result { - let path = shim_path(name)?; - let action = if !path.exists() { - ShimInstallAction::InstallNew - } else if std::fs::canonicalize(&path).ok() == std::fs::canonicalize(exe).ok() { - ShimInstallAction::KeepExisting - } else { - ShimInstallAction::ReplaceExisting - }; - Ok(ShimInstallPlan { name, path, action }) -} - -fn shim_action_label(action: ShimInstallAction) -> &'static str { - match action { - ShimInstallAction::InstallNew => "install", - ShimInstallAction::ReplaceExisting => "replace", - ShimInstallAction::KeepExisting => "keep", - } -} - -fn hook_action_summary(action: HookInstallAction) -> &'static str { - match action { - HookInstallAction::InstallNew => "install", - HookInstallAction::ReplaceManaged => "refresh", - HookInstallAction::BackupAndReplace => "preserve-original+replace", - } -} - -fn print_setup_plan( - repo_root: &Path, - fanout_plan: FanoutInstallPlan, - open_target_plan: OpenTargetInstallPlan, - profile: SetupProfile, - hook_plans: &[crate::hooks::HookInstallPlan], - shim_plans: &[ShimInstallPlan], - yes: bool, -) { - println!("repo: {}", repo_root.display()); - println!("setup plan:"); - println!(" - profile: {}", profile.as_str()); - println!(" - fanout mode: {}", fanout_plan.summary()); - println!(" - open target: {}", open_target_plan.summary(profile)); - for plan in hook_plans { - println!( - " - hook {}: {} ({})", - plan.hook_type.filename(), - hook_action_summary(plan.action), - plan.hook_path.display() - ); - if let Some(backup_path) = &plan.backup_path { - println!(" original hook saved as: {}", backup_path.display()); - println!( - " restore: mv '{}' '{}'", - backup_path.display(), - plan.hook_path.display() - ); - } - } - for plan in shim_plans { - println!( - " - shim {}: {} ({})", - plan.name, - shim_action_label(plan.action), - plan.path.display() - ); - } - if yes { - println!(" - confirmation: skipped (--yes)"); - } else { - println!(" - confirmation: required (use --yes to skip)"); - } -} - -fn print_applied_setup( - repo_root: &Path, - fanout_mode: FanoutMode, - open_target: OpenTarget, - hook_reports: &[crate::hooks::HookInstallReport], - shim_plans: &[ShimInstallPlan], - shim_paths: &ShimPaths, -) { - println!("Applied setup in {}:", repo_root.display()); - println!(" - fanout mode: {}", fanout_mode.as_str()); - println!(" - open target: {}", open_target.as_str()); - for report in hook_reports { - println!( - " - hook {}: {} ({})", - report.hook_type.filename(), - hook_action_summary(report.action), - report.hook_path.display() - ); - if report.backup_created { - if let Some(backup_path) = &report.backup_path { - println!(" original hook saved as: {}", backup_path.display()); - println!( - " restore: mv '{}' '{}'", - backup_path.display(), - report.hook_path.display() - ); - } - } - } - - for plan in shim_plans { - let path = match plan.name { - "opensession" => &shim_paths.opensession, - _ => &shim_paths.ops, - }; - println!( - " - shim {}: {} ({})", - plan.name, - shim_action_label(plan.action), - path.display() - ); - } -} - -#[derive(Debug, Clone)] -struct SessionCandidate { - path: PathBuf, - modified: std::time::SystemTime, -} - -fn collect_recent_candidates() -> Vec { - let mut candidates = Vec::new(); - for location in discover_sessions() { - for path in location.paths { - let Ok(metadata) = std::fs::metadata(&path) else { - continue; - }; - let Ok(modified) = metadata.modified() else { - continue; - }; - candidates.push(SessionCandidate { path, modified }); - } - } - - candidates.sort_by_key(|candidate| Reverse(candidate.modified)); - candidates.into_iter().take(SYNC_MAX_CANDIDATES).collect() -} - -fn same_repo_root(left: &Path, right: &Path) -> bool { - let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); - let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); - left == right -} - -fn parse_session_candidate(path: &Path) -> Option { - match parse_with_default_parsers(path) { - Ok(Some(session)) => { - if working_directory(&session).is_some() { - Some(session) - } else { - std::fs::read_to_string(path) - .ok() - .and_then(|content| Session::from_jsonl(&content).ok()) - } - } - Ok(None) | Err(_) => std::fs::read_to_string(path) - .ok() - .and_then(|content| Session::from_jsonl(&content).ok()), - } -} - -fn normalize_commit_hint(commit_hint: Option) -> Option { - commit_hint - .and_then(|raw| { - let trimmed = raw.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) - .filter(|sha| sha != "0000000000000000000000000000000000000000") -} - -fn list_branch_commits(repo_root: &Path, branch: &str, max_count: usize) -> HashSet { - let rev = format!("refs/heads/{branch}"); - let output = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("rev-list") - .arg("--max-count") - .arg(max_count.to_string()) - .arg(rev) - .output(); - let Ok(output) = output else { - return HashSet::new(); - }; - if !output.status.success() { - return HashSet::new(); - } - String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(ToOwned::to_owned) - .collect() -} - -fn commit_time_unix(repo_root: &Path, commit: &str) -> Option { - let output = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("show") - .arg("-s") - .arg("--format=%ct") - .arg(commit) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); - raw.parse::().ok() -} - -fn commit_shas_from_reflog(repo_root: &Path, start_ts: i64, end_ts: i64) -> Vec { - let git_dir_output = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("rev-parse") - .arg("--git-dir") - .output(); - let Ok(git_dir_output) = git_dir_output else { - return Vec::new(); - }; - if !git_dir_output.status.success() { - return Vec::new(); - } - let git_dir = String::from_utf8_lossy(&git_dir_output.stdout) - .trim() - .to_string(); - if git_dir.is_empty() { - return Vec::new(); - } - - let git_dir_path = if Path::new(&git_dir).is_absolute() { - PathBuf::from(git_dir) - } else { - repo_root.join(git_dir) - }; - let reflog_path = git_dir_path.join("logs").join("HEAD"); - let raw = std::fs::read_to_string(reflog_path); - let Ok(raw) = raw else { - return Vec::new(); - }; - - let mut seen = HashSet::new(); - let mut commits = Vec::new(); - for line in raw.lines() { - let Some((left, _)) = line.split_once('\t') else { - continue; - }; - let mut parts = left.split_whitespace(); - let _old = parts.next(); - let Some(new_sha) = parts.next() else { - continue; - }; - if new_sha.len() < 7 || !new_sha.chars().all(|ch| ch.is_ascii_hexdigit()) { - continue; - } - let mut tail = left.split_whitespace().rev(); - let _tz = tail.next(); - let Some(ts_raw) = tail.next() else { - continue; - }; - let Ok(ts) = ts_raw.parse::() else { - continue; - }; - if ts < start_ts || ts > end_ts { - continue; - } - if seen.insert(new_sha.to_string()) { - commits.push(new_sha.to_string()); - } - } - commits -} - -fn session_commit_links( - repo_root: &Path, - branch_commits: &HashSet, - session: &Session, - commit_hint: Option<&str>, -) -> Vec { - let created = session.context.created_at.timestamp(); - let updated = session.context.updated_at.timestamp(); - let (start, end) = if created <= updated { - (created, updated) - } else { - (updated, created) - }; - let mut commits = commit_shas_from_reflog(repo_root, start, end) - .into_iter() - .filter(|sha| branch_commits.contains(sha)) - .collect::>(); - - if let Some(hint) = commit_hint { - if branch_commits.contains(hint) && !commits.iter().any(|sha| sha == hint) { - if let Some(hint_ts) = commit_time_unix(repo_root, hint) { - let window_start = start.saturating_sub(COMMIT_HINT_GRACE_SECONDS); - let window_end = end.saturating_add(COMMIT_HINT_GRACE_SECONDS); - if hint_ts >= window_start && hint_ts <= window_end { - commits.push(hint.to_string()); - } - } - } - } - - commits -} - -fn load_daemon_config() -> DaemonConfig { - let home = match std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { - Ok(home) => home, - Err(_) => return DaemonConfig::default(), - }; - let path = PathBuf::from(home) - .join(".config") - .join("opensession") - .join(CONFIG_FILE_NAME); - let content = match std::fs::read_to_string(path) { - Ok(content) => content, - Err(_) => return DaemonConfig::default(), - }; - toml::from_str(&content).unwrap_or_default() -} - -fn sync_branch_session_to_hidden_ledger( - repo_root: &Path, - branch: &str, - commit_hint: Option, -) -> Result<()> { - let candidates = collect_recent_candidates(); - if candidates.is_empty() { - return Ok(()); - } - - let config = load_daemon_config(); - let branch_commits = list_branch_commits(repo_root, branch, SYNC_BRANCH_COMMITS_MAX); - if branch_commits.is_empty() { - return Ok(()); - } - let commit_hint = normalize_commit_hint(commit_hint); - let mut synced_any = false; - let mut seen_sessions = HashSet::new(); - - for candidate in candidates { - let Some(mut session) = parse_session_candidate(&candidate.path) else { - continue; - }; - let Some(cwd) = working_directory(&session).map(str::to_owned) else { - continue; - }; - let Some(session_repo) = opensession_git_native::ops::find_repo_root(Path::new(&cwd)) - else { - continue; - }; - if !same_repo_root(repo_root, &session_repo) { - continue; - } - - if config - .privacy - .exclude_tools - .iter() - .any(|tool| tool.eq_ignore_ascii_case(&session.agent.tool)) - { - continue; - } - if !seen_sessions.insert(session.session_id.clone()) { - continue; - } - - let commit_shas = - session_commit_links(repo_root, &branch_commits, &session, commit_hint.as_deref()); - if commit_shas.is_empty() { - continue; - } - - sanitize_session( - &mut session, - &SanitizeConfig { - strip_paths: config.privacy.strip_paths, - strip_env_vars: config.privacy.strip_env_vars, - exclude_patterns: config.privacy.exclude_patterns.clone(), - }, - ); - - let git_ctx = extract_git_context(&cwd); - let meta = build_git_storage_meta_json_with_git( - &session, - Some(&GitMeta { - remote: git_ctx.remote.clone(), - repo_name: git_ctx.repo_name.clone(), - branch: Some(branch.to_string()), - head: commit_hint - .clone() - .or_else(|| commit_shas.last().cloned()) - .or(git_ctx.commit.clone()), - commits: commit_shas.clone(), - }), - ); - let hail = session - .to_jsonl() - .context("serialize session to canonical HAIL JSONL")?; - - NativeGitStorage.store_session_at_ref( - repo_root, - &branch_ledger_ref(branch), - &session.session_id, - hail.as_bytes(), - &meta, - &commit_shas, - )?; - synced_any = true; - } - - let _ = synced_any; - Ok(()) -} - -fn run_check(repo_root: &PathBuf) -> Result<()> { - let colors = doctor_colors_enabled(); - let mut summary = DoctorSummary::default(); - let installed = list_installed_hooks(repo_root); - let fanout_mode = read_fanout_mode(repo_root)?.unwrap_or(FanoutMode::HiddenRef); - let branch = current_branch(repo_root)?; - let ledger_branch = ledger_branch_name(repo_root); - let ledger = branch_ledger_ref(&ledger_branch); - - println!("repo: {}", repo_root.display()); - println!("doctor checks:"); - - let hooks_summary = if installed.is_empty() { - "none".to_string() - } else { - installed - .iter() - .map(HookType::filename) - .collect::>() - .join(", ") - }; - let hook_level = if installed.is_empty() { - DoctorLevel::Warn - } else { - DoctorLevel::Ok - }; - print_doctor_item(colors, hook_level, "opensession hooks", &hooks_summary); - summary.record(hook_level); - - let mut required_actions = Vec::new(); - let mut optional_actions = Vec::new(); - - match shim_path("opensession") { - Ok(path) => { - let present = path.exists(); - let level = if present { - DoctorLevel::Ok - } else { - DoctorLevel::Warn - }; - print_doctor_item( - colors, - level, - "opensession shim", - &format!( - "{} ({})", - path.display(), - if present { "present" } else { "missing" } - ), - ); - summary.record(level); - if !present { - required_actions.push( - "run `opensession doctor --fix` to install hooks/shims for this repo" - .to_string(), - ); - } - } - Err(err) => { - print_doctor_item( - colors, - DoctorLevel::Fail, - "opensession shim", - &format!("unavailable ({err})"), - ); - summary.record(DoctorLevel::Fail); - } - } - - match shim_path("ops") { - Ok(path) => { - let present = path.exists(); - let level = if present { - DoctorLevel::Ok - } else { - DoctorLevel::Info - }; - print_doctor_item( - colors, - level, - "ops shim", - &format!( - "{} ({})", - path.display(), - if present { "present" } else { "missing" } - ), - ); - summary.record(level); - if !present { - optional_actions.push( - "optional: install `ops` shim via `opensession doctor --fix` for alias UX" - .to_string(), - ); - } - } - Err(err) => { - print_doctor_item( - colors, - DoctorLevel::Fail, - "ops shim", - &format!("unavailable ({err})"), - ); - summary.record(DoctorLevel::Fail); - } - } - - if let Ok(exe) = std::env::current_exe() { - print_doctor_item( - colors, - DoctorLevel::Ok, - "active binary", - &exe.display().to_string(), - ); - summary.record(DoctorLevel::Ok); - } - - print_doctor_item(colors, DoctorLevel::Ok, "fanout mode", fanout_mode.as_str()); - summary.record(DoctorLevel::Ok); - - let daemon_pid = daemon_pid_path()?; - let daemon = daemon_status(&daemon_pid); - let (daemon_level, daemon_summary, daemon_hint) = daemon_status_summary(&daemon, &daemon_pid); - print_doctor_item(colors, daemon_level, "daemon", &daemon_summary); - summary.record(daemon_level); - if let Some(hint) = daemon_hint { - print_doctor_hint(&hint); - if daemon_level == DoctorLevel::Info { - optional_actions.push(hint); - } else { - required_actions.push(hint); - } - } - - let readiness = review_readiness(repo_root); - let (readiness_level, readiness_summary, readiness_hint) = - review_readiness_summary(readiness.hidden_fanout_ready, readiness.remote_hidden_refs); - print_doctor_item( - colors, - readiness_level, - "review readiness", - &readiness_summary, - ); - summary.record(readiness_level); - if let Some(hint) = readiness_hint { - print_doctor_hint(&hint); - if readiness_level == DoctorLevel::Info { - optional_actions.push(hint); - } else { - required_actions.push(hint); - } - } - - let cleanup = cleanup_cmd::doctor_status(repo_root); - let cleanup_level = match cleanup.level { - CleanupDoctorLevel::Ok => DoctorLevel::Ok, - CleanupDoctorLevel::Warn => DoctorLevel::Warn, - }; - print_doctor_item(colors, cleanup_level, "cleanup", &cleanup.detail); - summary.record(cleanup_level); - if let Some(hint) = cleanup.hint { - print_doctor_hint(&hint); - required_actions.push(hint); - } - - print_doctor_item(colors, DoctorLevel::Ok, "current branch", &branch); - summary.record(DoctorLevel::Ok); - if branch != ledger_branch { - print_doctor_item(colors, DoctorLevel::Info, "ledger branch", &ledger_branch); - summary.record(DoctorLevel::Info); - optional_actions.push( - "optional: branch/ledger mismatch is expected on detached HEAD; verify before sharing" - .to_string(), - ); - } - print_doctor_item(colors, DoctorLevel::Ok, "expected ledger ref", &ledger); - summary.record(DoctorLevel::Ok); - - if !required_actions.is_empty() { - let mut dedup = HashSet::new(); - println!("next actions (recommended):"); - for suggestion in required_actions { - if dedup.insert(suggestion.clone()) { - println!(" - {suggestion}"); - } - } - } - if !optional_actions.is_empty() { - let mut dedup = HashSet::new(); - println!("next actions (optional):"); - for suggestion in optional_actions { - if dedup.insert(suggestion.clone()) { - println!(" - {suggestion}"); - } - } - } - - if summary.issue_categories() == 0 { - println!("doctor summary: no blocking issues."); - } else { - println!( - "doctor summary: found issues in {} categories.", - summary.issue_categories() - ); - } - - Ok(()) -} - -fn doctor_colors_enabled() -> bool { - io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() -} - -fn doctor_tag(level: DoctorLevel, colors: bool) -> String { - let plain = match level { - DoctorLevel::Ok => "[ OK ]", - DoctorLevel::Info => "[INFO]", - DoctorLevel::Warn => "[WARN]", - DoctorLevel::Fail => "[FAIL]", - }; - if !colors { - return plain.to_string(); - } - let code = match level { - DoctorLevel::Ok => "32", - DoctorLevel::Info => "36", - DoctorLevel::Warn => "33", - DoctorLevel::Fail => "31", - }; - format!("\x1b[1;{code}m{plain}\x1b[0m") -} - -fn print_doctor_item(colors: bool, level: DoctorLevel, label: &str, detail: &str) { - println!( - "{} {:<18} {}", - doctor_tag(level, colors), - format!("{label}:"), - detail - ); -} - -fn print_doctor_hint(detail: &str) { - println!(" hint: {detail}"); -} - -fn ensure_fanout_mode( - repo_root: &std::path::Path, - requested: Option, - interactive: bool, -) -> Result { - if let Some(mode) = requested { - write_fanout_mode(repo_root, mode)?; - println!("fanout mode set: {}", mode.as_str()); - return Ok(mode); - } - if let Some(mode) = read_fanout_mode(repo_root)? { - return Ok(mode); - } - - if !interactive { - bail!( - "fanout mode is not configured for this repository.\nnext: run `{}`", - suggested_doctor_command(FanoutMode::HiddenRef, OpenTarget::Web, SetupProfile::Local) - ); - } - - let mode = prompt_fanout_mode()?; - write_fanout_mode(repo_root, mode)?; - println!("fanout mode initialized: {}", mode.as_str()); - Ok(mode) -} - -fn ensure_open_target( - repo_root: &std::path::Path, - requested: Option, - interactive: bool, - profile: SetupProfile, -) -> Result { - if let Some(target) = requested { - write_repo_open_target(repo_root, target)?; - println!("open target set: {}", target.as_str()); - return Ok(target); - } - if let Some(target) = read_repo_open_target(repo_root)? { - return Ok(target); - } - - let default_target = default_open_target_for_profile(profile); - let target = if interactive { - let selected = prompt_open_target(default_target, profile)?; - println!("open target initialized: {}", selected.as_str()); - selected - } else { - println!( - "open target defaulted: {} (non-interactive)", - default_target.as_str() - ); - default_target - }; - write_repo_open_target(repo_root, target)?; - Ok(target) -} - -fn default_open_target_for_profile(profile: SetupProfile) -> OpenTarget { - match profile { - SetupProfile::Local => OpenTarget::Web, - SetupProfile::App => OpenTarget::App, - } -} - -fn read_fanout_mode(repo_root: &std::path::Path) -> Result> { - let output = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("config") - .arg("--local") - .arg("--get") - .arg(FANOUT_MODE_GIT_CONFIG_KEY) - .output() - .context("read git fanout mode")?; - - if !output.status.success() { - return Ok(None); - } - - let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if raw.is_empty() { - return Ok(None); - } - - Ok(Some( - FanoutMode::parse(&raw).unwrap_or(FanoutMode::HiddenRef), - )) -} - -fn write_fanout_mode(repo_root: &std::path::Path, mode: FanoutMode) -> Result<()> { - let output = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("config") - .arg("--local") - .arg(FANOUT_MODE_GIT_CONFIG_KEY) - .arg(mode.as_str()) - .output() - .context("write git fanout mode")?; - if !output.status.success() { - bail!( - "failed to store fanout mode in git config: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -fn prompt_fanout_mode() -> Result { - if !io::stdin().is_terminal() || !io::stdout().is_terminal() { - bail!( - "fanout mode prompt requires an interactive terminal.\nnext: run `{}`", - suggested_doctor_command(FanoutMode::HiddenRef, OpenTarget::Web, SetupProfile::Local) - ); - } - - println!("Choose OpenSession fanout mode for this repository:"); - println!(" 1) hidden refs (default)"); - println!(" 2) git notes"); - print!("select [1/2]: "); - io::stdout().flush().context("flush stdout")?; - - let mut line = String::new(); - io::stdin().read_line(&mut line).context("read selection")?; - Ok(parse_fanout_choice(&line).unwrap_or(FanoutMode::HiddenRef)) -} - -fn parse_fanout_choice(input: &str) -> Option { - FanoutMode::parse(input) -} - -fn prompt_open_target(default_target: OpenTarget, profile: SetupProfile) -> Result { - if !io::stdin().is_terminal() || !io::stdout().is_terminal() { - bail!( - "open target prompt requires an interactive terminal.\nnext: run `{}`", - suggested_doctor_command(FanoutMode::HiddenRef, default_target, profile) - ); - } - - println!("Choose OpenSession review opener for this repository:"); - println!(" 1) app"); - println!(" 2) web"); - println!(" default: {}", default_target.as_str()); - print!("select [1/2]: "); - io::stdout().flush().context("flush stdout")?; - - let mut line = String::new(); - io::stdin().read_line(&mut line).context("read selection")?; - Ok(parse_open_target_choice(&line).unwrap_or(default_target)) -} - -fn parse_open_target_choice(input: &str) -> Option { - OpenTarget::parse(input) -} - -fn print_daemon_status() -> Result<()> { - let pid_path = daemon_pid_path()?; - let status = daemon_status(&pid_path); - let (_, summary, hint) = daemon_status_summary(&status, &pid_path); - println!("daemon: {summary}"); - if let Some(hint) = hint { - println!("daemon hint: {hint}"); - } - Ok(()) -} - -fn daemon_status_summary( - status: &DaemonStatus, - pid_path: &std::path::Path, -) -> (DoctorLevel, String, Option) { - match status { - DaemonStatus::Running(pid) => (DoctorLevel::Ok, format!("running (pid {pid})"), None), - DaemonStatus::NotRunning => ( - DoctorLevel::Info, - format!("not running (pid file missing: {})", pid_path.display()), - Some( - "optional: start daemon for auto-capture with `opensession-daemon` (or `cargo run -p opensession-daemon -- run` in a source checkout)" - .to_string(), - ), - ), - DaemonStatus::StalePid(pid) => ( - DoctorLevel::Info, - format!( - "not running (stale pid file: {} -> pid {pid})", - pid_path.display() - ), - Some( - "optional: restart daemon for auto-capture with `opensession-daemon` (or `cargo run -p opensession-daemon -- run` in a source checkout)" - .to_string(), - ), - ), - DaemonStatus::Unreadable(err) => ( - DoctorLevel::Fail, - format!("status unavailable ({err})"), - None, - ), - } -} - -fn daemon_pid_path() -> Result { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .context("HOME/USERPROFILE is not set; cannot resolve daemon pid path")?; - Ok(PathBuf::from(home) - .join(".config") - .join("opensession") - .join("daemon.pid")) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum DaemonStatus { - Running(u32), - NotRunning, - StalePid(u32), - Unreadable(String), -} - -fn daemon_status(pid_path: &std::path::Path) -> DaemonStatus { - if !pid_path.exists() { - return DaemonStatus::NotRunning; - } - - let pid_raw = match std::fs::read_to_string(pid_path) { - Ok(raw) => raw, - Err(err) => return DaemonStatus::Unreadable(format!("read {}: {err}", pid_path.display())), - }; - let pid = match pid_raw.trim().parse::() { - Ok(pid) if pid > 0 => pid, - Ok(_) | Err(_) => { - return DaemonStatus::Unreadable(format!( - "invalid pid content in {}", - pid_path.display() - )); - } - }; - - if process_running(pid) { - DaemonStatus::Running(pid) - } else { - DaemonStatus::StalePid(pid) - } -} - -#[cfg(unix)] -fn process_running(pid: u32) -> bool { - // kill(pid, 0) does not send a signal; it only checks process existence/permission. - let rc = unsafe { libc::kill(pid as i32, 0) }; - if rc == 0 { - return true; - } - matches!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::EPERM) - ) -} - -#[cfg(not(unix))] -fn process_running(_pid: u32) -> bool { - false -} - -fn ledger_branch_name(repo_root: &std::path::Path) -> String { - let cwd = repo_root.to_string_lossy().to_string(); - let git_ctx = extract_git_context(&cwd); - resolve_ledger_branch(git_ctx.branch.as_deref(), git_ctx.commit.as_deref()) -} - -fn current_branch(repo_root: &PathBuf) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("rev-parse") - .arg("--abbrev-ref") - .arg("HEAD") - .output() - .context("resolve current git branch")?; - if !output.status.success() { - bail!( - "failed to read current branch: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -fn shim_path(name: &str) -> Result { - let home = std::env::var("HOME") - .context("HOME environment variable is not set; cannot resolve shim path")?; - Ok(PathBuf::from(home) - .join(".local") - .join("share") - .join("opensession") - .join("bin") - .join(name)) -} - -#[derive(Debug, Clone)] -struct ShimPaths { - opensession: PathBuf, - ops: PathBuf, -} - -fn install_cli_shims() -> Result { - let exe = std::env::current_exe().context("resolve current opensession executable path")?; - let opensession = install_cli_shim("opensession", &exe)?; - let ops = install_cli_shim("ops", &exe)?; - Ok(ShimPaths { opensession, ops }) -} - -fn install_cli_shim(name: &str, exe: &std::path::Path) -> Result { - let shim = shim_path(name)?; - if let Some(parent) = shim.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create shim directory {}", parent.display()))?; - } - - let existing_matches = if shim.exists() { - std::fs::canonicalize(&shim).ok() == std::fs::canonicalize(exe).ok() - } else { - false - }; - if existing_matches { - return Ok(shim); - } - - if shim.exists() { - std::fs::remove_file(&shim) - .with_context(|| format!("remove existing shim {}", shim.display()))?; - } - - #[cfg(unix)] - { - std::os::unix::fs::symlink(exe, &shim) - .with_context(|| format!("create shim symlink {}", shim.display()))?; - } - - #[cfg(not(unix))] - { - std::fs::copy(exe, &shim) - .with_context(|| format!("create shim copy {}", shim.display()))?; - } - - Ok(shim) -} - -#[derive(Debug, Clone, Copy)] -struct ReviewReadiness { - hidden_fanout_ready: bool, - remote_hidden_refs: bool, -} - -fn review_readiness(repo_root: &PathBuf) -> ReviewReadiness { - let hidden_fanout_ready = list_installed_hooks(repo_root).contains(&HookType::PrePush); - - let remote_hidden_refs = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("for-each-ref") - .arg("--count=1") - .arg("--format=%(refname)") - .arg("refs/remotes/*/opensession/branches") - .output() - .ok() - .filter(|out| out.status.success()) - .map(|out| !String::from_utf8_lossy(&out.stdout).trim().is_empty()) - .unwrap_or(false); - - ReviewReadiness { - hidden_fanout_ready, - remote_hidden_refs, - } -} - -fn review_readiness_summary( - hidden_fanout_ready: bool, - remote_hidden_refs: bool, -) -> (DoctorLevel, String, Option) { - let summary = format!( - "hidden-fanout={} hidden-refs={}", - if hidden_fanout_ready { "ok" } else { "missing" }, - if remote_hidden_refs { - "present" - } else { - "none-fetched" - } - ); - - if hidden_fanout_ready && remote_hidden_refs { - return (DoctorLevel::Ok, summary, None); - } - - if !hidden_fanout_ready { - return ( - DoctorLevel::Warn, - summary, - Some("run `opensession doctor --fix` to install the pre-push fanout hook".to_string()), - ); - } - - ( - DoctorLevel::Info, - summary, - Some( - "optional: fetch hidden refs before local review: `git fetch origin 'refs/opensession/branches/*:refs/remotes/origin/opensession/branches/*'`".to_string(), - ), - ) -} - -fn print_review_readiness(repo_root: &PathBuf) -> Result<()> { - let readiness = review_readiness(repo_root); - let (_, summary, _) = - review_readiness_summary(readiness.hidden_fanout_ready, readiness.remote_hidden_refs); - println!("review readiness: {summary}"); - Ok(()) -} - #[cfg(test)] mod tests { use super::*; use std::fs; + use std::process::Command; #[test] fn print_ledger_ref_matches_helper() { @@ -1536,8 +325,8 @@ mod tests { #[test] fn daemon_status_reports_not_running_when_pid_file_missing() { let tmp = tempfile::tempdir().expect("tempdir"); - let status = daemon_status(&tmp.path().join("daemon.pid")); - assert_eq!(status, DaemonStatus::NotRunning); + let status = status::daemon_status(&tmp.path().join("daemon.pid")); + assert_eq!(status, status::DaemonStatus::NotRunning); } #[test] @@ -1545,44 +334,55 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); let pid_path = tmp.path().join("daemon.pid"); fs::write(&pid_path, "not-a-pid").expect("write pid"); - let status = daemon_status(&pid_path); - assert!(matches!(status, DaemonStatus::Unreadable(_))); + let status = status::daemon_status(&pid_path); + assert!(matches!(status, status::DaemonStatus::Unreadable(_))); } #[test] fn daemon_status_summary_includes_hint_when_not_running() { let tmp = tempfile::tempdir().expect("tempdir"); let pid_path = tmp.path().join("daemon.pid"); - let (level, summary, hint) = daemon_status_summary(&DaemonStatus::NotRunning, &pid_path); - assert_eq!(level, DoctorLevel::Info); + let (level, summary, hint) = + status::daemon_status_summary(&status::DaemonStatus::NotRunning, &pid_path); + assert_eq!(level, doctor::DoctorLevel::Info); assert!(summary.contains("not running")); assert!(hint.expect("hint should exist").contains("optional:")); } #[test] fn review_readiness_summary_warns_when_hidden_fanout_missing() { - let (level, summary, hint) = review_readiness_summary(false, false); - assert_eq!(level, DoctorLevel::Warn); + let (level, summary, hint) = status::review_readiness_summary(false, false); + assert_eq!(level, doctor::DoctorLevel::Warn); assert!(summary.contains("hidden-fanout=missing")); - assert!(hint - .expect("hint should exist") - .contains("opensession doctor --fix")); + assert!( + hint.expect("hint should exist") + .contains("opensession doctor --fix") + ); } #[test] fn review_readiness_summary_marks_missing_refs_as_optional_info() { - let (level, summary, hint) = review_readiness_summary(true, false); - assert_eq!(level, DoctorLevel::Info); + let (level, summary, hint) = status::review_readiness_summary(true, false); + assert_eq!(level, doctor::DoctorLevel::Info); assert!(summary.contains("hidden-refs=none-fetched")); assert!(hint.expect("hint should exist").contains("optional:")); } #[test] fn doctor_tag_plain_is_ascii_stable() { - assert_eq!(doctor_tag(DoctorLevel::Ok, false), "[ OK ]"); - assert_eq!(doctor_tag(DoctorLevel::Info, false), "[INFO]"); - assert_eq!(doctor_tag(DoctorLevel::Warn, false), "[WARN]"); - assert_eq!(doctor_tag(DoctorLevel::Fail, false), "[FAIL]"); + assert_eq!(doctor::doctor_tag(doctor::DoctorLevel::Ok, false), "[ OK ]"); + assert_eq!( + doctor::doctor_tag(doctor::DoctorLevel::Info, false), + "[INFO]" + ); + assert_eq!( + doctor::doctor_tag(doctor::DoctorLevel::Warn, false), + "[WARN]" + ); + assert_eq!( + doctor::doctor_tag(doctor::DoctorLevel::Fail, false), + "[FAIL]" + ); } #[test] @@ -1598,7 +398,7 @@ mod tests { sync_branch_commit: None, profile: None, }; - let err = validate_setup_args(&args).expect_err("validate"); + let err = validation::validate_setup_args(&args).expect_err("validate"); assert!(err.to_string().contains("cannot be used")); } @@ -1615,7 +415,7 @@ mod tests { sync_branch_commit: None, profile: None, }; - let err = validate_setup_args(&args).expect_err("validate"); + let err = validation::validate_setup_args(&args).expect_err("validate"); assert!(err.to_string().contains("requires apply mode")); } @@ -1632,26 +432,27 @@ mod tests { sync_branch_commit: None, profile: None, }; - let err = validate_setup_args(&args).expect_err("validate"); - assert!(err - .to_string() - .contains("`--open-target` requires apply mode")); + let err = validation::validate_setup_args(&args).expect_err("validate"); + assert!( + err.to_string() + .contains("`--open-target` requires apply mode") + ); } #[test] fn parse_apply_confirmation_accepts_yes_aliases() { - assert!(parse_apply_confirmation("y")); - assert!(parse_apply_confirmation("Y")); - assert!(parse_apply_confirmation("yes")); - assert!(parse_apply_confirmation(" YES ")); + assert!(validation::parse_apply_confirmation("y")); + assert!(validation::parse_apply_confirmation("Y")); + assert!(validation::parse_apply_confirmation("yes")); + assert!(validation::parse_apply_confirmation(" YES ")); } #[test] fn parse_apply_confirmation_rejects_non_yes_values() { - assert!(!parse_apply_confirmation("")); - assert!(!parse_apply_confirmation("n")); - assert!(!parse_apply_confirmation("no")); - assert!(!parse_apply_confirmation("anything")); + assert!(!validation::parse_apply_confirmation("")); + assert!(!validation::parse_apply_confirmation("n")); + assert!(!validation::parse_apply_confirmation("no")); + assert!(!validation::parse_apply_confirmation("anything")); } #[test] @@ -1660,7 +461,8 @@ mod tests { existing: Some(FanoutMode::HiddenRef), requested: None, }; - let err = enforce_apply_mode_requirements(false, false, plan).expect_err("validate"); + let err = + validation::enforce_apply_mode_requirements(false, false, plan).expect_err("validate"); assert!(err.to_string().contains("requires explicit approval")); } @@ -1670,51 +472,85 @@ mod tests { existing: None, requested: None, }; - let err = enforce_apply_mode_requirements(false, true, plan).expect_err("validate"); + let err = + validation::enforce_apply_mode_requirements(false, true, plan).expect_err("validate"); assert!(err.to_string().contains("fanout mode is not configured")); } #[test] fn parse_fanout_choice_accepts_hidden_ref_aliases() { - assert_eq!(parse_fanout_choice("1"), Some(FanoutMode::HiddenRef)); assert_eq!( - parse_fanout_choice("hidden_ref"), + planning::parse_fanout_choice("1"), + Some(FanoutMode::HiddenRef) + ); + assert_eq!( + planning::parse_fanout_choice("hidden_ref"), + Some(FanoutMode::HiddenRef) + ); + assert_eq!( + planning::parse_fanout_choice("hidden"), Some(FanoutMode::HiddenRef) ); - assert_eq!(parse_fanout_choice("hidden"), Some(FanoutMode::HiddenRef)); } #[test] fn parse_fanout_choice_accepts_git_notes_aliases() { - assert_eq!(parse_fanout_choice("2"), Some(FanoutMode::GitNotes)); - assert_eq!(parse_fanout_choice("git_notes"), Some(FanoutMode::GitNotes)); - assert_eq!(parse_fanout_choice("notes"), Some(FanoutMode::GitNotes)); + assert_eq!( + planning::parse_fanout_choice("2"), + Some(FanoutMode::GitNotes) + ); + assert_eq!( + planning::parse_fanout_choice("git_notes"), + Some(FanoutMode::GitNotes) + ); + assert_eq!( + planning::parse_fanout_choice("notes"), + Some(FanoutMode::GitNotes) + ); } #[test] fn parse_fanout_choice_rejects_unknown_values() { - assert_eq!(parse_fanout_choice(""), None); - assert_eq!(parse_fanout_choice("unknown"), None); + assert_eq!(planning::parse_fanout_choice(""), None); + assert_eq!(planning::parse_fanout_choice("unknown"), None); } #[test] fn parse_open_target_choice_accepts_aliases() { - assert_eq!(parse_open_target_choice("1"), Some(OpenTarget::App)); - assert_eq!(parse_open_target_choice("app"), Some(OpenTarget::App)); - assert_eq!(parse_open_target_choice("desktop"), Some(OpenTarget::App)); - assert_eq!(parse_open_target_choice("2"), Some(OpenTarget::Web)); - assert_eq!(parse_open_target_choice("web"), Some(OpenTarget::Web)); - assert_eq!(parse_open_target_choice("browser"), Some(OpenTarget::Web)); + assert_eq!( + planning::parse_open_target_choice("1"), + Some(OpenTarget::App) + ); + assert_eq!( + planning::parse_open_target_choice("app"), + Some(OpenTarget::App) + ); + assert_eq!( + planning::parse_open_target_choice("desktop"), + Some(OpenTarget::App) + ); + assert_eq!( + planning::parse_open_target_choice("2"), + Some(OpenTarget::Web) + ); + assert_eq!( + planning::parse_open_target_choice("web"), + Some(OpenTarget::Web) + ); + assert_eq!( + planning::parse_open_target_choice("browser"), + Some(OpenTarget::Web) + ); } #[test] fn default_open_target_depends_on_setup_profile() { assert_eq!( - default_open_target_for_profile(SetupProfile::Local), + planning::default_open_target_for_profile(SetupProfile::Local), OpenTarget::Web ); assert_eq!( - default_open_target_for_profile(SetupProfile::App), + planning::default_open_target_for_profile(SetupProfile::App), OpenTarget::App ); } @@ -1746,17 +582,17 @@ mod tests { String::from_utf8_lossy(&init.stderr) ); - assert_eq!(read_fanout_mode(&repo).expect("read"), None); + assert_eq!(planning::read_fanout_mode(&repo).expect("read"), None); - write_fanout_mode(&repo, FanoutMode::GitNotes).expect("write"); + planning::write_fanout_mode(&repo, FanoutMode::GitNotes).expect("write"); assert_eq!( - read_fanout_mode(&repo).expect("read"), + planning::read_fanout_mode(&repo).expect("read"), Some(FanoutMode::GitNotes) ); - write_fanout_mode(&repo, FanoutMode::HiddenRef).expect("write"); + planning::write_fanout_mode(&repo, FanoutMode::HiddenRef).expect("write"); assert_eq!( - read_fanout_mode(&repo).expect("read"), + planning::read_fanout_mode(&repo).expect("read"), Some(FanoutMode::HiddenRef) ); } diff --git a/crates/cli/src/setup_cmd/branch_sync.rs b/crates/cli/src/setup_cmd/branch_sync.rs new file mode 100644 index 00000000..dbef16fd --- /dev/null +++ b/crates/cli/src/setup_cmd/branch_sync.rs @@ -0,0 +1,319 @@ +use super::{COMMIT_HINT_GRACE_SECONDS, SYNC_BRANCH_COMMITS_MAX, SYNC_MAX_CANDIDATES}; +use anyhow::{Context, Result}; +use opensession_core::Session; +use opensession_core::sanitize::{SanitizeConfig, sanitize_session}; +use opensession_core::session::{ + GitMeta, build_git_storage_meta_json_with_git, is_auxiliary_session, working_directory, +}; +use opensession_git_native::{NativeGitStorage, extract_git_context}; +use opensession_parser_discovery::discover_sessions; +use opensession_parsers::ParserRegistry; +use opensession_runtime_config::DaemonConfig; +use std::cmp::Reverse; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone)] +struct SessionCandidate { + path: PathBuf, + modified: std::time::SystemTime, +} + +fn collect_recent_candidates() -> Vec { + let mut candidates = Vec::new(); + for location in discover_sessions() { + for path in location.paths { + let Ok(metadata) = std::fs::metadata(&path) else { + continue; + }; + let Ok(modified) = metadata.modified() else { + continue; + }; + candidates.push(SessionCandidate { path, modified }); + } + } + + candidates.sort_by_key(|candidate| Reverse(candidate.modified)); + candidates.into_iter().take(SYNC_MAX_CANDIDATES).collect() +} + +fn same_repo_root(left: &Path, right: &Path) -> bool { + let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + left == right +} + +fn parse_session_candidate(path: &Path) -> Option { + match ParserRegistry::default().parse_path(path) { + Ok(Some(session)) => { + if working_directory(&session).is_some() { + Some(session) + } else { + std::fs::read_to_string(path) + .ok() + .and_then(|content| Session::from_jsonl(&content).ok()) + } + } + Ok(None) | Err(_) => std::fs::read_to_string(path) + .ok() + .and_then(|content| Session::from_jsonl(&content).ok()), + } +} + +fn normalize_commit_hint(commit_hint: Option) -> Option { + commit_hint + .and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + .filter(|sha| sha != "0000000000000000000000000000000000000000") +} + +fn list_branch_commits(repo_root: &Path, branch: &str, max_count: usize) -> HashSet { + let rev = format!("refs/heads/{branch}"); + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("rev-list") + .arg("--max-count") + .arg(max_count.to_string()) + .arg(rev) + .output(); + let Ok(output) = output else { + return HashSet::new(); + }; + if !output.status.success() { + return HashSet::new(); + } + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn commit_time_unix(repo_root: &Path, commit: &str) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("show") + .arg("-s") + .arg("--format=%ct") + .arg(commit) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + raw.parse::().ok() +} + +fn commit_shas_from_reflog(repo_root: &Path, start_ts: i64, end_ts: i64) -> Vec { + let git_dir_output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("rev-parse") + .arg("--git-dir") + .output(); + let Ok(git_dir_output) = git_dir_output else { + return Vec::new(); + }; + if !git_dir_output.status.success() { + return Vec::new(); + } + let git_dir = String::from_utf8_lossy(&git_dir_output.stdout) + .trim() + .to_string(); + if git_dir.is_empty() { + return Vec::new(); + } + + let git_dir_path = if Path::new(&git_dir).is_absolute() { + PathBuf::from(git_dir) + } else { + repo_root.join(git_dir) + }; + let reflog_path = git_dir_path.join("logs").join("HEAD"); + let raw = std::fs::read_to_string(reflog_path); + let Ok(raw) = raw else { + return Vec::new(); + }; + + let mut seen = HashSet::new(); + let mut commits = Vec::new(); + for line in raw.lines() { + let Some((left, _)) = line.split_once('\t') else { + continue; + }; + let mut parts = left.split_whitespace(); + let _old = parts.next(); + let Some(new_sha) = parts.next() else { + continue; + }; + if new_sha.len() < 7 || !new_sha.chars().all(|ch| ch.is_ascii_hexdigit()) { + continue; + } + let mut tail = left.split_whitespace().rev(); + let _tz = tail.next(); + let Some(ts_raw) = tail.next() else { + continue; + }; + let Ok(ts) = ts_raw.parse::() else { + continue; + }; + if ts < start_ts || ts > end_ts { + continue; + } + if seen.insert(new_sha.to_string()) { + commits.push(new_sha.to_string()); + } + } + commits +} + +fn session_commit_links( + repo_root: &Path, + branch_commits: &HashSet, + session: &Session, + commit_hint: Option<&str>, +) -> Vec { + let created = session.context.created_at.timestamp(); + let updated = session.context.updated_at.timestamp(); + let (start, end) = if created <= updated { + (created, updated) + } else { + (updated, created) + }; + let mut commits = commit_shas_from_reflog(repo_root, start, end) + .into_iter() + .filter(|sha| branch_commits.contains(sha)) + .collect::>(); + + if let Some(hint) = commit_hint { + if branch_commits.contains(hint) && !commits.iter().any(|sha| sha == hint) { + if let Some(hint_ts) = commit_time_unix(repo_root, hint) { + let window_start = start.saturating_sub(COMMIT_HINT_GRACE_SECONDS); + let window_end = end.saturating_add(COMMIT_HINT_GRACE_SECONDS); + if hint_ts >= window_start && hint_ts <= window_end { + commits.push(hint.to_string()); + } + } + } + } + + commits +} + +fn load_daemon_config() -> DaemonConfig { + let Ok(path) = opensession_paths::runtime_config_path() else { + return DaemonConfig::default(); + }; + let Ok(content) = std::fs::read_to_string(path) else { + return DaemonConfig::default(); + }; + toml::from_str(&content).unwrap_or_default() +} + +pub(super) fn sync_branch_session_to_hidden_ledger( + repo_root: &Path, + branch: &str, + commit_hint: Option, +) -> Result<()> { + let candidates = collect_recent_candidates(); + if candidates.is_empty() { + return Ok(()); + } + + let config = load_daemon_config(); + let branch_commits = list_branch_commits(repo_root, branch, SYNC_BRANCH_COMMITS_MAX); + if branch_commits.is_empty() { + return Ok(()); + } + let commit_hint = normalize_commit_hint(commit_hint); + let mut synced_any = false; + let mut seen_sessions = HashSet::new(); + + for candidate in candidates { + let Some(mut session) = parse_session_candidate(&candidate.path) else { + continue; + }; + let Some(cwd) = working_directory(&session).map(str::to_owned) else { + continue; + }; + let Some(session_repo) = opensession_git_native::ops::find_repo_root(Path::new(&cwd)) + else { + continue; + }; + if !same_repo_root(repo_root, &session_repo) { + continue; + } + if is_auxiliary_session(&session) { + continue; + } + + if config + .privacy + .exclude_tools + .iter() + .any(|tool| tool.eq_ignore_ascii_case(&session.agent.tool)) + { + continue; + } + if !seen_sessions.insert(session.session_id.clone()) { + continue; + } + + let commit_shas = + session_commit_links(repo_root, &branch_commits, &session, commit_hint.as_deref()); + if commit_shas.is_empty() { + continue; + } + + sanitize_session( + &mut session, + &SanitizeConfig { + strip_paths: config.privacy.strip_paths, + strip_env_vars: config.privacy.strip_env_vars, + exclude_patterns: config.privacy.exclude_patterns.clone(), + }, + ); + + let git_ctx = extract_git_context(&cwd); + let meta = build_git_storage_meta_json_with_git( + &session, + Some(&GitMeta { + remote: git_ctx.remote.clone(), + repo_name: git_ctx.repo_name.clone(), + branch: Some(branch.to_string()), + head: commit_hint + .clone() + .or_else(|| commit_shas.last().cloned()) + .or(git_ctx.commit.clone()), + commits: commit_shas.clone(), + }), + ); + let hail = session + .to_jsonl() + .context("serialize session to canonical HAIL JSONL")?; + + NativeGitStorage.store_session_at_ref( + repo_root, + &opensession_git_native::branch_ledger_ref(branch), + &session.session_id, + hail.as_bytes(), + &meta, + &commit_shas, + )?; + synced_any = true; + } + + let _ = synced_any; + Ok(()) +} diff --git a/crates/cli/src/setup_cmd/doctor.rs b/crates/cli/src/setup_cmd/doctor.rs new file mode 100644 index 00000000..a6e2231b --- /dev/null +++ b/crates/cli/src/setup_cmd/doctor.rs @@ -0,0 +1,84 @@ +use crate::open_target::OpenTarget; +use std::io::{self, IsTerminal}; + +use super::{FanoutMode, SetupProfile}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum DoctorLevel { + Ok, + Info, + Warn, + Fail, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(super) struct DoctorSummary { + ok: usize, + info: usize, + warn: usize, + fail: usize, +} + +impl DoctorSummary { + pub(super) fn record(&mut self, level: DoctorLevel) { + match level { + DoctorLevel::Ok => self.ok += 1, + DoctorLevel::Info => self.info += 1, + DoctorLevel::Warn => self.warn += 1, + DoctorLevel::Fail => self.fail += 1, + } + } + + pub(super) fn issue_categories(&self) -> usize { + self.warn + self.fail + } +} + +pub(super) fn suggested_doctor_command( + mode: FanoutMode, + target: OpenTarget, + profile: SetupProfile, +) -> String { + format!( + "opensession doctor --fix --yes --profile {} --fanout-mode {} --open-target {}", + profile.as_str(), + mode.as_str(), + target.as_str(), + ) +} + +pub(super) fn doctor_colors_enabled() -> bool { + io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +pub(super) fn doctor_tag(level: DoctorLevel, colors: bool) -> String { + let plain = match level { + DoctorLevel::Ok => "[ OK ]", + DoctorLevel::Info => "[INFO]", + DoctorLevel::Warn => "[WARN]", + DoctorLevel::Fail => "[FAIL]", + }; + if !colors { + return plain.to_string(); + } + let code = match level { + DoctorLevel::Ok => "32", + DoctorLevel::Info => "36", + DoctorLevel::Warn => "33", + DoctorLevel::Fail => "31", + }; + format!("\x1b[1;{code}m{plain}\x1b[0m") +} + +pub(super) fn print_doctor_item(colors: bool, level: DoctorLevel, label: &str, detail: &str) { + println!( + "{} {:<18} {}", + doctor_tag(level, colors), + format!("{label}:"), + detail + ); +} + +pub(super) fn print_doctor_hint(detail: &str) { + println!(" hint: {detail}"); +} diff --git a/crates/cli/src/setup_cmd/planning.rs b/crates/cli/src/setup_cmd/planning.rs new file mode 100644 index 00000000..4e65a9c3 --- /dev/null +++ b/crates/cli/src/setup_cmd/planning.rs @@ -0,0 +1,270 @@ +use super::doctor; +use super::shims::{ShimInstallPlan, ShimPaths, shim_action_label}; +use super::{FANOUT_MODE_GIT_CONFIG_KEY, FanoutMode, OpenTargetInstallPlan, SetupProfile}; +use crate::hooks::{HookInstallAction, HookInstallPlan, HookInstallReport}; +use crate::open_target::{OpenTarget, read_repo_open_target, write_repo_open_target}; +use anyhow::{Context, Result, bail}; +use std::io::{self, IsTerminal, Write}; +use std::path::Path; +use std::process::Command; + +pub(super) fn print_setup_plan( + repo_root: &Path, + fanout_plan: super::FanoutInstallPlan, + open_target_plan: OpenTargetInstallPlan, + profile: SetupProfile, + hook_plans: &[HookInstallPlan], + shim_plans: &[ShimInstallPlan], + yes: bool, +) { + println!("repo: {}", repo_root.display()); + println!("setup plan:"); + println!(" - profile: {}", profile.as_str()); + println!(" - fanout mode: {}", fanout_plan.summary()); + println!(" - open target: {}", open_target_plan.summary(profile)); + for plan in hook_plans { + println!( + " - hook {}: {} ({})", + plan.hook_type.filename(), + hook_action_summary(plan.action), + plan.hook_path.display() + ); + if let Some(backup_path) = &plan.backup_path { + println!(" original hook saved as: {}", backup_path.display()); + println!( + " restore: mv '{}' '{}'", + backup_path.display(), + plan.hook_path.display() + ); + } + } + for plan in shim_plans { + println!( + " - shim {}: {} ({})", + plan.name, + shim_action_label(plan.action), + plan.path.display() + ); + } + if yes { + println!(" - confirmation: skipped (--yes)"); + } else { + println!(" - confirmation: required (use --yes to skip)"); + } +} + +pub(super) fn print_applied_setup( + repo_root: &Path, + fanout_mode: FanoutMode, + open_target: OpenTarget, + hook_reports: &[HookInstallReport], + shim_plans: &[ShimInstallPlan], + shim_paths: &ShimPaths, +) { + println!("Applied setup in {}:", repo_root.display()); + println!(" - fanout mode: {}", fanout_mode.as_str()); + println!(" - open target: {}", open_target.as_str()); + for report in hook_reports { + println!( + " - hook {}: {} ({})", + report.hook_type.filename(), + hook_action_summary(report.action), + report.hook_path.display() + ); + if report.backup_created { + if let Some(backup_path) = &report.backup_path { + println!(" original hook saved as: {}", backup_path.display()); + println!( + " restore: mv '{}' '{}'", + backup_path.display(), + report.hook_path.display() + ); + } + } + } + + for plan in shim_plans { + let path = match plan.name { + "opensession" => &shim_paths.opensession, + _ => &shim_paths.ops, + }; + println!( + " - shim {}: {} ({})", + plan.name, + shim_action_label(plan.action), + path.display() + ); + } +} + +fn hook_action_summary(action: HookInstallAction) -> &'static str { + match action { + HookInstallAction::InstallNew => "install", + HookInstallAction::ReplaceManaged => "refresh", + HookInstallAction::BackupAndReplace => "preserve-original+replace", + } +} + +pub(super) fn ensure_fanout_mode( + repo_root: &Path, + requested: Option, + interactive: bool, +) -> Result { + if let Some(mode) = requested { + write_fanout_mode(repo_root, mode)?; + println!("fanout mode set: {}", mode.as_str()); + return Ok(mode); + } + if let Some(mode) = read_fanout_mode(repo_root)? { + return Ok(mode); + } + + if !interactive { + bail!( + "fanout mode is not configured for this repository.\nnext: run `{}`", + doctor::suggested_doctor_command( + FanoutMode::HiddenRef, + OpenTarget::Web, + SetupProfile::Local + ) + ); + } + + let mode = prompt_fanout_mode()?; + write_fanout_mode(repo_root, mode)?; + println!("fanout mode initialized: {}", mode.as_str()); + Ok(mode) +} + +pub(super) fn ensure_open_target( + repo_root: &Path, + requested: Option, + interactive: bool, + profile: SetupProfile, +) -> Result { + if let Some(target) = requested { + write_repo_open_target(repo_root, target)?; + println!("open target set: {}", target.as_str()); + return Ok(target); + } + if let Some(target) = read_repo_open_target(repo_root)? { + return Ok(target); + } + + let default_target = default_open_target_for_profile(profile); + let target = if interactive { + let selected = prompt_open_target(default_target, profile)?; + println!("open target initialized: {}", selected.as_str()); + selected + } else { + println!( + "open target defaulted: {} (non-interactive)", + default_target.as_str() + ); + default_target + }; + write_repo_open_target(repo_root, target)?; + Ok(target) +} + +pub(super) fn default_open_target_for_profile(profile: SetupProfile) -> OpenTarget { + match profile { + SetupProfile::Local => OpenTarget::Web, + SetupProfile::App => OpenTarget::App, + } +} + +pub(super) fn read_fanout_mode(repo_root: &Path) -> Result> { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("config") + .arg("--local") + .arg("--get") + .arg(FANOUT_MODE_GIT_CONFIG_KEY) + .output() + .context("read git fanout mode")?; + + if !output.status.success() { + return Ok(None); + } + + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if raw.is_empty() { + return Ok(None); + } + + Ok(Some( + FanoutMode::parse(&raw).unwrap_or(FanoutMode::HiddenRef), + )) +} + +pub(super) fn write_fanout_mode(repo_root: &Path, mode: FanoutMode) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("config") + .arg("--local") + .arg(FANOUT_MODE_GIT_CONFIG_KEY) + .arg(mode.as_str()) + .output() + .context("write git fanout mode")?; + if !output.status.success() { + bail!( + "failed to store fanout mode in git config: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +fn prompt_fanout_mode() -> Result { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + bail!( + "fanout mode prompt requires an interactive terminal.\nnext: run `{}`", + doctor::suggested_doctor_command( + FanoutMode::HiddenRef, + OpenTarget::Web, + SetupProfile::Local + ) + ); + } + + println!("Choose OpenSession fanout mode for this repository:"); + println!(" 1) hidden refs (default)"); + println!(" 2) git notes"); + print!("select [1/2]: "); + io::stdout().flush().context("flush stdout")?; + + let mut line = String::new(); + io::stdin().read_line(&mut line).context("read selection")?; + Ok(parse_fanout_choice(&line).unwrap_or(FanoutMode::HiddenRef)) +} + +pub(super) fn parse_fanout_choice(input: &str) -> Option { + FanoutMode::parse(input) +} + +fn prompt_open_target(default_target: OpenTarget, profile: SetupProfile) -> Result { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + bail!( + "open target prompt requires an interactive terminal.\nnext: run `{}`", + doctor::suggested_doctor_command(FanoutMode::HiddenRef, default_target, profile) + ); + } + + println!("Choose OpenSession review opener for this repository:"); + println!(" 1) app"); + println!(" 2) web"); + println!(" default: {}", default_target.as_str()); + print!("select [1/2]: "); + io::stdout().flush().context("flush stdout")?; + + let mut line = String::new(); + io::stdin().read_line(&mut line).context("read selection")?; + Ok(parse_open_target_choice(&line).unwrap_or(default_target)) +} + +pub(super) fn parse_open_target_choice(input: &str) -> Option { + OpenTarget::parse(input) +} diff --git a/crates/cli/src/setup_cmd/shims.rs b/crates/cli/src/setup_cmd/shims.rs new file mode 100644 index 00000000..a55e9229 --- /dev/null +++ b/crates/cli/src/setup_cmd/shims.rs @@ -0,0 +1,101 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ShimInstallAction { + InstallNew, + ReplaceExisting, + KeepExisting, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ShimInstallPlan { + pub(super) name: &'static str, + pub(super) path: PathBuf, + pub(super) action: ShimInstallAction, +} + +#[derive(Debug, Clone)] +pub(super) struct ShimPaths { + pub(super) opensession: PathBuf, + pub(super) ops: PathBuf, +} + +pub(super) fn plan_cli_shims() -> Result> { + let exe = std::env::current_exe().context("resolve current opensession executable path")?; + let mut plans = Vec::new(); + for name in ["opensession", "ops"] { + plans.push(plan_cli_shim(name, &exe)?); + } + Ok(plans) +} + +fn plan_cli_shim(name: &'static str, exe: &Path) -> Result { + let path = shim_path(name)?; + let action = if !path.exists() { + ShimInstallAction::InstallNew + } else if std::fs::canonicalize(&path).ok() == std::fs::canonicalize(exe).ok() { + ShimInstallAction::KeepExisting + } else { + ShimInstallAction::ReplaceExisting + }; + Ok(ShimInstallPlan { name, path, action }) +} + +pub(super) fn shim_action_label(action: ShimInstallAction) -> &'static str { + match action { + ShimInstallAction::InstallNew => "install", + ShimInstallAction::ReplaceExisting => "replace", + ShimInstallAction::KeepExisting => "keep", + } +} + +pub(super) fn shim_path(name: &str) -> Result { + Ok(opensession_paths::data_dir() + .context("Could not determine shim base directory")? + .join("bin") + .join(name)) +} + +pub(super) fn install_cli_shims() -> Result { + let exe = std::env::current_exe().context("resolve current opensession executable path")?; + let opensession = install_cli_shim("opensession", &exe)?; + let ops = install_cli_shim("ops", &exe)?; + Ok(ShimPaths { opensession, ops }) +} + +fn install_cli_shim(name: &str, exe: &Path) -> Result { + let shim = shim_path(name)?; + if let Some(parent) = shim.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create shim directory {}", parent.display()))?; + } + + let existing_matches = if shim.exists() { + std::fs::canonicalize(&shim).ok() == std::fs::canonicalize(exe).ok() + } else { + false + }; + if existing_matches { + return Ok(shim); + } + + if shim.exists() { + std::fs::remove_file(&shim) + .with_context(|| format!("remove existing shim {}", shim.display()))?; + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(exe, &shim) + .with_context(|| format!("create shim symlink {}", shim.display()))?; + } + + #[cfg(not(unix))] + { + std::fs::copy(exe, &shim) + .with_context(|| format!("create shim copy {}", shim.display()))?; + } + + Ok(shim) +} diff --git a/crates/cli/src/setup_cmd/status.rs b/crates/cli/src/setup_cmd/status.rs new file mode 100644 index 00000000..cd4a597c --- /dev/null +++ b/crates/cli/src/setup_cmd/status.rs @@ -0,0 +1,417 @@ +use super::FanoutMode; +use super::doctor::{self, DoctorLevel, DoctorSummary}; +use super::planning::read_fanout_mode; +use super::shims::shim_path; +use crate::cleanup_cmd::{self, CleanupDoctorLevel}; +use crate::hooks::{HookType, list_installed_hooks}; +use anyhow::{Context, Result, bail}; +use opensession_git_native::{branch_ledger_ref, extract_git_context, resolve_ledger_branch}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub(super) fn run_check(repo_root: &Path) -> Result<()> { + let colors = doctor::doctor_colors_enabled(); + let mut summary = DoctorSummary::default(); + let installed = list_installed_hooks(repo_root); + let fanout_mode = read_fanout_mode(repo_root)?.unwrap_or(FanoutMode::HiddenRef); + let branch = current_branch(repo_root)?; + let ledger_branch = ledger_branch_name(repo_root); + let ledger = branch_ledger_ref(&ledger_branch); + + println!("repo: {}", repo_root.display()); + println!("doctor checks:"); + + let hooks_summary = if installed.is_empty() { + "none".to_string() + } else { + installed + .iter() + .map(HookType::filename) + .collect::>() + .join(", ") + }; + let hook_level = if installed.is_empty() { + DoctorLevel::Warn + } else { + DoctorLevel::Ok + }; + doctor::print_doctor_item(colors, hook_level, "opensession hooks", &hooks_summary); + summary.record(hook_level); + + let mut required_actions = Vec::new(); + let mut optional_actions = Vec::new(); + + match shim_path("opensession") { + Ok(path) => { + let present = path.exists(); + let level = if present { + DoctorLevel::Ok + } else { + DoctorLevel::Warn + }; + doctor::print_doctor_item( + colors, + level, + "opensession shim", + &format!( + "{} ({})", + path.display(), + if present { "present" } else { "missing" } + ), + ); + summary.record(level); + if !present { + required_actions.push( + "run `opensession doctor --fix` to install hooks/shims for this repo" + .to_string(), + ); + } + } + Err(err) => { + doctor::print_doctor_item( + colors, + DoctorLevel::Fail, + "opensession shim", + &format!("unavailable ({err})"), + ); + summary.record(DoctorLevel::Fail); + } + } + + match shim_path("ops") { + Ok(path) => { + let present = path.exists(); + let level = if present { + DoctorLevel::Ok + } else { + DoctorLevel::Info + }; + doctor::print_doctor_item( + colors, + level, + "ops shim", + &format!( + "{} ({})", + path.display(), + if present { "present" } else { "missing" } + ), + ); + summary.record(level); + if !present { + optional_actions.push( + "optional: install `ops` shim via `opensession doctor --fix` for alias UX" + .to_string(), + ); + } + } + Err(err) => { + doctor::print_doctor_item( + colors, + DoctorLevel::Fail, + "ops shim", + &format!("unavailable ({err})"), + ); + summary.record(DoctorLevel::Fail); + } + } + + if let Ok(exe) = std::env::current_exe() { + doctor::print_doctor_item( + colors, + DoctorLevel::Ok, + "active binary", + &exe.display().to_string(), + ); + summary.record(DoctorLevel::Ok); + } + + doctor::print_doctor_item(colors, DoctorLevel::Ok, "fanout mode", fanout_mode.as_str()); + summary.record(DoctorLevel::Ok); + + let daemon_pid = daemon_pid_path()?; + let daemon = daemon_status(&daemon_pid); + let (daemon_level, daemon_summary, daemon_hint) = daemon_status_summary(&daemon, &daemon_pid); + doctor::print_doctor_item(colors, daemon_level, "daemon", &daemon_summary); + summary.record(daemon_level); + if let Some(hint) = daemon_hint { + doctor::print_doctor_hint(&hint); + if daemon_level == DoctorLevel::Info { + optional_actions.push(hint); + } else { + required_actions.push(hint); + } + } + + let readiness = review_readiness(repo_root); + let (readiness_level, readiness_summary, readiness_hint) = + review_readiness_summary(readiness.hidden_fanout_ready, readiness.remote_hidden_refs); + doctor::print_doctor_item( + colors, + readiness_level, + "review readiness", + &readiness_summary, + ); + summary.record(readiness_level); + if let Some(hint) = readiness_hint { + doctor::print_doctor_hint(&hint); + if readiness_level == DoctorLevel::Info { + optional_actions.push(hint); + } else { + required_actions.push(hint); + } + } + + let cleanup = cleanup_cmd::doctor_status(repo_root); + let cleanup_level = match cleanup.level { + CleanupDoctorLevel::Ok => DoctorLevel::Ok, + CleanupDoctorLevel::Warn => DoctorLevel::Warn, + }; + doctor::print_doctor_item(colors, cleanup_level, "cleanup", &cleanup.detail); + summary.record(cleanup_level); + if let Some(hint) = cleanup.hint { + doctor::print_doctor_hint(&hint); + required_actions.push(hint); + } + + doctor::print_doctor_item(colors, DoctorLevel::Ok, "current branch", &branch); + summary.record(DoctorLevel::Ok); + if branch != ledger_branch { + doctor::print_doctor_item(colors, DoctorLevel::Info, "ledger branch", &ledger_branch); + summary.record(DoctorLevel::Info); + optional_actions.push( + "optional: branch/ledger mismatch is expected on detached HEAD; verify before sharing" + .to_string(), + ); + } + doctor::print_doctor_item(colors, DoctorLevel::Ok, "expected ledger ref", &ledger); + summary.record(DoctorLevel::Ok); + + if !required_actions.is_empty() { + let mut dedup = HashSet::new(); + println!("next actions (recommended):"); + for suggestion in required_actions { + if dedup.insert(suggestion.clone()) { + println!(" - {suggestion}"); + } + } + } + if !optional_actions.is_empty() { + let mut dedup = HashSet::new(); + println!("next actions (optional):"); + for suggestion in optional_actions { + if dedup.insert(suggestion.clone()) { + println!(" - {suggestion}"); + } + } + } + + if summary.issue_categories() == 0 { + println!("doctor summary: no blocking issues."); + } else { + println!( + "doctor summary: found issues in {} categories.", + summary.issue_categories() + ); + } + + Ok(()) +} + +pub(super) fn print_daemon_status() -> Result<()> { + let pid_path = daemon_pid_path()?; + let status = daemon_status(&pid_path); + let (_, summary, hint) = daemon_status_summary(&status, &pid_path); + println!("daemon: {summary}"); + if let Some(hint) = hint { + println!("daemon hint: {hint}"); + } + Ok(()) +} + +pub(super) fn daemon_status_summary( + status: &DaemonStatus, + pid_path: &Path, +) -> (DoctorLevel, String, Option) { + match status { + DaemonStatus::Running(pid) => (DoctorLevel::Ok, format!("running (pid {pid})"), None), + DaemonStatus::NotRunning => ( + DoctorLevel::Info, + format!("not running (pid file missing: {})", pid_path.display()), + Some( + "optional: start daemon for auto-capture with `opensession-daemon` (or `cargo run -p opensession-daemon -- run` in a source checkout)" + .to_string(), + ), + ), + DaemonStatus::StalePid(pid) => ( + DoctorLevel::Info, + format!( + "not running (stale pid file: {} -> pid {pid})", + pid_path.display() + ), + Some( + "optional: restart daemon for auto-capture with `opensession-daemon` (or `cargo run -p opensession-daemon -- run` in a source checkout)" + .to_string(), + ), + ), + DaemonStatus::Unreadable(err) => ( + DoctorLevel::Fail, + format!("status unavailable ({err})"), + None, + ), + } +} + +fn daemon_pid_path() -> Result { + Ok(opensession_paths::config_dir() + .context("Could not determine daemon pid path")? + .join("daemon.pid")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum DaemonStatus { + Running(u32), + NotRunning, + StalePid(u32), + Unreadable(String), +} + +pub(super) fn daemon_status(pid_path: &Path) -> DaemonStatus { + if !pid_path.exists() { + return DaemonStatus::NotRunning; + } + + let pid_raw = match std::fs::read_to_string(pid_path) { + Ok(raw) => raw, + Err(err) => return DaemonStatus::Unreadable(format!("read {}: {err}", pid_path.display())), + }; + let pid = match pid_raw.trim().parse::() { + Ok(pid) if pid > 0 => pid, + Ok(_) | Err(_) => { + return DaemonStatus::Unreadable(format!( + "invalid pid content in {}", + pid_path.display() + )); + } + }; + + if process_running(pid) { + DaemonStatus::Running(pid) + } else { + DaemonStatus::StalePid(pid) + } +} + +#[cfg(unix)] +fn process_running(pid: u32) -> bool { + // SAFETY: kill(pid, 0) does not deliver a signal. It is the standard Unix probe for + // process existence and permissions, and we pass the parsed PID value directly to libc. + let rc = unsafe { libc::kill(pid as i32, 0) }; + if rc == 0 { + return true; + } + matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) +} + +#[cfg(not(unix))] +fn process_running(_pid: u32) -> bool { + false +} + +pub(super) fn ledger_branch_name(repo_root: &Path) -> String { + let cwd = repo_root.to_string_lossy().to_string(); + let git_ctx = extract_git_context(&cwd); + resolve_ledger_branch(git_ctx.branch.as_deref(), git_ctx.commit.as_deref()) +} + +pub(super) fn current_branch(repo_root: &Path) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("rev-parse") + .arg("--abbrev-ref") + .arg("HEAD") + .output() + .context("resolve current git branch")?; + if !output.status.success() { + bail!( + "failed to read current branch: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[derive(Debug, Clone, Copy)] +struct ReviewReadiness { + hidden_fanout_ready: bool, + remote_hidden_refs: bool, +} + +fn review_readiness(repo_root: &Path) -> ReviewReadiness { + let hidden_fanout_ready = list_installed_hooks(repo_root).contains(&HookType::PrePush); + + let remote_hidden_refs = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("for-each-ref") + .arg("--count=1") + .arg("--format=%(refname)") + .arg("refs/remotes/*/opensession/branches") + .output() + .ok() + .filter(|out| out.status.success()) + .map(|out| !String::from_utf8_lossy(&out.stdout).trim().is_empty()) + .unwrap_or(false); + + ReviewReadiness { + hidden_fanout_ready, + remote_hidden_refs, + } +} + +pub(super) fn review_readiness_summary( + hidden_fanout_ready: bool, + remote_hidden_refs: bool, +) -> (DoctorLevel, String, Option) { + let summary = format!( + "hidden-fanout={} hidden-refs={}", + if hidden_fanout_ready { "ok" } else { "missing" }, + if remote_hidden_refs { + "present" + } else { + "none-fetched" + } + ); + + if hidden_fanout_ready && remote_hidden_refs { + return (DoctorLevel::Ok, summary, None); + } + + if !hidden_fanout_ready { + return ( + DoctorLevel::Warn, + summary, + Some("run `opensession doctor --fix` to install the pre-push fanout hook".to_string()), + ); + } + + ( + DoctorLevel::Info, + summary, + Some( + "optional: fetch hidden refs before local review: `git fetch origin 'refs/opensession/branches/*:refs/remotes/origin/opensession/branches/*'`".to_string(), + ), + ) +} + +pub(super) fn print_review_readiness(repo_root: &Path) -> Result<()> { + let readiness = review_readiness(repo_root); + let (_, summary, _) = + review_readiness_summary(readiness.hidden_fanout_ready, readiness.remote_hidden_refs); + println!("review readiness: {summary}"); + Ok(()) +} diff --git a/crates/cli/src/setup_cmd/validation.rs b/crates/cli/src/setup_cmd/validation.rs new file mode 100644 index 00000000..bc0daf3b --- /dev/null +++ b/crates/cli/src/setup_cmd/validation.rs @@ -0,0 +1,75 @@ +use super::doctor; +use super::{FanoutInstallPlan, FanoutMode, SetupArgs, SetupProfile}; +use crate::open_target::OpenTarget; +use anyhow::{Context, Result, bail}; +use std::io::{self, IsTerminal, Write}; + +pub(super) fn validate_setup_args(args: &SetupArgs) -> Result<()> { + if args.check && args.yes { + bail!("`--yes` cannot be used with `--check`"); + } + if args.check && args.fanout_mode.is_some() { + bail!( + "`--fanout-mode` requires apply mode. next: run `opensession doctor --fix --yes --profile local --fanout-mode hidden_ref`" + ); + } + if args.check && args.open_target.is_some() { + bail!( + "`--open-target` requires apply mode. next: run `opensession doctor --fix --yes --profile local --open-target web`" + ); + } + Ok(()) +} + +pub(super) fn enforce_apply_mode_requirements( + interactive: bool, + yes: bool, + fanout_plan: FanoutInstallPlan, +) -> Result<()> { + let suggested_mode = fanout_plan.suggested_mode(); + if !interactive && !yes { + bail!( + "setup requires explicit approval in non-interactive mode.\nnext: run `{}`", + doctor::suggested_doctor_command(suggested_mode, OpenTarget::Web, SetupProfile::Local) + ); + } + if !interactive && fanout_plan.existing.is_none() && fanout_plan.requested.is_none() { + bail!( + "fanout mode is not configured for this repository, and setup cannot prompt in non-interactive mode.\nnext: run `{}`", + doctor::suggested_doctor_command( + FanoutMode::HiddenRef, + OpenTarget::Web, + SetupProfile::Local + ) + ); + } + Ok(()) +} + +pub(super) fn is_interactive_terminal() -> bool { + io::stdin().is_terminal() && io::stdout().is_terminal() +} + +pub(super) fn prompt_apply_confirmation( + mode_hint: FanoutMode, + open_target_hint: OpenTarget, + profile: SetupProfile, +) -> Result<()> { + print!("Apply these changes? [y/N]: "); + io::stdout().flush().context("flush stdout")?; + let mut line = String::new(); + io::stdin() + .read_line(&mut line) + .context("read setup confirmation")?; + if parse_apply_confirmation(&line) { + return Ok(()); + } + bail!( + "setup cancelled by user.\nnext: run `{}`", + doctor::suggested_doctor_command(mode_hint, open_target_hint, profile) + ); +} + +pub(super) fn parse_apply_confirmation(input: &str) -> bool { + matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} diff --git a/crates/cli/src/share.rs b/crates/cli/src/share.rs index c85397c7..88fef8ea 100644 --- a/crates/cli/src/share.rs +++ b/crates/cli/src/share.rs @@ -1,10 +1,10 @@ use crate::cleanup_cmd; use crate::config_cmd::load_repo_config; use crate::user_guidance::guided_error; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use clap::Args; -use opensession_core::object_store::{find_repo_root, read_local_object_from_uri}; use opensession_core::source_uri::{SourceSpec, SourceUri}; +use opensession_local_store::{find_repo_root, read_local_object_from_uri}; use std::fs::OpenOptions; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; @@ -675,7 +675,15 @@ fn try_copy_to_clipboard(value: &str) -> Result<()> { } } - bail!("clipboard copy is unavailable on this platform") + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + bail!("clipboard copy is unavailable on this platform"); + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + bail!("clipboard copy is unavailable on this platform"); + } } fn try_clipboard_command(program: &str, args: &[&str], value: &str) -> Result { @@ -708,9 +716,9 @@ mod tests { #[cfg(target_os = "linux")] use super::linux_clipboard_candidates; use super::{ - classify_share_failure, detect_quick_remote, parse_remote_host_and_path, - read_quick_auto_push_consent, resolve_mode, uri_for_remote, validate_rel_path, - write_quick_auto_push_consent, ShareMode, QUICK_AUTO_PUSH_CONSENT_GIT_KEY, + QUICK_AUTO_PUSH_CONSENT_GIT_KEY, ShareMode, classify_share_failure, detect_quick_remote, + parse_remote_host_and_path, read_quick_auto_push_consent, resolve_mode, uri_for_remote, + validate_rel_path, write_quick_auto_push_consent, }; use opensession_core::source_uri::SourceSpec; use opensession_core::source_uri::SourceUri; diff --git a/crates/cli/src/stream_push.rs b/crates/cli/src/stream_push.rs index aeef762d..f54121eb 100644 --- a/crates/cli/src/stream_push.rs +++ b/crates/cli/src/stream_push.rs @@ -4,7 +4,7 @@ //! (< 2s). Parses the full session file and upserts it into the local DB. //! The daemon handles uploading to the server via debounced file watching. -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use opensession_core::session::{is_auxiliary_session, working_directory}; use opensession_git_native::extract_git_context; use opensession_local_db::LocalDb; @@ -24,12 +24,8 @@ struct StreamState { } fn state_dir() -> Result { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .context("Could not determine home directory")?; - Ok(PathBuf::from(home) - .join(".config") - .join("opensession") + Ok(opensession_paths::config_dir() + .context("Could not determine home directory")? .join("stream-state")) } @@ -74,16 +70,14 @@ fn resolve_session_file(agent: &str) -> Result { /// Claude Code stores sessions under `~/.claude/projects//`. /// The project directory name is the CWD with `/` replaced by `-`. fn resolve_claude_code_session() -> Result { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .context("Could not determine home directory")?; + let home = opensession_paths::home_dir().context("Could not determine home directory")?; let cwd = std::env::current_dir().context("Could not determine current directory")?; let cwd_str = cwd.to_string_lossy(); // Claude Code project dir: CWD with '/' replaced by '-' let project_dir_name = cwd_str.replace('/', "-"); - let projects_dir = PathBuf::from(&home).join(".claude").join("projects"); + let projects_dir = home.join(".claude").join("projects"); let project_dir = projects_dir.join(&project_dir_name); if !project_dir.is_dir() { @@ -140,14 +134,17 @@ pub fn run_stream_push(agent: &str) -> Result<()> { } // Parse the full file with the standard parser. - let session = opensession_parsers::parse_with_default_parsers(&session_file)? + let session = opensession_parsers::ParserRegistry::default() + .parse_path(&session_file)? .ok_or_else(|| anyhow::anyhow!("No parser for {}", session_file.display()))?; if is_auxiliary_session(&session) { return Ok(()); } // Extract git context from session's working directory - let git = working_directory(&session).map(extract_git_context).unwrap_or_default(); + let git = working_directory(&session) + .map(extract_git_context) + .unwrap_or_default(); let local_git = opensession_local_db::git::GitContext { remote: git.remote.clone(), branch: git.branch.clone(), @@ -177,10 +174,8 @@ pub fn enable_stream_write(agent: &str) -> Result<()> { } fn claude_settings_path() -> Result { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .context("Could not determine home directory")?; - Ok(PathBuf::from(home).join(".claude").join("settings.json")) + let home = opensession_paths::home_dir().context("Could not determine home directory")?; + Ok(home.join(".claude").join("settings.json")) } const HOOK_MATCHER: &str = "Edit|Write|Bash|NotebookEdit"; diff --git a/crates/cli/src/summary_cmd.rs b/crates/cli/src/summary_cmd.rs index 1c2b6744..bc470dc3 100644 --- a/crates/cli/src/summary_cmd.rs +++ b/crates/cli/src/summary_cmd.rs @@ -1,13 +1,14 @@ use crate::runtime_settings::load_runtime_config; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use clap::{Args, Subcommand}; use opensession_core::session::working_directory; use opensession_git_native::extract_git_context; use opensession_local_db::{LocalDb, SessionSemanticSummaryUpsert}; -use opensession_parsers::parse_with_default_parsers; -use opensession_summary::{ - summarize_git_commit, summarize_git_working_tree, summarize_session, GitSummaryRequest, - SemanticSummaryArtifact, +use opensession_local_store::find_repo_root; +use opensession_parsers::ParserRegistry; +use opensession_summary::{GitSummaryRequest, SemanticSummaryArtifact}; +use opensession_summary_runtime::{ + summarize_git_commit, summarize_git_working_tree, summarize_session, }; use serde::Serialize; use std::path::{Path, PathBuf}; @@ -109,13 +110,15 @@ fn run_show(session_id: &str) -> Result<()> { async fn run_generate(args: SummaryRunArgs) -> Result<()> { let runtime = load_runtime_config().context("load runtime config")?; let settings = &runtime.summary; + let parser_registry = ParserRegistry::default(); if let Some(file) = args.file.as_deref() { let artifact = run_from_file(file, settings).await?; println!("{}", serde_json::to_string_pretty(&artifact)?); if !args.no_store && settings.persists_to_local_db() { - let session = parse_with_default_parsers(file) + let session = parser_registry + .parse_path(file) .with_context(|| format!("parse session file {}", file.display()))? .ok_or_else(|| anyhow!("unsupported session source format"))?; let db = LocalDb::open().context("open local db")?; @@ -148,13 +151,14 @@ async fn run_from_file( path: &Path, settings: &opensession_runtime_config::SummarySettings, ) -> Result { - let session = parse_with_default_parsers(path) + let session = ParserRegistry::default() + .parse_path(path) .with_context(|| format!("parse session file {}", path.display()))? .ok_or_else(|| anyhow!("unsupported session source format"))?; let git_request = if settings.allows_git_changes_fallback() { working_directory(&session) - .and_then(|cwd| opensession_core::object_store::find_repo_root(Path::new(cwd))) + .and_then(|cwd| find_repo_root(Path::new(cwd))) .map(|repo_root| GitSummaryRequest { repo_root, commit: working_directory(&session).and_then(|cwd| extract_git_context(cwd).commit), diff --git a/crates/cli/src/templates/cleanup/github-session-review.yml.tmpl b/crates/cli/src/templates/cleanup/github-session-review.yml.tmpl index f5b2a8b5..21bd2488 100644 --- a/crates/cli/src/templates/cleanup/github-session-review.yml.tmpl +++ b/crates/cli/src/templates/cleanup/github-session-review.yml.tmpl @@ -3,7 +3,7 @@ name: OpenSession Session Review on: pull_request: - types: [opened, reopened, synchronize] + types: [opened, reopened, synchronize, closed] permissions: contents: write @@ -13,27 +13,54 @@ permissions: jobs: session_review: runs-on: ubuntu-latest - if: github.event.pull_request.head.repo.full_name == github.repository + if: > + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.type != 'Bot' steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Resolve artifact storage + id: storage + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import os + import pathlib + import tomllib + + archive_branch = "" + config_path = pathlib.Path(".opensession/cleanup/config.toml") + if config_path.exists(): + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + archive_branch = str(data.get("session_archive_branch") or "").strip() + + pr_number = os.environ["PR_NUMBER"] + artifact_branch = archive_branch or f"opensession/pr-{pr_number}-sessions" + print(f"artifact_branch={artifact_branch}") + print(f"persistent={'1' if archive_branch else '0'}") + PY + - name: Compute ledger ref and publish artifact branch id: artifact env: BRANCH: ${{ github.head_ref }} - PR_NUMBER: ${{ github.event.pull_request.number }} + ARTIFACT_BRANCH: ${{ steps.storage.outputs.artifact_branch }} + PERSISTENT_STORAGE: ${{ steps.storage.outputs.persistent }} run: | set -euo pipefail - encoded="$(python - <<'PY' -import base64, os -print(base64.urlsafe_b64encode(os.environ['BRANCH'].encode()).decode().rstrip('=')) -PY -)" + encoded="$(python3 - <<'PY' + import base64, os + print(base64.urlsafe_b64encode(os.environ['BRANCH'].encode()).decode().rstrip('=')) + PY + )" ledger_ref="refs/opensession/branches/${encoded}" - artifact_branch="opensession/pr-${PR_NUMBER}-sessions" + artifact_branch="$ARTIFACT_BRANCH" has_ledger=0 push_ok=0 @@ -48,6 +75,7 @@ PY echo "artifact_branch=${artifact_branch}" >> "$GITHUB_OUTPUT" echo "has_ledger=${has_ledger}" >> "$GITHUB_OUTPUT" echo "push_ok=${push_ok}" >> "$GITHUB_OUTPUT" + echo "persistent_storage=${PERSISTENT_STORAGE}" >> "$GITHUB_OUTPUT" - name: Upsert session review comment uses: actions/github-script@v7 @@ -56,6 +84,7 @@ PY ARTIFACT_BRANCH: ${{ steps.artifact.outputs.artifact_branch }} HAS_LEDGER: ${{ steps.artifact.outputs.has_ledger }} PUSH_OK: ${{ steps.artifact.outputs.push_ok }} + PERSISTENT_STORAGE: ${{ steps.artifact.outputs.persistent_storage }} with: script: | const marker = ''; @@ -65,22 +94,32 @@ PY const pushOk = process.env.PUSH_OK === '1'; const ledgerRef = process.env.LEDGER_REF; const artifactBranch = process.env.ARTIFACT_BRANCH; + const persistentStorage = process.env.PERSISTENT_STORAGE === '1'; const branchUrl = `https://github.com/${owner}/${repo}/tree/${artifactBranch}`; const lines = [ marker, '### OpenSession Session Artifact', - `- Ledger ref: \`${ledgerRef}\``, - `- Artifact branch: \`${artifactBranch}\``, - `- Artifact branch link: [open](${branchUrl})`, + '', + '| Metric | Value |', + '| --- | --- |', + `| Ledger ref | \`${ledgerRef}\` |`, + `| Artifact branch | [\`${artifactBranch}\`](${branchUrl}) |`, + `| Retention | ${persistentStorage ? 'persistent archive branch' : 'ephemeral branch (deleted on PR close)'} |`, ]; if (!hasLedger) { - lines.push('- Status: hidden ledger ref not found yet (no tracked sessions pushed).'); + lines.push('| Status | waiting for first tracked session push |'); + lines.push('| Next action | Push at least one tracked session, then rerun the session-review workflow. |'); } else if (!pushOk) { - lines.push('- Status: ledger found, but failed to push artifact branch (check workflow write permissions).'); + lines.push('| Status | ledger found, but artifact publish failed |'); + lines.push('| Next action | Check workflow write permissions for `contents: write`. |'); + } else if (persistentStorage) { + lines.push('| Status | persistent archive branch updated |'); + lines.push('| Next action | Open the archive branch to inspect stored review snapshots. |'); } else { - lines.push('- Status: artifact branch updated.'); + lines.push('| Status | ephemeral artifact branch updated |'); + lines.push('| Next action | Open the artifact branch while the PR is active; it will be deleted on PR close. |'); } const body = `${lines.join('\n')}\n`; @@ -108,3 +147,55 @@ PY } else { await github.rest.issues.createComment({ owner, repo, issue_number, body }); } + + cleanup: + runs-on: ubuntu-latest + if: > + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.type != 'Bot' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve artifact storage + id: storage + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import os + import pathlib + import tomllib + + archive_branch = "" + config_path = pathlib.Path(".opensession/cleanup/config.toml") + if config_path.exists(): + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + archive_branch = str(data.get("session_archive_branch") or "").strip() + + pr_number = os.environ["PR_NUMBER"] + artifact_branch = archive_branch or f"opensession/pr-{pr_number}-sessions" + print(f"artifact_branch={artifact_branch}") + print(f"persistent={'1' if archive_branch else '0'}") + PY + + - name: Delete ephemeral artifact branch + if: steps.storage.outputs.persistent != '1' + env: + ARTIFACT_BRANCH: ${{ steps.storage.outputs.artifact_branch }} + run: | + set -euo pipefail + ok=0 + for attempt in 1 2; do + if git push origin ":refs/heads/$ARTIFACT_BRANCH" >/dev/null 2>&1; then + ok=1 + break + fi + sleep 2 + done + if [ "$ok" -ne 1 ]; then + echo "::warning::Failed to delete artifact branch $ARTIFACT_BRANCH after retry" + fi diff --git a/crates/cli/src/templates/cleanup/gitlab-session-review.yml.tmpl b/crates/cli/src/templates/cleanup/gitlab-session-review.yml.tmpl index 4f4b69e6..c7d50024 100644 --- a/crates/cli/src/templates/cleanup/gitlab-session-review.yml.tmpl +++ b/crates/cli/src/templates/cleanup/gitlab-session-review.yml.tmpl @@ -7,13 +7,25 @@ opensession_session_review: script: - set -eu - | + archive_branch="$(python3 - <<'PY' + import pathlib + import tomllib + + config_path = pathlib.Path(".opensession/cleanup/config.toml") + if config_path.exists(): + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + print(str(data.get("session_archive_branch") or "").strip()) + else: + print("") + PY + )" encoded="$(python3 - <<'PY' import base64, os print(base64.urlsafe_b64encode(os.environ['CI_COMMIT_REF_NAME'].encode()).decode().rstrip('=')) PY )" ledger_ref="refs/opensession/branches/${encoded}" - artifact_branch="opensession/mr-${CI_MERGE_REQUEST_IID}-sessions" + artifact_branch="${archive_branch:-opensession/mr-${CI_MERGE_REQUEST_IID}-sessions}" has_ledger=0 push_ok=0 @@ -35,20 +47,25 @@ PY body=" ### OpenSession Session Artifact -- Ledger ref: \\`${ledger_ref}\\` -- Artifact branch: \\`${artifact_branch}\\` -- Artifact branch link: ${CI_PROJECT_URL}/-/tree/${artifact_branch} +| Metric | Value | +| --- | --- | +| Ledger ref | \\`${ledger_ref}\\` | +| Artifact branch | ${CI_PROJECT_URL}/-/tree/${artifact_branch} | +| Retention | $( [ -n "$archive_branch" ] && printf 'persistent archive branch' || printf 'ephemeral branch' ) | " if [ "$has_ledger" -ne 1 ]; then body="${body} -- Status: hidden ledger ref not found yet (no tracked sessions pushed)." +| Status | waiting for first tracked session push | +| Next action | Push at least one tracked session, then rerun the session-review pipeline. |" elif [ "$push_ok" -ne 1 ]; then body="${body} -- Status: ledger found, but failed to push artifact branch (check runner push permissions)." +| Status | ledger found, but artifact publish failed | +| Next action | Check runner push permissions for artifact branch updates. |" else body="${body} -- Status: artifact branch updated." +| Status | artifact branch updated | +| Next action | $( [ -n "$archive_branch" ] && printf 'Open the archive branch to inspect stored review snapshots.' || printf 'Open the artifact branch while the MR is active.' ) |" fi notes_api="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes" diff --git a/crates/cli/src/upload.rs b/crates/cli/src/upload.rs index ddf594e4..0999111b 100644 --- a/crates/cli/src/upload.rs +++ b/crates/cli/src/upload.rs @@ -8,7 +8,7 @@ use std::time::Duration; use crate::config::{load_config, load_daemon_config}; use opensession_api_client::ApiClient; -use opensession_parsers::{all_parsers, parser_for_path}; +use opensession_parsers::ParserRegistry; /// Upload a session file to the configured server (or git branch with --git) pub async fn run_upload(file: &Path, parent_ids: &[String], use_git: bool) -> Result<()> { @@ -20,8 +20,8 @@ pub async fn run_upload(file: &Path, parent_ids: &[String], use_git: bool) -> Re let daemon_config = load_daemon_config()?; // Find a parser that can handle this file - let parsers = all_parsers(); - let parser = parser_for_path(&parsers, file); + let registry = ParserRegistry::default(); + let parser = registry.parser_for_path(file); let parser = match parser { Some(p) => p, diff --git a/crates/cli/src/url_opener.rs b/crates/cli/src/url_opener.rs index c0612713..8122145c 100644 --- a/crates/cli/src/url_opener.rs +++ b/crates/cli/src/url_opener.rs @@ -1,6 +1,6 @@ -use crate::open_target::{read_repo_open_target, OpenTarget}; -use anyhow::{anyhow, bail, Context, Result}; -use opensession_core::object_store::global_store_root; +use crate::open_target::{OpenTarget, read_repo_open_target}; +use anyhow::{Context, Result, anyhow, bail}; +use opensession_local_store::global_store_root; use reqwest::Url; use std::fs; use std::path::{Path, PathBuf}; @@ -133,7 +133,15 @@ impl UrlAdapter for SystemBrowserAdapter { } } - bail!("failed to open browser automatically") + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + bail!("failed to open browser automatically"); + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + bail!("failed to open browser automatically"); + } } } diff --git a/crates/cli/src/view.rs b/crates/cli/src/view.rs index 4c4eaf3f..7ea97873 100644 --- a/crates/cli/src/view.rs +++ b/crates/cli/src/view.rs @@ -1,19 +1,19 @@ use crate::{ config_cmd::load_repo_config, - open_target::{read_repo_open_target, OpenTarget}, + open_target::{OpenTarget, read_repo_open_target}, review, url_opener, user_guidance::guided_error, }; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use clap::Args; use opensession_api::{ LocalReviewBundle, LocalReviewCommit, LocalReviewPrMeta, LocalReviewSession, }; use opensession_core::{ - object_store::read_local_object_from_uri, - source_uri::{SourceSpec, SourceUri}, Session, + source_uri::{SourceSpec, SourceUri}, }; +use opensession_local_store::read_local_object_from_uri; use reqwest::Url; use serde::Deserialize; use std::collections::{HashMap, HashSet}; diff --git a/crates/cli/tests/handoff_cli.rs b/crates/cli/tests/handoff_cli.rs index 25530cef..47da4eb5 100644 --- a/crates/cli/tests/handoff_cli.rs +++ b/crates/cli/tests/handoff_cli.rs @@ -2,8 +2,6 @@ use opensession_core::testing; use opensession_core::{Agent, Content, Event, EventType, Session}; use serde_json::Value; use std::fs; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::process::{Command, Output}; @@ -103,6 +101,42 @@ fn make_hail_jsonl_with_cwd(session_id: &str, cwd: &Path) -> String { session.to_jsonl().expect("to jsonl") } +fn make_auxiliary_hail_jsonl_with_cwd(session_id: &str, cwd: &Path, parent_id: &str) -> String { + let mut session = Session::new( + session_id.to_string(), + Agent { + provider: "openai".to_string(), + model: "gpt-5".to_string(), + tool: "codex".to_string(), + tool_version: None, + }, + ); + session.context.title = Some("auxiliary helper".to_string()); + session + .context + .related_session_ids + .push(parent_id.to_string()); + session + .context + .attributes + .insert("cwd".to_string(), Value::String(cwd.display().to_string())); + session.context.attributes.insert( + "session_role".to_string(), + Value::String("auxiliary".to_string()), + ); + session.events.push(Event { + event_id: "e1".to_string(), + timestamp: chrono::Utc::now(), + event_type: EventType::AgentMessage, + task_id: None, + content: Content::text("helper output"), + duration_ms: None, + attributes: Default::default(), + }); + session.recompute_stats(); + session.to_jsonl().expect("to jsonl") +} + fn make_hail_jsonl_with_cwd_and_window( session_id: &str, cwd: &Path, @@ -145,9 +179,10 @@ fn first_non_empty_line(output: &[u8]) -> String { .to_string() } -fn setup_review_fixture( +fn setup_review_fixture_with_options( tmp: &tempfile::TempDir, fetch_hidden_refs: bool, + include_auxiliary: bool, ) -> (std::path::PathBuf, String) { let author = tmp.path().join("author"); let reviewer = tmp.path().join("reviewer"); @@ -194,6 +229,30 @@ fn setup_review_fixture( std::slice::from_ref(&feature_sha), ) .expect("store session in hidden ledger"); + if include_auxiliary { + let auxiliary_body = + make_auxiliary_hail_jsonl_with_cwd("s-review-aux", &author, "s-review"); + let auxiliary_meta = serde_json::json!({ + "schema_version": 2, + "session_id": "s-review-aux", + "session_role": "auxiliary", + "title": "auxiliary helper", + "tool": "codex", + "stats": { "files_changed": 0 }, + "git": { "commits": [feature_sha.clone()] } + }) + .to_string(); + storage + .store_session_at_ref( + &author, + &ledger_ref, + "s-review-aux", + auxiliary_body.as_bytes(), + auxiliary_meta.as_bytes(), + std::slice::from_ref(&feature_sha), + ) + .expect("store auxiliary session in hidden ledger"); + } run_git( &author, @@ -247,2091 +306,35 @@ fn setup_review_fixture( ) } -#[test] -fn help_shows_v1_commands() { - let tmp = make_home(); - let output = run(tmp.path(), tmp.path(), &["--help"]); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase(); - assert!(stdout.contains("register")); - assert!(stdout.contains("share")); - assert!(stdout.contains("view")); - assert!(stdout.contains("handoff")); - assert!(!stdout.contains("\n setup ")); - assert!(!stdout.contains("publish")); - assert!(stdout.contains("first-user flow (5 minutes):")); - assert!(stdout.contains("opensession docs quickstart")); - assert!(stdout.contains("common next steps:")); - assert!(stdout.contains("opensession doctor --fix")); -} - -#[test] -fn parse_help_shows_recovery_examples() { - let tmp = make_home(); - let output = run(tmp.path(), tmp.path(), &["parse", "--help"]); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Recovery examples:")); - assert!(stdout.contains("opensession parse --profile codex ./raw-session.jsonl --preview")); - assert!(stdout.contains( - "opensession parse --profile codex ./raw-session.jsonl --out ./session.hail.jsonl" - )); -} - -#[test] -fn share_help_shows_recovery_examples() { - let tmp = make_home(); - let output = run(tmp.path(), tmp.path(), &["share", "--help"]); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Recovery examples:")); - assert!(stdout.contains("opensession share os://src/local/ --git --remote origin")); - assert!(stdout.contains( - "opensession share os://src/git//ref//path/ --web" - )); -} - -#[test] -fn view_help_shows_recovery_examples() { - let tmp = make_home(); - let output = run(tmp.path(), tmp.path(), &["view", "--help"]); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Recovery examples:")); - assert!(stdout.contains("opensession view --no-open")); - assert!(stdout.contains("opensession view os://src/local/ --no-open")); - assert!(stdout.contains("opensession view ./session.hail.jsonl --no-open")); - assert!(stdout.contains("opensession view HEAD~3..HEAD --no-open")); -} - -#[test] -fn doctor_help_shows_recovery_examples() { - let tmp = make_home(); - let output = run(tmp.path(), tmp.path(), &["doctor", "--help"]); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Recovery examples:")); - assert!(stdout.contains("opensession doctor --fix --profile local")); - assert!(stdout.contains("opensession doctor --fix --yes --profile app")); - assert!(stdout.contains("opensession docs quickstart")); -} - -#[test] -fn doctor_yes_without_fix_shows_next_steps() { - let tmp = make_home(); - let output = run(tmp.path(), tmp.path(), &["doctor", "--yes"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("`--yes` requires `--fix`")); - assert!(stderr.contains("next:")); - assert!( - stderr.contains("opensession doctor --fix --yes --profile local --fanout-mode hidden_ref") - ); -} - -#[test] -fn doctor_fanout_without_fix_shows_next_steps() { - let tmp = make_home(); - let output = run( - tmp.path(), - tmp.path(), - &["doctor", "--fanout-mode", "hidden_ref"], - ); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("`--fanout-mode` requires `--fix`")); - assert!(stderr.contains("next:")); - assert!(stderr.contains("opensession doctor --fix --fanout-mode hidden_ref")); -} - -#[test] -fn register_and_cat_roundtrip() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-register")); - - let register_out = run( - tmp.path(), - &repo, - &["register", input.to_str().expect("path")], - ); - assert!( - register_out.status.success(), - "register failed: {}", - String::from_utf8_lossy(®ister_out.stderr) - ); - let uri = first_non_empty_line(®ister_out.stdout); - assert!(uri.starts_with("os://src/local/")); - - let cat_out = run(tmp.path(), &repo, &["cat", &uri]); - assert!(cat_out.status.success()); - let cat_body = String::from_utf8_lossy(&cat_out.stdout); - let parsed = Session::from_jsonl(&cat_body).expect("cat output is valid jsonl"); - assert_eq!(parsed.session_id, "s-register"); -} - -#[test] -fn share_web_rejects_local_uri() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-share-web")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let output = run(tmp.path(), &repo, &["share", &local_uri, "--web"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("next:")); - assert!(stderr.contains("--git --remote")); -} - -#[test] -fn share_git_requires_remote_guidance() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-share-missing-remote")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let share_out = run(tmp.path(), &repo, &["share", &local_uri, "--git"]); - assert!(!share_out.status.success()); - let stderr = String::from_utf8_lossy(&share_out.stderr); - assert!(stderr.contains("`--remote ` is required")); - assert!(stderr.contains("next:")); - assert!(stderr.contains("opensession share --git --remote origin")); -} - -#[test] -fn share_quick_auto_detects_origin_without_push_and_reports_state() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - let remote = tmp.path().join("remote.git"); - init_git_repo(&repo); - run_git( - tmp.path(), - &["init", "--bare", remote.to_str().expect("remote")], - ); - run_git( - &repo, - &["remote", "add", "origin", remote.to_str().expect("remote")], - ); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-share-quick-no-push")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let share_out = run( - tmp.path(), - &repo, - &["share", &local_uri, "--quick", "--json"], - ); - assert!( - share_out.status.success(), - "share --quick failed: {}", - String::from_utf8_lossy(&share_out.stderr) - ); - let payload: Value = serde_json::from_slice(&share_out.stdout).expect("quick share json"); - assert_eq!(payload.get("quick").and_then(Value::as_bool), Some(true)); - assert_eq!(payload.get("pushed").and_then(Value::as_bool), Some(false)); - assert_eq!( - payload.get("auto_push_consent").and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - payload.get("remote_target").and_then(Value::as_str), - Some("origin") - ); - assert!(payload - .get("push_cmd") - .and_then(Value::as_str) - .unwrap_or_default() - .contains("git push origin")); -} - -#[test] -fn share_quick_push_consent_persists_and_enables_auto_push() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - let remote = tmp.path().join("remote.git"); - init_git_repo(&repo); - run_git( - tmp.path(), - &["init", "--bare", remote.to_str().expect("remote")], - ); - run_git( - &repo, - &["remote", "add", "origin", remote.to_str().expect("remote")], - ); - - let first_input = repo.join("first.hail.jsonl"); - write_file(&first_input, &make_hail_jsonl("s-share-quick-first")); - let first_register = run( - tmp.path(), - &repo, - &["register", "--quiet", first_input.to_str().expect("path")], - ); - let first_local_uri = first_non_empty_line(&first_register.stdout); - let first_share = run( - tmp.path(), - &repo, - &["share", &first_local_uri, "--quick", "--push", "--json"], - ); - assert!( - first_share.status.success(), - "share --quick --push failed: {}", - String::from_utf8_lossy(&first_share.stderr) - ); - let first_payload: Value = - serde_json::from_slice(&first_share.stdout).expect("quick share json"); - assert_eq!( - first_payload.get("quick").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - first_payload.get("pushed").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - first_payload - .get("auto_push_consent") - .and_then(Value::as_bool), - Some(true) - ); - - let consent_config = first_non_empty_line( - &run_git( - &repo, - &[ - "config", - "--local", - "--get", - "opensession.share.auto-push-consent", - ], - ) - .stdout, - ); - assert_eq!(consent_config, "true"); - - let second_input = repo.join("second.hail.jsonl"); - write_file(&second_input, &make_hail_jsonl("s-share-quick-second")); - let second_register = run( - tmp.path(), - &repo, - &["register", "--quiet", second_input.to_str().expect("path")], - ); - let second_local_uri = first_non_empty_line(&second_register.stdout); - let second_hash = second_local_uri - .split('/') - .next_back() - .expect("local uri hash") - .to_string(); - - let second_share = run( - tmp.path(), - &repo, - &["share", &second_local_uri, "--quick", "--json"], - ); - assert!( - second_share.status.success(), - "second share --quick failed: {}", - String::from_utf8_lossy(&second_share.stderr) - ); - let second_payload: Value = - serde_json::from_slice(&second_share.stdout).expect("second quick share json"); - assert_eq!( - second_payload.get("quick").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - second_payload.get("pushed").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - second_payload - .get("auto_push_consent") - .and_then(Value::as_bool), - Some(true) - ); - - let ledger_ref = opensession_git_native::branch_ledger_ref("main"); - run_git( - tmp.path(), - &[ - "--git-dir", - remote.to_str().expect("remote"), - "show", - &format!("{ledger_ref}:sessions/{second_hash}.jsonl"), - ], - ); -} - -#[test] -fn share_quick_requires_remote_when_none_exist() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-share-quick-no-remote")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let share_out = run(tmp.path(), &repo, &["share", &local_uri, "--quick"]); - assert!(!share_out.status.success()); - let stderr = String::from_utf8_lossy(&share_out.stderr); - assert!(stderr.contains("no remotes were found")); - assert!(stderr.contains("git remote add origin")); -} - -#[test] -fn share_quick_rejects_ambiguous_remote_and_allows_explicit_override() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - run_git( - &repo, - &[ - "remote", - "add", - "upstream", - "https://github.com/example/upstream.git", - ], - ); - run_git( - &repo, - &[ - "remote", - "add", - "mirror", - "https://github.com/example/mirror.git", - ], - ); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-share-quick-ambiguous")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let ambiguous = run(tmp.path(), &repo, &["share", &local_uri, "--quick"]); - assert!(!ambiguous.status.success()); - let stderr = String::from_utf8_lossy(&ambiguous.stderr); - assert!(stderr.contains("could not choose a remote automatically")); - assert!(stderr.contains("--quick --remote origin")); - - let explicit = run( - tmp.path(), - &repo, - &[ - "share", &local_uri, "--quick", "--remote", "upstream", "--json", - ], - ); - assert!( - explicit.status.success(), - "share --quick --remote upstream failed: {}", - String::from_utf8_lossy(&explicit.stderr) - ); - let payload: Value = - serde_json::from_slice(&explicit.stdout).expect("explicit quick share json"); - assert_eq!(payload.get("quick").and_then(Value::as_bool), Some(true)); - assert_eq!( - payload.get("remote_target").and_then(Value::as_str), - Some("upstream") - ); -} - -#[test] -fn share_web_requires_config_with_next_steps() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let uri = opensession_core::source_uri::SourceUri::Src( - opensession_core::source_uri::SourceSpec::Git { - remote: "https://git.example/repo.git".to_string(), - r#ref: "refs/heads/main".to_string(), - path: "sessions/demo.jsonl".to_string(), - }, - ) - .to_string(); - - let out = run(tmp.path(), &repo, &["share", &uri, "--web"]); - assert!(!out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("missing config")); - assert!(stderr.contains("next:")); - assert!(stderr.contains("opensession config init --base-url")); -} - -#[test] -fn share_git_outside_repo_shows_next_steps() { - let tmp = make_home(); - let outside = tmp.path().join("outside"); - fs::create_dir_all(&outside).expect("create outside dir"); - - let input = outside.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-share-outside")); - let register_out = run( - tmp.path(), - &outside, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - assert!(local_uri.starts_with("os://src/local/")); - - let share_out = run( - tmp.path(), - &outside, - &["share", &local_uri, "--git", "--remote", "origin"], - ); - assert!(!share_out.status.success()); - let stderr = String::from_utf8_lossy(&share_out.stderr); - assert!(stderr.contains("current directory is not inside a git repository")); - assert!(stderr.contains("next:")); - assert!(stderr.contains("cd into the target git repository and retry")); -} - -#[test] -fn share_git_without_push_prints_push_command() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - let remote = tmp.path().join("remote.git"); - init_git_repo(&repo); - run_git( - tmp.path(), - &["init", "--bare", remote.to_str().expect("remote")], - ); - run_git( - &repo, - &["remote", "add", "origin", remote.to_str().expect("remote")], - ); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-share-git")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let share_out = run( - tmp.path(), - &repo, - &["share", &local_uri, "--git", "--remote", "origin"], - ); - assert!( - share_out.status.success(), - "share --git failed: {}", - String::from_utf8_lossy(&share_out.stderr) - ); - let stdout = String::from_utf8_lossy(&share_out.stdout); - let shared_uri = stdout.lines().next().unwrap_or_default(); - assert!(shared_uri.starts_with("os://src/git/") || shared_uri.starts_with("os://src/gh/")); - assert!(stdout.contains("push_cmd:")); - - let hash = local_uri.split('/').next_back().expect("local hash in uri"); - - run_git( - &repo, - &[ - "show", - &format!( - "{}:sessions/{hash}.jsonl", - opensession_git_native::branch_ledger_ref("main") - ), - ], - ); -} - -#[test] -fn share_git_with_gitlab_dot_com_remote_emits_gl_uri() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-share-gl")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let share_out = run( - tmp.path(), - &repo, - &[ - "share", - &local_uri, - "--git", - "--remote", - "https://gitlab.com/group/sub/repo.git", - ], - ); - assert!( - share_out.status.success(), - "share --git failed: {}", - String::from_utf8_lossy(&share_out.stderr) - ); - let stdout = String::from_utf8_lossy(&share_out.stdout); - let shared_uri = stdout.lines().next().unwrap_or_default(); - assert!( - shared_uri.starts_with("os://src/gl/"), - "expected gl uri, got: {shared_uri}" - ); -} - -#[test] -fn cleanup_init_github_writes_expected_files() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - let remote = tmp.path().join("cleanup-github-remote.git"); - init_git_repo(&repo); - run_git( - tmp.path(), - &["init", "--bare", remote.to_str().expect("remote")], - ); - run_git( - &repo, - &["remote", "add", "origin", remote.to_str().expect("remote")], - ); - - let out = run( - tmp.path(), - &repo, - &["cleanup", "init", "--provider", "github", "--yes", "--json"], - ); - assert!( - out.status.success(), - "cleanup init failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - - let payload: Value = serde_json::from_slice(&out.stdout).expect("cleanup init json"); - assert_eq!( - payload.get("configured").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - payload.get("provider").and_then(Value::as_str), - Some("github") - ); - - assert!( - repo.join(".opensession") - .join("cleanup") - .join("config.toml") - .exists(), - "expected cleanup config to exist" - ); - assert!( - repo.join(".opensession") - .join("cleanup") - .join("janitor.sh") - .exists(), - "expected janitor script to exist" - ); - assert!( - repo.join(".github") - .join("workflows") - .join("opensession-cleanup.yml") - .exists(), - "expected github workflow template to exist" - ); - assert!( - repo.join(".github") - .join("workflows") - .join("opensession-session-review.yml") - .exists(), - "expected github session review workflow template to exist" - ); +fn setup_review_fixture( + tmp: &tempfile::TempDir, + fetch_hidden_refs: bool, +) -> (std::path::PathBuf, String) { + setup_review_fixture_with_options(tmp, fetch_hidden_refs, false) } -#[test] -fn cleanup_init_gitlab_without_marker_reports_manual_steps() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - let remote = tmp.path().join("cleanup-gitlab-remote.git"); - init_git_repo(&repo); - run_git( - tmp.path(), - &["init", "--bare", remote.to_str().expect("remote")], - ); - run_git( - &repo, - &["remote", "add", "origin", remote.to_str().expect("remote")], - ); - - write_file(&repo.join(".gitlab-ci.yml"), "stages:\n - test\n"); - - let out = run( - tmp.path(), - &repo, - &["cleanup", "init", "--provider", "gitlab", "--yes", "--json"], - ); - assert!( - out.status.success(), - "cleanup init failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - - let payload: Value = serde_json::from_slice(&out.stdout).expect("cleanup init json"); - let manual_steps = payload - .get("manual_steps") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - assert!( - !manual_steps.is_empty(), - "expected manual steps for gitlab-ci" - ); - - assert!( - repo.join(".gitlab") - .join("opensession-cleanup.yml") - .exists(), - "expected gitlab cleanup template to exist" - ); - assert!( - repo.join(".gitlab") - .join("opensession-session-review.yml") - .exists(), - "expected gitlab session review template to exist" - ); - - let gitlab_ci = fs::read_to_string(repo.join(".gitlab-ci.yml")).expect("read gitlab-ci"); - assert!(gitlab_ci.contains("stages:")); - assert!(!gitlab_ci.contains("opensession-managed-cleanup")); -} - -#[test] -fn cleanup_status_reports_not_configured_then_configured() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - let remote = tmp.path().join("cleanup-status-remote.git"); - init_git_repo(&repo); - run_git( - tmp.path(), - &["init", "--bare", remote.to_str().expect("remote")], - ); - run_git( - &repo, - &["remote", "add", "origin", remote.to_str().expect("remote")], - ); - - let status_before = run(tmp.path(), &repo, &["cleanup", "status", "--json"]); - assert!( - status_before.status.success(), - "cleanup status failed: {}", - String::from_utf8_lossy(&status_before.stderr) - ); - let before_payload: Value = - serde_json::from_slice(&status_before.stdout).expect("cleanup status json"); - assert_eq!( - before_payload.get("configured").and_then(Value::as_bool), - Some(false) - ); - - let init = run( - tmp.path(), - &repo, - &["cleanup", "init", "--provider", "generic", "--yes"], - ); - assert!( - init.status.success(), - "cleanup init failed: {}", - String::from_utf8_lossy(&init.stderr) - ); - - let status_after = run(tmp.path(), &repo, &["cleanup", "status", "--json"]); - assert!( - status_after.status.success(), - "cleanup status failed: {}", - String::from_utf8_lossy(&status_after.stderr) - ); - let after_payload: Value = - serde_json::from_slice(&status_after.stdout).expect("cleanup status json"); - assert_eq!( - after_payload.get("configured").and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - after_payload.get("provider").and_then(Value::as_str), - Some("generic") - ); -} - -#[test] -fn cleanup_run_without_init_shows_next_steps() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run(tmp.path(), &repo, &["cleanup", "run"]); - assert!(!out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("cleanup janitor is not configured")); - assert!(stderr.contains("next:")); - assert!(stderr.contains("opensession cleanup init --provider auto")); -} - -#[test] -fn cleanup_run_dry_and_apply_handles_hidden_and_artifact_refs() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - let remote = tmp.path().join("cleanup-remote.git"); - init_git_repo(&repo); - - run_git( - tmp.path(), - &["init", "--bare", remote.to_str().expect("remote")], - ); - run_git( - &repo, - &["remote", "add", "origin", remote.to_str().expect("remote")], - ); - run_git(&repo, &["push", "origin", "main:main"]); - - let head_sha = first_non_empty_line(&run_git(&repo, &["rev-parse", "HEAD"]).stdout); - let ledger_ref = opensession_git_native::branch_ledger_ref("stale/branch"); - let session_body = make_hail_jsonl("s-cleanup"); - let meta_body = serde_json::json!({ - "schema_version": 2, - "session_id": "s-cleanup", - "git": { "commits": [head_sha.clone()] } - }) - .to_string(); - opensession_git_native::NativeGitStorage - .store_session_at_ref( - &repo, - &ledger_ref, - "s-cleanup", - session_body.as_bytes(), - meta_body.as_bytes(), - std::slice::from_ref(&head_sha), - ) - .expect("store hidden session"); - run_git( - &repo, - &["push", "origin", &format!("{ledger_ref}:{ledger_ref}")], - ); - - run_git(&repo, &["checkout", "-b", "opensession/pr-77-sessions"]); - write_file(&repo.join("artifact.txt"), "artifact branch\n"); - run_git(&repo, &["add", "."]); - run_git(&repo, &["commit", "-m", "artifact branch"]); - run_git( - &repo, - &[ - "push", - "origin", - "opensession/pr-77-sessions:opensession/pr-77-sessions", - ], - ); - run_git(&repo, &["checkout", "main"]); - - let init_out = run( - tmp.path(), - &repo, - &[ - "cleanup", - "init", - "--provider", - "generic", - "--remote", - "origin", - "--hidden-ttl-days", - "0", - "--artifact-ttl-days", - "0", - "--yes", - ], - ); - assert!( - init_out.status.success(), - "cleanup init failed: {}", - String::from_utf8_lossy(&init_out.stderr) - ); - - let dry_run = run(tmp.path(), &repo, &["cleanup", "run", "--json"]); - assert!( - dry_run.status.success(), - "cleanup dry-run failed: {}", - String::from_utf8_lossy(&dry_run.stderr) - ); - let dry_payload: Value = serde_json::from_slice(&dry_run.stdout).expect("cleanup run json"); - assert!( - dry_payload - .get("hidden_candidates") - .and_then(Value::as_array) - .map(|items| !items.is_empty()) - .unwrap_or(false), - "expected hidden ref candidate in dry-run" - ); - assert!( - dry_payload - .get("artifact_candidates") - .and_then(Value::as_array) - .map(|items| !items.is_empty()) - .unwrap_or(false), - "expected artifact branch candidate in dry-run" - ); - - let apply_out = run(tmp.path(), &repo, &["cleanup", "run", "--apply", "--json"]); - assert!( - apply_out.status.success(), - "cleanup apply failed: {}", - String::from_utf8_lossy(&apply_out.stderr) - ); - let apply_payload: Value = serde_json::from_slice(&apply_out.stdout).expect("cleanup run json"); - let deleted = apply_payload - .get("deleted") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - assert!( - deleted.iter().any(|item| { - item.as_str() - .unwrap_or_default() - .contains("refs/opensession/branches/") - }), - "expected hidden ref deletion" - ); - assert!( - deleted.iter().any(|item| { - item.as_str() - .unwrap_or_default() - .contains("refs/heads/opensession/pr-77-sessions") - }), - "expected artifact branch deletion" - ); -} - -#[test] -fn share_git_push_in_non_tty_does_not_trigger_cleanup_prompt() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - let remote = tmp.path().join("remote.git"); - init_git_repo(&repo); - run_git( - tmp.path(), - &["init", "--bare", remote.to_str().expect("remote")], - ); - run_git( - &repo, - &["remote", "add", "origin", remote.to_str().expect("remote")], - ); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-share-push-no-tty")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let share_out = run( - tmp.path(), - &repo, - &["share", &local_uri, "--git", "--remote", "origin", "--push"], - ); - assert!( - share_out.status.success(), - "share --push failed: {}", - String::from_utf8_lossy(&share_out.stderr) - ); - - assert!( - !repo - .join(".opensession") - .join("cleanup") - .join("config.toml") - .exists(), - "non-tty share should not auto-initialize cleanup config" - ); - - let prompted = Command::new("git") - .arg("-C") - .arg(&repo) - .arg("config") - .arg("--local") - .arg("--get") - .arg("opensession.cleanup.prompted") - .output() - .expect("read prompted config"); - assert!( - !prompted.status.success(), - "non-tty share should not set cleanup prompt git config" - ); -} - -#[test] -fn config_and_share_web_success() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let init_out = run( - tmp.path(), - &repo, - &["config", "init", "--base-url", "https://example.test"], - ); - assert!(init_out.status.success()); - - let uri = opensession_core::source_uri::SourceUri::Src( - opensession_core::source_uri::SourceSpec::Git { - remote: "https://git.example/repo.git".to_string(), - r#ref: "refs/heads/main".to_string(), - path: "sessions/demo.jsonl".to_string(), - }, - ) - .to_string(); - let out = run(tmp.path(), &repo, &["share", &uri, "--web"]); - assert!( - out.status.success(), - "share web failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.contains("https://example.test/src/git/")); - assert!(stdout.contains("base_url: https://example.test")); -} - -#[test] -fn share_web_supports_gl_and_git_routes() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let init_out = run( - tmp.path(), - &repo, - &["config", "init", "--base-url", "https://example.test"], - ); - assert!(init_out.status.success()); - - let gl_uri = opensession_core::source_uri::SourceUri::Src( - opensession_core::source_uri::SourceSpec::Gl { - project: "group/sub/repo".to_string(), - r#ref: "refs/heads/main".to_string(), - path: "sessions/demo.jsonl".to_string(), - }, - ) - .to_string(); - let gl_out = run(tmp.path(), &repo, &["share", &gl_uri, "--web"]); - assert!( - gl_out.status.success(), - "share web for gl failed: {}", - String::from_utf8_lossy(&gl_out.stderr) - ); - let gl_stdout = String::from_utf8_lossy(&gl_out.stdout); - assert!(gl_stdout.contains("https://example.test/src/gl/")); - - let git_uri = opensession_core::source_uri::SourceUri::Src( - opensession_core::source_uri::SourceSpec::Git { - remote: "https://gitlab.internal.example.com/group/repo.git".to_string(), - r#ref: "refs/heads/main".to_string(), - path: "sessions/demo.jsonl".to_string(), - }, - ) - .to_string(); - let git_out = run(tmp.path(), &repo, &["share", &git_uri, "--web"]); - assert!( - git_out.status.success(), - "share web for git failed: {}", - String::from_utf8_lossy(&git_out.stderr) - ); - let git_stdout = String::from_utf8_lossy(&git_out.stdout); - assert!(git_stdout.contains("https://example.test/src/git/")); -} - -#[test] -fn view_web_maps_remote_source_uri_to_src_route() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let init_out = run( - tmp.path(), - &repo, - &["config", "init", "--base-url", "https://example.test"], - ); - assert!(init_out.status.success()); - - let uri = opensession_core::source_uri::SourceUri::Src( - opensession_core::source_uri::SourceSpec::Gl { - project: "group/sub/repo".to_string(), - r#ref: "refs/heads/main".to_string(), - path: "sessions/demo.jsonl".to_string(), - }, - ) - .to_string(); - let out = run(tmp.path(), &repo, &["view", &uri, "--no-open", "--json"]); - assert!( - out.status.success(), - "view failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - let payload: Value = serde_json::from_slice(&out.stdout).expect("view json"); - let url = payload - .get("url") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - assert!( - url.starts_with("https://example.test/src/gl/"), - "unexpected url: {url}" - ); -} - -#[test] -fn view_local_uri_emits_local_review_url_without_opening_browser() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-view-local")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let view_out = run( - tmp.path(), - &repo, - &["view", &local_uri, "--no-open", "--json"], - ); - assert!( - view_out.status.success(), - "view failed: {}", - String::from_utf8_lossy(&view_out.stderr) - ); - let payload: Value = serde_json::from_slice(&view_out.stdout).expect("view json"); - assert_eq!(payload.get("mode").and_then(Value::as_str), Some("local")); - let url = payload - .get("url") - .and_then(Value::as_str) - .unwrap_or_default(); - assert!(url.contains("/review/local/"), "unexpected url: {url}"); -} - -#[test] -fn view_commit_target_builds_commit_review_bundle() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run(tmp.path(), &repo, &["view", "HEAD", "--no-open", "--json"]); - assert!( - out.status.success(), - "view commit failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - let payload: Value = serde_json::from_slice(&out.stdout).expect("view json"); - assert_eq!(payload.get("mode").and_then(Value::as_str), Some("commit")); - let url = payload - .get("url") - .and_then(Value::as_str) - .unwrap_or_default(); - assert!(url.contains("/review/local/"), "unexpected url: {url}"); -} - -#[test] -fn view_without_target_defaults_to_sessions_route() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run(tmp.path(), &repo, &["view", "--no-open", "--json"]); - assert!( - out.status.success(), - "view default failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - let payload: Value = serde_json::from_slice(&out.stdout).expect("view json"); - assert_eq!( - payload.get("mode").and_then(Value::as_str), - Some("sessions") - ); - let url = payload - .get("url") - .and_then(Value::as_str) - .unwrap_or_default(); - assert_eq!(url, "http://127.0.0.1:8788/sessions"); -} - -#[test] -fn view_without_target_prefills_repo_query_when_origin_matches_owner_repo() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - run_git( - &repo, - &[ - "remote", - "add", - "origin", - "https://github.com/acme/repo.git", - ], - ); - - let out = run(tmp.path(), &repo, &["view", "--no-open", "--json"]); - assert!( - out.status.success(), - "view default failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - let payload: Value = serde_json::from_slice(&out.stdout).expect("view json"); - let url = payload - .get("url") - .and_then(Value::as_str) - .unwrap_or_default(); - assert!(url.contains("/sessions?"), "unexpected url: {url}"); - assert!( - url.contains("git_repo_name=acme%2Frepo"), - "unexpected url: {url}" - ); -} - -#[test] -fn view_rejects_removed_tui_flag() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run(tmp.path(), &repo, &["view", "--tui"]); - assert!(!out.status.success(), "view --tui should be rejected"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("unexpected argument '--tui'"), - "unexpected stderr: {stderr}" - ); -} - -#[test] -fn view_without_target_open_mode_fails_closed_without_explicit_base_url() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - run_git( - &repo, - &["config", "--local", "opensession.open-target", "web"], - ); - - let out = run(tmp.path(), &repo, &["view", "--json"]); - assert!( - !out.status.success(), - "view default should fail without local server/base URL" - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("local sessions server is unavailable")); - assert!(stderr.contains("opensession view --no-open")); - assert!(stderr.contains("opensession config init --base-url")); -} - -#[cfg(target_os = "macos")] -#[test] -fn view_without_target_open_mode_suppresses_desktop_open_probe_stderr() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let bin = tmp.path().join("bin"); - fs::create_dir_all(&bin).expect("create bin"); - let fake_open = bin.join("open"); - write_file( - &fake_open, - "#!/bin/sh\necho OPEN_PROBE_MARKER >&2\nexit 1\n", - ); - let mut perms = fs::metadata(&fake_open) - .expect("stat fake open") - .permissions(); - perms.set_mode(0o755); - fs::set_permissions(&fake_open, perms).expect("chmod fake open"); - - let base_path = std::env::var("PATH").unwrap_or_default(); - let path_env = if base_path.is_empty() { - bin.display().to_string() - } else { - format!("{}:{}", bin.display(), base_path) - }; - - let mut cmd = Command::new(env!("CARGO_BIN_EXE_opensession")); - let out = cmd - .args(["view", "--json"]) - .current_dir(&repo) - .env("HOME", tmp.path()) - .env("NO_COLOR", "1") - .env("PATH", path_env) - .output() - .expect("run opensession"); - - assert!(!out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - !stderr.contains("OPEN_PROBE_MARKER"), - "desktop open probe stderr leaked: {stderr}" - ); -} - -#[cfg(target_os = "macos")] -#[test] -fn view_without_target_web_open_target_skips_desktop_probe() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - run_git( - &repo, - &["config", "--local", "opensession.open-target", "web"], - ); - - let bin = tmp.path().join("bin"); - fs::create_dir_all(&bin).expect("create bin"); - let marker_path = tmp.path().join("open-invoked"); - let fake_open = bin.join("open"); - write_file( - &fake_open, - format!( - "#!/bin/sh\nprintf invoked > \"{}\"\nexit 1\n", - marker_path.display() - ) - .as_str(), - ); - let mut perms = fs::metadata(&fake_open) - .expect("stat fake open") - .permissions(); - perms.set_mode(0o755); - fs::set_permissions(&fake_open, perms).expect("chmod fake open"); - - let base_path = std::env::var("PATH").unwrap_or_default(); - let path_env = if base_path.is_empty() { - bin.display().to_string() - } else { - format!("{}:{}", bin.display(), base_path) - }; - - let mut cmd = Command::new(env!("CARGO_BIN_EXE_opensession")); - let out = cmd - .args(["view", "--json"]) - .current_dir(&repo) - .env("HOME", tmp.path()) - .env("NO_COLOR", "1") - .env("PATH", path_env) - .output() - .expect("run opensession"); - - assert!(!out.status.success()); - assert!( - !marker_path.exists(), - "desktop open probe should be skipped for open-target=web" - ); -} - -#[test] -fn view_invalid_target_shows_next_steps() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run( - tmp.path(), - &repo, - &["view", "definitely-not-a-real-target", "--no-open"], - ); - assert!(!out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("unable to resolve view target")); - assert!(stderr.contains("next:")); - assert!(stderr.contains("opensession view os://src/... --no-open")); -} - -#[test] -fn register_rejects_non_hail_with_next_steps() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("not-hail.txt"); - write_file(&input, "this is not hail jsonl\n"); - - let out = run( - tmp.path(), - &repo, - &["register", input.to_str().expect("path")], - ); - assert!(!out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("register expects canonical HAIL JSONL")); - assert!(stderr.contains("next:")); - assert!(stderr.contains("opensession parse --profile codex")); -} - -#[test] -fn parse_invalid_profile_shows_next_steps() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let raw = repo.join("raw-session.jsonl"); - write_file(&raw, "{\"not\":\"a valid session format\"}\n"); - - let out = run( - tmp.path(), - &repo, - &[ - "parse", - "--profile", - "unknown-profile", - raw.to_str().expect("path"), - ], - ); - assert!(!out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("next:")); - assert!(stderr.contains("opensession parse --help")); -} - -#[test] -fn handoff_build_get_verify_pin_unpin_rm() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input_a = repo.join("a.hail.jsonl"); - let input_b = repo.join("b.hail.jsonl"); - write_file(&input_a, &make_hail_jsonl("s-a")); - write_file(&input_b, &make_hail_jsonl("s-b")); - - let reg_a = run( - tmp.path(), - &repo, - &["register", "--quiet", input_a.to_str().expect("path")], - ); - let reg_b = run( - tmp.path(), - &repo, - &["register", "--quiet", input_b.to_str().expect("path")], - ); - let uri_a = first_non_empty_line(®_a.stdout); - let uri_b = first_non_empty_line(®_b.stdout); - - let build = run( - tmp.path(), - &repo, - &[ - "handoff", - "build", - "--from", - &uri_a, - "--from", - &uri_b, - "--pin", - "latest", - "--validate", - ], - ); - assert!( - build.status.success(), - "handoff build failed: {}", - String::from_utf8_lossy(&build.stderr) - ); - let artifact_uri = first_non_empty_line(&build.stdout); - assert!(artifact_uri.starts_with("os://artifact/")); - - let get_json = run( - tmp.path(), - &repo, - &[ - "handoff", - "artifacts", - "get", - &artifact_uri, - "--format", - "canonical", - "--encode", - "json", - ], - ); - assert!(get_json.status.success()); - let parsed: Value = serde_json::from_slice(&get_json.stdout).expect("json output"); - assert!(parsed.as_array().is_some()); - - let verify = run( - tmp.path(), - &repo, - &["handoff", "artifacts", "verify", &artifact_uri], - ); - assert!(verify.status.success()); - - let rm_pinned = run( - tmp.path(), - &repo, - &["handoff", "artifacts", "rm", &artifact_uri], - ); - assert!(!rm_pinned.status.success()); - - let unpin = run( - tmp.path(), - &repo, - &["handoff", "artifacts", "unpin", "latest"], - ); - assert!(unpin.status.success()); - - let rm = run( - tmp.path(), - &repo, - &["handoff", "artifacts", "rm", &artifact_uri], - ); - assert!(rm.status.success()); -} - -#[test] -fn setup_non_tty_requires_yes() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run(tmp.path(), &repo, &["setup"]); - assert!( - !out.status.success(), - "setup should require --yes in non-tty mode" - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("requires explicit approval")); - assert!( - stderr.contains("opensession doctor --fix --yes --profile local --fanout-mode hidden_ref") - ); -} - -#[test] -fn setup_non_tty_yes_requires_explicit_fanout_when_unset() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run(tmp.path(), &repo, &["setup", "--yes"]); - assert!( - !out.status.success(), - "setup --yes should fail without explicit fanout when repo has no fanout config" - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("fanout mode is not configured")); - assert!( - stderr.contains("opensession doctor --fix --yes --profile local --fanout-mode hidden_ref") - ); -} - -#[test] -fn setup_yes_with_fanout_installs_pre_push_hook_with_original_copy() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - write_file( - &repo.join(".git").join("hooks").join("pre-push"), - "#!/bin/sh\necho custom\n", - ); - - let out = run( - tmp.path(), - &repo, - &["setup", "--yes", "--fanout-mode", "hidden_ref"], - ); - assert!( - out.status.success(), - "setup failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - - let backup = repo - .join(".git") - .join("hooks") - .join("pre-push.original.pre-opensession"); - assert!(backup.exists(), "expected original hook copy"); - let shim = tmp - .path() - .join(".local") - .join("share") - .join("opensession") - .join("bin") - .join("opensession"); - assert!(shim.exists(), "expected setup to install opensession shim"); - let ops_shim = tmp - .path() - .join(".local") - .join("share") - .join("opensession") - .join("bin") - .join("ops"); - assert!(ops_shim.exists(), "expected setup to install ops shim"); - - let hook_body = fs::read_to_string(repo.join(".git").join("hooks").join("pre-push")) - .expect("read pre-push hook"); - assert!(hook_body.contains("opensession-managed")); - assert!(hook_body.contains("setup --sync-branch-session")); - assert!(hook_body.contains("--sync-branch-commit")); - assert!(hook_body.contains("setup --print-ledger-ref")); - assert!(hook_body.contains("setup --print-fanout-mode")); - assert!(hook_body.contains("git notes --ref=opensession")); - assert!(hook_body.contains("git notes --ref=opensession copy -f")); - assert!(hook_body.contains(".local/share/opensession/bin/opensession")); - - let fanout = run_git( - &repo, - &["config", "--local", "--get", "opensession.fanout-mode"], - ); - assert_eq!(String::from_utf8_lossy(&fanout.stdout).trim(), "hidden_ref"); - let open_target = run_git( - &repo, - &["config", "--local", "--get", "opensession.open-target"], - ); - assert_eq!(String::from_utf8_lossy(&open_target.stdout).trim(), "web"); -} - -#[test] -fn doctor_fix_yes_with_fanout_mode_applies_setup() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run( - tmp.path(), - &repo, - &[ - "doctor", - "--fix", - "--yes", - "--fanout-mode", - "git_notes", - "--open-target", - "web", - ], - ); - assert!( - out.status.success(), - "doctor --fix failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - - let fanout = run_git( - &repo, - &["config", "--local", "--get", "opensession.fanout-mode"], - ); - assert_eq!(String::from_utf8_lossy(&fanout.stdout).trim(), "git_notes"); - let open_target = run_git( - &repo, - &["config", "--local", "--get", "opensession.open-target"], - ); - assert_eq!(String::from_utf8_lossy(&open_target.stdout).trim(), "web"); - assert!( - repo.join(".git").join("hooks").join("pre-push").exists(), - "expected pre-push hook to be installed by doctor --fix" - ); -} - -#[test] -fn setup_check_prints_expected_ledger_ref() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run(tmp.path(), &repo, &["setup", "--check"]); - assert!( - out.status.success(), - "setup --check failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.contains("current branch:")); - assert!(stdout.contains("main")); - assert!(stdout.contains(&opensession_git_native::branch_ledger_ref("main"))); - assert!(stdout.contains("ops shim:")); - assert!(stdout.contains("review readiness:")); -} - -#[test] -fn setup_print_fanout_mode_reads_git_config() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let out = run(tmp.path(), &repo, &["setup", "--print-fanout-mode"]); - assert!( - out.status.success(), - "print-fanout-mode failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hidden_ref"); - - let git_config = Command::new("git") - .arg("-C") - .arg(&repo) - .arg("config") - .arg("--local") - .arg("opensession.fanout-mode") - .arg("git_notes") - .output() - .expect("set git config"); - assert!( - git_config.status.success(), - "{}", - String::from_utf8_lossy(&git_config.stderr) - ); - - let out = run(tmp.path(), &repo, &["setup", "--print-fanout-mode"]); - assert!( - out.status.success(), - "print-fanout-mode failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "git_notes"); -} - -#[test] -fn setup_sync_branch_session_stores_latest_repo_session_to_hidden_ref() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let head_sha = first_non_empty_line(&run_git(&repo, &["rev-parse", "HEAD"]).stdout); - let session_path = tmp - .path() - .join(".codex") - .join("sessions") - .join("2026") - .join("02") - .join("26") - .join("rollout-2026-02-26T00-00-00-sync-session-1.jsonl"); - write_file( - &session_path, - &make_hail_jsonl_with_cwd("sync-session-1", &repo), - ); - - let out = run( - tmp.path(), - &repo, - &[ - "setup", - "--sync-branch-session", - "main", - "--sync-branch-commit", - &head_sha, - ], - ); - assert!( - out.status.success(), - "sync branch session failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - - let ledger_ref = opensession_git_native::branch_ledger_ref("main"); - let verify = Command::new("git") - .arg("-C") - .arg(&repo) - .arg("show-ref") - .arg("--verify") - .arg("--quiet") - .arg(&ledger_ref) - .output() - .expect("verify ledger ref exists"); - assert!( - verify.status.success(), - "expected ledger ref to exist after sync" - ); - - let index_blob = run_git( - &repo, - &[ - "show", - &format!("{ledger_ref}:v1/index/commits/{head_sha}/sync-session-1.json"), - ], - ); - let index_body = String::from_utf8_lossy(&index_blob.stdout); - assert!(index_body.contains("\"session_id\":\"sync-session-1\"")); -} - -#[test] -fn setup_sync_branch_session_maps_single_session_to_multiple_commits() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - write_file(&repo.join("a.txt"), "a\n"); - run_git(&repo, &["add", "."]); - run_git(&repo, &["commit", "-m", "feat: a"]); - let commit_a = first_non_empty_line(&run_git(&repo, &["rev-parse", "HEAD"]).stdout); - - write_file(&repo.join("b.txt"), "b\n"); - run_git(&repo, &["add", "."]); - run_git(&repo, &["commit", "-m", "feat: b"]); - let commit_b = first_non_empty_line(&run_git(&repo, &["rev-parse", "HEAD"]).stdout); - - let session_path = tmp - .path() - .join(".codex") - .join("sessions") - .join("2026") - .join("02") - .join("26") - .join("rollout-2026-02-26T00-00-00-sync-session-multi.jsonl"); - let created = chrono::Utc::now() - chrono::Duration::hours(2); - let updated = chrono::Utc::now() + chrono::Duration::hours(2); - write_file( - &session_path, - &make_hail_jsonl_with_cwd_and_window("sync-session-multi", &repo, created, updated), - ); - - let out = run( - tmp.path(), - &repo, - &[ - "setup", - "--sync-branch-session", - "main", - "--sync-branch-commit", - &commit_b, - ], - ); - assert!( - out.status.success(), - "sync branch session failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - - let ledger_ref = opensession_git_native::branch_ledger_ref("main"); - run_git( - &repo, - &[ - "show", - &format!("{ledger_ref}:v1/index/commits/{commit_a}/sync-session-multi.json"), - ], - ); - run_git( - &repo, - &[ - "show", - &format!("{ledger_ref}:v1/index/commits/{commit_b}/sync-session-multi.json"), - ], - ); -} - -#[test] -fn parse_profile_codex_outputs_canonical_jsonl() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("codex.jsonl"); - write_file( - &input, - r#"{"type":"session_meta","session_id":"abc","timestamp":"2026-02-14T00:00:00Z"} -{"type":"response_item","timestamp":"2026-02-14T00:00:01Z","payload":{"type":"message","role":"user","content":"hello"}}"#, - ); - - let out = run( - tmp.path(), - &repo, - &[ - "parse", - "--profile", - "codex", - input.to_str().expect("path"), - "--validate", - ], - ); - assert!( - out.status.success(), - "parse failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - - let parsed = Session::from_jsonl(&String::from_utf8_lossy(&out.stdout)).expect("jsonl"); - assert_eq!(parsed.version, Session::CURRENT_VERSION); - assert_eq!(parsed.agent.tool, "codex"); -} - -#[test] -fn inspect_local_and_artifact_json() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-inspect")); - - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let inspect_local = run(tmp.path(), &repo, &["inspect", &local_uri, "--json"]); - assert!(inspect_local.status.success()); - let local_json: Value = serde_json::from_slice(&inspect_local.stdout).expect("inspect local"); - assert_eq!(local_json["uri"], local_uri); - - let build = run( - tmp.path(), - &repo, - &["handoff", "build", "--from", &local_uri], - ); - assert!(build.status.success()); - let artifact_uri = first_non_empty_line(&build.stdout); - - let inspect_artifact = run(tmp.path(), &repo, &["inspect", &artifact_uri, "--json"]); - assert!(inspect_artifact.status.success()); - let artifact_json: Value = - serde_json::from_slice(&inspect_artifact.stdout).expect("inspect artifact"); - assert_eq!(artifact_json["uri"], artifact_uri); -} - -#[test] -fn parse_preview_option_prints_parser_used() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-preview")); - - let out = run( - tmp.path(), - &repo, - &[ - "parse", - "--profile", - "hail", - "--preview", - input.to_str().expect("path"), - ], - ); - assert!(out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("parser_used:")); -} - -#[test] -fn canonical_jsonl_register_rejects_non_hail_input() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("raw.jsonl"); - write_file(&input, "{\"type\":\"session_meta\"}\n"); - - let out = run( - tmp.path(), - &repo, - &["register", input.to_str().expect("path")], - ); - assert!(!out.status.success()); - assert!(String::from_utf8_lossy(&out.stderr).contains("opensession parse")); -} - -#[test] -fn docs_completion_still_available() { - let tmp = make_home(); - let out = run(tmp.path(), tmp.path(), &["docs", "completion", "bash"]); - assert!(out.status.success()); - assert!(!out.stdout.is_empty()); -} - -#[test] -fn docs_quickstart_prints_first_user_flow() { - let tmp = make_home(); - let out = run(tmp.path(), tmp.path(), &["docs", "quickstart"]); - assert!(out.status.success()); - let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.contains("OpenSession 5-minute first-user flow")); - assert!(stdout.contains("opensession doctor --fix")); - assert!(stdout.contains("opensession parse --profile codex")); - assert!(stdout.contains("opensession register ./session.hail.jsonl")); - assert!(stdout.contains("opensession share os://src/local/ --quick --remote origin")); -} - -#[test] -fn review_rejects_removed_tui_view_mode() { - let tmp = make_home(); - let (reviewer_repo, pr_link) = setup_review_fixture(&tmp, true); - - let out = run( - tmp.path(), - &reviewer_repo, - &["review", &pr_link, "--view", "tui", "--no-fetch"], - ); - assert!( - !out.status.success(), - "review --view tui should be rejected" - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("invalid value 'tui'"), - "unexpected stderr: {stderr}" - ); -} - -#[test] -fn review_json_builds_commit_grouped_bundle_from_hidden_refs() { - let tmp = make_home(); - let (reviewer_repo, pr_link) = setup_review_fixture(&tmp, true); - - let out = run( - tmp.path(), - &reviewer_repo, - &["review", &pr_link, "--json", "--no-fetch"], - ); - assert!( - out.status.success(), - "review failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - - let payload: Value = serde_json::from_slice(&out.stdout).expect("review json payload"); - assert_eq!(payload["commit_count"].as_u64().unwrap_or(0), 1); - assert_eq!(payload["mapped_commit_count"].as_u64().unwrap_or(0), 1); - assert!(payload["session_count"].as_u64().unwrap_or(0) >= 1); - - let bundle_path = payload["bundle_path"] - .as_str() - .expect("bundle path in payload"); - let bundle_raw = fs::read(bundle_path).expect("read review bundle"); - let bundle_json: Value = serde_json::from_slice(&bundle_raw).expect("bundle json"); - let first_commit = bundle_json["commits"] - .as_array() - .and_then(|rows| rows.first()) - .expect("first commit row"); - let first_session = first_commit["session_ids"] - .as_array() - .and_then(|rows| rows.first()) - .and_then(|v| v.as_str()) - .unwrap_or_default(); - assert_eq!(first_session, "s-review"); - assert!( - first_commit["semantic_summary"].is_object(), - "expected commit semantic_summary payload" - ); -} - -#[test] -fn review_no_fetch_succeeds_with_empty_session_groups_when_hidden_refs_missing() { - let tmp = make_home(); - let (reviewer_repo, pr_link) = setup_review_fixture(&tmp, false); - - let out = run( - tmp.path(), - &reviewer_repo, - &["review", &pr_link, "--json", "--no-fetch"], - ); - assert!( - out.status.success(), - "review should succeed without hidden refs: {}", - String::from_utf8_lossy(&out.stderr) - ); - let payload: Value = serde_json::from_slice(&out.stdout).expect("review json payload"); - assert_eq!(payload["commit_count"].as_u64().unwrap_or(0), 1); - assert_eq!(payload["mapped_commit_count"].as_u64().unwrap_or(0), 0); - assert_eq!(payload["session_count"].as_u64().unwrap_or(0), 0); - - let bundle_path = payload["bundle_path"] - .as_str() - .expect("bundle path in payload"); - let bundle_raw = fs::read(bundle_path).expect("read review bundle"); - let bundle_json: Value = serde_json::from_slice(&bundle_raw).expect("bundle json"); - let first_commit = bundle_json["commits"] - .as_array() - .and_then(|rows| rows.first()) - .expect("first commit row"); - assert!( - first_commit["semantic_summary"].is_object(), - "expected semantic summary even when mapped sessions are absent" - ); -} - -#[test] -fn handoff_get_raw_jsonl_outputs_session_json_rows() { - let tmp = make_home(); - let repo = tmp.path().join("repo"); - init_git_repo(&repo); - - let input = repo.join("sample.hail.jsonl"); - write_file(&input, &make_hail_jsonl("s-raw")); - let register_out = run( - tmp.path(), - &repo, - &["register", "--quiet", input.to_str().expect("path")], - ); - let local_uri = first_non_empty_line(®ister_out.stdout); - - let build = run( - tmp.path(), - &repo, - &["handoff", "build", "--from", &local_uri], - ); - let artifact_uri = first_non_empty_line(&build.stdout); - - let get = run( - tmp.path(), - &repo, - &[ - "handoff", - "artifacts", - "get", - &artifact_uri, - "--format", - "raw", - "--encode", - "jsonl", - ], - ); - assert!(get.status.success()); - let first_line = String::from_utf8_lossy(&get.stdout) - .lines() - .next() - .unwrap_or_default() - .to_string(); - let row: Value = serde_json::from_str(&first_line).expect("json row"); - assert!(row.get("session_id").is_some()); -} - -#[test] -fn testing_helper_agent_is_available() { - // Keep one assertion using opensession_core::testing to ensure dev-dependency path stays valid. - let agent = testing::agent(); - assert!(!agent.tool.is_empty()); -} +fn setup_review_fixture_with_auxiliary( + tmp: &tempfile::TempDir, + fetch_hidden_refs: bool, +) -> (std::path::PathBuf, String) { + setup_review_fixture_with_options(tmp, fetch_hidden_refs, true) +} + +#[path = "handoff_cli/cleanup_cli.rs"] +mod cleanup_cli; +#[path = "handoff_cli/handoff_cases.rs"] +mod handoff_cases; +#[path = "handoff_cli/help_cli.rs"] +mod help_cli; +#[path = "handoff_cli/inspect_cli.rs"] +mod inspect_cli; +#[path = "handoff_cli/parse_cli.rs"] +mod parse_cli; +#[path = "handoff_cli/review_cli.rs"] +mod review_cli; +#[path = "handoff_cli/setup_cli.rs"] +mod setup_cli; +#[path = "handoff_cli/share_cli.rs"] +mod share_cli; +#[path = "handoff_cli/view_cli.rs"] +mod view_cli; diff --git a/crates/cli/tests/handoff_cli/cleanup_cli.rs b/crates/cli/tests/handoff_cli/cleanup_cli.rs new file mode 100644 index 00000000..dcf47d46 --- /dev/null +++ b/crates/cli/tests/handoff_cli/cleanup_cli.rs @@ -0,0 +1,416 @@ +use super::*; + +#[test] +fn cleanup_init_github_writes_expected_files() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + let remote = tmp.path().join("cleanup-github-remote.git"); + init_git_repo(&repo); + run_git( + tmp.path(), + &["init", "--bare", remote.to_str().expect("remote")], + ); + run_git( + &repo, + &["remote", "add", "origin", remote.to_str().expect("remote")], + ); + + let out = run( + tmp.path(), + &repo, + &["cleanup", "init", "--provider", "github", "--yes", "--json"], + ); + assert!( + out.status.success(), + "cleanup init failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let payload: Value = serde_json::from_slice(&out.stdout).expect("cleanup init json"); + assert_eq!( + payload.get("configured").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + payload.get("provider").and_then(Value::as_str), + Some("github") + ); + + assert!( + repo.join(".opensession") + .join("cleanup") + .join("config.toml") + .exists(), + "expected cleanup config to exist" + ); + assert!( + repo.join(".opensession") + .join("cleanup") + .join("janitor.sh") + .exists(), + "expected janitor script to exist" + ); + assert!( + repo.join(".github") + .join("workflows") + .join("opensession-cleanup.yml") + .exists(), + "expected github workflow template to exist" + ); + assert!( + repo.join(".github") + .join("workflows") + .join("opensession-session-review.yml") + .exists(), + "expected github session review workflow template to exist" + ); +} + +#[test] +fn cleanup_init_persists_session_archive_branch_setting() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + let remote = tmp.path().join("cleanup-archive-remote.git"); + init_git_repo(&repo); + run_git( + tmp.path(), + &["init", "--bare", remote.to_str().expect("remote")], + ); + run_git( + &repo, + &["remote", "add", "origin", remote.to_str().expect("remote")], + ); + + let out = run( + tmp.path(), + &repo, + &[ + "cleanup", + "init", + "--provider", + "github", + "--session-archive-branch", + "pr/sessions", + "--yes", + "--json", + ], + ); + assert!( + out.status.success(), + "cleanup init failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let payload: Value = serde_json::from_slice(&out.stdout).expect("cleanup init json"); + assert_eq!( + payload + .get("session_archive_branch") + .and_then(Value::as_str), + Some("pr/sessions") + ); + + let config_body = fs::read_to_string( + repo.join(".opensession") + .join("cleanup") + .join("config.toml"), + ) + .expect("read cleanup config"); + assert!(config_body.contains("session_archive_branch = \"pr/sessions\"")); + + let workflow_body = fs::read_to_string( + repo.join(".github") + .join("workflows") + .join("opensession-session-review.yml"), + ) + .expect("read review workflow"); + assert!(workflow_body.contains("session_archive_branch")); + assert!(workflow_body.contains("Delete ephemeral artifact branch")); + assert!(workflow_body.contains("github.event.pull_request.user.type != 'Bot'")); +} + +#[test] +fn cleanup_init_gitlab_without_marker_reports_manual_steps() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + let remote = tmp.path().join("cleanup-gitlab-remote.git"); + init_git_repo(&repo); + run_git( + tmp.path(), + &["init", "--bare", remote.to_str().expect("remote")], + ); + run_git( + &repo, + &["remote", "add", "origin", remote.to_str().expect("remote")], + ); + + write_file(&repo.join(".gitlab-ci.yml"), "stages:\n - test\n"); + + let out = run( + tmp.path(), + &repo, + &["cleanup", "init", "--provider", "gitlab", "--yes", "--json"], + ); + assert!( + out.status.success(), + "cleanup init failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let payload: Value = serde_json::from_slice(&out.stdout).expect("cleanup init json"); + let manual_steps = payload + .get("manual_steps") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + assert!( + !manual_steps.is_empty(), + "expected manual steps for gitlab-ci" + ); + + assert!( + repo.join(".gitlab") + .join("opensession-cleanup.yml") + .exists(), + "expected gitlab cleanup template to exist" + ); + assert!( + repo.join(".gitlab") + .join("opensession-session-review.yml") + .exists(), + "expected gitlab session review template to exist" + ); + + let gitlab_ci = fs::read_to_string(repo.join(".gitlab-ci.yml")).expect("read gitlab-ci"); + assert!(gitlab_ci.contains("stages:")); + assert!(!gitlab_ci.contains("opensession-managed-cleanup")); +} + +#[test] +fn cleanup_status_reports_not_configured_then_configured() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + let remote = tmp.path().join("cleanup-status-remote.git"); + init_git_repo(&repo); + run_git( + tmp.path(), + &["init", "--bare", remote.to_str().expect("remote")], + ); + run_git( + &repo, + &["remote", "add", "origin", remote.to_str().expect("remote")], + ); + + let status_before = run(tmp.path(), &repo, &["cleanup", "status", "--json"]); + assert!( + status_before.status.success(), + "cleanup status failed: {}", + String::from_utf8_lossy(&status_before.stderr) + ); + let before_payload: Value = + serde_json::from_slice(&status_before.stdout).expect("cleanup status json"); + assert_eq!( + before_payload.get("configured").and_then(Value::as_bool), + Some(false) + ); + + let init = run( + tmp.path(), + &repo, + &["cleanup", "init", "--provider", "generic", "--yes"], + ); + assert!( + init.status.success(), + "cleanup init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + + let status_after = run(tmp.path(), &repo, &["cleanup", "status", "--json"]); + assert!( + status_after.status.success(), + "cleanup status failed: {}", + String::from_utf8_lossy(&status_after.stderr) + ); + let after_payload: Value = + serde_json::from_slice(&status_after.stdout).expect("cleanup status json"); + assert_eq!( + after_payload.get("configured").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + after_payload.get("provider").and_then(Value::as_str), + Some("generic") + ); +} + +#[test] +fn cleanup_run_without_init_shows_next_steps() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run(tmp.path(), &repo, &["cleanup", "run"]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("cleanup janitor is not configured")); + assert!(stderr.contains("next:")); + assert!(stderr.contains("opensession cleanup init --provider auto")); +} + +#[test] +fn cleanup_run_dry_and_apply_handles_hidden_and_artifact_refs() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + let remote = tmp.path().join("cleanup-remote.git"); + init_git_repo(&repo); + + run_git( + tmp.path(), + &["init", "--bare", remote.to_str().expect("remote")], + ); + run_git( + &repo, + &["remote", "add", "origin", remote.to_str().expect("remote")], + ); + run_git(&repo, &["push", "origin", "main:main"]); + + let head_sha = first_non_empty_line(&run_git(&repo, &["rev-parse", "HEAD"]).stdout); + let ledger_ref = opensession_git_native::branch_ledger_ref("stale/branch"); + let session_body = make_hail_jsonl("s-cleanup"); + let meta_body = serde_json::json!({ + "schema_version": 2, + "session_id": "s-cleanup", + "git": { "commits": [head_sha.clone()] } + }) + .to_string(); + opensession_git_native::NativeGitStorage + .store_session_at_ref( + &repo, + &ledger_ref, + "s-cleanup", + session_body.as_bytes(), + meta_body.as_bytes(), + std::slice::from_ref(&head_sha), + ) + .expect("store hidden session"); + run_git( + &repo, + &["push", "origin", &format!("{ledger_ref}:{ledger_ref}")], + ); + + run_git(&repo, &["checkout", "-b", "opensession/pr-77-sessions"]); + write_file(&repo.join("artifact.txt"), "artifact branch\n"); + run_git(&repo, &["add", "."]); + run_git(&repo, &["commit", "-m", "artifact branch"]); + run_git( + &repo, + &[ + "push", + "origin", + "opensession/pr-77-sessions:opensession/pr-77-sessions", + ], + ); + run_git(&repo, &["checkout", "main"]); + + run_git(&repo, &["checkout", "-b", "pr/sessions"]); + write_file(&repo.join("archive.txt"), "persistent archive branch\n"); + run_git(&repo, &["add", "."]); + run_git(&repo, &["commit", "-m", "archive branch"]); + run_git(&repo, &["push", "origin", "pr/sessions:pr/sessions"]); + run_git(&repo, &["checkout", "main"]); + + let init_out = run( + tmp.path(), + &repo, + &[ + "cleanup", + "init", + "--provider", + "generic", + "--remote", + "origin", + "--hidden-ttl-days", + "0", + "--artifact-ttl-days", + "0", + "--yes", + ], + ); + assert!( + init_out.status.success(), + "cleanup init failed: {}", + String::from_utf8_lossy(&init_out.stderr) + ); + + let dry_run = run(tmp.path(), &repo, &["cleanup", "run", "--json"]); + assert!( + dry_run.status.success(), + "cleanup dry-run failed: {}", + String::from_utf8_lossy(&dry_run.stderr) + ); + let dry_payload: Value = serde_json::from_slice(&dry_run.stdout).expect("cleanup run json"); + assert!( + dry_payload + .get("hidden_candidates") + .and_then(Value::as_array) + .map(|items| !items.is_empty()) + .unwrap_or(false), + "expected hidden ref candidate in dry-run" + ); + assert!( + dry_payload + .get("artifact_candidates") + .and_then(Value::as_array) + .map(|items| !items.is_empty()) + .unwrap_or(false), + "expected artifact branch candidate in dry-run" + ); + assert!( + !dry_payload + .get("artifact_candidates") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|item| item + .as_str() + .unwrap_or_default() + .contains("refs/heads/pr/sessions")), + "persistent archive branch should not be targeted by janitor" + ); + + let apply_out = run(tmp.path(), &repo, &["cleanup", "run", "--apply", "--json"]); + assert!( + apply_out.status.success(), + "cleanup apply failed: {}", + String::from_utf8_lossy(&apply_out.stderr) + ); + let apply_payload: Value = serde_json::from_slice(&apply_out.stdout).expect("cleanup run json"); + let deleted = apply_payload + .get("deleted") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + assert!( + deleted.iter().any(|item| { + item.as_str() + .unwrap_or_default() + .contains("refs/opensession/branches/") + }), + "expected hidden ref deletion" + ); + assert!( + deleted.iter().any(|item| { + item.as_str() + .unwrap_or_default() + .contains("refs/heads/opensession/pr-77-sessions") + }), + "expected artifact branch deletion" + ); + assert!( + !deleted.iter().any(|item| { + item.as_str() + .unwrap_or_default() + .contains("refs/heads/pr/sessions") + }), + "persistent archive branch should not be deleted" + ); +} diff --git a/crates/cli/tests/handoff_cli/handoff_cases.rs b/crates/cli/tests/handoff_cli/handoff_cases.rs new file mode 100644 index 00000000..412253b0 --- /dev/null +++ b/crates/cli/tests/handoff_cli/handoff_cases.rs @@ -0,0 +1,141 @@ +use super::*; + +#[test] +fn handoff_build_get_verify_pin_unpin_rm() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input_a = repo.join("a.hail.jsonl"); + let input_b = repo.join("b.hail.jsonl"); + write_file(&input_a, &make_hail_jsonl("s-a")); + write_file(&input_b, &make_hail_jsonl("s-b")); + + let reg_a = run( + tmp.path(), + &repo, + &["register", "--quiet", input_a.to_str().expect("path")], + ); + let reg_b = run( + tmp.path(), + &repo, + &["register", "--quiet", input_b.to_str().expect("path")], + ); + let uri_a = first_non_empty_line(®_a.stdout); + let uri_b = first_non_empty_line(®_b.stdout); + + let build = run( + tmp.path(), + &repo, + &[ + "handoff", + "build", + "--from", + &uri_a, + "--from", + &uri_b, + "--pin", + "latest", + "--validate", + ], + ); + assert!( + build.status.success(), + "handoff build failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + let artifact_uri = first_non_empty_line(&build.stdout); + assert!(artifact_uri.starts_with("os://artifact/")); + + let get_json = run( + tmp.path(), + &repo, + &[ + "handoff", + "artifacts", + "get", + &artifact_uri, + "--format", + "canonical", + "--encode", + "json", + ], + ); + assert!(get_json.status.success()); + let parsed: Value = serde_json::from_slice(&get_json.stdout).expect("json output"); + assert!(parsed.as_array().is_some()); + + let verify = run( + tmp.path(), + &repo, + &["handoff", "artifacts", "verify", &artifact_uri], + ); + assert!(verify.status.success()); + + let rm_pinned = run( + tmp.path(), + &repo, + &["handoff", "artifacts", "rm", &artifact_uri], + ); + assert!(!rm_pinned.status.success()); + + let unpin = run( + tmp.path(), + &repo, + &["handoff", "artifacts", "unpin", "latest"], + ); + assert!(unpin.status.success()); + + let rm = run( + tmp.path(), + &repo, + &["handoff", "artifacts", "rm", &artifact_uri], + ); + assert!(rm.status.success()); +} + +#[test] +fn handoff_get_raw_jsonl_outputs_session_json_rows() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-raw")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let build = run( + tmp.path(), + &repo, + &["handoff", "build", "--from", &local_uri], + ); + let artifact_uri = first_non_empty_line(&build.stdout); + + let get = run( + tmp.path(), + &repo, + &[ + "handoff", + "artifacts", + "get", + &artifact_uri, + "--format", + "raw", + "--encode", + "jsonl", + ], + ); + assert!(get.status.success()); + let first_line = String::from_utf8_lossy(&get.stdout) + .lines() + .next() + .unwrap_or_default() + .to_string(); + let row: Value = serde_json::from_str(&first_line).expect("json row"); + assert!(row.get("session_id").is_some()); +} diff --git a/crates/cli/tests/handoff_cli/help_cli.rs b/crates/cli/tests/handoff_cli/help_cli.rs new file mode 100644 index 00000000..c47266a2 --- /dev/null +++ b/crates/cli/tests/handoff_cli/help_cli.rs @@ -0,0 +1,126 @@ +use super::*; + +#[test] +fn help_shows_v1_commands() { + let tmp = make_home(); + let output = run(tmp.path(), tmp.path(), &["--help"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase(); + assert!(stdout.contains("register")); + assert!(stdout.contains("share")); + assert!(stdout.contains("view")); + assert!(stdout.contains("handoff")); + assert!(!stdout.contains("\n setup ")); + assert!(!stdout.contains("publish")); + assert!(stdout.contains("first-user flow (5 minutes):")); + assert!(stdout.contains("opensession docs quickstart")); + assert!(stdout.contains("common next steps:")); + assert!(stdout.contains("opensession doctor --fix")); +} + +#[test] +fn parse_help_shows_recovery_examples() { + let tmp = make_home(); + let output = run(tmp.path(), tmp.path(), &["parse", "--help"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Recovery examples:")); + assert!(stdout.contains("opensession parse --profile codex ./raw-session.jsonl --preview")); + assert!(stdout.contains( + "opensession parse --profile codex ./raw-session.jsonl --out ./session.hail.jsonl" + )); +} + +#[test] +fn share_help_shows_recovery_examples() { + let tmp = make_home(); + let output = run(tmp.path(), tmp.path(), &["share", "--help"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Recovery examples:")); + assert!(stdout.contains("opensession share os://src/local/ --git --remote origin")); + assert!(stdout.contains( + "opensession share os://src/git//ref//path/ --web" + )); +} + +#[test] +fn view_help_shows_recovery_examples() { + let tmp = make_home(); + let output = run(tmp.path(), tmp.path(), &["view", "--help"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Recovery examples:")); + assert!(stdout.contains("opensession view --no-open")); + assert!(stdout.contains("opensession view os://src/local/ --no-open")); + assert!(stdout.contains("opensession view ./session.hail.jsonl --no-open")); + assert!(stdout.contains("opensession view HEAD~3..HEAD --no-open")); +} + +#[test] +fn doctor_help_shows_recovery_examples() { + let tmp = make_home(); + let output = run(tmp.path(), tmp.path(), &["doctor", "--help"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Recovery examples:")); + assert!(stdout.contains("opensession doctor --fix --profile local")); + assert!(stdout.contains("opensession doctor --fix --yes --profile app")); + assert!(stdout.contains("opensession docs quickstart")); +} + +#[test] +fn doctor_yes_without_fix_shows_next_steps() { + let tmp = make_home(); + let output = run(tmp.path(), tmp.path(), &["doctor", "--yes"]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("`--yes` requires `--fix`")); + assert!(stderr.contains("next:")); + assert!( + stderr.contains("opensession doctor --fix --yes --profile local --fanout-mode hidden_ref") + ); +} + +#[test] +fn doctor_fanout_without_fix_shows_next_steps() { + let tmp = make_home(); + let output = run( + tmp.path(), + tmp.path(), + &["doctor", "--fanout-mode", "hidden_ref"], + ); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("`--fanout-mode` requires `--fix`")); + assert!(stderr.contains("next:")); + assert!(stderr.contains("opensession doctor --fix --fanout-mode hidden_ref")); +} + +#[test] +fn docs_completion_still_available() { + let tmp = make_home(); + let out = run(tmp.path(), tmp.path(), &["docs", "completion", "bash"]); + assert!(out.status.success()); + assert!(!out.stdout.is_empty()); +} + +#[test] +fn docs_quickstart_prints_first_user_flow() { + let tmp = make_home(); + let out = run(tmp.path(), tmp.path(), &["docs", "quickstart"]); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("OpenSession 5-minute first-user flow")); + assert!(stdout.contains("opensession doctor --fix")); + assert!(stdout.contains("opensession parse --profile codex")); + assert!(stdout.contains("opensession register ./session.hail.jsonl")); + assert!(stdout.contains("opensession share os://src/local/ --quick --remote origin")); +} + +#[test] +fn testing_helper_agent_is_available() { + // Keep one assertion using opensession_core::testing to ensure dev-dependency path stays valid. + let agent = testing::agent(); + assert!(!agent.tool.is_empty()); +} diff --git a/crates/cli/tests/handoff_cli/inspect_cli.rs b/crates/cli/tests/handoff_cli/inspect_cli.rs new file mode 100644 index 00000000..09436002 --- /dev/null +++ b/crates/cli/tests/handoff_cli/inspect_cli.rs @@ -0,0 +1,37 @@ +use super::*; + +#[test] +fn inspect_local_and_artifact_json() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-inspect")); + + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let inspect_local = run(tmp.path(), &repo, &["inspect", &local_uri, "--json"]); + assert!(inspect_local.status.success()); + let local_json: Value = serde_json::from_slice(&inspect_local.stdout).expect("inspect local"); + assert_eq!(local_json["uri"], local_uri); + + let build = run( + tmp.path(), + &repo, + &["handoff", "build", "--from", &local_uri], + ); + assert!(build.status.success()); + let artifact_uri = first_non_empty_line(&build.stdout); + + let inspect_artifact = run(tmp.path(), &repo, &["inspect", &artifact_uri, "--json"]); + assert!(inspect_artifact.status.success()); + let artifact_json: Value = + serde_json::from_slice(&inspect_artifact.stdout).expect("inspect artifact"); + assert_eq!(artifact_json["uri"], artifact_uri); +} diff --git a/crates/cli/tests/handoff_cli/parse_cli.rs b/crates/cli/tests/handoff_cli/parse_cli.rs new file mode 100644 index 00000000..2ba9f2e6 --- /dev/null +++ b/crates/cli/tests/handoff_cli/parse_cli.rs @@ -0,0 +1,125 @@ +use super::*; + +#[test] +fn register_rejects_non_hail_with_next_steps() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("not-hail.txt"); + write_file(&input, "this is not hail jsonl\n"); + + let out = run( + tmp.path(), + &repo, + &["register", input.to_str().expect("path")], + ); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("register expects canonical HAIL JSONL")); + assert!(stderr.contains("next:")); + assert!(stderr.contains("opensession parse --profile codex")); +} + +#[test] +fn parse_invalid_profile_shows_next_steps() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let raw = repo.join("raw-session.jsonl"); + write_file(&raw, "{\"not\":\"a valid session format\"}\n"); + + let out = run( + tmp.path(), + &repo, + &[ + "parse", + "--profile", + "unknown-profile", + raw.to_str().expect("path"), + ], + ); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("next:")); + assert!(stderr.contains("opensession parse --help")); +} + +#[test] +fn parse_profile_codex_outputs_canonical_jsonl() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("codex.jsonl"); + write_file( + &input, + r#"{"type":"session_meta","session_id":"abc","timestamp":"2026-02-14T00:00:00Z"} +{"type":"response_item","timestamp":"2026-02-14T00:00:01Z","payload":{"type":"message","role":"user","content":"hello"}}"#, + ); + + let out = run( + tmp.path(), + &repo, + &[ + "parse", + "--profile", + "codex", + input.to_str().expect("path"), + "--validate", + ], + ); + assert!( + out.status.success(), + "parse failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let parsed = Session::from_jsonl(&String::from_utf8_lossy(&out.stdout)).expect("jsonl"); + assert_eq!(parsed.version, Session::CURRENT_VERSION); + assert_eq!(parsed.agent.tool, "codex"); +} + +#[test] +fn parse_preview_option_prints_parser_used() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-preview")); + + let out = run( + tmp.path(), + &repo, + &[ + "parse", + "--profile", + "hail", + "--preview", + input.to_str().expect("path"), + ], + ); + assert!(out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("parser_used:")); +} + +#[test] +fn canonical_jsonl_register_rejects_non_hail_input() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("raw.jsonl"); + write_file(&input, "{\"type\":\"session_meta\"}\n"); + + let out = run( + tmp.path(), + &repo, + &["register", input.to_str().expect("path")], + ); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("opensession parse")); +} diff --git a/crates/cli/tests/handoff_cli/review_cli.rs b/crates/cli/tests/handoff_cli/review_cli.rs new file mode 100644 index 00000000..7a152fc3 --- /dev/null +++ b/crates/cli/tests/handoff_cli/review_cli.rs @@ -0,0 +1,130 @@ +use super::*; + +#[test] +fn review_rejects_removed_tui_view_mode() { + let tmp = make_home(); + let (reviewer_repo, pr_link) = setup_review_fixture(&tmp, true); + + let out = run( + tmp.path(), + &reviewer_repo, + &["review", &pr_link, "--view", "tui", "--no-fetch"], + ); + assert!( + !out.status.success(), + "review --view tui should be rejected" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("invalid value 'tui'"), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn review_json_builds_commit_grouped_bundle_from_hidden_refs() { + let tmp = make_home(); + let (reviewer_repo, pr_link) = setup_review_fixture(&tmp, true); + + let out = run( + tmp.path(), + &reviewer_repo, + &["review", &pr_link, "--json", "--no-fetch"], + ); + assert!( + out.status.success(), + "review failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let payload: Value = serde_json::from_slice(&out.stdout).expect("review json payload"); + assert_eq!(payload["commit_count"].as_u64().unwrap_or(0), 1); + assert_eq!(payload["mapped_commit_count"].as_u64().unwrap_or(0), 1); + assert!(payload["session_count"].as_u64().unwrap_or(0) >= 1); + + let bundle_path = payload["bundle_path"] + .as_str() + .expect("bundle path in payload"); + let bundle_raw = fs::read(bundle_path).expect("read review bundle"); + let bundle_json: Value = serde_json::from_slice(&bundle_raw).expect("bundle json"); + let first_commit = bundle_json["commits"] + .as_array() + .and_then(|rows| rows.first()) + .expect("first commit row"); + let first_session = first_commit["session_ids"] + .as_array() + .and_then(|rows| rows.first()) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert_eq!(first_session, "s-review"); + assert!( + first_commit["semantic_summary"].is_object(), + "expected commit semantic_summary payload" + ); +} + +#[test] +fn review_no_fetch_succeeds_with_empty_session_groups_when_hidden_refs_missing() { + let tmp = make_home(); + let (reviewer_repo, pr_link) = setup_review_fixture(&tmp, false); + + let out = run( + tmp.path(), + &reviewer_repo, + &["review", &pr_link, "--json", "--no-fetch"], + ); + assert!( + out.status.success(), + "review should succeed without hidden refs: {}", + String::from_utf8_lossy(&out.stderr) + ); + let payload: Value = serde_json::from_slice(&out.stdout).expect("review json payload"); + assert_eq!(payload["commit_count"].as_u64().unwrap_or(0), 1); + assert_eq!(payload["mapped_commit_count"].as_u64().unwrap_or(0), 0); + assert_eq!(payload["session_count"].as_u64().unwrap_or(0), 0); + + let bundle_path = payload["bundle_path"] + .as_str() + .expect("bundle path in payload"); + let bundle_raw = fs::read(bundle_path).expect("read review bundle"); + let bundle_json: Value = serde_json::from_slice(&bundle_raw).expect("bundle json"); + let first_commit = bundle_json["commits"] + .as_array() + .and_then(|rows| rows.first()) + .expect("first commit row"); + assert!( + first_commit["semantic_summary"].is_object(), + "expected semantic summary even when mapped sessions are absent" + ); +} + +#[test] +fn review_json_ignores_auxiliary_hidden_ref_sessions() { + let tmp = make_home(); + let (reviewer_repo, pr_link) = setup_review_fixture_with_auxiliary(&tmp, true); + + let out = run( + tmp.path(), + &reviewer_repo, + &["review", &pr_link, "--json", "--no-fetch"], + ); + assert!( + out.status.success(), + "review failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let payload: Value = serde_json::from_slice(&out.stdout).expect("review json payload"); + assert_eq!(payload["session_count"].as_u64().unwrap_or(0), 1); + + let bundle_path = payload["bundle_path"] + .as_str() + .expect("bundle path in payload"); + let bundle_raw = fs::read(bundle_path).expect("read review bundle"); + let bundle_json: Value = serde_json::from_slice(&bundle_raw).expect("bundle json"); + let session_ids = bundle_json["commits"][0]["session_ids"] + .as_array() + .expect("session ids"); + assert_eq!(session_ids.len(), 1); + assert_eq!(session_ids[0].as_str().unwrap_or_default(), "s-review"); +} diff --git a/crates/cli/tests/handoff_cli/setup_cli.rs b/crates/cli/tests/handoff_cli/setup_cli.rs new file mode 100644 index 00000000..a7cb2476 --- /dev/null +++ b/crates/cli/tests/handoff_cli/setup_cli.rs @@ -0,0 +1,384 @@ +use super::*; + +#[test] +fn setup_non_tty_requires_yes() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run(tmp.path(), &repo, &["setup"]); + assert!( + !out.status.success(), + "setup should require --yes in non-tty mode" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("requires explicit approval")); + assert!( + stderr.contains("opensession doctor --fix --yes --profile local --fanout-mode hidden_ref") + ); +} + +#[test] +fn setup_non_tty_yes_requires_explicit_fanout_when_unset() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run(tmp.path(), &repo, &["setup", "--yes"]); + assert!( + !out.status.success(), + "setup --yes should fail without explicit fanout when repo has no fanout config" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("fanout mode is not configured")); + assert!( + stderr.contains("opensession doctor --fix --yes --profile local --fanout-mode hidden_ref") + ); +} + +#[test] +fn setup_yes_with_fanout_installs_pre_push_hook_with_original_copy() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + write_file( + &repo.join(".git").join("hooks").join("pre-push"), + "#!/bin/sh\necho custom\n", + ); + + let out = run( + tmp.path(), + &repo, + &["setup", "--yes", "--fanout-mode", "hidden_ref"], + ); + assert!( + out.status.success(), + "setup failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let backup = repo + .join(".git") + .join("hooks") + .join("pre-push.original.pre-opensession"); + assert!(backup.exists(), "expected original hook copy"); + let shim = tmp + .path() + .join(".local") + .join("share") + .join("opensession") + .join("bin") + .join("opensession"); + assert!(shim.exists(), "expected setup to install opensession shim"); + let ops_shim = tmp + .path() + .join(".local") + .join("share") + .join("opensession") + .join("bin") + .join("ops"); + assert!(ops_shim.exists(), "expected setup to install ops shim"); + + let hook_body = fs::read_to_string(repo.join(".git").join("hooks").join("pre-push")) + .expect("read pre-push hook"); + assert!(hook_body.contains("opensession-managed")); + assert!(hook_body.contains("setup --sync-branch-session")); + assert!(hook_body.contains("--sync-branch-commit")); + assert!(hook_body.contains("setup --print-ledger-ref")); + assert!(hook_body.contains("setup --print-fanout-mode")); + assert!(hook_body.contains("git notes --ref=opensession")); + assert!(hook_body.contains("git notes --ref=opensession copy -f")); + assert!(hook_body.contains(".local/share/opensession/bin/opensession")); + + let fanout = run_git( + &repo, + &["config", "--local", "--get", "opensession.fanout-mode"], + ); + assert_eq!(String::from_utf8_lossy(&fanout.stdout).trim(), "hidden_ref"); + let open_target = run_git( + &repo, + &["config", "--local", "--get", "opensession.open-target"], + ); + assert_eq!(String::from_utf8_lossy(&open_target.stdout).trim(), "web"); +} + +#[test] +fn doctor_fix_yes_with_fanout_mode_applies_setup() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run( + tmp.path(), + &repo, + &[ + "doctor", + "--fix", + "--yes", + "--fanout-mode", + "git_notes", + "--open-target", + "web", + ], + ); + assert!( + out.status.success(), + "doctor --fix failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let fanout = run_git( + &repo, + &["config", "--local", "--get", "opensession.fanout-mode"], + ); + assert_eq!(String::from_utf8_lossy(&fanout.stdout).trim(), "git_notes"); + let open_target = run_git( + &repo, + &["config", "--local", "--get", "opensession.open-target"], + ); + assert_eq!(String::from_utf8_lossy(&open_target.stdout).trim(), "web"); + assert!( + repo.join(".git").join("hooks").join("pre-push").exists(), + "expected pre-push hook to be installed by doctor --fix" + ); +} + +#[test] +fn setup_check_prints_expected_ledger_ref() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run(tmp.path(), &repo, &["setup", "--check"]); + assert!( + out.status.success(), + "setup --check failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("current branch:")); + assert!(stdout.contains("main")); + assert!(stdout.contains(&opensession_git_native::branch_ledger_ref("main"))); + assert!(stdout.contains("ops shim:")); + assert!(stdout.contains("review readiness:")); +} + +#[test] +fn setup_print_fanout_mode_reads_git_config() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run(tmp.path(), &repo, &["setup", "--print-fanout-mode"]); + assert!( + out.status.success(), + "print-fanout-mode failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hidden_ref"); + + let git_config = Command::new("git") + .arg("-C") + .arg(&repo) + .arg("config") + .arg("--local") + .arg("opensession.fanout-mode") + .arg("git_notes") + .output() + .expect("set git config"); + assert!( + git_config.status.success(), + "{}", + String::from_utf8_lossy(&git_config.stderr) + ); + + let out = run(tmp.path(), &repo, &["setup", "--print-fanout-mode"]); + assert!( + out.status.success(), + "print-fanout-mode failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "git_notes"); +} + +#[test] +fn setup_sync_branch_session_stores_latest_repo_session_to_hidden_ref() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let head_sha = first_non_empty_line(&run_git(&repo, &["rev-parse", "HEAD"]).stdout); + let session_path = tmp + .path() + .join(".codex") + .join("sessions") + .join("2026") + .join("02") + .join("26") + .join("rollout-2026-02-26T00-00-00-sync-session-1.jsonl"); + write_file( + &session_path, + &make_hail_jsonl_with_cwd("sync-session-1", &repo), + ); + + let out = run( + tmp.path(), + &repo, + &[ + "setup", + "--sync-branch-session", + "main", + "--sync-branch-commit", + &head_sha, + ], + ); + assert!( + out.status.success(), + "sync branch session failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let ledger_ref = opensession_git_native::branch_ledger_ref("main"); + let verify = Command::new("git") + .arg("-C") + .arg(&repo) + .arg("show-ref") + .arg("--verify") + .arg("--quiet") + .arg(&ledger_ref) + .output() + .expect("verify ledger ref exists"); + assert!( + verify.status.success(), + "expected ledger ref to exist after sync" + ); + + let index_blob = run_git( + &repo, + &[ + "show", + &format!("{ledger_ref}:v1/index/commits/{head_sha}/sync-session-1.json"), + ], + ); + let index_body = String::from_utf8_lossy(&index_blob.stdout); + assert!(index_body.contains("\"session_id\":\"sync-session-1\"")); +} + +#[test] +fn setup_sync_branch_session_maps_single_session_to_multiple_commits() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + write_file(&repo.join("a.txt"), "a\n"); + run_git(&repo, &["add", "."]); + run_git(&repo, &["commit", "-m", "feat: a"]); + let commit_a = first_non_empty_line(&run_git(&repo, &["rev-parse", "HEAD"]).stdout); + + write_file(&repo.join("b.txt"), "b\n"); + run_git(&repo, &["add", "."]); + run_git(&repo, &["commit", "-m", "feat: b"]); + let commit_b = first_non_empty_line(&run_git(&repo, &["rev-parse", "HEAD"]).stdout); + + let session_path = tmp + .path() + .join(".codex") + .join("sessions") + .join("2026") + .join("02") + .join("26") + .join("rollout-2026-02-26T00-00-00-sync-session-multi.jsonl"); + let created = chrono::Utc::now() - chrono::Duration::hours(2); + let updated = chrono::Utc::now() + chrono::Duration::hours(2); + write_file( + &session_path, + &make_hail_jsonl_with_cwd_and_window("sync-session-multi", &repo, created, updated), + ); + + let out = run( + tmp.path(), + &repo, + &[ + "setup", + "--sync-branch-session", + "main", + "--sync-branch-commit", + &commit_b, + ], + ); + assert!( + out.status.success(), + "sync branch session failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let ledger_ref = opensession_git_native::branch_ledger_ref("main"); + run_git( + &repo, + &[ + "show", + &format!("{ledger_ref}:v1/index/commits/{commit_a}/sync-session-multi.json"), + ], + ); + run_git( + &repo, + &[ + "show", + &format!("{ledger_ref}:v1/index/commits/{commit_b}/sync-session-multi.json"), + ], + ); +} + +#[test] +fn setup_sync_branch_session_skips_auxiliary_sessions() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let head_sha = first_non_empty_line(&run_git(&repo, &["rev-parse", "HEAD"]).stdout); + let session_path = tmp + .path() + .join(".codex") + .join("sessions") + .join("2026") + .join("02") + .join("26") + .join("rollout-2026-02-26T00-00-00-sync-session-aux.jsonl"); + write_file( + &session_path, + &make_auxiliary_hail_jsonl_with_cwd("sync-session-aux", &repo, "parent-session"), + ); + + let out = run( + tmp.path(), + &repo, + &[ + "setup", + "--sync-branch-session", + "main", + "--sync-branch-commit", + &head_sha, + ], + ); + assert!( + out.status.success(), + "sync branch session failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let ledger_ref = opensession_git_native::branch_ledger_ref("main"); + let verify = Command::new("git") + .arg("-C") + .arg(&repo) + .arg("show-ref") + .arg("--verify") + .arg("--quiet") + .arg(&ledger_ref) + .output() + .expect("verify ledger ref absence"); + assert!( + !verify.status.success(), + "auxiliary sessions should not create a hidden ledger ref" + ); +} diff --git a/crates/cli/tests/handoff_cli/share_cli.rs b/crates/cli/tests/handoff_cli/share_cli.rs new file mode 100644 index 00000000..78d019be --- /dev/null +++ b/crates/cli/tests/handoff_cli/share_cli.rs @@ -0,0 +1,610 @@ +use super::*; + +#[test] +fn register_and_cat_roundtrip() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-register")); + + let register_out = run( + tmp.path(), + &repo, + &["register", input.to_str().expect("path")], + ); + assert!( + register_out.status.success(), + "register failed: {}", + String::from_utf8_lossy(®ister_out.stderr) + ); + let uri = first_non_empty_line(®ister_out.stdout); + assert!(uri.starts_with("os://src/local/")); + + let cat_out = run(tmp.path(), &repo, &["cat", &uri]); + assert!(cat_out.status.success()); + let cat_body = String::from_utf8_lossy(&cat_out.stdout); + let parsed = Session::from_jsonl(&cat_body).expect("cat output is valid jsonl"); + assert_eq!(parsed.session_id, "s-register"); +} + +#[test] +fn share_web_rejects_local_uri() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-share-web")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let output = run(tmp.path(), &repo, &["share", &local_uri, "--web"]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("next:")); + assert!(stderr.contains("--git --remote")); +} + +#[test] +fn share_git_requires_remote_guidance() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-share-missing-remote")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let share_out = run(tmp.path(), &repo, &["share", &local_uri, "--git"]); + assert!(!share_out.status.success()); + let stderr = String::from_utf8_lossy(&share_out.stderr); + assert!(stderr.contains("`--remote ` is required")); + assert!(stderr.contains("next:")); + assert!(stderr.contains("opensession share --git --remote origin")); +} + +#[test] +fn share_quick_auto_detects_origin_without_push_and_reports_state() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + let remote = tmp.path().join("remote.git"); + init_git_repo(&repo); + run_git( + tmp.path(), + &["init", "--bare", remote.to_str().expect("remote")], + ); + run_git( + &repo, + &["remote", "add", "origin", remote.to_str().expect("remote")], + ); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-share-quick-no-push")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let share_out = run( + tmp.path(), + &repo, + &["share", &local_uri, "--quick", "--json"], + ); + assert!( + share_out.status.success(), + "share --quick failed: {}", + String::from_utf8_lossy(&share_out.stderr) + ); + let payload: Value = serde_json::from_slice(&share_out.stdout).expect("quick share json"); + assert_eq!(payload.get("quick").and_then(Value::as_bool), Some(true)); + assert_eq!(payload.get("pushed").and_then(Value::as_bool), Some(false)); + assert_eq!( + payload.get("auto_push_consent").and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + payload.get("remote_target").and_then(Value::as_str), + Some("origin") + ); + assert!( + payload + .get("push_cmd") + .and_then(Value::as_str) + .unwrap_or_default() + .contains("git push origin") + ); +} + +#[test] +fn share_quick_push_consent_persists_and_enables_auto_push() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + let remote = tmp.path().join("remote.git"); + init_git_repo(&repo); + run_git( + tmp.path(), + &["init", "--bare", remote.to_str().expect("remote")], + ); + run_git( + &repo, + &["remote", "add", "origin", remote.to_str().expect("remote")], + ); + + let first_input = repo.join("first.hail.jsonl"); + write_file(&first_input, &make_hail_jsonl("s-share-quick-first")); + let first_register = run( + tmp.path(), + &repo, + &["register", "--quiet", first_input.to_str().expect("path")], + ); + let first_local_uri = first_non_empty_line(&first_register.stdout); + let first_share = run( + tmp.path(), + &repo, + &["share", &first_local_uri, "--quick", "--push", "--json"], + ); + assert!( + first_share.status.success(), + "share --quick --push failed: {}", + String::from_utf8_lossy(&first_share.stderr) + ); + let first_payload: Value = + serde_json::from_slice(&first_share.stdout).expect("quick share json"); + assert_eq!( + first_payload.get("quick").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + first_payload.get("pushed").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + first_payload + .get("auto_push_consent") + .and_then(Value::as_bool), + Some(true) + ); + + let consent_config = first_non_empty_line( + &run_git( + &repo, + &[ + "config", + "--local", + "--get", + "opensession.share.auto-push-consent", + ], + ) + .stdout, + ); + assert_eq!(consent_config, "true"); + + let second_input = repo.join("second.hail.jsonl"); + write_file(&second_input, &make_hail_jsonl("s-share-quick-second")); + let second_register = run( + tmp.path(), + &repo, + &["register", "--quiet", second_input.to_str().expect("path")], + ); + let second_local_uri = first_non_empty_line(&second_register.stdout); + let second_hash = second_local_uri + .split('/') + .next_back() + .expect("local uri hash") + .to_string(); + + let second_share = run( + tmp.path(), + &repo, + &["share", &second_local_uri, "--quick", "--json"], + ); + assert!( + second_share.status.success(), + "second share --quick failed: {}", + String::from_utf8_lossy(&second_share.stderr) + ); + let second_payload: Value = + serde_json::from_slice(&second_share.stdout).expect("second quick share json"); + assert_eq!( + second_payload.get("quick").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + second_payload.get("pushed").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + second_payload + .get("auto_push_consent") + .and_then(Value::as_bool), + Some(true) + ); + + let ledger_ref = opensession_git_native::branch_ledger_ref("main"); + run_git( + tmp.path(), + &[ + "--git-dir", + remote.to_str().expect("remote"), + "show", + &format!("{ledger_ref}:sessions/{second_hash}.jsonl"), + ], + ); +} + +#[test] +fn share_quick_requires_remote_when_none_exist() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-share-quick-no-remote")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let share_out = run(tmp.path(), &repo, &["share", &local_uri, "--quick"]); + assert!(!share_out.status.success()); + let stderr = String::from_utf8_lossy(&share_out.stderr); + assert!(stderr.contains("no remotes were found")); + assert!(stderr.contains("git remote add origin")); +} + +#[test] +fn share_quick_rejects_ambiguous_remote_and_allows_explicit_override() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + run_git( + &repo, + &[ + "remote", + "add", + "upstream", + "https://github.com/example/upstream.git", + ], + ); + run_git( + &repo, + &[ + "remote", + "add", + "mirror", + "https://github.com/example/mirror.git", + ], + ); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-share-quick-ambiguous")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let ambiguous = run(tmp.path(), &repo, &["share", &local_uri, "--quick"]); + assert!(!ambiguous.status.success()); + let stderr = String::from_utf8_lossy(&ambiguous.stderr); + assert!(stderr.contains("could not choose a remote automatically")); + assert!(stderr.contains("--quick --remote origin")); + + let explicit = run( + tmp.path(), + &repo, + &[ + "share", &local_uri, "--quick", "--remote", "upstream", "--json", + ], + ); + assert!( + explicit.status.success(), + "share --quick --remote upstream failed: {}", + String::from_utf8_lossy(&explicit.stderr) + ); + let payload: Value = + serde_json::from_slice(&explicit.stdout).expect("explicit quick share json"); + assert_eq!(payload.get("quick").and_then(Value::as_bool), Some(true)); + assert_eq!( + payload.get("remote_target").and_then(Value::as_str), + Some("upstream") + ); +} + +#[test] +fn share_web_requires_config_with_next_steps() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let uri = opensession_core::source_uri::SourceUri::Src( + opensession_core::source_uri::SourceSpec::Git { + remote: "https://git.example/repo.git".to_string(), + r#ref: "refs/heads/main".to_string(), + path: "sessions/demo.jsonl".to_string(), + }, + ) + .to_string(); + + let out = run(tmp.path(), &repo, &["share", &uri, "--web"]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("missing config")); + assert!(stderr.contains("next:")); + assert!(stderr.contains("opensession config init --base-url")); +} + +#[test] +fn share_git_outside_repo_shows_next_steps() { + let tmp = make_home(); + let outside = tmp.path().join("outside"); + fs::create_dir_all(&outside).expect("create outside dir"); + + let input = outside.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-share-outside")); + let register_out = run( + tmp.path(), + &outside, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + assert!(local_uri.starts_with("os://src/local/")); + + let share_out = run( + tmp.path(), + &outside, + &["share", &local_uri, "--git", "--remote", "origin"], + ); + assert!(!share_out.status.success()); + let stderr = String::from_utf8_lossy(&share_out.stderr); + assert!(stderr.contains("current directory is not inside a git repository")); + assert!(stderr.contains("next:")); + assert!(stderr.contains("cd into the target git repository and retry")); +} + +#[test] +fn share_git_without_push_prints_push_command() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + let remote = tmp.path().join("remote.git"); + init_git_repo(&repo); + run_git( + tmp.path(), + &["init", "--bare", remote.to_str().expect("remote")], + ); + run_git( + &repo, + &["remote", "add", "origin", remote.to_str().expect("remote")], + ); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-share-git")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let share_out = run( + tmp.path(), + &repo, + &["share", &local_uri, "--git", "--remote", "origin"], + ); + assert!( + share_out.status.success(), + "share --git failed: {}", + String::from_utf8_lossy(&share_out.stderr) + ); + let stdout = String::from_utf8_lossy(&share_out.stdout); + let shared_uri = stdout.lines().next().unwrap_or_default(); + assert!(shared_uri.starts_with("os://src/git/") || shared_uri.starts_with("os://src/gh/")); + assert!(stdout.contains("push_cmd:")); + + let hash = local_uri.split('/').next_back().expect("local hash in uri"); + + run_git( + &repo, + &[ + "show", + &format!( + "{}:sessions/{hash}.jsonl", + opensession_git_native::branch_ledger_ref("main") + ), + ], + ); +} + +#[test] +fn share_git_with_gitlab_dot_com_remote_emits_gl_uri() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-share-gl")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let share_out = run( + tmp.path(), + &repo, + &[ + "share", + &local_uri, + "--git", + "--remote", + "https://gitlab.com/group/sub/repo.git", + ], + ); + assert!( + share_out.status.success(), + "share --git failed: {}", + String::from_utf8_lossy(&share_out.stderr) + ); + let stdout = String::from_utf8_lossy(&share_out.stdout); + let shared_uri = stdout.lines().next().unwrap_or_default(); + assert!( + shared_uri.starts_with("os://src/gl/"), + "expected gl uri, got: {shared_uri}" + ); +} + +#[test] +fn share_git_push_in_non_tty_does_not_trigger_cleanup_prompt() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + let remote = tmp.path().join("remote.git"); + init_git_repo(&repo); + run_git( + tmp.path(), + &["init", "--bare", remote.to_str().expect("remote")], + ); + run_git( + &repo, + &["remote", "add", "origin", remote.to_str().expect("remote")], + ); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-share-push-no-tty")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let share_out = run( + tmp.path(), + &repo, + &["share", &local_uri, "--git", "--remote", "origin", "--push"], + ); + assert!( + share_out.status.success(), + "share --push failed: {}", + String::from_utf8_lossy(&share_out.stderr) + ); + + assert!( + !repo + .join(".opensession") + .join("cleanup") + .join("config.toml") + .exists(), + "non-tty share should not auto-initialize cleanup config" + ); + + let prompted = Command::new("git") + .arg("-C") + .arg(&repo) + .arg("config") + .arg("--local") + .arg("--get") + .arg("opensession.cleanup.prompted") + .output() + .expect("read prompted config"); + assert!( + !prompted.status.success(), + "non-tty share should not set cleanup prompt git config" + ); +} + +#[test] +fn config_and_share_web_success() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let init_out = run( + tmp.path(), + &repo, + &["config", "init", "--base-url", "https://example.test"], + ); + assert!(init_out.status.success()); + + let uri = opensession_core::source_uri::SourceUri::Src( + opensession_core::source_uri::SourceSpec::Git { + remote: "https://git.example/repo.git".to_string(), + r#ref: "refs/heads/main".to_string(), + path: "sessions/demo.jsonl".to_string(), + }, + ) + .to_string(); + let out = run(tmp.path(), &repo, &["share", &uri, "--web"]); + assert!( + out.status.success(), + "share web failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("https://example.test/src/git/")); + assert!(stdout.contains("base_url: https://example.test")); +} + +#[test] +fn share_web_supports_gl_and_git_routes() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let init_out = run( + tmp.path(), + &repo, + &["config", "init", "--base-url", "https://example.test"], + ); + assert!(init_out.status.success()); + + let gl_uri = opensession_core::source_uri::SourceUri::Src( + opensession_core::source_uri::SourceSpec::Gl { + project: "group/sub/repo".to_string(), + r#ref: "refs/heads/main".to_string(), + path: "sessions/demo.jsonl".to_string(), + }, + ) + .to_string(); + let gl_out = run(tmp.path(), &repo, &["share", &gl_uri, "--web"]); + assert!( + gl_out.status.success(), + "share web for gl failed: {}", + String::from_utf8_lossy(&gl_out.stderr) + ); + let gl_stdout = String::from_utf8_lossy(&gl_out.stdout); + assert!(gl_stdout.contains("https://example.test/src/gl/")); + + let git_uri = opensession_core::source_uri::SourceUri::Src( + opensession_core::source_uri::SourceSpec::Git { + remote: "https://gitlab.internal.example.com/group/repo.git".to_string(), + r#ref: "refs/heads/main".to_string(), + path: "sessions/demo.jsonl".to_string(), + }, + ) + .to_string(); + let git_out = run(tmp.path(), &repo, &["share", &git_uri, "--web"]); + assert!( + git_out.status.success(), + "share web for git failed: {}", + String::from_utf8_lossy(&git_out.stderr) + ); + let git_stdout = String::from_utf8_lossy(&git_out.stdout); + assert!(git_stdout.contains("https://example.test/src/git/")); +} diff --git a/crates/cli/tests/handoff_cli/view_cli.rs b/crates/cli/tests/handoff_cli/view_cli.rs new file mode 100644 index 00000000..46f4a277 --- /dev/null +++ b/crates/cli/tests/handoff_cli/view_cli.rs @@ -0,0 +1,306 @@ +use super::*; +#[cfg(target_os = "macos")] +use std::os::unix::fs::PermissionsExt; + +#[test] +fn view_web_maps_remote_source_uri_to_src_route() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let init_out = run( + tmp.path(), + &repo, + &["config", "init", "--base-url", "https://example.test"], + ); + assert!(init_out.status.success()); + + let uri = opensession_core::source_uri::SourceUri::Src( + opensession_core::source_uri::SourceSpec::Gl { + project: "group/sub/repo".to_string(), + r#ref: "refs/heads/main".to_string(), + path: "sessions/demo.jsonl".to_string(), + }, + ) + .to_string(); + let out = run(tmp.path(), &repo, &["view", &uri, "--no-open", "--json"]); + assert!( + out.status.success(), + "view failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let payload: Value = serde_json::from_slice(&out.stdout).expect("view json"); + let url = payload + .get("url") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + assert!( + url.starts_with("https://example.test/src/gl/"), + "unexpected url: {url}" + ); +} + +#[test] +fn view_local_uri_emits_local_review_url_without_opening_browser() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let input = repo.join("sample.hail.jsonl"); + write_file(&input, &make_hail_jsonl("s-view-local")); + let register_out = run( + tmp.path(), + &repo, + &["register", "--quiet", input.to_str().expect("path")], + ); + let local_uri = first_non_empty_line(®ister_out.stdout); + + let view_out = run( + tmp.path(), + &repo, + &["view", &local_uri, "--no-open", "--json"], + ); + assert!( + view_out.status.success(), + "view failed: {}", + String::from_utf8_lossy(&view_out.stderr) + ); + let payload: Value = serde_json::from_slice(&view_out.stdout).expect("view json"); + assert_eq!(payload.get("mode").and_then(Value::as_str), Some("local")); + let url = payload + .get("url") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!(url.contains("/review/local/"), "unexpected url: {url}"); +} + +#[test] +fn view_commit_target_builds_commit_review_bundle() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run(tmp.path(), &repo, &["view", "HEAD", "--no-open", "--json"]); + assert!( + out.status.success(), + "view commit failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let payload: Value = serde_json::from_slice(&out.stdout).expect("view json"); + assert_eq!(payload.get("mode").and_then(Value::as_str), Some("commit")); + let url = payload + .get("url") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!(url.contains("/review/local/"), "unexpected url: {url}"); +} + +#[test] +fn view_without_target_defaults_to_sessions_route() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run(tmp.path(), &repo, &["view", "--no-open", "--json"]); + assert!( + out.status.success(), + "view default failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let payload: Value = serde_json::from_slice(&out.stdout).expect("view json"); + assert_eq!( + payload.get("mode").and_then(Value::as_str), + Some("sessions") + ); + let url = payload + .get("url") + .and_then(Value::as_str) + .unwrap_or_default(); + assert_eq!(url, "http://127.0.0.1:8788/sessions"); +} + +#[test] +fn view_without_target_prefills_repo_query_when_origin_matches_owner_repo() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + run_git( + &repo, + &[ + "remote", + "add", + "origin", + "https://github.com/acme/repo.git", + ], + ); + + let out = run(tmp.path(), &repo, &["view", "--no-open", "--json"]); + assert!( + out.status.success(), + "view default failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let payload: Value = serde_json::from_slice(&out.stdout).expect("view json"); + let url = payload + .get("url") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!(url.contains("/sessions?"), "unexpected url: {url}"); + assert!( + url.contains("git_repo_name=acme%2Frepo"), + "unexpected url: {url}" + ); +} + +#[test] +fn view_rejects_removed_tui_flag() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run(tmp.path(), &repo, &["view", "--tui"]); + assert!(!out.status.success(), "view --tui should be rejected"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("unexpected argument '--tui'"), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn view_without_target_open_mode_fails_closed_without_explicit_base_url() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + run_git( + &repo, + &["config", "--local", "opensession.open-target", "web"], + ); + + let out = run(tmp.path(), &repo, &["view", "--json"]); + assert!( + !out.status.success(), + "view default should fail without local server/base URL" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("local sessions server is unavailable")); + assert!(stderr.contains("opensession view --no-open")); + assert!(stderr.contains("opensession config init --base-url")); +} + +#[cfg(target_os = "macos")] +#[test] +fn view_without_target_open_mode_suppresses_desktop_open_probe_stderr() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let bin = tmp.path().join("bin"); + fs::create_dir_all(&bin).expect("create bin"); + let fake_open = bin.join("open"); + write_file( + &fake_open, + "#!/bin/sh\necho OPEN_PROBE_MARKER >&2\nexit 1\n", + ); + let mut perms = fs::metadata(&fake_open) + .expect("stat fake open") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&fake_open, perms).expect("chmod fake open"); + + let base_path = std::env::var("PATH").unwrap_or_default(); + let path_env = if base_path.is_empty() { + bin.display().to_string() + } else { + format!("{}:{}", bin.display(), base_path) + }; + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_opensession")); + let out = cmd + .args(["view", "--json"]) + .current_dir(&repo) + .env("HOME", tmp.path()) + .env("NO_COLOR", "1") + .env("PATH", path_env) + .output() + .expect("run opensession"); + + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("OPEN_PROBE_MARKER"), + "desktop open probe stderr leaked: {stderr}" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn view_without_target_web_open_target_skips_desktop_probe() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + run_git( + &repo, + &["config", "--local", "opensession.open-target", "web"], + ); + + let bin = tmp.path().join("bin"); + fs::create_dir_all(&bin).expect("create bin"); + let marker_path = tmp.path().join("open-invoked"); + let fake_open = bin.join("open"); + write_file( + &fake_open, + format!( + "#!/bin/sh\nprintf invoked > \"{}\"\nexit 1\n", + marker_path.display() + ) + .as_str(), + ); + let mut perms = fs::metadata(&fake_open) + .expect("stat fake open") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&fake_open, perms).expect("chmod fake open"); + + let base_path = std::env::var("PATH").unwrap_or_default(); + let path_env = if base_path.is_empty() { + bin.display().to_string() + } else { + format!("{}:{}", bin.display(), base_path) + }; + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_opensession")); + let out = cmd + .args(["view", "--json"]) + .current_dir(&repo) + .env("HOME", tmp.path()) + .env("NO_COLOR", "1") + .env("PATH", path_env) + .output() + .expect("run opensession"); + + assert!(!out.status.success()); + assert!( + !marker_path.exists(), + "desktop open probe should be skipped for open-target=web" + ); +} + +#[test] +fn view_invalid_target_shows_next_steps() { + let tmp = make_home(); + let repo = tmp.path().join("repo"); + init_git_repo(&repo); + + let out = run( + tmp.path(), + &repo, + &["view", "definitely-not-a-real-target", "--no-open"], + ); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("unable to resolve view target")); + assert!(stderr.contains("next:")); + assert!(stderr.contains("opensession view os://src/... --no-open")); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d9c5ee15..172a5d5a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -2,6 +2,7 @@ name = "opensession-core" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true description = "HAIL (Human AI Interaction Log) core types and validation" @@ -25,7 +26,3 @@ thiserror = { workspace = true } regex = { workspace = true } base64 = { workspace = true } urlencoding = { workspace = true } -sha2 = { workspace = true } - -[dev-dependencies] -tempfile = { workspace = true } diff --git a/crates/core/src/agent_metrics.rs b/crates/core/src/agent_metrics.rs index dedec140..8715db30 100644 --- a/crates/core/src/agent_metrics.rs +++ b/crates/core/src/agent_metrics.rs @@ -23,18 +23,18 @@ pub fn max_active_agents(session: &Session) -> usize { for event in &session.events { let task_id = normalize_task_id(event); - if matches!(event.event_type, EventType::TaskStart { .. }) { - if let Some(task_id) = task_id { - active_task_ids.insert(task_id); - } + if matches!(event.event_type, EventType::TaskStart { .. }) + && let Some(task_id) = task_id + { + active_task_ids.insert(task_id); } max_subagents = max_subagents.max(active_task_ids.len()); - if matches!(event.event_type, EventType::TaskEnd { .. }) { - if let Some(task_id) = task_id { - active_task_ids.remove(task_id); - } + if matches!(event.event_type, EventType::TaskEnd { .. }) + && let Some(task_id) = task_id + { + active_task_ids.remove(task_id); } } diff --git a/crates/core/src/handoff.rs b/crates/core/src/handoff.rs index f9340c21..e11d3eb9 100644 --- a/crates/core/src/handoff.rs +++ b/crates/core/src/handoff.rs @@ -3,12 +3,40 @@ //! This module provides programmatic extraction of session summaries for handoff //! between agent sessions. It supports both single-session and multi-session merge. -use std::collections::{BTreeMap, HashMap, HashSet}; +#[path = "handoff/execution.rs"] +mod execution; +#[path = "handoff/hail_export.rs"] +mod hail_export; +#[path = "handoff/markdown.rs"] +mod markdown; +#[path = "handoff/merge.rs"] +mod merge; +#[cfg(test)] +#[path = "handoff/tests.rs"] +mod tests; +#[path = "handoff/validation.rs"] +mod validation; -use crate::extract::truncate_str; -use crate::{Content, ContentBlock, Event, EventType, Session, SessionContext, Stats}; +use std::collections::{HashMap, HashSet}; -// ─── Types ─────────────────────────────────────────────────────────────────── +use crate::extract::truncate_str; +use crate::{ContentBlock, Event, EventType, Session, Stats}; + +use execution::{ + build_execution_contract, build_work_packages, collect_evidence, collect_open_questions, + collect_undefined_fields, dedupe_keep_order, +}; + +pub use hail_export::generate_handoff_hail; +pub use markdown::{ + generate_handoff_markdown, generate_handoff_markdown_v2, generate_merged_handoff_markdown, + generate_merged_handoff_markdown_v2, +}; +pub use merge::merge_summaries; +pub use validation::{ + HandoffValidationReport, ValidationFinding, validate_handoff_summaries, + validate_handoff_summary, +}; /// A file change observed during a session. #[derive(Debug, Clone, serde::Serialize)] @@ -155,8 +183,6 @@ pub struct MergedHandoff { pub total_errors: Vec, } -// ─── Extraction ────────────────────────────────────────────────────────────── - const MAX_KEY_CONVERSATIONS: usize = 12; const MAX_USER_MESSAGES: usize = 18; const HEAD_KEEP_MESSAGES: usize = 3; @@ -220,8 +246,6 @@ impl HandoffSummary { } } -// ─── Functional extractors ────────────────────────────────────────────────── - /// Collect file changes, preserving create/delete precedence over edits. fn collect_file_changes(events: &[Event]) -> Vec { let map = events.iter().fold(HashMap::new(), |mut map, event| { @@ -239,29 +263,27 @@ fn collect_file_changes(events: &[Event]) -> Vec { } map }); - let mut result: Vec = map + + let mut changes: Vec = map .into_iter() .map(|(path, action)| FileChange { path, action }) .collect(); - result.sort_by(|a, b| a.path.cmp(&b.path)); - result + changes.sort_by(|a, b| a.path.cmp(&b.path)); + changes } -/// Collect read-only file paths (excluding those that were also modified). fn collect_files_read(events: &[Event], modified_paths: &HashSet<&str>) -> Vec { - let mut read: Vec = events - .iter() - .filter_map(|e| match &e.event_type { - EventType::FileRead { path } if !modified_paths.contains(path.as_str()) => { - Some(path.clone()) - } - _ => None, - }) - .collect::>() - .into_iter() - .collect(); - read.sort(); - read + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for event in events { + if let EventType::FileRead { path } = &event.event_type + && !modified_paths.contains(path.as_str()) + && seen.insert(path.clone()) + { + files.push(path.clone()); + } + } + files } fn collect_shell_commands(events: &[Event]) -> Vec { @@ -277,113 +299,80 @@ fn collect_shell_commands(events: &[Event]) -> Vec { .collect() } -/// Collect errors from failed shell commands and tool results. fn collect_errors(events: &[Event]) -> Vec { - events - .iter() - .filter_map(|event| match &event.event_type { - EventType::ShellCommand { command, exit_code } - if *exit_code != Some(0) && exit_code.is_some() => - { - Some(format!( - "Shell: `{}` → exit {}", - truncate_str(command, 80), - exit_code.unwrap() - )) - } - EventType::ToolResult { - is_error: true, - name, - .. - } => { - let detail = extract_text_from_event(event); - Some(match detail { - Some(d) => format!("Tool error: {} — {}", name, truncate_str(&d, 80)), - None => format!("Tool error: {name}"), - }) - } - _ => None, - }) - .collect() -} - -fn collect_verification(events: &[Event]) -> Verification { - let mut tool_result_by_call: HashMap = HashMap::new(); + let mut errors = Vec::new(); for event in events { - if let EventType::ToolResult { is_error, .. } = &event.event_type { - if let Some(call_id) = event.semantic_call_id() { - tool_result_by_call - .entry(call_id.to_string()) - .or_insert((event, *is_error)); + match &event.event_type { + EventType::ShellCommand { + command, + exit_code: Some(code), + } if *code != 0 => { + errors.push(format!( + "Command failed ({code}): {}", + collapse_whitespace(command) + )); } - } - } - - let mut checks_run = Vec::new(); - for event in events { - let EventType::ShellCommand { command, exit_code } = &event.event_type else { - continue; - }; - - let (status, resolved_exit_code) = match exit_code { - Some(0) => ("passed".to_string(), Some(0)), - Some(code) => ("failed".to_string(), Some(*code)), - None => { - if let Some(call_id) = event.semantic_call_id() { - if let Some((_, is_error)) = tool_result_by_call.get(call_id) { - if *is_error { - ("failed".to_string(), None) - } else { - ("passed".to_string(), None) - } - } else { - ("unknown".to_string(), None) - } + EventType::ToolResult { name, is_error, .. } if *is_error => { + if let Some(text) = extract_text_from_event(event) { + errors.push(format!("Tool {name} error: {}", truncate_str(&text, 200))); } else { - ("unknown".to_string(), None) + errors.push(format!("Tool {name} reported an error.")); } } - }; - - checks_run.push(CheckRun { - command: collapse_whitespace(command), - status, - exit_code: resolved_exit_code, - event_id: event.event_id.clone(), - }); + EventType::Custom { kind } if kind == "turn_aborted" => { + errors.push("Turn aborted.".to_string()); + } + _ => {} + } } + errors +} - let mut checks_passed: Vec = checks_run +fn collect_verification(events: &[Event]) -> Verification { + let checks_run = events .iter() - .filter(|run| run.status == "passed") - .map(|run| run.command.clone()) - .collect(); - let mut checks_failed: Vec = checks_run + .filter_map(|event| match &event.event_type { + EventType::ShellCommand { command, exit_code } => Some(CheckRun { + command: collapse_whitespace(command), + status: match exit_code { + Some(0) => "passed".to_string(), + Some(_) => "failed".to_string(), + None => "unknown".to_string(), + }, + exit_code: *exit_code, + event_id: event.event_id.clone(), + }), + _ => None, + }) + .collect::>(); + + let mut checks_passed = checks_run .iter() - .filter(|run| run.status == "failed") - .map(|run| run.command.clone()) - .collect(); + .filter(|check| check.status == "passed") + .map(|check| check.command.clone()) + .collect::>(); + let mut checks_failed = checks_run + .iter() + .filter(|check| check.status == "failed") + .map(|check| check.command.clone()) + .collect::>(); dedupe_keep_order(&mut checks_passed); dedupe_keep_order(&mut checks_failed); - let unresolved_failed = unresolved_failed_commands(&checks_run); - let mut required_checks_missing = unresolved_failed - .iter() - .map(|cmd| format!("Unresolved failed check: `{cmd}`")) - .collect::>(); - - let has_modified_files = events.iter().any(|event| { + let mut required_checks_missing = Vec::new(); + let has_files_modified = events.iter().any(|event| { matches!( - event.event_type, + &event.event_type, EventType::FileEdit { .. } | EventType::FileCreate { .. } | EventType::FileDelete { .. } ) }); - if has_modified_files && checks_run.is_empty() { + + if has_files_modified && checks_run.is_empty() { required_checks_missing - .push("No verification command found after file modifications.".to_string()); + .push("No verification command was run after file modifications.".to_string()); } Verification { @@ -396,36 +385,26 @@ fn collect_verification(events: &[Event]) -> Verification { fn collect_uncertainty(session: &Session, verification: &Verification) -> Uncertainty { let mut assumptions = Vec::new(); - if extract_objective(session) == "(objective unavailable)" { - assumptions.push( - "Objective inferred as unavailable; downstream agent must restate objective." - .to_string(), - ); + if verification.checks_run.is_empty() { + assumptions.push("Verification status is unknown because no checks were recorded.".into()); + } + if session.context.title.as_deref().is_none_or(str::is_empty) { + assumptions.push("Session title was unavailable.".into()); } - let open_questions = collect_open_questions(&session.events); - + let mut open_questions = collect_open_questions(&session.events); let mut decision_required = Vec::new(); - for event in &session.events { - if let EventType::Custom { kind } = &event.event_type { - if kind == "turn_aborted" { - let reason = event - .attr_str("reason") - .map(String::from) - .unwrap_or_else(|| "turn aborted".to_string()); - decision_required.push(format!("Turn aborted: {reason}")); - } - } - } - for missing in &verification.required_checks_missing { - decision_required.push(missing.clone()); + if session.events.iter().any( + |event| matches!(&event.event_type, EventType::Custom { kind } if kind == "turn_aborted"), + ) { + decision_required.push("Turn aborted before completion; decide whether to retry.".into()); } - for question in &open_questions { - decision_required.push(format!("Resolve open question: {question}")); + if !verification.checks_failed.is_empty() { + decision_required + .push("Fix failing verification commands before handoff is complete.".into()); } dedupe_keep_order(&mut assumptions); - let mut open_questions = open_questions; dedupe_keep_order(&mut open_questions); dedupe_keep_order(&mut decision_required); @@ -436,1578 +415,182 @@ fn collect_uncertainty(session: &Session, verification: &Verification) -> Uncert } } -fn build_execution_contract( - task_summaries: &[String], - verification: &Verification, - uncertainty: &Uncertainty, - shell_commands: &[ShellCmd], - files_modified: &[FileChange], - work_packages: &[WorkPackage], -) -> ExecutionContract { - let ordered_steps = work_packages - .iter() - .filter(|pkg| is_material_work_package(pkg)) - .map(|pkg| OrderedStep { - sequence: pkg.sequence, - work_package_id: pkg.id.clone(), - title: pkg.title.clone(), - status: pkg.status.clone(), - depends_on: pkg.depends_on.clone(), - started_at: pkg.started_at.clone(), - completed_at: pkg.completed_at.clone(), - evidence_refs: pkg.evidence_refs.clone(), - }) - .collect::>(); +fn collect_task_summaries(events: &[Event]) -> Vec { + let mut seen = HashSet::new(); + let mut summaries = Vec::new(); - let mut done_definition = ordered_steps - .iter() - .filter(|step| step.status == "completed") - .map(|step| { - let pkg = work_packages - .iter() - .find(|pkg| pkg.id == step.work_package_id) - .expect("ordered step must map to existing work package"); - let mut details = Vec::new(); - if let Some(outcome) = pkg.outcome.as_deref() { - details.push(format!("outcome: {}", truncate_str(outcome, 140))); - } - let footprint = work_package_footprint(pkg); - if !footprint.is_empty() { - details.push(footprint); - } - let at = step - .completed_at - .as_deref() - .or(step.started_at.as_deref()) - .unwrap_or("time-unavailable"); - if details.is_empty() { - format!("[{}] Completed `{}` at {}.", step.sequence, step.title, at) - } else { - format!( - "[{}] Completed `{}` at {} ({}).", - step.sequence, - step.title, - at, - details.join("; ") - ) - } - }) - .collect::>(); + for event in events { + let EventType::TaskEnd { + summary: Some(summary), + } = &event.event_type + else { + continue; + }; - if !verification.checks_passed.is_empty() { - let keep = verification - .checks_passed - .iter() - .take(3) - .map(|check| format!("`{check}`")) - .collect::>(); - let extra = verification.checks_passed.len().saturating_sub(3); - if extra > 0 { - done_definition.push(format!( - "Verification passed: {} (+{} more).", - keep.join(", "), - extra - )); - } else { - done_definition.push(format!("Verification passed: {}.", keep.join(", "))); + let summary = summary.trim(); + if summary.is_empty() { + continue; } - } - if !files_modified.is_empty() { - let keep = files_modified - .iter() - .take(3) - .map(|file| format!("`{}`", file.path)) - .collect::>(); - let extra = files_modified.len().saturating_sub(3); - if extra > 0 { - done_definition.push(format!( - "Changed {} file(s): {} (+{} more).", - files_modified.len(), - keep.join(", "), - extra - )); - } else { - done_definition.push(format!( - "Changed {} file(s): {}.", - files_modified.len(), - keep.join(", ") - )); + let normalized = collapse_whitespace(summary); + if normalized.eq_ignore_ascii_case("synthetic end (missing task_complete)") { + continue; + } + if seen.insert(normalized.clone()) { + summaries.push(truncate_str(&normalized, 180)); } } - if done_definition.is_empty() { - done_definition.extend(task_summaries.iter().take(5).cloned()); - } - dedupe_keep_order(&mut done_definition); + summaries +} - let mut next_actions = unresolved_failed_commands(&verification.checks_run) - .into_iter() - .map(|cmd| format!("Fix and re-run `{cmd}` until the check passes.")) +fn collect_user_messages(events: &[Event]) -> Vec { + let messages = events + .iter() + .filter(|e| matches!(&e.event_type, EventType::UserMessage)) + .filter_map(extract_text_from_event) + .map(|msg| truncate_str(&collapse_whitespace(&msg), 240)) .collect::>(); - next_actions.extend( - verification - .required_checks_missing - .iter() - .map(|missing| format!("Add/restore verification check: {missing}")), - ); - next_actions.extend(ordered_steps.iter().filter_map(|step| { - if step.status == "completed" || step.depends_on.is_empty() { - return None; - } - Some(format!( - "[{}] After dependencies [{}], execute `{}` ({}).", - step.sequence, - step.depends_on.join(", "), - step.title, - step.work_package_id - )) - })); - next_actions.extend( - uncertainty - .open_questions - .iter() - .map(|q| format!("Resolve open question: {q}")), - ); - let mut parallel_actions = ordered_steps + condense_head_tail(messages, HEAD_KEEP_MESSAGES, MAX_USER_MESSAGES) +} + +/// Pair adjacent User→Agent messages into conversations. +/// +/// Filters to message events only, then uses `windows(2)` to find +/// UserMessage→AgentMessage pairs. +fn collect_conversation_pairs(events: &[Event]) -> Vec { + let messages: Vec<&Event> = events .iter() - .filter(|step| { - step.status != "completed" - && step.depends_on.is_empty() - && step.work_package_id != "main" - }) - .map(|step| { - let at = step.started_at.as_deref().unwrap_or("time-unavailable"); - format!( - "[{}] `{}` ({}) — start: {}", - step.sequence, step.title, step.work_package_id, at + .filter(|e| { + matches!( + &e.event_type, + EventType::UserMessage | EventType::AgentMessage ) }) + .collect(); + + let conversations = messages + .windows(2) + .filter_map(|pair| match (&pair[0].event_type, &pair[1].event_type) { + (EventType::UserMessage, EventType::AgentMessage) => { + let user_text = extract_text_from_event(pair[0])?; + let agent_text = extract_text_from_event(pair[1])?; + Some(Conversation { + user: truncate_str(&user_text, 300), + agent: truncate_str(&agent_text, 300), + }) + } + _ => None, + }) .collect::>(); - if done_definition.is_empty() - && next_actions.is_empty() - && parallel_actions.is_empty() - && ordered_steps.is_empty() - { - next_actions.push( - "Define completion criteria and run at least one verification command.".to_string(), - ); - } - dedupe_keep_order(&mut next_actions); - dedupe_keep_order(&mut parallel_actions); + condense_head_tail( + conversations, + HEAD_KEEP_CONVERSATIONS, + MAX_KEY_CONVERSATIONS, + ) +} - let unresolved = unresolved_failed_commands(&verification.checks_run); - let mut ordered_commands = unresolved; - for cmd in shell_commands - .iter() - .map(|c| collapse_whitespace(&c.command)) - { - if !ordered_commands.iter().any(|existing| existing == &cmd) { - ordered_commands.push(cmd); - } +fn condense_head_tail(items: Vec, head_keep: usize, max_total: usize) -> Vec { + if items.len() <= max_total { + return items; } - let has_git_commit = shell_commands - .iter() - .any(|cmd| cmd.command.to_ascii_lowercase().contains("git commit")); - let (rollback_hint, rollback_hint_missing_reason) = if has_git_commit { - ( - Some( - "Use `git revert ` for committed changes, then re-run verification." - .to_string(), - ), - None, - ) - } else { - ( - None, - Some("No committed change signal found in events.".to_string()), - ) - }; + let max_total = max_total.max(head_keep); + let tail_keep = max_total.saturating_sub(head_keep); + let mut condensed = Vec::with_capacity(max_total); - ExecutionContract { - done_definition, - next_actions, - parallel_actions, - ordered_steps, - ordered_commands, - rollback_hint, - rollback_hint_missing_reason: rollback_hint_missing_reason.clone(), - rollback_hint_undefined_reason: rollback_hint_missing_reason, - } + condensed.extend(items.iter().take(head_keep).cloned()); + condensed.extend( + items + .iter() + .skip(items.len().saturating_sub(tail_keep)) + .cloned(), + ); + condensed } -fn work_package_footprint(pkg: &WorkPackage) -> String { - let mut details = Vec::new(); - if !pkg.files.is_empty() { - details.push(format!("files: {}", pkg.files.len())); - } - if !pkg.commands.is_empty() { - details.push(format!("commands: {}", pkg.commands.len())); - } - details.join(", ") +fn extract_first_user_text(session: &Session) -> Option { + crate::extract::extract_first_user_text(session) } -fn collect_evidence( - session: &Session, - objective: &str, - task_summaries: &[String], - uncertainty: &Uncertainty, -) -> Vec { - let mut evidence = Vec::new(); - let mut next_id = 1usize; - - if let Some(event) = find_objective_event(session) { - evidence.push(EvidenceRef { - id: format!("evidence-{next_id}"), - claim: format!("objective: {objective}"), - event_id: event.event_id.clone(), - timestamp: event.timestamp.to_rfc3339(), - source_type: event_source_type(event), - }); - next_id += 1; - } - - for summary in task_summaries { - if let Some(event) = find_task_summary_event(&session.events, summary) { - evidence.push(EvidenceRef { - id: format!("evidence-{next_id}"), - claim: format!("task_done: {summary}"), - event_id: event.event_id.clone(), - timestamp: event.timestamp.to_rfc3339(), - source_type: event_source_type(event), - }); - next_id += 1; - } +fn extract_objective(session: &Session) -> String { + if let Some(user_text) = extract_first_user_text(session).filter(|t| !t.trim().is_empty()) { + return truncate_str(&collapse_whitespace(&user_text), 200); } - for decision in &uncertainty.decision_required { - if let Some(event) = find_decision_event(&session.events, decision) { - evidence.push(EvidenceRef { - id: format!("evidence-{next_id}"), - claim: format!("decision_required: {decision}"), - event_id: event.event_id.clone(), - timestamp: event.timestamp.to_rfc3339(), - source_type: event_source_type(event), - }); - next_id += 1; - } + if let Some(task_title) = session + .events + .iter() + .find_map(|event| match &event.event_type { + EventType::TaskStart { title: Some(title) } => { + let title = title.trim(); + if title.is_empty() { + None + } else { + Some(title.to_string()) + } + } + _ => None, + }) + { + return truncate_str(&collapse_whitespace(&task_title), 200); } - evidence -} - -fn build_work_packages(events: &[Event], evidence: &[EvidenceRef]) -> Vec { - #[derive(Default)] - struct WorkPackageAcc { - title: Option, - status: String, - outcome: Option, - first_ts: Option>, - first_idx: Option, - completed_ts: Option>, - files: HashSet, - commands: Vec, - evidence_refs: Vec, + if let Some(task_summary) = session + .events + .iter() + .find_map(|event| match &event.event_type { + EventType::TaskEnd { + summary: Some(summary), + } => { + let summary = summary.trim(); + if summary.is_empty() { + None + } else { + Some(summary.to_string()) + } + } + _ => None, + }) + { + return truncate_str(&collapse_whitespace(&task_summary), 200); } - let mut evidence_by_event: HashMap<&str, Vec> = HashMap::new(); - for ev in evidence { - evidence_by_event - .entry(ev.event_id.as_str()) - .or_default() - .push(ev.id.clone()); + if let Some(title) = session.context.title.as_deref().map(str::trim) + && !title.is_empty() + { + return truncate_str(&collapse_whitespace(title), 200); } - let mut grouped: BTreeMap = BTreeMap::new(); - for (event_idx, event) in events.iter().enumerate() { - let key = package_key_for_event(event); - let acc = grouped - .entry(key.clone()) - .or_insert_with(|| WorkPackageAcc { - status: "pending".to_string(), - ..Default::default() - }); - - if acc.first_ts.is_none() { - acc.first_ts = Some(event.timestamp); - acc.first_idx = Some(event_idx); - } - if let Some(ids) = evidence_by_event.get(event.event_id.as_str()) { - acc.evidence_refs.extend(ids.clone()); - } + "(objective unavailable)".to_string() +} - match &event.event_type { - EventType::TaskStart { title } => { - if let Some(title) = title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { - acc.title = Some(title.to_string()); - } - if acc.status != "completed" { - acc.status = "in_progress".to_string(); - } - } - EventType::TaskEnd { summary } => { - acc.status = "completed".to_string(); - acc.completed_ts = Some(event.timestamp); - if let Some(summary) = summary - .as_deref() - .map(collapse_whitespace) - .filter(|summary| !summary.is_empty()) - { - acc.outcome = Some(summary.clone()); - if acc.title.is_none() { - acc.title = Some(truncate_str(&summary, 160)); - } - } - } - EventType::FileEdit { path, .. } - | EventType::FileCreate { path } - | EventType::FileDelete { path } => { - acc.files.insert(path.clone()); - if acc.status == "pending" { - acc.status = "in_progress".to_string(); - } - } - EventType::ShellCommand { command, .. } => { - acc.commands.push(collapse_whitespace(command)); - if acc.status == "pending" { - acc.status = "in_progress".to_string(); - } +fn extract_text_from_event(event: &Event) -> Option { + for block in &event.content.blocks { + if let ContentBlock::Text { text } = block { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); } - _ => {} } } + None +} - let mut by_first_seen = grouped - .into_iter() - .map(|(id, mut acc)| { - dedupe_keep_order(&mut acc.commands); - dedupe_keep_order(&mut acc.evidence_refs); - let mut files: Vec = acc.files.into_iter().collect(); - files.sort(); - ( - acc.first_ts, - acc.first_idx.unwrap_or(usize::MAX), - WorkPackage { - title: acc.title.unwrap_or_else(|| { - if id == "main" { - "Main flow".to_string() - } else { - format!("Task {id}") - } - }), - id, - status: acc.status, - sequence: 0, - started_at: acc.first_ts.map(|ts| ts.to_rfc3339()), - completed_at: acc.completed_ts.map(|ts| ts.to_rfc3339()), - outcome: acc.outcome, - depends_on: Vec::new(), - files, - commands: acc.commands, - evidence_refs: acc.evidence_refs, - }, - ) - }) - .collect::>(); - - by_first_seen.sort_by(|a, b| { - a.0.cmp(&b.0) - .then_with(|| a.1.cmp(&b.1)) - .then_with(|| a.2.id.cmp(&b.2.id)) - }); - - let mut packages = by_first_seen - .into_iter() - .map(|(_, _, package)| package) - .collect::>(); - - for (idx, package) in packages.iter_mut().enumerate() { - package.sequence = (idx + 1) as u32; - } - - for i in 0..packages.len() { - let cur_files: HashSet<&str> = packages[i].files.iter().map(String::as_str).collect(); - if cur_files.is_empty() { - continue; - } - let mut dependency: Option = None; - for j in (0..i).rev() { - let prev_files: HashSet<&str> = packages[j].files.iter().map(String::as_str).collect(); - if !prev_files.is_empty() && !cur_files.is_disjoint(&prev_files) { - dependency = Some(packages[j].id.clone()); - break; - } - } - if let Some(dep) = dependency { - packages[i].depends_on.push(dep); - } - } - - packages.retain(|pkg| pkg.id == "main" || is_material_work_package(pkg)); - let known_ids: HashSet = packages.iter().map(|pkg| pkg.id.clone()).collect(); - for pkg in &mut packages { - pkg.depends_on.retain(|dep| known_ids.contains(dep)); - } - - packages -} - -fn is_generic_work_package_title(id: &str, title: &str) -> bool { - title == "Main flow" || title == format!("Task {id}") -} - -fn is_material_work_package(pkg: &WorkPackage) -> bool { - if !pkg.files.is_empty() || !pkg.commands.is_empty() || pkg.outcome.is_some() { - return true; - } - - if pkg.id == "main" { - return pkg.status != "pending"; - } - - if !is_generic_work_package_title(&pkg.id, &pkg.title) { - return true; - } - - pkg.status == "completed" && !pkg.evidence_refs.is_empty() -} - -fn package_key_for_event(event: &Event) -> String { - if let Some(task_id) = event - .task_id - .as_deref() - .map(str::trim) - .filter(|id| !id.is_empty()) - { - return task_id.to_string(); - } - if let Some(group_id) = event.semantic_group_id() { - return group_id.to_string(); - } - "main".to_string() -} - -fn find_objective_event(session: &Session) -> Option<&Event> { - session - .events - .iter() - .find(|event| matches!(event.event_type, EventType::UserMessage)) - .or_else(|| { - session.events.iter().find(|event| { - matches!( - event.event_type, - EventType::TaskStart { .. } | EventType::TaskEnd { .. } - ) - }) - }) -} - -fn find_task_summary_event<'a>(events: &'a [Event], summary: &str) -> Option<&'a Event> { - let normalized_target = collapse_whitespace(summary); - events.iter().find(|event| { - let EventType::TaskEnd { - summary: Some(candidate), - } = &event.event_type - else { - return false; - }; - collapse_whitespace(candidate) == normalized_target - }) -} - -fn find_decision_event<'a>(events: &'a [Event], decision: &str) -> Option<&'a Event> { - if decision.to_ascii_lowercase().contains("turn aborted") { - return events.iter().find(|event| { - matches!( - &event.event_type, - EventType::Custom { kind } if kind == "turn_aborted" - ) - }); - } - if decision.to_ascii_lowercase().contains("open question") { - return events - .iter() - .find(|event| event.attr_str("source") == Some("interactive_question")); - } - None -} - -fn collect_open_questions(events: &[Event]) -> Vec { - let mut question_meta: BTreeMap = BTreeMap::new(); - let mut asked_order = Vec::new(); - let mut answered_ids = HashSet::new(); - - for event in events { - if event.attr_str("source") == Some("interactive_question") { - if let Some(items) = event - .attributes - .get("question_meta") - .and_then(|v| v.as_array()) - { - for item in items { - let Some(id) = item - .get("id") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - else { - continue; - }; - let text = item - .get("question") - .or_else(|| item.get("header")) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .unwrap_or(id); - if !question_meta.contains_key(id) { - asked_order.push(id.to_string()); - } - question_meta.insert(id.to_string(), text.to_string()); - } - } else if let Some(ids) = event - .attributes - .get("question_ids") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(String::from) - .collect::>() - }) - { - for id in ids { - if !question_meta.contains_key(&id) { - asked_order.push(id.clone()); - } - question_meta.entry(id.clone()).or_insert(id); - } - } - } - - if event.attr_str("source") == Some("interactive") { - if let Some(ids) = event - .attributes - .get("question_ids") - .and_then(|v| v.as_array()) - { - for id in ids - .iter() - .filter_map(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - { - answered_ids.insert(id.to_string()); - } - } - } - } - - asked_order - .into_iter() - .filter(|id| !answered_ids.contains(id)) - .map(|id| { - let text = question_meta - .get(&id) - .cloned() - .unwrap_or_else(|| id.clone()); - format!("{id}: {text}") - }) - .collect() -} - -fn unresolved_failed_commands(checks_run: &[CheckRun]) -> Vec { - let mut unresolved = Vec::new(); - for (idx, run) in checks_run.iter().enumerate() { - if run.status != "failed" { - continue; - } - let resolved = checks_run - .iter() - .skip(idx + 1) - .any(|later| later.command == run.command && later.status == "passed"); - if !resolved { - unresolved.push(run.command.clone()); - } - } - dedupe_keep_order(&mut unresolved); - unresolved -} - -fn dedupe_keep_order(values: &mut Vec) { - let mut seen = HashSet::new(); - values.retain(|value| seen.insert(value.clone())); -} - -fn objective_unavailable_reason(objective: &str) -> Option { - if objective.trim().is_empty() || objective == "(objective unavailable)" { - Some( - "No user prompt, task title/summary, or session title could be used to infer objective." - .to_string(), - ) - } else { - None - } -} - -fn collect_undefined_fields( - objective_undefined_reason: Option<&str>, - execution_contract: &ExecutionContract, - evidence: &[EvidenceRef], -) -> Vec { - let mut undefined = Vec::new(); - - if let Some(reason) = objective_undefined_reason { - undefined.push(UndefinedField { - path: "objective".to_string(), - undefined_reason: reason.to_string(), - }); - } - - if let Some(reason) = execution_contract - .rollback_hint_undefined_reason - .as_deref() - .or(execution_contract.rollback_hint_missing_reason.as_deref()) - { - undefined.push(UndefinedField { - path: "execution_contract.rollback_hint".to_string(), - undefined_reason: reason.to_string(), - }); - } - - if evidence.is_empty() { - undefined.push(UndefinedField { - path: "evidence".to_string(), - undefined_reason: - "No objective/task/decision evidence could be mapped to source events.".to_string(), - }); - } - - undefined -} - -fn event_source_type(event: &Event) -> String { - event - .source_raw_type() - .map(String::from) - .unwrap_or_else(|| match &event.event_type { - EventType::UserMessage => "UserMessage".to_string(), - EventType::AgentMessage => "AgentMessage".to_string(), - EventType::SystemMessage => "SystemMessage".to_string(), - EventType::Thinking => "Thinking".to_string(), - EventType::ToolCall { .. } => "ToolCall".to_string(), - EventType::ToolResult { .. } => "ToolResult".to_string(), - EventType::FileRead { .. } => "FileRead".to_string(), - EventType::CodeSearch { .. } => "CodeSearch".to_string(), - EventType::FileSearch { .. } => "FileSearch".to_string(), - EventType::FileEdit { .. } => "FileEdit".to_string(), - EventType::FileCreate { .. } => "FileCreate".to_string(), - EventType::FileDelete { .. } => "FileDelete".to_string(), - EventType::ShellCommand { .. } => "ShellCommand".to_string(), - EventType::ImageGenerate { .. } => "ImageGenerate".to_string(), - EventType::VideoGenerate { .. } => "VideoGenerate".to_string(), - EventType::AudioGenerate { .. } => "AudioGenerate".to_string(), - EventType::WebSearch { .. } => "WebSearch".to_string(), - EventType::WebFetch { .. } => "WebFetch".to_string(), - EventType::TaskStart { .. } => "TaskStart".to_string(), - EventType::TaskEnd { .. } => "TaskEnd".to_string(), - EventType::Custom { kind } => format!("Custom:{kind}"), - }) -} - -fn collect_task_summaries(events: &[Event]) -> Vec { - let mut seen = HashSet::new(); - let mut summaries = Vec::new(); - - for event in events { - let EventType::TaskEnd { - summary: Some(summary), - } = &event.event_type - else { - continue; - }; - - let summary = summary.trim(); - if summary.is_empty() { - continue; - } - - let normalized = collapse_whitespace(summary); - if normalized.eq_ignore_ascii_case("synthetic end (missing task_complete)") { - continue; - } - if seen.insert(normalized.clone()) { - summaries.push(truncate_str(&normalized, 180)); - } - } - - summaries -} - -fn collect_user_messages(events: &[Event]) -> Vec { - let messages = events - .iter() - .filter(|e| matches!(&e.event_type, EventType::UserMessage)) - .filter_map(extract_text_from_event) - .map(|msg| truncate_str(&collapse_whitespace(&msg), 240)) - .collect::>(); - condense_head_tail(messages, HEAD_KEEP_MESSAGES, MAX_USER_MESSAGES) -} - -/// Pair adjacent User→Agent messages into conversations. -/// -/// Filters to message events only, then uses `windows(2)` to find -/// UserMessage→AgentMessage pairs — no mutable tracking state needed. -fn collect_conversation_pairs(events: &[Event]) -> Vec { - let messages: Vec<&Event> = events - .iter() - .filter(|e| { - matches!( - &e.event_type, - EventType::UserMessage | EventType::AgentMessage - ) - }) - .collect(); - - let conversations = messages - .windows(2) - .filter_map(|pair| match (&pair[0].event_type, &pair[1].event_type) { - (EventType::UserMessage, EventType::AgentMessage) => { - let user_text = extract_text_from_event(pair[0])?; - let agent_text = extract_text_from_event(pair[1])?; - Some(Conversation { - user: truncate_str(&user_text, 300), - agent: truncate_str(&agent_text, 300), - }) - } - _ => None, - }) - .collect::>(); - - condense_head_tail( - conversations, - HEAD_KEEP_CONVERSATIONS, - MAX_KEY_CONVERSATIONS, - ) -} - -fn condense_head_tail(items: Vec, head_keep: usize, max_total: usize) -> Vec { - if items.len() <= max_total { - return items; - } - - let max_total = max_total.max(head_keep); - let tail_keep = max_total.saturating_sub(head_keep); - let mut condensed = Vec::with_capacity(max_total); - - condensed.extend(items.iter().take(head_keep).cloned()); - condensed.extend( - items - .iter() - .skip(items.len().saturating_sub(tail_keep)) - .cloned(), - ); - condensed -} - -// ─── Merge ─────────────────────────────────────────────────────────────────── - -/// Merge multiple session summaries into a single handoff context. -pub fn merge_summaries(summaries: &[HandoffSummary]) -> MergedHandoff { - let session_ids: Vec = summaries - .iter() - .map(|s| s.source_session_id.clone()) - .collect(); - let total_duration: u64 = summaries.iter().map(|s| s.duration_seconds).sum(); - let total_errors: Vec = summaries - .iter() - .flat_map(|s| { - s.errors - .iter() - .map(move |err| format!("[{}] {}", s.source_session_id, err)) - }) - .collect(); - - let all_modified: HashMap = summaries - .iter() - .flat_map(|s| &s.files_modified) - .fold(HashMap::new(), |mut map, fc| { - map.entry(fc.path.clone()).or_insert(fc.action); - map - }); - - // Compute sorted_read before consuming all_modified - let mut sorted_read: Vec = summaries - .iter() - .flat_map(|s| &s.files_read) - .filter(|p| !all_modified.contains_key(p.as_str())) - .cloned() - .collect::>() - .into_iter() - .collect(); - sorted_read.sort(); - - let mut sorted_modified: Vec = all_modified - .into_iter() - .map(|(path, action)| FileChange { path, action }) - .collect(); - sorted_modified.sort_by(|a, b| a.path.cmp(&b.path)); - - MergedHandoff { - source_session_ids: session_ids, - summaries: summaries.to_vec(), - all_files_modified: sorted_modified, - all_files_read: sorted_read, - total_duration_seconds: total_duration, - total_errors, - } -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct ValidationFinding { - pub code: String, - pub severity: String, - pub message: String, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct HandoffValidationReport { - pub session_id: String, - pub passed: bool, - pub findings: Vec, -} - -pub fn validate_handoff_summary(summary: &HandoffSummary) -> HandoffValidationReport { - let mut findings = Vec::new(); - - if summary.objective.trim().is_empty() || summary.objective == "(objective unavailable)" { - findings.push(ValidationFinding { - code: "objective_missing".to_string(), - severity: "warning".to_string(), - message: "Objective is unavailable.".to_string(), - }); - } - - let unresolved_failures = unresolved_failed_commands(&summary.verification.checks_run); - if !unresolved_failures.is_empty() && summary.execution_contract.next_actions.is_empty() { - findings.push(ValidationFinding { - code: "next_actions_missing".to_string(), - severity: "warning".to_string(), - message: "Unresolved failed checks exist but no next action was generated.".to_string(), - }); - } - - if !summary.files_modified.is_empty() && summary.verification.checks_run.is_empty() { - findings.push(ValidationFinding { - code: "verification_missing".to_string(), - severity: "warning".to_string(), - message: "Files were modified but no verification check was recorded.".to_string(), - }); - } - - if summary.evidence.is_empty() { - findings.push(ValidationFinding { - code: "evidence_missing".to_string(), - severity: "warning".to_string(), - message: "No evidence references were generated.".to_string(), - }); - } else if !summary - .evidence - .iter() - .any(|ev| ev.claim.starts_with("objective:")) - { - findings.push(ValidationFinding { - code: "objective_evidence_missing".to_string(), - severity: "warning".to_string(), - message: "Objective exists but objective evidence is missing.".to_string(), - }); - } - - if has_work_package_cycle(&summary.work_packages) { - findings.push(ValidationFinding { - code: "work_package_cycle".to_string(), - severity: "error".to_string(), - message: "work_packages.depends_on contains a cycle.".to_string(), - }); - } - - let has_material_packages = summary.work_packages.iter().any(is_material_work_package); - if has_material_packages && summary.execution_contract.ordered_steps.is_empty() { - findings.push(ValidationFinding { - code: "ordered_steps_missing".to_string(), - severity: "warning".to_string(), - message: "Material work packages exist but execution_contract.ordered_steps is empty." - .to_string(), - }); - } else if !ordered_steps_are_consistent( - &summary.execution_contract.ordered_steps, - &summary.work_packages, - ) { - findings.push(ValidationFinding { - code: "ordered_steps_inconsistent".to_string(), - severity: "error".to_string(), - message: - "execution_contract.ordered_steps is not temporally or referentially consistent." - .to_string(), - }); - } - - HandoffValidationReport { - session_id: summary.source_session_id.clone(), - passed: findings.is_empty(), - findings, - } -} - -pub fn validate_handoff_summaries(summaries: &[HandoffSummary]) -> Vec { - summaries.iter().map(validate_handoff_summary).collect() -} - -fn has_work_package_cycle(packages: &[WorkPackage]) -> bool { - let mut state: HashMap<&str, u8> = HashMap::new(); - let deps: HashMap<&str, Vec<&str>> = packages - .iter() - .map(|wp| { - ( - wp.id.as_str(), - wp.depends_on.iter().map(String::as_str).collect::>(), - ) - }) - .collect(); - - fn dfs<'a>( - node: &'a str, - state: &mut HashMap<&'a str, u8>, - deps: &HashMap<&'a str, Vec<&'a str>>, - ) -> bool { - match state.get(node).copied() { - Some(1) => return true, - Some(2) => return false, - _ => {} - } - state.insert(node, 1); - if let Some(children) = deps.get(node) { - for child in children { - if !deps.contains_key(child) { - continue; - } - if dfs(child, state, deps) { - return true; - } - } - } - state.insert(node, 2); - false - } - - for node in deps.keys().copied() { - if dfs(node, &mut state, &deps) { - return true; - } - } - false -} - -fn ordered_steps_are_consistent(steps: &[OrderedStep], work_packages: &[WorkPackage]) -> bool { - if steps.is_empty() { - return true; - } - - if !steps - .windows(2) - .all(|pair| pair[0].sequence < pair[1].sequence) - { - return false; - } - - let known_ids = work_packages - .iter() - .map(|pkg| pkg.id.as_str()) - .collect::>(); - if !steps - .iter() - .all(|step| known_ids.contains(step.work_package_id.as_str())) - { - return false; - } - - let is_monotonic_time = |left: Option<&str>, right: Option<&str>| -> bool { - match (left, right) { - (Some(l), Some(r)) => { - let left = chrono::DateTime::parse_from_rfc3339(l).ok(); - let right = chrono::DateTime::parse_from_rfc3339(r).ok(); - match (left, right) { - (Some(l), Some(r)) => l <= r, - _ => false, - } - } - _ => true, - } - }; - - steps - .windows(2) - .all(|pair| is_monotonic_time(pair[0].started_at.as_deref(), pair[1].started_at.as_deref())) -} - -// ─── Markdown generation ───────────────────────────────────────────────────── - -/// Generate a v2 Markdown handoff document from a single session summary. -pub fn generate_handoff_markdown_v2(summary: &HandoffSummary) -> String { - let mut md = String::new(); - md.push_str("# Session Handoff\n\n"); - append_v2_markdown_sections(&mut md, summary); - md -} - -/// Generate a v2 Markdown handoff document from merged summaries. -pub fn generate_merged_handoff_markdown_v2(merged: &MergedHandoff) -> String { - let mut md = String::new(); - md.push_str("# Merged Session Handoff\n\n"); - md.push_str(&format!( - "**Sessions:** {} | **Total Duration:** {}\n\n", - merged.source_session_ids.len(), - format_duration(merged.total_duration_seconds) - )); - - for (idx, summary) in merged.summaries.iter().enumerate() { - md.push_str(&format!( - "---\n\n## Session {} — {}\n\n", - idx + 1, - summary.source_session_id - )); - append_v2_markdown_sections(&mut md, summary); - md.push('\n'); - } - - md -} - -fn append_v2_markdown_sections(md: &mut String, summary: &HandoffSummary) { - md.push_str("## Objective\n"); - md.push_str(&summary.objective); - md.push_str("\n\n"); - - md.push_str("## Current State\n"); - md.push_str(&format!( - "- **Tool:** {} ({})\n- **Duration:** {}\n- **Messages:** {} | Tool calls: {} | Events: {}\n", - summary.tool, - summary.model, - format_duration(summary.duration_seconds), - summary.stats.message_count, - summary.stats.tool_call_count, - summary.stats.event_count - )); - if !summary.execution_contract.done_definition.is_empty() { - md.push_str("- **Done:**\n"); - for done in &summary.execution_contract.done_definition { - md.push_str(&format!(" - {done}\n")); - } - } - if !summary.execution_contract.ordered_steps.is_empty() { - md.push_str("- **Execution Timeline (ordered):**\n"); - for step in &summary.execution_contract.ordered_steps { - let started = step.started_at.as_deref().unwrap_or("?"); - let completed = step.completed_at.as_deref().unwrap_or("-"); - if step.depends_on.is_empty() { - md.push_str(&format!( - " - [{}] `{}` [{}] status={} start={} done={}\n", - step.sequence, - step.title, - step.work_package_id, - step.status, - started, - completed - )); - } else { - md.push_str(&format!( - " - [{}] `{}` [{}] status={} start={} done={} deps=[{}]\n", - step.sequence, - step.title, - step.work_package_id, - step.status, - started, - completed, - step.depends_on.join(", ") - )); - } - } - } - md.push('\n'); - - md.push_str("## Next Actions (ordered)\n"); - if summary.execution_contract.next_actions.is_empty() { - md.push_str("_(none)_\n"); - } else { - for (idx, action) in summary.execution_contract.next_actions.iter().enumerate() { - md.push_str(&format!("{}. {}\n", idx + 1, action)); - } - } - if !summary.execution_contract.parallel_actions.is_empty() { - md.push_str("\nParallelizable Work Packages:\n"); - for action in &summary.execution_contract.parallel_actions { - md.push_str(&format!("- {action}\n")); - } - } - md.push('\n'); - - md.push_str("## Verification\n"); - if summary.verification.checks_run.is_empty() { - md.push_str("- checks_run: _(none)_\n"); - } else { - for check in &summary.verification.checks_run { - let code = check - .exit_code - .map(|c| c.to_string()) - .unwrap_or_else(|| "?".to_string()); - md.push_str(&format!( - "- [{}] `{}` (exit: {}, event: {})\n", - check.status, check.command, code, check.event_id - )); - } - } - if !summary.verification.required_checks_missing.is_empty() { - md.push_str("- required_checks_missing:\n"); - for item in &summary.verification.required_checks_missing { - md.push_str(&format!(" - {item}\n")); - } - } - md.push('\n'); - - md.push_str("## Blockers / Decisions\n"); - if summary.uncertainty.decision_required.is_empty() - && summary.uncertainty.open_questions.is_empty() - { - md.push_str("_(none)_\n"); - } else { - for item in &summary.uncertainty.decision_required { - md.push_str(&format!("- {item}\n")); - } - if !summary.uncertainty.open_questions.is_empty() { - md.push_str("- open_questions:\n"); - for item in &summary.uncertainty.open_questions { - md.push_str(&format!(" - {item}\n")); - } - } - } - md.push('\n'); - - md.push_str("## Evidence Index\n"); - if summary.evidence.is_empty() { - md.push_str("_(none)_\n"); - } else { - for ev in &summary.evidence { - md.push_str(&format!( - "- `{}` {} ({}, {}, {})\n", - ev.id, ev.claim, ev.event_id, ev.source_type, ev.timestamp - )); - } - } - md.push('\n'); - - md.push_str("## Conversations\n"); - if summary.key_conversations.is_empty() { - md.push_str("_(none)_\n"); - } else { - for (idx, conv) in summary.key_conversations.iter().enumerate() { - md.push_str(&format!( - "### {}. User\n{}\n\n### {}. Agent\n{}\n\n", - idx + 1, - truncate_str(&conv.user, 300), - idx + 1, - truncate_str(&conv.agent, 300) - )); - } - } - - md.push_str("## User Messages\n"); - if summary.user_messages.is_empty() { - md.push_str("_(none)_\n"); - } else { - for (idx, msg) in summary.user_messages.iter().enumerate() { - md.push_str(&format!("{}. {}\n", idx + 1, truncate_str(msg, 150))); - } - } -} - -/// Generate a Markdown handoff document from a single session summary. -pub fn generate_handoff_markdown(summary: &HandoffSummary) -> String { - const MAX_TASK_SUMMARIES_DISPLAY: usize = 5; - let mut md = String::new(); - - md.push_str("# Session Handoff\n\n"); - - // Objective - md.push_str("## Objective\n"); - md.push_str(&summary.objective); - md.push_str("\n\n"); - - // Summary - md.push_str("## Summary\n"); - md.push_str(&format!( - "- **Tool:** {} ({})\n", - summary.tool, summary.model - )); - md.push_str(&format!( - "- **Duration:** {}\n", - format_duration(summary.duration_seconds) - )); - md.push_str(&format!( - "- **Messages:** {} | Tool calls: {} | Events: {}\n", - summary.stats.message_count, summary.stats.tool_call_count, summary.stats.event_count - )); - md.push('\n'); - - if !summary.task_summaries.is_empty() { - md.push_str("## Task Summaries\n"); - for (idx, task_summary) in summary - .task_summaries - .iter() - .take(MAX_TASK_SUMMARIES_DISPLAY) - .enumerate() - { - md.push_str(&format!("{}. {}\n", idx + 1, task_summary)); - } - if summary.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY { - md.push_str(&format!( - "- ... and {} more\n", - summary.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY - )); - } - md.push('\n'); - } - - // Files Modified - if !summary.files_modified.is_empty() { - md.push_str("## Files Modified\n"); - for fc in &summary.files_modified { - md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action)); - } - md.push('\n'); - } - - // Files Read - if !summary.files_read.is_empty() { - md.push_str("## Files Read\n"); - for path in &summary.files_read { - md.push_str(&format!("- `{path}`\n")); - } - md.push('\n'); - } - - // Shell Commands - if !summary.shell_commands.is_empty() { - md.push_str("## Shell Commands\n"); - for cmd in &summary.shell_commands { - let code_str = match cmd.exit_code { - Some(c) => c.to_string(), - None => "?".to_string(), - }; - md.push_str(&format!( - "- `{}` → {}\n", - truncate_str(&cmd.command, 80), - code_str - )); - } - md.push('\n'); - } - - // Errors - if !summary.errors.is_empty() { - md.push_str("## Errors\n"); - for err in &summary.errors { - md.push_str(&format!("- {err}\n")); - } - md.push('\n'); - } - - // Key Conversations (user + agent pairs) - if !summary.key_conversations.is_empty() { - md.push_str("## Key Conversations\n"); - for (i, conv) in summary.key_conversations.iter().enumerate() { - md.push_str(&format!( - "### {}. User\n{}\n\n### {}. Agent\n{}\n\n", - i + 1, - truncate_str(&conv.user, 300), - i + 1, - truncate_str(&conv.agent, 300), - )); - } - } - - // User Messages (fallback list) - if summary.key_conversations.is_empty() && !summary.user_messages.is_empty() { - md.push_str("## User Messages\n"); - for (i, msg) in summary.user_messages.iter().enumerate() { - md.push_str(&format!("{}. {}\n", i + 1, truncate_str(msg, 150))); - } - md.push('\n'); - } - - md +fn collapse_whitespace(input: &str) -> String { + input.split_whitespace().collect::>().join(" ") } -/// Generate a Markdown handoff document from a merged multi-session handoff. -pub fn generate_merged_handoff_markdown(merged: &MergedHandoff) -> String { - const MAX_TASK_SUMMARIES_DISPLAY: usize = 3; - let mut md = String::new(); - - md.push_str("# Merged Session Handoff\n\n"); - md.push_str(&format!( - "**Sessions:** {} | **Total Duration:** {}\n\n", - merged.source_session_ids.len(), - format_duration(merged.total_duration_seconds) - )); - - // Per-session summaries - for (i, s) in merged.summaries.iter().enumerate() { - md.push_str(&format!( - "---\n\n## Session {} — {}\n\n", - i + 1, - s.source_session_id - )); - md.push_str(&format!("**Objective:** {}\n\n", s.objective)); - md.push_str(&format!( - "- **Tool:** {} ({}) | **Duration:** {}\n", - s.tool, - s.model, - format_duration(s.duration_seconds) - )); - md.push_str(&format!( - "- **Messages:** {} | Tool calls: {} | Events: {}\n\n", - s.stats.message_count, s.stats.tool_call_count, s.stats.event_count - )); - - if !s.task_summaries.is_empty() { - md.push_str("### Task Summaries\n"); - for (j, task_summary) in s - .task_summaries - .iter() - .take(MAX_TASK_SUMMARIES_DISPLAY) - .enumerate() - { - md.push_str(&format!("{}. {}\n", j + 1, task_summary)); - } - if s.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY { - md.push_str(&format!( - "- ... and {} more\n", - s.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY - )); - } - md.push('\n'); - } - - // Key Conversations for this session - if !s.key_conversations.is_empty() { - md.push_str("### Conversations\n"); - for (j, conv) in s.key_conversations.iter().enumerate() { - md.push_str(&format!( - "**{}. User:** {}\n\n**{}. Agent:** {}\n\n", - j + 1, - truncate_str(&conv.user, 200), - j + 1, - truncate_str(&conv.agent, 200), - )); - } - } - } - - // Combined files - md.push_str("---\n\n## All Files Modified\n"); - if merged.all_files_modified.is_empty() { - md.push_str("_(none)_\n"); +fn objective_unavailable_reason(objective: &str) -> Option { + if objective.trim().is_empty() || objective == "(objective unavailable)" { + Some( + "No user prompt, task title/summary, or session title could be used to infer objective." + .to_string(), + ) } else { - for fc in &merged.all_files_modified { - md.push_str(&format!("- `{}` ({})\n", fc.path, fc.action)); - } - } - md.push('\n'); - - if !merged.all_files_read.is_empty() { - md.push_str("## All Files Read\n"); - for path in &merged.all_files_read { - md.push_str(&format!("- `{path}`\n")); - } - md.push('\n'); - } - - // Errors - if !merged.total_errors.is_empty() { - md.push_str("## All Errors\n"); - for err in &merged.total_errors { - md.push_str(&format!("- {err}\n")); - } - md.push('\n'); - } - - md -} - -// ─── Summary HAIL generation ───────────────────────────────────────────────── - -/// Generate a summary HAIL session from an original session. -/// -/// Filters events to only include important ones and truncates content. -pub fn generate_handoff_hail(session: &Session) -> Session { - let mut summary_session = Session { - version: session.version.clone(), - session_id: format!("handoff-{}", session.session_id), - agent: session.agent.clone(), - context: SessionContext { - title: Some(format!( - "Handoff: {}", - session.context.title.as_deref().unwrap_or("(untitled)") - )), - description: session.context.description.clone(), - tags: { - let mut tags = session.context.tags.clone(); - if !tags.contains(&"handoff".to_string()) { - tags.push("handoff".to_string()); - } - tags - }, - created_at: session.context.created_at, - updated_at: chrono::Utc::now(), - related_session_ids: vec![session.session_id.clone()], - attributes: HashMap::new(), - }, - events: Vec::new(), - stats: session.stats.clone(), - }; - - for event in &session.events { - let keep = matches!( - &event.event_type, - EventType::UserMessage - | EventType::AgentMessage - | EventType::FileEdit { .. } - | EventType::FileCreate { .. } - | EventType::FileDelete { .. } - | EventType::TaskStart { .. } - | EventType::TaskEnd { .. } - ) || matches!(&event.event_type, EventType::ShellCommand { exit_code, .. } if *exit_code != Some(0)); - - if !keep { - continue; - } - - // Truncate content blocks - let truncated_blocks: Vec = event - .content - .blocks - .iter() - .map(|block| match block { - ContentBlock::Text { text } => ContentBlock::Text { - text: truncate_str(text, 300), - }, - ContentBlock::Code { - code, - language, - start_line, - } => ContentBlock::Code { - code: truncate_str(code, 300), - language: language.clone(), - start_line: *start_line, - }, - other => other.clone(), - }) - .collect(); - - summary_session.events.push(Event { - event_id: event.event_id.clone(), - timestamp: event.timestamp, - event_type: event.event_type.clone(), - task_id: event.task_id.clone(), - content: Content { - blocks: truncated_blocks, - }, - duration_ms: event.duration_ms, - attributes: HashMap::new(), // strip detailed attributes - }); - } - - // Recompute stats for the filtered events - summary_session.recompute_stats(); - - summary_session -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -fn extract_first_user_text(session: &Session) -> Option { - crate::extract::extract_first_user_text(session) -} - -fn extract_objective(session: &Session) -> String { - if let Some(user_text) = extract_first_user_text(session).filter(|t| !t.trim().is_empty()) { - return truncate_str(&collapse_whitespace(&user_text), 200); - } - - if let Some(task_title) = session - .events - .iter() - .find_map(|event| match &event.event_type { - EventType::TaskStart { title: Some(title) } => { - let title = title.trim(); - if title.is_empty() { - None - } else { - Some(title.to_string()) - } - } - _ => None, - }) - { - return truncate_str(&collapse_whitespace(&task_title), 200); - } - - if let Some(task_summary) = session - .events - .iter() - .find_map(|event| match &event.event_type { - EventType::TaskEnd { - summary: Some(summary), - } => { - let summary = summary.trim(); - if summary.is_empty() { - None - } else { - Some(summary.to_string()) - } - } - _ => None, - }) - { - return truncate_str(&collapse_whitespace(&task_summary), 200); - } - - if let Some(title) = session.context.title.as_deref().map(str::trim) { - if !title.is_empty() { - return truncate_str(&collapse_whitespace(title), 200); - } - } - - "(objective unavailable)".to_string() -} - -fn extract_text_from_event(event: &Event) -> Option { - for block in &event.content.blocks { - if let ContentBlock::Text { text } = block { - let trimmed = text.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } + None } - None -} - -fn collapse_whitespace(input: &str) -> String { - input.split_whitespace().collect::>().join(" ") } /// Format seconds into a human-readable duration string. @@ -2025,782 +608,3 @@ pub fn format_duration(seconds: u64) -> String { format!("{h}h {m}m {s}s") } } - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use crate::{testing, Agent}; - - fn make_agent() -> Agent { - testing::agent() - } - - fn make_event(event_type: EventType, text: &str) -> Event { - testing::event(event_type, text) - } - - #[test] - fn test_format_duration() { - assert_eq!(format_duration(0), "0s"); - assert_eq!(format_duration(45), "45s"); - assert_eq!(format_duration(90), "1m 30s"); - assert_eq!(format_duration(750), "12m 30s"); - assert_eq!(format_duration(3661), "1h 1m 1s"); - } - - #[test] - fn test_handoff_summary_from_session() { - let mut session = Session::new("test-id".to_string(), make_agent()); - session.stats = Stats { - event_count: 10, - message_count: 3, - tool_call_count: 5, - duration_seconds: 750, - ..Default::default() - }; - session - .events - .push(make_event(EventType::UserMessage, "Fix the build error")); - session - .events - .push(make_event(EventType::AgentMessage, "I'll fix it now")); - session.events.push(make_event( - EventType::FileEdit { - path: "src/main.rs".to_string(), - diff: None, - }, - "", - )); - session.events.push(make_event( - EventType::FileRead { - path: "Cargo.toml".to_string(), - }, - "", - )); - session.events.push(make_event( - EventType::ShellCommand { - command: "cargo build".to_string(), - exit_code: Some(0), - }, - "", - )); - session.events.push(make_event( - EventType::TaskEnd { - summary: Some("Build now passes in local env".to_string()), - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - - assert_eq!(summary.source_session_id, "test-id"); - assert_eq!(summary.objective, "Fix the build error"); - assert_eq!(summary.files_modified.len(), 1); - assert_eq!(summary.files_modified[0].path, "src/main.rs"); - assert_eq!(summary.files_modified[0].action, "edited"); - assert_eq!(summary.files_read, vec!["Cargo.toml"]); - assert_eq!(summary.shell_commands.len(), 1); - assert_eq!( - summary.task_summaries, - vec!["Build now passes in local env".to_string()] - ); - assert_eq!(summary.key_conversations.len(), 1); - assert_eq!(summary.key_conversations[0].user, "Fix the build error"); - assert_eq!(summary.key_conversations[0].agent, "I'll fix it now"); - } - - #[test] - fn test_handoff_objective_falls_back_to_task_title() { - let mut session = Session::new("task-title-fallback".to_string(), make_agent()); - session.context.title = Some("session-019c-example.jsonl".to_string()); - session.events.push(make_event( - EventType::TaskStart { - title: Some("Refactor auth middleware for oauth callback".to_string()), - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - assert_eq!( - summary.objective, - "Refactor auth middleware for oauth callback" - ); - } - - #[test] - fn test_handoff_task_summaries_are_deduplicated() { - let mut session = Session::new("task-summary-dedupe".to_string(), make_agent()); - session.events.push(make_event( - EventType::TaskEnd { - summary: Some("Add worker profile guard".to_string()), - }, - "", - )); - session.events.push(make_event( - EventType::TaskEnd { - summary: Some(" ".to_string()), - }, - "", - )); - session.events.push(make_event( - EventType::TaskEnd { - summary: Some("Add worker profile guard".to_string()), - }, - "", - )); - session.events.push(make_event( - EventType::TaskEnd { - summary: Some("Hide teams nav for worker profile".to_string()), - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - assert_eq!( - summary.task_summaries, - vec![ - "Add worker profile guard".to_string(), - "Hide teams nav for worker profile".to_string() - ] - ); - } - - #[test] - fn test_files_read_excludes_modified() { - let mut session = Session::new("test-id".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "test")); - session.events.push(make_event( - EventType::FileRead { - path: "src/main.rs".to_string(), - }, - "", - )); - session.events.push(make_event( - EventType::FileEdit { - path: "src/main.rs".to_string(), - diff: None, - }, - "", - )); - session.events.push(make_event( - EventType::FileRead { - path: "README.md".to_string(), - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - assert_eq!(summary.files_read, vec!["README.md"]); - assert_eq!(summary.files_modified.len(), 1); - } - - #[test] - fn test_file_create_not_overwritten_by_edit() { - let mut session = Session::new("test-id".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "test")); - session.events.push(make_event( - EventType::FileCreate { - path: "new_file.rs".to_string(), - }, - "", - )); - session.events.push(make_event( - EventType::FileEdit { - path: "new_file.rs".to_string(), - diff: None, - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - assert_eq!(summary.files_modified[0].action, "created"); - } - - #[test] - fn test_shell_error_captured() { - let mut session = Session::new("test-id".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "test")); - session.events.push(make_event( - EventType::ShellCommand { - command: "cargo test".to_string(), - exit_code: Some(1), - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - assert_eq!(summary.errors.len(), 1); - assert!(summary.errors[0].contains("cargo test")); - } - - #[test] - fn test_generate_handoff_markdown() { - let mut session = Session::new("test-id".to_string(), make_agent()); - session.stats = Stats { - event_count: 10, - message_count: 3, - tool_call_count: 5, - duration_seconds: 750, - ..Default::default() - }; - session - .events - .push(make_event(EventType::UserMessage, "Fix the build error")); - session - .events - .push(make_event(EventType::AgentMessage, "I'll fix it now")); - session.events.push(make_event( - EventType::FileEdit { - path: "src/main.rs".to_string(), - diff: None, - }, - "", - )); - session.events.push(make_event( - EventType::ShellCommand { - command: "cargo build".to_string(), - exit_code: Some(0), - }, - "", - )); - session.events.push(make_event( - EventType::TaskEnd { - summary: Some("Compile error fixed by updating trait bounds".to_string()), - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - let md = generate_handoff_markdown(&summary); - - assert!(md.contains("# Session Handoff")); - assert!(md.contains("Fix the build error")); - assert!(md.contains("claude-code (claude-opus-4-6)")); - assert!(md.contains("12m 30s")); - assert!(md.contains("## Task Summaries")); - assert!(md.contains("Compile error fixed by updating trait bounds")); - assert!(md.contains("`src/main.rs` (edited)")); - assert!(md.contains("`cargo build` → 0")); - assert!(md.contains("## Key Conversations")); - } - - #[test] - fn test_merge_summaries() { - let mut s1 = Session::new("session-a".to_string(), make_agent()); - s1.stats.duration_seconds = 100; - s1.events.push(make_event(EventType::UserMessage, "task A")); - s1.events.push(make_event( - EventType::FileEdit { - path: "a.rs".to_string(), - diff: None, - }, - "", - )); - - let mut s2 = Session::new("session-b".to_string(), make_agent()); - s2.stats.duration_seconds = 200; - s2.events.push(make_event(EventType::UserMessage, "task B")); - s2.events.push(make_event( - EventType::FileEdit { - path: "b.rs".to_string(), - diff: None, - }, - "", - )); - - let sum1 = HandoffSummary::from_session(&s1); - let sum2 = HandoffSummary::from_session(&s2); - let merged = merge_summaries(&[sum1, sum2]); - - assert_eq!(merged.source_session_ids.len(), 2); - assert_eq!(merged.total_duration_seconds, 300); - assert_eq!(merged.all_files_modified.len(), 2); - } - - #[test] - fn test_generate_handoff_hail() { - let mut session = Session::new("test-id".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "Hello")); - session - .events - .push(make_event(EventType::AgentMessage, "Hi there")); - session.events.push(make_event( - EventType::FileRead { - path: "foo.rs".to_string(), - }, - "", - )); - session.events.push(make_event( - EventType::FileEdit { - path: "foo.rs".to_string(), - diff: Some("+added line".to_string()), - }, - "", - )); - session.events.push(make_event( - EventType::ShellCommand { - command: "cargo build".to_string(), - exit_code: Some(0), - }, - "", - )); - - let hail = generate_handoff_hail(&session); - - assert!(hail.session_id.starts_with("handoff-")); - assert_eq!(hail.context.related_session_ids, vec!["test-id"]); - assert!(hail.context.tags.contains(&"handoff".to_string())); - // FileRead and successful ShellCommand should be filtered out - assert_eq!(hail.events.len(), 3); // UserMessage, AgentMessage, FileEdit - // Verify HAIL roundtrip - let jsonl = hail.to_jsonl().unwrap(); - let parsed = Session::from_jsonl(&jsonl).unwrap(); - assert_eq!(parsed.session_id, hail.session_id); - } - - #[test] - fn test_generate_handoff_markdown_v2_section_order() { - let mut session = Session::new("v2-sections".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "Implement handoff v2")); - session.events.push(make_event( - EventType::FileEdit { - path: "crates/core/src/handoff.rs".to_string(), - diff: None, - }, - "", - )); - session.events.push(make_event( - EventType::ShellCommand { - command: "cargo test".to_string(), - exit_code: Some(0), - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - let md = generate_handoff_markdown_v2(&summary); - - let order = [ - "## Objective", - "## Current State", - "## Next Actions (ordered)", - "## Verification", - "## Blockers / Decisions", - "## Evidence Index", - "## Conversations", - "## User Messages", - ]; - - let mut last_idx = 0usize; - for section in order { - let idx = md.find(section).unwrap(); - assert!( - idx >= last_idx, - "section order mismatch for {section}: idx={idx}, last={last_idx}" - ); - last_idx = idx; - } - } - - #[test] - fn test_execution_contract_and_verification_from_failed_command() { - let mut session = Session::new("failed-check".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "Fix failing tests")); - session.events.push(make_event( - EventType::FileEdit { - path: "src/lib.rs".to_string(), - diff: None, - }, - "", - )); - session.events.push(make_event( - EventType::ShellCommand { - command: "cargo test".to_string(), - exit_code: Some(1), - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - assert!(summary - .verification - .checks_failed - .contains(&"cargo test".to_string())); - assert!(summary - .execution_contract - .next_actions - .iter() - .any(|action| action.contains("cargo test"))); - assert_eq!( - summary.execution_contract.ordered_commands.first(), - Some(&"cargo test".to_string()) - ); - assert!(summary.execution_contract.parallel_actions.is_empty()); - assert!(summary.execution_contract.rollback_hint.is_none()); - assert!(summary - .execution_contract - .rollback_hint_missing_reason - .is_some()); - assert!(summary - .execution_contract - .rollback_hint_undefined_reason - .is_some()); - } - - #[test] - fn test_validate_handoff_summary_flags_missing_objective() { - let session = Session::new("missing-objective".to_string(), make_agent()); - let summary = HandoffSummary::from_session(&session); - assert!(summary.objective_undefined_reason.is_some()); - assert!(summary - .undefined_fields - .iter() - .any(|f| f.path == "objective")); - let report = validate_handoff_summary(&summary); - - assert!(!report.passed); - assert!(report - .findings - .iter() - .any(|f| f.code == "objective_missing")); - } - - #[test] - fn test_validate_handoff_summary_flags_cycle() { - let mut session = Session::new("cycle-case".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "test")); - let mut summary = HandoffSummary::from_session(&session); - summary.work_packages = vec![ - WorkPackage { - id: "a".to_string(), - title: "A".to_string(), - status: "pending".to_string(), - sequence: 1, - started_at: None, - completed_at: None, - outcome: None, - depends_on: vec!["b".to_string()], - files: Vec::new(), - commands: Vec::new(), - evidence_refs: Vec::new(), - }, - WorkPackage { - id: "b".to_string(), - title: "B".to_string(), - status: "pending".to_string(), - sequence: 2, - started_at: None, - completed_at: None, - outcome: None, - depends_on: vec!["a".to_string()], - files: Vec::new(), - commands: Vec::new(), - evidence_refs: Vec::new(), - }, - ]; - - let report = validate_handoff_summary(&summary); - assert!(report - .findings - .iter() - .any(|f| f.code == "work_package_cycle")); - } - - #[test] - fn test_validate_handoff_summary_requires_next_actions_for_failed_checks() { - let mut session = Session::new("missing-next-action".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "test")); - let mut summary = HandoffSummary::from_session(&session); - summary.verification.checks_run = vec![CheckRun { - command: "cargo test".to_string(), - status: "failed".to_string(), - exit_code: Some(1), - event_id: "evt-1".to_string(), - }]; - summary.execution_contract.next_actions.clear(); - - let report = validate_handoff_summary(&summary); - assert!(report - .findings - .iter() - .any(|f| f.code == "next_actions_missing")); - } - - #[test] - fn test_validate_handoff_summary_flags_missing_objective_evidence() { - let mut session = Session::new("missing-objective-evidence".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "keep objective")); - let mut summary = HandoffSummary::from_session(&session); - summary.evidence = vec![EvidenceRef { - id: "evidence-1".to_string(), - claim: "task_done: something".to_string(), - event_id: "evt".to_string(), - timestamp: "2026-02-01T00:00:00Z".to_string(), - source_type: "TaskEnd".to_string(), - }]; - - let report = validate_handoff_summary(&summary); - assert!(report - .findings - .iter() - .any(|f| f.code == "objective_evidence_missing")); - } - - #[test] - fn test_execution_contract_includes_parallel_actions_for_independent_work_packages() { - let mut session = Session::new("parallel-actions".to_string(), make_agent()); - session.events.push(make_event( - EventType::UserMessage, - "Refactor two independent modules", - )); - - let mut a_start = make_event( - EventType::TaskStart { - title: Some("Refactor auth".to_string()), - }, - "", - ); - a_start.task_id = Some("auth".to_string()); - session.events.push(a_start); - - let mut a_edit = make_event( - EventType::FileEdit { - path: "src/auth.rs".to_string(), - diff: None, - }, - "", - ); - a_edit.task_id = Some("auth".to_string()); - session.events.push(a_edit); - - let mut b_start = make_event( - EventType::TaskStart { - title: Some("Refactor billing".to_string()), - }, - "", - ); - b_start.task_id = Some("billing".to_string()); - session.events.push(b_start); - - let mut b_edit = make_event( - EventType::FileEdit { - path: "src/billing.rs".to_string(), - diff: None, - }, - "", - ); - b_edit.task_id = Some("billing".to_string()); - session.events.push(b_edit); - - let summary = HandoffSummary::from_session(&session); - assert!(summary - .execution_contract - .parallel_actions - .iter() - .any(|action| action.contains("auth"))); - assert!(summary - .execution_contract - .parallel_actions - .iter() - .any(|action| action.contains("billing"))); - let md = generate_handoff_markdown_v2(&summary); - assert!(md.contains("Parallelizable Work Packages")); - } - - #[test] - fn test_done_definition_prefers_material_signals() { - let mut session = Session::new("material-signals".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "Implement feature X")); - session.events.push(make_event( - EventType::FileEdit { - path: "src/lib.rs".to_string(), - diff: None, - }, - "", - )); - session.events.push(make_event( - EventType::ShellCommand { - command: "cargo test".to_string(), - exit_code: Some(0), - }, - "", - )); - - let summary = HandoffSummary::from_session(&session); - assert!(summary - .execution_contract - .done_definition - .iter() - .any(|item| item.contains("Verification passed: `cargo test`"))); - assert!(summary - .execution_contract - .done_definition - .iter() - .any(|item| item.contains("Changed 1 file(s): `src/lib.rs`"))); - assert!(summary - .execution_contract - .ordered_steps - .iter() - .any(|step| step.work_package_id == "main")); - } - - #[test] - fn test_ordered_steps_keep_temporal_and_task_context() { - let mut session = Session::new("ordered-steps".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "Process two tasks")); - - let mut t1_start = make_event( - EventType::TaskStart { - title: Some("Prepare migration".to_string()), - }, - "", - ); - t1_start.task_id = Some("task-1".to_string()); - session.events.push(t1_start); - - let mut t1_end = make_event( - EventType::TaskEnd { - summary: Some("Migration script prepared".to_string()), - }, - "", - ); - t1_end.task_id = Some("task-1".to_string()); - session.events.push(t1_end); - - let mut t2_start = make_event( - EventType::TaskStart { - title: Some("Run verification".to_string()), - }, - "", - ); - t2_start.task_id = Some("task-2".to_string()); - session.events.push(t2_start); - - let mut t2_cmd = make_event( - EventType::ShellCommand { - command: "cargo test".to_string(), - exit_code: Some(0), - }, - "", - ); - t2_cmd.task_id = Some("task-2".to_string()); - session.events.push(t2_cmd); - - let summary = HandoffSummary::from_session(&session); - let steps = &summary.execution_contract.ordered_steps; - assert_eq!(steps.len(), 2); - assert!(steps[0].sequence < steps[1].sequence); - assert_eq!(steps[0].work_package_id, "task-1"); - assert_eq!(steps[1].work_package_id, "task-2"); - assert!(steps[0].completed_at.is_some()); - assert!(summary - .work_packages - .iter() - .find(|pkg| pkg.id == "task-1") - .and_then(|pkg| pkg.outcome.as_deref()) - .is_some()); - } - - #[test] - fn test_validate_handoff_summary_flags_inconsistent_ordered_steps() { - let mut session = Session::new("invalid-ordered-steps".to_string(), make_agent()); - session - .events - .push(make_event(EventType::UserMessage, "test ordered steps")); - let mut summary = HandoffSummary::from_session(&session); - summary.work_packages = vec![WorkPackage { - id: "main".to_string(), - title: "Main flow".to_string(), - status: "completed".to_string(), - sequence: 1, - started_at: Some("2026-02-19T00:00:00Z".to_string()), - completed_at: Some("2026-02-19T00:01:00Z".to_string()), - outcome: Some("done".to_string()), - depends_on: Vec::new(), - files: vec!["src/lib.rs".to_string()], - commands: Vec::new(), - evidence_refs: Vec::new(), - }]; - summary.execution_contract.ordered_steps = vec![OrderedStep { - sequence: 1, - work_package_id: "missing".to_string(), - title: "missing".to_string(), - status: "completed".to_string(), - depends_on: Vec::new(), - started_at: Some("2026-02-19T00:00:00Z".to_string()), - completed_at: Some("2026-02-19T00:01:00Z".to_string()), - evidence_refs: Vec::new(), - }]; - - let report = validate_handoff_summary(&summary); - assert!(report - .findings - .iter() - .any(|finding| finding.code == "ordered_steps_inconsistent")); - } - - #[test] - fn test_message_and_conversation_collections_are_condensed() { - let mut session = Session::new("condense".to_string(), make_agent()); - - for i in 0..24 { - session - .events - .push(make_event(EventType::UserMessage, &format!("user-{i}"))); - session - .events - .push(make_event(EventType::AgentMessage, &format!("agent-{i}"))); - } - - let summary = HandoffSummary::from_session(&session); - assert_eq!(summary.user_messages.len(), MAX_USER_MESSAGES); - assert_eq!( - summary.user_messages.first().map(String::as_str), - Some("user-0") - ); - assert_eq!( - summary.user_messages.last().map(String::as_str), - Some("user-23") - ); - - assert_eq!(summary.key_conversations.len(), MAX_KEY_CONVERSATIONS); - assert_eq!( - summary - .key_conversations - .first() - .map(|conv| conv.user.as_str()), - Some("user-0") - ); - assert_eq!( - summary - .key_conversations - .last() - .map(|conv| conv.user.as_str()), - Some("user-23") - ); - } -} diff --git a/crates/core/src/handoff/execution.rs b/crates/core/src/handoff/execution.rs new file mode 100644 index 00000000..4934d7b9 --- /dev/null +++ b/crates/core/src/handoff/execution.rs @@ -0,0 +1,680 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use crate::extract::truncate_str; +use crate::{Event, EventType, Session}; + +use super::{ + CheckRun, EvidenceRef, ExecutionContract, FileChange, OrderedStep, ShellCmd, Uncertainty, + UndefinedField, Verification, WorkPackage, collapse_whitespace, +}; + +pub(super) fn build_execution_contract( + task_summaries: &[String], + verification: &Verification, + uncertainty: &Uncertainty, + shell_commands: &[ShellCmd], + files_modified: &[FileChange], + work_packages: &[WorkPackage], +) -> ExecutionContract { + let ordered_steps = work_packages + .iter() + .filter(|pkg| is_material_work_package(pkg)) + .map(|pkg| OrderedStep { + sequence: pkg.sequence, + work_package_id: pkg.id.clone(), + title: pkg.title.clone(), + status: pkg.status.clone(), + depends_on: pkg.depends_on.clone(), + started_at: pkg.started_at.clone(), + completed_at: pkg.completed_at.clone(), + evidence_refs: pkg.evidence_refs.clone(), + }) + .collect::>(); + + let mut done_definition = ordered_steps + .iter() + .filter(|step| step.status == "completed") + .map(|step| { + let pkg = work_packages + .iter() + .find(|pkg| pkg.id == step.work_package_id) + .expect("ordered step must map to existing work package"); + let mut details = Vec::new(); + if let Some(outcome) = pkg.outcome.as_deref() { + details.push(format!("outcome: {}", truncate_str(outcome, 140))); + } + let footprint = work_package_footprint(pkg); + if !footprint.is_empty() { + details.push(footprint); + } + let at = step + .completed_at + .as_deref() + .or(step.started_at.as_deref()) + .unwrap_or("time-unavailable"); + if details.is_empty() { + format!("[{}] Completed `{}` at {}.", step.sequence, step.title, at) + } else { + format!( + "[{}] Completed `{}` at {} ({}).", + step.sequence, + step.title, + at, + details.join("; ") + ) + } + }) + .collect::>(); + + if !verification.checks_passed.is_empty() { + let keep = verification + .checks_passed + .iter() + .take(3) + .map(|check| format!("`{check}`")) + .collect::>(); + let extra = verification.checks_passed.len().saturating_sub(3); + if extra > 0 { + done_definition.push(format!( + "Verification passed: {} (+{} more).", + keep.join(", "), + extra + )); + } else { + done_definition.push(format!("Verification passed: {}.", keep.join(", "))); + } + } + + if !files_modified.is_empty() { + let keep = files_modified + .iter() + .take(3) + .map(|file| format!("`{}`", file.path)) + .collect::>(); + let extra = files_modified.len().saturating_sub(3); + if extra > 0 { + done_definition.push(format!( + "Changed {} file(s): {} (+{} more).", + files_modified.len(), + keep.join(", "), + extra + )); + } else { + done_definition.push(format!( + "Changed {} file(s): {}.", + files_modified.len(), + keep.join(", ") + )); + } + } + + if done_definition.is_empty() { + done_definition.extend(task_summaries.iter().take(5).cloned()); + } + dedupe_keep_order(&mut done_definition); + + let mut next_actions = unresolved_failed_commands(&verification.checks_run) + .into_iter() + .map(|cmd| format!("Fix and re-run `{cmd}` until the check passes.")) + .collect::>(); + next_actions.extend( + verification + .required_checks_missing + .iter() + .map(|missing| format!("Add/restore verification check: {missing}")), + ); + next_actions.extend(ordered_steps.iter().filter_map(|step| { + if step.status == "completed" || step.depends_on.is_empty() { + return None; + } + Some(format!( + "[{}] After dependencies [{}], execute `{}` ({}).", + step.sequence, + step.depends_on.join(", "), + step.title, + step.work_package_id + )) + })); + next_actions.extend( + uncertainty + .open_questions + .iter() + .map(|q| format!("Resolve open question: {q}")), + ); + let mut parallel_actions = ordered_steps + .iter() + .filter(|step| { + step.status != "completed" + && step.depends_on.is_empty() + && step.work_package_id != "main" + }) + .map(|step| { + let at = step.started_at.as_deref().unwrap_or("time-unavailable"); + format!( + "[{}] `{}` ({}) — start: {}", + step.sequence, step.title, step.work_package_id, at + ) + }) + .collect::>(); + + if done_definition.is_empty() + && next_actions.is_empty() + && parallel_actions.is_empty() + && ordered_steps.is_empty() + { + next_actions.push( + "Define completion criteria and run at least one verification command.".to_string(), + ); + } + dedupe_keep_order(&mut next_actions); + dedupe_keep_order(&mut parallel_actions); + + let unresolved = unresolved_failed_commands(&verification.checks_run); + let mut ordered_commands = unresolved; + for cmd in shell_commands + .iter() + .map(|c| collapse_whitespace(&c.command)) + { + if !ordered_commands.iter().any(|existing| existing == &cmd) { + ordered_commands.push(cmd); + } + } + + let has_git_commit = shell_commands + .iter() + .any(|cmd| cmd.command.to_ascii_lowercase().contains("git commit")); + let (rollback_hint, rollback_hint_missing_reason) = if has_git_commit { + ( + Some( + "Use `git revert ` for committed changes, then re-run verification." + .to_string(), + ), + None, + ) + } else { + ( + None, + Some("No committed change signal found in events.".to_string()), + ) + }; + + ExecutionContract { + done_definition, + next_actions, + parallel_actions, + ordered_steps, + ordered_commands, + rollback_hint, + rollback_hint_missing_reason: rollback_hint_missing_reason.clone(), + rollback_hint_undefined_reason: rollback_hint_missing_reason, + } +} + +fn work_package_footprint(pkg: &WorkPackage) -> String { + let mut details = Vec::new(); + if !pkg.files.is_empty() { + details.push(format!("files: {}", pkg.files.len())); + } + if !pkg.commands.is_empty() { + details.push(format!("commands: {}", pkg.commands.len())); + } + details.join(", ") +} + +pub(super) fn collect_evidence( + session: &Session, + objective: &str, + task_summaries: &[String], + uncertainty: &Uncertainty, +) -> Vec { + let mut evidence = Vec::new(); + let mut next_id = 1usize; + + if let Some(event) = find_objective_event(session) { + evidence.push(EvidenceRef { + id: format!("evidence-{next_id}"), + claim: format!("objective: {objective}"), + event_id: event.event_id.clone(), + timestamp: event.timestamp.to_rfc3339(), + source_type: event_source_type(event), + }); + next_id += 1; + } + + for summary in task_summaries { + if let Some(event) = find_task_summary_event(&session.events, summary) { + evidence.push(EvidenceRef { + id: format!("evidence-{next_id}"), + claim: format!("task_done: {summary}"), + event_id: event.event_id.clone(), + timestamp: event.timestamp.to_rfc3339(), + source_type: event_source_type(event), + }); + next_id += 1; + } + } + + for decision in &uncertainty.decision_required { + if let Some(event) = find_decision_event(&session.events, decision) { + evidence.push(EvidenceRef { + id: format!("evidence-{next_id}"), + claim: format!("decision_required: {decision}"), + event_id: event.event_id.clone(), + timestamp: event.timestamp.to_rfc3339(), + source_type: event_source_type(event), + }); + next_id += 1; + } + } + + evidence +} + +pub(super) fn build_work_packages(events: &[Event], evidence: &[EvidenceRef]) -> Vec { + #[derive(Default)] + struct WorkPackageAcc { + title: Option, + status: String, + outcome: Option, + first_ts: Option>, + first_idx: Option, + completed_ts: Option>, + files: HashSet, + commands: Vec, + evidence_refs: Vec, + } + + let mut evidence_by_event: HashMap<&str, Vec> = HashMap::new(); + for ev in evidence { + evidence_by_event + .entry(ev.event_id.as_str()) + .or_default() + .push(ev.id.clone()); + } + + let mut grouped: BTreeMap = BTreeMap::new(); + for (event_idx, event) in events.iter().enumerate() { + let key = package_key_for_event(event); + let acc = grouped + .entry(key.clone()) + .or_insert_with(|| WorkPackageAcc { + status: "pending".to_string(), + ..Default::default() + }); + + if acc.first_ts.is_none() { + acc.first_ts = Some(event.timestamp); + acc.first_idx = Some(event_idx); + } + if let Some(ids) = evidence_by_event.get(event.event_id.as_str()) { + acc.evidence_refs.extend(ids.clone()); + } + + match &event.event_type { + EventType::TaskStart { title } => { + if let Some(title) = title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + acc.title = Some(title.to_string()); + } + if acc.status != "completed" { + acc.status = "in_progress".to_string(); + } + } + EventType::TaskEnd { summary } => { + acc.status = "completed".to_string(); + acc.completed_ts = Some(event.timestamp); + if let Some(summary) = summary + .as_deref() + .map(collapse_whitespace) + .filter(|summary| !summary.is_empty()) + { + acc.outcome = Some(summary.clone()); + if acc.title.is_none() { + acc.title = Some(truncate_str(&summary, 160)); + } + } + } + EventType::FileEdit { path, .. } + | EventType::FileCreate { path } + | EventType::FileDelete { path } => { + acc.files.insert(path.clone()); + if acc.status == "pending" { + acc.status = "in_progress".to_string(); + } + } + EventType::ShellCommand { command, .. } => { + acc.commands.push(collapse_whitespace(command)); + if acc.status == "pending" { + acc.status = "in_progress".to_string(); + } + } + _ => {} + } + } + + let mut by_first_seen = grouped + .into_iter() + .map(|(id, mut acc)| { + dedupe_keep_order(&mut acc.commands); + dedupe_keep_order(&mut acc.evidence_refs); + let mut files: Vec = acc.files.into_iter().collect(); + files.sort(); + ( + acc.first_ts, + acc.first_idx.unwrap_or(usize::MAX), + WorkPackage { + title: acc.title.unwrap_or_else(|| { + if id == "main" { + "Main flow".to_string() + } else { + format!("Task {id}") + } + }), + id, + status: acc.status, + sequence: 0, + started_at: acc.first_ts.map(|ts| ts.to_rfc3339()), + completed_at: acc.completed_ts.map(|ts| ts.to_rfc3339()), + outcome: acc.outcome, + depends_on: Vec::new(), + files, + commands: acc.commands, + evidence_refs: acc.evidence_refs, + }, + ) + }) + .collect::>(); + + by_first_seen.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.id.cmp(&b.2.id)) + }); + + let mut packages = by_first_seen + .into_iter() + .map(|(_, _, package)| package) + .collect::>(); + + for (idx, package) in packages.iter_mut().enumerate() { + package.sequence = (idx + 1) as u32; + } + + for i in 0..packages.len() { + let cur_files: HashSet<&str> = packages[i].files.iter().map(String::as_str).collect(); + if cur_files.is_empty() { + continue; + } + let mut dependency: Option = None; + for j in (0..i).rev() { + let prev_files: HashSet<&str> = packages[j].files.iter().map(String::as_str).collect(); + if !prev_files.is_empty() && !cur_files.is_disjoint(&prev_files) { + dependency = Some(packages[j].id.clone()); + break; + } + } + if let Some(dep) = dependency { + packages[i].depends_on.push(dep); + } + } + + packages.retain(|pkg| pkg.id == "main" || is_material_work_package(pkg)); + let known_ids: HashSet = packages.iter().map(|pkg| pkg.id.clone()).collect(); + for pkg in &mut packages { + pkg.depends_on.retain(|dep| known_ids.contains(dep)); + } + + packages +} + +fn is_generic_work_package_title(id: &str, title: &str) -> bool { + title == "Main flow" || title == format!("Task {id}") +} + +pub(super) fn is_material_work_package(pkg: &WorkPackage) -> bool { + if !pkg.files.is_empty() || !pkg.commands.is_empty() || pkg.outcome.is_some() { + return true; + } + + if pkg.id == "main" { + return pkg.status != "pending"; + } + + if !is_generic_work_package_title(&pkg.id, &pkg.title) { + return true; + } + + pkg.status == "completed" && !pkg.evidence_refs.is_empty() +} + +fn package_key_for_event(event: &Event) -> String { + if let Some(task_id) = event + .task_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + return task_id.to_string(); + } + if let Some(group_id) = event.semantic_group_id() { + return group_id.to_string(); + } + "main".to_string() +} + +fn find_objective_event(session: &Session) -> Option<&Event> { + session + .events + .iter() + .find(|event| matches!(event.event_type, EventType::UserMessage)) + .or_else(|| { + session.events.iter().find(|event| { + matches!( + event.event_type, + EventType::TaskStart { .. } | EventType::TaskEnd { .. } + ) + }) + }) +} + +fn find_task_summary_event<'a>(events: &'a [Event], summary: &str) -> Option<&'a Event> { + let normalized_target = collapse_whitespace(summary); + events.iter().find(|event| { + let EventType::TaskEnd { + summary: Some(candidate), + } = &event.event_type + else { + return false; + }; + collapse_whitespace(candidate) == normalized_target + }) +} + +fn find_decision_event<'a>(events: &'a [Event], decision: &str) -> Option<&'a Event> { + if decision.to_ascii_lowercase().contains("turn aborted") { + return events.iter().find(|event| { + matches!( + &event.event_type, + EventType::Custom { kind } if kind == "turn_aborted" + ) + }); + } + if decision.to_ascii_lowercase().contains("open question") { + return events + .iter() + .find(|event| event.attr_str("source") == Some("interactive_question")); + } + None +} + +pub(super) fn collect_open_questions(events: &[Event]) -> Vec { + let mut question_meta: BTreeMap = BTreeMap::new(); + let mut asked_order = Vec::new(); + let mut answered_ids = HashSet::new(); + + for event in events { + if event.attr_str("source") == Some("interactive_question") { + if let Some(items) = event + .attributes + .get("question_meta") + .and_then(|v| v.as_array()) + { + for item in items { + let Some(id) = item + .get("id") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + else { + continue; + }; + let text = item + .get("question") + .or_else(|| item.get("header")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or(id); + if !question_meta.contains_key(id) { + asked_order.push(id.to_string()); + } + question_meta.insert(id.to_string(), text.to_string()); + } + } else if let Some(ids) = event + .attributes + .get("question_ids") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(String::from) + .collect::>() + }) + { + for id in ids { + if !question_meta.contains_key(&id) { + asked_order.push(id.clone()); + } + question_meta.entry(id.clone()).or_insert(id); + } + } + } + + if event.attr_str("source") == Some("interactive") + && let Some(ids) = event + .attributes + .get("question_ids") + .and_then(|v| v.as_array()) + { + for id in ids + .iter() + .filter_map(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + { + answered_ids.insert(id.to_string()); + } + } + } + + asked_order + .into_iter() + .filter(|id| !answered_ids.contains(id)) + .map(|id| { + let text = question_meta + .get(&id) + .cloned() + .unwrap_or_else(|| id.clone()); + format!("{id}: {text}") + }) + .collect() +} + +pub(super) fn unresolved_failed_commands(checks_run: &[CheckRun]) -> Vec { + let mut unresolved = Vec::new(); + for (idx, run) in checks_run.iter().enumerate() { + if run.status != "failed" { + continue; + } + let resolved = checks_run + .iter() + .skip(idx + 1) + .any(|later| later.command == run.command && later.status == "passed"); + if !resolved { + unresolved.push(run.command.clone()); + } + } + dedupe_keep_order(&mut unresolved); + unresolved +} + +pub(super) fn dedupe_keep_order(values: &mut Vec) { + let mut seen = HashSet::new(); + values.retain(|value| seen.insert(value.clone())); +} + +pub(super) fn collect_undefined_fields( + objective_undefined_reason: Option<&str>, + execution_contract: &ExecutionContract, + evidence: &[EvidenceRef], +) -> Vec { + let mut undefined = Vec::new(); + + if let Some(reason) = objective_undefined_reason { + undefined.push(UndefinedField { + path: "objective".to_string(), + undefined_reason: reason.to_string(), + }); + } + + if let Some(reason) = execution_contract + .rollback_hint_undefined_reason + .as_deref() + .or(execution_contract.rollback_hint_missing_reason.as_deref()) + { + undefined.push(UndefinedField { + path: "execution_contract.rollback_hint".to_string(), + undefined_reason: reason.to_string(), + }); + } + + if evidence.is_empty() { + undefined.push(UndefinedField { + path: "evidence".to_string(), + undefined_reason: + "No objective/task/decision evidence could be mapped to source events.".to_string(), + }); + } + + undefined +} + +fn event_source_type(event: &Event) -> String { + event + .source_raw_type() + .map(String::from) + .unwrap_or_else(|| match &event.event_type { + EventType::UserMessage => "UserMessage".to_string(), + EventType::AgentMessage => "AgentMessage".to_string(), + EventType::SystemMessage => "SystemMessage".to_string(), + EventType::Thinking => "Thinking".to_string(), + EventType::ToolCall { .. } => "ToolCall".to_string(), + EventType::ToolResult { .. } => "ToolResult".to_string(), + EventType::FileRead { .. } => "FileRead".to_string(), + EventType::CodeSearch { .. } => "CodeSearch".to_string(), + EventType::FileSearch { .. } => "FileSearch".to_string(), + EventType::FileEdit { .. } => "FileEdit".to_string(), + EventType::FileCreate { .. } => "FileCreate".to_string(), + EventType::FileDelete { .. } => "FileDelete".to_string(), + EventType::ShellCommand { .. } => "ShellCommand".to_string(), + EventType::ImageGenerate { .. } => "ImageGenerate".to_string(), + EventType::VideoGenerate { .. } => "VideoGenerate".to_string(), + EventType::AudioGenerate { .. } => "AudioGenerate".to_string(), + EventType::WebSearch { .. } => "WebSearch".to_string(), + EventType::WebFetch { .. } => "WebFetch".to_string(), + EventType::TaskStart { .. } => "TaskStart".to_string(), + EventType::TaskEnd { .. } => "TaskEnd".to_string(), + EventType::Custom { kind } => format!("Custom:{kind}"), + }) +} diff --git a/crates/core/src/handoff/hail_export.rs b/crates/core/src/handoff/hail_export.rs new file mode 100644 index 00000000..079a6b1e --- /dev/null +++ b/crates/core/src/handoff/hail_export.rs @@ -0,0 +1,88 @@ +use std::collections::HashMap; + +use crate::extract::truncate_str; +use crate::{Content, ContentBlock, Event, EventType, Session, SessionContext}; + +/// Generate a summary HAIL session from an original session. +/// +/// Filters events to only include important ones and truncates content. +pub fn generate_handoff_hail(session: &Session) -> Session { + let mut summary_session = Session { + version: session.version.clone(), + session_id: format!("handoff-{}", session.session_id), + agent: session.agent.clone(), + context: SessionContext { + title: Some(format!( + "Handoff: {}", + session.context.title.as_deref().unwrap_or("(untitled)") + )), + description: session.context.description.clone(), + tags: { + let mut tags = session.context.tags.clone(); + if !tags.contains(&"handoff".to_string()) { + tags.push("handoff".to_string()); + } + tags + }, + created_at: session.context.created_at, + updated_at: chrono::Utc::now(), + related_session_ids: vec![session.session_id.clone()], + attributes: HashMap::new(), + }, + events: Vec::new(), + stats: session.stats.clone(), + }; + + for event in &session.events { + let keep = matches!( + &event.event_type, + EventType::UserMessage + | EventType::AgentMessage + | EventType::FileEdit { .. } + | EventType::FileCreate { .. } + | EventType::FileDelete { .. } + | EventType::TaskStart { .. } + | EventType::TaskEnd { .. } + ) || matches!(&event.event_type, EventType::ShellCommand { exit_code, .. } if *exit_code != Some(0)); + + if !keep { + continue; + } + + let truncated_blocks: Vec = event + .content + .blocks + .iter() + .map(|block| match block { + ContentBlock::Text { text } => ContentBlock::Text { + text: truncate_str(text, 300), + }, + ContentBlock::Code { + code, + language, + start_line, + } => ContentBlock::Code { + code: truncate_str(code, 300), + language: language.clone(), + start_line: *start_line, + }, + other => other.clone(), + }) + .collect(); + + summary_session.events.push(Event { + event_id: event.event_id.clone(), + timestamp: event.timestamp, + event_type: event.event_type.clone(), + task_id: event.task_id.clone(), + content: Content { + blocks: truncated_blocks, + }, + duration_ms: event.duration_ms, + attributes: HashMap::new(), + }); + } + + summary_session.recompute_stats(); + summary_session +} diff --git a/crates/core/src/handoff/markdown.rs b/crates/core/src/handoff/markdown.rs new file mode 100644 index 00000000..f01b2bb7 --- /dev/null +++ b/crates/core/src/handoff/markdown.rs @@ -0,0 +1,392 @@ +use crate::extract::truncate_str; + +use super::{HandoffSummary, MergedHandoff, format_duration}; + +/// Generate a v2 Markdown handoff document from a single session summary. +pub fn generate_handoff_markdown_v2(summary: &HandoffSummary) -> String { + let mut md = String::new(); + md.push_str("# Session Handoff\n\n"); + append_v2_markdown_sections(&mut md, summary); + md +} + +/// Generate a v2 Markdown handoff document from merged summaries. +pub fn generate_merged_handoff_markdown_v2(merged: &MergedHandoff) -> String { + let mut md = String::new(); + md.push_str("# Merged Session Handoff\n\n"); + md.push_str(&format!( + "**Sessions:** {} | **Total Duration:** {}\n\n", + merged.source_session_ids.len(), + format_duration(merged.total_duration_seconds) + )); + + for (idx, summary) in merged.summaries.iter().enumerate() { + md.push_str(&format!( + "---\n\n## Session {} — {}\n\n", + idx + 1, + summary.source_session_id + )); + append_v2_markdown_sections(&mut md, summary); + md.push('\n'); + } + + md +} + +fn append_v2_markdown_sections(md: &mut String, summary: &HandoffSummary) { + md.push_str("## Objective\n"); + md.push_str(&summary.objective); + md.push_str("\n\n"); + + md.push_str("## Current State\n"); + md.push_str(&format!( + "- **Tool:** {} ({})\n- **Duration:** {}\n- **Messages:** {} | Tool calls: {} | Events: {}\n", + summary.tool, + summary.model, + format_duration(summary.duration_seconds), + summary.stats.message_count, + summary.stats.tool_call_count, + summary.stats.event_count + )); + if !summary.execution_contract.done_definition.is_empty() { + md.push_str("- **Done:**\n"); + for done in &summary.execution_contract.done_definition { + md.push_str(&format!(" - {done}\n")); + } + } + if !summary.execution_contract.ordered_steps.is_empty() { + md.push_str("- **Execution Timeline (ordered):**\n"); + for step in &summary.execution_contract.ordered_steps { + let started = step.started_at.as_deref().unwrap_or("?"); + let completed = step.completed_at.as_deref().unwrap_or("-"); + if step.depends_on.is_empty() { + md.push_str(&format!( + " - [{}] `{}` [{}] status={} start={} done={}\n", + step.sequence, + step.title, + step.work_package_id, + step.status, + started, + completed + )); + } else { + md.push_str(&format!( + " - [{}] `{}` [{}] status={} start={} done={} deps=[{}]\n", + step.sequence, + step.title, + step.work_package_id, + step.status, + started, + completed, + step.depends_on.join(", ") + )); + } + } + } + md.push('\n'); + + md.push_str("## Next Actions (ordered)\n"); + if summary.execution_contract.next_actions.is_empty() { + md.push_str("_(none)_\n"); + } else { + for (idx, action) in summary.execution_contract.next_actions.iter().enumerate() { + md.push_str(&format!("{}. {}\n", idx + 1, action)); + } + } + if !summary.execution_contract.parallel_actions.is_empty() { + md.push_str("\nParallelizable Work Packages:\n"); + for action in &summary.execution_contract.parallel_actions { + md.push_str(&format!("- {action}\n")); + } + } + md.push('\n'); + + md.push_str("## Verification\n"); + if summary.verification.checks_run.is_empty() { + md.push_str("- checks_run: _(none)_\n"); + } else { + for check in &summary.verification.checks_run { + let code = check + .exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "?".to_string()); + md.push_str(&format!( + "- [{}] `{}` (exit: {}, event: {})\n", + check.status, check.command, code, check.event_id + )); + } + } + if !summary.verification.required_checks_missing.is_empty() { + md.push_str("- required_checks_missing:\n"); + for item in &summary.verification.required_checks_missing { + md.push_str(&format!(" - {item}\n")); + } + } + md.push('\n'); + + md.push_str("## Blockers / Decisions\n"); + if summary.uncertainty.decision_required.is_empty() + && summary.uncertainty.open_questions.is_empty() + { + md.push_str("_(none)_\n"); + } else { + for item in &summary.uncertainty.decision_required { + md.push_str(&format!("- {item}\n")); + } + if !summary.uncertainty.open_questions.is_empty() { + md.push_str("- open_questions:\n"); + for item in &summary.uncertainty.open_questions { + md.push_str(&format!(" - {item}\n")); + } + } + } + md.push('\n'); + + md.push_str("## Evidence Index\n"); + if summary.evidence.is_empty() { + md.push_str("_(none)_\n"); + } else { + for evidence in &summary.evidence { + md.push_str(&format!( + "- `{}` {} ({}, {}, {})\n", + evidence.id, + evidence.claim, + evidence.event_id, + evidence.source_type, + evidence.timestamp + )); + } + } + md.push('\n'); + + md.push_str("## Conversations\n"); + if summary.key_conversations.is_empty() { + md.push_str("_(none)_\n"); + } else { + for (idx, conversation) in summary.key_conversations.iter().enumerate() { + md.push_str(&format!( + "### {}. User\n{}\n\n### {}. Agent\n{}\n\n", + idx + 1, + truncate_str(&conversation.user, 300), + idx + 1, + truncate_str(&conversation.agent, 300) + )); + } + } + + md.push_str("## User Messages\n"); + if summary.user_messages.is_empty() { + md.push_str("_(none)_\n"); + } else { + for (idx, message) in summary.user_messages.iter().enumerate() { + md.push_str(&format!("{}. {}\n", idx + 1, truncate_str(message, 150))); + } + } +} + +/// Generate a Markdown handoff document from a single session summary. +pub fn generate_handoff_markdown(summary: &HandoffSummary) -> String { + const MAX_TASK_SUMMARIES_DISPLAY: usize = 5; + let mut md = String::new(); + + md.push_str("# Session Handoff\n\n"); + + md.push_str("## Objective\n"); + md.push_str(&summary.objective); + md.push_str("\n\n"); + + md.push_str("## Summary\n"); + md.push_str(&format!( + "- **Tool:** {} ({})\n", + summary.tool, summary.model + )); + md.push_str(&format!( + "- **Duration:** {}\n", + format_duration(summary.duration_seconds) + )); + md.push_str(&format!( + "- **Messages:** {} | Tool calls: {} | Events: {}\n", + summary.stats.message_count, summary.stats.tool_call_count, summary.stats.event_count + )); + md.push('\n'); + + if !summary.task_summaries.is_empty() { + md.push_str("## Task Summaries\n"); + for (idx, task_summary) in summary + .task_summaries + .iter() + .take(MAX_TASK_SUMMARIES_DISPLAY) + .enumerate() + { + md.push_str(&format!("{}. {}\n", idx + 1, task_summary)); + } + if summary.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY { + md.push_str(&format!( + "- ... and {} more\n", + summary.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY + )); + } + md.push('\n'); + } + + if !summary.files_modified.is_empty() { + md.push_str("## Files Modified\n"); + for file_change in &summary.files_modified { + md.push_str(&format!( + "- `{}` ({})\n", + file_change.path, file_change.action + )); + } + md.push('\n'); + } + + if !summary.files_read.is_empty() { + md.push_str("## Files Read\n"); + for path in &summary.files_read { + md.push_str(&format!("- `{path}`\n")); + } + md.push('\n'); + } + + if !summary.shell_commands.is_empty() { + md.push_str("## Shell Commands\n"); + for command in &summary.shell_commands { + let code_str = match command.exit_code { + Some(code) => code.to_string(), + None => "?".to_string(), + }; + md.push_str(&format!( + "- `{}` → {}\n", + truncate_str(&command.command, 80), + code_str + )); + } + md.push('\n'); + } + + if !summary.errors.is_empty() { + md.push_str("## Errors\n"); + for error in &summary.errors { + md.push_str(&format!("- {error}\n")); + } + md.push('\n'); + } + + if !summary.key_conversations.is_empty() { + md.push_str("## Key Conversations\n"); + for (idx, conversation) in summary.key_conversations.iter().enumerate() { + md.push_str(&format!( + "### {}. User\n{}\n\n### {}. Agent\n{}\n\n", + idx + 1, + truncate_str(&conversation.user, 300), + idx + 1, + truncate_str(&conversation.agent, 300), + )); + } + } + + if summary.key_conversations.is_empty() && !summary.user_messages.is_empty() { + md.push_str("## User Messages\n"); + for (idx, message) in summary.user_messages.iter().enumerate() { + md.push_str(&format!("{}. {}\n", idx + 1, truncate_str(message, 150))); + } + md.push('\n'); + } + + md +} + +/// Generate a Markdown handoff document from a merged multi-session handoff. +pub fn generate_merged_handoff_markdown(merged: &MergedHandoff) -> String { + const MAX_TASK_SUMMARIES_DISPLAY: usize = 3; + let mut md = String::new(); + + md.push_str("# Merged Session Handoff\n\n"); + md.push_str(&format!( + "**Sessions:** {} | **Total Duration:** {}\n\n", + merged.source_session_ids.len(), + format_duration(merged.total_duration_seconds) + )); + + for (idx, summary) in merged.summaries.iter().enumerate() { + md.push_str(&format!( + "---\n\n## Session {} — {}\n\n", + idx + 1, + summary.source_session_id + )); + md.push_str(&format!("**Objective:** {}\n\n", summary.objective)); + md.push_str(&format!( + "- **Tool:** {} ({}) | **Duration:** {}\n", + summary.tool, + summary.model, + format_duration(summary.duration_seconds) + )); + md.push_str(&format!( + "- **Messages:** {} | Tool calls: {} | Events: {}\n\n", + summary.stats.message_count, summary.stats.tool_call_count, summary.stats.event_count + )); + + if !summary.task_summaries.is_empty() { + md.push_str("### Task Summaries\n"); + for (task_idx, task_summary) in summary + .task_summaries + .iter() + .take(MAX_TASK_SUMMARIES_DISPLAY) + .enumerate() + { + md.push_str(&format!("{}. {}\n", task_idx + 1, task_summary)); + } + if summary.task_summaries.len() > MAX_TASK_SUMMARIES_DISPLAY { + md.push_str(&format!( + "- ... and {} more\n", + summary.task_summaries.len() - MAX_TASK_SUMMARIES_DISPLAY + )); + } + md.push('\n'); + } + + if !summary.key_conversations.is_empty() { + md.push_str("### Conversations\n"); + for (conversation_idx, conversation) in summary.key_conversations.iter().enumerate() { + md.push_str(&format!( + "**{}. User:** {}\n\n**{}. Agent:** {}\n\n", + conversation_idx + 1, + truncate_str(&conversation.user, 200), + conversation_idx + 1, + truncate_str(&conversation.agent, 200), + )); + } + } + } + + md.push_str("---\n\n## All Files Modified\n"); + if merged.all_files_modified.is_empty() { + md.push_str("_(none)_\n"); + } else { + for file_change in &merged.all_files_modified { + md.push_str(&format!( + "- `{}` ({})\n", + file_change.path, file_change.action + )); + } + } + md.push('\n'); + + if !merged.all_files_read.is_empty() { + md.push_str("## All Files Read\n"); + for path in &merged.all_files_read { + md.push_str(&format!("- `{path}`\n")); + } + md.push('\n'); + } + + if !merged.total_errors.is_empty() { + md.push_str("## All Errors\n"); + for error in &merged.total_errors { + md.push_str(&format!("- {error}\n")); + } + md.push('\n'); + } + + md +} diff --git a/crates/core/src/handoff/merge.rs b/crates/core/src/handoff/merge.rs new file mode 100644 index 00000000..8a699bdf --- /dev/null +++ b/crates/core/src/handoff/merge.rs @@ -0,0 +1,58 @@ +use std::collections::{HashMap, HashSet}; + +use super::{FileChange, HandoffSummary, MergedHandoff}; + +/// Merge multiple session summaries into a single handoff context. +pub fn merge_summaries(summaries: &[HandoffSummary]) -> MergedHandoff { + let session_ids: Vec = summaries + .iter() + .map(|summary| summary.source_session_id.clone()) + .collect(); + let total_duration: u64 = summaries + .iter() + .map(|summary| summary.duration_seconds) + .sum(); + let total_errors: Vec = summaries + .iter() + .flat_map(|summary| { + summary + .errors + .iter() + .map(move |err| format!("[{}] {}", summary.source_session_id, err)) + }) + .collect(); + + let all_modified: HashMap = summaries + .iter() + .flat_map(|summary| &summary.files_modified) + .fold(HashMap::new(), |mut map, file_change| { + map.entry(file_change.path.clone()) + .or_insert(file_change.action); + map + }); + + let mut sorted_read: Vec = summaries + .iter() + .flat_map(|summary| &summary.files_read) + .filter(|path| !all_modified.contains_key(path.as_str())) + .cloned() + .collect::>() + .into_iter() + .collect(); + sorted_read.sort(); + + let mut sorted_modified: Vec = all_modified + .into_iter() + .map(|(path, action)| FileChange { path, action }) + .collect(); + sorted_modified.sort_by(|left, right| left.path.cmp(&right.path)); + + MergedHandoff { + source_session_ids: session_ids, + summaries: summaries.to_vec(), + all_files_modified: sorted_modified, + all_files_read: sorted_read, + total_duration_seconds: total_duration, + total_errors, + } +} diff --git a/crates/core/src/handoff/tests.rs b/crates/core/src/handoff/tests.rs new file mode 100644 index 00000000..0a60443d --- /dev/null +++ b/crates/core/src/handoff/tests.rs @@ -0,0 +1,803 @@ +use super::*; +use crate::{Agent, testing}; + +fn make_agent() -> Agent { + testing::agent() +} + +fn make_event(event_type: EventType, text: &str) -> Event { + testing::event(event_type, text) +} + +#[test] +fn test_format_duration() { + assert_eq!(format_duration(0), "0s"); + assert_eq!(format_duration(45), "45s"); + assert_eq!(format_duration(90), "1m 30s"); + assert_eq!(format_duration(750), "12m 30s"); + assert_eq!(format_duration(3661), "1h 1m 1s"); +} + +#[test] +fn test_handoff_summary_from_session() { + let mut session = Session::new("test-id".to_string(), make_agent()); + session.stats = Stats { + event_count: 10, + message_count: 3, + tool_call_count: 5, + duration_seconds: 750, + ..Default::default() + }; + session + .events + .push(make_event(EventType::UserMessage, "Fix the build error")); + session + .events + .push(make_event(EventType::AgentMessage, "I'll fix it now")); + session.events.push(make_event( + EventType::FileEdit { + path: "src/main.rs".to_string(), + diff: None, + }, + "", + )); + session.events.push(make_event( + EventType::FileRead { + path: "Cargo.toml".to_string(), + }, + "", + )); + session.events.push(make_event( + EventType::ShellCommand { + command: "cargo build".to_string(), + exit_code: Some(0), + }, + "", + )); + session.events.push(make_event( + EventType::TaskEnd { + summary: Some("Build now passes in local env".to_string()), + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + + assert_eq!(summary.source_session_id, "test-id"); + assert_eq!(summary.objective, "Fix the build error"); + assert_eq!(summary.files_modified.len(), 1); + assert_eq!(summary.files_modified[0].path, "src/main.rs"); + assert_eq!(summary.files_modified[0].action, "edited"); + assert_eq!(summary.files_read, vec!["Cargo.toml"]); + assert_eq!(summary.shell_commands.len(), 1); + assert_eq!( + summary.task_summaries, + vec!["Build now passes in local env".to_string()] + ); + assert_eq!(summary.key_conversations.len(), 1); + assert_eq!(summary.key_conversations[0].user, "Fix the build error"); + assert_eq!(summary.key_conversations[0].agent, "I'll fix it now"); +} + +#[test] +fn test_handoff_objective_falls_back_to_task_title() { + let mut session = Session::new("task-title-fallback".to_string(), make_agent()); + session.context.title = Some("session-019c-example.jsonl".to_string()); + session.events.push(make_event( + EventType::TaskStart { + title: Some("Refactor auth middleware for oauth callback".to_string()), + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + assert_eq!( + summary.objective, + "Refactor auth middleware for oauth callback" + ); +} + +#[test] +fn test_handoff_task_summaries_are_deduplicated() { + let mut session = Session::new("task-summary-dedupe".to_string(), make_agent()); + session.events.push(make_event( + EventType::TaskEnd { + summary: Some("Add worker profile guard".to_string()), + }, + "", + )); + session.events.push(make_event( + EventType::TaskEnd { + summary: Some(" ".to_string()), + }, + "", + )); + session.events.push(make_event( + EventType::TaskEnd { + summary: Some("Add worker profile guard".to_string()), + }, + "", + )); + session.events.push(make_event( + EventType::TaskEnd { + summary: Some("Hide teams nav for worker profile".to_string()), + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + assert_eq!( + summary.task_summaries, + vec![ + "Add worker profile guard".to_string(), + "Hide teams nav for worker profile".to_string() + ] + ); +} + +#[test] +fn test_files_read_excludes_modified() { + let mut session = Session::new("test-id".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "test")); + session.events.push(make_event( + EventType::FileRead { + path: "src/main.rs".to_string(), + }, + "", + )); + session.events.push(make_event( + EventType::FileEdit { + path: "src/main.rs".to_string(), + diff: None, + }, + "", + )); + session.events.push(make_event( + EventType::FileRead { + path: "README.md".to_string(), + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + assert_eq!(summary.files_read, vec!["README.md"]); + assert_eq!(summary.files_modified.len(), 1); +} + +#[test] +fn test_file_create_not_overwritten_by_edit() { + let mut session = Session::new("test-id".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "test")); + session.events.push(make_event( + EventType::FileCreate { + path: "new_file.rs".to_string(), + }, + "", + )); + session.events.push(make_event( + EventType::FileEdit { + path: "new_file.rs".to_string(), + diff: None, + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + assert_eq!(summary.files_modified[0].action, "created"); +} + +#[test] +fn test_shell_error_captured() { + let mut session = Session::new("test-id".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "test")); + session.events.push(make_event( + EventType::ShellCommand { + command: "cargo test".to_string(), + exit_code: Some(1), + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + assert_eq!(summary.errors.len(), 1); + assert!(summary.errors[0].contains("cargo test")); +} + +#[test] +fn test_generate_handoff_markdown() { + let mut session = Session::new("test-id".to_string(), make_agent()); + session.stats = Stats { + event_count: 10, + message_count: 3, + tool_call_count: 5, + duration_seconds: 750, + ..Default::default() + }; + session + .events + .push(make_event(EventType::UserMessage, "Fix the build error")); + session + .events + .push(make_event(EventType::AgentMessage, "I'll fix it now")); + session.events.push(make_event( + EventType::FileEdit { + path: "src/main.rs".to_string(), + diff: None, + }, + "", + )); + session.events.push(make_event( + EventType::ShellCommand { + command: "cargo build".to_string(), + exit_code: Some(0), + }, + "", + )); + session.events.push(make_event( + EventType::TaskEnd { + summary: Some("Compile error fixed by updating trait bounds".to_string()), + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + let md = generate_handoff_markdown(&summary); + + assert!(md.contains("# Session Handoff")); + assert!(md.contains("Fix the build error")); + assert!(md.contains("claude-code (claude-opus-4-6)")); + assert!(md.contains("12m 30s")); + assert!(md.contains("## Task Summaries")); + assert!(md.contains("Compile error fixed by updating trait bounds")); + assert!(md.contains("`src/main.rs` (edited)")); + assert!(md.contains("`cargo build` → 0")); + assert!(md.contains("## Key Conversations")); +} + +#[test] +fn test_merge_summaries() { + let mut s1 = Session::new("session-a".to_string(), make_agent()); + s1.stats.duration_seconds = 100; + s1.events.push(make_event(EventType::UserMessage, "task A")); + s1.events.push(make_event( + EventType::FileEdit { + path: "a.rs".to_string(), + diff: None, + }, + "", + )); + + let mut s2 = Session::new("session-b".to_string(), make_agent()); + s2.stats.duration_seconds = 200; + s2.events.push(make_event(EventType::UserMessage, "task B")); + s2.events.push(make_event( + EventType::FileEdit { + path: "b.rs".to_string(), + diff: None, + }, + "", + )); + + let sum1 = HandoffSummary::from_session(&s1); + let sum2 = HandoffSummary::from_session(&s2); + let merged = merge_summaries(&[sum1, sum2]); + + assert_eq!(merged.source_session_ids.len(), 2); + assert_eq!(merged.total_duration_seconds, 300); + assert_eq!(merged.all_files_modified.len(), 2); +} + +#[test] +fn test_generate_handoff_hail() { + let mut session = Session::new("test-id".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "Hello")); + session + .events + .push(make_event(EventType::AgentMessage, "Hi there")); + session.events.push(make_event( + EventType::FileRead { + path: "foo.rs".to_string(), + }, + "", + )); + session.events.push(make_event( + EventType::FileEdit { + path: "foo.rs".to_string(), + diff: Some("+added line".to_string()), + }, + "", + )); + session.events.push(make_event( + EventType::ShellCommand { + command: "cargo build".to_string(), + exit_code: Some(0), + }, + "", + )); + + let hail = generate_handoff_hail(&session); + + assert!(hail.session_id.starts_with("handoff-")); + assert_eq!(hail.context.related_session_ids, vec!["test-id"]); + assert!(hail.context.tags.contains(&"handoff".to_string())); + assert_eq!(hail.events.len(), 3); + let jsonl = hail.to_jsonl().unwrap(); + let parsed = Session::from_jsonl(&jsonl).unwrap(); + assert_eq!(parsed.session_id, hail.session_id); +} + +#[test] +fn test_generate_handoff_markdown_v2_section_order() { + let mut session = Session::new("v2-sections".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "Implement handoff v2")); + session.events.push(make_event( + EventType::FileEdit { + path: "crates/core/src/handoff.rs".to_string(), + diff: None, + }, + "", + )); + session.events.push(make_event( + EventType::ShellCommand { + command: "cargo test".to_string(), + exit_code: Some(0), + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + let md = generate_handoff_markdown_v2(&summary); + + let order = [ + "## Objective", + "## Current State", + "## Next Actions (ordered)", + "## Verification", + "## Blockers / Decisions", + "## Evidence Index", + "## Conversations", + "## User Messages", + ]; + + let mut last_idx = 0usize; + for section in order { + let idx = md.find(section).unwrap(); + assert!( + idx >= last_idx, + "section order mismatch for {section}: idx={idx}, last={last_idx}" + ); + last_idx = idx; + } +} + +#[test] +fn test_execution_contract_and_verification_from_failed_command() { + let mut session = Session::new("failed-check".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "Fix failing tests")); + session.events.push(make_event( + EventType::FileEdit { + path: "src/lib.rs".to_string(), + diff: None, + }, + "", + )); + session.events.push(make_event( + EventType::ShellCommand { + command: "cargo test".to_string(), + exit_code: Some(1), + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + assert!( + summary + .verification + .checks_failed + .contains(&"cargo test".to_string()) + ); + assert!( + summary + .execution_contract + .next_actions + .iter() + .any(|action| action.contains("cargo test")) + ); + assert_eq!( + summary.execution_contract.ordered_commands.first(), + Some(&"cargo test".to_string()) + ); + assert!(summary.execution_contract.parallel_actions.is_empty()); + assert!(summary.execution_contract.rollback_hint.is_none()); + assert!( + summary + .execution_contract + .rollback_hint_missing_reason + .is_some() + ); + assert!( + summary + .execution_contract + .rollback_hint_undefined_reason + .is_some() + ); +} + +#[test] +fn test_validate_handoff_summary_flags_missing_objective() { + let session = Session::new("missing-objective".to_string(), make_agent()); + let summary = HandoffSummary::from_session(&session); + assert!(summary.objective_undefined_reason.is_some()); + assert!( + summary + .undefined_fields + .iter() + .any(|field| field.path == "objective") + ); + let report = validate_handoff_summary(&summary); + + assert!(!report.passed); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == "objective_missing") + ); +} + +#[test] +fn test_validate_handoff_summary_flags_cycle() { + let mut session = Session::new("cycle-case".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "test")); + let mut summary = HandoffSummary::from_session(&session); + summary.work_packages = vec![ + WorkPackage { + id: "a".to_string(), + title: "A".to_string(), + status: "pending".to_string(), + sequence: 1, + started_at: None, + completed_at: None, + outcome: None, + depends_on: vec!["b".to_string()], + files: Vec::new(), + commands: Vec::new(), + evidence_refs: Vec::new(), + }, + WorkPackage { + id: "b".to_string(), + title: "B".to_string(), + status: "pending".to_string(), + sequence: 2, + started_at: None, + completed_at: None, + outcome: None, + depends_on: vec!["a".to_string()], + files: Vec::new(), + commands: Vec::new(), + evidence_refs: Vec::new(), + }, + ]; + + let report = validate_handoff_summary(&summary); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == "work_package_cycle") + ); +} + +#[test] +fn test_validate_handoff_summary_requires_next_actions_for_failed_checks() { + let mut session = Session::new("missing-next-action".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "test")); + let mut summary = HandoffSummary::from_session(&session); + summary.verification.checks_run = vec![CheckRun { + command: "cargo test".to_string(), + status: "failed".to_string(), + exit_code: Some(1), + event_id: "evt-1".to_string(), + }]; + summary.execution_contract.next_actions.clear(); + + let report = validate_handoff_summary(&summary); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == "next_actions_missing") + ); +} + +#[test] +fn test_validate_handoff_summary_flags_missing_objective_evidence() { + let mut session = Session::new("missing-objective-evidence".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "keep objective")); + let mut summary = HandoffSummary::from_session(&session); + summary.evidence = vec![EvidenceRef { + id: "evidence-1".to_string(), + claim: "task_done: something".to_string(), + event_id: "evt".to_string(), + timestamp: "2026-02-01T00:00:00Z".to_string(), + source_type: "TaskEnd".to_string(), + }]; + + let report = validate_handoff_summary(&summary); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == "objective_evidence_missing") + ); +} + +#[test] +fn test_execution_contract_includes_parallel_actions_for_independent_work_packages() { + let mut session = Session::new("parallel-actions".to_string(), make_agent()); + session.events.push(make_event( + EventType::UserMessage, + "Refactor two independent modules", + )); + + let mut auth_start = make_event( + EventType::TaskStart { + title: Some("Refactor auth".to_string()), + }, + "", + ); + auth_start.task_id = Some("auth".to_string()); + session.events.push(auth_start); + + let mut auth_edit = make_event( + EventType::FileEdit { + path: "src/auth.rs".to_string(), + diff: None, + }, + "", + ); + auth_edit.task_id = Some("auth".to_string()); + session.events.push(auth_edit); + + let mut billing_start = make_event( + EventType::TaskStart { + title: Some("Refactor billing".to_string()), + }, + "", + ); + billing_start.task_id = Some("billing".to_string()); + session.events.push(billing_start); + + let mut billing_edit = make_event( + EventType::FileEdit { + path: "src/billing.rs".to_string(), + diff: None, + }, + "", + ); + billing_edit.task_id = Some("billing".to_string()); + session.events.push(billing_edit); + + let summary = HandoffSummary::from_session(&session); + assert!( + summary + .execution_contract + .parallel_actions + .iter() + .any(|action| action.contains("auth")) + ); + assert!( + summary + .execution_contract + .parallel_actions + .iter() + .any(|action| action.contains("billing")) + ); + let md = generate_handoff_markdown_v2(&summary); + assert!(md.contains("Parallelizable Work Packages")); +} + +#[test] +fn test_done_definition_prefers_material_signals() { + let mut session = Session::new("material-signals".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "Implement feature X")); + session.events.push(make_event( + EventType::FileEdit { + path: "src/lib.rs".to_string(), + diff: None, + }, + "", + )); + session.events.push(make_event( + EventType::ShellCommand { + command: "cargo test".to_string(), + exit_code: Some(0), + }, + "", + )); + + let summary = HandoffSummary::from_session(&session); + assert!( + summary + .execution_contract + .done_definition + .iter() + .any(|item| item.contains("Verification passed: `cargo test`")) + ); + assert!( + summary + .execution_contract + .done_definition + .iter() + .any(|item| item.contains("Changed 1 file(s): `src/lib.rs`")) + ); + assert!( + summary + .execution_contract + .ordered_steps + .iter() + .any(|step| step.work_package_id == "main") + ); +} + +#[test] +fn test_ordered_steps_keep_temporal_and_task_context() { + let mut session = Session::new("ordered-steps".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "Process two tasks")); + + let mut task1_start = make_event( + EventType::TaskStart { + title: Some("Prepare migration".to_string()), + }, + "", + ); + task1_start.task_id = Some("task-1".to_string()); + session.events.push(task1_start); + + let mut task1_end = make_event( + EventType::TaskEnd { + summary: Some("Migration script prepared".to_string()), + }, + "", + ); + task1_end.task_id = Some("task-1".to_string()); + session.events.push(task1_end); + + let mut task2_start = make_event( + EventType::TaskStart { + title: Some("Run verification".to_string()), + }, + "", + ); + task2_start.task_id = Some("task-2".to_string()); + session.events.push(task2_start); + + let mut task2_cmd = make_event( + EventType::ShellCommand { + command: "cargo test".to_string(), + exit_code: Some(0), + }, + "", + ); + task2_cmd.task_id = Some("task-2".to_string()); + session.events.push(task2_cmd); + + let summary = HandoffSummary::from_session(&session); + let steps = &summary.execution_contract.ordered_steps; + assert_eq!(steps.len(), 2); + assert!(steps[0].sequence < steps[1].sequence); + assert_eq!(steps[0].work_package_id, "task-1"); + assert_eq!(steps[1].work_package_id, "task-2"); + assert!(steps[0].completed_at.is_some()); + assert!( + summary + .work_packages + .iter() + .find(|pkg| pkg.id == "task-1") + .and_then(|pkg| pkg.outcome.as_deref()) + .is_some() + ); +} + +#[test] +fn test_validate_handoff_summary_flags_inconsistent_ordered_steps() { + let mut session = Session::new("invalid-ordered-steps".to_string(), make_agent()); + session + .events + .push(make_event(EventType::UserMessage, "test ordered steps")); + let mut summary = HandoffSummary::from_session(&session); + summary.work_packages = vec![WorkPackage { + id: "main".to_string(), + title: "Main flow".to_string(), + status: "completed".to_string(), + sequence: 1, + started_at: Some("2026-02-19T00:00:00Z".to_string()), + completed_at: Some("2026-02-19T00:01:00Z".to_string()), + outcome: Some("done".to_string()), + depends_on: Vec::new(), + files: vec!["src/lib.rs".to_string()], + commands: Vec::new(), + evidence_refs: Vec::new(), + }]; + summary.execution_contract.ordered_steps = vec![OrderedStep { + sequence: 1, + work_package_id: "missing".to_string(), + title: "missing".to_string(), + status: "completed".to_string(), + depends_on: Vec::new(), + started_at: Some("2026-02-19T00:00:00Z".to_string()), + completed_at: Some("2026-02-19T00:01:00Z".to_string()), + evidence_refs: Vec::new(), + }]; + + let report = validate_handoff_summary(&summary); + assert!( + report + .findings + .iter() + .any(|finding| finding.code == "ordered_steps_inconsistent") + ); +} + +#[test] +fn test_message_and_conversation_collections_are_condensed() { + let mut session = Session::new("condense".to_string(), make_agent()); + + for idx in 0..24 { + session + .events + .push(make_event(EventType::UserMessage, &format!("user-{idx}"))); + session + .events + .push(make_event(EventType::AgentMessage, &format!("agent-{idx}"))); + } + + let summary = HandoffSummary::from_session(&session); + assert_eq!(summary.user_messages.len(), MAX_USER_MESSAGES); + assert_eq!( + summary.user_messages.first().map(String::as_str), + Some("user-0") + ); + assert_eq!( + summary.user_messages.last().map(String::as_str), + Some("user-23") + ); + + assert_eq!(summary.key_conversations.len(), MAX_KEY_CONVERSATIONS); + assert_eq!( + summary + .key_conversations + .first() + .map(|conversation| conversation.user.as_str()), + Some("user-0") + ); + assert_eq!( + summary + .key_conversations + .last() + .map(|conversation| conversation.user.as_str()), + Some("user-23") + ); +} diff --git a/crates/core/src/handoff/validation.rs b/crates/core/src/handoff/validation.rs new file mode 100644 index 00000000..db9fe7f1 --- /dev/null +++ b/crates/core/src/handoff/validation.rs @@ -0,0 +1,194 @@ +use std::collections::{HashMap, HashSet}; + +use super::execution::{is_material_work_package, unresolved_failed_commands}; +use super::{HandoffSummary, OrderedStep, WorkPackage}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ValidationFinding { + pub code: String, + pub severity: String, + pub message: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct HandoffValidationReport { + pub session_id: String, + pub passed: bool, + pub findings: Vec, +} + +pub fn validate_handoff_summary(summary: &HandoffSummary) -> HandoffValidationReport { + let mut findings = Vec::new(); + + if summary.objective.trim().is_empty() || summary.objective == "(objective unavailable)" { + findings.push(ValidationFinding { + code: "objective_missing".to_string(), + severity: "warning".to_string(), + message: "Objective is unavailable.".to_string(), + }); + } + + let unresolved_failures = unresolved_failed_commands(&summary.verification.checks_run); + if !unresolved_failures.is_empty() && summary.execution_contract.next_actions.is_empty() { + findings.push(ValidationFinding { + code: "next_actions_missing".to_string(), + severity: "warning".to_string(), + message: "Unresolved failed checks exist but no next action was generated.".to_string(), + }); + } + + if !summary.files_modified.is_empty() && summary.verification.checks_run.is_empty() { + findings.push(ValidationFinding { + code: "verification_missing".to_string(), + severity: "warning".to_string(), + message: "Files were modified but no verification check was recorded.".to_string(), + }); + } + + if summary.evidence.is_empty() { + findings.push(ValidationFinding { + code: "evidence_missing".to_string(), + severity: "warning".to_string(), + message: "No evidence references were generated.".to_string(), + }); + } else if !summary + .evidence + .iter() + .any(|evidence| evidence.claim.starts_with("objective:")) + { + findings.push(ValidationFinding { + code: "objective_evidence_missing".to_string(), + severity: "warning".to_string(), + message: "Objective exists but objective evidence is missing.".to_string(), + }); + } + + if has_work_package_cycle(&summary.work_packages) { + findings.push(ValidationFinding { + code: "work_package_cycle".to_string(), + severity: "error".to_string(), + message: "work_packages.depends_on contains a cycle.".to_string(), + }); + } + + let has_material_packages = summary.work_packages.iter().any(is_material_work_package); + if has_material_packages && summary.execution_contract.ordered_steps.is_empty() { + findings.push(ValidationFinding { + code: "ordered_steps_missing".to_string(), + severity: "warning".to_string(), + message: "Material work packages exist but execution_contract.ordered_steps is empty." + .to_string(), + }); + } else if !ordered_steps_are_consistent( + &summary.execution_contract.ordered_steps, + &summary.work_packages, + ) { + findings.push(ValidationFinding { + code: "ordered_steps_inconsistent".to_string(), + severity: "error".to_string(), + message: + "execution_contract.ordered_steps is not temporally or referentially consistent." + .to_string(), + }); + } + + HandoffValidationReport { + session_id: summary.source_session_id.clone(), + passed: findings.is_empty(), + findings, + } +} + +pub fn validate_handoff_summaries(summaries: &[HandoffSummary]) -> Vec { + summaries.iter().map(validate_handoff_summary).collect() +} + +fn has_work_package_cycle(packages: &[WorkPackage]) -> bool { + let mut state: HashMap<&str, u8> = HashMap::new(); + let deps: HashMap<&str, Vec<&str>> = packages + .iter() + .map(|pkg| { + ( + pkg.id.as_str(), + pkg.depends_on + .iter() + .map(String::as_str) + .collect::>(), + ) + }) + .collect(); + + fn dfs<'a>( + node: &'a str, + state: &mut HashMap<&'a str, u8>, + deps: &HashMap<&'a str, Vec<&'a str>>, + ) -> bool { + match state.get(node).copied() { + Some(1) => return true, + Some(2) => return false, + _ => {} + } + state.insert(node, 1); + if let Some(children) = deps.get(node) { + for child in children { + if !deps.contains_key(child) { + continue; + } + if dfs(child, state, deps) { + return true; + } + } + } + state.insert(node, 2); + false + } + + for node in deps.keys().copied() { + if dfs(node, &mut state, &deps) { + return true; + } + } + false +} + +fn ordered_steps_are_consistent(steps: &[OrderedStep], work_packages: &[WorkPackage]) -> bool { + if steps.is_empty() { + return true; + } + + if !steps + .windows(2) + .all(|pair| pair[0].sequence < pair[1].sequence) + { + return false; + } + + let known_ids = work_packages + .iter() + .map(|pkg| pkg.id.as_str()) + .collect::>(); + if !steps + .iter() + .all(|step| known_ids.contains(step.work_package_id.as_str())) + { + return false; + } + + let is_monotonic_time = |left: Option<&str>, right: Option<&str>| -> bool { + match (left, right) { + (Some(left), Some(right)) => { + let left = chrono::DateTime::parse_from_rfc3339(left).ok(); + let right = chrono::DateTime::parse_from_rfc3339(right).ok(); + match (left, right) { + (Some(left), Some(right)) => left <= right, + _ => false, + } + } + _ => true, + } + }; + + steps + .windows(2) + .all(|pair| is_monotonic_time(pair[0].started_at.as_deref(), pair[1].started_at.as_deref())) +} diff --git a/crates/core/src/handoff_artifact.rs b/crates/core/src/handoff_artifact.rs index f6e535dd..3093ec82 100644 --- a/crates/core/src/handoff_artifact.rs +++ b/crates/core/src/handoff_artifact.rs @@ -1,6 +1,4 @@ use std::cmp::Ordering; -use std::path::Path; -use std::time::UNIX_EPOCH; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -49,16 +47,6 @@ pub struct HandoffArtifact { pub derived_markdown: Option, } -impl HandoffArtifact { - pub fn stale_reasons(&self) -> Vec { - stale_reasons(&self.sources) - } - - pub fn is_stale(&self) -> bool { - !self.stale_reasons().is_empty() - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct HandoffSourceStaleReason { pub session_id: String, @@ -94,76 +82,19 @@ pub fn sort_sessions_time_asc(sessions: &mut [Session]) { }); } -pub fn source_fingerprint(path: &Path) -> std::io::Result { - let metadata = std::fs::metadata(path)?; - let mtime_ms = metadata - .modified() - .ok() - .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis() as u64) - .unwrap_or(0); - Ok(SourceFingerprint { - mtime_ms, - size: metadata.len(), - }) -} - pub fn source_from_session( session: &Session, - source_path: &Path, -) -> std::io::Result { - let fp = source_fingerprint(source_path)?; - Ok(HandoffArtifactSource { + source_path: impl Into, + fingerprint: SourceFingerprint, +) -> HandoffArtifactSource { + HandoffArtifactSource { session_id: session.session_id.clone(), tool: session.agent.tool.clone(), model: session.agent.model.clone(), - source_path: source_path.to_string_lossy().into_owned(), - source_mtime_ms: fp.mtime_ms, - source_size: fp.size, - }) -} - -pub fn stale_reasons(sources: &[HandoffArtifactSource]) -> Vec { - let mut reasons = Vec::new(); - for source in sources { - let path = Path::new(&source.source_path); - let metadata = match std::fs::metadata(path) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - reasons.push(HandoffSourceStaleReason { - session_id: source.session_id.clone(), - source_path: source.source_path.clone(), - reason: "missing_source_file".to_string(), - }); - continue; - } - Err(_) => { - reasons.push(HandoffSourceStaleReason { - session_id: source.session_id.clone(), - source_path: source.source_path.clone(), - reason: "unreadable_source_file".to_string(), - }); - continue; - } - }; - - let current_mtime_ms = metadata - .modified() - .ok() - .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis() as u64) - .unwrap_or(0); - let current_size = metadata.len(); - - if current_mtime_ms != source.source_mtime_ms || current_size != source.source_size { - reasons.push(HandoffSourceStaleReason { - session_id: source.session_id.clone(), - source_path: source.source_path.clone(), - reason: "source_fingerprint_changed".to_string(), - }); - } + source_path: source_path.into(), + source_mtime_ms: fingerprint.mtime_ms, + source_size: fingerprint.size, } - reasons } #[cfg(test)] @@ -202,23 +133,21 @@ mod tests { } #[test] - fn stale_reasons_detects_fingerprint_changes() { - let temp_path = std::env::temp_dir().join(format!( - "opensession-handoff-artifact-{}.jsonl", - Utc::now().timestamp_nanos_opt().unwrap_or_default() - )); - std::fs::write(&temp_path, b"before").expect("write temp file"); - + fn source_from_session_preserves_supplied_fingerprint() { let mut session = Session::new("session-1".to_string(), testing::agent()); session.context.created_at = Utc::now(); - let source = source_from_session(&session, &temp_path).expect("source fingerprint"); - assert!(stale_reasons(std::slice::from_ref(&source)).is_empty()); - - std::fs::write(&temp_path, b"after-after").expect("rewrite temp file"); - let reasons = stale_reasons(&[source]); - assert_eq!(reasons.len(), 1); - assert_eq!(reasons[0].reason, "source_fingerprint_changed"); + let source = source_from_session( + &session, + "/tmp/session.jsonl", + SourceFingerprint { + mtime_ms: 42, + size: 128, + }, + ); - let _ = std::fs::remove_file(&temp_path); + assert_eq!(source.session_id, "session-1"); + assert_eq!(source.source_path, "/tmp/session.jsonl"); + assert_eq!(source.source_mtime_ms, 42); + assert_eq!(source.source_size, 128); } } diff --git a/crates/core/src/jsonl.rs b/crates/core/src/jsonl.rs index 48e0963f..9d4e6bd9 100644 --- a/crates/core/src/jsonl.rs +++ b/crates/core/src/jsonl.rs @@ -13,7 +13,7 @@ //! The last line is aggregate stats (optional on write, recomputed on read if missing). use crate::trace::{Agent, Event, Session, SessionContext, Stats}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use std::io::{self, BufRead, Write}; /// A single line in a HAIL JSONL file diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 51932854..e07cfb3f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -3,7 +3,6 @@ pub mod extract; pub mod handoff; pub mod handoff_artifact; pub mod jsonl; -pub mod object_store; pub mod sanitize; pub mod scoring; pub mod session; diff --git a/crates/core/src/sanitize.rs b/crates/core/src/sanitize.rs index 03f1c55f..738d0db6 100644 --- a/crates/core/src/sanitize.rs +++ b/crates/core/src/sanitize.rs @@ -95,10 +95,10 @@ fn sanitize_content_block(block: &mut ContentBlock, config: &SanitizeConfig) { if config.strip_paths { *path = strip_home_dir(path); } - if let Some(c) = content { - if config.strip_env_vars { - *c = strip_env_vars(c); - } + if let Some(c) = content + && config.strip_env_vars + { + *c = strip_env_vars(c); } } _ => {} diff --git a/crates/core/src/scoring.rs b/crates/core/src/scoring.rs index 2fcba11d..8428dced 100644 --- a/crates/core/src/scoring.rs +++ b/crates/core/src/scoring.rs @@ -1,4 +1,4 @@ -use crate::{extract::extract_file_metadata, EventType, Session}; +use crate::{EventType, Session, extract::extract_file_metadata}; use std::collections::HashMap; use std::sync::Arc; @@ -233,7 +233,7 @@ fn count_recoveries(session: &Session) -> usize { #[cfg(test)] mod tests { use super::*; - use crate::{testing, Session}; + use crate::{Session, testing}; fn build_session(events: Vec) -> Session { let mut session = Session::new("score-test".to_string(), testing::agent()); diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index cc2b9ce8..2f797d55 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -170,10 +170,10 @@ pub fn build_git_storage_meta_json(session: &Session) -> Vec { #[cfg(test)] mod tests { use super::{ + ATTR_PARENT_SESSION_ID, ATTR_SESSION_ROLE, GitMeta, SessionRole, build_git_storage_meta_json, build_git_storage_meta_json_with_git, interaction_compressed_session, interaction_compressed_stats, is_auxiliary_session, - session_role, source_path, working_directory, GitMeta, SessionRole, ATTR_PARENT_SESSION_ID, - ATTR_SESSION_ROLE, + session_role, source_path, working_directory, }; use crate::trace::{Agent, Content, Event, EventType, Session}; use serde_json::Value; diff --git a/crates/core/src/source_uri.rs b/crates/core/src/source_uri.rs index 92cca4ef..eea31dcd 100644 --- a/crates/core/src/source_uri.rs +++ b/crates/core/src/source_uri.rs @@ -1,5 +1,5 @@ -use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use regex::Regex; use std::fmt; diff --git a/crates/core/src/validate.rs b/crates/core/src/validate.rs index 3b4a8257..a281c68e 100644 --- a/crates/core/src/validate.rs +++ b/crates/core/src/validate.rs @@ -188,9 +188,10 @@ mod tests { fn test_empty_session() { let session = make_session_with_events(vec![]); let errs = validate_session(&session).unwrap_err(); - assert!(errs - .iter() - .any(|e| matches!(e, ValidationError::EmptySession))); + assert!( + errs.iter() + .any(|e| matches!(e, ValidationError::EmptySession)) + ); } #[test] @@ -206,9 +207,10 @@ mod tests { }]); session.version = "bad-version".to_string(); let errs = validate_session(&session).unwrap_err(); - assert!(errs - .iter() - .any(|e| matches!(e, ValidationError::InvalidVersion { .. }))); + assert!( + errs.iter() + .any(|e| matches!(e, ValidationError::InvalidVersion { .. })) + ); } #[test] @@ -235,9 +237,10 @@ mod tests { }, ]); let errs = validate_session(&session).unwrap_err(); - assert!(errs - .iter() - .any(|e| matches!(e, ValidationError::DuplicateEventId { .. }))); + assert!( + errs.iter() + .any(|e| matches!(e, ValidationError::DuplicateEventId { .. })) + ); } fn make_event(id: &str, event_type: EventType) -> Event { @@ -321,9 +324,10 @@ mod tests { }, ]); let errs = validate_session(&session).unwrap_err(); - assert!(errs - .iter() - .any(|e| matches!(e, ValidationError::EventsOutOfOrder { index: 1 }))); + assert!( + errs.iter() + .any(|e| matches!(e, ValidationError::EventsOutOfOrder { index: 1 })) + ); } #[test] diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index fce3d00b..0327275a 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -2,6 +2,7 @@ name = "opensession-daemon" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true @@ -11,14 +12,20 @@ keywords = ["ai", "session", "daemon", "opensession"] categories = ["command-line-utilities", "development-tools"] include = ["src/**/*.rs", "Cargo.toml", "LICENSE", "README.md"] +[lib] +doctest = false + [lints] workspace = true [dependencies] opensession-core = { workspace = true } +opensession-paths = { workspace = true } opensession-runtime-config = { workspace = true } opensession-summary = { workspace = true } +opensession-summary-runtime = { workspace = true } opensession-parsers = { workspace = true } +opensession-parser-discovery = { workspace = true } opensession-api = { workspace = true, default-features = false } opensession-api-client = { workspace = true } opensession-local-db = { workspace = true } diff --git a/crates/daemon/src/cli.rs b/crates/daemon/src/cli.rs new file mode 100644 index 00000000..db6e9dc4 --- /dev/null +++ b/crates/daemon/src/cli.rs @@ -0,0 +1,34 @@ +use clap::{Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command( + name = "opensession-daemon", + about = "Background daemon for automatic session detection and local indexing" +)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum DaemonCommand { + /// Start the daemon event loop. + Run, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_defaults_to_run_when_no_subcommand() { + let cli = Cli::try_parse_from(["opensession-daemon"]).expect("parse cli"); + assert!(cli.command.is_none()); + } + + #[test] + fn cli_accepts_run_subcommand() { + let cli = Cli::try_parse_from(["opensession-daemon", "run"]).expect("parse cli"); + assert!(matches!(cli.command, Some(DaemonCommand::Run))); + } +} diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs index 8807f40a..37521344 100644 --- a/crates/daemon/src/config.rs +++ b/crates/daemon/src/config.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use opensession_paths::home_dir; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -6,20 +7,16 @@ use std::path::{Path, PathBuf}; // Re-export shared runtime config types pub use opensession_runtime_config::{ DaemonConfig, DaemonSettings, GitStorageMethod, PublishMode, SessionDefaultView, - CONFIG_FILE_NAME, }; /// Get the config directory path pub fn config_dir() -> Result { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .context("Could not determine home directory")?; - Ok(PathBuf::from(home).join(".config").join("opensession")) + opensession_paths::config_dir().context("Could not determine home directory") } /// Get the daemon config file path pub fn config_path() -> Result { - Ok(config_dir()?.join(CONFIG_FILE_NAME)) + opensession_paths::runtime_config_path().context("Could not determine config file path") } /// Get the PID file path @@ -53,10 +50,7 @@ fn normalize_fixed_runtime_tuning(config: &mut DaemonConfig) { /// Resolve watch paths based on watcher config pub fn resolve_watch_paths(config: &DaemonConfig) -> Vec { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(".")); + let home = home_dir().unwrap_or_else(|_| PathBuf::from(".")); let raw_paths = config.watchers.custom_paths.clone(); @@ -330,6 +324,22 @@ mod tests { assert_eq!(cfg.daemon.max_retries, defaults.daemon.max_retries); } + #[test] + fn daemon_config_dir_uses_centralized_path() { + assert_eq!( + config_dir().expect("config dir"), + opensession_paths::config_dir().expect("central config dir") + ); + } + + #[test] + fn daemon_config_path_uses_centralized_runtime_path() { + assert_eq!( + config_path().expect("config path"), + opensession_paths::runtime_config_path().expect("central runtime config path") + ); + } + #[test] fn test_find_repo_root() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/daemon/src/entrypoint.rs b/crates/daemon/src/entrypoint.rs new file mode 100644 index 00000000..d9a2a4b8 --- /dev/null +++ b/crates/daemon/src/entrypoint.rs @@ -0,0 +1,28 @@ +use clap::Parser; +use tracing::error; + +use crate::cli::{Cli, DaemonCommand}; + +pub(crate) async fn run_process() { + let cli = Cli::parse(); + initialize_tracing(); + + let result = match cli.command.unwrap_or(DaemonCommand::Run) { + DaemonCommand::Run => crate::runtime::run().await, + }; + + if let Err(error) = result { + error!("Daemon fatal error: {:#}", error); + std::process::exit(1); + } +} + +fn initialize_tracing() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("opensession_daemon=info".parse().unwrap()) + .add_directive(tracing::Level::WARN.into()), + ) + .init(); +} diff --git a/crates/daemon/src/hooks.rs b/crates/daemon/src/hooks.rs index 47c7b40b..2db08dd7 100644 --- a/crates/daemon/src/hooks.rs +++ b/crates/daemon/src/hooks.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; /// Git hook types managed by opensession. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -474,16 +474,18 @@ pub fn scan_for_secrets(content: &str) -> Vec { for (line_number, line) in content.lines().enumerate() { for (name, pattern) in &patterns { - if let Ok(re) = regex::Regex::new(pattern) { - if re.is_match(line) { - // Redact the matched portion - let redacted = re.replace_all(line, "[REDACTED]").to_string(); - matches.push(SecretMatch { - pattern_name: name.to_string(), - line_number: line_number + 1, - context: redacted, - }); - } + let re = match regex::Regex::new(pattern) { + Ok(re) => re, + Err(_) => continue, + }; + if re.is_match(line) { + // Redact the matched portion + let redacted = re.replace_all(line, "[REDACTED]").to_string(); + matches.push(SecretMatch { + pattern_name: name.to_string(), + line_number: line_number + 1, + context: redacted, + }); } } } @@ -681,10 +683,12 @@ mod tests { let dir = TempDir::new().unwrap(); let result = install_hooks(dir.path(), HookType::all()); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Not a git repository")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Not a git repository") + ); } #[test] diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index a79e31e0..2f394229 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -1,183 +1,14 @@ +mod cli; mod config; +mod entrypoint; mod health; pub mod hooks; mod repo_registry; +mod runtime; mod scheduler; mod watcher; -use anyhow::Result; -use clap::{Parser, Subcommand}; -use opensession_local_db::LocalDb; -use std::sync::Arc; -use tokio::sync::{mpsc, watch}; -use tracing::{error, info}; - -#[derive(Debug, Parser)] -#[command( - name = "opensession-daemon", - about = "Background daemon for automatic session detection and local indexing" -)] -struct Cli { - #[command(subcommand)] - command: Option, -} - -#[derive(Debug, Subcommand)] -enum DaemonCommand { - /// Start the daemon event loop. - Run, -} - #[tokio::main] async fn main() { - let cli = Cli::parse(); - - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::from_default_env() - .add_directive("opensession_daemon=info".parse().unwrap()) - .add_directive(tracing::Level::WARN.into()), - ) - .init(); - - let result = match cli.command.unwrap_or(DaemonCommand::Run) { - DaemonCommand::Run => run().await, - }; - - if let Err(e) = result { - error!("Daemon fatal error: {:#}", e); - std::process::exit(1); - } -} - -async fn run() -> Result<()> { - info!("opensession-daemon starting"); - - let cfg = config::load_config()?; - let watch_paths = config::resolve_watch_paths(&cfg); - - if watch_paths.is_empty() { - info!("No session directories found to watch. The daemon will idle."); - } else { - info!("Watching {} directories", watch_paths.len()); - } - - // Open local DB - let db = Arc::new(LocalDb::open()?); - info!("Local DB opened"); - - // Write PID file - write_pid_file()?; - - // Channel for file change events - let (tx, rx) = mpsc::unbounded_channel(); - - // Shutdown signal - let (shutdown_tx, shutdown_rx) = watch::channel(false); - - // Start file watcher (must keep handle alive) - let _watcher = if !watch_paths.is_empty() { - Some(watcher::start_watcher(&watch_paths, tx.clone())?) - } else { - None - }; - - if !watch_paths.is_empty() { - let seeded = watcher::seed_existing_session_files(&watch_paths, &tx); - if seeded > 0 { - info!( - "Queued {} existing session files for startup backfill", - seeded - ); - } - } - - // Start scheduler in background - let scheduler_cfg = cfg.clone(); - let scheduler_shutdown = shutdown_rx.clone(); - let scheduler_db = Arc::clone(&db); - let scheduler_handle = tokio::spawn(async move { - scheduler::run_scheduler(scheduler_cfg, rx, scheduler_shutdown, scheduler_db).await; - }); - - // Start health check in background - let health_shutdown = shutdown_rx.clone(); - let health_handle = tokio::spawn(health::run_health_check( - cfg.server.url.clone(), - cfg.server.api_key.clone(), - watch_paths.clone(), - cfg.daemon.health_check_interval_secs, - health_shutdown, - )); - - // Wait for shutdown signal - wait_for_shutdown().await; - - info!("Shutdown signal received, stopping..."); - let _ = shutdown_tx.send(true); - - // Wait for tasks to finish - let _ = scheduler_handle.await; - let _ = health_handle.await; - - // Clean up PID file - cleanup_pid_file(); - - info!("opensession-daemon stopped"); - Ok(()) -} - -/// Write PID file so the CLI can find us -fn write_pid_file() -> Result<()> { - let path = config::pid_file_path()?; - let dir = path.parent().unwrap(); - std::fs::create_dir_all(dir)?; - std::fs::write(&path, std::process::id().to_string())?; - info!("PID file written: {}", path.display()); - Ok(()) -} - -/// Remove PID file on clean shutdown -fn cleanup_pid_file() { - if let Ok(path) = config::pid_file_path() { - let _ = std::fs::remove_file(path); - } -} - -/// Wait for SIGTERM or SIGINT -async fn wait_for_shutdown() { - #[cfg(unix)] - { - use tokio::signal::unix::{signal, SignalKind}; - let mut sigterm = signal(SignalKind::terminate()).expect("Failed to register SIGTERM"); - let mut sigint = signal(SignalKind::interrupt()).expect("Failed to register SIGINT"); - tokio::select! { - _ = sigterm.recv() => info!("Received SIGTERM"), - _ = sigint.recv() => info!("Received SIGINT"), - } - } - #[cfg(not(unix))] - { - tokio::signal::ctrl_c() - .await - .expect("Failed to register Ctrl+C handler"); - info!("Received Ctrl+C"); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cli_defaults_to_run_when_no_subcommand() { - let cli = Cli::try_parse_from(["opensession-daemon"]).expect("parse cli"); - assert!(cli.command.is_none()); - } - - #[test] - fn cli_accepts_run_subcommand() { - let cli = Cli::try_parse_from(["opensession-daemon", "run"]).expect("parse cli"); - assert!(matches!(cli.command, Some(DaemonCommand::Run))); - } + entrypoint::run_process().await; } diff --git a/crates/daemon/src/runtime.rs b/crates/daemon/src/runtime.rs new file mode 100644 index 00000000..e23902cc --- /dev/null +++ b/crates/daemon/src/runtime.rs @@ -0,0 +1,126 @@ +use anyhow::Result; +use opensession_local_db::LocalDb; +use std::sync::Arc; +use tokio::sync::{mpsc, watch}; +use tracing::info; + +use crate::{config, health, scheduler, watcher}; + +pub(crate) async fn run() -> Result<()> { + info!("opensession-daemon starting"); + + let cfg = config::load_config()?; + let watch_paths = config::resolve_watch_paths(&cfg); + + if watch_paths.is_empty() { + info!("No session directories found to watch. The daemon will idle."); + } else { + info!("Watching {} directories", watch_paths.len()); + } + + let db = Arc::new(LocalDb::open()?); + info!("Local DB opened"); + + write_pid_file()?; + + let (tx, rx) = mpsc::unbounded_channel(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + let _watcher = start_watcher_pipeline(&watch_paths, &tx)?; + + let scheduler_cfg = cfg.clone(); + let scheduler_shutdown = shutdown_rx.clone(); + let scheduler_db = Arc::clone(&db); + let scheduler_handle = tokio::spawn(async move { + scheduler::run_scheduler(scheduler_cfg, rx, scheduler_shutdown, scheduler_db).await; + }); + + let health_shutdown = shutdown_rx.clone(); + let health_handle = tokio::spawn(health::run_health_check( + cfg.server.url.clone(), + cfg.server.api_key.clone(), + watch_paths.clone(), + cfg.daemon.health_check_interval_secs, + health_shutdown, + )); + + wait_for_shutdown().await; + + info!("Shutdown signal received, stopping..."); + let _ = shutdown_tx.send(true); + + let _ = scheduler_handle.await; + let _ = health_handle.await; + + cleanup_pid_file(); + + info!("opensession-daemon stopped"); + Ok(()) +} + +fn start_watcher_pipeline( + watch_paths: &[std::path::PathBuf], + tx: &mpsc::UnboundedSender, +) -> Result> { + if watch_paths.is_empty() { + return Ok(None); + } + + let watcher_handle = watcher::start_watcher(watch_paths, tx.clone())?; + let seeded = watcher::seed_existing_session_files(watch_paths, tx); + if seeded > 0 { + info!( + "Queued {} existing session files for startup backfill", + seeded + ); + } + Ok(Some(watcher_handle)) +} + +fn write_pid_file() -> Result<()> { + let path = config::pid_file_path()?; + let dir = path.parent().expect("pid file path should have parent"); + std::fs::create_dir_all(dir)?; + std::fs::write(&path, std::process::id().to_string())?; + info!("PID file written: {}", path.display()); + Ok(()) +} + +fn cleanup_pid_file() { + if let Ok(path) = config::pid_file_path() { + let _ = std::fs::remove_file(path); + } +} + +async fn wait_for_shutdown() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigterm = signal(SignalKind::terminate()).expect("Failed to register SIGTERM"); + let mut sigint = signal(SignalKind::interrupt()).expect("Failed to register SIGINT"); + tokio::select! { + _ = sigterm.recv() => info!("Received SIGTERM"), + _ = sigint.recv() => info!("Received SIGINT"), + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c() + .await + .expect("Failed to register Ctrl+C handler"); + info!("Received Ctrl+C"); + } +} + +#[cfg(test)] +mod tests { + use super::start_watcher_pipeline; + use tokio::sync::mpsc; + + #[test] + fn watcher_pipeline_skips_empty_watch_paths() { + let (tx, _rx) = mpsc::unbounded_channel(); + let watcher = start_watcher_pipeline(&[], &tx).expect("empty watcher pipeline"); + assert!(watcher.is_none()); + } +} diff --git a/crates/daemon/src/scheduler.rs b/crates/daemon/src/scheduler.rs index 526f1658..1cf81856 100644 --- a/crates/daemon/src/scheduler.rs +++ b/crates/daemon/src/scheduler.rs @@ -1,1588 +1,11 @@ -use anyhow::Result; -use chrono::{DateTime, Utc}; -use opensession_core::sanitize::{sanitize_session, SanitizeConfig}; -use opensession_core::session::{ - build_git_storage_meta_json_with_git, interaction_compressed_session, is_auxiliary_session, - working_directory, GitMeta, -}; -use opensession_core::Session; -use opensession_git_native::{ - branch_ledger_ref, extract_git_context, resolve_ledger_branch, PruneStats, - SessionSummaryLedgerRecord, SUMMARY_LEDGER_REF, -}; -use opensession_local_db::LocalDb; -use opensession_parsers::parse_with_default_parsers; -use opensession_summary::{summarize_session, GitSummaryRequest}; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::Duration; -use tokio::sync::mpsc; -use tokio::time::Instant; -use tracing::{debug, error, info, warn}; - -use crate::config::{ - DaemonConfig, DaemonSettings, GitStorageMethod, PublishMode, SessionDefaultView, -}; -use crate::repo_registry::RepoRegistry; -use crate::watcher::FileChangeEvent; - -// ── Helpers ────────────────────────────────────────────────────────────── - -/// Extract the working directory from session context attributes. -fn session_cwd(session: &Session) -> Option<&str> { - working_directory(session) -} - -/// Build a JSON metadata blob for git storage from a session. -fn build_session_meta_json(session: &Session, git: Option<&GitMeta>) -> Vec { - build_git_storage_meta_json_with_git(session, git) -} - -fn session_to_hail_jsonl_bytes(session: &Session) -> Option> { - match session.to_jsonl() { - Ok(jsonl) => Some(jsonl.into_bytes()), - Err(e) => { - warn!( - "Failed to serialize session {} to HAIL JSONL: {}", - session.session_id, e - ); - None - } - } -} - -/// Resolve the effective publish mode from canonical runtime config. -fn resolve_publish_mode(settings: &DaemonSettings) -> PublishMode { - settings.publish_on.clone() -} - -fn should_auto_upload(mode: &PublishMode) -> bool { - !matches!(mode, PublishMode::Manual) -} - -/// Resolve retention schedule for git-native session pruning. -fn resolve_git_retention_schedule(config: &DaemonConfig) -> Option<(u32, Duration)> { - if config.git_storage.method == GitStorageMethod::Sqlite { - return None; - } - if !config.git_storage.retention.enabled { - return None; - } - - let keep_days = config.git_storage.retention.keep_days; - let interval_secs = config.git_storage.retention.interval_secs.max(60); - Some((keep_days, Duration::from_secs(interval_secs))) -} - -fn resolve_lifecycle_schedule(config: &DaemonConfig) -> Option { - if !config.lifecycle.enabled { - return None; - } - Some(Duration::from_secs( - config.lifecycle.cleanup_interval_secs.max(60), - )) -} - -fn run_lifecycle_cleanup_on_start(config: &DaemonConfig, db: &LocalDb, registry: &RepoRegistry) { - if resolve_lifecycle_schedule(config).is_none() { - return; - } - if let Err(e) = run_lifecycle_cleanup_once(config, db, registry) { - warn!("Lifecycle startup cleanup failed: {e}"); - } -} - -fn run_git_retention_once(registry: &RepoRegistry, keep_days: u32) -> Result<()> { - let repo_roots = registry.repo_roots(); - if repo_roots.is_empty() { - debug!("Git retention: no tracked repositories"); - return Ok(()); - } - - let storage = opensession_git_native::NativeGitStorage; - for repo_root in repo_roots { - let refs = list_branch_ledger_refs(&repo_root); - if refs.is_empty() { - continue; - } - for ref_name in refs { - match storage.prune_by_age_at_ref(&repo_root, &ref_name, keep_days) { - Ok(PruneStats { - scanned_sessions, - expired_sessions, - rewritten, - }) => { - if rewritten { - info!( - repo = %repo_root.display(), - ref_name, - keep_days, - scanned_sessions, - expired_sessions, - "Git retention: pruned expired sessions" - ); - } else { - debug!( - repo = %repo_root.display(), - ref_name, - keep_days, - scanned_sessions, - "Git retention: no expired sessions" - ); - } - } - Err(e) => { - warn!( - repo = %repo_root.display(), - ref_name, - keep_days, - "Git retention failed: {e}" - ); - } - } - } - } - - Ok(()) -} - -fn resolve_repo_root_from_working_directory(cwd: Option<&str>) -> Option { - cwd.and_then(crate::config::find_repo_root) -} - -fn collect_lifecycle_repo_roots(db: &LocalDb, registry: &RepoRegistry) -> Result> { - let mut deduped: HashSet = registry.repo_roots().into_iter().collect(); - - let filter = opensession_local_db::LocalSessionFilter { - limit: None, - offset: None, - ..Default::default() - }; - let rows = db.list_sessions(&filter)?; - for row in rows { - if let Some(repo_root) = - resolve_repo_root_from_working_directory(row.working_directory.as_deref()) - { - deduped.insert(repo_root); - } - } - - let mut roots = deduped.into_iter().collect::>(); - roots.sort(); - Ok(roots) -} - -fn source_parent_directory_missing(source_path: &str) -> bool { - let path = Path::new(source_path); - path.parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .is_some_and(|parent| !parent.exists()) -} - -fn list_sessions_with_missing_source_parent_dirs(db: &LocalDb) -> Result> { - let mut orphaned = db - .list_session_source_paths()? - .into_iter() - .filter_map(|(session_id, source_path)| { - if source_parent_directory_missing(&source_path) { - Some(session_id) - } else { - None - } - }) - .collect::>(); - orphaned.sort(); - orphaned.dedup(); - Ok(orphaned) -} - -fn run_lifecycle_cleanup_once( - config: &DaemonConfig, - db: &LocalDb, - registry: &RepoRegistry, -) -> Result<()> { - if !config.lifecycle.enabled { - return Ok(()); - } - - let storage = opensession_git_native::NativeGitStorage; - let expired_sessions = db.list_expired_session_ids(config.lifecycle.session_ttl_days)?; - let orphaned_sessions = list_sessions_with_missing_source_parent_dirs(db)?; - let mut sessions_to_delete = expired_sessions - .into_iter() - .collect::>(); - sessions_to_delete.extend(orphaned_sessions); - let total_sessions_to_delete = sessions_to_delete.len(); - let mut deleted_sessions = 0usize; - - for session_id in sessions_to_delete { - let row = db.get_session_by_id(&session_id)?; - let repo_root = resolve_repo_root_from_working_directory( - row.as_ref() - .and_then(|row| row.working_directory.as_deref()), - ); - if let Some(repo_root) = repo_root { - if let Err(e) = - storage.delete_summary_at_ref(&repo_root, SUMMARY_LEDGER_REF, &session_id) - { - warn!( - repo = %repo_root.display(), - session_id, - "Lifecycle cleanup: failed to delete hidden-ref summary for expired session: {e}" - ); - } - } - db.delete_session(&session_id)?; - deleted_sessions = deleted_sessions.saturating_add(1); - } - - let deleted_local_summaries = - db.delete_expired_session_summaries(config.lifecycle.summary_ttl_days)? as usize; - - let repo_roots = collect_lifecycle_repo_roots(db, registry)?; - for repo_root in repo_roots { - match storage.prune_summaries_by_age_at_ref( - &repo_root, - SUMMARY_LEDGER_REF, - config.lifecycle.summary_ttl_days, - ) { - Ok(PruneStats { - scanned_sessions, - expired_sessions, - rewritten, - }) => { - if rewritten { - info!( - repo = %repo_root.display(), - scanned_sessions, - expired_sessions, - keep_days = config.lifecycle.summary_ttl_days, - "Lifecycle cleanup: pruned hidden-ref summaries" - ); - } else { - debug!( - repo = %repo_root.display(), - scanned_sessions, - keep_days = config.lifecycle.summary_ttl_days, - "Lifecycle cleanup: no hidden-ref summary pruning required" - ); - } - } - Err(e) => { - warn!( - repo = %repo_root.display(), - "Lifecycle cleanup: hidden-ref summary pruning failed: {e}" - ); - } - } - } - - if config.git_storage.method != GitStorageMethod::Sqlite { - run_git_retention_once(registry, config.lifecycle.session_ttl_days)?; - } - - info!( - deleted_sessions, - total_sessions_to_delete, - deleted_local_summaries, - session_ttl_days = config.lifecycle.session_ttl_days, - summary_ttl_days = config.lifecycle.summary_ttl_days, - "Lifecycle cleanup: cycle complete" - ); - Ok(()) -} - -fn list_branch_ledger_refs(repo_root: &Path) -> Vec { - let output = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("for-each-ref") - .arg("--format=%(refname)") - .arg(opensession_git_native::BRANCH_LEDGER_REF_PREFIX) - .output(); - let Ok(output) = output else { - return Vec::new(); - }; - if !output.status.success() { - return Vec::new(); - } - String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(ToOwned::to_owned) - .collect() -} - -fn commit_shas_from_reflog(repo_root: &Path, start_ts: i64, end_ts: i64) -> Vec { - let git_dir_output = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("rev-parse") - .arg("--git-dir") - .output(); - let Ok(git_dir_output) = git_dir_output else { - return Vec::new(); - }; - if !git_dir_output.status.success() { - return Vec::new(); - } - let git_dir = String::from_utf8_lossy(&git_dir_output.stdout) - .trim() - .to_string(); - if git_dir.is_empty() { - return Vec::new(); - } - let git_dir_path = if Path::new(&git_dir).is_absolute() { - PathBuf::from(git_dir) - } else { - repo_root.join(git_dir) - }; - let reflog_path = git_dir_path.join("logs").join("HEAD"); - let raw = std::fs::read_to_string(&reflog_path); - let Ok(raw) = raw else { - return Vec::new(); - }; - - let mut seen = HashSet::new(); - let mut commits = Vec::new(); - for line in raw.lines() { - let Some((left, _msg)) = line.split_once('\t') else { - continue; - }; - let mut pieces = left.split_whitespace(); - let _old = pieces.next(); - let new = pieces.next(); - let Some(new_sha) = new else { - continue; - }; - if new_sha.len() < 7 || !new_sha.chars().all(|c| c.is_ascii_hexdigit()) { - continue; - } - let mut tail = left.split_whitespace().rev(); - let _tz = tail.next(); - let ts_raw = tail.next(); - let Some(ts_raw) = ts_raw else { - continue; - }; - let Ok(ts) = ts_raw.parse::() else { - continue; - }; - if ts < start_ts || ts > end_ts { - continue; - } - if seen.insert(new_sha.to_string()) { - commits.push(new_sha.to_string()); - } - } - commits -} - -fn collect_commit_shas_for_session(repo_root: &Path, session: &Session) -> Vec { - let created = session.context.created_at.timestamp(); - let updated = session.context.updated_at.timestamp(); - let start = created.min(updated); - let end = created.max(updated); - - let mut commits = commit_shas_from_reflog(repo_root, start, end); - if commits.is_empty() { - let output = Command::new("git") - .arg("-C") - .arg(repo_root) - .arg("rev-parse") - .arg("HEAD") - .output(); - if let Ok(output) = output { - if output.status.success() { - let head = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !head.is_empty() { - commits.push(head); - } - } - } - } - commits -} - -/// Run the scheduler loop: receives file change events, debounces, parses, and marks share-ready state. -pub async fn run_scheduler( - config: DaemonConfig, - mut rx: mpsc::UnboundedReceiver, - mut shutdown: tokio::sync::watch::Receiver, - db: std::sync::Arc, -) { - let debounce_duration = Duration::from_secs(config.daemon.debounce_secs); - - let effective_mode = resolve_publish_mode(&config.daemon); - let mut repo_registry = match RepoRegistry::load_default() { - Ok(registry) => registry, - Err(e) => { - warn!("failed to load repo registry: {e}"); - RepoRegistry::default() - } - }; - - // Run lifecycle cleanup once at startup before interval-driven cycles. - run_lifecycle_cleanup_on_start(&config, &db, &repo_registry); - - // Pending changes: path -> when we last saw a change - let mut pending: HashMap = HashMap::new(); - - let mut tick = tokio::time::interval(Duration::from_secs(1)); - let retention_schedule = resolve_git_retention_schedule(&config); - let mut next_retention_run = retention_schedule.map(|(_, interval)| Instant::now() + interval); - let lifecycle_interval = resolve_lifecycle_schedule(&config); - let mut next_lifecycle_run = lifecycle_interval.map(|interval| Instant::now() + interval); - - loop { - tokio::select! { - // Receive new file change events - Some(event) = rx.recv() => { - debug!("Scheduling: {:?}", event.path.display()); - pending.insert(event.path, Instant::now()); - } - - // Periodic tick to check for debounced items - _ = tick.tick() => { - let now = Instant::now(); - let effective_debounce = match effective_mode { - PublishMode::Realtime => Duration::from_millis(config.daemon.realtime_debounce_ms), - _ => debounce_duration, - }; - - let ready: Vec = pending - .iter() - .filter(|(_, last_change)| now.duration_since(**last_change) >= effective_debounce) - .map(|(path, _)| path.clone()) - .collect(); - - for path in ready { - pending.remove(&path); - if matches!(effective_mode, PublishMode::Manual) { - debug!( - "Manual mode, indexing locally without auto-publish: {}", - path.display() - ); - } - if let Err(e) = process_file( - &path, - &config, - &db, - &mut repo_registry, - should_auto_upload(&effective_mode), - ) - .await - { - error!("Failed to process {}: {:#}", path.display(), e); - } - } - - if let (Some((keep_days, interval)), Some(next_at)) = - (retention_schedule, next_retention_run) - { - if now >= next_at { - if let Err(e) = run_git_retention_once(&repo_registry, keep_days) { - warn!("Git retention scan failed: {e}"); - } - next_retention_run = Some(now + interval); - } - } - - if let (Some(interval), Some(next_at)) = (lifecycle_interval, next_lifecycle_run) { - if now >= next_at { - if let Err(e) = run_lifecycle_cleanup_once(&config, &db, &repo_registry) { - warn!("Lifecycle cleanup failed: {e}"); - } - next_lifecycle_run = Some(now + interval); - } - } - - } - - // Shutdown signal - _ = shutdown.changed() => { - if *shutdown.borrow() { - info!("Scheduler shutting down"); - break; - } - } - } - } -} - -// --------------------------------------------------------------------------- -// process_file: orchestrator + helpers -// --------------------------------------------------------------------------- - -/// Process a single file: parse, store in local DB, sanitize, and prepare share state. -async fn process_file( - path: &PathBuf, - config: &DaemonConfig, - db: &LocalDb, - repo_registry: &mut RepoRegistry, - auto_upload: bool, -) -> Result<()> { - if was_already_uploaded(path, db)? { - return Ok(()); - } - - let mut session = match parse_session(path)? { - Some(s) => s, - None => return Ok(()), - }; - - // Resolve effective config (global + project-level) - let effective_config = resolve_effective_config(&session, config); - - if is_tool_excluded(&session, &effective_config) { - return Ok(()); - } - - store_locally(&session, path, db, &effective_config)?; - if let Err(error) = maybe_generate_semantic_summary(&session, db, &effective_config).await { - warn!( - session_id = %session.session_id, - "semantic summary generation skipped/failed: {error}" - ); - } - - if !auto_upload { - return Ok(()); - } - - sanitize(&mut session, &effective_config); - - let git_store = maybe_git_store(&session, &effective_config); - if let Some(ref stored) = git_store { - if let Err(e) = repo_registry.add(&stored.repo_root) { - warn!( - repo = %stored.repo_root.display(), - "failed to update repo registry: {e}" - ); - } - } - - mark_session_share_ready( - &session, - db, - git_store - .as_ref() - .and_then(|stored| stored.body_url.as_deref()), - ) -} - -fn resolve_effective_config(session: &Session, config: &DaemonConfig) -> DaemonConfig { - if let Some(cwd) = session_cwd(session) { - if let Some(repo_root) = crate::config::find_repo_root(cwd) { - if let Some(project) = crate::config::load_effective_project_config(&repo_root) { - return crate::config::merge_project_config(config, &project); - } - } - } - - config.clone() -} - -fn was_already_uploaded(path: &PathBuf, db: &LocalDb) -> Result { - let modified: DateTime = std::fs::metadata(path)?.modified()?.into(); - let path_str = path.to_string_lossy().to_string(); - if db.was_uploaded_after(&path_str, &modified)? { - debug!("Skipping already-uploaded file: {}", path.display()); - return Ok(true); - } - Ok(false) -} - -fn parse_session(path: &Path) -> Result> { - let session = match parse_with_default_parsers(path)? { - Some(session) => session, - None => { - warn!("No parser for: {}", path.display()); - return Ok(None); - } - }; - if is_auxiliary_session(&session) { - debug!("Skipping auxiliary session from {}", path.display()); - return Ok(None); - } - - info!("Parsing: {}", path.display()); - Ok(Some(session)) -} - -fn is_tool_excluded(session: &Session, config: &DaemonConfig) -> bool { - let excluded = config - .privacy - .exclude_tools - .iter() - .any(|t| t.eq_ignore_ascii_case(&session.agent.tool)); - - if excluded { - info!( - "Excluding tool '{}': source file excluded by config", - session.agent.tool, - ); - } - excluded -} - -fn store_locally( - session: &Session, - path: &Path, - db: &LocalDb, - config: &DaemonConfig, -) -> Result<()> { - let path_str = path.to_string_lossy().to_string(); - let local_session = if matches!( - config.daemon.session_default_view, - SessionDefaultView::Compressed - ) { - interaction_compressed_session(session) - } else { - session.clone() - }; - - let git = session_cwd(&local_session) - .map(extract_git_context) - .unwrap_or_default(); - let local_git = opensession_local_db::git::GitContext { - remote: git.remote.clone(), - branch: git.branch.clone(), - commit: git.commit.clone(), - repo_name: git.repo_name.clone(), - }; - - db.upsert_local_session(&local_session, &path_str, &local_git)?; - // Keep original source bytes so summary/vector rebuild can survive source-file cleanup. - match std::fs::read(path) { - Ok(body) => { - if let Err(error) = db.cache_body(&session.session_id, &body) { - warn!( - "Failed to cache source body for session {}: {}", - session.session_id, error - ); - } - } - Err(error) => { - warn!( - "Failed to read source file for session {} while caching body: {}", - session.session_id, error - ); - } - } - Ok(()) -} - -async fn maybe_generate_semantic_summary( - session: &Session, - db: &LocalDb, - config: &DaemonConfig, -) -> Result<()> { - let settings = &config.summary; - if !settings.should_generate_on_session_save() { - return Ok(()); - } - if settings.storage.backend == opensession_runtime_config::SummaryStorageBackend::None { - return Ok(()); - } - if !settings.is_configured() { - return Ok(()); - } - - let git_request = if settings.allows_git_changes_fallback() { - session_cwd(session).and_then(|cwd| { - crate::config::find_repo_root(cwd).map(|repo_root| GitSummaryRequest { - repo_root, - commit: extract_git_context(cwd).commit, - }) - }) - } else { - None - }; - - let artifact = summarize_session(session, settings, git_request.as_ref()) - .await - .map_err(anyhow::Error::msg)?; - - match settings.storage.backend { - opensession_runtime_config::SummaryStorageBackend::LocalDb => { - let summary_json = serde_json::to_string(&artifact.summary)?; - let source_details_json = if artifact.source_details.is_empty() { - None - } else { - Some(serde_json::to_string(&artifact.source_details)?) - }; - let diff_tree_json = if artifact.diff_tree.is_empty() { - None - } else { - Some(serde_json::to_string(&artifact.diff_tree)?) - }; - let generated_at = chrono::Utc::now().to_rfc3339(); - let provider = enum_label(&artifact.provider); - let source_kind = enum_label(&artifact.source_kind); - let generation_kind = enum_label(&artifact.generation_kind); - let model = if artifact.model.trim().is_empty() { - None - } else { - Some(artifact.model.clone()) - }; - let prompt_fingerprint = if artifact.prompt_fingerprint.trim().is_empty() { - None - } else { - Some(artifact.prompt_fingerprint) - }; - - db.upsert_session_semantic_summary( - &opensession_local_db::SessionSemanticSummaryUpsert { - session_id: &session.session_id, - summary_json: &summary_json, - generated_at: &generated_at, - provider: &provider, - model: model.as_deref(), - source_kind: &source_kind, - generation_kind: &generation_kind, - prompt_fingerprint: prompt_fingerprint.as_deref(), - source_details_json: source_details_json.as_deref(), - diff_tree_json: diff_tree_json.as_deref(), - error: artifact.error.as_deref(), - }, - )?; - } - opensession_runtime_config::SummaryStorageBackend::HiddenRef => { - let cwd = session_cwd(session) - .ok_or_else(|| anyhow::anyhow!("session working directory is missing"))?; - let repo_root = crate::config::find_repo_root(cwd) - .ok_or_else(|| anyhow::anyhow!("failed to resolve git repo root"))?; - let summary_value = serde_json::to_value(&artifact.summary)?; - let source_details = serde_json::to_value(&artifact.source_details)?; - let diff_tree_value = serde_json::to_value(&artifact.diff_tree)?; - let diff_tree = diff_tree_value.as_array().cloned().unwrap_or_default(); - let record = SessionSummaryLedgerRecord { - session_id: session.session_id.clone(), - generated_at: chrono::Utc::now().to_rfc3339(), - provider: enum_label(&artifact.provider), - model: (!artifact.model.trim().is_empty()).then_some(artifact.model.clone()), - source_kind: enum_label(&artifact.source_kind), - generation_kind: enum_label(&artifact.generation_kind), - prompt_fingerprint: (!artifact.prompt_fingerprint.trim().is_empty()) - .then_some(artifact.prompt_fingerprint), - summary: summary_value, - source_details, - diff_tree, - error: artifact.error.clone(), - }; - opensession_git_native::NativeGitStorage - .store_summary_at_ref(&repo_root, SUMMARY_LEDGER_REF, &record) - .map_err(anyhow::Error::msg)?; - } - opensession_runtime_config::SummaryStorageBackend::None => {} - } - - Ok(()) -} - -fn enum_label(value: &T) -> String { - serde_json::to_string(value) - .ok() - .map(|raw| raw.trim_matches('"').to_string()) - .filter(|raw| !raw.trim().is_empty()) - .unwrap_or_else(|| "unknown".to_string()) -} - -fn sanitize(session: &mut Session, config: &DaemonConfig) { - let sanitize_config = SanitizeConfig { - strip_paths: config.privacy.strip_paths, - strip_env_vars: config.privacy.strip_env_vars, - exclude_patterns: config.privacy.exclude_patterns.clone(), - }; - sanitize_session(session, &sanitize_config); -} - -/// Store a session to the local git-native branch when Git-Native mode is enabled. -/// Returns the body_url (raw content URL) on success, or None on failure/not configured. -struct GitStoreOutcome { - body_url: Option, - repo_root: PathBuf, -} - -fn maybe_git_store(session: &Session, config: &DaemonConfig) -> Option { - if config.git_storage.method == GitStorageMethod::Sqlite { - return None; - } - - let cwd = session_cwd(session)?; - let repo_root = crate::config::find_repo_root(cwd)?; - let git_ctx = extract_git_context(cwd); - let branch = resolve_ledger_branch(git_ctx.branch.as_deref(), git_ctx.commit.as_deref()); - let ref_name = branch_ledger_ref(&branch); - let commit_shas = collect_commit_shas_for_session(&repo_root, session); - - let hail_jsonl = session_to_hail_jsonl_bytes(session)?; - let git_meta = GitMeta { - remote: git_ctx.remote.clone(), - repo_name: git_ctx.repo_name.clone(), - branch: Some(branch), - head: git_ctx.commit.clone(), - commits: commit_shas.clone(), - }; - let meta_json = build_session_meta_json(session, Some(&git_meta)); - - let storage = opensession_git_native::NativeGitStorage; - match storage.store_session_at_ref( - &repo_root, - &ref_name, - &session.session_id, - &hail_jsonl, - &meta_json, - &commit_shas, - ) { - Ok(stored) => { - info!( - "Stored session {} to git ref {} at {}", - session.session_id, stored.ref_name, stored.hail_path - ); - // Try to generate raw URL from git remote - let body_url = git_ctx.remote.as_ref().map(|remote| { - opensession_git_native::generate_raw_url( - remote, - &stored.commit_id, - &stored.hail_path, - ) - }); - Some(GitStoreOutcome { - body_url, - repo_root, - }) - } - Err(e) => { - warn!( - "Git-native store failed for session {}: {}", - session.session_id, e - ); - None - } - } -} - -fn mark_session_share_ready(session: &Session, db: &LocalDb, body_url: Option<&str>) -> Result<()> { - if let Some(url) = body_url { - info!( - "Session {} stored in git-native ledger and share-ready ({})", - session.session_id, url - ); - } else { - info!( - "Session {} indexed locally. Share with CLI quick flow: opensession share os://src/local/ --quick", - session.session_id - ); - } - db.mark_synced(&session.session_id)?; - Ok(()) -} +mod config_resolution; +mod git_retention; +mod helpers; +mod lifecycle; +mod pipeline; +mod runtime; #[cfg(test)] -mod tests { - use super::*; - use opensession_core::{Agent, Content, Event, EventType, Session}; - use opensession_git_native::{ - NativeGitStorage, SessionSummaryLedgerRecord, SUMMARY_LEDGER_REF, - }; - use opensession_runtime_config::{SummaryProvider, SummaryStorageBackend, SummaryTriggerMode}; - use serde_json::json; - use std::collections::HashMap; - use std::path::PathBuf; - use std::process::Command; - use tempfile::tempdir; - - /// Helper: build a minimal Session with the given context attributes. - fn make_session_with_attrs(attrs: HashMap) -> Session { - let mut s = Session::new( - "test-session-id".into(), - Agent { - provider: "anthropic".into(), - model: "claude-opus-4-6".into(), - tool: "claude-code".into(), - tool_version: None, - }, - ); - s.context.attributes = attrs; - s - } - - fn make_interaction_fixture_session(session_id: &str) -> Session { - let mut session = Session::new( - session_id.to_string(), - Agent { - provider: "anthropic".into(), - model: "claude-opus-4-6".into(), - tool: "claude-code".into(), - tool_version: None, - }, - ); - session.events = vec![ - Event { - event_id: format!("{session_id}-user"), - timestamp: Utc::now(), - event_type: EventType::UserMessage, - task_id: None, - content: Content::text("hello"), - duration_ms: None, - attributes: HashMap::new(), - }, - Event { - event_id: format!("{session_id}-tool"), - timestamp: Utc::now(), - event_type: EventType::ToolCall { - name: "write_file".to_string(), - }, - task_id: None, - content: Content::text(""), - duration_ms: None, - attributes: HashMap::new(), - }, - ]; - session.recompute_stats(); - session - } - - fn init_git_repo(path: &Path) { - let status = Command::new("git") - .arg("init") - .current_dir(path) - .status() - .expect("git init should run"); - assert!(status.success(), "git init should succeed"); - - let status = Command::new("git") - .args(["config", "user.email", "test@opensession.local"]) - .current_dir(path) - .status() - .expect("git config user.email should run"); - assert!(status.success(), "git config user.email should succeed"); - - let status = Command::new("git") - .args(["config", "user.name", "OpenSession Tests"]) - .current_dir(path) - .status() - .expect("git config user.name should run"); - assert!(status.success(), "git config user.name should succeed"); - } - - #[test] - fn test_session_cwd_from_cwd_key() { - let mut attrs = HashMap::new(); - attrs.insert("cwd".into(), json!("/home/user/project")); - let session = make_session_with_attrs(attrs); - assert_eq!(session_cwd(&session), Some("/home/user/project")); - } - - #[test] - fn test_session_cwd_from_working_directory() { - let mut attrs = HashMap::new(); - attrs.insert("working_directory".into(), json!("/tmp/work")); - let session = make_session_with_attrs(attrs); - assert_eq!(session_cwd(&session), Some("/tmp/work")); - } - - #[test] - fn test_session_cwd_prefers_cwd_over_working_directory() { - let mut attrs = HashMap::new(); - attrs.insert("cwd".into(), json!("/preferred")); - attrs.insert("working_directory".into(), json!("/fallback")); - let session = make_session_with_attrs(attrs); - assert_eq!(session_cwd(&session), Some("/preferred")); - } - - #[test] - fn test_session_cwd_missing() { - let session = make_session_with_attrs(HashMap::new()); - assert_eq!(session_cwd(&session), None); - } - - #[test] - fn test_session_cwd_non_string_value_returns_none() { - let mut attrs = HashMap::new(); - attrs.insert("cwd".into(), json!(42)); - let session = make_session_with_attrs(attrs); - assert_eq!(session_cwd(&session), None); - } - - #[test] - fn test_build_session_meta_json_with_title() { - let mut session = make_session_with_attrs(HashMap::new()); - session.context.title = Some("My Session Title".into()); - - let bytes = build_session_meta_json(&session, None); - let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - - assert_eq!(parsed["session_id"], "test-session-id"); - assert_eq!(parsed["schema_version"], 2); - assert_eq!(parsed["title"], "My Session Title"); - assert_eq!(parsed["tool"], "claude-code"); - assert_eq!(parsed["model"], "claude-opus-4-6"); - assert!(parsed["stats"].is_object()); - } - - #[test] - fn test_build_session_meta_json_no_title() { - let session = make_session_with_attrs(HashMap::new()); - - let bytes = build_session_meta_json(&session, None); - let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - - assert_eq!(parsed["session_id"], "test-session-id"); - assert!(parsed["title"].is_null()); - assert_eq!(parsed["tool"], "claude-code"); - assert_eq!(parsed["model"], "claude-opus-4-6"); - } - - #[test] - fn test_build_session_meta_json_includes_git_block() { - let session = make_session_with_attrs(HashMap::new()); - let git = GitMeta { - remote: Some("git@github.com:org/repo.git".to_string()), - repo_name: Some("org/repo".to_string()), - branch: Some("feature/x".to_string()), - head: Some("abcd1234".to_string()), - commits: vec!["abcd1234".to_string()], - }; - - let bytes = build_session_meta_json(&session, Some(&git)); - let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(parsed["schema_version"], 2); - assert_eq!(parsed["git"]["repo_name"], "org/repo"); - assert_eq!(parsed["git"]["head"], "abcd1234"); - } - - #[test] - fn test_session_to_hail_jsonl_bytes_uses_header_event_stats_lines() { - let mut session = make_session_with_attrs(HashMap::new()); - session.events.push(opensession_core::Event { - event_id: "e1".into(), - timestamp: Utc::now(), - event_type: opensession_core::EventType::UserMessage, - task_id: None, - content: opensession_core::Content::text("hello"), - duration_ms: None, - attributes: HashMap::new(), - }); - session.recompute_stats(); - - let body = session_to_hail_jsonl_bytes(&session).expect("serialize HAIL JSONL"); - let text = String::from_utf8(body).expect("jsonl must be utf-8"); - let lines: Vec<&str> = text.lines().filter(|line| !line.is_empty()).collect(); - assert_eq!(lines.len(), 3, "expected header/event/stats lines"); - - let header: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); - assert_eq!(header["type"], "header"); - let event: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); - assert_eq!(event["type"], "event"); - let stats: serde_json::Value = serde_json::from_str(lines[2]).unwrap(); - assert_eq!(stats["type"], "stats"); - } - - #[test] - fn test_resolve_publish_mode_auto_publish_true() { - let settings = DaemonSettings { - auto_publish: true, - publish_on: PublishMode::SessionEnd, - ..Default::default() - }; - assert_eq!(resolve_publish_mode(&settings), PublishMode::SessionEnd); - } - - #[test] - fn test_resolve_publish_mode_auto_publish_false_manual() { - let settings = DaemonSettings { - auto_publish: false, - publish_on: PublishMode::Manual, - ..Default::default() - }; - assert_eq!(resolve_publish_mode(&settings), PublishMode::Manual); - } - - #[test] - fn test_resolve_publish_mode_uses_publish_on_even_when_auto_publish_false() { - let settings = DaemonSettings { - auto_publish: false, - publish_on: PublishMode::Realtime, - ..Default::default() - }; - assert_eq!(resolve_publish_mode(&settings), PublishMode::Realtime); - } - - #[test] - fn test_should_auto_upload_is_false_for_manual_mode() { - assert!(!should_auto_upload(&PublishMode::Manual)); - } - - #[test] - fn test_should_auto_upload_is_true_for_session_end_and_realtime() { - assert!(should_auto_upload(&PublishMode::SessionEnd)); - assert!(should_auto_upload(&PublishMode::Realtime)); - } - - #[test] - fn test_resolve_git_retention_schedule_disabled_by_default() { - let config = DaemonConfig::default(); - assert!(resolve_git_retention_schedule(&config).is_none()); - } - - #[test] - fn test_resolve_git_retention_schedule_enabled_native_mode() { - let mut config = DaemonConfig::default(); - config.git_storage.method = GitStorageMethod::Native; - config.git_storage.retention.enabled = true; - config.git_storage.retention.keep_days = 14; - config.git_storage.retention.interval_secs = 120; - - let (keep_days, interval) = - resolve_git_retention_schedule(&config).expect("retention should be enabled"); - assert_eq!(keep_days, 14); - assert_eq!(interval, Duration::from_secs(120)); - } - - #[test] - fn test_resolve_git_retention_schedule_enforces_min_interval() { - let mut config = DaemonConfig::default(); - config.git_storage.method = GitStorageMethod::Native; - config.git_storage.retention.enabled = true; - config.git_storage.retention.interval_secs = 0; - - let (_, interval) = - resolve_git_retention_schedule(&config).expect("retention should be enabled"); - assert_eq!(interval, Duration::from_secs(60)); - } - - #[test] - fn test_resolve_lifecycle_schedule_honors_enabled_and_min_interval() { - let mut config = DaemonConfig::default(); - config.lifecycle.enabled = false; - assert!(resolve_lifecycle_schedule(&config).is_none()); - - config.lifecycle.enabled = true; - config.lifecycle.cleanup_interval_secs = 12; - assert_eq!( - resolve_lifecycle_schedule(&config), - Some(Duration::from_secs(60)) - ); - - config.lifecycle.cleanup_interval_secs = 120; - assert_eq!( - resolve_lifecycle_schedule(&config), - Some(Duration::from_secs(120)) - ); - } - - #[test] - fn test_run_lifecycle_cleanup_on_start_runs_immediately() { - let tmp = tempdir().expect("tempdir"); - let db_path = tmp.path().join("local.db"); - let db = LocalDb::open_path(&db_path).expect("open local db"); - - let mut expired = make_interaction_fixture_session("startup-expired-session"); - expired.context.created_at = Utc::now() - chrono::Duration::days(90); - expired.context.updated_at = expired.context.created_at; - db.upsert_local_session( - &expired, - "/tmp/startup-expired-session.jsonl", - &opensession_local_db::git::GitContext::default(), - ) - .expect("upsert expired session"); - - let mut config = DaemonConfig::default(); - config.lifecycle.enabled = true; - config.lifecycle.session_ttl_days = 30; - config.lifecycle.summary_ttl_days = 30; - config.lifecycle.cleanup_interval_secs = 3600; - - run_lifecycle_cleanup_on_start(&config, &db, &RepoRegistry::default()); - - assert!( - db.get_session_by_id("startup-expired-session") - .expect("query expired session") - .is_none(), - "expired session should be deleted during startup lifecycle cleanup" - ); - } - - #[test] - fn test_run_lifecycle_cleanup_deletes_expired_sessions_and_hidden_ref_summaries() { - let tmp = tempdir().expect("tempdir"); - let repo_root = tmp.path().join("repo"); - std::fs::create_dir_all(&repo_root).expect("create repo root"); - init_git_repo(&repo_root); - - let db_path = tmp.path().join("local.db"); - let db = LocalDb::open_path(&db_path).expect("open local db"); - let mut expired = make_interaction_fixture_session("expired-session"); - expired.context.created_at = Utc::now() - chrono::Duration::days(90); - expired.context.updated_at = expired.context.created_at; - expired.context.attributes.insert( - "working_directory".to_string(), - json!(repo_root.to_string_lossy().to_string()), - ); - db.upsert_local_session( - &expired, - "/tmp/expired-session.jsonl", - &opensession_local_db::git::GitContext::default(), - ) - .expect("upsert expired session"); - - let mut active = make_interaction_fixture_session("active-session"); - active.context.attributes.insert( - "working_directory".to_string(), - json!(repo_root.to_string_lossy().to_string()), - ); - db.upsert_local_session( - &active, - "/tmp/active-session.jsonl", - &opensession_local_db::git::GitContext::default(), - ) - .expect("upsert active session"); - - let storage = NativeGitStorage; - storage - .store_summary_at_ref( - &repo_root, - SUMMARY_LEDGER_REF, - &SessionSummaryLedgerRecord { - session_id: "expired-session".to_string(), - generated_at: "2026-01-01T00:00:00Z".to_string(), - provider: "codex_exec".to_string(), - model: None, - source_kind: "session_signals".to_string(), - generation_kind: "provider".to_string(), - prompt_fingerprint: None, - summary: json!({ "changes": "expired" }), - source_details: json!({}), - diff_tree: vec![], - error: None, - }, - ) - .expect("store expired summary"); - storage - .store_summary_at_ref( - &repo_root, - SUMMARY_LEDGER_REF, - &SessionSummaryLedgerRecord { - session_id: "active-session".to_string(), - generated_at: "2026-01-01T00:00:00Z".to_string(), - provider: "codex_exec".to_string(), - model: None, - source_kind: "session_signals".to_string(), - generation_kind: "provider".to_string(), - prompt_fingerprint: None, - summary: json!({ "changes": "active" }), - source_details: json!({}), - diff_tree: vec![], - error: None, - }, - ) - .expect("store active summary"); - - let mut registry = RepoRegistry::default(); - registry - .add(&repo_root) - .expect("repo registry should accept repo root"); - - let mut config = DaemonConfig::default(); - config.lifecycle.enabled = true; - config.lifecycle.session_ttl_days = 30; - config.lifecycle.summary_ttl_days = 10_000; - config.lifecycle.cleanup_interval_secs = 60; - - run_lifecycle_cleanup_once(&config, &db, ®istry).expect("run lifecycle cleanup"); - - assert!( - db.get_session_by_id("expired-session") - .expect("query expired session") - .is_none(), - "expired session should be deleted" - ); - assert!( - db.get_session_by_id("active-session") - .expect("query active session") - .is_some(), - "active session should remain" - ); - assert!( - storage - .load_summary_at_ref(&repo_root, SUMMARY_LEDGER_REF, "expired-session") - .expect("load expired summary") - .is_none(), - "hidden-ref summary for expired session should be deleted" - ); - assert!( - storage - .load_summary_at_ref(&repo_root, SUMMARY_LEDGER_REF, "active-session") - .expect("load active summary") - .is_some(), - "active session summary should remain" - ); - } - - #[test] - fn test_run_lifecycle_cleanup_prunes_local_summary_rows_by_ttl() { - let tmp = tempdir().expect("tempdir"); - let db_path = tmp.path().join("local.db"); - let db = LocalDb::open_path(&db_path).expect("open local db"); - - let session_old = make_interaction_fixture_session("summary-old"); - db.upsert_local_session( - &session_old, - "/tmp/summary-old.jsonl", - &opensession_local_db::git::GitContext::default(), - ) - .expect("upsert old summary session"); - let session_new = make_interaction_fixture_session("summary-new"); - db.upsert_local_session( - &session_new, - "/tmp/summary-new.jsonl", - &opensession_local_db::git::GitContext::default(), - ) - .expect("upsert new summary session"); - - db.upsert_session_semantic_summary(&opensession_local_db::SessionSemanticSummaryUpsert { - session_id: "summary-old", - summary_json: r#"{"changes":"old"}"#, - generated_at: "2020-01-01T00:00:00Z", - provider: "codex_exec", - model: None, - source_kind: "session_signals", - generation_kind: "provider", - prompt_fingerprint: None, - source_details_json: None, - diff_tree_json: None, - error: None, - }) - .expect("insert old summary"); - db.upsert_session_semantic_summary(&opensession_local_db::SessionSemanticSummaryUpsert { - session_id: "summary-new", - summary_json: r#"{"changes":"new"}"#, - generated_at: "2999-01-01T00:00:00Z", - provider: "codex_exec", - model: None, - source_kind: "session_signals", - generation_kind: "provider", - prompt_fingerprint: None, - source_details_json: None, - diff_tree_json: None, - error: None, - }) - .expect("insert new summary"); - - let mut config = DaemonConfig::default(); - config.lifecycle.enabled = true; - config.lifecycle.session_ttl_days = 10_000; - config.lifecycle.summary_ttl_days = 30; - config.lifecycle.cleanup_interval_secs = 60; - - run_lifecycle_cleanup_once(&config, &db, &RepoRegistry::default()) - .expect("run lifecycle cleanup"); - - assert!( - db.get_session_semantic_summary("summary-old") - .expect("query old summary") - .is_none(), - "old summary should be pruned" - ); - assert!( - db.get_session_semantic_summary("summary-new") - .expect("query new summary") - .is_some(), - "new summary should remain" - ); - } - - #[test] - fn test_run_lifecycle_cleanup_deletes_sessions_with_missing_source_parent_dir() { - let tmp = tempdir().expect("tempdir"); - let db_path = tmp.path().join("local.db"); - let db = LocalDb::open_path(&db_path).expect("open local db"); - - let missing_parent_root = tmp.path().join("deleted-source-root"); - std::fs::create_dir_all(&missing_parent_root).expect("create missing parent root"); - let missing_parent_source = missing_parent_root.join("missing-parent.jsonl"); - - let existing_parent_root = tmp.path().join("existing-source-root"); - std::fs::create_dir_all(&existing_parent_root).expect("create existing parent root"); - let existing_parent_source = existing_parent_root.join("missing-file.jsonl"); - - let missing_parent_session = make_interaction_fixture_session("missing-parent-session"); - db.upsert_local_session( - &missing_parent_session, - missing_parent_source - .to_str() - .expect("missing parent source path should be utf-8"), - &opensession_local_db::git::GitContext::default(), - ) - .expect("upsert missing-parent session"); - - let existing_parent_session = make_interaction_fixture_session("existing-parent-session"); - db.upsert_local_session( - &existing_parent_session, - existing_parent_source - .to_str() - .expect("existing parent source path should be utf-8"), - &opensession_local_db::git::GitContext::default(), - ) - .expect("upsert existing-parent session"); - - std::fs::remove_dir_all(&missing_parent_root).expect("remove missing parent root"); - - let mut config = DaemonConfig::default(); - config.lifecycle.enabled = true; - config.lifecycle.session_ttl_days = 10_000; - config.lifecycle.summary_ttl_days = 10_000; - config.lifecycle.cleanup_interval_secs = 60; - - run_lifecycle_cleanup_once(&config, &db, &RepoRegistry::default()) - .expect("run lifecycle cleanup"); - - assert!( - db.get_session_by_id("missing-parent-session") - .expect("query missing-parent session") - .is_none(), - "session should be deleted when source parent directory is gone" - ); - assert!( - db.get_session_by_id("existing-parent-session") - .expect("query existing-parent session") - .is_some(), - "session should remain when source parent directory still exists" - ); - } - - #[test] - fn test_store_locally_uses_compressed_session_only_when_default_view_is_compressed() { - let tmp = tempdir().expect("tempdir"); - let db_path = PathBuf::from(tmp.path()).join("local.db"); - let db = LocalDb::open_path(&db_path).expect("open local db"); - - let full_session = make_interaction_fixture_session("store-full"); - let mut full_config = DaemonConfig::default(); - full_config.daemon.session_default_view = SessionDefaultView::Full; - store_locally( - &full_session, - Path::new("/tmp/store-full.jsonl"), - &db, - &full_config, - ) - .expect("store full session"); - - let stored_full = db - .get_session_by_id("store-full") - .expect("query full") - .expect("full session exists"); - assert_eq!(stored_full.event_count, 2); - - let compressed_session = make_interaction_fixture_session("store-compressed"); - let mut compressed_config = DaemonConfig::default(); - compressed_config.daemon.session_default_view = SessionDefaultView::Compressed; - store_locally( - &compressed_session, - Path::new("/tmp/store-compressed.jsonl"), - &db, - &compressed_config, - ) - .expect("store compressed session"); - - let stored_compressed = db - .get_session_by_id("store-compressed") - .expect("query compressed") - .expect("compressed session exists"); - assert_eq!(stored_compressed.event_count, 1); - } - - #[test] - fn test_store_locally_caches_source_body() { - let tmp = tempdir().expect("tempdir"); - let db_path = PathBuf::from(tmp.path()).join("local.db"); - let db = LocalDb::open_path(&db_path).expect("open local db"); - let session = make_interaction_fixture_session("store-cache"); - let source_path = PathBuf::from(tmp.path()).join("store-cache.jsonl"); - let source_body = b"{\"source\":\"fixture\"}\n".to_vec(); - std::fs::write(&source_path, &source_body).expect("write source fixture"); - - let mut config = DaemonConfig::default(); - config.daemon.session_default_view = SessionDefaultView::Full; - store_locally(&session, &source_path, &db, &config).expect("store cached session"); - - let cached = db - .get_cached_body("store-cache") - .expect("query body cache") - .expect("cache row should exist"); - assert_eq!(cached, source_body); - } - - #[tokio::test] - async fn test_auto_summary_runs_on_session_save_and_persists_row() { - let tmp = tempdir().expect("tempdir"); - let db_path = PathBuf::from(tmp.path()).join("local.db"); - let db = LocalDb::open_path(&db_path).expect("open local db"); - - let session = make_interaction_fixture_session("summary-auto"); - let mut config = DaemonConfig::default(); - config.summary.provider.id = SummaryProvider::CodexExec; - config.summary.storage.trigger = SummaryTriggerMode::OnSessionSave; - config.summary.storage.backend = SummaryStorageBackend::LocalDb; - - maybe_generate_semantic_summary(&session, &db, &config) - .await - .expect("summary generation should not fail hard"); - - let row = db - .get_session_semantic_summary("summary-auto") - .expect("query summary") - .expect("summary row should exist"); - assert_eq!(row.provider, "codex_exec"); - assert_eq!(row.source_kind, "session_signals"); - assert!(!row.summary_json.trim().is_empty()); - } - - #[tokio::test] - async fn test_auto_summary_skips_when_trigger_mode_is_manual() { - let tmp = tempdir().expect("tempdir"); - let db_path = PathBuf::from(tmp.path()).join("local.db"); - let db = LocalDb::open_path(&db_path).expect("open local db"); - - let session = make_interaction_fixture_session("summary-manual"); - let mut config = DaemonConfig::default(); - config.summary.provider.id = SummaryProvider::CodexExec; - config.summary.storage.trigger = SummaryTriggerMode::Manual; - config.summary.storage.backend = SummaryStorageBackend::LocalDb; - - maybe_generate_semantic_summary(&session, &db, &config) - .await - .expect("manual trigger should no-op"); - - let row = db - .get_session_semantic_summary("summary-manual") - .expect("query summary"); - assert!(row.is_none()); - } - - #[tokio::test] - async fn test_auto_summary_skips_when_storage_backend_is_none() { - let tmp = tempdir().expect("tempdir"); - let db_path = PathBuf::from(tmp.path()).join("local.db"); - let db = LocalDb::open_path(&db_path).expect("open local db"); - - let session = make_interaction_fixture_session("summary-no-persist"); - let mut config = DaemonConfig::default(); - config.summary.provider.id = SummaryProvider::CodexExec; - config.summary.storage.trigger = SummaryTriggerMode::OnSessionSave; - config.summary.storage.backend = SummaryStorageBackend::None; - - maybe_generate_semantic_summary(&session, &db, &config) - .await - .expect("none persist should no-op"); +mod tests; - let row = db - .get_session_semantic_summary("summary-no-persist") - .expect("query summary"); - assert!(row.is_none()); - } -} +pub use runtime::run_scheduler; diff --git a/crates/daemon/src/scheduler/config_resolution.rs b/crates/daemon/src/scheduler/config_resolution.rs new file mode 100644 index 00000000..4e098d24 --- /dev/null +++ b/crates/daemon/src/scheduler/config_resolution.rs @@ -0,0 +1,48 @@ +use std::time::Duration; + +use opensession_core::Session; + +use super::helpers::session_cwd; +use crate::config::{DaemonConfig, DaemonSettings, GitStorageMethod, PublishMode}; + +pub(super) fn resolve_publish_mode(settings: &DaemonSettings) -> PublishMode { + settings.publish_on.clone() +} + +pub(super) fn should_auto_upload(mode: &PublishMode) -> bool { + !matches!(mode, PublishMode::Manual) +} + +pub(super) fn resolve_git_retention_schedule(config: &DaemonConfig) -> Option<(u32, Duration)> { + if config.git_storage.method == GitStorageMethod::Sqlite { + return None; + } + if !config.git_storage.retention.enabled { + return None; + } + + let keep_days = config.git_storage.retention.keep_days; + let interval_secs = config.git_storage.retention.interval_secs.max(60); + Some((keep_days, Duration::from_secs(interval_secs))) +} + +pub(super) fn resolve_lifecycle_schedule(config: &DaemonConfig) -> Option { + if !config.lifecycle.enabled { + return None; + } + Some(Duration::from_secs( + config.lifecycle.cleanup_interval_secs.max(60), + )) +} + +pub(super) fn resolve_effective_config(session: &Session, config: &DaemonConfig) -> DaemonConfig { + if let Some(cwd) = session_cwd(session) { + if let Some(repo_root) = crate::config::find_repo_root(cwd) { + if let Some(project) = crate::config::load_effective_project_config(&repo_root) { + return crate::config::merge_project_config(config, &project); + } + } + } + + config.clone() +} diff --git a/crates/daemon/src/scheduler/git_retention.rs b/crates/daemon/src/scheduler/git_retention.rs new file mode 100644 index 00000000..63d46385 --- /dev/null +++ b/crates/daemon/src/scheduler/git_retention.rs @@ -0,0 +1,176 @@ +use anyhow::Result; +use opensession_core::Session; +use opensession_git_native::PruneStats; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tracing::{debug, info, warn}; + +use crate::repo_registry::RepoRegistry; + +pub(super) fn run_git_retention_once(registry: &RepoRegistry, keep_days: u32) -> Result<()> { + let repo_roots = registry.repo_roots(); + if repo_roots.is_empty() { + debug!("Git retention: no tracked repositories"); + return Ok(()); + } + + let storage = opensession_git_native::NativeGitStorage; + for repo_root in repo_roots { + let refs = list_branch_ledger_refs(&repo_root); + if refs.is_empty() { + continue; + } + for ref_name in refs { + let prune_result = storage.prune_by_age_at_ref(&repo_root, &ref_name, keep_days); + match prune_result { + Ok(PruneStats { + scanned_sessions, + expired_sessions, + rewritten, + }) => { + if rewritten { + info!( + repo = %repo_root.display(), + ref_name, + keep_days, + scanned_sessions, + expired_sessions, + "Git retention: pruned expired sessions" + ); + } else { + debug!( + repo = %repo_root.display(), + ref_name, + keep_days, + scanned_sessions, + "Git retention: no expired sessions" + ); + } + } + Err(error) => { + warn!( + repo = %repo_root.display(), + ref_name, + keep_days, + "Git retention failed: {error}" + ); + } + } + } + } + + Ok(()) +} + +fn list_branch_ledger_refs(repo_root: &Path) -> Vec { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("for-each-ref") + .arg("--format=%(refname)") + .arg(opensession_git_native::BRANCH_LEDGER_REF_PREFIX) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn commit_shas_from_reflog(repo_root: &Path, start_ts: i64, end_ts: i64) -> Vec { + let git_dir_output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("rev-parse") + .arg("--git-dir") + .output(); + let Ok(git_dir_output) = git_dir_output else { + return Vec::new(); + }; + if !git_dir_output.status.success() { + return Vec::new(); + } + let git_dir = String::from_utf8_lossy(&git_dir_output.stdout) + .trim() + .to_string(); + if git_dir.is_empty() { + return Vec::new(); + } + let git_dir_path = if Path::new(&git_dir).is_absolute() { + PathBuf::from(git_dir) + } else { + repo_root.join(git_dir) + }; + let reflog_path = git_dir_path.join("logs").join("HEAD"); + let raw = std::fs::read_to_string(&reflog_path); + let Ok(raw) = raw else { + return Vec::new(); + }; + + let mut seen = HashSet::new(); + let mut commits = Vec::new(); + for line in raw.lines() { + let Some((left, _msg)) = line.split_once('\t') else { + continue; + }; + let mut pieces = left.split_whitespace(); + let _old = pieces.next(); + let new = pieces.next(); + let Some(new_sha) = new else { + continue; + }; + if new_sha.len() < 7 || !new_sha.chars().all(|c| c.is_ascii_hexdigit()) { + continue; + } + let mut tail = left.split_whitespace().rev(); + let _tz = tail.next(); + let ts_raw = tail.next(); + let Some(ts_raw) = ts_raw else { + continue; + }; + let Ok(ts) = ts_raw.parse::() else { + continue; + }; + if ts < start_ts || ts > end_ts { + continue; + } + if seen.insert(new_sha.to_string()) { + commits.push(new_sha.to_string()); + } + } + commits +} + +pub(super) fn collect_commit_shas_for_session(repo_root: &Path, session: &Session) -> Vec { + let created = session.context.created_at.timestamp(); + let updated = session.context.updated_at.timestamp(); + let start = created.min(updated); + let end = created.max(updated); + + let mut commits = commit_shas_from_reflog(repo_root, start, end); + if commits.is_empty() { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("rev-parse") + .arg("HEAD") + .output(); + if let Ok(output) = output { + if output.status.success() { + let head = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !head.is_empty() { + commits.push(head); + } + } + } + } + commits +} diff --git a/crates/daemon/src/scheduler/helpers.rs b/crates/daemon/src/scheduler/helpers.rs new file mode 100644 index 00000000..5817818c --- /dev/null +++ b/crates/daemon/src/scheduler/helpers.rs @@ -0,0 +1,43 @@ +use crate::config::DaemonConfig; +use opensession_core::Session; +use opensession_core::sanitize::{SanitizeConfig, sanitize_session}; +use opensession_core::session::{GitMeta, build_git_storage_meta_json_with_git, working_directory}; + +pub(super) fn session_cwd(session: &Session) -> Option<&str> { + working_directory(session) +} + +pub(super) fn build_session_meta_json(session: &Session, git: Option<&GitMeta>) -> Vec { + build_git_storage_meta_json_with_git(session, git) +} + +pub(super) fn session_to_hail_jsonl_bytes(session: &Session) -> Option> { + match session.to_jsonl() { + Ok(jsonl) => Some(jsonl.into_bytes()), + Err(error) => { + tracing::warn!( + "Failed to serialize session {} to HAIL JSONL: {}", + session.session_id, + error + ); + None + } + } +} + +pub(super) fn enum_label(value: &T) -> String { + serde_json::to_string(value) + .ok() + .map(|raw| raw.trim_matches('"').to_string()) + .filter(|raw| !raw.trim().is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +pub(super) fn sanitize(session: &mut Session, config: &DaemonConfig) { + let sanitize_config = SanitizeConfig { + strip_paths: config.privacy.strip_paths, + strip_env_vars: config.privacy.strip_env_vars, + exclude_patterns: config.privacy.exclude_patterns.clone(), + }; + sanitize_session(session, &sanitize_config); +} diff --git a/crates/daemon/src/scheduler/lifecycle.rs b/crates/daemon/src/scheduler/lifecycle.rs new file mode 100644 index 00000000..1eca810a --- /dev/null +++ b/crates/daemon/src/scheduler/lifecycle.rs @@ -0,0 +1,172 @@ +use anyhow::Result; +use opensession_git_native::{PruneStats, SUMMARY_LEDGER_REF}; +use opensession_local_db::LocalDb; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use tracing::{debug, info, warn}; + +use crate::config::{DaemonConfig, GitStorageMethod}; +use crate::repo_registry::RepoRegistry; + +use super::config_resolution::resolve_lifecycle_schedule; +use super::git_retention::run_git_retention_once; + +pub(super) fn run_lifecycle_cleanup_on_start( + config: &DaemonConfig, + db: &LocalDb, + registry: &RepoRegistry, +) { + if resolve_lifecycle_schedule(config).is_none() { + return; + } + if let Err(error) = run_lifecycle_cleanup_once(config, db, registry) { + warn!("Lifecycle startup cleanup failed: {error}"); + } +} + +fn resolve_repo_root_from_working_directory(cwd: Option<&str>) -> Option { + cwd.and_then(crate::config::find_repo_root) +} + +fn collect_lifecycle_repo_roots(db: &LocalDb, registry: &RepoRegistry) -> Result> { + let mut deduped: HashSet = registry.repo_roots().into_iter().collect(); + + let filter = opensession_local_db::LocalSessionFilter { + limit: None, + offset: None, + ..Default::default() + }; + let rows = db.list_sessions(&filter)?; + for row in rows { + if let Some(repo_root) = + resolve_repo_root_from_working_directory(row.working_directory.as_deref()) + { + deduped.insert(repo_root); + } + } + + let mut roots = deduped.into_iter().collect::>(); + roots.sort(); + Ok(roots) +} + +fn source_parent_directory_missing(source_path: &str) -> bool { + let path = Path::new(source_path); + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .is_some_and(|parent| !parent.exists()) +} + +fn list_sessions_with_missing_source_parent_dirs(db: &LocalDb) -> Result> { + let mut orphaned = db + .list_session_source_paths()? + .into_iter() + .filter_map(|(session_id, source_path)| { + if source_parent_directory_missing(&source_path) { + Some(session_id) + } else { + None + } + }) + .collect::>(); + orphaned.sort(); + orphaned.dedup(); + Ok(orphaned) +} + +pub(super) fn run_lifecycle_cleanup_once( + config: &DaemonConfig, + db: &LocalDb, + registry: &RepoRegistry, +) -> Result<()> { + if !config.lifecycle.enabled { + return Ok(()); + } + + let storage = opensession_git_native::NativeGitStorage; + let expired_sessions = db.list_expired_session_ids(config.lifecycle.session_ttl_days)?; + let orphaned_sessions = list_sessions_with_missing_source_parent_dirs(db)?; + let mut sessions_to_delete = expired_sessions + .into_iter() + .collect::>(); + sessions_to_delete.extend(orphaned_sessions); + let total_sessions_to_delete = sessions_to_delete.len(); + let mut deleted_sessions = 0usize; + + for session_id in sessions_to_delete { + let row = db.get_session_by_id(&session_id)?; + let repo_root = resolve_repo_root_from_working_directory( + row.as_ref() + .and_then(|row| row.working_directory.as_deref()), + ); + if let Some(repo_root) = repo_root { + let delete_result = + storage.delete_summary_at_ref(&repo_root, SUMMARY_LEDGER_REF, &session_id); + if let Err(error) = delete_result { + warn!( + repo = %repo_root.display(), + session_id, + "Lifecycle cleanup: failed to delete hidden-ref summary for expired session: {error}" + ); + } + } + db.delete_session(&session_id)?; + deleted_sessions = deleted_sessions.saturating_add(1); + } + + let deleted_local_summaries = + db.delete_expired_session_summaries(config.lifecycle.summary_ttl_days)? as usize; + + let repo_roots = collect_lifecycle_repo_roots(db, registry)?; + for repo_root in repo_roots { + let prune_result = storage.prune_summaries_by_age_at_ref( + &repo_root, + SUMMARY_LEDGER_REF, + config.lifecycle.summary_ttl_days, + ); + match prune_result { + Ok(PruneStats { + scanned_sessions, + expired_sessions, + rewritten, + }) => { + if rewritten { + info!( + repo = %repo_root.display(), + scanned_sessions, + expired_sessions, + keep_days = config.lifecycle.summary_ttl_days, + "Lifecycle cleanup: pruned hidden-ref summaries" + ); + } else { + debug!( + repo = %repo_root.display(), + scanned_sessions, + keep_days = config.lifecycle.summary_ttl_days, + "Lifecycle cleanup: no hidden-ref summary pruning required" + ); + } + } + Err(error) => { + warn!( + repo = %repo_root.display(), + "Lifecycle cleanup: hidden-ref summary pruning failed: {error}" + ); + } + } + } + + if config.git_storage.method != GitStorageMethod::Sqlite { + run_git_retention_once(registry, config.lifecycle.session_ttl_days)?; + } + + info!( + deleted_sessions, + total_sessions_to_delete, + deleted_local_summaries, + session_ttl_days = config.lifecycle.session_ttl_days, + summary_ttl_days = config.lifecycle.summary_ttl_days, + "Lifecycle cleanup: cycle complete" + ); + Ok(()) +} diff --git a/crates/daemon/src/scheduler/pipeline.rs b/crates/daemon/src/scheduler/pipeline.rs new file mode 100644 index 00000000..d4909dfb --- /dev/null +++ b/crates/daemon/src/scheduler/pipeline.rs @@ -0,0 +1,359 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use opensession_core::Session; +use opensession_core::session::{GitMeta, interaction_compressed_session, is_auxiliary_session}; +use opensession_git_native::{ + SUMMARY_LEDGER_REF, SessionSummaryLedgerRecord, branch_ledger_ref, extract_git_context, + resolve_ledger_branch, +}; +use opensession_local_db::LocalDb; +use opensession_parsers::ParserRegistry; +use opensession_runtime_config::SummaryStorageBackend; +use opensession_summary::GitSummaryRequest; +use opensession_summary_runtime::summarize_session; +use std::path::{Path, PathBuf}; +use tracing::{debug, info, warn}; + +use crate::config::{DaemonConfig, GitStorageMethod, SessionDefaultView}; +use crate::repo_registry::RepoRegistry; + +use super::config_resolution::resolve_effective_config; +use super::git_retention::collect_commit_shas_for_session; +use super::helpers::{ + build_session_meta_json, enum_label, sanitize, session_cwd, session_to_hail_jsonl_bytes, +}; + +pub(super) async fn process_file( + path: &PathBuf, + config: &DaemonConfig, + db: &LocalDb, + repo_registry: &mut RepoRegistry, + auto_upload: bool, +) -> Result<()> { + if was_already_uploaded(path, db)? { + return Ok(()); + } + + let mut session = match parse_session(path)? { + Some(session) => session, + None => return Ok(()), + }; + + let effective_config = resolve_effective_config(&session, config); + + if is_tool_excluded(&session, &effective_config) { + return Ok(()); + } + + store_locally(&session, path, db, &effective_config)?; + if let Err(error) = maybe_generate_semantic_summary(&session, db, &effective_config).await { + warn!( + session_id = %session.session_id, + "semantic summary generation skipped/failed: {error}" + ); + } + + if !auto_upload { + return Ok(()); + } + + sanitize(&mut session, &effective_config); + + let git_store = maybe_git_store(&session, &effective_config); + if let Some(ref stored) = git_store { + if let Err(error) = repo_registry.add(&stored.repo_root) { + warn!( + repo = %stored.repo_root.display(), + "failed to update repo registry: {error}" + ); + } + } + + mark_session_share_ready( + &session, + db, + git_store + .as_ref() + .and_then(|stored| stored.body_url.as_deref()), + ) +} + +pub(super) fn was_already_uploaded(path: &PathBuf, db: &LocalDb) -> Result { + let modified: DateTime = std::fs::metadata(path)?.modified()?.into(); + let path_str = path.to_string_lossy().to_string(); + if db.was_uploaded_after(&path_str, &modified)? { + debug!("Skipping already-uploaded file: {}", path.display()); + return Ok(true); + } + Ok(false) +} + +pub(super) fn parse_session(path: &Path) -> Result> { + let session = match ParserRegistry::default().parse_path(path)? { + Some(session) => session, + None => { + warn!("No parser for: {}", path.display()); + return Ok(None); + } + }; + if is_auxiliary_session(&session) { + debug!("Skipping auxiliary session from {}", path.display()); + return Ok(None); + } + + info!("Parsing: {}", path.display()); + Ok(Some(session)) +} + +pub(super) fn is_tool_excluded(session: &Session, config: &DaemonConfig) -> bool { + let excluded = config + .privacy + .exclude_tools + .iter() + .any(|tool| tool.eq_ignore_ascii_case(&session.agent.tool)); + + if excluded { + info!( + "Excluding tool '{}': source file excluded by config", + session.agent.tool, + ); + } + excluded +} + +pub(super) fn store_locally( + session: &Session, + path: &Path, + db: &LocalDb, + config: &DaemonConfig, +) -> Result<()> { + let path_str = path.to_string_lossy().to_string(); + let local_session = if matches!( + config.daemon.session_default_view, + SessionDefaultView::Compressed + ) { + interaction_compressed_session(session) + } else { + session.clone() + }; + + let git = session_cwd(&local_session) + .map(extract_git_context) + .unwrap_or_default(); + let local_git = opensession_local_db::git::GitContext { + remote: git.remote.clone(), + branch: git.branch.clone(), + commit: git.commit.clone(), + repo_name: git.repo_name.clone(), + }; + + db.upsert_local_session(&local_session, &path_str, &local_git)?; + match std::fs::read(path) { + Ok(body) => { + if let Err(error) = db.cache_body(&session.session_id, &body) { + warn!( + "Failed to cache source body for session {}: {}", + session.session_id, error + ); + } + } + Err(error) => { + warn!( + "Failed to read source file for session {} while caching body: {}", + session.session_id, error + ); + } + } + Ok(()) +} + +pub(super) async fn maybe_generate_semantic_summary( + session: &Session, + db: &LocalDb, + config: &DaemonConfig, +) -> Result<()> { + let settings = &config.summary; + if !settings.should_generate_on_session_save() { + return Ok(()); + } + if settings.storage.backend == SummaryStorageBackend::None { + return Ok(()); + } + if !settings.is_configured() { + return Ok(()); + } + + let git_request = if settings.allows_git_changes_fallback() { + session_cwd(session).and_then(|cwd| { + crate::config::find_repo_root(cwd).map(|repo_root| GitSummaryRequest { + repo_root, + commit: extract_git_context(cwd).commit, + }) + }) + } else { + None + }; + + let artifact = summarize_session(session, settings, git_request.as_ref()) + .await + .map_err(anyhow::Error::msg)?; + + match settings.storage.backend { + SummaryStorageBackend::LocalDb => { + let summary_json = serde_json::to_string(&artifact.summary)?; + let source_details_json = if artifact.source_details.is_empty() { + None + } else { + Some(serde_json::to_string(&artifact.source_details)?) + }; + let diff_tree_json = if artifact.diff_tree.is_empty() { + None + } else { + Some(serde_json::to_string(&artifact.diff_tree)?) + }; + let generated_at = chrono::Utc::now().to_rfc3339(); + let provider = enum_label(&artifact.provider); + let source_kind = enum_label(&artifact.source_kind); + let generation_kind = enum_label(&artifact.generation_kind); + let model = if artifact.model.trim().is_empty() { + None + } else { + Some(artifact.model.clone()) + }; + let prompt_fingerprint = if artifact.prompt_fingerprint.trim().is_empty() { + None + } else { + Some(artifact.prompt_fingerprint) + }; + + db.upsert_session_semantic_summary( + &opensession_local_db::SessionSemanticSummaryUpsert { + session_id: &session.session_id, + summary_json: &summary_json, + generated_at: &generated_at, + provider: &provider, + model: model.as_deref(), + source_kind: &source_kind, + generation_kind: &generation_kind, + prompt_fingerprint: prompt_fingerprint.as_deref(), + source_details_json: source_details_json.as_deref(), + diff_tree_json: diff_tree_json.as_deref(), + error: artifact.error.as_deref(), + }, + )?; + } + SummaryStorageBackend::HiddenRef => { + let cwd = session_cwd(session) + .ok_or_else(|| anyhow::anyhow!("session working directory is missing"))?; + let repo_root = crate::config::find_repo_root(cwd) + .ok_or_else(|| anyhow::anyhow!("failed to resolve git repo root"))?; + let summary_value = serde_json::to_value(&artifact.summary)?; + let source_details = serde_json::to_value(&artifact.source_details)?; + let diff_tree_value = serde_json::to_value(&artifact.diff_tree)?; + let diff_tree = diff_tree_value.as_array().cloned().unwrap_or_default(); + let record = SessionSummaryLedgerRecord { + session_id: session.session_id.clone(), + generated_at: chrono::Utc::now().to_rfc3339(), + provider: enum_label(&artifact.provider), + model: (!artifact.model.trim().is_empty()).then_some(artifact.model.clone()), + source_kind: enum_label(&artifact.source_kind), + generation_kind: enum_label(&artifact.generation_kind), + prompt_fingerprint: (!artifact.prompt_fingerprint.trim().is_empty()) + .then_some(artifact.prompt_fingerprint), + summary: summary_value, + source_details, + diff_tree, + error: artifact.error.clone(), + }; + opensession_git_native::NativeGitStorage + .store_summary_at_ref(&repo_root, SUMMARY_LEDGER_REF, &record) + .map_err(anyhow::Error::msg)?; + } + SummaryStorageBackend::None => {} + } + + Ok(()) +} + +pub(super) struct GitStoreOutcome { + pub(super) body_url: Option, + pub(super) repo_root: PathBuf, +} + +pub(super) fn maybe_git_store(session: &Session, config: &DaemonConfig) -> Option { + if config.git_storage.method == GitStorageMethod::Sqlite { + return None; + } + + let cwd = session_cwd(session)?; + let repo_root = crate::config::find_repo_root(cwd)?; + let git_ctx = extract_git_context(cwd); + let branch = resolve_ledger_branch(git_ctx.branch.as_deref(), git_ctx.commit.as_deref()); + let ref_name = branch_ledger_ref(&branch); + let commit_shas = collect_commit_shas_for_session(&repo_root, session); + + let hail_jsonl = session_to_hail_jsonl_bytes(session)?; + let git_meta = GitMeta { + remote: git_ctx.remote.clone(), + repo_name: git_ctx.repo_name.clone(), + branch: Some(branch), + head: git_ctx.commit.clone(), + commits: commit_shas.clone(), + }; + let meta_json = build_session_meta_json(session, Some(&git_meta)); + + let storage = opensession_git_native::NativeGitStorage; + match storage.store_session_at_ref( + &repo_root, + &ref_name, + &session.session_id, + &hail_jsonl, + &meta_json, + &commit_shas, + ) { + Ok(stored) => { + info!( + "Stored session {} to git ref {} at {}", + session.session_id, stored.ref_name, stored.hail_path + ); + let body_url = git_ctx.remote.as_ref().map(|remote| { + opensession_git_native::generate_raw_url( + remote, + &stored.commit_id, + &stored.hail_path, + ) + }); + Some(GitStoreOutcome { + body_url, + repo_root, + }) + } + Err(error) => { + warn!( + "Git-native store failed for session {}: {}", + session.session_id, error + ); + None + } + } +} + +pub(super) fn mark_session_share_ready( + session: &Session, + db: &LocalDb, + body_url: Option<&str>, +) -> Result<()> { + if let Some(url) = body_url { + info!( + "Session {} stored in git-native ledger and share-ready ({})", + session.session_id, url + ); + } else { + info!( + "Session {} indexed locally. Share with CLI quick flow: opensession share os://src/local/ --quick", + session.session_id + ); + } + db.mark_synced(&session.session_id)?; + Ok(()) +} diff --git a/crates/daemon/src/scheduler/runtime.rs b/crates/daemon/src/scheduler/runtime.rs new file mode 100644 index 00000000..bae6413e --- /dev/null +++ b/crates/daemon/src/scheduler/runtime.rs @@ -0,0 +1,134 @@ +use opensession_local_db::LocalDb; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tracing::{debug, error, info, warn}; + +use crate::config::{DaemonConfig, PublishMode}; +use crate::repo_registry::RepoRegistry; +use crate::watcher::FileChangeEvent; + +use super::config_resolution::{ + resolve_git_retention_schedule, resolve_lifecycle_schedule, resolve_publish_mode, + should_auto_upload, +}; +use super::git_retention::run_git_retention_once; +use super::lifecycle::{run_lifecycle_cleanup_on_start, run_lifecycle_cleanup_once}; +use super::pipeline::process_file; + +pub async fn run_scheduler( + config: DaemonConfig, + mut rx: mpsc::UnboundedReceiver, + mut shutdown: tokio::sync::watch::Receiver, + db: std::sync::Arc, +) { + let debounce_duration = Duration::from_secs(config.daemon.debounce_secs); + + let effective_mode = resolve_publish_mode(&config.daemon); + let mut repo_registry = match RepoRegistry::load_default() { + Ok(registry) => registry, + Err(error) => { + warn!("failed to load repo registry: {error}"); + RepoRegistry::default() + } + }; + + run_lifecycle_cleanup_on_start(&config, &db, &repo_registry); + + let mut pending: HashMap = HashMap::new(); + + let mut tick = tokio::time::interval(Duration::from_secs(1)); + let retention_schedule = resolve_git_retention_schedule(&config); + let mut next_retention_run = retention_schedule.map(|(_, interval)| Instant::now() + interval); + let lifecycle_interval = resolve_lifecycle_schedule(&config); + let mut next_lifecycle_run = lifecycle_interval.map(|interval| Instant::now() + interval); + + loop { + tokio::select! { + Some(event) = rx.recv() => { + debug!("Scheduling: {:?}", event.path.display()); + pending.insert(event.path, Instant::now()); + } + _ = tick.tick() => { + let now = Instant::now(); + let effective_debounce = match effective_mode { + PublishMode::Realtime => Duration::from_millis(config.daemon.realtime_debounce_ms), + _ => debounce_duration, + }; + + let ready: Vec = pending + .iter() + .filter(|(_, last_change)| now.duration_since(**last_change) >= effective_debounce) + .map(|(path, _)| path.clone()) + .collect(); + + for path in ready { + pending.remove(&path); + if matches!(effective_mode, PublishMode::Manual) { + debug!( + "Manual mode, indexing locally without auto-publish: {}", + path.display() + ); + } + if let Err(error) = process_file( + &path, + &config, + &db, + &mut repo_registry, + should_auto_upload(&effective_mode), + ) + .await + { + error!("Failed to process {}: {:#}", path.display(), error); + } + } + + maybe_run_retention_cycle(now, retention_schedule, &mut next_retention_run, &repo_registry); + maybe_run_lifecycle_cycle(now, lifecycle_interval, &mut next_lifecycle_run, &config, &db, &repo_registry); + } + _ = shutdown.changed() => { + if *shutdown.borrow() { + info!("Scheduler shutting down"); + break; + } + } + } + } +} + +fn maybe_run_retention_cycle( + now: Instant, + retention_schedule: Option<(u32, Duration)>, + next_retention_run: &mut Option, + repo_registry: &RepoRegistry, +) { + if let (Some((keep_days, interval)), Some(next_at)) = (retention_schedule, *next_retention_run) + { + if now >= next_at { + if let Err(error) = run_git_retention_once(repo_registry, keep_days) { + warn!("Git retention scan failed: {error}"); + } + *next_retention_run = Some(now + interval); + } + } +} + +fn maybe_run_lifecycle_cycle( + now: Instant, + lifecycle_interval: Option, + next_lifecycle_run: &mut Option, + config: &DaemonConfig, + db: &LocalDb, + repo_registry: &RepoRegistry, +) { + if let (Some(interval), Some(next_at)) = (lifecycle_interval, *next_lifecycle_run) { + if now >= next_at { + if let Err(error) = run_lifecycle_cleanup_once(config, db, repo_registry) { + warn!("Lifecycle cleanup failed: {error}"); + } + *next_lifecycle_run = Some(now + interval); + } + } +} diff --git a/crates/daemon/src/scheduler/tests.rs b/crates/daemon/src/scheduler/tests.rs new file mode 100644 index 00000000..76542cf8 --- /dev/null +++ b/crates/daemon/src/scheduler/tests.rs @@ -0,0 +1,712 @@ +use super::config_resolution::{ + resolve_git_retention_schedule, resolve_lifecycle_schedule, resolve_publish_mode, + should_auto_upload, +}; +use super::helpers::{build_session_meta_json, session_cwd, session_to_hail_jsonl_bytes}; +use super::lifecycle::{run_lifecycle_cleanup_on_start, run_lifecycle_cleanup_once}; +use super::pipeline::{maybe_generate_semantic_summary, store_locally}; +use crate::config::{ + DaemonConfig, DaemonSettings, GitStorageMethod, PublishMode, SessionDefaultView, +}; +use crate::repo_registry::RepoRegistry; +use chrono::Utc; +use opensession_core::{Agent, Content, Event, EventType, Session}; +use opensession_git_native::{NativeGitStorage, SUMMARY_LEDGER_REF, SessionSummaryLedgerRecord}; +use opensession_local_db::LocalDb; +use opensession_runtime_config::{SummaryProvider, SummaryStorageBackend, SummaryTriggerMode}; +use serde_json::json; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; +use tempfile::tempdir; + +fn make_session_with_attrs(attrs: HashMap) -> Session { + let mut session = Session::new( + "test-session-id".into(), + Agent { + provider: "anthropic".into(), + model: "claude-opus-4-6".into(), + tool: "claude-code".into(), + tool_version: None, + }, + ); + session.context.attributes = attrs; + session +} + +fn make_interaction_fixture_session(session_id: &str) -> Session { + let mut session = Session::new( + session_id.to_string(), + Agent { + provider: "anthropic".into(), + model: "claude-opus-4-6".into(), + tool: "claude-code".into(), + tool_version: None, + }, + ); + session.events = vec![ + Event { + event_id: format!("{session_id}-user"), + timestamp: Utc::now(), + event_type: EventType::UserMessage, + task_id: None, + content: Content::text("hello"), + duration_ms: None, + attributes: HashMap::new(), + }, + Event { + event_id: format!("{session_id}-tool"), + timestamp: Utc::now(), + event_type: EventType::ToolCall { + name: "write_file".to_string(), + }, + task_id: None, + content: Content::text(""), + duration_ms: None, + attributes: HashMap::new(), + }, + ]; + session.recompute_stats(); + session +} + +fn init_git_repo(path: &Path) { + let status = Command::new("git") + .arg("init") + .current_dir(path) + .status() + .expect("git init should run"); + assert!(status.success(), "git init should succeed"); + + let status = Command::new("git") + .args(["config", "user.email", "test@opensession.local"]) + .current_dir(path) + .status() + .expect("git config user.email should run"); + assert!(status.success(), "git config user.email should succeed"); + + let status = Command::new("git") + .args(["config", "user.name", "OpenSession Tests"]) + .current_dir(path) + .status() + .expect("git config user.name should run"); + assert!(status.success(), "git config user.name should succeed"); +} + +#[test] +fn test_session_cwd_from_cwd_key() { + let mut attrs = HashMap::new(); + attrs.insert("cwd".into(), json!("/home/user/project")); + let session = make_session_with_attrs(attrs); + assert_eq!(session_cwd(&session), Some("/home/user/project")); +} + +#[test] +fn test_session_cwd_from_working_directory() { + let mut attrs = HashMap::new(); + attrs.insert("working_directory".into(), json!("/tmp/work")); + let session = make_session_with_attrs(attrs); + assert_eq!(session_cwd(&session), Some("/tmp/work")); +} + +#[test] +fn test_session_cwd_prefers_cwd_over_working_directory() { + let mut attrs = HashMap::new(); + attrs.insert("cwd".into(), json!("/preferred")); + attrs.insert("working_directory".into(), json!("/fallback")); + let session = make_session_with_attrs(attrs); + assert_eq!(session_cwd(&session), Some("/preferred")); +} + +#[test] +fn test_session_cwd_missing() { + let session = make_session_with_attrs(HashMap::new()); + assert_eq!(session_cwd(&session), None); +} + +#[test] +fn test_session_cwd_non_string_value_returns_none() { + let mut attrs = HashMap::new(); + attrs.insert("cwd".into(), json!(42)); + let session = make_session_with_attrs(attrs); + assert_eq!(session_cwd(&session), None); +} + +#[test] +fn test_build_session_meta_json_with_title() { + let mut session = make_session_with_attrs(HashMap::new()); + session.context.title = Some("My Session Title".into()); + + let bytes = build_session_meta_json(&session, None); + let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(parsed["session_id"], "test-session-id"); + assert_eq!(parsed["schema_version"], 2); + assert_eq!(parsed["title"], "My Session Title"); + assert_eq!(parsed["tool"], "claude-code"); + assert_eq!(parsed["model"], "claude-opus-4-6"); + assert!(parsed["stats"].is_object()); +} + +#[test] +fn test_build_session_meta_json_no_title() { + let session = make_session_with_attrs(HashMap::new()); + + let bytes = build_session_meta_json(&session, None); + let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(parsed["session_id"], "test-session-id"); + assert!(parsed["title"].is_null()); + assert_eq!(parsed["tool"], "claude-code"); + assert_eq!(parsed["model"], "claude-opus-4-6"); +} + +#[test] +fn test_build_session_meta_json_includes_git_block() { + let session = make_session_with_attrs(HashMap::new()); + let git = opensession_core::session::GitMeta { + remote: Some("git@github.com:org/repo.git".to_string()), + repo_name: Some("org/repo".to_string()), + branch: Some("feature/x".to_string()), + head: Some("abcd1234".to_string()), + commits: vec!["abcd1234".to_string()], + }; + + let bytes = build_session_meta_json(&session, Some(&git)); + let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(parsed["schema_version"], 2); + assert_eq!(parsed["git"]["repo_name"], "org/repo"); + assert_eq!(parsed["git"]["head"], "abcd1234"); +} + +#[test] +fn test_session_to_hail_jsonl_bytes_uses_header_event_stats_lines() { + let mut session = make_session_with_attrs(HashMap::new()); + session.events.push(opensession_core::Event { + event_id: "e1".into(), + timestamp: Utc::now(), + event_type: opensession_core::EventType::UserMessage, + task_id: None, + content: opensession_core::Content::text("hello"), + duration_ms: None, + attributes: HashMap::new(), + }); + session.recompute_stats(); + + let body = session_to_hail_jsonl_bytes(&session).expect("serialize HAIL JSONL"); + let text = String::from_utf8(body).expect("jsonl must be utf-8"); + let lines: Vec<&str> = text.lines().filter(|line| !line.is_empty()).collect(); + assert_eq!(lines.len(), 3, "expected header/event/stats lines"); + + let header: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(header["type"], "header"); + let event: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(event["type"], "event"); + let stats: serde_json::Value = serde_json::from_str(lines[2]).unwrap(); + assert_eq!(stats["type"], "stats"); +} + +#[test] +fn test_resolve_publish_mode_auto_publish_true() { + let settings = DaemonSettings { + auto_publish: true, + publish_on: PublishMode::SessionEnd, + ..Default::default() + }; + assert_eq!(resolve_publish_mode(&settings), PublishMode::SessionEnd); +} + +#[test] +fn test_resolve_publish_mode_auto_publish_false_manual() { + let settings = DaemonSettings { + auto_publish: false, + publish_on: PublishMode::Manual, + ..Default::default() + }; + assert_eq!(resolve_publish_mode(&settings), PublishMode::Manual); +} + +#[test] +fn test_resolve_publish_mode_uses_publish_on_even_when_auto_publish_false() { + let settings = DaemonSettings { + auto_publish: false, + publish_on: PublishMode::Realtime, + ..Default::default() + }; + assert_eq!(resolve_publish_mode(&settings), PublishMode::Realtime); +} + +#[test] +fn test_should_auto_upload_is_false_for_manual_mode() { + assert!(!should_auto_upload(&PublishMode::Manual)); +} + +#[test] +fn test_should_auto_upload_is_true_for_session_end_and_realtime() { + assert!(should_auto_upload(&PublishMode::SessionEnd)); + assert!(should_auto_upload(&PublishMode::Realtime)); +} + +#[test] +fn test_resolve_git_retention_schedule_disabled_by_default() { + let config = DaemonConfig::default(); + assert!(resolve_git_retention_schedule(&config).is_none()); +} + +#[test] +fn test_resolve_git_retention_schedule_enabled_native_mode() { + let mut config = DaemonConfig::default(); + config.git_storage.method = GitStorageMethod::Native; + config.git_storage.retention.enabled = true; + config.git_storage.retention.keep_days = 14; + config.git_storage.retention.interval_secs = 120; + + let (keep_days, interval) = + resolve_git_retention_schedule(&config).expect("retention should be enabled"); + assert_eq!(keep_days, 14); + assert_eq!(interval, Duration::from_secs(120)); +} + +#[test] +fn test_resolve_git_retention_schedule_enforces_min_interval() { + let mut config = DaemonConfig::default(); + config.git_storage.method = GitStorageMethod::Native; + config.git_storage.retention.enabled = true; + config.git_storage.retention.interval_secs = 0; + + let (_, interval) = + resolve_git_retention_schedule(&config).expect("retention should be enabled"); + assert_eq!(interval, Duration::from_secs(60)); +} + +#[test] +fn test_resolve_lifecycle_schedule_honors_enabled_and_min_interval() { + let mut config = DaemonConfig::default(); + config.lifecycle.enabled = false; + assert!(resolve_lifecycle_schedule(&config).is_none()); + + config.lifecycle.enabled = true; + config.lifecycle.cleanup_interval_secs = 12; + assert_eq!( + resolve_lifecycle_schedule(&config), + Some(Duration::from_secs(60)) + ); + + config.lifecycle.cleanup_interval_secs = 120; + assert_eq!( + resolve_lifecycle_schedule(&config), + Some(Duration::from_secs(120)) + ); +} + +#[test] +fn test_run_lifecycle_cleanup_on_start_runs_immediately() { + let tmp = tempdir().expect("tempdir"); + let db_path = tmp.path().join("local.db"); + let db = LocalDb::open_path(&db_path).expect("open local db"); + + let mut expired = make_interaction_fixture_session("startup-expired-session"); + expired.context.created_at = Utc::now() - chrono::Duration::days(90); + expired.context.updated_at = expired.context.created_at; + db.upsert_local_session( + &expired, + "/tmp/startup-expired-session.jsonl", + &opensession_local_db::git::GitContext::default(), + ) + .expect("upsert expired session"); + + let mut config = DaemonConfig::default(); + config.lifecycle.enabled = true; + config.lifecycle.session_ttl_days = 30; + config.lifecycle.summary_ttl_days = 30; + config.lifecycle.cleanup_interval_secs = 3600; + + run_lifecycle_cleanup_on_start(&config, &db, &RepoRegistry::default()); + + assert!( + db.get_session_by_id("startup-expired-session") + .expect("query expired session") + .is_none(), + "expired session should be deleted during startup lifecycle cleanup" + ); +} + +#[test] +fn test_run_lifecycle_cleanup_deletes_expired_sessions_and_hidden_ref_summaries() { + let tmp = tempdir().expect("tempdir"); + let repo_root = tmp.path().join("repo"); + std::fs::create_dir_all(&repo_root).expect("create repo root"); + init_git_repo(&repo_root); + + let db_path = tmp.path().join("local.db"); + let db = LocalDb::open_path(&db_path).expect("open local db"); + let mut expired = make_interaction_fixture_session("expired-session"); + expired.context.created_at = Utc::now() - chrono::Duration::days(90); + expired.context.updated_at = expired.context.created_at; + expired.context.attributes.insert( + "working_directory".to_string(), + json!(repo_root.to_string_lossy().to_string()), + ); + db.upsert_local_session( + &expired, + "/tmp/expired-session.jsonl", + &opensession_local_db::git::GitContext::default(), + ) + .expect("upsert expired session"); + + let mut active = make_interaction_fixture_session("active-session"); + active.context.attributes.insert( + "working_directory".to_string(), + json!(repo_root.to_string_lossy().to_string()), + ); + db.upsert_local_session( + &active, + "/tmp/active-session.jsonl", + &opensession_local_db::git::GitContext::default(), + ) + .expect("upsert active session"); + + let storage = NativeGitStorage; + storage + .store_summary_at_ref( + &repo_root, + SUMMARY_LEDGER_REF, + &SessionSummaryLedgerRecord { + session_id: "expired-session".to_string(), + generated_at: "2026-01-01T00:00:00Z".to_string(), + provider: "codex_exec".to_string(), + model: None, + source_kind: "session_signals".to_string(), + generation_kind: "provider".to_string(), + prompt_fingerprint: None, + summary: json!({ "changes": "expired" }), + source_details: json!({}), + diff_tree: vec![], + error: None, + }, + ) + .expect("store expired summary"); + storage + .store_summary_at_ref( + &repo_root, + SUMMARY_LEDGER_REF, + &SessionSummaryLedgerRecord { + session_id: "active-session".to_string(), + generated_at: "2026-01-01T00:00:00Z".to_string(), + provider: "codex_exec".to_string(), + model: None, + source_kind: "session_signals".to_string(), + generation_kind: "provider".to_string(), + prompt_fingerprint: None, + summary: json!({ "changes": "active" }), + source_details: json!({}), + diff_tree: vec![], + error: None, + }, + ) + .expect("store active summary"); + + let mut registry = RepoRegistry::default(); + registry + .add(&repo_root) + .expect("repo registry should accept repo root"); + + let mut config = DaemonConfig::default(); + config.lifecycle.enabled = true; + config.lifecycle.session_ttl_days = 30; + config.lifecycle.summary_ttl_days = 10_000; + config.lifecycle.cleanup_interval_secs = 60; + + run_lifecycle_cleanup_once(&config, &db, ®istry).expect("run lifecycle cleanup"); + + assert!( + db.get_session_by_id("expired-session") + .expect("query expired session") + .is_none(), + "expired session should be deleted" + ); + assert!( + db.get_session_by_id("active-session") + .expect("query active session") + .is_some(), + "active session should remain" + ); + assert!( + storage + .load_summary_at_ref(&repo_root, SUMMARY_LEDGER_REF, "expired-session") + .expect("load expired summary") + .is_none(), + "hidden-ref summary for expired session should be deleted" + ); + assert!( + storage + .load_summary_at_ref(&repo_root, SUMMARY_LEDGER_REF, "active-session") + .expect("load active summary") + .is_some(), + "active session summary should remain" + ); +} + +#[test] +fn test_run_lifecycle_cleanup_prunes_local_summary_rows_by_ttl() { + let tmp = tempdir().expect("tempdir"); + let db_path = tmp.path().join("local.db"); + let db = LocalDb::open_path(&db_path).expect("open local db"); + + let session_old = make_interaction_fixture_session("summary-old"); + db.upsert_local_session( + &session_old, + "/tmp/summary-old.jsonl", + &opensession_local_db::git::GitContext::default(), + ) + .expect("upsert old summary session"); + let session_new = make_interaction_fixture_session("summary-new"); + db.upsert_local_session( + &session_new, + "/tmp/summary-new.jsonl", + &opensession_local_db::git::GitContext::default(), + ) + .expect("upsert new summary session"); + + db.upsert_session_semantic_summary(&opensession_local_db::SessionSemanticSummaryUpsert { + session_id: "summary-old", + summary_json: r#"{"changes":"old"}"#, + generated_at: "2020-01-01T00:00:00Z", + provider: "codex_exec", + model: None, + source_kind: "session_signals", + generation_kind: "provider", + prompt_fingerprint: None, + source_details_json: None, + diff_tree_json: None, + error: None, + }) + .expect("insert old summary"); + db.upsert_session_semantic_summary(&opensession_local_db::SessionSemanticSummaryUpsert { + session_id: "summary-new", + summary_json: r#"{"changes":"new"}"#, + generated_at: "2999-01-01T00:00:00Z", + provider: "codex_exec", + model: None, + source_kind: "session_signals", + generation_kind: "provider", + prompt_fingerprint: None, + source_details_json: None, + diff_tree_json: None, + error: None, + }) + .expect("insert new summary"); + + let mut config = DaemonConfig::default(); + config.lifecycle.enabled = true; + config.lifecycle.session_ttl_days = 10_000; + config.lifecycle.summary_ttl_days = 30; + config.lifecycle.cleanup_interval_secs = 60; + + run_lifecycle_cleanup_once(&config, &db, &RepoRegistry::default()) + .expect("run lifecycle cleanup"); + + assert!( + db.get_session_semantic_summary("summary-old") + .expect("query old summary") + .is_none(), + "old summary should be pruned" + ); + assert!( + db.get_session_semantic_summary("summary-new") + .expect("query new summary") + .is_some(), + "new summary should remain" + ); +} + +#[test] +fn test_run_lifecycle_cleanup_deletes_sessions_with_missing_source_parent_dir() { + let tmp = tempdir().expect("tempdir"); + let db_path = tmp.path().join("local.db"); + let db = LocalDb::open_path(&db_path).expect("open local db"); + + let missing_parent_root = tmp.path().join("deleted-source-root"); + std::fs::create_dir_all(&missing_parent_root).expect("create missing parent root"); + let missing_parent_source = missing_parent_root.join("missing-parent.jsonl"); + + let existing_parent_root = tmp.path().join("existing-source-root"); + std::fs::create_dir_all(&existing_parent_root).expect("create existing parent root"); + let existing_parent_source = existing_parent_root.join("missing-file.jsonl"); + + let missing_parent_session = make_interaction_fixture_session("missing-parent-session"); + db.upsert_local_session( + &missing_parent_session, + missing_parent_source + .to_str() + .expect("missing parent source path should be utf-8"), + &opensession_local_db::git::GitContext::default(), + ) + .expect("upsert missing-parent session"); + + let existing_parent_session = make_interaction_fixture_session("existing-parent-session"); + db.upsert_local_session( + &existing_parent_session, + existing_parent_source + .to_str() + .expect("existing parent source path should be utf-8"), + &opensession_local_db::git::GitContext::default(), + ) + .expect("upsert existing-parent session"); + + std::fs::remove_dir_all(&missing_parent_root).expect("remove missing parent root"); + + let mut config = DaemonConfig::default(); + config.lifecycle.enabled = true; + config.lifecycle.session_ttl_days = 10_000; + config.lifecycle.summary_ttl_days = 10_000; + config.lifecycle.cleanup_interval_secs = 60; + + run_lifecycle_cleanup_once(&config, &db, &RepoRegistry::default()) + .expect("run lifecycle cleanup"); + + assert!( + db.get_session_by_id("missing-parent-session") + .expect("query missing-parent session") + .is_none(), + "session should be deleted when source parent directory is gone" + ); + assert!( + db.get_session_by_id("existing-parent-session") + .expect("query existing-parent session") + .is_some(), + "session should remain when source parent directory still exists" + ); +} + +#[test] +fn test_store_locally_uses_compressed_session_only_when_default_view_is_compressed() { + let tmp = tempdir().expect("tempdir"); + let db_path = PathBuf::from(tmp.path()).join("local.db"); + let db = LocalDb::open_path(&db_path).expect("open local db"); + + let full_session = make_interaction_fixture_session("store-full"); + let mut full_config = DaemonConfig::default(); + full_config.daemon.session_default_view = SessionDefaultView::Full; + store_locally( + &full_session, + Path::new("/tmp/store-full.jsonl"), + &db, + &full_config, + ) + .expect("store full session"); + + let stored_full = db + .get_session_by_id("store-full") + .expect("query full") + .expect("full session exists"); + assert_eq!(stored_full.event_count, 2); + + let compressed_session = make_interaction_fixture_session("store-compressed"); + let mut compressed_config = DaemonConfig::default(); + compressed_config.daemon.session_default_view = SessionDefaultView::Compressed; + store_locally( + &compressed_session, + Path::new("/tmp/store-compressed.jsonl"), + &db, + &compressed_config, + ) + .expect("store compressed session"); + + let stored_compressed = db + .get_session_by_id("store-compressed") + .expect("query compressed") + .expect("compressed session exists"); + assert_eq!(stored_compressed.event_count, 1); +} + +#[test] +fn test_store_locally_caches_source_body() { + let tmp = tempdir().expect("tempdir"); + let db_path = PathBuf::from(tmp.path()).join("local.db"); + let db = LocalDb::open_path(&db_path).expect("open local db"); + let session = make_interaction_fixture_session("store-cache"); + let source_path = PathBuf::from(tmp.path()).join("store-cache.jsonl"); + let source_body = b"{\"source\":\"fixture\"}\n".to_vec(); + std::fs::write(&source_path, &source_body).expect("write source fixture"); + + let mut config = DaemonConfig::default(); + config.daemon.session_default_view = SessionDefaultView::Full; + store_locally(&session, &source_path, &db, &config).expect("store cached session"); + + let cached = db + .get_cached_body("store-cache") + .expect("query body cache") + .expect("cache row should exist"); + assert_eq!(cached, source_body); +} + +#[tokio::test] +async fn test_auto_summary_runs_on_session_save_and_persists_row() { + let tmp = tempdir().expect("tempdir"); + let db_path = PathBuf::from(tmp.path()).join("local.db"); + let db = LocalDb::open_path(&db_path).expect("open local db"); + + let session = make_interaction_fixture_session("summary-auto"); + let mut config = DaemonConfig::default(); + config.summary.provider.id = SummaryProvider::CodexExec; + config.summary.storage.trigger = SummaryTriggerMode::OnSessionSave; + config.summary.storage.backend = SummaryStorageBackend::LocalDb; + + maybe_generate_semantic_summary(&session, &db, &config) + .await + .expect("summary generation should not fail hard"); + + let row = db + .get_session_semantic_summary("summary-auto") + .expect("query summary") + .expect("summary row should exist"); + assert_eq!(row.provider, "codex_exec"); + assert_eq!(row.source_kind, "session_signals"); + assert!(!row.summary_json.trim().is_empty()); +} + +#[tokio::test] +async fn test_auto_summary_skips_when_trigger_mode_is_manual() { + let tmp = tempdir().expect("tempdir"); + let db_path = PathBuf::from(tmp.path()).join("local.db"); + let db = LocalDb::open_path(&db_path).expect("open local db"); + + let session = make_interaction_fixture_session("summary-manual"); + let mut config = DaemonConfig::default(); + config.summary.provider.id = SummaryProvider::CodexExec; + config.summary.storage.trigger = SummaryTriggerMode::Manual; + config.summary.storage.backend = SummaryStorageBackend::LocalDb; + + maybe_generate_semantic_summary(&session, &db, &config) + .await + .expect("manual trigger should no-op"); + + let row = db + .get_session_semantic_summary("summary-manual") + .expect("query summary"); + assert!(row.is_none()); +} + +#[tokio::test] +async fn test_auto_summary_skips_when_storage_backend_is_none() { + let tmp = tempdir().expect("tempdir"); + let db_path = PathBuf::from(tmp.path()).join("local.db"); + let db = LocalDb::open_path(&db_path).expect("open local db"); + + let session = make_interaction_fixture_session("summary-no-persist"); + let mut config = DaemonConfig::default(); + config.summary.provider.id = SummaryProvider::CodexExec; + config.summary.storage.trigger = SummaryTriggerMode::OnSessionSave; + config.summary.storage.backend = SummaryStorageBackend::None; + + maybe_generate_semantic_summary(&session, &db, &config) + .await + .expect("none persist should no-op"); + + let row = db + .get_session_semantic_summary("summary-no-persist") + .expect("query summary"); + assert!(row.is_none()); +} diff --git a/crates/daemon/src/watcher.rs b/crates/daemon/src/watcher.rs index d960fb93..5cb8e03e 100644 --- a/crates/daemon/src/watcher.rs +++ b/crates/daemon/src/watcher.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; -use opensession_parsers::discover; +use opensession_parser_discovery::discover_sessions; use std::collections::HashSet; use std::path::{Path, PathBuf}; use tokio::sync::mpsc; @@ -22,7 +22,7 @@ pub fn seed_existing_session_files( return 0; } - let discovered_paths = discover::discover_sessions() + let discovered_paths = discover_sessions() .into_iter() .flat_map(|location| location.paths); enqueue_discovered_paths(watch_roots, discovered_paths, tx) diff --git a/crates/e2e/Cargo.toml b/crates/e2e/Cargo.toml index 60168436..12fc1d3c 100644 --- a/crates/e2e/Cargo.toml +++ b/crates/e2e/Cargo.toml @@ -2,6 +2,7 @@ name = "opensession-e2e" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true description = "E2E test suite for OpenSession API" diff --git a/crates/e2e/src/runner.rs b/crates/e2e/src/runner.rs index 06b42ecf..c83a152e 100644 --- a/crates/e2e/src/runner.rs +++ b/crates/e2e/src/runner.rs @@ -61,7 +61,11 @@ pub async fn run_all(ctx: Arc, filter: Option<&str>) crate::for_each_spec!(spawn_spec); let mut results = Vec::new(); - while let Some(result) = set.join_next().await { + loop { + let next_result = set.join_next().await; + let Some(result) = next_result else { + break; + }; match result { Ok(r) => results.push(r), Err(e) => results.push(TestResult { @@ -70,7 +74,7 @@ pub async fn run_all(ctx: Arc, filter: Option<&str>) duration: Duration::ZERO, error: Some(format!("{e:#}")), }), - } + }; } results.sort_by(|a, b| a.name.cmp(&b.name)); diff --git a/crates/e2e/src/specs/health.rs b/crates/e2e/src/specs/health.rs index 36563c1c..c0774455 100644 --- a/crates/e2e/src/specs/health.rs +++ b/crates/e2e/src/specs/health.rs @@ -1,4 +1,4 @@ -use anyhow::{ensure, Result}; +use anyhow::{Result, ensure}; use crate::client::TestContext; diff --git a/crates/e2e/src/specs/sessions.rs b/crates/e2e/src/specs/sessions.rs index dc51e152..c0288c9d 100644 --- a/crates/e2e/src/specs/sessions.rs +++ b/crates/e2e/src/specs/sessions.rs @@ -1,4 +1,4 @@ -use anyhow::{ensure, Result}; +use anyhow::{Result, ensure}; use uuid::Uuid; use crate::client::TestContext; diff --git a/crates/e2e/tests/common.rs b/crates/e2e/tests/common.rs index ee571766..609c3a35 100644 --- a/crates/e2e/tests/common.rs +++ b/crates/e2e/tests/common.rs @@ -12,16 +12,22 @@ pub struct RegisteredUser { pub tokens: AuthTokenResponse, } -pub fn test_context_from_env(base_url_env: &str) -> TestContext { - let base_url = std::env::var(base_url_env).unwrap_or_else(|_| { - panic!( - "missing required env var `{base_url_env}`. Set explicit local target URL for this E2E run." - ) - }); +pub fn test_context_from_env(base_url_env: &str) -> Option { + let base_url = match std::env::var(base_url_env) { + Ok(value) => value, + Err(_) => { + eprintln!( + "skipping E2E test: missing required env var `{base_url_env}`. \ +Set explicit local target URL for this E2E run." + ); + return None; + } + }; enforce_base_url_policy(base_url_env, &base_url); - TestContext::new(base_url) + Some(TestContext::new(base_url)) } +#[allow(dead_code)] pub async fn register_user(ctx: &TestContext, prefix: &str, password: &str) -> RegisteredUser { let client = reqwest::Client::new(); let suffix = uuid::Uuid::new_v4().simple().to_string(); diff --git a/crates/e2e/tests/environment.rs b/crates/e2e/tests/environment.rs new file mode 100644 index 00000000..22386fa6 --- /dev/null +++ b/crates/e2e/tests/environment.rs @@ -0,0 +1,8 @@ +mod common; + +#[test] +fn missing_base_url_env_skips_instead_of_panicking() { + let missing_env = format!("OPENSESSION_TEST_MISSING_{}", uuid::Uuid::new_v4().simple()); + + assert!(common::test_context_from_env(&missing_env).is_none()); +} diff --git a/crates/e2e/tests/server.rs b/crates/e2e/tests/server.rs index 8f3c36e3..992a6626 100644 --- a/crates/e2e/tests/server.rs +++ b/crates/e2e/tests/server.rs @@ -5,7 +5,7 @@ use opensession_api::{ChangePasswordRequest, CreateGitCredentialRequest, OkRespo use opensession_e2e::client::TestContext; use serde_json::json; -fn get_ctx() -> TestContext { +fn get_ctx() -> Option { test_context_from_env("OPENSESSION_E2E_SERVER_BASE_URL") } @@ -13,7 +13,9 @@ macro_rules! e2e_test { ($module:ident :: $name:ident) => { #[tokio::test] async fn $name() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; opensession_e2e::specs::$module::$name(&ctx).await.unwrap(); } }; @@ -23,7 +25,9 @@ opensession_e2e::for_each_spec!(e2e_test); #[tokio::test] async fn server_sessions_repos_list() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let response = ctx.get("/sessions/repos").await.expect("request failed"); assert_eq!( response.status().as_u16(), @@ -42,7 +46,9 @@ async fn server_sessions_repos_list() { #[tokio::test] async fn server_auth_password_change_success() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let user = register_user(&ctx, "server-password", "old-pass-123").await; let client = reqwest::Client::new(); @@ -86,7 +92,9 @@ async fn server_auth_password_change_success() { #[tokio::test] async fn server_auth_git_credentials_crud() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let user = register_user(&ctx, "server-git-cred", "test-pass-123").await; let client = reqwest::Client::new(); @@ -179,7 +187,9 @@ async fn server_auth_git_credentials_crud() { #[tokio::test] async fn server_admin_delete_session_authz() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let response = reqwest::Client::new() .delete(ctx.url(&format!( "/admin/sessions/{}", @@ -198,7 +208,9 @@ async fn server_admin_delete_session_authz() { #[tokio::test] async fn removed_team_and_sync_endpoints_are_unavailable() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let client = reqwest::Client::new(); for path in ["/teams", "/invitations", "/sync/pull"] { let response = client diff --git a/crates/e2e/tests/worker.rs b/crates/e2e/tests/worker.rs index 3dfb69dd..e8eefb16 100644 --- a/crates/e2e/tests/worker.rs +++ b/crates/e2e/tests/worker.rs @@ -6,7 +6,7 @@ use opensession_api::{ParsePreviewRequest, ParseSource}; use opensession_e2e::client::TestContext; use serde_json::json; -fn get_ctx() -> TestContext { +fn get_ctx() -> Option { test_context_from_env("OPENSESSION_E2E_WORKER_BASE_URL") } @@ -14,7 +14,9 @@ macro_rules! e2e_test { ($module:ident :: $name:ident) => { #[tokio::test] async fn $name() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; opensession_e2e::specs::$module::$name(&ctx).await.unwrap(); } }; @@ -24,7 +26,9 @@ opensession_e2e::for_each_spec!(e2e_test); #[tokio::test] async fn auth_providers_endpoint_is_available_in_worker() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let response = ctx.get("/auth/providers").await.expect("request failed"); assert_eq!( response.status().as_u16(), @@ -42,7 +46,9 @@ async fn auth_providers_endpoint_is_available_in_worker() { #[tokio::test] async fn worker_auth_register_login_me_refresh_logout_flow() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let user = register_user(&ctx, "worker-auth-flow", "testpass-12345").await; let access_token = user.tokens.access_token; let refresh_token = user.tokens.refresh_token; @@ -126,7 +132,9 @@ async fn worker_auth_register_login_me_refresh_logout_flow() { #[tokio::test] async fn worker_auth_oauth_redirect_callback_routes_are_exposed() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let providers_response = ctx.get("/auth/providers").await.expect("request failed"); assert_eq!(providers_response.status().as_u16(), 200); let providers: serde_json::Value = providers_response @@ -188,7 +196,9 @@ async fn worker_auth_oauth_redirect_callback_routes_are_exposed() { #[tokio::test] async fn worker_parse_preview_inline_success() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let session = opensession_e2e::fixtures::minimal_session(); let source_body = session.to_jsonl().expect("serialize fixture session"); let encoded = base64::engine::general_purpose::STANDARD.encode(source_body); @@ -230,7 +240,9 @@ async fn worker_parse_preview_inline_success() { #[tokio::test] async fn worker_parse_preview_git_credential_required() { - let ctx = get_ctx(); + let Some(ctx) = get_ctx() else { + return; + }; let user = register_user(&ctx, "worker-git-credential", "testpass-12345").await; let access_token = user.tokens.access_token; diff --git a/crates/git-native/Cargo.toml b/crates/git-native/Cargo.toml index 1e25c1a8..bd91eb1c 100644 --- a/crates/git-native/Cargo.toml +++ b/crates/git-native/Cargo.toml @@ -2,6 +2,7 @@ name = "opensession-git-native" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true description = "Git-native session storage for OpenSession using gix" diff --git a/crates/git-native/src/handoff_artifact_store.rs b/crates/git-native/src/handoff_artifact_store.rs index 09b201a3..f850a705 100644 --- a/crates/git-native/src/handoff_artifact_store.rs +++ b/crates/git-native/src/handoff_artifact_store.rs @@ -1,12 +1,12 @@ use std::path::Path; use std::process::Command; -use gix::object::tree::EntryKind; use gix::ObjectId; +use gix::object::tree::EntryKind; +use crate::HANDOFF_ARTIFACTS_REF_PREFIX; use crate::error::{GitStorageError, Result}; use crate::ops::{self, gix_err}; -use crate::HANDOFF_ARTIFACTS_REF_PREFIX; const ARTIFACT_BLOB_PATH: &str = "artifact.json"; diff --git a/crates/git-native/src/lib.rs b/crates/git-native/src/lib.rs index f1f0e07e..e250ddfd 100644 --- a/crates/git-native/src/lib.rs +++ b/crates/git-native/src/lib.rs @@ -9,15 +9,15 @@ pub mod url; #[cfg(test)] pub(crate) mod test_utils; -pub use context::{extract_git_context, normalize_repo_name, GitContext}; +pub use context::{GitContext, extract_git_context, normalize_repo_name}; pub use error::{GitStorageError, Result}; pub use handoff_artifact_store::{ artifact_ref_name, list_handoff_artifact_refs, load_handoff_artifact, store_handoff_artifact, }; pub use refs::{branch_ledger_ref, encode_branch_component, resolve_ledger_branch}; pub use store::{ - store_blob_at_ref, NativeGitStorage, PruneStats, SessionSummaryLedgerRecord, - StoredSummaryRecord, + NativeGitStorage, PruneStats, SessionSummaryLedgerRecord, StoredSummaryRecord, + store_blob_at_ref, }; pub use url::generate_raw_url; diff --git a/crates/git-native/src/ops.rs b/crates/git-native/src/ops.rs index 5b697c02..750d21dd 100644 --- a/crates/git-native/src/ops.rs +++ b/crates/git-native/src/ops.rs @@ -321,9 +321,11 @@ mod tests { .unwrap(); // Confirm it exists - assert!(find_ref_tip(&repo, "refs/heads/to-delete") - .unwrap() - .is_some()); + assert!( + find_ref_tip(&repo, "refs/heads/to-delete") + .unwrap() + .is_some() + ); // Delete it delete_ref(&repo, "refs/heads/to-delete", commit_id).unwrap(); diff --git a/crates/git-native/src/refs.rs b/crates/git-native/src/refs.rs index 5400c83d..387271ff 100644 --- a/crates/git-native/src/refs.rs +++ b/crates/git-native/src/refs.rs @@ -1,5 +1,5 @@ -use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use crate::BRANCH_LEDGER_REF_PREFIX; diff --git a/crates/git-native/src/store.rs b/crates/git-native/src/store.rs index 45e3bb39..a5d1ba71 100644 --- a/crates/git-native/src/store.rs +++ b/crates/git-native/src/store.rs @@ -1,8 +1,8 @@ use std::path::Path; use std::process::Command; -use gix::object::tree::EntryKind; use gix::ObjectId; +use gix::object::tree::EntryKind; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{debug, info}; @@ -81,11 +81,7 @@ impl NativeGitStorage { fn session_id_from_commit_message(message: &str) -> Option<&str> { let first = message.lines().next()?.trim(); let id = first.strip_prefix("session: ")?.trim(); - if id.is_empty() { - None - } else { - Some(id) - } + if id.is_empty() { None } else { Some(id) } } fn commit_index_path(commit_sha: &str, session_id: &str) -> String { @@ -121,11 +117,7 @@ impl NativeGitStorage { fn summary_session_id_from_commit_message(message: &str) -> Option<&str> { let first = message.lines().next()?.trim(); let id = first.strip_prefix("summary: ")?.trim(); - if id.is_empty() { - None - } else { - Some(id) - } + if id.is_empty() { None } else { Some(id) } } } @@ -817,10 +809,12 @@ mod tests { .delete_summary_at_ref(tmp.path(), ref_name, "session-delete") .expect("delete summary"); assert!(rewritten); - assert!(storage - .load_summary_at_ref(tmp.path(), ref_name, "session-delete") - .expect("load after delete") - .is_none()); + assert!( + storage + .load_summary_at_ref(tmp.path(), ref_name, "session-delete") + .expect("load after delete") + .is_none() + ); } #[test] @@ -868,14 +862,18 @@ mod tests { .expect("prune summaries"); assert!(stats.rewritten); assert_eq!(stats.expired_sessions, 2); - assert!(storage - .load_summary_at_ref(tmp.path(), ref_name, "summary-a") - .expect("load summary a") - .is_none()); - assert!(storage - .load_summary_at_ref(tmp.path(), ref_name, "summary-b") - .expect("load summary b") - .is_none()); + assert!( + storage + .load_summary_at_ref(tmp.path(), ref_name, "summary-a") + .expect("load summary a") + .is_none() + ); + assert!( + storage + .load_summary_at_ref(tmp.path(), ref_name, "summary-b") + .expect("load summary b") + .is_none() + ); } #[test] diff --git a/crates/local-db/Cargo.toml b/crates/local-db/Cargo.toml index 3b9d4d14..92adabe8 100644 --- a/crates/local-db/Cargo.toml +++ b/crates/local-db/Cargo.toml @@ -2,6 +2,7 @@ name = "opensession-local-db" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true description = "Local SQLite database shared by TUI and Daemon" @@ -21,6 +22,7 @@ workspace = true [dependencies] opensession-core = { workspace = true } opensession-api = { workspace = true, default-features = false, features = ["backend"] } +opensession-paths = { workspace = true } rusqlite = { workspace = true } chrono = { workspace = true } serde = { workspace = true } diff --git a/crates/local-db/src/connection.rs b/crates/local-db/src/connection.rs new file mode 100644 index 00000000..3f508e3e --- /dev/null +++ b/crates/local-db/src/connection.rs @@ -0,0 +1,120 @@ +use anyhow::{Context, Result}; +use opensession_paths::local_db_path; +use rusqlite::Connection; +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard}; + +use crate::migrations::{ + apply_local_migrations, repair_auxiliary_flags_from_source_path, + repair_session_tools_from_source_path, validate_local_schema, +}; + +/// Local SQLite index/cache shared by TUI and Daemon. +/// This is not the source of truth for canonical session bodies. +/// Thread-safe: wraps the connection in a Mutex so it can be shared via `Arc`. +pub struct LocalDb { + conn: Mutex, +} + +impl LocalDb { + /// Open (or create) the local database at the default path. + /// `~/.local/share/opensession/local.db` or `OPENSESSION_LOCAL_DB_PATH` when set. + pub fn open() -> Result { + let path = default_db_path()?; + Self::open_path(&path) + } + + /// Open (or create) the local database at a specific path. + pub fn open_path(path: &PathBuf) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create dir for {}", path.display()))?; + } + let conn = open_connection_with_latest_schema(path) + .with_context(|| format!("open local db {}", path.display()))?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub(crate) fn conn(&self) -> MutexGuard<'_, Connection> { + self.conn.lock().expect("local db mutex poisoned") + } +} + +fn open_connection_with_latest_schema(path: &PathBuf) -> Result { + let conn = Connection::open(path).with_context(|| format!("open db {}", path.display()))?; + conn.execute_batch("PRAGMA journal_mode=WAL;")?; + + // Disable FK constraints for local DB (index/cache, not source of truth) + conn.execute_batch("PRAGMA foreign_keys=OFF;")?; + + apply_local_migrations(&conn)?; + repair_session_tools_from_source_path(&conn)?; + repair_auxiliary_flags_from_source_path(&conn)?; + validate_local_schema(&conn)?; + + Ok(conn) +} + +fn default_db_path() -> Result { + local_db_path().context("Could not determine local db path") +} + +#[cfg(test)] +mod tests { + use super::default_db_path; + use std::ffi::OsString; + use std::path::PathBuf; + use std::sync::{Mutex, OnceLock}; + + fn env_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: tests serialize environment mutation with `env_test_lock`, so process + // environment updates do not race with other tests in this module. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + // SAFETY: tests serialize environment mutation with `env_test_lock`. + unsafe { std::env::set_var(self.key, value) }; + } else { + // SAFETY: tests serialize environment mutation with `env_test_lock`. + unsafe { std::env::remove_var(self.key) }; + } + } + } + + #[test] + fn default_db_path_uses_centralized_location() { + let _lock = env_test_lock().lock().expect("env lock"); + let _guard = EnvVarGuard::set("OPENSESSION_LOCAL_DB_PATH", ""); + let path = default_db_path().expect("default db path"); + assert!(path.ends_with(PathBuf::from(".local/share/opensession/local.db"))); + } + + #[test] + fn default_db_path_honors_env_override() { + let _lock = env_test_lock().lock().expect("env lock"); + let _guard = EnvVarGuard::set("OPENSESSION_LOCAL_DB_PATH", "/tmp/custom-local.db"); + assert_eq!( + default_db_path().expect("default db path"), + PathBuf::from("/tmp/custom-local.db") + ); + } +} diff --git a/crates/local-db/src/job_store.rs b/crates/local-db/src/job_store.rs new file mode 100644 index 00000000..b052ec9c --- /dev/null +++ b/crates/local-db/src/job_store.rs @@ -0,0 +1,184 @@ +use anyhow::Result; +use rusqlite::{OptionalExtension, params}; + +use crate::connection::LocalDb; + +/// Vector indexing progress/status snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VectorIndexJobRow { + pub status: String, + pub processed_sessions: u32, + pub total_sessions: u32, + pub message: Option, + pub started_at: Option, + pub finished_at: Option, +} + +/// Summary batch generation progress/status snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SummaryBatchJobRow { + pub status: String, + pub processed_sessions: u32, + pub total_sessions: u32, + pub failed_sessions: u32, + pub message: Option, + pub started_at: Option, + pub finished_at: Option, +} + +/// Lifecycle cleanup progress/status snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LifecycleCleanupJobRow { + pub status: String, + pub deleted_sessions: u32, + pub deleted_summaries: u32, + pub message: Option, + pub started_at: Option, + pub finished_at: Option, +} + +impl LocalDb { + pub fn set_vector_index_job(&self, payload: &VectorIndexJobRow) -> Result<()> { + self.conn().execute( + "INSERT INTO vector_index_jobs \ + (id, status, processed_sessions, total_sessions, message, started_at, finished_at, updated_at) \ + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, datetime('now')) \ + ON CONFLICT(id) DO UPDATE SET \ + status=excluded.status, \ + processed_sessions=excluded.processed_sessions, \ + total_sessions=excluded.total_sessions, \ + message=excluded.message, \ + started_at=excluded.started_at, \ + finished_at=excluded.finished_at, \ + updated_at=datetime('now')", + params![ + payload.status, + payload.processed_sessions as i64, + payload.total_sessions as i64, + payload.message, + payload.started_at, + payload.finished_at, + ], + )?; + Ok(()) + } + + pub fn get_vector_index_job(&self) -> Result> { + let row = self + .conn() + .query_row( + "SELECT status, processed_sessions, total_sessions, message, started_at, finished_at \ + FROM vector_index_jobs WHERE id = 1 LIMIT 1", + [], + |row| { + Ok(VectorIndexJobRow { + status: row.get(0)?, + processed_sessions: row.get::<_, i64>(1)?.max(0) as u32, + total_sessions: row.get::<_, i64>(2)?.max(0) as u32, + message: row.get(3)?, + started_at: row.get(4)?, + finished_at: row.get(5)?, + }) + }, + ) + .optional()?; + Ok(row) + } + + pub fn set_summary_batch_job(&self, payload: &SummaryBatchJobRow) -> Result<()> { + self.conn().execute( + "INSERT INTO summary_batch_jobs \ + (id, status, processed_sessions, total_sessions, failed_sessions, message, started_at, finished_at, updated_at) \ + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now')) \ + ON CONFLICT(id) DO UPDATE SET \ + status=excluded.status, \ + processed_sessions=excluded.processed_sessions, \ + total_sessions=excluded.total_sessions, \ + failed_sessions=excluded.failed_sessions, \ + message=excluded.message, \ + started_at=excluded.started_at, \ + finished_at=excluded.finished_at, \ + updated_at=datetime('now')", + params![ + payload.status, + payload.processed_sessions as i64, + payload.total_sessions as i64, + payload.failed_sessions as i64, + payload.message, + payload.started_at, + payload.finished_at, + ], + )?; + Ok(()) + } + + pub fn get_summary_batch_job(&self) -> Result> { + let row = self + .conn() + .query_row( + "SELECT status, processed_sessions, total_sessions, failed_sessions, message, started_at, finished_at \ + FROM summary_batch_jobs WHERE id = 1 LIMIT 1", + [], + |row| { + Ok(SummaryBatchJobRow { + status: row.get(0)?, + processed_sessions: row.get::<_, i64>(1)?.max(0) as u32, + total_sessions: row.get::<_, i64>(2)?.max(0) as u32, + failed_sessions: row.get::<_, i64>(3)?.max(0) as u32, + message: row.get(4)?, + started_at: row.get(5)?, + finished_at: row.get(6)?, + }) + }, + ) + .optional()?; + Ok(row) + } + + pub fn set_lifecycle_cleanup_job(&self, payload: &LifecycleCleanupJobRow) -> Result<()> { + self.conn().execute( + "INSERT INTO lifecycle_cleanup_jobs \ + (id, status, deleted_sessions, deleted_summaries, message, started_at, finished_at, updated_at) \ + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, datetime('now')) \ + ON CONFLICT(id) DO UPDATE SET \ + status=excluded.status, \ + deleted_sessions=excluded.deleted_sessions, \ + deleted_summaries=excluded.deleted_summaries, \ + message=excluded.message, \ + started_at=excluded.started_at, \ + finished_at=excluded.finished_at, \ + updated_at=datetime('now')", + params![ + payload.status, + payload.deleted_sessions as i64, + payload.deleted_summaries as i64, + payload.message, + payload.started_at, + payload.finished_at, + ], + )?; + Ok(()) + } + + pub fn get_lifecycle_cleanup_job(&self) -> Result> { + let row = self + .conn() + .query_row( + "SELECT status, deleted_sessions, deleted_summaries, message, started_at, finished_at \ + FROM lifecycle_cleanup_jobs WHERE id = 1 LIMIT 1", + [], + |row| { + Ok(LifecycleCleanupJobRow { + status: row.get(0)?, + deleted_sessions: row.get::<_, i64>(1)?.max(0) as u32, + deleted_summaries: row.get::<_, i64>(2)?.max(0) as u32, + message: row.get(3)?, + started_at: row.get(4)?, + finished_at: row.get(5)?, + }) + }, + ) + .optional()?; + Ok(row) + } +} diff --git a/crates/local-db/src/lib.rs b/crates/local-db/src/lib.rs index 5c12eec8..9cb88b25 100644 --- a/crates/local-db/src/lib.rs +++ b/crates/local-db/src/lib.rs @@ -1,1977 +1,35 @@ pub mod git; -use anyhow::{Context, Result}; -use opensession_api::db::migrations::{LOCAL_MIGRATIONS, MIGRATIONS}; -use opensession_core::session::{is_auxiliary_session, working_directory}; +mod connection; +mod job_store; +mod migrations; +mod repo_store; +mod session_store; +mod summary_store; +mod sync_store; +mod vector_store; + +pub use connection::LocalDb; +pub use job_store::{LifecycleCleanupJobRow, SummaryBatchJobRow, VectorIndexJobRow}; +pub use session_store::{ + LocalSessionFilter, LocalSessionLink, LocalSessionRow, LocalSortOrder, LocalTimeRange, + LogFilter, RemoteSessionSummary, +}; +pub use summary_store::{SessionSemanticSummaryRow, SessionSemanticSummaryUpsert}; +pub use vector_store::{VectorChunkCandidateRow, VectorChunkUpsert}; + +#[cfg(test)] +use anyhow::Result; +#[cfg(test)] +use opensession_api::db::migrations::LOCAL_MIGRATIONS; +#[cfg(test)] use opensession_core::trace::Session; -use rusqlite::{params, Connection, OptionalExtension}; -use serde_json::Value; -use std::fs; -use std::io::{BufRead, BufReader}; +#[cfg(test)] +use rusqlite::{Connection, params}; +#[cfg(test)] use std::path::PathBuf; -use std::sync::Mutex; - -use git::{normalize_repo_name, GitContext}; - -const SUMMARY_WORKER_TITLE_PREFIX_LOWER: &str = - "convert a real coding session into semantic compression."; - -/// A local session row stored in the local SQLite index/cache database. -#[derive(Debug, Clone)] -pub struct LocalSessionRow { - pub id: String, - pub source_path: Option, - pub sync_status: String, - pub last_synced_at: Option, - pub user_id: Option, - pub nickname: Option, - pub team_id: Option, - pub tool: String, - pub agent_provider: Option, - pub agent_model: Option, - pub title: Option, - pub description: Option, - pub tags: Option, - pub created_at: String, - pub uploaded_at: Option, - pub message_count: i64, - pub user_message_count: i64, - pub task_count: i64, - pub event_count: i64, - pub duration_seconds: i64, - pub total_input_tokens: i64, - pub total_output_tokens: i64, - pub git_remote: Option, - pub git_branch: Option, - pub git_commit: Option, - pub git_repo_name: Option, - pub pr_number: Option, - pub pr_url: Option, - pub working_directory: Option, - pub files_modified: Option, - pub files_read: Option, - pub has_errors: bool, - pub max_active_agents: i64, - pub is_auxiliary: bool, -} - -/// A lightweight local link row for session-to-session relationships. -#[derive(Debug, Clone)] -pub struct LocalSessionLink { - pub session_id: String, - pub linked_session_id: String, - pub link_type: String, - pub created_at: String, -} - -/// Session-level semantic summary row persisted in local SQLite. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SessionSemanticSummaryRow { - pub session_id: String, - pub summary_json: String, - pub generated_at: String, - pub provider: String, - pub model: Option, - pub source_kind: String, - pub generation_kind: String, - pub prompt_fingerprint: Option, - pub source_details_json: Option, - pub diff_tree_json: Option, - pub error: Option, - pub updated_at: String, -} - -/// Upsert payload for session-level semantic summaries. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SessionSemanticSummaryUpsert<'a> { - pub session_id: &'a str, - pub summary_json: &'a str, - pub generated_at: &'a str, - pub provider: &'a str, - pub model: Option<&'a str>, - pub source_kind: &'a str, - pub generation_kind: &'a str, - pub prompt_fingerprint: Option<&'a str>, - pub source_details_json: Option<&'a str>, - pub diff_tree_json: Option<&'a str>, - pub error: Option<&'a str>, -} - -/// Vector chunk payload persisted per session. -#[derive(Debug, Clone, PartialEq)] -pub struct VectorChunkUpsert { - pub chunk_id: String, - pub session_id: String, - pub chunk_index: u32, - pub start_line: u32, - pub end_line: u32, - pub line_count: u32, - pub content: String, - pub content_hash: String, - pub embedding: Vec, -} - -/// Candidate row used for local semantic vector ranking. -#[derive(Debug, Clone, PartialEq)] -pub struct VectorChunkCandidateRow { - pub chunk_id: String, - pub session_id: String, - pub start_line: u32, - pub end_line: u32, - pub content: String, - pub embedding: Vec, -} - -/// Vector indexing progress/status snapshot. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VectorIndexJobRow { - pub status: String, - pub processed_sessions: u32, - pub total_sessions: u32, - pub message: Option, - pub started_at: Option, - pub finished_at: Option, -} - -/// Summary batch generation progress/status snapshot. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SummaryBatchJobRow { - pub status: String, - pub processed_sessions: u32, - pub total_sessions: u32, - pub failed_sessions: u32, - pub message: Option, - pub started_at: Option, - pub finished_at: Option, -} - -/// Lifecycle cleanup progress/status snapshot. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LifecycleCleanupJobRow { - pub status: String, - pub deleted_sessions: u32, - pub deleted_summaries: u32, - pub message: Option, - pub started_at: Option, - pub finished_at: Option, -} - -fn infer_tool_from_source_path(source_path: Option<&str>) -> Option<&'static str> { - let source_path = source_path.map(|path| path.to_ascii_lowercase())?; - - if source_path.contains("/.codex/sessions/") - || source_path.contains("\\.codex\\sessions\\") - || source_path.contains("/codex/sessions/") - || source_path.contains("\\codex\\sessions\\") - { - return Some("codex"); - } - - if source_path.contains("/.claude/projects/") - || source_path.contains("\\.claude\\projects\\") - || source_path.contains("/claude/projects/") - || source_path.contains("\\claude\\projects\\") - { - return Some("claude-code"); - } - - None -} - -fn normalize_tool_for_source_path(current_tool: &str, source_path: Option<&str>) -> String { - infer_tool_from_source_path(source_path) - .unwrap_or(current_tool) - .to_string() -} - -fn normalize_non_empty(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn build_fts_query(raw: &str) -> Option { - let mut parts: Vec = Vec::new(); - for token in raw.split_whitespace() { - let trimmed = token.trim(); - if trimmed.is_empty() { - continue; - } - let escaped = trimmed.replace('"', "\"\""); - parts.push(format!("\"{escaped}\"")); - } - if parts.is_empty() { - return None; - } - Some(parts.join(" OR ")) -} - -fn json_object_string(value: &Value, keys: &[&str]) -> Option { - let obj = value.as_object()?; - for key in keys { - if let Some(found) = obj.get(*key).and_then(Value::as_str) { - let normalized = found.trim(); - if !normalized.is_empty() { - return Some(normalized.to_string()); - } - } - } - None -} - -fn git_context_from_session_attributes(session: &Session) -> GitContext { - let attrs = &session.context.attributes; - - let mut remote = normalize_non_empty(attrs.get("git_remote").and_then(Value::as_str)); - let mut branch = normalize_non_empty(attrs.get("git_branch").and_then(Value::as_str)); - let mut commit = normalize_non_empty(attrs.get("git_commit").and_then(Value::as_str)); - let mut repo_name = normalize_non_empty(attrs.get("git_repo_name").and_then(Value::as_str)); - - if let Some(git_value) = attrs.get("git") { - if remote.is_none() { - remote = json_object_string( - git_value, - &["remote", "repository_url", "repo_url", "origin", "url"], - ); - } - if branch.is_none() { - branch = json_object_string( - git_value, - &["branch", "git_branch", "current_branch", "ref", "head"], - ); - } - if commit.is_none() { - commit = json_object_string(git_value, &["commit", "commit_hash", "sha", "git_commit"]); - } - if repo_name.is_none() { - repo_name = json_object_string(git_value, &["repo_name", "repository", "repo", "name"]); - } - } - - if repo_name.is_none() { - repo_name = remote - .as_deref() - .and_then(normalize_repo_name) - .map(ToOwned::to_owned); - } - - GitContext { - remote, - branch, - commit, - repo_name, - } -} - -fn git_context_has_any_field(git: &GitContext) -> bool { - git.remote.is_some() || git.branch.is_some() || git.commit.is_some() || git.repo_name.is_some() -} - -fn merge_git_context(preferred: &GitContext, fallback: &GitContext) -> GitContext { - GitContext { - remote: preferred.remote.clone().or_else(|| fallback.remote.clone()), - branch: preferred.branch.clone().or_else(|| fallback.branch.clone()), - commit: preferred.commit.clone().or_else(|| fallback.commit.clone()), - repo_name: preferred - .repo_name - .clone() - .or_else(|| fallback.repo_name.clone()), - } -} - -/// Filter for listing sessions from the local DB. -#[derive(Debug, Clone)] -pub struct LocalSessionFilter { - pub team_id: Option, - pub sync_status: Option, - pub git_repo_name: Option, - pub search: Option, - pub exclude_low_signal: bool, - pub tool: Option, - pub sort: LocalSortOrder, - pub time_range: LocalTimeRange, - pub limit: Option, - pub offset: Option, -} - -impl Default for LocalSessionFilter { - fn default() -> Self { - Self { - team_id: None, - sync_status: None, - git_repo_name: None, - search: None, - exclude_low_signal: false, - tool: None, - sort: LocalSortOrder::Recent, - time_range: LocalTimeRange::All, - limit: None, - offset: None, - } - } -} - -/// Sort order for local session listing. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum LocalSortOrder { - #[default] - Recent, - Popular, - Longest, -} - -/// Time range filter for local session listing. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum LocalTimeRange { - Hours24, - Days7, - Days30, - #[default] - All, -} - -/// Minimal remote session payload needed for local index/cache upsert. -#[derive(Debug, Clone)] -pub struct RemoteSessionSummary { - pub id: String, - pub user_id: Option, - pub nickname: Option, - pub team_id: String, - pub tool: String, - pub agent_provider: Option, - pub agent_model: Option, - pub title: Option, - pub description: Option, - pub tags: Option, - pub created_at: String, - pub uploaded_at: String, - pub message_count: i64, - pub task_count: i64, - pub event_count: i64, - pub duration_seconds: i64, - pub total_input_tokens: i64, - pub total_output_tokens: i64, - pub git_remote: Option, - pub git_branch: Option, - pub git_commit: Option, - pub git_repo_name: Option, - pub pr_number: Option, - pub pr_url: Option, - pub working_directory: Option, - pub files_modified: Option, - pub files_read: Option, - pub has_errors: bool, - pub max_active_agents: i64, -} - -/// Extended filter for the `log` command. -#[derive(Debug, Default)] -pub struct LogFilter { - /// Filter by tool name (exact match). - pub tool: Option, - /// Filter by model (glob-like, uses LIKE). - pub model: Option, - /// Filter sessions created after this ISO8601 timestamp. - pub since: Option, - /// Filter sessions created before this ISO8601 timestamp. - pub before: Option, - /// Filter sessions that touched this file path (searches files_modified JSON). - pub touches: Option, - /// Free-text search in title, description, tags. - pub grep: Option, - /// Only sessions with errors. - pub has_errors: Option, - /// Filter by working directory (prefix match). - pub working_directory: Option, - /// Filter by git repo name. - pub git_repo_name: Option, - /// Maximum number of results. - pub limit: Option, - /// Offset for pagination. - pub offset: Option, -} - -/// Base FROM clause for session list queries. -const FROM_CLAUSE: &str = "\ -FROM sessions s \ -LEFT JOIN session_sync ss ON ss.session_id = s.id \ -LEFT JOIN users u ON u.id = s.user_id"; - -/// Local SQLite index/cache shared by TUI and Daemon. -/// This is not the source of truth for canonical session bodies. -/// Thread-safe: wraps the connection in a Mutex so it can be shared via `Arc`. -pub struct LocalDb { - conn: Mutex, -} - -impl LocalDb { - /// Open (or create) the local database at the default path. - /// `~/.local/share/opensession/local.db` - pub fn open() -> Result { - let path = default_db_path()?; - Self::open_path(&path) - } - - /// Open (or create) the local database at a specific path. - pub fn open_path(path: &PathBuf) -> Result { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create dir for {}", path.display()))?; - } - let conn = open_connection_with_latest_schema(path) - .with_context(|| format!("open local db {}", path.display()))?; - Ok(Self { - conn: Mutex::new(conn), - }) - } - - fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { - self.conn.lock().expect("local db mutex poisoned") - } - - // ── Upsert local session (parsed from file) ──────────────────────── - - pub fn upsert_local_session( - &self, - session: &Session, - source_path: &str, - git: &GitContext, - ) -> Result<()> { - let is_empty_signal = session.stats.event_count == 0 - && session.stats.message_count == 0 - && session.stats.user_message_count == 0 - && session.stats.task_count == 0; - if is_empty_signal { - // Some local tools create placeholder thread files before any real conversation. - // Do not index these rows; if one already exists, drop it. - self.delete_session(&session.session_id)?; - return Ok(()); - } - - let title = session.context.title.as_deref(); - let description = session.context.description.as_deref(); - let tags = if session.context.tags.is_empty() { - None - } else { - Some(session.context.tags.join(",")) - }; - let created_at = session.context.created_at.to_rfc3339(); - let cwd = working_directory(session).map(String::from); - let is_auxiliary = is_auxiliary_session(session); - - // Extract files_modified, files_read, and has_errors from events - let (files_modified, files_read, has_errors) = - opensession_core::extract::extract_file_metadata(session); - let max_active_agents = opensession_core::agent_metrics::max_active_agents(session) as i64; - let normalized_tool = - normalize_tool_for_source_path(&session.agent.tool, Some(source_path)); - let git_from_session = git_context_from_session_attributes(session); - let has_session_git = git_context_has_any_field(&git_from_session); - let merged_git = merge_git_context(&git_from_session, git); - - let conn = self.conn(); - // Body contents are resolved via canonical body URLs and local body cache. - conn.execute( - "INSERT INTO sessions \ - (id, team_id, tool, agent_provider, agent_model, \ - title, description, tags, created_at, \ - message_count, user_message_count, task_count, event_count, duration_seconds, \ - total_input_tokens, total_output_tokens, body_storage_key, \ - git_remote, git_branch, git_commit, git_repo_name, working_directory, \ - files_modified, files_read, has_errors, max_active_agents, is_auxiliary) \ - VALUES (?1,'personal',?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,'',?16,?17,?18,?19,?20,?21,?22,?23,?24,?25) \ - ON CONFLICT(id) DO UPDATE SET \ - tool=excluded.tool, agent_provider=excluded.agent_provider, \ - agent_model=excluded.agent_model, \ - title=excluded.title, description=excluded.description, \ - tags=excluded.tags, \ - message_count=excluded.message_count, user_message_count=excluded.user_message_count, \ - task_count=excluded.task_count, \ - event_count=excluded.event_count, duration_seconds=excluded.duration_seconds, \ - total_input_tokens=excluded.total_input_tokens, \ - total_output_tokens=excluded.total_output_tokens, \ - git_remote=CASE WHEN ?26=1 THEN excluded.git_remote ELSE COALESCE(git_remote, excluded.git_remote) END, \ - git_branch=CASE WHEN ?26=1 THEN excluded.git_branch ELSE COALESCE(git_branch, excluded.git_branch) END, \ - git_commit=CASE WHEN ?26=1 THEN excluded.git_commit ELSE COALESCE(git_commit, excluded.git_commit) END, \ - git_repo_name=CASE WHEN ?26=1 THEN excluded.git_repo_name ELSE COALESCE(git_repo_name, excluded.git_repo_name) END, \ - working_directory=excluded.working_directory, \ - files_modified=excluded.files_modified, files_read=excluded.files_read, \ - has_errors=excluded.has_errors, \ - max_active_agents=excluded.max_active_agents, \ - is_auxiliary=excluded.is_auxiliary", - params![ - &session.session_id, - &normalized_tool, - &session.agent.provider, - &session.agent.model, - title, - description, - &tags, - &created_at, - session.stats.message_count as i64, - session.stats.user_message_count as i64, - session.stats.task_count as i64, - session.stats.event_count as i64, - session.stats.duration_seconds as i64, - session.stats.total_input_tokens as i64, - session.stats.total_output_tokens as i64, - &merged_git.remote, - &merged_git.branch, - &merged_git.commit, - &merged_git.repo_name, - &cwd, - &files_modified, - &files_read, - has_errors, - max_active_agents, - is_auxiliary as i64, - has_session_git as i64, - ], - )?; - - conn.execute( - "INSERT INTO session_sync (session_id, source_path, sync_status) \ - VALUES (?1, ?2, 'local_only') \ - ON CONFLICT(session_id) DO UPDATE SET source_path=excluded.source_path", - params![&session.session_id, source_path], - )?; - Ok(()) - } - - // ── Upsert remote session (from server sync pull) ────────────────── - - pub fn upsert_remote_session(&self, summary: &RemoteSessionSummary) -> Result<()> { - let conn = self.conn(); - // Body contents are resolved via canonical body URLs and local body cache. - conn.execute( - "INSERT INTO sessions \ - (id, user_id, team_id, tool, agent_provider, agent_model, \ - title, description, tags, created_at, uploaded_at, \ - message_count, task_count, event_count, duration_seconds, \ - total_input_tokens, total_output_tokens, body_storage_key, \ - git_remote, git_branch, git_commit, git_repo_name, \ - pr_number, pr_url, working_directory, \ - files_modified, files_read, has_errors, max_active_agents, is_auxiliary) \ - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,'',?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,0) \ - ON CONFLICT(id) DO UPDATE SET \ - title=excluded.title, description=excluded.description, \ - tags=excluded.tags, uploaded_at=excluded.uploaded_at, \ - message_count=excluded.message_count, task_count=excluded.task_count, \ - event_count=excluded.event_count, duration_seconds=excluded.duration_seconds, \ - total_input_tokens=excluded.total_input_tokens, \ - total_output_tokens=excluded.total_output_tokens, \ - git_remote=excluded.git_remote, git_branch=excluded.git_branch, \ - git_commit=excluded.git_commit, git_repo_name=excluded.git_repo_name, \ - pr_number=excluded.pr_number, pr_url=excluded.pr_url, \ - working_directory=excluded.working_directory, \ - files_modified=excluded.files_modified, files_read=excluded.files_read, \ - has_errors=excluded.has_errors, \ - max_active_agents=excluded.max_active_agents, \ - is_auxiliary=excluded.is_auxiliary", - params![ - &summary.id, - &summary.user_id, - &summary.team_id, - &summary.tool, - &summary.agent_provider, - &summary.agent_model, - &summary.title, - &summary.description, - &summary.tags, - &summary.created_at, - &summary.uploaded_at, - summary.message_count, - summary.task_count, - summary.event_count, - summary.duration_seconds, - summary.total_input_tokens, - summary.total_output_tokens, - &summary.git_remote, - &summary.git_branch, - &summary.git_commit, - &summary.git_repo_name, - summary.pr_number, - &summary.pr_url, - &summary.working_directory, - &summary.files_modified, - &summary.files_read, - summary.has_errors, - summary.max_active_agents, - ], - )?; - - conn.execute( - "INSERT INTO session_sync (session_id, sync_status) \ - VALUES (?1, 'remote_only') \ - ON CONFLICT(session_id) DO UPDATE SET \ - sync_status = CASE WHEN session_sync.sync_status = 'local_only' THEN 'synced' ELSE session_sync.sync_status END", - params![&summary.id], - )?; - Ok(()) - } - - // ── List sessions ────────────────────────────────────────────────── - - fn build_local_session_where_clause( - filter: &LocalSessionFilter, - ) -> (String, Vec>) { - let mut where_clauses = vec![ - "1=1".to_string(), - "COALESCE(s.is_auxiliary, 0) = 0".to_string(), - format!( - "NOT (LOWER(COALESCE(s.tool, '')) = 'codex' \ - AND LOWER(COALESCE(s.title, '')) LIKE '{}%')", - SUMMARY_WORKER_TITLE_PREFIX_LOWER - ), - ]; - let mut param_values: Vec> = Vec::new(); - let mut idx = 1u32; - - if let Some(ref team_id) = filter.team_id { - where_clauses.push(format!("s.team_id = ?{idx}")); - param_values.push(Box::new(team_id.clone())); - idx += 1; - } - - if let Some(ref sync_status) = filter.sync_status { - where_clauses.push(format!("COALESCE(ss.sync_status, 'unknown') = ?{idx}")); - param_values.push(Box::new(sync_status.clone())); - idx += 1; - } - - if let Some(ref repo) = filter.git_repo_name { - where_clauses.push(format!("s.git_repo_name = ?{idx}")); - param_values.push(Box::new(repo.clone())); - idx += 1; - } - - if let Some(ref tool) = filter.tool { - where_clauses.push(format!("s.tool = ?{idx}")); - param_values.push(Box::new(tool.clone())); - idx += 1; - } - - if let Some(ref search) = filter.search { - let like = format!("%{search}%"); - where_clauses.push(format!( - "(s.title LIKE ?{i1} OR s.description LIKE ?{i2} OR s.tags LIKE ?{i3})", - i1 = idx, - i2 = idx + 1, - i3 = idx + 2, - )); - param_values.push(Box::new(like.clone())); - param_values.push(Box::new(like.clone())); - param_values.push(Box::new(like)); - idx += 3; - } - - if filter.exclude_low_signal { - where_clauses.push( - "NOT (COALESCE(s.message_count, 0) = 0 \ - AND COALESCE(s.user_message_count, 0) = 0 \ - AND COALESCE(s.task_count, 0) = 0 \ - AND COALESCE(s.event_count, 0) <= 2 \ - AND (s.title IS NULL OR TRIM(s.title) = ''))" - .to_string(), - ); - } - - let interval = match filter.time_range { - LocalTimeRange::Hours24 => Some("-1 day"), - LocalTimeRange::Days7 => Some("-7 days"), - LocalTimeRange::Days30 => Some("-30 days"), - LocalTimeRange::All => None, - }; - if let Some(interval) = interval { - where_clauses.push(format!("datetime(s.created_at) >= datetime('now', ?{idx})")); - param_values.push(Box::new(interval.to_string())); - } - - (where_clauses.join(" AND "), param_values) - } - - pub fn list_sessions(&self, filter: &LocalSessionFilter) -> Result> { - let (where_str, mut param_values) = Self::build_local_session_where_clause(filter); - let order_clause = match filter.sort { - LocalSortOrder::Popular => "s.message_count DESC, s.created_at DESC", - LocalSortOrder::Longest => "s.duration_seconds DESC, s.created_at DESC", - LocalSortOrder::Recent => "s.created_at DESC", - }; - - let mut sql = format!( - "SELECT {LOCAL_SESSION_COLUMNS} \ - {FROM_CLAUSE} WHERE {where_str} \ - ORDER BY {order_clause}" - ); - - if let Some(limit) = filter.limit { - sql.push_str(" LIMIT ?"); - param_values.push(Box::new(limit)); - if let Some(offset) = filter.offset { - sql.push_str(" OFFSET ?"); - param_values.push(Box::new(offset)); - } - } - - let param_refs: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|p| p.as_ref()).collect(); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(param_refs.as_slice(), row_to_local_session)?; - - let mut result = Vec::new(); - for row in rows { - result.push(row?); - } - - Ok(result) - } - - /// Count sessions for a given list filter (before UI-level page slicing). - pub fn count_sessions_filtered(&self, filter: &LocalSessionFilter) -> Result { - let mut count_filter = filter.clone(); - count_filter.limit = None; - count_filter.offset = None; - let (where_str, param_values) = Self::build_local_session_where_clause(&count_filter); - let sql = format!("SELECT COUNT(*) {FROM_CLAUSE} WHERE {where_str}"); - let param_refs: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|p| p.as_ref()).collect(); - let conn = self.conn(); - let count = conn.query_row(&sql, param_refs.as_slice(), |row| row.get(0))?; - Ok(count) - } - - /// List distinct tool names for the current list filter (ignores active tool filter). - pub fn list_session_tools(&self, filter: &LocalSessionFilter) -> Result> { - let mut tool_filter = filter.clone(); - tool_filter.tool = None; - tool_filter.limit = None; - tool_filter.offset = None; - let (where_str, param_values) = Self::build_local_session_where_clause(&tool_filter); - let sql = format!( - "SELECT DISTINCT s.tool \ - {FROM_CLAUSE} WHERE {where_str} \ - ORDER BY s.tool ASC" - ); - let param_refs: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|p| p.as_ref()).collect(); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))?; - - let mut tools = Vec::new(); - for row in rows { - let tool = row?; - if !tool.trim().is_empty() { - tools.push(tool); - } - } - Ok(tools) - } - - // ── Log query ───────────────────────────────────────────────────── - - /// Query sessions with extended filters for the `log` command. - pub fn list_sessions_log(&self, filter: &LogFilter) -> Result> { - let mut where_clauses = vec![ - "1=1".to_string(), - "COALESCE(s.is_auxiliary, 0) = 0".to_string(), - format!( - "NOT (LOWER(COALESCE(s.tool, '')) = 'codex' \ - AND LOWER(COALESCE(s.title, '')) LIKE '{}%')", - SUMMARY_WORKER_TITLE_PREFIX_LOWER - ), - ]; - let mut param_values: Vec> = Vec::new(); - let mut idx = 1u32; - - if let Some(ref tool) = filter.tool { - where_clauses.push(format!("s.tool = ?{idx}")); - param_values.push(Box::new(tool.clone())); - idx += 1; - } - - if let Some(ref model) = filter.model { - let like = model.replace('*', "%"); - where_clauses.push(format!("s.agent_model LIKE ?{idx}")); - param_values.push(Box::new(like)); - idx += 1; - } - - if let Some(ref since) = filter.since { - where_clauses.push(format!("s.created_at >= ?{idx}")); - param_values.push(Box::new(since.clone())); - idx += 1; - } - - if let Some(ref before) = filter.before { - where_clauses.push(format!("s.created_at < ?{idx}")); - param_values.push(Box::new(before.clone())); - idx += 1; - } - - if let Some(ref touches) = filter.touches { - let like = format!("%\"{touches}\"%"); - where_clauses.push(format!("s.files_modified LIKE ?{idx}")); - param_values.push(Box::new(like)); - idx += 1; - } - - if let Some(ref grep) = filter.grep { - let like = format!("%{grep}%"); - where_clauses.push(format!( - "(s.title LIKE ?{i1} OR s.description LIKE ?{i2} OR s.tags LIKE ?{i3})", - i1 = idx, - i2 = idx + 1, - i3 = idx + 2, - )); - param_values.push(Box::new(like.clone())); - param_values.push(Box::new(like.clone())); - param_values.push(Box::new(like)); - idx += 3; - } - - if let Some(true) = filter.has_errors { - where_clauses.push("s.has_errors = 1".to_string()); - } - - if let Some(ref wd) = filter.working_directory { - where_clauses.push(format!("s.working_directory LIKE ?{idx}")); - param_values.push(Box::new(format!("{wd}%"))); - idx += 1; - } - - if let Some(ref repo) = filter.git_repo_name { - where_clauses.push(format!("s.git_repo_name = ?{idx}")); - param_values.push(Box::new(repo.clone())); - idx += 1; - } - - let _ = idx; // suppress unused warning - - let where_str = where_clauses.join(" AND "); - let mut sql = format!( - "SELECT {LOCAL_SESSION_COLUMNS} \ - {FROM_CLAUSE} WHERE {where_str} \ - ORDER BY s.created_at DESC" - ); - - if let Some(limit) = filter.limit { - sql.push_str(" LIMIT ?"); - param_values.push(Box::new(limit)); - if let Some(offset) = filter.offset { - sql.push_str(" OFFSET ?"); - param_values.push(Box::new(offset)); - } - } - - let param_refs: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|p| p.as_ref()).collect(); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(param_refs.as_slice(), row_to_local_session)?; - - let mut result = Vec::new(); - for row in rows { - result.push(row?); - } - Ok(result) - } - - /// Get the latest N sessions for a specific tool, ordered by created_at DESC. - pub fn get_sessions_by_tool_latest( - &self, - tool: &str, - count: u32, - ) -> Result> { - let sql = format!( - "SELECT {LOCAL_SESSION_COLUMNS} \ - {FROM_CLAUSE} WHERE s.tool = ?1 AND COALESCE(s.is_auxiliary, 0) = 0 \ - ORDER BY s.created_at DESC" - ); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(params![tool], row_to_local_session)?; - let mut result = Vec::new(); - for row in rows { - result.push(row?); - } - - result.truncate(count as usize); - Ok(result) - } - - /// Get the latest N sessions across all tools, ordered by created_at DESC. - pub fn get_sessions_latest(&self, count: u32) -> Result> { - let sql = format!( - "SELECT {LOCAL_SESSION_COLUMNS} \ - {FROM_CLAUSE} WHERE COALESCE(s.is_auxiliary, 0) = 0 \ - ORDER BY s.created_at DESC" - ); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map([], row_to_local_session)?; - let mut result = Vec::new(); - for row in rows { - result.push(row?); - } - - result.truncate(count as usize); - Ok(result) - } - - /// Get the Nth most recent session for a specific tool (0 = HEAD, 1 = HEAD~1, etc.). - pub fn get_session_by_tool_offset( - &self, - tool: &str, - offset: u32, - ) -> Result> { - let sql = format!( - "SELECT {LOCAL_SESSION_COLUMNS} \ - {FROM_CLAUSE} WHERE s.tool = ?1 AND COALESCE(s.is_auxiliary, 0) = 0 \ - ORDER BY s.created_at DESC" - ); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(params![tool], row_to_local_session)?; - let result = rows.collect::, _>>()?; - Ok(result.into_iter().nth(offset as usize)) - } - - /// Get the Nth most recent session across all tools (0 = HEAD, 1 = HEAD~1, etc.). - pub fn get_session_by_offset(&self, offset: u32) -> Result> { - let sql = format!( - "SELECT {LOCAL_SESSION_COLUMNS} \ - {FROM_CLAUSE} WHERE COALESCE(s.is_auxiliary, 0) = 0 \ - ORDER BY s.created_at DESC" - ); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map([], row_to_local_session)?; - let result = rows.collect::, _>>()?; - Ok(result.into_iter().nth(offset as usize)) - } - - /// Fetch the source path used when the session was last parsed/loaded. - pub fn get_session_source_path(&self, session_id: &str) -> Result> { - let conn = self.conn(); - let result = conn - .query_row( - "SELECT source_path FROM session_sync WHERE session_id = ?1", - params![session_id], - |row| row.get(0), - ) - .optional()?; - - Ok(result) - } - - /// List every session id with a non-empty source path from session_sync. - pub fn list_session_source_paths(&self) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare( - "SELECT session_id, source_path \ - FROM session_sync \ - WHERE source_path IS NOT NULL AND TRIM(source_path) != ''", - )?; - let rows = stmt.query_map([], |row| { - let session_id: String = row.get(0)?; - let source_path: String = row.get(1)?; - Ok((session_id, source_path)) - })?; - rows.collect::, _>>() - .map_err(Into::into) - } - - /// Get a single session row by id. - pub fn get_session_by_id(&self, session_id: &str) -> Result> { - let sql = format!( - "SELECT {LOCAL_SESSION_COLUMNS} \ - {FROM_CLAUSE} WHERE s.id = ?1 LIMIT 1" - ); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let row = stmt - .query_map(params![session_id], row_to_local_session)? - .next() - .transpose()?; - Ok(row) - } - - /// List links where the given session is the source session. - pub fn list_session_links(&self, session_id: &str) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare( - "SELECT session_id, linked_session_id, link_type, created_at \ - FROM session_links WHERE session_id = ?1 ORDER BY created_at ASC", - )?; - let rows = stmt.query_map(params![session_id], |row| { - Ok(LocalSessionLink { - session_id: row.get(0)?, - linked_session_id: row.get(1)?, - link_type: row.get(2)?, - created_at: row.get(3)?, - }) - })?; - rows.collect::, _>>() - .map_err(Into::into) - } - - /// Count total sessions in the local DB. - pub fn session_count(&self) -> Result { - let count = self - .conn() - .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?; - Ok(count) - } - - // ── Delete session ───────────────────────────────────────────────── - - pub fn delete_session(&self, session_id: &str) -> Result<()> { - let conn = self.conn(); - conn.execute( - "DELETE FROM session_links WHERE session_id = ?1 OR linked_session_id = ?1", - params![session_id], - )?; - conn.execute( - "DELETE FROM vector_embeddings \ - WHERE chunk_id IN (SELECT id FROM vector_chunks WHERE session_id = ?1)", - params![session_id], - )?; - conn.execute( - "DELETE FROM vector_chunks_fts WHERE session_id = ?1", - params![session_id], - )?; - conn.execute( - "DELETE FROM vector_chunks WHERE session_id = ?1", - params![session_id], - )?; - conn.execute( - "DELETE FROM vector_index_sessions WHERE session_id = ?1", - params![session_id], - )?; - conn.execute( - "DELETE FROM session_semantic_summaries WHERE session_id = ?1", - params![session_id], - )?; - conn.execute( - "DELETE FROM body_cache WHERE session_id = ?1", - params![session_id], - )?; - conn.execute( - "DELETE FROM session_sync WHERE session_id = ?1", - params![session_id], - )?; - conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?; - Ok(()) - } - - // ── Semantic summary cache ─────────────────────────────────────────── - - pub fn upsert_session_semantic_summary( - &self, - payload: &SessionSemanticSummaryUpsert<'_>, - ) -> Result<()> { - self.conn().execute( - "INSERT INTO session_semantic_summaries (\ - session_id, summary_json, generated_at, provider, model, \ - source_kind, generation_kind, prompt_fingerprint, source_details_json, \ - diff_tree_json, error, updated_at\ - ) VALUES (\ - ?1, ?2, ?3, ?4, ?5, \ - ?6, ?7, ?8, ?9, \ - ?10, ?11, datetime('now')\ - ) \ - ON CONFLICT(session_id) DO UPDATE SET \ - summary_json=excluded.summary_json, \ - generated_at=excluded.generated_at, \ - provider=excluded.provider, \ - model=excluded.model, \ - source_kind=excluded.source_kind, \ - generation_kind=excluded.generation_kind, \ - prompt_fingerprint=excluded.prompt_fingerprint, \ - source_details_json=excluded.source_details_json, \ - diff_tree_json=excluded.diff_tree_json, \ - error=excluded.error, \ - updated_at=datetime('now')", - params![ - payload.session_id, - payload.summary_json, - payload.generated_at, - payload.provider, - payload.model, - payload.source_kind, - payload.generation_kind, - payload.prompt_fingerprint, - payload.source_details_json, - payload.diff_tree_json, - payload.error, - ], - )?; - Ok(()) - } - - pub fn list_expired_session_ids(&self, keep_days: u32) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare( - "SELECT id FROM sessions \ - WHERE julianday(created_at) <= julianday('now') - ?1 \ - ORDER BY created_at ASC", - )?; - let rows = stmt.query_map(params![keep_days as i64], |row| row.get(0))?; - rows.collect::, _>>() - .map_err(Into::into) - } - - /// List all known session ids for migration or maintenance workflows. - pub fn list_all_session_ids(&self) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare("SELECT id FROM sessions ORDER BY id ASC")?; - let rows = stmt.query_map([], |row| row.get(0))?; - rows.collect::, _>>() - .map_err(Into::into) - } - - /// List all session ids that currently have cached semantic summaries. - pub fn list_session_semantic_summary_ids(&self) -> Result> { - let conn = self.conn(); - let mut stmt = conn - .prepare("SELECT session_id FROM session_semantic_summaries ORDER BY session_id ASC")?; - let rows = stmt.query_map([], |row| row.get(0))?; - rows.collect::, _>>() - .map_err(Into::into) - } - - pub fn get_session_semantic_summary( - &self, - session_id: &str, - ) -> Result> { - let row = self - .conn() - .query_row( - "SELECT session_id, summary_json, generated_at, provider, model, \ - source_kind, generation_kind, prompt_fingerprint, source_details_json, \ - diff_tree_json, error, updated_at \ - FROM session_semantic_summaries WHERE session_id = ?1 LIMIT 1", - params![session_id], - |row| { - Ok(SessionSemanticSummaryRow { - session_id: row.get(0)?, - summary_json: row.get(1)?, - generated_at: row.get(2)?, - provider: row.get(3)?, - model: row.get(4)?, - source_kind: row.get(5)?, - generation_kind: row.get(6)?, - prompt_fingerprint: row.get(7)?, - source_details_json: row.get(8)?, - diff_tree_json: row.get(9)?, - error: row.get(10)?, - updated_at: row.get(11)?, - }) - }, - ) - .optional()?; - Ok(row) - } - - pub fn delete_expired_session_summaries(&self, keep_days: u32) -> Result { - let deleted = self.conn().execute( - "DELETE FROM session_semantic_summaries \ - WHERE julianday(generated_at) <= julianday('now') - ?1", - params![keep_days as i64], - )?; - Ok(deleted as u32) - } - - // ── Semantic vector index cache ──────────────────────────────────── - - pub fn vector_index_source_hash(&self, session_id: &str) -> Result> { - let hash = self - .conn() - .query_row( - "SELECT source_hash FROM vector_index_sessions WHERE session_id = ?1", - params![session_id], - |row| row.get(0), - ) - .optional()?; - Ok(hash) - } - - pub fn clear_vector_index(&self) -> Result<()> { - let conn = self.conn(); - conn.execute("DELETE FROM vector_embeddings", [])?; - conn.execute("DELETE FROM vector_chunks_fts", [])?; - conn.execute("DELETE FROM vector_chunks", [])?; - conn.execute("DELETE FROM vector_index_sessions", [])?; - Ok(()) - } - - pub fn replace_session_vector_chunks( - &self, - session_id: &str, - source_hash: &str, - model: &str, - chunks: &[VectorChunkUpsert], - ) -> Result<()> { - let mut conn = self.conn(); - let tx = conn.transaction()?; - - tx.execute( - "DELETE FROM vector_embeddings \ - WHERE chunk_id IN (SELECT id FROM vector_chunks WHERE session_id = ?1)", - params![session_id], - )?; - tx.execute( - "DELETE FROM vector_chunks_fts WHERE session_id = ?1", - params![session_id], - )?; - tx.execute( - "DELETE FROM vector_chunks WHERE session_id = ?1", - params![session_id], - )?; - - for chunk in chunks { - let embedding_json = serde_json::to_string(&chunk.embedding) - .context("serialize vector embedding for local cache")?; - tx.execute( - "INSERT INTO vector_chunks \ - (id, session_id, chunk_index, start_line, end_line, line_count, content, content_hash, created_at, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now'))", - params![ - &chunk.chunk_id, - &chunk.session_id, - chunk.chunk_index as i64, - chunk.start_line as i64, - chunk.end_line as i64, - chunk.line_count as i64, - &chunk.content, - &chunk.content_hash, - ], - )?; - tx.execute( - "INSERT INTO vector_embeddings \ - (chunk_id, model, embedding_dim, embedding_json, updated_at) \ - VALUES (?1, ?2, ?3, ?4, datetime('now'))", - params![ - &chunk.chunk_id, - model, - chunk.embedding.len() as i64, - &embedding_json - ], - )?; - tx.execute( - "INSERT INTO vector_chunks_fts (chunk_id, session_id, content) VALUES (?1, ?2, ?3)", - params![&chunk.chunk_id, &chunk.session_id, &chunk.content], - )?; - } - - tx.execute( - "INSERT INTO vector_index_sessions \ - (session_id, source_hash, chunk_count, last_indexed_at, updated_at) \ - VALUES (?1, ?2, ?3, datetime('now'), datetime('now')) \ - ON CONFLICT(session_id) DO UPDATE SET \ - source_hash=excluded.source_hash, \ - chunk_count=excluded.chunk_count, \ - last_indexed_at=datetime('now'), \ - updated_at=datetime('now')", - params![session_id, source_hash, chunks.len() as i64], - )?; - - tx.commit()?; - Ok(()) - } - - pub fn list_vector_chunk_candidates( - &self, - query: &str, - model: &str, - limit: u32, - ) -> Result> { - let Some(fts_query) = build_fts_query(query) else { - return Ok(Vec::new()); - }; - let conn = self.conn(); - let mut stmt = conn.prepare( - "SELECT c.id, c.session_id, c.start_line, c.end_line, c.content, e.embedding_json \ - FROM vector_chunks_fts f \ - INNER JOIN vector_chunks c ON c.id = f.chunk_id \ - INNER JOIN vector_embeddings e ON e.chunk_id = c.id \ - WHERE f.content MATCH ?1 AND e.model = ?2 \ - ORDER BY bm25(vector_chunks_fts) ASC, c.updated_at DESC \ - LIMIT ?3", - )?; - let rows = stmt.query_map(params![fts_query, model, limit as i64], |row| { - let embedding_json: String = row.get(5)?; - let embedding = - serde_json::from_str::>(&embedding_json).unwrap_or_else(|_| Vec::new()); - Ok(VectorChunkCandidateRow { - chunk_id: row.get(0)?, - session_id: row.get(1)?, - start_line: row.get::<_, i64>(2)?.max(0) as u32, - end_line: row.get::<_, i64>(3)?.max(0) as u32, - content: row.get(4)?, - embedding, - }) - })?; - rows.collect::, _>>() - .map_err(Into::into) - } - - pub fn list_recent_vector_chunks_for_model( - &self, - model: &str, - limit: u32, - ) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare( - "SELECT c.id, c.session_id, c.start_line, c.end_line, c.content, e.embedding_json \ - FROM vector_chunks c \ - INNER JOIN vector_embeddings e ON e.chunk_id = c.id \ - WHERE e.model = ?1 \ - ORDER BY c.updated_at DESC \ - LIMIT ?2", - )?; - let rows = stmt.query_map(params![model, limit as i64], |row| { - let embedding_json: String = row.get(5)?; - let embedding = - serde_json::from_str::>(&embedding_json).unwrap_or_else(|_| Vec::new()); - Ok(VectorChunkCandidateRow { - chunk_id: row.get(0)?, - session_id: row.get(1)?, - start_line: row.get::<_, i64>(2)?.max(0) as u32, - end_line: row.get::<_, i64>(3)?.max(0) as u32, - content: row.get(4)?, - embedding, - }) - })?; - rows.collect::, _>>() - .map_err(Into::into) - } - - pub fn set_vector_index_job(&self, payload: &VectorIndexJobRow) -> Result<()> { - self.conn().execute( - "INSERT INTO vector_index_jobs \ - (id, status, processed_sessions, total_sessions, message, started_at, finished_at, updated_at) \ - VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, datetime('now')) \ - ON CONFLICT(id) DO UPDATE SET \ - status=excluded.status, \ - processed_sessions=excluded.processed_sessions, \ - total_sessions=excluded.total_sessions, \ - message=excluded.message, \ - started_at=excluded.started_at, \ - finished_at=excluded.finished_at, \ - updated_at=datetime('now')", - params![ - payload.status, - payload.processed_sessions as i64, - payload.total_sessions as i64, - payload.message, - payload.started_at, - payload.finished_at, - ], - )?; - Ok(()) - } - - pub fn get_vector_index_job(&self) -> Result> { - let row = self - .conn() - .query_row( - "SELECT status, processed_sessions, total_sessions, message, started_at, finished_at \ - FROM vector_index_jobs WHERE id = 1 LIMIT 1", - [], - |row| { - Ok(VectorIndexJobRow { - status: row.get(0)?, - processed_sessions: row.get::<_, i64>(1)?.max(0) as u32, - total_sessions: row.get::<_, i64>(2)?.max(0) as u32, - message: row.get(3)?, - started_at: row.get(4)?, - finished_at: row.get(5)?, - }) - }, - ) - .optional()?; - Ok(row) - } - - pub fn set_summary_batch_job(&self, payload: &SummaryBatchJobRow) -> Result<()> { - self.conn().execute( - "INSERT INTO summary_batch_jobs \ - (id, status, processed_sessions, total_sessions, failed_sessions, message, started_at, finished_at, updated_at) \ - VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now')) \ - ON CONFLICT(id) DO UPDATE SET \ - status=excluded.status, \ - processed_sessions=excluded.processed_sessions, \ - total_sessions=excluded.total_sessions, \ - failed_sessions=excluded.failed_sessions, \ - message=excluded.message, \ - started_at=excluded.started_at, \ - finished_at=excluded.finished_at, \ - updated_at=datetime('now')", - params![ - payload.status, - payload.processed_sessions as i64, - payload.total_sessions as i64, - payload.failed_sessions as i64, - payload.message, - payload.started_at, - payload.finished_at, - ], - )?; - Ok(()) - } - - pub fn get_summary_batch_job(&self) -> Result> { - let row = self - .conn() - .query_row( - "SELECT status, processed_sessions, total_sessions, failed_sessions, message, started_at, finished_at \ - FROM summary_batch_jobs WHERE id = 1 LIMIT 1", - [], - |row| { - Ok(SummaryBatchJobRow { - status: row.get(0)?, - processed_sessions: row.get::<_, i64>(1)?.max(0) as u32, - total_sessions: row.get::<_, i64>(2)?.max(0) as u32, - failed_sessions: row.get::<_, i64>(3)?.max(0) as u32, - message: row.get(4)?, - started_at: row.get(5)?, - finished_at: row.get(6)?, - }) - }, - ) - .optional()?; - Ok(row) - } - - pub fn set_lifecycle_cleanup_job(&self, payload: &LifecycleCleanupJobRow) -> Result<()> { - self.conn().execute( - "INSERT INTO lifecycle_cleanup_jobs \ - (id, status, deleted_sessions, deleted_summaries, message, started_at, finished_at, updated_at) \ - VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, datetime('now')) \ - ON CONFLICT(id) DO UPDATE SET \ - status=excluded.status, \ - deleted_sessions=excluded.deleted_sessions, \ - deleted_summaries=excluded.deleted_summaries, \ - message=excluded.message, \ - started_at=excluded.started_at, \ - finished_at=excluded.finished_at, \ - updated_at=datetime('now')", - params![ - payload.status, - payload.deleted_sessions as i64, - payload.deleted_summaries as i64, - payload.message, - payload.started_at, - payload.finished_at, - ], - )?; - Ok(()) - } - - pub fn get_lifecycle_cleanup_job(&self) -> Result> { - let row = self - .conn() - .query_row( - "SELECT status, deleted_sessions, deleted_summaries, message, started_at, finished_at \ - FROM lifecycle_cleanup_jobs WHERE id = 1 LIMIT 1", - [], - |row| { - Ok(LifecycleCleanupJobRow { - status: row.get(0)?, - deleted_sessions: row.get::<_, i64>(1)?.max(0) as u32, - deleted_summaries: row.get::<_, i64>(2)?.max(0) as u32, - message: row.get(3)?, - started_at: row.get(4)?, - finished_at: row.get(5)?, - }) - }, - ) - .optional()?; - Ok(row) - } - - // ── Sync cursor ──────────────────────────────────────────────────── - - pub fn get_sync_cursor(&self, team_id: &str) -> Result> { - let cursor = self - .conn() - .query_row( - "SELECT cursor FROM sync_cursors WHERE team_id = ?1", - params![team_id], - |row| row.get(0), - ) - .optional()?; - Ok(cursor) - } - - pub fn set_sync_cursor(&self, team_id: &str, cursor: &str) -> Result<()> { - self.conn().execute( - "INSERT INTO sync_cursors (team_id, cursor, updated_at) \ - VALUES (?1, ?2, datetime('now')) \ - ON CONFLICT(team_id) DO UPDATE SET cursor=excluded.cursor, updated_at=datetime('now')", - params![team_id, cursor], - )?; - Ok(()) - } - - // ── Upload tracking ──────────────────────────────────────────────── - - /// Get sessions that are local_only and need to be uploaded. - pub fn pending_uploads(&self, team_id: &str) -> Result> { - let sql = format!( - "SELECT {LOCAL_SESSION_COLUMNS} \ - FROM sessions s \ - INNER JOIN session_sync ss ON ss.session_id = s.id \ - LEFT JOIN users u ON u.id = s.user_id \ - WHERE ss.sync_status = 'local_only' AND s.team_id = ?1 AND COALESCE(s.is_auxiliary, 0) = 0 \ - ORDER BY s.created_at ASC" - ); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(params![team_id], row_to_local_session)?; - let mut result = Vec::new(); - for row in rows { - result.push(row?); - } - Ok(result) - } - - pub fn mark_synced(&self, session_id: &str) -> Result<()> { - self.conn().execute( - "UPDATE session_sync SET sync_status = 'synced', last_synced_at = datetime('now') \ - WHERE session_id = ?1", - params![session_id], - )?; - Ok(()) - } - - /// Check if a session was already uploaded (synced or remote_only) since the given modification time. - pub fn was_uploaded_after( - &self, - source_path: &str, - modified: &chrono::DateTime, - ) -> Result { - let result: Option = self - .conn() - .query_row( - "SELECT last_synced_at FROM session_sync \ - WHERE source_path = ?1 AND sync_status = 'synced' AND last_synced_at IS NOT NULL", - params![source_path], - |row| row.get(0), - ) - .optional()?; - - if let Some(synced_at) = result { - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&synced_at) { - return Ok(dt >= *modified); - } - } - Ok(false) - } - - // ── Body cache (local read acceleration) ─────────────────────────── - - pub fn cache_body(&self, session_id: &str, body: &[u8]) -> Result<()> { - self.conn().execute( - "INSERT INTO body_cache (session_id, body, cached_at) \ - VALUES (?1, ?2, datetime('now')) \ - ON CONFLICT(session_id) DO UPDATE SET body=excluded.body, cached_at=datetime('now')", - params![session_id, body], - )?; - Ok(()) - } - - pub fn get_cached_body(&self, session_id: &str) -> Result>> { - let body = self - .conn() - .query_row( - "SELECT body FROM body_cache WHERE session_id = ?1", - params![session_id], - |row| row.get(0), - ) - .optional()?; - Ok(body) - } - - /// Find the most recently active session for a given repo path. - /// "Active" means the session's working_directory matches the repo path - /// and was created within the last `since_minutes` minutes. - pub fn find_active_session_for_repo( - &self, - repo_path: &str, - since_minutes: u32, - ) -> Result> { - let sql = format!( - "SELECT {LOCAL_SESSION_COLUMNS} \ - {FROM_CLAUSE} \ - WHERE s.working_directory LIKE ?1 \ - AND COALESCE(s.is_auxiliary, 0) = 0 \ - AND s.created_at >= datetime('now', ?2) \ - ORDER BY s.created_at DESC LIMIT 1" - ); - let since = format!("-{since_minutes} minutes"); - let like = format!("{repo_path}%"); - let conn = self.conn(); - let mut stmt = conn.prepare(&sql)?; - let row = stmt - .query_map(params![like, since], row_to_local_session)? - .next() - .transpose()?; - Ok(row) - } - - /// Get all session IDs currently in the local DB. - pub fn existing_session_ids(&self) -> std::collections::HashSet { - let conn = self.conn(); - let mut stmt = conn - .prepare("SELECT id FROM sessions") - .unwrap_or_else(|_| panic!("failed to prepare existing_session_ids query")); - let rows = stmt.query_map([], |row| row.get::<_, String>(0)); - let mut set = std::collections::HashSet::new(); - if let Ok(rows) = rows { - for row in rows.flatten() { - set.insert(row); - } - } - set - } - - /// Update only stats fields for an existing session (no git context re-extraction). - pub fn update_session_stats(&self, session: &Session) -> Result<()> { - let title = session.context.title.as_deref(); - let description = session.context.description.as_deref(); - let (files_modified, files_read, has_errors) = - opensession_core::extract::extract_file_metadata(session); - let max_active_agents = opensession_core::agent_metrics::max_active_agents(session) as i64; - let is_auxiliary = is_auxiliary_session(session); - - self.conn().execute( - "UPDATE sessions SET \ - title=?2, description=?3, \ - message_count=?4, user_message_count=?5, task_count=?6, \ - event_count=?7, duration_seconds=?8, \ - total_input_tokens=?9, total_output_tokens=?10, \ - files_modified=?11, files_read=?12, has_errors=?13, \ - max_active_agents=?14, is_auxiliary=?15 \ - WHERE id=?1", - params![ - &session.session_id, - title, - description, - session.stats.message_count as i64, - session.stats.user_message_count as i64, - session.stats.task_count as i64, - session.stats.event_count as i64, - session.stats.duration_seconds as i64, - session.stats.total_input_tokens as i64, - session.stats.total_output_tokens as i64, - &files_modified, - &files_read, - has_errors, - max_active_agents, - is_auxiliary as i64, - ], - )?; - Ok(()) - } - - /// Update only sync metadata path for an existing session. - pub fn set_session_sync_path(&self, session_id: &str, source_path: &str) -> Result<()> { - self.conn().execute( - "INSERT INTO session_sync (session_id, source_path) \ - VALUES (?1, ?2) \ - ON CONFLICT(session_id) DO UPDATE SET source_path = excluded.source_path", - params![session_id, source_path], - )?; - Ok(()) - } - - /// Get a list of distinct git repo names present in the DB. - pub fn list_repos(&self) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare( - "SELECT DISTINCT git_repo_name FROM sessions \ - WHERE git_repo_name IS NOT NULL AND COALESCE(is_auxiliary, 0) = 0 \ - ORDER BY git_repo_name ASC", - )?; - let rows = stmt.query_map([], |row| row.get(0))?; - let mut result = Vec::new(); - for row in rows { - result.push(row?); - } - Ok(result) - } - - /// Get a list of distinct, non-empty working directories present in the DB. - pub fn list_working_directories(&self) -> Result> { - let conn = self.conn(); - let mut stmt = conn.prepare( - "SELECT DISTINCT working_directory FROM sessions \ - WHERE working_directory IS NOT NULL AND TRIM(working_directory) <> '' \ - AND COALESCE(is_auxiliary, 0) = 0 \ - ORDER BY working_directory ASC", - )?; - let rows = stmt.query_map([], |row| row.get(0))?; - let mut result = Vec::new(); - for row in rows { - result.push(row?); - } - Ok(result) - } -} - -// ── Schema bootstrap ────────────────────────────────────────────────── - -fn open_connection_with_latest_schema(path: &PathBuf) -> Result { - let conn = Connection::open(path).with_context(|| format!("open db {}", path.display()))?; - conn.execute_batch("PRAGMA journal_mode=WAL;")?; - - // Disable FK constraints for local DB (index/cache, not source of truth) - conn.execute_batch("PRAGMA foreign_keys=OFF;")?; - - apply_local_migrations(&conn)?; - repair_session_tools_from_source_path(&conn)?; - repair_auxiliary_flags_from_source_path(&conn)?; - validate_local_schema(&conn)?; - - Ok(conn) -} - -fn apply_local_migrations(conn: &Connection) -> Result<()> { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS _migrations ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) - );", - ) - .context("create _migrations table for local db")?; - - for (name, sql) in MIGRATIONS.iter().chain(LOCAL_MIGRATIONS.iter()) { - let already_applied: bool = conn - .query_row( - "SELECT COUNT(*) > 0 FROM _migrations WHERE name = ?1", - [name], - |row| row.get(0), - ) - .unwrap_or(false); - - if already_applied { - continue; - } - - conn.execute_batch(sql) - .with_context(|| format!("apply local migration {name}"))?; - - conn.execute( - "INSERT OR IGNORE INTO _migrations (name) VALUES (?1)", - [name], - ) - .with_context(|| format!("record local migration {name}"))?; - } - - Ok(()) -} - -fn validate_local_schema(conn: &Connection) -> Result<()> { - let sql = format!("SELECT {LOCAL_SESSION_COLUMNS} {FROM_CLAUSE} WHERE 1=0"); - conn.prepare(&sql) - .map(|_| ()) - .context("validate local session schema") -} - -fn repair_session_tools_from_source_path(conn: &Connection) -> Result<()> { - let mut stmt = conn.prepare( - "SELECT s.id, s.tool, ss.source_path \ - FROM sessions s \ - LEFT JOIN session_sync ss ON ss.session_id = s.id \ - WHERE ss.source_path IS NOT NULL", - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - )) - })?; - - let mut updates: Vec<(String, String)> = Vec::new(); - for row in rows { - let (id, current_tool, source_path) = row?; - let normalized = normalize_tool_for_source_path(¤t_tool, source_path.as_deref()); - if normalized != current_tool { - updates.push((id, normalized)); - } - } - drop(stmt); - - for (id, tool) in updates { - conn.execute( - "UPDATE sessions SET tool = ?1 WHERE id = ?2", - params![tool, id], - )?; - } - - Ok(()) -} - -fn repair_auxiliary_flags_from_source_path(conn: &Connection) -> Result<()> { - let mut stmt = conn.prepare( - "SELECT s.id, ss.source_path \ - FROM sessions s \ - LEFT JOIN session_sync ss ON ss.session_id = s.id \ - WHERE ss.source_path IS NOT NULL \ - AND COALESCE(s.is_auxiliary, 0) = 0", - )?; - let rows = stmt.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) - })?; - - let mut updates: Vec = Vec::new(); - for row in rows { - let (id, source_path) = row?; - let Some(source_path) = source_path else { - continue; - }; - if infer_tool_from_source_path(Some(&source_path)) != Some("codex") { - continue; - } - if is_codex_auxiliary_source_file(&source_path) { - updates.push(id); - } - } - drop(stmt); - - for id in updates { - conn.execute( - "UPDATE sessions SET is_auxiliary = 1 WHERE id = ?1", - params![id], - )?; - } - - Ok(()) -} - -fn is_codex_auxiliary_source_file(source_path: &str) -> bool { - let Ok(file) = fs::File::open(source_path) else { - return false; - }; - let reader = BufReader::new(file); - for line in reader.lines().take(32) { - let Ok(raw) = line else { - continue; - }; - let line = raw.trim(); - if line.is_empty() { - continue; - } - - if line.contains("\"source\":{\"subagent\"") - || line.contains("\"source\": {\"subagent\"") - || line.contains("\"agent_role\":\"awaiter\"") - || line.contains("\"agent_role\":\"worker\"") - || line.contains("\"agent_role\":\"explorer\"") - || line.contains("\"agent_role\":\"subagent\"") - { - return true; - } - - if let Ok(parsed) = serde_json::from_str::(line) { - let is_session_meta = - parsed.get("type").and_then(|v| v.as_str()) == Some("session_meta"); - let payload = if is_session_meta { - parsed.get("payload") - } else { - Some(&parsed) - }; - if let Some(payload) = payload { - if payload.pointer("/source/subagent").is_some() { - return true; - } - let role = payload - .get("agent_role") - .and_then(|v| v.as_str()) - .map(str::to_ascii_lowercase); - if matches!( - role.as_deref(), - Some("awaiter") | Some("worker") | Some("explorer") | Some("subagent") - ) { - return true; - } - } - } - } - false -} - -/// Column list for SELECT queries against sessions + session_sync + users. -pub const LOCAL_SESSION_COLUMNS: &str = "\ -s.id, ss.source_path, COALESCE(ss.sync_status, 'unknown') AS sync_status, ss.last_synced_at, \ -s.user_id, u.nickname, s.team_id, s.tool, s.agent_provider, s.agent_model, \ -s.title, s.description, s.tags, s.created_at, s.uploaded_at, \ -s.message_count, COALESCE(s.user_message_count, 0), s.task_count, s.event_count, s.duration_seconds, \ -s.total_input_tokens, s.total_output_tokens, \ -s.git_remote, s.git_branch, s.git_commit, s.git_repo_name, \ -s.pr_number, s.pr_url, s.working_directory, \ -s.files_modified, s.files_read, s.has_errors, COALESCE(s.max_active_agents, 1), COALESCE(s.is_auxiliary, 0)"; - -fn row_to_local_session(row: &rusqlite::Row) -> rusqlite::Result { - let source_path: Option = row.get(1)?; - let tool: String = row.get(7)?; - let normalized_tool = normalize_tool_for_source_path(&tool, source_path.as_deref()); - - Ok(LocalSessionRow { - id: row.get(0)?, - source_path, - sync_status: row.get(2)?, - last_synced_at: row.get(3)?, - user_id: row.get(4)?, - nickname: row.get(5)?, - team_id: row.get(6)?, - tool: normalized_tool, - agent_provider: row.get(8)?, - agent_model: row.get(9)?, - title: row.get(10)?, - description: row.get(11)?, - tags: row.get(12)?, - created_at: row.get(13)?, - uploaded_at: row.get(14)?, - message_count: row.get(15)?, - user_message_count: row.get(16)?, - task_count: row.get(17)?, - event_count: row.get(18)?, - duration_seconds: row.get(19)?, - total_input_tokens: row.get(20)?, - total_output_tokens: row.get(21)?, - git_remote: row.get(22)?, - git_branch: row.get(23)?, - git_commit: row.get(24)?, - git_repo_name: row.get(25)?, - pr_number: row.get(26)?, - pr_url: row.get(27)?, - working_directory: row.get(28)?, - files_modified: row.get(29)?, - files_read: row.get(30)?, - has_errors: row.get::<_, i64>(31).unwrap_or(0) != 0, - max_active_agents: row.get(32).unwrap_or(1), - is_auxiliary: row.get::<_, i64>(33).unwrap_or(0) != 0, - }) -} - -fn default_db_path() -> Result { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .context("Could not determine home directory")?; - Ok(PathBuf::from(home) - .join(".local") - .join("share") - .join("opensession") - .join("local.db")) -} +#[cfg(test)] +use vector_store::build_fts_query; #[cfg(test)] mod tests { @@ -2373,10 +431,11 @@ mod tests { &crate::git::GitContext::default(), ) .expect("seed populated row"); - assert!(db - .get_session_by_id("empty-signal-replace") - .unwrap() - .is_some()); + assert!( + db.get_session_by_id("empty-signal-replace") + .unwrap() + .is_some() + ); let empty = Session::new( "empty-signal-replace".to_string(), @@ -2518,9 +577,11 @@ mod tests { let paths = db .list_session_source_paths() .expect("list source paths should work"); - assert!(paths - .iter() - .any(|(id, path)| id == "source-path-1" && path == "/tmp/source-path-1.jsonl")); + assert!( + paths + .iter() + .any(|(id, path)| id == "source-path-1" && path == "/tmp/source-path-1.jsonl") + ); assert!(paths.iter().all(|(id, _)| id != "source-path-2")); } @@ -3101,10 +1162,11 @@ mod tests { assert_eq!(row.id, "s4"); let row = db.get_session_by_tool_offset("gemini", 0).unwrap().unwrap(); assert_eq!(row.id, "s3"); - assert!(db - .get_session_by_tool_offset("gemini", 1) - .unwrap() - .is_none()); + assert!( + db.get_session_by_tool_offset("gemini", 1) + .unwrap() + .is_none() + ); } #[test] @@ -3268,14 +1330,16 @@ mod tests { .delete_expired_session_summaries(30) .expect("delete expired summaries"); assert_eq!(deleted, 1); - assert!(db - .get_session_semantic_summary("s1") - .expect("query old summary") - .is_none()); - assert!(db - .get_session_semantic_summary("s2") - .expect("query new summary") - .is_some()); + assert!( + db.get_session_semantic_summary("s1") + .expect("query old summary") + .is_none() + ); + assert!( + db.get_session_semantic_summary("s2") + .expect("query new summary") + .is_some() + ); } #[test] diff --git a/crates/local-db/src/migrations.rs b/crates/local-db/src/migrations.rs new file mode 100644 index 00000000..c64f6e81 --- /dev/null +++ b/crates/local-db/src/migrations.rs @@ -0,0 +1,176 @@ +use anyhow::{Context, Result}; +use opensession_api::db::migrations::{LOCAL_MIGRATIONS, MIGRATIONS}; +use rusqlite::{Connection, params}; +use std::fs; +use std::io::{BufRead, BufReader}; + +use crate::session_store::{ + FROM_CLAUSE, LOCAL_SESSION_COLUMNS, infer_tool_from_source_path, normalize_tool_for_source_path, +}; + +pub(crate) fn apply_local_migrations(conn: &Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS _migrations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ) + .context("create _migrations table for local db")?; + + for (name, sql) in MIGRATIONS.iter().chain(LOCAL_MIGRATIONS.iter()) { + let already_applied: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM _migrations WHERE name = ?1", + [name], + |row| row.get(0), + ) + .unwrap_or(false); + + if already_applied { + continue; + } + + conn.execute_batch(sql) + .with_context(|| format!("apply local migration {name}"))?; + + conn.execute( + "INSERT OR IGNORE INTO _migrations (name) VALUES (?1)", + [name], + ) + .with_context(|| format!("record local migration {name}"))?; + } + + Ok(()) +} + +pub(crate) fn validate_local_schema(conn: &Connection) -> Result<()> { + let sql = format!("SELECT {LOCAL_SESSION_COLUMNS} {FROM_CLAUSE} WHERE 1=0"); + conn.prepare(&sql) + .map(|_| ()) + .context("validate local session schema") +} + +pub(crate) fn repair_session_tools_from_source_path(conn: &Connection) -> Result<()> { + let mut stmt = conn.prepare( + "SELECT s.id, s.tool, ss.source_path \ + FROM sessions s \ + LEFT JOIN session_sync ss ON ss.session_id = s.id \ + WHERE ss.source_path IS NOT NULL", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + + let mut updates: Vec<(String, String)> = Vec::new(); + for row in rows { + let (id, current_tool, source_path) = row?; + let normalized = normalize_tool_for_source_path(¤t_tool, source_path.as_deref()); + if normalized != current_tool { + updates.push((id, normalized)); + } + } + drop(stmt); + + for (id, tool) in updates { + conn.execute( + "UPDATE sessions SET tool = ?1 WHERE id = ?2", + params![tool, id], + )?; + } + + Ok(()) +} + +pub(crate) fn repair_auxiliary_flags_from_source_path(conn: &Connection) -> Result<()> { + let mut stmt = conn.prepare( + "SELECT s.id, ss.source_path \ + FROM sessions s \ + LEFT JOIN session_sync ss ON ss.session_id = s.id \ + WHERE ss.source_path IS NOT NULL \ + AND COALESCE(s.is_auxiliary, 0) = 0", + )?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) + })?; + + let mut updates: Vec = Vec::new(); + for row in rows { + let (id, source_path) = row?; + let Some(source_path) = source_path else { + continue; + }; + if infer_tool_from_source_path(Some(&source_path)) != Some("codex") { + continue; + } + if is_codex_auxiliary_source_file(&source_path) { + updates.push(id); + } + } + drop(stmt); + + for id in updates { + conn.execute( + "UPDATE sessions SET is_auxiliary = 1 WHERE id = ?1", + params![id], + )?; + } + + Ok(()) +} + +fn is_codex_auxiliary_source_file(source_path: &str) -> bool { + let Ok(file) = fs::File::open(source_path) else { + return false; + }; + let reader = BufReader::new(file); + for line in reader.lines().take(32) { + let Ok(raw) = line else { + continue; + }; + let line = raw.trim(); + if line.is_empty() { + continue; + } + + if line.contains("\"source\":{\"subagent\"") + || line.contains("\"source\": {\"subagent\"") + || line.contains("\"agent_role\":\"awaiter\"") + || line.contains("\"agent_role\":\"worker\"") + || line.contains("\"agent_role\":\"explorer\"") + || line.contains("\"agent_role\":\"subagent\"") + { + return true; + } + + if let Ok(parsed) = serde_json::from_str::(line) { + let is_session_meta = + parsed.get("type").and_then(|v| v.as_str()) == Some("session_meta"); + let payload = if is_session_meta { + parsed.get("payload") + } else { + Some(&parsed) + }; + if let Some(payload) = payload { + if payload.pointer("/source/subagent").is_some() { + return true; + } + let role = payload + .get("agent_role") + .and_then(|v| v.as_str()) + .map(str::to_ascii_lowercase); + if matches!( + role.as_deref(), + Some("awaiter") | Some("worker") | Some("explorer") | Some("subagent") + ) { + return true; + } + } + } + } + false +} diff --git a/crates/local-db/src/repo_store.rs b/crates/local-db/src/repo_store.rs new file mode 100644 index 00000000..8069c1a1 --- /dev/null +++ b/crates/local-db/src/repo_store.rs @@ -0,0 +1,68 @@ +use anyhow::Result; + +use crate::connection::LocalDb; +use crate::session_store::{ + FROM_CLAUSE, LOCAL_SESSION_COLUMNS, LocalSessionRow, row_to_local_session, +}; + +impl LocalDb { + /// Find the most recently active session for a given repo path. + /// "Active" means the session's working_directory matches the repo path + /// and was created within the last `since_minutes` minutes. + pub fn find_active_session_for_repo( + &self, + repo_path: &str, + since_minutes: u32, + ) -> Result> { + let sql = format!( + "SELECT {LOCAL_SESSION_COLUMNS} \ + {FROM_CLAUSE} \ + WHERE s.working_directory LIKE ?1 \ + AND COALESCE(s.is_auxiliary, 0) = 0 \ + AND s.created_at >= datetime('now', ?2) \ + ORDER BY s.created_at DESC LIMIT 1" + ); + let since = format!("-{since_minutes} minutes"); + let like = format!("{repo_path}%"); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let row = stmt + .query_map(rusqlite::params![like, since], row_to_local_session)? + .next() + .transpose()?; + Ok(row) + } + + /// Get a list of distinct git repo names present in the DB. + pub fn list_repos(&self) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT DISTINCT git_repo_name FROM sessions \ + WHERE git_repo_name IS NOT NULL AND COALESCE(is_auxiliary, 0) = 0 \ + ORDER BY git_repo_name ASC", + )?; + let rows = stmt.query_map([], |row| row.get(0))?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) + } + + /// Get a list of distinct, non-empty working directories present in the DB. + pub fn list_working_directories(&self) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT DISTINCT working_directory FROM sessions \ + WHERE working_directory IS NOT NULL AND TRIM(working_directory) <> '' \ + AND COALESCE(is_auxiliary, 0) = 0 \ + ORDER BY working_directory ASC", + )?; + let rows = stmt.query_map([], |row| row.get(0))?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) + } +} diff --git a/crates/local-db/src/session_store.rs b/crates/local-db/src/session_store.rs new file mode 100644 index 00000000..d84937de --- /dev/null +++ b/crates/local-db/src/session_store.rs @@ -0,0 +1,978 @@ +use anyhow::Result; +use opensession_core::session::{is_auxiliary_session, working_directory}; +use opensession_core::trace::Session; +use rusqlite::params; +use serde_json::Value; +use std::collections::HashSet; + +use crate::connection::LocalDb; +use crate::git::{GitContext, normalize_repo_name}; + +pub(crate) const SUMMARY_WORKER_TITLE_PREFIX_LOWER: &str = + "convert a real coding session into semantic compression."; + +/// A local session row stored in the local SQLite index/cache database. +#[derive(Debug, Clone)] +pub struct LocalSessionRow { + pub id: String, + pub source_path: Option, + pub sync_status: String, + pub last_synced_at: Option, + pub user_id: Option, + pub nickname: Option, + pub team_id: Option, + pub tool: String, + pub agent_provider: Option, + pub agent_model: Option, + pub title: Option, + pub description: Option, + pub tags: Option, + pub created_at: String, + pub uploaded_at: Option, + pub message_count: i64, + pub user_message_count: i64, + pub task_count: i64, + pub event_count: i64, + pub duration_seconds: i64, + pub total_input_tokens: i64, + pub total_output_tokens: i64, + pub git_remote: Option, + pub git_branch: Option, + pub git_commit: Option, + pub git_repo_name: Option, + pub pr_number: Option, + pub pr_url: Option, + pub working_directory: Option, + pub files_modified: Option, + pub files_read: Option, + pub has_errors: bool, + pub max_active_agents: i64, + pub is_auxiliary: bool, +} + +/// A lightweight local link row for session-to-session relationships. +#[derive(Debug, Clone)] +pub struct LocalSessionLink { + pub session_id: String, + pub linked_session_id: String, + pub link_type: String, + pub created_at: String, +} + +pub(crate) fn infer_tool_from_source_path(source_path: Option<&str>) -> Option<&'static str> { + let source_path = source_path.map(|path| path.to_ascii_lowercase())?; + + if source_path.contains("/.codex/sessions/") + || source_path.contains("\\.codex\\sessions\\") + || source_path.contains("/codex/sessions/") + || source_path.contains("\\codex\\sessions\\") + { + return Some("codex"); + } + + if source_path.contains("/.claude/projects/") + || source_path.contains("\\.claude\\projects\\") + || source_path.contains("/claude/projects/") + || source_path.contains("\\claude\\projects\\") + { + return Some("claude-code"); + } + + None +} + +pub(crate) fn normalize_tool_for_source_path( + current_tool: &str, + source_path: Option<&str>, +) -> String { + infer_tool_from_source_path(source_path) + .unwrap_or(current_tool) + .to_string() +} + +fn normalize_non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn json_object_string(value: &Value, keys: &[&str]) -> Option { + let obj = value.as_object()?; + for key in keys { + if let Some(found) = obj.get(*key).and_then(Value::as_str) { + let normalized = found.trim(); + if !normalized.is_empty() { + return Some(normalized.to_string()); + } + } + } + None +} + +fn git_context_from_session_attributes(session: &Session) -> GitContext { + let attrs = &session.context.attributes; + + let mut remote = normalize_non_empty(attrs.get("git_remote").and_then(Value::as_str)); + let mut branch = normalize_non_empty(attrs.get("git_branch").and_then(Value::as_str)); + let mut commit = normalize_non_empty(attrs.get("git_commit").and_then(Value::as_str)); + let mut repo_name = normalize_non_empty(attrs.get("git_repo_name").and_then(Value::as_str)); + + if let Some(git_value) = attrs.get("git") { + if remote.is_none() { + remote = json_object_string( + git_value, + &["remote", "repository_url", "repo_url", "origin", "url"], + ); + } + if branch.is_none() { + branch = json_object_string( + git_value, + &["branch", "git_branch", "current_branch", "ref", "head"], + ); + } + if commit.is_none() { + commit = json_object_string(git_value, &["commit", "commit_hash", "sha", "git_commit"]); + } + if repo_name.is_none() { + repo_name = json_object_string(git_value, &["repo_name", "repository", "repo", "name"]); + } + } + + if repo_name.is_none() { + repo_name = remote + .as_deref() + .and_then(normalize_repo_name) + .map(ToOwned::to_owned); + } + + GitContext { + remote, + branch, + commit, + repo_name, + } +} + +fn git_context_has_any_field(git: &GitContext) -> bool { + git.remote.is_some() || git.branch.is_some() || git.commit.is_some() || git.repo_name.is_some() +} + +fn merge_git_context(preferred: &GitContext, fallback: &GitContext) -> GitContext { + GitContext { + remote: preferred.remote.clone().or_else(|| fallback.remote.clone()), + branch: preferred.branch.clone().or_else(|| fallback.branch.clone()), + commit: preferred.commit.clone().or_else(|| fallback.commit.clone()), + repo_name: preferred + .repo_name + .clone() + .or_else(|| fallback.repo_name.clone()), + } +} + +/// Filter for listing sessions from the local DB. +#[derive(Debug, Clone)] +pub struct LocalSessionFilter { + pub team_id: Option, + pub sync_status: Option, + pub git_repo_name: Option, + pub search: Option, + pub exclude_low_signal: bool, + pub tool: Option, + pub sort: LocalSortOrder, + pub time_range: LocalTimeRange, + pub limit: Option, + pub offset: Option, +} + +impl Default for LocalSessionFilter { + fn default() -> Self { + Self { + team_id: None, + sync_status: None, + git_repo_name: None, + search: None, + exclude_low_signal: false, + tool: None, + sort: LocalSortOrder::Recent, + time_range: LocalTimeRange::All, + limit: None, + offset: None, + } + } +} + +/// Sort order for local session listing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum LocalSortOrder { + #[default] + Recent, + Popular, + Longest, +} + +/// Time range filter for local session listing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum LocalTimeRange { + Hours24, + Days7, + Days30, + #[default] + All, +} + +/// Minimal remote session payload needed for local index/cache upsert. +#[derive(Debug, Clone)] +pub struct RemoteSessionSummary { + pub id: String, + pub user_id: Option, + pub nickname: Option, + pub team_id: String, + pub tool: String, + pub agent_provider: Option, + pub agent_model: Option, + pub title: Option, + pub description: Option, + pub tags: Option, + pub created_at: String, + pub uploaded_at: String, + pub message_count: i64, + pub task_count: i64, + pub event_count: i64, + pub duration_seconds: i64, + pub total_input_tokens: i64, + pub total_output_tokens: i64, + pub git_remote: Option, + pub git_branch: Option, + pub git_commit: Option, + pub git_repo_name: Option, + pub pr_number: Option, + pub pr_url: Option, + pub working_directory: Option, + pub files_modified: Option, + pub files_read: Option, + pub has_errors: bool, + pub max_active_agents: i64, +} + +/// Extended filter for the `log` command. +#[derive(Debug, Default)] +pub struct LogFilter { + /// Filter by tool name (exact match). + pub tool: Option, + /// Filter by model (glob-like, uses LIKE). + pub model: Option, + /// Filter sessions created after this ISO8601 timestamp. + pub since: Option, + /// Filter sessions created before this ISO8601 timestamp. + pub before: Option, + /// Filter sessions that touched this file path (searches files_modified JSON). + pub touches: Option, + /// Free-text search in title, description, tags. + pub grep: Option, + /// Only sessions with errors. + pub has_errors: Option, + /// Filter by working directory (prefix match). + pub working_directory: Option, + /// Filter by git repo name. + pub git_repo_name: Option, + /// Maximum number of results. + pub limit: Option, + /// Offset for pagination. + pub offset: Option, +} + +/// Base FROM clause for session list queries. +pub(crate) const FROM_CLAUSE: &str = "\ +FROM sessions s \ +LEFT JOIN session_sync ss ON ss.session_id = s.id \ +LEFT JOIN users u ON u.id = s.user_id"; + +pub(crate) const LOCAL_SESSION_COLUMNS: &str = "\ +s.id, ss.source_path, COALESCE(ss.sync_status, 'unknown') AS sync_status, ss.last_synced_at, \ +s.user_id, u.nickname, s.team_id, s.tool, s.agent_provider, s.agent_model, \ +s.title, s.description, s.tags, s.created_at, s.uploaded_at, \ +s.message_count, COALESCE(s.user_message_count, 0), s.task_count, s.event_count, s.duration_seconds, \ +s.total_input_tokens, s.total_output_tokens, \ +s.git_remote, s.git_branch, s.git_commit, s.git_repo_name, \ +s.pr_number, s.pr_url, s.working_directory, \ +s.files_modified, s.files_read, s.has_errors, COALESCE(s.max_active_agents, 1), COALESCE(s.is_auxiliary, 0)"; + +pub(crate) fn row_to_local_session(row: &rusqlite::Row) -> rusqlite::Result { + let source_path: Option = row.get(1)?; + let tool: String = row.get(7)?; + let normalized_tool = normalize_tool_for_source_path(&tool, source_path.as_deref()); + + Ok(LocalSessionRow { + id: row.get(0)?, + source_path, + sync_status: row.get(2)?, + last_synced_at: row.get(3)?, + user_id: row.get(4)?, + nickname: row.get(5)?, + team_id: row.get(6)?, + tool: normalized_tool, + agent_provider: row.get(8)?, + agent_model: row.get(9)?, + title: row.get(10)?, + description: row.get(11)?, + tags: row.get(12)?, + created_at: row.get(13)?, + uploaded_at: row.get(14)?, + message_count: row.get(15)?, + user_message_count: row.get(16)?, + task_count: row.get(17)?, + event_count: row.get(18)?, + duration_seconds: row.get(19)?, + total_input_tokens: row.get(20)?, + total_output_tokens: row.get(21)?, + git_remote: row.get(22)?, + git_branch: row.get(23)?, + git_commit: row.get(24)?, + git_repo_name: row.get(25)?, + pr_number: row.get(26)?, + pr_url: row.get(27)?, + working_directory: row.get(28)?, + files_modified: row.get(29)?, + files_read: row.get(30)?, + has_errors: row.get::<_, i64>(31).unwrap_or(0) != 0, + max_active_agents: row.get(32).unwrap_or(1), + is_auxiliary: row.get::<_, i64>(33).unwrap_or(0) != 0, + }) +} + +impl LocalDb { + pub(crate) fn build_local_session_where_clause( + filter: &LocalSessionFilter, + ) -> (String, Vec>) { + let mut where_clauses = vec![ + "1=1".to_string(), + "COALESCE(s.is_auxiliary, 0) = 0".to_string(), + format!( + "NOT (LOWER(COALESCE(s.tool, '')) = 'codex' \ + AND LOWER(COALESCE(s.title, '')) LIKE '{}%')", + SUMMARY_WORKER_TITLE_PREFIX_LOWER + ), + ]; + let mut param_values: Vec> = Vec::new(); + let mut idx = 1u32; + + if let Some(ref team_id) = filter.team_id { + where_clauses.push(format!("s.team_id = ?{idx}")); + param_values.push(Box::new(team_id.clone())); + idx += 1; + } + + if let Some(ref sync_status) = filter.sync_status { + where_clauses.push(format!("COALESCE(ss.sync_status, 'unknown') = ?{idx}")); + param_values.push(Box::new(sync_status.clone())); + idx += 1; + } + + if let Some(ref repo) = filter.git_repo_name { + where_clauses.push(format!("s.git_repo_name = ?{idx}")); + param_values.push(Box::new(repo.clone())); + idx += 1; + } + + if let Some(ref tool) = filter.tool { + where_clauses.push(format!("s.tool = ?{idx}")); + param_values.push(Box::new(tool.clone())); + idx += 1; + } + + if let Some(ref search) = filter.search { + let like = format!("%{search}%"); + where_clauses.push(format!( + "(s.title LIKE ?{i1} OR s.description LIKE ?{i2} OR s.tags LIKE ?{i3})", + i1 = idx, + i2 = idx + 1, + i3 = idx + 2, + )); + param_values.push(Box::new(like.clone())); + param_values.push(Box::new(like.clone())); + param_values.push(Box::new(like)); + idx += 3; + } + + if filter.exclude_low_signal { + where_clauses.push( + "NOT (COALESCE(s.message_count, 0) = 0 \ + AND COALESCE(s.user_message_count, 0) = 0 \ + AND COALESCE(s.task_count, 0) = 0 \ + AND COALESCE(s.event_count, 0) <= 2 \ + AND (s.title IS NULL OR TRIM(s.title) = ''))" + .to_string(), + ); + } + + let interval = match filter.time_range { + LocalTimeRange::Hours24 => Some("-1 day"), + LocalTimeRange::Days7 => Some("-7 days"), + LocalTimeRange::Days30 => Some("-30 days"), + LocalTimeRange::All => None, + }; + if let Some(interval) = interval { + where_clauses.push(format!("datetime(s.created_at) >= datetime('now', ?{idx})")); + param_values.push(Box::new(interval.to_string())); + } + + (where_clauses.join(" AND "), param_values) + } + + pub fn upsert_local_session( + &self, + session: &Session, + source_path: &str, + git: &GitContext, + ) -> Result<()> { + let is_empty_signal = session.stats.event_count == 0 + && session.stats.message_count == 0 + && session.stats.user_message_count == 0 + && session.stats.task_count == 0; + if is_empty_signal { + self.delete_session(&session.session_id)?; + return Ok(()); + } + + let title = session.context.title.as_deref(); + let description = session.context.description.as_deref(); + let tags = if session.context.tags.is_empty() { + None + } else { + Some(session.context.tags.join(",")) + }; + let created_at = session.context.created_at.to_rfc3339(); + let cwd = working_directory(session).map(String::from); + let is_auxiliary = is_auxiliary_session(session); + + let (files_modified, files_read, has_errors) = + opensession_core::extract::extract_file_metadata(session); + let max_active_agents = opensession_core::agent_metrics::max_active_agents(session) as i64; + let normalized_tool = + normalize_tool_for_source_path(&session.agent.tool, Some(source_path)); + let git_from_session = git_context_from_session_attributes(session); + let has_session_git = git_context_has_any_field(&git_from_session); + let merged_git = merge_git_context(&git_from_session, git); + + let conn = self.conn(); + conn.execute( + "INSERT INTO sessions \ + (id, team_id, tool, agent_provider, agent_model, \ + title, description, tags, created_at, \ + message_count, user_message_count, task_count, event_count, duration_seconds, \ + total_input_tokens, total_output_tokens, body_storage_key, \ + git_remote, git_branch, git_commit, git_repo_name, working_directory, \ + files_modified, files_read, has_errors, max_active_agents, is_auxiliary) \ + VALUES (?1,'personal',?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,'',?16,?17,?18,?19,?20,?21,?22,?23,?24,?25) \ + ON CONFLICT(id) DO UPDATE SET \ + tool=excluded.tool, agent_provider=excluded.agent_provider, \ + agent_model=excluded.agent_model, \ + title=excluded.title, description=excluded.description, \ + tags=excluded.tags, \ + message_count=excluded.message_count, user_message_count=excluded.user_message_count, \ + task_count=excluded.task_count, \ + event_count=excluded.event_count, duration_seconds=excluded.duration_seconds, \ + total_input_tokens=excluded.total_input_tokens, \ + total_output_tokens=excluded.total_output_tokens, \ + git_remote=CASE WHEN ?26=1 THEN excluded.git_remote ELSE COALESCE(git_remote, excluded.git_remote) END, \ + git_branch=CASE WHEN ?26=1 THEN excluded.git_branch ELSE COALESCE(git_branch, excluded.git_branch) END, \ + git_commit=CASE WHEN ?26=1 THEN excluded.git_commit ELSE COALESCE(git_commit, excluded.git_commit) END, \ + git_repo_name=CASE WHEN ?26=1 THEN excluded.git_repo_name ELSE COALESCE(git_repo_name, excluded.git_repo_name) END, \ + working_directory=excluded.working_directory, \ + files_modified=excluded.files_modified, files_read=excluded.files_read, \ + has_errors=excluded.has_errors, \ + max_active_agents=excluded.max_active_agents, \ + is_auxiliary=excluded.is_auxiliary", + params![ + &session.session_id, + &normalized_tool, + &session.agent.provider, + &session.agent.model, + title, + description, + &tags, + &created_at, + session.stats.message_count as i64, + session.stats.user_message_count as i64, + session.stats.task_count as i64, + session.stats.event_count as i64, + session.stats.duration_seconds as i64, + session.stats.total_input_tokens as i64, + session.stats.total_output_tokens as i64, + &merged_git.remote, + &merged_git.branch, + &merged_git.commit, + &merged_git.repo_name, + &cwd, + &files_modified, + &files_read, + has_errors, + max_active_agents, + is_auxiliary as i64, + has_session_git as i64, + ], + )?; + + conn.execute( + "INSERT INTO session_sync (session_id, source_path, sync_status) \ + VALUES (?1, ?2, 'local_only') \ + ON CONFLICT(session_id) DO UPDATE SET source_path=excluded.source_path", + params![&session.session_id, source_path], + )?; + Ok(()) + } + + pub fn upsert_remote_session(&self, summary: &RemoteSessionSummary) -> Result<()> { + let conn = self.conn(); + conn.execute( + "INSERT INTO sessions \ + (id, user_id, team_id, tool, agent_provider, agent_model, \ + title, description, tags, created_at, uploaded_at, \ + message_count, task_count, event_count, duration_seconds, \ + total_input_tokens, total_output_tokens, body_storage_key, \ + git_remote, git_branch, git_commit, git_repo_name, \ + pr_number, pr_url, working_directory, \ + files_modified, files_read, has_errors, max_active_agents, is_auxiliary) \ + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,'',?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,0) \ + ON CONFLICT(id) DO UPDATE SET \ + title=excluded.title, description=excluded.description, \ + tags=excluded.tags, uploaded_at=excluded.uploaded_at, \ + message_count=excluded.message_count, task_count=excluded.task_count, \ + event_count=excluded.event_count, duration_seconds=excluded.duration_seconds, \ + total_input_tokens=excluded.total_input_tokens, \ + total_output_tokens=excluded.total_output_tokens, \ + git_remote=excluded.git_remote, git_branch=excluded.git_branch, \ + git_commit=excluded.git_commit, git_repo_name=excluded.git_repo_name, \ + pr_number=excluded.pr_number, pr_url=excluded.pr_url, \ + working_directory=excluded.working_directory, \ + files_modified=excluded.files_modified, files_read=excluded.files_read, \ + has_errors=excluded.has_errors, \ + max_active_agents=excluded.max_active_agents, \ + is_auxiliary=excluded.is_auxiliary", + params![ + &summary.id, + &summary.user_id, + &summary.team_id, + &summary.tool, + &summary.agent_provider, + &summary.agent_model, + &summary.title, + &summary.description, + &summary.tags, + &summary.created_at, + &summary.uploaded_at, + summary.message_count, + summary.task_count, + summary.event_count, + summary.duration_seconds, + summary.total_input_tokens, + summary.total_output_tokens, + &summary.git_remote, + &summary.git_branch, + &summary.git_commit, + &summary.git_repo_name, + summary.pr_number, + &summary.pr_url, + &summary.working_directory, + &summary.files_modified, + &summary.files_read, + summary.has_errors, + summary.max_active_agents, + ], + )?; + + conn.execute( + "INSERT INTO session_sync (session_id, sync_status) \ + VALUES (?1, 'remote_only') \ + ON CONFLICT(session_id) DO UPDATE SET \ + sync_status = CASE WHEN session_sync.sync_status = 'local_only' THEN 'synced' ELSE session_sync.sync_status END", + params![&summary.id], + )?; + Ok(()) + } + + pub fn list_sessions(&self, filter: &LocalSessionFilter) -> Result> { + let (where_str, mut param_values) = Self::build_local_session_where_clause(filter); + let order_clause = match filter.sort { + LocalSortOrder::Popular => "s.message_count DESC, s.created_at DESC", + LocalSortOrder::Longest => "s.duration_seconds DESC, s.created_at DESC", + LocalSortOrder::Recent => "s.created_at DESC", + }; + + let mut sql = format!( + "SELECT {LOCAL_SESSION_COLUMNS} \ + {FROM_CLAUSE} WHERE {where_str} \ + ORDER BY {order_clause}" + ); + + if let Some(limit) = filter.limit { + sql.push_str(" LIMIT ?"); + param_values.push(Box::new(limit)); + if let Some(offset) = filter.offset { + sql.push_str(" OFFSET ?"); + param_values.push(Box::new(offset)); + } + } + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(param_refs.as_slice(), row_to_local_session)?; + + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + + Ok(result) + } + + pub fn count_sessions_filtered(&self, filter: &LocalSessionFilter) -> Result { + let mut count_filter = filter.clone(); + count_filter.limit = None; + count_filter.offset = None; + let (where_str, param_values) = Self::build_local_session_where_clause(&count_filter); + let sql = format!("SELECT COUNT(*) {FROM_CLAUSE} WHERE {where_str}"); + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let conn = self.conn(); + let count = conn.query_row(&sql, param_refs.as_slice(), |row| row.get(0))?; + Ok(count) + } + + pub fn list_session_tools(&self, filter: &LocalSessionFilter) -> Result> { + let mut tool_filter = filter.clone(); + tool_filter.tool = None; + tool_filter.limit = None; + tool_filter.offset = None; + let (where_str, param_values) = Self::build_local_session_where_clause(&tool_filter); + let sql = format!( + "SELECT DISTINCT s.tool \ + {FROM_CLAUSE} WHERE {where_str} \ + ORDER BY s.tool ASC" + ); + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))?; + + let mut tools = Vec::new(); + for row in rows { + let tool = row?; + if !tool.trim().is_empty() { + tools.push(tool); + } + } + Ok(tools) + } + + pub fn list_sessions_log(&self, filter: &LogFilter) -> Result> { + let mut where_clauses = vec![ + "1=1".to_string(), + "COALESCE(s.is_auxiliary, 0) = 0".to_string(), + format!( + "NOT (LOWER(COALESCE(s.tool, '')) = 'codex' \ + AND LOWER(COALESCE(s.title, '')) LIKE '{}%')", + SUMMARY_WORKER_TITLE_PREFIX_LOWER + ), + ]; + let mut param_values: Vec> = Vec::new(); + let mut idx = 1u32; + + if let Some(ref tool) = filter.tool { + where_clauses.push(format!("s.tool = ?{idx}")); + param_values.push(Box::new(tool.clone())); + idx += 1; + } + + if let Some(ref model) = filter.model { + let like = model.replace('*', "%"); + where_clauses.push(format!("s.agent_model LIKE ?{idx}")); + param_values.push(Box::new(like)); + idx += 1; + } + + if let Some(ref since) = filter.since { + where_clauses.push(format!("s.created_at >= ?{idx}")); + param_values.push(Box::new(since.clone())); + idx += 1; + } + + if let Some(ref before) = filter.before { + where_clauses.push(format!("s.created_at < ?{idx}")); + param_values.push(Box::new(before.clone())); + idx += 1; + } + + if let Some(ref touches) = filter.touches { + let like = format!("%\"{touches}\"%"); + where_clauses.push(format!("s.files_modified LIKE ?{idx}")); + param_values.push(Box::new(like)); + idx += 1; + } + + if let Some(ref grep) = filter.grep { + let like = format!("%{grep}%"); + where_clauses.push(format!( + "(s.title LIKE ?{i1} OR s.description LIKE ?{i2} OR s.tags LIKE ?{i3})", + i1 = idx, + i2 = idx + 1, + i3 = idx + 2, + )); + param_values.push(Box::new(like.clone())); + param_values.push(Box::new(like.clone())); + param_values.push(Box::new(like)); + idx += 3; + } + + if let Some(true) = filter.has_errors { + where_clauses.push("s.has_errors = 1".to_string()); + } + + if let Some(ref wd) = filter.working_directory { + where_clauses.push(format!("s.working_directory LIKE ?{idx}")); + param_values.push(Box::new(format!("{wd}%"))); + idx += 1; + } + + if let Some(ref repo) = filter.git_repo_name { + where_clauses.push(format!("s.git_repo_name = ?{idx}")); + param_values.push(Box::new(repo.clone())); + idx += 1; + } + + let _ = idx; + + let where_str = where_clauses.join(" AND "); + let mut sql = format!( + "SELECT {LOCAL_SESSION_COLUMNS} \ + {FROM_CLAUSE} WHERE {where_str} \ + ORDER BY s.created_at DESC" + ); + + if let Some(limit) = filter.limit { + sql.push_str(" LIMIT ?"); + param_values.push(Box::new(limit)); + if let Some(offset) = filter.offset { + sql.push_str(" OFFSET ?"); + param_values.push(Box::new(offset)); + } + } + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(param_refs.as_slice(), row_to_local_session)?; + + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) + } + + pub fn get_sessions_by_tool_latest( + &self, + tool: &str, + count: u32, + ) -> Result> { + let sql = format!( + "SELECT {LOCAL_SESSION_COLUMNS} \ + {FROM_CLAUSE} WHERE s.tool = ?1 AND COALESCE(s.is_auxiliary, 0) = 0 \ + ORDER BY s.created_at DESC" + ); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![tool], row_to_local_session)?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + + result.truncate(count as usize); + Ok(result) + } + + pub fn get_sessions_latest(&self, count: u32) -> Result> { + let sql = format!( + "SELECT {LOCAL_SESSION_COLUMNS} \ + {FROM_CLAUSE} WHERE COALESCE(s.is_auxiliary, 0) = 0 \ + ORDER BY s.created_at DESC" + ); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], row_to_local_session)?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + + result.truncate(count as usize); + Ok(result) + } + + pub fn get_session_by_tool_offset( + &self, + tool: &str, + offset: u32, + ) -> Result> { + let sql = format!( + "SELECT {LOCAL_SESSION_COLUMNS} \ + {FROM_CLAUSE} WHERE s.tool = ?1 AND COALESCE(s.is_auxiliary, 0) = 0 \ + ORDER BY s.created_at DESC" + ); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![tool], row_to_local_session)?; + let result = rows.collect::, _>>()?; + Ok(result.into_iter().nth(offset as usize)) + } + + pub fn get_session_by_offset(&self, offset: u32) -> Result> { + let sql = format!( + "SELECT {LOCAL_SESSION_COLUMNS} \ + {FROM_CLAUSE} WHERE COALESCE(s.is_auxiliary, 0) = 0 \ + ORDER BY s.created_at DESC" + ); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], row_to_local_session)?; + let result = rows.collect::, _>>()?; + Ok(result.into_iter().nth(offset as usize)) + } + + pub fn get_session_by_id(&self, session_id: &str) -> Result> { + let sql = format!( + "SELECT {LOCAL_SESSION_COLUMNS} \ + {FROM_CLAUSE} WHERE s.id = ?1 LIMIT 1" + ); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let row = stmt + .query_map(params![session_id], row_to_local_session)? + .next() + .transpose()?; + Ok(row) + } + + pub fn list_session_links(&self, session_id: &str) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT session_id, linked_session_id, link_type, created_at \ + FROM session_links WHERE session_id = ?1 ORDER BY created_at ASC", + )?; + let rows = stmt.query_map(params![session_id], |row| { + Ok(LocalSessionLink { + session_id: row.get(0)?, + linked_session_id: row.get(1)?, + link_type: row.get(2)?, + created_at: row.get(3)?, + }) + })?; + rows.collect::, _>>() + .map_err(Into::into) + } + + pub fn session_count(&self) -> Result { + let count = self + .conn() + .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?; + Ok(count) + } + + pub fn delete_session(&self, session_id: &str) -> Result<()> { + let conn = self.conn(); + conn.execute( + "DELETE FROM session_links WHERE session_id = ?1 OR linked_session_id = ?1", + params![session_id], + )?; + conn.execute( + "DELETE FROM vector_embeddings \ + WHERE chunk_id IN (SELECT id FROM vector_chunks WHERE session_id = ?1)", + params![session_id], + )?; + conn.execute( + "DELETE FROM vector_chunks_fts WHERE session_id = ?1", + params![session_id], + )?; + conn.execute( + "DELETE FROM vector_chunks WHERE session_id = ?1", + params![session_id], + )?; + conn.execute( + "DELETE FROM vector_index_sessions WHERE session_id = ?1", + params![session_id], + )?; + conn.execute( + "DELETE FROM session_semantic_summaries WHERE session_id = ?1", + params![session_id], + )?; + conn.execute( + "DELETE FROM body_cache WHERE session_id = ?1", + params![session_id], + )?; + conn.execute( + "DELETE FROM session_sync WHERE session_id = ?1", + params![session_id], + )?; + conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?; + Ok(()) + } + + pub fn existing_session_ids(&self) -> HashSet { + let conn = self.conn(); + let mut stmt = conn + .prepare("SELECT id FROM sessions") + .unwrap_or_else(|_| panic!("failed to prepare existing_session_ids query")); + let rows = stmt.query_map([], |row| row.get::<_, String>(0)); + let mut set = HashSet::new(); + if let Ok(rows) = rows { + for row in rows.flatten() { + set.insert(row); + } + } + set + } + + pub fn update_session_stats(&self, session: &Session) -> Result<()> { + let title = session.context.title.as_deref(); + let description = session.context.description.as_deref(); + let (files_modified, files_read, has_errors) = + opensession_core::extract::extract_file_metadata(session); + let max_active_agents = opensession_core::agent_metrics::max_active_agents(session) as i64; + let is_auxiliary = is_auxiliary_session(session); + + self.conn().execute( + "UPDATE sessions SET \ + title=?2, description=?3, \ + message_count=?4, user_message_count=?5, task_count=?6, \ + event_count=?7, duration_seconds=?8, \ + total_input_tokens=?9, total_output_tokens=?10, \ + files_modified=?11, files_read=?12, has_errors=?13, \ + max_active_agents=?14, is_auxiliary=?15 \ + WHERE id=?1", + params![ + &session.session_id, + title, + description, + session.stats.message_count as i64, + session.stats.user_message_count as i64, + session.stats.task_count as i64, + session.stats.event_count as i64, + session.stats.duration_seconds as i64, + session.stats.total_input_tokens as i64, + session.stats.total_output_tokens as i64, + &files_modified, + &files_read, + has_errors, + max_active_agents, + is_auxiliary as i64, + ], + )?; + Ok(()) + } +} diff --git a/crates/local-db/src/summary_store.rs b/crates/local-db/src/summary_store.rs new file mode 100644 index 00000000..1bbec106 --- /dev/null +++ b/crates/local-db/src/summary_store.rs @@ -0,0 +1,155 @@ +use anyhow::Result; +use rusqlite::{OptionalExtension, params}; + +use crate::connection::LocalDb; + +/// Session-level semantic summary row persisted in local SQLite. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionSemanticSummaryRow { + pub session_id: String, + pub summary_json: String, + pub generated_at: String, + pub provider: String, + pub model: Option, + pub source_kind: String, + pub generation_kind: String, + pub prompt_fingerprint: Option, + pub source_details_json: Option, + pub diff_tree_json: Option, + pub error: Option, + pub updated_at: String, +} + +/// Upsert payload for session-level semantic summaries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionSemanticSummaryUpsert<'a> { + pub session_id: &'a str, + pub summary_json: &'a str, + pub generated_at: &'a str, + pub provider: &'a str, + pub model: Option<&'a str>, + pub source_kind: &'a str, + pub generation_kind: &'a str, + pub prompt_fingerprint: Option<&'a str>, + pub source_details_json: Option<&'a str>, + pub diff_tree_json: Option<&'a str>, + pub error: Option<&'a str>, +} + +impl LocalDb { + pub fn upsert_session_semantic_summary( + &self, + payload: &SessionSemanticSummaryUpsert<'_>, + ) -> Result<()> { + self.conn().execute( + "INSERT INTO session_semantic_summaries (\ + session_id, summary_json, generated_at, provider, model, \ + source_kind, generation_kind, prompt_fingerprint, source_details_json, \ + diff_tree_json, error, updated_at\ + ) VALUES (\ + ?1, ?2, ?3, ?4, ?5, \ + ?6, ?7, ?8, ?9, \ + ?10, ?11, datetime('now')\ + ) \ + ON CONFLICT(session_id) DO UPDATE SET \ + summary_json=excluded.summary_json, \ + generated_at=excluded.generated_at, \ + provider=excluded.provider, \ + model=excluded.model, \ + source_kind=excluded.source_kind, \ + generation_kind=excluded.generation_kind, \ + prompt_fingerprint=excluded.prompt_fingerprint, \ + source_details_json=excluded.source_details_json, \ + diff_tree_json=excluded.diff_tree_json, \ + error=excluded.error, \ + updated_at=datetime('now')", + params![ + payload.session_id, + payload.summary_json, + payload.generated_at, + payload.provider, + payload.model, + payload.source_kind, + payload.generation_kind, + payload.prompt_fingerprint, + payload.source_details_json, + payload.diff_tree_json, + payload.error, + ], + )?; + Ok(()) + } + + pub fn list_expired_session_ids(&self, keep_days: u32) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT id FROM sessions \ + WHERE julianday(created_at) <= julianday('now') - ?1 \ + ORDER BY created_at ASC", + )?; + let rows = stmt.query_map(params![keep_days as i64], |row| row.get(0))?; + rows.collect::, _>>() + .map_err(Into::into) + } + + /// List all known session ids for migration or maintenance workflows. + pub fn list_all_session_ids(&self) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare("SELECT id FROM sessions ORDER BY id ASC")?; + let rows = stmt.query_map([], |row| row.get(0))?; + rows.collect::, _>>() + .map_err(Into::into) + } + + /// List all session ids that currently have cached semantic summaries. + pub fn list_session_semantic_summary_ids(&self) -> Result> { + let conn = self.conn(); + let mut stmt = conn + .prepare("SELECT session_id FROM session_semantic_summaries ORDER BY session_id ASC")?; + let rows = stmt.query_map([], |row| row.get(0))?; + rows.collect::, _>>() + .map_err(Into::into) + } + + pub fn get_session_semantic_summary( + &self, + session_id: &str, + ) -> Result> { + let row = self + .conn() + .query_row( + "SELECT session_id, summary_json, generated_at, provider, model, \ + source_kind, generation_kind, prompt_fingerprint, source_details_json, \ + diff_tree_json, error, updated_at \ + FROM session_semantic_summaries WHERE session_id = ?1 LIMIT 1", + params![session_id], + |row| { + Ok(SessionSemanticSummaryRow { + session_id: row.get(0)?, + summary_json: row.get(1)?, + generated_at: row.get(2)?, + provider: row.get(3)?, + model: row.get(4)?, + source_kind: row.get(5)?, + generation_kind: row.get(6)?, + prompt_fingerprint: row.get(7)?, + source_details_json: row.get(8)?, + diff_tree_json: row.get(9)?, + error: row.get(10)?, + updated_at: row.get(11)?, + }) + }, + ) + .optional()?; + Ok(row) + } + + pub fn delete_expired_session_summaries(&self, keep_days: u32) -> Result { + let deleted = self.conn().execute( + "DELETE FROM session_semantic_summaries \ + WHERE julianday(generated_at) <= julianday('now') - ?1", + params![keep_days as i64], + )?; + Ok(deleted as u32) + } +} diff --git a/crates/local-db/src/sync_store.rs b/crates/local-db/src/sync_store.rs new file mode 100644 index 00000000..1624cf6f --- /dev/null +++ b/crates/local-db/src/sync_store.rs @@ -0,0 +1,146 @@ +use anyhow::Result; +use rusqlite::{OptionalExtension, params}; + +use crate::connection::LocalDb; +use crate::session_store::{LOCAL_SESSION_COLUMNS, LocalSessionRow, row_to_local_session}; + +impl LocalDb { + /// Fetch the source path used when the session was last parsed/loaded. + pub fn get_session_source_path(&self, session_id: &str) -> Result> { + let conn = self.conn(); + let result = conn + .query_row( + "SELECT source_path FROM session_sync WHERE session_id = ?1", + params![session_id], + |row| row.get(0), + ) + .optional()?; + + Ok(result) + } + + /// List every session id with a non-empty source path from session_sync. + pub fn list_session_source_paths(&self) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT session_id, source_path \ + FROM session_sync \ + WHERE source_path IS NOT NULL AND TRIM(source_path) != ''", + )?; + let rows = stmt.query_map([], |row| { + let session_id: String = row.get(0)?; + let source_path: String = row.get(1)?; + Ok((session_id, source_path)) + })?; + rows.collect::, _>>() + .map_err(Into::into) + } + + pub fn get_sync_cursor(&self, team_id: &str) -> Result> { + let cursor = self + .conn() + .query_row( + "SELECT cursor FROM sync_cursors WHERE team_id = ?1", + params![team_id], + |row| row.get(0), + ) + .optional()?; + Ok(cursor) + } + + pub fn set_sync_cursor(&self, team_id: &str, cursor: &str) -> Result<()> { + self.conn().execute( + "INSERT INTO sync_cursors (team_id, cursor, updated_at) \ + VALUES (?1, ?2, datetime('now')) \ + ON CONFLICT(team_id) DO UPDATE SET cursor=excluded.cursor, updated_at=datetime('now')", + params![team_id, cursor], + )?; + Ok(()) + } + + /// Get sessions that are local_only and need to be uploaded. + pub fn pending_uploads(&self, team_id: &str) -> Result> { + let sql = format!( + "SELECT {LOCAL_SESSION_COLUMNS} \ + FROM sessions s \ + INNER JOIN session_sync ss ON ss.session_id = s.id \ + LEFT JOIN users u ON u.id = s.user_id \ + WHERE ss.sync_status = 'local_only' AND s.team_id = ?1 AND COALESCE(s.is_auxiliary, 0) = 0 \ + ORDER BY s.created_at ASC" + ); + let conn = self.conn(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![team_id], row_to_local_session)?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) + } + + pub fn mark_synced(&self, session_id: &str) -> Result<()> { + self.conn().execute( + "UPDATE session_sync SET sync_status = 'synced', last_synced_at = datetime('now') \ + WHERE session_id = ?1", + params![session_id], + )?; + Ok(()) + } + + /// Check if a session was already uploaded (synced or remote_only) since the given modification time. + pub fn was_uploaded_after( + &self, + source_path: &str, + modified: &chrono::DateTime, + ) -> Result { + let result: Option = self + .conn() + .query_row( + "SELECT last_synced_at FROM session_sync \ + WHERE source_path = ?1 AND sync_status = 'synced' AND last_synced_at IS NOT NULL", + params![source_path], + |row| row.get(0), + ) + .optional()?; + + if let Some(synced_at) = result { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&synced_at) { + return Ok(dt >= *modified); + } + } + Ok(false) + } + + pub fn cache_body(&self, session_id: &str, body: &[u8]) -> Result<()> { + self.conn().execute( + "INSERT INTO body_cache (session_id, body, cached_at) \ + VALUES (?1, ?2, datetime('now')) \ + ON CONFLICT(session_id) DO UPDATE SET body=excluded.body, cached_at=datetime('now')", + params![session_id, body], + )?; + Ok(()) + } + + pub fn get_cached_body(&self, session_id: &str) -> Result>> { + let body = self + .conn() + .query_row( + "SELECT body FROM body_cache WHERE session_id = ?1", + params![session_id], + |row| row.get(0), + ) + .optional()?; + Ok(body) + } + + /// Update only sync metadata path for an existing session. + pub fn set_session_sync_path(&self, session_id: &str, source_path: &str) -> Result<()> { + self.conn().execute( + "INSERT INTO session_sync (session_id, source_path) \ + VALUES (?1, ?2) \ + ON CONFLICT(session_id) DO UPDATE SET source_path = excluded.source_path", + params![session_id, source_path], + )?; + Ok(()) + } +} diff --git a/crates/local-db/src/vector_store.rs b/crates/local-db/src/vector_store.rs new file mode 100644 index 00000000..6253eadf --- /dev/null +++ b/crates/local-db/src/vector_store.rs @@ -0,0 +1,210 @@ +use anyhow::{Context, Result}; +use rusqlite::{OptionalExtension, params}; + +use crate::connection::LocalDb; + +/// Vector chunk payload persisted per session. +#[derive(Debug, Clone, PartialEq)] +pub struct VectorChunkUpsert { + pub chunk_id: String, + pub session_id: String, + pub chunk_index: u32, + pub start_line: u32, + pub end_line: u32, + pub line_count: u32, + pub content: String, + pub content_hash: String, + pub embedding: Vec, +} + +/// Candidate row used for local semantic vector ranking. +#[derive(Debug, Clone, PartialEq)] +pub struct VectorChunkCandidateRow { + pub chunk_id: String, + pub session_id: String, + pub start_line: u32, + pub end_line: u32, + pub content: String, + pub embedding: Vec, +} + +pub(crate) fn build_fts_query(raw: &str) -> Option { + let mut parts: Vec = Vec::new(); + for token in raw.split_whitespace() { + let trimmed = token.trim(); + if trimmed.is_empty() { + continue; + } + let escaped = trimmed.replace('"', "\"\""); + parts.push(format!("\"{escaped}\"")); + } + if parts.is_empty() { + return None; + } + Some(parts.join(" OR ")) +} + +impl LocalDb { + pub fn vector_index_source_hash(&self, session_id: &str) -> Result> { + let hash = self + .conn() + .query_row( + "SELECT source_hash FROM vector_index_sessions WHERE session_id = ?1", + params![session_id], + |row| row.get(0), + ) + .optional()?; + Ok(hash) + } + + pub fn clear_vector_index(&self) -> Result<()> { + let conn = self.conn(); + conn.execute("DELETE FROM vector_embeddings", [])?; + conn.execute("DELETE FROM vector_chunks_fts", [])?; + conn.execute("DELETE FROM vector_chunks", [])?; + conn.execute("DELETE FROM vector_index_sessions", [])?; + Ok(()) + } + + pub fn replace_session_vector_chunks( + &self, + session_id: &str, + source_hash: &str, + model: &str, + chunks: &[VectorChunkUpsert], + ) -> Result<()> { + let mut conn = self.conn(); + let tx = conn.transaction()?; + + tx.execute( + "DELETE FROM vector_embeddings \ + WHERE chunk_id IN (SELECT id FROM vector_chunks WHERE session_id = ?1)", + params![session_id], + )?; + tx.execute( + "DELETE FROM vector_chunks_fts WHERE session_id = ?1", + params![session_id], + )?; + tx.execute( + "DELETE FROM vector_chunks WHERE session_id = ?1", + params![session_id], + )?; + + for chunk in chunks { + let embedding_json = serde_json::to_string(&chunk.embedding) + .context("serialize vector embedding for local cache")?; + tx.execute( + "INSERT INTO vector_chunks \ + (id, session_id, chunk_index, start_line, end_line, line_count, content, content_hash, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now'))", + params![ + &chunk.chunk_id, + &chunk.session_id, + chunk.chunk_index as i64, + chunk.start_line as i64, + chunk.end_line as i64, + chunk.line_count as i64, + &chunk.content, + &chunk.content_hash, + ], + )?; + tx.execute( + "INSERT INTO vector_embeddings \ + (chunk_id, model, embedding_dim, embedding_json, updated_at) \ + VALUES (?1, ?2, ?3, ?4, datetime('now'))", + params![ + &chunk.chunk_id, + model, + chunk.embedding.len() as i64, + &embedding_json + ], + )?; + tx.execute( + "INSERT INTO vector_chunks_fts (chunk_id, session_id, content) VALUES (?1, ?2, ?3)", + params![&chunk.chunk_id, &chunk.session_id, &chunk.content], + )?; + } + + tx.execute( + "INSERT INTO vector_index_sessions \ + (session_id, source_hash, chunk_count, last_indexed_at, updated_at) \ + VALUES (?1, ?2, ?3, datetime('now'), datetime('now')) \ + ON CONFLICT(session_id) DO UPDATE SET \ + source_hash=excluded.source_hash, \ + chunk_count=excluded.chunk_count, \ + last_indexed_at=datetime('now'), \ + updated_at=datetime('now')", + params![session_id, source_hash, chunks.len() as i64], + )?; + + tx.commit()?; + Ok(()) + } + + pub fn list_vector_chunk_candidates( + &self, + query: &str, + model: &str, + limit: u32, + ) -> Result> { + let Some(fts_query) = build_fts_query(query) else { + return Ok(Vec::new()); + }; + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT c.id, c.session_id, c.start_line, c.end_line, c.content, e.embedding_json \ + FROM vector_chunks_fts f \ + INNER JOIN vector_chunks c ON c.id = f.chunk_id \ + INNER JOIN vector_embeddings e ON e.chunk_id = c.id \ + WHERE f.content MATCH ?1 AND e.model = ?2 \ + ORDER BY bm25(vector_chunks_fts) ASC, c.updated_at DESC \ + LIMIT ?3", + )?; + let rows = stmt.query_map(params![fts_query, model, limit as i64], |row| { + let embedding_json: String = row.get(5)?; + let embedding = + serde_json::from_str::>(&embedding_json).unwrap_or_else(|_| Vec::new()); + Ok(VectorChunkCandidateRow { + chunk_id: row.get(0)?, + session_id: row.get(1)?, + start_line: row.get::<_, i64>(2)?.max(0) as u32, + end_line: row.get::<_, i64>(3)?.max(0) as u32, + content: row.get(4)?, + embedding, + }) + })?; + rows.collect::, _>>() + .map_err(Into::into) + } + + pub fn list_recent_vector_chunks_for_model( + &self, + model: &str, + limit: u32, + ) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT c.id, c.session_id, c.start_line, c.end_line, c.content, e.embedding_json \ + FROM vector_chunks c \ + INNER JOIN vector_embeddings e ON e.chunk_id = c.id \ + WHERE e.model = ?1 \ + ORDER BY c.updated_at DESC \ + LIMIT ?2", + )?; + let rows = stmt.query_map(params![model, limit as i64], |row| { + let embedding_json: String = row.get(5)?; + let embedding = + serde_json::from_str::>(&embedding_json).unwrap_or_else(|_| Vec::new()); + Ok(VectorChunkCandidateRow { + chunk_id: row.get(0)?, + session_id: row.get(1)?, + start_line: row.get::<_, i64>(2)?.max(0) as u32, + end_line: row.get::<_, i64>(3)?.max(0) as u32, + content: row.get(4)?, + embedding, + }) + })?; + rows.collect::, _>>() + .map_err(Into::into) + } +} diff --git a/crates/local-store/Cargo.toml b/crates/local-store/Cargo.toml new file mode 100644 index 00000000..a429f00d --- /dev/null +++ b/crates/local-store/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "opensession-local-store" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Local source object storage helpers for OpenSession" +include = ["src/**/*.rs", "Cargo.toml", "LICENSE", "README.md"] + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +opensession-core = { workspace = true } +opensession-paths = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/core/src/object_store.rs b/crates/local-store/src/lib.rs similarity index 69% rename from crates/core/src/object_store.rs rename to crates/local-store/src/lib.rs index e7d35fef..cec99b4c 100644 --- a/crates/core/src/object_store.rs +++ b/crates/local-store/src/lib.rs @@ -1,4 +1,5 @@ -use crate::source_uri::{SourceSpec, SourceUri, SourceUriError}; +use opensession_core::source_uri::{SourceSpec, SourceUri, SourceUriError}; +use opensession_paths::local_store_root; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; @@ -11,7 +12,7 @@ pub struct StoredObject { } #[derive(Debug, thiserror::Error)] -pub enum ObjectStoreError { +pub enum LocalStoreError { #[error("could not determine home directory")] HomeUnavailable, #[error("invalid hash: {0}")] @@ -35,7 +36,7 @@ pub fn sha256_hex(bytes: &[u8]) -> String { out } -pub fn store_local_object(bytes: &[u8], cwd: &Path) -> Result { +pub fn store_local_object(bytes: &[u8], cwd: &Path) -> Result { let sha256 = sha256_hex(bytes); validate_hash(&sha256)?; let root = default_store_root(cwd)?; @@ -59,7 +60,7 @@ pub fn store_local_object(bytes: &[u8], cwd: &Path) -> Result Result<(SourceUri, PathBuf, Vec), ObjectStoreError> { +) -> Result<(SourceUri, PathBuf, Vec), LocalStoreError> { validate_hash(hash)?; for root in candidate_roots(cwd)? { let path = object_path(&root, hash)?; @@ -74,40 +75,32 @@ pub fn read_local_object( )); } } - Err(ObjectStoreError::NotFound(hash.to_string())) + Err(LocalStoreError::NotFound(hash.to_string())) } pub fn read_local_object_from_uri( uri: &SourceUri, cwd: &Path, -) -> Result<(PathBuf, Vec), ObjectStoreError> { - let hash = uri.as_local_hash().ok_or_else(|| { - ObjectStoreError::NotFound("uri is not a local source object".to_string()) - })?; +) -> Result<(PathBuf, Vec), LocalStoreError> { + let hash = uri + .as_local_hash() + .ok_or_else(|| LocalStoreError::NotFound("uri is not a local source object".to_string()))?; let (_uri, path, bytes) = read_local_object(hash, cwd)?; Ok((path, bytes)) } -pub fn default_store_root(cwd: &Path) -> Result { +fn default_store_root(cwd: &Path) -> Result { if let Some(repo_root) = find_repo_root(cwd) { return Ok(repo_root.join(".opensession").join("objects")); } global_store_root() } -pub fn global_store_root() -> Result { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .map(PathBuf::from) - .map_err(|_| ObjectStoreError::HomeUnavailable)?; - Ok(home - .join(".local") - .join("share") - .join("opensession") - .join("objects")) +pub fn global_store_root() -> Result { + local_store_root().map_err(|_| LocalStoreError::HomeUnavailable) } -pub fn object_path(root: &Path, hash: &str) -> Result { +fn object_path(root: &Path, hash: &str) -> Result { validate_hash(hash)?; Ok(root .join("sha256") @@ -116,7 +109,7 @@ pub fn object_path(root: &Path, hash: &str) -> Result .join(format!("{hash}.jsonl"))) } -pub fn candidate_roots(cwd: &Path) -> Result, ObjectStoreError> { +pub fn candidate_roots(cwd: &Path) -> Result, LocalStoreError> { let mut roots = Vec::new(); if let Some(repo_root) = find_repo_root(cwd) { roots.push(repo_root.join(".opensession").join("objects")); @@ -141,18 +134,22 @@ pub fn find_repo_root(from: &Path) -> Option { } } -fn validate_hash(hash: &str) -> Result<(), ObjectStoreError> { +fn validate_hash(hash: &str) -> Result<(), LocalStoreError> { let is_valid = hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()); if is_valid { Ok(()) } else { - Err(ObjectStoreError::InvalidHash(hash.to_string())) + Err(LocalStoreError::InvalidHash(hash.to_string())) } } #[cfg(test)] mod tests { - use super::{find_repo_root, object_path, read_local_object, sha256_hex, store_local_object}; + use super::{ + LocalStoreError, find_repo_root, global_store_root, read_local_object, sha256_hex, + store_local_object, + }; + use opensession_paths::local_store_root; use tempfile::tempdir; #[test] @@ -164,13 +161,9 @@ mod tests { } #[test] - fn object_path_layout_matches_spec() { - let hash = "a".repeat(64); - let path = object_path(std::path::Path::new("/tmp/objects"), &hash).expect("path"); - assert_eq!( - path, - std::path::PathBuf::from(format!("/tmp/objects/sha256/aa/aa/{hash}.jsonl")) - ); + fn global_store_root_uses_standard_home_fallback() { + let root = global_store_root().expect("global store root"); + assert_eq!(root, local_store_root().expect("centralized store root")); } #[test] @@ -187,6 +180,23 @@ mod tests { assert_eq!(uri.to_string(), stored.uri.to_string()); assert_eq!(path, stored.path); assert_eq!(bytes, b"{\"type\":\"header\"}\n"); + assert!( + stored + .path + .to_string_lossy() + .contains("/.opensession/objects/") + ); + } + + #[test] + fn read_local_object_returns_not_found_for_missing_hash() { + let tmp = tempdir().expect("tempdir"); + let error = read_local_object( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + tmp.path(), + ) + .expect_err("missing object"); + assert!(matches!(error, LocalStoreError::NotFound(_))); } #[test] diff --git a/crates/parser-discovery/Cargo.toml b/crates/parser-discovery/Cargo.toml new file mode 100644 index 00000000..77272764 --- /dev/null +++ b/crates/parser-discovery/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "opensession-parser-discovery" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Session discovery adapters for OpenSession parsers" + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +opensession-paths = { workspace = true } +glob = { workspace = true } +shellexpand = { workspace = true } +rusqlite = { workspace = true } diff --git a/crates/parsers/src/discover.rs b/crates/parser-discovery/src/lib.rs similarity index 51% rename from crates/parsers/src/discover.rs rename to crates/parser-discovery/src/lib.rs index c6928a30..6b0115cb 100644 --- a/crates/parsers/src/discover.rs +++ b/crates/parser-discovery/src/lib.rs @@ -1,57 +1,73 @@ use rusqlite::{Connection, OpenFlags}; use std::collections::HashSet; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Metadata about a discovered session location for a specific AI tool. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionLocation { pub tool: String, pub paths: Vec, } /// Discover local session files from known paths for all supported AI tools. +#[must_use] pub fn discover_sessions() -> Vec { - let home = dirs_home(); + discover_sessions_from_home(&dirs_home()) +} + +/// Discover sessions for a specific tool by name. +#[must_use] +pub fn discover_for_tool(tool: &str) -> Vec { + discover_for_tool_in(&dirs_home(), tool) +} + +/// Discover sessions matching an external parser's glob pattern. +#[must_use] +pub fn discover_external(glob_pattern: &str) -> Vec { + let expanded = shellexpand::tilde(glob_pattern).to_string(); + glob::glob(&expanded) + .map(|paths| paths.filter_map(Result::ok).collect()) + .unwrap_or_default() +} + +fn discover_sessions_from_home(home: &Path) -> Vec { let mut locations = Vec::new(); - discover_claude_code(&home, &mut locations); - discover_codex(&home, &mut locations); - discover_opencode(&home, &mut locations); - discover_cline(&home, &mut locations); - discover_amp(&home, &mut locations); - discover_cursor(&home, &mut locations); - discover_gemini(&home, &mut locations); + discover_claude_code(home, &mut locations); + discover_codex(home, &mut locations); + discover_opencode(home, &mut locations); + discover_cline(home, &mut locations); + discover_amp(home, &mut locations); + discover_cursor(home, &mut locations); + discover_gemini(home, &mut locations); locations } -/// Discover sessions for a specific tool by name. -pub fn discover_for_tool(tool: &str) -> Vec { - let home = dirs_home(); +fn discover_for_tool_in(home: &Path, tool: &str) -> Vec { match tool { "claude-code" => find_files_with_ext(&home.join(".claude").join("projects"), "jsonl") .into_iter() - .filter(|p| !crate::is_auxiliary_session_path(p)) + .filter(|path| !is_auxiliary_session_path(path)) .collect(), - "codex" => find_codex_sessions(&home), - "opencode" => find_opencode_sessions(&home), - "cline" => find_cline_sessions(&home), - "amp" => find_amp_threads(&home), - "cursor" => find_cursor_vscdb(&home), - "gemini" => find_gemini_sessions(&home), + "codex" => find_codex_sessions(home), + "opencode" => find_opencode_sessions(home), + "cline" => find_cline_sessions(home), + "amp" => find_amp_threads(home), + "cursor" => find_cursor_vscdb(home), + "gemini" => find_gemini_sessions(home), _ => Vec::new(), } } -// ── Per-tool discovery ────────────────────────────────────────────────────── - -fn discover_claude_code(home: &std::path::Path, locations: &mut Vec) { +fn discover_claude_code(home: &Path, locations: &mut Vec) { let claude_path = home.join(".claude").join("projects"); if !claude_path.exists() { return; } let paths: Vec<_> = find_files_with_ext(&claude_path, "jsonl") .into_iter() - .filter(|p| !crate::is_auxiliary_session_path(p)) + .filter(|path| !is_auxiliary_session_path(path)) .collect(); if !paths.is_empty() { locations.push(SessionLocation { @@ -61,7 +77,7 @@ fn discover_claude_code(home: &std::path::Path, locations: &mut Vec) { +fn discover_codex(home: &Path, locations: &mut Vec) { let paths = find_codex_sessions(home); if !paths.is_empty() { locations.push(SessionLocation { @@ -71,7 +87,7 @@ fn discover_codex(home: &std::path::Path, locations: &mut Vec) } } -fn discover_opencode(home: &std::path::Path, locations: &mut Vec) { +fn discover_opencode(home: &Path, locations: &mut Vec) { let paths = find_opencode_sessions(home); if !paths.is_empty() { locations.push(SessionLocation { @@ -81,7 +97,7 @@ fn discover_opencode(home: &std::path::Path, locations: &mut Vec) { +fn discover_cline(home: &Path, locations: &mut Vec) { let paths = find_cline_sessions(home); if !paths.is_empty() { locations.push(SessionLocation { @@ -91,7 +107,7 @@ fn discover_cline(home: &std::path::Path, locations: &mut Vec) } } -fn discover_amp(home: &std::path::Path, locations: &mut Vec) { +fn discover_amp(home: &Path, locations: &mut Vec) { let paths = find_amp_threads(home); if !paths.is_empty() { locations.push(SessionLocation { @@ -101,45 +117,38 @@ fn discover_amp(home: &std::path::Path, locations: &mut Vec) { } } -fn discover_gemini(home: &std::path::Path, locations: &mut Vec) { - let paths = find_gemini_sessions(home); +fn discover_cursor(home: &Path, locations: &mut Vec) { + let paths = find_cursor_vscdb(home); if !paths.is_empty() { locations.push(SessionLocation { - tool: "gemini".to_string(), + tool: "cursor".to_string(), paths, }); } } -fn discover_cursor(home: &std::path::Path, locations: &mut Vec) { - let paths = find_cursor_vscdb(home); +fn discover_gemini(home: &Path, locations: &mut Vec) { + let paths = find_gemini_sessions(home); if !paths.is_empty() { locations.push(SessionLocation { - tool: "cursor".to_string(), + tool: "gemini".to_string(), paths, }); } } -// ── Utilities ─────────────────────────────────────────────────────────────── - fn dirs_home() -> PathBuf { - std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(".")) + opensession_paths::home_dir().unwrap_or_else(|_| PathBuf::from(".")) } -/// Recursively find files with a given extension under a directory. -fn find_files_with_ext(dir: &std::path::Path, ext: &str) -> Vec { +fn find_files_with_ext(dir: &Path, ext: &str) -> Vec { let pattern = format!("{}/**/*.{}", dir.display(), ext); glob::glob(&pattern) .map(|paths| paths.filter_map(Result::ok).collect()) .unwrap_or_default() } -/// Codex stores sessions as JSONL files under ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl -fn find_codex_sessions(home: &std::path::Path) -> Vec { +fn find_codex_sessions(home: &Path) -> Vec { let mut roots = Vec::new(); if let Ok(codex_home) = std::env::var("CODEX_HOME") { let codex_home = codex_home.trim(); @@ -167,7 +176,7 @@ fn find_codex_sessions(home: &std::path::Path) -> Vec { out } -fn is_codex_rollout_session_file(path: &std::path::Path) -> bool { +fn is_codex_rollout_session_file(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .map(|name| { @@ -177,9 +186,7 @@ fn is_codex_rollout_session_file(path: &std::path::Path) -> bool { .unwrap_or(false) } -/// OpenCode stores session info as JSON files under -/// ~/.local/share/opencode/storage/session//.json -fn find_opencode_sessions(home: &std::path::Path) -> Vec { +fn find_opencode_sessions(home: &Path) -> Vec { let session_path = home .join(".local") .join("share") @@ -195,9 +202,7 @@ fn find_opencode_sessions(home: &std::path::Path) -> Vec { .unwrap_or_default() } -/// Cline stores sessions as task directories under ~/.cline/data/tasks/{taskId}/ -/// Each task has api_conversation_history.json as the entry point. -fn find_cline_sessions(home: &std::path::Path) -> Vec { +fn find_cline_sessions(home: &Path) -> Vec { let tasks_dir = home.join(".cline").join("data").join("tasks"); if !tasks_dir.exists() { return Vec::new(); @@ -208,8 +213,7 @@ fn find_cline_sessions(home: &std::path::Path) -> Vec { .unwrap_or_default() } -/// Amp stores threads as JSON files under ~/.local/share/amp/threads/T-{uuid}.json -fn find_amp_threads(home: &std::path::Path) -> Vec { +fn find_amp_threads(home: &Path) -> Vec { let threads_dir = home .join(".local") .join("share") @@ -224,17 +228,7 @@ fn find_amp_threads(home: &std::path::Path) -> Vec { .unwrap_or_default() } -/// Discover sessions matching an external parser's glob pattern. -pub fn discover_external(glob_pattern: &str) -> Vec { - let expanded = shellexpand::tilde(glob_pattern).to_string(); - glob::glob(&expanded) - .map(|paths| paths.filter_map(Result::ok).collect()) - .unwrap_or_default() -} - -/// Gemini CLI stores sessions as JSON or JSONL files under -/// ~/.gemini/tmp//chats/session-*.{json,jsonl} -fn find_gemini_sessions(home: &std::path::Path) -> Vec { +fn find_gemini_sessions(home: &Path) -> Vec { let gemini_path = home.join(".gemini").join("tmp"); if !gemini_path.exists() { return Vec::new(); @@ -249,20 +243,14 @@ fn find_gemini_sessions(home: &std::path::Path) -> Vec { results } -/// Cursor stores conversation data in SQLite databases (state.vscdb). -/// Global: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb -/// Per-workspace: ~/Library/Application Support/Cursor/User/workspaceStorage//state.vscdb -fn find_cursor_vscdb(home: &std::path::Path) -> Vec { +fn find_cursor_vscdb(home: &Path) -> Vec { let mut results = Vec::new(); - // macOS path let cursor_base = home .join("Library") .join("Application Support") .join("Cursor") .join("User"); - - // Linux path (XDG) let cursor_base_linux = home.join(".config").join("Cursor").join("User"); for base in &[&cursor_base, &cursor_base_linux] { @@ -270,13 +258,11 @@ fn find_cursor_vscdb(home: &std::path::Path) -> Vec { continue; } - // Global state.vscdb let global_db = base.join("globalStorage").join("state.vscdb"); if global_db.exists() && cursor_db_has_composer_data(&global_db) { results.push(global_db); } - // Per-workspace state.vscdb files let workspace_dir = base.join("workspaceStorage"); if workspace_dir.exists() { let pattern = format!("{}/*/state.vscdb", workspace_dir.display()); @@ -293,7 +279,7 @@ fn find_cursor_vscdb(home: &std::path::Path) -> Vec { results } -fn cursor_db_has_composer_data(path: &std::path::Path) -> bool { +fn cursor_db_has_composer_data(path: &Path) -> bool { let conn = match Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) { Ok(conn) => conn, Err(_) => return false, @@ -328,10 +314,31 @@ fn has_cursor_rows(conn: &Connection, table: &str) -> bool { conn.query_row(&sql, [], |row| row.get(0)).unwrap_or(false) } +fn is_auxiliary_session_path(path: &Path) -> bool { + let path_text = path.to_string_lossy(); + if path_text.contains("/subagents/") || path_text.contains("\\subagents\\") { + return true; + } + + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let lower = name.to_ascii_lowercase(); + lower.starts_with("agent-") + || lower.starts_with("agent_") + || lower.starts_with("subagent-") + || lower.starts_with("subagent_") +} + #[cfg(test)] mod tests { - use super::{find_codex_sessions, is_codex_rollout_session_file}; - use std::path::Path; + use super::{ + SessionLocation, discover_for_tool_in, discover_sessions_from_home, find_codex_sessions, + is_codex_rollout_session_file, + }; + use rusqlite::Connection; + use std::fs; + use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; fn env_test_lock() -> &'static Mutex<()> { @@ -355,14 +362,63 @@ mod tests { impl Drop for EnvVarRestore { fn drop(&mut self) { - if let Some(ref previous) = self.previous { - std::env::set_var(self.key, previous); + if let Some(previous) = self.previous.as_ref() { + set_env_for_test(self.key, previous); } else { - std::env::remove_var(self.key); + remove_env_for_test(self.key); } } } + fn set_env_for_test(key: &str, value: impl AsRef) { + // SAFETY: tests hold env_test_lock() while mutating process environment. + unsafe { std::env::set_var(key, value) }; + } + + fn remove_env_for_test(key: &str) { + // SAFETY: tests hold env_test_lock() while mutating process environment. + unsafe { std::env::remove_var(key) }; + } + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "{prefix}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&root).expect("create temp dir"); + root + } + + fn write_cursor_fixture_db(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create cursor parent"); + } + let conn = Connection::open(path).expect("create cursor db"); + conn.execute( + "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)", + [], + ) + .expect("create cursorDiskKV"); + conn.execute( + "INSERT INTO cursorDiskKV (key, value) VALUES (?1, ?2)", + ( + "composerData:test", + r#"{"composerId":"comp-1","conversation":[{"type":1,"text":"hello"}]}"#, + ), + ) + .expect("insert composer row"); + } + + fn collect_tools(locations: &[SessionLocation]) -> Vec<&str> { + locations + .iter() + .map(|location| location.tool.as_str()) + .collect() + } + #[test] fn codex_rollout_matcher_only_accepts_rollout_files() { assert!(is_codex_rollout_session_file(Path::new( @@ -383,39 +439,100 @@ mod tests { fn codex_discovery_ignores_non_rollout_jsonl() { let _guard = env_test_lock().lock().expect("env lock"); let restore = EnvVarRestore::capture("CODEX_HOME"); - let unique = format!( - "opensession-codex-discover-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("time") - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); + let root = unique_temp_dir("opensession-codex-discover"); let sessions_dir = root.join("sessions").join("2026").join("02").join("20"); - std::fs::create_dir_all(&sessions_dir).expect("mkdir"); + fs::create_dir_all(&sessions_dir).expect("mkdir"); - std::fs::write(sessions_dir.join("rollout-1.jsonl"), "{}\n").expect("rollout"); - std::fs::write(sessions_dir.join("rollout.jsonl"), "{}\n").expect("rollout base"); - std::fs::write(sessions_dir.join("summary.jsonl"), "{}\n").expect("summary"); - std::fs::write(sessions_dir.join("notes.jsonl"), "{}\n").expect("notes"); + fs::write(sessions_dir.join("rollout-1.jsonl"), "{}\n").expect("rollout"); + fs::write(sessions_dir.join("rollout.jsonl"), "{}\n").expect("rollout base"); + fs::write(sessions_dir.join("summary.jsonl"), "{}\n").expect("summary"); + fs::write(sessions_dir.join("notes.jsonl"), "{}\n").expect("notes"); - std::env::set_var("CODEX_HOME", &root); + set_env_for_test("CODEX_HOME", &root); let found = find_codex_sessions(Path::new("/this/home/path/does/not/exist")); - assert!(found - .iter() - .any(|path| path.ends_with(Path::new("rollout-1.jsonl")))); - assert!(found - .iter() - .any(|path| path.ends_with(Path::new("rollout.jsonl")))); - assert!(!found - .iter() - .any(|path| path.ends_with(Path::new("summary.jsonl")))); - assert!(!found - .iter() - .any(|path| path.ends_with(Path::new("notes.jsonl")))); + assert!( + found + .iter() + .any(|path| path.ends_with(Path::new("rollout-1.jsonl"))) + ); + assert!( + found + .iter() + .any(|path| path.ends_with(Path::new("rollout.jsonl"))) + ); + assert!( + !found + .iter() + .any(|path| path.ends_with(Path::new("summary.jsonl"))) + ); + assert!( + !found + .iter() + .any(|path| path.ends_with(Path::new("notes.jsonl"))) + ); - std::fs::remove_dir_all(&root).ok(); + fs::remove_dir_all(&root).ok(); drop(restore); } + + #[test] + fn discover_sessions_preserves_tool_order() { + let home = unique_temp_dir("opensession-discovery-order"); + let claude_dir = home.join(".claude/projects/demo"); + fs::create_dir_all(claude_dir.join("subagents")).expect("create claude dir"); + fs::write(claude_dir.join("session.jsonl"), "{}\n").expect("write claude"); + fs::write(claude_dir.join("subagents/agent-1.jsonl"), "{}\n").expect("write subagent"); + + let codex_dir = home.join(".codex/sessions/2026/02/20"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + fs::write(codex_dir.join("rollout.jsonl"), "{}\n").expect("write codex"); + + let opencode_dir = home.join(".local/share/opencode/storage/session/project"); + fs::create_dir_all(&opencode_dir).expect("create opencode dir"); + fs::write(opencode_dir.join("ses.json"), "{}\n").expect("write opencode"); + + let cline_dir = home.join(".cline/data/tasks/task-1"); + fs::create_dir_all(&cline_dir).expect("create cline dir"); + fs::write(cline_dir.join("api_conversation_history.json"), "{}\n").expect("write cline"); + + let amp_dir = home.join(".local/share/amp/threads"); + fs::create_dir_all(&_dir).expect("create amp dir"); + fs::write(amp_dir.join("T-1.json"), "{}\n").expect("write amp"); + + let cursor_db = home.join(".config/Cursor/User/workspaceStorage/test/state.vscdb"); + write_cursor_fixture_db(&cursor_db); + + let gemini_dir = home.join(".gemini/tmp/demo/chats"); + fs::create_dir_all(&gemini_dir).expect("create gemini dir"); + fs::write(gemini_dir.join("session-demo.json"), "{}\n").expect("write gemini"); + + let locations = discover_sessions_from_home(&home); + assert_eq!( + collect_tools(&locations), + vec![ + "claude-code", + "codex", + "opencode", + "cline", + "amp", + "cursor", + "gemini" + ] + ); + assert_eq!(locations[0].paths.len(), 1); + } + + #[test] + fn discover_for_tool_filters_auxiliary_claude_sessions() { + let home = unique_temp_dir("opensession-discovery-claude"); + let project_dir = home.join(".claude/projects/demo"); + fs::create_dir_all(project_dir.join("subagents")).expect("create claude project"); + fs::write(project_dir.join("session.jsonl"), "{}\n").expect("write primary"); + fs::write(project_dir.join("subagents/agent-123.jsonl"), "{}\n").expect("write agent"); + fs::write(project_dir.join("subagent-123.jsonl"), "{}\n").expect("write sibling"); + + let found = discover_for_tool_in(&home, "claude-code"); + assert_eq!(found, vec![project_dir.join("session.jsonl")]); + } } diff --git a/crates/parsers/Cargo.toml b/crates/parsers/Cargo.toml index a52454e9..06b174ea 100644 --- a/crates/parsers/Cargo.toml +++ b/crates/parsers/Cargo.toml @@ -2,6 +2,7 @@ name = "opensession-parsers" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true description = "Parsers for converting AI tool session data to HAIL format" @@ -27,3 +28,6 @@ regex = { workspace = true } toml = { workspace = true } rusqlite = { workspace = true } tempfile = { workspace = true } + +[dev-dependencies] +opensession-parser-discovery = { workspace = true } diff --git a/crates/parsers/src/amp.rs b/crates/parsers/src/amp.rs index 606487db..6f271768 100644 --- a/crates/parsers/src/amp.rs +++ b/crates/parsers/src/amp.rs @@ -1,5 +1,5 @@ -use crate::common::set_first; use crate::SessionParser; +use crate::common::set_first; use anyhow::{Context, Result}; use chrono::{TimeZone, Utc}; use opensession_core::trace::{ diff --git a/crates/parsers/src/claude_code/mod.rs b/crates/parsers/src/claude_code/mod.rs index fbd8ce8f..d0728b21 100644 --- a/crates/parsers/src/claude_code/mod.rs +++ b/crates/parsers/src/claude_code/mod.rs @@ -1,4 +1,6 @@ mod parse; +mod raw; +mod subagent; mod transform; use crate::SessionParser; @@ -57,9 +59,8 @@ impl ClaudeCodeParser { } // Re-export pub(crate) items needed by incremental.rs -pub(crate) use parse::{ - parse_timestamp, process_assistant_entry, process_user_entry, RawConversationEntry, RawEntry, -}; +pub(crate) use parse::{parse_timestamp, process_assistant_entry, process_user_entry}; +pub(crate) use raw::{RawConversationEntry, RawEntry}; pub fn is_claude_subagent_path(path: &Path) -> bool { let path_text = path.to_string_lossy(); diff --git a/crates/parsers/src/claude_code/parse.rs b/crates/parsers/src/claude_code/parse.rs index 953b34fd..35197c16 100644 --- a/crates/parsers/src/claude_code/parse.rs +++ b/crates/parsers/src/claude_code/parse.rs @@ -1,225 +1,21 @@ use super::transform::{build_cc_tool_result_content, classify_tool_use, tool_use_content}; +use super::{ + raw::{ + RawContent, RawContentBlock, RawConversationEntry, RawEntry, RawProgressEntry, + RawQueueOperationEntry, RawSummaryEntry, RawSystemEntry, + }, + subagent::{merge_subagent_sessions, read_subagent_meta}, +}; use crate::common::{ - attach_semantic_attrs, attach_source_attrs, infer_tool_kind, set_first, strip_system_reminders, - ToolUseInfo, + ToolUseInfo, attach_semantic_attrs, attach_source_attrs, infer_tool_kind, set_first, + strip_system_reminders, }; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use opensession_core::trace::{Agent, Content, Event, EventType, Session, SessionContext}; -use serde::Deserialize; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::io::BufRead; -use std::path::{Path, PathBuf}; - -// ── Raw JSONL deserialization types ────────────────────────────────────────── - -/// Top-level entry in the Claude Code JSONL file. -/// Each line is one of these. -#[derive(Debug, Deserialize)] -#[serde(tag = "type")] -pub(crate) enum RawEntry { - #[serde(rename = "user")] - User(RawConversationEntry), - #[serde(rename = "assistant")] - Assistant(RawConversationEntry), - #[serde(rename = "file-history-snapshot")] - FileHistorySnapshot {}, - #[serde(rename = "system")] - System(RawSystemEntry), - #[serde(rename = "progress")] - Progress(RawProgressEntry), - #[serde(rename = "queue-operation")] - QueueOperation(RawQueueOperationEntry), - #[serde(rename = "summary")] - Summary(RawSummaryEntry), - // Catch-all for unknown types we want to skip - #[serde(other)] - Unknown, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RawConversationEntry { - pub(crate) uuid: String, - #[serde(default)] - pub(crate) session_id: Option, - pub(crate) timestamp: String, - pub(crate) message: RawMessage, - #[serde(default)] - pub(crate) cwd: Option, - #[serde(default)] - pub(crate) git_branch: Option, - #[serde(default)] - pub(crate) version: Option, - #[allow(dead_code)] - #[serde(default)] - agent_id: Option, - #[allow(dead_code)] - #[serde(default)] - slug: Option, - #[allow(dead_code)] - #[serde(default, rename = "costUSD")] - cost_usd: Option, - #[serde(default)] - pub(crate) usage: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RawSystemEntry { - #[serde(default)] - pub(crate) uuid: Option, - #[serde(default)] - pub(crate) session_id: Option, - #[serde(default)] - pub(crate) timestamp: Option, - #[serde(default)] - pub(crate) content: Option, - #[serde(default)] - pub(crate) subtype: Option, - #[serde(default)] - pub(crate) level: Option, - #[serde(default)] - pub(crate) cwd: Option, - #[serde(default)] - pub(crate) git_branch: Option, - #[serde(default)] - pub(crate) version: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RawProgressEntry { - #[serde(default)] - pub(crate) uuid: Option, - #[serde(default)] - pub(crate) session_id: Option, - #[serde(default)] - pub(crate) timestamp: Option, - #[serde(default)] - pub(crate) data: Option, - #[serde(default)] - pub(crate) tool_use_id: Option, - #[serde(default)] - pub(crate) parent_tool_use_id: Option, - #[serde(default)] - pub(crate) cwd: Option, - #[serde(default)] - pub(crate) git_branch: Option, - #[serde(default)] - pub(crate) version: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RawQueueOperationEntry { - #[serde(default)] - pub(crate) session_id: Option, - #[serde(default)] - pub(crate) timestamp: Option, - #[serde(default)] - pub(crate) operation: Option, - #[serde(default)] - pub(crate) content: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RawSummaryEntry { - #[serde(default)] - pub(crate) uuid: Option, - #[serde(default)] - pub(crate) session_id: Option, - #[serde(default)] - pub(crate) timestamp: Option, - #[serde(default)] - pub(crate) leaf_uuid: Option, - #[serde(default)] - pub(crate) summary: Option, -} - -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -pub(crate) struct RawUsage { - #[serde(default)] - pub(crate) input_tokens: u64, - #[serde(default)] - pub(crate) output_tokens: u64, - #[serde(default)] - cache_read_input_tokens: u64, - #[serde(default)] - cache_creation_input_tokens: u64, -} - -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -pub(crate) struct RawMessage { - pub(crate) role: String, - pub(crate) content: RawContent, - #[serde(default)] - pub(crate) model: Option, -} - -/// Claude Code represents user message content as either a plain string -/// or an array of content blocks. -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub(crate) enum RawContent { - Text(String), - Blocks(Vec), -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type")] -pub(crate) enum RawContentBlock { - #[serde(rename = "text")] - Text { text: String }, - #[serde(rename = "thinking")] - Thinking { - #[serde(default)] - thinking: Option, - }, - #[serde(rename = "tool_use")] - ToolUse { - #[serde(default)] - id: Option, - name: String, - #[serde(default)] - input: serde_json::Value, - }, - #[serde(rename = "tool_result")] - ToolResult { - #[serde(default)] - tool_use_id: Option, - #[serde(default)] - content: ToolResultContent, - #[serde(default)] - is_error: bool, - }, - // Skip unknown block types gracefully - #[serde(other)] - Other, -} - -/// tool_result content can be a string, array of blocks, or absent -#[derive(Debug, Deserialize)] -#[serde(untagged)] -#[derive(Default)] -pub(crate) enum ToolResultContent { - Text(String), - Blocks(Vec), - #[default] - Null, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type")] -pub(crate) enum ToolResultBlock { - #[serde(rename = "text")] - Text { text: String }, - #[serde(other)] - Other, -} +use std::path::Path; // ── Parsing logic ─────────────────────────────────────────────────────────── @@ -452,398 +248,6 @@ pub(super) fn parse_claude_code_jsonl(path: &Path) -> Result { Ok(session) } -fn is_subagent_file_name(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - lower.starts_with("agent-") - || lower.starts_with("agent_") - || lower.starts_with("subagent-") - || lower.starts_with("subagent_") -} - -fn collect_subagent_dirs(parent_path: &Path) -> Vec { - let mut dirs = Vec::new(); - let mut seen = HashSet::new(); - let mut push_unique = |path: PathBuf| { - if seen.insert(path.clone()) { - dirs.push(path); - } - }; - - // Parent default layout: `/subagents/*.jsonl` - push_unique(parent_path.with_extension("").join("subagents")); - - // Fallback for legacy/alternate layouts in the same project folder. - if let Some(parent_dir) = parent_path.parent() { - push_unique(parent_dir.join("subagents")); - // Newer Claude Code layouts can place child logs directly beside the parent. - push_unique(parent_dir.to_path_buf()); - } - - dirs -} - -fn merge_subagent_session_ids_match(parent_session_id: &str, meta: &SubagentMeta) -> bool { - meta.session_id - .as_deref() - .is_some_and(|id| id == parent_session_id) - || meta - .parent_session_id - .as_deref() - .is_some_and(|id| id == parent_session_id) -} - -/// Look for likely subagent files and merge their events into the parent session. -fn merge_subagent_sessions(parent_path: &Path, parent_session_id: &str, session: &mut Session) { - let mut subagent_files: Vec<_> = collect_subagent_dirs(parent_path) - .into_iter() - .filter(|dir| dir.is_dir()) - .flat_map(|dir| match std::fs::read_dir(dir) { - Ok(entries) => entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter(|p| p.extension().is_some_and(|ext| ext == "jsonl")) - .collect(), - Err(_) => Vec::new(), - }) - .collect(); - - if subagent_files.is_empty() { - return; - } - - subagent_files.retain(|path| { - if path == parent_path { - return false; - } - - let file_name = match path.file_name().and_then(|n| n.to_str()) { - Some(name) => name, - None => return false, - }; - - if file_name.starts_with('.') { - return false; - } - - let in_subagents_dir = path - .parent() - .and_then(|dir| dir.file_name()) - .and_then(|name| name.to_str()) - .is_some_and(|name| name.eq_ignore_ascii_case("subagents")); - if in_subagents_dir && is_subagent_file_name(file_name) { - return true; - } - - let meta = read_subagent_meta(path); - matches!( - meta, - Some(meta) if merge_subagent_session_ids_match(parent_session_id, &meta) - ) - }); - - subagent_files.sort(); - if subagent_files.is_empty() { - return; - } - - for subagent_path in subagent_files { - let meta = read_subagent_meta(&subagent_path).unwrap_or(SubagentMeta { - slug: None, - agent_id: None, - session_id: None, - parent_session_id: None, - }); - let file_agent_id = subagent_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - - let task_id = meta - .agent_id - .as_ref() - .cloned() - .unwrap_or_else(|| file_agent_id.clone()); - - // Parse the subagent JSONL (same format as parent, no recursive subagent merging) - let sub_session = match parse_subagent_jsonl(&subagent_path) { - Ok(s) => s, - Err(e) => { - tracing::warn!( - "Failed to parse subagent {}: {}", - subagent_path.display(), - e - ); - continue; - } - }; - - if sub_session.events.is_empty() { - continue; - } - let task_title = meta - .slug - .as_ref() - .cloned() - .unwrap_or_else(|| task_id.clone()); - - let sub_model = if sub_session.agent.model != "unknown" { - Some(sub_session.agent.model.clone()) - } else { - None - }; - - // TaskStart event at the subagent's first event timestamp - let start_ts = sub_session.events.first().unwrap().timestamp; - let end_ts = sub_session.events.last().unwrap().timestamp; - - let mut start_attrs = HashMap::new(); - start_attrs.insert( - "subagent_id".to_string(), - serde_json::Value::String(task_id.clone()), - ); - start_attrs.insert("merged_subagent".to_string(), serde_json::Value::Bool(true)); - if let Some(ref model) = sub_model { - start_attrs.insert( - "model".to_string(), - serde_json::Value::String(model.clone()), - ); - } - - session.events.push(Event { - event_id: format!("{}-start", task_id), - timestamp: start_ts, - event_type: EventType::TaskStart { - title: Some(task_title), - }, - task_id: Some(task_id.clone()), - content: Content::text(""), - duration_ms: None, - attributes: start_attrs, - }); - - // Add all subagent events with task_id set - for mut event in sub_session.events { - event.task_id = Some(task_id.clone()); - // Prefix event_id to avoid collisions with parent - event.event_id = format!("{}:{}", task_id, event.event_id); - event.attributes.insert( - "subagent_id".to_string(), - serde_json::Value::String(task_id.clone()), - ); - event - .attributes - .insert("merged_subagent".to_string(), serde_json::Value::Bool(true)); - session.events.push(event); - } - - // TaskEnd event - let duration = (end_ts - start_ts).num_milliseconds().max(0) as u64; - let mut end_attrs = HashMap::new(); - end_attrs.insert( - "subagent_id".to_string(), - serde_json::Value::String(task_id.clone()), - ); - end_attrs.insert("merged_subagent".to_string(), serde_json::Value::Bool(true)); - session.events.push(Event { - event_id: format!("{}-end", task_id), - timestamp: end_ts, - event_type: EventType::TaskEnd { - summary: Some(format!( - "{} events, {}", - sub_session.stats.event_count, sub_session.agent.model - )), - }, - task_id: Some(task_id), - content: Content::text(""), - duration_ms: Some(duration), - attributes: end_attrs, - }); - } - - // Re-sort all events by timestamp - session.events.sort_by_key(|e| e.timestamp); -} - -/// Metadata extracted from the first line of a subagent JSONL -struct SubagentMeta { - slug: Option, - agent_id: Option, - session_id: Option, - parent_session_id: Option, -} - -fn read_subagent_meta(path: &Path) -> Option { - let file = std::fs::File::open(path).ok()?; - let mut reader = std::io::BufReader::new(file); - let mut first_line = String::new(); - reader.read_line(&mut first_line).ok()?; - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct FirstLine { - #[serde(default)] - slug: Option, - #[serde(default)] - agent_id: Option, - #[serde(default)] - session_id: Option, - #[serde(default, alias = "parentUuid", alias = "parentID", alias = "parentId")] - parent_session_id: Option, - } - - let parsed: FirstLine = serde_json::from_str(&first_line).ok()?; - Some(SubagentMeta { - slug: parsed.slug, - agent_id: parsed.agent_id, - session_id: parsed.session_id, - parent_session_id: parsed.parent_session_id, - }) -} - -/// Parse a subagent JSONL file (same format, but no recursive subagent merging) -fn parse_subagent_jsonl(path: &Path) -> Result { - let meta = read_subagent_meta(path); - let file = std::fs::File::open(path) - .with_context(|| format!("Failed to open subagent JSONL: {}", path.display()))?; - let reader = std::io::BufReader::new(file); - - let mut events: Vec = Vec::new(); - let mut model_name: Option = None; - let mut tool_version: Option = None; - let mut session_id: Option = None; - let mut cwd: Option = None; - let mut git_branch: Option = None; - let mut tool_use_info: HashMap = HashMap::new(); - - for line_result in reader.lines() { - let line = match line_result { - Ok(l) => l, - Err(_) => continue, - }; - if line.trim().is_empty() { - continue; - } - - let entry: RawEntry = match serde_json::from_str(&line) { - Ok(e) => e, - Err(_) => continue, - }; - - match entry { - RawEntry::FileHistorySnapshot {} | RawEntry::Unknown => continue, - RawEntry::System(system) => { - set_first(&mut session_id, system.session_id.clone()); - set_first(&mut tool_version, system.version.clone()); - set_first(&mut cwd, system.cwd.clone()); - set_first(&mut git_branch, system.git_branch.clone()); - events.push(system_entry_to_event(&system, &events)); - } - RawEntry::Progress(progress) => { - set_first(&mut session_id, progress.session_id.clone()); - set_first(&mut tool_version, progress.version.clone()); - set_first(&mut cwd, progress.cwd.clone()); - set_first(&mut git_branch, progress.git_branch.clone()); - events.push(progress_entry_to_event(&progress, &events)); - } - RawEntry::QueueOperation(queue_op) => { - set_first(&mut session_id, queue_op.session_id.clone()); - events.push(queue_operation_entry_to_event(&queue_op, &events)); - } - RawEntry::Summary(summary) => { - set_first(&mut session_id, summary.session_id.clone()); - events.push(summary_entry_to_event(&summary, &events)); - } - RawEntry::User(conv) => { - set_first(&mut session_id, conv.session_id.clone()); - set_first(&mut tool_version, conv.version.clone()); - set_first(&mut cwd, conv.cwd.clone()); - set_first(&mut git_branch, conv.git_branch.clone()); - if let Ok(ts) = parse_timestamp(&conv.timestamp) { - process_user_entry(&conv, ts, &mut events, &tool_use_info); - } - } - RawEntry::Assistant(conv) => { - set_first(&mut session_id, conv.session_id.clone()); - set_first(&mut tool_version, conv.version.clone()); - set_first(&mut model_name, conv.message.model.clone()); - set_first(&mut git_branch, conv.git_branch.clone()); - if let Ok(ts) = parse_timestamp(&conv.timestamp) { - process_assistant_entry(&conv, ts, &mut events, &mut tool_use_info); - } - } - } - } - - let session_id = session_id.unwrap_or_else(|| { - path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string() - }); - - let agent = Agent { - provider: "anthropic".to_string(), - model: model_name.unwrap_or_else(|| "unknown".to_string()), - tool: "claude-code".to_string(), - tool_version, - }; - - let (created_at, updated_at) = - if let (Some(first), Some(last)) = (events.first(), events.last()) { - (first.timestamp, last.timestamp) - } else { - let now = Utc::now(); - (now, now) - }; - - let parent_session_id = meta - .as_ref() - .and_then(|value| value.parent_session_id.clone()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - let mut attributes = HashMap::from([( - "source_path".to_string(), - serde_json::Value::String(path.to_string_lossy().to_string()), - )]); - attributes.insert( - "session_role".to_string(), - serde_json::Value::String(if parent_session_id.is_some() { - "auxiliary".to_string() - } else { - "primary".to_string() - }), - ); - if let Some(parent_session_id) = parent_session_id.as_ref() { - attributes.insert( - "parent_session_id".to_string(), - serde_json::Value::String(parent_session_id.clone()), - ); - } - if let Some(branch) = git_branch.as_ref() { - attributes.insert( - "git_branch".to_string(), - serde_json::Value::String(branch.clone()), - ); - } - - let context = SessionContext { - title: None, - description: None, - tags: vec!["claude-code".to_string()], - created_at, - updated_at, - related_session_ids: parent_session_id.clone().into_iter().collect(), - attributes, - }; - - let mut session = Session::new(session_id, agent); - session.context = context; - session.events = events; - session.recompute_stats(); - Ok(session) -} - pub(crate) fn parse_timestamp(ts: &str) -> Result> { // Claude Code timestamps are ISO 8601, e.g. "2026-02-06T04:46:17.839Z" DateTime::parse_from_rfc3339(ts) @@ -907,7 +311,7 @@ fn progress_text(data: Option<&serde_json::Value>) -> String { format!("Progress: {data_type}") } -fn system_entry_to_event(entry: &RawSystemEntry, events: &[Event]) -> Event { +pub(super) fn system_entry_to_event(entry: &RawSystemEntry, events: &[Event]) -> Event { let fallback = fallback_timestamp(events); let timestamp = parse_timestamp_with_fallback(entry.timestamp.as_deref(), fallback); let subtype = entry.subtype.as_deref().unwrap_or("unknown"); @@ -947,7 +351,7 @@ fn system_entry_to_event(entry: &RawSystemEntry, events: &[Event]) -> Event { } } -fn progress_entry_to_event(entry: &RawProgressEntry, events: &[Event]) -> Event { +pub(super) fn progress_entry_to_event(entry: &RawProgressEntry, events: &[Event]) -> Event { let fallback = fallback_timestamp(events); let timestamp = parse_timestamp_with_fallback(entry.timestamp.as_deref(), fallback); let mut attrs = HashMap::new(); @@ -987,7 +391,10 @@ fn progress_entry_to_event(entry: &RawProgressEntry, events: &[Event]) -> Event } } -fn queue_operation_entry_to_event(entry: &RawQueueOperationEntry, events: &[Event]) -> Event { +pub(super) fn queue_operation_entry_to_event( + entry: &RawQueueOperationEntry, + events: &[Event], +) -> Event { let fallback = fallback_timestamp(events); let timestamp = parse_timestamp_with_fallback(entry.timestamp.as_deref(), fallback); let operation = entry @@ -1027,7 +434,7 @@ fn queue_operation_entry_to_event(entry: &RawQueueOperationEntry, events: &[Even } } -fn summary_entry_to_event(entry: &RawSummaryEntry, events: &[Event]) -> Event { +pub(super) fn summary_entry_to_event(entry: &RawSummaryEntry, events: &[Event]) -> Event { let fallback = fallback_timestamp(events); let timestamp = parse_timestamp_with_fallback(entry.timestamp.as_deref(), fallback); let summary_text = event_text_or_default(entry.summary.as_deref(), "Summary"); @@ -1545,485 +952,4 @@ pub(super) fn parse_lines_impl(lines: &[String]) -> ParsedLines { } #[cfg(test)] -mod tests { - use super::*; - use chrono::Datelike; - use chrono::Duration; - use std::collections::HashMap; - use std::fs::{create_dir_all, write}; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn test_temp_root() -> std::path::PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let path = std::env::temp_dir().join(format!("opensession-claude-parser-{nanos}")); - create_dir_all(&path).expect("create test temp root"); - path - } - - #[test] - fn test_parse_timestamp() { - let ts = parse_timestamp("2026-02-06T04:46:17.839Z").unwrap(); - assert_eq!(ts.year(), 2026); - } - - #[test] - fn test_raw_entry_deserialization_user_string() { - let json = r#"{"type":"user","uuid":"abc","sessionId":"s1","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#; - let entry: RawEntry = serde_json::from_str(json).unwrap(); - match entry { - RawEntry::User(conv) => { - assert_eq!(conv.uuid, "abc"); - match conv.message.content { - RawContent::Text(t) => assert_eq!(t, "hello"), - _ => panic!("Expected text content"), - } - } - _ => panic!("Expected User entry"), - } - } - - #[test] - fn test_raw_entry_deserialization_assistant() { - let json = r#"{"type":"assistant","uuid":"def","sessionId":"s1","timestamp":"2026-01-01T00:00:00Z","message":{"role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"hi"}]}}"#; - let entry: RawEntry = serde_json::from_str(json).unwrap(); - match entry { - RawEntry::Assistant(conv) => { - assert_eq!(conv.message.model.as_deref(), Some("claude-opus-4-6")); - } - _ => panic!("Expected Assistant entry"), - } - } - - #[test] - fn test_raw_entry_skip_file_history() { - let json = r#"{"type":"file-history-snapshot","messageId":"abc","snapshot":{},"isSnapshotUpdate":false}"#; - let entry: RawEntry = serde_json::from_str(json).unwrap(); - matches!(entry, RawEntry::FileHistorySnapshot { .. }); - } - - #[test] - fn test_raw_entry_deserialization_queue_operation_and_summary() { - let queue_json = r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:01Z","sessionId":"s1","content":"queued"}"#; - let queue_entry: RawEntry = serde_json::from_str(queue_json).unwrap(); - match queue_entry { - RawEntry::QueueOperation(entry) => { - assert_eq!(entry.operation.as_deref(), Some("enqueue")); - assert_eq!(entry.content.as_deref(), Some("queued")); - assert_eq!(entry.session_id.as_deref(), Some("s1")); - } - _ => panic!("Expected QueueOperation entry"), - } - - let summary_json = - r#"{"type":"summary","summary":"Fix parser edge case","leafUuid":"leaf-1"}"#; - let summary_entry: RawEntry = serde_json::from_str(summary_json).unwrap(); - match summary_entry { - RawEntry::Summary(entry) => { - assert_eq!(entry.summary.as_deref(), Some("Fix parser edge case")); - assert_eq!(entry.leaf_uuid.as_deref(), Some("leaf-1")); - } - _ => panic!("Expected Summary entry"), - } - } - - #[test] - fn test_parse_lines_includes_system_progress_queue_and_summary_events() { - let lines = vec![ - serde_json::json!({ - "type": "system", - "uuid": "sys-1", - "sessionId": "s1", - "timestamp": "2026-01-01T00:00:00Z", - "gitBranch": "feature/session-branch", - "subtype": "local_command", - "content": "/usage" - }) - .to_string(), - serde_json::json!({ - "type": "progress", - "uuid": "prog-1", - "sessionId": "s1", - "timestamp": "2026-01-01T00:00:01Z", - "toolUseID": "tool-123", - "data": { - "type": "hook_progress", - "hookEvent": "PreToolUse", - "hookName": "PreToolUse:Task" - } - }) - .to_string(), - serde_json::json!({ - "type": "queue-operation", - "sessionId": "s1", - "timestamp": "2026-01-01T00:00:02Z", - "operation": "enqueue", - "content": "queued input" - }) - .to_string(), - serde_json::json!({ - "type": "summary", - "sessionId": "s1", - "leafUuid": "leaf-1", - "summary": "Fix parser edge case" - }) - .to_string(), - ]; - - let parsed = parse_lines_impl(&lines); - assert_eq!(parsed.events.len(), 4); - assert_eq!(parsed.session_id.as_deref(), Some("s1")); - assert!(parsed - .events - .iter() - .all(|event| matches!(event.event_type, EventType::SystemMessage))); - - let mut seen_raw_types = HashMap::new(); - for event in &parsed.events { - let raw_type = event - .attributes - .get("source.raw_type") - .and_then(|value| value.as_str()) - .unwrap_or("") - .to_string(); - seen_raw_types.insert(raw_type, event.event_id.clone()); - } - - assert!(seen_raw_types.contains_key("system")); - assert!(seen_raw_types.contains_key("progress")); - assert!(seen_raw_types.contains_key("queue-operation")); - assert!(seen_raw_types.contains_key("summary")); - let context = parsed.context.expect("context from parsed lines"); - assert_eq!( - context - .attributes - .get("git_branch") - .and_then(|value| value.as_str()), - Some("feature/session-branch") - ); - } - - #[test] - fn test_tool_result_without_tool_use_id_falls_back_to_recent_tool_use() { - let assistant_json = r#"{ - "type":"assistant", - "uuid":"a1", - "sessionId":"s1", - "timestamp":"2026-02-01T00:00:00Z", - "message":{ - "role":"assistant", - "model":"claude-opus-4-6", - "content":[ - {"type":"tool_use","name":"Read","input":{"file_path":"src/main.rs"}} - ] - } - }"#; - let user_json = r#"{ - "type":"user", - "uuid":"u1", - "sessionId":"s1", - "timestamp":"2026-02-01T00:00:01Z", - "message":{ - "role":"user", - "content":[ - {"type":"tool_result","content":"ok","is_error":false} - ] - } - }"#; - - let assistant_entry: RawEntry = serde_json::from_str(assistant_json).unwrap(); - let user_entry: RawEntry = serde_json::from_str(user_json).unwrap(); - let mut events = Vec::new(); - let mut tool_use_info = HashMap::new(); - - match assistant_entry { - RawEntry::Assistant(conv) => { - process_assistant_entry( - &conv, - parse_timestamp(&conv.timestamp).unwrap(), - &mut events, - &mut tool_use_info, - ); - } - _ => panic!("expected assistant entry"), - } - match user_entry { - RawEntry::User(conv) => { - process_user_entry( - &conv, - parse_timestamp(&conv.timestamp).unwrap(), - &mut events, - &tool_use_info, - ); - } - _ => panic!("expected user entry"), - } - - let result_event = events - .iter() - .find(|event| matches!(event.event_type, EventType::ToolResult { .. })) - .expect("tool result exists"); - match &result_event.event_type { - EventType::ToolResult { name, .. } => assert_eq!(name, "Read"), - _ => unreachable!(), - } - } - - #[test] - fn test_subagent_file_merge_handles_file_name_without_meta() { - let dir = test_temp_root(); - let parent_path = dir.as_path().join("session-parent.jsonl"); - let subagent_dir = parent_path.with_extension("").join("subagents"); - create_dir_all(&subagent_dir).unwrap(); - - let parent_session = "sess-parent"; - let subagent_session = "agent-abc123"; - - let parent_entry = serde_json::json!({ - "type": "user", - "uuid": "u1", - "sessionId": parent_session, - "timestamp": Utc::now().to_rfc3339(), - "message": { - "role": "user", - "content": "parent prompt" - } - }) - .to_string(); - write(&parent_path, parent_entry).unwrap(); - - let subagent_entry = serde_json::json!({ - "type": "assistant", - "uuid": "a1", - "sessionId": subagent_session, - "timestamp": Utc::now() - .checked_add_signed(Duration::seconds(1)) - .unwrap() - .to_rfc3339(), - "message": { - "role": "assistant", - "model": "claude-3-opus", - "content": [{ - "type": "text", - "text": "subagent reply" - }] - } - }) - .to_string(); - write( - subagent_dir.join(format!("{subagent_session}.jsonl")), - subagent_entry, - ) - .unwrap(); - - let session = parse_claude_code_jsonl(&parent_path).unwrap(); - assert_eq!(session.events.len(), 4); - assert!(session - .events - .iter() - .any(|e| matches!(e.event_type, EventType::TaskStart { .. }))); - assert!(session.events.iter().any(|e| { - e.attributes - .get("merged_subagent") - .and_then(|v| v.as_bool()) - == Some(true) - })); - assert!(session - .events - .iter() - .any(|e| matches!(e.event_type, EventType::AgentMessage))); - assert!(session - .events - .iter() - .any(|e| matches!(e.event_type, EventType::TaskEnd { .. }))); - // message_count includes user+agent messages and TaskEnd summaries. - assert_eq!(session.stats.message_count, 3); - } - - #[test] - fn test_subagent_file_merge_handles_sibling_layout_with_parent_id_meta() { - let dir = test_temp_root(); - let parent_path = dir.as_path().join("session-parent-sibling.jsonl"); - let parent_session = "sess-parent-sibling"; - - let parent_entry = serde_json::json!({ - "type": "user", - "uuid": "u1", - "sessionId": parent_session, - "timestamp": Utc::now().to_rfc3339(), - "message": { - "role": "user", - "content": "parent prompt" - } - }) - .to_string(); - write(&parent_path, parent_entry).unwrap(); - - let sibling_subagent_path = dir - .as_path() - .join("70dafb43-dbdd-4009-beb0-b6ac2bd9c4d1.jsonl"); - let subagent_entry = serde_json::json!({ - "type": "assistant", - "uuid": "a1", - "sessionId": "subagent-random", - "parentUuid": parent_session, - "timestamp": Utc::now() - .checked_add_signed(Duration::seconds(1)) - .unwrap() - .to_rfc3339(), - "message": { - "role": "assistant", - "model": "claude-3-opus", - "content": [{ - "type": "text", - "text": "sibling subagent reply" - }] - } - }) - .to_string(); - write(&sibling_subagent_path, subagent_entry).unwrap(); - - let session = parse_claude_code_jsonl(&parent_path).unwrap(); - assert!(session.events.iter().any(|event| { - event - .attributes - .get("merged_subagent") - .and_then(|value| value.as_bool()) - == Some(true) - })); - assert!(session.events.iter().any(|event| { - matches!(event.event_type, EventType::TaskStart { .. }) - && event - .attributes - .get("subagent_id") - .and_then(|value| value.as_str()) - .is_some() - })); - assert!(session.events.iter().any(|event| { - matches!(event.event_type, EventType::AgentMessage) - && event.content.blocks.iter().any(|block| { - matches!(block, opensession_core::trace::ContentBlock::Text { text } if text.contains("sibling subagent reply")) - }) - })); - } - - #[test] - fn test_parent_id_meta_marks_main_parser_session_as_auxiliary() { - let dir = test_temp_root(); - let path = dir - .as_path() - .join("70dafb43-dbdd-4009-beb0-b6ac2bd9c4d1.jsonl"); - let entry = serde_json::json!({ - "type": "assistant", - "uuid": "a1", - "sessionId": "subagent-random", - "parentId": "parent-main", - "timestamp": Utc::now().to_rfc3339(), - "message": { - "role": "assistant", - "model": "claude-3-opus", - "content": [{ - "type": "text", - "text": "sub" - }] - } - }) - .to_string(); - write(&path, entry).unwrap(); - - let parsed = parse_claude_code_jsonl(&path).unwrap(); - assert_eq!( - parsed - .context - .attributes - .get("session_role") - .and_then(|value| value.as_str()), - Some("auxiliary") - ); - assert_eq!( - parsed - .context - .attributes - .get("parent_session_id") - .and_then(|value| value.as_str()), - Some("parent-main") - ); - assert_eq!( - parsed.context.related_session_ids, - vec!["parent-main".to_string()] - ); - } - - #[test] - fn test_subagent_meta_reads_parent_uuid_aliases() { - let dir = test_temp_root(); - let subagent_path = dir.as_path().join("agent-xyz.jsonl"); - let subagent_entry = serde_json::json!({ - "type": "assistant", - "uuid": "a1", - "sessionId": "sub-1", - "timestamp": Utc::now().to_rfc3339(), - "parentId": "parent-1", - "message": { - "role": "assistant", - "model": "claude-3-opus", - "content": [{ - "type": "text", - "text": "sub" - }] - } - }) - .to_string(); - write(&subagent_path, subagent_entry).unwrap(); - - let meta = read_subagent_meta(&subagent_path).unwrap(); - assert_eq!(meta.parent_session_id.as_deref(), Some("parent-1")); - } - - #[test] - fn test_subagent_parse_sets_related_parent_session_id() { - let dir = test_temp_root(); - let subagent_path = dir.as_path().join("agent-related.jsonl"); - let subagent_entry = serde_json::json!({ - "type": "assistant", - "uuid": "a1", - "sessionId": "sub-2", - "timestamp": Utc::now().to_rfc3339(), - "parentId": "parent-2", - "message": { - "role": "assistant", - "model": "claude-3-opus", - "content": [{ - "type": "text", - "text": "sub" - }] - } - }) - .to_string(); - write(&subagent_path, subagent_entry).unwrap(); - - let parsed = parse_subagent_jsonl(&subagent_path).unwrap(); - assert_eq!( - parsed.context.related_session_ids, - vec!["parent-2".to_string()] - ); - assert_eq!( - parsed - .context - .attributes - .get("session_role") - .and_then(|value| value.as_str()), - Some("auxiliary") - ); - assert_eq!( - parsed - .context - .attributes - .get("parent_session_id") - .and_then(|value| value.as_str()), - Some("parent-2") - ); - } -} +mod tests; diff --git a/crates/parsers/src/claude_code/parse/tests.rs b/crates/parsers/src/claude_code/parse/tests.rs new file mode 100644 index 00000000..117e52f5 --- /dev/null +++ b/crates/parsers/src/claude_code/parse/tests.rs @@ -0,0 +1,487 @@ +use super::*; +use chrono::Datelike; +use chrono::Duration; +use std::collections::HashMap; +use std::fs::{create_dir_all, write}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn test_temp_root() -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("opensession-claude-parser-{nanos}")); + create_dir_all(&path).expect("create test temp root"); + path +} + +#[test] +fn test_parse_timestamp() { + let ts = parse_timestamp("2026-02-06T04:46:17.839Z").unwrap(); + assert_eq!(ts.year(), 2026); +} + +#[test] +fn test_raw_entry_deserialization_user_string() { + let json = r#"{"type":"user","uuid":"abc","sessionId":"s1","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#; + let entry: RawEntry = serde_json::from_str(json).unwrap(); + match entry { + RawEntry::User(conv) => { + assert_eq!(conv.uuid, "abc"); + match conv.message.content { + RawContent::Text(text) => assert_eq!(text, "hello"), + _ => panic!("Expected text content"), + } + } + _ => panic!("Expected User entry"), + } +} + +#[test] +fn test_raw_entry_deserialization_assistant() { + let json = r#"{"type":"assistant","uuid":"def","sessionId":"s1","timestamp":"2026-01-01T00:00:00Z","message":{"role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"hi"}]}}"#; + let entry: RawEntry = serde_json::from_str(json).unwrap(); + match entry { + RawEntry::Assistant(conv) => { + assert_eq!(conv.message.model.as_deref(), Some("claude-opus-4-6")); + } + _ => panic!("Expected Assistant entry"), + } +} + +#[test] +fn test_raw_entry_skip_file_history() { + let json = r#"{"type":"file-history-snapshot","messageId":"abc","snapshot":{},"isSnapshotUpdate":false}"#; + let entry: RawEntry = serde_json::from_str(json).unwrap(); + matches!(entry, RawEntry::FileHistorySnapshot { .. }); +} + +#[test] +fn test_raw_entry_deserialization_queue_operation_and_summary() { + let queue_json = r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:01Z","sessionId":"s1","content":"queued"}"#; + let queue_entry: RawEntry = serde_json::from_str(queue_json).unwrap(); + match queue_entry { + RawEntry::QueueOperation(entry) => { + assert_eq!(entry.operation.as_deref(), Some("enqueue")); + assert_eq!(entry.content.as_deref(), Some("queued")); + assert_eq!(entry.session_id.as_deref(), Some("s1")); + } + _ => panic!("Expected QueueOperation entry"), + } + + let summary_json = r#"{"type":"summary","summary":"Fix parser edge case","leafUuid":"leaf-1"}"#; + let summary_entry: RawEntry = serde_json::from_str(summary_json).unwrap(); + match summary_entry { + RawEntry::Summary(entry) => { + assert_eq!(entry.summary.as_deref(), Some("Fix parser edge case")); + assert_eq!(entry.leaf_uuid.as_deref(), Some("leaf-1")); + } + _ => panic!("Expected Summary entry"), + } +} + +#[test] +fn test_parse_lines_includes_system_progress_queue_and_summary_events() { + let lines = vec![ + serde_json::json!({ + "type": "system", + "uuid": "sys-1", + "sessionId": "s1", + "timestamp": "2026-01-01T00:00:00Z", + "gitBranch": "feature/session-branch", + "subtype": "local_command", + "content": "/usage" + }) + .to_string(), + serde_json::json!({ + "type": "progress", + "uuid": "prog-1", + "sessionId": "s1", + "timestamp": "2026-01-01T00:00:01Z", + "toolUseID": "tool-123", + "data": { + "type": "hook_progress", + "hookEvent": "PreToolUse", + "hookName": "PreToolUse:Task" + } + }) + .to_string(), + serde_json::json!({ + "type": "queue-operation", + "sessionId": "s1", + "timestamp": "2026-01-01T00:00:02Z", + "operation": "enqueue", + "content": "queued input" + }) + .to_string(), + serde_json::json!({ + "type": "summary", + "sessionId": "s1", + "leafUuid": "leaf-1", + "summary": "Fix parser edge case" + }) + .to_string(), + ]; + + let parsed = parse_lines_impl(&lines); + assert_eq!(parsed.events.len(), 4); + assert_eq!(parsed.session_id.as_deref(), Some("s1")); + assert!( + parsed + .events + .iter() + .all(|event| matches!(event.event_type, EventType::SystemMessage)) + ); + + let mut seen_raw_types = HashMap::new(); + for event in &parsed.events { + let raw_type = event + .attributes + .get("source.raw_type") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + seen_raw_types.insert(raw_type, event.event_id.clone()); + } + + assert!(seen_raw_types.contains_key("system")); + assert!(seen_raw_types.contains_key("progress")); + assert!(seen_raw_types.contains_key("queue-operation")); + assert!(seen_raw_types.contains_key("summary")); + let context = parsed.context.expect("context from parsed lines"); + assert_eq!( + context + .attributes + .get("git_branch") + .and_then(|value| value.as_str()), + Some("feature/session-branch") + ); +} + +#[test] +fn test_tool_result_without_tool_use_id_falls_back_to_recent_tool_use() { + let assistant_json = r#"{ + "type":"assistant", + "uuid":"a1", + "sessionId":"s1", + "timestamp":"2026-02-01T00:00:00Z", + "message":{ + "role":"assistant", + "model":"claude-opus-4-6", + "content":[ + {"type":"tool_use","name":"Read","input":{"file_path":"src/main.rs"}} + ] + } + }"#; + let user_json = r#"{ + "type":"user", + "uuid":"u1", + "sessionId":"s1", + "timestamp":"2026-02-01T00:00:01Z", + "message":{ + "role":"user", + "content":[ + {"type":"tool_result","content":"ok","is_error":false} + ] + } + }"#; + + let assistant_entry: RawEntry = serde_json::from_str(assistant_json).unwrap(); + let user_entry: RawEntry = serde_json::from_str(user_json).unwrap(); + let mut events = Vec::new(); + let mut tool_use_info = HashMap::new(); + + match assistant_entry { + RawEntry::Assistant(conv) => { + process_assistant_entry( + &conv, + parse_timestamp(&conv.timestamp).unwrap(), + &mut events, + &mut tool_use_info, + ); + } + _ => panic!("expected assistant entry"), + } + match user_entry { + RawEntry::User(conv) => { + process_user_entry( + &conv, + parse_timestamp(&conv.timestamp).unwrap(), + &mut events, + &tool_use_info, + ); + } + _ => panic!("expected user entry"), + } + + let result_event = events + .iter() + .find(|event| matches!(event.event_type, EventType::ToolResult { .. })) + .expect("tool result exists"); + match &result_event.event_type { + EventType::ToolResult { name, .. } => assert_eq!(name, "Read"), + _ => unreachable!(), + } +} + +#[test] +fn test_subagent_file_merge_handles_file_name_without_meta() { + let dir = test_temp_root(); + let parent_path = dir.as_path().join("session-parent.jsonl"); + let subagent_dir = parent_path.with_extension("").join("subagents"); + create_dir_all(&subagent_dir).unwrap(); + + let parent_session = "sess-parent"; + let subagent_session = "agent-abc123"; + + let parent_entry = serde_json::json!({ + "type": "user", + "uuid": "u1", + "sessionId": parent_session, + "timestamp": Utc::now().to_rfc3339(), + "message": { + "role": "user", + "content": "parent prompt" + } + }) + .to_string(); + write(&parent_path, parent_entry).unwrap(); + + let subagent_entry = serde_json::json!({ + "type": "assistant", + "uuid": "a1", + "sessionId": subagent_session, + "timestamp": Utc::now() + .checked_add_signed(Duration::seconds(1)) + .unwrap() + .to_rfc3339(), + "message": { + "role": "assistant", + "model": "claude-3-opus", + "content": [{ + "type": "text", + "text": "subagent reply" + }] + } + }) + .to_string(); + write( + subagent_dir.join(format!("{subagent_session}.jsonl")), + subagent_entry, + ) + .unwrap(); + + let session = parse_claude_code_jsonl(&parent_path).unwrap(); + assert_eq!(session.events.len(), 4); + assert!( + session + .events + .iter() + .any(|event| matches!(event.event_type, EventType::TaskStart { .. })) + ); + assert!(session.events.iter().any(|event| { + event + .attributes + .get("merged_subagent") + .and_then(|value| value.as_bool()) + == Some(true) + })); + assert!( + session + .events + .iter() + .any(|event| matches!(event.event_type, EventType::AgentMessage)) + ); + assert!( + session + .events + .iter() + .any(|event| matches!(event.event_type, EventType::TaskEnd { .. })) + ); + assert_eq!(session.stats.message_count, 3); +} + +#[test] +fn test_subagent_file_merge_handles_sibling_layout_with_parent_id_meta() { + let dir = test_temp_root(); + let parent_path = dir.as_path().join("session-parent-sibling.jsonl"); + let parent_session = "sess-parent-sibling"; + + let parent_entry = serde_json::json!({ + "type": "user", + "uuid": "u1", + "sessionId": parent_session, + "timestamp": Utc::now().to_rfc3339(), + "message": { + "role": "user", + "content": "parent prompt" + } + }) + .to_string(); + write(&parent_path, parent_entry).unwrap(); + + let sibling_subagent_path = dir + .as_path() + .join("70dafb43-dbdd-4009-beb0-b6ac2bd9c4d1.jsonl"); + let subagent_entry = serde_json::json!({ + "type": "assistant", + "uuid": "a1", + "sessionId": "subagent-random", + "parentUuid": parent_session, + "timestamp": Utc::now() + .checked_add_signed(Duration::seconds(1)) + .unwrap() + .to_rfc3339(), + "message": { + "role": "assistant", + "model": "claude-3-opus", + "content": [{ + "type": "text", + "text": "sibling subagent reply" + }] + } + }) + .to_string(); + write(&sibling_subagent_path, subagent_entry).unwrap(); + + let session = parse_claude_code_jsonl(&parent_path).unwrap(); + assert!(session.events.iter().any(|event| { + event + .attributes + .get("merged_subagent") + .and_then(|value| value.as_bool()) + == Some(true) + })); + assert!(session.events.iter().any(|event| { + matches!(event.event_type, EventType::TaskStart { .. }) + && event + .attributes + .get("subagent_id") + .and_then(|value| value.as_str()) + .is_some() + })); + assert!(session.events.iter().any(|event| { + matches!(event.event_type, EventType::AgentMessage) + && event.content.blocks.iter().any(|block| { + matches!(block, opensession_core::trace::ContentBlock::Text { text } if text.contains("sibling subagent reply")) + }) + })); +} + +#[test] +fn test_parent_id_meta_marks_main_parser_session_as_auxiliary() { + let dir = test_temp_root(); + let path = dir + .as_path() + .join("70dafb43-dbdd-4009-beb0-b6ac2bd9c4d1.jsonl"); + let entry = serde_json::json!({ + "type": "assistant", + "uuid": "a1", + "sessionId": "subagent-random", + "parentId": "parent-main", + "timestamp": Utc::now().to_rfc3339(), + "message": { + "role": "assistant", + "model": "claude-3-opus", + "content": [{ + "type": "text", + "text": "sub" + }] + } + }) + .to_string(); + write(&path, entry).unwrap(); + + let parsed = parse_claude_code_jsonl(&path).unwrap(); + assert_eq!( + parsed + .context + .attributes + .get("session_role") + .and_then(|value| value.as_str()), + Some("auxiliary") + ); + assert_eq!( + parsed + .context + .attributes + .get("parent_session_id") + .and_then(|value| value.as_str()), + Some("parent-main") + ); + assert_eq!( + parsed.context.related_session_ids, + vec!["parent-main".to_string()] + ); +} + +#[test] +fn test_subagent_meta_reads_parent_uuid_aliases() { + let dir = test_temp_root(); + let subagent_path = dir.as_path().join("agent-xyz.jsonl"); + let subagent_entry = serde_json::json!({ + "type": "assistant", + "uuid": "a1", + "sessionId": "sub-1", + "timestamp": Utc::now().to_rfc3339(), + "parentId": "parent-1", + "message": { + "role": "assistant", + "model": "claude-3-opus", + "content": [{ + "type": "text", + "text": "sub" + }] + } + }) + .to_string(); + write(&subagent_path, subagent_entry).unwrap(); + + let meta = read_subagent_meta(&subagent_path).unwrap(); + assert_eq!(meta.parent_session_id.as_deref(), Some("parent-1")); +} + +#[test] +fn test_subagent_parse_sets_related_parent_session_id() { + let dir = test_temp_root(); + let subagent_path = dir.as_path().join("agent-related.jsonl"); + let subagent_entry = serde_json::json!({ + "type": "assistant", + "uuid": "a1", + "sessionId": "sub-2", + "timestamp": Utc::now().to_rfc3339(), + "parentId": "parent-2", + "message": { + "role": "assistant", + "model": "claude-3-opus", + "content": [{ + "type": "text", + "text": "sub" + }] + } + }) + .to_string(); + write(&subagent_path, subagent_entry).unwrap(); + + let parsed = super::super::subagent::parse_subagent_jsonl(&subagent_path).unwrap(); + assert_eq!( + parsed.context.related_session_ids, + vec!["parent-2".to_string()] + ); + assert_eq!( + parsed + .context + .attributes + .get("session_role") + .and_then(|value| value.as_str()), + Some("auxiliary") + ); + assert_eq!( + parsed + .context + .attributes + .get("parent_session_id") + .and_then(|value| value.as_str()), + Some("parent-2") + ); +} diff --git a/crates/parsers/src/claude_code/raw.rs b/crates/parsers/src/claude_code/raw.rs new file mode 100644 index 00000000..53986f62 --- /dev/null +++ b/crates/parsers/src/claude_code/raw.rs @@ -0,0 +1,203 @@ +use serde::Deserialize; + +/// Top-level entry in the Claude Code JSONL file. +/// Each line is one of these. +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub(crate) enum RawEntry { + #[serde(rename = "user")] + User(RawConversationEntry), + #[serde(rename = "assistant")] + Assistant(RawConversationEntry), + #[serde(rename = "file-history-snapshot")] + FileHistorySnapshot {}, + #[serde(rename = "system")] + System(RawSystemEntry), + #[serde(rename = "progress")] + Progress(RawProgressEntry), + #[serde(rename = "queue-operation")] + QueueOperation(RawQueueOperationEntry), + #[serde(rename = "summary")] + Summary(RawSummaryEntry), + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RawConversationEntry { + pub(crate) uuid: String, + #[serde(default)] + pub(crate) session_id: Option, + pub(crate) timestamp: String, + pub(crate) message: RawMessage, + #[serde(default)] + pub(crate) cwd: Option, + #[serde(default)] + pub(crate) git_branch: Option, + #[serde(default)] + pub(crate) version: Option, + #[allow(dead_code)] + #[serde(default)] + agent_id: Option, + #[allow(dead_code)] + #[serde(default)] + slug: Option, + #[allow(dead_code)] + #[serde(default, rename = "costUSD")] + cost_usd: Option, + #[serde(default)] + pub(crate) usage: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RawSystemEntry { + #[serde(default)] + pub(crate) uuid: Option, + #[serde(default)] + pub(crate) session_id: Option, + #[serde(default)] + pub(crate) timestamp: Option, + #[serde(default)] + pub(crate) content: Option, + #[serde(default)] + pub(crate) subtype: Option, + #[serde(default)] + pub(crate) level: Option, + #[serde(default)] + pub(crate) cwd: Option, + #[serde(default)] + pub(crate) git_branch: Option, + #[serde(default)] + pub(crate) version: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RawProgressEntry { + #[serde(default)] + pub(crate) uuid: Option, + #[serde(default)] + pub(crate) session_id: Option, + #[serde(default)] + pub(crate) timestamp: Option, + #[serde(default)] + pub(crate) data: Option, + #[serde(default)] + pub(crate) tool_use_id: Option, + #[serde(default)] + pub(crate) parent_tool_use_id: Option, + #[serde(default)] + pub(crate) cwd: Option, + #[serde(default)] + pub(crate) git_branch: Option, + #[serde(default)] + pub(crate) version: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RawQueueOperationEntry { + #[serde(default)] + pub(crate) session_id: Option, + #[serde(default)] + pub(crate) timestamp: Option, + #[serde(default)] + pub(crate) operation: Option, + #[serde(default)] + pub(crate) content: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RawSummaryEntry { + #[serde(default)] + pub(crate) uuid: Option, + #[serde(default)] + pub(crate) session_id: Option, + #[serde(default)] + pub(crate) timestamp: Option, + #[serde(default)] + pub(crate) leaf_uuid: Option, + #[serde(default)] + pub(crate) summary: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawUsage { + #[serde(default)] + pub(crate) input_tokens: u64, + #[serde(default)] + pub(crate) output_tokens: u64, + #[serde(default)] + cache_read_input_tokens: u64, + #[serde(default)] + cache_creation_input_tokens: u64, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct RawMessage { + pub(crate) role: String, + pub(crate) content: RawContent, + #[serde(default)] + pub(crate) model: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum RawContent { + Text(String), + Blocks(Vec), +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub(crate) enum RawContentBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "thinking")] + Thinking { + #[serde(default)] + thinking: Option, + }, + #[serde(rename = "tool_use")] + ToolUse { + #[serde(default)] + id: Option, + name: String, + #[serde(default)] + input: serde_json::Value, + }, + #[serde(rename = "tool_result")] + ToolResult { + #[serde(default)] + tool_use_id: Option, + #[serde(default)] + content: ToolResultContent, + #[serde(default)] + is_error: bool, + }, + #[serde(other)] + Other, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(untagged)] +pub(crate) enum ToolResultContent { + Text(String), + Blocks(Vec), + #[default] + Null, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub(crate) enum ToolResultBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(other)] + Other, +} diff --git a/crates/parsers/src/claude_code/subagent.rs b/crates/parsers/src/claude_code/subagent.rs new file mode 100644 index 00000000..5765e90e --- /dev/null +++ b/crates/parsers/src/claude_code/subagent.rs @@ -0,0 +1,402 @@ +use super::parse::{parse_timestamp, process_assistant_entry, process_user_entry}; +use super::raw::RawEntry; +use crate::common::ToolUseInfo; +use crate::common::set_first; +use anyhow::{Context, Result}; +use chrono::Utc; +use opensession_core::trace::{Agent, Event, EventType, Session, SessionContext}; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::io::BufRead; +use std::path::{Path, PathBuf}; + +fn is_subagent_file_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("agent-") + || lower.starts_with("agent_") + || lower.starts_with("subagent-") + || lower.starts_with("subagent_") +} + +fn collect_subagent_dirs(parent_path: &Path) -> Vec { + let mut dirs = Vec::new(); + let mut seen = HashSet::new(); + let mut push_unique = |path: PathBuf| { + if seen.insert(path.clone()) { + dirs.push(path); + } + }; + + push_unique(parent_path.with_extension("").join("subagents")); + + if let Some(parent_dir) = parent_path.parent() { + push_unique(parent_dir.join("subagents")); + push_unique(parent_dir.to_path_buf()); + } + + dirs +} + +fn merge_subagent_session_ids_match(parent_session_id: &str, meta: &SubagentMeta) -> bool { + meta.session_id + .as_deref() + .is_some_and(|id| id == parent_session_id) + || meta + .parent_session_id + .as_deref() + .is_some_and(|id| id == parent_session_id) +} + +pub(super) fn merge_subagent_sessions( + parent_path: &Path, + parent_session_id: &str, + session: &mut Session, +) { + let mut subagent_files: Vec<_> = collect_subagent_dirs(parent_path) + .into_iter() + .filter(|dir| dir.is_dir()) + .flat_map(|dir| match std::fs::read_dir(dir) { + Ok(entries) => entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "jsonl")) + .collect(), + Err(_) => Vec::new(), + }) + .collect(); + + if subagent_files.is_empty() { + return; + } + + subagent_files.retain(|path| { + if path == parent_path { + return false; + } + + let file_name = match path.file_name().and_then(|name| name.to_str()) { + Some(name) => name, + None => return false, + }; + + if file_name.starts_with('.') { + return false; + } + + let in_subagents_dir = path + .parent() + .and_then(|dir| dir.file_name()) + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("subagents")); + if in_subagents_dir && is_subagent_file_name(file_name) { + return true; + } + + let meta = read_subagent_meta(path); + matches!( + meta, + Some(meta) if merge_subagent_session_ids_match(parent_session_id, &meta) + ) + }); + + subagent_files.sort(); + if subagent_files.is_empty() { + return; + } + + for subagent_path in subagent_files { + let meta = read_subagent_meta(&subagent_path).unwrap_or(SubagentMeta { + slug: None, + agent_id: None, + session_id: None, + parent_session_id: None, + }); + let file_agent_id = subagent_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("unknown") + .to_string(); + + let task_id = meta + .agent_id + .as_ref() + .cloned() + .unwrap_or_else(|| file_agent_id.clone()); + + let sub_session = match parse_subagent_jsonl(&subagent_path) { + Ok(session) => session, + Err(error) => { + tracing::warn!( + "Failed to parse subagent {}: {}", + subagent_path.display(), + error + ); + continue; + } + }; + + if sub_session.events.is_empty() { + continue; + } + let task_title = meta + .slug + .as_ref() + .cloned() + .unwrap_or_else(|| task_id.clone()); + + let sub_model = if sub_session.agent.model != "unknown" { + Some(sub_session.agent.model.clone()) + } else { + None + }; + + let start_ts = sub_session + .events + .first() + .expect("subagent start") + .timestamp; + let end_ts = sub_session.events.last().expect("subagent end").timestamp; + + let mut start_attrs = HashMap::new(); + start_attrs.insert( + "subagent_id".to_string(), + serde_json::Value::String(task_id.clone()), + ); + start_attrs.insert("merged_subagent".to_string(), serde_json::Value::Bool(true)); + if let Some(model) = sub_model.as_ref() { + start_attrs.insert( + "model".to_string(), + serde_json::Value::String(model.clone()), + ); + } + + session.events.push(Event { + event_id: format!("{task_id}-start"), + timestamp: start_ts, + event_type: EventType::TaskStart { + title: Some(task_title), + }, + task_id: Some(task_id.clone()), + content: opensession_core::trace::Content::text(""), + duration_ms: None, + attributes: start_attrs, + }); + + for mut event in sub_session.events { + event.task_id = Some(task_id.clone()); + event.event_id = format!("{}:{}", task_id, event.event_id); + event.attributes.insert( + "subagent_id".to_string(), + serde_json::Value::String(task_id.clone()), + ); + event + .attributes + .insert("merged_subagent".to_string(), serde_json::Value::Bool(true)); + session.events.push(event); + } + + let duration = (end_ts - start_ts).num_milliseconds().max(0) as u64; + let mut end_attrs = HashMap::new(); + end_attrs.insert( + "subagent_id".to_string(), + serde_json::Value::String(task_id.clone()), + ); + end_attrs.insert("merged_subagent".to_string(), serde_json::Value::Bool(true)); + session.events.push(Event { + event_id: format!("{task_id}-end"), + timestamp: end_ts, + event_type: EventType::TaskEnd { + summary: Some(format!( + "{} events, {}", + sub_session.stats.event_count, sub_session.agent.model + )), + }, + task_id: Some(task_id), + content: opensession_core::trace::Content::text(""), + duration_ms: Some(duration), + attributes: end_attrs, + }); + } + + session.events.sort_by_key(|event| event.timestamp); +} + +#[derive(Debug)] +pub(super) struct SubagentMeta { + pub(super) slug: Option, + pub(super) agent_id: Option, + pub(super) session_id: Option, + pub(super) parent_session_id: Option, +} + +pub(super) fn read_subagent_meta(path: &Path) -> Option { + let file = std::fs::File::open(path).ok()?; + let mut reader = std::io::BufReader::new(file); + let mut first_line = String::new(); + reader.read_line(&mut first_line).ok()?; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct FirstLine { + #[serde(default)] + slug: Option, + #[serde(default)] + agent_id: Option, + #[serde(default)] + session_id: Option, + #[serde(default, alias = "parentUuid", alias = "parentID", alias = "parentId")] + parent_session_id: Option, + } + + let parsed: FirstLine = serde_json::from_str(&first_line).ok()?; + Some(SubagentMeta { + slug: parsed.slug, + agent_id: parsed.agent_id, + session_id: parsed.session_id, + parent_session_id: parsed.parent_session_id, + }) +} + +pub(super) fn parse_subagent_jsonl(path: &Path) -> Result { + let meta = read_subagent_meta(path); + let file = std::fs::File::open(path) + .with_context(|| format!("Failed to open subagent JSONL: {}", path.display()))?; + let reader = std::io::BufReader::new(file); + + let mut events: Vec = Vec::new(); + let mut model_name: Option = None; + let mut tool_version: Option = None; + let mut session_id: Option = None; + let mut cwd: Option = None; + let mut git_branch: Option = None; + let mut tool_use_info: HashMap = HashMap::new(); + + for line_result in reader.lines() { + let line = match line_result { + Ok(line) => line, + Err(_) => continue, + }; + if line.trim().is_empty() { + continue; + } + + let entry: RawEntry = match serde_json::from_str(&line) { + Ok(entry) => entry, + Err(_) => continue, + }; + + match entry { + RawEntry::FileHistorySnapshot {} | RawEntry::Unknown => continue, + RawEntry::System(system) => { + set_first(&mut session_id, system.session_id.clone()); + set_first(&mut tool_version, system.version.clone()); + set_first(&mut cwd, system.cwd.clone()); + set_first(&mut git_branch, system.git_branch.clone()); + events.push(super::parse::system_entry_to_event(&system, &events)); + } + RawEntry::Progress(progress) => { + set_first(&mut session_id, progress.session_id.clone()); + set_first(&mut tool_version, progress.version.clone()); + set_first(&mut cwd, progress.cwd.clone()); + set_first(&mut git_branch, progress.git_branch.clone()); + events.push(super::parse::progress_entry_to_event(&progress, &events)); + } + RawEntry::QueueOperation(queue_op) => { + set_first(&mut session_id, queue_op.session_id.clone()); + events.push(super::parse::queue_operation_entry_to_event( + &queue_op, &events, + )); + } + RawEntry::Summary(summary) => { + set_first(&mut session_id, summary.session_id.clone()); + events.push(super::parse::summary_entry_to_event(&summary, &events)); + } + RawEntry::User(conv) => { + set_first(&mut session_id, conv.session_id.clone()); + set_first(&mut tool_version, conv.version.clone()); + set_first(&mut cwd, conv.cwd.clone()); + set_first(&mut git_branch, conv.git_branch.clone()); + if let Ok(ts) = parse_timestamp(&conv.timestamp) { + process_user_entry(&conv, ts, &mut events, &tool_use_info); + } + } + RawEntry::Assistant(conv) => { + set_first(&mut session_id, conv.session_id.clone()); + set_first(&mut tool_version, conv.version.clone()); + set_first(&mut model_name, conv.message.model.clone()); + set_first(&mut git_branch, conv.git_branch.clone()); + if let Ok(ts) = parse_timestamp(&conv.timestamp) { + process_assistant_entry(&conv, ts, &mut events, &mut tool_use_info); + } + } + } + } + + let session_id = session_id.unwrap_or_else(|| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("unknown") + .to_string() + }); + + let agent = Agent { + provider: "anthropic".to_string(), + model: model_name.unwrap_or_else(|| "unknown".to_string()), + tool: "claude-code".to_string(), + tool_version, + }; + + let (created_at, updated_at) = + if let (Some(first), Some(last)) = (events.first(), events.last()) { + (first.timestamp, last.timestamp) + } else { + let now = Utc::now(); + (now, now) + }; + + let parent_session_id = meta + .as_ref() + .and_then(|value| value.parent_session_id.clone()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let mut attributes = HashMap::from([( + "source_path".to_string(), + serde_json::Value::String(path.to_string_lossy().to_string()), + )]); + attributes.insert( + "session_role".to_string(), + serde_json::Value::String(if parent_session_id.is_some() { + "auxiliary".to_string() + } else { + "primary".to_string() + }), + ); + if let Some(parent_session_id) = parent_session_id.as_ref() { + attributes.insert( + "parent_session_id".to_string(), + serde_json::Value::String(parent_session_id.clone()), + ); + } + if let Some(branch) = git_branch.as_ref() { + attributes.insert( + "git_branch".to_string(), + serde_json::Value::String(branch.clone()), + ); + } + + let context = SessionContext { + title: None, + description: None, + tags: vec!["claude-code".to_string()], + created_at, + updated_at, + related_session_ids: parent_session_id.clone().into_iter().collect(), + attributes, + }; + + let mut session = Session::new(session_id, agent); + session.context = context; + session.events = events; + session.recompute_stats(); + Ok(session) +} diff --git a/crates/parsers/src/claude_code/transform.rs b/crates/parsers/src/claude_code/transform.rs index 808d5196..184203a3 100644 --- a/crates/parsers/src/claude_code/transform.rs +++ b/crates/parsers/src/claude_code/transform.rs @@ -1,11 +1,11 @@ -use crate::common::{build_tool_result_content, ToolUseInfo}; +use crate::common::{ToolUseInfo, build_tool_result_content}; use opensession_core::trace::{Content, ContentBlock, EventType}; // ── Content transformation helpers ────────────────────────────────────────── /// Extract raw text from ToolResult content -pub(super) fn tool_result_content_to_string(content: &super::parse::ToolResultContent) -> String { - use super::parse::{ToolResultBlock, ToolResultContent}; +pub(super) fn tool_result_content_to_string(content: &super::raw::ToolResultContent) -> String { + use super::raw::{ToolResultBlock, ToolResultContent}; match content { ToolResultContent::Text(text) => text.clone(), ToolResultContent::Blocks(blocks) => { @@ -23,7 +23,7 @@ pub(super) fn tool_result_content_to_string(content: &super::parse::ToolResultCo /// Build structured Content for a ToolResult event (delegates to common helper). pub(super) fn build_cc_tool_result_content( - raw_content: &super::parse::ToolResultContent, + raw_content: &super::raw::ToolResultContent, tool_info: &ToolUseInfo, ) -> Content { let raw_text = tool_result_content_to_string(raw_content); @@ -231,7 +231,7 @@ pub(super) fn tool_use_content(name: &str, input: &serde_json::Value) -> Content #[cfg(test)] mod tests { - use super::super::parse::ToolResultContent; + use super::super::raw::ToolResultContent; use super::*; use crate::common::ToolUseInfo; @@ -304,7 +304,7 @@ mod tests { #[test] fn test_tool_result_content_blocks() { - use super::super::parse::ToolResultBlock; + use super::super::raw::ToolResultBlock; let content = ToolResultContent::Blocks(vec![ToolResultBlock::Text { text: "line1".to_string(), }]); diff --git a/crates/parsers/src/cline.rs b/crates/parsers/src/cline.rs index 7dd94b65..962f8738 100644 --- a/crates/parsers/src/cline.rs +++ b/crates/parsers/src/cline.rs @@ -1,8 +1,8 @@ +use crate::SessionParser; use crate::common::{ - build_tool_result_content, canonical_tool_name, extract_tag_content, set_first, - strip_system_reminders, ToolUseInfo, INTERACTIVE_USER_INPUT_TOOL, + INTERACTIVE_USER_INPUT_TOOL, ToolUseInfo, build_tool_result_content, canonical_tool_name, + extract_tag_content, set_first, strip_system_reminders, }; -use crate::SessionParser; use anyhow::{Context, Result}; use chrono::{TimeZone, Utc}; use opensession_core::trace::{ diff --git a/crates/parsers/src/codex.rs b/crates/parsers/src/codex.rs deleted file mode 100644 index 56e3c291..00000000 --- a/crates/parsers/src/codex.rs +++ /dev/null @@ -1,3498 +0,0 @@ -use crate::common::{ - attach_semantic_attrs, attach_source_attrs, canonical_tool_name, infer_tool_kind, set_first, - INTERACTIVE_USER_INPUT_TOOL, -}; -use crate::SessionParser; -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use opensession_core::session::{ATTR_PARENT_SESSION_ID, ATTR_SESSION_ROLE}; -use opensession_core::trace::{ - Agent, Content, ContentBlock, Event, EventType, Session, SessionContext, -}; -use std::collections::{BTreeMap, HashMap}; -use std::io::BufRead; -use std::path::{Path, PathBuf}; - -pub struct CodexParser; - -impl SessionParser for CodexParser { - fn name(&self) -> &str { - "codex" - } - - fn can_parse(&self, path: &Path) -> bool { - path.extension().is_some_and(|ext| ext == "jsonl") - && path - .to_str() - .is_some_and(|s| s.contains(".codex/sessions") || s.contains("codex/sessions")) - } - - fn parse(&self, path: &Path) -> Result { - parse_codex_jsonl(path) - } -} - -#[derive(Debug, Clone, Default)] -struct RequestUserInputCallMeta { - questions: Vec, -} - -#[derive(Debug, Clone, Default)] -struct InteractiveQuestionMeta { - id: String, - header: Option, - question: Option, -} - -// ── Parsing logic ─────────────────────────────────────────────────────────── -// -// Codex CLI JSONL format: -// Line 1: {id, timestamp, instructions, git?} — session header (no `type` field) -// Line 2+: {record_type: "state"} — state markers (skip) -// Line 3+: {type: "message"|"reasoning"|"function_call"|..., ...} — entries -// -// Model is NOT stored in the JSONL — it's in ~/.codex/config.toml globally. - -fn parse_codex_jsonl(path: &Path) -> Result { - let file = std::fs::File::open(path) - .with_context(|| format!("Failed to open Codex JSONL: {}", path.display()))?; - let reader = std::io::BufReader::new(file); - - let mut events: Vec = Vec::new(); - let mut session_id: Option = None; - let mut event_counter = 0u64; - let mut first_user_text: Option = None; - let mut last_function_name = "unknown".to_string(); - // call_id → (event_id, function_name) for correlating function_call_output - let mut call_map: HashMap = HashMap::new(); - let mut session_ts: Option> = None; - let mut git_info: Option = None; - let mut cwd: Option = None; - let mut tool_version: Option = None; - let mut originator: Option = None; - let mut parent_session_id: Option = None; - let mut is_auxiliary_session = false; - let mut is_desktop = false; - let mut open_tasks: BTreeMap> = BTreeMap::new(); - let mut interactive_call_meta: HashMap = HashMap::new(); - - for line_result in reader.lines() { - let line = match line_result { - Ok(l) => l, - Err(_) => continue, - }; - if line.trim().is_empty() { - continue; - } - - let v: serde_json::Value = match serde_json::from_str(&line) { - Ok(v) => v, - Err(_) => continue, - }; - - let obj = match v.as_object() { - Some(o) => o, - None => continue, - }; - - // State marker — skip - if obj.contains_key("record_type") { - continue; - } - - // Codex Desktop "session_meta" header (has `type: "session_meta"` + `payload`) - if obj.get("type").and_then(|v| v.as_str()) == Some("session_meta") { - is_desktop = true; - if let Some(payload) = obj.get("payload") { - set_first( - &mut session_id, - payload.get("id").and_then(|v| v.as_str()).map(String::from), - ); - if let Some(ts_str) = payload.get("timestamp").and_then(|v| v.as_str()) { - set_first(&mut session_ts, parse_timestamp(ts_str).ok()); - } - if let Some(git) = payload.get("git") { - set_first(&mut git_info, Some(git.clone())); - } - set_first( - &mut cwd, - payload - .get("cwd") - .and_then(|v| v.as_str()) - .map(String::from), - ); - set_first( - &mut tool_version, - payload - .get("cli_version") - .and_then(|v| v.as_str()) - .map(String::from), - ); - set_first( - &mut originator, - payload - .get("originator") - .and_then(|v| v.as_str()) - .map(String::from), - ); - if codex_desktop_payload_is_auxiliary(payload) { - is_auxiliary_session = true; - } - set_first( - &mut parent_session_id, - codex_desktop_parent_session_id(payload), - ); - } - continue; - } - - // Session header — no `type` field, has `id` + `timestamp` (legacy CLI format) - if !obj.contains_key("type") { - set_first( - &mut session_id, - obj.get("id").and_then(|v| v.as_str()).map(String::from), - ); - if let Some(ts_str) = obj.get("timestamp").and_then(|v| v.as_str()) { - set_first(&mut session_ts, parse_timestamp(ts_str).ok()); - } - if let Some(git) = obj.get("git") { - git_info = Some(git.clone()); - } - continue; - } - - let top_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); - - // Per-entry timestamp (Desktop format includes timestamp on each line) - let entry_ts = obj - .get("timestamp") - .and_then(|v| v.as_str()) - .and_then(|s| parse_timestamp(s).ok()) - .or(session_ts) - .unwrap_or_else(Utc::now); - - // Codex Desktop: `response_item` wraps the payload which has the same - // structure as legacy flat entries (message, reasoning, function_call, etc.) - if top_type == "response_item" { - if let Some(payload) = obj.get("payload") { - if payload.get("type").and_then(|v| v.as_str()) == Some("message") - && payload.get("role").and_then(|v| v.as_str()) == Some("user") - && looks_like_summary_batch_prompt(&extract_message_text_blocks( - payload.get("content"), - )) - { - is_auxiliary_session = true; - } - // In Desktop format, response_item/message/role=user includes - // system-injected content (AGENTS.md, env context). The real user - // message comes from event_msg/user_message, so skip first_user_text - // extraction here for Desktop sessions. - let mut discard_user_text: Option = None; - let user_text_target = if is_desktop { - &mut discard_user_text - } else { - &mut first_user_text - }; - process_item_with_options( - payload, - entry_ts, - &mut events, - &mut event_counter, - user_text_target, - &mut last_function_name, - &mut call_map, - &mut interactive_call_meta, - is_desktop, - ); - } - continue; - } - - // Codex Desktop: `event_msg` contains UI-level events - if top_type == "event_msg" { - if let Some(payload) = obj.get("payload") { - let payload_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match payload_type { - "user_message" => { - if let Some(msg) = payload.get("message").and_then(|v| v.as_str()) { - let text = msg.trim().to_string(); - if looks_like_summary_batch_prompt(&text) { - is_auxiliary_session = true; - } - if text.is_empty() || looks_like_injected_codex_user_text(&text) { - continue; - } - set_first(&mut first_user_text, Some(text.clone())); - push_user_message_event( - &mut events, - &mut event_counter, - entry_ts, - &text, - Some("event_msg"), - ); - } - } - "agent_message" => { - if let Some(msg) = payload - .get("message") - .or_else(|| payload.get("text")) - .or_else(|| payload.get("content")) - .and_then(|v| v.as_str()) - { - push_agent_message_event( - &mut events, - &mut event_counter, - entry_ts, - msg, - Some("event_msg"), - ); - } - } - "agent_reasoning" | "agent_reasoning_raw_content" => { - if let Some(reasoning) = payload - .get("message") - .or_else(|| payload.get("text")) - .or_else(|| payload.get("content")) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - { - event_counter += 1; - let mut attributes = HashMap::new(); - let raw_type = if payload_type == "agent_reasoning_raw_content" { - "event_msg:agent_reasoning_raw_content" - } else { - "event_msg:agent_reasoning" - }; - attach_source_attrs( - &mut attributes, - Some("codex-desktop-v1"), - Some(raw_type), - ); - events.push(Event { - event_id: format!("codex-{}", event_counter), - timestamp: entry_ts, - event_type: EventType::Thinking, - task_id: None, - content: Content::text(reasoning), - duration_ms: None, - attributes, - }); - } - } - "token_count" => { - let sampled = extract_token_counts(payload); - let cumulative = extract_total_token_counts(payload); - if sampled.is_some() || cumulative.is_some() { - event_counter += 1; - let mut attributes = HashMap::new(); - attach_source_attrs( - &mut attributes, - Some("codex-desktop-v1"), - Some("event_msg:token_count"), - ); - if let Some((input_tokens, output_tokens)) = sampled { - if let Some(input_tokens) = input_tokens { - attributes.insert( - "input_tokens".to_string(), - serde_json::Value::Number(input_tokens.into()), - ); - } - if let Some(output_tokens) = output_tokens { - attributes.insert( - "output_tokens".to_string(), - serde_json::Value::Number(output_tokens.into()), - ); - } - } - if let Some((input_total_tokens, output_total_tokens)) = cumulative { - if let Some(input_total_tokens) = input_total_tokens { - attributes.insert( - "input_tokens_total".to_string(), - serde_json::Value::Number(input_total_tokens.into()), - ); - } - if let Some(output_total_tokens) = output_total_tokens { - attributes.insert( - "output_tokens_total".to_string(), - serde_json::Value::Number(output_total_tokens.into()), - ); - } - } - events.push(Event { - event_id: format!("codex-{}", event_counter), - timestamp: entry_ts, - event_type: EventType::Custom { - kind: "token_count".to_string(), - }, - task_id: payload - .get("turn_id") - .or_else(|| payload.get("task_id")) - .and_then(|v| v.as_str()) - .map(str::to_string), - content: Content::empty(), - duration_ms: None, - attributes, - }); - } - } - "context_compacted" => { - event_counter += 1; - let mut attributes = HashMap::new(); - attach_source_attrs( - &mut attributes, - Some("codex-desktop-v1"), - Some("event_msg:context_compacted"), - ); - events.push(Event { - event_id: format!("codex-{}", event_counter), - timestamp: entry_ts, - event_type: EventType::Custom { - kind: "context_compacted".to_string(), - }, - task_id: payload - .get("turn_id") - .or_else(|| payload.get("task_id")) - .and_then(|v| v.as_str()) - .map(str::to_string), - content: Content::text("context compacted"), - duration_ms: None, - attributes, - }); - } - "item_completed" => { - let item = payload.get("item").unwrap_or(&serde_json::Value::Null); - let item_type = item - .get("type") - .and_then(|v| v.as_str()) - .map(str::trim) - .unwrap_or(""); - if item_type.eq_ignore_ascii_case("plan") { - event_counter += 1; - let mut attributes = HashMap::new(); - attach_source_attrs( - &mut attributes, - Some("codex-desktop-v1"), - Some("event_msg:item_completed"), - ); - if let Some(plan_id) = item.get("id").and_then(|v| v.as_str()) { - attributes.insert( - "plan_id".to_string(), - serde_json::Value::String(plan_id.to_string()), - ); - } - if let Some(turn_id) = payload.get("turn_id").and_then(|v| v.as_str()) { - attributes.insert( - "turn_id".to_string(), - serde_json::Value::String(turn_id.to_string()), - ); - } - let plan_preview = item - .get("text") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .and_then(|v| v.lines().find(|line| !line.trim().is_empty())) - .map(str::trim) - .unwrap_or("plan completed"); - events.push(Event { - event_id: format!("codex-{}", event_counter), - timestamp: entry_ts, - event_type: EventType::Custom { - kind: "plan_completed".to_string(), - }, - task_id: payload - .get("turn_id") - .or_else(|| payload.get("task_id")) - .and_then(|v| v.as_str()) - .map(str::to_string), - content: Content::text(format!("Plan completed: {plan_preview}")), - duration_ms: None, - attributes, - }); - } - } - "turn_aborted" => { - event_counter += 1; - let mut attributes = HashMap::new(); - if let Some(reason) = payload - .get("reason") - .or_else(|| payload.get("message")) - .or_else(|| payload.get("error")) - .and_then(|v| v.as_str()) - { - attributes.insert( - "reason".to_string(), - serde_json::Value::String(reason.to_string()), - ); - } - let task_id = payload - .get("turn_id") - .and_then(|v| v.as_str()) - .map(String::from); - events.push(Event { - event_id: format!("codex-{}", event_counter), - timestamp: entry_ts, - event_type: EventType::Custom { - kind: "turn_aborted".to_string(), - }, - task_id, - content: Content::text("turn aborted"), - duration_ms: None, - attributes, - }); - } - "task_started" => { - let turn_id = payload - .get("turn_id") - .or_else(|| payload.get("task_id")) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(String::from); - if let Some(task_id) = turn_id { - let title = payload - .get("title") - .or_else(|| payload.get("task")) - .or_else(|| payload.get("name")) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(String::from); - open_tasks.insert(task_id.clone(), title.clone()); - event_counter += 1; - events.push(Event { - event_id: format!("codex-{}", event_counter), - timestamp: entry_ts, - event_type: EventType::TaskStart { - title: title.clone(), - }, - task_id: Some(task_id), - content: Content::text( - title.unwrap_or_else(|| "task started".to_string()), - ), - duration_ms: None, - attributes: HashMap::new(), - }); - } - } - "task_complete" | "task_completed" | "task_finished" => { - let turn_id = payload - .get("turn_id") - .or_else(|| payload.get("task_id")) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(String::from); - if let Some(task_id) = turn_id { - let summary = payload - .get("last_agent_message") - .or_else(|| payload.get("summary")) - .or_else(|| payload.get("message")) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(String::from); - if let Some(summary_text) = summary.as_deref() { - push_agent_message_event( - &mut events, - &mut event_counter, - entry_ts, - summary_text, - Some("event_msg"), - ); - } - open_tasks.remove(&task_id); - event_counter += 1; - events.push(Event { - event_id: format!("codex-{}", event_counter), - timestamp: entry_ts, - event_type: EventType::TaskEnd { - summary: summary.clone(), - }, - task_id: Some(task_id), - content: Content::text( - summary.unwrap_or_else(|| "task completed".to_string()), - ), - duration_ms: None, - attributes: HashMap::new(), - }); - } - } - _ => {} - } - } - continue; - } - - // Skip other Desktop-only wrapper types - if top_type == "turn_context" { - continue; - } - - // Legacy flat entry with type field (message, reasoning, function_call, etc.) - process_item_with_options( - &v, - entry_ts, - &mut events, - &mut event_counter, - &mut first_user_text, - &mut last_function_name, - &mut call_map, - &mut interactive_call_meta, - is_desktop, - ); - } - - if !open_tasks.is_empty() { - let synthetic_ts = events - .last() - .map(|event| event.timestamp) - .or(session_ts) - .unwrap_or_else(Utc::now); - for (task_id, title) in open_tasks { - event_counter += 1; - events.push(Event { - event_id: format!("codex-{}", event_counter), - timestamp: synthetic_ts, - event_type: EventType::TaskEnd { - summary: Some("synthetic end (missing task_complete)".to_string()), - }, - task_id: Some(task_id), - content: Content::text(title.unwrap_or_else(|| "synthetic task end".to_string())), - duration_ms: None, - attributes: HashMap::new(), - }); - } - } - - // ── Build Session ─────────────────────────────────────────────────────── - - let session_id = session_id.unwrap_or_else(|| { - path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string() - }); - - let (provider, model) = load_codex_agent_identity(); - let agent = Agent { - provider, - model, - tool: "codex".to_string(), - tool_version, - }; - - let (created_at, updated_at) = - if let (Some(first), Some(last)) = (events.first(), events.last()) { - (first.timestamp, last.timestamp) - } else { - let now = session_ts.unwrap_or_else(Utc::now); - (now, now) - }; - - let mut attributes = HashMap::new(); - if let Some(git) = git_info { - if let Some(branch) = json_object_string( - &git, - &["branch", "git_branch", "current_branch", "ref", "head"], - ) { - attributes.insert("git_branch".to_string(), serde_json::Value::String(branch)); - } - if let Some(repo_name) = - json_object_string(&git, &["repo_name", "repository", "repo", "name"]) - { - attributes.insert( - "git_repo_name".to_string(), - serde_json::Value::String(repo_name), - ); - } - attributes.insert("git".to_string(), git); - } - if let Some(ref dir) = cwd { - attributes.insert("cwd".to_string(), serde_json::Value::String(dir.clone())); - } - if let Some(ref orig) = originator { - attributes.insert( - "originator".to_string(), - serde_json::Value::String(orig.clone()), - ); - } - if first_user_text - .as_deref() - .is_some_and(looks_like_summary_batch_prompt) - { - is_auxiliary_session = true; - } - let mut related_session_ids = Vec::new(); - if is_auxiliary_session { - attributes.insert( - ATTR_SESSION_ROLE.to_string(), - serde_json::Value::String("auxiliary".to_string()), - ); - if let Some(parent_id) = parent_session_id.as_ref() { - attributes.insert( - ATTR_PARENT_SESSION_ID.to_string(), - serde_json::Value::String(parent_id.clone()), - ); - related_session_ids.push(parent_id.clone()); - } - } - - let title = first_user_text.map(|t| { - if t.chars().count() > 80 { - let truncated: String = t.chars().take(77).collect(); - format!("{}...", truncated) - } else { - t - } - }); - - let context = SessionContext { - title, - description: None, - tags: vec!["codex".to_string()], - created_at, - updated_at, - related_session_ids, - attributes, - }; - - let mut session = Session::new(session_id, agent); - session.context = context; - session.events = events; - session.recompute_stats(); - - Ok(session) -} - -/// Process a flat entry with `type` at the top level. -#[cfg(test)] -fn process_item( - item: &serde_json::Value, - ts: DateTime, - events: &mut Vec, - counter: &mut u64, - first_user_text: &mut Option, - last_function_name: &mut String, - call_map: &mut HashMap, -) { - let mut interactive_call_meta = HashMap::new(); - process_item_with_options( - item, - ts, - events, - counter, - first_user_text, - last_function_name, - call_map, - &mut interactive_call_meta, - false, - ); -} - -#[allow(clippy::too_many_arguments)] -fn process_item_with_options( - item: &serde_json::Value, - ts: DateTime, - events: &mut Vec, - counter: &mut u64, - first_user_text: &mut Option, - last_function_name: &mut String, - call_map: &mut HashMap, - interactive_call_meta: &mut HashMap, - filter_injected_user_text: bool, -) { - let item_type = match item.get("type").and_then(|v| v.as_str()) { - Some(t) => t, - None => return, - }; - - match item_type { - "message" => { - let role = item.get("role").and_then(|v| v.as_str()).unwrap_or(""); - let text = extract_message_text_blocks(item.get("content")); - - if text.is_empty() { - return; - } - - if role == "user" - && filter_injected_user_text - && looks_like_injected_codex_user_text(&text) - { - return; - } - - let event_type = match role { - "user" => EventType::UserMessage, - "assistant" => EventType::AgentMessage, - "developer" | "system" => return, - _ => return, - }; - - if role == "user" { - set_first(first_user_text, Some(text.clone())); - } - - if matches!(event_type, EventType::UserMessage) { - let source = if filter_injected_user_text { - Some("response_fallback") - } else { - None - }; - push_user_message_event(events, counter, ts, &text, source); - } else { - let source = if filter_injected_user_text { - Some("response_fallback") - } else { - None - }; - push_agent_message_event(events, counter, ts, &text, source); - } - } - "reasoning" => { - let summaries = item - .get("summary") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - let text: String = summaries - .iter() - .filter_map(|s| { - let stype = s.get("type").and_then(|v| v.as_str())?; - if stype == "summary_text" { - s.get("text").and_then(|v| v.as_str()).map(String::from) - } else { - None - } - }) - .collect::>() - .join("\n"); - - if !text.is_empty() { - *counter += 1; - let mut attributes = HashMap::new(); - attach_source_attrs(&mut attributes, Some("codex-jsonl-v1"), Some("reasoning")); - events.push(Event { - event_id: format!("codex-{}", counter), - timestamp: ts, - event_type: EventType::Thinking, - task_id: None, - content: Content::text(&text), - duration_ms: None, - attributes, - }); - } - } - "function_call" | "custom_tool_call" => { - let raw_name = item - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let name = canonical_tool_name(raw_name); - let custom_input = item.get("input").and_then(|v| v.as_str()).unwrap_or(""); - // function_call: arguments is a JSON string - // custom_tool_call: input is a raw string (patch content, etc.) - let args: serde_json::Value = if item_type == "custom_tool_call" { - serde_json::json!({ "input": custom_input }) - } else { - let args_str = item - .get("arguments") - .and_then(|v| v.as_str()) - .unwrap_or("{}"); - serde_json::from_str(args_str).unwrap_or(serde_json::Value::Null) - }; - - let call_id = item - .get("call_id") - .and_then(|v| v.as_str()) - .map(str::to_string); - if name == INTERACTIVE_USER_INPUT_TOOL { - if let Some(call_id) = call_id.as_ref() { - let meta = parse_request_user_input_call_meta(&args); - if !meta.questions.is_empty() { - interactive_call_meta.insert(call_id.clone(), meta); - } - } - } - - let event_type = classify_codex_function(&name, &args); - let content = if item_type == "custom_tool_call" { - // Custom tools store input as raw text (e.g. patch content) - Content::text(custom_input) - } else { - codex_function_content(&name, &args) - }; - - *counter += 1; - let event_id = format!("codex-{}", counter); - let mut attributes = HashMap::new(); - attach_source_attrs( - &mut attributes, - Some("codex-jsonl-v1"), - Some(if item_type == "custom_tool_call" { - "custom_tool_call" - } else { - "function_call" - }), - ); - attach_semantic_attrs( - &mut attributes, - None, - call_id.as_deref(), - Some(infer_tool_kind(&name)), - ); - - if let Some(call_id) = call_id.as_deref() { - call_map.insert(call_id.to_string(), (event_id.clone(), name.clone())); - } - *last_function_name = name; - - events.push(Event { - event_id, - timestamp: ts, - event_type, - task_id: None, - content, - duration_ms: None, - attributes, - }); - } - "function_call_output" | "custom_tool_call_output" => { - let raw_output = item.get("output").and_then(|v| v.as_str()).unwrap_or(""); - - let (output_text, is_error, duration_ms) = parse_function_output(raw_output); - - // Correlate with function_call via call_id - let (call_id_ref, call_name) = - if let Some(cid) = item.get("call_id").and_then(|v| v.as_str()) { - if let Some((eid, name)) = call_map.get(cid) { - (Some(eid.clone()), name.clone()) - } else { - (None, last_function_name.clone()) - } - } else { - let prev_id = if *counter > 0 { - Some(format!("codex-{}", counter)) - } else { - None - }; - (prev_id, last_function_name.clone()) - }; - - if call_name == INTERACTIVE_USER_INPUT_TOOL { - let call_meta = item - .get("call_id") - .and_then(|v| v.as_str()) - .and_then(|call_id| interactive_call_meta.remove(call_id)); - if let Some((interactive_text, question_ids, raw_answers)) = - parse_request_user_input_answers(&output_text) - { - if let Some(meta) = call_meta { - if !meta.questions.is_empty() { - *counter += 1; - let mut attributes = HashMap::new(); - attributes.insert( - "source".to_string(), - serde_json::Value::String("interactive_question".to_string()), - ); - if let Some(call_id) = item.get("call_id").and_then(|v| v.as_str()) { - attributes.insert( - "call_id".to_string(), - serde_json::Value::String(call_id.to_string()), - ); - } - attributes.insert( - "question_ids".to_string(), - serde_json::Value::Array( - meta.questions - .iter() - .map(|q| serde_json::Value::String(q.id.clone())) - .collect(), - ), - ); - attributes.insert( - "question_meta".to_string(), - serde_json::Value::Array( - meta.questions - .iter() - .map(|q| { - let mut row = serde_json::Map::new(); - row.insert( - "id".to_string(), - serde_json::Value::String(q.id.clone()), - ); - if let Some(header) = q.header.as_ref() { - row.insert( - "header".to_string(), - serde_json::Value::String(header.clone()), - ); - } - if let Some(question) = q.question.as_ref() { - row.insert( - "question".to_string(), - serde_json::Value::String(question.clone()), - ); - } - serde_json::Value::Object(row) - }) - .collect(), - ), - ); - events.push(Event { - event_id: format!("codex-{}", counter), - timestamp: ts, - event_type: EventType::SystemMessage, - task_id: None, - content: Content::text(render_interactive_questions( - &meta.questions, - )), - duration_ms: None, - attributes, - }); - } - } - set_first(first_user_text, Some(interactive_text.clone())); - *counter += 1; - let mut attributes = HashMap::new(); - attributes.insert( - "source".to_string(), - serde_json::Value::String("interactive".to_string()), - ); - attributes.insert( - "question_ids".to_string(), - serde_json::Value::Array( - question_ids - .iter() - .map(|id| serde_json::Value::String(id.clone())) - .collect(), - ), - ); - if let Some(call_id) = item.get("call_id").and_then(|v| v.as_str()) { - attributes.insert( - "call_id".to_string(), - serde_json::Value::String(call_id.to_string()), - ); - } - attributes.insert("raw_answers".to_string(), raw_answers); - events.push(Event { - event_id: format!("codex-{}", counter), - timestamp: ts, - event_type: EventType::UserMessage, - task_id: None, - content: Content::text(interactive_text), - duration_ms: None, - attributes, - }); - } - } - - *counter += 1; - let semantic_call_id = item.get("call_id").and_then(|v| v.as_str()); - let mut attributes = HashMap::new(); - attach_source_attrs( - &mut attributes, - Some("codex-jsonl-v1"), - Some(if item_type == "custom_tool_call_output" { - "custom_tool_call_output" - } else { - "function_call_output" - }), - ); - attach_semantic_attrs( - &mut attributes, - None, - semantic_call_id, - Some(infer_tool_kind(&call_name)), - ); - events.push(Event { - event_id: format!("codex-{}", counter), - timestamp: ts, - event_type: EventType::ToolResult { - name: call_name, - is_error, - call_id: call_id_ref, - }, - task_id: None, - content: Content::text(&output_text), - duration_ms, - attributes, - }); - } - "web_search_call" => { - let action = item.get("action").unwrap_or(&serde_json::Value::Null); - let action_type = action - .get("type") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .unwrap_or(""); - let status = item - .get("status") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(String::from); - let semantic_call_id = item - .get("id") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()); - let mut query_candidates: Vec = action - .get("queries") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .filter_map(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(String::from) - .collect(); - if let Some(query) = action - .get("query") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - { - if !query_candidates.iter().any(|existing| existing == query) { - query_candidates.insert(0, query.to_string()); - } - } - let url = action - .get("url") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(String::from); - let pattern = action - .get("pattern") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(String::from); - - let web_event = match action_type { - "search" => { - if query_candidates.is_empty() { - None - } else { - let joined = query_candidates.join(" | "); - let primary = query_candidates - .first() - .cloned() - .unwrap_or_else(|| joined.clone()); - Some(( - EventType::WebSearch { query: primary }, - Content::text(joined), - )) - } - } - "open_page" | "openPage" => { - if let Some(url) = url.clone() { - Some((EventType::WebFetch { url: url.clone() }, Content::text(url))) - } else { - Some(( - EventType::ToolCall { - name: "web_search".to_string(), - }, - Content::text("open_page"), - )) - } - } - "find_in_page" | "findInPage" => { - if let Some(url) = url.clone() { - let mut details = url.clone(); - if let Some(pattern) = pattern.as_deref() { - details.push_str("\npattern: "); - details.push_str(pattern); - } - Some((EventType::WebFetch { url }, Content::text(details))) - } else { - pattern.clone().map(|pattern| { - ( - EventType::ToolCall { - name: "web_search".to_string(), - }, - Content::text(format!("find_in_page: {pattern}")), - ) - }) - } - } - _ => { - if !query_candidates.is_empty() { - let joined = query_candidates.join(" | "); - Some(( - EventType::WebSearch { - query: query_candidates - .first() - .cloned() - .unwrap_or_else(|| joined.clone()), - }, - Content::text(joined), - )) - } else if let Some(url) = url.clone() { - Some((EventType::WebFetch { url: url.clone() }, Content::text(url))) - } else { - pattern.clone().map(|pattern| { - ( - EventType::ToolCall { - name: "web_search".to_string(), - }, - Content::text(pattern), - ) - }) - } - } - }; - - if let Some((event_type, content)) = web_event { - *counter += 1; - let mut attributes = HashMap::new(); - let raw_type = if action_type.is_empty() { - "web_search_call".to_string() - } else { - format!("web_search_call:{action_type}") - }; - attach_source_attrs( - &mut attributes, - Some("codex-jsonl-v1"), - Some(raw_type.as_str()), - ); - attach_semantic_attrs(&mut attributes, None, semantic_call_id, Some("web")); - if let Some(status) = status { - attributes.insert( - "web_search.status".to_string(), - serde_json::Value::String(status), - ); - } - if !query_candidates.is_empty() { - attributes.insert( - "web_search.queries".to_string(), - serde_json::Value::Array( - query_candidates - .iter() - .map(|query| serde_json::Value::String(query.clone())) - .collect(), - ), - ); - } - if let Some(pattern) = pattern { - attributes.insert( - "web_search.pattern".to_string(), - serde_json::Value::String(pattern), - ); - } - events.push(Event { - event_id: format!("codex-{}", counter), - timestamp: ts, - event_type, - task_id: None, - content, - duration_ms: None, - attributes, - }); - } - } - _ => {} - } -} - -fn push_user_message_event( - events: &mut Vec, - counter: &mut u64, - ts: DateTime, - text: &str, - source: Option<&str>, -) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - if matches!(source, Some("event_msg")) { - remove_duplicate_response_fallback(events, ts, trimmed); - } - if should_skip_duplicate_user_event(events, ts, trimmed, source) { - return; - } - - *counter += 1; - let mut attributes = HashMap::new(); - if let Some(source) = source { - attributes.insert( - "source".to_string(), - serde_json::Value::String(source.to_string()), - ); - attach_source_attrs(&mut attributes, Some("codex-desktop-v1"), Some(source)); - } - events.push(Event { - event_id: format!("codex-{}", counter), - timestamp: ts, - event_type: EventType::UserMessage, - task_id: None, - content: Content::text(trimmed), - duration_ms: None, - attributes, - }); -} - -fn push_agent_message_event( - events: &mut Vec, - counter: &mut u64, - ts: DateTime, - text: &str, - source: Option<&str>, -) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - if matches!(source, Some("event_msg")) { - remove_duplicate_agent_response_fallback(events, ts, trimmed); - } - if should_skip_duplicate_agent_event(events, ts, trimmed, source) { - return; - } - - *counter += 1; - let mut attributes = HashMap::new(); - if let Some(source) = source { - attributes.insert( - "source".to_string(), - serde_json::Value::String(source.to_string()), - ); - attach_source_attrs(&mut attributes, Some("codex-desktop-v1"), Some(source)); - } - events.push(Event { - event_id: format!("codex-{}", counter), - timestamp: ts, - event_type: EventType::AgentMessage, - task_id: None, - content: Content::text(trimmed), - duration_ms: None, - attributes, - }); -} - -fn remove_duplicate_response_fallback(events: &mut Vec, ts: DateTime, text: &str) { - let normalized = normalize_user_text_for_dedupe(text); - events.retain(|event| { - if !matches!(event.event_type, EventType::UserMessage) { - return true; - } - if event - .attributes - .get("source") - .and_then(|value| value.as_str()) - != Some("response_fallback") - { - return true; - } - if (event.timestamp - ts).num_seconds().abs() > 12 { - return true; - } - event_user_text(event) - .map(|existing| !user_texts_equivalent(&existing, &normalized)) - .unwrap_or(true) - }); -} - -fn remove_duplicate_agent_response_fallback( - events: &mut Vec, - ts: DateTime, - text: &str, -) { - let normalized = normalize_user_text_for_dedupe(text); - events.retain(|event| { - if !matches!(event.event_type, EventType::AgentMessage) { - return true; - } - if event - .attributes - .get("source") - .and_then(|value| value.as_str()) - != Some("response_fallback") - { - return true; - } - if (event.timestamp - ts).num_seconds().abs() > 12 { - return true; - } - event_agent_text(event) - .map(|existing| !user_texts_equivalent(&existing, &normalized)) - .unwrap_or(true) - }); -} - -fn should_skip_duplicate_user_event( - events: &[Event], - ts: DateTime, - text: &str, - source: Option<&str>, -) -> bool { - let source = match source { - Some(source) => source, - None => return false, - }; - let opposite = match opposite_dedupe_source(source) { - Some(opposite) => opposite, - None => return false, - }; - let normalized = normalize_user_text_for_dedupe(text); - events.iter().any(|event| { - if !matches!(event.event_type, EventType::UserMessage) { - return false; - } - let event_source = event - .attributes - .get("source") - .and_then(|value| value.as_str()); - if event_source != Some(opposite) && event_source != Some(source) { - return false; - } - let duplicate_window_secs = if event_source == Some(source) { 2 } else { 12 }; - if (event.timestamp - ts).num_seconds().abs() > duplicate_window_secs { - return false; - } - event_user_text(event) - .map(|existing| user_texts_equivalent(&existing, &normalized)) - .unwrap_or(false) - }) -} - -fn should_skip_duplicate_agent_event( - events: &[Event], - ts: DateTime, - text: &str, - source: Option<&str>, -) -> bool { - let source = match source { - Some(source) => source, - None => return false, - }; - let opposite = match opposite_dedupe_source(source) { - Some(opposite) => opposite, - None => return false, - }; - let normalized = normalize_user_text_for_dedupe(text); - events.iter().any(|event| { - if !matches!(event.event_type, EventType::AgentMessage) { - return false; - } - let event_source = event - .attributes - .get("source") - .and_then(|value| value.as_str()); - if event_source != Some(opposite) && event_source != Some(source) { - return false; - } - let duplicate_window_secs = if event_source == Some(source) { 2 } else { 12 }; - if (event.timestamp - ts).num_seconds().abs() > duplicate_window_secs { - return false; - } - event_agent_text(event) - .map(|existing| user_texts_equivalent(&existing, &normalized)) - .unwrap_or(false) - }) -} - -fn opposite_dedupe_source(source: &str) -> Option<&'static str> { - match source { - "event_msg" => Some("response_fallback"), - "response_fallback" => Some("event_msg"), - _ => None, - } -} - -fn event_user_text(event: &Event) -> Option { - if !matches!(event.event_type, EventType::UserMessage) { - return None; - } - let mut out = Vec::new(); - for block in &event.content.blocks { - if let ContentBlock::Text { text } = block { - for line in text.lines() { - let trimmed = line.trim(); - if !trimmed.is_empty() { - out.push(trimmed.to_string()); - } - } - } - } - if out.is_empty() { - None - } else { - Some(out.join("\n")) - } -} - -fn event_agent_text(event: &Event) -> Option { - if !matches!(event.event_type, EventType::AgentMessage) { - return None; - } - let mut out = Vec::new(); - for block in &event.content.blocks { - if let ContentBlock::Text { text } = block { - for line in text.lines() { - let trimmed = line.trim(); - if !trimmed.is_empty() { - out.push(trimmed.to_string()); - } - } - } - } - if out.is_empty() { - None - } else { - Some(out.join("\n")) - } -} - -fn normalize_user_text_for_dedupe(text: &str) -> String { - let normalized = text - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .filter(|line| { - let lower = line.to_ascii_lowercase(); - !matches!( - lower.as_str(), - "" | "" | "