godcrm/backend/routes/v3/chat/stepsController.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

127 lines
7.7 KiB
JavaScript

/**
* Conversation Steps API (ADR-110) and Chain status (ADR-077).
*/
import {
dbGet, dbAll, isPostgres, safeJsonParse, apiLogger,
success, error, badRequest, notFound,
requireAuth, ChainHandoffService,
} from './chatShared.js';
export default function registerStepsRoutes(router) {
// GET /conversations/:id/steps - List step messages
router.get('/conversations/:id/steps', requireAuth, async (req, res) => {
try {
const conversationId = parseInt(req.params.id);
if (!conversationId || isNaN(conversationId)) return badRequest(res, 'Invalid conversation ID');
const allowedTypes = ['thinking', 'tool_call', 'tool_result'];
const typeParam = req.query.type || 'tool_call,tool_result,thinking';
const requestedTypes = typeParam.split(',').map(t => t.trim()).filter(t => allowedTypes.includes(t));
if (requestedTypes.length === 0) return badRequest(res, `Invalid type filter. Allowed: ${allowedTypes.join(', ')}`);
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 100);
const page = Math.max(parseInt(req.query.page) || 1, 1);
const offset = (page - 1) * limit;
const conversation = await dbGet(isPostgres() ? `SELECT id FROM conversations WHERE id = $1` : `SELECT id FROM conversations WHERE id = ?`, [conversationId]);
if (!conversation) return notFound(res, 'Conversation');
const inPlaceholders = isPostgres() ? requestedTypes.map((_, i) => `$${i + 2}`).join(', ') : requestedTypes.map(() => '?').join(', ');
const countParams = [conversationId, ...requestedTypes];
const countRow = await dbGet(
isPostgres()
? `SELECT COUNT(*) as total FROM messages WHERE conversation_id = $1 AND content_type IN (${inPlaceholders}) AND (is_deleted = 0 OR is_deleted IS NULL)`
: `SELECT COUNT(*) as total FROM messages WHERE conversation_id = ? AND content_type IN (${requestedTypes.map(() => '?').join(', ')}) AND (is_deleted = 0 OR is_deleted IS NULL)`,
countParams
);
const total = countRow?.total || 0;
const queryParams = [conversationId, ...requestedTypes, limit, offset];
const inPlaceholdersFetch = isPostgres() ? requestedTypes.map((_, i) => `$${i + 2}`).join(', ') : requestedTypes.map(() => '?').join(', ');
const steps = await dbAll(
isPostgres()
? `SELECT id, content_type, content, tool_results, created_at FROM messages WHERE conversation_id = $1 AND content_type IN (${inPlaceholdersFetch}) AND (is_deleted = 0 OR is_deleted IS NULL) ORDER BY created_at ASC LIMIT $${requestedTypes.length + 2} OFFSET $${requestedTypes.length + 3}`
: `SELECT id, content_type, content, tool_results, created_at FROM messages WHERE conversation_id = ? AND content_type IN (${requestedTypes.map(() => '?').join(', ')}) AND (is_deleted = 0 OR is_deleted IS NULL) ORDER BY created_at ASC LIMIT ? OFFSET ?`,
queryParams
);
const PREVIEW_CHARS = 100;
const formattedSteps = steps.map(s => {
let toolName = null;
if (s.tool_results) { const toolData = safeJsonParse(s.tool_results); toolName = toolData?.tool || toolData?.name || null; }
if (!toolName && s.content_type === 'tool_call' && s.content) {
const parsed = safeJsonParse(s.content);
toolName = parsed?.tool || parsed?.name || null;
if (!toolName && typeof s.content === 'string' && s.content.length < 100 && !s.content.includes(' ')) toolName = s.content;
}
return { id: s.id, type: s.content_type, tool_name: toolName, preview: s.content ? s.content.substring(0, PREVIEW_CHARS) : '', timestamp: s.created_at };
});
const hasMore = offset + limit < total;
return success(res, { steps: formattedSteps, hasMore, nextCursor: hasMore ? page + 1 : null, total });
} catch (err) {
apiLogger.error({ err, conversationId: req.params.id }, 'ADR-110: Error fetching conversation steps');
return error(res, 'STEPS_LIST_ERROR', err.message, 500);
}
});
// GET /conversations/:id/steps/:stepId - Get step detail
router.get('/conversations/:id/steps/:stepId', requireAuth, async (req, res) => {
try {
const conversationId = parseInt(req.params.id);
const stepId = parseInt(req.params.stepId);
if (!conversationId || isNaN(conversationId) || !stepId || isNaN(stepId)) return badRequest(res, 'Invalid conversation or step ID');
const step = await dbGet(
isPostgres()
? `SELECT id, conversation_id, content_type, role, content, tool_results, agent_id, model_used, tokens_in, tokens_out, created_at FROM messages WHERE id = $1 AND conversation_id = $2 AND content_type IN ('thinking', 'tool_call', 'tool_result') AND (is_deleted = 0 OR is_deleted IS NULL)`
: `SELECT id, conversation_id, content_type, role, content, tool_results, agent_id, model_used, tokens_in, tokens_out, created_at FROM messages WHERE id = ? AND conversation_id = ? AND content_type IN ('thinking', 'tool_call', 'tool_result') AND (is_deleted = 0 OR is_deleted IS NULL)`,
[stepId, conversationId]
);
if (!step) return notFound(res, 'Step');
const toolData = step.tool_results ? safeJsonParse(step.tool_results) : null;
return success(res, {
id: step.id, type: step.content_type, content: step.content,
tool_name: toolData?.tool || toolData?.name || null, tool_args: toolData?.args || null,
metadata: { role: step.role, agent_id: step.agent_id, model: step.model_used, tokens: (step.tokens_in || step.tokens_out) ? { in: step.tokens_in, out: step.tokens_out } : null, result: toolData?.result || null },
conversation_id: step.conversation_id, created_at: step.created_at,
});
} catch (err) {
apiLogger.error({ err, stepId: req.params.stepId }, 'ADR-110: Error fetching step detail');
return error(res, 'STEP_DETAIL_ERROR', err.message, 500);
}
});
// GET /conversations/:id/chain - ADR-077 Task #11: Agent chain status
router.get('/conversations/:id/chain', requireAuth, async (req, res) => {
try {
const conversationId = parseInt(req.params.id);
if (!conversationId || isNaN(conversationId)) return badRequest(res, 'Invalid conversation ID');
const conv = await dbGet(isPostgres() ? `SELECT id, bound_table_id, bound_row_id FROM conversations WHERE id = $1` : `SELECT id, bound_table_id, bound_row_id FROM conversations WHERE id = ?`, [conversationId]);
if (!conv) return notFound(res, 'Conversation');
if (!conv.bound_table_id || !conv.bound_row_id) return notFound(res, 'Chain — conversation is not bound to a ticket');
const ticketRow = await dbGet(isPostgres() ? `SELECT id, data FROM table_rows WHERE id = $1 AND table_id = $2` : `SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?`, [conv.bound_row_id, conv.bound_table_id]);
if (!ticketRow) return notFound(res, 'Chain — bound ticket row not found');
const ticketData = safeJsonParse(ticketRow.data, {});
const chainId = ticketData._chain?.chain_id;
if (!chainId) return notFound(res, 'Chain — ticket has no chain metadata');
const chainStatus = await ChainHandoffService.getChainStatus(chainId);
if (chainStatus.status === 'not_found') return notFound(res, `Chain '${chainId}'`);
apiLogger.info({ conversationId, chainId, status: chainStatus.status }, 'ADR-077 Task #11: Fetched chain status for conversation');
return success(res, { chain_id: chainStatus.chain_id, status: chainStatus.status, progress: chainStatus.progress, tasks: chainStatus.tasks, current_step: chainStatus.current_step, next_step: chainStatus.next_step });
} catch (err) {
apiLogger.error({ err, conversationId: req.params.id }, 'ADR-077: Error fetching chain status for conversation');
return error(res, 'CHAIN_STATUS_ERROR', err.message, 500);
}
});
}