Files
ralpi/src/progress.ts

286 lines
7.7 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import type {
ProgressState,
PRDProgress,
Task,
Reflection,
ToolUsage,
} from "./types";
import { ensureDir } from "./utils";
/**
* Derive a stable PRD key from a source path relative to the project dir.
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
*/
export function derivePRDKey(projectDir: string, sourcePath: string): string {
const rel = path.relative(projectDir, sourcePath);
return rel
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Manages persistent progress state for a ralph execution.
* State is stored as JSON in .ralpi/progress.json.
* Supports multiple PRDs in progress simultaneously via the `prds` field.
* Falls back to legacy flat format for backward compatibility.
*/
export class ProgressTracker {
private statePath: string;
private state: ProgressState;
private prdKey: string;
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
const stateDir = path.join(projectDir, ".ralpi");
ensureDir(stateDir);
this.statePath = path.join(stateDir, "progress.json");
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
this.state = this.loadOrCreate(sourcePath);
}
/** Load existing state or create a fresh one */
private loadOrCreate(sourcePathHint: string): ProgressState {
if (fs.existsSync(this.statePath)) {
try {
const raw = fs.readFileSync(this.statePath, "utf-8");
const parsed = JSON.parse(raw) as ProgressState;
// Multi-PRD mode: check if we have a PRD entry
if (parsed.prds?.[this.prdKey]) {
// Found PRD entry — use it, but keep legacy fields for compat
return parsed;
}
// Legacy flat mode: check if the source path matches
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
// Migrate legacy state to PRD mode
parsed.prds = {
[this.prdKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
};
return parsed;
}
// Different PRD — create new entry alongside existing ones
if (parsed.prds) {
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
return parsed;
}
// Legacy flat state exists but for a different source — promote it to PRD mode
const legacyKey = derivePRDKey(
path.dirname(this.statePath),
parsed.sourcePath,
);
parsed.prds = {
[legacyKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
[this.prdKey]: this.freshPRD(sourcePathHint),
};
return parsed;
} catch {
// Fall through to create new
}
}
return this.freshState(sourcePathHint);
}
private freshPRD(sourcePath: string): PRDProgress {
return {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
};
}
private freshState(sourcePath: string): ProgressState {
return {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
prds: {
[this.prdKey]: {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
},
},
};
}
/** Get the PRD-scoped progress entry */
private getPRD(): PRDProgress {
if (!this.state.prds) {
// Should not happen after loadOrCreate, but guard anyway
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
}
if (!this.state.prds[this.prdKey]) {
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
}
return this.state.prds[this.prdKey];
}
/** Save current state to disk */
save(): void {
const prd = this.getPRD();
prd.lastUpdatedAt = new Date().toISOString();
// Sync legacy flat fields with current PRD for backward compat
this.state.sourcePath = prd.sourcePath;
this.state.tasks = prd.tasks;
this.state.startedAt = prd.startedAt;
this.state.lastUpdatedAt = prd.lastUpdatedAt;
this.state.paused = prd.paused;
fs.writeFileSync(
this.statePath,
JSON.stringify(this.state, null, 2),
"utf-8",
);
}
/** Mark a task as in progress */
markInProgress(taskId: string): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "in_progress";
prd.tasks[taskId].startedAt = new Date().toISOString();
this.save();
}
/** Mark a task as completed */
markCompleted(
taskId: string,
durationMs: number,
reflection?: Reflection,
toolUsage?: ToolUsage,
sessionFile?: string,
outputPreview?: string,
commitMessages?: string[],
commitSummary?: string,
): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "completed";
prd.tasks[taskId].completedAt = new Date().toISOString();
prd.tasks[taskId].durationMs = durationMs;
if (reflection) prd.tasks[taskId].reflection = reflection;
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
this.save();
}
/** Mark a task as failed */
markFailed(taskId: string, error: string): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "failed";
prd.tasks[taskId].error = error;
this.save();
}
/** Get task status */
getTaskStatus(taskId: string): Task["status"] {
const prd = this.getPRD();
return prd.tasks[taskId]?.status ?? "pending";
}
/** Get IDs of all completed tasks */
getCompletedTaskIds(): string[] {
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "completed")
.map(([id]) => id);
}
/** Get IDs of all failed tasks */
getFailedTaskIds(): string[] {
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "failed")
.map(([id]) => id);
}
/** Get all reflections from completed tasks */
getAllReflections(): Reflection[] {
const prd = this.getPRD();
const reflections: Reflection[] = [];
for (const info of Object.values(prd.tasks)) {
if (info.reflection) reflections.push(info.reflection);
}
return reflections;
}
/** Get reflections for specific dependency tasks */
getDependencyReflections(depIds: string[]): Reflection[] {
const prd = this.getPRD();
return depIds
.map((id) => prd.tasks[id]?.reflection)
.filter((r): r is Reflection => r !== undefined);
}
/** Increment retry count */
incrementRetry(taskId: string): number {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].retries++;
this.save();
return prd.tasks[taskId].retries;
}
/** Set paused state */
setPaused(paused: boolean): void {
const prd = this.getPRD();
prd.paused = paused;
this.save();
}
/** Get the raw PRD state (for status display) */
getState(): PRDProgress {
return this.getPRD();
}
/** Get all PRDs (for multi-PRD status display) */
getAllPRDs(): Record<string, PRDProgress> {
return this.state.prds ?? {};
}
/** Get the PRD key for this tracker */
getKey(): string {
return this.prdKey;
}
/** Reset all progress for this PRD */
reset(): void {
const prd = this.getPRD();
Object.assign(prd, this.freshPRD(prd.sourcePath));
this.save();
}
private ensureTask(prd: PRDProgress, taskId: string): void {
if (!prd.tasks[taskId]) {
prd.tasks[taskId] = { status: "pending", retries: 0 };
}
}
}