diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index d1c4dcc..df82c01 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -20,6 +20,9 @@ import { remediateTransaction, emptyCashbox, getCashbox, + getPendingCommand, + markCommandExecuting, + completeCommand, } from './state-store.js' import { initializeHal, type HalInstance } from './hal-service.js' @@ -348,6 +351,93 @@ ipcMain.handle('hal:cleanup', async () => { } }) +// ============================================================================= +// Operator Command Queue Poller +// Watches for pending commands inserted by the TUI or other local tools. +// ============================================================================= + +let commandPollInterval: ReturnType | null = null + +function startCommandPoller(): void { + commandPollInterval = setInterval(async () => { + try { + const cmd = getPendingCommand() + if (!cmd) return + + markCommandExecuting(cmd.id) + console.log('[CommandQueue] Executing command:', cmd.id, cmd.command) + + try { + const parsed = JSON.parse(cmd.command) as { + action: string + bills?: { denomination: number; count: number }[] + ref_txid?: string + } + + if (parsed.action !== 'dispense') { + completeCommand( + cmd.id, + JSON.stringify({ error: `Unknown action: ${parsed.action}` }), + true + ) + return + } + + if (!halInstance) { + completeCommand(cmd.id, JSON.stringify({ error: 'HAL not initialized' }), true) + return + } + + if (!parsed.bills || parsed.bills.length === 0) { + completeCommand(cmd.id, JSON.stringify({ error: 'No bills specified' }), true) + return + } + + const result = await halInstance.dispenseCash(parsed.bills) + const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const totalFiatCents = parsed.bills.reduce((s, b) => s + b.denomination * b.count * 100, 0) + + recordTransaction({ + txid, + type: 'manual_dispense', + status: result.dispensed ? 'complete' : 'dispense_error', + fiatCents: totalFiatCents, + sats: 0, + feeSats: 0, + feePercent: 0, + exchangeRate: 0, + currency: 'GTQ', + bills: parsed.bills, + cassettes: result.cassettes, + error: result.error, + }) + + let refRemediated = false + if (parsed.ref_txid) { + refRemediated = remediateTransaction(parsed.ref_txid, txid) + } + + completeCommand( + cmd.id, + JSON.stringify({ + txid, + dispensed: result.dispensed, + ref_remediated: refRemediated, + error: result.error, + }) + ) + console.log('[CommandQueue] Command complete:', cmd.id, 'txid:', txid) + } catch (error) { + const msg = error instanceof Error ? error.message : 'Command failed' + completeCommand(cmd.id, JSON.stringify({ error: msg }), true) + console.error('[CommandQueue] Command failed:', cmd.id, msg) + } + } catch (e) { + // Don't crash the poller on DB errors + } + }, 2000) +} + // App lifecycle app.whenReady().then(() => { // Enforce Content Security Policy via HTTP headers (defense-in-depth alongside meta tag) @@ -413,6 +503,7 @@ app.whenReady().then(() => { createWindow() startWatchdog() + startCommandPoller() app.on('activate', () => { // macOS: re-create window when dock icon clicked @@ -424,6 +515,7 @@ app.whenReady().then(() => { app.on('window-all-closed', () => { if (heartbeatInterval) clearInterval(heartbeatInterval) + if (commandPollInterval) clearInterval(commandPollInterval) closeDatabase() // Quit on all platforms (ATM doesn't need macOS dock behavior) app.quit() diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 91c469e..1f7b41b 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -95,6 +95,15 @@ export function initDatabase(dbPath?: string): void { dispensed INTEGER NOT NULL DEFAULT 0, rejected INTEGER NOT NULL DEFAULT 0 ); + + CREATE TABLE IF NOT EXISTS operator_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + command TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + result TEXT, + created_at INTEGER NOT NULL, + completed_at INTEGER + ); `) // Seed meta + cashbox if first run, or run migrations @@ -170,8 +179,20 @@ export function initDatabase(dbPath?: string): void { DROP TABLE transactions; ALTER TABLE transactions_new RENAME TO transactions; `) + db.exec(` + CREATE TABLE IF NOT EXISTS operator_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + command TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + result TEXT, + created_at INTEGER NOT NULL, + completed_at INTEGER + ); + `) db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('5', 'schema_version') - console.log('[StateStore] Migrated schema v4 → v5 (added manual_dispense type, remediated_by)') + console.log( + '[StateStore] Migrated schema v4 → v5 (added manual_dispense type, remediated_by, operator_commands)' + ) } const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get() @@ -401,6 +422,56 @@ export function recordTransaction(tx: TransactionInput): void { console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`) } +// --------------------------------------------------------------------------- +// Operator Command Queue +// --------------------------------------------------------------------------- + +export interface OperatorCommand { + id: number + command: string + status: string + created_at: number +} + +/** + * Get the next pending operator command (FIFO). + */ +export function getPendingCommand(): OperatorCommand | null { + if (!db) throw new Error('Database not initialized') + + const row = db + .prepare( + "SELECT id, command, status, created_at FROM operator_commands WHERE status = 'pending' ORDER BY id LIMIT 1" + ) + .get() as { id: number; command: string; status: string; created_at: number } | undefined + + return row ?? null +} + +/** + * Mark a command as executing (prevents double-pickup). + */ +export function markCommandExecuting(id: number): void { + if (!db) throw new Error('Database not initialized') + db.prepare( + "UPDATE operator_commands SET status = 'executing' WHERE id = ? AND status = 'pending'" + ).run(id) +} + +/** + * Complete a command with a result. + */ +export function completeCommand(id: number, result: string, error?: boolean): void { + if (!db) throw new Error('Database not initialized') + db.prepare( + 'UPDATE operator_commands SET status = ?, result = ?, completed_at = ? WHERE id = ?' + ).run(error ? 'error' : 'complete', result, Date.now(), id) +} + +// --------------------------------------------------------------------------- +// Transaction Remediation +// --------------------------------------------------------------------------- + /** * Mark a failed transaction as remediated by a manual dispense. * Only updates transactions with status 'dispense_error' or 'partial'.