diff --git a/.changeset/fn-6704-command-center-loc.md b/.changeset/fn-6704-command-center-loc.md new file mode 100644 index 0000000000..0be48faa95 --- /dev/null +++ b/.changeset/fn-6704-command-center-loc.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Populate Command Center Productivity Lines changed from merge-time commit association diff stats when available. diff --git a/docs/architecture.md b/docs/architecture.md index 19f777839c..cf6892c863 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -855,7 +855,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces Key server capabilities: - REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings - System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination -- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/live`) are project-scoped dashboard routes; `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume. +- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/live`) are project-scoped dashboard routes. `/productivity` reads Lines changed from nullable `task_commit_associations.additions`/`deletions` merge-time diff stats and keeps the unavailable sentinel when no in-range association has stats. `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume. - Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation - Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md) - `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup. @@ -1458,6 +1458,8 @@ Dashboard session-diff route registration (`packages/dashboard/src/routes/regist - `legacy` = recovered via legacy task-id/subject matching - `ambiguous` = manual reconciliation where historical task-id attribution could be misleading +Commit associations also carry optional `additions`/`deletions` shortstat counts captured by merge paths. These nullable fields are the Command Center Productivity LOC source: analytics sum additions + deletions only when at least one in-range row has stats, and preserve the `—` unavailable sentinel when all matching rows are `NULL` so unknown historical data is never rendered as `0`. + ### Done-task files-changed sources of truth Done-task file-count surfaces intentionally distinguish three data sources: diff --git a/docs/storage.md b/docs/storage.md index 6745137cb2..db538f09e3 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -391,9 +391,12 @@ The `tasks.githubTracking` JSON column stores per-task GitHub tracking state (`e The `tasks.sourceIssueClosedAt` column (migration 122) backs `TaskSourceIssue.closedAt`, a nullable ISO-8601 timestamp for the originating external issue's real close time. Going forward, the GitHub source-issue reconciler fills it when it closes the linked issue itself or observes GitHub's `closed_at`/`closedAt` value. Historical GitHub-imported `done`/`archived` rows that still have `NULL` can be filled retroactively by the optional manual `POST /api/git/github/backfill-source-issue-closed-at` sweep, now exposed as **Backfill exact close times** in the Command Center GitHub area's Fixed by Fusion card. The sweep is idempotent, paginated, writes only real GitHub `closed_at` values, reports `scanned`/`filled`/`skipped`/`errors`, and never overwrites an existing timestamp or runs automatically. Command Center "Fixed by Fusion" analytics read this exact timestamp when available and fall back to `updatedAt` only when it has not been observed. The `tasks.tokenUsage*` columns store cumulative per-task token usage for analytics. `tokenUsageModelProvider` and `tokenUsageModelId` are analytics-only snapshots of the actually-used runtime model recorded when usage is accumulated; they let Command Center group and price resolved-via-settings usage by provider/model without writing the task-level `modelProvider` / `modelId` own-model override fields that control future model resolution. Cost attribution reads the snapshot first and falls back to the legacy own-model columns for pre-snapshot rows. + +The `task_commit_associations.additions` and `task_commit_associations.deletions` columns (migration 123) store nullable merge-time git shortstat counts for the associated commit. Command Center Productivity uses `SUM(additions + deletions)` as the Lines changed source when at least one in-range association has non-null stats. `NULL` means stats were unknown or unavailable for that association, not zero; ranges with no non-null stats keep the unavailable `—` sentinel instead of reporting `0`. | `config` | Single-row project configuration (`nextId`, settings payload, workflow step counters). | | `workflow_steps` | Workflow step definitions (`prompt`/`script`) with phase, template metadata, and model overrides. | | `activityLog` | Per-project activity/event log with timestamp/type/task indexes. | +| `task_commit_associations` | Commit-to-task-lineage associations for canonical and legacy landed-commit attribution. Includes nullable `additions`/`deletions` diff-stat columns captured at merge time for Command Center Productivity LOC; `NULL` means stats unknown, not zero. | | `archivedTasks` | Archived task snapshots (compact JSON payload + archive timestamp). | | `automations` | Scheduled automation definitions, run state, and run history. | | `agents` | Agent registry/state/task assignment metadata. | diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index f48ad76ed5..5f6c372e5e 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -721,7 +721,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(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -783,7 +783,7 @@ describe("schema migration", () => { sourceIssueUrl: "https://github.com/runfusion/fusion/issues/10", sourceIssueClosedAt: null, }); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -816,7 +816,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -866,7 +866,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -895,7 +895,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(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -936,7 +936,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(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -970,7 +970,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(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -1007,7 +1007,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(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -1068,7 +1068,7 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -1106,7 +1106,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(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -1188,7 +1188,7 @@ describe("schema migration", () => { expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); expect(indexNames).toContain("idx_cli_sessions_project_state"); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -1220,7 +1220,7 @@ describe("schema migration", () => { .all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -1230,7 +1230,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(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -1287,20 +1287,20 @@ describe("schema migration", () => { .get() as { migrated_fragment_id: string | null }; expect(stepRow.migrated_fragment_id).toBeNull(); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); it("migration 109 is idempotent on re-init", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); 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(122); + expect(reopened.getSchemaVersion()).toBe(123); 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 e92550b846..dfb5980245 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(122); + expect(db.getSchemaVersion()).toBe(123); }); 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(122); + expect(db.getSchemaVersion()).toBe(123); }); 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(122); + expect(db.getSchemaVersion()).toBe(123); // 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(122); + expect(db.getSchemaVersion()).toBe(123); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); db.close(); }); @@ -1531,7 +1531,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); 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(122); + expect(db.getSchemaVersion()).toBe(123); 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(122); + expect(db.getSchemaVersion()).toBe(123); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1923,7 +1923,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1997,7 +1997,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); 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" }]); @@ -2021,7 +2021,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); 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" }]); @@ -2125,7 +2125,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2308,6 +2308,48 @@ describe("schema migrations", () => { db.close(); }); + it("migration v123 adds nullable task commit association diff-stat columns", () => { + tmpDir = makeTmpDir(); + const fusionDir = join(tmpDir, ".fusion"); + const localDb = new Database(fusionDir); + + localDb.exec(` + CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE IF NOT EXISTS task_commit_associations ( + id TEXT PRIMARY KEY, + taskLineageId TEXT NOT NULL, + taskIdSnapshot TEXT NOT NULL, + commitSha TEXT NOT NULL, + commitSubject TEXT NOT NULL, + authoredAt TEXT NOT NULL, + matchedBy TEXT NOT NULL, + confidence TEXT NOT NULL, + note TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + UNIQUE(taskLineageId, commitSha, matchedBy) + ); + `); + localDb.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '122')"); + localDb.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + localDb.exec(`INSERT INTO task_commit_associations + (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, createdAt, updatedAt) + VALUES ('assoc-1', 'lin-1', 'FN-6704', 'abc123', 'subject', '2026-06-19T00:00:00.000Z', 'canonical-lineage-trailer', 'canonical', '2026-06-19T00:00:00.000Z', '2026-06-19T00:00:00.000Z')`); + + localDb.init(); + + expect(localDb.getSchemaVersion()).toBe(123); + const columns = localDb.prepare("PRAGMA table_info(task_commit_associations)").all() as Array<{ name: string; notnull: number; dflt_value: string | null }>; + const additions = columns.find((column) => column.name === "additions"); + const deletions = columns.find((column) => column.name === "deletions"); + expect(additions).toMatchObject({ notnull: 0, dflt_value: null }); + expect(deletions).toMatchObject({ notnull: 0, dflt_value: null }); + const row = localDb.prepare("SELECT additions, deletions FROM task_commit_associations WHERE id = 'assoc-1'").get() as { additions: number | null; deletions: number | null }; + expect(row).toEqual({ additions: null, deletions: null }); + + localDb.close(); + }); + it("migration v74 adds tokenUsageCacheWriteTokens without data loss", () => { tmpDir = makeTmpDir(); const fusionDir = join(tmpDir, ".fusion"); @@ -2344,7 +2386,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(122); + expect(localDb.getSchemaVersion()).toBe(123); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2655,7 +2697,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(122); + expect(db.getSchemaVersion()).toBe(123); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2809,7 +2851,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(122); + expect(migrated.getSchemaVersion()).toBe(123); 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); @@ -2840,7 +2882,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(122); + expect(fresh.getSchemaVersion()).toBe(123); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2868,7 +2910,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(122); + expect(migrated.getSchemaVersion()).toBe(123); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2894,7 +2936,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(122); + expect(fresh.getSchemaVersion()).toBe(123); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2928,7 +2970,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(122); + expect(migrated.getSchemaVersion()).toBe(123); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2954,7 +2996,7 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(122); + expect(fresh.getSchemaVersion()).toBe(123); const tables = new Set( ( fresh @@ -3007,7 +3049,7 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => { // creation while table + row assertions still pass. Assert the real index // names the v120 migration creates (idxDeployments*, idxIncidents*) so that // regression is caught. - expect(migrated.getSchemaVersion()).toBe(122); + expect(migrated.getSchemaVersion()).toBe(123); const tables = new Set( ( migrated @@ -3066,7 +3108,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(122); + expect(migrated.getSchemaVersion()).toBe(123); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -3093,7 +3135,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(122); + expect(fresh.getSchemaVersion()).toBe(123); 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__/productivity-analytics.test.ts b/packages/core/src/__tests__/productivity-analytics.test.ts index f61622902f..5db312da2e 100644 --- a/packages/core/src/__tests__/productivity-analytics.test.ts +++ b/packages/core/src/__tests__/productivity-analytics.test.ts @@ -14,13 +14,19 @@ function insertTaskWithFiles(db: Database, id: string, files: string[], updatedA ).run(id, updatedAt, updatedAt, JSON.stringify(files)); } -function insertCommit(db: Database, id: string, sha: string, authoredAt: string): void { +function insertCommit( + db: Database, + id: string, + sha: string, + authoredAt: string, + stats: { additions?: number | null; deletions?: number | null } = {}, +): void { db.prepare( `INSERT INTO task_commit_associations (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, - matchedBy, confidence, createdAt, updatedAt) - VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?)`, - ).run(id, sha, authoredAt, authoredAt, authoredAt); + matchedBy, confidence, additions, deletions, createdAt, updatedAt) + VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?, ?, ?)`, + ).run(id, sha, authoredAt, stats.additions ?? null, stats.deletions ?? null, authoredAt, authoredAt); } function insertPr(db: Database, id: string, createdAtMs: number): void { @@ -74,13 +80,44 @@ describe("productivity-analytics", () => { expect(result.pullRequests).toBe(2); }); - it("reports LOC as unavailable (null + unavailable:true), never 0", () => { + it("reports LOC as unavailable (null + unavailable:true), never 0 when no stats exist", () => { insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c-null", "sha-null", "2026-03-01T00:00:00.000Z"); const result = aggregateProductivityAnalytics(db, {}); expect(result.loc).toEqual({ value: null, unavailable: true }); expect(result.loc.value).not.toBe(0); }); + it("sums additions and deletions into LOC when commit stats exist", () => { + insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z", { additions: 10, deletions: 5 }); + insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z", { additions: 3, deletions: 2 }); + insertCommit(db, "c-old", "sha-old", "2025-01-01T00:00:00.000Z", { additions: 100, deletions: 100 }); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.commits).toBe(2); + expect(result.loc).toEqual({ value: 20, unavailable: false }); + }); + + it("keeps the LOC sentinel when in-range commit rows have only null stats", () => { + insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z", { additions: null, deletions: null }); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.commits).toBe(2); + expect(result.loc).toEqual({ value: null, unavailable: true }); + expect(result.loc.value).not.toBe(0); + }); + + it("sums only valued LOC rows while allowing partial commit-stat coverage", () => { + insertCommit(db, "c-null", "sha-null", "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c-additions", "sha-additions", "2026-03-02T00:00:00.000Z", { additions: 7 }); + insertCommit(db, "c-deletions", "sha-deletions", "2026-03-03T00:00:00.000Z", { deletions: 4 }); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.commits).toBe(3); + expect(result.loc).toEqual({ value: 11, unavailable: false }); + }); + it("empty range returns zeroed structures, not nulls", () => { insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z"); insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z"); diff --git a/packages/core/src/__tests__/store-upsert.test.ts b/packages/core/src/__tests__/store-upsert.test.ts index b66a667b00..b753c95610 100644 --- a/packages/core/src/__tests__/store-upsert.test.ts +++ b/packages/core/src/__tests__/store-upsert.test.ts @@ -35,6 +35,46 @@ describe("TaskStore", () => { const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args); const taskDir = (taskId: string) => join(rootDir, ".fusion", "tasks", taskId); + describe("task commit association diff stats", () => { + it("round-trips nullable additions and deletions without coercing unknown stats to zero", async () => { + const withStats = await store.upsertTaskCommitAssociation({ + taskLineageId: "lineage-loc-stats", + taskIdSnapshot: "FN-6704", + commitSha: "abc123", + commitSubject: "feat: capture stats", + authoredAt: "2026-06-19T00:00:00.000Z", + matchedBy: "canonical-lineage-trailer", + confidence: "canonical", + additions: 12, + deletions: 3, + }); + expect(withStats.additions).toBe(12); + expect(withStats.deletions).toBe(3); + + await store.upsertTaskCommitAssociation({ + taskLineageId: "lineage-loc-stats", + taskIdSnapshot: "FN-6704", + commitSha: "def456", + commitSubject: "fix: unknown stats", + authoredAt: "2026-06-19T01:00:00.000Z", + matchedBy: "canonical-lineage-trailer", + confidence: "canonical", + }); + + const associations = await store.getTaskCommitAssociationsByLineageId("lineage-loc-stats"); + const persistedWithStats = associations.find((association) => association.commitSha === "abc123"); + const persistedUnknownStats = associations.find((association) => association.commitSha === "def456"); + expect(persistedWithStats).toMatchObject({ additions: 12, deletions: 3 }); + expect(persistedUnknownStats?.additions).toBeUndefined(); + expect(persistedUnknownStats?.deletions).toBeUndefined(); + + const rawUnknown = (store as any).db.prepare( + `SELECT additions, deletions FROM task_commit_associations WHERE commitSha = ?`, + ).get("def456") as { additions: number | null; deletions: number | null }; + expect(rawUnknown).toEqual({ additions: null, deletions: null }); + }); + }); + describe("upsertTask regression coverage", () => { it("creates tasks successfully on a fresh database schema", async () => { const freshRoot = makeTmpDir(); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 4b45956af5..6c72937a23 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 = 122; +const SCHEMA_VERSION = 123; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -476,6 +476,8 @@ CREATE TABLE IF NOT EXISTS task_commit_associations ( matchedBy TEXT NOT NULL CHECK (matchedBy IN ('canonical-lineage-trailer', 'legacy-task-id-trailer', 'legacy-subject', 'manual-reconciliation')), confidence TEXT NOT NULL CHECK (confidence IN ('canonical', 'legacy', 'ambiguous')), note TEXT, + additions INTEGER, + deletions INTEGER, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, UNIQUE(taskLineageId, commitSha, matchedBy) @@ -4965,6 +4967,16 @@ export class Database { }); } + // Migration 123: nullable merge-time diff stats for Command Center LOC analytics. + // FNXC:CommandCenterProductivity 2026-06-19-00:00: + // Productivity LOC must distinguish unknown historical commit stats from real zero-line commits. Store merge-time additions/deletions as nullable columns with no default; null means stats were unavailable, not zero. + if (version < 123) { + this.applyMigration(123, () => { + this.addColumnIfMissing("task_commit_associations", "additions", "INTEGER"); + this.addColumnIfMissing("task_commit_associations", "deletions", "INTEGER"); + }); + } + } /** diff --git a/packages/core/src/productivity-analytics.ts b/packages/core/src/productivity-analytics.ts index dcb5edba39..aff8a976a6 100644 --- a/packages/core/src/productivity-analytics.ts +++ b/packages/core/src/productivity-analytics.ts @@ -3,14 +3,15 @@ import type { Database } from "./db.js"; /** * Productivity analytics: files modified (count + language distribution) from * `tasks.modifiedFiles`, commit associations from `task_commit_associations`, - * pull requests from `pull_requests`, and LOC from commit diff stats. + * pull requests from `pull_requests`, and LOC from merge-time commit diff stats. * - * **LOC availability.** Fusion does not currently persist commit diff line - * stats (the `task_commit_associations` schema has no additions/deletions - * columns). LOC is therefore reported as the documented unavailable sentinel — - * `{ value: null, unavailable: true }` — **never `0`**, so a missing data source - * is never mistaken for "zero lines changed". When a diff-stats source is added, - * fill {@link LocSummary.value} and clear `unavailable`. + * **LOC availability.** Fusion persists nullable `additions`/`deletions` on + * `task_commit_associations` when merge paths can capture git shortstat output. + * LOC is reported as a real value only when at least one in-range association + * has non-null stats. If the range has no recorded stats, the documented + * unavailable sentinel — `{ value: null, unavailable: true }` — is preserved, + * **never `0`**, so missing historical data is not mistaken for "zero lines + * changed". * * Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by * `updatedAt` (the last time the task — and therefore its modifiedFiles — was @@ -32,8 +33,8 @@ export interface LanguageCount { } /** - * LOC summary. `value` is null and `unavailable` true until a commit diff-stats - * source exists — never `0`. + * LOC summary. `value` is null and `unavailable` true when no in-range commit + * association has diff stats — never `0` for unknown data. */ export interface LocSummary { value: number | null; @@ -51,7 +52,7 @@ export interface ProductivityAnalytics { commits: number; /** Rows in `pull_requests` in range. */ pullRequests: number; - /** LOC from commit diff stats — unavailable until a source exists. */ + /** LOC from commit association diff stats when at least one in-range row has stats. */ loc: LocSummary; } @@ -59,6 +60,13 @@ interface CountRow { count: number; } +interface CommitStatsRow { + count: number; + additions: number | null; + deletions: number | null; + statsRows: number; +} + interface ModifiedFilesRow { modifiedFiles: string | null; } @@ -73,8 +81,8 @@ function languageOf(path: string): string { /** * Aggregate productivity metrics over a date range. Empty range yields zeroed - * structures (not nulls); LOC is always the unavailable sentinel until a - * diff-stats source is wired. + * structures (not nulls); LOC remains the unavailable sentinel unless at least + * one in-range commit association carries diff stats. */ export function aggregateProductivityAnalytics( db: Database, @@ -135,13 +143,20 @@ export function aggregateProductivityAnalytics( } const commitWhere = commitClauses.length > 0 ? `WHERE ${commitClauses.join(" AND ")}` : ""; - const commits = ( - db - .prepare( - `SELECT COUNT(*) AS count FROM task_commit_associations ${commitWhere}`, - ) - .get(...commitParams) as CountRow - ).count; + const commitStats = db + .prepare( + `SELECT + COUNT(*) AS count, + SUM(additions) AS additions, + SUM(deletions) AS deletions, + COUNT(CASE WHEN additions IS NOT NULL OR deletions IS NOT NULL THEN 1 END) AS statsRows + FROM task_commit_associations ${commitWhere}`, + ) + .get(...commitParams) as CommitStatsRow; + const commits = commitStats.count; + const loc: LocSummary = commitStats.statsRows > 0 + ? { value: (commitStats.additions ?? 0) + (commitStats.deletions ?? 0), unavailable: false } + : { value: null, unavailable: true }; // Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so // convert the ISO bounds to epoch ms for comparison. @@ -169,7 +184,6 @@ export function aggregateProductivityAnalytics( byLanguage, commits, pullRequests, - // No commit diff-stats source yet — unavailable, never 0. - loc: { value: null, unavailable: true }, + loc, }; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d3400dec7f..04a05d1416 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -560,6 +560,8 @@ interface TaskCommitAssociationRow { matchedBy: TaskCommitAssociationMatchSource; confidence: TaskCommitAssociationConfidence; note: string | null; + additions: number | null; + deletions: number | null; createdAt: string; updatedAt: string; } @@ -16202,14 +16204,16 @@ ${notificationsSection}`; }); this.db.prepare( `INSERT INTO task_commit_associations - (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, additions, deletions, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET taskIdSnapshot = excluded.taskIdSnapshot, commitSubject = excluded.commitSubject, authoredAt = excluded.authoredAt, confidence = excluded.confidence, note = excluded.note, + additions = excluded.additions, + deletions = excluded.deletions, updatedAt = excluded.updatedAt`, ).run( association.id, @@ -16221,6 +16225,8 @@ ${notificationsSection}`; association.matchedBy, association.confidence, association.note ?? null, + association.additions ?? null, + association.deletions ?? null, association.createdAt, association.updatedAt, ); @@ -16231,7 +16237,12 @@ ${notificationsSection}`; const rows = this.db.prepare( `SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`, ).all(lineageId) as TaskCommitAssociationRow[]; - return rows.map((row) => normalizeTaskCommitAssociation({ ...row, note: row.note ?? undefined })); + return rows.map((row) => normalizeTaskCommitAssociation({ + ...row, + note: row.note ?? undefined, + additions: row.additions ?? undefined, + deletions: row.deletions ?? undefined, + })); } async replaceLegacyTaskCommitAssociations( diff --git a/packages/core/src/task-lineage.ts b/packages/core/src/task-lineage.ts index 3ce3013176..14ced13080 100644 --- a/packages/core/src/task-lineage.ts +++ b/packages/core/src/task-lineage.ts @@ -42,6 +42,8 @@ export function normalizeTaskCommitAssociation( return { ...row, note: row.note?.trim() || undefined, + additions: row.additions ?? undefined, + deletions: row.deletions ?? undefined, confidence: row.confidence ?? classifyTaskCommitAssociationConfidence(row.matchedBy), }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e4959547dd..39c31184cc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4387,6 +4387,8 @@ export interface TaskCommitAssociation { matchedBy: TaskCommitAssociationMatchSource; confidence: TaskCommitAssociationConfidence; note?: string; + additions?: number; + deletions?: number; createdAt: string; updatedAt: string; } diff --git a/packages/engine/src/__tests__/merger-commit-association-stats.test.ts b/packages/engine/src/__tests__/merger-commit-association-stats.test.ts new file mode 100644 index 0000000000..e4f315adaf --- /dev/null +++ b/packages/engine/src/__tests__/merger-commit-association-stats.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TaskStore } from "@fusion/core"; +import { recordCommitAssociationFromHead } from "../merger.js"; + +const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0; +const describeIfGit = hasGit ? describe : describe.skip; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +function makeRepo(): string { + const repo = mkdtempSync(join(tmpdir(), "fusion-merger-commit-assoc-")); + git(repo, "git init -b main"); + git(repo, "git config user.email fusion@example.com"); + git(repo, "git config user.name Fusion"); + writeFileSync(join(repo, "file.txt"), "one\ntwo\n"); + git(repo, "git add file.txt"); + git(repo, "git commit -m 'initial commit'"); + writeFileSync(join(repo, "file.txt"), "one\ntwo\nthree\nfour\n"); + git(repo, "git add file.txt"); + git(repo, "git commit -m 'update file'"); + return repo; +} + +function makeStore(): Pick { + return { + upsertTaskCommitAssociation: vi.fn(async (association) => ({ + id: "assoc-1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...association, + })), + } as Pick; +} + +describeIfGit("recordCommitAssociationFromHead", () => { + const cleanup: string[] = []; + let originalPath: string | undefined; + + afterEach(() => { + if (originalPath !== undefined) process.env.PATH = originalPath; + while (cleanup.length > 0) { + rmSync(cleanup.pop()!, { recursive: true, force: true }); + } + }); + + it("persists HEAD diff stats as additions and deletions", async () => { + const repo = makeRepo(); + cleanup.push(repo); + const store = makeStore(); + + await recordCommitAssociationFromHead(store as TaskStore, repo, "FN-6704", "lineage-1"); + + expect(store.upsertTaskCommitAssociation).toHaveBeenCalledWith(expect.objectContaining({ + taskLineageId: "lineage-1", + taskIdSnapshot: "FN-6704", + commitSubject: "update file", + additions: 2, + deletions: 0, + })); + }); + + it("persists the association without stats when shortstat capture fails", async () => { + const repo = makeRepo(); + const fakeBin = mkdtempSync(join(tmpdir(), "fusion-fake-git-")); + cleanup.push(repo, fakeBin); + const realGit = execSync("command -v git", { encoding: "utf-8" }).trim(); + const fakeGit = join(fakeBin, "git"); + writeFileSync(fakeGit, `#!/bin/sh\nif [ "$1" = "show" ] && [ "$2" = "--shortstat" ]; then\n echo shortstat failed >&2\n exit 42\nfi\nexec ${realGit} "$@"\n`); + chmodSync(fakeGit, 0o755); + originalPath = process.env.PATH; + process.env.PATH = `${fakeBin}:${originalPath ?? ""}`; + const store = makeStore(); + + await recordCommitAssociationFromHead(store as TaskStore, repo, "FN-6704", "lineage-1"); + + expect(store.upsertTaskCommitAssociation).toHaveBeenCalledWith(expect.objectContaining({ + taskLineageId: "lineage-1", + taskIdSnapshot: "FN-6704", + commitSubject: "update file", + additions: undefined, + deletions: undefined, + })); + }); +}); diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index e4d3e96cc9..de07b7f074 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -1327,6 +1327,8 @@ async function finalizeMerged( authoredAt: mergedAt, matchedBy: "canonical-lineage-trailer", confidence: "canonical", + additions: insertions, + deletions, }).catch(() => undefined); } } diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index e04354fe64..feee8e6cd2 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -3833,7 +3833,7 @@ async function generateAiMergeSubject( * is a denormalized convenience for lineage lookups, not a correctness * invariant, so a missed write must not block the merge. */ -async function recordCommitAssociationFromHead( +export async function recordCommitAssociationFromHead( store: TaskStore, rootDir: string, taskId: string, @@ -3857,6 +3857,17 @@ async function recordCommitAssociationFromHead( ); return; } + let additions: number | undefined; + let deletions: number | undefined; + try { + const shortstat = (await execAsync("git show --shortstat --format= HEAD", { cwd: rootDir })).stdout; + const parsed = parseShortstatSummary(shortstat); + additions = parsed.insertions; + deletions = parsed.deletions; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + mergerLog.warn(`${taskId}: commit-association diff stats unavailable; persisting lineage without LOC stats (${message})`); + } await store.upsertTaskCommitAssociation({ taskLineageId: lineageId, taskIdSnapshot: taskId, @@ -3865,6 +3876,8 @@ async function recordCommitAssociationFromHead( authoredAt, matchedBy: "canonical-lineage-trailer", confidence: "canonical", + additions, + deletions, }); } @@ -10385,6 +10398,8 @@ export async function aiMergeTask( authoredAt: mergeDetails.mergedAt ?? new Date().toISOString(), matchedBy: "canonical-lineage-trailer", confidence: "canonical", + additions: mergeDetails.insertions, + deletions: mergeDetails.deletions, }); } }