Files
fusion/packages/dashboard/app/api/tasks-lifecycle.ts
gsxdsm 06a5fd813d refactor: package code organization waves 6–7 (#2166)
## Summary

Waves 6–7 of package code organization (plan:
`docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md`),
after #2148.

### Wave 6
| New module | Parent |
|---|---|
| `merger-autostash-labels.ts` | `merger.ts` |
| `types/agent-state.ts` | `types.ts` |
| `app/api/tasks-lifecycle.ts` | `legacy.ts` |
| `task-store/task-row-mappers.ts` | `remaining-ops-3.ts` (rename) |

### Wave 7
| New module | Parent |
|---|---|
| `self-healing-optional-step-revision.ts` | `self-healing.ts` |
| `self-healing-path-utils.ts` | `self-healing.ts` |
| `merger-git-parse` (+ `quoteArg`, `getBranchChangedFiles`) |
`merger.ts` |
| `app/api/settings.ts` | `legacy.ts` |

Public import paths stay stable via re-exports.

## Test plan
- [x] engine + dashboard typecheck (incl. app)
- [x] eslint on touched modules
- [x] merger-autostash / parse-porcelain / getBranchChanged / api-tasks
- [ ] CI merge gate

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a dedicated task lifecycle API client (task promotion, merge,
retry/reset/duplicate, pause/unpause, archive/unarchive, revert, plan
approve/reject) plus branch-group and planner oversight actions.
* Added a settings/config API service (effective task settings, update
check/refresh/install).
* Introduced standardized agent lifecycle states with identity/ephemeral
detection helpers.
* **Bug Fixes**
* Improved autostash label compatibility and NUL-delimited changed-file
detection for branch diffs.
* **Refactor**
* Modularized merger labeling/parsing, self-healing helpers, and
lifecycle/type wiring while keeping behavior consistent.
* **Tests**
  * Updated merger verification tests for `git diff -z` output handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-16 15:42:22 -07:00

290 lines
11 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.js";
import { withProjectId } from "./health.js";
import type { DeleteTaskOptions, ArchiveTaskOptions } from "./tasks.js";
/** Manually promote a held card out of its hold column (U9). */
export function promoteTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/promote`, projectId), { method: "POST" });
}
/**
* 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 BranchGroupSummary extends BranchGroup {
members: BranchGroupMemberSummary[];
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[]> {
return api<{ archived: Task[] }>(withProjectId("/tasks/archive-all-done", projectId), { method: "POST" }).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" });
}