Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
207 lines
7.9 KiB
JavaScript
207 lines
7.9 KiB
JavaScript
/**
|
|
* Tickets CRUD Routes
|
|
* GET /tickets/:id — Get ticket with allowed transitions
|
|
* PATCH /tickets/:id/status — Change ticket state (state machine validated)
|
|
*/
|
|
|
|
import ChainHandoffService from '../../../services/ChainHandoffService.js';
|
|
import QualityGateService from '../../../services/QualityGateService.js';
|
|
import { success, error, badRequest } from '../../../utils/response.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import {
|
|
STATE_MAP, STATE_NAMES, TRANSITIONS,
|
|
HUMAN_ONLY_STATES, SUPERVISOR_AGENT_IDS,
|
|
resolveState, executeCascade,
|
|
} from './shared.js';
|
|
import { checkCompletionGate, formatGateError } from '../../../services/bdd/completionGate.js';
|
|
|
|
export default function registerCrudRoutes(router) {
|
|
/**
|
|
* PATCH /tickets/:id/status
|
|
* Change ticket status with state machine validation.
|
|
*
|
|
* Body: { new_state: "review" | 24277, notes?: "reason" }
|
|
* Response 200: { ticket_id, old_state, old_state_id, new_state, new_state_id, chain_id, updated_at }
|
|
*/
|
|
router.patch('/tickets/:id/status', async (req, res) => {
|
|
try {
|
|
const ticketId = parseInt(req.params.id, 10);
|
|
if (isNaN(ticketId)) {
|
|
return badRequest(res, 'Invalid ticket ID');
|
|
}
|
|
|
|
const { new_state, notes } = req.body;
|
|
|
|
if (new_state === undefined || new_state === null) {
|
|
return badRequest(res, 'new_state is required');
|
|
}
|
|
|
|
// 1. Resolve state name/ID
|
|
const { stateId, error: stateError } = resolveState(new_state);
|
|
if (stateError) {
|
|
return error(res, 'INVALID_STATE', stateError, 400);
|
|
}
|
|
|
|
// 2. Fetch current ticket
|
|
const ticket = await ChainHandoffService.getTicket(ticketId);
|
|
if (!ticket) {
|
|
return error(res, 'TICKET_NOT_FOUND', `Ticket ${ticketId} not found in tickets table`, 404);
|
|
}
|
|
|
|
const currentStateId = ticket.state;
|
|
|
|
// 3. Validate transition
|
|
const allowed = TRANSITIONS[currentStateId] || [];
|
|
if (!allowed.includes(stateId)) {
|
|
const fromName = STATE_NAMES[currentStateId] || String(currentStateId);
|
|
const toName = STATE_NAMES[stateId] || String(stateId);
|
|
const allowedNames = allowed.map(s => STATE_NAMES[s]).join(', ') || 'none';
|
|
return error(res, 'INVALID_TRANSITION',
|
|
`Cannot transition from '${fromName}' to '${toName}'. Allowed: ${allowedNames}`, 400);
|
|
}
|
|
|
|
// 4. Control gate: human OR supervisor can transition from 'control' state (ADR-109)
|
|
if (HUMAN_ONLY_STATES.has(currentStateId)) {
|
|
const userType = req.user.user_type || req.user.type || 'unknown';
|
|
const isSupervisor = SUPERVISOR_AGENT_IDS.has(req.user.id);
|
|
|
|
if (userType !== 'human' && !isSupervisor) {
|
|
return error(res, 'CONTROL_GATE',
|
|
`Only human users or supervisors can transition from '${STATE_NAMES[currentStateId]}'. ` +
|
|
`Agent user_type='${userType}' (id=${req.user.id}) is not allowed.`, 403);
|
|
}
|
|
|
|
// ADR-109: Audit trail for supervisor bypass
|
|
if (isSupervisor) {
|
|
apiLogger.info({
|
|
action: 'supervisor_bypass',
|
|
agent_id: req.user.id,
|
|
ticket_id: ticketId,
|
|
from_state: STATE_NAMES[currentStateId],
|
|
to_state: STATE_NAMES[stateId],
|
|
notes: notes || '',
|
|
}, 'Control Gate: Supervisor bypass activated (ADR-109)');
|
|
|
|
// Log to Agent Activity table (1701)
|
|
try {
|
|
await ChainHandoffService.logActivity({
|
|
action: 'supervisor_bypass',
|
|
agent_id: req.user.id,
|
|
ticket_id: ticketId,
|
|
details: {
|
|
from_state: STATE_NAMES[currentStateId],
|
|
to_state: STATE_NAMES[stateId],
|
|
notes,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
apiLogger.warn({ err: e.message }, 'Failed to log supervisor bypass activity');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4b. ADR-0002 §8 Phase 3 (G4) — completion gate on transition → done.
|
|
// Reject with 409 if any Must criterion is not yet `verified`. The gate
|
|
// is a no-op for tickets without Must criteria (must_total === 0).
|
|
if (stateId === STATE_MAP.done) {
|
|
try {
|
|
const gate = await checkCompletionGate(ticketId);
|
|
if (!gate.ok) {
|
|
apiLogger.info(
|
|
{ ticket_id: ticketId, must_total: gate.must_total, must_verified: gate.must_verified, blocker_count: gate.blockers.length },
|
|
'ADR-0002 G4: completion gate blocked done transition'
|
|
);
|
|
const body = formatGateError(gate);
|
|
return error(
|
|
res,
|
|
body.code,
|
|
`Cannot transition to 'done' — ${gate.blockers.length} of ${gate.must_total} must-criteria are not verified`,
|
|
409,
|
|
{ must_total: body.must_total, must_verified: body.must_verified, failed: body.failed }
|
|
);
|
|
}
|
|
} catch (gateErr) {
|
|
// Gate query failure is logged but does not block the transition —
|
|
// we treat the gate as non-authoritative when its read fails.
|
|
apiLogger.warn({ err: gateErr.message, ticket_id: ticketId }, 'completion gate query failed, allowing transition');
|
|
}
|
|
}
|
|
|
|
// 5. Execute status update via ChainHandoffService
|
|
const result = await ChainHandoffService.updateTicketStatus({
|
|
ticket_id: ticketId,
|
|
new_state: stateId,
|
|
agent_id: req.user.id,
|
|
notes: notes || '',
|
|
});
|
|
|
|
// 6. Execute cascade updates (Phase 1)
|
|
const cascadeResults = await executeCascade({
|
|
ticketId,
|
|
oldState: currentStateId,
|
|
newState: stateId,
|
|
agentId: req.user.id,
|
|
ticket,
|
|
});
|
|
|
|
// 7. ADR-109 Part C: Fire-and-forget quality gate on in_progress -> review
|
|
if (currentStateId === STATE_MAP.in_progress && stateId === STATE_MAP.review) {
|
|
QualityGateService.runQualityGate(ticketId, {
|
|
agent_id: req.user.id,
|
|
chain_id: ticket?._chain?.chain_id || null,
|
|
}).catch(err => {
|
|
apiLogger.error({ err, ticketId }, 'Tickets: Quality gate fire-and-forget failed');
|
|
});
|
|
}
|
|
|
|
// 8. Return structured response
|
|
return success(res, {
|
|
ticket_id: result.ticket_id,
|
|
old_state: STATE_NAMES[result.old_state] || String(result.old_state),
|
|
old_state_id: result.old_state,
|
|
new_state: STATE_NAMES[result.new_state] || String(result.new_state),
|
|
new_state_id: result.new_state,
|
|
chain_id: result.chain_id || null,
|
|
cascade: cascadeResults.levels,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err, ticketId: req.params.id }, 'Tickets: Status update failed');
|
|
if (err.message && err.message.includes('not found')) {
|
|
return error(res, 'TICKET_NOT_FOUND', err.message, 404);
|
|
}
|
|
return error(res, 'STATUS_UPDATE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /tickets/:id
|
|
* Get ticket details with human-readable state and allowed transitions.
|
|
*/
|
|
router.get('/tickets/:id', async (req, res) => {
|
|
try {
|
|
const ticketId = parseInt(req.params.id, 10);
|
|
if (isNaN(ticketId)) {
|
|
return badRequest(res, 'Invalid ticket ID');
|
|
}
|
|
|
|
const ticket = await ChainHandoffService.getTicket(ticketId);
|
|
if (!ticket) {
|
|
return error(res, 'TICKET_NOT_FOUND', `Ticket ${ticketId} not found in tickets table`, 404);
|
|
}
|
|
|
|
const currentState = ticket.state;
|
|
const allowedTransitions = (TRANSITIONS[currentState] || [])
|
|
.map(s => ({ id: s, name: STATE_NAMES[s] || String(s) }));
|
|
|
|
return success(res, {
|
|
...ticket,
|
|
state_name: STATE_NAMES[currentState] || 'unknown',
|
|
allowed_transitions: allowedTransitions,
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err, ticketId: req.params.id }, 'Tickets: Fetch failed');
|
|
return error(res, 'TICKET_FETCH_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
}
|