4e04d09f67
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
242 lines
9.0 KiB
TypeScript
242 lines
9.0 KiB
TypeScript
import {
|
|
isPRReviewFromMetadata,
|
|
isIssueWatchFromMetadata,
|
|
issueFieldsFromMetadata,
|
|
} from "@/lib/metadata-utils";
|
|
import type { KanbanState, TaskDependencyRef } from "@/lib/state/slices/kanban/types";
|
|
import type {
|
|
ForegroundActivity,
|
|
TaskPendingAction,
|
|
TaskPriority,
|
|
TaskState,
|
|
TaskSessionState,
|
|
} from "@/lib/types/http";
|
|
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
|
|
|
|
type KanbanTask = KanbanState["tasks"][number];
|
|
|
|
/**
|
|
* Shape accepted by {@link toKanbanTask}. Satisfied by both the HTTP Task DTO
|
|
* (which nests repositories) and the backend's `task.updated` WebSocket
|
|
* payload (which flattens `repository_id` and uses `task_id`).
|
|
*
|
|
* The single publisher / single mapper design relies on this shape being the
|
|
* contract; anyone changing what the backend emits needs to keep it in sync,
|
|
* and the parity test in `map-task.test.ts` will catch drift.
|
|
*/
|
|
export type TaskLike = {
|
|
id?: string;
|
|
task_id?: string;
|
|
workspace_id?: string;
|
|
workflow_id?: string;
|
|
workflow_step_id?: string;
|
|
title?: string;
|
|
description?: string | null;
|
|
autopilot?: boolean;
|
|
position?: number;
|
|
state?: TaskState;
|
|
priority?: TaskPriority;
|
|
repositories?: Array<{
|
|
id?: string;
|
|
repository_id: string;
|
|
base_branch?: string;
|
|
checkout_branch?: string;
|
|
position?: number;
|
|
}>;
|
|
workspace_folders?: Array<{
|
|
id: string;
|
|
local_path: string;
|
|
display_name: string;
|
|
position: number;
|
|
}>;
|
|
repository_id?: string;
|
|
primary_session_id?: string | null;
|
|
primary_session_state?: TaskSessionState | string | null;
|
|
primary_session_pending_action?: TaskPendingAction | null;
|
|
task_pending_action?: TaskPendingAction | null;
|
|
/** True when the task's session was mid-turn when the backend died. */
|
|
interrupted?: boolean;
|
|
/** True when a workflow step's auto_start_agent on_enter action failed to
|
|
* launch a run for this task. */
|
|
auto_start_failed?: boolean;
|
|
foreground_activity?: ForegroundActivity | null;
|
|
active_subagent_count?: number;
|
|
session_count?: number | null;
|
|
review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null;
|
|
primary_executor_id?: string | null;
|
|
primary_executor_type?: string | null;
|
|
primary_executor_name?: string | null;
|
|
is_remote_executor?: boolean;
|
|
parent_id?: string | null;
|
|
updated_at?: string;
|
|
created_at?: string;
|
|
wip_admitted?: boolean;
|
|
queued_for_step_id?: string | null;
|
|
queued_at?: string | null;
|
|
blocked?: boolean;
|
|
blocked_reason?: string | null;
|
|
depends_on?: TaskDependencyRef[] | null;
|
|
blocks?: TaskDependencyRef[] | null;
|
|
start_when_unblocked?: boolean;
|
|
metadata?: Record<string, unknown> | null;
|
|
archived_at?: string | null;
|
|
status_summary?: TaskStatusSummary | null;
|
|
status_summary_invalidated?: boolean;
|
|
};
|
|
|
|
export type WorkspaceMode = "inherit_parent" | "new_workspace" | "shared_group";
|
|
|
|
export function workspaceModeFromMetadata(
|
|
metadata: TaskLike["metadata"],
|
|
): WorkspaceMode | undefined {
|
|
const workspace = metadata?.workspace;
|
|
if (!workspace || typeof workspace !== "object") return undefined;
|
|
const mode = (workspace as Record<string, unknown>).mode;
|
|
if (mode === "inherit_parent" || mode === "new_workspace" || mode === "shared_group") {
|
|
return mode;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function pickRepositoryId(source: TaskLike): string | undefined {
|
|
return source.repository_id ?? source.repositories?.[0]?.repository_id ?? undefined;
|
|
}
|
|
|
|
function pickId(source: TaskLike): string {
|
|
return (source.id ?? source.task_id ?? "") as string;
|
|
}
|
|
|
|
export function pickPendingAction(action: unknown): TaskPendingAction | null | undefined {
|
|
if (action === null) return null;
|
|
if (action === "clarification" || action === "permission") {
|
|
return action;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function pickForegroundActivity(
|
|
activity: TaskLike["foreground_activity"],
|
|
): ForegroundActivity | null | undefined {
|
|
return activity === null ? null : (activity ?? undefined);
|
|
}
|
|
|
|
type KanbanTaskRepository = NonNullable<KanbanTask["repositories"]>[number];
|
|
|
|
function pickRepositories(source: TaskLike): KanbanTaskRepository[] | undefined {
|
|
if (!source.repositories) return undefined;
|
|
return source.repositories.map((r, idx) => ({
|
|
id: r.id ?? "",
|
|
repository_id: r.repository_id,
|
|
base_branch: r.base_branch ?? "",
|
|
checkout_branch: r.checkout_branch,
|
|
position: r.position ?? idx,
|
|
}));
|
|
}
|
|
|
|
function pickWorkspaceFolders(source: TaskLike): KanbanTask["workspaceFolders"] | undefined {
|
|
return source.workspace_folders?.map((folder) => ({ ...folder }));
|
|
}
|
|
|
|
/**
|
|
* Build a canonical {@link KanbanTask} from either an HTTP DTO or a WebSocket
|
|
* payload. Both paths share this helper so a single publisher change can never
|
|
* leave them out of sync again (cf. sidebar filter regressions where the HTTP
|
|
* snapshot derived `isPRReview` but the WS handler didn't).
|
|
*/
|
|
/**
|
|
* dependencyProjection normalizes the derived dependency fields.
|
|
*
|
|
* A payload that carries NO dependency key at all leaves them undefined rather
|
|
* than defaulting to "no edges". Most `task.updated` publishers are lightweight
|
|
* and omit them, and inventing empty arrays here is destructive twice over: the
|
|
* event can insert the task into the board store before boot hydration runs, and
|
|
* hydration then keeps the "fresher" WS copy — permanently erasing the edges and
|
|
* blanking the dependency chip. When the keys ARE present (every boot payload and
|
|
* list read computes them), an empty list is a real "no edges".
|
|
*/
|
|
function dependencyProjection(
|
|
source: TaskLike,
|
|
): Partial<
|
|
Pick<KanbanTask, "blocked" | "blockedReason" | "dependsOn" | "blocks" | "startWhenUnblocked">
|
|
> {
|
|
const mentionsDependencies =
|
|
source.blocked !== undefined ||
|
|
source.depends_on !== undefined ||
|
|
source.blocks !== undefined ||
|
|
source.start_when_unblocked !== undefined;
|
|
if (!mentionsDependencies) return {};
|
|
return {
|
|
blocked: source.blocked ?? false,
|
|
blockedReason: source.blocked_reason ?? undefined,
|
|
dependsOn: source.depends_on ?? [],
|
|
blocks: source.blocks ?? [],
|
|
startWhenUnblocked: source.start_when_unblocked ?? false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* `primaryExecutorId`/`Name` map with `?? undefined`, so an omitted wire field
|
|
* survives mapping as `undefined`. `isRemoteExecutor` maps with `?? false`
|
|
* instead, so it can never signal omission on its own — but the backend only
|
|
* ever emits `is_remote_executor` alongside `primary_executor_type` (both are
|
|
* derived from the same executor snapshot), so gating the whole bundle on
|
|
* `primaryExecutorType`'s own `undefined`-ness is the reliable signal.
|
|
*/
|
|
export function preserveOmittedExecutorFields(merged: KanbanTask, existing: KanbanTask): void {
|
|
if (merged.primaryExecutorType !== undefined) return;
|
|
copyPrimaryExecutorFields(merged, existing);
|
|
}
|
|
|
|
export function copyPrimaryExecutorFields(merged: KanbanTask, existing: KanbanTask): void {
|
|
merged.primaryExecutorId = existing.primaryExecutorId;
|
|
merged.primaryExecutorType = existing.primaryExecutorType;
|
|
merged.primaryExecutorName = existing.primaryExecutorName;
|
|
merged.isRemoteExecutor = existing.isRemoteExecutor;
|
|
}
|
|
|
|
export function toKanbanTask(source: TaskLike): KanbanTask {
|
|
return {
|
|
id: pickId(source),
|
|
workspaceId: source.workspace_id,
|
|
workflowId: source.workflow_id,
|
|
workflowStepId: source.workflow_step_id ?? "",
|
|
title: source.title ?? "",
|
|
description: source.description ?? undefined,
|
|
autopilot: source.autopilot,
|
|
priority: source.priority,
|
|
position: source.position ?? 0,
|
|
state: source.state,
|
|
repositoryId: pickRepositoryId(source),
|
|
repositories: pickRepositories(source),
|
|
workspaceFolders: pickWorkspaceFolders(source),
|
|
primarySessionId: source.primary_session_id ?? undefined,
|
|
primarySessionState: source.primary_session_state ?? undefined,
|
|
primarySessionPendingAction: pickPendingAction(source.primary_session_pending_action),
|
|
taskPendingAction: pickPendingAction(source.task_pending_action),
|
|
interrupted: source.interrupted,
|
|
autoStartFailed: source.auto_start_failed,
|
|
foregroundActivity: pickForegroundActivity(source.foreground_activity),
|
|
activeSubagentCount: source.active_subagent_count ?? undefined,
|
|
sessionCount: source.session_count ?? undefined,
|
|
reviewStatus: source.review_status ?? undefined,
|
|
primaryExecutorId: source.primary_executor_id ?? undefined,
|
|
primaryExecutorType: source.primary_executor_type ?? undefined,
|
|
primaryExecutorName: source.primary_executor_name ?? undefined,
|
|
isRemoteExecutor: source.is_remote_executor ?? false,
|
|
parentTaskId: source.parent_id ?? undefined,
|
|
workspaceMode: workspaceModeFromMetadata(source.metadata),
|
|
updatedAt: source.updated_at,
|
|
createdAt: source.created_at,
|
|
wipAdmitted: source.wip_admitted,
|
|
queuedForStepId: source.queued_for_step_id,
|
|
queuedAt: source.queued_at,
|
|
...dependencyProjection(source),
|
|
statusSummary: source.status_summary,
|
|
metadata: source.metadata,
|
|
isArchived: source.archived_at != null,
|
|
isPRReview: isPRReviewFromMetadata(source.metadata),
|
|
isIssueWatch: isIssueWatchFromMetadata(source.metadata),
|
|
...issueFieldsFromMetadata(source.metadata),
|
|
} as KanbanTask;
|
|
}
|