From a146230ef941ff1d191e353a452d4223af2feb5b Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Sat, 25 Jul 2026 16:51:48 +0200 Subject: [PATCH] feat(output): bound interface history by bytes --- .changeset/bounded-output-history.md | 5 + extensions/processes-dock/widget/setup.ts | 105 ++++++++++++------ .../components/log-file-viewer.test.ts | 13 +++ .../components/log-file-viewer.ts | 16 ++- .../components/log-overlay-component.test.ts | 6 +- .../components/log-overlay-component.ts | 1 + .../components/overview-component.ts | 78 ++++++++----- .../components/overview-render.test.ts | 45 +++++++- extensions/processes/config/defaults.ts | 1 + extensions/processes/config/schema.ts | 2 +- extensions/processes/config/types.ts | 1 + .../processes/handlers/subscriptions.test.ts | 30 +++++ .../processes/handlers/subscriptions.ts | 11 +- .../processes/settings/build-sections.test.ts | 2 +- .../processes/settings/build-sections.ts | 40 ++++++- extensions/shared/line-buffer.test.ts | 40 +++++++ extensions/shared/line-buffer.ts | 35 ++++++ extensions/shared/output-payload.test.ts | 27 +++++ extensions/shared/output-payload.ts | 28 +++++ src/protocol/requests.ts | 1 + 20 files changed, 407 insertions(+), 80 deletions(-) create mode 100644 .changeset/bounded-output-history.md create mode 100644 extensions/shared/line-buffer.test.ts create mode 100644 extensions/shared/line-buffer.ts create mode 100644 extensions/shared/output-payload.test.ts create mode 100644 extensions/shared/output-payload.ts diff --git a/.changeset/bounded-output-history.md b/.changeset/bounded-output-history.md new file mode 100644 index 0000000..dd81b92 --- /dev/null +++ b/.changeset/bounded-output-history.md @@ -0,0 +1,5 @@ +--- +"@aliou/pi-processes": minor +--- + +Bound in-memory process output by both line count and a new configurable byte budget. `/ps`, `/ps:logs`, and the dock now append live output without repeatedly reading full log files, and show a marker when a burst drops lines. diff --git a/extensions/processes-dock/widget/setup.ts b/extensions/processes-dock/widget/setup.ts index 887a307..734ec8e 100644 --- a/extensions/processes-dock/widget/setup.ts +++ b/extensions/processes-dock/widget/setup.ts @@ -6,11 +6,12 @@ import type { import { CHANNELS, type CommandPinPayload, - type ProcessesOutputChangedPayload, type ProcessProtocolNotificationPayload, } from "../../../src/protocol"; import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types"; import { isRecord } from "../../../src/utils/is-record"; +import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer"; +import { isOutputChangedPayload } from "../../shared/output-payload"; import { type ProcessLogLine, requestCombinedOutput, @@ -152,6 +153,13 @@ export function setupDockWidgets( const hardRefresh = () => { if (disposed) return; processes = sortProcesses(requestProcessList(events)); + const liveIds = new Set(processes.map((process) => process.id)); + for (const id of previews.keys()) { + if (!liveIds.has(id)) previews.delete(id); + } + for (const id of notifyMarkers.keys()) { + if (!liveIds.has(id)) notifyMarkers.delete(id); + } if (processes.some((process) => process.status === "running")) { hasSeenRunningProcess = true; } @@ -201,15 +209,19 @@ export function setupDockWidgets( } if (processLogStream.length > 0) return; - processLogStream = processes - .filter((process) => LIVE_STATUSES.has(process.status)) - .flatMap((process) => - requestCombinedOutput(events, process.id, 4).map((line) => ({ - processId: process.id, - line, - })), - ) - .slice(-config.output.maxOutputLines); + processLogStream = trimToBudget( + processes + .filter((process) => LIVE_STATUSES.has(process.status)) + .flatMap((process) => + requestCombinedOutput(events, process.id, 4).map((line) => ({ + processId: process.id, + line, + })), + ), + config.output.maxOutputLines, + config.output.maxOutputBytes, + (entry) => entry.line.text, + ); }; const syncPinnedConnection = () => { @@ -221,13 +233,17 @@ export function setupDockWidgets( pinnedConnection?.unsubscribe(); pinnedConnection = null; pinnedConnectionId = null; - pinnedLines = pinned - ? requestCombinedOutput( - events, - pinned.id, - config.output.defaultTailLines, - ) - : []; + pinnedLines = trimToBudget( + pinned + ? requestCombinedOutput( + events, + pinned.id, + config.output.defaultTailLines, + ) + : [], + config.output.maxOutputLines, + config.output.maxOutputBytes, + ); return; } @@ -241,24 +257,35 @@ export function setupDockWidgets( tailLines: config.output.defaultTailLines, }); if (isLogsConnectionError(connection)) { - pinnedLines = requestCombinedOutput( - events, - pinned.id, - config.output.defaultTailLines, + pinnedLines = trimToBudget( + requestCombinedOutput( + events, + pinned.id, + config.output.defaultTailLines, + ), + config.output.maxOutputLines, + config.output.maxOutputBytes, ); return; } pinnedConnection = connection; pinnedConnectionId = pinned.id; - pinnedLines = connection.initialLines; + pinnedLines = trimToBudget( + connection.initialLines, + config.output.maxOutputLines, + config.output.maxOutputBytes, + ); connection.onChunk((lines: ProcessLogLine[]) => { if (disposed) return; const id = pinnedConnectionId; if (!id) return; - pinnedLines = [...pinnedLines, ...lines].slice( - -config.output.maxOutputLines, + pinnedLines.push(...lines); + pinnedLines = trimToBudget( + pinnedLines, + config.output.maxOutputLines, + config.output.maxOutputBytes, ); const last = lines.at(-1); if (last) previews.set(id, last); @@ -308,16 +335,30 @@ export function setupDockWidgets( scheduleRefresh(); return; } - if (!payload.appendedText || payload.appendedText.length === 0) { + if ( + (!payload.appendedText || payload.appendedText.length === 0) && + !payload.droppedLines + ) { scheduleRefresh(); return; } - for (const line of payload.appendedText) { + const appended = [ + ...(payload.droppedLines + ? [buildDroppedOutputLine(payload.droppedLines)] + : []), + ...(payload.appendedText ?? []), + ]; + for (const line of appended) { previews.set(payload.id, line); processLogStream.push({ processId: payload.id, line }); } - processLogStream = processLogStream.slice(-config.output.maxOutputLines); + processLogStream = trimToBudget( + processLogStream, + config.output.maxOutputLines, + config.output.maxOutputBytes, + (entry) => entry.line.text, + ); render(); }; @@ -425,16 +466,6 @@ function isLogsConnectionError( return "ok" in connection && connection.ok === false; } -function isOutputChangedPayload( - payload: unknown, -): payload is ProcessesOutputChangedPayload { - return ( - isRecord(payload) && - typeof payload.id === "string" && - (payload.appendedText === undefined || Array.isArray(payload.appendedText)) - ); -} - function isLogMatchNotification( payload: unknown, ): payload is ProcessProtocolNotificationPayload & { diff --git a/extensions/processes-logs/components/log-file-viewer.test.ts b/extensions/processes-logs/components/log-file-viewer.test.ts index 5931c57..e8b4bb0 100644 --- a/extensions/processes-logs/components/log-file-viewer.test.ts +++ b/extensions/processes-logs/components/log-file-viewer.test.ts @@ -61,6 +61,19 @@ describe("LogFileViewer", () => { expect(lines).toEqual(["", "", "second", "third", "fourth"]); }); + it("trims the buffer by text budget", () => { + const viewer = new LogFileViewer( + [ + { type: "stdout", text: "old" }, + { type: "stdout", text: "keep" }, + ], + makeTheme(), + { followEnabled: false, maxBufferLines: 10, maxBufferBytes: 4 }, + ); + + expect(trimLines(viewer.render(20, 3))).toEqual(["", "", "keep"]); + }); + it("filters by stream", () => { const viewer = new LogFileViewer( [ diff --git a/extensions/processes-logs/components/log-file-viewer.ts b/extensions/processes-logs/components/log-file-viewer.ts index edd2c41..3f189b2 100644 --- a/extensions/processes-logs/components/log-file-viewer.ts +++ b/extensions/processes-logs/components/log-file-viewer.ts @@ -1,5 +1,6 @@ import type { Theme } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { trimToBudget } from "../../shared/line-buffer"; import type { ProcessLogLine } from "../logs-client"; export type StreamFilter = "both" | "stdout" | "stderr"; @@ -7,6 +8,7 @@ export type StreamFilter = "both" | "stdout" | "stderr"; interface LogFileViewerOptions { followEnabled: boolean; maxBufferLines: number; + maxBufferBytes?: number; } export class LogFileViewer { @@ -26,16 +28,22 @@ export class LogFileViewer { private readonly options: LogFileViewerOptions, ) { this.follow = options.followEnabled; - this.lines = initialLines.slice(-options.maxBufferLines); + this.lines = trimToBudget( + initialLines, + options.maxBufferLines, + options.maxBufferBytes ?? Number.MAX_SAFE_INTEGER, + ); this.refreshMatches(); } appendLines(lines: ProcessLogLine[]): void { if (lines.length === 0) return; this.lines.push(...lines); - if (this.lines.length > this.options.maxBufferLines) { - this.lines = this.lines.slice(-this.options.maxBufferLines); - } + this.lines = trimToBudget( + this.lines, + this.options.maxBufferLines, + this.options.maxBufferBytes ?? Number.MAX_SAFE_INTEGER, + ); this.refreshMatches(); if (this.follow) this.anchorEnd = null; } diff --git a/extensions/processes-logs/components/log-overlay-component.test.ts b/extensions/processes-logs/components/log-overlay-component.test.ts index 860c392..8efca15 100644 --- a/extensions/processes-logs/components/log-overlay-component.test.ts +++ b/extensions/processes-logs/components/log-overlay-component.test.ts @@ -45,7 +45,11 @@ function makeProcess(overrides: Partial = {}): ProcessInfo { */ function makeConfig() { return { - output: { defaultTailLines: 100, maxOutputLines: 2000 }, + output: { + defaultTailLines: 100, + maxOutputLines: 2000, + maxOutputBytes: 4 * 1024 * 1024, + }, follow: { enabledByDefault: true, autoHideOnFinish: false }, processList: { maxPreviewLines: 24, maxVisibleProcesses: 12 }, } as never; diff --git a/extensions/processes-logs/components/log-overlay-component.ts b/extensions/processes-logs/components/log-overlay-component.ts index 78f5714..8c73e60 100644 --- a/extensions/processes-logs/components/log-overlay-component.ts +++ b/extensions/processes-logs/components/log-overlay-component.ts @@ -415,6 +415,7 @@ export class LogOverlayComponent implements Component { viewer = new LogFileViewer(connection.initialLines, this.opts.theme, { followEnabled: this.opts.config.follow.enabledByDefault, maxBufferLines: this.opts.config.output.maxOutputLines, + maxBufferBytes: this.opts.config.output.maxOutputBytes, }); this.viewers.set(selected.id, viewer); this.pruneCachedViewers(); diff --git a/extensions/processes/components/overview-component.ts b/extensions/processes/components/overview-component.ts index 5e46f66..b5f8495 100644 --- a/extensions/processes/components/overview-component.ts +++ b/extensions/processes/components/overview-component.ts @@ -9,14 +9,11 @@ import { truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; -import { - CHANNELS, - type ProcessesOutputChangedPayload, - type ProcessProtocolConfig, -} from "../../../src/protocol"; +import { CHANNELS, type ProcessProtocolConfig } from "../../../src/protocol"; import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types"; import { formatRuntime, truncateCmd } from "../../../src/utils/format"; -import { isRecord } from "../../../src/utils/is-record"; +import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer"; +import { isOutputChangedPayload } from "../../shared/output-payload"; import { LineComponent, LinesComponent, statusColor } from "../../shared/ui"; import { type ProcessLogLine, @@ -197,7 +194,38 @@ export class OverviewComponent implements Component { if (!isOutputChangedPayload(payload)) return; const selected = this.selectedProcess(); if (!selected || selected.id !== payload.id) return; - this.refreshPreview(); + const appended = [ + ...(payload.droppedLines + ? [buildDroppedOutputLine(payload.droppedLines)] + : []), + ...(payload.appendedText ?? []), + ]; + if (appended.length === 0) return; + + const follow = this.isFollowingTail(); + const oldLength = this.previewLines.length; + this.previewLines.push(...appended); + const combinedLength = this.previewLines.length; + this.previewLines = trimToBudget( + this.previewLines, + MAX_PREVIEW_LINES, + this.opts.config.output.maxOutputBytes, + ); + if (follow) { + this.previewOffset = Math.max( + 0, + this.previewLines.length - this.previewHeight(), + ); + } else { + const removedFromHead = Math.min( + oldLength, + combinedLength - this.previewLines.length, + ); + this.previewOffset = Math.min( + Math.max(0, this.previewOffset - removedFromHead), + Math.max(0, this.previewLines.length - 1), + ); + } this.requestRender(); } @@ -287,18 +315,18 @@ export class OverviewComponent implements Component { // caller asked for newest), keep it there after reloading so new output // scrolls into view. Otherwise preserve the user's scroll position so a // deliberate K-scroll-up is not yanked back by the next output batch. - const oldTotal = this.previewLines.length; - const follow = - align === "newest" || - oldTotal === 0 || - this.previewOffset >= oldTotal - available; + const follow = align === "newest" || this.isFollowingTail(); const lines = requestCombinedOutput( this.opts.events, selected.id, this.opts.config.output.defaultTailLines, ); - this.previewLines = lines.slice(-MAX_PREVIEW_LINES); + this.previewLines = trimToBudget( + lines, + MAX_PREVIEW_LINES, + this.opts.config.output.maxOutputBytes, + ); const newTotal = this.previewLines.length; if (follow) { @@ -313,6 +341,13 @@ export class OverviewComponent implements Component { } } + private isFollowingTail(): boolean { + return ( + this.previewLines.length === 0 || + this.previewOffset >= this.previewLines.length - this.previewHeight() + ); + } + private cycleSort(): void { this.sort = OVERVIEW_SORTS[ @@ -586,13 +621,10 @@ export class OverviewComponent implements Component { const start = this.previewOffset; const slice = this.previewLines.slice(start, start + available); for (const line of slice) { + const text = + line.type === "stderr" ? t.fg("warning", line.text) : line.text; body.push( - truncateToWidth( - `${dim(PREVIEW_LOG_PREFIX)}${line.text}`, - width, - "", - true, - ), + truncateToWidth(`${dim(PREVIEW_LOG_PREFIX)}${text}`, width, "", true), ); } while (body.length < available + 1) body.push(""); @@ -700,11 +732,3 @@ export function applyOverviewView( } return copy; } - -// --- payload guards --- - -function isOutputChangedPayload( - payload: unknown, -): payload is ProcessesOutputChangedPayload { - return isRecord(payload) && typeof payload.id === "string"; -} diff --git a/extensions/processes/components/overview-render.test.ts b/extensions/processes/components/overview-render.test.ts index e58dfef..91439d6 100644 --- a/extensions/processes/components/overview-render.test.ts +++ b/extensions/processes/components/overview-render.test.ts @@ -23,7 +23,11 @@ function makeConfig() { execution: { shellPath: undefined }, interception: { blockBackgroundCommands: true }, processList: { maxPreviewLines: 24, maxVisibleProcesses: 12 }, - output: { defaultTailLines: 100, maxOutputLines: 2000 }, + output: { + defaultTailLines: 100, + maxOutputLines: 2000, + maxOutputBytes: 4 * 1024 * 1024, + }, follow: { enabledByDefault: true, autoHideOnFinish: false }, widget: { dockDefaultState: "closed" as const, dockHeight: 12 }, }; @@ -91,6 +95,11 @@ function makeEvents(processes: ProcessInfo[] = [], outputLines: string[] = []) { return; } }), + dispatch(channel: string, payload: unknown) { + for (const listener of listeners[channel] ?? []) { + listener(payload as never); + } + }, pinCalls, }; } @@ -448,4 +457,38 @@ describe("overview panel render width safety", () => { expect(body).not.toContain("out line 4"); expect(body).not.toContain("out line 1 "); }); + + it("appends output events without re-reading the log tail", () => { + const processes = [makeProcess({ id: "proc_1" })]; + const events = makeEvents(processes, ["initial"]); + const tui = { + requestRender: vi.fn(), + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + const requestsBefore = events.emit.mock.calls.filter( + ([channel]) => channel === CHANNELS.REQUEST_COMBINED_OUTPUT, + ).length; + + events.dispatch(CHANNELS.OUTPUT_CHANGED, { + id: "proc_1", + droppedLines: 2, + appendedText: [{ type: "stdout", text: "live line" }], + }); + + const body = component.render(112).map(stripAnsi).join("\n"); + expect(body).toContain("… 2 lines dropped (output too fast)"); + expect(body).toContain("live line"); + expect( + events.emit.mock.calls.filter( + ([channel]) => channel === CHANNELS.REQUEST_COMBINED_OUTPUT, + ), + ).toHaveLength(requestsBefore); + }); }); diff --git a/extensions/processes/config/defaults.ts b/extensions/processes/config/defaults.ts index 2f1300f..4fd13c3 100644 --- a/extensions/processes/config/defaults.ts +++ b/extensions/processes/config/defaults.ts @@ -14,6 +14,7 @@ export const DEFAULT_CONFIG: ProcessProtocolConfig = { output: { defaultTailLines: 100, maxOutputLines: 2000, + maxOutputBytes: 4 * 1024 * 1024, }, follow: { enabledByDefault: true, diff --git a/extensions/processes/config/schema.ts b/extensions/processes/config/schema.ts index 65c1140..33692d3 100644 --- a/extensions/processes/config/schema.ts +++ b/extensions/processes/config/schema.ts @@ -2,7 +2,7 @@ import { buildSchemaUrl } from "@aliou/pi-utils-settings"; import pkg from "../../../package.json" with { type: "json" }; -export const PROCESS_CONFIG_SCHEMA_VERSION = "0.10.0"; +export const PROCESS_CONFIG_SCHEMA_VERSION = "0.11.0"; export const PROCESS_CONFIG_SCHEMA_URL = buildSchemaUrl( pkg.name, PROCESS_CONFIG_SCHEMA_VERSION, diff --git a/extensions/processes/config/types.ts b/extensions/processes/config/types.ts index f1d6def..c604b3a 100644 --- a/extensions/processes/config/types.ts +++ b/extensions/processes/config/types.ts @@ -23,6 +23,7 @@ export interface ProcessListConfig { export interface OutputConfig { defaultTailLines?: number; maxOutputLines?: number; + maxOutputBytes?: number; } export interface FollowConfig { diff --git a/extensions/processes/handlers/subscriptions.test.ts b/extensions/processes/handlers/subscriptions.test.ts index bce3c1c..17c5236 100644 --- a/extensions/processes/handlers/subscriptions.test.ts +++ b/extensions/processes/handlers/subscriptions.test.ts @@ -158,6 +158,36 @@ describe("registerLogSubscriptions", () => { }); }); + it("prepends a visible marker when output lines were dropped", () => { + const events = createEventBus(); + const fake = createFakeManager(); + const chunk = vi.fn(); + + registerLogSubscriptions(events, fake.manager); + events.on(CHANNELS.LOGS_CHUNK, chunk); + events.emit(CHANNELS.LOGS_SUBSCRIBE, { + subscriberId: "sub_1", + processId: "proc_1", + reply: vi.fn(), + }); + + fake.emit({ + type: "process_output_changed", + id: "proc_1", + appendedText: [{ type: "stdout", text: "latest" }], + droppedLines: 4, + }); + + expect(chunk).toHaveBeenCalledWith({ + subscriberId: "sub_1", + processId: "proc_1", + lines: [ + { type: "stderr", text: "… 4 lines dropped (output too fast)" }, + { type: "stdout", text: "latest" }, + ], + }); + }); + it("purges subscribers when processes end or disappear", () => { const events = createEventBus(); const fake = createFakeManager(); diff --git a/extensions/processes/handlers/subscriptions.ts b/extensions/processes/handlers/subscriptions.ts index 70b904d..3f85617 100644 --- a/extensions/processes/handlers/subscriptions.ts +++ b/extensions/processes/handlers/subscriptions.ts @@ -7,6 +7,7 @@ import { type LogsUnsubscribePayload, } from "../../../src/protocol"; import { isRecord } from "../../../src/utils/is-record"; +import { buildDroppedOutputLine } from "../../shared/line-buffer"; interface LogSubscriber { subscriberId: string; @@ -67,7 +68,13 @@ export function registerLogSubscriptions( } if (event.type !== "process_output_changed") return; - if (!event.appendedText || event.appendedText.length === 0) return; + const lines = [ + ...(event.droppedLines + ? [buildDroppedOutputLine(event.droppedLines)] + : []), + ...(event.appendedText ?? []), + ]; + if (lines.length === 0) return; for (const subscriber of subscribers.values()) { if (subscriber.processId !== event.id) continue; @@ -75,7 +82,7 @@ export function registerLogSubscriptions( events.emit(CHANNELS.LOGS_CHUNK, { subscriberId: subscriber.subscriberId, processId: subscriber.processId, - lines: event.appendedText, + lines, }); } }); diff --git a/extensions/processes/settings/build-sections.test.ts b/extensions/processes/settings/build-sections.test.ts index 0b7d7ef..6dbcb4d 100644 --- a/extensions/processes/settings/build-sections.test.ts +++ b/extensions/processes/settings/build-sections.test.ts @@ -51,7 +51,7 @@ describe("buildSections", () => { .find((s) => s.label === "Interfaces") ?.items.find((item) => item.id === "logs.details"); - expect(logsItem?.currentValue).toBe("500 lines · 12 rows · manual"); + expect(logsItem?.currentValue).toBe("500 lines · 4 MB · 12 rows · manual"); expect(logsItem?.submenu).toBeTypeOf("function"); }); }); diff --git a/extensions/processes/settings/build-sections.ts b/extensions/processes/settings/build-sections.ts index 7b4a26a..96f5fa2 100644 --- a/extensions/processes/settings/build-sections.ts +++ b/extensions/processes/settings/build-sections.ts @@ -185,35 +185,49 @@ function buildLogsDetailItem( resolved.processList.maxPreviewLines; const historyLines = scopedConfig.output?.maxOutputLines ?? resolved.output.maxOutputLines; + const historyBudgetMb = Math.max( + 1, + Math.round( + (scopedConfig.output?.maxOutputBytes ?? resolved.output.maxOutputBytes) / + (1024 * 1024), + ), + ); const followByDefault = scopedConfig.follow?.enabledByDefault ?? resolved.follow.enabledByDefault; return { id: "logs.details", label: "Logs overlay", - currentValue: `${historyLines} lines · ${viewportRows} rows · ${followByDefault ? "follow" : "manual"}`, + currentValue: `${historyLines} lines · ${historyBudgetMb} MB · ${viewportRows} rows · ${followByDefault ? "follow" : "manual"}`, description: "Open focused settings for /ps:logs tabs, history, viewport, and follow behavior.", submenu: (_current, done) => { const current = scopedConfig; let nextViewportRows = String(viewportRows); let nextHistoryLines = String(historyLines); + let nextHistoryBudgetMb = String(historyBudgetMb); + let historyBudgetEdited = false; let nextFollowByDefault = followByDefault; let nextAutoHideOnFinish = scopedConfig.follow?.autoHideOnFinish ?? resolved.follow.autoHideOnFinish; const syncDraft = () => { + const output = { + ...current.output, + maxOutputLines: parsePositiveInt(nextHistoryLines), + }; + if (historyBudgetEdited) { + output.maxOutputBytes = + parsePositiveInt(nextHistoryBudgetMb) * 1024 * 1024; + } const updated: ProcessConfig = { ...current, processList: { ...current.processList, maxPreviewLines: parsePositiveInt(nextViewportRows), }, - output: { - ...current.output, - maxOutputLines: parsePositiveInt(nextHistoryLines), - }, + output, follow: { ...current.follow, enabledByDefault: nextFollowByDefault, @@ -253,6 +267,20 @@ function buildLogsDetailItem( }, validate: positiveIntegerError, }, + { + id: "logs.historyBudgetMb", + type: "text", + label: "History budget (MB)", + description: + "Maximum in-memory log history retained per process view.", + getValue: () => nextHistoryBudgetMb, + setValue: (value) => { + nextHistoryBudgetMb = value; + historyBudgetEdited = true; + syncDraft(); + }, + validate: positiveIntegerError, + }, { id: "logs.followByDefault", type: "boolean", @@ -278,7 +306,7 @@ function buildLogsDetailItem( }, ], getDoneSummary: () => - `${parsePositiveInt(nextHistoryLines)} lines · ${parsePositiveInt(nextViewportRows)} rows · ${nextFollowByDefault ? "follow" : "manual"}`, + `${parsePositiveInt(nextHistoryLines)} lines · ${parsePositiveInt(nextHistoryBudgetMb)} MB · ${parsePositiveInt(nextViewportRows)} rows · ${nextFollowByDefault ? "follow" : "manual"}`, onDone: (summary) => done(summary), }); }, diff --git a/extensions/shared/line-buffer.test.ts b/extensions/shared/line-buffer.test.ts new file mode 100644 index 0000000..3cae113 --- /dev/null +++ b/extensions/shared/line-buffer.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { trimToBudget } from "./line-buffer"; + +const line = (text: string) => ({ text }); + +describe("trimToBudget", () => { + it("bounds by line count", () => { + expect( + trimToBudget([line("one"), line("two"), line("three")], 2, 100), + ).toEqual([line("two"), line("three")]); + }); + + it("bounds by text budget", () => { + expect( + trimToBudget([line("1234"), line("5678"), line("90")], 10, 6), + ).toEqual([line("5678"), line("90")]); + }); + + it("measures UTF-8 bytes", () => { + expect(trimToBudget([line("old"), line("€€")], 10, 5)).toEqual([ + line("€€"), + ]); + }); + + it("applies both bounds", () => { + expect( + trimToBudget([line("old"), line("1234"), line("5678"), line("90")], 3, 6), + ).toEqual([line("5678"), line("90")]); + }); + + it("keeps one oversized newest line", () => { + expect(trimToBudget([line("old"), line("oversized")], 10, 2)).toEqual([ + line("oversized"), + ]); + }); + + it("returns an empty array for empty input", () => { + expect(trimToBudget([], 10, 10)).toEqual([]); + }); +}); diff --git a/extensions/shared/line-buffer.ts b/extensions/shared/line-buffer.ts new file mode 100644 index 0000000..79ef2af --- /dev/null +++ b/extensions/shared/line-buffer.ts @@ -0,0 +1,35 @@ +export const OUTPUT_DROPPED_MARKER_TYPE = "stderr" as const; + +export function buildDroppedOutputLine(count: number): { + type: typeof OUTPUT_DROPPED_MARKER_TYPE; + text: string; +} { + return { + type: OUTPUT_DROPPED_MARKER_TYPE, + text: `… ${count} lines dropped (output too fast)`, + }; +} + +export function trimToBudget( + lines: T[], + maxLines: number, + maxBytes: number, + textOf: (line: T) => string = (line) => (line as { text: string }).text, +): T[] { + if (lines.length === 0) return []; + + const lineLimit = Math.max(1, maxLines); + const out = + lines.length > lineLimit ? lines.slice(-lineLimit) : lines.slice(); + let bytes = 0; + + for (let index = out.length - 1; index >= 0; index--) { + bytes += Buffer.byteLength(textOf(out[index]), "utf-8"); + if (bytes > maxBytes) { + // Keep the newest line even when it alone exceeds the whole budget. + return index === out.length - 1 ? out.slice(index) : out.slice(index + 1); + } + } + + return out; +} diff --git a/extensions/shared/output-payload.test.ts b/extensions/shared/output-payload.test.ts new file mode 100644 index 0000000..6606fb5 --- /dev/null +++ b/extensions/shared/output-payload.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { isOutputChangedPayload } from "./output-payload"; + +describe("isOutputChangedPayload", () => { + it("accepts valid optional output fields", () => { + expect(isOutputChangedPayload({ id: "proc_1" })).toBe(true); + expect( + isOutputChangedPayload({ + id: "proc_1", + appendedText: [{ type: "stdout", text: "line" }], + droppedLines: 2, + }), + ).toBe(true); + }); + + it("rejects malformed lines and drop counts", () => { + expect(isOutputChangedPayload({ id: "proc_1", appendedText: [null] })).toBe( + false, + ); + expect( + isOutputChangedPayload({ id: "proc_1", droppedLines: Infinity }), + ).toBe(false); + expect(isOutputChangedPayload({ id: "proc_1", droppedLines: 1.5 })).toBe( + false, + ); + }); +}); diff --git a/extensions/shared/output-payload.ts b/extensions/shared/output-payload.ts new file mode 100644 index 0000000..7b59a77 --- /dev/null +++ b/extensions/shared/output-payload.ts @@ -0,0 +1,28 @@ +import type { ProcessesOutputChangedPayload } from "../../src/protocol"; +import { isRecord } from "../../src/utils/is-record"; + +export function isOutputChangedPayload( + payload: unknown, +): payload is ProcessesOutputChangedPayload { + return ( + isRecord(payload) && + typeof payload.id === "string" && + (payload.appendedText === undefined || + (Array.isArray(payload.appendedText) && + payload.appendedText.every(isOutputLine))) && + (payload.droppedLines === undefined || + (typeof payload.droppedLines === "number" && + Number.isSafeInteger(payload.droppedLines) && + payload.droppedLines > 0)) + ); +} + +function isOutputLine( + value: unknown, +): value is { type: "stdout" | "stderr"; text: string } { + return ( + isRecord(value) && + (value.type === "stdout" || value.type === "stderr") && + typeof value.text === "string" + ); +} diff --git a/src/protocol/requests.ts b/src/protocol/requests.ts index c75effb..cc30c33 100644 --- a/src/protocol/requests.ts +++ b/src/protocol/requests.ts @@ -58,6 +58,7 @@ export interface ProcessProtocolConfig { output: { defaultTailLines: number; maxOutputLines: number; + maxOutputBytes: number; }; follow: { enabledByDefault: boolean; -- 2.51.2