Server-beta: Postgres storage + independent runtime + BullMQ queue (Phases 1–3) (#2351)

* Add server beta runtime foundation

* Address server beta review findings

* Resolve server beta review comments

* Tighten server beta review follow-ups

* Harden server beta auth and search

* Avoid unnecessary FTS rebuilds

* Block scoped keys from creating projects

* Release BullMQ claims best effort on close

* Address server beta review blockers

* Reset BullMQ claims best effort

* Add Postgres observation storage foundation

* feat(server-beta): add independent runtime service

Introduce src/server/runtime/ as a self-contained server-beta runtime
that owns its lifecycle, Postgres bootstrap, and HTTP boundary without
depending on WorkerService.

ServerBetaService wraps the existing Server class, exposes
/healthz and /v1/info with runtime="server-beta", and persists state
to dedicated paths (.server-beta.pid|.port|.runtime.json). The four
boundary managers (queue, generation worker, provider registry, event
broadcaster) are intentionally disabled in this phase and report their
status through /v1/info; later phases activate them.

Adds plans/2026-05-07-finish-bullmq-branch-ship-plan.md to track the
remaining work for this branch.

Phase 2 of plans/2026-05-07-server-beta-independent-bullmq-observation-runtime.md.

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

* feat(server-beta): route CLI lifecycle and bundle separate runtime

scripts/build-hooks.js now produces plugin/scripts/server-beta-service.cjs
as a separate Node CJS bundle, alongside the existing worker-service
bundle. The server-beta runtime is now installable independently.

src/npx-cli/commands/server.ts routes start|stop|restart|status to the
server-beta lifecycle instead of the legacy worker. The worker keeps its
own start|stop|restart|status under the worker namespace; the two
runtimes can be operated independently.

src/services/worker-service.ts adds a server-* command parser branch
that delegates to the sibling server-beta-service.cjs bundle so
direct worker-service invocations still route to the right runtime.

tests/npx-cli-server-namespace.test.ts updated to expect server-beta
lifecycle routing.

Includes rebuilt plugin/scripts/*.cjs bundles produced by
build-and-sync.

Phase 2 of plans/2026-05-07-server-beta-independent-bullmq-observation-runtime.md.

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

* feat(server-beta): add BullMQ job queue primitives

Introduce src/server/jobs/ as the queue-side primitives that Phase 3 of
the server-beta runtime needs to operate.

types.ts defines a discriminated union over the four job kinds (event,
event-batch, summary, reindex) and maps each to a per-kind BullMQ queue
name and deterministic-ID prefix.

job-id.ts builds deterministic, colon-free BullMQ jobIds from
(kind, team, project, source). The colon ban exists because BullMQ uses
':' as a Redis key separator internally; embedding ':' in jobIds
breaks scan and state lookups.

ServerJobQueue.ts is a thin wrapper over BullMQ Queue + Worker that
enforces autorun:false, default concurrency 1, and an attached error
listener — all per BullMQ docs requirements. Test seams accept queue
and worker factories so unit tests do not need Redis.

outbox.ts publishes through the Postgres ObservationGenerationJob
repository as canonical history. enqueueOutbox writes the row first,
then publishes to BullMQ; if BullMQ throws, the row is transitioned to
failed and a failed event is appended. reconcileOnStartup re-enqueues
queued + processing rows after a restart, replacing terminal BullMQ
jobs that may still be holding the deterministic ID slot. markCompleted
and markFailed wrap transitionStatus and append the matching event row.

Includes 20 unit tests covering deterministic ID stability, colon-free
output, queue lifecycle, error-listener attachment, double-start
refusal, idempotent enqueue, BullMQ failure rollback, startup
reconciliation, max-attempts skipping, and completion / failure /
retry transitions.

Phase 3 commit 1 of plans/2026-05-07-server-beta-independent-bullmq-observation-runtime.md.

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

* feat(server-beta): activate queue boundary in runtime service

Wire ActiveServerBetaQueueManager into the server-beta runtime graph.
The active manager owns one ServerJobQueue per generation kind (event,
event-batch, summary, reindex) and surfaces lane metadata through
boundary health.

Selection is opt-in and fail-fast: if CLAUDE_MEM_QUEUE_ENGINE is set to
bullmq the active manager is constructed (and any Redis/config error
throws — no silent fallback to SQLite, per Phase 3 anti-pattern guard).
For any other engine the disabled boundary remains so worker-era and
test setups stay compatible.

Widens ServerBetaBoundaryHealth.status to a discriminated union
('disabled' | 'active' | 'errored') with optional details. The disabled
adapter still emits status='disabled', which keeps the existing
server-beta-service test green.

ServerBetaService receives the manager through a new optional
queueManager field on CreateServerBetaServiceOptions so test graphs
and Phase 4 wiring can inject custom managers.

Adds tests/server/runtime/active-queue-manager.test.ts covering bullmq
guard, active health shape, per-kind queue access, close behavior, and
post-close errored health.

Phase 3 commit 2 of plans/2026-05-07-server-beta-independent-bullmq-observation-runtime.md.

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

* fix(server-beta): cap /v1/events/batch at 500 events

Prevents unbounded array DoS surface flagged in PR review.

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-08 01:20:07 -07:00
committed by GitHub
parent 0a43ab7632
commit 36b0929fae
183 changed files with 35709 additions and 2033 deletions
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: Apache-2.0
import type { Application } from 'express';
import type { Database } from 'bun:sqlite';
import type { RouteHandler } from '../../services/server/Server.js';
type NodeHandler = ReturnType<typeof import('better-auth/node').toNodeHandler>;
const cachedHandlers = new WeakMap<Database, NodeHandler>();
async function getBetterAuthHandler(database: Database): Promise<NodeHandler> {
const cachedHandler = cachedHandlers.get(database);
if (cachedHandler) {
return cachedHandler;
}
const [{ toNodeHandler }, { createAuth }] = await Promise.all([
import('better-auth/node'),
import('./auth.js'),
]);
const handler = toNodeHandler(createAuth(database));
cachedHandlers.set(database, handler);
return handler;
}
export class BetterAuthRoutes implements RouteHandler {
constructor(private readonly getDatabase: () => Database) {}
setupRoutes(app: Application): void {
app.all('/api/auth/*splat', async (req, res, next) => {
try {
const handler = await getBetterAuthHandler(this.getDatabase());
await handler(req, res);
} catch (error) {
next(error);
}
});
}
}
+118
View File
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: Apache-2.0
import { createHash, randomBytes } from 'crypto';
import { Database } from 'bun:sqlite';
import { AuthRepository, ensureServerStorageSchema } from '../../storage/sqlite/index.js';
import type { ApiKey } from '../../core/schemas/auth.js';
export interface CreatedServerApiKey {
rawKey: string;
record: ApiKey;
}
export interface VerifiedServerApiKey {
record: ApiKey;
teamId: string | null;
projectId: string | null;
scopes: string[];
}
export interface CreateServerApiKeyInput {
name: string;
teamId?: string | null;
projectId?: string | null;
scopes?: string[];
expiresAtEpoch?: number | null;
metadata?: Record<string, unknown>;
}
export function hashServerApiKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex');
}
export function createRawServerApiKey(): string {
return `cmem_${randomBytes(32).toString('base64url')}`;
}
export function createServerApiKey(db: Database, input: CreateServerApiKeyInput): CreatedServerApiKey {
ensureServerStorageSchema(db);
const rawKey = createRawServerApiKey();
const repo = new AuthRepository(db);
const record = repo.createApiKey({
name: input.name,
teamId: input.teamId ?? null,
projectId: input.projectId ?? null,
keyHash: hashServerApiKey(rawKey),
prefix: rawKey.slice(0, 10),
scopes: input.scopes ?? [],
expiresAtEpoch: input.expiresAtEpoch ?? null,
metadata: input.metadata ?? {},
});
repo.createAuditLog({
teamId: record.teamId,
projectId: record.projectId,
actorType: 'system',
action: 'api_key.create',
targetType: 'api_key',
targetId: record.id,
});
return { rawKey, record };
}
export function verifyServerApiKey(
db: Database,
rawKey: string,
requiredScopes: string[] = [],
): VerifiedServerApiKey | null {
ensureServerStorageSchema(db);
const repo = new AuthRepository(db);
const record = repo.getApiKeyByHash(hashServerApiKey(rawKey));
if (!record || record.status !== 'active') {
return null;
}
if (record.expiresAtEpoch !== null && record.expiresAtEpoch <= Date.now()) {
return null;
}
if (!hasRequiredScopes(record.scopes, requiredScopes)) {
return null;
}
repo.markApiKeyUsed(record.id);
return {
record,
teamId: record.teamId,
projectId: record.projectId,
scopes: record.scopes,
};
}
export function listServerApiKeys(db: Database): ApiKey[] {
ensureServerStorageSchema(db);
return new AuthRepository(db).listApiKeys();
}
export function revokeServerApiKey(db: Database, id: string): ApiKey | null {
ensureServerStorageSchema(db);
const repo = new AuthRepository(db);
const record = repo.revokeApiKey(id);
if (record) {
repo.createAuditLog({
teamId: record.teamId,
projectId: record.projectId,
actorType: 'system',
action: 'api_key.revoke',
targetType: 'api_key',
targetId: record.id,
});
}
return record;
}
function hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean {
if (requiredScopes.length === 0 || grantedScopes.includes('*')) {
return true;
}
return requiredScopes.every(scope => grantedScopes.includes(scope));
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: Apache-2.0
import type { Database } from 'bun:sqlite';
import { betterAuth } from 'better-auth';
import { apiKey } from '@better-auth/api-key';
import { organization } from 'better-auth/plugins';
import { DATA_DIR, ensureDir } from '../../shared/paths.js';
export function createAuth(database: Database) {
ensureDir(DATA_DIR);
return betterAuth({
database,
baseURL: process.env.BETTER_AUTH_URL ?? process.env.CLAUDE_MEM_SERVER_URL ?? 'http://127.0.0.1:37777',
basePath: '/api/auth',
plugins: [
apiKey(),
organization({
teams: {
enabled: true,
},
}),
],
});
}
+215
View File
@@ -0,0 +1,215 @@
// SPDX-License-Identifier: Apache-2.0
import {
Queue,
Worker,
type Job,
type JobsOptions,
type Processor,
type QueueOptions,
type WorkerOptions
} from 'bullmq';
import { logger } from '../../utils/logger.js';
import type { RedisQueueConfig } from '../queue/redis-config.js';
// BullMQ Worker docs: https://docs.bullmq.io/guide/workers
// BullMQ Concurrency: https://docs.bullmq.io/guide/workers/concurrency
// BullMQ Stalled Jobs: https://docs.bullmq.io/guide/jobs/stalled
//
// ServerJobQueue is a thin wrapper around the BullMQ Queue + Worker pair for
// one named queue. It enforces:
// - autorun: false on every Worker (start() is called explicitly)
// - default concurrency: 1 (per-kind concurrency tuning happens later)
// - an attached `error` listener on every Worker (BullMQ docs require this
// to avoid unhandled-error crashes when a job throws)
// Postgres outbox is canonical history; BullMQ is the execution transport
// only. Do not treat completed/failed Worker state as authoritative.
export interface ServerJobCounts {
waiting: number;
active: number;
delayed: number;
failed: number;
completed: number;
}
export interface ServerJobQueueOptions<TPayload> {
name: string;
config: RedisQueueConfig;
concurrency?: number;
lockDurationMs?: number;
defaultJobOptions?: JobsOptions;
// Test seams: allow injecting fakes without touching Redis.
queueFactory?: (name: string, options: QueueOptions) => Pick<
Queue<TPayload>,
'add' | 'getJob' | 'getJobCounts' | 'remove' | 'close'
>;
workerFactory?: (
name: string,
processor: Processor<TPayload> | null,
options: WorkerOptions
) => Pick<Worker<TPayload>, 'on' | 'run' | 'close'>;
}
const DEFAULT_LOCK_DURATION_MS = 5 * 60 * 1000;
export class ServerJobQueue<TPayload extends object = object> {
readonly name: string;
private readonly config: RedisQueueConfig;
private readonly concurrency: number;
private readonly lockDurationMs: number;
private readonly defaultJobOptions: JobsOptions;
private readonly queueFactory?: ServerJobQueueOptions<TPayload>['queueFactory'];
private readonly workerFactory?: ServerJobQueueOptions<TPayload>['workerFactory'];
private queue: ReturnType<NonNullable<ServerJobQueueOptions<TPayload>['queueFactory']>> | Queue<TPayload> | null = null;
private worker: ReturnType<NonNullable<ServerJobQueueOptions<TPayload>['workerFactory']>> | Worker<TPayload> | null = null;
private started = false;
constructor(options: ServerJobQueueOptions<TPayload>) {
this.name = options.name;
this.config = options.config;
this.concurrency = options.concurrency ?? 1;
this.lockDurationMs = options.lockDurationMs ?? DEFAULT_LOCK_DURATION_MS;
this.defaultJobOptions = options.defaultJobOptions ?? {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 1000 },
removeOnFail: { age: 30 * 24 * 60 * 60, count: 1000 }
};
this.queueFactory = options.queueFactory;
this.workerFactory = options.workerFactory;
}
private getQueue(): NonNullable<typeof this.queue> {
if (this.queue) {
return this.queue;
}
const queueOptions: QueueOptions = {
connection: this.config.connection,
prefix: this.config.prefix,
defaultJobOptions: this.defaultJobOptions
};
this.queue = this.queueFactory
? this.queueFactory(this.name, queueOptions)
: new Queue<TPayload>(this.name, queueOptions);
return this.queue;
}
async add(jobId: string, payload: TPayload, options?: JobsOptions): Promise<void> {
if (jobId.includes(':')) {
throw new Error(`server job ID must not contain ':' (got ${jobId})`);
}
try {
await (this.getQueue().add as (
name: string,
data: TPayload,
opts?: JobsOptions
) => Promise<unknown>)(this.name, payload, {
...this.defaultJobOptions,
...options,
jobId
});
} catch (error) {
throw this.toRedisUnavailableError(error);
}
}
async getJob(jobId: string): Promise<Job<TPayload> | null | undefined> {
try {
return (await this.getQueue().getJob(jobId)) as Job<TPayload> | null | undefined;
} catch (error) {
throw this.toRedisUnavailableError(error);
}
}
async remove(jobId: string): Promise<void> {
try {
await this.getQueue().remove(jobId);
} catch (error) {
throw this.toRedisUnavailableError(error);
}
}
async getCounts(): Promise<ServerJobCounts> {
try {
const counts = await this.getQueue().getJobCounts(
'waiting',
'active',
'delayed',
'failed',
'completed'
);
return {
waiting: counts.waiting ?? 0,
active: counts.active ?? 0,
delayed: counts.delayed ?? 0,
failed: counts.failed ?? 0,
completed: counts.completed ?? 0
};
} catch (error) {
throw this.toRedisUnavailableError(error);
}
}
// BullMQ docs require `worker.on('error', ...)` to avoid unhandled rejections
// when a job throws. We construct the Worker with autorun: false so the
// caller controls startup explicitly via run().
start(processor: Processor<TPayload>): void {
if (this.started) {
throw new Error(`ServerJobQueue ${this.name} is already started`);
}
const workerOptions: WorkerOptions = {
connection: this.config.connection,
prefix: this.config.prefix,
autorun: false,
concurrency: this.concurrency,
lockDuration: this.lockDurationMs
};
const worker = this.workerFactory
? this.workerFactory(this.name, processor, workerOptions)
: new Worker<TPayload>(this.name, processor, workerOptions);
worker.on('error', (error: unknown) => {
logger.warn('QUEUE', `${this.name} worker error`, {
error: error instanceof Error ? error.message : String(error)
});
});
worker.run();
this.worker = worker;
this.started = true;
}
isStarted(): boolean {
return this.started;
}
async close(): Promise<void> {
const errors: Error[] = [];
if (this.worker) {
try {
await this.worker.close();
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
this.worker = null;
this.started = false;
}
if (this.queue) {
try {
await this.queue.close();
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
this.queue = null;
}
if (errors.length > 0) {
throw errors[0];
}
}
private toRedisUnavailableError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
return new Error(
`ServerJobQueue ${this.name} requires Redis/Valkey when CLAUDE_MEM_QUEUE_ENGINE=bullmq: ${message}`
);
}
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: Apache-2.0
import { createHash } from 'crypto';
import { SERVER_JOB_KIND_PREFIX, type ServerGenerationJobKind } from './types.js';
export interface ServerJobIdParts {
kind: ServerGenerationJobKind;
team_id: string;
project_id: string;
source_type: string;
source_id: string;
}
// SHA-256-derived deterministic IDs avoid Redis key collisions across tenants
// and keep BullMQ jobId deduplication intact across process restarts.
// Format: `${kindPrefix}_${sha256hex}` with NO ':' characters (BullMQ uses ':'
// internally as a key separator; embedding ':' in jobIds causes scan/state
// confusion).
export function buildServerJobId(parts: ServerJobIdParts): string {
const prefix = SERVER_JOB_KIND_PREFIX[parts.kind];
const canonical = JSON.stringify({
kind: parts.kind,
team_id: parts.team_id,
project_id: parts.project_id,
source_type: parts.source_type,
source_id: parts.source_id
});
const digest = createHash('sha256').update(canonical).digest('hex');
return `${prefix}_${digest}`;
}
+295
View File
@@ -0,0 +1,295 @@
// SPDX-License-Identifier: Apache-2.0
import type {
PostgresObservationGenerationJob,
PostgresObservationGenerationJobEventsRepository,
PostgresObservationGenerationJobRepository
} from '../../storage/postgres/generation-jobs.js';
import type { JsonObject } from '../../storage/postgres/utils.js';
import { logger } from '../../utils/logger.js';
import { buildServerJobId } from './job-id.js';
import type { ServerJobQueue } from './ServerJobQueue.js';
import type {
GenerateObservationsForEventJob,
GenerateSessionSummaryJob,
ReindexObservationJob,
ServerGenerationJobKind
} from './types.js';
// Postgres outbox is canonical history; BullMQ is the execution transport.
// Each outbox row corresponds to one observation_generation_jobs row, keyed
// by a deterministic BullMQ jobId so duplicate enqueues collapse on the
// transport side and dedup is enforced again by the row's idempotency_key.
export type SingleSourceJobPayload =
| GenerateObservationsForEventJob
| GenerateSessionSummaryJob
| ReindexObservationJob;
const KIND_TO_JOB_TYPE: Record<SingleSourceJobPayload['kind'], string> = {
event: 'observation_generate_for_event',
summary: 'observation_generate_session_summary',
reindex: 'observation_reindex'
};
export interface OutboxScope {
projectId: string;
teamId: string;
}
export interface EnqueueOutboxRowInput {
payload: SingleSourceJobPayload;
agentEventId?: string | null;
serverSessionId?: string | null;
maxAttempts?: number;
}
// `enqueueOutbox` writes the canonical row first, then publishes to BullMQ.
// If the BullMQ add() throws (for example Redis is unavailable), the row is
// transitioned to `failed` so the next reconciliation pass can resurrect it
// rather than leaving stale `queued` rows that never enter the transport.
export async function enqueueOutbox(
jobRepo: PostgresObservationGenerationJobRepository,
eventsRepo: PostgresObservationGenerationJobEventsRepository,
queue: ServerJobQueue<SingleSourceJobPayload>,
input: EnqueueOutboxRowInput
): Promise<{ row: PostgresObservationGenerationJob; bullmqJobId: string }> {
const { payload } = input;
const bullmqJobId = buildServerJobId({
kind: payload.kind,
team_id: payload.team_id,
project_id: payload.project_id,
source_type: payload.source_type,
source_id: payload.source_id
});
const row = await jobRepo.create({
projectId: payload.project_id,
teamId: payload.team_id,
sourceType: payload.source_type,
sourceId: payload.source_id,
agentEventId: input.agentEventId ?? extractAgentEventId(payload),
serverSessionId: input.serverSessionId ?? extractServerSessionId(payload),
jobType: KIND_TO_JOB_TYPE[payload.kind],
bullmqJobId,
maxAttempts: input.maxAttempts,
payload: payload as unknown as JsonObject
});
await eventsRepo.append({
generationJobId: row.id,
projectId: row.projectId,
teamId: row.teamId,
eventType: 'queued',
statusAfter: row.status,
attempt: row.attempts
});
try {
await queue.add(bullmqJobId, payload);
await eventsRepo.append({
generationJobId: row.id,
projectId: row.projectId,
teamId: row.teamId,
eventType: 'enqueued',
statusAfter: row.status,
attempt: row.attempts
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn('QUEUE', `failed to publish to BullMQ for job ${row.id}: ${message}`);
await jobRepo.transitionStatus({
id: row.id,
projectId: row.projectId,
teamId: row.teamId,
status: 'failed',
lastError: { message, source: 'bullmq_publish' }
});
await eventsRepo.append({
generationJobId: row.id,
projectId: row.projectId,
teamId: row.teamId,
eventType: 'failed',
statusAfter: 'failed',
attempt: row.attempts,
details: { source: 'bullmq_publish', message }
});
throw error;
}
return { row, bullmqJobId };
}
// `reconcileOnStartup` re-enqueues outbox rows that were left in `queued` or
// `processing` after a crash or restart. For each row we replace any
// terminal BullMQ job that may still be holding the deterministic ID slot
// (BullMQ refuses to re-add a jobId that already exists in `completed` or
// `failed` lists). Reconciliation is a no-op for rows past max_attempts.
export async function reconcileOnStartup(
jobRepo: PostgresObservationGenerationJobRepository,
eventsRepo: PostgresObservationGenerationJobEventsRepository,
queue: ServerJobQueue<SingleSourceJobPayload>,
scope: OutboxScope,
options?: { limit?: number }
): Promise<{ requeued: number; skipped: number }> {
const limit = options?.limit ?? 500;
const queued = await jobRepo.listByStatusForScope({
status: 'queued',
projectId: scope.projectId,
teamId: scope.teamId,
limit
});
const processing = await jobRepo.listByStatusForScope({
status: 'processing',
projectId: scope.projectId,
teamId: scope.teamId,
limit
});
let requeued = 0;
let skipped = 0;
for (const row of [...processing, ...queued]) {
if (row.attempts >= row.maxAttempts) {
skipped += 1;
continue;
}
const bullmqJobId = row.bullmqJobId ?? buildServerJobId(extractIdParts(row));
try {
await queue.remove(bullmqJobId);
} catch (error) {
logger.debug?.('QUEUE', `remove before re-add ignored for ${bullmqJobId}`, {
error: error instanceof Error ? error.message : String(error)
});
}
if (row.status === 'processing') {
await jobRepo.transitionStatus({
id: row.id,
projectId: row.projectId,
teamId: row.teamId,
status: 'queued'
});
await eventsRepo.append({
generationJobId: row.id,
projectId: row.projectId,
teamId: row.teamId,
eventType: 'queued',
statusAfter: 'queued',
attempt: row.attempts,
details: { source: 'reconcile_on_startup' }
});
}
await queue.add(bullmqJobId, row.payload as unknown as SingleSourceJobPayload);
await eventsRepo.append({
generationJobId: row.id,
projectId: row.projectId,
teamId: row.teamId,
eventType: 'enqueued',
statusAfter: 'queued',
attempt: row.attempts,
details: { source: 'reconcile_on_startup' }
});
requeued += 1;
}
return { requeued, skipped };
}
export async function markCompleted(
jobRepo: PostgresObservationGenerationJobRepository,
eventsRepo: PostgresObservationGenerationJobEventsRepository,
input: { id: string; projectId: string; teamId: string; details?: JsonObject }
): Promise<void> {
const updated = await jobRepo.transitionStatus({
id: input.id,
projectId: input.projectId,
teamId: input.teamId,
status: 'completed'
});
if (!updated) {
throw new Error(`generation job ${input.id} not found for scope`);
}
await eventsRepo.append({
generationJobId: updated.id,
projectId: updated.projectId,
teamId: updated.teamId,
eventType: 'completed',
statusAfter: 'completed',
attempt: updated.attempts,
details: input.details ?? {}
});
}
export async function markFailed(
jobRepo: PostgresObservationGenerationJobRepository,
eventsRepo: PostgresObservationGenerationJobEventsRepository,
input: {
id: string;
projectId: string;
teamId: string;
error: { message: string; source?: string };
nextAttemptAt?: Date | null;
}
): Promise<void> {
const status = input.nextAttemptAt ? 'queued' : 'failed';
const updated = await jobRepo.transitionStatus({
id: input.id,
projectId: input.projectId,
teamId: input.teamId,
status,
nextAttemptAt: input.nextAttemptAt ?? null,
lastError: { message: input.error.message, source: input.error.source ?? 'processor' }
});
if (!updated) {
throw new Error(`generation job ${input.id} not found for scope`);
}
await eventsRepo.append({
generationJobId: updated.id,
projectId: updated.projectId,
teamId: updated.teamId,
eventType: status === 'queued' ? 'retry_scheduled' : 'failed',
statusAfter: status,
attempt: updated.attempts,
details: { message: input.error.message, source: input.error.source ?? 'processor' }
});
}
function extractAgentEventId(payload: SingleSourceJobPayload): string | null {
return payload.kind === 'event' ? payload.agent_event_id : null;
}
function extractServerSessionId(payload: SingleSourceJobPayload): string | null {
return payload.kind === 'summary' ? payload.server_session_id : null;
}
function extractIdParts(row: PostgresObservationGenerationJob): {
kind: ServerGenerationJobKind;
team_id: string;
project_id: string;
source_type: string;
source_id: string;
} {
const kind = jobTypeToKind(row.jobType);
return {
kind,
team_id: row.teamId,
project_id: row.projectId,
source_type: row.sourceType,
source_id: row.sourceId
};
}
function jobTypeToKind(jobType: string): ServerGenerationJobKind {
for (const [kind, type] of Object.entries(KIND_TO_JOB_TYPE) as Array<
[SingleSourceJobPayload['kind'], string]
>) {
if (type === jobType) {
return kind;
}
}
throw new Error(`unknown observation generation job_type: ${jobType}`);
}
+59
View File
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: Apache-2.0
import type {
ObservationGenerationJobSourceType,
ObservationGenerationJobStatus
} from '../../storage/postgres/generation-jobs.js';
export type ServerGenerationJobKind = 'event' | 'event-batch' | 'summary' | 'reindex';
export type ServerGenerationJobStatus = ObservationGenerationJobStatus;
export interface ServerGenerationJob {
kind: ServerGenerationJobKind;
team_id: string;
project_id: string;
source_type: ObservationGenerationJobSourceType;
source_id: string;
generation_job_id: string;
}
export interface GenerateObservationsForEventJob extends ServerGenerationJob {
kind: 'event';
agent_event_id: string;
}
export interface GenerateObservationsForEventBatchJob extends ServerGenerationJob {
kind: 'event-batch';
agent_event_ids: string[];
}
export interface GenerateSessionSummaryJob extends ServerGenerationJob {
kind: 'summary';
server_session_id: string;
}
export interface ReindexObservationJob extends ServerGenerationJob {
kind: 'reindex';
observation_id: string;
}
export type ServerGenerationJobPayload =
| GenerateObservationsForEventJob
| GenerateObservationsForEventBatchJob
| GenerateSessionSummaryJob
| ReindexObservationJob;
export const SERVER_JOB_QUEUE_NAMES: Record<ServerGenerationJobKind, string> = {
event: 'server_beta_generate_event',
'event-batch': 'server_beta_generate_event_batch',
summary: 'server_beta_generate_summary',
reindex: 'server_beta_reindex'
};
export const SERVER_JOB_KIND_PREFIX: Record<ServerGenerationJobKind, string> = {
event: 'evt',
'event-batch': 'evtb',
summary: 'sum',
reindex: 'rdx'
};
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
export const serverMemoryPrompts = [
{
name: 'record_decision',
description: 'Capture a project decision in Claude-Mem Server memory.',
arguments: [
{ name: 'projectId', description: 'Server project id', required: true },
{ name: 'decision', description: 'Decision text', required: true },
],
},
] as const;
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
import { serverMemoryPrompts } from './prompts.js';
import { serverMemoryResources } from './resources.js';
import { serverMemoryTools } from './tools.js';
export function getServerMcpSurface() {
return {
tools: serverMemoryTools,
resources: serverMemoryResources,
prompts: serverMemoryPrompts,
};
}
+16
View File
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0
export const serverMemoryResources = [
{
uri: 'claude-mem://server/projects',
name: 'Claude-Mem Server Projects',
description: 'Authorized project list exposed by Claude-Mem Server.',
mimeType: 'application/json',
},
{
uri: 'claude-mem://server/memories/recent',
name: 'Recent Claude-Mem Server Memories',
description: 'Recent authorized memory items from the server core.',
mimeType: 'application/json',
},
] as const;
+96
View File
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: Apache-2.0
export interface ServerMcpToolDefinition {
name: string;
description: string;
inputSchema: {
type: 'object';
properties: Record<string, unknown>;
required?: string[];
};
}
export const serverMemoryTools: ServerMcpToolDefinition[] = [
{
name: 'memory_add',
description: 'Add a team-scoped memory item to Claude-Mem Server.',
inputSchema: {
type: 'object',
properties: {
projectId: { type: 'string' },
kind: { type: 'string', enum: ['observation', 'summary', 'prompt', 'manual'] },
type: { type: 'string' },
title: { type: 'string' },
narrative: { type: 'string' },
facts: { type: 'array', items: { type: 'string' } },
},
required: ['projectId', 'kind', 'type'],
},
},
{
name: 'memory_search',
description: 'Search server memory items within the authorized project/team scope.',
inputSchema: {
type: 'object',
properties: {
projectId: { type: 'string' },
query: { type: 'string' },
limit: { type: 'number', minimum: 1, maximum: 100 },
},
required: ['projectId', 'query'],
},
},
{
name: 'memory_context',
description: 'Build a compact context pack from matching server memories.',
inputSchema: {
type: 'object',
properties: {
projectId: { type: 'string' },
query: { type: 'string' },
limit: { type: 'number', minimum: 1, maximum: 50 },
},
required: ['projectId', 'query'],
},
},
{
name: 'memory_forget',
description: 'Forget or tombstone a memory item in the authorized project/team scope.',
inputSchema: {
type: 'object',
properties: {
projectId: { type: 'string' },
memoryId: { type: 'string' },
reason: { type: 'string' },
},
required: ['projectId', 'memoryId'],
},
},
{
name: 'memory_list_recent',
description: 'List recent server memories for an authorized project.',
inputSchema: {
type: 'object',
properties: {
projectId: { type: 'string' },
limit: { type: 'number', minimum: 1, maximum: 100 },
},
required: ['projectId'],
},
},
{
name: 'memory_record_decision',
description: 'Record an architectural or product decision as a server memory.',
inputSchema: {
type: 'object',
properties: {
projectId: { type: 'string' },
title: { type: 'string' },
decision: { type: 'string' },
rationale: { type: 'string' },
consequences: { type: 'array', items: { type: 'string' } },
},
required: ['projectId', 'title', 'decision'],
},
},
];
+125
View File
@@ -0,0 +1,125 @@
// SPDX-License-Identifier: Apache-2.0
import type { Database } from 'bun:sqlite';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { verifyServerApiKey } from '../auth/api-key-service.js';
export interface AuthContext {
userId: string | null;
organizationId: string | null;
teamId: string | null;
projectId: string | null;
scopes: string[];
apiKeyId: string | null;
mode: 'api-key' | 'local-dev';
}
declare module 'express-serve-static-core' {
interface Request {
authContext?: AuthContext;
}
}
export interface RequireAuthOptions {
requiredScopes?: string[];
authMode?: string;
allowLocalDevBypass?: boolean;
}
export function requireServerAuth(
getDatabase: () => Database,
options: RequireAuthOptions = {},
): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
const authMode = options.authMode ?? process.env.CLAUDE_MEM_AUTH_MODE ?? 'api-key';
const authorization = req.header('authorization') ?? '';
const rawKey = parseBearerToken(authorization);
const allowLocalDevBypass = options.allowLocalDevBypass ?? process.env.CLAUDE_MEM_ALLOW_LOCAL_DEV_BYPASS === '1';
if (
!rawKey
&& authMode === 'local-dev'
&& allowLocalDevBypass
&& isLocalhost(req)
&& hasLoopbackHostHeader(req)
&& !hasForwardedClientHeaders(req)
) {
req.authContext = {
userId: null,
organizationId: null,
teamId: null,
projectId: null,
scopes: ['local-dev'],
apiKeyId: null,
mode: 'local-dev',
};
next();
return;
}
if (!rawKey) {
res.status(401).json({ error: 'Unauthorized', message: 'Missing bearer API key' });
return;
}
const verified = verifyServerApiKey(getDatabase(), rawKey, options.requiredScopes ?? []);
if (!verified) {
res.status(403).json({ error: 'Forbidden', message: 'Invalid API key or insufficient scope' });
return;
}
req.authContext = {
userId: null,
organizationId: null,
teamId: verified.teamId,
projectId: verified.projectId,
scopes: verified.scopes,
apiKeyId: verified.record.id,
mode: 'api-key',
};
next();
};
}
function parseBearerToken(header: string): string | null {
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
return match?.[1]?.trim() || null;
}
function isLocalhost(req: Request): boolean {
const clientIp = req.ip || req.socket.remoteAddress || '';
return clientIp === '127.0.0.1'
|| clientIp === '::1'
|| clientIp === '::ffff:127.0.0.1'
|| clientIp === 'localhost';
}
function hasLoopbackHostHeader(req: Request): boolean {
const host = parseHostWithoutPort(req.header('host') ?? '');
return host === '127.0.0.1'
|| host === 'localhost'
|| host === '::1';
}
function parseHostWithoutPort(rawHost: string): string {
const host = rawHost.trim().toLowerCase();
if (host.startsWith('[')) {
const closeBracketIndex = host.indexOf(']');
return closeBracketIndex === -1 ? host : host.slice(1, closeBracketIndex);
}
const lastColonIndex = host.lastIndexOf(':');
if (lastColonIndex > -1 && /^\d+$/.test(host.slice(lastColonIndex + 1))) {
return host.slice(0, lastColonIndex);
}
return host;
}
function hasForwardedClientHeaders(req: Request): boolean {
return Boolean(
req.header('forwarded')
|| req.header('x-forwarded-for')
|| req.header('x-forwarded-host')
|| req.header('x-real-ip')
);
}
@@ -0,0 +1,547 @@
// SPDX-License-Identifier: Apache-2.0
import { createHash } from 'crypto';
import { EventEmitter } from 'events';
import { Queue, Worker, type Job, type JobType, type QueueOptions, type WorkerOptions } from 'bullmq';
import { Redis } from 'ioredis';
import type { PendingMessage, PendingMessageWithId } from '../../services/worker-types.js';
import type { CreateIteratorOptions } from '../../services/queue/SessionQueueProcessor.js';
import { logger } from '../../utils/logger.js';
import type {
HealthCheckedObservationQueueEngine,
ObservationQueueHealth,
ObservationQueueInspection,
} from './ObservationQueueEngine.js';
import { getRedisQueueConfig, type RedisQueueConfig } from './redis-config.js';
interface BullMqPendingPayload {
sessionDbId: number;
contentSessionId: string;
createdAtEpoch: number;
message: PendingMessage;
}
type BullMqJob = Pick<
Job<BullMqPendingPayload>,
'id' | 'data' | 'moveToCompleted' | 'moveToWait' | 'extendLock' | 'getState'
| 'remove'
>;
type BullMqQueue = Pick<
Queue<BullMqPendingPayload>,
'add' | 'getJob' | 'getJobCounts' | 'getJobs' | 'obliterate' | 'close'
>;
type BullMqWorker = Pick<Worker<BullMqPendingPayload>, 'getNextJob' | 'close'>;
interface RedisHealthClient {
status: string;
connect(): Promise<void>;
ping(): Promise<unknown>;
sadd(key: string, ...members: string[]): Promise<number>;
srem(key: string, ...members: string[]): Promise<number>;
smembers(key: string): Promise<string[]>;
quit(): Promise<unknown>;
disconnect(): void;
}
export interface BullMqObservationQueueEngineOptions {
config?: RedisQueueConfig;
queueFactory?: (name: string, options: QueueOptions) => BullMqQueue;
workerFactory?: (name: string, options: WorkerOptions) => BullMqWorker;
redisFactory?: (config: RedisQueueConfig) => RedisHealthClient;
onMutate?: () => void;
lockDurationMs?: number;
pollIntervalMs?: number;
}
interface SessionRuntime {
queue: BullMqQueue;
worker: BullMqWorker;
events: EventEmitter;
}
interface ClaimedJob {
sessionDbId: number;
job: BullMqJob;
token: string;
lockTimer: ReturnType<typeof setInterval> | null;
}
const QUEUE_JOB_TYPES: JobType[] = ['waiting', 'active', 'delayed', 'prioritized', 'waiting-children'];
const DEFAULT_LOCK_DURATION_MS = 5 * 60 * 1000;
const DEFAULT_POLL_INTERVAL_MS = 250;
export class BullMqObservationQueueEngine
implements HealthCheckedObservationQueueEngine, ObservationQueueInspection {
private readonly config: RedisQueueConfig;
private readonly sessions = new Map<number, SessionRuntime>();
private readonly activeClaims = new Map<number, ClaimedJob>();
private readonly lockDurationMs: number;
private readonly pollIntervalMs: number;
private readonly registryKey: string;
private nextClaimId = 1;
private nextEnqueueId = 1;
private healthClient: RedisHealthClient | null = null;
constructor(private readonly options: BullMqObservationQueueEngineOptions = {}) {
this.config = options.config ?? getRedisQueueConfig();
this.lockDurationMs = options.lockDurationMs ?? DEFAULT_LOCK_DURATION_MS;
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
this.registryKey = `${this.config.prefix}:queue_registry:sessions`;
}
async enqueue(sessionDbId: number, contentSessionId: string, message: PendingMessage): Promise<number> {
const runtime = this.getSessionRuntime(sessionDbId);
await this.registerSession(sessionDbId);
const createdAtEpoch = Date.now();
const payload: BullMqPendingPayload = {
sessionDbId,
contentSessionId,
createdAtEpoch,
message,
};
const jobId = getSafeJobId(contentSessionId, message, createdAtEpoch);
const existing = await runtime.queue.getJob(jobId);
if (existing && !await this.isTerminal(existing)) {
return 0;
}
if (existing) {
try {
await existing.remove();
} catch (error) {
throw this.toRedisUnavailableError(error);
}
}
try {
await runtime.queue.add(message.type, payload, {
jobId,
attempts: 1000000,
removeOnComplete: true,
removeOnFail: { age: 24 * 60 * 60, count: 1000 },
});
} catch (error) {
throw this.toRedisUnavailableError(error);
}
runtime.events.emit('message');
this.options.onMutate?.();
return this.nextEnqueueId++;
}
async *createIterator(options: CreateIteratorOptions): AsyncIterableIterator<PendingMessageWithId> {
const {
sessionDbId,
signal,
onIdleTimeout,
idleTimeoutMs = 3 * 60 * 1000,
} = options;
const runtime = this.getSessionRuntime(sessionDbId);
let lastActivityTime = Date.now();
while (!signal.aborted) {
const token = this.createToken(sessionDbId);
let job: BullMqJob | undefined;
try {
job = await runtime.worker.getNextJob(token, { block: false }) as BullMqJob | undefined;
} catch (error) {
throw this.toRedisUnavailableError(error);
}
if (job) {
const claimId = this.nextClaimId++;
this.activeClaims.set(claimId, {
sessionDbId,
job,
token,
lockTimer: this.startLockRenewal(job, token),
});
lastActivityTime = Date.now();
this.options.onMutate?.();
yield {
...job.data.message,
_persistentId: claimId,
_originalTimestamp: job.data.createdAtEpoch,
};
continue;
}
const received = await this.waitForMessage(runtime.events, signal, this.pollIntervalMs);
if (received) {
continue;
}
if (Date.now() - lastActivityTime >= idleTimeoutMs && !signal.aborted) {
onIdleTimeout?.();
return;
}
}
}
async confirmProcessed(messageId: number): Promise<number> {
const claimed = this.activeClaims.get(messageId);
if (!claimed) {
return 0;
}
try {
await claimed.job.moveToCompleted({ ok: true }, claimed.token, false);
} catch (error) {
throw this.toRedisUnavailableError(error);
}
this.finishClaim(messageId, claimed);
await this.unregisterSessionIfEmpty(claimed.sessionDbId);
this.options.onMutate?.();
return 1;
}
async clearPendingForSession(sessionDbId: number): Promise<number> {
const runtime = this.getSessionRuntime(sessionDbId);
const count = await this.getPendingCount(sessionDbId);
try {
await runtime.queue.obliterate({ force: true });
} catch (error) {
throw this.toRedisUnavailableError(error);
}
for (const [claimId, claimed] of Array.from(this.activeClaims.entries())) {
if (claimed.sessionDbId === sessionDbId) {
this.finishClaim(claimId, claimed);
}
}
await this.unregisterSessionIfEmpty(sessionDbId);
if (count > 0) {
runtime.events.emit('message');
this.options.onMutate?.();
}
return count;
}
async resetProcessingToPending(sessionDbId: number): Promise<number> {
let reset = 0;
let resetError: Error | null = null;
for (const [claimId, claimed] of Array.from(this.activeClaims.entries())) {
if (claimed.sessionDbId !== sessionDbId) {
continue;
}
try {
await claimed.job.moveToWait(claimed.token);
} catch (error) {
const normalized = this.toRedisUnavailableError(error);
resetError ??= normalized;
logger.warn('QUEUE', 'BullMQ active claim reset failed', {
sessionDbId,
jobId: claimed.job.id,
error: normalized.message,
});
continue;
}
this.finishClaim(claimId, claimed);
reset++;
}
if (reset > 0) {
this.getSessionRuntime(sessionDbId).events.emit('message');
this.options.onMutate?.();
}
if (resetError) {
throw resetError;
}
return reset;
}
async getPendingCount(sessionDbId: number): Promise<number> {
const counts = await this.getSessionRuntime(sessionDbId).queue.getJobCounts(...QUEUE_JOB_TYPES);
return sumCounts(counts);
}
async getTotalQueueDepth(): Promise<number> {
let total = 0;
const sessionIds = new Set<number>(this.sessions.keys());
for (const sessionDbId of await this.getRegisteredSessionIds()) {
sessionIds.add(sessionDbId);
}
for (const sessionDbId of sessionIds) {
total += await this.getPendingCount(sessionDbId);
}
return total;
}
async peekPendingTypes(sessionDbId: number): Promise<Array<{ message_type: string; tool_name: string | null }>> {
const jobs = await this.getSessionRuntime(sessionDbId).queue.getJobs(QUEUE_JOB_TYPES, 0, -1, true);
return jobs.map(job => ({
message_type: job.data.message.type,
tool_name: job.data.message.tool_name ?? null,
}));
}
async getHealth(): Promise<ObservationQueueHealth> {
try {
const client = this.getHealthClient();
if (client.status === 'wait' || client.status === 'end') {
await client.connect();
}
await client.ping();
return {
engine: 'bullmq',
redis: {
status: 'ok',
mode: this.config.mode,
host: this.config.host,
port: this.config.port,
prefix: this.config.prefix,
},
};
} catch (error) {
return {
engine: 'bullmq',
redis: {
status: 'error',
mode: this.config.mode,
host: this.config.host,
port: this.config.port,
prefix: this.config.prefix,
error: error instanceof Error ? error.message : String(error),
},
};
}
}
async assertHealthy(): Promise<void> {
const health = await this.getHealth();
if (health.redis.status !== 'ok') {
throw new Error(
`CLAUDE_MEM_QUEUE_ENGINE=bullmq requires Redis/Valkey at ${health.redis.host}:${health.redis.port}; ${health.redis.error ?? 'ping failed'}`
);
}
}
async close(): Promise<void> {
let releaseError: Error | null = null;
try {
await this.releaseActiveClaimsToWait();
} catch (error) {
releaseError = error instanceof Error ? error : new Error(String(error));
} finally {
for (const [claimId, claimed] of Array.from(this.activeClaims.entries())) {
this.finishClaim(claimId, claimed);
}
for (const runtime of this.sessions.values()) {
runtime.events.removeAllListeners();
await runtime.worker.close().catch(error => {
logger.warn('QUEUE', 'BullMQ worker close failed', {
error: error instanceof Error ? error.message : String(error),
});
});
await runtime.queue.close().catch(error => {
logger.warn('QUEUE', 'BullMQ queue close failed', {
error: error instanceof Error ? error.message : String(error),
});
});
}
this.sessions.clear();
if (this.healthClient) {
await this.healthClient.quit().catch(() => this.healthClient?.disconnect());
this.healthClient = null;
}
}
if (releaseError) {
throw releaseError;
}
}
private getSessionRuntime(sessionDbId: number): SessionRuntime {
const existing = this.sessions.get(sessionDbId);
if (existing) {
return existing;
}
const name = `claude_mem_session_${sessionDbId}`;
const queueOptions: QueueOptions = {
connection: this.config.connection,
prefix: this.config.prefix,
};
const workerOptions: WorkerOptions = {
connection: this.config.connection,
prefix: this.config.prefix,
autorun: false,
concurrency: 1,
lockDuration: this.lockDurationMs,
};
const runtime: SessionRuntime = {
queue: this.options.queueFactory
? this.options.queueFactory(name, queueOptions)
: new Queue<BullMqPendingPayload>(name, queueOptions),
worker: this.options.workerFactory
? this.options.workerFactory(name, workerOptions)
: new Worker<BullMqPendingPayload>(name, null, workerOptions),
events: new EventEmitter(),
};
this.sessions.set(sessionDbId, runtime);
return runtime;
}
private getHealthClient(): RedisHealthClient {
if (!this.healthClient) {
this.healthClient = this.options.redisFactory
? this.options.redisFactory(this.config)
: new Redis(this.config.connection) as RedisHealthClient;
}
return this.healthClient;
}
private async registerSession(sessionDbId: number): Promise<void> {
try {
await this.getHealthClient().sadd(this.registryKey, String(sessionDbId));
} catch (error) {
throw this.toRedisUnavailableError(error);
}
}
private async unregisterSessionIfEmpty(sessionDbId: number): Promise<void> {
if (await this.getPendingCount(sessionDbId) > 0) {
return;
}
try {
await this.getHealthClient().srem(this.registryKey, String(sessionDbId));
} catch (error) {
throw this.toRedisUnavailableError(error);
}
}
private async getRegisteredSessionIds(): Promise<number[]> {
let rawSessionIds: string[];
try {
rawSessionIds = await this.getHealthClient().smembers(this.registryKey);
} catch (error) {
throw this.toRedisUnavailableError(error);
}
return rawSessionIds
.map(raw => Number.parseInt(raw, 10))
.filter(sessionDbId => Number.isInteger(sessionDbId) && sessionDbId > 0);
}
private async isTerminal(job: BullMqJob): Promise<boolean> {
const state = await job.getState();
return state === 'completed' || state === 'failed' || state === 'unknown';
}
private startLockRenewal(job: BullMqJob, token: string): ReturnType<typeof setInterval> | null {
if (!job.extendLock) {
return null;
}
const interval = setInterval(() => {
job.extendLock(token, this.lockDurationMs).catch(error => {
logger.warn('QUEUE', 'BullMQ job lock renewal failed', {
jobId: job.id,
error: error instanceof Error ? error.message : String(error),
});
});
}, Math.max(1000, Math.floor(this.lockDurationMs / 2)));
return interval;
}
private finishClaim(claimId: number, claimed: ClaimedJob): void {
if (claimed.lockTimer) {
clearInterval(claimed.lockTimer);
}
this.activeClaims.delete(claimId);
}
private async releaseActiveClaimsToWait(): Promise<number> {
let released = 0;
let releaseError: Error | null = null;
for (const [claimId, claimed] of Array.from(this.activeClaims.entries())) {
try {
await claimed.job.moveToWait(claimed.token);
} catch (error) {
const normalized = this.toRedisUnavailableError(error);
releaseError ??= normalized;
logger.warn('QUEUE', 'BullMQ active claim release failed during close', {
sessionDbId: claimed.sessionDbId,
jobId: claimed.job.id,
error: normalized.message,
});
continue;
}
this.finishClaim(claimId, claimed);
released++;
this.sessions.get(claimed.sessionDbId)?.events.emit('message');
}
if (released > 0) {
this.options.onMutate?.();
}
if (releaseError) {
throw releaseError;
}
return released;
}
private waitForMessage(events: EventEmitter, signal: AbortSignal, timeoutMs: number): Promise<boolean> {
return new Promise(resolve => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeout !== undefined) {
clearTimeout(timeout);
}
events.off('message', onMessage);
signal.removeEventListener('abort', onAbort);
};
const onMessage = () => {
cleanup();
resolve(true);
};
const onAbort = () => {
cleanup();
resolve(false);
};
timeout = setTimeout(() => {
cleanup();
resolve(false);
}, timeoutMs);
events.once('message', onMessage);
signal.addEventListener('abort', onAbort, { once: true });
});
}
private createToken(sessionDbId: number): string {
return `claude-mem-${process.pid}-${sessionDbId}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
private toRedisUnavailableError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
return new Error(`BullMQ queue operation failed; Redis/Valkey is required when CLAUDE_MEM_QUEUE_ENGINE=bullmq: ${message}`);
}
}
export function getSafeJobId(contentSessionId: string, message: PendingMessage, createdAtEpoch: number): string {
if (message.type === 'observation') {
if (message.toolUseId) {
return `obs_${sha256(`${contentSessionId}\0${message.toolUseId}`)}`;
}
return `obs_${sha256(`${contentSessionId}\0${createdAtEpoch}\0${stableMessageFingerprint(message)}`)}`;
}
return `sum_${sha256(`${contentSessionId}\0${createdAtEpoch}\0${message.type}`)}`;
}
function stableMessageFingerprint(message: PendingMessage): string {
return JSON.stringify({
type: message.type,
tool_name: message.tool_name ?? null,
tool_input: message.tool_input ?? null,
tool_response: message.tool_response ?? null,
cwd: message.cwd ?? null,
prompt_number: message.prompt_number ?? null,
agentId: message.agentId ?? null,
agentType: message.agentType ?? null,
});
}
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function sumCounts(counts: Record<string, number>): number {
return QUEUE_JOB_TYPES.reduce((sum, type) => sum + (counts[type] ?? 0), 0);
}
+114
View File
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: Apache-2.0
import { EventEmitter } from 'events';
import type { Database } from 'bun:sqlite';
import { SessionQueueProcessor, type CreateIteratorOptions } from '../../services/queue/SessionQueueProcessor.js';
import { PendingMessageStore } from '../../services/sqlite/PendingMessageStore.js';
import type { PendingMessage, PendingMessageWithId } from '../../services/worker-types.js';
export interface ObservationQueueEngine {
enqueue(sessionDbId: number, contentSessionId: string, message: PendingMessage): Promise<number>;
createIterator(options: CreateIteratorOptions): AsyncIterableIterator<PendingMessageWithId>;
confirmProcessed(messageId: number): Promise<number>;
clearPendingForSession(sessionDbId: number): Promise<number>;
resetProcessingToPending(sessionDbId: number): Promise<number>;
getPendingCount(sessionDbId: number): Promise<number>;
getTotalQueueDepth(): Promise<number>;
close(): Promise<void>;
}
export interface ObservationQueueHealth {
engine: 'bullmq';
redis: {
status: 'ok' | 'error';
mode: string;
host: string;
port: number;
prefix: string;
error?: string;
};
}
export interface ObservationQueueInspection {
peekPendingTypes(sessionDbId: number): Promise<Array<{ message_type: string; tool_name: string | null }>>;
}
export type InspectableObservationQueueEngine = ObservationQueueEngine & ObservationQueueInspection;
export type HealthCheckedObservationQueueEngine = InspectableObservationQueueEngine & {
getHealth(): Promise<ObservationQueueHealth>;
assertHealthy(): Promise<void>;
};
export class SqliteObservationQueueEngine implements InspectableObservationQueueEngine {
private readonly store: PendingMessageStore;
private readonly eventsBySession = new Map<number, EventEmitter>();
constructor(db: Database, onMutate?: () => void) {
this.store = new PendingMessageStore(db, onMutate);
}
async enqueue(sessionDbId: number, contentSessionId: string, message: PendingMessage): Promise<number> {
const id = this.store.enqueue(sessionDbId, contentSessionId, message);
if (id > 0) {
this.emit(sessionDbId);
}
return id;
}
createIterator(options: CreateIteratorOptions): AsyncIterableIterator<PendingMessageWithId> {
const processor = new SessionQueueProcessor(this.store, this.getEvents(options.sessionDbId));
return processor.createIterator(options);
}
async confirmProcessed(messageId: number): Promise<number> {
return this.store.confirmProcessed(messageId);
}
async clearPendingForSession(sessionDbId: number): Promise<number> {
const rows = this.store.clearPendingForSession(sessionDbId);
if (rows > 0) {
this.emit(sessionDbId);
}
return rows;
}
async resetProcessingToPending(sessionDbId: number): Promise<number> {
const rows = this.store.resetProcessingToPending(sessionDbId);
if (rows > 0) {
this.emit(sessionDbId);
}
return rows;
}
async getPendingCount(sessionDbId: number): Promise<number> {
return this.store.getPendingCount(sessionDbId);
}
async getTotalQueueDepth(): Promise<number> {
return this.store.getTotalQueueDepth();
}
async peekPendingTypes(sessionDbId: number): Promise<Array<{ message_type: string; tool_name: string | null }>> {
return this.store.peekPendingTypes(sessionDbId);
}
async close(): Promise<void> {
for (const events of this.eventsBySession.values()) {
events.removeAllListeners();
}
this.eventsBySession.clear();
}
private getEvents(sessionDbId: number): EventEmitter {
let events = this.eventsBySession.get(sessionDbId);
if (!events) {
events = new EventEmitter();
this.eventsBySession.set(sessionDbId, events);
}
return events;
}
private emit(sessionDbId: number): void {
this.eventsBySession.get(sessionDbId)?.emit('message');
}
}
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: Apache-2.0
import type { RedisOptions } from 'ioredis';
import { existsSync } from 'fs';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import type { SettingsDefaults } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
export type ObservationQueueEngineName = 'sqlite' | 'bullmq';
export type RedisMode = 'external' | 'managed' | 'docker';
export interface RedisQueueConfig {
engine: ObservationQueueEngineName;
mode: RedisMode;
url: string | null;
host: string;
port: number;
prefix: string;
connection: RedisOptions;
}
export function getObservationQueueEngineName(): ObservationQueueEngineName {
const raw = getQueueSetting('CLAUDE_MEM_QUEUE_ENGINE').trim().toLowerCase();
if (raw === 'sqlite' || raw === 'bullmq') {
return raw;
}
throw new Error(`Invalid CLAUDE_MEM_QUEUE_ENGINE=${raw}; expected sqlite or bullmq`);
}
export function getRedisQueueConfig(): RedisQueueConfig {
const engine = getObservationQueueEngineName();
const mode = normalizeRedisMode(getQueueSetting('CLAUDE_MEM_REDIS_MODE'));
const url = getQueueSetting('CLAUDE_MEM_REDIS_URL').trim() || null;
const host = getQueueSetting('CLAUDE_MEM_REDIS_HOST').trim() || '127.0.0.1';
const port = parseRedisPort(getQueueSetting('CLAUDE_MEM_REDIS_PORT'));
const prefix = sanitizePrefix(getQueueSetting('CLAUDE_MEM_QUEUE_REDIS_PREFIX'));
const connection = url ? connectionFromUrl(url) : connectionFromHost(host, port);
return {
engine,
mode,
url,
host: url ? describeUrlHost(url).host : host,
port: url ? describeUrlHost(url).port : port,
prefix,
connection,
};
}
function getQueueSetting(key: keyof SettingsDefaults): string {
if (process.env[key] !== undefined) {
return process.env[key]!;
}
if (existsSync(USER_SETTINGS_PATH)) {
return SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)[key];
}
return SettingsDefaultsManager.get(key);
}
function normalizeRedisMode(value: string): RedisMode {
const normalized = value.trim().toLowerCase();
if (normalized === 'external' || normalized === 'managed' || normalized === 'docker') {
return normalized;
}
throw new Error(`Invalid CLAUDE_MEM_REDIS_MODE=${value}; expected external, managed, or docker`);
}
function parseRedisPort(value: string): number {
const port = Number.parseInt(value, 10);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid CLAUDE_MEM_REDIS_PORT=${value}; expected a TCP port`);
}
return port;
}
function sanitizePrefix(value: string): string {
return (value.trim() || 'claude_mem').replace(/[^a-zA-Z0-9_-]/g, '_');
}
function connectionFromHost(host: string, port: number): RedisOptions {
return {
host,
port,
maxRetriesPerRequest: null,
connectTimeout: 1000,
lazyConnect: true,
};
}
function connectionFromUrl(rawUrl: string): RedisOptions {
const parsed = new URL(rawUrl);
if (parsed.protocol !== 'redis:' && parsed.protocol !== 'rediss:') {
throw new Error('CLAUDE_MEM_REDIS_URL must use redis:// or rediss://');
}
const db = parsed.pathname.length > 1
? Number.parseInt(parsed.pathname.slice(1), 10)
: undefined;
if (db !== undefined && (!Number.isInteger(db) || db < 0)) {
throw new Error(`Invalid Redis database in CLAUDE_MEM_REDIS_URL: ${parsed.pathname}`);
}
return {
host: parsed.hostname || '127.0.0.1',
port: parsed.port ? Number.parseInt(parsed.port, 10) : 6379,
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
db,
tls: parsed.protocol === 'rediss:' ? {} : undefined,
maxRetriesPerRequest: null,
connectTimeout: 1000,
lazyConnect: true,
};
}
function describeUrlHost(rawUrl: string): { host: string; port: number } {
const parsed = new URL(rawUrl);
return {
host: parsed.hostname || '127.0.0.1',
port: parsed.port ? Number.parseInt(parsed.port, 10) : 6379,
};
}
+264
View File
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: Apache-2.0
import type { Application, Request, Response } from 'express';
import type { Database } from 'bun:sqlite';
import { z, type ZodTypeAny } from 'zod';
import type { RouteHandler } from '../../../services/server/Server.js';
import { CreateAgentEventSchema } from '../../../core/schemas/agent-event.js';
import { CreateMemoryItemSchema } from '../../../core/schemas/memory-item.js';
import { CreateProjectSchema } from '../../../core/schemas/project.js';
import { CreateServerSessionSchema } from '../../../core/schemas/session.js';
import {
AgentEventsRepository,
AuthRepository,
MemoryItemsRepository,
ProjectsRepository,
ServerSessionsRepository,
} from '../../../storage/sqlite/index.js';
import { requireServerAuth } from '../../middleware/auth.js';
declare const __DEFAULT_PACKAGE_VERSION__: string;
const BUILT_IN_VERSION = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined'
? __DEFAULT_PACKAGE_VERSION__
: 'development';
export interface ServerV1RoutesOptions {
getDatabase: () => Database;
authMode?: string;
runtime?: string;
allowLocalDevBypass?: boolean;
}
export class ServerV1Routes implements RouteHandler {
constructor(private readonly options: ServerV1RoutesOptions) {}
setupRoutes(app: Application): void {
const readAuth = requireServerAuth(this.options.getDatabase, {
authMode: this.options.authMode,
allowLocalDevBypass: this.options.allowLocalDevBypass,
requiredScopes: ['memories:read'],
});
const writeAuth = requireServerAuth(this.options.getDatabase, {
authMode: this.options.authMode,
allowLocalDevBypass: this.options.allowLocalDevBypass,
requiredScopes: ['memories:write'],
});
app.get('/healthz', (_req, res) => {
res.json({ status: 'ok' });
});
app.get('/v1/info', (_req, res) => {
res.json({
name: 'claude-mem-server',
version: BUILT_IN_VERSION,
...(this.options.runtime ? { runtime: this.options.runtime } : {}),
authMode: this.options.authMode ?? process.env.CLAUDE_MEM_AUTH_MODE ?? 'api-key',
});
});
app.get('/v1/projects', readAuth, (req, res) => {
const repo = new ProjectsRepository(this.options.getDatabase());
const projects = req.authContext?.projectId
? [repo.getById(req.authContext.projectId)].filter(project => project !== null)
: repo.list();
res.json({ projects });
this.audit(req, 'projects.list');
});
app.post('/v1/projects', writeAuth, this.handleCreate(CreateProjectSchema, (req, res, body) => {
if (req.authContext?.projectId) {
res.status(403).json({ error: 'Forbidden', message: 'Project-scoped API keys cannot create projects' });
return;
}
const project = new ProjectsRepository(this.options.getDatabase()).create(body);
this.audit(req, 'project.create', project.id);
res.status(201).json({ project });
}));
app.get('/v1/projects/:id', readAuth, (req, res) => {
const id = this.routeParam(req.params.id);
if (!this.ensureProjectAllowed(req, res, id)) return;
const project = new ProjectsRepository(this.options.getDatabase()).getById(id);
if (!project) {
res.status(404).json({ error: 'NotFound', message: 'Project not found' });
return;
}
this.audit(req, 'project.read', project.id);
res.json({ project });
});
app.post('/v1/sessions/start', writeAuth, this.handleCreate(CreateServerSessionSchema, (req, res, body) => {
if (!this.ensureProjectAllowed(req, res, body.projectId)) return;
const session = new ServerSessionsRepository(this.options.getDatabase()).create(body);
this.audit(req, 'session.start', session.id, session.projectId);
res.status(201).json({ session });
}));
app.post('/v1/sessions/:id/end', writeAuth, (req, res) => {
const id = this.routeParam(req.params.id);
const repo = new ServerSessionsRepository(this.options.getDatabase());
const existing = repo.getById(id);
if (!existing) {
res.status(404).json({ error: 'NotFound', message: 'Session not found' });
return;
}
if (!this.ensureProjectAllowed(req, res, existing.projectId)) return;
const session = repo.markCompleted(id);
this.audit(req, 'session.end', id, existing.projectId);
res.json({ session });
});
app.get('/v1/sessions/:id', readAuth, (req, res) => {
const id = this.routeParam(req.params.id);
const session = new ServerSessionsRepository(this.options.getDatabase()).getById(id);
if (!session) {
res.status(404).json({ error: 'NotFound', message: 'Session not found' });
return;
}
if (!this.ensureProjectAllowed(req, res, session.projectId)) return;
this.audit(req, 'session.read', session.id, session.projectId);
res.json({ session });
});
app.post('/v1/events', writeAuth, this.handleCreate(CreateAgentEventSchema, (req, res, body) => {
if (!this.ensureProjectAllowed(req, res, body.projectId)) return;
const event = new AgentEventsRepository(this.options.getDatabase()).create(body);
this.audit(req, 'event.write', event.id, event.projectId);
res.status(201).json({ event });
}));
app.post('/v1/events/batch', writeAuth, this.handleCreate(z.array(CreateAgentEventSchema).min(1).max(500), (req, res, body) => {
for (const event of body) {
if (!this.ensureProjectAllowed(req, res, event.projectId)) return;
}
const db = this.options.getDatabase();
const repo = new AgentEventsRepository(db);
const insertEvents = db.transaction((eventsToCreate: typeof body) => {
return eventsToCreate.map(event => repo.create(event));
});
const events = insertEvents(body);
this.audit(req, 'event.batch_write');
res.status(201).json({ events });
}));
app.get('/v1/events/:id', readAuth, (req, res) => {
const id = this.routeParam(req.params.id);
const event = new AgentEventsRepository(this.options.getDatabase()).getById(id);
if (!event) {
res.status(404).json({ error: 'NotFound', message: 'Event not found' });
return;
}
if (!this.ensureProjectAllowed(req, res, event.projectId)) return;
this.audit(req, 'event.read', event.id, event.projectId);
res.json({ event });
});
app.post('/v1/memories', writeAuth, this.handleCreate(CreateMemoryItemSchema, (req, res, body) => {
if (!this.ensureProjectAllowed(req, res, body.projectId)) return;
const memory = new MemoryItemsRepository(this.options.getDatabase()).create(body);
this.audit(req, 'memory.write', memory.id, memory.projectId);
res.status(201).json({ memory });
}));
app.get('/v1/memories/:id', readAuth, (req, res) => {
const id = this.routeParam(req.params.id);
const memory = new MemoryItemsRepository(this.options.getDatabase()).getById(id);
if (!memory) {
res.status(404).json({ error: 'NotFound', message: 'Memory not found' });
return;
}
if (!this.ensureProjectAllowed(req, res, memory.projectId)) return;
this.audit(req, 'memory.read', memory.id, memory.projectId);
res.json({ memory });
});
app.patch('/v1/memories/:id', writeAuth, this.handleCreate(CreateMemoryItemSchema.partial(), (req, res, body) => {
const id = this.routeParam(req.params.id);
const repo = new MemoryItemsRepository(this.options.getDatabase());
const existing = repo.getById(id);
if (!existing) {
res.status(404).json({ error: 'NotFound', message: 'Memory not found' });
return;
}
if (!this.ensureProjectAllowed(req, res, existing.projectId)) return;
if (body.projectId && body.projectId !== existing.projectId) {
res.status(400).json({ error: 'ValidationError', message: 'projectId cannot be changed' });
return;
}
const memory = repo.update(id, body);
this.audit(req, 'memory.update', id, existing.projectId);
res.json({ memory });
}));
app.post('/v1/search', readAuth, this.handleCreate(z.object({
projectId: z.string().min(1),
query: z.string().min(1),
limit: z.number().int().positive().max(100).optional(),
}), (req, res, body) => {
if (!this.ensureProjectAllowed(req, res, body.projectId)) return;
const memories = new MemoryItemsRepository(this.options.getDatabase()).search(body.projectId, body.query, body.limit ?? 20);
this.audit(req, 'memory.search', null, body.projectId);
res.json({ memories });
}));
app.post('/v1/context', readAuth, this.handleCreate(z.object({
projectId: z.string().min(1),
query: z.string().min(1),
limit: z.number().int().positive().max(50).optional(),
}), (req, res, body) => {
if (!this.ensureProjectAllowed(req, res, body.projectId)) return;
const memories = new MemoryItemsRepository(this.options.getDatabase()).search(body.projectId, body.query, body.limit ?? 10);
this.audit(req, 'memory.context', null, body.projectId);
res.json({ memories, context: memories.map(memory => memory.narrative ?? memory.text ?? memory.title).filter(Boolean).join('\n\n') });
}));
app.get('/v1/audit', readAuth, (req, res) => {
const projectId = String(req.query.projectId ?? '');
if (!projectId) {
res.status(400).json({ error: 'ValidationError', message: 'projectId query parameter is required' });
return;
}
if (!this.ensureProjectAllowed(req, res, projectId)) return;
res.json({ audit: new AuthRepository(this.options.getDatabase()).listAuditLogByProject(projectId) });
});
}
private handleCreate<S extends ZodTypeAny, T = z.infer<S>>(
schema: S,
handler: (req: Request, res: Response, body: T) => void,
) {
return (req: Request, res: Response) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({ error: 'ValidationError', issues: result.error.issues });
return;
}
handler(req, res, result.data as T);
};
}
private ensureProjectAllowed(req: Request, res: Response, projectId: string): boolean {
if (req.authContext?.projectId && req.authContext.projectId !== projectId) {
res.status(403).json({ error: 'Forbidden', message: 'API key is scoped to a different project' });
return false;
}
return true;
}
private routeParam(value: string | string[]): string {
return Array.isArray(value) ? value[0] ?? '' : value;
}
private audit(req: Request, action: string, targetId: string | null = null, projectId: string | null = null): void {
new AuthRepository(this.options.getDatabase()).createAuditLog({
teamId: req.authContext?.teamId ?? null,
projectId: projectId ?? req.authContext?.projectId ?? null,
actorType: req.authContext?.apiKeyId ? 'api_key' : 'system',
actorId: req.authContext?.apiKeyId ?? null,
action,
targetType: targetId ? action.split('.')[0] : null,
targetId,
});
}
}
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: Apache-2.0
import type { Processor } from 'bullmq';
import { ServerJobQueue } from '../jobs/ServerJobQueue.js';
import {
SERVER_JOB_QUEUE_NAMES,
type ServerGenerationJobKind,
type ServerGenerationJobPayload,
} from '../jobs/types.js';
import type { RedisQueueConfig } from '../queue/redis-config.js';
import { logger } from '../../utils/logger.js';
import type {
ServerBetaBoundaryHealth,
ServerBetaQueueManager,
} from './types.js';
// ActiveServerBetaQueueManager owns one ServerJobQueue per generation kind.
// It is wired in only when CLAUDE_MEM_QUEUE_ENGINE=bullmq is set; otherwise
// create-server-beta-service.ts keeps the disabled adapter in place.
//
// This boundary intentionally does not start any Worker processors here.
// Phase 4+ wires processors that consume the queues, calling
// `start(kind, processor)` once provider generation is ready. Until then,
// the queues exist as transports for `enqueueOutbox` to publish into.
const QUEUE_KINDS: ServerGenerationJobKind[] = ['event', 'event-batch', 'summary', 'reindex'];
export class ActiveServerBetaQueueManager implements ServerBetaQueueManager {
readonly kind = 'queue-manager' as const;
private readonly queues: Map<ServerGenerationJobKind, ServerJobQueue<ServerGenerationJobPayload>>;
private closed = false;
constructor(
private readonly config: RedisQueueConfig,
queues?: Map<ServerGenerationJobKind, ServerJobQueue<ServerGenerationJobPayload>>,
) {
if (config.engine !== 'bullmq') {
throw new Error(
`ActiveServerBetaQueueManager requires CLAUDE_MEM_QUEUE_ENGINE=bullmq (got ${config.engine}); ` +
'do not instantiate when bullmq is not selected.',
);
}
this.queues = queues ?? this.buildQueues(config);
}
getQueue(kind: ServerGenerationJobKind): ServerJobQueue<ServerGenerationJobPayload> {
const queue = this.queues.get(kind);
if (!queue) {
throw new Error(`unknown server generation job kind: ${kind}`);
}
return queue;
}
start(kind: ServerGenerationJobKind, processor: Processor<ServerGenerationJobPayload>): void {
this.getQueue(kind).start(processor);
}
getHealth(): ServerBetaBoundaryHealth {
if (this.closed) {
return { status: 'errored', reason: 'queue-manager closed' };
}
const lanes = QUEUE_KINDS.map((kind) => ({ kind, name: SERVER_JOB_QUEUE_NAMES[kind] }));
return {
status: 'active',
reason: 'BullMQ-backed queue manager wired',
details: {
engine: this.config.engine,
mode: this.config.mode,
host: this.config.host,
port: this.config.port,
prefix: this.config.prefix,
lanes,
},
};
}
async close(): Promise<void> {
if (this.closed) {
return;
}
this.closed = true;
const errors: Error[] = [];
for (const queue of this.queues.values()) {
try {
await queue.close();
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
if (errors.length > 0) {
logger.warn('QUEUE', 'errors closing server-beta queue manager', {
count: errors.length,
first: errors[0]!.message,
});
throw errors[0];
}
}
private buildQueues(
config: RedisQueueConfig,
): Map<ServerGenerationJobKind, ServerJobQueue<ServerGenerationJobPayload>> {
const map = new Map<ServerGenerationJobKind, ServerJobQueue<ServerGenerationJobPayload>>();
for (const kind of QUEUE_KINDS) {
map.set(
kind,
new ServerJobQueue<ServerGenerationJobPayload>({
name: SERVER_JOB_QUEUE_NAMES[kind],
config,
}),
);
}
return map;
}
}
+347
View File
@@ -0,0 +1,347 @@
// SPDX-License-Identifier: Apache-2.0
import type { Application } from 'express';
import { spawn } from 'child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import net from 'net';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { Server, type RouteHandler } from '../../services/server/Server.js';
import { paths } from '../../shared/paths.js';
import { logger } from '../../utils/logger.js';
import {
captureProcessStartToken,
verifyPidFileOwnership,
type PidInfo,
} from '../../supervisor/process-registry.js';
import type { ServerBetaServiceGraph } from './types.js';
const SERVER_BETA_RUNTIME = 'server-beta';
const DEFAULT_SERVER_BETA_HOST = '127.0.0.1';
const DEFAULT_SERVER_BETA_PORT = 37877;
export interface ServerBetaServiceOptions {
graph: ServerBetaServiceGraph;
host?: string;
port?: number;
persistRuntimeState?: boolean;
}
export interface ServerBetaRuntimeState {
runtime: typeof SERVER_BETA_RUNTIME;
pid: number;
port: number;
host: string;
startedAt: string;
bootstrap: ServerBetaServiceGraph['postgres']['bootstrap'];
boundaries: {
queueManager: ReturnType<ServerBetaServiceGraph['queueManager']['getHealth']>;
generationWorkerManager: ReturnType<ServerBetaServiceGraph['generationWorkerManager']['getHealth']>;
providerRegistry: ReturnType<ServerBetaServiceGraph['providerRegistry']['getHealth']>;
eventBroadcaster: ReturnType<ServerBetaServiceGraph['eventBroadcaster']['getHealth']>;
};
}
class ServerBetaRuntimeInfoRoutes implements RouteHandler {
constructor(private readonly graph: ServerBetaServiceGraph) {}
setupRoutes(app: Application): void {
app.get('/healthz', (_req, res) => {
res.json({ status: 'ok', runtime: SERVER_BETA_RUNTIME });
});
app.get('/v1/info', (_req, res) => {
res.json({
name: 'claude-mem-server',
runtime: SERVER_BETA_RUNTIME,
authMode: this.graph.authMode,
postgres: {
initialized: this.graph.postgres.bootstrap.initialized,
schemaVersion: this.graph.postgres.bootstrap.schemaVersion,
},
boundaries: {
queueManager: this.graph.queueManager.getHealth(),
generationWorkerManager: this.graph.generationWorkerManager.getHealth(),
providerRegistry: this.graph.providerRegistry.getHealth(),
eventBroadcaster: this.graph.eventBroadcaster.getHealth(),
},
});
});
}
}
export class ServerBetaService {
private readonly graph: ServerBetaServiceGraph;
private readonly host: string;
private readonly requestedPort: number;
private boundPort: number | null = null;
private readonly persistRuntimeState: boolean;
private server: Server | null = null;
private stopping = false;
constructor(options: ServerBetaServiceOptions) {
this.graph = options.graph;
this.host = options.host ?? process.env.CLAUDE_MEM_SERVER_HOST ?? DEFAULT_SERVER_BETA_HOST;
this.requestedPort = options.port ?? getServerBetaPort();
this.persistRuntimeState = options.persistRuntimeState ?? true;
}
async start(): Promise<void> {
if (this.server) {
return;
}
const server = new Server({
getInitializationComplete: () => this.graph.postgres.bootstrap.initialized,
getMcpReady: () => true,
onShutdown: () => this.stop(),
onRestart: async () => {
await this.stop();
await this.start();
},
workerPath: '',
runtime: SERVER_BETA_RUNTIME,
getAiStatus: () => ({
provider: 'disabled',
authMethod: this.graph.authMode,
lastInteraction: null,
}),
});
server.registerRoutes(new ServerBetaRuntimeInfoRoutes(this.graph));
server.finalizeRoutes();
await server.listen(this.requestedPort, this.host);
this.server = server;
this.boundPort = resolveBoundPort(server) ?? this.requestedPort;
if (this.persistRuntimeState) {
writeServerBetaState(this.runtimeState());
}
logger.info('SYSTEM', 'Server beta started', { host: this.host, port: this.boundPort, pid: process.pid });
}
async stop(): Promise<void> {
if (this.stopping) {
return;
}
this.stopping = true;
try {
if (this.server) {
try {
await this.server.close();
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException)?.code !== 'ERR_SERVER_NOT_RUNNING') {
throw error;
}
}
this.server = null;
}
await Promise.all([
this.graph.queueManager.close(),
this.graph.generationWorkerManager.close(),
this.graph.providerRegistry.close(),
this.graph.eventBroadcaster.close(),
]);
await this.graph.postgres.pool.end();
} finally {
if (this.persistRuntimeState) {
removeServerBetaState();
}
this.boundPort = null;
this.stopping = false;
logger.info('SYSTEM', 'Server beta stopped');
}
}
getRuntimeState(): ServerBetaRuntimeState {
return this.runtimeState();
}
private runtimeState(): ServerBetaRuntimeState {
return {
runtime: SERVER_BETA_RUNTIME,
pid: process.pid,
port: this.boundPort ?? this.requestedPort,
host: this.host,
startedAt: new Date().toISOString(),
bootstrap: this.graph.postgres.bootstrap,
boundaries: {
queueManager: this.graph.queueManager.getHealth(),
generationWorkerManager: this.graph.generationWorkerManager.getHealth(),
providerRegistry: this.graph.providerRegistry.getHealth(),
eventBroadcaster: this.graph.eventBroadcaster.getHealth(),
},
};
}
}
function resolveBoundPort(server: Server): number | null {
const address = server.getHttpServer()?.address();
return address && typeof address !== 'string' ? address.port : null;
}
export async function runServerBetaCli(argv: string[] = process.argv.slice(2)): Promise<void> {
const command = argv[0] ?? '--daemon';
const port = getServerBetaPort();
const host = process.env.CLAUDE_MEM_SERVER_HOST ?? DEFAULT_SERVER_BETA_HOST;
switch (command) {
case 'start': {
const existing = readServerBetaPidFile();
if (verifyPidFileOwnership(existing)) {
console.log(JSON.stringify({ status: 'ready', runtime: SERVER_BETA_RUNTIME, pid: existing.pid, port: existing.port }));
return;
}
const daemonPid = spawnServerBetaDaemon(port);
if (daemonPid === undefined) {
console.error('Failed to spawn server beta daemon.');
process.exit(1);
}
console.log(JSON.stringify({ status: 'starting', runtime: SERVER_BETA_RUNTIME, pid: daemonPid, port }));
return;
}
case 'stop': {
const existing = readServerBetaPidFile();
if (!verifyPidFileOwnership(existing)) {
removeServerBetaState();
console.log('Server beta is not running');
return;
}
process.kill(existing.pid, 'SIGTERM');
await waitForPidExit(existing.pid, 5000);
removeServerBetaState();
console.log('Server beta stopped');
return;
}
case 'restart': {
await runServerBetaCli(['stop']);
await runServerBetaCli(['start']);
return;
}
case 'status': {
const state = readServerBetaRuntimeState();
const pidInfo = readServerBetaPidFile();
if (state && verifyPidFileOwnership(pidInfo)) {
console.log('Server beta is running');
console.log(` PID: ${state.pid}`);
console.log(` Port: ${state.port}`);
console.log(` Runtime: ${state.runtime}`);
console.log(` Started: ${state.startedAt}`);
} else {
console.log('Server beta is not running');
}
return;
}
case '--daemon': {
const existing = readServerBetaPidFile();
if (verifyPidFileOwnership(existing) || await isPortInUse(port, host)) {
process.exit(0);
}
const { createServerBetaService } = await import('./create-server-beta-service.js');
const service = await createServerBetaService();
const shutdown = async () => {
await service.stop();
process.exit(0);
};
process.once('SIGTERM', shutdown);
process.once('SIGINT', shutdown);
await service.start();
return;
}
default:
console.error('Usage: server-beta-service start|stop|restart|status');
process.exit(1);
}
}
function getServerBetaPort(): number {
const parsed = Number.parseInt(process.env.CLAUDE_MEM_SERVER_PORT ?? '', 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_SERVER_BETA_PORT;
}
function spawnServerBetaDaemon(port: number): number | undefined {
const scriptPath = typeof __filename !== 'undefined' ? __filename : fileURLToPath(import.meta.url);
const child = spawn(process.execPath, [scriptPath, '--daemon'], {
detached: true,
stdio: 'ignore',
env: {
...process.env,
CLAUDE_MEM_SERVER_PORT: String(port),
},
});
child.unref();
return child.pid;
}
function writeServerBetaState(state: ServerBetaRuntimeState): void {
mkdirSync(dirname(paths.serverBetaRuntime()), { recursive: true });
const pidInfo: PidInfo = {
pid: state.pid,
port: state.port,
startedAt: state.startedAt,
startToken: captureProcessStartToken(state.pid) ?? undefined,
};
writeFileSync(paths.serverBetaPid(), JSON.stringify(pidInfo, null, 2));
writeFileSync(paths.serverBetaPort(), `${state.port}\n`);
writeFileSync(paths.serverBetaRuntime(), JSON.stringify(state, null, 2));
}
function readServerBetaPidFile(): PidInfo | null {
if (!existsSync(paths.serverBetaPid())) {
return null;
}
try {
return JSON.parse(readFileSync(paths.serverBetaPid(), 'utf-8')) as PidInfo;
} catch {
return null;
}
}
function readServerBetaRuntimeState(): ServerBetaRuntimeState | null {
if (!existsSync(paths.serverBetaRuntime())) {
return null;
}
try {
return JSON.parse(readFileSync(paths.serverBetaRuntime(), 'utf-8')) as ServerBetaRuntimeState;
} catch {
return null;
}
}
function removeServerBetaState(): void {
rmSync(paths.serverBetaPid(), { force: true });
rmSync(paths.serverBetaPort(), { force: true });
rmSync(paths.serverBetaRuntime(), { force: true });
}
async function isPortInUse(port: number, host: string): Promise<boolean> {
return new Promise(resolve => {
const socket = net.connect({ port, host });
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('error', () => resolve(false));
});
}
async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!verifyPidFileOwnership({ pid, port: 0, startedAt: '' })) {
return;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
if (process.argv[1]?.endsWith('ServerBetaService.ts') || process.argv[1]?.endsWith('server-beta-service.cjs')) {
runServerBetaCli().catch(error => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: Apache-2.0
import { createPostgresStorageRepositories, getSharedPostgresPool, SERVER_BETA_POSTGRES_SCHEMA_VERSION } from '../../storage/postgres/index.js';
import { bootstrapServerBetaPostgresSchema } from '../../storage/postgres/schema.js';
import type { PostgresPool } from '../../storage/postgres/pool.js';
import { getRedisQueueConfig } from '../queue/redis-config.js';
import { ActiveServerBetaQueueManager } from './ActiveServerBetaQueueManager.js';
import { ServerBetaService } from './ServerBetaService.js';
import {
DisabledServerBetaEventBroadcaster,
DisabledServerBetaGenerationWorkerManager,
DisabledServerBetaProviderRegistry,
DisabledServerBetaQueueManager,
type ServerBetaAuthMode,
type ServerBetaBootstrapStatus,
type ServerBetaQueueManager,
type ServerBetaServiceGraph,
} from './types.js';
export interface CreateServerBetaServiceOptions {
pool?: PostgresPool;
authMode?: ServerBetaAuthMode;
bootstrapSchema?: boolean;
queueManager?: ServerBetaQueueManager;
}
export async function createServerBetaService(
options: CreateServerBetaServiceOptions = {},
): Promise<ServerBetaService> {
const pool = options.pool ?? getSharedPostgresPool({ requireDatabaseUrl: true });
const bootstrap = await initializePostgres(pool, options.bootstrapSchema ?? true);
const graph: ServerBetaServiceGraph = {
runtime: 'server-beta',
postgres: {
pool,
bootstrap,
},
authMode: options.authMode ?? parseAuthMode(process.env.CLAUDE_MEM_AUTH_MODE),
queueManager: options.queueManager ?? buildQueueManager(),
generationWorkerManager: new DisabledServerBetaGenerationWorkerManager('Phase 2 boundary only; generation workers are not wired.'),
providerRegistry: new DisabledServerBetaProviderRegistry('Phase 2 boundary only; provider-backed generation is not wired.'),
eventBroadcaster: new DisabledServerBetaEventBroadcaster('Phase 2 boundary only; SSE/event broadcasting is not wired.'),
storage: createPostgresStorageRepositories(pool),
};
return new ServerBetaService({ graph });
}
// Queue manager selection is fail-fast on misconfiguration. If the user
// explicitly opts into BullMQ via CLAUDE_MEM_QUEUE_ENGINE=bullmq we build
// the active manager; any error there throws so the runtime does not
// silently fall back to a disabled queue. Default behavior (sqlite engine
// or no opt-in) keeps the disabled boundary so worker-era runtimes stay
// compatible.
function buildQueueManager(): ServerBetaQueueManager {
const config = getRedisQueueConfig();
if (config.engine !== 'bullmq') {
return new DisabledServerBetaQueueManager(
`Queue engine is "${config.engine}"; set CLAUDE_MEM_QUEUE_ENGINE=bullmq to activate the server-beta queue manager.`,
);
}
return new ActiveServerBetaQueueManager(config);
}
async function initializePostgres(pool: PostgresPool, bootstrapSchema: boolean): Promise<ServerBetaBootstrapStatus> {
if (!bootstrapSchema) {
return { initialized: false, schemaVersion: null, appliedAt: null };
}
await bootstrapServerBetaPostgresSchema(pool);
const result = await pool.query(
`
SELECT version, applied_at
FROM server_beta_schema_migrations
WHERE version = $1
`,
[SERVER_BETA_POSTGRES_SCHEMA_VERSION],
);
const row = result.rows[0] as { version?: number; applied_at?: Date | string } | undefined;
return {
initialized: row?.version === SERVER_BETA_POSTGRES_SCHEMA_VERSION,
schemaVersion: typeof row?.version === 'number' ? row.version : null,
appliedAt: row?.applied_at ? new Date(row.applied_at).toISOString() : null,
};
}
function parseAuthMode(value: string | undefined): ServerBetaAuthMode {
if (value === 'local-dev' || value === 'disabled') {
return value;
}
return 'api-key';
}
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: Apache-2.0
import type { PostgresPool, PostgresStorageRepositories } from '../../storage/postgres/index.js';
export type ServerBetaRuntimeName = 'server-beta';
export type ServerBetaAuthMode = 'api-key' | 'local-dev' | 'disabled';
export type DisabledBoundaryStatus = 'disabled';
export type ServerBetaBoundaryStatus = 'disabled' | 'active' | 'errored';
export interface ServerBetaBootstrapStatus {
initialized: boolean;
schemaVersion: number | null;
appliedAt: string | null;
error?: string;
}
export interface ServerBetaBoundaryHealth {
status: ServerBetaBoundaryStatus;
reason: string;
details?: Record<string, unknown>;
}
export interface ServerBetaQueueManager {
readonly kind: 'queue-manager';
getHealth(): ServerBetaBoundaryHealth;
close(): Promise<void>;
}
export interface ServerBetaGenerationWorkerManager {
readonly kind: 'generation-worker-manager';
getHealth(): ServerBetaBoundaryHealth;
close(): Promise<void>;
}
export interface ServerBetaProviderRegistry {
readonly kind: 'provider-registry';
getHealth(): ServerBetaBoundaryHealth;
close(): Promise<void>;
}
export interface ServerBetaEventBroadcaster {
readonly kind: 'event-broadcaster';
getHealth(): ServerBetaBoundaryHealth;
close(): Promise<void>;
}
export interface ServerBetaServiceGraph {
runtime: ServerBetaRuntimeName;
postgres: {
pool: PostgresPool;
bootstrap: ServerBetaBootstrapStatus;
};
authMode: ServerBetaAuthMode;
queueManager: ServerBetaQueueManager;
generationWorkerManager: ServerBetaGenerationWorkerManager;
providerRegistry: ServerBetaProviderRegistry;
eventBroadcaster: ServerBetaEventBroadcaster;
storage: PostgresStorageRepositories;
}
abstract class DisabledServerBetaBoundary {
abstract readonly kind: ServerBetaQueueManager['kind']
| ServerBetaGenerationWorkerManager['kind']
| ServerBetaProviderRegistry['kind']
| ServerBetaEventBroadcaster['kind'];
constructor(private readonly reason: string) {}
getHealth(): ServerBetaBoundaryHealth {
return { status: 'disabled' as const, reason: this.reason };
}
async close(): Promise<void> {}
}
export class DisabledServerBetaQueueManager extends DisabledServerBetaBoundary implements ServerBetaQueueManager {
readonly kind = 'queue-manager' as const;
}
export class DisabledServerBetaGenerationWorkerManager extends DisabledServerBetaBoundary implements ServerBetaGenerationWorkerManager {
readonly kind = 'generation-worker-manager' as const;
}
export class DisabledServerBetaProviderRegistry extends DisabledServerBetaBoundary implements ServerBetaProviderRegistry {
readonly kind = 'provider-registry' as const;
}
export class DisabledServerBetaEventBroadcaster extends DisabledServerBetaBoundary implements ServerBetaEventBroadcaster {
readonly kind = 'event-broadcaster' as const;
}