@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,380 @@
|
|
|
1
|
+
import type {LogRecord} from '@shipfox/api-logs-dto';
|
|
2
|
+
import {buildLogTree, type GroupLogNode, type LogNode, stripTrailingNewline} from './log-tree.js';
|
|
3
|
+
|
|
4
|
+
const out = (data: string, stream: 'stdout' | 'stderr' = 'stdout', ts = 0): LogRecord => ({
|
|
5
|
+
v: 1,
|
|
6
|
+
ts,
|
|
7
|
+
type: 'output',
|
|
8
|
+
stream,
|
|
9
|
+
data,
|
|
10
|
+
});
|
|
11
|
+
const gstart = (
|
|
12
|
+
groupId: string,
|
|
13
|
+
name: string,
|
|
14
|
+
parentGroupId: string | null = null,
|
|
15
|
+
ts = 0,
|
|
16
|
+
): LogRecord => ({
|
|
17
|
+
v: 1,
|
|
18
|
+
ts,
|
|
19
|
+
type: 'group_start',
|
|
20
|
+
group_id: groupId,
|
|
21
|
+
parent_group_id: parentGroupId,
|
|
22
|
+
name,
|
|
23
|
+
});
|
|
24
|
+
const gend = (groupId: string, ts = 0): LogRecord => ({
|
|
25
|
+
v: 1,
|
|
26
|
+
ts,
|
|
27
|
+
type: 'group_end',
|
|
28
|
+
group_id: groupId,
|
|
29
|
+
});
|
|
30
|
+
const end = (totalBytes = 0, ts = 0): LogRecord => ({
|
|
31
|
+
v: 1,
|
|
32
|
+
ts,
|
|
33
|
+
type: 'end',
|
|
34
|
+
total_bytes: totalBytes,
|
|
35
|
+
});
|
|
36
|
+
const gap = (droppedBytes = 0, ts = 0): LogRecord => ({
|
|
37
|
+
v: 1,
|
|
38
|
+
ts,
|
|
39
|
+
type: 'gap',
|
|
40
|
+
dropped_bytes: droppedBytes,
|
|
41
|
+
});
|
|
42
|
+
const capped = (ts = 0): LogRecord => ({v: 1, ts, type: 'capped'});
|
|
43
|
+
const runnerLost = (ts = 0): LogRecord => ({v: 1, ts, type: 'runner_lost'});
|
|
44
|
+
const agentSession = (
|
|
45
|
+
row: Extract<LogRecord, {type: 'agent_session'}>['row'] = {
|
|
46
|
+
kind: 'raw',
|
|
47
|
+
timestamp: 0,
|
|
48
|
+
label: 'entry',
|
|
49
|
+
raw: 'entry',
|
|
50
|
+
},
|
|
51
|
+
ts = 0,
|
|
52
|
+
): LogRecord => ({
|
|
53
|
+
v: 1,
|
|
54
|
+
ts,
|
|
55
|
+
type: 'agent_session',
|
|
56
|
+
row: {...row, timestamp: ts},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const asGroup = (node: LogNode | undefined): GroupLogNode => {
|
|
60
|
+
if (node?.kind !== 'group') throw new Error(`expected a group node, got ${node?.kind}`);
|
|
61
|
+
return node;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe('buildLogTree', () => {
|
|
65
|
+
test('numbers flat output lines and counts them', () => {
|
|
66
|
+
const records = [out('a'), out('b'), out('c')];
|
|
67
|
+
|
|
68
|
+
const tree = buildLogTree(records);
|
|
69
|
+
|
|
70
|
+
expect(tree.nodes.map((n) => (n.kind === 'output' ? n.lineNumber : null))).toEqual([1, 2, 3]);
|
|
71
|
+
expect(tree.lineCount).toBe(3);
|
|
72
|
+
expect(tree.terminated).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('reads originTs from the first record and null when empty', () => {
|
|
76
|
+
const withRecords = buildLogTree([out('a', 'stdout', 42)]);
|
|
77
|
+
const empty = buildLogTree([]);
|
|
78
|
+
|
|
79
|
+
expect(withRecords.originTs).toBe(42);
|
|
80
|
+
expect(empty.originTs).toBeNull();
|
|
81
|
+
expect(empty.nodes).toEqual([]);
|
|
82
|
+
expect(empty.lineCount).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('anchors originTs on the first agent_session record', () => {
|
|
86
|
+
const tree = buildLogTree([agentSession(undefined, 5), out('a', 'stdout', 9)]);
|
|
87
|
+
|
|
88
|
+
expect(tree.originTs).toBe(5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('nests output inside a closed group', () => {
|
|
92
|
+
const records = [gstart('g1', 'Build'), out('compiling'), gend('g1', 9)];
|
|
93
|
+
|
|
94
|
+
const tree = buildLogTree(records);
|
|
95
|
+
const group = asGroup(tree.nodes[0]);
|
|
96
|
+
|
|
97
|
+
expect(tree.nodes).toHaveLength(1);
|
|
98
|
+
expect(group.record.name).toBe('Build');
|
|
99
|
+
expect(group.closed).toBe(true);
|
|
100
|
+
expect(group.endTs).toBe(9);
|
|
101
|
+
expect(group.children).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('builds a multi-level group tree', () => {
|
|
105
|
+
const records = [
|
|
106
|
+
gstart('g1', 'Build'),
|
|
107
|
+
gstart('g2', 'Compile', 'g1'),
|
|
108
|
+
out('cc'),
|
|
109
|
+
gend('g2'),
|
|
110
|
+
gend('g1'),
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const tree = buildLogTree(records);
|
|
114
|
+
const g1 = asGroup(tree.nodes[0]);
|
|
115
|
+
const g2 = asGroup(g1.children[0]);
|
|
116
|
+
|
|
117
|
+
expect(g1.record.group_id).toBe('g1');
|
|
118
|
+
expect(g2.record.group_id).toBe('g2');
|
|
119
|
+
expect(g2.children[0]?.kind).toBe('output');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('precomputes the subtree output-line count per group', () => {
|
|
123
|
+
const records = [
|
|
124
|
+
gstart('g1', 'Build'),
|
|
125
|
+
out('a'),
|
|
126
|
+
gstart('g2', 'Compile', 'g1'),
|
|
127
|
+
out('b'),
|
|
128
|
+
out('c'),
|
|
129
|
+
gend('g2'),
|
|
130
|
+
gend('g1'),
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const g1 = asGroup(buildLogTree(records).nodes[0]);
|
|
134
|
+
const g2 = asGroup(g1.children[1]);
|
|
135
|
+
|
|
136
|
+
expect(g1.lineCount).toBe(3);
|
|
137
|
+
expect(g2.lineCount).toBe(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('leaves a group open when its end never arrives', () => {
|
|
141
|
+
const records = [gstart('g1', 'Build'), out('still going')];
|
|
142
|
+
|
|
143
|
+
const tree = buildLogTree(records);
|
|
144
|
+
const group = asGroup(tree.nodes[0]);
|
|
145
|
+
|
|
146
|
+
expect(group.closed).toBe(false);
|
|
147
|
+
expect(group.endTs).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('ignores an unbalanced group_end with no open group', () => {
|
|
151
|
+
const records = [out('a'), gend('ghost'), out('b')];
|
|
152
|
+
|
|
153
|
+
const tree = buildLogTree(records);
|
|
154
|
+
|
|
155
|
+
expect(tree.nodes.map((n) => n.kind)).toEqual(['output', 'output']);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('closes the matching group_id when a group_start was dropped (C1)', () => {
|
|
159
|
+
const records = [gstart('g1', 'Build'), out('inside g1'), gend('g2'), gend('g1', 5)];
|
|
160
|
+
|
|
161
|
+
const tree = buildLogTree(records);
|
|
162
|
+
const g1 = asGroup(tree.nodes[0]);
|
|
163
|
+
|
|
164
|
+
expect(tree.nodes).toHaveLength(1);
|
|
165
|
+
expect(g1.closed).toBe(true);
|
|
166
|
+
expect(g1.endTs).toBe(5);
|
|
167
|
+
expect(g1.children.map((n) => n.kind)).toEqual(['output']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('orphaned inner groups close with their matched ancestor', () => {
|
|
171
|
+
const records = [gstart('g1', 'Build'), gstart('g2', 'Compile', 'g1'), out('x'), gend('g1', 7)];
|
|
172
|
+
|
|
173
|
+
const tree = buildLogTree(records);
|
|
174
|
+
const g1 = asGroup(tree.nodes[0]);
|
|
175
|
+
const g2 = asGroup(g1.children[0]);
|
|
176
|
+
|
|
177
|
+
expect(g1.closed).toBe(true);
|
|
178
|
+
expect(g1.endTs).toBe(7);
|
|
179
|
+
expect(g2.closed).toBe(true);
|
|
180
|
+
expect(g2.endTs).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a dropped root group_end does not nest or taint the next root group', () => {
|
|
184
|
+
// g1's group_end is dropped under backlog pressure (a gap stands in for it); g2 declares
|
|
185
|
+
// parent_group_id null, so it lands at root, not inside still-open g1. g1 is orphan-closed
|
|
186
|
+
// when g2 opens, so a runner_lost inside g2 must not bubble into the already-closed g1.
|
|
187
|
+
const records = [
|
|
188
|
+
gstart('g1', 'first'),
|
|
189
|
+
out('a'),
|
|
190
|
+
gap(64),
|
|
191
|
+
gstart('g2', 'second'),
|
|
192
|
+
runnerLost(),
|
|
193
|
+
gend('g2'),
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const tree = buildLogTree(records);
|
|
197
|
+
const g1 = asGroup(tree.nodes[0]);
|
|
198
|
+
const g2 = asGroup(tree.nodes[1]);
|
|
199
|
+
|
|
200
|
+
expect(tree.nodes).toHaveLength(2);
|
|
201
|
+
expect(g1.closed).toBe(true);
|
|
202
|
+
expect(g1.endTs).toBeNull();
|
|
203
|
+
expect(g1.lineCount).toBe(1);
|
|
204
|
+
expect(g1.hasError).toBe(false);
|
|
205
|
+
expect(g2.hasError).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('a dropped inner group_end reparents the next sibling via parent_group_id', () => {
|
|
209
|
+
// g2's group_end is dropped; g3 declares parent g1, so it is a sibling of g2 under g1,
|
|
210
|
+
// not nested inside the still-open g2.
|
|
211
|
+
const records = [
|
|
212
|
+
gstart('g1', 'outer'),
|
|
213
|
+
gstart('g2', 'inner-a', 'g1'),
|
|
214
|
+
out('x'),
|
|
215
|
+
gstart('g3', 'inner-b', 'g1'),
|
|
216
|
+
out('y'),
|
|
217
|
+
gend('g3'),
|
|
218
|
+
gend('g1', 9),
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
const tree = buildLogTree(records);
|
|
222
|
+
const g1 = asGroup(tree.nodes[0]);
|
|
223
|
+
|
|
224
|
+
expect(g1.children.map((n) => n.kind)).toEqual(['group', 'group']);
|
|
225
|
+
const g2 = asGroup(g1.children[0]);
|
|
226
|
+
const g3 = asGroup(g1.children[1]);
|
|
227
|
+
expect(g2.record.group_id).toBe('g2');
|
|
228
|
+
expect(g2.closed).toBe(true);
|
|
229
|
+
expect(g2.endTs).toBeNull();
|
|
230
|
+
expect(g3.record.group_id).toBe('g3');
|
|
231
|
+
expect(g3.closed).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('places markers at the current nesting level', () => {
|
|
235
|
+
const records = [gstart('g1', 'Build'), gap(128), gend('g1'), end(2048)];
|
|
236
|
+
|
|
237
|
+
const tree = buildLogTree(records);
|
|
238
|
+
const g1 = asGroup(tree.nodes[0]);
|
|
239
|
+
|
|
240
|
+
expect(g1.children[0]?.kind).toBe('marker');
|
|
241
|
+
expect(tree.nodes[1]?.kind).toBe('marker');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('assigns a unique seq across siblings that reuse a group_id or marker ts', () => {
|
|
245
|
+
// A concatenated multi-step/retry stream reuses g1 and can repeat a marker (type, ts);
|
|
246
|
+
// seq stays unique so the React keys in LogView never collide.
|
|
247
|
+
const records = [
|
|
248
|
+
gstart('g1', 'first'),
|
|
249
|
+
gend('g1'),
|
|
250
|
+
gstart('g1', 'second'),
|
|
251
|
+
gend('g1'),
|
|
252
|
+
gap(1, 5),
|
|
253
|
+
gap(2, 5),
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
const seqs = buildLogTree(records).nodes.map((n) => n.seq);
|
|
257
|
+
|
|
258
|
+
expect(seqs).toEqual([0, 1, 2, 3]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('emits agent_session nodes without consuming a line number', () => {
|
|
262
|
+
const records = [out('a'), agentSession(), out('b')];
|
|
263
|
+
|
|
264
|
+
const tree = buildLogTree(records);
|
|
265
|
+
|
|
266
|
+
expect(tree.nodes.map((node) => node.kind)).toEqual(['output', 'session', 'output']);
|
|
267
|
+
expect(tree.nodes.flatMap((n) => (n.kind === 'output' ? [n.lineNumber] : []))).toEqual([1, 2]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('keeps stdout between a tool call and tool result in stream order', () => {
|
|
271
|
+
const toolCall = agentSession(
|
|
272
|
+
{kind: 'tool-call', timestamp: 1, id: 'call-1', name: 'edit_file', input: '{}'},
|
|
273
|
+
1,
|
|
274
|
+
);
|
|
275
|
+
const toolResult = agentSession(
|
|
276
|
+
{
|
|
277
|
+
kind: 'tool-result',
|
|
278
|
+
timestamp: 3,
|
|
279
|
+
toolCallId: 'call-1',
|
|
280
|
+
toolName: 'edit_file',
|
|
281
|
+
output: 'done',
|
|
282
|
+
isError: false,
|
|
283
|
+
},
|
|
284
|
+
3,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const tree = buildLogTree([toolCall, out('interleaved\n', 'stdout', 2), toolResult]);
|
|
288
|
+
|
|
289
|
+
expect(tree.nodes.map((node) => node.kind)).toEqual(['session', 'output', 'session']);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test.each([
|
|
293
|
+
['end', [end()], true],
|
|
294
|
+
['runner_lost', [runnerLost()], true],
|
|
295
|
+
['capped', [capped()], false],
|
|
296
|
+
['none', [out('a')], false],
|
|
297
|
+
])('terminated is %s-driven', (_label, records, expected) => {
|
|
298
|
+
const tree = buildLogTree(records as LogRecord[]);
|
|
299
|
+
|
|
300
|
+
expect(tree.terminated).toBe(expected);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('hasError annotation', () => {
|
|
304
|
+
test('a runner_lost marker in a group sets hasError', () => {
|
|
305
|
+
const records = [gstart('g1', 'Run'), runnerLost(), gend('g1')];
|
|
306
|
+
|
|
307
|
+
const group = asGroup(buildLogTree(records).nodes[0]);
|
|
308
|
+
|
|
309
|
+
expect(group.hasError).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('stderr does NOT set hasError (a channel, not a failure)', () => {
|
|
313
|
+
const records = [gstart('g1', 'Run'), out('boom', 'stderr'), gend('g1')];
|
|
314
|
+
|
|
315
|
+
const group = asGroup(buildLogTree(records).nodes[0]);
|
|
316
|
+
|
|
317
|
+
expect(group.hasError).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('gap and capped are warnings, not errors', () => {
|
|
321
|
+
const records = [gstart('g1', 'Run'), gap(64), capped(), gend('g1')];
|
|
322
|
+
|
|
323
|
+
const group = asGroup(buildLogTree(records).nodes[0]);
|
|
324
|
+
|
|
325
|
+
expect(group.hasError).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('a runner_lost bubbles up through nested ancestors', () => {
|
|
329
|
+
const records = [
|
|
330
|
+
gstart('g1', 'Build'),
|
|
331
|
+
gstart('g2', 'Compile', 'g1'),
|
|
332
|
+
runnerLost(),
|
|
333
|
+
gend('g2'),
|
|
334
|
+
gend('g1'),
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
const g1 = asGroup(buildLogTree(records).nodes[0]);
|
|
338
|
+
const g2 = asGroup(g1.children[0]);
|
|
339
|
+
|
|
340
|
+
expect(g1.hasError).toBe(true);
|
|
341
|
+
expect(g2.hasError).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('a closed sibling is not marked by a later runner_lost', () => {
|
|
345
|
+
const records = [
|
|
346
|
+
gstart('g1', 'first'),
|
|
347
|
+
out('ok'),
|
|
348
|
+
gend('g1'),
|
|
349
|
+
gstart('g2', 'second'),
|
|
350
|
+
runnerLost(),
|
|
351
|
+
gend('g2'),
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
const tree = buildLogTree(records);
|
|
355
|
+
const g1 = asGroup(tree.nodes[0]);
|
|
356
|
+
const g2 = asGroup(tree.nodes[1]);
|
|
357
|
+
|
|
358
|
+
expect(g1.hasError).toBe(false);
|
|
359
|
+
expect(g2.hasError).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('stripTrailingNewline', () => {
|
|
365
|
+
test('removes a single trailing newline', () => {
|
|
366
|
+
expect(stripTrailingNewline('line\n')).toBe('line');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('leaves a line without a trailing newline untouched', () => {
|
|
370
|
+
expect(stripTrailingNewline('line')).toBe('line');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('removes only one of several trailing newlines', () => {
|
|
374
|
+
expect(stripTrailingNewline('line\n\n')).toBe('line\n');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('strips a trailing CRLF, leaving no carriage return', () => {
|
|
378
|
+
expect(stripTrailingNewline('line\r\n')).toBe('line');
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type {LogRecord} from '@shipfox/api-logs-dto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure render transform for the step-log read stream. The runner emits a flat,
|
|
5
|
+
* ordered NDJSON record list; `group_start`/`group_end` form a tree that the
|
|
6
|
+
* reader reconstructs here before rendering. No React, no state — one function
|
|
7
|
+
* over the record array.
|
|
8
|
+
*
|
|
9
|
+
* records[] ──▶ buildLogTree ──▶ { nodes (forest), terminated, originTs, lineCount }
|
|
10
|
+
*
|
|
11
|
+
* Group closing matches `group_id` (not a blind top-of-stack pop) so a stream that
|
|
12
|
+
* drops a `group_start` under backlog/gap pressure but still delivers its
|
|
13
|
+
* `group_end` does not mis-nest everything after it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type OutputLogRecord = Extract<LogRecord, {type: 'output'}>;
|
|
17
|
+
export type GroupStartLogRecord = Extract<LogRecord, {type: 'group_start'}>;
|
|
18
|
+
export type EndLogRecord = Extract<LogRecord, {type: 'end'}>;
|
|
19
|
+
export type GapLogRecord = Extract<LogRecord, {type: 'gap'}>;
|
|
20
|
+
export type CappedLogRecord = Extract<LogRecord, {type: 'capped'}>;
|
|
21
|
+
export type RunnerLostLogRecord = Extract<LogRecord, {type: 'runner_lost'}>;
|
|
22
|
+
export type AgentSessionLogRecord = Extract<LogRecord, {type: 'agent_session'}>;
|
|
23
|
+
export type MarkerLogRecord = EndLogRecord | GapLogRecord | CappedLogRecord | RunnerLostLogRecord;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Stable, unique render key in creation order. A natural key is not enough: `group_id`
|
|
27
|
+
* and a marker's `(type, ts)` can both repeat among siblings once a consumer feeds a
|
|
28
|
+
* concatenated multi-step/retry stream (or two markers land in the same millisecond),
|
|
29
|
+
* and the append-only build order keeps `seq` stable across re-renders.
|
|
30
|
+
*/
|
|
31
|
+
export interface LogNodeBase {
|
|
32
|
+
seq: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface OutputLogNode extends LogNodeBase {
|
|
36
|
+
kind: 'output';
|
|
37
|
+
lineNumber: number;
|
|
38
|
+
record: OutputLogRecord;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MarkerLogNode extends LogNodeBase {
|
|
42
|
+
kind: 'marker';
|
|
43
|
+
record: MarkerLogRecord;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GroupLogNode extends LogNodeBase {
|
|
47
|
+
kind: 'group';
|
|
48
|
+
record: GroupStartLogRecord;
|
|
49
|
+
/** False when no matching `group_end` arrived (still streaming, or truncated). */
|
|
50
|
+
closed: boolean;
|
|
51
|
+
/** `group_end` timestamp when closed by its matching end, else null. */
|
|
52
|
+
endTs: number | null;
|
|
53
|
+
/** Precomputed: subtree contains a `runner_lost` (a genuine failure). `stderr` is a channel, not an error, so it never sets this. */
|
|
54
|
+
hasError: boolean;
|
|
55
|
+
/** Precomputed output-line count in the subtree, for the collapsed summary. */
|
|
56
|
+
lineCount: number;
|
|
57
|
+
children: LogNode[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SessionLogNode extends LogNodeBase {
|
|
61
|
+
kind: 'session';
|
|
62
|
+
record: AgentSessionLogRecord;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type LogNode = OutputLogNode | MarkerLogNode | GroupLogNode | SessionLogNode;
|
|
66
|
+
|
|
67
|
+
export interface LogTree {
|
|
68
|
+
nodes: LogNode[];
|
|
69
|
+
/** The stream is closed: the records contain an `end` or a `runner_lost`. */
|
|
70
|
+
terminated: boolean;
|
|
71
|
+
/** First record's timestamp; the baseline for relative timestamps. Null when empty. */
|
|
72
|
+
originTs: number | null;
|
|
73
|
+
/** Physical output lines (one per `output` record in v1); drives the end banner. */
|
|
74
|
+
lineCount: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const TRAILING_NEWLINE = /\r?\n$/;
|
|
78
|
+
|
|
79
|
+
/** Strips a single trailing line ending (CRLF or LF) so a line-framed record renders without a blank continuation. */
|
|
80
|
+
export function stripTrailingNewline(data: string): string {
|
|
81
|
+
return data.replace(TRAILING_NEWLINE, '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function assertNever(value: never): never {
|
|
85
|
+
throw new Error(`unexpected log record type: ${JSON.stringify(value)}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildLogTree(records: readonly LogRecord[]): LogTree {
|
|
89
|
+
const nodes: LogNode[] = [];
|
|
90
|
+
const stack: GroupLogNode[] = [];
|
|
91
|
+
let seq = 0;
|
|
92
|
+
let lineNumber = 0;
|
|
93
|
+
let lineCount = 0;
|
|
94
|
+
let terminated = false;
|
|
95
|
+
let originTs: number | null = null;
|
|
96
|
+
|
|
97
|
+
const childrenOf = (): LogNode[] => stack[stack.length - 1]?.children ?? nodes;
|
|
98
|
+
|
|
99
|
+
// Bubble a failure signal (a runner_lost only) to every currently-open ancestor group
|
|
100
|
+
// in one pass, so `hasError` is read in O(1) at render time instead of re-walking subtrees.
|
|
101
|
+
const markOpenGroupsError = (): void => {
|
|
102
|
+
for (const frame of stack) frame.hasError = true;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (const record of records) {
|
|
106
|
+
if (originTs === null) originTs = record.ts;
|
|
107
|
+
switch (record.type) {
|
|
108
|
+
case 'output': {
|
|
109
|
+
lineNumber += 1;
|
|
110
|
+
lineCount += 1;
|
|
111
|
+
for (const frame of stack) frame.lineCount += 1;
|
|
112
|
+
childrenOf().push({kind: 'output', seq: seq++, lineNumber, record});
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case 'group_start': {
|
|
116
|
+
// Reconcile the open stack to the declared parent before nesting. `parent_group_id`
|
|
117
|
+
// is the runner's stack top at emit time (null at the root), so any reader frame
|
|
118
|
+
// below that parent (or every open frame, when the parent is root) is a group whose
|
|
119
|
+
// own `group_end` was dropped under backlog pressure. Orphan-close those frames so a
|
|
120
|
+
// dropped end never mis-parents the groups that follow. A parent whose own start was
|
|
121
|
+
// dropped is not on the stack: it falls through to best-effort root placement.
|
|
122
|
+
const parentId = record.parent_group_id;
|
|
123
|
+
let parentIndex = -1;
|
|
124
|
+
if (parentId !== null) {
|
|
125
|
+
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
126
|
+
if (stack[i]?.record.group_id === parentId) {
|
|
127
|
+
parentIndex = i;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (let i = stack.length - 1; i > parentIndex; i -= 1) {
|
|
133
|
+
const frame = stack[i];
|
|
134
|
+
if (frame) frame.closed = true;
|
|
135
|
+
}
|
|
136
|
+
stack.length = parentIndex + 1;
|
|
137
|
+
|
|
138
|
+
const group: GroupLogNode = {
|
|
139
|
+
kind: 'group',
|
|
140
|
+
seq: seq++,
|
|
141
|
+
record,
|
|
142
|
+
closed: false,
|
|
143
|
+
endTs: null,
|
|
144
|
+
hasError: false,
|
|
145
|
+
lineCount: 0,
|
|
146
|
+
children: [],
|
|
147
|
+
};
|
|
148
|
+
childrenOf().push(group);
|
|
149
|
+
stack.push(group);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case 'group_end': {
|
|
153
|
+
// Close the matching open group_id; any inner frames orphaned by a dropped
|
|
154
|
+
// group_end close with it. An end with no matching open start is ignored.
|
|
155
|
+
let matchIndex = -1;
|
|
156
|
+
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
157
|
+
if (stack[i]?.record.group_id === record.group_id) {
|
|
158
|
+
matchIndex = i;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (matchIndex !== -1) {
|
|
163
|
+
for (let i = stack.length - 1; i >= matchIndex; i -= 1) {
|
|
164
|
+
const frame = stack[i];
|
|
165
|
+
if (frame) frame.closed = true;
|
|
166
|
+
}
|
|
167
|
+
const matched = stack[matchIndex];
|
|
168
|
+
if (matched) matched.endTs = record.ts;
|
|
169
|
+
stack.length = matchIndex;
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case 'end':
|
|
174
|
+
case 'gap':
|
|
175
|
+
case 'capped': {
|
|
176
|
+
if (record.type === 'end') terminated = true;
|
|
177
|
+
childrenOf().push({kind: 'marker', seq: seq++, record});
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case 'runner_lost': {
|
|
181
|
+
terminated = true;
|
|
182
|
+
childrenOf().push({kind: 'marker', seq: seq++, record});
|
|
183
|
+
markOpenGroupsError();
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case 'agent_session':
|
|
187
|
+
childrenOf().push({kind: 'session', seq: seq++, record});
|
|
188
|
+
break;
|
|
189
|
+
default:
|
|
190
|
+
assertNever(record);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
nodes,
|
|
196
|
+
terminated,
|
|
197
|
+
originTs,
|
|
198
|
+
lineCount,
|
|
199
|
+
};
|
|
200
|
+
}
|