@shipfox/client-logs 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +9 -0
- package/.storybook/preview.tsx +56 -0
- package/.swcrc +42 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +93 -0
- package/LICENSE +21 -0
- package/dist/components/agent-session-rows.d.ts +8 -0
- package/dist/components/agent-session-rows.d.ts.map +1 -0
- package/dist/components/agent-session-rows.js +374 -0
- package/dist/components/agent-session-rows.js.map +1 -0
- package/dist/components/index.d.ts +5 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/log-group.d.ts +11 -0
- package/dist/components/log-group.d.ts.map +1 -0
- package/dist/components/log-group.js +57 -0
- package/dist/components/log-group.js.map +1 -0
- package/dist/components/log-view.d.ts +20 -0
- package/dist/components/log-view.d.ts.map +1 -0
- package/dist/components/log-view.js +243 -0
- package/dist/components/log-view.js.map +1 -0
- package/dist/components/output-log-row.d.ts +9 -0
- package/dist/components/output-log-row.d.ts.map +1 -0
- package/dist/components/output-log-row.js +24 -0
- package/dist/components/output-log-row.js.map +1 -0
- package/dist/components/system-markers.d.ts +21 -0
- package/dist/components/system-markers.d.ts.map +1 -0
- package/dist/components/system-markers.js +113 -0
- package/dist/components/system-markers.js.map +1 -0
- package/dist/core/log-read.d.ts +33 -0
- package/dist/core/log-read.d.ts.map +1 -0
- package/dist/core/log-read.js +49 -0
- package/dist/core/log-read.js.map +1 -0
- package/dist/core/log-tree.d.ts +85 -0
- package/dist/core/log-tree.d.ts.map +1 -0
- package/dist/core/log-tree.js +139 -0
- package/dist/core/log-tree.js.map +1 -0
- package/dist/env.d.js +2 -0
- package/dist/env.d.js.map +1 -0
- package/dist/hooks/api/step-logs.d.ts +24 -0
- package/dist/hooks/api/step-logs.d.ts.map +1 -0
- package/dist/hooks/api/step-logs.js +129 -0
- package/dist/hooks/api/step-logs.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +84 -0
- package/src/components/agent-session-rows.tsx +341 -0
- package/src/components/index.ts +15 -0
- package/src/components/log-group.stories.tsx +204 -0
- package/src/components/log-group.tsx +54 -0
- package/src/components/log-view.stories.tsx +584 -0
- package/src/components/log-view.test.tsx +266 -0
- package/src/components/log-view.tsx +281 -0
- package/src/components/output-log-row.stories.tsx +76 -0
- package/src/components/output-log-row.tsx +35 -0
- package/src/components/system-markers.stories.tsx +46 -0
- package/src/components/system-markers.tsx +148 -0
- package/src/core/log-read.test.ts +219 -0
- package/src/core/log-read.ts +79 -0
- package/src/core/log-tree.test.ts +380 -0
- package/src/core/log-tree.ts +200 -0
- package/src/env.d.ts +7 -0
- package/src/hooks/api/step-logs-query.test.tsx +283 -0
- package/src/hooks/api/step-logs.test.ts +66 -0
- package/src/hooks/api/step-logs.ts +166 -0
- package/src/index.ts +18 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- package/vitest.config.ts +70 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
3
|
+
import {cleanup, renderHook, waitFor} from '@testing-library/react';
|
|
4
|
+
import type {ReactNode} from 'react';
|
|
5
|
+
import {STEP_LOG_DRAIN_REFETCH_MS, STEP_LOG_LIVE_REFETCH_MS} from '#core/log-read.js';
|
|
6
|
+
import {type UseStepAttemptLogsQueryOptions, useStepAttemptLogsQuery} from './step-logs.js';
|
|
7
|
+
|
|
8
|
+
const STEP_ID = '11111111-1111-4111-8111-111111111111';
|
|
9
|
+
|
|
10
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
11
|
+
return new Response(JSON.stringify(body), {
|
|
12
|
+
headers: {'content-type': 'application/json'},
|
|
13
|
+
status: 200,
|
|
14
|
+
...init,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const outputLine = (data: string, ts = 1): string =>
|
|
19
|
+
`${JSON.stringify({v: 1, ts, type: 'output', stream: 'stdout', data})}\n`;
|
|
20
|
+
const inlineBody = ({
|
|
21
|
+
ndjson,
|
|
22
|
+
nextCursor,
|
|
23
|
+
hasMore = false,
|
|
24
|
+
state = 'open',
|
|
25
|
+
}: {
|
|
26
|
+
ndjson: string;
|
|
27
|
+
nextCursor: number;
|
|
28
|
+
hasMore?: boolean;
|
|
29
|
+
state?: 'open' | 'closed';
|
|
30
|
+
}) => ({
|
|
31
|
+
mode: 'inline',
|
|
32
|
+
ndjson,
|
|
33
|
+
next_cursor: nextCursor,
|
|
34
|
+
has_more: hasMore,
|
|
35
|
+
state,
|
|
36
|
+
truncated: false,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const presignedBody = () => ({
|
|
40
|
+
mode: 'presigned',
|
|
41
|
+
url: 'https://storage.example.test/logs/object?sig=1',
|
|
42
|
+
state: 'closed',
|
|
43
|
+
expires_at: '2026-06-23T10:00:00.000Z',
|
|
44
|
+
total_bytes: 128,
|
|
45
|
+
truncated: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function renderStepLogsHook(
|
|
49
|
+
params: {stepId: string | undefined; attempt: number | undefined} = {
|
|
50
|
+
stepId: STEP_ID,
|
|
51
|
+
attempt: 1,
|
|
52
|
+
},
|
|
53
|
+
options: UseStepAttemptLogsQueryOptions = {},
|
|
54
|
+
) {
|
|
55
|
+
const queryClient = new QueryClient({
|
|
56
|
+
defaultOptions: {queries: {retry: false}},
|
|
57
|
+
});
|
|
58
|
+
const wrapper = ({children}: {children: ReactNode}) => (
|
|
59
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return renderHook(() => useStepAttemptLogsQuery(params.stepId, params.attempt, options), {
|
|
63
|
+
wrapper,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requestsFrom(fetchImpl: ReturnType<typeof vi.fn>): Request[] {
|
|
68
|
+
return fetchImpl.mock.calls.map((call) => call[0] as Request);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('useStepAttemptLogsQuery', () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
cleanup();
|
|
74
|
+
vi.useRealTimers();
|
|
75
|
+
vi.unstubAllGlobals();
|
|
76
|
+
configureApiClient({baseUrl: '', fetchImpl: undefined});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('does not fetch until step id and attempt are available', () => {
|
|
80
|
+
const fetchImpl = vi.fn(async () => jsonResponse(inlineBody({ndjson: '', nextCursor: 0})));
|
|
81
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
82
|
+
|
|
83
|
+
renderStepLogsHook({stepId: undefined, attempt: undefined});
|
|
84
|
+
|
|
85
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('uses the cached cursor on the next fetch and appends live records', async () => {
|
|
89
|
+
const fetchImpl = vi
|
|
90
|
+
.fn()
|
|
91
|
+
.mockResolvedValueOnce(
|
|
92
|
+
jsonResponse(inlineBody({ndjson: outputLine('alpha\n', 1), nextCursor: 5})),
|
|
93
|
+
)
|
|
94
|
+
.mockResolvedValueOnce(
|
|
95
|
+
jsonResponse(inlineBody({ndjson: outputLine('beta\n', 2), nextCursor: 6})),
|
|
96
|
+
);
|
|
97
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
98
|
+
|
|
99
|
+
const {result} = renderStepLogsHook();
|
|
100
|
+
await waitFor(() => expect(result.current.data?.records).toHaveLength(1));
|
|
101
|
+
await result.current.refetch();
|
|
102
|
+
|
|
103
|
+
await waitFor(() => expect(result.current.data?.records).toHaveLength(2));
|
|
104
|
+
const urls = requestsFrom(fetchImpl).map((request) => new URL(request.url));
|
|
105
|
+
expect(urls.map((url) => url.searchParams.get('cursor'))).toEqual(['0', '5']);
|
|
106
|
+
expect(result.current.data?.records.map((record) => record.type)).toEqual(['output', 'output']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('fetches and parses compacted presigned logs into the same record state', async () => {
|
|
110
|
+
const fetchImpl = vi.fn(async () => jsonResponse(presignedBody()));
|
|
111
|
+
const objectFetch = vi.fn(async () => new Response(outputLine('cold\n', 3), {status: 200}));
|
|
112
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
113
|
+
vi.stubGlobal('fetch', objectFetch);
|
|
114
|
+
|
|
115
|
+
const {result} = renderStepLogsHook();
|
|
116
|
+
|
|
117
|
+
await waitFor(() => expect(result.current.data?.source).toBe('presigned'));
|
|
118
|
+
expect(objectFetch).toHaveBeenCalledWith('https://storage.example.test/logs/object?sig=1', {
|
|
119
|
+
signal: expect.any(AbortSignal),
|
|
120
|
+
});
|
|
121
|
+
expect(result.current.data?.records).toMatchObject([{type: 'output', data: 'cold\n'}]);
|
|
122
|
+
expect(result.current.data?.state).toBe('compacted');
|
|
123
|
+
expect(result.current.data?.complete).toBe(true);
|
|
124
|
+
expect(result.current.data?.totalBytes).toBe(128);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('keeps prior records visible when a refetch fails', async () => {
|
|
128
|
+
const fetchImpl = vi
|
|
129
|
+
.fn()
|
|
130
|
+
.mockResolvedValueOnce(
|
|
131
|
+
jsonResponse(inlineBody({ndjson: outputLine('alpha\n', 1), nextCursor: 5})),
|
|
132
|
+
)
|
|
133
|
+
.mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
134
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
135
|
+
|
|
136
|
+
const {result} = renderStepLogsHook();
|
|
137
|
+
await waitFor(() => expect(result.current.data?.records).toHaveLength(1));
|
|
138
|
+
const refetch = await result.current.refetch();
|
|
139
|
+
|
|
140
|
+
expect(refetch.error).toBeTruthy();
|
|
141
|
+
expect(result.current.data?.records).toMatchObject([{type: 'output', data: 'alpha\n'}]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('retries transient initial errors before surfacing a failure', async () => {
|
|
145
|
+
const fetchImpl = vi
|
|
146
|
+
.fn()
|
|
147
|
+
.mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}))
|
|
148
|
+
.mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}))
|
|
149
|
+
.mockResolvedValueOnce(
|
|
150
|
+
jsonResponse(inlineBody({ndjson: outputLine('eventual logs\n', 1), nextCursor: 5})),
|
|
151
|
+
);
|
|
152
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
153
|
+
|
|
154
|
+
const {result} = renderStepLogsHook(
|
|
155
|
+
{stepId: STEP_ID, attempt: 1},
|
|
156
|
+
{initialErrorRetryCount: 2, initialErrorRetryDelayMs: 10},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await waitFor(() => expect(result.current.data?.records).toHaveLength(1));
|
|
160
|
+
expect(result.current.data?.records).toMatchObject([{type: 'output', data: 'eventual logs\n'}]);
|
|
161
|
+
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('stops polling after a persistent error instead of re-polling the dead cursor', async () => {
|
|
165
|
+
const fetchImpl = vi
|
|
166
|
+
.fn()
|
|
167
|
+
.mockResolvedValueOnce(
|
|
168
|
+
jsonResponse(inlineBody({ndjson: outputLine('alpha\n', 1), nextCursor: 5, hasMore: true})),
|
|
169
|
+
)
|
|
170
|
+
.mockResolvedValue(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
171
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
172
|
+
|
|
173
|
+
const {result} = renderStepLogsHook();
|
|
174
|
+
await waitFor(() => expect(result.current.data?.records).toHaveLength(1));
|
|
175
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
176
|
+
const callsWhenErrored = fetchImpl.mock.calls.length;
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
178
|
+
|
|
179
|
+
expect(fetchImpl.mock.calls.length).toBe(callsWhenErrored);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('keeps polling a missing stream when requested and merges the later response', async () => {
|
|
183
|
+
const fetchImpl = vi
|
|
184
|
+
.fn()
|
|
185
|
+
.mockResolvedValueOnce(jsonResponse({code: 'not-found'}, {status: 404}))
|
|
186
|
+
.mockResolvedValueOnce(
|
|
187
|
+
jsonResponse(
|
|
188
|
+
inlineBody({ndjson: outputLine('stream created\n', 1), nextCursor: 5, hasMore: true}),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
.mockResolvedValueOnce(
|
|
192
|
+
jsonResponse(inlineBody({ndjson: outputLine('next line\n', 2), nextCursor: 6})),
|
|
193
|
+
);
|
|
194
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
195
|
+
|
|
196
|
+
const {result} = renderStepLogsHook({stepId: STEP_ID, attempt: 1}, {retryMissingStream: true});
|
|
197
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
198
|
+
await waitFor(() => expect(result.current.data?.records).toHaveLength(2), {
|
|
199
|
+
timeout: STEP_LOG_LIVE_REFETCH_MS + STEP_LOG_DRAIN_REFETCH_MS + 1_500,
|
|
200
|
+
});
|
|
201
|
+
expect(result.current.data?.records.map((record) => record.type)).toEqual(['output', 'output']);
|
|
202
|
+
const urls = requestsFrom(fetchImpl).map((request) => new URL(request.url));
|
|
203
|
+
expect(urls.map((url) => url.searchParams.get('cursor'))).toEqual(['0', '0', '5']);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('settles a bounded missing stream retry window as complete empty logs', async () => {
|
|
207
|
+
const fetchImpl = vi.fn(async () => jsonResponse({code: 'not-found'}, {status: 404}));
|
|
208
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
209
|
+
|
|
210
|
+
const {result} = renderStepLogsHook(
|
|
211
|
+
{stepId: STEP_ID, attempt: 1},
|
|
212
|
+
{retryMissingStream: true, missingStreamRetryCount: 2, missingStreamRetryDelayMs: 10},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await waitFor(() => expect(result.current.data?.complete).toBe(true));
|
|
216
|
+
expect(result.current.data?.records).toEqual([]);
|
|
217
|
+
expect(result.current.data?.state).toBe('closed');
|
|
218
|
+
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('starts a fresh bounded window after unbounded missing-stream polling', async () => {
|
|
222
|
+
const fetchImpl = vi.fn(async () => jsonResponse({code: 'not-found'}, {status: 404}));
|
|
223
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
224
|
+
const queryClient = new QueryClient({
|
|
225
|
+
defaultOptions: {queries: {retry: false}},
|
|
226
|
+
});
|
|
227
|
+
const wrapper = ({children}: {children: ReactNode}) => (
|
|
228
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
229
|
+
);
|
|
230
|
+
const initialProps: {options: UseStepAttemptLogsQueryOptions} = {
|
|
231
|
+
options: {retryMissingStream: true},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const {result, rerender} = renderHook(
|
|
235
|
+
({options}: {options: UseStepAttemptLogsQueryOptions}) =>
|
|
236
|
+
useStepAttemptLogsQuery(STEP_ID, 1, options),
|
|
237
|
+
{wrapper, initialProps},
|
|
238
|
+
);
|
|
239
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
240
|
+
await result.current.refetch();
|
|
241
|
+
await result.current.refetch();
|
|
242
|
+
|
|
243
|
+
rerender({
|
|
244
|
+
options: {
|
|
245
|
+
retryMissingStream: true,
|
|
246
|
+
missingStreamRetryCount: 1,
|
|
247
|
+
missingStreamRetryDelayMs: 10,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
const firstBoundedRefetch = await result.current.refetch();
|
|
251
|
+
const secondBoundedRefetch = await result.current.refetch();
|
|
252
|
+
|
|
253
|
+
expect(firstBoundedRefetch.data).toBeUndefined();
|
|
254
|
+
expect(firstBoundedRefetch.error).toBeTruthy();
|
|
255
|
+
expect(secondBoundedRefetch.data?.complete).toBe(true);
|
|
256
|
+
expect(secondBoundedRefetch.data?.records).toEqual([]);
|
|
257
|
+
expect(fetchImpl).toHaveBeenCalledTimes(5);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('does not keep polling server errors when missing-stream retry is requested', async () => {
|
|
261
|
+
const fetchImpl = vi.fn(async () => jsonResponse({code: 'server-error'}, {status: 500}));
|
|
262
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
263
|
+
|
|
264
|
+
const {result} = renderStepLogsHook({stepId: STEP_ID, attempt: 1}, {retryMissingStream: true});
|
|
265
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, STEP_LOG_LIVE_REFETCH_MS + 100));
|
|
267
|
+
|
|
268
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('does not poll after a closed drained inline stream loads', async () => {
|
|
272
|
+
const fetchImpl = vi.fn(async () =>
|
|
273
|
+
jsonResponse(inlineBody({ndjson: outputLine('done\n', 1), nextCursor: 1, state: 'closed'})),
|
|
274
|
+
);
|
|
275
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
276
|
+
|
|
277
|
+
const {result} = renderStepLogsHook();
|
|
278
|
+
await waitFor(() => expect(result.current.data?.complete).toBe(true));
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
280
|
+
|
|
281
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {readStepAttemptLogsPage, stepLogsQueryKeys} from './step-logs.js';
|
|
3
|
+
|
|
4
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
5
|
+
return new Response(JSON.stringify(body), {
|
|
6
|
+
headers: {'content-type': 'application/json'},
|
|
7
|
+
status: 200,
|
|
8
|
+
...init,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function requestFrom(fetchImpl: ReturnType<typeof vi.fn>): Request {
|
|
13
|
+
return fetchImpl.mock.calls[0]?.[0] as Request;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('stepLogsQueryKeys', () => {
|
|
17
|
+
test('keys a log detail by step id and attempt', () => {
|
|
18
|
+
const key = stepLogsQueryKeys.detail('11111111-1111-4111-8111-111111111111', 2);
|
|
19
|
+
|
|
20
|
+
expect(key).toEqual(['step-logs', 'detail', '11111111-1111-4111-8111-111111111111', 2]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('readStepAttemptLogsPage', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('requests a step attempt log page with the cursor', async () => {
|
|
30
|
+
const body = {
|
|
31
|
+
mode: 'inline',
|
|
32
|
+
ndjson: '',
|
|
33
|
+
next_cursor: 8,
|
|
34
|
+
has_more: false,
|
|
35
|
+
state: 'open',
|
|
36
|
+
truncated: false,
|
|
37
|
+
};
|
|
38
|
+
const fetchImpl = vi.fn(async () => jsonResponse(body));
|
|
39
|
+
configureApiClient({fetchImpl});
|
|
40
|
+
|
|
41
|
+
const result = await readStepAttemptLogsPage({
|
|
42
|
+
stepId: '11111111-1111-4111-8111-111111111111',
|
|
43
|
+
attempt: 2,
|
|
44
|
+
cursor: 7,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const url = new URL(requestFrom(fetchImpl).url);
|
|
48
|
+
expect(result).toEqual(body);
|
|
49
|
+
expect(url.pathname).toBe('/steps/11111111-1111-4111-8111-111111111111/attempts/2/logs');
|
|
50
|
+
expect(url.searchParams.get('cursor')).toBe('7');
|
|
51
|
+
expect(requestFrom(fetchImpl).method).toBe('GET');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('bubbles API errors from the read endpoint', async () => {
|
|
55
|
+
const fetchImpl = vi.fn(async () => jsonResponse({code: 'not-found'}, {status: 404}));
|
|
56
|
+
configureApiClient({fetchImpl});
|
|
57
|
+
|
|
58
|
+
const result = readStepAttemptLogsPage({
|
|
59
|
+
stepId: '11111111-1111-4111-8111-111111111111',
|
|
60
|
+
attempt: 1,
|
|
61
|
+
cursor: 0,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await expect(result).rejects.toMatchObject({code: 'not-found', status: 404});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type {ReadLogsResponseDto} from '@shipfox/api-logs-dto';
|
|
2
|
+
import {ApiError, apiRequest} from '@shipfox/client-api';
|
|
3
|
+
import {useQuery, useQueryClient} from '@tanstack/react-query';
|
|
4
|
+
import {useRef} from 'react';
|
|
5
|
+
import {
|
|
6
|
+
mergeLogRead,
|
|
7
|
+
STEP_LOG_LIVE_REFETCH_MS,
|
|
8
|
+
type StepLogSnapshot,
|
|
9
|
+
stepLogRefetchInterval,
|
|
10
|
+
} from '#core/log-read.js';
|
|
11
|
+
|
|
12
|
+
export const stepLogsQueryKeys = {
|
|
13
|
+
all: ['step-logs'] as const,
|
|
14
|
+
detail: (stepId: string, attempt: number) =>
|
|
15
|
+
[...stepLogsQueryKeys.all, 'detail', stepId, attempt] as const,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
interface ReadStepAttemptLogsPageParams {
|
|
19
|
+
stepId: string;
|
|
20
|
+
attempt: number;
|
|
21
|
+
cursor: number;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readStepAttemptLogsPage({
|
|
26
|
+
stepId,
|
|
27
|
+
attempt,
|
|
28
|
+
cursor,
|
|
29
|
+
signal,
|
|
30
|
+
}: ReadStepAttemptLogsPageParams): Promise<ReadLogsResponseDto> {
|
|
31
|
+
const params = new URLSearchParams({cursor: String(cursor)});
|
|
32
|
+
return await apiRequest<ReadLogsResponseDto>(
|
|
33
|
+
`/steps/${encodeURIComponent(stepId)}/attempts/${attempt}/logs?${params.toString()}`,
|
|
34
|
+
{signal},
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class StepLogObjectFetchError extends Error {
|
|
39
|
+
readonly status: number;
|
|
40
|
+
|
|
41
|
+
constructor(status: number) {
|
|
42
|
+
super(`Could not load compacted logs (${status})`);
|
|
43
|
+
this.name = 'StepLogObjectFetchError';
|
|
44
|
+
this.status = status;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readPresignedLogObject(url: string, signal?: AbortSignal): Promise<string> {
|
|
49
|
+
const response = await fetch(url, signal ? {signal} : undefined);
|
|
50
|
+
if (!response.ok) throw new StepLogObjectFetchError(response.status);
|
|
51
|
+
return await response.text();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isMissingStepLogStreamError(error: unknown): boolean {
|
|
55
|
+
return error instanceof ApiError && error.status === 404 && error.code === 'not-found';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface UseStepAttemptLogsQueryOptions {
|
|
59
|
+
retryMissingStream?: boolean;
|
|
60
|
+
missingStreamRetryCount?: number | undefined;
|
|
61
|
+
missingStreamRetryDelayMs?: number | undefined;
|
|
62
|
+
initialErrorRetryCount?: number;
|
|
63
|
+
initialErrorRetryDelayMs?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useStepAttemptLogsQuery(
|
|
67
|
+
stepId: string | undefined,
|
|
68
|
+
attempt: number | undefined,
|
|
69
|
+
options: UseStepAttemptLogsQueryOptions = {},
|
|
70
|
+
) {
|
|
71
|
+
const queryClient = useQueryClient();
|
|
72
|
+
const missingStreamFailureCountRef = useRef(0);
|
|
73
|
+
const missingStreamScopeRef = useRef<string | null>(null);
|
|
74
|
+
const enabled = Boolean(stepId && attempt && Number.isInteger(attempt) && attempt > 0);
|
|
75
|
+
const queryKey =
|
|
76
|
+
enabled && stepId && attempt
|
|
77
|
+
? stepLogsQueryKeys.detail(stepId, attempt)
|
|
78
|
+
: [...stepLogsQueryKeys.all, 'detail'];
|
|
79
|
+
const missingStreamScope =
|
|
80
|
+
enabled && stepId && attempt
|
|
81
|
+
? `${stepId}:${attempt}:${options.missingStreamRetryCount ?? 'unbounded'}`
|
|
82
|
+
: null;
|
|
83
|
+
if (missingStreamScopeRef.current !== missingStreamScope) {
|
|
84
|
+
missingStreamScopeRef.current = missingStreamScope;
|
|
85
|
+
missingStreamFailureCountRef.current = 0;
|
|
86
|
+
}
|
|
87
|
+
const initialErrorRetryCount = options.initialErrorRetryCount ?? 0;
|
|
88
|
+
const initialErrorRetryDelayMs = options.initialErrorRetryDelayMs ?? STEP_LOG_LIVE_REFETCH_MS;
|
|
89
|
+
const missingStreamRetryDelayMs = options.missingStreamRetryDelayMs ?? STEP_LOG_LIVE_REFETCH_MS;
|
|
90
|
+
|
|
91
|
+
return useQuery({
|
|
92
|
+
queryKey,
|
|
93
|
+
enabled,
|
|
94
|
+
queryFn: async ({signal}) => {
|
|
95
|
+
const previous = queryClient.getQueryData<StepLogSnapshot>(queryKey);
|
|
96
|
+
let response: ReadLogsResponseDto;
|
|
97
|
+
try {
|
|
98
|
+
response = await readStepAttemptLogsPage({
|
|
99
|
+
stepId: stepId ?? '',
|
|
100
|
+
attempt: attempt ?? 0,
|
|
101
|
+
cursor: previous?.nextCursor ?? 0,
|
|
102
|
+
signal,
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (
|
|
106
|
+
options.retryMissingStream &&
|
|
107
|
+
previous === undefined &&
|
|
108
|
+
isMissingStepLogStreamError(error)
|
|
109
|
+
) {
|
|
110
|
+
const retryCount = options.missingStreamRetryCount;
|
|
111
|
+
if (retryCount === undefined) throw error;
|
|
112
|
+
if (missingStreamFailureCountRef.current >= retryCount) {
|
|
113
|
+
return emptyCompleteLogSnapshot();
|
|
114
|
+
}
|
|
115
|
+
missingStreamFailureCountRef.current += 1;
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
missingStreamFailureCountRef.current = 0;
|
|
121
|
+
|
|
122
|
+
if (response.mode === 'presigned') {
|
|
123
|
+
const ndjson = await readPresignedLogObject(response.url, signal);
|
|
124
|
+
return mergeLogRead(previous, {mode: 'presigned', response, ndjson});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return mergeLogRead(previous, {mode: 'inline', response});
|
|
128
|
+
},
|
|
129
|
+
retry: (failureCount, error) => {
|
|
130
|
+
if (initialErrorRetryCount <= 0) return false;
|
|
131
|
+
if (queryClient.getQueryData<StepLogSnapshot>(queryKey) !== undefined) return false;
|
|
132
|
+
if (options.retryMissingStream && isMissingStepLogStreamError(error)) return false;
|
|
133
|
+
return failureCount < initialErrorRetryCount;
|
|
134
|
+
},
|
|
135
|
+
retryDelay: initialErrorRetryDelayMs,
|
|
136
|
+
refetchInterval: (query) => {
|
|
137
|
+
if (
|
|
138
|
+
options.retryMissingStream &&
|
|
139
|
+
query.state.data === undefined &&
|
|
140
|
+
isMissingStepLogStreamError(query.state.error)
|
|
141
|
+
) {
|
|
142
|
+
return missingStreamRetryDelayMs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return stepLogRefetchInterval(query.state.data, query.state.status === 'error');
|
|
146
|
+
},
|
|
147
|
+
refetchIntervalInBackground: false,
|
|
148
|
+
refetchOnMount: (query) => !query.state.data?.complete,
|
|
149
|
+
refetchOnWindowFocus: (query) => !query.state.data?.complete,
|
|
150
|
+
refetchOnReconnect: (query) => !query.state.data?.complete,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function emptyCompleteLogSnapshot(): StepLogSnapshot {
|
|
155
|
+
return {
|
|
156
|
+
records: [],
|
|
157
|
+
nextCursor: 0,
|
|
158
|
+
source: 'inline',
|
|
159
|
+
state: 'closed',
|
|
160
|
+
complete: true,
|
|
161
|
+
hasMore: false,
|
|
162
|
+
truncated: false,
|
|
163
|
+
totalBytes: null,
|
|
164
|
+
expiresAt: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type {StepLogSnapshot} from '#core/log-read.js';
|
|
2
|
+
export {
|
|
3
|
+
buildLogTree,
|
|
4
|
+
type GroupLogNode,
|
|
5
|
+
type LogNode,
|
|
6
|
+
type LogTree,
|
|
7
|
+
type MarkerLogNode,
|
|
8
|
+
type OutputLogNode,
|
|
9
|
+
type SessionLogNode,
|
|
10
|
+
} from '#core/log-tree.js';
|
|
11
|
+
export * from './components/index.js';
|
|
12
|
+
export {
|
|
13
|
+
isMissingStepLogStreamError,
|
|
14
|
+
readStepAttemptLogsPage,
|
|
15
|
+
stepLogsQueryKeys,
|
|
16
|
+
type UseStepAttemptLogsQueryOptions,
|
|
17
|
+
useStepAttemptLogsQuery,
|
|
18
|
+
} from './hooks/api/step-logs.js';
|
package/test/setup.ts
ADDED