@shipfox/api-logs-dto 2.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 +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +15 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/schemas/append.d.ts +29 -0
- package/dist/schemas/append.d.ts.map +1 -0
- package/dist/schemas/append.js +25 -0
- package/dist/schemas/append.js.map +1 -0
- package/dist/schemas/index.d.ts +5 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +6 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/read.d.ts +34 -0
- package/dist/schemas/read.d.ts.map +1 -0
- package/dist/schemas/read.js +48 -0
- package/dist/schemas/read.js.map +1 -0
- package/dist/schemas/record.d.ts +178 -0
- package/dist/schemas/record.d.ts.map +1 -0
- package/dist/schemas/record.js +136 -0
- package/dist/schemas/record.js.map +1 -0
- package/dist/schemas/session-view.d.ts +178 -0
- package/dist/schemas/session-view.d.ts.map +1 -0
- package/dist/schemas/session-view.js +73 -0
- package/dist/schemas/session-view.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +55 -0
- package/src/events.ts +22 -0
- package/src/index.ts +46 -0
- package/src/schemas/append.test.ts +50 -0
- package/src/schemas/append.ts +55 -0
- package/src/schemas/index.ts +45 -0
- package/src/schemas/read.test.ts +69 -0
- package/src/schemas/read.ts +84 -0
- package/src/schemas/record.test.ts +275 -0
- package/src/schemas/record.ts +151 -0
- package/src/schemas/session-view.test.ts +96 -0
- package/src/schemas/session-view.ts +89 -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/vitest.config.ts +3 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {
|
|
3
|
+
type LogRecord,
|
|
4
|
+
logRecordSchema,
|
|
5
|
+
MAX_RECORD_DATA_BYTES,
|
|
6
|
+
MAX_RECORD_GROUP_ID_BYTES,
|
|
7
|
+
MAX_RECORD_NAME_BYTES,
|
|
8
|
+
parseLogRecordLine,
|
|
9
|
+
parseRawLogRecordLine,
|
|
10
|
+
type RawLogRecord,
|
|
11
|
+
rawLogRecordSchema,
|
|
12
|
+
} from './record.js';
|
|
13
|
+
|
|
14
|
+
const ts = 1765531200123;
|
|
15
|
+
const sessionRow = {
|
|
16
|
+
kind: 'message',
|
|
17
|
+
timestamp: ts,
|
|
18
|
+
role: 'assistant',
|
|
19
|
+
label: 'assistant',
|
|
20
|
+
meta: [],
|
|
21
|
+
text: 'hello',
|
|
22
|
+
terminalFailure: false,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/** Every record `type` and whether it is valid on the raw write path. */
|
|
26
|
+
const recordsByType: Array<{record: LogRecord; raw: boolean}> = [
|
|
27
|
+
{record: {v: 1, ts, type: 'output', stream: 'stdout', data: 'hello\n'}, raw: true},
|
|
28
|
+
{
|
|
29
|
+
record: {v: 1, ts, type: 'group_start', group_id: 'g1', parent_group_id: null, name: 'Install'},
|
|
30
|
+
raw: true,
|
|
31
|
+
},
|
|
32
|
+
{record: {v: 1, ts, type: 'group_end', group_id: 'g1'}, raw: true},
|
|
33
|
+
{record: {v: 1, ts, type: 'end', total_bytes: 1048576}, raw: true},
|
|
34
|
+
{record: {v: 1, ts, type: 'gap', dropped_bytes: 4096}, raw: true},
|
|
35
|
+
{
|
|
36
|
+
record: {
|
|
37
|
+
v: 1,
|
|
38
|
+
ts,
|
|
39
|
+
type: 'agent_session',
|
|
40
|
+
row: sessionRow,
|
|
41
|
+
},
|
|
42
|
+
raw: false,
|
|
43
|
+
},
|
|
44
|
+
{record: {v: 1, ts, type: 'capped'}, raw: false},
|
|
45
|
+
{record: {v: 1, ts, type: 'runner_lost'}, raw: false},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
describe('logRecordSchema (read union)', () => {
|
|
49
|
+
it.each(recordsByType)('parses the $record.type record', ({record}) => {
|
|
50
|
+
const parsed = logRecordSchema.parse(record);
|
|
51
|
+
|
|
52
|
+
expect(parsed).toEqual(record);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('parses an output record carrying stderr', () => {
|
|
56
|
+
const parsed = logRecordSchema.parse({v: 1, ts, type: 'output', stream: 'stderr', data: 'x'});
|
|
57
|
+
|
|
58
|
+
expect(parsed).toEqual({v: 1, ts, type: 'output', stream: 'stderr', data: 'x'});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects an unknown record type', () => {
|
|
62
|
+
expect(() => logRecordSchema.parse({v: 1, ts, type: 'exploded'})).toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects an unsupported version', () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
logRecordSchema.parse({v: 2, ts, type: 'output', stream: 'stdout', data: 'x'}),
|
|
68
|
+
).toThrow();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects an output record without a stream pipe', () => {
|
|
72
|
+
expect(() => logRecordSchema.parse({v: 1, ts, type: 'output', data: 'x'})).toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects an output record whose data exceeds the per-record byte cap', () => {
|
|
76
|
+
expect(() =>
|
|
77
|
+
logRecordSchema.parse({
|
|
78
|
+
v: 1,
|
|
79
|
+
ts,
|
|
80
|
+
type: 'output',
|
|
81
|
+
stream: 'stdout',
|
|
82
|
+
data: 'x'.repeat(MAX_RECORD_DATA_BYTES + 1),
|
|
83
|
+
}),
|
|
84
|
+
).toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('accepts data exactly at the per-record byte cap', () => {
|
|
88
|
+
const data = 'x'.repeat(MAX_RECORD_DATA_BYTES);
|
|
89
|
+
|
|
90
|
+
const parsed = logRecordSchema.parse({v: 1, ts, type: 'output', stream: 'stdout', data});
|
|
91
|
+
|
|
92
|
+
expect(Buffer.byteLength((parsed as {data: string}).data, 'utf8')).toBe(MAX_RECORD_DATA_BYTES);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('rejects an empty output record so it cannot store bytes without payload', () => {
|
|
96
|
+
expect(() =>
|
|
97
|
+
logRecordSchema.parse({v: 1, ts, type: 'output', stream: 'stdout', data: ''}),
|
|
98
|
+
).toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('parses a normalized agent_session record', () => {
|
|
102
|
+
const parsed = logRecordSchema.parse({v: 1, ts, type: 'agent_session', row: sessionRow});
|
|
103
|
+
|
|
104
|
+
expect(parsed).toEqual({v: 1, ts, type: 'agent_session', row: sessionRow});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects a raw agent_session record on the read path', () => {
|
|
108
|
+
expect(() => logRecordSchema.parse({v: 1, ts, type: 'agent_session', data: '{}'})).toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('accepts a null parent_group_id at the top level', () => {
|
|
112
|
+
const parsed = logRecordSchema.parse({
|
|
113
|
+
v: 1,
|
|
114
|
+
ts,
|
|
115
|
+
type: 'group_start',
|
|
116
|
+
group_id: 'g1',
|
|
117
|
+
parent_group_id: null,
|
|
118
|
+
name: 'root',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(parsed).toMatchObject({type: 'group_start', parent_group_id: null});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('rejects a group_start whose name exceeds the per-record byte cap', () => {
|
|
125
|
+
expect(() =>
|
|
126
|
+
logRecordSchema.parse({
|
|
127
|
+
v: 1,
|
|
128
|
+
ts,
|
|
129
|
+
type: 'group_start',
|
|
130
|
+
group_id: 'g1',
|
|
131
|
+
parent_group_id: null,
|
|
132
|
+
name: 'x'.repeat(MAX_RECORD_NAME_BYTES + 1),
|
|
133
|
+
}),
|
|
134
|
+
).toThrow();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('rejects a group_start whose group_id exceeds the byte cap', () => {
|
|
138
|
+
expect(() =>
|
|
139
|
+
logRecordSchema.parse({
|
|
140
|
+
v: 1,
|
|
141
|
+
ts,
|
|
142
|
+
type: 'group_start',
|
|
143
|
+
group_id: 'g'.repeat(MAX_RECORD_GROUP_ID_BYTES + 1),
|
|
144
|
+
parent_group_id: null,
|
|
145
|
+
name: 'root',
|
|
146
|
+
}),
|
|
147
|
+
).toThrow();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rejects a group_start whose parent_group_id exceeds the byte cap', () => {
|
|
151
|
+
expect(() =>
|
|
152
|
+
logRecordSchema.parse({
|
|
153
|
+
v: 1,
|
|
154
|
+
ts,
|
|
155
|
+
type: 'group_start',
|
|
156
|
+
group_id: 'g1',
|
|
157
|
+
parent_group_id: 'g'.repeat(MAX_RECORD_GROUP_ID_BYTES + 1),
|
|
158
|
+
name: 'root',
|
|
159
|
+
}),
|
|
160
|
+
).toThrow();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('accepts a group_id exactly at the byte cap', () => {
|
|
164
|
+
const group_id = 'g'.repeat(MAX_RECORD_GROUP_ID_BYTES);
|
|
165
|
+
|
|
166
|
+
const parsed = logRecordSchema.parse({
|
|
167
|
+
v: 1,
|
|
168
|
+
ts,
|
|
169
|
+
type: 'group_start',
|
|
170
|
+
group_id,
|
|
171
|
+
parent_group_id: null,
|
|
172
|
+
name: 'root',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(parsed).toMatchObject({type: 'group_start', group_id});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rejects a group_end whose group_id exceeds the byte cap', () => {
|
|
179
|
+
expect(() =>
|
|
180
|
+
logRecordSchema.parse({
|
|
181
|
+
v: 1,
|
|
182
|
+
ts,
|
|
183
|
+
type: 'group_end',
|
|
184
|
+
group_id: 'g'.repeat(MAX_RECORD_GROUP_ID_BYTES + 1),
|
|
185
|
+
}),
|
|
186
|
+
).toThrow();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('rejects an empty group_id', () => {
|
|
190
|
+
expect(() =>
|
|
191
|
+
logRecordSchema.parse({
|
|
192
|
+
v: 1,
|
|
193
|
+
ts,
|
|
194
|
+
type: 'group_start',
|
|
195
|
+
group_id: '',
|
|
196
|
+
parent_group_id: null,
|
|
197
|
+
name: 'root',
|
|
198
|
+
}),
|
|
199
|
+
).toThrow();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('rejects an empty parent_group_id (a non-null parent must be a real id)', () => {
|
|
203
|
+
expect(() =>
|
|
204
|
+
logRecordSchema.parse({
|
|
205
|
+
v: 1,
|
|
206
|
+
ts,
|
|
207
|
+
type: 'group_start',
|
|
208
|
+
group_id: 'g1',
|
|
209
|
+
parent_group_id: '',
|
|
210
|
+
name: 'root',
|
|
211
|
+
}),
|
|
212
|
+
).toThrow();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('rawLogRecordSchema (write path)', () => {
|
|
217
|
+
it.each(recordsByType.filter((r) => r.raw))('accepts the raw $record.type record', ({record}) => {
|
|
218
|
+
const parsed = rawLogRecordSchema.parse(record);
|
|
219
|
+
|
|
220
|
+
expect(parsed).toEqual(record);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it.each(
|
|
224
|
+
recordsByType.filter((r) => !r.raw),
|
|
225
|
+
)('rejects the read-only $record.type record even though the read union accepts it', ({
|
|
226
|
+
record,
|
|
227
|
+
}) => {
|
|
228
|
+
expect(() => logRecordSchema.parse(record)).not.toThrow();
|
|
229
|
+
|
|
230
|
+
expect(() => rawLogRecordSchema.parse(record)).toThrow();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('accepts a raw agent_session record whose data far exceeds the output byte cap', () => {
|
|
234
|
+
const data = 'x'.repeat(MAX_RECORD_DATA_BYTES * 4);
|
|
235
|
+
|
|
236
|
+
const parsed = rawLogRecordSchema.parse({v: 1, ts, type: 'agent_session', data});
|
|
237
|
+
|
|
238
|
+
expect(Buffer.byteLength((parsed as {data: string}).data, 'utf8')).toBe(
|
|
239
|
+
MAX_RECORD_DATA_BYTES * 4,
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('rejects an empty raw agent_session record', () => {
|
|
244
|
+
expect(() => rawLogRecordSchema.parse({v: 1, ts, type: 'agent_session', data: ''})).toThrow();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('parseLogRecordLine', () => {
|
|
249
|
+
it('parses a JSON line', () => {
|
|
250
|
+
const parsed: LogRecord = parseLogRecordLine(
|
|
251
|
+
'{"v":1,"ts":1,"type":"output","stream":"stdout","data":"hi"}',
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(parsed).toMatchObject({type: 'output', data: 'hi'});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('throws on invalid JSON', () => {
|
|
258
|
+
expect(() => parseLogRecordLine('{not json')).toThrow();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('parseRawLogRecordLine', () => {
|
|
263
|
+
it('parses a raw JSON line', () => {
|
|
264
|
+
const parsed: RawLogRecord = parseRawLogRecordLine(
|
|
265
|
+
'{"v":1,"ts":1,"type":"output","stream":"stdout","data":"hi"}',
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(parsed).toMatchObject({type: 'output', data: 'hi'});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('rejects a forged server-only tombstone', () => {
|
|
272
|
+
expect(() => parseRawLogRecordLine('{"v":1,"ts":1,"type":"capped"}')).toThrow();
|
|
273
|
+
expect(() => parseRawLogRecordLine('{"v":1,"ts":1,"type":"runner_lost"}')).toThrow();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {z} from 'zod';
|
|
2
|
+
import {sessionViewRowSchema} from './session-view.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* NDJSON log record contract — one JSON object per line, runner-framed.
|
|
6
|
+
*
|
|
7
|
+
* `offset` / `committed_length` are byte positions in the raw append NDJSON spool stream
|
|
8
|
+
* (envelope included) — the offset-CAS axis the runner tracks. The per-job accrual
|
|
9
|
+
* budget charges the normalized NDJSON bytes the server stores, so framing and control records
|
|
10
|
+
* count against it too. The per-record byte caps below bound each record so a single
|
|
11
|
+
* entry's overhead is known and a runner cannot grow storage without moving the
|
|
12
|
+
* budget: `data` is non-empty and <= MAX_RECORD_DATA_BYTES, the group `name` is
|
|
13
|
+
* <= MAX_RECORD_NAME_BYTES, the group ids are <= MAX_RECORD_GROUP_ID_BYTES, and every
|
|
14
|
+
* other field is fixed-shape.
|
|
15
|
+
*
|
|
16
|
+
* The envelope is `{v, ts}`, and every record is discriminated by a single flat
|
|
17
|
+
* `type`. The raw/write and stored/read unions are distinct types: the server-only
|
|
18
|
+
* `capped` / `runner_lost` tombstones are members of the read union only, so a forged
|
|
19
|
+
* tombstone cannot pass append validation.
|
|
20
|
+
*
|
|
21
|
+
* The append-side `agent_session` record carries one verbatim agent session entry line
|
|
22
|
+
* in `data`, forwarded opaquely by the runner. On successful ingest, the API normalizes
|
|
23
|
+
* that raw entry into one or more read-side `agent_session` records whose `row` is the
|
|
24
|
+
* canonical session view row. The raw and normalized records travel through the same log
|
|
25
|
+
* append/read pipe; only their contract at each boundary differs.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Largest decoded `data` payload per record. Longer lines are split by the runner. */
|
|
29
|
+
export const MAX_RECORD_DATA_BYTES = 16 * 1024;
|
|
30
|
+
|
|
31
|
+
/** Largest `group_start` name. Bounds the only variable-length control field. */
|
|
32
|
+
export const MAX_RECORD_NAME_BYTES = 1024;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Largest `group_id` / `parent_group_id`. The runner emits short monotonic ids (`g1`,
|
|
36
|
+
* `g2`, …); this only bounds a forged id from a lease-scoped writer so the ids stay a
|
|
37
|
+
* fixed-shape field like every other record field.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_RECORD_GROUP_ID_BYTES = 256;
|
|
40
|
+
|
|
41
|
+
// UTF-8 byte length without node:buffer, so this shared DTO stays browser-safe (the
|
|
42
|
+
// client log viewer imports these types too). Identical to Buffer.byteLength(x, 'utf8').
|
|
43
|
+
const utf8Encoder = new TextEncoder();
|
|
44
|
+
const utf8ByteLength = (value: string): number => utf8Encoder.encode(value).length;
|
|
45
|
+
|
|
46
|
+
const groupId = z
|
|
47
|
+
.string()
|
|
48
|
+
.min(1, {message: 'group id must not be empty'})
|
|
49
|
+
.refine((value) => utf8ByteLength(value) <= MAX_RECORD_GROUP_ID_BYTES, {
|
|
50
|
+
message: `group id exceeds ${MAX_RECORD_GROUP_ID_BYTES} bytes`,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const envelope = {
|
|
54
|
+
v: z.literal(1),
|
|
55
|
+
/** Epoch milliseconds, assigned by the runner at capture. */
|
|
56
|
+
ts: z.number().int().nonnegative(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const logOutput = z.object({
|
|
60
|
+
...envelope,
|
|
61
|
+
type: z.literal('output'),
|
|
62
|
+
stream: z.enum(['stdout', 'stderr']),
|
|
63
|
+
data: z
|
|
64
|
+
.string()
|
|
65
|
+
.min(1, {message: 'output data must not be empty'})
|
|
66
|
+
.refine((value) => utf8ByteLength(value) <= MAX_RECORD_DATA_BYTES, {
|
|
67
|
+
message: `data exceeds ${MAX_RECORD_DATA_BYTES} payload bytes`,
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const logGroupStart = z.object({
|
|
72
|
+
...envelope,
|
|
73
|
+
type: z.literal('group_start'),
|
|
74
|
+
group_id: groupId,
|
|
75
|
+
parent_group_id: groupId.nullable(),
|
|
76
|
+
name: z.string().refine((value) => utf8ByteLength(value) <= MAX_RECORD_NAME_BYTES, {
|
|
77
|
+
message: `group name exceeds ${MAX_RECORD_NAME_BYTES} bytes`,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const logGroupEnd = z.object({...envelope, type: z.literal('group_end'), group_id: groupId});
|
|
82
|
+
|
|
83
|
+
const logEnd = z.object({
|
|
84
|
+
...envelope,
|
|
85
|
+
type: z.literal('end'),
|
|
86
|
+
total_bytes: z.number().int().nonnegative(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const logGap = z.object({
|
|
90
|
+
...envelope,
|
|
91
|
+
type: z.literal('gap'),
|
|
92
|
+
dropped_bytes: z.number().int().nonnegative(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// One verbatim agent session entry line, forwarded opaquely by the runner. `data` is
|
|
96
|
+
// intentionally uncapped here: the per-line byte limit is the server's configurable
|
|
97
|
+
// LOG_MAX_SESSION_LINE_BYTES (a Zod schema cannot read runtime config, and a static cap
|
|
98
|
+
// would collide with the body-limit invariant), enforced in the append write path.
|
|
99
|
+
const rawAgentSession = z.object({
|
|
100
|
+
...envelope,
|
|
101
|
+
type: z.literal('agent_session'),
|
|
102
|
+
data: z.string().min(1, {message: 'agent_session data must not be empty'}),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const agentSession = z.object({
|
|
106
|
+
...envelope,
|
|
107
|
+
type: z.literal('agent_session'),
|
|
108
|
+
row: sessionViewRowSchema,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Server-only tombstones: NOT members of the raw write union.
|
|
112
|
+
const logCapped = z.object({...envelope, type: z.literal('capped')});
|
|
113
|
+
const logRunnerLost = z.object({...envelope, type: z.literal('runner_lost')});
|
|
114
|
+
|
|
115
|
+
/** Records a lease-scoped runner may append. The write path validates against this. */
|
|
116
|
+
export const rawLogRecordSchema = z.discriminatedUnion('type', [
|
|
117
|
+
logOutput,
|
|
118
|
+
logGroupStart,
|
|
119
|
+
logGroupEnd,
|
|
120
|
+
logEnd,
|
|
121
|
+
logGap,
|
|
122
|
+
rawAgentSession,
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
/** Stored/read records: regular records, normalized agent sessions, and tombstones. */
|
|
126
|
+
export const logRecordSchema = z.discriminatedUnion('type', [
|
|
127
|
+
logOutput,
|
|
128
|
+
logGroupStart,
|
|
129
|
+
logGroupEnd,
|
|
130
|
+
logEnd,
|
|
131
|
+
logGap,
|
|
132
|
+
agentSession,
|
|
133
|
+
logCapped,
|
|
134
|
+
logRunnerLost,
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
export type RawLogRecord = z.infer<typeof rawLogRecordSchema>;
|
|
138
|
+
export type LogRecord = z.infer<typeof logRecordSchema>;
|
|
139
|
+
|
|
140
|
+
export function parseLogRecordLine(line: string): LogRecord {
|
|
141
|
+
return logRecordSchema.parse(JSON.parse(line));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parses one NDJSON line against the raw write union. A forged
|
|
146
|
+
* server-only `capped` / `runner_lost` record fails here even though it is valid
|
|
147
|
+
* under the read union — this is the write-path forgery guard.
|
|
148
|
+
*/
|
|
149
|
+
export function parseRawLogRecordLine(line: string): RawLogRecord {
|
|
150
|
+
return rawLogRecordSchema.parse(JSON.parse(line));
|
|
151
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SESSION_VIEW_VERSION,
|
|
3
|
+
type SessionViewRow,
|
|
4
|
+
sessionViewRawRowSchema,
|
|
5
|
+
sessionViewRowSchema,
|
|
6
|
+
sessionViewSchema,
|
|
7
|
+
} from './session-view.js';
|
|
8
|
+
|
|
9
|
+
const timestamp = 1_765_531_200_123;
|
|
10
|
+
|
|
11
|
+
const rowsByKind: SessionViewRow[] = [
|
|
12
|
+
{
|
|
13
|
+
kind: 'message',
|
|
14
|
+
timestamp,
|
|
15
|
+
role: 'assistant',
|
|
16
|
+
label: 'assistant',
|
|
17
|
+
meta: [{label: 'model', value: 'anthropic/claude-opus-4'}],
|
|
18
|
+
text: 'Implemented the change.',
|
|
19
|
+
terminalFailure: false,
|
|
20
|
+
},
|
|
21
|
+
{kind: 'thinking', timestamp, text: 'Inspect the existing parser first.'},
|
|
22
|
+
{
|
|
23
|
+
kind: 'tool-call',
|
|
24
|
+
timestamp,
|
|
25
|
+
id: 'call-1',
|
|
26
|
+
name: 'read_file',
|
|
27
|
+
input: '{"path":"src/index.ts"}',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
kind: 'tool-result',
|
|
31
|
+
timestamp,
|
|
32
|
+
toolCallId: 'call-1',
|
|
33
|
+
toolName: 'read_file',
|
|
34
|
+
output: 'file contents',
|
|
35
|
+
isError: false,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
kind: 'lifecycle',
|
|
39
|
+
timestamp,
|
|
40
|
+
label: 'Session started',
|
|
41
|
+
detail: 'session-1',
|
|
42
|
+
meta: [],
|
|
43
|
+
tone: 'default',
|
|
44
|
+
terminalFailure: false,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
kind: 'raw',
|
|
48
|
+
timestamp,
|
|
49
|
+
label: 'Unknown session entry',
|
|
50
|
+
raw: '{"type":"future_entry","payload":{"value":true}}',
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
describe('sessionViewRowSchema', () => {
|
|
55
|
+
it.each(rowsByKind)('parses the $kind row', (row) => {
|
|
56
|
+
const parsed = sessionViewRowSchema.parse(row);
|
|
57
|
+
|
|
58
|
+
expect(parsed).toEqual(row);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects an unsupported row kind', () => {
|
|
62
|
+
const parse = () => sessionViewRowSchema.parse({kind: 'harness-specific', timestamp});
|
|
63
|
+
|
|
64
|
+
expect(parse).toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('keeps the raw fallback variant harness-neutral', () => {
|
|
68
|
+
const parsed = sessionViewRawRowSchema.parse({
|
|
69
|
+
kind: 'raw',
|
|
70
|
+
timestamp,
|
|
71
|
+
label: 'Malformed session entry',
|
|
72
|
+
raw: '{not json',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(parsed).toEqual({
|
|
76
|
+
kind: 'raw',
|
|
77
|
+
timestamp,
|
|
78
|
+
label: 'Malformed session entry',
|
|
79
|
+
raw: '{not json',
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('sessionViewSchema', () => {
|
|
85
|
+
it('parses the versioned session view envelope', () => {
|
|
86
|
+
const parsed = sessionViewSchema.parse({v: SESSION_VIEW_VERSION, rows: rowsByKind});
|
|
87
|
+
|
|
88
|
+
expect(parsed.rows).toHaveLength(rowsByKind.length);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects an unsupported session view version', () => {
|
|
92
|
+
const parse = () => sessionViewSchema.parse({v: 2, rows: []});
|
|
93
|
+
|
|
94
|
+
expect(parse).toThrow();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {z} from 'zod';
|
|
2
|
+
|
|
3
|
+
export const SESSION_VIEW_VERSION = 1;
|
|
4
|
+
|
|
5
|
+
const timestampSchema = z.number().int().nonnegative();
|
|
6
|
+
|
|
7
|
+
export const sessionViewRowMetaSchema = z.object({
|
|
8
|
+
label: z.string().min(1),
|
|
9
|
+
value: z.string(),
|
|
10
|
+
inline: z.boolean().optional(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const sessionViewRowBase = {
|
|
14
|
+
timestamp: timestampSchema,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const sessionViewMessageRowSchema = z.object({
|
|
18
|
+
...sessionViewRowBase,
|
|
19
|
+
kind: z.literal('message'),
|
|
20
|
+
role: z.string().min(1),
|
|
21
|
+
label: z.string().min(1),
|
|
22
|
+
meta: z.array(sessionViewRowMetaSchema).readonly(),
|
|
23
|
+
text: z.string(),
|
|
24
|
+
terminalFailure: z.boolean(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const sessionViewThinkingRowSchema = z.object({
|
|
28
|
+
...sessionViewRowBase,
|
|
29
|
+
kind: z.literal('thinking'),
|
|
30
|
+
text: z.string(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const sessionViewToolCallRowSchema = z.object({
|
|
34
|
+
...sessionViewRowBase,
|
|
35
|
+
kind: z.literal('tool-call'),
|
|
36
|
+
id: z.string().nullable(),
|
|
37
|
+
name: z.string().min(1),
|
|
38
|
+
input: z.string(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const sessionViewToolResultRowSchema = z.object({
|
|
42
|
+
...sessionViewRowBase,
|
|
43
|
+
kind: z.literal('tool-result'),
|
|
44
|
+
toolCallId: z.string().nullable(),
|
|
45
|
+
toolName: z.string().min(1),
|
|
46
|
+
output: z.string(),
|
|
47
|
+
isError: z.boolean(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const sessionViewLifecycleRowSchema = z.object({
|
|
51
|
+
...sessionViewRowBase,
|
|
52
|
+
kind: z.literal('lifecycle'),
|
|
53
|
+
label: z.string().min(1),
|
|
54
|
+
detail: z.string().nullable(),
|
|
55
|
+
meta: z.array(sessionViewRowMetaSchema).readonly(),
|
|
56
|
+
tone: z.enum(['default', 'warning', 'error']),
|
|
57
|
+
terminalFailure: z.boolean(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const sessionViewRawRowSchema = z.object({
|
|
61
|
+
...sessionViewRowBase,
|
|
62
|
+
kind: z.literal('raw'),
|
|
63
|
+
label: z.string().min(1),
|
|
64
|
+
raw: z.string(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const sessionViewRowSchema = z.discriminatedUnion('kind', [
|
|
68
|
+
sessionViewMessageRowSchema,
|
|
69
|
+
sessionViewThinkingRowSchema,
|
|
70
|
+
sessionViewToolCallRowSchema,
|
|
71
|
+
sessionViewToolResultRowSchema,
|
|
72
|
+
sessionViewLifecycleRowSchema,
|
|
73
|
+
sessionViewRawRowSchema,
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
export const sessionViewSchema = z.object({
|
|
77
|
+
v: z.literal(SESSION_VIEW_VERSION),
|
|
78
|
+
rows: z.array(sessionViewRowSchema).readonly(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export type SessionViewRowMeta = z.infer<typeof sessionViewRowMetaSchema>;
|
|
82
|
+
export type SessionViewMessageRow = z.infer<typeof sessionViewMessageRowSchema>;
|
|
83
|
+
export type SessionViewThinkingRow = z.infer<typeof sessionViewThinkingRowSchema>;
|
|
84
|
+
export type SessionViewToolCallRow = z.infer<typeof sessionViewToolCallRowSchema>;
|
|
85
|
+
export type SessionViewToolResultRow = z.infer<typeof sessionViewToolResultRowSchema>;
|
|
86
|
+
export type SessionViewLifecycleRow = z.infer<typeof sessionViewLifecycleRowSchema>;
|
|
87
|
+
export type SessionViewRawRow = z.infer<typeof sessionViewRawRowSchema>;
|
|
88
|
+
export type SessionViewRow = z.infer<typeof sessionViewRowSchema>;
|
|
89
|
+
export type SessionViewDto = z.infer<typeof sessionViewSchema>;
|