tycono 0.1.74-beta.4 → 0.1.75-beta.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.74-beta.4",
3
+ "version": "0.1.75-beta.0",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,12 @@ import { jobManager, type Job } from '../services/job-manager.js';
17
17
  import { ActivityStream, type ActivityEvent, type ActivitySubscriber } from '../services/activity-stream.js';
18
18
  import { earnCoinsInternal } from './coins.js';
19
19
  import { appendFollowUpToWave } from '../services/wave-tracker.js';
20
+ import { waveMultiplexer } from '../services/wave-multiplexer.js';
21
+
22
+ /* ─── SSE-003: Auto-attach child dispatch jobs to wave multiplexer ── */
23
+ jobManager.onJobCreated((job) => {
24
+ waveMultiplexer.onJobCreated(job);
25
+ });
20
26
 
21
27
  /* ─── Runner — lazy, re-created when engine changes ── */
22
28
 
@@ -34,6 +40,13 @@ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): vo
34
40
  const url = req.url ?? '';
35
41
  const method = req.method ?? '';
36
42
 
43
+ // ── /api/waves/:waveId/stream — SSE multiplexed wave stream ──
44
+ const waveStreamMatch = url.match(/^\/api\/waves\/([^/]+)\/stream/);
45
+ if (method === 'GET' && waveStreamMatch) {
46
+ handleWaveStream(waveStreamMatch[1], url, res, req);
47
+ return;
48
+ }
49
+
37
50
  // ── /api/waves/save ──
38
51
  if (method === 'POST' && url === '/api/waves/save') {
39
52
  readBody(req).then((body) => handleSaveWave(body, res));
@@ -186,6 +199,9 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
186
199
  });
187
200
  jobIds.push(job.id);
188
201
 
202
+ // SSE-001: Register wave job with multiplexer
203
+ waveMultiplexer.registerJob(waveId, job);
204
+
189
205
  // Add a role message (will be updated as execution progresses)
190
206
  const roleMsg: Message = {
191
207
  id: `msg-${Date.now() + 1}-role-${cRole}`,
@@ -377,6 +393,28 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
377
393
  jsonResponse(res, 200, { ok: true, path: `operations/waves/${baseName}.json` });
378
394
  }
379
395
 
396
+ /* ─── GET /api/waves/:waveId/stream — SSE multiplexed wave stream (SSE-002) ── */
397
+
398
+ function handleWaveStream(waveId: string, url: string, res: ServerResponse, req: IncomingMessage): void {
399
+ const fromMatch = url.match(/[?&]from=(\d+)/);
400
+ const fromWaveSeq = fromMatch ? parseInt(fromMatch[1], 10) : 0;
401
+
402
+ // Check if wave has any registered jobs
403
+ const jobIds = waveMultiplexer.getWaveJobIds(waveId);
404
+ if (jobIds.length === 0) {
405
+ res.writeHead(404, { 'Content-Type': 'application/json' });
406
+ res.end(JSON.stringify({ error: `No jobs found for wave: ${waveId}` }));
407
+ return;
408
+ }
409
+
410
+ // attach() handles everything: replay history + subscribe to live events
411
+ const client = waveMultiplexer.attach(waveId, res as any, fromWaveSeq);
412
+
413
+ req.on('close', () => {
414
+ waveMultiplexer.detach(waveId, client);
415
+ });
416
+ }
417
+
380
418
  /* ═══════════════════════════════════════════════
381
419
  Legacy /api/exec/* — kept for backward compat
382
420
  Now internally delegates to JobManager where possible
@@ -0,0 +1,268 @@
1
+ import { ActivityStream, type ActivityEvent, type ActivitySubscriber } from './activity-stream.js';
2
+ import type { Job } from './job-manager.js';
3
+ import type { Response } from 'express';
4
+
5
+ /* ─── Types ──────────────────────────────── */
6
+
7
+ export interface WaveStreamEnvelope {
8
+ /** Wave-level sequence (across all roles) */
9
+ waveSeq: number;
10
+ /** Session this event belongs to */
11
+ sessionId: string;
12
+ /** Original ActivityEvent (unchanged) */
13
+ event: ActivityEvent;
14
+ }
15
+
16
+ interface AttachedJob {
17
+ jobId: string;
18
+ sessionId: string;
19
+ roleId: string;
20
+ unsubscribe: () => void;
21
+ }
22
+
23
+ interface WaveStreamClient {
24
+ res: Response;
25
+ waveSeq: number;
26
+ attachedJobs: Map<string, AttachedJob>; // jobId → attachment
27
+ /** Set of event keys already sent (to avoid duplicates) */
28
+ sentEvents: Set<string>;
29
+ heartbeat: ReturnType<typeof setInterval>;
30
+ closed: boolean;
31
+ }
32
+
33
+ /* ─── WaveMultiplexer ────────────────────── */
34
+
35
+ class WaveMultiplexer {
36
+ /** waveId → set of connected SSE clients */
37
+ private clients = new Map<string, Set<WaveStreamClient>>();
38
+ /** waveId → Map<jobId, Job> for live jobs */
39
+ private waveJobs = new Map<string, Map<string, Job>>();
40
+
41
+ /**
42
+ * Register a job as belonging to a wave + auto-attach to existing clients.
43
+ */
44
+ registerJob(waveId: string, job: Job): void {
45
+ if (!this.waveJobs.has(waveId)) {
46
+ this.waveJobs.set(waveId, new Map());
47
+ }
48
+ this.waveJobs.get(waveId)!.set(job.id, job);
49
+
50
+ console.log(`[WaveMux] registerJob wave=${waveId} job=${job.id} role=${job.roleId}`);
51
+
52
+ // Auto-attach to all existing clients for this wave
53
+ const clients = this.clients.get(waveId);
54
+ if (clients) {
55
+ for (const client of clients) {
56
+ if (!client.closed) {
57
+ this.subscribeJobToClient(client, job, true);
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Connect a new SSE client to a wave stream.
65
+ * Replays all historical events + subscribes to live events.
66
+ */
67
+ attach(waveId: string, res: Response, fromWaveSeq: number): WaveStreamClient {
68
+ // SSE headers
69
+ res.writeHead(200, {
70
+ 'Content-Type': 'text/event-stream',
71
+ 'Cache-Control': 'no-cache',
72
+ 'Connection': 'keep-alive',
73
+ 'X-Accel-Buffering': 'no',
74
+ });
75
+ res.flushHeaders();
76
+
77
+ const client: WaveStreamClient = {
78
+ res,
79
+ waveSeq: 0,
80
+ attachedJobs: new Map(),
81
+ sentEvents: new Set(),
82
+ heartbeat: setInterval(() => {
83
+ if (client.closed || res.destroyed || res.writableEnded) {
84
+ clearInterval(client.heartbeat);
85
+ return;
86
+ }
87
+ try { res.write(': heartbeat\n\n'); } catch { /* ignore */ }
88
+ }, 15_000),
89
+ closed: false,
90
+ };
91
+
92
+ if (!this.clients.has(waveId)) {
93
+ this.clients.set(waveId, new Set());
94
+ }
95
+ this.clients.get(waveId)!.add(client);
96
+
97
+ // Replay + subscribe for all known jobs
98
+ const jobs = this.waveJobs.get(waveId);
99
+ if (jobs) {
100
+ // Phase 1: Replay all historical events sorted by timestamp
101
+ const allEvents: { event: ActivityEvent; sessionId: string }[] = [];
102
+
103
+ for (const [, job] of jobs) {
104
+ const events = ActivityStream.readFrom(job.id, 0);
105
+ const sessionId = job.sessionId ?? '';
106
+ for (const event of events) {
107
+ allEvents.push({ event, sessionId });
108
+ }
109
+ }
110
+
111
+ allEvents.sort((a, b) => a.event.ts.localeCompare(b.event.ts));
112
+
113
+ for (const item of allEvents) {
114
+ const waveSeq = client.waveSeq++;
115
+ if (waveSeq < fromWaveSeq) continue;
116
+
117
+ const key = `${item.event.roleId}:${item.event.seq}`;
118
+ client.sentEvents.add(key);
119
+
120
+ sendSSE(client, 'wave:event', {
121
+ waveSeq,
122
+ sessionId: item.sessionId,
123
+ event: item.event,
124
+ } as WaveStreamEnvelope);
125
+ }
126
+
127
+ // Phase 2: Subscribe to live events for running jobs
128
+ for (const [, job] of jobs) {
129
+ if (job.status === 'running' || job.status === 'awaiting_input') {
130
+ this.subscribeJobToClient(client, job, false);
131
+ }
132
+ }
133
+ }
134
+
135
+ console.log(`[WaveMux] attach wave=${waveId} jobs=${jobs?.size ?? 0} from=${fromWaveSeq}`);
136
+ return client;
137
+ }
138
+
139
+ /**
140
+ * Subscribe to a job's live events on a client.
141
+ * @param sendNotification - if true, send wave:role-attached + replay history (for late-joining jobs)
142
+ */
143
+ private subscribeJobToClient(client: WaveStreamClient, job: Job, sendNotification: boolean): void {
144
+ if (client.attachedJobs.has(job.id)) return;
145
+
146
+ const sessionId = job.sessionId ?? '';
147
+ const roleId = job.roleId;
148
+
149
+ if (sendNotification) {
150
+ // Notify client about new role joining
151
+ sendSSE(client, 'wave:role-attached', {
152
+ sessionId,
153
+ roleId,
154
+ jobId: job.id,
155
+ parentJobId: job.parentJobId,
156
+ });
157
+
158
+ // Replay this job's history (for late-joining child jobs)
159
+ const events = ActivityStream.readFrom(job.id, 0);
160
+ for (const event of events) {
161
+ const key = `${event.roleId}:${event.seq}`;
162
+ if (client.sentEvents.has(key)) continue;
163
+ client.sentEvents.add(key);
164
+
165
+ const waveSeq = client.waveSeq++;
166
+ sendSSE(client, 'wave:event', {
167
+ waveSeq,
168
+ sessionId,
169
+ event,
170
+ } as WaveStreamEnvelope);
171
+ }
172
+ }
173
+
174
+ // Subscribe to live events
175
+ const subscriber: ActivitySubscriber = (event: ActivityEvent) => {
176
+ if (client.closed) return;
177
+
178
+ const key = `${event.roleId}:${event.seq}`;
179
+ if (client.sentEvents.has(key)) return; // skip duplicate
180
+ client.sentEvents.add(key);
181
+
182
+ const waveSeq = client.waveSeq++;
183
+ sendSSE(client, 'wave:event', {
184
+ waveSeq,
185
+ sessionId,
186
+ event,
187
+ } as WaveStreamEnvelope);
188
+
189
+ if (event.type === 'job:done' || event.type === 'job:error') {
190
+ sendSSE(client, 'wave:role-detached', {
191
+ sessionId,
192
+ roleId,
193
+ reason: event.type === 'job:done' ? 'done' : 'error',
194
+ });
195
+ }
196
+ };
197
+
198
+ job.stream.subscribe(subscriber);
199
+
200
+ client.attachedJobs.set(job.id, {
201
+ jobId: job.id,
202
+ sessionId,
203
+ roleId,
204
+ unsubscribe: () => job.stream.unsubscribe(subscriber),
205
+ });
206
+
207
+ console.log(`[WaveMux] subscribed job=${job.id} role=${roleId} notify=${sendNotification}`);
208
+ }
209
+
210
+ /**
211
+ * Called when any new job is created — check if it belongs to a wave.
212
+ */
213
+ onJobCreated(job: Job): void {
214
+ // Find wave by tracing parentJobId chain
215
+ const waveId = this.findWaveIdForJob(job.id) ?? this.findWaveIdForJob(job.parentJobId ?? '');
216
+ if (!waveId) return;
217
+
218
+ // Register + auto-attach to clients
219
+ this.registerJob(waveId, job);
220
+ }
221
+
222
+ /**
223
+ * Disconnect a client from a wave stream
224
+ */
225
+ detach(waveId: string, client: WaveStreamClient): void {
226
+ client.closed = true;
227
+ clearInterval(client.heartbeat);
228
+
229
+ for (const [, attached] of client.attachedJobs) {
230
+ attached.unsubscribe();
231
+ }
232
+ client.attachedJobs.clear();
233
+ client.sentEvents.clear();
234
+
235
+ const clientSet = this.clients.get(waveId);
236
+ if (clientSet) {
237
+ clientSet.delete(client);
238
+ if (clientSet.size === 0) {
239
+ this.clients.delete(waveId);
240
+ }
241
+ }
242
+ }
243
+
244
+ private findWaveIdForJob(jobId: string): string | undefined {
245
+ for (const [waveId, jobs] of this.waveJobs) {
246
+ if (jobs.has(jobId)) return waveId;
247
+ }
248
+ return undefined;
249
+ }
250
+
251
+ getWaveJobIds(waveId: string): string[] {
252
+ const jobs = this.waveJobs.get(waveId);
253
+ return jobs ? Array.from(jobs.keys()) : [];
254
+ }
255
+ }
256
+
257
+ /* ─── Helpers ────────────────────────────── */
258
+
259
+ function sendSSE(client: WaveStreamClient, event: string, data: unknown): void {
260
+ if (client.closed || client.res.destroyed || client.res.writableEnded) return;
261
+ try {
262
+ client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
263
+ } catch { /* ignore write errors */ }
264
+ }
265
+
266
+ /* ─── Export singleton ───────────────────── */
267
+
268
+ export const waveMultiplexer = new WaveMultiplexer();