diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 8b12c03..e32b930 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -265,6 +265,10 @@ target_include_directories(step57_test PRIVATE src) add_executable(step58_test tests/step58_test.cpp) target_include_directories(step58_test PRIVATE src) +add_executable(step59_test tests/step59_test.cpp) +target_include_directories(step59_test PRIVATE src) +target_link_libraries(step59_test PRIVATE nlohmann_json::nlohmann_json) + add_executable(step60_test tests/step60_test.cpp) target_include_directories(step60_test PRIVATE src) target_link_libraries(step60_test PRIVATE nlohmann_json::nlohmann_json) diff --git a/editor/src/ASTMutationAPI.h b/editor/src/ASTMutationAPI.h new file mode 100644 index 0000000..d584748 --- /dev/null +++ b/editor/src/ASTMutationAPI.h @@ -0,0 +1,215 @@ +#pragma once +// Step 61: AST mutation API (extend) +// +// ASTMutationAPI: provides validated AST mutations with journal recording +// and OptimizationLock awareness. +// +// Methods: +// setProperty — set a single property on a node +// updateNode — bulk property update +// deleteNode — remove a node from its parent +// insertNode — add a node as child of a parent in a given role +// +// All mutations check for OptimizationLock annotations on the target +// node or its ancestors. If a lock is found the mutation still succeeds +// but a warning is returned (per spec: "warn, don't reject"). + +#include +#include +#include +#include "ast/ASTNode.h" +#include "ast/Module.h" +#include "ast/Function.h" +#include "ast/Variable.h" +#include "ast/Parameter.h" +#include "ast/Expression.h" +#include "ast/Annotation.h" + +class ASTMutationAPI { +public: + struct MutationResult { + bool success = false; + std::string warning; // non-empty if a warning was produced + std::string error; // non-empty if the mutation failed + }; + + void setRoot(ASTNode* root) { root_ = root; } + + // --- mutations --- + + MutationResult setProperty(const std::string& nodeId, + const std::string& property, + const std::string& value) { + MutationResult res; + ASTNode* node = findById(root_, nodeId); + if (!node) { + res.error = "Node not found: " + nodeId; + return res; + } + + // Lock check + std::string lockWarning = checkLock(node); + + if (!applyProperty(node, property, value)) { + res.error = "Cannot set property '" + property + + "' on " + node->conceptType; + return res; + } + + // Record in journal + journal_.push_back("setProperty " + nodeId + "." + property + + " = " + value); + + res.success = true; + res.warning = lockWarning; + return res; + } + + MutationResult updateNode(const std::string& nodeId, + const std::map& properties) { + MutationResult res; + ASTNode* node = findById(root_, nodeId); + if (!node) { + res.error = "Node not found: " + nodeId; + return res; + } + + std::string lockWarning = checkLock(node); + + for (const auto& [prop, val] : properties) { + if (!applyProperty(node, prop, val)) { + res.error = "Cannot set property '" + prop + + "' on " + node->conceptType; + return res; + } + } + + journal_.push_back("updateNode " + nodeId + " (" + + std::to_string(properties.size()) + " properties)"); + + res.success = true; + res.warning = lockWarning; + return res; + } + + MutationResult deleteNode(const std::string& nodeId) { + MutationResult res; + ASTNode* node = findById(root_, nodeId); + if (!node) { + res.error = "Node not found: " + nodeId; + return res; + } + if (!node->parent) { + res.error = "Cannot delete root node"; + return res; + } + + std::string lockWarning = checkLock(node); + + ASTNode* par = node->parent; + if (!par->removeChild(node)) { + res.error = "Failed to remove node from parent"; + return res; + } + + journal_.push_back("deleteNode " + nodeId); + + res.success = true; + res.warning = lockWarning; + return res; + } + + MutationResult insertNode(const std::string& parentId, + const std::string& role, + ASTNode* node) { + MutationResult res; + ASTNode* parent = findById(root_, parentId); + if (!parent) { + res.error = "Parent not found: " + parentId; + return res; + } + if (!node) { + res.error = "Node is null"; + return res; + } + + std::string lockWarning = checkLock(parent); + + parent->addChild(role, node); + + journal_.push_back("insertNode " + node->id + " into " + + parentId + "." + role); + + res.success = true; + res.warning = lockWarning; + return res; + } + + // --- journal --- + + std::vector getJournalEntries() const { return journal_; } + void clearJournal() { journal_.clear(); } + +private: + // Recursive node lookup + ASTNode* findById(ASTNode* node, const std::string& nodeId) const { + if (!node) return nullptr; + if (node->id == nodeId) return node; + for (auto* child : node->allChildren()) { + auto* found = findById(child, nodeId); + if (found) return found; + } + return nullptr; + } + + // Walk up the ancestor chain looking for an OptimizationLock annotation. + // Returns a warning string if locked, empty string otherwise. + std::string checkLock(const ASTNode* node) const { + const ASTNode* cur = node; + while (cur) { + for (const auto* anno : cur->getChildren("annotations")) { + if (anno->conceptType == "OptimizationLock") { + auto* lock = static_cast(anno); + return "Node is under OptimizationLock (locked by " + + lock->lockedBy + ": " + lock->lockReason + ")"; + } + } + cur = cur->parent; + } + return ""; + } + + // Apply a string property to a node. Returns false if the property + // is not recognised for the node's concept type. + bool applyProperty(ASTNode* node, const std::string& property, + const std::string& value) const { + const auto& ct = node->conceptType; + if (property == "name") { + if (ct == "Function") { static_cast(node)->name = value; return true; } + if (ct == "Variable") { static_cast(node)->name = value; return true; } + if (ct == "Parameter") { static_cast(node)->name = value; return true; } + if (ct == "Module") { static_cast(node)->name = value; return true; } + } + if (property == "strategy") { + if (ct == "DerefStrategy") { static_cast(node)->strategy = value; return true; } + if (ct == "DeallocateAnnotation") { static_cast(node)->strategy = value; return true; } + if (ct == "LifetimeAnnotation") { static_cast(node)->strategy = value; return true; } + if (ct == "ReclaimAnnotation") { static_cast(node)->strategy = value; return true; } + if (ct == "OwnerAnnotation") { static_cast(node)->strategy = value; return true; } + if (ct == "AllocateAnnotation") { static_cast(node)->strategy = value; return true; } + } + if (property == "op") { + if (ct == "BinaryOperation") { static_cast(node)->op = value; return true; } + } + if (property == "variableName") { + if (ct == "VariableReference") { static_cast(node)->variableName = value; return true; } + } + if (property == "targetLanguage") { + if (ct == "Module") { static_cast(node)->targetLanguage = value; return true; } + } + return false; + } + + ASTNode* root_ = nullptr; + std::vector journal_; +}; diff --git a/editor/src/BatchMutationAPI.h b/editor/src/BatchMutationAPI.h new file mode 100644 index 0000000..3c7b022 --- /dev/null +++ b/editor/src/BatchMutationAPI.h @@ -0,0 +1,237 @@ +#pragma once +// Step 63: Batch operations (extend) +// +// BatchMutationAPI: atomic all-or-nothing mutation sequences. +// +// applySequence(mutations) tries each mutation in order. If any +// mutation fails, every preceding mutation is rolled back in reverse +// order so the AST returns to its pre-batch state. +// +// Supported mutation types: "setProperty", "insertNode", "deleteNode". + +#include +#include +#include +#include +#include +#include "ast/ASTNode.h" +#include "ast/Module.h" +#include "ast/Function.h" +#include "ast/Variable.h" +#include "ast/Parameter.h" +#include "ast/Expression.h" + +class BatchMutationAPI { +public: + struct Mutation { + std::string type; // "setProperty", "insertNode", "deleteNode" + std::string nodeId; + std::string property; // for setProperty + std::string value; // for setProperty + std::string parentId; // for insertNode + std::string role; // for insertNode + ASTNode* newNode = nullptr; // for insertNode + }; + + struct BatchResult { + bool success = false; + int appliedCount = 0; + std::string error; + }; + + void setRoot(ASTNode* root) { root_ = root; } + + BatchResult applySequence(const std::vector& mutations) { + BatchResult res; + std::vector undoStack; + + for (size_t i = 0; i < mutations.size(); ++i) { + const auto& mut = mutations[i]; + std::string err; + + if (mut.type == "setProperty") { + err = applySetProperty(mut, undoStack); + } else if (mut.type == "insertNode") { + err = applyInsertNode(mut, undoStack); + } else if (mut.type == "deleteNode") { + err = applyDeleteNode(mut, undoStack); + } else { + err = "Unknown mutation type: " + mut.type; + } + + if (!err.empty()) { + // Roll back everything applied so far + rollback(undoStack); + res.success = false; + res.appliedCount = static_cast(i); + res.error = err; + return res; + } + } + + // All succeeded — record in journal + for (const auto& undo : undoStack) { + journal_.push_back(undo.description); + } + + res.success = true; + res.appliedCount = static_cast(mutations.size()); + return res; + } + + std::vector getJournalEntries() const { return journal_; } + void clearJournal() { journal_.clear(); } + +private: + // An undo action captures how to reverse a single mutation. + struct UndoAction { + std::function undo; + std::string description; + }; + + // --- mutation implementations --- + + std::string applySetProperty(const Mutation& mut, + std::vector& undoStack) { + ASTNode* node = findById(root_, mut.nodeId); + if (!node) + return "Node not found: " + mut.nodeId; + + // Read old value + std::string oldValue = getStringProperty(node, mut.property); + + if (!setStringProperty(node, mut.property, mut.value)) + return "Cannot set property '" + mut.property + + "' on " + node->conceptType; + + // Capture undo + std::string nid = mut.nodeId; + std::string prop = mut.property; + ASTNode** rootPtr = &root_; + undoStack.push_back({ + [node, prop, oldValue, this]() { + setStringProperty(node, prop, oldValue); + }, + "setProperty " + mut.nodeId + "." + mut.property + " = " + mut.value + }); + return ""; + } + + std::string applyInsertNode(const Mutation& mut, + std::vector& undoStack) { + ASTNode* parent = findById(root_, mut.parentId); + if (!parent) + return "Parent not found: " + mut.parentId; + if (!mut.newNode) + return "Node is null for insertNode"; + + parent->addChild(mut.role, mut.newNode); + + ASTNode* inserted = mut.newNode; + undoStack.push_back({ + [parent, inserted]() { + parent->removeChild(inserted); + }, + "insertNode " + inserted->id + " into " + mut.parentId + "." + mut.role + }); + return ""; + } + + std::string applyDeleteNode(const Mutation& mut, + std::vector& undoStack) { + ASTNode* node = findById(root_, mut.nodeId); + if (!node) + return "Node not found: " + mut.nodeId; + if (!node->parent) + return "Cannot delete root node"; + + ASTNode* parent = node->parent; + std::string role = findRole(parent, node); + if (role.empty()) + return "Could not determine role for node " + mut.nodeId; + + if (!parent->removeChild(node)) + return "Failed to remove node from parent"; + + undoStack.push_back({ + [parent, node, role]() { + parent->addChild(role, node); + }, + "deleteNode " + mut.nodeId + }); + return ""; + } + + // --- rollback --- + + static void rollback(std::vector& undoStack) { + for (auto it = undoStack.rbegin(); it != undoStack.rend(); ++it) { + it->undo(); + } + undoStack.clear(); + } + + // --- helpers --- + + ASTNode* findById(ASTNode* node, const std::string& id) const { + if (!node) return nullptr; + if (node->id == id) return node; + for (auto* child : node->allChildren()) { + auto* found = findById(child, id); + if (found) return found; + } + return nullptr; + } + + // Find which role a child occupies in its parent + static std::string findRole(const ASTNode* parent, const ASTNode* child) { + for (const auto& role : parent->childRoles()) { + for (const auto* kid : parent->getChildren(role)) { + if (kid == child) return role; + } + } + return ""; + } + + static std::string getStringProperty(const ASTNode* node, + const std::string& prop) { + const auto& ct = node->conceptType; + if (prop == "name") { + if (ct == "Function") return static_cast(node)->name; + if (ct == "Variable") return static_cast(node)->name; + if (ct == "Parameter") return static_cast(node)->name; + if (ct == "Module") return static_cast(node)->name; + } + if (prop == "op" && ct == "BinaryOperation") + return static_cast(node)->op; + if (prop == "variableName" && ct == "VariableReference") + return static_cast(node)->variableName; + if (prop == "targetLanguage" && ct == "Module") + return static_cast(node)->targetLanguage; + return ""; + } + + bool setStringProperty(ASTNode* node, const std::string& prop, + const std::string& value) const { + const auto& ct = node->conceptType; + if (prop == "name") { + if (ct == "Function") { static_cast(node)->name = value; return true; } + if (ct == "Variable") { static_cast(node)->name = value; return true; } + if (ct == "Parameter") { static_cast(node)->name = value; return true; } + if (ct == "Module") { static_cast(node)->name = value; return true; } + } + if (prop == "op" && ct == "BinaryOperation") { + static_cast(node)->op = value; return true; + } + if (prop == "variableName" && ct == "VariableReference") { + static_cast(node)->variableName = value; return true; + } + if (prop == "targetLanguage" && ct == "Module") { + static_cast(node)->targetLanguage = value; return true; + } + return false; + } + + ASTNode* root_ = nullptr; + std::vector journal_; +}; diff --git a/editor/src/ContextAPI.h b/editor/src/ContextAPI.h new file mode 100644 index 0000000..fe31338 --- /dev/null +++ b/editor/src/ContextAPI.h @@ -0,0 +1,261 @@ +#pragma once +// Step 62: Context API +// +// ContextAPI: provides scope, call-hierarchy, and data-flow queries +// over the SemAnno AST. +// +// Methods: +// getInScopeSymbols — variables/functions/parameters visible at a node +// getCallHierarchy — callers and callees of a function +// getDependencyGraph — data-flow dependencies of a node (via VariableRef) + +#include +#include +#include +#include "ast/ASTNode.h" +#include "ast/Module.h" +#include "ast/Function.h" +#include "ast/Variable.h" +#include "ast/Parameter.h" +#include "ast/Expression.h" + +class ContextAPI { +public: + struct Symbol { + std::string name; + std::string kind; // "function", "variable", "parameter" + std::string nodeId; + }; + + struct CallInfo { + std::string functionId; + std::string functionName; + std::vector callerIds; // functions that call this one + std::vector calleeIds; // functions called by this one + }; + + void setRoot(ASTNode* root) { root_ = root; } + + // --------------------------------------------------------------- + // getInScopeSymbols — walk up from nodeId, collecting symbols + // --------------------------------------------------------------- + std::vector getInScopeSymbols(const std::string& nodeId) const { + ASTNode* node = findById(root_, nodeId); + if (!node) return {}; + + std::vector symbols; + + // Walk up the ancestor chain (including node itself) + const ASTNode* cur = node; + while (cur) { + if (cur->conceptType == "Function") { + auto* fn = static_cast(cur); + // Parameters + for (auto* p : fn->getChildren("parameters")) { + if (p->conceptType == "Parameter") { + auto* param = static_cast(p); + addSymbolUnique(symbols, {param->name, "parameter", param->id}); + } + } + // Body variables + for (auto* child : fn->getChildren("body")) { + if (child->conceptType == "Variable") { + auto* v = static_cast(child); + addSymbolUnique(symbols, {v->name, "variable", v->id}); + } + } + } + else if (cur->conceptType == "Module") { + auto* mod = static_cast(cur); + // Module-level variables + for (auto* child : mod->getChildren("variables")) { + if (child->conceptType == "Variable") { + auto* v = static_cast(child); + addSymbolUnique(symbols, {v->name, "variable", v->id}); + } + } + // Module-level functions + for (auto* child : mod->getChildren("functions")) { + if (child->conceptType == "Function") { + auto* fn = static_cast(child); + addSymbolUnique(symbols, {fn->name, "function", fn->id}); + } + } + } + cur = cur->parent; + } + + return symbols; + } + + // --------------------------------------------------------------- + // getCallHierarchy + // --------------------------------------------------------------- + CallInfo getCallHierarchy(const std::string& functionId) const { + CallInfo info; + ASTNode* fnNode = findById(root_, functionId); + if (!fnNode || fnNode->conceptType != "Function") return info; + + auto* fn = static_cast(fnNode); + info.functionId = fn->id; + info.functionName = fn->name; + + // Collect ALL FunctionCall nodes in the entire tree + std::vector allCalls; + collectByType(root_, "FunctionCall", allCalls); + + // callerIds: functions that contain a call to fn->name + // calleeIds: functions called from within fn + for (auto* callNode : allCalls) { + auto* fc = static_cast(callNode); + + if (fc->functionName == fn->name) { + // Someone calls our function — find the enclosing Function + Function* caller = enclosingFunction(callNode); + if (caller && caller->id != fn->id) { + addUnique(info.callerIds, caller->id); + } + } + + // Is this call inside our function? + if (isDescendantOf(callNode, fnNode)) { + // Resolve callee function ID by name + std::string calleeId = resolveFunctionId(fc->functionName); + if (!calleeId.empty()) { + addUnique(info.calleeIds, calleeId); + } + } + } + + return info; + } + + // --------------------------------------------------------------- + // getDependencyGraph — data-flow deps via VariableReference + // --------------------------------------------------------------- + std::vector getDependencyGraph(const std::string& nodeId) const { + ASTNode* node = findById(root_, nodeId); + if (!node) return {}; + + // Collect all VariableReference nodes in the subtree + std::vector refs; + collectByType(node, "VariableReference", refs); + + std::vector deps; + for (auto* refNode : refs) { + auto* vr = static_cast(refNode); + // Resolve to the declaration node (Variable or Parameter) + std::string declId = resolveSymbolId(vr->variableName, refNode); + if (!declId.empty()) { + addUnique(deps, declId); + } + } + return deps; + } + +private: + // --- helpers --- + + ASTNode* findById(ASTNode* node, const std::string& id) const { + if (!node) return nullptr; + if (node->id == id) return node; + for (auto* child : node->allChildren()) { + auto* found = findById(child, id); + if (found) return found; + } + return nullptr; + } + + static void collectByType(ASTNode* node, const std::string& type, + std::vector& out) { + if (!node) return; + if (node->conceptType == type) out.push_back(node); + for (auto* child : node->allChildren()) { + collectByType(child, type, out); + } + } + + // Walk up to find the nearest enclosing Function + static Function* enclosingFunction(ASTNode* node) { + ASTNode* cur = node->parent; + while (cur) { + if (cur->conceptType == "Function") + return static_cast(cur); + cur = cur->parent; + } + return nullptr; + } + + // Check if child is a descendant of ancestor + static bool isDescendantOf(const ASTNode* child, const ASTNode* ancestor) { + const ASTNode* cur = child->parent; + while (cur) { + if (cur == ancestor) return true; + cur = cur->parent; + } + return false; + } + + // Resolve a function name to its node ID (searching module-level functions) + std::string resolveFunctionId(const std::string& name) const { + if (!root_) return ""; + // Find the module (root or first module child) + const ASTNode* mod = root_; + if (mod->conceptType != "Module") return ""; + + for (auto* child : mod->getChildren("functions")) { + if (child->conceptType == "Function") { + auto* fn = static_cast(child); + if (fn->name == name) return fn->id; + } + } + return ""; + } + + // Resolve a variable/parameter name to its declaration ID, + // searching up the scope chain from refNode. + std::string resolveSymbolId(const std::string& name, + const ASTNode* refNode) const { + const ASTNode* cur = refNode; + while (cur) { + if (cur->conceptType == "Function") { + auto* fn = static_cast(cur); + for (auto* p : fn->getChildren("parameters")) { + if (p->conceptType == "Parameter" && + static_cast(p)->name == name) + return p->id; + } + for (auto* child : fn->getChildren("body")) { + if (child->conceptType == "Variable" && + static_cast(child)->name == name) + return child->id; + } + } + else if (cur->conceptType == "Module") { + for (auto* child : cur->getChildren("variables")) { + if (child->conceptType == "Variable" && + static_cast(child)->name == name) + return child->id; + } + } + cur = cur->parent; + } + return ""; + } + + static void addSymbolUnique(std::vector& symbols, + const Symbol& sym) { + for (const auto& s : symbols) { + if (s.nodeId == sym.nodeId) return; + } + symbols.push_back(sym); + } + + static void addUnique(std::vector& vec, + const std::string& val) { + if (std::find(vec.begin(), vec.end(), val) == vec.end()) + vec.push_back(val); + } + + ASTNode* root_ = nullptr; +}; diff --git a/editor/src/WebSocketServer.h b/editor/src/WebSocketServer.h new file mode 100644 index 0000000..b33aa64 --- /dev/null +++ b/editor/src/WebSocketServer.h @@ -0,0 +1,359 @@ +#pragma once +// Step 59: WebSocket agent endpoint +// +// WebSocketAgentServer: manages agent sessions and routes JSON-RPC messages. +// Agents connect via WebSocket and exchange JSON-RPC over the connection. +// +// Architecture: +// WebSocketTransport (abstract) — handles raw WebSocket I/O +// WebSocketAgentServer — session management + JSON-RPC dispatch +// MockWebSocketTransport — for testing without real sockets +// +// The server runs alongside the existing stdin/stdout JSON-RPC in +// orchestrator_main.cpp. Both share the same processRequest() handler +// via the setRequestHandler() callback. + +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +// --------------------------------------------------------------------------- +// AgentSession — represents one connected agent +// --------------------------------------------------------------------------- +struct AgentSession { + std::string sessionId; + std::string agentName; // optional, set via setAgentName RPC + bool connected = false; + uint64_t connectedAtMs = 0; + uint64_t lastMessageAtMs = 0; + int messageCount = 0; +}; + +// Callback types +using JsonRpcHandler = std::function; +using SessionEventCallback = + std::function; + +// --------------------------------------------------------------------------- +// WebSocketTransport — abstract transport interface +// --------------------------------------------------------------------------- +class WebSocketTransport { +public: + virtual ~WebSocketTransport() = default; + + virtual bool start(int port) = 0; + virtual void stop() = 0; + virtual bool isRunning() const = 0; + + // Send a text message to a connected session + virtual bool sendMessage(const std::string& sessionId, + const std::string& message) = 0; + + // --- handler registration (called by WebSocketAgentServer) --- + + void setMessageHandler( + std::function handler) { + messageHandler_ = std::move(handler); + } + + void setConnectHandler( + std::function handler) { + connectHandler_ = std::move(handler); + } + + void setDisconnectHandler( + std::function handler) { + disconnectHandler_ = std::move(handler); + } + +protected: + std::function messageHandler_; + std::function connectHandler_; + std::function disconnectHandler_; +}; + +// --------------------------------------------------------------------------- +// WebSocketAgentServer +// --------------------------------------------------------------------------- +class WebSocketAgentServer { +public: + explicit WebSocketAgentServer(std::unique_ptr transport) + : transport_(std::move(transport)) + { + transport_->setMessageHandler( + [this](const std::string& sid, const std::string& msg) { + onMessage(sid, msg); + }); + transport_->setConnectHandler( + [this](const std::string& sid) { onConnect(sid); }); + transport_->setDisconnectHandler( + [this](const std::string& sid) { onDisconnect(sid); }); + } + + bool start(int port) { + port_ = port; + return transport_->start(port); + } + + void stop() { transport_->stop(); } + + bool isRunning() const { return transport_->isRunning(); } + int getPort() const { return port_; } + + // Register a fallback JSON-RPC handler for methods the server doesn't + // handle itself (e.g. the orchestrator's processRequest). + void setRequestHandler(JsonRpcHandler handler) { + requestHandler_ = std::move(handler); + } + + // Register a callback for session lifecycle events ("connected" / + // "disconnected"). + void setSessionEventCallback(SessionEventCallback cb) { + sessionEventCallback_ = std::move(cb); + } + + // --- session queries --- + + const AgentSession* getSession(const std::string& sessionId) const { + auto it = sessions_.find(sessionId); + return it != sessions_.end() ? &it->second : nullptr; + } + + std::vector getActiveSessions() const { + std::vector result; + for (const auto& [id, s] : sessions_) { + if (s.connected) result.push_back(s); + } + return result; + } + + size_t getActiveSessionCount() const { + size_t n = 0; + for (const auto& [id, s] : sessions_) { + if (s.connected) ++n; + } + return n; + } + +private: + // --- transport callbacks --- + + void onConnect(const std::string& sessionId) { + AgentSession s; + s.sessionId = sessionId; + s.connected = true; + s.connectedAtMs = nowMs(); + s.lastMessageAtMs = s.connectedAtMs; + sessions_[sessionId] = s; + + if (sessionEventCallback_) + sessionEventCallback_(s, "connected"); + } + + void onDisconnect(const std::string& sessionId) { + auto it = sessions_.find(sessionId); + if (it != sessions_.end()) { + it->second.connected = false; + if (sessionEventCallback_) + sessionEventCallback_(it->second, "disconnected"); + } + } + + void onMessage(const std::string& sessionId, const std::string& message) { + // Update session stats + auto it = sessions_.find(sessionId); + if (it != sessions_.end()) { + it->second.lastMessageAtMs = nowMs(); + it->second.messageCount++; + } + + // Parse + dispatch + json response; + try { + json request = json::parse(message); + response = processRequest(request, sessionId); + } catch (const std::exception& e) { + response = { + {"jsonrpc", "2.0"}, + {"id", nullptr}, + {"error", {{"code", -32700}, + {"message", std::string("Parse error: ") + e.what()}}} + }; + } + + transport_->sendMessage(sessionId, response.dump()); + } + + // Route a JSON-RPC request. Built-in methods are handled first; + // everything else is forwarded to the registered handler. + json processRequest(const json& request, const std::string& sessionId) { + json response; + response["jsonrpc"] = "2.0"; + response["id"] = request.contains("id") ? request["id"] : json(nullptr); + + try { + std::string method = request.at("method").get(); + + if (method == "ping") { + response["result"] = json{ + {"message", "pong"}, + {"sessionId", sessionId} + }; + } + else if (method == "getSessionInfo") { + auto* sess = getSession(sessionId); + if (sess) { + response["result"] = { + {"sessionId", sess->sessionId}, + {"agentName", sess->agentName}, + {"connected", sess->connected}, + {"messageCount", sess->messageCount} + }; + } else { + response["error"] = { + {"code", -32603}, + {"message", "Session not found"} + }; + } + } + else if (method == "setAgentName") { + std::string name = + request.at("params").at("name").get(); + auto sit = sessions_.find(sessionId); + if (sit != sessions_.end()) { + sit->second.agentName = name; + response["result"] = true; + } else { + response["error"] = { + {"code", -32603}, + {"message", "Session not found"} + }; + } + } + else if (method == "listSessions") { + json arr = json::array(); + for (const auto& [id, s] : sessions_) { + if (s.connected) { + arr.push_back({ + {"sessionId", s.sessionId}, + {"agentName", s.agentName}, + {"messageCount", s.messageCount} + }); + } + } + response["result"] = arr; + } + else if (requestHandler_) { + // Delegate to the orchestrator's processRequest + response = requestHandler_(request); + // Ensure jsonrpc and id are set + response["jsonrpc"] = "2.0"; + if (!response.contains("id")) + response["id"] = request.contains("id") + ? request["id"] : json(nullptr); + } + else { + response["error"] = { + {"code", -32601}, + {"message", "Method not found: " + method} + }; + } + } catch (const std::exception& e) { + response["error"] = { + {"code", -32600}, + {"message", std::string("Invalid request: ") + e.what()} + }; + } + + return response; + } + + static uint64_t nowMs() { + using namespace std::chrono; + return static_cast( + duration_cast( + steady_clock::now().time_since_epoch()) + .count()); + } + + std::unique_ptr transport_; + std::map sessions_; + JsonRpcHandler requestHandler_; + SessionEventCallback sessionEventCallback_; + int port_ = 0; +}; + +// --------------------------------------------------------------------------- +// MockWebSocketTransport — for testing without real sockets +// --------------------------------------------------------------------------- +class MockWebSocketTransport : public WebSocketTransport { +public: + bool start(int port) override { + port_ = port; + running_ = true; + return true; + } + + void stop() override { running_ = false; } + + bool isRunning() const override { return running_; } + + bool sendMessage(const std::string& sessionId, + const std::string& message) override { + sentMessages_[sessionId].push_back(message); + return true; + } + + // --- test helpers: simulate agent behaviour --- + + // Simulate an agent opening a WebSocket connection + std::string simulateConnect() { + std::string sessionId = "agent_" + std::to_string(nextId_++); + if (connectHandler_) connectHandler_(sessionId); + return sessionId; + } + + // Simulate an agent closing its connection + void simulateDisconnect(const std::string& sessionId) { + if (disconnectHandler_) disconnectHandler_(sessionId); + } + + // Simulate an agent sending a text message + void simulateMessage(const std::string& sessionId, + const std::string& message) { + if (messageHandler_) messageHandler_(sessionId, message); + } + + // Get all messages the server sent back to a session + std::vector getSentMessages( + const std::string& sessionId) const { + auto it = sentMessages_.find(sessionId); + return it != sentMessages_.end() + ? it->second + : std::vector{}; + } + + // Get the most recent message sent to a session + std::string getLastSentMessage(const std::string& sessionId) const { + auto it = sentMessages_.find(sessionId); + if (it != sentMessages_.end() && !it->second.empty()) + return it->second.back(); + return ""; + } + + // Clear recorded messages (useful between test cases) + void clearSentMessages() { sentMessages_.clear(); } + +private: + bool running_ = false; + int port_ = 0; + int nextId_ = 1; + std::map> sentMessages_; +}; diff --git a/editor/src/ast/ASTNode.h b/editor/src/ast/ASTNode.h index 11c7013..01cb2bf 100644 --- a/editor/src/ast/ASTNode.h +++ b/editor/src/ast/ASTNode.h @@ -63,6 +63,19 @@ public: return result; } + // Remove a child from whatever role it belongs to + bool removeChild(ASTNode* child) { + for (auto& [role, kids] : children_) { + auto it = std::find(kids.begin(), kids.end(), child); + if (it != kids.end()) { + (*it)->parent = nullptr; + kids.erase(it); + return true; + } + } + return false; + } + private: std::map> children_; }; diff --git a/editor/tests/step59_test.cpp b/editor/tests/step59_test.cpp new file mode 100644 index 0000000..a26f7bb --- /dev/null +++ b/editor/tests/step59_test.cpp @@ -0,0 +1,300 @@ +// Step 59 TDD Test: WebSocket agent endpoint +// +// Tests the WebSocketAgentServer with MockWebSocketTransport: +// 1. Server starts and reports running +// 2. Agent connects and receives a session ID +// 3. Agent sends JSON-RPC `ping`, receives `pong` with session ID +// 4. Multiple agents get unique session IDs +// 5. Agent disconnect is tracked (session marked inactive) +// 6. Session event callbacks fire on connect/disconnect +// 7. Invalid JSON produces a parse-error response +// 8. Unknown method produces method-not-found error +// 9. Custom request handler delegation works +// 10. listSessions returns all active agents + +#include +#include +#include +#include +#include +#include +#include "WebSocketServer.h" + +using json = nlohmann::json; + +static bool contains(const std::string& haystack, const std::string& needle) { + return haystack.find(needle) != std::string::npos; +} + +int main() { + int passed = 0; + int failed = 0; + + // --- Test 1: Server starts and reports running --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); // keep raw ptr for test helpers + WebSocketAgentServer server(std::move(transport)); + + assert(!server.isRunning() && "Server should not be running before start"); + + bool ok = server.start(9090); + assert(ok && "Server should start successfully"); + assert(server.isRunning() && "Server should be running after start"); + assert(server.getPort() == 9090 && "Port should match"); + + server.stop(); + assert(!server.isRunning() && "Server should stop"); + + std::cout << "Test 1 PASS: Server starts and stops correctly" << std::endl; + ++passed; + } + + // --- Test 2: Agent connects and gets session ID --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); + WebSocketAgentServer server(std::move(transport)); + server.start(9090); + + std::string sid = tp->simulateConnect(); + assert(!sid.empty() && "Session ID should not be empty"); + + const AgentSession* session = server.getSession(sid); + assert(session != nullptr && "Session should exist after connect"); + assert(session->connected && "Session should be marked connected"); + assert(session->sessionId == sid && "Session ID should match"); + assert(server.getActiveSessionCount() == 1 && "Should have 1 active session"); + + std::cout << "Test 2 PASS: Agent connects and gets session ID" << std::endl; + ++passed; + } + + // --- Test 3: Agent sends ping, receives pong with session ID --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); + WebSocketAgentServer server(std::move(transport)); + server.start(9090); + + std::string sid = tp->simulateConnect(); + + json pingRequest = { + {"jsonrpc", "2.0"}, + {"id", 1}, + {"method", "ping"} + }; + tp->simulateMessage(sid, pingRequest.dump()); + + std::string response = tp->getLastSentMessage(sid); + assert(!response.empty() && "Should receive a response"); + + json resp = json::parse(response); + assert(resp.contains("result") && "Response should have result"); + assert(resp["result"]["message"] == "pong" && "Result message should be pong"); + assert(resp["result"]["sessionId"] == sid && "Result should include session ID"); + assert(resp["id"] == 1 && "Response ID should match request"); + + std::cout << "Test 3 PASS: ping returns pong with session ID" << std::endl; + ++passed; + } + + // --- Test 4: Multiple agents get unique session IDs --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); + WebSocketAgentServer server(std::move(transport)); + server.start(9090); + + std::string sid1 = tp->simulateConnect(); + std::string sid2 = tp->simulateConnect(); + std::string sid3 = tp->simulateConnect(); + + assert(sid1 != sid2 && "Session IDs should be unique"); + assert(sid2 != sid3 && "Session IDs should be unique"); + assert(sid1 != sid3 && "Session IDs should be unique"); + assert(server.getActiveSessionCount() == 3 && "Should have 3 active sessions"); + + std::cout << "Test 4 PASS: Multiple agents get unique session IDs" << std::endl; + ++passed; + } + + // --- Test 5: Agent disconnect is tracked --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); + WebSocketAgentServer server(std::move(transport)); + server.start(9090); + + std::string sid = tp->simulateConnect(); + assert(server.getActiveSessionCount() == 1); + + tp->simulateDisconnect(sid); + + const AgentSession* session = server.getSession(sid); + assert(session != nullptr && "Session should still exist"); + assert(!session->connected && "Session should be marked disconnected"); + assert(server.getActiveSessionCount() == 0 && "No active sessions after disconnect"); + + std::cout << "Test 5 PASS: Agent disconnect tracked correctly" << std::endl; + ++passed; + } + + // --- Test 6: Session event callbacks fire --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); + WebSocketAgentServer server(std::move(transport)); + + std::vector events; + server.setSessionEventCallback( + [&events](const AgentSession& s, const std::string& event) { + events.push_back(s.sessionId + ":" + event); + }); + + server.start(9090); + + std::string sid = tp->simulateConnect(); + assert(events.size() == 1 && "Should have 1 event after connect"); + assert(contains(events[0], "connected") && "Event should be 'connected'"); + + tp->simulateDisconnect(sid); + assert(events.size() == 2 && "Should have 2 events after disconnect"); + assert(contains(events[1], "disconnected") && "Event should be 'disconnected'"); + + std::cout << "Test 6 PASS: Session event callbacks fire correctly" << std::endl; + ++passed; + } + + // --- Test 7: Invalid JSON produces parse-error response --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); + WebSocketAgentServer server(std::move(transport)); + server.start(9090); + + std::string sid = tp->simulateConnect(); + tp->simulateMessage(sid, "this is not json!!!"); + + std::string response = tp->getLastSentMessage(sid); + json resp = json::parse(response); + assert(resp.contains("error") && "Should have error for invalid JSON"); + assert(resp["error"]["code"] == -32700 && "Error code should be -32700 (Parse error)"); + + std::cout << "Test 7 PASS: Invalid JSON returns parse error" << std::endl; + ++passed; + } + + // --- Test 8: Unknown method produces method-not-found error --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); + WebSocketAgentServer server(std::move(transport)); + server.start(9090); + + std::string sid = tp->simulateConnect(); + json req = { + {"jsonrpc", "2.0"}, + {"id", 42}, + {"method", "nonExistentMethod"} + }; + tp->simulateMessage(sid, req.dump()); + + std::string response = tp->getLastSentMessage(sid); + json resp = json::parse(response); + assert(resp.contains("error") && "Should have error for unknown method"); + assert(resp["error"]["code"] == -32601 && "Error code should be -32601 (Method not found)"); + assert(resp["id"] == 42 && "Response ID should match request"); + + std::cout << "Test 8 PASS: Unknown method returns method-not-found error" << std::endl; + ++passed; + } + + // --- Test 9: Custom request handler delegation --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); + WebSocketAgentServer server(std::move(transport)); + + // Register a custom handler that knows about "getAST" + server.setRequestHandler([](const json& request) -> json { + std::string method = request.at("method").get(); + json response; + response["jsonrpc"] = "2.0"; + response["id"] = request.contains("id") ? request["id"] : json(nullptr); + if (method == "getAST") { + response["result"] = {{"concept", "Module"}, {"name", "TestModule"}}; + } else { + response["error"] = {{"code", -32601}, {"message", "Method not found"}}; + } + return response; + }); + + server.start(9090); + std::string sid = tp->simulateConnect(); + + json req = { + {"jsonrpc", "2.0"}, + {"id", 10}, + {"method", "getAST"} + }; + tp->simulateMessage(sid, req.dump()); + + std::string response = tp->getLastSentMessage(sid); + json resp = json::parse(response); + assert(resp.contains("result") && "Delegated handler should return result"); + assert(resp["result"]["concept"] == "Module" && "Should get Module from handler"); + assert(resp["id"] == 10 && "Response ID should match"); + + std::cout << "Test 9 PASS: Custom request handler delegation works" << std::endl; + ++passed; + } + + // --- Test 10: listSessions returns all active agents --- + { + auto transport = std::make_unique(); + auto* tp = transport.get(); + WebSocketAgentServer server(std::move(transport)); + server.start(9090); + + std::string sid1 = tp->simulateConnect(); + std::string sid2 = tp->simulateConnect(); + + // Set agent name on sid1 + json nameReq = { + {"jsonrpc", "2.0"}, + {"id", 1}, + {"method", "setAgentName"}, + {"params", {{"name", "CodeAssistant"}}} + }; + tp->simulateMessage(sid1, nameReq.dump()); + + // Disconnect sid2 + tp->simulateDisconnect(sid2); + + // Ask for session list from sid1 + json listReq = { + {"jsonrpc", "2.0"}, + {"id", 2}, + {"method", "listSessions"} + }; + tp->simulateMessage(sid1, listReq.dump()); + + std::string response = tp->getLastSentMessage(sid1); + json resp = json::parse(response); + assert(resp.contains("result") && "Should have result"); + assert(resp["result"].is_array() && "Result should be an array"); + // Only sid1 should be active (sid2 disconnected) + assert(resp["result"].size() == 1 && "Should have 1 active session"); + assert(resp["result"][0]["agentName"] == "CodeAssistant" && + "Agent name should be set"); + + std::cout << "Test 10 PASS: listSessions returns active agents" << std::endl; + ++passed; + } + + // --- Summary --- + std::cout << "\n=== Step 59 Results: " << passed << " passed, " + << failed << " failed ===" << std::endl; + return failed > 0 ? 1 : 0; +} diff --git a/editor/tests/step61_test.cpp b/editor/tests/step61_test.cpp index e3464f7..f479487 100644 --- a/editor/tests/step61_test.cpp +++ b/editor/tests/step61_test.cpp @@ -1,13 +1,12 @@ // Step 61 TDD Test: AST mutation API (extend) // // Tests extended mutation API with validation and lock checking: -// 1. updateNode bulk property update -// 2. Mutation recorded in operation journal -// 3. Mutation on OptimizationLock node produces warning (not rejection) -// 4. deleteNode removes a node from its parent -// 5. insertNode adds a node at the correct position -// -// Will fail until the extended mutation API is implemented. +// 1. setProperty updates a node property +// 2. updateNode bulk property update +// 3. Mutation recorded in operation journal +// 4. Mutation on OptimizationLock node produces warning (not rejection) +// 5. deleteNode removes a node from its parent +// 6. insertNode adds a node at the correct position #include #include @@ -20,42 +19,7 @@ #include "ast/Statement.h" #include "ast/Expression.h" #include "ast/Annotation.h" - -// Forward declaration — ASTMutationAPI -class ASTMutationAPI { -public: - struct MutationResult { - bool success; - std::string warning; // Non-empty if a warning was produced - std::string error; // Non-empty if mutation failed - }; - - // Set the root AST to mutate - void setRoot(ASTNode* root); - - // Update multiple properties on a node at once - MutationResult updateNode(const std::string& nodeId, - const std::map& properties); - - // Delete a node by ID (removes from parent) - MutationResult deleteNode(const std::string& nodeId); - - // Insert a node as child of parentId in the given role - MutationResult insertNode(const std::string& parentId, - const std::string& role, - ASTNode* node); - - // Set a property on a node - MutationResult setProperty(const std::string& nodeId, - const std::string& property, - const std::string& value); - - // Get operation journal entries since last call - std::vector getJournalEntries() const; - - // Clear journal - void clearJournal(); -}; +#include "ASTMutationAPI.h" int main() { int passed = 0; diff --git a/editor/tests/step62_test.cpp b/editor/tests/step62_test.cpp index cc4be94..dd154ee 100644 --- a/editor/tests/step62_test.cpp +++ b/editor/tests/step62_test.cpp @@ -5,8 +5,6 @@ // 2. getCallHierarchy returns callers and callees // 3. getDependencyGraph returns data flow dependencies // 4. Scope respects nesting (inner scope sees outer, not vice versa) -// -// Will fail until the Context API is implemented. #include #include @@ -20,35 +18,7 @@ #include "ast/Parameter.h" #include "ast/Statement.h" #include "ast/Expression.h" - -// Forward declaration — ContextAPI -class ContextAPI { -public: - struct Symbol { - std::string name; - std::string kind; // "function", "variable", "parameter" - std::string nodeId; - }; - - struct CallInfo { - std::string functionId; - std::string functionName; - std::vector callerIds; // Functions that call this one - std::vector calleeIds; // Functions called by this one - }; - - // Set the root AST - void setRoot(ASTNode* root); - - // Get symbols in scope at a given node position - std::vector getInScopeSymbols(const std::string& nodeId) const; - - // Get call hierarchy for a function - CallInfo getCallHierarchy(const std::string& functionId) const; - - // Get data dependency node IDs for a given node - std::vector getDependencyGraph(const std::string& nodeId) const; -}; +#include "ContextAPI.h" static bool hasSymbol(const std::vector& symbols, const std::string& name) { return std::any_of(symbols.begin(), symbols.end(), diff --git a/editor/tests/step63_test.cpp b/editor/tests/step63_test.cpp index fe52e9c..ea2f742 100644 --- a/editor/tests/step63_test.cpp +++ b/editor/tests/step63_test.cpp @@ -4,9 +4,8 @@ // 1. applySequence of mutations applies all atomically // 2. If one mutation in the batch fails, all preceding mutations roll back // 3. Successful batch records all operations in journal -// 4. Partial failure doesn't leave AST in inconsistent state -// -// Will fail until applySequence with rollback is implemented. +// 4. Empty batch succeeds trivially +// 5. Partial failure doesn't leave AST in inconsistent state #include #include @@ -19,38 +18,7 @@ #include "ast/Function.h" #include "ast/Variable.h" #include "ast/Expression.h" - -// Forward declaration — BatchMutationAPI -class BatchMutationAPI { -public: - struct Mutation { - std::string type; // "setProperty", "insertNode", "deleteNode" - std::string nodeId; - std::string property; // For setProperty - std::string value; // For setProperty - std::string parentId; // For insertNode - std::string role; // For insertNode - ASTNode* newNode = nullptr; // For insertNode - }; - - struct BatchResult { - bool success; - int appliedCount; // How many mutations were applied before failure - std::string error; // Error message if failed - }; - - // Set the root AST - void setRoot(ASTNode* root); - - // Apply a sequence of mutations atomically (all-or-nothing) - BatchResult applySequence(const std::vector& mutations); - - // Get journal entries - std::vector getJournalEntries() const; - - // Clear journal - void clearJournal(); -}; +#include "BatchMutationAPI.h" int main() { int passed = 0;