server-beta: Phases 4–13 — event pipeline, generation, MCP, compat, Docker, team audit, observability (#2383)

* feat(server-beta): Phase 4 — Postgres event-to-generation-job pipeline

Adds POST /v1/events, /v1/events/batch, GET /v1/jobs/:id, GET /v1/events/:id,
and POST /v1/memories on the server-beta runtime, backed by Postgres.

- Event row + outbox generation-job row insert in one withPostgresTransaction.
- BullMQ enqueue happens after commit; enqueue failure leaves the row queued
  for Phase 3 startup reconciliation.
- ?generate=false skips the outbox; ?wait=true returns queue status only,
  never observation IDs (provider generation is Phase 5).
- Batch pre-validates all event projectIds against api-key scope before any
  write; mixed-project batches reject 403 with zero side effects.
- /v1/memories is a direct insert alias — no generator, no outbox.
- Cross-tenant /v1/jobs/:id returns 404 to avoid leaking row existence.
- New PostgresAuthMiddleware reads api_keys by SHA-256 hash; populates
  req.authContext.teamId/projectId; legacy ServerV1Routes (SQLite, used by
  worker runtime) is left untouched.
- Tests: unit suite hardened with stubbed pool.query so route registration
  is safe; integration tests skip cleanly without CLAUDE_MEM_TEST_POSTGRES_URL.

Verification: 87 pass / 1 skip / 0 fail. No new typecheck errors. Required
greps for WorkerService and MemoryItemsRepository in src/server/routes/v1
and src/server/runtime return no hits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 5 — provider observation generator

Adds independent provider generation under src/server/generation/ with no
worker coupling. Server beta can now generate observations end-to-end:
event -> outbox -> BullMQ -> provider -> parser -> persisted observation.

- ProviderObservationGenerator orchestrates: lock outbox (queued -> processing),
  reload agent_event from Postgres (BullMQ payload is advisory only), call
  provider, hand raw text to processGeneratedResponse, route errors via
  markGenerationFailed with retryable flag from ServerClassifiedProviderError.
- processGeneratedResponse parses with parseAgentXml, persists via
  PostgresObservationRepository with deterministic
  generation_key = generation:v1:{job_id}:{index}:{fingerprint},
  links via PostgresObservationSourcesRepository, advances outbox status,
  appends observation_generation_job_events, audits — all in one
  withPostgresTransaction. Idempotent on retry via UNIQUE constraints.
- Three provider adapters under src/server/generation/providers/:
  Claude, Gemini, OpenRouter. Self-contained — no imports from
  src/services/worker/*. Worker providers unchanged.
- Shared error classification + prompt builder under providers/shared/.
  Prompt builder strips <private> at the edge; fully-private batches
  emit <skip_summary /> without billing the provider.
- ActiveServerBetaGenerationWorkerManager wires BullMQ Worker via
  ServerJobQueue.start(...) with concurrency 1 + autorun:false +
  worker.on('error') per BullMQ docs.
- New GET /v1/events/:id/observations on ServerV1PostgresRoutes returns
  observations linked via observation_sources, team/project scoped.

Verification: 104 pass / 4 skip / 0 fail. No typecheck regressions.
Anti-pattern greps clean for services/worker imports under src/server,
WorkerRef/ActiveSession/SessionStore in src/server/generation.

Deferred: ModeManager loading uses a stable fallback observation type
list; summary and reindex queue lanes are not yet wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 6 — independent server session semantics

server_sessions is now the canonical Server beta session model. Sessions
are independent of legacy worker ActiveSession state.

- PostgresServerSessionRepository extended: findByExternalIdForScope,
  endSession (idempotent via COALESCE(ended_at, now())),
  markGenerationStarted/Completed/Failed, listUnprocessedEvents (filters
  agent_events with completed agent_event jobs).
- ServerSessionRuntimeRepository wraps the repo; every method requires
  explicit team_id + project_id and validates scope via assertProjectOwnership.
- SessionGenerationPolicy supports per-event (default), debounce
  (BullMQ delayed-job replace via getJob+remove+add), and end-of-session.
  Configured via CLAUDE_MEM_SERVER_SESSION_POLICY and
  CLAUDE_MEM_SERVER_SESSION_DEBOUNCE_MS env vars; per-team override hooks
  are exposed on ServerV1PostgresRoutesOptions for future settings layer.
- POST /v1/sessions/start (find-or-create on (project_id, external_session_id),
  GET /v1/sessions/:id (scoped 404), POST /v1/sessions/:id/end
  (transactional: end + create summary outbox via UNIQUE collapse +
  enqueue post-commit). Re-ending is fully idempotent.
- processSessionSummaryResponse persists summary as kind='summary'
  observation with the same idempotency model
  (generation_key + observation_sources UNIQUE).
- ProviderObservationGenerator dispatches on source_type:
  agent_event -> processGeneratedResponse, session_summary ->
  processSessionSummaryResponse; loadEvents handles session-summary
  by loading unprocessed events.
- ActiveServerBetaGenerationWorkerManager wires summary BullMQ lane
  alongside event lane (concurrency=1, autorun=false, error listener
  attached per BullMQ docs).

Verification: 110 pass / 6 skip / 0 fail. Net typecheck error count
unchanged at 24 (pre-existing, none in Phase 6 files). Anti-pattern
greps clean for ActiveSession/SessionStore in src/server/runtime,
no worker imports anywhere in src/server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 7 — hook routing without worker dependency

Hooks can now talk directly to server-beta when CLAUDE_MEM_RUNTIME=server-beta
is selected, with a clean worker fallback when server-beta is unhealthy.

- src/services/hooks/server-beta-client.ts — typed HTTP client for
  /v1/sessions/start, /v1/events, /v1/sessions/:id/end. Throws
  ServerBetaClientError with kind classification (missing_api_key,
  transport, timeout, http_error, invalid_response) and isFallbackEligible
  helper. Zero imports from services/worker/.
- src/services/hooks/runtime-selector.ts — reads CLAUDE_MEM_RUNTIME from
  settings, returns worker or server-beta context, logs
  [server-beta-fallback] reason=<code> on every config-time fallback.
- src/services/hooks/server-beta-bootstrap.ts — Postgres-backed API key
  bootstrap. Find-or-creates local-hook-team + local-hook-project,
  generates cmem_<random> key (SHA-256 hashed), inserts into api_keys
  with scopes events:write/sessions:write/observations:read/jobs:read.
  Settings file written with chmod 0600. rotateServerBetaApiKey() wired
  to a new `claude-mem server keys rotate` command.
- src/cli/handlers/{observation,session-init,summarize}.ts — every hook
  handler tries server-beta first when configured, falls through to the
  existing worker path on transport/5xx/429/missing-key. One WARN line
  per fallback. Hook JSON output shape unchanged.
- src/shared/SettingsDefaultsManager.ts — three new keys with defaults:
  CLAUDE_MEM_SERVER_BETA_URL, CLAUDE_MEM_SERVER_BETA_API_KEY,
  CLAUDE_MEM_SERVER_BETA_PROJECT_ID.
- src/npx-cli/commands/install.ts — when installer selects server-beta
  runtime and CLAUDE_MEM_SERVER_DATABASE_URL is set, bootstraps a local
  API key automatically. Warns and continues if the DB URL is missing.

plugin/scripts/*.cjs bundles rebuilt via npm run build to pick up the
new hook handler code path. No plaintext keys in the bundle (verified).

Verification: 16 hook unit tests pass; 275 server/storage/services tests
pass with 7 pre-existing failures (verified independent of this change
via git stash --include-untracked). Build clean. No new typecheck
errors in Phase 7 files.

Anti-pattern guards verified:
- /api/sessions/observations only reached via explicit fallback path
- server-beta runtime never starts the worker process
- API keys live only in ~/.claude-mem/settings.json (chmod 0600), never
  in the bundle (grep confirmed)
- Worker fallback preserved, observable via single WARN line per call

Deferred: semantic context injection (UserPromptSubmit hook) stays
worker-only; server-beta does not yet expose /v1/context/semantic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 8 — MCP backed by server-beta core

MCP tools now route through server-beta in server-beta mode while keeping
worker-mode search/timeline/get_observations tools fully working.

- src/servers/mcp-server.ts — five new observation_* tools registered:
  observation_add, observation_record_event, observation_search,
  observation_context, observation_generation_status. Three memory_*
  compatibility aliases delegate to the canonical handlers. Worker
  auto-start is gated when selectRuntime() === 'server-beta' so MCP
  in server-beta mode never spawns the worker.
- src/services/hooks/server-beta-client.ts — addObservation,
  searchObservations, contextObservations, getJobStatus added so MCP
  shares one transport with hooks (Phase 7).
- src/server/routes/v1/ServerV1PostgresRoutes.ts — POST /v1/search and
  POST /v1/context REST cores backed by PostgresObservationRepository
  full-text search (GIN tsvector from Phase 1).
- Existing memory_search/timeline/get_observations tools call
  callWorkerAPI unchanged in worker mode; worker tests unaffected.

Verification: 39 pass / 4 skip / 0 fail on targeted suite. Pre-existing
7 baseline failures verified independent (git stash). No new typecheck
errors. WorkerService grep clean across src/servers/mcp-server.ts and
src/server/.

Anti-pattern guards verified:
- No duplicate generation logic in MCP — observation_record_event hits
  /v1/events which owns event+outbox+enqueue inside one tx
- WorkerService not imported anywhere under MCP server-beta path
- No hardcoded worker URLs — all transport via Phase 7 ServerBetaClient
- memory_* aliases retained, single handler per pair

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 9 — compatibility adapters without coupling

Legacy /api/sessions/observations and /api/sessions/summarize endpoints
keep working on server-beta runtime by translating to AgentEvent and
session-end calls — no worker code, no route duplication.

- src/server/services/IngestEventsService.ts — shared event-ingest path
  used by both /v1/events and the compat adapter. Owns transactional
  event row + outbox row + lifecycle log + post-commit BullMQ enqueue,
  honors Phase 6 SessionGenerationPolicy.
- src/server/services/EndSessionService.ts — shared session-end path
  used by both /v1/sessions/:id/end and the compat adapter. Idempotent
  ended_at + summary outbox + deterministic summary job id.
- src/server/compat/SessionsObservationsAdapter.ts — translates legacy
  POST /api/sessions/observations payload (Claude Code transcript shape)
  -> AgentEvent (source_adapter='claude-code-compat',
  event_type='tool_use') -> IngestEventsService.ingestOne. Resolves
  contentSessionId to server_sessions via find-or-create.
- src/server/compat/SessionsSummarizeAdapter.ts — translates legacy
  POST /api/sessions/summarize -> EndSessionService.end. Preserves the
  legacy agentId -> {status:'skipped', reason:'subagent_context'}
  behavior so existing clients see the same response shape.
- src/server/routes/v1/ServerV1PostgresRoutes.ts — refactored to
  delegate to the new shared services (-203 LoC net) so /v1 and
  /api compat both call the SAME canonical code path.
- src/server/runtime/ServerBetaService.ts — registers both compat
  adapters alongside ServerV1PostgresRoutes, sharing service instances.
- docs/server-beta-parity-map.md — full enumeration of legacy /api/*
  routes labeled native, adapter, or unsupported (with reasons).
  Viewer read-path adapters explicitly listed as unsupported pending
  a future viewer-rewrite phase.

Verification: 7 compat tests pass, 6 v1-routes tests still pass
(refactor preserved behavior), 4 session-routes tests pass. Pre-
existing 16 baseline failures verified independent via git stash.
Zero new typecheck errors.

Anti-pattern guards verified:
- No services/worker/http/routes or WorkerService imports under
  src/server/compat or src/server/runtime
- Compat adapters are thin translators with names ending in *Adapter
  and a top-of-file comment noting they are legacy compatibility
- /v1/* remains the canonical Server beta API; compat adapters
  call shared services rather than acting as a parallel API

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 10 — Docker stack and deployable runtime

Server beta now ships as a Docker stack with no worker process anywhere
and a separate horizontal generation worker for scaling.

- src/server/runtime/create-server-beta-service.ts — validateServerBetaEnv()
  fails fast on missing CLAUDE_MEM_SERVER_DATABASE_URL, requires
  CLAUDE_MEM_QUEUE_ENGINE=bullmq in Docker, rejects
  CLAUDE_MEM_AUTH_MODE=local-dev and CLAUDE_MEM_ALLOW_LOCAL_DEV_BYPASS
  inside containers (detected via /.dockerenv or CLAUDE_MEM_DOCKER=1).
  Adds CLAUDE_MEM_GENERATION_DISABLED so the HTTP service can run
  generator-free.
- src/server/runtime/ServerBetaService.ts — runServerBetaGenerationWorker
  for the dedicated consumer process; runServerBetaApiKeyCli is a new
  Postgres-backed `server api-key` command (the legacy worker CLI wrote
  to SQLite and was invisible to the Postgres runtime); getQueueHealth
  shim feeds /api/health a consistent ObservationQueueHealth shape.
- src/npx-cli/commands/{runtime,server}.ts — `claude-mem server worker
  start` subcommand that boots only the BullMQ consumer.
- docker/claude-mem/{Dockerfile,entrypoint.sh} — entrypoint forces
  CLAUDE_MEM_DOCKER=1 + CLAUDE_MEM_RUNTIME=server-beta and exposes
  three modes: server (HTTP only, generation disabled), worker (BullMQ
  consumer), shell. Worker bundle is no longer the default CMD.
- docker-compose.yml — full stack: postgres + valkey + claude-mem-server
  (HTTP-only) + claude-mem-worker (generation consumer). Wires
  service-to-service env vars.
- scripts/e2e-server-beta-docker.sh + docker/e2e/server-beta-e2e.mjs —
  E2E now hits /v1/sessions/start, /v1/events?wait=true, /v1/jobs/:id;
  asserts no worker-service.cjs process anywhere in the stack;
  one-shot docker compose run --rm verifies local-dev auth is
  rejected with the expected stderr; restart-and-verify confirms
  Postgres durability and BullMQ retry idempotency.
- docs/server.md — full Phase 10 doc: stack diagram, env table,
  worker mode, auth-in-Docker policy.
- docs/api.md — event generation semantics (wait=true, generationJob).

Verification: full Docker E2E PASSED on live daemon
(phase1 + phase2 + restart-and-verify + revoked-key + no-worker-
process + local-dev-rejected). Unit tests 292 pass / 9 skip / 7 fail
(7 fails pre-existing baseline). Zero new typecheck errors.

Anti-pattern guards verified:
- entrypoint never execs worker-service.cjs; E2E greps prove no
  worker process anywhere in the stack
- validateServerBetaEnv refuses local-dev auth in Docker with explicit
  remediation message; ALLOW_LOCAL_DEV_BYPASS rejected the same way
- Docker requires CLAUDE_MEM_QUEUE_ENGINE=bullmq; in-process queue
  rejected at startup
- claude-mem worker / worker-service / WorkerService greps clean
  in docker/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 11 — team-aware generation with audit chain

Generation jobs now carry team_id/project_id/api_key_id/actor_id/
source_adapter from enqueue through execution; the outbox is reloaded
from Postgres before any side effect so BullMQ payload can never act
as auth authority.

- src/server/jobs/types.ts — ServerGenerationJobPayloadSchema (Zod
  discriminated union) requires team_id, project_id, generation_job_id,
  source_adapter, api_key_id, actor_id (nullable), source_type, source_id,
  plus event_id / server_session_id per kind. assertServerGenerationJobPayload
  is called at enqueue (outbox.ts) and again at execution boundary.
- src/server/services/{IngestEventsService,EndSessionService}.ts +
  SessionGenerationPolicy.ts — thread identity context (apiKeyId, actorId,
  sourceAdapter) into both event and summary BullMQ payloads.
- src/server/generation/ProviderObservationGenerator.ts —
  loadCanonicalOutbox loads the outbox row WITHOUT scope filter, then
  compares candidate.team_id/project_id to payload.team_id/project_id;
  mismatch -> ServerGenerationScopeViolationError (non-retryable),
  failed status, generation_job.scope_violation audit. isApiKeyRevoked
  checks api_keys (revoked_at, expires_at, row missing) before any
  provider call; revoked -> generation_job.revoked_key audit + non-
  retryable failure. generation_job.processing audit emitted on lock.
- src/server/generation/processGeneratedResponse.ts — generated
  observations carry team_id/project_id/server_session_id from the
  reloaded source row (not job payload). observation_sources.metadata
  records source_adapter, actor_id, api_key_id for traceability.
  observation.created audit per observation; generation_job.completed
  audit per terminal transition. All audit rows reference the same
  generation_job_id in details.
- src/server/routes/v1/ServerV1PostgresRoutes.ts — GET /v1/teams/:id/jobs
  and GET /v1/projects/:id/jobs with SQL-layer scoping (WHERE team_id=$1
  [AND project_id=$2] [AND status=$3]); cross-tenant returns 404 to
  avoid leaking row existence. Pagination via status/limit/offset.
  audit_log rows for event.received, event.batch_received, observation.read.
- src/server/compat/{SessionsObservationsAdapter,SessionsSummarizeAdapter}.ts —
  propagate apiKeyId and sourceAdapter='claude-code-compat'.

Verification: 162 pass / 10 skip / 0 fail. Pre-existing failures in
tests/services/queue and tests/services/worker confirmed independent
via git stash. Zero new typecheck errors in server-beta files.
Required greps:
  rg "team_id.*req\.body|project_id.*req\.body" src/server -> 0 matches
Audit chain integration test passes — generation_job.processing,
observation.created, and generation_job.completed audit rows all
share the same generation_job_id reference.

Anti-pattern guards verified:
- BullMQ payload never acts as auth authority — Postgres outbox
  reload with mismatch check happens before every side effect
- team_id / project_id never derived from request body for scope
  decisions; always req.authContext.teamId / projectId
- Application-layer team/project filtering forbidden — listJobsForScope
  pushes scope into the SQL WHERE clause
- Project-scoped key on cross-project /v1/teams/:id/jobs returns 404
- Revoked api keys cause non-retryable failure with audit before
  any provider call

Deferred: a redundant generation_job.queued audit_log row (already
covered by observation_generation_job_events lifecycle log per Phase 1
schema split). Compat adapters set actor_id=null but propagate
api_key_id which is the canonical reference downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-beta): Phase 12 — observability and operations

Operators can now inspect, retry, and cancel generation jobs from the
CLI; queue lane metrics flow into /api/health and /v1/info; every
request gets a stable request_id that flows through HTTP -> audit ->
outbox -> generator -> completion log.

- src/server/middleware/request-id.ts — honors safe inbound X-Request-Id,
  mints uuid v4 otherwise. Set on req.requestId and echoed via response
  header so external traces can correlate.
- src/server/jobs/ServerJobQueue.ts — QueueEvents wired with completed,
  failed, progress, stalled, error listeners; lifecycle counters
  exposed via observe() API. Logs emitted as
  [generation] job=<id> source_type=<...> duration=<ms> attempts=<N>
  reason=<message>. Stalled and error counters survive worker restart.
- src/server/jobs/types.ts — ServerGenerationJob payload schema
  extended with optional request_id; flows through from HTTP into
  every BullMQ job.
- src/server/queue/ObservationQueueEngine.ts — health snapshot now
  carries per-lane (event, summary) counts via
  ObservationQueueHealthLaneSnapshot.
- src/server/runtime/{ActiveServerBetaQueueManager,
  ActiveServerBetaGenerationWorkerManager,ServerBetaService}.ts —
  per-lane getJobCounts feed /api/health and /v1/info; stalled events
  audit through audit_log with action generation_job.stalled.
- src/server/routes/v1/ServerV1PostgresRoutes.ts —
  GET /v1/jobs (status/source_type/since/limit/offset, scope from
  api-key, payload stripped unless ?include=payload AND admin scope),
  POST /v1/jobs/:id/retry (idempotent; queued -> no-op; audit
  generation_job.retried_by_operator), POST /v1/jobs/:id/cancel
  (terminal -> no-op; audit generation_job.cancelled_by_operator;
  generator reload-before-side-effects already prevents double work).
- src/server/services/IngestEventsService.ts +
  SessionGenerationPolicy.ts + ProviderObservationGenerator.ts —
  request_id propagated end to end. Generator extracts request_id
  from BullMQ payload and includes it in lock/processing/completion
  logs and audit details.
- src/npx-cli/commands/server-jobs.ts +
  src/npx-cli/commands/server.ts — `claude-mem server jobs
  status|failed|retry|cancel`. status compares Postgres outbox counts
  to BullMQ queue counts and surfaces divergence. failed prints
  attempts + last_error message. --team and --project filters.

Verification: 350 pass / 12 skip / 7 fail (pre-existing baseline,
verified independent via git stash). 18 new tests added (request-id
middleware, server-jobs CLI seams, jobs list/retry/cancel routes
Postgres-gated). Zero new typecheck errors.

Anti-pattern guards verified:
- agent_events.payload only emitted in /v1/jobs response inside the
  admin-gated branch (?include=payload + admin scope) — returns 403
  otherwise
- jobs retry on a queued row is a no-op (no double BullMQ enqueue,
  no double UPDATE)
- Every operator action writes to audit_log with the
  *_by_operator action and request_id correlation in details
- Stalled events audit through generation_job.stalled

Sample correlated trace (one request_id end to end):
  HTTP middleware: req.requestId = 'req-abc'
  audit event.received: details.requestId = 'req-abc'
  BullMQ payload: { request_id: 'req-abc', generation_job_id: 'gj_x' }
  generator lock log: [generation] job locked { jobId, requestId }
  audit generation_job.processing: details.requestId = 'req-abc'
  completion log: [generation] job=evt_... duration=1230ms

Deferred: live /api/health round-trip integration test (needs
Redis); stalled event live integration test (needs Redis); storing
request_id on the observations row itself (spec did not require).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(server-beta): add Phase 13 release readiness report

Captures the final verification gate: tests (1749 pass, 45 fail all
pre-existing baseline, zero regressions), required greps clean,
Docker E2E green end-to-end, all 7 exit criteria met, build clean,
typecheck unchanged from main. Documents deferred items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build(server-beta): rebuild server-beta-service bundle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): address Greptile review on PR #2383

- ProviderObservationGenerator.lockOutbox: skip duplicate worker run when
  another lock is active instead of returning the row, which previously let
  two BullMQ workers issue the (paid, rate-limited) external provider call
  before the persistence-layer terminal-status guard collapsed the duplicate.
  Reconciliation still recovers from a stale lock on startup or next retry.
- docker-compose.yml: require POSTGRES_USER/PASSWORD/DB env vars (no
  defaults). Stack refuses to start without explicit secrets. Added a header
  warning that the file must not be deployed unmodified.
- e2e-server-beta-docker.sh: export ephemeral test creds for the new
  required env vars so the Docker E2E driver still runs unattended.
- ServerBetaService api-key list: bound query with LIMIT/OFFSET (default 100,
  max 500) and add optional --team filter to prevent unintentional
  cross-tenant key metadata disclosure on shared admin hosts.
- SessionGenerationPolicy: fix dead `??` fallback for NaN parseInt result;
  use `||` so DEFAULT_DEBOUNCE_MS actually applies.
- ServerV1PostgresRoutes: `?wait=true` now actually waits — polls the outbox
  row until terminal status (timeout 30s, 100ms interval) on both
  /v1/events and /v1/events/batch. Returns `waitTimedOut: true` if the cap
  is hit so callers can re-poll the status endpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): address CodeRabbit + Greptile second review on PR #2383

P1 fixes
- Operator retry endpoint was re-publishing the Postgres outbox metadata
  column as the BullMQ payload; the worker's
  assertServerGenerationJobPayload always rejected it, leaving the row
  stuck in queued until startup reconciliation. Persist the BullMQ payload
  on the outbox row at create-time inside IngestEventsService and
  EndSessionService, then re-enqueue that canonical payload on retry.

Major fixes
- prompt-builder: escape server_session_id when interpolating into the
  XML prompt; previously a session id containing `<`, `&`, or quotes
  could inject XML into the provider input.
- ServerJobQueue: route both worker.on('stalled') and the QueueEvents
  'stalled' subscriber through a single notifyStalled helper that
  dedupes by jobId for 30s, so counters.stalled increments once per
  stall. QueueEvents 'error' now routes through notifyQueueError so
  it increments counters.errored and runs onError listeners — keeping
  observability symmetric across both sources.
- ServerV1PostgresRoutes: convert PostgresObservationRepository from
  three dynamic imports to a single static import for consistency.
- mcp-server / ServerBetaClient: actually forward the
  observation_record_event tool's `generate` flag through to the
  /v1/events endpoint as `?generate=false` instead of voiding it.
- server-sessions.markGenerationFailed: guard jsonb_set against a null
  error payload so the failure path can't null out metadata before the
  generation_status='failed' write commits.

Minor fixes
- server-sessions.endSession: keep updated_at stable on repeated calls
  so the documented idempotency contract holds.
- SettingsDefaultsManager + ServerBetaService.getServerBetaPort: derive
  the server-beta default port from UID (37877 + uid%100), matching the
  worker port pattern, so two users on the same host don't collide.
  Docker stacks always pass CLAUDE_MEM_SERVER_PORT explicitly so the
  containerized deployment is unaffected.
- server-session-runtime test: close the pg.Pool in afterAll.
- server-beta-release-readiness.md: escape pipes inside table inline
  code, add `text` language tag to the fenced log block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): address Greptile + CodeRabbit third review on PR #2383

P1 fixes
- SessionsObservationsAdapter.resolveServerSession: catch unique-violation
  (23505) on concurrent compat inserts and re-fetch instead of returning
  500. Two compat callers carrying the same contentSessionId can both
  observe `existing===null` and race on the (project_id,
  external_session_id) unique constraint; the second now resolves to the
  raced row instead of dropping the event.
- /v1/events/batch: pass `sourceAdapter: null` to ingestBatch so each
  event's BullMQ payload (and persisted outbox payload column) reflects
  its own event.sourceAdapter via buildEventBullmqPayload's fallback,
  rather than stamping the whole batch with the first event's adapter.

Minor
- server-session-runtime test afterEach: wrap DROP SCHEMA in try/finally
  so client.release() always runs even if the drop throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): drop `pool as never` cast — pg.Pool already matches PostgresPool

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): retry of completed job now 409s instead of duplicating

retryGenerationJob previously fell through to the reset+re-enqueue path
when called on a job in `completed` status. The observations index
dedupes on (generation_job_id, parsed_observation_index, content) but
LLM output is non-deterministic, so a second provider run almost always
produced a different content string and bypassed the index, persisting a
parallel set of observation rows attributed to the same generation job.

Match cancelGenerationJob's 409 guard for completed jobs. failed and
cancelled remain valid retry targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build(server-beta): rebuild bundles after rebase onto main

Regenerates the three plugin bundles so they reflect the rebased source
state. Mechanical rebuild output only — no source changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): wrap resolveServerSession in try/catch for structured error response

Greptile P1 on PR #2383: resolveServerSession was called before the try/catch
in both compat adapters, so Postgres errors during session lookup (timeout,
pool exhaustion, etc.) escaped to Express's default error handler and returned
HTML/text 500s. Legacy clients calling response.json() would get a parse
failure instead of the documented { stored: false, reason: 'internal_error' }
(or { status: 'error', reason: 'internal_error' } for the summarize adapter)
shape.

Move the resolveServerSession call inside the existing try block in both
adapters so any failure flows through the structured catch handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-beta): catch 23505 unique violation in POST /v1/sessions/start

Greptile P1 on PR #2383: concurrent requests with the same externalSessionId
can both pass the findByExternalIdForScope check, both call repo.create,
and the loser hits the (project_id, external_session_id) unique constraint.
The handler treated that as an unknown error and returned a 500.

Apply the same pattern resolveServerSession already uses: catch error.code
'23505' when externalSessionId is set, refetch the row inserted by the
winning request, and return 200 with that session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-05-11 00:26:11 -07:00
committed by GitHub
parent a10d1b342f
commit e7bbb2a9aa
72 changed files with 13901 additions and 982 deletions
+243
View File
@@ -0,0 +1,243 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import { logger } from '../../src/utils/logger.js';
import {
__clearServerJobsTestSeams,
__setServerJobsTestSeams,
runServerJobsCommand,
} from '../../src/npx-cli/commands/server-jobs.js';
// Phase 12 — `claude-mem server jobs` operator console. Uses the
// __setServerJobsTestSeams test seam (preferred over mock.module which leaks
// across Bun test files). Each test wires its own pool + bullmq fakes.
interface MockQueryCall { sql: string; params: unknown[] }
interface MockPool {
query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }>;
}
function buildMockPool(rowsFor: (sql: string, params: unknown[]) => unknown[]): { pool: MockPool; calls: MockQueryCall[] } {
const calls: MockQueryCall[] = [];
return {
calls,
pool: {
query: async (sql: string, params: unknown[] = []) => {
calls.push({ sql, params });
return { rows: rowsFor(sql, params) };
},
},
};
}
describe('Phase 12 — server jobs CLI', () => {
const originalEnv = { ...process.env };
let logSpies: ReturnType<typeof spyOn>[] = [];
let consoleLogSpy: ReturnType<typeof spyOn>;
let consoleErrSpy: ReturnType<typeof spyOn>;
let exitSpy: ReturnType<typeof spyOn>;
let exitCalls: number[] = [];
beforeEach(() => {
logSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
];
consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {});
consoleErrSpy = spyOn(console, 'error').mockImplementation(() => {});
exitCalls = [];
exitSpy = spyOn(process, 'exit').mockImplementation((code?: number) => {
exitCalls.push(code ?? 0);
throw new Error(`__exit_${code ?? 0}__`);
}) as never;
process.env.CLAUDE_MEM_SERVER_DATABASE_URL = 'postgres://test/test';
process.env.CLAUDE_MEM_SERVER_ADMIN = '1';
});
afterEach(() => {
__clearServerJobsTestSeams();
logSpies.forEach(s => s.mockRestore());
consoleLogSpy.mockRestore();
consoleErrSpy.mockRestore();
exitSpy.mockRestore();
process.env = { ...originalEnv };
mock.restore();
});
it('refuses unscoped operations without admin override', async () => {
delete process.env.CLAUDE_MEM_SERVER_ADMIN;
await expect(runServerJobsCommand(['status'])).rejects.toThrow(/__exit_1__/);
expect(exitCalls).toContain(1);
const errMsg = consoleErrSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(errMsg).toMatch(/Refusing to run unscoped/);
});
it('status divergence: surfaces postgres counts when bullmq is unavailable', async () => {
const mockData = buildMockPool((sql: string) => {
if (sql.includes('GROUP BY status')) {
return [{ status: 'queued', count: 3 }, { status: 'failed', count: 1 }];
}
return [];
});
__setServerJobsTestSeams({
openPool: async () => ({ pool: mockData.pool as never, releasePool: async () => {} }),
collectBullmqCounts: async () => { throw new Error('bullmq unavailable'); },
});
await runServerJobsCommand(['status', '--team', 'team-1']);
const printed = consoleLogSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(printed).toMatch(/"queued": 3/);
expect(printed).toMatch(/"failed": 1/);
expect(printed).toMatch(/"unavailable": true/);
});
it('status: detects divergence between postgres and bullmq counts', async () => {
const mockData = buildMockPool((sql: string) => {
if (sql.includes('GROUP BY status')) {
return [{ status: 'queued', count: 5 }, { status: 'failed', count: 2 }];
}
return [];
});
__setServerJobsTestSeams({
openPool: async () => ({ pool: mockData.pool as never, releasePool: async () => {} }),
collectBullmqCounts: async () => ({
event: { waiting: 1, active: 0, completed: 0, failed: 0, delayed: 0, stalled: 0 },
summary: { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0, stalled: 0 },
}),
});
await runServerJobsCommand(['status', '--team', 'team-1']);
const printed = consoleLogSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(printed).toMatch(/"queuedMismatch"/);
expect(printed).toMatch(/"postgres": 5/);
expect(printed).toMatch(/"bullmq": 1/);
expect(printed).toMatch(/"failedMismatch"/);
});
it('failed: lists failed jobs with last_error.message extracted', async () => {
const mockData = buildMockPool((sql: string) => {
if (sql.includes('status = \'failed\'')) {
return [{
id: 'gj_1',
source_type: 'agent_event',
source_id: 'ev_1',
attempts: 3,
failed_at: new Date('2026-05-08T12:00:00Z'),
last_error: { message: 'provider timeout' },
team_id: 'team-1',
project_id: 'p-1',
}];
}
return [];
});
__setServerJobsTestSeams({
openPool: async () => ({ pool: mockData.pool as never, releasePool: async () => {} }),
collectBullmqCounts: async () => { throw new Error('not configured'); },
});
await runServerJobsCommand(['failed', '--team', 'team-1']);
const printed = consoleLogSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(printed).toMatch(/"id": "gj_1"/);
expect(printed).toMatch(/"lastError": "provider timeout"/);
expect(printed).toMatch(/"attempts": 3/);
});
it('retry: idempotent on already-queued jobs (no UPDATE issued)', async () => {
const mockData = buildMockPool((sql: string) => {
if (sql.includes('SELECT id, team_id, project_id, status')) {
return [{
id: 'gj_1',
team_id: 'team-1',
project_id: 'p-1',
status: 'queued',
attempts: 0,
bullmq_job_id: 'evt_abc',
source_type: 'agent_event',
payload: { retried_count: 0 },
}];
}
return [];
});
let republishCalled = false;
__setServerJobsTestSeams({
openPool: async () => ({ pool: mockData.pool as never, releasePool: async () => {} }),
republishToBullmq: async () => { republishCalled = true; },
});
await runServerJobsCommand(['retry', 'gj_1', '--team', 'team-1']);
const printed = consoleLogSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(printed).toMatch(/"outcome": "noop_already_queued"/);
// Idempotent: no UPDATE/republish.
const updates = mockData.calls.filter(c => /^\s*UPDATE/m.test(c.sql));
expect(updates.length).toBe(0);
expect(republishCalled).toBe(false);
});
it('retry: re-enqueues a failed job and increments retried_count', async () => {
const calls: { sql: string; params: unknown[] }[] = [];
const pool: MockPool = {
query: async (sql: string, params: unknown[] = []) => {
calls.push({ sql, params });
if (sql.includes('SELECT id, team_id, project_id, status')) {
return { rows: [{
id: 'gj_failed',
team_id: 'team-1',
project_id: 'p-1',
status: 'failed',
attempts: 2,
bullmq_job_id: 'evt_abc',
source_type: 'agent_event',
payload: { retried_count: 1 },
}] };
}
if (sql.includes('UPDATE observation_generation_jobs')) {
return { rows: [{
id: 'gj_failed', status: 'queued', attempts: 2,
bullmq_job_id: 'evt_abc', source_type: 'agent_event',
}] };
}
return { rows: [] };
},
};
let republishCalled = false;
let republishedPayload: Record<string, unknown> = {};
__setServerJobsTestSeams({
openPool: async () => ({ pool: pool as never, releasePool: async () => {} }),
republishToBullmq: async (_st, _id, payload) => {
republishCalled = true;
republishedPayload = payload;
},
});
await runServerJobsCommand(['retry', 'gj_failed', '--team', 'team-1']);
const printed = consoleLogSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(printed).toMatch(/"outcome": "requeued"/);
expect(printed).toMatch(/"retriedCount": 2/);
expect(republishCalled).toBe(true);
expect(republishedPayload.retried_count).toBe(2);
// Lifecycle event row + audit row both inserted.
const inserts = calls.filter(c => /INSERT INTO/i.test(c.sql));
expect(inserts.length).toBeGreaterThanOrEqual(2);
});
it('cancel: refuses to cancel a completed job', async () => {
const mockData = buildMockPool((sql: string) => {
if (sql.includes('SELECT id, team_id, project_id, status')) {
return [{
id: 'gj_done',
team_id: 'team-1',
project_id: 'p-1',
status: 'completed',
attempts: 1,
bullmq_job_id: null,
source_type: 'agent_event',
payload: {},
}];
}
return [];
});
__setServerJobsTestSeams({
openPool: async () => ({ pool: mockData.pool as never, releasePool: async () => {} }),
});
await expect(runServerJobsCommand(['cancel', 'gj_done', '--team', 'team-1'])).rejects.toThrow(/__exit_1__/);
const errMsg = consoleErrSpy.mock.calls.map(c => String(c[0])).join('\n');
expect(errMsg).toMatch(/Cannot cancel a completed job/);
});
});
@@ -0,0 +1,358 @@
// SPDX-License-Identifier: Apache-2.0
// Phase 9 — compat adapter tests. Two layers:
// 1. Unit: validate the legacy → AgentEvent translation produced by the
// adapter when invoked through HTTP, using the same test harness as
// `tests/server/runtime/server-session-routes.test.ts`.
// 2. Integration: end-to-end through compat → IngestEventsService → Postgres,
// checking outbox row + BullMQ enqueue captured by a fake queue.
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import pg from 'pg';
import { createHash, randomBytes } from 'crypto';
import { Server } from '../../src/services/server/Server.js';
import { ServerV1PostgresRoutes } from '../../src/server/routes/v1/ServerV1PostgresRoutes.js';
import { SessionsObservationsAdapter } from '../../src/server/compat/SessionsObservationsAdapter.js';
import { SessionsSummarizeAdapter } from '../../src/server/compat/SessionsSummarizeAdapter.js';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
type PostgresPoolClient,
type PostgresStorageRepositories,
} from '../../src/storage/postgres/index.js';
import { DisabledServerBetaQueueManager } from '../../src/server/runtime/types.js';
import { logger } from '../../src/utils/logger.js';
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
function newApiKey(): { raw: string; hash: string } {
const raw = `cm_${randomBytes(24).toString('hex')}`;
const hash = createHash('sha256').update(raw).digest('hex');
return { raw, hash };
}
describe('Phase 9 compat adapters', () => {
if (!testDatabaseUrl) {
it.skip('requires CLAUDE_MEM_TEST_POSTGRES_URL', () => {});
return;
}
let pool: pg.Pool;
let client: PostgresPoolClient;
let schemaName: string;
let storage: PostgresStorageRepositories;
let server: Server;
let port: number;
let teamId: string;
let projectId: string;
let apiKeyRaw: string;
let projectScopedApiKey: string;
let enqueuedEventJobs: { id: string; payload: unknown }[] = [];
let enqueuedSummaryJobs: { id: string; payload: unknown }[] = [];
let loggerSpies: ReturnType<typeof spyOn>[] = [];
beforeEach(async () => {
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
];
pool = new pg.Pool({ connectionString: testDatabaseUrl });
client = await pool.connect();
schemaName = `cm_phase9_${crypto.randomUUID().replaceAll('-', '_')}`;
await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
await bootstrapServerBetaPostgresSchema(client);
pool.on('connect', (poolClient) => {
poolClient.query(`SET search_path TO ${quoteIdentifier(schemaName)}`).catch(() => {});
});
storage = createPostgresStorageRepositories(client);
const team = await storage.teams.create({ name: 'team-phase9' });
const project = await storage.projects.create({ teamId: team.id, name: 'phase9-project' });
teamId = team.id;
projectId = project.id;
// Team-scoped key (no project): /v1/events allowed; compat refused.
const teamKey = newApiKey();
apiKeyRaw = teamKey.raw;
await storage.auth.createApiKey({
keyHash: teamKey.hash,
teamId,
actorId: 'test',
scopes: ['memories:read', 'memories:write'],
});
// Project-scoped key (required by compat).
const projKey = newApiKey();
projectScopedApiKey = projKey.raw;
await storage.auth.createApiKey({
keyHash: projKey.hash,
teamId,
projectId,
actorId: 'test',
scopes: ['memories:read', 'memories:write'],
});
enqueuedEventJobs = [];
enqueuedSummaryJobs = [];
server = new Server({
getInitializationComplete: () => true,
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker.cjs',
runtime: 'server-beta',
getAiStatus: () => ({ provider: 'disabled', authMethod: 'api-key', lastInteraction: null }),
});
const v1Routes = new ServerV1PostgresRoutes({
pool: pool as never,
queueManager: new DisabledServerBetaQueueManager('disabled in tests'),
authMode: 'api-key',
runtime: 'server-beta',
sessionPolicy: 'per-event',
getEventQueue: () => ({
async add(jobId: string, payload: unknown) {
enqueuedEventJobs.push({ id: jobId, payload });
},
async getJob() { return null; },
async remove() {},
}) as never,
getSummaryQueue: () => ({
async add(jobId: string, payload: unknown) {
enqueuedSummaryJobs.push({ id: jobId, payload });
},
async getJob() { return null; },
async remove() {},
}) as never,
});
server.registerRoutes(v1Routes);
server.registerRoutes(new SessionsObservationsAdapter({
pool: pool as never,
ingestEvents: v1Routes.getIngestEventsService(),
authMode: 'api-key',
}));
server.registerRoutes(new SessionsSummarizeAdapter({
pool: pool as never,
endSession: v1Routes.getEndSessionService(),
authMode: 'api-key',
}));
server.finalizeRoutes();
await server.listen(0, '127.0.0.1');
const address = server.getHttpServer()?.address();
if (!address || typeof address === 'string') throw new Error('no port');
port = address.port;
});
afterEach(async () => {
try { await server.close(); } catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== 'ERR_SERVER_NOT_RUNNING') throw error;
}
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
client.release();
await pool.end();
loggerSpies.forEach(spy => spy.mockRestore());
mock.restore();
});
function authedFetch(rawKey: string, path: string, init: RequestInit = {}): Promise<Response> {
return fetch(`http://127.0.0.1:${port}${path}`, {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Bearer ${rawKey}`,
'Content-Type': 'application/json',
},
});
}
it('POST /api/sessions/observations creates event + outbox + enqueues, with legacy response shape', async () => {
const response = await authedFetch(projectScopedApiKey, '/api/sessions/observations', {
method: 'POST',
body: JSON.stringify({
contentSessionId: 'cc-session-uuid-1',
tool_name: 'Read',
tool_input: { file_path: '/x/y' },
tool_response: 'ok',
cwd: '/x',
platformSource: 'claude-code',
toolUseId: 'tu_abc',
}),
});
expect(response.status).toBe(200);
const body = await response.json();
// Legacy clients only check `status`; new clients can read the rest.
expect(body.status).toBe('queued');
expect(body.observationCount).toBe(1);
expect(typeof body.serverSessionId).toBe('string');
expect(typeof body.eventId).toBe('string');
expect(body.transport).toBe('enqueued');
expect(enqueuedEventJobs.length).toBe(1);
// Confirm the event row landed and references the new server_session.
const eventRows = await client.query(
`SELECT id, source_adapter, event_type, server_session_id, payload
FROM agent_events WHERE id = $1`,
[body.eventId],
);
expect(eventRows.rows.length).toBe(1);
const evt = eventRows.rows[0] as {
source_adapter: string;
event_type: string;
server_session_id: string;
payload: { tool_name: string };
};
expect(evt.source_adapter).toBe('claude-code-compat');
expect(evt.event_type).toBe('tool_use');
expect(evt.server_session_id).toBe(body.serverSessionId);
expect(evt.payload.tool_name).toBe('Read');
// Outbox row was created.
const outboxRows = await client.query(
`SELECT id, source_type, source_id FROM observation_generation_jobs WHERE agent_event_id = $1`,
[body.eventId],
);
expect(outboxRows.rows.length).toBe(1);
expect((outboxRows.rows[0] as { source_type: string }).source_type).toBe('agent_event');
});
it('POST /api/sessions/observations rejects team-scoped API keys with 400 (project scope required for compat)', async () => {
const response = await authedFetch(apiKeyRaw, '/api/sessions/observations', {
method: 'POST',
body: JSON.stringify({
contentSessionId: 'cc-session-uuid-2',
tool_name: 'Read',
}),
});
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe('BadRequest');
expect(enqueuedEventJobs.length).toBe(0);
});
it('POST /api/sessions/observations is idempotent on contentSessionId — same server_session reused', async () => {
const r1 = await authedFetch(projectScopedApiKey, '/api/sessions/observations', {
method: 'POST',
body: JSON.stringify({
contentSessionId: 'cc-shared-session',
tool_name: 'Read',
cwd: '/x',
}),
});
const b1 = await r1.json();
const r2 = await authedFetch(projectScopedApiKey, '/api/sessions/observations', {
method: 'POST',
body: JSON.stringify({
contentSessionId: 'cc-shared-session',
tool_name: 'Edit',
cwd: '/x',
}),
});
const b2 = await r2.json();
expect(b1.serverSessionId).toBe(b2.serverSessionId);
// Two events, two outbox rows.
expect(enqueuedEventJobs.length).toBe(2);
});
it('POST /api/sessions/summarize ends server_session and enqueues summary job (legacy response shape)', async () => {
// Seed an observation first so a server_session exists for this contentSessionId.
await authedFetch(projectScopedApiKey, '/api/sessions/observations', {
method: 'POST',
body: JSON.stringify({
contentSessionId: 'cc-summarize-session',
tool_name: 'Read',
cwd: '/x',
}),
});
const response = await authedFetch(projectScopedApiKey, '/api/sessions/summarize', {
method: 'POST',
body: JSON.stringify({
contentSessionId: 'cc-summarize-session',
last_assistant_message: 'final reply',
platformSource: 'claude-code',
}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body.status).toBe('queued');
expect(typeof body.serverSessionId).toBe('string');
expect(typeof body.generationJobId).toBe('string');
expect(body.transport).toBe('enqueued');
expect(enqueuedSummaryJobs.length).toBe(1);
// Confirm session ended + outbox row.
const sessionRows = await client.query(
`SELECT ended_at FROM server_sessions WHERE id = $1`,
[body.serverSessionId],
);
expect(sessionRows.rows.length).toBe(1);
expect((sessionRows.rows[0] as { ended_at: Date | null }).ended_at).not.toBeNull();
const outboxRows = await client.query(
`SELECT source_type FROM observation_generation_jobs WHERE id = $1`,
[body.generationJobId],
);
expect((outboxRows.rows[0] as { source_type: string }).source_type).toBe('session_summary');
});
it('POST /api/sessions/summarize with agentId returns subagent_context skip without enqueuing', async () => {
const response = await authedFetch(projectScopedApiKey, '/api/sessions/summarize', {
method: 'POST',
body: JSON.stringify({
contentSessionId: 'cc-subagent',
agentId: 'subagent-123',
}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body.status).toBe('skipped');
expect(body.reason).toBe('subagent_context');
expect(enqueuedSummaryJobs.length).toBe(0);
});
it('POST /api/sessions/summarize is idempotent on re-summarize (same outbox row)', async () => {
await authedFetch(projectScopedApiKey, '/api/sessions/observations', {
method: 'POST',
body: JSON.stringify({ contentSessionId: 'cc-resum', tool_name: 'Read', cwd: '/x' }),
});
const r1 = await authedFetch(projectScopedApiKey, '/api/sessions/summarize', {
method: 'POST',
body: JSON.stringify({ contentSessionId: 'cc-resum' }),
});
const b1 = await r1.json();
const r2 = await authedFetch(projectScopedApiKey, '/api/sessions/summarize', {
method: 'POST',
body: JSON.stringify({ contentSessionId: 'cc-resum' }),
});
const b2 = await r2.json();
expect(b1.generationJobId).toBe(b2.generationJobId);
const allJobs = await storage.observationGenerationJobs.listByStatusForScope({
status: 'queued',
projectId,
teamId,
});
const summaryJobs = allJobs.filter(j => j.sourceType === 'session_summary');
expect(summaryJobs.length).toBe(1);
});
it('POST /api/sessions/observations rejects requests without auth (401)', async () => {
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentSessionId: 'x', tool_name: 'Read' }),
});
expect(response.status).toBe(401);
expect(enqueuedEventJobs.length).toBe(0);
});
});
+94
View File
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, it, expect, mock, beforeEach } from 'bun:test';
let mockSettings: Record<string, string> = {};
mock.module('../../src/shared/hook-settings.js', () => ({
loadFromFileOnce: () => ({ ...mockSettings }),
}));
const warnLogs: Array<{ msg: string; details?: unknown }> = [];
mock.module('../../src/utils/logger.js', () => ({
logger: {
warn: (_component: string, msg: string, details?: unknown) => {
warnLogs.push({ msg, details });
},
info: () => {},
debug: () => {},
error: () => {},
failure: () => {},
dataIn: () => {},
formatTool: () => '',
},
}));
import {
resolveRuntimeContext,
selectRuntime,
buildServerBetaContext,
logServerBetaFallback,
} from '../../src/services/hooks/runtime-selector.js';
describe('runtime-selector', () => {
beforeEach(() => {
mockSettings = {
CLAUDE_MEM_RUNTIME: 'worker',
CLAUDE_MEM_SERVER_BETA_URL: '',
CLAUDE_MEM_SERVER_BETA_API_KEY: '',
CLAUDE_MEM_SERVER_BETA_PROJECT_ID: '',
};
warnLogs.length = 0;
});
it('selectRuntime defaults to worker', () => {
expect(selectRuntime()).toBe('worker');
});
it('selectRuntime returns server-beta when settings say so', () => {
mockSettings.CLAUDE_MEM_RUNTIME = 'server-beta';
expect(selectRuntime()).toBe('server-beta');
});
it('resolveRuntimeContext returns worker when runtime=worker', () => {
const ctx = resolveRuntimeContext();
expect(ctx.runtime).toBe('worker');
});
it('resolveRuntimeContext falls back to worker when api key is missing', () => {
mockSettings.CLAUDE_MEM_RUNTIME = 'server-beta';
mockSettings.CLAUDE_MEM_SERVER_BETA_URL = 'http://localhost:1234';
mockSettings.CLAUDE_MEM_SERVER_BETA_PROJECT_ID = 'p1';
const ctx = resolveRuntimeContext();
expect(ctx.runtime).toBe('worker');
expect(warnLogs.some(l => l.msg.includes('missing_api_key'))).toBe(true);
});
it('resolveRuntimeContext returns server-beta context when fully configured', () => {
mockSettings.CLAUDE_MEM_RUNTIME = 'server-beta';
mockSettings.CLAUDE_MEM_SERVER_BETA_URL = 'http://localhost:1234';
mockSettings.CLAUDE_MEM_SERVER_BETA_API_KEY = 'cmem_xyz';
mockSettings.CLAUDE_MEM_SERVER_BETA_PROJECT_ID = 'project-uuid';
const ctx = resolveRuntimeContext();
expect(ctx.runtime).toBe('server-beta');
if (ctx.runtime === 'server-beta') {
expect(ctx.projectId).toBe('project-uuid');
expect(ctx.serverBaseUrl).toBe('http://localhost:1234');
}
});
it('buildServerBetaContext returns null when project id missing', () => {
mockSettings.CLAUDE_MEM_RUNTIME = 'server-beta';
mockSettings.CLAUDE_MEM_SERVER_BETA_URL = 'http://localhost:1234';
mockSettings.CLAUDE_MEM_SERVER_BETA_API_KEY = 'cmem_xyz';
expect(buildServerBetaContext()).toBeNull();
expect(warnLogs.some(l => l.msg.includes('missing_project_id'))).toBe(true);
});
it('logServerBetaFallback emits a stable WARN code', () => {
logServerBetaFallback('transport', { route: '/v1/events' });
const matched = warnLogs.find(l => l.msg.includes('[server-beta-fallback]'));
expect(matched).toBeDefined();
expect(matched?.msg).toContain('reason=transport');
});
});
+289
View File
@@ -0,0 +1,289 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
mock.module('../../src/shared/worker-utils.js', () => ({
fetchWithTimeout: async (url: string, init: RequestInit, _timeoutMs: number) => {
return globalThis.fetch(url, init);
},
}));
import {
ServerBetaClient,
ServerBetaClientError,
isServerBetaClientError,
} from '../../src/services/hooks/server-beta-client.js';
interface CapturedRequest {
url: string;
method: string;
headers: Record<string, string>;
body?: unknown;
}
let captured: CapturedRequest[] = [];
const originalFetch = globalThis.fetch;
function installFetch(handler: (req: CapturedRequest) => Response | Promise<Response>): void {
// Reset capture buffer for each test.
captured = [];
globalThis.fetch = (async (url: string, init: RequestInit = {}) => {
const headers: Record<string, string> = {};
const rawHeaders = init.headers ?? {};
if (Array.isArray(rawHeaders)) {
for (const [k, v] of rawHeaders) headers[k.toLowerCase()] = v;
} else {
for (const [k, v] of Object.entries(rawHeaders as Record<string, string>)) {
headers[k.toLowerCase()] = v;
}
}
const body = init.body ? JSON.parse(String(init.body)) : undefined;
const req: CapturedRequest = { url, method: String(init.method ?? 'GET'), headers, body };
captured.push(req);
return handler(req);
}) as typeof globalThis.fetch;
}
describe('ServerBetaClient', () => {
beforeEach(() => {
captured = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('throws missing_api_key when apiKey is empty', async () => {
const client = new ServerBetaClient({ serverBaseUrl: 'http://x', apiKey: '' });
let caught: unknown;
try {
await client.recordEvent({
projectId: 'p1',
sourceType: 'hook',
eventType: 'tool_use',
occurredAtEpoch: 1,
});
} catch (error) {
caught = error;
}
expect(isServerBetaClientError(caught)).toBe(true);
if (caught instanceof ServerBetaClientError) {
expect(caught.kind).toBe('missing_api_key');
expect(caught.isFallbackEligible()).toBe(true);
}
});
it('startSession sends POST /v1/sessions/start with expected payload', async () => {
installFetch(async () => new Response(JSON.stringify({ session: { id: 'sess-1', projectId: 'p1', teamId: 't1', externalSessionId: 'ext', contentSessionId: 'ext' } }), { status: 201, headers: { 'content-type': 'application/json' } }));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999/', apiKey: 'cmem_test' });
const result = await client.startSession({
projectId: 'p1',
externalSessionId: 'ext',
contentSessionId: 'ext',
platformSource: 'claude-code',
});
expect(captured).toHaveLength(1);
expect(captured[0]?.url).toBe('http://localhost:9999/v1/sessions/start');
expect(captured[0]?.method).toBe('POST');
expect(captured[0]?.headers.authorization).toBe('Bearer cmem_test');
expect(captured[0]?.headers['content-type']).toBe('application/json');
expect((captured[0]?.body as Record<string, unknown>).projectId).toBe('p1');
expect((captured[0]?.body as Record<string, unknown>).externalSessionId).toBe('ext');
expect(result.session.id).toBe('sess-1');
});
it('recordEvent sends POST /v1/events with payload', async () => {
installFetch(async () => new Response(JSON.stringify({ event: { id: 'e1', projectId: 'p1', serverSessionId: null } }), { status: 201 }));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.recordEvent({
projectId: 'p1',
contentSessionId: 'cs1',
sourceType: 'hook',
eventType: 'tool_use',
occurredAtEpoch: 1234,
payload: { tool: 'Read' },
});
expect(captured[0]?.url).toBe('http://localhost:9999/v1/events');
expect((captured[0]?.body as Record<string, unknown>).eventType).toBe('tool_use');
expect((captured[0]?.body as Record<string, unknown>).sourceType).toBe('hook');
expect((captured[0]?.body as Record<string, unknown>).occurredAtEpoch).toBe(1234);
expect(result.event.id).toBe('e1');
});
it('endSession sends POST /v1/sessions/:id/end', async () => {
installFetch(async () => new Response(JSON.stringify({ session: { id: 'sess-1' } }), { status: 200 }));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
await client.endSession({ sessionId: 'sess-1' });
expect(captured[0]?.url).toBe('http://localhost:9999/v1/sessions/sess-1/end');
expect(captured[0]?.method).toBe('POST');
});
it('throws transport error on fetch failure', async () => {
globalThis.fetch = (async () => {
throw new Error('ECONNREFUSED');
}) as typeof globalThis.fetch;
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.recordEvent({ projectId: 'p1', sourceType: 'hook', eventType: 'tool_use', occurredAtEpoch: 1 });
} catch (error) {
caught = error;
}
expect(isServerBetaClientError(caught)).toBe(true);
if (caught instanceof ServerBetaClientError) {
expect(caught.kind).toBe('transport');
expect(caught.isFallbackEligible()).toBe(true);
}
});
it('classifies 5xx as fallback-eligible http_error', async () => {
installFetch(async () => new Response('boom', { status: 502 }));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.recordEvent({ projectId: 'p1', sourceType: 'hook', eventType: 'tool_use', occurredAtEpoch: 1 });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ServerBetaClientError);
if (caught instanceof ServerBetaClientError) {
expect(caught.kind).toBe('http_error');
expect(caught.status).toBe(502);
expect(caught.isFallbackEligible()).toBe(true);
}
});
it('classifies 4xx (not 429) as non-fallback http_error', async () => {
installFetch(async () => new Response('bad', { status: 400 }));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.recordEvent({ projectId: 'p1', sourceType: 'hook', eventType: 'tool_use', occurredAtEpoch: 1 });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ServerBetaClientError);
if (caught instanceof ServerBetaClientError) {
expect(caught.kind).toBe('http_error');
expect(caught.status).toBe(400);
expect(caught.isFallbackEligible()).toBe(false);
}
});
it('classifies 429 as fallback-eligible http_error', async () => {
installFetch(async () => new Response('rate', { status: 429 }));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.recordEvent({ projectId: 'p1', sourceType: 'hook', eventType: 'tool_use', occurredAtEpoch: 1 });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ServerBetaClientError);
if (caught instanceof ServerBetaClientError) {
expect(caught.status).toBe(429);
expect(caught.isFallbackEligible()).toBe(true);
}
});
it('strips trailing slash from baseUrl', async () => {
installFetch(async () => new Response(JSON.stringify({ session: { id: 's' } }), { status: 200 }));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999///', apiKey: 'cmem_test' });
await client.endSession({ sessionId: 's' });
expect(captured[0]?.url).toBe('http://localhost:9999/v1/sessions/s/end');
});
// ----- Phase 8 — MCP-backing methods. These exercise the same /v1/* paths
// the REST core exposes, so MCP tools never have a private write path. -----
it('addObservation sends POST /v1/memories with content', async () => {
installFetch(async () => new Response(
JSON.stringify({ memory: { id: 'o1', projectId: 'p1', teamId: 't1', serverSessionId: null, kind: 'manual', content: 'hello', metadata: {} } }),
{ status: 201 },
));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.addObservation({
projectId: 'p1',
content: 'hello',
kind: 'manual',
metadata: { source: 'mcp' },
});
expect(captured[0]?.url).toBe('http://localhost:9999/v1/memories');
expect(captured[0]?.method).toBe('POST');
expect((captured[0]?.body as Record<string, unknown>).content).toBe('hello');
expect((captured[0]?.body as Record<string, unknown>).kind).toBe('manual');
expect(result.memory.id).toBe('o1');
});
it('searchObservations sends POST /v1/search with query', async () => {
installFetch(async () => new Response(
JSON.stringify({ observations: [{ id: 'o1', projectId: 'p1', content: 'matched' }] }),
{ status: 200 },
));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.searchObservations({
projectId: 'p1',
query: 'login bug',
limit: 5,
});
expect(captured[0]?.url).toBe('http://localhost:9999/v1/search');
expect((captured[0]?.body as Record<string, unknown>).query).toBe('login bug');
expect((captured[0]?.body as Record<string, unknown>).limit).toBe(5);
expect(result.observations[0]?.id).toBe('o1');
});
it('contextObservations sends POST /v1/context and returns context string', async () => {
installFetch(async () => new Response(
JSON.stringify({
observations: [{ id: 'o1', projectId: 'p1', content: 'a' }, { id: 'o2', projectId: 'p1', content: 'b' }],
context: 'a\n\nb',
}),
{ status: 200 },
));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.contextObservations({ projectId: 'p1', query: 'q' });
expect(captured[0]?.url).toBe('http://localhost:9999/v1/context');
expect(result.context).toBe('a\n\nb');
expect(result.observations).toHaveLength(2);
});
it('getJobStatus sends GET /v1/jobs/:id', async () => {
installFetch(async () => new Response(
JSON.stringify({ generationJob: { id: 'j1', status: 'queued' } }),
{ status: 200 },
));
const client = new ServerBetaClient({ serverBaseUrl: 'http://localhost:9999', apiKey: 'cmem_test' });
const result = await client.getJobStatus('j1');
expect(captured[0]?.url).toBe('http://localhost:9999/v1/jobs/j1');
expect(captured[0]?.method).toBe('GET');
expect(result.generationJob.status).toBe('queued');
});
it('getJobStatus rejects empty jobId', async () => {
const client = new ServerBetaClient({ serverBaseUrl: 'http://x', apiKey: 'cmem_test' });
let caught: unknown;
try {
await client.getJobStatus('');
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ServerBetaClientError);
});
it('payload builders omit absent fields', () => {
const client = new ServerBetaClient({ serverBaseUrl: 'http://x', apiKey: 'k' });
expect(client.buildAddObservationPayload({ projectId: 'p', content: 'c' })).toEqual({
projectId: 'p',
content: 'c',
});
expect(client.buildSearchPayload({ projectId: 'p', query: 'q' })).toEqual({
projectId: 'p',
query: 'q',
});
expect(client.buildSearchPayload({ projectId: 'p', query: 'q', limit: 7 })).toEqual({
projectId: 'p',
query: 'q',
limit: 7,
});
});
});
@@ -0,0 +1,256 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import pg from 'pg';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
type PostgresPoolClient,
type PostgresStorageRepositories,
} from '../../../src/storage/postgres/index.js';
import {
processGeneratedResponse,
markGenerationFailed,
} from '../../../src/server/generation/processGeneratedResponse.js';
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
describe('processGeneratedResponse + markGenerationFailed', () => {
if (!testDatabaseUrl) {
it.skip('requires CLAUDE_MEM_TEST_POSTGRES_URL for Postgres integration', () => {});
return;
}
const pool = new pg.Pool({ connectionString: testDatabaseUrl });
let client: PostgresPoolClient;
let schemaName: string;
let storage: PostgresStorageRepositories;
let teamId: string;
let projectId: string;
let eventId: string;
let jobId: string;
beforeEach(async () => {
client = await pool.connect();
schemaName = `cm_phase5_${crypto.randomUUID().replaceAll('-', '_')}`;
await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
await bootstrapServerBetaPostgresSchema(client);
storage = createPostgresStorageRepositories(client);
const team = await storage.teams.create({ name: 'team-a' });
const project = await storage.projects.create({ teamId: team.id, name: 'proj-a' });
teamId = team.id;
projectId = project.id;
const event = await storage.agentEvents.create({
projectId,
teamId,
sourceAdapter: 'api',
eventType: 'tool_use',
payload: { tool: 'bash', input: 'ls' },
occurredAt: new Date(),
});
eventId = event.id;
const job = await storage.observationGenerationJobs.create({
projectId,
teamId,
sourceType: 'agent_event',
sourceId: event.id,
agentEventId: event.id,
jobType: 'observation_generate_for_event',
});
jobId = job.id;
// Re-bind the storage layer to the pool so processGeneratedResponse's
// internal transactions see the test schema. We do this by setting
// search_path for new pool connections via on-connect hook, but pg's
// Pool does not expose that easily. Workaround: use the pool from the
// search_path-aware helper below. For these tests we monkey-patch the
// shared pool to set search_path on new connections.
pool.on('connect', (poolClient) => {
poolClient.query(`SET search_path TO ${quoteIdentifier(schemaName)}`).catch(() => {});
});
});
afterEach(async () => {
if (client) {
try {
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
} catch {}
client.release();
}
pool.removeAllListeners('connect');
});
async function reloadJob() {
return await storage.observationGenerationJobs.getByIdForScope({
id: jobId,
projectId,
teamId,
});
}
it('persists observation, links source, and marks job completed for valid XML', async () => {
const xml = `
<observation>
<type>discovery</type>
<title>Tool ran</title>
<facts><fact>command was ls</fact></facts>
</observation>
`;
const job = await reloadJob();
expect(job).toBeTruthy();
// Lock first, like the real generator does.
await storage.observationGenerationJobs.transitionStatus({
id: jobId,
projectId,
teamId,
status: 'processing',
});
const fresh = (await reloadJob())!;
const outcome = await processGeneratedResponse({
pool: pool as unknown as Parameters<typeof processGeneratedResponse>[0]['pool'],
job: fresh,
rawText: xml,
providerLabel: 'fake',
modelId: 'fake-1',
});
expect(outcome.kind).toBe('completed');
if (outcome.kind === 'completed') {
expect(outcome.observations).toHaveLength(1);
expect(outcome.observations[0]!.generationKey).toMatch(/^generation:v1:/);
}
const reloaded = await reloadJob();
expect(reloaded?.status).toBe('completed');
// observation_sources row exists
const sources = await storage.observationSources.listByObservationForScope({
observationId: outcome.kind === 'completed' ? outcome.observations[0]!.id : '',
projectId,
teamId,
});
expect(sources).toHaveLength(1);
expect(sources[0]!.sourceType).toBe('agent_event');
expect(sources[0]!.sourceId).toBe(eventId);
expect(sources[0]!.generationJobId).toBe(jobId);
});
it('replaying the same job yields exactly one observation (idempotency)', async () => {
const xml = `<observation><type>discovery</type><title>Same</title><facts><fact>same</fact></facts></observation>`;
await storage.observationGenerationJobs.transitionStatus({
id: jobId,
projectId,
teamId,
status: 'processing',
});
const fresh = (await reloadJob())!;
const first = await processGeneratedResponse({
pool: pool as unknown as Parameters<typeof processGeneratedResponse>[0]['pool'],
job: fresh,
rawText: xml,
providerLabel: 'fake',
});
expect(first.kind).toBe('completed');
// Manually move job back to processing to simulate retry
// (in practice retry would create a new job invocation, but the
// idempotency guard is at the observation level via generation_key).
// The terminal-status check inside processGeneratedResponse will
// short-circuit the second call cleanly, demonstrating that retries
// do not re-write observations.
const second = await processGeneratedResponse({
pool: pool as unknown as Parameters<typeof processGeneratedResponse>[0]['pool'],
job: fresh,
rawText: xml,
providerLabel: 'fake',
});
expect(second.kind).toBe('completed');
// Verify only one observation exists
const list = await storage.observations.listByProject({ projectId, teamId });
expect(list).toHaveLength(1);
});
it('marks job completed with no observation when the response is a skip_summary', async () => {
await storage.observationGenerationJobs.transitionStatus({
id: jobId,
projectId,
teamId,
status: 'processing',
});
const fresh = (await reloadJob())!;
const outcome = await processGeneratedResponse({
pool: pool as unknown as Parameters<typeof processGeneratedResponse>[0]['pool'],
job: fresh,
rawText: '<skip_summary reason="all_events_private" />',
providerLabel: 'fake',
});
expect(outcome.kind).toBe('completed');
if (outcome.kind === 'completed') {
expect(outcome.observations).toHaveLength(0);
expect(outcome.privateContentDetected).toBe(true);
}
const list = await storage.observations.listByProject({ projectId, teamId });
expect(list).toHaveLength(0);
const reloaded = await reloadJob();
expect(reloaded?.status).toBe('completed');
});
it('returns parse_error and does not write observations for malformed XML', async () => {
await storage.observationGenerationJobs.transitionStatus({
id: jobId,
projectId,
teamId,
status: 'processing',
});
const fresh = (await reloadJob())!;
const outcome = await processGeneratedResponse({
pool: pool as unknown as Parameters<typeof processGeneratedResponse>[0]['pool'],
job: fresh,
rawText: 'this is just prose without any xml',
providerLabel: 'fake',
});
expect(outcome.kind).toBe('parse_error');
const list = await storage.observations.listByProject({ projectId, teamId });
expect(list).toHaveLength(0);
// Job still in processing — caller (ProviderObservationGenerator) is
// responsible for transitioning to failed/retry.
const reloaded = await reloadJob();
expect(reloaded?.status).toBe('processing');
});
it('markGenerationFailed routes to retry when retryable and attempts left', async () => {
await storage.observationGenerationJobs.transitionStatus({
id: jobId,
projectId,
teamId,
status: 'processing',
});
const fresh = (await reloadJob())!;
await markGenerationFailed({
pool: pool as unknown as Parameters<typeof markGenerationFailed>[0]['pool'],
job: fresh,
reason: 'transient',
classification: 'transient',
retryable: true,
});
const reloaded = await reloadJob();
expect(reloaded?.status).toBe('queued');
});
});
@@ -0,0 +1,153 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import pg from 'pg';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
type PostgresPoolClient,
type PostgresStorageRepositories,
} from '../../../src/storage/postgres/index.js';
import { ProviderObservationGenerator } from '../../../src/server/generation/ProviderObservationGenerator.js';
import type { ServerGenerationProvider } from '../../../src/server/generation/providers/shared/types.js';
import type { Job } from 'bullmq';
import type { GenerateObservationsForEventJob } from '../../../src/server/jobs/types.js';
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
class StubProvider implements ServerGenerationProvider {
readonly providerLabel = 'claude' as const;
calls = 0;
constructor(private readonly response: string | Error) {}
async generate() {
this.calls += 1;
if (this.response instanceof Error) throw this.response;
return { rawText: this.response, providerLabel: this.providerLabel };
}
}
describe('ProviderObservationGenerator', () => {
if (!testDatabaseUrl) {
it.skip('requires CLAUDE_MEM_TEST_POSTGRES_URL', () => {});
return;
}
const pool = new pg.Pool({ connectionString: testDatabaseUrl });
let client: PostgresPoolClient;
let schemaName: string;
let storage: PostgresStorageRepositories;
let teamId: string;
let projectId: string;
let eventId: string;
let jobId: string;
beforeEach(async () => {
client = await pool.connect();
schemaName = `cm_phase5_gen_${crypto.randomUUID().replaceAll('-', '_')}`;
await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
await bootstrapServerBetaPostgresSchema(client);
storage = createPostgresStorageRepositories(client);
pool.on('connect', (poolClient) => {
poolClient.query(`SET search_path TO ${quoteIdentifier(schemaName)}`).catch(() => {});
});
const team = await storage.teams.create({ name: 'team' });
const project = await storage.projects.create({ teamId: team.id, name: 'p' });
teamId = team.id;
projectId = project.id;
const event = await storage.agentEvents.create({
projectId,
teamId,
sourceAdapter: 'api',
eventType: 'tool_use',
payload: { x: 1 },
occurredAt: new Date(),
});
eventId = event.id;
const job = await storage.observationGenerationJobs.create({
projectId,
teamId,
sourceType: 'agent_event',
sourceId: event.id,
agentEventId: event.id,
jobType: 'observation_generate_for_event',
});
jobId = job.id;
});
afterEach(async () => {
if (client) {
try {
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
} catch {}
client.release();
}
pool.removeAllListeners('connect');
});
function makeJob(): Job<GenerateObservationsForEventJob> {
return {
id: 'bull-1',
data: {
kind: 'event',
team_id: teamId,
project_id: projectId,
source_type: 'agent_event',
source_id: eventId,
generation_job_id: jobId,
agent_event_id: eventId,
api_key_id: null,
actor_id: null,
source_adapter: 'api',
},
} as unknown as Job<GenerateObservationsForEventJob>;
}
it('completes a job using the fake provider response', async () => {
const xml = '<observation><type>discovery</type><title>OK</title><facts><fact>f</fact></facts></observation>';
const provider = new StubProvider(xml);
const generator = new ProviderObservationGenerator({
pool: pool as unknown as Parameters<typeof ProviderObservationGenerator['prototype']['process']>[0]['data'] extends never
? never
: never,
provider,
} as unknown as { pool: pg.Pool; provider: ServerGenerationProvider });
const result = await generator.process(makeJob());
expect(result.status).toBe('completed');
expect(result.observationCount).toBe(1);
expect(provider.calls).toBe(1);
const reloaded = await storage.observationGenerationJobs.getByIdForScope({
id: jobId,
projectId,
teamId,
});
expect(reloaded?.status).toBe('completed');
});
it('marks a job as failed (no retry) when provider returns malformed XML', async () => {
const provider = new StubProvider('not xml at all');
const generator = new ProviderObservationGenerator({
pool: pool as unknown as pg.Pool,
provider,
} as unknown as ConstructorParameters<typeof ProviderObservationGenerator>[0]);
await expect(generator.process(makeJob())).rejects.toThrow(/parse error/);
const reloaded = await storage.observationGenerationJobs.getByIdForScope({
id: jobId,
projectId,
teamId,
});
expect(reloaded?.status).toBe('failed');
});
});
+227
View File
@@ -0,0 +1,227 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'bun:test';
import {
ServerClassifiedProviderError,
classifyHttpProviderError,
parseRetryAfterMs,
} from '../../../src/server/generation/providers/shared/error-classification.js';
import { classifyClaudeServerError } from '../../../src/server/generation/providers/ClaudeObservationProvider.js';
import {
ClaudeObservationProvider,
} from '../../../src/server/generation/providers/ClaudeObservationProvider.js';
import { GeminiObservationProvider } from '../../../src/server/generation/providers/GeminiObservationProvider.js';
import { OpenRouterObservationProvider } from '../../../src/server/generation/providers/OpenRouterObservationProvider.js';
import { buildServerGenerationPrompt } from '../../../src/server/generation/providers/shared/prompt-builder.js';
import type { ServerGenerationContext } from '../../../src/server/generation/providers/shared/types.js';
function makeContext(overrides: Partial<{ payload: unknown; serverSessionId: string | null }> = {}): ServerGenerationContext {
return {
job: {
id: 'job-1',
projectId: 'proj-1',
teamId: 'team-1',
agentEventId: 'evt-1',
sourceType: 'agent_event',
sourceId: 'evt-1',
serverSessionId: overrides.serverSessionId ?? null,
jobType: 'observation_generate_for_event',
status: 'processing',
idempotencyKey: 'k',
bullmqJobId: null,
attempts: 1,
maxAttempts: 3,
nextAttemptAtEpoch: null,
lockedAtEpoch: null,
lockedBy: null,
completedAtEpoch: null,
failedAtEpoch: null,
cancelledAtEpoch: null,
lastError: null,
payload: {},
createdAtEpoch: 0,
updatedAtEpoch: 0,
},
events: [
{
id: 'evt-1',
projectId: 'proj-1',
teamId: 'team-1',
serverSessionId: overrides.serverSessionId ?? null,
sourceAdapter: 'api',
sourceEventId: null,
idempotencyKey: 'k',
eventType: 'tool_use',
payload: overrides.payload ?? { tool: 'bash', input: 'ls' },
metadata: {},
occurredAtEpoch: 0,
receivedAtEpoch: 0,
createdAtEpoch: 0,
},
],
project: {
projectId: 'proj-1',
teamId: 'team-1',
serverSessionId: overrides.serverSessionId ?? null,
projectName: 'demo',
},
};
}
describe('shared error classification', () => {
it('parseRetryAfterMs returns ms for numeric values', () => {
expect(parseRetryAfterMs('5')).toBe(5000);
expect(parseRetryAfterMs(null)).toBeUndefined();
});
it('classifyHttpProviderError returns rate_limit on 429', () => {
const err = classifyHttpProviderError({ status: 429, cause: new Error('rl'), providerLabel: 'X' });
expect(err.kind).toBe('rate_limit');
});
it('classifyHttpProviderError returns auth_invalid on 401/403', () => {
expect(classifyHttpProviderError({ status: 401, cause: 'x', providerLabel: 'X' }).kind).toBe('auth_invalid');
expect(classifyHttpProviderError({ status: 403, cause: 'x', providerLabel: 'X' }).kind).toBe('auth_invalid');
});
it('classifyHttpProviderError detects quota body markers regardless of status', () => {
const err = classifyHttpProviderError({
status: 500,
bodyText: 'RESOURCE_EXHAUSTED',
cause: new Error(''),
providerLabel: 'Gemini',
});
expect(err.kind).toBe('quota_exhausted');
});
it('classifyClaudeServerError treats 529 as transient', () => {
expect(classifyClaudeServerError({ status: 529, cause: 'x' }).kind).toBe('transient');
});
it('classifyClaudeServerError treats prompt-too-long as unrecoverable', () => {
expect(
classifyClaudeServerError({ status: 400, bodyText: 'prompt is too long', cause: 'x' }).kind,
).toBe('unrecoverable');
});
});
describe('buildServerGenerationPrompt', () => {
it('strips <private> tags from event payload before sending', () => {
const context = makeContext({
payload: '<private>secret</private>visible',
});
const result = buildServerGenerationPrompt(context);
expect(result.prompt).not.toContain('secret');
expect(result.prompt).toContain('visible');
expect(result.hadPrivateContent).toBe(true);
expect(result.skippedAll).toBe(false);
});
it('marks skippedAll when every event is fully private', () => {
const context = makeContext({ payload: '<private>secret</private>' });
const result = buildServerGenerationPrompt(context);
expect(result.skippedAll).toBe(true);
expect(result.hadPrivateContent).toBe(true);
});
it('includes generation_job_id and project metadata in the prompt', () => {
const result = buildServerGenerationPrompt(makeContext({ serverSessionId: 'session-x' }));
expect(result.prompt).toContain('<generation_job_id>job-1</generation_job_id>');
expect(result.prompt).toContain('<server_session_id>session-x</server_session_id>');
expect(result.prompt).toContain('<project_name>demo</project_name>');
});
});
class FakeFetch {
constructor(private readonly response: Response | (() => Response)) {}
fetch: typeof fetch = async () => {
return typeof this.response === 'function' ? this.response() : this.response;
};
}
function jsonResponse(status: number, body: unknown, headers?: Record<string, string>): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...(headers ?? {}) },
});
}
describe('ClaudeObservationProvider', () => {
it('returns synthetic skip when prompt builder reports skippedAll', async () => {
const provider = new ClaudeObservationProvider({ apiKey: 'fake', fetchImpl: async () => {
throw new Error('should not be called');
} });
const context = makeContext({ payload: '<private>secret</private>' });
const result = await provider.generate(context);
expect(result.rawText).toContain('<skip_summary');
});
it('parses Anthropic Messages text content into rawText', async () => {
const fakeFetch = new FakeFetch(
jsonResponse(200, {
content: [
{ type: 'text', text: '<observation><type>x</type><title>t</title></observation>' },
],
usage: { input_tokens: 10, output_tokens: 20 },
}),
);
const provider = new ClaudeObservationProvider({
apiKey: 'sk-fake',
fetchImpl: fakeFetch.fetch,
});
const result = await provider.generate(makeContext());
expect(result.rawText).toContain('<observation>');
expect(result.tokensUsed).toBe(30);
expect(result.providerLabel).toBe('claude');
});
it('classifies non-OK responses through classifyClaudeServerError', async () => {
const fakeFetch = new FakeFetch(jsonResponse(401, { error: { message: 'Invalid API key' } }));
const provider = new ClaudeObservationProvider({ apiKey: 'sk-fake', fetchImpl: fakeFetch.fetch });
await expect(provider.generate(makeContext())).rejects.toBeInstanceOf(ServerClassifiedProviderError);
});
});
describe('GeminiObservationProvider', () => {
it('parses generateContent response into rawText', async () => {
const fakeFetch = new FakeFetch(
jsonResponse(200, {
candidates: [{ content: { parts: [{ text: '<observation><type>x</type><title>g</title></observation>' }] } }],
usageMetadata: { totalTokenCount: 42 },
}),
);
const provider = new GeminiObservationProvider({ apiKey: 'fake', fetchImpl: fakeFetch.fetch });
const result = await provider.generate(makeContext());
expect(result.rawText).toContain('<observation>');
expect(result.tokensUsed).toBe(42);
expect(result.providerLabel).toBe('gemini');
});
});
describe('OpenRouterObservationProvider', () => {
it('parses OpenAI-style response and reports tokensUsed', async () => {
const fakeFetch = new FakeFetch(
jsonResponse(200, {
choices: [{ message: { content: '<observation><type>x</type><title>o</title></observation>' } }],
usage: { total_tokens: 100 },
}),
);
const provider = new OpenRouterObservationProvider({ apiKey: 'fake', fetchImpl: fakeFetch.fetch });
const result = await provider.generate(makeContext());
expect(result.rawText).toContain('<observation>');
expect(result.tokensUsed).toBe(100);
expect(result.providerLabel).toBe('openrouter');
});
it('classifies a 429 response as rate_limit', async () => {
const fakeFetch = new FakeFetch(jsonResponse(429, { error: { message: 'rl' } }));
const provider = new OpenRouterObservationProvider({ apiKey: 'fake', fetchImpl: fakeFetch.fetch });
try {
await provider.generate(makeContext());
expect.unreachable();
} catch (error) {
expect(error).toBeInstanceOf(ServerClassifiedProviderError);
expect((error as ServerClassifiedProviderError).kind).toBe('rate_limit');
}
});
});
@@ -0,0 +1,258 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import pg from 'pg';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
type PostgresPoolClient,
type PostgresStorageRepositories,
} from '../../../src/storage/postgres/index.js';
import {
ProviderObservationGenerator,
ServerGenerationScopeViolationError,
} from '../../../src/server/generation/ProviderObservationGenerator.js';
import { ServerGenerationJobPayloadValidationError } from '../../../src/server/jobs/types.js';
import type { ServerGenerationProvider } from '../../../src/server/generation/providers/shared/types.js';
import type { Job } from 'bullmq';
import type { ServerGenerationJobPayload, GenerateObservationsForEventJob } from '../../../src/server/jobs/types.js';
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
class StubProvider implements ServerGenerationProvider {
readonly providerLabel = 'claude' as const;
calls = 0;
constructor(private readonly response: string | Error) {}
async generate() {
this.calls += 1;
if (this.response instanceof Error) throw this.response;
return { rawText: this.response, providerLabel: this.providerLabel };
}
}
describe('Phase 11 — ProviderObservationGenerator scope enforcement', () => {
if (!testDatabaseUrl) {
it.skip('requires CLAUDE_MEM_TEST_POSTGRES_URL', () => {});
return;
}
const pool = new pg.Pool({ connectionString: testDatabaseUrl });
let client: PostgresPoolClient;
let schemaName: string;
let storage: PostgresStorageRepositories;
let teamId: string;
let foreignTeamId: string;
let projectId: string;
let eventId: string;
let jobId: string;
let apiKeyId: string;
beforeEach(async () => {
client = await pool.connect();
schemaName = `cm_phase11_${crypto.randomUUID().replaceAll('-', '_')}`;
await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
await bootstrapServerBetaPostgresSchema(client);
storage = createPostgresStorageRepositories(client);
pool.on('connect', (poolClient) => {
poolClient.query(`SET search_path TO ${quoteIdentifier(schemaName)}`).catch(() => {});
});
const team = await storage.teams.create({ name: 'team-a' });
const foreignTeam = await storage.teams.create({ name: 'team-b' });
const project = await storage.projects.create({ teamId: team.id, name: 'p' });
teamId = team.id;
foreignTeamId = foreignTeam.id;
projectId = project.id;
const apiKey = await storage.auth.createApiKey({
keyHash: 'h_' + crypto.randomUUID().replaceAll('-', ''),
teamId,
projectId,
actorId: 'system:phase11-test',
scopes: ['memories:write'],
});
apiKeyId = apiKey.id;
const event = await storage.agentEvents.create({
projectId,
teamId,
sourceAdapter: 'api',
eventType: 'tool_use',
payload: { x: 1 },
occurredAt: new Date(),
});
eventId = event.id;
const job = await storage.observationGenerationJobs.create({
projectId,
teamId,
sourceType: 'agent_event',
sourceId: event.id,
agentEventId: event.id,
jobType: 'observation_generate_for_event',
});
jobId = job.id;
});
afterEach(async () => {
if (client) {
try {
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
} catch {}
client.release();
}
pool.removeAllListeners('connect');
});
function makeJob(overrides: Partial<GenerateObservationsForEventJob> = {}): Job<ServerGenerationJobPayload> {
return {
id: 'bull-1',
data: {
kind: 'event',
team_id: teamId,
project_id: projectId,
source_type: 'agent_event',
source_id: eventId,
generation_job_id: jobId,
agent_event_id: eventId,
api_key_id: apiKeyId,
actor_id: 'system:phase11-test',
source_adapter: 'api',
...overrides,
},
} as unknown as Job<ServerGenerationJobPayload>;
}
it('rejects payload when reloaded outbox team_id differs from job payload team_id', async () => {
const provider = new StubProvider('<observation><type>x</type><title>OK</title></observation>');
const generator = new ProviderObservationGenerator({
pool: pool as unknown as pg.Pool,
provider,
} as unknown as ConstructorParameters<typeof ProviderObservationGenerator>[0]);
// Tampered payload — claims a different team.
const job = makeJob({ team_id: foreignTeamId });
await expect(generator.process(job)).rejects.toBeInstanceOf(ServerGenerationScopeViolationError);
expect(provider.calls).toBe(0);
// Job should be in 'failed' status with classification 'scope_mismatch'.
const reloaded = await storage.observationGenerationJobs.getByIdForScope({
id: jobId,
projectId,
teamId,
});
expect(reloaded?.status).toBe('failed');
// Audit row should have been written under generation_job.scope_violation.
const auditRows = await pool.query<{ action: string; details: unknown }>(
`SELECT action, details FROM audit_log WHERE resource_id = $1 AND action = $2`,
[jobId, 'generation_job.scope_violation'],
);
expect(auditRows.rows.length).toBeGreaterThanOrEqual(1);
});
it('rejects payload when api key was revoked between enqueue and execute', async () => {
// Revoke the api key.
await pool.query(
`UPDATE api_keys SET revoked_at = now() WHERE id = $1`,
[apiKeyId],
);
const provider = new StubProvider('<observation><type>x</type><title>OK</title></observation>');
const generator = new ProviderObservationGenerator({
pool: pool as unknown as pg.Pool,
provider,
} as unknown as ConstructorParameters<typeof ProviderObservationGenerator>[0]);
await expect(generator.process(makeJob())).rejects.toBeInstanceOf(ServerGenerationScopeViolationError);
expect(provider.calls).toBe(0);
const reloaded = await storage.observationGenerationJobs.getByIdForScope({
id: jobId,
projectId,
teamId,
});
expect(reloaded?.status).toBe('failed');
const auditRows = await pool.query<{ action: string }>(
`SELECT action FROM audit_log WHERE resource_id = $1 AND action = $2`,
[jobId, 'generation_job.revoked_key'],
);
expect(auditRows.rows.length).toBeGreaterThanOrEqual(1);
});
it('rejects malformed payload at execution boundary', async () => {
const provider = new StubProvider('<observation><type>x</type><title>OK</title></observation>');
const generator = new ProviderObservationGenerator({
pool: pool as unknown as pg.Pool,
provider,
} as unknown as ConstructorParameters<typeof ProviderObservationGenerator>[0]);
// Strip required fields — this should be caught BEFORE any DB lookup.
const job = {
id: 'bull-bad',
data: { kind: 'event', team_id: teamId },
} as unknown as Job<ServerGenerationJobPayload>;
await expect(generator.process(job)).rejects.toBeInstanceOf(
ServerGenerationJobPayloadValidationError,
);
expect(provider.calls).toBe(0);
});
it('writes the full audit chain on a successful generation', async () => {
const provider = new StubProvider(
'<observation><type>discovery</type><title>OK</title><facts><fact>f</fact></facts></observation>',
);
const generator = new ProviderObservationGenerator({
pool: pool as unknown as pg.Pool,
provider,
} as unknown as ConstructorParameters<typeof ProviderObservationGenerator>[0]);
const result = await generator.process(makeJob());
expect(result.status).toBe('completed');
expect(result.observationCount).toBe(1);
// Phase 11 — every observation row should carry team/project from the
// canonical outbox/source row, not from the BullMQ payload.
const obsRows = await pool.query<{ team_id: string; project_id: string }>(
`SELECT team_id, project_id FROM observations WHERE created_by_job_id = $1`,
[jobId],
);
expect(obsRows.rows.length).toBe(1);
expect(obsRows.rows[0]!.team_id).toBe(teamId);
expect(obsRows.rows[0]!.project_id).toBe(projectId);
// Phase 11 — observation_sources.metadata carries the identity context.
const sourceRows = await pool.query<{ metadata: { source_adapter: string; api_key_id: string | null; actor_id: string | null } }>(
`SELECT metadata FROM observation_sources WHERE generation_job_id = $1`,
[jobId],
);
expect(sourceRows.rows.length).toBe(1);
const meta = sourceRows.rows[0]!.metadata;
expect(meta.source_adapter).toBe('api');
expect(meta.api_key_id).toBe(apiKeyId);
expect(meta.actor_id).toBe('system:phase11-test');
// Phase 11 — full audit chain. Every row must reference generation_job_id
// in details for traceability.
const audit = await pool.query<{ action: string; details: { generationJobId?: string } }>(
`SELECT action, details FROM audit_log
WHERE (details->>'generationJobId') = $1 OR resource_id = $1
ORDER BY created_at ASC`,
[jobId],
);
const actions = audit.rows.map(r => r.action);
expect(actions).toContain('generation_job.processing');
expect(actions).toContain('observation.created');
expect(actions).toContain('generation_job.completed');
});
});
+4 -1
View File
@@ -179,7 +179,10 @@ const eventPayload: SingleSourceJobPayload = {
source_type: 'agent_event',
source_id: 'evt_1',
generation_job_id: 'gen_1',
agent_event_id: 'evt_1'
agent_event_id: 'evt_1',
api_key_id: 'apk_1',
actor_id: 'system:test',
source_adapter: 'api'
};
describe('outbox.enqueueOutbox', () => {
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'bun:test';
import {
ServerGenerationJobPayloadSchema,
ServerGenerationJobPayloadValidationError,
assertServerGenerationJobPayload,
} from '../../../src/server/jobs/types.js';
// Phase 11 — schema validation at the queue boundary. Every job payload must
// carry team_id, project_id, generation_job_id, source_adapter, and the
// (nullable) actor/api_key identity fields. Unit tests confirm that omitting
// any required field rejects the payload synchronously.
describe('ServerGenerationJobPayloadSchema', () => {
const validEvent = {
kind: 'event' as const,
team_id: 'team_1',
project_id: 'project_1',
source_type: 'agent_event' as const,
source_id: 'evt_1',
generation_job_id: 'gen_1',
agent_event_id: 'evt_1',
api_key_id: 'apk_1',
actor_id: 'system:test',
source_adapter: 'api',
};
it('accepts a fully populated event payload', () => {
const result = ServerGenerationJobPayloadSchema.safeParse(validEvent);
expect(result.success).toBe(true);
});
it('rejects payload missing team_id', () => {
const { team_id, ...rest } = validEvent;
const result = ServerGenerationJobPayloadSchema.safeParse(rest);
expect(result.success).toBe(false);
if (!result.success) {
const message = result.error.issues.map(i => i.path.join('.')).join(',');
expect(message).toContain('team_id');
}
});
it('rejects payload missing project_id', () => {
const { project_id, ...rest } = validEvent;
const result = ServerGenerationJobPayloadSchema.safeParse(rest);
expect(result.success).toBe(false);
});
it('rejects payload missing generation_job_id', () => {
const { generation_job_id, ...rest } = validEvent;
const result = ServerGenerationJobPayloadSchema.safeParse(rest);
expect(result.success).toBe(false);
});
it('rejects payload missing source_adapter', () => {
const { source_adapter, ...rest } = validEvent;
const result = ServerGenerationJobPayloadSchema.safeParse(rest);
expect(result.success).toBe(false);
});
it('requires the api_key_id field to be present (null is allowed)', () => {
const { api_key_id, ...withoutKey } = validEvent;
const result = ServerGenerationJobPayloadSchema.safeParse(withoutKey);
expect(result.success).toBe(false);
const withNullKey = { ...validEvent, api_key_id: null };
expect(ServerGenerationJobPayloadSchema.safeParse(withNullKey).success).toBe(true);
});
it('requires the actor_id field to be present (null is allowed)', () => {
const { actor_id, ...withoutActor } = validEvent;
const result = ServerGenerationJobPayloadSchema.safeParse(withoutActor);
expect(result.success).toBe(false);
const withNullActor = { ...validEvent, actor_id: null };
expect(ServerGenerationJobPayloadSchema.safeParse(withNullActor).success).toBe(true);
});
it('accepts a summary payload with server_session_id', () => {
const summary = {
kind: 'summary' as const,
team_id: 't1',
project_id: 'p1',
source_type: 'session_summary' as const,
source_id: 'ses_1',
generation_job_id: 'gen_2',
server_session_id: 'ses_1',
api_key_id: null,
actor_id: null,
source_adapter: 'api',
};
expect(ServerGenerationJobPayloadSchema.safeParse(summary).success).toBe(true);
});
it('rejects summary payload missing server_session_id', () => {
const summary = {
kind: 'summary' as const,
team_id: 't1',
project_id: 'p1',
source_type: 'session_summary' as const,
source_id: 'ses_1',
generation_job_id: 'gen_2',
api_key_id: null,
actor_id: null,
source_adapter: 'api',
};
expect(ServerGenerationJobPayloadSchema.safeParse(summary).success).toBe(false);
});
it('assertServerGenerationJobPayload throws ServerGenerationJobPayloadValidationError on bad input', () => {
expect(() => assertServerGenerationJobPayload({ kind: 'event' })).toThrow(
ServerGenerationJobPayloadValidationError,
);
});
it('assertServerGenerationJobPayload returns typed payload on success', () => {
const validated = assertServerGenerationJobPayload(validEvent);
expect(validated.kind).toBe('event');
expect(validated.team_id).toBe('team_1');
expect(validated.source_adapter).toBe('api');
});
});
+44 -2
View File
@@ -25,6 +25,7 @@ interface FakeWorkerState {
errorHandlers: Array<(error: unknown) => void>;
ranWith: 'autorun-false' | 'autorun-true' | null;
closed: boolean;
eventHandlers?: Map<string, (...args: unknown[]) => void>;
}
function buildFakeQueue(state: FakeQueueState) {
@@ -55,10 +56,14 @@ function buildFakeWorker(state: FakeWorkerState) {
state.processor = processor;
state.options = options;
return {
on: (event: string, handler: (error: unknown) => void) => {
on: (event: string, handler: (...args: unknown[]) => void) => {
if (event === 'error') {
state.errorHandlers.push(handler);
state.errorHandlers.push(handler as (error: unknown) => void);
}
// Phase 12 — capture all lifecycle handlers on the fake worker so
// tests can fire completed/failed/stalled events synchronously.
const ev = state.eventHandlers ?? (state.eventHandlers = new Map());
ev.set(event, handler);
},
run: () => {
state.ranWith = options.autorun === false ? 'autorun-false' : 'autorun-true';
@@ -165,6 +170,43 @@ describe('ServerJobQueue', () => {
).not.toThrow();
});
it('Phase 12 — emits completed/failed/stalled lifecycle events through observe()', () => {
const queueState: FakeQueueState = { added: [], removed: [], closed: false };
const workerState: FakeWorkerState = {
processor: null, options: null, errorHandlers: [], ranWith: null, closed: false,
};
const sjq = new ServerJobQueue<{ x: number }>({
name: 'q', config: fakeConfig,
queueFactory: buildFakeQueue(queueState),
workerFactory: buildFakeWorker(workerState),
});
const events: { kind: string; jobId?: string; arg?: unknown }[] = [];
sjq.observe({
onCompleted: (jobId, durationMs) => { events.push({ kind: 'completed', jobId, arg: durationMs }); },
onFailed: (jobId, attempts, reason) => { events.push({ kind: 'failed', jobId: jobId ?? '?', arg: { attempts, reason } }); },
onStalled: (jobId) => { events.push({ kind: 'stalled', jobId }); },
onError: (err) => { events.push({ kind: 'error', arg: err }); },
});
sjq.start(async () => {});
// Fire a fake "active" then "completed" so duration is positive.
workerState.eventHandlers?.get('active')?.({ id: 'job1' });
workerState.eventHandlers?.get('completed')?.({ id: 'job1', data: { source_type: 'agent_event' } }, { ok: true });
workerState.eventHandlers?.get('failed')?.({ id: 'job2', data: { source_type: 'agent_event' }, attemptsMade: 2 }, new Error('boom'));
workerState.eventHandlers?.get('stalled')?.('job3');
workerState.errorHandlers[0]!(new Error('worker err'));
expect(events.find(e => e.kind === 'completed')?.jobId).toBe('job1');
expect(events.find(e => e.kind === 'failed')?.jobId).toBe('job2');
expect(events.find(e => e.kind === 'stalled')?.jobId).toBe('job3');
expect(events.some(e => e.kind === 'error')).toBe(true);
const counters = sjq.getLifecycleCounters();
expect(counters.stalled).toBe(1);
expect(counters.errored).toBe(1);
});
it('closes worker and queue on close()', async () => {
const queueState: FakeQueueState = { added: [], removed: [], closed: false };
const workerState: FakeWorkerState = {
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'bun:test';
import express from 'express';
import { isAcceptableRequestId, requestIdMiddleware } from '../../../src/server/middleware/request-id.js';
describe('Phase 12 — request_id middleware', () => {
it('mints a request id when none is provided', async () => {
const app = express();
app.use(requestIdMiddleware());
app.get('/echo', (req, res) => {
res.json({ id: req.requestId ?? null });
});
const server = app.listen(0);
try {
const port = (server.address() as { port: number }).port;
const resp = await fetch(`http://127.0.0.1:${port}/echo`);
expect(resp.headers.get('x-request-id')).toBeTruthy();
const body = await resp.json() as { id: string };
expect(body.id.length).toBeGreaterThan(0);
expect(body.id).toBe(resp.headers.get('x-request-id'));
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
it('honors a safe inbound X-Request-Id header', async () => {
const app = express();
app.use(requestIdMiddleware());
app.get('/echo', (req, res) => {
res.json({ id: req.requestId ?? null });
});
const server = app.listen(0);
try {
const port = (server.address() as { port: number }).port;
const resp = await fetch(`http://127.0.0.1:${port}/echo`, {
headers: { 'X-Request-Id': 'abc-123_DEF' },
});
const body = await resp.json() as { id: string };
expect(body.id).toBe('abc-123_DEF');
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
it('rejects unsafe inbound request ids by minting a fresh uuid', async () => {
const app = express();
app.use(requestIdMiddleware());
app.get('/echo', (req, res) => {
res.json({ id: req.requestId ?? null });
});
const server = app.listen(0);
try {
const port = (server.address() as { port: number }).port;
const resp = await fetch(`http://127.0.0.1:${port}/echo`, {
headers: { 'X-Request-Id': '<script>alert(1)</script>' },
});
const body = await resp.json() as { id: string };
expect(body.id).not.toBe('<script>alert(1)</script>');
expect(isAcceptableRequestId(body.id)).toBe(true);
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
it('isAcceptableRequestId enforces the safe-charset, length contract', () => {
expect(isAcceptableRequestId('abc-123')).toBe(true);
expect(isAcceptableRequestId('A1_B2-C3')).toBe(true);
expect(isAcceptableRequestId('')).toBe(false);
expect(isAcceptableRequestId('a'.repeat(65))).toBe(false);
expect(isAcceptableRequestId('foo bar')).toBe(false);
expect(isAcceptableRequestId('-leading-dash')).toBe(false);
expect(isAcceptableRequestId('with"quote')).toBe(false);
});
});
@@ -0,0 +1,269 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import pg from 'pg';
import { createHash, randomBytes } from 'crypto';
import { Server } from '../../../src/services/server/Server.js';
import { ServerV1PostgresRoutes } from '../../../src/server/routes/v1/ServerV1PostgresRoutes.js';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
type PostgresPoolClient,
type PostgresStorageRepositories,
} from '../../../src/storage/postgres/index.js';
import { DisabledServerBetaQueueManager } from '../../../src/server/runtime/types.js';
import { logger } from '../../../src/utils/logger.js';
// Phase 12 — integration tests for GET /v1/jobs (with admin payload guard),
// POST /v1/jobs/:id/retry, POST /v1/jobs/:id/cancel. Postgres-gated; skipped
// without CLAUDE_MEM_TEST_POSTGRES_URL.
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
function newApiKey(): { raw: string; hash: string } {
const raw = `cm_${randomBytes(24).toString('hex')}`;
const hash = createHash('sha256').update(raw).digest('hex');
return { raw, hash };
}
describe('Phase 12 — GET /v1/jobs + retry/cancel routes', () => {
if (!testDatabaseUrl) {
it.skip('requires CLAUDE_MEM_TEST_POSTGRES_URL', () => {});
return;
}
let pool: pg.Pool;
let client: PostgresPoolClient;
let schemaName: string;
let storage: PostgresStorageRepositories;
let server: Server;
let port: number;
let teamId: string;
let projectId: string;
let writeKey: string;
let adminKey: string;
let jobId: string;
let loggerSpies: ReturnType<typeof spyOn>[] = [];
beforeEach(async () => {
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
];
pool = new pg.Pool({ connectionString: testDatabaseUrl });
client = await pool.connect();
schemaName = `cm_phase12_jobs_${crypto.randomUUID().replaceAll('-', '_')}`;
await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
await bootstrapServerBetaPostgresSchema(client);
pool.on('connect', (c) => {
c.query(`SET search_path TO ${quoteIdentifier(schemaName)}`).catch(() => {});
});
storage = createPostgresStorageRepositories(client);
const team = await storage.teams.create({ name: 'team-a' });
const project = await storage.projects.create({ teamId: team.id, name: 'p1' });
teamId = team.id;
projectId = project.id;
const writeMaterial = newApiKey();
writeKey = writeMaterial.raw;
await storage.auth.createApiKey({
keyHash: writeMaterial.hash,
teamId,
projectId: null,
actorId: 'system:phase12-write',
scopes: ['memories:read', 'memories:write'],
});
const adminMaterial = newApiKey();
adminKey = adminMaterial.raw;
await storage.auth.createApiKey({
keyHash: adminMaterial.hash,
teamId,
projectId: null,
actorId: 'system:phase12-admin',
scopes: ['memories:read', 'memories:write', 'memories:admin'],
});
const event = await storage.agentEvents.create({
projectId,
teamId,
sourceAdapter: 'api',
eventType: 'tool_use',
payload: { sensitive: 'should_not_leak' },
occurredAt: new Date(),
});
const job = await storage.observationGenerationJobs.create({
projectId,
teamId,
sourceType: 'agent_event',
sourceId: event.id,
agentEventId: event.id,
jobType: 'observation_generate_for_event',
payload: { sensitive: 'should_not_leak', request_id: 'req-12345' },
});
jobId = job.id;
server = new Server({
getInitializationComplete: () => true,
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker.cjs',
runtime: 'server-beta',
getAiStatus: () => ({ provider: 'disabled', authMethod: 'api-key', lastInteraction: null }),
});
server.registerRoutes(new ServerV1PostgresRoutes({
pool: pool as never,
queueManager: new DisabledServerBetaQueueManager('disabled in tests'),
authMode: 'api-key',
runtime: 'server-beta',
sessionPolicy: 'per-event',
getEventQueue: () => null,
getSummaryQueue: () => null,
}));
server.finalizeRoutes();
await server.listen(0, '127.0.0.1');
const address = server.getHttpServer()?.address();
if (!address || typeof address === 'string') throw new Error('no port');
port = address.port;
});
afterEach(async () => {
try { await server.close(); } catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== 'ERR_SERVER_NOT_RUNNING') throw error;
}
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
client.release();
await pool.end();
loggerSpies.forEach(spy => spy.mockRestore());
mock.restore();
});
function authedFetch(rawKey: string, path: string, init?: RequestInit): Promise<Response> {
return fetch(`http://127.0.0.1:${port}${path}`, {
...(init ?? {}),
headers: {
Authorization: `Bearer ${rawKey}`,
'Content-Type': 'application/json',
...((init?.headers as Record<string, string>) ?? {}),
},
});
}
it('GET /v1/jobs lists jobs without payload by default', async () => {
const resp = await authedFetch(writeKey, '/v1/jobs');
expect(resp.status).toBe(200);
const body = await resp.json() as { jobs: Array<Record<string, unknown>>; total: number };
expect(body.total).toBe(1);
expect(body.jobs[0]!.payload).toBeUndefined();
});
it('GET /v1/jobs?include=payload rejects without admin scope', async () => {
const resp = await authedFetch(writeKey, '/v1/jobs?include=payload');
expect(resp.status).toBe(403);
});
it('GET /v1/jobs?include=payload succeeds with admin scope and returns payload', async () => {
const resp = await authedFetch(adminKey, '/v1/jobs?include=payload');
expect(resp.status).toBe(200);
const body = await resp.json() as { jobs: Array<Record<string, unknown>> };
const payload = body.jobs[0]!.payload as { sensitive: string; request_id?: string };
expect(payload.sensitive).toBe('should_not_leak');
expect(payload.request_id).toBe('req-12345');
});
it('GET /v1/jobs supports source_type and since filters', async () => {
const future = new Date(Date.now() + 60_000).toISOString();
const resp = await authedFetch(writeKey, `/v1/jobs?source_type=agent_event&since=${future}`);
expect(resp.status).toBe(200);
const body = await resp.json() as { total: number };
expect(body.total).toBe(0);
});
it('POST /v1/jobs/:id/retry on a queued job is a no-op', async () => {
const resp = await authedFetch(writeKey, `/v1/jobs/${jobId}/retry`, { method: 'POST' });
expect(resp.status).toBe(200);
const body = await resp.json() as { alreadyQueued: boolean };
expect(body.alreadyQueued).toBe(true);
// Idempotent: a second call also reports already queued.
const resp2 = await authedFetch(writeKey, `/v1/jobs/${jobId}/retry`, { method: 'POST' });
expect(resp2.status).toBe(200);
const body2 = await resp2.json() as { alreadyQueued: boolean };
expect(body2.alreadyQueued).toBe(true);
});
it('POST /v1/jobs/:id/retry on a failed job re-queues idempotently', async () => {
// Force the row into failed.
await client.query(
`UPDATE observation_generation_jobs SET status = 'failed', failed_at = now() WHERE id = $1`,
[jobId],
);
const resp = await authedFetch(writeKey, `/v1/jobs/${jobId}/retry`, { method: 'POST' });
expect(resp.status).toBe(200);
const body = await resp.json() as {
alreadyQueued: boolean;
retriedCount: number;
generationJob: { status: string };
};
expect(body.alreadyQueued).toBe(false);
expect(body.retriedCount).toBe(1);
expect(body.generationJob.status).toBe('queued');
// Second retry on now-queued row is a no-op.
const resp2 = await authedFetch(writeKey, `/v1/jobs/${jobId}/retry`, { method: 'POST' });
const body2 = await resp2.json() as { alreadyQueued: boolean };
expect(body2.alreadyQueued).toBe(true);
// Audit row written.
const audit = await client.query(
`SELECT * FROM audit_log WHERE action = 'generation_job.retried_by_operator' AND resource_id = $1`,
[jobId],
);
expect(audit.rows.length).toBeGreaterThanOrEqual(1);
});
it('POST /v1/jobs/:id/cancel cancels a queued job and emits audit', async () => {
const resp = await authedFetch(writeKey, `/v1/jobs/${jobId}/cancel`, { method: 'POST' });
expect(resp.status).toBe(200);
const body = await resp.json() as { generationJob: { status: string }; alreadyCancelled: boolean };
expect(body.alreadyCancelled).toBe(false);
expect(body.generationJob.status).toBe('cancelled');
// Idempotent.
const resp2 = await authedFetch(writeKey, `/v1/jobs/${jobId}/cancel`, { method: 'POST' });
const body2 = await resp2.json() as { alreadyCancelled: boolean };
expect(body2.alreadyCancelled).toBe(true);
const audit = await client.query(
`SELECT * FROM audit_log WHERE action = 'generation_job.cancelled_by_operator' AND resource_id = $1`,
[jobId],
);
expect(audit.rows.length).toBeGreaterThanOrEqual(1);
});
it('request_id flows from header into audit details', async () => {
const resp = await authedFetch(writeKey, '/v1/jobs', {
headers: { 'X-Request-Id': 'op-correlation-007' },
});
expect(resp.status).toBe(200);
expect(resp.headers.get('x-request-id')).toBe('op-correlation-007');
const body = await resp.json() as { requestId: string };
expect(body.requestId).toBe('op-correlation-007');
const audit = await client.query(
`SELECT details FROM audit_log WHERE action = 'observation.read' ORDER BY created_at DESC LIMIT 1`,
);
const details = audit.rows[0]?.details as { requestId?: string };
expect(details?.requestId).toBe('op-correlation-007');
});
});
@@ -0,0 +1,259 @@
// SPDX-License-Identifier: Apache-2.0
//
// Phase 8 — verifies the new /v1/memories, /v1/search, /v1/context, and
// /v1/jobs/:id REST endpoints behave the way the MCP `observation_*` tools
// expect, and verifies the ServerBetaClient (which the MCP tools use) hits
// those endpoints end-to-end.
//
// Postgres-gated: requires CLAUDE_MEM_TEST_POSTGRES_URL.
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import pg from 'pg';
import { createHash, randomBytes } from 'crypto';
import { Server } from '../../../src/services/server/Server.js';
import { ServerV1PostgresRoutes } from '../../../src/server/routes/v1/ServerV1PostgresRoutes.js';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
type PostgresPoolClient,
type PostgresStorageRepositories,
} from '../../../src/storage/postgres/index.js';
import { DisabledServerBetaQueueManager } from '../../../src/server/runtime/types.js';
import { ServerBetaClient } from '../../../src/services/hooks/server-beta-client.js';
import { logger } from '../../../src/utils/logger.js';
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
function newApiKey(): { raw: string; hash: string } {
const raw = `cm_${randomBytes(24).toString('hex')}`;
const hash = createHash('sha256').update(raw).digest('hex');
return { raw, hash };
}
describe('Phase 8 MCP-backing REST endpoints (/v1/memories, /v1/search, /v1/context, /v1/jobs/:id)', () => {
if (!testDatabaseUrl) {
it.skip('requires CLAUDE_MEM_TEST_POSTGRES_URL', () => {});
return;
}
let pool: pg.Pool;
let client: PostgresPoolClient;
let schemaName: string;
let storage: PostgresStorageRepositories;
let server: Server;
let port: number;
let teamId: string;
let projectId: string;
let apiKeyRaw: string;
let loggerSpies: ReturnType<typeof spyOn>[] = [];
beforeEach(async () => {
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
];
pool = new pg.Pool({ connectionString: testDatabaseUrl });
client = await pool.connect();
schemaName = `cm_phase8_routes_${crypto.randomUUID().replaceAll('-', '_')}`;
await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
await bootstrapServerBetaPostgresSchema(client);
pool.on('connect', (poolClient) => {
poolClient.query(`SET search_path TO ${quoteIdentifier(schemaName)}`).catch(() => {});
});
storage = createPostgresStorageRepositories(client);
const team = await storage.teams.create({ name: 'team' });
const project = await storage.projects.create({ teamId: team.id, name: 'p' });
teamId = team.id;
projectId = project.id;
const { raw, hash } = newApiKey();
apiKeyRaw = raw;
await storage.auth.createApiKey({
keyHash: hash,
teamId,
projectId,
actorId: 'test',
scopes: ['memories:read', 'memories:write'],
});
server = new Server({
getInitializationComplete: () => true,
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker.cjs',
runtime: 'server-beta',
getAiStatus: () => ({ provider: 'disabled', authMethod: 'api-key', lastInteraction: null }),
});
server.registerRoutes(new ServerV1PostgresRoutes({
pool: pool as never,
queueManager: new DisabledServerBetaQueueManager('disabled in tests'),
authMode: 'api-key',
runtime: 'server-beta',
sessionPolicy: 'per-event',
// Capture-only queue stub so /v1/events succeeds without BullMQ.
getEventQueue: () => ({
async add() {},
async getJob() { return null; },
async remove() {},
}) as never,
getSummaryQueue: () => ({
async add() {},
async getJob() { return null; },
async remove() {},
}) as never,
}));
server.finalizeRoutes();
await server.listen(0, '127.0.0.1');
const address = server.getHttpServer()?.address();
if (!address || typeof address === 'string') throw new Error('no port');
port = address.port;
});
afterEach(async () => {
try { await server.close(); } catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== 'ERR_SERVER_NOT_RUNNING') throw error;
}
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
client.release();
await pool.end();
loggerSpies.forEach(spy => spy.mockRestore());
mock.restore();
});
function buildClient(): ServerBetaClient {
return new ServerBetaClient({
serverBaseUrl: `http://127.0.0.1:${port}`,
apiKey: apiKeyRaw,
});
}
it('observation_add path: POST /v1/memories inserts an observation without enqueuing generation', async () => {
const c = buildClient();
const before = await pool.query(`SELECT count(*)::int AS n FROM observation_generation_jobs`);
const result = await c.addObservation({
projectId,
content: 'Manual observation about login bug',
kind: 'manual',
metadata: { tag: 'mcp' },
});
expect(result.memory.id).toBeTruthy();
expect(result.memory.projectId).toBe(projectId);
expect(result.memory.content).toBe('Manual observation about login bug');
const obsCount = await pool.query(`SELECT count(*)::int AS n FROM observations`);
expect(obsCount.rows[0]?.n).toBe(1);
// Anti-pattern guard: /v1/memories MUST NOT create a generation job.
const after = await pool.query(`SELECT count(*)::int AS n FROM observation_generation_jobs`);
expect(after.rows[0]?.n).toBe(before.rows[0]?.n);
});
it('observation_record_event path: POST /v1/events creates event row + outbox row atomically', async () => {
const c = buildClient();
const result = await c.recordEvent({
projectId,
sourceType: 'api',
eventType: 'mcp_test_event',
occurredAtEpoch: Date.now(),
payload: { hello: 'world' },
});
expect(result.event.id).toBeTruthy();
const eventRows = await pool.query(`SELECT id, project_id FROM agent_events`);
expect(eventRows.rows).toHaveLength(1);
// The outbox row should exist because ?generate defaults to true.
const jobRows = await pool.query(
`SELECT id, source_type, status FROM observation_generation_jobs WHERE source_type = 'agent_event'`,
);
expect(jobRows.rows).toHaveLength(1);
expect(jobRows.rows[0]?.status).toBe('queued');
});
it('observation_search path: POST /v1/search returns FTS-ranked observations from PostgresObservationRepository', async () => {
// Seed two observations directly via REST so we exercise the same write path.
const c = buildClient();
await c.addObservation({ projectId, content: 'Refactored authentication middleware to use JWT verification', kind: 'manual' });
await c.addObservation({ projectId, content: 'Fixed flaky test in payment processing', kind: 'manual' });
const matches = await c.searchObservations({ projectId, query: 'authentication', limit: 10 });
expect(matches.observations.length).toBeGreaterThanOrEqual(1);
expect(matches.observations[0]?.content).toContain('authentication');
const noMatches = await c.searchObservations({ projectId, query: 'nonexistent_xyz_term', limit: 10 });
expect(noMatches.observations).toHaveLength(0);
});
it('observation_context path: POST /v1/context returns observations + concatenated context', async () => {
const c = buildClient();
await c.addObservation({ projectId, content: 'first observation about deployment pipeline', kind: 'manual' });
await c.addObservation({ projectId, content: 'second observation about deployment pipeline', kind: 'manual' });
const result = await c.contextObservations({ projectId, query: 'deployment', limit: 5 });
expect(result.observations.length).toBeGreaterThanOrEqual(2);
expect(result.context).toContain('deployment pipeline');
// Context joins observations with a blank line.
expect(result.context.split('\n\n').length).toBeGreaterThanOrEqual(2);
});
it('observation_generation_status path: GET /v1/jobs/:id returns the same payload as REST', async () => {
const c = buildClient();
const recorded = await c.recordEvent({
projectId,
sourceType: 'api',
eventType: 'mcp_status_test',
occurredAtEpoch: Date.now(),
});
const jobId = (recorded.generationJob as { id: string } | undefined)?.id;
expect(jobId).toBeTruthy();
const status = await c.getJobStatus(jobId!);
expect(status.generationJob.id).toBe(jobId);
expect(status.generationJob.status).toBe('queued');
// Compare with the raw HTTP response — same payload contract.
const raw = await fetch(`http://127.0.0.1:${port}/v1/jobs/${encodeURIComponent(jobId!)}`, {
headers: { Authorization: `Bearer ${apiKeyRaw}` },
});
expect(raw.status).toBe(200);
const rawJson = await raw.json();
expect(rawJson.generationJob.id).toBe(jobId);
});
it('end-to-end: observation_add → observation_search returns the inserted observation (no provider needed)', async () => {
const c = buildClient();
const inserted = await c.addObservation({
projectId,
content: 'End-to-end harness verifies idempotent search round-trip',
kind: 'manual',
});
const found = await c.searchObservations({ projectId, query: 'harness verifies idempotent', limit: 5 });
expect(found.observations.some(observation => observation.id === inserted.memory.id)).toBe(true);
});
it('cross-tenant request to /v1/search is rejected', async () => {
// Create a foreign project under a different team.
const otherTeam = await storage.teams.create({ name: 'foreign' });
const otherProject = await storage.projects.create({ teamId: otherTeam.id, name: 'foreign-p' });
const c = buildClient();
let caught: unknown;
try {
await c.searchObservations({ projectId: otherProject.id, query: 'anything' });
} catch (error) {
caught = error;
}
// The api-key is scoped to `projectId`; foreign access yields 403.
expect(String(caught)).toContain('403');
});
});
@@ -0,0 +1,226 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import pg from 'pg';
import { createHash, randomBytes } from 'crypto';
import { Server } from '../../../src/services/server/Server.js';
import { ServerV1PostgresRoutes } from '../../../src/server/routes/v1/ServerV1PostgresRoutes.js';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
type PostgresPoolClient,
type PostgresStorageRepositories,
} from '../../../src/storage/postgres/index.js';
import { DisabledServerBetaQueueManager } from '../../../src/server/runtime/types.js';
import { logger } from '../../../src/utils/logger.js';
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
function newApiKey(): { raw: string; hash: string } {
const raw = `cm_${randomBytes(24).toString('hex')}`;
const hash = createHash('sha256').update(raw).digest('hex');
return { raw, hash };
}
describe('ServerV1PostgresRoutes Phase 6 session endpoints', () => {
if (!testDatabaseUrl) {
it.skip('requires CLAUDE_MEM_TEST_POSTGRES_URL', () => {});
return;
}
let pool: pg.Pool;
let client: PostgresPoolClient;
let schemaName: string;
let storage: PostgresStorageRepositories;
let server: Server;
let port: number;
let teamId: string;
let projectId: string;
let apiKeyRaw: string;
let enqueuedEventJobs: { id: string; payload: unknown }[] = [];
let enqueuedSummaryJobs: { id: string; payload: unknown }[] = [];
let loggerSpies: ReturnType<typeof spyOn>[] = [];
beforeEach(async () => {
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
];
pool = new pg.Pool({ connectionString: testDatabaseUrl });
client = await pool.connect();
schemaName = `cm_phase6_routes_${crypto.randomUUID().replaceAll('-', '_')}`;
await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
await bootstrapServerBetaPostgresSchema(client);
pool.on('connect', (poolClient) => {
poolClient.query(`SET search_path TO ${quoteIdentifier(schemaName)}`).catch(() => {});
});
storage = createPostgresStorageRepositories(client);
const team = await storage.teams.create({ name: 'team' });
const project = await storage.projects.create({ teamId: team.id, name: 'p' });
teamId = team.id;
projectId = project.id;
const { raw, hash } = newApiKey();
apiKeyRaw = raw;
await storage.auth.createApiKey({
keyHash: hash,
teamId,
projectId,
actorId: 'test',
scopes: ['memories:read', 'memories:write'],
});
enqueuedEventJobs = [];
enqueuedSummaryJobs = [];
server = new Server({
getInitializationComplete: () => true,
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker.cjs',
runtime: 'server-beta',
getAiStatus: () => ({ provider: 'disabled', authMethod: 'api-key', lastInteraction: null }),
});
server.registerRoutes(new ServerV1PostgresRoutes({
pool: pool as never,
queueManager: new DisabledServerBetaQueueManager('disabled in tests'),
authMode: 'api-key',
runtime: 'server-beta',
sessionPolicy: 'per-event',
getEventQueue: () => ({
async add(jobId: string, payload: unknown) {
enqueuedEventJobs.push({ id: jobId, payload });
},
async getJob() { return null; },
async remove() {},
}) as never,
getSummaryQueue: () => ({
async add(jobId: string, payload: unknown) {
enqueuedSummaryJobs.push({ id: jobId, payload });
},
async getJob() { return null; },
async remove() {},
}) as never,
}));
server.finalizeRoutes();
await server.listen(0, '127.0.0.1');
const address = server.getHttpServer()?.address();
if (!address || typeof address === 'string') throw new Error('no port');
port = address.port;
});
afterEach(async () => {
try { await server.close(); } catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== 'ERR_SERVER_NOT_RUNNING') throw error;
}
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
client.release();
await pool.end();
loggerSpies.forEach(spy => spy.mockRestore());
mock.restore();
});
function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
return fetch(`http://127.0.0.1:${port}${path}`, {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Bearer ${apiKeyRaw}`,
'Content-Type': 'application/json',
},
});
}
it('POST /v1/sessions/start is idempotent on (project_id, external_session_id)', async () => {
const a = await authedFetch('/v1/sessions/start', {
method: 'POST',
body: JSON.stringify({ projectId, externalSessionId: 'ext-1' }),
});
expect(a.status).toBe(201);
const aJson = await a.json();
const b = await authedFetch('/v1/sessions/start', {
method: 'POST',
body: JSON.stringify({ projectId, externalSessionId: 'ext-1' }),
});
expect(b.status).toBe(200);
const bJson = await b.json();
expect(bJson.session.id).toBe(aJson.session.id);
});
it('POST /v1/sessions/:id/end enqueues exactly one summary job, idempotent on re-end', async () => {
const startResp = await authedFetch('/v1/sessions/start', {
method: 'POST',
body: JSON.stringify({ projectId, externalSessionId: 'ext-end' }),
});
const { session } = await startResp.json();
const end1 = await authedFetch(`/v1/sessions/${session.id}/end`, { method: 'POST' });
expect(end1.status).toBe(200);
const end1Json = await end1.json();
expect(end1Json.generationJob.sourceType).toBe('session_summary');
expect(end1Json.session.endedAtEpoch).not.toBeNull();
expect(enqueuedSummaryJobs.length).toBe(1);
const end2 = await authedFetch(`/v1/sessions/${session.id}/end`, { method: 'POST' });
expect(end2.status).toBe(200);
const end2Json = await end2.json();
// Same generation job id (UNIQUE collapse).
expect(end2Json.generationJob.id).toBe(end1Json.generationJob.id);
// Re-ending may still publish to the queue (BullMQ add() is idempotent on
// jobId), but the outbox row count is unchanged. We assert the outbox
// collapse rather than queue-publish count.
const allJobs = await storage.observationGenerationJobs.listByStatusForScope({
status: 'queued',
projectId,
teamId,
});
const summaryJobs = allJobs.filter(j => j.sourceType === 'session_summary');
expect(summaryJobs.length).toBe(1);
});
it('GET /v1/sessions/:id returns 404 for cross-project requests', async () => {
// Create a foreign project + session under a different team.
const otherTeam = await storage.teams.create({ name: 'other' });
const otherProject = await storage.projects.create({ teamId: otherTeam.id, name: 'other-p' });
const otherSession = await storage.sessions.create({
teamId: otherTeam.id,
projectId: otherProject.id,
externalSessionId: 'foreign',
});
const resp = await authedFetch(`/v1/sessions/${otherSession.id}`);
expect(resp.status).toBe(404);
});
it('POST /v1/events with per-event policy enqueues immediately', async () => {
const startResp = await authedFetch('/v1/sessions/start', {
method: 'POST',
body: JSON.stringify({ projectId, externalSessionId: 'ext-evt' }),
});
const { session } = await startResp.json();
const eventResp = await authedFetch('/v1/events', {
method: 'POST',
body: JSON.stringify({
projectId,
serverSessionId: session.id,
sourceType: 'api',
eventType: 'tool_use',
payload: { tool: 'read' },
occurredAtEpoch: Date.now(),
}),
});
expect(eventResp.status).toBe(201);
expect(enqueuedEventJobs.length).toBe(1);
});
});
@@ -0,0 +1,354 @@
// SPDX-License-Identifier: Apache-2.0
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'bun:test';
import pg from 'pg';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
PostgresServerSessionsRepository,
type PostgresPoolClient,
type PostgresStorageRepositories,
} from '../../../src/storage/postgres/index.js';
import { ServerSessionRuntimeRepository } from '../../../src/server/runtime/ServerSessionRuntimeRepository.js';
import {
buildEnqueueEventDecision,
buildSummaryJobId,
resolveSessionGenerationPolicy,
} from '../../../src/server/runtime/SessionGenerationPolicy.js';
import { processSessionSummaryResponse } from '../../../src/server/generation/processGeneratedResponse.js';
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
describe('SessionGenerationPolicy (pure)', () => {
it('defaults to per-event when env is unset', () => {
const oldEnv = process.env.CLAUDE_MEM_SERVER_SESSION_POLICY;
delete process.env.CLAUDE_MEM_SERVER_SESSION_POLICY;
try {
const resolved = resolveSessionGenerationPolicy();
expect(resolved.policy).toBe('per-event');
} finally {
if (oldEnv !== undefined) process.env.CLAUDE_MEM_SERVER_SESSION_POLICY = oldEnv;
}
});
it('honors explicit policy override', () => {
expect(resolveSessionGenerationPolicy({ policy: 'debounce' }).policy).toBe('debounce');
expect(resolveSessionGenerationPolicy({ policy: 'end-of-session' }).policy).toBe('end-of-session');
expect(resolveSessionGenerationPolicy({ policy: 'per-event' }).policy).toBe('per-event');
});
it('per-event policy enqueues immediately with no delay', () => {
const decision = buildEnqueueEventDecision({
event: makeFakeEvent('e1', 's1'),
outbox: makeFakeOutbox('j1', 'e1'),
}, { policy: 'per-event' });
expect(decision.shouldEnqueue).toBe(true);
expect(decision.reason).toBe('per-event');
expect(decision.jobsOptions).toBeUndefined();
});
it('debounce policy enqueues with delay', () => {
const decision = buildEnqueueEventDecision({
event: makeFakeEvent('e1', 's1'),
outbox: makeFakeOutbox('j1', 'e1'),
}, { policy: 'debounce', debounceWindowMs: 1234 });
expect(decision.shouldEnqueue).toBe(true);
expect(decision.reason).toBe('debounce');
expect(decision.jobsOptions?.delay).toBe(1234);
});
it('end-of-session policy skips enqueue', () => {
const decision = buildEnqueueEventDecision({
event: makeFakeEvent('e1', 's1'),
outbox: makeFakeOutbox('j1', 'e1'),
}, { policy: 'end-of-session' });
expect(decision.shouldEnqueue).toBe(false);
expect(decision.reason).toBe('end-of-session-skip');
});
it('summary job id is deterministic per server_session_id', () => {
const a = buildSummaryJobId({ serverSessionId: 's1', teamId: 't', projectId: 'p' });
const b = buildSummaryJobId({ serverSessionId: 's1', teamId: 't', projectId: 'p' });
const c = buildSummaryJobId({ serverSessionId: 's2', teamId: 't', projectId: 'p' });
expect(a).toBe(b);
expect(a).not.toBe(c);
expect(a).not.toContain(':');
});
});
describe('ServerSessionRuntimeRepository + Postgres', () => {
if (!testDatabaseUrl) {
it.skip('requires CLAUDE_MEM_TEST_POSTGRES_URL', () => {});
return;
}
const pool = new pg.Pool({ connectionString: testDatabaseUrl });
let client: PostgresPoolClient;
let schemaName: string;
let storage: PostgresStorageRepositories;
let runtime: ServerSessionRuntimeRepository;
let teamId: string;
let projectId: string;
beforeEach(async () => {
client = await pool.connect();
schemaName = `cm_phase6_${crypto.randomUUID().replaceAll('-', '_')}`;
await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
await bootstrapServerBetaPostgresSchema(client);
storage = createPostgresStorageRepositories(client);
runtime = new ServerSessionRuntimeRepository({ client });
const team = await storage.teams.create({ name: 'team' });
const project = await storage.projects.create({ teamId: team.id, name: 'p' });
teamId = team.id;
projectId = project.id;
});
afterEach(async () => {
if (!client) return;
try {
if (schemaName) {
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
}
} finally {
client.release();
}
});
afterAll(async () => {
await pool.end();
});
it('getActiveSession is idempotent on (project_id, external_session_id)', async () => {
const a = await runtime.getActiveSession({
teamId,
projectId,
externalSessionId: 'ext-1',
});
const b = await runtime.getActiveSession({
teamId,
projectId,
externalSessionId: 'ext-1',
});
expect(a.id).toBe(b.id);
expect(a.externalSessionId).toBe('ext-1');
});
it('endSession is idempotent and never duplicates summary jobs', async () => {
const session = await runtime.getActiveSession({
teamId,
projectId,
externalSessionId: 'ext-1',
});
const ended1 = await runtime.endSession({ id: session.id, projectId, teamId });
expect(ended1?.endedAtEpoch).not.toBeNull();
const firstEndedAt = ended1!.endedAtEpoch;
// Re-end: should preserve original ended_at because of COALESCE.
const ended2 = await runtime.endSession({ id: session.id, projectId, teamId });
expect(ended2?.endedAtEpoch).toBe(firstEndedAt);
// Now create a summary outbox row twice — UNIQUE on
// (team_id, project_id, source_type, source_id, job_type) collapses.
const job1 = await storage.observationGenerationJobs.create({
projectId,
teamId,
sourceType: 'session_summary',
sourceId: session.id,
serverSessionId: session.id,
jobType: 'observation_generate_session_summary',
});
const job2 = await storage.observationGenerationJobs.create({
projectId,
teamId,
sourceType: 'session_summary',
sourceId: session.id,
serverSessionId: session.id,
jobType: 'observation_generate_session_summary',
});
expect(job2.id).toBe(job1.id);
});
it('listUnprocessedEvents excludes events with completed jobs', async () => {
const session = await runtime.getActiveSession({
teamId,
projectId,
externalSessionId: 'ext-1',
});
const eventA = await storage.agentEvents.create({
projectId,
teamId,
serverSessionId: session.id,
sourceAdapter: 'api',
eventType: 'tool_use',
payload: { x: 1 },
occurredAt: new Date(Date.now() - 2000),
});
const eventB = await storage.agentEvents.create({
projectId,
teamId,
serverSessionId: session.id,
sourceAdapter: 'api',
eventType: 'tool_use',
payload: { x: 2 },
occurredAt: new Date(),
});
// Create a job for eventA and mark it completed.
const completedJob = await storage.observationGenerationJobs.create({
projectId,
teamId,
sourceType: 'agent_event',
sourceId: eventA.id,
agentEventId: eventA.id,
serverSessionId: session.id,
jobType: 'observation_generate_for_event',
});
await storage.observationGenerationJobs.transitionStatus({
id: completedJob.id,
projectId,
teamId,
status: 'processing',
});
await storage.observationGenerationJobs.transitionStatus({
id: completedJob.id,
projectId,
teamId,
status: 'completed',
});
const unprocessed = await runtime.listUnprocessedEvents({
teamId,
projectId,
serverSessionId: session.id,
});
expect(unprocessed.map(e => e.id)).toEqual([eventB.id]);
});
it('cross-tenant getById returns null', async () => {
const otherTeam = await storage.teams.create({ name: 'other' });
const otherProject = await storage.projects.create({ teamId: otherTeam.id, name: 'other-p' });
const otherSession = await new PostgresServerSessionsRepository(client).create({
teamId: otherTeam.id,
projectId: otherProject.id,
externalSessionId: 'other-1',
});
// Trying to read other team's session under our scope returns null.
const result = await runtime.getById({
id: otherSession.id,
teamId,
projectId,
});
expect(result).toBeNull();
});
it('processSessionSummaryResponse persists kind=summary observation idempotently', async () => {
const session = await runtime.getActiveSession({
teamId,
projectId,
externalSessionId: 'ext-summary',
});
const job = await storage.observationGenerationJobs.create({
projectId,
teamId,
sourceType: 'session_summary',
sourceId: session.id,
serverSessionId: session.id,
jobType: 'observation_generate_session_summary',
});
await storage.observationGenerationJobs.transitionStatus({
id: job.id,
projectId,
teamId,
status: 'processing',
});
const summaryXml = `<summary>
<request>investigate session</request>
<investigated>queries and traces</investigated>
<learned>system behavior</learned>
<completed>analysis</completed>
<next_steps>plan refactor</next_steps>
<notes>none</notes>
</summary>`;
const outcome1 = await processSessionSummaryResponse({
pool,
job,
rawText: summaryXml,
providerLabel: 'claude',
});
expect(outcome1.kind).toBe('completed');
if (outcome1.kind === 'completed') {
expect(outcome1.observations.length).toBeGreaterThan(0);
expect(outcome1.observations[0]!.kind).toBe('summary');
}
// Idempotent: replaying does not produce new observations because the
// job is already in completed state.
const outcome2 = await processSessionSummaryResponse({
pool,
job,
rawText: summaryXml,
providerLabel: 'claude',
});
expect(outcome2.kind).toBe('completed');
if (outcome2.kind === 'completed') {
expect(outcome2.observations.length).toBe(0);
}
});
});
function makeFakeEvent(id: string, sessionId: string | null) {
return {
id,
projectId: 'p',
teamId: 't',
serverSessionId: sessionId,
sourceAdapter: 'api',
sourceEventId: null,
idempotencyKey: 'k',
eventType: 'tool_use',
payload: {},
metadata: {},
occurredAtEpoch: 0,
receivedAtEpoch: 0,
createdAtEpoch: 0,
};
}
function makeFakeOutbox(id: string, eventId: string) {
return {
id,
projectId: 'p',
teamId: 't',
agentEventId: eventId,
sourceType: 'agent_event' as const,
sourceId: eventId,
serverSessionId: null,
jobType: 'observation_generate_for_event',
status: 'queued' as const,
idempotencyKey: 'k',
bullmqJobId: null,
attempts: 0,
maxAttempts: 3,
nextAttemptAtEpoch: null,
lockedAtEpoch: null,
lockedBy: null,
completedAtEpoch: null,
failedAtEpoch: null,
cancelledAtEpoch: null,
lastError: null,
payload: {},
createdAtEpoch: 0,
updatedAtEpoch: 0,
};
}
@@ -0,0 +1,246 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import pg from 'pg';
import { createHash, randomBytes } from 'crypto';
import { Server } from '../../../src/services/server/Server.js';
import { ServerV1PostgresRoutes } from '../../../src/server/routes/v1/ServerV1PostgresRoutes.js';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
type PostgresPoolClient,
type PostgresStorageRepositories,
} from '../../../src/storage/postgres/index.js';
import { DisabledServerBetaQueueManager } from '../../../src/server/runtime/types.js';
import { logger } from '../../../src/utils/logger.js';
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
function newApiKey(): { raw: string; hash: string } {
const raw = `cm_${randomBytes(24).toString('hex')}`;
const hash = createHash('sha256').update(raw).digest('hex');
return { raw, hash };
}
describe('Phase 11 — team/project queue listing endpoints', () => {
if (!testDatabaseUrl) {
it.skip('requires CLAUDE_MEM_TEST_POSTGRES_URL', () => {});
return;
}
let pool: pg.Pool;
let client: PostgresPoolClient;
let schemaName: string;
let storage: PostgresStorageRepositories;
let server: Server;
let port: number;
// Tenant scaffolding: two teams, two projects in team-A, one project in
// team-B. Three api keys: team-A team-scoped, team-A project-1-scoped,
// team-B team-scoped.
let teamAId: string;
let teamBId: string;
let projectA1Id: string;
let projectA2Id: string;
let projectB1Id: string;
let teamAKey: string;
let projectA1Key: string;
let teamBKey: string;
let loggerSpies: ReturnType<typeof spyOn>[] = [];
beforeEach(async () => {
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
];
pool = new pg.Pool({ connectionString: testDatabaseUrl });
client = await pool.connect();
schemaName = `cm_phase11_routes_${crypto.randomUUID().replaceAll('-', '_')}`;
await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
await bootstrapServerBetaPostgresSchema(client);
pool.on('connect', (poolClient) => {
poolClient.query(`SET search_path TO ${quoteIdentifier(schemaName)}`).catch(() => {});
});
storage = createPostgresStorageRepositories(client);
const teamA = await storage.teams.create({ name: 'team-a' });
const teamB = await storage.teams.create({ name: 'team-b' });
const projectA1 = await storage.projects.create({ teamId: teamA.id, name: 'p-a-1' });
const projectA2 = await storage.projects.create({ teamId: teamA.id, name: 'p-a-2' });
const projectB1 = await storage.projects.create({ teamId: teamB.id, name: 'p-b-1' });
teamAId = teamA.id;
teamBId = teamB.id;
projectA1Id = projectA1.id;
projectA2Id = projectA2.id;
projectB1Id = projectB1.id;
const teamAKeyMaterial = newApiKey();
teamAKey = teamAKeyMaterial.raw;
await storage.auth.createApiKey({
keyHash: teamAKeyMaterial.hash,
teamId: teamAId,
projectId: null,
actorId: 'system:phase11-team-a-key',
scopes: ['memories:read', 'memories:write'],
});
const projectA1KeyMaterial = newApiKey();
projectA1Key = projectA1KeyMaterial.raw;
await storage.auth.createApiKey({
keyHash: projectA1KeyMaterial.hash,
teamId: teamAId,
projectId: projectA1Id,
actorId: 'system:phase11-project-a1-key',
scopes: ['memories:read', 'memories:write'],
});
const teamBKeyMaterial = newApiKey();
teamBKey = teamBKeyMaterial.raw;
await storage.auth.createApiKey({
keyHash: teamBKeyMaterial.hash,
teamId: teamBId,
projectId: null,
actorId: 'system:phase11-team-b-key',
scopes: ['memories:read'],
});
// Seed two events in projectA1, one in projectA2, one in projectB1.
// Each event creates a generation_jobs row via storage.observationGenerationJobs.
for (const projectId of [projectA1Id, projectA1Id, projectA2Id, projectB1Id]) {
const teamForProject = projectId === projectB1Id ? teamBId : teamAId;
const event = await storage.agentEvents.create({
projectId,
teamId: teamForProject,
sourceAdapter: 'api',
eventType: 'tool_use',
payload: { p: projectId },
occurredAt: new Date(),
});
await storage.observationGenerationJobs.create({
projectId,
teamId: teamForProject,
sourceType: 'agent_event',
sourceId: event.id,
agentEventId: event.id,
jobType: 'observation_generate_for_event',
});
}
server = new Server({
getInitializationComplete: () => true,
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
workerPath: '/test/worker.cjs',
runtime: 'server-beta',
getAiStatus: () => ({ provider: 'disabled', authMethod: 'api-key', lastInteraction: null }),
});
server.registerRoutes(new ServerV1PostgresRoutes({
pool: pool as never,
queueManager: new DisabledServerBetaQueueManager('disabled in tests'),
authMode: 'api-key',
runtime: 'server-beta',
sessionPolicy: 'per-event',
getEventQueue: () => null,
getSummaryQueue: () => null,
}));
server.finalizeRoutes();
await server.listen(0, '127.0.0.1');
const address = server.getHttpServer()?.address();
if (!address || typeof address === 'string') throw new Error('no port');
port = address.port;
});
afterEach(async () => {
try { await server.close(); } catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== 'ERR_SERVER_NOT_RUNNING') throw error;
}
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
client.release();
await pool.end();
loggerSpies.forEach(spy => spy.mockRestore());
mock.restore();
});
function authedFetch(rawKey: string, path: string): Promise<Response> {
return fetch(`http://127.0.0.1:${port}${path}`, {
headers: {
Authorization: `Bearer ${rawKey}`,
'Content-Type': 'application/json',
},
});
}
it('GET /v1/teams/:id/jobs returns ALL jobs for the team when called by team-scoped key', async () => {
const resp = await authedFetch(teamAKey, `/v1/teams/${teamAId}/jobs`);
expect(resp.status).toBe(200);
const body = await resp.json();
// 2 jobs in projectA1 + 1 job in projectA2 = 3
expect(body.total).toBe(3);
expect(body.jobs.length).toBe(3);
expect(body.jobs.every((j: any) => j.teamId === teamAId)).toBe(true);
});
it('GET /v1/teams/:id/jobs returns 404 when caller is from a different team', async () => {
const resp = await authedFetch(teamBKey, `/v1/teams/${teamAId}/jobs`);
expect(resp.status).toBe(404);
});
it('GET /v1/teams/:id/jobs filters to project scope when caller is project-scoped', async () => {
const resp = await authedFetch(projectA1Key, `/v1/teams/${teamAId}/jobs`);
expect(resp.status).toBe(200);
const body = await resp.json();
expect(body.total).toBe(2);
expect(body.jobs.every((j: any) => j.projectId === projectA1Id)).toBe(true);
});
it('GET /v1/projects/:id/jobs returns 404 when project belongs to another team', async () => {
const resp = await authedFetch(teamAKey, `/v1/projects/${projectB1Id}/jobs`);
expect(resp.status).toBe(404);
});
it('GET /v1/projects/:id/jobs returns 404 when project-scoped key requests another project', async () => {
const resp = await authedFetch(projectA1Key, `/v1/projects/${projectA2Id}/jobs`);
expect(resp.status).toBe(404);
});
it('GET /v1/projects/:id/jobs allows project-scoped key to read its own project', async () => {
const resp = await authedFetch(projectA1Key, `/v1/projects/${projectA1Id}/jobs`);
expect(resp.status).toBe(200);
const body = await resp.json();
expect(body.total).toBe(2);
expect(body.jobs.every((j: any) => j.projectId === projectA1Id)).toBe(true);
});
it('GET /v1/projects/:id/jobs allows team-scoped key to read any project under its team', async () => {
const resp = await authedFetch(teamAKey, `/v1/projects/${projectA2Id}/jobs`);
expect(resp.status).toBe(200);
const body = await resp.json();
expect(body.total).toBe(1);
expect(body.jobs.every((j: any) => j.projectId === projectA2Id)).toBe(true);
});
it('supports status filter, limit, and offset', async () => {
const resp = await authedFetch(teamAKey, `/v1/teams/${teamAId}/jobs?status=queued&limit=2&offset=0`);
expect(resp.status).toBe(200);
const body = await resp.json();
expect(body.total).toBe(3);
expect(body.jobs.length).toBe(2);
expect(body.limit).toBe(2);
expect(body.offset).toBe(0);
expect(body.jobs.every((j: any) => j.status === 'queued')).toBe(true);
});
it('rejects unauthenticated requests', async () => {
const resp = await fetch(`http://127.0.0.1:${port}/v1/teams/${teamAId}/jobs`);
expect(resp.status).toBe(401);
});
});
+238 -2
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
import pg from 'pg';
import { ServerBetaService } from '../../src/server/runtime/ServerBetaService.js';
import {
DisabledServerBetaEventBroadcaster,
@@ -7,9 +8,14 @@ import {
DisabledServerBetaQueueManager,
type ServerBetaServiceGraph,
} from '../../src/server/runtime/types.js';
import {
bootstrapServerBetaPostgresSchema,
createPostgresStorageRepositories,
} from '../../src/storage/postgres/index.js';
import { logger } from '../../src/utils/logger.js';
const loggerSpies: ReturnType<typeof spyOn>[] = [];
const TEST_DATABASE_URL = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
describe('ServerBetaService', () => {
let service: ServerBetaService | null = null;
@@ -32,7 +38,7 @@ describe('ServerBetaService', () => {
);
service = new ServerBetaService({
graph: createTestGraph(),
graph: createStubGraph(),
port: 0,
host: '127.0.0.1',
persistRuntimeState: false,
@@ -50,14 +56,224 @@ describe('ServerBetaService', () => {
expect(body.runtime).toBe('server-beta');
expect(body.boundaries.queueManager.status).toBe('disabled');
});
// Phase 4 integration test: Postgres-backed v1 events route must enforce
// auth, write the event row, create the outbox row, and respond with both
// event and generationJob. Skipped when no test Postgres URL is set so the
// unit suite stays green on machines without Postgres available.
if (TEST_DATABASE_URL) {
it('writes events and outbox rows transactionally on POST /v1/events', async () => {
loggerSpies.push(
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
);
const pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
try {
await bootstrapServerBetaPostgresSchema(pool);
const repos = createPostgresStorageRepositories(pool);
// Set up team / project / api key fixtures.
const team = await repos.teams.create({ name: `phase4-${Date.now()}` });
const project = await repos.projects.create({
teamId: team.id,
name: `phase4-project-${Date.now()}`,
});
const rawKey = `cmem_test_phase4_${Date.now()}`;
const { createHash } = await import('crypto');
const keyHash = createHash('sha256').update(rawKey).digest('hex');
await repos.auth.createApiKey({
keyHash,
teamId: team.id,
actorId: 'test',
scopes: ['memories:write', 'memories:read'],
});
service = new ServerBetaService({
graph: createPostgresGraph(pool, 'api-key'),
port: 0,
host: '127.0.0.1',
persistRuntimeState: false,
});
await service.start();
const port = service.getRuntimeState().port;
const response = await fetch(`http://127.0.0.1:${port}/v1/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${rawKey}`,
},
body: JSON.stringify({
projectId: project.id,
sourceType: 'api',
eventType: 'observation.created',
payload: { phase: 4 },
occurredAtEpoch: Date.now(),
}),
});
expect(response.status).toBe(201);
const body = await response.json();
expect(body.event.projectId).toBe(project.id);
expect(body.event.teamId).toBe(team.id);
expect(body.generationJob).toBeDefined();
expect(body.generationJob.sourceType).toBe('agent_event');
expect(body.generationJob.sourceId).toBe(body.event.id);
// No active queue manager: enqueue must report queued_only.
expect(body.generationJob.transport).toBe('queued_only');
} finally {
await pool.end();
}
});
it('skips outbox creation when ?generate=false', async () => {
loggerSpies.push(
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
);
const pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
try {
await bootstrapServerBetaPostgresSchema(pool);
const repos = createPostgresStorageRepositories(pool);
const team = await repos.teams.create({ name: `phase4-skip-${Date.now()}` });
const project = await repos.projects.create({
teamId: team.id,
name: `phase4-skip-project-${Date.now()}`,
});
const rawKey = `cmem_test_phase4_skip_${Date.now()}`;
const { createHash } = await import('crypto');
await repos.auth.createApiKey({
keyHash: createHash('sha256').update(rawKey).digest('hex'),
teamId: team.id,
actorId: 'test',
scopes: ['memories:write', 'memories:read'],
});
service = new ServerBetaService({
graph: createPostgresGraph(pool, 'api-key'),
port: 0,
host: '127.0.0.1',
persistRuntimeState: false,
});
await service.start();
const port = service.getRuntimeState().port;
const response = await fetch(`http://127.0.0.1:${port}/v1/events?generate=false`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${rawKey}`,
},
body: JSON.stringify({
projectId: project.id,
sourceType: 'api',
eventType: 'observation.created',
payload: { phase: 4 },
occurredAtEpoch: Date.now(),
}),
});
expect(response.status).toBe(201);
const body = await response.json();
expect(body.event).toBeDefined();
expect(body.generationJob).toBeUndefined();
// Confirm no row in observation_generation_jobs for this event.
const result = await pool.query(
'SELECT count(*)::int AS count FROM observation_generation_jobs WHERE agent_event_id = $1',
[body.event.id],
);
expect((result.rows[0] as { count: number }).count).toBe(0);
} finally {
await pool.end();
}
});
it('rejects mixed-project batches before any side effect', async () => {
loggerSpies.push(
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
);
const pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
try {
await bootstrapServerBetaPostgresSchema(pool);
const repos = createPostgresStorageRepositories(pool);
const team = await repos.teams.create({ name: `phase4-batch-${Date.now()}` });
const projectA = await repos.projects.create({ teamId: team.id, name: `pa-${Date.now()}` });
const projectB = await repos.projects.create({ teamId: team.id, name: `pb-${Date.now()}` });
const rawKey = `cmem_test_phase4_batch_${Date.now()}`;
const { createHash } = await import('crypto');
await repos.auth.createApiKey({
keyHash: createHash('sha256').update(rawKey).digest('hex'),
teamId: team.id,
projectId: projectA.id,
actorId: 'test',
scopes: ['memories:write', 'memories:read'],
});
service = new ServerBetaService({
graph: createPostgresGraph(pool, 'api-key'),
port: 0,
host: '127.0.0.1',
persistRuntimeState: false,
});
await service.start();
const port = service.getRuntimeState().port;
const response = await fetch(`http://127.0.0.1:${port}/v1/events/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${rawKey}`,
},
body: JSON.stringify([
{
projectId: projectA.id,
sourceType: 'api',
eventType: 'observation.created',
payload: {},
occurredAtEpoch: Date.now(),
},
{
projectId: projectB.id,
sourceType: 'api',
eventType: 'observation.created',
payload: {},
occurredAtEpoch: Date.now(),
},
]),
});
expect(response.status).toBe(403);
const eventCount = await pool.query(
'SELECT count(*)::int AS count FROM agent_events WHERE team_id = $1',
[team.id],
);
expect((eventCount.rows[0] as { count: number }).count).toBe(0);
} finally {
await pool.end();
}
});
} else {
it.skip('postgres integration tests skipped (set CLAUDE_MEM_TEST_POSTGRES_URL to enable)', () => {});
}
});
function createTestGraph(): ServerBetaServiceGraph {
// `createStubGraph` keeps the existing in-process unit test alive without
// requiring a live Postgres. The fake pool's `end()` is the only contract
// touched by ServerBetaService.stop(). The Phase 4 ServerV1PostgresRoutes
// registered in start() do not call the pool until an HTTP request hits
// them; the existing /api/health and /v1/info checks bypass v1 entirely.
function createStubGraph(): ServerBetaServiceGraph {
return {
runtime: 'server-beta',
postgres: {
pool: {
end: mock(() => Promise.resolve()),
query: mock(() => Promise.reject(new Error('stub pool: query not supported in this test'))),
} as any,
bootstrap: {
initialized: true,
@@ -73,3 +289,23 @@ function createTestGraph(): ServerBetaServiceGraph {
storage: {} as any,
};
}
function createPostgresGraph(pool: pg.Pool, authMode: 'api-key' | 'local-dev'): ServerBetaServiceGraph {
return {
runtime: 'server-beta',
postgres: {
pool: pool as any,
bootstrap: {
initialized: true,
schemaVersion: 1,
appliedAt: new Date().toISOString(),
},
},
authMode,
queueManager: new DisabledServerBetaQueueManager('phase 4 integration test'),
generationWorkerManager: new DisabledServerBetaGenerationWorkerManager('test'),
providerRegistry: new DisabledServerBetaProviderRegistry('test'),
eventBroadcaster: new DisabledServerBetaEventBroadcaster('test'),
storage: createPostgresStorageRepositories(pool as any),
};
}
+77
View File
@@ -39,4 +39,81 @@ describe('MCP tool inputSchema declarations', () => {
expect(getObsSection).toContain("ids:");
expect(getObsSection).toContain("required:");
});
// Phase 8 — observation_* tools backed by server-beta REST core.
it('observation_add tool declares content as required', async () => {
const src = await Bun.file(mcpServerPath).text();
const section = src.slice(
src.indexOf("name: 'observation_add'"),
src.indexOf("name: 'observation_record_event'"),
);
expect(section).toContain('content:');
expect(section).toContain("required: ['content']");
expect(section).toContain('handleObservationAdd');
});
it('observation_record_event declares eventType as required', async () => {
const src = await Bun.file(mcpServerPath).text();
const section = src.slice(
src.indexOf("name: 'observation_record_event'"),
src.indexOf("name: 'observation_search'"),
);
expect(section).toContain('eventType:');
expect(section).toContain("required: ['eventType']");
expect(section).toContain('handleObservationRecordEvent');
});
it('observation_search declares query as required and accepts limit', async () => {
const src = await Bun.file(mcpServerPath).text();
const section = src.slice(
src.indexOf("name: 'observation_search'"),
src.indexOf("name: 'observation_context'"),
);
expect(section).toContain('query:');
expect(section).toContain('limit:');
expect(section).toContain("required: ['query']");
expect(section).toContain('handleObservationSearch');
});
it('observation_context declares query as required and exposes a limit cap', async () => {
const src = await Bun.file(mcpServerPath).text();
const section = src.slice(
src.indexOf("name: 'observation_context'"),
src.indexOf("name: 'observation_generation_status'"),
);
expect(section).toContain("required: ['query']");
expect(section).toContain('handleObservationContext');
});
it('observation_generation_status declares jobId as required', async () => {
const src = await Bun.file(mcpServerPath).text();
const section = src.slice(src.indexOf("name: 'observation_generation_status'"));
expect(section).toContain('jobId:');
expect(section).toContain("required: ['jobId']");
expect(section).toContain('handleObservationGenerationStatus');
});
it('memory_* compatibility aliases delegate to observation handlers', async () => {
const src = await Bun.file(mcpServerPath).text();
// The aliases must keep the same handler functions as the canonical
// observation_* tools, otherwise we have two write paths in MCP.
const memoryAdd = src.slice(src.indexOf("name: 'memory_add'"), src.indexOf("name: 'memory_search'"));
expect(memoryAdd).toContain('handleObservationAdd');
const memorySearch = src.slice(src.indexOf("name: 'memory_search'"), src.indexOf("name: 'memory_context'"));
expect(memorySearch).toContain('handleObservationSearch');
const memoryContext = src.slice(src.indexOf("name: 'memory_context'"), src.indexOf("name: 'smart_search'"));
expect(memoryContext).toContain('handleObservationContext');
});
it('mcp-server skips worker auto-start when runtime=server-beta (anti-pattern guard)', async () => {
const src = await Bun.file(mcpServerPath).text();
expect(src).toContain("selectRuntime() === 'server-beta'");
expect(src).toContain('skipping worker auto-start');
});
it('mcp-server does NOT import WorkerService (anti-pattern guard, plan line 772)', async () => {
const src = await Bun.file(mcpServerPath).text();
expect(src).not.toMatch(/from\s+['"][^'"]*WorkerService[^'"]*['"]/);
expect(src).not.toMatch(/import\s+\{[^}]*WorkerService[^}]*\}/);
});
});