godcrm/backend/services/chain-handoff/routing.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

561 lines
18 KiB
JavaScript

/**
* ChainHandoffService — Routing Module
*
* Agent resolution, name lookup, state name mapping,
* ticket queries, chain queries, handoff metadata, and activity logging.
*/
import { dbGet, dbAll, isPostgres, safeJsonParse } from '../../database/connection.js';
import { dbRun } from '../../database/connection.js';
import { apiLogger } from '../../utils/logger.js';
import { generateBaseId } from '../../utils/baseId.js';
import {
STATE,
AGENT_USERS,
TICKETS_TABLE_ID,
AGENT_ACTIVITY_TABLE_ID,
} from './constants.js';
import { checkCompletionGate } from '../bdd/completionGate.js';
// ----- AGENT RESOLUTION -----
/**
* Resolve agent user ID from name/slug.
* Maps common agent names to user IDs.
*
* @param {string} agentName - Agent name or slug
* @returns {number|null} Agent user ID or null
*/
function resolveAgentId(agentName) {
if (!agentName) return null;
const name = agentName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const mapping = {
'orchestrator': AGENT_USERS.ORCHESTRATOR,
'architect': AGENT_USERS.ARCHITECT,
'developer': AGENT_USERS.DEVELOPER,
'developer-ralph': AGENT_USERS.DEV_RALPH,
'dev-ralph': AGENT_USERS.DEV_RALPH,
'frontend': AGENT_USERS.FRONTEND,
'frontend-qa': AGENT_USERS.FRONTEND_QA,
'frontendqa': AGENT_USERS.FRONTEND_QA,
'test-runner': AGENT_USERS.TEST_RUNNER,
'test_runner': AGENT_USERS.TEST_RUNNER,
'table-architect': AGENT_USERS.TABLE_ARCHITECT,
'widget-developer': AGENT_USERS.WIDGET_DEVELOPER,
'document-agent': AGENT_USERS.DOCUMENT_AGENT,
'marketer': AGENT_USERS.MARKETER,
'nikich': AGENT_USERS.NIKICH,
'fitness-coach': AGENT_USERS.FITNESS_COACH,
'fitness_coach': AGENT_USERS.FITNESS_COACH,
'sysadmin': AGENT_USERS.SYSADMIN,
'sys-admin': AGENT_USERS.SYSADMIN,
};
return mapping[name] || null;
}
/**
* Get agent name from user ID.
*
* @param {number} agentId - Agent user ID
* @returns {string} Agent name
*/
function getAgentName(agentId) {
const mapping = {
[AGENT_USERS.ORCHESTRATOR]: 'Orchestrator',
[AGENT_USERS.ARCHITECT]: 'Architect',
[AGENT_USERS.DEVELOPER]: 'Developer',
[AGENT_USERS.DEV_RALPH]: 'Developer Ralph',
[AGENT_USERS.FRONTEND]: 'Frontend',
[AGENT_USERS.FRONTEND_QA]: 'Frontend QA',
[AGENT_USERS.TEST_RUNNER]: 'Test Runner',
[AGENT_USERS.TABLE_ARCHITECT]: 'Table Architect',
[AGENT_USERS.WIDGET_DEVELOPER]: 'Widget Developer',
[AGENT_USERS.DOCUMENT_AGENT]: 'Document Agent',
[AGENT_USERS.MARKETER]: 'Marketer',
[AGENT_USERS.NIKICH]: 'Nikich',
[AGENT_USERS.FITNESS_COACH]: 'Fitness Coach',
[AGENT_USERS.SYSADMIN]: 'SysAdmin',
};
return mapping[agentId] || `Agent-${agentId}`;
}
/**
* Convert state ID to human-readable name.
* ADR-101 Stage 3
*/
function getStateName(stateId) {
const names = {
[STATE.BACKLOG]: 'backlog',
[STATE.ASSIGNED]: 'assigned',
[STATE.IN_PROGRESS]: 'in_progress',
[STATE.REVIEW]: 'review',
[STATE.CONTROL]: 'control',
[STATE.REJECTED]: 'rejected',
[STATE.DONE]: 'done',
};
return names[stateId] || 'unknown';
}
// ----- TICKET QUERIES -----
/**
* Get a single ticket by ID from the Tickets table.
* ADR-098: Used by tickets API routes for validation and display.
*
* @param {number} ticketId - Ticket row ID
* @returns {Promise<Object|null>} Ticket data with id, created_at, updated_at, or null
*/
async function getTicket(ticketId) {
if (!ticketId) return null;
const row = await dbGet(
isPostgres()
? `SELECT id, data, created_at, updated_at FROM table_rows WHERE id = $1 AND table_id = $2`
: `SELECT id, data, created_at, updated_at FROM table_rows WHERE id = ? AND table_id = ?`,
[ticketId, TICKETS_TABLE_ID]
);
if (!row) return null;
const data = safeJsonParse(row.data, {});
return {
id: row.id,
...data,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
// ----- CHAIN QUERIES -----
/**
* Get all tasks in a chain.
*
* @param {string} chain_id - Chain identifier
* @returns {Promise<Array>} Array of ticket objects in the chain
*/
async function getChainTasks(chain_id) {
if (!chain_id) return [];
const rows = await dbAll(
isPostgres()
? `SELECT id, data, created_at, updated_at FROM table_rows
WHERE table_id = $1
AND data->'_chain'->>'chain_id' = $2
ORDER BY (data->'_chain'->>'step')::int ASC`
: `SELECT id, data, created_at, updated_at FROM table_rows
WHERE table_id = ?
AND json_extract(data, '$._chain.chain_id') = ?
ORDER BY CAST(json_extract(data, '$._chain.step') AS INTEGER) ASC`,
[TICKETS_TABLE_ID, chain_id]
);
return rows.map(row => {
const data = safeJsonParse(row.data, {});
return {
ticket_id: row.id,
chain_id: data._chain?.chain_id,
step: data._chain?.step,
what: data.what,
why: data.why || '',
state: data.state,
assigned_to: data.assigned_to,
chain_status: data._chain?.status,
dispatched_at: data._chain?.dispatched_at,
picked_up_at: data._chain?.picked_up_at,
completed_at: data._chain?.completed_at,
triggered_by: data._chain?.triggered_by ?? null,
triggers_next: data._chain?.triggers_next ?? null,
created_at: row.created_at,
updated_at: row.updated_at,
};
});
}
/**
* Get chain status summary.
*
* @param {string} chain_id - Chain identifier
* @returns {Promise<Object>} Chain status summary
*/
async function getChainStatus(chain_id) {
const tasks = await getChainTasks(chain_id);
if (tasks.length === 0) {
return { chain_id, status: 'not_found', tasks: [] };
}
const total = tasks.length;
const completed = tasks.filter(t => t.state === STATE.DONE).length;
const inProgress = tasks.filter(t => t.state === STATE.IN_PROGRESS).length;
const backlog = tasks.filter(t => t.state === STATE.BACKLOG).length;
const review = tasks.filter(t => t.state === STATE.REVIEW).length;
let overallStatus = 'in_progress';
if (completed === total) overallStatus = 'completed';
else if (backlog === total) overallStatus = 'pending';
return {
chain_id,
status: overallStatus,
progress: {
total,
completed,
in_progress: inProgress,
review,
backlog,
percent_complete: Math.round((completed / total) * 100),
},
tasks,
current_step: tasks.find(t => t.state === STATE.IN_PROGRESS)?.step || null,
next_step: tasks.find(t => t.state === STATE.BACKLOG)?.step || null,
};
}
/**
* Get pending tasks assigned to a specific agent.
*
* @param {number} agent_id - Agent user ID
* @returns {Promise<Array>} Array of pending ticket objects
*/
async function getAgentPendingTasks(agent_id) {
if (!agent_id) return [];
const rows = await dbAll(
isPostgres()
? `SELECT id, data, created_at, updated_at FROM table_rows
WHERE table_id = $1
AND (data->>'assigned_to')::int = $2
AND (data->>'state')::int IN ($3, $4, $5, $6)
ORDER BY created_at ASC`
: `SELECT id, data, created_at, updated_at FROM table_rows
WHERE table_id = ?
AND CAST(json_extract(data, '$.assigned_to') AS INTEGER) = ?
AND CAST(json_extract(data, '$.state') AS INTEGER) IN (?, ?, ?, ?)
ORDER BY created_at ASC`,
[TICKETS_TABLE_ID, agent_id, STATE.BACKLOG, STATE.ASSIGNED, STATE.IN_PROGRESS, STATE.REJECTED]
);
return rows.map(row => {
const data = safeJsonParse(row.data, {});
return {
ticket_id: row.id,
what: data.what,
why: data.why,
state: data.state,
priority: data.priority,
assigned_to: data.assigned_to,
acceptance_criteria: data.acceptance_criteria,
chain: data._chain || null,
created_at: row.created_at,
updated_at: row.updated_at,
};
});
}
// ----- HANDOFF METADATA -----
/**
* Build a handoff metadata object for passing context between agents.
* This is the standard format for what gets communicated during a handoff.
*
* @param {Object} params
* @param {number} params.from_agent - Dispatching agent user ID
* @param {number} params.to_agent - Receiving agent user ID
* @param {number} params.ticket_id - Ticket being handed off
* @param {string} params.chain_id - Chain identifier
* @param {number} params.step - Current step in the chain
* @param {string} [params.context] - Free-form context/instructions
* @param {Object} [params.artifacts] - Any artifacts from previous step (file paths, URLs, etc.)
* @param {Array<string>} [params.dependencies] - Ticket IDs this task depends on
* @returns {Object} Standardized handoff metadata
*/
function buildHandoffMetadata(params) {
const {
from_agent,
to_agent,
ticket_id,
chain_id,
step,
context = '',
artifacts = {},
dependencies = [],
} = params;
return {
handoff_version: '1.0',
chain_id,
step,
ticket_id,
from_agent,
to_agent,
timestamp: new Date().toISOString(),
context,
artifacts,
dependencies,
protocol: {
pickup: 'Agent sets ticket state to in_progress (24276)',
submit: 'Agent sets ticket state to review (24277)',
approve: 'Supervisor (user_id=53) or human sets state to done (24278)',
reject: 'Supervisor or human sets state to rejected (43438)',
fail: 'Agent keeps state in_progress and adds error to why field',
handoff: 'Agent dispatches next subtask with step+1',
states: 'backlog(24275) → assigned(43436) → in_progress(24276) → review(24277) → control(43437) → done(24278) | rejected(43438)',
},
};
}
// ----- ACTIVITY LOGGING -----
/**
* Log agent activity to the Agent Activity table.
*
* @param {Object} params
* @param {string} params.action - Action type (e.g., 'dispatch_subtask', 'status_update')
* @param {number} [params.agent_id] - Agent user ID
* @param {number} [params.ticket_id] - Related ticket ID
* @param {string} [params.chain_id] - Chain identifier
* @param {Object} [params.details] - Additional details
*/
async function logActivity(params) {
const {
action,
agent_id = null,
ticket_id = null,
chain_id = null,
details = {},
} = params;
try {
const activityData = {
action,
agent_id,
ticket_id,
chain_id,
details: typeof details === 'string' ? details : JSON.stringify(details),
timestamp: new Date().toISOString(),
success: true,
};
const baseId = generateBaseId('activity');
const dataJson = JSON.stringify(activityData);
if (isPostgres()) {
await dbRun(
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
VALUES ($1, $2, $3::jsonb, NOW(), NOW())`,
[AGENT_ACTIVITY_TABLE_ID, baseId, dataJson]
);
} else {
await dbRun(
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
VALUES (?, ?, ?, datetime('now'), datetime('now'))`,
[AGENT_ACTIVITY_TABLE_ID, baseId, dataJson]
);
}
} catch (err) {
// Activity logging should never block the main flow
apiLogger.error({ err, action, agent_id, ticket_id }, 'ChainHandoff: Failed to log activity');
}
}
// ----- STATUS TRACKING -----
/**
* Update ticket status (used by agents to pick up, complete, or fail tasks).
*
* @param {Object} params
* @param {number} params.ticket_id - Ticket row ID
* @param {number} params.new_state - New state ID (24275-24278)
* @param {number} params.agent_id - Agent user ID making the update
* @param {string} [params.notes] - Optional notes/reason for update
* @returns {Promise<Object>} Updated ticket data
*/
async function updateTicketStatus(params) {
const { ticket_id, new_state, agent_id, notes = '' } = params;
if (!ticket_id || !new_state) {
throw new Error('updateTicketStatus requires "ticket_id" and "new_state"');
}
// Validate state
const validStates = Object.values(STATE);
if (!validStates.includes(new_state)) {
throw new Error(`Invalid state ${new_state}. Valid: ${validStates.join(', ')}`);
}
// === ADR-109: Validate transition using same map as API route ===
// Unified state machine — service layer uses identical transition rules
const SERVICE_TRANSITIONS = {
[STATE.BACKLOG]: [STATE.ASSIGNED, STATE.IN_PROGRESS],
[STATE.ASSIGNED]: [STATE.IN_PROGRESS, STATE.BACKLOG],
[STATE.IN_PROGRESS]: [STATE.REVIEW, STATE.BACKLOG],
[STATE.REVIEW]: [STATE.CONTROL, STATE.IN_PROGRESS],
[STATE.CONTROL]: [STATE.DONE, STATE.REJECTED],
[STATE.REJECTED]: [STATE.IN_PROGRESS],
[STATE.DONE]: [], // terminal
};
// Fetch current ticket
const row = 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 = ?`,
[ticket_id, TICKETS_TABLE_ID]
);
if (!row) {
throw new Error(`Ticket ${ticket_id} not found in table ${TICKETS_TABLE_ID}`);
}
const existingData = safeJsonParse(row.data, {});
const oldState = existingData.state;
// === ADR-109: Validate transition before executing ===
if (oldState != null) {
const allowed = SERVICE_TRANSITIONS[oldState] || [];
if (allowed.length > 0 && !allowed.includes(new_state)) {
const stateNames = { [STATE.BACKLOG]: 'backlog', [STATE.ASSIGNED]: 'assigned', [STATE.IN_PROGRESS]: 'in_progress', [STATE.REVIEW]: 'review', [STATE.CONTROL]: 'control', [STATE.REJECTED]: 'rejected', [STATE.DONE]: 'done' };
const fromName = stateNames[oldState] || String(oldState);
const toName = stateNames[new_state] || String(new_state);
const allowedNames = allowed.map(s => stateNames[s] || String(s)).join(', ');
apiLogger.warn({ ticket_id, oldState, new_state, allowed }, `Service: Invalid transition ${fromName}${toName}`);
throw new Error(`Invalid transition: ${fromName}${toName}. Allowed: [${allowedNames}]`);
}
}
// === ADR-0002 §8 Phase 3 (G4) — completion gate (service-layer enforcement) ===
// The HTTP layer (PATCH /tickets/:id/status, PUT /tables/1708/rows/:id)
// already runs this gate. Repeating it here closes the agent path:
// ChainHandoffService.updateTicketStatus is also called from the
// chat→delegation fast-path and agent-tools, neither of which goes through
// the HTTP gate. The Error is caught by callers and surfaced as 409.
if (new_state === STATE.DONE && oldState !== STATE.DONE) {
try {
const gate = await checkCompletionGate(ticket_id);
if (!gate.ok) {
apiLogger.info(
{ ticket_id, must_total: gate.must_total, must_verified: gate.must_verified, blocker_count: gate.blockers.length },
'ADR-0002 G4: completion gate blocked done transition (service path)'
);
const blockerCodes = gate.blockers
.map(b => b.code || `#${b.id}`)
.slice(0, 5)
.join(', ');
const err = new Error(
`MUST_CRITERIA_INCOMPLETE: ${gate.blockers.length}/${gate.must_total} must-criteria not verified` +
(blockerCodes ? ` (${blockerCodes})` : '')
);
err.code = 'MUST_CRITERIA_INCOMPLETE';
err.statusCode = 409;
err.gate = {
must_total: gate.must_total,
must_verified: gate.must_verified,
failed: gate.blockers,
};
throw err;
}
} catch (gateErr) {
// Re-throw gate rejections; only swallow query failures (no `gate` field).
if (gateErr.code === 'MUST_CRITERIA_INCOMPLETE') throw gateErr;
apiLogger.warn({ err: gateErr.message, ticket_id }, 'completion gate query failed (service path), allowing transition');
}
}
// Update data with new state and chain status
const updatedData = {
...existingData,
state: new_state,
};
// Update chain metadata status (Phase 1 — 7 states)
if (updatedData._chain) {
if (new_state === STATE.ASSIGNED) {
updatedData._chain.status = 'assigned';
updatedData._chain.assigned_at = new Date().toISOString();
} else if (new_state === STATE.IN_PROGRESS) {
updatedData._chain.status = 'in_progress';
updatedData._chain.picked_up_at = new Date().toISOString();
updatedData._chain.picked_up_by = agent_id;
} else if (new_state === STATE.REVIEW) {
updatedData._chain.status = 'review';
} else if (new_state === STATE.CONTROL) {
updatedData._chain.status = 'control';
updatedData._chain.control_at = new Date().toISOString();
} else if (new_state === STATE.REJECTED) {
updatedData._chain.status = 'rejected';
updatedData._chain.rejected_at = new Date().toISOString();
updatedData._chain.rejected_by = agent_id;
} else if (new_state === STATE.DONE) {
updatedData._chain.status = 'completed';
updatedData._chain.completed_at = new Date().toISOString();
updatedData._chain.completed_by = agent_id;
}
}
// Add notes to why field if provided
if (notes) {
updatedData.why = existingData.why
? `${existingData.why}\n\n[Agent ${agent_id} @ ${new Date().toISOString()}]: ${notes}`
: `[Agent ${agent_id} @ ${new Date().toISOString()}]: ${notes}`;
}
const updatedJson = JSON.stringify(updatedData);
if (isPostgres()) {
await dbRun(
`UPDATE table_rows SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
[updatedJson, ticket_id]
);
} else {
await dbRun(
`UPDATE table_rows SET data = ?, updated_at = datetime('now') WHERE id = ?`,
[updatedJson, ticket_id]
);
}
// Log activity
await logActivity({
action: 'status_update',
agent_id,
ticket_id,
chain_id: updatedData._chain?.chain_id,
details: {
old_state: oldState,
new_state,
notes,
},
});
apiLogger.info({
ticket_id,
oldState,
new_state,
agent_id,
chain_id: updatedData._chain?.chain_id,
}, 'ChainHandoff: Ticket status updated');
return {
ticket_id,
old_state: oldState,
new_state,
chain_id: updatedData._chain?.chain_id,
data: updatedData,
};
}
export {
resolveAgentId,
getAgentName,
getStateName,
getTicket,
getChainTasks,
getChainStatus,
getAgentPendingTasks,
buildHandoffMetadata,
logActivity,
updateTicketStatus,
};