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

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import { fileURLToPath } from 'url';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as aa from '../../lib/db/repos/agent_actions.js';
import { makeActionService } from '../../lib/actions/service.js';
import { loadActions } from '../../lib/actions/registry.js';
const FIX = fileURLToPath(new URL('../fixtures/actions.test.json', import.meta.url));
const owner = { kind: 'user', id: null };
let svc, channels;
beforeAll(async () => { await resetDb(); await migrateUp(); });
beforeEach(() => {
channels = { powerGuest: vi.fn(async () => ({ ok: true, upid: 'U' })), restartService: vi.fn(async () => ({ ok: true, output: 'done' })) };
svc = makeActionService({ registry: loadActions(FIX), channels });
});
describe('action service', () => {
it('safe action executes immediately + audits', async () => {
const out = await svc.run('restart-caddy-ct100', owner);
expect(out.executed).toBe(true);
expect(channels.restartService).toHaveBeenCalledOnce();
});
it('risky action queues, does NOT execute', async () => {
const out = await svc.run('stop-ct107', owner);
expect(out.queued).toBe(true);
expect(channels.powerGuest).not.toHaveBeenCalled();
expect((await aa.listPending()).some(r => r.id === out.action_row_id)).toBe(true);
});
it('approve executes the queued risky action; reject does not', async () => {
const q = await svc.run('stop-ct107', owner);
const done = await svc.approve(q.action_row_id, owner);
expect(done.status).toBe('executed');
expect(channels.powerGuest).toHaveBeenCalledOnce();
const q2 = await svc.run('stop-ct107', owner);
const rej = await svc.reject(q2.action_row_id, owner);
expect(rej.status).toBe('rejected');
expect(channels.powerGuest).toHaveBeenCalledOnce(); // unchanged
});
it('unknown action → error', async () => {
await expect(svc.run('ghost', owner)).rejects.toThrow(/unknown action/i);
});
});