feat(FN-2496): add overlap ignore paths support to scheduler settings
- Add overlap-ignore path validation and typed settings support in core schema - Apply overlap ignore paths in scheduler overlap detection with dedicated engine tests - Add Settings modal UI and routes handling for overlap ignore paths including path-picker feedback fixes - Document overlap ignore paths in storage/settings docs and include a changeset for @runfusion/fusion
This commit is contained in:
@@ -69,6 +69,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
pollIntervalMs: 15000,
|
||||
heartbeatMultiplier: 1,
|
||||
groupOverlappingFiles: true,
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
pushAfterMerge: false,
|
||||
|
||||
@@ -1180,6 +1180,12 @@ export interface ProjectSettings {
|
||||
* Must be > 0. Default: 1 (no change). */
|
||||
heartbeatMultiplier?: number;
|
||||
groupOverlappingFiles: boolean;
|
||||
/** File/directory paths to ignore when evaluating overlap serialization.
|
||||
* Entries are project-relative paths (for example: `docs/README.md`, `docs/`, `generated/*`).
|
||||
* Absolute paths and `..` traversal are not allowed.
|
||||
* When set, matching paths are excluded from overlap checks for both
|
||||
* active in-progress tasks and in-review tasks with unmerged worktrees. */
|
||||
overlapIgnorePaths?: string[];
|
||||
autoMerge: boolean;
|
||||
/** How completed in-review tasks should be finalized when autoMerge is enabled.
|
||||
* - "direct": preserve the existing local squash-merge flow into the current branch
|
||||
|
||||
@@ -254,10 +254,74 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-overlap-ignore-group code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.settings-overlap-ignore-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.settings-overlap-ignore-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-overlap-ignore-path-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-overlap-path-picker-modal {
|
||||
max-width: min(960px, calc(100vw - var(--space-2xl)));
|
||||
}
|
||||
|
||||
.settings-overlap-path-picker-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
max-height: min(70vh, 720px);
|
||||
}
|
||||
|
||||
.settings-overlap-path-picker-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-section-divider {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: var(--space-lg) var(--space-xl);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-note {
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.settings-overlap-ignore-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-overlap-ignore-path-controls {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-overlap-ignore-row > .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.settings-overlap-path-picker-modal {
|
||||
max-width: calc(100vw - var(--space-md));
|
||||
}
|
||||
}
|
||||
|
||||
/* === Notifications Settings === */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
|
||||
import { Globe, Folder } from "lucide-react";
|
||||
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
||||
@@ -10,6 +10,8 @@ import { ThemeSelector } from "./ThemeSelector";
|
||||
import "./SettingsModal.css";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
||||
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
|
||||
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
@@ -146,6 +148,7 @@ export function SettingsModal({
|
||||
pollIntervalMs: 15000,
|
||||
heartbeatMultiplier: 1,
|
||||
groupOverlappingFiles: true,
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
recycleWorktrees: false,
|
||||
@@ -183,6 +186,16 @@ export function SettingsModal({
|
||||
);
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
|
||||
|
||||
const {
|
||||
entries: overlapPathPickerEntries,
|
||||
currentPath: overlapPathPickerCurrentPath,
|
||||
setPath: setOverlapPathPickerPath,
|
||||
loading: overlapPathPickerLoading,
|
||||
error: overlapPathPickerError,
|
||||
refresh: refreshOverlapPathPicker,
|
||||
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
|
||||
@@ -863,6 +876,85 @@ export function SettingsModal({
|
||||
}));
|
||||
}
|
||||
|
||||
const openOverlapPathPicker = useCallback((index: number) => {
|
||||
setOverlapPathPickerIndex(index);
|
||||
setOverlapPathPickerPath(".");
|
||||
}, [setOverlapPathPickerPath]);
|
||||
|
||||
const closeOverlapPathPicker = useCallback(() => {
|
||||
setOverlapPathPickerIndex(null);
|
||||
}, []);
|
||||
|
||||
const selectOverlapIgnorePath = useCallback((path: string) => {
|
||||
if (overlapPathPickerIndex === null) return;
|
||||
|
||||
setForm((f) => {
|
||||
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
||||
? [...f.overlapIgnorePaths]
|
||||
: [""];
|
||||
currentPaths[overlapPathPickerIndex] = path;
|
||||
return { ...f, overlapIgnorePaths: currentPaths };
|
||||
});
|
||||
|
||||
closeOverlapPathPicker();
|
||||
}, [overlapPathPickerIndex, closeOverlapPathPicker]);
|
||||
|
||||
const handleSelectCurrentDirectoryForOverlapIgnore = useCallback(() => {
|
||||
if (overlapPathPickerCurrentPath === ".") {
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryPath = overlapPathPickerCurrentPath.endsWith("/")
|
||||
? overlapPathPickerCurrentPath
|
||||
: `${overlapPathPickerCurrentPath}/`;
|
||||
|
||||
selectOverlapIgnorePath(directoryPath);
|
||||
}, [overlapPathPickerCurrentPath, selectOverlapIgnorePath]);
|
||||
|
||||
const handleOverlapPathPickerOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeOverlapPathPicker();
|
||||
}
|
||||
}, [closeOverlapPathPicker]);
|
||||
|
||||
const handleOverlapIgnorePathChange = useCallback((index: number, value: string) => {
|
||||
setForm((f) => {
|
||||
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
||||
? [...f.overlapIgnorePaths]
|
||||
: [""];
|
||||
currentPaths[index] = value;
|
||||
return { ...f, overlapIgnorePaths: currentPaths };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRemoveOverlapIgnorePath = useCallback((index: number) => {
|
||||
setForm((f) => {
|
||||
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
||||
? [...f.overlapIgnorePaths]
|
||||
: [""];
|
||||
const nextPaths = currentPaths.filter((_, i) => i !== index);
|
||||
return { ...f, overlapIgnorePaths: nextPaths.length > 0 ? nextPaths : [] };
|
||||
});
|
||||
|
||||
if (overlapPathPickerIndex === index) {
|
||||
closeOverlapPathPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlapPathPickerIndex !== null && overlapPathPickerIndex > index) {
|
||||
setOverlapPathPickerIndex(overlapPathPickerIndex - 1);
|
||||
}
|
||||
}, [overlapPathPickerIndex, closeOverlapPathPicker]);
|
||||
|
||||
const handleAddOverlapIgnorePath = useCallback(() => {
|
||||
setForm((f) => {
|
||||
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
||||
? f.overlapIgnorePaths
|
||||
: [""];
|
||||
return { ...f, overlapIgnorePaths: [...currentPaths, ""] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (prefixError || presetDraft) return;
|
||||
try {
|
||||
@@ -870,6 +962,7 @@ export function SettingsModal({
|
||||
...form,
|
||||
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
|
||||
taskPrefix: form.taskPrefix?.trim() || undefined,
|
||||
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
|
||||
};
|
||||
|
||||
// Always save both global and project settings with strict scope separation.
|
||||
@@ -2018,7 +2111,52 @@ export function SettingsModal({
|
||||
<small>When enabled, tasks that modify the same files are queued serially to avoid merge conflicts</small>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }} />
|
||||
<div className="form-group settings-overlap-ignore-group">
|
||||
<label>Ignored overlap paths</label>
|
||||
<small>
|
||||
Optional file or directory paths to ignore when overlap serialization is enabled.
|
||||
Paths are project-relative (for example <code>docs/</code> or <code>generated/*</code>).
|
||||
</small>
|
||||
<div className="settings-overlap-ignore-list">
|
||||
{(form.overlapIgnorePaths && form.overlapIgnorePaths.length > 0 ? form.overlapIgnorePaths : [""]).map((path, index) => (
|
||||
<div key={`overlap-ignore-${index}`} className="settings-overlap-ignore-row">
|
||||
<div className="settings-overlap-ignore-path-controls">
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
placeholder="docs/"
|
||||
onChange={(e) => handleOverlapIgnorePathChange(index, e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => openOverlapPathPicker(index)}
|
||||
aria-label={`Browse path for ignored overlap entry ${index + 1}`}
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleRemoveOverlapIgnorePath(index)}
|
||||
disabled={(form.overlapIgnorePaths ?? []).length === 0 && index === 0}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleAddOverlapIgnorePath}
|
||||
>
|
||||
Add ignored path
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<h5 className="settings-section-heading">Step Execution</h5>
|
||||
<div className="form-group">
|
||||
@@ -3527,6 +3665,60 @@ export function SettingsModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overlapPathPickerIndex !== null && (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={handleOverlapPathPickerOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Browse workspace path"
|
||||
>
|
||||
<div className="modal modal-lg settings-overlap-path-picker-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Select ignored overlap path</h3>
|
||||
<button className="modal-close" onClick={closeOverlapPathPicker} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body settings-overlap-path-picker-body">
|
||||
<p className="settings-overlap-path-picker-note">
|
||||
Choose a file to ignore directly, or navigate into a folder and select the current directory.
|
||||
</p>
|
||||
<FileBrowser
|
||||
entries={overlapPathPickerEntries}
|
||||
currentPath={overlapPathPickerCurrentPath}
|
||||
onSelectFile={selectOverlapIgnorePath}
|
||||
onNavigate={setOverlapPathPickerPath}
|
||||
loading={overlapPathPickerLoading}
|
||||
error={overlapPathPickerError}
|
||||
onRetry={refreshOverlapPathPicker}
|
||||
workspace="project"
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<div className="modal-actions-left">
|
||||
<small>
|
||||
Current directory: <code>{overlapPathPickerCurrentPath === "." ? "(project root)" : overlapPathPickerCurrentPath}</code>
|
||||
</small>
|
||||
</div>
|
||||
<div className="modal-actions-right">
|
||||
<button className="btn btn-sm" onClick={closeOverlapPathPicker}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSelectCurrentDirectoryForOverlapIgnore}
|
||||
disabled={overlapPathPickerCurrentPath === "."}
|
||||
>
|
||||
Select current directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Confirmation Dialog */}
|
||||
{importDialogOpen && importPreview && (
|
||||
|
||||
@@ -29,6 +29,7 @@ const mockTestMemoryRetrieval = vi.fn();
|
||||
const mockInstallQmd = vi.fn();
|
||||
const mockFetchGitRemotesDetailed = vi.fn();
|
||||
const mockFetchDashboardHealth = vi.fn();
|
||||
const mockUseWorkspaceFileBrowser = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
||||
@@ -75,6 +76,18 @@ vi.mock("../PluginSlot", () => ({
|
||||
PluginSlot: () => <div data-testid="plugin-slot">Plugin slot content</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
|
||||
useWorkspaceFileBrowser: (...args: unknown[]) => mockUseWorkspaceFileBrowser(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../FileBrowser", () => ({
|
||||
FileBrowser: ({ onSelectFile }: { onSelectFile: (path: string) => void }) => (
|
||||
<div data-testid="mock-overlap-file-browser">
|
||||
<button type="button" onClick={() => onSelectFile("README.md")}>Select README.md</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const defaultSettings = {
|
||||
@@ -82,6 +95,7 @@ const defaultSettings = {
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: true,
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
pushAfterMerge: false,
|
||||
@@ -164,6 +178,14 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
mockFetchGitRemotesDetailed.mockResolvedValue([]);
|
||||
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
|
||||
mockUseWorkspaceFileBrowser.mockReturnValue({
|
||||
entries: [],
|
||||
currentPath: ".",
|
||||
setPath: vi.fn(),
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
|
||||
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
||||
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
||||
@@ -412,6 +434,60 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduling overlap ignore paths", () => {
|
||||
it("renders existing overlap ignore paths from settings", async () => {
|
||||
mockFetchSettings.mockResolvedValue({
|
||||
...defaultSettings,
|
||||
overlapIgnorePaths: ["docs/", "generated/*"],
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
|
||||
expect(screen.getByDisplayValue("docs/")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports selecting ignore paths through the browse picker", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: /browse workspace path/i })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Select README.md" }));
|
||||
|
||||
expect(screen.getByDisplayValue("README.md")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes overlapIgnorePaths in save payload", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Select README.md" }));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /add ignored path/i }));
|
||||
const inputs = screen.getAllByPlaceholderText("docs/");
|
||||
await userEvent.type(inputs[1], "generated/*");
|
||||
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
||||
expect(payload.overlapIgnorePaths).toEqual(["README.md", "generated/*"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Number input clearing", () => {
|
||||
it("allows clearing maxConcurrent without leaving a stuck zero", async () => {
|
||||
renderModal();
|
||||
|
||||
@@ -505,6 +505,36 @@ function validateModelPresets(value: unknown): ModelPreset[] | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeOverlapIgnorePaths(value: unknown): string[] | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (!Array.isArray(value)) {
|
||||
throw badRequest("overlapIgnorePaths must be an array of project-relative paths");
|
||||
}
|
||||
|
||||
const normalized = value.map((entry, index) => {
|
||||
if (typeof entry !== "string") {
|
||||
throw badRequest(`overlapIgnorePaths[${index}] must be a string`);
|
||||
}
|
||||
|
||||
const trimmed = entry.trim().replaceAll("\\", "/");
|
||||
if (!trimmed) {
|
||||
throw badRequest(`overlapIgnorePaths[${index}] cannot be empty`);
|
||||
}
|
||||
|
||||
if (isAbsolute(trimmed) || /^[a-zA-Z]:\//.test(trimmed)) {
|
||||
throw badRequest(`overlapIgnorePaths[${index}] must be a project-relative path`);
|
||||
}
|
||||
|
||||
if (/^\.{1,2}(\/|$)/.test(trimmed) || /(^|\/)\.\.(\/|$)/.test(trimmed)) {
|
||||
throw badRequest(`overlapIgnorePaths[${index}] cannot include '..' traversal`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
});
|
||||
|
||||
return [...new Set(normalized)];
|
||||
}
|
||||
|
||||
// ── Run-Audit Timeline Types & Helpers ─────────────────────────────────────
|
||||
|
||||
/** Valid domain filters for run-audit queries. */
|
||||
@@ -2671,6 +2701,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
|
||||
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(clientSettings, "overlapIgnorePaths")) {
|
||||
clientSettings.overlapIgnorePaths = sanitizeOverlapIgnorePaths(clientSettings.overlapIgnorePaths);
|
||||
}
|
||||
|
||||
// Validate backup settings if provided
|
||||
if (clientSettings.autoBackupSchedule !== undefined && !validateBackupSchedule(clientSettings.autoBackupSchedule)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { PrMonitor } from "../pr-monitor.js";
|
||||
import { Scheduler, pathsOverlap } from "../scheduler.js";
|
||||
import { Scheduler, pathsOverlap, filterPathsByIgnoreList } from "../scheduler.js";
|
||||
import { AgentSemaphore } from "../concurrency.js";
|
||||
import type { TaskStore, Task, TaskDetail } from "@fusion/core";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -126,6 +126,25 @@ describe("pathsOverlap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterPathsByIgnoreList", () => {
|
||||
it("filters exact ignored file paths", () => {
|
||||
expect(filterPathsByIgnoreList(["docs/README.md", "src/index.ts"], ["docs/README.md"]))
|
||||
.toEqual(["src/index.ts"]);
|
||||
});
|
||||
|
||||
it("filters ignored directories with and without trailing slash", () => {
|
||||
expect(filterPathsByIgnoreList(["docs/guide.md", "docs/api/types.md", "src/index.ts"], ["docs"]))
|
||||
.toEqual(["src/index.ts"]);
|
||||
expect(filterPathsByIgnoreList(["docs/guide.md", "src/index.ts"], ["docs/"]))
|
||||
.toEqual(["src/index.ts"]);
|
||||
});
|
||||
|
||||
it("filters ignored glob-style directories", () => {
|
||||
expect(filterPathsByIgnoreList(["generated/*", "generated/client.ts", "src/index.ts"], ["generated/*"]))
|
||||
.toEqual(["src/index.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler", () => {
|
||||
// Helper to create mock MissionStore (shared across mission-related test suites)
|
||||
function createMockMissionStore(overrides = {}) {
|
||||
@@ -659,6 +678,122 @@ describe("Scheduler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlap ignore paths", () => {
|
||||
it("allows scheduling when overlap is only on ignored files", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "in-progress" }),
|
||||
createMockTask({ id: "FN-002", column: "todo" }),
|
||||
];
|
||||
|
||||
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
|
||||
if (taskId === "FN-001") return ["docs/README.md"];
|
||||
if (taskId === "FN-002") return ["docs/README.md"];
|
||||
return [];
|
||||
});
|
||||
|
||||
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||
const moveTask = vi.fn().mockResolvedValue(undefined);
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
groupOverlappingFiles: true,
|
||||
overlapIgnorePaths: ["docs/README.md"],
|
||||
}),
|
||||
parseFileScopeFromPrompt: parseScopeMock,
|
||||
updateTask,
|
||||
moveTask,
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress");
|
||||
expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
|
||||
});
|
||||
|
||||
it("allows scheduling when overlap is only within ignored directories", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "in-review", worktree: "/test/project/.worktrees/fn-001" }),
|
||||
createMockTask({ id: "FN-002", column: "todo" }),
|
||||
];
|
||||
|
||||
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
|
||||
if (taskId === "FN-001") return ["docs/guide.md"];
|
||||
if (taskId === "FN-002") return ["docs/reference.md"];
|
||||
return [];
|
||||
});
|
||||
|
||||
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||
const moveTask = vi.fn().mockResolvedValue(undefined);
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
groupOverlappingFiles: true,
|
||||
overlapIgnorePaths: ["docs/"],
|
||||
}),
|
||||
parseFileScopeFromPrompt: parseScopeMock,
|
||||
updateTask,
|
||||
moveTask,
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(moveTask).toHaveBeenCalledWith("FN-002", "in-progress");
|
||||
expect(updateTask).not.toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
|
||||
});
|
||||
|
||||
it("still blocks overlap for non-ignored paths", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "in-progress" }),
|
||||
createMockTask({ id: "FN-002", column: "todo" }),
|
||||
];
|
||||
|
||||
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
|
||||
if (taskId === "FN-001") return ["src/scheduler.ts"];
|
||||
if (taskId === "FN-002") return ["src/scheduler.ts"];
|
||||
return [];
|
||||
});
|
||||
|
||||
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||
const moveTask = vi.fn().mockResolvedValue(undefined);
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
groupOverlappingFiles: true,
|
||||
overlapIgnorePaths: ["docs/"],
|
||||
}),
|
||||
parseFileScopeFromPrompt: parseScopeMock,
|
||||
updateTask,
|
||||
moveTask,
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store);
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" });
|
||||
expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("worktree reservation", () => {
|
||||
it("assigns a planned worktree path before moving a task to in-progress", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
|
||||
@@ -51,6 +51,45 @@ export function pathsOverlap(a: string[], b: string[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeOverlapPath(path: string): string {
|
||||
return path.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function isIgnoredOverlapPath(path: string, ignorePath: string): boolean {
|
||||
const normalizedPath = normalizeOverlapPath(path);
|
||||
const normalizedIgnore = normalizeOverlapPath(ignorePath);
|
||||
|
||||
if (normalizedIgnore.endsWith("/*")) {
|
||||
const directory = normalizedIgnore.slice(0, -2);
|
||||
return normalizedPath === directory || normalizedPath.startsWith(`${directory}/`);
|
||||
}
|
||||
|
||||
if (normalizedIgnore.endsWith("/")) {
|
||||
const directory = normalizedIgnore.slice(0, -1);
|
||||
return normalizedPath === directory || normalizedPath.startsWith(normalizedIgnore);
|
||||
}
|
||||
|
||||
return normalizedPath === normalizedIgnore || normalizedPath.startsWith(`${normalizedIgnore}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove scope entries that match configured overlap-ignore paths.
|
||||
* Used by scheduler overlap gating so shared safe paths (docs/generated/etc.)
|
||||
* can bypass serialization while keeping overlap protection enabled globally.
|
||||
*/
|
||||
export function filterPathsByIgnoreList(paths: string[], ignorePaths?: string[]): string[] {
|
||||
if (!ignorePaths || ignorePaths.length === 0) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
const normalizedIgnorePaths = ignorePaths.map(normalizeOverlapPath).filter(Boolean);
|
||||
if (normalizedIgnorePaths.length === 0) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
return paths.filter((path) => !normalizedIgnorePaths.some((ignore) => isIgnoredOverlapPath(path, ignore)));
|
||||
}
|
||||
|
||||
export interface SchedulerOptions {
|
||||
/** Max concurrent in-progress tasks. Default: 2 */
|
||||
maxConcurrent?: number;
|
||||
@@ -592,10 +631,12 @@ export class Scheduler {
|
||||
*/
|
||||
const activeScopes = new Map<string, string[]>();
|
||||
if (settings.groupOverlappingFiles) {
|
||||
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
|
||||
// In-progress tasks
|
||||
for (const t of inProgress) {
|
||||
const scope = await this.store.parseFileScopeFromPrompt(t.id);
|
||||
if (scope.length > 0) activeScopes.set(t.id, scope);
|
||||
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
|
||||
if (filteredScope.length > 0) activeScopes.set(t.id, filteredScope);
|
||||
}
|
||||
// In-review tasks with unmerged worktrees
|
||||
const inReviewWithWorktree = tasks.filter(
|
||||
@@ -603,7 +644,8 @@ export class Scheduler {
|
||||
);
|
||||
for (const t of inReviewWithWorktree) {
|
||||
const scope = await this.store.parseFileScopeFromPrompt(t.id);
|
||||
if (scope.length > 0) activeScopes.set(t.id, scope);
|
||||
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
|
||||
if (filteredScope.length > 0) activeScopes.set(t.id, filteredScope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,7 +700,11 @@ export class Scheduler {
|
||||
|
||||
// Check file scope overlap when enabled
|
||||
if (settings.groupOverlappingFiles) {
|
||||
const taskScope = await this.store.parseFileScopeFromPrompt(task.id);
|
||||
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
|
||||
const taskScope = filterPathsByIgnoreList(
|
||||
await this.store.parseFileScopeFromPrompt(task.id),
|
||||
overlapIgnorePaths,
|
||||
);
|
||||
if (taskScope.length > 0) {
|
||||
let overlappingTaskId: string | null = null;
|
||||
for (const [ipId, ipScope] of activeScopes) {
|
||||
@@ -716,7 +762,10 @@ export class Scheduler {
|
||||
|
||||
// Track newly started task's file scope for overlap with remaining todo tasks
|
||||
if (settings.groupOverlappingFiles) {
|
||||
const scope = await this.store.parseFileScopeFromPrompt(task.id);
|
||||
const scope = filterPathsByIgnoreList(
|
||||
await this.store.parseFileScopeFromPrompt(task.id),
|
||||
settings.overlapIgnorePaths,
|
||||
);
|
||||
if (scope.length > 0) activeScopes.set(task.id, scope);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user