graph-db.js
19.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
/**
* Graph Database — SQLite for the Graph Agent architecture (P0 + P2)
*
* 表结构:
* system_agents Agent 本体注册表(TEXT PRIMARY KEY = system/agents/ 下的目录名),
* origin 分 builtin(仓库自带)/ created(create-agent 生成)两类
* company_agents 公司 ⇄ 本体的**雇佣关系**(28 批次 4)
* org_graph_nodes 公司组织图节点(company 对某个 system_agent 的引用 + 角色名)
* org_graph_edges 公司组织图**执行流**边(P2 起:condition + edge_kind,与 work_graph_edges 同构)
*
* 为什么 company_agents 不能复用 org_graph_nodes(28 §4.2 / §5.3):
* 后者是**执行流图**的节点(带 reports_to_node_id / 出入边 / authored_by),
* 而 P3B 规定 work 层本体被雇时**不写** org_graph_nodes
* (判据见 company-service.decideOrgNodeRecording:图基础设施本体、work graph 骨架
* 点名的本体、成型图上不在图里的角色,三类都只补 system_agents 不进 org 图)。
* 也就是说 org_graph_nodes 天生覆盖不全「这家公司到底雇了谁」——拿它当成员表,
* 查出来的名册会**少人**,而且少的正是跑得最多的那批 work 层本体。
* 雇佣关系因此独立成表,org_graph_nodes 继续只管执行流。
*
* P2(27-p2-org-graph.md §3.2)的三处改造:
* 1. org_graph_nodes 追加 status(软删除,供 fire 用)+ reports_to_node_id(汇报关系降级为节点属性)
* 2. 全量唯一索引 idx_org_graph_nodes_company_role → 部分唯一索引 ..._active
* (fire 成 status='removed' 之后,同角色能重新 hire)
* 3. org_graph_edges 从「汇报关系」重建为「执行流边」
*
* P3(27-p3-p4-work-graph-console.md §四.D.2.2,T-P3)的两处改造:
* 1. org_graph_edges 追加可空 fail_reason —— 一个节点可以有多条 fail 出边,
* 靠边上的 fail_reason 做细分路由(精确匹配优先,无匹配退到 fail_reason IS NULL 的兜底边)。
* 2. 唯一索引 idx_org_graph_edges_dedupe_active 的键必须把 fail_reason 一起算进去。
* (R3-F2)原本以为「多条 fail 边分别指向不同 to_node_id,本来就不撞」——不成立:
* D.2.2 允许同一对 from/to 上并存「精确 fail 边 + 兜底边」(例:verify 失败因
* input_underspecified 退回 plan,其余未归类的 fail 也退回 plan),
* 旧键把这两条视为同一条,insertEdge 的 ON CONFLICT DO NOTHING 会静默吞掉兜底边。
* 注:原 R-16「归因保持 org node 粒度封装、org 层回退只退到 org node」的假设已被 **R-17.1 推翻**——
* 跨层地址不落在边上(定义层写不出运行时的 work node),而是走 graph_run_nodes.fail_node_ref,
* 理由见 graph-engine.js 的「跨层节点地址(R-17.1)」一节。本文件的 DDL 不因此改动。
*
* 本文件所有 DDL 都是幂等的,getDb() 可重复调用 / 进程可反复重启。
*/
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const DATA_DIR = path.resolve(__dirname, '../../data');
const DB_PATH = process.env.GRAPH_DB_PATH || path.join(DATA_DIR, 'graph.sqlite');
let db = null;
/** 表是否已有某列(SQLite 的 ALTER TABLE ADD COLUMN 对已存在的列会直接报错) */
function hasColumn(database, table, column) {
return database.pragma(`table_info(${table})`).some((c) => c.name === 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 追加两列
if (!hasColumn(database, 'org_graph_nodes', 'status')) {
database.exec(`ALTER TABLE org_graph_nodes ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`);
}
if (!hasColumn(database, 'org_graph_nodes', 'reports_to_node_id')) {
// 默认值必须是 NULL,SQLite 才允许 ADD COLUMN 带 REFERENCES
database.exec(
`ALTER TABLE org_graph_nodes ADD COLUMN reports_to_node_id INTEGER REFERENCES org_graph_nodes(id)`
);
}
// 2. P1 建的全量唯一索引挡住了「fire 后重新 hire 同角色」,换成只约束 active 行的部分索引
// 注意 graph-service.js 的 ensureOrgGraphNodeIndex() 也必须同步改成建这个部分索引,
// 否则这里 DROP 掉的索引会被它重新建出来。
database.exec(`
DROP INDEX IF EXISTS idx_org_graph_nodes_company_role;
CREATE UNIQUE INDEX IF NOT EXISTS idx_org_graph_nodes_company_role_active
ON org_graph_nodes(company_id, role_name) WHERE status = 'active';
`);
// 3. 老的 org_graph_edges(relation_type 汇报关系)从建表至今零数据,直接 DROP 重建,无迁移负担
if (hasColumn(database, 'org_graph_edges', 'relation_type')) {
database.exec(`DROP TABLE org_graph_edges`);
createEdgesTable(database);
}
// 4. P3 T-P3:condition='fail' 的细分路由键(27-p3 §四.D.2.2)。
// 可空,旧行为 NULL——NULL 正好就是「兜底边」语义,存量数据不受影响。
if (!hasColumn(database, 'org_graph_edges', 'fail_reason')) {
database.exec(`ALTER TABLE org_graph_edges ADD COLUMN fail_reason TEXT`);
}
// 4b. P3B W7-fix:节点的来源。
// 判「这家公司的 org graph 成型了没有」原先看的是**边数**,理由是
// 「节点 hire() 自己就会造,拿节点当信号会废掉『连雇两个人第二个不入图』」。
// 但真跑里 orggraph-planning 裁出过一张**零条边的单节点图**,边数代理当场失效:
// 三条判据全落空 → workgraph-planning 现雇的 work 层本体被写进 org_graph_nodes,
// 下一次下达任务时它会被 advanceOne 当成 org 节点执行。
// 所以把「谁写的」显式记下来,不再靠边数猜。
// 'planner' = org-graph-service.writeGraph(规划 agent 提交的整张图)
// 'hire' = company-service.hire 顺手记的一行
// NULL = 本列之前的存量行,判定时回落到边数代理,存量行为不变
if (!hasColumn(database, 'org_graph_nodes', 'authored_by')) {
database.exec(`ALTER TABLE org_graph_nodes ADD COLUMN authored_by TEXT`);
}
// 5. P3 R3-F2:去重键补上 fail_reason,否则同一对 from/to 上的「精确 fail 边 + 兜底边」
// 只能活一条(insertEdge 用 ON CONFLICT DO NOTHING,被吞的那条不报错)。
//
// 键里写 COALESCE(fail_reason,'') 而不是裸列:SQLite 的 UNIQUE 索引里多个 NULL 互不相等,
// 裸列会让「两条都是兜底边(fail_reason 都是 NULL)的完全重复边」不再被去重——
// 修一个吞边反而丢掉去重能力。COALESCE 把 NULL 折成 '',NULL 之间照旧互相冲突。
// org-graph-service.insertEdge 的 ON CONFLICT 目标必须原样带上这个表达式和 WHERE。
//
// 幂等:存量库上是 P2 的四列索引(键更窄 = 约束更严),先 DROP 再按新定义建;
// 新键是旧键的超集,约束只会更松,已有数据一定建得起来。第二遍跑时 sqlite_master
// 里的定义已含 fail_reason,跳过 DROP,CREATE 走 IF NOT EXISTS,不重建索引。
const dedupeIdx = database
.prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`)
.get('idx_org_graph_edges_dedupe_active');
if (dedupeIdx && !/fail_reason/.test(dedupeIdx.sql || '')) {
database.exec(`DROP INDEX idx_org_graph_edges_dedupe_active`);
}
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_org_graph_edges_dedupe_active
ON org_graph_edges(company_id, from_node_id, to_node_id, condition, COALESCE(fail_reason, ''))
WHERE status = 'active';
`);
// 6. 28 批次 1:`source_kind` 的三分(global_agent / template / company_hired)收成
// `origin` 二分。`system/templates/` 已整体并入 `system/agents/`,
// 「本体住在哪个目录」不再是一个可区分的维度,剩下的唯一区别是**谁造的**:
// builtin = 仓库自带的本体(原 global_agent + template)
// created = 走 create-agent / hire 生成的(原 company_hired)
//
// 带常量 DEFAULT 的 ADD COLUMN 是 SQLite 允许的少数几种 ALTER 之一,
// 存量行会立刻被填成 'builtin',所以不存在「加了列还没回填」的中间态。
// **CHECK 加不上**:SQLite 只能靠整表重建改约束,而这张表被 org_graph_nodes /
// work_graph_nodes 的外键引着,为一条 CHECK 重建表不划算。取值域由写入侧
// (graph-seed-service / graph-service,全仓只有这两处写 system_agents)
// 和新建库的 CREATE TABLE 一起兜。
if (!hasColumn(database, 'system_agents', 'origin')) {
database.exec(`ALTER TABLE system_agents ADD COLUMN origin TEXT NOT NULL DEFAULT 'builtin'`);
}
// 回填放在 if 外面,且写成「只改需要改的行」:ALTER 与 UPDATE 是两条语句,
// 中间挂掉的话 company_hired 行会停在默认的 'builtin' 上,下次 migrate 必须能补上。
// WHERE 里的 origin <> 'created' 让第二遍变成零行更新(幂等)。
//
// **顺序不能反**:先把 source_kind 的信息回填进 origin,再删列。反过来做的话,
// 「origin 列出现之前写下的行」永远回不到正确的 origin —— 那正是这条回填存在的理由。
// 两步都用 hasColumn 守着,所以删过之后第二遍是零操作(幂等)。
if (hasColumn(database, 'system_agents', 'source_kind')) {
database.exec(`
UPDATE system_agents SET origin = 'created'
WHERE source_kind = 'company_hired' AND origin <> 'created'
`);
// 28-followups §3.1:source_kind 与 origin 一一映射,零信息量,读侧只有上面那一条回填。
// 删列会让所有还带着它的 INSERT 报错,所以这一步必须跑在**任何写入之前** ——
// migrate() 由 getDb() 在建表之后立刻调用,正是那个位置。
database.exec(`ALTER TABLE system_agents DROP COLUMN source_kind`);
}
// 7. 28 批次 4:company_agents 的存量回填。
backfillCompanyAgents(database);
}
/**
* 存量雇佣关系回填(28 批次 4-17)。
*
* 两个来源合起来才是完整名册,缺一不可:
* - org_graph_nodes 的 active 行 —— org 层雇的角色,但按 decideOrgNodeRecording
* 的三条判据,work 层本体根本不在这张表里(这正是不能拿它当成员表的原因);
* - `data/companies/<cid>/company.json` 的 `agents[]` —— hire() 一直在维护的那份名册,
* org / work 两层都在里面,是 work 层本体唯一的存量证据。
*
* 幂等靠 (company_id, role_name) 主键 + INSERT OR IGNORE:重复跑是零行变化。
*
* company.json 那一路**只收 system_agents 里已有的 id**:`system_agent_id` 是指向
* system_agents 的外键(且 PRAGMA foreign_keys=ON),插一个没有本体行的角色会直接抛,
* 而这里跑在 getDb() 里 —— 抛出去等于整个进程连不上库。少一行名册可以由 M2 迁移脚本
* 补,连不上库不行。
*/
function backfillCompanyAgents(database) {
database.exec(`
INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
SELECT company_id, system_agent_id, role_name, 'active', created_at
FROM org_graph_nodes
WHERE status = 'active'
`);
const companiesDir = path.join(DATA_DIR, 'companies');
if (!fs.existsSync(companiesDir)) return;
const insert = database.prepare(`
INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
SELECT @company_id, @role, @role, 'active', @hired_at
WHERE EXISTS (SELECT 1 FROM system_agents WHERE id = @role)
`);
for (const entry of fs.readdirSync(companiesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
let company;
try {
company = JSON.parse(fs.readFileSync(path.join(companiesDir, entry.name, 'company.json'), 'utf-8'));
} catch {
continue; // 没有 company.json / 内容坏掉的目录不是公司,跳过
}
for (const role of company.agents || []) {
if (typeof role !== 'string' || !role) continue;
// 存量里 role_name 与本体 id 恒相同(hire() 从来只用一个名字),所以两列都填 role
insert.run({ company_id: entry.name, role, hired_at: company.createdAt || new Date().toISOString() });
}
}
}
function createEdgesTable(database) {
database.exec(`
CREATE TABLE IF NOT EXISTS org_graph_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id TEXT NOT NULL,
from_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id),
to_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id),
condition TEXT NOT NULL CHECK (condition IN ('success','fail')),
fail_reason TEXT,
edge_kind TEXT NOT NULL DEFAULT 'forward' CHECK (edge_kind IN ('forward','backward')),
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL
);
`);
}
/**
* 测试进程打开**共享生产库**时直接拦下来。
*
* 挡的是「测试压根没设隔离 env」这个形态 —— `test-isolation-env.test.js` 挡不住它:
* 那条守卫查的是「设了 XXX_PATH、生产代码却不读」,一个字都没设的文件没有可查的东西。
* 实际后果:`agent-chat-tenant-isolation.test.js` 每跑一次往 `data/graph.sqlite` 落
* 2 条 goal + 2 条 task,攒到 25 条 `_global` goal 才被人翻库翻出来,期间测试一直全绿。
*
* 为什么放在 getDb() 而不是模块顶层:`require('work-graph-db')` 只为取一个常量是合法的
* (goal-task-model.test.js 就这么用),顶层抛会误伤。**真正开库**才是越界那一刻。
*
* 需要读真库的测试显式写 `process.env.GRAPH_DB_PATH = <真库路径>` 表明意图即可。
*/
/**
* ⚠️ **`getDb()` 会跑 `migrate()`** —— 也就是说,**任何 require 本模块并调 getDb() 的进程
* 都会就地修改生产库的 schema**,包括一条随手敲的 `node -e "...getDb()..."`。
*
* 这不是假想:2026-08-20 删 `source_kind` 那次,生产库的列是被一条**查状态用的**
* 一行脚本删掉的 —— 服务当时根本没重启,人也没打算迁移。结果是对的(迁移本身幂等、
* 76 行逐行核对无误),但「读一下状态」不该顺手改 schema 这件事本身值得知道。
*
* 下面这道闸门**只挡测试进程**(避免污染共享数据),故意不挡普通脚本 ——
* 挡了的话服务自己也起不来。要只读地看一眼,用
* `new (require('better-sqlite3'))('data/graph.sqlite', {readonly:true})`,别走 getDb()。
*/
function assertNotProdDbInTests() {
if (!process.env.VITEST) return;
if (process.env.GRAPH_DB_PATH) return;
throw new Error(
`[graph-db] 测试进程试图打开共享生产库 ${DB_PATH}。\n` +
`请在 require 任何路由/服务**之前**设 process.env.GRAPH_DB_PATH 指向一次性库` +
`(fs.mkdtempSync 造目录),afterAll 里删掉。\n` +
`DB_PATH 是模块级 const,晚一步赋值就已经绑到生产库上了。`
);
}
function getDb() {
if (db) return db;
assertNotProdDbInTests();
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS system_agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('public','company')),
owner_company_id TEXT,
-- legacy_path 是 28 批次 1 之后的遗留列,但**还没死**(28-followups §3.1)。
-- 新写入的行它恒等于 system/agents/{id}(= 可由 id 推导,冗余),
-- 但存量行里存着批次 4 之前的公司侧路径(data/companies/{cid}/agents/{role}),
-- 而 graph-seed-service 判「悬空行 / 孤儿行」正是靠「这个路径指向的目录还在不在」。
-- 删它之前的退出条件:库里不再有 legacy_path <> 'system/agents/' || id 的行。
-- (2026-08-20 实查生产库:76 行里还有 2 行不满足 —— org-planner / org-coder
-- 指着已经不存在的 data/companies/mycompany/agents/。所以这一版还不能删。)
--
-- source_kind 已于 2026-08-20 删除:它与 origin 一一映射
-- (global_agent 对 builtin / company_hired 对 created),实查生产库 76 行零例外,
-- 唯一读侧是 migrate() 里那条 origin 回填,而那是给「origin 列出现之前的行」用的一次性迁移。
-- 注意:本段刻意不用反引号 —— 这里在 JS 模板字符串里面,反引号会把它提前截断
-- (踩过两次:一次在这张表,一次在 work-graph-db 的 DDL)。
legacy_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
benchmark_score REAL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
-- 列排在最后是为了和存量库 ALTER 出来的列序一致(SQLite 的 ADD COLUMN 只能追加)
origin TEXT NOT NULL DEFAULT 'builtin' CHECK (origin IN ('builtin','created'))
);
-- 公司 ⇄ 本体的雇佣关系(28 §4.2)。公司不再持有 Agent 实体目录,
-- 「这家公司雇了谁」的唯一真相源就是这张表。
-- 主键取 (company_id, role_name) 而不是带上 system_agent_id:同一个本体可以在
-- 一家公司里担两个角色(两行),但同一个角色名在一家公司里只能有一个人。
CREATE TABLE IF NOT EXISTS company_agents (
company_id TEXT NOT NULL,
system_agent_id TEXT NOT NULL REFERENCES system_agents(id),
role_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
hired_at TEXT NOT NULL,
PRIMARY KEY (company_id, role_name)
);
CREATE TABLE IF NOT EXISTS org_graph_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id TEXT NOT NULL,
system_agent_id TEXT NOT NULL REFERENCES system_agents(id),
role_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
reports_to_node_id INTEGER REFERENCES org_graph_nodes(id),
created_at TEXT NOT NULL
);
`);
createEdgesTable(db);
migrate(db);
return db;
}
module.exports = {
getDb,
};