@saga-ai/dashboard 3.0.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/dist/cli.cjs ADDED
@@ -0,0 +1,1736 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_node_fs4 = require("node:fs");
28
+ var import_node_path10 = require("node:path");
29
+ var import_node_process6 = __toESM(require("node:process"), 1);
30
+ var import_commander = require("commander");
31
+
32
+ // src/commands/dashboard.ts
33
+ var import_node_process4 = __toESM(require("node:process"), 1);
34
+
35
+ // src/server/index.ts
36
+ var import_node_http = require("node:http");
37
+ var import_node_path8 = require("node:path");
38
+ var import_express3 = __toESM(require("express"), 1);
39
+
40
+ // src/server/routes.ts
41
+ var import_node_path4 = require("node:path");
42
+ var import_express2 = require("express");
43
+
44
+ // src/server/parser.ts
45
+ var import_promises2 = require("node:fs/promises");
46
+ var import_node_path2 = require("node:path");
47
+ var import_gray_matter2 = __toESM(require("gray-matter"), 1);
48
+
49
+ // src/utils/saga-scanner.ts
50
+ var import_promises = require("node:fs/promises");
51
+ var import_node_path = require("node:path");
52
+ var import_gray_matter = __toESM(require("gray-matter"), 1);
53
+ var EPIC_TITLE_PATTERN = /^#\s+(.+)$/m;
54
+ var STORY_MD_SUFFIX_PATTERN = /\/story\.md$/;
55
+ async function isDirectory(path) {
56
+ try {
57
+ const stats = await (0, import_promises.stat)(path);
58
+ return stats.isDirectory();
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+ async function fileExists(path) {
64
+ try {
65
+ await (0, import_promises.stat)(path);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+ function extractEpicTitle(content) {
72
+ const match = content.match(EPIC_TITLE_PATTERN);
73
+ return match ? match[1].trim() : null;
74
+ }
75
+ async function parseStoryFile(storyPath, epicSlug, options = {}) {
76
+ try {
77
+ const content = await (0, import_promises.readFile)(storyPath, "utf-8");
78
+ const storyDir = storyPath.replace(STORY_MD_SUFFIX_PATTERN, "");
79
+ const dirName = storyDir.split("/").pop() || "unknown";
80
+ const parsed = (0, import_gray_matter.default)(content);
81
+ const frontmatter = parsed.data;
82
+ const body = parsed.content;
83
+ const journalPath = (0, import_node_path.join)(storyDir, "journal.md");
84
+ const hasJournal = await fileExists(journalPath);
85
+ return {
86
+ slug: frontmatter.id || frontmatter.slug || dirName,
87
+ title: frontmatter.title || dirName,
88
+ status: frontmatter.status || "ready",
89
+ epicSlug,
90
+ storyPath,
91
+ worktreePath: options.worktreePath,
92
+ journalPath: hasJournal ? journalPath : void 0,
93
+ archived: options.archived,
94
+ frontmatter,
95
+ body
96
+ };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+ async function scanWorktrees(sagaRoot) {
102
+ const worktreesDir = (0, import_node_path.join)(sagaRoot, ".saga", "worktrees");
103
+ if (!await isDirectory(worktreesDir)) {
104
+ return [];
105
+ }
106
+ const epicEntries = await (0, import_promises.readdir)(worktreesDir);
107
+ const epicPromises = epicEntries.map(async (epicSlug) => {
108
+ const epicWorktreesDir = (0, import_node_path.join)(worktreesDir, epicSlug);
109
+ if (!await isDirectory(epicWorktreesDir)) {
110
+ return [];
111
+ }
112
+ const storyEntries = await (0, import_promises.readdir)(epicWorktreesDir);
113
+ const storyPromises = storyEntries.map(async (storySlug) => {
114
+ const worktreePath = (0, import_node_path.join)(epicWorktreesDir, storySlug);
115
+ if (!await isDirectory(worktreePath)) {
116
+ return null;
117
+ }
118
+ const storyPath = (0, import_node_path.join)(
119
+ worktreePath,
120
+ ".saga",
121
+ "epics",
122
+ epicSlug,
123
+ "stories",
124
+ storySlug,
125
+ "story.md"
126
+ );
127
+ return await parseStoryFile(storyPath, epicSlug, { worktreePath });
128
+ });
129
+ const stories = await Promise.all(storyPromises);
130
+ return stories.filter((story) => story !== null);
131
+ });
132
+ const epicStories = await Promise.all(epicPromises);
133
+ return epicStories.flat();
134
+ }
135
+ async function scanEpicsStories(sagaRoot) {
136
+ const epicsDir = (0, import_node_path.join)(sagaRoot, ".saga", "epics");
137
+ if (!await isDirectory(epicsDir)) {
138
+ return [];
139
+ }
140
+ const epicEntries = await (0, import_promises.readdir)(epicsDir);
141
+ const epicPromises = epicEntries.map(async (epicSlug) => {
142
+ const storiesDir = (0, import_node_path.join)(epicsDir, epicSlug, "stories");
143
+ if (!await isDirectory(storiesDir)) {
144
+ return [];
145
+ }
146
+ const storyEntries = await (0, import_promises.readdir)(storiesDir);
147
+ const storyPromises = storyEntries.map(async (storySlug) => {
148
+ const storyDir = (0, import_node_path.join)(storiesDir, storySlug);
149
+ if (!await isDirectory(storyDir)) {
150
+ return null;
151
+ }
152
+ const storyPath = (0, import_node_path.join)(storyDir, "story.md");
153
+ return await parseStoryFile(storyPath, epicSlug);
154
+ });
155
+ const stories = await Promise.all(storyPromises);
156
+ return stories.filter((story) => story !== null);
157
+ });
158
+ const epicStories = await Promise.all(epicPromises);
159
+ return epicStories.flat();
160
+ }
161
+ async function scanArchive(sagaRoot) {
162
+ const archiveDir = (0, import_node_path.join)(sagaRoot, ".saga", "archive");
163
+ if (!await isDirectory(archiveDir)) {
164
+ return [];
165
+ }
166
+ const epicEntries = await (0, import_promises.readdir)(archiveDir);
167
+ const epicPromises = epicEntries.map(async (epicSlug) => {
168
+ const epicArchiveDir = (0, import_node_path.join)(archiveDir, epicSlug);
169
+ if (!await isDirectory(epicArchiveDir)) {
170
+ return [];
171
+ }
172
+ const storyEntries = await (0, import_promises.readdir)(epicArchiveDir);
173
+ const storyPromises = storyEntries.map(async (storySlug) => {
174
+ const storyDir = (0, import_node_path.join)(epicArchiveDir, storySlug);
175
+ if (!await isDirectory(storyDir)) {
176
+ return null;
177
+ }
178
+ const storyPath = (0, import_node_path.join)(storyDir, "story.md");
179
+ return await parseStoryFile(storyPath, epicSlug, { archived: true });
180
+ });
181
+ const stories = await Promise.all(storyPromises);
182
+ return stories.filter((story) => story !== null);
183
+ });
184
+ const epicStories = await Promise.all(epicPromises);
185
+ return epicStories.flat();
186
+ }
187
+ async function scanAllStories(sagaRoot) {
188
+ const [worktreeStories, epicsStories, archivedStories] = await Promise.all([
189
+ scanWorktrees(sagaRoot),
190
+ scanEpicsStories(sagaRoot),
191
+ scanArchive(sagaRoot)
192
+ ]);
193
+ const seen = /* @__PURE__ */ new Set();
194
+ const result = [];
195
+ for (const story of worktreeStories) {
196
+ const key = `${story.epicSlug}/${story.slug}`;
197
+ if (!seen.has(key)) {
198
+ seen.add(key);
199
+ result.push(story);
200
+ }
201
+ }
202
+ for (const story of epicsStories) {
203
+ const key = `${story.epicSlug}/${story.slug}`;
204
+ if (!seen.has(key)) {
205
+ seen.add(key);
206
+ result.push(story);
207
+ }
208
+ }
209
+ for (const story of archivedStories) {
210
+ const key = `${story.epicSlug}/${story.slug}`;
211
+ if (!seen.has(key)) {
212
+ seen.add(key);
213
+ result.push(story);
214
+ }
215
+ }
216
+ return result;
217
+ }
218
+ async function scanEpics(sagaRoot) {
219
+ const epicsDir = (0, import_node_path.join)(sagaRoot, ".saga", "epics");
220
+ if (!await isDirectory(epicsDir)) {
221
+ return [];
222
+ }
223
+ const epicEntries = await (0, import_promises.readdir)(epicsDir);
224
+ const epicPromises = epicEntries.map(async (epicSlug) => {
225
+ const epicPath = (0, import_node_path.join)(epicsDir, epicSlug);
226
+ if (!await isDirectory(epicPath)) {
227
+ return null;
228
+ }
229
+ const epicMdPath = (0, import_node_path.join)(epicPath, "epic.md");
230
+ let content = "";
231
+ let title = epicSlug;
232
+ try {
233
+ content = await (0, import_promises.readFile)(epicMdPath, "utf-8");
234
+ title = extractEpicTitle(content) || epicSlug;
235
+ } catch {
236
+ }
237
+ return {
238
+ slug: epicSlug,
239
+ title,
240
+ epicPath,
241
+ epicMdPath,
242
+ content
243
+ };
244
+ });
245
+ const epics = await Promise.all(epicPromises);
246
+ return epics.filter((epic) => epic !== null);
247
+ }
248
+
249
+ // src/server/parser.ts
250
+ var STORY_MD_SUFFIX_PATTERN2 = /\/story\.md$/;
251
+ var JOURNAL_SECTION_PATTERN = /^##\s+/m;
252
+ function toApiStoryStatus(status) {
253
+ return status === "in_progress" ? "inProgress" : status;
254
+ }
255
+ function toApiTaskStatus(status) {
256
+ return status === "in_progress" ? "inProgress" : status;
257
+ }
258
+ function validateStatus(status) {
259
+ const validStatuses = ["ready", "in_progress", "blocked", "completed"];
260
+ if (typeof status === "string" && validStatuses.includes(status)) {
261
+ return status;
262
+ }
263
+ return "ready";
264
+ }
265
+ function validateTaskStatus(status) {
266
+ const validStatuses = ["pending", "in_progress", "completed"];
267
+ if (typeof status === "string" && validStatuses.includes(status)) {
268
+ return status;
269
+ }
270
+ return "pending";
271
+ }
272
+ function parseTasks(tasks) {
273
+ if (!Array.isArray(tasks)) {
274
+ return [];
275
+ }
276
+ return tasks.filter((t) => typeof t === "object" && t !== null).map((t) => ({
277
+ id: typeof t.id === "string" ? t.id : "unknown",
278
+ title: typeof t.title === "string" ? t.title : "Unknown Task",
279
+ status: toApiTaskStatus(validateTaskStatus(t.status))
280
+ }));
281
+ }
282
+ async function toStoryDetail(story, sagaRoot) {
283
+ let tasks = [];
284
+ try {
285
+ const content = await (0, import_promises2.readFile)(story.storyPath, "utf-8");
286
+ const parsed = (0, import_gray_matter2.default)(content);
287
+ tasks = parseTasks(parsed.data.tasks);
288
+ } catch {
289
+ tasks = parseTasks(story.frontmatter.tasks);
290
+ }
291
+ return {
292
+ slug: story.slug,
293
+ epicSlug: story.epicSlug,
294
+ title: story.title,
295
+ status: toApiStoryStatus(validateStatus(story.status)),
296
+ tasks,
297
+ content: story.body || void 0,
298
+ archived: story.archived,
299
+ paths: {
300
+ storyMd: (0, import_node_path2.relative)(sagaRoot, story.storyPath),
301
+ ...story.journalPath ? { journalMd: (0, import_node_path2.relative)(sagaRoot, story.journalPath) } : {},
302
+ ...story.worktreePath ? { worktree: (0, import_node_path2.relative)(sagaRoot, story.worktreePath) } : {}
303
+ }
304
+ };
305
+ }
306
+ async function buildEpic(scannedEpic, epicStories, sagaRoot) {
307
+ const stories = await Promise.all(epicStories.map((s) => toStoryDetail(s, sagaRoot)));
308
+ const storyCounts = {
309
+ total: stories.length,
310
+ ready: stories.filter((s) => s.status === "ready").length,
311
+ inProgress: stories.filter((s) => s.status === "inProgress").length,
312
+ blocked: stories.filter((s) => s.status === "blocked").length,
313
+ completed: stories.filter((s) => s.status === "completed").length
314
+ };
315
+ return {
316
+ slug: scannedEpic.slug,
317
+ title: scannedEpic.title,
318
+ content: scannedEpic.content,
319
+ storyCounts,
320
+ stories,
321
+ path: (0, import_node_path2.relative)(sagaRoot, scannedEpic.epicPath)
322
+ };
323
+ }
324
+ async function parseStory(storyPath, epicSlug) {
325
+ const { join: join10 } = await import("node:path");
326
+ const { stat: stat4 } = await import("node:fs/promises");
327
+ let content;
328
+ try {
329
+ content = await (0, import_promises2.readFile)(storyPath, "utf-8");
330
+ } catch {
331
+ return null;
332
+ }
333
+ const storyDir = storyPath.replace(STORY_MD_SUFFIX_PATTERN2, "");
334
+ const dirName = storyDir.split("/").pop() || "unknown";
335
+ let frontmatter = {};
336
+ let bodyContent = "";
337
+ try {
338
+ const parsed = (0, import_gray_matter2.default)(content);
339
+ frontmatter = parsed.data;
340
+ bodyContent = parsed.content;
341
+ } catch {
342
+ }
343
+ const slug = frontmatter.id || frontmatter.slug || dirName;
344
+ const title = frontmatter.title || dirName;
345
+ const status = toApiStoryStatus(validateStatus(frontmatter.status));
346
+ const tasks = parseTasks(frontmatter.tasks);
347
+ const journalPath = join10(storyDir, "journal.md");
348
+ let hasJournal = false;
349
+ try {
350
+ await stat4(journalPath);
351
+ hasJournal = true;
352
+ } catch {
353
+ }
354
+ return {
355
+ slug,
356
+ epicSlug,
357
+ title,
358
+ status,
359
+ tasks,
360
+ content: bodyContent || void 0,
361
+ paths: {
362
+ storyMd: storyPath,
363
+ ...hasJournal ? { journalMd: journalPath } : {}
364
+ }
365
+ };
366
+ }
367
+ async function parseJournal(journalPath) {
368
+ try {
369
+ const content = await (0, import_promises2.readFile)(journalPath, "utf-8");
370
+ const entries = [];
371
+ const sections = content.split(JOURNAL_SECTION_PATTERN).slice(1);
372
+ for (const section of sections) {
373
+ const lines = section.split("\n");
374
+ const headerLine = lines[0] || "";
375
+ const sectionContent = lines.slice(1).join("\n").trim();
376
+ if (headerLine.toLowerCase().startsWith("session:")) {
377
+ const timestamp = headerLine.substring("session:".length).trim();
378
+ entries.push({
379
+ timestamp,
380
+ type: "session",
381
+ title: `Session ${timestamp}`,
382
+ content: sectionContent
383
+ });
384
+ } else if (headerLine.toLowerCase().startsWith("blocker:")) {
385
+ const title = headerLine.substring("blocker:".length).trim();
386
+ entries.push({
387
+ timestamp: "",
388
+ // Blockers may not have timestamps
389
+ type: "blocker",
390
+ title,
391
+ content: sectionContent
392
+ });
393
+ } else if (headerLine.toLowerCase().startsWith("resolution:")) {
394
+ const title = headerLine.substring("resolution:".length).trim();
395
+ entries.push({
396
+ timestamp: "",
397
+ // Resolutions may not have timestamps
398
+ type: "resolution",
399
+ title,
400
+ content: sectionContent
401
+ });
402
+ }
403
+ }
404
+ return entries;
405
+ } catch {
406
+ return [];
407
+ }
408
+ }
409
+ async function scanSagaDirectory(sagaRoot) {
410
+ const [scannedEpics, scannedStories] = await Promise.all([
411
+ scanEpics(sagaRoot),
412
+ scanAllStories(sagaRoot)
413
+ ]);
414
+ const storiesByEpic = /* @__PURE__ */ new Map();
415
+ for (const story of scannedStories) {
416
+ const existing = storiesByEpic.get(story.epicSlug) || [];
417
+ existing.push(story);
418
+ storiesByEpic.set(story.epicSlug, existing);
419
+ }
420
+ const epicPromises = scannedEpics.map((scannedEpic) => {
421
+ const epicStories = storiesByEpic.get(scannedEpic.slug) || [];
422
+ return buildEpic(scannedEpic, epicStories, sagaRoot);
423
+ });
424
+ return Promise.all(epicPromises);
425
+ }
426
+
427
+ // src/server/session-routes.ts
428
+ var import_express = require("express");
429
+
430
+ // src/lib/sessions.ts
431
+ var import_node_child_process = require("node:child_process");
432
+ var import_node_fs = require("node:fs");
433
+ var import_promises3 = require("node:fs/promises");
434
+ var import_node_path3 = require("node:path");
435
+ var import_node_process = __toESM(require("node:process"), 1);
436
+ var SESSION_NAME_PARTS_COUNT = 4;
437
+ var PREVIEW_LINES_COUNT = 5;
438
+ var PREVIEW_MAX_LENGTH = 500;
439
+ var SESSION_NAME_PATTERN = /^(saga__[a-z0-9_-]+):/;
440
+ var OUTPUT_DIR = "/tmp/saga-sessions";
441
+ async function extractFileTimestamps(outputFile, status) {
442
+ try {
443
+ const stats = await (0, import_promises3.stat)(outputFile);
444
+ return {
445
+ startTime: stats.birthtime,
446
+ endTime: status === "completed" ? stats.mtime : void 0
447
+ };
448
+ } catch {
449
+ return { startTime: /* @__PURE__ */ new Date() };
450
+ }
451
+ }
452
+ async function generateOutputPreview(outputFile) {
453
+ try {
454
+ const content = await (0, import_promises3.readFile)(outputFile, "utf-8");
455
+ const lines = content.split("\n").filter((line) => line.length > 0);
456
+ if (lines.length === 0) {
457
+ return void 0;
458
+ }
459
+ const lastLines = lines.slice(-PREVIEW_LINES_COUNT);
460
+ let preview = lastLines.join("\n");
461
+ if (preview.length > PREVIEW_MAX_LENGTH) {
462
+ const truncated = preview.slice(0, PREVIEW_MAX_LENGTH);
463
+ const lastNewline = truncated.lastIndexOf("\n");
464
+ preview = lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated;
465
+ }
466
+ return preview;
467
+ } catch {
468
+ return void 0;
469
+ }
470
+ }
471
+ function listSessions() {
472
+ const result = (0, import_node_child_process.spawnSync)("tmux", ["ls"], { encoding: "utf-8" });
473
+ if (result.status !== 0) {
474
+ return [];
475
+ }
476
+ const sessions = [];
477
+ const lines = result.stdout.trim().split("\n");
478
+ for (const line of lines) {
479
+ const match = line.match(SESSION_NAME_PATTERN);
480
+ if (match) {
481
+ const name = match[1];
482
+ sessions.push({
483
+ name,
484
+ status: "running",
485
+ // If it shows up in tmux ls, it's running
486
+ outputFile: (0, import_node_path3.join)(OUTPUT_DIR, `${name}.out`)
487
+ });
488
+ }
489
+ }
490
+ return sessions;
491
+ }
492
+ function getSessionStatus(sessionName) {
493
+ const result = (0, import_node_child_process.spawnSync)("tmux", ["has-session", "-t", sessionName], {
494
+ encoding: "utf-8"
495
+ });
496
+ return {
497
+ running: result.status === 0
498
+ };
499
+ }
500
+ function streamLogs(sessionName) {
501
+ const outputFile = (0, import_node_path3.join)(OUTPUT_DIR, `${sessionName}.out`);
502
+ if (!(0, import_node_fs.existsSync)(outputFile)) {
503
+ throw new Error(`Output file not found: ${outputFile}`);
504
+ }
505
+ return new Promise((resolve, reject) => {
506
+ const child = (0, import_node_child_process.spawn)("tail", ["-f", outputFile], {
507
+ stdio: ["ignore", "pipe", "pipe"]
508
+ });
509
+ child.stdout?.on("data", (chunk) => {
510
+ import_node_process.default.stdout.write(chunk);
511
+ });
512
+ child.stderr?.on("data", (chunk) => {
513
+ import_node_process.default.stderr.write(chunk);
514
+ });
515
+ child.on("error", (err) => {
516
+ reject(new Error(`Failed to stream logs: ${err.message}`));
517
+ });
518
+ child.on("close", (_code) => {
519
+ resolve();
520
+ });
521
+ const sigintHandler = () => {
522
+ child.kill("SIGTERM");
523
+ import_node_process.default.removeListener("SIGINT", sigintHandler);
524
+ };
525
+ import_node_process.default.on("SIGINT", sigintHandler);
526
+ });
527
+ }
528
+ function parseSessionName(name) {
529
+ if (!name?.startsWith("saga__")) {
530
+ return null;
531
+ }
532
+ const parts = name.split("__");
533
+ if (parts.length !== SESSION_NAME_PARTS_COUNT) {
534
+ return null;
535
+ }
536
+ const [, epicSlug, storySlug, pid] = parts;
537
+ if (!(epicSlug && storySlug && pid)) {
538
+ return null;
539
+ }
540
+ return {
541
+ epicSlug,
542
+ storySlug
543
+ };
544
+ }
545
+ async function buildSessionInfo(name, status) {
546
+ const parsed = parseSessionName(name);
547
+ if (!parsed) {
548
+ return null;
549
+ }
550
+ const outputFile = (0, import_node_path3.join)(OUTPUT_DIR, `${name}.out`);
551
+ const outputAvailable = (0, import_node_fs.existsSync)(outputFile);
552
+ let startTime = /* @__PURE__ */ new Date();
553
+ let endTime;
554
+ let outputPreview;
555
+ if (outputAvailable) {
556
+ const timestamps = await extractFileTimestamps(outputFile, status);
557
+ startTime = timestamps.startTime;
558
+ endTime = timestamps.endTime;
559
+ outputPreview = await generateOutputPreview(outputFile);
560
+ }
561
+ return {
562
+ name,
563
+ epicSlug: parsed.epicSlug,
564
+ storySlug: parsed.storySlug,
565
+ status,
566
+ outputFile,
567
+ outputAvailable,
568
+ startTime,
569
+ endTime,
570
+ outputPreview
571
+ };
572
+ }
573
+
574
+ // src/lib/session-polling.ts
575
+ var POLLING_INTERVAL_MS = 3e3;
576
+ var pollingInterval = null;
577
+ var currentSessions = [];
578
+ var isFirstPoll = true;
579
+ function createSessionMap(sessions) {
580
+ return new Map(sessions.map((s) => [s.name, s]));
581
+ }
582
+ function hasSessionSetChanged(newMap, currentMap) {
583
+ for (const name of newMap.keys()) {
584
+ if (!currentMap.has(name)) {
585
+ return true;
586
+ }
587
+ }
588
+ for (const name of currentMap.keys()) {
589
+ if (!newMap.has(name)) {
590
+ return true;
591
+ }
592
+ }
593
+ return false;
594
+ }
595
+ function hasSessionPropertiesChanged(newMap, currentMap) {
596
+ for (const [name, newSession] of newMap) {
597
+ const currentSession = currentMap.get(name);
598
+ if (!currentSession) {
599
+ continue;
600
+ }
601
+ if (currentSession.status !== newSession.status) {
602
+ return true;
603
+ }
604
+ if (currentSession.outputPreview !== newSession.outputPreview) {
605
+ return true;
606
+ }
607
+ }
608
+ return false;
609
+ }
610
+ function detectChanges(newSessions) {
611
+ if (isFirstPoll) {
612
+ return true;
613
+ }
614
+ if (newSessions.length !== currentSessions.length) {
615
+ return true;
616
+ }
617
+ const newSessionMap = createSessionMap(newSessions);
618
+ const currentSessionMap = createSessionMap(currentSessions);
619
+ if (hasSessionSetChanged(newSessionMap, currentSessionMap)) {
620
+ return true;
621
+ }
622
+ return hasSessionPropertiesChanged(newSessionMap, currentSessionMap);
623
+ }
624
+ async function buildSessionInfoSafe(sessionName, status) {
625
+ try {
626
+ return await buildSessionInfo(sessionName, status);
627
+ } catch {
628
+ return null;
629
+ }
630
+ }
631
+ async function discoverSessions() {
632
+ const rawSessions = listSessions();
633
+ const sessionPromises = rawSessions.map((session) => {
634
+ const statusResult = getSessionStatus(session.name);
635
+ const status = statusResult.running ? "running" : "completed";
636
+ return buildSessionInfoSafe(session.name, status);
637
+ });
638
+ const results = await Promise.all(sessionPromises);
639
+ const detailedSessions = results.filter((s) => s !== null);
640
+ detailedSessions.sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
641
+ return detailedSessions;
642
+ }
643
+ async function pollSessions(broadcast) {
644
+ try {
645
+ const sessions = await discoverSessions();
646
+ const hasChanges = detectChanges(sessions);
647
+ if (hasChanges) {
648
+ currentSessions = sessions;
649
+ isFirstPoll = false;
650
+ broadcast({
651
+ type: "sessions:updated",
652
+ data: sessions
653
+ });
654
+ }
655
+ } catch {
656
+ }
657
+ }
658
+ function getCurrentSessions() {
659
+ return [...currentSessions];
660
+ }
661
+ function startSessionPolling(broadcast) {
662
+ stopSessionPolling();
663
+ pollSessions(broadcast);
664
+ pollingInterval = setInterval(() => {
665
+ pollSessions(broadcast);
666
+ }, POLLING_INTERVAL_MS);
667
+ }
668
+ function stopSessionPolling() {
669
+ if (pollingInterval) {
670
+ clearInterval(pollingInterval);
671
+ pollingInterval = null;
672
+ }
673
+ currentSessions = [];
674
+ isFirstPoll = true;
675
+ }
676
+
677
+ // src/server/session-routes.ts
678
+ var HTTP_NOT_FOUND = 404;
679
+ var HTTP_INTERNAL_ERROR = 500;
680
+ function createSessionApiRouter() {
681
+ const router = (0, import_express.Router)();
682
+ router.get("/sessions", (_req, res) => {
683
+ try {
684
+ let sessions = getCurrentSessions();
685
+ const { epicSlug, storySlug, status } = _req.query;
686
+ if (epicSlug && typeof epicSlug === "string") {
687
+ sessions = sessions.filter((s) => s.epicSlug === epicSlug);
688
+ if (storySlug && typeof storySlug === "string") {
689
+ sessions = sessions.filter((s) => s.storySlug === storySlug);
690
+ }
691
+ }
692
+ if (status && typeof status === "string" && (status === "running" || status === "completed")) {
693
+ sessions = sessions.filter((s) => s.status === status);
694
+ }
695
+ res.json(sessions);
696
+ } catch (_error) {
697
+ res.status(HTTP_INTERNAL_ERROR).json({ error: "Failed to fetch sessions" });
698
+ }
699
+ });
700
+ router.get("/sessions/:sessionName", (req, res) => {
701
+ try {
702
+ const { sessionName } = req.params;
703
+ const sessions = getCurrentSessions();
704
+ const session = sessions.find((s) => s.name === sessionName);
705
+ if (!session) {
706
+ res.status(HTTP_NOT_FOUND).json({ error: "Session not found" });
707
+ return;
708
+ }
709
+ res.json(session);
710
+ } catch (_error) {
711
+ res.status(HTTP_INTERNAL_ERROR).json({ error: "Failed to fetch session" });
712
+ }
713
+ });
714
+ return router;
715
+ }
716
+
717
+ // src/server/routes.ts
718
+ var HTTP_NOT_FOUND2 = 404;
719
+ var HTTP_INTERNAL_ERROR2 = 500;
720
+ function getEpics(sagaRoot) {
721
+ return scanSagaDirectory(sagaRoot);
722
+ }
723
+ function toEpicSummary(epic) {
724
+ return {
725
+ slug: epic.slug,
726
+ title: epic.title,
727
+ storyCounts: epic.storyCounts,
728
+ path: epic.path
729
+ };
730
+ }
731
+ function registerEpicsRoutes(router, sagaRoot) {
732
+ router.get("/epics", async (_req, res) => {
733
+ try {
734
+ const epics = await getEpics(sagaRoot);
735
+ const summaries = epics.map(toEpicSummary);
736
+ res.json(summaries);
737
+ } catch (_error) {
738
+ res.status(HTTP_INTERNAL_ERROR2).json({ error: "Failed to fetch epics" });
739
+ }
740
+ });
741
+ router.get("/epics/:slug", async (req, res) => {
742
+ try {
743
+ const { slug } = req.params;
744
+ const epics = await getEpics(sagaRoot);
745
+ const epic = epics.find((e) => e.slug === slug);
746
+ if (!epic) {
747
+ res.status(HTTP_NOT_FOUND2).json({ error: `Epic not found: ${slug}` });
748
+ return;
749
+ }
750
+ res.json(epic);
751
+ } catch (_error) {
752
+ res.status(HTTP_INTERNAL_ERROR2).json({ error: "Failed to fetch epic" });
753
+ }
754
+ });
755
+ }
756
+ function registerStoriesRoutes(router, sagaRoot) {
757
+ router.get("/stories/:epicSlug/:storySlug", async (req, res) => {
758
+ try {
759
+ const { epicSlug, storySlug } = req.params;
760
+ const epics = await getEpics(sagaRoot);
761
+ const epic = epics.find((e) => e.slug === epicSlug);
762
+ if (!epic) {
763
+ res.status(HTTP_NOT_FOUND2).json({ error: `Epic not found: ${epicSlug}` });
764
+ return;
765
+ }
766
+ const story = epic.stories.find((s) => s.slug === storySlug);
767
+ if (!story) {
768
+ res.status(HTTP_NOT_FOUND2).json({ error: `Story not found: ${storySlug}` });
769
+ return;
770
+ }
771
+ if (story.paths.journalMd) {
772
+ const journalPath = (0, import_node_path4.join)(sagaRoot, story.paths.journalMd);
773
+ const journal = await parseJournal(journalPath);
774
+ if (journal.length > 0) {
775
+ story.journal = journal;
776
+ }
777
+ }
778
+ res.json(story);
779
+ } catch (_error) {
780
+ res.status(HTTP_INTERNAL_ERROR2).json({ error: "Failed to fetch story" });
781
+ }
782
+ });
783
+ }
784
+ function createApiRouter(sagaRoot) {
785
+ const router = (0, import_express2.Router)();
786
+ registerEpicsRoutes(router, sagaRoot);
787
+ registerStoriesRoutes(router, sagaRoot);
788
+ router.use(createSessionApiRouter());
789
+ router.use((_req, res) => {
790
+ res.status(HTTP_NOT_FOUND2).json({ error: "API endpoint not found" });
791
+ });
792
+ return router;
793
+ }
794
+
795
+ // src/server/websocket.ts
796
+ var import_node_path7 = require("node:path");
797
+ var import_ws = require("ws");
798
+
799
+ // src/lib/log-stream-manager.ts
800
+ var import_node_fs2 = require("node:fs");
801
+ var import_promises4 = require("node:fs/promises");
802
+ var import_node_path5 = require("node:path");
803
+ var import_chokidar = __toESM(require("chokidar"), 1);
804
+ var LogStreamManager = class {
805
+ /**
806
+ * Active file watchers indexed by session name
807
+ */
808
+ watchers = /* @__PURE__ */ new Map();
809
+ /**
810
+ * Current file position (byte offset) per session for incremental reads
811
+ */
812
+ filePositions = /* @__PURE__ */ new Map();
813
+ /**
814
+ * Client subscriptions per session
815
+ */
816
+ subscriptions = /* @__PURE__ */ new Map();
817
+ /**
818
+ * Function to send messages to clients
819
+ */
820
+ sendToClient;
821
+ /**
822
+ * Create a new LogStreamManager instance
823
+ *
824
+ * @param sendToClient - Function to send log data messages to clients
825
+ */
826
+ constructor(sendToClient2) {
827
+ this.sendToClient = sendToClient2;
828
+ }
829
+ /**
830
+ * Get the number of subscriptions for a session
831
+ *
832
+ * @param sessionName - The session to check
833
+ * @returns Number of subscribed clients
834
+ */
835
+ getSubscriptionCount(sessionName) {
836
+ const subs = this.subscriptions.get(sessionName);
837
+ return subs ? subs.size : 0;
838
+ }
839
+ /**
840
+ * Check if a watcher exists for a session
841
+ *
842
+ * @param sessionName - The session to check
843
+ * @returns True if a watcher exists
844
+ */
845
+ hasWatcher(sessionName) {
846
+ return this.watchers.has(sessionName);
847
+ }
848
+ /**
849
+ * Get the current file position for a session
850
+ *
851
+ * @param sessionName - The session to check
852
+ * @returns The current byte offset, or 0 if not tracked
853
+ */
854
+ getFilePosition(sessionName) {
855
+ return this.filePositions.get(sessionName) ?? 0;
856
+ }
857
+ /**
858
+ * Subscribe a client to a session's log stream
859
+ *
860
+ * Reads the full file content and sends it as the initial message.
861
+ * Adds the client to the subscription set for incremental updates.
862
+ * Creates a file watcher if this is the first subscriber.
863
+ *
864
+ * @param sessionName - The session to subscribe to
865
+ * @param ws - The WebSocket client to subscribe
866
+ */
867
+ async subscribe(sessionName, ws) {
868
+ const outputFile = (0, import_node_path5.join)(OUTPUT_DIR, `${sessionName}.out`);
869
+ if (!(0, import_node_fs2.existsSync)(outputFile)) {
870
+ this.sendToClient(ws, {
871
+ type: "logs:error",
872
+ sessionName,
873
+ error: `Output file not found: ${outputFile}`
874
+ });
875
+ return;
876
+ }
877
+ const content = await (0, import_promises4.readFile)(outputFile, "utf-8");
878
+ this.sendToClient(ws, {
879
+ type: "logs:data",
880
+ sessionName,
881
+ data: content,
882
+ isInitial: true,
883
+ isComplete: false
884
+ });
885
+ this.filePositions.set(sessionName, content.length);
886
+ let subs = this.subscriptions.get(sessionName);
887
+ if (!subs) {
888
+ subs = /* @__PURE__ */ new Set();
889
+ this.subscriptions.set(sessionName, subs);
890
+ }
891
+ subs.add(ws);
892
+ if (!this.watchers.has(sessionName)) {
893
+ this.createWatcher(sessionName, outputFile);
894
+ }
895
+ }
896
+ /**
897
+ * Create a chokidar file watcher for a session's output file
898
+ *
899
+ * The watcher detects changes and triggers incremental content delivery
900
+ * to all subscribed clients.
901
+ *
902
+ * @param sessionName - The session name
903
+ * @param outputFile - Path to the session output file
904
+ */
905
+ createWatcher(sessionName, outputFile) {
906
+ const watcher = import_chokidar.default.watch(outputFile, {
907
+ persistent: true,
908
+ awaitWriteFinish: false
909
+ });
910
+ watcher.on("change", async () => {
911
+ await this.sendIncrementalContent(sessionName, outputFile);
912
+ });
913
+ this.watchers.set(sessionName, watcher);
914
+ }
915
+ /**
916
+ * Clean up a watcher and associated state for a session
917
+ *
918
+ * Closes the file watcher and removes all tracking state for the session.
919
+ * Should be called when the last subscriber unsubscribes or disconnects.
920
+ *
921
+ * @param sessionName - The session to clean up
922
+ */
923
+ cleanupWatcher(sessionName) {
924
+ const watcher = this.watchers.get(sessionName);
925
+ if (watcher) {
926
+ watcher.close();
927
+ this.watchers.delete(sessionName);
928
+ }
929
+ this.filePositions.delete(sessionName);
930
+ this.subscriptions.delete(sessionName);
931
+ }
932
+ /**
933
+ * Send incremental content to all subscribed clients for a session
934
+ *
935
+ * Reads from the last known position to the end of the file and sends
936
+ * the new content to all subscribed clients.
937
+ *
938
+ * @param sessionName - The session name
939
+ * @param outputFile - Path to the session output file
940
+ */
941
+ async sendIncrementalContent(sessionName, outputFile) {
942
+ const lastPosition = this.filePositions.get(sessionName) ?? 0;
943
+ const fileStat = await (0, import_promises4.stat)(outputFile);
944
+ const currentSize = fileStat.size;
945
+ if (currentSize <= lastPosition) {
946
+ return;
947
+ }
948
+ const newContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
949
+ this.filePositions.set(sessionName, currentSize);
950
+ const subs = this.subscriptions.get(sessionName);
951
+ if (subs) {
952
+ const message = {
953
+ type: "logs:data",
954
+ sessionName,
955
+ data: newContent,
956
+ isInitial: false,
957
+ isComplete: false
958
+ };
959
+ for (const ws of subs) {
960
+ this.sendToClient(ws, message);
961
+ }
962
+ }
963
+ }
964
+ /**
965
+ * Read file content from a specific position
966
+ *
967
+ * @param filePath - Path to the file
968
+ * @param start - Starting byte position
969
+ * @param end - Ending byte position
970
+ * @returns The content read from the file
971
+ */
972
+ readFromPosition(filePath, start, end) {
973
+ return new Promise((resolve, reject) => {
974
+ let content = "";
975
+ const stream = (0, import_node_fs2.createReadStream)(filePath, {
976
+ start,
977
+ end: end - 1,
978
+ // createReadStream end is inclusive
979
+ encoding: "utf-8"
980
+ });
981
+ stream.on("data", (chunk) => {
982
+ content += chunk;
983
+ });
984
+ stream.on("end", () => {
985
+ resolve(content);
986
+ });
987
+ stream.on("error", reject);
988
+ });
989
+ }
990
+ /**
991
+ * Unsubscribe a client from a session's log stream
992
+ *
993
+ * Removes the client from the subscription set. If this was the last
994
+ * subscriber, cleans up the watcher and associated state.
995
+ *
996
+ * @param sessionName - The session to unsubscribe from
997
+ * @param ws - The WebSocket client to unsubscribe
998
+ */
999
+ unsubscribe(sessionName, ws) {
1000
+ const subs = this.subscriptions.get(sessionName);
1001
+ if (subs) {
1002
+ subs.delete(ws);
1003
+ if (subs.size === 0) {
1004
+ this.cleanupWatcher(sessionName);
1005
+ }
1006
+ }
1007
+ }
1008
+ /**
1009
+ * Handle client disconnect by removing from all subscriptions
1010
+ *
1011
+ * Should be called when a WebSocket connection closes to clean up
1012
+ * any subscriptions the client may have had. Also triggers watcher
1013
+ * cleanup for any sessions that no longer have subscribers.
1014
+ *
1015
+ * @param ws - The WebSocket client that disconnected
1016
+ */
1017
+ handleClientDisconnect(ws) {
1018
+ for (const [sessionName, subs] of this.subscriptions) {
1019
+ subs.delete(ws);
1020
+ if (subs.size === 0) {
1021
+ this.cleanupWatcher(sessionName);
1022
+ }
1023
+ }
1024
+ }
1025
+ /**
1026
+ * Notify that a session has completed
1027
+ *
1028
+ * Reads any remaining content from the file and sends it with isComplete=true
1029
+ * to all subscribed clients, then cleans up the watcher regardless of
1030
+ * subscription count. Called by session polling when it detects completion.
1031
+ *
1032
+ * @param sessionName - The session that has completed
1033
+ */
1034
+ async notifySessionCompleted(sessionName) {
1035
+ const subs = this.subscriptions.get(sessionName);
1036
+ if (!subs || subs.size === 0) {
1037
+ return;
1038
+ }
1039
+ const outputFile = (0, import_node_path5.join)(OUTPUT_DIR, `${sessionName}.out`);
1040
+ let finalContent = "";
1041
+ try {
1042
+ if ((0, import_node_fs2.existsSync)(outputFile)) {
1043
+ const lastPosition = this.filePositions.get(sessionName) ?? 0;
1044
+ const fileStat = await (0, import_promises4.stat)(outputFile);
1045
+ const currentSize = fileStat.size;
1046
+ if (currentSize > lastPosition) {
1047
+ finalContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
1048
+ }
1049
+ }
1050
+ } catch {
1051
+ }
1052
+ const message = {
1053
+ type: "logs:data",
1054
+ sessionName,
1055
+ data: finalContent,
1056
+ isInitial: false,
1057
+ isComplete: true
1058
+ };
1059
+ for (const ws of subs) {
1060
+ this.sendToClient(ws, message);
1061
+ }
1062
+ this.cleanupWatcher(sessionName);
1063
+ }
1064
+ /**
1065
+ * Clean up all watchers and subscriptions
1066
+ *
1067
+ * Call this when shutting down the server.
1068
+ */
1069
+ async dispose() {
1070
+ const closePromises = [];
1071
+ for (const [, watcher] of this.watchers) {
1072
+ closePromises.push(watcher.close());
1073
+ }
1074
+ await Promise.all(closePromises);
1075
+ this.watchers.clear();
1076
+ this.filePositions.clear();
1077
+ this.subscriptions.clear();
1078
+ }
1079
+ };
1080
+
1081
+ // src/server/watcher.ts
1082
+ var import_node_events = require("node:events");
1083
+ var import_node_path6 = require("node:path");
1084
+ var import_node_process2 = __toESM(require("node:process"), 1);
1085
+ var import_chokidar2 = __toESM(require("chokidar"), 1);
1086
+ var MIN_PATH_PARTS = 4;
1087
+ var ARCHIVE_STORY_PARTS = 5;
1088
+ var EPIC_FILE_PARTS = 4;
1089
+ var STORY_FILE_PARTS = 6;
1090
+ var DEBOUNCE_DELAY_MS = 100;
1091
+ function shouldUsePolling() {
1092
+ return import_node_process2.default.env.SAGA_USE_POLLING === "1";
1093
+ }
1094
+ function isStoryMarkdownFile(fileName) {
1095
+ return fileName === "story.md" || fileName === "journal.md";
1096
+ }
1097
+ function parseArchivePath(parts, epicSlug) {
1098
+ if (parts.length >= ARCHIVE_STORY_PARTS) {
1099
+ const storySlug = parts[3];
1100
+ const fileName = parts[4];
1101
+ if (isStoryMarkdownFile(fileName)) {
1102
+ return {
1103
+ epicSlug,
1104
+ storySlug,
1105
+ archived: true,
1106
+ isEpicFile: false,
1107
+ isStoryFile: true,
1108
+ isMainStoryFile: fileName === "story.md"
1109
+ };
1110
+ }
1111
+ }
1112
+ return null;
1113
+ }
1114
+ function parseEpicsPath(parts, epicSlug) {
1115
+ if (parts.length === EPIC_FILE_PARTS && parts[3] === "epic.md") {
1116
+ return {
1117
+ epicSlug,
1118
+ archived: false,
1119
+ isEpicFile: true,
1120
+ isStoryFile: false,
1121
+ isMainStoryFile: false
1122
+ };
1123
+ }
1124
+ if (parts.length >= STORY_FILE_PARTS && parts[3] === "stories") {
1125
+ const storySlug = parts[4];
1126
+ const fileName = parts[5];
1127
+ if (isStoryMarkdownFile(fileName)) {
1128
+ return {
1129
+ epicSlug,
1130
+ storySlug,
1131
+ archived: false,
1132
+ isEpicFile: false,
1133
+ isStoryFile: true,
1134
+ isMainStoryFile: fileName === "story.md"
1135
+ };
1136
+ }
1137
+ }
1138
+ return null;
1139
+ }
1140
+ function parseFilePath(filePath, sagaRoot) {
1141
+ const relativePath = (0, import_node_path6.relative)(sagaRoot, filePath);
1142
+ const parts = relativePath.split(import_node_path6.sep);
1143
+ if (parts[0] !== ".saga" || parts.length < MIN_PATH_PARTS) {
1144
+ return null;
1145
+ }
1146
+ const epicSlug = parts[2];
1147
+ const isArchive = parts[1] === "archive";
1148
+ const isEpics = parts[1] === "epics";
1149
+ if (isArchive) {
1150
+ return parseArchivePath(parts, epicSlug);
1151
+ }
1152
+ if (isEpics) {
1153
+ return parseEpicsPath(parts, epicSlug);
1154
+ }
1155
+ return null;
1156
+ }
1157
+ function createDebouncer(delayMs) {
1158
+ const pending = /* @__PURE__ */ new Map();
1159
+ return {
1160
+ schedule(key, data, callback) {
1161
+ const existing = pending.get(key);
1162
+ if (existing) {
1163
+ clearTimeout(existing.timer);
1164
+ }
1165
+ const timer = setTimeout(() => {
1166
+ pending.delete(key);
1167
+ callback(data);
1168
+ }, delayMs);
1169
+ pending.set(key, { timer, data });
1170
+ },
1171
+ clear() {
1172
+ for (const { timer } of pending.values()) {
1173
+ clearTimeout(timer);
1174
+ }
1175
+ pending.clear();
1176
+ }
1177
+ };
1178
+ }
1179
+ function getEpicEventType(eventType) {
1180
+ if (eventType === "add") {
1181
+ return "epic:added";
1182
+ }
1183
+ if (eventType === "unlink") {
1184
+ return "epic:removed";
1185
+ }
1186
+ return "epic:changed";
1187
+ }
1188
+ function getStoryEventType(eventType, isMainStoryFile) {
1189
+ if (!isMainStoryFile) {
1190
+ return "story:changed";
1191
+ }
1192
+ if (eventType === "add") {
1193
+ return "story:added";
1194
+ }
1195
+ if (eventType === "unlink") {
1196
+ return "story:removed";
1197
+ }
1198
+ return "story:changed";
1199
+ }
1200
+ function determineEventType(eventType, parsed) {
1201
+ if (parsed.isEpicFile) {
1202
+ return getEpicEventType(eventType);
1203
+ }
1204
+ if (parsed.isStoryFile) {
1205
+ return getStoryEventType(eventType, parsed.isMainStoryFile);
1206
+ }
1207
+ return null;
1208
+ }
1209
+ function createDebounceKey(parsed) {
1210
+ const { epicSlug, storySlug, archived } = parsed;
1211
+ return storySlug ? `story:${epicSlug}:${storySlug}:${archived}` : `epic:${epicSlug}`;
1212
+ }
1213
+ function createChokidarWatcher(sagaRoot) {
1214
+ const epicsDir = (0, import_node_path6.join)(sagaRoot, ".saga", "epics");
1215
+ const archiveDir = (0, import_node_path6.join)(sagaRoot, ".saga", "archive");
1216
+ const usePolling = shouldUsePolling();
1217
+ return import_chokidar2.default.watch([epicsDir, archiveDir], {
1218
+ persistent: true,
1219
+ ignoreInitial: true,
1220
+ // Use polling for tests (reliable) or native watching for production (fast)
1221
+ usePolling,
1222
+ interval: usePolling ? DEBOUNCE_DELAY_MS : void 0,
1223
+ // Wait for writes to finish when polling
1224
+ awaitWriteFinish: usePolling ? {
1225
+ stabilityThreshold: 50,
1226
+ pollInterval: 50
1227
+ } : false
1228
+ });
1229
+ }
1230
+ function createFileEventHandler(sagaRoot, debouncer, emitter, state) {
1231
+ return (eventType, filePath) => {
1232
+ if (state.closed || !state.ready) {
1233
+ return;
1234
+ }
1235
+ const parsed = parseFilePath(filePath, sagaRoot);
1236
+ if (!parsed) {
1237
+ return;
1238
+ }
1239
+ const watcherEventType = determineEventType(eventType, parsed);
1240
+ if (!watcherEventType) {
1241
+ return;
1242
+ }
1243
+ const event = {
1244
+ type: watcherEventType,
1245
+ epicSlug: parsed.epicSlug,
1246
+ storySlug: parsed.storySlug,
1247
+ archived: parsed.archived,
1248
+ path: (0, import_node_path6.relative)(sagaRoot, filePath)
1249
+ };
1250
+ debouncer.schedule(createDebounceKey(parsed), event, (e) => {
1251
+ if (!state.closed) {
1252
+ emitter.emit(e.type, e);
1253
+ }
1254
+ });
1255
+ };
1256
+ }
1257
+ async function createSagaWatcher(sagaRoot) {
1258
+ const emitter = new import_node_events.EventEmitter();
1259
+ const debouncer = createDebouncer(DEBOUNCE_DELAY_MS);
1260
+ const watcher = createChokidarWatcher(sagaRoot);
1261
+ const state = { closed: false, ready: false };
1262
+ const handleFileEvent = createFileEventHandler(sagaRoot, debouncer, emitter, state);
1263
+ watcher.on("add", (path) => handleFileEvent("add", path));
1264
+ watcher.on("change", (path) => handleFileEvent("change", path));
1265
+ watcher.on("unlink", (path) => handleFileEvent("unlink", path));
1266
+ watcher.on("error", (error) => {
1267
+ if (!state.closed) {
1268
+ emitter.emit("error", error);
1269
+ }
1270
+ });
1271
+ await new Promise((resolve) => {
1272
+ watcher.on("ready", () => {
1273
+ state.ready = true;
1274
+ resolve();
1275
+ });
1276
+ });
1277
+ return {
1278
+ on(event, listener) {
1279
+ emitter.on(event, listener);
1280
+ return this;
1281
+ },
1282
+ async close() {
1283
+ state.closed = true;
1284
+ debouncer.clear();
1285
+ await watcher.close();
1286
+ }
1287
+ };
1288
+ }
1289
+
1290
+ // src/server/websocket.ts
1291
+ var HEARTBEAT_INTERVAL_MS = 3e4;
1292
+ function makeStoryKey(epicSlug, storySlug) {
1293
+ return `${epicSlug}:${storySlug}`;
1294
+ }
1295
+ function toEpicSummary2(epic) {
1296
+ return {
1297
+ slug: epic.slug,
1298
+ title: epic.title,
1299
+ storyCounts: epic.storyCounts,
1300
+ path: epic.path
1301
+ };
1302
+ }
1303
+ function sendToClient(ws, message) {
1304
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
1305
+ ws.send(JSON.stringify(message));
1306
+ }
1307
+ }
1308
+ function sendLogMessage(ws, message) {
1309
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
1310
+ ws.send(JSON.stringify({ event: message.type, data: message }));
1311
+ }
1312
+ }
1313
+ function handleStorySubscription(state, data, subscribe) {
1314
+ const { epicSlug, storySlug } = data || {};
1315
+ if (epicSlug && storySlug) {
1316
+ const key = makeStoryKey(epicSlug, storySlug);
1317
+ if (subscribe) {
1318
+ state.subscribedStories.add(key);
1319
+ } else {
1320
+ state.subscribedStories.delete(key);
1321
+ }
1322
+ }
1323
+ }
1324
+ function handleLogsSubscription(logStreamManager, ws, data, subscribe) {
1325
+ const { sessionName } = data || {};
1326
+ if (sessionName) {
1327
+ if (subscribe) {
1328
+ logStreamManager.subscribe(sessionName, ws);
1329
+ } else {
1330
+ logStreamManager.unsubscribe(sessionName, ws);
1331
+ }
1332
+ }
1333
+ }
1334
+ function processClientMessage(message, state, logStreamManager) {
1335
+ switch (message.event) {
1336
+ case "subscribe:story":
1337
+ handleStorySubscription(state, message.data, true);
1338
+ break;
1339
+ case "unsubscribe:story":
1340
+ handleStorySubscription(state, message.data, false);
1341
+ break;
1342
+ case "subscribe:logs":
1343
+ handleLogsSubscription(logStreamManager, state.ws, message.data, true);
1344
+ break;
1345
+ case "unsubscribe:logs":
1346
+ handleLogsSubscription(logStreamManager, state.ws, message.data, false);
1347
+ break;
1348
+ default:
1349
+ break;
1350
+ }
1351
+ }
1352
+ function hasSubscribers(clients, storyKey) {
1353
+ for (const [, state] of clients) {
1354
+ if (state.subscribedStories.has(storyKey)) {
1355
+ return true;
1356
+ }
1357
+ }
1358
+ return false;
1359
+ }
1360
+ function getStoryPath(sagaRoot, epicSlug, storySlug, archived) {
1361
+ return archived ? (0, import_node_path7.join)(sagaRoot, ".saga", "archive", epicSlug, storySlug, "story.md") : (0, import_node_path7.join)(sagaRoot, ".saga", "epics", epicSlug, "stories", storySlug, "story.md");
1362
+ }
1363
+ async function parseAndEnrichStory(sagaRoot, storyPath, epicSlug, archived) {
1364
+ const story = await parseStory(storyPath, epicSlug);
1365
+ if (!story) {
1366
+ return null;
1367
+ }
1368
+ story.paths.storyMd = (0, import_node_path7.relative)(sagaRoot, story.paths.storyMd);
1369
+ if (story.paths.journalMd) {
1370
+ story.paths.journalMd = (0, import_node_path7.relative)(sagaRoot, story.paths.journalMd);
1371
+ }
1372
+ story.archived = archived;
1373
+ if (story.paths.journalMd) {
1374
+ const journalPath = (0, import_node_path7.join)(sagaRoot, story.paths.journalMd);
1375
+ const journal = await parseJournal(journalPath);
1376
+ if (journal.length > 0) {
1377
+ story.journal = journal;
1378
+ }
1379
+ }
1380
+ return story;
1381
+ }
1382
+ async function handleStoryChangeEvent(event, sagaRoot, clients, broadcastToSubscribers, handleEpicChange) {
1383
+ const { epicSlug, storySlug, archived } = event;
1384
+ if (!storySlug) {
1385
+ return;
1386
+ }
1387
+ const storyKey = makeStoryKey(epicSlug, storySlug);
1388
+ if (!hasSubscribers(clients, storyKey)) {
1389
+ await handleEpicChange();
1390
+ return;
1391
+ }
1392
+ try {
1393
+ const storyPath = getStoryPath(sagaRoot, epicSlug, storySlug, archived);
1394
+ const story = await parseAndEnrichStory(sagaRoot, storyPath, epicSlug, archived);
1395
+ if (story) {
1396
+ broadcastToSubscribers(storyKey, { event: "story:updated", data: story });
1397
+ }
1398
+ await handleEpicChange();
1399
+ } catch {
1400
+ }
1401
+ }
1402
+ function handleClientMessage(data, state, logStreamManager) {
1403
+ try {
1404
+ const message = JSON.parse(data.toString());
1405
+ if (message.type === "ping") {
1406
+ sendToClient(state.ws, { event: "pong", data: null });
1407
+ return;
1408
+ }
1409
+ if (message.event) {
1410
+ processClientMessage(message, state, logStreamManager);
1411
+ }
1412
+ } catch {
1413
+ }
1414
+ }
1415
+ function setupClientHandlers(ws, state, clients, logStreamManager) {
1416
+ ws.on("pong", () => {
1417
+ state.isAlive = true;
1418
+ });
1419
+ ws.on("message", (data) => {
1420
+ handleClientMessage(data, state, logStreamManager);
1421
+ });
1422
+ ws.on("close", () => {
1423
+ clients.delete(ws);
1424
+ logStreamManager.handleClientDisconnect(ws);
1425
+ });
1426
+ ws.on("error", () => {
1427
+ clients.delete(ws);
1428
+ logStreamManager.handleClientDisconnect(ws);
1429
+ });
1430
+ }
1431
+ function handleNewConnection(ws, clients, logStreamManager) {
1432
+ const state = {
1433
+ ws,
1434
+ subscribedStories: /* @__PURE__ */ new Set(),
1435
+ isAlive: true
1436
+ };
1437
+ clients.set(ws, state);
1438
+ setupClientHandlers(ws, state, clients, logStreamManager);
1439
+ }
1440
+ function setupWatcherHandlers(watcher, sagaRoot, clients, broadcast, broadcastToSubscribers) {
1441
+ const handleEpicChange = async () => {
1442
+ try {
1443
+ const epics = await scanSagaDirectory(sagaRoot);
1444
+ const summaries = epics.map(toEpicSummary2);
1445
+ broadcast({ event: "epics:updated", data: summaries });
1446
+ } catch {
1447
+ }
1448
+ };
1449
+ watcher.on("epic:added", handleEpicChange);
1450
+ watcher.on("epic:changed", handleEpicChange);
1451
+ watcher.on("epic:removed", handleEpicChange);
1452
+ const handleStoryChange = (event) => {
1453
+ handleStoryChangeEvent(event, sagaRoot, clients, broadcastToSubscribers, handleEpicChange);
1454
+ };
1455
+ watcher.on("story:added", handleStoryChange);
1456
+ watcher.on("story:changed", handleStoryChange);
1457
+ watcher.on("story:removed", handleStoryChange);
1458
+ watcher.on("error", () => {
1459
+ });
1460
+ }
1461
+ function setupSessionPolling(broadcast, logStreamManager) {
1462
+ let previousSessionStates = /* @__PURE__ */ new Map();
1463
+ startSessionPolling((msg) => {
1464
+ broadcast({ event: msg.type, data: msg.data });
1465
+ const currentStates = /* @__PURE__ */ new Map();
1466
+ for (const session of msg.data) {
1467
+ currentStates.set(session.name, session.status);
1468
+ const previousStatus = previousSessionStates.get(session.name);
1469
+ if (previousStatus === "running" && session.status === "completed") {
1470
+ logStreamManager.notifySessionCompleted(session.name);
1471
+ }
1472
+ }
1473
+ previousSessionStates = currentStates;
1474
+ });
1475
+ }
1476
+ function setupHeartbeat(clients) {
1477
+ return setInterval(() => {
1478
+ for (const [ws, state] of clients) {
1479
+ if (!state.isAlive) {
1480
+ clients.delete(ws);
1481
+ ws.terminate();
1482
+ continue;
1483
+ }
1484
+ state.isAlive = false;
1485
+ ws.ping();
1486
+ }
1487
+ }, HEARTBEAT_INTERVAL_MS);
1488
+ }
1489
+ function createWebSocketInstance(state) {
1490
+ const {
1491
+ wss,
1492
+ clients,
1493
+ watcher,
1494
+ logStreamManager,
1495
+ heartbeatInterval,
1496
+ broadcast,
1497
+ broadcastToSubscribers
1498
+ } = state;
1499
+ return {
1500
+ broadcastEpicsUpdated(epics) {
1501
+ broadcast({ event: "epics:updated", data: epics });
1502
+ },
1503
+ broadcastStoryUpdated(story) {
1504
+ const key = makeStoryKey(story.epicSlug, story.slug);
1505
+ broadcastToSubscribers(key, { event: "story:updated", data: story });
1506
+ },
1507
+ async close() {
1508
+ clearInterval(heartbeatInterval);
1509
+ stopSessionPolling();
1510
+ await logStreamManager.dispose();
1511
+ for (const [ws] of clients) {
1512
+ ws.close();
1513
+ }
1514
+ clients.clear();
1515
+ if (watcher) {
1516
+ await watcher.close();
1517
+ }
1518
+ return new Promise((resolve, reject) => {
1519
+ wss.close((err) => {
1520
+ if (err) {
1521
+ reject(err);
1522
+ } else {
1523
+ resolve();
1524
+ }
1525
+ });
1526
+ });
1527
+ }
1528
+ };
1529
+ }
1530
+ async function createWebSocketServer(httpServer, sagaRoot) {
1531
+ const wss = new import_ws.WebSocketServer({ server: httpServer });
1532
+ const clients = /* @__PURE__ */ new Map();
1533
+ let watcher = null;
1534
+ try {
1535
+ watcher = await createSagaWatcher(sagaRoot);
1536
+ } catch {
1537
+ }
1538
+ const logStreamManager = new LogStreamManager(sendLogMessage);
1539
+ const broadcast = (message) => {
1540
+ for (const [ws] of clients) {
1541
+ sendToClient(ws, message);
1542
+ }
1543
+ };
1544
+ const broadcastToSubscribers = (storyKey, message) => {
1545
+ for (const [ws, state] of clients) {
1546
+ if (state.subscribedStories.has(storyKey)) {
1547
+ sendToClient(ws, message);
1548
+ }
1549
+ }
1550
+ };
1551
+ const heartbeatInterval = setupHeartbeat(clients);
1552
+ setupSessionPolling(broadcast, logStreamManager);
1553
+ wss.on("connection", (ws) => {
1554
+ handleNewConnection(ws, clients, logStreamManager);
1555
+ });
1556
+ if (watcher) {
1557
+ setupWatcherHandlers(watcher, sagaRoot, clients, broadcast, broadcastToSubscribers);
1558
+ }
1559
+ return createWebSocketInstance({
1560
+ wss,
1561
+ clients,
1562
+ watcher,
1563
+ logStreamManager,
1564
+ heartbeatInterval,
1565
+ broadcast,
1566
+ broadcastToSubscribers
1567
+ });
1568
+ }
1569
+
1570
+ // src/server/index.ts
1571
+ var DEFAULT_PORT = 3847;
1572
+ function createApp(sagaRoot) {
1573
+ const app = (0, import_express3.default)();
1574
+ app.use(import_express3.default.json());
1575
+ app.use((_req, res, next) => {
1576
+ res.header("Access-Control-Allow-Origin", "*");
1577
+ res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
1578
+ res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
1579
+ next();
1580
+ });
1581
+ app.get("/api/health", (_req, res) => {
1582
+ res.json({ status: "ok" });
1583
+ });
1584
+ app.use("/api", createApiRouter(sagaRoot));
1585
+ const clientDistPath = (0, import_node_path8.join)(__dirname, "client");
1586
+ const _indexHtmlPath = (0, import_node_path8.join)(clientDistPath, "index.html");
1587
+ app.use(import_express3.default.static(clientDistPath));
1588
+ app.get("/{*splat}", (_req, res) => {
1589
+ res.sendFile("index.html", { root: clientDistPath });
1590
+ });
1591
+ return app;
1592
+ }
1593
+ async function startServer(config) {
1594
+ const port = config.port ?? DEFAULT_PORT;
1595
+ const app = createApp(config.sagaRoot);
1596
+ const httpServer = (0, import_node_http.createServer)(app);
1597
+ const wsServer = await createWebSocketServer(httpServer, config.sagaRoot);
1598
+ return new Promise((resolve, reject) => {
1599
+ httpServer.on("error", reject);
1600
+ httpServer.listen(port, () => {
1601
+ resolve({
1602
+ app,
1603
+ httpServer,
1604
+ wsServer,
1605
+ port,
1606
+ close: async () => {
1607
+ await wsServer.close();
1608
+ return new Promise((resolveClose, rejectClose) => {
1609
+ httpServer.close((err) => {
1610
+ if (err) {
1611
+ rejectClose(err);
1612
+ } else {
1613
+ resolveClose();
1614
+ }
1615
+ });
1616
+ });
1617
+ }
1618
+ });
1619
+ });
1620
+ });
1621
+ }
1622
+
1623
+ // src/utils/project-discovery.ts
1624
+ var import_node_fs3 = require("node:fs");
1625
+ var import_node_path9 = require("node:path");
1626
+ var import_node_process3 = __toESM(require("node:process"), 1);
1627
+ function findProjectRoot(startDir) {
1628
+ let currentDir = startDir ?? import_node_process3.default.cwd();
1629
+ while (true) {
1630
+ const sagaDir = (0, import_node_path9.join)(currentDir, ".saga");
1631
+ if ((0, import_node_fs3.existsSync)(sagaDir)) {
1632
+ return currentDir;
1633
+ }
1634
+ const parentDir = (0, import_node_path9.dirname)(currentDir);
1635
+ if (parentDir === currentDir) {
1636
+ return null;
1637
+ }
1638
+ currentDir = parentDir;
1639
+ }
1640
+ }
1641
+ function resolveProjectPath(explicitPath) {
1642
+ if (explicitPath) {
1643
+ const sagaDir = (0, import_node_path9.join)(explicitPath, ".saga");
1644
+ if (!(0, import_node_fs3.existsSync)(sagaDir)) {
1645
+ throw new Error(
1646
+ `No .saga/ directory found at specified path: ${explicitPath}
1647
+ Make sure the path points to a SAGA project root.`
1648
+ );
1649
+ }
1650
+ return explicitPath;
1651
+ }
1652
+ const projectRoot = findProjectRoot();
1653
+ if (!projectRoot) {
1654
+ throw new Error(
1655
+ 'Could not find a SAGA project.\nNo .saga/ directory found in the current directory or any parent.\nRun "saga init" to initialize a new project, or use --path to specify the project location.'
1656
+ );
1657
+ }
1658
+ return projectRoot;
1659
+ }
1660
+
1661
+ // src/commands/dashboard.ts
1662
+ async function dashboardCommand(options) {
1663
+ let projectPath;
1664
+ try {
1665
+ projectPath = resolveProjectPath(options.path);
1666
+ } catch (error) {
1667
+ console.error(error instanceof Error ? error.message : "Failed to resolve SAGA project path");
1668
+ import_node_process4.default.exit(1);
1669
+ }
1670
+ try {
1671
+ const server = await startServer({
1672
+ sagaRoot: projectPath,
1673
+ port: options.port
1674
+ });
1675
+ console.log(`SAGA Dashboard server running on http://localhost:${server.port}`);
1676
+ console.log(`Project: ${projectPath}`);
1677
+ import_node_process4.default.on("SIGINT", async () => {
1678
+ await server.close();
1679
+ import_node_process4.default.exit(0);
1680
+ });
1681
+ import_node_process4.default.on("SIGTERM", async () => {
1682
+ await server.close();
1683
+ import_node_process4.default.exit(0);
1684
+ });
1685
+ } catch (_error) {
1686
+ import_node_process4.default.exit(1);
1687
+ }
1688
+ }
1689
+
1690
+ // src/commands/sessions/index.ts
1691
+ var import_node_process5 = __toESM(require("node:process"), 1);
1692
+ async function sessionsListCommand() {
1693
+ const sessions = await listSessions();
1694
+ console.log(JSON.stringify(sessions, null, 2));
1695
+ }
1696
+ async function sessionsStatusCommand(sessionName) {
1697
+ const status = await getSessionStatus(sessionName);
1698
+ console.log(JSON.stringify(status, null, 2));
1699
+ }
1700
+ async function sessionsLogsCommand(sessionName) {
1701
+ try {
1702
+ await streamLogs(sessionName);
1703
+ } catch (error) {
1704
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
1705
+ import_node_process5.default.exit(1);
1706
+ }
1707
+ }
1708
+
1709
+ // src/cli.ts
1710
+ var packageJsonPath = (0, import_node_path10.join)(__dirname, "..", "package.json");
1711
+ var packageJson = JSON.parse((0, import_node_fs4.readFileSync)(packageJsonPath, "utf-8"));
1712
+ var program = new import_commander.Command();
1713
+ program.name("saga").description("Dashboard and session monitoring for SAGA - Structured Autonomous Goal Achievement").version(packageJson.version).addHelpCommand("help [command]", "Display help for a command");
1714
+ program.option("-p, --path <dir>", "Path to SAGA project directory (overrides auto-discovery)");
1715
+ program.command("dashboard").description("Start the dashboard server").option("--port <n>", "Port to run the server on (default: 3847)", Number.parseInt).action(async (options) => {
1716
+ const globalOpts = program.opts();
1717
+ await dashboardCommand({
1718
+ path: globalOpts.path,
1719
+ port: options.port
1720
+ });
1721
+ });
1722
+ var sessionsCommand = program.command("sessions").description("Manage SAGA tmux sessions");
1723
+ sessionsCommand.command("list").description("List all SAGA sessions").action(async () => {
1724
+ await sessionsListCommand();
1725
+ });
1726
+ sessionsCommand.command("status <name>").description("Show session status").action(async (name) => {
1727
+ await sessionsStatusCommand(name);
1728
+ });
1729
+ sessionsCommand.command("logs <name>").description("Stream session output").action(async (name) => {
1730
+ await sessionsLogsCommand(name);
1731
+ });
1732
+ program.on("command:*", (operands) => {
1733
+ console.error(`error: unknown command '${operands[0]}'`);
1734
+ import_node_process6.default.exit(1);
1735
+ });
1736
+ program.parse();