feat(FN-5584): merge fusion/fn-5584

This commit is contained in:
gsxdsm
2026-05-25 15:27:06 -07:00
parent 06a107dcba
commit 5eacd79b43
29 changed files with 329 additions and 57 deletions

View File

@@ -715,7 +715,7 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
db.close();
});
@@ -748,7 +748,7 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
db.close();
});
@@ -798,7 +798,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
db.close();
});
@@ -827,7 +827,7 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
db.close();
});
@@ -862,7 +862,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(90);
expect(db.getSchemaVersion()).toBe(91);
db.close();
});

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { MissionStore } from "../mission-store.js";
import { Database } from "../db.js";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-db-mission-base-branch-"));
}
describe("mission baseBranch persistence", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
let store: MissionStore;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new MissionStore(fusionDir, db);
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("creates, reads, and updates mission baseBranch", () => {
const created = store.createMission({
title: "Mission",
baseBranch: "develop",
});
expect(created.baseBranch).toBe("develop");
const fetched = store.getMission(created.id);
expect(fetched?.baseBranch).toBe("develop");
const updated = store.updateMission(created.id, { baseBranch: "release/1.0" });
expect(updated.baseBranch).toBe("release/1.0");
const refetched = store.getMission(created.id);
expect(refetched?.baseBranch).toBe("release/1.0");
});
});

View File

@@ -330,7 +330,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -373,7 +373,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1443,7 +1443,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1468,11 +1468,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
db.close();
});
@@ -1507,7 +1507,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1548,7 +1548,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1620,7 +1620,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1860,7 +1860,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1934,7 +1934,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
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" }]);
@@ -1958,7 +1958,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
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" }]);
@@ -2062,7 +2062,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2281,7 +2281,7 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(90);
expect(localDb.getSchemaVersion()).toBe(91);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2592,7 +2592,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2746,7 +2746,7 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(90);
expect(migrated.getSchemaVersion()).toBe(91);
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);
@@ -2792,7 +2792,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(90);
expect(migrated.getSchemaVersion()).toBe(91);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2819,7 +2819,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(90);
expect(fresh.getSchemaVersion()).toBe(91);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(90);
expect(db1.getSchemaVersion()).toBe(91);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(90);
expect(db3.getSchemaVersion()).toBe(91);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(90);
expect(db1.getSchemaVersion()).toBe(91);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(90);
expect(db2.getSchemaVersion()).toBe(91);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(90);
expect(db1.getSchemaVersion()).toBe(91);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -1869,6 +1869,38 @@ describe("MissionStore", () => {
expect(task!.missionId).toBe(mission.id);
});
it("inherits mission baseBranch when no explicit override is provided", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission", baseBranch: "develop" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Original" });
const triaged = await msWithTs.triageFeature(feature.id);
const task = await ts.getTask(triaged.taskId!);
expect(task?.baseBranch).toBe("develop");
});
it("explicit baseBranch override takes precedence over mission default", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission", baseBranch: "develop" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Original" });
const triaged = await msWithTs.triageFeature(feature.id, undefined, undefined, { baseBranch: "release/1.0" });
const task = await ts.getTask(triaged.taskId!);
expect(task?.baseBranch).toBe("release/1.0");
});
it("uses provided title and description overrides", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
@@ -2031,6 +2063,39 @@ describe("MissionStore", () => {
expect(triaged[0].status).toBe("triaged");
});
it("triageSlice inherits mission baseBranch when no override is provided", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission", baseBranch: "develop" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const f1 = msWithTs.addFeature(slice.id, { title: "Feature 1" });
const triaged = await msWithTs.triageSlice(slice.id);
const task = await ts.getTask(triaged[0].taskId!);
expect(triaged[0].id).toBe(f1.id);
expect(task?.baseBranch).toBe("develop");
});
it("triageSlice does not inject baseBranch when mission has none", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
msWithTs.addFeature(slice.id, { title: "Feature 1" });
const triaged = await msWithTs.triageSlice(slice.id);
const task = await ts.getTask(triaged[0].taskId!);
expect(task?.baseBranch).toBeUndefined();
});
it("returns empty array if no defined features", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
@@ -2821,7 +2886,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
});
it("mission_features table has loop state columns", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
});
});
});

View File

@@ -42,7 +42,7 @@ describe("secrets schema migrations", () => {
const version = db
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(version.value).toBe("90");
expect(version.value).toBe("91");
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
@@ -105,7 +105,7 @@ describe("secrets schema migrations", () => {
const version = db
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(version.value).toBe("90");
expect(version.value).toBe("91");
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
@@ -155,7 +155,7 @@ describe("secrets schema migrations", () => {
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(projectVersion.value).toBe("90");
expect(projectVersion.value).toBe("91");
expect(centralVersion.value).toBe("13");
} finally {
projectDb.close();

View File

@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
expect(store.getDatabase().getSchemaVersion()).toBe(90);
expect(store.getDatabase().getSchemaVersion()).toBe(91);
});
it("migrates a legacy v88 database and preserves task rows", async () => {

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(90);
expect(db.getSchemaVersion()).toBe(91);
const index = db
.prepare(

View File

@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 90;
const SCHEMA_VERSION = 91;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -755,6 +755,7 @@ CREATE TABLE IF NOT EXISTS missions (
description TEXT,
status TEXT NOT NULL,
interviewState TEXT NOT NULL,
baseBranch TEXT,
autoAdvance INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
@@ -3565,6 +3566,12 @@ export class Database {
});
}
if (version < 91) {
this.applyMigration(91, () => {
this.addColumnIfMissing("missions", "baseBranch", "TEXT");
});
}
}
/**

View File

@@ -167,6 +167,7 @@ interface MissionRow {
description: string | null;
status: string;
interviewState: string;
baseBranch: string | null;
autoAdvance: number;
autopilotEnabled: number;
autopilotState: string;
@@ -338,6 +339,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description: row.description || undefined,
status: row.status as MissionStatus,
interviewState: row.interviewState as InterviewState,
baseBranch: row.baseBranch || undefined,
autoAdvance: Boolean(row.autoAdvance),
autopilotEnabled: Boolean(row.autopilotEnabled),
autopilotState: (row.autopilotState as AutopilotState) || "inactive",
@@ -527,6 +529,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description: input.description,
status: "planning",
interviewState: "not_started",
baseBranch: input.baseBranch,
autoAdvance: false,
autopilotEnabled: input.autopilotEnabled ?? false,
autopilotState: "inactive",
@@ -535,14 +538,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
};
this.db.prepare(`
INSERT INTO missions (id, title, description, status, interviewState, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
mission.id,
mission.title,
mission.description ?? null,
mission.status,
mission.interviewState,
mission.baseBranch ?? null,
mission.autoAdvance ? 1 : 0,
mission.autopilotEnabled ? 1 : 0,
mission.autopilotState ?? "inactive",
@@ -1094,6 +1098,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description = ?,
status = ?,
interviewState = ?,
baseBranch = ?,
autoAdvance = ?,
autopilotEnabled = ?,
autopilotState = ?,
@@ -1105,6 +1110,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
updated.description ?? null,
updated.status,
updated.interviewState,
updated.baseBranch ?? null,
updated.autoAdvance ? 1 : 0,
updated.autopilotEnabled ? 1 : 0,
updated.autopilotState ?? "inactive",
@@ -3127,6 +3133,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const slice = this.getSlice(feature.sliceId);
const milestone = slice ? this.getMilestone(slice.milestoneId) : undefined;
const missionId = milestone?.missionId;
const mission = missionId ? this.getMission(missionId) : undefined;
const resolvedBaseBranch = branchOptions?.baseBranch ?? mission?.baseBranch;
const lockScope = missionId ? `mission:${missionId}` : `mission-store:${this.taskStore.getRootDir()}`;
const guard = await runDeterministicDuplicateGuard(this.taskStore, {
@@ -3143,14 +3151,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
title: taskTitle || feature.title,
description,
branch: branchOptions?.branch,
baseBranch: branchOptions?.baseBranch,
baseBranch: resolvedBaseBranch,
...(missionId
? {
branchContext: {
groupId: `mission:${missionId}`,
source: "mission" as const,
assignmentMode: branchOptions?.assignmentMode ?? "shared",
inheritedBaseBranch: branchOptions?.baseBranch,
inheritedBaseBranch: resolvedBaseBranch,
},
}
: {}),
@@ -3208,6 +3216,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const features = this.listFeatures(sliceId);
const definedFeatures = features.filter((f) => f.status === "defined");
const milestone = this.getMilestone(slice.milestoneId);
const mission = milestone ? this.getMission(milestone.missionId) : undefined;
const resolvedBaseBranch = branchOptions?.baseBranch ?? mission?.baseBranch;
const triaged: MissionFeature[] = [];
for (const feature of definedFeatures) {
@@ -3217,6 +3228,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const updated = await this.triageFeature(feature.id, undefined, undefined, {
...branchOptions,
branch,
baseBranch: resolvedBaseBranch,
});
triaged.push(updated);
}

View File

@@ -122,6 +122,8 @@ export interface Mission {
description?: string;
/** Current lifecycle status */
status: MissionStatus;
/** Optional integration base branch inherited by triaged feature tasks */
baseBranch?: string;
/** State of the AI specification interview process */
interviewState: InterviewState;
/**
@@ -369,6 +371,8 @@ export interface MissionCreateInput {
title: string;
/** Detailed description of the mission's objectives */
description?: string;
/** Optional integration base branch for tasks created from this mission */
baseBranch?: string;
}
/** Input for creating a new Milestone */