feat(FN-4018): isolate split merger temp workspaces with settings persisten

Merged FN-4014 to fix dual-scope GitHub tracking settings persistence and validation (mission vs task scope), scoped repo saves to the relevant section, and documented the dual defaults. Also landed FN-4018 to isolate split merger temp workspaces and add regression checks for split-suite test isolat

Fusion-Task-Id: FN-4018
This commit is contained in:
Fusion
2026-05-11 11:36:39 -07:00
committed by gsxdsm
parent c41d49f28b
commit 98aa09f822
16 changed files with 160 additions and 25 deletions

View File

@@ -4,6 +4,7 @@ import {
DEFAULT_PROJECT_SETTINGS,
GLOBAL_SETTINGS_KEYS,
PROJECT_SETTINGS_KEYS,
isGlobalOnlySettingsKey,
isGlobalSettingsKey,
isProjectSettingsKey,
} from "../types.js";
@@ -95,6 +96,8 @@ describe("settings key parity", () => {
expect(isGlobalSettingsKey("githubAuthToken")).toBe(false);
expect(isProjectSettingsKey("githubTrackingDefaultRepo")).toBe(true);
expect(isGlobalSettingsKey("githubTrackingDefaultRepo")).toBe(true);
expect(isGlobalOnlySettingsKey("githubTrackingDefaultRepo")).toBe(false);
expect(isGlobalOnlySettingsKey("themeMode")).toBe(true);
});
it("keeps remoteAccess scoped to global settings only", () => {

View File

@@ -554,6 +554,33 @@ describe("TaskStore", () => {
// ── Scope Separation Regression Tests (FN-1729) ───────────────────────────
describe("scope separation regression", () => {
it("keeps dual-scope githubTrackingDefaultRepo in project scope while excluding global-only keys", async () => {
await harness.store().updateGlobalSettings({
githubTrackingDefaultRepo: "global/default",
themeMode: "light",
});
await harness.store().updateSettings({
githubTrackingDefaultRepo: "project/default",
themeMode: "dark",
});
const merged = await harness.store().getSettings();
const mergedFast = await harness.store().getSettingsFast();
const { global, project } = await harness.store().getSettingsByScope();
const scopedFast = await harness.store().getSettingsByScopeFast();
expect(merged.githubTrackingDefaultRepo).toBe("project/default");
expect(mergedFast.githubTrackingDefaultRepo).toBe("project/default");
expect(project.githubTrackingDefaultRepo).toBe("project/default");
expect(scopedFast.project.githubTrackingDefaultRepo).toBe("project/default");
expect(global.githubTrackingDefaultRepo).toBe("global/default");
expect(scopedFast.global.githubTrackingDefaultRepo).toBe("global/default");
expect((project as Record<string, unknown>).themeMode).toBeUndefined();
expect((scopedFast.project as Record<string, unknown>).themeMode).toBeUndefined();
});
it("getSettingsByScope: global lane keys never appear in project scope", async () => {
await harness.store().updateGlobalSettings({
executionGlobalProvider: "anthropic",

View File

@@ -346,3 +346,7 @@ export function isGlobalSettingsKey(key: string): key is keyof GlobalSettings {
export function isProjectSettingsKey(key: string): key is keyof ProjectSettings {
return (PROJECT_SETTINGS_KEYS as readonly string[]).includes(key);
}
export function isGlobalOnlySettingsKey(key: string): key is keyof GlobalSettings {
return isGlobalSettingsKey(key) && !isProjectSettingsKey(key);
}

View File

@@ -5,7 +5,7 @@ import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence } from "./types.js";
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { normalizeTaskPriority } from "./task-priority.js";
import { canAgentTakeImplementationTask } from "./agent-role-policy.js";
import { GlobalSettingsStore } from "./global-settings.js";
@@ -1783,7 +1783,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Strip global-only keys from project-level settings so stale project-scoped
// values don't override the correct global value during the spread merge.
const projectSettings = Object.fromEntries(
Object.entries(config.settings ?? {}).filter(([key]) => !isGlobalSettingsKey(key)),
Object.entries(config.settings ?? {}).filter(([key]) => !isGlobalOnlySettingsKey(key)),
);
return canonicalizeSettings({
...DEFAULT_SETTINGS,
@@ -1818,7 +1818,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// always done this; getSettingsFast() was missing the filter.
const projectSettings: Partial<Settings> | undefined = raw
? (Object.fromEntries(
Object.entries(raw).filter(([key]) => !isGlobalSettingsKey(key)),
Object.entries(raw).filter(([key]) => !isGlobalOnlySettingsKey(key)),
) as Partial<Settings>)
: undefined;
@@ -1846,7 +1846,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const projectSettings: Partial<ProjectSettings> = {};
if (config.settings) {
for (const key of Object.keys(config.settings)) {
if (!isGlobalSettingsKey(key)) {
if (!isGlobalOnlySettingsKey(key)) {
(projectSettings as Record<string, unknown>)[key] = (config.settings as Record<string, unknown>)[key];
}
}
@@ -1881,7 +1881,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const projectScoped: Partial<ProjectSettings> = {};
if (projectSettings) {
for (const key of Object.keys(projectSettings)) {
if (!isGlobalSettingsKey(key)) {
if (!isGlobalOnlySettingsKey(key)) {
(projectScoped as Record<string, unknown>)[key] = (projectSettings as Record<string, unknown>)[key];
}
}
@@ -1904,7 +1904,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Filter out global-only fields — they should go through updateGlobalSettings()
const projectPatch: Partial<Settings> = {};
for (const [key, value] of Object.entries(patch)) {
if (!isGlobalSettingsKey(key)) {
if (!isGlobalOnlySettingsKey(key)) {
(projectPatch as Record<string, unknown>)[key] = value;
}
}

View File

@@ -2469,6 +2469,7 @@ export {
DEFAULT_SETTINGS,
GLOBAL_SETTINGS_KEYS,
PROJECT_SETTINGS_KEYS,
isGlobalOnlySettingsKey,
isGlobalSettingsKey,
isProjectSettingsKey,
} from "./settings-schema.js";