Honor project and member auto-merge consent consistently throughout shared-branch integration. - Apply project autoMerge settings and explicit member overrides to shared-member hold decisions. - Expose shared-member integration hold state and controls in branch-group dashboard and lifecycle APIs. - Add regression coverage, operator documentation, and a patch changeset. Files changed: .changeset/fn-8823-shared-member-consent.md | 7 ++ docs/dashboard-guide.md | 4 +- packages/core/src/__tests__/task-merge.test.ts | 57 +++++++++++++++ packages/core/src/index.gate.ts | 3 + packages/core/src/index.ts | 3 + packages/core/src/merge/task-merge.ts | 81 ++++++++++++++++++--- .../dashboard/app/api/tasks/tasks-lifecycle.ts | 11 +++ .../dashboard/app/components/BranchGroupCard.css | 84 ++++++++++++++++++++++ .../dashboard/app/components/BranchGroupCard.tsx | 58 ++++++++++++++- packages/dashboard/app/components/ListView.tsx | 14 +++- .../dashboard/app/components/TaskDetailModal.tsx | 20 +++++- .../components/__tests__/BranchGroupCard.test.tsx | 44 +++++++++++- .../components/__tests__/TaskDetailModal.test.tsx | 24 ++++++- .../app/components/dashboard/MainContent.tsx | 2 +- .../app/components/useRightDockController.tsx | 2 +- packages/dashboard/app/hooks/useModalManager.ts | 6 ++ .../src/__tests__/routes-branch-groups.test.ts | 42 ++++++----- .../src/routes/register-branch-groups-routes.ts | 9 ++- ...cutor-live-branch-group-auto-merge-hold.test.ts | 21 +++--- .../src/__tests__/group-merge-coordinator.test.ts | 7 +- .../workflow-graph-executor-handlers.test.ts | 33 ++++++--- packages/engine/src/executor.ts | 27 ++++--- packages/engine/src/project-engine.ts | 9 ++- packages/engine/src/self-healing.ts | 16 ++--- .../src/workflow-node-runners/merge-runner.ts | 13 ++-- 25 files changed, 503 insertions(+), 94 deletions(-) Fusion-Task-Id: FN-8823 Fusion-Task-Lineage: 19a8ed3f-26e1-4c8e-8782-ca366718a3f2 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
/**
|
|
* FNXC:CodeOrganization 2026-07-16-12:00:
|
|
* Task lifecycle client API (promote/delete/merge/pause/archive/plan) peeled from legacy.ts.
|
|
*/
|
|
import type {
|
|
Task,
|
|
MergeResult,
|
|
BranchGroup,
|
|
BranchGroupPrState,
|
|
PlannerOverseerRuntimeSnapshot,
|
|
PlannerInterventionEntry,
|
|
} from "@fusion/core";
|
|
import { api } from "../client/client.js";
|
|
import { withProjectId } from "../client/health.js";
|
|
import type { DeleteTaskOptions, ArchiveTaskOptions } from "./tasks.js";
|
|
|
|
/**
|
|
* Manually promote a held card out of its hold column (U9).
|
|
*
|
|
* FNXC:WorkflowScheduling 2026-07-25-04:55:
|
|
* `force` waives the `unplanned-for-execution` gate (pending replan / pre-release
|
|
* Plan Review) and starts execution anyway. It is only ever sent after the
|
|
* operator confirms the override dialog the plain promote's rejection raises;
|
|
* capacity is still enforced server-side, so a forced promote can still reject.
|
|
*/
|
|
export function promoteTask(id: string, projectId?: string, options?: { force?: boolean }): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/promote`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ force: options?.force === true }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Soft-deletes a task by setting `deletedAt` server-side while preserving the row/artifacts,
|
|
* and keeping the task ID reserved.
|
|
*
|
|
* `removeDependencyReferences` allows forced delete by first removing incoming dependency links.
|
|
* `githubIssueAction` controls linked issue behavior (`close`, `delete`, or `leave`) during deletion.
|
|
*
|
|
* Hard removal is handled only by the archive-cleanup pipeline (after archival), not this endpoint.
|
|
*/
|
|
export function deleteTask(id: string, projectId?: string, options?: DeleteTaskOptions): Promise<Task> {
|
|
const search = new URLSearchParams();
|
|
if (options?.removeDependencyReferences) {
|
|
search.set("removeDependencyReferences", "true");
|
|
}
|
|
if (options?.removeLineageReferences) {
|
|
search.set("removeLineageReferences", "true");
|
|
}
|
|
if (options?.githubIssueAction) {
|
|
search.set("githubIssueAction", options.githubIssueAction);
|
|
}
|
|
// FNXC:TaskLifecycle 2026-07-16-12:00:
|
|
// FN-5233 route reads delete modifiers from query params, including allowResurrection.
|
|
if (options?.allowResurrection) {
|
|
search.set("allowResurrection", "true");
|
|
}
|
|
|
|
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
|
return api<Task>(withProjectId(`/tasks/${id}${suffix}`, projectId), { method: "DELETE" });
|
|
}
|
|
|
|
export function mergeTask(id: string, projectId?: string): Promise<MergeResult> {
|
|
return api<MergeResult>(withProjectId(`/tasks/${id}/merge`, projectId), { method: "POST" });
|
|
}
|
|
|
|
export interface BranchGroupMemberSummary {
|
|
taskId: string;
|
|
title: string;
|
|
column: Task["column"];
|
|
landed: boolean;
|
|
}
|
|
|
|
export interface BranchGroupReviewAdvisory {
|
|
taskId: string;
|
|
workflowStepId: string;
|
|
workflowStepName: string;
|
|
status: "passed" | "failed" | "advisory_failure" | "skipped" | "pending";
|
|
verdict?: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE";
|
|
notes?: string;
|
|
findings: Array<{ id: string; title: string; body: string; filePath?: string; line?: number; severity?: "low" | "medium" | "high" | "critical" }>;
|
|
}
|
|
|
|
export interface BranchGroupSummary extends BranchGroup {
|
|
members: BranchGroupMemberSummary[];
|
|
advisories: BranchGroupReviewAdvisory[];
|
|
completion: {
|
|
landed: number;
|
|
total: number;
|
|
complete: boolean;
|
|
};
|
|
}
|
|
|
|
export interface PromoteBranchGroupResult {
|
|
groupId: string;
|
|
status?: BranchGroup["status"];
|
|
prState?: BranchGroupPrState;
|
|
prNumber?: number;
|
|
prUrl?: string;
|
|
}
|
|
|
|
export function apiListBranchGroups(projectId?: string, status?: BranchGroup["status"]): Promise<{ groups: BranchGroupSummary[] }> {
|
|
const search = new URLSearchParams();
|
|
if (status) search.set("status", status);
|
|
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
|
return api<{ groups: BranchGroupSummary[] }>(withProjectId(`/branch-groups${suffix}`, projectId));
|
|
}
|
|
|
|
export function apiGetBranchGroup(id: string, projectId?: string): Promise<{ group: BranchGroupSummary }> {
|
|
return api<{ group: BranchGroupSummary }>(withProjectId(`/branch-groups/${id}`, projectId));
|
|
}
|
|
|
|
export function apiAssignTaskBranchGroup(
|
|
payload: { taskId: string; groupId?: string | null; branchName?: string },
|
|
projectId?: string,
|
|
): Promise<{ taskId: string; groupId: string | null }> {
|
|
return api<{ taskId: string; groupId: string | null }>(withProjectId("/branch-groups/assign", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function apiPromoteBranchGroup(id: string, projectId?: string): Promise<PromoteBranchGroupResult> {
|
|
return api<PromoteBranchGroupResult>(withProjectId(`/branch-groups/${id}/promote`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
export function apiAbandonBranchGroup(id: string, projectId?: string): Promise<{ groupId: string; group: BranchGroupSummary }> {
|
|
return api<{ groupId: string; group: BranchGroupSummary }>(withProjectId(`/branch-groups/${id}/abandon`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
export type RecoverBranchBindingOutcome =
|
|
| { taskId: string; result: "applied"; branch: string; aheadCount: number; integrationBase: string; previousBranch: string | null }
|
|
| { taskId: string; result: "skipped"; reason: "binding-intact" | "no-live-branch" | "ambiguous-candidates" | "no-unique-work"; candidates?: Array<{ branch: string; aheadCount: number }> };
|
|
|
|
export function retryTask(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/retry`, projectId), { method: "POST" });
|
|
}
|
|
|
|
/*
|
|
FNXC:ReviewLaneBypass 2026-07-09-00:00:
|
|
Operator/privileged review-lane bypass primitive (FN-7720). Bypasses the latest
|
|
failed pre-merge review step of an in-review task so it can advance past the
|
|
gate; a non-empty `reason` is mandatory and audited server-side. Mirrors
|
|
`retryTask`'s client shape.
|
|
*/
|
|
export function bypassReview(id: string, reason: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/bypass-review`, projectId), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
}
|
|
|
|
export function relaunchCliSession(sessionId: string, projectId?: string): Promise<{ ok: boolean; taskId?: string }> {
|
|
return api<{ ok: boolean; taskId?: string }>(
|
|
withProjectId(`/cli-sessions/${encodeURIComponent(sessionId)}/relaunch`, projectId),
|
|
{ method: "POST" },
|
|
);
|
|
}
|
|
|
|
export function recoverBranchBinding(id: string, projectId?: string): Promise<RecoverBranchBindingOutcome> {
|
|
return api<RecoverBranchBindingOutcome>(withProjectId(`/tasks/${id}/recover-branch-binding`, projectId), { method: "POST" });
|
|
}
|
|
|
|
export function resetTask(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/reset`, projectId), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ confirm: true }),
|
|
});
|
|
}
|
|
|
|
export function duplicateTask(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/duplicate`, projectId), { method: "POST" });
|
|
}
|
|
|
|
export function pauseTask(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/pause`, projectId), { method: "POST" });
|
|
}
|
|
|
|
export function unpauseTask(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/unpause`, projectId), { method: "POST" });
|
|
}
|
|
|
|
/*
|
|
FNXC:PlannerOversight 2026-07-04-17:00:
|
|
FN-7517 task-detail planner-overseer controls. `nudgeOverseer` asks the
|
|
engine to inject one steering-guidance comment into the task's currently
|
|
watched stage right now (guidance-only — never a merge/PR/destructive side
|
|
effect); `stopOverseer` disables active oversight for the task (writes the
|
|
per-task `plannerOversightLevel: "off"` override); `explainOverseer` is a
|
|
read of the current overseer runtime state (watched stage, reason, last
|
|
action, attempt count/limit) for the "explain current action" panel. Each
|
|
returns an `applied: false`/`snapshot: null` style result rather than
|
|
throwing when oversight is off/inactive or the engine runtime is
|
|
unavailable — callers should treat that as a normal disabled state, not an
|
|
error toast.
|
|
*/
|
|
export interface OverseerControlResult {
|
|
applied: boolean;
|
|
reason: string;
|
|
task?: Task;
|
|
}
|
|
|
|
export function nudgeOverseer(id: string, projectId?: string): Promise<OverseerControlResult> {
|
|
return api<OverseerControlResult>(withProjectId(`/tasks/${id}/overseer/nudge`, projectId), { method: "POST" });
|
|
}
|
|
|
|
export function stopOverseer(id: string, projectId?: string): Promise<OverseerControlResult> {
|
|
return api<OverseerControlResult>(withProjectId(`/tasks/${id}/overseer/stop`, projectId), { method: "POST" });
|
|
}
|
|
|
|
export function explainOverseer(id: string, projectId?: string): Promise<{ snapshot: PlannerOverseerRuntimeSnapshot | null }> {
|
|
return api<{ snapshot: PlannerOverseerRuntimeSnapshot | null }>(withProjectId(`/tasks/${id}/overseer/explain`, projectId), { method: "GET" });
|
|
}
|
|
|
|
/*
|
|
FNXC:PlannerOversight 2026-07-04-18:00:
|
|
FN-7519 read-only client fetch for the planner-intervention timeline. Mirrors
|
|
`explainOverseer`'s pattern; never mutates state and resolves to an empty
|
|
array when the task has no recorded interventions.
|
|
*/
|
|
export function fetchPlannerInterventionTimeline(id: string, projectId?: string): Promise<{ entries: PlannerInterventionEntry[] }> {
|
|
return api<{ entries: PlannerInterventionEntry[] }>(withProjectId(`/tasks/${id}/overseer/interventions`, projectId), { method: "GET" });
|
|
}
|
|
|
|
export function archiveTask(id: string, projectId?: string, options?: ArchiveTaskOptions): Promise<Task> {
|
|
const search = new URLSearchParams();
|
|
if (options?.removeLineageReferences) {
|
|
search.set("removeLineageReferences", "true");
|
|
}
|
|
|
|
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
|
return api<Task>(withProjectId(`/tasks/${id}/archive${suffix}`, projectId), { method: "POST" });
|
|
}
|
|
|
|
export function unarchiveTask(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/unarchive`, projectId), { method: "POST" });
|
|
}
|
|
|
|
/*
|
|
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
|
|
Client-side contract for `POST /tasks/:id/revert` (route owned by FN-7523/
|
|
FN-7524/FN-7547/FN-7548 — see the `FNXC:TaskRevert` block in
|
|
`register-task-workflow-routes.ts`). This is a discriminated union, NOT a
|
|
`Task` — the source task's column/status is never mutated by this call; the
|
|
caller (useTasks' `revertTask` op) refreshes the task list afterward so any
|
|
newly-created revert commit / AI-undo task becomes visible, without patching
|
|
the source task's column directly.
|
|
*/
|
|
export interface RevertTaskWorkspaceRepoResult {
|
|
repo: string;
|
|
classification?: string;
|
|
revertCommitSha?: string;
|
|
conflicts?: unknown;
|
|
alreadyReverted?: boolean;
|
|
}
|
|
|
|
export interface RevertTaskGitResult {
|
|
mode: "git";
|
|
clean: boolean;
|
|
revertCommitSha?: string;
|
|
revertCommitShas?: string[];
|
|
conflicts?: unknown;
|
|
alreadyReverted?: boolean;
|
|
unsupported?: boolean;
|
|
needsHuman?: boolean;
|
|
reason?: string;
|
|
workspace?: { repos: RevertTaskWorkspaceRepoResult[] };
|
|
}
|
|
|
|
export interface RevertTaskAiResult {
|
|
mode: "ai";
|
|
createdTaskId: string;
|
|
alreadyOpen?: boolean;
|
|
}
|
|
|
|
export type RevertTaskResult = RevertTaskGitResult | RevertTaskAiResult;
|
|
|
|
export interface RevertTaskOptions {
|
|
mode?: "git" | "ai" | "auto";
|
|
granularity?: "squash" | "per-sha";
|
|
}
|
|
|
|
export function revertTask(id: string, projectId?: string, body?: RevertTaskOptions): Promise<RevertTaskResult> {
|
|
return api<RevertTaskResult>(withProjectId(`/tasks/${id}/revert`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(body ?? {}),
|
|
});
|
|
}
|
|
|
|
export function archiveAllDone(projectId?: string): Promise<Task[]> {
|
|
/*
|
|
FNXC:ArchiveConfirmGate 2026-07-26-16:30:
|
|
The bulk archive route now requires an explicit `{ confirm: true }` body (400 without
|
|
it) so non-UI callers cannot silently sweep the Done column. The UI's own user-facing
|
|
confirmation happens before this client call; this body is the machine-level ack.
|
|
*/
|
|
return api<{ archived: Task[] }>(withProjectId("/tasks/archive-all-done", projectId), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ confirm: true }),
|
|
}).then(
|
|
(response) => response.archived
|
|
);
|
|
}
|
|
|
|
export function approvePlan(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/approve-plan`, projectId), { method: "POST" });
|
|
}
|
|
|
|
export function rejectPlan(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/reject-plan`, projectId), { method: "POST" });
|
|
}
|
|
|