refacil-sdd-ai 4.2.3 → 4.3.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/README.md +239 -214
- package/agents/auditor.md +182 -184
- package/agents/debugger.md +201 -204
- package/agents/implementer.md +150 -149
- package/agents/investigator.md +80 -89
- package/agents/proposer.md +219 -124
- package/agents/tester.md +140 -144
- package/agents/validator.md +153 -145
- package/bin/cli.js +158 -116
- package/lib/bus/askFulfillment.js +17 -17
- package/lib/bus/broker.js +599 -599
- package/lib/bus/ui/app.js +318 -318
- package/lib/commands/sdd.js +433 -0
- package/lib/hooks.js +236 -236
- package/lib/installer.js +55 -1
- package/lib/methodology-migration-pending.js +101 -136
- package/package.json +4 -6
- package/skills/apply/SKILL.md +122 -120
- package/skills/archive/SKILL.md +101 -107
- package/skills/ask/SKILL.md +78 -78
- package/skills/attend/SKILL.md +70 -70
- package/skills/bug/SKILL.md +121 -117
- package/skills/explore/SKILL.md +61 -63
- package/skills/guide/SKILL.md +79 -79
- package/skills/inbox/SKILL.md +43 -43
- package/skills/join/SKILL.md +82 -82
- package/skills/prereqs/BUS-CROSS-REPO.md +55 -55
- package/skills/prereqs/METHODOLOGY-CONTRACT.md +122 -115
- package/skills/prereqs/SKILL.md +30 -37
- package/skills/propose/SKILL.md +91 -102
- package/skills/reply/SKILL.md +44 -44
- package/skills/review/SKILL.md +135 -126
- package/skills/review/checklist-back.md +92 -92
- package/skills/review/checklist-front.md +72 -72
- package/skills/review/checklist.md +114 -114
- package/skills/say/SKILL.md +38 -38
- package/skills/setup/SKILL.md +85 -141
- package/skills/setup/troubleshooting.md +38 -35
- package/skills/test/SKILL.md +86 -94
- package/skills/test/testing-patterns.md +63 -63
- package/skills/up-code/SKILL.md +108 -108
- package/skills/update/SKILL.md +109 -132
- package/skills/verify/SKILL.md +128 -132
- package/templates/compact-guidance.md +45 -45
- package/templates/methodology-guide.md +46 -42
- package/config/openspec-config.yaml +0 -8
- package/skills/prereqs/OPENSPEC-DELTAS.md +0 -51
package/lib/bus/broker.js
CHANGED
|
@@ -1,599 +1,599 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const os = require('os');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const http = require('http');
|
|
5
|
-
const crypto = require('crypto');
|
|
6
|
-
|
|
7
|
-
const HOME_DIR = path.join(os.homedir(), '.refacil-sdd-ai');
|
|
8
|
-
const BUS_INFO_PATH = path.join(HOME_DIR, 'bus.json');
|
|
9
|
-
const SESSIONS_PATH = path.join(HOME_DIR, 'sessions.json');
|
|
10
|
-
|
|
11
|
-
const PORT_CANDIDATES = [7821, 7822, 7823];
|
|
12
|
-
const HOST = '127.0.0.1';
|
|
13
|
-
|
|
14
|
-
const storage = require('./storage');
|
|
15
|
-
const { askHasMatchingReply } = require('./askFulfillment');
|
|
16
|
-
|
|
17
|
-
let WebSocketServer;
|
|
18
|
-
try {
|
|
19
|
-
({ WebSocketServer } = require('ws'));
|
|
20
|
-
} catch (_) {
|
|
21
|
-
WebSocketServer = null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function ensureHomeDir() {
|
|
25
|
-
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function writeBusInfo(port) {
|
|
29
|
-
ensureHomeDir();
|
|
30
|
-
const info = {
|
|
31
|
-
port,
|
|
32
|
-
pid: process.pid,
|
|
33
|
-
startedAt: new Date().toISOString(),
|
|
34
|
-
};
|
|
35
|
-
fs.writeFileSync(BUS_INFO_PATH, JSON.stringify(info, null, 2) + '\n');
|
|
36
|
-
return info;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function removeBusInfo() {
|
|
40
|
-
try {
|
|
41
|
-
fs.unlinkSync(BUS_INFO_PATH);
|
|
42
|
-
} catch (_) {
|
|
43
|
-
// ignore
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function readSessions() {
|
|
48
|
-
try {
|
|
49
|
-
return JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8'));
|
|
50
|
-
} catch (_) {
|
|
51
|
-
return {};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function writeSessions(data) {
|
|
56
|
-
ensureHomeDir();
|
|
57
|
-
fs.writeFileSync(SESSIONS_PATH, JSON.stringify(data, null, 2) + '\n');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function tryListen(server, port) {
|
|
61
|
-
return new Promise((resolve, reject) => {
|
|
62
|
-
const onError = (err) => {
|
|
63
|
-
server.removeListener('listening', onListening);
|
|
64
|
-
reject(err);
|
|
65
|
-
};
|
|
66
|
-
const onListening = () => {
|
|
67
|
-
server.removeListener('error', onError);
|
|
68
|
-
resolve();
|
|
69
|
-
};
|
|
70
|
-
server.once('error', onError);
|
|
71
|
-
server.once('listening', onListening);
|
|
72
|
-
server.listen(port, HOST);
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function pickPort(server) {
|
|
77
|
-
for (const port of PORT_CANDIDATES) {
|
|
78
|
-
try {
|
|
79
|
-
await tryListen(server, port);
|
|
80
|
-
return port;
|
|
81
|
-
} catch (err) {
|
|
82
|
-
if (err && err.code === 'EADDRINUSE') continue;
|
|
83
|
-
throw err;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
throw new Error(
|
|
87
|
-
`No hay puertos disponibles (intentados: ${PORT_CANDIDATES.join(', ')})`,
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function newId() {
|
|
92
|
-
return crypto.randomUUID();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function nowIso() {
|
|
96
|
-
return new Date().toISOString();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function send(ws, obj) {
|
|
100
|
-
if (ws.readyState !== ws.OPEN) return;
|
|
101
|
-
try {
|
|
102
|
-
ws.send(JSON.stringify(obj));
|
|
103
|
-
} catch (_) {
|
|
104
|
-
// ignore broken pipe
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function createState() {
|
|
109
|
-
return {
|
|
110
|
-
rooms: new Map(), // roomName -> Set<sessionName>
|
|
111
|
-
sessions: new Map(), // sessionName -> { ws, room, repo, lastSeen, attending, watchOnly }
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function persistSessions(state) {
|
|
116
|
-
const data = {};
|
|
117
|
-
for (const [name, s] of state.sessions.entries()) {
|
|
118
|
-
if (s.watchOnly) continue;
|
|
119
|
-
data[name] = {
|
|
120
|
-
repo: s.repo || null,
|
|
121
|
-
room: s.room || null,
|
|
122
|
-
lastSeen: s.lastSeen || null,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
writeSessions(data);
|
|
127
|
-
} catch (_) {
|
|
128
|
-
// non-fatal
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function broadcast(state, roomName, msg, exceptSession = null) {
|
|
133
|
-
const members = state.rooms.get(roomName);
|
|
134
|
-
if (!members) return;
|
|
135
|
-
for (const name of members) {
|
|
136
|
-
if (name === exceptSession) continue;
|
|
137
|
-
const s = state.sessions.get(name);
|
|
138
|
-
if (!s) continue;
|
|
139
|
-
if (s.wss && s.wss.size > 0) {
|
|
140
|
-
for (const sockets of s.wss) send(sockets, { type: 'msg', ...msg });
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
// Watchers no están en rooms, pero sí en sessions con watchOnly=true.
|
|
144
|
-
// Si watchRoom es null, recibe msgs de TODAS las salas (modo UI / observador global).
|
|
145
|
-
for (const [, s] of state.sessions.entries()) {
|
|
146
|
-
if (!s.watchOnly) continue;
|
|
147
|
-
if (s.watchRoom === null || s.watchRoom === undefined || s.watchRoom === roomName) {
|
|
148
|
-
if (s.ws) send(s.ws, { type: 'msg', ...msg });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function attachWs(state, sessionName, ws) {
|
|
154
|
-
const s = state.sessions.get(sessionName);
|
|
155
|
-
if (!s || s.watchOnly) return;
|
|
156
|
-
if (!s.wss) s.wss = new Set();
|
|
157
|
-
s.wss.add(ws);
|
|
158
|
-
if (!ws._refacilAttachedSessions) ws._refacilAttachedSessions = new Set();
|
|
159
|
-
ws._refacilAttachedSessions.add(sessionName);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function detachWs(state, ws) {
|
|
163
|
-
if (!ws._refacilAttachedSessions) return;
|
|
164
|
-
for (const name of ws._refacilAttachedSessions) {
|
|
165
|
-
const s = state.sessions.get(name);
|
|
166
|
-
if (s && s.wss) s.wss.delete(ws);
|
|
167
|
-
}
|
|
168
|
-
ws._refacilAttachedSessions.clear();
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function appendHistory(_state, roomName, msg) {
|
|
172
|
-
try {
|
|
173
|
-
storage.append(roomName, msg);
|
|
174
|
-
} catch (_) {
|
|
175
|
-
// persistencia no debe romper el broker
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function leaveRoom(state, sessionName) {
|
|
180
|
-
const s = state.sessions.get(sessionName);
|
|
181
|
-
if (!s || !s.room) return;
|
|
182
|
-
const roomName = s.room;
|
|
183
|
-
const members = state.rooms.get(roomName);
|
|
184
|
-
if (members) {
|
|
185
|
-
members.delete(sessionName);
|
|
186
|
-
if (members.size === 0) state.rooms.delete(roomName);
|
|
187
|
-
}
|
|
188
|
-
const msg = {
|
|
189
|
-
id: newId(),
|
|
190
|
-
ts: nowIso(),
|
|
191
|
-
from: sessionName,
|
|
192
|
-
to: null,
|
|
193
|
-
room: roomName,
|
|
194
|
-
text: `${sessionName} salió de la sala`,
|
|
195
|
-
kind: 'system',
|
|
196
|
-
};
|
|
197
|
-
appendHistory(state, roomName, msg);
|
|
198
|
-
broadcast(state, roomName, msg);
|
|
199
|
-
s.room = null;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function handleJoin(state, ws, data) {
|
|
203
|
-
const { session, room, repo, intro } = data;
|
|
204
|
-
if (!session || !room) {
|
|
205
|
-
return send(ws, { type: 'system', event: 'error', detail: 'join requiere session y room' });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Si la sesión ya existe en otra sala, sacarla primero
|
|
209
|
-
const existing = state.sessions.get(session);
|
|
210
|
-
const isReturningToSameRoom = !!(existing && existing.room === room);
|
|
211
|
-
if (existing && existing.room && existing.room !== room) {
|
|
212
|
-
leaveRoom(state, session);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const meta = existing || {};
|
|
216
|
-
meta.repo = repo || meta.repo || null;
|
|
217
|
-
meta.room = room;
|
|
218
|
-
meta.watchOnly = false;
|
|
219
|
-
// Primer join o cambio de sala → lastSeen = ahora (sin backlog).
|
|
220
|
-
// Reingreso a la misma sala tras desconectar → preservar lastSeen existente
|
|
221
|
-
// para que /inbox traiga los mensajes perdidos mientras estuvo fuera.
|
|
222
|
-
if (!isReturningToSameRoom || !meta.lastSeen) {
|
|
223
|
-
meta.lastSeen = nowIso();
|
|
224
|
-
}
|
|
225
|
-
if (!meta.wss) meta.wss = new Set();
|
|
226
|
-
state.sessions.set(session, meta);
|
|
227
|
-
ws._refacilSession = session;
|
|
228
|
-
attachWs(state, session, ws);
|
|
229
|
-
|
|
230
|
-
if (!state.rooms.has(room)) state.rooms.set(room, new Set());
|
|
231
|
-
state.rooms.get(room).add(session);
|
|
232
|
-
|
|
233
|
-
const introText = intro || `${session} se unió a la sala`;
|
|
234
|
-
const msg = {
|
|
235
|
-
id: newId(),
|
|
236
|
-
ts: nowIso(),
|
|
237
|
-
from: session,
|
|
238
|
-
to: null,
|
|
239
|
-
room,
|
|
240
|
-
text: introText,
|
|
241
|
-
kind: 'system',
|
|
242
|
-
};
|
|
243
|
-
appendHistory(state, room, msg);
|
|
244
|
-
broadcast(state, room, msg);
|
|
245
|
-
|
|
246
|
-
send(ws, {
|
|
247
|
-
type: 'system',
|
|
248
|
-
event: 'joined',
|
|
249
|
-
detail: { room, session, members: Array.from(state.rooms.get(room)) },
|
|
250
|
-
});
|
|
251
|
-
persistSessions(state);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function handleLeave(state, ws, data) {
|
|
255
|
-
const session = data.session || ws._refacilSession;
|
|
256
|
-
if (!session) return;
|
|
257
|
-
leaveRoom(state, session);
|
|
258
|
-
send(ws, { type: 'system', event: 'left', detail: { session } });
|
|
259
|
-
persistSessions(state);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Destinos especiales: un ask por cada miembro de la sala excepto el emisor. */
|
|
263
|
-
const ASK_ALL_ALIASES = new Set(['all', '*', 'everyone']);
|
|
264
|
-
|
|
265
|
-
function resolveAskTargets(state, room, fromSession, rawTo) {
|
|
266
|
-
if (rawTo === undefined || rawTo === null) return [null];
|
|
267
|
-
const trimmed = String(rawTo).trim();
|
|
268
|
-
if (trimmed === '') return [null];
|
|
269
|
-
const bare = trimmed.replace(/^@/, '');
|
|
270
|
-
const key = bare.toLowerCase();
|
|
271
|
-
if (ASK_ALL_ALIASES.has(key)) {
|
|
272
|
-
const members = state.rooms.get(room);
|
|
273
|
-
if (!members || members.size === 0) return [];
|
|
274
|
-
const others = Array.from(members).filter((n) => n !== fromSession);
|
|
275
|
-
return others.length ? others.sort() : [];
|
|
276
|
-
}
|
|
277
|
-
return [bare];
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function handleSay(state, ws, data) {
|
|
281
|
-
const session = data.session || ws._refacilSession;
|
|
282
|
-
const s = session && state.sessions.get(session);
|
|
283
|
-
if (!s || !s.room) {
|
|
284
|
-
return send(ws, { type: 'system', event: 'error', detail: 'sesión no está en ninguna sala' });
|
|
285
|
-
}
|
|
286
|
-
const msg = {
|
|
287
|
-
id: newId(),
|
|
288
|
-
ts: nowIso(),
|
|
289
|
-
from: session,
|
|
290
|
-
to: null,
|
|
291
|
-
room: s.room,
|
|
292
|
-
text: data.text || '',
|
|
293
|
-
kind: 'broadcast',
|
|
294
|
-
};
|
|
295
|
-
appendHistory(state, s.room, msg);
|
|
296
|
-
broadcast(state, s.room, msg);
|
|
297
|
-
send(ws, { type: 'system', event: 'sent', detail: { id: msg.id } });
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function handleAsk(state, ws, data) {
|
|
301
|
-
const session = data.session || ws._refacilSession;
|
|
302
|
-
const s = session && state.sessions.get(session);
|
|
303
|
-
if (!s || !s.room) {
|
|
304
|
-
return send(ws, { type: 'system', event: 'error', detail: 'sesión no está en ninguna sala' });
|
|
305
|
-
}
|
|
306
|
-
const correlationId = data.correlationId || newId();
|
|
307
|
-
const targets = resolveAskTargets(state, s.room, session, data.to);
|
|
308
|
-
if (targets.length === 0) {
|
|
309
|
-
return send(ws, {
|
|
310
|
-
type: 'system',
|
|
311
|
-
event: 'error',
|
|
312
|
-
detail: 'sin destinatarios (@all en sala vacía o solo tú)',
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
let firstId = null;
|
|
316
|
-
for (const to of targets) {
|
|
317
|
-
const msg = {
|
|
318
|
-
id: newId(),
|
|
319
|
-
ts: nowIso(),
|
|
320
|
-
from: session,
|
|
321
|
-
to,
|
|
322
|
-
room: s.room,
|
|
323
|
-
text: data.text || '',
|
|
324
|
-
kind: 'ask',
|
|
325
|
-
correlationId,
|
|
326
|
-
};
|
|
327
|
-
if (!firstId) firstId = msg.id;
|
|
328
|
-
appendHistory(state, s.room, msg);
|
|
329
|
-
broadcast(state, s.room, msg);
|
|
330
|
-
}
|
|
331
|
-
send(ws, {
|
|
332
|
-
type: 'system',
|
|
333
|
-
event: 'sent',
|
|
334
|
-
detail: { id: firstId, correlationId, fanOut: targets.length },
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function handleReply(state, ws, data) {
|
|
339
|
-
const session = data.session || ws._refacilSession;
|
|
340
|
-
const s = session && state.sessions.get(session);
|
|
341
|
-
if (!s || !s.room) {
|
|
342
|
-
return send(ws, { type: 'system', event: 'error', detail: 'sesión no está en ninguna sala' });
|
|
343
|
-
}
|
|
344
|
-
let correlationId = data.correlationId || null;
|
|
345
|
-
let toOverride = data.to || null;
|
|
346
|
-
// Si el cliente no pasó correlationId, autocompletar con el ask MÁS ANTIGUO
|
|
347
|
-
// sin respuesta dirigido a esta sesión — así se alinea con el orden FIFO en
|
|
348
|
-
// que `attend` entrega las preguntas al LLM.
|
|
349
|
-
if (!correlationId) {
|
|
350
|
-
const hist = storage.readLast(s.room, 200);
|
|
351
|
-
for (const ask of hist) {
|
|
352
|
-
if (ask.kind !== 'ask') continue;
|
|
353
|
-
if (ask.to && ask.to !== session) continue;
|
|
354
|
-
const replied = askHasMatchingReply(hist, ask);
|
|
355
|
-
if (replied) continue;
|
|
356
|
-
correlationId = ask.correlationId || null;
|
|
357
|
-
if (!toOverride) toOverride = ask.from;
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
const msg = {
|
|
362
|
-
id: newId(),
|
|
363
|
-
ts: nowIso(),
|
|
364
|
-
from: session,
|
|
365
|
-
to: toOverride,
|
|
366
|
-
room: s.room,
|
|
367
|
-
text: data.text || '',
|
|
368
|
-
kind: 'reply',
|
|
369
|
-
correlationId,
|
|
370
|
-
};
|
|
371
|
-
appendHistory(state, s.room, msg);
|
|
372
|
-
broadcast(state, s.room, msg);
|
|
373
|
-
send(ws, { type: 'system', event: 'sent', detail: { id: msg.id, correlationId } });
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function handleHistory(state, ws, data) {
|
|
377
|
-
const session = data.session || ws._refacilSession;
|
|
378
|
-
const s = session && state.sessions.get(session);
|
|
379
|
-
const roomName = (s && s.room) || data.room;
|
|
380
|
-
if (!roomName) {
|
|
381
|
-
return send(ws, { type: 'history', messages: [] });
|
|
382
|
-
}
|
|
383
|
-
const messages = storage.readLast(roomName, data.n || 20);
|
|
384
|
-
send(ws, { type: 'history', messages });
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function handleInbox(state, ws, data) {
|
|
388
|
-
const session = data.session || ws._refacilSession;
|
|
389
|
-
const s = session && state.sessions.get(session);
|
|
390
|
-
if (!s || !s.room) {
|
|
391
|
-
return send(ws, { type: 'inbox', messages: [], newLastSeen: nowIso() });
|
|
392
|
-
}
|
|
393
|
-
const since = s.lastSeen || '1970-01-01T00:00:00.000Z';
|
|
394
|
-
const newMsgs = storage.readSince(s.room, since, session);
|
|
395
|
-
const newLastSeen = nowIso();
|
|
396
|
-
s.lastSeen = newLastSeen;
|
|
397
|
-
send(ws, { type: 'inbox', messages: newMsgs, newLastSeen });
|
|
398
|
-
persistSessions(state);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function handleWatch(state, ws, data) {
|
|
402
|
-
const session = data.session || ('watcher-' + newId().slice(0, 8));
|
|
403
|
-
const room = data.room || null;
|
|
404
|
-
state.sessions.set(session, {
|
|
405
|
-
ws,
|
|
406
|
-
room: null,
|
|
407
|
-
repo: null,
|
|
408
|
-
lastSeen: null,
|
|
409
|
-
watchOnly: true,
|
|
410
|
-
watchRoom: room,
|
|
411
|
-
});
|
|
412
|
-
ws._refacilSession = session;
|
|
413
|
-
send(ws, { type: 'system', event: 'watching', detail: { session, room } });
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function handleStatus(state, ws) {
|
|
417
|
-
const rooms = {};
|
|
418
|
-
for (const [name, members] of state.rooms.entries()) {
|
|
419
|
-
rooms[name] = Array.from(members);
|
|
420
|
-
}
|
|
421
|
-
send(ws, {
|
|
422
|
-
type: 'system',
|
|
423
|
-
event: 'status',
|
|
424
|
-
detail: {
|
|
425
|
-
rooms,
|
|
426
|
-
sessions: Array.from(state.sessions.keys()).filter(
|
|
427
|
-
(n) => !state.sessions.get(n).watchOnly,
|
|
428
|
-
),
|
|
429
|
-
port: state._port,
|
|
430
|
-
pid: process.pid,
|
|
431
|
-
startedAt: state._startedAt,
|
|
432
|
-
},
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function onMessage(state, ws, raw) {
|
|
437
|
-
let data;
|
|
438
|
-
try {
|
|
439
|
-
data = JSON.parse(raw.toString());
|
|
440
|
-
} catch (_) {
|
|
441
|
-
return send(ws, { type: 'system', event: 'error', detail: 'JSON inválido' });
|
|
442
|
-
}
|
|
443
|
-
// Si el cliente declara una sesión existente (no es join/watch), attach el ws
|
|
444
|
-
// para que reciba broadcasts mientras la conexión esté viva.
|
|
445
|
-
if (data.session && data.op !== 'watch' && data.op !== 'join') {
|
|
446
|
-
const s = state.sessions.get(data.session);
|
|
447
|
-
if (s && !s.watchOnly) {
|
|
448
|
-
ws._refacilSession = data.session;
|
|
449
|
-
attachWs(state, data.session, ws);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
switch (data.op) {
|
|
453
|
-
case 'join': return handleJoin(state, ws, data);
|
|
454
|
-
case 'leave': return handleLeave(state, ws, data);
|
|
455
|
-
case 'say': return handleSay(state, ws, data);
|
|
456
|
-
case 'ask': return handleAsk(state, ws, data);
|
|
457
|
-
case 'reply': return handleReply(state, ws, data);
|
|
458
|
-
case 'history': return handleHistory(state, ws, data);
|
|
459
|
-
case 'inbox': return handleInbox(state, ws, data);
|
|
460
|
-
case 'watch': return handleWatch(state, ws, data);
|
|
461
|
-
case 'status': return handleStatus(state, ws);
|
|
462
|
-
case 'ping': return send(ws, { type: 'system', event: 'pong' });
|
|
463
|
-
default:
|
|
464
|
-
return send(ws, { type: 'system', event: 'error', detail: `op desconocida: ${data.op}` });
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function onClose(state, ws) {
|
|
469
|
-
detachWs(state, ws);
|
|
470
|
-
const session = ws._refacilSession;
|
|
471
|
-
if (!session) return;
|
|
472
|
-
const s = state.sessions.get(session);
|
|
473
|
-
if (!s) return;
|
|
474
|
-
// Watchers son efímeros: se borran al cerrar WS.
|
|
475
|
-
if (s.watchOnly) {
|
|
476
|
-
state.sessions.delete(session);
|
|
477
|
-
}
|
|
478
|
-
// Sesiones normales persisten en la sala aunque cierren el WS — las skills del
|
|
479
|
-
// CLI abren conexiones cortas (say/ask/reply/history/inbox) y confían en que
|
|
480
|
-
// "estar unido" sobrevive entre invocaciones. Solo `leave` explícito saca.
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
const UI_DIR = path.join(__dirname, 'ui');
|
|
484
|
-
const UI_MIME = {
|
|
485
|
-
'.html': 'text/html; charset=utf-8',
|
|
486
|
-
'.css': 'text/css; charset=utf-8',
|
|
487
|
-
'.js': 'application/javascript; charset=utf-8',
|
|
488
|
-
'.svg': 'image/svg+xml',
|
|
489
|
-
'.png': 'image/png',
|
|
490
|
-
'.ico': 'image/x-icon',
|
|
491
|
-
};
|
|
492
|
-
|
|
493
|
-
function serveUi(req, res) {
|
|
494
|
-
let urlPath = req.url.split('?')[0];
|
|
495
|
-
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
|
|
496
|
-
const safePath = urlPath.replace(/^\/+/, '').replace(/\.\./g, '');
|
|
497
|
-
const filePath = path.join(UI_DIR, safePath);
|
|
498
|
-
if (!filePath.startsWith(UI_DIR)) {
|
|
499
|
-
res.writeHead(403);
|
|
500
|
-
res.end('forbidden');
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
fs.readFile(filePath, (err, data) => {
|
|
504
|
-
if (err) {
|
|
505
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
506
|
-
res.end('not found');
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
510
|
-
const mime = UI_MIME[ext] || 'application/octet-stream';
|
|
511
|
-
res.writeHead(200, { 'content-type': mime });
|
|
512
|
-
res.end(data);
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
async function start() {
|
|
517
|
-
if (!WebSocketServer) {
|
|
518
|
-
throw new Error(
|
|
519
|
-
"Dependencia 'ws' no encontrada. Instala con: npm install -g ws (o npm install ws en el paquete)",
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const state = createState();
|
|
524
|
-
// Restaurar sesiones persistidas para sobrevivir reinicios del broker.
|
|
525
|
-
try {
|
|
526
|
-
const persisted = readSessions();
|
|
527
|
-
for (const [name, meta] of Object.entries(persisted || {})) {
|
|
528
|
-
if (!meta || !meta.room) continue;
|
|
529
|
-
state.sessions.set(name, {
|
|
530
|
-
ws: null,
|
|
531
|
-
repo: meta.repo || null,
|
|
532
|
-
room: meta.room,
|
|
533
|
-
lastSeen: meta.lastSeen || null,
|
|
534
|
-
watchOnly: false,
|
|
535
|
-
});
|
|
536
|
-
if (!state.rooms.has(meta.room)) state.rooms.set(meta.room, new Set());
|
|
537
|
-
state.rooms.get(meta.room).add(name);
|
|
538
|
-
}
|
|
539
|
-
} catch (_) {
|
|
540
|
-
// sin persistencia previa
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const server = http.createServer((req, res) => {
|
|
544
|
-
if (req.method === 'GET') {
|
|
545
|
-
serveUi(req, res);
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
res.writeHead(405, { 'content-type': 'text/plain' });
|
|
549
|
-
res.end('method not allowed');
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
const port = await pickPort(server);
|
|
553
|
-
|
|
554
|
-
const wss = new WebSocketServer({ server });
|
|
555
|
-
wss.on('connection', (ws) => {
|
|
556
|
-
ws.on('message', (raw) => onMessage(state, ws, raw));
|
|
557
|
-
ws.on('close', () => onClose(state, ws));
|
|
558
|
-
ws.on('error', () => {});
|
|
559
|
-
});
|
|
560
|
-
const info = writeBusInfo(port);
|
|
561
|
-
state._port = port;
|
|
562
|
-
state._startedAt = info.startedAt;
|
|
563
|
-
|
|
564
|
-
const shutdown = () => {
|
|
565
|
-
try {
|
|
566
|
-
wss.close();
|
|
567
|
-
server.close();
|
|
568
|
-
} catch (_) {}
|
|
569
|
-
removeBusInfo();
|
|
570
|
-
process.exit(0);
|
|
571
|
-
};
|
|
572
|
-
process.on('SIGINT', shutdown);
|
|
573
|
-
process.on('SIGTERM', shutdown);
|
|
574
|
-
process.on('SIGHUP', shutdown);
|
|
575
|
-
|
|
576
|
-
return { port, pid: process.pid, startedAt: info.startedAt, state, server, wss };
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
module.exports = {
|
|
580
|
-
start,
|
|
581
|
-
BUS_INFO_PATH,
|
|
582
|
-
SESSIONS_PATH,
|
|
583
|
-
HOME_DIR,
|
|
584
|
-
PORT_CANDIDATES,
|
|
585
|
-
HOST,
|
|
586
|
-
};
|
|
587
|
-
|
|
588
|
-
if (require.main === module) {
|
|
589
|
-
start()
|
|
590
|
-
.then((info) => {
|
|
591
|
-
process.stdout.write(
|
|
592
|
-
`refacil-bus broker escuchando en ${HOST}:${info.port} (pid ${info.pid})\n`,
|
|
593
|
-
);
|
|
594
|
-
})
|
|
595
|
-
.catch((err) => {
|
|
596
|
-
process.stderr.write(`Error arrancando broker: ${err.message}\n`);
|
|
597
|
-
process.exit(1);
|
|
598
|
-
});
|
|
599
|
-
}
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
const HOME_DIR = path.join(os.homedir(), '.refacil-sdd-ai');
|
|
8
|
+
const BUS_INFO_PATH = path.join(HOME_DIR, 'bus.json');
|
|
9
|
+
const SESSIONS_PATH = path.join(HOME_DIR, 'sessions.json');
|
|
10
|
+
|
|
11
|
+
const PORT_CANDIDATES = [7821, 7822, 7823];
|
|
12
|
+
const HOST = '127.0.0.1';
|
|
13
|
+
|
|
14
|
+
const storage = require('./storage');
|
|
15
|
+
const { askHasMatchingReply } = require('./askFulfillment');
|
|
16
|
+
|
|
17
|
+
let WebSocketServer;
|
|
18
|
+
try {
|
|
19
|
+
({ WebSocketServer } = require('ws'));
|
|
20
|
+
} catch (_) {
|
|
21
|
+
WebSocketServer = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureHomeDir() {
|
|
25
|
+
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeBusInfo(port) {
|
|
29
|
+
ensureHomeDir();
|
|
30
|
+
const info = {
|
|
31
|
+
port,
|
|
32
|
+
pid: process.pid,
|
|
33
|
+
startedAt: new Date().toISOString(),
|
|
34
|
+
};
|
|
35
|
+
fs.writeFileSync(BUS_INFO_PATH, JSON.stringify(info, null, 2) + '\n');
|
|
36
|
+
return info;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function removeBusInfo() {
|
|
40
|
+
try {
|
|
41
|
+
fs.unlinkSync(BUS_INFO_PATH);
|
|
42
|
+
} catch (_) {
|
|
43
|
+
// ignore
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readSessions() {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8'));
|
|
50
|
+
} catch (_) {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeSessions(data) {
|
|
56
|
+
ensureHomeDir();
|
|
57
|
+
fs.writeFileSync(SESSIONS_PATH, JSON.stringify(data, null, 2) + '\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tryListen(server, port) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const onError = (err) => {
|
|
63
|
+
server.removeListener('listening', onListening);
|
|
64
|
+
reject(err);
|
|
65
|
+
};
|
|
66
|
+
const onListening = () => {
|
|
67
|
+
server.removeListener('error', onError);
|
|
68
|
+
resolve();
|
|
69
|
+
};
|
|
70
|
+
server.once('error', onError);
|
|
71
|
+
server.once('listening', onListening);
|
|
72
|
+
server.listen(port, HOST);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function pickPort(server) {
|
|
77
|
+
for (const port of PORT_CANDIDATES) {
|
|
78
|
+
try {
|
|
79
|
+
await tryListen(server, port);
|
|
80
|
+
return port;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err && err.code === 'EADDRINUSE') continue;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new Error(
|
|
87
|
+
`No hay puertos disponibles (intentados: ${PORT_CANDIDATES.join(', ')})`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function newId() {
|
|
92
|
+
return crypto.randomUUID();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function nowIso() {
|
|
96
|
+
return new Date().toISOString();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function send(ws, obj) {
|
|
100
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
101
|
+
try {
|
|
102
|
+
ws.send(JSON.stringify(obj));
|
|
103
|
+
} catch (_) {
|
|
104
|
+
// ignore broken pipe
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createState() {
|
|
109
|
+
return {
|
|
110
|
+
rooms: new Map(), // roomName -> Set<sessionName>
|
|
111
|
+
sessions: new Map(), // sessionName -> { ws, room, repo, lastSeen, attending, watchOnly }
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function persistSessions(state) {
|
|
116
|
+
const data = {};
|
|
117
|
+
for (const [name, s] of state.sessions.entries()) {
|
|
118
|
+
if (s.watchOnly) continue;
|
|
119
|
+
data[name] = {
|
|
120
|
+
repo: s.repo || null,
|
|
121
|
+
room: s.room || null,
|
|
122
|
+
lastSeen: s.lastSeen || null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
writeSessions(data);
|
|
127
|
+
} catch (_) {
|
|
128
|
+
// non-fatal
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function broadcast(state, roomName, msg, exceptSession = null) {
|
|
133
|
+
const members = state.rooms.get(roomName);
|
|
134
|
+
if (!members) return;
|
|
135
|
+
for (const name of members) {
|
|
136
|
+
if (name === exceptSession) continue;
|
|
137
|
+
const s = state.sessions.get(name);
|
|
138
|
+
if (!s) continue;
|
|
139
|
+
if (s.wss && s.wss.size > 0) {
|
|
140
|
+
for (const sockets of s.wss) send(sockets, { type: 'msg', ...msg });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Watchers no están en rooms, pero sí en sessions con watchOnly=true.
|
|
144
|
+
// Si watchRoom es null, recibe msgs de TODAS las salas (modo UI / observador global).
|
|
145
|
+
for (const [, s] of state.sessions.entries()) {
|
|
146
|
+
if (!s.watchOnly) continue;
|
|
147
|
+
if (s.watchRoom === null || s.watchRoom === undefined || s.watchRoom === roomName) {
|
|
148
|
+
if (s.ws) send(s.ws, { type: 'msg', ...msg });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function attachWs(state, sessionName, ws) {
|
|
154
|
+
const s = state.sessions.get(sessionName);
|
|
155
|
+
if (!s || s.watchOnly) return;
|
|
156
|
+
if (!s.wss) s.wss = new Set();
|
|
157
|
+
s.wss.add(ws);
|
|
158
|
+
if (!ws._refacilAttachedSessions) ws._refacilAttachedSessions = new Set();
|
|
159
|
+
ws._refacilAttachedSessions.add(sessionName);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function detachWs(state, ws) {
|
|
163
|
+
if (!ws._refacilAttachedSessions) return;
|
|
164
|
+
for (const name of ws._refacilAttachedSessions) {
|
|
165
|
+
const s = state.sessions.get(name);
|
|
166
|
+
if (s && s.wss) s.wss.delete(ws);
|
|
167
|
+
}
|
|
168
|
+
ws._refacilAttachedSessions.clear();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function appendHistory(_state, roomName, msg) {
|
|
172
|
+
try {
|
|
173
|
+
storage.append(roomName, msg);
|
|
174
|
+
} catch (_) {
|
|
175
|
+
// persistencia no debe romper el broker
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function leaveRoom(state, sessionName) {
|
|
180
|
+
const s = state.sessions.get(sessionName);
|
|
181
|
+
if (!s || !s.room) return;
|
|
182
|
+
const roomName = s.room;
|
|
183
|
+
const members = state.rooms.get(roomName);
|
|
184
|
+
if (members) {
|
|
185
|
+
members.delete(sessionName);
|
|
186
|
+
if (members.size === 0) state.rooms.delete(roomName);
|
|
187
|
+
}
|
|
188
|
+
const msg = {
|
|
189
|
+
id: newId(),
|
|
190
|
+
ts: nowIso(),
|
|
191
|
+
from: sessionName,
|
|
192
|
+
to: null,
|
|
193
|
+
room: roomName,
|
|
194
|
+
text: `${sessionName} salió de la sala`,
|
|
195
|
+
kind: 'system',
|
|
196
|
+
};
|
|
197
|
+
appendHistory(state, roomName, msg);
|
|
198
|
+
broadcast(state, roomName, msg);
|
|
199
|
+
s.room = null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function handleJoin(state, ws, data) {
|
|
203
|
+
const { session, room, repo, intro } = data;
|
|
204
|
+
if (!session || !room) {
|
|
205
|
+
return send(ws, { type: 'system', event: 'error', detail: 'join requiere session y room' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Si la sesión ya existe en otra sala, sacarla primero
|
|
209
|
+
const existing = state.sessions.get(session);
|
|
210
|
+
const isReturningToSameRoom = !!(existing && existing.room === room);
|
|
211
|
+
if (existing && existing.room && existing.room !== room) {
|
|
212
|
+
leaveRoom(state, session);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const meta = existing || {};
|
|
216
|
+
meta.repo = repo || meta.repo || null;
|
|
217
|
+
meta.room = room;
|
|
218
|
+
meta.watchOnly = false;
|
|
219
|
+
// Primer join o cambio de sala → lastSeen = ahora (sin backlog).
|
|
220
|
+
// Reingreso a la misma sala tras desconectar → preservar lastSeen existente
|
|
221
|
+
// para que /inbox traiga los mensajes perdidos mientras estuvo fuera.
|
|
222
|
+
if (!isReturningToSameRoom || !meta.lastSeen) {
|
|
223
|
+
meta.lastSeen = nowIso();
|
|
224
|
+
}
|
|
225
|
+
if (!meta.wss) meta.wss = new Set();
|
|
226
|
+
state.sessions.set(session, meta);
|
|
227
|
+
ws._refacilSession = session;
|
|
228
|
+
attachWs(state, session, ws);
|
|
229
|
+
|
|
230
|
+
if (!state.rooms.has(room)) state.rooms.set(room, new Set());
|
|
231
|
+
state.rooms.get(room).add(session);
|
|
232
|
+
|
|
233
|
+
const introText = intro || `${session} se unió a la sala`;
|
|
234
|
+
const msg = {
|
|
235
|
+
id: newId(),
|
|
236
|
+
ts: nowIso(),
|
|
237
|
+
from: session,
|
|
238
|
+
to: null,
|
|
239
|
+
room,
|
|
240
|
+
text: introText,
|
|
241
|
+
kind: 'system',
|
|
242
|
+
};
|
|
243
|
+
appendHistory(state, room, msg);
|
|
244
|
+
broadcast(state, room, msg);
|
|
245
|
+
|
|
246
|
+
send(ws, {
|
|
247
|
+
type: 'system',
|
|
248
|
+
event: 'joined',
|
|
249
|
+
detail: { room, session, members: Array.from(state.rooms.get(room)) },
|
|
250
|
+
});
|
|
251
|
+
persistSessions(state);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function handleLeave(state, ws, data) {
|
|
255
|
+
const session = data.session || ws._refacilSession;
|
|
256
|
+
if (!session) return;
|
|
257
|
+
leaveRoom(state, session);
|
|
258
|
+
send(ws, { type: 'system', event: 'left', detail: { session } });
|
|
259
|
+
persistSessions(state);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Destinos especiales: un ask por cada miembro de la sala excepto el emisor. */
|
|
263
|
+
const ASK_ALL_ALIASES = new Set(['all', '*', 'everyone']);
|
|
264
|
+
|
|
265
|
+
function resolveAskTargets(state, room, fromSession, rawTo) {
|
|
266
|
+
if (rawTo === undefined || rawTo === null) return [null];
|
|
267
|
+
const trimmed = String(rawTo).trim();
|
|
268
|
+
if (trimmed === '') return [null];
|
|
269
|
+
const bare = trimmed.replace(/^@/, '');
|
|
270
|
+
const key = bare.toLowerCase();
|
|
271
|
+
if (ASK_ALL_ALIASES.has(key)) {
|
|
272
|
+
const members = state.rooms.get(room);
|
|
273
|
+
if (!members || members.size === 0) return [];
|
|
274
|
+
const others = Array.from(members).filter((n) => n !== fromSession);
|
|
275
|
+
return others.length ? others.sort() : [];
|
|
276
|
+
}
|
|
277
|
+
return [bare];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function handleSay(state, ws, data) {
|
|
281
|
+
const session = data.session || ws._refacilSession;
|
|
282
|
+
const s = session && state.sessions.get(session);
|
|
283
|
+
if (!s || !s.room) {
|
|
284
|
+
return send(ws, { type: 'system', event: 'error', detail: 'sesión no está en ninguna sala' });
|
|
285
|
+
}
|
|
286
|
+
const msg = {
|
|
287
|
+
id: newId(),
|
|
288
|
+
ts: nowIso(),
|
|
289
|
+
from: session,
|
|
290
|
+
to: null,
|
|
291
|
+
room: s.room,
|
|
292
|
+
text: data.text || '',
|
|
293
|
+
kind: 'broadcast',
|
|
294
|
+
};
|
|
295
|
+
appendHistory(state, s.room, msg);
|
|
296
|
+
broadcast(state, s.room, msg);
|
|
297
|
+
send(ws, { type: 'system', event: 'sent', detail: { id: msg.id } });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function handleAsk(state, ws, data) {
|
|
301
|
+
const session = data.session || ws._refacilSession;
|
|
302
|
+
const s = session && state.sessions.get(session);
|
|
303
|
+
if (!s || !s.room) {
|
|
304
|
+
return send(ws, { type: 'system', event: 'error', detail: 'sesión no está en ninguna sala' });
|
|
305
|
+
}
|
|
306
|
+
const correlationId = data.correlationId || newId();
|
|
307
|
+
const targets = resolveAskTargets(state, s.room, session, data.to);
|
|
308
|
+
if (targets.length === 0) {
|
|
309
|
+
return send(ws, {
|
|
310
|
+
type: 'system',
|
|
311
|
+
event: 'error',
|
|
312
|
+
detail: 'sin destinatarios (@all en sala vacía o solo tú)',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
let firstId = null;
|
|
316
|
+
for (const to of targets) {
|
|
317
|
+
const msg = {
|
|
318
|
+
id: newId(),
|
|
319
|
+
ts: nowIso(),
|
|
320
|
+
from: session,
|
|
321
|
+
to,
|
|
322
|
+
room: s.room,
|
|
323
|
+
text: data.text || '',
|
|
324
|
+
kind: 'ask',
|
|
325
|
+
correlationId,
|
|
326
|
+
};
|
|
327
|
+
if (!firstId) firstId = msg.id;
|
|
328
|
+
appendHistory(state, s.room, msg);
|
|
329
|
+
broadcast(state, s.room, msg);
|
|
330
|
+
}
|
|
331
|
+
send(ws, {
|
|
332
|
+
type: 'system',
|
|
333
|
+
event: 'sent',
|
|
334
|
+
detail: { id: firstId, correlationId, fanOut: targets.length },
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function handleReply(state, ws, data) {
|
|
339
|
+
const session = data.session || ws._refacilSession;
|
|
340
|
+
const s = session && state.sessions.get(session);
|
|
341
|
+
if (!s || !s.room) {
|
|
342
|
+
return send(ws, { type: 'system', event: 'error', detail: 'sesión no está en ninguna sala' });
|
|
343
|
+
}
|
|
344
|
+
let correlationId = data.correlationId || null;
|
|
345
|
+
let toOverride = data.to || null;
|
|
346
|
+
// Si el cliente no pasó correlationId, autocompletar con el ask MÁS ANTIGUO
|
|
347
|
+
// sin respuesta dirigido a esta sesión — así se alinea con el orden FIFO en
|
|
348
|
+
// que `attend` entrega las preguntas al LLM.
|
|
349
|
+
if (!correlationId) {
|
|
350
|
+
const hist = storage.readLast(s.room, 200);
|
|
351
|
+
for (const ask of hist) {
|
|
352
|
+
if (ask.kind !== 'ask') continue;
|
|
353
|
+
if (ask.to && ask.to !== session) continue;
|
|
354
|
+
const replied = askHasMatchingReply(hist, ask);
|
|
355
|
+
if (replied) continue;
|
|
356
|
+
correlationId = ask.correlationId || null;
|
|
357
|
+
if (!toOverride) toOverride = ask.from;
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const msg = {
|
|
362
|
+
id: newId(),
|
|
363
|
+
ts: nowIso(),
|
|
364
|
+
from: session,
|
|
365
|
+
to: toOverride,
|
|
366
|
+
room: s.room,
|
|
367
|
+
text: data.text || '',
|
|
368
|
+
kind: 'reply',
|
|
369
|
+
correlationId,
|
|
370
|
+
};
|
|
371
|
+
appendHistory(state, s.room, msg);
|
|
372
|
+
broadcast(state, s.room, msg);
|
|
373
|
+
send(ws, { type: 'system', event: 'sent', detail: { id: msg.id, correlationId } });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function handleHistory(state, ws, data) {
|
|
377
|
+
const session = data.session || ws._refacilSession;
|
|
378
|
+
const s = session && state.sessions.get(session);
|
|
379
|
+
const roomName = (s && s.room) || data.room;
|
|
380
|
+
if (!roomName) {
|
|
381
|
+
return send(ws, { type: 'history', messages: [] });
|
|
382
|
+
}
|
|
383
|
+
const messages = storage.readLast(roomName, data.n || 20);
|
|
384
|
+
send(ws, { type: 'history', messages });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function handleInbox(state, ws, data) {
|
|
388
|
+
const session = data.session || ws._refacilSession;
|
|
389
|
+
const s = session && state.sessions.get(session);
|
|
390
|
+
if (!s || !s.room) {
|
|
391
|
+
return send(ws, { type: 'inbox', messages: [], newLastSeen: nowIso() });
|
|
392
|
+
}
|
|
393
|
+
const since = s.lastSeen || '1970-01-01T00:00:00.000Z';
|
|
394
|
+
const newMsgs = storage.readSince(s.room, since, session);
|
|
395
|
+
const newLastSeen = nowIso();
|
|
396
|
+
s.lastSeen = newLastSeen;
|
|
397
|
+
send(ws, { type: 'inbox', messages: newMsgs, newLastSeen });
|
|
398
|
+
persistSessions(state);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function handleWatch(state, ws, data) {
|
|
402
|
+
const session = data.session || ('watcher-' + newId().slice(0, 8));
|
|
403
|
+
const room = data.room || null;
|
|
404
|
+
state.sessions.set(session, {
|
|
405
|
+
ws,
|
|
406
|
+
room: null,
|
|
407
|
+
repo: null,
|
|
408
|
+
lastSeen: null,
|
|
409
|
+
watchOnly: true,
|
|
410
|
+
watchRoom: room,
|
|
411
|
+
});
|
|
412
|
+
ws._refacilSession = session;
|
|
413
|
+
send(ws, { type: 'system', event: 'watching', detail: { session, room } });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function handleStatus(state, ws) {
|
|
417
|
+
const rooms = {};
|
|
418
|
+
for (const [name, members] of state.rooms.entries()) {
|
|
419
|
+
rooms[name] = Array.from(members);
|
|
420
|
+
}
|
|
421
|
+
send(ws, {
|
|
422
|
+
type: 'system',
|
|
423
|
+
event: 'status',
|
|
424
|
+
detail: {
|
|
425
|
+
rooms,
|
|
426
|
+
sessions: Array.from(state.sessions.keys()).filter(
|
|
427
|
+
(n) => !state.sessions.get(n).watchOnly,
|
|
428
|
+
),
|
|
429
|
+
port: state._port,
|
|
430
|
+
pid: process.pid,
|
|
431
|
+
startedAt: state._startedAt,
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function onMessage(state, ws, raw) {
|
|
437
|
+
let data;
|
|
438
|
+
try {
|
|
439
|
+
data = JSON.parse(raw.toString());
|
|
440
|
+
} catch (_) {
|
|
441
|
+
return send(ws, { type: 'system', event: 'error', detail: 'JSON inválido' });
|
|
442
|
+
}
|
|
443
|
+
// Si el cliente declara una sesión existente (no es join/watch), attach el ws
|
|
444
|
+
// para que reciba broadcasts mientras la conexión esté viva.
|
|
445
|
+
if (data.session && data.op !== 'watch' && data.op !== 'join') {
|
|
446
|
+
const s = state.sessions.get(data.session);
|
|
447
|
+
if (s && !s.watchOnly) {
|
|
448
|
+
ws._refacilSession = data.session;
|
|
449
|
+
attachWs(state, data.session, ws);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
switch (data.op) {
|
|
453
|
+
case 'join': return handleJoin(state, ws, data);
|
|
454
|
+
case 'leave': return handleLeave(state, ws, data);
|
|
455
|
+
case 'say': return handleSay(state, ws, data);
|
|
456
|
+
case 'ask': return handleAsk(state, ws, data);
|
|
457
|
+
case 'reply': return handleReply(state, ws, data);
|
|
458
|
+
case 'history': return handleHistory(state, ws, data);
|
|
459
|
+
case 'inbox': return handleInbox(state, ws, data);
|
|
460
|
+
case 'watch': return handleWatch(state, ws, data);
|
|
461
|
+
case 'status': return handleStatus(state, ws);
|
|
462
|
+
case 'ping': return send(ws, { type: 'system', event: 'pong' });
|
|
463
|
+
default:
|
|
464
|
+
return send(ws, { type: 'system', event: 'error', detail: `op desconocida: ${data.op}` });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function onClose(state, ws) {
|
|
469
|
+
detachWs(state, ws);
|
|
470
|
+
const session = ws._refacilSession;
|
|
471
|
+
if (!session) return;
|
|
472
|
+
const s = state.sessions.get(session);
|
|
473
|
+
if (!s) return;
|
|
474
|
+
// Watchers son efímeros: se borran al cerrar WS.
|
|
475
|
+
if (s.watchOnly) {
|
|
476
|
+
state.sessions.delete(session);
|
|
477
|
+
}
|
|
478
|
+
// Sesiones normales persisten en la sala aunque cierren el WS — las skills del
|
|
479
|
+
// CLI abren conexiones cortas (say/ask/reply/history/inbox) y confían en que
|
|
480
|
+
// "estar unido" sobrevive entre invocaciones. Solo `leave` explícito saca.
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const UI_DIR = path.join(__dirname, 'ui');
|
|
484
|
+
const UI_MIME = {
|
|
485
|
+
'.html': 'text/html; charset=utf-8',
|
|
486
|
+
'.css': 'text/css; charset=utf-8',
|
|
487
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
488
|
+
'.svg': 'image/svg+xml',
|
|
489
|
+
'.png': 'image/png',
|
|
490
|
+
'.ico': 'image/x-icon',
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
function serveUi(req, res) {
|
|
494
|
+
let urlPath = req.url.split('?')[0];
|
|
495
|
+
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
|
|
496
|
+
const safePath = urlPath.replace(/^\/+/, '').replace(/\.\./g, '');
|
|
497
|
+
const filePath = path.join(UI_DIR, safePath);
|
|
498
|
+
if (!filePath.startsWith(UI_DIR)) {
|
|
499
|
+
res.writeHead(403);
|
|
500
|
+
res.end('forbidden');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
fs.readFile(filePath, (err, data) => {
|
|
504
|
+
if (err) {
|
|
505
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
506
|
+
res.end('not found');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
510
|
+
const mime = UI_MIME[ext] || 'application/octet-stream';
|
|
511
|
+
res.writeHead(200, { 'content-type': mime });
|
|
512
|
+
res.end(data);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function start() {
|
|
517
|
+
if (!WebSocketServer) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
"Dependencia 'ws' no encontrada. Instala con: npm install -g ws (o npm install ws en el paquete)",
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const state = createState();
|
|
524
|
+
// Restaurar sesiones persistidas para sobrevivir reinicios del broker.
|
|
525
|
+
try {
|
|
526
|
+
const persisted = readSessions();
|
|
527
|
+
for (const [name, meta] of Object.entries(persisted || {})) {
|
|
528
|
+
if (!meta || !meta.room) continue;
|
|
529
|
+
state.sessions.set(name, {
|
|
530
|
+
ws: null,
|
|
531
|
+
repo: meta.repo || null,
|
|
532
|
+
room: meta.room,
|
|
533
|
+
lastSeen: meta.lastSeen || null,
|
|
534
|
+
watchOnly: false,
|
|
535
|
+
});
|
|
536
|
+
if (!state.rooms.has(meta.room)) state.rooms.set(meta.room, new Set());
|
|
537
|
+
state.rooms.get(meta.room).add(name);
|
|
538
|
+
}
|
|
539
|
+
} catch (_) {
|
|
540
|
+
// sin persistencia previa
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const server = http.createServer((req, res) => {
|
|
544
|
+
if (req.method === 'GET') {
|
|
545
|
+
serveUi(req, res);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
res.writeHead(405, { 'content-type': 'text/plain' });
|
|
549
|
+
res.end('method not allowed');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const port = await pickPort(server);
|
|
553
|
+
|
|
554
|
+
const wss = new WebSocketServer({ server });
|
|
555
|
+
wss.on('connection', (ws) => {
|
|
556
|
+
ws.on('message', (raw) => onMessage(state, ws, raw));
|
|
557
|
+
ws.on('close', () => onClose(state, ws));
|
|
558
|
+
ws.on('error', () => {});
|
|
559
|
+
});
|
|
560
|
+
const info = writeBusInfo(port);
|
|
561
|
+
state._port = port;
|
|
562
|
+
state._startedAt = info.startedAt;
|
|
563
|
+
|
|
564
|
+
const shutdown = () => {
|
|
565
|
+
try {
|
|
566
|
+
wss.close();
|
|
567
|
+
server.close();
|
|
568
|
+
} catch (_) {}
|
|
569
|
+
removeBusInfo();
|
|
570
|
+
process.exit(0);
|
|
571
|
+
};
|
|
572
|
+
process.on('SIGINT', shutdown);
|
|
573
|
+
process.on('SIGTERM', shutdown);
|
|
574
|
+
process.on('SIGHUP', shutdown);
|
|
575
|
+
|
|
576
|
+
return { port, pid: process.pid, startedAt: info.startedAt, state, server, wss };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
module.exports = {
|
|
580
|
+
start,
|
|
581
|
+
BUS_INFO_PATH,
|
|
582
|
+
SESSIONS_PATH,
|
|
583
|
+
HOME_DIR,
|
|
584
|
+
PORT_CANDIDATES,
|
|
585
|
+
HOST,
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
if (require.main === module) {
|
|
589
|
+
start()
|
|
590
|
+
.then((info) => {
|
|
591
|
+
process.stdout.write(
|
|
592
|
+
`refacil-bus broker escuchando en ${HOST}:${info.port} (pid ${info.pid})\n`,
|
|
593
|
+
);
|
|
594
|
+
})
|
|
595
|
+
.catch((err) => {
|
|
596
|
+
process.stderr.write(`Error arrancando broker: ${err.message}\n`);
|
|
597
|
+
process.exit(1);
|
|
598
|
+
});
|
|
599
|
+
}
|