feat(actions): tiered action service (safe-run / risky-queue / approve)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 21:40:50 +10:00
parent a186116c4d
commit 62113f37e6
2 changed files with 86 additions and 0 deletions

43
lib/actions/service.js Normal file
View File

@@ -0,0 +1,43 @@
import * as aa from '../db/repos/agent_actions.js';
import { loadActions } from './registry.js';
import { powerGuest } from './channels/proxmox.js';
import { restartService } from './channels/ssh.js';
// Single choke point. Dispatches one whitelisted action to its channel. The
// registry + channels are injectable for tests; production wiring uses defaults.
export function makeActionService({ registry = loadActions(), channels = { powerGuest, restartService } } = {}) {
async function execute(a) {
if (a.kind === 'guest_power') return channels.powerGuest({ node: a.node, vmid: a.vmid, op: a.op, kindPath: a.kindPath || 'lxc' });
if (a.kind === 'service_restart') return channels.restartService({ ip: registry.hostIp(a.host), actionId: a.id });
throw new Error(`unknown kind: ${a.kind}`);
}
async function run(actionId, actor, agent_id = null) {
const a = registry.get(actionId);
if (!a) throw new Error(`unknown action: ${actionId}`);
if (a.tier === 'risky') {
const row = await aa.create({ action_id: a.id, tier: a.tier, params: {}, agent_id, requested_by: actor });
return { queued: true, action_row_id: row.id };
}
const result = await execute(a);
const row = await aa.create({ action_id: a.id, tier: a.tier, agent_id, requested_by: actor });
await aa.resolve(row.id, 'executed', result, actor);
return { executed: true, result };
}
async function approve(rowId, owner) {
const row = await aa.getById(rowId);
if (!row || row.status !== 'pending') throw new Error('not a pending action');
const a = registry.get(row.action_id);
if (!a) throw new Error(`unknown action: ${row.action_id}`);
try {
const result = await execute(a);
return aa.resolve(rowId, 'executed', result, owner);
} catch (e) {
return aa.resolve(rowId, 'failed', { error: String(e?.message || e) }, owner);
}
}
const reject = (rowId, owner) => aa.resolve(rowId, 'rejected', null, owner);
return { run, approve, reject, list: () => registry.list() };
}