Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
141 lines
4.7 KiB
JavaScript
141 lines
4.7 KiB
JavaScript
#!/usr/bin/env node
|
|
// BUG-MCP-001 cleanup: rows where `data` was stored as a JSON-encoded
|
|
// *string* in JSONB (jsonb_typeof = 'string') instead of a proper object.
|
|
//
|
|
// Root cause (fixed separately): MCP/agent callers occasionally pass `data`
|
|
// as a stringified JSON. Without an entry guard, `JSON.stringify(string)`
|
|
// at the SQL boundary produced a doubly-encoded string — so JSONB stored a
|
|
// quoted string and `data->>'field'` operators returned NULL.
|
|
//
|
|
// Two contamination sub-shapes observed in the wild:
|
|
// A. WELL-FORMED — inner string parses cleanly to an object.
|
|
// B. TRUNCATED — inner string is missing its closing `}`. Repair by
|
|
// appending `}` (and `}}}` for nested-object tails) until
|
|
// JSON.parse succeeds, capped at 4 attempts.
|
|
//
|
|
// Usage:
|
|
// node scripts/cleanup-bug-mcp-001.mjs --dry-run (default)
|
|
// node scripts/cleanup-bug-mcp-001.mjs --apply (writes)
|
|
// node scripts/cleanup-bug-mcp-001.mjs --apply --table-id 7288
|
|
//
|
|
// Idempotent.
|
|
|
|
import { dbAll, dbGet, dbRun } from '../backend/database/connection.js';
|
|
|
|
const APPLY = process.argv.includes('--apply');
|
|
const DRY = !APPLY;
|
|
const tableArgIdx = process.argv.indexOf('--table-id');
|
|
const TABLE_FILTER = tableArgIdx >= 0 ? Number(process.argv[tableArgIdx + 1]) : null;
|
|
|
|
const log = (...a) => console.log('[bug-mcp-001]', ...a);
|
|
|
|
/**
|
|
* Try to recover an object from a possibly-truncated JSON string.
|
|
* Returns { object, repaired } or null if unrecoverable.
|
|
*/
|
|
function recoverObject(s) {
|
|
// First: clean parse
|
|
try {
|
|
const parsed = JSON.parse(s);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return { object: parsed, repaired: false };
|
|
}
|
|
return null;
|
|
} catch {}
|
|
|
|
// Second: maybe missing closing braces — try appending up to 4
|
|
for (let n = 1; n <= 4; n++) {
|
|
try {
|
|
const parsed = JSON.parse(s + '}'.repeat(n));
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return { object: parsed, repaired: true };
|
|
}
|
|
} catch {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function main() {
|
|
log(DRY ? 'DRY-RUN (no writes)' : 'APPLY mode (writes ENABLED)');
|
|
if (TABLE_FILTER) log(`scope: table_id = ${TABLE_FILTER}`);
|
|
|
|
const params = TABLE_FILTER ? [TABLE_FILTER] : [];
|
|
const filter = TABLE_FILTER ? 'AND table_id = ?' : '';
|
|
|
|
const rows = await dbAll(
|
|
`SELECT id, table_id, data
|
|
FROM table_rows
|
|
WHERE jsonb_typeof(data) = 'string' ${filter}
|
|
ORDER BY table_id, id`,
|
|
params
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
log('no contaminated rows — nothing to do');
|
|
return;
|
|
}
|
|
|
|
log(`found ${rows.length} contaminated row(s)`);
|
|
|
|
let cleanCount = 0;
|
|
let repairedCount = 0;
|
|
const unrecoverable = [];
|
|
|
|
for (const row of rows) {
|
|
// pg returns jsonb-string as a JS string (the inner value, unescaped).
|
|
// SQLite mode would store it as a JSON-encoded text; unwrap one level.
|
|
let innerString;
|
|
if (typeof row.data === 'string') {
|
|
innerString = row.data;
|
|
} else {
|
|
// Defensive: shouldn't happen for jsonb_typeof=string, but unwrap if needed
|
|
innerString = String(row.data);
|
|
}
|
|
|
|
const recovered = recoverObject(innerString);
|
|
if (!recovered) {
|
|
unrecoverable.push({ id: row.id, table_id: row.table_id, tail: innerString.slice(-60) });
|
|
continue;
|
|
}
|
|
|
|
if (recovered.repaired) repairedCount++;
|
|
else cleanCount++;
|
|
|
|
if (!DRY) {
|
|
await dbRun(
|
|
`UPDATE table_rows SET data = ? WHERE id = ? AND table_id = ?`,
|
|
[JSON.stringify(recovered.object), row.id, row.table_id]
|
|
);
|
|
}
|
|
}
|
|
|
|
log(`clean : ${cleanCount} row(s) (string parsed straight to object)`);
|
|
log(`repaired: ${repairedCount} row(s) (truncated tail; appended closing braces)`);
|
|
log(`skipped : ${unrecoverable.length} row(s) (could not recover — manual review)`);
|
|
for (const u of unrecoverable.slice(0, 10)) log(` skip id=${u.id} table=${u.table_id} tail=${u.tail || u.reason}`);
|
|
|
|
if (DRY) {
|
|
log(`DRY-RUN done. would update ${cleanCount + repairedCount} row(s). re-run with --apply to write.`);
|
|
return;
|
|
}
|
|
|
|
// Verify
|
|
const remaining = await dbGet(
|
|
`SELECT COUNT(*)::int AS n FROM table_rows
|
|
WHERE jsonb_typeof(data) = 'string' ${filter}`,
|
|
params
|
|
);
|
|
log(`post-fix contaminated rows remaining: ${remaining?.n ?? 0}`);
|
|
if ((remaining?.n ?? 0) === unrecoverable.length) {
|
|
log('SUCCESS: all recoverable rows fixed' + (unrecoverable.length ? ' (skipped rows left as-is)' : ''));
|
|
} else {
|
|
log('WARN: residual count differs from expected — review manually');
|
|
}
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error('[bug-mcp-001] FATAL:', err);
|
|
process.exit(1);
|
|
})
|
|
.finally(() => process.exit(0));
|