feat(wave1): @mosaic/types populated + @mosaic/queue migrated to use it (#1)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #1.
This commit is contained in:
219
packages/queue/tests/cli.test.ts
Normal file
219
packages/queue/tests/cli.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { runQueueCli, type QueueCliDependencies, type QueueRepository } from '../src/cli.js';
|
||||
import type { Task } from '@mosaic/types';
|
||||
|
||||
function createRepositoryMock(): QueueRepository {
|
||||
return {
|
||||
create: vi.fn(() =>
|
||||
Promise.resolve<Task>({
|
||||
id: 'MQ-005',
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
lane: 'any',
|
||||
retryCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}),
|
||||
),
|
||||
list: vi.fn(() => Promise.resolve([])),
|
||||
get: vi.fn(() => Promise.resolve(null)),
|
||||
claim: vi.fn(() =>
|
||||
Promise.resolve<Task>({
|
||||
id: 'MQ-005',
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
status: 'claimed',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
lane: 'any',
|
||||
claimedBy: 'agent-a',
|
||||
claimedAt: 2,
|
||||
claimTTL: 60,
|
||||
retryCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
}),
|
||||
),
|
||||
release: vi.fn(() =>
|
||||
Promise.resolve<Task>({
|
||||
id: 'MQ-005',
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
lane: 'any',
|
||||
retryCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 3,
|
||||
}),
|
||||
),
|
||||
complete: vi.fn(() =>
|
||||
Promise.resolve<Task>({
|
||||
id: 'MQ-005',
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
status: 'completed',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
lane: 'any',
|
||||
completionSummary: 'done',
|
||||
retryCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
completedAt: 4,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createDependencies(
|
||||
repository: QueueRepository,
|
||||
): QueueCliDependencies & { outputs: string[]; errors: string[] } {
|
||||
const outputs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const close = vi.fn(() => Promise.resolve(undefined));
|
||||
|
||||
return {
|
||||
openSession: () =>
|
||||
Promise.resolve({
|
||||
repository,
|
||||
close,
|
||||
}),
|
||||
stdout: (line) => {
|
||||
outputs.push(line);
|
||||
},
|
||||
stderr: (line) => {
|
||||
errors.push(line);
|
||||
},
|
||||
outputs,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runQueueCli', () => {
|
||||
it('creates a task from command options', async () => {
|
||||
const repository = createRepositoryMock();
|
||||
const dependencies = createDependencies(repository);
|
||||
|
||||
const exitCode = await runQueueCli(
|
||||
[
|
||||
'node',
|
||||
'mosaic',
|
||||
'queue',
|
||||
'create',
|
||||
'queue',
|
||||
'phase1',
|
||||
'MQ-005',
|
||||
'--title',
|
||||
'Build CLI',
|
||||
'--priority',
|
||||
'high',
|
||||
'--lane',
|
||||
'coding',
|
||||
'--dependency',
|
||||
'MQ-002',
|
||||
'MQ-003',
|
||||
],
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(repository.create).toHaveBeenCalledWith({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
description: undefined,
|
||||
priority: 'high',
|
||||
dependencies: ['MQ-002', 'MQ-003'],
|
||||
lane: 'coding',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists tasks with filters', async () => {
|
||||
const repository = createRepositoryMock();
|
||||
const dependencies = createDependencies(repository);
|
||||
|
||||
const exitCode = await runQueueCli(
|
||||
[
|
||||
'node',
|
||||
'mosaic',
|
||||
'queue',
|
||||
'list',
|
||||
'--project',
|
||||
'queue',
|
||||
'--mission',
|
||||
'phase1',
|
||||
'--status',
|
||||
'pending',
|
||||
],
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(repository.list).toHaveBeenCalledWith({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
status: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('claims and completes tasks with typed options', async () => {
|
||||
const repository = createRepositoryMock();
|
||||
const dependencies = createDependencies(repository);
|
||||
|
||||
const claimExitCode = await runQueueCli(
|
||||
[
|
||||
'node',
|
||||
'mosaic',
|
||||
'queue',
|
||||
'claim',
|
||||
'MQ-005',
|
||||
'--agent',
|
||||
'agent-a',
|
||||
'--ttl',
|
||||
'60',
|
||||
],
|
||||
dependencies,
|
||||
);
|
||||
|
||||
const completeExitCode = await runQueueCli(
|
||||
[
|
||||
'node',
|
||||
'mosaic',
|
||||
'queue',
|
||||
'complete',
|
||||
'MQ-005',
|
||||
'--agent',
|
||||
'agent-a',
|
||||
'--summary',
|
||||
'done',
|
||||
],
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(claimExitCode).toBe(0);
|
||||
expect(completeExitCode).toBe(0);
|
||||
expect(repository.claim).toHaveBeenCalledWith('MQ-005', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
expect(repository.complete).toHaveBeenCalledWith('MQ-005', {
|
||||
agentId: 'agent-a',
|
||||
summary: 'done',
|
||||
});
|
||||
});
|
||||
});
|
||||
50
packages/queue/tests/mcp-server.test.ts
Normal file
50
packages/queue/tests/mcp-server.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
QUEUE_MCP_TOOL_DEFINITIONS,
|
||||
buildQueueMcpServer,
|
||||
} from '../src/mcp-server.js';
|
||||
|
||||
describe('queue MCP server', () => {
|
||||
it('declares all required phase-1 tools', () => {
|
||||
const toolNames = QUEUE_MCP_TOOL_DEFINITIONS.map((tool) => tool.name).sort();
|
||||
|
||||
expect(toolNames).toEqual([
|
||||
'queue_claim',
|
||||
'queue_complete',
|
||||
'queue_fail',
|
||||
'queue_get',
|
||||
'queue_heartbeat',
|
||||
'queue_list',
|
||||
'queue_release',
|
||||
'queue_status',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds an MCP server instance', () => {
|
||||
const server = buildQueueMcpServer({
|
||||
openSession: () =>
|
||||
Promise.resolve({
|
||||
repository: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(null),
|
||||
claim: () => Promise.reject(new Error('not implemented')),
|
||||
heartbeat: () => Promise.reject(new Error('not implemented')),
|
||||
release: () => Promise.reject(new Error('not implemented')),
|
||||
complete: () => Promise.reject(new Error('not implemented')),
|
||||
fail: () => Promise.reject(new Error('not implemented')),
|
||||
},
|
||||
checkHealth: () =>
|
||||
Promise.resolve({
|
||||
checkedAt: 1,
|
||||
latencyMs: 0,
|
||||
ok: true,
|
||||
response: 'PONG',
|
||||
}),
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
});
|
||||
90
packages/queue/tests/mcp-tool-schemas.test.ts
Normal file
90
packages/queue/tests/mcp-tool-schemas.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
queueClaimToolInputSchema,
|
||||
queueCompleteToolInputSchema,
|
||||
queueFailToolInputSchema,
|
||||
queueGetToolInputSchema,
|
||||
queueHeartbeatToolInputSchema,
|
||||
queueListToolInputSchema,
|
||||
queueReleaseToolInputSchema,
|
||||
queueStatusToolInputSchema,
|
||||
} from '../src/mcp-tool-schemas.js';
|
||||
|
||||
describe('MCP tool schemas', () => {
|
||||
it('validates queue_list filters', () => {
|
||||
const parsed = queueListToolInputSchema.parse({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
status: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires a taskId for queue_get', () => {
|
||||
expect(() => queueGetToolInputSchema.parse({})).toThrowError();
|
||||
});
|
||||
|
||||
it('requires positive ttlSeconds for queue_claim', () => {
|
||||
expect(() =>
|
||||
queueClaimToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 0,
|
||||
}),
|
||||
).toThrowError();
|
||||
});
|
||||
|
||||
it('accepts optional fields for queue_heartbeat and queue_release', () => {
|
||||
const heartbeat = queueHeartbeatToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
ttlSeconds: 30,
|
||||
});
|
||||
|
||||
const release = queueReleaseToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
});
|
||||
|
||||
expect(heartbeat).toEqual({
|
||||
taskId: 'MQ-007',
|
||||
ttlSeconds: 30,
|
||||
});
|
||||
expect(release).toEqual({
|
||||
taskId: 'MQ-007',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates queue_complete and queue_fail payloads', () => {
|
||||
const complete = queueCompleteToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
agentId: 'agent-a',
|
||||
summary: 'done',
|
||||
});
|
||||
|
||||
const fail = queueFailToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
reason: 'boom',
|
||||
});
|
||||
|
||||
expect(complete).toEqual({
|
||||
taskId: 'MQ-007',
|
||||
agentId: 'agent-a',
|
||||
summary: 'done',
|
||||
});
|
||||
expect(fail).toEqual({
|
||||
taskId: 'MQ-007',
|
||||
reason: 'boom',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts an empty payload for queue_status', () => {
|
||||
const parsed = queueStatusToolInputSchema.parse({});
|
||||
|
||||
expect(parsed).toEqual({});
|
||||
});
|
||||
});
|
||||
76
packages/queue/tests/redis-connection.test.ts
Normal file
76
packages/queue/tests/redis-connection.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createRedisClient,
|
||||
resolveRedisUrl,
|
||||
runRedisHealthCheck,
|
||||
} from '../src/redis-connection.js';
|
||||
|
||||
describe('resolveRedisUrl', () => {
|
||||
it('prefers VALKEY_URL when both env vars are present', () => {
|
||||
const url = resolveRedisUrl({
|
||||
VALKEY_URL: 'redis://valkey.local:6379',
|
||||
REDIS_URL: 'redis://redis.local:6379',
|
||||
});
|
||||
|
||||
expect(url).toBe('redis://valkey.local:6379');
|
||||
});
|
||||
|
||||
it('falls back to REDIS_URL when VALKEY_URL is missing', () => {
|
||||
const url = resolveRedisUrl({
|
||||
REDIS_URL: 'redis://redis.local:6379',
|
||||
});
|
||||
|
||||
expect(url).toBe('redis://redis.local:6379');
|
||||
});
|
||||
|
||||
it('throws loudly when no redis environment variable exists', () => {
|
||||
expect(() => resolveRedisUrl({})).toThrowError(
|
||||
/Missing required Valkey\/Redis connection URL/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRedisClient', () => {
|
||||
it('uses env URL for client creation with no hardcoded defaults', () => {
|
||||
class FakeRedis {
|
||||
public readonly url: string;
|
||||
|
||||
public constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
const client = createRedisClient({
|
||||
env: {
|
||||
VALKEY_URL: 'redis://queue.local:6379',
|
||||
},
|
||||
redisConstructor: FakeRedis,
|
||||
});
|
||||
|
||||
expect(client.url).toBe('redis://queue.local:6379');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runRedisHealthCheck', () => {
|
||||
it('returns healthy status when ping succeeds', async () => {
|
||||
const health = await runRedisHealthCheck({
|
||||
ping: () => Promise.resolve('PONG'),
|
||||
});
|
||||
|
||||
expect(health.ok).toBe(true);
|
||||
expect(health.response).toBe('PONG');
|
||||
expect(health.latencyMs).toBeTypeOf('number');
|
||||
expect(health.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('returns unhealthy status when ping fails', async () => {
|
||||
const health = await runRedisHealthCheck({
|
||||
ping: () => Promise.reject(new Error('connection refused')),
|
||||
});
|
||||
|
||||
expect(health.ok).toBe(false);
|
||||
expect(health.error).toMatch(/connection refused/i);
|
||||
expect(health.latencyMs).toBeTypeOf('number');
|
||||
});
|
||||
});
|
||||
9
packages/queue/tests/smoke.test.ts
Normal file
9
packages/queue/tests/smoke.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { packageVersion } from '../src/index.js';
|
||||
|
||||
describe('package bootstrap', () => {
|
||||
it('exposes package version constant', () => {
|
||||
expect(packageVersion).toBe('0.1.0');
|
||||
});
|
||||
});
|
||||
459
packages/queue/tests/task-atomic.test.ts
Normal file
459
packages/queue/tests/task-atomic.test.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RedisTaskRepository,
|
||||
TaskAlreadyExistsError,
|
||||
TaskOwnershipError,
|
||||
TaskTransitionError,
|
||||
type RedisTaskClient,
|
||||
type RedisTaskTransaction,
|
||||
} from '../src/task-repository.js';
|
||||
|
||||
type QueuedOperation =
|
||||
| {
|
||||
readonly type: 'set';
|
||||
readonly key: string;
|
||||
readonly value: string;
|
||||
readonly mode?: 'NX' | 'XX';
|
||||
}
|
||||
| {
|
||||
readonly type: 'sadd';
|
||||
readonly key: string;
|
||||
readonly member: string;
|
||||
};
|
||||
|
||||
class InMemoryRedisBackend {
|
||||
public readonly kv = new Map<string, string>();
|
||||
public readonly sets = new Map<string, Set<string>>();
|
||||
public readonly revisions = new Map<string, number>();
|
||||
|
||||
public getRevision(key: string): number {
|
||||
return this.revisions.get(key) ?? 0;
|
||||
}
|
||||
|
||||
public bumpRevision(key: string): void {
|
||||
this.revisions.set(key, this.getRevision(key) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryRedisTransaction implements RedisTaskTransaction {
|
||||
private readonly operations: QueuedOperation[] = [];
|
||||
|
||||
public constructor(
|
||||
private readonly backend: InMemoryRedisBackend,
|
||||
private readonly watchedRevisions: ReadonlyMap<string, number>,
|
||||
) {}
|
||||
|
||||
public set(key: string, value: string, mode?: 'NX' | 'XX'): RedisTaskTransaction {
|
||||
this.operations.push({
|
||||
type: 'set',
|
||||
key,
|
||||
value,
|
||||
mode,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public sadd(key: string, member: string): RedisTaskTransaction {
|
||||
this.operations.push({
|
||||
type: 'sadd',
|
||||
key,
|
||||
member,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public exec(): Promise<readonly (readonly [Error | null, unknown])[] | null> {
|
||||
for (const [key, revision] of this.watchedRevisions.entries()) {
|
||||
if (this.backend.getRevision(key) !== revision) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
const results: (readonly [Error | null, unknown])[] = [];
|
||||
|
||||
for (const operation of this.operations) {
|
||||
if (operation.type === 'set') {
|
||||
const exists = this.backend.kv.has(operation.key);
|
||||
if (operation.mode === 'NX' && exists) {
|
||||
results.push([null, null]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (operation.mode === 'XX' && !exists) {
|
||||
results.push([null, null]);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.backend.kv.set(operation.key, operation.value);
|
||||
this.backend.bumpRevision(operation.key);
|
||||
results.push([null, 'OK']);
|
||||
continue;
|
||||
}
|
||||
|
||||
const set = this.backend.sets.get(operation.key) ?? new Set<string>();
|
||||
const before = set.size;
|
||||
|
||||
set.add(operation.member);
|
||||
this.backend.sets.set(operation.key, set);
|
||||
this.backend.bumpRevision(operation.key);
|
||||
results.push([null, set.size === before ? 0 : 1]);
|
||||
}
|
||||
|
||||
return Promise.resolve(results);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryAtomicRedisClient implements RedisTaskClient {
|
||||
private watchedRevisions = new Map<string, number>();
|
||||
|
||||
public constructor(private readonly backend: InMemoryRedisBackend) {}
|
||||
|
||||
public get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.backend.kv.get(key) ?? null);
|
||||
}
|
||||
|
||||
public mget(...keys: string[]): Promise<(string | null)[]> {
|
||||
return Promise.resolve(keys.map((key) => this.backend.kv.get(key) ?? null));
|
||||
}
|
||||
|
||||
public set(
|
||||
key: string,
|
||||
value: string,
|
||||
mode?: 'NX' | 'XX',
|
||||
): Promise<'OK' | null> {
|
||||
const exists = this.backend.kv.has(key);
|
||||
|
||||
if (mode === 'NX' && exists) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (mode === 'XX' && !exists) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
this.backend.kv.set(key, value);
|
||||
this.backend.bumpRevision(key);
|
||||
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public smembers(key: string): Promise<string[]> {
|
||||
return Promise.resolve([...(this.backend.sets.get(key) ?? new Set<string>())]);
|
||||
}
|
||||
|
||||
public sadd(key: string, member: string): Promise<number> {
|
||||
const values = this.backend.sets.get(key) ?? new Set<string>();
|
||||
const before = values.size;
|
||||
|
||||
values.add(member);
|
||||
this.backend.sets.set(key, values);
|
||||
this.backend.bumpRevision(key);
|
||||
|
||||
return Promise.resolve(values.size === before ? 0 : 1);
|
||||
}
|
||||
|
||||
public watch(...keys: string[]): Promise<'OK'> {
|
||||
this.watchedRevisions = new Map(
|
||||
keys.map((key) => [key, this.backend.getRevision(key)]),
|
||||
);
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public unwatch(): Promise<'OK'> {
|
||||
this.watchedRevisions.clear();
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public multi(): RedisTaskTransaction {
|
||||
const watchedSnapshot = new Map(this.watchedRevisions);
|
||||
this.watchedRevisions.clear();
|
||||
return new InMemoryRedisTransaction(this.backend, watchedSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
class StrictAtomicRedisClient extends InMemoryAtomicRedisClient {
|
||||
public override set(
|
||||
key: string,
|
||||
value: string,
|
||||
mode?: 'NX' | 'XX',
|
||||
): Promise<'OK' | null> {
|
||||
void key;
|
||||
void value;
|
||||
void mode;
|
||||
throw new Error('Direct set() is not allowed in strict atomic tests.');
|
||||
}
|
||||
|
||||
public override sadd(key: string, member: string): Promise<number> {
|
||||
void key;
|
||||
void member;
|
||||
throw new Error('Direct sadd() is not allowed in strict atomic tests.');
|
||||
}
|
||||
}
|
||||
|
||||
function createRepositoryPair(now: () => number): [RedisTaskRepository, RedisTaskRepository] {
|
||||
const backend = new InMemoryRedisBackend();
|
||||
|
||||
return [
|
||||
new RedisTaskRepository({
|
||||
client: new InMemoryAtomicRedisClient(backend),
|
||||
now,
|
||||
}),
|
||||
new RedisTaskRepository({
|
||||
client: new InMemoryAtomicRedisClient(backend),
|
||||
now,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStrictRepositoryPair(
|
||||
now: () => number,
|
||||
): [RedisTaskRepository, RedisTaskRepository] {
|
||||
const backend = new InMemoryRedisBackend();
|
||||
|
||||
return [
|
||||
new RedisTaskRepository({
|
||||
client: new StrictAtomicRedisClient(backend),
|
||||
now,
|
||||
}),
|
||||
new RedisTaskRepository({
|
||||
client: new StrictAtomicRedisClient(backend),
|
||||
now,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
describe('RedisTaskRepository atomic transitions', () => {
|
||||
it('creates atomically under concurrent create race', async () => {
|
||||
const [repositoryA, repositoryB] = createStrictRepositoryPair(
|
||||
() => 1_700_000_000_000,
|
||||
);
|
||||
|
||||
const [createA, createB] = await Promise.allSettled([
|
||||
repositoryA.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-CREATE',
|
||||
title: 'create race',
|
||||
}),
|
||||
repositoryB.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-CREATE',
|
||||
title: 'create race duplicate',
|
||||
}),
|
||||
]);
|
||||
|
||||
const fulfilled = [createA, createB].filter(
|
||||
(result) => result.status === 'fulfilled',
|
||||
);
|
||||
const rejected = [createA, createB].filter(
|
||||
(result) => result.status === 'rejected',
|
||||
);
|
||||
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect(rejected[0]?.reason).toBeInstanceOf(TaskAlreadyExistsError);
|
||||
});
|
||||
|
||||
it('claims a pending task once and blocks concurrent double-claim', async () => {
|
||||
let timestamp = 1_700_000_000_000;
|
||||
const now = (): number => timestamp;
|
||||
const [repositoryA, repositoryB] = createRepositoryPair(now);
|
||||
|
||||
await repositoryA.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004',
|
||||
title: 'Atomic claim',
|
||||
});
|
||||
|
||||
const [claimA, claimB] = await Promise.allSettled([
|
||||
repositoryA.claim('MQ-004', { agentId: 'agent-a', ttlSeconds: 60 }),
|
||||
repositoryB.claim('MQ-004', { agentId: 'agent-b', ttlSeconds: 60 }),
|
||||
]);
|
||||
|
||||
const fulfilled = [claimA, claimB].filter((result) => result.status === 'fulfilled');
|
||||
const rejected = [claimA, claimB].filter((result) => result.status === 'rejected');
|
||||
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows claim takeover after TTL expiry', async () => {
|
||||
let timestamp = 1_700_000_000_000;
|
||||
const now = (): number => timestamp;
|
||||
const [repositoryA, repositoryB] = createRepositoryPair(now);
|
||||
|
||||
await repositoryA.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-EXP',
|
||||
title: 'TTL expiry',
|
||||
});
|
||||
|
||||
await repositoryA.claim('MQ-004-EXP', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 1,
|
||||
});
|
||||
|
||||
timestamp += 2_000;
|
||||
|
||||
const takeover = await repositoryB.claim('MQ-004-EXP', {
|
||||
agentId: 'agent-b',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
expect(takeover.claimedBy).toBe('agent-b');
|
||||
});
|
||||
|
||||
it('releases a claimed task back to pending', async () => {
|
||||
const [repository] = createRepositoryPair(() => 1_700_000_000_000);
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-REL',
|
||||
title: 'Release test',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-004-REL', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
const released = await repository.release('MQ-004-REL', {
|
||||
agentId: 'agent-a',
|
||||
});
|
||||
|
||||
expect(released.status).toBe('pending');
|
||||
expect(released.claimedBy).toBeUndefined();
|
||||
expect(released.claimedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('heartbeats, completes, and fails with valid transitions', async () => {
|
||||
let timestamp = 1_700_000_000_000;
|
||||
const now = (): number => timestamp;
|
||||
const [repository] = createRepositoryPair(now);
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-HCF',
|
||||
title: 'Transition test',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-004-HCF', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
timestamp += 1_000;
|
||||
const heartbeat = await repository.heartbeat('MQ-004-HCF', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 120,
|
||||
});
|
||||
expect(heartbeat.claimTTL).toBe(120);
|
||||
expect(heartbeat.claimedAt).toBe(1_700_000_001_000);
|
||||
|
||||
const completed = await repository.complete('MQ-004-HCF', {
|
||||
agentId: 'agent-a',
|
||||
summary: 'done',
|
||||
});
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.completionSummary).toBe('done');
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-FAIL',
|
||||
title: 'Failure test',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-004-FAIL', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
const failed = await repository.fail('MQ-004-FAIL', {
|
||||
agentId: 'agent-a',
|
||||
reason: 'boom',
|
||||
});
|
||||
|
||||
expect(failed.status).toBe('failed');
|
||||
expect(failed.failureReason).toBe('boom');
|
||||
expect(failed.retryCount).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects invalid transitions', async () => {
|
||||
const [repository] = createRepositoryPair(() => 1_700_000_000_000);
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-INV',
|
||||
title: 'Invalid transitions',
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.complete('MQ-004-INV', {
|
||||
agentId: 'agent-a',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskTransitionError);
|
||||
});
|
||||
|
||||
it('enforces claim ownership for release and complete', async () => {
|
||||
const [repository] = createRepositoryPair(() => 1_700_000_000_000);
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-OWN',
|
||||
title: 'Ownership checks',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-004-OWN', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.release('MQ-004-OWN', {
|
||||
agentId: 'agent-b',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskOwnershipError);
|
||||
|
||||
await expect(
|
||||
repository.complete('MQ-004-OWN', {
|
||||
agentId: 'agent-b',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskOwnershipError);
|
||||
});
|
||||
|
||||
it('merges concurrent non-conflicting update patches atomically', async () => {
|
||||
const [repositoryA, repositoryB] = createRepositoryPair(() => 1_700_000_000_000);
|
||||
|
||||
await repositoryA.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-UPD',
|
||||
title: 'Original title',
|
||||
description: 'Original description',
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
repositoryA.update('MQ-004-UPD', {
|
||||
title: 'Updated title',
|
||||
}),
|
||||
repositoryB.update('MQ-004-UPD', {
|
||||
description: 'Updated description',
|
||||
}),
|
||||
]);
|
||||
|
||||
const latest = await repositoryA.get('MQ-004-UPD');
|
||||
|
||||
expect(latest).not.toBeNull();
|
||||
expect(latest?.title).toBe('Updated title');
|
||||
expect(latest?.description).toBe('Updated description');
|
||||
});
|
||||
});
|
||||
332
packages/queue/tests/task-repository.test.ts
Normal file
332
packages/queue/tests/task-repository.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RedisTaskRepository,
|
||||
TaskAlreadyExistsError,
|
||||
TaskTransitionError,
|
||||
type RedisTaskClient,
|
||||
type RedisTaskTransaction,
|
||||
} from '../src/task-repository.js';
|
||||
|
||||
class NoopRedisTransaction implements RedisTaskTransaction {
|
||||
private readonly operations: (
|
||||
| {
|
||||
readonly type: 'set';
|
||||
readonly key: string;
|
||||
readonly value: string;
|
||||
readonly mode?: 'NX' | 'XX';
|
||||
}
|
||||
| {
|
||||
readonly type: 'sadd';
|
||||
readonly key: string;
|
||||
readonly member: string;
|
||||
}
|
||||
)[] = [];
|
||||
|
||||
public constructor(
|
||||
private readonly kv: Map<string, string>,
|
||||
private readonly sets: Map<string, Set<string>>,
|
||||
) {}
|
||||
|
||||
public set(key: string, value: string, mode?: 'NX' | 'XX'): RedisTaskTransaction {
|
||||
this.operations.push({
|
||||
type: 'set',
|
||||
key,
|
||||
value,
|
||||
mode,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public sadd(key: string, member: string): RedisTaskTransaction {
|
||||
this.operations.push({
|
||||
type: 'sadd',
|
||||
key,
|
||||
member,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public exec(): Promise<readonly (readonly [Error | null, unknown])[] | null> {
|
||||
const results: (readonly [Error | null, unknown])[] = [];
|
||||
|
||||
for (const operation of this.operations) {
|
||||
if (operation.type === 'set') {
|
||||
const exists = this.kv.has(operation.key);
|
||||
|
||||
if (operation.mode === 'NX' && exists) {
|
||||
results.push([null, null]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (operation.mode === 'XX' && !exists) {
|
||||
results.push([null, null]);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.kv.set(operation.key, operation.value);
|
||||
results.push([null, 'OK']);
|
||||
continue;
|
||||
}
|
||||
|
||||
const values = this.sets.get(operation.key) ?? new Set<string>();
|
||||
const beforeSize = values.size;
|
||||
|
||||
values.add(operation.member);
|
||||
this.sets.set(operation.key, values);
|
||||
results.push([null, values.size === beforeSize ? 0 : 1]);
|
||||
}
|
||||
|
||||
return Promise.resolve(results);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryRedisClient implements RedisTaskClient {
|
||||
private readonly kv = new Map<string, string>();
|
||||
private readonly sets = new Map<string, Set<string>>();
|
||||
|
||||
public get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.kv.get(key) ?? null);
|
||||
}
|
||||
|
||||
public mget(...keys: string[]): Promise<(string | null)[]> {
|
||||
return Promise.resolve(keys.map((key) => this.kv.get(key) ?? null));
|
||||
}
|
||||
|
||||
public set(
|
||||
key: string,
|
||||
value: string,
|
||||
mode?: 'NX' | 'XX',
|
||||
): Promise<'OK' | null> {
|
||||
const exists = this.kv.has(key);
|
||||
|
||||
if (mode === 'NX' && exists) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (mode === 'XX' && !exists) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
this.kv.set(key, value);
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public smembers(key: string): Promise<string[]> {
|
||||
return Promise.resolve([...(this.sets.get(key) ?? new Set<string>())]);
|
||||
}
|
||||
|
||||
public sadd(key: string, member: string): Promise<number> {
|
||||
const values = this.sets.get(key) ?? new Set<string>();
|
||||
const beforeSize = values.size;
|
||||
|
||||
values.add(member);
|
||||
this.sets.set(key, values);
|
||||
|
||||
return Promise.resolve(values.size === beforeSize ? 0 : 1);
|
||||
}
|
||||
|
||||
public watch(): Promise<'OK'> {
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public unwatch(): Promise<'OK'> {
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public multi(): RedisTaskTransaction {
|
||||
return new NoopRedisTransaction(this.kv, this.sets);
|
||||
}
|
||||
}
|
||||
|
||||
class MgetTrackingRedisClient extends InMemoryRedisClient {
|
||||
public getCalls = 0;
|
||||
public mgetCalls = 0;
|
||||
public lastMgetKeys: string[] = [];
|
||||
|
||||
public override get(key: string): Promise<string | null> {
|
||||
this.getCalls += 1;
|
||||
return super.get(key);
|
||||
}
|
||||
|
||||
public override mget(...keys: string[]): Promise<(string | null)[]> {
|
||||
this.mgetCalls += 1;
|
||||
this.lastMgetKeys = [...keys];
|
||||
return super.mget(...keys);
|
||||
}
|
||||
}
|
||||
|
||||
describe('RedisTaskRepository CRUD', () => {
|
||||
it('creates and fetches a task with defaults', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const created = await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003',
|
||||
title: 'Implement task CRUD',
|
||||
});
|
||||
|
||||
const fetched = await repository.get('MQ-003');
|
||||
|
||||
expect(created.id).toBe('MQ-003');
|
||||
expect(created.status).toBe('pending');
|
||||
expect(created.priority).toBe('medium');
|
||||
expect(created.lane).toBe('any');
|
||||
expect(created.dependencies).toEqual([]);
|
||||
expect(created.createdAt).toBe(1_700_000_000_000);
|
||||
expect(fetched).toEqual(created);
|
||||
});
|
||||
|
||||
it('throws when creating a duplicate task id', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003',
|
||||
title: 'First task',
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003',
|
||||
title: 'Duplicate',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskAlreadyExistsError);
|
||||
});
|
||||
|
||||
it('lists tasks and filters by project, mission, and status', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003A',
|
||||
title: 'Pending task',
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase2',
|
||||
taskId: 'MQ-003B',
|
||||
title: 'Claimed task',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-003B', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
const byProject = await repository.list({
|
||||
project: 'queue',
|
||||
});
|
||||
const byMission = await repository.list({
|
||||
mission: 'phase2',
|
||||
});
|
||||
const byStatus = await repository.list({
|
||||
status: 'claimed',
|
||||
});
|
||||
|
||||
expect(byProject).toHaveLength(2);
|
||||
expect(byMission.map((task) => task.taskId)).toEqual(['MQ-003B']);
|
||||
expect(byStatus.map((task) => task.taskId)).toEqual(['MQ-003B']);
|
||||
});
|
||||
|
||||
it('lists 3+ tasks with a single mget call', async () => {
|
||||
const client = new MgetTrackingRedisClient();
|
||||
const repository = new RedisTaskRepository({
|
||||
client,
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase-list',
|
||||
taskId: 'MQ-MGET-001',
|
||||
title: 'Task one',
|
||||
});
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase-list',
|
||||
taskId: 'MQ-MGET-002',
|
||||
title: 'Task two',
|
||||
});
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase-list',
|
||||
taskId: 'MQ-MGET-003',
|
||||
title: 'Task three',
|
||||
});
|
||||
|
||||
const tasks = await repository.list();
|
||||
|
||||
expect(tasks).toHaveLength(3);
|
||||
expect(client.mgetCalls).toBe(1);
|
||||
expect(client.getCalls).toBe(0);
|
||||
expect(client.lastMgetKeys).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('updates mutable fields and preserves immutable fields', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
now: () => 1_700_000_000_001,
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003',
|
||||
title: 'Original title',
|
||||
description: 'Original description',
|
||||
});
|
||||
|
||||
const updated = await repository.update('MQ-003', {
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
priority: 'high',
|
||||
lane: 'coding',
|
||||
dependencies: ['MQ-002'],
|
||||
metadata: {
|
||||
source: 'unit-test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(updated.title).toBe('Updated title');
|
||||
expect(updated.description).toBe('Updated description');
|
||||
expect(updated.priority).toBe('high');
|
||||
expect(updated.lane).toBe('coding');
|
||||
expect(updated.dependencies).toEqual(['MQ-002']);
|
||||
expect(updated.metadata).toEqual({ source: 'unit-test' });
|
||||
expect(updated.project).toBe('queue');
|
||||
expect(updated.taskId).toBe('MQ-003');
|
||||
expect(updated.updatedAt).toBe(1_700_000_000_001);
|
||||
});
|
||||
|
||||
it('rejects status transitions through update()', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003-TRANSITION',
|
||||
title: 'Transition guard',
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.update('MQ-003-TRANSITION', {
|
||||
status: 'completed',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskTransitionError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user