From ab9fdc4136ba4b14db29c100128e52b52b7bee43 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:20:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(telemetry):=20U1=20=E2=80=94=20queryable?= =?UTF-8?q?=20usage=5Fevents=20table=20+=20emitUsageEvent=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema migration 117→118 adds usage_events; events captured via a dedicated emitUsageEvent seam wired through AgentLogger tool hooks + executor session context (model/provider/nodeId), not by widening log signatures. meta is size-capped and carries only non-sensitive descriptors. --- .../core/src/__tests__/db-migrate.test.ts | 30 +- packages/core/src/__tests__/db.test.ts | 44 +-- .../core/src/__tests__/goals-schema.test.ts | 2 +- .../core/src/__tests__/insight-store.test.ts | 10 +- .../__tests__/merge-request-record.test.ts | 2 +- .../core/src/__tests__/mission-store.test.ts | 2 +- packages/core/src/__tests__/run-audit.test.ts | 4 +- .../src/__tests__/store-merge-queue.test.ts | 2 +- .../core/src/__tests__/task-documents.test.ts | 2 +- .../core/src/__tests__/usage-events.test.ts | 204 +++++++++++++ packages/core/src/db.ts | 57 +++- packages/core/src/index.ts | 13 + packages/core/src/store.ts | 17 ++ packages/core/src/usage-events.ts | 280 ++++++++++++++++++ .../engine/src/__tests__/agent-logger.test.ts | 92 ++++++ packages/engine/src/agent-logger.ts | 77 +++++ packages/engine/src/executor.ts | 11 + .../src/store/__tests__/roadmap-store.test.ts | 4 +- 18 files changed, 801 insertions(+), 52 deletions(-) create mode 100644 packages/core/src/__tests__/usage-events.test.ts create mode 100644 packages/core/src/usage-events.ts diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 3b97bdf49a..5f91c30949 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1000,7 +1000,7 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1038,7 +1038,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1120,7 +1120,7 @@ describe("schema migration", () => { expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); expect(indexNames).toContain("idx_cli_sessions_project_state"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1152,7 +1152,7 @@ describe("schema migration", () => { .all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1162,7 +1162,7 @@ describe("schema migration", () => { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; expect(tables.map((row) => row.name)).toContain("cli_sessions"); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1219,20 +1219,20 @@ describe("schema migration", () => { .get() as { migrated_fragment_id: string | null }; expect(stepRow.migrated_fragment_id).toBeNull(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); it("migration 109 is idempotent on re-init", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); // Re-open the same on-disk DB: already at 109, the 109 block must be a no-op. const reopened = new Database(fusionDir); reopened.init(); - expect(reopened.getSchemaVersion()).toBe(117); + expect(reopened.getSchemaVersion()).toBe(118); const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>; expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1); const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index d661041003..aa49d59aa6 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,15 +1488,15 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1531,7 +1531,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1572,7 +1572,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1644,7 +1644,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1884,7 +1884,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1958,7 +1958,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1982,7 +1982,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2086,7 +2086,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2305,7 +2305,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(117); + expect(localDb.getSchemaVersion()).toBe(118); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2616,7 +2616,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2770,7 +2770,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(117); + expect(migrated.getSchemaVersion()).toBe(118); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2801,7 +2801,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(117); + expect(fresh.getSchemaVersion()).toBe(118); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2829,7 +2829,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(117); + expect(migrated.getSchemaVersion()).toBe(118); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2855,7 +2855,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(117); + expect(fresh.getSchemaVersion()).toBe(118); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2889,7 +2889,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(117); + expect(migrated.getSchemaVersion()).toBe(118); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2930,7 +2930,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(117); + expect(migrated.getSchemaVersion()).toBe(118); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2957,7 +2957,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(117); + expect(fresh.getSchemaVersion()).toBe(118); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index c18b43ff60..75cf8c431e 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 49fb26341e..84f57c78a3 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(117); + expect(db1.getSchemaVersion()).toBe(118); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(117); + expect(db3.getSchemaVersion()).toBe(118); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(117); + expect(db1.getSchemaVersion()).toBe(118); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(117); + expect(db2.getSchemaVersion()).toBe(118); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(117); + expect(db1.getSchemaVersion()).toBe(118); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index 0872baa07e..f68511ee11 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index a78da744cb..71b9dd6758 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 814b23a1a6..68faeb4cdb 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -583,8 +583,8 @@ describe("Run Audit", () => { expect(indexNames).toContain("idxRunAuditEventsTimestamp"); }); - it("schema version is bumped to 117", () => { - expect(db.getSchemaVersion()).toBe(117); + it("schema version is bumped to 118", () => { + expect(db.getSchemaVersion()).toBe(118); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 9a128a7ba9..f4311184d8 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(117); + expect(store.getDatabase().getSchemaVersion()).toBe(118); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index b1fa2e2e2d..ec92575074 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); const index = db .prepare( diff --git a/packages/core/src/__tests__/usage-events.test.ts b/packages/core/src/__tests__/usage-events.test.ts new file mode 100644 index 0000000000..d17587b55e --- /dev/null +++ b/packages/core/src/__tests__/usage-events.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database, SCHEMA_VERSION } from "../db.js"; +import { + emitUsageEvent, + queryUsageEvents, + countUsageEventsBy, + categorizeToolName, + USAGE_EVENT_META_MAX_BYTES, +} from "../usage-events.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-usage-events-test-")); +} + +describe("usage_events", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("creates usage_events table with expected columns on fresh init", () => { + const columns = db.prepare("PRAGMA table_info(usage_events)").all() as Array<{ name: string }>; + expect(columns.map((c) => c.name)).toEqual([ + "id", + "ts", + "kind", + "taskId", + "agentId", + "nodeId", + "model", + "provider", + "toolName", + "category", + "meta", + ]); + }); + + it("creates the ts/taskId/agentId indexes on fresh init", () => { + const indexes = ( + db + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='usage_events'") + .all() as Array<{ name: string }> + ).map((r) => r.name); + expect(indexes).toContain("idxUsageEventsTs"); + expect(indexes).toContain("idxUsageEventsTaskId"); + expect(indexes).toContain("idxUsageEventsAgentId"); + }); + + it("inserts one row for a tool_call event with correct category", () => { + const ok = emitUsageEvent(db, { + kind: "tool_call", + taskId: "T-1", + agentId: "A-1", + nodeId: "node-1", + model: "claude-sonnet-4-5", + provider: "anthropic", + toolName: "Read", + }); + expect(ok).toBe(true); + + const rows = queryUsageEvents(db, { taskId: "T-1" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + kind: "tool_call", + taskId: "T-1", + agentId: "A-1", + nodeId: "node-1", + model: "claude-sonnet-4-5", + provider: "anthropic", + toolName: "Read", + }); + }); + + it("categorizes tool names into coarse buckets", () => { + expect(categorizeToolName("Read")).toBe("read"); + expect(categorizeToolName("Grep")).toBe("read"); + expect(categorizeToolName("Edit")).toBe("edit"); + expect(categorizeToolName("Write")).toBe("edit"); + expect(categorizeToolName("Bash")).toBe("execute"); + expect(categorizeToolName("WebFetch")).toBe("network"); + expect(categorizeToolName("Unknown")).toBe("other"); + expect(categorizeToolName(undefined)).toBe("other"); + expect(categorizeToolName(null)).toBe("other"); + }); + + it("rejects a meta payload over the byte cap at write (event skipped, nothing inserted)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const huge = "x".repeat(USAGE_EVENT_META_MAX_BYTES + 100); + const ok = emitUsageEvent(db, { + kind: "tool_error", + taskId: "T-cap", + meta: { blob: huge }, + }); + expect(ok).toBe(false); + expect(queryUsageEvents(db, { taskId: "T-cap" })).toHaveLength(0); + warn.mockRestore(); + }); + + it("never lets tool-argument content land in meta (caller controls meta; arg helpers are not stored)", () => { + // The write helper only persists what the caller puts in `meta`. A caller + // that follows the contract (descriptors only) leaves no tool args behind. + emitUsageEvent(db, { + kind: "tool_call", + taskId: "T-safe", + toolName: "Bash", + category: "execute", + meta: { durationMs: 12 }, + }); + const rows = queryUsageEvents(db, { taskId: "T-safe" }); + expect(rows).toHaveLength(1); + expect(rows[0].meta).toEqual({ durationMs: 12 }); + // No tool-argument/content fields are present. + const metaKeys = Object.keys(rows[0].meta ?? {}); + expect(metaKeys).not.toContain("command"); + expect(metaKeys).not.toContain("args"); + expect(metaKeys).not.toContain("content"); + }); + + it("skips a malformed event (unknown kind) without throwing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const ok = emitUsageEvent(db, { + // @ts-expect-error intentionally invalid kind + kind: "not_a_real_kind", + taskId: "T-bad", + }); + expect(ok).toBe(false); + expect(queryUsageEvents(db, { taskId: "T-bad" })).toHaveLength(0); + warn.mockRestore(); + }); + + it("range-queries by inclusive ts bounds, ordered ascending", () => { + emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Read", ts: "2026-01-01T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Edit", ts: "2026-01-02T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Bash", ts: "2026-01-03T00:00:00.000Z" }); + + const rows = queryUsageEvents(db, { + from: "2026-01-02T00:00:00.000Z", + to: "2026-01-03T00:00:00.000Z", + }); + expect(rows.map((r) => r.toolName)).toEqual(["Edit", "Bash"]); + }); + + it("counts events grouped by a column over a range", () => { + emitUsageEvent(db, { kind: "tool_call", toolName: "Read", category: "read" }); + emitUsageEvent(db, { kind: "tool_call", toolName: "Grep", category: "read" }); + emitUsageEvent(db, { kind: "tool_call", toolName: "Bash", category: "execute" }); + + const byCategory = countUsageEventsBy(db, "category"); + const map = new Map(byCategory.map((r) => [r.key, r.count])); + expect(map.get("read")).toBe(2); + expect(map.get("execute")).toBe(1); + }); + + it("records a chat-style event with null taskId and a set agentId", () => { + emitUsageEvent(db, { kind: "user_message", taskId: null, agentId: "A-chat" }); + const rows = queryUsageEvents(db, { kind: "user_message" }); + expect(rows).toHaveLength(1); + expect(rows[0].taskId).toBeNull(); + expect(rows[0].agentId).toBe("A-chat"); + }); + + // Migration: seed a DB at the PREVIOUS schema version, run migrate, assert + // the table exists and SCHEMA_VERSION equals the highest migration target. + // Fresh-DB tests cannot catch the early-return bug this guards. + it("creates usage_events when migrating from the previous schema version", () => { + db.exec("DROP INDEX IF EXISTS idxUsageEventsTs"); + db.exec("DROP INDEX IF EXISTS idxUsageEventsTaskId"); + db.exec("DROP INDEX IF EXISTS idxUsageEventsAgentId"); + db.exec("DROP TABLE IF EXISTS usage_events"); + db.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'").run(String(SCHEMA_VERSION - 1)); + + (db as unknown as { migrate: () => void }).migrate(); + + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='usage_events'") + .get() as { name: string } | undefined; + expect(table?.name).toBe("usage_events"); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + + // The migrated table is writable and queryable. + emitUsageEvent(db, { kind: "session_start", taskId: "T-mig", agentId: "A-mig" }); + expect(queryUsageEvents(db, { taskId: "T-mig" })).toHaveLength(1); + }); + + it("SCHEMA_VERSION matches the highest applied migration on a fresh DB", () => { + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + }); +}); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 3ac67d7b67..78332279f6 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 117; +const SCHEMA_VERSION = 118; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -1207,6 +1207,29 @@ CREATE TABLE IF NOT EXISTS todo_items ( CREATE INDEX IF NOT EXISTS idxTodoListsProjectId ON todo_lists(projectId); CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId); CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder); + +-- Normalized, queryable telemetry of agent activity (tool calls, messages, +-- session lifecycle). Fed by emitUsageEvent from the executor/session layer so +-- analytics never has to parse per-task JSONL agent logs at query time. +-- The meta column carries only non-sensitive descriptors (error code, +-- category, duration) -- never tool arguments/content/credentials -- and is +-- capped at write (see usage-events.ts). +CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + kind TEXT NOT NULL, + taskId TEXT, + agentId TEXT, + nodeId TEXT, + model TEXT, + provider TEXT, + toolName TEXT, + category TEXT, + meta TEXT +); +CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts); +CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId); +CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId); `; const TABLE_LEVEL_CONSTRAINT_PREFIXES = new Set([ @@ -4718,6 +4741,38 @@ export class Database { }); } + // Migration 118: Queryable usage_events telemetry table (tool calls, + // messages, session lifecycle). Mirrors the SCHEMA_SQL definition above so + // a fresh-from-SCHEMA_SQL DB and a migrated DB converge on the same table. + if (version < 118) { + this.applyMigration(118, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + kind TEXT NOT NULL, + taskId TEXT, + agentId TEXT, + nodeId TEXT, + model TEXT, + provider TEXT, + toolName TEXT, + category TEXT, + meta TEXT + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId) + `); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dbe4fbc7c5..362127af74 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -517,6 +517,19 @@ export { computeRetrySummary, RETRY_STORM_WARNING_RATIO } from "./retry-summary. export { RetryStormError, serializeRetryStormError } from "./retry-storm-error.js"; export { aggregateAgentTokenUsage } from "./agent-token-usage.js"; export type { AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js"; +export { + emitUsageEvent, + queryUsageEvents, + countUsageEventsBy, + categorizeToolName, + USAGE_EVENT_META_MAX_BYTES, +} from "./usage-events.js"; +export type { + UsageEvent, + UsageEventInput, + UsageEventKind, + UsageEventRangeQuery, +} from "./usage-events.js"; export { STALLED_REVIEW_REENQUEUE_THRESHOLD, STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9917d7f6d4..76c88c3657 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -142,6 +142,7 @@ import { readAgentLogEntriesByTimeRange, } from "./agent-log-file-store.js"; import { truncateAgentLogDetail } from "./agent-log-constants.js"; +import { emitUsageEvent as emitUsageEventToDb, type UsageEventInput } from "./usage-events.js"; import { validateNodeOverrideChange } from "./node-override-guard.js"; import { sanitizeTitle, summarizeTitle } from "./ai-summarize.js"; import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-drift.js"; @@ -11679,6 +11680,22 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } } + /** + * Append a normalized telemetry row to `usage_events` (tool calls, messages, + * session lifecycle) for the Command Center analytics layer. Callers in the + * executor/session layer pass `model`/`provider`/`nodeId`/`category` from the + * session context (see usage-events.ts / KTD3). + * + * **Fail-soft**: the underlying helper swallows malformed events and write + * errors, so this never throws and never aborts the agent-log write or the + * agent hot path. + * + * @returns `true` if a row was inserted, `false` if the event was skipped. + */ + emitUsageEvent(event: UsageEventInput): boolean { + return emitUsageEventToDb(this.db, event); + } + /** * Flush all buffered agent log entries to per-task JSONL files. * Called when the buffer is full or on a timer. diff --git a/packages/core/src/usage-events.ts b/packages/core/src/usage-events.ts new file mode 100644 index 0000000000..a2532df2a8 --- /dev/null +++ b/packages/core/src/usage-events.ts @@ -0,0 +1,280 @@ +import type { Database } from "./db.js"; + +/** + * Queryable telemetry of agent activity (tool calls, messages, session + * lifecycle), persisted to the `usage_events` table (db.ts schema). This is the + * normalized source the Command Center analytics layer reads from, so it does + * not have to parse per-task JSONL agent logs at query time. + * + * Events are appended via {@link emitUsageEvent} from the executor/session layer + * where `model`/`provider`/`nodeId`/`category` are already in scope (see + * KTD3/U1). The append helper is intentionally fail-soft: a malformed event or a + * write error is swallowed so it never aborts the underlying agent-log write or + * the agent hot path. + */ + +/** + * The kind of activity an event records. + * + * - `tool_call` — an agent invoked a tool (agent-log `type: "tool"` maps here; + * `AgentLogType` has no `tool_call` member). + * - `tool_result` / `tool_error` — the tool completed / failed. + * - `user_message` — a human-authored message (chat/CLI sessions). + * - `session_start` / `session_stop` — session lifecycle. + */ +export type UsageEventKind = + | "tool_call" + | "tool_result" + | "tool_error" + | "user_message" + | "session_start" + | "session_stop"; + +const USAGE_EVENT_KINDS: ReadonlySet = new Set([ + "tool_call", + "tool_result", + "tool_error", + "user_message", + "session_start", + "session_stop", +]); + +/** + * Maximum serialized byte size of a `meta` payload. Events whose `meta` + * exceeds this cap are rejected at write (the whole event is skipped) rather + * than truncated, so an oversized payload can never silently land partial data. + */ +export const USAGE_EVENT_META_MAX_BYTES = 4096; + +/** An event to append to `usage_events`. */ +export interface UsageEventInput { + kind: UsageEventKind; + /** ISO-8601 timestamp. Defaults to now when omitted. */ + ts?: string; + taskId?: string | null; + agentId?: string | null; + /** Workflow/session node this event belongs to; null when no node context. */ + nodeId?: string | null; + model?: string | null; + provider?: string | null; + toolName?: string | null; + category?: string | null; + /** + * Non-sensitive descriptors only (error code, category, duration). NEVER tool + * arguments/content or credential-class fields. Capped at + * {@link USAGE_EVENT_META_MAX_BYTES}; over the cap, the event is rejected. + */ + meta?: Record | null; +} + +/** A row read back from `usage_events`. */ +export interface UsageEvent { + id: number; + ts: string; + kind: UsageEventKind; + taskId: string | null; + agentId: string | null; + nodeId: string | null; + model: string | null; + provider: string | null; + toolName: string | null; + category: string | null; + meta: Record | null; +} + +interface UsageEventRow { + id: number; + ts: string; + kind: string; + taskId: string | null; + agentId: string | null; + nodeId: string | null; + model: string | null; + provider: string | null; + toolName: string | null; + category: string | null; + meta: string | null; +} + +/** + * Coarse tool category derived from a tool name, for the Tools analytics area. + * Pure and side-effect free; callers may also pass an explicit `category`. + */ +export function categorizeToolName(toolName: string | null | undefined): string { + if (!toolName) return "other"; + const name = toolName.toLowerCase(); + if (name === "read" || name === "grep" || name === "glob" || name === "ls" || name.includes("search")) { + return "read"; + } + if (name === "edit" || name === "write" || name === "multiedit" || name.includes("notebook")) { + return "edit"; + } + if (name === "bash" || name.includes("exec") || name.includes("command") || name.includes("terminal")) { + return "execute"; + } + if (name.includes("web") || name.includes("fetch") || name.includes("http")) { + return "network"; + } + return "other"; +} + +/** + * Validate and serialize a `meta` payload. Returns the serialized JSON string, + * or throws if it exceeds the byte cap. `null`/`undefined` serialize to `null`. + */ +function serializeMeta(meta: Record | null | undefined): string | null { + if (meta === undefined || meta === null) return null; + const serialized = JSON.stringify(meta); + if (serialized === undefined) return null; + if (Buffer.byteLength(serialized, "utf8") > USAGE_EVENT_META_MAX_BYTES) { + throw new Error( + `usage_events meta payload exceeds ${USAGE_EVENT_META_MAX_BYTES} bytes (got ${Buffer.byteLength(serialized, "utf8")})`, + ); + } + return serialized; +} + +/** + * Append a single usage event. **Fail-soft**: a malformed event (unknown kind), + * an oversized `meta`, or any DB error is logged and swallowed — it must never + * throw, so it cannot abort the underlying agent-log write or the hot path. + * + * @returns `true` if the row was inserted, `false` if the event was skipped. + */ +export function emitUsageEvent(db: Database, event: UsageEventInput): boolean { + try { + if (!event || !USAGE_EVENT_KINDS.has(event.kind)) { + return false; + } + const ts = event.ts ?? new Date().toISOString(); + const meta = serializeMeta(event.meta); + db.prepare( + `INSERT INTO usage_events + (ts, kind, taskId, agentId, nodeId, model, provider, toolName, category, meta) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + ts, + event.kind, + event.taskId ?? null, + event.agentId ?? null, + event.nodeId ?? null, + event.model ?? null, + event.provider ?? null, + event.toolName ?? null, + event.category ?? null, + meta, + ); + return true; + } catch (err) { + console.warn("[fusion] emitUsageEvent skipped a malformed/failed event:", err); + return false; + } +} + +/** Filters for {@link queryUsageEvents}. All bounds are inclusive. */ +export interface UsageEventRangeQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; + kind?: UsageEventKind; + taskId?: string; + agentId?: string; +} + +function rowToUsageEvent(row: UsageEventRow): UsageEvent { + let meta: Record | null = null; + if (row.meta) { + try { + meta = JSON.parse(row.meta) as Record; + } catch { + meta = null; + } + } + return { + id: row.id, + ts: row.ts, + kind: row.kind as UsageEventKind, + taskId: row.taskId, + agentId: row.agentId, + nodeId: row.nodeId, + model: row.model, + provider: row.provider, + toolName: row.toolName, + category: row.category, + meta, + }; +} + +/** + * Range-scan `usage_events` ordered by timestamp ascending. Mirrors the + * windowed-scan shape of `agent-token-usage.ts`, generalized to an arbitrary + * `(from, to)` range with optional kind/task/agent filters. + */ +export function queryUsageEvents(db: Database, query: UsageEventRangeQuery = {}): UsageEvent[] { + const clauses: string[] = []; + const params: Array = []; + if (query.from !== undefined) { + clauses.push("ts >= ?"); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push("ts <= ?"); + params.push(query.to); + } + if (query.kind !== undefined) { + clauses.push("kind = ?"); + params.push(query.kind); + } + if (query.taskId !== undefined) { + clauses.push("taskId = ?"); + params.push(query.taskId); + } + if (query.agentId !== undefined) { + clauses.push("agentId = ?"); + params.push(query.agentId); + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = db + .prepare(`SELECT * FROM usage_events ${where} ORDER BY ts ASC, id ASC`) + .all(...params) as UsageEventRow[]; + return rows.map(rowToUsageEvent); +} + +/** + * Count `usage_events` grouped by a single column over a range. Convenience for + * the analytics aggregators (e.g. tool calls by `category`). + */ +export function countUsageEventsBy( + db: Database, + column: "kind" | "category" | "toolName" | "model" | "provider" | "nodeId" | "agentId", + query: UsageEventRangeQuery = {}, +): Array<{ key: string | null; count: number }> { + const clauses: string[] = []; + const params: Array = []; + if (query.from !== undefined) { + clauses.push("ts >= ?"); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push("ts <= ?"); + params.push(query.to); + } + if (query.kind !== undefined) { + clauses.push("kind = ?"); + params.push(query.kind); + } + if (query.taskId !== undefined) { + clauses.push("taskId = ?"); + params.push(query.taskId); + } + if (query.agentId !== undefined) { + clauses.push("agentId = ?"); + params.push(query.agentId); + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = db + .prepare(`SELECT ${column} AS key, COUNT(*) AS count FROM usage_events ${where} GROUP BY ${column}`) + .all(...params) as Array<{ key: string | null; count: number }>; + return rows; +} diff --git a/packages/engine/src/__tests__/agent-logger.test.ts b/packages/engine/src/__tests__/agent-logger.test.ts index 1503a0909e..961abda4f8 100644 --- a/packages/engine/src/__tests__/agent-logger.test.ts +++ b/packages/engine/src/__tests__/agent-logger.test.ts @@ -496,4 +496,96 @@ describe("AgentLogger", () => { ); }); }); + + // ── usage_events emission (U1) ───────────────────────────────────── + describe("usage_events emission", () => { + function createUsageStore() { + return { + appendAgentLog: vi.fn().mockResolvedValue(undefined), + emitUsageEvent: vi.fn().mockReturnValue(true), + } as unknown as TaskStore & { emitUsageEvent: ReturnType }; + } + + it("emits a tool_call usage event with model/provider/nodeId on tool start", () => { + const store = createUsageStore(); + const logger = new AgentLogger({ store, taskId: "FN-UE-1", agent: "executor" }); + logger.setUsageContext({ + model: "claude-sonnet-4-5", + provider: "anthropic", + nodeId: "node-x", + agentId: "A-1", + }); + + logger.onToolStart("Read", { path: "secret/credentials.env" }); + + expect(store.emitUsageEvent).toHaveBeenCalledTimes(1); + const event = store.emitUsageEvent.mock.calls[0][0]; + expect(event).toMatchObject({ + kind: "tool_call", + taskId: "FN-UE-1", + agentId: "A-1", + nodeId: "node-x", + model: "claude-sonnet-4-5", + provider: "anthropic", + toolName: "Read", + category: "read", + }); + // The tool-argument content (the file path) MUST NOT appear in meta. + const meta = (event.meta ?? {}) as Record; + expect(JSON.stringify(meta)).not.toContain("credentials.env"); + }); + + it("does not emit usage events when no usage context is set", () => { + const store = createUsageStore(); + const logger = new AgentLogger({ store, taskId: "FN-UE-2" }); + logger.onToolStart("Bash", { command: "ls" }); + expect(store.emitUsageEvent).not.toHaveBeenCalled(); + }); + + it("integration: a session calling 3 tools yields 3 tool_call rows with model/provider/nodeId", () => { + const store = createUsageStore(); + const logger = new AgentLogger({ store, taskId: "FN-UE-3", agent: "executor" }); + logger.setUsageContext({ + model: "gpt-5", + provider: "openai", + nodeId: "local", + agentId: "A-3", + }); + + logger.onToolStart("Read", { path: "a.ts" }); + logger.onToolStart("Edit", { path: "a.ts" }); + logger.onToolStart("Bash", { command: "pnpm test" }); + + const toolCalls = store.emitUsageEvent.mock.calls + .map((c) => c[0]) + .filter((e) => e.kind === "tool_call"); + expect(toolCalls).toHaveLength(3); + expect(toolCalls.map((e) => e.toolName)).toEqual(["Read", "Edit", "Bash"]); + for (const event of toolCalls) { + expect(event.model).toBe("gpt-5"); + expect(event.provider).toBe("openai"); + expect(event.nodeId).toBe("local"); + expect(event.agentId).toBe("A-3"); + } + }); + + it("emits tool_result with a duration descriptor and no result payload", () => { + const store = createUsageStore(); + const logger = new AgentLogger({ store, taskId: "FN-UE-4" }); + logger.setUsageContext({ model: "m", provider: "p", nodeId: "n", agentId: "a" }); + + logger.onToolStart("Bash", { command: "echo hi" }); + logger.onToolEnd("Bash", false, "super-secret-output"); + + const endEvent = store.emitUsageEvent.mock.calls + .map((c) => c[0]) + .find((e) => e.kind === "tool_result"); + expect(endEvent).toBeDefined(); + expect(endEvent.toolName).toBe("Bash"); + const meta = (endEvent.meta ?? {}) as Record; + expect(meta).toHaveProperty("durationMs"); + // The tool result payload MUST NOT leak into meta. + expect(JSON.stringify(meta)).not.toContain("super-secret-output"); + }); + }); }); diff --git a/packages/engine/src/agent-logger.ts b/packages/engine/src/agent-logger.ts index 5360353d11..5692b2d940 100644 --- a/packages/engine/src/agent-logger.ts +++ b/packages/engine/src/agent-logger.ts @@ -1,6 +1,24 @@ import type { TaskStore, AgentLogEntry, AgentRole } from "@fusion/core"; +import { categorizeToolName } from "@fusion/core"; import { createLogger } from "./logger.js"; +/** + * Session-context fields that let the logger emit normalized `usage_events` + * telemetry (KTD3/U1) alongside its agent-log writes. Populated by the + * executor/session layer where `model`/`provider`/`nodeId` are resolved; when + * absent, no usage events are emitted (the agent-log behavior is unchanged). + */ +export interface AgentLoggerUsageContext { + /** Resolved model id for the running session, when known. */ + model?: string | null; + /** Resolved provider for the running session, when known. */ + provider?: string | null; + /** Workflow/session node the session is routed to, when known. */ + nodeId?: string | null; + /** The agent id producing the activity, when known. */ + agentId?: string | null; +} + /** Default byte threshold before an automatic flush. */ const FLUSH_SIZE_BYTES = 1024; /** Default timer interval (ms) for periodic flush of small writes. */ @@ -68,6 +86,12 @@ export interface AgentLoggerOptions { flushSizeBytes?: number; /** Timer interval (ms) for periodic flush. Defaults to 500. */ flushIntervalMs?: number; + /** + * When provided (with `store` + `taskId`), tool start/end callbacks also emit + * normalized `usage_events` telemetry carrying the session's model/provider/ + * node context. Omit to leave agent-log behavior unchanged. + */ + usageContext?: AgentLoggerUsageContext; } /** @@ -113,6 +137,9 @@ export class AgentLogger { private readonly log = createLogger("agent-logger"); private readonly persistAgentToolOutput: boolean; private readonly persistAgentThinkingLog: boolean; + private usageContext?: AgentLoggerUsageContext; + /** Tracks tool start times so tool_result/tool_error can record a duration. */ + private readonly toolStartedAt = new Map(); constructor(options: AgentLoggerOptions) { this.store = options.store; @@ -125,6 +152,7 @@ export class AgentLogger { this.flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS; this.persistAgentToolOutput = options.persistAgentToolOutput !== false; this.persistAgentThinkingLog = options.persistAgentThinkingLog === true; + this.usageContext = options.usageContext; // Bind callbacks so they can be passed directly as function references this.onText = this.onText.bind(this); @@ -133,6 +161,39 @@ export class AgentLogger { this.onToolEnd = this.onToolEnd.bind(this); } + /** + * Set (or update) the session context used to emit `usage_events` telemetry. + * The executor resolves `model`/`provider`/`nodeId` after the logger is + * constructed, so it calls this once those are known. + */ + setUsageContext(context: AgentLoggerUsageContext | undefined): void { + this.usageContext = context; + } + + /** + * Emit a normalized tool `usage_events` row through the task store, if a store, + * taskId, and usage context are all available. Fail-soft via store.emitUsageEvent. + */ + private emitToolUsageEvent( + kind: "tool_call" | "tool_result" | "tool_error", + toolName: string, + meta?: Record, + ): void { + const ctx = this.usageContext; + if (!ctx || !this.store || !this.taskId) return; + this.store.emitUsageEvent({ + kind, + taskId: this.taskId, + agentId: ctx.agentId ?? null, + nodeId: ctx.nodeId ?? null, + model: ctx.model ?? null, + provider: ctx.provider ?? null, + toolName, + category: categorizeToolName(toolName), + ...(meta !== undefined && { meta }), + }); + } + /** * Callback for agent text deltas. Buffers text and flushes on size * threshold or after a timer interval. Compatible with `AgentOptions.onText`. @@ -178,6 +239,10 @@ export class AgentLogger { this.flushThinkingBuffer(); const detail = summarizeToolArgs(name, args); this.writeEntry(name, "tool", detail, `Failed to log tool start "${name}" for ${this.taskId}`); + // agent-log type "tool" maps to usage_events kind "tool_call". meta carries + // only non-sensitive descriptors (category) — never the tool arguments. + this.toolStartedAt.set(name, Date.now()); + this.emitToolUsageEvent("tool_call", name); } /** @@ -196,6 +261,18 @@ export class AgentLogger { detail = typeof result === "string" ? result : JSON.stringify(result); } this.writeEntry(name, type, detail, `Failed to log tool end "${name}" (${type}) for ${this.taskId}`); + // Record completion as tool_result/tool_error with a duration descriptor. + // meta NEVER includes the tool result payload — only non-sensitive metrics. + const startedAt = this.toolStartedAt.get(name); + if (startedAt !== undefined) this.toolStartedAt.delete(name); + const meta: Record = {}; + if (startedAt !== undefined) meta.durationMs = Date.now() - startedAt; + if (isError) meta.isError = true; + this.emitToolUsageEvent( + isError ? "tool_error" : "tool_result", + name, + Object.keys(meta).length > 0 ? meta : undefined, + ); } /** diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 6fae544a68..72743b4bbc 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -7689,6 +7689,17 @@ export class TaskExecutor { const executorFallbackModelId = settings.fallbackModelId; const executorThinkingLevel = detail.thinkingLevel ?? settings.defaultThinkingLevel; + // U1 telemetry: now that the session model/provider/node are resolved, + // give the agent logger the context it needs to emit usage_events tool + // rows (KTD3). nodeId is sourced from the routed/effective node, null + // when the task has no node context. + agentLogger.setUsageContext({ + model: executorModelId ?? null, + provider: executorProvider ?? null, + nodeId: detail.effectiveNodeId ?? detail.nodeId ?? null, + agentId: engineRunContext.agentId ?? null, + }); + // Determine whether we're resuming a previous session (pause/resume) // or starting fresh. Use file-based sessions so conversation state // persists across pause/unpause cycles. Resume is allowed only when diff --git a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts index 55de7b1987..48bd180997 100644 --- a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +++ b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts @@ -743,10 +743,10 @@ describe("RoadmapStore", () => { }); describe("schema version", () => { - it("schema version is 117 after init", () => { + it("schema version is 118 after init", () => { // Tracks @fusion/core's SCHEMA_VERSION (the roadmap store layers on core's // Database). Bump this in lockstep when core adds a migration. - expect(db.getSchemaVersion()).toBe(117); + expect(db.getSchemaVersion()).toBe(118); }); });