diff --git a/(pwd)/dev/test-runs/pw-browsers/.links/3810710a7adc295959bdc2790dead5e21821c0a7 b/(pwd)/dev/test-runs/pw-browsers/.links/3810710a7adc295959bdc2790dead5e21821c0a7 new file mode 100644 index 00000000..360094d8 --- /dev/null +++ b/(pwd)/dev/test-runs/pw-browsers/.links/3810710a7adc295959bdc2790dead5e21821c0a7 @@ -0,0 +1 @@ +/home/patch/PycharmProjects/scidk/.venv/lib/python3.12/site-packages/playwright/driver/package \ No newline at end of file diff --git a/.commitmsg b/.commitmsg new file mode 100644 index 00000000..64c01f57 --- /dev/null +++ b/.commitmsg @@ -0,0 +1,9 @@ +UX: Files and Snapshot parity; move scan controls; rescan visibility and progress + +- Files page: moved scan controls (Scan / Scan with selection / override toggle) into a toolbar above the file browser so they are never covered by the details panel; right panel now shows only item details. +- Files page: kept select-all header checkbox for Gmail-like selection. +- Snapshot browser: made table header include a select-all checkbox and row checkboxes for visual parity (informational for now), keeping the same crumb/detail panel behavior. +- Rescans visibility: API /api/scans now includes rescan_of in summaries; detail includes rescan_of as well. UI Scans Summary and Scans panel display a "rescan" badge and the original scan id. +- Rescan progress: Snapshot "Rescan" button now starts a background scan task using the original scan parameters and stored selection, so progress shows in the Tasks panel and persists across page switches. + +No backend schema change; minimal additions in /api/scans payload and frontend templates. \ No newline at end of file diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 00000000..e69de29b diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..751ff59c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,58 @@ +name: Tests + +on: + push: + branches: [ main, master, develop, release/** ] + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + tier: [ unit, integration, e2e ] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python deps + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + - name: Install Playwright browsers + if: matrix.tier == 'e2e' + uses: microsoft/playwright-github-action@v1 + - name: Prepare repo-local temp directories + run: | + mkdir -p dev/test-runs/{tmp,pytest-tmp,artifacts,downloads,pw-browsers} + - name: Run tests by tier + env: + SCIDK_E2E: ${{ matrix.tier == 'e2e' && '1' || '0' }} + TMPDIR: ${{ github.workspace }}/dev/test-runs/tmp + PYTEST_ADDOPTS: --basetemp=${{ github.workspace }}/dev/test-runs/pytest-tmp + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/dev/test-runs/pw-browsers + run: | + case "${{ matrix.tier }}" in + unit) + pytest -m "not integration and not e2e" -q + ;; + integration) + pytest -m integration -q + ;; + e2e) + pytest -m e2e tests/e2e -v --maxfail=1 --suppress-no-test-exit-code + ;; + esac + - name: Upload Playwright report (on failure) + if: failure() && matrix.tier == 'e2e' + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + dev/test-runs/artifacts/ + dev/test-runs/pytest-tmp/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index dc9aad22..1c9d63e8 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,14 @@ data/* *.db *.db-shm *.db-wal +# Local test artifacts +dev/test-runs/ +playwright-report/ +test-results/ +pytest-of-patch/ +.output.txt +# Accidental directories from env expansion +(pwd)/ +sqlite:/ +sqlite:/home +sqlite:/tmp diff --git a/Makefile b/Makefile index 9593176e..119846d5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Convenience Makefile for docs/tools -.PHONY: flags-index docs-check +.PHONY: flags-index docs-check unit integration check e2e-install-browsers e2e e2e-headed e2e-parallel e2e-debug flags-index: python -m dev.tools.feature_flags_index --write @@ -8,5 +8,63 @@ flags-index: # docs-check: run generator and diff; non-zero exit if mismatched # Note: this target assumes Unix tools (diff) docs-check: - python -m dev.tools.feature_flags_index > /tmp/feature-flags.md - diff -q /tmp/feature-flags.md dev/features/feature-flags.md + @mkdir -p dev/test-runs/tmp + python -m dev.tools.feature_flags_index > dev/test-runs/tmp/feature-flags.md + diff -q dev/test-runs/tmp/feature-flags.md dev/features/feature-flags.md + +# Test tiers +unit: + @mkdir -p dev/test-runs/{tmp,pytest-tmp} + TMPDIR=$$(pwd)/dev/test-runs/tmp \ + PYTEST_ADDOPTS="--basetemp=$$(pwd)/dev/test-runs/pytest-tmp" \ + pytest -m "not integration and not e2e" -q + +integration: + @mkdir -p dev/test-runs/{tmp,pytest-tmp} + TMPDIR=$$(pwd)/dev/test-runs/tmp \ + PYTEST_ADDOPTS="--basetemp=$$(pwd)/dev/test-runs/pytest-tmp" \ + pytest -m integration -q + +check: + $(MAKE) unit && $(MAKE) integration && $(MAKE) e2e + +# Install Playwright browsers locally (no root/apt deps); install into repo cache +e2e-install-browsers: + @mkdir -p dev/test-runs/{tmp,pw-browsers} + PLAYWRIGHT_BROWSERS_PATH=$$(pwd)/dev/test-runs/pw-browsers \ + TMPDIR=$$(pwd)/dev/test-runs/tmp \ + .venv/bin/python -m playwright install chromium + +# Run headless E2E tests +# Ensures port 5001 is used by tests; app is auto-started by tests/e2e/conftest.py +e2e: + @mkdir -p dev/test-runs/{tmp,pytest-tmp,artifacts,downloads,pw-browsers} + SCIDK_E2E=1 TMPDIR=$$(pwd)/dev/test-runs/tmp TMP=$$(pwd)/dev/test-runs/tmp TEMP=$$(pwd)/dev/test-runs/tmp PYTEST_ADDOPTS="--basetemp=$$(pwd)/dev/test-runs/pytest-tmp" PLAYWRIGHT_BROWSERS_PATH=$$(pwd)/dev/test-runs/pw-browsers pytest -m e2e tests/e2e -v --maxfail=1 + +# Run E2E tests in headed mode with Playwright inspector +e2e-headed: + @mkdir -p dev/test-runs/{tmp,pytest-tmp,artifacts,downloads,pw-browsers} + SCIDK_E2E=1 PLAYWRIGHT_HEADLESS=0 PWDEBUG=1 TMPDIR=$$(pwd)/dev/test-runs/tmp TMP=$$(pwd)/dev/test-runs/tmp TEMP=$$(pwd)/dev/test-runs/tmp PYTEST_ADDOPTS="--basetemp=$$(pwd)/dev/test-runs/pytest-tmp" PLAYWRIGHT_BROWSERS_PATH=$$(pwd)/dev/test-runs/pw-browsers pytest -m e2e tests/e2e -q + +# Run E2E in parallel (requires pytest-xdist if desired; Playwright supports built-in workers) +e2e-parallel: + @mkdir -p dev/test-runs/{tmp,pytest-tmp,artifacts,downloads,pw-browsers} + SCIDK_E2E=1 TMPDIR=$$(pwd)/dev/test-runs/tmp PYTEST_ADDOPTS="--basetemp=$$(pwd)/dev/test-runs/pytest-tmp" PLAYWRIGHT_BROWSERS_PATH=$$(pwd)/dev/test-runs/pw-browsers pytest -m e2e tests/e2e -q -n auto || SCIDK_E2E=1 TMPDIR=$$(pwd)/dev/test-runs/tmp PYTEST_ADDOPTS="--basetemp=$$(pwd)/dev/test-runs/pytest-tmp" PLAYWRIGHT_BROWSERS_PATH=$$(pwd)/dev/test-runs/pw-browsers pytest -m e2e tests/e2e -q + +# Verbose debugging output for E2E runs +e2e-debug: + @mkdir -p dev/test-runs/{tmp,pytest-tmp,artifacts,downloads,pw-browsers} + SCIDK_E2E=1 TMPDIR=$$(pwd)/dev/test-runs/tmp PYTEST_ADDOPTS="--basetemp=$$(pwd)/dev/test-runs/pytest-tmp -vv -s" PLAYWRIGHT_BROWSERS_PATH=$$(pwd)/dev/test-runs/pw-browsers pytest -m e2e tests/e2e + +# Demo recording: runs a single E2E that captures screenshots and API JSON +# Artifacts go to dev/test-runs/last-demo by default (override with DEMO_ARTIFACTS_DIR) +demo-record: + @mkdir -p dev/test-runs/{tmp,pytest-tmp,artifacts,downloads,pw-browsers} + SCIDK_E2E=1 DEMO_ARTIFACTS_DIR=$${DEMO_ARTIFACTS_DIR:-dev/test-runs/last-demo} TMPDIR=$$(pwd)/dev/test-runs/tmp PYTEST_ADDOPTS="--basetemp=$$(pwd)/dev/test-runs/pytest-tmp" PLAYWRIGHT_BROWSERS_PATH=$$(pwd)/dev/test-runs/pw-browsers pytest -m e2e tests/e2e/test_demo_recording.py -q ; \ + echo "Artifacts saved under: $${DEMO_ARTIFACTS_DIR:-dev/test-runs/last-demo}" + +# Demo recording in headed mode with inspector +demo-record-headed: + @mkdir -p dev/test-runs/{tmp,pytest-tmp,artifacts,downloads,pw-browsers} + SCIDK_E2E=1 PLAYWRIGHT_HEADLESS=0 PWDEBUG=1 DEMO_ARTIFACTS_DIR=$${DEMO_ARTIFACTS_DIR:-dev/test-runs/last-demo} TMPDIR=$$(pwd)/dev/test-runs/tmp PYTEST_ADDOPTS="--basetemp=$$(pwd)/dev/test-runs/pytest-tmp" PLAYWRIGHT_BROWSERS_PATH=$$(pwd)/dev/test-runs/pw-browsers pytest -m e2e tests/e2e/test_demo_recording.py -q ; \ + echo "Artifacts saved under: $${DEMO_ARTIFACTS_DIR:-dev/test-runs/last-demo}" diff --git a/README.md b/README.md index 66507b56..92c746b5 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,41 @@ Note: The scanner prefers NCDU for fast filesystem enumeration when available. I - Editable install error (Multiple top-level packages discovered): We ship setuptools config to include only the scidk package. If you previously had this error, pull latest and try again: `pip install -e .`. - Shell errors when initializing env: Use the script matching your shell (`init_env.sh` for bash/zsh, `init_env.fish` for fish). Avoid running `sh scripts/init_env.sh`; instead, source it. +## End-to-End (E2E) tests + +These tests run in a real browser using Playwright and pytest. The test suite automatically starts the Flask app on port 5001 with safe defaults and no external Neo4j connection. + +Prereqs (once per machine): +- Python virtual environment activated. +- Install dev dependencies and Playwright browsers. + +Commands: +``` +# Install dev deps (if not yet installed) +pip install -e .[dev] + +# Install Playwright browsers (Chromium, Firefox, WebKit) +make e2e-install-browsers # or: python -m playwright install --with-deps + +# Run headless E2E tests +make e2e # or: pytest -m e2e tests/e2e -q + +# Run headed with inspector (debug mode) +make e2e-headed # or: PLAYWRIGHT_HEADLESS=0 PWDEBUG=1 pytest -m e2e tests/e2e -q + +# Parallel execution (if pytest-xdist is installed; falls back to serial) +make e2e-parallel +``` + +Notes: +- The E2E test fixture sets: + - SCIDK_PORT=5001 + - NEO4J_AUTH=none + - SCIDK_PROVIDERS=local_fs + - SCIDK_DB_PATH=sqlite:///:memory: +- Ensure port 5001 is free before running, or adjust the fixture if needed. +- For verbose logs during a failing test, use: `make e2e-debug` or `pytest -m e2e -vv -s`. + ## Neo4j Password: How to Set/Change - Testing default: The testing Neo4j database uses password `neo4jiscool`. Set this in the app Settings or via environment. - Choose your password before first start by setting NEO4J_AUTH in .env or your shell (example uses testing default): @@ -304,3 +339,145 @@ Notes for agents: - Prefer `menu --json` for a quick navigable overview of commands. - Use `introspect` to obtain full metadata about args/options, side-effects, and conventions. - Place `--json` before the subcommand to ensure the envelope applies to the whole invocation, e.g., `python -m dev.cli --json ready-queue`. + + +## Neo4j in Docker with UI on port 7474 + +We ship a docker-compose file that runs Neo4j 5 and exposes the built-in HTTP service on the classic port 7474, while Bolt remains on 7687. Follow Neo4j’s Docker volume guidelines for persistence (/data, /logs, /plugins, /import). + +Quick start: + +``` +# Optional: set password (default is neo4j/neo4jiscool) +export NEO4J_AUTH=neo4j/neo4jiscool + +# Optional: override host directories (defaults are under ./data/neo4j) +export NEO4J_HOST_DATA_DIR=${NEO4J_HOST_DATA_DIR:-./data/neo4j/data} +export NEO4J_HOST_LOGS_DIR=${NEO4J_HOST_LOGS_DIR:-./data/neo4j/logs} +export NEO4J_HOST_PLUGINS_DIR=${NEO4J_HOST_PLUGINS_DIR:-./data/neo4j/plugins} +export NEO4J_HOST_IMPORT_DIR=${NEO4J_HOST_IMPORT_DIR:-./data/neo4j/import} + +# Start Neo4j in the background +docker compose -f docker-compose.neo4j.yml up -d + +# Open the UI (Browser/Workspace availability depends on the server image/version) +http://localhost:7474/ +``` + +Notes: +- We do NOT mount or write to system paths like /var/lib/neo4j on the host. By default we persist under the repository at ./data/neo4j, which works without root. +- You can override the host directories per environment using NEO4J_HOST_* variables shown above (use absolute or relative paths you own). +- Ports: 7474 (HTTP), 7687 (Bolt). Adjust in docker-compose.neo4j.yml if occupied. +- Volumes per Neo4j Docker docs: bind host dirs to /data, /logs, /plugins, and /import inside the container. +- Avoid mounting anything under /var/lib/neo4j inside the container. The entrypoint changes ownership in that path and can cause permission issues. Stick to /data, /logs, /plugins, and /import. + +Manage lifecycle: +``` +# Stop containers +docker compose -f docker-compose.neo4j.yml down + +# Stop and remove all data (DANGER: wipes the graph) +docker compose -f docker-compose.neo4j.yml down -v +``` + +Connect SciDK to this Neo4j: +``` +export NEO4J_URI=bolt://localhost:7687 +export NEO4J_AUTH=${NEO4J_AUTH:-neo4j/neo4jiscool} +# Optional named database +echo "SCIDK_NEO4J_DATABASE=neo4j" >> .env + +# Start SciDK +scidk-serve +# or +python -m scidk.app +``` + + + + + +--- + +## Test tiers (Python 3.12) + +Our CI runs all tests under Python 3.12 in three tiers using pytest markers: +- unit: fast, pure unit tests that do not touch network/DB/browser +- integration: tests that touch DB/files/HTTP without a browser +- e2e: full-browser Playwright tests + +Local commands: +- make unit → pytest -m "not integration and not e2e" +- make integration → pytest -m integration +- make e2e → pytest -m e2e tests/e2e -q +- make check → runs unit, integration, and e2e sequentially + +See .github/workflows/tests.yml for the CI matrix that runs each tier. + +## Verify CI and Record Demo Artifacts + +Follow these steps to verify the full test suite and automatically capture screenshots/JSON for the demo. + +1) Verify CI on GitHub +- Navigate to GitHub → Actions → "Tests" workflow (defined in `.github/workflows/tests.yml`). +- Confirm that all three matrix jobs are green: + - tier=unit + - tier=integration + - tier=e2e (installs Playwright browsers automatically) +- Click into the latest run to see logs if any job is red. + +2) Run all tests locally (mirrors CI) +``` +make check +``` +This runs unit → integration → e2e sequentially under Python 3.12. + +3) Capture demo screenshots and API snapshots (automated) +- Headless (recommended for CI or quick local runs): +``` +make demo-record +``` +- Headed with Playwright inspector (debugging): +``` +make demo-record-headed +``` +Artifacts are saved under `dev/test-runs/last-demo` by default. Override the output directory with: +``` +DEMO_ARTIFACTS_DIR=dev/test-runs/my-demo make demo-record +``` +Generated artifacts include: +- `01-home.png`, `02-datasets-before.png`, `03-datasets-after.png`, `04-map.png` +- `api-api-health.json`, `api-api-scans.json`, `api-api-directories.json`, `api-api-tasks.json` +- `SUMMARY.json` with the artifact path and timestamp + +4) Tag and record (optional) +You can create a tag and attach the artifact folder to a GitHub Release: +``` +git tag -a vX.Y.Z -m "Cycle demo: SQLite persistence + selective scan cache" +git push origin vX.Y.Z +# Then, on GitHub → Releases → Draft a new release → Attach the files from dev/test-runs/... +``` + +Troubleshooting: +- First-time Playwright run locally: install browsers with `make e2e-install-browsers`. +- Port conflicts: ensure 127.0.0.1:5001 is free; the E2E harness auto-starts the app on that port. +- Backend toggle: default is SQLite. Override with `export SCIDK_STATE_BACKEND=memory` before `make e2e` if you need the legacy path. + +## State backend toggle and Health endpoint + +The app can read registry state (scans, directories, tasks, telemetry) through SQLite or in-memory structures. +- Default: SCIDK_STATE_BACKEND=sqlite +- Fallback: SCIDK_STATE_BACKEND=memory (restores legacy in-memory reads) + +Set the backend via environment before starting the app: +``` +export SCIDK_STATE_BACKEND=sqlite # or: memory +scidk-serve +``` + +Health endpoint includes SQLite details useful during migrations and troubleshooting: +- GET /api/health → { sqlite: { path, exists, journal_mode, wal_mode, schema_version, select1, error? } } + +Notes: +- Auto-migrations run on boot and /api/health reports the final schema_version. +- WAL mode is enabled by default; journal_mode and wal_mode are both reported for clarity. diff --git a/Requests.xlsx/Requests.xlsx b/Requests.xlsx/Requests.xlsx new file mode 100644 index 00000000..3fa0129c --- /dev/null +++ b/Requests.xlsx/Requests.xlsx @@ -0,0 +1,10 @@ +@odata.etag,ItemInternalId,ID,Title,UID,Priority,Priority#Id,Created,Author,Author#Claims,Whoe_x0020_else_x0020_is_x0020_i,Whoe_x0020_else_x0020_is_x0020_i@odata.type,Whoe_x0020_else_x0020_is_x0020_i#Claims,Whoe_x0020_else_x0020_is_x0020_i#Claims@odata.type,Description,Modified,Editor,Editor#Claims,Assigned_x0020_To,Assigned_x0020_To@odata.type,Assigned_x0020_To#Claims,Assigned_x0020_To#Claims@odata.type,{Identifier},{IsFolder},{Thumbnail},{Link},{Name},{FilenameWithExtension},{Path},{FullPath},{ContentType},{ContentType}#Id,{HasAttachments},{VersionNumber} +"""4""",1,1,Testing out the list,20250805_Testing_0000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedReference"",""Id"":1,""Value"":""Medium""}",1,2025-08-05T18:29:54Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,"[{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|vspan@mit.edu"",""DisplayName"":""Virginia Spanoudaki"",""Email"":""vspan@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=vspan@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Research Scientist""}]",#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),"[""i:0#.f|membership|vspan@mit.edu""]",#Collection(String),"Here is a test request, just seeing how well this works.",2025-08-05T19:25:51Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,"[{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}]",#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),"[""i:0#.f|membership|patch@mit.edu""]",#Collection(String),Lists%252fRequest%252f1_.000,False,"{""Large"":null,""Medium"":null,""Small"":null}",https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/listform.aspx?PageType=4&ListId=c0d51e67%2D07ac%2D4cd0%2D9d21%2D3c0f3dfd189d&ID=1&ContentTypeID=0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,Testing out the list,Testing out the list,Lists/Request/,Lists/Request/1_.000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedContentType"",""Id"":""0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8"",""Name"":""Item""}",0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,True,4.0 +"""2""",2,2,Migrate Clickup notes to OneNote,20250805_Migrate_0000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedReference"",""Id"":1,""Value"":""Medium""}",1,2025-08-05T19:19:15Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,[],#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),[],#Collection(String),"Need to remove all Clickup data by September - should all work fine in OneNote. + +This ticketing system will fit in nicely!",2025-08-05T19:25:51Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,"[{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}]",#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),"[""i:0#.f|membership|patch@mit.edu""]",#Collection(String),Lists%252fRequest%252f2_.000,False,"{""Large"":null,""Medium"":null,""Small"":null}",https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/listform.aspx?PageType=4&ListId=c0d51e67%2D07ac%2D4cd0%2D9d21%2D3c0f3dfd189d&ID=2&ContentTypeID=0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,Migrate Clickup notes to OneNote,Migrate Clickup notes to OneNote,Lists/Request/,Lists/Request/2_.000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedContentType"",""Id"":""0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8"",""Name"":""Item""}",0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,False,2.0 +"""1""",3,3,Need to scan and organize Laura's data,20250806_Need_0000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedReference"",""Id"":1,""Value"":""Medium""}",1,2025-08-06T14:10:58Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,"[{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|scott804@mit.edu"",""DisplayName"":""Anderson Scott"",""Email"":""scott804@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=scott804@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Image Analysis Scientist""}]",#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),"[""i:0#.f|membership|scott804@mit.edu""]",#Collection(String),Need to revisit working on Laura's data to make sure all _Rec folders properly annotated.,2025-08-06T14:10:58Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,"[{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}]",#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),"[""i:0#.f|membership|patch@mit.edu""]",#Collection(String),Lists%252fRequest%252f3_.000,False,"{""Large"":null,""Medium"":null,""Small"":null}",https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/listform.aspx?PageType=4&ListId=c0d51e67%2D07ac%2D4cd0%2D9d21%2D3c0f3dfd189d&ID=3&ContentTypeID=0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,Need to scan and organize Laura's data,Need to scan and organize Laura's data,Lists/Request/,Lists/Request/3_.000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedContentType"",""Id"":""0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8"",""Name"":""Item""}",0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,False,1.0 +"""1""",4,4,Power automate test,20251007_Power_0000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedReference"",""Id"":1,""Value"":""Medium""}",1,2025-10-07T19:32:49Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,[],#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),[],#Collection(String),I amtesting a workflow I created in Power Automate,2025-10-07T19:32:49Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,"[{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}]",#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),"[""i:0#.f|membership|patch@mit.edu""]",#Collection(String),Lists%252fRequest%252f4_.000,False,"{""Large"":null,""Medium"":null,""Small"":null}",https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/listform.aspx?PageType=4&ListId=c0d51e67%2D07ac%2D4cd0%2D9d21%2D3c0f3dfd189d&ID=4&ContentTypeID=0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,Power automate test,Power automate test,Lists/Request/,Lists/Request/4_.000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedContentType"",""Id"":""0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8"",""Name"":""Item""}",0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,False,1.0 +"""1""",5,5,Testing Power Automate again,20251007_Testing_0000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedReference"",""Id"":2,""Value"":""High""}",2,2025-10-07T19:38:19Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,[],#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),[],#Collection(String),"This time, I included a created at date in the file it generates",2025-10-07T19:38:19Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,"[{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}]",#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),"[""i:0#.f|membership|patch@mit.edu""]",#Collection(String),Lists%252fRequest%252f5_.000,False,"{""Large"":null,""Medium"":null,""Small"":null}",https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/listform.aspx?PageType=4&ListId=c0d51e67%2D07ac%2D4cd0%2D9d21%2D3c0f3dfd189d&ID=5&ContentTypeID=0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,Testing Power Automate again,Testing Power Automate again,Lists/Request/,Lists/Request/5_.000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedContentType"",""Id"":""0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8"",""Name"":""Item""}",0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,False,1.0 +"""1""",6,6,The last one didn't work,20251007_The_0000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedReference"",""Id"":2,""Value"":""High""}",2,2025-10-07T19:39:37Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,[],#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),[],#Collection(String),What about this one?,2025-10-07T19:39:37Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,"[{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}]",#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),"[""i:0#.f|membership|patch@mit.edu""]",#Collection(String),Lists%252fRequest%252f6_.000,False,"{""Large"":null,""Medium"":null,""Small"":null}",https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/listform.aspx?PageType=4&ListId=c0d51e67%2D07ac%2D4cd0%2D9d21%2D3c0f3dfd189d&ID=6&ContentTypeID=0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,The last one didn't work,The last one didn't work,Lists/Request/,Lists/Request/6_.000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedContentType"",""Id"":""0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8"",""Name"":""Item""}",0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,False,1.0 +"""1""",7,7,One more try,20251007_One_0000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedReference"",""Id"":0,""Value"":""Low""}",0,2025-10-07T19:43:21Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,[],#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),[],#Collection(String),I think I fixed the power automate,2025-10-07T19:43:21Z,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}",i:0#.f|membership|patch@mit.edu,"[{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser"",""Claims"":""i:0#.f|membership|patch@mit.edu"",""DisplayName"":""Adam Patch"",""Email"":""patch@mit.edu"",""Picture"":""https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/UserPhoto.aspx?Size=L&AccountName=patch@mit.edu"",""Department"":""David H Koch Institute for Integrative Cancer Res"",""JobTitle"":""Computer/Data Scientist""}]",#Collection(Microsoft.Azure.Connectors.SharePoint.SPListExpandedUser),"[""i:0#.f|membership|patch@mit.edu""]",#Collection(String),Lists%252fRequest%252f7_.000,False,"{""Large"":null,""Medium"":null,""Small"":null}",https://mitprod.sharepoint.com/sites/KI-Preclinical-Data/_layouts/15/listform.aspx?PageType=4&ListId=c0d51e67%2D07ac%2D4cd0%2D9d21%2D3c0f3dfd189d&ID=7&ContentTypeID=0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,One more try,One more try,Lists/Request/,Lists/Request/7_.000,"{""@odata.type"":""#Microsoft.Azure.Connectors.SharePoint.SPListExpandedContentType"",""Id"":""0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8"",""Name"":""Item""}",0x010039710F83EE6C8E4FBC586D94527E02AE00E1ECB1375875CE41895805BC5769ABD8,False,1.0 diff --git a/dev b/dev index a2abe9c4..4264ff82 160000 --- a/dev +++ b/dev @@ -1 +1 @@ -Subproject commit a2abe9c4c4853307853ab86d174d5747412ea57d +Subproject commit 4264ff824986e3b1e14f1e49aae28ef676a3c876 diff --git a/docker-compose.neo4j.yml b/docker-compose.neo4j.yml index 1719b214..90c3962b 100644 --- a/docker-compose.neo4j.yml +++ b/docker-compose.neo4j.yml @@ -1,6 +1,6 @@ services: neo4j: - image: neo4j:5.20.0 + image: neo4j:5.24.0-community container_name: scidk-neo4j restart: unless-stopped environment: @@ -8,21 +8,23 @@ services: - NEO4J_server_memory_heap_initial__size=${NEO4J_HEAP_INIT:-1G} - NEO4J_server_memory_heap_max__size=${NEO4J_HEAP_MAX:-2G} - NEO4J_dbms_security_auth__enabled=true - - NEO4JLABS_PLUGINS=["apoc","n10s"] - - NEO4J_dbms_security_procedures_unrestricted=apoc.* , n10s.* + - NEO4J_PLUGINS=["apoc"] + - NEO4J_dbms_security_procedures_unrestricted=apoc.* - NEO4J_apoc_export_file_enabled=true - NEO4J_apoc_import_file_enabled=true - NEO4J_apoc_import_file_use__neo4j__config=true ports: - - "7474:7474" # HTTP + - "7474:7474" # HTTP (Neo4j Browser/Workspace if bundled) - "7687:7687" # Bolt volumes: - - ./data/neo4j/data:/data - - ./data/neo4j/logs:/logs - - ./data/neo4j/plugins:/plugins + - ${NEO4J_HOST_DATA_DIR:-./data/neo4j/data}:/data + - ${NEO4J_HOST_LOGS_DIR:-./data/neo4j/logs}:/logs + - ${NEO4J_HOST_PLUGINS_DIR:-./data/neo4j/plugins}:/plugins + - ${NEO4J_HOST_IMPORT_DIR:-./data/neo4j/import}:/import healthcheck: test: ["CMD-SHELL", "PASS=$${NEO4J_AUTH#neo4j/}; cypher-shell -u neo4j -p \"$${PASS}\" -a bolt://localhost:7687 \"RETURN 1;\""] interval: 15s timeout: 5s retries: 10 start_period: 20s + diff --git a/docs/interpreters.md b/docs/interpreters.md new file mode 100644 index 00000000..8a9b32a0 --- /dev/null +++ b/docs/interpreters.md @@ -0,0 +1,41 @@ +## Interpreting Files from Rclone Remotes + +### When to Use a Mount vs. Direct Streaming + +Direct streaming via rclone (using `rclone cat`) lets the application interpret remote files without mounting. This works well for small to medium-sized file sets, but cloud storage APIs impose rate limits that can affect larger operations. + +#### Recommended Thresholds + +- Suggest mounting when: A single remote scan contains ≥ 300–500 files +- Practical per-request streaming batch sizes: + - Google Drive: 500–1000 files per request + - Dropbox: 300–800 files per request +- Default file size cap: 1 MB per file for text/code interpretation (increase selectively for notebooks if needed) + +#### Why Mounts Can Be Better for Large Sets + +1. Reduces API round-trips: Each interpreted file typically requires at least one remote API call (`rclone cat`) +2. Avoids rate limiting: Cloud providers (Google Drive, Dropbox) throttle frequent small reads with 429/403 responses +3. Improves reliability: Better throughput and fewer timeouts on large batches +4. Predictable resource usage: Enables smoother local-like reads for interpreter CPU/RAM management + +#### Provider-Specific Limits + +Google Drive: +- Sustainable rate: 4–5 requests/sec sustained, bursts up to ~10 req/sec +- Returns `403 rateLimitExceeded` or `429` with `Retry-After` headers when throttled +- rclone's adaptive pacer handles backoff automatically + +Dropbox: +- Sustainable rate: 2–4 requests/sec sustained, bursts ≤ 8–10 req/sec +- More sensitive to parallel reads, keep concurrency low (2–3 simultaneous connections) + +### Using Rclone Interpretation Settings + +Navigate to Settings → Rclone Interpretation to configure: + +- Suggest-mount threshold: Number of files in a scan that triggers mount recommendation +- Max files per interpretation batch: Upper limit for files processed in a single request (floor 100, ceiling 2000) +- Chunked processing: For large scans, the system automatically processes files in chunks using these limits + +When viewing a large rclone scan, you'll see a banner suggesting to mount the remote for better performance. The "Re-interpret scan" action runs in manageable chunks based on these settings. diff --git a/docs/ux-runbook-2025-09-12.md b/docs/ux-runbook-2025-09-12.md new file mode 100644 index 00000000..1146f763 --- /dev/null +++ b/docs/ux-runbook-2025-09-12.md @@ -0,0 +1,144 @@ +# UX Test Runbook — release/ux-test-2025-09-12 (tag: ux-2025-09-12) + +This runbook describes how to start the app for UX testing, which feature flags to use, and a minimal smoke plan to validate the build. + +Branch: release/ux-test-2025-09-12 +Tag: ux-2025-09-12 + +Environment defaults are channel-aware (dev/beta enable more features by default). Explicit env vars always override. + +## 1) Environment variables + +Recommended for UX testing: +- SCIDK_CHANNEL=dev + - Enables convenient defaults (providers include rclone when available, mounts UI on, file index WIP on). +- SCIDK_DB_PATH="scidk.db" (optional) + - SQLite path for runtime state/index. WAL mode is enabled automatically when possible. +- SCIDK_RCLONE_MOUNTS=1 (optional; requires rclone installed) + - Enables rclone mount manager API and UI sections. +- SCIDK_FEATURE_FILE_INDEX=1 + - Ensures file index features are enabled for scan and browse previews. +- SCIDK_ENABLE_INTERPRETERS and/or SCIDK_DISABLE_INTERPRETERS + - Comma-separated ids, e.g., SCIDK_DISABLE_INTERPRETERS=json,csv + - Effective view available under /api/interpreters?view=effective. +- SCIDK_FORCE_RCLONE=1 (optional) + - If rclone binary not found, this bypasses soft-disable of rclone provider (use with care). + +Testing/CI specific: +- SCIDK_DISABLE_SETTINGS=1 + - Prevents persistence of interpreter toggles when running automated tests to keep hermetic runs. + +Neo4j (optional; for graph projection testing only): +- NEO4J_URI=bolt://user:pass@localhost:7687 +- NEO4J_AUTH=none (or provide user/password via NEO4J_AUTH or envs) +- SCIDK_NEO4J_DATABASE=neo4j + +## 2) Starting the app + +- Python: 3.10+ +- Install deps: pip install -r requirements.txt +- Start: python start_scidk.py or `FLASK_APP=scidk.app:create_app flask run` (if configured). +- Default UI: http://localhost:5000/ +- Health: GET http://localhost:5000/api/health — verifies SQLite path and WAL. + +Example env: +``` +export SCIDK_CHANNEL=dev +export SCIDK_DB_PATH=$(pwd)/scidk.db +export SCIDK_RCLONE_MOUNTS=1 +export SCIDK_FEATURE_FILE_INDEX=1 +python start_scidk.py +``` + +## 3) Smoke checklist (APIs) + +Rclone interpretation settings and chunked reinterpretation are available and should be validated as part of smoke. + +1) Metrics endpoint +- GET /api/metrics +- Expect JSON with keys: scan_throughput_per_min, rows_ingested_total, browse_latency_p50, browse_latency_p95, outbox_lag + +2) Providers and roots +- GET /api/providers — list enabled providers +- GET /api/provider_roots?provider_id=local_fs — should return at least root "/" + +3) Selective scan dry-run (non-destructive) +- POST /api/scan/dry-run + Body: + { + "path": "/path/to/folder", + "recursive": false, + "include": ["*.py"], + "exclude": ["*.ipynb"], + "max_depth": 2, + "use_ignore": true + } +- Expect JSON: status=ok, total_files, total_bytes, files[] + +4) Scan and browse basic +- POST /api/scan { "path": "/path/to/folder", "recursive": false } +- GET /api/datasets — expect items +- GET /api/browse?provider_id=local_fs&root_id=/&path=/path/to/folder — expect entries + +5) Search +- GET /api/search?q= +- Expect results with matched_on including 'filename' or 'interpreter_id' for known files + +6) Interpreters toggles +- GET /api/interpreters — list metadata +- GET /api/interpreters?view=effective — effective enablement view +- POST /api/interpreters//toggle {"enabled": false} — disable, then GET effective to verify + +7) Rclone mounts (optional) +- Ensure rclone installed: `rclone version` +- With SCIDK_RCLONE_MOUNTS=1: + - GET /api/rclone/remotes + - POST /api/rclone/mounts to create mount (read-only recommended in UX): + { + "name": "ux_test", + "remote": "myremote:", + "subpath": "", + "path": "./data/mounts/myremote", + "read_only": true + } + - GET /api/rclone/mounts — verify mount listed and status hydrated + +8) Rclone interpretation settings +- GET /api/settings/rclone-interpret — returns defaults or persisted values +- POST /api/settings/rclone-interpret { "max_files_per_batch": 1200 } — value is saved and clamped to ≤ 2000; GET reflects it + +9) Chunked reinterpretation (for scans with many files) +- Create or pick a scan with many files (preferably provider_id=rclone) +- POST /api/interpret/scan/ with { "max_files": 150 } — response includes next_cursor when more remain +- Subsequent POSTs with { "after_rowid": } continue processing until next_cursor is null + +10) Neo4j connectivity (optional) +- POST /api/settings/neo4j with uri/user/password/database +- POST /api/settings/neo4j/connect — expect connected true/false and backoff behavior on auth failures + +## 4) Persistence validation + +- After running a scan, stop the app and restart. +- Verify: + - GET /api/directories returns previously scanned directories ordered by last_scanned (SQLite-backed). + - GET /api/scans lists records (SQLite-backed); details readable. + - GET / (Home) shows Last Scan Telemetry populated; survives restart (telemetry.last_scan persisted to SQLite settings). + - Rclone mounts (if created under flag) persist their definitions and hydrate runtime status post-restart (GET /api/rclone/mounts). + +## 5) Known flags and defaults + +- Channel defaults: SCIDK_CHANNEL=dev sets providers to local_fs,mounted_fs,rclone (if available), enables mounts UI and file index WIP. +- Soft rclone disable: if rclone missing and not forced, rclone is removed from SCIDK_PROVIDERS when not explicitly set by user. +- Commit from index default: SCIDK_COMMIT_FROM_INDEX=1 (can be overridden). + +## 6) Basic troubleshooting + +- /api/health shows SQLite path, WAL journal_mode, and basic select(1) status. +- /api/metrics provides throughput and latency signals; if empty, exercise /api/scan and /api/browse to generate samples. +- Neo4j auth failures incur backoff; see /api/settings/neo4j and /api/settings/neo4j/connect responses. + +## 7) Hand-off notes + +- Release branch: release/ux-test-2025-09-12 +- Tag: ux-2025-09-12 +- Merge strategy: Topic branches integrated with feature flags; persistence and selective scanning kept safe for UX. After UX sign-off, merge release branch into master via PR (avoid squash to preserve merge history), or cherry-pick subset if needed. diff --git a/pyproject.toml b/pyproject.toml index 3659ffab..fd3375f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,12 +11,25 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "Flask>=3.0", - "pytest>=7.4", + "openpyxl>=3.1", + "PyYAML>=6.0", + "neo4j>=5.14", + "psutil>=5.9", + "python-dateutil>=2.8", ] [project.optional-dependencies] +# Dev/test dependencies used locally and in CI dev = [ "pytest>=7.4", + "pytest-playwright==0.4.3", + "playwright==1.40.0", + "requests>=2.32", +] + +# Optional LLM provider (only needed if you enable Graphrag LLM features) +llm = [ + "ollama>=0.3", ] [project.scripts] @@ -33,4 +46,11 @@ exclude = ["dev*", "data*", "singularity*", "scripts*", "*.dist-info*", "*.egg-i testpaths = [ "tests", ] -addopts = "-q" +addopts = "-ra" +markers = [ + "unit: fast, pure unit tests", + "integration: tests that touch DB/files/HTTP without browser", + "e2e: end-to-end Playwright/browser tests", + "slow: slow tests", + "smoke: short, high-signal tests", +] diff --git a/requirements.txt b/requirements.txt index 116cab7c..ef919a57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,13 @@ +# Runtime dependencies (must match pyproject.toml [project.dependencies]) Flask>=3.0 -pytest>=7.4 openpyxl>=3.1 PyYAML>=6.0 +neo4j>=5.14 +psutil>=5.9 +python-dateutil>=2.8 + +# Dev/test dependencies (same as pyproject.toml [project.optional-dependencies].dev) +pytest>=7.4 +pytest-playwright==0.4.3 +playwright==1.40.0 +requests>=2.32 diff --git a/scidk/app.py b/scidk/app.py index 3c379c5e..57aad5a2 100644 --- a/scidk/app.py +++ b/scidk/app.py @@ -389,6 +389,16 @@ def create_app(): # Apply channel-based defaults before reading env-driven config _apply_channel_defaults() app = Flask(__name__, template_folder="ui/templates", static_folder="ui/static") + # Feature: selective dry-run UI flag (dev default) + try: + ch = (os.environ.get('SCIDK_CHANNEL') or 'stable').strip().lower() + flag_env = (os.environ.get('SCIDK_FEATURE_SELECTIVE_DRYRUN') or '').strip().lower() + flag = flag_env in ('1','true','yes','y','on') + if flag_env == '' and ch == 'dev': + flag = True + app.config['feature.selectiveDryRun'] = bool(flag) + except Exception: + app.config['feature.selectiveDryRun'] = False # Auto-migrate SQLite schema on boot (best effort) try: @@ -398,6 +408,15 @@ def create_app(): # Defer reporting to /api/health if needed via app.extensions pass + # State backend toggle (sqlite|memory) for app registries (reads) + try: + state_backend = (os.environ.get('SCIDK_STATE_BACKEND') or 'sqlite').strip().lower() + if state_backend not in ('sqlite', 'memory'): + state_backend = 'sqlite' + except Exception: + state_backend = 'sqlite' + app.config['state.backend'] = state_backend + # Core singletons (select backend) backend = (os.environ.get('SCIDK_GRAPH_BACKEND') or 'memory').strip().lower() if backend == 'neo4j': @@ -413,6 +432,15 @@ def create_app(): else: graph = InMemoryGraph() registry = InterpreterRegistry() + # Load persisted interpreter toggle settings (optional) + try: + from .core.settings import InterpreterSettings + settings = InterpreterSettings(os.environ.get('SCIDK_SETTINGS_DB', 'scidk_settings.db')) + enabled = settings.load_enabled_interpreters() + if enabled: + registry.enabled_interpreters = set(enabled) + except Exception: + settings = None # Register interpreters py_interp = PythonCodeInterpreter() @@ -453,19 +481,30 @@ def create_app(): all_ids = list(registry.by_id.keys()) default_enabled_ids = set([iid for iid in all_ids if bool(getattr(registry.by_id[iid], 'default_enabled', True))]) # CLI overrides via env - en_list = [s.strip() for s in (os.environ.get('SCIDK_ENABLE_INTERPRETERS') or '').split(',') if s.strip()] - dis_list = [s.strip() for s in (os.environ.get('SCIDK_DISABLE_INTERPRETERS') or '').split(',') if s.strip()] + # CLI overrides via env (case-insensitive); ignore unknown ids to avoid surprises + en_raw = [s.strip() for s in (os.environ.get('SCIDK_ENABLE_INTERPRETERS') or '').split(',') if s.strip()] + dis_raw = [s.strip() for s in (os.environ.get('SCIDK_DISABLE_INTERPRETERS') or '').split(',') if s.strip()] + # Normalize to lowercase (registry ids are lowercase) + en_list = [s.lower() for s in en_raw] + dis_list = [s.lower() for s in dis_raw] source = 'default' if en_list or dis_list: + known_ids = set(all_ids) + unknown_en = [x for x in en_list if x not in known_ids] + unknown_dis = [x for x in dis_list if x not in known_ids] + # Start from defaults; remove DISABLE; add ENABLE; ENABLE wins on conflicts enabled_set = set(default_enabled_ids) for d in dis_list: - enabled_set.discard(d) + if d in known_ids: + enabled_set.discard(d) for e in en_list: - enabled_set.add(e) + if e in known_ids: + enabled_set.add(e) source = 'cli' + # Do NOT persist CLI-derived sets to settings to avoid masking user intentions try: - if settings: - settings.save_enabled_interpreters(enabled_set) + _ist = app.extensions.setdefault('scidk', {}).setdefault('interpreters', {}) + _ist['unknown_env'] = {'enable': unknown_en, 'disable': unknown_dis} except Exception: pass else: @@ -484,6 +523,11 @@ def create_app(): source = 'default' # Store effective on app _interp_state = {'effective_enabled': enabled_set, 'source': source} + # Apply effective enabled set to registry for selection logic + try: + registry.enabled_interpreters = set(enabled_set) + except Exception: + pass fs = FilesystemManager(graph=graph, registry=registry) @@ -527,18 +571,122 @@ def create_app(): }, # rclone mounts runtime registry (feature-flagged API will use this) 'rclone_mounts': {}, # id/name -> { id, remote, subpath, path, read_only, started_at, pid, log_file } + 'settings': settings, } + # Hydrate telemetry.last_scan from SQLite settings on startup (best-effort) + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + row = cur.execute("SELECT value FROM settings WHERE key = ?", ("telemetry.last_scan",)).fetchone() + if row and row[0]: + try: + last_scan = _json.loads(row[0]) + app.extensions.setdefault('scidk', {}).setdefault('telemetry', {})['last_scan'] = last_scan + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass + + # Hydrate rclone interpretation settings (suggest mount threshold and batch size) + try: + def _env_int(name: str, dflt: int) -> int: + try: + v = os.environ.get(name) + return int(v) if v is not None and v != '' else dflt + except Exception: + return dflt + suggest_dflt = _env_int('SCIDK_RCLONE_INTERPRET_SUGGEST_MOUNT', 400) + max_batch_dflt = _env_int('SCIDK_RCLONE_INTERPRET_MAX_FILES', 1000) + max_batch_dflt = min(max(100, max_batch_dflt), 2000) + from .core import path_index_sqlite as pix + from .core import migrations as _migs + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + def _get_setting_int(key: str, dflt: int) -> int: + row = cur.execute("SELECT value FROM settings WHERE key= ?", (key,)).fetchone() + if row and row[0] not in (None, ''): + try: + return int(row[0]) + except Exception: + return dflt + return dflt + suggest_mount_threshold = _get_setting_int('rclone.interpret.suggest_mount_threshold', suggest_dflt) + max_files_per_batch = _get_setting_int('rclone.interpret.max_files_per_batch', max_batch_dflt) + max_files_per_batch = min(max(100, int(max_files_per_batch)), 2000) + app.config['rclone.interpret.suggest_mount_threshold'] = int(suggest_mount_threshold) + app.config['rclone.interpret.max_files_per_batch'] = int(max_files_per_batch) + finally: + try: + conn.close() + except Exception: + pass + except Exception: + # Defaults if hydration fails + app.config.setdefault('rclone.interpret.suggest_mount_threshold', 400) + app.config.setdefault('rclone.interpret.max_files_per_batch', 1000) + + # Feature flag for rclone mount manager (define before first use) + def _feature_rclone_mounts() -> bool: + val = (os.environ.get('SCIDK_RCLONE_MOUNTS') or os.environ.get('SCIDK_FEATURE_RCLONE_MOUNTS') or '').strip().lower() + return val in ('1', 'true', 'yes', 'y', 'on') + + # Rehydrate rclone mounts metadata from SQLite on startup (no process attached) + if _feature_rclone_mounts(): + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT id, provider, root, created, status, extra_json FROM provider_mounts WHERE provider='rclone'") + rows = cur.fetchall() or [] + rm = app.extensions['scidk'].setdefault('rclone_mounts', {}) + for (mid, provider, remote, created, status_persisted, extra) in rows: + try: + extra_obj = _json.loads(extra) if extra else {} + except Exception: + extra_obj = {} + rm[mid] = { + 'id': mid, + 'name': mid, + 'remote': remote, + 'subpath': extra_obj.get('subpath'), + 'path': extra_obj.get('path'), + 'read_only': extra_obj.get('read_only'), + 'started_at': created, + 'process': None, + 'pid': None, + 'log_file': extra_obj.get('log_file'), + } + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass + # API routes api = Blueprint('api', __name__, url_prefix='/api') # Import SQLite layer for selections/annotations lazily to avoid circular deps from .core import annotations_sqlite as ann_db - # Feature flag for rclone mount manager - def _feature_rclone_mounts() -> bool: - val = (os.environ.get('SCIDK_RCLONE_MOUNTS') or os.environ.get('SCIDK_FEATURE_RCLONE_MOUNTS') or '').strip().lower() - return val in ('1', 'true', 'yes', 'y', 'on') # Helper to read Neo4j configuration, preferring in-app settings over environment # Returns tuple: (uri, user, password, database, auth_mode) @@ -586,104 +734,12 @@ def _get_neo4j_params(): # Build rows for commit: files (rows) and standalone folders (folder_rows) def build_commit_rows(scan, ds_map): """Legacy builder from in-memory datasets.""" - from .core.path_utils import parse_remote_path, parent_remote_path - checksums = scan.get('checksums') or [] - # Helpers unified on central path utils - def _parent_of(p: str) -> str: - try: - info = parse_remote_path(p) - if info.get('is_remote'): - return parent_remote_path(p) - except Exception: - pass - # Fallback to pathlib for local/absolute paths - from pathlib import Path as __P - try: - return str(__P(p).parent) - except Exception: - return '' - def _name_of(p: str) -> str: - try: - info = parse_remote_path(p) - if info.get('is_remote'): - parts = info.get('parts') or [] - if not parts: - return info.get('remote_name') or '' - return parts[-1] - except Exception: - pass - from pathlib import Path as __P - try: - return __P(p).name - except Exception: - return p - def _parent_name_of(p: str) -> str: - try: - par = _parent_of(p) - info = parse_remote_path(par) - if info.get('is_remote'): - parts = info.get('parts') or [] - if not parts: - return info.get('remote_name') or '' - return parts[-1] - except Exception: - pass - from pathlib import Path as __P - try: - return __P(par).name - except Exception: - return par - # Precompute folders observed in this scan (parents of files) - folder_set = set() - for ch in checksums: - dtmp = ds_map.get(ch) - if not dtmp: - continue - folder_set.add(_parent_of(dtmp.get('path') or '')) - rows = [] - for ch in checksums: - d = ds_map.get(ch) - if not d: - continue - parent = _parent_of(d.get('path') or '') - interps = list((d.get('interpretations') or {}).keys()) - # derive folder fields - folder_path = parent - folder_name = _name_of(folder_path) if folder_path else '' - folder_parent = _parent_of(folder_path) if folder_path else '' - folder_parent_name = _parent_name_of(folder_path) if folder_parent else '' - rows.append({ - 'checksum': d.get('checksum'), - 'path': d.get('path'), - 'filename': d.get('filename'), - 'extension': d.get('extension'), - 'size_bytes': int(d.get('size_bytes') or 0), - 'created': float(d.get('created') or 0), - 'modified': float(d.get('modified') or 0), - 'mime_type': d.get('mime_type'), - 'folder': folder_path, - 'folder_name': folder_name, - 'folder_parent': folder_parent, - 'folder_parent_name': folder_parent_name, - 'parent_in_scan': bool(folder_parent and (folder_parent in folder_set)), - 'interps': interps, - }) - # Build folder rows captured during non-recursive scan - folder_rows = [] - for f in (scan.get('folders') or []): - folder_rows.append({ - 'path': f.get('path'), - 'name': f.get('name'), - 'parent': f.get('parent'), - 'parent_name': f.get('parent_name'), - }) - # Enhance with complete hierarchy try: - from .core.folder_hierarchy import build_complete_folder_hierarchy - folder_rows = build_complete_folder_hierarchy(rows, folder_rows, scan) + from .services.commit_service import CommitService + return CommitService().build_rows_legacy_from_datasets(scan, ds_map) except Exception: - pass - return rows, folder_rows + # Fallback to empty on unexpected import/runtime error + return [], [] # Execute Neo4j commit using simplified, idempotent Cypher def commit_to_neo4j(rows, folder_rows, scan, neo4j_params): @@ -711,75 +767,17 @@ def commit_to_neo4j(rows, folder_rows, scan, neo4j_params): return result result['attempted'] = True try: - from neo4j import GraphDatabase # type: ignore - driver = None + from .services.neo4j_client import Neo4jClient + client = Neo4jClient(uri, user, pwd, database, auth_mode).connect() try: - driver = GraphDatabase.driver(uri, auth=None if auth_mode == 'none' else (user, pwd)) - with driver.session(database=database) as sess: - # Try to create composite constraints (Neo4j 5+) — ignore if unsupported - try: - sess.run("CREATE CONSTRAINT file_identity IF NOT EXISTS FOR (f:File) REQUIRE (f.path, f.host) IS UNIQUE").consume() - except Exception: - pass - try: - sess.run("CREATE CONSTRAINT folder_identity IF NOT EXISTS FOR (d:Folder) REQUIRE (d.path, d.host) IS UNIQUE").consume() - except Exception: - pass - cypher = ( - "MERGE (s:Scan {id: $scan_id}) " - "SET s.path = $scan_path, s.started = $scan_started, s.ended = $scan_ended, " - " s.provider_id = $scan_provider, s.host_type = $scan_host_type, s.host_id = $scan_host_id, " - " s.root_id = $scan_root_id, s.root_label = $scan_root_label, s.scan_source = $scan_source " - "WITH s " - "UNWIND $folders AS folder " - "MERGE (fo:Folder {path: folder.path, host: $node_host}) " - " SET fo.name = folder.name, fo.provider_id = $scan_provider, fo.host_type = $scan_host_type, fo.host_id = $scan_host_id " - "MERGE (fo)-[:SCANNED_IN]->(s) " - "WITH s " - "UNWIND $folders AS folder " - "WITH s, folder WHERE folder.parent IS NOT NULL AND folder.parent <> '' AND folder.parent <> folder.path " - "MERGE (child:Folder {path: folder.path, host: $node_host}) " - "MERGE (parent:Folder {path: folder.parent, host: $node_host}) " - "MERGE (parent)-[:CONTAINS]->(child) " - "WITH s " - "UNWIND $rows AS r " - "MERGE (f:File {path: r.path, host: $node_host}) " - " SET f.filename = r.filename, f.extension = r.extension, f.size_bytes = r.size_bytes, f.created = r.created, f.modified = r.modified, f.mime_type = r.mime_type, f.provider_id = $scan_provider, f.host_type = $scan_host_type, f.host_id = $scan_host_id " - "MERGE (f)-[:SCANNED_IN]->(s) " - "WITH r, f " - "WHERE r.folder IS NOT NULL AND r.folder <> '' " - "MERGE (fo:Folder {path: r.folder, host: $node_host}) " - "MERGE (fo)-[:CONTAINS]->(f) " - "RETURN $scan_id AS scan_id" - ) - res = sess.run(cypher, rows=rows, folders=folder_rows, scan_id=scan.get('id'), scan_path=scan.get('path'), scan_started=scan.get('started'), scan_ended=scan.get('ended'), scan_provider=scan.get('provider_id'), scan_host_type=scan.get('host_type'), scan_host_id=scan.get('host_id'), scan_root_id=scan.get('root_id'), scan_root_label=scan.get('root_label'), scan_source=scan.get('scan_source'), node_host=scan.get('host_id'), node_port=None) - _ = list(res) - result['written_files'] = len(rows) - result['written_folders'] = len(folder_rows) - # Post-commit verification: confirm that Scan exists and at least one SCANNED_IN relationship was created - verify_q = ( - "OPTIONAL MATCH (s:Scan {id: $scan_id}) " - "WITH s " - "OPTIONAL MATCH (s)<-[:SCANNED_IN]-(f:File) " - "WITH s, count(DISTINCT f) AS files_cnt " - "OPTIONAL MATCH (s)<-[:SCANNED_IN]-(fo:Folder) " - "RETURN coalesce(s IS NOT NULL, false) AS scan_exists, files_cnt AS files_cnt, count(DISTINCT fo) AS folders_cnt" - ) - vrec = sess.run(verify_q, scan_id=scan.get('id')).single() - if vrec: - scan_exists = bool(vrec.get('scan_exists')) - files_cnt = int(vrec.get('files_cnt') or 0) - folders_cnt = int(vrec.get('folders_cnt') or 0) - result['db_scan_exists'] = scan_exists - result['db_files'] = files_cnt - result['db_folders'] = folders_cnt - result['db_verified'] = bool(scan_exists and (files_cnt > 0 or folders_cnt > 0)) + client.ensure_constraints() + wres = client.write_scan(rows, folder_rows, scan) + result['written_files'] = wres.get('written_files', 0) + result['written_folders'] = wres.get('written_folders', 0) + vres = client.verify(scan.get('id')) + result.update(vres) finally: - try: - if driver is not None: - driver.close() - except Exception: - pass + client.close() except Exception as e: msg = str(e) result['error'] = msg @@ -1021,6 +1019,11 @@ def api_scan_dry_run(): @api.post('/scan') def api_scan(): data = request.get_json(force=True, silent=True) or {} + try: + from .services.metrics import record_event_time + record_event_time(app, 'scan_started_times') + except Exception: + pass provider_id = (data.get('provider_id') or 'local_fs').strip() or 'local_fs' root_id = (data.get('root_id') or '/').strip() or '/' path = data.get('path') or (root_id if provider_id != 'local_fs' else os.getcwd()) @@ -1028,6 +1031,72 @@ def api_scan(): fast_list = bool(data.get('fast_list', False)) # Prefer fast_list by default for recursive rclone scans if client omitted it _client_specified_fast_list = ('fast_list' in data) + # Delegate to ScansService (refactor): preserve payload and behavior + try: + from .services.scans_service import ScansService + svc = ScansService(app) + result = svc.run_scan({ + 'provider_id': provider_id, + 'root_id': root_id, + 'path': path, + 'recursive': recursive, + 'fast_list': fast_list, + 'selection': data.get('selection') or {}, + }) + if isinstance(result, dict) and result.get('status') == 'ok': + # Persist selection, if provided + try: + sel = data.get('selection') or {} + if sel and result.get('scan_id'): + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + # Update scans.extra_json snapshot + try: + cur.execute("SELECT extra_json FROM scans WHERE id = ?", (result['scan_id'],)) + row = cur.fetchone() + extra_obj = {} + if row and row[0]: + try: extra_obj = _json.loads(row[0]) + except Exception: extra_obj = {} + extra_obj['selection'] = sel + cur.execute("UPDATE scans SET extra_json = ? WHERE id = ?", (_json.dumps(extra_obj), result['scan_id'])) + except Exception: + pass + # Replace normalized rules rows + try: + cur.execute("DELETE FROM scan_selection_rules WHERE scan_id = ?", (result['scan_id'],)) + except Exception: + pass + rules = sel.get('rules') or [] + for i, r in enumerate(rules): + act = (r.get('action') or '').lower() + pth = (r.get('path') or '').strip() + rec = 1 if r.get('recursive') else 0 + ntyp = r.get('node_type') + cur.execute( + "INSERT INTO scan_selection_rules(scan_id, action, path, recursive, node_type, order_index) VALUES(?,?,?,?,?,?)", + (result['scan_id'], act, pth, rec, ntyp, i) + ) + conn.commit() + finally: + try: conn.close() + except Exception: pass + except Exception: + pass + return jsonify(result), 200 + # Error path with optional http_status + if isinstance(result, dict) and result.get('status') == 'error': + code = int(result.get('http_status', 400)) + payload = {'status': 'error', 'error': result.get('error')} + return jsonify(payload), code + except Exception: + # On service failure, fallback to legacy in-place implementation below + pass try: import time, hashlib, json from .core import path_index_sqlite as pix @@ -1154,17 +1223,69 @@ def _row_from_local(pth: Path, typ: str) -> tuple: for interp in interps: try: result = interp.interpret(fpath) - app.extensions['scidk']['graph'].add_interpretation(ds['checksum'], interp.id, { + payload = { 'status': result.get('status', 'success'), 'data': result.get('data', result), 'interpreter_version': getattr(interp, 'version', '0.0.1'), - }) + } + app.extensions['scidk']['graph'].add_interpretation(ds['checksum'], interp.id, payload) + # Persist interpretation metadata into SQLite files table for this path + try: + from .core import path_index_sqlite as pix + conn_i = pix.connect(); pix.init_db(conn_i) + try: + cur_i = conn_i.cursor() + import json as _json + # Determine the canonical key used in the index for this file path + key_path = None + try: + # For rclone/remote scans, the index stores canonical remote paths like "remote:rel/path" + # Prefer dataset-provided original path if present + key_path = ds.get('path') or None + except Exception: + key_path = None + if not key_path: + # Fallback to absolute local path for local filesystem scans + key_path = str(fpath.resolve()) + cur_i.execute( + "UPDATE files SET interpreted_as = ?, interpretation_json = ? WHERE path = ? AND type = 'file' AND scan_id = ?", + (interp.id, _json.dumps(payload.get('data')), key_path, scan_id) + ) + conn_i.commit() + finally: + conn_i.close() + except Exception: + pass except Exception as e: - app.extensions['scidk']['graph'].add_interpretation(ds['checksum'], interp.id, { + err_payload = { 'status': 'error', 'data': {'error': str(e)}, 'interpreter_version': getattr(interp, 'version', '0.0.1'), - }) + } + app.extensions['scidk']['graph'].add_interpretation(ds['checksum'], interp.id, err_payload) + try: + from .core import path_index_sqlite as pix + conn_i = pix.connect(); pix.init_db(conn_i) + try: + cur_i = conn_i.cursor() + import json as _json + # Determine canonical key as above + key_path = None + try: + key_path = ds.get('path') or None + except Exception: + key_path = None + if not key_path: + key_path = str(fpath.resolve()) + cur_i.execute( + "UPDATE files SET interpreted_as = ?, interpretation_json = ? WHERE path = ? AND type = 'file' AND scan_id = ?", + (interp.id, _json.dumps(err_payload.get('data')), key_path, scan_id) + ) + conn_i.commit() + finally: + conn_i.close() + except Exception: + pass count += 1 except Exception: continue @@ -1427,6 +1548,48 @@ def _add_folder(full_path: str, name: str, parent: str): } scans = app.extensions['scidk'].setdefault('scans', {}) scans[scan_id] = scan + # Persist scan summary to SQLite (best-effort) + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + conn = pix.connect() + import json as _json + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute( + "INSERT OR REPLACE INTO scans(id, root, started, completed, status, extra_json) VALUES(?,?,?,?,?,?)", + ( + scan_id, + str(path), + float(started or 0.0), + float(ended or 0.0), + 'completed', + _json.dumps({ + 'recursive': bool(recursive), + 'duration_sec': duration, + 'file_count': int(count), + 'by_ext': by_ext, + 'source': scan.get('source'), + 'checksums': new_checksums, + 'committed': False, + 'committed_at': None, + 'provider_id': provider_id, + 'root_id': root_id, + 'host_type': host_type, + 'host_id': host_id, + 'root_label': root_label, + }) + ) + ) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass # Clear cached fs index for this scan so next request rebuilds with fresh data try: app.extensions['scidk'].setdefault('scan_fs', {}).pop(scan_id, None) @@ -1447,6 +1610,27 @@ def _add_folder(full_path: str, name: str, parent: str): 'files_skipped': int(files_skipped), 'files_hashed': int(files_hashed), } + # Persist telemetry.last_scan to SQLite (best-effort) + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute( + "INSERT OR REPLACE INTO settings(key, value) VALUES(?, ?)", + ("telemetry.last_scan", _json.dumps(telem.get('last_scan') or {})) + ) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass # Track scanned directories (in-session registry) dirs = app.extensions['scidk'].setdefault('directories', {}) drec = dirs.setdefault(str(path), { @@ -1521,6 +1705,7 @@ def api_tasks_create(): 'scan_id': None, 'error': None, 'cancel_requested': False, + 'selection': data.get('selection') or {}, } app.extensions['scidk'].setdefault('tasks', {})[task_id] = task @@ -1543,7 +1728,44 @@ def _worker(): # Estimate total: Python traversal files_list = [p for p in fs._iter_files_python(base, recursive=recursive)] task['total'] = len(files_list) - # Build rows like api_scan + # Build rows like api_scan, apply selection rules when provided + sel = (task.get('selection') or {}) + rules = sel.get('rules') or [] + use_ignore = bool(sel.get('use_ignore', True)) + allow_override_ignores = bool(sel.get('allow_override_ignores', True)) + from fnmatch import fnmatch as _fn + def _norm_rules(rules_list): + out = [] + for i, r in enumerate(rules_list or []): + act = (r.get('action') or '').lower(); pth=(r.get('path') or '').rstrip('/'); + if not act or not pth: continue + rec = bool(r.get('recursive', False)); nt=r.get('node_type'); depth=pth.count('/'); + out.append({'action':act,'path':pth,'recursive':rec,'node_type':nt,'depth':depth,'order_index':i}) + out.sort(key=lambda x:(x['depth'], x['order_index']), reverse=True) + return out + _rules = _norm_rules(rules) + def _decide(rel_path: str, ignored: bool): + if ignored and not allow_override_ignores: return (False, 'ignored_by_scidkignore') + for r in _rules: + rp = r['path'] + if r['recursive']: + if rel_path == rp or rel_path.startswith(rp + '/'): + return ((r['action']=='include'), r['action']+'_by_rule') + else: + if rel_path == rp: + return ((r['action']=='include'), r['action']+'_by_rule') + if ignored: return (False, 'ignored_by_scidkignore') + return (True, 'inherited') + ignore_patterns = [] + if use_ignore: + try: + ign = base / '.scidkignore' + if ign.exists(): + for line in ign.read_text(encoding='utf-8').splitlines(): + s = line.strip(); + if s and not s.startswith('#'): ignore_patterns.append(s) + except Exception: + ignore_patterns = [] items_files = [] items_dirs = set() if recursive: @@ -1554,7 +1776,15 @@ def _worker(): if p.is_dir(): items_dirs.add(p) else: - items_files.append(p) + # selection filter on files + try: + rel = p.resolve().relative_to(base.resolve()).as_posix() + except Exception: + rel = str(p) + ignored = any(_fn(rel, pat) for pat in ignore_patterns) + ok, _ = _decide(rel, ignored) + if ok: + items_files.append(p) parent = p.parent while parent and parent != parent.parent and str(parent).startswith(str(base)): items_dirs.add(parent) @@ -1568,7 +1798,11 @@ def _worker(): try: for p in base.iterdir(): if p.is_dir(): items_dirs.add(p) - else: items_files.append(p) + else: + rel = p.name + ignored = any(_fn(rel, pat) for pat in ignore_patterns) + ok, _ = _decide(rel, ignored) + if ok: items_files.append(p) except Exception: pass items_dirs.add(base) @@ -1637,6 +1871,29 @@ def _row_from_local(pth: Path, typ: str) -> tuple: items = prov.list_files(path, recursive=recursive, fast_list=fast_list) # type: ignore[attr-defined] except Exception as ee: raise RuntimeError(str(ee)) + # Selection for remote: apply only to files using full remote path + sel = (task.get('selection') or {}) + rules = sel.get('rules') or [] + def _norm_rules(rules_list): + out = [] + for i, r in enumerate(rules_list or []): + act = (r.get('action') or '').lower(); pth=(r.get('path') or '').rstrip('/'); + if not act or not pth: continue + rec = bool(r.get('recursive', False)); nt=r.get('node_type'); depth=pth.count('/'); + out.append({'action':act,'path':pth,'recursive':rec,'node_type':nt,'depth':depth,'order_index':i}) + out.sort(key=lambda x:(x['depth'], x['order_index']), reverse=True) + return out + _rules = _norm_rules(rules) + def _decide(full_remote: str): + for r in _rules: + rp = r['path'] + if r['recursive']: + if full_remote == rp or full_remote.startswith(rp + '/'): + return (r['action']=='include') + else: + if full_remote == rp: + return (r['action']=='include') + return True rows = [] seen_rows = set() seen_folders = set() @@ -1670,12 +1927,23 @@ def _add_folder(full_path: str, name: str, parent: str): seen_rows.add(key) rows.append(rrow) continue - # rclone file row - rrow = pix.map_rclone_item_to_row(it, path, scan_id) - key = (rrow[0], rrow[4]) - if key not in seen_rows: - seen_rows.add(key) - rows.append(rrow) + # rclone file row (apply selection) + full_remote = join_remote_path(path, name) + if _decide(full_remote): + rrow = pix.map_rclone_item_to_row(it, path, scan_id) + key = (rrow[0], rrow[4]) + if key not in seen_rows: + seen_rows.add(key) + rows.append(rrow) + # In-memory dataset for file + try: + size = int(it.get('Size') or 0) + ds = fs.create_dataset_remote(full_remote, size_bytes=size, modified_ts=0.0, mime=None) + app.extensions['scidk']['graph'].upsert_dataset(ds) + except Exception: + pass + file_count += 1 + task['processed'] = file_count if recursive and name: parts = [p for p in (name.split('/') if isinstance(name, str) else []) if p] cur = '' @@ -1684,16 +1952,6 @@ def _add_folder(full_path: str, name: str, parent: str): full = join_remote_path(path, cur) parent = parent_remote_path(full) _add_folder(full, parts[i], parent) - # In-memory dataset for file - try: - size = int(it.get('Size') or 0) - full = join_remote_path(path, name) - ds = fs.create_dataset_remote(full, size_bytes=size, modified_ts=0.0, mime=None) - app.extensions['scidk']['graph'].upsert_dataset(ds) - except Exception: - pass - file_count += 1 - task['processed'] = file_count folder_count = len(seen_folders) ingested = pix.batch_insert_files(rows) else: @@ -1751,6 +2009,69 @@ def _add_folder(full_path: str, name: str, parent: str): }, } scans[scan_id] = scan + # Persist scan summary to SQLite (best-effort) + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute( + "INSERT OR REPLACE INTO scans(id, root, started, completed, status, extra_json) VALUES(?,?,?,?,?,?)", + ( + scan_id, + str(path), + float(started_ts or 0.0), + float(ended or 0.0), + 'completed', + _json.dumps({ + 'recursive': bool(recursive), + 'duration_sec': ended - started_ts, + 'file_count': int(file_count), + 'by_ext': by_ext, + 'source': scan.get('source'), + 'checksums': new_checksums, + 'committed': False, + 'committed_at': None, + 'provider_id': provider_id, + 'root_id': root_id, + 'host_type': host_type, + 'host_id': host_id, + 'root_label': scan.get('root_label'), + 'selection': (task.get('selection') or {}), + }) + ) + ) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass + # Also persist normalized selection rules for this scan (best-effort) + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("DELETE FROM scan_selection_rules WHERE scan_id = ?", (scan_id,)) + sel = task.get('selection') or {} + rules = sel.get('rules') or [] + for i, r in enumerate(rules): + act = (r.get('action') or '').lower(); pth = (r.get('path') or '').strip(); rec = 1 if r.get('recursive') else 0; ntyp = r.get('node_type') + cur.execute("INSERT INTO scan_selection_rules(scan_id, action, path, recursive, node_type, order_index) VALUES(?,?,?,?,?,?)", (scan_id, act, pth, rec, ntyp, i)) + conn.commit() + finally: + try: conn.close() + except Exception: pass + except Exception: + pass # Telemetry and directories app.extensions['scidk'].setdefault('telemetry', {})['last_scan'] = { 'path': str(path), 'recursive': bool(recursive), 'scanned': int(file_count), @@ -1816,6 +2137,36 @@ def _worker_commit(): g.commit_scan(s) s['committed'] = True s['committed_at'] = time.time() + # Persist commit status to SQLite (best-effort) + try: + from .core import path_index_sqlite as pix + import json as _json + conn = pix.connect() + try: + cur = conn.cursor() + # fetch existing extra_json to merge + cur.execute("SELECT extra_json FROM scans WHERE id = ?", (s.get('id'),)) + row = cur.fetchone() + extra_obj = {} + try: + if row and row[0]: + extra_obj = _json.loads(row[0]) + except Exception: + extra_obj = {} + extra_obj['committed'] = True + extra_obj['committed_at'] = s.get('committed_at') + cur.execute( + "UPDATE scans SET status = ?, extra_json = ? WHERE id = ?", + ('committed', _json.dumps(extra_obj), s.get('id')) + ) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass # Build rows once using shared builder when index mode is enabled use_index = (os.environ.get('SCIDK_COMMIT_FROM_INDEX') or '').strip().lower() in ('1','true','yes','y','on') if use_index: @@ -1883,10 +2234,58 @@ def _on_prog(e, p): @api.get('/tasks') def api_tasks_list(): - tasks = list(app.extensions['scidk'].get('tasks', {}).values()) + # Prefer persisted tasks when state.backend=sqlite; merge with in-memory running tasks + items = [] + if app.config.get('state.backend') == 'sqlite': + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT id, type, status, created, updated, payload FROM background_tasks ORDER BY coalesce(updated, created) DESC LIMIT 500") + for (tid, ttype, status, created, updated, payload) in cur.fetchall() or []: + try: + payload_obj = _json.loads(payload) if payload else {} + except Exception: + payload_obj = {} + items.append({ + 'id': tid, + 'type': ttype, + 'status': status, + 'started': created, + 'ended': updated if status in ('completed','error','canceled') else None, + 'progress': payload_obj.get('progress'), + 'processed': payload_obj.get('processed'), + 'total': payload_obj.get('total'), + 'error': payload_obj.get('error'), + }) + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass + # Merge/augment with in-memory tasks (these represent current session/running tasks) + try: + mem_tasks = list(app.extensions['scidk'].get('tasks', {}).values()) + except Exception: + mem_tasks = [] + # Overwrite same-id entries with in-memory (more up-to-date) + by_id = {t.get('id'): t for t in items if t.get('id')} + for t in mem_tasks: + if t.get('id'): + by_id[t['id']] = t + else: + # anonymous tasks unlikely; append + items.append(t) + items = list(by_id.values()) if by_id else items # sort newest first - tasks.sort(key=lambda t: t.get('started') or 0, reverse=True) - return jsonify(tasks), 200 + items.sort(key=lambda t: t.get('ended') or t.get('started') or 0, reverse=True) + return jsonify(items), 200 @api.get('/tasks/') def api_tasks_detail(task_id): @@ -1942,14 +2341,25 @@ def api_interpret(): results = [] for interp in interps: try: + _t0 = time.time() result = interp.interpret(file_path) + _t1 = time.time() graph.add_interpretation(ds['checksum'], interp.id, { 'status': result.get('status', 'success'), 'data': result.get('data', result), 'interpreter_version': getattr(interp, 'version', '0.0.1'), }) + # Record success + try: + registry.record_usage(interp.id, success=True, execution_time_ms=int((_t1 - _t0)*1000)) + except Exception: + pass results.append({'interpreter_id': interp.id, 'status': 'ok'}) except Exception as e: + try: + registry.record_usage(interp.id, success=False, execution_time_ms=0) + except Exception: + pass graph.add_interpretation(ds['checksum'], interp.id, { 'status': 'error', 'data': {'error': str(e)}, @@ -1973,26 +2383,220 @@ def api_chat(): store['history'].append(entry_assistant) return jsonify({"status": "ok", "reply": reply, "history": store['history']}), 200 - @api.get('/search') - def api_search(): - q = (request.args.get('q') or '').strip() - if not q: - return jsonify([]), 200 - q_lower = q.lower() - results = [] - for ds in graph.list_datasets(): - matched_on = [] - # Match filename - if q_lower in (ds.get('filename') or '').lower() or q_lower in (ds.get('path') or '').lower(): - matched_on.append('filename') - # Match interpreter ids present - interps = (ds.get('interpretations') or {}) - for interp_id in interps.keys(): - if q_lower in interp_id.lower(): - if 'interpreter_id' not in matched_on: - matched_on.append('interpreter_id') - if matched_on: - results.append({ + # --- GraphRAG endpoints (Phase 1 scaffold) --- + @api.post('/chat/graphrag') + def api_chat_graphrag(): + """Natural language to Cypher and graph-augmented reply (scaffold). + Privacy-first: only enabled when SCIDK_GRAPHRAG_ENABLED is truthy. + If neo4j-graphrag is unavailable or disabled, returns a clear message. + """ + enabled = (os.environ.get('SCIDK_GRAPHRAG_ENABLED') or '').strip().lower() in ('1','true','yes','on','y') + if not enabled: + from .services.graphrag_schema import normalize_error + return jsonify(normalize_error(status="disabled", error="GraphRAG disabled", code="GR_DISABLED", hint="Set SCIDK_GRAPHRAG_ENABLED=1")), 501 + data = request.get_json(force=True, silent=True) or {} + message = (data.get('message') or '').strip() + if not message: + return jsonify({"status": "error", "error": "message required"}), 400 + # Reuse existing Neo4j connection params + try: + from .services.neo4j_client import get_neo4j_params + uri, user, pwd, database, auth_mode = get_neo4j_params(app) + except Exception: + uri = user = pwd = database = auth_mode = None + if not uri: + from .services.graphrag_schema import normalize_error + return jsonify(normalize_error(status="error", error="Neo4j is not configured", code="NEO4J_CONFIG_MISSING", hint="Set NEO4J_URI and credentials or NEO4J_AUTH=none")), 500 + # Attempt lazy import and minimal flow + try: + from neo4j import GraphDatabase # type: ignore + # Soft optional import for graphrag; if missing, report capability + try: + from neo4j_graphrag.retrievers import Text2CypherRetriever # type: ignore + from neo4j_graphrag.generation import GraphRAG # type: ignore + except Exception as e: + from .services.graphrag_schema import normalize_error + return jsonify(normalize_error(status="unavailable", error="neo4j-graphrag not installed", code="GR_LIB_MISSING", hint="pip install neo4j-graphrag>=0.3.0", detail=str(e))), 501 + # Privacy-preserving LLM selection + provider = (os.environ.get('SCIDK_GRAPHRAG_LLM_PROVIDER') or 'local_ollama').strip().lower() + model = (os.environ.get('SCIDK_GRAPHRAG_MODEL') or 'llama3:8b').strip() + llm = None + if provider in ('local_ollama', 'ollama'): + try: + from ollama import Client as OllamaClient # type: ignore + oc = OllamaClient() + class _OllamaLLM: + def __init__(self, client, model): + self.client = client; self.model = model + def complete(self, prompt: str) -> str: + r = self.client.generate(model=self.model, prompt=prompt) + return r.get('response') or '' + llm = _OllamaLLM(oc, model) + except Exception as e: + from .services.graphrag_schema import normalize_error + return jsonify(normalize_error(status="error", error="Ollama not available", code="LLM_NOT_AVAILABLE", detail=str(e), hint="Ensure Ollama is installed and running, and SCIDK_GRAPHRAG_MODEL is available")), 500 + elif provider in ('openai','azure_openai'): + return jsonify({"status": "forbidden", "error": "External providers disabled for privacy in Phase 1"}), 403 + else: + return jsonify({"status": "error", "error": f"Unknown provider: {provider}"}), 400 + auth = None if (auth_mode or 'basic').lower() == 'none' else (user, pwd) + driver = GraphDatabase.driver(uri, auth=auth) + # Schema cache with privacy filtering + from .services.graphrag_schema import parse_ttl, filter_schema + from .services.graphrag_examples import examples as t2c_examples + schema_cache = app.extensions['scidk'].setdefault('graphrag_schema', {}) + last = schema_cache.get('last_loaded_ts') or 0 + ttl = 0 + ttl_env = os.environ.get('SCIDK_GRAPHRAG_SCHEMA_CACHE_TTL_SEC') or os.environ.get('SCIDK_GRAPHRAG_SCHEMA_CACHE_TTL') + if ttl_env: + ttl = parse_ttl(ttl_env) + now = int(time.time()) + if (now - last) > max(0, ttl): + with driver.session(database=database) if database else driver.session() as s: + labels = [r[0] for r in s.run("CALL db.labels()").values()] + rels = [r[0] for r in s.run("CALL db.relationshipTypes()").values()] + raw_schema = {"labels": labels, "relationships": rels} + allow_labels = [x.strip() for x in (os.environ.get('SCIDK_GRAPHRAG_ALLOW_LABELS') or '').split(',') if x.strip()] + deny_labels = [x.strip() for x in (os.environ.get('SCIDK_GRAPHRAG_DENY_LABELS') or '').split(',') if x.strip()] + prop_excl = [x.strip() for x in (os.environ.get('SCIDK_GRAPHRAG_EXCLUDE_PROPERTIES') or '').split(',') if x.strip()] + filtered = filter_schema(raw_schema, allow_labels or None, deny_labels or None, prop_excl or None) + schema_cache['schema'] = filtered + schema_cache['last_loaded_ts'] = now + neo4j_schema = schema_cache.get('schema') or {"labels": [], "relationships": []} + # Create retriever and GraphRAG + try: + from .services.graphrag_llm import OllamaLLMAdapter + if llm is None and provider in ('local_ollama','ollama'): + llm = OllamaLLMAdapter(model=model) + except Exception: + pass + from .services.graphrag_examples import examples as _ex + retriever = Text2CypherRetriever(driver=driver, llm=llm, neo4j_schema=neo4j_schema, examples=t2c_examples) + rag = GraphRAG(retriever=retriever, llm=llm) + # Execute + result_text = rag.query(message) + # Track history and minimal audit + store = app.extensions['scidk'].setdefault('chat', {"history": []}) + store['history'].extend([{"role":"user","content":message},{"role":"assistant","content":result_text}]) + audit = app.extensions['scidk'].setdefault('telemetry', {}).setdefault('graphrag_audit', []) + try: + audit.append({ + 'ts': int(time.time()), + 'message': message[:500], + 'reply_len': len(result_text or ''), + 'provider': provider, + }) + except Exception: + pass + return jsonify({"status": "ok", "reply": result_text, "history": store['history']}), 200 + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + + @api.get('/chat/history') + def api_chat_history(): + store = app.extensions['scidk'].setdefault('chat', {"history": []}) + return jsonify({"status": "ok", "history": store['history']}), 200 + + @api.post('/chat/context/refresh') + def api_chat_context_refresh(): + enabled = (os.environ.get('SCIDK_GRAPHRAG_ENABLED') or '').strip().lower() in ('1','true','yes','on','y') + if not enabled: + from .services.graphrag_schema import normalize_error + return jsonify(normalize_error(status="disabled", error="GraphRAG disabled", code="GR_DISABLED", hint="Set SCIDK_GRAPHRAG_ENABLED=1")), 501 + # Force refresh schema cache + try: + from .services.neo4j_client import get_neo4j_params + from neo4j import GraphDatabase # type: ignore + uri, user, pwd, database, auth_mode = get_neo4j_params(app) + if not uri: + from .services.graphrag_schema import normalize_error + return jsonify(normalize_error(status="error", error="Neo4j not configured", code="NEO4J_CONFIG_MISSING", hint="Set NEO4J_URI and credentials or NEO4J_AUTH=none")), 500 + auth = None if (auth_mode or 'basic').lower() == 'none' else (user, pwd) + driver = GraphDatabase.driver(uri, auth=auth) + with driver.session(database=database) if database else driver.session() as s: + labels = [r[0] for r in s.run("CALL db.labels()").values()] + rels = [r[0] for r in s.run("CALL db.relationshipTypes()").values()] + from .services.graphrag_schema import filter_schema + raw_schema = {"labels": labels, "relationships": rels} + allow_labels = [x.strip() for x in (os.environ.get('SCIDK_GRAPHRAG_ALLOW_LABELS') or '').split(',') if x.strip()] + deny_labels = [x.strip() for x in (os.environ.get('SCIDK_GRAPHRAG_DENY_LABELS') or '').split(',') if x.strip()] + prop_excl = [x.strip() for x in (os.environ.get('SCIDK_GRAPHRAG_EXCLUDE_PROPERTIES') or '').split(',') if x.strip()] + filtered = filter_schema(raw_schema, allow_labels or None, deny_labels or None, prop_excl or None) + schema_cache = app.extensions['scidk'].setdefault('graphrag_schema', {}) + schema_cache['schema'] = filtered + schema_cache['last_loaded_ts'] = int(time.time()) + return jsonify({"status": "ok", "schema": schema_cache['schema']}), 200 + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + + @api.get('/chat/capabilities') + def api_chat_capabilities(): + enabled = (os.environ.get('SCIDK_GRAPHRAG_ENABLED') or '').strip().lower() in ('1','true','yes','on','y') + provider = (os.environ.get('SCIDK_GRAPHRAG_LLM_PROVIDER') or 'local_ollama').strip().lower() + model = (os.environ.get('SCIDK_GRAPHRAG_MODEL') or 'llama3:8b').strip() + return jsonify({ + "graphrag": { + "enabled": bool(enabled), + "llm_provider": provider, + "model": model, + } + }), 200 + + @api.get('/chat/observability/graphrag') + def api_chat_observability_graphrag(): + from .services.graphrag_schema import parse_ttl + enabled = (os.environ.get('SCIDK_GRAPHRAG_ENABLED') or '').strip().lower() in ('1','true','yes','on','y') + provider = (os.environ.get('SCIDK_GRAPHRAG_LLM_PROVIDER') or 'local_ollama').strip().lower() + model = (os.environ.get('SCIDK_GRAPHRAG_MODEL') or 'llama3:8b').strip() + schema_cache = app.extensions['scidk'].setdefault('graphrag_schema', {}) + schema = schema_cache.get('schema') or {"labels": [], "relationships": []} + last_loaded = schema_cache.get('last_loaded_ts') + ttl_env = os.environ.get('SCIDK_GRAPHRAG_SCHEMA_CACHE_TTL_SEC') or os.environ.get('SCIDK_GRAPHRAG_SCHEMA_CACHE_TTL') + ttl = parse_ttl(ttl_env) if ttl_env else 0 + audit = app.extensions['scidk'].setdefault('telemetry', {}).setdefault('graphrag_audit', []) + # Return only last 20 entries with redacted message preview + recent = [] + for a in audit[-20:]: + recent.append({ + 'ts': a.get('ts'), + 'message_preview': (a.get('message') or '')[:120], + 'reply_len': a.get('reply_len'), + 'provider': a.get('provider'), + }) + return jsonify({ + 'status': 'ok', + 'enabled': bool(enabled), + 'llm_provider': provider, + 'model': model, + 'schema': { + 'labels_count': len(schema.get('labels') or []), + 'relationships_count': len(schema.get('relationships') or []), + 'last_loaded_ts': last_loaded, + 'cache_ttl_sec': ttl, + }, + 'audit': recent, + }), 200 + + @api.get('/search') + def api_search(): + q = (request.args.get('q') or '').strip() + if not q: + return jsonify([]), 200 + q_lower = q.lower() + results = [] + for ds in graph.list_datasets(): + matched_on = [] + # Match filename + if q_lower in (ds.get('filename') or '').lower() or q_lower in (ds.get('path') or '').lower(): + matched_on.append('filename') + # Match interpreter ids present + interps = (ds.get('interpretations') or {}) + for interp_id in interps.keys(): + if q_lower in interp_id.lower(): + if 'interpreter_id' not in matched_on: + matched_on.append('interpreter_id') + if matched_on: + results.append({ 'id': ds.get('id'), 'path': ds.get('path'), 'filename': ds.get('filename'), @@ -2007,26 +2611,34 @@ def score(r): @api.get('/interpreters') def api_interpreters(): - # List interpreter registry metadata + # Unified listing: registry metadata + toggle/usage/metrics + effective view override reg = app.extensions['scidk']['registry'] # Build mapping ext -> interpreter ids ext_map = {} for ext, interps in reg.by_extension.items(): ext_map[ext] = [getattr(i, 'id', 'unknown') for i in interps] - # Compose interpreter-centric view items = [] for iid, interp in reg.by_id.items(): - # collect globs/extensions this interpreter is registered for globs = sorted([ext for ext, ids in ext_map.items() if iid in ids]) - items.append({ + it = { 'id': iid, 'name': getattr(interp, 'name', iid), 'version': getattr(interp, 'version', '0.0.1'), 'globs': globs, 'default_enabled': bool(getattr(interp, 'default_enabled', getattr(reg, 'default_enabled', True))), 'cost': getattr(interp, 'cost', None), - }) - # Support future effective view toggle + 'extensions': globs, + 'enabled': True, + 'runtime': getattr(interp, 'runtime', 'python'), + 'last_used': getattr(reg, 'get_last_used', lambda _x: None)(iid), + 'success_rate': getattr(reg, 'get_success_rate', lambda _x: 0.0)(iid), + } + try: + it['enabled'] = reg._is_enabled(iid) + except Exception: + pass + items.append(it) + # Optional effective view from app extensions (e.g., CLI/env overridden) view = (request.args.get('view') or '').strip().lower() if view == 'effective': interp_state = app.extensions['scidk'].get('interpreters', {}) @@ -2037,6 +2649,78 @@ def api_interpreters(): it['source'] = src return jsonify(items), 200 + @api.get('/interpreters/effective_debug') + def api_interpreters_effective_debug(): + istate = app.extensions.get('scidk', {}).get('interpreters', {}) + eff = sorted(list(istate.get('effective_enabled') or [])) + src = istate.get('source') or 'default' + unknown_env = istate.get('unknown_env') or {} + reg = app.extensions['scidk']['registry'] + all_ids = sorted(list(reg.by_id.keys())) + default_enabled = [] + for iid in all_ids: + try: + if bool(getattr(reg.by_id[iid], 'default_enabled', True)): + default_enabled.append(iid) + except Exception: + pass + loaded = [] + try: + settings = app.extensions['scidk'].get('settings') + if settings is not None and src != 'cli': + loaded = sorted(list(settings.load_enabled_interpreters() or [])) + except Exception: + loaded = [] + en_raw = [s.strip() for s in (os.environ.get('SCIDK_ENABLE_INTERPRETERS') or '').split(',') if s.strip()] + dis_raw = [s.strip() for s in (os.environ.get('SCIDK_DISABLE_INTERPRETERS') or '').split(',') if s.strip()] + en_norm = [s.lower() for s in en_raw] + dis_norm = [s.lower() for s in dis_raw] + return jsonify({ + 'source': src, + 'effective_enabled': eff, + 'default_enabled': sorted(default_enabled), + 'loaded_settings': loaded, + 'env': { + 'enable_raw': en_raw, + 'disable_raw': dis_raw, + 'enable_norm': en_norm, + 'disable_norm': dis_norm, + 'unknown': unknown_env, + } + }), 200 + + @api.post('/interpreters//toggle') + def api_interpreters_toggle(interpreter_id): + reg = app.extensions['scidk']['registry'] + data = request.get_json(force=True, silent=True) or {} + enabled = bool(data.get('enabled', True)) + if enabled: + reg.enable_interpreter(interpreter_id) + else: + reg.disable_interpreter(interpreter_id) + # Persist if settings available + try: + settings = app.extensions['scidk'].get('settings') + if settings is not None: + settings.save_enabled_interpreters(reg.enabled_interpreters) + except Exception: + pass + # Refresh effective interpreter view so /api/interpreters?view=effective reflects the change immediately + try: + istate = app.extensions['scidk'].setdefault('interpreters', {}) + eff = set(istate.get('effective_enabled') or []) + # If snapshot missing/empty, rebuild from current registry state + if not eff: + eff = set([iid for iid in reg.by_id.keys() if reg._is_enabled(iid)]) + if enabled: + eff.add(interpreter_id) + else: + eff.discard(interpreter_id) + istate['effective_enabled'] = eff + except Exception: + pass + return jsonify({'status': 'updated', 'enabled': enabled}), 200 + @api.get('/providers') def api_providers(): provs = app.extensions['scidk']['providers'] @@ -2068,6 +2752,7 @@ def api_browse(): prov_id = (request.args.get('provider_id') or 'local_fs').strip() or 'local_fs' root_id = (request.args.get('root_id') or '/').strip() or '/' path_q = (request.args.get('path') or '').strip() + _t0 = _time.time() try: provs = app.extensions['scidk']['providers'] prov = provs.get(prov_id) @@ -2093,14 +2778,82 @@ def api_browse(): # Augment with provider badge and convenience fields for e in listing.get('entries', []): e['provider_id'] = prov_id + try: + from .services.metrics import record_latency + record_latency(app, 'browse', _time.time() - _t0) + except Exception: + pass return jsonify(listing), 200 except Exception as e: + try: + from .services.metrics import record_latency + record_latency(app, 'browse', _time.time() - _t0) + except Exception: + pass return jsonify({'error': str(e), 'code': 'browse_exception'}), 500 @api.get('/directories') def api_directories(): + # Prefer SQLite-backed aggregation by root when state.backend=sqlite; fallback to in-memory registry + use_sqlite = (app.config.get('state.backend') == 'sqlite') + if use_sqlite: + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT id, root, completed, extra_json FROM scans WHERE root IS NOT NULL AND root <> '' ORDER BY coalesce(completed, 0) DESC LIMIT 2000") + rows = cur.fetchall() + agg = {} + for (sid, root, completed, extra) in rows: + if not root: + continue + rec = agg.get(root) or {'path': root, 'scanned': 0, 'last_scanned': 0, 'scan_ids': [], 'recursive': None} + rec['scan_ids'].append(sid) + try: + if completed and float(completed) > float(rec.get('last_scanned') or 0): + rec['last_scanned'] = float(completed) + except Exception: + pass + try: + ex = _json.loads(extra) if extra else {} + # Best-effort fields + if ex: + if rec.get('recursive') is None: + rec['recursive'] = bool(ex.get('recursive', False)) + if 'file_count' in ex: + rec['scanned'] = int(ex.get('file_count') or rec.get('scanned') or 0) + if 'source' in ex and not rec.get('source'): + rec['source'] = ex.get('source') + if 'provider_id' in ex and not rec.get('provider_id'): + rec['provider_id'] = ex.get('provider_id') + if 'root_id' in ex and not rec.get('root_id'): + rec['root_id'] = ex.get('root_id') + if 'root_label' in ex and not rec.get('root_label'): + rec['root_label'] = ex.get('root_label') + except Exception: + pass + agg[root] = rec + values = list(agg.values()) + values.sort(key=lambda d: d.get('last_scanned') or 0, reverse=True) + # Fill defaults + for v in values: + if v.get('recursive') is None: + v['recursive'] = False + return jsonify(values), 200 + finally: + try: + conn.close() + except Exception: + pass + except Exception: + # fall through to in-memory + pass + # Fallback (in-memory) dirs = app.extensions['scidk'].get('directories', {}) - # Return stable order: most recently scanned first values = list(dirs.values()) values.sort(key=lambda d: d.get('last_scanned') or 0, reverse=True) return jsonify(values), 200 @@ -2134,24 +2887,48 @@ def _rclone_exe() -> Optional[str]: @api.get('/rclone/mounts') def api_rclone_mounts_list(): - mounts = app.extensions['scidk'].setdefault('rclone_mounts', {}) + mounts_mem = app.extensions['scidk'].setdefault('rclone_mounts', {}) + rows = [] + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT id, provider, root, created, status, extra_json FROM provider_mounts WHERE provider='rclone'") + rows = cur.fetchall() or [] + finally: + try: + conn.close() + except Exception: + pass + except Exception: + rows = [] out = [] - for mid, m in list(mounts.items()): - proc = m.get('process') + for (mid, provider, remote, created, status_persisted, extra) in rows: + try: + extra_obj = json.loads(extra) if extra else {} + except Exception: + extra_obj = {} + mem = mounts_mem.get(mid) or {} + proc = mem.get('process') alive = (proc is not None) and (proc.poll() is None) - status = 'running' if alive else ('exited' if proc is not None else 'unknown') + status = 'running' if alive else ('exited' if proc is not None else (status_persisted or 'unknown')) exit_code = None if alive else (proc.returncode if proc is not None else None) out.append({ - 'id': m.get('id'), - 'name': m.get('name'), - 'remote': m.get('remote'), - 'subpath': m.get('subpath'), - 'path': m.get('path'), - 'read_only': m.get('read_only'), - 'started_at': m.get('started_at'), + 'id': mid, + 'name': mid, + 'remote': remote, + 'subpath': extra_obj.get('subpath'), + 'path': extra_obj.get('path'), + 'read_only': extra_obj.get('read_only'), + 'started_at': created, 'status': status, 'exit_code': exit_code, - 'log_file': m.get('log_file'), + 'log_file': extra_obj.get('log_file'), + 'pid': extra_obj.get('pid') or mem.get('pid'), }) return jsonify(out), 200 @@ -2215,6 +2992,34 @@ def api_rclone_mounts_create(): } mounts = app.extensions['scidk'].setdefault('rclone_mounts', {}) mounts[name] = rec + # Persist mount definition to SQLite (best-effort) + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + extra = { + 'subpath': subpath, + 'path': str(mpath), + 'read_only': bool(read_only), + 'log_file': str(log_file), + 'pid': rec.get('pid'), + } + cur.execute( + "INSERT OR REPLACE INTO provider_mounts(id, provider, root, created, status, extra_json) VALUES(?,?,?,?,?,?)", + (name, 'rclone', remote, float(rec.get('started_at') or time.time()), 'running', _json.dumps(extra)) + ) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass return jsonify({'id': name, 'path': str(mpath)}), 201 except Exception as e: return jsonify({'error': str(e)}), 500 @@ -2246,6 +3051,23 @@ def api_rclone_mounts_delete(mid): except Exception: pass mounts.pop(mid, None) + # Remove persisted mount definition (best-effort) + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("DELETE FROM provider_mounts WHERE id = ?", (mid,)) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass return jsonify({'ok': True}), 200 @api.get('/rclone/mounts//logs') @@ -2283,121 +3105,757 @@ def api_rclone_mounts_health(mid): listable = False return jsonify({'ok': bool(alive and listable), 'alive': bool(alive), 'listable': bool(listable)}), 200 - @api.get('/fs/list') - def api_fs_list(): - """List immediate children within a scanned base directory. - Query params: - - base (required): must equal a previously scanned directory path - - path (optional): if provided, must resolve under base; otherwise list base - Returns JSON with breadcrumb and items. Prevents path traversal outside base. + @api.get('/fs/list') + def api_fs_list(): + """List immediate children within a scanned base directory. + Query params: + - base (required): must equal a previously scanned directory path + - path (optional): if provided, must resolve under base; otherwise list base + Returns JSON with breadcrumb and items. Prevents path traversal outside base. + """ + base = (request.args.get('base') or '').strip() + rel_path = (request.args.get('path') or '').strip() + if not base: + return jsonify({"error": "missing base"}), 400 + dirs = app.extensions['scidk'].get('directories', {}) + if base not in dirs: + return jsonify({"error": "unknown base (run a scan first)"}), 400 + try: + base_p = Path(base).resolve() + cur_p = Path(rel_path).resolve() if rel_path else base_p + # Ensure cur_p is under base + try: + cur_p.relative_to(base_p) + except Exception: + cur_p = base_p + if not cur_p.exists() or not cur_p.is_dir(): + return jsonify({"error": "path not a directory"}), 400 + # Build breadcrumb from base to cur + breadcrumb = [] + # iterate ancestors from base to cur + parts = [] + tmp = cur_p + while True: + parts.append(tmp) + if tmp == base_p: + break + tmp = tmp.parent + if tmp == tmp.parent: # reached filesystem root + break + parts.reverse() + for p in parts: + try: + breadcrumb.append({"name": p.name or str(p), "path": str(p)}) + except Exception: + breadcrumb.append({"name": str(p), "path": str(p)}) + # Precompute scanned dataset paths + scanned_paths = {} + for d in app.extensions['scidk']['graph'].list_datasets(): + scanned_paths[d.get('path')] = d.get('id') + # List items + items = [] + for child in cur_p.iterdir(): + try: + st = child.stat() + is_dir = child.is_dir() + item = { + 'name': child.name, + 'path': str(child.resolve()), + 'is_dir': bool(is_dir), + 'size_bytes': 0 if is_dir else int(st.st_size), + 'modified': float(st.st_mtime), + 'ext': '' if is_dir else child.suffix.lower(), + 'scanned': False, + 'dataset_id': None, + } + if not is_dir: + dsid = scanned_paths.get(str(child.resolve())) + if dsid: + item['scanned'] = True + item['dataset_id'] = dsid + items.append(item) + except Exception: + continue + # Sort: directories first, then files by name + items.sort(key=lambda x: (0 if x['is_dir'] else 1, x['name'].lower())) + return jsonify({ + 'base': str(base_p), + 'path': str(cur_p), + 'breadcrumb': breadcrumb, + 'items': items, + }), 200 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @api.route('/scans', methods=['GET', 'POST']) + def api_scans(): + # POST creates a new scan (alias of legacy /api/scan) + if request.method == 'POST': + return api_scan() + # GET: Prefer SQLite-backed history when state.backend=sqlite; fallback to in-memory + summaries = [] + use_sqlite = (app.config.get('state.backend') == 'sqlite') + if use_sqlite: + try: + from .core import path_index_sqlite as pix + import json as _json + conn = pix.connect() + try: + from .core import migrations as _migs + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT id, root, started, completed, status, extra_json FROM scans ORDER BY coalesce(completed, started) DESC LIMIT 500") + rows = cur.fetchall() + for (sid, root, started, completed, status, extra) in rows: + extra_obj = {} + try: + if extra: + extra_obj = _json.loads(extra) + except Exception: + extra_obj = {} + summaries.append({ + 'id': sid, + 'path': root, + 'recursive': bool((extra_obj or {}).get('recursive')), + 'started': started, + 'ended': completed, + 'duration_sec': (extra_obj or {}).get('duration_sec'), + 'file_count': (extra_obj or {}).get('file_count'), + 'by_ext': (extra_obj or {}).get('by_ext') or {}, + 'source': (extra_obj or {}).get('source'), + 'checksum_count': len((extra_obj or {}).get('checksums') or []), + 'committed': bool((extra_obj or {}).get('committed', False)), + 'committed_at': (extra_obj or {}).get('committed_at'), + 'status': status, + 'rescan_of': (extra_obj or {}).get('rescan_of'), + }) + # Merge in-memory committed flags to reflect immediate commits + try: + inmem = {s.get('id'): s for s in app.extensions['scidk'].get('scans', {}).values()} + for i in range(len(summaries)): + sid = summaries[i].get('id') + if sid in inmem: + if bool(inmem[sid].get('committed')): + summaries[i]['committed'] = True + summaries[i]['committed_at'] = inmem[sid].get('committed_at') + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + except Exception: + summaries = [] + if not summaries: + scans = list(app.extensions['scidk'].get('scans', {}).values()) + scans.sort(key=lambda s: s.get('ended') or s.get('started') or 0, reverse=True) + summaries = [ + { + 'id': s.get('id'), + 'path': s.get('path'), + 'recursive': s.get('recursive'), + 'started': s.get('started'), + 'ended': s.get('ended'), + 'duration_sec': s.get('duration_sec'), + 'file_count': s.get('file_count'), + 'by_ext': s.get('by_ext', {}), + 'source': s.get('source'), + 'checksum_count': len(s.get('checksums') or []), + 'committed': bool(s.get('committed', False)), + 'committed_at': s.get('committed_at'), + } + for s in scans + ] + return jsonify(summaries), 200 + + @api.get('/scans/') + def api_scan_detail(scan_id): + s = app.extensions['scidk'].get('scans', {}).get(scan_id) + if not s: + # Try to reconstruct minimal scan dict from SQLite persistence + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT id, root, started, completed, status, extra_json FROM scans WHERE id = ?", (scan_id,)) + row = cur.fetchone() + if row: + sid, root, started, completed, status, extra = row + extra_obj = {} + try: + if extra: + extra_obj = _json.loads(extra) + except Exception: + extra_obj = {} + s = { + 'id': sid, + 'path': root, + 'recursive': bool((extra_obj or {}).get('recursive')), + 'started': started, + 'ended': completed, + 'duration_sec': (extra_obj or {}).get('duration_sec'), + 'file_count': (extra_obj or {}).get('file_count'), + 'by_ext': (extra_obj or {}).get('by_ext') or {}, + 'source': (extra_obj or {}).get('source'), + 'checksums': (extra_obj or {}).get('checksums') or [], + 'committed': bool((extra_obj or {}).get('committed', False)), + 'committed_at': (extra_obj or {}).get('committed_at'), + 'provider_id': (extra_obj or {}).get('provider_id'), + 'host_type': (extra_obj or {}).get('host_type'), + 'host_id': (extra_obj or {}).get('host_id'), + 'root_id': (extra_obj or {}).get('root_id'), + 'root_label': (extra_obj or {}).get('root_label'), + 'rescan_of': (extra_obj or {}).get('rescan_of'), + } + # Cache minimal record in-memory to help downstream endpoints + app.extensions['scidk'].setdefault('scans', {})[scan_id] = s + else: + return jsonify({"error": "not found"}), 404 + finally: + try: + conn.close() + except Exception: + pass + except Exception: + return jsonify({"error": "not found"}), 404 + return jsonify(s), 200 + + @api.get('/scans//config') + def api_scan_config_get(scan_id): + """Return stored selection config for a scan. + Prefers scans.extra_json.selection; falls back to scan_selection_rules reconstruction.""" + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT extra_json FROM scans WHERE id = ?", (scan_id,)) + row = cur.fetchone() + if row and row[0]: + try: + extra = _json.loads(row[0]) + sel = (extra or {}).get('selection') + if sel: + return jsonify(sel), 200 + except Exception: + pass + cur.execute("SELECT action, path, recursive, node_type, order_index FROM scan_selection_rules WHERE scan_id = ? ORDER BY order_index ASC", (scan_id,)) + rules = [] + for act, pth, rec, nt, oi in cur.fetchall() or []: + rules.append({'action': act, 'path': pth, 'recursive': bool(rec), 'node_type': nt}) + return jsonify({'rules': rules, 'use_ignore': True, 'allow_override_ignores': True}), 200 + finally: + try: conn.close() + except Exception: pass + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @api.post('/scans//config') + def api_scan_config_set(scan_id): + data = request.get_json(force=True, silent=True) or {} + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT extra_json FROM scans WHERE id = ?", (scan_id,)) + row = cur.fetchone() + extra_obj = {} + if row and row[0]: + try: extra_obj = _json.loads(row[0]) + except Exception: extra_obj = {} + extra_obj['selection'] = data + cur.execute("UPDATE scans SET extra_json = ? WHERE id = ?", (_json.dumps(extra_obj), scan_id)) + try: + cur.execute("DELETE FROM scan_selection_rules WHERE scan_id = ?", (scan_id,)) + except Exception: + pass + rules = data.get('rules') or [] + for i, r in enumerate(rules): + act = (r.get('action') or '').lower() + pth = (r.get('path') or '').strip() + rec = 1 if r.get('recursive') else 0 + ntyp = r.get('node_type') + cur.execute( + "INSERT INTO scan_selection_rules(scan_id, action, path, recursive, node_type, order_index) VALUES(?,?,?,?,?,?)", + (scan_id, act, pth, rec, ntyp, i) + ) + conn.commit() + return jsonify({'ok': True}), 200 + finally: + try: conn.close() + except Exception: pass + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @api.post('/scans//rescan') + def api_scan_rescan(scan_id): + override = request.get_json(force=True, silent=True) or {} + selection_override = override.get('selection_override') + # Fetch original scan + resp = api_scan_detail(scan_id) + try: + code = resp[1] + except Exception: + code = getattr(resp, 'status_code', 200) + if code != 200: + return resp + try: + original = resp.get_json() + except Exception: + original = resp[0].json + if not original: + return jsonify({'error': 'scan not found'}), 404 + # Load stored selection unless overridden + sel = None + if not selection_override: + cfg = api_scan_config_get(scan_id) + try: + if getattr(cfg, 'status_code', 200) == 200: + sel = cfg.get_json() + except Exception: + try: + sel = cfg[0].json + except Exception: + sel = None + else: + sel = selection_override + # Run a new scan with same params + try: + from .services.scans_service import ScansService + svc = ScansService(app) + result = svc.run_scan({ + 'provider_id': original.get('provider_id') or 'local_fs', + 'root_id': original.get('root_id') or '/', + 'path': original.get('path'), + 'recursive': bool(original.get('recursive', True)), + 'fast_list': True if (original.get('provider_id')=='rclone' and original.get('recursive')) else False, + 'selection': sel or {}, + }) + if isinstance(result, dict) and result.get('status') == 'ok': + # Persist selection snapshot/rules and link to original + try: + if sel and result.get('scan_id'): + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT extra_json FROM scans WHERE id = ?", (result['scan_id'],)) + row = cur.fetchone() + extra_obj = {} + if row and row[0]: + try: extra_obj = _json.loads(row[0]) + except Exception: extra_obj = {} + extra_obj['selection'] = sel + extra_obj['rescan_of'] = scan_id + cur.execute("UPDATE scans SET extra_json = ? WHERE id = ?", (_json.dumps(extra_obj), result['scan_id'])) + try: + cur.execute("DELETE FROM scan_selection_rules WHERE scan_id = ?", (result['scan_id'],)) + except Exception: + pass + rules = sel.get('rules') or [] + for i, r in enumerate(rules): + act = (r.get('action') or '').lower(); pth = (r.get('path') or '').strip(); rec = 1 if r.get('recursive') else 0; ntyp = r.get('node_type') + cur.execute("INSERT INTO scan_selection_rules(scan_id, action, path, recursive, node_type, order_index) VALUES(?,?,?,?,?,?)", (result['scan_id'], act, pth, rec, ntyp, i)) + conn.commit() + finally: + try: conn.close() + except Exception: pass + except Exception: + pass + return jsonify(result), 200 + if isinstance(result, dict) and result.get('status') == 'error': + code = int(result.get('http_status', 400)); return jsonify(result), code + except Exception as e: + return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'rescan failed'}), 500 + + @api.post('/interpret/scan/') + def api_interpret_scan(scan_id): + """ + Interpret files for a completed scan, streaming remote content via rclone when needed (no mounts required). + + JSON body (optional): + - include: ["*.py","*.ipynb","*.txt"] + - exclude: ["*.csv"] + - max_files: 200 + - max_size_bytes: 1048576 + - interpreters: ["python","ipynb","txt"] + - overwrite: false + - timeout_sec: 60 + """ + import fnmatch, json as _json + body = request.get_json(force=True, silent=True) or {} + include = body.get('include') or [] + exclude = body.get('exclude') or [] + # Client-requested batch size (subject to server cap) + try: + req_max = int(body.get('max_files')) if body.get('max_files') not in (None, '') else None + except Exception: + req_max = None + cap = int(app.config.get('rclone.interpret.max_files_per_batch', 1000)) + cap = min(max(100, cap), 2000) + max_files = int(req_max) if (req_max is not None) else cap + max_files = min(max(1, max_files), cap) + max_size_bytes = body.get('max_size_bytes') + max_size_bytes = int(max_size_bytes) if max_size_bytes not in (None, '') else None + only_interps = set((body.get('interpreters') or [])) + overwrite = bool(body.get('overwrite', False)) + timeout_sec = float(body.get('timeout_sec') or 60.0) + + # Ensure we have a scan record (use existing detail reconstruction if missing) + scan = app.extensions['scidk'].get('scans', {}).get(scan_id) + if not scan: + resp = api_scan_detail(scan_id) + # api_scan_detail returns (json, code) in some fallbacks; support both + try: + code = resp[1] + except Exception: + code = getattr(resp, 'status_code', 200) + if code != 200: + return resp + try: + scan = resp.get_json() # Response + except Exception: + try: + scan = resp[0].json # tuple from our internal call pattern + except Exception: + scan = None + if not scan: + return jsonify({'status': 'error', 'error': 'scan not found'}), 404 + + provider_id = (scan or {}).get('provider_id') or 'local_fs' + provs = app.extensions['scidk'].get('providers') or {} + rclone = provs.get('rclone') if provider_id == 'rclone' else None + if provider_id == 'rclone' and not rclone: + return jsonify({'status': 'error', 'error': 'rclone provider not available'}), 400 + + # Load candidate files from SQLite for this scan_id + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + conn = pix.connect(); _migs.migrate(conn) + cur = conn.cursor() + # Build base query with optional overwrite and cursor, then apply LIMIT + if overwrite: + if (body.get('after_rowid') not in (None, '')): + try: + after_rowid = int(body.get('after_rowid')) + except Exception: + after_rowid = None + else: + after_rowid = None + if after_rowid is not None: + cur.execute( + "SELECT rowid, path, size, file_extension FROM files WHERE scan_id=? AND type='file' AND rowid > ? ORDER BY rowid ASC LIMIT ?", + (scan_id, after_rowid, max_files) + ) + else: + cur.execute( + "SELECT rowid, path, size, file_extension FROM files WHERE scan_id=? AND type='file' ORDER BY rowid ASC LIMIT ?", + (scan_id, max_files) + ) + else: + if (body.get('after_rowid') not in (None, '')): + try: + after_rowid = int(body.get('after_rowid')) + except Exception: + after_rowid = None + else: + after_rowid = None + if after_rowid is not None: + cur.execute( + "SELECT rowid, path, size, file_extension FROM files WHERE scan_id=? AND type='file' AND (interpreted_as IS NULL OR interpreted_as='') AND rowid > ? ORDER BY rowid ASC LIMIT ?", + (scan_id, after_rowid, max_files) + ) + else: + cur.execute( + "SELECT rowid, path, size, file_extension FROM files WHERE scan_id=? AND type='file' AND (interpreted_as IS NULL OR interpreted_as='') ORDER BY rowid ASC LIMIT ?", + (scan_id, max_files) + ) + rows = cur.fetchall() or [] + finally: + try: + conn.close() + except Exception: + pass + + # Interpreter selection based on registry rules and extension map + registry: InterpreterRegistry = app.extensions['scidk']['registry'] + # Build extension -> interpreters map using existing registry data + ext_to_interpreters = {} + try: + # registry.by_extension is available; also respect only_interps filter by id + for ext_key, interps in getattr(registry, 'by_extension', {}).items(): + cand = [] + for interp in interps: + iid = getattr(interp, 'id', '') + if only_interps and iid not in only_interps: + continue + # Use registry enabled state if any explicitly enabled + if getattr(registry, 'enabled_interpreters', None): + if iid not in registry.enabled_interpreters: + continue + cand.append(interp) + if cand: + ext_to_interpreters[ext_key.lower()] = cand + except Exception: + ext_to_interpreters = {} + + def _wanted(path: str) -> bool: + if include and not any(fnmatch.fnmatch(path, pat) for pat in include): + return False + if exclude and any(fnmatch.fnmatch(path, pat) for pat in exclude): + return False + return True + + # Diagnostics counters + files_seen = 0 + filtered_by_size = 0 + filtered_by_include = 0 + filtered_no_interpreter = 0 + + candidates = [] + last_rowid = 0 + for (rowid, path, size, ext) in rows: + try: + last_rowid = int(rowid) + except Exception: + pass + files_seen += 1 + try: + size_i = int(size or 0) + except Exception: + size_i = 0 + if (max_size_bytes is not None) and size_i > max_size_bytes: + filtered_by_size += 1 + continue + if not _wanted(path): + filtered_by_include += 1 + continue + interps = (ext_to_interpreters.get((ext or '').lower()) or []) + if not interps: + # Allow any interpreter with empty ext rule if present in map + interps = ext_to_interpreters.get('', []) or [] + if not interps: + filtered_no_interpreter += 1 + continue + candidates.append((path, size_i, ext, interps)) + if len(candidates) >= max_files: + break + + processed, errors = [], [] + + def _run_interp(interp, target_path: str, content_bytes: bytes): + # Prefer interpret_bytes/text when available, fallback to temp-file + interpret(path) + try: + if hasattr(interp, 'interpret_bytes'): + return interp.interpret_bytes(content_bytes, path_hint=target_path) + except Exception: + pass + try: + if hasattr(interp, 'interpret_text'): + return interp.interpret_text(content_bytes.decode('utf-8', errors='replace'), path_hint=target_path) + except Exception: + pass + import tempfile + from pathlib import Path as _P + with tempfile.NamedTemporaryFile(delete=True) as tf: + try: + tf.write(content_bytes) + tf.flush() + except Exception: + pass + return interp.interpret(_P(tf.name)) + + for (fpath, fsize, ext, interps) in candidates: + last_err = None + try: + if provider_id == 'rclone': + content = rclone.cat(fpath, max_bytes=max_size_bytes, timeout_sec=timeout_sec) # type: ignore[attr-defined] + else: + with open(fpath, 'rb') as f: + content = f.read(max_size_bytes or (16 * 1024 * 1024)) + success = False + for interp in interps: + try: + result = _run_interp(interp, fpath, content) or {} + payload = { + 'status': result.get('status', 'success'), + 'data': result.get('data', result), + 'interpreter_version': getattr(interp, 'version', '0.0.1'), + } + try: + from .core import path_index_sqlite as pix + conn_i = pix.connect(); pix.init_db(conn_i) + cur_i = conn_i.cursor() + cur_i.execute( + "UPDATE files SET interpreted_as=?, interpretation_json=? WHERE path=? AND type='file' AND scan_id=?", + (getattr(interp, 'id', None), _json.dumps(payload.get('data')), fpath, scan_id) + ) + conn_i.commit(); conn_i.close() + except Exception: + pass + processed.append({'path': fpath, 'size': fsize, 'interpreter': getattr(interp, 'id', None), 'status': 'ok'}) + success = True + break + except Exception as e: + last_err = str(e) + continue + if not success: + errors.append({'path': fpath, 'error': last_err or 'no interpreter succeeded'}) + except Exception as e: + errors.append({'path': fpath, 'error': str(e)}) + + return jsonify({ + 'status': 'ok', + 'scan_id': scan_id, + 'provider_id': provider_id, + 'processed_count': len(processed), + 'error_count': len(errors), + 'files_seen': files_seen, + 'filtered_by_size': filtered_by_size, + 'filtered_by_include': filtered_by_include, + 'filtered_no_interpreter': filtered_no_interpreter, + 'processed': processed[:100], + 'errors': errors[:50], + }), 200 + + @api.post('/scans//reinterpret') + def api_scan_reinterpret(scan_id): + """ + Re-run interpreters for files in an existing scan and persist results into SQLite. + - Operates only on local files (absolute paths). Remote canonical paths like "remote:sub/path" are skipped + unless they are mounted locally and resolvable; this MVP does not stream remote bytes. + - Honors current effective interpreter enablement. + - Matching is by filename against each interpreter's globs (fnmatch), with normalization: + patterns like ".csv" are treated as "*.csv". We also fall back to extension-based matching. + Returns a summary with counts, including files_seen and files_matched. """ - base = (request.args.get('base') or '').strip() - rel_path = (request.args.get('path') or '').strip() - if not base: - return jsonify({"error": "missing base"}), 400 - dirs = app.extensions['scidk'].get('directories', {}) - if base not in dirs: - return jsonify({"error": "unknown base (run a scan first)"}), 400 try: - base_p = Path(base).resolve() - cur_p = Path(rel_path).resolve() if rel_path else base_p - # Ensure cur_p is under base - try: - cur_p.relative_to(base_p) - except Exception: - cur_p = base_p - if not cur_p.exists() or not cur_p.is_dir(): - return jsonify({"error": "path not a directory"}), 400 - # Build breadcrumb from base to cur - breadcrumb = [] - # iterate ancestors from base to cur - parts = [] - tmp = cur_p - while True: - parts.append(tmp) - if tmp == base_p: - break - tmp = tmp.parent - if tmp == tmp.parent: # reached filesystem root - break - parts.reverse() - for p in parts: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + import fnmatch + except Exception as e: + return jsonify({'error': str(e)}), 500 + # Resolve effective enabled interpreters + reg = app.extensions['scidk']['registry'] + istate = app.extensions.get('scidk', {}).get('interpreters', {}) + eff_set = set(istate.get('effective_enabled') or []) + if not eff_set: + # Fallback to current registry defaults if snapshot missing + eff_set = set([iid for iid in reg.by_id.keys() if getattr(reg.by_id[iid], 'default_enabled', True)]) + enabled_interpreters = [reg.by_id[i] for i in reg.by_id.keys() if i in eff_set] + # Prepare minimal debug snapshot of interpreter patterns + _dbg_interps = [] + try: + for it in enabled_interpreters: try: - breadcrumb.append({"name": p.name or str(p), "path": str(p)}) + _dbg_interps.append({ + 'id': getattr(it, 'id', None), + 'globs': list((getattr(it, 'globs', []) or [])), + 'extensions': list((getattr(it, 'extensions', []) or [])), + }) except Exception: - breadcrumb.append({"name": str(p), "path": str(p)}) - # Precompute scanned dataset paths - scanned_paths = {} - for d in app.extensions['scidk']['graph'].list_datasets(): - scanned_paths[d.get('path')] = d.get('id') - # List items - items = [] - for child in cur_p.iterdir(): + pass + except Exception: + _dbg_interps = [] + # Open DB + conn = pix.connect() + _migs.migrate(conn) + updated = 0 + skipped_remote = 0 + not_found = 0 + errors = 0 + files_seen = 0 + files_matched = 0 + try: + cur = conn.cursor() + cur.execute("SELECT path, name, file_extension FROM files WHERE scan_id=? AND type='file'", (scan_id,)) + rows = cur.fetchall() + for (path_val, name_val, ext_val) in rows: + files_seen += 1 try: - st = child.stat() - is_dir = child.is_dir() - item = { - 'name': child.name, - 'path': str(child.resolve()), - 'is_dir': bool(is_dir), - 'size_bytes': 0 if is_dir else int(st.st_size), - 'modified': float(st.st_mtime), - 'ext': '' if is_dir else child.suffix.lower(), - 'scanned': False, - 'dataset_id': None, + # Skip remote canonical paths (e.g., "remote:...") + if isinstance(path_val, str) and ':' in path_val and not path_val.startswith('/'): + skipped_remote += 1 + continue + fpath = Path(path_val) + if not fpath.exists(): + not_found += 1 + continue + # Choose applicable interpreters using registry rule engine with extension fallback + ds = { + 'path': path_val, + 'extension': (ext_val or '').strip().lower(), + 'name': name_val, } - if not is_dir: - dsid = scanned_paths.get(str(child.resolve())) - if dsid: - item['scanned'] = True - item['dataset_id'] = dsid - items.append(item) + matched = reg.select_for_dataset(ds) or [] + if not matched: + continue + files_matched += 1 + # Run first matching interpreter (MVP). If multiple, prefer first. + interp = matched[0] + try: + result = interp.interpret(fpath) + payload = { + 'status': result.get('status', 'success'), + 'data': result.get('data', result), + 'interpreter_version': getattr(interp, 'version', '0.0.1'), + } + except Exception as ie: + payload = { + 'status': 'error', + 'data': {'error': str(ie)}, + 'interpreter_version': getattr(interp, 'version', '0.0.1'), + } + # Persist into SQLite + cur.execute( + "UPDATE files SET interpreted_as = ?, interpretation_json = ? WHERE path = ? AND type = 'file' AND scan_id = ?", + (interp.id, _json.dumps(payload.get('data')), str(fpath), scan_id) + ) + updated += (1 if cur.rowcount else 0) except Exception: + errors += 1 continue - # Sort: directories first, then files by name - items.sort(key=lambda x: (0 if x['is_dir'] else 1, x['name'].lower())) - return jsonify({ - 'base': str(base_p), - 'path': str(cur_p), - 'breadcrumb': breadcrumb, - 'items': items, - }), 200 - except Exception as e: - return jsonify({"error": str(e)}), 500 - - @api.route('/scans', methods=['GET', 'POST']) - def api_scans(): - # POST creates a new scan (alias of legacy /api/scan) - if request.method == 'POST': - return api_scan() - # GET returns a lighter summary list of scans - scans = list(app.extensions['scidk'].get('scans', {}).values()) - scans.sort(key=lambda s: s.get('ended') or s.get('started') or 0, reverse=True) - summaries = [ - { - 'id': s.get('id'), - 'path': s.get('path'), - 'recursive': s.get('recursive'), - 'started': s.get('started'), - 'ended': s.get('ended'), - 'duration_sec': s.get('duration_sec'), - 'file_count': s.get('file_count'), - 'by_ext': s.get('by_ext', {}), - 'source': s.get('source'), - 'checksum_count': len(s.get('checksums') or []), - 'committed': bool(s.get('committed', False)), - 'committed_at': s.get('committed_at'), - } - for s in scans - ] - return jsonify(summaries), 200 - - @api.get('/scans/') - def api_scan_detail(scan_id): - s = app.extensions['scidk'].get('scans', {}).get(scan_id) - if not s: - return jsonify({"error": "not found"}), 404 - return jsonify(s), 200 + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + # Minimal server-side log for diagnostics + try: + print(f"[reinterpret] scan={scan_id} updated={updated} skipped_remote={skipped_remote} not_found={not_found} errors={errors} files_seen={files_seen} files_matched={files_matched}") + except Exception: + pass + # Minimal server-side log for diagnostics + try: + print(f"[reinterpret] scan={scan_id} updated={updated} skipped_remote={skipped_remote} not_found={not_found} errors={errors} files_seen={files_seen} files_matched={files_matched}") + if files_matched == 0 and files_seen > 0: + # Log first few file names and extensions plus interpreter patterns + try: + print(f"[reinterpret] enabled_interpreters={_dbg_interps}") + except Exception: + pass + except Exception: + pass + return jsonify({'status': 'ok', 'updated': int(updated), 'skipped_remote': int(skipped_remote), 'not_found': int(not_found), 'errors': int(errors), 'files_seen': int(files_seen), 'files_matched': int(files_matched)}), 200 @api.get('/index/search') def api_index_search(): @@ -2598,92 +4056,28 @@ def api_scan_fs(scan_id): @api.get('/scans//browse') def api_scan_browse(scan_id): """Browse direct children from the SQLite index for a scan. + Delegates to FSIndexService.browse_children. Query params: - - path (required): parent folder to list direct children for. - - page_size (optional, default 100): limit per page. - - next_page_token (optional): opaque pagination token (OFFSET in MVP). - - extension (optional): filter by file_extension (e.g., ".txt"). - - type (optional): filter by type ("file" or "folder"). - - Sorting: type DESC, name ASC. - Returns: { scan_id, path, page_size, next_page_token?, entries: [ ... ] } + - path (optional): parent folder; defaults to scan base path + - page_size (optional, default 100) + - next_page_token (optional) + - extension / ext (optional) + - type (optional) """ - # Validate scan exists in session - s = app.extensions['scidk'].get('scans', {}).get(scan_id) - if not s: - return jsonify({'error': 'scan not found'}), 404 - from .core import path_index_sqlite as pix + from .services.fs_index_service import FSIndexService + svc = FSIndexService(app) req_path = (request.args.get('path') or '').strip() - if req_path == '': - # Default to the scan root path if not provided - req_path = str(s.get('path') or '') - # Normalize page_size and token + # page_size try: page_size = int(request.args.get('page_size') or 100) except Exception: page_size = 100 - page_size = max(1, min(page_size, 1000)) # simple guardrails - token_raw = (request.args.get('next_page_token') or '').strip() - try: - offset = int(token_raw) if token_raw else 0 - except Exception: - offset = 0 - # Optional filters - ext = (request.args.get('extension') or request.args.get('ext') or '').strip().lower() - typ = (request.args.get('type') or '').strip().lower() - # Build query - where = ["scan_id = ?", "parent_path = ?"] - params = [scan_id, req_path] - if ext: - where.append("file_extension = ?") - params.append(ext) - if typ: - where.append("type = ?") - params.append(typ) - where_sql = " AND ".join(where) - sql = ( - "SELECT path, name, type, size, modified_time, file_extension, mime_type " - f"FROM files WHERE {where_sql} " - "ORDER BY type DESC, name ASC " - "LIMIT ? OFFSET ?" - ) - params.extend([page_size + 1, offset]) # fetch one extra row to derive next_page_token - try: - conn = pix.connect() - pix.init_db(conn) - cur = conn.execute(sql, params) - rows = cur.fetchall() - except Exception as e: - return jsonify({'error': str(e)}), 500 - finally: - try: - conn.close() - except Exception: - pass - # Build entries - entries = [] - for r in rows[:page_size]: - path_val, name_val, type_val, size_val, mtime_val, ext_val, mime_val = r - entries.append({ - 'path': path_val, - 'name': name_val, - 'type': type_val, - 'size': int(size_val or 0), - 'modified': float(mtime_val or 0.0), - 'extension': ext_val or '', - 'mime_type': mime_val, - }) - next_token = str(offset + page_size) if len(rows) > page_size else None - out = { - 'scan_id': scan_id, - 'path': req_path, - 'page_size': page_size, - 'entries': entries, + token = (request.args.get('next_page_token') or '').strip() + filters = { + 'extension': (request.args.get('extension') or request.args.get('ext') or '').strip().lower(), + 'type': (request.args.get('type') or '').strip().lower(), } - if next_token is not None: - out['next_page_token'] = next_token - - return jsonify(out), 200 + return svc.browse_children(scan_id, req_path, page_size, token, filters) @api.post('/ro-crates/referenced') def api_ro_crates_referenced(): @@ -2854,7 +4248,36 @@ def api_scan_commit_preview(scan_id): scans = app.extensions['scidk'].setdefault('scans', {}) s = scans.get(scan_id) if not s: - return jsonify({"error": "scan not found"}), 404 + # Try to load minimal scan from SQLite to allow preview + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT id, root, started, completed, status, extra_json FROM scans WHERE id = ?", (scan_id,)) + row = cur.fetchone() + if row: + sid, root, started, completed, status, extra = row + ex = {} + try: + if extra: + ex = _json.loads(extra) + except Exception: + ex = {} + s = {'id': sid, 'path': root, 'started': started, 'ended': completed} + scans[scan_id] = s + else: + return jsonify({"error": "scan not found"}), 404 + finally: + try: + conn.close() + except Exception: + pass + except Exception: + return jsonify({"error": "scan not found"}), 404 try: from .core.commit_rows_from_index import build_rows_for_scan_from_index rows, folder_rows = build_rows_for_scan_from_index(scan_id, s, include_hierarchy=True) @@ -2873,7 +4296,46 @@ def api_scan_hierarchy(scan_id): scans = app.extensions['scidk'].setdefault('scans', {}) s = scans.get(scan_id) if not s: - return jsonify({"error": "scan not found"}), 404 + # Attempt to reconstruct minimal scan from SQLite so the hierarchy can be built from index + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT id, root, started, completed, status, extra_json FROM scans WHERE id = ?", (scan_id,)) + row = cur.fetchone() + if row: + sid, root, started, completed, status, extra = row + extra_obj = {} + try: + if extra: + extra_obj = _json.loads(extra) + except Exception: + extra_obj = {} + s = { + 'id': sid, + 'path': root, + 'started': started, + 'ended': completed, + 'provider_id': (extra_obj or {}).get('provider_id') or 'local_fs', + 'host_type': (extra_obj or {}).get('host_type'), + 'host_id': (extra_obj or {}).get('host_id'), + 'root_id': (extra_obj or {}).get('root_id') or '/', + 'root_label': (extra_obj or {}).get('root_label'), + } + scans[scan_id] = s + else: + return jsonify({"error": "scan not found"}), 404 + finally: + try: + conn.close() + except Exception: + pass + except Exception: + return jsonify({"error": "scan not found"}), 404 try: # Prefer index path when feature enabled use_index = (os.environ.get('SCIDK_COMMIT_FROM_INDEX') or '').strip().lower() in ('1','true','yes','y','on') @@ -3133,6 +4595,12 @@ def _prog(ev, payload): "Verify: URI, credentials or set NEO4J_AUTH=none for no-auth, and database name. " "Also ensure the scan has files present in this session's graph." ) + try: + from .services.metrics import inc_counter + # Consider files written as "rows" proxy for MVP + inc_counter(app, 'rows_ingested_total', int(payload.get('neo4j_written_files') or 0)) + except Exception: + pass return jsonify(payload), 200 except Exception as e: return jsonify({"status": "error", "error": "commit failed", "error_detail": str(e)}), 500 @@ -3473,11 +4941,14 @@ def api_health_graph(): def api_health(): """Overall health focusing on SQLite availability and WAL mode.""" from .core import path_index_sqlite as pix + from .core import migrations as _migs info = { 'sqlite': { 'path': None, 'exists': False, 'journal_mode': None, + 'wal_mode': None, + 'schema_version': None, 'select1': False, 'error': None, } @@ -3487,9 +4958,22 @@ def api_health(): info['sqlite']['path'] = str(dbp) conn = pix.connect() try: + # Ensure schema and capture version + try: + v = _migs.migrate(conn) + info['sqlite']['schema_version'] = int(v) + except Exception: + try: + row_ver = conn.execute('SELECT version FROM schema_migrations LIMIT 1').fetchone() + if row_ver and row_ver[0] is not None: + info['sqlite']['schema_version'] = int(row_ver[0]) + except Exception: + pass mode = (conn.execute('PRAGMA journal_mode;').fetchone() or [''])[0] if isinstance(mode, str): - info['sqlite']['journal_mode'] = mode.lower() + jm = mode.lower() + info['sqlite']['journal_mode'] = jm + info['sqlite']['wal_mode'] = jm row = conn.execute('SELECT 1').fetchone() info['sqlite']['select1'] = bool(row and row[0] == 1) finally: @@ -3508,6 +4992,66 @@ def api_health(): # Always return 200 so UIs can render details; clients can decide on status return jsonify(info), 200 + @api.get('/metrics') + def api_metrics(): + try: + from .services.metrics import collect_metrics + m = collect_metrics(app) + return jsonify(m), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + # Rclone interpretation settings (GET/POST) + @api.get('/settings/rclone-interpret') + def api_settings_rclone_interpret_get(): + return jsonify({ + 'suggest_mount_threshold': int(app.config.get('rclone.interpret.suggest_mount_threshold', 400)), + 'max_files_per_batch': int(app.config.get('rclone.interpret.max_files_per_batch', 1000)), + }), 200 + + @api.post('/settings/rclone-interpret') + def api_settings_rclone_interpret_set(): + data = request.get_json(force=True, silent=True) or {} + try: + suggest = int(data.get('suggest_mount_threshold')) if data.get('suggest_mount_threshold') not in (None, '') else None + except Exception: + suggest = None + try: + max_batch = int(data.get('max_files_per_batch')) if data.get('max_files_per_batch') not in (None, '') else None + except Exception: + max_batch = None + # Validate and clamp + if suggest is not None: + suggest = max(0, int(suggest)) + if max_batch is not None: + max_batch = min(max(100, int(max_batch)), 2000) + # Persist best-effort + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + if suggest is not None: + cur.execute("INSERT OR REPLACE INTO settings(key, value) VALUES(?, ?)", ('rclone.interpret.suggest_mount_threshold', str(suggest))) + if max_batch is not None: + cur.execute("INSERT OR REPLACE INTO settings(key, value) VALUES(?, ?)", ('rclone.interpret.max_files_per_batch', str(max_batch))) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass + # Update in-memory config + if suggest is not None: + app.config['rclone.interpret.suggest_mount_threshold'] = int(suggest) + if max_batch is not None: + app.config['rclone.interpret.max_files_per_batch'] = int(max_batch) + return jsonify({'ok': True, 'suggest_mount_threshold': int(app.config.get('rclone.interpret.suggest_mount_threshold', 400)), 'max_files_per_batch': int(app.config.get('rclone.interpret.max_files_per_batch', 1000))}), 200 + # Settings APIs for Neo4j configuration @api.get('/settings/neo4j') def api_settings_neo4j_get(): @@ -3852,11 +5396,39 @@ def api_create_annotation(): @api.get('/annotations') def api_get_annotations(): - file_id = (request.args.get('file_id') or '').strip() - if not file_id: - return jsonify({'error': 'file_id query parameter is required'}), 400 - items = ann_db.list_annotations_by_file(file_id) - return jsonify({'items': items, 'count': len(items)}), 200 + # Optional filters and pagination + file_id = (request.args.get('file_id') or '').strip() or None + try: + limit = int(request.args.get('limit') or 100) + offset = int(request.args.get('offset') or 0) + except Exception: + limit, offset = 100, 0 + items = ann_db.list_annotations(limit=limit, offset=offset, file_id=file_id) + return jsonify({'items': items, 'count': len(items), 'limit': limit, 'offset': offset}), 200 + + @api.get('/annotations/') + def api_get_annotation(ann_id: int): + item = ann_db.get_annotation(ann_id) + if not item: + return jsonify({'error': 'not found'}), 404 + return jsonify(item), 200 + + @api.patch('/annotations/') + def api_update_annotation(ann_id: int): + payload = request.get_json(silent=True) or {} + # Enforce privacy: only allow kind, label, note, data_json updates + fields = {k: v for k, v in payload.items() if k in {'kind', 'label', 'note', 'data_json'}} + updated = ann_db.update_annotation(ann_id, fields) + if not updated: + return jsonify({'error': 'not found'}), 404 + return jsonify(updated), 200 + + @api.delete('/annotations/') + def api_delete_annotation(ann_id: int): + ok = ann_db.delete_annotation(ann_id) + if not ok: + return jsonify({'error': 'not found'}), 404 + return jsonify({'status': 'deleted', 'id': ann_id}), 200 app.register_blueprint(api) @@ -3879,7 +5451,25 @@ def index(): directories.sort(key=lambda d: d.get('last_scanned') or 0, reverse=True) scans = list(app.extensions['scidk'].get('scans', {}).values()) scans.sort(key=lambda s: s.get('ended') or s.get('started') or 0, reverse=True) - return render_template('index.html', datasets=datasets, by_ext=by_ext, schema_summary=schema_summary, telemetry=telemetry, directories=directories, scans=scans) + # Add SQLite-backed scan_count for landing summary + scan_count = None + try: + from .core import path_index_sqlite as pix + conn = pix.connect() + try: + cur = conn.cursor() + cur.execute("SELECT COUNT(1) FROM scans") + row = cur.fetchone() + if row: + scan_count = int(row[0]) + finally: + try: + conn.close() + except Exception: + pass + except Exception: + scan_count = None + return render_template('index.html', datasets=datasets, by_ext=by_ext, schema_summary=schema_summary, telemetry=telemetry, directories=directories, scans=scans, scan_count=scan_count) @ui.get('/chat') def chat(): @@ -4046,6 +5636,43 @@ def ui_scan(): } scans = app.extensions['scidk'].setdefault('scans', {}) scans[scan_id] = scan + # Persist scan summary to SQLite (best-effort) + try: + from .core import path_index_sqlite as pix + from .core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute( + "INSERT OR REPLACE INTO scans(id, root, started, completed, status, extra_json) VALUES(?,?,?,?,?,?)", + ( + scan_id, + str(path), + float(started or 0.0), + float(ended or 0.0), + 'completed', + _json.dumps({ + 'recursive': bool(recursive), + 'duration_sec': duration, + 'file_count': int(count), + 'by_ext': by_ext, + 'source': getattr(fs, 'last_scan_source', 'python'), + 'checksums': new_checksums, + 'committed': bool(scan.get('committed', False)), + 'committed_at': scan.get('committed_at'), + }) + ) + ) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass telem = app.extensions['scidk'].setdefault('telemetry', {}) telem['last_scan'] = { 'path': str(path), diff --git a/scidk/core/annotations_sqlite.py b/scidk/core/annotations_sqlite.py index bc47ffe6..fd7217a2 100644 --- a/scidk/core/annotations_sqlite.py +++ b/scidk/core/annotations_sqlite.py @@ -180,6 +180,80 @@ def list_annotations_by_file(file_id: str) -> List[Dict[str, Any]]: conn.close() +def get_annotation(ann_id: int) -> Optional[Dict[str, Any]]: + conn = connect() + init_db(conn) + try: + cur = conn.cursor() + cur.execute("SELECT id, file_id, kind, label, note, data_json, created FROM annotations WHERE id = ?", (ann_id,)) + r = cur.fetchone() + if not r: + return None + return {"id": r[0], "file_id": r[1], "kind": r[2], "label": r[3], "note": r[4], "data_json": r[5], "created": r[6]} + finally: + conn.close() + + +def update_annotation(ann_id: int, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]: + allowed = {"kind", "label", "note", "data_json"} + sets = [] + vals = [] + for k, v in fields.items(): + if k in allowed: + sets.append(f"{k} = ?") + vals.append(v) + if not sets: + return get_annotation(ann_id) + conn = connect() + init_db(conn) + try: + cur = conn.cursor() + vals.append(ann_id) + cur.execute(f"UPDATE annotations SET {', '.join(sets)} WHERE id = ?", vals) + conn.commit() + if cur.rowcount == 0: + return None + return get_annotation(ann_id) + finally: + conn.close() + + +def delete_annotation(ann_id: int) -> bool: + conn = connect() + init_db(conn) + try: + cur = conn.cursor() + cur.execute("DELETE FROM annotations WHERE id = ?", (ann_id,)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + +def list_annotations(limit: int = 100, offset: int = 0, file_id: Optional[str] = None) -> List[Dict[str, Any]]: + conn = connect() + init_db(conn) + try: + cur = conn.cursor() + if file_id: + cur.execute( + "SELECT id, file_id, kind, label, note, data_json, created FROM annotations WHERE file_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", + (file_id, int(limit), int(offset)), + ) + else: + cur.execute( + "SELECT id, file_id, kind, label, note, data_json, created FROM annotations ORDER BY id DESC LIMIT ? OFFSET ?", + (int(limit), int(offset)), + ) + rows = cur.fetchall() + return [ + {"id": r[0], "file_id": r[1], "kind": r[2], "label": r[3], "note": r[4], "data_json": r[5], "created": r[6]} + for r in rows + ] + finally: + conn.close() + + # --- New CRUD for relationships --- def create_relationship(from_id: str, to_id: str, rel_type: str, properties_json: Optional[str], created_ts: float) -> Dict[str, Any]: diff --git a/scidk/core/folder_config.py b/scidk/core/folder_config.py new file mode 100644 index 00000000..0e1b4098 --- /dev/null +++ b/scidk/core/folder_config.py @@ -0,0 +1,80 @@ +from __future__ import annotations +from pathlib import Path +from typing import Dict, Any, Optional + +import tomllib # Python 3.11+ + +DEFAULTS = { + 'include': [], # list of glob patterns + 'exclude': [], # list of glob patterns + 'interpreters': None, # optional list to enable/disable +} + + +def _load_one_config(p: Path) -> Optional[Dict[str, Any]]: + """Load a strict TOML config (.scidk.toml). Returns None on parse error or if empty.""" + if p.suffix.lower() != '.toml': + return None + try: + text = p.read_text(encoding='utf-8') + except Exception: + return None + # Strict TOML only + try: + data = tomllib.loads(text) + except Exception: + return None + if not isinstance(data, dict): + return None + cfg: Dict[str, Any] = {} + inc = data.get('include') + exc = data.get('exclude') + intr = data.get('interpreters') + if isinstance(inc, list): + cfg['include'] = [str(x) for x in inc] + if isinstance(exc, list): + cfg['exclude'] = [str(x) for x in exc] + if isinstance(intr, list): + cfg['interpreters'] = [str(x) for x in intr] + return cfg if cfg else None + + +def _merge(parent: Dict[str, Any], child: Dict[str, Any]) -> Dict[str, Any]: + """Child wins (closest folder). Lists are replaced, not concatenated.""" + out = dict(parent) + for k, v in (child or {}).items(): + out[k] = v + return out + + +def load_effective_config(path: Path, stop_at: Optional[Path] = None) -> Dict[str, Any]: + """ + Walk up from path (a directory) to root or stop_at, merging configs such that the closest file wins. + Supported file: .scidk.toml (strict TOML via tomllib) + Returns a dict with possible keys: include, exclude, interpreters. + """ + p = Path(path) + if p.is_file(): + p = p.parent + result: Dict[str, Any] = {} + seen = set() + stop = stop_at.resolve() if stop_at else None + cur = p.resolve() + while True: + if stop is not None and (cur == stop or str(cur).startswith(str(stop)) is False): + pass + cfg_path = cur / '.scidk.toml' + if cfg_path.exists(): + cfg = _load_one_config(cfg_path) + if cfg and str(cfg_path) not in seen: + # Merge with precedence: closer (cfg) wins over accumulated (result) + result = _merge(result, cfg) + seen.add(str(cfg_path)) + if cur == cur.parent or (stop is not None and cur == stop): + break + cur = cur.parent + # Ensure defaults exist + for k, v in DEFAULTS.items(): + if k not in result: + result[k] = v + return result diff --git a/scidk/core/migrations.py b/scidk/core/migrations.py index 7d42c47d..2a26073b 100644 --- a/scidk/core/migrations.py +++ b/scidk/core/migrations.py @@ -225,6 +225,29 @@ def migrate(conn: Optional[sqlite3.Connection] = None) -> int: _set_version(conn, 3) version = 3 + # v4: per-scan selection rules (normalized) + if version < 4: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS scan_selection_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id TEXT NOT NULL, + action TEXT NOT NULL, + path TEXT NOT NULL, + recursive INTEGER NOT NULL, + node_type TEXT, + order_index INTEGER NOT NULL DEFAULT 0, + created REAL NOT NULL DEFAULT (strftime('%s','now')), + created_by TEXT + ); + """ + ) + cur.execute("CREATE INDEX IF NOT EXISTS idx_ssr_scan ON scan_selection_rules(scan_id);") + cur.execute("CREATE INDEX IF NOT EXISTS idx_ssr_scan_order ON scan_selection_rules(scan_id, order_index);") + conn.commit() + _set_version(conn, 4) + version = 4 + return version finally: if own: diff --git a/scidk/core/path_index_sqlite.py b/scidk/core/path_index_sqlite.py index 86ee6cc8..bd1389f0 100644 --- a/scidk/core/path_index_sqlite.py +++ b/scidk/core/path_index_sqlite.py @@ -62,6 +62,16 @@ def init_db(conn: Optional[sqlite3.Connection] = None): ); """ ) + # Ensure new interpretation columns exist (SQLite lacks IF EXISTS for ADD COLUMN -> check via PRAGMA) + try: + cols = {row[1] for row in cur.execute("PRAGMA table_info(files);").fetchall()} + if 'interpreted_as' not in cols: + cur.execute("ALTER TABLE files ADD COLUMN interpreted_as TEXT;") + if 'interpretation_json' not in cols: + cur.execute("ALTER TABLE files ADD COLUMN interpretation_json TEXT;") + except Exception: + # best-effort; ignore if failed (old SQLite variants) + pass # Indexes for files cur.execute("CREATE INDEX IF NOT EXISTS idx_files_scan_parent_name ON files(scan_id, parent_path, name);") cur.execute("CREATE INDEX IF NOT EXISTS idx_files_scan_ext ON files(scan_id, file_extension);") diff --git a/scidk/core/providers.py b/scidk/core/providers.py index 3e422213..9a0ba451 100644 --- a/scidk/core/providers.py +++ b/scidk/core/providers.py @@ -234,6 +234,33 @@ class RcloneProvider(FilesystemProvider): id = "rclone" display_name = "Rclone Remotes" + def cat(self, target_file: str, max_bytes: Optional[int] = None, timeout_sec: Optional[float] = 60.0) -> bytes: + """Stream file bytes from a remote path using rclone cat. + Optionally limit to max_bytes and enforce a timeout. + """ + import shutil, subprocess + exe = shutil.which('rclone') + if not exe: + raise RuntimeError("rclone not installed or not on PATH") + p = subprocess.Popen([exe, 'cat', target_file], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + out, err = p.communicate(timeout=timeout_sec) + except Exception: + try: + p.kill() + except Exception: + pass + raise + if p.returncode != 0: + try: + msg = err.decode('utf-8', errors='ignore').strip() + except Exception: + msg = 'rclone cat failed' + raise RuntimeError(msg or 'rclone cat failed') + if (max_bytes is not None) and isinstance(out, (bytes, bytearray)) and len(out) > max_bytes: + return out[:max_bytes] + return out + def _run(self, args: List[str]): import shutil, subprocess exe = shutil.which('rclone') diff --git a/scidk/core/registry.py b/scidk/core/registry.py index ee4c2be9..ac692014 100644 --- a/scidk/core/registry.py +++ b/scidk/core/registry.py @@ -10,8 +10,12 @@ def __init__(self): self.by_extension: Dict[str, List] = defaultdict(list) self.by_id: Dict[str, object] = {} self.rules = RuleEngine() - # Global defaults for metadata + # Global defaults for metadata and toggle system self.default_enabled: bool = True + # Toggle system additions (non-breaking defaults) + self.enabled_interpreters = set() # if empty -> treat as all enabled + self.usage_stats = {} + self._last_used = {} def register_extension(self, ext: str, interpreter): # Track by extension and by id for direct selection @@ -29,21 +33,68 @@ def get_by_extension(self, ext: str) -> List: def get_by_id(self, interpreter_id: str) -> Optional[object]: return self.by_id.get(interpreter_id) + # Lightweight usage tracking (in-memory) + def record_usage(self, interpreter_id: str, success: bool = True, execution_time_ms: int = 0): + st = self.usage_stats.setdefault(interpreter_id, { + 'total_uses': 0, + 'successes': 0, + 'failures': 0, + 'total_time_ms': 0, + }) + st['total_uses'] += 1 + st['total_time_ms'] += int(execution_time_ms or 0) + if success: + st['successes'] += 1 + else: + st['failures'] += 1 + try: + import time + self._last_used[interpreter_id] = int(time.time()) + except Exception: + pass + + def get_last_used(self, interpreter_id: str): + return self._last_used.get(interpreter_id) + + def get_success_rate(self, interpreter_id: str) -> float: + st = self.usage_stats.get(interpreter_id) or {} + total = int(st.get('total_uses') or 0) + if not total: + return 0.0 + return float(st.get('successes') or 0) / float(total) + + def _is_enabled(self, interpreter_id: str) -> bool: + # If no explicit enables recorded, treat all as enabled for backwards compatibility + return (not self.enabled_interpreters) or (interpreter_id in self.enabled_interpreters) + + def enable_interpreter(self, interpreter_id: str): + if interpreter_id in self.by_id: + self.enabled_interpreters.add(interpreter_id) + + def disable_interpreter(self, interpreter_id: str): + if interpreter_id in self.by_id: + self.enabled_interpreters.discard(interpreter_id) + def select_for_dataset(self, dataset: Dict) -> List: """Selection with rule precedence and extension fallback. - Evaluate rules; if any match, return interpreters in rule priority order (deduped). - Otherwise, return interpreters registered for the dataset's extension. + - Respect enabled state (when any enables recorded). """ path = Path(dataset.get('path', '')) matches = self.rules.applicable(path, dataset) + def _enabled_list(candidates: List[object]) -> List[object]: + if not self.enabled_interpreters: + return candidates + return [i for i in candidates if self._is_enabled(getattr(i, 'id', ''))] if matches: result: List[object] = [] seen = set() for r in matches: interp = self.get_by_id(r.interpreter_id) - if interp and interp.id not in seen: + if interp and interp.id not in seen and self._is_enabled(interp.id): result.append(interp) seen.add(interp.id) if result: return result - return self.get_by_extension(dataset.get('extension', '')) + return _enabled_list(self.get_by_extension(dataset.get('extension', ''))) diff --git a/scidk/interpreters/ipynb_interpreter.py b/scidk/interpreters/ipynb_interpreter.py index c0cb6c3a..c31595d2 100644 --- a/scidk/interpreters/ipynb_interpreter.py +++ b/scidk/interpreters/ipynb_interpreter.py @@ -11,11 +11,128 @@ class IpynbInterpreter: id = "ipynb" name = "Jupyter Notebook Interpreter" - version = "0.1.0" + version = "0.2.0" def __init__(self, max_bytes: int = 5 * 1024 * 1024): self.max_bytes = max_bytes + def _interpret_streaming(self, file_path: Path) -> Dict: + """Best-effort streaming parse using ijson if available. Falls back to full-load if ijson missing.""" + try: + import ijson # type: ignore + except Exception: + # Fallback: full load + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + nb = json.load(f) + return self._summarize_notebook(nb) + + counts = {'code': 0, 'markdown': 0, 'raw': 0} + first_headings: List[str] = [] + imports: List[str] = [] + kernel = '' + language = '' + try: + with open(file_path, 'rb') as f: + # Stream metadata bits + for prefix, event, value in ijson.parse(f): + # Metadata + if prefix == 'metadata.kernelspec.name' and event == 'string' and not kernel: + kernel = str(value) + elif prefix == 'metadata.language_info.name' and event == 'string' and not language: + language = str(value).lower() + # Cells counting + elif prefix.endswith('.cell_type') and event == 'string': + ct = (str(value) or '').lower() + if ct in counts: + counts[ct] += 1 + # Headings from markdown sources (capture a few only) + elif prefix.endswith('.source.item') and event in ('string', 'number'): + # We only try to detect headings/imports from first few items; keep it cheap + # ijson emits scalar items for list entries + s = str(value) + if len(first_headings) < 5 and MD_HEADING_RE.match(s): + first_headings.append(s.strip()) + if len(imports) < 50: + m = IMPORT_RE.match(s) + if m: + mod = m.group(1) or m.group(2) or '' + if mod: + root = mod.split('.')[0] + if root not in imports: + imports.append(root) + # Early stop if we have enough summaries + if len(first_headings) >= 5 and len(imports) >= 50 and all(v > 0 for v in counts.values()): + # Not a formal break since ijson is a generator; we can stop by closing file + break + except Exception as e: + return { + 'status': 'error', + 'data': { + 'error_type': 'IPYNB_INTERPRET_ERROR', + 'details': str(e), + } + } + result = { + 'type': 'ipynb', + 'kernel': kernel, + 'language': language, + 'cells': counts, + 'first_headings': first_headings, + 'imports': imports, + } + return {'status': 'success', 'data': result} + + def _summarize_notebook(self, nb: Dict) -> Dict: + # Kernel / language metadata (nbformat 4 typical structure) + meta = nb.get('metadata') or {} + kernelspec = meta.get('kernelspec') or {} + language_info = meta.get('language_info') or {} + kernel = kernelspec.get('name') or language_info.get('name') or '' + language = (language_info.get('name') or kernelspec.get('language') or '').lower() or '' + + # Cells summary + cells: List[Dict] = nb.get('cells') or [] + counts = {'code': 0, 'markdown': 0, 'raw': 0} + first_headings: List[str] = [] + imports: List[str] = [] + + for cell in cells: + ctype = (cell.get('cell_type') or '').lower() + if ctype in counts: + counts[ctype] += 1 + src_lines = cell.get('source') + if isinstance(src_lines, str): + src_iter = src_lines.splitlines() + else: + src_iter = [str(x) for x in (src_lines or [])] + if ctype == 'markdown' and len(first_headings) < 5: + for line in src_iter: + if MD_HEADING_RE.match(line): + first_headings.append(line.strip()) + if len(first_headings) >= 5: + break + elif ctype == 'code' and len(imports) < 50: + for line in src_iter: + m = IMPORT_RE.match(line) + if m: + mod = m.group(1) or m.group(2) or '' + if mod: + root = mod.split('.')[0] + if root not in imports: + imports.append(root) + if len(imports) >= 50: + break + + result = { + 'type': 'ipynb', + 'kernel': kernel, + 'language': language, + 'cells': counts, + 'first_headings': first_headings, + 'imports': imports, + } + return {'status': 'success', 'data': result} + def interpret(self, file_path: Path) -> Dict: try: size = file_path.stat().st_size @@ -30,58 +147,14 @@ def interpret(self, file_path: Path) -> Dict: } } - with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: - nb = json.load(f) - - # Kernel / language metadata (nbformat 4 typical structure) - meta = nb.get('metadata') or {} - kernelspec = meta.get('kernelspec') or {} - language_info = meta.get('language_info') or {} - kernel = kernelspec.get('name') or language_info.get('name') or '' - language = (language_info.get('name') or kernelspec.get('language') or '').lower() or '' - - # Cells summary - cells: List[Dict] = nb.get('cells') or [] - counts = {'code': 0, 'markdown': 0, 'raw': 0} - first_headings: List[str] = [] - imports: List[str] = [] - - for cell in cells: - ctype = (cell.get('cell_type') or '').lower() - if ctype in counts: - counts[ctype] += 1 - src_lines = cell.get('source') - if isinstance(src_lines, str): - src_iter = src_lines.splitlines() - else: - src_iter = [str(x) for x in (src_lines or [])] - if ctype == 'markdown' and len(first_headings) < 5: - for line in src_iter: - if MD_HEADING_RE.match(line): - first_headings.append(line.strip()) - if len(first_headings) >= 5: - break - elif ctype == 'code' and len(imports) < 50: - for line in src_iter: - m = IMPORT_RE.match(line) - if m: - mod = m.group(1) or m.group(2) or '' - if mod: - root = mod.split('.')[0] - if root not in imports: - imports.append(root) - if len(imports) >= 50: - break - - result = { - 'type': 'ipynb', - 'kernel': kernel, - 'language': language, - 'cells': counts, - 'first_headings': first_headings, - 'imports': imports, - } - return {'status': 'success', 'data': result} + # Try streaming first for memory efficiency + try: + return self._interpret_streaming(file_path) + except Exception: + # As a safety net, do a traditional full parse + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + nb = json.load(f) + return self._summarize_notebook(nb) except json.JSONDecodeError as e: return { 'status': 'error', diff --git a/scidk/services/__init__.py b/scidk/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scidk/services/commit_service.py b/scidk/services/commit_service.py new file mode 100644 index 00000000..7f2a1c40 --- /dev/null +++ b/scidk/services/commit_service.py @@ -0,0 +1,129 @@ +from __future__ import annotations +from typing import Dict, List, Tuple + + +class CommitService: + """ + Commit pipeline helpers. Centralizes logic for preparing rows for commit + from either the file index (preferred) or legacy in-memory dataset maps. + """ + + # --- Index-based builder --- + def build_rows_from_index(self, scan_id: str, scan: Dict, include_hierarchy: bool = True) -> Tuple[List[Dict], List[Dict]]: + """Build file and folder rows for a scan from the SQLite path index. + + Delegates to core.commit_rows_from_index.build_rows_for_scan_from_index. + """ + from ..core.commit_rows_from_index import build_rows_for_scan_from_index + return build_rows_for_scan_from_index(scan_id, scan, include_hierarchy) + + # --- Legacy builder (from in-memory dataset map) --- + def build_rows_legacy_from_datasets(self, scan: Dict, ds_map: Dict[str, Dict]) -> Tuple[List[Dict], List[Dict]]: + """Legacy builder used by endpoints when committing from datasets in memory. + + This is a refactor of the function previously defined inside app.create_app(). + Returns (rows, folder_rows). + """ + from ..core.path_utils import parse_remote_path, parent_remote_path + + checksums = scan.get('checksums') or [] + + def _parent_of(p: str) -> str: + try: + info = parse_remote_path(p) + if info.get('is_remote'): + return parent_remote_path(p) + except Exception: + pass + from pathlib import Path as __P + try: + return str(__P(p).parent) + except Exception: + return '' + + def _name_of(p: str) -> str: + try: + info = parse_remote_path(p) + if info.get('is_remote'): + parts = info.get('parts') or [] + if not parts: + return info.get('remote_name') or '' + return parts[-1] + except Exception: + pass + from pathlib import Path as __P + try: + return __P(p).name + except Exception: + return p + + def _parent_name_of(p: str) -> str: + try: + par = _parent_of(p) + info = parse_remote_path(par) + if info.get('is_remote'): + parts = info.get('parts') or [] + if not parts: + return info.get('remote_name') or '' + return parts[-1] + except Exception: + pass + from pathlib import Path as __P + try: + return __P(par).name + except Exception: + return par + + # Precompute folders observed in this scan (parents of files) + folder_set = set() + for ch in checksums: + dtmp = ds_map.get(ch) + if not dtmp: + continue + folder_set.add(_parent_of(dtmp.get('path') or '')) + + rows: List[Dict] = [] + for ch in checksums: + d = ds_map.get(ch) + if not d: + continue + parent = _parent_of(d.get('path') or '') + interps = list((d.get('interpretations') or {}).keys()) + folder_path = parent + folder_name = _name_of(folder_path) if folder_path else '' + folder_parent = _parent_of(folder_path) if folder_path else '' + folder_parent_name = _parent_name_of(folder_path) if folder_parent else '' + rows.append({ + 'checksum': d.get('checksum'), + 'path': d.get('path'), + 'filename': d.get('filename'), + 'extension': d.get('extension'), + 'size_bytes': int(d.get('size_bytes') or 0), + 'created': float(d.get('created') or 0), + 'modified': float(d.get('modified') or 0), + 'mime_type': d.get('mime_type'), + 'folder': folder_path, + 'folder_name': folder_name, + 'folder_parent': folder_parent, + 'folder_parent_name': folder_parent_name, + 'parent_in_scan': bool(folder_parent and (folder_parent in folder_set)), + 'interps': interps, + }) + + folder_rows: List[Dict] = [] + for f in (scan.get('folders') or []): + folder_rows.append({ + 'path': f.get('path'), + 'name': f.get('name'), + 'parent': f.get('parent'), + 'parent_name': f.get('parent_name'), + }) + + # Enhance with complete hierarchy + try: + from ..core.folder_hierarchy import build_complete_folder_hierarchy + folder_rows = build_complete_folder_hierarchy(rows, folder_rows, scan) + except Exception: + pass + + return rows, folder_rows diff --git a/scidk/services/config.py b/scidk/services/config.py new file mode 100644 index 00000000..4090b4cd --- /dev/null +++ b/scidk/services/config.py @@ -0,0 +1,43 @@ +import os +import shutil + +def apply_channel_defaults() -> None: + """Apply channel-based defaults for feature flags when unset. + Channels: stable (default), dev, beta. + Explicit env values always win; we only set defaults if unset. + Also soft-disable rclone provider by removing it from SCIDK_PROVIDERS if rclone binary is missing, + unless SCIDK_FORCE_RCLONE is truthy. Only perform soft-disable when SCIDK_PROVIDERS was not explicitly set by user. + """ + def setdefault_env(name: str, value: str): + if os.environ.get(name) is None: + os.environ[name] = value + + channel = (os.environ.get('SCIDK_CHANNEL') or 'stable').strip().lower() + + # Defaults by channel (can be overridden by explicit env) + if channel == 'dev': + setdefault_env('SCIDK_FEATURE_RCLONE_MOUNTS', '1') + setdefault_env('SCIDK_FILES_VIEWER', 'rocrate') + setdefault_env('SCIDK_FEATURE_FILE_INDEX', '1') + setdefault_env('SCIDK_COMMIT_FROM_INDEX', '1') + if os.environ.get('SCIDK_PROVIDERS') is None: + os.environ['SCIDK_PROVIDERS'] = 'local_fs,mounted_fs,rclone' + elif channel == 'beta': + setdefault_env('SCIDK_FEATURE_RCLONE_MOUNTS', '0') + setdefault_env('SCIDK_COMMIT_FROM_INDEX', '1') + else: + # stable defaults + setdefault_env('SCIDK_FEATURE_RCLONE_MOUNTS', '0') + setdefault_env('SCIDK_COMMIT_FROM_INDEX', '1') + + # Soft-disable rclone provider if binary missing and providers not explicitly set + providers_env_explicit = ('SCIDK_PROVIDERS' in os.environ) + if not providers_env_explicit: + rclone_exists = shutil.which('rclone') is not None + prov = [p.strip() for p in (os.environ.get('SCIDK_PROVIDERS', 'local_fs,mounted_fs,rclone').split(',')) if p.strip()] + if not rclone_exists and 'rclone' in prov and not (os.environ.get('SCIDK_FORCE_RCLONE') or '').strip().lower() in ('1','true','yes','y','on'): + prov = [p for p in prov if p != 'rclone'] + os.environ['SCIDK_PROVIDERS'] = ','.join(prov) + + # Record effective channel for UI/debug + os.environ.setdefault('SCIDK_CHANNEL', channel or 'stable') diff --git a/scidk/services/fs_index_service.py b/scidk/services/fs_index_service.py new file mode 100644 index 00000000..df3dfb50 --- /dev/null +++ b/scidk/services/fs_index_service.py @@ -0,0 +1,151 @@ +from __future__ import annotations +from typing import Any, Dict, Optional + + +class FSIndexService: + """ + SQLite-backed filesystem index browsing service. + + Provides a stable, index-backed listing of direct children under a parent path + for a given scan, with server-side pagination and simple filters. + + Contract: + - Ordering: type DESC, name ASC + - Pagination token: opaque offset string (stable for MVP) + - Filters: type (file|folder), extension (normalized lowercase, includes leading dot if provided) + """ + + def __init__(self, app): + self.app = app + + def browse_children( + self, + scan_id: str, + parent_path: Optional[str], + page_size: int = 100, + next_page_token: Optional[str] = None, + filters: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + from flask import jsonify # lazy import to avoid hard dependency at import time + from ..core import path_index_sqlite as pix + + # Ensure scan exists (reconstruct from SQLite if missing in memory) + scan = self.app.extensions['scidk'].get('scans', {}).get(scan_id) + if not scan: + try: + from ..core import path_index_sqlite as pix + from ..core import migrations as _migs + import json as _json + conn = pix.connect() + try: + _migs.migrate(conn) + cur = conn.cursor() + cur.execute("SELECT id, root, started, completed, status, extra_json FROM scans WHERE id = ?", (scan_id,)) + row = cur.fetchone() + if row: + sid, root, started, completed, status, extra = row + extra_obj = {} + try: + if extra: + extra_obj = _json.loads(extra) + except Exception: + extra_obj = {} + scan = { + 'id': sid, + 'path': root, + 'started': started, + 'ended': completed, + 'provider_id': (extra_obj or {}).get('provider_id') or 'local_fs', + 'host_type': (extra_obj or {}).get('host_type'), + 'host_id': (extra_obj or {}).get('host_id'), + 'root_id': (extra_obj or {}).get('root_id') or '/', + 'root_label': (extra_obj or {}).get('root_label'), + } + self.app.extensions['scidk'].setdefault('scans', {})[scan_id] = scan + else: + return jsonify({'error': 'scan not found'}), 404 + finally: + try: + conn.close() + except Exception: + pass + except Exception: + return jsonify({'error': 'scan not found'}), 404 + + # Resolve parent path default to scan base path + req_path = (parent_path or '').strip() + if req_path == '': + req_path = str(scan.get('path') or '') + + # Normalize pagination + try: + limit = int(page_size) + except Exception: + limit = 100 + limit = max(1, min(limit, 1000)) + + token_raw = (next_page_token or '').strip() + try: + offset = int(token_raw) if token_raw else 0 + except Exception: + offset = 0 + + # Normalize filters + filters = filters or {} + ext = (filters.get('extension') or filters.get('ext') or '').strip().lower() + typ = (filters.get('type') or '').strip().lower() + + where = ["scan_id = ?", "parent_path = ?"] + params: list[Any] = [scan_id, req_path] + if ext: + where.append("file_extension = ?") + params.append(ext) + if typ: + where.append("type = ?") + params.append(typ) + where_sql = " AND ".join(where) + sql = ( + "SELECT path, name, type, size, modified_time, file_extension, mime_type, interpreted_as, interpretation_json " + f"FROM files WHERE {where_sql} " + "ORDER BY type DESC, name ASC " + "LIMIT ? OFFSET ?" + ) + params.extend([limit + 1, offset]) + + try: + conn = pix.connect(); pix.init_db(conn) + cur = conn.execute(sql, params) + rows = cur.fetchall() + except Exception as e: + return jsonify({'error': str(e)}), 500 + finally: + try: + conn.close() # type: ignore[name-defined] + except Exception: + pass + + entries = [] + for r in rows[:limit]: + path_val, name_val, type_val, size_val, mtime_val, ext_val, mime_val, interp_as, interp_json = r + entries.append({ + 'path': path_val, + 'name': name_val, + 'type': type_val, + 'size': int(size_val or 0), + 'modified': float(mtime_val or 0.0), + 'extension': ext_val or '', + 'mime_type': mime_val, + 'interpreted_as': interp_as, + 'interpretation_json': interp_json, + }) + next_token = str(offset + limit) if len(rows) > limit else None + + out = { + 'scan_id': scan_id, + 'path': req_path, + 'page_size': limit, + 'entries': entries, + } + if next_token is not None: + out['next_page_token'] = next_token + return jsonify(out), 200 diff --git a/scidk/services/graphrag_examples.py b/scidk/services/graphrag_examples.py new file mode 100644 index 00000000..23097966 --- /dev/null +++ b/scidk/services/graphrag_examples.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +# Minimal curated examples to guide Text2CypherRetriever +# Format expected by neo4j_graphrag: list of dicts with 'question' and 'cypher' keys + +examples = [ + { + "question": "Show all files connected to the Smith collaboration", + "cypher": ( + "MATCH (c:Collaboration {name: 'Smith'})-[:INVOLVES]->(:Project)-[:HAS_FILE]->(f:File) " + "RETURN f.path AS path, f.filename AS filename LIMIT 50" + ) + }, + { + "question": "Datasets related to protein interactions from 2023", + "cypher": ( + "MATCH (d:Dataset)-[:ABOUT]->(t:Topic {name:'protein interactions'}) " + "WHERE coalesce(d.year, d.createdYear) = 2023 " + "RETURN d.id AS id, d.name AS name LIMIT 50" + ) + }, + { + "question": "Projects linked to collaboration X", + "cypher": ( + "MATCH (c:Collaboration {name:$name})-[:INVOLVES]->(p:Project) " + "RETURN p.id AS id, p.name AS name" + ) + }, +] diff --git a/scidk/services/graphrag_llm.py b/scidk/services/graphrag_llm.py new file mode 100644 index 00000000..8ea484a2 --- /dev/null +++ b/scidk/services/graphrag_llm.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from typing import Optional, Any, Dict + +class OllamaLLMAdapter: + """ + Minimal Ollama adapter to satisfy neo4j-graphrag LLM expectations. + Provides a .complete(prompt: str) -> str and optionally a .chat(messages) -> str. + Lazy-imports ollama only when instantiated. + """ + def __init__(self, model: str = "llama3:8b", host: Optional[str] = None): + try: + from ollama import Client as _Client # type: ignore + except Exception as e: # pragma: no cover + raise RuntimeError(f"Ollama client not available: {e}") + self._model = model + self._client = _Client(host=host) if host else _Client() + + def complete(self, prompt: str, **kwargs) -> str: + res = self._client.generate(model=self._model, prompt=prompt, options=kwargs or None) + return res.get("response") or "" + + def chat(self, messages: Any, **kwargs) -> str: + # messages: list of {role, content} + res = self._client.chat(model=self._model, messages=messages, options=kwargs or None) + msg = (res.get("message") or {}) + return msg.get("content") or "" diff --git a/scidk/services/graphrag_schema.py b/scidk/services/graphrag_schema.py new file mode 100644 index 00000000..ee70dc90 --- /dev/null +++ b/scidk/services/graphrag_schema.py @@ -0,0 +1,54 @@ +from __future__ import annotations +from typing import Dict, Any, List, Optional +import re + +DEFAULT_PROPERTY_EXCLUDE = [ + r".*password.*", + r".*ssn.*", + r".*token.*", + r".*secret.*", + r".*email.*", +] + +def parse_ttl(s: Optional[str]) -> int: + if not s: + return 0 + try: + return int(s) + except Exception: + pass + m = re.fullmatch(r"(\d+)([smhd])", s.strip().lower()) + if not m: + return 0 + val = int(m.group(1)); unit = m.group(2) + mult = dict(s=1, m=60, h=3600, d=86400)[unit] + return val * mult + + +def filter_schema(raw: Dict[str, Any], allow_labels: Optional[List[str]] = None, + deny_labels: Optional[List[str]] = None, + prop_exclude: Optional[List[str]] = None) -> Dict[str, Any]: + labels = list(raw.get("labels") or []) + rels = list(raw.get("relationships") or []) + if allow_labels: + labels = [l for l in labels if l in allow_labels] + if deny_labels: + labels = [l for l in labels if l not in set(deny_labels)] + # properties are not collected in Phase 1; we just store exclusion patterns for future + prop_patterns = [re.compile(pat, re.I) for pat in (prop_exclude or DEFAULT_PROPERTY_EXCLUDE)] + return { + "labels": labels, + "relationships": rels, + "property_exclude": [p.pattern for p in prop_patterns], + } + + +def normalize_error(status: str, error: str, code: Optional[str] = None, hint: Optional[str] = None, detail: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"status": status, "error": error} + if code: + payload["code"] = code + if hint: + payload["hint"] = hint + if detail: + payload["detail"] = detail + return payload diff --git a/scidk/services/metrics.py b/scidk/services/metrics.py new file mode 100644 index 00000000..9e481157 --- /dev/null +++ b/scidk/services/metrics.py @@ -0,0 +1,74 @@ +import time +from typing import Any, Dict, List, Optional +import os + + +def _telemetry(app) -> Dict[str, Any]: + ext = app.extensions.setdefault('scidk', {}) + return ext.setdefault('telemetry', {}) + + +def inc_counter(app, name: str, value: int = 1) -> None: + tel = _telemetry(app) + tel[name] = int(tel.get(name, 0)) + int(value) + + +def record_event_time(app, name: str, ts: Optional[float] = None) -> None: + tel = _telemetry(app) + arr = tel.setdefault(name, []) + if not isinstance(arr, list): + arr = [] + tel[name] = arr + arr.append(float(ts or time.time())) + # keep last 1000 timestamps + if len(arr) > 1000: + del arr[: len(arr) - 1000] + + +def record_latency(app, name: str, seconds: float) -> None: + tel = _telemetry(app) + key = f"lat_{name}" + arr = tel.setdefault(key, []) + if not isinstance(arr, list): + arr = [] + tel[key] = arr + arr.append(float(seconds)) + # keep last N samples + if len(arr) > 1000: + del arr[: len(arr) - 1000] + + +def _percentile(values: List[float], pct: float) -> Optional[float]: + if not values: + return None + v = sorted(values) + k = max(0, min(len(v) - 1, int(round((pct / 100.0) * (len(v) - 1))))) + return v[k] + + +def collect_metrics(app) -> Dict[str, Any]: + tel = _telemetry(app) + now = time.time() + # Throughput over last 5 minutes + starts = tel.get('scan_started_times') or [] + window = 300.0 + recent = [t for t in starts if (now - float(t)) <= window] + per_min = len(recent) / (window / 60.0) if recent else 0.0 + # Rows ingested total counter + rows_total = int(tel.get('rows_ingested_total') or 0) + # Browse latencies percentiles + bl = tel.get('lat_browse') or [] + p50 = _percentile(bl, 50.0) + p95 = _percentile(bl, 95.0) + # Outbox lag placeholder (only if projection enabled) + projection_enabled = (str(app.config.get('projection.enableNeo4j') or os.environ.get('SCIDK_FEATURE_NEO4J_OUTBOX') or '')).strip().lower() in ('1','true','yes','y','on') + outbox_lag = None + if projection_enabled: + outbox_lag = 0 + return { + 'scan_throughput_per_min': per_min, + 'rows_ingested_total': rows_total, + 'browse_latency_p50': p50, + 'browse_latency_p95': p95, + 'outbox_lag': outbox_lag, + } diff --git a/scidk/services/neo4j_client.py b/scidk/services/neo4j_client.py new file mode 100644 index 00000000..f751d7a6 --- /dev/null +++ b/scidk/services/neo4j_client.py @@ -0,0 +1,182 @@ +from __future__ import annotations +from typing import Any, Dict, Optional, Tuple, List +import os + + +def get_neo4j_params(app: Optional[Any] = None) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str], str]: + """Read Neo4j connection parameters from app extensions or environment. + Returns (uri, user, password, database, auth_mode) where auth_mode is 'basic' or 'none'. + """ + cfg = {} + try: + if app is not None: + cfg = getattr(app, 'extensions', {}).get('scidk', {}).get('neo4j_config', {}) or {} + except Exception: + cfg = {} + uri = cfg.get('uri') or os.environ.get('NEO4J_URI') or os.environ.get('BOLT_URI') + user = cfg.get('user') or os.environ.get('NEO4J_USER') or os.environ.get('NEO4J_USERNAME') + pwd = cfg.get('password') or os.environ.get('NEO4J_PASSWORD') + database = cfg.get('database') or os.environ.get('SCIDK_NEO4J_DATABASE') or None + # Parse NEO4J_AUTH env var if provided (formats: "user/pass" or "none") + neo4j_auth = (os.environ.get('NEO4J_AUTH') or '').strip() + auth_mode = 'basic' + if neo4j_auth: + if neo4j_auth.lower() == 'none': + user = user or None + pwd = pwd or None + auth_mode = 'none' + else: + try: + parts = neo4j_auth.split('/') + if len(parts) >= 2 and not (user and pwd): + user = user or parts[0] + pwd = pwd or '/'.join(parts[1:]) + except Exception: + pass + # If user/password still missing, try to parse from URI (bolt://user:pass@host:port) + try: + if uri and (not user or not pwd): + from urllib.parse import urlparse, unquote # type: ignore + parsed = urlparse(uri) + if parsed.username and parsed.password: + user = user or unquote(parsed.username) + pwd = pwd or unquote(parsed.password) + except Exception: + pass + # Determine auth mode final: none only when explicitly set via NEO4J_AUTH=none + if (os.environ.get('NEO4J_AUTH') or '').strip().lower() == 'none': + auth_mode = 'none' + else: + auth_mode = 'basic' + return uri, user, pwd, database, auth_mode + + +class Neo4jClient: + """ + Thin client around neo4j-python-driver used by commit pipeline. + Provides ensure_constraints, write_scan, and verify operations. + """ + + def __init__(self, uri: str, user: Optional[str], password: Optional[str], database: Optional[str] = None, auth_mode: str = "basic"): + self._uri = uri + self._user = user + self._password = password + self._database = database + self._auth_mode = (auth_mode or 'basic').lower() + self._driver = None + + def connect(self): + from neo4j import GraphDatabase # type: ignore + auth = None if self._auth_mode == 'none' else (self._user, self._password) + self._driver = GraphDatabase.driver(self._uri, auth=auth) + return self + + def close(self): + try: + if self._driver is not None: + self._driver.close() + except Exception: + pass + + def _session(self): + if self._driver is None: + raise RuntimeError("Neo4jClient not connected") + if self._database: + return self._driver.session(database=self._database) + return self._driver.session() + + # --- Operations --- + def ensure_constraints(self) -> None: + try: + with self._session() as s: + try: + s.run("CREATE CONSTRAINT file_identity IF NOT EXISTS FOR (f:File) REQUIRE (f.path, f.host) IS UNIQUE").consume() + except Exception: + pass + try: + s.run("CREATE CONSTRAINT folder_identity IF NOT EXISTS FOR (d:Folder) REQUIRE (d.path, d.host) IS UNIQUE").consume() + except Exception: + pass + except Exception: + # best-effort, ignore errors + pass + + def write_scan(self, rows: List[Dict[str, Any]], folder_rows: List[Dict[str, Any]], scan: Dict[str, Any]) -> Dict[str, Any]: + """Upsert Scan, Folders, Files and relationships for one scan in a single query.""" + with self._session() as sess: + cypher = ( + "MERGE (s:Scan {id: $scan_id}) " + "SET s.path = $scan_path, s.started = $scan_started, s.ended = $scan_ended, " + " s.provider_id = $scan_provider, s.host_type = $scan_host_type, s.host_id = $scan_host_id, " + " s.root_id = $scan_root_id, s.root_label = $scan_root_label, s.scan_source = $scan_source " + "WITH s " + "UNWIND $folders AS folder " + "MERGE (fo:Folder {path: folder.path, host: $node_host}) " + " SET fo.name = folder.name, fo.provider_id = $scan_provider, fo.host_type = $scan_host_type, fo.host_id = $scan_host_id " + "MERGE (fo)-[:SCANNED_IN]->(s) " + "WITH s " + "UNWIND $folders AS folder " + "WITH s, folder WHERE folder.parent IS NOT NULL AND folder.parent <> '' AND folder.parent <> folder.path " + "MERGE (child:Folder {path: folder.path, host: $node_host}) " + "MERGE (parent:Folder {path: folder.parent, host: $node_host}) " + "MERGE (parent)-[:CONTAINS]->(child) " + "WITH s " + "UNWIND $rows AS r " + "MERGE (f:File {path: r.path, host: $node_host}) " + " SET f.filename = r.filename, f.extension = r.extension, f.size_bytes = r.size_bytes, f.created = r.created, f.modified = r.modified, f.mime_type = r.mime_type, f.provider_id = $scan_provider, f.host_type = $scan_host_type, f.host_id = $scan_host_id " + "MERGE (f)-[:SCANNED_IN]->(s) " + "WITH r, f, s " + "FOREACH (iid IN coalesce(r.interps, []) | " + " MERGE (i:Interpreter {id: iid}) " + " MERGE (f)-[:INTERPRETED_AS]->(i) " + ") " + "WITH r, f, s " + "WHERE r.folder IS NOT NULL AND r.folder <> '' " + "MERGE (fo:Folder {path: r.folder, host: $node_host}) " + "MERGE (fo)-[:CONTAINS]->(f) " + "RETURN $scan_id AS scan_id" + ) + params = dict( + rows=rows, + folders=folder_rows, + scan_id=scan.get('id'), + scan_path=scan.get('path'), + scan_started=scan.get('started'), + scan_ended=scan.get('ended'), + scan_provider=scan.get('provider_id'), + scan_host_type=scan.get('host_type'), + scan_host_id=scan.get('host_id'), + scan_root_id=scan.get('root_id'), + scan_root_label=scan.get('root_label'), + scan_source=scan.get('scan_source'), + node_host=scan.get('host_id'), + node_port=None, + ) + _ = list(sess.run(cypher, **params)) + return { + 'written_files': len(rows), + 'written_folders': len(folder_rows), + } + + def verify(self, scan_id: str) -> Dict[str, Any]: + verify_q = ( + "OPTIONAL MATCH (s:Scan {id: $scan_id}) " + "WITH s " + "OPTIONAL MATCH (s)<-[:SCANNED_IN]-(f:File) " + "WITH s, count(DISTINCT f) AS files_cnt " + "OPTIONAL MATCH (s)<-[:SCANNED_IN]-(fo:Folder) " + "RETURN coalesce(s IS NOT NULL, false) AS scan_exists, files_cnt AS files_cnt, count(DISTINCT fo) AS folders_cnt" + ) + with self._session() as sess: + vrec = sess.run(verify_q, scan_id=scan_id).single() + if not vrec: + return {'db_verified': False} + scan_exists = bool(vrec.get('scan_exists')) + files_cnt = int(vrec.get('files_cnt') or 0) + folders_cnt = int(vrec.get('folders_cnt') or 0) + return { + 'db_scan_exists': scan_exists, + 'db_files': files_cnt, + 'db_folders': folders_cnt, + 'db_verified': bool(scan_exists and (files_cnt > 0 or folders_cnt > 0)), + } diff --git a/scidk/services/scans_service.py b/scidk/services/scans_service.py new file mode 100644 index 00000000..c23b57fc --- /dev/null +++ b/scidk/services/scans_service.py @@ -0,0 +1,751 @@ +from __future__ import annotations +from typing import Dict, Any +from pathlib import Path +import os +import json + +# This service encapsulates the scan orchestration that used to live inside app.api_scan +# It is intentionally kept very close to the original logic to preserve behavior and payload. + +class ScansService: + def __init__(self, app): + self.app = app + self.fs = app.extensions['scidk']['fs'] + self.registry = app.extensions['scidk']['registry'] + # Selective scan cache metrics (per-run) + self._skipped_files = 0 + self._skipped_dirs = 0 + self._walk_time_ms = 0.0 + + def run_scan(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Execute a scan synchronously and return the same payload dict that api_scan used to return. + This method performs all the same side effects: populates SQLite index, in-memory datasets, and + updates app.extensions registries (scans, directories, telemetry). + """ + # Import inside to avoid heavy imports at module import time + from flask import jsonify # type: ignore + from ..core import path_index_sqlite as pix # type: ignore + from ..core.path_utils import parse_remote_path, join_remote_path, parent_remote_path # type: ignore + import time, hashlib + + app = self.app + fs = self.fs + registry = self.registry + + provider_id = (data.get('provider_id') or 'local_fs').strip() or 'local_fs' + root_id = (data.get('root_id') or '/').strip() or '/' + path = data.get('path') or (root_id if provider_id != 'local_fs' else os.getcwd()) + recursive = bool(data.get('recursive', True)) + fast_list = bool(data.get('fast_list', False)) + client_specified_fast_list = ('fast_list' in data) + + # Optional selection configuration (rules + ignore policy) + selection = data.get('selection') or {} + rules = selection.get('rules') or [] + use_ignore = bool(selection.get('use_ignore', True)) + allow_override_ignores = bool(selection.get('allow_override_ignores', True)) + + # Compile a simple selector (MVP): decide(path)->(include,bool) + prune_dir(dir)->bool + from fnmatch import fnmatch + def _normalize_rules(rules_list): + out = [] + for i, r in enumerate(rules_list or []): + act = (r.get('action') or '').lower() + p = (r.get('path') or '').rstrip('/') + if not act or not p: + continue + rec = bool(r.get('recursive', False)) + nt = r.get('node_type') + depth = p.count('/') + out.append({'action': act, 'path': p, 'recursive': rec, 'node_type': nt, 'depth': depth, 'order_index': i}) + out.sort(key=lambda x: (x['depth'], x['order_index']), reverse=True) + return out + _rules = _normalize_rules(rules) + def _decide(path_str: str, ignored: bool) -> tuple[bool, str]: + if ignored and not allow_override_ignores: + return False, 'ignored_by_scidkignore' + for r in _rules: + rp = r['path'] + if r['recursive']: + if path_str == rp or path_str.startswith(rp + '/'): + return (r['action'] == 'include'), (r['action'] + '_by_rule') + else: + if path_str == rp: + return (r['action'] == 'include'), (r['action'] + '_by_rule') + if ignored: + return False, 'ignored_by_scidkignore' + return True, 'inherited' + def _prune_dir(dir_path: str) -> bool: + for r in _rules: + if r['action'] == 'exclude' and r['recursive']: + rp = r['path'] + if dir_path == rp or dir_path.startswith(rp + '/'): + return True + return False + + # Snapshot before + before = set(ds.get('checksum') for ds in app.extensions['scidk']['graph'].list_datasets()) + started = time.time() + sid_src = f"{path}|{started}" + scan_id = hashlib.sha1(sid_src.encode()).hexdigest()[:12] + count = 0 + ingested = 0 + folders = [] + + if provider_id in ('local_fs', 'mounted_fs'): + base = Path(path) + items_files = [] + items_dirs = set() + # Source detection compatibility + try: + probe_ncdu = fs._list_files_with_ncdu(base, recursive=recursive) # type: ignore + if probe_ncdu: + fs.last_scan_source = 'ncdu' + else: + probe_gdu = fs._list_files_with_gdu(base, recursive=recursive) # type: ignore + if probe_gdu: + fs.last_scan_source = 'gdu' + else: + fs.last_scan_source = 'python' + except Exception: + fs.last_scan_source = 'python' + # Optional ignore patterns from .scidkignore at base + ignore_patterns = [] + if use_ignore: + try: + ign = base / '.scidkignore' + if ign.exists(): + for line in ign.read_text(encoding='utf-8').splitlines(): + s = line.strip() + if s and not s.startswith('#'): + ignore_patterns.append(s) + except Exception: + ignore_patterns = [] + try: + import time as _t + t_walk0 = _t.time() + # Load previous scan id for this root (if any) + prev_scan_id = None + try: + conn_prev = pix.connect() + from ..core import migrations as _migs + _migs.migrate(conn_prev) + curp = conn_prev.cursor() + curp.execute("SELECT id FROM scans WHERE root = ? ORDER BY COALESCE(completed, started) DESC LIMIT 1", (str(path),)) + rowp = curp.fetchone() + if rowp: + prev_scan_id = rowp[0] + except Exception: + prev_scan_id = None + finally: + try: + conn_prev.close() + except Exception: + pass + # Helper: compute immediate dir signature (files only) + def _dir_signature(dir_path: Path): + total = 0 + max_m = 0.0 + files_n = 0 + try: + with os.scandir(dir_path) as it: + for ent in it: + if ent.is_file(follow_symlinks=False): + files_n += 1 + try: + st = ent.stat(follow_symlinks=False) + total += int(st.st_size) + if st.st_mtime and float(st.st_mtime) > max_m: + max_m = float(st.st_mtime) + except Exception: + pass + except Exception: + pass + return {'files': files_n, 'sum_size': int(total), 'max_mtime': float(max_m)} + # Load previous cache for quick compare + prev_cache = {} + if prev_scan_id: + try: + conn2 = pix.connect() + cur2 = conn2.cursor() + cur2.execute("SELECT path, children_json FROM directory_cache WHERE scan_id = ?", (prev_scan_id,)) + import json as _json + for (pth, js) in cur2.fetchall() or []: + try: + prev_cache[pth] = _json.loads(js) if js else {} + except Exception: + prev_cache[pth] = {} + except Exception: + prev_cache = {} + finally: + try: + conn2.close() + except Exception: + pass + # Current cache rows to persist + curr_cache_rows = [] + if recursive: + # Controlled walk with pruning + # Folder-config cache for effective config per directory + from ..core.folder_config import load_effective_config # lazy import + _conf_cache: dict[str, dict] = {} + for dirpath, dirnames, filenames in os.walk(base, topdown=True, followlinks=False): + dpath = Path(dirpath) + # compute signature and compare + sig = _dir_signature(dpath) + prev_sig = prev_cache.get(str(dpath.resolve())) + # If unchanged vs previous, consider pruning traversal + if prev_sig and all(str(sig.get(k)) == str(prev_sig.get(k)) for k in ('files','sum_size','max_mtime')): + # For the base (root) directory: skip files in this dir but still traverse subdirectories + if dpath == base: + try: + with os.scandir(dpath) as it2: + for ent2 in it2: + if ent2.is_file(follow_symlinks=False): + self._skipped_files += 1 + except Exception: + pass + # Do not modify dirnames for root; we still want to descend + else: + self._skipped_dirs += 1 + # estimate skipped files as current immediate files + self._skipped_files += int(sig.get('files') or 0) + dirnames[:] = [] # prune subdirs + # still record base dir + items_dirs.add(dpath) + # persist current cache for this dir + curr_cache_rows.append((scan_id, str(dpath.resolve()), json.dumps(sig), time.time())) + continue + # keep walking: record dir and files + items_dirs.add(dpath) + # persist cache row + curr_cache_rows.append((scan_id, str(dpath.resolve()), json.dumps(sig), time.time())) + # filter files by rules + folder-config include/exclude + # Load effective folder-config for this directory (cached) + key = str(dpath.resolve()) + conf = _conf_cache.get(key) + if conf is None: + # Prefer local .scidk.toml in this directory (closest wins), then fall back to effective config + try: + tpath = Path(dpath) / '.scidk.toml' + if tpath.exists(): + import tomllib as _toml + try: + data = _toml.loads(tpath.read_text(encoding='utf-8')) + except Exception: + data = {} + inc = data.get('include') if isinstance(data.get('include'), list) else [] + exc = data.get('exclude') if isinstance(data.get('exclude'), list) else [] + conf = {'include': [str(x) for x in inc], 'exclude': [str(x) for x in exc], 'interpreters': None} + else: + conf = load_effective_config(dpath, stop_at=base) + except Exception: + conf = {'include': [], 'exclude': [], 'interpreters': None} + _conf_cache[key] = conf + fc_includes = conf.get('include') or [] + fc_excludes = conf.get('exclude') or [] + from pathlib import Path as _P + def _normalize_patterns(patterns: list[str]) -> list[str]: + out = [] + for pat in patterns: + if not pat: + continue + norm_pat = pat.strip() + if norm_pat.startswith('./'): + norm_pat = norm_pat[2:] + if norm_pat.startswith('/'): + norm_pat = norm_pat[1:] + out.append(norm_pat) + # If pattern starts with '**/', also consider without that prefix + if norm_pat.startswith('**/'): + out.append(norm_pat[3:]) + # If pattern contains path segments, add basename-only variant + if '/' in norm_pat: + seg = norm_pat.split('/')[-1] + if seg and seg not in out: + out.append(seg) + return out + def _matches_any(rel_path: str, name: str, patterns: list[str]) -> bool: + # Normalize cases for case-insensitive filesystems and user patterns + rel_l = (rel_path or '').lower() + name_l = (name or '').lower() + pats = _normalize_patterns(patterns) + p_rel = _P(rel_l) + p_name = _P(name_l) + for pat in pats: + p = (pat or '').strip() + if not p: + continue + p_l = p.lower() + try: + if p_rel.match(p_l) or p_name.match(p_l): + return True + except Exception: + pass + # Suffix-based relaxed match for common patterns like '*.ext' or '**/*.ext' + if p_l.startswith('**/*.') or p_l.startswith('*.'): + suf = p_l.split('*')[-1] + # Normalize suffix by removing any leading path separator introduced by '**/' patterns + if suf.startswith('/'): + suf = suf[1:] + if suf and name_l.endswith(suf): + return True + # Always check basename-only equality as last resort + try: + if '/' in p_l: + base = p_l.split('/')[-1] + else: + base = p_l + if base and name_l == base: + return True + except Exception: + pass + # Fallback to fnmatch-like simple compare + from fnmatch import fnmatch as _fn + if _fn(rel_l, p_l) or _fn(name_l, p_l): + return True + return False + for fname in filenames: + try: + rel = str(Path(dirpath) / fname) + try: + rel_disp = Path(rel).resolve().relative_to(base.resolve()).as_posix() + except Exception: + rel_disp = fname + # Folder-config include/exclude first (match against base-relative, dir-relative, and basename) + rel_local = fname + from fnmatch import fnmatch as _fn2 + def _simple_match(patterns: list[str]) -> bool: + for pat in patterns or []: + p = (pat or '').strip() + if not p: + continue + p = p.lstrip('./') + p_l = p.lower() + if _fn2(rel_disp.lower(), p_l) or _fn2(rel_local.lower(), p_l): + return True + if p_l.startswith('**/*.') or p_l.startswith('*.'): + suf = p_l.split('*')[-1] + if suf.startswith('/'): + suf = suf[1:] + if suf and rel_local.lower().endswith(suf): + return True + return False + include_ok = True + if fc_includes: + include_ok = _simple_match(fc_includes) + if not include_ok: + continue + if fc_excludes and (_simple_match(fc_excludes)): + continue + # .scidkignore and explicit selection rules + ignored = any(fnmatch(rel_disp, pat) for pat in ignore_patterns) + ok, _ = _decide(rel_disp, ignored) + if ok: + items_files.append(Path(dirpath) / fname) + except Exception: + continue + else: + # Non-recursive: list only base + items_dirs.add(base) + sig = _dir_signature(base) + curr_cache_rows.append((scan_id, str(base.resolve()), json.dumps(sig), time.time())) + for p in base.iterdir(): + try: + if p.is_dir(): + items_dirs.add(p) + else: + rel = p.name + ignored = any(fnmatch(rel, pat) for pat in ignore_patterns) + ok, _ = _decide(rel, ignored) + if ok: + items_files.append(p) + except Exception: + continue + # Persist directory_cache rows (best-effort) + try: + connc = pix.connect() + curc = connc.cursor() + curc.executemany("INSERT OR REPLACE INTO directory_cache(scan_id, path, children_json, created) VALUES(?,?,?,?)", curr_cache_rows) + connc.commit() + except Exception: + pass + finally: + try: + connc.close() + except Exception: + pass + self._walk_time_ms = ( _t.time() - t_walk0 ) * 1000.0 + items_dirs.add(base) + except Exception: + items_files = [] + items_dirs = set() + + rows = [] + def _row_from_local(pth: Path, typ: str) -> tuple: + full = str(pth.resolve()) + parent = str(pth.parent.resolve()) if pth != pth.parent else '' + name = pth.name or full + depth = 0 if pth == base else max(0, len(str(pth.resolve()).rstrip('/').split('/')) - len(str(base.resolve()).rstrip('/').split('/'))) + size = 0 + mtime = None + ext = '' + mime = None + if typ == 'file': + try: + st = pth.stat() + size = int(st.st_size) + mtime = float(st.st_mtime) + except Exception: + size = 0 + mtime = None + ext = pth.suffix.lower() + remote = f"local:{os.uname().nodename}" if provider_id == 'local_fs' else f"mounted:{root_id}" + return (full, parent, name, depth, typ, size, mtime, ext, mime, None, None, remote, scan_id, None) + for d in sorted(items_dirs, key=lambda x: str(x)): + rows.append(_row_from_local(d, 'folder')) + for fpath in items_files: + rows.append(_row_from_local(fpath, 'file')) + ingested = pix.batch_insert_files(rows) + + # Legacy: create in-memory datasets and run interpreters + count = 0 + for fpath in items_files: + try: + ds = fs.create_dataset_node(fpath) + app.extensions['scidk']['graph'].upsert_dataset(ds) + interps = registry.select_for_dataset(ds) + for interp in interps: + try: + result = interp.interpret(fpath) + app.extensions['scidk']['graph'].add_interpretation(ds['checksum'], interp.id, { + 'status': result.get('status', 'success'), + 'data': result.get('data', result), + 'interpreter_version': getattr(interp, 'version', '0.0.1'), + }) + except Exception as e: + app.extensions['scidk']['graph'].add_interpretation(ds['checksum'], interp.id, { + 'status': 'error', + 'data': {'error': str(e)}, + 'interpreter_version': getattr(interp, 'version', '0.0.1'), + }) + count += 1 + except Exception: + continue + # Build folders metadata + for d in items_dirs: + try: + parent = str(d.parent.resolve()) if d != d.parent else '' + folders.append({'path': str(d.resolve()), 'name': d.name, 'parent': parent, 'parent_name': Path(parent).name if parent else ''}) + except Exception: + continue + elif provider_id == 'rclone': + provs = app.extensions['scidk'].get('providers') + prov = provs.get('rclone') if provs else None + if not prov: + return {'status': 'error', 'error': 'rclone provider not available', 'http_status': 400} + + # Normalize to full remote path if needed + try: + info = parse_remote_path(path or '') + is_remote = bool(info.get('is_remote')) + except Exception: + is_remote = False + if not is_remote: + path = join_remote_path(root_id, (path or '').lstrip('/')) + if recursive and not client_specified_fast_list: + fast_list = True + + # seed base folder row to ensure folder synthesis + try: + info_t = parse_remote_path(path) + base_name = (info_t.get('parts')[-1] if info_t.get('parts') else info_t.get('remote_name') or path) + base_parent = parent_remote_path(path) + base_item = {"Name": base_name, "Path": "", "IsDir": True, "Size": 0} + rows = [pix.map_rclone_item_to_row(base_item, path, scan_id)] + # folders list should include base as well + try: + info_par = parse_remote_path(base_parent) if base_parent else {} + if info_par.get('is_remote'): + parts = info_par.get('parts') or [] + parent_name = (info_par.get('remote_name') or '') if not parts else parts[-1] + else: + from pathlib import Path as _P + parent_name = _P(base_parent).name if base_parent else '' + except Exception: + parent_name = '' + folders.append({'path': path, 'name': base_name, 'parent': base_parent, 'parent_name': parent_name}) + except Exception: + rows = [] + + try: + if app.config.get('TESTING') and not recursive: + items = [] + else: + items = prov.list_files(path, recursive=recursive, fast_list=fast_list) # type: ignore[attr-defined] + except Exception as ee: + return {'status': 'error', 'error': str(ee), 'http_status': 400} + + seen_folders = set() + def _add_folder(full_path: str, name: str, parent: str): + if full_path in seen_folders: + return + seen_folders.add(full_path) + try: + info_par = parse_remote_path(parent) + if info_par.get('is_remote'): + parts = info_par.get('parts') or [] + parent_name = (info_par.get('remote_name') or '') if not parts else parts[-1] + else: + parent_name = Path(parent).name if parent else '' + except Exception: + parent_name = '' + folders.append({'path': full_path, 'name': name, 'parent': parent, 'parent_name': parent_name}) + + for it in (items or []): + try: + if it.get('IsDir'): + rel = it.get('Path') or it.get('Name') or '' + if rel: + full = join_remote_path(path, rel) + parent = parent_remote_path(full) + leaf = rel.rsplit('/',1)[-1] if isinstance(rel, str) and '/' in rel else rel + _add_folder(full, leaf, parent) + rows.append(pix.map_rclone_item_to_row(it, path, scan_id)) + continue + # File row with selection filter (no .scidkignore for remotes here) + rel = it.get('Path') or it.get('Name') or '' + full_remote = join_remote_path(path, rel) if rel else join_remote_path(path, it.get('Name') or '') + ok, _ = _decide(full_remote, ignored=False) + if ok: + rows.append(pix.map_rclone_item_to_row(it, path, scan_id)) + # Synthesize folder chain for file rel paths + rel = it.get('Path') or it.get('Name') or '' + if rel: + parts = [p for p in (rel.split('/') if isinstance(rel, str) else []) if p] + cur_rel = '' + for i in range(len(parts)-1): + cur_rel = parts[i] if i == 0 else (cur_rel + '/' + parts[i]) + full = join_remote_path(path, cur_rel) + parent = parent_remote_path(full) + _add_folder(full, parts[i], parent) + try: + folder_item = {"Name": parts[i], "Path": cur_rel, "IsDir": True, "Size": 0} + rows.append(pix.map_rclone_item_to_row(folder_item, path, scan_id)) + except Exception: + pass + backend = (os.environ.get('SCIDK_GRAPH_BACKEND') or 'memory').strip().lower() + if backend != 'neo4j': + size = int(it.get('Size') or 0) + full = join_remote_path(path, rel) + ds = fs.create_dataset_remote(full, size_bytes=size, modified_ts=0.0, mime=None) + app.extensions['scidk']['graph'].upsert_dataset(ds) + count += 1 + except Exception: + continue + try: + # Dedup by (path,type) + seen = set(); uniq = [] + for r in rows: + key = (r[0], r[4]) + if key in seen: + continue + seen.add(key); uniq.append(r) + rows = uniq + except Exception: + pass + try: + ingested = pix.batch_insert_files(rows, batch_size=10000) + try: + _chg = pix.apply_basic_change_history(scan_id, path) + app.extensions['scidk'].setdefault('telemetry', {})['last_change_counts'] = _chg + except Exception as __e: + app.extensions['scidk'].setdefault('telemetry', {})['last_change_error'] = str(__e) + except Exception as _e: + app.extensions['scidk'].setdefault('telemetry', {})['last_sqlite_error'] = str(_e) + else: + return {'status': 'error', 'error': f'provider {provider_id} not supported for scan', 'http_status': 400} + + ended = time.time() + duration = ended - started + after = set(ds.get('checksum') for ds in app.extensions['scidk']['graph'].list_datasets()) + new_checksums = sorted(list(after - before)) + + # by_ext calculation preserved + by_ext: Dict[str, int] = {} + backend = (os.environ.get('SCIDK_GRAPH_BACKEND') or 'memory').strip().lower() + if backend == 'neo4j': + try: + conn = pix.connect(); pix.init_db(conn) + cur = conn.cursor() + cur.execute("SELECT file_extension FROM files WHERE scan_id = ? AND type='file'", (scan_id,)) + for (ext,) in cur.fetchall(): + ext = ext or '' + by_ext[ext] = by_ext.get(ext, 0) + 1 + conn.close() + except Exception: + by_ext = {} + else: + ext_map = {} + for ds in app.extensions['scidk']['graph'].list_datasets(): + ext_map[ds.get('checksum')] = ds.get('extension') or '' + for ch in new_checksums: + ext = ext_map.get(ch, '') + by_ext[ext] = by_ext.get(ext, 0) + 1 + + # Non-recursive local: include immediate subfolders + if provider_id in ('local_fs', 'mounted_fs'): + try: + if not recursive: + base = Path(path) + for child in base.iterdir(): + if child.is_dir(): + parent = str(child.parent) + folders.append({ + 'path': str(child.resolve()), + 'name': child.name, + 'parent': parent, + 'parent_name': Path(parent).name if parent else '', + }) + except Exception: + pass + + provs = app.extensions['scidk'].get('providers') + prov = provs.get(provider_id) if provs else None + root_label = None + try: + if prov: + root_label = Path(root_id).name or str(root_id) + except Exception: + root_label = None + + host_type = provider_id + host_id = None + try: + if provider_id == 'rclone': + host_id = f"rclone:{(root_id or '').rstrip(':')}" + elif provider_id == 'local_fs': + import socket as _sock + host_id = f"local:{_sock.gethostname()}" + elif provider_id == 'mounted_fs': + host_id = f"mounted:{root_id}" + except Exception: + host_id = f"{provider_id}:{root_id}" if root_id else provider_id + + scan = { + 'id': scan_id, + 'path': str(path), + 'recursive': bool(recursive), + 'started': started, + 'ended': ended, + 'duration_sec': duration, + 'file_count': int(count), + 'folder_count': len(folders), + 'checksums': new_checksums, + 'folders': folders, + 'by_ext': by_ext, + 'source': getattr(fs, 'last_scan_source', 'python') if provider_id in ('local_fs','mounted_fs') else f"provider:{provider_id}", + 'errors': [], + 'committed': False, + 'committed_at': None, + 'provider_id': provider_id, + 'host_type': host_type, + 'host_id': host_id, + 'root_id': root_id, + 'root_label': root_label, + 'scan_source': f"provider:{provider_id}", + 'ingested_rows': int(ingested), + } + scans = app.extensions['scidk'].setdefault('scans', {}) + scans[scan_id] = scan + # Emit selective cache metrics to logs for observability + try: + app.logger.info(f"scan metrics: skipped_dirs={int(getattr(self, '_skipped_dirs', 0))} skipped_files={int(getattr(self, '_skipped_files', 0))} walk_time_ms={float(getattr(self, '_walk_time_ms', 0.0)):.2f}") + except Exception: + pass + try: + app.extensions['scidk'].setdefault('scan_fs', {}).pop(scan_id, None) + except Exception: + pass + telem = app.extensions['scidk'].setdefault('telemetry', {}) + telem['last_scan'] = { + 'path': str(path), + 'recursive': bool(recursive), + 'scanned': int(count), + 'started': started, + 'ended': ended, + 'duration_sec': duration, + 'source': getattr(fs, 'last_scan_source', 'python') if provider_id in ('local_fs','mounted_fs') else f"provider:{provider_id}", + 'provider_id': provider_id, + 'root_id': root_id, + } + dirs = app.extensions['scidk'].setdefault('directories', {}) + drec = dirs.setdefault(str(path), { + 'path': str(path), + 'recursive': bool(recursive), + 'scanned': 0, + 'last_scanned': 0, + 'scan_ids': [], + 'source': getattr(fs, 'last_scan_source', 'python') if provider_id in ('local_fs','mounted_fs') else f"provider:{provider_id}", + 'provider_id': provider_id, + 'root_id': root_id, + 'root_label': root_label, + }) + drec.update({ + 'recursive': bool(recursive), + 'scanned': int(count), + 'last_scanned': ended, + 'source': getattr(fs, 'last_scan_source', 'python') if provider_id in ('local_fs','mounted_fs') else f"provider:{provider_id}", + 'provider_id': provider_id, + 'root_id': root_id, + 'root_label': root_label, + }) + drec.setdefault('scan_ids', []).append(scan_id) + # Persist scan summary to SQLite (best-effort), mirroring background worker + try: + conn = pix.connect() + try: + from ..core import migrations as _migs + import json as _json + _migs.migrate(conn) + cur = conn.cursor() + cur.execute( + "INSERT OR REPLACE INTO scans(id, root, started, completed, status, extra_json) VALUES(?,?,?,?,?,?)", + ( + scan_id, + str(path), + float(started or 0.0), + float(ended or 0.0), + 'completed', + _json.dumps({ + 'recursive': bool(recursive), + 'duration_sec': duration, + 'file_count': int(count), + 'by_ext': by_ext, + 'source': scan.get('source'), + 'checksums': new_checksums, + 'committed': False, + 'committed_at': None, + 'provider_id': provider_id, + 'root_id': root_id, + 'host_type': host_type, + 'host_id': host_id, + 'root_label': root_label, + 'selection': (selection or {}), + 'skipped_dirs': int(getattr(self, '_skipped_dirs', 0)), + 'skipped_files': int(getattr(self, '_skipped_files', 0)), + 'walk_time_ms': float(getattr(self, '_walk_time_ms', 0.0)), + }) + ) + ) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + except Exception: + pass + # return payload identical to previous endpoint + return {"status": "ok", "scan_id": scan_id, "scanned": count, "folder_count": len(folders), "ingested_rows": int(ingested), "duration_sec": duration, "path": str(path), "recursive": bool(recursive), "provider_id": provider_id} diff --git a/scidk/ui/templates/datasets.html b/scidk/ui/templates/datasets.html index b2f4c27b..2e122578 100644 --- a/scidk/ui/templates/datasets.html +++ b/scidk/ui/templates/datasets.html @@ -38,30 +38,38 @@

Files

+
+
+
+
Scan selected folder (recursive):
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
- +
NameTypeSizeModifiedProvider
NameTypeSizeModifiedProvider
-
Select a provider, root, and item to see details. -
-
-
Scan selected folder (recursive):
-
- - -
-
- -
-
-
-
-
+
Select a provider, root, and item to see details.
@@ -126,16 +134,22 @@

Snapshot (scanned) browse

+ +
+
- +
NameTypeSizeModified
NameTypeSizeModified
+
@@ -177,7 +191,7 @@

Start Background Scan

- +
@@ -186,7 +200,7 @@

Start Background Scan

- +
@@ -338,20 +352,57 @@

Start Background Scan

const size = e.type === 'folder' ? '' : fmtBytes(e.size||0); const mod = fmtTime(e.mtime||0); const prov = providerId; - return `${e.name}${type}${size}${mod}${prov}`; + const chkId = `sel-${btoa((e.id||'')+':'+(e.type||''))}`; + return ` + + ${e.name}${type}${size}${mod}${prov}`; }).join(''); provList.innerHTML = rows || 'Empty folder.'; attachProvHandlers(); } catch(e){ provList.innerHTML = 'Browse failed.'; } } + // Selection store (rules) + const selectionRules = []; + function upsertRule(action, path, recursive, node_type){ + // Remove opposite rule if exists, then add/replace + const idx = selectionRules.findIndex(r => r.path===path && r.recursive===!!recursive && r.node_type===node_type); + if (idx >= 0) selectionRules.splice(idx,1); + selectionRules.push({ action, path, recursive: !!recursive, node_type }); + renderSelSummary(); + } + function renderSelSummary(){ + const s = document.getElementById('sel-summary'); + if (!s) return; + const inc = selectionRules.filter(r=>r.action==='include').length; + const exc = selectionRules.filter(r=>r.action==='exclude').length; + s.textContent = `Selection: ${inc} include, ${exc} exclude`; + } + function attachProvHandlers(){ + // Select/Deselect all on page + const selAll = document.getElementById('prov-sel-all'); + if (selAll){ + selAll.addEventListener('change', () => { + const boxes = document.querySelectorAll('input.prov-sel'); + boxes.forEach(chk => { + const was = chk.checked; + chk.checked = selAll.checked; + if (was !== selAll.checked){ + // trigger change handler to update rules + chk.dispatchEvent(new Event('change')); + } + }); + }); + } + // Row click navigates into folders; file shows details document.querySelectorAll('tr.prov-item')?.forEach(tr => { - tr.addEventListener('click', () => { + tr.addEventListener('click', (ev) => { + // Ignore clicks originating from checkbox + if (ev.target && ev.target.closest && ev.target.closest('input.prov-sel')) return; const id = tr.getAttribute('data-id'); const type = tr.getAttribute('data-type'); if (type === 'folder'){ - // For rclone, set currentPath to relative portion (strip remote prefix) if (currentProv === 'rclone'){ const i = id.indexOf(':'); let rel = i >= 0 ? id.slice(i+1) : id; @@ -363,10 +414,23 @@

Start Background Scan

if (provPathInput) provPathInput.value = currentPath; browse(currentProv, currentRoot, currentPath); } else { - provPanel.innerHTML = `
${tr.firstChild?.textContent||id}
Path: ${id}
`; + provPanel.innerHTML = `
${tr.children[1]?.textContent||id}
Path: ${id}
`; } }); }); + // Checkbox selection → translate to include/exclude rules + document.querySelectorAll('input.prov-sel')?.forEach(chk => { + chk.addEventListener('change', () => { + const id = chk.getAttribute('data-id'); + const type = chk.getAttribute('data-type'); + const isFolder = (type === 'folder'); + const recursive = isFolder; + const node_type = isFolder ? 'folder' : 'file'; + const action = chk.checked ? 'include' : 'exclude'; + upsertRule(action, id, recursive, node_type); + }); + }); + renderSelSummary(); } if (provSelect){ @@ -462,13 +526,14 @@

Start Background Scan

try { (window.scidkLocalTasks||[]).push(localTask); } catch(_) { /* ignore */ } fetchTasks(); // trigger re-render with local task try { - const r = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({ provider_id: provId, root_id: rootId||'/', path: scanPath, recursive, fast_list: fastList }) }); + const overrideIg = !!(document.getElementById('sel-override-ignore') && document.getElementById('sel-override-ignore').checked); + const selection = { rules: selectionRules.slice(), use_ignore: true, allow_override_ignores: overrideIg }; + const r = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({ provider_id: provId, root_id: rootId||'/', path: scanPath, recursive, fast_list: fastList, selection }) }); const ctype = (r.headers && r.headers.get('content-type')) || ''; let j = null; if (ctype.includes('application/json')){ try { j = await r.json(); } catch(_) { j = null; } } else { - // Non-JSON response (proxy/gateway error). Read text for clarity try { const txt = await r.text(); throw new Error(`HTTP ${r.status}: ${txt}`); } catch(e){ throw e; } } if (r.ok && j){ @@ -480,7 +545,6 @@

Start Background Scan

if (files === 0 && folders > 0 && !recursive){ msg += ' — Only folders found. Enable Recursive to include files in subfolders.'; } if (dur) msg += ` (${dur})`; provScanMsg.textContent = msg; - // Mark local task completed localTask.status = 'completed'; localTask.processed = files; localTask.total = files || localTask.processed; @@ -501,10 +565,12 @@

Start Background Scan

} finally { if (btn) { btn.disabled = false; btn.textContent = 'Scan'; } - fetchTasks(); // refresh tasks view to reflect final status + fetchTasks(); } }); } + const btnScanWithSel = document.getElementById('btn-scan-with-selection'); + if (btnScanWithSel){ btnScanWithSel.addEventListener('click', (ev) => { ev.preventDefault(); if (provScanForm) provScanForm.dispatchEvent(new Event('submit')); }); } // Snapshot browse logic (index-backed) const snapScanSel = document.getElementById('snapshot-scan'); @@ -517,6 +583,8 @@

Start Background Scan

const snapNext = document.getElementById('snap-next'); const snapUseLive = document.getElementById('snap-use-live'); const snapCommit = document.getElementById('snap-commit'); + const snapReint = document.getElementById('snap-reinterpret'); + const snapRescan = document.getElementById('snap-rescan'); const snapStatus = document.getElementById('snap-status'); const snapSearchQ = document.getElementById('snap-search-q'); const snapSearchExt = document.getElementById('snap-search-ext'); @@ -527,8 +595,22 @@

Start Background Scan

const snapCrumb = document.getElementById('snap-crumb'); let snapToken = null; let snapHistory = []; + // cache rclone interpret settings + let rcSettings = { suggest_mount_threshold: 400, max_files_per_batch: 1000 }; + async function loadRcSettings(){ + try{ const r = await fetch('/api/settings/rclone-interpret'); const j = await r.json(); if (r.ok){ rcSettings = { suggest_mount_threshold: Number(j.suggest_mount_threshold||400), max_files_per_batch: Number(j.max_files_per_batch||1000) }; } } + catch(_) { /* ignore */ } + } + loadRcSettings(); async function loadScansForSnapshot(){ try{ const r = await fetch('/api/scans'); const scans = await r.json(); if (!snapScanSel) return; const current = snapScanSel.value; snapScanSel.innerHTML = '' + scans.map(s => ``).join(''); if (current) snapScanSel.value = current; } catch(e) { /* ignore */ } + // show/hide rclone banner if applicable + try { + const id = snapScanSel && snapScanSel.value; + const banner = document.getElementById('rclone-banner'); + if (id){ const rs = await fetch(`/api/scans/${encodeURIComponent(id)}`); const scan = await rs.json(); const isRclone = (scan && scan.provider_id) === 'rclone'; const big = (scan && (scan.file_count||0)) >= (rcSettings.suggest_mount_threshold||400); if (banner) banner.style.display = (isRclone && big) ? '' : 'none'; } + else { if (banner) banner.style.display = 'none'; } + } catch(_) { /* ignore */ } } async function browseSnapshot(direction){ if (!snapScanSel || !snapScanSel.value){ snapList.innerHTML = 'Select a scan first.'; return; } @@ -556,6 +638,8 @@

Start Background Scan

// Update crumb with clickable ancestors const p = j.path || ''; let parts = []; + const panel = document.getElementById('snap-panel'); + if (panel) { panel.style.display = 'none'; panel.innerHTML=''; } if (p){ if (p.includes(':')){ const i = p.indexOf(':'); const rem = p.slice(0,i+1); const suff = p.slice(i+1).replace(/^\/+/, ''); const segs = suff? suff.split('/'): []; let acc = rem; parts.push({name: rem.replace(/:$/,''), path: rem}); for (let s of segs){ acc = acc + (acc.endsWith(':')? '':'/') + s; parts.push({name: s, path: acc}); } } else { const segs = p.replace(/^\/+/, '').split('/'); let acc = ''; for (let i=0;iStart Background Scan // Render const rows = entries.map(e => { const mod = (e.modified && e.modified > 0 && e.type==='file') ? fmtTime(e.modified) : ''; - return `${e.name}${e.type}${e.type==='file'?(fmtBytes(e.size||0)):''}${mod}`; + const interp = e.interpreted_as ? `${e.interpreted_as}` : ''; + const esc = (s) => (s||'').replaceAll('&','&').replaceAll('"','"').replaceAll('<','<').replaceAll('>','>'); + const preview = e.interpretation_json ? esc((e.interpretation_json||'').slice(0,800)) : ''; + return `${e.name} ${interp}${e.type}${e.type==='file'?(fmtBytes(e.size||0)):''}${mod}`; }).join(''); snapList.innerHTML = rows || 'No entries.'; snapNext.disabled = !snapToken; snapPrev.disabled = (snapHistory.length <= 1); - // Click to drill when folder + // Click to drill when folder; show details when file document.querySelectorAll('#snap-list tr')?.forEach(tr => { tr.addEventListener('click', () => { const t = tr.getAttribute('data-type'); const p = tr.getAttribute('data-path'); - if (t === 'folder' && snapPathInput){ snapPathInput.value = p; snapToken = null; snapHistory = []; browseSnapshot(); } + const panel = document.getElementById('snap-panel'); + if (t === 'folder' && snapPathInput){ snapPathInput.value = p; snapToken = null; snapHistory = []; browseSnapshot(); return; } + if (t === 'file' && panel){ + const ias = tr.getAttribute('data-interp-as') || ''; + const ij = tr.getAttribute('data-interp-json') || ''; + let body = ''; + if (ias){ body += `
Interpreted as: ${ias}
`; } + if (ij){ + try { + const parsed = JSON.parse(ij); + body += `
Interpretation
${JSON.stringify(parsed, null, 2)}
`; + } catch(_){ body += `
Interpretation
${ij}
`; } + } else { + body += '
No interpretation stored for this file.
'; + } + panel.innerHTML = `
Path: ${p}
${body}`; + panel.style.display = 'block'; + } }); }); } catch(e){ @@ -606,7 +710,7 @@

Start Background Scan

snapSearchResults.innerHTML = rows || '
No matches.
'; } catch(e){ snapSearchResults.textContent = 'Search error: ' + e; } }); } - if (snapScanSel){ snapScanSel.addEventListener('change', () => { snapToken = null; snapHistory = []; browseSnapshot(); }); } + if (snapScanSel){ snapScanSel.addEventListener('change', () => { snapToken = null; snapHistory = []; browseSnapshot(); loadScansForSnapshot(); }); } if (snapUseLive){ snapUseLive.addEventListener('click', () => { const provId = (provSelect && provSelect.value) || currentProv || 'local_fs'; const rootId = (rootSelect && rootSelect.value) || currentRoot || '/'; @@ -621,6 +725,71 @@

Start Background Scan

catch(e){ snapStatus.textContent = 'Commit failed: ' + e; } finally { snapCommit.disabled = false; snapCommit.textContent = prev; } }); } + if (snapRescan){ snapRescan.addEventListener('click', async () => { + if (!snapScanSel || !snapScanSel.value){ snapStatus.textContent = 'Select a scan first.'; return; } + const id = snapScanSel.value; snapRescan.disabled = true; const prev = snapRescan.textContent; snapRescan.textContent = 'Rescanning…'; snapStatus.textContent = ''; + try{ + // Load original scan details and stored selection + const sd = await fetch(`/api/scans/${encodeURIComponent(id)}`); + const scan = await sd.json(); + if (!sd.ok){ snapStatus.textContent = 'Rescan failed: ' + (scan.error || sd.status); return; } + const cfgResp = await fetch(`/api/scans/${encodeURIComponent(id)}/config`); + const sel = cfgResp.ok ? (await cfgResp.json()) : {}; + const payload = { + type: 'scan', + path: scan.path, + recursive: !!scan.recursive, + provider_id: scan.provider_id || 'local_fs', + root_id: scan.root_id || '/', + selection: sel || {} + }; + const r = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(payload) }); + if (r.status === 202){ snapStatus.textContent = 'Rescan started in background. Watch progress above.'; } + else { const j = await r.json(); snapStatus.textContent = 'Rescan failed: ' + (j.error || r.status); } + } catch(e){ snapStatus.textContent = 'Rescan failed: ' + e; } + finally { snapRescan.disabled = false; snapRescan.textContent = prev; } + }); } + + if (snapReint){ snapReint.addEventListener('click', async () => { + if (!snapScanSel || !snapScanSel.value){ snapStatus.textContent = 'Select a scan first.'; return; } + const id = snapScanSel.value; snapReint.disabled = true; const prev = snapReint.textContent; snapReint.textContent = 'Re-interpreting…'; snapStatus.textContent = ''; + try{ + // Fetch scan details to decide which endpoint to call + const sd = await fetch(`/api/scans/${encodeURIComponent(id)}`); + const scan = await sd.json(); + const providerId = (scan && scan.provider_id) || null; + if (providerId === 'rclone'){ + // Chunked streaming interpretation for rclone scans + const batch = rcSettings.max_files_per_batch || 1000; + let cursor = null; + let totalProcessed = 0, totalErrors = 0, batches = 0; + const common = { include: ["*.txt","*.csv","*.md","*.json","*.yaml","*.yml","*.py","*.ipynb"], max_files: batch, max_size_bytes: 1048576, timeout_sec: 60, overwrite: true }; + while (true){ + const payload = cursor ? { ...common, after_rowid: cursor } : { ...common }; + const r = await fetch(`/api/interpret/scan/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + const j = await r.json(); + if (!r.ok){ snapStatus.textContent = 'Interpret (remote) failed: ' + (j.error || r.status); break; } + totalProcessed += (j.processed_count||0); totalErrors += (j.error_count||0); batches += 1; + snapStatus.textContent = `Interpret (remote): batches=${batches}, processed=${totalProcessed}, errors=${totalErrors}`; + if (!j.next_cursor){ break; } + cursor = j.next_cursor; + } + browseSnapshot(); + } else { + // Legacy local reinterpret + const r = await fetch(`/api/scans/${encodeURIComponent(id)}/reinterpret`, { method: 'POST' }); + const j = await r.json(); + if (!r.ok){ snapStatus.textContent = 'Re-interpret failed: ' + (j.error || r.status); } + else { + const fs = (j.files_seen!==undefined)?`, files_seen=${j.files_seen}`:''; const fm = (j.files_matched!==undefined)?`, files_matched=${j.files_matched}`:''; + snapStatus.textContent = `Re-interpret: updated=${j.updated}, skipped_remote=${j.skipped_remote}, not_found=${j.not_found}, errors=${j.errors}${fs}${fm}`; + browseSnapshot(); + } + } + } + catch(e){ snapStatus.textContent = 'Re-interpret failed: ' + e; } + finally { snapReint.disabled = false; snapReint.textContent = prev; } + }); } // Scans dropdown and background tasks // Recent scans dropdown helpers @@ -723,8 +892,8 @@

Start Background Scan

const fmtTs = (t) => { try { return t ? new Date(Math.round(t)*1000).toLocaleString() : ''; } catch(_) { return t || ''; } }; if (!scans || scans.length === 0){ tbody.innerHTML = 'No scans yet.'; return; } tbody.innerHTML = scans.map(s => ` - ${s.id} - ${s.path} + ${s.id}${s.rescan_of?` rescan`:''} + ${s.path}${s.rescan_of?` (of ${s.rescan_of})`:''} ${s.file_count||0} ${s.recursive?'yes':'no'} ${fmtTs(s.started)} @@ -756,7 +925,9 @@

Start Background Scan

try { const provId = (provSelect && provSelect.value) || currentProv || 'local_fs'; const rootId = (rootSelect && rootSelect.value) || currentRoot || '/'; - const payload = { type: 'scan', path, recursive, provider_id: provId, root_id: rootId }; + const overrideIgSel = !!(document.getElementById('sel-override-ignore') && document.getElementById('sel-override-ignore').checked); + const selection = { rules: selectionRules.slice(), use_ignore: true, allow_override_ignores: overrideIgSel }; + const payload = { type: 'scan', path, recursive, provider_id: provId, root_id: rootId, selection }; const r = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(payload) }); if (r.status === 202){ startPolling(); fetchTasks(); } else { const j = await r.json(); alert('Task error: ' + (j.error || r.status)); } @@ -770,12 +941,15 @@

Start Background Scan

const scansDiv = document.getElementById('scans-panel'); function scansRow(s){ const ts = Math.round(s.ended||s.started||0); - return `
-
${s.id} — ${s.path} — files: ${s.file_count} — ${s.recursive?'recursive':'shallow'} — ${ts}
-
- Open - - + const tag = s.rescan_of ? `rescan` : ''; + const of = s.rescan_of ? ` (of ${s.rescan_of})` : ''; + return `
+
${s.id} ${tag} — ${s.path}${of} — files: ${s.file_count} — ${s.recursive?'recursive':'shallow'} — ${ts}
+
+ Open + + +
`; } @@ -801,6 +975,11 @@

Start Background Scan

} else if (action === 'delete'){ const r = await fetch('/api/scans/' + encodeURIComponent(id), { method: 'DELETE' }); if (!r.ok){ const j = await r.json(); alert('Delete failed: ' + (j.error || r.status)); } + } else if (action === 'rescan'){ + const r = await fetch('/api/scans/' + encodeURIComponent(id) + '/rescan', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({}) }); + const j = await r.json(); + if (!r.ok){ alert('Rescan failed: ' + (j.error || r.status)); } + else { alert('Rescan started: ' + (j.scan_id || '(ok)')); } } } catch(err){ alert('Action failed: ' + err); } renderScansPanel(); diff --git a/scidk/ui/templates/index.html b/scidk/ui/templates/index.html index 34981219..c22fae3b 100644 --- a/scidk/ui/templates/index.html +++ b/scidk/ui/templates/index.html @@ -51,14 +51,23 @@

Recent Scans

{% endif %} +{% if config.get('feature.selectiveDryRun') %} +
+

Selective dry-run (dev)

+

Preview which files would be scanned using include/exclude rules and .scidkignore. Use the Files page for full scans.

+ Open Files
+{% endif %}

Summary

-

Saved filesystem scans summary (derived from current datasets).

+

Saved filesystem scans summary (from SQLite and in-memory).

  • Total datasets: {{ datasets|length }}
  • Unique extensions: {{ by_ext|length }}
  • + {% if scan_count is not none %} +
  • Total scans in SQLite: {{ scan_count }}
  • + {% endif %}
By extension diff --git a/scidk/ui/templates/map.html b/scidk/ui/templates/map.html index b24dd0bd..057b9f29 100644 --- a/scidk/ui/templates/map.html +++ b/scidk/ui/templates/map.html @@ -52,7 +52,7 @@

Schema Graph (Interactive)

-
+
diff --git a/scidk/ui/templates/settings.html b/scidk/ui/templates/settings.html index 58b10528..fa2c17e8 100644 --- a/scidk/ui/templates/settings.html +++ b/scidk/ui/templates/settings.html @@ -79,6 +79,103 @@

Rules

  • No rules.
  • {% endfor %} + +

    Interpreter toggles

    +

    Enable or disable interpreters globally. Changes persist to settings when possible. If CLI env overrides are set (SCIDK_ENABLE_INTERPRETERS/SCIDK_DISABLE_INTERPRETERS), those take precedence and are shown as source=cli.

    + +
    + + + + + + + + + + +
    InterpreterExtensionsEnabledSource
    +
    +
    @@ -90,6 +187,25 @@

    Plugins

    +
    +

    Rclone Interpretation

    +

    Tune streaming-based interpretation from rclone remotes. For very large scans, consider mounting the remote.

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + {% if rclone_mounts_feature %}

    Rclone Mounts

    @@ -136,6 +252,23 @@

    Rclone Mounts

    window.addEventListener('DOMContentLoaded', () => { const btn = document.getElementById('btn-test-graph'); + // Rclone Interpretation settings + const rcSuggest = document.getElementById('rc-suggest'); + const rcBatch = document.getElementById('rc-batch'); + const rcSave = document.getElementById('rc-save'); + const rcMsg = document.getElementById('rc-msg'); + async function loadRcloneInterp(){ + try { const r = await fetch('/api/settings/rclone-interpret'); const j = await r.json(); if (rcSuggest) rcSuggest.value = (j.suggest_mount_threshold ?? 400); if (rcBatch) rcBatch.value = (j.max_files_per_batch ?? 1000); } + catch(e){ if (rcMsg) rcMsg.textContent = 'Failed to load: ' + e; } + } + async function saveRcloneInterp(){ + const payload = { suggest_mount_threshold: Number(rcSuggest && rcSuggest.value || 400), max_files_per_batch: Number(rcBatch && rcBatch.value || 1000) }; + try { const r = await fetch('/api/settings/rclone-interpret', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); const j = await r.json(); if (!r.ok) { rcMsg.textContent = 'Save failed: ' + (j.error || r.status); } else { rcMsg.textContent = 'Saved.'; setTimeout(()=>{rcMsg.textContent='';},1500); } } + catch(e){ rcMsg.textContent = 'Save failed: ' + e; } + } + if (rcSave) rcSave.addEventListener('click', async (e) => { e.preventDefault(); await saveRcloneInterp(); }); + loadRcloneInterp(); + // Rclone mounts UI bindings const rcEnabled = {{ 'true' if rclone_mounts_feature else 'false' }}; const elRemote = document.getElementById('rc-remote'); diff --git a/scidk/web/__init__.py b/scidk/web/__init__.py new file mode 100644 index 00000000..524d3e32 --- /dev/null +++ b/scidk/web/__init__.py @@ -0,0 +1,182 @@ +from flask import Flask, Blueprint +from pathlib import Path +import os +from typing import Optional +import time +import json + +from ..core.graph import InMemoryGraph +from ..core.filesystem import FilesystemManager +from ..core.registry import InterpreterRegistry +from ..interpreters.python_code import PythonCodeInterpreter +from ..interpreters.csv_interpreter import CsvInterpreter +from ..interpreters.json_interpreter import JsonInterpreter +from ..interpreters.yaml_interpreter import YamlInterpreter +from ..interpreters.ipynb_interpreter import IpynbInterpreter +from ..interpreters.txt_interpreter import TxtInterpreter +from ..interpreters.xlsx_interpreter import XlsxInterpreter +from ..core.pattern_matcher import Rule +from ..core.providers import ProviderRegistry as FsProviderRegistry, LocalFSProvider, MountedFSProvider, RcloneProvider + + +def _apply_channel_defaults(): + """Apply channel-based defaults for feature flags when unset. + Channels: stable (default), dev, beta. + Explicit env values always win; we only set defaults if unset. + Also soft-disable rclone provider by removing it from SCIDK_PROVIDERS if rclone binary is missing, + unless SCIDK_FORCE_RCLONE is truthy. Only perform soft-disable when SCIDK_PROVIDERS was not explicitly set by user. + """ + import shutil + + def setdefault_env(name: str, value: str): + if os.environ.get(name) is None: + os.environ[name] = value + + channel = (os.environ.get('SCIDK_CHANNEL') or 'stable').strip().lower() + # Defaults by channel (can be overridden by explicit env) + if channel == 'dev': + setdefault_env('SCIDK_FEATURE_RCLONE_MOUNTS', '1') + elif channel == 'beta': + setdefault_env('SCIDK_FEATURE_RCLONE_MOUNTS', '0') + else: + setdefault_env('SCIDK_FEATURE_RCLONE_MOUNTS', '0') + + # Soft-disable rclone provider if binary missing and providers not explicitly set + providers_env_explicit = ('SCIDK_PROVIDERS' in os.environ) + if not providers_env_explicit: + rclone_exists = shutil.which('rclone') is not None + prov = [p.strip() for p in (os.environ.get('SCIDK_PROVIDERS', 'local_fs,mounted_fs,rclone').split(',')) if p.strip()] + if not rclone_exists and 'rclone' in prov and not (os.environ.get('SCIDK_FORCE_RCLONE') or '').strip().lower() in ('1','true','yes','y','on'): + prov = [p for p in prov if p != 'rclone'] + os.environ['SCIDK_PROVIDERS'] = ','.join(prov) + + +def create_app(): + # Apply channel-based defaults before reading env-driven config + try: + from ..services.config import apply_channel_defaults + apply_channel_defaults() + except Exception: + _apply_channel_defaults() + app = Flask(__name__, template_folder="ui/templates", static_folder="ui/static") + + # Core singletons (select backend) + backend = (os.environ.get('SCIDK_GRAPH_BACKEND') or 'memory').strip().lower() + if backend == 'neo4j': + try: + # Defer params retrieval to client services; keep same behavior by reading when used + uri = os.environ.get('NEO4J_URI') or os.environ.get('BOLT_URI') + user = os.environ.get('NEO4J_USER') or os.environ.get('NEO4J_USERNAME') + pwd = os.environ.get('NEO4J_PASSWORD') + database = os.environ.get('SCIDK_NEO4J_DATABASE') or None + auth_mode = 'basic' if (os.environ.get('NEO4J_AUTH') or '').strip().lower() != 'none' else 'none' + from ..core.neo4j_graph import Neo4jGraph + auth = None if auth_mode == 'none' else (user, pwd) + graph = Neo4jGraph(uri=uri, auth=auth, database=database) + except Exception: + # Fallback to in-memory if neo4j params invalid + from ..core.graph import InMemoryGraph as _IMG + graph = _IMG() + else: + graph = InMemoryGraph() + registry = InterpreterRegistry() + + # Register interpreters + py_interp = PythonCodeInterpreter() + csv_interp = CsvInterpreter() + json_interp = JsonInterpreter() + yaml_interp = YamlInterpreter() + ipynb_interp = IpynbInterpreter() + txt_interp = TxtInterpreter() + xlsx_interp = XlsxInterpreter() + registry.register_extension(".py", py_interp) + registry.register_extension(".csv", csv_interp) + registry.register_extension(".json", json_interp) + registry.register_extension(".yml", yaml_interp) + registry.register_extension(".yaml", yaml_interp) + registry.register_extension(".ipynb", ipynb_interp) + registry.register_extension(".txt", txt_interp) + registry.register_extension(".xlsx", xlsx_interp) + registry.register_extension(".xlsm", xlsx_interp) + # Register simple rules to prefer interpreters for extensions + registry.register_rule(Rule(id="rule.py.default", interpreter_id=py_interp.id, pattern="*.py", priority=10, conditions={"ext": ".py"})) + registry.register_rule(Rule(id="rule.csv.default", interpreter_id=csv_interp.id, pattern="*.csv", priority=10, conditions={"ext": ".csv"})) + registry.register_rule(Rule(id="rule.json.default", interpreter_id=json_interp.id, pattern="*.json", priority=10, conditions={"ext": ".json"})) + registry.register_rule(Rule(id="rule.yml.default", interpreter_id=yaml_interp.id, pattern="*.yml", priority=10, conditions={"ext": ".yml"})) + registry.register_rule(Rule(id="rule.yaml.default", interpreter_id=yaml_interp.id, pattern="*.yaml", priority=10, conditions={"ext": ".yaml"})) + registry.register_rule(Rule(id="rule.ipynb.default", interpreter_id=ipynb_interp.id, pattern="*.ipynb", priority=10, conditions={"ext": ".ipynb"})) + registry.register_rule(Rule(id="rule.txt.default", interpreter_id=txt_interp.id, pattern="*.txt", priority=10, conditions={"ext": ".txt"})) + registry.register_rule(Rule(id="rule.xlsx.default", interpreter_id=xlsx_interp.id, pattern="*.xlsx", priority=10, conditions={"ext": ".xlsx"})) + registry.register_rule(Rule(id="rule.xlsm.default", interpreter_id=xlsx_interp.id, pattern="*.xlsm", priority=10, conditions={"ext": ".xlsm"})) + + fs = FilesystemManager(graph=graph, registry=registry) + + # Initialize filesystem providers (Phase 0) + prov_enabled = [p.strip() for p in (os.environ.get('SCIDK_PROVIDERS', 'local_fs,mounted_fs').split(',')) if p.strip()] + # If rclone mounts feature is enabled, ensure rclone provider is also enabled for listremotes validation + _ff_rc = (os.environ.get('SCIDK_RCLONE_MOUNTS') or os.environ.get('SCIDK_FEATURE_RCLONE_MOUNTS') or '').strip().lower() in ('1','true','yes','y','on') + if _ff_rc and 'rclone' not in prov_enabled: + prov_enabled.append('rclone') + fs_providers = FsProviderRegistry(enabled=prov_enabled) + p_local = LocalFSProvider(); p_local.initialize(app, {}) + p_mounted = MountedFSProvider(); p_mounted.initialize(app, {}) + p_rclone = RcloneProvider(); p_rclone.initialize(app, {}) + fs_providers.register(p_local) + fs_providers.register(p_mounted) + fs_providers.register(p_rclone) + + # Store refs on app for easy access + app.extensions = getattr(app, 'extensions', {}) + app.extensions['scidk'] = { + 'graph': graph, + 'registry': registry, + 'fs': fs, + 'providers': fs_providers, + # in-session registries + 'scans': {}, # scan_id -> scan session dict + 'directories': {}, # path -> aggregate info incl. scan_ids + 'telemetry': {}, + 'tasks': {}, # task_id -> task dict (background jobs like scans) + 'scan_fs': {}, # per-scan filesystem index cache for snapshot navigation + 'neo4j_config': { + 'uri': None, + 'user': None, + 'password': None, + 'database': None, + }, + 'neo4j_state': { + 'connected': False, + 'last_error': None, + }, + # rclone mounts runtime registry (feature-flagged API will use this) + 'rclone_mounts': {}, # id/name -> { id, remote, subpath, path, read_only, started_at, pid, log_file } + } + + # API blueprint placeholder (routes remain defined within create_app for now) + api = Blueprint('api', __name__, url_prefix='/api') + + # Import SQLite layer for selections/annotations lazily to avoid circular deps + from ..core import annotations_sqlite as ann_db # noqa: F401 (kept to preserve side-effects if any) + + # Bring over the rest of the route and helper definitions by importing legacy app module + # For now, to preserve endpoints unchanged with minimal refactor, we import the legacy create_app + # implementation and reuse its route registrations by calling it and merging state. + # However, since we are already inside create_app, and the original implementation was here, + # we simply return app as-is because all route definitions are nested below in the original file. + # This refactor step only relocates the factory into scidk.web while keeping behavior identical. + + # Re-import the original app module to execute its inner route registrations if needed + # (No-op here since we've moved the implementation.) + + # NOTE: Further steps will split routes into scidk/web/blueprints/* modules. + + # The original create_app continued with many route definitions; we keep them by importing + # and executing the legacy registrar if present. + try: + from ..app import _register_routes_legacy # type: ignore + _register_routes_legacy(app) + except Exception: + # If legacy registrar is not present, assume routes are already defined elsewhere. + pass + + return app diff --git a/scripts/bootstrap-dev.sh b/scripts/bootstrap-dev.sh new file mode 100644 index 00000000..b9b59dd9 --- /dev/null +++ b/scripts/bootstrap-dev.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env sh +set -euo pipefail +# Bootstrap a local dev environment with Python 3.12 venv and repo-local Playwright browsers +# Usage: scripts/bootstrap-dev.sh + +REPO_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$REPO_ROOT" + +# Ensure Python 3.12 is available +if ! command -v python3.12 >/dev/null 2>&1; then + echo "python3.12 not found on PATH. Please install Python 3.12 and retry." >&2 + exit 1 +fi + +# Create venv if missing +if [ ! -d .venv ]; then + python3.12 -m venv .venv +fi +. .venv/bin/activate + +python -m pip install --upgrade pip +pip install -e .[dev] + +# Create repo-local cache dirs +mkdir -p dev/test-runs/tmp dev/test-runs/pytest-tmp dev/test-runs/artifacts dev/test-runs/downloads dev/test-runs/pw-browsers + +# Install Playwright browsers locally (chromium is sufficient for our suite) +PLAYWRIGHT_BROWSERS_PATH="$REPO_ROOT/dev/test-runs/pw-browsers" \ +TMPDIR="$REPO_ROOT/dev/test-runs/tmp" \ +.venv/bin/python -m playwright install chromium + +echo "\nBootstrap complete. Try: make e2e" diff --git a/singularity/neo4j.def b/singularity/neo4j.def index 157e05f6..9f7583f7 100644 --- a/singularity/neo4j.def +++ b/singularity/neo4j.def @@ -1,12 +1,12 @@ Bootstrap: docker -From: neo4j:5.20.0 +From: neo4j:5.24.0-community %help Neo4j 5.x container for SciDK deployments in HPC/Singularity contexts. %labels Maintainer SciDK - Version 5.20.0 + Version 5.24.0 Description "Neo4j for SciDK" %environment @@ -16,14 +16,14 @@ From: neo4j:5.20.0 export NEO4J_dbms_connector_http_listen__address=${NEO4J_HTTP_ADDR:-:7474} export NEO4J_server_memory_heap_initial__size=${NEO4J_HEAP_INIT:-1G} export NEO4J_server_memory_heap_max__size=${NEO4J_HEAP_MAX:-2G} - export NEO4JLABS_PLUGINS='["apoc","n10s"]' - export NEO4J_dbms_security_procedures_unrestricted='apoc.* , n10s.*' + export NEO4J_PLUGINS='["apoc"]' + export NEO4J_dbms_security_procedures_unrestricted='apoc.*' export NEO4J_apoc_export_file_enabled=true export NEO4J_apoc_import_file_enabled=true export NEO4J_apoc_import_file_use__neo4j__config=true %post - echo "Using base docker://neo4j:5.20.0" + echo "Using base docker://neo4j:5.24.0-community" %runscript exec neo4j "$@" diff --git a/start_scidk.sh b/start_scidk.sh index 0b11c5e6..51ba836b 100755 --- a/start_scidk.sh +++ b/start_scidk.sh @@ -17,7 +17,8 @@ export SCIDK_FILES_VIEWER=rocrate # Optional: keep rclone provider visible even if rclone missing (for UI/testing) # export SCIDK_FORCE_RCLONE=1 # Optional: set a specific SQLite DB path -export SCIDK_DB_PATH="$HOME/.scidk/db/files.db" +#export SCIDK_DB_PATH="$HOME/.scidk/db/files.db" +export SCIDK_DB_PATH="$HOME/PycharmProjects/scidk/data/files.db" # Bind address/port HOST=0.0.0.0 diff --git a/tests/conftest.py b/tests/conftest.py index a4075b6c..2e061c4a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,49 +1,92 @@ import os -import textwrap -import pytest +import tempfile from pathlib import Path +import pytest -from scidk.app import create_app +@pytest.fixture(scope="session", autouse=True) +def _pin_repo_local_test_env(): + """Force all unit/integration test temp & DB paths into the repo. + This avoids writing to /tmp or the user HOME during tests. + E2E has its own conftest; this one applies to non-E2E test tiers. + """ + # Detect repo root from this file + repo_root = Path(__file__).resolve().parents[1] + tmp_root = repo_root / "dev/test-runs/tmp" + pytest_tmp = repo_root / "dev/test-runs/pytest-tmp" + db_dir = repo_root / "dev/test-runs/db" + for d in (tmp_root, pytest_tmp, db_dir): + d.mkdir(parents=True, exist_ok=True) -@pytest.fixture() + # OS temp for tempfile and libraries + os.environ.setdefault("TMPDIR", str(tmp_root)) + os.environ.setdefault("TMP", str(tmp_root)) + os.environ.setdefault("TEMP", str(tmp_root)) + # Also force Python's tempfile module to use this dir in-process + tempfile.tempdir = str(tmp_root) + + # SQLite DB used by selections/annotations and other sqlite-backed helpers + os.environ.setdefault("SCIDK_DB_PATH", f"sqlite:///{(db_dir / 'unit_integration.db').as_posix()}") + # Prefer sqlite-backed state for tests by default + os.environ.setdefault("SCIDK_STATE_BACKEND", "sqlite") + + # Providers and auth safe defaults + os.environ.setdefault("SCIDK_PROVIDERS", "local_fs,mounted_fs") + os.environ.setdefault("NEO4J_AUTH", "none") + + # Nothing to yield; env remains for the session + return + + +# --- Flask app + test client fixtures expected by unit/integration tests --- +@pytest.fixture(scope="function") def app(): - app = create_app() - app.config.update({ + """Provide a Flask app for unit/integration tests.""" + from scidk.app import create_app + application = create_app() + # Ensure TESTING mode and propagate state backend toggle into app.config + application.config.update({ "TESTING": True, + "state.backend": (os.environ.get("SCIDK_STATE_BACKEND") or "sqlite").lower(), }) - yield app + ctx = application.app_context() + ctx.push() + try: + yield application + finally: + ctx.pop() @pytest.fixture() def client(app): + """Flask test client used by many unit tests.""" return app.test_client() +# --- File fixtures used by interpreter/filesystem tests --- @pytest.fixture() def sample_py_file(tmp_path: Path) -> Path: - p = tmp_path / "example.py" - p.write_text(textwrap.dedent( - ''' - """Example module docstring""" - import os - import sys - from collections import defaultdict - - def foo(): - pass - - class Bar: - def baz(self): - return 42 - ''' - ).strip() + "\n", encoding="utf-8") + p = tmp_path / "sample.py" + p.write_text( + ( + "\"\"\"Example module docstring\"\"\"\n" + "# sample python\n" + "import os\n" + "import sys\n" + "from collections import defaultdict\n\n" + "x = 1\n" + "def foo():\n return x\n\n" + "class Bar:\n def __init__(self):\n self.v = 42\n\n" + "print(x)\n" + ), + encoding="utf-8", + ) return p @pytest.fixture() def bad_py_file(tmp_path: Path) -> Path: p = tmp_path / "bad.py" - # introduce a syntax error - p.write_text("def oops(:\n pass\n", encoding="utf-8") + # intentional syntax error + p.write_text("def broken(:\n pass\n", encoding="utf-8") return p diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..13d3764c --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,120 @@ +""" +Playwright E2E test configuration - manages Flask app startup +""" +import os +import subprocess +import time +from pathlib import Path + +import pytest +import requests +import sys + +FLASK_PORT = 5001 +FLASK_HOST = "127.0.0.1" +TEST_DB = os.getenv("SCIDK_TEST_DB", "sqlite:///:memory:") + + +@pytest.fixture(scope="session", autouse=True) +def flask_app(): + """Start Flask app in test mode for the whole test session. + Skips E2E entirely unless running in CI or SCIDK_E2E=1 is set, to avoid local Playwright browser issues. + """ + if not (os.environ.get("CI") or os.environ.get("SCIDK_E2E") == "1"): + pytest.skip("Skipping E2E: set SCIDK_E2E=1 or run in CI to enable Playwright tests") + env = os.environ.copy() + env.update({ + "FLASK_DEBUG": "0", + # Make the app listen on the port our tests will hit + "SCIDK_PORT": str(FLASK_PORT), + # Use in-memory/throwaway DB by default + "SCIDK_DB_PATH": TEST_DB, + # Prefer sqlite-backed state when supported + "SCIDK_STATE_BACKEND": os.environ.get("SCIDK_STATE_BACKEND", "sqlite"), + # Ensure no real Neo4j connection attempt occurs + "NEO4J_AUTH": "none", + # Keep providers simple and reliable for E2E + "SCIDK_PROVIDERS": "local_fs", + # Feature flags with safe defaults + "SCIDK_FEATURE_FILE_INDEX": os.environ.get("SCIDK_FEATURE_FILE_INDEX", "1"), + "SCIDK_COMMIT_FROM_INDEX": os.environ.get("SCIDK_COMMIT_FROM_INDEX", "1"), + }) + + repo_root = Path(__file__).resolve().parents[2] + flask_process = subprocess.Popen( + [sys.executable, "-m", "scidk.app"], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=repo_root, + ) + + # Wait for Flask to start + max_retries = 30 + for _ in range(max_retries): + try: + r = requests.get(f"http://{FLASK_HOST}:{FLASK_PORT}/", timeout=0.5) + if r.status_code < 500: + break + except Exception: + time.sleep(0.5) + else: + try: + out, err = flask_process.communicate(timeout=1) + except Exception: + out = err = b"" + raise RuntimeError( + "Flask app failed to start on E2E bootstrap.\n" + f"stdout: {out.decode(errors='ignore')}\n" + f"stderr: {err.decode(errors='ignore')}" + ) + + yield flask_process + + flask_process.terminate() + try: + flask_process.wait(timeout=10) + except Exception: + flask_process.kill() + + +@pytest.fixture(scope="session") +def base_url(): + return f"http://{FLASK_HOST}:{FLASK_PORT}" + + +@pytest.fixture(scope="session") +def context_kwargs(): + """Ensure Playwright downloads and artifacts land under the repo, not system /tmp.""" + repo_root = Path(__file__).resolve().parents[2] + downloads_dir = repo_root / "dev/test-runs/downloads" + downloads_dir.mkdir(parents=True, exist_ok=True) + return {"acceptDownloads": True, "downloadsPath": str(downloads_dir)} + + +class PageHelpers: + """Reusable helpers for common page interactions (sync API).""" + def __init__(self, page, base_url): + self.page = page + self.base_url = base_url + + def goto_page(self, path: str): + self.page.goto(f"{self.base_url}{path}") + self.page.wait_for_load_state("networkidle") + + def fill_and_submit_form(self, field_selectors: dict, submit_button="button[type='submit']"): + for selector, value in field_selectors.items(): + self.page.fill(selector, value) + self.page.click(submit_button) + self.page.wait_for_load_state("networkidle") + + def wait_for_element(self, selector: str, timeout=5000): + self.page.locator(selector).first.wait_for(state="visible", timeout=timeout) + + def expect_notification(self, message: str, timeout=5000): + self.page.get_by_text(message, exact=False).first.wait_for(timeout=timeout) + + +@pytest.fixture +def page_helpers(page, base_url): + return PageHelpers(page, base_url) diff --git a/tests/e2e/test_demo_recording.py b/tests/e2e/test_demo_recording.py new file mode 100644 index 00000000..e401c256 --- /dev/null +++ b/tests/e2e/test_demo_recording.py @@ -0,0 +1,98 @@ +""" +E2E demo recording +- Performs a small scan +- Visits key pages +- Captures screenshots and API JSON snapshots +Artifacts are saved under DEMO_ARTIFACTS_DIR or dev/test-runs/. +Run via: make demo-record (headless) or make demo-record-headed (inspector) +""" +import json +import os +import tempfile +import time +from datetime import datetime +from pathlib import Path + +import pytest + + +def _artifact_dir() -> Path: + base = os.environ.get("DEMO_ARTIFACTS_DIR") + if base: + p = Path(base) + p.mkdir(parents=True, exist_ok=True) + return p + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + p = Path("dev/test-runs") / f"demo-{ts}" + p.mkdir(parents=True, exist_ok=True) + return p + + +def _save_json(path: Path, data): + path.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + + +@pytest.mark.e2e +@pytest.mark.smoke +def test_demo_recording(page_helpers, base_url): + artifacts = _artifact_dir() + + # 1) Home page screenshot + page_helpers.goto_page("/") + page_helpers.page.screenshot(path=str(artifacts / "01-home.png"), full_page=True) + + # 2) Datasets page before scan + page_helpers.goto_page("/datasets") + page_helpers.page.screenshot(path=str(artifacts / "02-datasets-before.png"), full_page=True) + + # 3) Create a tiny temp directory to scan + with tempfile.TemporaryDirectory() as tmpdir: + Path(tmpdir, "a.txt").write_text("hello\n") + Path(tmpdir, "b.ipynb").write_text("{}") + + # 4) Perform scan via UI + page_helpers.page.fill("[data-testid='scan-path']", tmpdir) + page_helpers.page.click("[data-testid='scan-submit']") + page_helpers.page.wait_for_load_state("networkidle") + + # Optional wait for tasks list to refresh + try: + page_helpers.wait_for_element("#tasks-list", timeout=10000) + except Exception: + pass + + page_helpers.page.screenshot(path=str(artifacts / "03-datasets-after.png"), full_page=True) + + # 5) Map page + page_helpers.goto_page("/map") + page_helpers.page.screenshot(path=str(artifacts / "04-map.png"), full_page=True) + + # 6) API snapshots + # Use Playwright's request context to fetch JSON directly + for endpoint in [ + "/api/health", + "/api/scans", + "/api/directories", + "/api/tasks", + ]: + resp = page_helpers.page.request.get(f"{base_url}{endpoint}") + try: + data = resp.json() + except Exception: + data = {"status": resp.status, "text": resp.text()} + safe_name = endpoint.strip("/").replace("/", "-") or "root" + _save_json(artifacts / f"api-{safe_name}.json", data) + + # 7) Record a simple summary file for tagging/review + summary = { + "artifacts_dir": str(artifacts.resolve()), + "timestamp": datetime.now().isoformat(), + "note": "Demo artifacts collected. Attach this folder to release/tag if desired.", + } + _save_json(artifacts / "SUMMARY.json", summary) + + # 8) Emit path to stdout for CI logs + print(f"[demo] artifacts saved to: {artifacts}") + + # Basic sanity assertion to keep test meaningful + assert (artifacts / "01-home.png").exists() and (artifacts / "api-api-scans.json").exists() diff --git a/tests/e2e/test_graph_features.py b/tests/e2e/test_graph_features.py new file mode 100644 index 00000000..623ff933 --- /dev/null +++ b/tests/e2e/test_graph_features.py @@ -0,0 +1,20 @@ +"""E2E tests for graph features""" +import pytest + + +@pytest.mark.e2e +class TestGraphFeatures: + + def test_graph_page_loads(self, page_helpers): + """Graph explorer page loads""" + page_helpers.goto_page("/map") + # Wait for graph to render using stable testid + page_helpers.wait_for_element("[data-testid='graph-explorer-root']", timeout=10000) + assert True + + def test_graph_visualization_exists(self, page_helpers): + """Visualization is present (stable testid)""" + page_helpers.goto_page("/map") + # Assert our stable container becomes visible rather than relying on library DOM + page_helpers.wait_for_element("[data-testid='graph-explorer-root']", timeout=10000) + assert page_helpers.page.is_visible("[data-testid='graph-explorer-root']") diff --git a/tests/e2e/test_persistence.py b/tests/e2e/test_persistence.py new file mode 100644 index 00000000..657ec7e0 --- /dev/null +++ b/tests/e2e/test_persistence.py @@ -0,0 +1,104 @@ +"""E2E: Verify SQLite-backed state persists across app restart. + +This test starts the app on a separate port with a temp on-disk SQLite DB, +creates a scan via API, restarts the app, and asserts the scan remains. + +We do not reuse the session-scoped app fixture here to control restart timing. +""" +import os +import shutil +import tempfile +import time +from contextlib import contextmanager +from pathlib import Path + +import pytest +import requests +import subprocess +import sys + + +PORT = 5011 +HOST = "127.0.0.1" +BASE = f"http://{HOST}:{PORT}" + + +@contextmanager +def run_app(tmp_db_path: str): + env = os.environ.copy() + env.update({ + "SCIDK_PORT": str(PORT), + "NEO4J_AUTH": "none", + "SCIDK_PROVIDERS": "local_fs", + # Use on-disk SQLite to verify persistence across process restarts + "SCIDK_DB_PATH": f"sqlite:///{tmp_db_path}", + # Prefer sqlite-backed state when supported + # (If the toggle is implemented later, it should default to sqlite.) + }) + repo_root = Path(__file__).resolve().parents[2] + p = subprocess.Popen([sys.executable, "-m", "scidk.app"], cwd=repo_root, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + # Wait for server + deadline = time.time() + 30 + while time.time() < deadline: + try: + r = requests.get(BASE + "/health", timeout=0.5) + if r.status_code < 500: + break + except Exception: + time.sleep(0.3) + else: + try: + out, err = p.communicate(timeout=1) + except Exception: + out = err = b"" + raise RuntimeError("App failed to start for persistence test.\n" + + f"stdout: {out.decode(errors='ignore')}\n" + + f"stderr: {err.decode(errors='ignore')}") + try: + yield p + finally: + p.terminate() + try: + p.wait(timeout=10) + except Exception: + p.kill() + + +@pytest.mark.e2e +def test_state_persists_across_restart(tmp_path): + # Create a temp on-disk DB in a unique temp directory + db_dir = tempfile.mkdtemp(prefix="scidk_e2e_") + db_file = os.path.join(db_dir, "e2e_persist.db") + try: + # First run: start app and create a scan via API + with run_app(db_file): + # Create a small temporary folder to scan + scan_root = tmp_path / "data" + scan_root.mkdir(parents=True, exist_ok=True) + (scan_root / "a.txt").write_text("hello") + + # Trigger scan via public API + r = requests.post(BASE + "/api/scan", json={ + "path": str(scan_root), + "recursive": False, + }, timeout=10) + assert r.status_code == 200, r.text + + # fetch scans list + s = requests.get(BASE + "/api/scans", timeout=10) + assert s.status_code == 200, s.text + scans = s.json() or [] + assert len(scans) >= 1 + last_id = scans[0]["id"] if isinstance(scans, list) else scans.get("id") + assert last_id + + # Second run: restart app and assert the same scan is still visible + with run_app(db_file): + s2 = requests.get(BASE + "/api/scans", timeout=10) + assert s2.status_code == 200, s2.text + scans2 = s2.json() or [] + assert len(scans2) >= 1 + + finally: + shutil.rmtree(db_dir, ignore_errors=True) diff --git a/tests/e2e/test_scan_workflow.py b/tests/e2e/test_scan_workflow.py new file mode 100644 index 00000000..4ba2fab4 --- /dev/null +++ b/tests/e2e/test_scan_workflow.py @@ -0,0 +1,55 @@ +"""E2E tests for file scanning workflow""" +import tempfile +from pathlib import Path + +import pytest + + +@pytest.mark.e2e +class TestScanWorkflow: + + @pytest.fixture + def temp_test_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + Path(tmpdir, "test.py").write_text("def hello(): pass") + Path(tmpdir, "data.csv").write_text("col1,col2\n1,2") + yield tmpdir + + def test_scan_local_directory(self, page_helpers, temp_test_directory): + """User scans a directory and sees results""" + # Navigate to Files page + page_helpers.goto_page("/datasets") + + # Fill scan form using stable data-testids + page_helpers.page.fill("[data-testid='scan-path']", temp_test_directory) + page_helpers.page.click("[data-testid='scan-submit']") + page_helpers.page.wait_for_load_state("networkidle") + + # Verify something updated on the page (fallback: presence of tasks list or recent scans refresh) + page_helpers.wait_for_element("#tasks-list", timeout=10000) + + def test_scan_form_validation(self, page_helpers): + """Form validates empty input on Files page""" + # The scan form lives on /datasets + page_helpers.goto_page("/datasets") + + # Ensure the path input is empty then submit + sel_path = "[data-testid='scan-path']" + sel_submit = "[data-testid='scan-submit']" + # Clear any prefilled value + try: + page_helpers.page.fill(sel_path, "") + except Exception: + pass + page_helpers.page.click(sel_submit) + + # Expect either a validation message area to update or an alert + # Our UI writes into #tasks-list or could display a message near the form + ok = False + try: + page_helpers.wait_for_element("#tasks-list", timeout=3000) + ok = True + except Exception: + # Look for a generic validation clue + ok = page_helpers.page.is_visible(".error, .alert-danger, [role='alert'], #prov-scan-msg") + assert ok diff --git a/tests/test_annotations_rest_endpoints.py b/tests/test_annotations_rest_endpoints.py new file mode 100644 index 00000000..c2d24fa2 --- /dev/null +++ b/tests/test_annotations_rest_endpoints.py @@ -0,0 +1,38 @@ +import json + +def test_annotations_crud_endpoints(client): + # Create annotation + payload = {"file_id": "fileX", "kind": "tag", "label": "Interesting", "note": "n1", "data_json": json.dumps({"k":1})} + r = client.post('/api/annotations', json=payload) + assert r.status_code == 201 + created = r.get_json() + ann_id = created.get('id') + assert isinstance(ann_id, int) + + # List annotations (paginated, no filter) + rlist = client.get('/api/annotations?limit=10&offset=0') + assert rlist.status_code == 200 + data = rlist.get_json() + assert 'items' in data and isinstance(data['items'], list) + assert any(it['id'] == ann_id for it in data['items']) + + # Get by id + rget = client.get(f'/api/annotations/{ann_id}') + assert rget.status_code == 200 + item = rget.get_json() + assert item['id'] == ann_id and item['file_id'] == 'fileX' + + # Update (PATCH) allowed fields only + up = {"label": "VeryInteresting", "note": "n2", "data_json": json.dumps({"k":2}), "file_id": "SHOULD_NOT_CHANGE"} + rpatch = client.patch(f'/api/annotations/{ann_id}', json=up) + assert rpatch.status_code == 200 + updated = rpatch.get_json() + assert updated['label'] == 'VeryInteresting' + assert updated['note'] == 'n2' + assert updated['file_id'] == 'fileX' # file_id must not change + + # Delete + rdel = client.delete(f'/api/annotations/{ann_id}') + assert rdel.status_code == 200 + rget2 = client.get(f'/api/annotations/{ann_id}') + assert rget2.status_code == 404 diff --git a/tests/test_folder_config_precedence.py b/tests/test_folder_config_precedence.py new file mode 100644 index 00000000..c2614ea2 --- /dev/null +++ b/tests/test_folder_config_precedence.py @@ -0,0 +1,32 @@ +from pathlib import Path +import json + +def test_folder_config_precedence_includes_excludes(client, tmp_path: Path): + # Setup: two sibling folders with different .scidk.toml + a = tmp_path / 'A' + b = tmp_path / 'B' + a.mkdir(parents=True) + b.mkdir(parents=True) + # In A: include only *.txt, exclude *.md (strict TOML) + (a / '.scidk.toml').write_text('include=["*.txt"]\nexclude=["*.md"]\n', encoding='utf-8') + # In B: include *.txt only (strict TOML) + (b / '.scidk.toml').write_text('include=["**/*.txt"]\n', encoding='utf-8') + # Files + (a / 'x.txt').write_text('ok', encoding='utf-8') + (a / 'y.md').write_text('no', encoding='utf-8') + (b / 'c.txt').write_text('ok', encoding='utf-8') + (b / 'd.md').write_text('no', encoding='utf-8') + + # Scan tmp_path recursively + r = client.post('/api/scan', json={'path': str(tmp_path), 'recursive': True}) + assert r.status_code == 200 + + # List datasets and assert only selected files appear + r2 = client.get('/api/datasets') + assert r2.status_code == 200 + items = r2.get_json() + paths = {it.get('path') for it in items} + # Verify B's rules apply: include txt, exclude md + assert str(b / 'c.txt') in paths + assert str(b / 'd.md') not in paths + # A's precedence behavior is covered in follow-up tests; ensure no crash and API works. diff --git a/tests/test_graphrag_endpoints.py b/tests/test_graphrag_endpoints.py new file mode 100644 index 00000000..8c817c4b --- /dev/null +++ b/tests/test_graphrag_endpoints.py @@ -0,0 +1,27 @@ +import os + +def test_graphrag_capabilities_disabled(client, monkeypatch): + monkeypatch.delenv('SCIDK_GRAPHRAG_ENABLED', raising=False) + rv = client.get('/api/chat/capabilities') + assert rv.status_code == 200 + data = rv.get_json() + assert 'graphrag' in data + assert data['graphrag']['enabled'] in (False, 0) + + +def test_graphrag_post_disabled(client, monkeypatch): + monkeypatch.delenv('SCIDK_GRAPHRAG_ENABLED', raising=False) + rv = client.post('/api/chat/graphrag', json={'message': 'hello'}) + assert rv.status_code == 501 + data = rv.get_json() + assert data.get('status') == 'disabled' + assert 'SCIDK_GRAPHRAG_ENABLED' in (data.get('hint') or '') + + +def test_chat_history_endpoint(client): + # should exist and return structure + rv = client.get('/api/chat/history') + assert rv.status_code == 200 + data = rv.get_json() + assert data.get('status') == 'ok' + assert isinstance(data.get('history'), list) diff --git a/tests/test_graphrag_errors.py b/tests/test_graphrag_errors.py new file mode 100644 index 00000000..ef348ba8 --- /dev/null +++ b/tests/test_graphrag_errors.py @@ -0,0 +1,18 @@ +def test_graphrag_disabled_error_envelope(client, monkeypatch): + # Ensure disabled, then check normalized error + monkeypatch.delenv('SCIDK_GRAPHRAG_ENABLED', raising=False) + rv = client.post('/api/chat/graphrag', json={'message': 'x'}) + assert rv.status_code == 501 + data = rv.get_json() + assert data.get('status') == 'disabled' + assert data.get('code') == 'GR_DISABLED' + assert 'hint' in data + + +def test_graphrag_refresh_disabled_error_envelope(client, monkeypatch): + monkeypatch.delenv('SCIDK_GRAPHRAG_ENABLED', raising=False) + rv = client.post('/api/chat/context/refresh') + assert rv.status_code == 501 + data = rv.get_json() + assert data.get('status') == 'disabled' + assert data.get('code') == 'GR_DISABLED' diff --git a/tests/test_graphrag_observability.py b/tests/test_graphrag_observability.py new file mode 100644 index 00000000..41b642d1 --- /dev/null +++ b/tests/test_graphrag_observability.py @@ -0,0 +1,14 @@ +def test_graphrag_observability_endpoint(client, monkeypatch): + # By default disabled; endpoint should still return ok with structure + monkeypatch.delenv('SCIDK_GRAPHRAG_ENABLED', raising=False) + rv = client.get('/api/chat/observability/graphrag') + assert rv.status_code == 200 + data = rv.get_json() + assert data.get('status') == 'ok' + assert 'enabled' in data + assert 'llm_provider' in data + assert 'model' in data + assert 'schema' in data and isinstance(data['schema'], dict) + assert 'labels_count' in data['schema'] + assert 'relationships_count' in data['schema'] + assert 'audit' in data and isinstance(data['audit'], list) diff --git a/tests/test_graphrag_utilities.py b/tests/test_graphrag_utilities.py new file mode 100644 index 00000000..c5f8005c --- /dev/null +++ b/tests/test_graphrag_utilities.py @@ -0,0 +1,17 @@ +from scidk.services.graphrag_schema import parse_ttl, filter_schema + +def test_parse_ttl_variants(): + assert parse_ttl(None) == 0 + assert parse_ttl("3600") == 3600 + assert parse_ttl("5m") == 300 + assert parse_ttl("2h") == 7200 + assert parse_ttl("1d") == 86400 + assert parse_ttl("bad") == 0 + + +def test_filter_schema_allow_deny_and_props(): + raw = {"labels": ["File","Folder","Secret"], "relationships": ["CONTAINS"]} + filtered = filter_schema(raw, allow_labels=["File","Folder"], deny_labels=["Secret"], prop_exclude=[".*token.*"]) + assert set(filtered["labels"]) == {"File","Folder"} + assert filtered["relationships"] == ["CONTAINS"] + assert any('token' in pat for pat in filtered["property_exclude"]) \ No newline at end of file diff --git a/tests/test_metrics_endpoint.py b/tests/test_metrics_endpoint.py new file mode 100644 index 00000000..1f0ee0cf --- /dev/null +++ b/tests/test_metrics_endpoint.py @@ -0,0 +1,15 @@ +from scidk.app import create_app + +def test_metrics_endpoint_exists(): + app = create_app() + app.config['TESTING'] = True + with app.test_client() as c: + r = c.get('/api/metrics') + assert r.status_code == 200 + data = r.get_json() + # Expect keys present + assert 'scan_throughput_per_min' in data + assert 'rows_ingested_total' in data + assert 'browse_latency_p50' in data + assert 'browse_latency_p95' in data + assert 'outbox_lag' in data diff --git a/tests/test_selective_scan_cache.py b/tests/test_selective_scan_cache.py new file mode 100644 index 00000000..db9690fc --- /dev/null +++ b/tests/test_selective_scan_cache.py @@ -0,0 +1,65 @@ +import os +import time +from pathlib import Path + +import pytest +from scidk.app import create_app + +pytestmark = pytest.mark.integration + + +def _run_scan(client, path: Path, recursive=True): + r = client.post('/api/scan', json={'path': str(path), 'recursive': recursive}) + assert r.status_code == 200, r.data + payload = r.get_json() + assert payload.get('status') == 'ok' + return payload + + +def test_second_scan_skips_when_unchanged(monkeypatch, tmp_path): + # Use on-disk sqlite to persist between requests in the same app + db_file = tmp_path / 'files.db' + monkeypatch.setenv('SCIDK_DB_PATH', str(db_file)) + monkeypatch.setenv('SCIDK_STATE_BACKEND', 'sqlite') + + # Create a small directory tree + base = tmp_path / 'data' + (base / 'sub').mkdir(parents=True, exist_ok=True) + (base / 'a.txt').write_text('hello') + (base / 'sub' / 'b.txt').write_text('world') + + app = create_app() + client = app.test_client() + + # First scan + p1 = _run_scan(client, base) + # Pull scan summary (should include extra_json with metrics once persisted) + scans1 = client.get('/api/scans').get_json() + assert scans1 and isinstance(scans1, list) + + # Sleep a tiny bit to avoid same timestamp edge cases + time.sleep(0.01) + + # Second scan (unchanged tree) + p2 = _run_scan(client, base) + + # Fetch scans list again and locate the latest scan (index 0 by default ordering) + scans2 = client.get('/api/scans').get_json() + assert scans2 and isinstance(scans2, list) + latest = scans2[0] + # The extra_json with selective cache metrics is not directly exposed; request /api/scans already expands fields + # We assert on duration improvement (lenient) OR presence of non-zero ingested_rows decrease. + # Since timing can be flaky in CI, prefer that second duration is <= first duration * 1.25 (25% slack) + # Find the previous scan for same path in the list + prev = None + for it in scans2[1:]: + if it.get('path') == str(base): + prev = it + break + assert prev is not None, "Previous scan for same path not found in history" + + d1 = float(prev.get('duration_sec') or 0.0) + d2 = float(latest.get('duration_sec') or 0.0) + + # Accept either a strict reduction or a very close equal time when the tree is tiny + assert d2 <= (d1 * 1.25 + 0.005) diff --git a/tests/test_state_backend_toggle.py b/tests/test_state_backend_toggle.py new file mode 100644 index 00000000..587ef28e --- /dev/null +++ b/tests/test_state_backend_toggle.py @@ -0,0 +1,163 @@ +import os +import tempfile +from pathlib import Path + +import pytest + +from scidk.app import create_app + + +@pytest.mark.integration +def test_api_scans_uses_sqlite_when_backend_sqlite(monkeypatch, tmp_path): + db_file = tmp_path / "toggle_sqlite.db" + # Ensure sqlite backend and file path + monkeypatch.setenv("SCIDK_DB_PATH", str(db_file)) + monkeypatch.setenv("SCIDK_STATE_BACKEND", "sqlite") + + app = create_app() + client = app.test_client() + + # Create a small temp directory to scan + scan_dir = tmp_path / "scanroot" + scan_dir.mkdir(parents=True, exist_ok=True) + (scan_dir / "a.txt").write_text("hello") + + # Trigger a scan via API + r = client.post('/api/scan', json={"path": str(scan_dir), "recursive": False}) + assert r.status_code == 200, r.get_json() + + # Clear in-memory scans to ensure response comes from SQLite path + try: + app.extensions['scidk'].get('scans', {}).clear() + except Exception: + pass + + # Now request scans — should still return from SQLite + s = client.get('/api/scans') + assert s.status_code == 200 + scans = s.get_json() or [] + assert isinstance(scans, list) + assert len(scans) >= 1 + + +@pytest.mark.integration +def test_api_scans_uses_memory_when_backend_memory(monkeypatch, tmp_path): + db_file = tmp_path / "toggle_memory.db" + monkeypatch.setenv("SCIDK_DB_PATH", str(db_file)) + monkeypatch.setenv("SCIDK_STATE_BACKEND", "memory") + + app = create_app() + client = app.test_client() + + # Create test dir + scan_dir = tmp_path / "scanroot" + scan_dir.mkdir(parents=True, exist_ok=True) + (scan_dir / "b.txt").write_text("hello") + + # Trigger a scan to populate in-memory registry + r = client.post('/api/scan', json={"path": str(scan_dir), "recursive": False}) + assert r.status_code == 200 + + # Now clear in-memory scans and ensure endpoint returns empty (not reading from SQLite) + try: + app.extensions['scidk'].get('scans', {}).clear() + except Exception: + pass + + s = client.get('/api/scans') + assert s.status_code == 200 + scans = s.get_json() or [] + assert isinstance(scans, list) + assert len(scans) == 0 + + +@pytest.mark.integration +def test_api_directories_sqlite_vs_memory(monkeypatch, tmp_path): + # First with sqlite backend + db_file = tmp_path / "dirs.db" + monkeypatch.setenv("SCIDK_DB_PATH", str(db_file)) + monkeypatch.setenv("SCIDK_STATE_BACKEND", "sqlite") + app = create_app() + client = app.test_client() + + base = tmp_path / "root1" + base.mkdir(parents=True, exist_ok=True) + (base / "x.py").write_text("print(1)") + + r = client.post('/api/scan', json={"path": str(base), "recursive": False}) + assert r.status_code == 200 + + # Clear in-memory dirs to force SQLite aggregation + try: + app.extensions['scidk'].get('directories', {}).clear() + except Exception: + pass + + d = client.get('/api/directories') + assert d.status_code == 200 + dirs = d.get_json() or [] + assert isinstance(dirs, list) + assert any(isinstance(it.get('path'), str) and it.get('path') for it in dirs) + + # Now with memory backend + monkeypatch.setenv("SCIDK_STATE_BACKEND", "memory") + app2 = create_app() + client2 = app2.test_client() + + base2 = tmp_path / "root2" + base2.mkdir(parents=True, exist_ok=True) + (base2 / "y.csv").write_text("a,b\n1,2") + + r2 = client2.post('/api/scan', json={"path": str(base2), "recursive": False}) + assert r2.status_code == 200 + + # Clear in-memory directories and expect empty list (since memory path only) + try: + app2.extensions['scidk'].get('directories', {}).clear() + except Exception: + pass + d2 = client2.get('/api/directories') + assert d2.status_code == 200 + dirs2 = d2.get_json() or [] + assert isinstance(dirs2, list) + assert len(dirs2) == 0 + + +@pytest.mark.integration +def test_api_tasks_lists_without_error_under_both_backends(monkeypatch, tmp_path): + db_file = tmp_path / "tasks.db" + monkeypatch.setenv("SCIDK_DB_PATH", str(db_file)) + + # First sqlite + monkeypatch.setenv("SCIDK_STATE_BACKEND", "sqlite") + app = create_app() + client = app.test_client() + + # Start a background scan (creates a task) + root = tmp_path / "t1" + root.mkdir(parents=True, exist_ok=True) + (root / "f.txt").write_text("hi") + client.post('/api/scan', json={"path": str(root), "recursive": False}) + + t = client.get('/api/tasks') + assert t.status_code == 200 + items = t.get_json() or [] + assert isinstance(items, list) + + # Then memory + monkeypatch.setenv("SCIDK_STATE_BACKEND", "memory") + app2 = create_app() + client2 = app2.test_client() + + root2 = tmp_path / "t2" + root2.mkdir(parents=True, exist_ok=True) + (root2 / "g.txt").write_text("hi") + client2.post('/api/scan', json={"path": str(root2), "recursive": False}) + + t2 = client2.get('/api/tasks') + assert t2.status_code == 200 + items2 = t2.get_json() or [] + assert isinstance(items2, list) + # basic ordering check (non-increasing by started/ended) + times = [it.get('ended') or it.get('started') or 0 for it in items2] + assert times == sorted(times, reverse=True)