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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
148
packages/core/src/__tests__/branch-group-store.test.ts
Normal file
148
packages/core/src/__tests__/branch-group-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user