graph-service.js
14.9 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
/**
* Graph Service — P1 虚拟引用寻址支撑函数(新文件,P1 专属)
*
* 不修改 P0 已有的 graph-db.js / graph-seed-service.js(那是并行工作线),
* 只通过 graph-db.js 导出的 getDb() 读写同一个 SQLite 连接。
*
* 详细需求与设计见 docs/todo/system-design/27-p1-virtual-reference.md。
*/
const fs = require('fs');
const path = require('path');
const { getDb } = require('./graph-db');
const { missingBodyDefinition, BASE_SKELETON_SENTINEL } = require('./agent-locator');
const ROOT = path.resolve(__dirname, '../..');
let indexEnsured = false;
/**
* 查询某个 system_agent id 对应的共享 agent.md 绝对路径。
*
* P1 时这里带着 `AND source_kind = 'template'` 的过滤,因为 `system/agents/` 和
* `system/templates/` 下有 7 个同名 id、内容不同步,命中错的那一行会读错文件。
* 28 批次 1 把两个目录合并成唯一的本体注册表之后,同名冲突不复存在,过滤也就成了
* 纯粹的伤害:那 7 个 id 恒返回 null → hire() 每次都退回全量拷贝。所以过滤去掉,
* 路径也不再读 legacy_path,而是直接由 id 推导(本体地址恒为 system/agents/{id})。
*
* 仍然返回 null 的两种情况,都是防御性的:
* - 库里没有这行(还没 seed / 是别的命名空间的东西);
* - 目录在库里、`agent.md` 却不在磁盘上。`ceo` 就当过这样的残缺本体
* (只有 plan.md + todo.md),P1 时它让 hire() 指向一个不存在的文件,
* `mycompany/company.json` 的 `"ceo": null` 就是这么来的。
*
* @param {string} systemAgentId
* @returns {string|null} 共享 agent.md 的绝对路径;查不到行或文件不存在时返回 null
*/
function lookupSharedAgentMd(systemAgentId) {
const db = getDb();
const row = db.prepare(`SELECT id FROM system_agents WHERE id = ?`).get(systemAgentId);
if (!row) return null;
const agentMdPath = path.resolve(ROOT, 'system', 'agents', systemAgentId, 'agent.md');
return fs.existsSync(agentMdPath) ? agentMdPath : null;
}
/**
* P2(27-p2-org-graph.md §3.2)起改为**部分**唯一索引:只约束 status='active' 的行,
* 这样某个角色被 fire(status='removed')之后,同一个 role_name 能被重新 hire 进来。
* 索引本身 graph-db.js 的 migrate() 已经建过了,这里保留是为了让本模块不依赖调用顺序;
* 名字/条件必须与 graph-db.js 完全一致,否则会互相把对方的索引建回来。
*/
function ensureOrgGraphNodeIndex(db) {
if (indexEnsured) return;
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_org_graph_nodes_company_role_active
ON org_graph_nodes(company_id, role_name) WHERE status = 'active'
`);
indexEnsured = true;
}
/**
* 保证 system_agents 里有 systemAgentId 这一行(缺则按 seedCompanyAgents() 对同样场景的
* 字段约定 fallback 插入 origin='created' 的记录)。已存在的行一个字段都不改——
* 绝不把 origin='builtin' 的公共本体降级成公司私有。
*
* P3B/W7 起从 upsertOrgGraphNode() 里拆出来单独导出:work 层本体被雇时**不写**
* org_graph_nodes(判据见 company-service.decideOrgNodeRecording),但 system_agents
* 这一行仍然必须补——`work_graph_nodes.system_agent_id` 是指向该表的外键,
* 缺行的话 workgraph-planning 雇完人再提交图会直接外键挂(27-P3B §四 W1)。
*
* 幂等,可重复调用。调用方自己起事务时也安全(better-sqlite3 支持嵌套 transaction)。
*
* @param {string} companyId
* @param {string} systemAgentId
* @param {string} roleName - 保留形参(调用方都在传),28 批次 4 起不再参与路径推导:
* 本体只有一份、恒在 `system/agents/{systemAgentId}`,角色名不进路径
* @param {object} [opts] - `{ status }`:新造的本体传 'draft'(28 批次 5-22 的闸门),
* 缺省 'active' 保持既有语义(补一行审计用的引用记录,不改上岗资格)
* @returns {boolean} true = 本次真的插了一行;false = 早就有了
*/
function ensureSystemAgentRow(companyId, systemAgentId, roleName, opts = {}) {
const db = getDb();
const existingAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`).get(systemAgentId);
if (existingAgent) return false;
const now = new Date().toISOString();
db.prepare(`
INSERT INTO system_agents (id, name, scope, owner_company_id, origin, legacy_path, status, benchmark_score, created_at, updated_at)
VALUES (@id, @name, 'company', @owner_company_id, 'created', @legacy_path, @status, NULL, @created_at, @updated_at)
`).run({
id: systemAgentId,
name: systemAgentId,
owner_company_id: companyId,
legacy_path: `system/agents/${systemAgentId}`,
status: opts.status || 'active',
created_at: now,
updated_at: now,
});
return true;
}
/**
* draft 本体的转正闸门(28 批次 5-23)。
*
* 为什么是**按需校验**而不是「agent-creator 写完就升」:填 agent.md / soul.md / SKILL.md 的
* 是 agent-creator 自己的 Claude CLI 子进程,触发它的是一次 fire-and-forget 的 HTTP 自调用
* (`company.js` 的 /hire,唯一调用方——`/templates/:id/generate` 已于 2026-08-23 随手工
* 模板管理页一起下线)—— 那条链路上**没有**「写完了」这个
* 回调点可挂。所以判据只认磁盘:谁来问上岗资格,就现场数一次文件。
* 幂等且便宜(三次 readFileSync),可以放在 hire() 的热路径上。
*
* 只升不降:已经 active 的行一个字段都不碰 —— 本体在跑的时候被 agent 自己改空了
* (evolve 中途、或者只是暂时写坏),不该把它从岗位上撤下来。
*
* `training` 与 `draft` 同属「定义还没填满」:前者是这个状态在 `config.json` 里的拼法
* (hireFromBaseMetaAgent / scaffoldNewAgent 写的),而 `graph-seed-service.seedAgents()`
* 会把 config.json 的 status 原样 upsert 进库。只认 'draft' 的话,一份被 seed 回收过的
* 新本体会永远卡在 training 上 —— 闸门抬不起来,且不报错。
*
* @param {string} agentId
* @returns {{ status: string, missing: string[], promoted: boolean }}
* status 是**校验之后**的库内状态;库里没有这行时返回 status='unknown'
*/
const UNGRADUATED_STATUS = new Set(['draft', 'training']);
/**
* 这个库内状态算不算「定义还没填满、不许上岗」。
*
* 必须由本模块单点回答,调用方不能自己写 `status !== 'active'`:`system_agents.status`
* 不是一个封闭取值域 —— `seedAgents()` 把 `config.json.status` 原样 upsert 进库,
* 生产库里因此有 `enabled` / `idle` / `ready` / `trained` 共 36 行合法本体。
* 拿 `!== 'active'` 当闸门 = 把这 36 个(含 workgraph-planning / master / agent-creator /
* qa-judge)全判成「定义没填满」,hire() 返回 body_not_ready 且 missing 为空数组,
* 报的理由还是错的。闸门要挡的只有 draft / training 这两个「刚建出来还没填」的状态。
*/
function isUngraduatedStatus(status) {
return UNGRADUATED_STATUS.has(status);
}
/**
* 「图基础设施本体」的取值域 —— **永不作为 org 节点**的 config.json `type`。
*
* 与 isUngraduatedStatus 同一个道理:取值域必须单点持有,抄一份就迟早漂。
* 这一条已经真漂过:`company-service.decideOrgNodeRecording` 判据①在 P3B/W7 从
* `{'meta-bootstrap'}` 放宽到 `{'meta-bootstrap','meta-planning'}`(要盖住按需雇的
* workgraph-planning 以及它自己雇的 prd/sdd/brd-planning、goal-benchmark),
* 而 `graph-seed-service` 里那份手抄的 `isMetaBootstrap()` 停在只认 meta-bootstrap。
* 后果不是「补种脚本画歪一张图」:`seedCompanyAgents()` 会把 hire() 特意排除掉的
* 规划本体重新塞回 `org_graph_nodes`(`authored_by=null` 的孤儿节点),而
* `work-graph-db.js:473` 起 org run 读的就是这张表的 active 行 —— 多出来的是一个
* **真的会被执行**、却谁也点不亮的 org 节点(正是 graph-engine.resolvePlanningAgentName
* 注释里点名要防的形态)。
*
* 更糟的是 `verifyCounts()` 用**同一个**手抄函数算期望值:两边一致地错 =
* `npm run graph:seed` exit 0、零告警,这条漂移永远发现不了。所以判据只能有一处。
*
* 放在 graph-service 而不是 agent-locator:这是**图语义**(谁算组织图节点),
* 不是目录寻址。company-service 眼下仍持有一份同名字面量(那是别人的文件,本轮不动),
* 它已经 require 了本模块,改成引用这里是一行的事;在那之前由
* `server/tests/unit/seed-org-node-guard.test.js` 的漂移守卫盯着两边一致。
*/
const NON_ORG_AGENT_TYPES = new Set(['meta-bootstrap', 'meta-planning']);
/** 这个 config.json `type` 算不算图基础设施本体(永不入 org graph) */
function isNonOrgAgentType(type) {
return NON_ORG_AGENT_TYPES.has(type);
}
function promoteDraftIfComplete(agentId) {
const db = getDb();
const row = db.prepare(`SELECT status FROM system_agents WHERE id = ?`).get(agentId);
if (!row) return { status: 'unknown', missing: [], promoted: false };
if (!UNGRADUATED_STATUS.has(row.status)) return { status: row.status, missing: [], promoted: false };
const missing = missingBodyDefinition(agentId);
if (missing.length > 0) {
// 把「为什么没转正」说出来:绝大多数情况是本体刚从 `_base` 拷出来、agent-creator
// 那次 fire-and-forget 还没落盘(或者压根没跑成),文件都在、只是内容还是通用骨架。
// 不打这条日志的话,现场只能看到 hire() 返回 body_not_ready,而目录、库行、文件一应俱全,
// 没有任何一处指向「哨兵还在」这个真实原因。
console.log(`[Graph] ⏸️ 本体 ${agentId} 仍在 ${row.status}:缺 ${missing.join(' / ')}(空白,或仍带 ${BASE_SKELETON_SENTINEL} 未被填充)`);
return { status: row.status, missing, promoted: false };
}
db.prepare(`UPDATE system_agents SET status = 'active', updated_at = ? WHERE id = ? AND status = ?`)
.run(new Date().toISOString(), agentId, row.status);
console.log(`[Graph] 🎓 本体 ${agentId} 定义已齐全,status: ${row.status} → active`);
return { status: 'active', missing: [], promoted: true };
}
/**
* 这家公司的 org graph 有没有成型,以及某个角色在不在图上(P3B §四 W7 的判据之一)。
*
* `hasWorkflow` 判的是「orggraph-planning 提交过一张图没有」,而**不是**「有没有节点」——
* 节点 hire() 自己就会造(P1/P2 语义),拿节点数当信号会把「同一家公司连雇两个人,
* 第二个不入图」这条既有语义废掉。
*
* 主判据是 `authored_by='planner'` 的节点(writeGraph 是唯一写它的地方)。
* 早先用的是**边数**代理,而真跑里 orggraph-planning 裁出过一张**零条边的单节点图**,
* 代理当场失效:三条判据全落空 → work 层本体被写进 org_graph_nodes,
* 下一次下达任务时被 advanceOne 当 org 节点执行(P3B W7-fix)。
* 边数保留为存量行(authored_by IS NULL)的回落判据。
*
* 此后这张图的作者就是 orggraph-planning,
* hire() 不再往上追加节点:`work-graph-db.js:473` 起 org run 时读的正是 org_graph_nodes
* 的 active 行,混进去的 work 层本体不是画歪一张图,而是真的多出一个会被执行的 org 节点。
*
* @param {string} companyId
* @param {string} roleName
* @returns {{hasWorkflow: boolean, isNode: boolean}}
*/
function orgGraphMembership(companyId, roleName) {
const db = getDb();
// 主判据:有没有 planner 写下的节点。这是「orggraph-planning 提交过一张图」的**直接**证据。
const planned = db.prepare(
`SELECT COUNT(*) AS c FROM org_graph_nodes
WHERE company_id = ? AND status = 'active' AND authored_by = 'planner'`
).get(companyId);
// 回落判据:本列之前的存量行 authored_by 是 NULL,对它们仍用原来的边数代理,行为不变。
const edges = db.prepare(
`SELECT COUNT(*) AS c FROM org_graph_edges WHERE company_id = ? AND status = 'active'`
).get(companyId);
const node = db.prepare(
`SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'`
).get(companyId, roleName);
const hasWorkflow = ((planned && planned.c) || 0) > 0 || ((edges && edges.c) || 0) > 0;
return { hasWorkflow, isNode: ((node && node.c) || 0) > 0 };
}
/**
* 为 (companyId, roleName) 建立/复用一条 org_graph_nodes 引用记录。
*
* 语义是「把这个 agent 记成本公司 org graph 的一个节点」——**显式动作**,
* 调用方有两类:org-graph-service.insertNode(规划 agent 提交 org graph)与
* company-service.hire 判定为 org 层招聘时。work 层招聘不走这里(P3B §四 W7)。
*
* system_agents 缺行时先补(ensureSystemAgentRow),因为 org_graph_nodes.system_agent_id
* 也是外键。
*
* 幂等:org_graph_nodes(company_id, role_name) WHERE status='active' 部分唯一索引
* + INSERT ... ON CONFLICT(...) WHERE status='active' DO NOTHING,重复调用不产生重复行。
* (SQLite 的 conflict target 要匹配部分索引,必须原样带上索引的 WHERE 条件。)
*
* @param {string} companyId
* @param {string} systemAgentId
* @param {string} roleName
* @returns {void}
*/
function upsertOrgGraphNode(companyId, systemAgentId, roleName, authoredBy = 'hire') {
const db = getDb();
ensureOrgGraphNodeIndex(db);
const now = new Date().toISOString();
const tx = db.transaction(() => {
ensureSystemAgentRow(companyId, systemAgentId, roleName);
db.prepare(`
INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at, authored_by)
VALUES (@company_id, @system_agent_id, @role_name, 'active', @created_at, @authored_by)
ON CONFLICT(company_id, role_name) WHERE status = 'active' DO NOTHING
`).run({
company_id: companyId,
system_agent_id: systemAgentId,
role_name: roleName,
created_at: now,
authored_by: authoredBy,
});
// 冲突被 DO NOTHING 吞掉时行还在,但来源可能需要升格:
// hire 先记了一行(authored_by='hire'),随后 orggraph-planning 提交整张图把它纳入,
// 这行就该算 planner 写的。反向**不升格** —— planner 写过的图不会因为后续 hire 降级。
if (authoredBy === 'planner') {
db.prepare(`
UPDATE org_graph_nodes SET authored_by = 'planner'
WHERE company_id = ? AND role_name = ? AND status = 'active'
`).run(companyId, roleName);
}
});
tx();
}
module.exports = {
lookupSharedAgentMd,
ensureSystemAgentRow,
promoteDraftIfComplete,
isUngraduatedStatus,
NON_ORG_AGENT_TYPES,
isNonOrgAgentType,
orgGraphMembership,
upsertOrgGraphNode,
};