fix(handlers): restore GET /workspaces/:id/memories as v2 plugin shim (#1828) #1852
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user