@saga-ai/cli 2.12.1 → 2.14.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 +552 -29
- package/dist/client/assets/index-CdpmfaeI.css +1 -0
- package/dist/client/assets/index-sgqIEDO2.js +198 -0
- package/dist/client/index.html +2 -2
- package/package.json +11 -3
- package/dist/client/assets/index-Ct5VoJmK.js +0 -165
- package/dist/client/assets/index-DVpp5008.css +0 -1
package/dist/cli.cjs
CHANGED
|
@@ -25,8 +25,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
25
25
|
|
|
26
26
|
// src/cli.ts
|
|
27
27
|
var import_commander = require("commander");
|
|
28
|
-
var
|
|
29
|
-
var
|
|
28
|
+
var import_node_path9 = require("node:path");
|
|
29
|
+
var import_node_fs8 = require("node:fs");
|
|
30
30
|
|
|
31
31
|
// src/commands/init.ts
|
|
32
32
|
var import_node_path2 = require("node:path");
|
|
@@ -583,6 +583,7 @@ async function findStory(projectPath, query, options = {}) {
|
|
|
583
583
|
// src/lib/sessions.ts
|
|
584
584
|
var import_node_child_process = require("node:child_process");
|
|
585
585
|
var import_node_fs4 = require("node:fs");
|
|
586
|
+
var import_promises2 = require("node:fs/promises");
|
|
586
587
|
var import_node_path4 = require("node:path");
|
|
587
588
|
var OUTPUT_DIR = "/tmp/saga-sessions";
|
|
588
589
|
function shellEscape(str) {
|
|
@@ -741,6 +742,70 @@ async function killSession(sessionName) {
|
|
|
741
742
|
killed: result.status === 0
|
|
742
743
|
};
|
|
743
744
|
}
|
|
745
|
+
function parseSessionName(name) {
|
|
746
|
+
if (!name || !name.startsWith("saga__")) {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
const parts = name.split("__");
|
|
750
|
+
if (parts.length !== 4) {
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
const [, epicSlug, storySlug, pid] = parts;
|
|
754
|
+
if (!epicSlug || !storySlug || !pid) {
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
epicSlug,
|
|
759
|
+
storySlug
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
async function buildSessionInfo(name, status) {
|
|
763
|
+
const parsed = parseSessionName(name);
|
|
764
|
+
if (!parsed) {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
const outputFile = (0, import_node_path4.join)(OUTPUT_DIR, `${name}.out`);
|
|
768
|
+
const outputAvailable = (0, import_node_fs4.existsSync)(outputFile);
|
|
769
|
+
let startTime = /* @__PURE__ */ new Date();
|
|
770
|
+
let endTime;
|
|
771
|
+
let outputPreview;
|
|
772
|
+
if (outputAvailable) {
|
|
773
|
+
try {
|
|
774
|
+
const stats = await (0, import_promises2.stat)(outputFile);
|
|
775
|
+
startTime = stats.birthtime;
|
|
776
|
+
if (status === "completed") {
|
|
777
|
+
endTime = stats.mtime;
|
|
778
|
+
}
|
|
779
|
+
} catch {
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
const content = await (0, import_promises2.readFile)(outputFile, "utf-8");
|
|
783
|
+
const lines = content.split("\n").filter((line) => line.length > 0);
|
|
784
|
+
if (lines.length > 0) {
|
|
785
|
+
const lastLines = lines.slice(-5);
|
|
786
|
+
let preview = lastLines.join("\n");
|
|
787
|
+
if (preview.length > 500) {
|
|
788
|
+
const truncated = preview.slice(0, 500);
|
|
789
|
+
const lastNewline = truncated.lastIndexOf("\n");
|
|
790
|
+
preview = lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated;
|
|
791
|
+
}
|
|
792
|
+
outputPreview = preview;
|
|
793
|
+
}
|
|
794
|
+
} catch {
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return {
|
|
798
|
+
name,
|
|
799
|
+
epicSlug: parsed.epicSlug,
|
|
800
|
+
storySlug: parsed.storySlug,
|
|
801
|
+
status,
|
|
802
|
+
outputFile,
|
|
803
|
+
outputAvailable,
|
|
804
|
+
startTime,
|
|
805
|
+
endTime,
|
|
806
|
+
outputPreview
|
|
807
|
+
};
|
|
808
|
+
}
|
|
744
809
|
|
|
745
810
|
// src/commands/implement.ts
|
|
746
811
|
var DEFAULT_MAX_CYCLES = 10;
|
|
@@ -1276,21 +1341,21 @@ Searched in: ${(0, import_node_path5.join)(projectPath, ".saga", "worktrees")}`)
|
|
|
1276
1341
|
}
|
|
1277
1342
|
|
|
1278
1343
|
// src/server/index.ts
|
|
1279
|
-
var
|
|
1344
|
+
var import_express3 = __toESM(require("express"), 1);
|
|
1280
1345
|
var import_http = require("http");
|
|
1281
1346
|
var import_path6 = require("path");
|
|
1282
1347
|
|
|
1283
1348
|
// src/server/routes.ts
|
|
1284
|
-
var
|
|
1349
|
+
var import_express2 = require("express");
|
|
1285
1350
|
|
|
1286
1351
|
// src/server/parser.ts
|
|
1287
|
-
var
|
|
1352
|
+
var import_promises3 = require("fs/promises");
|
|
1288
1353
|
var import_path2 = require("path");
|
|
1289
1354
|
var import_gray_matter2 = __toESM(require("gray-matter"), 1);
|
|
1290
1355
|
async function toStoryDetail(story, sagaRoot) {
|
|
1291
1356
|
let tasks = [];
|
|
1292
1357
|
try {
|
|
1293
|
-
const content = await (0,
|
|
1358
|
+
const content = await (0, import_promises3.readFile)(story.storyPath, "utf-8");
|
|
1294
1359
|
const parsed = (0, import_gray_matter2.default)(content);
|
|
1295
1360
|
tasks = parseTasks(parsed.data.tasks);
|
|
1296
1361
|
} catch {
|
|
@@ -1335,11 +1400,11 @@ function validateTaskStatus(status) {
|
|
|
1335
1400
|
return "pending";
|
|
1336
1401
|
}
|
|
1337
1402
|
async function parseStory(storyPath, epicSlug) {
|
|
1338
|
-
const { join:
|
|
1339
|
-
const { stat:
|
|
1403
|
+
const { join: join14 } = await import("path");
|
|
1404
|
+
const { stat: stat4 } = await import("fs/promises");
|
|
1340
1405
|
let content;
|
|
1341
1406
|
try {
|
|
1342
|
-
content = await (0,
|
|
1407
|
+
content = await (0, import_promises3.readFile)(storyPath, "utf-8");
|
|
1343
1408
|
} catch {
|
|
1344
1409
|
return null;
|
|
1345
1410
|
}
|
|
@@ -1355,10 +1420,10 @@ async function parseStory(storyPath, epicSlug) {
|
|
|
1355
1420
|
const title = frontmatter.title || dirName;
|
|
1356
1421
|
const status = validateStatus(frontmatter.status);
|
|
1357
1422
|
const tasks = parseTasks(frontmatter.tasks);
|
|
1358
|
-
const journalPath =
|
|
1423
|
+
const journalPath = join14(storyDir, "journal.md");
|
|
1359
1424
|
let hasJournal = false;
|
|
1360
1425
|
try {
|
|
1361
|
-
await
|
|
1426
|
+
await stat4(journalPath);
|
|
1362
1427
|
hasJournal = true;
|
|
1363
1428
|
} catch {
|
|
1364
1429
|
}
|
|
@@ -1376,7 +1441,7 @@ async function parseStory(storyPath, epicSlug) {
|
|
|
1376
1441
|
}
|
|
1377
1442
|
async function parseJournal(journalPath) {
|
|
1378
1443
|
try {
|
|
1379
|
-
const content = await (0,
|
|
1444
|
+
const content = await (0, import_promises3.readFile)(journalPath, "utf-8");
|
|
1380
1445
|
const entries = [];
|
|
1381
1446
|
const sections = content.split(/^##\s+/m).slice(1);
|
|
1382
1447
|
for (const section of sections) {
|
|
@@ -1455,6 +1520,142 @@ async function scanSagaDirectory(sagaRoot) {
|
|
|
1455
1520
|
|
|
1456
1521
|
// src/server/routes.ts
|
|
1457
1522
|
var import_path3 = require("path");
|
|
1523
|
+
|
|
1524
|
+
// src/server/session-routes.ts
|
|
1525
|
+
var import_express = require("express");
|
|
1526
|
+
|
|
1527
|
+
// src/lib/session-polling.ts
|
|
1528
|
+
var POLLING_INTERVAL_MS = 3e3;
|
|
1529
|
+
var pollingInterval = null;
|
|
1530
|
+
var currentSessions = [];
|
|
1531
|
+
var isFirstPoll = true;
|
|
1532
|
+
function getCurrentSessions() {
|
|
1533
|
+
return [...currentSessions];
|
|
1534
|
+
}
|
|
1535
|
+
function startSessionPolling(broadcast) {
|
|
1536
|
+
stopSessionPolling();
|
|
1537
|
+
pollSessions(broadcast);
|
|
1538
|
+
pollingInterval = setInterval(() => {
|
|
1539
|
+
pollSessions(broadcast);
|
|
1540
|
+
}, POLLING_INTERVAL_MS);
|
|
1541
|
+
}
|
|
1542
|
+
function stopSessionPolling() {
|
|
1543
|
+
if (pollingInterval) {
|
|
1544
|
+
clearInterval(pollingInterval);
|
|
1545
|
+
pollingInterval = null;
|
|
1546
|
+
}
|
|
1547
|
+
currentSessions = [];
|
|
1548
|
+
isFirstPoll = true;
|
|
1549
|
+
}
|
|
1550
|
+
async function pollSessions(broadcast) {
|
|
1551
|
+
try {
|
|
1552
|
+
const sessions = await discoverSessions();
|
|
1553
|
+
const hasChanges = detectChanges(sessions);
|
|
1554
|
+
if (hasChanges) {
|
|
1555
|
+
currentSessions = sessions;
|
|
1556
|
+
isFirstPoll = false;
|
|
1557
|
+
broadcast({
|
|
1558
|
+
type: "sessions:updated",
|
|
1559
|
+
sessions
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
console.error("Error polling sessions:", error);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
async function discoverSessions() {
|
|
1567
|
+
const rawSessions = await listSessions();
|
|
1568
|
+
const detailedSessions = [];
|
|
1569
|
+
for (const session of rawSessions) {
|
|
1570
|
+
try {
|
|
1571
|
+
const statusResult = await getSessionStatus(session.name);
|
|
1572
|
+
const status = statusResult.running ? "running" : "completed";
|
|
1573
|
+
const detailed = await buildSessionInfo(session.name, status);
|
|
1574
|
+
if (detailed) {
|
|
1575
|
+
detailedSessions.push(detailed);
|
|
1576
|
+
}
|
|
1577
|
+
} catch (error) {
|
|
1578
|
+
console.error(`Error building session info for ${session.name}:`, error);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
detailedSessions.sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
|
|
1582
|
+
return detailedSessions;
|
|
1583
|
+
}
|
|
1584
|
+
function detectChanges(newSessions) {
|
|
1585
|
+
if (isFirstPoll) {
|
|
1586
|
+
return true;
|
|
1587
|
+
}
|
|
1588
|
+
if (newSessions.length !== currentSessions.length) {
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
const newSessionMap = /* @__PURE__ */ new Map();
|
|
1592
|
+
for (const session of newSessions) {
|
|
1593
|
+
newSessionMap.set(session.name, session);
|
|
1594
|
+
}
|
|
1595
|
+
const currentSessionMap = /* @__PURE__ */ new Map();
|
|
1596
|
+
for (const session of currentSessions) {
|
|
1597
|
+
currentSessionMap.set(session.name, session);
|
|
1598
|
+
}
|
|
1599
|
+
for (const name of newSessionMap.keys()) {
|
|
1600
|
+
if (!currentSessionMap.has(name)) {
|
|
1601
|
+
return true;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
for (const name of currentSessionMap.keys()) {
|
|
1605
|
+
if (!newSessionMap.has(name)) {
|
|
1606
|
+
return true;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
for (const [name, newSession] of newSessionMap) {
|
|
1610
|
+
const currentSession = currentSessionMap.get(name);
|
|
1611
|
+
if (currentSession && currentSession.status !== newSession.status) {
|
|
1612
|
+
return true;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// src/server/session-routes.ts
|
|
1619
|
+
function createSessionApiRouter() {
|
|
1620
|
+
const router = (0, import_express.Router)();
|
|
1621
|
+
router.get("/sessions", (_req, res) => {
|
|
1622
|
+
try {
|
|
1623
|
+
let sessions = getCurrentSessions();
|
|
1624
|
+
const { epicSlug, storySlug, status } = _req.query;
|
|
1625
|
+
if (epicSlug && typeof epicSlug === "string") {
|
|
1626
|
+
sessions = sessions.filter((s) => s.epicSlug === epicSlug);
|
|
1627
|
+
if (storySlug && typeof storySlug === "string") {
|
|
1628
|
+
sessions = sessions.filter((s) => s.storySlug === storySlug);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (status && typeof status === "string" && (status === "running" || status === "completed")) {
|
|
1632
|
+
sessions = sessions.filter((s) => s.status === status);
|
|
1633
|
+
}
|
|
1634
|
+
res.json(sessions);
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
console.error("Error fetching sessions:", error);
|
|
1637
|
+
res.status(500).json({ error: "Failed to fetch sessions" });
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
router.get("/sessions/:sessionName", (req, res) => {
|
|
1641
|
+
try {
|
|
1642
|
+
const { sessionName } = req.params;
|
|
1643
|
+
const sessions = getCurrentSessions();
|
|
1644
|
+
const session = sessions.find((s) => s.name === sessionName);
|
|
1645
|
+
if (!session) {
|
|
1646
|
+
res.status(404).json({ error: "Session not found" });
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
res.json(session);
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
console.error("Error fetching session:", error);
|
|
1652
|
+
res.status(500).json({ error: "Failed to fetch session" });
|
|
1653
|
+
}
|
|
1654
|
+
});
|
|
1655
|
+
return router;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// src/server/routes.ts
|
|
1458
1659
|
async function getEpics(sagaRoot) {
|
|
1459
1660
|
return scanSagaDirectory(sagaRoot);
|
|
1460
1661
|
}
|
|
@@ -1467,7 +1668,7 @@ function toEpicSummary(epic) {
|
|
|
1467
1668
|
};
|
|
1468
1669
|
}
|
|
1469
1670
|
function createApiRouter(sagaRoot) {
|
|
1470
|
-
const router = (0,
|
|
1671
|
+
const router = (0, import_express2.Router)();
|
|
1471
1672
|
router.get("/epics", async (_req, res) => {
|
|
1472
1673
|
try {
|
|
1473
1674
|
const epics = await getEpics(sagaRoot);
|
|
@@ -1520,6 +1721,7 @@ function createApiRouter(sagaRoot) {
|
|
|
1520
1721
|
res.status(500).json({ error: "Failed to fetch story" });
|
|
1521
1722
|
}
|
|
1522
1723
|
});
|
|
1724
|
+
router.use(createSessionApiRouter());
|
|
1523
1725
|
router.use((_req, res) => {
|
|
1524
1726
|
res.status(404).json({ error: "API endpoint not found" });
|
|
1525
1727
|
});
|
|
@@ -1685,6 +1887,290 @@ async function createSagaWatcher(sagaRoot) {
|
|
|
1685
1887
|
|
|
1686
1888
|
// src/server/websocket.ts
|
|
1687
1889
|
var import_path5 = require("path");
|
|
1890
|
+
|
|
1891
|
+
// src/lib/log-stream-manager.ts
|
|
1892
|
+
var import_chokidar2 = __toESM(require("chokidar"), 1);
|
|
1893
|
+
var import_node_fs6 = require("node:fs");
|
|
1894
|
+
var import_promises4 = require("node:fs/promises");
|
|
1895
|
+
var import_node_path6 = require("node:path");
|
|
1896
|
+
var LogStreamManager = class {
|
|
1897
|
+
/**
|
|
1898
|
+
* Active file watchers indexed by session name
|
|
1899
|
+
*/
|
|
1900
|
+
watchers = /* @__PURE__ */ new Map();
|
|
1901
|
+
/**
|
|
1902
|
+
* Current file position (byte offset) per session for incremental reads
|
|
1903
|
+
*/
|
|
1904
|
+
filePositions = /* @__PURE__ */ new Map();
|
|
1905
|
+
/**
|
|
1906
|
+
* Client subscriptions per session
|
|
1907
|
+
*/
|
|
1908
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
1909
|
+
/**
|
|
1910
|
+
* Function to send messages to clients
|
|
1911
|
+
*/
|
|
1912
|
+
sendToClient;
|
|
1913
|
+
/**
|
|
1914
|
+
* Create a new LogStreamManager instance
|
|
1915
|
+
*
|
|
1916
|
+
* @param sendToClient - Function to send log data messages to clients
|
|
1917
|
+
*/
|
|
1918
|
+
constructor(sendToClient) {
|
|
1919
|
+
this.sendToClient = sendToClient;
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Get the number of subscriptions for a session
|
|
1923
|
+
*
|
|
1924
|
+
* @param sessionName - The session to check
|
|
1925
|
+
* @returns Number of subscribed clients
|
|
1926
|
+
*/
|
|
1927
|
+
getSubscriptionCount(sessionName) {
|
|
1928
|
+
const subs = this.subscriptions.get(sessionName);
|
|
1929
|
+
return subs ? subs.size : 0;
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Check if a watcher exists for a session
|
|
1933
|
+
*
|
|
1934
|
+
* @param sessionName - The session to check
|
|
1935
|
+
* @returns True if a watcher exists
|
|
1936
|
+
*/
|
|
1937
|
+
hasWatcher(sessionName) {
|
|
1938
|
+
return this.watchers.has(sessionName);
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Get the current file position for a session
|
|
1942
|
+
*
|
|
1943
|
+
* @param sessionName - The session to check
|
|
1944
|
+
* @returns The current byte offset, or 0 if not tracked
|
|
1945
|
+
*/
|
|
1946
|
+
getFilePosition(sessionName) {
|
|
1947
|
+
return this.filePositions.get(sessionName) ?? 0;
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Subscribe a client to a session's log stream
|
|
1951
|
+
*
|
|
1952
|
+
* Reads the full file content and sends it as the initial message.
|
|
1953
|
+
* Adds the client to the subscription set for incremental updates.
|
|
1954
|
+
* Creates a file watcher if this is the first subscriber.
|
|
1955
|
+
*
|
|
1956
|
+
* @param sessionName - The session to subscribe to
|
|
1957
|
+
* @param ws - The WebSocket client to subscribe
|
|
1958
|
+
*/
|
|
1959
|
+
async subscribe(sessionName, ws) {
|
|
1960
|
+
const outputFile = (0, import_node_path6.join)(OUTPUT_DIR, `${sessionName}.out`);
|
|
1961
|
+
if (!(0, import_node_fs6.existsSync)(outputFile)) {
|
|
1962
|
+
this.sendToClient(ws, {
|
|
1963
|
+
type: "logs:error",
|
|
1964
|
+
sessionName,
|
|
1965
|
+
error: `Output file not found: ${outputFile}`
|
|
1966
|
+
});
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
const content = await (0, import_promises4.readFile)(outputFile, "utf-8");
|
|
1970
|
+
this.sendToClient(ws, {
|
|
1971
|
+
type: "logs:data",
|
|
1972
|
+
sessionName,
|
|
1973
|
+
data: content,
|
|
1974
|
+
isInitial: true,
|
|
1975
|
+
isComplete: false
|
|
1976
|
+
});
|
|
1977
|
+
this.filePositions.set(sessionName, content.length);
|
|
1978
|
+
let subs = this.subscriptions.get(sessionName);
|
|
1979
|
+
if (!subs) {
|
|
1980
|
+
subs = /* @__PURE__ */ new Set();
|
|
1981
|
+
this.subscriptions.set(sessionName, subs);
|
|
1982
|
+
}
|
|
1983
|
+
subs.add(ws);
|
|
1984
|
+
if (!this.watchers.has(sessionName)) {
|
|
1985
|
+
this.createWatcher(sessionName, outputFile);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Create a chokidar file watcher for a session's output file
|
|
1990
|
+
*
|
|
1991
|
+
* The watcher detects changes and triggers incremental content delivery
|
|
1992
|
+
* to all subscribed clients.
|
|
1993
|
+
*
|
|
1994
|
+
* @param sessionName - The session name
|
|
1995
|
+
* @param outputFile - Path to the session output file
|
|
1996
|
+
*/
|
|
1997
|
+
createWatcher(sessionName, outputFile) {
|
|
1998
|
+
const watcher = import_chokidar2.default.watch(outputFile, {
|
|
1999
|
+
persistent: true,
|
|
2000
|
+
awaitWriteFinish: false
|
|
2001
|
+
});
|
|
2002
|
+
watcher.on("change", async () => {
|
|
2003
|
+
await this.sendIncrementalContent(sessionName, outputFile);
|
|
2004
|
+
});
|
|
2005
|
+
this.watchers.set(sessionName, watcher);
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Clean up a watcher and associated state for a session
|
|
2009
|
+
*
|
|
2010
|
+
* Closes the file watcher and removes all tracking state for the session.
|
|
2011
|
+
* Should be called when the last subscriber unsubscribes or disconnects.
|
|
2012
|
+
*
|
|
2013
|
+
* @param sessionName - The session to clean up
|
|
2014
|
+
*/
|
|
2015
|
+
cleanupWatcher(sessionName) {
|
|
2016
|
+
const watcher = this.watchers.get(sessionName);
|
|
2017
|
+
if (watcher) {
|
|
2018
|
+
watcher.close();
|
|
2019
|
+
this.watchers.delete(sessionName);
|
|
2020
|
+
}
|
|
2021
|
+
this.filePositions.delete(sessionName);
|
|
2022
|
+
this.subscriptions.delete(sessionName);
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Send incremental content to all subscribed clients for a session
|
|
2026
|
+
*
|
|
2027
|
+
* Reads from the last known position to the end of the file and sends
|
|
2028
|
+
* the new content to all subscribed clients.
|
|
2029
|
+
*
|
|
2030
|
+
* @param sessionName - The session name
|
|
2031
|
+
* @param outputFile - Path to the session output file
|
|
2032
|
+
*/
|
|
2033
|
+
async sendIncrementalContent(sessionName, outputFile) {
|
|
2034
|
+
const lastPosition = this.filePositions.get(sessionName) ?? 0;
|
|
2035
|
+
const fileStat = await (0, import_promises4.stat)(outputFile);
|
|
2036
|
+
const currentSize = fileStat.size;
|
|
2037
|
+
if (currentSize <= lastPosition) {
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
const newContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
|
|
2041
|
+
this.filePositions.set(sessionName, currentSize);
|
|
2042
|
+
const subs = this.subscriptions.get(sessionName);
|
|
2043
|
+
if (subs) {
|
|
2044
|
+
const message = {
|
|
2045
|
+
type: "logs:data",
|
|
2046
|
+
sessionName,
|
|
2047
|
+
data: newContent,
|
|
2048
|
+
isInitial: false,
|
|
2049
|
+
isComplete: false
|
|
2050
|
+
};
|
|
2051
|
+
for (const ws of subs) {
|
|
2052
|
+
this.sendToClient(ws, message);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Read file content from a specific position
|
|
2058
|
+
*
|
|
2059
|
+
* @param filePath - Path to the file
|
|
2060
|
+
* @param start - Starting byte position
|
|
2061
|
+
* @param end - Ending byte position
|
|
2062
|
+
* @returns The content read from the file
|
|
2063
|
+
*/
|
|
2064
|
+
readFromPosition(filePath, start, end) {
|
|
2065
|
+
return new Promise((resolve2, reject) => {
|
|
2066
|
+
let content = "";
|
|
2067
|
+
const stream = (0, import_node_fs6.createReadStream)(filePath, {
|
|
2068
|
+
start,
|
|
2069
|
+
end: end - 1,
|
|
2070
|
+
// createReadStream end is inclusive
|
|
2071
|
+
encoding: "utf-8"
|
|
2072
|
+
});
|
|
2073
|
+
stream.on("data", (chunk) => {
|
|
2074
|
+
content += chunk;
|
|
2075
|
+
});
|
|
2076
|
+
stream.on("end", () => {
|
|
2077
|
+
resolve2(content);
|
|
2078
|
+
});
|
|
2079
|
+
stream.on("error", reject);
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* Unsubscribe a client from a session's log stream
|
|
2084
|
+
*
|
|
2085
|
+
* Removes the client from the subscription set. If this was the last
|
|
2086
|
+
* subscriber, cleans up the watcher and associated state.
|
|
2087
|
+
*
|
|
2088
|
+
* @param sessionName - The session to unsubscribe from
|
|
2089
|
+
* @param ws - The WebSocket client to unsubscribe
|
|
2090
|
+
*/
|
|
2091
|
+
unsubscribe(sessionName, ws) {
|
|
2092
|
+
const subs = this.subscriptions.get(sessionName);
|
|
2093
|
+
if (subs) {
|
|
2094
|
+
subs.delete(ws);
|
|
2095
|
+
if (subs.size === 0) {
|
|
2096
|
+
this.cleanupWatcher(sessionName);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Handle client disconnect by removing from all subscriptions
|
|
2102
|
+
*
|
|
2103
|
+
* Should be called when a WebSocket connection closes to clean up
|
|
2104
|
+
* any subscriptions the client may have had. Also triggers watcher
|
|
2105
|
+
* cleanup for any sessions that no longer have subscribers.
|
|
2106
|
+
*
|
|
2107
|
+
* @param ws - The WebSocket client that disconnected
|
|
2108
|
+
*/
|
|
2109
|
+
handleClientDisconnect(ws) {
|
|
2110
|
+
for (const [sessionName, subs] of this.subscriptions) {
|
|
2111
|
+
subs.delete(ws);
|
|
2112
|
+
if (subs.size === 0) {
|
|
2113
|
+
this.cleanupWatcher(sessionName);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Notify that a session has completed
|
|
2119
|
+
*
|
|
2120
|
+
* Reads any remaining content from the file and sends it with isComplete=true
|
|
2121
|
+
* to all subscribed clients, then cleans up the watcher regardless of
|
|
2122
|
+
* subscription count. Called by session polling when it detects completion.
|
|
2123
|
+
*
|
|
2124
|
+
* @param sessionName - The session that has completed
|
|
2125
|
+
*/
|
|
2126
|
+
async notifySessionCompleted(sessionName) {
|
|
2127
|
+
const subs = this.subscriptions.get(sessionName);
|
|
2128
|
+
if (!subs || subs.size === 0) {
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
const outputFile = (0, import_node_path6.join)(OUTPUT_DIR, `${sessionName}.out`);
|
|
2132
|
+
let finalContent = "";
|
|
2133
|
+
try {
|
|
2134
|
+
if ((0, import_node_fs6.existsSync)(outputFile)) {
|
|
2135
|
+
const lastPosition = this.filePositions.get(sessionName) ?? 0;
|
|
2136
|
+
const fileStat = await (0, import_promises4.stat)(outputFile);
|
|
2137
|
+
const currentSize = fileStat.size;
|
|
2138
|
+
if (currentSize > lastPosition) {
|
|
2139
|
+
finalContent = await this.readFromPosition(outputFile, lastPosition, currentSize);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
} catch {
|
|
2143
|
+
}
|
|
2144
|
+
const message = {
|
|
2145
|
+
type: "logs:data",
|
|
2146
|
+
sessionName,
|
|
2147
|
+
data: finalContent,
|
|
2148
|
+
isInitial: false,
|
|
2149
|
+
isComplete: true
|
|
2150
|
+
};
|
|
2151
|
+
for (const ws of subs) {
|
|
2152
|
+
this.sendToClient(ws, message);
|
|
2153
|
+
}
|
|
2154
|
+
this.cleanupWatcher(sessionName);
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Clean up all watchers and subscriptions
|
|
2158
|
+
*
|
|
2159
|
+
* Call this when shutting down the server.
|
|
2160
|
+
*/
|
|
2161
|
+
async dispose() {
|
|
2162
|
+
const closePromises = [];
|
|
2163
|
+
for (const [, watcher] of this.watchers) {
|
|
2164
|
+
closePromises.push(watcher.close());
|
|
2165
|
+
}
|
|
2166
|
+
await Promise.all(closePromises);
|
|
2167
|
+
this.watchers.clear();
|
|
2168
|
+
this.filePositions.clear();
|
|
2169
|
+
this.subscriptions.clear();
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
|
|
2173
|
+
// src/server/websocket.ts
|
|
1688
2174
|
function makeStoryKey(epicSlug, storySlug) {
|
|
1689
2175
|
return `${epicSlug}:${storySlug}`;
|
|
1690
2176
|
}
|
|
@@ -1721,11 +2207,30 @@ async function createWebSocketServer(httpServer, sagaRoot) {
|
|
|
1721
2207
|
ws.send(JSON.stringify(message));
|
|
1722
2208
|
}
|
|
1723
2209
|
}
|
|
2210
|
+
function sendLogMessage(ws, message) {
|
|
2211
|
+
if (ws.readyState === import_ws.WebSocket.OPEN) {
|
|
2212
|
+
ws.send(JSON.stringify({ event: message.type, data: message }));
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
const logStreamManager = new LogStreamManager(sendLogMessage);
|
|
1724
2216
|
function broadcast(message) {
|
|
1725
2217
|
for (const [ws] of clients) {
|
|
1726
2218
|
sendToClient(ws, message);
|
|
1727
2219
|
}
|
|
1728
2220
|
}
|
|
2221
|
+
let previousSessionStates = /* @__PURE__ */ new Map();
|
|
2222
|
+
startSessionPolling((msg) => {
|
|
2223
|
+
broadcast({ event: msg.type, data: msg.sessions });
|
|
2224
|
+
const currentStates = /* @__PURE__ */ new Map();
|
|
2225
|
+
for (const session of msg.sessions) {
|
|
2226
|
+
currentStates.set(session.name, session.status);
|
|
2227
|
+
const previousStatus = previousSessionStates.get(session.name);
|
|
2228
|
+
if (previousStatus === "running" && session.status === "completed") {
|
|
2229
|
+
logStreamManager.notifySessionCompleted(session.name);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
previousSessionStates = currentStates;
|
|
2233
|
+
});
|
|
1729
2234
|
function broadcastToSubscribers(storyKey, message) {
|
|
1730
2235
|
for (const [ws, state] of clients) {
|
|
1731
2236
|
if (state.subscribedStories.has(storyKey)) {
|
|
@@ -1766,6 +2271,20 @@ async function createWebSocketServer(httpServer, sagaRoot) {
|
|
|
1766
2271
|
}
|
|
1767
2272
|
break;
|
|
1768
2273
|
}
|
|
2274
|
+
case "subscribe:logs": {
|
|
2275
|
+
const { sessionName } = message.data || {};
|
|
2276
|
+
if (sessionName) {
|
|
2277
|
+
logStreamManager.subscribe(sessionName, ws);
|
|
2278
|
+
}
|
|
2279
|
+
break;
|
|
2280
|
+
}
|
|
2281
|
+
case "unsubscribe:logs": {
|
|
2282
|
+
const { sessionName } = message.data || {};
|
|
2283
|
+
if (sessionName) {
|
|
2284
|
+
logStreamManager.unsubscribe(sessionName, ws);
|
|
2285
|
+
}
|
|
2286
|
+
break;
|
|
2287
|
+
}
|
|
1769
2288
|
default:
|
|
1770
2289
|
break;
|
|
1771
2290
|
}
|
|
@@ -1774,10 +2293,12 @@ async function createWebSocketServer(httpServer, sagaRoot) {
|
|
|
1774
2293
|
});
|
|
1775
2294
|
ws.on("close", () => {
|
|
1776
2295
|
clients.delete(ws);
|
|
2296
|
+
logStreamManager.handleClientDisconnect(ws);
|
|
1777
2297
|
});
|
|
1778
2298
|
ws.on("error", (err) => {
|
|
1779
2299
|
console.error("WebSocket error:", err);
|
|
1780
2300
|
clients.delete(ws);
|
|
2301
|
+
logStreamManager.handleClientDisconnect(ws);
|
|
1781
2302
|
});
|
|
1782
2303
|
});
|
|
1783
2304
|
if (watcher) {
|
|
@@ -1848,6 +2369,8 @@ async function createWebSocketServer(httpServer, sagaRoot) {
|
|
|
1848
2369
|
},
|
|
1849
2370
|
async close() {
|
|
1850
2371
|
clearInterval(heartbeatInterval);
|
|
2372
|
+
stopSessionPolling();
|
|
2373
|
+
await logStreamManager.dispose();
|
|
1851
2374
|
for (const [ws] of clients) {
|
|
1852
2375
|
ws.close();
|
|
1853
2376
|
}
|
|
@@ -1871,8 +2394,8 @@ async function createWebSocketServer(httpServer, sagaRoot) {
|
|
|
1871
2394
|
// src/server/index.ts
|
|
1872
2395
|
var DEFAULT_PORT = 3847;
|
|
1873
2396
|
function createApp(sagaRoot) {
|
|
1874
|
-
const app = (0,
|
|
1875
|
-
app.use(
|
|
2397
|
+
const app = (0, import_express3.default)();
|
|
2398
|
+
app.use(import_express3.default.json());
|
|
1876
2399
|
app.use((_req, res, next) => {
|
|
1877
2400
|
res.header("Access-Control-Allow-Origin", "*");
|
|
1878
2401
|
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
@@ -1885,7 +2408,7 @@ function createApp(sagaRoot) {
|
|
|
1885
2408
|
app.use("/api", createApiRouter(sagaRoot));
|
|
1886
2409
|
const clientDistPath = (0, import_path6.join)(__dirname, "client");
|
|
1887
2410
|
const indexHtmlPath = (0, import_path6.join)(clientDistPath, "index.html");
|
|
1888
|
-
app.use(
|
|
2411
|
+
app.use(import_express3.default.static(clientDistPath));
|
|
1889
2412
|
app.get("/{*splat}", (_req, res) => {
|
|
1890
2413
|
res.sendFile("index.html", { root: clientDistPath });
|
|
1891
2414
|
});
|
|
@@ -1953,7 +2476,7 @@ async function dashboardCommand(options) {
|
|
|
1953
2476
|
}
|
|
1954
2477
|
|
|
1955
2478
|
// src/commands/scope-validator.ts
|
|
1956
|
-
var
|
|
2479
|
+
var import_node_path7 = require("node:path");
|
|
1957
2480
|
function getFilePathFromInput(hookInput) {
|
|
1958
2481
|
try {
|
|
1959
2482
|
const data = JSON.parse(hookInput);
|
|
@@ -1973,10 +2496,10 @@ function isArchiveAccess(path) {
|
|
|
1973
2496
|
return path.includes(".saga/archive");
|
|
1974
2497
|
}
|
|
1975
2498
|
function isWithinWorktree(filePath, worktreePath) {
|
|
1976
|
-
const absoluteFilePath = (0,
|
|
1977
|
-
const absoluteWorktree = (0,
|
|
1978
|
-
const relativePath = (0,
|
|
1979
|
-
if (relativePath.startsWith("..") || (0,
|
|
2499
|
+
const absoluteFilePath = (0, import_node_path7.resolve)(filePath);
|
|
2500
|
+
const absoluteWorktree = (0, import_node_path7.resolve)(worktreePath);
|
|
2501
|
+
const relativePath = (0, import_node_path7.relative)(absoluteWorktree, absoluteFilePath);
|
|
2502
|
+
if (relativePath.startsWith("..") || (0, import_node_path7.resolve)(relativePath) === relativePath) {
|
|
1980
2503
|
return false;
|
|
1981
2504
|
}
|
|
1982
2505
|
return true;
|
|
@@ -2091,8 +2614,8 @@ async function findCommand(query, options) {
|
|
|
2091
2614
|
}
|
|
2092
2615
|
|
|
2093
2616
|
// src/commands/worktree.ts
|
|
2094
|
-
var
|
|
2095
|
-
var
|
|
2617
|
+
var import_node_path8 = require("node:path");
|
|
2618
|
+
var import_node_fs7 = require("node:fs");
|
|
2096
2619
|
var import_node_child_process3 = require("node:child_process");
|
|
2097
2620
|
function runGitCommand(args, cwd) {
|
|
2098
2621
|
try {
|
|
@@ -2123,7 +2646,7 @@ function getMainBranch(cwd) {
|
|
|
2123
2646
|
}
|
|
2124
2647
|
function createWorktree(projectPath, epicSlug, storySlug) {
|
|
2125
2648
|
const branchName = `story-${storySlug}-epic-${epicSlug}`;
|
|
2126
|
-
const worktreePath = (0,
|
|
2649
|
+
const worktreePath = (0, import_node_path8.join)(
|
|
2127
2650
|
projectPath,
|
|
2128
2651
|
".saga",
|
|
2129
2652
|
"worktrees",
|
|
@@ -2136,7 +2659,7 @@ function createWorktree(projectPath, epicSlug, storySlug) {
|
|
|
2136
2659
|
error: `Branch already exists: ${branchName}`
|
|
2137
2660
|
};
|
|
2138
2661
|
}
|
|
2139
|
-
if ((0,
|
|
2662
|
+
if ((0, import_node_fs7.existsSync)(worktreePath)) {
|
|
2140
2663
|
return {
|
|
2141
2664
|
success: false,
|
|
2142
2665
|
error: `Worktree directory already exists: ${worktreePath}`
|
|
@@ -2154,8 +2677,8 @@ function createWorktree(projectPath, epicSlug, storySlug) {
|
|
|
2154
2677
|
error: `Failed to create branch: ${createBranchResult.output}`
|
|
2155
2678
|
};
|
|
2156
2679
|
}
|
|
2157
|
-
const worktreeParent = (0,
|
|
2158
|
-
(0,
|
|
2680
|
+
const worktreeParent = (0, import_node_path8.join)(projectPath, ".saga", "worktrees", epicSlug);
|
|
2681
|
+
(0, import_node_fs7.mkdirSync)(worktreeParent, { recursive: true });
|
|
2159
2682
|
const createWorktreeResult = runGitCommand(
|
|
2160
2683
|
["worktree", "add", worktreePath, branchName],
|
|
2161
2684
|
projectPath
|
|
@@ -2211,8 +2734,8 @@ async function sessionsKillCommand(sessionName) {
|
|
|
2211
2734
|
}
|
|
2212
2735
|
|
|
2213
2736
|
// src/cli.ts
|
|
2214
|
-
var packageJsonPath = (0,
|
|
2215
|
-
var packageJson = JSON.parse((0,
|
|
2737
|
+
var packageJsonPath = (0, import_node_path9.join)(__dirname, "..", "package.json");
|
|
2738
|
+
var packageJson = JSON.parse((0, import_node_fs8.readFileSync)(packageJsonPath, "utf-8"));
|
|
2216
2739
|
var program = new import_commander.Command();
|
|
2217
2740
|
program.name("saga").description("CLI for SAGA - Structured Autonomous Goal Achievement").version(packageJson.version).addHelpCommand("help [command]", "Display help for a command");
|
|
2218
2741
|
program.option("-p, --path <dir>", "Path to SAGA project directory (overrides auto-discovery)");
|