diff --git a/lib/db/migrations/016_agent_actions.sql b/lib/db/migrations/016_agent_actions.sql new file mode 100644 index 0000000..4a7ec5e --- /dev/null +++ b/lib/db/migrations/016_agent_actions.sql @@ -0,0 +1,19 @@ +-- 016_agent_actions.sql — queue + audit trail for Little Blue's infra actions. +-- Risky actions land here as 'pending' for owner approval; every executed action +-- (safe or approved-risky) is recorded with its result. Deliberately separate from +-- pending_changes (entity CRUD) to isolate command execution. +CREATE TABLE agent_actions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + action_id text NOT NULL, -- whitelist id from config/actions.json + params jsonb NOT NULL DEFAULT '{}'::jsonb, + agent_id uuid REFERENCES agents(id), + tier text NOT NULL CHECK (tier IN ('safe','risky')), + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','executed','failed','rejected')), + result jsonb, + requested_by jsonb, + resolved_by jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + resolved_at timestamptz +); +CREATE INDEX idx_agent_actions_pending ON agent_actions(status) WHERE status='pending'; diff --git a/lib/db/repos/agent_actions.js b/lib/db/repos/agent_actions.js new file mode 100644 index 0000000..8853832 --- /dev/null +++ b/lib/db/repos/agent_actions.js @@ -0,0 +1,38 @@ +import { pool } from '../pool.js'; +import { recordAudit } from './audit.js'; + +export async function create({ action_id, tier, params, agent_id, requested_by }) { + const { rows: [r] } = await pool.query( + `INSERT INTO agent_actions(action_id, tier, params, agent_id, requested_by) + VALUES($1,$2,$3,$4,$5) RETURNING *`, + [action_id, tier, params || {}, agent_id || null, requested_by || null] + ); + await recordAudit(requested_by, 'create', 'agent_action', r.id, null, r); + return r; +} + +export async function listPending({ limit = 100 } = {}) { + const { rows } = await pool.query( + `SELECT * FROM agent_actions WHERE status='pending' ORDER BY created_at LIMIT $1`, [limit]); + return rows; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query(`SELECT * FROM agent_actions WHERE id=$1`, [id]); + return r; +} + +export async function resolve(id, status, result, resolved_by) { + const { rows: [r] } = await pool.query( + `UPDATE agent_actions SET status=$1, result=$2, resolved_by=$3, resolved_at=now() + WHERE id=$4 AND status='pending' RETURNING *`, + [status, result || null, resolved_by || null, id]); + if (r) await recordAudit(resolved_by, 'update', 'agent_action', id, null, r); + return r; +} + +export async function recent({ limit = 50 } = {}) { + const { rows } = await pool.query( + `SELECT * FROM agent_actions WHERE status<>'pending' ORDER BY resolved_at DESC NULLS LAST LIMIT $1`, [limit]); + return rows; +} diff --git a/tests/db/agent_actions.test.js b/tests/db/agent_actions.test.js new file mode 100644 index 0000000..af98f45 --- /dev/null +++ b/tests/db/agent_actions.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as aa from '../../lib/db/repos/agent_actions.js'; + +const owner = { kind: 'user', id: null }; +beforeAll(async () => { await resetDb(); await migrateUp(); }); + +describe('agent_actions repo', () => { + it('creates pending, lists it, resolves once', async () => { + const row = await aa.create({ action_id: 'stop-ct107', tier: 'risky', params: {}, requested_by: owner }); + expect(row.status).toBe('pending'); + expect((await aa.listPending()).some(r => r.id === row.id)).toBe(true); + const done = await aa.resolve(row.id, 'executed', { ok: true }, owner); + expect(done.status).toBe('executed'); + const again = await aa.resolve(row.id, 'rejected', null, owner); // already resolved + expect(again).toBeUndefined(); + }); +});