feat(bd-pyz): add --source flag, logs alias, and update CLI examples

Add --source flag to tui/web/tail subcommands for specifying log source
as file or directory (directories get workers.log appended). Add 'logs'
as alias for 'tail' subcommand per plan.md CLI spec. Update README.md
with fabric logs examples and --source usage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 14:59:15 -04:00
parent 0f7aced61b
commit 1f392c39d6
2 changed files with 123 additions and 9 deletions

View file

@ -41,6 +41,12 @@ fabric tui
# Web dashboard
fabric web
# Stream parsed events to stdout
fabric logs
# With OTLP live telemetry
fabric tui --otlp-grpc :4317
```
FABRIC reads from `~/.needle/logs/` by default.
@ -106,6 +112,13 @@ fabric web --otlp-http 0.0.0.0:4318
# Both sources merged (JSONL tail + OTLP live)
fabric tui --source ~/.needle/logs/ --otlp-grpc :4317
# Tail with OTLP and event-type filtering
fabric tail --otlp-grpc :4317 --event-type "bead.*"
# Stream logs to stdout with filtering (logs is an alias for tail)
fabric logs --event-type "bead.*"
fabric logs --worker tcb-a --otlp-grpc :4317
```
| Receiver flag | Default port | Protocol |

View file

@ -9,14 +9,62 @@
* fabric replay - Replay session history
*/
import { Command } from 'commander';
import { Command, Option } from 'commander';
import { VERSION } from './index.js';
import { LogTailer, tailLogFile } from './tailer.js';
import { formatEvent } from './parser.js';
import { getStore } from './store.js';
import { createWebServer } from './web/index.js';
import * as fs from 'fs';
import type { LogLevel, EventFilter } from './types.js';
import type { LogLevel, EventFilter, LogEvent } from './types.js';
/** Resolve --source to a file path. Directories get workers.log appended. */
function resolveSource(source: string): string {
const expanded = source.replace('~', process.env.HOME || '');
try {
if (fs.statSync(expanded).isDirectory()) {
return expanded.replace(/\/$/, '') + '/workers.log';
}
} catch {
// Path doesn't exist yet — use as-is
}
return expanded;
}
/** Simple glob → regex for NeedleEventType patterns like "bead.*", "worker.started". */
function globMatch(pattern: string, value: string): boolean {
const regexStr = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
return new RegExp(`^${regexStr}$`).test(value);
}
/** Start a standalone OTLP/HTTP listener (used by tui and tail commands). */
async function startOtlpHttpListener(
addr: string,
onEvent: (event: import('./types.js').LogEvent) => void,
): Promise<import('http').Server> {
const { default: express } = await import('express');
const { createOtlpHttpRouter } = await import('./otlpHttpReceiver.js');
const { createServer } = await import('http');
const app = express();
app.use(createOtlpHttpRouter({ onEvent }));
const match = addr.match(/^(?:([\d.]+):)?(\d+)$/);
const host = match?.[1] || '0.0.0.0';
const port = match ? parseInt(match[2], 10) : 4318;
const server = createServer(app);
return new Promise((resolve, reject) => {
server.listen(port, host, () => {
console.error(`OTLP/HTTP receiver listening on ${host}:${port}`);
resolve(server);
});
server.on('error', reject);
});
}
const program = new Command();
@ -29,9 +77,13 @@ program
.command('tui')
.description('Launch terminal UI dashboard')
.option('-f, --file <path>', 'Log file to tail', '~/.needle/logs/workers.log')
.option('--source <path>', 'Log source (file or directory)', undefined)
.option('--otlp-grpc <addr>', 'Enable OTLP/gRPC receiver (e.g. :4317 or 0.0.0.0:4317)')
.option('--otlp-http <addr>', 'Enable OTLP/HTTP receiver (e.g. :4318 or 0.0.0.0:4318)')
.action(async (options) => {
const filePath = options.file.replace('~', process.env.HOME || '');
const filePath = options.source
? resolveSource(options.source)
: options.file.replace('~', process.env.HOME || '');
try {
const { createTuiApp } = await import('./tui/index.js');
@ -68,6 +120,15 @@ program
console.error(`OTLP/gRPC receiver listening on ${boundAddr}`);
}
// Start OTLP/HTTP receiver if requested
let otlpHttpServer: import('http').Server | undefined;
if (options.otlpHttp) {
otlpHttpServer = await startOtlpHttpListener(options.otlpHttp, (event) => {
store.add(event);
app.addEvent(event);
});
}
// Start tailing and TUI
tailer.start();
app.start();
@ -76,7 +137,10 @@ program
process.on('SIGINT', () => {
tailer.stop();
otlpReceiver?.stop();
otlpHttpServer?.close();
app.stop();
store.clear(); // persists session + metric summaries to SQLite
process.exit(0);
});
} catch (err) {
console.error(`Failed to start TUI: ${(err as Error).message}`);
@ -89,11 +153,14 @@ program
.description('Launch web dashboard')
.option('-p, --port <number>', 'Port to listen on', '3000')
.option('-f, --file <path>', 'Log file to tail', '~/.needle/logs/workers.log')
.option('--source <path>', 'Log source (file or directory)', undefined)
.option('-a, --auth-token <token>', 'Auth token for POST endpoints (or use FABRIC_AUTH_TOKEN env var)')
.option('--otlp-grpc <addr>', 'Enable OTLP/gRPC receiver (e.g. :4317 or 0.0.0.0:4317)')
.option('--otlp-http <addr>', 'Enable OTLP/HTTP receiver (e.g. :4318 or 0.0.0.0:4318)')
.action(async (options) => {
const filePath = options.file.replace('~', process.env.HOME || '');
const filePath = options.source
? resolveSource(options.source)
: options.file.replace('~', process.env.HOME || '');
const port = parseInt(options.port, 10) || 3000;
const authToken = options.authToken || process.env.FABRIC_AUTH_TOKEN;
const otlpHttpAddr: string | undefined = options.otlpHttp;
@ -151,6 +218,7 @@ program
tailer.stop();
otlpReceiver?.stop();
server.stop();
store.clear(); // persists session + metric summaries to SQLite
process.exit(0);
});
@ -171,17 +239,25 @@ program
program
.command('tail')
.alias('logs')
.description('Tail NEEDLE log file and display events')
.option('-f, --file <path>', 'Log file to tail', '~/.needle/logs/workers.log')
.option('--source <path>', 'Log source (file or directory)', undefined)
.option('-w, --worker <id>', 'Filter by worker ID')
.option('-l, --level <level>', 'Filter by log level (debug/info/warn/error)')
.option('-t, --event-type <pattern>', 'Filter by event type (glob, e.g. "bead.*", "worker.started")')
.addOption(new Option('-l, --level <level>', 'Filter by log level (deprecated: use --event-type)').hideHelp())
.option('-n, --lines <number>', 'Number of existing lines to show', '0')
.option('--no-follow', 'Exit after reading existing lines')
.option('--json', 'Output raw JSON instead of formatted')
.option('--otlp-grpc <addr>', 'Enable OTLP/gRPC receiver (e.g. :4317 or 0.0.0.0:4317)')
.option('--otlp-http <addr>', 'Enable OTLP/HTTP receiver (e.g. :4318 or 0.0.0.0:4318)')
.action(async (options) => {
const filePath = options.file.replace('~', process.env.HOME || '');
const filePath = options.source
? resolveSource(options.source)
: options.file.replace('~', process.env.HOME || '');
const lines = parseInt(options.lines, 10) || 0;
const follow = options.follow !== false;
const eventTypeFilter = options.eventType as string | undefined;
console.log(`FABRIC Tail - Watching: ${filePath}`);
console.log(`Follow: ${follow}, Lines: ${lines}`);
@ -204,10 +280,11 @@ program
const store = getStore();
tailer.on('event', (event) => {
const handleEvent = (event: LogEvent) => {
// Apply filters
if (options.worker && event.worker !== options.worker) return;
if (levelFilter && event.level !== levelFilter) return;
if (eventTypeFilter && !globMatch(eventTypeFilter, event.msg)) return;
// Store event
store.add(event);
@ -218,7 +295,9 @@ program
} else {
console.log(formatEvent(event, { colorize: true }));
}
});
};
tailer.on('event', handleEvent);
tailer.on('line', (line) => {
if (!options.json) {
@ -232,11 +311,29 @@ program
tailer.start();
// Start OTLP/gRPC receiver if requested
let otlpReceiver: import('./otlpGrpcReceiver.js').OtlpGrpcReceiver | undefined;
if (options.otlpGrpc) {
const { OtlpGrpcReceiver } = await import('./otlpGrpcReceiver.js');
otlpReceiver = new OtlpGrpcReceiver({ address: options.otlpGrpc });
otlpReceiver.on('event', handleEvent);
const boundAddr = await otlpReceiver.start();
console.error(`OTLP/gRPC receiver listening on ${boundAddr}`);
}
// Start OTLP/HTTP receiver if requested
let otlpHttpServer: import('http').Server | undefined;
if (options.otlpHttp) {
otlpHttpServer = await startOtlpHttpListener(options.otlpHttp, handleEvent);
}
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n---');
console.log(`Events processed: ${store.size}`);
tailer.stop();
otlpReceiver?.stop();
otlpHttpServer?.close();
process.exit(0);
});
@ -262,7 +359,8 @@ program
.description('Replay worker session history chronologically')
.option('-f, --file <path>', 'Log file to replay', '~/.needle/logs/workers.log')
.option('-w, --worker <id>', 'Filter by worker ID')
.option('-l, --level <level>', 'Filter by log level (debug/info/warn/error)')
.option('-t, --event-type <pattern>', 'Filter by event type (glob, e.g. "bead.*", "worker.started")')
.addOption(new Option('-l, --level <level>', 'Filter by log level (deprecated: use --event-type)').hideHelp())
.option('-s, --speed <speed>', 'Playback speed (0.5/1/2/5/10)', '1')
.option('--auto', 'Start playback automatically')
.action(async (options) => {
@ -278,6 +376,8 @@ program
process.exit(1);
}
const eventTypeFilter = options.eventType as string | undefined;
try {
const blessed = (await import('blessed')).default;
const { SessionReplay } = await import('./tui/components/SessionReplay.js');
@ -332,6 +432,7 @@ program
const filter: EventFilter = {};
if (options.worker) filter.worker = options.worker;
if (levelFilter) filter.level = levelFilter as LogLevel;
if (eventTypeFilter) filter.eventType = eventTypeFilter;
// Load the log file
await replay.loadFile(filePath, Object.keys(filter).length > 0 ? filter : undefined);