---
phase: 30-hardware-detection-mode-selection
plan: 01
subsystem: api
tags: [hardware-detection, systeminformation, settings-persistence, ollama, model-catalog, zod]

# Dependency graph
requires:
  - phase: 28-ollama-integration
    provides: ollama service, model catalog, getRecommendedModel
provides:
  - hardwareService with Apple Silicon / GPU / cpu_only tier detection and 5-min cache
  - nexusSettingsService with Zod-validated file-backed mode persistence
  - Unauthenticated GET /api/system/providers endpoint for pre-auth hardware probe
  - GET /api/system/providers/recommendation — catalog filtered by detected tier
  - Board-auth-gated GET/PATCH /api/nexus/settings for mode persistence
  - Extended ollama-model-catalog.json with tier arrays on all variants and qwen3 family
  - getRecommendedModel supports optional hardwareTier parameter for tier-aware filtering
affects: [30-02-onboarding-ui, 31-cloud-providers, 33-personal-ai-assistant]

# Tech tracking
tech-stack:
  added: [systeminformation@5]
  patterns:
    - Promise.race with timeout for unreliable system calls (GPU detection)
    - vi.hoisted() for mock functions referenced in vi.mock() factories
    - File-backed JSON settings with Zod schema and graceful default fallback
    - Unauthenticated route mounted before boardMutationGuard by placing before api = Router()

key-files:
  created:
    - server/src/services/hardware.ts
    - server/src/services/nexus-settings.ts
    - server/src/routes/hardware.ts
    - server/src/routes/nexus-settings.ts
    - server/src/__tests__/30-hardware-detection.test.ts
  modified:
    - server/src/services/ollama.ts
    - server/src/data/ollama-model-catalog.json
    - server/src/app.ts

key-decisions:
  - "Use Promise.race with 3000ms timeout for si.graphics() — failure degrades to cpu_only tier, never blocks startup"
  - "Apple Silicon detected via process.platform === 'darwin' && cpuModel.startsWith('Apple') — does NOT call si.graphics()"
  - "Hardware routes mounted with app.use('/api', hardwareRoutes()) before const api = Router() to bypass boardMutationGuard"
  - "nexusSettingsService uses Zod z.enum(NEXUS_MODES).default('both') — validation at write time, default at read time"
  - "Model catalog tier arrays: cpu_only models serve all tiers, apple_silicon excludes large GPU-only models, gpu tier gets all"

patterns-established:
  - "Promise.race timeout pattern: pair any unreliable external call with a reject-after-N-ms promise"
  - "vi.hoisted() pattern: use for mock function references needed inside vi.mock() factory callbacks"
  - "File-backed settings: mkdirSync recursive + writeFileSync in set(), graceful default in get() catch block"

requirements-completed: [ONBD-02, ONBD-03, ONBD-01]

# Metrics
duration: 15min
completed: 2026-04-02
---

# Phase 30 Plan 01: Hardware Detection, Nexus Settings, Model Catalog Summary

**Hardware tier detection (Apple Silicon/GPU/CPU-only) via systeminformation with 3s timeout, file-backed mode persistence via Zod-validated nexus-settings service, extended model catalog with tier arrays, and unauthenticated /api/system/providers endpoint**

## Performance

- **Duration:** 15 min
- **Started:** 2026-04-02T23:16:00Z
- **Completed:** 2026-04-02T23:22:00Z
- **Tasks:** 2
- **Files modified:** 8

## Accomplishments
- Hardware detection service with Apple Silicon / discrete GPU / CPU-only tiers, 3-second timeout on GPU probe, 5-minute cache
- Nexus settings service persisting mode to `data/nexus-settings.json` with Zod validation and graceful default
- Extended model catalog with `tier` arrays on all 11 variants plus new qwen3:8b family (12 variants total)
- `getRecommendedModel` upgraded with optional `hardwareTier` parameter for tier-aware filtering
- Unauthenticated `GET /api/system/providers` endpoint mounted before boardMutationGuard
- Board-auth-gated `GET/PATCH /api/nexus/settings` for mode persistence
- 13 passing unit tests covering all hardware tiers, settings CRUD, catalog validation, and tier filtering

## Task Commits

1. **Task 1: Hardware service, nexus-settings service, model catalog, and tests** - `766460a1` (feat)
2. **Task 2: Hardware and nexus-settings routes, app.ts mounting** - `86e30e5c` (feat)

## Files Created/Modified
- `server/src/services/hardware.ts` - hardwareService factory with Apple Silicon/GPU/cpu_only detection and cache
- `server/src/services/nexus-settings.ts` - nexusSettingsService with Zod validation and file persistence
- `server/src/routes/hardware.ts` - Unauthenticated GET /system/providers and /system/providers/recommendation
- `server/src/routes/nexus-settings.ts` - Board-auth-gated GET/PATCH /nexus/settings
- `server/src/__tests__/30-hardware-detection.test.ts` - 13 unit tests using vi.hoisted() pattern
- `server/src/services/ollama.ts` - Added tier?: string[] to CatalogVariant, hardwareTier param to getRecommendedModel
- `server/src/data/ollama-model-catalog.json` - Added tier arrays to all variants, added qwen3 family
- `server/src/app.ts` - Imported and mounted hardwareRoutes (before api router) and nexusSettingsRoutes (on api router)

## Decisions Made
- Used `Promise.race` with a 3000ms timeout for `si.graphics()` — this matches the plan spec and ensures GPU probe never blocks the server for more than 3 seconds
- Apple Silicon path intentionally skips `si.graphics()` entirely — unified memory means GPU VRAM concept doesn't apply
- Hardware routes placed before `const api = Router()` to remain outside `boardMutationGuard` — this is the correct Express mounting position for unauthenticated endpoints
- Used `vi.hoisted()` to fix the Vitest mock hoisting issue — `mockGraphicsFn` must be initialized before `vi.mock()` factory runs

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] Fixed vi.mock() hoisting issue in test file**
- **Found during:** Task 1 (TDD test implementation)
- **Issue:** `const mockGraphicsFn = vi.fn()` declared before `vi.mock()` but Vitest hoists `vi.mock()` calls, causing "Cannot access 'mockGraphicsFn' before initialization" error
- **Fix:** Used `vi.hoisted()` to initialize `mockGraphicsFn` in a hoisted context, and updated all test references to use it directly instead of `vi.mocked(si.default.graphics)`
- **Files modified:** server/src/__tests__/30-hardware-detection.test.ts
- **Verification:** All 13 tests pass
- **Committed in:** 766460a1 (Task 1 commit)

**2. [Rule 2 - Missing Critical] Added _resetHardwareCache export for test isolation**
- **Found during:** Task 1 (TDD test implementation)
- **Issue:** Module-level cache in hardware.ts bleeds between tests — hardware tier tests would return stale cached values
- **Fix:** Exported `_resetHardwareCache()` function from hardware.ts, called in `beforeEach` in test file
- **Files modified:** server/src/services/hardware.ts, server/src/__tests__/30-hardware-detection.test.ts
- **Verification:** Each test runs with fresh detection state
- **Committed in:** 766460a1 (Task 1 commit)

---

**Total deviations:** 2 auto-fixed (1 bug, 1 missing critical)
**Impact on plan:** Both fixes necessary for correct test behavior. No scope creep.

## Issues Encountered
- Existing test suite has 18 pre-existing failures across 5 test files (skill-registry-routes, agent-permissions-routes, heartbeat-workspace-session, app-hmr-port, plugin-worker-manager) — confirmed pre-existing by stash test, not caused by this plan's changes.

## User Setup Required
None - no external service configuration required.

## Next Phase Readiness
- Hardware probe API ready for consumption by Plan 02 onboarding UI
- Mode persistence API ready — onboarding can set mode via PATCH /api/nexus/settings
- Model catalog with tier arrays ready — onboarding can show tier-appropriate model recommendations
- `GET /api/system/providers/recommendation` combines hardware detection + catalog filtering in one call

---
*Phase: 30-hardware-detection-mode-selection*
*Completed: 2026-04-02*
