@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
@@ -1,4 +1,4 @@
1
- import type {LogRecord} from '@shipfox/api-logs-dto';
1
+ import type {LogRecord} from './log-model.js';
2
2
  import {buildLogTree, type GroupLogNode, type LogNode, stripTrailingNewline} from './log-tree.js';
3
3
 
4
4
  const out = (data: string, stream: 'stdout' | 'stderr' = 'stdout', ts = 0): LogRecord => ({
@@ -17,27 +17,27 @@ const gstart = (
17
17
  v: 1,
18
18
  ts,
19
19
  type: 'group_start',
20
- group_id: groupId,
21
- parent_group_id: parentGroupId,
20
+ groupId,
21
+ parentGroupId,
22
22
  name,
23
23
  });
24
24
  const gend = (groupId: string, ts = 0): LogRecord => ({
25
25
  v: 1,
26
26
  ts,
27
27
  type: 'group_end',
28
- group_id: groupId,
28
+ groupId,
29
29
  });
30
30
  const end = (totalBytes = 0, ts = 0): LogRecord => ({
31
31
  v: 1,
32
32
  ts,
33
33
  type: 'end',
34
- total_bytes: totalBytes,
34
+ totalBytes,
35
35
  });
36
36
  const gap = (droppedBytes = 0, ts = 0): LogRecord => ({
37
37
  v: 1,
38
38
  ts,
39
39
  type: 'gap',
40
- dropped_bytes: droppedBytes,
40
+ droppedBytes,
41
41
  });
42
42
  const capped = (ts = 0): LogRecord => ({v: 1, ts, type: 'capped'});
43
43
  const runnerLost = (ts = 0): LogRecord => ({v: 1, ts, type: 'runner_lost'});
@@ -114,8 +114,8 @@ describe('buildLogTree', () => {
114
114
  const g1 = asGroup(tree.nodes[0]);
115
115
  const g2 = asGroup(g1.children[0]);
116
116
 
117
- expect(g1.record.group_id).toBe('g1');
118
- expect(g2.record.group_id).toBe('g2');
117
+ expect(g1.record.groupId).toBe('g1');
118
+ expect(g2.record.groupId).toBe('g2');
119
119
  expect(g2.children[0]?.kind).toBe('output');
120
120
  });
121
121
 
@@ -224,10 +224,10 @@ describe('buildLogTree', () => {
224
224
  expect(g1.children.map((n) => n.kind)).toEqual(['group', 'group']);
225
225
  const g2 = asGroup(g1.children[0]);
226
226
  const g3 = asGroup(g1.children[1]);
227
- expect(g2.record.group_id).toBe('g2');
227
+ expect(g2.record.groupId).toBe('g2');
228
228
  expect(g2.closed).toBe(true);
229
229
  expect(g2.endTs).toBeNull();
230
- expect(g3.record.group_id).toBe('g3');
230
+ expect(g3.record.groupId).toBe('g3');
231
231
  expect(g3.closed).toBe(true);
232
232
  });
233
233
 
@@ -1,4 +1,4 @@
1
- import type {LogRecord} from '@shipfox/api-logs-dto';
1
+ import type {LogRecord} from './log-model.js';
2
2
 
3
3
  /**
4
4
  * Pure render transform for the step-log read stream. The runner emits a flat,
@@ -119,11 +119,11 @@ export function buildLogTree(records: readonly LogRecord[]): LogTree {
119
119
  // own `group_end` was dropped under backlog pressure. Orphan-close those frames so a
120
120
  // dropped end never mis-parents the groups that follow. A parent whose own start was
121
121
  // dropped is not on the stack: it falls through to best-effort root placement.
122
- const parentId = record.parent_group_id;
122
+ const parentId = record.parentGroupId;
123
123
  let parentIndex = -1;
124
124
  if (parentId !== null) {
125
125
  for (let i = stack.length - 1; i >= 0; i -= 1) {
126
- if (stack[i]?.record.group_id === parentId) {
126
+ if (stack[i]?.record.groupId === parentId) {
127
127
  parentIndex = i;
128
128
  break;
129
129
  }
@@ -154,7 +154,7 @@ export function buildLogTree(records: readonly LogRecord[]): LogTree {
154
154
  // group_end close with it. An end with no matching open start is ignored.
155
155
  let matchIndex = -1;
156
156
  for (let i = stack.length - 1; i >= 0; i -= 1) {
157
- if (stack[i]?.record.group_id === record.group_id) {
157
+ if (stack[i]?.record.groupId === record.groupId) {
158
158
  matchIndex = i;
159
159
  break;
160
160
  }
@@ -0,0 +1,56 @@
1
+ import {parseLogNdjson, toLogRead} from './log-mapper.js';
2
+
3
+ const outputLine = (data: string, ts = 1): string =>
4
+ `${JSON.stringify({v: 1, ts, type: 'output', stream: 'stdout', data})}\n`;
5
+
6
+ describe('parseLogNdjson', () => {
7
+ test('validates and maps records to package-owned camel-case fields', () => {
8
+ const records = parseLogNdjson(
9
+ `${JSON.stringify({
10
+ v: 1,
11
+ ts: 1,
12
+ type: 'group_start',
13
+ group_id: 'build',
14
+ parent_group_id: null,
15
+ name: 'Build',
16
+ })}\r\n${outputLine('done\n', 2)}`,
17
+ );
18
+
19
+ expect(records).toEqual([
20
+ {
21
+ v: 1,
22
+ ts: 1,
23
+ type: 'group_start',
24
+ groupId: 'build',
25
+ parentGroupId: null,
26
+ name: 'Build',
27
+ },
28
+ {v: 1, ts: 2, type: 'output', stream: 'stdout', data: 'done\n'},
29
+ ]);
30
+ });
31
+
32
+ test('rejects an invalid external record before a snapshot can be merged', () => {
33
+ expect(() => parseLogNdjson('{"v":1,"ts":1,"type":"nope"}\n')).toThrow();
34
+ });
35
+ });
36
+
37
+ describe('toLogRead', () => {
38
+ test('maps response envelopes to camel-case domain fields', () => {
39
+ expect(
40
+ toLogRead({
41
+ mode: 'presigned',
42
+ url: 'https://storage.example.test/logs/object?sig=1',
43
+ state: 'closed',
44
+ expires_at: '2026-06-23T10:00:00.000Z',
45
+ total_bytes: 128,
46
+ truncated: false,
47
+ }),
48
+ ).toEqual({
49
+ mode: 'presigned',
50
+ url: 'https://storage.example.test/logs/object?sig=1',
51
+ expiresAt: '2026-06-23T10:00:00.000Z',
52
+ totalBytes: 128,
53
+ truncated: false,
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,75 @@
1
+ import {
2
+ type LogRecord as LogRecordDto,
3
+ parseLogRecordLine,
4
+ type ReadLogsResponseDto,
5
+ } from '@shipfox/api-logs-dto';
6
+ import type {InlineLogRead, LogRecord, PresignedLogRead, SessionViewRow} from '#core/log-model.js';
7
+
8
+ const NDJSON_LINE_BREAK = /\r?\n/;
9
+
10
+ export function toLogRead(response: ReadLogsResponseDto): InlineLogRead | PresignedLogRead {
11
+ if (response.mode === 'presigned') {
12
+ return {
13
+ mode: 'presigned',
14
+ url: response.url,
15
+ expiresAt: response.expires_at,
16
+ totalBytes: response.total_bytes,
17
+ truncated: response.truncated,
18
+ };
19
+ }
20
+
21
+ return {
22
+ mode: 'inline',
23
+ ndjson: response.ndjson,
24
+ nextCursor: response.next_cursor,
25
+ hasMore: response.has_more,
26
+ state: response.state,
27
+ truncated: response.truncated,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * The compacted object is fetched from a presigned external URL, so every line is
33
+ * validated at this boundary before it can enter the package-owned query snapshot.
34
+ */
35
+ export function parseLogNdjson(ndjson: string): LogRecord[] {
36
+ return ndjson
37
+ .split(NDJSON_LINE_BREAK)
38
+ .filter((line) => line.length > 0)
39
+ .map((line) => toLogRecord(parseLogRecordLine(line)));
40
+ }
41
+
42
+ export function toLogRecord(record: LogRecordDto): LogRecord {
43
+ const base: {v: 1; ts: number} = {v: record.v, ts: record.ts};
44
+ switch (record.type) {
45
+ case 'output':
46
+ return {...base, type: record.type, stream: record.stream, data: record.data};
47
+ case 'group_start':
48
+ return {
49
+ ...base,
50
+ type: record.type,
51
+ groupId: record.group_id,
52
+ parentGroupId: record.parent_group_id,
53
+ name: record.name,
54
+ };
55
+ case 'group_end':
56
+ return {...base, type: record.type, groupId: record.group_id};
57
+ case 'end':
58
+ return {...base, type: record.type, totalBytes: record.total_bytes};
59
+ case 'gap':
60
+ return {...base, type: record.type, droppedBytes: record.dropped_bytes};
61
+ case 'agent_session':
62
+ return {...base, type: record.type, row: toSessionViewRow(record.row)};
63
+ case 'capped':
64
+ case 'runner_lost':
65
+ return {...base, type: record.type};
66
+ }
67
+ }
68
+
69
+ function toSessionViewRow(
70
+ row: Extract<LogRecordDto, {type: 'agent_session'}>['row'],
71
+ ): SessionViewRow {
72
+ return row.kind === 'message' || row.kind === 'lifecycle'
73
+ ? {...row, meta: row.meta.map((meta) => ({...meta}))}
74
+ : {...row};
75
+ }
@@ -45,7 +45,14 @@ describe('readStepAttemptLogsPage', () => {
45
45
  });
46
46
 
47
47
  const url = new URL(requestFrom(fetchImpl).url);
48
- expect(result).toEqual(body);
48
+ expect(result).toEqual({
49
+ mode: 'inline',
50
+ ndjson: '',
51
+ nextCursor: 8,
52
+ hasMore: false,
53
+ state: 'open',
54
+ truncated: false,
55
+ });
49
56
  expect(url.pathname).toBe('/steps/11111111-1111-4111-8111-111111111111/attempts/2/logs');
50
57
  expect(url.searchParams.get('cursor')).toBe('7');
51
58
  expect(requestFrom(fetchImpl).method).toBe('GET');
@@ -63,4 +70,17 @@ describe('readStepAttemptLogsPage', () => {
63
70
 
64
71
  await expect(result).rejects.toMatchObject({code: 'not-found', status: 404});
65
72
  });
73
+
74
+ test('rejects an invalid response envelope before it can advance a cached cursor', async () => {
75
+ const fetchImpl = vi.fn(async () => jsonResponse({mode: 'inline', next_cursor: 8}));
76
+ configureApiClient({fetchImpl});
77
+
78
+ const result = readStepAttemptLogsPage({
79
+ stepId: '11111111-1111-4111-8111-111111111111',
80
+ attempt: 1,
81
+ cursor: 7,
82
+ });
83
+
84
+ await expect(result).rejects.toMatchObject({code: 'invalid-response'});
85
+ });
66
86
  });
@@ -1,5 +1,5 @@
1
- import type {ReadLogsResponseDto} from '@shipfox/api-logs-dto';
2
- import {ApiError, apiRequest} from '@shipfox/client-api';
1
+ import {readLogsResponseSchema} from '@shipfox/api-logs-dto';
2
+ import {ApiError, checkedApiRequest} from '@shipfox/client-api';
3
3
  import {useQuery, useQueryClient} from '@tanstack/react-query';
4
4
  import {useRef} from 'react';
5
5
  import {
@@ -8,6 +8,7 @@ import {
8
8
  type StepLogSnapshot,
9
9
  stepLogRefetchInterval,
10
10
  } from '#core/log-read.js';
11
+ import {parseLogNdjson, toLogRead} from './log-mapper.js';
11
12
 
12
13
  export const stepLogsQueryKeys = {
13
14
  all: ['step-logs'] as const,
@@ -27,12 +28,14 @@ export async function readStepAttemptLogsPage({
27
28
  attempt,
28
29
  cursor,
29
30
  signal,
30
- }: ReadStepAttemptLogsPageParams): Promise<ReadLogsResponseDto> {
31
+ }: ReadStepAttemptLogsPageParams) {
31
32
  const params = new URLSearchParams({cursor: String(cursor)});
32
- return await apiRequest<ReadLogsResponseDto>(
33
+ const response = await checkedApiRequest(
34
+ readLogsResponseSchema,
33
35
  `/steps/${encodeURIComponent(stepId)}/attempts/${attempt}/logs?${params.toString()}`,
34
36
  {signal},
35
37
  );
38
+ return toLogRead(response);
36
39
  }
37
40
 
38
41
  class StepLogObjectFetchError extends Error {
@@ -63,6 +66,12 @@ export interface UseStepAttemptLogsQueryOptions {
63
66
  initialErrorRetryDelayMs?: number;
64
67
  }
65
68
 
69
+ /**
70
+ * Narrow React Query wrapper for step logs. Its query function can read the prior
71
+ * snapshot imperatively, but bounded missing-stream retries need a ref that resets
72
+ * when the step, attempt, or retry budget changes. Keep that lifecycle state here
73
+ * rather than exporting query options that could accidentally share it between views.
74
+ */
66
75
  export function useStepAttemptLogsQuery(
67
76
  stepId: string | undefined,
68
77
  attempt: number | undefined,
@@ -93,7 +102,7 @@ export function useStepAttemptLogsQuery(
93
102
  enabled,
94
103
  queryFn: async ({signal}) => {
95
104
  const previous = queryClient.getQueryData<StepLogSnapshot>(queryKey);
96
- let response: ReadLogsResponseDto;
105
+ let response: Awaited<ReturnType<typeof readStepAttemptLogsPage>>;
97
106
  try {
98
107
  response = await readStepAttemptLogsPage({
99
108
  stepId: stepId ?? '',
@@ -121,10 +130,18 @@ export function useStepAttemptLogsQuery(
121
130
 
122
131
  if (response.mode === 'presigned') {
123
132
  const ndjson = await readPresignedLogObject(response.url, signal);
124
- return mergeLogRead(previous, {mode: 'presigned', response, ndjson});
133
+ return mergeLogRead(previous, {
134
+ mode: 'presigned',
135
+ response,
136
+ records: parseLogNdjson(ndjson),
137
+ });
125
138
  }
126
139
 
127
- return mergeLogRead(previous, {mode: 'inline', response});
140
+ return mergeLogRead(previous, {
141
+ mode: 'inline',
142
+ response,
143
+ records: parseLogNdjson(response.ndjson),
144
+ });
128
145
  },
129
146
  retry: (failureCount, error) => {
130
147
  if (initialErrorRetryCount <= 0) return false;
package/src/index.ts CHANGED
@@ -1,3 +1,10 @@
1
+ export type {
2
+ LogRecord,
3
+ LogSource,
4
+ LogState,
5
+ SessionViewRow,
6
+ SessionViewRowMeta,
7
+ } from '#core/log-model.js';
1
8
  export type {StepLogSnapshot} from '#core/log-read.js';
2
9
  export {
3
10
  buildLogTree,