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:
parent
01e71b0954
commit
89b0ceaa73
2 changed files with 164 additions and 1 deletions
|
|
@ -20,6 +20,9 @@ import {
|
||||||
remediateTransaction,
|
remediateTransaction,
|
||||||
emptyCashbox,
|
emptyCashbox,
|
||||||
getCashbox,
|
getCashbox,
|
||||||
|
getPendingCommand,
|
||||||
|
markCommandExecuting,
|
||||||
|
completeCommand,
|
||||||
} from './state-store.js'
|
} from './state-store.js'
|
||||||
import { initializeHal, type HalInstance } from './hal-service.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 lifecycle
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
// Enforce Content Security Policy via HTTP headers (defense-in-depth alongside meta tag)
|
// Enforce Content Security Policy via HTTP headers (defense-in-depth alongside meta tag)
|
||||||
|
|
@ -413,6 +503,7 @@ app.whenReady().then(() => {
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
startWatchdog()
|
startWatchdog()
|
||||||
|
startCommandPoller()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
// macOS: re-create window when dock icon clicked
|
// macOS: re-create window when dock icon clicked
|
||||||
|
|
@ -424,6 +515,7 @@ app.whenReady().then(() => {
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (heartbeatInterval) clearInterval(heartbeatInterval)
|
if (heartbeatInterval) clearInterval(heartbeatInterval)
|
||||||
|
if (commandPollInterval) clearInterval(commandPollInterval)
|
||||||
closeDatabase()
|
closeDatabase()
|
||||||
// Quit on all platforms (ATM doesn't need macOS dock behavior)
|
// Quit on all platforms (ATM doesn't need macOS dock behavior)
|
||||||
app.quit()
|
app.quit()
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,15 @@ export function initDatabase(dbPath?: string): void {
|
||||||
dispensed INTEGER NOT NULL DEFAULT 0,
|
dispensed INTEGER NOT NULL DEFAULT 0,
|
||||||
rejected 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
|
// Seed meta + cashbox if first run, or run migrations
|
||||||
|
|
@ -170,8 +179,20 @@ export function initDatabase(dbPath?: string): void {
|
||||||
DROP TABLE transactions;
|
DROP TABLE transactions;
|
||||||
ALTER TABLE transactions_new RENAME TO 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')
|
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()
|
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})`)
|
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.
|
* Mark a failed transaction as remediated by a manual dispense.
|
||||||
* Only updates transactions with status 'dispense_error' or 'partial'.
|
* Only updates transactions with status 'dispense_error' or 'partial'.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue