feat: operator command queue for local TUI dispense

Add operator_commands table and polling loop so the TUI (or other local
tools) can trigger manual dispenses by inserting a command row into
SQLite. The Electron main process polls every 2s, executes pending
commands via HAL, records the transaction, and updates the command
status with the result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-23 01:12:29 -04:00
commit 89b0ceaa73
2 changed files with 164 additions and 1 deletions

View file

@ -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<typeof setInterval> | 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()