jarvis-agent-factory 3.22.2 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/engine/db.js +97 -8
- package/src/engine/gates.js +1 -1
- package/src/engine/server.js +103 -39
- package/src/install.js +14 -16
- package/src/web/routes.js +15 -5
- package/src/web/views/pipeline.html +131 -3
package/package.json
CHANGED
package/src/engine/db.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import {
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
3
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* 打开引擎数据库,固定存储在 ~/.jarvis/engine.db
|
|
8
|
+
* @returns {DatabaseSync}
|
|
9
|
+
*/
|
|
10
|
+
export function openDb() {
|
|
11
|
+
const dir = resolve(homedir(), '.jarvis');
|
|
7
12
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
8
|
-
const db = new DatabaseSync(
|
|
13
|
+
const db = new DatabaseSync(resolve(dir, 'engine.db'));
|
|
9
14
|
db.exec('PRAGMA journal_mode=WAL');
|
|
10
15
|
db.exec('PRAGMA busy_timeout=5000');
|
|
11
16
|
initSchema(db);
|
|
@@ -45,6 +50,20 @@ function initSchema(db) {
|
|
|
45
50
|
updated_at TEXT NOT NULL
|
|
46
51
|
);
|
|
47
52
|
`);
|
|
53
|
+
// pipeline_runs: 每次 /jarvis 调用产生独立运行记录(Session Model B)
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS pipeline_runs (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
session_id TEXT NOT NULL,
|
|
58
|
+
project TEXT NOT NULL,
|
|
59
|
+
pipeline_type TEXT NOT NULL DEFAULT 'full',
|
|
60
|
+
current_gate TEXT NOT NULL DEFAULT 'Gate A',
|
|
61
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
62
|
+
started_at TEXT NOT NULL,
|
|
63
|
+
completed_at TEXT
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_pipeline_runs_session ON pipeline_runs(session_id, started_at DESC);
|
|
66
|
+
`);
|
|
48
67
|
try { db.exec("ALTER TABLE agent_models ADD COLUMN effort TEXT NOT NULL DEFAULT 'high'"); } catch {}
|
|
49
68
|
|
|
50
69
|
// ---- 迁移:修复旧 pipeline 表 CHECK(id=1) 约束 ----
|
|
@@ -113,14 +132,30 @@ function initSchema(db) {
|
|
|
113
132
|
|
|
114
133
|
// ---- 旧列迁移(向后兼容) ----
|
|
115
134
|
try { db.exec("ALTER TABLE sessions ADD COLUMN status TEXT DEFAULT 'active'"); } catch {}
|
|
135
|
+
|
|
136
|
+
// ---- 迁移旧 pipeline 数据为首条 pipeline_run ----
|
|
137
|
+
const existingRuns = db.prepare('SELECT COUNT(*) as cnt FROM pipeline_runs').get();
|
|
138
|
+
if (existingRuns.cnt === 0) {
|
|
139
|
+
const oldPipelines = db.prepare('SELECT * FROM pipeline').all();
|
|
140
|
+
for (const p of oldPipelines) {
|
|
141
|
+
if (!p.session_id) continue;
|
|
142
|
+
const runId = 'run_' + Date.now() + '_' + p.session_id.slice(-6);
|
|
143
|
+
db.prepare(`INSERT OR IGNORE INTO pipeline_runs (id, session_id, project, pipeline_type, current_gate, status, started_at)
|
|
144
|
+
VALUES (?, ?, ?, ?, ?, 'active', ?)`).run(
|
|
145
|
+
runId, p.session_id, p.project || 'jarvis', p.pipeline_type || 'full', p.current_gate || 'Gate A', p.started_at || new Date().toISOString()
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
116
149
|
}
|
|
117
150
|
|
|
118
151
|
// ---- Pipeline (per-session) ----
|
|
119
152
|
export function getPipeline(db, sessionId) {
|
|
120
|
-
|
|
153
|
+
if (!sessionId) return null;
|
|
154
|
+
return db.prepare('SELECT * FROM pipeline WHERE session_id=?').get(sessionId);
|
|
121
155
|
}
|
|
122
156
|
export function updatePipelineGate(db, sessionId, gate) {
|
|
123
|
-
|
|
157
|
+
if (!sessionId) throw new Error('session_id required');
|
|
158
|
+
db.prepare(`UPDATE pipeline SET current_gate=?, updated_at=datetime('now') WHERE session_id=?`).run(gate, sessionId);
|
|
124
159
|
}
|
|
125
160
|
/** @param {'full'|'frontend'|'backend'} pipelineType */
|
|
126
161
|
export function initPipeline(db, sessionId, project, pipelineType = 'full') {
|
|
@@ -132,8 +167,9 @@ export function getAllPipelines(db) {
|
|
|
132
167
|
|
|
133
168
|
// ---- Checkpoints (per-session) ----
|
|
134
169
|
export function getCheckpoints(db, gate, sessionId) {
|
|
135
|
-
if (
|
|
136
|
-
return db.prepare('SELECT * FROM checkpoints WHERE
|
|
170
|
+
if (!sessionId) return [];
|
|
171
|
+
if (gate) return db.prepare('SELECT * FROM checkpoints WHERE gate=? AND session_id=?').all(gate, sessionId);
|
|
172
|
+
return db.prepare('SELECT * FROM checkpoints WHERE session_id=? ORDER BY passed_at').all(sessionId);
|
|
137
173
|
}
|
|
138
174
|
export function addCheckpoint(db, gate, advanceTo, sessionId) {
|
|
139
175
|
db.prepare(`INSERT OR REPLACE INTO checkpoints (session_id, gate, passed_at, advance_to) VALUES (?, ?, datetime('now'), ?)`).run(sessionId, gate, advanceTo);
|
|
@@ -190,3 +226,56 @@ export function getAgentConfig(db) {
|
|
|
190
226
|
export function setAgentModel(db, agentId, model, effort) {
|
|
191
227
|
db.prepare(`INSERT OR REPLACE INTO agent_models (agent_id, model, effort, updated_at) VALUES (?, ?, ?, datetime('now'))`).run(agentId, model, effort || 'high');
|
|
192
228
|
}
|
|
229
|
+
|
|
230
|
+
// ---- Pipeline Runs(Session Model B)----
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 创建新的 pipeline run
|
|
234
|
+
* @param {DatabaseSync} db
|
|
235
|
+
* @param {string} sessionId
|
|
236
|
+
* @param {string} project
|
|
237
|
+
* @param {string} [pipelineType='full']
|
|
238
|
+
* @returns {string} runId
|
|
239
|
+
*/
|
|
240
|
+
export function createPipelineRun(db, sessionId, project, pipelineType = 'full') {
|
|
241
|
+
const id = 'run_' + Date.now();
|
|
242
|
+
db.prepare(`INSERT INTO pipeline_runs (id, session_id, project, pipeline_type, current_gate, status, started_at)
|
|
243
|
+
VALUES (?, ?, ?, ?, 'Gate A', 'active', datetime('now'))`).run(id, sessionId, project, pipelineType);
|
|
244
|
+
return id;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 获取指定 run */
|
|
248
|
+
export function getPipelineRun(db, runId) {
|
|
249
|
+
return db.prepare('SELECT * FROM pipeline_runs WHERE id=?').get(runId);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 获取 session 的当前活跃 run(最新一条 status=active)
|
|
254
|
+
* @returns {object|undefined}
|
|
255
|
+
*/
|
|
256
|
+
export function getActiveRun(db, sessionId) {
|
|
257
|
+
return db.prepare("SELECT * FROM pipeline_runs WHERE session_id=? AND status='active' ORDER BY started_at DESC LIMIT 1").get(sessionId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 获取 session 的所有 runs(按时间倒序)
|
|
262
|
+
* @returns {object[]}
|
|
263
|
+
*/
|
|
264
|
+
export function getSessionRuns(db, sessionId) {
|
|
265
|
+
return db.prepare('SELECT * FROM pipeline_runs WHERE session_id=? ORDER BY started_at DESC').all(sessionId);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 更新 run 的当前 Gate */
|
|
269
|
+
export function updateRunGate(db, runId, gate) {
|
|
270
|
+
db.prepare("UPDATE pipeline_runs SET current_gate=? WHERE id=?").run(gate, runId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 完成 run */
|
|
274
|
+
export function completeRun(db, runId) {
|
|
275
|
+
db.prepare("UPDATE pipeline_runs SET status='completed', completed_at=datetime('now') WHERE id=?").run(runId);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 中止 run */
|
|
279
|
+
export function abortRun(db, runId) {
|
|
280
|
+
db.prepare("UPDATE pipeline_runs SET status='aborted', completed_at=datetime('now') WHERE id=?").run(runId);
|
|
281
|
+
}
|
package/src/engine/gates.js
CHANGED
|
@@ -81,7 +81,7 @@ export const GATE_CHECKS = {
|
|
|
81
81
|
export const GATE_OPERATIONS = {
|
|
82
82
|
'Gate A': { allow: ['read','write_doc'], deny: ['write_code','spawn_impl','spawn_test','build','deploy'] },
|
|
83
83
|
'Gate B': { allow: ['read','write_doc'], deny: ['write_code','spawn_impl','spawn_test','build','deploy'] },
|
|
84
|
-
'Gate C': { allow: ['read','write_doc','sweep_arch'
|
|
84
|
+
'Gate C': { allow: ['read','write_doc','sweep_arch','write_code','spawn_impl'], deny: ['spawn_test','build','deploy'] },
|
|
85
85
|
'Gate C1': { allow: ['read','lint','build','fix'], deny: ['spawn_impl','spawn_test','deploy','write_code'] },
|
|
86
86
|
'Gate C1.5': { allow: ['read','preview','fix'], deny: ['spawn_impl','spawn_test','build','deploy','write_code'] },
|
|
87
87
|
'Gate C2': { allow: ['read','spawn_test','fix'], deny: ['spawn_impl','deploy','write_code'] },
|
package/src/engine/server.js
CHANGED
|
@@ -4,12 +4,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
4
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, copyFileSync } from 'node:fs';
|
|
8
8
|
import { resolve, join } from 'node:path';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { createServer } from 'node:net';
|
|
11
11
|
import { createServer as createHttpServer } from 'node:http';
|
|
12
|
-
import { openDb, getPipeline, initPipeline, getCheckpoints, addCheckpoint, updatePipelineGate, getSessions, getSession, addSession, heartbeatSession, removeSession, markStaleSessions, resumeSession, migrateSession, getAllPipelines, getAgentConfig, setAgentModel } from './db.js';
|
|
12
|
+
import { openDb, getPipeline, initPipeline, getCheckpoints, addCheckpoint, updatePipelineGate, getSessions, getSession, addSession, heartbeatSession, removeSession, markStaleSessions, resumeSession, migrateSession, getAllPipelines, getAgentConfig, setAgentModel, createPipelineRun, getPipelineRun, getActiveRun, getSessionRuns, updateRunGate, completeRun } from './db.js';
|
|
13
13
|
import { GATE_CHECKS, GATE_DIRS, AGENT_LIST, PIPELINE_DEFS, findGateArtifacts, formatGateDisplay, getPipelineGates, getPipelineName, getGateOperations, DEFAULT_PIPELINE } from './gates.js';
|
|
14
14
|
import { getAgentsByPlatform, getPlatforms, getPlatformModels, getAgentList } from './agent-registry.js';
|
|
15
15
|
import { setupApiRoutes } from '../web/routes.js';
|
|
@@ -17,7 +17,10 @@ import { setupApiRoutes } from '../web/routes.js';
|
|
|
17
17
|
const PID_FILE = resolve(homedir(), '.jarvis', 'engine.pid');
|
|
18
18
|
const DEFAULT_PORT = 3456;
|
|
19
19
|
const DEFAULT_WEB_PORT = 3457;
|
|
20
|
-
const SESSION_TIMEOUT =
|
|
20
|
+
const SESSION_TIMEOUT = 1_800_000; // 30分钟超时,标记 inactive 而非删除
|
|
21
|
+
|
|
22
|
+
/** stdio 模式下 extra?.sessionId 为空,用此变量记录最近一次 session_join 的会话 ID */
|
|
23
|
+
let _lastSessionId = null;
|
|
21
24
|
|
|
22
25
|
/** 绑定地址:仅 IPv4 本地回环 */
|
|
23
26
|
const BIND_HOST = '127.0.0.1';
|
|
@@ -57,12 +60,32 @@ export async function startEngine({ port = DEFAULT_PORT, projectRoot = '.', stdi
|
|
|
57
60
|
if (!existsSync(pidDir)) mkdirSync(pidDir, { recursive: true });
|
|
58
61
|
writeFileSync(PID_FILE, String(process.pid));
|
|
59
62
|
|
|
63
|
+
// 旧数据库迁移:从 <projectRoot>/.jarvis/ 移动到 ~/.jarvis/
|
|
64
|
+
const oldDbPath = resolve(root, '.jarvis', 'engine.db');
|
|
65
|
+
const newDbPath = resolve(homedir(), '.jarvis', 'engine.db');
|
|
66
|
+
if (existsSync(oldDbPath) && !existsSync(newDbPath)) {
|
|
67
|
+
copyFileSync(oldDbPath, newDbPath);
|
|
68
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
69
|
+
const oldAux = oldDbPath + suffix;
|
|
70
|
+
const newAux = newDbPath + suffix;
|
|
71
|
+
if (existsSync(oldAux)) copyFileSync(oldAux, newAux);
|
|
72
|
+
}
|
|
73
|
+
console.log(' ✓ 旧数据库已迁移: ' + oldDbPath + ' → ' + newDbPath);
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
const app = new Hono();
|
|
61
77
|
const mcpServer = new McpServer({ name: 'jarvis-engine', version: readPkgVersion() });
|
|
62
|
-
const db = openDb(
|
|
78
|
+
const db = openDb();
|
|
63
79
|
|
|
64
80
|
// 心跳保活 + 过期标记
|
|
65
81
|
setInterval(() => { markStaleSessions(db, SESSION_TIMEOUT); }, 30_000);
|
|
82
|
+
// 引擎内部自动心跳:每 5 分钟对所有 active 会话更新心跳,防止 stdio 模式下心跳丢失
|
|
83
|
+
setInterval(() => {
|
|
84
|
+
const activeSessions = getSessions(db, 'active');
|
|
85
|
+
for (const s of activeSessions) {
|
|
86
|
+
heartbeatSession(db, s.id);
|
|
87
|
+
}
|
|
88
|
+
}, 300_000);
|
|
66
89
|
|
|
67
90
|
// ---- MCP Tools (不变) ----
|
|
68
91
|
registerMcpTools(mcpServer, db, root);
|
|
@@ -146,7 +169,12 @@ function registerMcpTools(server, db, root) {
|
|
|
146
169
|
},
|
|
147
170
|
async ({ platform, resume_session_id, pipeline_type }, extra) => {
|
|
148
171
|
const sid = extra?.sessionId || `s${Date.now()}`;
|
|
172
|
+
_lastSessionId = sid; // stdio 模式回退:记录最近会话
|
|
149
173
|
const pt = pipeline_type || DEFAULT_PIPELINE;
|
|
174
|
+
// 白名单校验 pipeline_type,防止存储型 XSS
|
|
175
|
+
if (!['full', 'frontend', 'backend', 'lite'].includes(pt)) {
|
|
176
|
+
return resp({ error: `Invalid pipeline_type: ${pt}. Valid: full, frontend, backend, lite` });
|
|
177
|
+
}
|
|
150
178
|
if (resume_session_id) {
|
|
151
179
|
const old = getSession(db, resume_session_id);
|
|
152
180
|
if (old) {
|
|
@@ -158,20 +186,23 @@ function registerMcpTools(server, db, root) {
|
|
|
158
186
|
if (existing) {
|
|
159
187
|
heartbeatSession(db, sid);
|
|
160
188
|
const p = getPipeline(db, sid);
|
|
189
|
+
// Session Model B: 无活跃 run 时自动创建
|
|
190
|
+
const runId = getActiveRun(db, sid)?.id || createPipelineRun(db, sid, p?.project || root, p?.pipeline_type || pt);
|
|
161
191
|
return resp({
|
|
162
192
|
session_id: sid, platform: existing.platform,
|
|
163
193
|
gate: p?.current_gate || 'Gate A',
|
|
164
194
|
pipeline_type: p?.pipeline_type || DEFAULT_PIPELINE,
|
|
165
|
-
project: p?.project || root, resumed: false,
|
|
195
|
+
project: p?.project || root, run_id: runId, resumed: false,
|
|
166
196
|
});
|
|
167
197
|
}
|
|
168
198
|
addSession(db, sid, platform || 'unknown', 'member');
|
|
169
199
|
if (!getPipeline(db, sid)) initPipeline(db, sid, root, pt);
|
|
170
200
|
const p = getPipeline(db, sid);
|
|
201
|
+
const runId = createPipelineRun(db, sid, p?.project || root, p?.pipeline_type || pt);
|
|
171
202
|
return resp({
|
|
172
203
|
session_id: sid, platform: platform || 'unknown',
|
|
173
204
|
gate: p?.current_gate || 'Gate A',
|
|
174
|
-
pipeline_type: pt, project: p?.project || root,
|
|
205
|
+
pipeline_type: pt, project: p?.project || root, run_id: runId,
|
|
175
206
|
message: '\u{1F195} 新会话已初始化,独立流水线已就绪。',
|
|
176
207
|
resumed: !!resume_session_id,
|
|
177
208
|
});
|
|
@@ -180,9 +211,17 @@ function registerMcpTools(server, db, root) {
|
|
|
180
211
|
server.tool('session_heartbeat', '心跳保活。', {},
|
|
181
212
|
async (_args, extra) => {
|
|
182
213
|
const sid = extra?.sessionId;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
214
|
+
// stdio 模式下 extra?.sessionId 可能为空,查找最近活跃会话
|
|
215
|
+
if (sid && getSession(db, sid)) {
|
|
216
|
+
heartbeatSession(db, sid);
|
|
217
|
+
return resp({ ok: true, session_id: sid });
|
|
218
|
+
}
|
|
219
|
+
// 回退:更新所有 active 会话的心跳
|
|
220
|
+
const activeSessions = getSessions(db, 'active');
|
|
221
|
+
for (const s of activeSessions) {
|
|
222
|
+
heartbeatSession(db, s.id);
|
|
223
|
+
}
|
|
224
|
+
return resp({ ok: true, heartbeat_count: activeSessions.length });
|
|
186
225
|
});
|
|
187
226
|
|
|
188
227
|
server.tool('session_list', '列出所有活跃会话。', {}, async () => {
|
|
@@ -210,19 +249,24 @@ function registerMcpTools(server, db, root) {
|
|
|
210
249
|
server.tool('pipeline_init', '【会话隔离】初始化当前会话流水线。',
|
|
211
250
|
{ project_name: z.string().optional(), pipeline_type: z.string().optional() },
|
|
212
251
|
async ({ project_name, pipeline_type }, extra) => {
|
|
213
|
-
const sid = extra?.sessionId ||
|
|
252
|
+
const sid = extra?.sessionId || _lastSessionId;
|
|
253
|
+
if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
|
|
214
254
|
const pt = pipeline_type || DEFAULT_PIPELINE;
|
|
255
|
+
// Session Model B: 创建新 run,同步更新 pipeline 快照
|
|
256
|
+
const runId = createPipelineRun(db, sid, project_name || root, pt);
|
|
215
257
|
initPipeline(db, sid, project_name || root, pt);
|
|
216
258
|
return resp({
|
|
217
|
-
ok: true, session_id: sid, pipeline_type: pt,
|
|
218
|
-
message: '
|
|
259
|
+
ok: true, session_id: sid, run_id: runId, pipeline_type: pt,
|
|
260
|
+
message: 'New pipeline run created. Next: Gate A',
|
|
219
261
|
state: getPipeline(db, sid),
|
|
220
262
|
});
|
|
221
263
|
});
|
|
222
264
|
|
|
223
|
-
server.tool('pipeline_status', '【会话隔离】当前会话流水线状态。',
|
|
224
|
-
|
|
225
|
-
|
|
265
|
+
server.tool('pipeline_status', '【会话隔离】当前会话流水线状态。',
|
|
266
|
+
{ run_id: z.string().optional() },
|
|
267
|
+
async ({ run_id }, extra) => {
|
|
268
|
+
const sid = extra?.sessionId || _lastSessionId;
|
|
269
|
+
if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
|
|
226
270
|
const p = getPipeline(db, sid);
|
|
227
271
|
const pt = p?.pipeline_type || DEFAULT_PIPELINE;
|
|
228
272
|
const gateList = getPipelineGates(pt);
|
|
@@ -236,12 +280,14 @@ function registerMcpTools(server, db, root) {
|
|
|
236
280
|
};
|
|
237
281
|
});
|
|
238
282
|
const current = gates.find(g => !g.passed)?.gate || 'Complete';
|
|
283
|
+
const runId = run_id || getActiveRun(db, sid)?.id;
|
|
239
284
|
return resp({
|
|
240
285
|
session_id: sid, project: root, pipeline_type: pt,
|
|
241
286
|
pipeline_name: getPipelineName(pt),
|
|
242
287
|
current_gate: current,
|
|
243
288
|
completed: gates.filter(g => g.passed).map(g => g.gate),
|
|
244
289
|
gates,
|
|
290
|
+
run_id: runId,
|
|
245
291
|
all_sessions: getSessions(db).map(s => ({
|
|
246
292
|
id: s.id, gate: getPipeline(db, s.id)?.current_gate || '?',
|
|
247
293
|
})),
|
|
@@ -250,27 +296,31 @@ function registerMcpTools(server, db, root) {
|
|
|
250
296
|
});
|
|
251
297
|
|
|
252
298
|
server.tool('gate_enforce', '【会话隔离·硬约束】验证Gate条件。',
|
|
253
|
-
{ gate: z.string().optional() },
|
|
254
|
-
async ({ gate }, extra) => {
|
|
255
|
-
const sid = extra?.sessionId ||
|
|
299
|
+
{ gate: z.string().optional(), run_id: z.string().optional() },
|
|
300
|
+
async ({ gate, run_id }, extra) => {
|
|
301
|
+
const sid = extra?.sessionId || _lastSessionId;
|
|
302
|
+
if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
|
|
303
|
+
const runId = run_id || getActiveRun(db, sid)?.id;
|
|
256
304
|
const gateList = sessionGates(db, sid);
|
|
257
305
|
const target = gate || getPipeline(db, sid)?.current_gate || gateList[0];
|
|
258
306
|
const artifacts = findGateArtifacts(join(root, 'docs'), target);
|
|
259
307
|
const checkpoints = getCheckpoints(db, target, sid);
|
|
260
308
|
const allowed = artifacts.length > 0 || checkpoints.length > 0;
|
|
261
309
|
return resp(allowed
|
|
262
|
-
? { gate: target, allowed: true, session_id: sid, message: `${target} — proceed.` }
|
|
310
|
+
? { gate: target, allowed: true, session_id: sid, run_id: runId, message: `${target} — proceed.` }
|
|
263
311
|
: {
|
|
264
|
-
gate: target, allowed: false, session_id: sid,
|
|
312
|
+
gate: target, allowed: false, session_id: sid, run_id: runId,
|
|
265
313
|
blocked_reasons: [artifacts.length ? '' : `No artifacts in docs/${GATE_DIRS[target] || '?'}/`].filter(Boolean),
|
|
266
314
|
action_required: GATE_CHECKS[target]?.check || '',
|
|
267
315
|
});
|
|
268
316
|
});
|
|
269
317
|
|
|
270
318
|
server.tool('advance_gate', '【会话隔离·硬约束】推进Gate。',
|
|
271
|
-
{ gate: z.string() },
|
|
272
|
-
async ({ gate }, extra) => {
|
|
273
|
-
const sid = extra?.sessionId ||
|
|
319
|
+
{ gate: z.string(), run_id: z.string().optional() },
|
|
320
|
+
async ({ gate, run_id }, extra) => {
|
|
321
|
+
const sid = extra?.sessionId || _lastSessionId;
|
|
322
|
+
if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
|
|
323
|
+
const runId = run_id || getActiveRun(db, sid)?.id;
|
|
274
324
|
const p = getPipeline(db, sid);
|
|
275
325
|
const gateList = sessionGates(db, sid);
|
|
276
326
|
const cur = p?.current_gate || gateList[0];
|
|
@@ -283,8 +333,10 @@ function registerMcpTools(server, db, root) {
|
|
|
283
333
|
if (artifacts.length === 0 && cps.length === 0) return resp({ allowed: false, error: `${cur} conditions NOT met.` });
|
|
284
334
|
addCheckpoint(db, cur, gate, sid);
|
|
285
335
|
updatePipelineGate(db, sid, gate);
|
|
336
|
+
// Session Model B: 同步更新 pipeline_runs 中的 current_gate
|
|
337
|
+
if (runId) updateRunGate(db, runId, gate);
|
|
286
338
|
return resp({
|
|
287
|
-
allowed: true, session_id: sid, previous_gate: cur, current_gate: gate,
|
|
339
|
+
allowed: true, session_id: sid, run_id: runId, previous_gate: cur, current_gate: gate,
|
|
288
340
|
next: gateList[ti + 1] || 'Complete',
|
|
289
341
|
message: gateList[ti + 1] ? `Next: ${gateList[ti + 1]}` : 'Complete!',
|
|
290
342
|
});
|
|
@@ -292,9 +344,11 @@ function registerMcpTools(server, db, root) {
|
|
|
292
344
|
|
|
293
345
|
server.tool('gate_jump',
|
|
294
346
|
'【lite模式·入口跳转】跳过无关Gate直接进入目标Gate。仅当pipeline_type为lite时可用。',
|
|
295
|
-
{ gate: z.string().describe('目标Gate,如 Gate C / Gate D / Gate E') },
|
|
296
|
-
async ({ gate }, extra) => {
|
|
297
|
-
const sid = extra?.sessionId ||
|
|
347
|
+
{ gate: z.string().describe('目标Gate,如 Gate C / Gate D / Gate E'), run_id: z.string().optional() },
|
|
348
|
+
async ({ gate, run_id }, extra) => {
|
|
349
|
+
const sid = extra?.sessionId || _lastSessionId;
|
|
350
|
+
if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
|
|
351
|
+
const runId = run_id || getActiveRun(db, sid)?.id;
|
|
298
352
|
const p = getPipeline(db, sid);
|
|
299
353
|
const pt = p?.pipeline_type || DEFAULT_PIPELINE;
|
|
300
354
|
const def = PIPELINE_DEFS[pt];
|
|
@@ -303,15 +357,19 @@ function registerMcpTools(server, db, root) {
|
|
|
303
357
|
const ti = gateList.indexOf(gate);
|
|
304
358
|
if (ti === -1) return resp({ allowed: false, error: `未知 Gate: ${gate}。有效: ${gateList.join(', ')}` });
|
|
305
359
|
updatePipelineGate(db, sid, gate);
|
|
360
|
+
if (runId) updateRunGate(db, runId, gate);
|
|
306
361
|
return resp({
|
|
307
|
-
allowed: true, session_id: sid, pipeline_type: pt, entry_gate: gate,
|
|
362
|
+
allowed: true, session_id: sid, run_id: runId, pipeline_type: pt, entry_gate: gate,
|
|
308
363
|
message: `已跳转至 ${gate},跳过了 ${gateList.slice(0, ti).join(', ')}。剩余: ${gateList.slice(ti).join(' → ')}`,
|
|
309
364
|
});
|
|
310
365
|
});
|
|
311
366
|
|
|
312
|
-
server.tool('report_status', '【会话隔离】流水线完整报告。',
|
|
313
|
-
|
|
314
|
-
|
|
367
|
+
server.tool('report_status', '【会话隔离】流水线完整报告。',
|
|
368
|
+
{ run_id: z.string().optional() },
|
|
369
|
+
async ({ run_id }, extra) => {
|
|
370
|
+
const sid = extra?.sessionId || _lastSessionId;
|
|
371
|
+
if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
|
|
372
|
+
const runId = run_id || getActiveRun(db, sid)?.id;
|
|
315
373
|
const gateList = sessionGates(db, sid);
|
|
316
374
|
const gates = gateList.map(g => ({
|
|
317
375
|
gate: g, passed: getCheckpoints(db, g, sid).length > 0,
|
|
@@ -319,7 +377,7 @@ function registerMcpTools(server, db, root) {
|
|
|
319
377
|
}));
|
|
320
378
|
const completed = gates.filter(g => g.passed).length;
|
|
321
379
|
return resp({
|
|
322
|
-
session_id: sid, project: root,
|
|
380
|
+
session_id: sid, project: root, run_id: runId,
|
|
323
381
|
pipeline_type: getPipeline(db, sid)?.pipeline_type || DEFAULT_PIPELINE,
|
|
324
382
|
progress: `${completed}/${gateList.length}`,
|
|
325
383
|
current: gates.find(g => !g.passed)?.gate || 'Complete',
|
|
@@ -334,17 +392,20 @@ function registerMcpTools(server, db, root) {
|
|
|
334
392
|
'spawn_impl', 'spawn_test', 'lint', 'build', 'preview',
|
|
335
393
|
'review', 'audit', 'deploy', 'fix',
|
|
336
394
|
]).describe('要执行的操作类型'),
|
|
395
|
+
run_id: z.string().optional(),
|
|
337
396
|
},
|
|
338
|
-
async ({ operation }, extra) => {
|
|
339
|
-
const sid = extra?.sessionId ||
|
|
397
|
+
async ({ operation, run_id }, extra) => {
|
|
398
|
+
const sid = extra?.sessionId || _lastSessionId;
|
|
399
|
+
if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
|
|
400
|
+
const runId = run_id || getActiveRun(db, sid)?.id;
|
|
340
401
|
const p = getPipeline(db, sid);
|
|
341
402
|
const gateList = sessionGates(db, sid);
|
|
342
403
|
const cur = p?.current_gate || gateList[0];
|
|
343
404
|
const ops = getGateOperations(cur);
|
|
344
405
|
const allowed = ops.allow.includes(operation);
|
|
345
|
-
if (allowed) return resp({ allowed: true, gate: cur, operation, session_id: sid, message: `${operation} 在 ${cur} 允许执行` });
|
|
406
|
+
if (allowed) return resp({ allowed: true, gate: cur, operation, session_id: sid, run_id: runId, message: `${operation} 在 ${cur} 允许执行` });
|
|
346
407
|
return resp({
|
|
347
|
-
allowed: false, gate: cur, operation, session_id: sid,
|
|
408
|
+
allowed: false, gate: cur, operation, session_id: sid, run_id: runId,
|
|
348
409
|
blocked_reasons: [
|
|
349
410
|
`操作 "${operation}" 在 ${cur} 不被允许`,
|
|
350
411
|
`允许的操作: ${ops.allow.join(', ')}`,
|
|
@@ -357,9 +418,11 @@ function registerMcpTools(server, db, root) {
|
|
|
357
418
|
|
|
358
419
|
server.tool('pipeline_guide',
|
|
359
420
|
'【硬约束·流程指引】返回当前Gate的完整上下文:允许/禁止的操作、可生成的Agent类型、下一步行动指南。在不确定下一步做什么时调用。',
|
|
360
|
-
{},
|
|
361
|
-
async (
|
|
362
|
-
const sid = extra?.sessionId ||
|
|
421
|
+
{ run_id: z.string().optional() },
|
|
422
|
+
async ({ run_id }, extra) => {
|
|
423
|
+
const sid = extra?.sessionId || _lastSessionId;
|
|
424
|
+
if (!sid) return resp({ error: 'session_id required. Call session_join first.' });
|
|
425
|
+
const runId = run_id || getActiveRun(db, sid)?.id;
|
|
363
426
|
const p = getPipeline(db, sid);
|
|
364
427
|
const gateList = sessionGates(db, sid);
|
|
365
428
|
const cur = p?.current_gate || gateList[0];
|
|
@@ -379,6 +442,7 @@ function registerMcpTools(server, db, root) {
|
|
|
379
442
|
session_id: sid, gate: cur, gate_index: ci + 1, total_gates: gateList.length,
|
|
380
443
|
pipeline_type: p?.pipeline_type || DEFAULT_PIPELINE,
|
|
381
444
|
pipeline_name: getPipelineName(p?.pipeline_type || DEFAULT_PIPELINE),
|
|
445
|
+
run_id: runId,
|
|
382
446
|
allowed_operations: ops.allow, forbidden_operations: ops.deny,
|
|
383
447
|
agent_spawn: agentGuide[cur] || { can_spawn: [], note: '未知Gate' },
|
|
384
448
|
gate_requirement: GATE_CHECKS[cur]?.check || '',
|
package/src/install.js
CHANGED
|
@@ -55,12 +55,11 @@ export async function install({ platform, target, pkgRoot, platforms, force, glo
|
|
|
55
55
|
if (!destExists) mkdirSync(destRoot, { recursive: true });
|
|
56
56
|
|
|
57
57
|
let totalFiles = 0, totalSkipped = 0;
|
|
58
|
-
const hashRoot = isGlobal ? globalTarget(platform) : destRoot;
|
|
59
58
|
for (const bucket of INSTALL_BUCKETS) {
|
|
60
59
|
const srcDir = join(srcRoot, bucket);
|
|
61
60
|
const destDir = join(destRoot, bucket);
|
|
62
61
|
if (!existsSync(srcDir)) continue;
|
|
63
|
-
const stats = mergeDir(srcDir, destDir
|
|
62
|
+
const stats = mergeDir(srcDir, destDir);
|
|
64
63
|
totalFiles += stats.files;
|
|
65
64
|
totalSkipped += stats.skipped;
|
|
66
65
|
const tag = existsSync(destDir) && stats.files > 0 ? '~' : '+';
|
|
@@ -214,16 +213,16 @@ function fileHash(filePath) {
|
|
|
214
213
|
catch { return null; }
|
|
215
214
|
}
|
|
216
215
|
|
|
217
|
-
/** 加载/保存文件 hash
|
|
218
|
-
function loadHashes(
|
|
219
|
-
const f =
|
|
216
|
+
/** 加载/保存文件 hash 记录,统一存储在 ~/.jarvis/file-hashes.json */
|
|
217
|
+
function loadHashes() {
|
|
218
|
+
const f = resolve(homedir(), '.jarvis', 'file-hashes.json');
|
|
220
219
|
try { return existsSync(f) ? JSON.parse(readFileSync(f, 'utf-8')) : {}; }
|
|
221
220
|
catch { return {}; }
|
|
222
221
|
}
|
|
223
|
-
function saveHashes(
|
|
224
|
-
const dir =
|
|
222
|
+
function saveHashes(hashes) {
|
|
223
|
+
const dir = resolve(homedir(), '.jarvis');
|
|
225
224
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
226
|
-
writeFileSync(
|
|
225
|
+
writeFileSync(resolve(dir, 'file-hashes.json'), JSON.stringify(hashes, null, 2));
|
|
227
226
|
}
|
|
228
227
|
|
|
229
228
|
/**
|
|
@@ -233,30 +232,29 @@ function saveHashes(root, hashes) {
|
|
|
233
232
|
* - 目标 hash == 旧源 hash → 用户未修改,安全覆盖
|
|
234
233
|
* - 目标 hash != 旧源 hash → 用户已修改,跳过
|
|
235
234
|
*/
|
|
236
|
-
function mergeDir(src, dest
|
|
235
|
+
function mergeDir(src, dest) {
|
|
237
236
|
let files = 0, dirs = 0, skipped = 0;
|
|
238
237
|
if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
|
|
239
238
|
|
|
240
|
-
const hashes =
|
|
239
|
+
const hashes = loadHashes();
|
|
241
240
|
|
|
242
241
|
for (const entry of readdirSync(src)) {
|
|
243
242
|
if (SKIP_FILES.has(entry)) continue;
|
|
244
243
|
if (entry.startsWith('.') || entry === 'node_modules') continue;
|
|
245
244
|
const sp = join(src, entry), dp = join(dest, entry);
|
|
246
245
|
if (statSync(sp).isDirectory()) {
|
|
247
|
-
const d = mergeDir(sp, dp
|
|
246
|
+
const d = mergeDir(sp, dp);
|
|
248
247
|
files += d.files; dirs += d.dirs + 1; skipped += d.skipped;
|
|
249
248
|
} else {
|
|
250
|
-
const relPath = dp.replace(dest, '').replace(/\\/g, '/');
|
|
251
249
|
const newHash = fileHash(sp);
|
|
252
250
|
|
|
253
251
|
if (!existsSync(dp)) {
|
|
254
252
|
// 新文件
|
|
255
253
|
copyFileSync(sp, dp);
|
|
256
|
-
hashes[
|
|
254
|
+
hashes[dp] = newHash;
|
|
257
255
|
files++;
|
|
258
256
|
} else {
|
|
259
|
-
const oldHash = hashes[
|
|
257
|
+
const oldHash = hashes[dp];
|
|
260
258
|
const destHash = fileHash(dp);
|
|
261
259
|
|
|
262
260
|
if (newHash === oldHash) {
|
|
@@ -265,7 +263,7 @@ function mergeDir(src, dest, root) {
|
|
|
265
263
|
} else if (!oldHash || destHash === oldHash) {
|
|
266
264
|
// 新安装或用户未修改 → 安全覆盖
|
|
267
265
|
copyFileSync(sp, dp);
|
|
268
|
-
hashes[
|
|
266
|
+
hashes[dp] = newHash;
|
|
269
267
|
files++;
|
|
270
268
|
} else {
|
|
271
269
|
// 用户已修改目标文件 → 保留
|
|
@@ -275,7 +273,7 @@ function mergeDir(src, dest, root) {
|
|
|
275
273
|
}
|
|
276
274
|
}
|
|
277
275
|
|
|
278
|
-
|
|
276
|
+
saveHashes(hashes);
|
|
279
277
|
return { files, dirs, skipped };
|
|
280
278
|
}
|
|
281
279
|
|
package/src/web/routes.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { readdirSync, existsSync } from 'node:fs';
|
|
4
4
|
import { streamSSE } from 'hono/streaming';
|
|
5
|
-
import { getPipeline, getCheckpoints, addCheckpoint, updatePipelineGate, getSessions, getAllPipelines, getAgentConfig, setAgentModel, resumeSession, markStaleSessions } from '../engine/db.js';
|
|
5
|
+
import { getPipeline, getCheckpoints, addCheckpoint, updatePipelineGate, getSessions, getAllPipelines, getAgentConfig, setAgentModel, resumeSession, markStaleSessions, getSessionRuns } from '../engine/db.js';
|
|
6
6
|
import { GATES, GATE_CHECKS, GATE_DIRS, AGENT_LIST, AVAILABLE_MODELS, findGateArtifacts, formatGateDisplay, getPipelineGates, getPipelineName, DEFAULT_PIPELINE } from '../engine/gates.js';
|
|
7
7
|
import { getAgentList, getPlatformModels, getCategories, getAgentsByPlatform, getPlatforms } from '../engine/agent-registry.js';
|
|
8
8
|
import { syncAgentFile } from '../engine/agent-fs.js';
|
|
@@ -58,7 +58,7 @@ export function setupApiRoutes(app, db, root) {
|
|
|
58
58
|
|
|
59
59
|
// 引擎状态 + MCP 平台接入信息
|
|
60
60
|
app.get('/api/status', (c) => {
|
|
61
|
-
markStaleSessions(db,
|
|
61
|
+
markStaleSessions(db, 1_800_000); // 30min 超时,与 server.js SESSION_TIMEOUT 一致
|
|
62
62
|
const sessions = getSessions(db, 'active');
|
|
63
63
|
const connectedPlatforms = {};
|
|
64
64
|
for (const p of ['claude', 'opencode', 'codex']) {
|
|
@@ -109,7 +109,8 @@ export function setupApiRoutes(app, db, root) {
|
|
|
109
109
|
app.get('/api/gate/:gate/enforce', (c) => {
|
|
110
110
|
const gate = c.req.param('gate').replace(/_/g, ' ');
|
|
111
111
|
const artifacts = findGateArtifacts(getDocsDir(root), gate);
|
|
112
|
-
const sid = c.req.query('session_id')
|
|
112
|
+
const sid = c.req.query('session_id');
|
|
113
|
+
if (!sid) return c.json({ error: 'session_id query parameter required' }, 400);
|
|
113
114
|
const checkpoints = getCheckpoints(db, gate, sid);
|
|
114
115
|
const allowed = artifacts.length > 0 || checkpoints.length > 0;
|
|
115
116
|
return c.json({
|
|
@@ -124,7 +125,8 @@ export function setupApiRoutes(app, db, root) {
|
|
|
124
125
|
|
|
125
126
|
app.post('/api/gate/advance', async (c) => {
|
|
126
127
|
const body = await c.req.json();
|
|
127
|
-
const sid = body.session_id
|
|
128
|
+
const sid = body.session_id;
|
|
129
|
+
if (!sid) return c.json({ error: 'session_id required in request body' }, 400);
|
|
128
130
|
const pstate = getPipeline(db, sid);
|
|
129
131
|
const pt = pstate?.pipeline_type || DEFAULT_PIPELINE;
|
|
130
132
|
const gateList = getPipelineGates(pt);
|
|
@@ -153,7 +155,7 @@ export function setupApiRoutes(app, db, root) {
|
|
|
153
155
|
});
|
|
154
156
|
|
|
155
157
|
app.get('/api/sessions', (c) => {
|
|
156
|
-
markStaleSessions(db,
|
|
158
|
+
markStaleSessions(db, 1_800_000); // 30min 超时,与 server.js SESSION_TIMEOUT 一致
|
|
157
159
|
const sessions = getSessions(db).map(s => {
|
|
158
160
|
const p = getPipeline(db, s.id);
|
|
159
161
|
return {
|
|
@@ -179,6 +181,14 @@ export function setupApiRoutes(app, db, root) {
|
|
|
179
181
|
return c.json({ ok: true, session_id: sid, status: 'active', gate: p?.current_gate || '?' });
|
|
180
182
|
});
|
|
181
183
|
|
|
184
|
+
// Session Model B: 查询 session 的所有 pipeline runs
|
|
185
|
+
app.get('/api/pipeline-runs', (c) => {
|
|
186
|
+
const sessionId = c.req.query('session_id');
|
|
187
|
+
if (!sessionId) return c.json({ error: 'session_id query parameter required' }, 400);
|
|
188
|
+
const runs = getSessionRuns(db, sessionId);
|
|
189
|
+
return c.json({ runs, count: runs.length, session_id: sessionId });
|
|
190
|
+
});
|
|
191
|
+
|
|
182
192
|
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
183
193
|
|
|
184
194
|
app.get('/api/agents', (c) => {
|
|
@@ -144,6 +144,26 @@
|
|
|
144
144
|
</div>
|
|
145
145
|
</div>
|
|
146
146
|
|
|
147
|
+
<!-- 历史 Runs 面板 -->
|
|
148
|
+
<div class="bg-white rounded-xl border border-slate-200 shadow-sm mb-8 overflow-hidden">
|
|
149
|
+
<div class="flex items-center justify-between px-6 py-4 cursor-pointer hover:bg-slate-50 transition-colors" onclick="toggleRunsPanel()">
|
|
150
|
+
<div class="flex items-center gap-3">
|
|
151
|
+
<i data-lucide="history" class="w-4 h-4 text-indigo-500"></i>
|
|
152
|
+
<h3 class="text-sm font-semibold text-slate-700">历史运行记录</h3>
|
|
153
|
+
<span class="text-[11px] text-slate-400 font-mono" id="runsCount">0 次运行</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="flex items-center gap-2">
|
|
156
|
+
<span class="text-[10px] text-slate-400" id="runsToggleLabel">展开</span>
|
|
157
|
+
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400 transition-transform" id="runsToggleIcon"></i>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
<div id="runsPanel" class="hidden border-t border-slate-100">
|
|
161
|
+
<div id="runsList" class="divide-y divide-slate-100 max-h-96 overflow-y-auto">
|
|
162
|
+
<p class="text-xs text-slate-400 text-center py-8 font-mono">请选择左侧会话查看运行记录</p>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
147
167
|
<!-- Gate 步骤列表 -->
|
|
148
168
|
<div class="bg-white rounded-xl p-6 border border-slate-200 shadow-sm">
|
|
149
169
|
<div class="flex items-center justify-between mb-1">
|
|
@@ -287,6 +307,9 @@ async function refresh() {
|
|
|
287
307
|
selectedSession = sessionData.session_id;
|
|
288
308
|
}
|
|
289
309
|
|
|
310
|
+
// 加载选中会话的历史 Runs
|
|
311
|
+
fetchPipelineRuns(selectedSession);
|
|
312
|
+
|
|
290
313
|
const activeCount = allSessions.filter(s => s.status === 'active').length;
|
|
291
314
|
document.getElementById('pipelineProject').textContent =
|
|
292
315
|
'会话隔离模式 · ' + (allSessions.length || 0) + ' 个会话(' + activeCount + ' 活跃)· 每 5 秒自动刷新';
|
|
@@ -407,7 +430,7 @@ function renderSessions() {
|
|
|
407
430
|
document.getElementById('sessionsList').innerHTML = filtered.map(s => {
|
|
408
431
|
const isActive = s.id === selectedSession;
|
|
409
432
|
const isInactive = s.status === 'inactive';
|
|
410
|
-
const isOnline = !isInactive && (Date.now() - s.heartbeat) <
|
|
433
|
+
const isOnline = !isInactive && (Date.now() - s.heartbeat) < 600000; // 10分钟
|
|
411
434
|
|
|
412
435
|
let statusColor, statusLabel;
|
|
413
436
|
if (isInactive) { statusColor = 'bg-amber-500'; statusLabel = '休眠'; }
|
|
@@ -448,8 +471,8 @@ async function resumeSession(sid) {
|
|
|
448
471
|
}
|
|
449
472
|
|
|
450
473
|
async function checkGate(gate) {
|
|
451
|
-
|
|
452
|
-
const d = await fetchAPI('/api/gate/' + gate.replace(/ /g, '_') + '/enforce' +
|
|
474
|
+
if (!selectedSession) { toast('请先选择一个会话', false); return; }
|
|
475
|
+
const d = await fetchAPI('/api/gate/' + gate.replace(/ /g, '_') + '/enforce?session_id=' + selectedSession);
|
|
453
476
|
if (!d) return;
|
|
454
477
|
const info = GATE_INFO[gate] || {};
|
|
455
478
|
toast(d.allowed
|
|
@@ -459,6 +482,7 @@ async function checkGate(gate) {
|
|
|
459
482
|
}
|
|
460
483
|
|
|
461
484
|
async function advanceGate(gate) {
|
|
485
|
+
if (!selectedSession) { toast('请先选择一个会话', false); return; }
|
|
462
486
|
const resp = await fetch('/api/gate/advance', {
|
|
463
487
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
464
488
|
body: JSON.stringify({ gate, session_id: selectedSession })
|
|
@@ -486,6 +510,110 @@ function toast(msg, isSuccess) {
|
|
|
486
510
|
function showHelp() { document.getElementById('helpModal').classList.remove('hidden'); document.getElementById('helpModal').classList.add('flex'); }
|
|
487
511
|
function closeHelp() { document.getElementById('helpModal').classList.add('hidden'); document.getElementById('helpModal').classList.remove('flex'); }
|
|
488
512
|
|
|
513
|
+
/**
|
|
514
|
+
* 展开/收起历史 Runs 面板
|
|
515
|
+
*/
|
|
516
|
+
function toggleRunsPanel() {
|
|
517
|
+
const panel = document.getElementById('runsPanel');
|
|
518
|
+
const icon = document.getElementById('runsToggleIcon');
|
|
519
|
+
const label = document.getElementById('runsToggleLabel');
|
|
520
|
+
if (panel.classList.contains('hidden')) {
|
|
521
|
+
panel.classList.remove('hidden');
|
|
522
|
+
icon.style.transform = 'rotate(180deg)';
|
|
523
|
+
label.textContent = '收起';
|
|
524
|
+
} else {
|
|
525
|
+
panel.classList.add('hidden');
|
|
526
|
+
icon.style.transform = 'rotate(0deg)';
|
|
527
|
+
label.textContent = '展开';
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* 获取指定会话的 Pipeline Runs 历史
|
|
533
|
+
* @param {string|null} sessionId - 会话 ID
|
|
534
|
+
*/
|
|
535
|
+
async function fetchPipelineRuns(sessionId) {
|
|
536
|
+
if (!sessionId) {
|
|
537
|
+
document.getElementById('runsList').innerHTML = '<p class="text-xs text-slate-400 text-center py-8 font-mono">请选择左侧会话查看运行记录</p>';
|
|
538
|
+
document.getElementById('runsCount').textContent = '0 次运行';
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const resp = await fetch('/api/pipeline-runs?session_id=' + encodeURIComponent(sessionId));
|
|
543
|
+
if (!resp.ok) {
|
|
544
|
+
document.getElementById('runsList').innerHTML = '<p class="text-xs text-red-400 text-center py-8 font-mono">加载失败(HTTP ' + resp.status + '),请稍后重试</p>';
|
|
545
|
+
document.getElementById('runsCount').textContent = '---';
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const data = await resp.json();
|
|
549
|
+
if (!data || !Array.isArray(data.runs)) {
|
|
550
|
+
document.getElementById('runsList').innerHTML = '<p class="text-xs text-red-400 text-center py-8 font-mono">数据格式异常,请刷新页面重试</p>';
|
|
551
|
+
document.getElementById('runsCount').textContent = '---';
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
renderRunsHistory(data);
|
|
555
|
+
} catch (_) {
|
|
556
|
+
// 网络错误或 JSON 解析失败
|
|
557
|
+
document.getElementById('runsList').innerHTML = '<p class="text-xs text-red-400 text-center py-8 font-mono">网络错误,无法加载运行记录</p>';
|
|
558
|
+
document.getElementById('runsCount').textContent = '---';
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* 渲染历史 Runs 列表
|
|
564
|
+
* @param {{ runs: Array, count: number }} runsData - API 返回的 runs 数据
|
|
565
|
+
*/
|
|
566
|
+
function renderRunsHistory(runsData) {
|
|
567
|
+
const runs = runsData.runs;
|
|
568
|
+
document.getElementById('runsCount').textContent = runs.length + ' 次运行';
|
|
569
|
+
|
|
570
|
+
if (runs.length === 0) {
|
|
571
|
+
document.getElementById('runsList').innerHTML = '<p class="text-xs text-slate-400 text-center py-8 font-mono">暂无运行记录</p>';
|
|
572
|
+
lucide.createIcons();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** 状态 → 视觉映射表 */
|
|
577
|
+
const STATUS_MAP = {
|
|
578
|
+
active: { dotColor: 'bg-emerald-500', badgeClass: 'bg-emerald-50 text-emerald-700', label: '运行中' },
|
|
579
|
+
completed: { dotColor: 'bg-slate-400', badgeClass: 'bg-slate-100 text-slate-600', label: '已完成' },
|
|
580
|
+
aborted: { dotColor: 'bg-red-500', badgeClass: 'bg-red-50 text-red-700', label: '已终止' },
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
document.getElementById('runsList').innerHTML = runs.map(function(run) {
|
|
584
|
+
const st = STATUS_MAP[run.status] || STATUS_MAP.active;
|
|
585
|
+
const isActive = run.status === 'active';
|
|
586
|
+
const shortRunId = (run.id || '').slice(0, 16) + '...';
|
|
587
|
+
const ptName = PIPELINE_NAMES[run.pipeline_type] || run.pipeline_type || '未知';
|
|
588
|
+
let dateText = '---';
|
|
589
|
+
if (run.started_at) {
|
|
590
|
+
try {
|
|
591
|
+
const d = new Date(run.started_at.replace(' ', 'T'));
|
|
592
|
+
dateText = d.toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
|
|
593
|
+
} catch (_) {
|
|
594
|
+
// 日期解析失败时使用原始字符串,后端 datetime('now') 格式安全
|
|
595
|
+
dateText = run.started_at.replace(/[<>&"']/g, '');
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return (
|
|
600
|
+
'<div class="flex items-center gap-4 px-6 py-3 hover:bg-slate-50/50 transition-colors ' +
|
|
601
|
+
(isActive ? 'bg-indigo-50/30 border-l-[3px] border-indigo-500' : 'border-l-[3px] border-transparent') + '">' +
|
|
602
|
+
'<div class="flex items-center gap-2 min-w-0">' +
|
|
603
|
+
'<span class="w-2 h-2 rounded-full flex-shrink-0 ' + st.dotColor + '"></span>' +
|
|
604
|
+
'<span class="text-xs font-mono text-slate-600 truncate" title="' + run.id + '">' + shortRunId + '</span>' +
|
|
605
|
+
'</div>' +
|
|
606
|
+
'<span class="text-[10px] font-medium bg-indigo-50 text-indigo-600 px-2 py-0.5 rounded flex-shrink-0">' + ptName + '</span>' +
|
|
607
|
+
'<span class="text-[11px] text-slate-500 flex-shrink-0 font-mono">' + (run.current_gate || '---') + '</span>' +
|
|
608
|
+
'<span class="text-[10px] text-slate-400 flex-shrink-0 ml-auto font-mono">' + dateText + '</span>' +
|
|
609
|
+
'<span class="text-[10px] font-medium px-2 py-0.5 rounded-full flex-shrink-0 ' + st.badgeClass + '">' + st.label + '</span>' +
|
|
610
|
+
'</div>'
|
|
611
|
+
);
|
|
612
|
+
}).join('');
|
|
613
|
+
|
|
614
|
+
lucide.createIcons();
|
|
615
|
+
}
|
|
616
|
+
|
|
489
617
|
// MCP 平台接入状态轮询
|
|
490
618
|
async function loadMcpStatus(){
|
|
491
619
|
try{
|