Compare commits
2 Commits
0d6c688015
...
3051e6e0a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 3051e6e0a9 | |||
| 4cd382b829 |
@@ -354,25 +354,86 @@ Key constraints:
|
||||
## Phase 4 — Security: E2E Backup & Offline
|
||||
|
||||
### Step 4.1 — E2E encrypted backup
|
||||
- [ ] `src/main/backup/e2e-crypto.ts` + `backup-manager.ts`
|
||||
- [x] `src/main/backup/e2e-crypto.ts` + `backup-manager.ts`
|
||||
- **Outcome:** User data never leaves the device unencrypted.
|
||||
|
||||
### Step 4.2 — Offline sync queue
|
||||
- [ ] `src/main/backup/sync-queue.ts` + `sync_queue` table
|
||||
- [x] `src/main/backup/sync-queue.ts` + `sync_queue` table
|
||||
- **Outcome:** Queued actions auto-sync when online.
|
||||
|
||||
### Step 4.3 — Migrate to SQLCipher
|
||||
- [ ] Replace `better-sqlite3` with `@journeyapps/sqlcipher`
|
||||
- **Outcome:** All local data encrypted at rest.
|
||||
> **Step 4.3 (SQLCipher) — Dropped.** OS-level FDE covers at-rest encryption for a local-first desktop app. Backups already E2E encrypted via Argon2id + AES-256-GCM. Native module build complexity, ~10% perf overhead, and key management UX friction not justified by the threat model.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Shared Memory
|
||||
|
||||
### Step 5.1 — Three-tier local memory
|
||||
- [ ] `src/main/database/shared-memory.ts`: conversation buffer + agent KV store + multi-collection vector store
|
||||
- [ ] `agent_memory` table in schema
|
||||
- **Outcome:** Short-term, long-term, and semantic memory.
|
||||
> **Objective:** Add persistent chat history (multi-turn conversations survive app restarts) and an agent KV store (agents persist learned facts — preferences, patterns, corrections). All memory lives in Electron's SQLite (local-first). The orchestrator populates `conversationHistory` for the backend LLM, and pre-loads agent memories into context. Backend gets new memory tools + a critical fix to wire the chat WS for bidirectional tool calls.
|
||||
>
|
||||
> **Backend Phase 5 plan:** `../adiuva-api/AI_REFACTOR_PLAN.md` Phase 5 section.
|
||||
>
|
||||
> **Why this matters:**
|
||||
> - **Chat history:** Currently every message is ephemeral — the AI has no memory between turns or across restarts. With persistence, multi-turn conversations work, the LLM sees prior turns, and conversations feel continuous across app restarts.
|
||||
> - **Agent KV:** Agents currently have zero long-term memory. They can't remember "User prefers short task names" or "Client X wants weekly Monday updates". The KV store transforms agents from stateless tool executors into learning assistants that improve over time.
|
||||
|
||||
### Step 5.1 — Chat history + agent memory tables
|
||||
- [ ] Add `chatMessages` table to `src/main/db/schema.ts`:
|
||||
- `id` (text PK), `scope` (text: `'global'` or `'project:<uuid>'`), `role` (text: `'user'|'assistant'`), `content` (text), `error` (integer 0|1, default 0), `createdAt` (integer)
|
||||
- Export `ChatMessage` / `NewChatMessage` inferred types
|
||||
- [ ] Add `agentMemory` table to `src/main/db/schema.ts`:
|
||||
- `id` (text PK), `agentName` (text: e.g. `'task_agent'`), `key` (text), `value` (text — JSON-serialized), `scope` (text: `'global'` or `'project:<uuid>'`), `createdAt` (integer), `updatedAt` (integer)
|
||||
- Unique constraint: upsert by `(agentName, key, scope)` — same key overwrites value
|
||||
- Export `AgentMemoryEntry` / `NewAgentMemoryEntry` inferred types
|
||||
- [ ] Add migration SQL to `src/main/db/index.ts`:
|
||||
- `CREATE TABLE IF NOT EXISTS chat_messages (...)` and `CREATE TABLE IF NOT EXISTS agent_memory (...)`
|
||||
- `CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_memory_unique ON agent_memory(agent_name, key, scope)`
|
||||
- [ ] Register both tables in `TABLE_REGISTRY` in `src/main/api/drizzle-executor.ts`
|
||||
- Enables backend agent tools to read/write both tables via `execute_on_client()` tool calls
|
||||
- **Files:** `src/main/db/schema.ts`, `src/main/db/index.ts`, `src/main/api/drizzle-executor.ts`
|
||||
- **Outcome:** Two new persistent tables for chat and agent memory. Backend can access both via existing WS tool_call protocol.
|
||||
|
||||
### Step 5.2 — Chat and memory tRPC routers
|
||||
- [ ] Add `chatMessagesRouter` to `src/main/router/index.ts`:
|
||||
- `chatMessages.list` — query: `{ scope: string, limit?: number }` → messages ordered by `createdAt ASC`, default limit 50
|
||||
- `chatMessages.append` — mutation: `{ scope, role: 'user'|'assistant', content, error?: boolean }` → insert with UUID + timestamp
|
||||
- `chatMessages.clear` — mutation: `{ scope }` → delete all messages for that scope
|
||||
- [ ] Add `agentMemoryRouter` to `src/main/router/index.ts`:
|
||||
- `agentMemory.list` — query: `{ scope?, agentName? }` → all memories, filterable
|
||||
- `agentMemory.delete` — mutation: `{ id }` → delete single entry
|
||||
- `agentMemory.clearScope` — mutation: `{ scope }` → clear all memories for a scope
|
||||
- Purpose: UI display + user management of agent memories (reads/deletes only — writes come from backend agent tools)
|
||||
- [ ] Merge both into `appRouter`
|
||||
- **Files:** `src/main/router/index.ts`
|
||||
- **Outcome:** Renderer can list/clear chat history and view/manage agent memories.
|
||||
|
||||
### Step 5.3 — Orchestrator context enrichment
|
||||
- [ ] Update `buildChatContext()` in `src/main/ai/orchestrator.ts`:
|
||||
- Derive `scope` from context type + projectId (`'global'` or `'project:<uuid>'`)
|
||||
- Query last 20 chat messages from `chatMessages` table for that scope → map to `{ role, content }` → set as `conversationHistory` on the `ChatContext`
|
||||
- Query last 20 agent memories from `agentMemory` table for scope (global + project if project-scoped) → format as `"[key]: value"` strings → include in context
|
||||
- [ ] Extend `ChatContextSchema` in `src/shared/api-types.ts` with optional `agentMemories: z.array(z.object({ key: z.string(), value: z.string(), agentName: z.string() }))` field
|
||||
- **Files:** `src/main/ai/orchestrator.ts`, `src/shared/api-types.ts`
|
||||
- **Outcome:** Backend LLM sees multi-turn history and pre-loaded agent memories in every request.
|
||||
|
||||
### Step 5.4 — Renderer chat persistence
|
||||
- [ ] Update `src/renderer/hooks/useAIChat.ts`:
|
||||
- Compute `scope` from `defaultContext`: `'global'` or `'project:<projectId>'`
|
||||
- Seed `messages` state from `trpc.chatMessages.list.useQuery({ scope })` on mount
|
||||
- After each user message and assistant response, call `trpc.chatMessages.append.useMutation()` to persist
|
||||
- Update `clearMessages()` to also call `trpc.chatMessages.clear.mutate({ scope })`
|
||||
- [ ] Handle project deletion cleanup in `projectsRouter.delete`:
|
||||
- Delete `chatMessages` where `scope = 'project:<projectId>'`
|
||||
- Delete `agentMemory` where `scope = 'project:<projectId>'`
|
||||
- **Files:** `src/renderer/hooks/useAIChat.ts`, `src/main/router/index.ts`
|
||||
- **Outcome:** Chat messages persist across app restarts. Scope isolation between global and project threads. Project deletion cleans up associated memory.
|
||||
|
||||
### Design Decisions
|
||||
- **All memory in Electron SQLite** — local-first, E2E encrypted by default. Backend never stores user memories.
|
||||
- **Implicit sessions** — one chat thread per scope (`'global'` or `'project:<uuid>'`), no conversation list UI. Matches current UX.
|
||||
- **Scope encoding** — `'global'` or `'project:<uuid>'` as a text column. Simple, queryable, extensible to per-client etc.
|
||||
- **Upsert by (agentName, key, scope)** — prevents duplicate facts; agent can overwrite a preference without creating duplicates.
|
||||
- **Pre-load + recall tool** — 20 recent memories injected into context at conversation start; agents also have `recall_memories` tool for targeted mid-conversation lookup (backend Phase 5).
|
||||
- **Last 20 messages/memories** — balances context quality vs. token cost.
|
||||
- **No vector embedding of chat messages** — recency-based retrieval is the right fit for conversation history. Notes vector search stays as-is.
|
||||
|
||||
---
|
||||
|
||||
@@ -410,7 +471,6 @@ Key constraints:
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `ws` | WebSocket client for backend streaming |
|
||||
| `@journeyapps/sqlcipher` | Encrypted SQLite (replaces `better-sqlite3`) |
|
||||
| `argon2` | Key derivation for E2E backup |
|
||||
| `node-cron` | Batch agent scheduling |
|
||||
| `chokidar` | File watching (plugin) |
|
||||
|
||||
75
package-lock.json
generated
75
package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@trpc/react-query": "^11.10.0",
|
||||
"@trpc/server": "^11.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"argon2": "^0.44.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -2164,6 +2165,12 @@
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild-kit/core-utils": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
|
||||
@@ -5093,6 +5100,15 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@phc/format": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -9290,6 +9306,22 @@
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/argon2": {
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
|
||||
"integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@phc/format": "^1.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -10681,11 +10713,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -15631,7 +15679,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jest-worker": {
|
||||
@@ -18087,6 +18134,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz",
|
||||
"integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-api-version": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
|
||||
@@ -18139,6 +18195,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -18795,7 +18862,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -21471,7 +21537,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -21484,7 +21549,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -24047,7 +24111,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@trpc/react-query": "^11.10.0",
|
||||
"@trpc/server": "^11.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"argon2": "^0.44.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -108,6 +108,9 @@ export class BackendClient {
|
||||
private shouldReconnect = false;
|
||||
private reconnectAttempt = 0;
|
||||
|
||||
/** Optional callback fired when the persistent WS successfully connects. */
|
||||
private onConnectedCallback: (() => void) | null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
@@ -411,7 +414,103 @@ export class BackendClient {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Persistent device WebSocket (Step 3.5)
|
||||
// E2E Backup (Phase 4, Step 4.1)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Uploads an E2E-encrypted backup blob to the backend.
|
||||
* The body is raw bytes; metadata is passed via custom headers.
|
||||
*
|
||||
* @param blob - The packed ADV1 backup blob.
|
||||
* @param version - Monotonically increasing version number (unix seconds).
|
||||
* @param timestamp - Unix epoch milliseconds of the backup.
|
||||
* @param checksum - SHA-256 hex digest of the blob.
|
||||
*/
|
||||
async uploadBackup(
|
||||
blob: Buffer,
|
||||
version: number,
|
||||
timestamp: number,
|
||||
checksum: string,
|
||||
): Promise<{ ok: boolean }> {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/backup`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-Backup-Version': String(version),
|
||||
'X-Backup-Timestamp': String(timestamp),
|
||||
'X-Backup-Checksum': checksum,
|
||||
'Content-Length': String(blob.length),
|
||||
},
|
||||
body: new Uint8Array(blob),
|
||||
signal: AbortSignal.timeout(60_000), // large file may take time
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
if (res.status === 401) throw new AuthExpiredError();
|
||||
if (res.status === 402) throw new Error('Backup quota exceeded for your current plan.');
|
||||
if (res.status >= 500) throw new ServerError(text || res.statusText, res.status);
|
||||
throw new Error(`Backup upload failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the latest backup blob from the backend.
|
||||
* Returns null if no backup exists (404) or content is unchanged (304).
|
||||
*
|
||||
* @param ifModifiedSince - Optional: only download if newer than this Unix ms.
|
||||
*/
|
||||
async downloadBackup(
|
||||
ifModifiedSince?: number,
|
||||
): Promise<{ blob: Buffer; version: number; timestamp: number; checksum: string } | null> {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
if (ifModifiedSince !== undefined) {
|
||||
headers['If-Modified-Since'] = new Date(ifModifiedSince).toUTCString();
|
||||
}
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/backup`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
if (res.status === 304 || res.status === 404) return null;
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
if (res.status === 401) throw new AuthExpiredError();
|
||||
throw new Error(`Backup download failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const blob = Buffer.from(arrayBuffer);
|
||||
|
||||
const version = Number(res.headers.get('X-Backup-Version') ?? '0');
|
||||
const timestamp = Number(res.headers.get('X-Backup-Timestamp') ?? '0');
|
||||
const checksum = res.headers.get('X-Backup-Checksum') ?? '';
|
||||
|
||||
return { blob, version, timestamp, checksum };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be invoked each time the persistent device WS
|
||||
* successfully connects (including reconnects). Used by the sync queue
|
||||
* to replay queued operations when connectivity is restored.
|
||||
*/
|
||||
onConnected(callback: () => void): void {
|
||||
this.onConnectedCallback = callback;
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -478,6 +577,11 @@ export class BackendClient {
|
||||
this.reconnectAttempt = 0;
|
||||
console.log('[DeviceWS] Connected.');
|
||||
|
||||
// Notify connectivity listeners (e.g. sync queue)
|
||||
if (this.onConnectedCallback) {
|
||||
try { this.onConnectedCallback(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Fetch enabled local agent IDs bound to this device
|
||||
const deviceId = getDeviceId();
|
||||
let agentIds: string[] = [];
|
||||
|
||||
@@ -54,6 +54,13 @@ export class AuthManager {
|
||||
private static instance: AuthManager | null = null;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* User's account password held in memory only — never persisted to disk.
|
||||
* Set at login/register; cleared at logout. Used by BackupManager to
|
||||
* derive the AES encryption key for database backups.
|
||||
*/
|
||||
private _cachedPassword: string | null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
@@ -73,6 +80,7 @@ export class AuthManager {
|
||||
const data = await this.post<AuthTokens>('/api/v1/auth/register', { email, password });
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
this._cachedPassword = password;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@@ -81,11 +89,13 @@ export class AuthManager {
|
||||
const data = await this.post<AuthTokens>('/api/v1/auth/login', { email, password });
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
this._cachedPassword = password;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Clear all stored auth tokens. */
|
||||
async logout(): Promise<void> {
|
||||
this._cachedPassword = null;
|
||||
await Promise.all([
|
||||
deleteToken(TOKEN_KEYS.access),
|
||||
deleteToken(TOKEN_KEYS.refresh),
|
||||
@@ -93,6 +103,15 @@ export class AuthManager {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the password that was cached at login.
|
||||
* Used exclusively by BackupManager for key derivation — never persisted.
|
||||
* Returns null if the user has not logged in this session.
|
||||
*/
|
||||
getCachedPassword(): string | null {
|
||||
return this._cachedPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a valid access token, refreshing transparently if near expiry.
|
||||
* Returns `null` if not authenticated.
|
||||
|
||||
253
src/main/backup/backup-manager.ts
Normal file
253
src/main/backup/backup-manager.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* BackupManager — orchestrates E2E encrypted database backup and restore.
|
||||
*
|
||||
* Flow (create):
|
||||
* 1. Use better-sqlite3's `.backup()` to snapshot the live DB to a temp file
|
||||
* (WAL-safe, consistent snapshot).
|
||||
* 2. Read the snapshot into a Buffer; delete the temp file.
|
||||
* 3. Retrieve the user's cached password (set at login; never persisted).
|
||||
* 4. Generate a random 16-byte salt; derive a 256-bit AES key via Argon2id.
|
||||
* 5. Encrypt with AES-256-GCM; pack into the ADV1 blob format.
|
||||
* 6. Compute SHA-256 checksum of the packed blob.
|
||||
* 7. Upload to the backend. On offline failure: enqueue in sync_queue.
|
||||
* 8. Update `lastBackupAt` in electron-store.
|
||||
*
|
||||
* Flow (restore):
|
||||
* 1. Download the latest backup blob from the backend.
|
||||
* 2. Verify the SHA-256 checksum.
|
||||
* 3. Unpack → extract salt, IV, authTag, ciphertext.
|
||||
* 4. Derive key from cached password + embedded salt.
|
||||
* 5. Decrypt and authenticate.
|
||||
* 6. Verify the decrypted bytes start with the SQLite magic string.
|
||||
* 7. Close the current DB; atomically replace the file; reopen.
|
||||
* 8. Send `backup:restored` IPC event so the renderer refreshes all data.
|
||||
*
|
||||
* Security notes:
|
||||
* - The user's password is held in memory only (never written to disk).
|
||||
* - A fresh random 16-byte salt is generated per backup so the same
|
||||
* password always produces a different ciphertext.
|
||||
* - The backend treats the blob as opaque bytes and only verifies the
|
||||
* SHA-256 checksum before accepting the upload.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 4, Step 4.1
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { getStore } from '../store';
|
||||
import { getRawSqlite, closeDb, getDbPath, initDb } from '../db';
|
||||
import { getAuthManager } from '../auth/auth-manager';
|
||||
import {
|
||||
deriveKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
computeChecksum,
|
||||
packBackup,
|
||||
unpackBackup,
|
||||
} from './e2e-crypto';
|
||||
import type { BackupMetadata } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BackupResult {
|
||||
success: boolean;
|
||||
timestamp: number;
|
||||
checksum: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite magic bytes (first 16 bytes of every valid .db file)
|
||||
// ---------------------------------------------------------------------------
|
||||
const SQLITE_MAGIC = Buffer.from('SQLite format 3\x00');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackupManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BackupManager {
|
||||
private static instance: BackupManager | null = null;
|
||||
|
||||
private _periodicTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): BackupManager {
|
||||
if (!this.instance) this.instance = new BackupManager();
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates an E2E-encrypted backup of the local SQLite database and uploads
|
||||
* it to the backend.
|
||||
*
|
||||
* @throws if not authenticated, or if no password has been cached.
|
||||
*/
|
||||
async createBackup(): Promise<BackupResult> {
|
||||
const password = this._requirePassword();
|
||||
|
||||
// 1. Consistent snapshot via better-sqlite3 .backup()
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'adiuva-bk-'));
|
||||
const tempPath = path.join(tempDir, 'snapshot.db');
|
||||
|
||||
try {
|
||||
await getRawSqlite().backup(tempPath);
|
||||
|
||||
// 2. Read snapshot into memory
|
||||
const dbBuffer = await fs.readFile(tempPath);
|
||||
|
||||
// 3. Derive key with a fresh random salt
|
||||
const salt = crypto.randomBytes(16);
|
||||
const key = await deriveKey(password, salt);
|
||||
|
||||
// 4. Encrypt
|
||||
const { ciphertext, iv, authTag } = encrypt(dbBuffer, key);
|
||||
|
||||
// 5. Pack
|
||||
const blob = packBackup(ciphertext, iv, authTag, salt);
|
||||
|
||||
// 6. Checksum
|
||||
const checksum = computeChecksum(blob);
|
||||
|
||||
// 7. Upload
|
||||
const timestamp = Date.now();
|
||||
const version = Math.floor(timestamp / 1000); // incrementing unix seconds
|
||||
|
||||
const { getBackendClient } = await import('../api/backend-client');
|
||||
await getBackendClient().uploadBackup(blob, version, timestamp, checksum);
|
||||
|
||||
// 8. Persist last-backup timestamp
|
||||
getStore().set('lastBackupAt', timestamp);
|
||||
|
||||
return { success: true, timestamp, checksum, sizeBytes: blob.length };
|
||||
} catch (err) {
|
||||
// Enqueue for retry if offline
|
||||
const { OfflineError } = await import('../api/backend-client');
|
||||
if (err instanceof OfflineError) {
|
||||
const { getSyncQueue } = await import('./sync-queue');
|
||||
getSyncQueue().enqueue('backup', {});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the latest backup from the backend, decrypts it, and replaces
|
||||
* the local database file.
|
||||
*
|
||||
* After a successful restore the renderer is notified via the
|
||||
* `backup:restored` IPC event so it can reload all query caches.
|
||||
*/
|
||||
async restoreBackup(): Promise<void> {
|
||||
const password = this._requirePassword();
|
||||
|
||||
const { getBackendClient } = await import('../api/backend-client');
|
||||
const result = await getBackendClient().downloadBackup();
|
||||
if (!result) throw new Error('No backup found on the server.');
|
||||
|
||||
const { blob, checksum } = result;
|
||||
|
||||
// Verify integrity
|
||||
const actualChecksum = computeChecksum(blob);
|
||||
if (actualChecksum !== checksum) {
|
||||
throw new Error('Backup integrity check failed: checksum mismatch.');
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
const { salt, iv, authTag, ciphertext } = unpackBackup(blob);
|
||||
const key = await deriveKey(password, salt);
|
||||
const plaintext = decrypt(ciphertext, key, iv, authTag);
|
||||
|
||||
// Validate SQLite magic
|
||||
if (!plaintext.subarray(0, 16).equals(SQLITE_MAGIC)) {
|
||||
throw new Error('Restore failed: decrypted data is not a valid SQLite database.');
|
||||
}
|
||||
|
||||
// Atomic DB replacement
|
||||
const dbPath = getDbPath();
|
||||
const backupPath = dbPath + '.restore-tmp';
|
||||
await fs.writeFile(backupPath, plaintext);
|
||||
|
||||
closeDb();
|
||||
await fs.rename(backupPath, dbPath);
|
||||
initDb();
|
||||
|
||||
// Notify renderer to reload
|
||||
BrowserWindow.getAllWindows().forEach((w) => {
|
||||
w.webContents.send('backup:restored');
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns backup history metadata from the backend. */
|
||||
async getHistory(): Promise<BackupMetadata[]> {
|
||||
const { getBackendClient } = await import('../api/backend-client');
|
||||
return getBackendClient().proxyGet<BackupMetadata[]>('/api/v1/backup/history');
|
||||
}
|
||||
|
||||
/** Deletes a specific backup by ID. */
|
||||
async deleteBackup(id: string): Promise<void> {
|
||||
const { getBackendClient } = await import('../api/backend-client');
|
||||
await getBackendClient().proxyDelete(`/api/v1/backup/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Periodic backup scheduling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Starts a periodic backup timer based on the current store settings.
|
||||
* Safe to call multiple times — stops any existing timer first.
|
||||
*/
|
||||
schedulePeriodicBackup(): void {
|
||||
this.stopPeriodicBackup();
|
||||
const hours = getStore().get('backupIntervalHours') ?? 24;
|
||||
const ms = hours * 60 * 60 * 1000;
|
||||
this._periodicTimer = setInterval(() => {
|
||||
this.createBackup().catch((err) =>
|
||||
console.error('[Backup] Periodic backup failed:', err),
|
||||
);
|
||||
}, ms);
|
||||
console.log(`[Backup] Periodic backup scheduled every ${hours}h.`);
|
||||
}
|
||||
|
||||
stopPeriodicBackup(): void {
|
||||
if (this._periodicTimer !== null) {
|
||||
clearInterval(this._periodicTimer);
|
||||
this._periodicTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private _requirePassword(): string {
|
||||
const pw = getAuthManager().getCachedPassword();
|
||||
if (!pw) {
|
||||
throw new Error(
|
||||
'Backup encryption key unavailable. Please log in first.',
|
||||
);
|
||||
}
|
||||
return pw;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getBackupManager(): BackupManager {
|
||||
return BackupManager.getInstance();
|
||||
}
|
||||
160
src/main/backup/e2e-crypto.ts
Normal file
160
src/main/backup/e2e-crypto.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* E2E encryption primitives for local database backup.
|
||||
*
|
||||
* Encryption scheme:
|
||||
* - Key derivation : Argon2id (time=3, memory=64 MB, parallelism=4, 256-bit output)
|
||||
* - Cipher : AES-256-GCM with a random 12-byte IV per backup
|
||||
* - Integrity : SHA-256 checksum of the packed blob (matches backend's verify_checksum)
|
||||
*
|
||||
* Packed blob layout (binary, big-endian where applicable):
|
||||
* [4 bytes] magic "ADV1"
|
||||
* [16 bytes] Argon2 salt (random per backup)
|
||||
* [12 bytes] AES-GCM IV (random per backup)
|
||||
* [16 bytes] AES-GCM authTag
|
||||
* [N bytes] ciphertext
|
||||
*
|
||||
* The salt being embedded in the blob means a fresh random salt (and therefore
|
||||
* a fresh derived key) is used for every backup, even with the same password.
|
||||
*
|
||||
* The backend receives and stores the blob as opaque bytes — it never decrypts
|
||||
* and only verifies the SHA-256 checksum before accepting the upload.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 4, Step 4.1
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { hash as argon2Hash, argon2id } from 'argon2';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** ASCII magic bytes that identify a valid Adiuva backup blob. */
|
||||
const MAGIC = Buffer.from('ADV1');
|
||||
|
||||
const SALT_LEN = 16;
|
||||
const IV_LEN = 12;
|
||||
const AUTH_TAG_LEN = 16;
|
||||
const KEY_LEN = 32; // 256-bit AES key
|
||||
|
||||
// Argon2id parameters — follows OWASP recommended minimums.
|
||||
const ARGON2_TIME_COST = 3;
|
||||
const ARGON2_MEMORY_COST = 65_536; // 64 MB
|
||||
const ARGON2_PARALLELISM = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Derives a 256-bit AES key from `password` and `salt` using Argon2id.
|
||||
*
|
||||
* @param password - The user's account password (never persisted; cleared after use).
|
||||
* @param salt - 16-byte random salt (stored in the backup blob header).
|
||||
*/
|
||||
export async function deriveKey(password: string, salt: Buffer): Promise<Buffer> {
|
||||
const hash = await argon2Hash(password, {
|
||||
type: argon2id,
|
||||
salt,
|
||||
timeCost: ARGON2_TIME_COST,
|
||||
memoryCost: ARGON2_MEMORY_COST,
|
||||
parallelism: ARGON2_PARALLELISM,
|
||||
hashLength: KEY_LEN,
|
||||
raw: true,
|
||||
});
|
||||
return hash as Buffer;
|
||||
}
|
||||
|
||||
export interface EncryptResult {
|
||||
ciphertext: Buffer;
|
||||
iv: Buffer;
|
||||
authTag: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts `plaintext` with AES-256-GCM using the supplied `key`.
|
||||
* A cryptographically random 12-byte IV is generated for each call.
|
||||
*/
|
||||
export function encrypt(plaintext: Buffer, key: Buffer): EncryptResult {
|
||||
const iv = crypto.randomBytes(IV_LEN);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return { ciphertext, iv, authTag };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts and authenticates `ciphertext`.
|
||||
* Throws if the authTag is wrong (tampered data).
|
||||
*/
|
||||
export function decrypt(
|
||||
ciphertext: Buffer,
|
||||
key: Buffer,
|
||||
iv: Buffer,
|
||||
authTag: Buffer,
|
||||
): Buffer {
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SHA-256 hex digest of `data`.
|
||||
* Matches the checksum algorithm used by the backend (`hashlib.sha256`).
|
||||
*/
|
||||
export function computeChecksum(data: Buffer): string {
|
||||
return crypto.createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs all encrypted components into a single blob for upload.
|
||||
*
|
||||
* Layout: [magic][salt][iv][authTag][ciphertext]
|
||||
*/
|
||||
export function packBackup(
|
||||
ciphertext: Buffer,
|
||||
iv: Buffer,
|
||||
authTag: Buffer,
|
||||
salt: Buffer,
|
||||
): Buffer {
|
||||
return Buffer.concat([MAGIC, salt, iv, authTag, ciphertext]);
|
||||
}
|
||||
|
||||
export interface UnpackedBackup {
|
||||
salt: Buffer;
|
||||
iv: Buffer;
|
||||
authTag: Buffer;
|
||||
ciphertext: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a packed backup blob back into its components.
|
||||
* Throws if the magic bytes are missing or the blob is too short.
|
||||
*/
|
||||
export function unpackBackup(blob: Buffer): UnpackedBackup {
|
||||
const headerLen = MAGIC.length + SALT_LEN + IV_LEN + AUTH_TAG_LEN;
|
||||
if (blob.length < headerLen + 1) {
|
||||
throw new Error('Invalid backup blob: too short');
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
|
||||
const magic = blob.subarray(offset, offset + MAGIC.length);
|
||||
if (!magic.equals(MAGIC)) {
|
||||
throw new Error('Invalid backup blob: unrecognised format (bad magic bytes)');
|
||||
}
|
||||
offset += MAGIC.length;
|
||||
|
||||
const salt = Buffer.from(blob.subarray(offset, offset + SALT_LEN));
|
||||
offset += SALT_LEN;
|
||||
|
||||
const iv = Buffer.from(blob.subarray(offset, offset + IV_LEN));
|
||||
offset += IV_LEN;
|
||||
|
||||
const authTag = Buffer.from(blob.subarray(offset, offset + AUTH_TAG_LEN));
|
||||
offset += AUTH_TAG_LEN;
|
||||
|
||||
const ciphertext = Buffer.from(blob.subarray(offset));
|
||||
|
||||
return { salt, iv, authTag, ciphertext };
|
||||
}
|
||||
157
src/main/backup/sync-queue.ts
Normal file
157
src/main/backup/sync-queue.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* SyncQueue — persists failed backup attempts in SQLite and retries them
|
||||
* automatically when connectivity is restored.
|
||||
*
|
||||
* Current actions supported:
|
||||
* - 'backup': re-runs BackupManager.createBackup() on next online opportunity
|
||||
*
|
||||
* The queue stores *intent* (not data). When a backup is retried, a fresh
|
||||
* DB snapshot is taken so the latest data is always included.
|
||||
*
|
||||
* Retry policy: max 5 attempts per item; after that the item is marked
|
||||
* 'failed' and left for manual inspection.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 4, Step 4.2
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { syncQueue } from '../db/schema';
|
||||
import type { SyncQueueItem } from '../db/schema';
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
export class SyncQueue {
|
||||
private static instance: SyncQueue | null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SyncQueue {
|
||||
if (!this.instance) this.instance = new SyncQueue();
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Enqueue a failed action for retry when the device comes back online. */
|
||||
enqueue(action: string, payload: Record<string, unknown> = {}): void {
|
||||
try {
|
||||
getDb()
|
||||
.insert(syncQueue)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
action,
|
||||
payload: JSON.stringify(payload),
|
||||
status: 'pending',
|
||||
retries: 0,
|
||||
createdAt: Date.now(),
|
||||
lastAttemptAt: null,
|
||||
})
|
||||
.run();
|
||||
console.log(`[SyncQueue] Enqueued action: ${action}`);
|
||||
} catch (err) {
|
||||
console.error('[SyncQueue] Failed to enqueue action:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending queue items in order.
|
||||
* Called automatically when the persistent WebSocket (re)connects.
|
||||
*/
|
||||
async processQueue(): Promise<void> {
|
||||
let items: SyncQueueItem[];
|
||||
try {
|
||||
items = getDb()
|
||||
.select()
|
||||
.from(syncQueue)
|
||||
.where(eq(syncQueue.status, 'pending'))
|
||||
.all();
|
||||
} catch {
|
||||
// DB may not be initialized yet during early startup
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) return;
|
||||
console.log(`[SyncQueue] Processing ${items.length} pending item(s)…`);
|
||||
|
||||
for (const item of items) {
|
||||
await this._processItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns all items currently in the queue (for diagnostics). */
|
||||
getAll(): SyncQueueItem[] {
|
||||
try {
|
||||
return getDb().select().from(syncQueue).all();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async _processItem(item: SyncQueueItem): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
|
||||
// Bump attempt count immediately
|
||||
db.update(syncQueue)
|
||||
.set({ retries: item.retries + 1, lastAttemptAt: now })
|
||||
.where(eq(syncQueue.id, item.id))
|
||||
.run();
|
||||
|
||||
try {
|
||||
await this._dispatch(item.action, JSON.parse(item.payload) as Record<string, unknown>);
|
||||
|
||||
// Success — remove from queue
|
||||
db.delete(syncQueue).where(eq(syncQueue.id, item.id)).run();
|
||||
console.log(`[SyncQueue] Action '${item.action}' succeeded.`);
|
||||
} catch (err) {
|
||||
const newRetries = item.retries + 1;
|
||||
if (newRetries >= MAX_RETRIES) {
|
||||
db.update(syncQueue)
|
||||
.set({ status: 'failed', lastAttemptAt: now })
|
||||
.where(eq(syncQueue.id, item.id))
|
||||
.run();
|
||||
console.error(
|
||||
`[SyncQueue] Action '${item.action}' permanently failed after ${MAX_RETRIES} attempts:`,
|
||||
err,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[SyncQueue] Action '${item.action}' failed (attempt ${newRetries}/${MAX_RETRIES}):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _dispatch(
|
||||
action: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'backup': {
|
||||
void payload; // payload reserved for future per-action metadata
|
||||
const { getBackupManager } = await import('./backup-manager');
|
||||
await getBackupManager().createBackup();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown sync queue action: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSyncQueue(): SyncQueue {
|
||||
return SyncQueue.getInstance();
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import { app } from 'electron';
|
||||
import path from 'node:path';
|
||||
import * as schema from './schema';
|
||||
|
||||
/** Resolved path to the SQLite database file. Set once in initDb(). */
|
||||
let _dbPath: string | null = null;
|
||||
|
||||
/** Raw better-sqlite3 instance (needed for .backup() API). */
|
||||
let _rawSqlite: Database.Database | null = null;
|
||||
|
||||
// SQL to create all tables if they don't exist (non-destructive push strategy)
|
||||
const MIGRATION_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
@@ -63,6 +69,16 @@ const MIGRATION_SQL = `
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
retries INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_attempt_at INTEGER
|
||||
);
|
||||
`;
|
||||
|
||||
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
||||
@@ -71,12 +87,14 @@ let dbInstance: DbInstance | null = null;
|
||||
|
||||
export function initDb(): DbInstance {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dbPath = path.join(userDataPath, 'adiuva.db');
|
||||
_dbPath = path.join(userDataPath, 'adiuva.db');
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
const sqlite = new Database(_dbPath);
|
||||
_rawSqlite = sqlite;
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('synchronous = NORMAL');
|
||||
|
||||
// Run non-destructive migrations on every start
|
||||
sqlite.exec(MIGRATION_SQL);
|
||||
@@ -95,3 +113,31 @@ export function getDb(): DbInstance {
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/** Returns the absolute path to the active SQLite database file. */
|
||||
export function getDbPath(): string {
|
||||
if (!_dbPath) throw new Error('Database not initialized.');
|
||||
return _dbPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw better-sqlite3 Database instance.
|
||||
* Used by BackupManager for the `.backup()` API.
|
||||
*/
|
||||
export function getRawSqlite(): Database.Database {
|
||||
if (!_rawSqlite) throw new Error('Database not initialized.');
|
||||
return _rawSqlite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection and clears all module-level references.
|
||||
* Called by BackupManager before atomically replacing the DB file.
|
||||
* After calling this, you must call `initDb()` again to re-open.
|
||||
*/
|
||||
export function closeDb(): void {
|
||||
if (_rawSqlite) {
|
||||
try { _rawSqlite.close(); } catch { /* ignore */ }
|
||||
_rawSqlite = null;
|
||||
}
|
||||
dbInstance = null;
|
||||
}
|
||||
|
||||
@@ -77,3 +77,19 @@ export type NewNote = InferInsertModel<typeof notes>;
|
||||
|
||||
export type TaskComment = InferSelectModel<typeof taskComments>;
|
||||
export type NewTaskComment = InferInsertModel<typeof taskComments>;
|
||||
|
||||
export const syncQueue = sqliteTable('sync_queue', {
|
||||
id: text('id').primaryKey(),
|
||||
/** Action to retry (currently only 'backup'). */
|
||||
action: text('action').notNull(),
|
||||
/** JSON-serialised metadata for the queued action. */
|
||||
payload: text('payload').notNull().default('{}'),
|
||||
/** 'pending' while waiting to run; 'failed' after max retries exhausted. */
|
||||
status: text('status', { enum: ['pending', 'failed'] }).notNull().default('pending'),
|
||||
retries: integer('retries', { mode: 'number' }).notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
lastAttemptAt: integer('last_attempt_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export type SyncQueueItem = InferSelectModel<typeof syncQueue>;
|
||||
export type NewSyncQueueItem = InferInsertModel<typeof syncQueue>;
|
||||
|
||||
@@ -7,6 +7,9 @@ import { createIPCHandler } from './ipc';
|
||||
import { initVectorDb, migrateNotesIfNeeded } from './db/vectordb';
|
||||
import { getAuthManager } from './auth/auth-manager';
|
||||
import { getBackendClient } from './api/backend-client';
|
||||
import { getBackupManager } from './backup/backup-manager';
|
||||
import { getSyncQueue } from './backup/sync-queue';
|
||||
import { getStore } from './store';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
@@ -64,17 +67,31 @@ app.on('ready', () => {
|
||||
.then(() => migrateNotesIfNeeded())
|
||||
.catch((err) => console.error('[VectorDB] Init or migration failed:', err));
|
||||
|
||||
// Register sync-queue callback: process queued backup ops on reconnect
|
||||
getBackendClient().onConnected(() => {
|
||||
getSyncQueue().processQueue().catch((err) =>
|
||||
console.error('[SyncQueue] processQueue error:', err),
|
||||
);
|
||||
});
|
||||
|
||||
// Persistent device WebSocket for agent triggers — best-effort on startup
|
||||
getAuthManager()
|
||||
.isAuthenticated()
|
||||
.then((authenticated) => {
|
||||
if (authenticated) return getBackendClient().connectPersistent();
|
||||
if (authenticated) {
|
||||
void getBackendClient().connectPersistent();
|
||||
// Start periodic backup if enabled
|
||||
if (getStore().get('backupEnabled')) {
|
||||
getBackupManager().schedulePeriodicBackup();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
|
||||
});
|
||||
|
||||
// Clean up the persistent WS before the app exits
|
||||
// Clean up the persistent WS and backup timers before the app exits
|
||||
app.on('will-quit', () => {
|
||||
getBackupManager().stopPeriodicBackup();
|
||||
getBackendClient().disconnectPersistent();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import { getDb } from '../db';
|
||||
import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema';
|
||||
import { getStore, getDeviceId } from '../store';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
import type { AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog, JourneyMessage } from '../../shared/api-types';
|
||||
import type { AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog, JourneyMessage, BackupMetadata } from '../../shared/api-types';
|
||||
import { orchestrate, dailyBrief } from '../ai/orchestrator';
|
||||
import { upsertNoteEmbedding } from '../db/vectordb';
|
||||
import { getAuthManager, AuthError } from '../auth/auth-manager';
|
||||
import { getBackupManager } from '../backup/backup-manager';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
@@ -849,6 +850,8 @@ const authRouter = router({
|
||||
await auth.login(input.email, input.password);
|
||||
// Connect persistent device WS now that we have a valid token
|
||||
void getBackendClient().connectPersistent();
|
||||
// Start periodic backup if the user has it enabled
|
||||
if (getStore().get('backupEnabled')) getBackupManager().schedulePeriodicBackup();
|
||||
return { success: true as const, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||
@@ -859,7 +862,8 @@ const authRouter = router({
|
||||
logout: publicProcedure.mutation(async () => {
|
||||
const auth = getAuthManager();
|
||||
await auth.logout();
|
||||
// Disconnect persistent device WS — stops agent triggers until next login
|
||||
// Stop backup scheduler and disconnect WS
|
||||
getBackupManager().stopPeriodicBackup();
|
||||
getBackendClient().disconnectPersistent();
|
||||
return { success: true as const };
|
||||
}),
|
||||
@@ -896,6 +900,88 @@ const authRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup router — E2E encrypted database backup (Phase 4, Step 4.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const backupRouter = router({
|
||||
/** Trigger a manual backup immediately. */
|
||||
create: publicProcedure.mutation(async () => {
|
||||
try {
|
||||
const result = await getBackupManager().createBackup();
|
||||
return { success: true as const, error: null, timestamp: result.timestamp, checksum: result.checksum, sizeBytes: result.sizeBytes };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Backup failed';
|
||||
return { success: false as const, error: msg, timestamp: 0, checksum: '', sizeBytes: 0 };
|
||||
}
|
||||
}),
|
||||
|
||||
/** Restore the latest backup from the backend (destructive — replaces local DB). */
|
||||
restore: publicProcedure.mutation(async () => {
|
||||
try {
|
||||
await getBackupManager().restoreBackup();
|
||||
return { success: true as const, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Restore failed';
|
||||
return { success: false as const, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
/** List backup history metadata (no blob bytes). */
|
||||
history: publicProcedure.query(async () => {
|
||||
try {
|
||||
return await getBackupManager().getHistory() as BackupMetadata[];
|
||||
} catch {
|
||||
return [] as BackupMetadata[];
|
||||
}
|
||||
}),
|
||||
|
||||
/** Delete a specific backup by ID. */
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await getBackupManager().deleteBackup(input.id);
|
||||
return { success: true as const, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Delete failed';
|
||||
return { success: false as const, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
/** Returns current backup settings from the store. */
|
||||
settings: publicProcedure.query(() => {
|
||||
const store = getStore();
|
||||
return {
|
||||
backupEnabled: store.get('backupEnabled'),
|
||||
backupIntervalHours: store.get('backupIntervalHours'),
|
||||
lastBackupAt: store.get('lastBackupAt'),
|
||||
};
|
||||
}),
|
||||
|
||||
/** Updates backup settings and restarts the periodic backup timer. */
|
||||
updateSettings: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
backupEnabled: z.boolean().optional(),
|
||||
backupIntervalHours: z.number().int().min(1).max(168).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
const store = getStore();
|
||||
const bm = getBackupManager();
|
||||
if (input.backupEnabled !== undefined) store.set('backupEnabled', input.backupEnabled);
|
||||
if (input.backupIntervalHours !== undefined)
|
||||
store.set('backupIntervalHours', input.backupIntervalHours);
|
||||
|
||||
// Restart timer with updated settings
|
||||
bm.stopPeriodicBackup();
|
||||
if (store.get('backupEnabled')) bm.schedulePeriodicBackup();
|
||||
|
||||
return { success: true as const };
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
settings: settingsRouter,
|
||||
@@ -908,6 +994,7 @@ export const appRouter = router({
|
||||
ai: aiRouter,
|
||||
auth: authRouter,
|
||||
agent: agentRouter,
|
||||
backup: backupRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -12,6 +12,12 @@ interface AppSettings {
|
||||
* configured on (Step 3.3).
|
||||
*/
|
||||
deviceId: string;
|
||||
/** Whether automatic periodic backup is enabled. */
|
||||
backupEnabled: boolean;
|
||||
/** How often to run an automatic backup (hours). Default 24. */
|
||||
backupIntervalHours: number;
|
||||
/** Unix epoch ms of the last successful backup upload. Null if none. */
|
||||
lastBackupAt: number | null;
|
||||
}
|
||||
|
||||
let _store: Store<AppSettings> | null = null;
|
||||
@@ -25,6 +31,9 @@ export function getStore(): Store<AppSettings> {
|
||||
userName: 'there',
|
||||
backendUrl: 'http://localhost:8000',
|
||||
deviceId: '',
|
||||
backupEnabled: false,
|
||||
backupIntervalHours: 24,
|
||||
lastBackupAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user