Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
02da363
Pin mqttBridge payload contents to kill surviving mutants
ng Jun 6, 2026
960cbf5
test(streaming): kill surviving mutants in mqttBridge (#635)
ng Jun 7, 2026
d31df6d
feat(autopilot): reactive rules engine + builder UI (nav hidden until…
ng Jun 7, 2026
39e1dfb
chore(deps): Update @formatjs/intl-localematcher all non-major depend…
renovate[bot] Jun 8, 2026
1906475
fix(sleep-detector): debounce presence to stop session fragmentation …
ng Jun 11, 2026
d397ab1
ci(mutation): bump github-script to v9 for Node 24 runtime (#625)
ng Jun 11, 2026
7598a90
test(streaming): kill mqttBridge HA discovery mutants via full payloa…
ng Jun 11, 2026
35f0fba
fix: stop reporting 83°F on an off pod side (level-0 neutral phantom)…
ng Jun 11, 2026
257891b
test(db): kill src/db/schema.ts mutants via getTableConfig structural…
ng Jun 13, 2026
f612b7c
Move dry-run notice above the editor's right-column cards (#646)
ng Jun 15, 2026
bddf36c
Persist downsampled capacitive frames for historical replay (#645)
ng Jun 15, 2026
b972ddb
chore(deps): Update @types/node all non-major dependencies (#647)
renovate[bot] Jun 15, 2026
af919b2
Automation builder: legible backtest chart + editable number fields (…
ng Jun 15, 2026
8ef6474
fix(biometrics): stop archive pruner dying on SIGPIPE (/persistent fi…
ng Jun 15, 2026
9406d52
feat(autopilot): consume persisted cap frames for backtest + zone rep…
ng Jun 15, 2026
5093703
chore(deps): Update node Node.js to v24 (#416)
renovate[bot] Jun 15, 2026
d9fafa0
chore: pin pod runtime to Node 24 (match .node-version) (#650)
ng Jun 15, 2026
215bd43
chore(deps): Update typescript dependency typescript to v6 (#315)
renovate[bot] Jun 15, 2026
9e0d076
fix(piezoStream): eliminate file-tailing race that flaked CI (#652)
ng Jun 15, 2026
f698549
test(system): kill surviving mutants in system router (#591) (#653)
ng Jun 21, 2026
9a2418b
chore(deps): Update @base-ui/react all non-major dependencies (#654)
renovate[bot] Jun 22, 2026
caf4283
chore(deps): Update @tanstack/react-query all non-major dependencies …
renovate[bot] Jun 30, 2026
e48996e
docs(review): add repo-wide code review report (2026-07-01)
ng Jul 2, 2026
88eef99
docs(review): add actionable fix plan for 2026-07-01 code review
ng Jul 2, 2026
b30a8bf
fix(db): restore monotonic ordering in biometrics migration journal
ng Jul 3, 2026
3ac3c78
fix(health): stop phantom scheduler-drift reloads for non-schedule jobs
ng Jul 3, 2026
8f5184f
fix(hardware): prevent permanent command/response misalignment after …
ng Jul 3, 2026
0efa528
fix(environment-monitor): gate RAW timestamps to a sane wall-clock wi…
ng Jul 3, 2026
308b4f0
chore(review): mark Batch 1 complete; fix non-null assertion in trans…
ng Jul 3, 2026
8f00fd4
fix(homekit): reconcile intendedPower latch with observed firmware state
ng Jul 3, 2026
d78b8be
fix(automation): propagate timezone changes to the running engine
ng Jul 3, 2026
7639756
fix(scheduler): tear down partially-initialized JobManager on loadSch…
ng Jul 3, 2026
99d065d
fix(hardware): re-baseline gesture counters on degraded-to-running re…
ng Jul 3, 2026
afad9f2
fix(hardware): back sideLock mutex with globalThis
ng Jul 3, 2026
9a63c45
fix(export): unwedge archive endpoint on client disconnect or hung tar
ng Jul 3, 2026
5df41c9
fix(server): gate tRPC panel out of production; drop misleading prote…
ng Jul 3, 2026
0d0570a
chore(review): mark Batch 2 complete; lint fixes in sideLock test
ng Jul 3, 2026
4ce7ae7
fix(piezo-processor): share one DBHolder across side processors
ng Jul 3, 2026
43e8da7
fix(calibrator): gate daily fallback on persisted profile age and DAI…
ng Jul 3, 2026
fe5576d
fix(raw-follower): survive RAW file rotation between find and open
ng Jul 3, 2026
aa15134
fix(sleep-detector): make pump movement gate per-side
ng Jul 3, 2026
beb415c
fix(ui): resume HTTP polling when the device-status WebSocket dies or…
ng Jul 3, 2026
a3e8edd
fix(ui): make snoozed alarm Cancel button clear the snoozed sides
ng Jul 3, 2026
a78d868
fix(ui): hold optimistic temp/power state until a status frame confir…
ng Jul 3, 2026
dc7d040
chore(review): mark Batch 3 complete; lint fixes in new hooks/tests
ng Jul 3, 2026
fc797ae
fix(modules): Python low-severity review items 4.7-4.11 + 6-value cap…
ng Jul 3, 2026
2bd95fa
fix(streaming,hardware): low-severity review items 4.3-4.6
ng Jul 3, 2026
f3adeea
fix(automation,scheduler): low-severity review items 4.12-4.13
ng Jul 3, 2026
878e7d0
fix(db,scripts): low-severity review items 4.14-4.18
ng Jul 3, 2026
920f3c1
fix(server,ui): low-severity review items 4.2, 4.21-4.23
ng Jul 3, 2026
34d1cb5
chore(repo): review items 4.24-4.26 — hygiene, CLAUDE.md, audit tracking
ng Jul 3, 2026
7f3a82c
chore: gitignore __pycache__
ng Jul 3, 2026
0c87deb
fix(automation): honor manual overrides and missing setpoints
ng Jul 3, 2026
7945fee
chore(docs): plan remaining 2026-07-03 review fixes; retire completed…
ng Jul 3, 2026
c44da6a
fix: complete review and temperature unit fixes
ng Jul 3, 2026
7593463
fix(streaming): skip SEQNO and prefer biometrics RAW (#608)
onemec Jul 5, 2026
420b875
chore(deps): Update @eslint/css all non-major dependencies (#661)
renovate[bot] Jul 6, 2026
cf895d7
fix(install): grant service user journal read so log viewer works on …
ng Jul 7, 2026
613b69a
fix(hardware): stop pump stall guard false-tripping on firmware-comma…
ng Jul 12, 2026
9316e94
test(streaming): kill piezoStream surviving mutants (#591 hit list) (…
ng Jul 12, 2026
821ac78
chore(sp-bundle-logs): capture streamer attach log and archive/disk p…
ng Jul 12, 2026
a4dadcc
fix(biometrics-pruner): use POSIX df/ls so busybox pods actually prun…
ng Jul 12, 2026
a3d885f
Merge remote-tracking branch 'origin/main' into HEAD
ng Jul 12, 2026
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
100 changes: 100 additions & 0 deletions .claude/skills/mutation-hitlist/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
name: mutation-hitlist
description: Work the weekly mutation-testing hit list (issue #591) — look up the top surviving file, pull the full survivor list from CI artifacts, write killing tests, verify with a scoped Stryker run, and report back. Use when asked to "address this week's mutation testing", "work the hit list", or kill surviving mutants.
---

# Weekly mutation-testing workflow

Issue **#591** is the auto-updated tracking issue (workflow:
`.github/workflows/mutation.yml`, weekly schedule). Its body holds the score,
per-shard table, and a truncated top-50 hit list. Work happens in comments;
never edit the body.

## 1. Look up this week's target

```bash
gh issue view 591 --repo sleepypod/core --json body -q .body | head -80
```

- Note the **run id** from the "HTML reports" link and the trigger commit.
- Pick the top surviving file that has no recent comment claiming it
(`gh issue view 591 --comments` — prior weeks' work is recorded there).

## 2. Pull the FULL survivor list (the issue shows only top 50)

Each shard uploads `mutation-report-<shard>` containing `mutation.json`:

```bash
cd $(mktemp -d)
gh run download <RUN_ID> --repo sleepypod/core -n mutation-report-<shard>
python3 <repo>/.claude/skills/mutation-hitlist/survivors.py mutation.json <fileBasename>
```

Shard names: `hardware-lib`, `homekit`, `hooks`, `server`,
`services-scheduler`, `streaming` (see the matrix in mutation.yml).

## 3. Branch and verify staleness

- Branch off **fresh `origin/dev`** (`test/<file>-mutants-591`).
- `git diff <trigger-commit> origin/dev --stat -- <file> <its test file>` —
if the source changed since the run, line numbers are stale; re-run scoped
Stryker first instead of trusting the artifact.

## 4. Write killing tests

A killing test must FAIL when the mutation is applied. Expected test file is
`<dir>/tests/<name>.test.ts`. Prefer **no source changes**; if internals are
unreachable, extending an existing `__test__` export with pure functions is
the accepted pattern (see `piezoStream.ts`).

Playbook by mutator:
- `StringLiteral` in thrown errors / WS error messages → assert exact message
content, not just `/some regex/` that an empty string can't fail.
- `StringLiteral` in `console.log/warn` → `vi.spyOn(console, ...)` and assert
`expect.stringContaining(...)` plus exact numeric args where deterministic.
- `EqualityOperator` (`<` vs `<=`) → test the exact boundary value.
- `ConditionalExpression true/false` → one test per branch, asserting the
branch-specific observable (message text, count, absence of a frame).
- `BlockStatement {}` on guards → assert the guarded side effect happened
(port refuses connections after shutdown, no duplicate frames, etc.).
- `ArithmeticOperator` on buffer offsets → multi-tick scenarios (partial
append, then seek) with exactly-once + content assertions.

## 5. Known equivalent / unkillable categories (don't chase these)

- **Module-level initializer mutants** (const config at top of file,
`new Decoder({...})` options): activate at import; the vitest runner's
module cache makes them false survivors. Unkillable without per-mutant
process restart.
- Timing/perf-only values (`setTimeout` delays, yield cadence like
`recordsSinceYield >= 500`, read-chunk sizes that only change speed).
- Double-guarded checks (a second guard downstream makes the first
unobservable), `Math.random()` entropy, `>= 0` on sizes iterated with
`for..of` over an empty set.

## 6. Verify — tests locally, mutation in CI

Do **NOT** run Stryker locally — mutation runs happen in CI only.

```bash
pnpm exec vitest run src/<path>/tests/<file>.test.ts
```

Then let CI produce the mutation numbers:

- **Per-PR** (preferred): apply the `mutation-test` label to the PR —
`mutation.yml` runs on it and comments a summary.
- **Manual**: Actions → "Mutation Testing" → Run workflow, with the `scope`
input set to the target file glob (blank = all shard defaults).
- **Weekly**: the Saturday 06:00 UTC schedule refreshes issue #591's baseline.

Pull the run's `mutation-report-<shard>` artifact and re-run `survivors.py`
on its `mutation.json` for the post-change survivor list.

## 7. Report and ship

- PR to `dev`, title `test(<area>): kill <file> surviving mutants (#591 hit list)`,
with a test plan section.
- Comment on #591 following the established format: before → after counts,
file score, and a line-by-line justification for each remaining survivor
(see the mqttBridge comment for the template).
27 changes: 27 additions & 0 deletions .claude/skills/mutation-hitlist/survivors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Extract surviving mutants for one file from a Stryker mutation.json.

Usage: survivors.py <mutation.json> <source-file-substring>
Prints one line per surviving mutant: location, mutator, replacement.
"""
import json
import sys

if len(sys.argv) != 3:
sys.exit(__doc__)

report = json.load(open(sys.argv[1]))
needle = sys.argv[2]
matches = [k for k in report['files'] if needle in k]
if not matches:
sys.exit(f'no file matching {needle!r}; files: {len(report["files"])}')
for key in matches:
mutants = [m for m in report['files'][key]['mutants'] if m['status'] == 'Survived']
print(f'{key}: {len(mutants)} surviving')
for m in sorted(mutants, key=lambda m: (m['location']['start']['line'],
m['location']['start']['column'])):
loc = m['location']
repl = m['replacement'][:100].replace('\n', ' ')
print(f" L{loc['start']['line']}:{loc['start']['column']}"
f"-{loc['end']['line']}:{loc['end']['column']}"
f" {m['mutatorName']}: {repl}")
6 changes: 3 additions & 3 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ jobs:

- name: Comment on PR (labeled PR runs only)
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const fs = require('fs')
Expand Down Expand Up @@ -212,7 +212,7 @@ jobs:
- name: Update pinned tracking issue (scheduled runs only)
id: update-issue
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
uses: actions/github-script@v7
uses: actions/github-script@v9
env:
ISSUE_TITLE: '📊 Mutation testing — weekly baseline'
with:
Expand Down Expand Up @@ -397,7 +397,7 @@ jobs:

- name: Compute aggregated row
id: compute
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const fs = require('fs')
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/python-modules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ jobs:
target: .
cov_source: .
deps: ""
- name: calibrator
workdir: modules/calibrator
target: .
cov_source: .
deps: ""
- name: piezo-processor
workdir: modules/piezo-processor
target: .
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ reports/mutation
sleepypod.dev.db
sleepypod.dev.db-shm
sleepypod.dev.db-wal
# biometrics.dev.db itself stays tracked (intentional dev fixture); the WAL/SHM
# sidecars are transient per-machine state and must not be committed.
biometrics.dev.db-shm
biometrics.dev.db-wal

# SQLite test/local databases
sleepypod.test.db
Expand All @@ -55,3 +59,4 @@ sleepypod.local.db-wal
# SQLite databases (local)
*.db
.reviews/
__pycache__/
2 changes: 1 addition & 1 deletion .node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22
24
62 changes: 55 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,69 @@ This file provides instructions and context for AI coding agents working on this

## Build & Test

_Add your build and test commands here_

```bash
# Example:
# npm install
# npm test
pnpm install # deps (pnpm workspace)
pnpm dev # Next.js dev server
pnpm build # production build (standalone output)
pnpm tsc # type-check (quality gate)
pnpm lint # eslint (quality gate)
pnpm test # vitest unit suite (quality gate)
pnpm test:mutation # Stryker mutation testing (slow; CI tracks score)

# Python modules (each modules/<name>/ is a uv project):
cd modules/<name> && uv run --with pytest pytest test_main.py
# Use the module's locked deps (uv run), NOT an ad-hoc venv — unpinned
# numpy/scipy versions change signal-processing results.
```

Quality gates (`pnpm tsc && pnpm lint && pnpm test`) also run in the pre-push
hook. CI runs the Python module matrix in
`.github/workflows/python-modules.yml`.

## Architecture Overview

_Add a brief overview of your project architecture_
Next.js (App Router) server that runs ON the pod and controls it:

- `src/hardware/` — DAC transport over Unix socket to frankenfirmware
(`dacTransport.ts` prod server-mode, `socketClient.ts` dev client-mode),
`dacMonitor` status polling, gesture detection, pump-stall guard, snooze.
- `src/server/` — tRPC routers (also exposed REST-style via trpc-to-openapi
at `/api/*`). **No auth: LAN-only trust model** — see `openapi.ts`.
- `src/scheduler/` — node-schedule JobManager (temperature/power/alarm
schedules from sleepypod.db plus prime/reboot/LED/run-once system jobs).
- `src/automation/` — Autopilot engine: signal-driven rules with tick /
timeOfDay / signalChange triggers, evaluated in the device timezone.
- `src/streaming/` — WebSocket sensor stream on port 3001 (`piezoStream`),
frame normalization, optional MQTT bridge (Home Assistant discovery).
- `src/homekit/` — HAP bridge (thermostat, power/snooze switches, sensors);
hardware writes serialize through `sideController` + `sideLock`.
- `src/db/` — two SQLite DBs via drizzle: `sleepypod.db` (config/state,
`migrations/`) and `biometrics.db` (time-series, `biometrics-migrations/`);
`retention.ts` prunes time-series tables.
- `modules/` — Python daemons reading CBOR `*.RAW` sensor files and writing
biometrics.db: piezo-processor (vitals), sleep-detector (presence/sessions),
environment-monitor (temps), calibrator, cover-buttons; `modules/common/`
is their shared library.
- `app/` — routes/UI; `src/components/` + `src/hooks/` — frontend. Device
status prefers the WS stream with tRPC HTTP fallback (`useDeviceStatus`).

Cross-cutting invariants:
- Per-side hardware writes MUST go through `withSideLock` (globalThis-backed).
- Singletons live on `globalThis` (Turbopack can duplicate module instances).
- DAC protocol has no correlation ids — commands are strictly sequential;
never read a response you didn't just send a command for.

## Conventions & Patterns

_Add your project-specific conventions here_
- One commit per fix/feature; conventional-commit subjects (semantic-release).
- Vitest tests live in `tests/` dirs beside the code (`src/**/tests/*.test.ts`);
Python tests are `modules/<name>/test_main.py` with pod-only imports stubbed
via `sys.modules` so they run on dev machines.
- Stryker mutation testing is active — behavioral fixes need a pinning test
or they surface as surviving mutants.
- DB schema changes: edit `src/db/*schema.ts`, then `pnpm db:generate` /
`pnpm db:biometrics:generate`. Never hand-edit migration journals — entry
`when` values must stay strictly increasing (enforced by a test).

## Debugging a pod

Expand Down
5 changes: 5 additions & 0 deletions app/[lang]/autopilot/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AutopilotConsole } from '@/src/components/Autopilot/AutopilotConsole'

export default function AutopilotPage() {
return <AutopilotConsole />
}
61 changes: 61 additions & 0 deletions app/api/export/archive/route.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ vi.mock('node:fs/promises', () => ({ ...fsMock, default: fsMock }))
interface FakeChild extends EventEmitter {
stdout: Readable
stderr: EventEmitter
kill: ReturnType<typeof vi.fn>
}

const spawnMock = vi.hoisted(() => ({
Expand Down Expand Up @@ -60,6 +61,7 @@ function makeFakeChild(): FakeChild {
const child = new EventEmitter() as FakeChild
child.stdout = stdout
child.stderr = stderr
child.kill = vi.fn()
return child
}

Expand Down Expand Up @@ -262,6 +264,65 @@ describe('GET /api/export/archive', () => {
expect(res2.status).not.toBe(429)
})

it('rejects non-numeric startTs/endTs with 400 without latching inflight', async () => {
const { GET } = await loadRoute()
const res = await GET(new Request('http://localhost/api/export/archive?startTs=garbage'))
expect(res.status).toBe(400)
const body = await res.json()
expect(body).toEqual({ error: 'startTs and endTs must be numeric epoch seconds' })
expect(spawnMock.spawn).not.toHaveBeenCalled()

// inflight must not have been taken by the rejected request.
const res2 = await GET(new Request('http://localhost/api/export/archive?include=raw'))
expect(res2.status).toBe(200)
})

it('kills tar, clears inflight, and removes staging when the client disconnects', async () => {
const { GET } = await loadRoute()
const controller = new AbortController()
const req = new Request('http://localhost/api/export/archive?include=raw', {
signal: controller.signal,
})
const res = await GET(req)
expect(res.status).toBe(200)

const child = spawnMock.lastChild
if (!child) throw new Error('expected spawn to have been called')

controller.abort()

expect(child.kill).toHaveBeenCalledWith('SIGKILL')
expect(fsMock.rm).toHaveBeenCalledWith(
expect.stringContaining('sp-export-'),
{ recursive: true, force: true },
)
// inflight cleared — a follow-up export is allowed.
const res2 = await GET(new Request('http://localhost/api/export/archive?include=raw'))
expect(res2.status).not.toBe(429)
})

it('kills tar and clears inflight when the watchdog expires', async () => {
process.env.EXPORT_WATCHDOG_MS = '30'
try {
const { GET } = await loadRoute()
const res = await GET(new Request('http://localhost/api/export/archive?include=raw'))
expect(res.status).toBe(200)

const child = spawnMock.lastChild
if (!child) throw new Error('expected spawn to have been called')

// tar never emits close/error — the watchdog must fire.
await new Promise(r => setTimeout(r, 80))
expect(child.kill).toHaveBeenCalledWith('SIGKILL')

const res2 = await GET(new Request('http://localhost/api/export/archive?include=raw'))
expect(res2.status).not.toBe(429)
}
finally {
delete process.env.EXPORT_WATCHDOG_MS
}
})

it('uses the provided startTs and endTs in the filename', async () => {
const { GET } = await loadRoute()
const req = new Request('http://localhost/api/export/archive?startTs=100&endTs=200&include=raw')
Expand Down
Loading
Loading