godcrm/backend/services/agent-tools/copy-tools.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

511 lines
20 KiB
JavaScript

/**
* Copy Tool Handlers — copy_table / copy_project / copy_space
*
* Marketplace staging-space curation primitives. All three support modes
* schema_only / full / template, run-wide relation-awareness (rewriting
* intra-scope relation column targets + cell values via row_map), and
* cascade rollback on mid-op failure.
*/
import { dbGet, dbRun, dbAll, sqlNow } from '../../database/connection.js';
import { generateBaseId } from '../../utils/baseId.js';
import { parseRowData } from './data-tools.js';
import { aiLogger } from '../../utils/logger.js';
// Tables we refuse to copy even when explicitly inside a project/space scope.
// _secrets / _secrets_audit are owner-bound and meaningless outside the
// origin space. (Document widget tables _atoms / _registry / _doc_statuses
// ARE in scope — they hold real user content.)
const SKIP_TABLE_NAMES = new Set(['_secrets', '_secrets_audit']);
// Per-row fields scrubbed automatically in 'template' mode in addition to
// user-supplied strip_columns. Keeps copied artifacts ownerless/timestampless.
const TEMPLATE_DEFAULT_STRIP = new Set([
'owner_id', 'created_by', 'created_at', 'updated_at',
'assigned_to', 'assigned_by', 'sealed_by', 'sealed_at',
]);
function safeJson(s) {
if (s === null || s === undefined) return null;
if (typeof s === 'object') return s;
try { return JSON.parse(s); } catch { return null; }
}
// Both relation config shapes seen in DB:
// short: { relation_table: <id|null> }
// full: { relation: { enabled: true, tableId: "<id>", ... } }
function parseRelationTarget(rawConfig) {
const config = safeJson(rawConfig);
if (!config) return null;
if (Object.prototype.hasOwnProperty.call(config, 'relation_table')) {
const v = config.relation_table;
if (v === null || v === undefined || v === '') return null;
return Number(v);
}
if (config.relation && config.relation.enabled) {
const v = config.relation.tableId;
if (v === null || v === undefined || v === '') return null;
return Number(v);
}
return null;
}
function patchRelationTargetInConfig(rawConfig, newTargetId) {
const cfg = safeJson(rawConfig) || {};
if (Object.prototype.hasOwnProperty.call(cfg, 'relation_table')) {
cfg.relation_table = newTargetId;
}
if (cfg.relation && cfg.relation.enabled) {
cfg.relation.tableId = String(newTargetId);
}
return cfg;
}
function remapRelationValue(rawValue, subRowMap) {
if (rawValue === null || rawValue === undefined || rawValue === '') {
return rawValue;
}
if (Array.isArray(rawValue)) {
return rawValue
.map(v => subRowMap[String(v)] ?? null)
.filter(v => v !== null);
}
return subRowMap[String(rawValue)] ?? null;
}
async function insertColumn(newTableId, col, idx) {
const cfgStr = typeof col.config === 'string' ? col.config : JSON.stringify(col.config ?? {});
const result = await dbRun(
`INSERT INTO table_columns
(table_id, column_name, display_name, type, config, width, is_required, order_index, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
[newTableId, col.column_name, col.display_name, col.type, cfgStr,
col.width ?? 150, col.is_required ?? 0, col.order_index ?? idx]
);
return result.lastInsertRowid || result.lastID;
}
async function insertRow(tableId, data, userId) {
const baseId = generateBaseId();
const result = await dbRun(
`INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
[tableId, baseId, JSON.stringify(data), userId || 1]
);
return result.lastInsertRowid || result.lastID;
}
// =========================================================================
// copyTableCore — single-table worker shared by all three public handlers.
// Mutates `tableMap`, `rowMap`, `tracking` so callers can chain calls and
// perform a second-pass relation rewrite once every table exists.
// =========================================================================
async function copyTableCore(opts, userId) {
const {
src_table_id, dst_project_id, name, icon, description,
mode, row_filter, strip_columns,
keep_external_relations,
ctxTableIds, tableMap, rowMap, tracking,
} = opts;
const src = await dbGet('SELECT * FROM universal_tables WHERE id = ?', [src_table_id]);
if (!src) return { error: `src table ${src_table_id} not found` };
const dstProj = await dbGet('SELECT id FROM projects WHERE id = ?', [dst_project_id]);
if (!dstProj) return { error: `dst project ${dst_project_id} not found` };
const srcColumns = await dbAll(
`SELECT id, column_name, display_name, type, config, width, is_required, order_index
FROM table_columns WHERE table_id = ? ORDER BY order_index, id`,
[src_table_id]
);
const stripColSet = new Set(strip_columns || []);
const droppedRelations = [];
const colsToCopy = [];
for (const c of srcColumns) {
if (stripColSet.has(c.column_name)) continue;
if (c.type === 'relation' || c.type === 'link') {
const targetTableId = parseRelationTarget(c.config);
if (targetTableId === null) {
colsToCopy.push({ src: c, willRewriteTarget: false });
continue;
}
if (ctxTableIds.has(Number(targetTableId))) {
colsToCopy.push({ src: c, willRewriteTarget: true, oldTargetTableId: Number(targetTableId) });
continue;
}
if (keep_external_relations) {
colsToCopy.push({ src: c, willRewriteTarget: false });
continue;
}
droppedRelations.push({
table_id: src_table_id,
table_name: src.name,
column: c.column_name,
reason: 'external relation target not in copy scope',
original_target: targetTableId,
});
continue;
}
colsToCopy.push({ src: c, willRewriteTarget: false });
}
// ADR-164 Phase 3a: inherit ONLY the agent marker onto the copy, so a copied
// AI Agents table stays marker-identifiable. Other table_types (documents_*, etc.)
// are deliberately NOT carried — keeping copy semantics byte-identical outside the
// agent case (in-scope discipline; a general table_type carry-over is a separate call).
const copyTableType = src.table_type === 'ai_agents' ? 'ai_agents' : null;
const tableInsert = await dbRun(
`INSERT INTO universal_tables (project_id, name, icon, description, table_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
[dst_project_id,
name || `${src.name} (copy)`,
icon || src.icon || '📊',
description ?? src.description ?? '',
copyTableType]
);
const newTableId = tableInsert.lastInsertRowid || tableInsert.lastID;
tracking.tables.push(newTableId);
tableMap[String(src_table_id)] = newTableId;
const colMap = {};
for (let i = 0; i < colsToCopy.length; i++) {
const { src: c, willRewriteTarget, oldTargetTableId } = colsToCopy[i];
const newColId = await insertColumn(newTableId, {
column_name: c.column_name,
display_name: c.display_name,
type: c.type,
config: safeJson(c.config) || {},
width: c.width,
is_required: c.is_required,
order_index: c.order_index ?? (i + 1),
}, i);
colMap[c.column_name] = { new_id: newColId, type: c.type, willRewriteTarget, oldTargetTableId };
}
let copiedRows = 0;
if (mode !== 'schema_only') {
const limit = (row_filter && Number.isInteger(row_filter.limit)) ? row_filter.limit : null;
const baseQ = 'SELECT id, data FROM table_rows WHERE table_id = ? ORDER BY id ASC';
const rows = limit ? await dbAll(`${baseQ} LIMIT ?`, [src_table_id, limit])
: await dbAll(baseQ, [src_table_id]);
for (const r of rows) {
const orig = parseRowData(r.data) || {};
const copy = {};
for (const [k, v] of Object.entries(orig)) {
if (stripColSet.has(k)) continue;
if (mode === 'template' && TEMPLATE_DEFAULT_STRIP.has(k)) continue;
if (!colMap[k]) continue; // column was stripped → drop the cell too
copy[k] = v;
}
const newRowId = await insertRow(newTableId, copy, userId);
rowMap[`${src_table_id}:${r.id}`] = newRowId;
copiedRows++;
}
}
return {
success: true,
new_table_id: newTableId,
src_table_id,
copied_rows: copiedRows,
dropped_relations: droppedRelations,
_colMap: colMap,
};
}
// Second pass: every relation column that was in-scope (target table also
// copied) needs (1) its column config rewritten to the new target id and
// (2) every cell value remapped through row_map.
async function rewriteInScopeRelations(perTableMeta, tableMap, rowMap) {
for (const meta of perTableMeta) {
for (const [colName, colInfo] of Object.entries(meta._colMap)) {
if (!colInfo.willRewriteTarget) continue;
const oldTargetId = colInfo.oldTargetTableId;
const newTargetId = tableMap[String(oldTargetId)];
if (!newTargetId) continue;
const colRow = await dbGet('SELECT config FROM table_columns WHERE id = ?', [colInfo.new_id]);
const patched = patchRelationTargetInConfig(colRow?.config, newTargetId);
await dbRun(
`UPDATE table_columns SET config = ?, updated_at = ${sqlNow()} WHERE id = ?`,
[JSON.stringify(patched), colInfo.new_id]
);
const subMap = {};
for (const [k, v] of Object.entries(rowMap)) {
const [src_t, src_r] = k.split(':');
if (Number(src_t) === oldTargetId) subMap[src_r] = v;
}
if (Object.keys(subMap).length === 0) continue;
const rows = await dbAll('SELECT id, data FROM table_rows WHERE table_id = ?', [meta.new_table_id]);
for (const r of rows) {
const data = parseRowData(r.data) || {};
if (!(colName in data)) continue;
data[colName] = remapRelationValue(data[colName], subMap);
await dbRun(
`UPDATE table_rows SET data = ?, updated_at = ${sqlNow()} WHERE id = ?`,
[JSON.stringify(data), r.id]
);
}
}
}
}
async function rollback(tracking) {
try {
for (const tid of tracking.tables) {
try { await dbRun('DELETE FROM table_rows WHERE table_id = ?', [tid]); } catch {}
try { await dbRun('DELETE FROM table_columns WHERE table_id = ?', [tid]); } catch {}
try { await dbRun('DELETE FROM universal_tables WHERE id = ?', [tid]); } catch {}
}
for (const pid of tracking.projects) {
try { await dbRun('DELETE FROM projects WHERE id = ?', [pid]); } catch {}
}
for (const sid of tracking.spaces) {
try { await dbRun('DELETE FROM spaces WHERE id = ?', [sid]); } catch {}
}
} catch (e) {
aiLogger.error({ err: e.message }, 'copy-tools rollback failed');
}
}
function validateMode(mode) {
return ['schema_only', 'full', 'template'].includes(mode);
}
// =========================================================================
// Public handlers
// =========================================================================
export const copyToolHandlers = {
async copy_table(args, userId) {
const {
src_table_id, dst_project_id, name, icon, description,
mode = 'full', row_filter,
strip_columns = [],
keep_external_relations = false,
} = args || {};
if (typeof src_table_id !== 'number') return { error: 'src_table_id is required (number)' };
if (typeof dst_project_id !== 'number') return { error: 'dst_project_id is required (number)' };
if (!validateMode(mode)) return { error: `mode must be one of schema_only|full|template, got ${mode}` };
const tracking = { spaces: [], projects: [], tables: [] };
try {
const ctxTableIds = new Set([Number(src_table_id)]);
const tableMap = {};
const rowMap = {};
const r = await copyTableCore({
src_table_id, dst_project_id, name, icon, description,
mode, row_filter, strip_columns, keep_external_relations,
ctxTableIds, tableMap, rowMap, tracking,
}, userId);
if (r.error) { await rollback(tracking); return { error: r.error }; }
// Single-table self-relations get rewritten too (rare but supported).
await rewriteInScopeRelations([r], tableMap, rowMap);
return {
success: true,
new_table_id: r.new_table_id,
copied_rows: r.copied_rows,
dropped_relations: r.dropped_relations,
message: `Copied table ${src_table_id}${r.new_table_id} (${r.copied_rows} rows, ${r.dropped_relations.length} relations stripped)`,
};
} catch (e) {
await rollback(tracking);
aiLogger.error({ err: e.message, src_table_id }, 'copy_table failed');
return { error: e.message };
}
},
async copy_project(args, userId) {
const {
src_project_id, dst_space_id, name, icon, description,
mode = 'full', strip_columns = [],
keep_external_relations = false,
} = args || {};
if (typeof src_project_id !== 'number') return { error: 'src_project_id is required (number)' };
if (typeof dst_space_id !== 'number') return { error: 'dst_space_id is required (number)' };
if (!validateMode(mode)) return { error: `mode must be one of schema_only|full|template, got ${mode}` };
const srcProj = await dbGet('SELECT * FROM projects WHERE id = ?', [src_project_id]);
if (!srcProj) return { error: `src project ${src_project_id} not found` };
const dstSpace = await dbGet('SELECT id FROM spaces WHERE id = ?', [dst_space_id]);
if (!dstSpace) return { error: `dst space ${dst_space_id} not found` };
const tracking = { spaces: [], projects: [], tables: [] };
try {
const projRes = await dbRun(
`INSERT INTO projects (space_id, name, icon, description, owner_id, type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
[dst_space_id,
name || `${srcProj.name} (copy)`,
icon || srcProj.icon || '📁',
description ?? srcProj.description ?? '',
Number(userId || 1),
srcProj.type || 'project']
);
const newProjectId = projRes.lastInsertRowid || projRes.lastID;
tracking.projects.push(newProjectId);
const srcTables = await dbAll(
'SELECT id, name FROM universal_tables WHERE project_id = ? ORDER BY id ASC',
[src_project_id]
);
const skippedTables = [];
const tablesInScope = [];
for (const t of srcTables) {
if (SKIP_TABLE_NAMES.has(t.name)) {
skippedTables.push({ id: t.id, name: t.name, reason: 'system table' });
} else {
tablesInScope.push(t);
}
}
const ctxTableIds = new Set(tablesInScope.map(t => Number(t.id)));
const tableMap = {};
const rowMap = {};
const perTableMeta = [];
let totalRows = 0;
const droppedRelations = [];
for (const t of tablesInScope) {
const r = await copyTableCore({
src_table_id: Number(t.id),
dst_project_id: newProjectId,
mode, strip_columns, keep_external_relations,
ctxTableIds, tableMap, rowMap, tracking,
}, userId);
if (r.error) throw new Error(`copy of table ${t.id} failed: ${r.error}`);
perTableMeta.push(r);
totalRows += r.copied_rows;
droppedRelations.push(...r.dropped_relations);
}
await rewriteInScopeRelations(perTableMeta, tableMap, rowMap);
return {
success: true,
new_project_id: newProjectId,
copied_tables: tablesInScope.length,
copied_rows: totalRows,
table_map: tableMap,
skipped_tables: skippedTables,
dropped_relations: droppedRelations,
message: `Copied project ${src_project_id}${newProjectId} (${tablesInScope.length} tables, ${totalRows} rows, ${skippedTables.length} skipped, ${droppedRelations.length} relations stripped)`,
};
} catch (e) {
await rollback(tracking);
aiLogger.error({ err: e.message, src_project_id }, 'copy_project failed');
return { error: e.message };
}
},
async copy_space(args, userId) {
const {
src_space_id, dst_owner_id, name, icon, description,
mode = 'full', strip_columns = [],
keep_external_relations = false,
} = args || {};
if (typeof src_space_id !== 'number') return { error: 'src_space_id is required (number)' };
if (!validateMode(mode)) return { error: `mode must be one of schema_only|full|template, got ${mode}` };
const srcSpace = await dbGet('SELECT * FROM spaces WHERE id = ?', [src_space_id]);
if (!srcSpace) return { error: `src space ${src_space_id} not found` };
const tracking = { spaces: [], projects: [], tables: [] };
try {
const ownerId = Number(dst_owner_id || userId || srcSpace.owner_id || 1);
const spaceRes = await dbRun(
`INSERT INTO spaces (owner_id, name, description, icon, type, visibility, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
[ownerId,
name || `${srcSpace.name} (copy)`,
description ?? srcSpace.description ?? null,
icon || srcSpace.icon || '📁',
srcSpace.type || 'workspace',
srcSpace.visibility || 'internal']
);
const newSpaceId = spaceRes.lastInsertRowid || spaceRes.lastID;
tracking.spaces.push(newSpaceId);
const allTables = await dbAll(`
SELECT ut.id, ut.name, ut.project_id
FROM universal_tables ut
JOIN projects p ON ut.project_id = p.id
WHERE p.space_id = ? ORDER BY ut.id ASC`, [src_space_id]);
const tablesInScope = allTables.filter(t => !SKIP_TABLE_NAMES.has(t.name));
const skippedTables = allTables
.filter(t => SKIP_TABLE_NAMES.has(t.name))
.map(t => ({ id: t.id, name: t.name, reason: 'system table' }));
const ctxTableIds = new Set(tablesInScope.map(t => Number(t.id)));
const srcProjects = await dbAll(
'SELECT * FROM projects WHERE space_id = ? ORDER BY id ASC',
[src_space_id]
);
const projectMap = {};
const tableMap = {};
const rowMap = {};
const perTableMeta = [];
let totalTables = 0;
let totalRows = 0;
const droppedRelations = [];
for (const sp of srcProjects) {
const projRes = await dbRun(
`INSERT INTO projects (space_id, name, icon, description, owner_id, type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
[newSpaceId, sp.name, sp.icon || '📁', sp.description || '',
ownerId, sp.type || 'project']
);
const newProjId = projRes.lastInsertRowid || projRes.lastID;
tracking.projects.push(newProjId);
projectMap[String(sp.id)] = newProjId;
const projTables = tablesInScope.filter(t => Number(t.project_id) === Number(sp.id));
for (const t of projTables) {
const r = await copyTableCore({
src_table_id: Number(t.id),
dst_project_id: newProjId,
mode, strip_columns, keep_external_relations,
ctxTableIds, tableMap, rowMap, tracking,
}, userId);
if (r.error) throw new Error(`copy of table ${t.id} failed: ${r.error}`);
perTableMeta.push(r);
totalRows += r.copied_rows;
totalTables++;
droppedRelations.push(...r.dropped_relations);
}
}
await rewriteInScopeRelations(perTableMeta, tableMap, rowMap);
return {
success: true,
new_space_id: newSpaceId,
project_map: projectMap,
table_map: tableMap,
totals: {
projects: srcProjects.length,
tables: totalTables,
rows: totalRows,
},
skipped_tables: skippedTables,
dropped_relations: droppedRelations,
message: `Copied space ${src_space_id}${newSpaceId} (${srcProjects.length} projects, ${totalTables} tables, ${totalRows} rows)`,
};
} catch (e) {
await rollback(tracking);
aiLogger.error({ err: e.message, src_space_id }, 'copy_space failed');
return { error: e.message };
}
},
};