godcrm/backend/routes/v3/tickets/execution.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

236 lines
9 KiB
JavaScript

/**
* Tickets Execution Routes
* POST /tickets/:id/message — Send message in ticket's bound chat
* POST /tickets/:id/invoke-agent — Invoke assigned agent to work on ticket (ADR-077 Task #12)
*/
import ChainHandoffService from '../../../services/ChainHandoffService.js';
import { success, created, error, badRequest } from '../../../utils/response.js';
import { dbGet, dbRun, isPostgres } from '../../../database/connection.js';
import { apiLogger } from '../../../utils/logger.js';
import { resolveAgentUser } from '../../../services/agent-users.js';
import { createAndDispatchJob } from '../../../services/AgentJobService.js';
import {
STATE_MAP, TRANSITIONS, TICKETS_TABLE_ID,
parseStatusDirective,
} from './shared.js';
export default function registerExecutionRoutes(router) {
/**
* POST /tickets/:id/message
* Send a message in the ticket's bound row conversation.
*
* Body: { content }
* Returns: { message_id, conversation_id }
*/
router.post('/tickets/:id/message', async (req, res) => {
try {
const ticketId = parseInt(req.params.id, 10);
if (isNaN(ticketId)) {
return badRequest(res, 'Invalid ticket ID');
}
const { content } = req.body;
if (!content || !content.trim()) {
return badRequest(res, 'content is required');
}
// Verify ticket exists
const ticket = await ChainHandoffService.getTicket(ticketId);
if (!ticket) {
return error(res, 'TICKET_NOT_FOUND', `Ticket ${ticketId} not found`, 404);
}
// Find or create bound conversation
let conversation = await dbGet(
isPostgres()
? `SELECT id FROM conversations WHERE bound_table_id = $1 AND bound_row_id = $2 LIMIT 1`
: `SELECT id FROM conversations WHERE bound_table_id = ? AND bound_row_id = ? LIMIT 1`,
[TICKETS_TABLE_ID, ticketId]
);
let conversationId;
if (conversation) {
conversationId = conversation.id;
} else {
// Create bound conversation
const convResult = await dbRun(
isPostgres()
? `INSERT INTO conversations (title, type, bound_table_id, bound_row_id, created_by, created_at, updated_at)
VALUES ($1, 'row', $2, $3, $4, NOW(), NOW()) RETURNING id`
: `INSERT INTO conversations (title, type, bound_table_id, bound_row_id, created_by, created_at, updated_at)
VALUES (?, 'row', ?, ?, ?, datetime('now'), datetime('now'))`,
[`Ticket #${ticketId}: ${(ticket.what || '').substring(0, 60)}`, TICKETS_TABLE_ID, ticketId, req.user.id]
);
conversationId = convResult?.lastInsertRowid || convResult?.rows?.[0]?.id;
}
// Insert message
const msgResult = await dbRun(
isPostgres()
? `INSERT INTO messages (conversation_id, sender_id, content, created_at)
VALUES ($1, $2, $3, NOW()) RETURNING id`
: `INSERT INTO messages (conversation_id, sender_id, content, created_at)
VALUES (?, ?, ?, datetime('now'))`,
[conversationId, req.user.id, content.trim()]
);
const messageId = msgResult?.lastInsertRowid || msgResult?.rows?.[0]?.id;
// ADR-077 Task #7: Auto-update ticket status when agent writes status directive
let autoStatusUpdate;
const directive = parseStatusDirective(content.trim());
if (directive) {
const targetStateId = STATE_MAP[directive.targetState];
const currentStateId = ticket.state;
const allowed = TRANSITIONS[currentStateId] || [];
if (targetStateId && allowed.includes(targetStateId)) {
try {
await ChainHandoffService.updateTicketStatus({
ticket_id: ticketId,
new_state: targetStateId,
agent_id: req.user.id,
notes: `Auto-updated from message status directive: ${directive.rawStatus}`,
});
autoStatusUpdate = {
new_state: directive.targetState,
new_state_id: targetStateId,
raw_status: directive.rawStatus,
};
apiLogger.info({ ticketId, directive, targetStateId }, 'Tickets: Auto-status-update from message directive');
} catch (updateErr) {
apiLogger.warn({ err: updateErr, ticketId, directive }, 'Tickets: Auto-status-update failed (non-fatal)');
}
}
}
const responseData = {
message_id: messageId,
conversation_id: conversationId,
ticket_id: ticketId,
};
if (autoStatusUpdate) {
responseData.auto_status_update = autoStatusUpdate;
}
return created(res, responseData);
} catch (err) {
apiLogger.error({ err, ticketId: req.params.id }, 'Tickets: Message send failed');
return error(res, 'MESSAGE_SEND_FAILED', err.message, 500);
}
});
/**
* POST /tickets/:id/invoke-agent
* Invoke the assigned agent to work on a ticket (ADR-077 Task #12).
*
* This bridges ticket dispatch and agent execution:
* 1. Looks up the ticket and its assigned agent
* 2. Resolves the agent user (for job dispatch)
* 3. Finds or creates a bound conversation for the ticket
* 4. Dispatches an async agent job via AgentJobService
* 5. Optionally transitions ticket from backlog → in_progress
*
* Body: { message?: string }
* Returns: { job_id, conversation_id, agent_name, ticket_id }
*/
router.post('/tickets/:id/invoke-agent', async (req, res) => {
try {
const ticketId = parseInt(req.params.id, 10);
if (isNaN(ticketId)) {
return badRequest(res, 'Invalid ticket ID');
}
// 1. Fetch ticket
const ticket = await ChainHandoffService.getTicket(ticketId);
if (!ticket) {
return error(res, 'TICKET_NOT_FOUND', `Ticket ${ticketId} not found`, 404);
}
// 2. Validate state — cannot invoke on terminal ticket
if (ticket.state === STATE_MAP.done) {
return error(res, 'TICKET_TERMINAL', 'Cannot invoke agent on a completed ticket', 400);
}
// 3. Validate assigned agent
if (!ticket.assigned_to) {
return badRequest(res, 'Ticket has no assigned_to agent. Dispatch the ticket first.');
}
// 4. Resolve agent user
const agent = await resolveAgentUser(ticket.assigned_to);
if (!agent) {
return error(res, 'AGENT_NOT_FOUND', `Cannot resolve agent for assigned_to=${ticket.assigned_to}`, 400);
}
// 5. Find or create bound conversation
let conversationId;
const existingConv = await dbGet(
isPostgres()
? `SELECT id FROM conversations WHERE bound_table_id = $1 AND bound_row_id = $2 LIMIT 1`
: `SELECT id FROM conversations WHERE bound_table_id = ? AND bound_row_id = ? LIMIT 1`,
[TICKETS_TABLE_ID, ticketId]
);
if (existingConv) {
conversationId = existingConv.id;
} else {
const convResult = await dbRun(
isPostgres()
? `INSERT INTO conversations (title, type, bound_table_id, bound_row_id, created_by, created_at, updated_at)
VALUES ($1, 'row', $2, $3, $4, NOW(), NOW()) RETURNING id`
: `INSERT INTO conversations (title, type, bound_table_id, bound_row_id, created_by, created_at, updated_at)
VALUES (?, 'row', ?, ?, ?, datetime('now'), datetime('now'))`,
[`Ticket #${ticketId}: ${(ticket.what || '').substring(0, 60)}`, TICKETS_TABLE_ID, ticketId, req.user.id]
);
conversationId = convResult?.lastInsertRowid || convResult?.rows?.[0]?.id;
}
// 6. Build message content
const { message } = req.body;
const messageContent = message || `${ticket.what || 'Work on this ticket'}${ticket.acceptance_criteria ? `\n\nAcceptance Criteria:\n${ticket.acceptance_criteria}` : ''}`;
// 7. Auto-transition backlog → in_progress
if (ticket.state === STATE_MAP.backlog || ticket.state === STATE_MAP.assigned) {
try {
await ChainHandoffService.updateTicketStatus({
ticket_id: ticketId,
new_state: STATE_MAP.in_progress,
agent_id: req.user.id,
notes: 'Auto-transitioned by invoke-agent',
});
} catch (transitionErr) {
apiLogger.warn({ err: transitionErr, ticketId }, 'Tickets: Auto-transition failed during invoke-agent (non-fatal)');
}
}
// 8. Dispatch agent job
const job = await createAndDispatchJob({
conversationId,
agent,
triggeredByUserId: req.user.id,
messageContent,
options: { agent_mode: 'agent' },
ticketId,
});
apiLogger.info({
ticketId,
conversationId,
agentName: agent.name,
jobId: job.jobId,
}, 'Tickets: Agent invoked for ticket');
return success(res, {
job_id: job.jobId,
conversation_id: conversationId,
agent_name: agent.name,
ticket_id: ticketId,
});
} catch (err) {
apiLogger.error({ err, ticketId: req.params.id }, 'Tickets: Invoke agent failed');
return error(res, 'INVOKE_AGENT_FAILED', err.message, 500);
}
});
}