This commit is contained in:
2026-05-30 19:37:17 -04:00
parent 81e0e8ec1c
commit e6a8c8bedc
19 changed files with 2393 additions and 858 deletions

View File

@@ -1,20 +1,33 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ProgressState, Task, Reflection } from "./types";
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 .ralph/progress.json
* State is stored as JSON in .ralph/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) {
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
const stateDir = path.join(projectDir, ".ralph");
ensureDir(stateDir);
this.statePath = path.join(stateDir, "progress.json");
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
this.state = this.loadOrCreate(sourcePath);
}
@@ -23,13 +36,59 @@ export class ProgressTracker {
if (fs.existsSync(this.statePath)) {
try {
const raw = fs.readFileSync(this.statePath, "utf-8");
return JSON.parse(raw) as ProgressState;
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: sourcePathHint,
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
@@ -37,9 +96,47 @@ export class ProgressTracker {
};
}
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 {
this.state.lastUpdatedAt = new Date().toISOString();
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),
@@ -49,9 +146,10 @@ export class ProgressTracker {
/** Mark a task as in progress */
markInProgress(taskId: string): void {
this.ensureTask(taskId);
this.state.tasks[taskId].status = "in_progress";
this.state.tasks[taskId].startedAt = new Date().toISOString();
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "in_progress";
prd.tasks[taskId].startedAt = new Date().toISOString();
this.save();
}
@@ -60,89 +158,108 @@ export class ProgressTracker {
taskId: string,
durationMs: number,
reflection?: Reflection,
toolUsage?: ToolUsage,
sessionFile?: string,
outputPreview?: string,
commitMessages?: string[],
commitSummary?: string,
): void {
this.ensureTask(taskId);
this.state.tasks[taskId].status = "completed";
this.state.tasks[taskId].completedAt = new Date().toISOString();
this.state.tasks[taskId].durationMs = durationMs;
if (reflection) {
this.state.tasks[taskId].reflection = reflection;
}
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 {
this.ensureTask(taskId);
this.state.tasks[taskId].status = "failed";
this.state.tasks[taskId].error = error;
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"] {
return this.state.tasks[taskId]?.status ?? "pending";
const prd = this.getPRD();
return prd.tasks[taskId]?.status ?? "pending";
}
/** Get IDs of all completed tasks */
getCompletedTaskIds(): string[] {
return Object.entries(this.state.tasks)
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "completed")
.map(([id]) => id);
}
/** Get all reflections from completed tasks */
getAllReflections(): Reflection[] {
const prd = this.getPRD();
const reflections: Reflection[] = [];
for (const info of Object.values(this.state.tasks)) {
if (info.reflection) {
reflections.push(info.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) => this.state.tasks[id]?.reflection)
.map((id) => prd.tasks[id]?.reflection)
.filter((r): r is Reflection => r !== undefined);
}
/** Increment retry count */
incrementRetry(taskId: string): number {
this.ensureTask(taskId);
this.state.tasks[taskId].retries++;
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].retries++;
this.save();
return this.state.tasks[taskId].retries;
return prd.tasks[taskId].retries;
}
/** Set paused state */
setPaused(paused: boolean): void {
this.state.paused = paused;
const prd = this.getPRD();
prd.paused = paused;
this.save();
}
/** Get the raw state (for status display) */
getState(): ProgressState {
return this.state;
/** Get the raw PRD state (for status display) */
getState(): PRDProgress {
return this.getPRD();
}
/** Reset all progress */
/** 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 {
this.state = {
sourcePath: this.state.sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
};
const prd = this.getPRD();
Object.assign(prd, this.freshPRD(prd.sourcePath));
this.save();
}
private ensureTask(taskId: string): void {
if (!this.state.tasks[taskId]) {
this.state.tasks[taskId] = { status: "pending", retries: 0 };
private ensureTask(prd: PRDProgress, taskId: string): void {
if (!prd.tasks[taskId]) {
prd.tasks[taskId] = { status: "pending", retries: 0 };
}
}
}