feat(core): one-time hard-move migration of workflow-policy settings to workflow setting values

30 moved keys (step execution, review/approval, per-phase model lanes) leave
DEFAULT_PROJECT_SETTINGS; marker-gated idempotent per-project migration writes
customized values to every in-use (workflowId, projectId); stale-writer guard;
tombstone allowlist derived from the builtin catalog.
This commit is contained in:
gsxdsm
2026-06-04 23:23:48 -07:00
parent 9a4343be26
commit 4fe7dbe016
14 changed files with 1155 additions and 330 deletions

View File

@@ -70,12 +70,42 @@ interface MockTask {
column: string;
}
// `requirePrApproval` MOVED to workflow settings (U4): the CLI now resolves the
// task's EFFECTIVE workflow settings and overlays them onto the project base. So a
// mock store must expose `requirePrApproval` (and any moved key) through the
// effective-settings resolver store surface (`getWorkflowSettingValues` etc.), not
// through `getSettings()`. These stubs make `resolveEffectiveSettings` degrade to
// `builtin:coding` and read the moved value from the stored workflow values.
const MOVED_TEST_KEYS = new Set(["requirePrApproval"]);
function splitMovedSettings(settings: Record<string, unknown>) {
const projectSettings: Record<string, unknown> = {};
const workflowValues: Record<string, unknown> = {};
for (const [key, value] of Object.entries(settings)) {
if (MOVED_TEST_KEYS.has(key)) workflowValues[key] = value;
else projectSettings[key] = value;
}
return { projectSettings, workflowValues };
}
function workflowSettingsResolverStubs(workflowValues: Record<string, unknown>) {
return {
// No selection → resolver degrades to builtin:coding, whose declarations carry
// the moved-key catalog; the stored values below override the declaration default.
getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined),
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
getWorkflowSettingValues: vi.fn().mockReturnValue(workflowValues),
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("test-project"),
};
}
function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
const emitter = new EventEmitter();
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
const { projectSettings, workflowValues } = splitMovedSettings(settings);
return Object.assign(emitter, {
getTask: vi.fn().mockResolvedValue(task),
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
getSettings: vi.fn().mockResolvedValue({ ...projectSettings }),
updateTask: vi.fn(async (id: string, patch: Record<string, unknown>) => {
updates.push({ id, patch });
}),
@@ -86,6 +116,7 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
getBranchGroup: vi.fn().mockReturnValue(null),
updateBranchGroup: vi.fn(),
listTasksByBranchGroup: vi.fn().mockResolvedValue([]),
...workflowSettingsResolverStubs(workflowValues),
_updates: updates,
});
}
@@ -93,9 +124,11 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
function makeStatefulStore(task: MockTask, settings: Record<string, unknown> = {}) {
const emitter = new EventEmitter();
let state = structuredClone(task);
const { projectSettings, workflowValues } = splitMovedSettings(settings);
return Object.assign(emitter, {
getTask: vi.fn(async () => structuredClone(state)),
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
getSettings: vi.fn().mockResolvedValue({ ...projectSettings }),
...workflowSettingsResolverStubs(workflowValues),
updateTask: vi.fn(async (_id: string, patch: Record<string, unknown>) => {
state = { ...state, ...patch };
}),

View File

@@ -24,7 +24,7 @@ const execAsync = promisify(exec);
const execFileAsync: (file: string, args: string[], opts?: import("node:child_process").ExecFileOptions) => Promise<{ stdout: string; stderr: string }> = (file, args, opts) =>
(promisify(childProcess.execFile) as (f: string, a: string[], o?: object) => Promise<{ stdout: string; stderr: string }>)(file, args, opts);
import type { TaskStore } from "@fusion/core";
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core";
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded, resolveEffectiveSettings } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine";
import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool } from "@fusion/engine";
@@ -448,6 +448,17 @@ export async function processPullRequestMergeTask(
const branch = getTaskBranchName(task.id);
const settings = await store.getSettings();
// `requirePrApproval` MOVED to workflow settings (U4): resolve the task's
// effective workflow settings and overlay them onto the project/global base so
// the approval-gate reads the per-(workflow, project) value post-migration. The
// resolver never throws — a missing workflow degrades to built-in declaration
// defaults (requirePrApproval=false), matching the pre-move default.
try {
const effective = await resolveEffectiveSettings(store, { id: task.id });
Object.assign(settings as Record<string, unknown>, effective);
} catch {
// Defensive: keep the base settings if effective resolution fails entirely.
}
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
const projectDefaultBranch = resolvedIntegrationBranch;