fix(bd-vqd): Align NeedleLogEntry and parser with NEEDLE telemetry format
- NeedleLogEntry.worker: accept string | NeedleWorkerObject (NEEDLE emits flat runner-provider-model-identifier strings) - Add optional level field to NeedleLogEntry (NEEDLE always includes it) - parseNeedleFormat: reconstruct worker as runner-provider-model-identifier from legacy object form (was missing provider and model) - parseNeedleFormat: gate provider/model extraction on object form (was accessing .provider/.model unconditionally, yielding undefined) - parseNeedleFormat: prefer entry.level over inferred level - inferLogLevel: match NEEDLE's _needle_telemetry_infer_level rules exactly (prefix/suffix matching: error.*, *.failed, *.retry, debug.*) - Add NeedleEventType, NeedleWorkerStatus types to types.ts - Add session/provider/model optional fields to LogEvent - Fix store.ts status detection: match on exact NEEDLE event types instead of substring-based heuristics - Update all tests to match corrected behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
68005c4578
commit
374be30dac
6 changed files with 246 additions and 129 deletions
|
|
@ -33,7 +33,7 @@ describe('NEEDLE-FABRIC Integration', () => {
|
|||
|
||||
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?.worker).toBe('claude-anthropic-sonnet-test12');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.msg).toBe('worker.started');
|
||||
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
|
||||
|
|
@ -153,9 +153,11 @@ describe('NEEDLE-FABRIC Integration', () => {
|
|||
});
|
||||
|
||||
it('should parse bead.claim_retry event with warn level', () => {
|
||||
// NEEDLE emits bead.claim_retry with explicit level: "warn"
|
||||
const log = JSON.stringify({
|
||||
ts: '2026-03-04T19:37:22.192Z',
|
||||
event: 'bead.claim_retry',
|
||||
level: 'warn',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
|
|
@ -313,19 +315,15 @@ describe('NEEDLE-FABRIC Integration', () => {
|
|||
});
|
||||
|
||||
describe('error events', () => {
|
||||
it('should infer error level for events with "error" in name', () => {
|
||||
it('should parse error.* events with error level (explicit in NEEDLE output)', () => {
|
||||
// NEEDLE emits error.* events with explicit level: "error"
|
||||
const log = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.error',
|
||||
event: 'error.agent_crash',
|
||||
level: 'error',
|
||||
session: 'test-session',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
provider: 'code',
|
||||
model: 'sonnet',
|
||||
identifier: 'test',
|
||||
},
|
||||
worker: 'claude-code-sonnet-test',
|
||||
data: {
|
||||
bead_id: 'bd-abc',
|
||||
error: 'Failed to process bead',
|
||||
},
|
||||
});
|
||||
|
|
@ -334,14 +332,16 @@ describe('NEEDLE-FABRIC Integration', () => {
|
|||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.level).toBe('error');
|
||||
expect(result?.msg).toBe('bead.error');
|
||||
expect(result?.msg).toBe('error.agent_crash');
|
||||
expect(result?.error).toBe('Failed to process bead');
|
||||
});
|
||||
|
||||
it('should infer error level for events with "fail" in name', () => {
|
||||
it('should parse bead.failed with error level (explicit in NEEDLE output)', () => {
|
||||
// NEEDLE emits bead.failed with explicit level: "error"
|
||||
const log = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.failed',
|
||||
level: 'error',
|
||||
session: 'test-session',
|
||||
worker: 'claude-test',
|
||||
data: {
|
||||
|
|
@ -356,20 +356,22 @@ describe('NEEDLE-FABRIC Integration', () => {
|
|||
expect(result?.msg).toBe('bead.failed');
|
||||
});
|
||||
|
||||
it('should infer error level for queue.exhausted events', () => {
|
||||
it('should parse bead.claim_exhausted with error level (explicit in NEEDLE output)', () => {
|
||||
// NEEDLE emits bead.claim_exhausted with explicit level: "error"
|
||||
const log = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'queue.exhausted',
|
||||
event: 'bead.claim_exhausted',
|
||||
level: 'error',
|
||||
session: 'test-session',
|
||||
worker: 'claude-test',
|
||||
data: {},
|
||||
data: { max_retries: 5 },
|
||||
});
|
||||
|
||||
const result = parseLogLine(log);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.level).toBe('error');
|
||||
expect(result?.msg).toBe('queue.exhausted');
|
||||
expect(result?.msg).toBe('bead.claim_exhausted');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -388,7 +390,7 @@ describe('NEEDLE-FABRIC Integration', () => {
|
|||
|
||||
// Verify first event
|
||||
expect(results[0].msg).toBe('worker.started');
|
||||
expect(results[0].worker).toBe('claude-w1');
|
||||
expect(results[0].worker).toBe('claude-code-sonnet-w1');
|
||||
expect(results[0].pid).toBe(123);
|
||||
|
||||
// Verify second event
|
||||
|
|
@ -405,7 +407,7 @@ describe('NEEDLE-FABRIC Integration', () => {
|
|||
expect(results[3].consecutive_empty).toBe(1);
|
||||
|
||||
// All events should have same worker
|
||||
expect(results.every((r) => r.worker === 'claude-w1')).toBe(true);
|
||||
expect(results.every((r) => r.worker === 'claude-code-sonnet-w1')).toBe(true);
|
||||
|
||||
// All events should have same session
|
||||
expect(results.every((r) => r.session === 'test')).toBe(true);
|
||||
|
|
@ -573,7 +575,7 @@ describe('NEEDLE-FABRIC Integration', () => {
|
|||
expect(events).toHaveLength(7);
|
||||
|
||||
// Verify all events have correct worker
|
||||
expect(events.every((e) => e.worker === 'claude-test')).toBe(true);
|
||||
expect(events.every((e) => e.worker === 'claude-code-glm-4.7-test')).toBe(true);
|
||||
|
||||
// Verify all events have correct session
|
||||
expect(events.every((e) => e.session === 'forge-glm-test')).toBe(true);
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ describe('parseLogLine', () => {
|
|||
|
||||
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?.worker).toBe('claude-code-glm-4.7-test');
|
||||
expect(result?.msg).toBe('worker.started');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.session).toBe('forge-glm-test');
|
||||
|
|
@ -184,10 +184,11 @@ describe('parseLogLine', () => {
|
|||
});
|
||||
|
||||
it('should infer error level from event name', () => {
|
||||
// NEEDLE rule: events with error.* prefix -> error
|
||||
const errorEvents = [
|
||||
'bead.error',
|
||||
'worker.failed',
|
||||
'bead.claim_exhausted',
|
||||
'error.claim_failed',
|
||||
'error.agent_crash',
|
||||
'error.timeout',
|
||||
];
|
||||
|
||||
for (const eventName of errorEvents) {
|
||||
|
|
@ -210,7 +211,8 @@ describe('parseLogLine', () => {
|
|||
});
|
||||
|
||||
it('should infer warn level from event name', () => {
|
||||
const warnEvents = ['bead.claim_retry', 'worker.warning'];
|
||||
// NEEDLE rule: events with *.failed or *.retry suffix -> warn
|
||||
const warnEvents = ['bead.failed', 'hook.failed'];
|
||||
|
||||
for (const eventName of warnEvents) {
|
||||
const line = JSON.stringify({
|
||||
|
|
@ -232,9 +234,10 @@ describe('parseLogLine', () => {
|
|||
});
|
||||
|
||||
it('should infer debug level from event name', () => {
|
||||
// NEEDLE rule: events with debug.* prefix -> debug
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.debug',
|
||||
event: 'debug.probe',
|
||||
session: 'test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
|
|
@ -304,7 +307,7 @@ describe('parseLogLine', () => {
|
|||
expect(result?.workspace).toBe('/home/coder/test');
|
||||
});
|
||||
|
||||
it('should flatten worker to runner-identifier format', () => {
|
||||
it('should flatten worker to runner-provider-model-identifier format', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.started',
|
||||
|
|
@ -320,7 +323,7 @@ describe('parseLogLine', () => {
|
|||
|
||||
const result = parseLogLine(line);
|
||||
|
||||
expect(result?.worker).toBe('needle-prod-worker-1');
|
||||
expect(result?.worker).toBe('needle-anthropic-opus-prod-worker-1');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1818,7 +1821,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
|
||||
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?.worker).toBe('claude-anthropic-sonnet-test12');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.msg).toBe('worker.started');
|
||||
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
|
||||
|
|
@ -1849,7 +1852,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
const result = parseLogLine(line);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.worker).toBe('claude-test');
|
||||
expect(result?.worker).toBe('claude-code-glm-4.7-test');
|
||||
expect(result?.provider).toBe('code');
|
||||
expect(result?.model).toBe('glm-4.7');
|
||||
expect(result?.session).toBe('forge-glm-test');
|
||||
|
|
@ -1886,7 +1889,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
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?.worker).toBe('claude-code-glm-4.7-test');
|
||||
expect(result?.attempt).toBe(1);
|
||||
expect(result?.actor).toBe('forge-glm-test');
|
||||
});
|
||||
|
|
@ -1952,10 +1955,11 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
|
||||
describe('bead.claim_retry event', () => {
|
||||
it('should parse bead.claim_retry event with warn level', () => {
|
||||
// Sample from ~/.needle/logs/forge-glm-test.log
|
||||
// NEEDLE emits bead.claim_retry with explicit level: "warn"
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:37:22.192Z',
|
||||
event: 'bead.claim_retry',
|
||||
level: 'warn',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
|
|
@ -1975,7 +1979,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
|
||||
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?.level).toBe('warn'); // NEEDLE emits this with explicit level: "warn"
|
||||
expect(result?.bead).toBe('bd-e6jq');
|
||||
expect(result?.attempt).toBe(1);
|
||||
expect(result?.max_retries).toBe(5);
|
||||
|
|
@ -1993,6 +1997,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:37:22.536Z',
|
||||
event: 'bead.claim_retry',
|
||||
level: 'warn',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
|
|
@ -2018,10 +2023,11 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
|
||||
describe('bead.claim_exhausted event', () => {
|
||||
it('should parse bead.claim_exhausted event with error level', () => {
|
||||
// Sample from ~/.needle/logs/forge-glm-test.log
|
||||
// NEEDLE emits bead.claim_exhausted with explicit level: "error"
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T19:37:23.647Z',
|
||||
event: 'bead.claim_exhausted',
|
||||
level: 'error',
|
||||
session: 'forge-glm-test',
|
||||
worker: {
|
||||
runner: 'claude',
|
||||
|
|
@ -2040,7 +2046,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
|
||||
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?.level).toBe('error'); // NEEDLE emits this with explicit level: "error"
|
||||
expect(result?.max_retries).toBe(5);
|
||||
});
|
||||
});
|
||||
|
|
@ -2070,7 +2076,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
expect(result).not.toBeNull();
|
||||
expect(result?.msg).toBe('heartbeat.emitted');
|
||||
expect(result?.level).toBe('info');
|
||||
expect(result?.worker).toBe('claude-test12');
|
||||
expect(result?.worker).toBe('claude-anthropic-sonnet-test12');
|
||||
expect(result?.session).toBe('needle-claude-anthropic-sonnet-test12');
|
||||
expect(result?.uptime_seconds).toBe(3600);
|
||||
expect(result?.beads_completed).toBe(5);
|
||||
|
|
@ -2137,10 +2143,15 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
});
|
||||
|
||||
describe('level inference from event names', () => {
|
||||
it('should infer error level for events with "error"', () => {
|
||||
// inferLogLevel is the fallback used when no explicit level field is present (legacy logs).
|
||||
// NEEDLE always includes level explicitly; these tests verify the inference rules match
|
||||
// NEEDLE's _needle_telemetry_infer_level: error.* -> error, *.failed/*.retry -> warn,
|
||||
// debug.* -> debug, else -> info.
|
||||
|
||||
it('should infer error level for error.* prefix events', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.error',
|
||||
event: 'error.agent_crash',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
|
|
@ -2150,7 +2161,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
expect(result?.level).toBe('error');
|
||||
});
|
||||
|
||||
it('should infer error level for events with "fail"', () => {
|
||||
it('should infer warn level for *.failed suffix events', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.failed',
|
||||
|
|
@ -2160,13 +2171,13 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('error');
|
||||
expect(result?.level).toBe('warn');
|
||||
});
|
||||
|
||||
it('should infer warn level for events with "retry"', () => {
|
||||
it('should infer warn level for *.retry suffix events', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.claim_retry',
|
||||
event: 'claim.retry',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
|
|
@ -2176,23 +2187,10 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
expect(result?.level).toBe('warn');
|
||||
});
|
||||
|
||||
it('should infer warn level for events with "warn"', () => {
|
||||
it('should infer debug level for debug.* prefix events', () => {
|
||||
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',
|
||||
event: 'debug.probe',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
|
|
@ -2214,6 +2212,22 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('info');
|
||||
});
|
||||
|
||||
it('should use explicit level field when present rather than inferring', () => {
|
||||
// bead.claim_retry would infer info (ends with _retry not .retry) but NEEDLE
|
||||
// always emits it with explicit level: "warn"
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'bead.claim_retry',
|
||||
level: 'warn',
|
||||
session: 'test-session',
|
||||
worker: { runner: 'claude', provider: 'code', model: 'sonnet', identifier: 'test' },
|
||||
data: {}
|
||||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.level).toBe('warn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timestamp conversion', () => {
|
||||
|
|
@ -2248,7 +2262,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
});
|
||||
|
||||
describe('worker identifier flattening', () => {
|
||||
it('should flatten worker object to runner-identifier format', () => {
|
||||
it('should flatten worker object to runner-provider-model-identifier format', () => {
|
||||
const line = JSON.stringify({
|
||||
ts: '2026-03-04T16:17:34.008Z',
|
||||
event: 'worker.started',
|
||||
|
|
@ -2258,7 +2272,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
});
|
||||
|
||||
const result = parseLogLine(line);
|
||||
expect(result?.worker).toBe('claude-prod');
|
||||
expect(result?.worker).toBe('claude-anthropic-opus-prod');
|
||||
});
|
||||
|
||||
it('should preserve provider and model as separate fields', () => {
|
||||
|
|
@ -2296,7 +2310,7 @@ describe('parseLogLine - NEEDLE format', () => {
|
|||
const needleResult = parseLogLine(needleLine);
|
||||
const legacyResult = parseLogLine(legacyLine);
|
||||
|
||||
expect(needleResult?.worker).toBe('claude-test');
|
||||
expect(needleResult?.worker).toBe('claude-code-sonnet-test');
|
||||
expect(needleResult?.msg).toBe('worker.started');
|
||||
|
||||
expect(legacyResult?.worker).toBe('w-legacy');
|
||||
|
|
|
|||
|
|
@ -85,19 +85,28 @@ export function parseLogLine(line: string): LogEvent | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* NEEDLE log format interface
|
||||
* NEEDLE worker object — legacy format only, present in some tests.
|
||||
* Production NEEDLE emits worker as a flat string: runner-provider-model-identifier.
|
||||
*/
|
||||
interface NeedleWorkerObject {
|
||||
runner: string; // e.g., "claude"
|
||||
provider: string; // e.g., "anthropic", "openai"
|
||||
model: string; // e.g., "sonnet", "gpt-4o"
|
||||
identifier: string; // e.g., "alpha", "bravo"
|
||||
}
|
||||
|
||||
/**
|
||||
* NEEDLE log format interface.
|
||||
* worker is a flat string in current NEEDLE output (runner-provider-model-identifier).
|
||||
* The object form is retained for backward compat with legacy test fixtures.
|
||||
*/
|
||||
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
|
||||
ts: string; // ISO 8601 timestamp
|
||||
event: string; // Event type (e.g., "worker.started", "bead.claimed")
|
||||
level?: string; // Log level — always present in current NEEDLE output
|
||||
session: string; // Session identifier
|
||||
worker: string | NeedleWorkerObject; // Flat string in production; object in legacy fixtures
|
||||
data: Record<string, unknown>; // Event-specific payload
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -125,16 +134,16 @@ function parseNeedleFormat(entry: NeedleLogEntry): LogEvent {
|
|||
// Convert ISO timestamp to Unix milliseconds
|
||||
const ts = new Date(entry.ts).getTime();
|
||||
|
||||
// Handle worker as string (aligned format) or object (legacy)
|
||||
// Handle worker as string (current NEEDLE format) or object (legacy test fixtures)
|
||||
const worker = typeof entry.worker === 'string'
|
||||
? entry.worker
|
||||
: `${entry.worker.runner}-${entry.worker.identifier}`;
|
||||
: `${entry.worker.runner}-${entry.worker.provider}-${entry.worker.model}-${entry.worker.identifier}`;
|
||||
|
||||
// Use event as message
|
||||
// Use event type as message
|
||||
const msg = entry.event;
|
||||
|
||||
// Infer log level from event name
|
||||
const level = inferLogLevel(entry.event);
|
||||
// Use the level NEEDLE provides; fall back to inference only for legacy entries without it
|
||||
const level = isValidLogLevel(entry.level) ? entry.level as LogLevel : inferLogLevel(entry.event);
|
||||
|
||||
// Build LogEvent
|
||||
const event: LogEvent = {
|
||||
|
|
@ -172,10 +181,12 @@ function parseNeedleFormat(entry: NeedleLogEntry): LogEvent {
|
|||
event.path = data.path;
|
||||
}
|
||||
|
||||
// Copy session and other NEEDLE-specific fields
|
||||
// Copy session and, when available from the object form, provider/model
|
||||
event.session = entry.session;
|
||||
event.provider = entry.worker.provider;
|
||||
event.model = entry.worker.model;
|
||||
if (typeof entry.worker !== 'string') {
|
||||
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'];
|
||||
|
|
@ -189,33 +200,22 @@ function parseNeedleFormat(entry: NeedleLogEntry): LogEvent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Infer log level from event name
|
||||
* 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
|
||||
* Mirrors NEEDLE's _needle_telemetry_infer_level rules exactly:
|
||||
* error.* → error
|
||||
* *.failed → warn
|
||||
* *.retry → warn
|
||||
* debug.* → debug
|
||||
* everything else → info
|
||||
*
|
||||
* This is only used as a fallback when the event's level field is absent
|
||||
* (legacy log entries). Current NEEDLE always includes level explicitly.
|
||||
*/
|
||||
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
|
||||
if (eventName.startsWith('error.')) return 'error';
|
||||
if (eventName.endsWith('.failed') || eventName.endsWith('.retry')) return 'warn';
|
||||
if (eventName.startsWith('debug.')) return 'debug';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -168,33 +168,33 @@ describe('InMemoryEventStore', () => {
|
|||
expect(worker?.status).toBe('error');
|
||||
});
|
||||
|
||||
it('should set status to idle on completed message', () => {
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'Task completed successfully' }));
|
||||
it('should set status to idle on bead.completed event', () => {
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed' }));
|
||||
|
||||
const worker = store.getWorker('w-test');
|
||||
expect(worker?.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should set status to idle on complete message', () => {
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'Task complete' }));
|
||||
it('should set status to idle on worker.idle event', () => {
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'worker.idle' }));
|
||||
|
||||
const worker = store.getWorker('w-test');
|
||||
expect(worker?.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should set status to active on Starting message', () => {
|
||||
it('should set status to active on worker.started event', () => {
|
||||
// First make it idle
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'Task completed' }));
|
||||
// Then starting
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'Starting new task' }));
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed' }));
|
||||
// Then active
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'worker.started' }));
|
||||
|
||||
const worker = store.getWorker('w-test');
|
||||
expect(worker?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('should increment beadsCompleted when task completes with bead', () => {
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'Task completed', bead: 'bd-1' }));
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'Task completed', bead: 'bd-2' }));
|
||||
it('should increment beadsCompleted when bead.completed event has bead', () => {
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed', bead: 'bd-1' }));
|
||||
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed', bead: 'bd-2' }));
|
||||
|
||||
const worker = store.getWorker('w-test');
|
||||
expect(worker?.beadsCompleted).toBe(2);
|
||||
|
|
|
|||
38
src/store.ts
38
src/store.ts
|
|
@ -411,18 +411,30 @@ export class InMemoryEventStore implements EventStore {
|
|||
}
|
||||
}
|
||||
|
||||
// Update status based on event
|
||||
// Update status based on NEEDLE event type (event.msg holds the event type string)
|
||||
const needleEvent = event.msg;
|
||||
if (event.level === 'error') {
|
||||
worker.status = 'error';
|
||||
} else if (event.msg.includes('completed') || event.msg.includes('complete')) {
|
||||
} else if (
|
||||
needleEvent === 'bead.completed' ||
|
||||
needleEvent === 'worker.idle' ||
|
||||
needleEvent === 'worker.stopped' ||
|
||||
needleEvent === 'worker.draining'
|
||||
) {
|
||||
worker.status = 'idle';
|
||||
if (event.bead) {
|
||||
if (needleEvent === 'bead.completed' && event.bead) {
|
||||
worker.beadsCompleted++;
|
||||
}
|
||||
// Clear active files and bead on completion
|
||||
worker.activeFiles = [];
|
||||
worker.activeBead = undefined;
|
||||
} else if (event.msg.includes('Starting') || event.msg.includes('starting')) {
|
||||
if (needleEvent === 'bead.completed') {
|
||||
worker.activeFiles = [];
|
||||
worker.activeBead = undefined;
|
||||
}
|
||||
} else if (
|
||||
needleEvent === 'worker.started' ||
|
||||
needleEvent === 'bead.claimed' ||
|
||||
needleEvent === 'bead.agent_started' ||
|
||||
needleEvent === 'execution.started'
|
||||
) {
|
||||
worker.status = 'active';
|
||||
}
|
||||
|
||||
|
|
@ -455,15 +467,9 @@ export class InMemoryEventStore implements EventStore {
|
|||
this.taskStartTimes.set(beadId, event.ts);
|
||||
}
|
||||
|
||||
// Check for task completion
|
||||
const msg = event.msg?.toLowerCase() || '';
|
||||
if (
|
||||
msg.includes('completed') ||
|
||||
msg.includes('finished') ||
|
||||
msg.includes('done') ||
|
||||
msg.includes('success') ||
|
||||
msg.includes('closed')
|
||||
) {
|
||||
// Check for task completion — match on NEEDLE event type exactly
|
||||
const msg = event.msg || '';
|
||||
if (msg === 'bead.completed' || msg === 'bead.failed') {
|
||||
const startTime = this.taskStartTimes.get(beadId);
|
||||
if (startTime) {
|
||||
const durationMs = event.ts - startTime;
|
||||
|
|
|
|||
95
src/types.ts
95
src/types.ts
|
|
@ -8,6 +8,92 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|||
|
||||
export type WorkerStatus = 'active' | 'idle' | 'error';
|
||||
|
||||
/**
|
||||
* NEEDLE worker status values as emitted in heartbeat files and worker.* events.
|
||||
* FABRIC maps these to the simpler WorkerStatus for display.
|
||||
*/
|
||||
export type NeedleWorkerStatus = 'idle' | 'executing' | 'draining' | 'starting';
|
||||
|
||||
/**
|
||||
* All event types emitted by NEEDLE's telemetry pipeline.
|
||||
* Format: category.action — matches NEEDLE's _needle_telemetry_emit event_type argument.
|
||||
*/
|
||||
export type NeedleEventType =
|
||||
// Worker lifecycle
|
||||
| 'worker.started'
|
||||
| 'worker.idle'
|
||||
| 'worker.stopped'
|
||||
| 'worker.draining'
|
||||
// Bead lifecycle
|
||||
| 'bead.claimed'
|
||||
| 'bead.prompt_built'
|
||||
| 'bead.agent_started'
|
||||
| 'bead.agent_completed'
|
||||
| 'bead.completed'
|
||||
| 'bead.failed'
|
||||
| 'bead.released'
|
||||
| 'bead.claim_retry'
|
||||
| 'bead.claim_exhausted'
|
||||
// Bead mitosis
|
||||
| 'bead.mitosis.check'
|
||||
| 'bead.mitosis.started'
|
||||
| 'bead.mitosis.child_created'
|
||||
| 'bead.mitosis.complete'
|
||||
| 'bead.mitosis.failed'
|
||||
| 'bead.mitosis.skipped'
|
||||
// Strand lifecycle
|
||||
| 'strand.started'
|
||||
| 'strand.completed'
|
||||
| 'strand.fallthrough'
|
||||
| 'strand.skipped'
|
||||
// Hook lifecycle
|
||||
| 'hook.started'
|
||||
| 'hook.completed'
|
||||
| 'hook.failed'
|
||||
// Heartbeat
|
||||
| 'heartbeat.emitted'
|
||||
| 'heartbeat.stuck_detected'
|
||||
| 'heartbeat.recovery'
|
||||
// Mend (maintenance)
|
||||
| 'mend.orphan_released'
|
||||
| 'mend.heartbeat_cleaned'
|
||||
| 'mend.logs_pruned'
|
||||
| 'mend.completed'
|
||||
// Unravel (alternatives)
|
||||
| 'unravel.alternatives_created'
|
||||
| 'unravel.alternative_created'
|
||||
| 'unravel.analysis_started'
|
||||
| 'unravel.analysis_completed'
|
||||
// Weave (documentation gaps)
|
||||
| 'weave.bead_created'
|
||||
| 'weave.analysis_started'
|
||||
| 'weave.analysis_completed'
|
||||
// Pulse (health monitoring)
|
||||
| 'pulse.bead_created'
|
||||
| 'pulse.scan_started'
|
||||
| 'pulse.scan_completed'
|
||||
| 'pulse.issue_detected'
|
||||
| 'pulse.detector_started'
|
||||
| 'pulse.detector_completed'
|
||||
// Error events
|
||||
| 'error.claim_failed'
|
||||
| 'error.agent_crash'
|
||||
| 'error.timeout'
|
||||
| 'error.release_failed'
|
||||
// Effort & budget
|
||||
| 'effort.recorded'
|
||||
| 'budget.warning'
|
||||
| 'budget.exceeded'
|
||||
| 'budget.per_bead_exceeded'
|
||||
// File locks
|
||||
| 'file.checkout'
|
||||
| 'file.conflict'
|
||||
| 'file.release'
|
||||
| 'file.stale'
|
||||
| 'lock.priority_bump'
|
||||
| 'lock.priority_bump_received'
|
||||
| 'lock.expired';
|
||||
|
||||
// ============================================
|
||||
// Conversation Event Types
|
||||
// ============================================
|
||||
|
|
@ -258,6 +344,15 @@ export interface LogEvent {
|
|||
/** Optional: Error details */
|
||||
error?: string;
|
||||
|
||||
/** NEEDLE session identifier (e.g. 'needle-claude-anthropic-sonnet-alpha') */
|
||||
session?: string;
|
||||
|
||||
/** AI provider extracted from NEEDLE worker string (e.g. 'anthropic', 'openai') */
|
||||
provider?: string;
|
||||
|
||||
/** AI model identifier extracted from NEEDLE worker string (e.g. 'sonnet', 'gpt-4o') */
|
||||
model?: string;
|
||||
|
||||
/** Any additional fields */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue