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/log/src/index.ts b/packages/log/src/index.ts index f73585f..86bdffe 100644 --- a/packages/log/src/index.ts +++ b/packages/log/src/index.ts @@ -9,3 +9,4 @@ export { type LogTier, type LogQuery, } from './agent-logs.js'; +export { registerLogCommand } from './cli.js'; diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 36ea3fe..ee85365 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -30,6 +30,7 @@ "@mosaicstack/brain": "workspace:*", "@mosaicstack/config": "workspace:*", "@mosaicstack/forge": "workspace:*", + "@mosaicstack/log": "workspace:*", "@mosaicstack/macp": "workspace:*", "@mosaicstack/memory": "workspace:*", "@mosaicstack/prdy": "workspace:*", diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 286dc93..378a93d 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -3,6 +3,7 @@ import { createRequire } from 'module'; import { Command } from 'commander'; import { registerBrainCommand } from '@mosaicstack/brain'; +import { registerLogCommand } from '@mosaicstack/log'; import { registerMemoryCommand } from '@mosaicstack/memory'; import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQueueCommand } from '@mosaicstack/queue'; @@ -350,6 +351,10 @@ registerBrainCommand(program); registerQualityRails(program); +// ─── log ───────────────────────────────────────────────────────────────── + +registerLogCommand(program); + // ─── memory ────────────────────────────────────────────────────────────── registerMemoryCommand(program); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07adbe0..007b954 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,9 @@ importers: '@mosaicstack/db': specifier: workspace:* version: link:../db + 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) @@ -469,6 +472,9 @@ importers: '@mosaicstack/forge': specifier: workspace:* version: link:../forge + '@mosaicstack/log': + specifier: workspace:* + version: link:../log '@mosaicstack/macp': specifier: workspace:* version: link:../macp