ralphflow 0.2.0 → 0.4.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.
@@ -0,0 +1,323 @@
1
+ import {
2
+ getAllLoopStates,
3
+ getDb,
4
+ listFlows,
5
+ loadConfig,
6
+ parseTracker,
7
+ resolveFlowDir
8
+ } from "./chunk-GVOJO5IN.js";
9
+
10
+ // src/dashboard/server.ts
11
+ import { Hono as Hono2 } from "hono";
12
+ import { cors } from "hono/cors";
13
+ import { serve } from "@hono/node-server";
14
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
15
+ import { join as join3, dirname } from "path";
16
+ import { fileURLToPath } from "url";
17
+ import { WebSocketServer as WebSocketServer2 } from "ws";
18
+ import chalk from "chalk";
19
+
20
+ // src/dashboard/api.ts
21
+ import { Hono } from "hono";
22
+ import { readFileSync, writeFileSync, readdirSync, existsSync } from "fs";
23
+ import { resolve } from "path";
24
+ function createApiRoutes(cwd) {
25
+ const api = new Hono();
26
+ api.get("/api/apps", (c) => {
27
+ const flows = listFlows(cwd);
28
+ const apps = flows.map((appName) => {
29
+ const flowDir = resolveFlowDir(cwd, appName);
30
+ const config = loadConfig(flowDir);
31
+ const sortedLoops = Object.entries(config.loops).sort(([, a], [, b]) => a.order - b.order);
32
+ return {
33
+ appName,
34
+ appType: config.name,
35
+ description: config.description || "",
36
+ loops: sortedLoops.map(([key, loop]) => ({
37
+ key,
38
+ name: loop.name,
39
+ order: loop.order,
40
+ stages: loop.stages
41
+ }))
42
+ };
43
+ });
44
+ return c.json(apps);
45
+ });
46
+ api.get("/api/apps/:app/status", (c) => {
47
+ const appName = c.req.param("app");
48
+ const flowDir = resolveFlowDir(cwd, appName);
49
+ const config = loadConfig(flowDir);
50
+ const sortedLoops = Object.entries(config.loops).sort(([, a], [, b]) => a.order - b.order);
51
+ const statuses = sortedLoops.map(([key, loop]) => ({
52
+ key,
53
+ ...parseTracker(loop.tracker, flowDir, loop.name)
54
+ }));
55
+ return c.json(statuses);
56
+ });
57
+ api.get("/api/apps/:app/config", (c) => {
58
+ const appName = c.req.param("app");
59
+ const flowDir = resolveFlowDir(cwd, appName);
60
+ const config = loadConfig(flowDir);
61
+ return c.json(config);
62
+ });
63
+ api.get("/api/apps/:app/db", (c) => {
64
+ const appName = c.req.param("app");
65
+ const db = getDb(cwd);
66
+ const rows = getAllLoopStates(db, appName);
67
+ return c.json(rows);
68
+ });
69
+ api.get("/api/apps/:app/loops/:loop/prompt", (c) => {
70
+ const { app: appName, loop: loopKey } = c.req.param();
71
+ const flowDir = resolveFlowDir(cwd, appName);
72
+ const config = loadConfig(flowDir);
73
+ const loopConfig = config.loops[loopKey];
74
+ if (!loopConfig) return c.json({ error: `Loop "${loopKey}" not found` }, 404);
75
+ const promptPath = resolve(flowDir, loopConfig.prompt);
76
+ if (!validatePath(promptPath, cwd)) {
77
+ return c.json({ error: "Invalid path" }, 403);
78
+ }
79
+ if (!existsSync(promptPath)) {
80
+ return c.json({ error: "prompt.md not found" }, 404);
81
+ }
82
+ const content = readFileSync(promptPath, "utf-8");
83
+ return c.json({ path: loopConfig.prompt, content });
84
+ });
85
+ api.put("/api/apps/:app/loops/:loop/prompt", async (c) => {
86
+ const { app: appName, loop: loopKey } = c.req.param();
87
+ const flowDir = resolveFlowDir(cwd, appName);
88
+ const config = loadConfig(flowDir);
89
+ const loopConfig = config.loops[loopKey];
90
+ if (!loopConfig) return c.json({ error: `Loop "${loopKey}" not found` }, 404);
91
+ const promptPath = resolve(flowDir, loopConfig.prompt);
92
+ if (!validatePath(promptPath, cwd)) {
93
+ return c.json({ error: "Invalid path" }, 403);
94
+ }
95
+ const body = await c.req.json();
96
+ writeFileSync(promptPath, body.content, "utf-8");
97
+ return c.json({ ok: true });
98
+ });
99
+ api.get("/api/apps/:app/loops/:loop/tracker", (c) => {
100
+ const { app: appName, loop: loopKey } = c.req.param();
101
+ const flowDir = resolveFlowDir(cwd, appName);
102
+ const config = loadConfig(flowDir);
103
+ const loopConfig = config.loops[loopKey];
104
+ if (!loopConfig) return c.json({ error: `Loop "${loopKey}" not found` }, 404);
105
+ const trackerPath = resolve(flowDir, loopConfig.tracker);
106
+ if (!validatePath(trackerPath, cwd)) {
107
+ return c.json({ error: "Invalid path" }, 403);
108
+ }
109
+ if (!existsSync(trackerPath)) {
110
+ return c.json({ error: "tracker.md not found", content: "" }, 404);
111
+ }
112
+ const content = readFileSync(trackerPath, "utf-8");
113
+ return c.json({ path: loopConfig.tracker, content });
114
+ });
115
+ api.get("/api/apps/:app/loops/:loop/files", (c) => {
116
+ const { app: appName, loop: loopKey } = c.req.param();
117
+ const flowDir = resolveFlowDir(cwd, appName);
118
+ const config = loadConfig(flowDir);
119
+ const loopConfig = config.loops[loopKey];
120
+ if (!loopConfig) return c.json({ error: `Loop "${loopKey}" not found` }, 404);
121
+ const loopDir = resolve(flowDir, loopConfig.prompt, "..");
122
+ if (!validatePath(loopDir, cwd)) {
123
+ return c.json({ error: "Invalid path" }, 403);
124
+ }
125
+ if (!existsSync(loopDir)) {
126
+ return c.json({ files: [] });
127
+ }
128
+ const files = readdirSync(loopDir, { withFileTypes: true }).map((d) => ({
129
+ name: d.name,
130
+ isDirectory: d.isDirectory()
131
+ }));
132
+ return c.json({ files });
133
+ });
134
+ return api;
135
+ }
136
+ function validatePath(resolvedPath, cwd) {
137
+ const ralphFlowDir = resolve(cwd, ".ralph-flow");
138
+ return resolvedPath.startsWith(ralphFlowDir) && !resolvedPath.includes("..");
139
+ }
140
+
141
+ // src/dashboard/watcher.ts
142
+ import { watch } from "chokidar";
143
+ import { join as join2, relative, sep } from "path";
144
+ import { WebSocket } from "ws";
145
+ function setupWatcher(cwd, wss) {
146
+ const ralphFlowDir = join2(cwd, ".ralph-flow");
147
+ const debounceTimers = /* @__PURE__ */ new Map();
148
+ const watcher = watch(ralphFlowDir, {
149
+ ignoreInitial: true
150
+ });
151
+ watcher.on("change", (filePath) => {
152
+ if (!filePath.endsWith(".md") && !filePath.endsWith(".yaml")) return;
153
+ const existing = debounceTimers.get(filePath);
154
+ if (existing) clearTimeout(existing);
155
+ debounceTimers.set(filePath, setTimeout(() => {
156
+ debounceTimers.delete(filePath);
157
+ handleFileChange(filePath, cwd, wss);
158
+ }, 300));
159
+ });
160
+ let cachedDbState = "";
161
+ const dbPollInterval = setInterval(() => {
162
+ try {
163
+ const db = getDb(cwd);
164
+ const flows = listFlows(cwd);
165
+ const allStates = {};
166
+ for (const flow of flows) {
167
+ allStates[flow] = getAllLoopStates(db, flow);
168
+ }
169
+ const stateStr = JSON.stringify(allStates);
170
+ if (stateStr !== cachedDbState) {
171
+ cachedDbState = stateStr;
172
+ const fullStatus = buildFullStatus(cwd);
173
+ broadcast(wss, { type: "status:full", apps: fullStatus });
174
+ }
175
+ } catch {
176
+ }
177
+ }, 2e3);
178
+ wss.on("connection", (ws) => {
179
+ const fullStatus = buildFullStatus(cwd);
180
+ ws.send(JSON.stringify({ type: "status:full", apps: fullStatus }));
181
+ });
182
+ return {
183
+ close() {
184
+ watcher.close();
185
+ clearInterval(dbPollInterval);
186
+ for (const timer of debounceTimers.values()) clearTimeout(timer);
187
+ debounceTimers.clear();
188
+ }
189
+ };
190
+ }
191
+ function handleFileChange(filePath, cwd, wss) {
192
+ const ralphFlowDir = join2(cwd, ".ralph-flow");
193
+ const rel = relative(ralphFlowDir, filePath);
194
+ const parts = rel.split(sep);
195
+ if (parts.length < 2) return;
196
+ const appName = parts[0];
197
+ if (filePath.endsWith("tracker.md")) {
198
+ try {
199
+ const flowDir = resolveFlowDir(cwd, appName);
200
+ const config = loadConfig(flowDir);
201
+ for (const [key, loop] of Object.entries(config.loops)) {
202
+ const trackerFullPath = join2(flowDir, loop.tracker);
203
+ if (trackerFullPath === filePath) {
204
+ const status = parseTracker(loop.tracker, flowDir, loop.name);
205
+ broadcast(wss, {
206
+ type: "tracker:updated",
207
+ app: appName,
208
+ loop: key,
209
+ status: { key, ...status }
210
+ });
211
+ return;
212
+ }
213
+ }
214
+ } catch {
215
+ }
216
+ }
217
+ broadcast(wss, {
218
+ type: "file:changed",
219
+ app: appName,
220
+ path: rel
221
+ });
222
+ }
223
+ function buildFullStatus(cwd) {
224
+ const flows = listFlows(cwd);
225
+ return flows.map((appName) => {
226
+ try {
227
+ const flowDir = resolveFlowDir(cwd, appName);
228
+ const config = loadConfig(flowDir);
229
+ const sortedLoops = Object.entries(config.loops).sort(([, a], [, b]) => a.order - b.order);
230
+ return {
231
+ appName,
232
+ appType: config.name,
233
+ description: config.description || "",
234
+ loops: sortedLoops.map(([key, loop]) => ({
235
+ key,
236
+ name: loop.name,
237
+ order: loop.order,
238
+ stages: loop.stages,
239
+ status: parseTracker(loop.tracker, flowDir, loop.name)
240
+ }))
241
+ };
242
+ } catch {
243
+ return {
244
+ appName,
245
+ appType: "unknown",
246
+ description: "",
247
+ loops: []
248
+ };
249
+ }
250
+ });
251
+ }
252
+ function broadcast(wss, event) {
253
+ const data = JSON.stringify(event);
254
+ for (const client of wss.clients) {
255
+ if (client.readyState === WebSocket.OPEN) {
256
+ client.send(data);
257
+ }
258
+ }
259
+ }
260
+
261
+ // src/dashboard/server.ts
262
+ var __dirname = dirname(fileURLToPath(import.meta.url));
263
+ function resolveUiPath() {
264
+ const candidates = [
265
+ join3(__dirname, "..", "dashboard", "ui", "index.html"),
266
+ // dev: src/dashboard/ -> src/dashboard/ui/
267
+ join3(__dirname, "..", "src", "dashboard", "ui", "index.html")
268
+ // bundled: dist/ -> src/dashboard/ui/
269
+ ];
270
+ for (const candidate of candidates) {
271
+ if (existsSync2(candidate)) return candidate;
272
+ }
273
+ throw new Error(
274
+ `Dashboard UI not found. Searched:
275
+ ${candidates.join("\n")}`
276
+ );
277
+ }
278
+ async function startDashboard(options) {
279
+ const { cwd, port = 4242 } = options;
280
+ const app = new Hono2();
281
+ app.use("*", cors({
282
+ origin: (origin) => origin || "*",
283
+ allowMethods: ["GET", "PUT", "POST", "DELETE"]
284
+ }));
285
+ const apiRoutes = createApiRoutes(cwd);
286
+ app.route("/", apiRoutes);
287
+ app.get("/", (c) => {
288
+ const htmlPath = resolveUiPath();
289
+ const html = readFileSync2(htmlPath, "utf-8");
290
+ return c.html(html);
291
+ });
292
+ const server = serve({
293
+ fetch: app.fetch,
294
+ port,
295
+ hostname: "127.0.0.1"
296
+ });
297
+ const wss = new WebSocketServer2({ noServer: true });
298
+ server.on("upgrade", (request, socket, head) => {
299
+ const url = new URL(request.url || "/", `http://${request.headers.host}`);
300
+ if (url.pathname === "/ws") {
301
+ wss.handleUpgrade(request, socket, head, (ws) => {
302
+ wss.emit("connection", ws, request);
303
+ });
304
+ } else {
305
+ socket.destroy();
306
+ }
307
+ });
308
+ const watcherHandle = setupWatcher(cwd, wss);
309
+ console.log();
310
+ console.log(chalk.bold(` Dashboard ${chalk.dim("\u2192")} http://localhost:${port}`));
311
+ console.log(chalk.dim(` Watching ${cwd}/.ralph-flow/`));
312
+ console.log();
313
+ return {
314
+ close() {
315
+ watcherHandle.close();
316
+ wss.close();
317
+ server.close();
318
+ }
319
+ };
320
+ }
321
+ export {
322
+ startDashboard
323
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralphflow",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Multi-agent AI workflow orchestration framework for Claude Code. Define pipelines as loops, coordinate parallel agents, and ship structured work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,9 +47,13 @@
47
47
  },
48
48
  "files": [
49
49
  "dist/",
50
- "src/templates/"
50
+ "src/templates/",
51
+ "src/dashboard/ui/"
51
52
  ],
52
53
  "dependencies": {
54
+ "@hono/node-server": "^1.19.11",
55
+ "@inquirer/prompts": "^8.3.0",
56
+ "better-sqlite3": "^12.6.2",
53
57
  "chalk": "^5.3.0",
54
58
  "chokidar": "^4.0.0",
55
59
  "cli-table3": "^0.6.5",
@@ -60,6 +64,7 @@
60
64
  "yaml": "^2.6.0"
61
65
  },
62
66
  "devDependencies": {
67
+ "@types/better-sqlite3": "^7.6.13",
63
68
  "@types/node": "^22.0.0",
64
69
  "@types/ws": "^8.5.0",
65
70
  "tsup": "^8.3.0",