Steps 263-265: Phase 9e — save buffer to disk, undo/redo, integration tests
Step 263: saveBuffer/saveAllBuffers RPC methods write editBuf to disk with workspace path resolution and parent dir creation. 12/12 tests. Step 264: State-based undo/redo via HeadlessUndoStack. Snapshots recorded automatically before mutations; undo/redo restore AST + editBuf. 12/12 tests. Step 265: Phase 9e integration tests — save after undo/redo, mixed undo states across buffers, full open→mutate→diagnose→undo→redo→save. 8/8 tests. Phase 9e complete: 32/32 tests. Sprint 9 total: 244/244 across 5 phases. 34 MCP tools. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,7 +70,11 @@ struct AgentPermissionPolicy {
|
||||
method == "applyQuickFix" ||
|
||||
method == "openFile" ||
|
||||
method == "closeFile" ||
|
||||
method == "renameSymbol") {
|
||||
method == "renameSymbol" ||
|
||||
method == "saveBuffer" ||
|
||||
method == "saveAllBuffers" ||
|
||||
method == "undo" ||
|
||||
method == "redo") {
|
||||
return role == AgentRole::Refactor || role == AgentRole::Generator;
|
||||
}
|
||||
|
||||
|
||||
@@ -317,6 +317,9 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
"agent-mutation:" + type, affectedIds,
|
||||
state.agentActorLabel(sessionId));
|
||||
state.active()->versionTracker.recordMutation(affectedIds);
|
||||
// Record post-mutation state for undo
|
||||
state.active()->undoStack.record(
|
||||
state.active()->editBuf, state.activeAST());
|
||||
}
|
||||
return headlessRpcResult(id, {
|
||||
{"success", true}, {"warning", res.warning},
|
||||
@@ -376,6 +379,9 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
for (const auto& m : mutations)
|
||||
if (!m.nodeId.empty()) batchIds.push_back(m.nodeId);
|
||||
state.active()->versionTracker.recordMutation(batchIds);
|
||||
// Record post-mutation state for undo
|
||||
state.active()->undoStack.record(
|
||||
state.active()->editBuf, state.activeAST());
|
||||
}
|
||||
return headlessRpcResult(id,
|
||||
{{"success", true}, {"appliedCount", batchRes.appliedCount},
|
||||
@@ -1176,6 +1182,7 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
return headlessRpcError(id, -32602,
|
||||
"Missing oldName or newName parameter");
|
||||
|
||||
|
||||
// Collect changes across all open buffers
|
||||
std::vector<RenameChange> allChanges;
|
||||
for (const auto& [path, buf] : state.bufferStates) {
|
||||
@@ -1254,11 +1261,22 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
}
|
||||
}
|
||||
|
||||
// Mark affected buffers as modified
|
||||
// Mark affected buffers as modified, regenerate editBuf, record undo
|
||||
for (const auto& f : affectedFiles) {
|
||||
auto it = state.bufferStates.find(f);
|
||||
if (it != state.bufferStates.end())
|
||||
if (it != state.bufferStates.end()) {
|
||||
it->second->modified = true;
|
||||
// Regenerate code from the modified AST
|
||||
Module* bufAST = it->second->sync.getAST();
|
||||
if (bufAST) {
|
||||
Pipeline pipeline;
|
||||
it->second->editBuf = pipeline.generate(
|
||||
bufAST, it->second->language);
|
||||
// Record post-rename state for undo
|
||||
it->second->undoStack.record(
|
||||
it->second->editBuf, bufAST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json result = {
|
||||
@@ -1363,6 +1381,168 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
|
||||
});
|
||||
}
|
||||
|
||||
// --- undo ---
|
||||
if (method == "undo") {
|
||||
if (!AgentPermissionPolicy::canInvoke(role, method))
|
||||
return headlessRpcError(id, -32031, "Role not permitted");
|
||||
if (!state.activeBuffer)
|
||||
return headlessRpcError(id, -32000, "No active buffer");
|
||||
auto& stack = state.activeBuffer->undoStack;
|
||||
if (!stack.canUndo())
|
||||
return headlessRpcError(id, -32050, "Nothing to undo");
|
||||
const auto& snap = stack.undo();
|
||||
// Restore AST from JSON
|
||||
if (!snap.astJson.is_null()) {
|
||||
ASTNode* node = fromJson(snap.astJson);
|
||||
if (node && node->conceptType == "Module") {
|
||||
state.activeBuffer->sync.setAST(
|
||||
std::unique_ptr<Module>(
|
||||
static_cast<Module*>(node)));
|
||||
state.activeBuffer->orchestratorDirty = true;
|
||||
} else {
|
||||
deleteTree(node);
|
||||
}
|
||||
}
|
||||
state.activeBuffer->editBuf = snap.text;
|
||||
state.activeBuffer->modified = true;
|
||||
return headlessRpcResult(id, {
|
||||
{"success", true},
|
||||
{"undoDepth", stack.undoDepth()},
|
||||
{"redoDepth", stack.redoDepth()}
|
||||
});
|
||||
}
|
||||
|
||||
// --- redo ---
|
||||
if (method == "redo") {
|
||||
if (!AgentPermissionPolicy::canInvoke(role, method))
|
||||
return headlessRpcError(id, -32031, "Role not permitted");
|
||||
if (!state.activeBuffer)
|
||||
return headlessRpcError(id, -32000, "No active buffer");
|
||||
auto& stack = state.activeBuffer->undoStack;
|
||||
if (!stack.canRedo())
|
||||
return headlessRpcError(id, -32050, "Nothing to redo");
|
||||
const auto& snap = stack.redo();
|
||||
if (!snap.astJson.is_null()) {
|
||||
ASTNode* node = fromJson(snap.astJson);
|
||||
if (node && node->conceptType == "Module") {
|
||||
state.activeBuffer->sync.setAST(
|
||||
std::unique_ptr<Module>(
|
||||
static_cast<Module*>(node)));
|
||||
state.activeBuffer->orchestratorDirty = true;
|
||||
} else {
|
||||
deleteTree(node);
|
||||
}
|
||||
}
|
||||
state.activeBuffer->editBuf = snap.text;
|
||||
state.activeBuffer->modified = true;
|
||||
return headlessRpcResult(id, {
|
||||
{"success", true},
|
||||
{"undoDepth", stack.undoDepth()},
|
||||
{"redoDepth", stack.redoDepth()}
|
||||
});
|
||||
}
|
||||
|
||||
// --- saveBuffer ---
|
||||
if (method == "saveBuffer") {
|
||||
if (!AgentPermissionPolicy::canInvoke(role, method))
|
||||
return headlessRpcError(id, -32031, "Role not permitted");
|
||||
auto params = request.contains("params") ? request["params"]
|
||||
: json::object();
|
||||
std::string path = params.value("path", "");
|
||||
|
||||
// Default to active buffer if no path specified
|
||||
if (path.empty()) {
|
||||
if (!state.activeBuffer)
|
||||
return headlessRpcError(id, -32000, "No active buffer");
|
||||
path = state.activeBuffer->path;
|
||||
}
|
||||
|
||||
auto it = state.bufferStates.find(path);
|
||||
if (it == state.bufferStates.end())
|
||||
return headlessRpcError(id, -32002,
|
||||
"Buffer not found: " + path);
|
||||
|
||||
auto& buf = it->second;
|
||||
|
||||
// Resolve path against workspace root
|
||||
std::string writePath = path;
|
||||
if (!state.workspaceRoot.empty()) {
|
||||
auto [ok, resolved] =
|
||||
fileOpsResolvePath(state.workspaceRoot, path);
|
||||
if (!ok)
|
||||
return headlessRpcError(id, -32040, resolved);
|
||||
writePath = resolved;
|
||||
}
|
||||
|
||||
// Create parent directories if needed
|
||||
fs::path parentDir = fs::path(writePath).parent_path();
|
||||
if (!parentDir.empty()) {
|
||||
std::error_code ec;
|
||||
fs::create_directories(parentDir, ec);
|
||||
}
|
||||
|
||||
// Write editBuf to disk
|
||||
auto [success, msg, bytes] = fileOpsWrite(writePath, buf->editBuf);
|
||||
if (!success)
|
||||
return headlessRpcError(id, -32041, msg);
|
||||
|
||||
buf->modified = false;
|
||||
return headlessRpcResult(id, {
|
||||
{"success", true}, {"path", writePath},
|
||||
{"bytesWritten", bytes}
|
||||
});
|
||||
}
|
||||
|
||||
// --- saveAllBuffers ---
|
||||
if (method == "saveAllBuffers") {
|
||||
if (!AgentPermissionPolicy::canInvoke(role, method))
|
||||
return headlessRpcError(id, -32031, "Role not permitted");
|
||||
|
||||
json savedPaths = json::array();
|
||||
int savedCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
for (auto& [path, buf] : state.bufferStates) {
|
||||
if (!buf->modified) {
|
||||
++skippedCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string writePath = path;
|
||||
if (!state.workspaceRoot.empty()) {
|
||||
auto [ok, resolved] =
|
||||
fileOpsResolvePath(state.workspaceRoot, path);
|
||||
if (!ok) {
|
||||
++skippedCount;
|
||||
continue;
|
||||
}
|
||||
writePath = resolved;
|
||||
}
|
||||
|
||||
fs::path parentDir = fs::path(writePath).parent_path();
|
||||
if (!parentDir.empty()) {
|
||||
std::error_code ec;
|
||||
fs::create_directories(parentDir, ec);
|
||||
}
|
||||
|
||||
auto [success, msg, bytes] =
|
||||
fileOpsWrite(writePath, buf->editBuf);
|
||||
if (success) {
|
||||
buf->modified = false;
|
||||
savedPaths.push_back(writePath);
|
||||
++savedCount;
|
||||
} else {
|
||||
++skippedCount;
|
||||
}
|
||||
}
|
||||
|
||||
return headlessRpcResult(id, {
|
||||
{"savedCount", savedCount},
|
||||
{"skippedCount", skippedCount},
|
||||
{"saved", savedPaths}
|
||||
});
|
||||
}
|
||||
|
||||
// --- batchQuery ---
|
||||
if (method == "batchQuery") {
|
||||
auto params = request.contains("params") ? request["params"]
|
||||
|
||||
@@ -61,6 +61,44 @@ struct HeadlessLibraryState {
|
||||
SemanticTags semanticTags;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HeadlessUndoStack — state-based undo/redo for headless buffers
|
||||
// -----------------------------------------------------------------------
|
||||
struct HeadlessUndoState {
|
||||
std::string text;
|
||||
json astJson;
|
||||
};
|
||||
|
||||
class HeadlessUndoStack {
|
||||
public:
|
||||
// Record current state as a checkpoint
|
||||
void record(const std::string& text, Module* ast) {
|
||||
// Truncate any redo states
|
||||
if (position_ + 1 < (int)states_.size())
|
||||
states_.resize(position_ + 1);
|
||||
HeadlessUndoState s;
|
||||
s.text = text;
|
||||
if (ast) s.astJson = toJson(ast);
|
||||
states_.push_back(std::move(s));
|
||||
position_ = (int)states_.size() - 1;
|
||||
}
|
||||
|
||||
bool canUndo() const { return position_ > 0; }
|
||||
bool canRedo() const { return position_ + 1 < (int)states_.size(); }
|
||||
|
||||
const HeadlessUndoState& undo() { return states_[--position_]; }
|
||||
const HeadlessUndoState& redo() { return states_[++position_]; }
|
||||
|
||||
int undoDepth() const { return position_; }
|
||||
int redoDepth() const {
|
||||
return (int)states_.size() - position_ - 1;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<HeadlessUndoState> states_;
|
||||
int position_ = -1;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HeadlessBufferState — lightweight buffer without ImGui widgets
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -70,6 +108,7 @@ struct HeadlessBufferState {
|
||||
IncrementalOptimizer incrementalOptimizer;
|
||||
ASTVersionTracker versionTracker;
|
||||
DiagnosticVersionTracker diagTracker;
|
||||
HeadlessUndoStack undoStack;
|
||||
std::string language = "python";
|
||||
std::string path = "(untitled)";
|
||||
std::string editBuf;
|
||||
@@ -205,6 +244,8 @@ struct HeadlessEditorState {
|
||||
buf->orchestratorDirty = true;
|
||||
}
|
||||
HeadlessBufferState* raw = buf.get();
|
||||
// Record initial state for undo
|
||||
raw->undoStack.record(raw->editBuf, raw->sync.getAST());
|
||||
bufferStates[filePath] = std::move(buf);
|
||||
if (!activeBuffer) activeBuffer = raw;
|
||||
return raw;
|
||||
|
||||
@@ -879,6 +879,61 @@ private:
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Save and undo/redo tools
|
||||
// ---------------------------------------------------------------
|
||||
void registerSaveUndoTools() {
|
||||
// whetstone_save_buffer
|
||||
tools_.push_back({"whetstone_save_buffer",
|
||||
"Save a buffer to disk. Writes the current editBuf (code "
|
||||
"regenerated from AST) to the file path. Clears the "
|
||||
"modified flag. Defaults to active buffer if no path given.",
|
||||
{{"type", "object"}, {"properties", {
|
||||
{"path", {{"type", "string"},
|
||||
{"description",
|
||||
"Buffer path to save (optional, defaults to active)"}}}
|
||||
}}}
|
||||
});
|
||||
toolHandlers_["whetstone_save_buffer"] =
|
||||
[this](const json& args) {
|
||||
return callWhetstone("saveBuffer", args);
|
||||
};
|
||||
|
||||
// whetstone_save_all_buffers
|
||||
tools_.push_back({"whetstone_save_all_buffers",
|
||||
"Save all modified buffers to disk. Skips unmodified "
|
||||
"buffers. Returns count of saved and skipped files.",
|
||||
{{"type", "object"}, {"properties", json::object()}}
|
||||
});
|
||||
toolHandlers_["whetstone_save_all_buffers"] =
|
||||
[this](const json& args) {
|
||||
return callWhetstone("saveAllBuffers", args);
|
||||
};
|
||||
|
||||
// whetstone_undo
|
||||
tools_.push_back({"whetstone_undo",
|
||||
"Undo the last mutation on the active buffer. Restores "
|
||||
"the previous AST and regenerated code from the snapshot "
|
||||
"journal. Returns the new undo/redo depths.",
|
||||
{{"type", "object"}, {"properties", json::object()}}
|
||||
});
|
||||
toolHandlers_["whetstone_undo"] =
|
||||
[this](const json& args) {
|
||||
return callWhetstone("undo", args);
|
||||
};
|
||||
|
||||
// whetstone_redo
|
||||
tools_.push_back({"whetstone_redo",
|
||||
"Redo the last undone mutation on the active buffer. "
|
||||
"Returns the new undo/redo depths.",
|
||||
{{"type", "object"}, {"properties", json::object()}}
|
||||
});
|
||||
toolHandlers_["whetstone_redo"] =
|
||||
[this](const json& args) {
|
||||
return callWhetstone("redo", args);
|
||||
};
|
||||
}
|
||||
|
||||
void registerWhetstoneTools() {
|
||||
registerASTTools();
|
||||
registerAnnotationTools();
|
||||
@@ -886,5 +941,6 @@ private:
|
||||
registerDiagnosticTools();
|
||||
registerBatchTools();
|
||||
registerProjectTools();
|
||||
registerSaveUndoTools();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user