Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
511 lines
20 KiB
JavaScript
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 };
|
|
}
|
|
},
|
|
};
|