Files
chat/config/migrate-orphaned-agent-files.js
Hanzo Dev ba0eb6f2d2 fork: rename internal workspace packages to @hanzochat scope
librechat-data-provider  → @hanzochat/data-provider
@librechat/data-schemas  → @hanzochat/data-schemas
@librechat/client        → @hanzochat/client
(@hanzochat/api already forked; external @librechat/agents fork follows)

All imports, package.json names/deps, tsconfig path aliases, jest
moduleNameMapper, and rollup/vite build aliases updated. The data-provider
react-query subpath resolves via the exports map. Lockfiles regenerated in a
follow-on once the canonical package manager is confirmed.
2026-07-14 05:55:49 -07:00

161 lines
5.5 KiB
JavaScript

const path = require('path');
const { logger, runAsSystem } = require('@hanzochat/data-schemas');
const { TOOL_RESOURCE_KEYS, collectToolResourceFileIds } = require('@hanzochat/api');
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const connect = require('./connect');
const { Agent, File } = require('~/db/models');
/**
* Cap on the number of per-agent entries we retain in `results.details`. Larger
* runs still update every affected agent and still report accurate aggregate
* counts — we just stop accumulating sample data past this threshold to keep
* memory bounded on deployments with thousands of corrupted agents.
*/
const DETAIL_SAMPLE_LIMIT = 50;
/**
* Cleans up orphaned file_id references from agent `tool_resources` — that is,
* file_ids that remain on an agent after the underlying File document has
* already been deleted (see issue #12776). These stubs otherwise accumulate and
* eventually block new uploads with "Duplicate file detected."
*
* Safe to re-run — if there are no orphans, nothing is written.
*
* @param {{ dryRun?: boolean, batchSize?: number }} [options]
*/
async function migrateOrphanedAgentFiles({ dryRun = true, batchSize = 100 } = {}) {
await connect();
logger.info('Starting Orphaned Agent Files Migration', { dryRun, batchSize });
/*
* Scan and heal across every tenant. Without this wrapper the tenant
* isolation plugin either scopes queries to a (non-existent) tenant or
* throws under TENANT_ISOLATION_STRICT=true, making the script unusable
* as the intended remediation path for corrupted agents.
*/
return runAsSystem(async () => {
const totalAgents = await Agent.countDocuments();
logger.info(`Scanning ${totalAgents} agent(s) for orphaned file references`);
const results = {
dryRun,
scannedAgents: 0,
agentsWithOrphans: 0,
agentsUpdated: 0,
totalOrphansRemoved: 0,
errors: 0,
details: [],
};
const cursor = Agent.find({}, { id: 1, name: 1, tool_resources: 1 })
.lean()
.cursor({ batchSize });
for await (const agent of cursor) {
results.scannedAgents++;
try {
const referencedFileIds = collectToolResourceFileIds(agent.tool_resources);
if (referencedFileIds.length === 0) {
continue;
}
const existing = await File.find(
{ file_id: { $in: referencedFileIds } },
{ file_id: 1, _id: 0 },
).lean();
const existingIds = new Set(existing.map((f) => f.file_id));
const orphans = referencedFileIds.filter((id) => !existingIds.has(id));
if (orphans.length === 0) {
continue;
}
results.agentsWithOrphans++;
results.totalOrphansRemoved += orphans.length;
if (results.details.length < DETAIL_SAMPLE_LIMIT) {
results.details.push({
agentId: agent.id,
name: agent.name,
orphanCount: orphans.length,
orphans,
});
}
if (dryRun) {
logger.debug(`[dry-run] Would prune ${orphans.length} orphan(s) from agent ${agent.id}`);
continue;
}
const pullAllOps = {};
for (const key of TOOL_RESOURCE_KEYS) {
pullAllOps[`tool_resources.${key}.file_ids`] = orphans;
}
const updateResult = await Agent.updateOne({ _id: agent._id }, { $pullAll: pullAllOps });
if (updateResult.modifiedCount > 0) {
results.agentsUpdated++;
logger.info(
`Pruned ${orphans.length} orphan(s) from agent "${agent.name}" (${agent.id})`,
);
}
} catch (error) {
results.errors++;
logger.error(`Failed to process agent ${agent.id}`, { error: error.message });
}
}
logger.info('Orphaned Agent Files Migration completed', {
dryRun,
scannedAgents: results.scannedAgents,
agentsWithOrphans: results.agentsWithOrphans,
agentsUpdated: results.agentsUpdated,
totalOrphansRemoved: results.totalOrphansRemoved,
errors: results.errors,
});
return results;
});
}
if (require.main === module) {
const dryRun = process.argv.includes('--dry-run');
const batchSize =
parseInt(process.argv.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1]) || 100;
migrateOrphanedAgentFiles({ dryRun, batchSize })
.then((result) => {
console.log(`\n=== ${dryRun ? 'DRY RUN ' : ''}RESULTS ===`);
console.log(`Agents scanned: ${result.scannedAgents}`);
console.log(`Agents with orphans: ${result.agentsWithOrphans}`);
console.log(
`Orphan references ${dryRun ? 'to remove' : 'removed'}: ${result.totalOrphansRemoved}`,
);
if (!dryRun) {
console.log(`Agents updated: ${result.agentsUpdated}`);
}
if (result.errors > 0) {
console.log(`Errors: ${result.errors}`);
}
if (result.details.length > 0) {
console.log('\nAffected agents:');
result.details.forEach((d, i) => {
console.log(` ${i + 1}. "${d.name}" (${d.agentId}) — ${d.orphanCount} orphan(s)`);
});
if (result.agentsWithOrphans > result.details.length) {
console.log(
` ... and ${result.agentsWithOrphans - result.details.length} more (sample capped at ${DETAIL_SAMPLE_LIMIT})`,
);
}
}
process.exit(0);
})
.catch((error) => {
console.error('Orphaned agent files migration failed:', error);
process.exit(1);
});
}
module.exports = { migrateOrphanedAgentFiles };