graph-run-phase-note.test.js
6.32 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
/**
* Unit Tests — POST /api/graph-runs/phase-note(42 号文档 Phase 4)
*
* `bin/lemon.js task transition` 额外打这个端点,把 agent 的阶段流转(RESEARCH/PLAN/
* EXECUTE/VERIFY 之类)落进 timeline.ndjson——org-coder 等角色调 legacy 的
* `/api/agent/:agentId/task/transition` 时,work graph 节点场景下那个端点的 sessions
* Map 找不到会话,静默跳过;这条端点从 --artifact 路径本身(节点私有产物目录的既有形状
* `.../runs/{rootRunId}/nodes/{nodeRunId}/...`)解出两个 ID,不需要任何新的环境变量或
* agent.md 改动。
*
* 钉住的边界(39 号文档 §七.2 定案):这条端点只追加 timeline,绝不碰
* graph_run_nodes/graph_runs 任何一行——不影响 needs/produces 契约、不影响 validator
* 判定依据。
*
* 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,与 graph-run-route-io.test.js 同法。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import express from 'express';
const TEST_DB_PATH = '/tmp/test-graph-run-phase-note.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
'../../services/graph-db',
'../../services/graph-service',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/company-paths',
'../../services/graph-run-sse',
'../../services/graph-engine',
'../../routes/graph-run',
];
describe('POST /api/graph-runs/phase-note', () => {
let wdb;
let sse;
let srv;
let base;
let runsDir;
const companyId = 'co-phase-note';
beforeAll(async () => {
require('../helpers/ensure-test-company').ensureTestCompany(companyId);
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-note-runs-'));
process.env.GRAPH_RUNS_DIR = runsDir;
for (const m of MODULES) delete require.cache[require.resolve(m)];
wdb = require('../../services/work-graph-db');
sse = require('../../services/graph-run-sse');
const app = express();
app.use(express.json());
app.use('/api/graph-runs', require('../../routes/graph-run'));
await new Promise((resolve) => {
srv = app.listen(0, '127.0.0.1', () => {
base = `http://127.0.0.1:${srv.address().port}`;
resolve();
});
});
});
afterAll(async () => {
if (srv) await new Promise((r) => srv.close(r));
require('../helpers/ensure-test-company').removeTestCompany(companyId);
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
delete process.env.GRAPH_DB_PATH;
delete process.env.GRAPH_RUNS_DIR;
for (const m of MODULES) delete require.cache[require.resolve(m)];
});
function ensureAgents(ids) {
const db = wdb.getDb();
const now = new Date().toISOString();
const ins = db.prepare(`
INSERT OR IGNORE INTO system_agents
(id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
`);
for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
}
function makeRun() {
ensureAgents(['org-coder']);
const g = wdb.createWorkGraph({ companyId, nodes: ['org-coder'], edges: [] });
expect(g.errors || []).toEqual([]);
const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
require('../../services/graph-engine').markRunReady(run.id);
const node = wdb.getRunState(run.id).nodes[0];
return { runId: run.id, nodeRunId: node.id };
}
async function post(body) {
const r = await fetch(`${base}/api/graph-runs/phase-note`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return [r.status, await r.json()];
}
it('合法的节点产物路径 → 写进 timeline.ndjson,节点状态不受影响', async () => {
const { runId, nodeRunId } = makeRun();
const beforeStatus = wdb.getRunState(runId).nodes[0].status;
const artifact = `/Users/x/lemonboss/data/companies/${companyId}/runs/${runId}/nodes/${nodeRunId}/_plan.md`;
const [status, body] = await post({ artifact, phase: 'plan', agentId: `${companyId}:org-coder` });
expect(status).toBe(200);
expect(body.ok).toBe(true);
expect(body.rootRunId).toBe(runId);
expect(body.nodeRunId).toBe(nodeRunId);
// timeline.ndjson 里真的能查到这条记录
const raw = fs.readFileSync(sse.timelinePath(runId), 'utf8');
const lines = raw.trim().split('\n').map((l) => JSON.parse(l));
const mine = lines.find((l) => l.kind === 'agent.phase' && l.ref === nodeRunId);
expect(mine).toBeTruthy();
expect(mine.payload.phase).toBe('plan');
expect(mine.payload.artifact).toBe(artifact);
expect(mine.payload.agentId).toBe(`${companyId}:org-coder`);
expect(mine.root_run_id).toBe(runId);
// 不改契约面:节点状态原样,没有因为这条追加日志被结算或改动
expect(wdb.getRunState(runId).nodes[0].status).toBe(beforeStatus);
});
it('artifact 路径不是节点产物目录形状(legacy 会话式 agent 的调用) → ok:false,安静跳过', async () => {
const [status, body] = await post({
artifact: '/Users/x/lemonboss/system/agents/some-agent/co1/task-abc/_plan.md',
phase: 'plan',
agentId: 'co1:some-agent',
});
expect(status).toBe(200);
expect(body.ok).toBe(false);
expect(body.reason).toBe('not-a-graph-artifact');
});
it('artifact 路径形状对,但 rootRunId 不对应真实存在的 run → ok:false', async () => {
const [status, body] = await post({
artifact: `/Users/x/lemonboss/data/companies/${companyId}/runs/run_does_not_exist/nodes/rn_does_not_exist/_plan.md`,
phase: 'plan',
});
expect(status).toBe(200);
expect(body.ok).toBe(false);
expect(body.reason).toBe('not-a-graph-artifact');
});
it('没传 artifact → ok:false,reason:no-artifact', async () => {
const [status, body] = await post({ phase: 'plan' });
expect(status).toBe(200);
expect(body.ok).toBe(false);
expect(body.reason).toBe('no-artifact');
});
});