refactor: remove backup, storage, and plugin types from Electron app

- Delete src/main/backup/ (backup-manager, e2e-crypto, sync-queue)
- Remove backup lifecycle from index.ts and router
- Remove syncQueue table from db/schema.ts
- Remove backupEnabled/backupIntervalHours/lastBackupAt from store
- Remove uploadBackup/downloadBackup from backend-client
- Update embed URL to /api/v1/chat/embed
- Remove PluginListing, InstalledPlugin from batch-types
- Remove PermissionGrant, BackupMetadata from api-types
This commit is contained in:
2026-04-08 00:48:00 +02:00
parent 3ae9e450be
commit bd9af5ddd6
10 changed files with 7 additions and 854 deletions

View File

@@ -11,7 +11,7 @@
* `final` / `ping` frames → Client handles `tool_call` via DrizzleExecutor
* and sends back `tool_result`.
*
* - Embeddings: POST `/api/v1/storage/vectors/embed`
* - Embeddings: POST `/api/v1/chat/embed`
* - Health: GET `/api/v1/health`
*
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.3
@@ -198,9 +198,6 @@ export class BackendClient {
private shouldReconnect = false;
private reconnectAttempt = 0;
/** Optional callback fired when the persistent WS successfully connects. */
private onConnectedCallback: (() => void) | null = null;
/** V3 stream listeners keyed by requestId. */
private streamListeners: Map<string, StreamListener> = new Map();
@@ -258,8 +255,8 @@ export class BackendClient {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
logHttp('POST', `${this.baseUrl}/api/v1/storage/vectors/embed`, { text: text.slice(0, 80) + '…' });
const res = await fetch(`${this.baseUrl}/api/v1/storage/vectors/embed`, {
logHttp('POST', `${this.baseUrl}/api/v1/chat/embed`, { text: text.slice(0, 80) + '…' });
const res = await fetch(`${this.baseUrl}/api/v1/chat/embed`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -269,7 +266,7 @@ export class BackendClient {
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
logHttpResponse('POST', `${this.baseUrl}/api/v1/storage/vectors/embed`, res.status);
logHttpResponse('POST', `${this.baseUrl}/api/v1/chat/embed`, res.status);
await this.assertHttpOk(res);
const data = toCamelCase<{ vector: number[] }>(await res.json());
return data.vector;
@@ -555,104 +552,6 @@ export class BackendClient {
);
}
// -------------------------------------------------------------------------
// 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;
}
// -------------------------------------------------------------------------
/**
@@ -719,11 +618,6 @@ 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 */ }
}
// Read enabled local agent IDs from local storage
const deviceId = getDeviceId();
const agentIds = getLocalAgents()

View File

@@ -1,253 +0,0 @@
/**
* 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();
}

View File

@@ -1,156 +0,0 @@
/**
* E2E encryption primitives for local database backup.
*
* Encryption scheme:
* - Key derivation : scrypt (N=2^17, r=8, p=1, 256-bit output) via Node.js built-in crypto
* - 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] scrypt 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';
// ---------------------------------------------------------------------------
// 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
// scrypt parameters — follows OWASP recommended minimums.
// N=2^17 (131072), r=8, p=1 — ~64 MB memory, strong against GPU attacks.
const SCRYPT_N = 131_072;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Derives a 256-bit AES key from `password` and `salt` using scrypt.
*
* @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 function deriveKey(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }, (err, key) => {
if (err) reject(err);
else resolve(key);
});
});
}
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 };
}

View File

@@ -1,157 +0,0 @@
/**
* 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();
}

View File

@@ -103,19 +103,3 @@ export type AgentRun = InferSelectModel<typeof agentRuns>;
export type NewAgentRun = InferInsertModel<typeof agentRuns>;
export type AgentRunAction = InferSelectModel<typeof agentRunActions>;
export type NewAgentRunAction = InferInsertModel<typeof agentRunActions>;
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>;

View File

@@ -7,8 +7,6 @@ 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';
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
@@ -69,23 +67,12 @@ 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) {
void getBackendClient().connectPersistent();
// Start periodic backup if enabled
if (getStore().get('backupEnabled')) {
getBackupManager().schedulePeriodicBackup();
}
}
})
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
@@ -98,7 +85,6 @@ app.on('ready', () => {
app.on('will-quit', () => {
stopBriefScheduler();
stopAgentScheduler();
getBackupManager().stopPeriodicBackup();
getBackendClient().disconnectPersistent();
});

View File

@@ -7,11 +7,10 @@ import { clients, projects, tasks, timelineEvents, notes, taskComments, agentRun
import { getStore, getDeviceId, getLocalAgents, getLocalAgent, saveLocalAgent, deleteLocalAgent } from '../store';
import type { LocalAgentLocalConfig } from '../store';
import { getBackendClient } from '../api/backend-client';
import type { AgentCatalogItem, CloudAgentConfig, AgentRunLog, BackupMetadata } from '../../shared/api-types';
import type { AgentCatalogItem, CloudAgentConfig, AgentRunLog } from '../../shared/api-types';
import { orchestrate, orchestrateFloating, dailyBrief, getCachedBrief, invalidateBriefCache } 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();
@@ -1038,8 +1037,6 @@ 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';
@@ -1050,8 +1047,7 @@ const authRouter = router({
logout: publicProcedure.mutation(async () => {
const auth = getAuthManager();
await auth.logout();
// Stop backup scheduler and disconnect WS
getBackupManager().stopPeriodicBackup();
// Disconnect persistent WS
getBackendClient().disconnectPersistent();
return { success: true as const };
}),
@@ -1070,9 +1066,8 @@ const authRouter = router({
// Token stored but backend unreachable or token invalid
if (err instanceof AuthError && err.statusCode === 401) {
await auth.logout();
// Also tear down persistent WS and backup scheduler
// Also tear down persistent WS
getBackendClient().disconnectPersistent();
getBackupManager().stopPeriodicBackup();
return { authenticated: false as const, profile: null };
}
return { authenticated: true as const, profile: null };
@@ -1099,88 +1094,6 @@ 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,
@@ -1193,7 +1106,6 @@ export const appRouter = router({
ai: aiRouter,
auth: authRouter,
agent: agentRouter,
backup: backupRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -30,12 +30,6 @@ interface AppSettings {
* configured on.
*/
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;
/** Cached daily brief — regenerated once per day or when relevant data changes. */
dailyBriefCache: { content: string; date: string } | null;
/** Locally-managed agent configurations. */
@@ -52,9 +46,6 @@ export function getStore(): Store<AppSettings> {
encryptedTokens: {},
backendUrl: 'http://localhost:8000',
deviceId: '',
backupEnabled: false,
backupIntervalHours: 24,
lastBackupAt: null,
dailyBriefCache: null,
localAgents: [],
},

View File

@@ -275,14 +275,6 @@ export const AgentManifestSchema = z.object({
});
export type AgentManifest = z.infer<typeof AgentManifestSchema>;
export const PermissionGrantSchema = z.object({
plugin: z.string(),
permissionType: z.string(),
resourcePath: z.string(),
grantedAt: z.string().datetime(),
});
export type PermissionGrant = z.infer<typeof PermissionGrantSchema>;
// ---------------------------------------------------------------------------
// Agent REST API — response types (Phase 3, Step 3.4)
// ---------------------------------------------------------------------------
@@ -357,16 +349,3 @@ export const JourneyMessageSchema = z.object({
promptTemplate: z.string().optional(),
});
export type JourneyMessage = z.infer<typeof JourneyMessageSchema>;
// ---------------------------------------------------------------------------
// Backup
// ---------------------------------------------------------------------------
export const BackupMetadataSchema = z.object({
version: z.number().int(),
/** Unix epoch seconds (matches backend `timestamp: int`). */
timestamp: z.number().int(),
checksum: z.string(),
chunkCount: z.number().int(),
});
export type BackupMetadata = z.infer<typeof BackupMetadataSchema>;

View File

@@ -123,33 +123,6 @@ export const BatchRunResultSchema = z.object({
});
export type BatchRunResult = z.infer<typeof BatchRunResultSchema>;
// ---------------------------------------------------------------------------
// Plugin Marketplace
// ---------------------------------------------------------------------------
export const PluginListingSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
author: z.string(),
version: z.string(),
rating: z.number().min(0).max(5),
installs: z.number().int().min(0),
category: z.string(),
permissions: z.array(z.string()),
/** Price in cents. `0` means free. */
price: z.number().int().min(0),
});
export type PluginListing = z.infer<typeof PluginListingSchema>;
export const InstalledPluginSchema = z.object({
listing: PluginListingSchema,
installedAt: z.string().datetime(),
enabled: z.boolean(),
storageConfig: BatchStorageSchema,
});
export type InstalledPlugin = z.infer<typeof InstalledPluginSchema>;
// ---------------------------------------------------------------------------
// Data Manager
// ---------------------------------------------------------------------------