feat: persist run state to disk with recovery on startup

This commit is contained in:
2026-07-16 10:23:03 +03:00
parent db11d7a4d9
commit e37545ffff
4 changed files with 310 additions and 4 deletions
+142
View File
@@ -151,6 +151,148 @@ TEST(RunnerTest, RunStatusToString) {
EXPECT_EQ(runStatusToString(RunStatus::Error), "error");
}
TEST(PersistenceTest, SaveLoadRoundTrip) {
setupDirs();
PipelineRunner runner(testPipelinesDir, testLogsDir);
// Test loadRunState on non-existent file
RunState empty = runner.loadRunState("nonexistent");
EXPECT_TRUE(empty.run_id.isEmpty());
// Create state file with one step
PIString runId = "test-run-123";
PIString statePath = testLogsDir + runId + ".state.json";
PIJSON j = PIJSON::newObject();
j["run_id"] = runId;
j["pipeline_id"] = "test-pipeline";
j["status"] = "running";
j["current_step"] = 0;
PIJSON stepsArr = PIJSON::newArray();
PIJSON sj = PIJSON::newObject();
sj["step_index"] = 0;
sj["title"] = "Step 1";
sj["status"] = "completed";
sj["returncode"] = 0;
sj["output"] = "output data";
sj["error"] = "";
stepsArr << sj;
j["steps"] = stepsArr;
PIFile::writeAll(statePath, j.toJSON(PIJSON::Tree).toUTF8());
// Load via runner
RunState loaded = runner.loadRunState(runId);
EXPECT_EQ(loaded.run_id, runId);
EXPECT_EQ(loaded.pipeline_id, "test-pipeline");
EXPECT_EQ(loaded.status, RunStatus::Running);
EXPECT_EQ(loaded.steps.size(), 1);
EXPECT_EQ(loaded.steps[0].title, "Step 1");
EXPECT_EQ(loaded.steps[0].status, RunStatus::Completed);
cleanupDirs();
}
TEST(PersistenceTest, RecoverRuns) {
setupDirs();
// Create state file manually to simulate a running state from a previous session
PIString runId = "recovered-run-456";
PIString statePath = testLogsDir + runId + ".state.json";
PIJSON j = PIJSON::newObject();
j["run_id"] = runId;
j["pipeline_id"] = "some-pipeline";
j["status"] = "running";
j["current_step"] = 1;
PIJSON stepsArr = PIJSON::newArray();
PIJSON sj = PIJSON::newObject();
sj["step_index"] = 0;
sj["title"] = "Step 1";
sj["status"] = "completed";
sj["returncode"] = 0;
sj["output"] = "";
sj["error"] = "";
stepsArr << sj;
PIJSON sj2 = PIJSON::newObject();
sj2["step_index"] = 1;
sj2["title"] = "Step 2";
sj2["status"] = "pending";
sj2["returncode"] = 0;
sj2["output"] = "";
sj2["error"] = "";
stepsArr << sj2;
j["steps"] = stepsArr;
PIFile::writeAll(statePath, j.toJSON(PIJSON::Tree).toUTF8());
// Also create a completed state file that should NOT be recovered
PIString completedRunId = "completed-run-789";
PIString completedStatePath = testLogsDir + completedRunId + ".state.json";
PIJSON j2 = PIJSON::newObject();
j2["run_id"] = completedRunId;
j2["pipeline_id"] = "some-pipeline";
j2["status"] = "completed";
j2["current_step"] = 0;
j2["steps"] = PIJSON::newArray();
PIFile::writeAll(completedStatePath, j2.toJSON(PIJSON::Tree).toUTF8());
// New runner should recover only the running state
PipelineRunner runner(testPipelinesDir, testLogsDir);
runner.recoverRuns();
RunState recovered = runner.getRunState(runId);
EXPECT_EQ(recovered.run_id, runId);
EXPECT_EQ(recovered.status, RunStatus::Running);
EXPECT_EQ(recovered.steps.size(), 2);
// Completed run should NOT be in memory after recovery
EXPECT_FALSE(runner.isRunActive(completedRunId));
cleanupDirs();
}
TEST(PersistenceTest, CompletedRunStatePersisted) {
setupDirs();
PipelineRunner runner(testPipelinesDir, testLogsDir);
Pipeline pl;
pl.id = "persist-test";
pl.name = "Persist Test";
pl.working_dir = "/tmp";
pl.created_at = nowISO();
pl.updated_at = pl.created_at;
Prompt p;
p.id = "p1";
p.text = "test";
p.title = "Step 1";
p.order = 0;
pl.prompts << p;
savePipeline(testPipelinesDir, pl);
PIString runId = runner.startRun("persist-test");
EXPECT_FALSE(runId.isEmpty());
waitForRun(runner, runId);
// Verify state file exists and contains final status
PIString statePath = testLogsDir + runId + ".state.json";
EXPECT_TRUE(PIFile::isExists(statePath));
RunState loaded = runner.loadRunState(runId);
EXPECT_EQ(loaded.run_id, runId);
cleanupDirs();
}
#ifdef FAKE
static Pipeline makeFakePipeline(const PIString & id, const PIString & name, int numSteps) {