Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
195 lines
10 KiB
JavaScript
195 lines
10 KiB
JavaScript
/**
|
|
* Conversation extras routes: bind, bind-lab, labs, settings, sub-agents.
|
|
* Extracted from conversationCrudController.js to keep files under 400 lines.
|
|
*/
|
|
|
|
import {
|
|
dbRun, dbGet, dbAll, isPostgres, safeJsonParse, apiLogger,
|
|
success, created, error, badRequest, notFound,
|
|
requireAuth,
|
|
} from './chatShared.js';
|
|
import {
|
|
resolveAgentUser, validateSubAgentRowIds, enrichSubAgents,
|
|
} from './chatAgentHelpers.js';
|
|
|
|
export default function registerConversationExtrasRoutes(router) {
|
|
|
|
// POST /conversations/:id/bind - Bind to task
|
|
router.post('/conversations/:id/bind', requireAuth, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { table_id, row_id } = req.body;
|
|
if (isPostgres()) await dbRun(`UPDATE conversations SET bound_table_id = $1, bound_row_id = $2, updated_at = NOW() WHERE id = $3`, [table_id, row_id, id]);
|
|
else await dbRun(`UPDATE conversations SET bound_table_id = ?, bound_row_id = ?, updated_at = datetime('now') WHERE id = ?`, [table_id, row_id, id]);
|
|
const conversation = await dbGet(isPostgres() ? `SELECT * FROM conversations WHERE id = $1` : `SELECT * FROM conversations WHERE id = ?`, [id]);
|
|
return success(res, conversation);
|
|
} catch (err) {
|
|
apiLogger.error('Error binding conversation:', err);
|
|
return error(res, 'BIND_CONVERSATION_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// POST /conversations/:id/bind-lab - Bind to lab
|
|
router.post('/conversations/:id/bind-lab', requireAuth, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { lab_id } = req.body;
|
|
if (!lab_id) return badRequest(res, 'lab_id is required');
|
|
if (isPostgres()) await dbRun(`UPDATE conversations SET lab_id = $1, updated_at = NOW() WHERE id = $2`, [lab_id, id]);
|
|
else await dbRun(`UPDATE conversations SET lab_id = ?, updated_at = datetime('now') WHERE id = ?`, [lab_id, id]);
|
|
const conversation = await dbGet(isPostgres() ? `SELECT * FROM conversations WHERE id = $1` : `SELECT * FROM conversations WHERE id = ?`, [id]);
|
|
apiLogger.info({ conversationId: id, labId: lab_id }, 'Conversation bound to lab');
|
|
return success(res, conversation);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error binding conversation to lab');
|
|
return error(res, 'BIND_LAB_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// GET /labs/:labId/conversations - Get conversations for a lab
|
|
router.get('/labs/:labId/conversations', requireAuth, async (req, res) => {
|
|
try {
|
|
const { labId } = req.params;
|
|
const userId = req.user.userId;
|
|
const { limit = 500, offset = 0 } = req.query;
|
|
|
|
const conversations = await dbAll(
|
|
isPostgres()
|
|
? `SELECT c.* FROM conversations c JOIN conversation_participants cp ON c.id = cp.conversation_id WHERE c.lab_id = $1 AND cp.user_id = $2 ORDER BY c.updated_at DESC LIMIT $3 OFFSET $4`
|
|
: `SELECT c.* FROM conversations c JOIN conversation_participants cp ON c.id = cp.conversation_id WHERE c.lab_id = ? AND cp.user_id = ? ORDER BY c.updated_at DESC LIMIT ? OFFSET ?`,
|
|
[labId, userId, parseInt(limit), parseInt(offset)]
|
|
);
|
|
|
|
const conversationsWithParticipants = await Promise.all(
|
|
conversations.map(async (conv) => {
|
|
const participants = await dbAll(
|
|
isPostgres()
|
|
? `SELECT cp.user_id, cp.role, u.name, u.email, COALESCE(u.avatar, '') as avatar_url FROM conversation_participants cp JOIN users u ON cp.user_id = u.id WHERE cp.conversation_id = $1 ORDER BY cp.joined_at ASC`
|
|
: `SELECT cp.user_id, cp.role, u.name, u.email FROM conversation_participants cp JOIN users u ON cp.user_id = u.id WHERE cp.conversation_id = ? ORDER BY cp.joined_at ASC`,
|
|
[conv.id]
|
|
);
|
|
return { ...conv, participants };
|
|
})
|
|
);
|
|
|
|
return success(res, conversationsWithParticipants);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error getting lab conversations');
|
|
return error(res, 'GET_LAB_CONVERSATIONS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// PUT /conversations/:id/sub-agents - Update sub_agents
|
|
router.put('/conversations/:id/sub-agents', requireAuth, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { sub_agents } = req.body;
|
|
if (!Array.isArray(sub_agents)) return badRequest(res, 'sub_agents must be an array');
|
|
|
|
let subAgentsJson = '[]';
|
|
if (sub_agents.length > 0) {
|
|
const validIds = await validateSubAgentRowIds(sub_agents);
|
|
if (validIds.length === 0) return badRequest(res, 'None of the provided sub_agents row_ids are valid AI Agents');
|
|
const normalizedSubAgents = sub_agents
|
|
.map(item => { const rowId = typeof item === 'object' ? item.row_id : item; if (!validIds.includes(rowId)) return null; if (typeof item === 'object') return { row_id: item.row_id, response_mode: item.response_mode || 'always' }; return { row_id: item, response_mode: 'always' }; })
|
|
.filter(Boolean);
|
|
subAgentsJson = JSON.stringify(normalizedSubAgents);
|
|
}
|
|
|
|
if (isPostgres()) await dbRun(`UPDATE conversations SET sub_agents = $1::jsonb, updated_at = NOW() WHERE id = $2`, [subAgentsJson, id]);
|
|
else await dbRun(`UPDATE conversations SET sub_agents = ?, updated_at = datetime('now') WHERE id = ?`, [subAgentsJson, id]);
|
|
|
|
const enriched = await enrichSubAgents(JSON.parse(subAgentsJson));
|
|
apiLogger.info({ conversationId: id, subAgentsCount: enriched.length }, 'Ticket #41053: Updated sub_agents');
|
|
return success(res, { sub_agents: enriched });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error updating sub_agents');
|
|
return error(res, 'UPDATE_SUB_AGENTS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// POST /conversations/:id/settings - Save chat settings
|
|
router.post('/conversations/:id/settings', requireAuth, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const newSettings = req.body;
|
|
const existing = await dbGet(isPostgres() ? `SELECT settings FROM conversations WHERE id = $1` : `SELECT settings FROM conversations WHERE id = ?`, [id]);
|
|
let currentSettings = {};
|
|
if (existing?.settings) { try { currentSettings = JSON.parse(existing.settings); } catch { /* ignore */ } }
|
|
const merged = { ...currentSettings, ...newSettings };
|
|
if (isPostgres()) await dbRun(`UPDATE conversations SET settings = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(merged), id]);
|
|
else await dbRun(`UPDATE conversations SET settings = ?, updated_at = datetime('now') WHERE id = ?`, [JSON.stringify(merged), id]);
|
|
return success(res, { saved: true });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error saving settings');
|
|
return error(res, 'SAVE_SETTINGS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// PATCH /conversations/:id/readonly — ADR-0068 WP-B (B3).
|
|
// Toggle channel-style lock. Only the conversation creator OR space owner can
|
|
// flip it. Owners always retain write access via sender_kind='space'; non-owners
|
|
// see a read-only banner with a deep link to the comment thread (resolved on
|
|
// the client via comment_thread_child_id in the list response).
|
|
router.patch('/conversations/:id/readonly', requireAuth, async (req, res) => {
|
|
const conversationId = Number(req.params.id);
|
|
if (!Number.isFinite(conversationId) || conversationId <= 0) {
|
|
return badRequest(res, 'invalid conversation id');
|
|
}
|
|
const desired = req.body?.is_readonly;
|
|
if (typeof desired !== 'boolean') {
|
|
return badRequest(res, 'is_readonly must be a boolean');
|
|
}
|
|
const userId = req.user.userId;
|
|
try {
|
|
const conv = await dbGet(
|
|
isPostgres()
|
|
? `SELECT c.id, c.created_by, c.space_id, s.owner_id AS space_owner_id
|
|
FROM conversations c LEFT JOIN spaces s ON s.id = c.space_id
|
|
WHERE c.id = $1`
|
|
: `SELECT c.id, c.created_by, c.space_id, s.owner_id AS space_owner_id
|
|
FROM conversations c LEFT JOIN spaces s ON s.id = c.space_id
|
|
WHERE c.id = ?`,
|
|
[conversationId]
|
|
);
|
|
if (!conv) return notFound(res, 'Conversation not found');
|
|
|
|
const isCreator = Number(conv.created_by) === Number(userId);
|
|
const isSpaceOwner = conv.space_owner_id != null
|
|
&& Number(conv.space_owner_id) === Number(userId);
|
|
if (!isCreator && !isSpaceOwner) {
|
|
return error(res, 'FORBIDDEN', 'Only the conversation creator or space owner can toggle readonly', 403);
|
|
}
|
|
|
|
await dbRun(
|
|
isPostgres()
|
|
? `UPDATE conversations SET is_readonly = $1, updated_at = NOW() WHERE id = $2`
|
|
: `UPDATE conversations SET is_readonly = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
[desired, conversationId]
|
|
);
|
|
apiLogger.info({ conversationId, userId, desired }, 'ADR-0068 WP-B (B3): readonly toggled');
|
|
return success(res, { id: conversationId, is_readonly: desired });
|
|
} catch (err) {
|
|
apiLogger.error({ err, conversationId }, 'ADR-0068 WP-B (B3): readonly toggle failed');
|
|
return error(res, 'READONLY_TOGGLE_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// PATCH /conversations/:id/settings - Update conversation settings
|
|
router.patch('/conversations/:id/settings', requireAuth, async (req, res) => {
|
|
const conversationId = Number(req.params.id);
|
|
if (!conversationId) return badRequest(res, 'Invalid conversation ID');
|
|
try {
|
|
const conv = await dbGet(isPostgres() ? 'SELECT id, settings FROM conversations WHERE id = $1' : 'SELECT id, settings FROM conversations WHERE id = ?', [conversationId]);
|
|
if (!conv) return notFound(res, 'Conversation');
|
|
const existingSettings = safeJsonParse(conv.settings, {});
|
|
const mergedSettings = { ...existingSettings, ...req.body };
|
|
const settingsJson = JSON.stringify(mergedSettings);
|
|
await dbRun(isPostgres() ? 'UPDATE conversations SET settings = $1, updated_at = NOW() WHERE id = $2' : "UPDATE conversations SET settings = ?, updated_at = datetime('now') WHERE id = ?", [settingsJson, conversationId]);
|
|
return success(res, { settings: mergedSettings });
|
|
} catch (err) {
|
|
apiLogger.error({ err, conversationId }, 'Error updating conversation settings');
|
|
return error(res, 'SETTINGS_UPDATE_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
}
|