@shipfox/api-logs 9.3.0 → 10.1.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 (52) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +35 -0
  3. package/dist/core/append-logs.d.ts.map +1 -1
  4. package/dist/core/append-logs.js +100 -12
  5. package/dist/core/append-logs.js.map +1 -1
  6. package/dist/core/close-stream.d.ts +6 -5
  7. package/dist/core/close-stream.d.ts.map +1 -1
  8. package/dist/core/close-stream.js +25 -7
  9. package/dist/core/close-stream.js.map +1 -1
  10. package/dist/core/entities/attempt-stream.d.ts +5 -0
  11. package/dist/core/entities/attempt-stream.d.ts.map +1 -1
  12. package/dist/core/entities/attempt-stream.js +1 -1
  13. package/dist/core/entities/attempt-stream.js.map +1 -1
  14. package/dist/core/session/claude/rows.d.ts +4 -2
  15. package/dist/core/session/claude/rows.d.ts.map +1 -1
  16. package/dist/core/session/claude/rows.js +137 -10
  17. package/dist/core/session/claude/rows.js.map +1 -1
  18. package/dist/core/session/claude-parser.d.ts +8 -1
  19. package/dist/core/session/claude-parser.d.ts.map +1 -1
  20. package/dist/core/session/claude-parser.js +30 -4
  21. package/dist/core/session/claude-parser.js.map +1 -1
  22. package/dist/core/session/parse-session.d.ts +6 -1
  23. package/dist/core/session/parse-session.d.ts.map +1 -1
  24. package/dist/core/session/parse-session.js +2 -2
  25. package/dist/core/session/parse-session.js.map +1 -1
  26. package/dist/db/db.d.ts +94 -0
  27. package/dist/db/db.d.ts.map +1 -1
  28. package/dist/db/schema/attempt-streams.d.ts +94 -0
  29. package/dist/db/schema/attempt-streams.d.ts.map +1 -1
  30. package/dist/db/schema/attempt-streams.js +9 -1
  31. package/dist/db/schema/attempt-streams.js.map +1 -1
  32. package/dist/db/streams.d.ts +17 -6
  33. package/dist/db/streams.d.ts.map +1 -1
  34. package/dist/db/streams.js +21 -6
  35. package/dist/db/streams.js.map +1 -1
  36. package/dist/tsconfig.test.tsbuildinfo +1 -1
  37. package/drizzle/0001_strong_major_mapleleaf.sql +4 -0
  38. package/drizzle/meta/0001_snapshot.json +555 -0
  39. package/drizzle/meta/_journal.json +7 -0
  40. package/package.json +5 -5
  41. package/src/core/append-logs.test.ts +144 -4
  42. package/src/core/append-logs.ts +158 -6
  43. package/src/core/close-stream.ts +26 -7
  44. package/src/core/entities/attempt-stream.ts +6 -0
  45. package/src/core/finalize-attempt-stream.test.ts +57 -0
  46. package/src/core/session/claude/rows.ts +176 -7
  47. package/src/core/session/claude-parser.test.ts +279 -2
  48. package/src/core/session/claude-parser.ts +55 -4
  49. package/src/core/session/parse-session.ts +10 -1
  50. package/src/db/schema/attempt-streams.ts +10 -0
  51. package/src/db/streams.ts +43 -7
  52. package/tsconfig.build.tsbuildinfo +1 -1
@@ -5,6 +5,7 @@ import type {
5
5
  SessionViewToolCallRow,
6
6
  SessionViewToolResultRow,
7
7
  } from '@shipfox/api-logs-dto';
8
+ import type {ClaudeParseContext} from '../claude-parser.js';
8
9
  import {asLooseObject} from '../entry-schema.js';
9
10
  import {
10
11
  booleanField,
@@ -12,20 +13,132 @@ import {
12
13
  formatNumber,
13
14
  isMeta,
14
15
  metaItem,
16
+ numberField,
15
17
  stringField,
16
18
  stringifyValue,
17
19
  toJson,
18
20
  } from '../object.js';
19
21
  import {lifecycleRow, messageRow, thinkingRow} from '../rows.js';
20
22
 
23
+ export const PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES = new Set<string>([
24
+ 'thinking_tokens',
25
+ 'status',
26
+ 'session_state_changed',
27
+ 'task_progress',
28
+ 'hook_progress',
29
+ 'hook_started',
30
+ 'commands_changed',
31
+ 'files_persisted',
32
+ 'memory_recall',
33
+ 'local_command_output',
34
+ 'plugin_install',
35
+ ]);
36
+ const OUTPUT_REPROMPT_PREFIX = 'The previous turn ended without setting required workflow outputs:';
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
+ 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
+ model_refusal_fallback: {
95
+ label: 'Model refused, fell back',
96
+ tone: 'warning',
97
+ },
98
+ model_refusal_no_fallback: {
99
+ label: 'Model refused',
100
+ tone: 'error',
101
+ },
102
+ mirror_error: {
103
+ label: 'Session mirror failed',
104
+ tone: 'error',
105
+ detail: (message) => stringField(message, 'error') ?? null,
106
+ },
107
+ worker_shutting_down: {
108
+ label: 'Worker shutting down',
109
+ tone: 'warning',
110
+ },
111
+ elicitation_complete: {
112
+ label: 'Elicitation complete',
113
+ tone: 'default',
114
+ },
115
+ notification: {
116
+ label: 'Notification',
117
+ tone: 'default',
118
+ },
119
+ task_started: {
120
+ label: 'Task started',
121
+ tone: 'default',
122
+ },
123
+ task_updated: {
124
+ label: 'Task updated',
125
+ tone: 'default',
126
+ },
127
+ task_notification: {
128
+ label: 'Task notification',
129
+ tone: 'default',
130
+ },
131
+ };
132
+
21
133
  export function systemRow(
22
134
  timestamp: number,
23
135
  message: Record<string, unknown>,
136
+ context: ClaudeParseContext,
24
137
  ): SessionViewLifecycleRow {
25
138
  const subtype = stringField(message, 'subtype');
26
- const label = subtype === 'init' || message.type === 'init' ? 'Session started' : 'Session event';
27
- const detail = stringField(message, 'session_id') ?? stringField(message, 'sessionId') ?? null;
28
- const meta = [
139
+ const sessionId = stringField(message, 'session_id') ?? stringField(message, 'sessionId');
140
+ const isInit = subtype === 'init' || message.type === 'init';
141
+ const baseMeta = [
29
142
  metaItem('cwd', stringField(message, 'cwd'), false),
30
143
  metaItem('model', stringField(message, 'model')),
31
144
  metaItem(
@@ -34,7 +147,44 @@ export function systemRow(
34
147
  ),
35
148
  ].filter(isMeta);
36
149
 
37
- return lifecycleRow(timestamp, label, detail, 'default', false, meta);
150
+ if (!isInit) {
151
+ const mapping = subtype === undefined ? undefined : SYSTEM_EVENT_MAPPINGS[subtype];
152
+ const label =
153
+ mapping === undefined
154
+ ? `Session event (${subtype ?? 'unknown'})`
155
+ : typeof mapping.label === 'function'
156
+ ? mapping.label(message)
157
+ : mapping.label;
158
+ const tone =
159
+ mapping === undefined
160
+ ? 'default'
161
+ : typeof mapping.tone === 'function'
162
+ ? mapping.tone(message)
163
+ : mapping.tone;
164
+ const detail = mapping?.detail?.(message) ?? null;
165
+ const meta = [...baseMeta, ...(mapping?.meta?.(message) ?? [])];
166
+
167
+ return lifecycleRow(timestamp, label, detail, tone, false, meta);
168
+ }
169
+
170
+ const isNewSession =
171
+ !context.hasInit || sessionId === undefined || sessionId !== context.sessionId;
172
+ context.hasInit = true;
173
+ context.sessionId = sessionId ?? null;
174
+ context.turn = isNewSession ? 1 : context.turn + 1;
175
+
176
+ const label = isNewSession ? 'Session started' : `Turn ${context.turn} started`;
177
+ const meta = [
178
+ metaItem('session', isNewSession ? sessionId : null),
179
+ metaItem('turn', isNewSession ? null : String(context.turn)),
180
+ ...baseMeta,
181
+ ].filter(isMeta);
182
+
183
+ return lifecycleRow(timestamp, label, null, 'default', false, meta);
184
+ }
185
+
186
+ function titleCase(value: string): string {
187
+ return value.length === 0 ? value : `${value[0]?.toUpperCase()}${value.slice(1)}`;
38
188
  }
39
189
 
40
190
  export function assistantRows(
@@ -92,11 +242,12 @@ export function userRows(
92
242
  message: Record<string, unknown>,
93
243
  ): readonly SessionViewRow[] {
94
244
  const sdkMessage = asLooseObject(message.message) ?? message;
245
+ const role = isPlatformMessage(sdkMessage) ? 'platform' : 'user';
95
246
  const rows: SessionViewRow[] = [];
96
247
  const textParts: string[] = [];
97
248
  const pushText = () => {
98
249
  if (textParts.length === 0) return;
99
- rows.push(messageRow(timestamp, 'user', 'user', textParts.join('\n\n'), false));
250
+ rows.push(messageRow(timestamp, role, role, textParts.join('\n\n'), false));
100
251
  textParts.length = 0;
101
252
  };
102
253
 
@@ -117,12 +268,14 @@ export function userRows(
117
268
  if (rows.length > 0) return rows;
118
269
 
119
270
  const content = stringField(sdkMessage, 'content');
120
- return [messageRow(timestamp, 'user', 'user', content ?? toJson(message), false)];
271
+ return [messageRow(timestamp, role, role, content ?? toJson(message), false)];
121
272
  }
122
273
 
123
274
  export function resultRow(
124
275
  timestamp: number,
125
276
  message: Record<string, unknown>,
277
+ turn: number,
278
+ isFinalResult: boolean,
126
279
  ): SessionViewLifecycleRow {
127
280
  const isError = booleanField(message, 'is_error') || booleanField(message, 'isError');
128
281
  const subtype = stringField(message, 'subtype');
@@ -133,6 +286,7 @@ export function resultRow(
133
286
  stringField(message, 'message') ??
134
287
  null;
135
288
  const meta = [
289
+ metaItem('turn', !terminalFailure && !isFinalResult && turn > 0 ? String(turn) : null),
136
290
  numberMeta(message, 'duration_ms', 'duration', 'ms'),
137
291
  numberMeta(message, 'duration_api_ms', 'api duration', 'ms'),
138
292
  numberMeta(message, 'num_turns', 'turns'),
@@ -141,7 +295,11 @@ export function resultRow(
141
295
 
142
296
  return lifecycleRow(
143
297
  timestamp,
144
- terminalFailure ? 'Session failed' : 'Session completed',
298
+ terminalFailure
299
+ ? 'Session failed'
300
+ : isFinalResult || turn === 0
301
+ ? 'Session completed'
302
+ : `Turn ${turn} completed`,
145
303
  detail,
146
304
  terminalFailure ? 'error' : 'default',
147
305
  terminalFailure,
@@ -149,6 +307,17 @@ export function resultRow(
149
307
  );
150
308
  }
151
309
 
310
+ function isPlatformMessage(message: Record<string, unknown>): boolean {
311
+ const content = field(message, 'content');
312
+ if (typeof content === 'string') return content.startsWith(OUTPUT_REPROMPT_PREFIX);
313
+ if (!Array.isArray(content)) return false;
314
+
315
+ return content.some((block) => {
316
+ const object = asLooseObject(block);
317
+ return stringField(object, 'text')?.startsWith(OUTPUT_REPROMPT_PREFIX) === true;
318
+ });
319
+ }
320
+
152
321
  function contentBlocks(message: Record<string, unknown>): Record<string, unknown>[] {
153
322
  const content = message.content;
154
323
  if (!Array.isArray(content)) return [];
@@ -1,9 +1,152 @@
1
- import {parseClaudeSessionRecord} from './claude-parser.js';
1
+ import {PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES} from './claude/rows.js';
2
+ import {createClaudeParseContext, parseClaudeSessionRecord} from './claude-parser.js';
2
3
 
3
4
  const record = (data: unknown, ts = 1) => ({
4
5
  ts,
5
6
  data: typeof data === 'string' ? data : JSON.stringify(data),
6
7
  });
8
+ const meta = (label: string, value: string, inline?: boolean) =>
9
+ inline == null ? {label, value} : {label, value, inline};
10
+
11
+ const namedSystemEvents = [
12
+ {
13
+ subtype: 'permission_denied',
14
+ data: {tool_name: 'mcp__shipfox_outputs__set_output', decision_reason: 'Permission mode'},
15
+ expected: {
16
+ label: 'Permission denied',
17
+ detail: 'mcp__shipfox_outputs__set_output',
18
+ meta: [meta('reason', 'Permission mode')],
19
+ tone: 'error',
20
+ },
21
+ },
22
+ {
23
+ subtype: 'api_retry',
24
+ data: {attempt: 2, max_retries: 3, error_status: 429, retry_delay_ms: 1_500},
25
+ expected: {
26
+ label: 'API retry',
27
+ detail: 'attempt 2/3',
28
+ meta: [meta('status', '429'), meta('retry delay', '1,500 ms')],
29
+ tone: 'warning',
30
+ },
31
+ },
32
+ {
33
+ subtype: 'informational',
34
+ data: {level: 'warning', content: 'The hook blocked continuation.'},
35
+ expected: {
36
+ label: 'Warning',
37
+ detail: 'The hook blocked continuation.',
38
+ meta: [],
39
+ tone: 'warning',
40
+ },
41
+ },
42
+ {
43
+ subtype: 'compact_boundary',
44
+ data: {
45
+ compact_metadata: {trigger: 'auto', pre_tokens: 12_000, post_tokens: 4_000, duration_ms: 250},
46
+ },
47
+ expected: {
48
+ label: 'Context compacted',
49
+ detail: null,
50
+ meta: [
51
+ meta('trigger', 'auto'),
52
+ meta('tokens before', '12K tokens'),
53
+ meta('tokens after', '4,000 tokens'),
54
+ meta('duration', '250 ms'),
55
+ ],
56
+ tone: 'default',
57
+ },
58
+ },
59
+ {
60
+ subtype: 'model_refusal_fallback',
61
+ data: {},
62
+ expected: {
63
+ label: 'Model refused, fell back',
64
+ detail: null,
65
+ meta: [],
66
+ tone: 'warning',
67
+ },
68
+ },
69
+ {
70
+ subtype: 'model_refusal_no_fallback',
71
+ data: {},
72
+ expected: {
73
+ label: 'Model refused',
74
+ detail: null,
75
+ meta: [],
76
+ tone: 'error',
77
+ },
78
+ },
79
+ {
80
+ subtype: 'mirror_error',
81
+ data: {error: 'Session store unavailable'},
82
+ expected: {
83
+ label: 'Session mirror failed',
84
+ detail: 'Session store unavailable',
85
+ meta: [],
86
+ tone: 'error',
87
+ },
88
+ },
89
+ {
90
+ subtype: 'worker_shutting_down',
91
+ data: {},
92
+ expected: {
93
+ label: 'Worker shutting down',
94
+ detail: null,
95
+ meta: [],
96
+ tone: 'warning',
97
+ },
98
+ },
99
+ {
100
+ subtype: 'elicitation_complete',
101
+ data: {},
102
+ expected: {
103
+ label: 'Elicitation complete',
104
+ detail: null,
105
+ meta: [],
106
+ tone: 'default',
107
+ },
108
+ },
109
+ {
110
+ subtype: 'notification',
111
+ data: {},
112
+ expected: {
113
+ label: 'Notification',
114
+ detail: null,
115
+ meta: [],
116
+ tone: 'default',
117
+ },
118
+ },
119
+ {
120
+ subtype: 'task_started',
121
+ data: {},
122
+ expected: {
123
+ label: 'Task started',
124
+ detail: null,
125
+ meta: [],
126
+ tone: 'default',
127
+ },
128
+ },
129
+ {
130
+ subtype: 'task_updated',
131
+ data: {},
132
+ expected: {
133
+ label: 'Task updated',
134
+ detail: null,
135
+ meta: [],
136
+ tone: 'default',
137
+ },
138
+ },
139
+ {
140
+ subtype: 'task_notification',
141
+ data: {},
142
+ expected: {
143
+ label: 'Task notification',
144
+ detail: null,
145
+ meta: [],
146
+ tone: 'default',
147
+ },
148
+ },
149
+ ] as const;
7
150
 
8
151
  describe('parseClaudeSessionRecord', () => {
9
152
  it('returns a raw row for malformed JSON', () => {
@@ -43,8 +186,9 @@ describe('parseClaudeSessionRecord', () => {
43
186
  kind: 'lifecycle',
44
187
  timestamp: 1,
45
188
  label: 'Session started',
46
- detail: 'session-1',
189
+ detail: null,
47
190
  meta: [
191
+ {label: 'session', value: 'session-1'},
48
192
  {label: 'cwd', value: '/workspace', inline: false},
49
193
  {label: 'model', value: 'claude-opus-4-8'},
50
194
  ],
@@ -54,6 +198,139 @@ describe('parseClaudeSessionRecord', () => {
54
198
  ]);
55
199
  });
56
200
 
201
+ it.each([
202
+ ...PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES,
203
+ ])('drops pure-progress system subtype %s', (subtype) => {
204
+ const rows = parseClaudeSessionRecord(record({type: 'system', subtype}));
205
+
206
+ expect(rows).toEqual([]);
207
+ });
208
+
209
+ it.each(namedSystemEvents)('names and tones $subtype system events', ({
210
+ subtype,
211
+ data,
212
+ expected,
213
+ }) => {
214
+ const rows = parseClaudeSessionRecord(record({type: 'system', subtype, ...data}));
215
+
216
+ expect(rows).toEqual([
217
+ {
218
+ kind: 'lifecycle',
219
+ timestamp: 1,
220
+ ...expected,
221
+ terminalFailure: false,
222
+ },
223
+ ]);
224
+ });
225
+
226
+ it('reports repeated init and result records as turns within one session', () => {
227
+ const context = createClaudeParseContext();
228
+ const rows = [
229
+ ...parseClaudeSessionRecord(
230
+ record({type: 'system', subtype: 'init', session_id: 'session-1'}),
231
+ context,
232
+ ),
233
+ ...parseClaudeSessionRecord(
234
+ record({type: 'result', subtype: 'success', result: 'first response'}),
235
+ context,
236
+ false,
237
+ ),
238
+ ...parseClaudeSessionRecord(
239
+ record({type: 'system', subtype: 'init', session_id: 'session-1'}),
240
+ context,
241
+ ),
242
+ ...parseClaudeSessionRecord(
243
+ record({type: 'result', subtype: 'success', result: 'final response'}),
244
+ context,
245
+ ),
246
+ ];
247
+
248
+ expect(rows).toEqual([
249
+ {
250
+ kind: 'lifecycle',
251
+ timestamp: 1,
252
+ label: 'Session started',
253
+ detail: null,
254
+ meta: [meta('session', 'session-1')],
255
+ tone: 'default',
256
+ terminalFailure: false,
257
+ },
258
+ {
259
+ kind: 'lifecycle',
260
+ timestamp: 1,
261
+ label: 'Turn 1 completed',
262
+ detail: 'first response',
263
+ meta: [{label: 'turn', value: '1'}],
264
+ tone: 'default',
265
+ terminalFailure: false,
266
+ },
267
+ {
268
+ kind: 'lifecycle',
269
+ timestamp: 1,
270
+ label: 'Turn 2 started',
271
+ detail: null,
272
+ meta: [{label: 'turn', value: '2'}],
273
+ tone: 'default',
274
+ terminalFailure: false,
275
+ },
276
+ {
277
+ kind: 'lifecycle',
278
+ timestamp: 1,
279
+ label: 'Session completed',
280
+ detail: 'final response',
281
+ meta: [],
282
+ tone: 'default',
283
+ terminalFailure: false,
284
+ },
285
+ ]);
286
+ });
287
+
288
+ it('qualifies unmapped system events instead of using a bare generic label', () => {
289
+ const rows = parseClaudeSessionRecord(
290
+ record({type: 'system', subtype: 'custom_event', session_id: 'session-1'}),
291
+ );
292
+
293
+ expect(rows).toEqual([
294
+ {
295
+ kind: 'lifecycle',
296
+ timestamp: 1,
297
+ label: 'Session event (custom_event)',
298
+ detail: null,
299
+ meta: [],
300
+ tone: 'default',
301
+ terminalFailure: false,
302
+ },
303
+ ]);
304
+ });
305
+
306
+ it('labels workflow-output re-prompts as platform messages', () => {
307
+ const rows = parseClaudeSessionRecord(
308
+ record({
309
+ type: 'user',
310
+ message: {
311
+ role: 'user',
312
+ content:
313
+ 'The previous turn ended without setting required workflow outputs: answer. ' +
314
+ 'Call set_output for each missing key, then provide your final response.',
315
+ },
316
+ }),
317
+ );
318
+
319
+ expect(rows).toEqual([
320
+ {
321
+ kind: 'message',
322
+ timestamp: 1,
323
+ role: 'platform',
324
+ label: 'platform',
325
+ meta: [],
326
+ text:
327
+ 'The previous turn ended without setting required workflow outputs: answer. ' +
328
+ 'Call set_output for each missing key, then provide your final response.',
329
+ terminalFailure: false,
330
+ },
331
+ ]);
332
+ });
333
+
57
334
  it('expands assistant text, thinking, and tool-use blocks in order', () => {
58
335
  const rows = parseClaudeSessionRecord(
59
336
  record({
@@ -1,6 +1,13 @@
1
1
  import type {SessionViewRow} from '@shipfox/api-logs-dto';
2
2
  import {z} from 'zod';
3
- import {assistantRows, resultRow, systemRow, userRows} from './claude/rows.js';
3
+ import {
4
+ assistantRows,
5
+ PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES,
6
+ resultRow,
7
+ systemRow,
8
+ userRows,
9
+ } from './claude/rows.js';
10
+ import {stringField} from './object.js';
4
11
  import {rawRecordRow} from './rows.js';
5
12
  import type {AgentSessionRecord} from './session-record.js';
6
13
 
@@ -10,7 +17,43 @@ const claudeMessageSchema = z
10
17
  })
11
18
  .catchall(z.unknown());
12
19
 
13
- export function parseClaudeSessionRecord(record: AgentSessionRecord): readonly SessionViewRow[] {
20
+ export interface ClaudeParseContext {
21
+ hasInit: boolean;
22
+ sessionId: string | null;
23
+ turn: number;
24
+ }
25
+
26
+ export function createClaudeParseContext(): ClaudeParseContext {
27
+ return {hasInit: false, sessionId: null, turn: 0};
28
+ }
29
+
30
+ export function claudeInitSessionId(record: AgentSessionRecord): string | undefined {
31
+ let json: unknown;
32
+ try {
33
+ json = JSON.parse(record.data);
34
+ } catch {
35
+ return undefined;
36
+ }
37
+
38
+ const parsed = claudeMessageSchema.safeParse(json);
39
+ if (!parsed.success) return undefined;
40
+
41
+ const message = parsed.data;
42
+ if (
43
+ message.type !== 'init' &&
44
+ !(message.type === 'system' && stringField(message, 'subtype') === 'init')
45
+ ) {
46
+ return undefined;
47
+ }
48
+
49
+ return stringField(message, 'session_id') ?? stringField(message, 'sessionId');
50
+ }
51
+
52
+ export function parseClaudeSessionRecord(
53
+ record: AgentSessionRecord,
54
+ context: ClaudeParseContext = createClaudeParseContext(),
55
+ isFinalResult = true,
56
+ ): readonly SessionViewRow[] {
14
57
  let json: unknown;
15
58
  try {
16
59
  json = JSON.parse(record.data);
@@ -22,16 +65,24 @@ export function parseClaudeSessionRecord(record: AgentSessionRecord): readonly S
22
65
  if (!parsed.success) return [rawRecordRow(record, 'Unsupported Claude message')];
23
66
 
24
67
  const message = parsed.data;
68
+ if (
69
+ message.type === 'system' &&
70
+ typeof message.subtype === 'string' &&
71
+ PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES.has(message.subtype)
72
+ ) {
73
+ return [];
74
+ }
75
+
25
76
  switch (message.type) {
26
77
  case 'system':
27
78
  case 'init':
28
- return [systemRow(record.ts, message)];
79
+ return [systemRow(record.ts, message, context)];
29
80
  case 'assistant':
30
81
  return assistantRows(record.ts, message);
31
82
  case 'user':
32
83
  return userRows(record.ts, message);
33
84
  case 'result':
34
- return [resultRow(record.ts, message)];
85
+ return [resultRow(record.ts, message, context.turn, isFinalResult)];
35
86
  default:
36
87
  return [rawRecordRow(record, `Unknown Claude message: ${message.type}`)];
37
88
  }
@@ -1,15 +1,24 @@
1
1
  import type {SessionViewRow} from '@shipfox/api-logs-dto';
2
2
  import type {Harness} from '@shipfox/workflow-document';
3
+ import type {ClaudeParseContext} from './claude-parser.js';
3
4
  import {parseClaudeSessionRecord} from './claude-parser.js';
4
5
  import {parsePiSessionRecord} from './pi-parser.js';
5
6
  import type {AgentSessionRecord} from './session-record.js';
6
7
 
8
+ export interface SessionParseContext {
9
+ claude?: ClaudeParseContext;
10
+ isFinalResult?: boolean;
11
+ }
12
+
7
13
  export function parseSessionRecord(
8
14
  record: AgentSessionRecord,
9
15
  harness: Harness,
16
+ context?: SessionParseContext,
10
17
  ): readonly SessionViewRow[] {
11
18
  try {
12
- return harness === 'claude' ? parseClaudeSessionRecord(record) : parsePiSessionRecord(record);
19
+ return harness === 'claude'
20
+ ? parseClaudeSessionRecord(record, context?.claude, context?.isFinalResult ?? true)
21
+ : parsePiSessionRecord(record);
13
22
  } catch {
14
23
  return [
15
24
  {
@@ -1,3 +1,4 @@
1
+ import type {SessionViewLifecycleRow} from '@shipfox/api-logs-dto';
1
2
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
3
  import {sql} from 'drizzle-orm';
3
4
  import {
@@ -5,6 +6,7 @@ import {
5
6
  boolean,
6
7
  index,
7
8
  integer,
9
+ jsonb,
8
10
  text,
9
11
  timestamp,
10
12
  uniqueIndex,
@@ -49,6 +51,10 @@ export const attemptStreams = pgTable(
49
51
  .default('open'),
50
52
  closeReason: text('close_reason', {enum: ['declared', 'timeout']}),
51
53
  declaredTotalBytes: bigint('declared_total_bytes', {mode: 'number'}),
54
+ claudeHasInit: boolean('claude_has_init').notNull().default(false),
55
+ claudeSessionId: text('claude_session_id'),
56
+ claudeTurn: integer('claude_turn').notNull().default(0),
57
+ claudePendingResult: jsonb('claude_pending_result').$type<SessionViewLifecycleRow>(),
52
58
  truncated: boolean('truncated').notNull().default(false),
53
59
  objectKey: text('object_key'),
54
60
  createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
@@ -97,6 +103,10 @@ export function toAttemptStream(row: AttemptStreamDb): AttemptStream {
97
103
  state: row.state,
98
104
  closeReason: row.closeReason,
99
105
  declaredTotalBytes: row.declaredTotalBytes,
106
+ claudeHasInit: row.claudeHasInit,
107
+ claudeSessionId: row.claudeSessionId,
108
+ claudeTurn: row.claudeTurn,
109
+ claudePendingResult: row.claudePendingResult ?? null,
100
110
  truncated: row.truncated,
101
111
  objectKey: row.objectKey,
102
112
  createdAt: row.createdAt,