Ctrl K
ComunidadGuías…

config/migrate-orphaned-agent-files.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
const path = require('path');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const { TOOL_RESOURCE_KEYS, collectToolResourceFileIds } = require('@librechat/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 };