@shipfox/client-logs 3.0.1 → 5.0.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -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 +2 -31
- 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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {LogRecord} from '
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
118
|
-
expect(g2.record.
|
|
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.
|
|
227
|
+
expect(g2.record.groupId).toBe('g2');
|
|
228
228
|
expect(g2.closed).toBe(true);
|
|
229
229
|
expect(g2.endTs).toBeNull();
|
|
230
|
-
expect(g3.record.
|
|
230
|
+
expect(g3.record.groupId).toBe('g3');
|
|
231
231
|
expect(g3.closed).toBe(true);
|
|
232
232
|
});
|
|
233
233
|
|
package/src/core/log-tree.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {LogRecord} from '
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
2
|
-
import {ApiError,
|
|
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)
|
|
31
|
+
}: ReadStepAttemptLogsPageParams) {
|
|
31
32
|
const params = new URLSearchParams({cursor: String(cursor)});
|
|
32
|
-
|
|
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:
|
|
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, {
|
|
133
|
+
return mergeLogRead(previous, {
|
|
134
|
+
mode: 'presigned',
|
|
135
|
+
response,
|
|
136
|
+
records: parseLogNdjson(ndjson),
|
|
137
|
+
});
|
|
125
138
|
}
|
|
126
139
|
|
|
127
|
-
return mergeLogRead(previous, {
|
|
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;
|