graph-db.js
14.8 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
/**
* 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。
*/
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' 让第二遍变成零行更新(幂等)。
database.exec(`
UPDATE system_agents SET origin = 'created'
WHERE source_kind = 'company_hired' AND origin <> 'created'
`);
// 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
);
`);
}
function getDb() {
if (db) return db;
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,
-- source_kind / legacy_path 是 28 批次 1 之后的**遗留列**:读侧已全部改走
-- origin + 「路径恒为 system/agents/{id}」的推导,这两列只为存量库和还没改完的
-- 批次(company_agents 表落地在批次 4)留一版兼容,写入侧仍按 origin 一一映射维护。
source_kind TEXT NOT NULL CHECK (source_kind IN ('global_agent','template','company_hired')),
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,
};