fix(memories): upsert namespace before HTTP commit — fleet-wide memory-write outage #2517
@@ -107,7 +107,6 @@ func (h *MemoriesHandler) withMemoryV2APIs(plugin memoryPluginAPI, resolver name
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
// Commit handles POST /workspaces/:id/memories
|
||||
// Stores a memory fact with a scope (LOCAL, TEAM, GLOBAL) and an optional
|
||||
// namespace (defaults to "general"). Namespaces implement the Holaboss
|
||||
@@ -213,6 +212,26 @@ func (h *MemoriesHandler) Commit(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the namespace row exists before the write — the plugin's
|
||||
// CommitMemory contract is "namespace must already exist (auto-created
|
||||
// by handler if not)" and memory_records carries an FK to
|
||||
// memory_namespaces. The MCP tool path (mcp_tools_memory_v2.go) has
|
||||
// always done this upsert; this HTTP path skipped it, so any workspace
|
||||
// whose namespace row was never seeded (every workspace created after
|
||||
// the Phase A2 backfill that only ever wrote through this surface —
|
||||
// i.e. the runtime a2a commit_memory tool and the canvas) failed every
|
||||
// write with `memory_records_namespace_fkey` (fleet-wide, 2026-06-10).
|
||||
// UpsertNamespace is idempotent, so the extra round-trip on warm
|
||||
// namespaces is a cheap no-op.
|
||||
if _, err := h.memv2.plugin.UpsertNamespace(ctx, nsName, contract.NamespaceUpsert{Kind: kindFromNamespace(nsName)}); err != nil {
|
||||
log.Printf(
|
||||
"Commit memory namespace upsert error: workspace=%s scope=%s namespace=%s err_class=%T err=%q",
|
||||
workspaceID, body.Scope, nsName, err, err,
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store memory"})
|
||||
return
|
||||
}
|
||||
|
||||
// Plugin write. The plugin owns its own embedding generation (FTS
|
||||
// + vector indices are internal to memory_plugin schema), so we no
|
||||
// longer call h.embed here — that becomes dead weight on this path
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/namespace"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -97,6 +97,103 @@ func TestMemoriesCommit_Local_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoriesCommit_UpsertsNamespaceBeforeWrite pins the fleet-wide
|
||||
// 2026-06-10 regression: the HTTP Commit path went straight to
|
||||
// plugin.CommitMemory without ensuring the namespace row exists, so any
|
||||
// workspace whose memory_namespaces row was never seeded (everything
|
||||
// created after the Phase A2 backfill that only wrote through this
|
||||
// surface — the runtime a2a commit_memory tool and the canvas) failed
|
||||
// every write with memory_records_namespace_fkey. The MCP tool path has
|
||||
// always upserted first; this asserts the HTTP path does too, and in
|
||||
// the right order.
|
||||
//
|
||||
// MUTATION: drop the UpsertNamespace call in MemoriesHandler.Commit →
|
||||
// calls slice misses "upsert" → RED (the exact production failure).
|
||||
func TestMemoriesCommit_UpsertsNamespaceBeforeWrite(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
var calls []string
|
||||
var upsertNS string
|
||||
var upsertKind contract.NamespaceKind
|
||||
plugin := &stubMemoryPlugin{
|
||||
upsertFn: func(_ context.Context, name string, body contract.NamespaceUpsert) (*contract.Namespace, error) {
|
||||
calls = append(calls, "upsert")
|
||||
upsertNS = name
|
||||
upsertKind = body.Kind
|
||||
return &contract.Namespace{Name: name, Kind: body.Kind}, nil
|
||||
},
|
||||
commitFn: func(_ context.Context, ns string, body contract.MemoryWrite) (*contract.MemoryWriteResponse, error) {
|
||||
calls = append(calls, "commit")
|
||||
return &contract.MemoryWriteResponse{ID: "mem-up-1", Namespace: ns}, nil
|
||||
},
|
||||
}
|
||||
handler := NewMemoriesHandler().withMemoryV2APIs(
|
||||
plugin,
|
||||
memCommitResolver("ws-up", contract.NamespaceKindWorkspace),
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-up"}}
|
||||
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"content":"first ever memory","scope":"LOCAL"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Commit(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(calls) != 2 || calls[0] != "upsert" || calls[1] != "commit" {
|
||||
t.Errorf("namespace must be upserted BEFORE the write, got call order %v", calls)
|
||||
}
|
||||
if upsertNS != "workspace:ws-up" {
|
||||
t.Errorf("upsert namespace = %q, want workspace:ws-up", upsertNS)
|
||||
}
|
||||
if upsertKind != contract.NamespaceKindWorkspace {
|
||||
t.Errorf("upsert kind = %q, want %q", upsertKind, contract.NamespaceKindWorkspace)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoriesCommit_UpsertError_500 — an upsert failure must surface as
|
||||
// the same stable generic 500 (no plugin internals leaked) and must NOT
|
||||
// proceed to the write.
|
||||
func TestMemoriesCommit_UpsertError_500(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
commitCalled := false
|
||||
plugin := &stubMemoryPlugin{
|
||||
upsertFn: func(_ context.Context, _ string, _ contract.NamespaceUpsert) (*contract.Namespace, error) {
|
||||
return nil, errors.New("plugin down")
|
||||
},
|
||||
commitFn: func(_ context.Context, ns string, _ contract.MemoryWrite) (*contract.MemoryWriteResponse, error) {
|
||||
commitCalled = true
|
||||
return &contract.MemoryWriteResponse{ID: "nope", Namespace: ns}, nil
|
||||
},
|
||||
}
|
||||
handler := NewMemoriesHandler().withMemoryV2APIs(
|
||||
plugin,
|
||||
memCommitResolver("ws-up2", contract.NamespaceKindWorkspace),
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-up2"}}
|
||||
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"content":"x","scope":"LOCAL"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Commit(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on upsert failure, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("failed to store memory")) {
|
||||
t.Errorf("error body must stay the stable generic message, got %s", w.Body.String())
|
||||
}
|
||||
if commitCalled {
|
||||
t.Error("CommitMemory must not run when the namespace upsert failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoriesCommit_Global_AsRoot(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -663,6 +760,7 @@ func TestCommitMemory_LocalScope_NoDelimiterEscape(t *testing.T) {
|
||||
t.Errorf("LOCAL memory content should be stored verbatim, got %q (expected %q)", cap.Body.Content, content)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- MemoriesHandler: Update (PATCH) ----------
|
||||
//
|
||||
// Pin the full Update flow: namespace-only edit, content edit (LOCAL),
|
||||
@@ -681,4 +779,4 @@ func TestCommitMemory_LocalScope_NoDelimiterEscape(t *testing.T) {
|
||||
// Caller passes content + namespace identical to existing values:
|
||||
// post-normalisation nothing changed. Return 200 with changed=false,
|
||||
// no UPDATE, no audit row. Saves a round-trip + an audit-log entry on
|
||||
// idempotent re-edits (e.g. user clicks Save without changing fields).
|
||||
// idempotent re-edits (e.g. user clicks Save without changing fields).
|
||||
|
||||
Reference in New Issue
Block a user