Commit Graph

4124 Commits

Author SHA1 Message Date
Hongming Wang
9f47ecf86e
Merge branch 'staging' into fix/memory-v2-i3-export-on 2026-05-04 09:44:37 -07:00
Hongming Wang
ebc20794f3 fix(admin-memories): include each member's private namespace in export
ReadableNamespaces(rootID) returns {workspace:rootID, team:rootID,
org:rootID} — the workspace: namespace it surfaces is the root's only.
The I3 batching change resolved namespaces once per root which silently
dropped every child workspace's private memories from admin export
(workspace:childID never reached the plugin search).

Keep the per-root batching win for team:/org:/custom: namespaces;
inject each member's workspace:<id> + owner mapping explicitly so
coverage matches the legacy per-workspace iteration.

Cost stays at 1 SQL + N_roots resolver + 1 plugin search.

Test changes:
- New TestExport_IncludesEveryMembersPrivateNamespace uses a
  per-workspace resolver stub (mirrors real behaviour) and asserts
  every member's workspace:<id> reaches the plugin search AND that
  children's private memories appear in the response with correct
  owner attribution. Verified to FAIL on the pre-fix code.
- TestExport_BatchesPluginCallsByRoot updated to expect 5 namespaces
  (3 workspace + team + org) instead of 3 — it had pinned the buggy
  3-namespace behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:44:06 -07:00
Hongming Wang
281cb04163
Merge pull request #2751 from Molecule-AI/fix/memory-v2-opt2-boot-e2e
Memory v2 fixup Opt-2: real-subprocess boot E2E
2026-05-04 16:27:56 +00:00
Hongming Wang
fe7ff5440d Memory v2 fixup Opt-2: add E2E.md operator runbook
Companion to boot_e2e_test.go (just merged). Documents:
  - When the E2E suite runs (build tag + env var)
  - Local run with docker postgres
  - CI integration example (label-gated workflow step)
  - What each test pins
  - Explicit gap list (migration drift, recovery, TTL)
2026-05-04 09:24:16 -07:00
Hongming Wang
5b0a75ab73 Memory v2 fixup Optional-2: real-subprocess boot E2E
Self-review #293. PR-11's E2E test uses sqlmock + httptest —
integration, not E2E. This adds the actual real-subprocess test:
build the binary with `go build`, start it pointing at real postgres,
drive HTTP via the real client.

What in-process tests miss that this catches:
  - Binary build / boot-path panics (env var typos, mixed-key
    interface bugs that only surface when start() runs)
  - Wire encoding bugs that sqlmock smooths over (the pq.Array
    regression from PR-3 development would have been caught here)
  - HTTP+TCP-socket edge cases
  - Real upsert behavior under postgres ON CONFLICT (C1 fix)

Build-tag gated so default CI doesn't require docker:
  go test -tags memory_plugin_e2e -v ./cmd/memory-plugin-postgres/

Tests skip silently when MEMORY_PLUGIN_E2E_DB is unset.

Three tests:
  1. TestE2E_BootAndHealth — capabilities advertised correctly
  2. TestE2E_FullCommitSearchForgetRoundTrip — full agent flow
  3. TestE2E_IdempotencyKey — C1 upsert against real postgres

Plus E2E.md operator runbook with docker quickstart + CI integration
example + explicit statement of what's still uncovered (migration
drift, recovery scenarios, TTL eviction over real time).
2026-05-04 09:23:46 -07:00
Hongming Wang
a6dadc7ee0
Merge pull request #2750 from Molecule-AI/fix/memory-v2-i5-namespace-cleanup
Memory v2 fixup I5: workspace purge cleans up plugin namespace
2026-05-04 16:23:41 +00:00
Hongming Wang
5e52a0fdad
Merge pull request #2748 from Molecule-AI/docs/memory-v2-fixup-docs
Memory v2 docs update: idempotency key + verify mode + cutover runbook
2026-05-04 16:21:02 +00:00
Hongming Wang
6b445aae2d Memory v2 fixup I5: workspace purge cleans up plugin namespace
Self-review #291. When a workspace is hard-purged, its
`workspace:<id>` namespace stays in the plugin storage. Over time
deleted workspaces accumulate as orphan namespaces.

Fix: optional namespaceCleanupFn hook on WorkspaceHandler. The
purge path (workspace_crud.go ~line 520) iterates each purged id
and calls the hook best-effort. main.go wires the hook to
plugin.DeleteNamespace when MEMORY_PLUGIN_URL is set; operators
who haven't enabled the plugin keep the no-op default.

Why a hook (not direct plugin import):
  * Keeps WorkspaceHandler decoupled from the memory contract
    package (easier to test, smaller blast radius if the contract
    bumps)
  * Tests inject a captureCleanupHook stub without standing up a
    real plugin client
  * Production wiring stays a one-liner in main.go

What gets cleaned up:
  * `workspace:<id>` for each purged workspace
  * NOT `team:<root>` / `org:<root>` — those may still be
    referenced by other workspaces under the same root, so dropping
    them on a single workspace's purge would orphan team/org data
    for the survivors. Operator can purge those manually after
    confirming the entire root is gone.

What stays untouched:
  * Soft-removed workspaces (status='removed', no ?purge=true). The
    grace window is by design — the data should still be there if
    the operator unremoves.

Tests:
  * TestWithNamespaceCleanup_DefaultIsNil pins the safe default
  * TestWithNamespaceCleanup_NilStaysNil pins the explicit-nil case
  * TestWithNamespaceCleanup_AttachesFn pins the wiring
  * TestPurge_CallsCleanupHookPerID exercises the per-id loop body
  * TestPurge_NilHookIsSkipped pins the nil guard

A full end-to-end Delete-handler test requires mocking broadcaster
+ provisioner + descendant SQL chain, which is out-of-scope for a
single fixup. Integration coverage for the wired path lives in
PR-11's E2E swap test (#293 follow-up).
2026-05-04 09:20:37 -07:00
Hongming Wang
4f3d51bd61
Merge branch 'staging' into docs/memory-v2-fixup-docs 2026-05-04 09:18:49 -07:00
Hongming Wang
9a64aeaa2c Memory v2 fixup I3: admin export O(workspaces) → O(N_roots+1)
Self-review #289. The previous exportViaPlugin ran one resolver CTE
walk + one plugin search PER WORKSPACE. For a 1000-workspace tenant
that's 1000× of each, mostly redundant — workspaces sharing a
team/org root see identical readable namespaces.

New strategy:
  1. Single SQL pass returns each workspace + its computed root_id
     via a recursive CTE (loadWorkspacesWithRoots).
  2. Group by root → unique tree count is typically << workspace
     count.
  3. Resolver runs ONCE per root (any member sees the same readable
     list).
  4. Build the union of all root namespaces; single plugin.Search
     call.
  5. Map each memory back to a workspace_name via pickOwnerForNamespace
     (workspace:<id> → matching member; team:* / org:* / custom:* →
     canonical first member of root group).

Net call cost: 1 SQL + N_roots resolver + 1 plugin call (vs
N_workspaces × resolver + N_workspaces × plugin in the old code).

Tests:
  * TestExport_BatchesPluginCallsByRoot pins the new behavior
    explicitly: 3 workspaces under 1 root → exactly 1 plugin search
    (was 3 with the old code).
  * TestPickOwnerForNamespace covers all five attribution cases:
    workspace:<id> match, workspace:<id> no-match-fallback, team:*,
    org:*, custom:* → first-member-of-root-group; plus empty-members
    fallback.
  * All 9 existing TestExport_* / TestImport_* / TestPickOwner /
    TestNamespaceKindFromLegacyScope / TestSkipImport / etc. tests
    remain green (verified with -run "Export").

The legacy DB path (when MEMORY_V2_CUTOVER unset) is unchanged.
2026-05-04 09:17:30 -07:00
Hongming Wang
2d783b5ca6 Memory v2 docs update: idempotency key + verify mode + cutover runbook
Updates plugin-author and operator docs to reflect the four fixup
PRs (C1, C2, I1, I4) for self-review findings.

Stacked on C1+C2 so the docs reference behavior that lands in the
same wave; rebases to staging once those merge.

What changes:

  * docs/memory-plugins/README.md
    - New "Memory idempotency" section explaining MemoryWrite.id
      contract: omit → plugin generates UUID; supplied → upsert
    - "Replacing the built-in plugin" rewritten as a 6-step
      operator runbook with concrete commands for -dry-run / -apply
      / -verify / MEMORY_V2_CUTOVER, including the failure path
      ("if -verify reports mismatches, do not flip the cutover flag")
    - Added link to new CHANGELOG.md

  * docs/memory-plugins/testing-your-plugin.md
    - New TestMyPlugin_IDIsIdempotencyKey example: write same id
      twice, assert single row + updated content
    - "What the harness does NOT cover" expanded with two new
      operational gates: backfill twice → no double; verify-mode
      reports zero mismatches

  * docs/memory-plugins/pinecone-example/README.md
    - Wire-mapping table updated: id (caller-supplied) → Pinecone
      vector id (upsert); id (omitted) → plugin-generated UUID
    - Production-hardening checklist gained an idempotency-key item

  * docs/memory-plugins/CHANGELOG.md (new)
    - Captures the four fixup PRs in one place with severity-ordered
      summary, plugin-author action items, and remaining open
      follow-ups (#289, #291, #293) for transparency

No code changes. Docs-only PR.
2026-05-04 09:08:28 -07:00
Hongming Wang
6fc328ef44
Merge pull request #2747 from Molecule-AI/fix/memory-v2-c2-backfill-verify
Memory v2 fixup C2: backfill -verify mode (parity check)
2026-05-04 16:08:27 +00:00
Hongming Wang
bb3212ad37
Merge branch 'staging' into fix/memory-v2-c2-backfill-verify 2026-05-04 09:08:21 -07:00
Hongming Wang
1986260603 Merge remote-tracking branch 'origin/fix/memory-v2-c1-backfill-idempotent' into docs/memory-v2-fixup-docs 2026-05-04 09:05:11 -07:00
Hongming Wang
d297e75fc9
Merge pull request #2746 from Molecule-AI/fix/memory-v2-i1-i4-small
Memory v2 fixup I1+I4: expires_at validation + audit JSON marshal
2026-05-04 16:05:02 +00:00
Hongming Wang
3ae0513209
Merge pull request #2744 from Molecule-AI/fix/memory-v2-c1-backfill-idempotent
Memory v2 fixup C1: backfill idempotency via MemoryWrite.id
2026-05-04 16:04:54 +00:00
Hongming Wang
4b6373861c Memory v2 fixup C2: backfill -verify mode (parity check)
Self-review missed deliverable from PR-7's task spec. Operators had
no way to confirm a -apply produced equivalent search results to the
legacy agent_memories direct queries; this PR ships that.

Usage:
  memory-backfill -verify                      # 50-workspace random sample
  memory-backfill -verify -verify-sample=200   # bigger sample
  memory-backfill -verify -workspace=<uuid>    # one specific workspace

Algorithm:
  1. Pick N random workspaces (or use -workspace if specified)
  2. For each: query agent_memories direct, query plugin search via
     the workspace's readable namespace list
  3. Multiset-compare contents: every legacy row must have a matching
     plugin row. Plugin having MORE rows is OK (team-shared content
     may be visible from sibling workspaces).
  4. Print mismatches with content excerpt; non-zero mismatches/errors
     yields a non-zero exit so CI can gate cutover.

Sql:
  - Sampling uses ORDER BY random() LIMIT N (TABLESAMPLE has surprising
    distribution at small populations).
  - Filters out status='removed' workspaces.

Test coverage:
  * pickWorkspaceSample: single-ws short-circuit, random sampling,
    query error, scan error
  * queryLegacyMemories: happy path, error path
  * verifyParity:
      - all match → 1 match, 0 mismatch
      - missing-from-plugin → 1 mismatch with content excerpt
      - plugin-extra rows → 1 match (legacy is subset of plugin)
      - legacy query error → 1 error counter
      - resolver error → 1 error counter
      - plugin search error → 1 error counter
      - no readable namespaces + empty legacy → match
      - no readable namespaces + non-empty legacy → mismatch
      - pickSample error → propagated up
  * CLI: -verify+-apply rejected as mutually exclusive; -verify alone
    is a valid mode

Note: namespaceResolverAdapter bridges *namespace.Resolver to the
verify package's verifyResolver interface so verify.go has zero
dependency on the namespace package — keeps test stubs minimal.
2026-05-04 09:01:31 -07:00
Hongming Wang
3886e8fb9f
Merge pull request #2745 from Molecule-AI/fix/harness-stub-auth-headers-1arg
fix(harness): stub platform_auth with *args lambdas (#2743 fallout)
2026-05-04 15:58:24 +00:00
Hongming Wang
d48693144b Memory v2 fixup I1+I4: expires_at validation + audit JSON marshal
Two small Important findings from self-review, bundled because both
are <20 line changes touching the same file.

I1: expires_at silent drop
  - mcp_tools_memory_v2.go:130 had `if t, err := ...; err == nil { ... }`
    which dropped malformed timestamps without telling the agent.
    Agent passes `expires_at: "tomorrow"`, gets a 200, and the memory
    has no TTL.
  - Now returns a clear error: "invalid expires_at: must be RFC3339"
  - Test renamed: TestCommitMemoryV2_BadExpiresIsIgnored (which
    codified the bug) → TestCommitMemoryV2_BadExpiresReturnsError
    (which pins the fix).

I4: audit log JSON via Sprintf-%q
  - auditOrgWrite was building activity_logs.metadata via fmt.Sprintf
    with %q. Go-quoted strings happen to coincide with JSON-quoted
    for ASCII (and today's values are pure ASCII: UUID + hex digest)
    so the bug was latent.
  - Replaced with json.Marshal of map[string]string. Same wire shape
    today, but won't silently produce invalid JSON if metadata grows
    to include arbitrary content snippets.
  - New test TestAuditOrgWrite_MetadataIsValidJSON uses a custom
    sqlmock.Argument matcher (jsonValidMatcher) that fails the test
    if the metadata column isn't parseable JSON. The test runs
    auditOrgWrite with a content string containing quotes,
    backslashes, and a control byte — values where %q would diverge
    from JSON-quote.

Both pre-existing tests (TestCommitMemoryV2_AuditsOrgWrites etc.)
remain green.
2026-05-04 08:57:58 -07:00
Hongming Wang
1b207b214d fix(harness): stub platform_auth with *args lambdas (#2743 fallout)
PR #2743 (multi-workspace MCP PR-2) made auth_headers accept an
optional ``workspace_id`` arg and self_source_headers stayed
1-arg-required. The peer-discovery-404 harness replay stubbed both
with 0-arg lambdas, so the helper call inside the replay raised:

    TypeError: <lambda>() takes 0 positional arguments but 1 was given

…and the diagnostic captured by the replay was the TypeError text,
not the platform-404 string the assertion grep'd for. Caught by
PR-2737 (auto-promote staging→main) — the replay went red right
after #2743 merged into staging.

Switching both stubs to ``*args, **kwargs`` makes them tolerant of
both the legacy 0-arg call shape AND the new 1-arg-with-workspace
call shape, so neither the harness nor the in-tree unit tests need
to know which version of the runtime helpers ran the call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:55:42 -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
7cffff844b
Merge pull request #2743 from Molecule-AI/feat/mcp-multi-workspace-pr2
feat(mcp): cross-workspace delegation routing (multi-ws PR-2)
2026-05-04 15:43:20 +00:00
Hongming Wang
4a0d7cd545
Merge branch 'staging' into feat/mcp-multi-workspace-pr2 2026-05-04 08:37:20 -07:00
Hongming Wang
35b3ea598a test: fix WORKSPACE_ID assert to match module attr (CI portability)
CI's pytest harness pre-sets WORKSPACE_ID=test in the env before
test collection, so a2a_client's module-level WORKSPACE_ID
(captured at import time, line 24) holds "test" — but the local
fixture's monkeypatch.setenv("WORKSPACE_ID", ...) only affects the
ENV value seen on later os.environ reads, NOT the already-bound
module attribute.

Assert against a2a_client.WORKSPACE_ID directly so the test is
portable across local + CI runs without monkey-patching the module
itself (which a future test reload might undo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:35:48 -07:00
Hongming Wang
1161b97faf feat(mcp): cross-workspace delegation routing (multi-ws PR-2)
PR-2 of the multi-workspace external-agent stack. PR-1 (#2739)
landed per-workspace auth + heartbeat + inbox. This PR threads
``source_workspace_id`` through the A2A client + tool surface so an
agent registered against multiple workspaces can list peers across
all of them and delegate from a specific source.

Changes
-------

* ``a2a_client``: ``discover_peer``, ``send_a2a_message``,
  ``get_peers_with_diagnostic``, and ``enrich_peer_metadata`` now
  accept ``source_workspace_id``. Routing uses it for both the
  X-Workspace-ID header and (transitively, via ``auth_headers(src)``)
  the bearer token. Defaults to module-level WORKSPACE_ID for
  back-compat.
* ``a2a_client._peer_to_source``: a new lock-free cache mapping each
  discovered peer back to the source workspace whose registry
  surfaced it. ``tool_list_peers`` populates the cache on every call;
  ``tool_delegate_task`` consults it for auto-routing.
* ``a2a_tools.tool_list_peers(source_workspace_id=None)``: when
  multiple workspaces are registered (MOLECULE_WORKSPACES) and no
  explicit source is passed, aggregates peers across every
  registered workspace and tags each entry with ``via: <src[:8]>``.
  Single-workspace mode is unchanged — no ``via:`` annotation, same
  output shape.
* ``a2a_tools.tool_delegate_task`` and ``tool_delegate_task_async``
  resolve source via ``source_workspace_id arg → _peer_to_source[target]
  → WORKSPACE_ID``. Agents almost never need to specify ``source_*``
  explicitly — call ``list_peers`` first and the cache handles the
  rest.
* ``tool_delegate_task_async`` idempotency key now includes the
  source workspace, so the same task delegated from two registered
  workspaces produces two distinct delegations (the right behavior
  — one per tenant audit trail).
* ``platform_auth.list_registered_workspaces()``: new helper for the
  tool layer to enumerate the multi-ws registry. Lock-free reads
  matched by the existing single-writer-per-workspace contract from
  PR-1.
* ``platform_auth.self_source_headers``: now passes ``workspace_id``
  through to ``auth_headers`` — without this, a multi-workspace POST
  source-tagged with ``X-Workspace-ID=ws_b`` was authenticating
  with ws_a's token (or no token if MOLECULE_WORKSPACE_TOKEN unset).
  Latent PR-1 bug exposed by the new tool surface.
* ``a2a_mcp_server`` tool dispatch passes ``source_workspace_id``
  from the tool call arguments.
* ``platform_tools.registry``: add ``source_workspace_id`` to the
  delegate_task, delegate_task_async, check_task_status, list_peers
  input schemas with copy explaining when to use it (rarely — the
  cache handles it).

Tests (15 new, all passing)
---------------------------

``test_a2a_multi_workspace.py``:
* TestDiscoverPeerSourceRouting (3): src arg drives header+token,
  fallback to module ws when omitted, invalid target short-circuits
  before any HTTP attempt.
* TestSendA2AMessageSourceRouting (1): X-Workspace-ID source header
  + Authorization bearer both come from the source arg via the
  patched self_source_headers chain.
* TestGetPeersSourceRouting (1): URL path AND headers use the
  source workspace id.
* TestToolListPeersAggregation (4): aggregates across multiple
  registered workspaces, tags origin, leaves single-workspace path
  unchanged, explicit src arg overrides aggregation, diagnostic
  joining when every workspace returns empty.
* TestToolDelegateTaskAutoRouting (3): cache-driven auto-route,
  explicit override beats cache, single-workspace fallback to
  module WORKSPACE_ID.
* TestListRegisteredWorkspaces (3): registry enumeration helper.

Plus ``tests/snapshots/a2a_instructions_mcp.txt`` regenerated to
absorb the new ``source_workspace_id`` schema entries.

Back-compat
-----------

Every change defaults ``source_workspace_id=None``; legacy
single-workspace operators (no MOLECULE_WORKSPACES) see identical
behavior — same URLs, same headers, same tool output. The 24
PR-1 tests + 125 existing A2A tests all still pass.

Out of scope (PR-3)
-------------------

Memory namespacing per registered workspace lands after the new
memory system v2 PR (#2740) settles in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:32:24 -07:00
Hongming Wang
059962a0a3
Merge pull request #2742 from Molecule-AI/feat/memory-v2-pr11-e2e-swap
Memory v2 PR-11: E2E test — flat-plugin swap proves contract works
2026-05-04 15:29:56 +00:00
Hongming Wang
b07575c710
Merge branch 'staging' into feat/memory-v2-pr11-e2e-swap 2026-05-04 08:24:26 -07:00
Hongming Wang
586fa5f84e
Merge pull request #2741 from Molecule-AI/feat/memory-v2-pr10-docs
Memory v2 PR-10: operator docs for writing a custom memory plugin
2026-05-04 15:20:35 +00: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
0f46c7eefe
Merge pull request #2739 from Molecule-AI/feat/mcp-multi-workspace-pr1
mcp: support multi-workspace external-agent registration (PR-1 of stack)
2026-05-04 15:19:03 +00:00
Hongming Wang
8aea1f008c
Merge pull request #2740 from Molecule-AI/feat/memory-v2-pr8-cutover
Memory v2 PR-8: cutover — admin export/import via plugin
2026-05-04 15:18:17 +00:00
Hongming Wang
8417bce50d Memory v2 PR-10: operator docs for writing a custom memory plugin
Builds on merged PR-1..7 (PR-8 in queue). Pure docs; no code.

What ships:
  * docs/memory-plugins/README.md — contract overview, capability
    negotiation, deployment models, replacement workflow
  * docs/memory-plugins/testing-your-plugin.md — using the contract
    test harness to validate wire compatibility, what the harness
    DOES NOT cover (capability accuracy, TTL eviction, concurrency)
  * docs/memory-plugins/pinecone-example/README.md — worked example
    of a Pinecone-backed plugin: capability mapping (only embedding,
    no FTS), wire mapping (memory → vector + metadata), production-
    hardening checklist

Documentation strategy:
  * Lead with what workspace-server takes care of (security perimeter,
    redaction, ACL, GLOBAL audit, prompt-injection wrap) so plugin
    authors don't reimplement those layers
  * Show three deployment models (same machine / separate container /
    self-managed) so operators see their topology
  * Capability table makes it explicit what each capability gates so
    a plugin that supports only one (e.g. semantic search) is still
    a useful plugin
  * Pinecone example is honest: shows the skeleton, the wire mapping,
    and explicitly calls out what's MISSING from the sketch (batch
    commits, TTL janitor, circuit breaker, metrics)
2026-05-04 08:17:03 -07:00
Hongming Wang
3195657837 fix: bot-lint nits — drop unused imports, add reason to except
Resolves three github-code-quality threads blocking PR-2739 merge:
- workspace/tests/test_mcp_cli_multi_workspace.py: remove unused
  `import os` and `from unittest.mock import patch` (left over from
  an earlier test draft that mocked at the os.environ layer).
- workspace/mcp_cli.py:523: replace bare `pass` in the
  register_workspace_token ImportError handler with a debug log line +
  one-line comment explaining the silent-degrade contract (older
  installs that don't yet ship the helper fall back to the legacy
  single-token path; single-workspace operators see no behavior
  change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:16:12 -07:00
Hongming Wang
7b0bd32957 Memory v2 PR-8: cutover — admin export/import via plugin
Builds on merged PR-1..7. Adds the operator-controlled cutover flag
that flips admin export/import from the legacy direct-DB path to the
v2 plugin path.

Activation: MEMORY_V2_CUTOVER=true AND the v2 plugin is wired via
WithMemoryV2. Both must be true to take the new path; either being
false falls through to the existing legacy SQL code unchanged.

What ships:
  * AdminMemoriesHandler gains plugin + resolver fields, wired via
    WithMemoryV2 (production) / withMemoryV2APIs (tests)
  * Export: enumerates workspaces, asks resolver for each one's
    readable namespaces, searches each via plugin, deduplicates by
    memory id, applies SAFE-T1201 redaction on emitted content
    (F1084 parity). Returns the legacy memoryExportEntry shape so
    existing tooling keeps working.
  * Import: scope→namespace translation mirrors PR-6 shim. Uses
    UpsertNamespace + CommitMemory; runs SAFE-T1201 redaction
    BEFORE the plugin sees the content (F1085 parity).
  * Helpers: legacyScopeFromNamespace + namespaceKindFromLegacyScope
    (lifted out so admin_memories doesn't depend on MCP handler
    helpers). skipImport typed error.

Operational rollout (cutover sequencing):
  1. Today: MEMORY_V2_CUTOVER unset → legacy DB path.
  2. After PR-7 backfill applied + smoke verified: operator sets
     MEMORY_V2_CUTOVER=true.
  3. From that point, admin export/import operate on plugin
     storage; legacy agent_memories table is read-only for the
     ~60-day grace window before PR-9 drops it.

Coverage on new paths:
  * cutoverActive: 100%
  * WithMemoryV2 / withMemoryV2APIs: 100%
  * importViaPlugin: 100%
  * exportViaPlugin: 97.2% (one defensive scan-error branch in the
    workspace-list loop)
  * scopeToWritableNamespaceForImport: 76.9% (resolver-error and
    no-matching-kind branches exercised end-to-end via Import)
  * legacyScopeFromNamespace + namespaceKindFromLegacyScope: 100%

Edge cases pinned:
  * Cutover flag matrix (env unset/true/false × wired/unwired)
  * Export deduplicates memories shared across team (one row per id)
  * Export tolerates per-workspace failures (resolver / plugin) and
    keeps going on the rest
  * Export returns 500 only when the top-level workspace query fails
  * Empty readable namespaces → empty export (no panic)
  * Export redacts secrets in plugin path
  * Import: unknown workspace skipped, unknown scope skipped,
    plugin upsert/commit errors counted as errors
  * Import redacts secrets BEFORE plugin sees content
  * Legacy export/import path unchanged when cutover flag unset
2026-05-04 08:15:10 -07:00
Hongming Wang
6fb9bc9bcd mcp: regenerate platform_auth signature snapshot for auth_headers(workspace_id=...)
PR-1's auth_headers added an optional workspace_id parameter for
multi-workspace token routing; the signature drift gate
(test_platform_auth_signature_matches_snapshot) caught the change as
expected. Snapshot regenerated to capture the new shape — diff is
visible in the PR for reviewers + template repos that depend on this
surface.

Behavior unchanged: auth_headers() with no arg still routes through
the legacy resolution path (back-compat exact); the workspace_id arg
is opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:11:23 -07:00
Hongming Wang
9cd2c02f14
Merge branch 'staging' into feat/mcp-multi-workspace-pr1 2026-05-04 08:07:34 -07:00
Hongming Wang
9929f73e80
Merge pull request #2738 from Molecule-AI/feat/memory-v2-pr7-backfill
Memory v2 PR-7: one-shot backfill CLI (dry-run + apply)
2026-05-04 15:07:14 +00:00
Hongming Wang
829ab66462 mcp: support multi-workspace external-agent registration (PR-1)
External MCP agents (e.g. Claude Code installed on a company PC) can
now register against MULTIPLE workspaces from a single process — the
agent participates as a peer in workspace A (company) AND workspace B
(personal) simultaneously, with one merged inbox tagged so replies
route to the correct tenant.

Use case (verbatim from operator): "I have this computer AI thats in
company's PC, he is going to be put in company's workspace, but
personally, I want to register it to my own workspace as well, so
that I can talk to it and asking him to do work."

## What changed

**Wire format** — new env var:

  MOLECULE_WORKSPACES='[
    {"id":"<company-wsid>","token":"<company-tok>"},
    {"id":"<personal-wsid>","token":"<personal-tok>"}
  ]'

When set, mcp_cli iterates the array and spawns one (register +
heartbeat + inbox poller) trio per workspace. Single-workspace mode
(WORKSPACE_ID + MOLECULE_WORKSPACE_TOKEN) is unchanged — every
existing operator's setup keeps working bit-for-bit.

**Per-workspace token registry** (platform_auth.py):
  register_workspace_token(wsid, tok) — populated by mcp_cli once
  per workspace before any thread spawns; thread-safe registration
  + lock-free reads on the hot path. auth_headers(workspace_id=...)
  routes to the per-workspace token; auth_headers() with no arg
  uses the legacy resolution path unchanged (back-compat).

**Per-workspace inbox cursors** (inbox.py):
  InboxState now supports cursor_paths={wsid: Path,...}. Each poller
  advances its own cursor — one workspace's slow poll can't stall
  another, and a 410 only resets the affected workspace's cursor.
  Single-workspace constructor (cursor_path=Path(...)) still works
  exactly as before via __post_init__ promotion to the empty-string
  key. Cursor filenames disambiguated by workspace_id[:8] when
  multi-workspace; single-workspace keeps the legacy filename so
  upgrade doesn't invalidate on-disk state.

**Arrival workspace tagging** (inbox.py):
  InboxMessage.arrival_workspace_id — tells the agent which OF ITS
  workspaces the inbound message arrived on. Set by the poller from
  the cursor key. to_dict() omits the field when empty so single-
  workspace consumers see no shape change.

**Reply routing** (a2a_tools.py + a2a_mcp_server.py + registry.py):
  send_message_to_user(workspace_id=...) — optional override that
  selects which workspace's /notify endpoint to POST to (and which
  token authenticates). Multi-workspace agents pass the inbound
  message's arrival_workspace_id; single-workspace agents omit it
  and route to the only registered workspace via the legacy URL.

## Out of scope (future PRs)

- PR-2: cross-workspace delegation auto-routing — when an agent
  receives a request from personal-ws "delegate to ops-bot" and
  ops-bot lives in company-ws, the agent should auto-pick its
  company-ws identity for the outbound delegate_task. Today the
  agent must pass via_workspace explicitly (or fall through to
  primary workspace).
- PR-3: memory namespacing — commit_memory() still writes to the
  primary workspace's memory regardless of inbound context. Will
  revisit when the new memory system (PR #2733 just landed) settles.

## Tests

  workspace/tests/test_mcp_cli_multi_workspace.py — 24 new tests:
    * MOLECULE_WORKSPACES JSON parsing (valid + 6 error shapes)
    * Token registry register / lookup / rotation / clear
    * auth_headers routing by workspace_id with legacy fallback
    * Per-workspace cursor save/load/reset isolation
    * arrival_workspace_id present-when-set, omitted-when-empty
    * default_cursor_path namespacing

  All 110 pre-existing tests in test_mcp_cli.py / test_inbox.py /
  test_platform_auth.py still pass — back-compat is mechanical.

Refs: project memory entry "External agent multi-workspace
registration", design questions answered 2026-05-04 by user
(JSON env var; explicit memory writes deferred to PR-3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:06:00 -07:00
Hongming Wang
3b3e821a60
Merge pull request #2736 from Molecule-AI/feat/memory-v2-pr6-compat-shim
Memory v2 PR-6: backward-compat shim — legacy tools route to v2
2026-05-04 15:05:14 +00:00
Hongming Wang
a08eaa6ca2
Merge pull request #2735 from Molecule-AI/auto-sync/main-51e7d946
chore: sync main → staging (auto, ff to 51e7d946)
2026-05-04 08:04:43 -07:00
Hongming Wang
c5322f318a Memory v2 PR-7: one-shot backfill CLI (dry-run + apply)
Builds on merged PR-1..6. Operator runs this once at cutover to copy
agent_memories rows into the v2 plugin's storage.

Usage:
  memory-backfill -dry-run                    # count + diff, no writes
  memory-backfill -apply                      # actually copy
  memory-backfill -apply -limit=10000         # cap rows per run
  memory-backfill -apply -workspace=<uuid>    # one workspace only

Required env: DATABASE_URL + MEMORY_PLUGIN_URL.

Translation matches the PR-6 legacy shim:
  LOCAL  → workspace:<workspace_id>
  TEAM   → team:<root_id> (resolved via the same namespace.Resolver
                           the runtime uses)
  GLOBAL → org:<root_id>

Idempotent: each row is keyed by its UUID; re-running the backfill
does not duplicate writes (plugin handles deduplication).

What ships:
  * cmd/memory-backfill/main.go: CLI entry, run() driver,
    backfill() workhorse, mapScopeToNamespace + namespaceKindFromString
    helpers
  * main_test.go: 100% on the functional logic (mapScopeToNamespace,
    namespaceKindFromString, backfill(), all CLI validation paths)

Coverage: 80.2% of statements. The 19.8% gap is main()'s body
(log.Fatalf — not unit-testable) and run()'s real-DB integration
(sql.Open + db.PingContext + new client/resolver — requires a live
postgres). Integration coverage for this path lives in PR-11
(E2E plugin-swap test).

Edge cases pinned (in functional logic):
  * Every legacy scope → namespace mapping
  * Unknown scope → skip with diagnostic, increment skipped counter
  * Resolver error → propagate, abort run
  * No-matching-kind in writable list → skip with error message
  * Plugin UpsertNamespace error → increment errors, continue
  * Plugin CommitMemory error → increment errors, continue
  * Query error → propagate, abort
  * Scan error → increment errors, continue
  * Mid-iteration row error → propagate, abort
  * Workspace filter passes through to SQL WHERE clause
  * Dry-run mode never calls plugin
  * CLI: rejects both/neither modes, missing env vars, bad flags
2026-05-04 08:04:07 -07:00
Hongming Wang
290e6dfdc3 Memory v2 PR-6: backward-compat shim — legacy tools route to v2
Builds on merged PR-1..5. Adds the bridge that lets legacy
commit_memory / recall_memory tools route through the v2 plugin path
when MEMORY_PLUGIN_URL is wired, otherwise fall through to the
existing DB-backed code unchanged.

What ships:
  * handlers/mcp_tools_memory_legacy_shim.go — translation helpers:
      scopeToWritableNamespace, scopeToReadableNamespaces,
      commitMemoryLegacyShim, recallMemoryLegacyShim,
      namespaceKindToLegacyScope
  * handlers/mcp_tools.go — toolCommitMemory + toolRecallMemory now
    delegate to the shim when memv2 is wired

Translation:
  commit:  LOCAL  → workspace:<self>
           TEAM   → team:<root>     (resolver picks at runtime)
           empty  → defaults to LOCAL (preserves legacy default)
           GLOBAL → still rejected at MCP bridge (C3 preserved)
  recall:  LOCAL  → search restricted to workspace:<self>
           TEAM   → workspace:<self> + team:<root>
           empty  → all readable (matches v2 default behavior)
           GLOBAL → blocked at MCP bridge (C3 preserved)

Response shapes are preserved exactly:
  commit: {"id":"...","scope":"LOCAL"|"TEAM"} — agents see no diff
  recall: [{"id":"...","content":"...","scope":"LOCAL"|...,"created_at":"..."}, ...]
  org-namespace memories get the same [MEMORY id=... scope=ORG ns=...]
  prefix as v2 search; legacy scope label comes back as "GLOBAL"

Operational rollout:
  * Today: MEMORY_PLUGIN_URL unset on most operators → legacy DB path
  * After PR-7 backfill: operators set MEMORY_PLUGIN_URL → all writes
    flow through plugin transparently
  * After PR-8 cutover: dual-write removed, plugin is the only path
  * After PR-9 (~60 days later): legacy tool entries dropped entirely

Coverage: 100% on every helper, 100% on recallMemoryLegacyShim,
94.7% on commitMemoryLegacyShim. The 1 uncovered line is a defensive
guard against a v2-response-parse error that's unreachable when the
v2 tool is operating correctly (it always returns valid JSON).

Edge cases pinned:
  * scope translation for every legacy value + invalid scope
  * resolver error propagation
  * plugin error propagation
  * GLOBAL still blocked
  * default-scope fallback (LOCAL)
  * empty content rejected
  * No-op when v2 unwired (legacy SQL path exercised via sqlmock)
  * org-namespace memory wrap on recall + GLOBAL scope label round-trip
  * No-results returns "No memories found." (legacy message preserved)
2026-05-04 08:01:41 -07:00
Hongming Wang
f74fff6ae4
Merge pull request #2734 from Molecule-AI/feat/memory-v2-pr5-mcp-tools
Memory v2 PR-5: 6 new MCP tools wired through the plugin
2026-05-04 14:53:45 +00:00
Hongming Wang
5bfa4b1d80 Memory v2 PR-5: 6 new MCP tools wired through the plugin
Builds on PR-1, PR-2, PR-3, PR-4 (all merged). Adds the agent-facing
v2 surface for the memory plugin contract.

What ships (all in handlers/mcp_tools_memory_v2.go, no edits to
the legacy commit_memory / recall_memory paths):

  commit_memory_v2   — write to a namespace; default workspace:self
  search_memory      — search across namespaces; default = all readable
  commit_summary     — kind=summary, 30-day default TTL, runtime-overridable
  list_writable_namespaces — discover what you can write to
  list_readable_namespaces — discover what you can read from
  forget_memory      — delete by id, only in namespaces you can write to

Workspace-server is the security perimeter — every layer the plugin
mustn't be trusted with runs here:

  * SAFE-T1201 redactSecrets BEFORE every plugin write
  * Server-side ACL re-validation: CanWrite + IntersectReadable run
    on EVERY request, never trusting client-supplied namespaces (a
    canvas re-parent between list_writable and commit would otherwise
    let a stale namespace slip through)
  * org:* writes audited to activity_logs (SHA256, not plaintext) —
    matches memories.go:201-221 so the schema stays uniform
  * Audit failure does NOT block the write (logged + continue) —
    failing closed would deny org-scope writes whenever activity_logs
    is unhappy
  * org:* memories get the [MEMORY id=... scope=ORG ns=...]: prefix
    on read — preserves the prompt-injection mitigation from
    memories.go:455-461

Coexistence design: legacy commit_memory + recall_memory still wired
to their old code paths in mcp_tools.go. PR-6 will alias them to
delegate to these v2 implementations. PR-9 (60 days post-cutover)
removes the legacy entries.

Wiring:
  * MCPHandler gains an memv2 field (nil-safe; tools return a clear
    error when MEMORY_PLUGIN_URL is unset rather than crashing)
  * WithMemoryV2(plugin, resolver) is the production wiring API
    main.go calls at boot
  * withMemoryV2APIs(plugin, resolver) is the test-injectable variant
    against the memoryPluginAPI / namespaceResolverAPI interfaces

Coverage: 100.0% on every new function in mcp_tools_memory_v2.go.

Edge cases pinned:
  * empty/whitespace content → reject before plugin
  * plugin unconfigured → clear error, no crash
  * ACL violation → clear error
  * resolver error → wrapped error
  * plugin error → wrapped error
  * malformed expires_at → silently ignored (no exception)
  * org write audit failure → logged, write proceeds
  * search namespace intersection drops foreign entries
  * search with all-foreign namespaces → empty result, plugin not called
  * search org memories get delimiter wrap, workspace memories do not
  * forget with explicit + default namespace
  * forget cross-scope rejected
  * pickStr / pickStringSlice handle missing keys, wrong types, mixed slices
  * wrapOrgDelimiter format is exact-match
  * dispatch wires all 6 tools (no "unknown tool" error)
2026-05-04 07:50:26 -07:00
Hongming Wang
51e7d94605
Merge pull request #2724 from Molecule-AI/staging
staging → main: auto-promote 3f4c5f8
2026-05-04 07:50:20 -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
c53b2b104f
Merge pull request #2730 from Molecule-AI/feat/memory-v2-pr4-namespace-resolver
Memory v2 PR-4: namespace resolver + tests (stacked on PR-1)
2026-05-04 14:28:22 +00: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
f05633f5b0
Merge pull request #2732 from Molecule-AI/fix/canary-timeout-tail-latency
ci(canary): bump synth timeout 12→20 min to absorb apt tail latency
2026-05-04 14:04:53 +00:00