@lnilluv/pi-ralph-loop 1.1.0 → 1.2.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/package.json +1 -1
- package/src/index.ts +34 -3
- package/src/runner-rpc.ts +3 -0
- package/tests/index.test.ts +185 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync,
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, AgentEndEvent as PiAgentEndEvent, ToolResultEvent as PiToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
5
5
|
import {
|
|
@@ -844,8 +844,8 @@ function exportRalphLogs(taskDir: string, destDir: string): { iterations: number
|
|
|
844
844
|
for (const entry of readdirSync(transcriptsDir)) {
|
|
845
845
|
const srcPath = join(transcriptsDir, entry);
|
|
846
846
|
try {
|
|
847
|
-
const stat =
|
|
848
|
-
if (stat.isFile()) {
|
|
847
|
+
const stat = lstatSync(srcPath);
|
|
848
|
+
if (stat.isFile() && !stat.isSymbolicLink()) {
|
|
849
849
|
copyFileSync(srcPath, join(destTranscripts, entry));
|
|
850
850
|
transcripts++;
|
|
851
851
|
}
|
|
@@ -1707,6 +1707,30 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
|
|
|
1707
1707
|
});
|
|
1708
1708
|
if (!result || result.kind === "not-found") return;
|
|
1709
1709
|
|
|
1710
|
+
const statusPath = join(result.taskDir, ".ralph-runner", "status.json");
|
|
1711
|
+
if (!existsSync(statusPath)) {
|
|
1712
|
+
ctx.ui.notify(`No active loop found at ${displayPath(ctx.cwd, result.taskDir)}. No run data exists.`, "warning");
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
const statusFile = readStatusFile(result.taskDir);
|
|
1717
|
+
const finishedStatuses = new Set([
|
|
1718
|
+
"complete",
|
|
1719
|
+
"max-iterations",
|
|
1720
|
+
"no-progress-exhaustion",
|
|
1721
|
+
"stopped",
|
|
1722
|
+
"timeout",
|
|
1723
|
+
"error",
|
|
1724
|
+
"cancelled",
|
|
1725
|
+
]);
|
|
1726
|
+
if (statusFile?.status && finishedStatuses.has(statusFile.status)) {
|
|
1727
|
+
ctx.ui.notify(
|
|
1728
|
+
`No active loop found at ${displayPath(ctx.cwd, result.taskDir)}. The loop already ended with status: ${statusFile.status}.`,
|
|
1729
|
+
"warning",
|
|
1730
|
+
);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1710
1734
|
createCancelSignal(result.taskDir);
|
|
1711
1735
|
ctx.ui.notify("Cancel requested. The active iteration will be terminated immediately.", "warning");
|
|
1712
1736
|
},
|
|
@@ -1737,6 +1761,13 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
|
|
|
1737
1761
|
ralphPath = join(taskDir, "RALPH.md");
|
|
1738
1762
|
}
|
|
1739
1763
|
|
|
1764
|
+
const resolvedTaskDir = resolve(taskDir);
|
|
1765
|
+
const resolvedCwd = resolve(ctx.cwd);
|
|
1766
|
+
if (!resolvedTaskDir.startsWith(resolvedCwd + "/") && resolvedTaskDir !== resolvedCwd) {
|
|
1767
|
+
ctx.ui.notify("Task path must be within the current working directory.", "error");
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1740
1771
|
if (existsSync(ralphPath)) {
|
|
1741
1772
|
ctx.ui.notify(`RALPH.md already exists at ${displayPath(ctx.cwd, ralphPath)}. Not overwriting.`, "error");
|
|
1742
1773
|
return;
|
package/src/runner-rpc.ts
CHANGED
|
@@ -34,6 +34,9 @@ export type RpcSubprocessConfig = {
|
|
|
34
34
|
thinkingLevel?: string;
|
|
35
35
|
/** Callback for observing events as they stream */
|
|
36
36
|
onEvent?: (event: RpcEvent) => void;
|
|
37
|
+
/** AbortSignal for cooperative cancellation. On abort, the direct child process is SIGKILLed.
|
|
38
|
+
* Grandchild processes may survive — the caller is responsible for process group cleanup
|
|
39
|
+
* if full-tree termination is required. */
|
|
37
40
|
signal?: AbortSignal;
|
|
38
41
|
};
|
|
39
42
|
|
package/tests/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
@@ -592,6 +592,18 @@ test("/ralph-cancel writes the cancel flag from persisted active loop state afte
|
|
|
592
592
|
guardrails: { blockCommands: [], protectedFiles: [] },
|
|
593
593
|
stopRequested: false,
|
|
594
594
|
};
|
|
595
|
+
writeStatusFile(taskDir, {
|
|
596
|
+
loopToken: persistedState.loopToken,
|
|
597
|
+
ralphPath: join(taskDir, "RALPH.md"),
|
|
598
|
+
taskDir,
|
|
599
|
+
cwd,
|
|
600
|
+
status: "running",
|
|
601
|
+
currentIteration: 3,
|
|
602
|
+
maxIterations: 5,
|
|
603
|
+
timeout: 300,
|
|
604
|
+
startedAt: new Date().toISOString(),
|
|
605
|
+
guardrails: { blockCommands: [], protectedFiles: [] },
|
|
606
|
+
});
|
|
595
607
|
const notifications: Array<{ message: string; level: string }> = [];
|
|
596
608
|
const harness = createHarness();
|
|
597
609
|
const handler = harness.handler("ralph-cancel");
|
|
@@ -624,6 +636,113 @@ test("/ralph-cancel writes the cancel flag from persisted active loop state afte
|
|
|
624
636
|
assert.equal(notifications.some(({ message }) => message.includes("No active ralph loop")), false);
|
|
625
637
|
});
|
|
626
638
|
|
|
639
|
+
test("/ralph-cancel refuses when the loop already finished", async (t) => {
|
|
640
|
+
const cwd = createTempDir();
|
|
641
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
642
|
+
|
|
643
|
+
const taskDir = join(cwd, "finished-loop-task");
|
|
644
|
+
mkdirSync(taskDir, { recursive: true });
|
|
645
|
+
writeStatusFile(taskDir, {
|
|
646
|
+
loopToken: "finished-loop-token",
|
|
647
|
+
ralphPath: join(taskDir, "RALPH.md"),
|
|
648
|
+
taskDir,
|
|
649
|
+
cwd,
|
|
650
|
+
status: "complete",
|
|
651
|
+
currentIteration: 3,
|
|
652
|
+
maxIterations: 5,
|
|
653
|
+
timeout: 300,
|
|
654
|
+
startedAt: new Date().toISOString(),
|
|
655
|
+
completedAt: new Date().toISOString(),
|
|
656
|
+
guardrails: { blockCommands: [], protectedFiles: [] },
|
|
657
|
+
});
|
|
658
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
659
|
+
const harness = createHarness();
|
|
660
|
+
const handler = harness.handler("ralph-cancel");
|
|
661
|
+
let ctx: any;
|
|
662
|
+
ctx = {
|
|
663
|
+
cwd,
|
|
664
|
+
hasUI: true,
|
|
665
|
+
ui: {
|
|
666
|
+
notify: (message: string, level: string) => notifications.push({ message, level }),
|
|
667
|
+
select: async () => undefined,
|
|
668
|
+
input: async () => undefined,
|
|
669
|
+
editor: async () => undefined,
|
|
670
|
+
setStatus: () => undefined,
|
|
671
|
+
},
|
|
672
|
+
sessionManager: createSessionManager([
|
|
673
|
+
{
|
|
674
|
+
type: "custom",
|
|
675
|
+
customType: "ralph-loop-state",
|
|
676
|
+
data: {
|
|
677
|
+
active: true,
|
|
678
|
+
loopToken: "finished-loop-token",
|
|
679
|
+
cwd,
|
|
680
|
+
taskDir,
|
|
681
|
+
iteration: 3,
|
|
682
|
+
maxIterations: 5,
|
|
683
|
+
noProgressStreak: 0,
|
|
684
|
+
iterationSummaries: [],
|
|
685
|
+
guardrails: { blockCommands: [], protectedFiles: [] },
|
|
686
|
+
stopRequested: false,
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
], "session-a"),
|
|
690
|
+
getRuntimeCtx: () => ctx,
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
await handler("", ctx);
|
|
694
|
+
|
|
695
|
+
assert.equal(existsSync(join(taskDir, ".ralph-runner", "cancel.flag")), false);
|
|
696
|
+
assert.ok(notifications.some(({ message, level }) => level === "warning" && message.includes("The loop already ended with status: complete.")));
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("/ralph-cancel refuses when no status.json exists", async (t) => {
|
|
700
|
+
const cwd = createTempDir();
|
|
701
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
702
|
+
|
|
703
|
+
const taskDir = join(cwd, "missing-status-task");
|
|
704
|
+
mkdirSync(taskDir, { recursive: true });
|
|
705
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
706
|
+
const harness = createHarness();
|
|
707
|
+
const handler = harness.handler("ralph-cancel");
|
|
708
|
+
let ctx: any;
|
|
709
|
+
ctx = {
|
|
710
|
+
cwd,
|
|
711
|
+
hasUI: true,
|
|
712
|
+
ui: {
|
|
713
|
+
notify: (message: string, level: string) => notifications.push({ message, level }),
|
|
714
|
+
select: async () => undefined,
|
|
715
|
+
input: async () => undefined,
|
|
716
|
+
editor: async () => undefined,
|
|
717
|
+
setStatus: () => undefined,
|
|
718
|
+
},
|
|
719
|
+
sessionManager: createSessionManager([
|
|
720
|
+
{
|
|
721
|
+
type: "custom",
|
|
722
|
+
customType: "ralph-loop-state",
|
|
723
|
+
data: {
|
|
724
|
+
active: true,
|
|
725
|
+
loopToken: "missing-status-loop-token",
|
|
726
|
+
cwd,
|
|
727
|
+
taskDir,
|
|
728
|
+
iteration: 3,
|
|
729
|
+
maxIterations: 5,
|
|
730
|
+
noProgressStreak: 0,
|
|
731
|
+
iterationSummaries: [],
|
|
732
|
+
guardrails: { blockCommands: [], protectedFiles: [] },
|
|
733
|
+
stopRequested: false,
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
], "session-a"),
|
|
737
|
+
getRuntimeCtx: () => ctx,
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
await handler("", ctx);
|
|
741
|
+
|
|
742
|
+
assert.equal(existsSync(join(taskDir, ".ralph-runner", "cancel.flag")), false);
|
|
743
|
+
assert.ok(notifications.some(({ message, level }) => level === "warning" && message.includes("No run data exists.")));
|
|
744
|
+
});
|
|
745
|
+
|
|
627
746
|
test("/ralph-scaffold creates a parseable scaffold from a task name", async (t) => {
|
|
628
747
|
const cwd = createTempDir();
|
|
629
748
|
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
@@ -680,6 +799,35 @@ test("/ralph-scaffold accepts path-style arguments", async (t) => {
|
|
|
680
799
|
assert.equal(existsSync(join(cwd, "feature", "new-task", "RALPH.md")), true);
|
|
681
800
|
});
|
|
682
801
|
|
|
802
|
+
test("/ralph-scaffold rejects paths outside the current working directory", async (t) => {
|
|
803
|
+
const cwd = createTempDir();
|
|
804
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
805
|
+
|
|
806
|
+
const escapedTaskDir = join(cwd, "..", "escape");
|
|
807
|
+
t.after(() => rmSync(escapedTaskDir, { recursive: true, force: true }));
|
|
808
|
+
|
|
809
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
810
|
+
const harness = createHarness();
|
|
811
|
+
const handler = harness.handler("ralph-scaffold");
|
|
812
|
+
const ctx = {
|
|
813
|
+
cwd,
|
|
814
|
+
hasUI: true,
|
|
815
|
+
ui: {
|
|
816
|
+
notify: (message: string, level: string) => notifications.push({ message, level }),
|
|
817
|
+
select: async () => undefined,
|
|
818
|
+
input: async () => undefined,
|
|
819
|
+
editor: async () => undefined,
|
|
820
|
+
setStatus: () => undefined,
|
|
821
|
+
},
|
|
822
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
await handler("../escape", ctx);
|
|
826
|
+
|
|
827
|
+
assert.equal(existsSync(join(escapedTaskDir, "RALPH.md")), false);
|
|
828
|
+
assert.deepEqual(notifications, [{ message: "Task path must be within the current working directory.", level: "error" }]);
|
|
829
|
+
});
|
|
830
|
+
|
|
683
831
|
test("/ralph-scaffold refuses to overwrite an existing RALPH.md", async (t) => {
|
|
684
832
|
const cwd = createTempDir();
|
|
685
833
|
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
@@ -846,6 +994,42 @@ test("/ralph-logs excludes runtime control files", async (t) => {
|
|
|
846
994
|
assert.equal(existsSync(join(exportedDir, "active-loops")), false);
|
|
847
995
|
});
|
|
848
996
|
|
|
997
|
+
test("/ralph-logs skips symlinked transcript entries", async (t) => {
|
|
998
|
+
const cwd = createTempDir();
|
|
999
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
1000
|
+
|
|
1001
|
+
const taskDir = join(cwd, "my-task");
|
|
1002
|
+
mkdirSync(join(taskDir, ".ralph-runner", "transcripts"), { recursive: true });
|
|
1003
|
+
writeFileSync(join(taskDir, "RALPH.md"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
|
|
1004
|
+
writeFileSync(join(taskDir, ".ralph-runner", "status.json"), JSON.stringify({ status: "running" }), "utf8");
|
|
1005
|
+
writeFileSync(join(taskDir, ".ralph-runner", "iterations.jsonl"), "{\"iteration\":1}\n", "utf8");
|
|
1006
|
+
writeFileSync(join(taskDir, ".ralph-runner", "events.jsonl"), "{\"event\":1}\n", "utf8");
|
|
1007
|
+
writeFileSync(join(taskDir, "secret.txt"), "top secret", "utf8");
|
|
1008
|
+
writeFileSync(join(taskDir, ".ralph-runner", "transcripts", "good.txt"), "good", "utf8");
|
|
1009
|
+
symlinkSync(join(taskDir, "secret.txt"), join(taskDir, ".ralph-runner", "transcripts", "leak.txt"));
|
|
1010
|
+
|
|
1011
|
+
const harness = createHarness();
|
|
1012
|
+
const handler = harness.handler("ralph-logs");
|
|
1013
|
+
const ctx = {
|
|
1014
|
+
cwd,
|
|
1015
|
+
hasUI: true,
|
|
1016
|
+
ui: {
|
|
1017
|
+
notify: () => undefined,
|
|
1018
|
+
select: async () => undefined,
|
|
1019
|
+
input: async () => undefined,
|
|
1020
|
+
editor: async () => undefined,
|
|
1021
|
+
setStatus: () => undefined,
|
|
1022
|
+
},
|
|
1023
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
await handler("my-task --dest exported", ctx);
|
|
1027
|
+
|
|
1028
|
+
const exportedDir = join(cwd, "exported");
|
|
1029
|
+
assert.equal(existsSync(join(exportedDir, "transcripts", "good.txt")), true);
|
|
1030
|
+
assert.equal(existsSync(join(exportedDir, "transcripts", "leak.txt")), false);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
849
1033
|
test("/ralph reverse engineer this app with an injected llm-strengthened draft still shows review before start", async (t) => {
|
|
850
1034
|
const cwd = createTempDir();
|
|
851
1035
|
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|