feat(queue): stage queue migration package

This commit is contained in:
2026-03-06 13:18:22 -06:00
parent 5103406c93
commit 727b3defc9
21 changed files with 2835 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
import { describe, expect, it, vi } from 'vitest';
import { runQueueCli, type QueueCliDependencies, type QueueRepository } from '../src/cli.js';
function createRepositoryMock(): QueueRepository {
return {
create: vi.fn(() =>
Promise.resolve({
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({
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({
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({
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',
});
});
});

View 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();
});
});

View 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({});
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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);
});
});