@lnilluv/pi-ralph-loop 1.0.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/README.md +3 -0
- package/package.json +1 -1
- package/src/index.ts +336 -1
- package/src/ralph.ts +8 -1
- package/src/runner-rpc.ts +36 -1
- package/src/runner-state.ts +20 -3
- package/src/runner.ts +109 -24
- package/tests/index.test.ts +460 -4
- package/tests/ralph.test.ts +24 -0
- package/tests/runner-event-contract.test.ts +1 -1
- package/tests/runner-rpc.test.ts +89 -1
- package/tests/runner-state.test.ts +28 -0
- package/tests/runner.test.ts +206 -1
package/src/runner.ts
CHANGED
|
@@ -21,9 +21,12 @@ import {
|
|
|
21
21
|
type RunnerStatusFile,
|
|
22
22
|
appendIterationRecord,
|
|
23
23
|
appendRunnerEvent,
|
|
24
|
+
checkCancelSignal,
|
|
24
25
|
checkStopSignal,
|
|
26
|
+
clearCancelSignal,
|
|
25
27
|
clearRunnerDir,
|
|
26
28
|
clearStopSignal,
|
|
29
|
+
createCancelSignal,
|
|
27
30
|
ensureRunnerDir,
|
|
28
31
|
readActiveLoopRegistry,
|
|
29
32
|
readIterationRecords,
|
|
@@ -52,6 +55,8 @@ export type RunnerConfig = {
|
|
|
52
55
|
cwd: string;
|
|
53
56
|
timeout: number;
|
|
54
57
|
maxIterations: number;
|
|
58
|
+
/** Error policy: true = stop on error (default), false = continue on error */
|
|
59
|
+
stopOnError?: boolean;
|
|
55
60
|
/** Completion promise string from RALPH.md */
|
|
56
61
|
completionPromise?: string;
|
|
57
62
|
guardrails: { blockCommands: string[]; protectedFiles: string[] };
|
|
@@ -412,6 +417,7 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
|
|
|
412
417
|
pi,
|
|
413
418
|
runtimeArgs: initialRuntimeArgs = {},
|
|
414
419
|
} = config;
|
|
420
|
+
let currentStopOnError = config.stopOnError ?? true;
|
|
415
421
|
const runtimeArgs = initialRuntimeArgs;
|
|
416
422
|
|
|
417
423
|
const taskDir = dirname(ralphPath);
|
|
@@ -495,6 +501,13 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
|
|
|
495
501
|
break;
|
|
496
502
|
}
|
|
497
503
|
|
|
504
|
+
if (checkCancelSignal(taskDir)) {
|
|
505
|
+
recordActiveLoopStopObservation(cwd, taskDir, new Date().toISOString());
|
|
506
|
+
clearCancelSignal(taskDir);
|
|
507
|
+
finalStatus = "cancelled";
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
|
|
498
511
|
// Re-parse RALPH.md every iteration (live editing support)
|
|
499
512
|
if (!existsSync(ralphPath)) {
|
|
500
513
|
onNotify?.(`RALPH.md not found at ${ralphPath}, stopping runner`, "error");
|
|
@@ -523,6 +536,7 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
|
|
|
523
536
|
currentRequiredOutputs = fm.requiredOutputs ?? [];
|
|
524
537
|
currentInterIterationDelay = fm.interIterationDelay;
|
|
525
538
|
currentGuardrails = { blockCommands: fm.guardrails.blockCommands, protectedFiles: fm.guardrails.protectedFiles };
|
|
539
|
+
currentStopOnError = config.stopOnError ?? fm.stopOnError;
|
|
526
540
|
|
|
527
541
|
// Update status to running
|
|
528
542
|
const runningStatus: RunnerStatusFile = {
|
|
@@ -540,6 +554,7 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
|
|
|
540
554
|
onIterationStart?.(i, currentMaxIterations);
|
|
541
555
|
|
|
542
556
|
const iterStartMs = Date.now();
|
|
557
|
+
const iterationAbortController = new AbortController();
|
|
543
558
|
const completionRecord = currentCompletionPromise ? createCompletionRecord() : undefined;
|
|
544
559
|
logRunnerEvent(taskDir, {
|
|
545
560
|
type: "iteration.started",
|
|
@@ -577,29 +592,75 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
|
|
|
577
592
|
// Run RPC iteration
|
|
578
593
|
onNotify?.(`Iteration ${i}/${currentMaxIterations} starting`, "info");
|
|
579
594
|
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
595
|
+
const cancelPollInterval = setInterval(() => {
|
|
596
|
+
if (checkCancelSignal(taskDir)) {
|
|
597
|
+
iterationAbortController.abort();
|
|
598
|
+
}
|
|
599
|
+
}, 500);
|
|
600
|
+
|
|
601
|
+
let rpcResult: RpcSubprocessResult;
|
|
602
|
+
try {
|
|
603
|
+
rpcResult = await runRpcIteration({
|
|
604
|
+
prompt,
|
|
605
|
+
cwd,
|
|
606
|
+
timeoutMs: currentTimeout * 1000,
|
|
607
|
+
spawnCommand,
|
|
608
|
+
spawnArgs,
|
|
609
|
+
env: {
|
|
610
|
+
RALPH_RUNNER_TASK_DIR: taskDir,
|
|
611
|
+
RALPH_RUNNER_CWD: cwd,
|
|
612
|
+
RALPH_RUNNER_LOOP_TOKEN: loopToken,
|
|
613
|
+
RALPH_RUNNER_CURRENT_ITERATION: String(i),
|
|
614
|
+
RALPH_RUNNER_MAX_ITERATIONS: String(currentMaxIterations),
|
|
615
|
+
RALPH_RUNNER_NO_PROGRESS_STREAK: String(noProgressStreak),
|
|
616
|
+
RALPH_RUNNER_GUARDRAILS: JSON.stringify(currentGuardrails),
|
|
617
|
+
},
|
|
618
|
+
modelPattern: config.modelPattern,
|
|
619
|
+
provider: config.provider,
|
|
620
|
+
modelId: config.modelId,
|
|
621
|
+
thinkingLevel: config.thinkingLevel,
|
|
622
|
+
signal: iterationAbortController.signal,
|
|
623
|
+
});
|
|
624
|
+
} finally {
|
|
625
|
+
clearInterval(cancelPollInterval);
|
|
626
|
+
}
|
|
600
627
|
|
|
601
628
|
const iterEndMs = Date.now();
|
|
602
629
|
|
|
630
|
+
if (rpcResult.cancelled) {
|
|
631
|
+
const iterRecord: IterationRecord = {
|
|
632
|
+
iteration: i,
|
|
633
|
+
status: "cancelled",
|
|
634
|
+
startedAt: new Date(iterStartMs).toISOString(),
|
|
635
|
+
completedAt: new Date(iterEndMs).toISOString(),
|
|
636
|
+
durationMs: iterEndMs - iterStartMs,
|
|
637
|
+
progress: false,
|
|
638
|
+
changedFiles: [],
|
|
639
|
+
noProgressStreak: noProgressStreak + 1,
|
|
640
|
+
loopToken,
|
|
641
|
+
rpcTelemetry: rpcResult.telemetry,
|
|
642
|
+
};
|
|
643
|
+
iterations.push(iterRecord);
|
|
644
|
+
appendIterationRecord(taskDir, iterRecord);
|
|
645
|
+
writeIterationTranscriptSafe(iterRecord, rpcResult.lastAssistantText, "Iteration cancelled by operator");
|
|
646
|
+
logRunnerEvent(taskDir, {
|
|
647
|
+
type: "iteration.completed",
|
|
648
|
+
timestamp: new Date(iterEndMs).toISOString(),
|
|
649
|
+
iteration: i,
|
|
650
|
+
loopToken,
|
|
651
|
+
status: "cancelled",
|
|
652
|
+
progress: iterRecord.progress,
|
|
653
|
+
changedFiles: [],
|
|
654
|
+
noProgressStreak: iterRecord.noProgressStreak,
|
|
655
|
+
reason: "operator-cancel",
|
|
656
|
+
});
|
|
657
|
+
recordActiveLoopStopObservation(cwd, taskDir, new Date().toISOString());
|
|
658
|
+
clearCancelSignal(taskDir);
|
|
659
|
+
finalStatus = "cancelled";
|
|
660
|
+
onIterationComplete?.(iterRecord);
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
|
|
603
664
|
// Handle RPC failure
|
|
604
665
|
if (!rpcResult.success) {
|
|
605
666
|
const iterRecord: IterationRecord = {
|
|
@@ -639,13 +700,29 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
|
|
|
639
700
|
|
|
640
701
|
if (rpcResult.timedOut) {
|
|
641
702
|
onNotify?.(`Iteration ${i} timed out after ${currentTimeout}s`, "warning");
|
|
642
|
-
|
|
703
|
+
if (currentStopOnError) {
|
|
704
|
+
finalStatus = "timeout";
|
|
705
|
+
onIterationComplete?.(iterRecord);
|
|
706
|
+
break;
|
|
707
|
+
} else {
|
|
708
|
+
noProgressStreak += 1;
|
|
709
|
+
onNotify?.(`Continuing (stop_on_error=false).`, "warning");
|
|
710
|
+
onIterationComplete?.(iterRecord);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
643
713
|
} else {
|
|
644
714
|
onNotify?.(`Iteration ${i} error: ${rpcResult.error ?? "unknown"}`, "error");
|
|
645
|
-
|
|
715
|
+
if (currentStopOnError) {
|
|
716
|
+
finalStatus = "error";
|
|
717
|
+
onIterationComplete?.(iterRecord);
|
|
718
|
+
break;
|
|
719
|
+
} else {
|
|
720
|
+
noProgressStreak += 1;
|
|
721
|
+
onNotify?.(`Continuing (stop_on_error=false).`, "warning");
|
|
722
|
+
onIterationComplete?.(iterRecord);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
646
725
|
}
|
|
647
|
-
onIterationComplete?.(iterRecord);
|
|
648
|
-
break;
|
|
649
726
|
}
|
|
650
727
|
|
|
651
728
|
// After snapshot
|
|
@@ -878,6 +955,14 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
|
|
|
878
955
|
|
|
879
956
|
onNotify?.(`Iteration ${i} complete (${Math.round((iterEndMs - iterStartMs) / 1000)}s)`, "info");
|
|
880
957
|
|
|
958
|
+
// Quick cancel check before delay
|
|
959
|
+
if (checkCancelSignal(taskDir)) {
|
|
960
|
+
recordActiveLoopStopObservation(cwd, taskDir, new Date().toISOString());
|
|
961
|
+
clearCancelSignal(taskDir);
|
|
962
|
+
finalStatus = "cancelled";
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
|
|
881
966
|
if (i < currentMaxIterations && currentInterIterationDelay > 0) {
|
|
882
967
|
const stoppedDuringDelay = await waitForInterIterationDelay(taskDir, cwd, currentInterIterationDelay);
|
|
883
968
|
if (stoppedDuringDelay) {
|
package/tests/index.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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";
|
|
6
|
-
import registerRalphCommands, { runCommands } from "../src/index.ts";
|
|
6
|
+
import registerRalphCommands, { parseLogExportArgs, runCommands } from "../src/index.ts";
|
|
7
7
|
import { SECRET_PATH_POLICY_TOKEN } from "../src/secret-paths.ts";
|
|
8
|
-
import { generateDraft, parseRalphMarkdown, slugifyTask, validateFrontmatter, type DraftPlan, type DraftTarget } from "../src/ralph.ts";
|
|
8
|
+
import { generateDraft, inspectDraftContent, parseRalphMarkdown, slugifyTask, validateFrontmatter, type DraftPlan, type DraftTarget } from "../src/ralph.ts";
|
|
9
9
|
import type { StrengthenDraftRuntime } from "../src/ralph-draft-llm.ts";
|
|
10
10
|
import type { RunnerConfig, RunnerResult } from "../src/runner.ts";
|
|
11
11
|
import { runRalphLoop as realRunRalphLoop, captureTaskDirectorySnapshot, assessTaskDirectoryProgress, summarizeChangedFiles } from "../src/runner.ts";
|
|
@@ -398,7 +398,7 @@ test("registerRalphCommands is idempotent for the same extension API instance",
|
|
|
398
398
|
registerRalphCommands(pi, {} as any);
|
|
399
399
|
registerRalphCommands(pi, {} as any);
|
|
400
400
|
|
|
401
|
-
assert.deepEqual(registeredCommands, ["ralph", "ralph-draft", "ralph-stop"]);
|
|
401
|
+
assert.deepEqual(registeredCommands, ["ralph", "ralph-draft", "ralph-stop", "ralph-cancel", "ralph-scaffold", "ralph-logs"]);
|
|
402
402
|
assert.deepEqual(registeredEvents, [
|
|
403
403
|
"tool_call",
|
|
404
404
|
"tool_execution_start",
|
|
@@ -574,6 +574,462 @@ test("/ralph-stop writes the durable stop flag from persisted active loop state
|
|
|
574
574
|
assert.equal(notifications.some(({ message }) => message.includes("No active ralph loop")), false);
|
|
575
575
|
});
|
|
576
576
|
|
|
577
|
+
test("/ralph-cancel writes the cancel flag from persisted active loop state after reload", async (t) => {
|
|
578
|
+
const cwd = createTempDir();
|
|
579
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
580
|
+
|
|
581
|
+
const taskDir = join(cwd, "persisted-loop-task");
|
|
582
|
+
mkdirSync(taskDir, { recursive: true });
|
|
583
|
+
const persistedState = {
|
|
584
|
+
active: true,
|
|
585
|
+
loopToken: "persisted-loop-token",
|
|
586
|
+
cwd,
|
|
587
|
+
taskDir,
|
|
588
|
+
iteration: 3,
|
|
589
|
+
maxIterations: 5,
|
|
590
|
+
noProgressStreak: 0,
|
|
591
|
+
iterationSummaries: [],
|
|
592
|
+
guardrails: { blockCommands: [], protectedFiles: [] },
|
|
593
|
+
stopRequested: false,
|
|
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
|
+
});
|
|
607
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
608
|
+
const harness = createHarness();
|
|
609
|
+
const handler = harness.handler("ralph-cancel");
|
|
610
|
+
let ctx: any;
|
|
611
|
+
ctx = {
|
|
612
|
+
cwd,
|
|
613
|
+
hasUI: true,
|
|
614
|
+
ui: {
|
|
615
|
+
notify: (message: string, level: string) => notifications.push({ message, level }),
|
|
616
|
+
select: async () => undefined,
|
|
617
|
+
input: async () => undefined,
|
|
618
|
+
editor: async () => undefined,
|
|
619
|
+
setStatus: () => undefined,
|
|
620
|
+
},
|
|
621
|
+
sessionManager: createSessionManager([
|
|
622
|
+
{
|
|
623
|
+
type: "custom",
|
|
624
|
+
customType: "ralph-loop-state",
|
|
625
|
+
data: persistedState,
|
|
626
|
+
},
|
|
627
|
+
], "session-a"),
|
|
628
|
+
getRuntimeCtx: () => ctx,
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
await handler("", ctx);
|
|
632
|
+
|
|
633
|
+
assert.equal(existsSync(join(taskDir, ".ralph-runner", "cancel.flag")), true);
|
|
634
|
+
assert.equal(existsSync(join(taskDir, ".ralph-runner", "stop.flag")), false);
|
|
635
|
+
assert.ok(notifications.some(({ message }) => message.includes("Cancel requested. The active iteration will be terminated immediately.")));
|
|
636
|
+
assert.equal(notifications.some(({ message }) => message.includes("No active ralph loop")), false);
|
|
637
|
+
});
|
|
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
|
+
|
|
746
|
+
test("/ralph-scaffold creates a parseable scaffold from a task name", async (t) => {
|
|
747
|
+
const cwd = createTempDir();
|
|
748
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
749
|
+
|
|
750
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
751
|
+
const harness = createHarness();
|
|
752
|
+
const handler = harness.handler("ralph-scaffold");
|
|
753
|
+
const ctx = {
|
|
754
|
+
cwd,
|
|
755
|
+
hasUI: true,
|
|
756
|
+
ui: {
|
|
757
|
+
notify: (message: string, level: string) => notifications.push({ message, level }),
|
|
758
|
+
select: async () => undefined,
|
|
759
|
+
input: async () => undefined,
|
|
760
|
+
editor: async () => undefined,
|
|
761
|
+
setStatus: () => undefined,
|
|
762
|
+
},
|
|
763
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
await handler("my-task", ctx);
|
|
767
|
+
|
|
768
|
+
const ralphPath = join(cwd, "my-task", "RALPH.md");
|
|
769
|
+
assert.equal(existsSync(ralphPath), true);
|
|
770
|
+
const inspection = inspectDraftContent(readFileSync(ralphPath, "utf8"));
|
|
771
|
+
assert.equal(inspection.error, undefined);
|
|
772
|
+
assert.equal(inspection.parsed?.frontmatter.maxIterations, 10);
|
|
773
|
+
assert.equal(inspection.parsed?.frontmatter.timeout, 120);
|
|
774
|
+
assert.deepEqual(inspection.parsed?.frontmatter.commands, []);
|
|
775
|
+
assert.ok(notifications.some(({ message, level }) => level === "info" && message.includes("Scaffolded")));
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("/ralph-scaffold accepts path-style arguments", async (t) => {
|
|
779
|
+
const cwd = createTempDir();
|
|
780
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
781
|
+
|
|
782
|
+
const harness = createHarness();
|
|
783
|
+
const handler = harness.handler("ralph-scaffold");
|
|
784
|
+
const ctx = {
|
|
785
|
+
cwd,
|
|
786
|
+
hasUI: true,
|
|
787
|
+
ui: {
|
|
788
|
+
notify: () => undefined,
|
|
789
|
+
select: async () => undefined,
|
|
790
|
+
input: async () => undefined,
|
|
791
|
+
editor: async () => undefined,
|
|
792
|
+
setStatus: () => undefined,
|
|
793
|
+
},
|
|
794
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
await handler("feature/new-task", ctx);
|
|
798
|
+
|
|
799
|
+
assert.equal(existsSync(join(cwd, "feature", "new-task", "RALPH.md")), true);
|
|
800
|
+
});
|
|
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
|
+
|
|
831
|
+
test("/ralph-scaffold refuses to overwrite an existing RALPH.md", async (t) => {
|
|
832
|
+
const cwd = createTempDir();
|
|
833
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
834
|
+
|
|
835
|
+
const taskDir = join(cwd, "my-task");
|
|
836
|
+
mkdirSync(taskDir, { recursive: true });
|
|
837
|
+
const ralphPath = join(taskDir, "RALPH.md");
|
|
838
|
+
writeFileSync(ralphPath, "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
|
|
839
|
+
|
|
840
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
841
|
+
const harness = createHarness();
|
|
842
|
+
const handler = harness.handler("ralph-scaffold");
|
|
843
|
+
const ctx = {
|
|
844
|
+
cwd,
|
|
845
|
+
hasUI: true,
|
|
846
|
+
ui: {
|
|
847
|
+
notify: (message: string, level: string) => notifications.push({ message, level }),
|
|
848
|
+
select: async () => undefined,
|
|
849
|
+
input: async () => undefined,
|
|
850
|
+
editor: async () => undefined,
|
|
851
|
+
setStatus: () => undefined,
|
|
852
|
+
},
|
|
853
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
await handler("my-task", ctx);
|
|
857
|
+
|
|
858
|
+
assert.equal(readFileSync(ralphPath, "utf8"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n");
|
|
859
|
+
assert.ok(notifications.some(({ message, level }) => level === "error" && message.includes("already exists at")));
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
test("/ralph-scaffold requires a task name", async (t) => {
|
|
863
|
+
const cwd = createTempDir();
|
|
864
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
865
|
+
|
|
866
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
867
|
+
const harness = createHarness();
|
|
868
|
+
const handler = harness.handler("ralph-scaffold");
|
|
869
|
+
const ctx = {
|
|
870
|
+
cwd,
|
|
871
|
+
hasUI: true,
|
|
872
|
+
ui: {
|
|
873
|
+
notify: (message: string, level: string) => notifications.push({ message, level }),
|
|
874
|
+
select: async () => undefined,
|
|
875
|
+
input: async () => undefined,
|
|
876
|
+
editor: async () => undefined,
|
|
877
|
+
setStatus: () => undefined,
|
|
878
|
+
},
|
|
879
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
await handler(" ", ctx);
|
|
883
|
+
|
|
884
|
+
assert.deepEqual(notifications, [{ message: "/ralph-scaffold expects a task name or path.", level: "error" }]);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
test("/ralph-logs exports artifacts from a task with .ralph-runner/", async (t) => {
|
|
888
|
+
const cwd = createTempDir();
|
|
889
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
890
|
+
|
|
891
|
+
const taskDir = join(cwd, "my-task");
|
|
892
|
+
mkdirSync(join(taskDir, ".ralph-runner", "transcripts"), { recursive: true });
|
|
893
|
+
writeFileSync(join(taskDir, "RALPH.md"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
|
|
894
|
+
writeFileSync(join(taskDir, ".ralph-runner", "status.json"), JSON.stringify({ status: "running" }), "utf8");
|
|
895
|
+
writeFileSync(join(taskDir, ".ralph-runner", "iterations.jsonl"), "{\"iteration\":1}\n{\"iteration\":2}\n", "utf8");
|
|
896
|
+
writeFileSync(join(taskDir, ".ralph-runner", "events.jsonl"), "{\"event\":1}\n", "utf8");
|
|
897
|
+
writeFileSync(join(taskDir, ".ralph-runner", "transcripts", "one.txt"), "one", "utf8");
|
|
898
|
+
writeFileSync(join(taskDir, ".ralph-runner", "transcripts", "two.txt"), "two", "utf8");
|
|
899
|
+
|
|
900
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
901
|
+
const harness = createHarness();
|
|
902
|
+
const handler = harness.handler("ralph-logs");
|
|
903
|
+
const ctx = {
|
|
904
|
+
cwd,
|
|
905
|
+
hasUI: true,
|
|
906
|
+
ui: {
|
|
907
|
+
notify: (message: string, level: string) => notifications.push({ message, level }),
|
|
908
|
+
select: async () => undefined,
|
|
909
|
+
input: async () => undefined,
|
|
910
|
+
editor: async () => undefined,
|
|
911
|
+
setStatus: () => undefined,
|
|
912
|
+
},
|
|
913
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
await handler("my-task --dest exported", ctx);
|
|
917
|
+
|
|
918
|
+
const exportedDir = join(cwd, "exported");
|
|
919
|
+
assert.equal(existsSync(join(exportedDir, "status.json")), true);
|
|
920
|
+
assert.equal(readFileSync(join(exportedDir, "iterations.jsonl"), "utf8"), "{\"iteration\":1}\n{\"iteration\":2}\n");
|
|
921
|
+
assert.equal(readFileSync(join(exportedDir, "events.jsonl"), "utf8"), "{\"event\":1}\n");
|
|
922
|
+
assert.equal(readFileSync(join(exportedDir, "transcripts", "one.txt"), "utf8"), "one");
|
|
923
|
+
assert.equal(readFileSync(join(exportedDir, "transcripts", "two.txt"), "utf8"), "two");
|
|
924
|
+
assert.ok(notifications.some(({ message, level }) => level === "info" && message.includes("Exported 2 iteration records, 1 events, 2 transcripts to ./exported")));
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("/ralph-logs fails when no .ralph-runner/ exists", async (t) => {
|
|
928
|
+
const cwd = createTempDir();
|
|
929
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
930
|
+
|
|
931
|
+
const taskDir = join(cwd, "my-task");
|
|
932
|
+
mkdirSync(taskDir, { recursive: true });
|
|
933
|
+
writeFileSync(join(taskDir, "RALPH.md"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
|
|
934
|
+
|
|
935
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
936
|
+
const harness = createHarness();
|
|
937
|
+
const handler = harness.handler("ralph-logs");
|
|
938
|
+
const ctx = {
|
|
939
|
+
cwd,
|
|
940
|
+
hasUI: true,
|
|
941
|
+
ui: {
|
|
942
|
+
notify: (message: string, level: string) => notifications.push({ message, level }),
|
|
943
|
+
select: async () => undefined,
|
|
944
|
+
input: async () => undefined,
|
|
945
|
+
editor: async () => undefined,
|
|
946
|
+
setStatus: () => undefined,
|
|
947
|
+
},
|
|
948
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
await handler("my-task", ctx);
|
|
952
|
+
|
|
953
|
+
assert.ok(notifications.some(({ message, level }) => level === "error" && message.startsWith("Log export failed: No .ralph-runner directory found at ")));
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("parseLogExportArgs parses --dest correctly", () => {
|
|
957
|
+
assert.deepEqual(parseLogExportArgs("my-task --dest exported"), { path: "my-task", dest: "exported" });
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("/ralph-logs excludes runtime control files", async (t) => {
|
|
961
|
+
const cwd = createTempDir();
|
|
962
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
963
|
+
|
|
964
|
+
const taskDir = join(cwd, "my-task");
|
|
965
|
+
mkdirSync(join(taskDir, ".ralph-runner", "active-loops"), { recursive: true });
|
|
966
|
+
writeFileSync(join(taskDir, "RALPH.md"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
|
|
967
|
+
writeFileSync(join(taskDir, ".ralph-runner", "status.json"), JSON.stringify({ status: "running" }), "utf8");
|
|
968
|
+
writeFileSync(join(taskDir, ".ralph-runner", "iterations.jsonl"), "{\"iteration\":1}\n", "utf8");
|
|
969
|
+
writeFileSync(join(taskDir, ".ralph-runner", "events.jsonl"), "{\"event\":1}\n", "utf8");
|
|
970
|
+
writeFileSync(join(taskDir, ".ralph-runner", "stop.flag"), "", "utf8");
|
|
971
|
+
writeFileSync(join(taskDir, ".ralph-runner", "cancel.flag"), "", "utf8");
|
|
972
|
+
writeFileSync(join(taskDir, ".ralph-runner", "active-loops", "nested.txt"), "skip me", "utf8");
|
|
973
|
+
|
|
974
|
+
const harness = createHarness();
|
|
975
|
+
const handler = harness.handler("ralph-logs");
|
|
976
|
+
const ctx = {
|
|
977
|
+
cwd,
|
|
978
|
+
hasUI: true,
|
|
979
|
+
ui: {
|
|
980
|
+
notify: () => undefined,
|
|
981
|
+
select: async () => undefined,
|
|
982
|
+
input: async () => undefined,
|
|
983
|
+
editor: async () => undefined,
|
|
984
|
+
setStatus: () => undefined,
|
|
985
|
+
},
|
|
986
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
await handler("my-task --dest exported", ctx);
|
|
990
|
+
|
|
991
|
+
const exportedDir = join(cwd, "exported");
|
|
992
|
+
assert.equal(existsSync(join(exportedDir, "stop.flag")), false);
|
|
993
|
+
assert.equal(existsSync(join(exportedDir, "cancel.flag")), false);
|
|
994
|
+
assert.equal(existsSync(join(exportedDir, "active-loops")), false);
|
|
995
|
+
});
|
|
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
|
+
|
|
577
1033
|
test("/ralph reverse engineer this app with an injected llm-strengthened draft still shows review before start", async (t) => {
|
|
578
1034
|
const cwd = createTempDir();
|
|
579
1035
|
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|