@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.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +27 -0
  3. package/dist/components/agent-session-rows.d.ts +1 -1
  4. package/dist/components/agent-session-rows.d.ts.map +1 -1
  5. package/dist/components/agent-session-rows.js.map +1 -1
  6. package/dist/components/log-view.d.ts +1 -1
  7. package/dist/components/log-view.d.ts.map +1 -1
  8. package/dist/components/log-view.js.map +1 -1
  9. package/dist/components/system-markers.js +2 -2
  10. package/dist/components/system-markers.js.map +1 -1
  11. package/dist/core/log-model.d.ts +93 -0
  12. package/dist/core/log-model.d.ts.map +1 -0
  13. package/dist/core/log-model.js +3 -0
  14. package/dist/core/log-model.js.map +1 -0
  15. package/dist/core/log-read.d.ts +7 -14
  16. package/dist/core/log-read.d.ts.map +1 -1
  17. package/dist/core/log-read.js +9 -13
  18. package/dist/core/log-read.js.map +1 -1
  19. package/dist/core/log-tree.d.ts +1 -1
  20. package/dist/core/log-tree.d.ts.map +1 -1
  21. package/dist/core/log-tree.js +3 -3
  22. package/dist/core/log-tree.js.map +1 -1
  23. package/dist/hooks/api/log-mapper.d.ts +10 -0
  24. package/dist/hooks/api/log-mapper.d.ts.map +1 -0
  25. package/dist/hooks/api/log-mapper.js +92 -0
  26. package/dist/hooks/api/log-mapper.js.map +1 -0
  27. package/dist/hooks/api/step-logs.d.ts +7 -2
  28. package/dist/hooks/api/step-logs.d.ts.map +1 -1
  29. package/dist/hooks/api/step-logs.js +14 -5
  30. package/dist/hooks/api/step-logs.js.map +1 -1
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/tsconfig.test.tsbuildinfo +1 -1
  35. package/package.json +7 -7
  36. package/src/components/agent-session-rows.tsx +1 -1
  37. package/src/components/log-group.stories.tsx +2 -2
  38. package/src/components/log-view.stories.tsx +10 -10
  39. package/src/components/log-view.test.tsx +3 -3
  40. package/src/components/log-view.tsx +1 -1
  41. package/src/components/system-markers.stories.tsx +3 -3
  42. package/src/components/system-markers.tsx +2 -2
  43. package/src/core/log-model.ts +76 -0
  44. package/src/core/log-read.test.ts +58 -139
  45. package/src/core/log-read.ts +12 -25
  46. package/src/core/log-tree.test.ts +10 -10
  47. package/src/core/log-tree.ts +4 -4
  48. package/src/hooks/api/log-mapper.test.ts +56 -0
  49. package/src/hooks/api/log-mapper.ts +75 -0
  50. package/src/hooks/api/step-logs.test.ts +21 -1
  51. package/src/hooks/api/step-logs.ts +24 -7
  52. package/src/index.ts +7 -0
  53. package/tsconfig.build.tsbuildinfo +1 -1
  54. 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.0.0",
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": "5.0.0",
25
- "@shipfox/client-api": "4.0.0",
26
- "@shipfox/react-ui": "0.3.5"
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;
@@ -13,8 +13,8 @@ const groupStart = (groupId: string, name: string): GroupStartLogRecord => ({
13
13
  v: 1,
14
14
  ts: at(0),
15
15
  type: 'group_start',
16
- group_id: groupId,
17
- parent_group_id: null,
16
+ groupId,
17
+ parentGroupId: null,
18
18
  name,
19
19
  });
20
20
 
@@ -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
- group_id: groupId,
32
- parent_group_id: parentGroupId,
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
- group_id: groupId,
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', dropped_bytes: 2048},
57
- {v: 1, ts: at(14), type: 'end', total_bytes: 15_360},
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', total_bytes: 9_216},
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', total_bytes: 4096},
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', total_bytes: 12_288},
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', total_bytes: 0}]} />
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', total_bytes: 0}]} />);
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', dropped_bytes: 2048} as const, label: 'Output missing'},
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', total_bytes: 15_360}}
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', dropped_bytes: 2048}} />
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', total_bytes: 15_360}}
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.total_bytes),
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.dropped_bytes)}`}
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, ReadLogsResponseDto} from '@shipfox/api-logs-dto';
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 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'}> => ({
18
+ const inline = (params: Partial<InlineLogRead> = {}): InlineLogRead => ({
27
19
  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,
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: 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
- });
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, {mode: 'inline', response});
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('preserves records on an empty open inline page', () => {
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, {mode: 'inline', response});
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 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,
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('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);
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
  });
@@ -1,18 +1,13 @@
1
- import {type LogRecord, parseLogRecordLine, type ReadLogsResponseDto} from '@shipfox/api-logs-dto';
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: 'inline' | 'presigned';
15
- state: 'open' | 'closed' | 'compacted';
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: 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
- }
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: parseLogNdjson(read.ndjson),
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.total_bytes,
48
- expiresAt: read.response.expires_at,
35
+ totalBytes: read.response.totalBytes,
36
+ expiresAt: read.response.expiresAt,
49
37
  };
50
38
  }
51
39
 
52
- const records = parseLogNdjson(read.response.ndjson);
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.next_cursor,
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.has_more,
48
+ hasMore: read.response.hasMore,
62
49
  truncated: read.response.truncated,
63
50
  totalBytes: null,
64
51
  expiresAt: null,