fix(handlers): restore GET /workspaces/:id/memories as v2 plugin shim (#1828) #1852

Merged
agent-dev-a merged 1 commits from fix/memory-legacy-search-shim into main 2026-05-26 08:46:23 +00:00
3 changed files with 171 additions and 0 deletions
@@ -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)
}
@@ -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 ----------
@@ -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