Commit Graph

10 Commits

Author SHA1 Message Date
Hongming Wang
b5435b4732 fix(memory v2): warn at boot when cutover env half-configured
MEMORY_V2_CUTOVER=true gates the admin export/import path on the v2
plugin, but the cutoverActive() check in admin_memories.go silently
returns false when the plugin isn't wired:

  func (h *AdminMemoriesHandler) cutoverActive() bool {
      if os.Getenv(envMemoryV2Cutover) != "true" {
          return false
      }
      return h.plugin != nil && h.resolver != nil
  }

Two operator misconfigs hit the silent-fallback path:

  1. MEMORY_V2_CUTOVER=true set, MEMORY_PLUGIN_URL unset
     → wiring.Build returns nil → handler stays on legacy SQL path
     → operator sees no error, assumes cutover is live, but every
        request still writes the legacy table.

  2. MEMORY_V2_CUTOVER=true set, MEMORY_PLUGIN_URL set, but plugin
     unreachable at boot
     → wiring.Build still returns the bundle (intentional — circuit
        breaker handles ongoing unavailability), but every cutover
        write quietly falls back via the breaker.
     → only signal: legacy table keeps growing.

Both are exactly the "structurally invisible until prod" failure
mode; the only real-world detection today is "notice the legacy
table is still being written to," which no operator will check.

Add loud, distinctive WARN log lines at Build() time for both
shapes. Boot logs are operator-visible, so a half-config is
immediately obvious without needing dashboards.

Tests:
  * 4 new (cutover+no-URL → warn, neither set → silent, cutover+probe-
    fail → loud warn, probe-fail-without-cutover → quiet generic)
  * 6 existing (still pass; pin no-warning-on-happy-path)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:24:11 -07:00
Hongming Wang
707e4d7342 Memory v2 wiring: replace decorative tests with real integration
Self-review of #2755 found two tests that didn't actually exercise the
production code path:

- TestNamespaceCleanupFn_NamespaceFormat asserted
  "workspace:" + "abc-123" == "workspace:abc-123" — a compile-time
  invariant, not runtime behavior. Provided no protection if the closure
  in Bundle.NamespaceCleanupFn ever stopped using that prefix.

- TestNamespaceCleanupFn_FailureLogsButReturns built a *parallel*
  cleanup closure inline with errors.New, then invoked the parallel
  closure. The production closure was never exercised. A regression
  in NamespaceCleanupFn (e.g. forgetting the deferred recover, calling
  the plugin without nil-check) would still pass this test.

Replaced both with real integration:

- TestNamespaceCleanupFn_HitsPluginAtCorrectNamespace spins up
  httptest.Server, points MEMORY_PLUGIN_URL at it, calls Build(),
  invokes the production closure, and asserts the server actually
  saw DELETE /v1/namespaces/workspace:abc-123.

- TestNamespaceCleanupFn_PluginErrorDoesNotPanic exercises the
  failure path for real: server returns 500 on DELETE, closure must
  log and return without propagating. defer-recover is belt-and-
  suspenders since production calls this from a for-loop in
  workspace_crud.go that has no recover.

Couldn't ship with #2755 because the merge queue locks the branch
once enqueued. Following up now that #2755 is merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:38:59 -07:00
Hongming Wang
46731729d4 Memory v2 fixup Critical: wire plugin from main.go (was fully dormant)
Caught during continued review: the entire v2 plugin system shipped
in PRs #2729-#2742 + #2744-#2751 was never actually invoked because
main.go and router.go don't construct the plugin client/resolver or
attach the WithMemoryV2 / WithNamespaceCleanup hooks.

Operators setting MEMORY_PLUGIN_URL=... saw zero behavior change
because nothing read it. Every fixup we shipped (idempotency, verify
mode, expires_at validation, audit JSON, namespace cleanup, O(N)
export, boot E2E) was also dormant for the same reason.

Root cause: when a multi-handler feature lands across many PRs, none
of them are individually responsible for wiring main.go — and the
master-task-tracking issue didn't gate-check that the wiring landed.
Add main.go integration to every multi-handler RFC checklist.

What ships:

  * internal/memory/wiring/wiring.go: new package that constructs the
    plugin client + resolver from MEMORY_PLUGIN_URL once. Returns nil
    when unset (preserves zero-config legacy behavior). Probes
    /v1/health at boot but doesn't fail-closed — the MCP layer's
    circuit breaker handles ongoing unavailability.

  * internal/memory/wiring/wiring_test.go: 6 tests covering the
    nil/non-nil bundle paths + the namespace-cleanup closure
    contract (nil-safe, format-stable, failure-tolerant).

  * cmd/server/main.go: imports memwiring, calls Build(db.DB) once
    after WorkspaceHandler creation, attaches WithNamespaceCleanup,
    threads the bundle through router.Setup.

  * internal/router/router.go: Setup signature gains *memwiring.Bundle
    param. Inside, attaches WithMemoryV2 to AdminMemoriesHandler and
    MCPHandler when the bundle is non-nil.

After this, the v2 plugin is reachable end-to-end:

  Operator sets MEMORY_PLUGIN_URL → main.Build instantiates client +
  resolver → WorkspaceHandler gets cleanup hook → router wires
  AdminMemoriesHandler + MCPHandler with WithMemoryV2 → MCP tool
  calls (commit_memory_v2, search_memory, etc.) actually do
  something → admin export/import respects MEMORY_V2_CUTOVER.

Prerequisite for #292 (staging verification) — without this, the
operator runbook's step 2 (set MEMORY_PLUGIN_URL, observe behavior)
silently no-ops.

Verified: all 9 affected test packages still green
(memory/{client,contract,e2e,namespace,pgplugin,wiring}, handlers,
router, plus the build).
2026-05-04 10:22:30 -07:00
Hongming Wang
1e97fb9a16 Memory v2 fixup C1: backfill idempotency via MemoryWrite.id
Self-review (post-merge) flagged that the backfill claimed to be
idempotent on re-run but actually duplicates every row because the
plugin's INSERT uses gen_random_uuid() and ignores any id passed in.

Fix is contract-level: extend MemoryWrite with an optional `id`
idempotency key. When supplied, the plugin MUST treat the write as
upsert keyed on this id; when omitted, the plugin generates a fresh
UUID (production agent commits keep working unchanged).

Changes:
  * docs/api-protocol/memory-plugin-v1.yaml: add id field with
    description that flags it as idempotency key
  * internal/memory/contract/contract.go: add ID to MemoryWrite struct,
    update memory_write_minimal golden vector
  * internal/memory/pgplugin/store.go: split CommitMemory into two
    paths — upsert when body.ID set (INSERT ... ON CONFLICT (id) DO
    UPDATE), plain INSERT otherwise
  * cmd/memory-backfill/main.go: pass agent_memories.id to MemoryWrite,
    fix the false comment about 409 deduplication

New tests:
  * pgplugin: TestCommitMemory_WithIDUpserts pins the upsert SQL is
    used when id is set; TestCommitMemory_UpsertScanError covers the
    error branch
  * backfill: TestBackfill_PassesSourceUUIDAsIdempotencyKey pins the
    forwarding behavior; TestBackfill_RerunIsIdempotent simulates a
    retry and asserts both runs pass the same uuid (plugin upsert is
    what makes this safe)

Why this matters: operators retrying a failed backfill (which they
will — networks fail, transactions abort) would otherwise create N
duplicates per memory. The duplicates aren't visible until search
results show obvious dupes — debugging that under prod load is bad.

Production agent commits are unaffected: they leave id empty, the
plugin generates a fresh UUID via gen_random_uuid(), zero behavior
change for the hot path.
2026-05-04 08:54:13 -07:00
Hongming Wang
b937415e1e Memory v2 PR-11: E2E test — flat-plugin swap proves contract works
Final implementation PR. Builds on PR-1..10 (all merged or queued).

Proves the central design property of the plugin contract: ANY
plugin satisfying the v1 OpenAPI spec works as a drop-in replacement
for the built-in postgres plugin. If this test fails after a refactor,
the contract has drifted in a way that breaks ecosystem plugins.

What ships:
  * internal/memory/e2e/swap_test.go — five E2E tests against a
    deliberately minimal "flat-memory" stub plugin (~50 LOC, single
    map, zero capabilities)
  * MCPHandler.Dispatch — small exported wrapper around dispatch so
    out-of-package E2E tests can drive tools by name without
    duplicating the whole MCP RPC stack

E2E coverage:
  * TestE2E_FlatPluginRoundTrip: full lifecycle
    - list_writable_namespaces returns 3 entries
    - commit_memory_v2 writes through plugin
    - search_memory finds it back
    - commit_summary writes a summary
    - forget_memory deletes
    - search after forget excludes the deleted memory

  * TestE2E_LegacyShimRoutesThroughFlatPlugin: PR-6 shim wired up
    - Legacy commit_memory(scope=LOCAL) ends up in plugin storage
    - Legacy recall_memory finds it back through plugin search
    - Response shapes preserved (scope:LOCAL stays scope:LOCAL)

  * TestE2E_OrgMemoriesDelimiterWrap: prompt-injection mitigation
    - Org-namespace memory committed
    - Audit INSERT into activity_logs verified
    - Search returns content with [MEMORY id=... scope=ORG ns=...]
      prefix applied

  * TestE2E_StubPluginCapabilitiesAreEmpty: capability negotiation
    - Stub plugin reports zero capabilities
    - Client.SupportsCapability returns false for FTS, embedding
    - Confirms graceful degradation when plugin doesn't support a
      feature

  * TestE2E_PluginUnreachable_AgentSeesClearError: failure surface
    - Plugin URL pointing at bogus port
    - commit_memory_v2 returns informative error
    - No nil-pointer dereference; error message is actionable

The flat plugin is intentionally minimal — it has no namespaces table
distinct from memory records, no FTS, no semantic search, no TTL. The
test proves operators can drop in a 50-line plugin and the agent
behavior is identical (modulo capability-gated features).
2026-05-04 08:20:35 -07:00
Hongming Wang
f2397bf138
Merge pull request #2733 from Molecule-AI/feat/memory-v2-pr3-postgres-plugin
Memory v2 PR-3: built-in postgres plugin server + schema migrations
2026-05-04 14:37:24 +00:00
Hongming Wang
ff5f4cbf7c Memory v2 PR-3: built-in postgres plugin server + schema migrations
Builds on merged PR-1 (#2729), independent of PR-2/PR-4.

Implements every endpoint of the v1 plugin contract behind an HTTP
server (cmd/memory-plugin-postgres/) backed by postgres. Operators
run this binary next to workspace-server; it's the default
implementation MEMORY_PLUGIN_URL points at.

What ships:
  - cmd/memory-plugin-postgres/main.go: boot, signal-driven shutdown,
    boot-time migrations, configurable LISTEN/DATABASE/MIGRATION_DIR
  - cmd/memory-plugin-postgres/migrations/001_memory_v2.up.sql:
      memory_namespaces (PK on name, kind CHECK, expires_at, metadata)
      memory_records (FK to namespaces with CASCADE, kind+source CHECK,
                      pgvector embedding, FTS tsvector, ivfflat partial
                      index on embedding, partial index on expires_at)
  - internal/memory/pgplugin/store.go: storage layer using lib/pq
  - internal/memory/pgplugin/handlers.go: HTTP layer (no router dep —
    a switch on URL.Path keeps the binary's dep surface tiny)
  - 100% statement coverage on store.go + handlers.go

Schema notes:
  - These tables live next to the plugin binary, NOT in workspace-
    server/migrations/. When operators swap the plugin, these tables
    become orphaned (operator drops manually). Documented in PR-10.
  - Search supports semantic (pgvector cosine) → FTS (>=2 char query)
    → ILIKE (1-char query) → recent-listing (no query), with a TTL
    filter applied uniformly across all paths.
  - DELETE on namespace cascades to memory_records (FK ON DELETE
    CASCADE) — a deleted namespace immediately frees its memories.

Coverage corner cases pinned:
  - Health: ok, degraded (db ping fails), no-ping fn
  - Every CRUD endpoint: happy path, bad name, bad JSON, bad body,
    not-found, store errors, exec/scan/marshal errors
  - Search: FTS, semantic, short-query (ILIKE), no-query (recent),
    kinds filter, store errors, scan errors, mid-iteration row error
  - Routing edge cases: unknown path, empty namespace, unknown sub,
    method-not-allowed, GET on /v1/health (allowed), POST on /v1/health
    (404), GET on /v1/search (404)
  - Helper internals: marshalMetadata (nil/happy/unmarshalable),
    nullTime (nil/non-nil), vectorString (empty/format),
    nullVectorString (empty/non-empty), scanNamespace +
    scanMemory metadata-decode errors

No callers in workspace-server yet; integration starts in PR-5
(MCP handlers wire the plugin client through to MCP tools).
2026-05-04 07:31:56 -07:00
Hongming Wang
01b653d6b0 Memory v2 PR-4: namespace resolver + tests
Stacked on PR-1 (#2729). Computes the readable/writable namespace lists
for a workspace from the live workspaces tree at request time. No
precomputed columns, no migrations — re-parenting on canvas takes
effect immediately on the next memory call.

What ships:
  - workspace-server/internal/memory/namespace/resolver.go
    - walkChain: recursive CTE, walks parent_id chain to root, capped
      at depth 50 to defend against malformed/cyclic data
    - derive: maps a chain to (workspace, team, org) namespace strings
    - ReadableNamespaces / WritableNamespaces: the public API
    - CanWrite + IntersectReadable: server-side ACL helpers MCP
      handlers (PR-5) will call before talking to the plugin
  - resolver_test.go: 100% statement coverage

Design choices worth flagging:
  - Today's tree is depth-1 (root + children). The recursive CTE
    handles arbitrary depth so we don't have to revisit the resolver
    when the tree deepens.
  - GLOBAL→org write restriction (memories.go:167-174) is preserved
    by gating the org namespace's Writable flag on parent_id IS NULL.
  - Removed-status workspaces are NOT filtered from the chain walk —
    matches today's TEAM behavior (memories.go:367-372 filters on
    read, not on tree walk).
  - IntersectReadable with empty `requested` returns ALL readable
    namespaces (default-search-everything semantic from the discovery
    tools spec).

This package has zero callers in this PR; integration starts in PR-5.
2026-05-04 07:25:33 -07:00
Hongming Wang
c1cff3169f Memory v2 PR-2: HTTP plugin client + breaker + capability negotiation
Builds on PR-1 (#2729). Implements every endpoint in the OpenAPI spec
plus two operational concerns the agent never sees:

  1. Capability negotiation. Boot/Refresh probes /v1/health and
     captures the plugin's capability list. MCP handlers (PR-5) ask
     SupportsCapability before exposing capability-gated features —
     e.g., agents can only request semantic search when "embedding"
     is reported.

  2. Circuit breaker. Three consecutive failures open the breaker for
     60 seconds; while open, calls fail fast with ErrBreakerOpen.
     Picked these constants because:
       - 3 failures: long enough to skip transient blips, short enough
         to react before all in-flight handlers stack on the timeout
       - 60s cooldown: long enough to back off a flapping plugin,
         short enough that recovery is felt within a single session
     4xx responses do NOT count toward the breaker (those are client
     bugs, not plugin health issues); 5xx + transport errors do.

What ships:
  - workspace-server/internal/memory/client/client.go
  - client_test.go: 100% statement coverage

Coverage corner cases pinned:
  - env-var success branches in New (parseDurationEnv applied)
  - json.Marshal error (via channel in Propagation)
  - http.NewRequestWithContext error (via unbalanced bracket in BaseURL)
  - 204 NoContent on endpoint that normally has a body
  - 4xx vs 5xx breaker behavior (4xx must NOT trip)
  - breaker cooldown elapsed → reset on next success
  - all 6 public endpoints fail-fast when breaker is open

This package has no callers in this PR; integration starts in PR-5.
2026-05-04 06:57:24 -07:00
Hongming Wang
53d823e719 Memory v2 PR-1: OpenAPI plugin contract + Go bindings
First of 11 PRs implementing the memory-system plugin refactor (RFC #2728).
This PR is pure additive scaffolding — no behavior change, no integration
yet. It defines the wire shape between workspace-server and a memory
plugin so PR-2 (HTTP client) and PR-3 (built-in postgres plugin) can be
built against a single source of truth.

What ships:
  - docs/api-protocol/memory-plugin-v1.yaml: OpenAPI 3.0.3 spec covering
    /v1/health, namespace upsert/patch/delete, memory commit, search,
    forget. Auth-free (private network only); workspace-server is the
    only sanctioned client and the security perimeter.
  - workspace-server/internal/memory/contract: typed Go bindings with
    Validate() methods on every wire object so both client (PR-2) and
    server (PR-3) self-check at the boundary.
  - Round-trip JSON tests for every type (catch asymmetric tag bugs).
  - 5 golden vector files under testdata/ pinning the exact wire shape;
    update via UPDATE_GOLDENS=1.

Coverage: 100% of statements in contract.go.

The validation rules encode design decisions worth flagging in review:
  - SearchRequest with empty Namespaces is REJECTED at plugin level —
    workspace-server is required to intersect the readable set
    server-side; an empty list reaching the plugin is a bug.
  - NamespacePatch with no fields is REJECTED — empty patches are
    pointless round-trips.
  - MemoryWrite with whitespace-only Content is REJECTED — zero-info
    memories pollute search results.

No code yet calls into this package; integration starts in PR-2.
2026-05-04 06:45:52 -07:00