Files
kandev/apps/web/lib/kanban/map-task.ts
T
2026-08-22 14:46:25 +00:00

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;
}