@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,148 @@
|
|
|
1
|
+
import {Icon, type IconName} from '@shipfox/react-ui/icon';
|
|
2
|
+
import {LogContent, LogRow} from '@shipfox/react-ui/log';
|
|
3
|
+
import {cn, formatBytes, formatDuration} from '@shipfox/react-ui/utils';
|
|
4
|
+
import type {
|
|
5
|
+
CappedLogRecord,
|
|
6
|
+
EndLogRecord,
|
|
7
|
+
GapLogRecord,
|
|
8
|
+
RunnerLostLogRecord,
|
|
9
|
+
} from '#core/log-tree.js';
|
|
10
|
+
|
|
11
|
+
type MarkerTone = 'default' | 'warning' | 'error';
|
|
12
|
+
|
|
13
|
+
const toneText: Record<MarkerTone, string> = {
|
|
14
|
+
default: 'text-foreground-neutral-muted',
|
|
15
|
+
warning: 'text-orange-600 dark:text-orange-400',
|
|
16
|
+
error: 'text-red-600 dark:text-red-400',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
interface LogMarkerRowProps {
|
|
20
|
+
icon: IconName;
|
|
21
|
+
tone: MarkerTone;
|
|
22
|
+
timestamp?: Date | null;
|
|
23
|
+
/** Plain-language clause after the label: what it means / what to do. */
|
|
24
|
+
detail?: string;
|
|
25
|
+
/** Right-aligned `font-code` figures (bytes, line count, duration). */
|
|
26
|
+
meta?: string;
|
|
27
|
+
terminalFailure?: boolean;
|
|
28
|
+
children: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Shared timeline-marker row: a non-numbered line with a leading icon, a bold label, an
|
|
33
|
+
* optional plain-language detail clause, a dashed divider, and optional right-aligned
|
|
34
|
+
* monospace figures. The detail explains the consequence to the operator (the label
|
|
35
|
+
* names the event, the icon/tone carry severity), so the copy reads helpfully rather
|
|
36
|
+
* than mechanically.
|
|
37
|
+
*/
|
|
38
|
+
function LogMarkerRow({
|
|
39
|
+
icon,
|
|
40
|
+
tone,
|
|
41
|
+
timestamp = null,
|
|
42
|
+
detail,
|
|
43
|
+
meta,
|
|
44
|
+
terminalFailure = false,
|
|
45
|
+
children,
|
|
46
|
+
}: LogMarkerRowProps) {
|
|
47
|
+
return (
|
|
48
|
+
<LogRow
|
|
49
|
+
lineNumber={null}
|
|
50
|
+
timestamp={timestamp}
|
|
51
|
+
tone={tone}
|
|
52
|
+
data-log-terminal-failure={terminalFailure ? 'true' : undefined}
|
|
53
|
+
>
|
|
54
|
+
<LogContent className={cn('block', toneText[tone])}>
|
|
55
|
+
<span className="inline-flex w-full items-center gap-8">
|
|
56
|
+
<Icon name={icon} className="size-14 flex-none" aria-hidden="true" />
|
|
57
|
+
{/* Label, detail, and figures share one text cluster joined by a literal
|
|
58
|
+
" · ". Flex `gap` is visual only and would copy with no separator, so the
|
|
59
|
+
separators are real inline text. The dashed rule trails after the text
|
|
60
|
+
(an aria-hidden flex filler) and contributes nothing to a selection. */}
|
|
61
|
+
<span className="min-w-0">
|
|
62
|
+
<span className="font-medium">{children}</span>
|
|
63
|
+
{detail != null && (
|
|
64
|
+
<>
|
|
65
|
+
{' · '}
|
|
66
|
+
<span className="font-normal opacity-80">{detail}</span>
|
|
67
|
+
</>
|
|
68
|
+
)}
|
|
69
|
+
{meta != null && (
|
|
70
|
+
<>
|
|
71
|
+
{' · '}
|
|
72
|
+
<span className="font-code tabular-nums opacity-80">{meta}</span>
|
|
73
|
+
</>
|
|
74
|
+
)}
|
|
75
|
+
</span>
|
|
76
|
+
<span
|
|
77
|
+
aria-hidden="true"
|
|
78
|
+
className="h-px flex-1 border-t border-dashed border-current opacity-30"
|
|
79
|
+
/>
|
|
80
|
+
</span>
|
|
81
|
+
</LogContent>
|
|
82
|
+
</LogRow>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface EndMarkerProps {
|
|
87
|
+
record: EndLogRecord;
|
|
88
|
+
lineCount: number;
|
|
89
|
+
durationMs?: number | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Clean end of the log: line count + output bytes (+ overall duration). `total_bytes` is payload bytes. */
|
|
93
|
+
export function EndMarker({record, lineCount, durationMs = null}: EndMarkerProps) {
|
|
94
|
+
const meta = [
|
|
95
|
+
`${lineCount} ${lineCount === 1 ? 'line' : 'lines'}`,
|
|
96
|
+
formatBytes(record.total_bytes),
|
|
97
|
+
...(durationMs != null ? [formatDuration(durationMs)] : []),
|
|
98
|
+
].join(' · ');
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<LogMarkerRow icon="flagLine" tone="default" timestamp={new Date(record.ts)} meta={meta}>
|
|
102
|
+
End of log
|
|
103
|
+
</LogMarkerRow>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The runner's local backlog shed bytes before upload, so some output never arrived. A warning. */
|
|
108
|
+
export function GapMarker({record}: {record: GapLogRecord}) {
|
|
109
|
+
return (
|
|
110
|
+
<LogMarkerRow
|
|
111
|
+
icon="errorWarningLine"
|
|
112
|
+
tone="warning"
|
|
113
|
+
timestamp={new Date(record.ts)}
|
|
114
|
+
detail={`the runner fell behind and dropped ${formatBytes(record.dropped_bytes)}`}
|
|
115
|
+
>
|
|
116
|
+
Output missing
|
|
117
|
+
</LogMarkerRow>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The job hit its shared log size limit; logging stopped but the step kept running. A warning. */
|
|
122
|
+
export function CappedMarker({record}: {record: CappedLogRecord}) {
|
|
123
|
+
return (
|
|
124
|
+
<LogMarkerRow
|
|
125
|
+
icon="forbidLine"
|
|
126
|
+
tone="warning"
|
|
127
|
+
timestamp={new Date(record.ts)}
|
|
128
|
+
detail="later output isn't shown; the step kept running"
|
|
129
|
+
>
|
|
130
|
+
Log size limit reached
|
|
131
|
+
</LogMarkerRow>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The runner disappeared and the stream was force-closed. A terminal failure (status taxonomy §9). */
|
|
136
|
+
export function RunnerLostMarker({record}: {record: RunnerLostLogRecord}) {
|
|
137
|
+
return (
|
|
138
|
+
<LogMarkerRow
|
|
139
|
+
icon="closeCircleLine"
|
|
140
|
+
tone="error"
|
|
141
|
+
timestamp={new Date(record.ts)}
|
|
142
|
+
detail="the log ends here and may be incomplete"
|
|
143
|
+
terminalFailure
|
|
144
|
+
>
|
|
145
|
+
Runner disconnected
|
|
146
|
+
</LogMarkerRow>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type {LogRecord, ReadLogsResponseDto} from '@shipfox/api-logs-dto';
|
|
2
|
+
import {
|
|
3
|
+
mergeLogRead,
|
|
4
|
+
parseLogNdjson,
|
|
5
|
+
STEP_LOG_DRAIN_REFETCH_MS,
|
|
6
|
+
STEP_LOG_LIVE_REFETCH_MS,
|
|
7
|
+
type StepLogSnapshot,
|
|
8
|
+
stepLogRefetchInterval,
|
|
9
|
+
} from './log-read.js';
|
|
10
|
+
|
|
11
|
+
const output = (data: string, ts = 1): LogRecord => ({
|
|
12
|
+
v: 1,
|
|
13
|
+
ts,
|
|
14
|
+
type: 'output',
|
|
15
|
+
stream: 'stdout',
|
|
16
|
+
data,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const line = (record: LogRecord): string => `${JSON.stringify(record)}\n`;
|
|
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'}> => ({
|
|
27
|
+
mode: 'inline',
|
|
28
|
+
ndjson: params.ndjson,
|
|
29
|
+
next_cursor: params.nextCursor ?? 1,
|
|
30
|
+
has_more: params.hasMore ?? false,
|
|
31
|
+
state: params.state ?? 'open',
|
|
32
|
+
truncated: params.truncated ?? false,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const presigned = (
|
|
36
|
+
params: Partial<Extract<ReadLogsResponseDto, {mode: 'presigned'}>> = {},
|
|
37
|
+
): Extract<ReadLogsResponseDto, {mode: 'presigned'}> => ({
|
|
38
|
+
mode: 'presigned',
|
|
39
|
+
url: params.url ?? 'https://storage.example.test/logs/object?sig=1',
|
|
40
|
+
state: params.state ?? 'closed',
|
|
41
|
+
expires_at: params.expires_at ?? '2026-06-23T10:00:00.000Z',
|
|
42
|
+
total_bytes: params.total_bytes ?? 128,
|
|
43
|
+
truncated: params.truncated ?? false,
|
|
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
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('mergeLogRead', () => {
|
|
87
|
+
test('appends inline records and advances the cursor', () => {
|
|
88
|
+
const previous: StepLogSnapshot = {
|
|
89
|
+
records: [output('old\n', 1)],
|
|
90
|
+
nextCursor: 3,
|
|
91
|
+
source: 'inline',
|
|
92
|
+
state: 'open',
|
|
93
|
+
complete: false,
|
|
94
|
+
hasMore: false,
|
|
95
|
+
truncated: false,
|
|
96
|
+
totalBytes: null,
|
|
97
|
+
expiresAt: null,
|
|
98
|
+
};
|
|
99
|
+
const response = inline({ndjson: line(output('new\n', 2)), nextCursor: 4, hasMore: true});
|
|
100
|
+
|
|
101
|
+
const snapshot = mergeLogRead(previous, {mode: 'inline', response});
|
|
102
|
+
|
|
103
|
+
expect(snapshot.records).toEqual([output('old\n', 1), output('new\n', 2)]);
|
|
104
|
+
expect(snapshot.nextCursor).toBe(4);
|
|
105
|
+
expect(snapshot.hasMore).toBe(true);
|
|
106
|
+
expect(snapshot.complete).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('preserves records on an empty open inline page', () => {
|
|
110
|
+
const previous: StepLogSnapshot = {
|
|
111
|
+
records: [output('old\n', 1)],
|
|
112
|
+
nextCursor: 3,
|
|
113
|
+
source: 'inline',
|
|
114
|
+
state: 'open',
|
|
115
|
+
complete: false,
|
|
116
|
+
hasMore: false,
|
|
117
|
+
truncated: false,
|
|
118
|
+
totalBytes: null,
|
|
119
|
+
expiresAt: null,
|
|
120
|
+
};
|
|
121
|
+
const response = inline({ndjson: '', nextCursor: 3, state: 'open'});
|
|
122
|
+
|
|
123
|
+
const snapshot = mergeLogRead(previous, {mode: 'inline', response});
|
|
124
|
+
|
|
125
|
+
expect(snapshot.records).toEqual(previous.records);
|
|
126
|
+
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
|
+
});
|
|
141
|
+
|
|
142
|
+
test('replaces partial inline records with the compacted object records', () => {
|
|
143
|
+
const previous: StepLogSnapshot = {
|
|
144
|
+
records: [output('partial\n', 1)],
|
|
145
|
+
nextCursor: 2,
|
|
146
|
+
source: 'inline',
|
|
147
|
+
state: 'closed',
|
|
148
|
+
complete: false,
|
|
149
|
+
hasMore: true,
|
|
150
|
+
truncated: false,
|
|
151
|
+
totalBytes: null,
|
|
152
|
+
expiresAt: null,
|
|
153
|
+
};
|
|
154
|
+
const response = presigned({
|
|
155
|
+
expires_at: '2026-06-23T10:00:00.000Z',
|
|
156
|
+
total_bytes: 256,
|
|
157
|
+
truncated: true,
|
|
158
|
+
});
|
|
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
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('stepLogRefetchInterval', () => {
|
|
177
|
+
const snapshot = (overrides: Partial<StepLogSnapshot>): StepLogSnapshot => ({
|
|
178
|
+
records: [],
|
|
179
|
+
nextCursor: 0,
|
|
180
|
+
source: 'inline',
|
|
181
|
+
state: 'open',
|
|
182
|
+
complete: false,
|
|
183
|
+
hasMore: false,
|
|
184
|
+
truncated: false,
|
|
185
|
+
totalBytes: null,
|
|
186
|
+
expiresAt: null,
|
|
187
|
+
...overrides,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('does not poll before the first snapshot', () => {
|
|
191
|
+
const interval = stepLogRefetchInterval(undefined);
|
|
192
|
+
|
|
193
|
+
expect(interval).toBe(false);
|
|
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);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {type LogRecord, parseLogRecordLine, type ReadLogsResponseDto} from '@shipfox/api-logs-dto';
|
|
2
|
+
|
|
3
|
+
export const STEP_LOG_DRAIN_REFETCH_MS = 250;
|
|
4
|
+
export const STEP_LOG_LIVE_REFETCH_MS = 2_000;
|
|
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
|
+
export interface StepLogSnapshot {
|
|
12
|
+
records: LogRecord[];
|
|
13
|
+
nextCursor: number;
|
|
14
|
+
source: 'inline' | 'presigned';
|
|
15
|
+
state: 'open' | 'closed' | 'compacted';
|
|
16
|
+
complete: boolean;
|
|
17
|
+
hasMore: boolean;
|
|
18
|
+
truncated: boolean;
|
|
19
|
+
totalBytes: number | null;
|
|
20
|
+
expiresAt: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ResolvedStepLogRead =
|
|
24
|
+
| {mode: 'inline'; response: InlineReadLogsResponse}
|
|
25
|
+
| {mode: 'presigned'; response: PresignedReadLogsResponse; ndjson: string};
|
|
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
|
+
}
|
|
33
|
+
|
|
34
|
+
export function mergeLogRead(
|
|
35
|
+
previous: StepLogSnapshot | undefined,
|
|
36
|
+
read: ResolvedStepLogRead,
|
|
37
|
+
): StepLogSnapshot {
|
|
38
|
+
if (read.mode === 'presigned') {
|
|
39
|
+
return {
|
|
40
|
+
records: parseLogNdjson(read.ndjson),
|
|
41
|
+
nextCursor: 0,
|
|
42
|
+
source: 'presigned',
|
|
43
|
+
state: 'compacted',
|
|
44
|
+
complete: true,
|
|
45
|
+
hasMore: false,
|
|
46
|
+
truncated: read.response.truncated,
|
|
47
|
+
totalBytes: read.response.total_bytes,
|
|
48
|
+
expiresAt: read.response.expires_at,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const records = parseLogNdjson(read.response.ndjson);
|
|
53
|
+
const complete = read.response.state === 'closed' && !read.response.has_more;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
records: [...(previous?.records ?? []), ...records],
|
|
57
|
+
nextCursor: read.response.next_cursor,
|
|
58
|
+
source: 'inline',
|
|
59
|
+
state: read.response.state,
|
|
60
|
+
complete,
|
|
61
|
+
hasMore: read.response.has_more,
|
|
62
|
+
truncated: read.response.truncated,
|
|
63
|
+
totalBytes: null,
|
|
64
|
+
expiresAt: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function stepLogRefetchInterval(
|
|
69
|
+
snapshot: StepLogSnapshot | undefined,
|
|
70
|
+
lastFetchErrored = false,
|
|
71
|
+
): number | false {
|
|
72
|
+
// A persistent failure (deleted step, 5xx, an unparseable record line) never advances
|
|
73
|
+
// the cursor, so without this guard the interval re-polls the same dead cursor forever.
|
|
74
|
+
// Stop once a fetch errors out (React Query's own retry absorbs transient blips first);
|
|
75
|
+
// refetchOnWindowFocus/Reconnect (gated on !complete) resume it.
|
|
76
|
+
if (lastFetchErrored) return false;
|
|
77
|
+
if (!snapshot || snapshot.complete) return false;
|
|
78
|
+
return snapshot.hasMore ? STEP_LOG_DRAIN_REFETCH_MS : STEP_LOG_LIVE_REFETCH_MS;
|
|
79
|
+
}
|