feat(bd-2fxx): Align parser to NEEDLE's actual log schema
- Accept ISO timestamp strings, convert to Unix milliseconds for internal use
- Use 'event' field as event type (map to 'msg' for internal use)
- Flatten worker object to string: ${runner}-${identifier}
- Infer log level from event name (error/warn/info/debug)
- Extract bead_id, duration_ms from data payload
- Add session, provider, model fields from NEEDLE format
- Maintain backward compatibility with legacy format
- Add comprehensive NEEDLE format test cases
Verified with actual NEEDLE log files:
- 146/146 lines parsed successfully
- Correctly identifies error-level events (claim_exhausted)
- Correctly identifies warn-level events (claim_retry)
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
parent
fd04c7fa6d
commit
f1a6bb6691
2 changed files with 897 additions and 2 deletions
|
|
@ -103,6 +103,227 @@ describe('parseLogLine', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('NEEDLE format', () => {
|
||||
it('should parse NEEDLE format with ISO timestamp', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.started',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'glm-4.7',
|
||||
identifier: 'test',
|
||||
},
|
||||
data: {
|
||||
pid: 2789549,
|
||||
workspace: '/home/coder/forge',
|
||||
agent: 'claude-code-glm-4.7',
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.ts).toBe(1772641054008); // Unix ms from '2026-03-04T16:17:34.008Z'
|
||||
expect(result?.worker).toBe('claude-test');
|
||||
expect(result?.msg).toBe('worker.started');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.session).toBe('forge-glm-test');
|
||||
expect(result?.provider).toBe('code');
|
||||
expect(result?.model).toBe('glm-4.7');
|
||||
});
|
||||
|
||||
it('should extract bead_id from data payload', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.claimed',
|
||||
session: 'test-session',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'sonnet',
|
||||
identifier: 'worker1',
|
||||
},
|
||||
data: {
|
||||
bead_id: 'bd-2ok0',
|
||||
title: 'Test task',
|
||||
workspace: '/home/coder/forge',
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.bead).toBe('bd-2ok0');
|
||||
});
|
||||
|
||||
it('should extract duration_ms from data payload', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.completed',
|
||||
session: 'test-session',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'anthropic',
|
||||
model: 'sonnet',
|
||||
identifier: 'test',
|
||||
},
|
||||
data: {
|
||||
bead_id: 'bd-xyz',
|
||||
duration_ms: 10076,
|
||||
output_file: '/tmp/output.log',
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.duration_ms).toBe(10076);
|
||||
expect(result?.bead).toBe('bd-xyz');
|
||||
});
|
||||
|
||||
it('should infer error level from event name', () => {
|
||||
const errorEvents = [
|
||||
'bead.error',
|
||||
'worker.failed',
|
||||
'bead.claim_exhausted',
|
||||
];
|
||||
|
||||
for (const eventName of errorEvents) {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: eventName,
|
||||
session: 'test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'test',
|
||||
identifier: 'test',
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should infer warn level from event name', () => {
|
||||
const warnEvents = ['bead.claim_retry', 'worker.warning'];
|
||||
|
||||
for (const eventName of warnEvents) {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: eventName,
|
||||
session: 'test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'test',
|
||||
identifier: 'test',
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('warn');
|
||||
}
|
||||
});
|
||||
|
||||
it('should infer debug level from event name', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.debug',
|
||||
session: 'test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'test',
|
||||
identifier: 'test',
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('debug');
|
||||
});
|
||||
|
||||
it('should default to info level for normal events', () => {
|
||||
const infoEvents = [
|
||||
'worker.started',
|
||||
'worker.idle',
|
||||
'bead.claimed',
|
||||
'bead.completed',
|
||||
'effort.recorded',
|
||||
];
|
||||
|
||||
for (const eventName of infoEvents) {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: eventName,
|
||||
session: 'test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'test',
|
||||
identifier: 'test',
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('info');
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve additional data fields', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.claimed',
|
||||
session: 'test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'test',
|
||||
identifier: 'test',
|
||||
},
|
||||
data: {
|
||||
bead_id: 'bd-123',
|
||||
title: 'Custom title',
|
||||
attempt: 3,
|
||||
workspace: '/home/coder/test',
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.title).toBe('Custom title');
|
||||
expect(result?.attempt).toBe(3);
|
||||
expect(result?.workspace).toBe('/home/coder/test');
|
||||
});
|
||||
|
||||
it('should flatten worker to runner-identifier format', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.started',
|
||||
session: 'test',
|
||||
worker: {
|
||||
runner: 'needle',
|
||||
provider: 'anthropic',
|
||||
model: 'opus',
|
||||
identifier: 'prod-worker-1',
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result?.worker).toBe('needle-prod-worker-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('should return null for empty string', () => {
|
||||
expect(parseLogLine('')).toBeNull();
|
||||
|
|
@ -1553,3 +1774,533 @@ describe('formatConversationEvent', () => {
|
|||
expect(formatted).toContain('[truncated]');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// NEEDLE Log Format Tests
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Tests for NEEDLE structured log format parsing.
|
||||
*
|
||||
* NEEDLE format structure:
|
||||
* {
|
||||
* ts: ISO 8601 string,
|
||||
* event: string (e.g., "worker.started", "bead.claimed"),
|
||||
* session: string,
|
||||
* worker: { runner, provider, model, identifier },
|
||||
* data: { ...event-specific payload }
|
||||
* }
|
||||
*
|
||||
* Sample log lines from ~/.needle/logs/
|
||||
*/
|
||||
describe('parseLogLine - NEEDLE format', () => {
|
||||
describe('worker.started event', () => {
|
||||
it('should parse worker.started event with minimal fields', () => {
|
||||
// Sample from ~/.needle/logs/needle-claude-anthropic-sonnet-test12.log
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.started',
|
||||
session: 'needle-claude-anthropic-sonnet-test12',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'anthropic',
|
||||
model: 'sonnet',
|
||||
identifier: 'test12'
|
||||
},
|
||||
data: {
|
||||
pid: 1929276,
|
||||
workspace: '/home/coder/NEEDLE',
|
||||
agent: 'claude-anthropic-sonnet'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.ts).toBe(new Date('2026-03-04T16:17:34.008Z').getTime());
|
||||
expect(result?.worker).toBe('claude-test12');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.msg).toBe('worker.started');
|
||||
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
|
||||
expect(result?.provider).toBe('anthropic');
|
||||
expect(result?.model).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should parse worker.started event with full data', () => {
|
||||
// Sample from ~/.needle/logs/forge-glm-test.log
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:31:30.245Z',
|
||||
event: 'worker.started',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'glm-4.7',
|
||||
identifier: 'test'
|
||||
},
|
||||
data: {
|
||||
workspace: '/home/coder/forge',
|
||||
agent: 'claude-code-glm-4.7',
|
||||
session: 'forge-glm-test',
|
||||
timestamp: '2026-03-04T19:31:30Z'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.worker).toBe('claude-test');
|
||||
expect(result?.provider).toBe('code');
|
||||
expect(result?.model).toBe('glm-4.7');
|
||||
expect(result?.session).toBe('forge-glm-test');
|
||||
// Additional data fields should be preserved
|
||||
expect(result?.workspace).toBe('/home/coder/forge');
|
||||
expect(result?.agent).toBe('claude-code-glm-4.7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bead.claimed event', () => {
|
||||
it('should parse bead.claimed event with bead_id', () => {
|
||||
// Sample from ~/.needle/logs/forge-glm-test.log
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:31:34.851Z',
|
||||
event: 'bead.claimed',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'glm-4.7',
|
||||
identifier: 'test'
|
||||
},
|
||||
data: {
|
||||
bead_id: 'bd-2ok0',
|
||||
actor: 'forge-glm-test',
|
||||
attempt: 1,
|
||||
workspace: '/home/coder/forge'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.msg).toBe('bead.claimed');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.bead).toBe('bd-2ok0');
|
||||
expect(result?.worker).toBe('claude-test');
|
||||
expect(result?.attempt).toBe(1);
|
||||
expect(result?.actor).toBe('forge-glm-test');
|
||||
});
|
||||
|
||||
it('should parse bead.claimed event with title', () => {
|
||||
// Sample from ~/.needle/logs/forge-glm-test.log
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:31:34.978Z',
|
||||
event: 'bead.claimed',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'glm-4.7',
|
||||
identifier: 'test'
|
||||
},
|
||||
data: {
|
||||
bead_id: 'bd-2ok0',
|
||||
workspace: '/home/coder/forge',
|
||||
agent: 'claude-code-glm-4.7',
|
||||
title: 'Add model alias mapping for opencode'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.bead).toBe('bd-2ok0');
|
||||
expect(result?.title).toBe('Add model alias mapping for opencode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bead.completed event', () => {
|
||||
it('should parse bead.completed event with duration', () => {
|
||||
// Sample from ~/.needle/logs/forge-glm-test.log
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:37:19.590Z',
|
||||
event: 'bead.completed',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'glm-4.7',
|
||||
identifier: 'test'
|
||||
},
|
||||
data: {
|
||||
bead_id: 'bd-2ok0',
|
||||
duration_ms: 28854,
|
||||
output_file: '/tmp/needle-dispatch-bd-2ok0-FHwgcG7A.log'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.msg).toBe('bead.completed');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.bead).toBe('bd-2ok0');
|
||||
expect(result?.duration_ms).toBe(28854);
|
||||
expect(result?.output_file).toBe('/tmp/needle-dispatch-bd-2ok0-FHwgcG7A.log');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bead.claim_retry event', () => {
|
||||
it('should parse bead.claim_retry event with warn level', () => {
|
||||
// Sample from ~/.needle/logs/forge-glm-test.log
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:37:22.192Z',
|
||||
event: 'bead.claim_retry',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'glm-4.7',
|
||||
identifier: 'test'
|
||||
},
|
||||
data: {
|
||||
bead_id: 'bd-e6jq',
|
||||
attempt: 1,
|
||||
max_retries: 5,
|
||||
actor: 'forge-glm-test'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.msg).toBe('bead.claim_retry');
|
||||
expect(result?.level).toBe('warn'); // 'retry' in event name triggers warn level
|
||||
expect(result?.bead).toBe('bd-e6jq');
|
||||
expect(result?.attempt).toBe(1);
|
||||
expect(result?.max_retries).toBe(5);
|
||||
});
|
||||
|
||||
it('should parse multiple claim_retry attempts', () => {
|
||||
const attempts = [
|
||||
{ attempt: 2, bead_id: 'bd-2ee5' },
|
||||
{ attempt: 3, bead_id: 'bd-e6jq' },
|
||||
{ attempt: 4, bead_id: 'bd-e6jq' },
|
||||
{ attempt: 5, bead_id: 'bd-e6jq' }
|
||||
];
|
||||
|
||||
for (const { attempt, bead_id } of attempts) {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:37:22.536Z',
|
||||
event: 'bead.claim_retry',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'glm-4.7',
|
||||
identifier: 'test'
|
||||
},
|
||||
data: {
|
||||
bead_id,
|
||||
attempt,
|
||||
max_retries: 5,
|
||||
actor: 'forge-glm-test'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.level).toBe('warn');
|
||||
expect(result?.attempt).toBe(attempt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bead.claim_exhausted event', () => {
|
||||
it('should parse bead.claim_exhausted event with error level', () => {
|
||||
// Sample from ~/.needle/logs/forge-glm-test.log
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:37:23.647Z',
|
||||
event: 'bead.claim_exhausted',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'glm-4.7',
|
||||
identifier: 'test'
|
||||
},
|
||||
data: {
|
||||
max_retries: 5,
|
||||
actor: 'forge-glm-test',
|
||||
workspace: '/home/coder/forge'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.msg).toBe('bead.claim_exhausted');
|
||||
expect(result?.level).toBe('error'); // 'exhausted' in event name triggers error level
|
||||
expect(result?.max_retries).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat.emitted event', () => {
|
||||
it('should parse heartbeat.emitted event', () => {
|
||||
// Constructed based on NEEDLE format pattern
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'heartbeat.emitted',
|
||||
session: 'needle-claude-anthropic-sonnet-test12',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'anthropic',
|
||||
model: 'sonnet',
|
||||
identifier: 'test12'
|
||||
},
|
||||
data: {
|
||||
uptime_seconds: 3600,
|
||||
beads_completed: 5,
|
||||
last_bead_id: 'bd-abc123'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.msg).toBe('heartbeat.emitted');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.worker).toBe('claude-test12');
|
||||
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
|
||||
expect(result?.uptime_seconds).toBe(3600);
|
||||
expect(result?.beads_completed).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worker.idle event', () => {
|
||||
it('should parse worker.idle event', () => {
|
||||
// Sample from ~/.needle/logs/needle-claude-anthropic-sonnet-test12.log
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:36.243Z',
|
||||
event: 'worker.idle',
|
||||
session: 'needle-claude-anthropic-sonnet-test12',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'anthropic',
|
||||
model: 'sonnet',
|
||||
identifier: 'test12'
|
||||
},
|
||||
data: {
|
||||
consecutive_empty: 1,
|
||||
idle_seconds: 0,
|
||||
workspace: '/home/coder/NEEDLE',
|
||||
agent: 'claude-anthropic-sonnet'
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.msg).toBe('worker.idle');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.consecutive_empty).toBe(1);
|
||||
expect(result?.idle_seconds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effort.recorded event', () => {
|
||||
it('should parse effort.recorded event with duration', () => {
|
||||
// Sample from ~/.needle/logs/forge-glm-test.log
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:37:19.616Z',
|
||||
event: 'effort.recorded',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'glm-4.7',
|
||||
identifier: 'test'
|
||||
},
|
||||
data: {
|
||||
bead_id: 'bd-2ok0',
|
||||
duration_ms: 28854
|
||||
}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.msg).toBe('effort.recorded');
|
||||
expect(result?.bead).toBe('bd-2ok0');
|
||||
expect(result?.duration_ms).toBe(28854);
|
||||
});
|
||||
});
|
||||
|
||||
describe('level inference from event names', () => {
|
||||
it('should infer error level for events with "error"', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.error',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('error');
|
||||
});
|
||||
|
||||
it('should infer error level for events with "fail"', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.failed',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('error');
|
||||
});
|
||||
|
||||
it('should infer warn level for events with "retry"', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.claim_retry',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('warn');
|
||||
});
|
||||
|
||||
it('should infer warn level for events with "warn"', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.warning',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('warn');
|
||||
});
|
||||
|
||||
it('should infer debug level for events with "debug"', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.debug',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('debug');
|
||||
});
|
||||
|
||||
it('should default to info level for unknown events', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'custom.event',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timestamp conversion', () => {
|
||||
it('should convert ISO 8601 timestamp to Unix milliseconds', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.started',
|
||||
session: 'test',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
// Verify the timestamp is correctly converted
|
||||
expect(result?.ts).toBe(1709569054008);
|
||||
});
|
||||
|
||||
it('should handle timestamps with different timezone offsets', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34+00:00',
|
||||
event: 'worker.started',
|
||||
session: 'test',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(typeof result?.ts).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('worker identifier flattening', () => {
|
||||
it('should flatten worker object to runner-identifier format', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.started',
|
||||
session: 'test',
|
||||
worker: { runner: 'claude', provider: 'anthropic', model: 'opus', identifier: 'prod' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.worker).toBe('claude-prod');
|
||||
});
|
||||
|
||||
it('should preserve provider and model as separate fields', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.started',
|
||||
session: 'test',
|
||||
worker: { runner: 'claude', provider: 'anthropic', model: 'opus-4', identifier: 'prod' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.provider).toBe('anthropic');
|
||||
expect(result?.model).toBe('opus-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed NEEDLE and legacy format', () => {
|
||||
it('should parse NEEDLE format when mixed with legacy format', () => {
|
||||
const needleLine = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.started',
|
||||
session: 'test',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const legacyLine = JSON.stringify({
|
||||
ts: 1709569054008,
|
||||
worker: 'w-legacy',
|
||||
level: 'info',
|
||||
msg: 'Legacy message'
|
||||
});
|
||||
|
||||
const needleResult = parseLogLine(needleLine);
|
||||
const legacyResult = parseLogLine(legacyLine);
|
||||
|
||||
expect(needleResult?.worker).toBe('claude-test');
|
||||
expect(needleResult?.msg).toBe('worker.started');
|
||||
|
||||
expect(legacyResult?.worker).toBe('w-legacy');
|
||||
expect(legacyResult?.msg).toBe('Legacy message');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
148
src/parser.ts
148
src/parser.ts
|
|
@ -20,6 +20,10 @@ import {
|
|||
/**
|
||||
* Parse a single log line
|
||||
*
|
||||
* Supports two formats:
|
||||
* 1. NEEDLE format: {ts: ISO string, event: string, worker: {...}, session: string, data: {...}}
|
||||
* 2. Legacy format: {ts: Unix ms, worker: string, level: string, msg: string}
|
||||
*
|
||||
* @param line - Raw log line (JSON string)
|
||||
* @returns Parsed LogEvent or null if invalid
|
||||
*/
|
||||
|
|
@ -32,7 +36,12 @@ export function parseLogLine(line: string): LogEvent | null {
|
|||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
|
||||
// Validate required fields
|
||||
// Detect format and route to appropriate parser
|
||||
if (isNeedleFormat(parsed)) {
|
||||
return parseNeedleFormat(parsed);
|
||||
}
|
||||
|
||||
// Legacy format validation
|
||||
if (typeof parsed.ts !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -75,6 +84,137 @@ export function parseLogLine(line: string): LogEvent | null {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NEEDLE log format interface
|
||||
*/
|
||||
interface NeedleLogEntry {
|
||||
ts: string; // ISO 8601 timestamp
|
||||
event: string; // Event type (e.g., "worker.started", "bead.claimed")
|
||||
session: string; // Session identifier
|
||||
worker: {
|
||||
runner: string; // e.g., "claude"
|
||||
provider: string; // e.g., "code", "anthropic"
|
||||
model: string; // e.g., "glm-4.7", "sonnet"
|
||||
identifier: string; // e.g., "test", "align"
|
||||
};
|
||||
data: Record<string, unknown>; // Event-specific payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parsed object matches NEEDLE format
|
||||
*/
|
||||
function isNeedleFormat(parsed: unknown): parsed is NeedleLogEntry {
|
||||
if (typeof parsed !== 'object' || parsed === null) return false;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// NEEDLE format has: ts (string), event (string), worker (object)
|
||||
return (
|
||||
typeof obj.ts === 'string' &&
|
||||
typeof obj.event === 'string' &&
|
||||
typeof obj.worker === 'object' &&
|
||||
obj.worker !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse NEEDLE format log entry
|
||||
*/
|
||||
function parseNeedleFormat(entry: NeedleLogEntry): LogEvent {
|
||||
// Convert ISO timestamp to Unix milliseconds
|
||||
const ts = new Date(entry.ts).getTime();
|
||||
|
||||
// Flatten worker object: ${runner}-${identifier}
|
||||
const worker = `${entry.worker.runner}-${entry.worker.identifier}`;
|
||||
|
||||
// Use event as message
|
||||
const msg = entry.event;
|
||||
|
||||
// Infer log level from event name
|
||||
const level = inferLogLevel(entry.event);
|
||||
|
||||
// Build LogEvent
|
||||
const event: LogEvent = {
|
||||
ts,
|
||||
worker,
|
||||
level,
|
||||
msg,
|
||||
};
|
||||
|
||||
// Extract optional fields from data payload
|
||||
const data = entry.data || {};
|
||||
|
||||
// Extract bead_id (map to 'bead' field)
|
||||
if (typeof data.bead_id === 'string') {
|
||||
event.bead = data.bead_id;
|
||||
}
|
||||
|
||||
// Extract duration_ms
|
||||
if (typeof data.duration_ms === 'number') {
|
||||
event.duration_ms = data.duration_ms;
|
||||
}
|
||||
|
||||
// Extract error if present
|
||||
if (typeof data.error === 'string') {
|
||||
event.error = data.error;
|
||||
}
|
||||
|
||||
// Extract tool if present
|
||||
if (typeof data.tool === 'string') {
|
||||
event.tool = data.tool;
|
||||
}
|
||||
|
||||
// Extract path if present
|
||||
if (typeof data.path === 'string') {
|
||||
event.path = data.path;
|
||||
}
|
||||
|
||||
// Copy session and other NEEDLE-specific fields
|
||||
event.session = entry.session;
|
||||
event.provider = entry.worker.provider;
|
||||
event.model = entry.worker.model;
|
||||
|
||||
// Copy remaining data fields (excluding already extracted ones)
|
||||
const extractedFields = ['bead_id', 'duration_ms', 'error', 'tool', 'path'];
|
||||
for (const key of Object.keys(data)) {
|
||||
if (!extractedFields.includes(key) && !(key in event)) {
|
||||
event[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer log level from event name
|
||||
*
|
||||
* Maps NEEDLE event types to log levels:
|
||||
* - error: events containing "error", "fail", "exhausted"
|
||||
* - warn: events containing "retry", "warn"
|
||||
* - debug: events containing "debug"
|
||||
* - info: everything else
|
||||
*/
|
||||
function inferLogLevel(eventName: string): LogLevel {
|
||||
const lower = eventName.toLowerCase();
|
||||
|
||||
// Error-level events
|
||||
if (lower.includes('error') || lower.includes('fail') || lower.includes('exhausted')) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// Warn-level events
|
||||
if (lower.includes('retry') || lower.includes('warn')) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
// Debug-level events
|
||||
if (lower.includes('debug')) {
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
// Default to info
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multiple log lines
|
||||
*
|
||||
|
|
@ -150,7 +290,11 @@ function isValidLogLevel(level: unknown): level is LogLevel {
|
|||
* Check if field is a standard LogEvent field
|
||||
*/
|
||||
function isStandardField(key: string): boolean {
|
||||
return ['ts', 'worker', 'level', 'msg', 'tool', 'path', 'bead', 'duration_ms', 'error'].includes(key);
|
||||
return [
|
||||
'ts', 'worker', 'level', 'msg', 'tool', 'path', 'bead', 'duration_ms', 'error',
|
||||
// NEEDLE-specific fields
|
||||
'session', 'provider', 'model'
|
||||
].includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue