@newrelic/preflight 1.4.47 → 1.5.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/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/live-event-bus.d.ts +32 -0
- package/dist/dashboard/live-event-bus.d.ts.map +1 -1
- package/dist/dashboard/live-event-bus.js.map +1 -1
- package/dist/dashboard/routes/api-handler.d.ts +46 -0
- package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
- package/dist/dashboard/routes/api-handler.js +188 -5
- package/dist/dashboard/routes/api-handler.js.map +1 -1
- package/dist/dashboard/subagent-timeline-store.d.ts +156 -0
- package/dist/dashboard/subagent-timeline-store.d.ts.map +1 -0
- package/dist/dashboard/subagent-timeline-store.js +674 -0
- package/dist/dashboard/subagent-timeline-store.js.map +1 -0
- package/dist/dashboard/workflow-store.d.ts +85 -0
- package/dist/dashboard/workflow-store.d.ts.map +1 -0
- package/dist/dashboard/workflow-store.js +330 -0
- package/dist/dashboard/workflow-store.js.map +1 -0
- package/dist/hooks/event-processor.d.ts +86 -1
- package/dist/hooks/event-processor.d.ts.map +1 -1
- package/dist/hooks/event-processor.js +182 -0
- package/dist/hooks/event-processor.js.map +1 -1
- package/dist/hooks/subagent-watcher.d.ts +182 -0
- package/dist/hooks/subagent-watcher.d.ts.map +1 -0
- package/dist/hooks/subagent-watcher.js +765 -0
- package/dist/hooks/subagent-watcher.js.map +1 -0
- package/dist/hooks/workflow-script-parser.d.ts +44 -0
- package/dist/hooks/workflow-script-parser.d.ts.map +1 -0
- package/dist/hooks/workflow-script-parser.js +255 -0
- package/dist/hooks/workflow-script-parser.js.map +1 -0
- package/dist/hooks/workflow-watcher.d.ts +65 -0
- package/dist/hooks/workflow-watcher.d.ts.map +1 -0
- package/dist/hooks/workflow-watcher.js +402 -0
- package/dist/hooks/workflow-watcher.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +315 -11
- package/dist/index.js.map +1 -1
- package/dist/metrics/cost-tracker.d.ts +113 -17
- package/dist/metrics/cost-tracker.d.ts.map +1 -1
- package/dist/metrics/cost-tracker.js +151 -8
- package/dist/metrics/cost-tracker.js.map +1 -1
- package/dist/metrics/workflow-run-tracker.d.ts +140 -0
- package/dist/metrics/workflow-run-tracker.d.ts.map +1 -0
- package/dist/metrics/workflow-run-tracker.js +306 -0
- package/dist/metrics/workflow-run-tracker.js.map +1 -0
- package/dist/storage/session-store.d.ts +8 -0
- package/dist/storage/session-store.d.ts.map +1 -1
- package/dist/storage/session-store.js +1 -1
- package/dist/storage/session-store.js.map +1 -1
- package/dist/storage/types.d.ts +40 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/transport/nr-ingest.d.ts +153 -2
- package/dist/transport/nr-ingest.d.ts.map +1 -1
- package/dist/transport/nr-ingest.js +276 -1
- package/dist/transport/nr-ingest.js.map +1 -1
- package/dist/web/assets/index-B4DkT4Go.css +2 -0
- package/dist/web/assets/index-CWwZdwYX.js +64 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CrBs4WEp.js +0 -64
- package/dist/web/assets/index-DwBQxRYb.css +0 -2
package/dist/index.js
CHANGED
|
@@ -42,6 +42,11 @@ import { LiveSessionRegistry } from './metrics/live-session-registry.js';
|
|
|
42
42
|
import { TurnCostAttributor } from './metrics/turn-cost-attributor.js';
|
|
43
43
|
import { TurnTracker } from './metrics/turn-tracker.js';
|
|
44
44
|
import { GitEfficiencyTracker, parseDefaultBranchFromSymbolicRef, } from './metrics/git-efficiency-tracker.js';
|
|
45
|
+
import { WorkflowRunTracker } from './metrics/workflow-run-tracker.js';
|
|
46
|
+
import { SubagentWatcher } from './hooks/subagent-watcher.js';
|
|
47
|
+
import { WorkflowWatcher } from './hooks/workflow-watcher.js';
|
|
48
|
+
import { WorkflowStore } from './dashboard/workflow-store.js';
|
|
49
|
+
import { SubagentTimelineStore } from './dashboard/subagent-timeline-store.js';
|
|
45
50
|
import { NrIngestManager } from './transport/nr-ingest.js';
|
|
46
51
|
import { AuditTrailManager } from './security/audit-trail.js';
|
|
47
52
|
import { LiveEventBus } from './dashboard/index.js';
|
|
@@ -426,10 +431,19 @@ async function main() {
|
|
|
426
431
|
let alertRulesWatchTimer;
|
|
427
432
|
let localStoreForShutdown;
|
|
428
433
|
let gcInterval;
|
|
434
|
+
// Periodic local session-JSON flush so a non-clean exit (crash/SIGKILL)
|
|
435
|
+
// loses at most one interval of session data — persistSession otherwise runs
|
|
436
|
+
// only on clean shutdown. Cleared in the shutdown handler. Synthetic /
|
|
437
|
+
// provisional ids (pending-/local-/proxy-) are skipped inside persistSession.
|
|
438
|
+
let sessionPersistInterval;
|
|
429
439
|
// When this MCP starts headless (EADDRINUSE skip), this interval retries
|
|
430
440
|
// dashboardServer.start() periodically so we can take over if the current
|
|
431
441
|
// owner exits. Cleared in the shutdown handler.
|
|
432
442
|
let dashboardRepollInterval;
|
|
443
|
+
// Watcher instances are declared here so the shutdown handler can stop them
|
|
444
|
+
// regardless of which mode (stdio vs. local) is active.
|
|
445
|
+
let activeSubagentWatcher = null;
|
|
446
|
+
let activeWorkflowWatcher = null;
|
|
433
447
|
// Aborts the async resolveSessionId polling loop when shutdown fires so
|
|
434
448
|
// the breadcrumb poll does not outlive the process.
|
|
435
449
|
let sessionResolutionAbort;
|
|
@@ -456,6 +470,8 @@ async function main() {
|
|
|
456
470
|
clearInterval(gcInterval);
|
|
457
471
|
if (dashboardRepollInterval)
|
|
458
472
|
clearInterval(dashboardRepollInterval);
|
|
473
|
+
if (sessionPersistInterval)
|
|
474
|
+
clearInterval(sessionPersistInterval);
|
|
459
475
|
// Remove this MCP's heartbeat so the next dashboard-owner GC pass
|
|
460
476
|
// doesn't have to mtime-archive our buffer file.
|
|
461
477
|
localStoreForShutdown?.removeHeartbeat();
|
|
@@ -480,6 +496,8 @@ async function main() {
|
|
|
480
496
|
alertRulesWatcher = undefined;
|
|
481
497
|
}
|
|
482
498
|
eventProcessor?.stop();
|
|
499
|
+
activeSubagentWatcher?.stop();
|
|
500
|
+
activeWorkflowWatcher?.stop();
|
|
483
501
|
liveSessionRegistry?.stopSampling();
|
|
484
502
|
// Use allSettled so a failure in one stop() doesn't prevent the others.
|
|
485
503
|
const stopResults = await Promise.allSettled([
|
|
@@ -649,6 +667,19 @@ async function main() {
|
|
|
649
667
|
const turnCostAttributor = new TurnCostAttributor();
|
|
650
668
|
const turnTracker = new TurnTracker();
|
|
651
669
|
const gitEfficiencyTracker = new GitEfficiencyTracker();
|
|
670
|
+
const workflowRunTracker = new WorkflowRunTracker();
|
|
671
|
+
// Read-only filesystem reader for `/api/workflows` routes.
|
|
672
|
+
// Constructed eagerly (not just inside the dashboard block) so when only
|
|
673
|
+
// the stdio MCP is running, the cost tracker still gets per-run lookups
|
|
674
|
+
// when the watcher's reconciliation pass needs them.
|
|
675
|
+
const workflowStoreInstance = new WorkflowStore({
|
|
676
|
+
getCostForRun: (runId) => costTracker.getCostForWorkflowRun(runId),
|
|
677
|
+
});
|
|
678
|
+
// Per-session subagent timeline reader — backs the "agent fan-out"
|
|
679
|
+
// swimlane chart (GET /api/sessions/:sessionId/subagents). On-demand,
|
|
680
|
+
// bounded, and mtime-cached; reads the same subagent JSONL transcripts the
|
|
681
|
+
// watcher tails, but only for the one session the dashboard asks about.
|
|
682
|
+
const subagentTimelineInstance = new SubagentTimelineStore({});
|
|
652
683
|
const toolCallBuffer = [];
|
|
653
684
|
const toolCallBufferAccessor = {
|
|
654
685
|
getRecords: () => toolCallBuffer,
|
|
@@ -967,6 +998,48 @@ async function main() {
|
|
|
967
998
|
localStore: {
|
|
968
999
|
peekAllBuffers: () => localStore.peekAllBuffers(),
|
|
969
1000
|
},
|
|
1001
|
+
// Workflow store reads on-disk wf_*.json rollups so
|
|
1002
|
+
// the /api/workflows endpoints work even when the watcher is
|
|
1003
|
+
// disabled — dashboard surfaces are functional from day one.
|
|
1004
|
+
workflowStore: workflowStoreInstance,
|
|
1005
|
+
// Agent fan-out swimlane data for one session (on-demand, bounded,
|
|
1006
|
+
// mtime-cached). Wrapped so the dashboard tree only sees the single
|
|
1007
|
+
// method it needs.
|
|
1008
|
+
subagentTimeline: {
|
|
1009
|
+
getSubagentsForSession: (id) => subagentTimelineInstance.getSubagentsForSession(id),
|
|
1010
|
+
getAgentCalls: (s, a) => subagentTimelineInstance.getAgentCalls(s, a),
|
|
1011
|
+
},
|
|
1012
|
+
// Wire the observability-health snapshot so GET
|
|
1013
|
+
// /api/observability-health returns live watcher state instead of
|
|
1014
|
+
// always 503-ing, and /api/cost's `reconciliationDeltaPct` resolves
|
|
1015
|
+
// to a real value instead of always null. Read lazily at request time
|
|
1016
|
+
// via the `activeSubagentWatcher` binding (this `api` object is built
|
|
1017
|
+
// before the watcher is constructed, but getSnapshot only fires on an
|
|
1018
|
+
// HTTP request — long after startWatchers() has run).
|
|
1019
|
+
//
|
|
1020
|
+
// The SubagentWatcher does not expose a public health accessor today,
|
|
1021
|
+
// so we report the honest minimum: whether the watcher is active. When
|
|
1022
|
+
// it is disabled (binding null) we return a zeroed "disabled" snapshot
|
|
1023
|
+
// rather than throw, so the endpoint degrades gracefully. The 1h cost
|
|
1024
|
+
// self-check delta is not surfaced through a readable accessor yet, so
|
|
1025
|
+
// costSelfCheckDeltaPct is null (honest) — it leaves the dashboard's
|
|
1026
|
+
// reconciliation banner hidden until that plumbing lands.
|
|
1027
|
+
observabilityHealth: {
|
|
1028
|
+
getSnapshot: () => {
|
|
1029
|
+
// Read live counters off the SubagentWatcher when it's running.
|
|
1030
|
+
// A null binding => watcher disabled (env flag off / wrong mode)
|
|
1031
|
+
// => a zeroed "disabled" snapshot. costSelfCheckDeltaPct stays
|
|
1032
|
+
// null until the 1h self-check is wired.
|
|
1033
|
+
const stats = activeSubagentWatcher?.getHealthStats();
|
|
1034
|
+
return {
|
|
1035
|
+
watcherActive: activeSubagentWatcher !== null,
|
|
1036
|
+
filesWatched: stats?.filesWatched ?? 0,
|
|
1037
|
+
parseErrors: stats?.parseErrors ?? 0,
|
|
1038
|
+
watcherDisabledByLock: stats?.watcherDisabledByLock ?? false,
|
|
1039
|
+
costSelfCheckDeltaPct: null,
|
|
1040
|
+
};
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
970
1043
|
},
|
|
971
1044
|
alertEngine,
|
|
972
1045
|
alertLog,
|
|
@@ -1317,8 +1390,88 @@ async function main() {
|
|
|
1317
1390
|
});
|
|
1318
1391
|
}
|
|
1319
1392
|
},
|
|
1393
|
+
// Feed each Agent-tool ToolCallRecord into the workflow tracker
|
|
1394
|
+
// so AiWorkflowRun events ship for `run_source='agent_tool'`.
|
|
1395
|
+
onWorkflowAgent: (record) => {
|
|
1396
|
+
workflowRunTracker.recordToolCall(record);
|
|
1397
|
+
// Drain immediately — recordToolCall already pushes the completed run
|
|
1398
|
+
// into the drainable queue, so each Agent call yields exactly one
|
|
1399
|
+
// AiWorkflowRun event with no harvest-tick latency.
|
|
1400
|
+
for (const run of workflowRunTracker.drainCompleted()) {
|
|
1401
|
+
capturedNrIngest?.ingestWorkflowRun(run);
|
|
1402
|
+
}
|
|
1403
|
+
},
|
|
1404
|
+
// Subagent JSONL transcripts are the only place per-agent
|
|
1405
|
+
// tokens (cache_read 91.5% of total!) are visible. Route through the
|
|
1406
|
+
// CostTracker with the entry's `timestamp_ms` as the `ctx.timestampMs`
|
|
1407
|
+
// override so cross-midnight runs bucket correctly, AND emit one
|
|
1408
|
+
// `AiSubagentTurn` event per turn for NR-side queryability.
|
|
1409
|
+
onSubagentTurn: (turn) => {
|
|
1410
|
+
if (!costTracker || !config)
|
|
1411
|
+
return;
|
|
1412
|
+
const usage = {
|
|
1413
|
+
inputTokens: turn.inputTokens,
|
|
1414
|
+
outputTokens: turn.outputTokens,
|
|
1415
|
+
// Subagent reasoning tokens (extended thinking) live under
|
|
1416
|
+
// `output_tokens_details.reasoning_tokens`; map to `thinkingTokens`
|
|
1417
|
+
// so the existing `thinkingPerMTok` rate column charges correctly.
|
|
1418
|
+
thinkingTokens: turn.reasoningTokens,
|
|
1419
|
+
cacheReadTokens: turn.cacheReadTokens,
|
|
1420
|
+
cacheCreationTokens: turn.cacheCreationTokens,
|
|
1421
|
+
totalTokens: turn.inputTokens +
|
|
1422
|
+
turn.outputTokens +
|
|
1423
|
+
turn.reasoningTokens +
|
|
1424
|
+
turn.cacheReadTokens +
|
|
1425
|
+
turn.cacheCreationTokens,
|
|
1426
|
+
};
|
|
1427
|
+
const breakdown = costTracker.recordTokenUsage(usage, turn.model, {
|
|
1428
|
+
timestampMs: turn.timestampMs,
|
|
1429
|
+
workflowRunId: turn.workflowRunId,
|
|
1430
|
+
agentId: turn.agentId,
|
|
1431
|
+
});
|
|
1432
|
+
// Pricing miss → usd:null on the wire; we recompute here so
|
|
1433
|
+
// the breakdown view distinguishes "0 because pricing absent" from
|
|
1434
|
+
// "0 because the turn truly had zero cost".
|
|
1435
|
+
const usd = breakdown.totalUsd > 0 ? breakdown.totalUsd : null;
|
|
1436
|
+
capturedNrIngest?.ingestSubagentTurn({
|
|
1437
|
+
workflow_run_id: turn.workflowRunId,
|
|
1438
|
+
agent_id: turn.agentId,
|
|
1439
|
+
parent_session_id: turn.parentSessionId,
|
|
1440
|
+
message_id: turn.messageId,
|
|
1441
|
+
turn_uuid: turn.turnUuid,
|
|
1442
|
+
timestamp_ms: turn.timestampMs,
|
|
1443
|
+
model: turn.model,
|
|
1444
|
+
input_tokens: turn.inputTokens,
|
|
1445
|
+
output_tokens: turn.outputTokens,
|
|
1446
|
+
cache_creation_tokens: turn.cacheCreationTokens,
|
|
1447
|
+
cache_read_tokens: turn.cacheReadTokens,
|
|
1448
|
+
reasoning_tokens: turn.reasoningTokens,
|
|
1449
|
+
usd,
|
|
1450
|
+
stop_reason: turn.stopReason,
|
|
1451
|
+
schema_fingerprint: turn.schemaFingerprint,
|
|
1452
|
+
});
|
|
1453
|
+
},
|
|
1454
|
+
onObservabilityHealth: (health) => {
|
|
1455
|
+
capturedNrIngest?.ingestObservabilityHealth({
|
|
1456
|
+
timestamp: health.timestamp,
|
|
1457
|
+
watcher: health.watcher,
|
|
1458
|
+
files_watched: health.filesWatched,
|
|
1459
|
+
lines_read: health.linesRead,
|
|
1460
|
+
bytes_read: health.bytesRead,
|
|
1461
|
+
parse_errors: health.parseErrors,
|
|
1462
|
+
schema_drifts: health.schemaDrifts,
|
|
1463
|
+
last_error: health.lastError,
|
|
1464
|
+
...(health.event ? { event: health.event } : {}),
|
|
1465
|
+
...(health.dimension ? { dimension: health.dimension } : {}),
|
|
1466
|
+
...(health.fingerprint ? { fingerprint: health.fingerprint } : {}),
|
|
1467
|
+
...(health.workflowRunId ? { workflow_run_id: health.workflowRunId } : {}),
|
|
1468
|
+
...(typeof health.costSelfCheckDeltaPct === 'number'
|
|
1469
|
+
? { cost_self_check_delta_pct: health.costSelfCheckDeltaPct }
|
|
1470
|
+
: {}),
|
|
1471
|
+
});
|
|
1472
|
+
},
|
|
1320
1473
|
});
|
|
1321
|
-
persistSession = () => {
|
|
1474
|
+
persistSession = (opts) => {
|
|
1322
1475
|
if (!sessionStore || !sessionTracker || !taskDetector || !config)
|
|
1323
1476
|
return;
|
|
1324
1477
|
try {
|
|
@@ -1330,6 +1483,10 @@ async function main() {
|
|
|
1330
1483
|
efficiencyScorer,
|
|
1331
1484
|
developer: config.developer ?? 'unknown',
|
|
1332
1485
|
repoName: currentRepoName,
|
|
1486
|
+
// A periodic checkpoint is a live, in-progress session — persisting it
|
|
1487
|
+
// as 'completed' makes the dashboard render a still-running session as
|
|
1488
|
+
// done. Only the terminal (shutdown) save marks it completed.
|
|
1489
|
+
outcome: opts?.periodic ? 'in progress' : 'completed',
|
|
1333
1490
|
platform: eventProcessor?.activePlatform,
|
|
1334
1491
|
instructionPromptHash: instructionDriftTracker.promptHash,
|
|
1335
1492
|
});
|
|
@@ -1338,27 +1495,156 @@ async function main() {
|
|
|
1338
1495
|
instructionDriftTracker.recordSessionOutcome(driftRecord);
|
|
1339
1496
|
}
|
|
1340
1497
|
// Skip persisting the synthetic session JSON written by --local /
|
|
1341
|
-
// proxy modes
|
|
1342
|
-
// bookkeeping; they don't correspond to a real Claude
|
|
1343
|
-
// and produce confusing
|
|
1344
|
-
//
|
|
1498
|
+
// proxy modes and the provisional pending-<ts> id. These IDs are
|
|
1499
|
+
// MCP-internal bookkeeping; they don't correspond to a real Claude
|
|
1500
|
+
// Code session and produce confusing rows in the dashboard history.
|
|
1501
|
+
// On the periodic path this is a silent no-op (no log spam while the
|
|
1502
|
+
// real session id is still being resolved).
|
|
1345
1503
|
const isSyntheticId = isSyntheticSessionId(summary.sessionId);
|
|
1346
1504
|
if (isSyntheticId) {
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1505
|
+
if (!opts?.periodic) {
|
|
1506
|
+
logger.info('Skipping synthetic session JSON persistence', {
|
|
1507
|
+
sessionId: summary.sessionId,
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
sessionStore.saveSession(summary);
|
|
1513
|
+
// checkAndGenerateLastWeek() is idempotent (existsSync check before
|
|
1514
|
+
// any real work), so calling it on every periodic checkpoint too is
|
|
1515
|
+
// cheap — and necessary: it otherwise only ran on the clean-shutdown
|
|
1516
|
+
// path, so a SIGKILL (common in containers under memory pressure)
|
|
1517
|
+
// after a periodic write meant the weekly summary never ran for that
|
|
1518
|
+
// week at all.
|
|
1519
|
+
weeklySummaryGenerator?.checkAndGenerateLastWeek();
|
|
1520
|
+
if (opts?.periodic) {
|
|
1521
|
+
// Lightweight checkpoint: log at debug so the cadence stays quiet.
|
|
1522
|
+
logger.debug('Session checkpointed', { sessionId: summary.sessionId });
|
|
1350
1523
|
}
|
|
1351
1524
|
else {
|
|
1352
|
-
sessionStore.saveSession(summary);
|
|
1353
|
-
weeklySummaryGenerator?.checkAndGenerateLastWeek();
|
|
1354
1525
|
logger.info('Session saved', { sessionId: summary.sessionId });
|
|
1355
1526
|
}
|
|
1356
1527
|
}
|
|
1357
1528
|
catch (err) {
|
|
1358
|
-
logger.warn('Failed to save session
|
|
1529
|
+
logger.warn('Failed to save session', { error: String(err) });
|
|
1359
1530
|
}
|
|
1360
1531
|
};
|
|
1361
1532
|
eventProcessor.start();
|
|
1533
|
+
// Checkpoint the in-progress session to local JSON every 30s so a non-clean
|
|
1534
|
+
// exit (crash / SIGKILL) loses at most ~30s of data instead of the whole
|
|
1535
|
+
// session. persistSession() no-ops for synthetic / provisional ids, so this
|
|
1536
|
+
// is safe to arm immediately. unref'd so it never keeps the process alive.
|
|
1537
|
+
const SESSION_PERSIST_INTERVAL_MS = 30_000;
|
|
1538
|
+
sessionPersistInterval = setInterval(() => {
|
|
1539
|
+
try {
|
|
1540
|
+
persistSession?.({ periodic: true });
|
|
1541
|
+
}
|
|
1542
|
+
catch (err) {
|
|
1543
|
+
logger.warn('Periodic session persist failed', { error: String(err) });
|
|
1544
|
+
}
|
|
1545
|
+
}, SESSION_PERSIST_INTERVAL_MS);
|
|
1546
|
+
sessionPersistInterval.unref?.();
|
|
1547
|
+
// Single-mode rule: the watcher runs in `--stdio` mode by default.
|
|
1548
|
+
// Opt-in to watcher-in-dashboard via `NR_AI_WATCHER_MODE=local`.
|
|
1549
|
+
const watcherMode = (process.env['NR_AI_WATCHER_MODE'] ?? 'stdio').toLowerCase();
|
|
1550
|
+
const isStdioWatcher = options.stdio === true;
|
|
1551
|
+
const isLocalWatcher = !isStdioWatcher;
|
|
1552
|
+
const watcherShouldRun = (isStdioWatcher && (watcherMode === 'stdio' || watcherMode === '')) ||
|
|
1553
|
+
(isLocalWatcher && watcherMode === 'local');
|
|
1554
|
+
// The SubagentWatcher is the ONLY thing that feeds per-agent (subagent)
|
|
1555
|
+
// token cost into the CostTracker (via onSubagentTurn → subagentCostUsd).
|
|
1556
|
+
// With it off, a session's persisted/headline cost silently excludes ALL
|
|
1557
|
+
// subagent spend — which is the majority of agentic cost — so the dashboard
|
|
1558
|
+
// shows a per-session total far below the subagent breakdown rendered right
|
|
1559
|
+
// below it. It is therefore default-ON; set NR_AI_ENABLE_SUBAGENT_WATCHER=0
|
|
1560
|
+
// to opt out. In `--stdio` mode it is scoped to the parent session
|
|
1561
|
+
// (parentSessionId filter), so it only ever attributes that session's own
|
|
1562
|
+
// subagents — parent tokens (onTokenEvent, parent transcript) and subagent
|
|
1563
|
+
// tokens (onSubagentTurn, subagent transcripts) are disjoint, so there is no
|
|
1564
|
+
// double count. The WorkflowWatcher stays opt-in (NR_AI_ENABLE_WORKFLOW_WATCHER=1).
|
|
1565
|
+
const subagentWatcherEnabled = process.env['NR_AI_ENABLE_SUBAGENT_WATCHER'] !== '0';
|
|
1566
|
+
const workflowWatcherEnabled = process.env['NR_AI_ENABLE_WORKFLOW_WATCHER'] === '1';
|
|
1567
|
+
// Construct + start the watchers for a given session id. In `--stdio` mode
|
|
1568
|
+
// the watchers filter discovered transcript dirs by `parentSessionId`; in
|
|
1569
|
+
// `--local` mode they run unfiltered (parentSessionId: undefined). Shared by
|
|
1570
|
+
// the initial startup call below and the async re-point call in the
|
|
1571
|
+
// provisional-session path so both produce identical wiring — see
|
|
1572
|
+
// repointWatchersToRealSession below.
|
|
1573
|
+
const startWatchers = (watcherSessionId) => {
|
|
1574
|
+
if (watcherShouldRun && subagentWatcherEnabled) {
|
|
1575
|
+
activeSubagentWatcher = new SubagentWatcher({
|
|
1576
|
+
storagePath: config.storagePath,
|
|
1577
|
+
parentSessionId: isStdioWatcher ? watcherSessionId : undefined,
|
|
1578
|
+
// Runtime cost-self-check: a drift > 5% surfaces as an
|
|
1579
|
+
// `AiObservabilityHealth { event: 'cost_self_check' }` event. We
|
|
1580
|
+
// compare like-with-like from two INDEPENDENT code paths so a
|
|
1581
|
+
// regression in either is caught:
|
|
1582
|
+
// - trackedUsd: subagent cost the live CostTracker accumulated from
|
|
1583
|
+
// the onSubagentTurn feed (the headline/persisted path), and
|
|
1584
|
+
// - groundTruthUsd: an independent re-parse of the same session's
|
|
1585
|
+
// subagent transcripts via SubagentTimelineStore (the trace path).
|
|
1586
|
+
// Both dedup streaming-duplicate lines by message.id, so a healthy
|
|
1587
|
+
// system reads ~0%; any divergence (e.g. one path regressing on dedup
|
|
1588
|
+
// or pricing) shows up as a real, non-zero delta. Only meaningful in
|
|
1589
|
+
// --stdio mode, where the watcher is scoped to this one session.
|
|
1590
|
+
costSelfCheck: () => {
|
|
1591
|
+
const trackedUsd = costTracker.getSubagentMetrics().subagentUsd;
|
|
1592
|
+
let groundTruthUsd = trackedUsd;
|
|
1593
|
+
try {
|
|
1594
|
+
const tl = subagentTimelineInstance.getSubagentsForSession(watcherSessionId);
|
|
1595
|
+
groundTruthUsd = tl.agents.reduce((sum, a) => sum + (a.usd ?? 0), 0);
|
|
1596
|
+
}
|
|
1597
|
+
catch {
|
|
1598
|
+
// On any re-parse error fall back to trackedUsd → 0% delta (no
|
|
1599
|
+
// false alarm); the error is already surfaced via watcher health.
|
|
1600
|
+
groundTruthUsd = trackedUsd;
|
|
1601
|
+
}
|
|
1602
|
+
return { trackedUsd, groundTruthUsd };
|
|
1603
|
+
},
|
|
1604
|
+
});
|
|
1605
|
+
activeSubagentWatcher.start();
|
|
1606
|
+
logger.info('SubagentWatcher started', {
|
|
1607
|
+
mode: watcherMode,
|
|
1608
|
+
parentSessionId: isStdioWatcher ? watcherSessionId : null,
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
if (watcherShouldRun && workflowWatcherEnabled) {
|
|
1612
|
+
activeWorkflowWatcher = new WorkflowWatcher({
|
|
1613
|
+
storagePath: config.storagePath,
|
|
1614
|
+
parentSessionId: isStdioWatcher ? watcherSessionId : undefined,
|
|
1615
|
+
getCostForRun: (runId) => costTracker.getCostForWorkflowRun(runId),
|
|
1616
|
+
});
|
|
1617
|
+
activeWorkflowWatcher.setOnRun((run) => {
|
|
1618
|
+
capturedNrIngest?.ingestScriptWorkflowRun(run);
|
|
1619
|
+
});
|
|
1620
|
+
activeWorkflowWatcher.setOnHealth((health) => {
|
|
1621
|
+
capturedNrIngest?.ingestObservabilityHealth(health);
|
|
1622
|
+
});
|
|
1623
|
+
activeWorkflowWatcher.start();
|
|
1624
|
+
logger.info('WorkflowWatcher started', {
|
|
1625
|
+
mode: watcherMode,
|
|
1626
|
+
parentSessionId: isStdioWatcher ? watcherSessionId : null,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
// Re-point the watchers from a provisional `pending-<ts>` session id to the
|
|
1631
|
+
// resolved real session id. Neither SubagentWatcher nor WorkflowWatcher
|
|
1632
|
+
// exposes a parentSessionId setter (the filter is `private readonly`), so we
|
|
1633
|
+
// stop the provisionally-scoped instance and reconstruct it scoped to the
|
|
1634
|
+
// real id — mirroring the eventProcessor.replaceStore() hot-swap. Only the
|
|
1635
|
+
// watchers that were actually started get rebuilt.
|
|
1636
|
+
const repointWatchersToRealSession = (realSessionId) => {
|
|
1637
|
+
if (activeSubagentWatcher) {
|
|
1638
|
+
activeSubagentWatcher.stop();
|
|
1639
|
+
activeSubagentWatcher = null;
|
|
1640
|
+
}
|
|
1641
|
+
if (activeWorkflowWatcher) {
|
|
1642
|
+
activeWorkflowWatcher.stop();
|
|
1643
|
+
activeWorkflowWatcher = null;
|
|
1644
|
+
}
|
|
1645
|
+
startWatchers(realSessionId);
|
|
1646
|
+
};
|
|
1647
|
+
startWatchers(sessionTraceId);
|
|
1362
1648
|
if (options.stdio) {
|
|
1363
1649
|
// Wire audit trail into resource handlers (was undefined at createServer() time).
|
|
1364
1650
|
// Same instance is shared with the DashboardServer and NrIngestManager so all
|
|
@@ -1391,6 +1677,13 @@ async function main() {
|
|
|
1391
1677
|
storagePath: config.storagePath,
|
|
1392
1678
|
signal: sessionResolutionAbort.signal,
|
|
1393
1679
|
});
|
|
1680
|
+
// Guard against a shutdown that fired while we were awaiting —
|
|
1681
|
+
// the signal is aborted but no exception was thrown (e.g. the
|
|
1682
|
+
// resolver returned successfully just before abort was set).
|
|
1683
|
+
if (sessionResolutionAbort?.signal.aborted) {
|
|
1684
|
+
logger.info('Session ID resolution aborted by shutdown (post-await guard)');
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1394
1687
|
// Adopt the real session ID without clearing accumulated metrics.
|
|
1395
1688
|
sessionTraceId = realId;
|
|
1396
1689
|
sessionTracker.adoptSessionId(realId);
|
|
@@ -1452,6 +1745,17 @@ async function main() {
|
|
|
1452
1745
|
capturedNrIngest = nrIngest;
|
|
1453
1746
|
nrIngest.start();
|
|
1454
1747
|
}
|
|
1748
|
+
// Re-point the subagent/workflow watchers from the provisional
|
|
1749
|
+
// `pending-<ts>` id to the resolved real session id. The watchers
|
|
1750
|
+
// were constructed in the startup block with the provisional id and
|
|
1751
|
+
// filter discovered transcript dirs by it; a `pending-*` id never
|
|
1752
|
+
// matches a real UUID session dir, so without this re-point they
|
|
1753
|
+
// capture nothing for the life of the process. Done after the
|
|
1754
|
+
// NrIngest reassignment above so the rebuilt WorkflowWatcher's
|
|
1755
|
+
// onRun/onHealth closures observe the live `capturedNrIngest`.
|
|
1756
|
+
// Guarded inside repoint: only watchers that were actually started
|
|
1757
|
+
// are stopped + reconstructed; if none ran this is a no-op.
|
|
1758
|
+
repointWatchersToRealSession(realId);
|
|
1455
1759
|
// Register full tools, replacing the pending handlers.
|
|
1456
1760
|
const configFilePath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
|
|
1457
1761
|
const configSummary = {
|