498 lines
13 KiB
TypeScript
498 lines
13 KiB
TypeScript
import {
|
|
closeSync,
|
|
existsSync,
|
|
openSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
readSync,
|
|
statSync,
|
|
unwatchFile,
|
|
watchFile,
|
|
} from "node:fs";
|
|
import { basename, join } from "node:path";
|
|
import { getSfEnv } from "./env.js";
|
|
|
|
export type LogSourceName = "notif" | "session" | "activity" | "audit";
|
|
|
|
export interface MergedLogEvent {
|
|
source: LogSourceName;
|
|
timestamp: string;
|
|
message: string;
|
|
severity?: string;
|
|
filePath: string;
|
|
}
|
|
|
|
interface SourceFile {
|
|
source: LogSourceName;
|
|
path: string;
|
|
}
|
|
|
|
export interface CollectRecentLogOptions {
|
|
basePath: string;
|
|
sfHome?: string;
|
|
source?: LogSourceName;
|
|
severity?: string;
|
|
limit?: number;
|
|
}
|
|
|
|
interface TailOptions extends CollectRecentLogOptions {
|
|
stdout?: Pick<typeof process.stdout, "write">;
|
|
stderr?: Pick<typeof process.stderr, "write">;
|
|
}
|
|
|
|
interface ParsedLogsArgs {
|
|
command: "tail" | "follow";
|
|
source?: LogSourceName;
|
|
severity?: string;
|
|
}
|
|
|
|
const SOURCE_ALIASES: Record<string, LogSourceName> = {
|
|
notif: "notif",
|
|
notification: "notif",
|
|
notifications: "notif",
|
|
session: "session",
|
|
sessions: "session",
|
|
activity: "activity",
|
|
audit: "audit",
|
|
};
|
|
|
|
function normalizeSource(value: string | undefined): LogSourceName | undefined {
|
|
if (!value) return undefined;
|
|
return SOURCE_ALIASES[value.toLowerCase()];
|
|
}
|
|
|
|
function sfHomeFromEnv(): string {
|
|
return getSfEnv().sfHome;
|
|
}
|
|
|
|
export function getProjectSessionKey(basePath: string): string {
|
|
return `--${basePath.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
}
|
|
|
|
function latestJsonlFile(dir: string): string | null {
|
|
if (!existsSync(dir)) return null;
|
|
try {
|
|
const candidates = readdirSync(dir)
|
|
.filter((file) => file.endsWith(".jsonl"))
|
|
.map((file) => {
|
|
const path = join(dir, file);
|
|
try {
|
|
const stat = statSync(path);
|
|
return { path, mtimeMs: stat.mtimeMs };
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(
|
|
(entry): entry is { path: string; mtimeMs: number } => entry !== null,
|
|
);
|
|
candidates.sort(
|
|
(a, b) =>
|
|
b.mtimeMs - a.mtimeMs ||
|
|
basename(b.path).localeCompare(basename(a.path)),
|
|
);
|
|
return candidates[0]?.path ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveSourceFiles(basePath: string, sfHome: string): SourceFile[] {
|
|
const sfDir = join(basePath, ".sf");
|
|
const sessionKey = getProjectSessionKey(basePath);
|
|
const sessionFile =
|
|
latestJsonlFile(join(sfHome, "agent", "sessions", sessionKey)) ??
|
|
latestJsonlFile(join(sfHome, "sessions", sessionKey));
|
|
const activityFile = latestJsonlFile(join(sfDir, "activity"));
|
|
|
|
const files: Array<SourceFile | null> = [
|
|
{ source: "notif", path: join(sfDir, "notifications.jsonl") },
|
|
sessionFile ? { source: "session", path: sessionFile } : null,
|
|
activityFile ? { source: "activity", path: activityFile } : null,
|
|
{ source: "audit", path: join(sfDir, "audit-log.jsonl") },
|
|
];
|
|
|
|
return files.filter(
|
|
(file): file is SourceFile => file !== null && existsSync(file.path),
|
|
);
|
|
}
|
|
|
|
function readRecentLines(filePath: string, maxBytes = 256 * 1024): string[] {
|
|
let content: string;
|
|
try {
|
|
const stat = statSync(filePath);
|
|
if (stat.size <= maxBytes) {
|
|
content = readFileSync(filePath, "utf-8");
|
|
} else {
|
|
const fd = openSync(filePath, "r");
|
|
try {
|
|
const buffer = Buffer.alloc(maxBytes);
|
|
readSync(fd, buffer, 0, maxBytes, stat.size - maxBytes);
|
|
content = buffer.toString("utf-8");
|
|
const firstNewline = content.indexOf("\n");
|
|
if (firstNewline >= 0) content = content.slice(firstNewline + 1);
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
} catch {
|
|
return [];
|
|
}
|
|
return content.split(/\n/).filter((line) => line.trim().length > 0);
|
|
}
|
|
|
|
function parseTimestamp(value: unknown): string {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return new Date(value).toISOString();
|
|
}
|
|
if (typeof value === "string" && value.trim()) {
|
|
const date = new Date(value);
|
|
if (!Number.isNaN(date.getTime())) return date.toISOString();
|
|
}
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function truncateOneLine(value: string, max = 220): string {
|
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
if (compact.length <= max) return compact;
|
|
return `${compact.slice(0, max - 3)}...`;
|
|
}
|
|
|
|
function textFromContent(content: unknown): string {
|
|
if (typeof content === "string") return content;
|
|
if (!Array.isArray(content)) return "";
|
|
return content
|
|
.filter(
|
|
(block): block is Record<string, unknown> =>
|
|
block && typeof block === "object",
|
|
)
|
|
.filter((block) => block.type === "text" || block.type === "thinking")
|
|
.map((block) => String(block.text ?? block.content ?? ""))
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
function toolNamesFromContent(content: unknown): string[] {
|
|
if (!Array.isArray(content)) return [];
|
|
return content
|
|
.filter(
|
|
(block): block is Record<string, unknown> =>
|
|
block && typeof block === "object",
|
|
)
|
|
.filter((block) => block.type === "toolCall")
|
|
.map((block) => String(block.name ?? block.toolName ?? ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function summarizeSessionLikeEntry(
|
|
source: LogSourceName,
|
|
entry: Record<string, unknown>,
|
|
filePath: string,
|
|
): MergedLogEvent | null {
|
|
const rawMessage = entry.message as Record<string, unknown> | undefined;
|
|
const message =
|
|
rawMessage && typeof rawMessage === "object" ? rawMessage : entry;
|
|
const role = String(message.role ?? "");
|
|
const entryType = String(entry.type ?? "");
|
|
|
|
if (entryType === "tool_execution_start") {
|
|
const toolName = String(entry.toolName ?? "tool");
|
|
return {
|
|
source,
|
|
timestamp: parseTimestamp(entry.timestamp ?? entry.ts),
|
|
message: `tool: ${toolName}`,
|
|
filePath,
|
|
};
|
|
}
|
|
|
|
if (entryType === "execution_complete") {
|
|
return {
|
|
source,
|
|
timestamp: parseTimestamp(entry.timestamp ?? entry.ts),
|
|
message: `execution complete: ${String(entry.status ?? "completed")}`,
|
|
severity: entry.status === "error" ? "error" : undefined,
|
|
filePath,
|
|
};
|
|
}
|
|
|
|
if (entryType === "message_update") {
|
|
const update = entry.assistantMessageEvent as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
const updateType = String(update?.type ?? "");
|
|
if (updateType === "text_delta" || updateType === "thinking_delta") {
|
|
const delta = String(update?.delta ?? update?.text ?? "");
|
|
if (!delta.trim()) return null;
|
|
return {
|
|
source,
|
|
timestamp: parseTimestamp(entry.timestamp ?? entry.ts),
|
|
message: `assistant: ${truncateOneLine(delta)}`,
|
|
filePath,
|
|
};
|
|
}
|
|
if (updateType === "toolcall_end") {
|
|
const toolCall = update?.toolCall as Record<string, unknown> | undefined;
|
|
const toolName = String(toolCall?.name ?? "tool");
|
|
return {
|
|
source,
|
|
timestamp: parseTimestamp(entry.timestamp ?? entry.ts),
|
|
message: `tool: ${toolName}`,
|
|
filePath,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (role === "assistant") {
|
|
const tools = toolNamesFromContent(message.content);
|
|
const text = truncateOneLine(textFromContent(message.content));
|
|
const stopReason =
|
|
typeof message.stopReason === "string" ? message.stopReason : undefined;
|
|
const parts: string[] = [];
|
|
if (text) parts.push(`assistant: ${text}`);
|
|
if (tools.length > 0) parts.push(`tools: ${tools.join(", ")}`);
|
|
if (stopReason) parts.push(`stop: ${stopReason}`);
|
|
if (parts.length === 0) return null;
|
|
return {
|
|
source,
|
|
timestamp: parseTimestamp(entry.timestamp ?? message.timestamp),
|
|
message: parts.join(" | "),
|
|
severity: stopReason === "error" ? "error" : undefined,
|
|
filePath,
|
|
};
|
|
}
|
|
|
|
if (role === "toolResult") {
|
|
const toolName = String(message.toolName ?? "");
|
|
if (!toolName) return null;
|
|
return {
|
|
source,
|
|
timestamp: parseTimestamp(entry.timestamp ?? message.timestamp),
|
|
message: `tool result: ${toolName}${message.isError === true ? " error" : ""}`,
|
|
severity: message.isError === true ? "error" : undefined,
|
|
filePath,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function parseLogLine(
|
|
source: LogSourceName,
|
|
line: string,
|
|
filePath: string,
|
|
): MergedLogEvent | null {
|
|
let entry: Record<string, unknown>;
|
|
try {
|
|
entry = JSON.parse(line) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (source === "notif") {
|
|
const message = String(entry.message ?? "");
|
|
if (!message.trim()) return null;
|
|
return {
|
|
source,
|
|
timestamp: parseTimestamp(entry.ts ?? entry.timestamp),
|
|
message: truncateOneLine(message),
|
|
severity: typeof entry.severity === "string" ? entry.severity : undefined,
|
|
filePath,
|
|
};
|
|
}
|
|
|
|
if (source === "audit") {
|
|
const component = String(entry.component ?? "audit");
|
|
const message = String(entry.message ?? entry.error ?? "");
|
|
if (!message.trim()) return null;
|
|
return {
|
|
source,
|
|
timestamp: parseTimestamp(entry.ts ?? entry.timestamp),
|
|
message: `[${component}] ${truncateOneLine(message)}`,
|
|
severity: typeof entry.severity === "string" ? entry.severity : "error",
|
|
filePath,
|
|
};
|
|
}
|
|
|
|
return summarizeSessionLikeEntry(source, entry, filePath);
|
|
}
|
|
|
|
function matchesFilters(
|
|
event: MergedLogEvent,
|
|
source?: LogSourceName,
|
|
severity?: string,
|
|
): boolean {
|
|
if (source && event.source !== source) return false;
|
|
if (
|
|
severity &&
|
|
(event.severity ?? "").toLowerCase() !== severity.toLowerCase()
|
|
)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
export function collectRecentLogEvents(
|
|
options: CollectRecentLogOptions,
|
|
): MergedLogEvent[] {
|
|
const sfHome = options.sfHome ?? sfHomeFromEnv();
|
|
const files = resolveSourceFiles(options.basePath, sfHome);
|
|
const limit = Math.max(1, options.limit ?? 50);
|
|
const events: MergedLogEvent[] = [];
|
|
|
|
for (const file of files) {
|
|
if (options.source && file.source !== options.source) continue;
|
|
for (const line of readRecentLines(file.path)) {
|
|
const event = parseLogLine(file.source, line, file.path);
|
|
if (event && matchesFilters(event, options.source, options.severity)) {
|
|
events.push(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
events.sort(
|
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
);
|
|
return events.slice(-limit);
|
|
}
|
|
|
|
export function formatMergedLogEvent(event: MergedLogEvent): string {
|
|
return `${event.timestamp} [${event.source}] ${event.message}\n`;
|
|
}
|
|
|
|
function parseLogsArgs(argv: string[]): ParsedLogsArgs | null {
|
|
const args = argv.slice(1);
|
|
const sub = args[0];
|
|
if (sub !== "tail" && sub !== "follow") return null;
|
|
|
|
const parsed: ParsedLogsArgs = { command: sub };
|
|
for (let i = 1; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg === "--source" && i + 1 < args.length) {
|
|
parsed.source = normalizeSource(args[++i]);
|
|
} else if (arg.startsWith("--source=")) {
|
|
parsed.source = normalizeSource(arg.slice("--source=".length));
|
|
} else if (arg === "--severity" && i + 1 < args.length) {
|
|
parsed.severity = args[++i];
|
|
} else if (arg.startsWith("--severity=")) {
|
|
parsed.severity = arg.slice("--severity=".length);
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function usage(): string {
|
|
return (
|
|
[
|
|
"Usage: sf logs tail|follow [--source notif|session|activity|audit] [--severity level]",
|
|
"",
|
|
"Streams notifications, latest session summaries, latest activity log, and audit errors.",
|
|
].join("\n") + "\n"
|
|
);
|
|
}
|
|
|
|
function readFromOffset(
|
|
filePath: string,
|
|
offset: number,
|
|
): { lines: string[]; offset: number } {
|
|
try {
|
|
const stat = statSync(filePath);
|
|
const start = stat.size < offset ? 0 : offset;
|
|
const length = stat.size - start;
|
|
if (length <= 0) return { lines: [], offset: stat.size };
|
|
|
|
const fd = openSync(filePath, "r");
|
|
try {
|
|
const buffer = Buffer.alloc(length);
|
|
readSync(fd, buffer, 0, length, start);
|
|
return {
|
|
lines: buffer
|
|
.toString("utf-8")
|
|
.split(/\n/)
|
|
.filter((line) => line.trim().length > 0),
|
|
offset: stat.size,
|
|
};
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
} catch {
|
|
return { lines: [], offset };
|
|
}
|
|
}
|
|
|
|
export async function runLogsCli(
|
|
argv: string[],
|
|
options: TailOptions,
|
|
): Promise<number> {
|
|
const parsed = parseLogsArgs(argv);
|
|
const stdout = options.stdout ?? process.stdout;
|
|
const stderr = options.stderr ?? process.stderr;
|
|
if (!parsed) {
|
|
stderr.write(usage());
|
|
return 1;
|
|
}
|
|
if (
|
|
parsed.source === undefined &&
|
|
argv.some((arg) => arg.startsWith("--source"))
|
|
) {
|
|
stderr.write("sf logs: unknown --source value\n");
|
|
return 1;
|
|
}
|
|
|
|
const source = parsed.source ?? options.source;
|
|
const severity = parsed.severity ?? options.severity;
|
|
const sfHome = options.sfHome ?? sfHomeFromEnv();
|
|
|
|
for (const event of collectRecentLogEvents({
|
|
basePath: options.basePath,
|
|
sfHome,
|
|
source,
|
|
severity,
|
|
limit: 50,
|
|
})) {
|
|
stdout.write(formatMergedLogEvent(event));
|
|
}
|
|
|
|
const offsets = new Map<string, number>();
|
|
const watched = new Map<string, SourceFile>();
|
|
|
|
const refreshWatches = () => {
|
|
for (const file of resolveSourceFiles(options.basePath, sfHome)) {
|
|
if (source && file.source !== source) continue;
|
|
if (watched.has(file.path)) continue;
|
|
watched.set(file.path, file);
|
|
try {
|
|
offsets.set(file.path, statSync(file.path).size);
|
|
} catch {
|
|
offsets.set(file.path, 0);
|
|
}
|
|
watchFile(file.path, { interval: 500 }, () => {
|
|
const currentOffset = offsets.get(file.path) ?? 0;
|
|
const result = readFromOffset(file.path, currentOffset);
|
|
offsets.set(file.path, result.offset);
|
|
for (const line of result.lines) {
|
|
const event = parseLogLine(file.source, line, file.path);
|
|
if (event && matchesFilters(event, source, severity)) {
|
|
stdout.write(formatMergedLogEvent(event));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
refreshWatches();
|
|
const refreshTimer = setInterval(refreshWatches, 1000);
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const stop = () => {
|
|
clearInterval(refreshTimer);
|
|
for (const path of watched.keys()) unwatchFile(path);
|
|
process.off("SIGINT", stop);
|
|
process.off("SIGTERM", stop);
|
|
resolve();
|
|
};
|
|
process.on("SIGINT", stop);
|
|
process.on("SIGTERM", stop);
|
|
});
|
|
|
|
return 0;
|
|
}
|