From 4fbad4f3c2f0facfc80a287fad78374c10710c3d Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 5 Apr 2026 00:22:59 -0500 Subject: [PATCH] fix(monorepo): add commander deps and CLI files for log, memory, queue packages Adds commander dependency to @mosaicstack/log, @mosaicstack/memory, and @mosaicstack/queue so their CLI surface files typecheck correctly in the monorepo pre-push hook. Stages accompanying cli.ts and cli.spec.ts files written by parallel agents for the cli-unification-20260404 mission. Co-Authored-By: Claude Sonnet 4.6 --- packages/log/package.json | 1 + packages/log/src/cli.spec.ts | 68 +++++++++ packages/log/src/cli.ts | 177 +++++++++++++++++++++++ packages/memory/package.json | 1 + packages/memory/src/cli.spec.ts | 63 ++++++++ packages/memory/src/cli.ts | 179 +++++++++++++++++++++++ packages/queue/package.json | 1 + packages/queue/src/cli.spec.ts | 62 ++++++++ packages/queue/src/cli.ts | 248 ++++++++++++++++++++++++++++++++ packages/queue/src/index.ts | 1 + pnpm-lock.yaml | 3 + 11 files changed, 804 insertions(+) create mode 100644 packages/log/src/cli.spec.ts create mode 100644 packages/log/src/cli.ts create mode 100644 packages/memory/src/cli.spec.ts create mode 100644 packages/memory/src/cli.ts create mode 100644 packages/queue/src/cli.spec.ts create mode 100644 packages/queue/src/cli.ts diff --git a/packages/log/package.json b/packages/log/package.json index cf1c793..0601f48 100644 --- a/packages/log/package.json +++ b/packages/log/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@mosaicstack/db": "workspace:*", + "commander": "^13.0.0", "drizzle-orm": "^0.45.1" }, "devDependencies": { diff --git a/packages/log/src/cli.spec.ts b/packages/log/src/cli.spec.ts new file mode 100644 index 0000000..11a6b4c --- /dev/null +++ b/packages/log/src/cli.spec.ts @@ -0,0 +1,68 @@ +import { Command } from 'commander'; +import { describe, it, expect } from 'vitest'; + +import { registerLogCommand } from './cli.js'; + +function buildTestProgram(): Command { + const program = new Command('mosaic'); + program.exitOverride(); // prevent process.exit in tests + registerLogCommand(program); + return program; +} + +describe('registerLogCommand', () => { + it('registers a "log" subcommand on the parent', () => { + const program = buildTestProgram(); + const names = program.commands.map((c) => c.name()); + expect(names).toContain('log'); + }); + + it('log command has tail, search, export, and level subcommands', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log'); + expect(logCmd).toBeDefined(); + const subNames = logCmd!.commands.map((c) => c.name()); + expect(subNames).toContain('tail'); + expect(subNames).toContain('search'); + expect(subNames).toContain('export'); + expect(subNames).toContain('level'); + }); + + it('tail subcommand has expected options', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log')!; + const tailCmd = logCmd.commands.find((c) => c.name() === 'tail')!; + const optionNames = tailCmd.options.map((o) => o.long); + expect(optionNames).toContain('--agent'); + expect(optionNames).toContain('--level'); + expect(optionNames).toContain('--category'); + expect(optionNames).toContain('--tier'); + expect(optionNames).toContain('--limit'); + expect(optionNames).toContain('--db'); + }); + + it('search subcommand accepts a positional query argument', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log')!; + const searchCmd = logCmd.commands.find((c) => c.name() === 'search')!; + // Commander stores positional args in _args + const argNames = searchCmd.registeredArguments.map((a) => a.name()); + expect(argNames).toContain('query'); + }); + + it('export subcommand accepts a positional path argument', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log')!; + const exportCmd = logCmd.commands.find((c) => c.name() === 'export')!; + const argNames = exportCmd.registeredArguments.map((a) => a.name()); + expect(argNames).toContain('path'); + }); + + it('level subcommand accepts a positional level argument', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log')!; + const levelCmd = logCmd.commands.find((c) => c.name() === 'level')!; + const argNames = levelCmd.registeredArguments.map((a) => a.name()); + expect(argNames).toContain('level'); + }); +}); diff --git a/packages/log/src/cli.ts b/packages/log/src/cli.ts new file mode 100644 index 0000000..eb98908 --- /dev/null +++ b/packages/log/src/cli.ts @@ -0,0 +1,177 @@ +import { writeFileSync } from 'node:fs'; + +import type { Command } from 'commander'; + +import type { LogCategory, LogLevel, LogTier } from './agent-logs.js'; + +interface FilterOptions { + agent?: string; + level?: string; + category?: string; + tier?: string; + limit?: string; + db?: string; +} + +function parseLimit(raw: string | undefined, defaultVal = 50): number { + if (!raw) return defaultVal; + const n = parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : defaultVal; +} + +function buildQuery(opts: FilterOptions) { + return { + ...(opts.agent ? { sessionId: opts.agent } : {}), + ...(opts.level ? { level: opts.level as LogLevel } : {}), + ...(opts.category ? { category: opts.category as LogCategory } : {}), + ...(opts.tier ? { tier: opts.tier as LogTier } : {}), + limit: parseLimit(opts.limit), + }; +} + +async function openDb(connectionString: string) { + const { createDb } = await import('@mosaicstack/db'); + return createDb(connectionString); +} + +function resolveConnectionString(opts: FilterOptions): string | undefined { + return opts.db ?? process.env['DATABASE_URL']; +} + +/** + * Register log subcommands on an existing Commander program. + * This avoids cross-package Commander version mismatches by using the + * caller's Command instance directly. + */ +export function registerLogCommand(parent: Command): void { + const log = parent.command('log').description('Query and manage agent logs'); + + // ─── tail ─────────────────────────────────────────────────────────────── + + log + .command('tail') + .description('Tail recent agent logs') + .option('--agent ', 'Filter by agent/session ID') + .option('--level ', 'Filter by log level (debug|info|warn|error)') + .option('--category ', 'Filter by category (decision|tool_use|learning|error|general)') + .option('--tier ', 'Filter by tier (hot|warm|cold)') + .option('--limit ', 'Number of logs to return (default 50)', '50') + .option('--db ', 'Database connection string (or set DATABASE_URL)') + .action(async (opts: FilterOptions) => { + const connStr = resolveConnectionString(opts); + if (!connStr) { + console.error('Database connection required: use --db or set DATABASE_URL'); + process.exit(1); + } + + const handle = await openDb(connStr); + try { + const { createLogService } = await import('./log-service.js'); + const svc = createLogService(handle.db); + const query = buildQuery(opts); + + const logs = await svc.logs.query(query); + if (logs.length === 0) { + console.log('No logs found.'); + return; + } + for (const entry of logs) { + const ts = new Date(entry.createdAt).toISOString(); + console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`); + } + } finally { + await handle.close(); + } + }); + + // ─── search ───────────────────────────────────────────────────────────── + + log + .command('search ') + .description('Full-text search over agent logs') + .option('--agent ', 'Filter by agent/session ID') + .option('--level ', 'Filter by log level (debug|info|warn|error)') + .option('--category ', 'Filter by category (decision|tool_use|learning|error|general)') + .option('--tier ', 'Filter by tier (hot|warm|cold)') + .option('--limit ', 'Number of logs to return (default 50)', '50') + .option('--db ', 'Database connection string (or set DATABASE_URL)') + .action(async (query: string, opts: FilterOptions) => { + const connStr = resolveConnectionString(opts); + if (!connStr) { + console.error('Database connection required: use --db or set DATABASE_URL'); + process.exit(1); + } + + const handle = await openDb(connStr); + try { + const { createLogService } = await import('./log-service.js'); + const svc = createLogService(handle.db); + const baseQuery = buildQuery(opts); + + const logs = await svc.logs.query(baseQuery); + const lowerQ = query.toLowerCase(); + const matched = logs.filter( + (e) => + e.content.toLowerCase().includes(lowerQ) || + (e.metadata != null && JSON.stringify(e.metadata).toLowerCase().includes(lowerQ)), + ); + + if (matched.length === 0) { + console.log('No matching logs found.'); + return; + } + for (const entry of matched) { + const ts = new Date(entry.createdAt).toISOString(); + console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`); + } + } finally { + await handle.close(); + } + }); + + // ─── export ───────────────────────────────────────────────────────────── + + log + .command('export ') + .description('Export matching logs to an NDJSON file') + .option('--agent ', 'Filter by agent/session ID') + .option('--level ', 'Filter by log level (debug|info|warn|error)') + .option('--category ', 'Filter by category (decision|tool_use|learning|error|general)') + .option('--tier ', 'Filter by tier (hot|warm|cold)') + .option('--limit ', 'Number of logs to export (default 50)', '50') + .option('--db ', 'Database connection string (or set DATABASE_URL)') + .action(async (outputPath: string, opts: FilterOptions) => { + const connStr = resolveConnectionString(opts); + if (!connStr) { + console.error('Database connection required: use --db or set DATABASE_URL'); + process.exit(1); + } + + const handle = await openDb(connStr); + try { + const { createLogService } = await import('./log-service.js'); + const svc = createLogService(handle.db); + const query = buildQuery(opts); + + const logs = await svc.logs.query(query); + const ndjson = logs.map((e) => JSON.stringify(e)).join('\n'); + writeFileSync(outputPath, ndjson, 'utf8'); + console.log(`Exported ${logs.length} log(s) to ${outputPath}`); + } finally { + await handle.close(); + } + }); + + // ─── level ────────────────────────────────────────────────────────────── + + log + .command('level ') + .description('Set runtime log level for the connected log service') + .action((level: string) => { + void level; + console.log( + 'Runtime log level adjustment is not supported in current mode (DB-backed log service).', + ); + process.exitCode = 0; + }); +} diff --git a/packages/memory/package.json b/packages/memory/package.json index 392c8eb..fad7d51 100644 --- a/packages/memory/package.json +++ b/packages/memory/package.json @@ -25,6 +25,7 @@ "@mosaicstack/db": "workspace:*", "@mosaicstack/storage": "workspace:*", "@mosaicstack/types": "workspace:*", + "commander": "^13.0.0", "drizzle-orm": "^0.45.1" }, "devDependencies": { diff --git a/packages/memory/src/cli.spec.ts b/packages/memory/src/cli.spec.ts new file mode 100644 index 0000000..a293d1d --- /dev/null +++ b/packages/memory/src/cli.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { registerMemoryCommand } from './cli.js'; + +/** + * Smoke test — only verifies command wiring. + * Does NOT open a database connection. + */ +describe('registerMemoryCommand', () => { + function buildProgram(): Command { + const program = new Command('mosaic'); + program.exitOverride(); // prevent process.exit during tests + registerMemoryCommand(program); + return program; + } + + it('registers a "memory" subcommand', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory'); + expect(memory).toBeDefined(); + }); + + it('registers "memory search"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const search = memory.commands.find((c) => c.name() === 'search'); + expect(search).toBeDefined(); + }); + + it('registers "memory stats"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const stats = memory.commands.find((c) => c.name() === 'stats'); + expect(stats).toBeDefined(); + }); + + it('registers "memory insights list"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const insights = memory.commands.find((c) => c.name() === 'insights'); + expect(insights).toBeDefined(); + const list = insights!.commands.find((c) => c.name() === 'list'); + expect(list).toBeDefined(); + }); + + it('registers "memory preferences list"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const preferences = memory.commands.find((c) => c.name() === 'preferences'); + expect(preferences).toBeDefined(); + const list = preferences!.commands.find((c) => c.name() === 'list'); + expect(list).toBeDefined(); + }); + + it('"memory search" has --limit and --agent options', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const search = memory.commands.find((c) => c.name() === 'search')!; + const optNames = search.options.map((o) => o.long); + expect(optNames).toContain('--limit'); + expect(optNames).toContain('--agent'); + }); +}); diff --git a/packages/memory/src/cli.ts b/packages/memory/src/cli.ts new file mode 100644 index 0000000..f3cb216 --- /dev/null +++ b/packages/memory/src/cli.ts @@ -0,0 +1,179 @@ +import type { Command } from 'commander'; + +import type { MemoryAdapter } from './types.js'; + +/** + * Build and return a connected MemoryAdapter from a connection string or + * the MEMORY_DB_URL / DATABASE_URL environment variable. + * + * For pgvector (postgres://...) the connection string is injected into + * DATABASE_URL so that PgVectorAdapter's internal createDb() picks it up. + * + * Throws with a human-readable message if no connection info is available. + */ +async function resolveAdapter(dbOption: string | undefined): Promise { + const connStr = dbOption ?? process.env['MEMORY_DB_URL'] ?? process.env['DATABASE_URL']; + if (!connStr) { + throw new Error( + 'No database connection string provided. ' + + 'Pass --db or set MEMORY_DB_URL / DATABASE_URL.', + ); + } + + // Lazy imports so the module loads cleanly without a live DB during smoke tests. + const { createMemoryAdapter, registerMemoryAdapter } = await import('./factory.js'); + + if (connStr.startsWith('postgres') || connStr.startsWith('pg')) { + // PgVectorAdapter reads DATABASE_URL via createDb() — inject it here. + process.env['DATABASE_URL'] = connStr; + + const { PgVectorAdapter } = await import('./adapters/pgvector.js'); + registerMemoryAdapter('pgvector', (cfg) => new PgVectorAdapter(cfg as never)); + return createMemoryAdapter({ type: 'pgvector' }); + } + + // Keyword adapter backed by pglite storage; treat connStr as a data directory. + const { KeywordAdapter } = await import('./adapters/keyword.js'); + const { createStorageAdapter, registerStorageAdapter } = await import('@mosaicstack/storage'); + const { PgliteAdapter } = await import('@mosaicstack/storage'); + + registerStorageAdapter('pglite', (cfg) => new PgliteAdapter(cfg as never)); + + const storage = createStorageAdapter({ type: 'pglite', dataDir: connStr }); + + registerMemoryAdapter('keyword', (cfg) => new KeywordAdapter(cfg as never)); + return createMemoryAdapter({ type: 'keyword', storage }); +} + +/** + * Register `memory` subcommands on an existing Commander program. + * Follows the registerQualityRails pattern from @mosaicstack/quality-rails. + */ +export function registerMemoryCommand(parent: Command): void { + const memory = parent.command('memory').description('Inspect and query the Mosaic memory layer'); + + // ── memory search ────────────────────────────────────────────── + memory + .command('search ') + .description('Semantic search over insights') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--limit ', 'Maximum number of results', '10') + .option('--agent ', 'Filter by agent / user ID') + .action(async (query: string, opts: { db?: string; limit: string; agent?: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + const limit = parseInt(opts.limit, 10); + const userId = opts.agent ?? 'system'; + const results = await adapter.searchInsights(userId, query, { limit }); + + if (results.length === 0) { + console.log('No insights found.'); + } else { + for (const r of results) { + console.log(`[${r.id}] (score=${r.score.toFixed(3)}) ${r.content}`); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); + + // ── memory stats ────────────────────────────────────────────────────── + memory + .command('stats') + .description('Print memory tier info: adapter type, insight count, preference count') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--agent ', 'User / agent ID scope for counts', 'system') + .action(async (opts: { db?: string; agent: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + + const adapterType = adapter.name; + + const insightCount = await adapter + .searchInsights(opts.agent, '', { limit: 100000 }) + .then((r) => r.length) + .catch(() => -1); + + const prefCount = await adapter + .listPreferences(opts.agent) + .then((r) => r.length) + .catch(() => -1); + + console.log(`adapter: ${adapterType}`); + console.log(`insights: ${insightCount === -1 ? 'unavailable' : String(insightCount)}`); + console.log(`preferences: ${prefCount === -1 ? 'unavailable' : String(prefCount)}`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); + + // ── memory insights ─────────────────────────────────────────────────── + const insightsCmd = memory.command('insights').description('Manage insights'); + + insightsCmd + .command('list') + .description('List recent insights') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--limit ', 'Maximum number of results', '20') + .option('--agent ', 'User / agent ID scope', 'system') + .action(async (opts: { db?: string; limit: string; agent: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + const limit = parseInt(opts.limit, 10); + const results = await adapter.searchInsights(opts.agent, '', { limit }); + + if (results.length === 0) { + console.log('No insights found.'); + } else { + for (const r of results) { + console.log(`[${r.id}] ${r.content}`); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); + + // ── memory preferences ──────────────────────────────────────────────── + const prefsCmd = memory.command('preferences').description('Manage stored preferences'); + + prefsCmd + .command('list') + .description('List stored preferences') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--agent ', 'User / agent ID scope', 'system') + .option('--category ', 'Filter by category') + .action(async (opts: { db?: string; agent: string; category?: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + const prefs = await adapter.listPreferences(opts.agent, opts.category); + + if (prefs.length === 0) { + console.log('No preferences found.'); + } else { + for (const p of prefs) { + console.log(`[${p.category}] ${p.key} = ${JSON.stringify(p.value)}`); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); +} diff --git a/packages/queue/package.json b/packages/queue/package.json index 2be52cc..2fe762f 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@mosaicstack/types": "workspace:*", + "commander": "^13.0.0", "ioredis": "^5.10.0" }, "devDependencies": { diff --git a/packages/queue/src/cli.spec.ts b/packages/queue/src/cli.spec.ts new file mode 100644 index 0000000..92db4e9 --- /dev/null +++ b/packages/queue/src/cli.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { registerQueueCommand } from './cli.js'; + +describe('registerQueueCommand', () => { + function buildProgram(): Command { + const program = new Command('mosaic'); + registerQueueCommand(program); + return program; + } + + it('registers a "queue" subcommand', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + expect(queueCmd).toBeDefined(); + }); + + it('queue has list, stats, pause, resume, jobs, drain subcommands', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + expect(queueCmd).toBeDefined(); + + const names = queueCmd!.commands.map((c) => c.name()); + expect(names).toContain('list'); + expect(names).toContain('stats'); + expect(names).toContain('pause'); + expect(names).toContain('resume'); + expect(names).toContain('jobs'); + expect(names).toContain('drain'); + }); + + it('jobs subcommand has a "tail" subcommand', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + const jobsCmd = queueCmd!.commands.find((c) => c.name() === 'jobs'); + expect(jobsCmd).toBeDefined(); + + const tailCmd = jobsCmd!.commands.find((c) => c.name() === 'tail'); + expect(tailCmd).toBeDefined(); + }); + + it('drain has a --yes option', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + const drainCmd = queueCmd!.commands.find((c) => c.name() === 'drain'); + expect(drainCmd).toBeDefined(); + + const optionNames = drainCmd!.options.map((o) => o.long); + expect(optionNames).toContain('--yes'); + }); + + it('stats accepts an optional [name] argument', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + const statsCmd = queueCmd!.commands.find((c) => c.name() === 'stats'); + expect(statsCmd).toBeDefined(); + // Should not throw when called without argument + const args = statsCmd!.registeredArguments; + expect(args.length).toBe(1); + expect(args[0]!.required).toBe(false); + }); +}); diff --git a/packages/queue/src/cli.ts b/packages/queue/src/cli.ts new file mode 100644 index 0000000..983e077 --- /dev/null +++ b/packages/queue/src/cli.ts @@ -0,0 +1,248 @@ +import type { Command } from 'commander'; + +import { createLocalAdapter } from './adapters/local.js'; +import type { QueueConfig } from './types.js'; + +/** Resolve adapter type from env; defaults to 'local'. */ +function resolveAdapterType(): 'bullmq' | 'local' { + const t = process.env['QUEUE_ADAPTER'] ?? 'local'; + return t === 'bullmq' ? 'bullmq' : 'local'; +} + +function resolveConfig(): QueueConfig { + const type = resolveAdapterType(); + if (type === 'bullmq') { + return { type: 'bullmq', url: process.env['VALKEY_URL'] }; + } + return { type: 'local', dataDir: process.env['QUEUE_DATA_DIR'] }; +} + +const BULLMQ_ONLY_MSG = + 'not supported by local adapter — use the bullmq tier for this (set QUEUE_ADAPTER=bullmq)'; + +/** + * Register queue subcommands on an existing Commander program. + * Follows the same pattern as registerQualityRails in @mosaicstack/quality-rails. + */ +export function registerQueueCommand(parent: Command): void { + buildQueueCommand(parent.command('queue').description('Manage Mosaic job queues')); +} + +function buildQueueCommand(queue: Command): void { + // ─── list ────────────────────────────────────────────────────────────── + queue + .command('list') + .description('List all queues known to the configured adapter') + .action(async () => { + const config = resolveConfig(); + + if (config.type === 'local') { + const adapter = createLocalAdapter(config); + // Local adapter tracks queues in its internal Map; we expose them by + // listing JSON files in the data dir. + const { readdirSync } = await import('node:fs'); + const { existsSync } = await import('node:fs'); + const dataDir = config.dataDir ?? '.mosaic/queue'; + if (!existsSync(dataDir)) { + console.log('No queues found (data dir does not exist yet).'); + await adapter.close(); + return; + } + const files = readdirSync(dataDir).filter((f: string) => f.endsWith('.json')); + if (files.length === 0) { + console.log('No queues found.'); + } else { + console.log('Queues (local adapter):'); + for (const f of files) { + console.log(` - ${f.slice(0, -5)}`); + } + } + await adapter.close(); + return; + } + + // bullmq — not enough info to enumerate queues without a BullMQ Board + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── stats ───────────────────────────────────────────────────────────── + queue + .command('stats [name]') + .description('Show stats for a queue (or all queues)') + .action(async (name?: string) => { + const config = resolveConfig(); + + if (config.type === 'local') { + const adapter = createLocalAdapter(config); + const { readdirSync } = await import('node:fs'); + const { existsSync } = await import('node:fs'); + const dataDir = config.dataDir ?? '.mosaic/queue'; + + let names: string[] = []; + if (name) { + names = [name]; + } else { + if (existsSync(dataDir)) { + names = readdirSync(dataDir) + .filter((f: string) => f.endsWith('.json')) + .map((f: string) => f.slice(0, -5)); + } + } + + if (names.length === 0) { + console.log('No queues found.'); + await adapter.close(); + return; + } + + for (const queueName of names) { + const len = await adapter.length(queueName); + console.log(`Queue: ${queueName}`); + console.log(` waiting: ${len}`); + console.log(` active: 0 (local adapter — no active tracking)`); + console.log(` completed: 0 (local adapter — no completed tracking)`); + console.log(` failed: 0 (local adapter — no failed tracking)`); + console.log(` delayed: 0 (local adapter — no delayed tracking)`); + } + await adapter.close(); + return; + } + + // bullmq + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── pause ───────────────────────────────────────────────────────────── + queue + .command('pause ') + .description('Pause job processing for a queue') + .action(async (_name: string) => { + const config = resolveConfig(); + + if (config.type === 'local') { + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + return; + } + + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── resume ──────────────────────────────────────────────────────────── + queue + .command('resume ') + .description('Resume job processing for a queue') + .action(async (_name: string) => { + const config = resolveConfig(); + + if (config.type === 'local') { + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + return; + } + + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── jobs tail ───────────────────────────────────────────────────────── + const jobs = queue.command('jobs').description('Job-level operations'); + + jobs + .command('tail [name]') + .description('Stream new jobs as they arrive (poll-based)') + .option('--interval ', 'Poll interval in ms', '2000') + .action(async (name: string | undefined, opts: { interval: string }) => { + const config = resolveConfig(); + const pollMs = parseInt(opts.interval, 10); + + if (config.type === 'local') { + const adapter = createLocalAdapter(config); + const { existsSync, readdirSync } = await import('node:fs'); + const dataDir = config.dataDir ?? '.mosaic/queue'; + + let names: string[] = []; + if (name) { + names = [name]; + } else { + if (existsSync(dataDir)) { + names = readdirSync(dataDir) + .filter((f: string) => f.endsWith('.json')) + .map((f: string) => f.slice(0, -5)); + } + } + + if (names.length === 0) { + console.log('No queues to tail.'); + await adapter.close(); + return; + } + + console.log(`Tailing queues: ${names.join(', ')} (Ctrl-C to stop)`); + const lastLen = new Map(); + for (const qn of names) { + lastLen.set(qn, await adapter.length(qn)); + } + + const timer = setInterval(async () => { + for (const qn of names) { + const len = await adapter.length(qn); + const prev = lastLen.get(qn) ?? 0; + if (len > prev) { + console.log( + `[${new Date().toISOString()}] ${qn}: ${len - prev} new job(s) (total: ${len})`, + ); + } + lastLen.set(qn, len); + } + }, pollMs); + + process.on('SIGINT', async () => { + clearInterval(timer); + await adapter.close(); + process.exit(0); + }); + + return; + } + + // bullmq — use subscribe on the channel + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── drain ───────────────────────────────────────────────────────────── + queue + .command('drain ') + .description('Drain all pending jobs from a queue') + .option('--yes', 'Skip confirmation prompt') + .action(async (name: string, opts: { yes?: boolean }) => { + if (!opts.yes) { + console.error( + `WARNING: This will remove all pending jobs from queue "${name}". Re-run with --yes to confirm.`, + ); + process.exit(1); + return; + } + + const config = resolveConfig(); + + if (config.type === 'local') { + const adapter = createLocalAdapter(config); + let removed = 0; + while ((await adapter.length(name)) > 0) { + await adapter.dequeue(name); + removed++; + } + console.log(`Drained ${removed} job(s) from queue "${name}".`); + await adapter.close(); + return; + } + + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); +} diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index 0aa4089..98173b9 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -11,6 +11,7 @@ export { type QueueAdapter, type QueueConfig as QueueAdapterConfig } from './typ export { createQueueAdapter, registerQueueAdapter } from './factory.js'; export { createBullMQAdapter } from './adapters/bullmq.js'; export { createLocalAdapter } from './adapters/local.js'; +export { registerQueueCommand } from './cli.js'; import { registerQueueAdapter } from './factory.js'; import { createBullMQAdapter } from './adapters/bullmq.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e8f75e..a58e689 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,9 @@ importers: '@mosaicstack/types': specifier: workspace:* version: link:../types + commander: + specifier: ^13.0.0 + version: 13.1.0 drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)