diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index beb06a8886..bbe4ab9e02 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -16,7 +16,7 @@ and password-reconciliation connection uses that administrative role, so a nativ | `supabase stack prepare` | Download artifacts without starting services. | | `supabase stack start` | Create or resume the project's stack. | | `supabase stack status` | Show identity, readiness, and drift, or export connection variables with `--env`. | -| `supabase stack logs` | Stream live stack logs. | +| `supabase stack logs` | Print retained stack logs; `-f` streams new lines. | | `supabase stack restart` | Restart an existing stack using its saved effective configuration. | | `supabase stack stop` | Stop a stack while retaining its data. | @@ -161,7 +161,7 @@ Other values are rejected. The override is applied before reading the project co `supabase services` follows the same backend selection. In stack mode it lists image versions and canonical `ghcr.io/supabase/cli/...` names from the installed CLI's artifact catalog, including -Mailpit and Vector. PostgreSQL uses the selected major version (15 or 17); invalid configuration +Mailpit. PostgreSQL uses the selected major version (15 or 17); invalid configuration or an unsupported PostgreSQL major warns with the cause and uses catalog defaults. This inventory describes the CLI catalog, not running containers, downloaded images, or service health. The Docker and native stack runtimes use the same catalog versions, though a running stack launched by another CLI @@ -238,18 +238,25 @@ lint transaction (always rolled back). It does not launch a client binary. ## Reading stack logs -`supabase stack logs` streams live stdout/stderr from composition members without -starting an owner or service. Select `--stack ` or `--stack-id `; -`--service ` can include standalone services too. The command -requires a reachable owner and streams until interrupted. Ctrl-C leaves services -running. There is no retained history, `--tail`, or `--follow` flag. - -Text uses ` //: ` and strips terminal -control sequences. For automation use `--output-format stream-json`: each -`log-entry` contains `timestamp`, `service`, `instance_id`, `stream`, `line`, and -`source: "live"`. Finite JSON output is not supported. Delivery is best effort; -stdout/stderr and different services may interleave. Missing stacks, unavailable -owners, and unmatched services fail with status 1; interruption exits 130. +`supabase stack logs` prints the retained stdout/stderr of composition members and +exits; `-f/--follow` then streams new lines until interrupted. Select `--stack ` +or `--stack-id `; the repeatable `--service ` can include +standalone services too. History is read from the persisted log files, so it works +while the stack is stopped; `--follow` requires a running owner and fails before +printing anything without one. Neither mode starts an owner or service, and Ctrl-C +leaves services running. + +`--tail N` (default 200) keeps the newest lines across the selected services and +`--since` takes a duration (`10m`, `1h30m`), an ISO-8601 time, or `start` for each +service's latest launch. Text uses ` | ` with aligned +labels, shows launches and lost output as dim separators, strips terminal control +sequences, and notes on stderr when the tail hides older lines. For automation use +`--output-format stream-json`: each `log-entry` contains `timestamp`, `service`, +`instance_id`, `stream`, `line`, and `source` (`history` or `live`), and markers are +`log-marker` events with `kind` and, for lost output, `count`. `--output-format json` +prints the history as one array and does not accept `--follow`. Missing stacks, +unmatched services, and `--follow` without an owner fail with status 1; interruption +exits 130. ## Data and configuration diff --git a/apps/cli/src/command-internal/colors.ts b/apps/cli/src/command-internal/colors.ts index 50addc7657..b1b6933cf1 100644 --- a/apps/cli/src/command-internal/colors.ts +++ b/apps/cli/src/command-internal/colors.ts @@ -77,3 +77,18 @@ export function green(text: string, stream: ColorStream = process.stderr): strin export function gray(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("gray", text, { validateStream: false }) : text; } + +/** Renders in magenta. */ +export function magenta(text: string, stream: ColorStream = process.stderr): string { + return supportsColor(stream) ? styleText("magenta", text, { validateStream: false }) : text; +} + +/** Renders in blue. */ +export function blue(text: string, stream: ColorStream = process.stderr): string { + return supportsColor(stream) ? styleText("blue", text, { validateStream: false }) : text; +} + +/** Renders dimmed. */ +export function dim(text: string, stream: ColorStream = process.stderr): string { + return supportsColor(stream) ? styleText("dim", text, { validateStream: false }) : text; +} diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 9e8a09e3ae..1002a2b5d7 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -465,7 +465,7 @@ describe("dbConfigResolver (db-url under the stack backend)", () => { registered: true, }), followStatus: Stream.empty, - logs: Stream.empty, + readLogs: () => Stream.empty, credentials: () => unused, saveSnapshot: () => unused, restoreSnapshot: () => unused, diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts index 59a2a106ed..035d2546fe 100644 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ b/apps/cli/src/command-internal/db-target-flags.ts @@ -139,6 +139,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ // experimental stack flags "capability", "service", + "since", "stack", "stack-id", "preparation", diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index bf8ddcd138..3009d58552 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -228,12 +228,6 @@ const ANALYTICS_PORT: PortSetting = { key: "port", configPath: "analytics.port", }; -const ANALYTICS_VECTOR_PORT: PortSetting = { - envVar: "SUPABASE_ANALYTICS_VECTOR_PORT", - section: "analytics", - key: "vector_port", - configPath: "analytics.vector_port", -}; const EDGE_RUNTIME_INSPECTOR_PORT: PortSetting = { envVar: "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", section: "edge_runtime", @@ -268,7 +262,6 @@ const endpointSettingsByServiceEndpoint: Readonly { + const subject = { service: record.service, instance_id: record.instanceId }; + if (record.kind === "stdout" || record.kind === "stderr") + return { + type: "log-entry", + timestamp: record.timestamp, + source, + ...subject, + stream: record.kind, + line: record.text ?? "", + }; + return { + type: "log-marker", + timestamp: record.timestamp, + source, + ...subject, + kind: record.kind, + ...(record.stream === undefined ? {} : { stream: record.stream }), + ...(record.count === undefined ? {} : { count: record.count }), + }; +}; + +/** The text of a launch or lost marker, as `stack logs` prints it. */ +export const markerText = (record: LogRecord) => { + if (record.kind === "launch") + return record.launchId === undefined ? "--- launch ---" : `--- launch ${record.launchId} ---`; + if (record.count === undefined) return "--- older records were removed by retention ---"; + const unit = record.count === 1 ? "chunk" : "chunks"; + return `--- ${record.count} ${record.stream ?? "output"} ${unit} lost ---`; +}; diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index af9f38fc3b..e020809e10 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -124,7 +124,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { registered: true, }), followStatus: Stream.empty, - logs: Stream.empty, + readLogs: () => Stream.empty, credentials: () => Effect.succeed({ databaseUrl: "postgresql://postgres:secret@127.0.0.1:54322/postgres" }), saveSnapshot: () => Effect.die("unused"), diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 4e960d99b1..1f73deb1b8 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -565,7 +565,7 @@ const stackService = ( prepare: Effect.void, status: observation, followStatus: Stream.empty, - logs: Stream.empty, + readLogs: () => Stream.empty, credentials: () => Effect.succeed({}), } satisfies Omit; switch (creation.service) { diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 7344c9711b..f6838085f6 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -112,7 +112,7 @@ function generateStackApi(workdir: string) { saveSnapshot: unusedStackFn, restoreSnapshot: unusedStackFn, resetData: unusedStack, - logs: Stream.empty, + readLogs: () => Stream.empty, followStatus: Stream.empty, status: Effect.succeed({ id: "primary", diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 85ebc7eaaa..c7035c2c4f 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -114,7 +114,7 @@ function syncStackApi(workdir: string, port: number) { saveSnapshot: unusedSyncFn, restoreSnapshot: unusedSyncFn, resetData: unusedSync, - logs: Stream.empty, + readLogs: () => Stream.empty, followStatus: Stream.empty, status: Effect.succeed({ id: "primary", diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index debb4eae72..48f591f4dc 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1633,7 +1633,7 @@ describe("db start stack backend", () => { registered: true, })), followStatus: Stream.empty, - logs: Stream.empty, + readLogs: () => Stream.empty, credentials: () => Effect.sync(() => { state.credentialsCalled = true; diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md index 36fd889193..d924a73b05 100644 --- a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -1,42 +1,80 @@ # `supabase stack logs` -Streams live stdout/stderr from the selected saved stack. The experimental -feature flag controls command registration. It connects to an existing owner; -it never launches an owner or starts/stops a service. +Prints the retained service logs of the selected saved stack and exits; with +`-f/--follow` it then streams new lines until interrupted. The experimental +feature flag controls command registration. It never launches an owner or +starts/stops a service. ## Selection and files Select the current project/branch/name, `--stack `, or `--stack-id `. The selectors are mutually exclusive. By default, only composition members are -included. `--service ` can also select standalone instances. -An unavailable owner, missing stack, or unmatched service fails with status 1. +included. `--service ` is repeatable and can also select +standalone instances; a value that matches no saved instance fails with status 1. +A missing stack fails with status 1. -Reads saved definitions under `/stacks//`. -Discovery ensures the registry directory exists with mode 0700 and probes local -owners. Shared routing/settings may read project config and profiles. No project -files, service configuration, artifacts, or data are changed. +Reads saved definitions under `/stacks//` and +the persisted segments under +`/stacks//logs///.log` +directly, so history is readable while the owner is down. Discovery ensures the +registry directory exists with mode 0700 and probes local owners. Shared +routing/settings may read project config and profiles. No project files, +service configuration, artifacts, logs, or data are changed. -## Output and cancellation +## History, follow, and flags -This is a live-only stream with no retained history, cursor, or `--tail` option. -It continues until interrupted or the selected streams close. `--follow` is -unnecessary and is not accepted. Legacy `-o/--output` and finite JSON output are -rejected; use text or `--output-format stream-json`. +`--tail N` (default 200, 0 prints none) keeps the newest N output lines across +the selected services, with the `launch` and `lost` markers between them. +`--since` accepts a duration before now (`30s`, `10m`, `1h30m`, `2d`), an +ISO-8601 time, or `start`, which keeps the records of each instance's current +launch and later ones, in history and while following. The current launch is the +launch id saved in the stack definition, which keeps increasing across owner +restarts, or else the highest launch record in history (all records when +retention removed that launch record). Records are +ordered by timestamp, service, instance, and file position. Timestamps are the +owner's clock at each line's first byte. Lines end at `\n`, `\r\n`, or a lone +`\r`, so carriage-return progress updates print as separate lines. History is +read as a stream holding at most N lines per instance, while every matching line +is counted for the footer. -Text writes ` //: `. Terminal -control sequences are stripped from text. Stream JSON emits `log-entry` events -with `timestamp`, `service`, `instance_id`, `stream`, `line`, and `source: "live"`. -Lines preserve their content in machine output. UTF-8 and line fragments are -assembled separately for each instance and stdout/stderr channel. Timestamps -reflect receipt by the CLI. Delivery is best effort: slow subscribers can lose entries. Ordering across -stdout/stderr channels and different services is not guaranteed. +`-f/--follow` requires a reachable owner: without one it fails with status 1 +before printing anything and suggests running without `--follow`. It prints the +history, then streams each instance's new records through the owner from the +last record read, so no record is repeated or skipped between history and live +output. With `--tail 0` no history is read and the owner starts each follow at +its current end. A selected instance the running owner does not serve is named +in a warning and not followed; when it serves none of them, the command fails +with status 1 after printing the history. The owner's log shipping to Analytics reads the persisted logs +separately and does not affect this command. -Interrupting the command cancels its subscriptions, exits with status 130, and -leaves the owner and services running. Successful stream completion exits 0; -selection, connection, or stream failures exit 1. +## Output + +Text writes ` | ` with the service labels padded +to one width; a service kind selected more than once is labelled +`:`. Times are local. `launch` +and `lost` records are dim separator lines (`--- launch 2 ---`, +`--- 3 output chunks lost ---`, `--- older records were removed by retention ---`). +Labels are coloured and markers dimmed only when stdout is a colour-capable +terminal. Terminal control sequences are stripped from text. When a non-zero +tail hides older lines, stderr gets +`showing last 200 of 5,234 lines, use --tail/--since`; an empty history without +`--follow` prints `No retained log lines for the selected services.` on stderr. + +Stream JSON emits a `log-entry` event per output line with `timestamp`, +`service`, `instance_id`, `stream` (`stdout` or `stderr`), `line`, and `source` +(`history` or `live`), and a `log-marker` event per marker with `timestamp`, +`service`, `instance_id`, `kind` (`launch` or `lost`), `source`, and, for chunks +lost before they were written, `count` and, when known, `stream`. A `lost` marker without +`count` reports segments removed by retention. `--output-format json` prints +one array of the same objects; it cannot be combined with `--follow`. Lines +preserve their content in machine output. Legacy `-o/--output` is rejected. + +Interrupting a follow cancels its subscriptions, exits with status 130, and +leaves the owner and services running. History reads and completed follows exit +0; selection, owner, or read failures exit 1. ## Telemetry -Standard command telemetry is retained; log contents are not custom telemetry -properties. Telemetry flushes on every exit to +Standard command telemetry is retained, with `-f` reported as `follow`; log +contents are not custom telemetry properties. Telemetry flushes on every exit to `/telemetry.json`. diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index 1a4c8bc34d..8b707c123b 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -17,7 +17,30 @@ const config = { ), service: Flag.string("service").pipe( Flag.withDescription( - "Stream one service kind or instance ID; defaults to composition members.", + "Read one service kind or instance ID; repeat to select several. Defaults to composition members.", + ), + Flag.atLeast(0), + ), + follow: Flag.boolean("follow").pipe( + Flag.withAlias("f"), + Flag.withDescription( + "After the history, keep streaming new lines until interrupted; requires a running stack.", + ), + Flag.withDefault(false), + ), + tail: Flag.integer("tail").pipe( + Flag.filter( + (tail) => tail >= 0, + (tail) => `Expected --tail to be 0 or more, got ${tail}`, + ), + Flag.withDescription( + "Number of retained lines to print across the selected services (default 200); 0 prints none.", + ), + Flag.withDefault(200), + ), + since: Flag.string("since").pipe( + Flag.withDescription( + "Only print lines since a duration ago (10m, 1h30m), an ISO-8601 time, or start for each service's latest launch.", ), Flag.optional, ), @@ -26,19 +49,28 @@ const config = { export type StackLogsFlags = CliCommand.Command.Config.Infer; export const stackLogsCommand = Command.make("logs", config).pipe( - Command.withDescription("Stream live logs until interrupted; retained history is unavailable."), - Command.withShortDescription("Stream managed local stack logs"), + Command.withDescription( + "Print retained service logs and exit; with --follow, keep streaming new lines. History is readable while the stack is stopped.", + ), + Command.withShortDescription("Show managed local stack logs"), Command.withExamples([ { - command: "supabase stack logs --service database", - description: "Stream new database logs", + command: "supabase stack logs --service database --since 10m", + description: "Print database lines from the last ten minutes", }, { - command: "supabase stack logs --output-format stream-json", - description: "Stream new stack logs as structured events", + command: "supabase stack logs -f --tail 0", + description: "Stream only new lines until interrupted", + }, + { + command: "supabase stack logs --follow --output-format stream-json", + description: "Stream history and new lines as structured events", }, ]), Command.withHandler((flags) => - stackLogs(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), + stackLogs(flags).pipe( + withCommandTelemetry({ flags, config, aliases: { f: "follow" } }), + withJsonErrorHandling, + ), ), ); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.format.ts b/apps/cli/src/commands/experimental/stack/logs/logs.format.ts new file mode 100644 index 0000000000..d1a204c66f --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.format.ts @@ -0,0 +1,241 @@ +import { DateTime, Option } from "effect"; +import type { LogPosition, LogRecord, StackLogRecord } from "@supabase/stack/effect"; +import { + aqua, + blue, + type ColorStream, + dim, + green, + magenta, + yellow, +} from "../../../../command-internal/colors.ts"; +import { stripControlSequences } from "../../../../shared/output/strip-control-sequences.ts"; +import { markerText } from "../../../../command-internal/stack-log-events.ts"; + +/** A `--since` bound: an absolute time, or each instance's latest launch. */ +export type SinceBound = + | { readonly kind: "time"; readonly iso: string } + | { readonly kind: "start" }; + +const durationShape = /^(?:\d+(?:\.\d+)?(?:ms|s|m|h|d))+$/u; +const durationPart = /(\d+(?:\.\d+)?)(ms|s|m|h|d)/gu; +const unitMillis: Readonly> = { + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, +}; +const isoDate = /^\d{4}-\d{2}-\d{2}/u; + +/** Parses `start`, a duration before `nowMillis` such as `1h30m`, or an ISO-8601 time. */ +export const parseSince = (value: string, nowMillis: number): SinceBound | undefined => { + if (value === "start") return { kind: "start" }; + if (durationShape.test(value)) { + let millis = 0; + for (const [, amount, unit] of value.matchAll(durationPart)) + millis += Number(amount) * (unitMillis[unit ?? ""] ?? 0); + return Option.match(DateTime.make(nowMillis - millis), { + onNone: () => undefined, + onSome: (time) => ({ kind: "time", iso: DateTime.formatIso(time) }), + }); + } + if (!isoDate.test(value)) return undefined; + return Option.match(DateTime.make(value), { + onNone: () => undefined, + onSome: (time) => ({ kind: "time", iso: DateTime.formatIso(time) }), + }); +}; + +const isLine = (record: LogRecord) => record.kind === "stdout" || record.kind === "stderr"; + +/** The newest `tail` output lines, with the markers between them, and how many lines exist. */ +export interface LogWindow { + readonly records: ReadonlyArray; + readonly shown: number; + readonly total: number; +} + +const tailWindow = (records: ReadonlyArray, tail: number): LogWindow => { + const total = records.filter(isLine).length; + if (total <= tail) return { records, shown: total, total }; + let seen = 0; + let start = records.length; + while (seen < tail && start > 0) { + start -= 1; + const record = records[start]; + if (record !== undefined && isLine(record)) seen += 1; + } + return { records: seen === 0 ? [] : records.slice(start), shown: seen, total }; +}; + +const comparePositions = (left: LogPosition, right: LogPosition) => + left.generation - right.generation || left.byteOffset - right.byteOffset; + +const origin: LogPosition = { generation: 0, byteOffset: 0 }; +const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); +const compareRecords = (left: StackLogRecord, right: StackLogRecord) => + compareText(left.timestamp, right.timestamp) || + compareText(left.service, right.service) || + compareText(left.instanceId, right.instanceId) || + comparePositions( + left.position ?? left.resumeAt ?? origin, + right.position ?? right.resumeAt ?? origin, + ); + +interface Launch { + readonly id: number; + /** Unknown until its launch record is read; a saved launch may not have written one. */ + readonly timestamp: string | undefined; +} + +interface InstanceTail { + records: Array; + head: number; + lines: number; + total: number; + launch: Launch | undefined; +} + +/** Whether a record belongs to `launchId` or a later launch; a gap marker carries no launch id. */ +export const isFromLaunch = (record: LogRecord, launchId: number | undefined) => + launchId === undefined || record.launchId === undefined || record.launchId >= launchId; + +/** Launch ids increase per instance; a gap marker carries none, so its time decides. */ +const fromLaunch = (record: StackLogRecord, launch: Launch) => + record.launchId === undefined + ? launch.timestamp === undefined || record.timestamp >= launch.timestamp + : isFromLaunch(record, launch.id); + +/** + * Collects each instance's newest `tail` lines and last position. With `startLaunches`, it keeps + * only the records of each instance's current launch: its saved launch id, or else its highest + * launch record, which `launches` reports for following. + */ +export const makeHistoryCollector = ( + tail: number, + startLaunches: ReadonlyMap | undefined, +) => { + const instances = new Map(); + const positions = new Map(); + const launches = new Map(startLaunches); + const push = (record: StackLogRecord) => { + const position = record.position; + const last = positions.get(record.instanceId); + if (position !== undefined && (last === undefined || comparePositions(position, last) > 0)) + positions.set(record.instanceId, position); + let state = instances.get(record.instanceId); + if (state === undefined) { + const saved = startLaunches?.get(record.instanceId); + state = { + records: [], + head: 0, + lines: 0, + total: 0, + launch: saved === undefined ? undefined : { id: saved, timestamp: undefined }, + }; + instances.set(record.instanceId, state); + } + if (startLaunches !== undefined) { + const latest = state.launch; + if ( + record.kind === "launch" && + record.launchId !== undefined && + (latest === undefined || + record.launchId > latest.id || + (record.launchId === latest.id && latest.timestamp === undefined)) + ) { + const launch = { id: record.launchId, timestamp: record.timestamp }; + state.records = state.records.slice(state.head).filter((kept) => fromLaunch(kept, launch)); + state.head = 0; + state.lines = state.records.filter(isLine).length; + state.total = state.lines; + state.launch = launch; + launches.set(record.instanceId, launch.id); + } else if (latest !== undefined && !fromLaunch(record, latest)) return; + } + const line = isLine(record); + if (line) state.total += 1; + // Kept records stay in display order; a record older than every kept line of a full window + // would be trimmed at once. + const oldest = state.records[state.head]; + if (state.lines >= tail && oldest !== undefined && compareRecords(record, oldest) < 0) return; + let low = state.head; + let high = state.records.length; + while (low < high) { + const middle = (low + high) >>> 1; + const kept = state.records[middle]; + if (kept !== undefined && compareRecords(kept, record) <= 0) low = middle + 1; + else high = middle; + } + if (low === state.records.length) state.records.push(record); + else state.records.splice(low, 0, record); + if (line) state.lines += 1; + while (state.lines > tail && state.head < state.records.length) { + const dropped = state.records[state.head]; + state.head += 1; + if (dropped !== undefined && isLine(dropped)) state.lines -= 1; + } + if (state.head > 1024 && state.head * 2 > state.records.length) { + state.records = state.records.slice(state.head); + state.head = 0; + } + }; + const finish = (): { + readonly window: LogWindow; + readonly positions: typeof positions; + readonly launches: ReadonlyMap; + } => { + const kept = [...instances.values()].flatMap((state) => state.records.slice(state.head)); + const total = [...instances.values()].reduce((sum, state) => sum + state.total, 0); + const window = tailWindow(kept.toSorted(compareRecords), tail); + return { window: { ...window, total }, positions, launches }; + }; + return { push, finish }; +}; + +/** Whether a followed record comes after the history already printed. */ +export const isAfter = (record: LogRecord, printed: LogPosition | undefined) => + printed === undefined || + record.position === undefined || + comparePositions(record.position, printed) > 0; + +const palette = [aqua, yellow, green, magenta, blue]; +const pad = (value: number, width: number) => String(value).padStart(width, "0"); + +const localTime = (iso: string) => + Option.match(DateTime.make(iso), { + onNone: () => iso, + onSome: (time) => { + const parts = DateTime.toParts(DateTime.setZone(time, DateTime.zoneMakeLocal())); + return `${pad(parts.hour, 2)}:${pad(parts.minute, 2)}:${pad(parts.second, 2)}.${pad(parts.millisecond, 3)}`; + }, + }); + +/** Formats records as `