@shipfox/api-logs 10.0.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-logs",
3
3
  "license": "MIT",
4
- "version": "10.0.0",
4
+ "version": "10.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -25,7 +25,7 @@
25
25
  "@temporalio/workflow": "1.18.1",
26
26
  "drizzle-orm": "^0.45.2",
27
27
  "zod": "^4.4.3",
28
- "@shipfox/api-auth-context": "10.0.0",
28
+ "@shipfox/api-auth-context": "10.1.0",
29
29
  "@shipfox/api-logs-dto": "9.0.2",
30
30
  "@shipfox/api-workflows-dto": "10.0.0",
31
31
  "@shipfox/workflow-document": "2.1.3",
@@ -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,101 @@ 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
+ 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
+
37
133
  export function systemRow(
38
134
  timestamp: number,
39
135
  message: Record<string, unknown>,
@@ -51,8 +147,25 @@ export function systemRow(
51
147
  ),
52
148
  ].filter(isMeta);
53
149
 
54
- if (!isInit)
55
- return lifecycleRow(timestamp, 'Session event', sessionId ?? null, 'default', false, baseMeta);
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
+ }
56
169
 
57
170
  const isNewSession =
58
171
  !context.hasInit || sessionId === undefined || sessionId !== context.sessionId;
@@ -61,11 +174,17 @@ export function systemRow(
61
174
  context.turn = isNewSession ? 1 : context.turn + 1;
62
175
 
63
176
  const label = isNewSession ? 'Session started' : `Turn ${context.turn} started`;
64
- const meta = [metaItem('turn', isNewSession ? null : String(context.turn)), ...baseMeta].filter(
65
- isMeta,
66
- );
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
+ }
67
185
 
68
- return lifecycleRow(timestamp, label, sessionId ?? null, 'default', false, meta);
186
+ function titleCase(value: string): string {
187
+ return value.length === 0 ? value : `${value[0]?.toUpperCase()}${value.slice(1)}`;
69
188
  }
70
189
 
71
190
  export function assistantRows(
@@ -5,6 +5,148 @@ const record = (data: unknown, ts = 1) => ({
5
5
  ts,
6
6
  data: typeof data === 'string' ? data : JSON.stringify(data),
7
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;
8
150
 
9
151
  describe('parseClaudeSessionRecord', () => {
10
152
  it('returns a raw row for malformed JSON', () => {
@@ -44,8 +186,9 @@ describe('parseClaudeSessionRecord', () => {
44
186
  kind: 'lifecycle',
45
187
  timestamp: 1,
46
188
  label: 'Session started',
47
- detail: 'session-1',
189
+ detail: null,
48
190
  meta: [
191
+ {label: 'session', value: 'session-1'},
49
192
  {label: 'cwd', value: '/workspace', inline: false},
50
193
  {label: 'model', value: 'claude-opus-4-8'},
51
194
  ],
@@ -63,6 +206,23 @@ describe('parseClaudeSessionRecord', () => {
63
206
  expect(rows).toEqual([]);
64
207
  });
65
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
+
66
226
  it('reports repeated init and result records as turns within one session', () => {
67
227
  const context = createClaudeParseContext();
68
228
  const rows = [
@@ -90,8 +250,8 @@ describe('parseClaudeSessionRecord', () => {
90
250
  kind: 'lifecycle',
91
251
  timestamp: 1,
92
252
  label: 'Session started',
93
- detail: 'session-1',
94
- meta: [],
253
+ detail: null,
254
+ meta: [meta('session', 'session-1')],
95
255
  tone: 'default',
96
256
  terminalFailure: false,
97
257
  },
@@ -108,7 +268,7 @@ describe('parseClaudeSessionRecord', () => {
108
268
  kind: 'lifecycle',
109
269
  timestamp: 1,
110
270
  label: 'Turn 2 started',
111
- detail: 'session-1',
271
+ detail: null,
112
272
  meta: [{label: 'turn', value: '2'}],
113
273
  tone: 'default',
114
274
  terminalFailure: false,
@@ -125,7 +285,7 @@ describe('parseClaudeSessionRecord', () => {
125
285
  ]);
126
286
  });
127
287
 
128
- it('keeps non-init system messages as session events', () => {
288
+ it('qualifies unmapped system events instead of using a bare generic label', () => {
129
289
  const rows = parseClaudeSessionRecord(
130
290
  record({type: 'system', subtype: 'custom_event', session_id: 'session-1'}),
131
291
  );
@@ -134,8 +294,8 @@ describe('parseClaudeSessionRecord', () => {
134
294
  {
135
295
  kind: 'lifecycle',
136
296
  timestamp: 1,
137
- label: 'Session event',
138
- detail: 'session-1',
297
+ label: 'Session event (custom_event)',
298
+ detail: null,
139
299
  meta: [],
140
300
  tone: 'default',
141
301
  terminalFailure: false,