tycono 0.1.74 → 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 +1 -1
- package/src/api/src/routes/execute.ts +38 -0
- package/src/api/src/services/wave-multiplexer.ts +268 -0
- package/src/web/dist/assets/index-BcaNcOCL.js +110 -0
- package/src/web/dist/assets/{preview-app-WN7GvXXY.js → preview-app-cOnPQipW.js} +1 -1
- package/src/web/dist/index.html +1 -1
- package/templates/CLAUDE.md.tmpl +10 -2
- package/templates/teams/startup.json +13 -5
- package/src/web/dist/assets/index-HUPb1tEm.js +0 -109
package/package.json
CHANGED
|
@@ -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();
|