Files
mosaic/packages/queue/src/cli.ts
Jason Woltje 23469e7b33 feat(wave1): populate @mosaic/types and migrate @mosaic/queue imports
- @mosaic/types: full type definitions extracted from queue, bootstrap, context packages
- @mosaic/queue: type imports now sourced from @mosaic/types via workspace:*
- Task, TaskStatus, TaskPriority, TaskLane, CreateTaskInput, etc. centralised
- Runtime constants (TASK_STATUSES etc.) remain in queue/src/task.ts
2026-03-06 16:43:44 -06:00

345 lines
9.4 KiB
TypeScript

import {
Command,
CommanderError,
InvalidArgumentError,
Option,
} from 'commander';
import { assertRedisHealthy, createRedisClient } from './redis-connection.js';
import {
RedisTaskRepository,
type ClaimTaskInput,
type CompleteTaskInput,
type RedisTaskClient,
} from './task-repository.js';
import { TASK_LANES, TASK_PRIORITIES, TASK_STATUSES } from './task.js';
import type {
CreateTaskInput,
TaskLane,
TaskListFilters,
TaskPriority,
TaskStatus,
} from '@mosaic/types';
export type QueueRepository = Pick<
RedisTaskRepository,
'create' | 'list' | 'get' | 'claim' | 'release' | 'complete'
>;
export interface QueueRepositorySession {
readonly repository: QueueRepository;
readonly close: () => Promise<void>;
}
export interface QueueCliDependencies {
readonly openSession: () => Promise<QueueRepositorySession>;
readonly stdout: (line: string) => void;
readonly stderr: (line: string) => void;
}
interface CreateCommandOptions {
readonly title: string;
readonly description?: string;
readonly priority?: TaskPriority;
readonly lane?: TaskLane;
readonly dependency?: string[];
}
interface ListCommandOptions {
readonly project?: string;
readonly mission?: string;
readonly status?: TaskStatus;
}
interface ClaimCommandOptions {
readonly agent: string;
readonly ttl: number;
}
interface ReleaseCommandOptions {
readonly agent?: string;
}
interface CompleteCommandOptions {
readonly agent?: string;
readonly summary?: string;
}
interface ClosableRedisTaskClient extends RedisTaskClient {
ping(): Promise<string>;
quit(): Promise<string>;
}
const DEFAULT_DEPENDENCIES: QueueCliDependencies = {
openSession: openRedisSession,
stdout: (line: string) => {
console.log(line);
},
stderr: (line: string) => {
console.error(line);
},
};
const PRIORITY_SET = new Set<TaskPriority>(TASK_PRIORITIES);
const LANE_SET = new Set<TaskLane>(TASK_LANES);
const STATUS_SET = new Set<TaskStatus>(TASK_STATUSES);
export function buildQueueCli(
dependencyOverrides: Partial<QueueCliDependencies> = {},
): Command {
const dependencies = resolveDependencies(dependencyOverrides);
const program = new Command();
program
.name('mosaic')
.description('mosaic queue command line interface')
.exitOverride();
program.configureOutput({
writeOut: (output: string) => dependencies.stdout(output.trimEnd()),
writeErr: (output: string) => dependencies.stderr(output.trimEnd()),
});
const queue = program.command('queue').description('Manage queue tasks');
queue
.command('create <project> <mission> <taskId>')
.description('Create a queue task')
.requiredOption('--title <title>', 'Task title')
.option('--description <description>', 'Task description')
.addOption(
new Option('--priority <priority>', 'Task priority')
.choices(TASK_PRIORITIES)
.argParser(parsePriority),
)
.addOption(
new Option('--lane <lane>', 'Task lane').choices(TASK_LANES).argParser(parseLane),
)
.option('--dependency <taskIds...>', 'Task dependencies')
.action(
async (
project: string,
mission: string,
taskId: string,
options: CreateCommandOptions,
) => {
await withSession(dependencies, async (repository) => {
const payload: CreateTaskInput = {
project,
mission,
taskId,
title: options.title,
description: options.description,
priority: options.priority,
dependencies: options.dependency,
lane: options.lane,
};
const task = await repository.create(payload);
dependencies.stdout(JSON.stringify(task, null, 2));
});
},
);
queue
.command('list')
.description('List queue tasks')
.option('--project <project>', 'Filter by project')
.option('--mission <mission>', 'Filter by mission')
.addOption(
new Option('--status <status>', 'Filter by status')
.choices(TASK_STATUSES)
.argParser(parseStatus),
)
.action(async (options: ListCommandOptions) => {
await withSession(dependencies, async (repository) => {
const filters: TaskListFilters = {
project: options.project,
mission: options.mission,
status: options.status,
};
const tasks = await repository.list(filters);
dependencies.stdout(JSON.stringify(tasks, null, 2));
});
});
queue
.command('show <taskId>')
.description('Show a single queue task')
.action(async (taskId: string) => {
await withSession(dependencies, async (repository) => {
const task = await repository.get(taskId);
if (task === null) {
throw new Error(`Task ${taskId} was not found.`);
}
dependencies.stdout(JSON.stringify(task, null, 2));
});
});
queue
.command('claim <taskId>')
.description('Claim a pending task')
.requiredOption('--agent <agentId>', 'Agent identifier')
.requiredOption('--ttl <seconds>', 'Claim TTL in seconds', parsePositiveInteger)
.action(async (taskId: string, options: ClaimCommandOptions) => {
await withSession(dependencies, async (repository) => {
const claimInput: ClaimTaskInput = {
agentId: options.agent,
ttlSeconds: options.ttl,
};
const task = await repository.claim(taskId, claimInput);
dependencies.stdout(JSON.stringify(task, null, 2));
});
});
queue
.command('release <taskId>')
.description('Release a claimed task back to pending')
.option('--agent <agentId>', 'Expected owner agent id')
.action(async (taskId: string, options: ReleaseCommandOptions) => {
await withSession(dependencies, async (repository) => {
const task = await repository.release(taskId, {
agentId: options.agent,
});
dependencies.stdout(JSON.stringify(task, null, 2));
});
});
queue
.command('complete <taskId>')
.description('Complete a claimed task')
.option('--agent <agentId>', 'Expected owner agent id')
.option('--summary <summary>', 'Optional completion summary')
.action(async (taskId: string, options: CompleteCommandOptions) => {
await withSession(dependencies, async (repository) => {
const completeInput: CompleteTaskInput = {
agentId: options.agent,
summary: options.summary,
};
const task = await repository.complete(taskId, completeInput);
dependencies.stdout(JSON.stringify(task, null, 2));
});
});
return program;
}
export async function runQueueCli(
argv: string[] = process.argv,
dependencyOverrides: Partial<QueueCliDependencies> = {},
): Promise<number> {
const dependencies = resolveDependencies(dependencyOverrides);
const program = buildQueueCli(dependencies);
try {
await program.parseAsync(argv, {
from: 'node',
});
return 0;
} catch (error) {
if (error instanceof CommanderError) {
if (error.code === 'commander.helpDisplayed') {
return 0;
}
if (error.code.startsWith('commander.')) {
return error.exitCode;
}
}
dependencies.stderr(formatError(error));
return 1;
}
}
async function openRedisSession(): Promise<QueueRepositorySession> {
const redisClient = createRedisClient<ClosableRedisTaskClient>();
try {
await assertRedisHealthy(redisClient);
return {
repository: new RedisTaskRepository({
client: redisClient,
}),
close: async () => {
await redisClient.quit();
},
};
} catch (error) {
await redisClient.quit();
throw error;
}
}
async function withSession(
dependencies: QueueCliDependencies,
action: (repository: QueueRepository) => Promise<void>,
): Promise<void> {
const session = await dependencies.openSession();
try {
await action(session.repository);
} finally {
await session.close();
}
}
function resolveDependencies(
overrides: Partial<QueueCliDependencies>,
): QueueCliDependencies {
const openSession = overrides.openSession ?? DEFAULT_DEPENDENCIES.openSession;
const stdout = overrides.stdout ?? DEFAULT_DEPENDENCIES.stdout;
const stderr = overrides.stderr ?? DEFAULT_DEPENDENCIES.stderr;
return {
openSession: () => openSession(),
stdout: (line: string) => stdout(line),
stderr: (line: string) => stderr(line),
};
}
function parsePositiveInteger(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new InvalidArgumentError(`Expected a positive integer, received "${value}"`);
}
return parsed;
}
function parsePriority(value: string): TaskPriority {
if (!PRIORITY_SET.has(value as TaskPriority)) {
throw new InvalidArgumentError(
`Expected one of ${TASK_PRIORITIES.join(', ')}, received "${value}"`,
);
}
return value as TaskPriority;
}
function parseLane(value: string): TaskLane {
if (!LANE_SET.has(value as TaskLane)) {
throw new InvalidArgumentError(
`Expected one of ${TASK_LANES.join(', ')}, received "${value}"`,
);
}
return value as TaskLane;
}
function parseStatus(value: string): TaskStatus {
if (!STATUS_SET.has(value as TaskStatus)) {
throw new InvalidArgumentError(
`Expected one of ${TASK_STATUSES.join(', ')}, received "${value}"`,
);
}
return value as TaskStatus;
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}