Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ jobs:
mutate: "src/streaming/**/*.ts,!src/**/tests/**,!src/**/*.test.ts,!src/**/*.test.tsx,!src/**/*.d.ts"
- name: hooks
mutate: "src/hooks/**/*.ts,src/hooks/**/*.tsx,!src/**/tests/**,!src/**/*.test.ts,!src/**/*.test.tsx,!src/**/*.d.ts"
- name: biometrics
mutate: "src/biometrics/**/*.ts,!src/**/tests/**,!src/**/*.test.ts,!src/**/*.test.tsx,!src/**/*.d.ts"
- name: db
mutate: "src/db/**/*.ts,!src/**/tests/**,!src/**/*.test.ts,!src/**/*.test.tsx,!src/**/*.d.ts"
name: mutate (${{ matrix.shard.name }})
Expand Down
23 changes: 16 additions & 7 deletions .github/workflows/python-modules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ on:
branches: [dev]
paths:
- 'modules/**/*.py'
- 'modules/common/biometrics/*.json'
- 'modules/piezo-processor/fixtures/*.json'
- 'modules/**/pyproject.toml'
- 'modules/**/uv.lock'
- 'modules/.coveragerc'
Expand All @@ -14,6 +16,8 @@ on:
types: [opened, synchronize, reopened]
paths:
- 'modules/**/*.py'
- 'modules/common/biometrics/*.json'
- 'modules/piezo-processor/fixtures/*.json'
- 'modules/**/pyproject.toml'
- 'modules/**/uv.lock'
- 'modules/.coveragerc'
Expand Down Expand Up @@ -50,9 +54,9 @@ jobs:
workdir: modules
target: common
cov_source: common
# cbor2 is needed to decode the real NATS capture fixtures through
# NatsFollower's decode path (test_nats_follower).
deps: "cbor2"
# Build-identity tests inspect installed NumPy/SciPy metadata;
# use the piezo module's locked environment, including cbor2.
deps: ""
- name: cover-buttons
workdir: modules/cover-buttons
target: .
Expand All @@ -72,6 +76,7 @@ jobs:
workdir: modules/piezo-processor
target: .
cov_source: .
cov_extra: "--cov=../common/biometrics"
deps: "numpy scipy"
- name: sleep-detector
workdir: modules/sleep-detector
Expand All @@ -95,7 +100,7 @@ jobs:
with:
# Pod runtime is pinned to 3.10 by every module's pyproject
# (`requires-python = ">=3.9,<3.11"`). Match it in CI.
python-version: '3.14'
python-version: '3.10'

- name: Install uv
uses: astral-sh/setup-uv@v4
Expand All @@ -105,7 +110,10 @@ jobs:
- name: Install test deps
working-directory: ${{ matrix.module.workdir }}
run: |
if [ -f uv.lock ]; then
if [ "${{ matrix.module.name }}" = "common" ]; then
# Keep common's cwd/import layout while using the numerical lock.
UV_PROJECT_ENVIRONMENT="$PWD/.venv" uv sync --project piezo-processor --locked --no-install-project
elif [ -f uv.lock ]; then
uv sync --locked --no-install-project
else
uv venv .venv
Expand All @@ -125,12 +133,13 @@ jobs:
pytest ${{ matrix.module.target }} \
--cov-config=${{ github.workspace }}/modules/.coveragerc \
--cov=${{ matrix.module.cov_source }} \
${{ matrix.module.cov_extra }} \
--cov-report=xml:coverage.xml \
--junit-xml=junit.xml \
-o junit_family=legacy

- name: Upload coverage to Codecov
if: ${{ !cancelled() }}
if: ${{ github.repository == 'sleepypod/core' && !cancelled() }}
uses: codecov/codecov-action@v5
with:
use_oidc: true
Expand All @@ -143,7 +152,7 @@ jobs:
working-directory: ${{ matrix.module.workdir }}

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
if: ${{ github.repository == 'sleepypod/core' && !cancelled() }}
uses: codecov/codecov-action@v5
with:
report_type: test_results
Expand Down
24 changes: 21 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ jobs:
- name: Install Dependencies
run: pnpm install --frozen-lockfile

- name: Setup Python for runtime integration
if: matrix.task.name == 'Unit Tests' || matrix.task.name == 'Browser Tests'
uses: actions/setup-python@v6
with:
python-version: '3.10'

- name: Setup uv for runtime integration
if: matrix.task.name == 'Unit Tests' || matrix.task.name == 'Browser Tests'
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Install locked runtime integration dependencies
if: matrix.task.name == 'Unit Tests' || matrix.task.name == 'Browser Tests'
working-directory: modules/piezo-processor
run: uv sync --locked --no-install-project

- name: Cache Assets (Vitest)
id: asset-cache
uses: actions/cache@v5
Expand All @@ -68,7 +85,7 @@ jobs:
BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }}

- name: Upload coverage reports to Codecov
if: matrix.task.name == 'Unit Tests' && !cancelled()
if: github.repository == 'sleepypod/core' && matrix.task.name == 'Unit Tests' && !cancelled()
uses: codecov/codecov-action@v5
with:
use_oidc: true
Expand All @@ -79,7 +96,7 @@ jobs:
flags: javascript

- name: Upload test results to Codecov
if: matrix.task.name == 'Unit Tests' && !cancelled()
if: github.repository == 'sleepypod/core' && matrix.task.name == 'Unit Tests' && !cancelled()
uses: codecov/codecov-action@v5
with:
report_type: test_results
Expand All @@ -89,4 +106,5 @@ jobs:
fail_ci_if_error: false
slug: sleepypod/core
files: test-results/junit.xml
flags: javascript
flags: javascript

16 changes: 16 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,19 @@ sudo bash /tmp/sleepypod-install --purge-free-sleep
It also flushes the running iptables and ip6tables rules; the install then reconfigures iptables itself. Without the flag, run the cleanup block the installer prints, reboot, and re-run the installer. free-sleep's `unblock_internet_access.sh` is not a substitute: it flushes runtime rules only and leaves the persisted files in place.

Run `sp-uninstall` if you want to revert (the script flushes iptables and removes sleepypod's units); the purge is not undone, so reinstall free-sleep from its own installer.


### Runtime filesystem reads and standalone tracing

Pod data (RAW captures, saved alarm state, HomeKit identity seeds, archive-push
configuration, procfs and storage-accounting paths) is read on the destination
pod. These dynamic filesystem reads use `turbopackIgnore` call annotations so
Next does not infer that the build machine's whole checkout belongs in the
standalone output. The annotations affect packaging only; existing validation,
file access and error handling still run on the pod.

The biometrics worker location probes are also excluded from automatic tracing:
`scripts/build/biometrics-worker.mjs` explicitly generates `worker.cjs`,
`coordinator.cjs` and `shadow-host.cjs` in the standalone bundle after Next builds.
Keep that packaging step and its dependency-graph checks when changing tracing.
Do not apply these annotations to shipped assets that need inclusion in a release.
7 changes: 7 additions & 0 deletions drizzle.package-experiments.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
schema: './src/db/package-experiment-schema.ts',
out: './src/db/package-experiment-migrations',
dialect: 'sqlite',
})
7 changes: 7 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export default defineConfig([
tseslint.configs.strict,
pluginReact.configs.flat.recommended,
...nextVitals,
// Next selects its Babel parser for JS and for .mts/.cts config files.
// The strict TypeScript rules above need TypeScript scope references;
// otherwise used runtime variables are reported as type-only usages.
{
files: ['**/*.js', '**/*.mjs', '**/*.cjs', '**/*.mts', '**/*.cts'],
languageOptions: { parser: tseslint.parser },
},

globalIgnores([
'.claude/**',
Expand Down
1 change: 1 addition & 0 deletions modules/common/biometrics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Versioned biometric configuration contracts shared with TypeScript."""
64 changes: 64 additions & 0 deletions modules/common/biometrics/build_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Versioned identity of the shipped replay sources and installed runtime.

Distribution RECORD hashes identify installed wheel metadata, not an attestation
that every installed binary is unmodified. No archive or patient data is read.
"""
import hashlib
import importlib.metadata
from pathlib import Path
import platform
import sys
import sysconfig

from .config import config_hash

SOURCE_FILES = (
'common/__init__.py', 'common/cbor_raw.py',
'common/biometrics/__init__.py', 'common/biometrics/build_identity.py',
'common/biometrics/config.py', 'common/biometrics/manifest.v1.json',
'common/biometrics/frames.py', 'common/biometrics/gates.py',
'common/biometrics/processing.py', 'common/biometrics/routing.py',
'common/biometrics/signal.py', 'common/biometrics/raw_artifacts.py',
'common/biometrics/raw_records.py', 'common/biometrics/retained_replay.py',
'common/biometrics/execution_lock.py',
'common/biometrics/replay.py', 'common/biometrics/replay_worker.py',
'piezo-processor/pyproject.toml', 'piezo-processor/uv.lock',
)
MAX_FILE_BYTES = 16 * 1024 * 1024


def _digest_file(path):
digest = hashlib.sha256()
size = 0
with path.open('rb') as stream:
while True:
chunk = stream.read(65536)
if not chunk:
break
size += len(chunk)
if size > MAX_FILE_BYTES:
raise ValueError('Replay build identity file exceeds its bound')
digest.update(chunk)
return digest.hexdigest()


def replay_build_identity():
root = Path(__file__).resolve().parents[2]
sources = {name: _digest_file(root / name) for name in SOURCE_FILES}
dependencies = {}
for name in ('numpy', 'scipy'):
distribution = importlib.metadata.distribution(name)
record = distribution.read_text('RECORD')
wheel = distribution.read_text('WHEEL')
if not record or not wheel or len(record.encode()) > MAX_FILE_BYTES or len(wheel.encode()) > MAX_FILE_BYTES:
raise ValueError('Replay dependency build metadata is unavailable')
dependencies[name] = {'version': distribution.version,
'record_sha256': hashlib.sha256(record.encode()).hexdigest(),
'wheel_sha256': hashlib.sha256(wheel.encode()).hexdigest()}
identity = {'version': 'waveform-python-build-v1', 'sources': sources,
'dependencies': dependencies, 'runtime': {
'implementation': platform.python_implementation(), 'version': sys.version,
'cache_tag': sys.implementation.cache_tag, 'soabi': sysconfig.get_config_var('SOABI'),
'machine': platform.machine(), 'system': platform.system(), 'byteorder': sys.byteorder,
}}
return {'hash': config_hash(identity), 'identity': identity}
55 changes: 55 additions & 0 deletions modules/common/biometrics/collection_worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Bounded cleanup of server-owned retained waveforms, never the archive."""
import argparse
from contextlib import closing
import json
from pathlib import Path
import sqlite3
import sys
import time

from .raw_artifacts import RawArtifactStore
from .replay_worker import _unique_object, _invalid_constant, configure_process_limits


def run(input_stream, output_stream, database_path, retained_directory):
payload = input_stream.read(1025)
if len(payload) > 1024:
raise ValueError('RAW cleanup input exceeds byte budget')
work = json.loads(payload, object_pairs_hook=_unique_object, parse_constant=_invalid_constant)
if work != {'version': 'raw-collection-work-v1'}:
raise ValueError('Invalid RAW cleanup envelope')
directory = Path(retained_directory)
removed_released = removed_orphans = 0
if directory.is_symlink():
raise ValueError('Invalid retained directory')
if directory.exists():
database = Path(database_path)
if database.is_symlink() or not database.is_file():
raise ValueError('Invalid biometrics database')
with closing(sqlite3.connect(database.resolve().as_uri() + '?mode=rw', uri=True, timeout=1)) as connection:
connection.execute('PRAGMA foreign_keys = ON')
store = RawArtifactStore(directory)
now_ms = time.time_ns() // 1_000_000
removed_released = len(store.collect_released(connection, now_ms=now_ms))
removed_orphans = len(store.collect_orphans(connection, now_ms=now_ms))
result = {'version': 'raw-collection-result-v1', 'released': removed_released, 'orphans': removed_orphans}
output_stream.write((json.dumps(result, separators=(',', ':')) + '\n').encode())
output_stream.flush()


def main():
parser = argparse.ArgumentParser(description='Collect retained RAW files')
parser.add_argument('--database-path', required=True)
parser.add_argument('--retained-directory', required=True)
args = parser.parse_args()
try:
configure_process_limits()
run(sys.stdin.buffer, sys.stdout.buffer, args.database_path, args.retained_directory)
return 0
except Exception:
sys.stderr.write('RAW collection worker failed\n')
return 1


if __name__ == '__main__':
sys.exit(main())
Loading
Loading