Step 323: Workflow Sidecar Persistence (12/12 tests)
Save/load workflow state to .whetstone/<project>.workflow.json with directory creation, overwrite, delete, and multi-workflow support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1931,4 +1931,9 @@ add_executable(step322_test tests/step322_test.cpp)
|
||||
target_include_directories(step322_test PRIVATE src)
|
||||
target_link_libraries(step322_test PRIVATE nlohmann_json::nlohmann_json)
|
||||
|
||||
# Step 323: Workflow Sidecar Persistence
|
||||
add_executable(step323_test tests/step323_test.cpp)
|
||||
target_include_directories(step323_test PRIVATE src)
|
||||
target_link_libraries(step323_test PRIVATE nlohmann_json::nlohmann_json)
|
||||
|
||||
# Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies)
|
||||
|
||||
108
editor/src/WorkflowPersistence.h
Normal file
108
editor/src/WorkflowPersistence.h
Normal file
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
// Step 323: Workflow Sidecar Persistence
|
||||
//
|
||||
// Save and load workflow state to .whetstone/<project>.workflow.json
|
||||
// so workflows survive across sessions.
|
||||
|
||||
#include "WorkflowState.h"
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <sys/stat.h>
|
||||
|
||||
// --- SaveResult ---
|
||||
|
||||
struct SaveResult {
|
||||
bool success = false;
|
||||
std::string path;
|
||||
int bytesWritten = 0;
|
||||
int itemCount = 0;
|
||||
};
|
||||
|
||||
// --- Path helper ---
|
||||
|
||||
inline std::string workflowSidecarPath(const std::string& workspaceRoot,
|
||||
const std::string& projectName) {
|
||||
return workspaceRoot + "/.whetstone/" + projectName + ".workflow.json";
|
||||
}
|
||||
|
||||
// --- Create parent directories recursively ---
|
||||
|
||||
inline bool ensureDirectoryExists(const std::string& path) {
|
||||
// Find last slash to get directory
|
||||
auto pos = path.rfind('/');
|
||||
if (pos == std::string::npos) return true;
|
||||
std::string dir = path.substr(0, pos);
|
||||
|
||||
// Simple recursive mkdir
|
||||
std::string current;
|
||||
for (size_t i = 0; i < dir.size(); ++i) {
|
||||
current += dir[i];
|
||||
if (dir[i] == '/' || i == dir.size() - 1) {
|
||||
mkdir(current.c_str(), 0755);
|
||||
}
|
||||
}
|
||||
// Check if directory exists
|
||||
struct stat st{};
|
||||
return stat(dir.c_str(), &st) == 0 && S_ISDIR(st.st_mode);
|
||||
}
|
||||
|
||||
// --- Save workflow ---
|
||||
|
||||
inline SaveResult saveWorkflow(const std::string& workspaceRoot,
|
||||
const WorkflowState& state) {
|
||||
SaveResult result;
|
||||
result.path = workflowSidecarPath(workspaceRoot, state.projectName);
|
||||
|
||||
if (!ensureDirectoryExists(result.path)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
json j = state.toJson();
|
||||
std::string content = j.dump(2);
|
||||
|
||||
std::ofstream ofs(result.path);
|
||||
if (!ofs.is_open()) return result;
|
||||
|
||||
ofs << content;
|
||||
ofs.close();
|
||||
|
||||
if (ofs.fail()) return result;
|
||||
|
||||
result.success = true;
|
||||
result.bytesWritten = static_cast<int>(content.size());
|
||||
result.itemCount = state.queue.size();
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Load workflow ---
|
||||
|
||||
inline std::optional<WorkflowState> loadWorkflow(const std::string& workspaceRoot,
|
||||
const std::string& projectName) {
|
||||
std::string path = workflowSidecarPath(workspaceRoot, projectName);
|
||||
|
||||
std::ifstream ifs(path);
|
||||
if (!ifs.is_open()) return std::nullopt;
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << ifs.rdbuf();
|
||||
std::string content = oss.str();
|
||||
|
||||
if (content.empty()) return std::nullopt;
|
||||
|
||||
try {
|
||||
json j = json::parse(content);
|
||||
return WorkflowState::fromJson(j);
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete workflow ---
|
||||
|
||||
inline bool deleteWorkflow(const std::string& workspaceRoot,
|
||||
const std::string& projectName) {
|
||||
std::string path = workflowSidecarPath(workspaceRoot, projectName);
|
||||
return std::remove(path.c_str()) == 0;
|
||||
}
|
||||
283
editor/tests/step323_test.cpp
Normal file
283
editor/tests/step323_test.cpp
Normal file
@@ -0,0 +1,283 @@
|
||||
// Step 323: Workflow Sidecar Persistence (12 tests)
|
||||
// Tests save/load roundtrip, field preservation, history, missing file,
|
||||
// directory creation, delete, multiple workflows, results, empty workflow.
|
||||
|
||||
#include "WorkflowPersistence.h"
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static int passed = 0, failed = 0;
|
||||
#define TEST(name) { std::cout << " " << #name << "... "; }
|
||||
#define PASS() { std::cout << "PASS\n"; ++passed; }
|
||||
#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; }
|
||||
#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {}
|
||||
|
||||
static std::string testDir;
|
||||
|
||||
static void setupTestDir() {
|
||||
testDir = "/tmp/whetstone_step323_test_" + std::to_string(getpid());
|
||||
fs::create_directories(testDir);
|
||||
}
|
||||
|
||||
static void cleanupTestDir() {
|
||||
fs::remove_all(testDir);
|
||||
}
|
||||
|
||||
// Helper: create a WorkflowState with some items
|
||||
static WorkflowState makeWorkflow(const std::string& name, int itemCount) {
|
||||
WorkflowState ws(name);
|
||||
for (int i = 0; i < itemCount; ++i) {
|
||||
WorkItem wi;
|
||||
wi.id = "wi-" + std::to_string(i);
|
||||
wi.nodeId = "node_" + std::to_string(i);
|
||||
wi.nodeName = "func_" + std::to_string(i);
|
||||
wi.nodeType = "Function";
|
||||
wi.bufferId = "buf-1";
|
||||
wi.contextWidth = "local";
|
||||
wi.workerType = "llm";
|
||||
wi.priority = "medium";
|
||||
wi.status = WI_READY;
|
||||
wi.createdAt = workItemTimestamp();
|
||||
ws.queue.enqueue(wi);
|
||||
}
|
||||
return ws;
|
||||
}
|
||||
|
||||
// 1. Save/load roundtrip
|
||||
void test_save_load_roundtrip() {
|
||||
TEST(save_load_roundtrip);
|
||||
auto ws = makeWorkflow("proj1", 3);
|
||||
auto result = saveWorkflow(testDir, ws);
|
||||
CHECK(result.success, "save succeeded");
|
||||
CHECK(result.itemCount == 3, "itemCount=3");
|
||||
CHECK(result.bytesWritten > 0, "bytes written");
|
||||
|
||||
auto loaded = loadWorkflow(testDir, "proj1");
|
||||
CHECK(loaded.has_value(), "load succeeded");
|
||||
CHECK(loaded->projectName == "proj1", "projectName");
|
||||
CHECK(loaded->queue.size() == 3, "3 items loaded");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 2. All work item fields preserved
|
||||
void test_fields_preserved() {
|
||||
TEST(fields_preserved);
|
||||
WorkflowState ws("fields");
|
||||
WorkItem wi;
|
||||
wi.id = "wi-field";
|
||||
wi.nodeId = "n42";
|
||||
wi.nodeName = "computeHash";
|
||||
wi.nodeType = "Function";
|
||||
wi.bufferId = "buf-main";
|
||||
wi.contextWidth = "module";
|
||||
wi.workerType = "deterministic";
|
||||
wi.reviewRequired = true;
|
||||
wi.priority = "critical";
|
||||
wi.dependencies = {"wi-0", "wi-1"};
|
||||
wi.status = WI_ASSIGNED;
|
||||
wi.assignee = "worker-7";
|
||||
wi.createdAt = "2026-01-01T00:00:00Z";
|
||||
wi.assignedAt = "2026-01-01T00:01:00Z";
|
||||
ws.queue.enqueue(wi);
|
||||
|
||||
saveWorkflow(testDir, ws);
|
||||
auto loaded = loadWorkflow(testDir, "fields");
|
||||
CHECK(loaded.has_value(), "loaded");
|
||||
|
||||
auto item = loaded->queue.getItem("wi-field");
|
||||
CHECK(item.has_value(), "item found");
|
||||
CHECK(item->nodeId == "n42", "nodeId");
|
||||
CHECK(item->nodeName == "computeHash", "nodeName");
|
||||
CHECK(item->contextWidth == "module", "contextWidth");
|
||||
CHECK(item->workerType == "deterministic", "workerType");
|
||||
CHECK(item->reviewRequired == true, "reviewRequired");
|
||||
CHECK(item->priority == "critical", "priority");
|
||||
CHECK(item->assignee == "worker-7", "assignee");
|
||||
CHECK(item->assignedAt == "2026-01-01T00:01:00Z", "assignedAt");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 3. Status history preserved
|
||||
void test_history_preserved() {
|
||||
TEST(history_preserved);
|
||||
WorkflowState ws("hist");
|
||||
ws.recordChange("wi-1", "", "pending", "system", "created");
|
||||
ws.recordChange("wi-1", "pending", "ready", "system", "deps met");
|
||||
ws.recordChange("wi-1", "ready", "assigned", "routing-engine");
|
||||
|
||||
saveWorkflow(testDir, ws);
|
||||
auto loaded = loadWorkflow(testDir, "hist");
|
||||
CHECK(loaded.has_value(), "loaded");
|
||||
|
||||
auto h = loaded->getHistory("wi-1");
|
||||
CHECK(h.size() == 3, "3 history entries, got " + std::to_string(h.size()));
|
||||
CHECK(h[0].toStatus == "pending", "first=pending");
|
||||
CHECK(h[1].actor == "system", "second actor=system");
|
||||
CHECK(h[2].toStatus == "assigned", "third=assigned");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 4. Missing file returns nullopt
|
||||
void test_missing_file() {
|
||||
TEST(missing_file);
|
||||
auto loaded = loadWorkflow(testDir, "nonexistent");
|
||||
CHECK(!loaded.has_value(), "nullopt for missing file");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 5. Save creates parent directories
|
||||
void test_creates_directories() {
|
||||
TEST(creates_directories);
|
||||
std::string subDir = testDir + "/nested/workspace";
|
||||
auto ws = makeWorkflow("dirtest", 1);
|
||||
auto result = saveWorkflow(subDir, ws);
|
||||
CHECK(result.success, "save succeeded with new dirs");
|
||||
CHECK(fs::exists(subDir + "/.whetstone/dirtest.workflow.json"), "file exists");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 6. Delete removes file
|
||||
void test_delete_workflow() {
|
||||
TEST(delete_workflow);
|
||||
auto ws = makeWorkflow("deleteme", 1);
|
||||
saveWorkflow(testDir, ws);
|
||||
|
||||
std::string path = workflowSidecarPath(testDir, "deleteme");
|
||||
CHECK(fs::exists(path), "file exists before delete");
|
||||
|
||||
bool deleted = deleteWorkflow(testDir, "deleteme");
|
||||
CHECK(deleted, "delete returned true");
|
||||
CHECK(!fs::exists(path), "file removed");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 7. Multiple workflows in same workspace
|
||||
void test_multiple_workflows() {
|
||||
TEST(multiple_workflows);
|
||||
auto ws1 = makeWorkflow("alpha", 2);
|
||||
auto ws2 = makeWorkflow("beta", 4);
|
||||
saveWorkflow(testDir, ws1);
|
||||
saveWorkflow(testDir, ws2);
|
||||
|
||||
auto l1 = loadWorkflow(testDir, "alpha");
|
||||
auto l2 = loadWorkflow(testDir, "beta");
|
||||
CHECK(l1.has_value(), "alpha loaded");
|
||||
CHECK(l2.has_value(), "beta loaded");
|
||||
CHECK(l1->queue.size() == 2, "alpha has 2 items");
|
||||
CHECK(l2->queue.size() == 4, "beta has 4 items");
|
||||
CHECK(l1->projectName == "alpha", "alpha name");
|
||||
CHECK(l2->projectName == "beta", "beta name");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 8. Workflow with results attached
|
||||
void test_results_preserved() {
|
||||
TEST(results_preserved);
|
||||
WorkflowState ws("results");
|
||||
WorkItem wi;
|
||||
wi.id = "wi-res";
|
||||
wi.nodeId = "n1";
|
||||
wi.nodeName = "gen";
|
||||
wi.nodeType = "Function";
|
||||
wi.priority = "medium";
|
||||
wi.status = WI_COMPLETE;
|
||||
wi.createdAt = workItemTimestamp();
|
||||
wi.result.generatedCode = "def gen(): return 42";
|
||||
wi.result.confidence = 0.95f;
|
||||
wi.result.tokensBudget = 4000;
|
||||
wi.result.tokensGenerated = 120;
|
||||
wi.result.reasoning = "trivial pure function";
|
||||
wi.result.diagnostics = {json{{"level", "info"}, {"msg", "ok"}}};
|
||||
wi.result.astJson = json{{"type", "Function"}, {"name", "gen"}};
|
||||
ws.queue.enqueue(wi);
|
||||
|
||||
saveWorkflow(testDir, ws);
|
||||
auto loaded = loadWorkflow(testDir, "results");
|
||||
CHECK(loaded.has_value(), "loaded");
|
||||
|
||||
auto item = loaded->queue.getItem("wi-res");
|
||||
CHECK(item.has_value(), "item found");
|
||||
CHECK(item->result.generatedCode == "def gen(): return 42", "code");
|
||||
CHECK(item->result.confidence > 0.94f, "confidence");
|
||||
CHECK(item->result.tokensBudget == 4000, "budget");
|
||||
CHECK(item->result.tokensGenerated == 120, "generated");
|
||||
CHECK(item->result.reasoning == "trivial pure function", "reasoning");
|
||||
CHECK(item->result.diagnostics.size() == 1, "diagnostics");
|
||||
CHECK(item->result.astJson.contains("name"), "astJson");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 9. Empty workflow save/load
|
||||
void test_empty_workflow() {
|
||||
TEST(empty_workflow);
|
||||
WorkflowState ws("empty");
|
||||
auto result = saveWorkflow(testDir, ws);
|
||||
CHECK(result.success, "save empty succeeded");
|
||||
CHECK(result.itemCount == 0, "0 items");
|
||||
|
||||
auto loaded = loadWorkflow(testDir, "empty");
|
||||
CHECK(loaded.has_value(), "loaded empty");
|
||||
CHECK(loaded->queue.size() == 0, "0 items loaded");
|
||||
CHECK(loaded->projectName == "empty", "name preserved");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 10. Sidecar path format
|
||||
void test_sidecar_path() {
|
||||
TEST(sidecar_path);
|
||||
auto path = workflowSidecarPath("/home/user/project", "myapp");
|
||||
CHECK(path == "/home/user/project/.whetstone/myapp.workflow.json", "path format: " + path);
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 11. Overwrite existing workflow
|
||||
void test_overwrite() {
|
||||
TEST(overwrite);
|
||||
auto ws1 = makeWorkflow("overwrite", 2);
|
||||
saveWorkflow(testDir, ws1);
|
||||
|
||||
auto ws2 = makeWorkflow("overwrite", 5);
|
||||
saveWorkflow(testDir, ws2);
|
||||
|
||||
auto loaded = loadWorkflow(testDir, "overwrite");
|
||||
CHECK(loaded.has_value(), "loaded");
|
||||
CHECK(loaded->queue.size() == 5, "5 items (overwritten)");
|
||||
PASS();
|
||||
}
|
||||
|
||||
// 12. Delete nonexistent returns false
|
||||
void test_delete_nonexistent() {
|
||||
TEST(delete_nonexistent);
|
||||
bool deleted = deleteWorkflow(testDir, "ghost");
|
||||
CHECK(!deleted, "delete nonexistent returns false");
|
||||
PASS();
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::cout << "=== Step 323: Workflow Sidecar Persistence ===\n";
|
||||
setupTestDir();
|
||||
try {
|
||||
test_save_load_roundtrip();
|
||||
test_fields_preserved();
|
||||
test_history_preserved();
|
||||
test_missing_file();
|
||||
test_creates_directories();
|
||||
test_delete_workflow();
|
||||
test_multiple_workflows();
|
||||
test_results_preserved();
|
||||
test_empty_workflow();
|
||||
test_sidecar_path();
|
||||
test_overwrite();
|
||||
test_delete_nonexistent();
|
||||
} catch (const std::exception& e) {
|
||||
std::cout << "EXCEPTION: " << e.what() << "\n";
|
||||
++failed;
|
||||
}
|
||||
cleanupTestDir();
|
||||
std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n";
|
||||
return failed > 0 ? 1 : 0;
|
||||
}
|
||||
19
progress.md
19
progress.md
@@ -1265,6 +1265,25 @@ from item statuses, tracks audit trail, computes stats.
|
||||
**Files modified:**
|
||||
- `editor/CMakeLists.txt` — step322_test target
|
||||
|
||||
### Step 323: Workflow Sidecar Persistence
|
||||
**Status:** PASS (12/12 tests)
|
||||
|
||||
Save and load workflow state to `.whetstone/<project>.workflow.json` so
|
||||
workflows survive across sessions. Handles directory creation, overwrite,
|
||||
delete, and multiple workflows in the same workspace.
|
||||
|
||||
**Files created:**
|
||||
- `editor/src/WorkflowPersistence.h` — SaveResult struct, workflowSidecarPath,
|
||||
ensureDirectoryExists, saveWorkflow (serialize + write), loadWorkflow
|
||||
(read + deserialize, nullopt if missing), deleteWorkflow
|
||||
- `editor/tests/step323_test.cpp` — 12 tests: save/load roundtrip, all fields
|
||||
preserved, history preserved, missing file nullopt, directory creation,
|
||||
delete removes file, multiple workflows, results preserved, empty workflow,
|
||||
sidecar path format, overwrite, delete nonexistent
|
||||
|
||||
**Files modified:**
|
||||
- `editor/CMakeLists.txt` — step323_test target
|
||||
|
||||
---
|
||||
|
||||
# Roadmap Planning — Sprints 12-25+
|
||||
|
||||
Reference in New Issue
Block a user