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:
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
mapClaudeCodeObservationToAgentEvent,
|
||||
mapClaudeCodeSessionInitToAgentEvent,
|
||||
} from '../../src/adapters/claude-code/mapper.js';
|
||||
import { genericRestEventExamples } from '../../src/adapters/generic-rest/examples.js';
|
||||
|
||||
describe('claude-code adapter mapper', () => {
|
||||
it('maps hook observation payloads to agent events without dropping legacy fields', () => {
|
||||
const event = mapClaudeCodeObservationToAgentEvent('project-1', {
|
||||
contentSessionId: 'content-1',
|
||||
platformSource: 'Claude Code',
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: 'README.md' },
|
||||
tool_response: { content: 'hello' },
|
||||
cwd: '/repo',
|
||||
agentId: 'agent-1',
|
||||
agentType: 'subagent',
|
||||
tool_use_id: 'tool-1',
|
||||
}, 123);
|
||||
|
||||
expect(event).toMatchObject({
|
||||
projectId: 'project-1',
|
||||
sourceType: 'hook',
|
||||
eventType: 'observation.created',
|
||||
contentSessionId: 'content-1',
|
||||
occurredAtEpoch: 123,
|
||||
});
|
||||
expect(event.payload).toMatchObject({
|
||||
platformSource: 'claude',
|
||||
tool_name: 'Read',
|
||||
cwd: '/repo',
|
||||
agentId: 'agent-1',
|
||||
agentType: 'subagent',
|
||||
tool_use_id: 'tool-1',
|
||||
toolUseId: 'tool-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps session init payloads using normalized platform source', () => {
|
||||
const event = mapClaudeCodeSessionInitToAgentEvent('project-1', {
|
||||
contentSessionId: 'content-1',
|
||||
platformSource: 'codex transcript',
|
||||
}, 456);
|
||||
|
||||
expect(event.eventType).toBe('session.init');
|
||||
expect(event.payload).toMatchObject({ platformSource: 'codex' });
|
||||
});
|
||||
|
||||
it('ships generic REST examples for non-Claude agents', () => {
|
||||
expect(genericRestEventExamples.codexObservation.payload.platformSource).toBe('codex');
|
||||
expect(genericRestEventExamples.opencodeObservation.payload.platformSource).toBe('opencode');
|
||||
expect(genericRestEventExamples.customMemory.kind).toBe('manual');
|
||||
});
|
||||
});
|
||||
@@ -31,8 +31,7 @@ describe('Codex transcript ingestion on Windows (#2192)', () => {
|
||||
});
|
||||
|
||||
it('requeues in-flight processing rows when the generator aborts (queue self-deadlock fix)', () => {
|
||||
expect(sessionRoutesSource).toMatch(/Generator aborted/);
|
||||
expect(sessionRoutesSource).toMatch(/processingMessageIds\.slice\(\)/);
|
||||
expect(sessionRoutesSource).toMatch(/inflightStore\.markFailed\(messageId\)/);
|
||||
expect(sessionRoutesSource).toMatch(/resetProcessingToPending/);
|
||||
expect(sessionRoutesSource).toMatch(/Reset processing messages after generator error/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
AgentEventSchema,
|
||||
ApiKeySchema,
|
||||
ContextPackSchema,
|
||||
MemoryItemSchema,
|
||||
ProjectSchema,
|
||||
ServerSessionSchema,
|
||||
TeamSchema
|
||||
} from '../../../src/core/schemas/index.js';
|
||||
|
||||
describe('server storage Zod schemas', () => {
|
||||
it('parses the shared contracts used by server-owned tables', () => {
|
||||
const now = Date.now();
|
||||
const project = ProjectSchema.parse({
|
||||
id: 'project-1',
|
||||
name: 'Claude Mem',
|
||||
createdAtEpoch: now,
|
||||
updatedAtEpoch: now
|
||||
});
|
||||
|
||||
const session = ServerSessionSchema.parse({
|
||||
id: 'session-1',
|
||||
projectId: project.id,
|
||||
startedAtEpoch: now,
|
||||
updatedAtEpoch: now
|
||||
});
|
||||
|
||||
const memoryItem = MemoryItemSchema.parse({
|
||||
id: 'memory-1',
|
||||
projectId: project.id,
|
||||
serverSessionId: session.id,
|
||||
kind: 'observation',
|
||||
type: 'learned',
|
||||
createdAtEpoch: now,
|
||||
updatedAtEpoch: now
|
||||
});
|
||||
|
||||
const event = AgentEventSchema.parse({
|
||||
id: 'event-1',
|
||||
projectId: project.id,
|
||||
sourceType: 'hook',
|
||||
eventType: 'observation.created',
|
||||
occurredAtEpoch: now,
|
||||
createdAtEpoch: now
|
||||
});
|
||||
|
||||
const team = TeamSchema.parse({
|
||||
id: 'team-1',
|
||||
name: 'Team',
|
||||
createdAtEpoch: now,
|
||||
updatedAtEpoch: now
|
||||
});
|
||||
|
||||
const apiKey = ApiKeySchema.parse({
|
||||
id: 'key-1',
|
||||
name: 'Local key',
|
||||
keyHash: 'hash',
|
||||
createdAtEpoch: now,
|
||||
updatedAtEpoch: now
|
||||
});
|
||||
|
||||
const contextPack = ContextPackSchema.parse({
|
||||
projectId: project.id,
|
||||
generatedAtEpoch: now,
|
||||
items: [memoryItem]
|
||||
});
|
||||
|
||||
expect(project.metadata).toEqual({});
|
||||
expect(session.platformSource).toBe('claude');
|
||||
expect(memoryItem.facts).toEqual([]);
|
||||
expect(event.payload).toEqual({});
|
||||
expect(team.metadata).toEqual({});
|
||||
expect(apiKey.status).toBe('active');
|
||||
expect(contextPack.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects invalid enum values at the contract boundary', () => {
|
||||
expect(() => MemoryItemSchema.parse({
|
||||
id: 'memory-1',
|
||||
projectId: 'project-1',
|
||||
kind: 'legacy',
|
||||
type: 'learned',
|
||||
createdAtEpoch: Date.now(),
|
||||
updatedAtEpoch: Date.now()
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ function seedDatabase(dbPath: string, opts: { observerSessions: number; stuckCou
|
||||
|
||||
const insertPending = db.prepare(
|
||||
`INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, created_at_epoch)
|
||||
VALUES (?, 'keep-content', 'observation', 'failed', ?)`
|
||||
VALUES (?, 'keep-content', 'observation', 'processing', ?)`
|
||||
);
|
||||
for (let i = 0; i < opts.stuckCount; i++) {
|
||||
insertPending.run(keepSessionDbId, epoch);
|
||||
|
||||
@@ -236,6 +236,15 @@ describe('Install Non-TTY Support', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime selection', () => {
|
||||
it('offers Server (beta) while keeping worker as the default runtime', () => {
|
||||
expect(installSource).toContain("'server-beta'");
|
||||
expect(installSource).toContain('Server (beta)');
|
||||
expect(installSource).toContain("initialValue: 'worker'");
|
||||
expect(installSource).toContain('CLAUDE_MEM_RUNTIME');
|
||||
});
|
||||
});
|
||||
|
||||
describe('post-install Next Steps copy', () => {
|
||||
it('frames the choice as two paths', () => {
|
||||
expect(installSource).toContain('Two paths from here:');
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const indexSource = readFileSync(join(__dirname, '..', 'src', 'npx-cli', 'index.ts'), 'utf-8');
|
||||
const serverSource = readFileSync(join(__dirname, '..', 'src', 'npx-cli', 'commands', 'server.ts'), 'utf-8');
|
||||
const workerServiceSource = readFileSync(join(__dirname, '..', 'src', 'services', 'worker-service.ts'), 'utf-8');
|
||||
|
||||
describe('npx CLI server namespace', () => {
|
||||
it('routes the server namespace through the server command module', () => {
|
||||
expect(indexSource).toContain("case 'server'");
|
||||
expect(indexSource).toContain("await import('./commands/server.js')");
|
||||
expect(indexSource).toContain('await runServerCommand(args.slice(1))');
|
||||
});
|
||||
|
||||
it('routes worker lifecycle aliases through the server command module', () => {
|
||||
expect(indexSource).toContain("case 'worker'");
|
||||
expect(indexSource).toContain('runWorkerAliasCommand(args.slice(1))');
|
||||
expect(serverSource).toContain('runWorkerLifecycleCommand');
|
||||
expect(serverSource).toContain('runStartCommand()');
|
||||
expect(serverSource).toContain('runStopCommand()');
|
||||
expect(serverSource).toContain('runRestartCommand()');
|
||||
expect(serverSource).toContain('runStatusCommand()');
|
||||
});
|
||||
|
||||
it('routes server lifecycle commands while keeping reserved commands nonzero failures', () => {
|
||||
expect(serverSource).toContain('runServerBetaLifecycleCommand(subCommand)');
|
||||
expect(serverSource).toContain('runServerBetaStartCommand()');
|
||||
expect(serverSource).toContain('runServerBetaStopCommand()');
|
||||
expect(serverSource).toContain('runServerBetaRestartCommand()');
|
||||
expect(serverSource).toContain('runServerBetaStatusCommand()');
|
||||
expect(serverSource).toContain("'logs'");
|
||||
expect(serverSource).toContain("'doctor'");
|
||||
expect(serverSource).toContain("'migrate'");
|
||||
expect(serverSource).toContain("'export'");
|
||||
expect(serverSource).toContain("'import'");
|
||||
expect(serverSource).toContain("process.exit(1)");
|
||||
expect(serverSource).toContain('runServerApiKeyCommand(argv.slice(1))');
|
||||
expect(serverSource).not.toContain('runServerLogsCommand');
|
||||
});
|
||||
|
||||
it('normalizes direct worker-service server invocations', () => {
|
||||
expect(workerServiceSource).toContain("rawCommand === 'server'");
|
||||
expect(workerServiceSource).toContain('lifecycleCommands.has(maybeSubCommand)');
|
||||
expect(workerServiceSource).toContain('command: `server-${maybeSubCommand}`');
|
||||
expect(workerServiceSource).toContain("case 'server-start'");
|
||||
expect(workerServiceSource).toContain('runServerBetaServiceCli(command.slice');
|
||||
expect(workerServiceSource).toContain('serverCommands.has(maybeSubCommand)');
|
||||
expect(workerServiceSource).toContain("case 'server-api-key'");
|
||||
expect(workerServiceSource).toContain('runServerApiKeyCli(commandArgs)');
|
||||
expect(workerServiceSource).toContain("case 'server-help'");
|
||||
expect(workerServiceSource).toContain("case 'worker-help'");
|
||||
expect(workerServiceSource).not.toContain('command: maybeSubCommand ??');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import {
|
||||
createServerApiKey,
|
||||
hashServerApiKey,
|
||||
revokeServerApiKey,
|
||||
verifyServerApiKey,
|
||||
} from '../../src/server/auth/api-key-service.js';
|
||||
import { requireServerAuth } from '../../src/server/middleware/auth.js';
|
||||
import { ProjectsRepository, TeamsRepository } from '../../src/storage/sqlite/index.js';
|
||||
|
||||
describe('server API key auth', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('creates raw keys once while storing only a hash', () => {
|
||||
const created = createServerApiKey(db, {
|
||||
name: 'Team key',
|
||||
teamId: null,
|
||||
projectId: null,
|
||||
scopes: ['memories:read'],
|
||||
});
|
||||
|
||||
expect(created.rawKey).toStartWith('cmem_');
|
||||
expect(created.record.keyHash).toBe(hashServerApiKey(created.rawKey));
|
||||
expect(created.record.keyHash).not.toContain(created.rawKey);
|
||||
expect(created.record.prefix).toBe(created.rawKey.slice(0, 10));
|
||||
});
|
||||
|
||||
it('verifies required scopes and rejects revoked keys', () => {
|
||||
const created = createServerApiKey(db, {
|
||||
name: 'Scoped key',
|
||||
scopes: ['memories:read'],
|
||||
});
|
||||
|
||||
expect(verifyServerApiKey(db, created.rawKey, ['memories:read'])?.record.id).toBe(created.record.id);
|
||||
expect(verifyServerApiKey(db, created.rawKey, ['memories:write'])).toBeNull();
|
||||
|
||||
revokeServerApiKey(db, created.record.id);
|
||||
expect(verifyServerApiKey(db, created.rawKey, ['memories:read'])).toBeNull();
|
||||
});
|
||||
|
||||
it('middleware allows localhost local-dev without a bearer token', () => {
|
||||
const middleware = requireServerAuth(() => db, { authMode: 'local-dev', allowLocalDevBypass: true });
|
||||
const req: any = {
|
||||
ip: '127.0.0.1',
|
||||
socket: {},
|
||||
header: (name: string) => name.toLowerCase() === 'host' ? '127.0.0.1:37777' : undefined,
|
||||
};
|
||||
const res: any = {
|
||||
status: () => res,
|
||||
json: () => {},
|
||||
};
|
||||
let calledNext = false;
|
||||
|
||||
middleware(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
|
||||
expect(calledNext).toBe(true);
|
||||
expect(req.authContext).toMatchObject({ mode: 'local-dev', scopes: ['local-dev'] });
|
||||
});
|
||||
|
||||
it('middleware requires explicit opt-in before local-dev bypass is honored', () => {
|
||||
const middleware = requireServerAuth(() => db, { authMode: 'local-dev' });
|
||||
const req: any = {
|
||||
ip: '127.0.0.1',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
header: (name: string) => name.toLowerCase() === 'host' ? 'localhost:37777' : undefined,
|
||||
};
|
||||
const res: any = {
|
||||
statusCode: 200,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json: () => {},
|
||||
};
|
||||
let calledNext = false;
|
||||
|
||||
middleware(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
|
||||
expect(calledNext).toBe(false);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('middleware blocks local-dev bypass when forwarded proxy headers are present', () => {
|
||||
const middleware = requireServerAuth(() => db, { authMode: 'local-dev', allowLocalDevBypass: true });
|
||||
const req: any = {
|
||||
ip: '127.0.0.1',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
header: (name: string) => {
|
||||
const normalized = name.toLowerCase();
|
||||
if (normalized === 'host') return 'claude-mem.example.com';
|
||||
if (normalized === 'x-forwarded-for') return '203.0.113.10';
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
const res: any = {
|
||||
statusCode: 200,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json: () => {},
|
||||
};
|
||||
let calledNext = false;
|
||||
|
||||
middleware(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
|
||||
expect(calledNext).toBe(false);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('middleware accepts bracketed IPv6 loopback host headers in explicit local-dev mode', () => {
|
||||
const middleware = requireServerAuth(() => db, { authMode: 'local-dev', allowLocalDevBypass: true });
|
||||
const req: any = {
|
||||
ip: '::1',
|
||||
socket: { remoteAddress: '::1' },
|
||||
header: (name: string) => name.toLowerCase() === 'host' ? '[::1]:37777' : undefined,
|
||||
};
|
||||
const res: any = {
|
||||
status: () => res,
|
||||
json: () => {},
|
||||
};
|
||||
let calledNext = false;
|
||||
|
||||
middleware(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
|
||||
expect(calledNext).toBe(true);
|
||||
expect(req.authContext).toMatchObject({ mode: 'local-dev', scopes: ['local-dev'] });
|
||||
});
|
||||
|
||||
it('middleware defaults to API-key auth when auth mode is not explicitly set', () => {
|
||||
const originalAuthMode = process.env.CLAUDE_MEM_AUTH_MODE;
|
||||
delete process.env.CLAUDE_MEM_AUTH_MODE;
|
||||
try {
|
||||
const middleware = requireServerAuth(() => db);
|
||||
const req: any = {
|
||||
ip: '127.0.0.1',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
header: (name: string) => name.toLowerCase() === 'host' ? 'localhost:37777' : undefined,
|
||||
};
|
||||
const res: any = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body: unknown) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
let calledNext = false;
|
||||
|
||||
middleware(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
|
||||
expect(calledNext).toBe(false);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toMatchObject({ error: 'Unauthorized' });
|
||||
} finally {
|
||||
if (originalAuthMode === undefined) {
|
||||
delete process.env.CLAUDE_MEM_AUTH_MODE;
|
||||
} else {
|
||||
process.env.CLAUDE_MEM_AUTH_MODE = originalAuthMode;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('middleware requires a scoped bearer API key outside local-dev fallback', () => {
|
||||
const team = new TeamsRepository(db).create({ name: 'Core' });
|
||||
const project = new ProjectsRepository(db).create({ name: 'Project' });
|
||||
const created = createServerApiKey(db, {
|
||||
name: 'Write key',
|
||||
teamId: team.id,
|
||||
projectId: project.id,
|
||||
scopes: ['memories:write'],
|
||||
});
|
||||
const middleware = requireServerAuth(() => db, {
|
||||
authMode: 'api-key',
|
||||
requiredScopes: ['memories:write'],
|
||||
});
|
||||
const req: any = {
|
||||
ip: '10.0.0.5',
|
||||
socket: {},
|
||||
header: (name: string) => name.toLowerCase() === 'authorization' ? `Bearer ${created.rawKey}` : undefined,
|
||||
};
|
||||
const res: any = {
|
||||
status: () => res,
|
||||
json: () => {},
|
||||
};
|
||||
let calledNext = false;
|
||||
|
||||
middleware(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
|
||||
expect(calledNext).toBe(true);
|
||||
expect(req.authContext).toMatchObject({
|
||||
mode: 'api-key',
|
||||
apiKeyId: created.record.id,
|
||||
teamId: team.id,
|
||||
projectId: project.id,
|
||||
scopes: ['memories:write'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildServerJobId } from '../../../src/server/jobs/job-id.js';
|
||||
|
||||
const baseParts = {
|
||||
kind: 'event' as const,
|
||||
team_id: 'team_abc',
|
||||
project_id: 'project_xyz',
|
||||
source_type: 'agent_event',
|
||||
source_id: 'evt_001'
|
||||
};
|
||||
|
||||
describe('buildServerJobId', () => {
|
||||
it('produces deterministic IDs across invocations', () => {
|
||||
const a = buildServerJobId(baseParts);
|
||||
const b = buildServerJobId(baseParts);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('changes the digest when any scope field changes', () => {
|
||||
const baseId = buildServerJobId(baseParts);
|
||||
const variants = [
|
||||
{ ...baseParts, team_id: 'team_other' },
|
||||
{ ...baseParts, project_id: 'project_other' },
|
||||
{ ...baseParts, source_type: 'observation_reindex' },
|
||||
{ ...baseParts, source_id: 'evt_002' },
|
||||
{ ...baseParts, kind: 'reindex' as const }
|
||||
];
|
||||
for (const variant of variants) {
|
||||
expect(buildServerJobId(variant)).not.toBe(baseId);
|
||||
}
|
||||
});
|
||||
|
||||
it('emits IDs without colons so BullMQ key separators stay safe', () => {
|
||||
const id = buildServerJobId(baseParts);
|
||||
expect(id.includes(':')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses a kind-prefixed sha256 hex format', () => {
|
||||
const id = buildServerJobId(baseParts);
|
||||
expect(id).toMatch(/^evt_[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('uses different prefixes per kind', () => {
|
||||
const event = buildServerJobId({ ...baseParts, kind: 'event' });
|
||||
const batch = buildServerJobId({ ...baseParts, kind: 'event-batch' });
|
||||
const summary = buildServerJobId({ ...baseParts, kind: 'summary' });
|
||||
const reindex = buildServerJobId({ ...baseParts, kind: 'reindex' });
|
||||
expect(event.startsWith('evt_')).toBe(true);
|
||||
expect(batch.startsWith('evtb_')).toBe(true);
|
||||
expect(summary.startsWith('sum_')).toBe(true);
|
||||
expect(reindex.startsWith('rdx_')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
enqueueOutbox,
|
||||
markCompleted,
|
||||
markFailed,
|
||||
reconcileOnStartup,
|
||||
type SingleSourceJobPayload
|
||||
} from '../../../src/server/jobs/outbox.js';
|
||||
import type { ServerJobQueue } from '../../../src/server/jobs/ServerJobQueue.js';
|
||||
import type {
|
||||
ObservationGenerationJobStatus,
|
||||
PostgresObservationGenerationJob,
|
||||
PostgresObservationGenerationJobEvent,
|
||||
PostgresObservationGenerationJobEventsRepository,
|
||||
PostgresObservationGenerationJobRepository
|
||||
} from '../../../src/storage/postgres/generation-jobs.js';
|
||||
|
||||
interface CreateInput {
|
||||
id?: string;
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
sourceType: PostgresObservationGenerationJob['sourceType'];
|
||||
sourceId: string;
|
||||
agentEventId?: string | null;
|
||||
serverSessionId?: string | null;
|
||||
jobType: string;
|
||||
status?: ObservationGenerationJobStatus;
|
||||
bullmqJobId?: string | null;
|
||||
maxAttempts?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface StubJobRepoState {
|
||||
rows: Map<string, PostgresObservationGenerationJob>;
|
||||
counter: number;
|
||||
}
|
||||
|
||||
function buildStubJobRepo(state: StubJobRepoState): PostgresObservationGenerationJobRepository {
|
||||
const rowId = () => `job_${++state.counter}`;
|
||||
const ts = () => Date.now();
|
||||
|
||||
return {
|
||||
async create(input: CreateInput): Promise<PostgresObservationGenerationJob> {
|
||||
const idempotencyKey = `idem:${input.teamId}:${input.projectId}:${input.sourceType}:${input.sourceId}:${input.jobType}`;
|
||||
const existing = [...state.rows.values()].find(r => r.idempotencyKey === idempotencyKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const id = input.id ?? rowId();
|
||||
const row: PostgresObservationGenerationJob = {
|
||||
id,
|
||||
projectId: input.projectId,
|
||||
teamId: input.teamId,
|
||||
agentEventId: input.agentEventId ?? null,
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId,
|
||||
serverSessionId: input.serverSessionId ?? null,
|
||||
jobType: input.jobType,
|
||||
status: input.status ?? 'queued',
|
||||
idempotencyKey,
|
||||
bullmqJobId: input.bullmqJobId ?? null,
|
||||
attempts: 0,
|
||||
maxAttempts: input.maxAttempts ?? 3,
|
||||
nextAttemptAtEpoch: null,
|
||||
lockedAtEpoch: null,
|
||||
lockedBy: null,
|
||||
completedAtEpoch: null,
|
||||
failedAtEpoch: null,
|
||||
cancelledAtEpoch: null,
|
||||
lastError: null,
|
||||
payload: (input.payload as PostgresObservationGenerationJob['payload']) ?? {},
|
||||
createdAtEpoch: ts(),
|
||||
updatedAtEpoch: ts()
|
||||
};
|
||||
state.rows.set(id, row);
|
||||
return row;
|
||||
},
|
||||
|
||||
async getByIdForScope(input) {
|
||||
const row = state.rows.get(input.id);
|
||||
if (!row || row.projectId !== input.projectId || row.teamId !== input.teamId) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
},
|
||||
|
||||
async transitionStatus(input) {
|
||||
const row = state.rows.get(input.id);
|
||||
if (!row || row.projectId !== input.projectId || row.teamId !== input.teamId) {
|
||||
return null;
|
||||
}
|
||||
const next: PostgresObservationGenerationJob = {
|
||||
...row,
|
||||
status: input.status,
|
||||
attempts: input.status === 'processing' ? row.attempts + 1 : row.attempts,
|
||||
lastError: input.lastError ?? null,
|
||||
nextAttemptAtEpoch: input.nextAttemptAt ? input.nextAttemptAt.getTime() : null,
|
||||
completedAtEpoch: input.status === 'completed' ? ts() : null,
|
||||
failedAtEpoch: input.status === 'failed' ? ts() : null,
|
||||
cancelledAtEpoch: input.status === 'cancelled' ? ts() : null,
|
||||
updatedAtEpoch: ts()
|
||||
};
|
||||
state.rows.set(input.id, next);
|
||||
return next;
|
||||
},
|
||||
|
||||
async listByStatusForScope(input) {
|
||||
return [...state.rows.values()].filter(
|
||||
r => r.status === input.status && r.projectId === input.projectId && r.teamId === input.teamId
|
||||
);
|
||||
}
|
||||
} as unknown as PostgresObservationGenerationJobRepository;
|
||||
}
|
||||
|
||||
interface EventLogEntry {
|
||||
generationJobId: string;
|
||||
eventType: PostgresObservationGenerationJobEvent['eventType'];
|
||||
statusAfter: ObservationGenerationJobStatus;
|
||||
attempt: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildStubEventsRepo(log: EventLogEntry[]): PostgresObservationGenerationJobEventsRepository {
|
||||
return {
|
||||
async append(input) {
|
||||
log.push({
|
||||
generationJobId: input.generationJobId,
|
||||
eventType: input.eventType,
|
||||
statusAfter: input.statusAfter,
|
||||
attempt: input.attempt ?? 0,
|
||||
details: input.details ?? {}
|
||||
});
|
||||
return {
|
||||
id: `evt_${log.length}`,
|
||||
generationJobId: input.generationJobId,
|
||||
eventType: input.eventType,
|
||||
statusAfter: input.statusAfter,
|
||||
attempt: input.attempt ?? 0,
|
||||
details: input.details ?? {},
|
||||
createdAtEpoch: Date.now()
|
||||
};
|
||||
},
|
||||
async listByJobForScope() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as PostgresObservationGenerationJobEventsRepository;
|
||||
}
|
||||
|
||||
interface StubQueueState {
|
||||
added: Array<{ jobId: string; payload: SingleSourceJobPayload }>;
|
||||
removed: string[];
|
||||
failOnAdd: boolean;
|
||||
}
|
||||
|
||||
function buildStubQueue(state: StubQueueState): ServerJobQueue<SingleSourceJobPayload> {
|
||||
return {
|
||||
name: 'stub',
|
||||
add: async (jobId: string, payload: SingleSourceJobPayload) => {
|
||||
if (state.failOnAdd) {
|
||||
throw new Error('redis unavailable');
|
||||
}
|
||||
state.added.push({ jobId, payload });
|
||||
},
|
||||
remove: async (jobId: string) => {
|
||||
state.removed.push(jobId);
|
||||
},
|
||||
getJob: async () => null,
|
||||
getCounts: async () => ({ waiting: 0, active: 0, delayed: 0, failed: 0, completed: 0 }),
|
||||
start: () => {},
|
||||
isStarted: () => false,
|
||||
close: async () => {}
|
||||
} as unknown as ServerJobQueue<SingleSourceJobPayload>;
|
||||
}
|
||||
|
||||
const eventPayload: SingleSourceJobPayload = {
|
||||
kind: 'event',
|
||||
team_id: 'team_1',
|
||||
project_id: 'project_1',
|
||||
source_type: 'agent_event',
|
||||
source_id: 'evt_1',
|
||||
generation_job_id: 'gen_1',
|
||||
agent_event_id: 'evt_1'
|
||||
};
|
||||
|
||||
describe('outbox.enqueueOutbox', () => {
|
||||
it('writes the row, records two events, and publishes to BullMQ', async () => {
|
||||
const repoState: StubJobRepoState = { rows: new Map(), counter: 0 };
|
||||
const log: EventLogEntry[] = [];
|
||||
const queueState: StubQueueState = { added: [], removed: [], failOnAdd: false };
|
||||
const jobRepo = buildStubJobRepo(repoState);
|
||||
const eventsRepo = buildStubEventsRepo(log);
|
||||
const queue = buildStubQueue(queueState);
|
||||
|
||||
const { row, bullmqJobId } = await enqueueOutbox(jobRepo, eventsRepo, queue, {
|
||||
payload: eventPayload
|
||||
});
|
||||
|
||||
expect(row.status).toBe('queued');
|
||||
expect(row.agentEventId).toBe('evt_1');
|
||||
expect(row.jobType).toBe('observation_generate_for_event');
|
||||
expect(bullmqJobId.startsWith('evt_')).toBe(true);
|
||||
expect(bullmqJobId.includes(':')).toBe(false);
|
||||
expect(queueState.added).toHaveLength(1);
|
||||
expect(queueState.added[0]!.jobId).toBe(bullmqJobId);
|
||||
expect(log.map(e => e.eventType)).toEqual(['queued', 'enqueued']);
|
||||
});
|
||||
|
||||
it('suppresses duplicate enqueues by returning the same idempotency-keyed row', async () => {
|
||||
const repoState: StubJobRepoState = { rows: new Map(), counter: 0 };
|
||||
const log: EventLogEntry[] = [];
|
||||
const queueState: StubQueueState = { added: [], removed: [], failOnAdd: false };
|
||||
const jobRepo = buildStubJobRepo(repoState);
|
||||
const eventsRepo = buildStubEventsRepo(log);
|
||||
const queue = buildStubQueue(queueState);
|
||||
|
||||
const first = await enqueueOutbox(jobRepo, eventsRepo, queue, { payload: eventPayload });
|
||||
const second = await enqueueOutbox(jobRepo, eventsRepo, queue, { payload: eventPayload });
|
||||
|
||||
expect(second.row.id).toBe(first.row.id);
|
||||
expect(second.bullmqJobId).toBe(first.bullmqJobId);
|
||||
expect(repoState.rows.size).toBe(1);
|
||||
});
|
||||
|
||||
it('marks the row failed when BullMQ publish throws', async () => {
|
||||
const repoState: StubJobRepoState = { rows: new Map(), counter: 0 };
|
||||
const log: EventLogEntry[] = [];
|
||||
const queueState: StubQueueState = { added: [], removed: [], failOnAdd: true };
|
||||
const jobRepo = buildStubJobRepo(repoState);
|
||||
const eventsRepo = buildStubEventsRepo(log);
|
||||
const queue = buildStubQueue(queueState);
|
||||
|
||||
await expect(
|
||||
enqueueOutbox(jobRepo, eventsRepo, queue, { payload: eventPayload })
|
||||
).rejects.toThrow(/redis unavailable/);
|
||||
|
||||
const row = [...repoState.rows.values()][0]!;
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.lastError?.source).toBe('bullmq_publish');
|
||||
const eventTypes = log.map(e => e.eventType);
|
||||
expect(eventTypes).toContain('queued');
|
||||
expect(eventTypes).toContain('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outbox.reconcileOnStartup', () => {
|
||||
it('replaces terminal BullMQ jobs and re-enqueues queued + processing rows', async () => {
|
||||
const repoState: StubJobRepoState = { rows: new Map(), counter: 0 };
|
||||
const log: EventLogEntry[] = [];
|
||||
const queueState: StubQueueState = { added: [], removed: [], failOnAdd: false };
|
||||
const jobRepo = buildStubJobRepo(repoState);
|
||||
const eventsRepo = buildStubEventsRepo(log);
|
||||
const queue = buildStubQueue(queueState);
|
||||
|
||||
await enqueueOutbox(jobRepo, eventsRepo, queue, { payload: eventPayload });
|
||||
queueState.added.length = 0;
|
||||
log.length = 0;
|
||||
|
||||
const result = await reconcileOnStartup(jobRepo, eventsRepo, queue, {
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1'
|
||||
});
|
||||
|
||||
expect(result.requeued).toBe(1);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(queueState.removed).toHaveLength(1);
|
||||
expect(queueState.added).toHaveLength(1);
|
||||
expect(log.some(e => e.eventType === 'enqueued')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips rows that have hit max_attempts', async () => {
|
||||
const repoState: StubJobRepoState = { rows: new Map(), counter: 0 };
|
||||
const log: EventLogEntry[] = [];
|
||||
const queueState: StubQueueState = { added: [], removed: [], failOnAdd: false };
|
||||
const jobRepo = buildStubJobRepo(repoState);
|
||||
const eventsRepo = buildStubEventsRepo(log);
|
||||
const queue = buildStubQueue(queueState);
|
||||
|
||||
const created = await jobRepo.create({
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1',
|
||||
sourceType: 'agent_event',
|
||||
sourceId: 'evt_1',
|
||||
agentEventId: 'evt_1',
|
||||
jobType: 'observation_generate_for_event',
|
||||
payload: {},
|
||||
maxAttempts: 1
|
||||
});
|
||||
repoState.rows.set(created.id, { ...created, attempts: 1 });
|
||||
|
||||
const result = await reconcileOnStartup(jobRepo, eventsRepo, queue, {
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1'
|
||||
});
|
||||
|
||||
expect(result.requeued).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(queueState.added).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('demotes processing rows back to queued before re-enqueue', async () => {
|
||||
const repoState: StubJobRepoState = { rows: new Map(), counter: 0 };
|
||||
const log: EventLogEntry[] = [];
|
||||
const queueState: StubQueueState = { added: [], removed: [], failOnAdd: false };
|
||||
const jobRepo = buildStubJobRepo(repoState);
|
||||
const eventsRepo = buildStubEventsRepo(log);
|
||||
const queue = buildStubQueue(queueState);
|
||||
|
||||
const created = await jobRepo.create({
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1',
|
||||
sourceType: 'agent_event',
|
||||
sourceId: 'evt_1',
|
||||
agentEventId: 'evt_1',
|
||||
jobType: 'observation_generate_for_event',
|
||||
payload: {}
|
||||
});
|
||||
repoState.rows.set(created.id, { ...created, status: 'processing', attempts: 1 });
|
||||
|
||||
await reconcileOnStartup(jobRepo, eventsRepo, queue, {
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1'
|
||||
});
|
||||
|
||||
const row = repoState.rows.get(created.id)!;
|
||||
expect(row.status).toBe('queued');
|
||||
expect(queueState.added).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('outbox.markCompleted / markFailed', () => {
|
||||
it('transitions to completed and appends a completed event', async () => {
|
||||
const repoState: StubJobRepoState = { rows: new Map(), counter: 0 };
|
||||
const log: EventLogEntry[] = [];
|
||||
const jobRepo = buildStubJobRepo(repoState);
|
||||
const eventsRepo = buildStubEventsRepo(log);
|
||||
|
||||
const created = await jobRepo.create({
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1',
|
||||
sourceType: 'agent_event',
|
||||
sourceId: 'evt_1',
|
||||
agentEventId: 'evt_1',
|
||||
jobType: 'observation_generate_for_event'
|
||||
});
|
||||
|
||||
await markCompleted(jobRepo, eventsRepo, {
|
||||
id: created.id,
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1'
|
||||
});
|
||||
|
||||
expect(repoState.rows.get(created.id)!.status).toBe('completed');
|
||||
expect(log[0]!.eventType).toBe('completed');
|
||||
});
|
||||
|
||||
it('transitions to failed and records the error', async () => {
|
||||
const repoState: StubJobRepoState = { rows: new Map(), counter: 0 };
|
||||
const log: EventLogEntry[] = [];
|
||||
const jobRepo = buildStubJobRepo(repoState);
|
||||
const eventsRepo = buildStubEventsRepo(log);
|
||||
|
||||
const created = await jobRepo.create({
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1',
|
||||
sourceType: 'agent_event',
|
||||
sourceId: 'evt_1',
|
||||
agentEventId: 'evt_1',
|
||||
jobType: 'observation_generate_for_event'
|
||||
});
|
||||
|
||||
await markFailed(jobRepo, eventsRepo, {
|
||||
id: created.id,
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1',
|
||||
error: { message: 'provider 500', source: 'processor' }
|
||||
});
|
||||
|
||||
expect(repoState.rows.get(created.id)!.status).toBe('failed');
|
||||
expect(repoState.rows.get(created.id)!.lastError).toEqual({
|
||||
message: 'provider 500',
|
||||
source: 'processor'
|
||||
});
|
||||
expect(log[0]!.eventType).toBe('failed');
|
||||
});
|
||||
|
||||
it('schedules a retry by transitioning to queued when nextAttemptAt is given', async () => {
|
||||
const repoState: StubJobRepoState = { rows: new Map(), counter: 0 };
|
||||
const log: EventLogEntry[] = [];
|
||||
const jobRepo = buildStubJobRepo(repoState);
|
||||
const eventsRepo = buildStubEventsRepo(log);
|
||||
|
||||
const created = await jobRepo.create({
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1',
|
||||
sourceType: 'agent_event',
|
||||
sourceId: 'evt_1',
|
||||
agentEventId: 'evt_1',
|
||||
jobType: 'observation_generate_for_event'
|
||||
});
|
||||
|
||||
const retryAt = new Date(Date.now() + 60_000);
|
||||
await markFailed(jobRepo, eventsRepo, {
|
||||
id: created.id,
|
||||
projectId: 'project_1',
|
||||
teamId: 'team_1',
|
||||
error: { message: 'transient', source: 'processor' },
|
||||
nextAttemptAt: retryAt
|
||||
});
|
||||
|
||||
expect(repoState.rows.get(created.id)!.status).toBe('queued');
|
||||
expect(log[0]!.eventType).toBe('retry_scheduled');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import type { Job, Processor, QueueOptions, WorkerOptions } from 'bullmq';
|
||||
import { ServerJobQueue } from '../../../src/server/jobs/ServerJobQueue.js';
|
||||
import type { RedisQueueConfig } from '../../../src/server/queue/redis-config.js';
|
||||
|
||||
const fakeConfig: RedisQueueConfig = {
|
||||
engine: 'bullmq',
|
||||
mode: 'managed',
|
||||
url: 'redis://test/0',
|
||||
host: 'test',
|
||||
port: 6379,
|
||||
prefix: 'cmem-test',
|
||||
connection: { host: 'test', port: 6379, lazyConnect: true }
|
||||
};
|
||||
|
||||
interface FakeQueueState {
|
||||
added: Array<{ name: string; payload: unknown; jobId?: string }>;
|
||||
removed: string[];
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
interface FakeWorkerState {
|
||||
processor: Processor<unknown> | null;
|
||||
options: WorkerOptions | null;
|
||||
errorHandlers: Array<(error: unknown) => void>;
|
||||
ranWith: 'autorun-false' | 'autorun-true' | null;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
function buildFakeQueue(state: FakeQueueState) {
|
||||
return (_name: string, _options: QueueOptions) => ({
|
||||
add: async (name: string, payload: unknown, opts?: { jobId?: string }) => {
|
||||
state.added.push({ name, payload, jobId: opts?.jobId });
|
||||
return { id: opts?.jobId ?? 'job_anon' } as Job<unknown>;
|
||||
},
|
||||
getJob: async (_id: string) => null,
|
||||
getJobCounts: async (..._states: string[]) => ({
|
||||
waiting: 1,
|
||||
active: 0,
|
||||
delayed: 0,
|
||||
failed: 0,
|
||||
completed: 0
|
||||
}),
|
||||
remove: async (id: string) => {
|
||||
state.removed.push(id);
|
||||
},
|
||||
close: async () => {
|
||||
state.closed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildFakeWorker(state: FakeWorkerState) {
|
||||
return (_name: string, processor: Processor<unknown> | null, options: WorkerOptions) => {
|
||||
state.processor = processor;
|
||||
state.options = options;
|
||||
return {
|
||||
on: (event: string, handler: (error: unknown) => void) => {
|
||||
if (event === 'error') {
|
||||
state.errorHandlers.push(handler);
|
||||
}
|
||||
},
|
||||
run: () => {
|
||||
state.ranWith = options.autorun === false ? 'autorun-false' : 'autorun-true';
|
||||
},
|
||||
close: async () => {
|
||||
state.closed = true;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe('ServerJobQueue', () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('rejects jobIds that contain colons (BullMQ key separator)', async () => {
|
||||
const queueState: FakeQueueState = { added: [], removed: [], closed: false };
|
||||
const sjq = new ServerJobQueue<{ x: number }>({
|
||||
name: 'q',
|
||||
config: fakeConfig,
|
||||
queueFactory: buildFakeQueue(queueState)
|
||||
});
|
||||
await expect(sjq.add('bad:id', { x: 1 })).rejects.toThrow(/must not contain ':'/);
|
||||
expect(queueState.added.length).toBe(0);
|
||||
await sjq.close();
|
||||
});
|
||||
|
||||
it('passes the jobId through to BullMQ Queue.add', async () => {
|
||||
const queueState: FakeQueueState = { added: [], removed: [], closed: false };
|
||||
const sjq = new ServerJobQueue<{ x: number }>({
|
||||
name: 'q',
|
||||
config: fakeConfig,
|
||||
queueFactory: buildFakeQueue(queueState)
|
||||
});
|
||||
await sjq.add('evt_abc', { x: 1 });
|
||||
expect(queueState.added).toHaveLength(1);
|
||||
expect(queueState.added[0]!.jobId).toBe('evt_abc');
|
||||
expect(queueState.added[0]!.payload).toEqual({ x: 1 });
|
||||
await sjq.close();
|
||||
});
|
||||
|
||||
it('starts the worker with autorun: false and attaches an error listener', () => {
|
||||
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)
|
||||
});
|
||||
sjq.start(async () => {});
|
||||
|
||||
expect(workerState.options?.autorun).toBe(false);
|
||||
expect(workerState.options?.concurrency).toBe(1);
|
||||
expect(workerState.errorHandlers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(workerState.ranWith).toBe('autorun-false');
|
||||
expect(sjq.isStarted()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses double-start to avoid duplicate Worker instances', () => {
|
||||
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)
|
||||
});
|
||||
sjq.start(async () => {});
|
||||
expect(() => sjq.start(async () => {})).toThrow(/already started/);
|
||||
});
|
||||
|
||||
it('error listener absorbs worker errors without throwing', () => {
|
||||
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)
|
||||
});
|
||||
sjq.start(async () => {});
|
||||
expect(() =>
|
||||
workerState.errorHandlers[0]!(new Error('worker crashed'))
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('closes worker and queue on close()', async () => {
|
||||
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)
|
||||
});
|
||||
sjq.start(async () => {});
|
||||
await sjq.add('evt_test', { x: 1 });
|
||||
await sjq.close();
|
||||
expect(workerState.closed).toBe(true);
|
||||
expect(queueState.closed).toBe(true);
|
||||
expect(sjq.isStarted()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getServerMcpSurface } from '../../src/server/mcp/register.js';
|
||||
|
||||
describe('server MCP surface', () => {
|
||||
it('declares memory tools with concrete input schemas', () => {
|
||||
const surface = getServerMcpSurface();
|
||||
const names = surface.tools.map(tool => tool.name);
|
||||
|
||||
expect(names).toEqual([
|
||||
'memory_add',
|
||||
'memory_search',
|
||||
'memory_context',
|
||||
'memory_forget',
|
||||
'memory_list_recent',
|
||||
'memory_record_decision',
|
||||
]);
|
||||
|
||||
for (const tool of surface.tools) {
|
||||
expect(tool.inputSchema.type).toBe('object');
|
||||
expect(Object.keys(tool.inputSchema.properties).length).toBeGreaterThan(0);
|
||||
expect(tool.inputSchema.required?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps resources and prompts available without Bun-only imports', () => {
|
||||
const surface = getServerMcpSurface();
|
||||
|
||||
expect(surface.resources[0].uri).toStartWith('claude-mem://server/');
|
||||
expect(surface.prompts[0].name).toBe('record_decision');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { ActiveServerBetaQueueManager } from '../../../src/server/runtime/ActiveServerBetaQueueManager.js';
|
||||
import { ServerJobQueue } from '../../../src/server/jobs/ServerJobQueue.js';
|
||||
import type {
|
||||
ServerGenerationJobKind,
|
||||
ServerGenerationJobPayload,
|
||||
} from '../../../src/server/jobs/types.js';
|
||||
import type { RedisQueueConfig } from '../../../src/server/queue/redis-config.js';
|
||||
|
||||
const bullmqConfig: RedisQueueConfig = {
|
||||
engine: 'bullmq',
|
||||
mode: 'managed',
|
||||
url: null,
|
||||
host: '127.0.0.1',
|
||||
port: 6379,
|
||||
prefix: 'cmem-test',
|
||||
connection: { host: '127.0.0.1', port: 6379, lazyConnect: true },
|
||||
};
|
||||
|
||||
const sqliteConfig: RedisQueueConfig = {
|
||||
...bullmqConfig,
|
||||
engine: 'sqlite',
|
||||
};
|
||||
|
||||
function buildStubQueues(): {
|
||||
queues: Map<ServerGenerationJobKind, ServerJobQueue<ServerGenerationJobPayload>>;
|
||||
closedNames: string[];
|
||||
} {
|
||||
const closedNames: string[] = [];
|
||||
const make = (name: string) => ({
|
||||
name,
|
||||
add: async () => {},
|
||||
remove: async () => {},
|
||||
getJob: async () => null,
|
||||
getCounts: async () => ({ waiting: 0, active: 0, delayed: 0, failed: 0, completed: 0 }),
|
||||
start: () => {},
|
||||
isStarted: () => false,
|
||||
close: async () => {
|
||||
closedNames.push(name);
|
||||
},
|
||||
}) as unknown as ServerJobQueue<ServerGenerationJobPayload>;
|
||||
|
||||
const queues = new Map<ServerGenerationJobKind, ServerJobQueue<ServerGenerationJobPayload>>();
|
||||
queues.set('event', make('event'));
|
||||
queues.set('event-batch', make('event-batch'));
|
||||
queues.set('summary', make('summary'));
|
||||
queues.set('reindex', make('reindex'));
|
||||
return { queues, closedNames };
|
||||
}
|
||||
|
||||
describe('ActiveServerBetaQueueManager', () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('refuses construction when engine is not bullmq', () => {
|
||||
expect(() => new ActiveServerBetaQueueManager(sqliteConfig)).toThrow(/CLAUDE_MEM_QUEUE_ENGINE=bullmq/);
|
||||
});
|
||||
|
||||
it('reports active health with all four lanes when constructed against bullmq', () => {
|
||||
const { queues } = buildStubQueues();
|
||||
const manager = new ActiveServerBetaQueueManager(bullmqConfig, queues);
|
||||
const health = manager.getHealth();
|
||||
expect(health.status).toBe('active');
|
||||
expect(health.details?.engine).toBe('bullmq');
|
||||
const lanes = health.details?.lanes as Array<{ kind: string; name: string }> | undefined;
|
||||
expect(lanes?.map((l) => l.kind).sort()).toEqual(['event', 'event-batch', 'reindex', 'summary']);
|
||||
});
|
||||
|
||||
it('exposes per-kind queues via getQueue', () => {
|
||||
const { queues } = buildStubQueues();
|
||||
const manager = new ActiveServerBetaQueueManager(bullmqConfig, queues);
|
||||
expect(manager.getQueue('event')).toBe(queues.get('event'));
|
||||
expect(manager.getQueue('summary')).toBe(queues.get('summary'));
|
||||
});
|
||||
|
||||
it('closes every queue on close() and reports errored health afterwards', async () => {
|
||||
const { queues, closedNames } = buildStubQueues();
|
||||
const manager = new ActiveServerBetaQueueManager(bullmqConfig, queues);
|
||||
await manager.close();
|
||||
expect(closedNames.sort()).toEqual(['event', 'event-batch', 'reindex', 'summary']);
|
||||
expect(manager.getHealth().status).toBe('errored');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import { ServerBetaService } from '../../src/server/runtime/ServerBetaService.js';
|
||||
import {
|
||||
DisabledServerBetaEventBroadcaster,
|
||||
DisabledServerBetaGenerationWorkerManager,
|
||||
DisabledServerBetaProviderRegistry,
|
||||
DisabledServerBetaQueueManager,
|
||||
type ServerBetaServiceGraph,
|
||||
} from '../../src/server/runtime/types.js';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
const loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('ServerBetaService', () => {
|
||||
let service: ServerBetaService | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (service) {
|
||||
await service.stop();
|
||||
service = null;
|
||||
}
|
||||
loggerSpies.splice(0).forEach(spy => spy.mockRestore());
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('serves server-beta runtime labels from independent runtime routes', async () => {
|
||||
loggerSpies.push(
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
);
|
||||
|
||||
service = new ServerBetaService({
|
||||
graph: createTestGraph(),
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
persistRuntimeState: false,
|
||||
});
|
||||
await service.start();
|
||||
const address = service.getRuntimeState();
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${address.port}/api/health`);
|
||||
expect(health.status).toBe(200);
|
||||
expect((await health.json()).runtime).toBe('server-beta');
|
||||
|
||||
const info = await fetch(`http://127.0.0.1:${address.port}/v1/info`);
|
||||
expect(info.status).toBe(200);
|
||||
const body = await info.json();
|
||||
expect(body.runtime).toBe('server-beta');
|
||||
expect(body.boundaries.queueManager.status).toBe('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
function createTestGraph(): ServerBetaServiceGraph {
|
||||
return {
|
||||
runtime: 'server-beta',
|
||||
postgres: {
|
||||
pool: {
|
||||
end: mock(() => Promise.resolve()),
|
||||
} as any,
|
||||
bootstrap: {
|
||||
initialized: true,
|
||||
schemaVersion: 1,
|
||||
appliedAt: new Date(0).toISOString(),
|
||||
},
|
||||
},
|
||||
authMode: 'local-dev',
|
||||
queueManager: new DisabledServerBetaQueueManager('test'),
|
||||
generationWorkerManager: new DisabledServerBetaGenerationWorkerManager('test'),
|
||||
providerRegistry: new DisabledServerBetaProviderRegistry('test'),
|
||||
eventBroadcaster: new DisabledServerBetaEventBroadcaster('test'),
|
||||
storage: {} as any,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
mock.module('../../src/services/worker/http/middleware.js', () => ({
|
||||
createMiddleware: () => [],
|
||||
requireLocalhost: (_req: any, _res: any, next: any) => next(),
|
||||
summarizeRequestBody: () => 'test body',
|
||||
}));
|
||||
|
||||
import { Server } from '../../src/services/server/Server.js';
|
||||
import type { RouteHandler, ServerOptions } from '../../src/services/server/Server.js';
|
||||
|
||||
@@ -67,6 +61,40 @@ describe('Server', () => {
|
||||
|
||||
expect(typeof server.app.listen).toBe('function');
|
||||
});
|
||||
|
||||
it('should register pre-body-parser routes before normal middleware', async () => {
|
||||
server = new Server({
|
||||
...mockOptions,
|
||||
preBodyParserRoutes: [{
|
||||
setupRoutes(app) {
|
||||
app.post('/api/auth/*splat', (req, res) => {
|
||||
res.json({
|
||||
bodyParsed: req.body !== undefined,
|
||||
});
|
||||
});
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/auth/session`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Origin: 'http://localhost:37777',
|
||||
},
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:37777');
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.bodyParsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listen', () => {
|
||||
@@ -286,6 +314,33 @@ describe('Server', () => {
|
||||
expect(body.pid).toBeDefined();
|
||||
expect(typeof body.pid).toBe('number');
|
||||
});
|
||||
|
||||
it('should return degraded health when BullMQ Redis health is errored', async () => {
|
||||
server = new Server({
|
||||
...mockOptions,
|
||||
getQueueHealth: () => ({
|
||||
engine: 'bullmq',
|
||||
redis: {
|
||||
status: 'error',
|
||||
mode: 'external',
|
||||
host: '127.0.0.1',
|
||||
port: 6379,
|
||||
prefix: 'test_prefix',
|
||||
error: 'connection refused',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(body.status).toBe('degraded');
|
||||
expect(body.queue.redis.status).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readiness endpoint', () => {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { Server, type ServerOptions } from '../../src/services/server/Server.js';
|
||||
import { ServerV1Routes } from '../../src/server/routes/v1/ServerV1Routes.js';
|
||||
import { createServerApiKey } from '../../src/server/auth/api-key-service.js';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('server REST API v1 routes', () => {
|
||||
let db: Database;
|
||||
let server: Server;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
];
|
||||
db = new Database(':memory:');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
const options: ServerOptions = {
|
||||
getInitializationComplete: () => true,
|
||||
getMcpReady: () => true,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({
|
||||
provider: 'claude',
|
||||
authMethod: 'cli',
|
||||
lastInteraction: null,
|
||||
}),
|
||||
};
|
||||
server = new Server(options);
|
||||
server.registerRoutes(new ServerV1Routes({
|
||||
getDatabase: () => db,
|
||||
authMode: 'local-dev',
|
||||
allowLocalDevBypass: true,
|
||||
}));
|
||||
server.finalizeRoutes();
|
||||
await server.listen(0, '127.0.0.1');
|
||||
const address = server.getHttpServer()?.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Expected server to bind to an ephemeral TCP port');
|
||||
}
|
||||
port = address.port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await server.close();
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ERR_SERVER_NOT_RUNNING') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
db.close();
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('creates projects, sessions, events, memories, and searchable context', async () => {
|
||||
const projectResponse = await post('/v1/projects', {
|
||||
name: 'Claude Mem',
|
||||
rootPath: '/tmp/claude-mem',
|
||||
});
|
||||
expect(projectResponse.status).toBe(201);
|
||||
const { project } = await projectResponse.json();
|
||||
|
||||
const sessionResponse = await post('/v1/sessions/start', {
|
||||
projectId: project.id,
|
||||
memorySessionId: 'memory-1',
|
||||
});
|
||||
expect(sessionResponse.status).toBe(201);
|
||||
const { session } = await sessionResponse.json();
|
||||
|
||||
const eventResponse = await post('/v1/events', {
|
||||
projectId: project.id,
|
||||
serverSessionId: session.id,
|
||||
sourceType: 'api',
|
||||
eventType: 'observation.created',
|
||||
payload: { type: 'learned' },
|
||||
occurredAtEpoch: Date.now(),
|
||||
});
|
||||
expect(eventResponse.status).toBe(201);
|
||||
|
||||
const memoryResponse = await post('/v1/memories', {
|
||||
projectId: project.id,
|
||||
serverSessionId: session.id,
|
||||
kind: 'manual',
|
||||
type: 'note',
|
||||
title: 'Queue backend',
|
||||
narrative: 'BullMQ keeps deployable server queues in Valkey.',
|
||||
facts: ['BullMQ mode requires Redis or Valkey'],
|
||||
});
|
||||
expect(memoryResponse.status).toBe(201);
|
||||
const { memory } = await memoryResponse.json();
|
||||
|
||||
const searchResponse = await post('/v1/search', {
|
||||
projectId: project.id,
|
||||
query: 'BullMQ',
|
||||
});
|
||||
expect(searchResponse.status).toBe(200);
|
||||
const search = await searchResponse.json();
|
||||
expect(search.memories.map((item: any) => item.id)).toContain(memory.id);
|
||||
|
||||
const stemmedSearchResponse = await post('/v1/search', {
|
||||
projectId: project.id,
|
||||
query: 'queue',
|
||||
});
|
||||
expect(stemmedSearchResponse.status).toBe(200);
|
||||
const stemmedSearch = await stemmedSearchResponse.json();
|
||||
expect(stemmedSearch.memories.map((item: any) => item.id)).toContain(memory.id);
|
||||
|
||||
const contextResponse = await post('/v1/context', {
|
||||
projectId: project.id,
|
||||
query: 'Valkey',
|
||||
});
|
||||
expect(contextResponse.status).toBe(200);
|
||||
const context = await contextResponse.json();
|
||||
expect(context.context).toContain('Valkey');
|
||||
|
||||
const endResponse = await post(`/v1/sessions/${session.id}/end`, {});
|
||||
expect(endResponse.status).toBe(200);
|
||||
expect((await endResponse.json()).session.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('denies writes when an API key lacks write scope', async () => {
|
||||
const key = createServerApiKey(db, {
|
||||
name: 'read only',
|
||||
scopes: ['memories:read'],
|
||||
});
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/projects`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${key.rawKey}`,
|
||||
},
|
||||
body: JSON.stringify({ name: 'Denied' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('denies project creation when an API key is scoped to an existing project', async () => {
|
||||
const projectResponse = await post('/v1/projects', { name: 'Owner Project' });
|
||||
expect(projectResponse.status).toBe(201);
|
||||
const { project } = await projectResponse.json();
|
||||
const key = createServerApiKey(db, {
|
||||
name: 'project scoped writer',
|
||||
projectId: project.id,
|
||||
scopes: ['memories:write'],
|
||||
});
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/projects`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${key.rawKey}`,
|
||||
},
|
||||
body: JSON.stringify({ name: 'Forbidden Project' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const row = db.prepare('SELECT COUNT(*) AS count FROM projects').get() as { count: number };
|
||||
expect(row.count).toBe(1);
|
||||
});
|
||||
|
||||
it('limits project listing to the API key project scope', async () => {
|
||||
const projectAResponse = await post('/v1/projects', { name: 'Scoped Project A' });
|
||||
const projectBResponse = await post('/v1/projects', { name: 'Scoped Project B' });
|
||||
expect(projectAResponse.status).toBe(201);
|
||||
expect(projectBResponse.status).toBe(201);
|
||||
const { project: projectA } = await projectAResponse.json();
|
||||
await projectBResponse.json();
|
||||
const key = createServerApiKey(db, {
|
||||
name: 'project A reader',
|
||||
projectId: projectA.id,
|
||||
scopes: ['memories:read'],
|
||||
});
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/projects`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key.rawKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.projects.map((project: any) => project.id)).toEqual([projectA.id]);
|
||||
});
|
||||
|
||||
it('rejects mixed-project event batches without partial writes', async () => {
|
||||
const projectAResponse = await post('/v1/projects', { name: 'Project A' });
|
||||
const projectBResponse = await post('/v1/projects', { name: 'Project B' });
|
||||
expect(projectAResponse.status).toBe(201);
|
||||
expect(projectBResponse.status).toBe(201);
|
||||
const { project: projectA } = await projectAResponse.json();
|
||||
const { project: projectB } = await projectBResponse.json();
|
||||
const key = createServerApiKey(db, {
|
||||
name: 'project A writer',
|
||||
projectId: projectA.id,
|
||||
scopes: ['memories:write'],
|
||||
});
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/events/batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${key.rawKey}`,
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{
|
||||
projectId: projectA.id,
|
||||
sourceType: 'api',
|
||||
eventType: 'observation.created',
|
||||
payload: { index: 1 },
|
||||
occurredAtEpoch: Date.now(),
|
||||
},
|
||||
{
|
||||
projectId: projectB.id,
|
||||
sourceType: 'api',
|
||||
eventType: 'observation.created',
|
||||
payload: { index: 2 },
|
||||
occurredAtEpoch: Date.now(),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const row = db.prepare('SELECT COUNT(*) AS count FROM agent_events').get() as { count: number };
|
||||
expect(row.count).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects memory updates that move records across projects', async () => {
|
||||
const projectAResponse = await post('/v1/projects', { name: 'Memory Project A' });
|
||||
const projectBResponse = await post('/v1/projects', { name: 'Memory Project B' });
|
||||
expect(projectAResponse.status).toBe(201);
|
||||
expect(projectBResponse.status).toBe(201);
|
||||
const { project: projectA } = await projectAResponse.json();
|
||||
const { project: projectB } = await projectBResponse.json();
|
||||
const memoryResponse = await post('/v1/memories', {
|
||||
projectId: projectA.id,
|
||||
kind: 'manual',
|
||||
type: 'note',
|
||||
title: 'Pinned project',
|
||||
});
|
||||
expect(memoryResponse.status).toBe(201);
|
||||
const { memory } = await memoryResponse.json();
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/memories/${memory.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: projectB.id,
|
||||
kind: 'manual',
|
||||
type: 'note',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const stored = db.prepare('SELECT project_id FROM memory_items WHERE id = ?').get(memory.id) as { project_id: string };
|
||||
expect(stored.project_id).toBe(projectA.id);
|
||||
});
|
||||
|
||||
async function post(path: string, body: unknown): Promise<Response> {
|
||||
return fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
|
||||
import { createSDKSession } from '../../../src/services/sqlite/Sessions.js';
|
||||
import { SqliteObservationQueueEngine } from '../../../src/server/queue/ObservationQueueEngine.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
describe('ObservationQueueEngine contract', () => {
|
||||
let db: Database;
|
||||
let engine: SqliteObservationQueueEngine;
|
||||
let sessionDbId: number;
|
||||
const contentSessionId = 'engine-contract-session';
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
engine = new SqliteObservationQueueEngine(db);
|
||||
sessionDbId = createSDKSession(db, contentSessionId, 'test-project', 'Test prompt');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
engine.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('deduplicates messages by content session and tool use id', async () => {
|
||||
const firstId = await engine.enqueue(sessionDbId, contentSessionId, {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-1',
|
||||
});
|
||||
const duplicateId = await engine.enqueue(sessionDbId, contentSessionId, {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-1',
|
||||
});
|
||||
|
||||
expect(firstId).toBeGreaterThan(0);
|
||||
expect(duplicateId).toBe(0);
|
||||
expect(await engine.getPendingCount(sessionDbId)).toBe(1);
|
||||
});
|
||||
|
||||
test('iterator yields FIFO messages with provider metadata intact', async () => {
|
||||
const firstId = await engine.enqueue(sessionDbId, contentSessionId, {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
tool_input: { file: 'a.ts' },
|
||||
agentId: 'agent-1',
|
||||
agentType: 'subagent',
|
||||
});
|
||||
const secondId = await engine.enqueue(sessionDbId, contentSessionId, {
|
||||
type: 'summarize',
|
||||
last_assistant_message: 'done',
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
const first = await iterator.next();
|
||||
const second = await iterator.next();
|
||||
abortController.abort();
|
||||
|
||||
expect(first.done).toBe(false);
|
||||
expect(second.done).toBe(false);
|
||||
expect(first.value).toMatchObject({
|
||||
_persistentId: firstId,
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
tool_input: { file: 'a.ts' },
|
||||
agentId: 'agent-1',
|
||||
agentType: 'subagent',
|
||||
});
|
||||
expect(typeof first.value._originalTimestamp).toBe('number');
|
||||
expect(second.value).toMatchObject({
|
||||
_persistentId: secondId,
|
||||
type: 'summarize',
|
||||
last_assistant_message: 'done',
|
||||
});
|
||||
});
|
||||
|
||||
test('resetProcessingToPending makes claimed rows visible after restart', async () => {
|
||||
const messageId = await engine.enqueue(sessionDbId, contentSessionId, {
|
||||
type: 'observation',
|
||||
tool_name: 'Grep',
|
||||
});
|
||||
|
||||
const firstController = new AbortController();
|
||||
const firstIterator = engine.createIterator({
|
||||
sessionDbId,
|
||||
signal: firstController.signal,
|
||||
});
|
||||
const claimed = await firstIterator.next();
|
||||
firstController.abort();
|
||||
|
||||
expect(claimed.value._persistentId).toBe(messageId);
|
||||
expect(await engine.resetProcessingToPending(sessionDbId)).toBe(1);
|
||||
|
||||
const secondController = new AbortController();
|
||||
const secondIterator = engine.createIterator({
|
||||
sessionDbId,
|
||||
signal: secondController.signal,
|
||||
});
|
||||
const reclaimed = await secondIterator.next();
|
||||
secondController.abort();
|
||||
|
||||
expect(reclaimed.value._persistentId).toBe(messageId);
|
||||
});
|
||||
|
||||
test('iterator exits through idle timeout callback', async () => {
|
||||
const abortController = new AbortController();
|
||||
let idleTimedOut = false;
|
||||
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId,
|
||||
signal: abortController.signal,
|
||||
idleTimeoutMs: 10,
|
||||
onIdleTimeout: () => {
|
||||
idleTimedOut = true;
|
||||
abortController.abort();
|
||||
},
|
||||
});
|
||||
|
||||
const result = await iterator.next();
|
||||
|
||||
expect(result.done).toBe(true);
|
||||
expect(idleTimedOut).toBe(true);
|
||||
});
|
||||
|
||||
test('getTotalQueueDepth counts pending and processing rows across sessions', async () => {
|
||||
const otherSessionDbId = createSDKSession(db, 'engine-contract-other', 'test-project', 'Other prompt');
|
||||
await engine.enqueue(sessionDbId, contentSessionId, { type: 'observation', tool_name: 'Read' });
|
||||
await engine.enqueue(otherSessionDbId, 'engine-contract-other', { type: 'summarize' });
|
||||
|
||||
const abortController = new AbortController();
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
await iterator.next();
|
||||
abortController.abort();
|
||||
|
||||
expect(await engine.getPendingCount(sessionDbId)).toBe(1);
|
||||
expect(await engine.getPendingCount(otherSessionDbId)).toBe(1);
|
||||
expect(await engine.getTotalQueueDepth()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,9 @@ function createMockStore(): PendingMessageStore {
|
||||
tool_response: msg.tool_response ? JSON.parse(msg.tool_response) : undefined,
|
||||
prompt_number: msg.prompt_number || undefined,
|
||||
cwd: msg.cwd || undefined,
|
||||
last_assistant_message: msg.last_assistant_message || undefined
|
||||
last_assistant_message: msg.last_assistant_message || undefined,
|
||||
agentId: msg.agent_id ?? undefined,
|
||||
agentType: msg.agent_type ?? undefined
|
||||
}))
|
||||
} as unknown as PendingMessageStore;
|
||||
}
|
||||
@@ -31,10 +33,9 @@ function createMockMessage(overrides: Partial<PersistentPendingMessage> = {}): P
|
||||
last_assistant_message: null,
|
||||
prompt_number: 1,
|
||||
status: 'pending',
|
||||
retry_count: 0,
|
||||
created_at_epoch: Date.now(),
|
||||
started_processing_at_epoch: null,
|
||||
completed_at_epoch: null,
|
||||
agent_type: null,
|
||||
agent_id: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
@@ -67,21 +68,20 @@ describe('SessionQueueProcessor', () => {
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal,
|
||||
onIdleTimeout
|
||||
onIdleTimeout,
|
||||
idleTimeoutMs: SHORT_TIMEOUT_MS
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
const startTime = Date.now();
|
||||
const results: any[] = [];
|
||||
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
expect(onIdleTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should invoke onIdleTimeout callback when idle timeout occurs', async () => {
|
||||
@@ -93,13 +93,12 @@ describe('SessionQueueProcessor', () => {
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal,
|
||||
onIdleTimeout
|
||||
onIdleTimeout,
|
||||
idleTimeoutMs: 50
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
setTimeout(() => abortController.abort(), 50);
|
||||
|
||||
const results: any[] = [];
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
@@ -123,13 +122,14 @@ describe('SessionQueueProcessor', () => {
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal,
|
||||
onIdleTimeout
|
||||
onIdleTimeout,
|
||||
idleTimeoutMs: 50
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
const results: any[] = [];
|
||||
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
setTimeout(() => abortController.abort(), 25);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
@@ -281,7 +281,7 @@ describe('SessionQueueProcessor', () => {
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should continue after store error with backoff', async () => {
|
||||
it('should retry after a transient store claim error', async () => {
|
||||
let callCount = 0;
|
||||
|
||||
(store.claimNextMessage as any) = mock(() => {
|
||||
@@ -289,29 +289,22 @@ describe('SessionQueueProcessor', () => {
|
||||
if (callCount === 1) {
|
||||
throw new Error('Database error');
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return createMockMessage({ id: 1 });
|
||||
}
|
||||
return null;
|
||||
return createMockMessage({ id: 7 });
|
||||
});
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal
|
||||
signal: abortController.signal,
|
||||
claimRetryDelayMs: 1
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
const results: any[] = [];
|
||||
const result = await iterator.next();
|
||||
abortController.abort();
|
||||
|
||||
setTimeout(() => abortController.abort(), 1500);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
break;
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(callCount).toBeGreaterThanOrEqual(2);
|
||||
expect(result.done).toBe(false);
|
||||
expect(result.value._persistentId).toBe(7);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should exit cleanly if aborted during error backoff', async () => {
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Redis } from 'ioredis';
|
||||
import {
|
||||
BullMqObservationQueueEngine,
|
||||
getSafeJobId,
|
||||
type BullMqObservationQueueEngineOptions,
|
||||
} from '../../../src/server/queue/BullMqObservationQueueEngine.js';
|
||||
import type { PendingMessage } from '../../../src/services/worker-types.js';
|
||||
|
||||
class FakeJob {
|
||||
state: string = 'waiting';
|
||||
failMoveToWait = false;
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly name: string,
|
||||
readonly data: any,
|
||||
) {}
|
||||
|
||||
async getState(): Promise<string> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async moveToCompleted(): Promise<void> {
|
||||
this.state = 'completed';
|
||||
}
|
||||
|
||||
async remove(): Promise<void> {
|
||||
this.state = 'removed';
|
||||
}
|
||||
|
||||
async moveToWait(): Promise<number> {
|
||||
if (this.failMoveToWait) {
|
||||
throw new Error('moveToWait failed');
|
||||
}
|
||||
this.state = 'waiting';
|
||||
return 0;
|
||||
}
|
||||
|
||||
async extendLock(): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeQueue {
|
||||
readonly jobs: FakeJob[] = [];
|
||||
failObliterate = false;
|
||||
closed = false;
|
||||
|
||||
async add(name: string, data: any, opts: { jobId?: string } = {}): Promise<FakeJob> {
|
||||
const id = opts.jobId ?? String(this.jobs.length + 1);
|
||||
const existing = this.jobs.find(job => job.id === id && job.state !== 'removed');
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const job = new FakeJob(id, name, data);
|
||||
this.jobs.push(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJob(jobId: string): Promise<FakeJob | undefined> {
|
||||
return this.jobs.find(job => job.id === jobId && job.state !== 'removed');
|
||||
}
|
||||
|
||||
async getJobCounts(...types: string[]): Promise<Record<string, number>> {
|
||||
return Object.fromEntries(types.map(type => [type, this.jobs.filter(job => job.state === type).length]));
|
||||
}
|
||||
|
||||
async getJobs(types: string[]): Promise<FakeJob[]> {
|
||||
return this.jobs.filter(job => types.includes(job.state));
|
||||
}
|
||||
|
||||
async obliterate(): Promise<void> {
|
||||
if (this.failObliterate) {
|
||||
throw new Error('obliterate failed');
|
||||
}
|
||||
this.jobs.length = 0;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
async claimNext(): Promise<FakeJob | undefined> {
|
||||
const job = this.jobs.find(item => item.state === 'waiting');
|
||||
if (job) {
|
||||
job.state = 'active';
|
||||
}
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRedis {
|
||||
status: string = 'wait';
|
||||
readonly sets = new Map<string, Set<string>>();
|
||||
failSets = false;
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.status = 'ready';
|
||||
}
|
||||
|
||||
async ping(): Promise<string> {
|
||||
return 'PONG';
|
||||
}
|
||||
|
||||
async sadd(key: string, ...members: string[]): Promise<number> {
|
||||
if (this.failSets) {
|
||||
throw new Error('sadd failed');
|
||||
}
|
||||
let set = this.sets.get(key);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
this.sets.set(key, set);
|
||||
}
|
||||
const before = set.size;
|
||||
members.forEach(member => set.add(member));
|
||||
return set.size - before;
|
||||
}
|
||||
|
||||
async srem(key: string, ...members: string[]): Promise<number> {
|
||||
if (this.failSets) {
|
||||
throw new Error('srem failed');
|
||||
}
|
||||
const set = this.sets.get(key);
|
||||
if (!set) return 0;
|
||||
let removed = 0;
|
||||
for (const member of members) {
|
||||
if (set.delete(member)) removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async smembers(key: string): Promise<string[]> {
|
||||
if (this.failSets) {
|
||||
throw new Error('smembers failed');
|
||||
}
|
||||
return Array.from(this.sets.get(key) ?? []);
|
||||
}
|
||||
|
||||
async quit(): Promise<void> {
|
||||
this.status = 'end';
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.status = 'end';
|
||||
}
|
||||
}
|
||||
|
||||
function createEngine(options: Partial<BullMqObservationQueueEngineOptions> & {
|
||||
queues?: Map<string, FakeQueue>;
|
||||
redis?: FakeRedis;
|
||||
} = {}) {
|
||||
const queues = options.queues ?? new Map<string, FakeQueue>();
|
||||
const redis = options.redis ?? new FakeRedis();
|
||||
const { queues: _queues, redis: _redis, ...engineOptions } = options;
|
||||
const getQueue = (name: string) => {
|
||||
let queue = queues.get(name);
|
||||
if (!queue) {
|
||||
queue = new FakeQueue();
|
||||
queues.set(name, queue);
|
||||
}
|
||||
return queue;
|
||||
};
|
||||
const engine = new BullMqObservationQueueEngine({
|
||||
config: {
|
||||
engine: 'bullmq',
|
||||
mode: 'external',
|
||||
url: null,
|
||||
host: '127.0.0.1',
|
||||
port: 6379,
|
||||
prefix: 'test_prefix',
|
||||
connection: {
|
||||
host: '127.0.0.1',
|
||||
port: 6379,
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
},
|
||||
lockDurationMs: 60_000,
|
||||
pollIntervalMs: 5,
|
||||
queueFactory: name => getQueue(name) as any,
|
||||
workerFactory: name => ({
|
||||
getNextJob: () => getQueue(name).claimNext(),
|
||||
close: async () => {},
|
||||
}) as any,
|
||||
redisFactory: () => redis as any,
|
||||
...engineOptions,
|
||||
});
|
||||
return { engine, queues, redis };
|
||||
}
|
||||
|
||||
describe('BullMqObservationQueueEngine', () => {
|
||||
let engine: BullMqObservationQueueEngine | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await engine?.close();
|
||||
engine = null;
|
||||
});
|
||||
|
||||
test('uses safe hashed job ids without colon', () => {
|
||||
const observation: PendingMessage = {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool:with:colon',
|
||||
};
|
||||
const summarize: PendingMessage = {
|
||||
type: 'summarize',
|
||||
last_assistant_message: 'done',
|
||||
};
|
||||
|
||||
const obsId = getSafeJobId('session:1', observation, 123);
|
||||
const sumId = getSafeJobId('session:1', summarize, 123);
|
||||
const fallbackA = getSafeJobId('session:1', { type: 'observation', tool_name: 'Read' }, 123);
|
||||
const fallbackB = getSafeJobId('session:1', { type: 'observation', tool_name: 'Read' }, 124);
|
||||
|
||||
expect(obsId).toStartWith('obs_');
|
||||
expect(sumId).toStartWith('sum_');
|
||||
expect(obsId).not.toContain(':');
|
||||
expect(sumId).not.toContain(':');
|
||||
expect(fallbackA).not.toBe(fallbackB);
|
||||
});
|
||||
|
||||
test('deduplicates active observation jobs by content session and tool use id', async () => {
|
||||
({ engine } = createEngine());
|
||||
|
||||
const first = await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-1',
|
||||
});
|
||||
const duplicate = await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-1',
|
||||
});
|
||||
|
||||
expect(first).toBeGreaterThan(0);
|
||||
expect(duplicate).toBe(0);
|
||||
expect(await engine.getPendingCount(1)).toBe(1);
|
||||
});
|
||||
|
||||
test('replaces terminal jobs before reusing a deterministic BullMQ job id', async () => {
|
||||
const result = createEngine();
|
||||
engine = result.engine;
|
||||
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-1',
|
||||
});
|
||||
const queue = result.queues.get('claude_mem_session_1')!;
|
||||
queue.jobs[0].state = 'failed';
|
||||
|
||||
const replacement = await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-1',
|
||||
});
|
||||
|
||||
expect(replacement).toBeGreaterThan(0);
|
||||
expect(queue.jobs.map(job => job.state)).toEqual(['removed', 'waiting']);
|
||||
expect(await engine.getPendingCount(1)).toBe(1);
|
||||
});
|
||||
|
||||
test('yields per-session FIFO messages and confirms exact claimed jobs', async () => {
|
||||
const result = createEngine();
|
||||
engine = result.engine;
|
||||
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'First',
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Second',
|
||||
toolUseId: 'tool-b',
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId: 1,
|
||||
signal: controller.signal,
|
||||
idleTimeoutMs: 100,
|
||||
});
|
||||
|
||||
const first = await iterator.next();
|
||||
const second = await iterator.next();
|
||||
|
||||
expect(first.value).toMatchObject({ type: 'observation', tool_name: 'First' });
|
||||
expect(second.value).toMatchObject({ type: 'observation', tool_name: 'Second' });
|
||||
expect(first.value._persistentId).not.toBe(second.value._persistentId);
|
||||
|
||||
expect(await engine.confirmProcessed(first.value._persistentId)).toBe(1);
|
||||
expect(await engine.getPendingCount(1)).toBe(1);
|
||||
expect(await engine.confirmProcessed(second.value._persistentId)).toBe(1);
|
||||
expect(await engine.getPendingCount(1)).toBe(0);
|
||||
expect(await result.redis.smembers('test_prefix:queue_registry:sessions')).toEqual([]);
|
||||
|
||||
controller.abort();
|
||||
await iterator.return?.();
|
||||
});
|
||||
|
||||
test('resetProcessingToPending returns claimed jobs to the session queue', async () => {
|
||||
({ engine } = createEngine());
|
||||
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId: 1,
|
||||
signal: controller.signal,
|
||||
idleTimeoutMs: 100,
|
||||
});
|
||||
const first = await iterator.next();
|
||||
|
||||
expect(first.value.tool_name).toBe('Read');
|
||||
expect(await engine.resetProcessingToPending(1)).toBe(1);
|
||||
|
||||
const second = await iterator.next();
|
||||
expect(second.value.tool_name).toBe('Read');
|
||||
|
||||
controller.abort();
|
||||
await iterator.return?.();
|
||||
});
|
||||
|
||||
test('resetProcessingToPending attempts every active claim before throwing', async () => {
|
||||
const result = createEngine();
|
||||
engine = result.engine;
|
||||
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Write',
|
||||
toolUseId: 'tool-b',
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId: 1,
|
||||
signal: controller.signal,
|
||||
idleTimeoutMs: 100,
|
||||
});
|
||||
await iterator.next();
|
||||
await iterator.next();
|
||||
|
||||
const queue = result.queues.get('claude_mem_session_1')!;
|
||||
const failedJob = queue.jobs[0];
|
||||
const releasedJob = queue.jobs[1];
|
||||
failedJob.failMoveToWait = true;
|
||||
|
||||
await expect(engine.resetProcessingToPending(1)).rejects.toThrow('moveToWait failed');
|
||||
|
||||
expect(failedJob.state).toBe('active');
|
||||
expect(releasedJob.state).toBe('waiting');
|
||||
|
||||
failedJob.failMoveToWait = false;
|
||||
expect(await engine.resetProcessingToPending(1)).toBe(1);
|
||||
|
||||
controller.abort();
|
||||
await iterator.return?.();
|
||||
});
|
||||
|
||||
test('close moves local active claims back to wait before dropping state', async () => {
|
||||
const result = createEngine();
|
||||
engine = result.engine;
|
||||
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId: 1,
|
||||
signal: controller.signal,
|
||||
idleTimeoutMs: 100,
|
||||
});
|
||||
|
||||
const first = await iterator.next();
|
||||
expect(first.value.tool_name).toBe('Read');
|
||||
expect(result.queues.get('claude_mem_session_1')!.jobs[0].state).toBe('active');
|
||||
|
||||
await engine.close();
|
||||
engine = null;
|
||||
|
||||
expect(result.queues.get('claude_mem_session_1')!.jobs[0].state).toBe('waiting');
|
||||
|
||||
controller.abort();
|
||||
await iterator.return?.();
|
||||
});
|
||||
|
||||
test('close releases local resources when moving a job back to wait fails', async () => {
|
||||
const result = createEngine();
|
||||
engine = result.engine;
|
||||
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Write',
|
||||
toolUseId: 'tool-b',
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId: 1,
|
||||
signal: controller.signal,
|
||||
idleTimeoutMs: 100,
|
||||
});
|
||||
await iterator.next();
|
||||
await iterator.next();
|
||||
|
||||
const queue = result.queues.get('claude_mem_session_1')!;
|
||||
const failedJob = queue.jobs[0];
|
||||
const releasedJob = queue.jobs[1];
|
||||
failedJob.failMoveToWait = true;
|
||||
await expect(engine.close()).rejects.toThrow('moveToWait failed');
|
||||
engine = null;
|
||||
|
||||
expect(failedJob.state).toBe('active');
|
||||
expect(releasedJob.state).toBe('waiting');
|
||||
expect(queue.closed).toBe(true);
|
||||
expect(result.redis.status).toBe('end');
|
||||
|
||||
controller.abort();
|
||||
await iterator.return?.();
|
||||
});
|
||||
|
||||
test('clearPendingForSession preserves active claims when Redis deletion fails', async () => {
|
||||
const result = createEngine();
|
||||
engine = result.engine;
|
||||
|
||||
await engine.enqueue(1, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId: 1,
|
||||
signal: controller.signal,
|
||||
idleTimeoutMs: 100,
|
||||
});
|
||||
await iterator.next();
|
||||
|
||||
const queue = result.queues.get('claude_mem_session_1')!;
|
||||
queue.failObliterate = true;
|
||||
await expect(engine.clearPendingForSession(1)).rejects.toThrow('obliterate failed');
|
||||
|
||||
queue.failObliterate = false;
|
||||
expect(await engine.resetProcessingToPending(1)).toBe(1);
|
||||
expect(queue.jobs[0].state).toBe('waiting');
|
||||
|
||||
controller.abort();
|
||||
await iterator.return?.();
|
||||
});
|
||||
|
||||
test('discovers queue depth from Redis registry after process restart', async () => {
|
||||
const queues = new Map<string, FakeQueue>();
|
||||
const redis = new FakeRedis();
|
||||
const firstProcess = createEngine({ queues, redis });
|
||||
engine = firstProcess.engine;
|
||||
|
||||
await engine.enqueue(7, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
|
||||
expect(await redis.smembers('test_prefix:queue_registry:sessions')).toEqual(['7']);
|
||||
|
||||
await engine.close();
|
||||
const secondProcess = createEngine({ queues, redis });
|
||||
engine = secondProcess.engine;
|
||||
|
||||
expect(await engine.getTotalQueueDepth()).toBe(1);
|
||||
expect(secondProcess.queues.get('claude_mem_session_7')).toBeDefined();
|
||||
});
|
||||
|
||||
test('clearPendingForSession prunes empty sessions from the Redis registry', async () => {
|
||||
const queues = new Map<string, FakeQueue>();
|
||||
const redis = new FakeRedis();
|
||||
const firstProcess = createEngine({ queues, redis });
|
||||
engine = firstProcess.engine;
|
||||
|
||||
await engine.enqueue(7, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
|
||||
expect(await redis.smembers('test_prefix:queue_registry:sessions')).toEqual(['7']);
|
||||
expect(await engine.clearPendingForSession(7)).toBe(1);
|
||||
expect(await redis.smembers('test_prefix:queue_registry:sessions')).toEqual([]);
|
||||
});
|
||||
|
||||
test('reports Redis health without creating sqlite fallback', async () => {
|
||||
({ engine } = createEngine());
|
||||
|
||||
const health = await engine.getHealth();
|
||||
|
||||
expect(health.engine).toBe('bullmq');
|
||||
expect(health.redis.status).toBe('ok');
|
||||
expect(health.redis.prefix).toBe('test_prefix');
|
||||
});
|
||||
|
||||
test('assertHealthy fails instead of falling back when Redis is unavailable', async () => {
|
||||
({ engine } = createEngine({
|
||||
redisFactory: () => ({
|
||||
status: 'wait',
|
||||
connect: async () => {},
|
||||
ping: async () => {
|
||||
throw new Error('connection refused');
|
||||
},
|
||||
sadd: async () => 0,
|
||||
srem: async () => 0,
|
||||
smembers: async () => [],
|
||||
quit: async () => {},
|
||||
disconnect: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
await expect(engine.assertHealthy()).rejects.toThrow('CLAUDE_MEM_QUEUE_ENGINE=bullmq requires Redis/Valkey');
|
||||
});
|
||||
|
||||
const redisIntegrationTest = process.env.CLAUDE_MEM_RUN_REDIS_QUEUE_TESTS === 'true'
|
||||
? test
|
||||
: test.skip;
|
||||
|
||||
redisIntegrationTest('releases active jobs and discovers registry with real Redis', async () => {
|
||||
const redisUrl = process.env.CLAUDE_MEM_REDIS_URL ?? 'redis://127.0.0.1:6379';
|
||||
const prefix = `cm_test_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parsedRedisUrl = new URL(redisUrl);
|
||||
const redisConnection = {
|
||||
host: parsedRedisUrl.hostname || '127.0.0.1',
|
||||
port: parsedRedisUrl.port ? Number.parseInt(parsedRedisUrl.port, 10) : 6379,
|
||||
username: parsedRedisUrl.username ? decodeURIComponent(parsedRedisUrl.username) : undefined,
|
||||
password: parsedRedisUrl.password ? decodeURIComponent(parsedRedisUrl.password) : undefined,
|
||||
db: parsedRedisUrl.pathname.length > 1 ? Number.parseInt(parsedRedisUrl.pathname.slice(1), 10) : undefined,
|
||||
tls: parsedRedisUrl.protocol === 'rediss:' ? {} : undefined,
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
const client = new Redis(redisUrl, {
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: null,
|
||||
connectTimeout: 1000,
|
||||
});
|
||||
await client.connect();
|
||||
await client.ping();
|
||||
await client.quit();
|
||||
|
||||
const config = {
|
||||
engine: 'bullmq' as const,
|
||||
mode: 'external' as const,
|
||||
url: redisUrl,
|
||||
host: redisConnection.host,
|
||||
port: redisConnection.port,
|
||||
prefix,
|
||||
connection: redisConnection,
|
||||
};
|
||||
|
||||
engine = new BullMqObservationQueueEngine({
|
||||
config,
|
||||
lockDurationMs: 60_000,
|
||||
pollIntervalMs: 5,
|
||||
});
|
||||
|
||||
await engine.enqueue(99, 'content-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'Read',
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = engine.createIterator({
|
||||
sessionDbId: 99,
|
||||
signal: controller.signal,
|
||||
idleTimeoutMs: 100,
|
||||
});
|
||||
const first = await iterator.next();
|
||||
expect(first.value.tool_name).toBe('Read');
|
||||
await engine.close();
|
||||
engine = null;
|
||||
|
||||
const restarted = new BullMqObservationQueueEngine({
|
||||
config,
|
||||
lockDurationMs: 60_000,
|
||||
pollIntervalMs: 5,
|
||||
});
|
||||
engine = restarted;
|
||||
expect(await restarted.getTotalQueueDepth()).toBe(1);
|
||||
expect(await restarted.clearPendingForSession(99)).toBe(1);
|
||||
|
||||
controller.abort();
|
||||
await iterator.return?.();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
describe('redis queue config', () => {
|
||||
const previousEnv = new Map<string, string | undefined>();
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of previousEnv.entries()) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
previousEnv.clear();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
test('loads queue settings from settings file with env override precedence', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'claude-mem-redis-config-'));
|
||||
const settingsPath = join(tempDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
CLAUDE_MEM_QUEUE_ENGINE: 'bullmq',
|
||||
CLAUDE_MEM_REDIS_MODE: 'external',
|
||||
CLAUDE_MEM_REDIS_HOST: 'settings-host',
|
||||
CLAUDE_MEM_REDIS_PORT: '6381',
|
||||
CLAUDE_MEM_REDIS_URL: '',
|
||||
CLAUDE_MEM_QUEUE_REDIS_PREFIX: 'settings-prefix',
|
||||
}), 'utf-8');
|
||||
|
||||
mock.module('../../../src/shared/paths.js', () => ({
|
||||
USER_SETTINGS_PATH: settingsPath,
|
||||
}));
|
||||
|
||||
setEnv('CLAUDE_MEM_REDIS_HOST', 'env-host');
|
||||
|
||||
const { getRedisQueueConfig, getObservationQueueEngineName } = await import('../../../src/server/queue/redis-config.js');
|
||||
|
||||
expect(getObservationQueueEngineName()).toBe('bullmq');
|
||||
const config = getRedisQueueConfig();
|
||||
expect(config.host).toBe('env-host');
|
||||
expect(config.port).toBe(6381);
|
||||
expect(config.prefix).toBe('settings-prefix');
|
||||
});
|
||||
|
||||
function setEnv(key: string, value: string): void {
|
||||
if (!previousEnv.has(key)) {
|
||||
previousEnv.set(key, process.env[key]);
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
|
||||
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
|
||||
import { PendingMessageStore } from '../../../src/services/sqlite/PendingMessageStore.js';
|
||||
import { createSDKSession } from '../../../src/services/sqlite/Sessions.js';
|
||||
import type { PendingMessage } from '../../../src/services/worker-types.js';
|
||||
|
||||
function getColumnNames(db: Database, table: string): string[] {
|
||||
@@ -313,3 +315,101 @@ describe('PendingMessageStore current schema guardrails', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PendingMessageStore', () => {
|
||||
let db: Database;
|
||||
let store: PendingMessageStore;
|
||||
let sessionDbId: number;
|
||||
const CONTENT_SESSION_ID = 'test-queue-store';
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
store = new PendingMessageStore(db);
|
||||
sessionDbId = createSDKSession(db, CONTENT_SESSION_ID, 'test-project', 'Test prompt');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
function enqueueMessage(overrides: Partial<PendingMessage> = {}): number {
|
||||
return store.enqueue(sessionDbId, CONTENT_SESSION_ID, createPendingMessage(overrides));
|
||||
}
|
||||
|
||||
test('claimNextMessage claims pending messages in FIFO order', () => {
|
||||
const firstId = enqueueMessage({ tool_name: 'First' });
|
||||
const secondId = enqueueMessage({ tool_name: 'Second' });
|
||||
|
||||
const first = store.claimNextMessage(sessionDbId);
|
||||
const second = store.claimNextMessage(sessionDbId);
|
||||
|
||||
expect(first?.id).toBe(firstId);
|
||||
expect(second?.id).toBe(secondId);
|
||||
expect(first?.status).toBe('processing');
|
||||
expect(second?.status).toBe('processing');
|
||||
});
|
||||
|
||||
test('claimNextMessage ignores already processing messages until reset', () => {
|
||||
const firstId = enqueueMessage({ tool_name: 'First' });
|
||||
const secondId = enqueueMessage({ tool_name: 'Second' });
|
||||
|
||||
expect(store.claimNextMessage(sessionDbId)?.id).toBe(firstId);
|
||||
expect(store.claimNextMessage(sessionDbId)?.id).toBe(secondId);
|
||||
expect(store.claimNextMessage(sessionDbId)).toBeNull();
|
||||
|
||||
expect(store.resetProcessingToPending(sessionDbId)).toBe(2);
|
||||
expect(store.claimNextMessage(sessionDbId)?.id).toBe(firstId);
|
||||
});
|
||||
|
||||
test('resetProcessingToPending only affects the specified session', () => {
|
||||
const session2Id = createSDKSession(db, 'other-session', 'test-project', 'Test');
|
||||
const session1MessageId = enqueueMessage();
|
||||
const session2MessageId = store.enqueue(session2Id, 'other-session', {
|
||||
type: 'observation',
|
||||
tool_name: 'OtherTool',
|
||||
});
|
||||
|
||||
expect(store.claimNextMessage(sessionDbId)?.id).toBe(session1MessageId);
|
||||
expect(store.claimNextMessage(session2Id)?.id).toBe(session2MessageId);
|
||||
|
||||
expect(store.resetProcessingToPending(sessionDbId)).toBe(1);
|
||||
|
||||
const session1Msg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(session1MessageId) as { status: string };
|
||||
const session2Msg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(session2MessageId) as { status: string };
|
||||
expect(session1Msg.status).toBe('pending');
|
||||
expect(session2Msg.status).toBe('processing');
|
||||
});
|
||||
|
||||
test('clearPendingForSession removes pending and processing rows', () => {
|
||||
const firstId = enqueueMessage({ tool_name: 'First' });
|
||||
enqueueMessage({ tool_name: 'Second' });
|
||||
|
||||
expect(store.claimNextMessage(sessionDbId)?.id).toBe(firstId);
|
||||
expect(store.getPendingCount(sessionDbId)).toBe(2);
|
||||
expect(store.clearPendingForSession(sessionDbId)).toBe(2);
|
||||
expect(store.getPendingCount(sessionDbId)).toBe(0);
|
||||
});
|
||||
|
||||
test('deduplicates by content session and tool use id', () => {
|
||||
const firstId = enqueueMessage({ toolUseId: 'tool-1' });
|
||||
const duplicateId = enqueueMessage({ toolUseId: 'tool-1' });
|
||||
|
||||
expect(firstId).toBeGreaterThan(0);
|
||||
expect(duplicateId).toBe(0);
|
||||
expect(store.getPendingCount(sessionDbId)).toBe(1);
|
||||
});
|
||||
|
||||
test('queue depth helpers count pending and processing rows across sessions', () => {
|
||||
const session2Id = createSDKSession(db, 'other-depth-session', 'test-project', 'Test');
|
||||
|
||||
enqueueMessage();
|
||||
store.enqueue(session2Id, 'other-depth-session', { type: 'summarize' });
|
||||
store.claimNextMessage(sessionDbId);
|
||||
|
||||
expect(store.getPendingCount(sessionDbId)).toBe(1);
|
||||
expect(store.getPendingCount(session2Id)).toBe(1);
|
||||
expect(store.getTotalQueueDepth()).toBe(2);
|
||||
expect(store.hasAnyPendingWork()).toBe(true);
|
||||
expect(store.getSessionsWithPendingMessages()).toEqual([sessionDbId, session2Id]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,15 @@ describe('MigrationRunner', () => {
|
||||
expect(tables).toContain('session_summaries');
|
||||
expect(tables).toContain('user_prompts');
|
||||
expect(tables).toContain('pending_messages');
|
||||
expect(tables).toContain('projects');
|
||||
expect(tables).toContain('server_sessions');
|
||||
expect(tables).toContain('agent_events');
|
||||
expect(tables).toContain('memory_items');
|
||||
expect(tables).toContain('memory_sources');
|
||||
expect(tables).toContain('teams');
|
||||
expect(tables).toContain('team_members');
|
||||
expect(tables).toContain('api_keys');
|
||||
expect(tables).toContain('audit_log');
|
||||
});
|
||||
|
||||
it('should create sdk_sessions with all expected columns', () => {
|
||||
@@ -125,6 +134,104 @@ describe('MigrationRunner', () => {
|
||||
expect(versions).toContain(21);
|
||||
expect(versions).toContain(22);
|
||||
expect(versions).toContain(30);
|
||||
expect(versions).toContain(33);
|
||||
expect(versions).toContain(34);
|
||||
});
|
||||
|
||||
it('should create server-owned storage tables without changing legacy readability', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const epoch = Date.now();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('content-readable', 'memory-readable', 'legacy-project', now, epoch, 'active');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO observations (memory_session_id, project, type, title, narrative, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('memory-readable', 'legacy-project', 'learned', 'Legacy observation', 'Still queryable', now, epoch);
|
||||
|
||||
const observation = db.prepare('SELECT title, narrative FROM observations WHERE memory_session_id = ?').get('memory-readable') as { title: string; narrative: string };
|
||||
expect(observation.title).toBe('Legacy observation');
|
||||
expect(observation.narrative).toBe('Still queryable');
|
||||
|
||||
const memoryItems = db.prepare('SELECT COUNT(*) as count FROM memory_items').get() as { count: number };
|
||||
expect(memoryItems.count).toBe(0);
|
||||
});
|
||||
|
||||
it('should tighten legacy pending_messages status checks from old migration 28 databases', () => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
version INTEGER UNIQUE NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE sdk_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_session_id TEXT UNIQUE NOT NULL,
|
||||
memory_session_id TEXT UNIQUE,
|
||||
project TEXT NOT NULL,
|
||||
platform_source TEXT NOT NULL DEFAULT 'claude',
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
completed_at TEXT,
|
||||
completed_at_epoch INTEGER,
|
||||
status TEXT CHECK(status IN ('active', 'completed', 'failed')) NOT NULL DEFAULT 'active'
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE pending_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_db_id INTEGER NOT NULL,
|
||||
content_session_id TEXT NOT NULL,
|
||||
tool_use_id TEXT,
|
||||
message_type TEXT NOT NULL CHECK(message_type IN ('observation', 'summarize')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'processed', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
completed_at_epoch INTEGER,
|
||||
worker_pid INTEGER
|
||||
)
|
||||
`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(28, now);
|
||||
const sessionId = Number(db.prepare(`
|
||||
INSERT INTO sdk_sessions (content_session_id, project, started_at, started_at_epoch)
|
||||
VALUES ('legacy-content', 'legacy-project', ?, ?)
|
||||
`).run(now, Date.now()).lastInsertRowid);
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, created_at_epoch)
|
||||
VALUES (?, 'legacy-content', 'observation', 'pending', ?)
|
||||
`).run(sessionId, Date.now());
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, created_at_epoch)
|
||||
VALUES (?, 'legacy-content', 'observation', 'failed', ?)
|
||||
`).run(sessionId, Date.now());
|
||||
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const pendingRows = db.prepare('SELECT COUNT(*) AS count FROM pending_messages').get() as { count: number };
|
||||
expect(pendingRows.count).toBe(1);
|
||||
const columns = getColumns(db, 'pending_messages').map(column => column.name);
|
||||
expect(columns).not.toContain('retry_count');
|
||||
expect(columns).not.toContain('completed_at_epoch');
|
||||
expect(columns).not.toContain('worker_pid');
|
||||
|
||||
expect(() => db.prepare(`
|
||||
INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, created_at_epoch)
|
||||
VALUES (?, 'legacy-content', 'observation', 'failed', ?)
|
||||
`).run(sessionId, Date.now())).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ describe('Stale AbortController Guard (#1099)', () => {
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
earliestPendingTimestamp: null,
|
||||
claimedMessageIds: [],
|
||||
conversationHistory: [],
|
||||
currentProvider: null,
|
||||
consecutiveRestarts: 0,
|
||||
processingMessageIds: [],
|
||||
lastGeneratorActivity: Date.now()
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
|
||||
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
|
||||
import type { DatabaseManager } from '../../../src/services/worker/DatabaseManager.js';
|
||||
import { SessionManager } from '../../../src/services/worker/SessionManager.js';
|
||||
|
||||
describe('SessionManager queue integration', () => {
|
||||
let db: Database;
|
||||
let store: SessionStore;
|
||||
let manager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
store = new SessionStore(db);
|
||||
|
||||
const dbManager = {
|
||||
getSessionStore: () => store,
|
||||
getSessionById: (sessionDbId: number) => {
|
||||
const session = store.getSessionById(sessionDbId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionDbId} not found`);
|
||||
}
|
||||
return session;
|
||||
},
|
||||
} as unknown as DatabaseManager;
|
||||
|
||||
manager = new SessionManager(dbManager);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await manager.shutdownAll();
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('confirmClaimedMessages only deletes claimed rows and preserves newly queued work', async () => {
|
||||
const sessionDbId = store.createSDKSession(
|
||||
'content-ack-invariant',
|
||||
'test-project',
|
||||
'Test prompt'
|
||||
);
|
||||
manager.initializeSession(sessionDbId);
|
||||
|
||||
await manager.queueObservation(sessionDbId, {
|
||||
tool_name: 'FirstTool',
|
||||
tool_input: { step: 1 },
|
||||
tool_response: { ok: true },
|
||||
prompt_number: 1,
|
||||
toolUseId: 'tool-a',
|
||||
});
|
||||
|
||||
const iterator = manager.getMessageIterator(sessionDbId);
|
||||
const first = await iterator.next();
|
||||
expect(first.done).toBe(false);
|
||||
expect(first.value?._persistentId).toBeGreaterThan(0);
|
||||
|
||||
await manager.queueObservation(sessionDbId, {
|
||||
tool_name: 'SecondTool',
|
||||
tool_input: { step: 2 },
|
||||
tool_response: { ok: true },
|
||||
prompt_number: 1,
|
||||
toolUseId: 'tool-b',
|
||||
});
|
||||
|
||||
expect(await manager.confirmClaimedMessages(sessionDbId)).toBe(1);
|
||||
await iterator.return?.();
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT tool_use_id, status
|
||||
FROM pending_messages
|
||||
WHERE session_db_id = ?
|
||||
ORDER BY id ASC
|
||||
`).all(sessionDbId) as Array<{ tool_use_id: string; status: string }>;
|
||||
|
||||
expect(rows).toEqual([{ tool_use_id: 'tool-b', status: 'pending' }]);
|
||||
expect(await manager.getTotalQueueDepth()).toBe(1);
|
||||
});
|
||||
|
||||
test('initializeQueueEngine does not require the database before sqlite mode is used', async () => {
|
||||
const previous = process.env.CLAUDE_MEM_QUEUE_ENGINE;
|
||||
process.env.CLAUDE_MEM_QUEUE_ENGINE = 'sqlite';
|
||||
try {
|
||||
const earlyManager = new SessionManager({
|
||||
getSessionStore: () => {
|
||||
throw new Error('Database not initialized');
|
||||
},
|
||||
} as unknown as DatabaseManager);
|
||||
|
||||
await expect(earlyManager.initializeQueueEngine()).resolves.toBeUndefined();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.CLAUDE_MEM_QUEUE_ENGINE;
|
||||
} else {
|
||||
process.env.CLAUDE_MEM_QUEUE_ENGINE = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -189,7 +189,7 @@ describe('Transactions Module', () => {
|
||||
|
||||
describe('storeObservationsAndMarkComplete', () => {
|
||||
|
||||
it('should store observations, summary, and mark message complete', () => {
|
||||
it('should store observations, summary, and remove completed queue message', () => {
|
||||
const { memorySessionId, sessionDbId } = createSessionWithMemoryId('content-complete', 'complete-session');
|
||||
const project = 'test-project';
|
||||
const observations = [createObservationInput({ title: 'Complete Obs' })];
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import pg from 'pg';
|
||||
import {
|
||||
SERVER_BETA_POSTGRES_TABLES,
|
||||
bootstrapServerBetaPostgresSchema,
|
||||
buildObservationGenerationKey,
|
||||
createPostgresStorageRepositories,
|
||||
type PostgresPoolClient,
|
||||
type PostgresStorageRepositories
|
||||
} from '../../../src/storage/postgres/index.js';
|
||||
|
||||
const testDatabaseUrl = process.env.CLAUDE_MEM_TEST_POSTGRES_URL;
|
||||
|
||||
describe('server beta postgres schema bootstrap', () => {
|
||||
it('acquires and releases a client when bootstrapping from a pool', async () => {
|
||||
const queries: string[] = [];
|
||||
let released = false;
|
||||
const pool = {
|
||||
totalCount: 0,
|
||||
idleCount: 0,
|
||||
waitingCount: 0,
|
||||
async connect() {
|
||||
return {
|
||||
release(): void {
|
||||
released = true;
|
||||
},
|
||||
async query(text: string) {
|
||||
queries.push(text);
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
};
|
||||
},
|
||||
async query(): Promise<never> {
|
||||
throw new Error('pool query should not be used for schema bootstrap');
|
||||
}
|
||||
};
|
||||
|
||||
await bootstrapServerBetaPostgresSchema(pool);
|
||||
|
||||
expect(queries[0]).toBe('BEGIN');
|
||||
expect(queries.at(-1)).toBe('COMMIT');
|
||||
expect(released).toBe(true);
|
||||
});
|
||||
|
||||
it('uses an already-connected pool client without reconnecting it', async () => {
|
||||
const queries: string[] = [];
|
||||
const client = {
|
||||
async connect(): Promise<never> {
|
||||
throw new Error('client should not reconnect');
|
||||
},
|
||||
release(): void {},
|
||||
async query(text: string) {
|
||||
queries.push(text);
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
} as unknown as PostgresPoolClient;
|
||||
|
||||
await bootstrapServerBetaPostgresSchema(client);
|
||||
|
||||
expect(queries[0]).toBe('BEGIN');
|
||||
expect(queries.at(-1)).toBe('COMMIT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('server beta postgres observation storage', () => {
|
||||
if (!testDatabaseUrl) {
|
||||
it.skip('requires explicit CLAUDE_MEM_TEST_POSTGRES_URL for Postgres integration tests', () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = new pg.Pool({ connectionString: testDatabaseUrl });
|
||||
let client: PostgresPoolClient;
|
||||
let schemaName: string;
|
||||
let storage: PostgresStorageRepositories;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = await pool.connect();
|
||||
schemaName = `cm_pg_test_${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);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`);
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
it('creates the Phase 1 schema idempotently', async () => {
|
||||
await bootstrapServerBetaPostgresSchema(client);
|
||||
|
||||
const result = await client.query<{ table_name: string }>(
|
||||
`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = $1
|
||||
`,
|
||||
[schemaName]
|
||||
);
|
||||
const tables = new Set(result.rows.map(row => row.table_name));
|
||||
|
||||
for (const table of SERVER_BETA_POSTGRES_TABLES) {
|
||||
expect(tables.has(table)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces project/team ownership for project-scoped writes', async () => {
|
||||
const teamA = await storage.teams.create({ name: 'Team A' });
|
||||
const teamB = await storage.teams.create({ name: 'Team B' });
|
||||
const projectA = await storage.projects.create({ teamId: teamA.id, name: 'Project A' });
|
||||
|
||||
await expect(storage.projects.create({ teamId: 'missing-team', name: 'Invalid' })).rejects.toThrow();
|
||||
await expect(storage.sessions.create({
|
||||
projectId: projectA.id,
|
||||
teamId: teamB.id
|
||||
})).rejects.toThrow(/project_id must belong to team_id/);
|
||||
});
|
||||
|
||||
it('deduplicates agent events with deterministic idempotency keys when source event IDs are omitted', async () => {
|
||||
const { project, session } = await createFixtureScope(storage);
|
||||
const occurredAt = new Date('2026-05-07T20:00:00.000Z');
|
||||
const payload = { message: 'same payload', nested: { b: 2, a: 1 } };
|
||||
|
||||
const first = await storage.agentEvents.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
serverSessionId: session.id,
|
||||
sourceAdapter: 'claude-code',
|
||||
eventType: 'user_prompt',
|
||||
payload,
|
||||
occurredAt
|
||||
});
|
||||
const second = await storage.agentEvents.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
serverSessionId: session.id,
|
||||
sourceAdapter: 'claude-code',
|
||||
eventType: 'user_prompt',
|
||||
payload: { nested: { a: 1, b: 2 }, message: 'same payload' },
|
||||
occurredAt
|
||||
});
|
||||
const withNativeId = await storage.agentEvents.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceAdapter: 'cursor',
|
||||
sourceEventId: 'event-1',
|
||||
eventType: 'tool_call',
|
||||
payload: { one: true },
|
||||
occurredAt
|
||||
});
|
||||
const duplicateNativeId = await storage.agentEvents.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceAdapter: 'cursor',
|
||||
sourceEventId: 'event-1',
|
||||
eventType: 'tool_call',
|
||||
payload: { two: true },
|
||||
occurredAt
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.idempotencyKey).toBe(first.idempotencyKey);
|
||||
expect(duplicateNativeId.id).toBe(withNativeId.id);
|
||||
});
|
||||
|
||||
it('creates observations, searches content, links sources, and preserves generation retry idempotency', async () => {
|
||||
const { project, session, event, eventJob } = await createFixtureScopeWithEventJob(storage);
|
||||
const generationKey = buildObservationGenerationKey({
|
||||
generationJobId: eventJob.id,
|
||||
parsedObservationIndex: 0,
|
||||
content: 'Postgres is the canonical observation store'
|
||||
});
|
||||
|
||||
const observation = await storage.observations.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
serverSessionId: session.id,
|
||||
content: 'Postgres is the canonical observation store',
|
||||
generationKey,
|
||||
createdByJobId: eventJob.id,
|
||||
metadata: { generated: true }
|
||||
});
|
||||
const retry = await storage.observations.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
serverSessionId: session.id,
|
||||
content: 'Postgres is the canonical observation store',
|
||||
generationKey,
|
||||
createdByJobId: eventJob.id
|
||||
});
|
||||
const source = await storage.observationSources.addSource({
|
||||
observationId: observation.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: event.id,
|
||||
generationJobId: eventJob.id
|
||||
});
|
||||
const duplicateSource = await storage.observationSources.addSource({
|
||||
observationId: observation.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: event.id,
|
||||
generationJobId: eventJob.id
|
||||
});
|
||||
const search = await storage.observations.search({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
query: 'canonical observation'
|
||||
});
|
||||
|
||||
expect(retry.id).toBe(observation.id);
|
||||
expect(source.id).toBe(duplicateSource.id);
|
||||
expect(search.map(item => item.id)).toContain(observation.id);
|
||||
await expect(storage.observationSources.listByObservationForScope({
|
||||
observationId: observation.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
})).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('scopes observation generation_key idempotency to project and team', async () => {
|
||||
const firstScope = await createFixtureScope(storage);
|
||||
const secondScope = await createFixtureScope(storage);
|
||||
const generationKey = 'shared-generation-key';
|
||||
|
||||
const first = await storage.observations.create({
|
||||
projectId: firstScope.project.id,
|
||||
teamId: firstScope.project.teamId,
|
||||
content: 'First scoped generation key observation',
|
||||
generationKey
|
||||
});
|
||||
const retry = await storage.observations.create({
|
||||
projectId: firstScope.project.id,
|
||||
teamId: firstScope.project.teamId,
|
||||
content: 'First scoped generation key observation retry',
|
||||
generationKey
|
||||
});
|
||||
const second = await storage.observations.create({
|
||||
projectId: secondScope.project.id,
|
||||
teamId: secondScope.project.teamId,
|
||||
content: 'Second scoped generation key observation',
|
||||
generationKey
|
||||
});
|
||||
|
||||
expect(retry.id).toBe(first.id);
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.projectId).toBe(secondScope.project.id);
|
||||
expect(second.teamId).toBe(secondScope.project.teamId);
|
||||
});
|
||||
|
||||
it('scopes observation source reads to the observation project and team', async () => {
|
||||
const { project, event, eventJob } = await createFixtureScopeWithEventJob(storage);
|
||||
const other = await createFixtureScope(storage);
|
||||
const observation = await storage.observations.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
content: 'Scoped observation source reader'
|
||||
});
|
||||
|
||||
await storage.observationSources.addSource({
|
||||
observationId: observation.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: event.id,
|
||||
generationJobId: eventJob.id
|
||||
});
|
||||
|
||||
await expect(storage.observationSources.listByObservationForScope({
|
||||
observationId: observation.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
})).resolves.toHaveLength(1);
|
||||
await expect(storage.observationSources.listByObservationForScope({
|
||||
observationId: observation.id,
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId
|
||||
})).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mutate scoped observation source, job transition, or job event writes with the wrong scope', async () => {
|
||||
const { project, event, eventJob } = await createFixtureScopeWithEventJob(storage);
|
||||
const other = await createFixtureScope(storage);
|
||||
const observation = await storage.observations.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
content: 'Wrong-scope mutation guard'
|
||||
});
|
||||
|
||||
await expect(storage.observationSources.addSource({
|
||||
observationId: observation.id,
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: event.id,
|
||||
generationJobId: eventJob.id
|
||||
})).rejects.toThrow(/observation_id/);
|
||||
await expect(storage.observationGenerationJobs.transitionStatus({
|
||||
id: eventJob.id,
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId,
|
||||
status: 'processing',
|
||||
lockedBy: 'wrong-scope-worker'
|
||||
})).resolves.toBeNull();
|
||||
await expect(storage.observationGenerationJobEvents.append({
|
||||
generationJobId: eventJob.id,
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId,
|
||||
eventType: 'processing',
|
||||
statusAfter: 'processing'
|
||||
})).rejects.toThrow(/generation_job_id must belong/);
|
||||
|
||||
await expect(storage.observationSources.listByObservationForScope({
|
||||
observationId: observation.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
})).resolves.toEqual([]);
|
||||
await expect(storage.observationGenerationJobs.getByIdForScope({
|
||||
id: eventJob.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
})).resolves.toMatchObject({ status: 'queued', attempts: 0, lockedBy: null });
|
||||
await expect(storage.observationGenerationJobEvents.listByJobForScope({
|
||||
generationJobId: eventJob.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
})).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('deduplicates sessions by deterministic identity when external session IDs are omitted', async () => {
|
||||
const { project } = await createFixtureScope(storage);
|
||||
|
||||
const first = await storage.sessions.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
contentSessionId: 'content-session-1',
|
||||
agentId: 'agent-1',
|
||||
platformSource: 'claude-code',
|
||||
metadata: { first: true }
|
||||
});
|
||||
const second = await storage.sessions.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
contentSessionId: 'content-session-1',
|
||||
agentId: 'agent-1',
|
||||
platformSource: 'claude-code',
|
||||
metadata: { second: true }
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.idempotencyKey).toBe(first.idempotencyKey);
|
||||
expect(second.idempotencyKey).not.toBeNull();
|
||||
});
|
||||
|
||||
it('exposes scoped getters for auth-visible project resources', async () => {
|
||||
const { project, session, event, eventJob } = await createFixtureScopeWithEventJob(storage);
|
||||
const other = await createFixtureScope(storage);
|
||||
const observation = await storage.observations.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
serverSessionId: session.id,
|
||||
content: 'Scoped getter observation',
|
||||
createdByJobId: eventJob.id
|
||||
});
|
||||
|
||||
await expect(storage.projects.getByIdForTeam(project.id, project.teamId)).resolves.toMatchObject({ id: project.id });
|
||||
await expect(storage.sessions.getByIdForScope({
|
||||
id: session.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
})).resolves.toMatchObject({ id: session.id });
|
||||
await expect(storage.agentEvents.getByIdForScope({
|
||||
id: event.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
})).resolves.toMatchObject({ id: event.id });
|
||||
await expect(storage.observationGenerationJobs.getByIdForScope({
|
||||
id: eventJob.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
})).resolves.toMatchObject({ id: eventJob.id });
|
||||
await expect(storage.observations.getByIdForScope({
|
||||
id: observation.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
})).resolves.toMatchObject({ id: observation.id });
|
||||
|
||||
await expect(storage.projects.getByIdForTeam(project.id, other.project.teamId)).resolves.toBeNull();
|
||||
await expect(storage.sessions.getByIdForScope({
|
||||
id: session.id,
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId
|
||||
})).resolves.toBeNull();
|
||||
await expect(storage.agentEvents.getByIdForScope({
|
||||
id: event.id,
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId
|
||||
})).resolves.toBeNull();
|
||||
await expect(storage.observationGenerationJobs.getByIdForScope({
|
||||
id: eventJob.id,
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId
|
||||
})).resolves.toBeNull();
|
||||
await expect(storage.observations.getByIdForScope({
|
||||
id: observation.id,
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('does not expose unscoped auth-visible getters on exported repositories', async () => {
|
||||
for (const repository of [
|
||||
storage.projects,
|
||||
storage.sessions,
|
||||
storage.agentEvents,
|
||||
storage.observationGenerationJobs,
|
||||
storage.observations,
|
||||
storage.observationSources
|
||||
]) {
|
||||
const exposed = repository as unknown as Record<string, unknown>;
|
||||
expect(exposed.getById).toBeUndefined();
|
||||
expect(exposed[['getById', 'Internal'].join('')]).toBeUndefined();
|
||||
expect(exposed[['listBy', 'Status'].join('')]).toBeUndefined();
|
||||
expect(exposed[['listBy', 'Job'].join('')]).toBeUndefined();
|
||||
expect(exposed[['listBy', 'Observation'].join('')]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('scopes team lookup by membership', async () => {
|
||||
const team = await storage.teams.create({ name: 'Scoped Team' });
|
||||
await storage.teams.addMember({ teamId: team.id, userId: 'member-1', role: 'viewer' });
|
||||
|
||||
await expect(storage.teams.getByIdForUser({
|
||||
id: team.id,
|
||||
userId: 'member-1'
|
||||
})).resolves.toMatchObject({ id: team.id });
|
||||
await expect(storage.teams.getByIdForUser({
|
||||
id: team.id,
|
||||
userId: 'outsider'
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects illegal generation job lifecycle transitions and max-attempt retries', async () => {
|
||||
const { project, event } = await createFixtureScopeWithEventJob(storage);
|
||||
const job = await storage.observationGenerationJobs.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: event.id,
|
||||
agentEventId: event.id,
|
||||
jobType: 'single_attempt_generate',
|
||||
maxAttempts: 1
|
||||
});
|
||||
|
||||
const processing = await storage.observationGenerationJobs.transitionStatus({
|
||||
id: job.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
status: 'processing',
|
||||
lockedBy: 'worker-1'
|
||||
});
|
||||
await expect(storage.observationGenerationJobs.transitionStatus({
|
||||
id: job.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
status: 'queued',
|
||||
nextAttemptAt: new Date('2026-05-07T22:00:00.000Z')
|
||||
})).rejects.toThrow(/max_attempts/);
|
||||
const failed = await storage.observationGenerationJobs.transitionStatus({
|
||||
id: job.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
status: 'failed',
|
||||
lastError: { message: 'attempt failed' }
|
||||
});
|
||||
|
||||
expect(processing?.attempts).toBe(1);
|
||||
expect(failed?.failedAtEpoch).not.toBeNull();
|
||||
expect(failed?.completedAtEpoch).toBeNull();
|
||||
expect(failed?.cancelledAtEpoch).toBeNull();
|
||||
await expect(storage.observationGenerationJobs.transitionStatus({
|
||||
id: job.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
status: 'processing',
|
||||
lockedBy: 'worker-2'
|
||||
})).rejects.toThrow(/terminal status failed/);
|
||||
});
|
||||
|
||||
it('allows only one worker to transition a queued generation job to processing', async () => {
|
||||
const { eventJob } = await createFixtureScopeWithEventJob(storage);
|
||||
let workerA: PostgresPoolClient | null = null;
|
||||
let workerB: PostgresPoolClient | null = null;
|
||||
|
||||
try {
|
||||
workerA = await pool.connect();
|
||||
workerB = await pool.connect();
|
||||
await workerA.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
|
||||
await workerB.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
|
||||
const workerAStorage = createPostgresStorageRepositories(workerA);
|
||||
const workerBStorage = createPostgresStorageRepositories(workerB);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
workerAStorage.observationGenerationJobs.transitionStatus({
|
||||
id: eventJob.id,
|
||||
projectId: eventJob.projectId,
|
||||
teamId: eventJob.teamId,
|
||||
status: 'processing',
|
||||
lockedBy: 'worker-a'
|
||||
}),
|
||||
workerBStorage.observationGenerationJobs.transitionStatus({
|
||||
id: eventJob.id,
|
||||
projectId: eventJob.projectId,
|
||||
teamId: eventJob.teamId,
|
||||
status: 'processing',
|
||||
lockedBy: 'worker-b'
|
||||
})
|
||||
]);
|
||||
const fulfilled = results.filter(result => result.status === 'fulfilled');
|
||||
const rejected = results.filter(result => result.status === 'rejected');
|
||||
const claimed = await storage.observationGenerationJobs.getByIdForScope({
|
||||
id: eventJob.id,
|
||||
projectId: eventJob.projectId,
|
||||
teamId: eventJob.teamId
|
||||
});
|
||||
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect(claimed?.status).toBe('processing');
|
||||
expect(claimed?.attempts).toBe(1);
|
||||
} finally {
|
||||
workerA?.release();
|
||||
workerB?.release();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates server session ownership when creating event generation jobs', async () => {
|
||||
const scope = await createFixtureScopeWithEventJob(storage);
|
||||
const other = await createFixtureScope(storage);
|
||||
const siblingSession = await storage.sessions.create({
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.team.id,
|
||||
externalSessionId: crypto.randomUUID()
|
||||
});
|
||||
|
||||
await expect(storage.observationGenerationJobs.create({
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.team.id,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: scope.event.id,
|
||||
agentEventId: scope.event.id,
|
||||
serverSessionId: other.session.id,
|
||||
jobType: 'invalid_cross_scope_session'
|
||||
})).rejects.toThrow(/server_session_id must belong/);
|
||||
await expect(storage.observationGenerationJobs.create({
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.team.id,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: scope.event.id,
|
||||
agentEventId: scope.event.id,
|
||||
serverSessionId: siblingSession.id,
|
||||
jobType: 'invalid_event_session'
|
||||
})).rejects.toThrow(/server_session_id must match/);
|
||||
});
|
||||
|
||||
it('requires linked generation jobs to match observation source models', async () => {
|
||||
const { project, event, eventJob } = await createFixtureScopeWithEventJob(storage);
|
||||
const secondEvent = await storage.agentEvents.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceAdapter: 'claude-code',
|
||||
sourceEventId: crypto.randomUUID(),
|
||||
eventType: 'assistant_response',
|
||||
payload: { content: 'second response' },
|
||||
occurredAt: new Date('2026-05-07T21:30:00.000Z')
|
||||
});
|
||||
const secondJob = await storage.observationGenerationJobs.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: secondEvent.id,
|
||||
agentEventId: secondEvent.id,
|
||||
jobType: 'generate_observations'
|
||||
});
|
||||
const observation = await storage.observations.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
content: 'Observation source model validation'
|
||||
});
|
||||
|
||||
await expect(storage.observationSources.addSource({
|
||||
observationId: observation.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: event.id,
|
||||
generationJobId: secondJob.id
|
||||
})).rejects.toThrow(/source model/);
|
||||
await expect(storage.observationSources.addSource({
|
||||
observationId: observation.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: event.id,
|
||||
agentEventId: secondEvent.id,
|
||||
generationJobId: eventJob.id
|
||||
})).rejects.toThrow(/source_id must equal agent_event_id/);
|
||||
});
|
||||
|
||||
it('validates non-agent observation sources that are not linked through generation jobs', async () => {
|
||||
const scope = await createFixtureScope(storage);
|
||||
const other = await createFixtureScope(storage);
|
||||
const targetObservation = await storage.observations.create({
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.project.teamId,
|
||||
content: 'Target observation for non-agent source validation'
|
||||
});
|
||||
const sourceObservation = await storage.observations.create({
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.project.teamId,
|
||||
content: 'Source observation for reindex validation'
|
||||
});
|
||||
const otherObservation = await storage.observations.create({
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId,
|
||||
content: 'Cross-scope source observation'
|
||||
});
|
||||
|
||||
await expect(storage.observationSources.addSource({
|
||||
observationId: targetObservation.id,
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.project.teamId,
|
||||
sourceType: 'session_summary',
|
||||
sourceId: scope.session.id
|
||||
})).resolves.toMatchObject({ sourceType: 'session_summary', sourceId: scope.session.id });
|
||||
await expect(storage.observationSources.addSource({
|
||||
observationId: targetObservation.id,
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.project.teamId,
|
||||
sourceType: 'observation_reindex',
|
||||
sourceId: sourceObservation.id
|
||||
})).resolves.toMatchObject({ sourceType: 'observation_reindex', sourceId: sourceObservation.id });
|
||||
await expect(storage.observationSources.addSource({
|
||||
observationId: targetObservation.id,
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.project.teamId,
|
||||
sourceType: 'session_summary',
|
||||
sourceId: other.session.id
|
||||
})).rejects.toThrow(/server_session_id must belong/);
|
||||
await expect(storage.observationSources.addSource({
|
||||
observationId: targetObservation.id,
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.project.teamId,
|
||||
sourceType: 'observation_reindex',
|
||||
sourceId: otherObservation.id
|
||||
})).rejects.toThrow(/observation_reindex source_id must belong/);
|
||||
});
|
||||
|
||||
it('scopes generation job source uniqueness to project and team', async () => {
|
||||
const firstScope = await createFixtureScope(storage);
|
||||
const secondScope = await createFixtureScope(storage);
|
||||
const sharedSourceId = 'shared-source-id';
|
||||
const jobType = 'shared_source_generate';
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO observation_generation_jobs (
|
||||
id, project_id, team_id, source_type, source_id, job_type, status, idempotency_key
|
||||
)
|
||||
VALUES ($1, $2, $3, 'observation_reindex', $4, $5, 'queued', $6)
|
||||
`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
firstScope.project.id,
|
||||
firstScope.project.teamId,
|
||||
sharedSourceId,
|
||||
jobType,
|
||||
'first-scope-source-key'
|
||||
]
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO observation_generation_jobs (
|
||||
id, project_id, team_id, source_type, source_id, job_type, status, idempotency_key
|
||||
)
|
||||
VALUES ($1, $2, $3, 'observation_reindex', $4, $5, 'queued', $6)
|
||||
`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
secondScope.project.id,
|
||||
secondScope.project.teamId,
|
||||
sharedSourceId,
|
||||
jobType,
|
||||
'second-scope-source-key'
|
||||
]
|
||||
);
|
||||
await expect(client.query(
|
||||
`
|
||||
INSERT INTO observation_generation_jobs (
|
||||
id, project_id, team_id, source_type, source_id, job_type, status, idempotency_key
|
||||
)
|
||||
VALUES ($1, $2, $3, 'observation_reindex', $4, $5, 'queued', $6)
|
||||
`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
firstScope.project.id,
|
||||
firstScope.project.teamId,
|
||||
sharedSourceId,
|
||||
jobType,
|
||||
'duplicate-first-scope-source-key'
|
||||
]
|
||||
)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('deduplicates generation jobs by source model and records lifecycle events', async () => {
|
||||
const { project, session, event, eventJob } = await createFixtureScopeWithEventJob(storage);
|
||||
const other = await createFixtureScope(storage);
|
||||
const duplicateEventJob = await storage.observationGenerationJobs.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: event.id,
|
||||
agentEventId: event.id,
|
||||
jobType: 'generate_observations'
|
||||
});
|
||||
|
||||
const summaryJob = await storage.observationGenerationJobs.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'session_summary',
|
||||
sourceId: session.id,
|
||||
serverSessionId: session.id,
|
||||
jobType: 'generate_session_summary'
|
||||
});
|
||||
const observation = await storage.observations.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
content: 'Reindexable observation'
|
||||
});
|
||||
const reindexJob = await storage.observationGenerationJobs.create({
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
sourceType: 'observation_reindex',
|
||||
sourceId: observation.id,
|
||||
jobType: 'reindex_observation'
|
||||
});
|
||||
const processing = await storage.observationGenerationJobs.transitionStatus({
|
||||
id: eventJob.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
status: 'processing',
|
||||
lockedBy: 'worker-1'
|
||||
});
|
||||
await storage.observationGenerationJobEvents.append({
|
||||
generationJobId: eventJob.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
eventType: 'queued',
|
||||
statusAfter: 'queued'
|
||||
});
|
||||
await storage.observationGenerationJobEvents.append({
|
||||
generationJobId: eventJob.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
eventType: 'processing',
|
||||
statusAfter: 'processing',
|
||||
attempt: processing?.attempts ?? 1
|
||||
});
|
||||
|
||||
const scopedQueuedJobs = await storage.observationGenerationJobs.listByStatusForScope({
|
||||
status: 'queued',
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
});
|
||||
const wrongScopeQueuedJobs = await storage.observationGenerationJobs.listByStatusForScope({
|
||||
status: 'queued',
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId
|
||||
});
|
||||
const lifecycle = await storage.observationGenerationJobEvents.listByJobForScope({
|
||||
generationJobId: eventJob.id,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId
|
||||
});
|
||||
const wrongScopeLifecycle = await storage.observationGenerationJobEvents.listByJobForScope({
|
||||
generationJobId: eventJob.id,
|
||||
projectId: other.project.id,
|
||||
teamId: other.project.teamId
|
||||
});
|
||||
|
||||
expect(duplicateEventJob.id).toBe(eventJob.id);
|
||||
expect(summaryJob.sourceType).toBe('session_summary');
|
||||
expect(summaryJob.agentEventId).toBeNull();
|
||||
expect(summaryJob.serverSessionId).toBe(session.id);
|
||||
expect(reindexJob.sourceType).toBe('observation_reindex');
|
||||
expect(reindexJob.agentEventId).toBeNull();
|
||||
expect(processing?.attempts).toBe(1);
|
||||
expect(scopedQueuedJobs.map(job => job.id).sort()).toEqual([summaryJob.id, reindexJob.id].sort());
|
||||
expect(wrongScopeQueuedJobs).toEqual([]);
|
||||
expect(lifecycle.map(eventRecord => eventRecord.eventType)).toEqual(['queued', 'processing']);
|
||||
expect(wrongScopeLifecycle).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function createFixtureScope(storage: PostgresStorageRepositories) {
|
||||
const team = await storage.teams.create({ name: 'Core' });
|
||||
const project = await storage.projects.create({ teamId: team.id, name: 'Claude Mem' });
|
||||
const session = await storage.sessions.create({
|
||||
projectId: project.id,
|
||||
teamId: team.id,
|
||||
externalSessionId: crypto.randomUUID(),
|
||||
platformSource: 'claude-code'
|
||||
});
|
||||
|
||||
return { team, project, session };
|
||||
}
|
||||
|
||||
async function createFixtureScopeWithEventJob(storage: PostgresStorageRepositories) {
|
||||
const scope = await createFixtureScope(storage);
|
||||
const event = await storage.agentEvents.create({
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.team.id,
|
||||
serverSessionId: scope.session.id,
|
||||
sourceAdapter: 'claude-code',
|
||||
sourceEventId: crypto.randomUUID(),
|
||||
eventType: 'assistant_response',
|
||||
payload: { content: 'response' },
|
||||
occurredAt: new Date('2026-05-07T21:00:00.000Z')
|
||||
});
|
||||
const eventJob = await storage.observationGenerationJobs.create({
|
||||
projectId: scope.project.id,
|
||||
teamId: scope.team.id,
|
||||
sourceType: 'agent_event',
|
||||
sourceId: event.id,
|
||||
agentEventId: event.id,
|
||||
serverSessionId: scope.session.id,
|
||||
jobType: 'generate_observations'
|
||||
});
|
||||
|
||||
return { ...scope, event, eventJob };
|
||||
}
|
||||
|
||||
function quoteIdentifier(identifier: string): string {
|
||||
return `"${identifier.replaceAll('"', '""')}"`;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import {
|
||||
AgentEventsRepository,
|
||||
AuthRepository,
|
||||
MemoryItemsRepository,
|
||||
ProjectsRepository,
|
||||
SERVER_OWNED_TABLES,
|
||||
ServerSessionsRepository,
|
||||
TeamsRepository,
|
||||
ensureServerStorageSchema
|
||||
} from '../../../src/storage/sqlite/index.js';
|
||||
import { parseJsonArray, parseJsonObject } from '../../../src/storage/sqlite/serde.js';
|
||||
|
||||
interface TableNameRow {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function withDb(fn: (db: Database) => void): void {
|
||||
const db = new Database(':memory:');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
try {
|
||||
fn(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('server-owned sqlite storage boundary', () => {
|
||||
it('creates every server-owned table idempotently', () => {
|
||||
withDb(db => {
|
||||
ensureServerStorageSchema(db);
|
||||
ensureServerStorageSchema(db);
|
||||
|
||||
const rows = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as TableNameRow[];
|
||||
const tables = rows.map(row => row.name);
|
||||
|
||||
for (const table of SERVER_OWNED_TABLES) {
|
||||
expect(tables).toContain(table);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips repository records using JSON-as-TEXT fields', () => {
|
||||
withDb(db => {
|
||||
const projects = new ProjectsRepository(db);
|
||||
const sessions = new ServerSessionsRepository(db);
|
||||
const events = new AgentEventsRepository(db);
|
||||
const memories = new MemoryItemsRepository(db);
|
||||
const teams = new TeamsRepository(db);
|
||||
const auth = new AuthRepository(db);
|
||||
|
||||
const project = projects.create({
|
||||
name: 'Claude Mem',
|
||||
rootPath: '/tmp/claude-mem',
|
||||
metadata: { source: 'test' }
|
||||
});
|
||||
const session = sessions.create({
|
||||
projectId: project.id,
|
||||
memorySessionId: 'memory-1'
|
||||
});
|
||||
const event = events.create({
|
||||
projectId: project.id,
|
||||
serverSessionId: session.id,
|
||||
sourceType: 'hook',
|
||||
eventType: 'observation.created',
|
||||
payload: { type: 'learned' },
|
||||
occurredAtEpoch: Date.now()
|
||||
});
|
||||
const memory = memories.create({
|
||||
projectId: project.id,
|
||||
serverSessionId: session.id,
|
||||
legacyObservationId: 42,
|
||||
kind: 'observation',
|
||||
type: 'learned',
|
||||
title: 'Storage boundary',
|
||||
facts: ['JSON text is decoded'],
|
||||
metadata: { legacyTable: 'observations' }
|
||||
});
|
||||
const source = memories.addSource({
|
||||
memoryItemId: memory.id,
|
||||
sourceType: 'observation',
|
||||
legacyTable: 'observations',
|
||||
legacyId: 42
|
||||
});
|
||||
const team = teams.create({ name: 'Core' });
|
||||
const member = teams.addMember({ teamId: team.id, userId: 'user-1', role: 'owner' });
|
||||
const key = auth.createApiKey({
|
||||
teamId: team.id,
|
||||
projectId: project.id,
|
||||
name: 'placeholder',
|
||||
keyHash: 'hash-1',
|
||||
scopes: ['memory:read']
|
||||
});
|
||||
const audit = auth.createAuditLog({
|
||||
teamId: team.id,
|
||||
projectId: project.id,
|
||||
actorType: 'api_key',
|
||||
actorId: key.id,
|
||||
action: 'memory.read'
|
||||
});
|
||||
|
||||
expect(project.metadata.source).toBe('test');
|
||||
expect(session.memorySessionId).toBe('memory-1');
|
||||
expect(event.payload).toEqual({ type: 'learned' });
|
||||
expect(memory.facts).toEqual(['JSON text is decoded']);
|
||||
expect(source.legacyTable).toBe('observations');
|
||||
expect(member.role).toBe('owner');
|
||||
expect(key.scopes).toEqual(['memory:read']);
|
||||
expect(audit.action).toBe('memory.read');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not require legacy worker tables to use server-owned repositories', () => {
|
||||
withDb(db => {
|
||||
const projects = new ProjectsRepository(db);
|
||||
const project = projects.create({ name: 'Server only' });
|
||||
|
||||
expect(project.name).toBe('Server only');
|
||||
expect(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations'").get()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents duplicate legacy observation backfill rows', () => {
|
||||
withDb(db => {
|
||||
const projects = new ProjectsRepository(db);
|
||||
const memories = new MemoryItemsRepository(db);
|
||||
const project = projects.create({ name: 'Legacy Backfill' });
|
||||
|
||||
const first = memories.create({
|
||||
projectId: project.id,
|
||||
legacyObservationId: 42,
|
||||
kind: 'observation',
|
||||
type: 'learned',
|
||||
});
|
||||
|
||||
expect(first.legacyObservationId).toBe(42);
|
||||
expect(() => memories.create({
|
||||
projectId: project.id,
|
||||
legacyObservationId: 42,
|
||||
kind: 'observation',
|
||||
type: 'learned',
|
||||
})).toThrow();
|
||||
|
||||
memories.addSource({
|
||||
memoryItemId: first.id,
|
||||
sourceType: 'observation',
|
||||
legacyTable: 'observations',
|
||||
legacyId: 42,
|
||||
});
|
||||
|
||||
expect(() => memories.addSource({
|
||||
memoryItemId: first.id,
|
||||
sourceType: 'observation',
|
||||
legacyTable: 'observations',
|
||||
legacyId: 42,
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects server-session links across project boundaries', () => {
|
||||
withDb(db => {
|
||||
const projects = new ProjectsRepository(db);
|
||||
const sessions = new ServerSessionsRepository(db);
|
||||
const events = new AgentEventsRepository(db);
|
||||
const memories = new MemoryItemsRepository(db);
|
||||
|
||||
const projectA = projects.create({ name: 'Project A' });
|
||||
const projectB = projects.create({ name: 'Project B' });
|
||||
const sessionA = sessions.create({ projectId: projectA.id });
|
||||
|
||||
expect(() => events.create({
|
||||
projectId: projectB.id,
|
||||
serverSessionId: sessionA.id,
|
||||
sourceType: 'hook',
|
||||
eventType: 'observation.created',
|
||||
occurredAtEpoch: Date.now(),
|
||||
})).toThrow(/server_session_id must belong to project_id/);
|
||||
|
||||
expect(() => memories.create({
|
||||
projectId: projectB.id,
|
||||
serverSessionId: sessionA.id,
|
||||
kind: 'manual',
|
||||
type: 'note',
|
||||
})).toThrow(/server_session_id must belong to project_id/);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects moving a server session across projects after child records exist', () => {
|
||||
withDb(db => {
|
||||
const projects = new ProjectsRepository(db);
|
||||
const sessions = new ServerSessionsRepository(db);
|
||||
const events = new AgentEventsRepository(db);
|
||||
const memories = new MemoryItemsRepository(db);
|
||||
|
||||
const projectA = projects.create({ name: 'Project A' });
|
||||
const projectB = projects.create({ name: 'Project B' });
|
||||
const sessionA = sessions.create({ projectId: projectA.id });
|
||||
events.create({
|
||||
projectId: projectA.id,
|
||||
serverSessionId: sessionA.id,
|
||||
sourceType: 'hook',
|
||||
eventType: 'observation.created',
|
||||
occurredAtEpoch: Date.now(),
|
||||
});
|
||||
memories.create({
|
||||
projectId: projectA.id,
|
||||
serverSessionId: sessionA.id,
|
||||
kind: 'manual',
|
||||
type: 'note',
|
||||
});
|
||||
|
||||
expect(() => db.prepare('UPDATE server_sessions SET project_id = ? WHERE id = ?').run(projectB.id, sessionA.id))
|
||||
.toThrow(/project_id cannot change/);
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades malformed JSON fields to empty values', () => {
|
||||
expect(parseJsonObject('{not-json')).toEqual({});
|
||||
expect(parseJsonArray('{not-json')).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats FTS5 operator words as literal search terms', () => {
|
||||
withDb(db => {
|
||||
const projects = new ProjectsRepository(db);
|
||||
const memories = new MemoryItemsRepository(db);
|
||||
const project = projects.create({ name: 'Search operators' });
|
||||
const memory = memories.create({
|
||||
projectId: project.id,
|
||||
kind: 'manual',
|
||||
type: 'note',
|
||||
text: 'OR NOT AND are literal notes from a shell transcript',
|
||||
});
|
||||
|
||||
expect(memories.search(project.id, 'OR').map(item => item.id)).toContain(memory.id);
|
||||
expect(memories.search(project.id, 'AND shell').map(item => item.id)).toContain(memory.id);
|
||||
expect(memories.search(project.id, 'server-beta')).toEqual([]);
|
||||
expect(memories.search(project.id, 'foo OR')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('splits punctuation the same way as the FTS tokenizer', () => {
|
||||
withDb(db => {
|
||||
const projects = new ProjectsRepository(db);
|
||||
const memories = new MemoryItemsRepository(db);
|
||||
const project = projects.create({ name: 'Search punctuation' });
|
||||
const memory = memories.create({
|
||||
projectId: project.id,
|
||||
kind: 'manual',
|
||||
type: 'note',
|
||||
facts: ['run:1778147273-16934'],
|
||||
concepts: ['server-beta'],
|
||||
});
|
||||
|
||||
expect(memories.search(project.id, '1778147273-16934').map(item => item.id)).toContain(memory.id);
|
||||
expect(memories.search(project.id, 'server-beta').map(item => item.id)).toContain(memory.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,8 @@ describe('ResponseProcessor', () => {
|
||||
cleanupProcessed: mock(() => 0),
|
||||
resetStuckMessages: mock(() => 0),
|
||||
}),
|
||||
clearPendingForSession: mock(() => {}),
|
||||
confirmClaimedMessages: mock(() => Promise.resolve(0)),
|
||||
resetProcessingToPending: mock(() => Promise.resolve(0)),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
mockBroadcast = mock(() => {});
|
||||
@@ -120,9 +121,9 @@ describe('ResponseProcessor', () => {
|
||||
cumulativeInputTokens: 100,
|
||||
cumulativeOutputTokens: 50,
|
||||
earliestPendingTimestamp: Date.now() - 10000,
|
||||
claimedMessageIds: [],
|
||||
conversationHistory: [],
|
||||
currentProvider: 'claude',
|
||||
processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
...overrides,
|
||||
} as ActiveSession;
|
||||
}
|
||||
@@ -207,11 +208,11 @@ describe('ResponseProcessor', () => {
|
||||
|
||||
describe('non-XML observer responses', () => {
|
||||
it('warns and clears pending work when the observer returns non-XML prose', async () => {
|
||||
const clearPendingForSession = mock(() => {});
|
||||
const confirmClaimedMessages = mock(() => Promise.resolve(0));
|
||||
mockSessionManager = {
|
||||
getMessageIterator: async function* () { yield* []; },
|
||||
getPendingMessageStore: () => ({ confirmProcessed: mock(() => {}) }),
|
||||
clearPendingForSession,
|
||||
confirmClaimedMessages,
|
||||
} as unknown as SessionManager;
|
||||
|
||||
const session = createMockSession();
|
||||
@@ -233,7 +234,7 @@ describe('ResponseProcessor', () => {
|
||||
expect.stringMatching(/^TestAgent returned non-XML\/empty response/),
|
||||
expect.objectContaining({ sessionId: 1 })
|
||||
);
|
||||
expect(clearPendingForSession).toHaveBeenCalledWith(1);
|
||||
expect(confirmClaimedMessages).toHaveBeenCalledWith(1);
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
expect(mockStoreObservations).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -458,11 +459,11 @@ describe('ResponseProcessor', () => {
|
||||
|
||||
describe('handling empty / non-XML response', () => {
|
||||
it('clears pending work and does NOT call storeObservations on empty response', async () => {
|
||||
const clearPendingForSession = mock(() => {});
|
||||
const confirmClaimedMessages = mock(() => Promise.resolve(0));
|
||||
mockSessionManager = {
|
||||
getMessageIterator: async function* () { yield* []; },
|
||||
getPendingMessageStore: () => ({ confirmProcessed: mock(() => {}) }),
|
||||
clearPendingForSession,
|
||||
confirmClaimedMessages,
|
||||
} as unknown as SessionManager;
|
||||
|
||||
const session = createMockSession();
|
||||
@@ -474,16 +475,16 @@ describe('ResponseProcessor', () => {
|
||||
);
|
||||
|
||||
expect(mockStoreObservations).not.toHaveBeenCalled();
|
||||
expect(clearPendingForSession).toHaveBeenCalledWith(1);
|
||||
expect(confirmClaimedMessages).toHaveBeenCalledWith(1);
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('clears pending work and does NOT call storeObservations on plain-text response', async () => {
|
||||
const clearPendingForSession = mock(() => {});
|
||||
const confirmClaimedMessages = mock(() => Promise.resolve(0));
|
||||
mockSessionManager = {
|
||||
getMessageIterator: async function* () { yield* []; },
|
||||
getPendingMessageStore: () => ({ confirmProcessed: mock(() => {}) }),
|
||||
clearPendingForSession,
|
||||
confirmClaimedMessages,
|
||||
} as unknown as SessionManager;
|
||||
|
||||
const session = createMockSession();
|
||||
@@ -495,7 +496,7 @@ describe('ResponseProcessor', () => {
|
||||
);
|
||||
|
||||
expect(mockStoreObservations).not.toHaveBeenCalled();
|
||||
expect(clearPendingForSession).toHaveBeenCalledWith(1);
|
||||
expect(confirmClaimedMessages).toHaveBeenCalledWith(1);
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -625,7 +626,12 @@ describe('ResponseProcessor', () => {
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error if memorySessionId is missing from session', async () => {
|
||||
it('should reset processing work if memorySessionId is missing from session', async () => {
|
||||
const resetProcessingToPending = mock(() => Promise.resolve(1));
|
||||
mockSessionManager = {
|
||||
getMessageIterator: async function* () { yield* []; },
|
||||
resetProcessingToPending,
|
||||
} as unknown as SessionManager;
|
||||
const session = createMockSession({
|
||||
memorySessionId: null, // Missing memory session ID
|
||||
});
|
||||
@@ -635,18 +641,19 @@ describe('ResponseProcessor', () => {
|
||||
<narrative>some narrative</narrative>
|
||||
</observation>`;
|
||||
|
||||
await expect(
|
||||
processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
)
|
||||
).rejects.toThrow('Cannot store observations: memorySessionId not yet captured');
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
expect(resetProcessingToPending).toHaveBeenCalledWith(1);
|
||||
expect(mockStoreObservations).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ describe('SessionCleanupHelper', () => {
|
||||
cumulativeInputTokens: 100,
|
||||
cumulativeOutputTokens: 50,
|
||||
earliestPendingTimestamp: Date.now() - 10000, // 10 seconds ago
|
||||
claimedMessageIds: [],
|
||||
conversationHistory: [],
|
||||
currentProvider: 'claude',
|
||||
processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import http from 'http';
|
||||
import { createMiddleware } from '../../../src/services/worker/http/middleware.js';
|
||||
|
||||
function isAllowedOrigin(origin: string | undefined): boolean {
|
||||
if (!origin) return true;
|
||||
@@ -11,23 +11,6 @@ function isAllowedOrigin(origin: string | undefined): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildProductionCorsMiddleware() {
|
||||
return cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin ||
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:')) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('CORS not allowed'));
|
||||
}
|
||||
},
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
credentials: false
|
||||
});
|
||||
}
|
||||
|
||||
describe('CORS Restriction', () => {
|
||||
describe('allowed origins', () => {
|
||||
it('allows requests without Origin header (hooks, curl, CLI)', () => {
|
||||
@@ -78,8 +61,7 @@ describe('CORS Restriction', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildProductionCorsMiddleware());
|
||||
createMiddleware(() => '').forEach(middleware => app.use(middleware));
|
||||
|
||||
app.all('/api/settings', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
@@ -108,7 +90,7 @@ describe('CORS Restriction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect([200, 204]).toContain(response.status);
|
||||
const allowedMethods = response.headers.get('access-control-allow-methods');
|
||||
expect(allowedMethods).toContain('PUT');
|
||||
});
|
||||
@@ -122,7 +104,7 @@ describe('CORS Restriction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect([200, 204]).toContain(response.status);
|
||||
const allowedMethods = response.headers.get('access-control-allow-methods');
|
||||
expect(allowedMethods).toContain('PATCH');
|
||||
});
|
||||
@@ -136,7 +118,7 @@ describe('CORS Restriction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect([200, 204]).toContain(response.status);
|
||||
const allowedMethods = response.headers.get('access-control-allow-methods');
|
||||
expect(allowedMethods).toContain('DELETE');
|
||||
});
|
||||
@@ -151,11 +133,26 @@ describe('CORS Restriction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect([200, 204]).toContain(response.status);
|
||||
const allowedHeaders = response.headers.get('access-control-allow-headers');
|
||||
expect(allowedHeaders).toContain('Content-Type');
|
||||
});
|
||||
|
||||
it('preflight response includes Authorization in allowed headers', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Origin': 'http://localhost:37777',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'Authorization',
|
||||
},
|
||||
});
|
||||
|
||||
expect([200, 204]).toContain(response.status);
|
||||
const allowedHeaders = response.headers.get('access-control-allow-headers');
|
||||
expect(allowedHeaders).toContain('Authorization');
|
||||
});
|
||||
|
||||
it('preflight from localhost includes allow-origin header', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
|
||||
method: 'OPTIONS',
|
||||
@@ -166,7 +163,7 @@ describe('CORS Restriction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect([200, 204]).toContain(response.status);
|
||||
const origin = response.headers.get('access-control-allow-origin');
|
||||
expect(origin).toBe('http://localhost:37777');
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('Zombie Agent Prevention', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
pendingStore = new PendingMessageStore(db, 3);
|
||||
pendingStore = new PendingMessageStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,9 +37,9 @@ describe('Zombie Agent Prevention', () => {
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
earliestPendingTimestamp: null,
|
||||
claimedMessageIds: [],
|
||||
conversationHistory: [],
|
||||
currentProvider: null,
|
||||
processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -198,22 +198,18 @@ describe('Zombie Agent Prevention', () => {
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
test('should recover stuck processing messages via claimNextMessage self-healing', async () => {
|
||||
const sessionId = createDbSession('content-stuck-recovery');
|
||||
test('should recover processing messages through explicit restart reset', async () => {
|
||||
const sessionId = createDbSession('content-restart-reset');
|
||||
|
||||
const msgId = enqueueTestMessage(sessionId, 'content-stuck-recovery');
|
||||
const msgId = enqueueTestMessage(sessionId, 'content-restart-reset');
|
||||
const claimed = pendingStore.claimNextMessage(sessionId);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(msgId);
|
||||
|
||||
const staleTimestamp = Date.now() - 120_000;
|
||||
db.run(
|
||||
`UPDATE pending_messages SET started_processing_at_epoch = ? WHERE id = ?`,
|
||||
[staleTimestamp, msgId]
|
||||
);
|
||||
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(1);
|
||||
expect(pendingStore.claimNextMessage(sessionId)).toBeNull();
|
||||
|
||||
expect(pendingStore.resetProcessingToPending(sessionId)).toBe(1);
|
||||
const recovered = pendingStore.claimNextMessage(sessionId);
|
||||
expect(recovered).not.toBeNull();
|
||||
expect(recovered!.id).toBe(msgId);
|
||||
@@ -248,7 +244,7 @@ describe('Zombie Agent Prevention', () => {
|
||||
|
||||
describe('Session Termination Invariant', () => {
|
||||
|
||||
test('should mark messages abandoned when session is terminated', () => {
|
||||
test('should clear messages when session is terminated', () => {
|
||||
const sessionId = createDbSession('content-terminate-1');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-1');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-1');
|
||||
@@ -256,8 +252,8 @@ describe('Zombie Agent Prevention', () => {
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
const abandoned = pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sessionId });
|
||||
expect(abandoned).toBe(2);
|
||||
const cleared = pendingStore.clearPendingForSession(sessionId);
|
||||
expect(cleared).toBe(2);
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
@@ -268,20 +264,20 @@ describe('Zombie Agent Prevention', () => {
|
||||
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
|
||||
const abandoned = pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sessionId });
|
||||
expect(abandoned).toBe(0);
|
||||
const cleared = pendingStore.clearPendingForSession(sessionId);
|
||||
expect(cleared).toBe(0);
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should be idempotent — double terminate marks zero on second call', () => {
|
||||
test('should be idempotent — double terminate clears zero on second call', () => {
|
||||
const sessionId = createDbSession('content-terminate-idempotent');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-idempotent');
|
||||
|
||||
const first = pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sessionId });
|
||||
const first = pendingStore.clearPendingForSession(sessionId);
|
||||
expect(first).toBe(1);
|
||||
|
||||
const second = pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sessionId });
|
||||
const second = pendingStore.clearPendingForSession(sessionId);
|
||||
expect(second).toBe(0);
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
@@ -313,9 +309,9 @@ describe('Zombie Agent Prevention', () => {
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sid1 });
|
||||
pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sid2 });
|
||||
pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sid3 });
|
||||
pendingStore.clearPendingForSession(sid1);
|
||||
pendingStore.clearPendingForSession(sid2);
|
||||
pendingStore.clearPendingForSession(sid3);
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
@@ -327,14 +323,14 @@ describe('Zombie Agent Prevention', () => {
|
||||
enqueueTestMessage(sid1, 'content-isolate-1');
|
||||
enqueueTestMessage(sid2, 'content-isolate-2');
|
||||
|
||||
pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sid1 });
|
||||
pendingStore.clearPendingForSession(sid1);
|
||||
|
||||
expect(pendingStore.getPendingCount(sid1)).toBe(0);
|
||||
expect(pendingStore.getPendingCount(sid2)).toBe(1);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
});
|
||||
|
||||
test('should mark both pending and processing messages as abandoned', () => {
|
||||
test('should clear both pending and processing messages', () => {
|
||||
const sessionId = createDbSession('content-mixed-status');
|
||||
|
||||
const msgId1 = enqueueTestMessage(sessionId, 'content-mixed-status');
|
||||
@@ -346,8 +342,8 @@ describe('Zombie Agent Prevention', () => {
|
||||
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
|
||||
const abandoned = pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sessionId });
|
||||
expect(abandoned).toBe(2);
|
||||
const cleared = pendingStore.clearPendingForSession(sessionId);
|
||||
expect(cleared).toBe(2);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -362,7 +358,7 @@ describe('Zombie Agent Prevention', () => {
|
||||
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
|
||||
|
||||
pendingStore.transitionMessagesTo('abandoned', { sessionDbId: sessionId });
|
||||
pendingStore.clearPendingForSession(sessionId);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user