ralphflow 0.4.0 → 0.5.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/README.md +77 -87
- package/dist/chunk-TCCMQDVT.js +505 -0
- package/dist/ralphflow.js +207 -275
- package/dist/server-DOSLU36L.js +821 -0
- package/package.json +1 -2
- package/src/dashboard/ui/index.html +2760 -350
- package/src/templates/code-implementation/loops/00-story-loop/prompt.md +12 -6
- package/src/templates/code-implementation/ralphflow.yaml +3 -0
- package/src/templates/research/ralphflow.yaml +4 -0
- package/dist/chunk-GVOJO5IN.js +0 -274
- package/dist/server-O6J52DZT.js +0 -323
|
@@ -15,8 +15,10 @@ Read `.ralph-flow/{{APP_NAME}}/00-story-loop/tracker.md` FIRST to determine wher
|
|
|
15
15
|
## State Machine (3 stages per story)
|
|
16
16
|
|
|
17
17
|
**FIRST — Check completion.** Read the tracker. If the Stories Queue has entries
|
|
18
|
-
AND every entry is `[x]
|
|
19
|
-
|
|
18
|
+
AND every entry is `[x]` (no pending stories), do NOT write the completion promise yet.
|
|
19
|
+
Instead, go to **"No Stories? Collect Them"** to ask the user for new stories.
|
|
20
|
+
Only write `<promise>ALL STORIES PROCESSED</promise>` when the user explicitly
|
|
21
|
+
confirms they have no more stories to add.
|
|
20
22
|
|
|
21
23
|
Pick the lowest-numbered `ready` story. NEVER process a `blocked` story.
|
|
22
24
|
|
|
@@ -24,12 +26,16 @@ Pick the lowest-numbered `ready` story. NEVER process a `blocked` story.
|
|
|
24
26
|
|
|
25
27
|
## No Stories? Collect Them
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
**Triggers when:**
|
|
30
|
+
- `stories.md` has no stories at all (first run, empty queue with no entries), OR
|
|
31
|
+
- All stories in the queue are completed (`[x]`) and there are no `pending` stories left
|
|
32
|
+
|
|
33
|
+
**Flow:**
|
|
34
|
+
1. Tell the user: *"No pending stories. Tell me what you want to build — describe features, problems, or goals in your own words."*
|
|
29
35
|
2. Use `AskUserQuestion` to prompt: "What do you want to build or fix next?" (open-ended)
|
|
30
|
-
3. As the user narrates, capture each distinct idea as a `## STORY-{N}: {Title}` in `stories.md` with description and `**Depends on:** None` (or dependencies if mentioned)
|
|
36
|
+
3. As the user narrates, capture each distinct idea as a `## STORY-{N}: {Title}` in `stories.md` (continue numbering from existing stories) with description and `**Depends on:** None` (or dependencies if mentioned)
|
|
31
37
|
4. **Confirm stories & dependencies** — present all captured stories back. Use `AskUserQuestion` (up to 5 questions) to validate: correct stories? right dependency order? any to split/merge? priority adjustments?
|
|
32
|
-
5. Apply corrections, finalize `stories.md`, proceed to normal flow
|
|
38
|
+
5. Apply corrections, finalize `stories.md`, add new entries to tracker queue, proceed to normal flow
|
|
33
39
|
|
|
34
40
|
---
|
|
35
41
|
|
|
@@ -27,6 +27,7 @@ loops:
|
|
|
27
27
|
completion: "ALL STORIES PROCESSED"
|
|
28
28
|
feeds: [tasks-loop]
|
|
29
29
|
multi_agent: false
|
|
30
|
+
model: claude-sonnet-4-6
|
|
30
31
|
cadence: 0
|
|
31
32
|
|
|
32
33
|
tasks-loop:
|
|
@@ -44,6 +45,7 @@ loops:
|
|
|
44
45
|
completion: "ALL TASKS COMPLETE"
|
|
45
46
|
fed_by: [story-loop]
|
|
46
47
|
feeds: [delivery-loop]
|
|
48
|
+
model: claude-sonnet-4-6
|
|
47
49
|
multi_agent:
|
|
48
50
|
enabled: true
|
|
49
51
|
max_agents: 4
|
|
@@ -68,4 +70,5 @@ loops:
|
|
|
68
70
|
completion: "ALL DELIVERABLES PRESENTED"
|
|
69
71
|
fed_by: [tasks-loop]
|
|
70
72
|
multi_agent: false
|
|
73
|
+
model: claude-sonnet-4-6
|
|
71
74
|
cadence: 0
|
|
@@ -27,6 +27,7 @@ loops:
|
|
|
27
27
|
completion: "ALL TOPICS DISCOVERED"
|
|
28
28
|
feeds: [research-loop]
|
|
29
29
|
multi_agent: false
|
|
30
|
+
model: claude-sonnet-4-6
|
|
30
31
|
cadence: 0
|
|
31
32
|
|
|
32
33
|
research-loop:
|
|
@@ -41,6 +42,7 @@ loops:
|
|
|
41
42
|
completion: "ALL TOPICS RESEARCHED"
|
|
42
43
|
fed_by: [discovery-loop]
|
|
43
44
|
feeds: [story-loop]
|
|
45
|
+
model: claude-sonnet-4-6
|
|
44
46
|
multi_agent:
|
|
45
47
|
enabled: true
|
|
46
48
|
max_agents: 4
|
|
@@ -65,6 +67,7 @@ loops:
|
|
|
65
67
|
fed_by: [research-loop]
|
|
66
68
|
feeds: [document-loop]
|
|
67
69
|
multi_agent: false
|
|
70
|
+
model: claude-sonnet-4-6
|
|
68
71
|
cadence: 0
|
|
69
72
|
|
|
70
73
|
document-loop:
|
|
@@ -77,4 +80,5 @@ loops:
|
|
|
77
80
|
completion: "DOCUMENT COMPLETE"
|
|
78
81
|
fed_by: [story-loop]
|
|
79
82
|
multi_agent: false
|
|
83
|
+
model: claude-sonnet-4-6
|
|
80
84
|
cadence: 0
|
package/dist/chunk-GVOJO5IN.js
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
// src/core/config.ts
|
|
2
|
-
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { parse as parseYaml } from "yaml";
|
|
5
|
-
var LOOP_ALIASES = {
|
|
6
|
-
// code-implementation aliases
|
|
7
|
-
story: "story-loop",
|
|
8
|
-
stories: "story-loop",
|
|
9
|
-
tasks: "tasks-loop",
|
|
10
|
-
task: "tasks-loop",
|
|
11
|
-
delivery: "delivery-loop",
|
|
12
|
-
deliver: "delivery-loop",
|
|
13
|
-
// research aliases
|
|
14
|
-
discovery: "discovery-loop",
|
|
15
|
-
discover: "discovery-loop",
|
|
16
|
-
research: "research-loop",
|
|
17
|
-
document: "document-loop",
|
|
18
|
-
doc: "document-loop"
|
|
19
|
-
};
|
|
20
|
-
function listFlows(cwd) {
|
|
21
|
-
const baseDir = join(cwd, ".ralph-flow");
|
|
22
|
-
if (!existsSync(baseDir)) return [];
|
|
23
|
-
return readdirSync(baseDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).filter((d) => existsSync(join(baseDir, d.name, "ralphflow.yaml"))).map((d) => d.name);
|
|
24
|
-
}
|
|
25
|
-
function resolveFlowDir(cwd, flowName) {
|
|
26
|
-
const baseDir = join(cwd, ".ralph-flow");
|
|
27
|
-
if (!existsSync(baseDir)) {
|
|
28
|
-
throw new Error("No .ralph-flow/ found. Run `npx ralphflow init` first.");
|
|
29
|
-
}
|
|
30
|
-
const flows = listFlows(cwd);
|
|
31
|
-
if (flows.length === 0) {
|
|
32
|
-
throw new Error("No flows found in .ralph-flow/. Run `npx ralphflow init` first.");
|
|
33
|
-
}
|
|
34
|
-
if (flowName) {
|
|
35
|
-
if (!flows.includes(flowName)) {
|
|
36
|
-
throw new Error(`Flow "${flowName}" not found. Available: ${flows.join(", ")}`);
|
|
37
|
-
}
|
|
38
|
-
return join(baseDir, flowName);
|
|
39
|
-
}
|
|
40
|
-
if (flows.length === 1) {
|
|
41
|
-
return join(baseDir, flows[0]);
|
|
42
|
-
}
|
|
43
|
-
throw new Error(
|
|
44
|
-
`Multiple flows found: ${flows.join(", ")}. Use --flow <name> to specify which one.`
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
function loadConfig(flowDir) {
|
|
48
|
-
const configPath = join(flowDir, "ralphflow.yaml");
|
|
49
|
-
if (!existsSync(configPath)) {
|
|
50
|
-
throw new Error(`No ralphflow.yaml found in ${flowDir}`);
|
|
51
|
-
}
|
|
52
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
53
|
-
const config = parseYaml(raw);
|
|
54
|
-
if (!config.name) {
|
|
55
|
-
throw new Error('ralphflow.yaml: missing required field "name"');
|
|
56
|
-
}
|
|
57
|
-
if (!config.loops || Object.keys(config.loops).length === 0) {
|
|
58
|
-
throw new Error('ralphflow.yaml: missing required field "loops"');
|
|
59
|
-
}
|
|
60
|
-
if (!config.dir) {
|
|
61
|
-
config.dir = ".ralph-flow";
|
|
62
|
-
}
|
|
63
|
-
return config;
|
|
64
|
-
}
|
|
65
|
-
function resolveLoop(config, name) {
|
|
66
|
-
if (config.loops[name]) {
|
|
67
|
-
return { key: name, loop: config.loops[name] };
|
|
68
|
-
}
|
|
69
|
-
const aliased = LOOP_ALIASES[name.toLowerCase()];
|
|
70
|
-
if (aliased && config.loops[aliased]) {
|
|
71
|
-
return { key: aliased, loop: config.loops[aliased] };
|
|
72
|
-
}
|
|
73
|
-
for (const [key, loop] of Object.entries(config.loops)) {
|
|
74
|
-
if (key.startsWith(name) || loop.name.toLowerCase().includes(name.toLowerCase())) {
|
|
75
|
-
return { key, loop };
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
const available = Object.keys(config.loops).join(", ");
|
|
79
|
-
throw new Error(`Unknown loop "${name}". Available: ${available}`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// src/core/db.ts
|
|
83
|
-
import Database from "better-sqlite3";
|
|
84
|
-
import { join as join2 } from "path";
|
|
85
|
-
import { existsSync as existsSync2, mkdirSync } from "fs";
|
|
86
|
-
var SCHEMA = `
|
|
87
|
-
CREATE TABLE IF NOT EXISTS loop_state (
|
|
88
|
-
flow_name TEXT NOT NULL,
|
|
89
|
-
loop_key TEXT NOT NULL,
|
|
90
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
91
|
-
iterations_run INTEGER NOT NULL DEFAULT 0,
|
|
92
|
-
completed_at TEXT,
|
|
93
|
-
PRIMARY KEY (flow_name, loop_key)
|
|
94
|
-
);
|
|
95
|
-
`;
|
|
96
|
-
var _db = null;
|
|
97
|
-
function getDb(cwd) {
|
|
98
|
-
if (_db) return _db;
|
|
99
|
-
const dir = join2(cwd, ".ralph-flow");
|
|
100
|
-
if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
|
|
101
|
-
_db = new Database(join2(dir, ".ralphflow.db"));
|
|
102
|
-
_db.pragma("journal_mode = WAL");
|
|
103
|
-
_db.exec(SCHEMA);
|
|
104
|
-
return _db;
|
|
105
|
-
}
|
|
106
|
-
function getLoopStatus(db, flow, loopKey) {
|
|
107
|
-
const row = db.prepare("SELECT status FROM loop_state WHERE flow_name = ? AND loop_key = ?").get(flow, loopKey);
|
|
108
|
-
return row ? row.status : "pending";
|
|
109
|
-
}
|
|
110
|
-
function isLoopComplete(db, flow, loopKey) {
|
|
111
|
-
return getLoopStatus(db, flow, loopKey) === "complete";
|
|
112
|
-
}
|
|
113
|
-
function markLoopRunning(db, flow, loopKey) {
|
|
114
|
-
db.prepare(`
|
|
115
|
-
INSERT INTO loop_state (flow_name, loop_key, status, iterations_run)
|
|
116
|
-
VALUES (?, ?, 'running', 0)
|
|
117
|
-
ON CONFLICT(flow_name, loop_key) DO UPDATE SET status = 'running'
|
|
118
|
-
`).run(flow, loopKey);
|
|
119
|
-
}
|
|
120
|
-
function incrementIteration(db, flow, loopKey) {
|
|
121
|
-
db.prepare(`
|
|
122
|
-
UPDATE loop_state SET iterations_run = iterations_run + 1
|
|
123
|
-
WHERE flow_name = ? AND loop_key = ?
|
|
124
|
-
`).run(flow, loopKey);
|
|
125
|
-
}
|
|
126
|
-
function markLoopComplete(db, flow, loopKey) {
|
|
127
|
-
db.prepare(`
|
|
128
|
-
UPDATE loop_state SET status = 'complete', completed_at = datetime('now')
|
|
129
|
-
WHERE flow_name = ? AND loop_key = ?
|
|
130
|
-
`).run(flow, loopKey);
|
|
131
|
-
}
|
|
132
|
-
function getAllLoopStates(db, flow) {
|
|
133
|
-
return db.prepare("SELECT * FROM loop_state WHERE flow_name = ?").all(flow);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// src/core/status.ts
|
|
137
|
-
import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
|
|
138
|
-
import { join as join3 } from "path";
|
|
139
|
-
import chalk from "chalk";
|
|
140
|
-
import Table from "cli-table3";
|
|
141
|
-
async function showStatus(cwd, flowName) {
|
|
142
|
-
const flows = flowName ? [flowName] : listFlows(cwd);
|
|
143
|
-
if (flows.length === 0) {
|
|
144
|
-
console.log();
|
|
145
|
-
console.log(chalk.yellow(" No flows found. Run `npx ralphflow init` first."));
|
|
146
|
-
console.log();
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
for (const flow of flows) {
|
|
150
|
-
const flowDir = resolveFlowDir(cwd, flow);
|
|
151
|
-
const config = loadConfig(flowDir);
|
|
152
|
-
console.log();
|
|
153
|
-
console.log(chalk.bold(` RalphFlow \u2014 ${flow}`));
|
|
154
|
-
console.log();
|
|
155
|
-
const table = new Table({
|
|
156
|
-
chars: {
|
|
157
|
-
top: "",
|
|
158
|
-
"top-mid": "",
|
|
159
|
-
"top-left": "",
|
|
160
|
-
"top-right": "",
|
|
161
|
-
bottom: "",
|
|
162
|
-
"bottom-mid": "",
|
|
163
|
-
"bottom-left": "",
|
|
164
|
-
"bottom-right": "",
|
|
165
|
-
left: " ",
|
|
166
|
-
"left-mid": "",
|
|
167
|
-
mid: "",
|
|
168
|
-
"mid-mid": "",
|
|
169
|
-
right: "",
|
|
170
|
-
"right-mid": "",
|
|
171
|
-
middle: " "
|
|
172
|
-
},
|
|
173
|
-
style: { "padding-left": 0, "padding-right": 1 },
|
|
174
|
-
head: [
|
|
175
|
-
chalk.dim("Loop"),
|
|
176
|
-
chalk.dim("Stage"),
|
|
177
|
-
chalk.dim("Active"),
|
|
178
|
-
chalk.dim("Progress")
|
|
179
|
-
]
|
|
180
|
-
});
|
|
181
|
-
const sortedLoops = Object.entries(config.loops).sort(([, a], [, b]) => a.order - b.order);
|
|
182
|
-
for (const [key, loop] of sortedLoops) {
|
|
183
|
-
const status = parseTracker(loop.tracker, flowDir, loop.name);
|
|
184
|
-
table.push([
|
|
185
|
-
loop.name,
|
|
186
|
-
status.stage,
|
|
187
|
-
status.active,
|
|
188
|
-
`${status.completed}/${status.total}`
|
|
189
|
-
]);
|
|
190
|
-
if (status.agents && status.agents.length > 0) {
|
|
191
|
-
for (const agent of status.agents) {
|
|
192
|
-
table.push([
|
|
193
|
-
chalk.dim(` ${agent.name}`),
|
|
194
|
-
chalk.dim(agent.stage),
|
|
195
|
-
chalk.dim(agent.activeTask),
|
|
196
|
-
chalk.dim(agent.lastHeartbeat)
|
|
197
|
-
]);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
console.log(table.toString());
|
|
202
|
-
}
|
|
203
|
-
console.log();
|
|
204
|
-
}
|
|
205
|
-
function parseTracker(trackerPath, flowDir, loopName) {
|
|
206
|
-
const fullPath = join3(flowDir, trackerPath);
|
|
207
|
-
const status = {
|
|
208
|
-
loop: loopName,
|
|
209
|
-
stage: "\u2014",
|
|
210
|
-
active: "none",
|
|
211
|
-
completed: 0,
|
|
212
|
-
total: 0
|
|
213
|
-
};
|
|
214
|
-
if (!existsSync3(fullPath)) {
|
|
215
|
-
return status;
|
|
216
|
-
}
|
|
217
|
-
const content = readFileSync2(fullPath, "utf-8");
|
|
218
|
-
const lines = content.split("\n");
|
|
219
|
-
for (const line of lines) {
|
|
220
|
-
const metaMatch = line.match(/^- (\w[\w_]*): (.+)$/);
|
|
221
|
-
if (metaMatch) {
|
|
222
|
-
const [, key, value] = metaMatch;
|
|
223
|
-
if (key === "stage") status.stage = value.trim();
|
|
224
|
-
if (key === "active_story" || key === "active_task") status.active = value.trim();
|
|
225
|
-
if (key === "completed_stories" || key === "completed_tasks") {
|
|
226
|
-
const arrayMatch = value.match(/\[(.+)\]/);
|
|
227
|
-
if (arrayMatch) {
|
|
228
|
-
status.completed = arrayMatch[1].split(",").filter((s) => s.trim()).length;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
const unchecked = (content.match(/- \[ \]/g) || []).length;
|
|
234
|
-
const checked = (content.match(/- \[x\]/gi) || []).length;
|
|
235
|
-
if (unchecked + checked > 0) {
|
|
236
|
-
status.total = unchecked + checked;
|
|
237
|
-
status.completed = checked;
|
|
238
|
-
}
|
|
239
|
-
const agentTableMatch = content.match(/\| agent \|.*\n\|[-|]+\n((?:\|.*\n)*)/);
|
|
240
|
-
if (agentTableMatch) {
|
|
241
|
-
const agentRows = agentTableMatch[1].trim().split("\n");
|
|
242
|
-
status.agents = [];
|
|
243
|
-
for (const row of agentRows) {
|
|
244
|
-
const cells = row.split("|").map((s) => s.trim()).filter(Boolean);
|
|
245
|
-
if (cells.length >= 4) {
|
|
246
|
-
status.agents.push({
|
|
247
|
-
name: cells[0],
|
|
248
|
-
activeTask: cells[1],
|
|
249
|
-
stage: cells[2],
|
|
250
|
-
lastHeartbeat: cells[3]
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
if (status.agents.length === 0) {
|
|
255
|
-
status.agents = void 0;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return status;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export {
|
|
262
|
-
listFlows,
|
|
263
|
-
resolveFlowDir,
|
|
264
|
-
loadConfig,
|
|
265
|
-
resolveLoop,
|
|
266
|
-
getDb,
|
|
267
|
-
isLoopComplete,
|
|
268
|
-
markLoopRunning,
|
|
269
|
-
incrementIteration,
|
|
270
|
-
markLoopComplete,
|
|
271
|
-
getAllLoopStates,
|
|
272
|
-
showStatus,
|
|
273
|
-
parseTracker
|
|
274
|
-
};
|
package/dist/server-O6J52DZT.js
DELETED
|
@@ -1,323 +0,0 @@
|
|
|
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
|
-
};
|