feat(workflows): test-fire — safe dry-run of any flow on demand

Engine testRun() walks the whole graph immediately: waits pass through,
gates auto-confirm, side-effecting actions short-circuit to {dryRun, would}
so no real emails go out. POST /admin/workflows/:id/test-run returns the
run status + per-node step log. Admin list gets a flask button that opens
a result modal with an optional entity id (e.g. invoice) for conditions.
This commit is contained in:
Luca
2026-06-23 13:57:02 +02:00
parent 192d2cbc06
commit e70ddd36b8
8 changed files with 192 additions and 3 deletions
+22
View File
@@ -96,6 +96,28 @@ router.get('/:id/runs', requirePermission('workflows.view'), async (req, res, ne
} catch (e) { next(e); }
});
// Test-fire: run the workflow on demand (default dry-run — side effects mocked,
// waits skipped, gates auto-confirm) and return the step-by-step log.
router.post('/:id/test-run', requirePermission('workflows.manage'), async (req, res, next) => {
try {
const { entityType, entityId, payload, dryRun } = req.body || {};
const runId = await workflows.testRun(Number(req.params.id), {
entityType: entityType || null,
entityId: entityId != null && entityId !== '' ? Number(entityId) : null,
payload: payload && typeof payload === 'object' ? payload : {},
dryRun: dryRun !== false, // default true (safe)
});
const run = await db('workflow_runs').where({ id: runId }).first();
const steps = await db('workflow_run_steps').where({ run_id: runId }).orderBy('id', 'asc');
res.json({
runId,
dryRun: dryRun !== false,
status: run?.status,
steps: steps.map((s) => ({ ...s, result: parseJson(s.result, null) })),
});
} catch (e) { next(e); }
});
// --- List / get ---
router.get('/', requirePermission('workflows.view'), async (req, res, next) => {
try {