Implement P6 TODO stubs: runbook CRUD, execution triggers, approval flow, Slack bot

- Runbooks: list (paginated), get, create (with step counting), archive
- Executions: trigger with dry_run + variables, history, detail with audit trail
- Approvals: list pending, approve/reject with Redis pub/sub notification to agent
- Slack bot: approve_step/reject_step button handlers with DB updates + agent bridge
- All routes use withTenant() RLS
This commit is contained in:
2026-03-01 03:21:06 +00:00
parent eec1df4c69
commit c5f4246fe9
3 changed files with 210 additions and 44 deletions

View File

@@ -1,8 +1,12 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import pino from 'pino';
import { withTenant } from '../data/db.js';
import { AgentBridge } from '../bridge/agent-bridge.js';
import { config } from '../config/index.js';
const logger = pino({ name: 'api-approvals' });
const bridge = new AgentBridge(config.REDIS_URL);
const approvalDecisionSchema = z.object({
decision: z.enum(['approve', 'reject']),
@@ -12,22 +16,66 @@ const approvalDecisionSchema = z.object({
export function registerApprovalRoutes(app: FastifyInstance) {
// List pending approvals for tenant
app.get('/api/v1/approvals', async (req, reply) => {
// TODO: SELECT from audit_entries WHERE status = 'awaiting_approval'
return { approvals: [] };
const tenantId = (req as any).tenantId;
const result = await withTenant(tenantId, async (client) => {
return client.query(
`SELECT ae.id, ae.execution_id, ae.step_index, ae.command, ae.safety_level, ae.started_at,
e.runbook_id, r.name as runbook_name
FROM audit_entries ae
JOIN executions e ON ae.execution_id = e.id
JOIN runbooks r ON e.runbook_id = r.id
WHERE ae.status = 'awaiting_approval'
ORDER BY ae.started_at DESC`,
);
});
return { approvals: result.rows };
});
// Approve or reject a step
app.post('/api/v1/approvals/:stepId', async (req, reply) => {
const { stepId } = req.params as { stepId: string };
const body = approvalDecisionSchema.parse(req.body);
const tenantId = (req as any).tenantId;
const userId = (req as any).userId;
// TODO: Update audit entry, notify agent via WebSocket/Redis pub-sub
logger.info({ stepId, decision: body.decision }, 'Approval decision recorded');
const result = await withTenant(tenantId, async (client) => {
// Get the audit entry and its execution
const entry = await client.query(
`SELECT ae.id, ae.execution_id, ae.status, e.runbook_id
FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id
WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`,
[stepId],
);
return {
step_id: stepId,
decision: body.decision,
reason: body.reason,
};
if (!entry.rows[0]) return null;
const newStatus = body.decision === 'approve' ? 'approved' : 'rejected';
await client.query(
`UPDATE audit_entries SET status = $1, approved_by = $2, approval_method = 'api'
WHERE id = $3`,
[newStatus, userId, stepId],
);
// If rejected, abort the execution
if (body.decision === 'reject') {
await client.query(
`UPDATE executions SET status = 'aborted', completed_at = now() WHERE id = $1`,
[entry.rows[0].execution_id],
);
}
return entry.rows[0];
});
if (!result) return reply.status(404).send({ error: 'Pending approval not found' });
// Notify agent via Redis pub/sub
await bridge.sendApproval(tenantId, result.execution_id, stepId, body.decision);
logger.info({ stepId, decision: body.decision, userId }, 'Approval decision recorded');
return { step_id: stepId, decision: body.decision, reason: body.reason };
});
}

View File

@@ -1,8 +1,12 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import pino from 'pino';
import { withTenant } from '../data/db.js';
import { AgentBridge } from '../bridge/agent-bridge.js';
import { config } from '../config/index.js';
const logger = pino({ name: 'api-runbooks' });
const bridge = new AgentBridge(config.REDIS_URL);
const createRunbookSchema = z.object({
name: z.string().min(1).max(200),
@@ -20,54 +24,130 @@ export function registerRunbookRoutes(app: FastifyInstance) {
// List runbooks
app.get('/api/v1/runbooks', async (req, reply) => {
const query = listQuerySchema.parse(req.query);
// TODO: SELECT from runbooks with RLS tenant context
return { runbooks: [], page: query.page, limit: query.limit, total: 0 };
const tenantId = (req as any).tenantId;
const offset = (query.page - 1) * query.limit;
const result = await withTenant(tenantId, async (client) => {
let sql = 'SELECT id, name, description, step_count, status, created_by, created_at, updated_at FROM runbooks WHERE 1=1';
const params: any[] = [];
let idx = 1;
if (query.status) { sql += ` AND status = $${idx++}`; params.push(query.status); }
sql += ` ORDER BY updated_at DESC LIMIT $${idx++} OFFSET $${idx++}`;
params.push(query.limit, offset);
const rows = await client.query(sql, params);
const total = await client.query('SELECT COUNT(*)::int as count FROM runbooks' + (query.status ? ` WHERE status = '${query.status}'` : ''));
return { rows: rows.rows, total: total.rows[0]?.count ?? 0 };
});
return { runbooks: result.rows, page: query.page, limit: query.limit, total: result.total };
});
// Get single runbook
app.get('/api/v1/runbooks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
// TODO: SELECT by id
return { runbook: null };
const tenantId = (req as any).tenantId;
const result = await withTenant(tenantId, async (client) => {
return client.query('SELECT * FROM runbooks WHERE id = $1', [id]);
});
if (!result.rows[0]) return reply.status(404).send({ error: 'Runbook not found' });
return { runbook: result.rows[0] };
});
// Create runbook
app.post('/api/v1/runbooks', async (req, reply) => {
const body = createRunbookSchema.parse(req.body);
// TODO: INSERT into runbooks, parse YAML, validate steps
logger.info({ name: body.name }, 'Runbook created');
return reply.status(201).send({ id: 'placeholder', ...body });
const tenantId = (req as any).tenantId;
const userId = (req as any).userId;
// Count steps in YAML (basic: count lines starting with "- description:" or "- command:")
const stepCount = (body.yaml_content.match(/^\s*-\s+(description|command):/gm) ?? []).length;
const result = await withTenant(tenantId, async (client) => {
return client.query(
`INSERT INTO runbooks (tenant_id, name, description, yaml_content, step_count, created_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[tenantId, body.name, body.description ?? '', body.yaml_content, Math.max(1, Math.ceil(stepCount / 2)), userId],
);
});
logger.info({ name: body.name, tenantId }, 'Runbook created');
return reply.status(201).send({ runbook: result.rows[0] });
});
// Trigger runbook execution
app.post('/api/v1/runbooks/:id/execute', async (req, reply) => {
const { id } = req.params as { id: string };
const tenantId = (req as any).tenantId;
const userId = (req as any).userId;
const body = z.object({
dry_run: z.boolean().default(false),
variables: z.record(z.string()).optional(),
}).parse(req.body ?? {});
// TODO: Create execution record, dispatch to agent via WebSocket/queue
logger.info({ runbookId: id, dryRun: body.dry_run }, 'Execution triggered');
const execution = await withTenant(tenantId, async (client) => {
// Verify runbook exists
const rb = await client.query('SELECT id, name FROM runbooks WHERE id = $1', [id]);
if (!rb.rows[0]) return null;
// Create execution record
const exec = await client.query(
`INSERT INTO executions (tenant_id, runbook_id, triggered_by, trigger_source, dry_run, variables)
VALUES ($1, $2, $3, 'api', $4, $5) RETURNING *`,
[tenantId, id, userId, body.dry_run, JSON.stringify(body.variables ?? {})],
);
return exec.rows[0];
});
if (!execution) return reply.status(404).send({ error: 'Runbook not found' });
logger.info({ runbookId: id, executionId: execution.id, dryRun: body.dry_run }, 'Execution triggered');
return reply.status(202).send({
execution_id: 'placeholder',
execution_id: execution.id,
runbook_id: id,
status: 'pending',
dry_run: body.dry_run,
});
});
// Get execution history
// Get execution history for a runbook
app.get('/api/v1/runbooks/:id/executions', async (req, reply) => {
const { id } = req.params as { id: string };
// TODO: SELECT from executions
return { executions: [] };
const tenantId = (req as any).tenantId;
const result = await withTenant(tenantId, async (client) => {
return client.query(
`SELECT id, triggered_by, trigger_source, dry_run, status, started_at, completed_at, created_at
FROM executions WHERE runbook_id = $1 ORDER BY created_at DESC LIMIT 50`,
[id],
);
});
return { executions: result.rows };
});
// Get execution detail (with step-by-step audit trail)
// Get execution detail with step-by-step audit trail
app.get('/api/v1/executions/:executionId', async (req, reply) => {
const { executionId } = req.params as { executionId: string };
// TODO: SELECT execution + JOIN audit_entries
return { execution: null, steps: [] };
const tenantId = (req as any).tenantId;
const result = await withTenant(tenantId, async (client) => {
const execution = await client.query('SELECT * FROM executions WHERE id = $1', [executionId]);
const steps = await client.query(
`SELECT step_index, command, safety_level, status, approved_by, approval_method,
exit_code, started_at, completed_at, duration_ms
FROM audit_entries WHERE execution_id = $1 ORDER BY step_index`,
[executionId],
);
return { execution: execution.rows[0] ?? null, steps: steps.rows };
});
if (!result.execution) return reply.status(404).send({ error: 'Execution not found' });
return result;
});
}

View File

@@ -2,17 +2,15 @@ import type { FastifyInstance } from 'fastify';
import pino from 'pino';
import crypto from 'node:crypto';
import { config } from '../config/index.js';
import { withTenant } from '../data/db.js';
import { AgentBridge } from '../bridge/agent-bridge.js';
const logger = pino({ name: 'slackbot' });
const bridge = new AgentBridge(config.REDIS_URL);
/**
* Slack interactive message handler.
* Receives button clicks for approve/reject from Slack Block Kit messages.
*/
export function registerSlackRoutes(app: FastifyInstance) {
// Slack Events API verification + interactive payloads
// Slack Events API verification
app.post('/slack/events', async (req, reply) => {
// Verify Slack signature
if (config.SLACK_SIGNING_SECRET) {
const timestamp = req.headers['x-slack-request-timestamp'] as string;
const signature = req.headers['x-slack-signature'] as string;
@@ -24,8 +22,6 @@ export function registerSlackRoutes(app: FastifyInstance) {
}
const payload = req.body as any;
// URL verification challenge
if (payload.type === 'url_verification') {
return { challenge: payload.challenge };
}
@@ -33,9 +29,8 @@ export function registerSlackRoutes(app: FastifyInstance) {
return { ok: true };
});
// Slack interactive components (button clicks)
// Slack interactive components (approve/reject buttons)
app.post('/slack/interactions', async (req, reply) => {
// Slack sends form-encoded payload
const rawPayload = (req.body as any)?.payload;
if (!rawPayload) return reply.status(400).send({ error: 'Missing payload' });
@@ -49,13 +44,59 @@ export function registerSlackRoutes(app: FastifyInstance) {
if (payload.type === 'block_actions') {
for (const action of payload.actions ?? []) {
const [actionType, stepId] = (action.action_id ?? '').split(':');
const slackUserId = payload.user?.id ?? 'unknown';
if (actionType === 'approve_step') {
logger.info({ stepId, user: payload.user?.id }, 'Step approved via Slack');
// TODO: Update audit entry, resume execution
} else if (actionType === 'reject_step') {
logger.info({ stepId, user: payload.user?.id }, 'Step rejected via Slack');
// TODO: Update audit entry, abort execution
if (actionType === 'approve_step' && stepId) {
// Look up the audit entry to get tenant + execution context
const { pool } = await import('../data/db.js');
const entry = await pool.query(
`SELECT ae.id, ae.execution_id, e.tenant_id
FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id
WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`,
[stepId],
);
if (entry.rows[0]) {
const { tenant_id, execution_id } = entry.rows[0];
await withTenant(tenant_id, async (client) => {
await client.query(
`UPDATE audit_entries SET status = 'approved', approved_by = $1, approval_method = 'slack_button'
WHERE id = $2`,
[slackUserId, stepId],
);
});
await bridge.sendApproval(tenant_id, execution_id, stepId, 'approve');
logger.info({ stepId, user: slackUserId }, 'Step approved via Slack');
}
} else if (actionType === 'reject_step' && stepId) {
const { pool } = await import('../data/db.js');
const entry = await pool.query(
`SELECT ae.id, ae.execution_id, e.tenant_id
FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id
WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`,
[stepId],
);
if (entry.rows[0]) {
const { tenant_id, execution_id } = entry.rows[0];
await withTenant(tenant_id, async (client) => {
await client.query(
`UPDATE audit_entries SET status = 'rejected', approved_by = $1, approval_method = 'slack_button'
WHERE id = $2`,
[slackUserId, stepId],
);
await client.query(
`UPDATE executions SET status = 'aborted', completed_at = now() WHERE id = $1`,
[execution_id],
);
});
await bridge.sendApproval(tenant_id, execution_id, stepId, 'reject');
logger.info({ stepId, user: slackUserId }, 'Step rejected via Slack');
}
}
}
}
@@ -66,13 +107,10 @@ export function registerSlackRoutes(app: FastifyInstance) {
function verifySlackSignature(body: string, timestamp: string, signature: string, secret: string): boolean {
if (!timestamp || !signature) return false;
// Reject stale requests (>5 min)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
const sigBasestring = `v0:${timestamp}:${body}`;
const expected = 'v0=' + crypto.createHmac('sha256', secret).update(sigBasestring).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}