---
phase: 02-data-backup-recovery
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
  - server/services/backup.js
  - server/services/backupScheduler.js
  - server/routes/backup.js
  - server/lib/validation.js
  - server/index.js
  - client/src/services/api.js
autonomous: true
requirements:
  - BAK-01
  - BAK-02
  - BAK-03

must_haves:
  truths:
    - "Running POST /api/backup/run copies changed files from ./data/ to the configured external drive path via rsync"
    - "A manifest.json with SHA-256 hashes exists inside each snapshot directory after backup completes"
    - "Backup runs automatically on a daily cron schedule registered with eventScheduler"
    - "GET /api/backup/status returns last backup time, next scheduled time, and current status"
    - "GET /api/backup/snapshots returns a list of all snapshots with creation time and file count"
  artifacts:
    - path: "server/services/backup.js"
      provides: "Core backup engine: runBackup, generateManifest, listSnapshots, restoreSnapshot, getState"
      exports: ["runBackup", "generateManifest", "listSnapshots", "restoreSnapshot", "getState", "getNextRunTime"]
    - path: "server/services/backupScheduler.js"
      provides: "Scheduler registration mirroring brainScheduler.js pattern"
      exports: ["startBackupScheduler", "stopBackupScheduler"]
    - path: "server/routes/backup.js"
      provides: "REST API: GET /status, POST /run, GET /snapshots, POST /restore"
    - path: "server/lib/validation.js"
      provides: "Zod schemas: backupConfigSchema, restoreRequestSchema"
      contains: "backupConfigSchema"
    - path: "client/src/services/api.js"
      provides: "Client API functions: getBackupStatus, triggerBackup, getBackupSnapshots, restoreBackup"
  key_links:
    - from: "server/routes/backup.js"
      to: "server/services/backup.js"
      via: "import and call runBackup, listSnapshots, getState, restoreSnapshot"
      pattern: "import.*backup"
    - from: "server/services/backupScheduler.js"
      to: "server/services/eventScheduler.js"
      via: "schedule() call with cron type"
      pattern: "eventScheduler.*schedule"
    - from: "server/index.js"
      to: "server/routes/backup.js"
      via: "app.use('/api/backup', backupRoutes)"
      pattern: "app\\.use.*backup"
    - from: "server/index.js"
      to: "server/services/backupScheduler.js"
      via: "startBackupScheduler() call at server startup"
      pattern: "startBackupScheduler"
---

<objective>
Build the backup engine, scheduler, REST routes, and client API functions for PortOS data backup.

Purpose: Establish the core backup infrastructure — rsync-based incremental copy from `./data/` to an external drive, SHA-256 manifest generation for integrity verification, cron scheduling via the existing eventScheduler, and a complete REST API. This enables Plan 02 to wire up the dashboard widget and restore UI.

Output: Working backup service with scheduler, validated routes, and client API functions ready for widget consumption.
</objective>

<execution_context>
@/Users/antic/.claude/get-shit-done/workflows/execute-plan.md
@/Users/antic/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-data-backup-recovery/02-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
<!-- Executor should use these directly -- no codebase exploration needed. -->

From server/services/eventScheduler.js:
```javascript
export {
  schedule,    // schedule({ id, type, cron, intervalMs, delayMs, handler, metadata })
  cancel,      // cancel(id) -> boolean
  pause,       // pause(id) -> boolean
  resume,      // resume(id) -> boolean
  getScheduledEvents, // -> Array<{ id, type, active, nextRunAt, lastRunAt, runCount, metadata }>
  getEvent,    // getEvent(id) -> { id, type, active, cron, intervalMs, nextRunAt, lastRunAt, runCount, metadata } | null
  getHistory,
  getStats,
  cancelAll,
  triggerNow,  // triggerNow(id) -> Promise<boolean>
  parseCronToNextRun,
  MAX_TIMEOUT
}
```

From server/services/brainScheduler.js (pattern to mirror):
```javascript
export function startBrainScheduler() { ... }  // Called at server startup in index.js
export function stopBrainScheduler() { ... }
```

From server/services/settings.js:
```javascript
export const getSettings = load;  // -> Promise<object> (reads data/settings.json)
export const updateSettings = async (patch) => { ... }; // -> Promise<merged>
```

From server/lib/fileUtils.js:
```javascript
export const PATHS = {
  root: join(__lib_dirname, '../..'),
  data: join(__lib_dirname, '../../data'),
  // ... other paths ...
  meatspace: join(__lib_dirname, '../../data/meatspace')
};
export async function ensureDir(dir) { ... }
export async function readJSONFile(filePath, defaultValue = null, { allowArray, logError } = {}) { ... }
```

From server/lib/errorHandler.js:
```javascript
export class ServerError extends Error { ... }
export function asyncHandler(fn) { ... }  // Wraps async route handlers
export function normalizeError(err) { ... }
```

From server/lib/validation.js:
```javascript
export function validateRequest(schema, data) { ... }  // Throws ServerError on failure
```

From server/index.js (route registration pattern):
```javascript
import backupRoutes from './routes/backup.js';  // alphabetical with other imports
app.use('/api/backup', backupRoutes);            // alphabetical among app.use() calls
import { startBackupScheduler } from './services/backupScheduler.js';
startBackupScheduler();  // after startBrainScheduler() call on line 228
```

From client/src/services/api.js (pattern):
```javascript
async function request(endpoint, options = {}) { ... }  // base request function
export const getBackupStatus = (options) => request('/backup/status', options);
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create backup service and scheduler</name>
  <files>
    server/services/backup.js
    server/services/backupScheduler.js
  </files>
  <action>
Create `server/services/backup.js` with these exports:

1. **`runBackup(destPath, io = null)`** — Core backup function:
   - Check `isRunning` flag; if true, log and return `{ skipped: true }`.
   - Validate `destPath` exists with `fs.access(destPath)`. If missing, throw `new Error('Backup destination not found: ${destPath}')`.
   - Set `isRunning = true`.
   - Create snapshot ID from ISO timestamp with colons replaced: `new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '')`.
   - Create snapshot dir: `join(destPath, 'snapshots', snapshotId)` and data subdir: `join(snapshotDir, 'data')`.
   - Spawn rsync: `spawn('/usr/bin/rsync', ['--archive', '--itemize-changes', srcDir + '/', dataDestDir], { shell: false })`.
   - Collect changed file lines from stdout (lines starting with `>` or `<`).
   - Accept exit codes 0 and 24 as success (24 = vanished files, normal for active system). Reject on other codes.
   - After rsync, call `generateManifest(dataDestDir, join(snapshotDir, 'manifest.json'))`.
   - Save state via `saveState({ lastRun, lastSnapshotId, status: 'ok', filesChanged, error: null })`.
   - Set `isRunning = false` in a finally-equivalent pattern (set in both success path and error path).
   - If `io` is provided, emit `backup:started` and `backup:completed` events.
   - Use `PATHS.data` from `../lib/fileUtils.js` as source directory.

2. **`generateManifest(snapshotDataDir, manifestPath)`** — Walk the snapshot data directory recursively:
   - Use `fs.readdir` with `{ recursive: true }` and `fs.stat` to find regular files.
   - For files under 512KB: `createHash('sha256').update(await readFile(filePath)).digest('hex')`.
   - For files 512KB+: use streaming hash with `fs.createReadStream(filePath)` piped to `createHash('sha256')`.
   - Write `manifest.json`: `{ generatedAt, fileCount, files: { relativePath: sha256Hash } }`.
   - Return the manifest object.

3. **`listSnapshots(destPath)`** — Read `join(destPath, 'snapshots')` directory, for each entry read its `manifest.json` if exists, return `[{ id, createdAt, fileCount }]` sorted newest-first.

4. **`restoreSnapshot(destPath, snapshotId, { dryRun = true, subdirFilter = null } = {})`** — Reverse rsync from snapshot back to `PATHS.data`:
   - Build flags array: if `dryRun`, add `'--dry-run'`. Always include `'--archive'`, `'--itemize-changes'`.
   - If `subdirFilter`, add `--include=${subdirFilter}/***`, `--include=*/`, `--exclude=*`.
   - Spawn rsync from `join(destPath, 'snapshots', snapshotId, 'data')` to `PATHS.data`.
   - Return `{ dryRun, snapshotId, subdirFilter, changedFiles }`.

5. **`getState()`** — Read `join(PATHS.data, 'backup', 'state.json')` via `readJSONFile` with default `{ lastRun: null, status: 'never', lastSnapshotId: null, filesChanged: 0, error: null }`.

6. **`saveState(patch)`** — Read current state, merge patch, write back to `join(PATHS.data, 'backup', 'state.json')`. Ensure `data/backup/` directory exists via `ensureDir`.

7. **`getNextRunTime()`** — Import `getEvent` from `eventScheduler.js`, call `getEvent('backup-daily')`, return `event?.nextRunAt ? new Date(event.nextRunAt).toISOString() : null`.

Internal helper **`runRsync(srcDir, destDir, flags = [])`** — as described in research Pattern 2. Use `spawn('/usr/bin/rsync', ...)` with `shell: false`.

Logging: Use single-line emoji format per CLAUDE.md convention with `console.log()`:
- `console.log('backup starting: snapshot ${snapshotId}')` (with appropriate emoji)
- `console.log('backup rsync complete: ${changed.length} files changed (exit ${code})')` (with appropriate emoji)
- `console.log('backup manifest: ${manifest.fileCount} files')` (with appropriate emoji)

Create `server/services/backupScheduler.js` mirroring `brainScheduler.js` pattern:

1. **`startBackupScheduler()`** — Import `schedule` from `eventScheduler.js` and `getSettings` from `settings.js`. Call `schedule({ id: 'backup-daily', type: 'cron', cron: settings.backup?.cronExpression || '0 2 * * *', handler: async () => { ... call runBackup ... }, metadata: { source: 'backupScheduler' } })`. Only schedule if `settings.backup?.enabled !== false` and `settings.backup?.destPath` exists. Log startup.

2. **`stopBackupScheduler()`** — Import `cancel` from `eventScheduler.js`, cancel `'backup-daily'`.

No try/catch blocks — errors bubble up per CLAUDE.md convention. The only exceptions are the `.catch()` patterns for file reads returning defaults (e.g., `readdir(...).catch(() => [])` in listSnapshots) which are acceptable project patterns.
  </action>
  <verify>
    <automated>cd /Users/antic/github.com/atomantic/PortOS/server && node -e "import('./services/backup.js').then(m => { console.log('exports:', Object.keys(m)); })" && node -e "import('./services/backupScheduler.js').then(m => { console.log('exports:', Object.keys(m)); })"</automated>
  </verify>
  <done>
    - backup.js exports: runBackup, generateManifest, listSnapshots, restoreSnapshot, getState, saveState, getNextRunTime
    - backupScheduler.js exports: startBackupScheduler, stopBackupScheduler
    - No syntax errors on import
  </done>
</task>

<task type="auto">
  <name>Task 2: Create routes, validation schemas, wire into server, and add client API functions</name>
  <files>
    server/routes/backup.js
    server/lib/validation.js
    server/index.js
    client/src/services/api.js
  </files>
  <action>
**1. Add Zod schemas to `server/lib/validation.js`:**

Add at the end of the file (before the closing comment if any), in a new section:

```javascript
// =============================================================================
// BACKUP SCHEMAS
// =============================================================================

export const backupConfigSchema = z.object({
  destPath: z.string().min(1),
  cronExpression: z.string().optional(),
  enabled: z.boolean().optional().default(true)
});

export const restoreRequestSchema = z.object({
  snapshotId: z.string().min(1),
  subdirFilter: z.string().optional().nullable(),
  dryRun: z.boolean().optional().default(true)
});
```

**2. Create `server/routes/backup.js`:**

```javascript
import { Router } from 'express';
import { asyncHandler } from '../lib/errorHandler.js';
import { validateRequest } from '../lib/validation.js';
import { restoreRequestSchema } from '../lib/validation.js';
import * as backup from '../services/backup.js';
import { getSettings } from '../services/settings.js';

const router = Router();

// GET /api/backup/status
router.get('/status', asyncHandler(async (req, res) => {
  const state = await backup.getState();
  const settings = await getSettings();
  const nextRun = backup.getNextRunTime();
  res.json({ ...state, destPath: settings.backup?.destPath ?? null, nextRun });
}));

// POST /api/backup/run
router.post('/run', asyncHandler(async (req, res) => {
  const settings = await getSettings();
  const io = req.app.get('io');
  const result = await backup.runBackup(settings.backup?.destPath, io);
  res.json(result);
}));

// GET /api/backup/snapshots
router.get('/snapshots', asyncHandler(async (req, res) => {
  const settings = await getSettings();
  const snapshots = await backup.listSnapshots(settings.backup?.destPath);
  res.json(snapshots);
}));

// POST /api/backup/restore
router.post('/restore', asyncHandler(async (req, res) => {
  const { snapshotId, subdirFilter, dryRun } = validateRequest(restoreRequestSchema, req.body);
  const settings = await getSettings();
  const result = await backup.restoreSnapshot(settings.backup?.destPath, snapshotId, { dryRun, subdirFilter });
  res.json(result);
}));

export default router;
```

**3. Wire into `server/index.js`:**

- Add import alphabetically among the existing route imports (after `autobiographyRoutes`, before `brainRoutes`):
  `import backupRoutes from './routes/backup.js';`

- Add route registration alphabetically among `app.use()` calls (after `/api/attachments` and before `/api/brain`):
  `app.use('/api/backup', backupRoutes);`

- Add scheduler import alongside the `brainScheduler` import:
  `import { startBackupScheduler } from './services/backupScheduler.js';`

- Add `startBackupScheduler();` call right after the existing `startBrainScheduler();` call (around line 228-229).

**4. Add client API functions to `client/src/services/api.js`:**

Add these exports at an appropriate location (alphabetically near other domain-grouped functions, or at the end before the file's last export group):

```javascript
// Backup
export const getBackupStatus = (options) => request('/backup/status', options);
export const triggerBackup = () => request('/backup/run', { method: 'POST' });
export const getBackupSnapshots = (options) => request('/backup/snapshots', options);
export const restoreBackup = (data) => request('/backup/restore', { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' } });
```

Note: Check if `request()` already sets `Content-Type: application/json` header by default (it likely does based on the existing patterns). If so, omit the explicit headers in `restoreBackup`. Match the existing pattern in the file.
  </action>
  <verify>
    <automated>cd /Users/antic/github.com/atomantic/PortOS/server && npm test 2>&1 | tail -5</automated>
  </verify>
  <done>
    - All existing server tests pass (no regressions)
    - `server/routes/backup.js` exists with GET /status, POST /run, GET /snapshots, POST /restore handlers
    - `server/lib/validation.js` contains backupConfigSchema and restoreRequestSchema
    - `server/index.js` imports and mounts backup routes at `/api/backup`
    - `server/index.js` calls `startBackupScheduler()` at startup
    - `client/src/services/api.js` exports getBackupStatus, triggerBackup, getBackupSnapshots, restoreBackup
  </done>
</task>

</tasks>

<verification>
1. `node -e "import('./server/services/backup.js').then(m => console.log(Object.keys(m)))"` — confirms exports
2. `node -e "import('./server/services/backupScheduler.js').then(m => console.log(Object.keys(m)))"` — confirms exports
3. `cd server && npm test` — all existing tests pass (no regressions from validation.js or index.js changes)
4. `grep -c 'backupConfigSchema\|restoreRequestSchema' server/lib/validation.js` — returns 2+
5. `grep 'api/backup' server/index.js` — confirms route mount
6. `grep 'startBackupScheduler' server/index.js` — confirms scheduler wired
7. `grep 'getBackupStatus\|triggerBackup\|getBackupSnapshots\|restoreBackup' client/src/services/api.js` — confirms client functions
</verification>

<success_criteria>
- backup.js and backupScheduler.js exist and import without errors
- Validation schemas added to validation.js
- Routes mounted at /api/backup in index.js
- Scheduler starts at server boot
- Client API functions available for widget consumption
- All existing server tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/02-data-backup-recovery/02-01-SUMMARY.md`
</output>
