@graypark/loophaus 3.0.0 → 3.1.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/bin/loophaus.mjs +172 -0
- package/core/events.mjs +44 -0
- package/core/trace-analyzer.mjs +51 -0
- package/package.json +1 -1
package/bin/loophaus.mjs
CHANGED
|
@@ -40,6 +40,9 @@ Usage:
|
|
|
40
40
|
npx @graypark/loophaus uninstall [--host <name>]
|
|
41
41
|
npx @graypark/loophaus status [--name <loop>]
|
|
42
42
|
npx @graypark/loophaus stats [--name <loop>]
|
|
43
|
+
npx @graypark/loophaus watch
|
|
44
|
+
npx @graypark/loophaus replay <trace-file> [--speed 2]
|
|
45
|
+
npx @graypark/loophaus compare <trace1> <trace2>
|
|
43
46
|
npx @graypark/loophaus loops
|
|
44
47
|
npx @graypark/loophaus --version
|
|
45
48
|
|
|
@@ -202,6 +205,172 @@ async function runStats() {
|
|
|
202
205
|
console.log(`Trace file: .loophaus/trace.jsonl (${events.length} events)`);
|
|
203
206
|
}
|
|
204
207
|
|
|
208
|
+
async function runWatch() {
|
|
209
|
+
const { getTracePath } = await import("../core/event-logger.mjs");
|
|
210
|
+
const { watch: fsWatch } = await import("node:fs");
|
|
211
|
+
const { readFile, stat } = await import("node:fs/promises");
|
|
212
|
+
const tracePath = getTracePath();
|
|
213
|
+
|
|
214
|
+
console.log(`Watching ${tracePath}...`);
|
|
215
|
+
console.log("(Ctrl+C to stop)\n");
|
|
216
|
+
|
|
217
|
+
let lastSize = 0;
|
|
218
|
+
try {
|
|
219
|
+
const s = await stat(tracePath);
|
|
220
|
+
lastSize = s.size;
|
|
221
|
+
} catch { /* file doesn't exist yet */ }
|
|
222
|
+
|
|
223
|
+
const COLORS = {
|
|
224
|
+
iteration: "\x1b[36m",
|
|
225
|
+
stop: "\x1b[31m",
|
|
226
|
+
continue: "\x1b[32m",
|
|
227
|
+
error: "\x1b[31m",
|
|
228
|
+
cost: "\x1b[33m",
|
|
229
|
+
state_change: "\x1b[35m",
|
|
230
|
+
verify_script: "\x1b[32m",
|
|
231
|
+
verify_failed: "\x1b[31m",
|
|
232
|
+
story_complete: "\x1b[32m",
|
|
233
|
+
loop_start: "\x1b[36m",
|
|
234
|
+
loop_end: "\x1b[36m",
|
|
235
|
+
};
|
|
236
|
+
const RESET = "\x1b[0m";
|
|
237
|
+
|
|
238
|
+
function printEvent(line) {
|
|
239
|
+
try {
|
|
240
|
+
const e = JSON.parse(line);
|
|
241
|
+
const color = COLORS[e.event] || "";
|
|
242
|
+
const time = e.ts ? new Date(e.ts).toLocaleTimeString() : "";
|
|
243
|
+
const detail = e.iteration ? ` iter=${e.iteration}` : e.reason ? ` reason=${e.reason}` : "";
|
|
244
|
+
console.log(`${color}[${time}] ${e.event}${detail}${RESET}`);
|
|
245
|
+
} catch { /* skip malformed */ }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const raw = await readFile(tracePath, "utf-8");
|
|
250
|
+
const lines = raw.trim().split("\n").slice(-20);
|
|
251
|
+
for (const line of lines) printEvent(line);
|
|
252
|
+
if (lines.length > 0) console.log("--- live ---\n");
|
|
253
|
+
} catch { /* no file yet */ }
|
|
254
|
+
|
|
255
|
+
const { dirname: pathDirname } = await import("node:path");
|
|
256
|
+
const dir = pathDirname(tracePath);
|
|
257
|
+
try {
|
|
258
|
+
fsWatch(dir, { recursive: false }, async () => {
|
|
259
|
+
try {
|
|
260
|
+
const s = await stat(tracePath);
|
|
261
|
+
if (s.size > lastSize) {
|
|
262
|
+
const raw = await readFile(tracePath, "utf-8");
|
|
263
|
+
const lines = raw.trim().split("\n");
|
|
264
|
+
const newLines = [];
|
|
265
|
+
let pos = 0;
|
|
266
|
+
for (const line of lines) {
|
|
267
|
+
pos += Buffer.byteLength(line + "\n");
|
|
268
|
+
if (pos > lastSize) newLines.push(line);
|
|
269
|
+
}
|
|
270
|
+
for (const line of newLines) printEvent(line);
|
|
271
|
+
lastSize = s.size;
|
|
272
|
+
}
|
|
273
|
+
} catch { /* read error */ }
|
|
274
|
+
});
|
|
275
|
+
} catch {
|
|
276
|
+
console.log("Cannot watch file. Make sure .loophaus/ directory exists.");
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
process.stdin.resume();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function runReplay() {
|
|
284
|
+
const file = args[1];
|
|
285
|
+
if (!file) {
|
|
286
|
+
console.log("Usage: loophaus replay <trace-file> [--speed 2]");
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
const speedRaw = getFlag("--speed") || "1";
|
|
290
|
+
const speed = speedRaw === "instant" ? 999999 : (parseFloat(speedRaw) || 1);
|
|
291
|
+
const speedLabel = speed >= 999999 ? "instant" : `${speed}x`;
|
|
292
|
+
|
|
293
|
+
const { readTrace } = await import("../core/event-logger.mjs");
|
|
294
|
+
const { replayTrace, analyzeTrace } = await import("../core/trace-analyzer.mjs");
|
|
295
|
+
|
|
296
|
+
let events;
|
|
297
|
+
if (file === ".loophaus/trace.jsonl" || file === "trace.jsonl") {
|
|
298
|
+
events = await readTrace();
|
|
299
|
+
} else {
|
|
300
|
+
const { readFile } = await import("node:fs/promises");
|
|
301
|
+
const raw = await readFile(file, "utf-8");
|
|
302
|
+
events = raw.trim().split("\n").map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (events.length === 0) { console.log("No events found."); return; }
|
|
306
|
+
|
|
307
|
+
const replayed = replayTrace(events, speed);
|
|
308
|
+
const analysis = analyzeTrace(events);
|
|
309
|
+
|
|
310
|
+
console.log(`Replaying ${events.length} events (${speedLabel})\n`);
|
|
311
|
+
|
|
312
|
+
const COLORS = { iteration: "\x1b[36m", stop: "\x1b[31m", continue: "\x1b[32m", error: "\x1b[31m", cost: "\x1b[33m", state_change: "\x1b[35m", verify_script: "\x1b[32m", verify_failed: "\x1b[31m", story_complete: "\x1b[32m", loop_start: "\x1b[36m", loop_end: "\x1b[36m" };
|
|
313
|
+
const RESET = "\x1b[0m";
|
|
314
|
+
|
|
315
|
+
let prevMs = 0;
|
|
316
|
+
for (const e of replayed) {
|
|
317
|
+
const delay = speed >= 999999 ? 0 : e.relativeMs - prevMs;
|
|
318
|
+
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
|
319
|
+
prevMs = e.relativeMs;
|
|
320
|
+
|
|
321
|
+
const color = COLORS[e.event] || "";
|
|
322
|
+
const time = e.ts ? new Date(e.ts).toLocaleTimeString() : "";
|
|
323
|
+
const detail = e.iteration ? ` iter=${e.iteration}` : e.reason ? ` reason=${e.reason}` : "";
|
|
324
|
+
console.log(`${color}[${time}] ${e.event}${detail}${RESET}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
console.log(`\n--- Summary ---`);
|
|
328
|
+
console.log(`Iterations: ${analysis.iterations}`);
|
|
329
|
+
console.log(`Duration: ${Math.round(analysis.durationMs / 1000)}s`);
|
|
330
|
+
if (analysis.totalCost > 0) console.log(`Cost: $${analysis.totalCost.toFixed(4)}`);
|
|
331
|
+
if (analysis.lastStopReason) console.log(`Stop reason: ${analysis.lastStopReason}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function runCompare() {
|
|
335
|
+
const file1 = args[1];
|
|
336
|
+
const file2 = args[2];
|
|
337
|
+
if (!file1 || !file2) {
|
|
338
|
+
console.log("Usage: loophaus compare <trace1> <trace2>");
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const { readFile } = await import("node:fs/promises");
|
|
343
|
+
const { compareTraces } = await import("../core/trace-analyzer.mjs");
|
|
344
|
+
|
|
345
|
+
function loadTrace(file) {
|
|
346
|
+
return readFile(file, "utf-8").then(raw =>
|
|
347
|
+
raw.trim().split("\n").map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const [t1, t2] = await Promise.all([loadTrace(file1), loadTrace(file2)]);
|
|
352
|
+
const result = compareTraces(t1, t2);
|
|
353
|
+
|
|
354
|
+
console.log("Loop Comparison");
|
|
355
|
+
console.log("═══════════════\n");
|
|
356
|
+
|
|
357
|
+
const fmt = (label, v1, v2, diff, unit = "") => {
|
|
358
|
+
const arrow = diff > 0 ? `+${diff}` : `${diff}`;
|
|
359
|
+
const color = diff > 0 ? "\x1b[31m" : diff < 0 ? "\x1b[32m" : "";
|
|
360
|
+
const reset = "\x1b[0m";
|
|
361
|
+
console.log(` ${label.padEnd(20)} ${String(v1).padStart(8)}${unit} vs ${String(v2).padStart(8)}${unit} ${color}(${arrow}${unit})${reset}`);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
fmt("Iterations", result.trace1.iterations, result.trace2.iterations, result.diff.iterations);
|
|
365
|
+
fmt("Duration", Math.round(result.trace1.durationMs / 1000), Math.round(result.trace2.durationMs / 1000), Math.round(result.diff.durationMs / 1000), "s");
|
|
366
|
+
fmt("Stories done", result.trace1.storiesCompleted, result.trace2.storiesCompleted, result.diff.storiesCompleted);
|
|
367
|
+
if (result.trace1.totalCost || result.trace2.totalCost) {
|
|
368
|
+
fmt("Cost", result.trace1.totalCost.toFixed(4), result.trace2.totalCost.toFixed(4), result.diff.totalCost.toFixed(4), "$");
|
|
369
|
+
}
|
|
370
|
+
fmt("Errors", result.trace1.errors, result.trace2.errors, result.diff.errors);
|
|
371
|
+
console.log("");
|
|
372
|
+
}
|
|
373
|
+
|
|
205
374
|
try {
|
|
206
375
|
switch (command) {
|
|
207
376
|
case "install": await runInstall(); break;
|
|
@@ -209,6 +378,9 @@ try {
|
|
|
209
378
|
case "status": await runStatus(); break;
|
|
210
379
|
case "stats": await runStats(); break;
|
|
211
380
|
case "loops": await runLoops(); break;
|
|
381
|
+
case "watch": await runWatch(); break;
|
|
382
|
+
case "replay": await runReplay(); break;
|
|
383
|
+
case "compare": await runCompare(); break;
|
|
212
384
|
default:
|
|
213
385
|
if (command.startsWith("-")) {
|
|
214
386
|
await runInstall();
|
package/core/events.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Event type constants and filter utilities
|
|
2
|
+
|
|
3
|
+
export const EventType = {
|
|
4
|
+
ITERATION: "iteration",
|
|
5
|
+
STOP: "stop",
|
|
6
|
+
CONTINUE: "continue",
|
|
7
|
+
DECISION: "decision",
|
|
8
|
+
STORY_COMPLETE: "story_complete",
|
|
9
|
+
TEST_RESULT: "test_result",
|
|
10
|
+
COST: "cost",
|
|
11
|
+
VERIFY_SCRIPT: "verify_script",
|
|
12
|
+
VERIFY_FAILED: "verify_failed",
|
|
13
|
+
LOOP_START: "loop_start",
|
|
14
|
+
LOOP_END: "loop_end",
|
|
15
|
+
CHECKPOINT: "checkpoint",
|
|
16
|
+
ERROR: "error",
|
|
17
|
+
STATE_CHANGE: "state_change",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function filterByType(events, type) {
|
|
21
|
+
return events.filter(e => e.event === type);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function filterByTimeRange(events, start, end) {
|
|
25
|
+
return events.filter(e => {
|
|
26
|
+
const ts = new Date(e.ts).getTime();
|
|
27
|
+
return ts >= start.getTime() && ts <= end.getTime();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function filterByLoopId(events, loopId) {
|
|
32
|
+
return events.filter(e => e.loop_id === loopId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function summarizeEvents(events) {
|
|
36
|
+
const counts = {};
|
|
37
|
+
for (const e of events) {
|
|
38
|
+
counts[e.event] = (counts[e.event] || 0) + 1;
|
|
39
|
+
}
|
|
40
|
+
const first = events[0];
|
|
41
|
+
const last = events[events.length - 1];
|
|
42
|
+
const durationMs = first && last ? new Date(last.ts).getTime() - new Date(first.ts).getTime() : 0;
|
|
43
|
+
return { counts, total: events.length, durationMs, firstTs: first?.ts, lastTs: last?.ts };
|
|
44
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Pure functions for trace analysis
|
|
2
|
+
|
|
3
|
+
import { EventType, filterByType, summarizeEvents } from "./events.mjs";
|
|
4
|
+
|
|
5
|
+
export function analyzeTrace(events) {
|
|
6
|
+
const summary = summarizeEvents(events);
|
|
7
|
+
const iterations = filterByType(events, EventType.ITERATION);
|
|
8
|
+
const stops = filterByType(events, EventType.STOP);
|
|
9
|
+
const errors = filterByType(events, EventType.ERROR);
|
|
10
|
+
const stories = filterByType(events, EventType.STORY_COMPLETE);
|
|
11
|
+
const costs = filterByType(events, EventType.COST);
|
|
12
|
+
|
|
13
|
+
const totalCost = costs.reduce((s, e) => s + (e.totalCost || 0), 0);
|
|
14
|
+
const lastStop = stops[stops.length - 1];
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
...summary,
|
|
18
|
+
iterations: iterations.length,
|
|
19
|
+
stops: stops.length,
|
|
20
|
+
errors: errors.length,
|
|
21
|
+
storiesCompleted: stories.length,
|
|
22
|
+
totalCost,
|
|
23
|
+
lastStopReason: lastStop?.reason || null,
|
|
24
|
+
avgIterationMs: iterations.length > 1 ? summary.durationMs / iterations.length : 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function compareTraces(trace1, trace2) {
|
|
29
|
+
const a1 = analyzeTrace(trace1);
|
|
30
|
+
const a2 = analyzeTrace(trace2);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
trace1: a1,
|
|
34
|
+
trace2: a2,
|
|
35
|
+
diff: {
|
|
36
|
+
iterations: a2.iterations - a1.iterations,
|
|
37
|
+
totalCost: a2.totalCost - a1.totalCost,
|
|
38
|
+
durationMs: a2.durationMs - a1.durationMs,
|
|
39
|
+
storiesCompleted: a2.storiesCompleted - a1.storiesCompleted,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function replayTrace(events, speed = 1) {
|
|
45
|
+
if (events.length === 0) return [];
|
|
46
|
+
const firstTs = new Date(events[0].ts).getTime();
|
|
47
|
+
return events.map(e => ({
|
|
48
|
+
...e,
|
|
49
|
+
relativeMs: Math.round((new Date(e.ts).getTime() - firstTs) / speed),
|
|
50
|
+
}));
|
|
51
|
+
}
|