FN-5678: add branch-group data model foundation

Establish per-mission/per-planning branch-group persistence and task metadata plumbing for shared auto-merge behavior.

- add `branch_groups` schema, indexes, exports, and store types for mission/planning branch assignment
- wire task source metadata branch-context helpers and branch-group row handling into core store flows
- add branch-group and migration coverage plus roadmap schema-version assertion wording fix
- add published changeset and storage doc note for the new branch-group persistence layer
- preserve forward-only migration safety by advancing schema to 96 and gating branch-group migration at `< 96`

Files changed:
 .changeset/per-mission-automerge-foundation.md     |  11 ++
 docs/storage.md                                    |   1 +
 packages/core/src/__tests__/backup.test.ts         |  32 ++++
 .../core/src/__tests__/branch-group-store.test.ts  | 148 ++++++++++++++++++
 packages/core/src/__tests__/db-migrate.test.ts     |  40 +++++
 packages/core/src/__tests__/db.test.ts             |  16 ++
 packages/core/src/__tests__/mission-store.test.ts  |  16 ++
 packages/core/src/db.ts                            |  52 ++++++-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/mission-store.ts                 |  19 ++-
 packages/core/src/mission-types.ts                 |   4 +
 packages/core/src/store.ts                         | 171 ++++++++++++++++++++-
 packages/core/src/types.ts                         |  59 +++++++
 13 files changed, 556 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-5678
Fusion-Task-Lineage: cd6e5312-a9c5-475f-a0de-1d3090395bdd
This commit is contained in:
gsxdsm
2026-05-29 11:15:47 -07:00
parent 91137ecb51
commit 72214132d2
13 changed files with 556 additions and 15 deletions

View File

@@ -15,6 +15,7 @@ import {
} from "../backup.js";
import { Database } from "../db.js";
import { RoutineStore } from "../routine-store.js";
import { TaskStore } from "../store.js";
import type { ProjectSettings } from "../types.js";
describe("BackupManager", () => {
@@ -392,6 +393,37 @@ describe("BackupManager", () => {
const backups = await readdir(join(tempDir, ".fusion/backups"));
expect(backups.some((name) => name.startsWith("fusion-central-pre-restore-"))).toBe(true);
});
it("preserves branch groups + mission/task autoMerge across backup restore", async () => {
const rootDir = tempDir;
const globalDir = join(tempDir, ".fusion-global");
await rm(join(fusionDir, "fusion.db"), { force: true });
const store = new TaskStore(rootDir, globalDir);
await store.init();
const mission = store.getMissionStore().createMission({ title: "Backup Mission", autoMerge: true });
const task = await store.createTask({ description: "Backup task", autoMerge: true });
const group = store.createBranchGroup({ sourceType: "mission", sourceId: mission.id, branchName: "fn/backup-shared" });
await store.setTaskBranchGroup(task.id, group.id);
store.close();
const backup = await backupManager.createBackup();
await writeFile(join(fusionDir, "fusion.db"), "corrupted");
await backupManager.restoreBackup(backup.filename, { createPreRestoreBackup: false });
const restoredStore = new TaskStore(rootDir, globalDir);
await restoredStore.init();
const restoredMission = restoredStore.getMissionStore().getMission(mission.id);
const restoredTask = await restoredStore.getTask(task.id);
const restoredGroup = restoredStore.getBranchGroup(group.id);
expect(restoredMission?.autoMerge).toBe(true);
expect(restoredTask.autoMerge).toBe(true);
expect(restoredTask.branchContext?.groupId).toBe(group.id);
expect(restoredGroup?.sourceId).toBe(mission.id);
restoredStore.close();
});
});
});

View File

@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fusion-branch-group-test-"));
}
describe("TaskStore branch groups", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = join(rootDir, ".fusion-global");
store = new TaskStore(rootDir, globalDir);
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("creates, reads, lists, and updates branch groups", () => {
const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" });
expect(group.id.startsWith("BG-")).toBe(true);
expect(group.autoMerge).toBe(false);
expect(group.prState).toBe("none");
expect(group.status).toBe("open");
expect(store.getBranchGroup(group.id)?.branchName).toBe("fn/shared");
expect(store.getBranchGroupBySource("mission", "M-1")?.id).toBe(group.id);
expect(store.listBranchGroups().map((entry) => entry.id)).toContain(group.id);
const updated = store.updateBranchGroup(group.id, { status: "finalized", autoMerge: true, prState: "open", prNumber: 12 });
expect(updated.autoMerge).toBe(true);
expect(updated.prState).toBe("open");
expect(updated.prNumber).toBe(12);
expect(updated.closedAt).toBeTypeOf("number");
expect(store.listBranchGroups({ status: "finalized" }).map((entry) => entry.id)).toContain(group.id);
const abandoned = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-2", branchName: "fn/abandoned" });
const abandonedUpdated = store.updateBranchGroup(abandoned.id, { status: "abandoned" });
expect(abandonedUpdated.closedAt).toBeTypeOf("number");
});
it("enforces unique branchName", () => {
store.createBranchGroup({ sourceType: "mission", sourceId: "M-1", branchName: "fn/shared" });
expect(() =>
store.createBranchGroup({ sourceType: "planning", sourceId: "PS-1", branchName: "fn/shared" })
).toThrow();
});
it("rejects duplicate branch group primary key id", () => {
const now = Date.now();
(store as any).db
.prepare(
"INSERT INTO branch_groups (id, sourceType, sourceId, branchName, worktreePath, autoMerge, prState, prUrl, prNumber, status, createdAt, updatedAt, closedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run("BG-fixed", "mission", "M-1", "fn/fixed-1", null, 0, "none", null, null, "open", now, now, null);
expect(() =>
(store as any).db
.prepare(
"INSERT INTO branch_groups (id, sourceType, sourceId, branchName, worktreePath, autoMerge, prState, prUrl, prNumber, status, createdAt, updatedAt, closedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run("BG-fixed", "mission", "M-2", "fn/fixed-2", null, 0, "none", null, null, "open", now, now, null)
).toThrow();
});
it("sets and clears task branchContext via setTaskBranchGroup", async () => {
const task = await store.createTask({ description: "branch link test" });
const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-1", branchName: "fn/planning" });
const onUpdated = vi.fn();
store.on("task:updated", onUpdated);
await store.setTaskBranchGroup(task.id, group.id);
const linked = await store.getTask(task.id);
expect(linked.branchContext).toEqual({ groupId: group.id, source: "planning", assignmentMode: "shared" });
await store.setTaskBranchGroup(task.id, null);
const cleared = await store.getTask(task.id);
expect(cleared.branchContext).toBeUndefined();
expect(onUpdated).toHaveBeenCalled();
await expect(store.setTaskBranchGroup(task.id, "BG-missing")).rejects.toThrow("not found");
});
it("keeps task autoMerge/branchContext undefined when unset", async () => {
const task = await store.createTask({ description: "defaults" });
const reloaded = await store.getTask(task.id);
expect(reloaded.autoMerge).toBeUndefined();
expect(reloaded.branchContext).toBeUndefined();
const slim = await store.listTasks({ slim: true, includeArchived: false });
const slimTask = slim.find((entry) => entry.id === task.id)!;
expect(slimTask.autoMerge).toBeUndefined();
expect(slimTask.branchContext).toBeUndefined();
});
it("hides linked tasks from slim output after soft delete", async () => {
const task = await store.createTask({ description: "soft delete" });
const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-3", branchName: "fn/deleted" });
await store.setTaskBranchGroup(task.id, group.id);
await store.deleteTask(task.id);
const slim = await store.listTasks({ slim: true, includeArchived: false });
expect(slim.find((entry) => entry.id === task.id)).toBeUndefined();
});
it("preserves autoMerge + branchContext in slim list/search/modifiedSince and archived slim", async () => {
const task = await store.createTask({ description: "slim check" });
const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-2", branchName: "fn/mission" });
await store.setTaskBranchGroup(task.id, group.id);
await store.updateTask(task.id, { autoMerge: true });
const slim = await store.listTasks({ slim: true, includeArchived: false });
const slimTask = slim.find((entry) => entry.id === task.id)!;
expect(slimTask.autoMerge).toBe(true);
expect(slimTask.branchContext?.groupId).toBe(group.id);
const search = await store.searchTasks(task.id, { slim: true, includeArchived: false });
expect(search[0].autoMerge).toBe(true);
expect(search[0].branchContext?.groupId).toBe(group.id);
const since = new Date(Date.now() - 60_000).toISOString();
const modified = await store.listTasksModifiedSince(since, 50, { includeArchived: false });
const modifiedTask = modified.tasks.find((entry) => entry.id === task.id)!;
expect(modifiedTask.autoMerge).toBe(true);
expect(modifiedTask.branchContext?.groupId).toBe(group.id);
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const archivedSlim = await store.listTasks({ column: "archived", slim: true, includeArchived: true });
const archivedTask = archivedSlim.find((entry) => entry.id === task.id)!;
expect(archivedTask.autoMerge).toBe(true);
expect(archivedTask.branchContext?.groupId).toBe(group.id);
});
});

View File

@@ -832,6 +832,46 @@ describe("schema migration", () => {
db.close();
});
it("adds branch_groups table and autoMerge columns when migrating from schema version 93", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '93')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS missions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
interviewState TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.init();
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("branch_groups");
const taskColumns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(taskColumns.map((column) => column.name)).toContain("autoMerge");
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(94);
db.close();
});
it("v76 backfill preserves explicit gateMode and defaults the rest to advisory (FN-4497)", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");

View File

@@ -338,6 +338,22 @@ describe("Database", () => {
const columnNames = columns.map((column) => column.name);
expect(columnNames).toContain("tokenUsageCacheWriteTokens");
});
it("creates branch_groups table, indexes, and autoMerge columns", () => {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("branch_groups");
const branchIndexes = db.prepare("PRAGMA index_list('branch_groups')").all() as Array<{ name: string }>;
const indexNames = branchIndexes.map((row) => row.name);
expect(indexNames).toContain("idxBranchGroupsSource");
expect(indexNames).toContain("idxBranchGroupsBranchName");
const taskColumns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(taskColumns.map((column) => column.name)).toContain("autoMerge");
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
});
it("seeds lastModified", () => {
const ts = db.getLastModified();
expect(ts).toBeGreaterThan(0);

View File

@@ -3293,6 +3293,22 @@ describe("MissionStore", () => {
});
});
it("persists mission autoMerge true/false/undefined", () => {
const enabled = store.createMission({ title: "Enabled", autoMerge: true });
const disabled = store.createMission({ title: "Disabled", autoMerge: false });
const unset = store.createMission({ title: "Unset" });
expect(store.getMission(enabled.id)?.autoMerge).toBe(true);
expect(store.getMission(disabled.id)?.autoMerge).toBe(false);
expect(store.getMission(unset.id)?.autoMerge).toBeUndefined();
store.updateMission(enabled.id, { autoMerge: false });
store.updateMission(disabled.id, { autoMerge: true });
expect(store.getMission(enabled.id)?.autoMerge).toBe(false);
expect(store.getMission(disabled.id)?.autoMerge).toBe(true);
});
it("exports and applies mission hierarchy snapshots", () => {
const mission = store.createMission({ title: "Snapshot Mission" });
const milestone = store.addMilestone(mission.id, { title: "MS" });