Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
514 lines
16 KiB
JavaScript
514 lines
16 KiB
JavaScript
/**
|
|
* AIAgentsPackService - Creates AI Agents project with all necessary tables
|
|
*
|
|
* Creates:
|
|
* - API Keys table
|
|
* - Agents table
|
|
* - Prompts Library
|
|
* - Message Logs
|
|
* - Variable Mappings
|
|
* - AI Analytics
|
|
*/
|
|
|
|
import { dbGet, dbRun, dbAll } from '../database/connection.js';
|
|
import { aiLogger } from '../utils/logger.js';
|
|
|
|
/**
|
|
* Generate unique base_id for rows
|
|
*/
|
|
function generateBaseId(prefix = 'row') {
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
const AI_TABLES_TO_COPY = [
|
|
'ai_operators',
|
|
'ai_agents',
|
|
'ai_chat_history',
|
|
'ai_run_logs',
|
|
'ai_usage_analytics',
|
|
'ai_feedback',
|
|
'ai_api_keys',
|
|
'ai_models',
|
|
'ai_tools',
|
|
'api_keys_list'
|
|
];
|
|
|
|
const AI_TABLES_SKIP_ROWS = new Set(['ai_chat_history']);
|
|
|
|
function normalizeTableName(name) {
|
|
if (!name) return '';
|
|
return String(name).trim().toLowerCase().replace(/\s+/g, '_');
|
|
}
|
|
|
|
/**
|
|
* Get table schema columns from PostgreSQL information_schema
|
|
*/
|
|
async function getTableSchemaColumns(tableName) {
|
|
const rows = await dbAll(`
|
|
SELECT column_name as name
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
ORDER BY ordinal_position
|
|
`, [tableName]);
|
|
return rows.map((row) => row.name);
|
|
}
|
|
|
|
function normalizeRowData(data, oldIdToName, newNameToId) {
|
|
if (!data || typeof data !== 'object') return data;
|
|
const normalized = {};
|
|
|
|
Object.entries(data).forEach(([key, value]) => {
|
|
if (oldIdToName[key]) {
|
|
return;
|
|
}
|
|
normalized[key] = value;
|
|
});
|
|
|
|
Object.entries(oldIdToName).forEach(([oldId, columnName]) => {
|
|
if (data[oldId] === undefined) return;
|
|
const newId = newNameToId[columnName];
|
|
if (newId && normalized[newId] === undefined) {
|
|
normalized[newId] = data[oldId];
|
|
}
|
|
if (normalized[columnName] === undefined) {
|
|
normalized[columnName] = data[oldId];
|
|
}
|
|
});
|
|
|
|
return normalized;
|
|
}
|
|
|
|
async function copyAIAgentsTablesFromSystemData(targetProjectId) {
|
|
// Find Development space (admin system space with maintained AI Agents tables)
|
|
const sourceSpace = await dbGet(
|
|
"SELECT id FROM spaces WHERE LOWER(name) = 'development' LIMIT 1"
|
|
);
|
|
|
|
if (!sourceSpace) {
|
|
throw new Error('Development space not found - cannot copy AI Agents tables');
|
|
}
|
|
|
|
// Find System Data project (contains AI Agents template tables)
|
|
let sourceProject = await dbGet(
|
|
"SELECT id FROM projects WHERE space_id = ? AND name = 'System Data' ORDER BY id ASC LIMIT 1",
|
|
[sourceSpace.id]
|
|
);
|
|
|
|
// Fallback: find any project with AI tables
|
|
if (!sourceProject) {
|
|
const tableList = AI_TABLES_TO_COPY.map((name) => `'${name}'`).join(', ');
|
|
sourceProject = await dbGet(
|
|
`
|
|
SELECT DISTINCT ut.project_id as id
|
|
FROM universal_tables ut
|
|
JOIN projects p ON p.id = ut.project_id
|
|
WHERE p.space_id = ?
|
|
AND LOWER(REPLACE(ut.name, ' ', '_')) IN (${tableList})
|
|
ORDER BY ut.project_id ASC
|
|
LIMIT 1
|
|
`,
|
|
[sourceSpace.id]
|
|
);
|
|
}
|
|
|
|
if (!sourceProject) {
|
|
throw new Error('System Data project not found in Development/NeoMetal space');
|
|
}
|
|
|
|
const sourceTables = await dbAll(
|
|
'SELECT * FROM universal_tables WHERE project_id = ?',
|
|
[sourceProject.id]
|
|
);
|
|
const tablesByName = new Map();
|
|
sourceTables.forEach((table) => {
|
|
const normalized = normalizeTableName(table.name || table.display_name);
|
|
if (normalized) {
|
|
tablesByName.set(normalized, table);
|
|
}
|
|
});
|
|
|
|
const tableSchema = await getTableSchemaColumns('universal_tables');
|
|
const tableCopyFields = tableSchema.filter((col) => !['id', 'project_id'].includes(col));
|
|
const tableInsertSql = `
|
|
INSERT INTO universal_tables (project_id, ${tableCopyFields.join(',')})
|
|
VALUES (?, ${tableCopyFields.map(() => '?').join(',')})
|
|
`;
|
|
|
|
const columnSchema = await getTableSchemaColumns('table_columns');
|
|
const columnCopyFields = columnSchema.filter((col) => !['id', 'table_id'].includes(col));
|
|
const columnInsertSql = `
|
|
INSERT INTO table_columns (table_id, ${columnCopyFields.join(',')})
|
|
VALUES (?, ${columnCopyFields.map(() => '?').join(',')})
|
|
`;
|
|
|
|
const rowSchema = await getTableSchemaColumns('table_rows');
|
|
const rowCopyFields = rowSchema.filter((col) => !['id', 'table_id'].includes(col));
|
|
const rowInsertSql = `
|
|
INSERT INTO table_rows (table_id, ${rowCopyFields.join(',')})
|
|
VALUES (?, ${rowCopyFields.map(() => '?').join(',')})
|
|
`;
|
|
|
|
const createdTables = {};
|
|
|
|
for (const tableName of AI_TABLES_TO_COPY) {
|
|
const sourceTable = tablesByName.get(tableName);
|
|
if (!sourceTable) {
|
|
aiLogger.warn({ tableName }, 'Source table not found in System Data');
|
|
continue;
|
|
}
|
|
|
|
const tableValues = tableCopyFields.map((field) => sourceTable[field]);
|
|
const tableResult = await dbRun(tableInsertSql, [targetProjectId, ...tableValues]);
|
|
const targetTableId = tableResult.lastInsertRowid || tableResult.lastID;
|
|
createdTables[tableName] = targetTableId;
|
|
|
|
const sourceColumns = await dbAll(
|
|
'SELECT * FROM table_columns WHERE table_id = ? ORDER BY order_index ASC, id ASC',
|
|
[sourceTable.id]
|
|
);
|
|
const oldIdToName = {};
|
|
for (const col of sourceColumns) {
|
|
if (col.column_name) {
|
|
oldIdToName[col.id] = col.column_name;
|
|
}
|
|
const values = columnCopyFields.map((field) => col[field]);
|
|
await dbRun(columnInsertSql, [targetTableId, ...values]);
|
|
}
|
|
|
|
const newColumns = await dbAll(
|
|
'SELECT id, column_name FROM table_columns WHERE table_id = ?',
|
|
[targetTableId]
|
|
);
|
|
const newNameToId = {};
|
|
newColumns.forEach((col) => {
|
|
if (col.column_name) {
|
|
newNameToId[col.column_name] = col.id;
|
|
}
|
|
});
|
|
|
|
if (tableName === 'ai_agents' && !newNameToId.context_settings) {
|
|
const maxOrder = await dbGet(
|
|
'SELECT MAX(order_index) as maxOrder FROM table_columns WHERE table_id = ?',
|
|
[targetTableId]
|
|
);
|
|
const nextOrder = (maxOrder?.maxOrder || 0) + 1;
|
|
await dbRun(
|
|
`INSERT INTO table_columns (table_id, column_name, display_name, type, is_required, config, order_index, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 0, NULL, ?, datetime('now'), datetime('now'))`,
|
|
[targetTableId, 'context_settings', 'Context Settings', 'long_text', nextOrder]
|
|
);
|
|
const contextColumn = await dbGet(
|
|
'SELECT id FROM table_columns WHERE table_id = ? AND column_name = ?',
|
|
[targetTableId, 'context_settings']
|
|
);
|
|
if (contextColumn?.id) {
|
|
newNameToId.context_settings = contextColumn.id;
|
|
}
|
|
}
|
|
|
|
if (AI_TABLES_SKIP_ROWS.has(tableName)) {
|
|
aiLogger.debug({ tableName }, 'Skipping rows');
|
|
continue;
|
|
}
|
|
|
|
const sourceRows = await dbAll(
|
|
'SELECT * FROM table_rows WHERE table_id = ?',
|
|
[sourceTable.id]
|
|
);
|
|
for (const row of sourceRows) {
|
|
let dataValue = row.data;
|
|
if (typeof dataValue === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(dataValue);
|
|
dataValue = JSON.stringify(normalizeRowData(parsed, oldIdToName, newNameToId));
|
|
} catch {
|
|
dataValue = row.data;
|
|
}
|
|
}
|
|
const rowValues = rowCopyFields.map((field) => (field === 'data' ? dataValue : row[field]));
|
|
await dbRun(rowInsertSql, [targetTableId, ...rowValues]);
|
|
}
|
|
|
|
aiLogger.debug({ table: sourceTable.name, targetTableId, rowCount: sourceRows.length }, 'Copied table');
|
|
}
|
|
|
|
return { sourceProjectId: sourceProject.id, createdTables };
|
|
}
|
|
|
|
/**
|
|
* Create a project within a space
|
|
*/
|
|
async function createProject(spaceId, name, type, icon, description, ownerId) {
|
|
const result = await dbRun(`
|
|
INSERT INTO projects (name, type, icon, description, owner_id, space_id, settings, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, '{}', datetime('now'), datetime('now'))
|
|
`, [name, type, icon, description, ownerId, spaceId]);
|
|
|
|
aiLogger.info({ name, projectId: result.lastInsertRowid }, 'Created project');
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
/**
|
|
* Create a table within a project
|
|
*/
|
|
async function createTable(projectId, name, icon, description) {
|
|
const result = await dbRun(`
|
|
INSERT INTO universal_tables (project_id, name, icon, description, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
`, [projectId, name, icon, description]);
|
|
|
|
aiLogger.debug({ name, tableId: result.lastInsertRowid }, 'Created table');
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
/**
|
|
* Create columns for a table
|
|
*/
|
|
async function createColumns(tableId, columns) {
|
|
for (let i = 0; i < columns.length; i++) {
|
|
const col = columns[i];
|
|
const config = {
|
|
icon: col.icon || null,
|
|
...(col.settings || {})
|
|
};
|
|
await dbRun(`
|
|
INSERT INTO table_columns (table_id, column_name, display_name, type, config, width, is_required, order_index, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
`, [
|
|
tableId,
|
|
col.key || col.name.toLowerCase().replace(/\s+/g, '_'),
|
|
col.name,
|
|
col.type,
|
|
JSON.stringify(config),
|
|
col.width || 150,
|
|
col.required ? 1 : 0,
|
|
i + 1
|
|
]);
|
|
}
|
|
aiLogger.debug({ count: columns.length }, 'Added columns');
|
|
}
|
|
|
|
/**
|
|
* Create rows for a table
|
|
*/
|
|
async function createRows(tableId, rows, createdBy) {
|
|
for (const row of rows) {
|
|
await dbRun(`
|
|
INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
`, [tableId, generateBaseId(), JSON.stringify(row), createdBy]);
|
|
}
|
|
aiLogger.debug({ count: rows.length }, 'Added sample rows');
|
|
}
|
|
|
|
/**
|
|
* Create AI Agents Pack for a space
|
|
*/
|
|
export async function createAIAgentsPack(spaceId, ownerId) {
|
|
aiLogger.info({ spaceId }, 'Creating AI Agents Pack');
|
|
|
|
const projectId = await createProject(
|
|
spaceId,
|
|
'AI Agents',
|
|
'ai_agents',
|
|
'🤖',
|
|
'AI agents management, prompts and message logs',
|
|
ownerId
|
|
);
|
|
|
|
const copied = await copyAIAgentsTablesFromSystemData(projectId);
|
|
|
|
aiLogger.info({ projectId, sourceProjectId: copied.sourceProjectId }, 'AI Agents Pack copied from System Data');
|
|
|
|
return {
|
|
success: true,
|
|
projectId
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ensure Agents table has required columns (tags, vector, response_mode)
|
|
* Call this to upgrade existing tables.
|
|
* ADR-091 Phase 1 Task 2: adds response_mode select column with default 'mention_only'.
|
|
*/
|
|
export async function ensureAgentsTableColumns(tableId) {
|
|
aiLogger.info({ tableId }, 'Ensuring Agents table has required columns');
|
|
|
|
// Get existing columns
|
|
const existingColumns = await dbAll(
|
|
'SELECT column_name FROM table_columns WHERE table_id = ?',
|
|
[tableId]
|
|
);
|
|
const existingNames = new Set(existingColumns.map(c => c.column_name));
|
|
|
|
// Get max order_index
|
|
const maxOrderRow = await dbGet(
|
|
'SELECT MAX(order_index) as max_order FROM table_columns WHERE table_id = ?',
|
|
[tableId]
|
|
);
|
|
let nextOrder = (maxOrderRow?.max_order || 0) + 1;
|
|
|
|
const columnsToAdd = [];
|
|
|
|
// Check for tags column
|
|
if (!existingNames.has('tags')) {
|
|
columnsToAdd.push({
|
|
key: 'tags',
|
|
name: 'Tags',
|
|
type: 'multi_select',
|
|
icon: '🏷️',
|
|
order: nextOrder++,
|
|
settings: {
|
|
options: [
|
|
{ value: 'assistant', label: 'Assistant', color: '#3B82F6' },
|
|
{ value: 'builder', label: 'Builder', color: '#10B981' },
|
|
{ value: 'analyst', label: 'Analyst', color: '#8B5CF6' },
|
|
{ value: 'creative', label: 'Creative', color: '#F59E0B' },
|
|
{ value: 'code', label: 'Code', color: '#EF4444' },
|
|
{ value: 'data', label: 'Data', color: '#06B6D4' },
|
|
{ value: 'utility', label: 'Utility', color: '#6B7280' }
|
|
]
|
|
}
|
|
});
|
|
}
|
|
|
|
// Check for vector column
|
|
if (!existingNames.has('vector')) {
|
|
columnsToAdd.push({
|
|
key: 'vector',
|
|
name: 'Vector',
|
|
type: 'vector',
|
|
icon: '🧬',
|
|
order: nextOrder++,
|
|
settings: {
|
|
formula: '{{name}} | {{description}} | {{system_prompt}}',
|
|
agent_id: null,
|
|
auto_generate: true
|
|
}
|
|
});
|
|
}
|
|
|
|
// Check for response_mode column (ADR-091: Unified Conversation Model)
|
|
if (!existingNames.has('response_mode')) {
|
|
columnsToAdd.push({
|
|
key: 'response_mode',
|
|
name: 'Response Mode',
|
|
type: 'select',
|
|
icon: '💬',
|
|
order: nextOrder++,
|
|
settings: {
|
|
options: [
|
|
{ value: 'always', label: 'Always respond' },
|
|
{ value: 'topic_only', label: 'Topic only' },
|
|
{ value: 'mention_only', label: 'Mention only' }
|
|
],
|
|
defaultValue: 'mention_only'
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add missing columns
|
|
for (const col of columnsToAdd) {
|
|
const config = { icon: col.icon, ...col.settings };
|
|
await dbRun(`
|
|
INSERT INTO table_columns (table_id, column_name, display_name, type, config, width, is_required, order_index, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 150, 0, ?, datetime('now'), datetime('now'))
|
|
`, [tableId, col.key, col.name, col.type, JSON.stringify(config), col.order]);
|
|
|
|
aiLogger.info({ tableId, column: col.key }, 'Added missing column to Agents table');
|
|
}
|
|
|
|
return {
|
|
added: columnsToAdd.map(c => c.key),
|
|
existed: [...existingNames]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Upgrade all Agents tables in all spaces with missing columns
|
|
*/
|
|
export async function upgradeAllAgentsTables() {
|
|
aiLogger.info({}, 'Upgrading all Agents tables');
|
|
|
|
// Find all Agents tables — match various naming conventions
|
|
const agentsTables = await dbAll(`
|
|
SELECT ut.id, ut.name, p.name as project_name, s.name as space_name
|
|
FROM universal_tables ut
|
|
JOIN projects p ON ut.project_id = p.id
|
|
JOIN spaces s ON p.space_id = s.id
|
|
WHERE LOWER(ut.name) = 'agents'
|
|
OR LOWER(ut.name) = 'ai agents'
|
|
OR LOWER(ut.name) LIKE '%ai_agents%'
|
|
OR ut.name LIKE '%Agents%'
|
|
OR ut.name LIKE '%agents%'
|
|
`);
|
|
|
|
const results = [];
|
|
for (const table of agentsTables) {
|
|
try {
|
|
const result = await ensureAgentsTableColumns(table.id);
|
|
results.push({
|
|
tableId: table.id,
|
|
tableName: table.name,
|
|
spaceName: table.space_name,
|
|
...result
|
|
});
|
|
} catch (error) {
|
|
aiLogger.error({ err: error, tableId: table.id }, 'Failed to upgrade Agents table');
|
|
results.push({
|
|
tableId: table.id,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Install AI Agents Pack for a user
|
|
* Creates a space and adds AI Agents project
|
|
*/
|
|
export async function installAIAgentsPackForUser(userId) {
|
|
aiLogger.info({ userId }, 'Installing AI Agents Pack for User');
|
|
|
|
try {
|
|
// Check if user already has AI Agents space
|
|
const existingSpace = await dbGet(`
|
|
SELECT id FROM spaces WHERE owner_id = ? AND name = 'AI Agents'
|
|
`, [userId]);
|
|
|
|
if (existingSpace) {
|
|
aiLogger.debug({ userId }, 'User already has AI Agents space, skipping');
|
|
return { success: true, spaceId: existingSpace.id, existing: true };
|
|
}
|
|
|
|
// Create space for AI Agents
|
|
const spaceResult = await dbRun(`
|
|
INSERT INTO spaces (name, type, icon, description, owner_id, settings, created_at, updated_at)
|
|
VALUES ('AI Agents', 'business', '🤖', 'AI Agents workspace with chat, prompts and analytics', ?, '{}', datetime('now'), datetime('now'))
|
|
`, [userId]);
|
|
|
|
const spaceId = spaceResult.lastInsertRowid;
|
|
aiLogger.info({ spaceId }, 'Created AI Agents space');
|
|
|
|
// Create AI Agents Pack
|
|
const result = await createAIAgentsPack(spaceId, userId);
|
|
|
|
return {
|
|
success: true,
|
|
spaceId,
|
|
projectId: result.projectId
|
|
};
|
|
} catch (error) {
|
|
aiLogger.error({ err: error, userId }, 'Failed to install AI Agents Pack');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export default {
|
|
createAIAgentsPack,
|
|
installAIAgentsPackForUser,
|
|
ensureAgentsTableColumns,
|
|
upgradeAllAgentsTables
|
|
};
|