@shipfox/api-logs 10.0.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 +23 -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 +221 -15
  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 +295 -13
  40. package/src/core/session/claude-parser.test.ts +620 -11
  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;
@@ -13,6 +13,7 @@ import {
13
13
  formatNumber,
14
14
  isMeta,
15
15
  metaItem,
16
+ numberField,
16
17
  stringField,
17
18
  stringifyValue,
18
19
  toJson,
@@ -34,6 +35,127 @@ export const PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES = new Set<string>([
34
35
  ]);
35
36
  const OUTPUT_REPROMPT_PREFIX = 'The previous turn ended without setting required workflow outputs:';
36
37
 
38
+ type SystemEventMapping = {
39
+ label: string | ((message: Record<string, unknown>) => string);
40
+ tone:
41
+ | SessionViewLifecycleRow['tone']
42
+ | ((message: Record<string, unknown>) => SessionViewLifecycleRow['tone']);
43
+ detail?: (message: Record<string, unknown>) => string | null;
44
+ meta?: (message: Record<string, unknown>) => readonly SessionViewRowMeta[];
45
+ };
46
+
47
+ export const SYSTEM_EVENT_MAPPINGS: Record<string, SystemEventMapping> = {
48
+ permission_denied: {
49
+ label: 'Permission denied',
50
+ tone: 'error',
51
+ detail: (message) => stringField(message, 'tool_name') ?? null,
52
+ meta: (message) =>
53
+ [
54
+ metaItem(
55
+ 'reason',
56
+ stringField(message, 'decision_reason') ?? stringField(message, 'message'),
57
+ ),
58
+ ].filter(isMeta),
59
+ },
60
+ api_retry: {
61
+ label: 'API retry',
62
+ tone: 'warning',
63
+ detail: (message) => {
64
+ const attempt = numberField(message, 'attempt');
65
+ const maxRetries = numberField(message, 'max_retries');
66
+ return attempt == null || maxRetries == null
67
+ ? null
68
+ : `attempt ${formatNumber(attempt)}/${formatNumber(maxRetries)}`;
69
+ },
70
+ meta: (message) =>
71
+ [
72
+ numberMeta(message, 'error_status', 'status'),
73
+ numberMeta(message, 'retry_delay_ms', 'retry delay', 'ms'),
74
+ ].filter(isMeta),
75
+ },
76
+ informational: {
77
+ label: (message) => titleCase(stringField(message, 'level') ?? 'informational'),
78
+ tone: (message) => (stringField(message, 'level') === 'warning' ? 'warning' : 'default'),
79
+ detail: (message) => stringField(message, 'content') ?? null,
80
+ },
81
+ compact_boundary: {
82
+ label: 'Context compacted',
83
+ tone: 'default',
84
+ meta: (message) => {
85
+ const compactMetadata = asLooseObject(field(message, 'compact_metadata')) ?? {};
86
+ return [
87
+ metaItem('trigger', stringField(compactMetadata, 'trigger')),
88
+ numberMeta(compactMetadata, 'pre_tokens', 'tokens before', 'tokens'),
89
+ numberMeta(compactMetadata, 'post_tokens', 'tokens after', 'tokens'),
90
+ numberMeta(compactMetadata, 'duration_ms', 'duration', 'ms'),
91
+ ].filter(isMeta);
92
+ },
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
+ },
120
+ model_refusal_fallback: {
121
+ label: 'Model refused, fell back',
122
+ tone: 'warning',
123
+ },
124
+ model_refusal_no_fallback: {
125
+ label: 'Model refused',
126
+ tone: 'error',
127
+ },
128
+ mirror_error: {
129
+ label: 'Session mirror failed',
130
+ tone: 'error',
131
+ detail: (message) => stringField(message, 'error') ?? null,
132
+ },
133
+ worker_shutting_down: {
134
+ label: 'Worker shutting down',
135
+ tone: 'warning',
136
+ },
137
+ elicitation_complete: {
138
+ label: 'Elicitation complete',
139
+ tone: 'default',
140
+ },
141
+ notification: {
142
+ label: 'Notification',
143
+ tone: 'default',
144
+ },
145
+ task_started: {
146
+ label: 'Task started',
147
+ tone: 'default',
148
+ },
149
+ task_updated: {
150
+ label: 'Task updated',
151
+ tone: 'default',
152
+ },
153
+ task_notification: {
154
+ label: 'Task notification',
155
+ tone: 'default',
156
+ },
157
+ };
158
+
37
159
  export function systemRow(
38
160
  timestamp: number,
39
161
  message: Record<string, unknown>,
@@ -51,40 +173,177 @@ export function systemRow(
51
173
  ),
52
174
  ].filter(isMeta);
53
175
 
54
- if (!isInit)
55
- return lifecycleRow(timestamp, 'Session event', sessionId ?? null, 'default', false, baseMeta);
176
+ if (!isInit) {
177
+ const mapping = subtype === undefined ? undefined : SYSTEM_EVENT_MAPPINGS[subtype];
178
+ const label =
179
+ mapping === undefined
180
+ ? `Session event (${subtype ?? 'unknown'})`
181
+ : typeof mapping.label === 'function'
182
+ ? mapping.label(message)
183
+ : mapping.label;
184
+ const tone =
185
+ mapping === undefined
186
+ ? 'default'
187
+ : typeof mapping.tone === 'function'
188
+ ? mapping.tone(message)
189
+ : mapping.tone;
190
+ const detail = mapping?.detail?.(message) ?? null;
191
+ const meta = [...baseMeta, ...(mapping?.meta?.(message) ?? [])];
192
+
193
+ return lifecycleRow(timestamp, label, detail, tone, false, meta);
194
+ }
56
195
 
57
196
  const isNewSession =
58
197
  !context.hasInit || sessionId === undefined || sessionId !== context.sessionId;
198
+ if (isNewSession) {
199
+ context.pendingToolRows.length = 0;
200
+ context.toolCallRows.clear();
201
+ }
59
202
  context.hasInit = true;
60
203
  context.sessionId = sessionId ?? null;
61
204
  context.turn = isNewSession ? 1 : context.turn + 1;
62
205
 
63
206
  const label = isNewSession ? 'Session started' : `Turn ${context.turn} started`;
64
- const meta = [metaItem('turn', isNewSession ? null : String(context.turn)), ...baseMeta].filter(
65
- isMeta,
207
+ const meta = [
208
+ metaItem('session', isNewSession ? sessionId : null),
209
+ metaItem('turn', isNewSession ? null : String(context.turn)),
210
+ ...baseMeta,
211
+ ].filter(isMeta);
212
+
213
+ return lifecycleRow(timestamp, label, null, 'default', false, meta);
214
+ }
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,
66
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
+ }
67
293
 
68
- return lifecycleRow(timestamp, label, sessionId ?? null, 'default', false, meta);
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
+
306
+ function titleCase(value: string): string {
307
+ return value.length === 0 ? value : `${value[0]?.toUpperCase()}${value.slice(1)}`;
308
+ }
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);
69
318
  }
70
319
 
71
320
  export function assistantRows(
72
321
  timestamp: number,
73
322
  message: Record<string, unknown>,
323
+ context: ClaudeParseContext,
74
324
  ): readonly SessionViewRow[] {
75
325
  const sdkMessage = asLooseObject(message.message) ?? message;
76
326
  const rows: SessionViewRow[] = [];
77
327
  const textParts: string[] = [];
78
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
+ };
79
338
 
80
339
  const pushText = () => {
81
340
  if (textParts.length === 0) return;
82
- rows.push(messageRow(timestamp, 'assistant', 'assistant', textParts.join('\n\n'), false));
341
+ pushRow(messageRow(timestamp, 'assistant', 'assistant', textParts.join('\n\n'), false));
83
342
  textParts.length = 0;
84
343
  };
85
344
  const pushThinking = () => {
86
345
  if (thinkingParts.length === 0) return;
87
- rows.push(thinkingRow(timestamp, thinkingParts.join('\n\n')));
346
+ pushRow(thinkingRow(timestamp, thinkingParts.join('\n\n')));
88
347
  thinkingParts.length = 0;
89
348
  };
90
349
 
@@ -93,7 +352,14 @@ export function assistantRows(
93
352
  if (type === 'tool_use' || type === 'tool-use' || type === 'toolCall') {
94
353
  pushText();
95
354
  pushThinking();
96
- 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
+ }
97
363
  continue;
98
364
  }
99
365
 
@@ -112,7 +378,7 @@ export function assistantRows(
112
378
  pushText();
113
379
  pushThinking();
114
380
 
115
- if (rows.length > 0) return rows;
381
+ if (rows.length > 0 || queueRows) return rows;
116
382
 
117
383
  const text = stringField(sdkMessage, 'content') ?? stringField(message, 'result');
118
384
  return [messageRow(timestamp, 'assistant', 'assistant', text ?? toJson(message), false)];
@@ -121,11 +387,14 @@ export function assistantRows(
121
387
  export function userRows(
122
388
  timestamp: number,
123
389
  message: Record<string, unknown>,
390
+ context: ClaudeParseContext,
124
391
  ): readonly SessionViewRow[] {
125
392
  const sdkMessage = asLooseObject(message.message) ?? message;
126
393
  const role = isPlatformMessage(sdkMessage) ? 'platform' : 'user';
127
394
  const rows: SessionViewRow[] = [];
128
395
  const textParts: string[] = [];
396
+ const hadPendingToolCall = context.toolCallRows.size > 0;
397
+ let matchedToolResult = false;
129
398
  const pushText = () => {
130
399
  if (textParts.length === 0) return;
131
400
  rows.push(messageRow(timestamp, role, role, textParts.join('\n\n'), false));
@@ -136,7 +405,11 @@ export function userRows(
136
405
  const type = stringField(block, 'type');
137
406
  if (type === 'tool_result' || type === 'tool-result') {
138
407
  pushText();
139
- 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);
140
413
  continue;
141
414
  }
142
415
 
@@ -146,10 +419,19 @@ export function userRows(
146
419
 
147
420
  pushText();
148
421
 
149
- 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
+ }
150
426
 
151
- const content = stringField(sdkMessage, 'content');
152
- 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;
153
435
  }
154
436
 
155
437
  export function resultRow(