@shipfox/client-logs 4.0.0 → 6.0.1
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +27 -0
- package/dist/components/agent-session-rows.d.ts +1 -1
- package/dist/components/agent-session-rows.d.ts.map +1 -1
- package/dist/components/agent-session-rows.js.map +1 -1
- package/dist/components/log-view.d.ts +1 -1
- package/dist/components/log-view.d.ts.map +1 -1
- package/dist/components/log-view.js.map +1 -1
- package/dist/components/system-markers.js +2 -2
- package/dist/components/system-markers.js.map +1 -1
- package/dist/core/log-model.d.ts +93 -0
- package/dist/core/log-model.d.ts.map +1 -0
- package/dist/core/log-model.js +3 -0
- package/dist/core/log-model.js.map +1 -0
- package/dist/core/log-read.d.ts +7 -14
- package/dist/core/log-read.d.ts.map +1 -1
- package/dist/core/log-read.js +9 -13
- package/dist/core/log-read.js.map +1 -1
- package/dist/core/log-tree.d.ts +1 -1
- package/dist/core/log-tree.d.ts.map +1 -1
- package/dist/core/log-tree.js +3 -3
- package/dist/core/log-tree.js.map +1 -1
- package/dist/hooks/api/log-mapper.d.ts +10 -0
- package/dist/hooks/api/log-mapper.d.ts.map +1 -0
- package/dist/hooks/api/log-mapper.js +92 -0
- package/dist/hooks/api/log-mapper.js.map +1 -0
- package/dist/hooks/api/step-logs.d.ts +7 -2
- package/dist/hooks/api/step-logs.d.ts.map +1 -1
- package/dist/hooks/api/step-logs.js +14 -5
- package/dist/hooks/api/step-logs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/src/components/agent-session-rows.tsx +1 -1
- package/src/components/log-group.stories.tsx +2 -2
- package/src/components/log-view.stories.tsx +10 -10
- package/src/components/log-view.test.tsx +3 -3
- package/src/components/log-view.tsx +1 -1
- package/src/components/system-markers.stories.tsx +3 -3
- package/src/components/system-markers.tsx +2 -2
- package/src/core/log-model.ts +76 -0
- package/src/core/log-read.test.ts +58 -139
- package/src/core/log-read.ts +12 -25
- package/src/core/log-tree.test.ts +10 -10
- package/src/core/log-tree.ts +4 -4
- package/src/hooks/api/log-mapper.test.ts +56 -0
- package/src/hooks/api/log-mapper.ts +75 -0
- package/src/hooks/api/step-logs.test.ts +21 -1
- package/src/hooks/api/step-logs.ts +24 -7
- package/src/index.ts +7 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/vercel.json +5 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-logs",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "6.0.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -10,9 +10,6 @@
|
|
|
10
10
|
"type": "module",
|
|
11
11
|
"main": "dist/index.js",
|
|
12
12
|
"types": "dist/index.d.ts",
|
|
13
|
-
"imports": {
|
|
14
|
-
"#*": "./dist/*"
|
|
15
|
-
},
|
|
16
13
|
"exports": {
|
|
17
14
|
".": {
|
|
18
15
|
"types": "./dist/index.d.ts",
|
|
@@ -21,15 +18,18 @@
|
|
|
21
18
|
},
|
|
22
19
|
"dependencies": {
|
|
23
20
|
"@swc/helpers": "^0.5.17",
|
|
24
|
-
"@shipfox/api-logs-dto": "
|
|
25
|
-
"@shipfox/
|
|
26
|
-
"@shipfox/
|
|
21
|
+
"@shipfox/api-logs-dto": "9.0.1",
|
|
22
|
+
"@shipfox/react-ui": "0.3.6",
|
|
23
|
+
"@shipfox/client-api": "6.0.1"
|
|
27
24
|
},
|
|
28
25
|
"peerDependencies": {
|
|
29
26
|
"@tanstack/react-query": "^5.101.0",
|
|
30
27
|
"react": "^19.0.0",
|
|
31
28
|
"react-dom": "^19.0.0"
|
|
32
29
|
},
|
|
30
|
+
"imports": {
|
|
31
|
+
"#*": "./dist/*"
|
|
32
|
+
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "shipfox-swc",
|
|
35
35
|
"check": "shipfox-biome-check",
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import type {SessionViewRow, SessionViewRowMeta} from '@shipfox/api-logs-dto';
|
|
4
3
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
5
4
|
import {
|
|
6
5
|
LogContent,
|
|
@@ -12,6 +11,7 @@ import {
|
|
|
12
11
|
import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
|
|
13
12
|
import {cn} from '@shipfox/react-ui/utils';
|
|
14
13
|
import {Fragment, useState} from 'react';
|
|
14
|
+
import type {SessionViewRow, SessionViewRowMeta} from '#core/log-model.js';
|
|
15
15
|
|
|
16
16
|
const PREVIEW_CHAR_LIMIT = 1200;
|
|
17
17
|
const WORD_SUMMARY_CHAR_LIMIT = 5000;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {LogRecord, SessionViewRow} from '@shipfox/api-logs-dto';
|
|
2
1
|
import type {Meta, StoryObj} from '@storybook/react';
|
|
2
|
+
import type {LogRecord, SessionViewRow} from '#core/log-model.js';
|
|
3
3
|
import {LogView, LogViewSkeleton} from './log-view.js';
|
|
4
4
|
|
|
5
5
|
const ESC = String.fromCharCode(27);
|
|
@@ -28,15 +28,15 @@ const groupStart = (
|
|
|
28
28
|
v: 1,
|
|
29
29
|
ts: at(offset),
|
|
30
30
|
type: 'group_start',
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
groupId,
|
|
32
|
+
parentGroupId,
|
|
33
33
|
name,
|
|
34
34
|
});
|
|
35
35
|
const groupEnd = (groupId: string, offset: number): LogRecord => ({
|
|
36
36
|
v: 1,
|
|
37
37
|
ts: at(offset),
|
|
38
38
|
type: 'group_end',
|
|
39
|
-
|
|
39
|
+
groupId,
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
const showcaseRecords: LogRecord[] = [
|
|
@@ -53,8 +53,8 @@ const showcaseRecords: LogRecord[] = [
|
|
|
53
53
|
out('running 42 tests\n', 10),
|
|
54
54
|
out('FAIL client.test.ts > retries on 503\n', 11, 'stderr'),
|
|
55
55
|
groupEnd('g3', 12),
|
|
56
|
-
{v: 1, ts: at(13), type: 'gap',
|
|
57
|
-
{v: 1, ts: at(14), type: 'end',
|
|
56
|
+
{v: 1, ts: at(13), type: 'gap', droppedBytes: 2048},
|
|
57
|
+
{v: 1, ts: at(14), type: 'end', totalBytes: 15_360},
|
|
58
58
|
];
|
|
59
59
|
|
|
60
60
|
// A pipeline nested three levels deep: Deploy > Build > Compile, and Deploy >
|
|
@@ -85,7 +85,7 @@ const nestedRecords: LogRecord[] = [
|
|
|
85
85
|
groupEnd('g7', 14),
|
|
86
86
|
groupEnd('g5', 14.5),
|
|
87
87
|
groupEnd('g1', 15),
|
|
88
|
-
{v: 1, ts: at(15.2), type: 'end',
|
|
88
|
+
{v: 1, ts: at(15.2), type: 'end', totalBytes: 9_216},
|
|
89
89
|
];
|
|
90
90
|
|
|
91
91
|
const unifiedAgentRecords: LogRecord[] = [
|
|
@@ -155,7 +155,7 @@ const unifiedAgentRecords: LogRecord[] = [
|
|
|
155
155
|
},
|
|
156
156
|
4,
|
|
157
157
|
),
|
|
158
|
-
{v: 1, ts: at(5), type: 'end',
|
|
158
|
+
{v: 1, ts: at(5), type: 'end', totalBytes: 4096},
|
|
159
159
|
];
|
|
160
160
|
|
|
161
161
|
const awaitingAgentRecords: LogRecord[] = [
|
|
@@ -415,7 +415,7 @@ const allAgentSessionTypeRecords: LogRecord[] = [
|
|
|
415
415
|
16,
|
|
416
416
|
),
|
|
417
417
|
session({kind: 'raw', timestamp: 0, label: 'Malformed session entry', raw: '{not-json'}, 17),
|
|
418
|
-
{v: 1, ts: at(20), type: 'end',
|
|
418
|
+
{v: 1, ts: at(20), type: 'end', totalBytes: 12_288},
|
|
419
419
|
];
|
|
420
420
|
|
|
421
421
|
const meta = {
|
|
@@ -483,7 +483,7 @@ export const MinimalOutput: Story = {
|
|
|
483
483
|
args: {showLineNumbers: true},
|
|
484
484
|
render: (args) => (
|
|
485
485
|
<div className="max-w-3xl">
|
|
486
|
-
<LogView {...args} records={[{v: 1, ts: at(1), type: 'end',
|
|
486
|
+
<LogView {...args} records={[{v: 1, ts: at(1), type: 'end', totalBytes: 0}]} />
|
|
487
487
|
</div>
|
|
488
488
|
),
|
|
489
489
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {LogRecord} from '@shipfox/api-logs-dto';
|
|
2
1
|
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
|
|
2
|
+
import type {LogRecord} from '#core/log-model.js';
|
|
3
3
|
import {LogView, LogViewSkeleton} from './log-view.js';
|
|
4
4
|
|
|
5
5
|
const ts = new Date('2026-06-23T10:00:00.000Z').getTime();
|
|
@@ -58,7 +58,7 @@ describe('LogView', () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
test('renders no-output copy before the end marker for an end-marker-only stream', () => {
|
|
61
|
-
render(<LogView records={[{v: 1, ts, type: 'end',
|
|
61
|
+
render(<LogView records={[{v: 1, ts, type: 'end', totalBytes: 0}]} />);
|
|
62
62
|
|
|
63
63
|
expect(screen.getByText('Step produced no output')).toBeDefined();
|
|
64
64
|
expect(screen.getByText('End of log')).toBeDefined();
|
|
@@ -67,7 +67,7 @@ describe('LogView', () => {
|
|
|
67
67
|
|
|
68
68
|
test.each([
|
|
69
69
|
{record: {v: 1, ts, type: 'runner_lost'} as const, label: 'Runner disconnected'},
|
|
70
|
-
{record: {v: 1, ts, type: 'gap',
|
|
70
|
+
{record: {v: 1, ts, type: 'gap', droppedBytes: 2048} as const, label: 'Output missing'},
|
|
71
71
|
{record: {v: 1, ts, type: 'capped'} as const, label: 'Log size limit reached'},
|
|
72
72
|
])('does not show no-output copy for a $record.type marker-only stream', ({record, label}) => {
|
|
73
73
|
render(<LogView records={[record]} />);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import type {LogRecord} from '@shipfox/api-logs-dto';
|
|
4
3
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
5
4
|
import {LogContent, LogRow, LogRows, type LogTimestampMode} from '@shipfox/react-ui/log';
|
|
6
5
|
import {Skeleton} from '@shipfox/react-ui/skeleton';
|
|
7
6
|
import {type ReactNode, type UIEventHandler, useEffect, useMemo, useRef} from 'react';
|
|
7
|
+
import type {LogRecord} from '#core/log-model.js';
|
|
8
8
|
import {
|
|
9
9
|
assertNever,
|
|
10
10
|
buildLogTree,
|
|
@@ -19,7 +19,7 @@ export const Playground: Story = {
|
|
|
19
19
|
<div className="max-w-3xl">
|
|
20
20
|
<LogRows>
|
|
21
21
|
<EndMarker
|
|
22
|
-
record={{v: 1, ts, type: 'end',
|
|
22
|
+
record={{v: 1, ts, type: 'end', totalBytes: 15_360}}
|
|
23
23
|
lineCount={412}
|
|
24
24
|
durationMs={2100}
|
|
25
25
|
/>
|
|
@@ -32,11 +32,11 @@ export const Variants: Story = {
|
|
|
32
32
|
render: () => (
|
|
33
33
|
<div className="max-w-3xl">
|
|
34
34
|
<LogRows>
|
|
35
|
-
<GapMarker record={{v: 1, ts, type: 'gap',
|
|
35
|
+
<GapMarker record={{v: 1, ts, type: 'gap', droppedBytes: 2048}} />
|
|
36
36
|
<CappedMarker record={{v: 1, ts, type: 'capped'}} />
|
|
37
37
|
<RunnerLostMarker record={{v: 1, ts, type: 'runner_lost'}} />
|
|
38
38
|
<EndMarker
|
|
39
|
-
record={{v: 1, ts, type: 'end',
|
|
39
|
+
record={{v: 1, ts, type: 'end', totalBytes: 15_360}}
|
|
40
40
|
lineCount={412}
|
|
41
41
|
durationMs={2100}
|
|
42
42
|
/>
|
|
@@ -93,7 +93,7 @@ export interface EndMarkerProps {
|
|
|
93
93
|
export function EndMarker({record, lineCount, durationMs = null}: EndMarkerProps) {
|
|
94
94
|
const meta = [
|
|
95
95
|
`${lineCount} ${lineCount === 1 ? 'line' : 'lines'}`,
|
|
96
|
-
formatBytes(record.
|
|
96
|
+
formatBytes(record.totalBytes),
|
|
97
97
|
...(durationMs != null ? [formatDuration(durationMs)] : []),
|
|
98
98
|
].join(' · ');
|
|
99
99
|
|
|
@@ -111,7 +111,7 @@ export function GapMarker({record}: {record: GapLogRecord}) {
|
|
|
111
111
|
icon="errorWarningLine"
|
|
112
112
|
tone="warning"
|
|
113
113
|
timestamp={new Date(record.ts)}
|
|
114
|
-
detail={`the runner fell behind and dropped ${formatBytes(record.
|
|
114
|
+
detail={`the runner fell behind and dropped ${formatBytes(record.droppedBytes)}`}
|
|
115
115
|
>
|
|
116
116
|
Output missing
|
|
117
117
|
</LogMarkerRow>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface SessionViewRowMeta {
|
|
2
|
+
label: string;
|
|
3
|
+
value: string;
|
|
4
|
+
inline?: boolean | undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type SessionViewRow =
|
|
8
|
+
| {
|
|
9
|
+
kind: 'message';
|
|
10
|
+
timestamp: number;
|
|
11
|
+
role: string;
|
|
12
|
+
label: string;
|
|
13
|
+
meta: readonly SessionViewRowMeta[];
|
|
14
|
+
text: string;
|
|
15
|
+
terminalFailure: boolean;
|
|
16
|
+
}
|
|
17
|
+
| {kind: 'thinking'; timestamp: number; text: string}
|
|
18
|
+
| {kind: 'tool-call'; timestamp: number; id: string | null; name: string; input: string}
|
|
19
|
+
| {
|
|
20
|
+
kind: 'tool-result';
|
|
21
|
+
timestamp: number;
|
|
22
|
+
toolCallId: string | null;
|
|
23
|
+
toolName: string;
|
|
24
|
+
output: string;
|
|
25
|
+
isError: boolean;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
kind: 'lifecycle';
|
|
29
|
+
timestamp: number;
|
|
30
|
+
label: string;
|
|
31
|
+
detail: string | null;
|
|
32
|
+
meta: readonly SessionViewRowMeta[];
|
|
33
|
+
tone: 'default' | 'warning' | 'error';
|
|
34
|
+
terminalFailure: boolean;
|
|
35
|
+
}
|
|
36
|
+
| {kind: 'raw'; timestamp: number; label: string; raw: string};
|
|
37
|
+
|
|
38
|
+
interface LogRecordBase {
|
|
39
|
+
v: 1;
|
|
40
|
+
ts: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type LogRecord =
|
|
44
|
+
| (LogRecordBase & {type: 'output'; stream: 'stdout' | 'stderr'; data: string})
|
|
45
|
+
| (LogRecordBase & {
|
|
46
|
+
type: 'group_start';
|
|
47
|
+
groupId: string;
|
|
48
|
+
parentGroupId: string | null;
|
|
49
|
+
name: string;
|
|
50
|
+
})
|
|
51
|
+
| (LogRecordBase & {type: 'group_end'; groupId: string})
|
|
52
|
+
| (LogRecordBase & {type: 'end'; totalBytes: number})
|
|
53
|
+
| (LogRecordBase & {type: 'gap'; droppedBytes: number})
|
|
54
|
+
| (LogRecordBase & {type: 'agent_session'; row: SessionViewRow})
|
|
55
|
+
| (LogRecordBase & {type: 'capped'})
|
|
56
|
+
| (LogRecordBase & {type: 'runner_lost'});
|
|
57
|
+
|
|
58
|
+
export type LogSource = 'inline' | 'presigned';
|
|
59
|
+
export type LogState = 'open' | 'closed' | 'compacted';
|
|
60
|
+
|
|
61
|
+
export interface InlineLogRead {
|
|
62
|
+
mode: 'inline';
|
|
63
|
+
ndjson: string;
|
|
64
|
+
nextCursor: number;
|
|
65
|
+
hasMore: boolean;
|
|
66
|
+
state: 'open' | 'closed';
|
|
67
|
+
truncated: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PresignedLogRead {
|
|
71
|
+
mode: 'presigned';
|
|
72
|
+
url: string;
|
|
73
|
+
expiresAt: string;
|
|
74
|
+
totalBytes: number;
|
|
75
|
+
truncated: boolean;
|
|
76
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type {LogRecord,
|
|
1
|
+
import type {InlineLogRead, LogRecord, PresignedLogRead} from './log-model.js';
|
|
2
2
|
import {
|
|
3
3
|
mergeLogRead,
|
|
4
|
-
parseLogNdjson,
|
|
5
4
|
STEP_LOG_DRAIN_REFETCH_MS,
|
|
6
5
|
STEP_LOG_LIVE_REFETCH_MS,
|
|
7
6
|
type StepLogSnapshot,
|
|
@@ -16,75 +15,27 @@ const output = (data: string, ts = 1): LogRecord => ({
|
|
|
16
15
|
data,
|
|
17
16
|
});
|
|
18
17
|
|
|
19
|
-
const
|
|
20
|
-
const inline = (params: {
|
|
21
|
-
ndjson: string;
|
|
22
|
-
nextCursor?: number;
|
|
23
|
-
hasMore?: boolean;
|
|
24
|
-
state?: 'open' | 'closed';
|
|
25
|
-
truncated?: boolean;
|
|
26
|
-
}): Extract<ReadLogsResponseDto, {mode: 'inline'}> => ({
|
|
18
|
+
const inline = (params: Partial<InlineLogRead> = {}): InlineLogRead => ({
|
|
27
19
|
mode: 'inline',
|
|
28
|
-
ndjson:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
state:
|
|
32
|
-
truncated:
|
|
20
|
+
ndjson: '',
|
|
21
|
+
nextCursor: 1,
|
|
22
|
+
hasMore: false,
|
|
23
|
+
state: 'open',
|
|
24
|
+
truncated: false,
|
|
25
|
+
...params,
|
|
33
26
|
});
|
|
34
27
|
|
|
35
|
-
const presigned = (
|
|
36
|
-
params: Partial<Extract<ReadLogsResponseDto, {mode: 'presigned'}>> = {},
|
|
37
|
-
): Extract<ReadLogsResponseDto, {mode: 'presigned'}> => ({
|
|
28
|
+
const presigned = (params: Partial<PresignedLogRead> = {}): PresignedLogRead => ({
|
|
38
29
|
mode: 'presigned',
|
|
39
|
-
url:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe('parseLogNdjson', () => {
|
|
47
|
-
test('returns an empty record list for an empty body', () => {
|
|
48
|
-
const records = parseLogNdjson('');
|
|
49
|
-
|
|
50
|
-
expect(records).toEqual([]);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('parses multiple record lines', () => {
|
|
54
|
-
const first = output('first\n', 1);
|
|
55
|
-
const second = output('second\n', 2);
|
|
56
|
-
|
|
57
|
-
const records = parseLogNdjson(line(first) + line(second));
|
|
58
|
-
|
|
59
|
-
expect(records).toEqual([first, second]);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('throws when a line is not a valid log record', () => {
|
|
63
|
-
const parse = () => parseLogNdjson('{"v":1,"ts":1,"type":"nope"}\n');
|
|
64
|
-
|
|
65
|
-
expect(parse).toThrow();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('splits records on CRLF line breaks', () => {
|
|
69
|
-
const first = output('first\n', 1);
|
|
70
|
-
const second = output('second\n', 2);
|
|
71
|
-
|
|
72
|
-
const records = parseLogNdjson(`${JSON.stringify(first)}\r\n${JSON.stringify(second)}\r\n`);
|
|
73
|
-
|
|
74
|
-
expect(records).toEqual([first, second]);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('skips blank lines between records', () => {
|
|
78
|
-
const record = output('only\n', 1);
|
|
79
|
-
|
|
80
|
-
const records = parseLogNdjson(`\n${line(record)}\n`);
|
|
81
|
-
|
|
82
|
-
expect(records).toEqual([record]);
|
|
83
|
-
});
|
|
30
|
+
url: 'https://storage.example.test/logs/object?sig=1',
|
|
31
|
+
expiresAt: '2026-06-23T10:00:00.000Z',
|
|
32
|
+
totalBytes: 128,
|
|
33
|
+
truncated: false,
|
|
34
|
+
...params,
|
|
84
35
|
});
|
|
85
36
|
|
|
86
37
|
describe('mergeLogRead', () => {
|
|
87
|
-
test('appends inline records and advances the cursor', () => {
|
|
38
|
+
test('appends validated inline records and advances the cursor', () => {
|
|
88
39
|
const previous: StepLogSnapshot = {
|
|
89
40
|
records: [output('old\n', 1)],
|
|
90
41
|
nextCursor: 3,
|
|
@@ -96,9 +47,12 @@ describe('mergeLogRead', () => {
|
|
|
96
47
|
totalBytes: null,
|
|
97
48
|
expiresAt: null,
|
|
98
49
|
};
|
|
99
|
-
const response = inline({ndjson: line(output('new\n', 2)), nextCursor: 4, hasMore: true});
|
|
100
50
|
|
|
101
|
-
const snapshot = mergeLogRead(previous, {
|
|
51
|
+
const snapshot = mergeLogRead(previous, {
|
|
52
|
+
mode: 'inline',
|
|
53
|
+
response: inline({nextCursor: 4, hasMore: true}),
|
|
54
|
+
records: [output('new\n', 2)],
|
|
55
|
+
});
|
|
102
56
|
|
|
103
57
|
expect(snapshot.records).toEqual([output('old\n', 1), output('new\n', 2)]);
|
|
104
58
|
expect(snapshot.nextCursor).toBe(4);
|
|
@@ -106,7 +60,7 @@ describe('mergeLogRead', () => {
|
|
|
106
60
|
expect(snapshot.complete).toBe(false);
|
|
107
61
|
});
|
|
108
62
|
|
|
109
|
-
test('
|
|
63
|
+
test('does not advance the cursor when no validated records are supplied', () => {
|
|
110
64
|
const previous: StepLogSnapshot = {
|
|
111
65
|
records: [output('old\n', 1)],
|
|
112
66
|
nextCursor: 3,
|
|
@@ -118,58 +72,45 @@ describe('mergeLogRead', () => {
|
|
|
118
72
|
totalBytes: null,
|
|
119
73
|
expiresAt: null,
|
|
120
74
|
};
|
|
121
|
-
const response = inline({ndjson: '', nextCursor: 3, state: 'open'});
|
|
122
75
|
|
|
123
|
-
const snapshot = mergeLogRead(previous, {
|
|
76
|
+
const snapshot = mergeLogRead(previous, {
|
|
77
|
+
mode: 'inline',
|
|
78
|
+
response: inline({nextCursor: 3}),
|
|
79
|
+
records: [],
|
|
80
|
+
});
|
|
124
81
|
|
|
125
82
|
expect(snapshot.records).toEqual(previous.records);
|
|
126
83
|
expect(snapshot.nextCursor).toBe(3);
|
|
127
|
-
expect(snapshot.state).toBe('open');
|
|
128
|
-
expect(snapshot.complete).toBe(false);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test('marks a closed drained inline stream complete', () => {
|
|
132
|
-
const response = inline({ndjson: line(output('done\n')), state: 'closed', hasMore: false});
|
|
133
|
-
|
|
134
|
-
const snapshot = mergeLogRead(undefined, {mode: 'inline', response});
|
|
135
|
-
|
|
136
|
-
expect(snapshot.records).toEqual([output('done\n')]);
|
|
137
|
-
expect(snapshot.state).toBe('closed');
|
|
138
|
-
expect(snapshot.complete).toBe(true);
|
|
139
|
-
expect(snapshot.hasMore).toBe(false);
|
|
140
84
|
});
|
|
141
85
|
|
|
142
|
-
test('replaces partial inline records with
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
86
|
+
test('replaces partial inline records with compacted records', () => {
|
|
87
|
+
const snapshot = mergeLogRead(
|
|
88
|
+
{
|
|
89
|
+
records: [output('partial\n', 1)],
|
|
90
|
+
nextCursor: 2,
|
|
91
|
+
source: 'inline',
|
|
92
|
+
state: 'closed',
|
|
93
|
+
complete: false,
|
|
94
|
+
hasMore: true,
|
|
95
|
+
truncated: false,
|
|
96
|
+
totalBytes: null,
|
|
97
|
+
expiresAt: null,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
mode: 'presigned',
|
|
101
|
+
response: presigned({totalBytes: 256, truncated: true}),
|
|
102
|
+
records: [output('full\n', 2)],
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(snapshot).toMatchObject({
|
|
107
|
+
records: [output('full\n', 2)],
|
|
108
|
+
source: 'presigned',
|
|
109
|
+
state: 'compacted',
|
|
110
|
+
complete: true,
|
|
111
|
+
totalBytes: 256,
|
|
157
112
|
truncated: true,
|
|
158
113
|
});
|
|
159
|
-
|
|
160
|
-
const snapshot = mergeLogRead(previous, {
|
|
161
|
-
mode: 'presigned',
|
|
162
|
-
response,
|
|
163
|
-
ndjson: line(output('full\n', 2)),
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
expect(snapshot.records).toEqual([output('full\n', 2)]);
|
|
167
|
-
expect(snapshot.source).toBe('presigned');
|
|
168
|
-
expect(snapshot.state).toBe('compacted');
|
|
169
|
-
expect(snapshot.complete).toBe(true);
|
|
170
|
-
expect(snapshot.totalBytes).toBe(256);
|
|
171
|
-
expect(snapshot.expiresAt).toBe('2026-06-23T10:00:00.000Z');
|
|
172
|
-
expect(snapshot.truncated).toBe(true);
|
|
173
114
|
});
|
|
174
115
|
});
|
|
175
116
|
|
|
@@ -187,33 +128,11 @@ describe('stepLogRefetchInterval', () => {
|
|
|
187
128
|
...overrides,
|
|
188
129
|
});
|
|
189
130
|
|
|
190
|
-
test('
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
expect(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
test('polls quickly while draining buffered inline pages', () => {
|
|
197
|
-
const interval = stepLogRefetchInterval(snapshot({hasMore: true}));
|
|
198
|
-
|
|
199
|
-
expect(interval).toBe(STEP_LOG_DRAIN_REFETCH_MS);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test('polls at live-tail cadence for an open drained stream', () => {
|
|
203
|
-
const interval = stepLogRefetchInterval(snapshot({state: 'open', hasMore: false}));
|
|
204
|
-
|
|
205
|
-
expect(interval).toBe(STEP_LOG_LIVE_REFETCH_MS);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test('stops polling once the stream is complete', () => {
|
|
209
|
-
const interval = stepLogRefetchInterval(snapshot({state: 'closed', complete: true}));
|
|
210
|
-
|
|
211
|
-
expect(interval).toBe(false);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test('stops polling after a fetch errors out instead of re-polling the dead cursor', () => {
|
|
215
|
-
const interval = stepLogRefetchInterval(snapshot({hasMore: true}), true);
|
|
216
|
-
|
|
217
|
-
expect(interval).toBe(false);
|
|
131
|
+
test('keeps drain, live-tail, terminal, and error polling policies', () => {
|
|
132
|
+
expect(stepLogRefetchInterval(undefined)).toBe(false);
|
|
133
|
+
expect(stepLogRefetchInterval(snapshot({hasMore: true}))).toBe(STEP_LOG_DRAIN_REFETCH_MS);
|
|
134
|
+
expect(stepLogRefetchInterval(snapshot({}))).toBe(STEP_LOG_LIVE_REFETCH_MS);
|
|
135
|
+
expect(stepLogRefetchInterval(snapshot({complete: true}))).toBe(false);
|
|
136
|
+
expect(stepLogRefetchInterval(snapshot({}), true)).toBe(false);
|
|
218
137
|
});
|
|
219
138
|
});
|
package/src/core/log-read.ts
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type {InlineLogRead, LogRecord, LogSource, LogState, PresignedLogRead} from './log-model.js';
|
|
2
2
|
|
|
3
3
|
export const STEP_LOG_DRAIN_REFETCH_MS = 250;
|
|
4
4
|
export const STEP_LOG_LIVE_REFETCH_MS = 2_000;
|
|
5
5
|
|
|
6
|
-
const NDJSON_LINE_BREAK = /\r?\n/;
|
|
7
|
-
|
|
8
|
-
type InlineReadLogsResponse = Extract<ReadLogsResponseDto, {mode: 'inline'}>;
|
|
9
|
-
type PresignedReadLogsResponse = Extract<ReadLogsResponseDto, {mode: 'presigned'}>;
|
|
10
|
-
|
|
11
6
|
export interface StepLogSnapshot {
|
|
12
7
|
records: LogRecord[];
|
|
13
8
|
nextCursor: number;
|
|
14
|
-
source:
|
|
15
|
-
state:
|
|
9
|
+
source: LogSource;
|
|
10
|
+
state: LogState;
|
|
16
11
|
complete: boolean;
|
|
17
12
|
hasMore: boolean;
|
|
18
13
|
truncated: boolean;
|
|
@@ -21,15 +16,8 @@ export interface StepLogSnapshot {
|
|
|
21
16
|
}
|
|
22
17
|
|
|
23
18
|
export type ResolvedStepLogRead =
|
|
24
|
-
| {mode: 'inline'; response:
|
|
25
|
-
| {mode: 'presigned'; response:
|
|
26
|
-
|
|
27
|
-
export function parseLogNdjson(ndjson: string): LogRecord[] {
|
|
28
|
-
return ndjson
|
|
29
|
-
.split(NDJSON_LINE_BREAK)
|
|
30
|
-
.filter((line) => line.length > 0)
|
|
31
|
-
.map(parseLogRecordLine);
|
|
32
|
-
}
|
|
19
|
+
| {mode: 'inline'; response: InlineLogRead; records: readonly LogRecord[]}
|
|
20
|
+
| {mode: 'presigned'; response: PresignedLogRead; records: readonly LogRecord[]};
|
|
33
21
|
|
|
34
22
|
export function mergeLogRead(
|
|
35
23
|
previous: StepLogSnapshot | undefined,
|
|
@@ -37,28 +25,27 @@ export function mergeLogRead(
|
|
|
37
25
|
): StepLogSnapshot {
|
|
38
26
|
if (read.mode === 'presigned') {
|
|
39
27
|
return {
|
|
40
|
-
records:
|
|
28
|
+
records: [...read.records],
|
|
41
29
|
nextCursor: 0,
|
|
42
30
|
source: 'presigned',
|
|
43
31
|
state: 'compacted',
|
|
44
32
|
complete: true,
|
|
45
33
|
hasMore: false,
|
|
46
34
|
truncated: read.response.truncated,
|
|
47
|
-
totalBytes: read.response.
|
|
48
|
-
expiresAt: read.response.
|
|
35
|
+
totalBytes: read.response.totalBytes,
|
|
36
|
+
expiresAt: read.response.expiresAt,
|
|
49
37
|
};
|
|
50
38
|
}
|
|
51
39
|
|
|
52
|
-
const
|
|
53
|
-
const complete = read.response.state === 'closed' && !read.response.has_more;
|
|
40
|
+
const complete = read.response.state === 'closed' && !read.response.hasMore;
|
|
54
41
|
|
|
55
42
|
return {
|
|
56
|
-
records: [...(previous?.records ?? []), ...records],
|
|
57
|
-
nextCursor: read.response.
|
|
43
|
+
records: [...(previous?.records ?? []), ...read.records],
|
|
44
|
+
nextCursor: read.response.nextCursor,
|
|
58
45
|
source: 'inline',
|
|
59
46
|
state: read.response.state,
|
|
60
47
|
complete,
|
|
61
|
-
hasMore: read.response.
|
|
48
|
+
hasMore: read.response.hasMore,
|
|
62
49
|
truncated: read.response.truncated,
|
|
63
50
|
totalBytes: null,
|
|
64
51
|
expiresAt: null,
|