From 233f372711fb35509623035793c0c997db6c6e35 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 25 May 2026 18:34:29 +0000 Subject: [PATCH] fix(handlers): restore GET /workspaces/:id/memories as v2 plugin shim (#1828) Phase A3 (#1792) removed the legacy GET /memories endpoint because it read the frozen agent_memories table. This broke old SDK callers (AwarenessClient, runtime agents) that 404'd into the canvas frontend. - Add MemoriesHandler.Search that proxies to the v2 plugin and reshapes the response to the legacy contract: [{id, content, scope, created_at}]. - Wire wsAuth.GET("/memories", memsh.Search) in router.go. - Return 503 when the memory plugin is not wired (matches Commit). - Return 502 on plugin search failure (matches v2 handler semantics). Tests cover: success (legacy shape + scope mapping), no-plugin 503, resolver error 500, plugin error 502. Fixes #1828 (GET 404 into canvas HTML). Co-Authored-By: Claude Opus 4.7 --- .../internal/handlers/memories.go | 57 +++++++++ .../internal/handlers/memories_test.go | 110 ++++++++++++++++++ workspace-server/internal/router/router.go | 4 + 3 files changed, 171 insertions(+) diff --git a/workspace-server/internal/handlers/memories.go b/workspace-server/internal/handlers/memories.go index 85bf52db0..b5b269312 100644 --- a/workspace-server/internal/handlers/memories.go +++ b/workspace-server/internal/handlers/memories.go @@ -260,3 +260,60 @@ func (h *MemoriesHandler) Commit(c *gin.Context) { // namespace — the latter is an internal storage detail. c.JSON(http.StatusCreated, gin.H{"id": memoryID, "scope": body.Scope, "namespace": namespace}) } + +// Search handles GET /workspaces/:id/memories (legacy v1 read path). +// +// Phase A3 (#1792) removed the original v1 Search because it read the frozen +// agent_memories table. This shim restores the endpoint for old callers +// (AwarenessClient, runtime SDKs) by proxying through the v2 plugin and +// reshaping the response to the legacy contract. +func (h *MemoriesHandler) Search(c *gin.Context) { + workspaceID := c.Param("id") + ctx := c.Request.Context() + + if h.memv2 == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "memory plugin is not configured (set MEMORY_PLUGIN_URL)", + }) + return + } + + readable, err := h.memv2.resolver.ReadableNamespaces(ctx, workspaceID) + if err != nil { + log.Printf("memories search: resolve readable namespaces for %s failed: %v", workspaceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve readable namespaces"}) + return + } + nsNames := make([]string, len(readable)) + for i, ns := range readable { + nsNames[i] = ns.Name + } + + resp, err := h.memv2.plugin.Search(ctx, contract.SearchRequest{ + Namespaces: nsNames, + Limit: 50, + }) + if err != nil { + log.Printf("memories search: plugin search for %s failed: %v", workspaceID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "memory plugin search failed"}) + return + } + + type legacyEntry struct { + ID string `json:"id"` + Content string `json:"content"` + Scope string `json:"scope"` + CreatedAt string `json:"created_at"` + } + out := make([]legacyEntry, 0, len(resp.Memories)) + for _, m := range resp.Memories { + scope := namespaceKindToLegacyScope(m.Namespace) + out = append(out, legacyEntry{ + ID: m.ID, + Content: m.Content, + Scope: scope, + CreatedAt: m.CreatedAt.Format("2006-01-02T15:04:05Z"), + }) + } + c.JSON(http.StatusOK, out) +} diff --git a/workspace-server/internal/handlers/memories_test.go b/workspace-server/internal/handlers/memories_test.go index 2da748b5a..b3f38f7c2 100644 --- a/workspace-server/internal/handlers/memories_test.go +++ b/workspace-server/internal/handlers/memories_test.go @@ -4,10 +4,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" "testing" + "time" "github.com/DATA-DOG/go-sqlmock" "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract" @@ -193,6 +195,114 @@ func TestMemoriesCommit_MissingFields(t *testing.T) { // ---------- MemoriesHandler: Search ---------- +func TestMemoriesSearch_Success(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + plugin := &stubMemoryPlugin{ + searchFn: func(_ context.Context, body contract.SearchRequest) (*contract.SearchResponse, error) { + return &contract.SearchResponse{ + Memories: []contract.Memory{ + {ID: "mem-1", Namespace: "workspace:ws-1", Content: "fact A", CreatedAt: time.Date(2026, 5, 25, 10, 0, 0, 0, time.UTC)}, + {ID: "mem-2", Namespace: "team:team-1", Content: "fact B", CreatedAt: time.Date(2026, 5, 25, 11, 0, 0, 0, time.UTC)}, + }, + }, nil + }, + } + resolver := &stubNamespaceResolver{ + readable: []namespace.Namespace{ + {Name: "workspace:ws-1", Kind: contract.NamespaceKindWorkspace}, + {Name: "team:team-1", Kind: contract.NamespaceKindTeam}, + }, + } + handler := NewMemoriesHandler().withMemoryV2APIs(plugin, resolver) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}} + c.Request = httptest.NewRequest("GET", "/", nil) + + handler.Search(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp) != 2 { + t.Fatalf("expected 2 results, got %d", len(resp)) + } + if resp[0]["id"] != "mem-1" { + t.Errorf("expected id mem-1, got %v", resp[0]["id"]) + } + if resp[0]["scope"] != "LOCAL" { + t.Errorf("expected scope LOCAL, got %v", resp[0]["scope"]) + } + if resp[1]["scope"] != "TEAM" { + t.Errorf("expected scope TEAM, got %v", resp[1]["scope"]) + } +} + +func TestMemoriesSearch_NoPlugin_503(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + handler := NewMemoriesHandler() + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}} + c.Request = httptest.NewRequest("GET", "/", nil) + + handler.Search(c) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestMemoriesSearch_ResolverError_500(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + plugin := &stubMemoryPlugin{} + resolver := &stubNamespaceResolver{err: errors.New("resolver down")} + handler := NewMemoriesHandler().withMemoryV2APIs(plugin, resolver) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}} + c.Request = httptest.NewRequest("GET", "/", nil) + + handler.Search(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestMemoriesSearch_PluginError_502(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + plugin := &stubMemoryPlugin{ + searchFn: func(_ context.Context, _ contract.SearchRequest) (*contract.SearchResponse, error) { + return nil, errors.New("plugin timeout") + }, + } + resolver := &stubNamespaceResolver{ + readable: []namespace.Namespace{{Name: "workspace:ws-1", Kind: contract.NamespaceKindWorkspace}}, + } + handler := NewMemoriesHandler().withMemoryV2APIs(plugin, resolver) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}} + c.Request = httptest.NewRequest("GET", "/", nil) + + handler.Search(c) + + if w.Code != http.StatusBadGateway { + t.Errorf("expected 502, got %d: %s", w.Code, w.Body.String()) + } +} + // ---------- MemoriesHandler: Delete ---------- // ---------- nextArg helper ---------- diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index ff6874fba..19424d67b 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -282,11 +282,15 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi // POST /memories stays — it routes through the v2 plugin per // #1794 and is the high-volume write surface (workspace // runtimes posting conversation snapshots etc.). + // GET /memories restored as a v2 shim (issue #1828) so legacy + // SDK callers (AwarenessClient, runtime agents) don't 404 into + // the canvas frontend. memsh := handlers.NewMemoriesHandler() if memBundle != nil { memsh.WithMemoryV2(memBundle.Plugin, memBundle.Resolver) } wsAuth.POST("/memories", memsh.Commit) + wsAuth.GET("/memories", memsh.Search) // Memory v2 — canvas reads through the plugin so the Memory // tab surfaces post-cutover state (memory_records) instead -- 2.52.0