Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 107 additions & 43 deletions public/app-ralph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,71 @@

import { esc, escAttr, state, wpSettings, haptic } from "./app-state";

// ── Types ──

export interface RalphLoop {
readonly project: string;
readonly active: boolean;
readonly completed: boolean;
readonly finished?: string;
readonly started?: string;
readonly audit?: boolean;
readonly cleanup?: boolean;
readonly cleanupEnabled?: boolean;
readonly auditFixEnabled?: boolean;
readonly iteration?: number;
readonly totalIterations?: number;
readonly tasksDone?: number;
readonly tasksTotal?: number;
readonly agent?: string;
readonly planFile?: string;
readonly progressFile?: string;
readonly lastOutput?: string;
readonly worktreeMode?: string;
readonly worktreeBranch?: string;
}

interface RalphStatusResult {
readonly hitLimit: boolean;
readonly status: string;
readonly statusLabel: string;
readonly dotClass: string;
readonly dotTitle: string;
}

interface MachineEntry {
readonly url: string;
readonly name: string;
}

interface StartRalphBody {
project: string;
iterations: number;
planFile?: string;
agent: string;
format: boolean;
cleanup: boolean;
auditFix: boolean;
worktree: false | "plan" | "task";
worktreeBranch?: string;
worktreeBase?: string;
newBranch?: string;
sourceBranch?: string;
}

// ── Dependency injection ──

interface RalphDeps {
api: (path: string, opts?: any, machineUrl?: string) => Promise<any>;
errorMessage: (err: any) => string;
api: <T = unknown>(path: string, opts?: RequestInit, machineUrl?: string) => Promise<T>;
errorMessage: (err: unknown) => string;
showView: (name: string) => void;
getMachines: () => any[];
getMachines: () => MachineEntry[];
backToSessions: () => void;
loadSessions: () => Promise<void>;
renderSidebar: () => void;
startSidebarRefresh: () => void;
getSidebarRefreshTimer: () => any;
setSidebarRefreshTimer: (v: any) => void;
getSidebarRefreshTimer: () => ReturnType<typeof setInterval> | null;
setSidebarRefreshTimer: (v: ReturnType<typeof setInterval> | null) => void;
}

let deps: RalphDeps;
Expand All @@ -27,8 +79,8 @@ export function initRalphDeps(d: RalphDeps) {

// ── Status helpers ──

export function getRalphStatus(loop) {
const hitLimit = !loop.active && !loop.completed && loop.finished;
export function getRalphStatus(loop: RalphLoop): RalphStatusResult {
const hitLimit = !loop.active && !loop.completed && !!loop.finished;
return {
hitLimit,
status: loop.audit ? "audit" : loop.cleanup ? "cleanup" : loop.active ? "running" : loop.completed ? "done" : hitLimit ? "limit" : "idle",
Expand All @@ -40,7 +92,7 @@ export function getRalphStatus(loop) {

// ── Card rendering ──

export function renderRalphCardHtml(loop, machineUrl) {
export function renderRalphCardHtml(loop: RalphLoop, machineUrl: string): string {
const { status, statusLabel, dotClass, dotTitle } = getRalphStatus(loop);
const taskPct = loop.tasksTotal > 0 ? Math.round((loop.tasksDone / loop.tasksTotal) * 100) : 0;
const taskLabel = loop.tasksDone + '/' + loop.tasksTotal + ' tasks';
Expand All @@ -62,7 +114,7 @@ export function renderRalphCardHtml(loop, machineUrl) {
'</div>';
}

export function sidebarRalphCardHtml(loop, machineUrl) {
export function sidebarRalphCardHtml(loop: RalphLoop, machineUrl: string): string {
const { status, statusLabel, dotClass, dotTitle } = getRalphStatus(loop);
const mUrl = escAttr(machineUrl || '');
return '<div class="ralph-card sidebar-ralph-card ' + status + '" onclick="openRalphDetail(\'' + escAttr(loop.project) + '\', \'' + mUrl + '\')">' +
Expand All @@ -75,7 +127,7 @@ export function sidebarRalphCardHtml(loop, machineUrl) {

// ── Detail view ──

export function openRalphDetail(project, machineUrl) {
export function openRalphDetail(project: string, machineUrl?: string): void {
state.currentRalphProject = project;
state.currentRalphMachine = machineUrl || "";
deps.showView("ralph-detail");
Expand All @@ -87,7 +139,7 @@ export async function refreshRalphDetail() {
const logEl = document.getElementById("ralph-log");
const actions = document.getElementById("ralph-actions");
try {
const data = await deps.api("/ralph", undefined, state.currentRalphMachine);
const data = await deps.api<{ loops?: RalphLoop[] }>("/ralph", undefined, state.currentRalphMachine);
const loop = (data.loops || []).find(l => l.project === state.currentRalphProject);
if (!loop) {
header.innerHTML = '<span class="ralph-status failed">NOT FOUND</span>';
Expand Down Expand Up @@ -139,7 +191,7 @@ export async function refreshRalphDetail() {

// fetch log and render iteration cards
try {
const logData = await deps.api("/ralph/log?project=" + encodeURIComponent(state.currentRalphProject), undefined, state.currentRalphMachine);
const logData = await deps.api<{ log?: string; totalLines?: number }>("/ralph/log?project=" + encodeURIComponent(state.currentRalphProject), undefined, state.currentRalphMachine);
if (logData.log != null) {
const container = document.getElementById("ralph-log-container");
const wasScrolled = container.scrollTop + container.clientHeight >= container.scrollHeight - 30;
Expand All @@ -152,7 +204,7 @@ export async function refreshRalphDetail() {

// ── Iteration parsing/rendering ──

export function parseIterations(log) {
export function parseIterations(log: string): Array<{ title: string; body: string }> {
const sections = [];
const marker = /^=== 🥋 (.+?) ===$/gm;
let match, lastIdx = 0, lastTitle = null;
Expand Down Expand Up @@ -228,12 +280,12 @@ export async function cancelRalph() {
}
}

export async function dismissRalph(project, event, machineUrl) {
export async function dismissRalph(project: string, event: Event, machineUrl: string): Promise<void> {
event.stopPropagation();
if (!confirm("dismiss ralph loop for " + project + "?")) return;
try {
// Auto-cancel if still active
const data = await deps.api("/ralph", undefined, machineUrl);
const data = await deps.api<{ loops?: RalphLoop[] }>("/ralph", undefined, machineUrl);
const loop = (data.loops || []).find(l => l.project === project);
if (loop && loop.active) {
await deps.api("/ralph/cancel", {
Expand Down Expand Up @@ -264,7 +316,12 @@ export async function dismissRalph(project, event, machineUrl) {

// ── Start form ──

function getStartMachine() {
function getCheckedRadioValue(name: string, fallback = "false"): string {
const radio = document.querySelector<HTMLInputElement>(`input[name="${name}"]:checked`);
return radio?.value ?? fallback;
}

function getStartMachine(): string | undefined {
return state.ralphStartMachine || undefined;
}

Expand All @@ -289,7 +346,7 @@ async function loadStartFormData() {
const machine = getStartMachine();
const sel = document.getElementById("ralph-project-select") as HTMLSelectElement;
try {
const data = await deps.api("/projects", undefined, machine);
const data = await deps.api<{ projects?: string[] }>("/projects", undefined, machine);
const projects = data.projects || [];
sel.innerHTML = projects.map(p => '<option value="' + esc(p) + '">' + esc(p) + '</option>').join("");
if (state.currentRalphProject && projects.includes(state.currentRalphProject)) {
Expand All @@ -300,7 +357,7 @@ async function loadStartFormData() {
}
sel.onchange = function() {
loadPlanFiles(sel.value);
const isoVal = ((document.querySelector('input[name="ralph-isolation"]:checked') as HTMLInputElement) || {} as any).value;
const isoVal = getCheckedRadioValue("ralph-isolation");
if (isoVal === "branch") loadBranches(sel.value);
if (isoVal === "plan" || isoVal === "task") loadWorktreeBranches(sel.value);
};
Expand Down Expand Up @@ -334,27 +391,34 @@ async function loadStartFormData() {
state.currentRalphAgent = "";
}
// Lock all fields except iterations when continuing a stopped loop
const lockable = [sel, document.getElementById("ralph-plan-select"), document.getElementById("ralph-agent-select"),
document.getElementById("ralph-cleanup-toggle"), document.getElementById("ralph-audit-fix-toggle"),
document.getElementById("ralph-worktree-name"), document.getElementById("ralph-worktree-base"),
document.getElementById("ralph-branch-name"), document.getElementById("ralph-source-branch")] as HTMLElement[];
const lockable: (HTMLInputElement | HTMLSelectElement | null)[] = [
sel,
document.getElementById("ralph-plan-select") as HTMLSelectElement | null,
document.getElementById("ralph-agent-select") as HTMLSelectElement | null,
document.getElementById("ralph-cleanup-toggle") as HTMLInputElement | null,
document.getElementById("ralph-audit-fix-toggle") as HTMLInputElement | null,
document.getElementById("ralph-worktree-name") as HTMLInputElement | null,
document.getElementById("ralph-worktree-base") as HTMLSelectElement | null,
document.getElementById("ralph-branch-name") as HTMLInputElement | null,
document.getElementById("ralph-source-branch") as HTMLSelectElement | null,
];
if (state.restartingRalph) {
lockable.forEach(el => { if (el) (el as any).disabled = true; });
lockable.forEach(el => { if (el) el.disabled = true; });
// Show the actual worktree branch name in the disabled field
if (state.currentRalphWorktreeBranch) {
(document.getElementById("ralph-worktree-name") as HTMLInputElement).value = state.currentRalphWorktreeBranch;
}
state.currentRalphWorktreeBranch = "";
} else {
lockable.forEach(el => { if (el) (el as any).disabled = false; });
lockable.forEach(el => { if (el) el.disabled = false; });
}
}

async function loadPlanFiles(project) {
async function loadPlanFiles(project: string): Promise<void> {
const planSel = document.getElementById("ralph-plan-select") as HTMLSelectElement;
if (!project) { planSel.innerHTML = '<option value="">select a project first</option>'; return; }
try {
const data = await deps.api("/ralph/plans?project=" + encodeURIComponent(project), undefined, getStartMachine());
const data = await deps.api<{ plans?: string[] }>("/ralph/plans?project=" + encodeURIComponent(project), undefined, getStartMachine());
const plans = data.plans || [];
if (plans.length === 0) {
planSel.innerHTML = '<option value="">no .md files found</option>';
Expand All @@ -368,18 +432,18 @@ async function loadPlanFiles(project) {
syncIterationsFromPlan(project, planSel.value);
}

async function syncIterationsFromPlan(project, planFile) {
async function syncIterationsFromPlan(project: string, planFile: string): Promise<void> {
if (!project || !planFile) return;
try {
const tc = await deps.api("/ralph/task-count?project=" + encodeURIComponent(project) + "&plan=" + encodeURIComponent(planFile), undefined, getStartMachine());
const tc = await deps.api<{ total: number; done: number }>("/ralph/task-count?project=" + encodeURIComponent(project) + "&plan=" + encodeURIComponent(planFile), undefined, getStartMachine());
if (tc.total > 0) {
(document.getElementById("ralph-iterations-input") as HTMLInputElement).value = String(tc.total - tc.done);
}
} catch {}
}

export function onIsolationChange() {
const val = ((document.querySelector('input[name="ralph-isolation"]:checked') as HTMLInputElement) || {} as any).value || "false";
export function onIsolationChange(): void {
const val = getCheckedRadioValue("ralph-isolation");
const isWorktree = val === "plan" || val === "task";
document.getElementById("ralph-branch-fields").style.display = val === "branch" ? "flex" : "none";
document.getElementById("ralph-worktree-fields").style.display = isWorktree ? "flex" : "none";
Expand All @@ -391,30 +455,30 @@ export function onIsolationChange() {
}
}

function updateWorktreePlaceholder(mode) {
function updateWorktreePlaceholder(mode: string): void {
const input = document.getElementById("ralph-worktree-name") as HTMLInputElement;
input.placeholder = mode === "task" ? "ralph/1-task-slug (auto per task)" : "ralph/plan-my-feature";
if (mode === "task") { input.value = ""; input.disabled = true; }
else { input.disabled = false; }
}

async function loadWorktreeBranches(project) {
async function loadWorktreeBranches(project: string): Promise<void> {
const sel = document.getElementById("ralph-worktree-base") as HTMLSelectElement;
if (!project) { sel.innerHTML = '<option value="">select a project first</option>'; return; }
try {
const data = await deps.api("/ralph/branches?project=" + encodeURIComponent(project), undefined, getStartMachine());
const data = await deps.api<{ branches?: string[]; current?: string }>("/ralph/branches?project=" + encodeURIComponent(project), undefined, getStartMachine());
const branches = data.branches || [];
sel.innerHTML = branches.map(b => '<option value="' + escAttr(b) + '"' + (b === "main" ? " selected" : "") + '>' + esc(b) + '</option>').join("");
} catch {
sel.innerHTML = '<option value="">failed to load</option>';
}
}

async function loadBranches(project) {
async function loadBranches(project: string): Promise<void> {
const sel = document.getElementById("ralph-source-branch") as HTMLSelectElement;
if (!project) { sel.innerHTML = '<option value="">select a project first</option>'; return; }
try {
const data = await deps.api("/ralph/branches?project=" + encodeURIComponent(project), undefined, getStartMachine());
const data = await deps.api<{ branches?: string[]; current?: string }>("/ralph/branches?project=" + encodeURIComponent(project), undefined, getStartMachine());
const branches = data.branches || [];
if (branches.length === 0) {
sel.innerHTML = '<option value="">no branches found</option>';
Expand Down Expand Up @@ -443,7 +507,7 @@ export async function startRalph() {
// Warn if plan has format issues — worker will auto-number
let format = false;
try {
const tc = await deps.api("/ralph/task-count?project=" + encodeURIComponent(project) + "&plan=" + encodeURIComponent(planFile), undefined, machine);
const tc = await deps.api<{ total: number; done: number; issues?: string[] }>("/ralph/task-count?project=" + encodeURIComponent(project) + "&plan=" + encodeURIComponent(planFile), undefined, machine);
if (tc.issues && tc.issues.length > 0) {
const msg = "Plan format issues in " + planFile + ":\n\n" +
tc.issues.map(i => "• " + i).join("\n") +
Expand All @@ -456,9 +520,9 @@ export async function startRalph() {
}
} catch {}

const isoVal = ((document.querySelector('input[name="ralph-isolation"]:checked') as HTMLInputElement) || {} as any).value || "false";
const worktree = (isoVal === "plan" || isoVal === "task") ? isoVal : false;
const body: any = { project, iterations, planFile, agent, format, cleanup, auditFix, worktree };
const isoVal = getCheckedRadioValue("ralph-isolation");
const worktree: false | "plan" | "task" = (isoVal === "plan" || isoVal === "task") ? isoVal : false;
const body: StartRalphBody = { project, iterations, planFile, agent, format, cleanup, auditFix, worktree };
if (worktree) {
const wtName = (document.getElementById("ralph-worktree-name") as HTMLInputElement).value.trim();
const wtBase = (document.getElementById("ralph-worktree-base") as HTMLSelectElement).value;
Expand Down Expand Up @@ -487,7 +551,7 @@ export async function startRalph() {
}
}

export function continueRalph(planFile, agent, cleanup, auditFix, worktreeMode, worktreeBranch?) {
export function continueRalph(planFile: string, agent: string, cleanup: boolean, auditFix: boolean, worktreeMode: string, worktreeBranch?: string): void {
state.currentRalphPlanFile = planFile || "";
state.currentRalphAgent = agent || "";
state.currentRalphCleanup = cleanup;
Expand All @@ -513,7 +577,7 @@ export async function discardRalph() {
}
}

export function showRalphStart(machineUrl) {
export function showRalphStart(machineUrl?: string): void {
if (!wpSettings.ralphEnabled) return;
state.ralphStartMachine = machineUrl || "";
state.restartingRalph = false;
Expand All @@ -525,7 +589,7 @@ export function showRalphStart(machineUrl) {

const prevRalphStates: Record<string, string> = {};

function getRalphNotificationStatus(loop) {
function getRalphNotificationStatus(loop: RalphLoop): string {
if (loop.audit) return "running";
if (loop.cleanup) return "running";
if (loop.active) return "running";
Expand All @@ -534,7 +598,7 @@ function getRalphNotificationStatus(loop) {
return "idle";
}

export function checkRalphTransitions(loops, mUrl, mName) {
export function checkRalphTransitions(loops: RalphLoop[] | undefined, mUrl: string, _mName: string): void {
if (!loops) return;
for (const loop of loops) {
const key = mUrl + "|" + loop.project;
Expand Down
2 changes: 1 addition & 1 deletion src/public-assets.ts

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions src/server/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import type { EventBody } from "../broker/codec.js";

const log = createLogger("backend");

export class DuplicateSessionError extends Error {
readonly code = "DUPLICATE_SESSION" as const;
constructor(name: string) {
super(`duplicate session: ${name}`);
this.name = "DuplicateSessionError";
}
}

const BROKER_HANDSHAKE_TIMEOUT_MS = 1000;
const BROKER_CONNECT_TIMEOUT_MS = 1500;
/** How often the recovery watchdog re-probes a broker that previously failed.
Expand Down Expand Up @@ -356,9 +364,7 @@ export class BackendRouter implements SessionBackend {
const broker = this.requireBroker();
const existing = await broker.list();
if (existing.includes(name)) {
const err = new Error(`duplicate session: ${name}`);
(err as any).code = "DUPLICATE_SESSION";
throw err;
throw new DuplicateSessionError(name);
}
await broker.createSession(name, cwd, cmd, loadSettings);
log.info("session created via router", { name, backend: "broker" });
Expand Down
5 changes: 2 additions & 3 deletions src/server/mock-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* __setTestBackend(). No real broker daemon needed.
*/
import type { SessionBackend } from "./backend.js";
import { DuplicateSessionError } from "./backend.js";
import { stripAnsi } from "./strip-ansi.js";

export interface MockBackendOptions {
Expand Down Expand Up @@ -63,9 +64,7 @@ export class MockBackend implements SessionBackend {
this.lastCreateArgs = { name, cwd, cmd };
if (this._onBeforeCreate) this._onBeforeCreate(name);
if (this._sessions.has(name)) {
const err = new Error(`duplicate session: ${name}`);
(err as any).code = "DUPLICATE_SESSION";
throw err;
throw new DuplicateSessionError(name);
}
this._sessions.add(name);
}
Expand Down
Loading
Loading