Files
2026-09-19 21:26:36 +01:00

205 lines
7.7 KiB
TypeScript

import type { AppState, KanbanState } from "@/lib/state/store";
import { primaryTaskRepository } from "@/lib/types/http";
import type { WorkflowSnapshot, Message, Task } from "@/lib/types/http";
import { pickAssignee, pickPendingAction, workspaceModeFromMetadata } from "@/lib/kanban/map-task";
import {
isPRReviewFromMetadata,
isIssueWatchFromMetadata,
issueFieldsFromMetadata,
} from "@/lib/metadata-utils";
type KanbanTask = KanbanState["tasks"][number];
function snapshotTaskWorkspaceId(
task: WorkflowSnapshot["tasks"][number],
fallback: string,
): string {
return task.workspace_id ?? fallback;
}
// Split out so the snapshot->task mapper (already at the complexity limit
// from its long list of `??` fallbacks) doesn't need to absorb one more.
function resolveAutoStartFailed(task: WorkflowSnapshot["tasks"][number]): boolean {
return task.auto_start_failed ?? false;
}
function resolveWorkspaceOrphaned(task: WorkflowSnapshot["tasks"][number]): boolean {
return task.workspace_orphaned ?? false;
}
function snapshotWorkflowStep(step: WorkflowSnapshot["steps"][number]) {
return {
id: step.id,
title: step.name,
color: step.color ?? "bg-neutral-400",
position: step.position,
events: step.events,
allow_manual_move: step.allow_manual_move,
auto_advance_requires_signal: step.auto_advance_requires_signal,
prompt: step.prompt,
is_start_step: step.is_start_step,
show_in_command_panel: step.show_in_command_panel,
agent_profile_id: step.agent_profile_id,
session_target: step.session_target ?? null,
profile_session_start_policy: step.profile_session_start_policy,
profile_session_end_policy: step.profile_session_end_policy,
complete_task_on_enter: step.complete_task_on_enter,
cancel_triggers_turn_complete: step.cancel_triggers_turn_complete,
wip_limit: step.wip_limit,
pull_from_step_id: step.pull_from_step_id ?? null,
stage_type: step.stage_type,
order_revision: step.order_revision,
};
}
function primaryExecutorFields(task: Task) {
return {
primaryExecutorId: task.primary_executor_id ?? undefined,
primaryExecutorProfileId: task.primary_executor_profile_id ?? undefined,
primaryExecutorType: task.primary_executor_type ?? undefined,
primaryExecutorName: task.primary_executor_name ?? undefined,
isRemoteExecutor: task.is_remote_executor ?? false,
};
}
function parseLabels(value: string | undefined): string[] {
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((label): label is string => typeof label === "string")
: [];
} catch {
return [];
}
}
export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
// Handle empty snapshot (ephemeral tasks have no workflow)
if (!snapshot.workflow) {
return {
kanban: {
workflowId: "",
isLoading: false,
steps: [],
tasks: [],
},
};
}
const tasks = snapshot.tasks
.filter((task) => !task.is_ephemeral) // Filter out ephemeral tasks (e.g., quick chat)
// eslint-disable-next-line complexity -- The boot mapper projects one complete task record for hydration.
.map((task) => {
const workflowStepId = task.workflow_step_id;
if (!workflowStepId) return null;
const primary = primaryTaskRepository(task.repositories);
return {
id: task.id,
workspaceId: snapshotTaskWorkspaceId(task, snapshot.workflow.workspace_id),
workflowId: snapshot.workflow.id,
workflowStepId,
workflowAgentOverrides: task.workflow_agent_overrides,
title: task.title,
description: task.description ?? undefined,
autopilot: task.autopilot ?? false,
position: task.position ?? 0,
state: task.state,
// Preserve WIP admission and queue metadata during HTTP snapshot
// refreshes. The Go boot mapper, WebSocket handler, and canonical
// task mapper already carry these fields. This path must keep the
// same values so queue classification and ordering stay consistent
// after a workflow switch or reconnect.
priority: task.priority,
origin: task.origin,
createdAt: task.created_at,
wipAdmitted: task.wip_admitted,
queuedForStepId: task.queued_for_step_id,
queuedAt: task.queued_at,
repositoryId: primary?.repository_id ?? undefined,
repositories: task.repositories?.map((r) => ({
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,
})),
primarySessionId: task.primary_session_id ?? undefined,
primarySessionState: task.primary_session_state ?? undefined,
...primaryExecutorFields(task),
primaryAgentProfileId: task.primary_agent_profile_id ?? undefined,
primaryAgentName: task.primary_agent_name ?? undefined,
labels: parseLabels(task.labels),
primarySessionPendingAction: pickPendingAction(task.primary_session_pending_action),
taskPendingAction: pickPendingAction(task.task_pending_action),
foregroundActivity: task.foreground_activity ?? undefined,
autoStartFailed: resolveAutoStartFailed(task),
parkedOnBackgroundWork: task.parked_on_background_work,
parkedRevision: task.parked_revision,
parkedEpoch: task.parked_epoch,
workspaceOrphaned: resolveWorkspaceOrphaned(task),
activeSubagentCount: task.active_subagent_count ?? undefined,
sessionCount: task.session_count ?? undefined,
reviewStatus: task.review_status ?? undefined,
statusSummary: task.status_summary,
assigneeUserId: pickAssignee(task.assignee_user_id),
parentTaskId: task.parent_id ?? undefined,
metadata: task.metadata,
workspaceMode: workspaceModeFromMetadata(task.metadata),
updatedAt: task.updated_at,
isPRReview: isPRReviewFromMetadata(task.metadata),
isIssueWatch: isIssueWatchFromMetadata(task.metadata),
...issueFieldsFromMetadata(task.metadata),
} as KanbanTask;
})
.filter((task): task is KanbanTask => task !== null);
return {
kanban: {
workflowId: snapshot.workflow.id,
isLoading: false,
steps: snapshot.steps.map(snapshotWorkflowStep),
tasks,
},
};
}
export function taskToState(
task: Task,
sessionId?: string | null,
messages?: { items: Message[]; hasMore?: boolean; oldestCursor?: string | null },
): Partial<AppState> {
const resolvedSessionId = sessionId ?? messages?.items[0]?.session_id ?? null;
return {
tasks: {
activeTaskId: task.id,
activeSessionId: resolvedSessionId,
pinnedSessionId: null,
lastSessionByTaskId: resolvedSessionId ? { [task.id]: resolvedSessionId } : {},
resumeSkippedSessionIds: {},
},
messages:
resolvedSessionId && messages
? {
bySession: {
[resolvedSessionId]: messages.items,
},
metaBySession: {
[resolvedSessionId]: {
isLoading: false,
isLoadingMore: false,
historyInitialized: true,
hasMore: messages.hasMore ?? false,
oldestCursor: messages.oldestCursor ?? messages.items[0]?.id ?? null,
},
},
}
: undefined,
};
}