diff --git a/docs/done/production-deploy.md b/docs/done/production-deploy.md index e0df8b3..a27a56a 100644 --- a/docs/done/production-deploy.md +++ b/docs/done/production-deploy.md @@ -60,6 +60,35 @@ docker run -d --name lemonboss \ **当前模型配置**(2026-08-27 起):智谱 GLM-5.3-Flash,`ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic`,`ANTHROPIC_MODEL=glm-5.3-flash`(此前是 DeepSeek `deepseek-v4-flash` + `https://api.deepseek.com/anthropic`)。 +## 跨环境同步公司数据(graph.sqlite) + +2026-08-28 把本地 `mycompany` / `innovator-fde-trial` 两家公司同步到生产时踩出来的经验。 +「公司数据」分两半,**必须分开处理**: + +1. **工作文件**:`data/companies/{cid}/`(memory / conversations / workspace 等),普通文件,直接 + `rsync -az` 到 `/opt/lemonboss/data/companies/{cid}/`,传完 `chown hexyun:hexyun`(root 传的文件 + 默认 root:root)。 +2. **注册信息**:「这家公司雇了谁、org/work graph 长什么样、goal/task/run 记录」全部在 + `data/graph.sqlite` 里,不在文件系统里——只搬文件,网页上完全看不到这家公司。 + +第 2 部分**不要手写 SELECT * 去搬**,用 `scripts/sync-company-to-graph-db.js`(先 `--dry-run` 看 +一遍行数,确认无误再去掉这个 flag 跑真的)。这个脚本已经绕开两个手搓时踩过的坑: + +- **列序不能信**:本地库和生产库的表字段名一样,但物理列序可能不同——一个是老库一路 + `ALTER TABLE` 攒出来的顺序,一个是某次重建/清空后吃到的当时最新 `CREATE TABLE` 字面量顺序。 + `SELECT *` 位置对齐的 `INSERT` 会把值静默写进错误的列,`NOT NULL` 列能报错拦一部分,可空/ + 同类型的列错了不会有任何提示。详见 `server/services/graph-db.js` 的 `migrate()` 函数头注释。 +- **`graph_runs` ⇄ `graph_run_nodes` 是循环外键**(`graph_runs.parent_node_run_id` 指 + `graph_run_nodes(id)`,`graph_run_nodes.run_id` 指 `graph_runs(id)`),批量搬迁需要先插 + `graph_runs`(`parent_node_run_id` 置空)、再插 `graph_run_nodes`、最后回填。**回填那条 + `UPDATE` 的关联子查询必须给内外表都加别名**——两边都用裸表名的话,SQLite 会把子查询里的列 + 解析到错误的关联对象,静默回填成 `NULL`,`changes` 还显示"成功",不会报错。 + +操作顺序:先在生产上备份(`tar` 打包 `companies/` 目录 + 用 `db.backup()` 存一份 +`graph.sqlite.bak-`),再 rsync 文件,再跑同步脚本(对着生产的 `/app/data/graph.sqlite`, +可以通过 `docker exec lemonboss node scripts/sync-company-to-graph-db.js ...` 或者先 +`docker cp` 脚本进去跑),最后 `docker restart lemonboss` 让心跳/graph-seed 重新装载。 + ## 其他线索 - `/opt/lemonboss/.env` 里的 `OPEN_API_KEYS` 是 `/api/open/*` 外部调用的鉴权 key,本文档不记录明文值。**注意**:`.env` 文件本身对 `ANTHROPIC_*` 这三个变量是摆设(见上),但 `OPEN_API_KEYS` 等其他没有被 `docker run -e` 固化的变量,还是照常从 `.env` 里读取生效的——只有被固化过的那几个例外。 diff --git a/scripts/sync-company-to-graph-db.js b/scripts/sync-company-to-graph-db.js new file mode 100644 index 0000000..37822f3 --- /dev/null +++ b/scripts/sync-company-to-graph-db.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * sync-company-to-graph-db.js — 把一批公司在 graph.sqlite 里的全部行,从一个库合并进另一个库 + * + * 背景: 2026-08-28 把本地 mycompany / innovator-fde-trial 同步到生产时手搓过一次这个逻辑, + * 踩了两个坑,见 docs/done/production-deploy.md「跨环境同步公司数据」一节。这个脚本是那次 + * 手搓版本的正式化,以后再有「把某几家公司搬到另一个 graph.sqlite」的需求,直接用它, + * 不要重新手写 SELECT * 的合并逻辑。 + * + * 两个坑,这个脚本已经绕开: + * 1. 列序不能信。本地库和生产库的表结构字段名一样,但物理列序可能不同——同一张表, + * 有的库是老版本 CREATE TABLE 之后靠 ALTER TABLE 一路加列,有的库是某次重建/清空后 + * 吃到了当时最新的 CREATE TABLE 字面量,两边列的物理顺序会对不上。SELECT * 位置对齐 + * 的 INSERT 会静默把值写进错误的列(NOT NULL 约束能接住一部分,其余的没有任何报错)。 + * 这里一律按列名显式对齐(用 PRAGMA table_info 在运行时取目标库的列名)。 + * 2. graph_runs.parent_node_run_id ⇄ graph_run_nodes.run_id 是循环外键。先插 graph_runs + * (parent_node_run_id 强制置 NULL),再插 graph_run_nodes,最后回填 parent_node_run_id。 + * 回填那条 UPDATE 的关联子查询**必须**给内外表都加别名——用裸表名的话 SQLite 会把子查询 + * 里的列解析错关联对象,静默回填成 NULL,不报错、不报变更数异常(changes 照样是「成功」)。 + * + * 用法: + * node scripts/sync-company-to-graph-db.js \ + * --source data/graph.sqlite \ + * --target /path/to/other/graph.sqlite \ + * --companies mycompany,innovator-fde-trial \ + * [--dry-run] + * + * --dry-run 整个合并跑在一个事务里,最后主动抛出回滚,不留任何行;控制台照常打印各表行数。 + * + * 表顺序即依赖顺序(父表先于子表插入): + * system_agents(公司私有的) → company_agents → org_graph_nodes → org_graph_edges + * → work_graphs → work_graph_nodes → work_graph_edges → graph_runs → graph_run_nodes + * → graph_run_loop_records → graph_files → graph_edge_contracts → goals → tasks + * + * 不做的事: 不搬 data/companies/{cid}/ 下的工作文件(memory / conversations / workspace)—— + * 那部分是普通文件,直接 rsync 就行,不需要脚本。这个脚本只管 graph.sqlite 里的行。 + */ + +const Database = require('better-sqlite3'); + +function parseArgs(argv) { + const out = { dryRun: argv.includes('--dry-run') }; + for (const flag of ['--source', '--target', '--companies']) { + const i = argv.indexOf(flag); + if (i !== -1) out[flag.slice(2)] = argv[i + 1]; + } + return out; +} + +const { source, target, companies, dryRun } = parseArgs(process.argv.slice(2)); +if (!source || !target || !companies) { + console.error( + 'usage: node scripts/sync-company-to-graph-db.js --source --target --companies id1,id2 [--dry-run]' + ); + process.exit(1); +} +const COMPANIES = companies.split(',').map((s) => s.trim()).filter(Boolean); +const ph = COMPANIES.map(() => '?').join(','); + +const db = new Database(target); +db.pragma('foreign_keys = ON'); +db.exec(`ATTACH DATABASE '${source}' AS src`); + +// 列名一律从目标库运行时读取(PRAGMA table_info),不写死顺序——见文件头坑 1。 +const targetCols = (table) => db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name); + +const log = (...a) => console.log(dryRun ? '[dry-run]' : '[sync]', ...a); + +function insert(table, mode, whereSql, colOverrides = {}) { + const cols = targetCols(table); + const selectExprs = cols.map((c) => (c in colOverrides ? `${colOverrides[c]} AS ${c}` : c)); + const sql = `INSERT ${mode} INTO ${table} (${cols.join(', ')}) SELECT ${selectExprs.join(', ')} FROM src.${table} ${whereSql}`; + const info = db.prepare(sql).run(...COMPANIES); + log(`${table}: ${info.changes} rows`); +} + +const tx = db.transaction(() => { + insert('system_agents', 'OR REPLACE', `WHERE owner_company_id IN (${ph})`); + insert('company_agents', 'OR IGNORE', `WHERE company_id IN (${ph})`); + insert('org_graph_nodes', 'OR IGNORE', `WHERE company_id IN (${ph})`); + insert('org_graph_edges', 'OR IGNORE', `WHERE company_id IN (${ph})`); + insert('work_graphs', 'OR IGNORE', `WHERE company_id IN (${ph})`); + insert('work_graph_nodes', 'OR IGNORE', + `WHERE work_graph_id IN (SELECT id FROM src.work_graphs WHERE company_id IN (${ph}))`); + insert('work_graph_edges', 'OR IGNORE', + `WHERE work_graph_id IN (SELECT id FROM src.work_graphs WHERE company_id IN (${ph}))`); + + // 坑 2:先插 graph_runs,parent_node_run_id 强制 NULL,等 graph_run_nodes 落地后再回填。 + insert('graph_runs', 'OR IGNORE', `WHERE company_id IN (${ph})`, { parent_node_run_id: 'NULL' }); + insert('graph_run_nodes', 'OR IGNORE', + `WHERE run_id IN (SELECT id FROM src.graph_runs WHERE company_id IN (${ph}))`); + + // 回填必须给内外表都加别名,否则子查询里的列会解析错关联对象,静默写成 NULL(坑 2 的后半)。 + const backfill = db.prepare(` + UPDATE graph_runs AS tgt SET parent_node_run_id = ( + SELECT s.parent_node_run_id FROM src.graph_runs AS s WHERE s.id = tgt.id + ) + WHERE tgt.company_id IN (${ph}) + `).run(...COMPANIES); + log(`graph_runs.parent_node_run_id backfilled: ${backfill.changes} rows touched`); + + insert('graph_run_loop_records', 'OR IGNORE', + `WHERE run_id IN (SELECT id FROM src.graph_runs WHERE company_id IN (${ph}))`); + insert('graph_files', 'OR IGNORE', `WHERE company_id IN (${ph})`); + insert('graph_edge_contracts', 'OR IGNORE', + `WHERE edge_layer = 'org' AND edge_id IN (SELECT CAST(id AS TEXT) FROM src.org_graph_edges WHERE company_id IN (${ph}))`); + insert('graph_edge_contracts', 'OR IGNORE', + `WHERE edge_layer = 'work' AND edge_id IN ( + SELECT id FROM src.work_graph_edges + WHERE work_graph_id IN (SELECT id FROM src.work_graphs WHERE company_id IN (${ph})) + )`); + insert('goals', 'OR IGNORE', `WHERE company_id IN (${ph})`); + insert('tasks', 'OR IGNORE', `WHERE company_id IN (${ph})`); + + // FK 校验放在事务内部:同一连接能看见本事务未提交的写入,dry-run 也能校验到真实结果。 + const fkErrors = db.pragma('foreign_key_check'); + if (fkErrors.length) { + throw new Error('foreign_key_check failed: ' + JSON.stringify(fkErrors)); + } + + if (dryRun) throw new Error('__DRY_RUN_ROLLBACK__'); +}); + +let failed = false; +try { + tx(); + console.log('sync complete'); +} catch (e) { + if (e.message === '__DRY_RUN_ROLLBACK__') { + log('rolled back (dry run) — no rows persisted, foreign_key_check passed'); + console.log('dry run complete, no changes persisted'); + } else { + console.error(e.message); + failed = true; + } +} finally { + db.exec('DETACH DATABASE src'); + db.close(); +} +if (failed) process.exit(1); diff --git a/server/services/graph-db.js b/server/services/graph-db.js index 8f15f0e..b2ce55a 100644 --- a/server/services/graph-db.js +++ b/server/services/graph-db.js @@ -54,6 +54,19 @@ function hasColumn(database, table, column) { /** * 存量库迁移。新建库走上面的 CREATE TABLE 就已经是目标形态,这里全部是 no-op。 + * + * ⚠️ 「目标形态」只保证列名和默认值一致,**不保证物理列序跟老库一样**:新建库拿到的是 + * CREATE TABLE 字面量里写的顺序,老库(一路 ALTER TABLE 攒起来的)拿到的是历次 ALTER + * 的追加顺序,两者不一定相同——只有 system_agents.origin 那一列刻意「放最后」对齐过 + * (见下面 CREATE TABLE 里的注释),org_graph_nodes 的 status / reports_to_node_id 等 + * 没有这个保证。2026-08-28 往生产同步公司数据时实测过:本地库(一直没被重建,列序是 + * ALTER 攒出来的)和生产库(发生过重置,吃到的是新版字面量)对同一张 org_graph_nodes + * 表列序完全不同。 + * + * 后果:任何跨库操作(备份还原、环境间同步、手写迁移脚本)一律不能用 `SELECT *` 做 + * 位置对齐的搬运——按列名对齐,或者干脆用 scripts/sync-company-to-graph-db.js(已经 + * 在跑时读 PRAGMA table_info 解决了这个问题)。NOT NULL 列能接住一部分错位,可空/同类型 + * 列错位了不会有任何报错,数据会静默写歪。 */ function migrate(database) { // 1. org_graph_nodes 追加两列