Merge pull request #2729 from Molecule-AI/feat/memory-v2-pr1-contract
Memory v2 PR-1: OpenAPI plugin contract + Go bindings
This commit is contained in:
commit
f52de74b7b
349
docs/api-protocol/memory-plugin-v1.yaml
Normal file
349
docs/api-protocol/memory-plugin-v1.yaml
Normal file
@ -0,0 +1,349 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Molecule Memory Plugin v1
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Contract between workspace-server and a memory backend plugin. The
|
||||
plugin owns its own storage; workspace-server is the security
|
||||
perimeter (secret redaction, namespace ACL, GLOBAL audit/wrap).
|
||||
|
||||
Defined in RFC #2728. See docs/rfc/memory-v2-rationale.md for design
|
||||
rationale.
|
||||
|
||||
Auth: none. Plugins MUST be reachable only on a private network or
|
||||
unix socket — workspace-server is the only sanctioned client.
|
||||
servers:
|
||||
- url: http://localhost:9100
|
||||
description: Built-in postgres-backed plugin (default)
|
||||
|
||||
paths:
|
||||
/v1/health:
|
||||
get:
|
||||
summary: Liveness + capability probe
|
||||
operationId: getHealth
|
||||
responses:
|
||||
'200':
|
||||
description: Plugin healthy
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/HealthResponse' }
|
||||
'503':
|
||||
description: Plugin unhealthy (e.g., backing store down)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/v1/namespaces/{name}:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/NamespaceName'
|
||||
put:
|
||||
summary: Upsert a namespace (idempotent)
|
||||
operationId: upsertNamespace
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NamespaceUpsert' }
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/Namespace' }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
patch:
|
||||
summary: Update namespace metadata or TTL
|
||||
operationId: patchNamespace
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NamespacePatch' }
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/Namespace' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
delete:
|
||||
summary: Delete namespace and all its memories (operator action)
|
||||
operationId: deleteNamespace
|
||||
responses:
|
||||
'204':
|
||||
description: Deleted
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/v1/namespaces/{name}/memories:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/NamespaceName'
|
||||
post:
|
||||
summary: Write a memory to a namespace
|
||||
description: |
|
||||
`content` MUST already be secret-redacted by the workspace-server.
|
||||
Plugin does not run additional redaction.
|
||||
operationId: commitMemory
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/MemoryWrite' }
|
||||
responses:
|
||||
'201':
|
||||
description: Memory persisted
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/MemoryWriteResponse' }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/v1/search:
|
||||
post:
|
||||
summary: Search memories across one or more namespaces
|
||||
description: |
|
||||
workspace-server MUST intersect the requested `namespaces` with
|
||||
the caller's currently-readable set BEFORE invoking this
|
||||
endpoint. The plugin treats the list as authoritative.
|
||||
operationId: searchMemories
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SearchRequest' }
|
||||
responses:
|
||||
'200':
|
||||
description: Search results
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SearchResponse' }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
|
||||
/v1/memories/{id}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
delete:
|
||||
summary: Forget a memory by id
|
||||
description: |
|
||||
`requested_by_namespace` is the namespace the caller has write
|
||||
access to; the plugin SHOULD reject if the memory doesn't belong
|
||||
to that namespace.
|
||||
operationId: forgetMemory
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ForgetRequest' }
|
||||
responses:
|
||||
'204':
|
||||
description: Forgotten
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
components:
|
||||
parameters:
|
||||
NamespaceName:
|
||||
in: path
|
||||
name: name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 256
|
||||
pattern: '^[a-z]+:[A-Za-z0-9_:.\-]+$'
|
||||
example: 'workspace:550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
responses:
|
||||
Namespace:
|
||||
description: Namespace state
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Namespace' }
|
||||
BadRequest:
|
||||
description: Invalid input
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
Forbidden:
|
||||
description: Caller lacks write access to the requested namespace
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
schemas:
|
||||
HealthResponse:
|
||||
type: object
|
||||
required: [status, version, capabilities]
|
||||
properties:
|
||||
status: { type: string, enum: [ok, degraded] }
|
||||
version: { type: string, example: "1.0.0" }
|
||||
capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [embedding, fts, ttl, pin, propagation]
|
||||
description: |
|
||||
Optional features this plugin supports. workspace-server
|
||||
adapts MCP responses based on this list (e.g., agents can
|
||||
request semantic search only when `embedding` is present).
|
||||
|
||||
NamespaceKind:
|
||||
type: string
|
||||
enum: [workspace, team, org, custom]
|
||||
|
||||
Namespace:
|
||||
type: object
|
||||
required: [name, kind, created_at]
|
||||
properties:
|
||||
name: { type: string }
|
||||
kind: { $ref: '#/components/schemas/NamespaceKind' }
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
NamespaceUpsert:
|
||||
type: object
|
||||
required: [kind]
|
||||
properties:
|
||||
kind: { $ref: '#/components/schemas/NamespaceKind' }
|
||||
expires_at: { type: string, format: date-time, nullable: true }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
|
||||
NamespacePatch:
|
||||
type: object
|
||||
properties:
|
||||
expires_at: { type: string, format: date-time, nullable: true }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
|
||||
MemoryKind:
|
||||
type: string
|
||||
enum: [fact, summary, checkpoint]
|
||||
|
||||
MemorySource:
|
||||
type: string
|
||||
enum: [agent, runtime, user]
|
||||
|
||||
MemoryWrite:
|
||||
type: object
|
||||
required: [content, kind, source]
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Already secret-redacted by workspace-server.
|
||||
kind: { $ref: '#/components/schemas/MemoryKind' }
|
||||
source: { $ref: '#/components/schemas/MemorySource' }
|
||||
expires_at: { type: string, format: date-time, nullable: true }
|
||||
propagation:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
description: |
|
||||
Opaque metadata the plugin stores and returns. Reserved for
|
||||
future cross-namespace propagation semantics.
|
||||
pin: { type: boolean, default: false }
|
||||
embedding:
|
||||
type: array
|
||||
items: { type: number }
|
||||
nullable: true
|
||||
description: |
|
||||
Optional pre-computed embedding. Plugins reporting the
|
||||
`embedding` capability MAY ignore this and recompute.
|
||||
|
||||
MemoryWriteResponse:
|
||||
type: object
|
||||
required: [id, namespace]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
namespace: { type: string }
|
||||
|
||||
Memory:
|
||||
type: object
|
||||
required: [id, namespace, content, kind, source, created_at]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
namespace: { type: string }
|
||||
content: { type: string }
|
||||
kind: { $ref: '#/components/schemas/MemoryKind' }
|
||||
source: { $ref: '#/components/schemas/MemorySource' }
|
||||
expires_at: { type: string, format: date-time, nullable: true }
|
||||
propagation:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
pin: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
score:
|
||||
type: number
|
||||
nullable: true
|
||||
description: Relevance score from search (semantic + FTS).
|
||||
|
||||
SearchRequest:
|
||||
type: object
|
||||
required: [namespaces]
|
||||
properties:
|
||||
namespaces:
|
||||
type: array
|
||||
items: { type: string }
|
||||
minItems: 1
|
||||
description: |
|
||||
Already intersected with the caller's readable set by
|
||||
workspace-server.
|
||||
query: { type: string }
|
||||
kinds:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/MemoryKind' }
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
embedding:
|
||||
type: array
|
||||
items: { type: number }
|
||||
nullable: true
|
||||
|
||||
SearchResponse:
|
||||
type: object
|
||||
required: [memories]
|
||||
properties:
|
||||
memories:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Memory' }
|
||||
|
||||
ForgetRequest:
|
||||
type: object
|
||||
required: [requested_by_namespace]
|
||||
properties:
|
||||
requested_by_namespace:
|
||||
type: string
|
||||
description: Namespace the caller has write access to.
|
||||
|
||||
Error:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum:
|
||||
- bad_request
|
||||
- not_found
|
||||
- forbidden
|
||||
- internal
|
||||
- unavailable
|
||||
message: { type: string }
|
||||
details:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
319
workspace-server/internal/memory/contract/contract.go
Normal file
319
workspace-server/internal/memory/contract/contract.go
Normal file
@ -0,0 +1,319 @@
|
||||
// Package contract holds the typed Go bindings for the Memory Plugin v1
|
||||
// HTTP contract defined at docs/api-protocol/memory-plugin-v1.yaml.
|
||||
//
|
||||
// These types are the wire shape between workspace-server (the only
|
||||
// sanctioned client) and any memory plugin implementation. They are
|
||||
// kept in their own package so the plugin client (PR-2) and the
|
||||
// built-in postgres plugin server (PR-3) share a single source of
|
||||
// truth for JSON tags and validation rules.
|
||||
//
|
||||
// Validation lives next to the types via the Validate() methods so
|
||||
// every wire object self-checks; PR-2's HTTP client and PR-3's HTTP
|
||||
// server both call Validate() at the boundary.
|
||||
package contract
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SchemaVersion pins the contract revision the workspace-server expects
|
||||
// from /v1/health responses. Bump in lockstep with the OpenAPI spec.
|
||||
const SchemaVersion = "1.0.0"
|
||||
|
||||
// Capability strings reported by /v1/health. Plugins MAY report any
|
||||
// subset; workspace-server gates feature exposure on what's reported.
|
||||
const (
|
||||
CapabilityEmbedding = "embedding"
|
||||
CapabilityFTS = "fts"
|
||||
CapabilityTTL = "ttl"
|
||||
CapabilityPin = "pin"
|
||||
CapabilityPropagation = "propagation"
|
||||
)
|
||||
|
||||
// NamespaceKind enumerates the four namespace shapes workspace-server
|
||||
// derives from the team tree. `custom` is reserved for operator-defined
|
||||
// cross-workspace channels.
|
||||
type NamespaceKind string
|
||||
|
||||
const (
|
||||
NamespaceKindWorkspace NamespaceKind = "workspace"
|
||||
NamespaceKindTeam NamespaceKind = "team"
|
||||
NamespaceKindOrg NamespaceKind = "org"
|
||||
NamespaceKindCustom NamespaceKind = "custom"
|
||||
)
|
||||
|
||||
// MemoryKind distinguishes facts (point-in-time observations), summaries
|
||||
// (compressed multi-fact rollups), and checkpoints (durable state
|
||||
// markers between sessions).
|
||||
type MemoryKind string
|
||||
|
||||
const (
|
||||
MemoryKindFact MemoryKind = "fact"
|
||||
MemoryKindSummary MemoryKind = "summary"
|
||||
MemoryKindCheckpoint MemoryKind = "checkpoint"
|
||||
)
|
||||
|
||||
// MemorySource records who wrote a memory: the agent itself, the
|
||||
// workspace runtime (e.g., end-of-session auto-summary), or the user
|
||||
// (canvas-side input).
|
||||
type MemorySource string
|
||||
|
||||
const (
|
||||
MemorySourceAgent MemorySource = "agent"
|
||||
MemorySourceRuntime MemorySource = "runtime"
|
||||
MemorySourceUser MemorySource = "user"
|
||||
)
|
||||
|
||||
// ErrorCode enumerates the wire error codes plugins return.
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrorCodeBadRequest ErrorCode = "bad_request"
|
||||
ErrorCodeNotFound ErrorCode = "not_found"
|
||||
ErrorCodeForbidden ErrorCode = "forbidden"
|
||||
ErrorCodeInternal ErrorCode = "internal"
|
||||
ErrorCodeUnavailable ErrorCode = "unavailable"
|
||||
)
|
||||
|
||||
// HealthResponse is the body of GET /v1/health.
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
// HasCapability reports whether the plugin advertises the named
|
||||
// capability. Tolerant of nil receivers so callers can probe before
|
||||
// the health check completes.
|
||||
func (h *HealthResponse) HasCapability(c string) bool {
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
for _, cap := range h.Capabilities {
|
||||
if cap == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Namespace is the persisted namespace state returned by upsert/patch
|
||||
// and embedded in audit responses.
|
||||
type Namespace struct {
|
||||
Name string `json:"name"`
|
||||
Kind NamespaceKind `json:"kind"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// NamespaceUpsert is the body of PUT /v1/namespaces/{name}.
|
||||
type NamespaceUpsert struct {
|
||||
Kind NamespaceKind `json:"kind"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// NamespacePatch is the body of PATCH /v1/namespaces/{name}.
|
||||
type NamespacePatch struct {
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryWrite is the body of POST /v1/namespaces/{name}/memories.
|
||||
//
|
||||
// `Content` MUST be pre-redacted by workspace-server (SAFE-T1201).
|
||||
// Plugins do not run additional redaction; the workspace-server is the
|
||||
// security perimeter.
|
||||
type MemoryWrite struct {
|
||||
Content string `json:"content"`
|
||||
Kind MemoryKind `json:"kind"`
|
||||
Source MemorySource `json:"source"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Propagation map[string]interface{} `json:"propagation,omitempty"`
|
||||
Pin bool `json:"pin,omitempty"`
|
||||
Embedding []float32 `json:"embedding,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryWriteResponse is the body of 201 from POST .../memories.
|
||||
type MemoryWriteResponse struct {
|
||||
ID string `json:"id"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
// Memory is a stored memory record returned by search.
|
||||
type Memory struct {
|
||||
ID string `json:"id"`
|
||||
Namespace string `json:"namespace"`
|
||||
Content string `json:"content"`
|
||||
Kind MemoryKind `json:"kind"`
|
||||
Source MemorySource `json:"source"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Propagation map[string]interface{} `json:"propagation,omitempty"`
|
||||
Pin bool `json:"pin,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// SearchRequest is the body of POST /v1/search.
|
||||
//
|
||||
// `Namespaces` MUST already be intersected with the caller's readable
|
||||
// set by workspace-server. The plugin treats it as authoritative.
|
||||
type SearchRequest struct {
|
||||
Namespaces []string `json:"namespaces"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Kinds []MemoryKind `json:"kinds,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Embedding []float32 `json:"embedding,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResponse is the body of 200 from POST /v1/search.
|
||||
type SearchResponse struct {
|
||||
Memories []Memory `json:"memories"`
|
||||
}
|
||||
|
||||
// ForgetRequest is the body of DELETE /v1/memories/{id}.
|
||||
type ForgetRequest struct {
|
||||
RequestedByNamespace string `json:"requested_by_namespace"`
|
||||
}
|
||||
|
||||
// Error is the standard error envelope for non-2xx responses.
|
||||
type Error struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return "<nil contract.Error>"
|
||||
}
|
||||
return fmt.Sprintf("memory-plugin: %s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
// Per the OpenAPI spec: lowercase prefix, colon, then alnum + a small
|
||||
// set of separators. Caps the length at 256 to bound storage.
|
||||
var namespacePattern = regexp.MustCompile(`^[a-z]+:[A-Za-z0-9_:.\-]+$`)
|
||||
|
||||
const maxNamespaceLen = 256
|
||||
|
||||
// ValidateNamespaceName enforces the wire-level namespace string
|
||||
// format. Run by both client (before request) and server (on receive).
|
||||
func ValidateNamespaceName(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("namespace name is empty")
|
||||
}
|
||||
if len(name) > maxNamespaceLen {
|
||||
return fmt.Errorf("namespace name exceeds %d chars", maxNamespaceLen)
|
||||
}
|
||||
if !namespacePattern.MatchString(name) {
|
||||
return fmt.Errorf("namespace name %q does not match required pattern %s",
|
||||
name, namespacePattern.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks NamespaceUpsert against the OpenAPI constraints.
|
||||
func (u *NamespaceUpsert) Validate() error {
|
||||
if u == nil {
|
||||
return errors.New("nil NamespaceUpsert")
|
||||
}
|
||||
if !validNamespaceKind(u.Kind) {
|
||||
return fmt.Errorf("invalid namespace kind %q", u.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks NamespacePatch is at least one mutation. An entirely
|
||||
// empty patch is rejected so callers don't waste round-trips.
|
||||
func (p *NamespacePatch) Validate() error {
|
||||
if p == nil {
|
||||
return errors.New("nil NamespacePatch")
|
||||
}
|
||||
if p.ExpiresAt == nil && p.Metadata == nil {
|
||||
return errors.New("patch has no fields set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks MemoryWrite. Empty content is rejected (zero-length
|
||||
// memories are pure overhead). Both kind and source are required.
|
||||
func (w *MemoryWrite) Validate() error {
|
||||
if w == nil {
|
||||
return errors.New("nil MemoryWrite")
|
||||
}
|
||||
if strings.TrimSpace(w.Content) == "" {
|
||||
return errors.New("content is empty")
|
||||
}
|
||||
if !validMemoryKind(w.Kind) {
|
||||
return fmt.Errorf("invalid memory kind %q", w.Kind)
|
||||
}
|
||||
if !validMemorySource(w.Source) {
|
||||
return fmt.Errorf("invalid memory source %q", w.Source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks SearchRequest. The namespace list must be non-empty
|
||||
// because workspace-server is required to intersect server-side; an
|
||||
// empty list at this layer is a bug, not a "search everything" intent.
|
||||
func (s *SearchRequest) Validate() error {
|
||||
if s == nil {
|
||||
return errors.New("nil SearchRequest")
|
||||
}
|
||||
if len(s.Namespaces) == 0 {
|
||||
return errors.New("namespaces is empty (workspace-server must intersect, not the plugin)")
|
||||
}
|
||||
for i, ns := range s.Namespaces {
|
||||
if err := ValidateNamespaceName(ns); err != nil {
|
||||
return fmt.Errorf("namespaces[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
if s.Limit < 0 || s.Limit > 100 {
|
||||
return fmt.Errorf("limit %d out of range [0,100]", s.Limit)
|
||||
}
|
||||
for i, k := range s.Kinds {
|
||||
if !validMemoryKind(k) {
|
||||
return fmt.Errorf("kinds[%d]: invalid memory kind %q", i, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks ForgetRequest.
|
||||
func (f *ForgetRequest) Validate() error {
|
||||
if f == nil {
|
||||
return errors.New("nil ForgetRequest")
|
||||
}
|
||||
return ValidateNamespaceName(f.RequestedByNamespace)
|
||||
}
|
||||
|
||||
func validNamespaceKind(k NamespaceKind) bool {
|
||||
switch k {
|
||||
case NamespaceKindWorkspace, NamespaceKindTeam, NamespaceKindOrg, NamespaceKindCustom:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validMemoryKind(k MemoryKind) bool {
|
||||
switch k {
|
||||
case MemoryKindFact, MemoryKindSummary, MemoryKindCheckpoint:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validMemorySource(s MemorySource) bool {
|
||||
switch s {
|
||||
case MemorySourceAgent, MemorySourceRuntime, MemorySourceUser:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
527
workspace-server/internal/memory/contract/contract_test.go
Normal file
527
workspace-server/internal/memory/contract/contract_test.go
Normal file
@ -0,0 +1,527 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- HealthResponse ---
|
||||
|
||||
func TestHealthResponse_HasCapability(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
h *HealthResponse
|
||||
cap string
|
||||
want bool
|
||||
}{
|
||||
{"nil receiver", nil, CapabilityEmbedding, false},
|
||||
{"empty caps", &HealthResponse{Capabilities: nil}, CapabilityEmbedding, false},
|
||||
{"present", &HealthResponse{Capabilities: []string{CapabilityFTS, CapabilityEmbedding}}, CapabilityEmbedding, true},
|
||||
{"absent", &HealthResponse{Capabilities: []string{CapabilityFTS}}, CapabilityEmbedding, false},
|
||||
{"unknown cap string", &HealthResponse{Capabilities: []string{"future-cap"}}, "future-cap", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.h.HasCapability(tc.cap); got != tc.want {
|
||||
t.Errorf("HasCapability(%q) = %v, want %v", tc.cap, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateNamespaceName ---
|
||||
|
||||
func TestValidateNamespaceName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", true},
|
||||
{"workspace uuid", "workspace:550e8400-e29b-41d4-a716-446655440000", false},
|
||||
{"team uuid", "team:550e8400-e29b-41d4-a716-446655440000", false},
|
||||
{"org slug", "org:acme-corp", false},
|
||||
{"custom slug", "custom:engineering-shared", false},
|
||||
{"no colon", "workspace_self", true},
|
||||
{"empty prefix", ":foo", true},
|
||||
{"empty body", "workspace:", true},
|
||||
{"uppercase prefix", "WORKSPACE:abc", true},
|
||||
{"prefix with digit", "ws1:abc", true},
|
||||
{"body with space", "workspace:abc def", true},
|
||||
{"body with slash", "workspace:abc/def", true},
|
||||
{"valid with dots", "workspace:abc.def.ghi", false},
|
||||
{"valid with underscores", "workspace:abc_def", false},
|
||||
{"valid with double colon in body", "team:abc:def", false},
|
||||
{"too long", "workspace:" + strings.Repeat("a", 257), true},
|
||||
{"exactly max", "workspace:" + strings.Repeat("a", maxNamespaceLen-len("workspace:")), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateNamespaceName(tc.in)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("ValidateNamespaceName(%q) err=%v, wantErr=%v", tc.in, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- NamespaceUpsert.Validate ---
|
||||
|
||||
func TestNamespaceUpsert_Validate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *NamespaceUpsert
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, true},
|
||||
{"workspace kind", &NamespaceUpsert{Kind: NamespaceKindWorkspace}, false},
|
||||
{"team kind", &NamespaceUpsert{Kind: NamespaceKindTeam}, false},
|
||||
{"org kind", &NamespaceUpsert{Kind: NamespaceKindOrg}, false},
|
||||
{"custom kind", &NamespaceUpsert{Kind: NamespaceKindCustom}, false},
|
||||
{"empty kind", &NamespaceUpsert{Kind: ""}, true},
|
||||
{"unknown kind", &NamespaceUpsert{Kind: "futurekind"}, true},
|
||||
{"with TTL", &NamespaceUpsert{Kind: NamespaceKindTeam, ExpiresAt: timePtr(time.Now().Add(time.Hour))}, false},
|
||||
{"with metadata", &NamespaceUpsert{Kind: NamespaceKindOrg, Metadata: map[string]interface{}{"tier": "pro"}}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.in.Validate()
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- NamespacePatch.Validate ---
|
||||
|
||||
func TestNamespacePatch_Validate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *NamespacePatch
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, true},
|
||||
{"empty patch", &NamespacePatch{}, true},
|
||||
{"only TTL", &NamespacePatch{ExpiresAt: timePtr(time.Now())}, false},
|
||||
{"only metadata", &NamespacePatch{Metadata: map[string]interface{}{"k": "v"}}, false},
|
||||
{"both fields", &NamespacePatch{ExpiresAt: timePtr(time.Now()), Metadata: map[string]interface{}{"k": "v"}}, false},
|
||||
// Note: empty (non-nil) metadata map IS considered a mutation —
|
||||
// it lets operators clear metadata by sending {}.
|
||||
{"empty metadata map mutates", &NamespacePatch{Metadata: map[string]interface{}{}}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.in.Validate()
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- MemoryWrite.Validate ---
|
||||
|
||||
func TestMemoryWrite_Validate(t *testing.T) {
|
||||
valid := func(mut func(*MemoryWrite)) *MemoryWrite {
|
||||
w := &MemoryWrite{
|
||||
Content: "user prefers tabs",
|
||||
Kind: MemoryKindFact,
|
||||
Source: MemorySourceAgent,
|
||||
}
|
||||
if mut != nil {
|
||||
mut(w)
|
||||
}
|
||||
return w
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in *MemoryWrite
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, true},
|
||||
{"happy path", valid(nil), false},
|
||||
{"empty content", valid(func(w *MemoryWrite) { w.Content = "" }), true},
|
||||
{"whitespace-only content", valid(func(w *MemoryWrite) { w.Content = " \t\n " }), true},
|
||||
{"summary kind", valid(func(w *MemoryWrite) { w.Kind = MemoryKindSummary }), false},
|
||||
{"checkpoint kind", valid(func(w *MemoryWrite) { w.Kind = MemoryKindCheckpoint }), false},
|
||||
{"empty kind", valid(func(w *MemoryWrite) { w.Kind = "" }), true},
|
||||
{"unknown kind", valid(func(w *MemoryWrite) { w.Kind = "rumor" }), true},
|
||||
{"runtime source", valid(func(w *MemoryWrite) { w.Source = MemorySourceRuntime }), false},
|
||||
{"user source", valid(func(w *MemoryWrite) { w.Source = MemorySourceUser }), false},
|
||||
{"empty source", valid(func(w *MemoryWrite) { w.Source = "" }), true},
|
||||
{"unknown source", valid(func(w *MemoryWrite) { w.Source = "spy" }), true},
|
||||
{"with embedding", valid(func(w *MemoryWrite) { w.Embedding = []float32{0.1, 0.2, 0.3} }), false},
|
||||
{"with TTL", valid(func(w *MemoryWrite) { w.ExpiresAt = timePtr(time.Now().Add(time.Hour)) }), false},
|
||||
{"with propagation", valid(func(w *MemoryWrite) { w.Propagation = map[string]interface{}{"hop": 1} }), false},
|
||||
{"pin true", valid(func(w *MemoryWrite) { w.Pin = true }), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.in.Validate()
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- SearchRequest.Validate ---
|
||||
|
||||
func TestSearchRequest_Validate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *SearchRequest
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, true},
|
||||
{"empty namespaces", &SearchRequest{}, true},
|
||||
{"single ns", &SearchRequest{Namespaces: []string{"workspace:abc"}}, false},
|
||||
{"multi ns", &SearchRequest{Namespaces: []string{"workspace:abc", "team:def", "org:ghi"}}, false},
|
||||
{"invalid ns in list", &SearchRequest{Namespaces: []string{"workspace:abc", "BAD"}}, true},
|
||||
{"limit zero", &SearchRequest{Namespaces: []string{"workspace:abc"}, Limit: 0}, false},
|
||||
{"limit max", &SearchRequest{Namespaces: []string{"workspace:abc"}, Limit: 100}, false},
|
||||
{"limit too high", &SearchRequest{Namespaces: []string{"workspace:abc"}, Limit: 101}, true},
|
||||
{"limit negative", &SearchRequest{Namespaces: []string{"workspace:abc"}, Limit: -1}, true},
|
||||
{"valid kinds", &SearchRequest{Namespaces: []string{"workspace:abc"}, Kinds: []MemoryKind{MemoryKindFact, MemoryKindSummary}}, false},
|
||||
{"invalid kind in list", &SearchRequest{Namespaces: []string{"workspace:abc"}, Kinds: []MemoryKind{"bogus"}}, true},
|
||||
{"with query and embedding", &SearchRequest{Namespaces: []string{"workspace:abc"}, Query: "prefs", Embedding: []float32{1, 2, 3}}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.in.Validate()
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- ForgetRequest.Validate ---
|
||||
|
||||
func TestForgetRequest_Validate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *ForgetRequest
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, true},
|
||||
{"empty ns", &ForgetRequest{}, true},
|
||||
{"valid ns", &ForgetRequest{RequestedByNamespace: "workspace:abc"}, false},
|
||||
{"invalid ns", &ForgetRequest{RequestedByNamespace: "no-colon"}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.in.Validate()
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("Validate() err=%v, wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Error type ---
|
||||
|
||||
func TestError_Error(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *Error
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, "<nil contract.Error>"},
|
||||
{"basic", &Error{Code: ErrorCodeNotFound, Message: "ns gone"}, "memory-plugin: not_found: ns gone"},
|
||||
{"with details", &Error{Code: ErrorCodeInternal, Message: "boom", Details: map[string]interface{}{"trace": "x"}}, "memory-plugin: internal: boom"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.in.Error(); got != tc.want {
|
||||
t.Errorf("Error() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Verifies Error implements the standard error interface so callers
|
||||
// can use errors.As/errors.Is. This was missed pre-PR; an incident
|
||||
// in PR #2509 was caused by a type that looked like an error but
|
||||
// wasn't assertable, so we pin the contract explicitly.
|
||||
var e error = &Error{Code: ErrorCodeBadRequest, Message: "x"}
|
||||
var target *Error
|
||||
if !errors.As(e, &target) {
|
||||
t.Errorf("Error must satisfy errors.As to *Error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Round-trip JSON tests for every type ---
|
||||
|
||||
func TestRoundTrip_HealthResponse(t *testing.T) {
|
||||
original := HealthResponse{
|
||||
Status: "ok",
|
||||
Version: SchemaVersion,
|
||||
Capabilities: []string{CapabilityFTS, CapabilityEmbedding, CapabilityTTL},
|
||||
}
|
||||
roundTripJSON(t, original, &HealthResponse{}, func(got, want interface{}) {
|
||||
g := got.(*HealthResponse)
|
||||
w := want.(HealthResponse)
|
||||
if g.Status != w.Status || g.Version != w.Version {
|
||||
t.Errorf("status/version mismatch")
|
||||
}
|
||||
if len(g.Capabilities) != len(w.Capabilities) {
|
||||
t.Errorf("capabilities len mismatch: got %d want %d", len(g.Capabilities), len(w.Capabilities))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoundTrip_Namespace(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
exp := now.Add(24 * time.Hour)
|
||||
original := Namespace{
|
||||
Name: "workspace:550e8400-e29b-41d4-a716-446655440000",
|
||||
Kind: NamespaceKindWorkspace,
|
||||
ExpiresAt: &exp,
|
||||
Metadata: map[string]interface{}{"owner": "agent-x"},
|
||||
CreatedAt: now,
|
||||
}
|
||||
roundTripJSON(t, original, &Namespace{}, nil)
|
||||
}
|
||||
|
||||
func TestRoundTrip_NamespaceUpsert(t *testing.T) {
|
||||
exp := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
||||
original := NamespaceUpsert{
|
||||
Kind: NamespaceKindTeam,
|
||||
ExpiresAt: &exp,
|
||||
Metadata: map[string]interface{}{"tier": "pro"},
|
||||
}
|
||||
roundTripJSON(t, original, &NamespaceUpsert{}, nil)
|
||||
}
|
||||
|
||||
func TestRoundTrip_NamespacePatch(t *testing.T) {
|
||||
exp := time.Now().UTC().Truncate(time.Second)
|
||||
original := NamespacePatch{
|
||||
ExpiresAt: &exp,
|
||||
Metadata: map[string]interface{}{"k": "v"},
|
||||
}
|
||||
roundTripJSON(t, original, &NamespacePatch{}, nil)
|
||||
}
|
||||
|
||||
func TestRoundTrip_MemoryWrite(t *testing.T) {
|
||||
exp := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
||||
original := MemoryWrite{
|
||||
Content: "remembered fact",
|
||||
Kind: MemoryKindFact,
|
||||
Source: MemorySourceAgent,
|
||||
ExpiresAt: &exp,
|
||||
Propagation: map[string]interface{}{"hop": float64(1)},
|
||||
Pin: true,
|
||||
Embedding: []float32{0.1, 0.2, 0.3},
|
||||
}
|
||||
roundTripJSON(t, original, &MemoryWrite{}, func(got, want interface{}) {
|
||||
g := got.(*MemoryWrite)
|
||||
w := want.(MemoryWrite)
|
||||
if g.Content != w.Content || g.Kind != w.Kind || g.Source != w.Source {
|
||||
t.Errorf("content/kind/source mismatch")
|
||||
}
|
||||
if g.Pin != w.Pin {
|
||||
t.Errorf("pin mismatch")
|
||||
}
|
||||
if len(g.Embedding) != len(w.Embedding) {
|
||||
t.Errorf("embedding len mismatch")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoundTrip_MemoryWriteResponse(t *testing.T) {
|
||||
original := MemoryWriteResponse{
|
||||
ID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
Namespace: "workspace:abc",
|
||||
}
|
||||
roundTripJSON(t, original, &MemoryWriteResponse{}, nil)
|
||||
}
|
||||
|
||||
func TestRoundTrip_Memory(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
score := 0.87
|
||||
original := Memory{
|
||||
ID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
Namespace: "team:abc",
|
||||
Content: "team agreed on tabs",
|
||||
Kind: MemoryKindFact,
|
||||
Source: MemorySourceAgent,
|
||||
CreatedAt: now,
|
||||
Score: &score,
|
||||
}
|
||||
roundTripJSON(t, original, &Memory{}, func(got, want interface{}) {
|
||||
g := got.(*Memory)
|
||||
w := want.(Memory)
|
||||
if g.ID != w.ID || g.Namespace != w.Namespace {
|
||||
t.Errorf("id/ns mismatch")
|
||||
}
|
||||
if g.Score == nil || *g.Score != *w.Score {
|
||||
t.Errorf("score mismatch")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoundTrip_SearchRequest(t *testing.T) {
|
||||
original := SearchRequest{
|
||||
Namespaces: []string{"workspace:abc", "team:def"},
|
||||
Query: "prefs",
|
||||
Kinds: []MemoryKind{MemoryKindFact, MemoryKindSummary},
|
||||
Limit: 20,
|
||||
Embedding: []float32{1, 2, 3},
|
||||
}
|
||||
roundTripJSON(t, original, &SearchRequest{}, nil)
|
||||
}
|
||||
|
||||
func TestRoundTrip_SearchResponse(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
original := SearchResponse{
|
||||
Memories: []Memory{
|
||||
{ID: "id-1", Namespace: "workspace:abc", Content: "x", Kind: MemoryKindFact, Source: MemorySourceAgent, CreatedAt: now},
|
||||
{ID: "id-2", Namespace: "team:def", Content: "y", Kind: MemoryKindSummary, Source: MemorySourceRuntime, CreatedAt: now},
|
||||
},
|
||||
}
|
||||
roundTripJSON(t, original, &SearchResponse{}, nil)
|
||||
}
|
||||
|
||||
func TestRoundTrip_ForgetRequest(t *testing.T) {
|
||||
original := ForgetRequest{RequestedByNamespace: "workspace:abc"}
|
||||
roundTripJSON(t, original, &ForgetRequest{}, nil)
|
||||
}
|
||||
|
||||
func TestRoundTrip_Error(t *testing.T) {
|
||||
original := Error{
|
||||
Code: ErrorCodeBadRequest,
|
||||
Message: "invalid input",
|
||||
Details: map[string]interface{}{"field": "kind"},
|
||||
}
|
||||
roundTripJSON(t, original, &Error{}, nil)
|
||||
}
|
||||
|
||||
// --- Golden vector tests ---
|
||||
//
|
||||
// These pin the exact wire shape against committed JSON files. If a
|
||||
// future refactor accidentally changes a JSON tag or omits a field, the
|
||||
// golden test fails. Update goldens via `go test -update` (env var
|
||||
// based; see updateGoldens()).
|
||||
|
||||
func TestGolden_HealthResponse_OK(t *testing.T) {
|
||||
checkGolden(t, "health_ok.json", HealthResponse{
|
||||
Status: "ok",
|
||||
Version: "1.0.0",
|
||||
Capabilities: []string{"fts", "embedding"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestGolden_NamespaceUpsert_Workspace(t *testing.T) {
|
||||
checkGolden(t, "namespace_upsert_workspace.json", NamespaceUpsert{
|
||||
Kind: NamespaceKindWorkspace,
|
||||
})
|
||||
}
|
||||
|
||||
func TestGolden_MemoryWrite_Minimal(t *testing.T) {
|
||||
checkGolden(t, "memory_write_minimal.json", MemoryWrite{
|
||||
Content: "user prefers tabs over spaces",
|
||||
Kind: MemoryKindFact,
|
||||
Source: MemorySourceAgent,
|
||||
})
|
||||
}
|
||||
|
||||
func TestGolden_SearchRequest_MultiNamespace(t *testing.T) {
|
||||
checkGolden(t, "search_request_multi_namespace.json", SearchRequest{
|
||||
Namespaces: []string{
|
||||
"workspace:550e8400-e29b-41d4-a716-446655440000",
|
||||
"team:660e8400-e29b-41d4-a716-446655440001",
|
||||
"org:acme-corp",
|
||||
},
|
||||
Query: "indentation preferences",
|
||||
Limit: 20,
|
||||
})
|
||||
}
|
||||
|
||||
func TestGolden_Error_NotFound(t *testing.T) {
|
||||
checkGolden(t, "error_not_found.json", Error{
|
||||
Code: ErrorCodeNotFound,
|
||||
Message: "namespace not found",
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func timePtr(t time.Time) *time.Time { return &t }
|
||||
|
||||
// roundTripJSON marshals `original` to JSON, unmarshals into `got`,
|
||||
// then validates the round-trip integrity. If `extra` is non-nil it
|
||||
// runs additional type-specific assertions.
|
||||
func roundTripJSON(t *testing.T, original interface{}, got interface{}, extra func(got, want interface{})) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
// Re-marshal the unmarshaled value and compare to the original
|
||||
// JSON. Catches asymmetric tag bugs (e.g., `omitempty` differences).
|
||||
roundData, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("re-marshal: %v", err)
|
||||
}
|
||||
if err := jsonEqual(data, roundData); err != nil {
|
||||
t.Errorf("round-trip diverged:\n before: %s\n after: %s\n diff: %v", data, roundData, err)
|
||||
}
|
||||
if extra != nil {
|
||||
extra(got, original)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonEqual compares two JSON byte slices semantically (key order
|
||||
// independent, type-preserving).
|
||||
func jsonEqual(a, b []byte) error {
|
||||
var ax, bx interface{}
|
||||
if err := json.Unmarshal(a, &ax); err != nil {
|
||||
return fmt.Errorf("a unmarshal: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &bx); err != nil {
|
||||
return fmt.Errorf("b unmarshal: %w", err)
|
||||
}
|
||||
an, _ := json.Marshal(ax)
|
||||
bn, _ := json.Marshal(bx)
|
||||
if string(an) != string(bn) {
|
||||
return fmt.Errorf("differ: %s vs %s", an, bn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkGolden(t *testing.T, filename string, value interface{}) {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", filename)
|
||||
got, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
got = append(got, '\n')
|
||||
|
||||
if updateGoldens() {
|
||||
if err := os.WriteFile(path, got, 0644); err != nil {
|
||||
t.Fatalf("write golden: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden %s: %v (run with UPDATE_GOLDENS=1 to create)", path, err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("golden %s mismatch:\n--- got ---\n%s\n--- want ---\n%s", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func updateGoldens() bool { return os.Getenv("UPDATE_GOLDENS") == "1" }
|
||||
4
workspace-server/internal/memory/contract/testdata/error_not_found.json
vendored
Normal file
4
workspace-server/internal/memory/contract/testdata/error_not_found.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"code": "not_found",
|
||||
"message": "namespace not found"
|
||||
}
|
||||
8
workspace-server/internal/memory/contract/testdata/health_ok.json
vendored
Normal file
8
workspace-server/internal/memory/contract/testdata/health_ok.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"capabilities": [
|
||||
"fts",
|
||||
"embedding"
|
||||
]
|
||||
}
|
||||
5
workspace-server/internal/memory/contract/testdata/memory_write_minimal.json
vendored
Normal file
5
workspace-server/internal/memory/contract/testdata/memory_write_minimal.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"content": "user prefers tabs over spaces",
|
||||
"kind": "fact",
|
||||
"source": "agent"
|
||||
}
|
||||
3
workspace-server/internal/memory/contract/testdata/namespace_upsert_workspace.json
vendored
Normal file
3
workspace-server/internal/memory/contract/testdata/namespace_upsert_workspace.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"kind": "workspace"
|
||||
}
|
||||
9
workspace-server/internal/memory/contract/testdata/search_request_multi_namespace.json
vendored
Normal file
9
workspace-server/internal/memory/contract/testdata/search_request_multi_namespace.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"namespaces": [
|
||||
"workspace:550e8400-e29b-41d4-a716-446655440000",
|
||||
"team:660e8400-e29b-41d4-a716-446655440001",
|
||||
"org:acme-corp"
|
||||
],
|
||||
"query": "indentation preferences",
|
||||
"limit": 20
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user