@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.
Files changed (80) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +9 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.swcrc +42 -0
  5. package/.turbo/turbo-build.log +2 -0
  6. package/.turbo/turbo-type$colon$emit.log +1 -0
  7. package/.turbo/turbo-type.log +1 -0
  8. package/CHANGELOG.md +93 -0
  9. package/LICENSE +21 -0
  10. package/dist/components/agent-session-rows.d.ts +8 -0
  11. package/dist/components/agent-session-rows.d.ts.map +1 -0
  12. package/dist/components/agent-session-rows.js +374 -0
  13. package/dist/components/agent-session-rows.js.map +1 -0
  14. package/dist/components/index.d.ts +5 -0
  15. package/dist/components/index.d.ts.map +1 -0
  16. package/dist/components/index.js +6 -0
  17. package/dist/components/index.js.map +1 -0
  18. package/dist/components/log-group.d.ts +11 -0
  19. package/dist/components/log-group.d.ts.map +1 -0
  20. package/dist/components/log-group.js +57 -0
  21. package/dist/components/log-group.js.map +1 -0
  22. package/dist/components/log-view.d.ts +20 -0
  23. package/dist/components/log-view.d.ts.map +1 -0
  24. package/dist/components/log-view.js +243 -0
  25. package/dist/components/log-view.js.map +1 -0
  26. package/dist/components/output-log-row.d.ts +9 -0
  27. package/dist/components/output-log-row.d.ts.map +1 -0
  28. package/dist/components/output-log-row.js +24 -0
  29. package/dist/components/output-log-row.js.map +1 -0
  30. package/dist/components/system-markers.d.ts +21 -0
  31. package/dist/components/system-markers.d.ts.map +1 -0
  32. package/dist/components/system-markers.js +113 -0
  33. package/dist/components/system-markers.js.map +1 -0
  34. package/dist/core/log-read.d.ts +33 -0
  35. package/dist/core/log-read.d.ts.map +1 -0
  36. package/dist/core/log-read.js +49 -0
  37. package/dist/core/log-read.js.map +1 -0
  38. package/dist/core/log-tree.d.ts +85 -0
  39. package/dist/core/log-tree.d.ts.map +1 -0
  40. package/dist/core/log-tree.js +139 -0
  41. package/dist/core/log-tree.js.map +1 -0
  42. package/dist/env.d.js +2 -0
  43. package/dist/env.d.js.map +1 -0
  44. package/dist/hooks/api/step-logs.d.ts +24 -0
  45. package/dist/hooks/api/step-logs.d.ts.map +1 -0
  46. package/dist/hooks/api/step-logs.js +129 -0
  47. package/dist/hooks/api/step-logs.js.map +1 -0
  48. package/dist/index.d.ts +5 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +5 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/tsconfig.test.tsbuildinfo +1 -0
  53. package/package.json +84 -0
  54. package/src/components/agent-session-rows.tsx +341 -0
  55. package/src/components/index.ts +15 -0
  56. package/src/components/log-group.stories.tsx +204 -0
  57. package/src/components/log-group.tsx +54 -0
  58. package/src/components/log-view.stories.tsx +584 -0
  59. package/src/components/log-view.test.tsx +266 -0
  60. package/src/components/log-view.tsx +281 -0
  61. package/src/components/output-log-row.stories.tsx +76 -0
  62. package/src/components/output-log-row.tsx +35 -0
  63. package/src/components/system-markers.stories.tsx +46 -0
  64. package/src/components/system-markers.tsx +148 -0
  65. package/src/core/log-read.test.ts +219 -0
  66. package/src/core/log-read.ts +79 -0
  67. package/src/core/log-tree.test.ts +380 -0
  68. package/src/core/log-tree.ts +200 -0
  69. package/src/env.d.ts +7 -0
  70. package/src/hooks/api/step-logs-query.test.tsx +283 -0
  71. package/src/hooks/api/step-logs.test.ts +66 -0
  72. package/src/hooks/api/step-logs.ts +166 -0
  73. package/src/index.ts +18 -0
  74. package/test/setup.ts +3 -0
  75. package/tsconfig.build.json +9 -0
  76. package/tsconfig.build.tsbuildinfo +1 -0
  77. package/tsconfig.json +3 -0
  78. package/tsconfig.test.json +8 -0
  79. package/vercel.json +8 -0
  80. 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
+ }