jarvis-agent-factory 3.0.0 → 3.2.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/dashboard.html +26 -4
- package/src/engine/server.js +196 -43
package/package.json
CHANGED
|
@@ -53,7 +53,10 @@ footer{text-align:center;padding:24px;color:var(--muted);font-size:12px}
|
|
|
53
53
|
<body>
|
|
54
54
|
<header>
|
|
55
55
|
<div><h1>🧠 Jarvis Engine</h1></div>
|
|
56
|
-
<div
|
|
56
|
+
<div style=display:flex;gap:12px;align-items:center>
|
|
57
|
+
<button class="btn primary" onclick=initPipeline()>🔒 Init Pipeline</button>
|
|
58
|
+
<span class=ver id=version>v---</span>
|
|
59
|
+
</div>
|
|
57
60
|
</header>
|
|
58
61
|
<main>
|
|
59
62
|
<div class=stats>
|
|
@@ -123,11 +126,15 @@ async function refresh() {
|
|
|
123
126
|
async function checkGate(gate) {
|
|
124
127
|
const r = await fetch('/mcp', {
|
|
125
128
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
126
|
-
body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/call',params:{name:'
|
|
129
|
+
body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/call',params:{name:'gate_enforce',arguments:{gate}}})
|
|
127
130
|
});
|
|
128
131
|
const m = await r.json();
|
|
129
132
|
const d = JSON.parse(m.result.content[0].text);
|
|
130
|
-
|
|
133
|
+
if (d.allowed) {
|
|
134
|
+
toast(`✅ ${gate} — Ready. Click Advance →`);
|
|
135
|
+
} else {
|
|
136
|
+
toast(`🚫 ${gate} BLOCKED — ${d.blocked_reasons?.join('; ') || d.action_required}`);
|
|
137
|
+
}
|
|
131
138
|
}
|
|
132
139
|
|
|
133
140
|
async function advanceGate(gate) {
|
|
@@ -137,7 +144,22 @@ async function advanceGate(gate) {
|
|
|
137
144
|
});
|
|
138
145
|
const m = await r.json();
|
|
139
146
|
const d = JSON.parse(m.result.content[0].text);
|
|
140
|
-
|
|
147
|
+
if (d.allowed) {
|
|
148
|
+
toast(`🚀 ${d.message || ('Advanced to ' + d.current_gate)}`);
|
|
149
|
+
refresh();
|
|
150
|
+
} else {
|
|
151
|
+
toast(`🚫 FSM BLOCKED — ${d.error}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function initPipeline() {
|
|
156
|
+
const r = await fetch('/mcp', {
|
|
157
|
+
method:'POST', headers:{'Content-Type':'application/json'},
|
|
158
|
+
body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/call',params:{name:'pipeline_init',arguments:{}}})
|
|
159
|
+
});
|
|
160
|
+
const m = await r.json();
|
|
161
|
+
const d = JSON.parse(m.result.content[0].text);
|
|
162
|
+
toast(d.ok ? '🔒 Pipeline initialized — hard state active' : d.message);
|
|
141
163
|
refresh();
|
|
142
164
|
}
|
|
143
165
|
|
package/src/engine/server.js
CHANGED
|
@@ -38,84 +38,237 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
|
|
|
38
38
|
|
|
39
39
|
const server = new McpServer({ name: 'jarvis-engine', version: readPkgVersion() });
|
|
40
40
|
|
|
41
|
+
// ---- Session Manager (multi-session safety) ----
|
|
42
|
+
const sessions = new Map(); // sessionId → { id, platform, created_at, last_heartbeat, role }
|
|
43
|
+
const SESSION_TIMEOUT = 120_000; // 2 min heartbeat timeout
|
|
44
|
+
let leaderSessionId = null;
|
|
45
|
+
|
|
46
|
+
function cleanupStaleSessions() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
for (const [sid, s] of sessions) {
|
|
49
|
+
if (now - s.last_heartbeat > SESSION_TIMEOUT) {
|
|
50
|
+
sessions.delete(sid);
|
|
51
|
+
if (sid === leaderSessionId) {
|
|
52
|
+
leaderSessionId = null;
|
|
53
|
+
// Elect new leader
|
|
54
|
+
const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
|
|
55
|
+
if (oldest) { leaderSessionId = oldest.id; oldest.role = 'leader'; }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// If no leader but sessions exist, elect oldest
|
|
60
|
+
if (!leaderSessionId && sessions.size > 0) {
|
|
61
|
+
const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
|
|
62
|
+
leaderSessionId = oldest.id;
|
|
63
|
+
oldest.role = 'leader';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requireLeader(sessionId) {
|
|
68
|
+
cleanupStaleSessions();
|
|
69
|
+
const s = sessions.get(sessionId);
|
|
70
|
+
if (!s) return { error: 'Session not registered. Call session_join first.' };
|
|
71
|
+
if (s.role !== 'leader') return { error: `Write lock held by session ${leaderSessionId}. You are observer (read-only).` };
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Heartbeat cleanup
|
|
76
|
+
setInterval(cleanupStaleSessions, 30_000);
|
|
77
|
+
|
|
78
|
+
// ---- Pipeline state machine (hard constraints) ----
|
|
79
|
+
const pipelinePath = join(root, '.jarvis', 'pipeline.json');
|
|
80
|
+
|
|
81
|
+
function readPipeline() {
|
|
82
|
+
if (!existsSync(pipelinePath)) return null;
|
|
83
|
+
try { return JSON.parse(readFileSync(pipelinePath, 'utf-8')); } catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
function writePipeline(state) {
|
|
86
|
+
const dir = join(root, '.jarvis'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
87
|
+
writeFileSync(pipelinePath, JSON.stringify({ ...state, updated_at: new Date().toISOString() }, null, 2));
|
|
88
|
+
}
|
|
89
|
+
|
|
41
90
|
// ==============================
|
|
42
91
|
// TOOLS
|
|
43
92
|
// ==============================
|
|
44
93
|
|
|
45
|
-
//
|
|
94
|
+
// --- Session management ---
|
|
95
|
+
server.tool(
|
|
96
|
+
'session_join',
|
|
97
|
+
'【多会话安全】注册当前会话。第一个注册的会话获得 leader 写锁,后续会话为 observer(只读)。返回 session_id 和角色。',
|
|
98
|
+
{ platform: z.enum(['claude','opencode','codex','other']).optional().describe('平台名称') },
|
|
99
|
+
async ({ platform }, extra) => {
|
|
100
|
+
const sessionId = extra?.sessionId || `s${Date.now()}`;
|
|
101
|
+
cleanupStaleSessions();
|
|
102
|
+
const existing = sessions.get(sessionId);
|
|
103
|
+
if (existing) { existing.last_heartbeat = Date.now(); return { content: [{ type: 'text', text: JSON.stringify({ session_id: sessionId, role: existing.role, leader: leaderSessionId, active_sessions: sessions.size }) }] }; }
|
|
104
|
+
|
|
105
|
+
const role = sessions.size === 0 ? 'leader' : 'observer';
|
|
106
|
+
const s = { id: sessionId, platform: platform || 'unknown', created_at: Date.now(), last_heartbeat: Date.now(), role };
|
|
107
|
+
sessions.set(sessionId, s);
|
|
108
|
+
if (role === 'leader') leaderSessionId = sessionId;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
112
|
+
session_id: sessionId, role, leader: leaderSessionId,
|
|
113
|
+
active_sessions: sessions.size,
|
|
114
|
+
message: role === 'leader' ? '🔑 You are leader — write access granted.' : '👁 You are observer — read-only. Leader holds write lock.',
|
|
115
|
+
}, null, 2) }],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
server.tool('session_heartbeat', '【多会话安全】发送心跳,保持会话活跃。每 60 秒至少发一次,否则会话超时被清理。', {},
|
|
121
|
+
async (_args, extra) => {
|
|
122
|
+
const sid = extra?.sessionId;
|
|
123
|
+
if (!sid || !sessions.has(sid)) return { content: [{ type: 'text', text: JSON.stringify({ error: 'Session not found. Call session_join first.' }) }] };
|
|
124
|
+
sessions.get(sid).last_heartbeat = Date.now();
|
|
125
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, session_id: sid, role: sessions.get(sid).role, leader: leaderSessionId }) }] };
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
server.tool('session_list', '【多会话安全】列出所有活跃会话及其角色。', {},
|
|
130
|
+
async () => {
|
|
131
|
+
cleanupStaleSessions();
|
|
132
|
+
const list = [...sessions.values()].map(s => ({ session_id: s.id, platform: s.platform, role: s.role, leader: s.id === leaderSessionId, last_heartbeat_ago: `${Math.round((Date.now() - s.last_heartbeat) / 1000)}s` }));
|
|
133
|
+
return { content: [{ type: 'text', text: JSON.stringify({ active_sessions: sessions.size, leader_session: leaderSessionId, sessions: list }) }] };
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
server.tool('session_leave', '【多会话安全】主动离开。如果是 leader,锁自动移交给最老的 observer。', {},
|
|
138
|
+
async (_args, extra) => {
|
|
139
|
+
const sid = extra?.sessionId;
|
|
140
|
+
if (!sid || !sessions.has(sid)) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Session not registered.' }) }] };
|
|
141
|
+
const wasLeader = sessions.get(sid).role === 'leader';
|
|
142
|
+
sessions.delete(sid);
|
|
143
|
+
if (wasLeader) {
|
|
144
|
+
leaderSessionId = null;
|
|
145
|
+
const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
|
|
146
|
+
if (oldest) { leaderSessionId = oldest.id; oldest.role = 'leader'; }
|
|
147
|
+
}
|
|
148
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Session left.', lock_transferred: wasLeader && leaderSessionId ? `Lock → ${leaderSessionId}` : 'No active sessions' }) }] };
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// --- Pipeline management ---
|
|
153
|
+
|
|
154
|
+
// Tool: pipeline_init — hard state bootstrap
|
|
155
|
+
server.tool(
|
|
156
|
+
'pipeline_init',
|
|
157
|
+
'【硬约束·需Leader】初始化流水线状态机。只有 leader 会话可调用。observer 会被拒绝。',
|
|
158
|
+
{ project_name: z.string().optional().describe('项目名称(可选)') },
|
|
159
|
+
async ({ project_name }, extra) => {
|
|
160
|
+
const lockErr = requireLeader(extra?.sessionId); if (lockErr) return { content: [{ type: 'text', text: JSON.stringify(lockErr) }] };
|
|
161
|
+
const existing = readPipeline();
|
|
162
|
+
if (existing) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline already initialized', state: existing, session: extra?.sessionId, role: 'leader' }, null, 2) }] };
|
|
163
|
+
const state = { project: project_name || root, current_gate: 'Gate A', started_at: new Date().toISOString(), gates_passed: [], mode: 'strict', initialized_by: extra?.sessionId };
|
|
164
|
+
writePipeline(state);
|
|
165
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline initialized — hard state machine active. Next: Gate A', state, session: extra?.sessionId, role: 'leader' }, null, 2) }] };
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Tool: pipeline_status
|
|
46
170
|
server.tool(
|
|
47
171
|
'pipeline_status',
|
|
48
|
-
'完整流水线状态:当前 Gate、已完成 Gate、产物文件、Gate
|
|
172
|
+
'完整流水线状态:当前 Gate、已完成 Gate、产物文件、Gate 检查点时间戳。同时读取硬状态 pipeline.json。',
|
|
49
173
|
{},
|
|
50
174
|
async () => {
|
|
175
|
+
const pstate = readPipeline();
|
|
51
176
|
const gates = GATES.map(gate => {
|
|
52
177
|
const checkpoints = readCheckpoints(root, gate);
|
|
53
|
-
return {
|
|
54
|
-
gate,
|
|
55
|
-
passed: checkpoints.length > 0,
|
|
56
|
-
checkpoints,
|
|
57
|
-
artifacts: findGateArtifacts(join(root, 'docs'), gate),
|
|
58
|
-
requirement: GATE_CHECKS[gate]?.check || '',
|
|
59
|
-
};
|
|
178
|
+
return { gate, passed: checkpoints.length > 0, checkpoints, artifacts: findGateArtifacts(join(root, 'docs'), gate), requirement: GATE_CHECKS[gate]?.check || '' };
|
|
60
179
|
});
|
|
61
|
-
const current = gates.find(g => !g.passed) ||
|
|
180
|
+
const current = pstate?.current_gate || (gates.find(g => !g.passed)?.gate || 'Gate A');
|
|
181
|
+
const sessionInfo = { active_sessions: sessions.size, leader: leaderSessionId, sessions: [...sessions.values()].map(s => ({ id: s.id, role: s.role, platform: s.platform, alive_s: Math.round((Date.now() - s.last_heartbeat)/1000) })) };
|
|
62
182
|
return {
|
|
63
183
|
content: [{ type: 'text', text: JSON.stringify({
|
|
64
184
|
project: root,
|
|
65
|
-
|
|
185
|
+
mode: pstate?.mode || 'soft',
|
|
186
|
+
current_gate: current,
|
|
66
187
|
completed: gates.filter(g => g.passed).map(g => g.gate),
|
|
67
188
|
gates,
|
|
68
|
-
|
|
189
|
+
sessions: sessionInfo,
|
|
190
|
+
_display: formatGateDisplay(gates, current),
|
|
69
191
|
}, null, 2) }],
|
|
70
192
|
};
|
|
71
193
|
}
|
|
72
194
|
);
|
|
73
195
|
|
|
74
|
-
// Tool:
|
|
196
|
+
// Tool: gate_enforce — HARD constraint
|
|
75
197
|
server.tool(
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
{ gate: z.enum(GATES).describe('要检查的 Gate
|
|
198
|
+
'gate_enforce',
|
|
199
|
+
'【硬约束】验证当前 Gate 的通过条件。返回 allowed=true 才可以 proceed。allowed=false 时必须先完成条件再重试。不可绕过。',
|
|
200
|
+
{ gate: z.enum(GATES).optional().describe('要检查的 Gate(默认当前 Gate)') },
|
|
79
201
|
async ({ gate }) => {
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
202
|
+
const pstate = readPipeline();
|
|
203
|
+
const targetGate = gate || pstate?.current_gate || 'Gate A';
|
|
204
|
+
const artifacts = findGateArtifacts(join(root, 'docs'), targetGate);
|
|
205
|
+
const checkpoints = readCheckpoints(root, targetGate);
|
|
206
|
+
const requirement = GATE_CHECKS[targetGate];
|
|
207
|
+
|
|
208
|
+
// Hard check: must have artifacts and/or checkpoints
|
|
209
|
+
const hasArtifacts = artifacts.length > 0;
|
|
210
|
+
const hasCheckpoint = checkpoints.length > 0;
|
|
211
|
+
const allowed = hasArtifacts || hasCheckpoint;
|
|
212
|
+
|
|
213
|
+
const reasons = [];
|
|
214
|
+
if (!hasArtifacts) reasons.push(`No artifacts found in docs/${GATE_DIRS[targetGate] || '?'}/`);
|
|
215
|
+
if (!hasCheckpoint) reasons.push(`No checkpoint recorded for ${targetGate}`);
|
|
216
|
+
|
|
84
217
|
return {
|
|
85
218
|
content: [{ type: 'text', text: JSON.stringify({
|
|
86
|
-
gate,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
219
|
+
gate: targetGate,
|
|
220
|
+
allowed,
|
|
221
|
+
enforced: true,
|
|
222
|
+
requirement: requirement?.check || '',
|
|
223
|
+
check_results: { artifacts_found: artifacts, checkpoints_found: checkpoints.map(c => c.passed_at) },
|
|
224
|
+
...(allowed ? { message: `✅ ${targetGate} conditions met — proceed to next gate` } : { blocked_reasons: reasons, action_required: requirement?.check || 'Complete gate requirements' }),
|
|
92
225
|
}, null, 2) }],
|
|
93
226
|
};
|
|
94
227
|
}
|
|
95
228
|
);
|
|
96
229
|
|
|
97
|
-
// Tool: advance_gate
|
|
230
|
+
// Tool: advance_gate — FSM enforced + leader check
|
|
98
231
|
server.tool(
|
|
99
232
|
'advance_gate',
|
|
100
|
-
'
|
|
101
|
-
{ gate: z.enum(GATES).describe('要推进到的 Gate
|
|
102
|
-
async ({ gate }) => {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
const
|
|
233
|
+
'【硬约束·需Leader】推进到下一个 Gate。仅 leader 会话可调用,observer 被拒绝。非顺序推进被 FSM 拒绝。',
|
|
234
|
+
{ gate: z.enum(GATES).describe('要推进到的 Gate 名称') },
|
|
235
|
+
async ({ gate }, extra) => {
|
|
236
|
+
const lockErr = requireLeader(extra?.sessionId); if (lockErr) return { content: [{ type: 'text', text: JSON.stringify(lockErr) }] };
|
|
237
|
+
const pstate = readPipeline();
|
|
238
|
+
const currentGate = pstate?.current_gate || 'Gate A';
|
|
239
|
+
const currentIdx = GATES.indexOf(currentGate);
|
|
240
|
+
const targetIdx = GATES.indexOf(gate);
|
|
241
|
+
|
|
242
|
+
if (targetIdx === -1) return { content: [{ type: 'text', text: JSON.stringify({ error: `Unknown gate: ${gate}` }) }] };
|
|
243
|
+
|
|
244
|
+
// FSM: only allow current gate → next gate (no skipping)
|
|
245
|
+
if (targetIdx <= currentIdx) {
|
|
246
|
+
return { content: [{ type: 'text', text: JSON.stringify({ allowed: false, error: `FSM blocked: Cannot move backward or stay. Current: ${currentGate}, Requested: ${gate}` }) }] };
|
|
247
|
+
}
|
|
248
|
+
if (targetIdx > currentIdx + 1) {
|
|
249
|
+
return { content: [{ type: 'text', text: JSON.stringify({ allowed: false, error: `FSM blocked: Cannot skip gates. Current: ${currentGate}, Requested: ${gate}. Must advance to ${GATES[currentIdx + 1]} first.` }) }] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Enforce: must pass current gate
|
|
253
|
+
const artifacts = findGateArtifacts(join(root, 'docs'), currentGate);
|
|
254
|
+
const checkpoints = readCheckpoints(root, currentGate);
|
|
255
|
+
if (artifacts.length === 0 && checkpoints.length === 0) {
|
|
256
|
+
return { content: [{ type: 'text', text: JSON.stringify({ allowed: false, error: `FSM blocked: ${currentGate} conditions NOT met. Run gate_enforce first. Required: ${GATE_CHECKS[currentGate]?.check}` }) }] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Allowed — advance
|
|
106
260
|
const cpDir = join(root, '.jarvis', 'checkpoints');
|
|
107
261
|
if (!existsSync(cpDir)) mkdirSync(cpDir, { recursive: true });
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
262
|
+
writeFileSync(join(cpDir, `${currentGate.replace(/ /g, '_')}.json`), JSON.stringify({ gate: currentGate, passed_at: new Date().toISOString(), advance_to: gate }, null, 2));
|
|
263
|
+
writePipeline({ ...pstate, current_gate: gate, gates_passed: [...(pstate?.gates_passed || []), currentGate] });
|
|
264
|
+
|
|
265
|
+
const nextGate = GATES[targetIdx + 1];
|
|
112
266
|
return {
|
|
113
267
|
content: [{ type: 'text', text: JSON.stringify({
|
|
114
|
-
|
|
115
|
-
previous_gate:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
next: GATES[idx + 1] || 'Done',
|
|
268
|
+
allowed: true,
|
|
269
|
+
previous_gate: currentGate, marked_passed_at: new Date().toISOString(),
|
|
270
|
+
current_gate: gate, next: nextGate || 'Pipeline Complete',
|
|
271
|
+
message: nextGate ? `Next: ${nextGate}` : '🎉 All gates passed! Move to Gate E: Release.',
|
|
119
272
|
}, null, 2) }],
|
|
120
273
|
};
|
|
121
274
|
}
|
|
@@ -211,7 +364,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
|
|
|
211
364
|
await server.connect(transport);
|
|
212
365
|
|
|
213
366
|
// ---- Health ----
|
|
214
|
-
app.get('/health', (_req, res) => res.json({ status: 'ok', version: readPkgVersion(), tools: ['pipeline_status', '
|
|
367
|
+
app.get('/health', (_req, res) => res.json({ status: 'ok', version: readPkgVersion(), tools: ['pipeline_init', 'pipeline_status', 'gate_enforce', 'advance_gate', 'report_status'] }));
|
|
215
368
|
|
|
216
369
|
// ---- SSE (real-time pipeline events) ----
|
|
217
370
|
const sseClients = new Set();
|