FN-6704: capture merge LOC for productivity analytics

Capture merge-time diff stats so Command Center Productivity can report real Lines changed when available.

- Add nullable additions/deletions columns to task commit associations with migration and storage normalization.
- Persist git shortstat counts from merge association paths without blocking merges when stats are unavailable.
- Aggregate Productivity LOC from recorded commit stats while preserving the unavailable sentinel for historical unknowns.
- Cover migration, store upsert, analytics, and merger association stats behavior with tests and documentation.

Files changed:
 .changeset/fn-6704-command-center-loc.md           |  5 ++
 docs/architecture.md                               |  4 +-
 docs/storage.md                                    |  3 +
 packages/core/src/__tests__/db-migrate.test.ts     | 32 ++++----
 packages/core/src/__tests__/db.test.ts             | 90 ++++++++++++++++------
 .../src/__tests__/productivity-analytics.test.ts   | 47 +++++++++--
 packages/core/src/__tests__/store-upsert.test.ts   | 40 ++++++++++
 packages/core/src/db.ts                            | 14 +++-
 packages/core/src/productivity-analytics.ts        | 56 +++++++++-----
 packages/core/src/store.ts                         | 17 +++-
 packages/core/src/task-lineage.ts                  |  2 +
 packages/core/src/types.ts                         |  2 +
 .../merger-commit-association-stats.test.ts        | 90 ++++++++++++++++++++++
 packages/engine/src/merger-ai.ts                   |  2 +
 packages/engine/src/merger.ts                      | 17 +++-
 15 files changed, 349 insertions(+), 72 deletions(-)

Fusion-Task-Id: FN-6704

Fusion-Task-Lineage: f079fecb-eade-4318-bc55-23aa48097b4a
This commit is contained in:
gsxdsm
2026-06-19 06:51:57 -07:00
parent 0ba4bbebf4
commit 47e7b4a150
15 changed files with 349 additions and 72 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Populate Command Center Productivity Lines changed from merge-time commit association diff stats when available.

View File

@@ -855,7 +855,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
Key server capabilities: Key server capabilities:
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings - 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 - 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 - 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) - 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. - `/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 - `legacy` = recovered via legacy task-id/subject matching
- `ambiguous` = manual reconciliation where historical task-id attribution could be misleading - `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 files-changed sources of truth
Done-task file-count surfaces intentionally distinguish three data sources: Done-task file-count surfaces intentionally distinguish three data sources:

View File

@@ -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.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 `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). | | `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. | | `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. | | `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). | | `archivedTasks` | Archived task snapshots (compact JSON payload + archive timestamp). |
| `automations` | Scheduled automation definitions, run state, and run history. | | `automations` | Scheduled automation definitions, run state, and run history. |
| `agents` | Agent registry/state/task assignment metadata. | | `agents` | Agent registry/state/task assignment metadata. |

View File

@@ -721,7 +721,7 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull(); expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -783,7 +783,7 @@ describe("schema migration", () => {
sourceIssueUrl: "https://github.com/runfusion/fusion/issues/10", sourceIssueUrl: "https://github.com/runfusion/fusion/issues/10",
sourceIssueClosedAt: null, sourceIssueClosedAt: null,
}); });
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -816,7 +816,7 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" },
]); ]);
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -866,7 +866,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0, reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0, reviewerFallbackRetryCount: 0,
}); });
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -895,7 +895,7 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -936,7 +936,7 @@ describe("schema migration", () => {
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -970,7 +970,7 @@ describe("schema migration", () => {
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
]); ]);
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -1007,7 +1007,7 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -1068,7 +1068,7 @@ describe("schema migration", () => {
expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn).toBeDefined();
expect(customFieldsColumn?.dflt_value).toBe("'{}'"); expect(customFieldsColumn?.dflt_value).toBe("'{}'");
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -1106,7 +1106,7 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>; 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(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -1188,7 +1188,7 @@ describe("schema migration", () => {
expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); expect(indexNames).toContain("idx_cli_sessions_chatSessionId");
expect(indexNames).toContain("idx_cli_sessions_project_state"); expect(indexNames).toContain("idx_cli_sessions_project_state");
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -1220,7 +1220,7 @@ describe("schema migration", () => {
.all() as Array<{ name: string }>; .all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); 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 }>; 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(tables.map((row) => row.name)).toContain("cli_sessions");
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -1287,20 +1287,20 @@ describe("schema migration", () => {
.get() as { migrated_fragment_id: string | null }; .get() as { migrated_fragment_id: string | null };
expect(stepRow.migrated_fragment_id).toBeNull(); expect(stepRow.migrated_fragment_id).toBeNull();
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
it("migration 109 is idempotent on re-init", () => { it("migration 109 is idempotent on re-init", () => {
const db = new Database(fusionDir); const db = new Database(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
// Re-open the same on-disk DB: already at 109, the 109 block must be a no-op. // Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
const reopened = new Database(fusionDir); const reopened = new Database(fusionDir);
reopened.init(); 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 }>; const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>;
expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1); expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>; const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;

View File

@@ -334,7 +334,7 @@ describe("Database", () => {
}); });
it("seeds schema version", () => { it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
}); });
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -393,7 +393,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => { it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow(); expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
}); });
it("does not overwrite existing config on re-init", () => { it("does not overwrite existing config on re-init", () => {
// Update the config // Update the config
@@ -1463,7 +1463,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29) // 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 // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; 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); const db = new Database(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
// Re-init should not fail // Re-init should not fail
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
// Re-init should not fail // Re-init should not fail
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
db.close(); db.close();
}); });
@@ -1531,7 +1531,7 @@ describe("schema migrations", () => {
db.init(); 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 cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority"); expect(cols.map((col) => col.name)).toContain("priority");
@@ -1572,7 +1572,7 @@ describe("schema migrations", () => {
db.init(); 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 cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -1644,7 +1644,7 @@ describe("schema migrations", () => {
db.init(); 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 cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -1923,7 +1923,7 @@ describe("schema migrations", () => {
db.init(); 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 }>; const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments"); expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1997,7 +1997,7 @@ describe("schema migrations", () => {
db.init(); 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 }>; 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" }]); expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -2021,7 +2021,7 @@ describe("schema migrations", () => {
db.init(); 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 }>; 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" }]); expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2125,7 +2125,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 // Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2308,6 +2308,48 @@ describe("schema migrations", () => {
db.close(); 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", () => { it("migration v74 adds tokenUsageCacheWriteTokens without data loss", () => {
tmpDir = makeTmpDir(); tmpDir = makeTmpDir();
const fusionDir = join(tmpDir, ".fusion"); const fusionDir = join(tmpDir, ".fusion");
@@ -2344,7 +2386,7 @@ describe("schema migrations", () => {
localDb.init(); 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 }>; const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2655,7 +2697,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir); const db = createDatabase(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(122); expect(db.getSchemaVersion()).toBe(123);
expect(db.getLastModified()).toBeGreaterThan(0); expect(db.getLastModified()).toBeGreaterThan(0);
db.close(); db.close();
@@ -2809,7 +2851,7 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion); migrated = new Database(fusion);
migrated.init(); 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 rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const names = new Set(rows.map((row) => row.name)); const names = new Set(rows.map((row) => row.name));
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
@@ -2840,7 +2882,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
const fresh = new Database(fusion); const fresh = new Database(fusion);
try { try {
fresh.init(); fresh.init();
expect(fresh.getSchemaVersion()).toBe(122); expect(fresh.getSchemaVersion()).toBe(123);
const names = new Set( const names = new Set(
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), (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 = new Database(fusion);
migrated.init(); migrated.init();
expect(migrated.getSchemaVersion()).toBe(122); expect(migrated.getSchemaVersion()).toBe(123);
const names = new Set( const names = new Set(
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), (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); const fresh = new Database(fusion);
try { try {
fresh.init(); fresh.init();
expect(fresh.getSchemaVersion()).toBe(122); expect(fresh.getSchemaVersion()).toBe(123);
const table = fresh const table = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined; .get() as { name: string } | undefined;
@@ -2928,7 +2970,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
migrated = new Database(fusion); migrated = new Database(fusion);
migrated.init(); migrated.init();
expect(migrated.getSchemaVersion()).toBe(122); expect(migrated.getSchemaVersion()).toBe(123);
const table = migrated const table = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined; .get() as { name: string } | undefined;
@@ -2954,7 +2996,7 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => {
const fresh = new Database(fusion); const fresh = new Database(fusion);
try { try {
fresh.init(); fresh.init();
expect(fresh.getSchemaVersion()).toBe(122); expect(fresh.getSchemaVersion()).toBe(123);
const tables = new Set( const tables = new Set(
( (
fresh fresh
@@ -3007,7 +3049,7 @@ describe("migration v120 adds deployments + incidents tables (U13)", () => {
// creation while table + row assertions still pass. Assert the real index // creation while table + row assertions still pass. Assert the real index
// names the v120 migration creates (idxDeployments*, idxIncidents*) so that // names the v120 migration creates (idxDeployments*, idxIncidents*) so that
// regression is caught. // regression is caught.
expect(migrated.getSchemaVersion()).toBe(122); expect(migrated.getSchemaVersion()).toBe(123);
const tables = new Set( const tables = new Set(
( (
migrated migrated
@@ -3066,7 +3108,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion); migrated = new Database(fusion);
migrated.init(); migrated.init();
expect(migrated.getSchemaVersion()).toBe(122); expect(migrated.getSchemaVersion()).toBe(123);
const tables = migrated const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>; .all() as Array<{ name: string }>;
@@ -3093,7 +3135,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try { try {
fresh.init(); fresh.init();
expect(fresh.getSchemaVersion()).toBe(122); expect(fresh.getSchemaVersion()).toBe(123);
const tables = fresh const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>; .all() as Array<{ name: string }>;

View File

@@ -14,13 +14,19 @@ function insertTaskWithFiles(db: Database, id: string, files: string[], updatedA
).run(id, updatedAt, updatedAt, JSON.stringify(files)); ).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( db.prepare(
`INSERT INTO task_commit_associations `INSERT INTO task_commit_associations
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt,
matchedBy, confidence, createdAt, updatedAt) matchedBy, confidence, additions, deletions, createdAt, updatedAt)
VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?)`, VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?, ?, ?)`,
).run(id, sha, authoredAt, authoredAt, authoredAt); ).run(id, sha, authoredAt, stats.additions ?? null, stats.deletions ?? null, authoredAt, authoredAt);
} }
function insertPr(db: Database, id: string, createdAtMs: number): void { function insertPr(db: Database, id: string, createdAtMs: number): void {
@@ -74,13 +80,44 @@ describe("productivity-analytics", () => {
expect(result.pullRequests).toBe(2); 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"); 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, {}); const result = aggregateProductivityAnalytics(db, {});
expect(result.loc).toEqual({ value: null, unavailable: true }); expect(result.loc).toEqual({ value: null, unavailable: true });
expect(result.loc.value).not.toBe(0); 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", () => { it("empty range returns zeroed structures, not nulls", () => {
insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z"); insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z");
insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z"); insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z");

View File

@@ -35,6 +35,46 @@ describe("TaskStore", () => {
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args); const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
const taskDir = (taskId: string) => join(rootDir, ".fusion", "tasks", taskId); 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", () => { describe("upsertTask regression coverage", () => {
it("creates tasks successfully on a fresh database schema", async () => { it("creates tasks successfully on a fresh database schema", async () => {
const freshRoot = makeTmpDir(); const freshRoot = makeTmpDir();

View File

@@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
// ── Schema Definition ──────────────────────────────────────────────── // ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 122; const SCHEMA_VERSION = 123;
const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16; 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')), 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')), confidence TEXT NOT NULL CHECK (confidence IN ('canonical', 'legacy', 'ambiguous')),
note TEXT, note TEXT,
additions INTEGER,
deletions INTEGER,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
UNIQUE(taskLineageId, commitSha, matchedBy) 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");
});
}
} }
/** /**

View File

@@ -3,14 +3,15 @@ import type { Database } from "./db.js";
/** /**
* Productivity analytics: files modified (count + language distribution) from * Productivity analytics: files modified (count + language distribution) from
* `tasks.modifiedFiles`, commit associations from `task_commit_associations`, * `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 * **LOC availability.** Fusion persists nullable `additions`/`deletions` on
* stats (the `task_commit_associations` schema has no additions/deletions * `task_commit_associations` when merge paths can capture git shortstat output.
* columns). LOC is therefore reported as the documented unavailable sentinel — * LOC is reported as a real value only when at least one in-range association
* `{ value: null, unavailable: true }` — **never `0`**, so a missing data source * has non-null stats. If the range has no recorded stats, the documented
* is never mistaken for "zero lines changed". When a diff-stats source is added, * unavailable sentinel — `{ value: null, unavailable: true }` — is preserved,
* fill {@link LocSummary.value} and clear `unavailable`. * **never `0`**, so missing historical data is not mistaken for "zero lines
* changed".
* *
* Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by * Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by
* `updatedAt` (the last time the task — and therefore its modifiedFiles — was * `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 * LOC summary. `value` is null and `unavailable` true when no in-range commit
* source exists — never `0`. * association has diff stats — never `0` for unknown data.
*/ */
export interface LocSummary { export interface LocSummary {
value: number | null; value: number | null;
@@ -51,7 +52,7 @@ export interface ProductivityAnalytics {
commits: number; commits: number;
/** Rows in `pull_requests` in range. */ /** Rows in `pull_requests` in range. */
pullRequests: number; 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; loc: LocSummary;
} }
@@ -59,6 +60,13 @@ interface CountRow {
count: number; count: number;
} }
interface CommitStatsRow {
count: number;
additions: number | null;
deletions: number | null;
statsRows: number;
}
interface ModifiedFilesRow { interface ModifiedFilesRow {
modifiedFiles: string | null; modifiedFiles: string | null;
} }
@@ -73,8 +81,8 @@ function languageOf(path: string): string {
/** /**
* Aggregate productivity metrics over a date range. Empty range yields zeroed * Aggregate productivity metrics over a date range. Empty range yields zeroed
* structures (not nulls); LOC is always the unavailable sentinel until a * structures (not nulls); LOC remains the unavailable sentinel unless at least
* diff-stats source is wired. * one in-range commit association carries diff stats.
*/ */
export function aggregateProductivityAnalytics( export function aggregateProductivityAnalytics(
db: Database, db: Database,
@@ -135,13 +143,20 @@ export function aggregateProductivityAnalytics(
} }
const commitWhere = const commitWhere =
commitClauses.length > 0 ? `WHERE ${commitClauses.join(" AND ")}` : ""; commitClauses.length > 0 ? `WHERE ${commitClauses.join(" AND ")}` : "";
const commits = ( const commitStats = db
db .prepare(
.prepare( `SELECT
`SELECT COUNT(*) AS count FROM task_commit_associations ${commitWhere}`, COUNT(*) AS count,
) SUM(additions) AS additions,
.get(...commitParams) as CountRow SUM(deletions) AS deletions,
).count; 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 // Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so
// convert the ISO bounds to epoch ms for comparison. // convert the ISO bounds to epoch ms for comparison.
@@ -169,7 +184,6 @@ export function aggregateProductivityAnalytics(
byLanguage, byLanguage,
commits, commits,
pullRequests, pullRequests,
// No commit diff-stats source yet — unavailable, never 0. loc,
loc: { value: null, unavailable: true },
}; };
} }

View File

@@ -560,6 +560,8 @@ interface TaskCommitAssociationRow {
matchedBy: TaskCommitAssociationMatchSource; matchedBy: TaskCommitAssociationMatchSource;
confidence: TaskCommitAssociationConfidence; confidence: TaskCommitAssociationConfidence;
note: string | null; note: string | null;
additions: number | null;
deletions: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -16202,14 +16204,16 @@ ${notificationsSection}`;
}); });
this.db.prepare( this.db.prepare(
`INSERT INTO task_commit_associations `INSERT INTO task_commit_associations
(id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, createdAt, updatedAt) (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, additions, deletions, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET
taskIdSnapshot = excluded.taskIdSnapshot, taskIdSnapshot = excluded.taskIdSnapshot,
commitSubject = excluded.commitSubject, commitSubject = excluded.commitSubject,
authoredAt = excluded.authoredAt, authoredAt = excluded.authoredAt,
confidence = excluded.confidence, confidence = excluded.confidence,
note = excluded.note, note = excluded.note,
additions = excluded.additions,
deletions = excluded.deletions,
updatedAt = excluded.updatedAt`, updatedAt = excluded.updatedAt`,
).run( ).run(
association.id, association.id,
@@ -16221,6 +16225,8 @@ ${notificationsSection}`;
association.matchedBy, association.matchedBy,
association.confidence, association.confidence,
association.note ?? null, association.note ?? null,
association.additions ?? null,
association.deletions ?? null,
association.createdAt, association.createdAt,
association.updatedAt, association.updatedAt,
); );
@@ -16231,7 +16237,12 @@ ${notificationsSection}`;
const rows = this.db.prepare( const rows = this.db.prepare(
`SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`, `SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`,
).all(lineageId) as TaskCommitAssociationRow[]; ).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( async replaceLegacyTaskCommitAssociations(

View File

@@ -42,6 +42,8 @@ export function normalizeTaskCommitAssociation(
return { return {
...row, ...row,
note: row.note?.trim() || undefined, note: row.note?.trim() || undefined,
additions: row.additions ?? undefined,
deletions: row.deletions ?? undefined,
confidence: row.confidence ?? classifyTaskCommitAssociationConfidence(row.matchedBy), confidence: row.confidence ?? classifyTaskCommitAssociationConfidence(row.matchedBy),
}; };
} }

View File

@@ -4387,6 +4387,8 @@ export interface TaskCommitAssociation {
matchedBy: TaskCommitAssociationMatchSource; matchedBy: TaskCommitAssociationMatchSource;
confidence: TaskCommitAssociationConfidence; confidence: TaskCommitAssociationConfidence;
note?: string; note?: string;
additions?: number;
deletions?: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -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<TaskStore, "upsertTaskCommitAssociation"> {
return {
upsertTaskCommitAssociation: vi.fn(async (association) => ({
id: "assoc-1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...association,
})),
} as Pick<TaskStore, "upsertTaskCommitAssociation">;
}
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,
}));
});
});

View File

@@ -1327,6 +1327,8 @@ async function finalizeMerged(
authoredAt: mergedAt, authoredAt: mergedAt,
matchedBy: "canonical-lineage-trailer", matchedBy: "canonical-lineage-trailer",
confidence: "canonical", confidence: "canonical",
additions: insertions,
deletions,
}).catch(() => undefined); }).catch(() => undefined);
} }
} }

View File

@@ -3833,7 +3833,7 @@ async function generateAiMergeSubject(
* is a denormalized convenience for lineage lookups, not a correctness * is a denormalized convenience for lineage lookups, not a correctness
* invariant, so a missed write must not block the merge. * invariant, so a missed write must not block the merge.
*/ */
async function recordCommitAssociationFromHead( export async function recordCommitAssociationFromHead(
store: TaskStore, store: TaskStore,
rootDir: string, rootDir: string,
taskId: string, taskId: string,
@@ -3857,6 +3857,17 @@ async function recordCommitAssociationFromHead(
); );
return; 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({ await store.upsertTaskCommitAssociation({
taskLineageId: lineageId, taskLineageId: lineageId,
taskIdSnapshot: taskId, taskIdSnapshot: taskId,
@@ -3865,6 +3876,8 @@ async function recordCommitAssociationFromHead(
authoredAt, authoredAt,
matchedBy: "canonical-lineage-trailer", matchedBy: "canonical-lineage-trailer",
confidence: "canonical", confidence: "canonical",
additions,
deletions,
}); });
} }
@@ -10385,6 +10398,8 @@ export async function aiMergeTask(
authoredAt: mergeDetails.mergedAt ?? new Date().toISOString(), authoredAt: mergeDetails.mergedAt ?? new Date().toISOString(),
matchedBy: "canonical-lineage-trailer", matchedBy: "canonical-lineage-trailer",
confidence: "canonical", confidence: "canonical",
additions: mergeDetails.insertions,
deletions: mergeDetails.deletions,
}); });
} }
} }