From 67dc7eba3b08823f17802b593f0457f78a61b7d8 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Fri, 30 Oct 2026 16:30:56 +0530 Subject: [PATCH 01/10] feat(logs): accept --search and --cursor for server logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logs:get declares --search (trimmed, at most 256 characters through the shared withinLength check, the logs service limit) and --cursor (opaque, trimmed). Both are server-only: the window rule becomes onlyWithServer, which takes any catalog flag, so --type deployment or live with either exits 2 before any log read. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/commands/launch/logs/get.test.ts | 16 +++++-- src/commands/launch/logs/get.ts | 11 ++++- src/logs/logs.inputs.test.ts | 65 +++++++++++++++++++++++----- src/logs/logs.inputs.ts | 19 ++++++-- test/integration/logs-get.test.ts | 2 + 5 files changed, 94 insertions(+), 19 deletions(-) diff --git a/src/commands/launch/logs/get.test.ts b/src/commands/launch/logs/get.test.ts index bc100625..e3855f51 100644 --- a/src/commands/launch/logs/get.test.ts +++ b/src/commands/launch/logs/get.test.ts @@ -76,12 +76,22 @@ describe('LogsGet', () => { }); it('exposes the log type as --type and never as --log-type', () => { - expect(Object.keys(LogsGet.flags).sort()).toEqual(['deployment', 'env', 'org', 'project', 'since', 'type', 'until']); + expect(Object.keys(LogsGet.flags).sort()).toEqual([ + 'cursor', + 'deployment', + 'env', + 'org', + 'project', + 'search', + 'since', + 'type', + 'until', + ]); expect(LogsGet.flags.type).toBe(catalog['log-type']); }); - it('declares the window rules and the log type input the rules read', () => { - expect(LogsGet.rules).toHaveLength(2); + it('declares the four server-only rules and the log type input the rules read', () => { + expect(LogsGet.rules).toHaveLength(4); expect(Object.keys(LogsGet.inputs)).toContain('log-type'); }); diff --git a/src/commands/launch/logs/get.ts b/src/commands/launch/logs/get.ts index da0d7d25..b2cfd054 100644 --- a/src/commands/launch/logs/get.ts +++ b/src/commands/launch/logs/get.ts @@ -11,7 +11,7 @@ import { DeploymentUnsuccessfulError, DeploymentWaitInterruptedError } from '../ import { DeploymentFollower } from '../../../deployments/deployment.follower'; import { fetchDeployment } from '../../../deployments/deployment.resolver'; import { defaultWatchTiming } from '../../../deployments/deployment.watcher'; -import { LOG_TYPE_FLAG, windowOnlyWithServer } from '../../../logs/logs.inputs'; +import { LOG_TYPE_FLAG, onlyWithServer } from '../../../logs/logs.inputs'; import { deploymentStatusLine, logsWaitTimedOutMessage, @@ -34,6 +34,8 @@ const getInputs = inputs({ 'log-type': {}, since: {}, until: {}, + search: {}, + cursor: {}, }); const { 'log-type': logTypeFlag, ...otherFlags } = flagsFor(getInputs); @@ -58,7 +60,12 @@ export default class LogsGet extends LaunchCommand { static flags = { ...otherFlags, [LOG_TYPE_FLAG]: logTypeFlag }; - static rules = [windowOnlyWithServer('since'), windowOnlyWithServer('until')]; + static rules = [ + onlyWithServer('since'), + onlyWithServer('until'), + onlyWithServer('search'), + onlyWithServer('cursor'), + ]; protected async parse< F extends Record, diff --git a/src/logs/logs.inputs.test.ts b/src/logs/logs.inputs.test.ts index 2f592960..f8cb2b3a 100644 --- a/src/logs/logs.inputs.test.ts +++ b/src/logs/logs.inputs.test.ts @@ -6,10 +6,10 @@ import { resolveInputs, resolveInputsTraced } from '../core/resolve'; import { projectTypeChoiceOf } from '../projects/project.inputs'; import type { ApiSurface } from '../resources'; import { catalog, resolution } from '../resources'; -import { logTypeOf, logsFlags, logsResolution, windowOnlyWithServer } from './logs.inputs'; +import { logTypeOf, logsFlags, logsResolution, onlyWithServer } from './logs.inputs'; -const logInputs = inputs({ 'log-type': {}, since: {}, until: {} }); -const rules = [windowOnlyWithServer('since'), windowOnlyWithServer('until')]; +const logInputs = inputs({ 'log-type': {}, since: {}, until: {}, search: {}, cursor: {} }); +const rules = [onlyWithServer('since'), onlyWithServer('until'), onlyWithServer('search'), onlyWithServer('cursor')]; const prompts: unknown[] = []; const services = { api: {} as ApiSurface, @@ -28,6 +28,10 @@ function resolve(parsed: Record) { return resolveInputsTraced(logInputs, { parsed, projectConfig: {}, services, rules }); } +function resolveSearch(parsed: Record) { + return resolveInputsTraced(inputs({ 'log-type': {}, search: {} }), { parsed, projectConfig: {}, services, rules: [] }); +} + async function failureOf(parsed: Record): Promise { return resolve(parsed).then( () => { @@ -42,9 +46,16 @@ beforeEach(() => { }); describe('logs inputs', () => { - it('shares the log type, since and until through the catalog without touching the project type flag', () => { + it('shares the log type, window, search and cursor through the catalog without touching the project type flag', () => { expect(Object.keys(logsFlags).sort()).toEqual(Object.keys(logsResolution).sort()); - expect(Object.keys(logsFlags)).toHaveLength(3); + expect(Object.keys(logsFlags).sort()).toEqual(['cursor', 'log-type', 'search', 'since', 'until']); + expect(catalog.search).toBe(logsFlags.search); + expect(catalog.cursor).toBe(logsFlags.cursor); + expect(resolution.search).toBe(logsResolution.search); + expect(resolution.cursor).toBe(logsResolution.cursor); + expect(logsFlags.search.description).toContain('256 characters'); + expect(logsFlags.search.required).toBeFalsy(); + expect(logsFlags.cursor.required).toBeFalsy(); expect(catalog['log-type']).toBe(logsFlags['log-type']); expect(catalog.since).toBe(logsFlags.since); expect(catalog.until).toBe(logsFlags.until); @@ -130,16 +141,50 @@ describe('logs inputs', () => { expect(untilDefault.message).toBe('--until is only supported when --type is one of server; --type is deployment.'); }); - it('accepts since and until with the server type, and a blank one with any type', async () => { - const server = await resolve({ 'log-type': 'Server', since: ' 2h ', until: '1h' }); - const blank = await resolve({ since: ' ', until: '' }); + it('refuses --search and --cursor with the default and explicit deployment type and with live', async () => { + const searchDefault = await failureOf({ search: 'timeout' }); + const searchLive = await failureOf({ 'log-type': 'live', search: 'timeout' }); + const cursorDeployment = await failureOf({ 'log-type': 'deployment', cursor: '[1,"a"]' }); + + expect(searchDefault).toBeInstanceOf(UsageError); + expect(searchDefault.exitCode).toBe(2); + expect(searchDefault.message).toBe('--search is only supported when --type is one of server; --type is deployment.'); + expect(searchLive.message).toBe('--search is only supported when --type is one of server; --type is live.'); + expect(cursorDeployment.message).toBe('--cursor is only supported when --type is one of server; --type is deployment.'); + }); + + it('accepts the server-only flags with the server type, and a blank one with any type', async () => { + const server = await resolve({ 'log-type': 'Server', since: ' 2h ', until: '1h', search: ' timeout ', cursor: ' [1,"a"] ' }); + const blank = await resolve({ since: ' ', until: '', search: ' ', cursor: '' }); const nulls = await resolve({ 'log-type': 'live', since: null, until: undefined }); - expect(server.resolved).toEqual({ 'log-type': 'server', since: '2h', until: '1h' }); - expect(blank.resolved).toEqual({ 'log-type': 'deployment', since: undefined, until: undefined }); + expect(server.resolved).toEqual({ 'log-type': 'server', since: '2h', until: '1h', search: 'timeout', cursor: '[1,"a"]' }); + expect(blank.resolved).toEqual({ + 'log-type': 'deployment', + since: undefined, + until: undefined, + search: undefined, + cursor: undefined, + }); expect(nulls.resolved['log-type']).toBe('live'); }); + it('accepts a search of exactly 256 characters and refuses 257 with a usage error naming --search', async () => { + const longest = 'a'.repeat(256); + const accepted = await resolveSearch({ 'log-type': 'server', search: ` ${longest} ` }); + const failure = await resolveSearch({ 'log-type': 'server', search: `${longest}b` }).then( + () => { + throw new Error('resolution did not fail'); + }, + (error: UsageError) => error, + ); + + expect(accepted.resolved.search).toBe(longest); + expect(failure).toBeInstanceOf(UsageError); + expect(failure.exitCode).toBe(2); + expect(failure.message).toBe('--search must be 256 characters or fewer; that value is 257 characters.'); + }); + it('refuses a window flag when the log type is not an input of the command, rather than skipping the check', async () => { const bare = inputs({ since: {} }); const run = resolveInputs(bare, { parsed: { since: '1h' }, projectConfig: {}, services, rules }); diff --git a/src/logs/logs.inputs.ts b/src/logs/logs.inputs.ts index 09d244c1..e0cacf21 100644 --- a/src/logs/logs.inputs.ts +++ b/src/logs/logs.inputs.ts @@ -4,7 +4,8 @@ import { InputDependencyError } from '../core/errors'; import type { ResolutionSpec } from '../core/resolution'; import type { Rule } from '../core/rules'; import { requireValueOf } from '../core/rules'; -import { isAbsent, oneOf } from '../core/values'; +import { isAbsent, oneOf, withinLength } from '../core/values'; +import type { FlagKey } from '../resources'; export const LOG_TYPES = ['deployment', 'server', 'live'] as const; @@ -14,7 +15,9 @@ export const DEFAULT_LOG_TYPE: LogType = 'deployment'; export const LOG_TYPE_FLAG = 'type'; -export const WINDOW_LOG_TYPES: readonly LogType[] = ['server']; +export const SERVER_ONLY_LOG_TYPES: readonly LogType[] = ['server']; + +export const MAX_SEARCH_LENGTH = 256; const WINDOW_FORMATS = 'relative (45s, 30m, 12hr, 2d) or an ISO 8601 timestamp with an offset'; @@ -28,6 +31,12 @@ export const logsFlags = { until: Flags.string({ description: `Server logs only: end of the window, ${WINDOW_FORMATS}. Defaults to now`, }), + search: Flags.string({ + description: `Server logs only: show only lines containing this text, up to ${MAX_SEARCH_LENGTH} characters`, + }), + cursor: Flags.string({ + description: 'Server logs only: resume from the cursor printed by an earlier run', + }), }; export function logTypeOf(value: string): LogType { @@ -41,9 +50,11 @@ export const logsResolution = { } satisfies ResolutionSpec, since: { normalize: async (value) => value.trim() } satisfies ResolutionSpec, until: { normalize: async (value) => value.trim() } satisfies ResolutionSpec, + search: { normalize: (value) => withinLength('search', value, MAX_SEARCH_LENGTH) } satisfies ResolutionSpec, + cursor: { normalize: async (value) => value.trim() } satisfies ResolutionSpec, }; -export function windowOnlyWithServer(flag: 'since' | 'until'): Rule { +export function onlyWithServer(flag: FlagKey): Rule { return (resolved) => { if (isAbsent(resolved[flag])) { return; @@ -57,6 +68,6 @@ export function windowOnlyWithServer(flag: 'since' | 'until'): Rule { ); } - requireValueOf(flag, LOG_TYPE_FLAG, WINDOW_LOG_TYPES, type); + requireValueOf(flag, LOG_TYPE_FLAG, SERVER_ONLY_LOG_TYPES, type); }; } diff --git a/test/integration/logs-get.test.ts b/test/integration/logs-get.test.ts index f99f6934..48c3c6fa 100644 --- a/test/integration/logs-get.test.ts +++ b/test/integration/logs-get.test.ts @@ -1032,6 +1032,8 @@ describe('integration: launch:logs:get on the wire', () => { it.each([ [['--since', '1h'], '--since is only supported when --type is one of server; --type is deployment.'], [['--until', '1h', '--type', 'live'], '--until is only supported when --type is one of server; --type is live.'], + [['--search', 'timeout'], '--search is only supported when --type is one of server; --type is deployment.'], + [['--cursor', '[1,"a"]', '--type', 'live'], '--cursor is only supported when --type is one of server; --type is live.'], [['--since', '12hr', '--until', '2026-10-28T10:00:00Z', '--type', 'server'], '--since and --until must both be relative (like 12hr) or both absolute (ISO 8601) timestamps.'], [['--since', '30m', '--until', '2h', '--type', 'server'], '--since must not be later than --until.'], ])('T61: exits 2 for %j with nothing read', async (flags, message) => { From 2a7dde988550e3e0526595a52b8a87ca61178e59 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Fri, 30 Oct 2026 16:36:32 +0530 Subject: [PATCH 02/10] feat(logs): page server logs oldest-first over the REST cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerLogsApi.page reads one page with sortDirection=asc, sending search and cursor only when given, and returns the logs with the server's pageInfo. ServerLogsApi.pages follows pageInfo.newestCursor with the same window and search, and stops when the server reports nothing newer, sends no pageInfo (a logs service without CL-7180), an empty page or no cursor, or after MAX_PAGES requests. A page that fails rejects the walk after the pages already read. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/logs/server-logs.api.test.ts | 138 +++++++++++++++++++++++++++++++ src/logs/server-logs.api.ts | 47 ++++++++++- src/logs/types.ts | 14 ++++ 3 files changed, 198 insertions(+), 1 deletion(-) diff --git a/src/logs/server-logs.api.test.ts b/src/logs/server-logs.api.test.ts index 8af58e24..4113c910 100644 --- a/src/logs/server-logs.api.test.ts +++ b/src/logs/server-logs.api.test.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto'; +import { MAX_PAGES } from '../core/constants'; import { LaunchApiError } from '../transport/errors'; import type { RestApiClient, RestRequest } from '../transport/rest-client'; import { LOGS_ERROR_MESSAGES } from './logs.errors'; @@ -26,6 +27,28 @@ function fakeRestClient(result: unknown) { return { client, requests, messages }; } +function fakePagedClient(pages: unknown[]) { + const queries: unknown[] = []; + const client = { + request: async (req: RestRequest) => { + queries.push(req.query); + return pages[queries.length - 1]; + }, + } as unknown as RestApiClient; + + return { client, queries }; +} + +async function collect(pages: AsyncIterable): Promise { + const seen: unknown[] = []; + + for await (const page of pages) { + seen.push(page); + } + + return seen; +} + describe('ServerLogsApi', () => { it('reads the window as a scoped GET on the deployment path and returns the entries in order', async () => { const entries = [ @@ -49,6 +72,121 @@ describe('ServerLogsApi', () => { expect(messages[0]).toBe(LOGS_ERROR_MESSAGES); }); + it('reads one page oldest-first without search or cursor and returns its entries and page info', async () => { + const entries = [{ timestamp: '2026-10-28T10:00:00.000Z', message: 'first', source: 'edge', cursor: '[1,"a"]' }]; + const pageInfo = { hasOlder: null, hasNewer: true, oldestCursor: '[1,"a"]', newestCursor: '[1,"a"]' }; + const { client, requests, messages } = fakeRestClient({ serverLogs: entries, pageInfo }); + + const result = await new ServerLogsApi(client).page(WINDOW); + + expect(result).toEqual({ logs: entries, pageInfo }); + expect(requests).toEqual([ + { + method: 'GET', + path: `/projects/${PROJECT}/environments/${ENVIRONMENT}/deployments/${DEPLOYMENT}/logs/server-logs`, + orgUid: ORG, + projectUid: PROJECT, + query: { startTime: 1_000, endTime: 2_000, sortDirection: 'asc' }, + }, + ]); + expect(messages[0]).toBe(LOGS_ERROR_MESSAGES); + }); + + it('sends the search text and the cursor exactly as given and returns no page info when the server sends none', async () => { + const cursor = `[${Date.now()},"${randomBytes(6).toString('hex')}"]`; + const { client, requests } = fakeRestClient({ serverLogs: [] }); + + const result = await new ServerLogsApi(client).page({ ...WINDOW, search: 'upstream timeout', cursor }); + + expect(result).toEqual({ logs: [], pageInfo: undefined }); + expect(requests[0].query).toEqual({ + startTime: 1_000, + endTime: 2_000, + sortDirection: 'asc', + search: 'upstream timeout', + cursor, + }); + }); + + it('walks a single page and stops when the server reports nothing newer', async () => { + const only = { serverLogs: [{ message: 'only' }], pageInfo: { hasNewer: false, newestCursor: '[1,"a"]' } }; + const { client, queries } = fakePagedClient([only, only]); + + const seen = await collect(new ServerLogsApi(client).pages({ ...WINDOW, search: 'x' })); + + expect(seen).toEqual([{ logs: only.serverLogs, pageInfo: only.pageInfo }]); + expect(queries).toEqual([{ startTime: 1_000, endTime: 2_000, sortDirection: 'asc', search: 'x' }]); + }); + + it('follows the newest cursor of each page with the same window and search until nothing is newer', async () => { + const first = { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }; + const second = { serverLogs: [{ message: 'two' }], pageInfo: { hasNewer: true, newestCursor: '[2,"b"]' } }; + const last = { serverLogs: [{ message: 'three' }], pageInfo: { hasNewer: false, newestCursor: '[3,"c"]' } }; + const { client, queries } = fakePagedClient([first, second, last, last]); + + const seen = await collect(new ServerLogsApi(client).pages({ ...WINDOW, search: 'x', cursor: '[0,"z"]' })); + + expect(seen).toEqual([first, second, last].map(({ serverLogs, pageInfo }) => ({ logs: serverLogs, pageInfo }))); + expect(queries).toEqual([ + { startTime: 1_000, endTime: 2_000, sortDirection: 'asc', search: 'x', cursor: '[0,"z"]' }, + { startTime: 1_000, endTime: 2_000, sortDirection: 'asc', search: 'x', cursor: '[1,"a"]' }, + { startTime: 1_000, endTime: 2_000, sortDirection: 'asc', search: 'x', cursor: '[2,"b"]' }, + ]); + }); + + it.each([ + ['a server without page info', { serverLogs: [{ message: 'one' }] }], + ['no direction answer', { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: null, newestCursor: '[1,"a"]' } }], + ['an empty page that claims more', { serverLogs: [], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }], + ['no newest cursor', { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: null } }], + ['a blank newest cursor', { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: ' ' } }], + ])('stops after one request for %s', async (_case, body: { serverLogs: unknown[]; pageInfo?: unknown }) => { + const { client, queries } = fakePagedClient([body, body]); + + const seen = await collect(new ServerLogsApi(client).pages(WINDOW)); + + expect(seen).toEqual([{ logs: body.serverLogs, pageInfo: body.pageInfo }]); + expect(queries).toHaveLength(1); + }); + + it('stops after exactly MAX_PAGES requests from a server that always has more, ending on a page that has more', async () => { + const endless = Array.from({ length: MAX_PAGES + 1 }, (_, index) => ({ + serverLogs: [{ message: `line ${index}` }], + pageInfo: { hasNewer: true, newestCursor: `[${index},"c"]` }, + })); + const { client, queries } = fakePagedClient(endless); + + const seen = (await collect(new ServerLogsApi(client).pages(WINDOW))) as { pageInfo: { newestCursor: string } }[]; + + expect(MAX_PAGES).toBe(100); + expect(queries).toHaveLength(MAX_PAGES); + expect(seen).toHaveLength(MAX_PAGES); + expect(seen.at(-1)?.pageInfo.newestCursor).toBe(`[${MAX_PAGES - 1},"c"]`); + }); + + it('yields the pages it read and then rejects with the failure of a later page', async () => { + const failure = new LaunchApiError(408, [{ code: 'launch.LOGS.QUERY_TIMEOUT', message: 'slow' }]); + const first = { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }; + let calls = 0; + const client = { + request: async () => { + calls += 1; + return calls === 1 ? first : Promise.reject(failure); + }, + } as unknown as RestApiClient; + const seen: unknown[] = []; + + const walking = (async () => { + for await (const page of new ServerLogsApi(client).pages(WINDOW)) { + seen.push(page); + } + })(); + + await expect(walking).rejects.toBe(failure); + expect(seen).toEqual([{ logs: first.serverLogs, pageInfo: first.pageInfo }]); + expect(calls).toBe(2); + }); + it('returns an empty page as an empty list', async () => { const { client } = fakeRestClient({ serverLogs: [] }); diff --git a/src/logs/server-logs.api.ts b/src/logs/server-logs.api.ts index e0ea909e..ee0c7e47 100644 --- a/src/logs/server-logs.api.ts +++ b/src/logs/server-logs.api.ts @@ -1,14 +1,17 @@ +import { MAX_PAGES } from '../core/constants'; import { DeploymentsApi, type GetDeploymentParams } from '../deployments/deployments.api'; import { assertArray } from '../transport/envelope'; import type { RestApiClient } from '../transport/rest-client'; import { LOGS_ERROR_MESSAGES } from './logs.errors'; -import type { ServerLog, ServerLogsResponse } from './types'; +import type { ServerLog, ServerLogsPage, ServerLogsResponse } from './types'; export * from './types'; export interface ServerLogsWindowParams extends GetDeploymentParams { startTime: number; endTime: number; + search?: string; + cursor?: string; } export class ServerLogsApi { @@ -30,4 +33,46 @@ export class ServerLogsApi { return response.serverLogs; } + + async page(params: ServerLogsWindowParams): Promise { + const response = await this.client.request( + { + method: 'GET', + path: `${DeploymentsApi.pathOf(params)}/logs/server-logs`, + orgUid: params.org, + projectUid: params.project, + query: { + startTime: params.startTime, + endTime: params.endTime, + sortDirection: 'asc', + ...(params.search !== undefined && { search: params.search }), + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, + }, + LOGS_ERROR_MESSAGES, + ); + + assertArray(response, 'serverLogs', 'server log page'); + + return { logs: response.serverLogs, pageInfo: response.pageInfo }; + } + + async *pages(params: ServerLogsWindowParams): AsyncGenerator { + let cursor = params.cursor; + + for (let fetched = 0; fetched < MAX_PAGES; fetched += 1) { + const page = await this.page({ ...params, cursor }); + + yield page; + + const next = page.pageInfo?.newestCursor; + const hasNext = typeof next === 'string' && next.trim() !== ''; + + if (page.pageInfo?.hasNewer !== true || page.logs.length === 0 || !hasNext) { + return; + } + + cursor = next; + } + } } diff --git a/src/logs/types.ts b/src/logs/types.ts index aaf9d153..4d6ae1f1 100644 --- a/src/logs/types.ts +++ b/src/logs/types.ts @@ -4,8 +4,22 @@ export interface ServerLog { timestamp?: string | number; message?: string; source?: string; + cursor?: string; +} + +export interface ServerLogsPageInfo { + hasOlder?: boolean | null; + hasNewer?: boolean | null; + oldestCursor?: string | null; + newestCursor?: string | null; } export interface ServerLogsResponse { serverLogs: ServerLog[]; + pageInfo?: ServerLogsPageInfo; +} + +export interface ServerLogsPage { + logs: ServerLog[]; + pageInfo?: ServerLogsPageInfo; } From 0dd518575442a228c44a01bed2a2d078809c17c0 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Fri, 30 Oct 2026 16:46:00 +0530 Subject: [PATCH 03/10] feat(logs): read every server log page in the window, with search and cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logs:get --type server now walks ServerLogsApi.pages with the resolved window, --search and --cursor, printing each page as it arrives, so a window past one page is printed in full and in order without holding it in memory. The single windowed read, its 5000-line cap notice and SERVER_LOGS_PAGE_CAP are gone. Every server-log request now carries sortDirection=asc. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/commands/launch/logs/get.test.ts | 94 ++++++++++++++++------------ src/commands/launch/logs/get.ts | 21 +++---- src/logs/logs.presenter.test.ts | 8 --- src/logs/logs.presenter.ts | 11 +--- src/logs/server-logs.api.test.ts | 32 +--------- src/logs/server-logs.api.ts | 19 +----- src/logs/types.ts | 2 - src/resources.test.ts | 2 +- test/integration/logs-get.test.ts | 50 +++++++++------ 9 files changed, 98 insertions(+), 141 deletions(-) diff --git a/src/commands/launch/logs/get.test.ts b/src/commands/launch/logs/get.test.ts index e3855f51..6f198bd7 100644 --- a/src/commands/launch/logs/get.test.ts +++ b/src/commands/launch/logs/get.test.ts @@ -19,6 +19,7 @@ interface Options { latest?: unknown; get?: () => Promise; serverLogs?: unknown[]; + serverPages?: unknown[]; outputIsTTY?: boolean; } @@ -32,7 +33,7 @@ function commandUnderTest(options: Options = {}) { }; const latestCalls: unknown[] = []; const getCalls: unknown[] = []; - const windowCalls: unknown[] = []; + const walkCalls: unknown[] = []; const command = Object.create(LogsGet.prototype) as LogsGet; Object.assign(command, { @@ -54,16 +55,16 @@ function commandUnderTest(options: Options = {}) { }, }, serverLogs: { - window: async (params: unknown) => { - windowCalls.push(params); - return options.serverLogs ?? []; + async *pages(params: unknown) { + walkCalls.push(params); + yield* options.serverPages ?? [{ logs: options.serverLogs ?? [], pageInfo: { hasNewer: false } }]; }, }, }, }, }); - return { command, lines, latestCalls, getCalls, windowCalls }; + return { command, lines, latestCalls, getCalls, walkCalls }; } function outcome(kind: DeploymentOutcome['kind'], status: string, deployment: object = {}): DeploymentOutcome { @@ -100,7 +101,7 @@ describe('LogsGet', () => { jest.spyOn(interruptible, 'onInterrupt').mockReturnValue(release); const follow = jest.spyOn(LiveLogFollower.prototype, 'follow').mockResolvedValue(undefined); const watch = jest.spyOn(DeploymentFollower.prototype, 'watch'); - const { command, latestCalls, getCalls, windowCalls } = commandUnderTest({ resolved: { 'log-type': 'live' } }); + const { command, latestCalls, getCalls, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'live' } }); await command.run(); @@ -110,7 +111,7 @@ describe('LogsGet', () => { expect(follow.mock.calls[0][1].aborted).toBe(false); expect(latestCalls).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); expect(getCalls).toEqual([]); - expect(windowCalls).toEqual([]); + expect(walkCalls).toEqual([]); expect(watch).not.toHaveBeenCalled(); expect(release).toHaveBeenCalledTimes(1); }); @@ -162,15 +163,15 @@ describe('LogsGet', () => { it('refuses a missing latest deployment with exit 2 for both types', async () => { for (const type of ['deployment', 'server']) { - const { command, windowCalls } = commandUnderTest({ resolved: { 'log-type': type }, latest: undefined }); + const { command, walkCalls } = commandUnderTest({ resolved: { 'log-type': type }, latest: undefined }); await expect(command.run()).rejects.toThrow('No deployments found in this environment.'); - expect(windowCalls).toEqual([]); + expect(walkCalls).toEqual([]); } }); it('verifies a given deployment with a get and never asks for the latest', async () => { - const { command, latestCalls, getCalls, windowCalls } = commandUnderTest({ + const { command, latestCalls, getCalls, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server', deployment: 'd9' }, }); @@ -178,7 +179,7 @@ describe('LogsGet', () => { expect(getCalls).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1', deployment: 'd9' }]); expect(latestCalls).toEqual([]); - expect(windowCalls).toEqual([ + expect(walkCalls).toEqual([ { org: 'org1', project: 'proj1', @@ -191,18 +192,18 @@ describe('LogsGet', () => { }); it('reads the server logs of the latest deployment when none is given', async () => { - const { command, latestCalls, getCalls, windowCalls } = commandUnderTest({ resolved: { 'log-type': 'server' } }); + const { command, latestCalls, getCalls, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server' } }); await command.run(); expect(latestCalls).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); expect(getCalls).toEqual([]); - expect(windowCalls).toEqual([expect.objectContaining({ deployment: 'dLatest' })]); + expect(walkCalls).toEqual([expect.objectContaining({ deployment: 'dLatest' })]); }); it('turns a not-found verification into the generic exit-2 wording and reads no logs', async () => { const missing = new LaunchApiError(404, [{ code: 'launch.DEPLOYMENT.NOT_FOUND', message: 'raw' }]); - const { command, windowCalls } = commandUnderTest({ + const { command, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server', deployment: 'd9' }, get: async () => Promise.reject(missing), }); @@ -211,7 +212,7 @@ describe('LogsGet', () => { await expect(running).rejects.toThrow(UsageError); await expect(running).rejects.toThrow('No deployment with UID "d9" found in this environment.'); - expect(windowCalls).toEqual([]); + expect(walkCalls).toEqual([]); }); it('propagates a failed verification untouched', async () => { @@ -221,8 +222,34 @@ describe('LogsGet', () => { await expect(command.run()).rejects.toBe(failure); }); - it('prints the server lines and stays quiet about the cap at 4999 lines', async () => { - const serverLogs = Array.from({ length: 4999 }, (_, index) => ({ + it('walks the server pages with the window, search and cursor and prints every line in order', async () => { + const { command, lines, walkCalls } = commandUnderTest({ + resolved: { 'log-type': 'server', deployment: 'd9', search: 'timeout', cursor: '[1,"a"]' }, + serverPages: [ + { logs: [{ message: 'one' }, { message: 'two' }], pageInfo: { hasNewer: true, newestCursor: '[2,"b"]' } }, + { logs: [{ message: 'three' }], pageInfo: { hasNewer: false, newestCursor: '[3,"c"]' } }, + ], + }); + + await command.run(); + + expect(lines).toEqual(['one', 'two', 'three']); + expect(walkCalls).toEqual([ + { + org: 'org1', + project: 'proj1', + environment: 'env1', + deployment: 'd9', + startTime: NOW - 3_600_000, + endTime: NOW, + search: 'timeout', + cursor: '[1,"a"]', + }, + ]); + }); + + it('prints a full page of 5000 lines with no cap notice when the server reports nothing newer', async () => { + const serverLogs = Array.from({ length: 5000 }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', message: `line ${index}`, })); @@ -230,26 +257,9 @@ describe('LogsGet', () => { await command.run(); - expect(lines).toHaveLength(4999); + expect(lines).toHaveLength(5000); expect(lines[0]).toBe('2026-10-28 11:30:00.000: line 0'); - expect(lines.join('\n')).not.toContain('Showing the newest'); - }); - - it('prints the cap notice last at exactly 5000 lines and not at 5001', async () => { - const entries = (count: number) => - Array.from({ length: count }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', message: `line ${index}` })); - const capped = commandUnderTest({ resolved: { 'log-type': 'server' }, serverLogs: entries(5000) }); - const over = commandUnderTest({ resolved: { 'log-type': 'server' }, serverLogs: entries(5001) }); - - await capped.command.run(); - await over.command.run(); - - expect(capped.lines).toHaveLength(5001); - expect(capped.lines.at(-1)).toBe( - 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.', - ); - expect(over.lines).toHaveLength(5001); - expect(over.lines.join('\n')).not.toContain('Showing the newest'); + expect(lines.at(-1)).toBe('2026-10-28 11:30:00.000: line 4999'); }); it('prints the empty server notice for the resolved window, yellow on a terminal', async () => { @@ -264,7 +274,7 @@ describe('LogsGet', () => { it('prints the 24 hour notice before the lines and sends the window as the user gave it', async () => { const serverLogs = [{ timestamp: '2026-10-27T11:00:00.000Z', message: 'old' }]; - const { command, lines, windowCalls } = commandUnderTest({ + const { command, lines, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server', since: '2d' }, serverLogs, }); @@ -275,7 +285,7 @@ describe('LogsGet', () => { 'Server logs are kept for 24 hours; showing from 2026-10-27T12:00:00.000Z.', '2026-10-27 11:00:00.000: old', ]); - expect(windowCalls).toEqual([expect.objectContaining({ startTime: NOW - 172_800_000, endTime: NOW })]); + expect(walkCalls).toEqual([expect.objectContaining({ startTime: NOW - 172_800_000, endTime: NOW })]); }); it('prints no notice for a window of exactly 24 hours', async () => { @@ -290,17 +300,19 @@ describe('LogsGet', () => { }); it('rejects a bad window before any read', async () => { - const { command, windowCalls } = commandUnderTest({ resolved: { 'log-type': 'server', since: 'yesterday' } }); + const { command, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server', since: 'yesterday' } }); await expect(command.run()).rejects.toThrow(UsageError); - expect(windowCalls).toEqual([]); + expect(walkCalls).toEqual([]); }); it('propagates a failed server read', async () => { const failure = new LaunchApiError(500, [{ code: 'launch.UNKNOWN', message: 'boom' }]); const { command } = commandUnderTest({ resolved: { 'log-type': 'server' } }); Object.assign((command as unknown as { services: { api: { serverLogs: object } } }).services.api.serverLogs, { - window: async () => Promise.reject(failure), + async *pages() { + yield await Promise.reject(failure); + }, }); await expect(command.run()).rejects.toBe(failure); diff --git a/src/commands/launch/logs/get.ts b/src/commands/launch/logs/get.ts index b2cfd054..e0dd5c89 100644 --- a/src/commands/launch/logs/get.ts +++ b/src/commands/launch/logs/get.ts @@ -18,13 +18,11 @@ import { noDeploymentLogsLine, noServerLogsLine, serverLogLine, - serverLogsCapLine, serverLogsRetentionLine, stoppedFollowingLine, } from '../../../logs/logs.presenter'; import { LiveLogFollower } from '../../../logs/live-log.follower'; import { parseLogWindow, type LogWindow } from '../../../logs/log-window'; -import { SERVER_LOGS_PAGE_CAP } from '../../../logs/types'; const getInputs = inputs({ org: { required: true }, @@ -121,23 +119,22 @@ export default class LogsGet extends LaunchCommand { private async printServerLogs(scope: LogScope, window: LogWindow): Promise { const outputIsTTY = this.services.outputIsTTY === true; const { startTime, endTime, clampedFrom } = window; - const logs = await this.services.api.serverLogs.window({ ...scope, startTime, endTime }); + const { search, cursor } = this.resolved; + let printed = 0; if (clampedFrom !== undefined) { this.ux.print(serverLogsRetentionLine(clampedFrom, outputIsTTY)); } - if (logs.length === 0) { - this.ux.print(noServerLogsLine(startTime, endTime, outputIsTTY)); - return; - } - - for (const log of logs) { - this.ux.print(serverLogLine(log, outputIsTTY, process.stdout.columns)); + for await (const page of this.services.api.serverLogs.pages({ ...scope, startTime, endTime, search, cursor })) { + for (const log of page.logs) { + this.ux.print(serverLogLine(log, outputIsTTY, process.stdout.columns)); + printed += 1; + } } - if (logs.length === SERVER_LOGS_PAGE_CAP) { - this.ux.print(serverLogsCapLine(outputIsTTY)); + if (printed === 0) { + this.ux.print(noServerLogsLine(startTime, endTime, outputIsTTY)); } } diff --git a/src/logs/logs.presenter.test.ts b/src/logs/logs.presenter.test.ts index 892c686a..09165da8 100644 --- a/src/logs/logs.presenter.test.ts +++ b/src/logs/logs.presenter.test.ts @@ -8,7 +8,6 @@ import { noDeploymentLogsLine, noServerLogsLine, serverLogLine, - serverLogsCapLine, serverLogsRetentionLine, stoppedFollowingLine, } from './logs.presenter'; @@ -38,13 +37,6 @@ describe('logs presenter', () => { ); }); - it('words the 5000-line notice exactly', () => { - expect(serverLogsCapLine(false)).toBe( - 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.', - ); - expect(serverLogsCapLine(true)).toBe(yellow(serverLogsCapLine(false))); - }); - it('words the 24 hour notice from the clamped start', () => { const from = Date.parse('2026-10-27T10:00:00.000Z'); diff --git a/src/logs/logs.presenter.ts b/src/logs/logs.presenter.ts index b1890160..ccb5c7d3 100644 --- a/src/logs/logs.presenter.ts +++ b/src/logs/logs.presenter.ts @@ -3,7 +3,7 @@ import { clockOf, deploymentLogLine } from '../deployments/deployment.presenter' import type { Deployment } from '../deployments/types'; import { styled } from '../core/style'; import { isRecord } from '../core/values'; -import { SERVER_LOGS_PAGE_CAP, type ServerLog } from './types'; +import type { ServerLog } from './types'; const MS_PER_MINUTE = 60_000; @@ -46,15 +46,6 @@ export function noServerLogsLine(startTime: number, endTime: number, outputIsTTY return styled(`No server logs found between ${isoOf(startTime)} and ${isoOf(endTime)}.`, 'yellow', outputIsTTY); } -export function serverLogsCapLine(outputIsTTY: boolean): string { - return styled( - `Showing the newest ${SERVER_LOGS_PAGE_CAP} server log lines in this window. ` + - 'Narrow --since/--until to see earlier ones.', - 'yellow', - outputIsTTY, - ); -} - export function serverLogsRetentionLine(clampedFrom: number, outputIsTTY: boolean): string { return styled(`Server logs are kept for 24 hours; showing from ${isoOf(clampedFrom)}.`, 'yellow', outputIsTTY); } diff --git a/src/logs/server-logs.api.test.ts b/src/logs/server-logs.api.test.ts index 4113c910..4315f666 100644 --- a/src/logs/server-logs.api.test.ts +++ b/src/logs/server-logs.api.test.ts @@ -50,28 +50,6 @@ async function collect(pages: AsyncIterable): Promise { } describe('ServerLogsApi', () => { - it('reads the window as a scoped GET on the deployment path and returns the entries in order', async () => { - const entries = [ - { timestamp: '2026-10-28T10:00:00.000Z', message: 'first', source: 'edge' }, - { timestamp: '2026-10-28T10:00:01.000Z', message: 'second', source: 'edge' }, - ]; - const { client, requests, messages } = fakeRestClient({ serverLogs: entries }); - - const result = await new ServerLogsApi(client).window(WINDOW); - - expect(result).toEqual(entries); - expect(requests).toEqual([ - { - method: 'GET', - path: `/projects/${PROJECT}/environments/${ENVIRONMENT}/deployments/${DEPLOYMENT}/logs/server-logs`, - orgUid: ORG, - projectUid: PROJECT, - query: { startTime: 1_000, endTime: 2_000 }, - }, - ]); - expect(messages[0]).toBe(LOGS_ERROR_MESSAGES); - }); - it('reads one page oldest-first without search or cursor and returns its entries and page info', async () => { const entries = [{ timestamp: '2026-10-28T10:00:00.000Z', message: 'first', source: 'edge', cursor: '[1,"a"]' }]; const pageInfo = { hasOlder: null, hasNewer: true, oldestCursor: '[1,"a"]', newestCursor: '[1,"a"]' }; @@ -187,18 +165,12 @@ describe('ServerLogsApi', () => { expect(calls).toBe(2); }); - it('returns an empty page as an empty list', async () => { - const { client } = fakeRestClient({ serverLogs: [] }); - - await expect(new ServerLogsApi(client).window(WINDOW)).resolves.toEqual([]); - }); - it.each([[{}], [{ serverLogs: null }], [{ serverLogs: {} }], [null], ['text']])( 'raises a malformed-response error when the body %j has no serverLogs array', async (body) => { const { client } = fakeRestClient(body); - const reading = new ServerLogsApi(client).window(WINDOW); + const reading = new ServerLogsApi(client).page(WINDOW); await expect(reading).rejects.toThrow(LaunchApiError); await expect(reading).rejects.toThrow('The Launch API returned a server log page without a serverLogs array.'); @@ -209,6 +181,6 @@ describe('ServerLogsApi', () => { const failure = new LaunchApiError(502, [{ code: 'launch.UPSTREAM', message: 'Bad gateway' }]); const client = { request: async () => Promise.reject(failure) } as unknown as RestApiClient; - await expect(new ServerLogsApi(client).window(WINDOW)).rejects.toBe(failure); + await expect(new ServerLogsApi(client).page(WINDOW)).rejects.toBe(failure); }); }); diff --git a/src/logs/server-logs.api.ts b/src/logs/server-logs.api.ts index ee0c7e47..cea449b9 100644 --- a/src/logs/server-logs.api.ts +++ b/src/logs/server-logs.api.ts @@ -3,7 +3,7 @@ import { DeploymentsApi, type GetDeploymentParams } from '../deployments/deploym import { assertArray } from '../transport/envelope'; import type { RestApiClient } from '../transport/rest-client'; import { LOGS_ERROR_MESSAGES } from './logs.errors'; -import type { ServerLog, ServerLogsPage, ServerLogsResponse } from './types'; +import type { ServerLogsPage, ServerLogsResponse } from './types'; export * from './types'; @@ -17,23 +17,6 @@ export interface ServerLogsWindowParams extends GetDeploymentParams { export class ServerLogsApi { constructor(private readonly client: RestApiClient) {} - async window(params: ServerLogsWindowParams): Promise { - const response = await this.client.request( - { - method: 'GET', - path: `${DeploymentsApi.pathOf(params)}/logs/server-logs`, - orgUid: params.org, - projectUid: params.project, - query: { startTime: params.startTime, endTime: params.endTime }, - }, - LOGS_ERROR_MESSAGES, - ); - - assertArray(response, 'serverLogs', 'server log page'); - - return response.serverLogs; - } - async page(params: ServerLogsWindowParams): Promise { const response = await this.client.request( { diff --git a/src/logs/types.ts b/src/logs/types.ts index 4d6ae1f1..de8af6ba 100644 --- a/src/logs/types.ts +++ b/src/logs/types.ts @@ -1,5 +1,3 @@ -export const SERVER_LOGS_PAGE_CAP = 5000; - export interface ServerLog { timestamp?: string | number; message?: string; diff --git a/src/resources.test.ts b/src/resources.test.ts index 68f9de40..8a49b7b0 100644 --- a/src/resources.test.ts +++ b/src/resources.test.ts @@ -237,7 +237,7 @@ describe('the api surface', () => { const scope = { org: 'o1', project: 'p1', environment: 'e1', deployment: 'd1' }; await api.deploymentLogs.after({ ...scope, timestamp: '2026-09-25T10:00:00.000Z' }); - await api.serverLogs.window({ ...scope, startTime: 1, endTime: 2 }); + await api.serverLogs.page({ ...scope, startTime: 1, endTime: 2 }); await api.deployments.get(scope); expect(sent.logs.map((req) => req.path)).toEqual([ diff --git a/test/integration/logs-get.test.ts b/test/integration/logs-get.test.ts index 48c3c6fa..67a7dc32 100644 --- a/test/integration/logs-get.test.ts +++ b/test/integration/logs-get.test.ts @@ -98,8 +98,8 @@ function buildLogs(lines: unknown[]): nock.Scope { function serverLogs(window: { startTime: number; endTime: number }, entries: unknown[]): nock.Scope { return hub() .get(SERVER_LOGS) - .query({ startTime: String(window.startTime), endTime: String(window.endTime) }) - .reply(200, { serverLogs: entries }); + .query({ startTime: String(window.startTime), endTime: String(window.endTime), sortDirection: 'asc' }) + .reply(200, { serverLogs: entries, pageInfo: { hasNewer: false } }); } const SESSION_FRAME = 'event: session\ndata: {"expiresAt":"2026-10-28T12:05:00.000Z"}\n\n'; @@ -497,7 +497,7 @@ describe('integration: launch:logs:get on the wire', () => { deploymentGet('LIVE'); const read = hub() .get(SERVER_LOGS) - .query({ startTime: String(startTime), endTime: String(endTime) }) + .query({ startTime: String(startTime), endTime: String(endTime), sortDirection: 'asc' }) .reply(200, { serverLogs: [ { timestamp: '2026-10-28T09:15:00.123Z', message: 'GET /index 200', source: 'edge-fn-source' }, @@ -567,28 +567,40 @@ describe('integration: launch:logs:get on the wire', () => { ]); }); - it.each([ - [5000, true], - [4999, false], - ])('T54: with %i server lines the cap notice shows is %s', async (count, shows) => { + it('T54: walks two pages on the wire, carrying the newest cursor and the same window, and prints them in order', async () => { jest.spyOn(Date, 'now').mockReturnValue(NOW); + const window = { startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc', search: 'timeout' }; + const firstCursor = `[${NOW - 1_800_000},"${DEPLOYMENT_UID}"]`; ownedProject(); deploymentGet('LIVE'); - serverLogs( - { startTime: NOW - 3_600_000, endTime: NOW }, - Array.from({ length: count }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', message: `line ${index}` })), - ); + const first = hub() + .get(SERVER_LOGS) + .query(window) + .reply(200, { + serverLogs: [{ timestamp: '2026-10-28T11:30:00.000Z', message: 'upstream timeout one' }], + pageInfo: { hasOlder: null, hasNewer: true, oldestCursor: firstCursor, newestCursor: firstCursor }, + }); + const second = hub() + .get(SERVER_LOGS) + .query({ ...window, cursor: firstCursor }) + .reply(200, { + serverLogs: [{ timestamp: '2026-10-28T11:45:00.000Z', message: 'upstream timeout two' }], + pageInfo: { hasOlder: null, hasNewer: false, oldestCursor: null, newestCursor: null }, + }); - const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server'), config); - const lines = outputLines(stdout); + const { error, stdout } = await runCommand( + getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server', '--search', ' timeout '), + config, + ); expect(error).toBeUndefined(); - expect(lines).toHaveLength(shows ? count + 1 : count); - expect(lines.at(-1)).toBe( - shows - ? 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.' - : `2026-10-28 11:30:00.000: line ${count - 1}`, - ); + expect(first.isDone()).toBe(true); + expect(second.isDone()).toBe(true); + expect(outputLines(stdout)).toEqual([ + '2026-10-28 11:30:00.000: upstream timeout one', + '2026-10-28 11:45:00.000: upstream timeout two', + ]); + expect(onWire).toEqual([]); }); it('T55: prints the 24 hour notice first for --since 2d and sends the user own start time', async () => { From d7bfb0c4a48183daf9e17034d23ffb607176c4a7 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Fri, 30 Oct 2026 16:48:15 +0530 Subject: [PATCH 04/10] feat(logs): print a resume command when the server-log walk hits the page cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the walk stops after MAX_PAGES pages with the last page still reporting newer logs, logs:get prints a yellow notice and a ready-to-run command that resumes from that page's newest cursor. The window is printed as absolute ISO times, never the relative --since/--until the user typed: re-resolving a relative --since on resume would move the start past the cursor and silently skip the logs in between. Search and cursor are single-quoted for a POSIX shell. nextCursorOf is the one rule for "this page leads to another", shared by the walk and the hint. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/commands/launch/logs/get.test.ts | 20 ++++++++++++++ src/commands/launch/logs/get.ts | 11 ++++++++ src/logs/logs.presenter.test.ts | 39 ++++++++++++++++++++++++++++ src/logs/logs.presenter.ts | 27 +++++++++++++++++++ src/logs/server-logs.api.ts | 14 ++++++---- test/integration/logs-get.test.ts | 35 +++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 5 deletions(-) diff --git a/src/commands/launch/logs/get.test.ts b/src/commands/launch/logs/get.test.ts index 6f198bd7..6cf8bc7f 100644 --- a/src/commands/launch/logs/get.test.ts +++ b/src/commands/launch/logs/get.test.ts @@ -248,6 +248,26 @@ describe('LogsGet', () => { ]); }); + it('prints the cap notice and a resume command from the last newest cursor when the walk ends with more to read', async () => { + const { command, lines } = commandUnderTest({ + resolved: { 'log-type': 'server', deployment: 'd9', search: 'time out', since: '2h' }, + serverPages: [ + { logs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }, + { logs: [{ message: 'two' }], pageInfo: { hasNewer: true, newestCursor: '[2,"b"]' } }, + ], + }); + + await command.run(); + + expect(lines).toEqual([ + 'one', + 'two', + 'Stopped after 100 pages of server logs; more remain in this window.', + 'Resume with: csdx launch:logs:get --org org1 --project proj1 --env env1 --deployment d9 --type server ' + + `--since 2026-10-28T10:00:00.000Z --until 2026-10-28T12:00:00.000Z --search 'time out' --cursor '[2,"b"]'`, + ]); + }); + it('prints a full page of 5000 lines with no cap notice when the server reports nothing newer', async () => { const serverLogs = Array.from({ length: 5000 }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', diff --git a/src/commands/launch/logs/get.ts b/src/commands/launch/logs/get.ts index e0dd5c89..7124f1fb 100644 --- a/src/commands/launch/logs/get.ts +++ b/src/commands/launch/logs/get.ts @@ -18,11 +18,14 @@ import { noDeploymentLogsLine, noServerLogsLine, serverLogLine, + serverLogsPageCapLine, + serverLogsResumeLine, serverLogsRetentionLine, stoppedFollowingLine, } from '../../../logs/logs.presenter'; import { LiveLogFollower } from '../../../logs/live-log.follower'; import { parseLogWindow, type LogWindow } from '../../../logs/log-window'; +import { nextCursorOf } from '../../../logs/server-logs.api'; const getInputs = inputs({ org: { required: true }, @@ -121,6 +124,7 @@ export default class LogsGet extends LaunchCommand { const { startTime, endTime, clampedFrom } = window; const { search, cursor } = this.resolved; let printed = 0; + let resumeFrom: string | undefined; if (clampedFrom !== undefined) { this.ux.print(serverLogsRetentionLine(clampedFrom, outputIsTTY)); @@ -131,11 +135,18 @@ export default class LogsGet extends LaunchCommand { this.ux.print(serverLogLine(log, outputIsTTY, process.stdout.columns)); printed += 1; } + + resumeFrom = nextCursorOf(page); } if (printed === 0) { this.ux.print(noServerLogsLine(startTime, endTime, outputIsTTY)); } + + if (resumeFrom !== undefined) { + this.ux.print(serverLogsPageCapLine(outputIsTTY)); + this.ux.print(serverLogsResumeLine({ ...scope, startTime, endTime, search, cursor: resumeFrom })); + } } private async untilInterrupted(follow: (stop: AbortSignal) => Promise): Promise { diff --git a/src/logs/logs.presenter.test.ts b/src/logs/logs.presenter.test.ts index 09165da8..e10ec539 100644 --- a/src/logs/logs.presenter.test.ts +++ b/src/logs/logs.presenter.test.ts @@ -8,6 +8,8 @@ import { noDeploymentLogsLine, noServerLogsLine, serverLogLine, + serverLogsPageCapLine, + serverLogsResumeLine, serverLogsRetentionLine, stoppedFollowingLine, } from './logs.presenter'; @@ -37,6 +39,43 @@ describe('logs presenter', () => { ); }); + it('words the page cap notice in yellow on a terminal and plain on a pipe', () => { + expect(serverLogsPageCapLine(false)).toBe('Stopped after 100 pages of server logs; more remain in this window.'); + expect(serverLogsPageCapLine(true)).toBe(yellow(serverLogsPageCapLine(false))); + }); + + it('builds a resume command with the absolute window and the cursor, and no --search when there was none', () => { + const line = serverLogsResumeLine({ + org: 'o1', + project: 'p1', + environment: 'e1', + deployment: 'd1', + startTime: Date.parse('2026-10-28T00:00:00.000Z'), + endTime: Date.parse('2026-10-28T12:00:00.250Z'), + cursor: '[1761652800000,"AZr3kQ"]', + }); + + expect(line).toBe( + 'Resume with: csdx launch:logs:get --org o1 --project p1 --env e1 --deployment d1 --type server ' + + '--since 2026-10-28T00:00:00.000Z --until 2026-10-28T12:00:00.250Z --cursor \'[1761652800000,"AZr3kQ"]\'', + ); + }); + + it('quotes the search for a shell, escaping a single quote inside it', () => { + const line = serverLogsResumeLine({ + org: 'o1', + project: 'p1', + environment: 'e1', + deployment: 'd1', + startTime: 0, + endTime: 1, + search: "can't connect", + cursor: '[1,"a"]', + }); + + expect(line).toContain(` --search 'can'\\''t connect' --cursor '[1,"a"]'`); + }); + it('words the 24 hour notice from the clamped start', () => { const from = Date.parse('2026-10-27T10:00:00.000Z'); diff --git a/src/logs/logs.presenter.ts b/src/logs/logs.presenter.ts index ccb5c7d3..28a43759 100644 --- a/src/logs/logs.presenter.ts +++ b/src/logs/logs.presenter.ts @@ -1,3 +1,4 @@ +import { MAX_PAGES } from '../core/constants'; import { DEPLOYMENT_WAIT_TIMEOUT_MS } from '../deployments/deployment.watcher'; import { clockOf, deploymentLogLine } from '../deployments/deployment.presenter'; import type { Deployment } from '../deployments/types'; @@ -19,6 +20,18 @@ function deploymentLogsHint(scope: DeploymentScopeLabels, deploymentUid: string) return `csdx launch:logs:get ${flags} --deployment ${deploymentUid}`; } +export interface ServerLogsResume extends DeploymentScopeLabels { + deployment: string; + startTime: number; + endTime: number; + search?: string; + cursor: string; +} + +function shellQuoted(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + function isoOf(time: number): string { return new Date(time).toISOString(); } @@ -46,6 +59,20 @@ export function noServerLogsLine(startTime: number, endTime: number, outputIsTTY return styled(`No server logs found between ${isoOf(startTime)} and ${isoOf(endTime)}.`, 'yellow', outputIsTTY); } +export function serverLogsPageCapLine(outputIsTTY: boolean): string { + return styled(`Stopped after ${MAX_PAGES} pages of server logs; more remain in this window.`, 'yellow', outputIsTTY); +} + +export function serverLogsResumeLine(resume: ServerLogsResume): string { + const window = `--type server --since ${isoOf(resume.startTime)} --until ${isoOf(resume.endTime)}`; + const search = resume.search === undefined ? '' : ` --search ${shellQuoted(resume.search)}`; + + return ( + `Resume with: ${deploymentLogsHint(resume, resume.deployment)} ${window}` + + `${search} --cursor ${shellQuoted(resume.cursor)}` + ); +} + export function serverLogsRetentionLine(clampedFrom: number, outputIsTTY: boolean): string { return styled(`Server logs are kept for 24 hours; showing from ${isoOf(clampedFrom)}.`, 'yellow', outputIsTTY); } diff --git a/src/logs/server-logs.api.ts b/src/logs/server-logs.api.ts index cea449b9..510724b1 100644 --- a/src/logs/server-logs.api.ts +++ b/src/logs/server-logs.api.ts @@ -14,6 +14,13 @@ export interface ServerLogsWindowParams extends GetDeploymentParams { cursor?: string; } +export function nextCursorOf(page: ServerLogsPage): string | undefined { + const next = page.pageInfo?.newestCursor; + const hasNext = typeof next === 'string' && next.trim() !== ''; + + return page.pageInfo?.hasNewer === true && page.logs.length > 0 && hasNext ? next : undefined; +} + export class ServerLogsApi { constructor(private readonly client: RestApiClient) {} @@ -48,14 +55,11 @@ export class ServerLogsApi { yield page; - const next = page.pageInfo?.newestCursor; - const hasNext = typeof next === 'string' && next.trim() !== ''; + cursor = nextCursorOf(page); - if (page.pageInfo?.hasNewer !== true || page.logs.length === 0 || !hasNext) { + if (cursor === undefined) { return; } - - cursor = next; } } } diff --git a/test/integration/logs-get.test.ts b/test/integration/logs-get.test.ts index 67a7dc32..8f868e34 100644 --- a/test/integration/logs-get.test.ts +++ b/test/integration/logs-get.test.ts @@ -603,6 +603,41 @@ describe('integration: launch:logs:get on the wire', () => { expect(onWire).toEqual([]); }); + it('stops a server that always has more after 100 requests and prints a resume command with an absolute window', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + let reads = 0; + ownedProject(); + deploymentGet('LIVE'); + hub() + .get(SERVER_LOGS) + .query(true) + .times(101) + .reply(200, () => { + reads += 1; + const cursor = `[${NOW - 3_600_000 + reads},"${DEPLOYMENT_UID}"]`; + return { + serverLogs: [{ timestamp: NOW - 3_600_000 + reads, message: `line ${reads}` }], + pageInfo: { hasNewer: true, newestCursor: cursor }, + }; + }); + + const { error, stdout } = await runCommand( + getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server', '--since', '1h'), + config, + ); + const lines = outputLines(stdout); + + expect(error).toBeUndefined(); + expect(reads).toBe(100); + expect(lines).toHaveLength(102); + expect(lines.slice(-2)).toEqual([ + 'Stopped after 100 pages of server logs; more remain in this window.', + `Resume with: csdx launch:logs:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID} ` + + `--deployment ${DEPLOYMENT_UID} --type server --since 2026-10-28T11:00:00.000Z ` + + `--until 2026-10-28T12:00:00.000Z --cursor '[${NOW - 3_600_000 + 100},"${DEPLOYMENT_UID}"]'`, + ]); + }); + it('T55: prints the 24 hour notice first for --since 2d and sends the user own start time', async () => { jest.spyOn(Date, 'now').mockReturnValue(NOW); ownedProject(); From f71e0add8c18ee9934b22532bdc46f7252d85d97 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Fri, 30 Oct 2026 16:51:05 +0530 Subject: [PATCH 05/10] feat(logs): stop a server-log walk on Ctrl-C with a resume command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logs:get --type server now runs inside the same onInterrupt wrapper as the deployment and live follows. Ctrl-C lets the page in flight finish printing, requests no further page, prints "Stopped following the logs." and the resume command from the last printed page's newest cursor, and exits 3 with the listener released. untilInterrupted takes the extra lines to print after the stop line. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/commands/launch/logs/get.test.ts | 36 +++++++++++++++++++---- src/commands/launch/logs/get.ts | 43 +++++++++++++++++++++++----- test/integration/logs-get.test.ts | 26 +++++++++++++---- 3 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/commands/launch/logs/get.test.ts b/src/commands/launch/logs/get.test.ts index 6cf8bc7f..5e6d2e38 100644 --- a/src/commands/launch/logs/get.test.ts +++ b/src/commands/launch/logs/get.test.ts @@ -450,14 +450,40 @@ describe('LogsGet', () => { expect(lines).toEqual([]); }); - it('registers no interrupt listener and reads no deployment logs for --type server', async () => { - const listener = jest.spyOn(interruptible, 'onInterrupt'); + it('stops the server walk on Ctrl-C after the page in hand, prints a resume command, exits 3 and releases', async () => { + const release = jest.fn(); + let interrupt: () => void = () => undefined; + jest.spyOn(interruptible, 'onInterrupt').mockImplementation((listener) => { + interrupt = listener; + return release; + }); const watch = jest.spyOn(DeploymentFollower.prototype, 'watch'); - const { command } = commandUnderTest({ resolved: { 'log-type': 'server' } }); + const { command, lines } = commandUnderTest({ resolved: { 'log-type': 'server', deployment: 'd9' } }); + let requested = 0; + Object.assign((command as unknown as { services: { api: { serverLogs: object } } }).services.api.serverLogs, { + async *pages() { + requested = 1; + yield { logs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }; + requested = 2; + interrupt(); + yield { logs: [{ message: 'two' }], pageInfo: { hasNewer: true, newestCursor: '[2,"b"]' } }; + requested = 3; + yield { logs: [{ message: 'three' }], pageInfo: { hasNewer: false } }; + }, + }); - await command.run(); + const running = command.run(); - expect(listener).not.toHaveBeenCalled(); + await expect(running).rejects.toMatchObject({ oclif: { exit: EXIT_CANCELLED } }); + expect(requested).toBe(2); + expect(lines).toEqual([ + 'one', + 'two', + 'Stopped following the logs.', + 'Resume with: csdx launch:logs:get --org org1 --project proj1 --env env1 --deployment d9 --type server ' + + `--since 2026-10-28T11:00:00.000Z --until 2026-10-28T12:00:00.000Z --cursor '[2,"b"]'`, + ]); + expect(release).toHaveBeenCalledTimes(1); expect(watch).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/launch/logs/get.ts b/src/commands/launch/logs/get.ts index 7124f1fb..43edead0 100644 --- a/src/commands/launch/logs/get.ts +++ b/src/commands/launch/logs/get.ts @@ -41,6 +41,10 @@ const getInputs = inputs({ const { 'log-type': logTypeFlag, ...otherFlags } = flagsFor(getInputs); +interface ServerResume { + cursor?: string; +} + interface LogScope { org: string; project: string; @@ -88,8 +92,13 @@ export default class LogsGet extends LaunchCommand { if (type === 'server') { const window = parseLogWindow({ since: this.resolved.since, until: this.resolved.until }, () => Date.now()); + const scope = await this.verifiedDeployment({ org, project, environment }); + const resume: ServerResume = {}; - await this.printServerLogs(await this.verifiedDeployment({ org, project, environment }), window); + await this.untilInterrupted( + (stop) => this.printServerLogs(scope, window, stop, resume), + () => (resume.cursor === undefined ? [] : [this.resumeLine(scope, window, resume.cursor)]), + ); return; } @@ -119,12 +128,20 @@ export default class LogsGet extends LaunchCommand { return { ...environmentScope, deployment: latest.uid }; } - private async printServerLogs(scope: LogScope, window: LogWindow): Promise { + private resumeLine(scope: LogScope, { startTime, endTime }: LogWindow, cursor: string): string { + return serverLogsResumeLine({ ...scope, startTime, endTime, search: this.resolved.search, cursor }); + } + + private async printServerLogs( + scope: LogScope, + window: LogWindow, + stop: AbortSignal, + resume: ServerResume, + ): Promise { const outputIsTTY = this.services.outputIsTTY === true; const { startTime, endTime, clampedFrom } = window; const { search, cursor } = this.resolved; let printed = 0; - let resumeFrom: string | undefined; if (clampedFrom !== undefined) { this.ux.print(serverLogsRetentionLine(clampedFrom, outputIsTTY)); @@ -136,20 +153,27 @@ export default class LogsGet extends LaunchCommand { printed += 1; } - resumeFrom = nextCursorOf(page); + resume.cursor = nextCursorOf(page); + + if (stop.aborted) { + throw stop.reason; + } } if (printed === 0) { this.ux.print(noServerLogsLine(startTime, endTime, outputIsTTY)); } - if (resumeFrom !== undefined) { + if (resume.cursor !== undefined) { this.ux.print(serverLogsPageCapLine(outputIsTTY)); - this.ux.print(serverLogsResumeLine({ ...scope, startTime, endTime, search, cursor: resumeFrom })); + this.ux.print(this.resumeLine(scope, window, resume.cursor)); } } - private async untilInterrupted(follow: (stop: AbortSignal) => Promise): Promise { + private async untilInterrupted( + follow: (stop: AbortSignal) => Promise, + afterStopped: () => string[] = () => [], + ): Promise { const stop = new AbortController(); const release = onInterrupt(() => stop.abort(new DeploymentWaitInterruptedError('Stopped following the logs.'))); @@ -158,6 +182,11 @@ export default class LogsGet extends LaunchCommand { } catch (error) { if (stop.signal.aborted && error === stop.signal.reason) { this.ux.print(stoppedFollowingLine(this.services.outputIsTTY === true)); + + for (const line of afterStopped()) { + this.ux.print(line); + } + this.exit(EXIT_CANCELLED); } diff --git a/test/integration/logs-get.test.ts b/test/integration/logs-get.test.ts index 8f868e34..c22d7b81 100644 --- a/test/integration/logs-get.test.ts +++ b/test/integration/logs-get.test.ts @@ -727,10 +727,11 @@ describe('integration: launch:logs:get on the wire', () => { expect(stdout.split('\n')).toEqual(['', 'no time', '2026-10-28 11:00:00.000:', '2026-10-28 11:00:01.000: epoch', '', '']); }); - it('T59: a server run registers no SIGINT handler and starts no spinner', async () => { + it('T59: stops a server walk on Ctrl-C with a resume command and exit 3, no spinner, no listener left', async () => { const restoreStdout = stdoutReportingTTY(true); const loader = jest.spyOn(cliux, 'loaderV2'); const baseline = process.listenerCount('SIGINT'); + const firstCursor = `[${NOW - 1_800_000},"${DEPLOYMENT_UID}"]`; let during = -1; try { @@ -739,16 +740,29 @@ describe('integration: launch:logs:get on the wire', () => { deploymentGet('LIVE'); hub() .get(SERVER_LOGS) - .query(true) + .query({ startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc' }) .reply(200, () => { during = process.listenerCount('SIGINT'); - return { serverLogs: [{ timestamp: '2026-10-28T11:30:00.000Z', message: 'hi' }] }; + setImmediate(() => process.emit('SIGINT')); + return { + serverLogs: [{ timestamp: '2026-10-28T11:30:00.000Z', message: 'hi' }], + pageInfo: { hasNewer: true, newestCursor: firstCursor }, + }; }); + const second = hub().get(SERVER_LOGS).query(true).reply(200, { serverLogs: [], pageInfo: { hasNewer: false } }); - const { error } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server'), config); + const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server'), config); - expect(error).toBeUndefined(); - expect(during).toBe(baseline); + expect(error?.oclif?.exit).toBe(3); + expect(outputLines(stdout)).toEqual([ + expect.stringMatching(/^\d{2}:\d{2}:\d{2} {2}hi$/), + 'Stopped following the logs.', + `Resume with: csdx launch:logs:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID} ` + + `--deployment ${DEPLOYMENT_UID} --type server --since 2026-10-28T11:00:00.000Z ` + + `--until 2026-10-28T12:00:00.000Z --cursor '${firstCursor}'`, + ]); + expect(second.isDone()).toBe(false); + expect(during).toBe(baseline + 1); expect(process.listenerCount('SIGINT')).toBe(baseline); expect(loader).not.toHaveBeenCalled(); } finally { From 0ca7ed038b41ce4de27846ecea2337a0ada7ac3b Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Fri, 30 Oct 2026 16:55:48 +0530 Subject: [PATCH 06/10] feat(logs): word the server-log search and cursor errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOGS_ERROR_MESSAGES rewords the four codes the logs service added with CL-7180. INVALID_CURSOR says the cursor did not come from logs:get and to run again without --cursor. SEARCH_TOO_LONG repeats the 256-character limit. INVALID_SOURCE and INVALID_SORT_DIRECTION can only follow a CLI bug, since the CLI never sends source and always sends asc, so they ask for an update. The field-keyed body the real service sends is covered on the wire. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/logs/logs.errors.test.ts | 10 ++++++++++ src/logs/logs.errors.ts | 8 ++++++++ test/integration/logs-get.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/logs/logs.errors.test.ts b/src/logs/logs.errors.test.ts index 7df5638c..07051116 100644 --- a/src/logs/logs.errors.test.ts +++ b/src/logs/logs.errors.test.ts @@ -17,6 +17,16 @@ describe('logs error wording', () => { ['launch.DEPLOYMENT.ENVIRONMENT_MISMATCH', 'That deployment does not belong to this environment.'], ['launch.PROJECT.NOT_FOUND', 'No project found with that UID.'], ['launch.DEPLOYMENT.NOT_FOUND', 'No deployment found with that UID.'], + ['launch.SERVERLOGS.SEARCH_TOO_LONG', '--search must be 256 characters or fewer.'], + [ + 'launch.SERVERLOGS.INVALID_CURSOR', + 'That cursor was not produced by launch:logs:get. Run the command again without --cursor.', + ], + ['launch.SERVERLOGS.INVALID_SOURCE', 'The Launch API rejected the server log query. Update the CLI and try again.'], + [ + 'launch.SERVERLOGS.INVALID_SORT_DIRECTION', + 'The Launch API rejected the server log query. Update the CLI and try again.', + ], ])('rewords %s and never shows the raw message', (code, wording) => { const error = new LaunchApiError(400, [{ code, message: 'raw server text' }], LOGS_ERROR_MESSAGES); diff --git a/src/logs/logs.errors.ts b/src/logs/logs.errors.ts index cb94369f..dd16e106 100644 --- a/src/logs/logs.errors.ts +++ b/src/logs/logs.errors.ts @@ -1,5 +1,8 @@ import type { ErrorMessages } from '../transport/errors'; import { DEPLOYMENT_ERROR_MESSAGES } from '../deployments/deployment.errors'; +import { MAX_SEARCH_LENGTH } from './logs.inputs'; + +const QUERY_REJECTED = 'The Launch API rejected the server log query. Update the CLI and try again.'; export const LOGS_ERROR_MESSAGES: ErrorMessages = { ...DEPLOYMENT_ERROR_MESSAGES, @@ -11,4 +14,9 @@ export const LOGS_ERROR_MESSAGES: ErrorMessages = { 'launch.DEPLOYMENT.PROJECT_MISMATCH': 'That deployment does not belong to this project.', 'launch.DEPLOYMENT.ENVIRONMENT_MISMATCH': 'That deployment does not belong to this environment.', 'launch.PROJECT.NOT_FOUND': 'No project found with that UID.', + 'launch.SERVERLOGS.SEARCH_TOO_LONG': `--search must be ${MAX_SEARCH_LENGTH} characters or fewer.`, + 'launch.SERVERLOGS.INVALID_CURSOR': + 'That cursor was not produced by launch:logs:get. Run the command again without --cursor.', + 'launch.SERVERLOGS.INVALID_SOURCE': QUERY_REJECTED, + 'launch.SERVERLOGS.INVALID_SORT_DIRECTION': QUERY_REJECTED, }; diff --git a/test/integration/logs-get.test.ts b/test/integration/logs-get.test.ts index c22d7b81..4f90fc51 100644 --- a/test/integration/logs-get.test.ts +++ b/test/integration/logs-get.test.ts @@ -709,6 +709,30 @@ describe('integration: launch:logs:get on the wire', () => { expect(onWire).toEqual([]); }, 20_000); + it('words a field-keyed 400 for a foreign --cursor in the CLI own words, sends the cursor as given, exit 1', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + ownedProject(); + deploymentGet('LIVE'); + const read = hub() + .get(SERVER_LOGS) + .query({ startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc', cursor: 'garbage' }) + .reply(400, { + errors: [{ cursor: { code: 'launch.SERVERLOGS.INVALID_CURSOR', message: 'cursor is not valid' } }], + status: 400, + }); + + const { error, stdout } = await runCommand( + getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server', '--cursor', 'garbage'), + config, + ); + + expect(read.isDone()).toBe(true); + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('That cursor was not produced by launch:logs:get. Run the command again without --cursor.'); + expect(stdout).toBe(''); + expect(onWire).toEqual([]); + }); + it('T58: prints a fallback for server entries missing their timestamp, message or everything', async () => { jest.spyOn(Date, 'now').mockReturnValue(NOW); ownedProject(); From f9c0fda822225f44d6487fa23343826106f29113 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Fri, 30 Oct 2026 17:38:15 +0530 Subject: [PATCH 07/10] feat(logs): name the search when no server log matched it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty --search result now reads 'No server logs matched "" between and .' instead of the generic no-logs line, so an empty result is not mistaken for an empty window. .talismanrc: refreshed the checksums of the three test files this branch changed (logs-get.test.ts, get.test.ts, resources.test.ts). #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .talismanrc | 6 +++--- src/commands/launch/logs/get.test.ts | 10 ++++++++++ src/commands/launch/logs/get.ts | 2 +- src/logs/logs.presenter.test.ts | 10 ++++++++++ src/logs/logs.presenter.ts | 6 ++++-- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.talismanrc b/.talismanrc index c9fd18d3..2f68198a 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,8 +1,8 @@ fileignoreconfig: - filename: test/integration/logs-get.test.ts - checksum: 7822d714f476fa4ee588f1b602a7c9e69b8843cc60c09b4ae14cbe4eb2b4ad3b + checksum: fddd8566da6bd9d574005825167bd3ae0d053abb09a49bf49d4566c70fec5639 - filename: src/commands/launch/logs/get.test.ts - checksum: 5ee3d8ba6b014b3a9ac558a750a9a58782bb679d32d30b621e41f977dcc98a0a + checksum: b1184a9ae3845f3a1f70c73d87c1ad287ba8277d190a0783820626a3a4cf60b3 - filename: src/core/failure.test.ts checksum: 242d43885d1e3ef46edf1e347899a90549ebf95ec6827426746852f7a6dfc418 - filename: test/integration/retired-commands.test.ts @@ -228,7 +228,7 @@ fileignoreconfig: allowed_patterns: - "FlagKey" - filename: src/resources.test.ts - checksum: 05a4c54c69bfff1042fa0df038c46881830e3d7014777c87919c5c0739974f45 + checksum: 5966384474a44b82bde8cf71e9c7d8c2e6c3b73e260a957e4691b20d6970b534 allowed_patterns: - "a project name passed on argv to the project resolver" - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" diff --git a/src/commands/launch/logs/get.test.ts b/src/commands/launch/logs/get.test.ts index 5e6d2e38..fd26027e 100644 --- a/src/commands/launch/logs/get.test.ts +++ b/src/commands/launch/logs/get.test.ts @@ -292,6 +292,16 @@ describe('LogsGet', () => { ]); }); + it('names the search in the empty server notice when nothing matched it', async () => { + const { command, lines } = commandUnderTest({ resolved: { 'log-type': 'server', search: 'timeout' } }); + + await command.run(); + + expect(lines).toEqual([ + 'No server logs matched "timeout" between 2026-10-28T11:00:00.000Z and 2026-10-28T12:00:00.000Z.', + ]); + }); + it('prints the 24 hour notice before the lines and sends the window as the user gave it', async () => { const serverLogs = [{ timestamp: '2026-10-27T11:00:00.000Z', message: 'old' }]; const { command, lines, walkCalls } = commandUnderTest({ diff --git a/src/commands/launch/logs/get.ts b/src/commands/launch/logs/get.ts index 43edead0..bf551472 100644 --- a/src/commands/launch/logs/get.ts +++ b/src/commands/launch/logs/get.ts @@ -161,7 +161,7 @@ export default class LogsGet extends LaunchCommand { } if (printed === 0) { - this.ux.print(noServerLogsLine(startTime, endTime, outputIsTTY)); + this.ux.print(noServerLogsLine(startTime, endTime, outputIsTTY, search)); } if (resume.cursor !== undefined) { diff --git a/src/logs/logs.presenter.test.ts b/src/logs/logs.presenter.test.ts index e10ec539..a1ffd1c8 100644 --- a/src/logs/logs.presenter.test.ts +++ b/src/logs/logs.presenter.test.ts @@ -39,6 +39,16 @@ describe('logs presenter', () => { ); }); + it('names the search text when nothing in the window matched it', () => { + const start = Date.parse('2026-10-28T09:00:00.000Z'); + const end = Date.parse('2026-10-28T10:00:00.000Z'); + + expect(noServerLogsLine(start, end, false, 'upstream timeout')).toBe( + 'No server logs matched "upstream timeout" between 2026-10-28T09:00:00.000Z and 2026-10-28T10:00:00.000Z.', + ); + expect(noServerLogsLine(start, end, true, 'x')).toBe(yellow(noServerLogsLine(start, end, false, 'x'))); + }); + it('words the page cap notice in yellow on a terminal and plain on a pipe', () => { expect(serverLogsPageCapLine(false)).toBe('Stopped after 100 pages of server logs; more remain in this window.'); expect(serverLogsPageCapLine(true)).toBe(yellow(serverLogsPageCapLine(false))); diff --git a/src/logs/logs.presenter.ts b/src/logs/logs.presenter.ts index 28a43759..9171ac5c 100644 --- a/src/logs/logs.presenter.ts +++ b/src/logs/logs.presenter.ts @@ -55,8 +55,10 @@ export function noDeploymentLogsLine(deployment: string, outputIsTTY: boolean): return styled(`No deployment logs found for deployment ${deployment}.`, 'yellow', outputIsTTY); } -export function noServerLogsLine(startTime: number, endTime: number, outputIsTTY: boolean): string { - return styled(`No server logs found between ${isoOf(startTime)} and ${isoOf(endTime)}.`, 'yellow', outputIsTTY); +export function noServerLogsLine(startTime: number, endTime: number, outputIsTTY: boolean, search?: string): string { + const found = search === undefined ? 'found' : `matched "${search}"`; + + return styled(`No server logs ${found} between ${isoOf(startTime)} and ${isoOf(endTime)}.`, 'yellow', outputIsTTY); } export function serverLogsPageCapLine(outputIsTTY: boolean): string { From a6061b64734d7fa568af4e0f711eefd4b92569fa Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Fri, 30 Oct 2026 17:38:17 +0530 Subject: [PATCH 08/10] docs(logs): document --search, --cursor and the server-log cursor walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README carries the regenerated logs:get usage with --search and --cursor. AGENTS.md describes the walk: oldest-first pages over newestCursor, nextCursorOf as the one continuation rule, the MAX_PAGES end, the resume command with an absolute window, and Ctrl-C during the walk exiting 3. .talismanrc: refreshed the README.md and AGENTS.md checksums and added src/logs/logs.inputs.test.ts. Talisman flags words like key, password and --var in them; none holds a secret. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .talismanrc | 6 ++++-- AGENTS.md | 18 ++++++++++++++---- README.md | 4 +++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.talismanrc b/.talismanrc index 2f68198a..a72ae2bd 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,6 @@ fileignoreconfig: +- filename: src/logs/logs.inputs.test.ts + checksum: 7b4df8790179c7dc4385acd1d380bb91397523bab111593742e2fc53c94da375 - filename: test/integration/logs-get.test.ts checksum: fddd8566da6bd9d574005825167bd3ae0d053abb09a49bf49d4566c70fec5639 - filename: src/commands/launch/logs/get.test.ts @@ -233,7 +235,7 @@ fileignoreconfig: - "a project name passed on argv to the project resolver" - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" - filename: AGENTS.md - checksum: aafd85677e293bf72dd5764875b202ac3a5531aa083c1c452adaa55021af8c95 + checksum: ba37b6b17d97940a6f7d5e69a531796ab0ee5b6859a9cd5cbdec56e4b7731b26 allowed_patterns: - "Do not add an exemption to a guard to make a new file pass" - "passes its own error wording to `client.request\\(req, MESSAGES\\)`" @@ -329,7 +331,7 @@ fileignoreconfig: - filename: src/commands/launch/env/update.test.ts checksum: 2da219594e7bdd589adc0eff9ff035252a75aaa51dfa20514b18fd3266d7b752 - filename: README.md - checksum: 8f1e4d97fa7e97a4c6a3bdb6bbf3d0f4d3735c3dfc23579a4d0a7c6d725bc8e4 + checksum: d054f1c14732d42c745f6e038f187aa5e04f6789b4d58cfaf5604e43fabc714e allowed_patterns: - "password-protection" - "-p, --password" diff --git a/AGENTS.md b/AGENTS.md index 682770c3..8ff3847f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,9 +163,9 @@ src/ git/ the internal git-namespace / repository / branch lookups organizations/ the --org flag, its organization picker, and the lookup behind it, which reads the Contentstack Management API rather than Launch - logs/ logs:get: the server-log window parser and repository, the live log API over SSE, and - the LiveLogFollower that reconnects it; the build-log follow is deployments'. It - reaches deployments only + logs/ logs:get: the server-log window parser, the server-log repository and its cursor walk, + the live log API over SSE, and the LiveLogFollower that reconnects it; the build-log + follow is deployments'. It reaches deployments only cache/ the CDN cache purge behind launch:cache:purge: CacheApi, the --path / --prefix / --tags / --hosts inputs and their normalisation, the criterion prompt and the success lines site/ the URL choice for an environment's live site (`siteUrlOf`, shared with the deployment follower), @@ -495,7 +495,7 @@ promoting these to `src/core/catalog.ts` now. | 0 | none - oclif exits 0 implicitly | the command did what it was asked to do | | 1 | `EXIT_RUNTIME` | a runtime failure - `LaunchApiError`, `LaunchNetworkError`, `UnauthenticatedError`, anything oclif handles | | 2 | `EXIT_USAGE` | a usage error - `UsageError`, `MissingInputError`, a failing cross-flag rule | -| 3 | `EXIT_CANCELLED` | the user declined a confirmation, chose nothing at a picker (`CancelledError`) or pressed Ctrl-C while the CLI was following a deployment's logs or a live log stream (`logs:get`) | +| 3 | `EXIT_CANCELLED` | the user declined a confirmation, chose nothing at a picker (`CancelledError`) or pressed Ctrl-C while the CLI was following a deployment's logs, walking server-log pages or following a live log stream (`logs:get`) | `launch:functions:serve` is deliberately a plain oclif `Command` rather than a `LaunchCommand`, because it talks to no API and needs no auth gate, and it is **outside** @@ -746,6 +746,16 @@ server's own 5-minute cap: it exits 0 and is never followed by a reconnect. Ctrl sleep and the socket listen to, exit 3. The header builder, `buildRequestHeaders` in `rest-client.ts`, is the only place `X-CS-CLI`, `x-cs-api-version`, the auth headers and the org/project scope headers are assembled. +**Server logs are a cursor walk.** `logs:get --type server` reads `GET …/logs/server-logs` oldest-first +(`sortDirection=asc`) through `ServerLogsApi.pages`, which follows `pageInfo.newestCursor` with the same window, +`--search` and `--cursor` and prints each page as it arrives, so a window is never held in memory. `nextCursorOf` +is the one rule for "this page leads to another": `hasNewer` true, a non-empty page and a non-blank cursor. The walk +also ends after `MAX_PAGES` pages, and a logs service without `pageInfo` (before CL-7180) ends it after one. When the +last page still leads on, at the cap or on Ctrl-C (exit 3, after the page in hand is printed), the command prints a +`Resume with:` command whose `--since`/`--until` are the **absolute** window of the run: re-resolving a relative +`--since` on resume would move the start past the cursor and silently skip the logs between. The cursor is opaque; +the CLI prints it single-quoted and never parses it, and the server's `INVALID_CURSOR` is the only check on it. + **Writing a test for it: the fake clock must advance.** `now()` and `sleep()` are injected and both are required, not optional with defaults. A fake `now: () => 0` with a `sleep` that does nothing makes the deadline unreachable and the suite **hangs** rather than failing - the same failure mode diff --git a/README.md b/README.md index bb845f8b..f8e447bb 100644 --- a/README.md +++ b/README.md @@ -557,15 +557,17 @@ Read the build or server logs of a deployment of a Launch environment ``` USAGE $ csdx launch:logs:get [-c ] [-d ] [--org ] [--project ] [--env ] [--deployment - ] [--since ] [--until ] [--type ] + ] [--since ] [--until ] [--search ] [--cursor ] [--type ] FLAGS -c, --config= Path to the local '.cs-launch.json' file -d, --data-dir= Current working directory + --cursor= Server logs only: resume from the cursor printed by an earlier run --deployment= Deployment UID --env= Environment name or UID --org= Organization UID --project= Project name or UID + --search= Server logs only: show only lines containing this text, up to 256 characters --since= Server logs only: start of the window, relative (45s, 30m, 12hr, 2d) or an ISO 8601 timestamp with an offset. Defaults to one hour before --until --type= Kind of logs to read (deployment | server | live). Defaults to deployment From ec8c3e52a7fde85d18d4bf41e3daeffc2ba73660 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Fri, 30 Oct 2026 17:46:18 +0530 Subject: [PATCH 09/10] fix(logs): refuse --search and --cursor when the logs service ignores them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A logs service without CL-7180 returns no pageInfo and drops search and cursor, so --search would print every log in the window as if it matched and --cursor would start the window over. ServerLogsApi.page now exits 1 with "The Launch API in this region does not support --search or --cursor for server logs yet." when either was sent and no pageInfo came back. A plain window still works against either service. .talismanrc: refreshed the logs-get.test.ts checksum. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .talismanrc | 2 +- src/logs/server-logs.api.test.ts | 22 +++++++++++++++++++--- src/logs/server-logs.api.ts | 10 +++++++++- test/integration/logs-get.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/.talismanrc b/.talismanrc index a72ae2bd..cca8cba6 100644 --- a/.talismanrc +++ b/.talismanrc @@ -2,7 +2,7 @@ fileignoreconfig: - filename: src/logs/logs.inputs.test.ts checksum: 7b4df8790179c7dc4385acd1d380bb91397523bab111593742e2fc53c94da375 - filename: test/integration/logs-get.test.ts - checksum: fddd8566da6bd9d574005825167bd3ae0d053abb09a49bf49d4566c70fec5639 + checksum: ce0b900bad922e911896de19387686fdc224ba6e62b380dae09e7153c59770a8 - filename: src/commands/launch/logs/get.test.ts checksum: b1184a9ae3845f3a1f70c73d87c1ad287ba8277d190a0783820626a3a4cf60b3 - filename: src/core/failure.test.ts diff --git a/src/logs/server-logs.api.test.ts b/src/logs/server-logs.api.test.ts index 4315f666..0fc5764b 100644 --- a/src/logs/server-logs.api.test.ts +++ b/src/logs/server-logs.api.test.ts @@ -70,13 +70,14 @@ describe('ServerLogsApi', () => { expect(messages[0]).toBe(LOGS_ERROR_MESSAGES); }); - it('sends the search text and the cursor exactly as given and returns no page info when the server sends none', async () => { + it('sends the search text and the cursor exactly as given', async () => { const cursor = `[${Date.now()},"${randomBytes(6).toString('hex')}"]`; - const { client, requests } = fakeRestClient({ serverLogs: [] }); + const pageInfo = { hasNewer: false, newestCursor: null }; + const { client, requests } = fakeRestClient({ serverLogs: [], pageInfo }); const result = await new ServerLogsApi(client).page({ ...WINDOW, search: 'upstream timeout', cursor }); - expect(result).toEqual({ logs: [], pageInfo: undefined }); + expect(result).toEqual({ logs: [], pageInfo }); expect(requests[0].query).toEqual({ startTime: 1_000, endTime: 2_000, @@ -165,6 +166,21 @@ describe('ServerLogsApi', () => { expect(calls).toBe(2); }); + it.each([ + [{ search: 'timeout' }], + [{ cursor: '[1,"a"]' }], + ])('refuses a page without page info for %j, since an older logs service ignores both', async (extra) => { + const { client } = fakeRestClient({ serverLogs: [{ message: 'unfiltered' }] }); + + const reading = new ServerLogsApi(client).page({ ...WINDOW, ...extra }); + + await expect(reading).rejects.toThrow(LaunchApiError); + await expect(reading).rejects.toThrow( + 'The Launch API in this region does not support --search or --cursor for server logs yet. ' + + 'Run the command without them.', + ); + }); + it.each([[{}], [{ serverLogs: null }], [{ serverLogs: {} }], [null], ['text']])( 'raises a malformed-response error when the body %j has no serverLogs array', async (body) => { diff --git a/src/logs/server-logs.api.ts b/src/logs/server-logs.api.ts index 510724b1..566cff50 100644 --- a/src/logs/server-logs.api.ts +++ b/src/logs/server-logs.api.ts @@ -1,6 +1,7 @@ import { MAX_PAGES } from '../core/constants'; import { DeploymentsApi, type GetDeploymentParams } from '../deployments/deployments.api'; -import { assertArray } from '../transport/envelope'; +import { isRecord } from '../core/values'; +import { assertArray, malformed } from '../transport/envelope'; import type { RestApiClient } from '../transport/rest-client'; import { LOGS_ERROR_MESSAGES } from './logs.errors'; import type { ServerLogsPage, ServerLogsResponse } from './types'; @@ -44,6 +45,13 @@ export class ServerLogsApi { assertArray(response, 'serverLogs', 'server log page'); + if ((params.search !== undefined || params.cursor !== undefined) && !isRecord(response.pageInfo)) { + throw malformed( + 'The Launch API in this region does not support --search or --cursor for server logs yet. ' + + 'Run the command without them.', + ); + } + return { logs: response.serverLogs, pageInfo: response.pageInfo }; } diff --git a/test/integration/logs-get.test.ts b/test/integration/logs-get.test.ts index 4f90fc51..d4318e26 100644 --- a/test/integration/logs-get.test.ts +++ b/test/integration/logs-get.test.ts @@ -733,6 +733,29 @@ describe('integration: launch:logs:get on the wire', () => { expect(onWire).toEqual([]); }); + it('exits 1 without printing unfiltered logs when a logs service before CL-7180 ignores --search', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + ownedProject(); + deploymentGet('LIVE'); + const read = hub() + .get(SERVER_LOGS) + .query({ startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc', search: 'timeout' }) + .reply(200, { serverLogs: [{ timestamp: '2026-10-28T11:30:00.000Z', message: 'GET / 200' }] }); + + const { error, stdout } = await runCommand( + getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server', '--search', 'timeout'), + config, + ); + + expect(read.isDone()).toBe(true); + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe( + 'The Launch API in this region does not support --search or --cursor for server logs yet. ' + + 'Run the command without them.', + ); + expect(stdout).toBe(''); + }); + it('T58: prints a fallback for server entries missing their timestamp, message or everything', async () => { jest.spyOn(Date, 'now').mockReturnValue(NOW); ownedProject(); From 92592d3c5a55e5003c5217e7e94ebecb7bc69a62 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Mon, 2 Nov 2026 10:27:05 +0530 Subject: [PATCH 10/10] fix(logs): keep the 5000-line notice on a logs service without page info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (dhruv-parekh-cs): a logs service before CL-7180 caps a window at its newest 5000 lines and sends no pageInfo, so the walk ends after one page and nextCursorOf gives no resume command. The truncation went silent. logs:get --type server now prints the old notice, "Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.", when the last page has no pageInfo and holds exactly SERVER_LOGS_PAGE_CAP lines. With CL-7180 deployed pageInfo is always present and the resume command covers truncation. .talismanrc: refreshed the checksums of get.test.ts and logs-get.test.ts. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .talismanrc | 4 ++-- src/commands/launch/logs/get.test.ts | 17 +++++++++++++++++ src/commands/launch/logs/get.ts | 9 +++++++++ src/logs/logs.presenter.test.ts | 8 ++++++++ src/logs/logs.presenter.ts | 11 ++++++++++- src/logs/types.ts | 2 ++ test/integration/logs-get.test.ts | 22 ++++++++++++++++++++++ 7 files changed, 70 insertions(+), 3 deletions(-) diff --git a/.talismanrc b/.talismanrc index cca8cba6..42a7f0ac 100644 --- a/.talismanrc +++ b/.talismanrc @@ -2,9 +2,9 @@ fileignoreconfig: - filename: src/logs/logs.inputs.test.ts checksum: 7b4df8790179c7dc4385acd1d380bb91397523bab111593742e2fc53c94da375 - filename: test/integration/logs-get.test.ts - checksum: ce0b900bad922e911896de19387686fdc224ba6e62b380dae09e7153c59770a8 + checksum: 909965f29b2e6a0b6220b22c175b4a39edc05759fba1274a7a1f2f750a7e0e3b - filename: src/commands/launch/logs/get.test.ts - checksum: b1184a9ae3845f3a1f70c73d87c1ad287ba8277d190a0783820626a3a4cf60b3 + checksum: d94d390de150956c799f64a84adfe0e02b5b21c66ddcc5cf55a1329aa67ec1a7 - filename: src/core/failure.test.ts checksum: 242d43885d1e3ef46edf1e347899a90549ebf95ec6827426746852f7a6dfc418 - filename: test/integration/retired-commands.test.ts diff --git a/src/commands/launch/logs/get.test.ts b/src/commands/launch/logs/get.test.ts index fd26027e..b5d6e9e6 100644 --- a/src/commands/launch/logs/get.test.ts +++ b/src/commands/launch/logs/get.test.ts @@ -268,6 +268,23 @@ describe('LogsGet', () => { ]); }); + it.each([ + [5000, true], + [4999, false], + ])('on a logs service without page info, %i lines show the 5000-line notice: %s', async (count, shows) => { + const logs = Array.from({ length: count }, (_, index) => ({ message: `line ${index}` })); + const { command, lines } = commandUnderTest({ resolved: { 'log-type': 'server' }, serverPages: [{ logs }] }); + + await command.run(); + + expect(lines).toHaveLength(shows ? count + 1 : count); + expect(lines.at(-1)).toBe( + shows + ? 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.' + : `line ${count - 1}`, + ); + }); + it('prints a full page of 5000 lines with no cap notice when the server reports nothing newer', async () => { const serverLogs = Array.from({ length: 5000 }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', diff --git a/src/commands/launch/logs/get.ts b/src/commands/launch/logs/get.ts index bf551472..6780e23d 100644 --- a/src/commands/launch/logs/get.ts +++ b/src/commands/launch/logs/get.ts @@ -18,6 +18,7 @@ import { noDeploymentLogsLine, noServerLogsLine, serverLogLine, + serverLogsCapLine, serverLogsPageCapLine, serverLogsResumeLine, serverLogsRetentionLine, @@ -26,6 +27,8 @@ import { import { LiveLogFollower } from '../../../logs/live-log.follower'; import { parseLogWindow, type LogWindow } from '../../../logs/log-window'; import { nextCursorOf } from '../../../logs/server-logs.api'; +import { SERVER_LOGS_PAGE_CAP, type ServerLogsPage } from '../../../logs/types'; +import { isRecord } from '../../../core/values'; const getInputs = inputs({ org: { required: true }, @@ -142,6 +145,7 @@ export default class LogsGet extends LaunchCommand { const { startTime, endTime, clampedFrom } = window; const { search, cursor } = this.resolved; let printed = 0; + let last: ServerLogsPage | undefined; if (clampedFrom !== undefined) { this.ux.print(serverLogsRetentionLine(clampedFrom, outputIsTTY)); @@ -154,6 +158,7 @@ export default class LogsGet extends LaunchCommand { } resume.cursor = nextCursorOf(page); + last = page; if (stop.aborted) { throw stop.reason; @@ -164,6 +169,10 @@ export default class LogsGet extends LaunchCommand { this.ux.print(noServerLogsLine(startTime, endTime, outputIsTTY, search)); } + if (last !== undefined && !isRecord(last.pageInfo) && last.logs.length === SERVER_LOGS_PAGE_CAP) { + this.ux.print(serverLogsCapLine(outputIsTTY)); + } + if (resume.cursor !== undefined) { this.ux.print(serverLogsPageCapLine(outputIsTTY)); this.ux.print(this.resumeLine(scope, window, resume.cursor)); diff --git a/src/logs/logs.presenter.test.ts b/src/logs/logs.presenter.test.ts index a1ffd1c8..a7d5ef8c 100644 --- a/src/logs/logs.presenter.test.ts +++ b/src/logs/logs.presenter.test.ts @@ -8,6 +8,7 @@ import { noDeploymentLogsLine, noServerLogsLine, serverLogLine, + serverLogsCapLine, serverLogsPageCapLine, serverLogsResumeLine, serverLogsRetentionLine, @@ -49,6 +50,13 @@ describe('logs presenter', () => { expect(noServerLogsLine(start, end, true, 'x')).toBe(yellow(noServerLogsLine(start, end, false, 'x'))); }); + it('words the 5000-line notice of a logs service without page info exactly', () => { + expect(serverLogsCapLine(false)).toBe( + 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.', + ); + expect(serverLogsCapLine(true)).toBe(yellow(serverLogsCapLine(false))); + }); + it('words the page cap notice in yellow on a terminal and plain on a pipe', () => { expect(serverLogsPageCapLine(false)).toBe('Stopped after 100 pages of server logs; more remain in this window.'); expect(serverLogsPageCapLine(true)).toBe(yellow(serverLogsPageCapLine(false))); diff --git a/src/logs/logs.presenter.ts b/src/logs/logs.presenter.ts index 9171ac5c..12ad24b4 100644 --- a/src/logs/logs.presenter.ts +++ b/src/logs/logs.presenter.ts @@ -4,7 +4,7 @@ import { clockOf, deploymentLogLine } from '../deployments/deployment.presenter' import type { Deployment } from '../deployments/types'; import { styled } from '../core/style'; import { isRecord } from '../core/values'; -import type { ServerLog } from './types'; +import { SERVER_LOGS_PAGE_CAP, type ServerLog } from './types'; const MS_PER_MINUTE = 60_000; @@ -61,6 +61,15 @@ export function noServerLogsLine(startTime: number, endTime: number, outputIsTTY return styled(`No server logs ${found} between ${isoOf(startTime)} and ${isoOf(endTime)}.`, 'yellow', outputIsTTY); } +export function serverLogsCapLine(outputIsTTY: boolean): string { + return styled( + `Showing the newest ${SERVER_LOGS_PAGE_CAP} server log lines in this window. ` + + 'Narrow --since/--until to see earlier ones.', + 'yellow', + outputIsTTY, + ); +} + export function serverLogsPageCapLine(outputIsTTY: boolean): string { return styled(`Stopped after ${MAX_PAGES} pages of server logs; more remain in this window.`, 'yellow', outputIsTTY); } diff --git a/src/logs/types.ts b/src/logs/types.ts index de8af6ba..4d6ae1f1 100644 --- a/src/logs/types.ts +++ b/src/logs/types.ts @@ -1,3 +1,5 @@ +export const SERVER_LOGS_PAGE_CAP = 5000; + export interface ServerLog { timestamp?: string | number; message?: string; diff --git a/test/integration/logs-get.test.ts b/test/integration/logs-get.test.ts index d4318e26..3aad9c8b 100644 --- a/test/integration/logs-get.test.ts +++ b/test/integration/logs-get.test.ts @@ -638,6 +638,28 @@ describe('integration: launch:logs:get on the wire', () => { ]); }); + it('T54: keeps the 5000-line notice when a logs service before CL-7180 returns a full page with no page info', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + ownedProject(); + deploymentGet('LIVE'); + const read = hub() + .get(SERVER_LOGS) + .query({ startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc' }) + .reply(200, { + serverLogs: Array.from({ length: 5000 }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', message: `line ${index}` })), + }); + + const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server'), config); + const lines = outputLines(stdout); + + expect(error).toBeUndefined(); + expect(read.isDone()).toBe(true); + expect(lines).toHaveLength(5001); + expect(lines.at(-1)).toBe( + 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.', + ); + }); + it('T55: prints the 24 hour notice first for --since 2d and sends the user own start time', async () => { jest.spyOn(Date, 'now').mockReturnValue(NOW); ownedProject();