739 lines
33 KiB
TypeScript
739 lines
33 KiB
TypeScript
/**
|
|
* collabSync — translates backend collaboration data format to frontend store types.
|
|
*
|
|
* Backend uses snake_case; frontend uses camelCase. This module bridges the two.
|
|
*/
|
|
|
|
import type { ChatChannel, ChatMessage, ChannelType } from '../types/chat'
|
|
import type { KanbanBoard, KanbanColumn, KanbanPhase, KanbanTask, TaskPriority, AgentAnimStatus, ProgressEntry, Session, SessionMode, Project, EmployeeAssignment, RoleAggregatedStatus, RoleWorkItemActivitySection, RoleWorkItemRow, RoleWorkItemSummary, WorkItemGate, WorkItemProgressEntry } from '../types/kanban'
|
|
import { normalizeProgressLog, normalizeWorkItemLog } from './progressLog'
|
|
import { canonicalizeSessionExecutionIdentity } from './sessionIdentity'
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
const UNIX_MS_THRESHOLD = 1_000_000_000_000
|
|
const WORK_ITEM_EVENT_RE = /^\[Company:([^\]]+)\]\s*(.*)$/
|
|
const COMPANY_RUNTIME_EVENT_RE = /^\[Company\]\s*(.*)$/
|
|
|
|
export function mergeSessionDetailHasMore(
|
|
previous: boolean | undefined,
|
|
incoming: boolean,
|
|
isHistoryPage: boolean,
|
|
): boolean {
|
|
// A latest-page refresh only describes that 200-row response. It must not
|
|
// reopen an older-history cursor that the user already exhausted.
|
|
if (!isHistoryPage && previous === false) return false
|
|
return incoming
|
|
}
|
|
|
|
function normalizeAgentRuntimeStatus(rawStatus: unknown, rawAgentStatus: unknown): AgentAnimStatus | undefined {
|
|
if (rawAgentStatus === 'idle' || rawAgentStatus === 'reflecting' || rawAgentStatus === 'tool_active') {
|
|
return rawAgentStatus
|
|
}
|
|
return rawStatus === 'running' ? 'reflecting' : undefined
|
|
}
|
|
|
|
function normalizeEpochMs(raw: unknown): number {
|
|
const value = typeof raw === 'string' ? Number(raw) : raw
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) return Date.now()
|
|
return value < UNIX_MS_THRESHOLD ? value * 1000 : value
|
|
}
|
|
|
|
function mapBackendProgressLog(raw: any): ProgressEntry[] {
|
|
if (!Array.isArray(raw)) return []
|
|
return normalizeProgressLog(raw
|
|
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
|
|
.map((entry) => ({
|
|
timestamp: normalizeEpochMs(entry.timestamp),
|
|
type: (entry.type ?? 'status_change') as ProgressEntry['type'],
|
|
summary: typeof entry.summary === 'string' ? entry.summary : '',
|
|
detail: typeof entry.detail === 'string' ? entry.detail : undefined,
|
|
turnId: typeof entry.turn_id === 'string'
|
|
? entry.turn_id
|
|
: typeof entry.turnId === 'string'
|
|
? entry.turnId
|
|
: undefined,
|
|
itemId: typeof entry.item_id === 'string'
|
|
? entry.item_id
|
|
: typeof entry.itemId === 'string'
|
|
? entry.itemId
|
|
: undefined,
|
|
streamId: typeof entry.stream_id === 'string'
|
|
? entry.stream_id
|
|
: typeof entry.streamId === 'string'
|
|
? entry.streamId
|
|
: undefined,
|
|
toolCallId: typeof entry.tool_call_id === 'string'
|
|
? entry.tool_call_id
|
|
: typeof entry.toolCallId === 'string'
|
|
? entry.toolCallId
|
|
: undefined,
|
|
permissionGroupKey: typeof entry.permission_group_key === 'string'
|
|
? entry.permission_group_key
|
|
: typeof entry.permissionGroupKey === 'string'
|
|
? entry.permissionGroupKey
|
|
: undefined,
|
|
seq: typeof entry.seq === 'number' && Number.isFinite(entry.seq) ? entry.seq : undefined,
|
|
executionMode: typeof entry.execution_mode === 'string'
|
|
? entry.execution_mode
|
|
: typeof entry.executionMode === 'string'
|
|
? entry.executionMode
|
|
: undefined,
|
|
})))
|
|
}
|
|
|
|
function mapBackendWorkItemLog(raw: any): WorkItemProgressEntry[] {
|
|
if (!Array.isArray(raw)) return []
|
|
return normalizeWorkItemLog(raw
|
|
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
|
|
.map((entry) => {
|
|
const runtimeTaskId = typeof entry.runtime_task_id === 'string'
|
|
? entry.runtime_task_id
|
|
: typeof entry.runtimeTaskId === 'string'
|
|
? entry.runtimeTaskId
|
|
: typeof entry.execution_turn_id === 'string'
|
|
? entry.execution_turn_id
|
|
: typeof entry.executionTurnId === 'string'
|
|
? entry.executionTurnId
|
|
: undefined
|
|
const executionTurnId = typeof entry.execution_turn_id === 'string'
|
|
? entry.execution_turn_id
|
|
: typeof entry.executionTurnId === 'string'
|
|
? entry.executionTurnId
|
|
: runtimeTaskId
|
|
return {
|
|
timestamp: normalizeEpochMs(entry.timestamp),
|
|
type: (entry.type ?? 'gate_result') as WorkItemProgressEntry['type'],
|
|
workItemProjectionId: typeof entry.work_item_projection_id === 'string'
|
|
? entry.work_item_projection_id
|
|
: typeof entry.workItemProjectionId === 'string'
|
|
? entry.workItemProjectionId
|
|
: undefined,
|
|
workItemTurnType: typeof entry.work_item_turn_type === 'string'
|
|
? entry.work_item_turn_type
|
|
: typeof entry.workItemTurnType === 'string'
|
|
? entry.workItemTurnType
|
|
: undefined,
|
|
workItemProjectionTitle: typeof entry.work_item_projection_title === 'string'
|
|
? entry.work_item_projection_title
|
|
: typeof entry.workItemProjectionTitle === 'string'
|
|
? entry.workItemProjectionTitle
|
|
: undefined,
|
|
runtimeTaskId,
|
|
executionTurnId,
|
|
roleName: typeof entry.role_name === 'string'
|
|
? entry.role_name
|
|
: typeof entry.roleName === 'string'
|
|
? entry.roleName
|
|
: undefined,
|
|
detail: typeof entry.detail === 'string' ? entry.detail : undefined,
|
|
}
|
|
}))
|
|
}
|
|
|
|
function workItemProjectionTitle(projectionId: string): string {
|
|
return projectionId
|
|
.replace(/[_-]/g, ' ')
|
|
.replace(/\b\w/g, (char) => char.toUpperCase())
|
|
}
|
|
|
|
function workItemEntryType(action: string): WorkItemProgressEntry['type'] {
|
|
const actionLower = action.toLowerCase()
|
|
if (actionLower.includes('starting') || actionLower.includes('started')) return 'work_item_started'
|
|
if (actionLower.includes('gate passed') || actionLower.includes('approved') || actionLower.includes('completed')) return 'gate_approved'
|
|
if (actionLower.includes('rejected') || actionLower.includes('reworking')) return 'gate_rejected'
|
|
if (actionLower.includes('awaiting peer')) return 'awaiting_peer'
|
|
if (actionLower.includes('awaiting manager review')) return 'awaiting_manager_review'
|
|
if (
|
|
actionLower.includes('awaiting user')
|
|
|| actionLower.includes('awaiting human review')
|
|
|| actionLower.includes('awaiting review')
|
|
|| actionLower.includes('awaiting confirmation')
|
|
|| actionLower.includes('awaiting feedback')
|
|
) {
|
|
return 'awaiting_human'
|
|
}
|
|
if (actionLower.includes('failed')) return 'work_item_failed'
|
|
if (actionLower.includes('deadlock')) return 'deadlock'
|
|
return 'gate_result'
|
|
}
|
|
|
|
function workItemEntryFromMessage(message: ChatMessage): WorkItemProgressEntry | null {
|
|
const content = message.content.trim()
|
|
const meta = (message.metadata ?? {}) as Record<string, unknown>
|
|
const roleName = message.sender !== 'system' && message.sender !== 'assistant'
|
|
? message.senderName
|
|
: undefined
|
|
const executionTurnId = typeof meta.forwarded_from === 'string'
|
|
? meta.forwarded_from
|
|
: typeof meta.task_id === 'string'
|
|
? meta.task_id
|
|
: typeof meta.taskId === 'string'
|
|
? meta.taskId
|
|
: undefined
|
|
|
|
const projectionMatch = WORK_ITEM_EVENT_RE.exec(content)
|
|
if (projectionMatch) {
|
|
const [, projectionId, actionRaw] = projectionMatch
|
|
const action = actionRaw.trim()
|
|
return {
|
|
timestamp: message.timestamp,
|
|
type: workItemEntryType(action),
|
|
workItemProjectionId: projectionId,
|
|
workItemProjectionTitle: workItemProjectionTitle(projectionId),
|
|
roleName,
|
|
detail: action || undefined,
|
|
runtimeTaskId: executionTurnId,
|
|
executionTurnId,
|
|
}
|
|
}
|
|
|
|
const runtimeMatch = COMPANY_RUNTIME_EVENT_RE.exec(content)
|
|
if (runtimeMatch) {
|
|
const action = runtimeMatch[1].trim()
|
|
return {
|
|
timestamp: message.timestamp,
|
|
type: workItemEntryType(action),
|
|
workItemProjectionId: 'company_runtime',
|
|
workItemProjectionTitle: 'Company Runtime',
|
|
roleName,
|
|
detail: action || undefined,
|
|
runtimeTaskId: executionTurnId,
|
|
executionTurnId,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function deriveWorkItemLog(messages: ChatMessage[]): WorkItemProgressEntry[] {
|
|
return messages
|
|
.map(workItemEntryFromMessage)
|
|
.filter((entry): entry is WorkItemProgressEntry => entry != null)
|
|
.sort((a, b) => a.timestamp - b.timestamp)
|
|
}
|
|
|
|
function hydrateCompanyRuntimeSessions(sessions: Session[], messages: ChatMessage[]): Session[] {
|
|
if (sessions.length === 0 || messages.length === 0) return sessions
|
|
|
|
const messagesByChannel = new Map<string, ChatMessage[]>()
|
|
for (const message of messages) {
|
|
const bucket = messagesByChannel.get(message.channelId)
|
|
if (bucket) bucket.push(message)
|
|
else messagesByChannel.set(message.channelId, [message])
|
|
}
|
|
|
|
return sessions.map((session) => {
|
|
const derivedWorkItemLog = deriveWorkItemLog(messagesByChannel.get(session.channelId) ?? [])
|
|
const workItemLog = session.workItemLog && session.workItemLog.length > 0
|
|
? session.workItemLog
|
|
: derivedWorkItemLog
|
|
const latestConcreteWorkItem = [...workItemLog]
|
|
.reverse()
|
|
.find((entry) => {
|
|
const projectionId = entry.workItemProjectionId
|
|
return projectionId && projectionId !== 'company_runtime'
|
|
})
|
|
const workItemProjectionId = session.mode === 'primary'
|
|
? (latestConcreteWorkItem?.workItemProjectionId ?? session.workItemProjectionId)
|
|
: (session.workItemProjectionId ?? latestConcreteWorkItem?.workItemProjectionId)
|
|
const isCompanyRuntime = session.isCompanyRuntime || (session.mode === 'primary' && workItemLog.length > 0)
|
|
|
|
if (
|
|
workItemLog.length === 0
|
|
&& workItemProjectionId === session.workItemProjectionId
|
|
&& isCompanyRuntime === session.isCompanyRuntime
|
|
) {
|
|
return session
|
|
}
|
|
|
|
return {
|
|
...session,
|
|
isCompanyRuntime,
|
|
workItemProjectionId,
|
|
...(workItemLog.length > 0 ? { workItemLog } : {}),
|
|
}
|
|
})
|
|
}
|
|
|
|
function hydrateCompanyRuntimeTasks(tasks: KanbanTask[], sessions: Session[]): KanbanTask[] {
|
|
if (tasks.length === 0 || sessions.length === 0) return tasks
|
|
|
|
const sessionsByTaskId = new Map(sessions.map((session) => [session.taskId, session]))
|
|
return tasks.map((task) => {
|
|
const session = sessionsByTaskId.get(task.id)
|
|
if (!session) return task
|
|
|
|
const workItemProjectionId = session.workItemProjectionId ?? task.workItemProjectionId
|
|
const companyProfile = session.companyProfile ?? task.companyProfile
|
|
const workItemRoleId = session.workItemRoleId ?? task.workItemRoleId
|
|
const workItemRoleName = session.workItemRoleName ?? task.workItemRoleName
|
|
|
|
if (
|
|
workItemProjectionId === task.workItemProjectionId
|
|
&& companyProfile === task.companyProfile
|
|
&& workItemRoleId === task.workItemRoleId
|
|
&& workItemRoleName === task.workItemRoleName
|
|
) {
|
|
return task
|
|
}
|
|
|
|
return {
|
|
...task,
|
|
workItemProjectionId,
|
|
companyProfile,
|
|
workItemRoleId,
|
|
workItemRoleName,
|
|
}
|
|
})
|
|
}
|
|
|
|
export function mapBackendChannel(raw: any): ChatChannel {
|
|
return {
|
|
id: raw.channel_id ?? raw.id ?? '',
|
|
type: (raw.channel_type ?? raw.type ?? 'session') as ChannelType,
|
|
name: raw.name ?? '',
|
|
officeId: raw.office_id ?? raw.officeId,
|
|
participants: raw.participants ?? [],
|
|
pinned: !!raw.pinned,
|
|
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
|
|
}
|
|
}
|
|
|
|
export function mapBackendMessage(raw: any): ChatMessage {
|
|
const rawSenderName = raw.sender_name ?? raw.senderName ?? ''
|
|
const senderName = String(rawSenderName).trim().toLowerCase() === 'task generalist'
|
|
? 'OPC'
|
|
: rawSenderName
|
|
return {
|
|
id: raw.message_id ?? raw.id ?? '',
|
|
channelId: raw.channel_id ?? raw.channelId ?? '',
|
|
sender: raw.sender ?? '',
|
|
senderName,
|
|
content: raw.content ?? '',
|
|
timestamp: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.timestamp ?? Date.now()),
|
|
replyToId: raw.reply_to_id ?? raw.replyToId,
|
|
mentions: raw.mentions ?? [],
|
|
metadata: raw.metadata,
|
|
senderDeleted: !!(raw.sender_deleted ?? raw.senderDeleted),
|
|
}
|
|
}
|
|
|
|
export function mapBackendBoard(raw: any): KanbanBoard {
|
|
return {
|
|
id: raw.board_id ?? raw.id ?? '',
|
|
name: raw.name ?? '',
|
|
description: raw.description,
|
|
color: raw.color ?? '#3b82f6',
|
|
officeId: raw.office_id ?? raw.officeId,
|
|
prefix: raw.prefix ?? 'T',
|
|
nextTaskNum: raw.next_task_num ?? raw.nextTaskNum ?? 1,
|
|
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
|
|
updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at * 1000 : (raw.updatedAt ?? Date.now()),
|
|
}
|
|
}
|
|
|
|
export function mapBackendColumn(raw: any): KanbanColumn {
|
|
return {
|
|
id: raw.column_id ?? raw.id ?? '',
|
|
boardId: raw.board_id ?? raw.boardId ?? '',
|
|
name: raw.name ?? '',
|
|
color: raw.color ?? '#888',
|
|
sortOrder: raw.sort_order ?? raw.sortOrder ?? 0,
|
|
isTerminal: !!(raw.is_terminal ?? raw.isTerminal),
|
|
// Phase 2: work-item column metadata
|
|
roleLabel: raw.role_label ?? raw.roleLabel,
|
|
gateType: raw.gate_type ?? raw.gateType,
|
|
isParallel: raw.is_parallel ?? raw.isParallel,
|
|
}
|
|
}
|
|
|
|
export function mapBackendTask(raw: any): KanbanTask {
|
|
const agentStatus = normalizeAgentRuntimeStatus(raw.status, raw.agent_status ?? raw.agentStatus)
|
|
// Backend now emits the work-item phase plus a `kanban_column` projection.
|
|
// The legacy `status: <column>` alias on the kanban-card payload was removed
|
|
// when DelegationWorkItem.status was retired, so we accept either shape and
|
|
// fall back to the explicit `column_id` for the few payloads that still use
|
|
// the older field name.
|
|
const columnId = raw.kanban_column ?? raw.column_id ?? raw.columnId ?? ''
|
|
const cardId = raw.work_item_id ?? raw.workItemId ?? raw.task_id ?? raw.id ?? ''
|
|
const runtimeTaskId = raw.runtime_task_id ?? raw.runtimeTaskId
|
|
?? raw.execution_turn_id ?? raw.executionTurnId
|
|
?? ((raw.work_item_id ?? raw.workItemId) ? undefined : (raw.task_id ?? raw.id))
|
|
const executionTurnId = raw.execution_turn_id ?? raw.executionTurnId ?? runtimeTaskId
|
|
const executionMode = raw.execution_mode ?? raw.executionMode
|
|
const rawProjectionId = raw.work_item_projection_id ?? raw.workItemProjectionId
|
|
const isTaskModeRuntime = executionMode === 'task_mode' || rawProjectionId === 'task_mode_execution'
|
|
return {
|
|
id: cardId,
|
|
displayId: raw.display_id ?? raw.displayId ?? '',
|
|
boardId: raw.board_id ?? raw.boardId ?? '',
|
|
columnId,
|
|
title: raw.title ?? '',
|
|
description: raw.description,
|
|
priority: (raw.priority ?? null) as TaskPriority | null,
|
|
assigneeIds: raw.assignee_ids ?? raw.assigneeIds ?? [],
|
|
tags: raw.tags ?? [],
|
|
sortOrder: raw.sort_order ?? raw.sortOrder ?? 0,
|
|
sessionId: raw.session_id ?? raw.sessionId,
|
|
workItemId: raw.work_item_id ?? raw.workItemId,
|
|
runtimeTaskId,
|
|
executionTurnId,
|
|
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
|
|
updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at * 1000 : (raw.updatedAt ?? Date.now()),
|
|
// Phase 2: agent runtime state
|
|
agentStatus,
|
|
currentTool: raw.current_tool ?? raw.currentTool,
|
|
displayTool: raw.display_tool ?? raw.displayTool ?? raw.current_tool ?? raw.currentTool,
|
|
toolElapsedMs: raw.tool_elapsed_ms ?? raw.toolElapsedMs,
|
|
lastToolSummary: raw.last_tool_summary ?? raw.lastToolSummary,
|
|
contextTokens: raw.context_tokens ?? raw.contextTokens,
|
|
contextWindow: raw.context_window ?? raw.contextWindow,
|
|
contextRemainingPct: raw.context_remaining_pct ?? raw.contextRemainingPct,
|
|
inputTokens: raw.input_tokens ?? raw.inputTokens,
|
|
outputTokens: raw.output_tokens ?? raw.outputTokens,
|
|
totalTokens: raw.total_tokens ?? raw.totalTokens,
|
|
turnCostUsd: raw.turn_cost_usd ?? raw.turnCostUsd,
|
|
sessionCostUsd: raw.session_cost_usd ?? raw.sessionCostUsd,
|
|
pendingPermissionCount: raw.pending_permission_count ?? raw.pendingPermissionCount,
|
|
drainMode: raw.drain_mode ?? raw.drainMode,
|
|
residentStatus: raw.resident_status ?? raw.residentStatus,
|
|
actionableInboxCount: raw.actionable_inbox_count ?? raw.actionableInboxCount,
|
|
protocolBacklogCount: raw.protocol_backlog_count ?? raw.protocolBacklogCount,
|
|
notificationBacklogCount: raw.notification_backlog_count ?? raw.notificationBacklogCount,
|
|
latestNotification: raw.latest_notification ?? raw.latestNotification,
|
|
// Phase 2: work-item runtime & dependencies
|
|
workItemProjectionId: isTaskModeRuntime ? undefined : rawProjectionId,
|
|
workItemTurnType: isTaskModeRuntime ? undefined : (raw.work_item_turn_type ?? raw.workItemTurnType),
|
|
companyProfile: isTaskModeRuntime ? undefined : (raw.company_profile ?? raw.companyProfile),
|
|
orgId: isTaskModeRuntime ? undefined : (raw.org_id ?? raw.organization_id ?? raw.orgId ?? raw.organizationId),
|
|
workItemRoleId: isTaskModeRuntime ? undefined : (raw.work_item_role_id ?? raw.workItemRoleId),
|
|
workItemRoleName: isTaskModeRuntime ? undefined : (raw.work_item_role_name ?? raw.workItemRoleName),
|
|
workItemGate: isTaskModeRuntime ? undefined : _mapWorkItemGate(raw.work_item_gate ?? raw.workItemGate),
|
|
employeeAssignment: _mapEmployeeAssignment(raw.employee_assignment ?? raw.employeeAssignment),
|
|
selectedExecutionAgent: raw.selected_execution_agent ?? raw.selectedExecutionAgent,
|
|
originChannel: raw.origin_channel ?? raw.originChannel,
|
|
dependencies: raw.dependencies,
|
|
progressLog: mapBackendProgressLog(raw.progress_log ?? raw.progressLog),
|
|
handoffContext: raw.handoff_context ?? raw.handoffContext,
|
|
phase: raw.phase,
|
|
runtimeSessionId: raw.runtime_session_id ?? raw.runtimeSessionId,
|
|
resumeCursor: raw.resume_cursor ?? raw.resumeCursor,
|
|
worktreePath: raw.worktree_path ?? raw.worktreePath,
|
|
blockedReason: raw.blocked_reason ?? raw.blockedReason,
|
|
reviewVerdict: raw.review_verdict ?? raw.reviewVerdict,
|
|
reviewSummary: raw.review_summary ?? raw.reviewSummary,
|
|
reviewOwnerRoleId: raw.review_owner_role_id ?? raw.reviewOwnerRoleId,
|
|
reviewOwnerSeatId: raw.review_owner_seat_id ?? raw.reviewOwnerSeatId,
|
|
managerRoleId: raw.manager_role_id ?? raw.managerRoleId,
|
|
managerSeatId: raw.manager_seat_id ?? raw.managerSeatId,
|
|
scopeKey: raw.scope_key ?? raw.scopeKey,
|
|
completionReport: raw.completion_report ?? raw.completionReport,
|
|
reworkFeedback: raw.rework_feedback ?? raw.reworkFeedback,
|
|
planningContext: raw.planning_context ?? raw.planningContext,
|
|
deliverables: raw.deliverables,
|
|
acceptanceCriteria: raw.acceptance_criteria ?? raw.acceptanceCriteria,
|
|
delegationRationale: raw.delegation_rationale ?? raw.delegationRationale,
|
|
nonOverlapGuard: raw.non_overlap_guard ?? raw.nonOverlapGuard,
|
|
coordinationNotes: raw.coordination_notes ?? raw.coordinationNotes,
|
|
originalMessage: raw.original_message ?? raw.originalMessage,
|
|
residentAssignment: raw.resident_assignment ?? raw.residentAssignment,
|
|
memberSessionState: raw.member_session_state ?? raw.memberSessionState,
|
|
ownershipContract: raw.ownership_contract ?? raw.ownershipContract,
|
|
}
|
|
}
|
|
|
|
function _mapEmployeeAssignment(raw: any): EmployeeAssignment | undefined {
|
|
if (!raw || typeof raw !== 'object') return undefined
|
|
return {
|
|
name: raw.name,
|
|
employeeId: raw.employee_id ?? raw.employeeId,
|
|
category: raw.category,
|
|
experienceScore: raw.experience_score ?? raw.experienceScore,
|
|
domains: raw.domains,
|
|
preferredExternalAgent: raw.preferred_external_agent ?? raw.preferredExternalAgent,
|
|
promptContext: raw.prompt_context ?? raw.promptContext,
|
|
deltaContext: raw.delta_context ?? raw.deltaContext,
|
|
skillRefs: raw.skill_refs ?? raw.skillRefs,
|
|
}
|
|
}
|
|
|
|
function _mapWorkItemGate(raw: any): WorkItemGate | undefined {
|
|
if (!raw || typeof raw !== 'object') return undefined
|
|
return {
|
|
type: raw.type ?? raw.gate_type ?? raw.gateType,
|
|
reviewerRole: raw.reviewer_role ?? raw.reviewerRole,
|
|
autoApprove: raw.auto_approve ?? raw.autoApprove,
|
|
criteria: raw.criteria,
|
|
}
|
|
}
|
|
|
|
// Backend phase string is the source of truth — the frontend lives off
|
|
// that vocabulary too (see ``types/kanban.ts:KanbanPhase``). Validate at
|
|
// the deserialization boundary so a typo upstream surfaces here, not deep
|
|
// inside the rendering code.
|
|
const KNOWN_PHASES: ReadonlySet<KanbanPhase> = new Set<KanbanPhase>([
|
|
'queued', 'ready', 'ready_for_rework', 'waiting_dependencies',
|
|
'running', 'waiting_for_peer', 'waiting_for_children', 'paused', 'needs_attention',
|
|
'awaiting_manager_review', 'awaiting_human',
|
|
'approved', 'failed', 'cancelled',
|
|
])
|
|
|
|
const KNOWN_AGGREGATED_STATUS: ReadonlySet<RoleAggregatedStatus> = new Set<RoleAggregatedStatus>([
|
|
'active', 'waiting', 'pending', 'done', 'failed',
|
|
])
|
|
|
|
function coercePhase(value: unknown): KanbanPhase {
|
|
if (typeof value === 'string' && KNOWN_PHASES.has(value as KanbanPhase)) {
|
|
return value as KanbanPhase
|
|
}
|
|
// Falling back to ``queued`` matches the column id ``todo``, which is
|
|
// the safest "haven't done anything yet" placeholder.
|
|
return 'queued'
|
|
}
|
|
|
|
function coerceAggregatedStatus(value: unknown): RoleAggregatedStatus {
|
|
if (typeof value === 'string' && KNOWN_AGGREGATED_STATUS.has(value as RoleAggregatedStatus)) {
|
|
return value as RoleAggregatedStatus
|
|
}
|
|
return 'pending'
|
|
}
|
|
|
|
function coerceRuntimeStatus(value: unknown): AgentAnimStatus {
|
|
if (value === 'reflecting' || value === 'tool_active' || value === 'idle') return value
|
|
return 'idle'
|
|
}
|
|
|
|
function mapBackendRoleWorkItemActivitySection(raw: any): RoleWorkItemActivitySection {
|
|
const kind = typeof raw.kind === 'string' && raw.kind.trim() ? raw.kind : 'activity'
|
|
const title = typeof raw.title === 'string' && raw.title.trim()
|
|
? raw.title
|
|
: kind.replace(/_/g, ' ')
|
|
return {
|
|
kind,
|
|
title,
|
|
roleName: typeof raw.role_name === 'string'
|
|
? raw.role_name
|
|
: (typeof raw.roleName === 'string' ? raw.roleName : undefined),
|
|
runtimeTaskId: typeof raw.runtime_task_id === 'string'
|
|
? raw.runtime_task_id
|
|
: (typeof raw.runtimeTaskId === 'string' ? raw.runtimeTaskId : undefined),
|
|
entries: mapBackendProgressLog(raw.entries),
|
|
}
|
|
}
|
|
|
|
function mapBackendRoleWorkItemRow(raw: any): RoleWorkItemRow {
|
|
const rawActivitySections: unknown[] = Array.isArray(raw.activity_sections)
|
|
? raw.activity_sections
|
|
: Array.isArray(raw.activitySections)
|
|
? raw.activitySections
|
|
: []
|
|
return {
|
|
workItemId: typeof raw.work_item_id === 'string' ? raw.work_item_id : (raw.workItemId ?? ''),
|
|
workItemProjectionId: typeof raw.work_item_projection_id === 'string'
|
|
? raw.work_item_projection_id
|
|
: (raw.workItemProjectionId ?? undefined),
|
|
phase: coercePhase(raw.phase),
|
|
kanbanColumn: typeof raw.kanban_column === 'string'
|
|
? raw.kanban_column
|
|
: (raw.kanbanColumn ?? 'todo'),
|
|
title: typeof raw.title === 'string' ? raw.title : '',
|
|
kind: typeof raw.kind === 'string' ? raw.kind : (raw.kind ?? undefined),
|
|
isReviewTarget: !!(raw.is_review_target ?? raw.isReviewTarget),
|
|
executorRoleId: raw.executor_role_id ?? raw.executorRoleId ?? undefined,
|
|
executorRoleName: raw.executor_role_name ?? raw.executorRoleName ?? undefined,
|
|
reviewerRoleId: raw.reviewer_role_id ?? raw.reviewerRoleId ?? undefined,
|
|
createdAt: normalizeEpochMs(raw.created_at ?? raw.createdAt),
|
|
updatedAt: normalizeEpochMs(raw.updated_at ?? raw.updatedAt),
|
|
executionTurnId: raw.execution_turn_id ?? raw.executionTurnId ?? undefined,
|
|
activitySections: rawActivitySections
|
|
.filter((section): section is Record<string, unknown> => !!section && typeof section === 'object')
|
|
.map(mapBackendRoleWorkItemActivitySection),
|
|
progressLog: mapBackendProgressLog(raw.progress_log ?? raw.progressLog),
|
|
}
|
|
}
|
|
|
|
function mapBackendRoleWorkItems(raw: any): Record<string, RoleWorkItemSummary> | undefined {
|
|
if (!raw || typeof raw !== 'object') return undefined
|
|
const out: Record<string, RoleWorkItemSummary> = {}
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
if (!value || typeof value !== 'object') continue
|
|
const summary = value as Record<string, unknown>
|
|
const workItemsRaw = Array.isArray(summary.work_items)
|
|
? summary.work_items
|
|
: Array.isArray(summary.workItems)
|
|
? summary.workItems
|
|
: []
|
|
const workItems = (workItemsRaw as any[])
|
|
.map(mapBackendRoleWorkItemRow)
|
|
// Defensive ASC sort by createdAt — backend already orders, but a
|
|
// belt-and-braces sort here means UI never sees a flapping row order
|
|
// if backend ordering ever drifts.
|
|
.sort((a, b) => a.createdAt - b.createdAt)
|
|
const roleKey = typeof summary.role_key === 'string'
|
|
? summary.role_key
|
|
: (typeof summary.roleKey === 'string' ? summary.roleKey : key)
|
|
out[key] = {
|
|
roleKey,
|
|
roleId: typeof summary.role_id === 'string'
|
|
? summary.role_id
|
|
: (typeof summary.roleId === 'string' ? summary.roleId : key),
|
|
roleName: typeof summary.role_name === 'string'
|
|
? summary.role_name
|
|
: (typeof summary.roleName === 'string' ? summary.roleName : key),
|
|
roleSessionId: typeof summary.role_session_id === 'string'
|
|
? summary.role_session_id
|
|
: (typeof summary.roleSessionId === 'string' ? summary.roleSessionId : undefined),
|
|
teamInstanceId: typeof summary.team_instance_id === 'string'
|
|
? summary.team_instance_id
|
|
: (typeof summary.teamInstanceId === 'string' ? summary.teamInstanceId : undefined),
|
|
runtimeStatus: coerceRuntimeStatus(summary.runtime_status ?? summary.runtimeStatus),
|
|
aggregatedStatus: coerceAggregatedStatus(summary.aggregated_status ?? summary.aggregatedStatus),
|
|
workItems,
|
|
}
|
|
}
|
|
return Object.keys(out).length > 0 ? out : undefined
|
|
}
|
|
|
|
export function mapBackendSession(raw: any): Session {
|
|
const agentStatus = normalizeAgentRuntimeStatus(raw.status, raw.agent_status ?? raw.agentStatus)
|
|
const taskId = raw.task_id ?? raw.taskId ?? ''
|
|
const runtimeTaskId = raw.runtime_task_id ?? raw.runtimeTaskId ?? raw.execution_turn_id ?? raw.executionTurnId ?? taskId
|
|
const executionTurnId = raw.execution_turn_id ?? raw.executionTurnId ?? runtimeTaskId
|
|
const executionMode = raw.execution_mode ?? raw.executionMode
|
|
const rawExecMode = String(raw.exec_mode ?? raw.execMode ?? '').trim().toLowerCase()
|
|
const rawProjectionId = raw.work_item_projection_id ?? raw.workItemProjectionId
|
|
const isTaskModeRuntime = (
|
|
rawExecMode === 'task'
|
|
|| rawExecMode === 'project'
|
|
|| rawExecMode === 'single'
|
|
|| executionMode === 'task_mode'
|
|
|| rawProjectionId === 'task_mode_execution'
|
|
)
|
|
const parentSessionId = isTaskModeRuntime
|
|
? undefined
|
|
: (raw.parent_session_id ?? raw.parentSessionId)
|
|
const mapped: Session = {
|
|
projectId: raw.project_id ?? raw.projectId ?? '',
|
|
taskId,
|
|
runtimeTaskId,
|
|
executionTurnId,
|
|
channelId: raw.channel_id ?? raw.channelId ?? '',
|
|
sessionId: raw.session_id ?? raw.sessionId,
|
|
parentSessionId,
|
|
mode: (isTaskModeRuntime ? 'primary' : (raw.mode ?? (parentSessionId ? 'child' : 'primary'))) as SessionMode,
|
|
title: raw.title ?? 'Untitled',
|
|
status: raw.status ?? 'pending',
|
|
columnId: raw.column_id ?? raw.columnId ?? 'todo',
|
|
assigneeIds: raw.assignee_ids ?? raw.assigneeIds ?? [],
|
|
priority: raw.priority ?? null,
|
|
tags: raw.tags ?? [],
|
|
agentStatus,
|
|
currentTool: raw.current_tool ?? raw.currentTool,
|
|
displayTool: raw.display_tool ?? raw.displayTool ?? raw.current_tool ?? raw.currentTool,
|
|
toolElapsedMs: raw.tool_elapsed_ms ?? raw.toolElapsedMs,
|
|
lastToolSummary: raw.last_tool_summary ?? raw.lastToolSummary,
|
|
contextTokens: raw.context_tokens ?? raw.contextTokens,
|
|
contextWindow: raw.context_window ?? raw.contextWindow,
|
|
contextRemainingPct: raw.context_remaining_pct ?? raw.contextRemainingPct,
|
|
inputTokens: raw.input_tokens ?? raw.inputTokens,
|
|
outputTokens: raw.output_tokens ?? raw.outputTokens,
|
|
totalTokens: raw.total_tokens ?? raw.totalTokens,
|
|
turnCostUsd: raw.turn_cost_usd ?? raw.turnCostUsd,
|
|
sessionCostUsd: raw.session_cost_usd ?? raw.sessionCostUsd,
|
|
pendingPermissionCount: raw.pending_permission_count ?? raw.pendingPermissionCount,
|
|
drainMode: raw.drain_mode ?? raw.drainMode,
|
|
progressLog: mapBackendProgressLog(raw.progress_log ?? raw.progressLog),
|
|
createdAt: typeof raw.created_at === 'number' ? raw.created_at * 1000 : (raw.createdAt ?? Date.now()),
|
|
updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at * 1000 : (raw.updatedAt ?? Date.now()),
|
|
messageCount: raw.message_count ?? raw.messageCount ?? 0,
|
|
latestPreview: raw.latest_preview ?? raw.latestPreview,
|
|
latestSender: raw.latest_sender ?? raw.latestSender,
|
|
latestMessageId: raw.latest_message_id ?? raw.latestMessageId,
|
|
indexLoaded: raw.index_loaded ?? raw.indexLoaded,
|
|
detailLoaded: raw.detail_loaded ?? raw.detailLoaded,
|
|
fullLoaded: raw.full_loaded ?? raw.fullLoaded,
|
|
hasMore: raw.has_more ?? raw.hasMore,
|
|
summaryHasMore: raw.summary_has_more ?? raw.summaryHasMore,
|
|
fullHasMore: raw.full_has_more ?? raw.fullHasMore,
|
|
detailLoading: raw.detail_loading ?? raw.detailLoading,
|
|
detailError: raw.detail_error ?? raw.detailError,
|
|
viewGeneration: raw.view_generation ?? raw.viewGeneration,
|
|
execMode: raw.exec_mode ?? raw.execMode,
|
|
companyProfile: isTaskModeRuntime ? undefined : (raw.company_profile ?? raw.companyProfile),
|
|
orgId: isTaskModeRuntime ? undefined : (raw.org_id ?? raw.organization_id ?? raw.orgId ?? raw.organizationId),
|
|
preferredAgent: raw.preferred_agent ?? raw.preferredAgent,
|
|
// Company Mode metadata
|
|
workItemProjectionId: isTaskModeRuntime ? undefined : rawProjectionId,
|
|
workItemTurnType: isTaskModeRuntime ? undefined : (raw.work_item_turn_type ?? raw.workItemTurnType),
|
|
workItemRoleId: isTaskModeRuntime ? undefined : (raw.work_item_role_id ?? raw.workItemRoleId),
|
|
workItemRoleName: isTaskModeRuntime ? undefined : (raw.work_item_role_name ?? raw.workItemRoleName),
|
|
workItemGate: isTaskModeRuntime ? undefined : _mapWorkItemGate(raw.work_item_gate ?? raw.workItemGate),
|
|
employeeAssignment: _mapEmployeeAssignment(raw.employee_assignment ?? raw.employeeAssignment),
|
|
selectedExecutionAgent: raw.selected_execution_agent ?? raw.selectedExecutionAgent,
|
|
originChannel: raw.origin_channel ?? raw.originChannel,
|
|
originTaskId: raw.origin_task_id ?? raw.originTaskId ?? raw.task_id ?? raw.taskId,
|
|
runtimeControlState: raw.runtime_control_state ?? raw.runtimeControlState,
|
|
canStop: raw.can_stop ?? raw.canStop,
|
|
canResume: raw.can_resume ?? raw.canResume,
|
|
resumeParentTaskId: raw.resume_parent_task_id ?? raw.resumeParentTaskId,
|
|
resumeParentSessionId: raw.resume_parent_session_id ?? raw.resumeParentSessionId,
|
|
pendingRuntimeCheckpointId: raw.pending_runtime_checkpoint_id ?? raw.pendingRuntimeCheckpointId,
|
|
stopIntentId: raw.stop_intent_id ?? raw.stopIntentId,
|
|
handoffContext: raw.handoff_context ?? raw.handoffContext,
|
|
handoffTo: raw.handoff_to ?? raw.handoffTo,
|
|
artifacts: raw.artifacts,
|
|
isCompanyRuntime: isTaskModeRuntime ? false : (raw.is_company_runtime ?? raw.isCompanyRuntime),
|
|
workItemLog: isTaskModeRuntime ? [] : mapBackendWorkItemLog(raw.work_item_log ?? raw.workItemLog),
|
|
roleWorkItems: isTaskModeRuntime ? undefined : mapBackendRoleWorkItems(raw.role_work_items ?? raw.roleWorkItems),
|
|
executorRoleWorkItems: isTaskModeRuntime
|
|
? undefined
|
|
: mapBackendRoleWorkItems(raw.executor_role_work_items ?? raw.executorRoleWorkItems),
|
|
draftTurnId: raw.draft_turn_id ?? raw.draftTurnId,
|
|
runtimeSessionId: raw.runtime_session_id ?? raw.runtimeSessionId,
|
|
resumeCursor: raw.resume_cursor ?? raw.resumeCursor,
|
|
activeSubagents: raw.active_subagents ?? raw.activeSubagents,
|
|
permissionRequests: raw.permission_requests ?? raw.permissionRequests,
|
|
worktreePath: raw.worktree_path ?? raw.worktreePath,
|
|
residentStatus: raw.resident_status ?? raw.residentStatus,
|
|
actionableInboxCount: raw.actionable_inbox_count ?? raw.actionableInboxCount,
|
|
protocolBacklogCount: raw.protocol_backlog_count ?? raw.protocolBacklogCount,
|
|
notificationBacklogCount: raw.notification_backlog_count ?? raw.notificationBacklogCount,
|
|
latestNotification: raw.latest_notification ?? raw.latestNotification,
|
|
}
|
|
return canonicalizeSessionExecutionIdentity(mapped)
|
|
}
|
|
|
|
export interface CollabSyncData {
|
|
channels: ChatChannel[]
|
|
messages: ChatMessage[]
|
|
boards: KanbanBoard[]
|
|
columns: KanbanColumn[]
|
|
tasks: KanbanTask[]
|
|
sessions: Session[]
|
|
}
|
|
|
|
export function mapCollabSyncPayload(payload: any): CollabSyncData {
|
|
const channels = (payload.channels ?? []).map(mapBackendChannel)
|
|
const messages = (payload.messages ?? []).map(mapBackendMessage)
|
|
const boards = (payload.boards ?? []).map(mapBackendBoard)
|
|
const columns = (payload.columns ?? []).map(mapBackendColumn)
|
|
const sessions = hydrateCompanyRuntimeSessions(
|
|
(payload.sessions ?? []).map(mapBackendSession),
|
|
messages,
|
|
)
|
|
const tasks = hydrateCompanyRuntimeTasks(
|
|
(payload.tasks ?? []).map(mapBackendTask),
|
|
sessions,
|
|
)
|
|
|
|
return {
|
|
channels,
|
|
messages,
|
|
boards,
|
|
columns,
|
|
tasks,
|
|
sessions,
|
|
}
|
|
}
|