chore: fork with local patches for Syslog Solution
Patches applied: - enforceRelayTTL — auto-deletes expired relay nodes (>7d) - updateNode — tenant enforcement (block updates on NULL-namespace nodes) - buildCanonicalMetadata — reject nested objects, sanitize values - createNode — source-based and title-based deduplication - getContext — NPE-safe count queries, stale node detection - repair_log integration on updateNode - bridge-validation.js — auto-fill defaults for tenant/namespace Origin: github.com/bradwmorris/ra-h_os@70e95e7
This commit is contained in:
@@ -21,7 +21,10 @@ function getEdges(filters = {}) {
|
||||
|
||||
const rows = query(sql, params);
|
||||
|
||||
return rows.map(formatEdgeRow);
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
context: parseContext(row.context)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +34,11 @@ function getEdgeById(id) {
|
||||
const rows = query('SELECT * FROM edges WHERE id = ?', [id]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return formatEdgeRow(rows[0]);
|
||||
const row = rows[0];
|
||||
return {
|
||||
...row,
|
||||
context: parseContext(row.context)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +59,21 @@ function createEdge(edgeData) {
|
||||
throw new Error('Edge explanation is required');
|
||||
}
|
||||
|
||||
const cleanExplanation = explanation.trim();
|
||||
// P1 — Dedup check before edge creation
|
||||
const existing = db.prepare(
|
||||
`SELECT id FROM edges
|
||||
WHERE from_node_id = ?
|
||||
AND to_node_id = ?
|
||||
AND (explanation = ? OR json_extract(context, '$.explanation') = ?)`
|
||||
).get(from_node_id, to_node_id, explanation.trim(), explanation.trim());
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing.id,
|
||||
status: 'existing',
|
||||
warning: 'duplicate_edge_skipped'
|
||||
};
|
||||
}
|
||||
|
||||
// Simple context without AI inference
|
||||
// The main app can re-infer types when it loads
|
||||
@@ -60,13 +81,13 @@ function createEdge(edgeData) {
|
||||
type: 'related_to',
|
||||
confidence: 0.5,
|
||||
inferred_at: now,
|
||||
explanation: cleanExplanation,
|
||||
explanation: explanation.trim(),
|
||||
created_via: 'mcp'
|
||||
};
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at, explanation)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = runWithBusyRetry(() => stmt.run(
|
||||
@@ -74,13 +95,18 @@ function createEdge(edgeData) {
|
||||
to_node_id,
|
||||
JSON.stringify(context),
|
||||
source,
|
||||
now,
|
||||
cleanExplanation
|
||||
now
|
||||
),
|
||||
'createEdge'
|
||||
);
|
||||
|
||||
// P0 — After writing edge to DB, sync explanation column
|
||||
const edgeId = Number(result.lastInsertRowid);
|
||||
if (context.explanation) {
|
||||
const syncStmt = db.prepare('UPDATE edges SET explanation = ? WHERE id = ?');
|
||||
runWithBusyRetry(() => syncStmt.run(context.explanation, edgeId), 'createEdge_explanation_sync');
|
||||
}
|
||||
|
||||
return getEdgeById(edgeId);
|
||||
}
|
||||
|
||||
@@ -96,36 +122,29 @@ function updateEdge(id, updates) {
|
||||
throw new Error(`Edge with ID ${id} not found. Use rah_query_edges to find edges by node ID.`);
|
||||
}
|
||||
|
||||
const cleanExplanation = typeof explanation === 'string' ? explanation.trim() : '';
|
||||
|
||||
// If explanation changed, update both the normal field and the compatibility copy.
|
||||
if (cleanExplanation) {
|
||||
// If explanation changed, update context
|
||||
if (explanation && explanation.trim()) {
|
||||
const now = new Date().toISOString();
|
||||
const newContext = {
|
||||
...existing.context,
|
||||
explanation: cleanExplanation,
|
||||
explanation: explanation.trim(),
|
||||
inferred_at: now,
|
||||
created_via: 'mcp'
|
||||
};
|
||||
|
||||
const stmt = db.prepare('UPDATE edges SET context = ?, explanation = ? WHERE id = ?');
|
||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), cleanExplanation, id), 'updateEdge');
|
||||
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
|
||||
|
||||
// P0 — After updating edge, sync explanation column
|
||||
const syncStmt = db.prepare('UPDATE edges SET explanation = ? WHERE id = ?');
|
||||
runWithBusyRetry(() => syncStmt.run(explanation.trim(), id), 'updateEdge_explanation_sync');
|
||||
} else if (contextUpdates) {
|
||||
const contextExplanation = typeof contextUpdates.explanation === 'string'
|
||||
? contextUpdates.explanation.trim()
|
||||
: '';
|
||||
const newContext = {
|
||||
...existing.context,
|
||||
...contextUpdates,
|
||||
...(contextExplanation ? { explanation: contextExplanation } : {})
|
||||
...contextUpdates
|
||||
};
|
||||
if (contextExplanation) {
|
||||
const stmt = db.prepare('UPDATE edges SET context = ?, explanation = ? WHERE id = ?');
|
||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), contextExplanation, id), 'updateEdge');
|
||||
} else {
|
||||
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
|
||||
}
|
||||
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
|
||||
}
|
||||
|
||||
return getEdgeById(id);
|
||||
@@ -211,15 +230,6 @@ function getEdgeCount() {
|
||||
return Number(rows[0].count);
|
||||
}
|
||||
|
||||
function formatEdgeRow(row) {
|
||||
const context = parseContext(row.context);
|
||||
return {
|
||||
...row,
|
||||
context,
|
||||
explanation: row.explanation || context?.explanation || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse context JSON safely.
|
||||
*/
|
||||
|
||||
@@ -131,22 +131,50 @@ function buildCanonicalMetadata({ existing, metadata }) {
|
||||
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
|
||||
};
|
||||
|
||||
const merged = {
|
||||
...prior,
|
||||
...incoming,
|
||||
state: incoming.state === 'processed' ? 'processed' : (prior.state === 'processed' ? 'processed' : 'not_processed'),
|
||||
captured_by: incoming.captured_by || prior.captured_by || 'human',
|
||||
source_metadata: sourceMetadata,
|
||||
};
|
||||
// Sanitize a value: reject nested objects/arrays, return trimmed string or undefined
|
||||
function sanitizeValue(value) {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Unwrap nested {value: ...} format from MCP tool calls
|
||||
if (value.value && typeof value.value === 'string') return value.value.trim();
|
||||
console.warn(`[buildCanonicalMetadata] WARNING: Nested object/array rejected. Value: ${JSON.stringify(value).substring(0, 200)}`);
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const type = normalizeString(incoming.type) || normalizeString(prior.type);
|
||||
const capturedMethod = normalizeString(incoming.captured_method) || normalizeString(prior.captured_method);
|
||||
// Build merged metadata from prior and incoming, but only keep string values
|
||||
const merged = {};
|
||||
|
||||
if (type) merged.type = type;
|
||||
else delete merged.type;
|
||||
// Start with source_metadata (always an object)
|
||||
merged.source_metadata = sourceMetadata;
|
||||
|
||||
if (capturedMethod) merged.captured_method = capturedMethod;
|
||||
else delete merged.captured_method;
|
||||
// Copy from prior: only string values
|
||||
Object.keys(prior).forEach(key => {
|
||||
if (key === 'source_metadata') return; // handled separately
|
||||
const val = prior[key];
|
||||
if (typeof val === 'string' && val.trim().length > 0) {
|
||||
merged[key] = val;
|
||||
}
|
||||
});
|
||||
|
||||
// Override with incoming: only string values
|
||||
Object.keys(incoming).forEach(key => {
|
||||
if (key === 'source_metadata') return; // handled separately
|
||||
const sanitized = sanitizeValue(incoming[key]);
|
||||
const val = sanitized;
|
||||
if (typeof val === 'string' && val.trim().length > 0) {
|
||||
merged[key] = val.trim();
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure canonical defaults
|
||||
if (!merged.state) merged.state = 'not_processed';
|
||||
if (!merged.captured_by) merged.captured_by = 'human';
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -402,6 +430,36 @@ function getChunkStatusForSource(sourceText) {
|
||||
/**
|
||||
* Create a new node.
|
||||
*/
|
||||
/**
|
||||
* Enforce TTL on relay messages — delete expired ones.
|
||||
* Relay messages have a TTL of 7 days.
|
||||
*/
|
||||
function enforceRelayTTL() {
|
||||
const db = getDb();
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - 7);
|
||||
const cutoffISO = cutoff.toISOString();
|
||||
|
||||
// Find relay messages older than 7 days
|
||||
const expired = query(`
|
||||
SELECT id, title, created_at FROM nodes
|
||||
WHERE metadata LIKE '%relay_message%'
|
||||
AND created_at < ?
|
||||
ORDER BY created_at ASC
|
||||
`, [cutoffISO]);
|
||||
|
||||
if (expired.length > 0) {
|
||||
const ids = expired.map(n => n.id);
|
||||
const idsStr = ids.join(',');
|
||||
console.warn(`[enforceRelayTTL] ${expired.length} relay messages expired, deleting (IDs: ${idsStr})`);
|
||||
db.prepare(`DELETE FROM nodes WHERE id IN (${idsStr})`).run();
|
||||
db.prepare(`DELETE FROM repair_log WHERE node_id IN (${idsStr})`).run();
|
||||
db.prepare(`DELETE FROM edges WHERE from_node_id IN (${idsStr}) OR to_node_id IN (${idsStr})`).run();
|
||||
}
|
||||
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
function createNode(nodeData) {
|
||||
const {
|
||||
title: rawTitle,
|
||||
@@ -414,6 +472,34 @@ function createNode(nodeData) {
|
||||
|
||||
const title = sanitizeTitle(rawTitle);
|
||||
|
||||
// FIX 2: Deduplication — check if a node with same source or title+description already exists
|
||||
const existingSource = source || ([title, description].filter(Boolean).join('\n\n').trim() || null);
|
||||
const existingSourceNormalized = normalizeString(existingSource);
|
||||
const existingTitleNormalized = normalizeString(title);
|
||||
|
||||
// Check for source-based deduplication first
|
||||
const sourceCheckResults = query(`
|
||||
SELECT id, title FROM nodes WHERE source = ?
|
||||
`, [existingSourceNormalized || '']);
|
||||
|
||||
if (sourceCheckResults.length > 0) {
|
||||
console.warn(`[createNode] WARNING: Node with identical source already exists (ID: ${sourceCheckResults[0].id}, Title: ${sourceCheckResults[0].title}). Skipping duplicate creation.`);
|
||||
return getNodeById(Number(sourceCheckResults[0].id));
|
||||
}
|
||||
|
||||
// Check for title+description deduplication
|
||||
const dedupCheckResults = query(`
|
||||
SELECT id, title FROM nodes WHERE title = ?
|
||||
`, [title]);
|
||||
|
||||
if (dedupCheckResults.length > 0) {
|
||||
console.warn(`[createNode] WARNING: Node with identical title already exists (ID: ${dedupCheckResults[0].id}, Title: ${dedupCheckResults[0].title}). Skipping duplicate creation.`);
|
||||
return getNodeById(Number(dedupCheckResults[0].id));
|
||||
}
|
||||
|
||||
// Enforce TTL on relay messages before creating new node
|
||||
enforceRelayTTL();
|
||||
|
||||
const canonicalMetadata = buildCanonicalMetadata({ metadata });
|
||||
const now = new Date().toISOString();
|
||||
const db = getDb();
|
||||
@@ -449,6 +535,9 @@ function createNode(nodeData) {
|
||||
* Update an existing node.
|
||||
*/
|
||||
function updateNode(id, updates, options = {}) {
|
||||
// Enforce TTL on relay messages before updating any node
|
||||
enforceRelayTTL();
|
||||
|
||||
const { title, description, source, link, event_date, metadata } = updates;
|
||||
const now = new Date().toISOString();
|
||||
const db = getDb();
|
||||
@@ -459,7 +548,32 @@ function updateNode(id, updates, options = {}) {
|
||||
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
|
||||
}
|
||||
|
||||
const mergedMetadata = metadata !== undefined
|
||||
// === TENANT ENFORCEMENT: require namespace/tenant in metadata ===
|
||||
// Extract tenant from existing node metadata
|
||||
let nodeTenant = null;
|
||||
try {
|
||||
const meta = typeof existing.metadata === 'string'
|
||||
? JSON.parse(existing.metadata)
|
||||
: existing.metadata;
|
||||
nodeTenant = meta.namespace || null;
|
||||
} catch (e) {
|
||||
// metadata parse error — treat as no tenant
|
||||
}
|
||||
|
||||
// Block update if no tenant/namespace
|
||||
// Bootstrap: allow updates that SET namespace on NULL-namespace nodes
|
||||
const metaUpdate = metadata || {};
|
||||
const isSettingNamespace = metaUpdate.namespace && (
|
||||
typeof metaUpdate.namespace === 'string' ||
|
||||
(typeof metaUpdate.namespace === 'object' && metaUpdate.namespace.value)
|
||||
);
|
||||
|
||||
if (!nodeTenant && !isSettingNamespace) {
|
||||
console.warn(`[updateNode] BLOCK: Node #${id} has no tenant/namespace. Refusing update.`);
|
||||
throw new Error(`Node #${id} has no namespace field in metadata. Update blocked. Set metadata.namespace before updating.`);
|
||||
}
|
||||
|
||||
const mergedMetadata = metadata !== undefined
|
||||
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
|
||||
: undefined;
|
||||
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
|
||||
@@ -510,6 +624,23 @@ function updateNode(id, updates, options = {}) {
|
||||
|
||||
});
|
||||
|
||||
// === REPAIR LOG: log the update ===
|
||||
try {
|
||||
const logStmt = db.prepare(
|
||||
'INSERT INTO repair_log(node_id, action, old_metadata, new_metadata, timestamp, verified, tenant) VALUES (?, ?, ?, ?, datetime(?), 0, ?)'
|
||||
);
|
||||
logStmt.run(
|
||||
id,
|
||||
'updateNode',
|
||||
typeof existing.metadata === 'string' ? existing.metadata : JSON.stringify(existing.metadata ?? null),
|
||||
mergedMetadata ? JSON.stringify(mergedMetadata) : 'unchanged',
|
||||
new Date().toISOString().split('T')[0],
|
||||
nodeTenant || 'unknown'
|
||||
);
|
||||
} catch (logErr) {
|
||||
console.warn(`[updateNode] repair_log write failed: ${logErr.message}`);
|
||||
}
|
||||
|
||||
return getNodeById(id);
|
||||
}
|
||||
|
||||
@@ -537,8 +668,10 @@ function getNodeCount() {
|
||||
* Returns stats, hub nodes, and recent activity.
|
||||
*/
|
||||
function getContext() {
|
||||
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
|
||||
const edgeCount = query('SELECT COUNT(*) as count FROM edges')[0].count;
|
||||
const nodeCountRow = query('SELECT COUNT(*) as count FROM nodes');
|
||||
const nodeCount = (nodeCountRow && nodeCountRow[0] && nodeCountRow[0].count) || 0;
|
||||
const edgeCountRow = query('SELECT COUNT(*) as count FROM edges');
|
||||
const edgeCount = (edgeCountRow && edgeCountRow[0] && edgeCountRow[0].count) || 0;
|
||||
|
||||
const recentNodes = query(`
|
||||
SELECT n.id, n.title, n.description
|
||||
@@ -556,10 +689,34 @@ function getContext() {
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
// Get all nodes for bulk operations
|
||||
const allNodes = query(`
|
||||
SELECT n.id, n.title, n.description, n.metadata, n.created_at, n.updated_at
|
||||
FROM nodes n
|
||||
ORDER BY n.updated_at DESC
|
||||
`).map(row => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description ?? null,
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
}));
|
||||
|
||||
// Detect stale nodes (no update in 30 days, state is not_processed)
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const staleNodes = allNodes.filter(n =>
|
||||
n.metadata?.state === 'not_processed' &&
|
||||
n.updated_at &&
|
||||
n.updated_at < thirtyDaysAgo
|
||||
);
|
||||
|
||||
return {
|
||||
stats: { nodeCount, edgeCount, dimensionCount: 0 },
|
||||
recentNodes,
|
||||
hubNodes
|
||||
hubNodes,
|
||||
allNodes,
|
||||
staleNodes
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user