import { isPRReviewFromMetadata, isIssueWatchFromMetadata, issueFieldsFromMetadata, } from "@/lib/metadata-utils"; import type { KanbanState, TaskDependencyRef } from "@/lib/state/slices/kanban/types"; import type { ForegroundActivity, TaskPendingAction, TaskOrigin, 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; origin?: TaskOrigin | string | null; repositories?: Array<{ id?: string; repository_id: string; base_branch?: string; checkout_branch?: string; branch_policy_id?: string; branch_policy_name?: string; branch_policy_base_branch?: string; branch_policy_branch_template?: string; branch_policy_pull_request_target?: 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; parked_on_background_work?: boolean; parked_revision?: number; parked_epoch?: number; active_subagent_count?: number; session_count?: number | null; review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null; primary_executor_id?: string | null; primary_executor_profile_id?: string | null; primary_executor_type?: string | null; primary_executor_name?: string | null; primary_agent_name?: string | null; primary_agent_profile_id?: string | null; labels?: string | string[] | null; is_remote_executor?: boolean; parent_id?: string | null; assignee_user_id?: string; 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 | 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).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; } function pickLabels(source: TaskLike): string[] { if (Array.isArray(source.labels)) return source.labels.filter((label) => typeof label === "string"); if (!source.labels) return []; try { const decoded: unknown = JSON.parse(source.labels); return Array.isArray(decoded) ? decoded.filter((label): label is string => typeof label === "string") : []; } catch { return []; } } 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[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, branch_policy_id: r.branch_policy_id, branch_policy_name: r.branch_policy_name, branch_policy_base_branch: r.branch_policy_base_branch, branch_policy_branch_template: r.branch_policy_branch_template, branch_policy_pull_request_target: r.branch_policy_pull_request_target, position: r.position ?? idx, })); } function pickWorkspaceFolders(source: TaskLike): KanbanTask["workspaceFolders"] | undefined { return source.workspace_folders?.map((folder) => ({ ...folder })); } function primaryExecutorProjection(source: TaskLike) { return { primaryExecutorId: source.primary_executor_id ?? undefined, primaryExecutorProfileId: source.primary_executor_profile_id ?? undefined, primaryExecutorType: source.primary_executor_type ?? undefined, primaryExecutorName: source.primary_executor_name ?? undefined, isRemoteExecutor: source.is_remote_executor ?? false, }; } /** * 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 > { 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.primaryExecutorProfileId = existing.primaryExecutorProfileId; merged.primaryExecutorType = existing.primaryExecutorType; merged.primaryExecutorName = existing.primaryExecutorName; merged.isRemoteExecutor = existing.isRemoteExecutor; } /** * "Unassigned" has one spelling in the store: undefined. * * The wire uses an empty string for it (that is what a PATCH sends to * unassign), so without this the store would hold both "" and undefined for * the same state and every consumer would need to know that. */ export function pickAssignee(value: string | undefined): string | undefined { return value ? value : undefined; } // eslint-disable-next-line complexity -- Maps the complete task wire contract into the shared Kanban model. 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, origin: source.origin, 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), parkedOnBackgroundWork: source.parked_on_background_work, parkedRevision: source.parked_revision, parkedEpoch: source.parked_epoch, activeSubagentCount: source.active_subagent_count ?? undefined, sessionCount: source.session_count ?? undefined, reviewStatus: source.review_status ?? undefined, ...primaryExecutorProjection(source), primaryAgentProfileId: source.primary_agent_profile_id ?? undefined, primaryAgentName: source.primary_agent_name ?? undefined, labels: pickLabels(source), assigneeUserId: pickAssignee(source.assignee_user_id), 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; }