@shipfox/api-logs 10.1.0 → 10.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.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +13 -0
  3. package/dist/core/append-logs.d.ts.map +1 -1
  4. package/dist/core/append-logs.js +13 -5
  5. package/dist/core/append-logs.js.map +1 -1
  6. package/dist/core/close-stream.d.ts.map +1 -1
  7. package/dist/core/close-stream.js +16 -1
  8. package/dist/core/close-stream.js.map +1 -1
  9. package/dist/core/entities/attempt-stream.d.ts +2 -1
  10. package/dist/core/entities/attempt-stream.d.ts.map +1 -1
  11. package/dist/core/entities/attempt-stream.js.map +1 -1
  12. package/dist/core/session/claude/rows.d.ts +15 -3
  13. package/dist/core/session/claude/rows.d.ts.map +1 -1
  14. package/dist/core/session/claude/rows.js +127 -13
  15. package/dist/core/session/claude/rows.js.map +1 -1
  16. package/dist/core/session/claude-parser.d.ts +4 -2
  17. package/dist/core/session/claude-parser.d.ts.map +1 -1
  18. package/dist/core/session/claude-parser.js +56 -10
  19. package/dist/core/session/claude-parser.js.map +1 -1
  20. package/dist/db/db.d.ts +113 -0
  21. package/dist/db/db.d.ts.map +1 -1
  22. package/dist/db/schema/attempt-streams.d.ts +113 -0
  23. package/dist/db/schema/attempt-streams.d.ts.map +1 -1
  24. package/dist/db/schema/attempt-streams.js +2 -0
  25. package/dist/db/schema/attempt-streams.js.map +1 -1
  26. package/dist/db/streams.d.ts +3 -1
  27. package/dist/db/streams.d.ts.map +1 -1
  28. package/dist/db/streams.js +6 -1
  29. package/dist/db/streams.js.map +1 -1
  30. package/dist/tsconfig.test.tsbuildinfo +1 -1
  31. package/drizzle/0002_wise_dorian_gray.sql +1 -0
  32. package/drizzle/meta/0002_snapshot.json +561 -0
  33. package/drizzle/meta/_journal.json +7 -0
  34. package/package.json +4 -4
  35. package/src/core/append-logs.test.ts +57 -0
  36. package/src/core/append-logs.ts +15 -3
  37. package/src/core/close-stream.ts +17 -1
  38. package/src/core/entities/attempt-stream.ts +2 -1
  39. package/src/core/session/claude/rows.ts +172 -9
  40. package/src/core/session/claude-parser.test.ts +453 -4
  41. package/src/core/session/claude-parser.ts +59 -10
  42. package/src/db/schema/attempt-streams.ts +3 -1
  43. package/src/db/streams.ts +10 -2
  44. package/test/fixtures/claude-session/agent-step.jsonl +12 -0
  45. package/test/fixtures/claude-session/sdk-system-subtypes.json +32 -0
  46. package/test/fixtures/claude-session.ts +45 -0
  47. package/tsconfig.build.tsbuildinfo +1 -1
@@ -30,6 +30,7 @@ import {
30
30
  import {allowedBudget} from './budget.js';
31
31
  import {closeStream, controlTombstone} from './close-stream.js';
32
32
  import {MalformedLogChunkError, OffsetGapError} from './errors.js';
33
+ import {flushPendingToolRows} from './session/claude/rows.js';
33
34
  import {
34
35
  type ClaudeParseContext,
35
36
  claudeInitSessionId,
@@ -180,6 +181,7 @@ interface StoredBody {
180
181
  recordCounts: Partial<Record<LogRecord['type'], number>>;
181
182
  claudeParseContext: ClaudeParseContext | undefined;
182
183
  claudePendingResult: SessionViewLifecycleRow | null | undefined;
184
+ claudePendingToolRows: readonly SessionViewRow[] | undefined;
183
185
  }
184
186
 
185
187
  /**
@@ -303,6 +305,12 @@ function buildStoredBody(
303
305
  }
304
306
  }
305
307
 
308
+ if (isStreamFinal && parseContext?.claude !== undefined) {
309
+ for (const row of flushPendingToolRows(parseContext.claude)) {
310
+ storedRecords.push(storedAgentSessionRow(row));
311
+ }
312
+ }
313
+
306
314
  const body = Buffer.from(storedRecords.map((record) => `${JSON.stringify(record)}\n`).join(''));
307
315
  const recordCounts: Partial<Record<LogRecord['type'], number>> = {};
308
316
  for (const record of storedRecords) {
@@ -314,6 +322,7 @@ function buildStoredBody(
314
322
  recordCounts,
315
323
  claudeParseContext: parseContext?.claude,
316
324
  claudePendingResult: parseContext?.claude === undefined ? undefined : pendingResult,
325
+ claudePendingToolRows: parseContext?.claude?.pendingToolRows,
317
326
  };
318
327
  }
319
328
 
@@ -451,18 +460,20 @@ export async function appendLogs(
451
460
  }
452
461
 
453
462
  const parseHarness =
454
- sessionHarness === 'claude' || stream.claudePendingResult !== null
463
+ sessionHarness === 'claude' ||
464
+ stream.claudePendingResult !== null ||
465
+ stream.claudePendingToolRows.length > 0
455
466
  ? 'claude'
456
467
  : sessionHarness;
457
468
  const stored = buildStoredBody(
458
469
  parsed.records,
459
470
  parseHarness,
460
471
  parseHarness === 'claude'
461
- ? {
472
+ ? createClaudeParseContext(stream.claudePendingToolRows, {
462
473
  hasInit: stream.claudeHasInit,
463
474
  sessionId: stream.claudeSessionId,
464
475
  turn: stream.claudeTurn,
465
- }
476
+ })
466
477
  : undefined,
467
478
  parseHarness === 'claude' ? stream.claudePendingResult : undefined,
468
479
  declaredTotalBytes !== undefined,
@@ -488,6 +499,7 @@ export async function appendLogs(
488
499
  sessionId: stored.claudeParseContext.sessionId,
489
500
  turn: stored.claudeParseContext.turn,
490
501
  pendingResult: stored.claudePendingResult ?? null,
502
+ pendingToolRows: stored.claudePendingToolRows ?? [],
491
503
  });
492
504
  }
493
505
  }
@@ -47,7 +47,23 @@ export async function closeStream(
47
47
  });
48
48
  if (!closedResult) return null;
49
49
 
50
- const {stream: closed, pendingClaudeResult} = closedResult;
50
+ const {stream: closed, pendingClaudeResult, pendingClaudeToolRows} = closedResult;
51
+ if (pendingClaudeToolRows.length > 0) {
52
+ const data = Buffer.from(
53
+ `${pendingClaudeToolRows
54
+ .map((row) =>
55
+ JSON.stringify({v: 1, ts: row.timestamp, type: 'agent_session', row} satisfies LogRecord),
56
+ )
57
+ .join('\n')}\n`,
58
+ );
59
+ await insertChunk(tx, {
60
+ streamId: closed.id,
61
+ streamOffset: closed.committedLength,
62
+ byteLen: data.length,
63
+ data,
64
+ origin: 'control',
65
+ });
66
+ }
51
67
  if (pendingClaudeResult !== null) {
52
68
  const record: LogRecord = {
53
69
  v: 1,
@@ -1,4 +1,4 @@
1
- import type {SessionViewLifecycleRow} from '@shipfox/api-logs-dto';
1
+ import type {SessionViewLifecycleRow, SessionViewRow} from '@shipfox/api-logs-dto';
2
2
 
3
3
  /** Lifecycle state of an attempt's log stream. */
4
4
  export type StreamState = 'open' | 'closed';
@@ -35,6 +35,7 @@ export interface AttemptStream {
35
35
  claudeSessionId: string | null;
36
36
  claudeTurn: number;
37
37
  claudePendingResult: SessionViewLifecycleRow | null;
38
+ claudePendingToolRows: SessionViewRow[];
38
39
  truncated: boolean;
39
40
  objectKey: string | null;
40
41
  createdAt: Date;
@@ -44,7 +44,7 @@ type SystemEventMapping = {
44
44
  meta?: (message: Record<string, unknown>) => readonly SessionViewRowMeta[];
45
45
  };
46
46
 
47
- const SYSTEM_EVENT_MAPPINGS: Record<string, SystemEventMapping> = {
47
+ export const SYSTEM_EVENT_MAPPINGS: Record<string, SystemEventMapping> = {
48
48
  permission_denied: {
49
49
  label: 'Permission denied',
50
50
  tone: 'error',
@@ -91,6 +91,32 @@ const SYSTEM_EVENT_MAPPINGS: Record<string, SystemEventMapping> = {
91
91
  ].filter(isMeta);
92
92
  },
93
93
  },
94
+ hook_response: {
95
+ label: (message) => {
96
+ const outcome = stringField(message, 'outcome');
97
+ return outcome === 'error'
98
+ ? 'Hook failed'
99
+ : outcome === 'cancelled'
100
+ ? 'Hook cancelled'
101
+ : outcome === 'success'
102
+ ? 'Hook completed'
103
+ : 'Hook response';
104
+ },
105
+ tone: (message) => {
106
+ const outcome = stringField(message, 'outcome');
107
+ return outcome === 'error' ? 'error' : outcome === 'cancelled' ? 'warning' : 'default';
108
+ },
109
+ detail: (message) =>
110
+ stringField(message, 'output') ??
111
+ stringField(message, 'stderr') ??
112
+ stringField(message, 'stdout') ??
113
+ null,
114
+ meta: (message) =>
115
+ [
116
+ metaItem('hook', stringField(message, 'hook_name')),
117
+ numberMeta(message, 'exit_code', 'exit code'),
118
+ ].filter(isMeta),
119
+ },
94
120
  model_refusal_fallback: {
95
121
  label: 'Model refused, fell back',
96
122
  tone: 'warning',
@@ -169,6 +195,10 @@ export function systemRow(
169
195
 
170
196
  const isNewSession =
171
197
  !context.hasInit || sessionId === undefined || sessionId !== context.sessionId;
198
+ if (isNewSession) {
199
+ context.pendingToolRows.length = 0;
200
+ context.toolCallRows.clear();
201
+ }
172
202
  context.hasInit = true;
173
203
  context.sessionId = sessionId ?? null;
174
204
  context.turn = isNewSession ? 1 : context.turn + 1;
@@ -183,27 +213,137 @@ export function systemRow(
183
213
  return lifecycleRow(timestamp, label, null, 'default', false, meta);
184
214
  }
185
215
 
216
+ export function authStatusRow(
217
+ timestamp: number,
218
+ message: Record<string, unknown>,
219
+ ): SessionViewLifecycleRow {
220
+ const error = stringField(message, 'error');
221
+ const output = stringList(field(message, 'output')).join('\n');
222
+ const isAuthenticating = booleanField(message, 'isAuthenticating');
223
+
224
+ return lifecycleRow(
225
+ timestamp,
226
+ error
227
+ ? 'Authentication failed'
228
+ : isAuthenticating
229
+ ? 'Authenticating'
230
+ : 'Authentication complete',
231
+ (error ?? output) || null,
232
+ error ? 'error' : 'default',
233
+ false,
234
+ );
235
+ }
236
+
237
+ const RATE_LIMIT_STATUS_MAPPINGS: Record<
238
+ string,
239
+ {label: string; tone: SessionViewLifecycleRow['tone']}
240
+ > = {
241
+ rejected: {label: 'Rate limit exceeded', tone: 'error'},
242
+ allowed_warning: {label: 'Rate limit warning', tone: 'warning'},
243
+ allowed: {label: 'Rate limit available', tone: 'default'},
244
+ };
245
+
246
+ export function rateLimitRow(
247
+ timestamp: number,
248
+ message: Record<string, unknown>,
249
+ ): SessionViewLifecycleRow {
250
+ const rateLimitInfo = asLooseObject(field(message, 'rate_limit_info')) ?? {};
251
+ const status = stringField(rateLimitInfo, 'status');
252
+ const rateLimitType = stringField(rateLimitInfo, 'rateLimitType');
253
+ const utilization = numberField(rateLimitInfo, 'utilization');
254
+ const mapping = status === undefined ? undefined : RATE_LIMIT_STATUS_MAPPINGS[status];
255
+ const meta = [
256
+ metaItem('utilization', utilization == null ? null : `${formatNumber(utilization * 100)}%`),
257
+ mapping === undefined ? metaItem('status', status ?? null) : null,
258
+ ].filter(isMeta);
259
+
260
+ return lifecycleRow(
261
+ timestamp,
262
+ mapping?.label ?? 'Rate limit updated',
263
+ rateLimitType == null ? null : humanizeEnumValue(rateLimitType),
264
+ mapping?.tone ?? 'warning',
265
+ false,
266
+ meta,
267
+ );
268
+ }
269
+
270
+ export function toolUseSummary(
271
+ message: Record<string, unknown>,
272
+ context: ClaudeParseContext,
273
+ ): boolean {
274
+ const summary = stringField(message, 'summary');
275
+ if (summary === undefined) return false;
276
+
277
+ const precedingToolUseIds = stringList(field(message, 'preceding_tool_use_ids'));
278
+ const toolUseId = stringField(message, 'tool_use_id');
279
+ const candidateIds =
280
+ precedingToolUseIds.length > 0
281
+ ? precedingToolUseIds
282
+ : toolUseId === undefined
283
+ ? []
284
+ : [toolUseId];
285
+
286
+ for (const id of [...candidateIds].reverse()) {
287
+ const row = context.toolCallRows.get(id);
288
+ if (row === undefined) continue;
289
+
290
+ row.summary = row.summary === undefined ? summary : `${row.summary}\n\n${summary}`;
291
+ return true;
292
+ }
293
+
294
+ return false;
295
+ }
296
+
297
+ export function flushPendingToolRows(context: ClaudeParseContext): readonly SessionViewRow[] {
298
+ if (context.pendingToolRows.length === 0) return [];
299
+
300
+ const rows = context.pendingToolRows;
301
+ context.pendingToolRows = [];
302
+ context.toolCallRows.clear();
303
+ return rows;
304
+ }
305
+
186
306
  function titleCase(value: string): string {
187
307
  return value.length === 0 ? value : `${value[0]?.toUpperCase()}${value.slice(1)}`;
188
308
  }
189
309
 
310
+ function humanizeEnumValue(value: string): string {
311
+ return titleCase(value.replaceAll('_', ' '));
312
+ }
313
+
314
+ function stringList(value: unknown): string[] {
315
+ if (!Array.isArray(value)) return [];
316
+
317
+ return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
318
+ }
319
+
190
320
  export function assistantRows(
191
321
  timestamp: number,
192
322
  message: Record<string, unknown>,
323
+ context: ClaudeParseContext,
193
324
  ): readonly SessionViewRow[] {
194
325
  const sdkMessage = asLooseObject(message.message) ?? message;
195
326
  const rows: SessionViewRow[] = [];
196
327
  const textParts: string[] = [];
197
328
  const thinkingParts: string[] = [];
329
+ let queueRows = context.toolCallRows.size > 0;
330
+
331
+ const pushRow = (row: SessionViewRow) => {
332
+ if (queueRows) {
333
+ context.pendingToolRows.push(row);
334
+ } else {
335
+ rows.push(row);
336
+ }
337
+ };
198
338
 
199
339
  const pushText = () => {
200
340
  if (textParts.length === 0) return;
201
- rows.push(messageRow(timestamp, 'assistant', 'assistant', textParts.join('\n\n'), false));
341
+ pushRow(messageRow(timestamp, 'assistant', 'assistant', textParts.join('\n\n'), false));
202
342
  textParts.length = 0;
203
343
  };
204
344
  const pushThinking = () => {
205
345
  if (thinkingParts.length === 0) return;
206
- rows.push(thinkingRow(timestamp, thinkingParts.join('\n\n')));
346
+ pushRow(thinkingRow(timestamp, thinkingParts.join('\n\n')));
207
347
  thinkingParts.length = 0;
208
348
  };
209
349
 
@@ -212,7 +352,14 @@ export function assistantRows(
212
352
  if (type === 'tool_use' || type === 'tool-use' || type === 'toolCall') {
213
353
  pushText();
214
354
  pushThinking();
215
- rows.push(toolCallRow(timestamp, block));
355
+ const row = toolCallRow(timestamp, block);
356
+ if (row.id === null) {
357
+ pushRow(row);
358
+ } else {
359
+ queueRows = true;
360
+ context.pendingToolRows.push(row);
361
+ context.toolCallRows.set(row.id, row);
362
+ }
216
363
  continue;
217
364
  }
218
365
 
@@ -231,7 +378,7 @@ export function assistantRows(
231
378
  pushText();
232
379
  pushThinking();
233
380
 
234
- if (rows.length > 0) return rows;
381
+ if (rows.length > 0 || queueRows) return rows;
235
382
 
236
383
  const text = stringField(sdkMessage, 'content') ?? stringField(message, 'result');
237
384
  return [messageRow(timestamp, 'assistant', 'assistant', text ?? toJson(message), false)];
@@ -240,11 +387,14 @@ export function assistantRows(
240
387
  export function userRows(
241
388
  timestamp: number,
242
389
  message: Record<string, unknown>,
390
+ context: ClaudeParseContext,
243
391
  ): readonly SessionViewRow[] {
244
392
  const sdkMessage = asLooseObject(message.message) ?? message;
245
393
  const role = isPlatformMessage(sdkMessage) ? 'platform' : 'user';
246
394
  const rows: SessionViewRow[] = [];
247
395
  const textParts: string[] = [];
396
+ const hadPendingToolCall = context.toolCallRows.size > 0;
397
+ let matchedToolResult = false;
248
398
  const pushText = () => {
249
399
  if (textParts.length === 0) return;
250
400
  rows.push(messageRow(timestamp, role, role, textParts.join('\n\n'), false));
@@ -255,7 +405,11 @@ export function userRows(
255
405
  const type = stringField(block, 'type');
256
406
  if (type === 'tool_result' || type === 'tool-result') {
257
407
  pushText();
258
- rows.push(toolResultRow(timestamp, block));
408
+ const row = toolResultRow(timestamp, block);
409
+ if (row.toolCallId !== null && context.toolCallRows.has(row.toolCallId)) {
410
+ matchedToolResult = true;
411
+ }
412
+ rows.push(row);
259
413
  continue;
260
414
  }
261
415
 
@@ -265,10 +419,19 @@ export function userRows(
265
419
 
266
420
  pushText();
267
421
 
268
- if (rows.length > 0) return rows;
422
+ if (rows.length === 0) {
423
+ const content = stringField(sdkMessage, 'content');
424
+ rows.push(messageRow(timestamp, role, role, content ?? toJson(message), false));
425
+ }
269
426
 
270
- const content = stringField(sdkMessage, 'content');
271
- return [messageRow(timestamp, role, role, content ?? toJson(message), false)];
427
+ if (hadPendingToolCall) {
428
+ if (matchedToolResult) {
429
+ context.pendingToolRows.push(...rows);
430
+ return [];
431
+ }
432
+ return [...flushPendingToolRows(context), ...rows];
433
+ }
434
+ return rows;
272
435
  }
273
436
 
274
437
  export function resultRow(