diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index f8fde2c..d8f929b 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -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) diff --git a/editor/src/WorkflowPersistence.h b/editor/src/WorkflowPersistence.h new file mode 100644 index 0000000..3197745 --- /dev/null +++ b/editor/src/WorkflowPersistence.h @@ -0,0 +1,108 @@ +#pragma once +// Step 323: Workflow Sidecar Persistence +// +// Save and load workflow state to .whetstone/.workflow.json +// so workflows survive across sessions. + +#include "WorkflowState.h" +#include +#include +#include +#include +#include + +// --- 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(content.size()); + result.itemCount = state.queue.size(); + return result; +} + +// --- Load workflow --- + +inline std::optional 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; +} diff --git a/editor/tests/step323_test.cpp b/editor/tests/step323_test.cpp new file mode 100644 index 0000000..a0cbd0d --- /dev/null +++ b/editor/tests/step323_test.cpp @@ -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 +#include +#include +#include + +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; +} diff --git a/progress.md b/progress.md index 616a159..4ce5c22 100644 --- a/progress.md +++ b/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/.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+