Commit 4659236107ae7c49e4a09830136869cbcbce42f1

Authored by yibo
1 parent 2fc5039f

tool: 补删公司脚本,配对 sync-company-to-graph-db.js

2026-08-28 清理僵尸公司 mycompany(company.json 已丢失导致界面永远不可见)时,
发现 org-coder/org-planner 这类按角色名生成 id 的本体会被多家公司共用同一行
system_agents,owner_company_id 只代表最后一次雇它的是谁——直接按它删会删断
其他公司仍在用的外键,脚本已加"仍被引用就跳过"保护,本地/生产 dry-run 验证过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpkWPa2CmuWJFJZksqvxWB
scripts/delete-company-from-graph-db.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * delete-company-from-graph-db.js — 彻底删掉一家公司在 graph.sqlite 里的全部行
  4 + *
  5 + * 跟 scripts/sync-company-to-graph-db.js 是一对:那个脚本负责把公司的行搬进另一个库,
  6 + * 这个脚本负责把公司的行从一个库里删干净。用于清理僵尸/废弃公司——比如
  7 + * `data/companies/{cid}/company.json` 已经不存在(company-service.list() 靠这个文件
  8 + * 判断"这是不是一家公司",文件没了这家公司在界面上永远不可见,但 graph.sqlite 里的行、
  9 + * `data/companies/{cid}/` 目录下的残留文件不会跟着自动消失)。
  10 + *
  11 + * 只管 graph.sqlite 里的行,不碰 data/companies/{cid}/ 目录——目录是普通文件,
  12 + * 删之前自己看一眼里面还有没有想留的东西,确认没有再 `rm -rf`。
  13 + *
  14 + * 删除顺序是插入顺序的镜像(先删子表,最后删 system_agents,因为前面所有表都有外键指它):
  15 + * graph_runs.parent_node_run_id 先置 NULL(打破跟 graph_run_nodes 的循环外键)
  16 + * → graph_edge_contracts → graph_run_loop_records → graph_run_nodes → graph_runs
  17 + * → graph_files → work_graph_edges → work_graph_nodes → work_graphs
  18 + * → org_graph_edges → org_graph_nodes → company_agents → tasks → goals
  19 + * → system_agents(只删 owner_company_id = 这家公司、且已经没有任何 org/work 节点或雇佣关系
  20 + * 引用的行——像 org-coder / org-planner 这类按角色名生成 id 的本体,实测在本仓库的存量
  21 + * 数据里被多家公司共用同一行,owner_company_id 只反映最后一次雇它的是谁,见 2026-08-28
  22 + * 删 mycompany 时的教训。公共模板 scope='public' 的行本来就不会被这条 WHERE 命中)
  23 + *
  24 + * 用法:
  25 + * node scripts/delete-company-from-graph-db.js --db data/graph.sqlite --company mycompany [--dry-run]
  26 + *
  27 + * --dry-run 整个删除跑在一个事务里,最后主动抛出回滚,不留任何改动;控制台照常打印各表行数。
  28 + */
  29 +
  30 +const Database = require('better-sqlite3');
  31 +
  32 +function parseArgs(argv) {
  33 + const out = { dryRun: argv.includes('--dry-run') };
  34 + for (const flag of ['--db', '--company']) {
  35 + const i = argv.indexOf(flag);
  36 + if (i !== -1) out[flag.slice(2)] = argv[i + 1];
  37 + }
  38 + return out;
  39 +}
  40 +
  41 +const { db: dbPath, company, dryRun } = parseArgs(process.argv.slice(2));
  42 +if (!dbPath || !company) {
  43 + console.error('usage: node scripts/delete-company-from-graph-db.js --db <path> --company <id> [--dry-run]');
  44 + process.exit(1);
  45 +}
  46 +
  47 +const db = new Database(dbPath);
  48 +db.pragma('foreign_keys = ON');
  49 +
  50 +const log = (...a) => console.log(dryRun ? '[dry-run]' : '[delete]', ...a);
  51 +
  52 +function del(table, whereSql, params = [company]) {
  53 + const info = db.prepare(`DELETE FROM ${table} ${whereSql}`).run(...params);
  54 + log(`${table}: ${info.changes} rows`);
  55 +}
  56 +
  57 +const tx = db.transaction(() => {
  58 + // 打破 graph_runs ⇄ graph_run_nodes 的循环外键,否则删 graph_run_nodes 时会被
  59 + // 还指着它的 graph_runs.parent_node_run_id 拦住。
  60 + db.prepare(`UPDATE graph_runs SET parent_node_run_id = NULL WHERE company_id = ?`).run(company);
  61 +
  62 + del('graph_edge_contracts',
  63 + `WHERE edge_layer = 'org' AND edge_id IN (SELECT CAST(id AS TEXT) FROM org_graph_edges WHERE company_id = ?)`);
  64 + del('graph_edge_contracts',
  65 + `WHERE edge_layer = 'work' AND edge_id IN (
  66 + SELECT id FROM work_graph_edges WHERE work_graph_id IN (SELECT id FROM work_graphs WHERE company_id = ?)
  67 + )`);
  68 + del('graph_run_loop_records', `WHERE run_id IN (SELECT id FROM graph_runs WHERE company_id = ?)`);
  69 + del('graph_run_nodes', `WHERE run_id IN (SELECT id FROM graph_runs WHERE company_id = ?)`);
  70 + del('graph_runs', `WHERE company_id = ?`);
  71 + del('graph_files', `WHERE company_id = ?`);
  72 + del('work_graph_edges', `WHERE work_graph_id IN (SELECT id FROM work_graphs WHERE company_id = ?)`);
  73 + del('work_graph_nodes', `WHERE work_graph_id IN (SELECT id FROM work_graphs WHERE company_id = ?)`);
  74 + del('work_graphs', `WHERE company_id = ?`);
  75 + del('org_graph_edges', `WHERE company_id = ?`);
  76 + del('org_graph_nodes', `WHERE company_id = ?`);
  77 + del('company_agents', `WHERE company_id = ?`);
  78 + del('tasks', `WHERE company_id = ?`);
  79 + del('goals', `WHERE company_id = ?`);
  80 +
  81 + // system_agents 的 id 有时被多家公司共用同一行(角色名撞了就共用,见文件头注释更新)——
  82 + // owner_company_id 只反映"最后一次雇它的是谁",不代表没有别的公司还在用同一个 id。
  83 + // 只删这家公司名下、且已经没有任何 org/work 节点或雇佣关系再引用的行,避免删出悬空外键。
  84 + del('system_agents', `
  85 + WHERE owner_company_id = ?
  86 + AND id NOT IN (SELECT system_agent_id FROM org_graph_nodes)
  87 + AND id NOT IN (SELECT system_agent_id FROM work_graph_nodes)
  88 + AND id NOT IN (SELECT system_agent_id FROM company_agents)
  89 + `);
  90 +
  91 + const fkErrors = db.pragma('foreign_key_check');
  92 + if (fkErrors.length) {
  93 + throw new Error('foreign_key_check failed: ' + JSON.stringify(fkErrors));
  94 + }
  95 +
  96 + if (dryRun) throw new Error('__DRY_RUN_ROLLBACK__');
  97 +});
  98 +
  99 +let failed = false;
  100 +try {
  101 + tx();
  102 + console.log('delete complete');
  103 +} catch (e) {
  104 + if (e.message === '__DRY_RUN_ROLLBACK__') {
  105 + log('rolled back (dry run) — no rows removed, foreign_key_check passed');
  106 + console.log('dry run complete, no changes persisted');
  107 + } else {
  108 + console.error(e.message);
  109 + failed = true;
  110 + }
  111 +} finally {
  112 + db.close();
  113 +}
  114 +if (failed) process.exit(1);
... ...